From 5a3ae8c56a065df56f1b4c15154b3817814aa7c2 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 17:09:03 +0200 Subject: [PATCH 01/59] docs: plan concise spellings for the public API The examples repeat a handful of shapes that make the library read as verbose: exact numbers spelled as Rational { n, d }, a value dug out of a result through three accessors, a trace built by hand, printf noise, and a rounding restated at every use. The design and the plan add short spellings for each, keeping every existing one. examples/simple.cpp now prints with std::println, the spelling every example moves to. Signed-off-by: Christian Parpart --- .../plans/2026-09-30-concise-spellings.md | 1518 +++++++++++++++++ .../2026-09-30-concise-spellings-design.md | 50 + examples/simple.cpp | 10 +- 3 files changed, 1573 insertions(+), 5 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-30-concise-spellings.md create mode 100644 docs/superpowers/specs/2026-09-30-concise-spellings-design.md diff --git a/docs/superpowers/plans/2026-09-30-concise-spellings.md b/docs/superpowers/plans/2026-09-30-concise-spellings.md new file mode 100644 index 00000000..9003c800 --- /dev/null +++ b/docs/superpowers/plans/2026-09-30-concise-spellings.md @@ -0,0 +1,1518 @@ +# Concise Spellings 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:** Make formula-cpp less verbose to use by adding short spellings for what the examples repeat most, without taking any spelling away. Then rewrite every example with them and print through `std::print` / `std::println`. + +**Architecture:** Every new spelling is an **addition** that forwards to what already exists, and every existing spelling keeps compiling. The additions: + +- a `consteval` exact-decimal literal; +- wider input factories; +- `number_of()`, which returns "the number, or nothing"; +- `describe()` and `std::formatter` for the remaining enums and types; +- `traced()`, plus an `explain_*` twin for every evaluation verb; +- `DecimalRounding`, which states a rounding once; +- throwing twins for the `Measured` operations; +- `yields(expr)`, which names the result quantity once, where the formula is declared. + +The last task group rewrites all 19 examples, and the eight guides that are checked line-for-line against them. + +**Tech Stack:** C++23, header-only. Catch2 3.6 via CPM, `STATIC_REQUIRE`, the `test/negative/` harness, the ctest docs checks (`cmake/CheckGuideSnippets.cmake`), and ``. + +**Spec:** the *Design* section below. Task 0 saves it verbatim as `docs/superpowers/specs/2026-09-30-concise-spellings-design.md` and this plan as `docs/superpowers/plans/2026-09-30-concise-spellings.md`. + +**Order:** tasks run in number order. Each library task (1–9) is self-contained. Tasks 11–15 consume all of them. Line anchors were read at master `eb5eed8`. Find each place by the name quoted beside its anchor, never by the number alone. + +--- + +## Design + +### Why + +The 19 programs in `examples/` repeat a few shapes, and those shapes make the library feel verbose: + +| Shape | ≈ sites in examples | Evidence | +|---|---|---| +| `formula::Rational { 273, 10 }`, `*Rational::make(..)`, `*Rational::from_decimal(..)` | ~260 | **38 files define their own `rat()`**. Header comments call a `rat()` that does not exist (`expression.hpp:87,94`, `rejection.hpp:12,149,223-238`, `lookup.hpp`, `precision.hpp:837`, `series.hpp:193`). | +| `Measured { Rational { n } }`; `measured_series(Measured{..}, …)` repeating `Q` for every element | ~115, plus 20 series | `series.cpp` layers `m()` over `rat()`. | +| `r.has_value() && r->is_value() && r->measurement().value() == X` | ~60 | 7 local helpers (`valueOf`, `exact` ×2, `exact_text`, `fraction_string`, …) | +| A hand-built trace, `Trace<> t{}; RecordingSink<> s{t}; (void) verb(…, s);`, then evaluating again for the value | 8 helpers, 8 inline blocks | `explain*` exists only for a Node, a series, a retry and a worksheet. | +| printf noise: `%.*s`, `static_cast`, `.to_double()`, `? "yes" : "no"` | ~250 | Only `display.cpp` and `electricity_bill.cpp` use the existing `std::format` support. | +| An enum turned into text by hand | 3 switches | No `describe()` for `ConstraintOutcomeKind`, `RetryEnd`, … | +| `` restated at every use | ~25 | A standard states its rounding once. | +| The result quantity named again at every call | ~100 | Mostly inside per-file helper lambdas. | + +### Decisions (owner, 2026-09-30) + +- All four addition groups are in scope: literals and inputs; reading and printing; a trace from every verb; stating a rule once. +- **Bound formulas are in scope.** `yields(expr)` names the result once. The author still names it; nothing is deduced from the expression. +- **`std::print` / `std::println` replace printf and iostream** throughout the examples, tools, support code and docs, not only in the examples. + +### Rules nothing here may bend + +- Exactness: no implicit `double`, and a literal is exact or refused. +- Absent is not zero. +- The author names the result quantity. +- No unit is guessed for a bare number. +- No macros. +- Every fallible operation keeps its `checked_` form. +- One mistake produces one message, which begins `formula: ` or names a `formula_` guard. + +### Considered and not done + +| Idea | Why not | +|---|---| +| Deduce the result from the expression | The result is the author's choice (`evaluate.hpp:469-472`). `yields` keeps it the author's choice. | +| A defaulted quantity tag via `decltype([]{})` | CONTRIBUTING invariant 6: the type differs in each translation unit, which was verified to fail at link time. | +| A macro to declare quantities | The design requires traceability without macros (`docs/superpowers/specs/2026-09-23-formula-cpp-design.md:194-201`). | +| Implicit `double` → `Rational` | Inexact. `_r` gives the short spelling exactly. | +| Deduce `constant`'s unit, or a lookup's key unit, from the other operand | A standard states 47.3 kN against a quantity declared in N, and a table in mm may key a quantity declared in m. | +| A default `render_trace` limit or format rounding mode | Both are deliberate (`trace_render.hpp:15`, `format.hpp:460`). | +| `var = 180` binding | It would be an assignment operator on a const object that assigns nothing. `Measured { 180 }` already compiles. | +| `yields` over a curve | `checked_evaluate_curve` names two results (domain and values). Out of scope. | +| `DecimalRounding` for `rounded_ln` / `log10` / `exp` | These take no unit, and `` is already short. | +| Migrating the ~2600 `rat()` uses in `test/` | Out of scope; tests adopt `_r` as they are touched. | + +--- + +## Global Constraints + +These bind every task. + +- **C++23, header-only**, no dependency beyond the standard library in `include/`. +- **Worktree.** All work happens in `D:\formula-cpp\.claude\worktrees\concise-spellings` on `feature/concise-spellings`, branched from master `eb5eed8`. Never touch `D:\formula-cpp` itself or another worktree. Only `STATUS.md`, in the main tree, is written by the controller (see *Status board*). +- **Verification per task: the Windows compilers only, MSVC `cl` and `clang-cl`** (owner, 2026-09-30, for speed; this replaces the earlier MSVC + g++-14 rule). No WSL build runs during Tasks 1–15. The full suite (all eight presets, g++-14 and clang++ under WSL, Doxygen 1.9.8, `mkdocs build --strict`) runs once, in Task 16, and is driven to green there. Set `$S = C:\Users\c.parpart\AppData\Local\Temp\claude\D--formula-cpp\b0c0e78c-1b2d-4d4c-a792-0760adeb4a03\scratchpad` and `$T = D:\formula-cpp\.claude\worktrees\concise-spellings`. + - **Verify(``)**, the per-task gate, is two commands, and both must print `ALL OK`: + 1. `pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Exclude "^negative\."`: the full cl-debug build, then every test except the negative ones (unit, compile-time, hygiene, example, docs, census). + 2. `pwsh -NoProfile -File $S\neg.ps1 -Tree $T -Filter ""`: the negative tests matching ``, on cl-debug **and** clangcl-debug. `EXPECT_COUNT` is checked only off MSVC (`test/CMakeLists.txt:202-209`), so clang-cl is what proves one message per mistake. Each negative case builds only its own target, so no full clang-cl build runs. + + `` names the task's new negatives **and** the existing ones in the same area, which each task lists. A task with no negatives runs command 1 only. + - **Quick loop:** `pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Filter ""`. + - **g++-only findings arrive late.** A `-Wshadow` hit, a `consteval` call evaluated behind a false `&&` (`opaque.hpp:588-592`), or a libstdc++ difference surfaces only in Task 16. Guard against the known ones while writing: the consumer-globals names below, and staged checks in their own `if constexpr`. Budget one fix round in Task 16. + - Never redirect a build to `/dev/null`, because a `STATIC_REQUIRE` failure is a *build* error. Prove a `-Filter` selected something, from ctest's count, before trusting it. + - **Counts are deltas.** Task 0 records the cl-debug baseline. Each task reports its total and the difference, which must equal its stated delta ("+N cases, +M negative"). +- **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`), so formatters live in `format.hpp` and explain twins in `trace.hpp`; + - every public `static_assert` message begins `formula: ` and is stable; + - never identify a type with `decltype([]{})`; + - a new header goes into the install `FILE_SET` (`CMakeLists.txt:41-106`, `hygiene.installed-headers`) and into `test/consumer_globals_tests.cpp`'s includes (`hygiene.consumer-globals`). +- **Consumer globals.** `test/consumer_globals_tests.cpp:130-153` declares 258 `int` globals. No new parameter or local may reuse one: g++ `-Wshadow` and cl C4459 turn it into a consumer's build error. Names this plan is tempted by that are **on the list** include: `bound`, `bounds`, `expression`, `sink`, `outcome`, `outcomes`, `result`, `value`, `values`, `number`, `format`, `unit`, `kind`, `source`, `environment`, `element`, `elements`, `measurement`, `mode`, `text`, `label`, `name`, `digits`, `count`, `first`, `last`, `size`, `data`, `view`, `string`, `input`, `output`, `lower`, `upper`, `low`, `high`. Use `boundFormula`, `recordingSink`, `evaluated`, `spelling`, `shownIn`, and similar names instead. + - Every new entry point is also exercised in `consumer_globals_tests.cpp`, with its result checked in `consumer_globals_run_tests.cpp`. The probe guards only what it instantiates. +- **Defect classes.** Before each task, read `D:\formula-cpp\.superpowers\sdd\2026-09-25-methods-and-overlays\defect-classes.md`. Each task's report says what was checked for each of its nine classes. The ones this plan hits most: + - **2, one mistake → two messages:** gate every forwarding overload. + - **4:** no `{}` default member initialiser on a member holding an expression, such as `Yields::expression`. + - **5:** fixtures must tell right from wrong. + - **7:** every negative case gets a deletion check. +- **Negative tests.** A negative test is `test/negative/.cpp` plus `formula_add_negative_test( "" …)` in `test/CMakeLists.txt`; ctest names it `negative.`. + - Register it with a deliberately wrong expected text, and watch it fail. Then register the right text and watch it pass. + - **Deletion check:** delete the guard and confirm the case compiles; then restore the file **with a plain write**. + - A guard reached from a `consteval` call is matched by its **name**, with no `EXPECT_COUNT`: clang and clang-cl report a failed consteval call twice (`test/CMakeLists.txt:303-307`). +- **Documentation is checked.** Eight guides are held line-for-line to their example by `docs.-output` and `docs.-snippets` (`examples/CMakeLists.txt:45-326`): + - `dimensions`, `calculations`, `statistics`, `methods-and-overlays`, `series`, `records`, `opaque-and-retry`, `display`, plus `docs.readme-display-output`; + - every ```` ```cpp ```` block must be consecutive source lines of the example, and every ```` ```text ```` block consecutive lines of its real output; + - **so a checked guide changes only in the same task as its example** (Tasks 11–15). Library tasks document in Doxygen, in `CHANGELOG.md`, and in the *unchecked* guides named in each task; + - a refusal quoted in `docs/` must be a header's message verbatim (`hygiene.documented-diagnostic-text`). +- **No internal labels in public text.** No task, phase, plan, lane or reviewer names in code, docs, commit messages or the PR. Every sentence must make sense to a reader who never saw this plan. Never cite `STATUS.md` or its URL. +- **No third-party standard content.** Cite only `Example Standard N:YYYY` (`hygiene.no-real-standards`). Fixture values are plainly invented, and a size-like value is not a Renard R40 number (100, 106, 112, … 450, 475, 500, …). Primes such as 103, 127, 139, 163, 197 work. +- **Do not run clang-format** on existing files; match the surrounding style by hand. A Doxygen `///` comment goes on every new public entity and member. +- **Printing:** new or touched code prints with `std::print` / `std::println`. No new `printf`, `puts` or iostream anywhere. The one exception is `support/fail_without_dialogs.cpp`, whose CRT-failure handler must neither allocate nor throw. +- **CHANGELOG.md:** entries go under `## [Unreleased]` (`CHANGELOG.md:7`), in `### Added` / `### Changed` subsections that Task 1 creates, in the task that changes public behaviour. +- **Commits:** a conventional subject, a body that says why, and the last line exactly `Signed-off-by: Christian Parpart `. One commit per task. Every commit builds and passes on its own. Never `--no-verify`, never amend another task's commit. +- **Status board:** the controller only (see *Execution*). Implementers report to the controller and never edit `STATUS.md`. + +## Review Focus + +These are the five inputs most likely to bite a user that no task's happy-path tests reach. Each line's test is added to the task that owns the code. + +1. **A `_r` spelling that is valid C++ but means something else**: `017_r` (octal to a C++ reader), `0x1F_r`, `1e19_r`, 19 fractional digits, `.5_r`, `5._r`, `1'000_r`. Each must be exact or refused at compile time by a named guard, never silently wrong. *Task 1.* +2. **A wrong `measured_series` element**: `Measured`, `double`, a wide unsigned, `bool`. Each draws one library message, never an overload list. *Task 2.* +3. **ADL ambiguity with a consumer's same-named helper**: `describe(ConstraintOutcomeKind)` already exists locally in `constraints.cpp:78` and `methods_and_overlays.cpp:219`. Removing both is part of Task 4, and a CHANGELOG *Changed* entry records the rule, as 0.2.0 did (`CHANGELOG.md:285-290`). *Task 4.* +4. **A `Yields` misused**: evaluated for another quantity, holding a series evaluated as a single value, or wrapped around `documented()`. The first two draw one library message each; the third works. An explicit same-quantity result is allowed. *Task 9.* +5. **Printed output that changes by accident**: `%f` of `to_double()` printed `0.600000`, where `{}` of a `Rational` prints `0.6`, and a `Measured` adds its unit symbol. Every intended change updates the README or guide block in the same commit; everything else stays byte-identical. The gallery (`tools/gallery`) must match `docs/gallery.md` byte for byte. *Tasks 10–15.* + +## File map + +| File | Responsibility | Tasks | +|---|---|---| +| `include/formula-cpp/rational.hpp` | `formula::literals::operator""_r` and its guards | 1 | +| `include/formula-cpp/environment.hpp` | `not_measured`; the wider `measured_series` | 2 | +| `include/formula-cpp/band.hpp`, `lookup.hpp` | `band(Rational, Rational)`, `breakpoint(Rational)` | 2 | +| `include/formula-cpp/outcome.hpp` | `number_of`; `describe(ValueSource / OutcomeKind)` | 3, 4 | +| `include/formula-cpp/retry.hpp`, `rejection.hpp` | `number_of` for their outcomes; `describe(RetryEnd)` | 3, 4 | +| `include/formula-cpp/constraint.hpp`, `series.hpp` | `describe(ConstraintOutcomeKind / FailureSite)` | 4 | +| `include/formula-cpp/format.hpp` | formatters for `Outcome`, `Unit`, `Dimension` and the described enums | 5 | +| `include/formula-cpp/vocabulary.hpp`, `render.hpp` | `symbol_of()`; `render(x, RenderOptions)` | 5 | +| `include/formula-cpp/trace.hpp` | `Traced`, `traced`, the `explain_*` twins | 6, 9 | +| `include/formula-cpp/rounding.hpp` and the rounding factories | `DecimalRounding`, `SignificantRounding`, `declared_rounding`, overloads | 7 | +| `include/formula-cpp/measured.hpp` | `convert_to`, `round_to_declared`, `within_bounds`; dimension check at compile time | 8 | +| `include/formula-cpp/yields.hpp` (new), `evaluate.hpp` users | `Yields`, `yields`, overloads that deduce the result | 9 | +| `tools/gallery/main.cpp`, `support/census_report.cpp`, `test/overflow_census_tests.cpp`, `test/package/main.cpp` | `std::print` | 10 | +| `examples/*.cpp`, `docs/*.md`, `README.md` | the rewrite | 11–15 | + +--- + +## Execution + +- **The controller** (this session) owns the worktree, dispatch, review gates and the status board. +- **Status board** (`/contour-workflows:status-board`). `STATUS.md` at `D:\formula-cpp` already exists and is excluded (`.git/info/exclude:9`). + - In Task 0, archive its finished plan into `.superpowers/status-archive-2026-09-30.md` (`.superpowers/` is git-ignored, `.gitignore:17`). + - Replace the plan section with this one: goal, decisions, and a `| Phase | Tasks done | State | Where it is |` table with one row per group (Setup 0; Literals and inputs 1–2; Reading and printing 3–5; Traces 6; Rules stated once 7–8; Bound formulas 9; Printing 10; Examples 11–15; Finish 16). + - Then render and publish. There is no remembered URL (`git config --local status-board.url` is unset), so the first publish creates the artifact. Store its URL and give the owner the link once. + - **Update and republish at every state change, in the same turn**: dispatched, reported, reviewed, fixed, landed, blocked. Take the stamp from `date`. +- **Speed (owner, 2026-09-30: as fast as possible).** + - Per-task gates run on Windows compilers only (*Global Constraints*). + - A task's review may run while the next task's implementer starts, when the next task touches none of the reviewed task's files. `CHANGELOG.md`, `test/CMakeLists.txt` and the consumer-globals files are shared, so the implementer appends to them after the review's fixes land. A review fix is then its own commit on top, never an amend. + - Build trees persist between tasks (never delete `out/build/*`), so each gate builds incrementally. +- **Mode (owner, 2026-09-30): subagent-driven** (`superpowers:subagent-driven-development`). One fresh implementer per task, then a fresh reviewer, then the next task, and one whole-branch review in Task 16. The tasks share interfaces (Tasks 11–15 consume 1–9), and a shipped mistake in a public spelling is expensive to take back. + +--- + +### Task 0: Setup, baseline, board, and the `` probe + +**Files:** +- Create: `docs/superpowers/specs/2026-09-30-concise-spellings-design.md` (the *Design* section, verbatim) +- Create: `docs/superpowers/plans/2026-09-30-concise-spellings.md` (this file, verbatim) +- Modify: `examples/simple.cpp` (probe only) + +**Interfaces:** Produces the worktree, the scripts in `$S`, both baselines, and the board URL. + +- [ ] **Step 1: Create the worktree.** Use `superpowers:using-git-worktrees`: `git -C D:\formula-cpp worktree add .claude/worktrees/concise-spellings -b feature/concise-spellings eb5eed8`. +- [ ] **Step 2: Copy the scripts.** Copy `cl.ps1`, `gcc14.sh`, `verify.ps1`, `windows-matrix.ps1`, `posix-matrix.sh` and `docs-pages.sh` from `C:\Users\c.parpart\AppData\Local\Temp\claude\D--formula-cpp\6031eb20-da56-4aa2-bb6a-de8c11d53bfc\scratchpad\` into `$S`. + - In each, replace the old session id `6031eb20-da56-4aa2-bb6a-de8c11d53bfc` with `b0c0e78c-1b2d-4d4c-a792-0760adeb4a03`. + - Replace the default tree `next-features` with `concise-spellings`. + - `grep -n "6031eb20\|next-features" $S/*` must print nothing. + + Then write `$S\neg.ps1`, which runs the negative tests matching a filter on both Windows compilers without a full build: + +```powershell +# Run the negative tests matching -Filter on cl-debug and clangcl-debug. Each negative case builds only its own +# target, so neither preset needs a full build; a preset is configured on first use. +# Usage: pwsh -NoProfile -File neg.ps1 [-Tree ] -Filter +param( + [string]$Tree = "D:\formula-cpp\.claude\worktrees\concise-spellings", + [Parameter(Mandatory = $true)][string]$Filter +) +$ErrorActionPreference = "Stop" +$vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -property installationPath +& "$vs\Common7\Tools\Launch-VsDevShell.ps1" -Arch amd64 -HostArch amd64 -SkipAutomaticLocation | Out-Null +Set-Location $Tree +foreach ($preset in @("cl-debug", "clangcl-debug")) { + if (-not (Test-Path "out\build\$preset\build.ninja")) { + cmake --preset $preset + if ($LASTEXITCODE -ne 0) { Write-Host "CONFIGURE FAILED ($preset)"; exit 10 } + } + ctest --preset $preset -j 12 -R "negative\.($Filter)" + if ($LASTEXITCODE -ne 0) { Write-Host "TESTS FAILED ($preset)"; exit 12 } +} +Write-Host "ALL OK (negatives: $Filter)" +exit 0 +``` + + Prove it can fail. Run `neg.ps1 -Filter "rational_from_floating_point"` with that case's expected text temporarily wrong in the build tree's generated `negative/.expect.cmake`. Expected: `TESTS FAILED`. Restore the text; expected: `ALL OK`. +- [ ] **Step 3: Baseline.** Run `pwsh -NoProfile -File $S\cl.ps1 -Tree $T` (the full cl-debug suite, negatives included) and `neg.ps1 -Filter ".*"` once for clang-cl's negatives. Expected: `ALL OK` from both. Record the cl-debug total. +- [ ] **Step 4: Board.** Archive, rewrite and publish `STATUS.md` as *Execution* says. Give the owner the link. +- [ ] **Step 5: Probe `` on every CI leg.** Rewrite `examples/simple.cpp`'s output line to use `std::println`, and change nothing else: + +```cpp +#include +// … + std::println("{} = {} ({})", + formula::Describe::symbol, + result.measurement().value().to_double(), + result.is_value() ? "computed" : "no value"); +``` + + Run `cl.ps1 -Tree $T -Filter "example\.simple"`. Expected: PASS. The draft PR's CI answers for the other compilers. +- [ ] **Step 6: Commit** the spec, the plan and the probe as one commit, `docs: plan concise spellings for the public API`, whose body says `simple.cpp` now prints with `std::println`. +- [ ] **Step 7: Push and open a draft PR** (the owner's standing rule is to open the PR early as a draft): `git push -u origin feature/concise-spellings`, then `gh pr create --draft` with a title and body that describe the goal, not the plan. + - Watch the **macOS-15 AppleClang** leg. + - **If it fails on ``, stop and report to the owner** with the log. The options are a newer Xcode on the runner or Homebrew LLVM; this plan does not pick one. + +### Task 1: The exact decimal literal `_r` + +**Files:** +- Modify: `include/formula-cpp/rational.hpp`: guards beside `Pi` (`:516`), literal after it +- Create: `test/rational_literal_tests.cpp` (registered in `test/CMakeLists.txt:22-106`) +- Create: `test/negative/rational_literal_{out_of_range,too_many_places,not_a_decimal,octal}.cpp` +- Modify: `docs/numbers.md` (a section, *Writing an exact decimal*), `CHANGELOG.md`, `test/consumer_globals_tests.cpp`, `test/consumer_globals_run_tests.cpp` + +**Interfaces:** +- Produces: `formula::literals::operator""_r(char const*) -> Rational`, which is `consteval`, in `inline namespace literals`, so both `using namespace formula::literals;` and `formula::operator""_r` work. Also the guards `detail::formula_rational_literal_out_of_range()` and `detail::formula_rational_literal_not_a_decimal()`. + +- [ ] **Step 1: Write the failing tests** in `test/rational_literal_tests.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include + +#include + +using namespace formula::literals; +using formula::Rational; + +TEST_CASE("_r: an integer spelling is that integer", "[rational][literal]") +{ + STATIC_REQUIRE(457_r == Rational { 457 }); + STATIC_REQUIRE(0_r == Rational {}); + STATIC_REQUIRE(1'000'003_r == Rational { 1'000'003 }); + STATIC_REQUIRE(9'223'372'036'854'775'807_r == Rational { formula::detail::IntMax }); +} + +TEST_CASE("_r: a decimal spelling is its exact value, not the nearest double", "[rational][literal]") +{ + STATIC_REQUIRE(27.3_r == Rational { 273, 10 }); + STATIC_REQUIRE(0.47_r == Rational { 47, 100 }); // the double 0.47 is not 47/100 + STATIC_REQUIRE(0.0213_r == Rational { 213, 10'000 }); + STATIC_REQUIRE(.5_r == Rational { 1, 2 }); + STATIC_REQUIRE(5._r == Rational { 5 }); +} + +TEST_CASE("_r: trailing fractional zeros cost nothing", "[rational][literal]") +{ + STATIC_REQUIRE(4.210_r == Rational { 421, 100 }); + STATIC_REQUIRE(1.500000000000000000000000_r == Rational { 3, 2 }); // 24 places, most of them zeros +} + +TEST_CASE("_r: an exponent scales exactly", "[rational][literal]") +{ + STATIC_REQUIRE(1.5e-3_r == Rational { 3, 2'000 }); + STATIC_REQUIRE(7e2_r == Rational { 700 }); + STATIC_REQUIRE(1.3E+2_r == Rational { 130 }); + STATIC_REQUIRE(1'3.7e1_r == Rational { 137 }); +} + +TEST_CASE("_r: a minus sign is Rational's own negation", "[rational][literal]") +{ + STATIC_REQUIRE(-27.3_r == Rational { -273, 10 }); +} +``` + +- [ ] **Step 2: Run the tests; they fail.** `$S\cl.ps1 -Filter "literal"`. Expected: BUILD FAILED, `operator""_r` not found. +- [ ] **Step 3: Implement** in `rational.hpp`. Add `#include ` if it is missing. + +```cpp +namespace detail +{ + /// A `_r` literal whose exact value `Rational` cannot hold: too many + /// significant digits, or a denominator above `Int`'s range. Deliberately + /// not `constexpr`, like `formula_exponent_out_of_range` + /// (`dimension.hpp`): the literal operator is `consteval`, so reaching + /// this fails to compile and the diagnostic names it. + [[noreturn]] inline void formula_rational_literal_out_of_range() + { + std::abort(); + } + + /// A `_r` literal spelled as something other than a decimal: a + /// hexadecimal or binary integer, or an integer with a leading zero, + /// which C++ reads as octal. Same mechanism as above. + [[noreturn]] inline void formula_rational_literal_not_a_decimal() + { + std::abort(); + } + + /// The exact value of a decimal literal's spelling: digits, an optional + /// fraction, an optional exponent, and digit separators. + consteval Rational rational_from_spelling(char const* spelling) + { + std::size_t at = 0; + bool const hasPoint = [&] { + for (std::size_t probe = 0; spelling[probe] != '\0'; ++probe) + if (spelling[probe] == '.' || spelling[probe] == 'e' || spelling[probe] == 'E') + return true; + return false; + }(); + if (spelling[0] == '0' && spelling[1] != '\0' && !hasPoint) + formula_rational_literal_not_a_decimal(); // 017, 0x1F, 0b101 + Int mantissa = 0; + int scale = 0; // decimal exponent the digits carry + int pendingZeros = 0; // fractional zeros not yet multiplied in + bool inFraction = false; + for (; spelling[at] != '\0' && spelling[at] != 'e' && spelling[at] != 'E'; ++at) + { + char const symbolAt = spelling[at]; + if (symbolAt == '\'') + continue; + if (symbolAt == '.') + { + inFraction = true; + continue; + } + if (symbolAt < '0' || symbolAt > '9') + formula_rational_literal_not_a_decimal(); + int const digitValue = symbolAt - '0'; + if (inFraction && digitValue == 0) + { + ++pendingZeros; + continue; + } + for (int zero = 0; zero <= pendingZeros; ++zero) // the zeros, then this digit's place + { + if (mantissa > (IntMax - (zero == pendingZeros ? digitValue : 0)) / 10) + formula_rational_literal_out_of_range(); + mantissa = mantissa * 10 + (zero == pendingZeros ? digitValue : 0); + } + if (inFraction) + scale -= pendingZeros + 1; + pendingZeros = 0; + } + if (!inFraction) + scale += pendingZeros; // unreachable for an integer spelling; kept for symmetry + if (spelling[at] == 'e' || spelling[at] == 'E') + { + ++at; + bool const negative = spelling[at] == '-'; + if (spelling[at] == '-' || spelling[at] == '+') + ++at; + int written = 0; + for (; spelling[at] != '\0'; ++at) + { + if (spelling[at] == '\'') + continue; + written = written * 10 + (spelling[at] - '0'); + if (written > 1'000) + formula_rational_literal_out_of_range(); + } + scale += negative ? -written : written; + } + std::expected const made = Rational::from_decimal(mantissa, scale); + if (!made) + formula_rational_literal_out_of_range(); + return *made; + } +} // namespace detail + +inline namespace literals +{ + /// An exact decimal: `27.3_r` is 273/10, never the `double` nearest it. + /// An exponent scales exactly (`1.5e-3_r` is 3/2000); `-27.3_r` is + /// `Rational`'s own negation. A spelling `Rational` cannot hold, or one + /// that is not a decimal (`0x1F_r`, and `017_r`, which C++ reads as + /// octal), fails to compile, naming `formula_rational_literal_out_of_range` + /// or `formula_rational_literal_not_a_decimal`. + consteval Rational operator""_r(char const* spelling) + { + return detail::rational_from_spelling(spelling); + } +} // namespace literals +``` + + The fixture `1'3.7e1_r` checks that separators are skipped in the mantissa. The trailing-zeros case checks that `pendingZeros` defers fraction zeros, so 24 places that are mostly zeros still fit. +- [ ] **Step 4: The negative cases.** + - Each file includes ``, declares `using namespace formula::literals;`, and holds one `constexpr formula::Rational refused = ;` and `int main() {}`: + - `rational_literal_out_of_range`: `9'223'372'036'854'775'808_r` + - `rational_literal_too_many_places`: `0.0000000000000000001_r` + - `rational_literal_not_a_decimal`: `0x1F_r` + - `rational_literal_octal`: `017_r` + - Register them after the exponent sentinels (`test/CMakeLists.txt:287-289`), with a comment naming the NAME-not-message mechanism and no `EXPECT_COUNT`: + +```cmake +formula_add_negative_test(rational_literal_out_of_range "formula_rational_literal_out_of_range") +formula_add_negative_test(rational_literal_too_many_places "formula_rational_literal_out_of_range") +formula_add_negative_test(rational_literal_not_a_decimal "formula_rational_literal_not_a_decimal") +formula_add_negative_test(rational_literal_octal "formula_rational_literal_not_a_decimal") +``` + + Follow the wrong-text-first protocol, then do the deletion check on each guard call. +- [ ] **Step 5: Probe, docs and CHANGELOG.** + - Add `27.3_r` to the consumer-globals probe, and check its value in the run test. + - Add a *Writing an exact decimal* section to `docs/numbers.md`. It says why a literal and not `double`, lists the refusals, and says `Rational { 1, 3 }` or `1_r / 3` remains the spelling for a fraction. + - In `CHANGELOG.md`, create `### Added` under `## [Unreleased]` with the literal's entry. +- [ ] **Step 6: Verify(`rational_literal_.*|rational_from_.*|exponent_.*`).** Expected: `ALL OK`, +5 cases, and +4 negative tests passing on cl-debug and clangcl-debug. +- [ ] **Step 7: Commit** `feat(rational): add the exact decimal literal _r`. + +### Task 2: Shorter inputs + +**Files:** +- Modify: `include/formula-cpp/environment.hpp:167-176` (`measured_series`), `band.hpp:103-111`, `lookup.hpp:1318-1327` +- Modify: every header doc comment that calls the nonexistent `rat(`: `grep -n "rat(" include/` (13 lines), rewritten with `_r` or `Rational { n, d }` +- Test: `test/environment_tests.cpp`, `band_tests.cpp`, `lookup_tests.cpp`, `measured_tests.cpp` +- Create: `test/negative/measured_series_element_{other_quantity,double,wide_unsigned}.cpp` +- Modify: `docs/quantities.md` (*Supplying values*), `CHANGELOG.md`, the consumer-globals probe and run test + +**Interfaces:** +- Consumes: `_r` (Task 1). +- Produces: + - `formula::NotMeasured` and `inline constexpr NotMeasured not_measured {}`; + - `measured_series(elements...)`, where each element is a `Measured`, anything convertible to `Rational`, or `not_measured`; + - `band(Rational low, Rational high) -> Band`; + - `breakpoint(Rational key) -> Breakpoint`. + +- [ ] **Step 1: Write the failing tests.** + +```cpp +// environment_tests.cpp +TEST_CASE("measured_series: plain numbers and not_measured stand for elements", "[environment][series]") +{ + using namespace formula::literals; + constexpr auto mixed = formula::measured_series(127, 10.3_r, formula::not_measured, formula::Rational { 1, 3 }); + STATIC_REQUIRE(mixed.size() == 4); + STATIC_REQUIRE(mixed[0] == formula::Measured { 127 }); + STATIC_REQUIRE(mixed[1] == formula::Measured { formula::Rational { 103, 10 } }); + STATIC_REQUIRE(mixed[2].is_absent()); + STATIC_REQUIRE(mixed[3] == formula::Measured { formula::Rational { 1, 3 } }); + // The old spelling is unchanged. + constexpr auto spelled = formula::measured_series(formula::Measured { 127 }, formula::Measured::absent()); + STATIC_REQUIRE(spelled[0] == mixed[0]); + STATIC_REQUIRE(spelled[1].is_absent()); +} + +// measured_tests.cpp -- pins a spelling the examples adopt +TEST_CASE("Measured: an integer is a present value without spelling Rational", "[measured]") +{ + STATIC_REQUIRE(formula::Measured { 139 }.value() == formula::Rational { 139 }); +} + +// band_tests.cpp +TEST_CASE("band: bounds given as exact numbers", "[band]") +{ + using namespace formula::literals; + STATIC_REQUIRE(formula::band(83.7_r, 97.3_r) == formula::band(837, 10, 973, 10)); + STATIC_REQUIRE(formula::band(0, 127) == formula::band(0, 1, 127, 1)); +} + +// lookup_tests.cpp +TEST_CASE("breakpoint: a key given as an exact number", "[lookup]") +{ + using namespace formula::literals; + STATIC_REQUIRE(formula::breakpoint(12.7_r) == formula::breakpoint(127, 10)); + STATIC_REQUIRE(formula::breakpoint(127) == formula::Breakpoint { 127, 1 }); // the integer overload still wins +} +``` + + Use each file's existing fixture quantity (`Retained`, `SpecimenMass`); if a file has none, declare one with a prime-valued fixture. +- [ ] **Step 2: Run the tests; they fail.** `cl.ps1 -Filter "measured_series|band|breakpoint|Measured"`. Expected: BUILD FAILED. +- [ ] **Step 3: Implement.** + +```cpp +// environment.hpp, replacing measured_series (:167-176) +/// An element of `measured_series` that was not measured: +/// `measured_series(127, formula::not_measured, 139)`. +struct NotMeasured +{ + /// Every `NotMeasured` is the same. + [[nodiscard]] constexpr bool operator==(NotMeasured const&) const noexcept = default; +}; + +/// The spelling of an absent element -- see `NotMeasured`. +inline constexpr NotMeasured not_measured {}; + +namespace detail +{ + /// Fails to compile when an element of `measured_series` is a value of + /// another quantity. A `double` is refused by `Rational` itself, in its + /// own words, and never reaches this. + template + struct RequireSeriesElementOf + { + static constexpr bool isMeasuredOfAnother = !std::is_same_v> && requires { typename Given::quantity_tag_probe; }; + static_assert(std::is_same_v> || std::is_same_v || std::is_constructible_v, + "formula: an element of measured_series is a Measured of that one quantity, an exact number, or " + "formula::not_measured; the quantity and the element's type appear in this diagnostic as the template " + "arguments of RequireSeriesElementOf"); + static constexpr bool value = true; + }; + + /// @p given as the series element it stands for. + template + [[nodiscard]] constexpr Measured series_element(Given given) noexcept + { + if constexpr (std::is_same_v>) + return given; + else if constexpr (std::is_same_v) + return Measured::absent(); + else + return Measured { Rational { given } }; + } +} // namespace detail + +/// Builds a series from its elements, in order, and counts them. Each element +/// is a `Measured`, an exact number (`127`, `10.3_r`, a `Rational`), or +/// `not_measured`: `measured_series(127, 10.3_r, formula::not_measured)`. +/// `Q` is stated rather than deduced, so a `Measured` of another quantity is refused. +template +[[nodiscard]] constexpr auto measured_series(Given... given) noexcept +{ + static_assert((detail::RequireSeriesElementOf::value && ...)); + return MeasuredSeries { std::array, sizeof...(Given)> { detail::series_element(given)... } }; +} +``` + + - Drop the `isMeasuredOfAnother` probe line if unused. The only point of the check is one message per wrong element. + - A `double` element must draw only `Rational`'s message ("formula: a floating-point value is not an exact rational"). `std::is_constructible_v` is true, because the refusing constructor exists, so `RequireSeriesElementOf` stays silent and `Rational { given }` fires. + - A wide unsigned element likewise draws only `Rational`'s own unsigned message. + +```cpp +// band.hpp, after band(n, d, n, d) +/// Builds a `Band` from its low (inclusive) and high (exclusive) bound as +/// exact numbers: `band(83.7_r, 97.3_r)`, `band(0, 127)`. +[[nodiscard]] constexpr Band band(Rational lowBound, Rational highBound) noexcept +{ + return { lowBound.numerator(), lowBound.denominator(), highBound.numerator(), highBound.denominator() }; +} + +// lookup.hpp, after breakpoint(n, d) +/// Builds a `Breakpoint` from its key as an exact number: `breakpoint(12.7_r)`. +/// An integer still takes the overload above, so `breakpoint(127)` is unchanged. +[[nodiscard]] constexpr Breakpoint breakpoint(Rational keyValue) noexcept +{ + return { keyValue.numerator(), keyValue.denominator() }; +} +``` + +- [ ] **Step 4: Negative cases.** + +```cmake +formula_add_negative_test(measured_series_element_other_quantity + "formula: an element of measured_series is a Measured of that one quantity" EXPECT_COUNT 1 + REJECT "no matching" "cannot convert" "could not convert") +formula_add_negative_test(measured_series_element_double + "formula: a floating-point value is not an exact rational" EXPECT_COUNT 1 + REJECT "an element of measured_series") +formula_add_negative_test(measured_series_element_wide_unsigned + "formula: this unsigned type can hold values above Rational's maximum" EXPECT_COUNT 1 + REJECT "an element of measured_series") +``` + + The existing `measured_series_short` and `measured_series_padded_*` negatives must still pass unchanged. +- [ ] **Step 5: Header comments, docs, probe, CHANGELOG.** + - Replace each `rat(` in `include/` comments. + - `docs/quantities.md` gains *Supplying values*: `Measured { 139 }`, `Measured { 10.3_r }`, and `measured_series` with plain numbers and `not_measured`. + - Add the probe calls and the *Added* entries. +- [ ] **Step 6: Verify(`measured_series_.*|entered_series_.*|environment_series_.*|rational_from_.*`).** Expected: `ALL OK`, +4 cases, +3 negatives, and every existing negative in that set still passing. +- [ ] **Step 7: Commit** `feat(environment): accept plain numbers and not_measured in measured_series`. + +### Task 3: `number_of`, the number or nothing + +**Files:** +- Modify: `include/formula-cpp/outcome.hpp` (after `Outcome`, `:195`), `retry.hpp` (after `RetryOutcome`), `rejection.hpp` (after `RejectionOutcome`) +- Test: `test/outcome_tests.cpp`, `retry_tests.cpp`, `rejection_tests.cpp`, `method_tests.cpp` (an `Evaluated`) +- Modify: `docs/expressions.md` (*Reading a result*), `CHANGELOG.md`, the probe + +**Interfaces:** +- Produces `formula::number_of(x) -> std::optional`, with overloads for: + - `Measured const&` + - `Outcome const&` (engaged only when `is_value()`) + - `std::optional const&` + - `std::expected const&` for any `T` with a `number_of` + - `RetryOutcome const&` and `RejectionOutcome const&`, through their `outcome()` + + Because `optional == Rational` is false when the optional is empty, `number_of(x) == 0.5_r` is a complete check. + +- [ ] **Step 1: Write the failing tests.** + +```cpp +// outcome_tests.cpp +TEST_CASE("number_of: the number of a value, nothing for every other kind", "[outcome]") +{ + using Outcome = formula::Outcome; + STATIC_REQUIRE(formula::number_of(Outcome::value(formula::Measured { 139 }, formula::ValueSource::Derived)) == formula::Rational { 139 }); + STATIC_REQUIRE(formula::number_of(Outcome::value(formula::Measured { 139 }, formula::ValueSource::ManuallyEntered)) == formula::Rational { 139 }); + STATIC_REQUIRE(!formula::number_of(Outcome::empty()).has_value()); + STATIC_REQUIRE(!formula::number_of(Outcome::verdict({ "repeat the test" })).has_value()); + STATIC_REQUIRE(!formula::number_of(Outcome::invalid({ "discarded" })).has_value()); + STATIC_REQUIRE(formula::number_of(formula::Measured { 139 }) == formula::Rational { 139 }); + STATIC_REQUIRE(!formula::number_of(formula::Measured::absent()).has_value()); +} + +TEST_CASE("number_of: an error is nothing, a success is its number", "[outcome]") +{ + using Checked = std::expected, formula::ArithmeticError>; + constexpr Checked succeeded = formula::Outcome::value(formula::Measured { 163 }, formula::ValueSource::Derived); + constexpr Checked failed = std::unexpected { formula::ArithmeticError::Overflow }; + STATIC_REQUIRE(formula::number_of(succeeded) == formula::Rational { 163 }); + STATIC_REQUIRE(!formula::number_of(failed).has_value()); + // Evaluated is an expected of an optional. + constexpr formula::Evaluated evaluated = std::optional { formula::Rational { 197 } }; + STATIC_REQUIRE(formula::number_of(evaluated) == formula::Rational { 197 }); +} +``` + + Add one test each in `retry_tests.cpp` and `rejection_tests.cpp`, using an existing fixture's accepted outcome, `formula::number_of(*accepted) == `. **Defect class 5:** choose a fixture whose value differs from the empty and verdict cases. +- [ ] **Step 2: Run the tests; they fail.** Expected: BUILD FAILED, `number_of` undeclared. +- [ ] **Step 3: Implement.** + +```cpp +// outcome.hpp +/// The number @p measured holds, or nothing when it is absent. +template +[[nodiscard]] constexpr std::optional number_of(Measured const& measured) noexcept +{ + return measured.stored(); +} + +/// The number @p produced holds when it is a value -- derived, measured or +/// entered -- and nothing for an empty, a verdict or an invalid outcome, +/// which `kind()` tells apart. +template +[[nodiscard]] constexpr std::optional number_of(Outcome const& produced) noexcept +{ + if (!produced.is_value()) + return std::nullopt; + Measured const held = produced.measurement(); + return held.stored(); +} + +/// @p held itself: what `Evaluated` holds on success. +[[nodiscard]] constexpr std::optional number_of(std::optional const& held) noexcept +{ + return held; +} + +/// The number a successful @p checked holds, or nothing on failure -- so +/// `formula::number_of(checked_evaluate(...)) == 0.5_r` is a complete check. +template + requires requires(T const& succeeded) { number_of(succeeded); } +[[nodiscard]] constexpr std::optional number_of(std::expected const& checked) noexcept +{ + if (!checked.has_value()) + return std::nullopt; + return number_of(*checked); +} + +// retry.hpp / rejection.hpp: one each, through outcome() +/// The number the outcome a retry ended with holds -- see `number_of(Outcome)`. +template +[[nodiscard]] constexpr std::optional number_of(RetryOutcome const& ended) noexcept +{ + return number_of(ended.outcome()); +} +``` + + Add `#include ` where it is missing. +- [ ] **Step 4: Docs, probe, CHANGELOG.** `docs/expressions.md` gets *Reading a result*: `number_of`, and why it is an `optional` and not a zero. Add the probe entries and the *Added* entry. +- [ ] **Step 5: Verify** (command 1 only; no negatives). Expected: `ALL OK`, +4 cases. +- [ ] **Step 6: Commit** `feat(outcome): add number_of, the number a result holds or nothing`. + +### Task 4: `describe()` for the remaining enums + +**Files:** +- Modify: `outcome.hpp` (`ValueSource`, `OutcomeKind`), `constraint.hpp:48` (`ConstraintOutcomeKind`), `retry.hpp:89` (`RetryEnd`), `series.hpp:738` (`FailureSite`) +- Modify: `examples/constraints.cpp:78-92` and `examples/methods_and_overlays.cpp:219-233` (delete the local `describe`, which would now be ambiguous by ADL) +- Test: `outcome_tests.cpp`, `constraint_tests.cpp`, `retry_tests.cpp`, `series_tests.cpp` +- Modify: `CHANGELOG.md` (*Added*, and *Changed* for the ADL rule) + +**Interfaces:** Produces `describe(E) -> std::string_view` for those five enums. Each returns a lowercase phrase with no trailing punctuation, as `describe(ArithmeticError)` does (`error.hpp:45-64`). The words: + +| Enum | Words | +|---|---| +| `ConstraintOutcomeKind` | `satisfied`, `violated`, `not checked`, `invalid`. These are exactly the examples' words, so their output is unchanged. | +| `ValueSource` | `derived`, `measured`, `manually entered` | +| `OutcomeKind` | `value`, `empty`, `verdict`, `invalid` | +| `RetryEnd` | `accepted`, `exhausted`, `not judgeable`, `not recorded`, `failed`, `manually entered` | +| `FailureSite` | one lowercase phrase per enumerator, read from its Doxygen comment | + +- [ ] **Step 1: Write the failing tests:** one `STATIC_REQUIRE(formula::describe(E::X) == "…")` per enumerator of each enum. +- [ ] **Step 2: Run them; they fail.** +- [ ] **Step 3: Implement** each `describe` as a `switch` over every enumerator, beside its enum, in `describe(ArithmeticError)`'s shape, ending in a fallback like `return "unknown constraint outcome";`. +- [ ] **Step 4: Remove the two example helpers.** + - Delete `describe(formula::ConstraintOutcomeKind)` from `constraints.cpp` and `methods_and_overlays.cpp`. Their calls now resolve to the library's. + - Run `grep -rn "describe(formula::\(ValueSource\|OutcomeKind\|RetryEnd\|FailureSite\|ConstraintOutcomeKind\)" test examples tools docs`. Every hit is a now-ambiguous helper: delete it, or call `::describe`. + - `ctest -R "example\.(constraints|methods_and_overlays)|docs\.methods"` must pass with **unchanged output**. +- [ ] **Step 5: CHANGELOG.** Add an *Added* entry. Add a *Changed* entry modelled on `CHANGELOG.md:285-290`: an unqualified call of `describe` with one of these enums now finds the library's by ADL, and a consumer's own `describe` for the same enum has to be renamed or called as `::describe`. +- [ ] **Step 6: Verify** (command 1 only). Expected: `ALL OK`, +5 cases. +- [ ] **Step 7: Commit** `feat: describe constraint outcomes, retry ends, value sources, outcome kinds and failure sites`. + +### Task 5: Formatters, `symbol_of()`, and `render` without a placeholder vocabulary + +**Files:** +- Modify: `include/formula-cpp/format.hpp`: + - factor `formatter>`'s parse and write (`:595-640`) into `detail::parse_measured_format_field` and `detail::format_measured`; + - add the new specialisations after it. +- Modify: `vocabulary.hpp:426-430` (`symbol_of`), `render.hpp` (beside `:2770`) +- Test: `test/format_tests.cpp`, `vocabulary_tests.cpp`, `render_tests.cpp` +- Modify: `CHANGELOG.md` and the probe. `docs/display.md` is checked, so its section lands with `display.cpp` in Task 12. + +**Interfaces:** +- Consumes the `describe` overloads from Task 4. +- Produces: + - `std::formatter>`, with `Measured`'s grammar. A value is written as the `Measured`. An empty outcome is `(not measured)`. A verdict or invalid outcome is its label, padded by the spec's fill, alignment and width; places and mode do not apply to it. + - `std::formatter`: its symbol. + - `std::formatter`: exponents joined by spaces (`L^2 M^-3`, `L^(1/2)`), named bases by name, and `(dimensionless)` for a pure number. This is exactly `dimensions_and_units.cpp:24-58`'s spelling, without the leading space. + - `std::formatter` for each enum in `detail::formats_by_describe`: `ArithmeticError`, `RoundingMode`, `BoundsCheck`, `SnapTie`, `Monotone`, `CumulativeDirection`, `ValueSource`, `OutcomeKind`, `ConstraintOutcomeKind`, `RetryEnd`, `FailureSite`. It writes `describe(e)` with `formatter`'s spec. + - `symbol_of(vocabulary = DefaultVocabulary {})`. + - `render(x, RenderOptions)` and `render(x, RenderOptions)`. + +- [ ] **Step 1: Write the failing tests.** + +```cpp +// format_tests.cpp (Kilojoule-quantity fixture as in the existing Measured tests) +TEST_CASE("format: an outcome writes its value, or says why it has none", "[format]") +{ + using O = formula::Outcome; + CHECK(std::format("{}", O::value(formula::Measured { formula::Rational { 26, 5 } }, formula::ValueSource::Derived)) == "5.2 kJ"); + CHECK(std::format("{:.3HalfEven}", O::value(formula::Measured { formula::Rational { 26, 5 } }, formula::ValueSource::Derived)) == "5.200 kJ"); + CHECK(std::format("{}", O::empty()) == "(not measured)"); + CHECK(std::format("{:>18}", O::verdict({ "repeat the test" })) == " repeat the test"); + CHECK(std::format("{:.2HalfEven}", O::invalid({ "discarded" })) == "discarded"); +} + +TEST_CASE("format: a unit is its symbol, a dimension its exponents", "[format]") +{ + CHECK(std::format("{}", formula::unit::Kilojoule) == "kJ"); + CHECK(std::format("{}", formula::dim::Mass / formula::dim::Volume) == "L^-3 M^1"); + CHECK(std::format("{}", formula::nth_root(formula::dim::Length, 2)) == "L^(1/2)"); + CHECK(std::format("{}", formula::dim::Scalar) == "(dimensionless)"); +} + +TEST_CASE("format: a described enumeration is its words, aligned like a string", "[format]") +{ + CHECK(std::format("{}", formula::ArithmeticError::Overflow) == "overflow in exact arithmetic"); + CHECK(std::format("[{:<12}]", formula::ConstraintOutcomeKind::Violated) == "[violated ]"); + CHECK(std::format("{}", formula::ValueSource::ManuallyEntered) == "manually entered"); +} + +// vocabulary_tests.cpp +STATIC_REQUIRE(formula::symbol_of() == formula::symbol_of(formula::DefaultVocabulary {})); + +// render_tests.cpp +CHECK(formula::render(ratio, formula::RenderOptions { .numbers = formula::NumberStyle::exact_decimal() }) + == formula::render(ratio, formula::DefaultVocabulary {}, { .numbers = formula::NumberStyle::exact_decimal() })); +``` + + Before asserting the Dimension output, read it from `ctest -R example.dimensions_and_units -V`. The expected strings above follow the example's order (L, M, T, I, Theta, N, J, then named bases), and the test must use the example's real output. +- [ ] **Step 2: Run them; they fail.** +- [ ] **Step 3: Implement.** + +```cpp +namespace formula::detail +{ +/// The formula enumerations `std::format` writes through their `describe()`: +/// one row per enumeration, so a new one is a new row. +template +inline constexpr bool formats_by_describe = false; +template <> +inline constexpr bool formats_by_describe = true; +// … one line each for RoundingMode, BoundsCheck, SnapTie, Monotone, CumulativeDirection, +// ValueSource, OutcomeKind, ConstraintOutcomeKind, RetryEnd, FailureSite + +/// A dimension's exponents, as `std::format` writes them -- see `formatter`. +[[nodiscard]] inline std::string dimension_text(Dimension const& shown); +} // namespace formula::detail + +namespace std +{ +/// `std::format` of a `formula::Outcome`: a value as `Measured` writes +/// it, in the same grammar; an empty outcome as `(not measured)`; a verdict or +/// an invalid outcome as its label, padded by the spec's fill, alignment and +/// width -- a rounding in the spec does not apply to words. +template +struct formatter, char> +{ + constexpr auto parse(std::format_parse_context& parseContext) + { + return formula::detail::parse_measured_format_field(parseContext, _spec); + } + + template + auto format(formula::Outcome const& shown, FormatContext& formatContext) const + { + if (shown.is_verdict()) + return formula::detail::write_formatted_number(shown.verdict_label(), std::string_view {}, _spec, formatContext.out()); + if (shown.is_invalid()) + return formula::detail::write_formatted_number(shown.reason_label(), std::string_view {}, _spec, formatContext.out()); + return formula::detail::format_measured(shown.measurement(), _spec, formatContext.out()); + } + + private: + formula::detail::NumberFormatSpec _spec {}; +}; + +/// `std::format` of a `formula::Unit`: its symbol, as a string is written. +template <> +struct formatter: formatter +{ + template + auto format(formula::Unit const& shownIn, FormatContext& formatContext) const + { + return formatter::format(formula::view(shownIn.symbolText), formatContext); + } +}; + +/// `std::format` of a `formula::Dimension`: `L^2 M^-3`, `L^(1/2)`, named +/// bases by name, `(dimensionless)` for a pure number. +template <> +struct formatter: formatter +{ + template + auto format(formula::Dimension const& shown, FormatContext& formatContext) const + { + std::string const spelled = formula::detail::dimension_text(shown); + return formatter::format(spelled, formatContext); + } +}; + +/// `std::format` of a formula enumeration: its `describe()` words, aligned +/// and padded as a string is. +template + requires formula::detail::formats_by_describe +struct formatter: formatter +{ + template + auto format(E shown, FormatContext& formatContext) const + { + return formatter::format(describe(shown), formatContext); + } +}; +} // namespace std +``` + + - `formatter>` keeps its behaviour exactly; only its body moves into the two `detail` helpers. All existing `format_tests.cpp` cases must pass unchanged. + - `symbol_of`: `template constexpr std::string_view symbol_of(V const& vocabulary = V {})`. + - `render`: two overloads beside `render.hpp:2770`, each forwarding to `render(node, DefaultVocabulary {}, renderOptions)`. They are not ambiguous: `Vocabulary` is a closed concept that `RenderOptions` does not satisfy, and a braced list cannot deduce `V`. If `document` has a `(x, V, RenderOptions)` form, give it the same twin. +- [ ] **Step 4: Probe, CHANGELOG.** Add a probe line formatting an `Outcome`, a `Unit`, a `Dimension` and an enum, and add the *Added* entries. +- [ ] **Step 5: Verify(`.*format.*`).** Expected: `ALL OK`, +5 cases, and no existing format test changed. The regex covers the existing format-spec negatives, since the parse was refactored. +- [ ] **Step 6: Commit** `feat(format): format outcomes, units, dimensions and enumerations; default symbol_of's vocabulary`. + +### Task 6: A trace from every evaluation verb + +**Files:** +- Modify: `include/formula-cpp/trace.hpp`: `Traced` and `traced` before `Explained` (`:4327`); twins after `explain_retry` (`:4507`); re-express `explain_series` (`:4410-4419`) and `explain_retry` (`:4489-4507`) through `traced` +- Test: `test/trace_tests.cpp` (`traced`), `method_tests.cpp`, `curve_tests.cpp`, `rejection_tests.cpp`, `constraint_tests.cpp`, `conformity_tests.cpp` (one twin each, beside that verb's fixtures) +- Modify: `docs/tracing.md` (*Tracing any evaluation*), `CHANGELOG.md`, the probe + +**Interfaces:** +- Produces: + - `template struct Traced { R outcome; Trace trace {}; };` + - `traced(evaluation, vocabulary = DefaultVocabulary {}) -> Traced`, where `evaluation` is callable with a `RecordingSink`; + - twins, each exactly the verb's result plus its trace: + - `explain_method(m, env, V = {}) -> Traced>` + - `explain_check_method(m, env, V = {})` + - `explain_curve(curve, env, V = {})` + - `explain_rejection(rejection, env, V = {})` + - `explain_check(constraint, env, V = {})` + - `explain_check_all(set, env, V = {})` + - `explain_conformity(conformity, env, V = {})` + +- [ ] **Step 1: Write the failing tests.** For each twin, in the verb's own test file, write the old hand-built form and the twin, and assert both parts agree: + +```cpp +TEST_CASE("explain_method: the method's value and the trace a RecordingSink records", "[method][trace]") +{ + formula::Trace<> handBuilt {}; + formula::Evaluated const direct = + formula::evaluate_method(compressiveStrength, specimen, formula::RecordingSink<> { handBuilt }); + auto const explained = formula::explain_method(compressiveStrength, specimen); + CHECK(explained.outcome == direct); + CHECK(formula::render_trace(explained.trace, { .maxSteps = 100 }) == formula::render_trace(handBuilt, { .maxSteps = 100 })); + CHECK(!explained.trace.empty()); +} +``` + + In `trace_tests.cpp`, test `traced` with a lambda over `checked_evaluate`, and assert the outcome equals `checked_evaluate`'s. Add one case where the evaluation fails (division by zero): the failure is in `outcome`, and the trace holds the steps up to it. +- [ ] **Step 2: Run them; they fail.** +- [ ] **Step 3: Implement.** + +```cpp +/// What an evaluation returned, together with how it was reached. +template +struct Traced +{ + /// Exactly what the evaluation returned, failure included. + R outcome; + /// Every step the evaluation recorded -- empty when nothing was derived, + /// as for an entered value; see `explain`. + Trace trace {}; +}; + +/// Runs @p evaluation with a `RecordingSink` writing every symbol as +/// @p vocabulary says, and returns what it returned with the trace it +/// recorded -- so any verb that takes a sink can be traced in one call: +/// `traced([&](auto recordingSink) { return check_method(m, env, recordingSink); })`. +template + requires std::invocable> +[[nodiscard]] auto traced(F&& evaluation, V const& vocabulary = V {}) + -> Traced>>> +{ + Trace recorded {}; + auto evaluated = std::invoke(evaluation, RecordingSink { recorded, vocabulary }); + return { std::move(evaluated), std::move(recorded) }; +} + +/// Evaluates @p m for @p Tag and records how -- `evaluate_method`'s traced twin. +template +[[nodiscard]] auto explain_method(M const& m, Env const& environmentGiven, V const& vocabulary = V {}) +{ + return traced([&](auto recordingSink) { return evaluate_method(m, environmentGiven, recordingSink); }, vocabulary); +} +// … explain_check_method, explain_curve, explain_rejection, +// explain_check, explain_check_all and explain_conformity in the same three lines, each +// documented as "'s traced twin". +``` + + - Parameter names must avoid the consumer globals: `environmentGiven`, not `environment`; `recordingSink`, not `sink`. + - Make `explain_series` and `explain_retry` call `traced` internally and move the result into their existing return types. Their tests must pass unchanged. +- [ ] **Step 4: Docs, probe, CHANGELOG.** + - `docs/tracing.md`, *Tracing any evaluation*: `traced` and the twins, and the `{ outcome, trace }` shape they share with `explain_series` and `explain_retry`. + - The probe instantiates `traced` and two twins. +- [ ] **Step 5: Verify** (command 1 only). Expected: `ALL OK`, +9 cases, and no existing trace test changed. +- [ ] **Step 6: Commit** `feat(trace): trace any evaluation with traced, and give every verb an explain twin`. + +### Task 7: State a rounding once: `DecimalRounding` + +**Files:** +- Modify: `include/formula-cpp/rounding.hpp` (the two types and `declared_rounding`) +- Modify, one forwarding overload beside each existing factory: + - `rounding_node.hpp:101-112` (`rounded`, `rounded_to_digits`) + - `method.hpp:1135-1140` (`rounding_rule`) + - `overlay.hpp:726-740` (both `with_rounding`) + - `opaque.hpp:1051` (`rounded_output`) + - `rounded_root.hpp:324` (`rounded_sqrt`) + - `series.hpp:520` (`rounded_elementwise`, single-places form) + - `precision.hpp` / `statistics.hpp`, wherever `grep -n "Unit U, DecimalPlaces Places, RoundingMode Mode" include/` shows another **public factory**. Class templates and `detail` stay as they are. +- Test: `rounding_node_tests.cpp`, `method_tests.cpp`, `overlay_tests.cpp`, `rounded_output_tests.cpp`, `rounded_root_tests.cpp`, `series_tests.cpp` +- Create: `test/negative/decimal_rounding_unit_dimension_mismatch.cpp` +- Modify: `docs/rounding-and-conditionals.md` (*Naming a rounding once*), `CHANGELOG.md`, the probe + +**Interfaces:** +- Produces: + - `struct DecimalRounding { Unit unit; DecimalPlaces places; RoundingMode mode; }` and `struct SignificantRounding { Unit unit; SignificantDigits digits; RoundingMode mode; }`, both structural and usable as template arguments; + - `constexpr DecimalRounding declared_rounding(Unit, RoundingMode)`, whose places are `unit.decimals`; + - overloads `rounded(x)`, `rounded_to_digits(x)`, `rounding_rule()`, `with_rounding(citation)`, `with_rounding()` (refused as today), `rounded_output<"name", R>(call)`, `rounded_sqrt(x)` and `rounded_elementwise(s)`. +- **Not named `Rounding`:** `overlay.hpp:3177` has a template parameter of that name. + +- [ ] **Step 1: Write the failing tests.** Each asserts that the node or rule built with the value has **exactly the same type** as the one built with the triple: + +```cpp +TEST_CASE("DecimalRounding: the same node as the three arguments it names", "[rounding_node]") +{ + constexpr formula::DecimalRounding tenthMillimetre { formula::unit::Millimetre, formula::DecimalPlaces { 1 }, formula::RoundingMode::HalfAwayFromZero }; + using ByValue = decltype(formula::rounded(formula::var)); + using ByTriple = decltype(formula::rounded(formula::var)); + STATIC_REQUIRE(std::is_same_v); +} + +TEST_CASE("declared_rounding: the places a unit declares", "[rounding]") +{ + constexpr formula::DecimalRounding cents = formula::declared_rounding(formula::unit::Kilogram, formula::RoundingMode::HalfEven); + STATIC_REQUIRE(cents.places == formula::DecimalPlaces { formula::unit::Kilogram.decimals }); +} +``` + + Write one `std::is_same_v` case per overload, in that factory's test file. +- [ ] **Step 2: Run them; they fail.** +- [ ] **Step 3: Implement.** + +```cpp +// rounding.hpp +/// A rounding to decimal places, named once and used wherever a method rounds +/// the same way: which unit the places are of, how many, and which way to go. +/// `constexpr DecimalRounding tenthMpa { unit::Megapascal, DecimalPlaces { 1 }, RoundingMode::HalfAwayFromZero };` +/// then `rounded(x)`, `rounding_rule()`. Every factory +/// that takes the three arguments separately also takes this. +struct DecimalRounding +{ + /// The unit the places are counted in. + Unit unit; + /// How many decimal places of `unit` to keep. + DecimalPlaces places; + /// Which way to break ties, and which way to go. + RoundingMode mode; +}; + +/// A rounding to significant digits, named once -- `DecimalRounding`'s +/// counterpart for `rounded_to_digits`. +struct SignificantRounding +{ + /// The unit the digits are counted in. + Unit unit; + /// How many significant digits to keep. + SignificantDigits digits; + /// Which way to break ties, and which way to go. + RoundingMode mode; +}; + +/// A rounding to the decimal places @p roundedIn declares, under @p roundingMode. +[[nodiscard]] constexpr DecimalRounding declared_rounding(Unit roundedIn, RoundingMode roundingMode) noexcept +{ + return DecimalRounding { roundedIn, DecimalPlaces { roundedIn.decimals }, roundingMode }; +} + +// rounding_node.hpp, beside rounded +/// `operand` rounded as @p R names: `rounded(var)`. +template +[[nodiscard]] constexpr auto rounded(Operand operand) noexcept +{ + return rounded(operand); +} +``` + + - `rounding.hpp` must see `Unit`. If it does not include `unit.hpp`, move the two types into `unit.hpp`, or into a header both include, rather than create an include cycle. + - Overload resolution needs nothing extra. A `DecimalRounding` cannot initialise the old overload's `Unit U` parameter, so that overload drops out by substitution failure. Three explicit arguments cannot fit the new overload's `` head. + - A unit of the wrong dimension still draws the rounding node's own message, once. +- [ ] **Step 4: Negative case.** `decimal_rounding_unit_dimension_mismatch.cpp` applies a `DecimalRounding` in `unit::Gram` to `var`: + +```cmake +formula_add_negative_test(decimal_rounding_unit_dimension_mismatch + "formula: this rounding node names a unit that does not measure the dimension of the expression it rounds" EXPECT_COUNT 1) +``` + +- [ ] **Step 5: Docs, probe, CHANGELOG.** `docs/rounding-and-conditionals.md` gets *Naming a rounding once*, with `declared_rounding`. +- [ ] **Step 6: Verify(`decimal_rounding_.*|rounded_.*|rounding_.*|method_.*rounding.*|overlay_.*rounding.*|series_round_.*`).** Expected: `ALL OK`, +8 cases, +1 negative, and the existing rounding negatives unchanged. +- [ ] **Step 7: Commit** `feat(rounding): name a rounding once with DecimalRounding`. + +### Task 8: Throwing twins for `Measured`, and a conversion refused at compile time + +**Files:** +- Modify: `include/formula-cpp/measured.hpp:173-216` +- Test: `test/measured_tests.cpp`. Find any test pinning `checked_convert_to`'s runtime `DomainError` with `grep -n "checked_convert_to" test/`; each such case becomes the negative below. +- Create: `test/negative/measured_convert_dimension_mismatch.cpp` +- Modify: `docs/quantities.md`, `CHANGELOG.md` (*Added*, and *Changed* for the compile-time refusal), the probe + +**Interfaces:** +- Produces: + - `convert_to(Measured) -> Measured` + - `round_to_declared(Measured, RoundingMode) -> Measured` + - `within_bounds(Measured) -> BoundsCheck` + + Each throws `ArithmeticException`, via `detail::or_throw` of its `checked_` form (`error.hpp:7-10`). `checked_convert_to(Measured)` now refuses mismatched dimensions at compile time. + +- [ ] **Step 1: Write the failing tests.** + +```cpp +TEST_CASE("convert_to: the throwing twin of checked_convert_to", "[measured]") +{ + STATIC_REQUIRE(formula::convert_to(formula::Measured { 457 }) == *formula::checked_convert_to(formula::Measured { 457 })); + STATIC_REQUIRE(formula::convert_to(formula::Measured::absent()).is_absent()); +} + +TEST_CASE("round_to_declared and within_bounds: throwing twins", "[measured]") +{ + // A value that rounds differently under HalfEven and HalfAwayFromZero (defect class 5). + CHECK(formula::round_to_declared(formula::Measured { formula::Rational { 2'125, 1'000 } }, formula::RoundingMode::HalfEven) + == *formula::checked_round_to_declared(formula::Measured { formula::Rational { 2'125, 1'000 } }, formula::RoundingMode::HalfEven)); + CHECK(formula::within_bounds(formula::Measured::absent()) == formula::BoundsCheck::NotMeasured); +} +``` + +- [ ] **Step 2: Run them; they fail.** +- [ ] **Step 3: Implement.** + +```cpp +namespace detail +{ + /// Fails to compile when a measurement is converted into a quantity of + /// another dimension: no such conversion exists, and both dimensions are + /// known here. + template + struct RequireConvertibleQuantities + { + static_assert(Describe::dimension == Describe::dimension, + "formula: these two quantities measure different dimensions, so no conversion between them exists; " + "the two quantities appear in this diagnostic as the template arguments of RequireConvertibleQuantities"); + static constexpr bool value = true; + }; +} // namespace detail + +template +[[nodiscard]] constexpr std::expected, ArithmeticError> checked_convert_to(Measured value) noexcept +{ + static_assert(detail::RequireConvertibleQuantities::value); + if (value.is_absent()) + return Measured {}; + // … the conversion unchanged, without the runtime dimension check +} + +/// Throwing spelling of `checked_convert_to`, for callers who would only rethrow. +template +[[nodiscard]] constexpr Measured convert_to(Measured measured) +{ + return detail::or_throw(checked_convert_to(measured)); +} +// round_to_declared and within_bounds likewise. +``` + + - Update `checked_convert_to`'s comment: the dimensions are now checked where the call is written, whether or not a value is present. + - The parameter names in these signatures stay as the surrounding code has them. `value` is on the consumer-globals list, but this file already uses it and cl never reports a function template's parameter. Do not rename existing ones. +- [ ] **Step 4: Negative case.** + +```cmake +formula_add_negative_test(measured_convert_dimension_mismatch + "formula: these two quantities measure different dimensions, so no conversion between them exists" EXPECT_COUNT 1) +``` + +- [ ] **Step 5: Docs, probe, CHANGELOG.** In `docs/quantities.md`, describe the twins and move the conversion's refusal to compile time. *Changed*: a `checked_convert_to` between dimensions that differ used to compile and return `DomainError`; now it does not compile. +- [ ] **Step 6: Verify(`measured_.*|unit_dimension_mismatch|dimension_mismatch`).** Expected: `ALL OK`, +2 cases, +1 negative, and the runtime `DomainError` cases removed (report how many). +- [ ] **Step 7: Commit** `feat(measured): add throwing twins and refuse a conversion across dimensions where it is written`. + +### Task 9: Bound formulas: `yields(expr)` + +**Files:** +- Create: `include/formula-cpp/yields.hpp`, which includes `evaluate.hpp` and holds `Yields`, `yields`, and the `evaluate` / `checked_evaluate` overloads +- Modify, one forwarding overload each: + - `series.hpp` (`checked_evaluate_series`) + - `rejection.hpp:1430` (`checked_evaluate_rejection`) + - `trace.hpp` (`explain`, `checked_explain`, `explain_series`, `explain_rejection`) + - `render.hpp` and `document.hpp` (forward to the inner expression) + - `calculation.hpp:442-446` (`define`) +- Modify: `include/formula-cpp/formula.hpp` (include `yields.hpp`), `CMakeLists.txt:41-106` (`FILE_SET`), `test/consumer_globals_tests.cpp` (include and probe) +- Create: `test/yields_tests.cpp` (registered in `test/CMakeLists.txt`) and `test/negative/yields_{result_dimension_mismatch,relabelled,series_as_single}.cpp` +- Modify: `docs/expressions.md` (*Naming the result once*), `CHANGELOG.md` + +**Interfaces:** +- Consumes: `explain_rejection` (Task 6). +- Produces: + - `template struct Yields { using quantity = Q; static constexpr bool valid; E expression; };` + - `yields(E) -> Yields` + - For every verb in *Files*, an overload `verb(Yields const&, …)`. It returns what `verb(boundFormula.expression, …)` returns. An explicit `Result` equal to `Q` is accepted; any other `Result` is refused. + +- [ ] **Step 1: Write the failing tests** in `test/yields_tests.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include +#include +#include + +#include + +namespace +{ +namespace unit = formula::unit; +using formula::var; +using WaterVolume = formula::Quantity; +using CementVolume = formula::Quantity; +using WaterCementRatio = formula::Quantity; + +constexpr auto ratio = formula::yields(var / var); +constexpr auto batch = formula::environment(formula::Measured { 163 }, formula::Measured { 307 }); +} // namespace + +TEST_CASE("yields: the result quantity is named once, where the formula is written", "[yields]") +{ + STATIC_REQUIRE(std::is_same_v, formula::ArithmeticError>>); + STATIC_REQUIRE(formula::checked_evaluate(ratio, batch) == formula::checked_evaluate(ratio.expression, batch)); + STATIC_REQUIRE(formula::checked_evaluate(ratio, batch) == formula::checked_evaluate(ratio, batch)); + STATIC_REQUIRE(formula::number_of(formula::evaluate(ratio, batch)) == formula::Rational { 163, 307 }); +} + +TEST_CASE("yields: explain, render and document see the formula itself", "[yields]") +{ + auto const explained = formula::explain(ratio, batch); + CHECK(explained.outcome == formula::explain(ratio.expression, batch).outcome); + CHECK(formula::render(ratio) == formula::render(ratio.expression)); + CHECK(formula::document(ratio).formula == formula::document(ratio.expression).formula); +} + +TEST_CASE("yields: around documented(), and as a calculation's definition", "[yields]") +{ + constexpr auto cited = formula::yields(formula::documented(var / var, { .title = "Water/cement ratio", .reference = "Example Standard 1:2020" })); + STATIC_REQUIRE(formula::number_of(formula::checked_evaluate(cited, batch)) == formula::Rational { 163, 307 }); + constexpr auto definition = formula::define(ratio); + STATIC_REQUIRE(std::is_same_v); +} +``` + + Add one case each for a series (`checked_evaluate_series(yields(series expression), env)`) and for a rejection, using fixtures copied from `series_tests.cpp` and `rejection_tests.cpp`. +- [ ] **Step 2: Run them; they fail.** +- [ ] **Step 3: Implement** `yields.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +/// @file +/// A formula bound to the quantity it computes, named once where the formula +/// is written: `constexpr auto ratio = yields(var / var);` +/// then `evaluate(ratio, environment)`. The author still names the result -- +/// nothing is deduced from the expression, whose dimension does not name a +/// quantity (`evaluate.hpp`) -- but only once. A `Yields` is not a node: it is +/// the top of a formula. Nest `documented()` inside it, not around it, and +/// reuse the formula inside another through `.expression`. + +#include +#include + +#include +#include + +namespace formula +{ + +namespace detail +{ + /// The result a verb is asked for when a `Yields` supplies it. + struct ResultOfYields + { + }; + + /// Whether @p E computes @p Q's dimension; true for an expression that + /// publishes none, which its verb checks instead. + template + [[nodiscard]] consteval bool yields_measures() noexcept + { + if constexpr (requires { E::dimension; }) + return refused_already() || E::dimension == Describe::dimension; + else + return true; + } + + /// Fails to compile when a `Yields` is evaluated for another quantity than + /// the one it names. + template + struct RequireYieldsResult + { + static_assert(std::is_same_v || std::is_same_v, + "formula: this formula names its result quantity with yields; evaluate it for that quantity, or " + "name none -- the two quantities appear in this diagnostic as the template arguments of " + "RequireYieldsResult"); + static constexpr bool value = std::is_same_v || std::is_same_v; + }; +} // namespace detail + +/// A formula and the quantity it computes -- built by `yields(expression)`. +template +struct Yields +{ + static_assert(!requires { E::dimension; } || RequireResultDimension::value); + + /// The quantity this formula computes. + using quantity = Q; + + /// Whether the check above holds, asked without firing it, so that a verb + /// given a refused `Yields` adds no second message. + static constexpr bool valid = detail::yields_measures(); + + /// The formula. Deliberately no `{}` default member initialiser -- see + /// `Corrections` (`lookup.hpp`). + E expression; +}; + +/// @p formulaExpression, bound to the quantity @p Q it computes. +template +[[nodiscard]] constexpr Yields yields(E formulaExpression) noexcept +{ + return Yields { formulaExpression }; +} + +/// `checked_evaluate(boundFormula.expression, ...)`, `Q` taken from the `Yields`. +template +[[nodiscard]] constexpr std::expected, ArithmeticError> checked_evaluate(Yields const& boundFormula, + Env const& environmentGiven, + Sink recordingSink = {}) noexcept +{ + if constexpr (!detail::RequireYieldsResult::value || !Yields::valid) + return Outcome::empty(); // refused already, where the mistake is + else + return checked_evaluate(boundFormula.expression, environmentGiven, recordingSink); +} + +/// Throwing spelling of the overload above. +template +[[nodiscard]] constexpr Outcome evaluate(Yields const& boundFormula, Env const& environmentGiven, Sink recordingSink = {}) +{ + return detail::or_throw(checked_evaluate(boundFormula, environmentGiven, recordingSink)); +} + +} // namespace formula +``` + + - `RequireResultDimension` lives in `evaluate.hpp`'s `detail`. Use its real name and namespace. + - The other overloads (`checked_evaluate_series`, `checked_evaluate_rejection`, `explain`, `checked_explain`, `explain_series`, `explain_rejection`, `render`, `render`, `document`, `define`) follow the same shape in their own headers, and each includes `yields.hpp`. The rendering overloads forward `boundFormula.expression` with no result check: rendering names no result. + - `define` is `template define(Yields const&) -> Definition`. +- [ ] **Step 4: Negative cases.** + +```cmake +formula_add_negative_test(yields_result_dimension_mismatch + "formula: this result quantity does not measure the dimension this expression computes" EXPECT_COUNT 1) +formula_add_negative_test(yields_relabelled + "formula: this formula names its result quantity with yields" EXPECT_COUNT 1 + REJECT "no matching") +formula_add_negative_test(yields_series_as_single + "this expression is a series, not a single value; evaluate it with checked_evaluate_series" EXPECT_COUNT 1 + REJECT "formula: this formula names its result quantity with yields") +``` + + Deletion-check each case. For the first one, also evaluate the refused `Yields` in the negative file. `EXPECT_COUNT 1` then proves the `valid` gate stops the verb's second message (defect class 2). +- [ ] **Step 5: Install and hygiene.** Add `yields.hpp` to `FILE_SET`, to `formula.hpp`, and to the consumer-globals includes, and probe `evaluate(ratio, batch)`. +- [ ] **Step 6: Docs, CHANGELOG.** `docs/expressions.md` gets *Naming the result once*. It covers why this is not deduction, the nesting rule with `documented()`, and `.expression` for reuse. +- [ ] **Step 7: Verify(`yields_.*|evaluate_.*|define_.*|calculation_.*|rejection_result_dimension`).** Expected: `ALL OK`, +5 cases, +3 negatives, and `hygiene.installed-headers` and `hygiene.consumer-globals` passing. +- [ ] **Step 8: Commit** `feat: bind a formula to its result quantity with yields`. + +### Task 10: `std::print` outside the examples + +**Files:** +- Modify: `tools/gallery/main.cpp` (26 printf calls), `support/census_report.cpp`, `test/overflow_census_tests.cpp`, `test/package/main.cpp` +- Modify: `support/fail_without_dialogs.cpp:59`, comment only: keep `std::fputs`, and say why. It runs in a CRT invalid-parameter handler that is `noexcept` and must neither allocate nor throw, and `std::print` may do both. + +**Interfaces:** None. Output must be byte-identical. + +- [ ] **Step 1: Capture a baseline.** Run the gallery and census programs and keep their outputs in `$S\baseline\`. +- [ ] **Step 2: Convert.** + - `std::printf("%s\n", x.c_str())` becomes `std::println("{}", x)`. + - `%.*s` of a view becomes `{}`. + - A `%d` / `%zu` becomes `{}`. + - Keep the text literally the same. + - Do not use `std::print(stdout, …)` where plain `std::print(…)` does. +- [ ] **Step 3: Verify byte-identity.** The same programs' outputs are `fc /b`-identical to the baseline. Then run Verify (command 1 only): `ALL OK`, with the gallery check and the census page check passing. Also check the `Package` workflow's compiler has `` (`.github/workflows/package.yml`). If it does not, keep `test/package/main.cpp` as it is and say so in the report. The package build itself is proved in Task 16's CI run. +- [ ] **Step 4: Commit** `refactor: print with std::print in tools, support code and tests`. + +### Tasks 11–15: Rewrite the examples + +**The same rules apply to every example task. A reviewer rejects a task that breaks any of them:** + +1. **Library spellings**: + - `_r` for every decimal; + - `Measured { n }`; + - `measured_series(values…)`; + - `band(a_r, b_r)` and `breakpoint(x_r)`; + - `number_of(x) == v` for value checks; + - `yields` for a formula evaluated more than once; + - `traced` or an `explain_*` twin instead of any `Trace<> + RecordingSink` block; + - `DecimalRounding` for a rounding used more than once; + - throwing twins where the example unwrapped a `checked_` call it never handled; + - `auto const` where the type was spelled twice; + - `.contains()` instead of `.find() != npos`. +2. **Delete every local helper the library now covers**: `rat`, `m`, `valueOf`, `exact`, `exact_text`, `fraction_string`, `strengthOf` / `diameterOf`-style builders that only wrap `environment(Measured{…})`, trace helpers, `endName`, `outcome_word` and `print_dimension`. Keep a helper that states something about the example's domain. +3. **Print with `std::println`**, formatting library values directly (`{}` of a `Rational`, `Measured`, `Outcome`, `Unit`, `Dimension` or enum). No `%.*s`, `.data()`, `.c_str()` or `static_cast`, and no `.to_double()` just to print. +4. **Output.** Keep every line a doc, the README or a ctest regex quotes **byte-identical**. Change a line only on purpose, and update its quote in the same commit. Run the example before and after, and report the diff of its output, which must be empty or intended line by line. +5. **Keep every self-check** and the final `all checks passed: yes`, which the ctest `PASS_REGULAR_EXPRESSION` pins. Evaluate once, and check what was printed. +6. **Docs in step.** Every ```` ```cpp ```` block quoted from a rewritten file is updated to the new lines, and the unchecked guides' "verbatim" claims are made true. Checked guides must pass `docs.-output` and `docs.-snippets`. +7. **Measure.** Report the file's line count and `grep -c` of `Rational {`, `Measured<`, `measurement().value()` and `RecordingSink`, before and after. + +Each example task: +- runs Verify (command 1 only): `ALL OK`, no delta in the unit tests, and the examples and docs tests passing; +- commits as `docs(examples): `; +- names no task. + +### Task 11: The small examples and the README + +**Files:** `examples/simple.cpp`, `expressions.cpp`, `quantities.cpp`, `exact_numbers.cpp`, `citations.cpp`, `tracing.cpp`, `composition.cpp`; `README.md`; `docs/expressions.md`, `quantities.md`, `numbers.md`, `citations.md`, `tracing.md` + +- [ ] **Step 1:** Rewrite by the rules. Representative target for `expressions.cpp` §5: + +```cpp +constexpr auto waterCementRatio = formula::yields(var / var); +// … + auto const batch = formula::environment(formula::Measured { 180 }, formula::Measured { 300 }, + formula::entered(formula::Measured { 0.5_r })); + auto const ratio = formula::checked_evaluate(waterCementRatio, batch); + std::println("{} = {} ({})", formula::symbol_of(), *ratio, ratio->source()); + // … + bool const overrideWinsOutright = ratio && ratio->is_overridden() && formula::number_of(ratio) == 0.5_r; +``` + + - `exact_numbers.cpp` gains the self-check it lacks, plus a final `all checks passed: yes`. Keep its pinned line `ten tenths == one: yes`. +- [ ] **Step 2: Update the README.** + - Update every README snippet, and its quoted output, to the real new code and output. + - Fix the README's `evaluate` / `checked_evaluate` drift against `citations.cpp`. + - `docs.readme-display-output` belongs to Task 12; do not touch those blocks here. +- [ ] **Step 3:** Verify, then commit. + +### Task 12: Dimensions and display + +**Files:** `examples/dimensions_and_units.cpp`, `display.cpp`; `docs/dimensions.md`, `display.md`; README display blocks + +- [ ] **Step 1:** `dimensions_and_units.cpp`: delete `print_exponent` and `print_dimension`, and print with `std::println("{} = {}", label, dimension)`. Each printed line stays as `docs/dimensions.md` quotes it. +- [ ] **Step 2:** `display.cpp`: + - Delete `rat`. + - Use `measured_series(4.21_r, …)`, `render(x, { .numbers = … })` without `DefaultVocabulary {}`, and `traced` or `checked_explain` instead of the four `Trace<>` blocks. + - Add a short section that formats an `Outcome`, a `Unit`, a `Dimension` and an enum. Its lines become `docs/display.md`'s new section *Formatting outcomes, units, dimensions and enumerations*, with a ```` ```text ```` block of its real output. +- [ ] **Step 3:** Verify, including `docs.dimensions-*`, `docs.display-*` and `docs.readme-display-output`, then commit. + +### Task 13: Constraints, rounding and lookup tables + +**Files:** `examples/constraints.cpp`, `rounding_and_conditionals.cpp`, `lookup_tables.cpp`; `docs/constraints.md`, `rounding-and-conditionals.md`, `lookup-tables.md` + +- [ ] **Step 1:** Rewrite by the rules. Representative target for `rounding_and_conditionals.cpp`: + +```cpp +constexpr formula::DecimalRounding wholeMillimetre { unit::Millimetre, DecimalPlaces { 0 }, RoundingMode::HalfAwayFromZero }; +constexpr formula::DecimalRounding tenthMillimetre { unit::Millimetre, DecimalPlaces { 1 }, RoundingMode::HalfAwayFromZero }; +constexpr auto coarseInput = formula::rounded(var); +constexpr auto sizeAdjustedDiameter = + formula::when(var > formula::constant(17.3_r), coarseInput, formula::rounded(var)); +``` + + - `constraints.cpp` replaces its hand-built trace (`:99-106`) with `explain_check`. + - `lookup_tables.cpp`: + - `valueOf` / `errorOf` become `number_of` / `.error()`; + - the table in `SizeBands` uses `band(0, 127)`, `band(127, 173)`, …, with its comments kept, because the guide quotes them; + - the manual trace becomes `checked_explain`, which keeps the failure's trace, where `explain` would throw. +- [ ] **Step 2:** Verify, then commit. + +### Task 14: Methods, records and series + +**Files:** `examples/methods_and_overlays.cpp`, `records.cpp`, `series.cpp`; `docs/methods-and-overlays.md`, `records.md`, `series.md` + +- [ ] **Step 1:** Rewrite by the rules. + - `methods_and_overlays.cpp`: + - `derivationOf` / `acceptanceOf` become `explain_method` / `explain_check_method`; + - the method's rounding becomes `rounding_rule()` over a named `DecimalRounding`; + - `**result` becomes `number_of(result)`. + - `series.cpp`: + - delete `rat`, `m`, the three trace helpers and `outcome_word`; + - use `measured_series(130, 210, 95, 340, 28)`; + - `{:}` of `ConstraintOutcomeKind` replaces `outcome_word`. + - `records.cpp`: `exact()` becomes `number_of`, and `traceOf` becomes `traced` / `checked_explain`. +- [ ] **Step 2:** Verify (`docs.methods-and-overlays-*`, `docs.records-*`, `docs.series-*`), then commit. + +### Task 15: Statistics, opaque operations and retry, and the electricity bill + +**Files:** `examples/statistics.cpp`, `opaque_and_retry.cpp`, `electricity_bill.cpp`; `docs/statistics.md`, `opaque-and-retry.md`, `calculations.md` + +- [ ] **Step 1:** Rewrite by the rules. + - `statistics.cpp`: + - delete `rat` and `fraction_string`; + - the five `without_outliers<…>(…, repeatTest, rejectionRule)` calls share one local `constexpr` of the repeated arguments (a small lambda or a named node); + - the rounded root uses a `DecimalRounding`; + - every trace-then-evaluate-again pair becomes one `explain_rejection` or `checked_explain`. + - `opaque_and_retry.cpp`: + - the 87 `formula::Rational {` become `_r` or integers; + - `endName` becomes `{}` of `RetryEnd` (its words change from `Accepted` to `accepted`: update the guide's output in the same commit); + - the five identical `retry<…>(fromZero, halving, settled, repeatDetermination, settledCitation)` share one local; + - `rounded_output<"slope", millimetrePerSecond, DecimalPlaces { 4 }, RoundingMode::HalfEven>` becomes a named `DecimalRounding`. + - `electricity_bill.cpp`: + - `rounded` becomes `rounded`; + - `sheet.set(Measured { Rational { 1, 4 } })` becomes `sheet.set(Measured { 0.25_r })`; + - `render(bill, DefaultVocabulary {}, { … })` becomes `render(bill, { … })`; + - `std::printf("%s\n", std::format(…).c_str())` becomes `std::println(…)`. +- [ ] **Step 2:** Verify (`docs.statistics-*`, `docs.opaque-and-retry-*`, `docs.calculations-*`, `census.*`), then commit. + +### Task 16: Finish + +**Files:** none new. + +This is the first time the non-Windows compilers see the branch; the task's job is to drive **every** compiler to green. Set `$SW = /mnt/c/Users/c.parpart/AppData/Local/Temp/claude/D--formula-cpp/b0c0e78c-1b2d-4d4c-a792-0760adeb4a03/scratchpad` and `$TW = /mnt/d/formula-cpp/.claude/worktrees/concise-spellings`. + +- [ ] **Step 1: The full suite, everything at once.** Start these concurrently (Windows and WSL do not share a build tree): + - `pwsh -NoProfile -File $S\windows-matrix.ps1 -Tree $T`: cl-debug, cl-release, clangcl-debug and clangcl-release, each with every negative test. Must print `MATRIX OK`. + - `wsl bash $SW/posix-matrix.sh --tree $TW`: gcc-release with g++-14, clang-debug, clang-release and clang-ubsan. Must print `MATRIX OK`. + - `wsl bash $SW/docs-pages.sh --tree $TW` (Doxygen 1.9.8, no warnings), and `mkdocs build --strict` on Windows. + - At the same time, push the branch so the draft PR's CI runs the macOS AppleClang leg and the `Package` workflow. +- [ ] **Step 2: Fix to green.** + - Group every failure by cause: g++ `-Wshadow`, a message counted twice off MSVC, libc++ or AppleClang, Doxygen, or a docs check. + - Fix one group per commit, each named for what it fixes, never for this plan. + - After each fix, re-run only the legs that failed. After the last fix, run Step 1 once more in full. + - Repeat until every local leg prints `MATRIX OK` and every CI job is green. +- [ ] **Step 2a: Whole-branch review.** Dispatch one fresh reviewer, on the most capable model, over `git diff eb5eed8...HEAD`, against this plan's *Design*, *Global Constraints* and *Review Focus*. Run it in parallel with Step 1, since it reads code and builds nothing. Fix what it finds with one commit per finding group, then finish with Step 2's full run. +- [ ] **Step 3: Measure.** Totals over `examples/`, before (`eb5eed8`) and after: lines, and the counts of `Rational {`, `Measured<`, `measurement().value()`, `%.*s`, `RecordingSink` and local `rat(`. +- [ ] **Step 4: PR.** Update the draft PR's title and body (`/contour-workflows:update-pr`) with what changed, the before/after counts, and the two *Changed* entries (the ADL rule for `describe`, and the compile-time conversion refusal). Mark it ready once every CI job is green (`/contour-workflows:fix-ci` for any that is not). **Do not merge** without the owner. +- [ ] **Step 5: Board.** Mark every row landed or done, state what waits for the owner, and republish. + +--- + +## Self-review (writing-plans checklist) + +1. **Spec coverage:** + + | Design decision | Task | + |---|---| + | Literals and inputs | 1–2 | + | Reading and printing | 3–5 | + | A trace from every verb | 6 | + | A rule stated once | 7–8 | + | Bound formulas | 9 | + | `std::print` everywhere | 0, 10, 11–15 | + | Examples rewritten | 11–15 | + | Docs in step | every task | + + Nothing in *Design* is without a task. +2. **Placeholder scan:** + - Library tasks carry their code. + - "Likewise" appears only where the plan gives one full overload and the rest differ solely in the verb's name, and each such family is named in full. + - Example tasks carry rules plus a representative target, because pre-writing 19 files would go stale on the first line. + - Values the implementer must read from real output are said to be read, not invented: the Dimension spelling, the `FailureSite` words, and the examples' outputs. +3. **Type consistency:** these names are used identically in every task: `number_of`, `Traced { outcome; trace; }`, `traced`, `explain_method` / `explain_check_method` / `explain_curve` / `explain_rejection` / `explain_check` / `explain_check_all` / `explain_conformity`, `DecimalRounding { unit; places; mode; }`, `SignificantRounding`, `declared_rounding`, `Yields { expression }`, `yields`, `detail::ResultOfYields`, `not_measured` / `NotMeasured`, and `operator""_r`. +4. **Review Focus:** each of the five lines has its test in its owning task: 1 → Task 1, Steps 1 and 4; 2 → Task 2, Step 4; 3 → Task 4, Steps 4–5; 4 → Task 9, Steps 1 and 4; 5 → Task 10, Step 3, and rules 4 and 6 of Tasks 11–15. diff --git a/docs/superpowers/specs/2026-09-30-concise-spellings-design.md b/docs/superpowers/specs/2026-09-30-concise-spellings-design.md new file mode 100644 index 00000000..7d67956b --- /dev/null +++ b/docs/superpowers/specs/2026-09-30-concise-spellings-design.md @@ -0,0 +1,50 @@ +# Concise Spellings Design + +The implementation plan is `docs/superpowers/plans/2026-09-30-concise-spellings.md`. + + +## Why + +The 19 programs in `examples/` repeat a few shapes, and those shapes make the library feel verbose: + +| Shape | ≈ sites in examples | Evidence | +|---|---|---| +| `formula::Rational { 273, 10 }`, `*Rational::make(..)`, `*Rational::from_decimal(..)` | ~260 | **38 files define their own `rat()`**. Header comments call a `rat()` that does not exist (`expression.hpp:87,94`, `rejection.hpp:12,149,223-238`, `lookup.hpp`, `precision.hpp:837`, `series.hpp:193`). | +| `Measured { Rational { n } }`; `measured_series(Measured{..}, …)` repeating `Q` for every element | ~115, plus 20 series | `series.cpp` layers `m()` over `rat()`. | +| `r.has_value() && r->is_value() && r->measurement().value() == X` | ~60 | 7 local helpers (`valueOf`, `exact` ×2, `exact_text`, `fraction_string`, …) | +| A hand-built trace, `Trace<> t{}; RecordingSink<> s{t}; (void) verb(…, s);`, then evaluating again for the value | 8 helpers, 8 inline blocks | `explain*` exists only for a Node, a series, a retry and a worksheet. | +| printf noise: `%.*s`, `static_cast`, `.to_double()`, `? "yes" : "no"` | ~250 | Only `display.cpp` and `electricity_bill.cpp` use the existing `std::format` support. | +| An enum turned into text by hand | 3 switches | No `describe()` for `ConstraintOutcomeKind`, `RetryEnd`, … | +| `` restated at every use | ~25 | A standard states its rounding once. | +| The result quantity named again at every call | ~100 | Mostly inside per-file helper lambdas. | + +## Decisions (owner, 2026-09-30) + +- All four addition groups are in scope: literals and inputs; reading and printing; a trace from every verb; stating a rule once. +- **Bound formulas are in scope.** `yields(expr)` names the result once. The author still names it; nothing is deduced from the expression. +- **`std::print` / `std::println` replace printf and iostream** throughout the examples, tools, support code and docs, not only in the examples. + +## Rules nothing here may bend + +- Exactness: no implicit `double`, and a literal is exact or refused. +- Absent is not zero. +- The author names the result quantity. +- No unit is guessed for a bare number. +- No macros. +- Every fallible operation keeps its `checked_` form. +- One mistake produces one message, which begins `formula: ` or names a `formula_` guard. + +## Considered and not done + +| Idea | Why not | +|---|---| +| Deduce the result from the expression | The result is the author's choice (`evaluate.hpp:469-472`). `yields` keeps it the author's choice. | +| A defaulted quantity tag via `decltype([]{})` | CONTRIBUTING invariant 6: the type differs in each translation unit, which was verified to fail at link time. | +| A macro to declare quantities | The design requires traceability without macros (`docs/superpowers/specs/2026-09-23-formula-cpp-design.md:194-201`). | +| Implicit `double` → `Rational` | Inexact. `_r` gives the short spelling exactly. | +| Deduce `constant`'s unit, or a lookup's key unit, from the other operand | A standard states 47.3 kN against a quantity declared in N, and a table in mm may key a quantity declared in m. | +| A default `render_trace` limit or format rounding mode | Both are deliberate (`trace_render.hpp:15`, `format.hpp:460`). | +| `var = 180` binding | It would be an assignment operator on a const object that assigns nothing. `Measured { 180 }` already compiles. | +| `yields` over a curve | `checked_evaluate_curve` names two results (domain and values). Out of scope. | +| `DecimalRounding` for `rounded_ln` / `log10` / `exp` | These take no unit, and `` is already short. | +| Migrating the ~2600 `rat()` uses in `test/` | Out of scope; tests adopt `_r` as they are touched. | diff --git a/examples/simple.cpp b/examples/simple.cpp index 3db0bd66..2fd67ce2 100644 --- a/examples/simple.cpp +++ b/examples/simple.cpp @@ -4,7 +4,7 @@ #include -#include +#include /// A quantity is a type. It carries its own symbol, its own description and the /// unit its values are stated in, and it is distinct from every other quantity @@ -25,9 +25,9 @@ int main() formula::Outcome const result = formula::evaluate(waterCementRatio, batch); - std::printf("%s = %f (%s)\n", - formula::Describe::symbol.data(), - result.measurement().value().to_double(), - result.is_value() ? "computed" : "no value"); + std::println("{} = {:f} ({})", + formula::Describe::symbol, + result.measurement().value().to_double(), + result.is_value() ? "computed" : "no value"); return 0; } From 878a7405c87668bf8d70fee116eb6f092305b35c Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 17:24:00 +0200 Subject: [PATCH 02/59] feat(rational): add the exact decimal literal _r A decimal such as 27.3 written as a double is the binary fraction nearest it, not 273/10, and Rational deliberately refuses a double for that reason. That left Rational::from_decimal(273, -1) as the only exact way to write a decimal, which is hard to read in a formula. `27.3_r` reads the spelling itself, so nothing is rounded on the way in. The literal is consteval and takes the raw spelling: integers, a fraction with a leading or trailing point, an exponent that scales exactly, digit separators, and trailing fractional zeros that cost nothing. A spelling Rational cannot hold, or one that is not a decimal (hexadecimal, binary, and a leading zero, which C++ reads as octal), fails to compile and the diagnostic names the guard that was reached. Signed-off-by: Christian Parpart --- CHANGELOG.md | 8 ++ docs/numbers.md | 36 ++++++ include/formula-cpp/rational.hpp | 106 ++++++++++++++++++ test/CMakeLists.txt | 10 ++ test/consumer_globals_run_tests.cpp | 2 +- test/consumer_globals_tests.cpp | 6 + .../rational_literal_not_a_decimal.cpp | 12 ++ test/negative/rational_literal_octal.cpp | 12 ++ .../rational_literal_out_of_range.cpp | 12 ++ .../rational_literal_too_many_places.cpp | 12 ++ test/rational_literal_tests.cpp | 45 ++++++++ 11 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 test/negative/rational_literal_not_a_decimal.cpp create mode 100644 test/negative/rational_literal_octal.cpp create mode 100644 test/negative/rational_literal_out_of_range.cpp create mode 100644 test/negative/rational_literal_too_many_places.cpp create mode 100644 test/rational_literal_tests.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 2659050a..4c79da6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ change is recorded here. ## [Unreleased] +### Added + +- `_r`, an exact decimal literal: `27.3_r` is the `Rational` 273/10, where `27.3` is the double + nearest it. It reads integers, fractions, a leading or trailing point (`.5_r`, `5._r`), an + exponent (`1.5e-3_r` is 3/2000) and digit separators, and a minus sign is `Rational`'s own + negation. A spelling `Rational` cannot hold, and one that is not a decimal (`0x1F_r`, and `017_r`, + which C++ reads as octal), fails to compile. + ## [0.2.0] - 2026-09-30 The second release. It adds calculations -- quantities defined by formulas, with a dependency graph diff --git a/docs/numbers.md b/docs/numbers.md index 82f8685c..817e7138 100644 --- a/docs/numbers.md +++ b/docs/numbers.md @@ -75,6 +75,42 @@ so silently converting one to a `Rational` would make `0.45` mean 8106479329266893 / 2^54, not 9/20 -- exactly the confusion this type exists to prevent. +## Writing an exact decimal + +`Rational::from_decimal(273, -1)` is exact but hard to read. The literal `_r` +writes the same value the way it is written on paper: + +```cpp +using namespace formula::literals; + +Rational const a = 27.3_r; // 273/10, not the double nearest 27.3 +Rational const b = 0.47_r; // 47/100 +Rational const c = 1.5e-3_r; // 3/2000 +Rational const d = -27.3_r; // -273/10 +``` + +A literal has to be a `_r` literal rather than a `double` for the reason given +under *Why not `double`*: `0.47` as a `double` is not 47/100, and a `Rational` +made from it would carry that error. `0.47_r` is read from its spelling, so +nothing is rounded on the way in. An exponent scales exactly, digit separators +(`1'000.5_r`) are ignored, and a minus sign is `Rational`'s own negation. + +The literal is evaluated at compile time, so a spelling it cannot honour does not +compile. The diagnostic names the function that was reached: + +| Spelling | Why it is refused | Named in the diagnostic | +|---|---|---| +| `9'223'372'036'854'775'808_r` | more significant digits than `Rational`'s 64-bit numerator holds | `formula_rational_literal_out_of_range` | +| `0.0000000000000000001_r` | a denominator of 10^19 does not fit either | `formula_rational_literal_out_of_range` | +| `0x1F_r`, `0b101_r` | not a decimal | `formula_rational_literal_not_a_decimal` | +| `017_r` | C++ reads a leading zero as octal, so it is not the decimal 17 | `formula_rational_literal_not_a_decimal` | + +Trailing zeros after the point cost nothing: `4.210_r` is 421/100, and a +literal with two dozen places still works when most of them are zeros. + +The literal is for decimals. A fraction such as one third is still +`Rational { 1, 3 }`, or `1_r / 3`. + ## Exact or nothing `checked_` means exactly one thing in this library: the function returns diff --git a/include/formula-cpp/rational.hpp b/include/formula-cpp/rational.hpp index 0b075e49..7d3f7a0b 100644 --- a/include/formula-cpp/rational.hpp +++ b/include/formula-cpp/rational.hpp @@ -18,7 +18,9 @@ #include #include +#include #include +#include #include #include #include @@ -515,6 +517,110 @@ constexpr Rational& operator/=(Rational& leftOperand, Rational rightOperand) /// state which number it was. inline constexpr Rational Pi { 245'850'922, 78'256'779 }; +namespace detail +{ + /// A `_r` literal whose exact value `Rational` cannot hold: too many + /// significant digits, or a denominator above `Int`'s range. Deliberately + /// not `constexpr`, like `formula_exponent_out_of_range` + /// (`dimension.hpp`): the literal operator is `consteval`, so reaching + /// this fails to compile and the diagnostic names it. + [[noreturn]] inline void formula_rational_literal_out_of_range() + { + std::abort(); + } + + /// A `_r` literal spelled as something other than a decimal: a + /// hexadecimal or binary integer, or an integer with a leading zero, + /// which C++ reads as octal. Same mechanism as above. + [[noreturn]] inline void formula_rational_literal_not_a_decimal() + { + std::abort(); + } + + /// The exact value of a decimal literal's spelling: digits, an optional + /// fraction, an optional exponent, and digit separators. + consteval Rational rational_from_spelling(char const* spelling) + { + std::size_t at = 0; + bool const hasPoint = [&] { + for (std::size_t probe = 0; spelling[probe] != '\0'; ++probe) + if (spelling[probe] == '.' || spelling[probe] == 'e' || spelling[probe] == 'E') + return true; + return false; + }(); + if (spelling[0] == '0' && spelling[1] != '\0' && !hasPoint) + formula_rational_literal_not_a_decimal(); // 017, 0x1F, 0b101 + Int mantissa = 0; + int decimalScale = 0; // decimal exponent the digits carry + int pendingZeros = 0; // fractional zeros not yet multiplied in + bool inFraction = false; + for (; spelling[at] != '\0' && spelling[at] != 'e' && spelling[at] != 'E'; ++at) + { + char const symbolAt = spelling[at]; + if (symbolAt == '\'') + continue; + if (symbolAt == '.') + { + inFraction = true; + continue; + } + if (symbolAt < '0' || symbolAt > '9') + formula_rational_literal_not_a_decimal(); + int const digitValue = symbolAt - '0'; + if (inFraction && digitValue == 0) + { + ++pendingZeros; + continue; + } + for (int zero = 0; zero <= pendingZeros; ++zero) // the zeros, then this digit's place + { + int const placed = zero == pendingZeros ? digitValue : 0; + if (mantissa > (IntMax - placed) / 10) + formula_rational_literal_out_of_range(); + mantissa = mantissa * 10 + placed; + } + if (inFraction) + decimalScale -= pendingZeros + 1; + pendingZeros = 0; + } + if (spelling[at] == 'e' || spelling[at] == 'E') + { + ++at; + bool const negative = spelling[at] == '-'; + if (spelling[at] == '-' || spelling[at] == '+') + ++at; + int written = 0; + for (; spelling[at] != '\0'; ++at) + { + if (spelling[at] == '\'') + continue; + written = written * 10 + (spelling[at] - '0'); + if (written > 1'000) + formula_rational_literal_out_of_range(); + } + decimalScale += negative ? -written : written; + } + std::expected const made = Rational::from_decimal(mantissa, decimalScale); + if (!made) + formula_rational_literal_out_of_range(); + return *made; + } +} // namespace detail + +inline namespace literals +{ + /// An exact decimal: `27.3_r` is 273/10, never the `double` nearest it. + /// An exponent scales exactly (`1.5e-3_r` is 3/2000); `-27.3_r` is + /// `Rational`'s own negation. A spelling `Rational` cannot hold, or one + /// that is not a decimal (`0x1F_r`, and `017_r`, which C++ reads as + /// octal), fails to compile, naming `formula_rational_literal_out_of_range` + /// or `formula_rational_literal_not_a_decimal`. + consteval Rational operator""_r(char const* spelling) + { + return detail::rational_from_spelling(spelling); + } +} // namespace literals + namespace detail { /// The integer @p degree-th root of @p radicand, or nothing when it is not diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index d0073f63..6958895f 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -30,6 +30,7 @@ add_executable(formula-cpp-tests error_tests.cpp expression_tests.cpp rational_tests.cpp + rational_literal_tests.cpp render_tests.cpp rounding_tests.cpp dimension_tests.cpp @@ -288,6 +289,15 @@ formula_add_negative_test(exponent_zero_denominator "formula_exponent_denominator_must_not_be_zero") formula_add_negative_test(exponent_out_of_range "formula_exponent_out_of_range") +# The `_r` literal is `consteval`, so a spelling it refuses is caught the same +# way as the exponent sentinels above: by the NAME of the deliberately +# non-`constexpr` guard, which every compiler quotes, and with no EXPECT_COUNT, +# because clang and clang-cl report a failed consteval call twice. +formula_add_negative_test(rational_literal_out_of_range "formula_rational_literal_out_of_range") +formula_add_negative_test(rational_literal_too_many_places "formula_rational_literal_out_of_range") +formula_add_negative_test(rational_literal_not_a_decimal "formula_rational_literal_not_a_decimal") +formula_add_negative_test(rational_literal_octal "formula_rational_literal_not_a_decimal") + formula_add_negative_test(dimension_mismatch "formula: these two dimensions are not the same") formula_add_negative_test(dimension_named_base_mismatch diff --git a/test/consumer_globals_run_tests.cpp b/test/consumer_globals_run_tests.cpp index 5c5209b8..62c8b256 100644 --- a/test/consumer_globals_run_tests.cpp +++ b/test/consumer_globals_run_tests.cpp @@ -13,7 +13,7 @@ TEST_CASE("a consumer's ordinary globals do not break a build that includes every header", "[hygiene]") { ConsumerGlobalsProbe const probe = probe_consumer_globals(); - REQUIRE(probe.checks.size() == 87); + REQUIRE(probe.checks.size() == 88); for (std::size_t index = 0; index < probe.checks.size(); ++index) { INFO("check " << index); diff --git a/test/consumer_globals_tests.cpp b/test/consumer_globals_tests.cpp index 5d90157e..fb2678cf 100644 --- a/test/consumer_globals_tests.cpp +++ b/test/consumer_globals_tests.cpp @@ -1224,5 +1224,11 @@ ConsumerGlobalsProbe probe_consumer_globals() probe.checks.push_back(std::format("{}", formula::Rational { 3, 5 }) == "0.6" && std::format("{:>10~HalfEven}", thirdEdge) == " \xe2\x89\x88" "150.7 mm" && std::format("{:/}", thirdEdge) == "452/3 mm"); + + // The exact decimal literal: 27.3 is 273/10, not the double nearest it. + { + using namespace formula::literals; + probe.checks.push_back(27.3_r == formula::Rational { 273, 10 }); + } return probe; } diff --git a/test/negative/rational_literal_not_a_decimal.cpp b/test/negative/rational_literal_not_a_decimal.cpp new file mode 100644 index 00000000..ff38a25a --- /dev/null +++ b/test/negative/rational_literal_not_a_decimal.cpp @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 +// A hexadecimal spelling is not a decimal. +// This must not compile. +#include + +using namespace formula::literals; + +constexpr formula::Rational refused = 0x1F_r; + +int main() +{ +} diff --git a/test/negative/rational_literal_octal.cpp b/test/negative/rational_literal_octal.cpp new file mode 100644 index 00000000..26a3083b --- /dev/null +++ b/test/negative/rational_literal_octal.cpp @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 +// C++ reads a leading zero as octal, so this is not the decimal 17 a reader may expect. +// This must not compile. +#include + +using namespace formula::literals; + +constexpr formula::Rational refused = 017_r; + +int main() +{ +} diff --git a/test/negative/rational_literal_out_of_range.cpp b/test/negative/rational_literal_out_of_range.cpp new file mode 100644 index 00000000..2651ff42 --- /dev/null +++ b/test/negative/rational_literal_out_of_range.cpp @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 +// One more than the largest integer a Rational holds. +// This must not compile. +#include + +using namespace formula::literals; + +constexpr formula::Rational refused = 9'223'372'036'854'775'808_r; + +int main() +{ +} diff --git a/test/negative/rational_literal_too_many_places.cpp b/test/negative/rational_literal_too_many_places.cpp new file mode 100644 index 00000000..4d922145 --- /dev/null +++ b/test/negative/rational_literal_too_many_places.cpp @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 +// A denominator of 10^19 is above the range of the integers a Rational holds. +// This must not compile. +#include + +using namespace formula::literals; + +constexpr formula::Rational refused = 0.0000000000000000001_r; + +int main() +{ +} diff --git a/test/rational_literal_tests.cpp b/test/rational_literal_tests.cpp new file mode 100644 index 00000000..b625cd47 --- /dev/null +++ b/test/rational_literal_tests.cpp @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include + +using namespace formula::literals; +using formula::Rational; + +TEST_CASE("_r: an integer spelling is that integer", "[rational][literal]") +{ + STATIC_REQUIRE(457_r == Rational { 457 }); + STATIC_REQUIRE(0_r == Rational {}); + STATIC_REQUIRE(1'000'003_r == Rational { 1'000'003 }); + STATIC_REQUIRE(9'223'372'036'854'775'807_r == Rational { formula::detail::IntMax }); +} + +TEST_CASE("_r: a decimal spelling is its exact value, not the nearest double", "[rational][literal]") +{ + STATIC_REQUIRE(27.3_r == Rational { 273, 10 }); + STATIC_REQUIRE(0.47_r == Rational { 47, 100 }); // the double 0.47 is not 47/100 + STATIC_REQUIRE(0.0213_r == Rational { 213, 10'000 }); + STATIC_REQUIRE(.5_r == Rational { 1, 2 }); + STATIC_REQUIRE(5._r == Rational { 5 }); + STATIC_REQUIRE(1'000.5_r == Rational { 2'001, 2 }); + STATIC_REQUIRE(1_r / 3 == Rational { 1, 3 }); // a fraction is still spelled as one +} + +TEST_CASE("_r: trailing fractional zeros cost nothing", "[rational][literal]") +{ + STATIC_REQUIRE(4.210_r == Rational { 421, 100 }); + STATIC_REQUIRE(1.500000000000000000000000_r == Rational { 3, 2 }); // 24 places, most of them zeros +} + +TEST_CASE("_r: an exponent scales exactly", "[rational][literal]") +{ + STATIC_REQUIRE(1.5e-3_r == Rational { 3, 2'000 }); + STATIC_REQUIRE(7e2_r == Rational { 700 }); + STATIC_REQUIRE(1.3E+2_r == Rational { 130 }); + STATIC_REQUIRE(1'3.7e1_r == Rational { 137 }); +} + +TEST_CASE("_r: a minus sign is Rational's own negation", "[rational][literal]") +{ + STATIC_REQUIRE(-27.3_r == Rational { -273, 10 }); +} From f49657816c425d66568edd59f5f74c99cce50389 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 17:32:59 +0200 Subject: [PATCH 03/59] feat(environment): accept plain numbers and not_measured in measured_series A series of measurements had to spell every element as Measured { Rational { ... } }, and a point that was not measured as Measured::absent(), although the quantity is already stated once in measured_series. Each element may now be a Measured, anything a Rational is built from (127, 10.3_r, a Rational), or not_measured. A wrong element draws one message: a Measured of another quantity, a string or a bool is refused by measured_series in its own words, and a double or a wide unsigned integer by Rational's, with the series' own check silent. band(low, high) and breakpoint(key) likewise take exact numbers, so band(83.7_r, 97.3_r) and breakpoint(12.7_r) read as the numbers they are. Every earlier spelling still works. The header comments that called a rat() helper which does not exist now show the real spellings, and the line numbers the documentation quotes from the headers follow the move. Signed-off-by: Christian Parpart --- CHANGELOG.md | 4 ++ docs/expressions.md | 6 +- docs/lookup-tables.md | 6 +- docs/quantities.md | 34 ++++++++++ include/formula-cpp/band.hpp | 7 +++ include/formula-cpp/conformity.hpp | 2 +- include/formula-cpp/critical_value.hpp | 2 +- include/formula-cpp/curve.hpp | 2 +- include/formula-cpp/environment.hpp | 63 ++++++++++++++++--- include/formula-cpp/expression.hpp | 4 +- include/formula-cpp/lookup.hpp | 17 +++-- include/formula-cpp/precision.hpp | 4 +- include/formula-cpp/rejection.hpp | 10 +-- include/formula-cpp/series.hpp | 2 +- test/CMakeLists.txt | 13 ++++ test/band_tests.cpp | 7 +++ test/consumer_globals_run_tests.cpp | 2 +- test/consumer_globals_tests.cpp | 12 ++++ test/environment_tests.cpp | 18 ++++++ test/lookup_tests.cpp | 7 +++ test/measured_tests.cpp | 5 ++ .../measured_series_element_double.cpp | 17 +++++ ...measured_series_element_other_quantity.cpp | 21 +++++++ .../measured_series_element_wide_unsigned.cpp | 20 ++++++ 24 files changed, 252 insertions(+), 33 deletions(-) create mode 100644 test/negative/measured_series_element_double.cpp create mode 100644 test/negative/measured_series_element_other_quantity.cpp create mode 100644 test/negative/measured_series_element_wide_unsigned.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c79da6b..fc5d8cf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ change is recorded here. exponent (`1.5e-3_r` is 3/2000) and digit separators, and a minus sign is `Rational`'s own negation. A spelling `Rational` cannot hold, and one that is not a decimal (`0x1F_r`, and `017_r`, which C++ reads as octal), fails to compile. +- `measured_series` takes plain numbers and `not_measured` beside `Measured`: + `measured_series(127, 10.3_r, not_measured)`. An element that is none of these draws + one message. `band(low, high)` takes its bounds, and `breakpoint(key)` its key, as exact numbers: + `band(83.7_r, 97.3_r)`, `breakpoint(12.7_r)`. Every earlier spelling stays. ## [0.2.0] - 2026-09-30 diff --git a/docs/expressions.md b/docs/expressions.md index 1051b0b0..9382e677 100644 --- a/docs/expressions.md +++ b/docs/expressions.md @@ -163,11 +163,11 @@ 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/environment.hpp(439): error C2338: static assertion failed: 'formula: this environment provides no value for this quantity; the quantity and the environment appear in this diagnostic as the template arguments of RequireProvided' -include\formula-cpp/environment.hpp(439): note: the template instantiation context (the oldest one first) is +include\formula-cpp/environment.hpp(486): error C2338: static assertion failed: 'formula: this environment provides no value for this quantity; the quantity and the environment appear in this diagnostic as the template arguments of RequireProvided' +include\formula-cpp/environment.hpp(486): note: the template instantiation context (the oldest one first) is test\negative\quantity_alias_environment_missing.cpp(15): note: see reference to function template instantiation 'formula::Measured formula::Environment>::get(void) noexcept const' being compiled test\negative\quantity_alias_environment_missing.cpp(15): note: see the first reference to 'formula::Environment>::get' in 'main' -include\formula-cpp/environment.hpp(584): note: see reference to class template instantiation 'formula::detail::RequireProvided>>' being compiled +include\formula-cpp/environment.hpp(631): note: see reference to class template instantiation 'formula::detail::RequireProvided>>' being compiled ``` The prototype this layer replaced answered a missing input with a runtime diff --git a/docs/lookup-tables.md b/docs/lookup-tables.md index c38789d2..e7416744 100644 --- a/docs/lookup-tables.md +++ b/docs/lookup-tables.md @@ -178,10 +178,10 @@ compiler), with the rest of the instantiation backtrace below these lines: ``` In file included from test\negative\lookup_band_gap.cpp:10: In file included from include\formula-cpp/lookup.hpp:474: -include\formula-cpp/band.hpp(219,19): error: static assertion failed due to requirement 'bands_are_adjacent(formula::Band{103, 1, 197, 1}, formula::Band{241, 1, 331, 1})': formula: this band table has a gap or overlap between two adjacent bands; the earlier band's declared high bound and the later band's declared low bound do not match exactly, and the two offending Band values appear in this diagnostic as the template arguments First and Second of RequireBandsAdjacent - 219 | static_assert(bands_are_adjacent(First, Second), +include\formula-cpp/band.hpp(226,19): error: static assertion failed due to requirement 'bands_are_adjacent(formula::Band{103, 1, 197, 1}, formula::Band{241, 1, 331, 1})': formula: this band table has a gap or overlap between two adjacent bands; the earlier band's declared high bound and the later band's declared low bound do not match exactly, and the two offending Band values appear in this diagnostic as the template arguments First and Second of RequireBandsAdjacent + 226 | static_assert(bands_are_adjacent(First, Second), | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -include\formula-cpp/band.hpp(260,29): note: in instantiation of template class 'formula::RequireBandsAdjacent' requested here +include\formula-cpp/band.hpp(267,29): note: in instantiation of template class 'formula::RequireBandsAdjacent' requested here ``` The message names **both offending rows**, as the values you typed: the one diff --git a/docs/quantities.md b/docs/quantities.md index daeac882..2af3a61d 100644 --- a/docs/quantities.md +++ b/docs/quantities.md @@ -281,6 +281,40 @@ nobody could perform is refused even when there was no value to get wrong: an absent measurement, converted: still absent ``` +## Supplying values + +A measurement is written with the number it holds, not with `Rational` +spelled out around it. An integer is a value as it stands, and `_r` +(`using namespace formula::literals;`) is an exact decimal: + +```cpp +using namespace formula::literals; + +formula::Measured const whole { 139 }; +formula::Measured const fractional { 10.3_r }; +``` + +`10.3_r` is exactly 103/10. A plain `10.3` is refused with a message that +says why -- it is the double nearest 10.3, not 10.3 -- and so is an unsigned +integer wide enough to hold values a `Rational` cannot. + +`formula::measured_series` takes the same spellings, mixed freely, and +`formula::not_measured` for a point that was not measured. It is the same +as `Measured::absent()`, and either may stand in one series: + +```cpp +constexpr auto screens = formula::measured_series(127, 10.3_r, formula::not_measured, 139); +``` + +The series has four elements and the third is absent, not zero. Each element +may still be a `Measured`; a `Measured` of another quantity is +refused, and only one message says so. + +A band's bounds and a breakpoint's key are numbers in the same way: +`formula::band(83.7_r, 97.3_r)` and `formula::breakpoint(12.7_r)` are the +bands and breakpoints that `band(837, 10, 973, 10)` and `breakpoint(127, 10)` +spell as numerator over denominator, and those spellings stay. + ## Bounds, precision and conversion `formula::checked_within_bounds` and `formula::checked_round_to_declared` diff --git a/include/formula-cpp/band.hpp b/include/formula-cpp/band.hpp index e22861ae..e82cba8b 100644 --- a/include/formula-cpp/band.hpp +++ b/include/formula-cpp/band.hpp @@ -110,6 +110,13 @@ struct Band return { lowNumerator, lowDenominator, highNumerator, highDenominator }; } +/// Builds a `Band` from its low (inclusive) and high (exclusive) bound as +/// exact numbers: `band(83.7_r, 97.3_r)`, `band(0, 127)`. +[[nodiscard]] constexpr Band band(Rational lowBound, Rational highBound) noexcept +{ + return { lowBound.numerator(), lowBound.denominator(), highBound.numerator(), highBound.denominator() }; +} + /// 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 diff --git a/include/formula-cpp/conformity.hpp b/include/formula-cpp/conformity.hpp index 9fa46083..83c5baae 100644 --- a/include/formula-cpp/conformity.hpp +++ b/include/formula-cpp/conformity.hpp @@ -119,7 +119,7 @@ namespace detail }; } // namespace detail -/// No limit on this side of the row: `LimitRow { limit(rat(60)), unbounded }` +/// No limit on this side of the row: `LimitRow { limit(60), unbounded }` /// is "at least 60". inline constexpr Limit unbounded = detail::LimitAccess::none(); diff --git a/include/formula-cpp/critical_value.hpp b/include/formula-cpp/critical_value.hpp index c8e84f94..724e9538 100644 --- a/include/formula-cpp/critical_value.hpp +++ b/include/formula-cpp/critical_value.hpp @@ -402,7 +402,7 @@ struct SampleSizeLookupNode: NodeBase }; /// Declares a critical-value lookup: -/// `critical_value(var, { rat(10), rat(30), ... })`. +/// `critical_value(var, { 10, 30, ... })`. /// /// `Sizes` and `ResultUnit` are not deduced, for the reason `banded_lookup` /// gives: a table's structure is the author's declared intent. `corrections` diff --git a/include/formula-cpp/curve.hpp b/include/formula-cpp/curve.hpp index fd86dc17..92f0aaa0 100644 --- a/include/formula-cpp/curve.hpp +++ b/include/formula-cpp/curve.hpp @@ -453,7 +453,7 @@ struct InterpolateAlongNode: NodeBase }; /// The value of @p curveExpression at @p at: `interpolate_at(curve(screens, -/// passing), constant(rat(42, 10)))`. +/// passing), constant(4.2_r))`. template [[nodiscard]] constexpr InterpolateAlongNode interpolate_at(C curveExpression, At at) noexcept { diff --git a/include/formula-cpp/environment.hpp b/include/formula-cpp/environment.hpp index 6745c51f..bfc18f19 100644 --- a/include/formula-cpp/environment.hpp +++ b/include/formula-cpp/environment.hpp @@ -164,15 +164,62 @@ class MeasuredSeries std::array, N> _elements; }; -/// Builds a series from its elements, in order, and counts them: -/// `measured_series(Measured { ... }, Measured::absent(), ...)`. -/// `Q` is stated rather than deduced, so every element must already be a -/// `Measured` of that one quantity. -template - requires(std::is_same_v> && ...) -[[nodiscard]] constexpr auto measured_series(Ms... elements) noexcept +/// An element of `measured_series` that was not measured: +/// `measured_series(127, formula::not_measured, 139)`. +struct NotMeasured { - return MeasuredSeries { std::array, sizeof...(Ms)> { elements... } }; + /// Every `NotMeasured` is the same. + [[nodiscard]] constexpr bool operator==(NotMeasured const&) const noexcept = default; +}; + +/// The spelling of an absent element -- see `NotMeasured`. +inline constexpr NotMeasured not_measured {}; + +namespace detail +{ + /// Fails to compile when an element of `measured_series` is neither a + /// `Measured`, `not_measured`, nor something `Rational` is built from + /// (a `Measured` of another quantity, a string, ...). A `double` or a wide + /// unsigned integer is refused by `Rational` itself, in its own words, and + /// draws nothing here. + template + struct RequireSeriesElementOf + { + static_assert(std::is_same_v> || std::is_same_v || std::is_constructible_v, + "formula: an element of measured_series is a Measured of that one quantity, an exact number, or " + "formula::not_measured; the quantity and the element's type appear in this diagnostic as the template " + "arguments of RequireSeriesElementOf"); + + static constexpr bool value = true; + }; + + /// @p given as the series element it stands for. After a refusal by + /// `RequireSeriesElementOf` an element yields absent, so the mistake is + /// reported once and not again by the conversion below. + template + [[nodiscard]] constexpr Measured series_element(Given given) noexcept + { + if constexpr (std::is_same_v>) + return given; + else if constexpr (std::is_same_v) + return Measured::absent(); + else if constexpr (std::is_constructible_v) + return Measured { Rational { given } }; + else + return Measured::absent(); + } +} // namespace detail + +/// Builds a series from its elements, in order, and counts them. Each element +/// is a `Measured`, an exact number (`127`, `10.3_r`, a `Rational`), or +/// `not_measured`: `measured_series(127, 10.3_r, formula::not_measured)`. +/// `Q` is stated rather than deduced, so a `Measured` of another quantity is +/// refused. +template +[[nodiscard]] constexpr auto measured_series(Given... given) noexcept +{ + static_assert((detail::RequireSeriesElementOf::value && ...)); + return MeasuredSeries { std::array, sizeof...(Given)> { detail::series_element(given)... } }; } /// A series a person supplied, as opposed to one the apparatus reported: the diff --git a/include/formula-cpp/expression.hpp b/include/formula-cpp/expression.hpp index 2a9d0326..95c3a1d7 100644 --- a/include/formula-cpp/expression.hpp +++ b/include/formula-cpp/expression.hpp @@ -84,14 +84,14 @@ struct ConstantNode: NodeBase static constexpr Dimension dimension = U.dimension; }; -/// A coefficient with a unit: `constant(rat(150))`. +/// A coefficient with a unit: `constant(150)`. template [[nodiscard]] constexpr ConstantNode constant(Rational value) noexcept { return ConstantNode { {}, value }; } -/// A dimensionless coefficient: `number(rat(1, 4))`. +/// A dimensionless coefficient: `number(0.25_r)`. /// /// There is deliberately no overload that guesses a unit for a bare number. /// Guessing wrong is exactly the failure the dimension layer exists to prevent. diff --git a/include/formula-cpp/lookup.hpp b/include/formula-cpp/lookup.hpp index f719ab82..98699f32 100644 --- a/include/formula-cpp/lookup.hpp +++ b/include/formula-cpp/lookup.hpp @@ -607,7 +607,7 @@ namespace detail /// public aggregate with public members, so /// /// inline constexpr ExactLookupNode node { -/// {}, { rat(781, 1000) }, Shape::Prism }; +/// {}, { 0.781_r }, Shape::Prism }; /// /// compiled, linked, and evaluated the two rows nobody typed as `0` -- checked /// against the installed package on all three node kinds, all three of which @@ -806,7 +806,7 @@ struct BandedLookupNode: NodeBase }; /// Declares a banded lookup: `banded_lookup(var, { rat(863, 1000), rat(1043, 1000), rat(1127, 1000) })`. +/// unit::One>(var, { 0.863_r, 1.043_r, 1.127_r })`. /// /// `KeyUnit`, `Bands` and `ResultUnit` are deliberately not deduced -- the /// same reason `rounded` (`rounding_node.hpp`) leaves its @@ -1158,7 +1158,7 @@ struct RequireValidKeyTable: detail::KeyChecks node { -/// {}, { rat(1127, 1000), rat(863, 1000), rat(1043, 1000) }, Shape::Cube }; +/// {}, { 1.127_r, 0.863_r, 1.043_r }, Shape::Cube }; /// /// -- and only a `static_assert` in the class body refuses that. With the /// asserts in the factory it compiles, links, and carries a silently @@ -1207,7 +1207,7 @@ struct ExactLookupNode: NodeBase }; /// Declares an exact lookup: `exact_lookup(shape, -/// { rat(1043, 1000), rat(863, 1000), rat(781, 1000) })`. +/// { 1.043_r, 0.863_r, 0.781_r })`. /// /// `Keys` and `ResultUnit` are deliberately not deduced, for the reason /// `banded_lookup` leaves its structural parameters unstated at the argument @@ -1326,6 +1326,13 @@ struct Breakpoint return { keyNumerator, keyDenominator }; } +/// Builds a `Breakpoint` from its key as an exact number: `breakpoint(12.7_r)`. +/// An integer still takes the overload above, so `breakpoint(127)` is unchanged. +[[nodiscard]] constexpr Breakpoint breakpoint(Rational keyValue) noexcept +{ + return { keyValue.numerator(), keyValue.denominator() }; +} + /// 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 @@ -1772,7 +1779,7 @@ struct InterpolatingLookupNode: NodeBase }; /// Declares an interpolating lookup: `interpolating_lookup(var, { rat(873, 1000), rat(1043, 1000), rat(1217, 1000) })`. +/// Points, unit::One>(var, { 0.873_r, 1.043_r, 1.217_r })`. /// /// `KeyUnit`, `Points` and `ResultUnit` are deliberately not deduced, for the /// reason `banded_lookup` leaves its structural parameters unstated at the diff --git a/include/formula-cpp/precision.hpp b/include/formula-cpp/precision.hpp index c9a1b2b6..0163c2d2 100644 --- a/include/formula-cpp/precision.hpp +++ b/include/formula-cpp/precision.hpp @@ -834,8 +834,8 @@ struct PrecisionLimitNode: NodeBase }; /// A precision limit evaluated at the level of the results it checks: -/// `precision_limit((var + var) / rat(2), -/// constant(rat(1, 10)) + rat(1, 50) * precision_level)`. +/// `precision_limit((var + var) / 2, +/// constant(0.1_r) + 0.02_r * precision_level)`. template [[nodiscard]] constexpr auto precision_limit(Level levelExpression, Limit limitExpression) noexcept { diff --git a/include/formula-cpp/rejection.hpp b/include/formula-cpp/rejection.hpp index 3125cc2a..ba2d7698 100644 --- a/include/formula-cpp/rejection.hpp +++ b/include/formula-cpp/rejection.hpp @@ -9,7 +9,7 @@ /// formula::without_outliers, formula::KeepAtLeast<4>>( /// formula::series, -/// formula::deviation_from_mean(formula::rat(6, 100) * formula::pass_mean), +/// formula::deviation_from_mean(0.06_r * formula::pass_mean), /// formula::Verdict { "discard the determinations and repeat the test" }, /// formula::Citation { .title = "Example Standard", .section = "7.4" }) /// @@ -146,7 +146,7 @@ enum class CriterionKind : std::uint8_t // ------------------------------------------------------------ placeholders /// The current pass's mean, read inside a rejection's limit expression: a -/// relative tolerance is `rat(6, 100) * pass_mean`. `Q` names the +/// relative tolerance is `0.06_r * pass_mean`. `Q` names the /// quantity the mean is a value of. template struct PassMeanNode: NodeBase @@ -220,14 +220,14 @@ struct GapToRange static constexpr CriterionKind kind = CriterionKind::GapToRange; }; -/// abs(x - pass mean) against @p limitExpression: `deviation_from_mean(rat(6, 100) * pass_mean)`. +/// abs(x - pass mean) against @p limitExpression: `deviation_from_mean(0.06_r * pass_mean)`. template [[nodiscard]] constexpr DeviationFromMean deviation_from_mean(Limit limitExpression) noexcept { return DeviationFromMean { limitExpression }; } -/// abs(x - pass mean) / s against @p limitExpression: `deviation_in_stddevs(rat(7, 4))`. +/// abs(x - pass mean) / s against @p limitExpression: `deviation_in_stddevs(1.75_r)`. template [[nodiscard]] constexpr DeviationInStddevs deviation_in_stddevs(Limit limitExpression) noexcept { @@ -235,7 +235,7 @@ template } /// gap / range for the two extremes against @p limitExpression: -/// `gap_to_range(critical_value(pass_count, {...}) * rat(1, 100))`. +/// `gap_to_range(critical_value(pass_count, {...}) * 0.01_r)`. template [[nodiscard]] constexpr GapToRange gap_to_range(Limit limitExpression) noexcept { diff --git a/include/formula-cpp/series.hpp b/include/formula-cpp/series.hpp index 9a43e647..28a63d21 100644 --- a/include/formula-cpp/series.hpp +++ b/include/formula-cpp/series.hpp @@ -190,7 +190,7 @@ struct SeriesConstantNode: SeriesNodeBase }; /// A per-element constant, its length counted from the values given: -/// `series_constant(rat(1), rat(2), rat(3))`. +/// `series_constant(1, 2, 3)`. template requires(sizeof...(Rs) > 0) && (std::convertible_to && ...) [[nodiscard]] constexpr auto series_constant(Rs... values) noexcept diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 6958895f..8af7042b 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -435,6 +435,19 @@ formula_add_negative_test(environment_series_length_mismatch formula_add_negative_test(measured_series_short "this series was given a different number of elements than its length") +# A wrong element in measured_series: another quantity's measurement draws the +# series' own message (once, and no overload list); a double or a wide +# unsigned draws Rational's, and the series' message stays silent. +formula_add_negative_test(measured_series_element_other_quantity + "formula: an element of measured_series is a Measured of that one quantity" EXPECT_COUNT 1 + REJECT "no matching" "cannot convert" "could not convert") +formula_add_negative_test(measured_series_element_double + "formula: a floating-point value is not an exact rational" EXPECT_COUNT 1 + REJECT "an element of measured_series") +formula_add_negative_test(measured_series_element_wide_unsigned + "formula: this unsigned type can hold values above Rational's maximum" EXPECT_COUNT 1 + REJECT "an element of measured_series") + # A series where one value is asked for: checked_evaluate, evaluate and # variant. The REJECTs pin that nothing past the refusal reads the # environment, and that variants(...) and method(...) around a refused diff --git a/test/band_tests.cpp b/test/band_tests.cpp index 5f97e705..2d093042 100644 --- a/test/band_tests.cpp +++ b/test/band_tests.cpp @@ -266,3 +266,10 @@ TEST_CASE("RequireBandWellFormed accepts a well-formed band", "[band]") { STATIC_REQUIRE(formula::RequireBandWellFormed::value); } + +TEST_CASE("band: bounds given as exact numbers", "[band]") +{ + using namespace formula::literals; + STATIC_REQUIRE(formula::band(83.7_r, 97.3_r) == formula::band(837, 10, 973, 10)); + STATIC_REQUIRE(formula::band(0, 127) == formula::band(0, 1, 127, 1)); +} diff --git a/test/consumer_globals_run_tests.cpp b/test/consumer_globals_run_tests.cpp index 62c8b256..9525400d 100644 --- a/test/consumer_globals_run_tests.cpp +++ b/test/consumer_globals_run_tests.cpp @@ -13,7 +13,7 @@ TEST_CASE("a consumer's ordinary globals do not break a build that includes every header", "[hygiene]") { ConsumerGlobalsProbe const probe = probe_consumer_globals(); - REQUIRE(probe.checks.size() == 88); + REQUIRE(probe.checks.size() == 89); for (std::size_t index = 0; index < probe.checks.size(); ++index) { INFO("check " << index); diff --git a/test/consumer_globals_tests.cpp b/test/consumer_globals_tests.cpp index fb2678cf..b5ec7c40 100644 --- a/test/consumer_globals_tests.cpp +++ b/test/consumer_globals_tests.cpp @@ -1230,5 +1230,17 @@ ConsumerGlobalsProbe probe_consumer_globals() using namespace formula::literals; probe.checks.push_back(27.3_r == formula::Rational { 273, 10 }); } + + // Plain numbers and not_measured in a series, and exact numbers for a + // band's bounds and a breakpoint's key. + { + using namespace formula::literals; + auto const suppliedSeries = formula::measured_series(127, 10.3_r, formula::not_measured); + probe.checks.push_back(suppliedSeries.size() == 3 && suppliedSeries.element(0).value() == formula::Rational { 127 } + && suppliedSeries.element(1).value() == formula::Rational { 103, 10 } + && suppliedSeries.element(2).is_absent() + && formula::band(83.7_r, 97.3_r) == formula::band(837, 10, 973, 10) + && formula::breakpoint(12.7_r) == formula::breakpoint(127, 10)); + } return probe; } diff --git a/test/environment_tests.cpp b/test/environment_tests.cpp index 79d2cf34..fd386c34 100644 --- a/test/environment_tests.cpp +++ b/test/environment_tests.cpp @@ -15,6 +15,9 @@ struct CementVolume: formula::Quantity { }; +struct Retained: formula::Quantity +{ +}; constexpr formula::Rational rat(std::int64_t numerator, std::int64_t denominator = 1) { @@ -94,3 +97,18 @@ TEST_CASE("environment: Entered compares by the measurement it carries", "[envir STATIC_REQUIRE_FALSE(low == absent); STATIC_REQUIRE(absent == formula::entered(formula::Measured::absent())); } + +TEST_CASE("measured_series: plain numbers and not_measured stand for elements", "[environment][series]") +{ + using namespace formula::literals; + constexpr auto mixed = formula::measured_series(127, 10.3_r, formula::not_measured, formula::Rational { 1, 3 }); + STATIC_REQUIRE(mixed.size() == 4); + STATIC_REQUIRE(mixed.element(0) == formula::Measured { 127 }); + STATIC_REQUIRE(mixed.element(1) == formula::Measured { formula::Rational { 103, 10 } }); + STATIC_REQUIRE(mixed.element(2).is_absent()); + STATIC_REQUIRE(mixed.element(3) == formula::Measured { formula::Rational { 1, 3 } }); + // The old spelling is unchanged. + constexpr auto spelled = formula::measured_series(formula::Measured { 127 }, formula::Measured::absent()); + STATIC_REQUIRE(spelled.element(0) == mixed.element(0)); + STATIC_REQUIRE(spelled.element(1).is_absent()); +} diff --git a/test/lookup_tests.cpp b/test/lookup_tests.cpp index b54d164b..b5b4ad6f 100644 --- a/test/lookup_tests.cpp +++ b/test/lookup_tests.cpp @@ -1167,3 +1167,10 @@ TEST_CASE("a row hit can still overflow in the result-unit conversion, and says STATIC_REQUIRE(!computed.has_value()); STATIC_REQUIRE(computed.error() == formula::ArithmeticError::Overflow); } + +TEST_CASE("breakpoint: a key given as an exact number", "[lookup]") +{ + using namespace formula::literals; + STATIC_REQUIRE(formula::breakpoint(12.7_r) == formula::breakpoint(127, 10)); + STATIC_REQUIRE(formula::breakpoint(127) == formula::Breakpoint { 127, 1 }); // the integer overload still wins +} diff --git a/test/measured_tests.cpp b/test/measured_tests.cpp index fdd63593..2e49cfda 100644 --- a/test/measured_tests.cpp +++ b/test/measured_tests.cpp @@ -493,3 +493,8 @@ TEST_CASE("a price converts from cents to euros exactly and never from euros to REQUIRE_FALSE(absentInYen.has_value()); CHECK(absentInYen.error() == ArithmeticError::DomainError); } + +TEST_CASE("Measured: an integer is a present value without spelling Rational", "[measured]") +{ + STATIC_REQUIRE(formula::Measured { 139 }.value() == formula::Rational { 139 }); +} diff --git a/test/negative/measured_series_element_double.cpp b/test/negative/measured_series_element_double.cpp new file mode 100644 index 00000000..a25baf7f --- /dev/null +++ b/test/negative/measured_series_element_double.cpp @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: a floating-point value is not an exact rational +// +// A series element written as a double. `Rational` refuses it in its own +// words; the series' own check stays silent, so the one mistake is one message. +#include + +struct Retained: formula::Quantity +{ +}; + +inline constexpr auto screens = formula::measured_series(127, 10.3); + +int main() +{ + return screens.element(0).has_value() ? 0 : 1; +} diff --git a/test/negative/measured_series_element_other_quantity.cpp b/test/negative/measured_series_element_other_quantity.cpp new file mode 100644 index 00000000..c11f4865 --- /dev/null +++ b/test/negative/measured_series_element_other_quantity.cpp @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: an element of measured_series is a Measured of that one quantity +// +// A series of retained masses with one element that is a measurement of +// another quantity. It must not compile, and the mistake draws this one +// message, not a list of overloads or of conversions that do not exist. +#include + +struct Retained: formula::Quantity +{ +}; +struct Passing: formula::Quantity +{ +}; + +inline constexpr auto screens = formula::measured_series(127, formula::Measured { 139 }); + +int main() +{ + return screens.element(0).has_value() ? 0 : 1; +} diff --git a/test/negative/measured_series_element_wide_unsigned.cpp b/test/negative/measured_series_element_wide_unsigned.cpp new file mode 100644 index 00000000..2c7cae66 --- /dev/null +++ b/test/negative/measured_series_element_wide_unsigned.cpp @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: this unsigned type can hold values above Rational's maximum +// +// A series element written as a 64-bit unsigned integer, which can hold values +// `Rational` cannot. `Rational` refuses it in its own words; the series' own +// check stays silent, so the one mistake is one message. +#include + +#include + +struct Retained: formula::Quantity +{ +}; + +inline constexpr auto screens = formula::measured_series(127, std::uint64_t { 139 }); + +int main() +{ + return screens.element(0).has_value() ? 0 : 1; +} From 05b93b72969fbb9c1147d3f5d8cf29bc533f0d10 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 17:36:41 +0200 Subject: [PATCH 04/59] test(rational): pin every refusal of the _r literal A hexadecimal spelling with an E in it, such as 0x1E, looks like it has an exponent, so the leading-zero check is skipped and only the digit check in the loop refuses the x. Nothing pinned that check: without it the x would be read as a digit and the literal would yield a wrong number silently. It now has a negative case and a comment saying why it must stay. Also pin the spellings a reader is most likely to get wrong: 00.5 and 0e3 are exact, 1e-18 is the largest exact denominator, and 1e19 is refused by name however it is spelled. Signed-off-by: Christian Parpart --- include/formula-cpp/rational.hpp | 2 +- test/CMakeLists.txt | 2 ++ .../rational_literal_exponent_out_of_range.cpp | 12 ++++++++++++ .../rational_literal_hex_with_exponent_letter.cpp | 12 ++++++++++++ test/rational_literal_tests.cpp | 7 +++++++ 5 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 test/negative/rational_literal_exponent_out_of_range.cpp create mode 100644 test/negative/rational_literal_hex_with_exponent_letter.cpp diff --git a/include/formula-cpp/rational.hpp b/include/formula-cpp/rational.hpp index 7d3f7a0b..c0259cf2 100644 --- a/include/formula-cpp/rational.hpp +++ b/include/formula-cpp/rational.hpp @@ -564,7 +564,7 @@ namespace detail inFraction = true; continue; } - if (symbolAt < '0' || symbolAt > '9') + if (symbolAt < '0' || symbolAt > '9') // the only refusal of a hexadecimal spelling holding e or E, such as 0x1E formula_rational_literal_not_a_decimal(); int const digitValue = symbolAt - '0'; if (inFraction && digitValue == 0) diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 8af7042b..1cefb349 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -297,6 +297,8 @@ formula_add_negative_test(rational_literal_out_of_range "formula_rational_litera formula_add_negative_test(rational_literal_too_many_places "formula_rational_literal_out_of_range") formula_add_negative_test(rational_literal_not_a_decimal "formula_rational_literal_not_a_decimal") formula_add_negative_test(rational_literal_octal "formula_rational_literal_not_a_decimal") +formula_add_negative_test(rational_literal_hex_with_exponent_letter "formula_rational_literal_not_a_decimal") +formula_add_negative_test(rational_literal_exponent_out_of_range "formula_rational_literal_out_of_range") formula_add_negative_test(dimension_mismatch "formula: these two dimensions are not the same") diff --git a/test/negative/rational_literal_exponent_out_of_range.cpp b/test/negative/rational_literal_exponent_out_of_range.cpp new file mode 100644 index 00000000..9a0c98f2 --- /dev/null +++ b/test/negative/rational_literal_exponent_out_of_range.cpp @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 +// 1e19 is an integer no Rational holds, so it is refused however it is spelled. +// This must not compile. +#include + +using namespace formula::literals; + +constexpr formula::Rational refused = 1e19_r; + +int main() +{ +} diff --git a/test/negative/rational_literal_hex_with_exponent_letter.cpp b/test/negative/rational_literal_hex_with_exponent_letter.cpp new file mode 100644 index 00000000..0d2a5467 --- /dev/null +++ b/test/negative/rational_literal_hex_with_exponent_letter.cpp @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 +// A hexadecimal spelling with an E in it is not a decimal either, though the E looks like an exponent. +// This must not compile. +#include + +using namespace formula::literals; + +constexpr formula::Rational refused = 0x1E_r; + +int main() +{ +} diff --git a/test/rational_literal_tests.cpp b/test/rational_literal_tests.cpp index b625cd47..5443ea57 100644 --- a/test/rational_literal_tests.cpp +++ b/test/rational_literal_tests.cpp @@ -43,3 +43,10 @@ TEST_CASE("_r: a minus sign is Rational's own negation", "[rational][literal]") { STATIC_REQUIRE(-27.3_r == Rational { -273, 10 }); } + +TEST_CASE("_r: the edges of what is exact", "[rational][literal]") +{ + STATIC_REQUIRE(00.5_r == Rational { 1, 2 }); // a leading zero is fine once there is a point + STATIC_REQUIRE(0e3_r == Rational {}); + STATIC_REQUIRE(1e-18_r == Rational { 1, 1'000'000'000'000'000'000 }); // the largest exact denominator +} From 463a333737adf520e96e5b0e05d51fa355f30663 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 17:38:44 +0200 Subject: [PATCH 05/59] test(environment): pin the refusal of a bool series element A bool is not an exact number: Rational is deliberately not built from one, so `measured_series(127, true)` must be refused, and by one message rather than an overload list or a second refusal from the conversion that would follow. The sibling cases pin a Measured of another quantity, a double and a wide unsigned integer; a bool was the one wrong element left unpinned. Signed-off-by: Christian Parpart --- test/CMakeLists.txt | 3 +++ test/negative/measured_series_element_bool.cpp | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 test/negative/measured_series_element_bool.cpp diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 1cefb349..620def38 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -443,6 +443,9 @@ formula_add_negative_test(measured_series_short formula_add_negative_test(measured_series_element_other_quantity "formula: an element of measured_series is a Measured of that one quantity" EXPECT_COUNT 1 REJECT "no matching" "cannot convert" "could not convert") +formula_add_negative_test(measured_series_element_bool + "formula: an element of measured_series is a Measured of that one quantity" EXPECT_COUNT 1 + REJECT "no matching" "cannot convert" "could not convert") formula_add_negative_test(measured_series_element_double "formula: a floating-point value is not an exact rational" EXPECT_COUNT 1 REJECT "an element of measured_series") diff --git a/test/negative/measured_series_element_bool.cpp b/test/negative/measured_series_element_bool.cpp new file mode 100644 index 00000000..3c571b38 --- /dev/null +++ b/test/negative/measured_series_element_bool.cpp @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: an element of measured_series is a Measured of that one quantity +// +// A series element written as a bool. `Rational` is deliberately not built from +// a bool, so the series' own check refuses it, once; the conversion that would +// follow stays silent, so the one mistake is one message and no overload list. +#include + +struct Retained: formula::Quantity +{ +}; + +inline constexpr auto screens = formula::measured_series(127, true); + +int main() +{ + return screens.element(0).has_value() ? 0 : 1; +} From ac22e9927f7bfb3716cb44fae20eb9764c9506f1 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 17:41:39 +0200 Subject: [PATCH 06/59] feat(outcome): add number_of, the number a result holds or nothing Reading a number out of a result meant choosing between is_value(), measurement().value() and the std::expected around it, and forgetting one check turned an absent number, a verdict or an error into a throw or a zero. number_of returns a std::optional for a Measured, an Outcome, a checked_evaluate result, an Evaluated, a RetryOutcome and a RejectionOutcome: the number when there is one, nothing otherwise. Because comparing an empty optional with a number is false, number_of(checked_evaluate(...)) == 0.5_r is a complete check. Zero is not used for "no number", since zero is a measurement. Signed-off-by: Christian Parpart --- CHANGELOG.md | 4 +++ docs/expressions.md | 23 +++++++++++++++++ include/formula-cpp/outcome.hpp | 40 +++++++++++++++++++++++++++++ include/formula-cpp/rejection.hpp | 8 ++++++ include/formula-cpp/retry.hpp | 7 +++++ test/consumer_globals_run_tests.cpp | 2 +- test/consumer_globals_tests.cpp | 9 +++++++ test/outcome_tests.cpp | 35 +++++++++++++++++++++++++ test/rejection_tests.cpp | 9 +++++++ test/retry_tests.cpp | 16 +++++++++++- 10 files changed, 151 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc5d8cf8..37b375d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ change is recorded here. `measured_series(127, 10.3_r, not_measured)`. An element that is none of these draws one message. `band(low, high)` takes its bounds, and `breakpoint(key)` its key, as exact numbers: `band(83.7_r, 97.3_r)`, `breakpoint(12.7_r)`. Every earlier spelling stays. +- `number_of(x)`, the number a result holds or nothing, as a `std::optional`. It reads a + `Measured`, an `Outcome`, a `checked_evaluate` result, an `Evaluated`, a + `RetryOutcome` and a `RejectionOutcome`, and is empty for an absent number, an error, a verdict + and an invalid result, so `number_of(checked_evaluate(...)) == 0.5_r` is a complete check. ## [0.2.0] - 2026-09-30 diff --git a/docs/expressions.md b/docs/expressions.md index 9382e677..2f0bd1c4 100644 --- a/docs/expressions.md +++ b/docs/expressions.md @@ -250,6 +250,29 @@ both the value and the fact that it was entered: w/c = 0.500000 (entered) ``` +## Reading a result + +Most of the time a caller wants only the number, and needs to know that there +may be none. `formula::number_of(x)` returns a `std::optional`: the +number `x` holds, or nothing. It reads a `Measured`, an `Outcome`, the +`std::expected` that `checked_evaluate` returns, an `Evaluated`, a +`RetryOutcome` and a `RejectionOutcome`: + +```cpp +using namespace formula::literals; + +// 0.5 when the formula evaluates to a number; nothing when an input was never +// measured, when the arithmetic failed, and for a verdict or an invalid result. +bool const isHalf = formula::number_of(formula::checked_evaluate(ratio, batch)) == 0.5_r; +``` + +It is an `optional` and not a zero because zero is a measurement: a specimen +that weighed nothing and a specimen never weighed are different results. +`optional == Rational` is false when the optional is empty, so the comparison +above is a complete check -- an absent number, an error and a verdict all +compare unequal to every number. `number_of` says nothing about *why* there is +no number; ask `Outcome::kind()` or the error for that. + ## Choosing a representation Two entry points evaluate a formula, and they answer different questions: diff --git a/include/formula-cpp/outcome.hpp b/include/formula-cpp/outcome.hpp index cf5155f5..2bec842f 100644 --- a/include/formula-cpp/outcome.hpp +++ b/include/formula-cpp/outcome.hpp @@ -19,8 +19,11 @@ #include #include +#include #include +#include +#include #include namespace formula @@ -194,4 +197,41 @@ class Outcome InvalidReason _reason {}; }; + +/// The number @p measured holds, or nothing when it is absent. +template +[[nodiscard]] constexpr std::optional number_of(Measured const& measured) noexcept +{ + return measured.stored(); +} + +/// The number @p produced holds when it is a value -- derived, measured or +/// entered -- and nothing for an empty, a verdict or an invalid outcome, +/// which `kind()` tells apart. +template +[[nodiscard]] constexpr std::optional number_of(Outcome const& produced) noexcept +{ + if (!produced.is_value()) + return std::nullopt; + Measured const held = produced.measurement(); + return held.stored(); +} + +/// @p held itself: what `Evaluated` holds on success. +[[nodiscard]] constexpr std::optional number_of(std::optional const& held) noexcept +{ + return held; +} + +/// The number a successful @p checked holds, or nothing on failure -- so +/// `formula::number_of(checked_evaluate(...)) == 0.5_r` is a complete check. +template + requires requires(T const& succeeded) { number_of(succeeded); } +[[nodiscard]] constexpr std::optional number_of(std::expected const& checked) noexcept +{ + if (!checked.has_value()) + return std::nullopt; + return number_of(*checked); +} + } // namespace formula diff --git a/include/formula-cpp/rejection.hpp b/include/formula-cpp/rejection.hpp index ba2d7698..2672ef46 100644 --- a/include/formula-cpp/rejection.hpp +++ b/include/formula-cpp/rejection.hpp @@ -741,6 +741,14 @@ class RejectionOutcome std::size_t _passes = 0; }; +/// The number the outcome of a rejection holds -- the mean of the survivors -- +/// or nothing for a verdict or an empty result; see `number_of(Outcome)`. +template +[[nodiscard]] constexpr std::optional number_of(RejectionOutcome const& rejected) noexcept +{ + return number_of(rejected.outcome()); +} + namespace detail { /// How a rejection ended. diff --git a/include/formula-cpp/retry.hpp b/include/formula-cpp/retry.hpp index 64eb94dd..bee8dcdc 100644 --- a/include/formula-cpp/retry.hpp +++ b/include/formula-cpp/retry.hpp @@ -1246,6 +1246,13 @@ class RetryOutcome std::optional _acceptedAt; }; +/// The number the outcome a retry ended with holds -- see `number_of(Outcome)`. +template +[[nodiscard]] constexpr std::optional number_of(RetryOutcome const& ended) noexcept +{ + return number_of(ended.outcome()); +} + namespace detail { /// The one place a `RetryOutcome` is made. diff --git a/test/consumer_globals_run_tests.cpp b/test/consumer_globals_run_tests.cpp index 9525400d..68ec205f 100644 --- a/test/consumer_globals_run_tests.cpp +++ b/test/consumer_globals_run_tests.cpp @@ -13,7 +13,7 @@ TEST_CASE("a consumer's ordinary globals do not break a build that includes every header", "[hygiene]") { ConsumerGlobalsProbe const probe = probe_consumer_globals(); - REQUIRE(probe.checks.size() == 89); + REQUIRE(probe.checks.size() == 90); for (std::size_t index = 0; index < probe.checks.size(); ++index) { INFO("check " << index); diff --git a/test/consumer_globals_tests.cpp b/test/consumer_globals_tests.cpp index b5ec7c40..43b18d38 100644 --- a/test/consumer_globals_tests.cpp +++ b/test/consumer_globals_tests.cpp @@ -1242,5 +1242,14 @@ ConsumerGlobalsProbe probe_consumer_globals() && formula::band(83.7_r, 97.3_r) == formula::band(837, 10, 973, 10) && formula::breakpoint(12.7_r) == formula::breakpoint(127, 10)); } + // The number a result holds, or nothing: a value's, a retry's accepted + // value, and an error's nothing. + probe.checks.push_back(formula::number_of(plain).has_value() + && formula::number_of(plain) == plain.measurement().value() + && formula::number_of(checked) == formula::number_of(plain) + && formula::number_of(edgesRetried) == formula::Rational { 163 } + && !formula::number_of(std::expected, formula::ArithmeticError> { + std::unexpected { formula::ArithmeticError::Overflow } }) + .has_value()); return probe; } diff --git a/test/outcome_tests.cpp b/test/outcome_tests.cpp index f917b313..83b70da5 100644 --- a/test/outcome_tests.cpp +++ b/test/outcome_tests.cpp @@ -1,8 +1,13 @@ // SPDX-License-Identifier: Apache-2.0 +#include +#include #include #include +#include +#include + namespace { @@ -134,3 +139,33 @@ TEST_CASE("outcome: the label of a non-verdict outcome is empty rather than stal STATIC_REQUIRE(outcome.verdict_label().empty()); STATIC_REQUIRE(outcome.reason_label().empty()); } + +TEST_CASE("number_of: the number of a value, nothing for every other kind", "[outcome]") +{ + using MassOutcome = formula::Outcome; + constexpr formula::Measured prime { rat(139) }; + STATIC_REQUIRE(formula::number_of(MassOutcome::value(prime, formula::ValueSource::Derived)) == rat(139)); + STATIC_REQUIRE(formula::number_of(MassOutcome::value(prime, formula::ValueSource::ManuallyEntered)) == rat(139)); + STATIC_REQUIRE(!formula::number_of(MassOutcome::empty()).has_value()); + STATIC_REQUIRE(!formula::number_of(MassOutcome::verdict(formula::Verdict { "repeat the test" })).has_value()); + STATIC_REQUIRE(!formula::number_of(MassOutcome::invalid(formula::InvalidReason { "discarded" })).has_value()); + STATIC_REQUIRE(formula::number_of(prime) == rat(139)); + STATIC_REQUIRE(!formula::number_of(formula::Measured::absent()).has_value()); +} + +TEST_CASE("number_of: an error is nothing, a success is its number", "[outcome]") +{ + using Checked = std::expected, formula::ArithmeticError>; + constexpr Checked succeeded = + formula::Outcome::value(formula::Measured { rat(163) }, formula::ValueSource::Derived); + constexpr Checked failed = std::unexpected { formula::ArithmeticError::Overflow }; + STATIC_REQUIRE(formula::number_of(succeeded) == rat(163)); + STATIC_REQUIRE(!formula::number_of(failed).has_value()); + // Evaluated is an expected of an optional. + constexpr formula::Evaluated evaluated = std::optional { rat(197) }; + constexpr formula::Evaluated notMeasured = std::optional {}; + constexpr formula::Evaluated refused = std::unexpected { formula::ArithmeticError::Overflow }; + STATIC_REQUIRE(formula::number_of(evaluated) == rat(197)); + STATIC_REQUIRE(!formula::number_of(notMeasured).has_value()); + STATIC_REQUIRE(!formula::number_of(refused).has_value()); +} diff --git a/test/rejection_tests.cpp b/test/rejection_tests.cpp index b3e7ce79..e16256ea 100644 --- a/test/rejection_tests.cpp +++ b/test/rejection_tests.cpp @@ -1458,3 +1458,12 @@ TEST_CASE("an observation that cannot be read fails a rejection at its own posit STATIC_REQUIRE(failed.error().error == formula::ArithmeticError::Overflow); STATIC_REQUIRE(*failed.error().element == 1); } + +TEST_CASE("number_of a rejection is the mean of the survivors, and nothing for its verdict", "[rejection]") +{ + constexpr auto settled = formula::checked_evaluate_rejection(rejectionA, fixtureA); + constexpr auto aborted = formula::checked_evaluate_rejection(rejectionA1, fixtureA); + STATIC_REQUIRE(formula::number_of(*settled) == rat(321, 8)); + STATIC_REQUIRE(formula::number_of(settled) == rat(321, 8)); + STATIC_REQUIRE(!formula::number_of(*aborted).has_value()); +} diff --git a/test/retry_tests.cpp b/test/retry_tests.cpp index ce9c038e..aa948068 100644 --- a/test/retry_tests.cpp +++ b/test/retry_tests.cpp @@ -1342,4 +1342,18 @@ TEST_CASE("the attempt's environment has neither hook when the specimen's has ne CHECK(typedReads == 4); CHECK(formula::render_trace(explained.trace, { .maxSteps = 60 }).find("s_w = 152/25 g, entered by hand\n") != std::string::npos); -} \ No newline at end of file +} + +TEST_CASE("number_of a retry is the accepted value, and nothing for its verdict", "[retry]") +{ + constexpr auto four = + formula::retry(fromZero, halving, settled, repeat, cite); + constexpr auto three = + formula::retry(fromZero, halving, settled, repeat, cite); + constexpr auto accepted = formula::checked_evaluate_retry(four, nothing); + constexpr auto exhausted = formula::checked_evaluate_retry(three, nothing); + // The accepted 11.4 g, not the exhausted run's last value, 10.64 g. + STATIC_REQUIRE(formula::number_of(*accepted) == rat(57, 5)); + STATIC_REQUIRE(formula::number_of(accepted) == rat(57, 5)); + STATIC_REQUIRE(!formula::number_of(*exhausted).has_value()); +} From c3fa208af882eb6e764f2664a71727a9f7a6b7d9 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 17:45:38 +0200 Subject: [PATCH 07/59] feat: describe constraint outcomes, retry ends, value sources, outcome kinds and failure sites Five public enums had no words of their own, so every caller that wanted to print one wrote a switch: the two examples each carried a local describe(ConstraintOutcomeKind). The library now gives each a lowercase phrase with no trailing punctuation, as describe(ArithmeticError) does. The two local helpers are removed, since an unqualified call would now be ambiguous with the library's by argument-dependent lookup. Their words equal the library's, so the examples print exactly what they did. The CHANGELOG records the rule for a consumer's own describe of these enums. Signed-off-by: Christian Parpart --- CHANGELOG.md | 12 +++++++++++ examples/constraints.cpp | 16 --------------- examples/methods_and_overlays.cpp | 16 --------------- include/formula-cpp/constraint.hpp | 17 ++++++++++++++++ include/formula-cpp/outcome.hpp | 32 ++++++++++++++++++++++++++++++ include/formula-cpp/retry.hpp | 21 ++++++++++++++++++++ include/formula-cpp/series.hpp | 13 ++++++++++++ test/constraint_tests.cpp | 8 ++++++++ test/outcome_tests.cpp | 15 ++++++++++++++ test/retry_tests.cpp | 10 ++++++++++ test/series_tests.cpp | 6 ++++++ 11 files changed, 134 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37b375d2..aee17452 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,18 @@ change is recorded here. `Measured`, an `Outcome`, a `checked_evaluate` result, an `Evaluated`, a `RetryOutcome` and a `RejectionOutcome`, and is empty for an absent number, an error, a verdict and an invalid result, so `number_of(checked_evaluate(...)) == 0.5_r` is a complete check. +- `describe` of a `ConstraintOutcomeKind`, a `RetryEnd`, a `ValueSource`, an `OutcomeKind` and a + `FailureSite`: a lowercase phrase with no trailing punctuation, as `describe` of an + `ArithmeticError` already gave -- `satisfied`, `not checked`, `manually entered`, `verdict`, + `result element`. + +### Changed + +- An unqualified call of `describe` with a `ConstraintOutcomeKind`, a `RetryEnd`, a `ValueSource`, + an `OutcomeKind` or a `FailureSite` now finds the library's function by argument-dependent + lookup. A consumer's own `describe` for one of these enums -- a `describe(ConstraintOutcomeKind)` + helper, say -- now makes such a call ambiguous, and has to be renamed or removed, as + `examples/constraints.cpp`'s was, or called by a qualified name such as `::describe`. ## [0.2.0] - 2026-09-30 diff --git a/examples/constraints.cpp b/examples/constraints.cpp index b14c9199..596494c7 100644 --- a/examples/constraints.cpp +++ b/examples/constraints.cpp @@ -75,22 +75,6 @@ constexpr auto dividesByZero = formula::Measured::absent()); } -[[nodiscard]] std::string_view describe(formula::ConstraintOutcomeKind kind) -{ - switch (kind) - { - case formula::ConstraintOutcomeKind::Satisfied: - return "satisfied"; - case formula::ConstraintOutcomeKind::Violated: - return "violated"; - case formula::ConstraintOutcomeKind::NotChecked: - return "not checked"; - case formula::ConstraintOutcomeKind::Invalid: - return "invalid"; - } - return "unknown"; -} - // Checks @p subject against @p environment through a fresh RecordingSink and // renders the one-step trace it produced -- the same shape // examples/rounding_and_conditionals.cpp uses for a Node's own trace, just diff --git a/examples/methods_and_overlays.cpp b/examples/methods_and_overlays.cpp index e19827de..942fc2fa 100644 --- a/examples/methods_and_overlays.cpp +++ b/examples/methods_and_overlays.cpp @@ -216,22 +216,6 @@ inline constexpr auto westRevised = formula::overlay(formula::with_constraints( inline constexpr auto western = formula::apply(west, compressiveStrength); inline constexpr auto westernRevised = formula::apply(westRevised, western); -[[nodiscard]] std::string_view describe(formula::ConstraintOutcomeKind kind) -{ - switch (kind) - { - case formula::ConstraintOutcomeKind::Satisfied: - return "satisfied"; - case formula::ConstraintOutcomeKind::Violated: - return "violated"; - case formula::ConstraintOutcomeKind::NotChecked: - return "not checked"; - case formula::ConstraintOutcomeKind::Invalid: - return "invalid"; - } - return "unknown"; -} - template void print_outcomes(char const* method, std::array const& outcomes) { diff --git a/include/formula-cpp/constraint.hpp b/include/formula-cpp/constraint.hpp index 548cb398..3ede30e0 100644 --- a/include/formula-cpp/constraint.hpp +++ b/include/formula-cpp/constraint.hpp @@ -57,6 +57,23 @@ enum class ConstraintOutcomeKind : std::uint8_t Invalid, }; +/// A lowercase phrase with no trailing punctuation, so callers can embed it in a longer sentence. +[[nodiscard]] constexpr std::string_view describe(ConstraintOutcomeKind judged) noexcept +{ + switch (judged) + { + case ConstraintOutcomeKind::Satisfied: + return "satisfied"; + case ConstraintOutcomeKind::Violated: + return "violated"; + case ConstraintOutcomeKind::NotChecked: + return "not checked"; + case ConstraintOutcomeKind::Invalid: + return "invalid"; + } + return "unknown constraint outcome"; +} + /// What checking a `Constraint` produced. /// /// **A constraint whose predicate could not be evaluated must never report diff --git a/include/formula-cpp/outcome.hpp b/include/formula-cpp/outcome.hpp index 2bec842f..165e5a0c 100644 --- a/include/formula-cpp/outcome.hpp +++ b/include/formula-cpp/outcome.hpp @@ -42,6 +42,21 @@ enum class ValueSource : std::uint8_t ManuallyEntered, }; +/// A lowercase phrase with no trailing punctuation, so callers can embed it in a longer sentence. +[[nodiscard]] constexpr std::string_view describe(ValueSource valueSource) noexcept +{ + switch (valueSource) + { + case ValueSource::Derived: + return "derived"; + case ValueSource::Measured: + return "measured"; + case ValueSource::ManuallyEntered: + return "manually entered"; + } + return "unknown value source"; +} + /// Which alternative an `Outcome` holds. /// /// There is deliberately no `Overridden` alternative: an override is the `Value` @@ -56,6 +71,23 @@ enum class OutcomeKind : std::uint8_t Invalid, }; +/// A lowercase phrase with no trailing punctuation, so callers can embed it in a longer sentence. +[[nodiscard]] constexpr std::string_view describe(OutcomeKind held) noexcept +{ + switch (held) + { + case OutcomeKind::Value: + return "value"; + case OutcomeKind::Empty: + return "empty"; + case OutcomeKind::Verdict: + return "verdict"; + case OutcomeKind::Invalid: + return "invalid"; + } + return "unknown outcome kind"; +} + /// A decision rather than a number: "reject the specimen", "repeat the test". struct Verdict { diff --git a/include/formula-cpp/retry.hpp b/include/formula-cpp/retry.hpp index bee8dcdc..5eb20674 100644 --- a/include/formula-cpp/retry.hpp +++ b/include/formula-cpp/retry.hpp @@ -109,6 +109,27 @@ enum class RetryEnd : std::uint8_t ManuallyEntered, }; +/// A lowercase phrase with no trailing punctuation, so callers can embed it in a longer sentence. +[[nodiscard]] constexpr std::string_view describe(RetryEnd ended) noexcept +{ + switch (ended) + { + case RetryEnd::Accepted: + return "accepted"; + case RetryEnd::Exhausted: + return "exhausted"; + case RetryEnd::NotJudgeable: + return "not judgeable"; + case RetryEnd::NotRecorded: + return "not recorded"; + case RetryEnd::Failed: + return "failed"; + case RetryEnd::ManuallyEntered: + return "manually entered"; + } + return "unknown retry end"; +} + /// The most attempts a retry may allow. The methods this shape exists for /// 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 diff --git a/include/formula-cpp/series.hpp b/include/formula-cpp/series.hpp index 28a63d21..8d769ace 100644 --- a/include/formula-cpp/series.hpp +++ b/include/formula-cpp/series.hpp @@ -746,6 +746,19 @@ enum class FailureSite : std::uint8_t InputObservation, }; +/// A lowercase phrase with no trailing punctuation, so callers can embed it in a longer sentence. +[[nodiscard]] constexpr std::string_view describe(FailureSite site) noexcept +{ + switch (site) + { + case FailureSite::ResultElement: + return "result element"; + case FailureSite::InputObservation: + return "input observation"; + } + return "unknown failure site"; +} + /// Why a series could not be evaluated, and where. struct SeriesFailure { diff --git a/test/constraint_tests.cpp b/test/constraint_tests.cpp index 41d77865..cb5b75dd 100644 --- a/test/constraint_tests.cpp +++ b/test/constraint_tests.cpp @@ -232,3 +232,11 @@ TEST_CASE("constraint set: a not-checked constraint does not suppress a violated CHECK(outcomes[1].verdict()->label == std::string_view { "reject the specimen" }); } } + +TEST_CASE("constraint outcome kinds describe themselves in lowercase words", "[constraint]") +{ + STATIC_REQUIRE(formula::describe(formula::ConstraintOutcomeKind::Satisfied) == "satisfied"); + STATIC_REQUIRE(formula::describe(formula::ConstraintOutcomeKind::Violated) == "violated"); + STATIC_REQUIRE(formula::describe(formula::ConstraintOutcomeKind::NotChecked) == "not checked"); + STATIC_REQUIRE(formula::describe(formula::ConstraintOutcomeKind::Invalid) == "invalid"); +} diff --git a/test/outcome_tests.cpp b/test/outcome_tests.cpp index 83b70da5..fb3aa9b7 100644 --- a/test/outcome_tests.cpp +++ b/test/outcome_tests.cpp @@ -169,3 +169,18 @@ TEST_CASE("number_of: an error is nothing, a success is its number", "[outcome]" STATIC_REQUIRE(!formula::number_of(notMeasured).has_value()); STATIC_REQUIRE(!formula::number_of(refused).has_value()); } + +TEST_CASE("value sources describe themselves in lowercase words", "[outcome]") +{ + STATIC_REQUIRE(formula::describe(formula::ValueSource::Derived) == "derived"); + STATIC_REQUIRE(formula::describe(formula::ValueSource::Measured) == "measured"); + STATIC_REQUIRE(formula::describe(formula::ValueSource::ManuallyEntered) == "manually entered"); +} + +TEST_CASE("outcome kinds describe themselves in lowercase words", "[outcome]") +{ + STATIC_REQUIRE(formula::describe(formula::OutcomeKind::Value) == "value"); + STATIC_REQUIRE(formula::describe(formula::OutcomeKind::Empty) == "empty"); + STATIC_REQUIRE(formula::describe(formula::OutcomeKind::Verdict) == "verdict"); + STATIC_REQUIRE(formula::describe(formula::OutcomeKind::Invalid) == "invalid"); +} diff --git a/test/retry_tests.cpp b/test/retry_tests.cpp index aa948068..6a932703 100644 --- a/test/retry_tests.cpp +++ b/test/retry_tests.cpp @@ -1357,3 +1357,13 @@ TEST_CASE("number_of a retry is the accepted value, and nothing for its verdict" STATIC_REQUIRE(formula::number_of(accepted) == rat(57, 5)); STATIC_REQUIRE(!formula::number_of(*exhausted).has_value()); } + +TEST_CASE("retry ends describe themselves in lowercase words", "[retry]") +{ + STATIC_REQUIRE(formula::describe(formula::RetryEnd::Accepted) == "accepted"); + STATIC_REQUIRE(formula::describe(formula::RetryEnd::Exhausted) == "exhausted"); + STATIC_REQUIRE(formula::describe(formula::RetryEnd::NotJudgeable) == "not judgeable"); + STATIC_REQUIRE(formula::describe(formula::RetryEnd::NotRecorded) == "not recorded"); + STATIC_REQUIRE(formula::describe(formula::RetryEnd::Failed) == "failed"); + STATIC_REQUIRE(formula::describe(formula::RetryEnd::ManuallyEntered) == "manually entered"); +} diff --git a/test/series_tests.cpp b/test/series_tests.cpp index 58aa4997..ca3d5994 100644 --- a/test/series_tests.cpp +++ b/test/series_tests.cpp @@ -952,3 +952,9 @@ TEST_CASE("a per-element rounding is a series node carrying its unit, table and STATIC_REQUIRE(Rounding::mode == formula::RoundingMode::Floor); STATIC_REQUIRE_FALSE(Rounding::refused); } + +TEST_CASE("failure sites describe themselves in lowercase words", "[series]") +{ + STATIC_REQUIRE(formula::describe(formula::FailureSite::ResultElement) == "result element"); + STATIC_REQUIRE(formula::describe(formula::FailureSite::InputObservation) == "input observation"); +} From cdedb0335145f08e230b2e8c392601e774a3a3b2 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 17:53:29 +0200 Subject: [PATCH 08/59] feat(format): format outcomes, units, dimensions and enumerations; default symbol_of's vocabulary The examples and a consumer's own output print results, units, dimensions and the words of an enumeration, and each of those needed a hand-written helper. std::format now writes an Outcome (a value as a Measured, an empty one as "(not measured)", a verdict or invalid one as its label), a Unit (its symbol), a Dimension (L^2 M^-3, L^(1/2), (dimensionless)) and every enumeration with a describe(), which lists itself beside that describe() so format.hpp includes no further header. symbol_of() without a vocabulary is the declared symbol, and render() and document() take RenderOptions without a vocabulary that renames nothing. Two hygiene checks learn the new spellings: the forwarding overloads that pass RenderOptions on, and format.hpp's use of . The documented diagnostic quoted from format.hpp follows its line. Signed-off-by: Christian Parpart --- CHANGELOG.md | 9 ++ cmake/CheckPublicHeaderIncludes.cmake | 2 +- cmake/CheckVocabularyReach.cmake | 5 +- docs/display.md | 4 +- include/formula-cpp/constraint.hpp | 7 ++ include/formula-cpp/curve.hpp | 6 + include/formula-cpp/document.hpp | 10 ++ include/formula-cpp/error.hpp | 9 ++ include/formula-cpp/format.hpp | 169 ++++++++++++++++++++++++-- include/formula-cpp/outcome.hpp | 13 +- include/formula-cpp/render.hpp | 10 ++ include/formula-cpp/retry.hpp | 6 + include/formula-cpp/rounding.hpp | 6 + include/formula-cpp/series.hpp | 12 ++ include/formula-cpp/snap.hpp | 6 + include/formula-cpp/trace.hpp | 6 + include/formula-cpp/unit.hpp | 6 + include/formula-cpp/vocabulary.hpp | 7 +- test/consumer_globals_run_tests.cpp | 2 +- test/consumer_globals_tests.cpp | 24 ++++ test/document_tests.cpp | 4 + test/format_tests.cpp | 27 ++++ test/render_tests.cpp | 11 ++ test/vocabulary_tests.cpp | 7 ++ 24 files changed, 346 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aee17452..a8702bb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,15 @@ change is recorded here. `FailureSite`: a lowercase phrase with no trailing punctuation, as `describe` of an `ArithmeticError` already gave -- `satisfied`, `not checked`, `manually entered`, `verdict`, `result element`. +- `std::format` writes an `Outcome`, a `Unit`, a `Dimension` and every enumeration that has a + `describe()`, with `` included. An `Outcome` takes a `Measured`'s spec and + writes a value as it does, an empty one as `(not measured)`, and a verdict or an invalid one as + its label, padded by the spec's fill, alignment and width. A `Unit` is its symbol (`kJ`); a + `Dimension` its exponents (`L^2 M^-3`, `L^(1/2)`, `(dimensionless)`); an enumeration its words + (`overflow in exact arithmetic`), aligned as a string is. +- `symbol_of()` without a vocabulary is `Describe::symbol`, and `render(x, options)`, + `render(x, options)`, `document(x, options)` and `document(x, options)` take + `RenderOptions` without a vocabulary that renames nothing. ### Changed diff --git a/cmake/CheckPublicHeaderIncludes.cmake b/cmake/CheckPublicHeaderIncludes.cmake index 2727699f..1f93a927 100644 --- a/cmake/CheckPublicHeaderIncludes.cmake +++ b/cmake/CheckPublicHeaderIncludes.cmake @@ -131,7 +131,7 @@ set(exemptAllowances "trace.hpp=vector" "trace_render.hpp=string" "latex_math.hpp=string" - "format.hpp=format" + "format.hpp=format,string" ) set(overreaches "") diff --git a/cmake/CheckVocabularyReach.cmake b/cmake/CheckVocabularyReach.cmake index 356bc2c6..fe6da023 100644 --- a/cmake/CheckVocabularyReach.cmake +++ b/cmake/CheckVocabularyReach.cmake @@ -50,8 +50,9 @@ foreach(file IN LISTS surfaces) # Whole-line comments: the doc comments in these headers name # `Describe::symbol` and one-argument calls freely. string(REGEX REPLACE "\n[ \t]*//[^\n]*" "\n" code "${contents}") - # The public forwarding overloads, each exactly one line of this shape. - string(REGEX REPLACE "\n[ \t]*return (render|document)<[A-Za-z:]+>[(]node, DefaultVocabulary {}[)];" "\n" code + # The public forwarding overloads, each exactly one line of this shape -- + # the ones that take `RenderOptions` pass them on. + string(REGEX REPLACE "\n[ \t]*return (render|document)<[A-Za-z:]+>[(]node, DefaultVocabulary {}(, renderOptions)?[)];" "\n" code "${code}") string(REGEX REPLACE "\n[ \t]*return render[(]node[)];" "\n" code "${code}") diff --git a/docs/display.md b/docs/display.md index 806c463b..16006430 100644 --- a/docs/display.md +++ b/docs/display.md @@ -583,8 +583,8 @@ before the call stack of the evaluation: ``` test\negative\format_places_without_mode.cpp(17): error C7595: 'std::basic_format_string::basic_format_string': call to immediate function is not a constant expression -include\formula-cpp/format.hpp(333): note: failure was caused by call of undefined function or one not declared 'constexpr' -include\formula-cpp/format.hpp(333): note: see usage of 'formula::detail::formula_number_format_needs_a_rounding_mode' +include\formula-cpp/format.hpp(336): note: failure was caused by call of undefined function or one not declared 'constexpr' +include\formula-cpp/format.hpp(336): note: see usage of 'formula::detail::formula_number_format_needs_a_rounding_mode' ``` clang and g++ name the same function, in their own words. diff --git a/include/formula-cpp/constraint.hpp b/include/formula-cpp/constraint.hpp index 3ede30e0..e60e077e 100644 --- a/include/formula-cpp/constraint.hpp +++ b/include/formula-cpp/constraint.hpp @@ -30,6 +30,7 @@ #include #include #include +#include #include #include @@ -74,6 +75,12 @@ enum class ConstraintOutcomeKind : std::uint8_t return "unknown constraint outcome"; } +namespace detail +{ +template <> +inline constexpr bool formats_by_describe = true; +} // namespace detail + /// What checking a `Constraint` produced. /// /// **A constraint whose predicate could not be evaluated must never report diff --git a/include/formula-cpp/curve.hpp b/include/formula-cpp/curve.hpp index 92f0aaa0..7d938e5f 100644 --- a/include/formula-cpp/curve.hpp +++ b/include/formula-cpp/curve.hpp @@ -275,6 +275,12 @@ enum class Monotone : std::uint8_t return "unknown direction"; } +namespace detail +{ +template <> +inline constexpr bool formats_by_describe = true; +} // namespace detail + /// The rule a curve broke where it failed, as its trace step names it. enum class CurveBreak : std::uint8_t { diff --git a/include/formula-cpp/document.hpp b/include/formula-cpp/document.hpp index 1dea7d2f..c363e2c1 100644 --- a/include/formula-cpp/document.hpp +++ b/include/formula-cpp/document.hpp @@ -1589,4 +1589,14 @@ template return document(node, detail::styled(vocabulary, renderOptions.numbers)); } +/// Documents @p node as `document(node, DefaultVocabulary {}, renderOptions)` +/// does: every symbol as `Describe::symbol` says and every number as +/// @p renderOptions says, without naming a vocabulary that renames nothing. +template + requires requires(X const& written, DefaultVocabulary const& byDefault) { document(written, byDefault); } +[[nodiscard]] Documentation document(X const& node, RenderOptions renderOptions) +{ + return document(node, DefaultVocabulary {}, renderOptions); +} + } // namespace formula diff --git a/include/formula-cpp/error.hpp b/include/formula-cpp/error.hpp index 7598e77d..4a4d526c 100644 --- a/include/formula-cpp/error.hpp +++ b/include/formula-cpp/error.hpp @@ -63,6 +63,15 @@ enum class ArithmeticError : std::uint8_t return "unknown arithmetic error"; } +namespace detail +{ +/// An enumeration listed here is written by `std::format` through its `describe()`. +template +inline constexpr bool formats_by_describe = false; +template <> +inline constexpr bool formats_by_describe = true; +} // namespace detail + /// Thrown by the operator layer when the corresponding `checked_` operation fails. class ArithmeticException: public std::exception { diff --git a/include/formula-cpp/format.hpp b/include/formula-cpp/format.hpp index 2a804cf2..3deabf94 100644 --- a/include/formula-cpp/format.hpp +++ b/include/formula-cpp/format.hpp @@ -43,9 +43,11 @@ /// twice, which breaks the one-definition rule. Only `char` formatting is /// provided: a unit's symbol is UTF-8 bytes. +#include #include #include #include +#include #include #include #include @@ -56,6 +58,7 @@ #include #include #include +#include #include namespace formula::detail @@ -420,6 +423,74 @@ template writeFill(padding - paddingBefore); return destination; } + +/// 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. +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; + if (parsed.body == NumberFormatBody::Approximated && !parsed.places.has_value() + && (declaredPlaces > 18 || declaredPlaces < -18)) + formula_number_format_places_out_of_range(); + return specEnd; +} + +/// 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`). +template +[[nodiscard]] OutputIterator format_measured(Measured const& shownMeasured, + NumberFormatSpec const& formatSpec, + OutputIterator destination) +{ + 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); +} + +/// Appends @p baseName and @p exponentValue to @p spelled as `L^2` or +/// `L^(1/2)`, after a space when @p spelled is not empty; nothing when the +/// exponent is zero. +inline void append_exponent_text(std::string& spelled, std::string_view baseName, Exponent exponentValue) +{ + if (is_zero(exponentValue)) + return; + if (!spelled.empty()) + spelled += ' '; + spelled += baseName; + spelled += '^'; + if (is_integer(exponentValue)) + spelled += std::to_string(exponentValue.numerator); + else + spelled += '(' + std::to_string(exponentValue.numerator) + '/' + std::to_string(exponentValue.denominator) + ')'; +} + +/// A dimension's exponents, as `std::format` writes them -- see +/// `formatter`. +[[nodiscard]] inline std::string dimension_text(Dimension const& shownDimension) +{ + std::string spelled; + append_exponent_text(spelled, "L", shownDimension.length); + append_exponent_text(spelled, "M", shownDimension.mass); + append_exponent_text(spelled, "T", shownDimension.time); + append_exponent_text(spelled, "I", shownDimension.current); + append_exponent_text(spelled, "Theta", shownDimension.temperature); + append_exponent_text(spelled, "N", shownDimension.amount); + append_exponent_text(spelled, "J", shownDimension.luminosity); + for (NamedBase const& namedBase: shownDimension.namedBases) + append_exponent_text(spelled, view(namedBase.name), namedBase.exponent); + if (is_dimensionless(shownDimension)) + spelled = "(dimensionless)"; + return spelled; +} } // namespace formula::detail // The specialisations are declared inside `namespace std` rather than as @@ -594,12 +665,7 @@ struct formatter, char> /// refused here. constexpr auto parse(std::format_parse_context& parseContext) { - auto const specEnd = formula::detail::parse_number_format_field(parseContext, _spec); - constexpr int declaredPlaces = formula::Describe::unit.decimals; - if (_spec.body == formula::detail::NumberFormatBody::Approximated && !_spec.places.has_value() - && (declaredPlaces > 18 || declaredPlaces < -18)) - formula::detail::formula_number_format_places_out_of_range(); - return specEnd; + return formula::detail::parse_measured_format_field(parseContext, _spec); } /// Writes @p shown as the spec says, or `(not measured)` when it is @@ -613,16 +679,95 @@ struct formatter, char> template auto format(formula::Measured const& shown, FormatContext& formatContext) const { - if (shown.is_absent()) + return formula::detail::format_measured(shown, _spec, formatContext.out()); + } + + private: + formula::detail::NumberFormatSpec _spec {}; +}; + +/// `std::format` of a `formula::Outcome`, in the grammar of +/// `formatter>`: a value as a `Measured` is written, +/// an empty outcome as `(not measured)`, and a verdict or an invalid outcome +/// as its label, filled, aligned and padded to the spec's width. A rounding in +/// the spec does not apply to words, but a spec the grammar does not allow is +/// refused as it is for a `Measured`. +/// +/// std::format("{}", Outcome::value(Measured { Rational { 26, 5 } }, ValueSource::Derived)) 5.2 kJ +/// std::format("{}", Outcome::empty()) (not measured) +/// std::format("{:>18}", Outcome::verdict({ "repeat the test" })) " repeat the test" +/// +/// Owned by this library: a consumer's own specialisation of it would define +/// it twice, which breaks the one-definition rule. +template +struct formatter, char> +{ + /// Reads the spec up to its `}`, as `formatter>` does. + constexpr auto parse(std::format_parse_context& parseContext) + { + return formula::detail::parse_measured_format_field(parseContext, _spec); + } + + /// Writes @p shown as the spec says. + /// @throws std::format_error as `formatter>` does, + /// for a value. + template + auto format(formula::Outcome const& shown, FormatContext& formatContext) const + { + if (shown.is_verdict()) return formula::detail::write_formatted_number( - formula::NotMeasuredText, std::string_view {}, _spec, formatContext.out()); - formula::Unit const shownIn = formula::Describe::unit; - formula::NumberText const spelled = formula::detail::spell_formatted_number(*shown.stored(), shownIn, _spec); - return formula::detail::write_formatted_number( - spelled.view(), formula::view(shownIn.symbolText), _spec, formatContext.out()); + shown.verdict_label(), std::string_view {}, _spec, formatContext.out()); + if (shown.is_invalid()) + return formula::detail::write_formatted_number( + shown.reason_label(), std::string_view {}, _spec, formatContext.out()); + return formula::detail::format_measured(shown.measurement(), _spec, formatContext.out()); } private: formula::detail::NumberFormatSpec _spec {}; }; + +/// `std::format` of a `formula::Unit`: its symbol, filled and aligned as a +/// string is. +template <> +struct formatter: formatter +{ + /// Writes the symbol of @p shownIn. + template + auto format(formula::Unit const& shownIn, FormatContext& formatContext) const + { + return formatter::format(formula::view(shownIn.symbolText), formatContext); + } +}; + +/// `std::format` of a `formula::Dimension`: its exponents joined by spaces, +/// `L^2 M^-3` or `L^(1/2)` (base names `L`, `M`, `T`, `I`, `Theta`, `N`, `J`, +/// each left out at exponent 0), then any named base by its name, and +/// `(dimensionless)` for a pure number. Filled and aligned as a string is. +template <> +struct formatter: formatter +{ + /// Writes @p shown. + template + auto format(formula::Dimension const& shown, FormatContext& formatContext) const + { + std::string const spelled = formula::detail::dimension_text(shown); + return formatter::format(spelled, formatContext); + } +}; + +/// `std::format` of a formula enumeration that `detail::formats_by_describe` +/// lists: its `describe()` words, filled and aligned as a string is. The +/// enumeration's own header must be included. +template + requires formula::detail::formats_by_describe +struct formatter: formatter +{ + /// Writes `describe(shown)`. + template + auto format(E shown, FormatContext& formatContext) const + { + return formatter::format(describe(shown), formatContext); + } +}; } // namespace std diff --git a/include/formula-cpp/outcome.hpp b/include/formula-cpp/outcome.hpp index 165e5a0c..cb4536b3 100644 --- a/include/formula-cpp/outcome.hpp +++ b/include/formula-cpp/outcome.hpp @@ -57,6 +57,12 @@ enum class ValueSource : std::uint8_t return "unknown value source"; } +namespace detail +{ +template <> +inline constexpr bool formats_by_describe = true; +} // namespace detail + /// Which alternative an `Outcome` holds. /// /// There is deliberately no `Overridden` alternative: an override is the `Value` @@ -88,6 +94,12 @@ enum class OutcomeKind : std::uint8_t return "unknown outcome kind"; } +namespace detail +{ +template <> +inline constexpr bool formats_by_describe = true; +} // namespace detail + /// A decision rather than a number: "reject the specimen", "repeat the test". struct Verdict { @@ -229,7 +241,6 @@ class Outcome InvalidReason _reason {}; }; - /// The number @p measured holds, or nothing when it is absent. template [[nodiscard]] constexpr std::optional number_of(Measured const& measured) noexcept diff --git a/include/formula-cpp/render.hpp b/include/formula-cpp/render.hpp index 0b1b8c7b..6c92a835 100644 --- a/include/formula-cpp/render.hpp +++ b/include/formula-cpp/render.hpp @@ -2771,4 +2771,14 @@ template { return render(node, detail::styled(vocabulary, renderOptions.numbers)); } + +/// Renders @p node as `render(node, DefaultVocabulary {}, renderOptions)` +/// does: every symbol as `Describe::symbol` says and every number as +/// @p renderOptions says, without naming a vocabulary that renames nothing. +template + requires requires(X const& written, DefaultVocabulary const& byDefault) { render(written, byDefault); } +[[nodiscard]] std::string render(X const& node, RenderOptions renderOptions) +{ + return render(node, DefaultVocabulary {}, renderOptions); +} } // namespace formula diff --git a/include/formula-cpp/retry.hpp b/include/formula-cpp/retry.hpp index 5eb20674..fff18a8e 100644 --- a/include/formula-cpp/retry.hpp +++ b/include/formula-cpp/retry.hpp @@ -130,6 +130,12 @@ enum class RetryEnd : std::uint8_t return "unknown retry end"; } +namespace detail +{ +template <> +inline constexpr bool formats_by_describe = true; +} // namespace detail + /// The most attempts a retry may allow. The methods this shape exists for /// 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 diff --git a/include/formula-cpp/rounding.hpp b/include/formula-cpp/rounding.hpp index 1794e955..f5865a65 100644 --- a/include/formula-cpp/rounding.hpp +++ b/include/formula-cpp/rounding.hpp @@ -70,6 +70,12 @@ enum class RoundingMode : std::uint8_t return "unknown rounding mode"; } +namespace detail +{ +template <> +inline constexpr bool formats_by_describe = true; +} // namespace detail + /// A decimal scale. Negative values are meaningful: DecimalPlaces { -1 } rounds /// to whole tens, which norms do ask for. struct DecimalPlaces diff --git a/include/formula-cpp/series.hpp b/include/formula-cpp/series.hpp index 8d769ace..379964e8 100644 --- a/include/formula-cpp/series.hpp +++ b/include/formula-cpp/series.hpp @@ -553,6 +553,12 @@ enum class CumulativeDirection : std::uint8_t return "from an unknown end"; } +namespace detail +{ +template <> +inline constexpr bool formats_by_describe = true; +} // namespace detail + namespace detail { /// Fails to compile when `sum` is given a single value. Named so the @@ -759,6 +765,12 @@ enum class FailureSite : std::uint8_t return "unknown failure site"; } +namespace detail +{ +template <> +inline constexpr bool formats_by_describe = true; +} // namespace detail + /// Why a series could not be evaluated, and where. struct SeriesFailure { diff --git a/include/formula-cpp/snap.hpp b/include/formula-cpp/snap.hpp index a8891170..a5d6d6f9 100644 --- a/include/formula-cpp/snap.hpp +++ b/include/formula-cpp/snap.hpp @@ -58,6 +58,12 @@ enum class SnapTie : std::uint8_t return "toward an unknown side"; } +namespace detail +{ +template <> +inline constexpr bool formats_by_describe = true; +} // namespace detail + namespace detail { /// Fails to compile when a snap's permitted set is empty: there is nothing diff --git a/include/formula-cpp/trace.hpp b/include/formula-cpp/trace.hpp index eed0c56a..74328d3c 100644 --- a/include/formula-cpp/trace.hpp +++ b/include/formula-cpp/trace.hpp @@ -561,6 +561,12 @@ enum class Branch : std::uint8_t return "unknown branch"; } +namespace detail +{ +template <> +inline constexpr bool formats_by_describe = true; +} // namespace detail + /// What stands on one side of a binary step -- `Add` to `Divide` and their /// elementwise twins -- so that a line with fewer than two operands still /// says which side each one is, and what took the other's place. diff --git a/include/formula-cpp/unit.hpp b/include/formula-cpp/unit.hpp index 58718286..d46c7074 100644 --- a/include/formula-cpp/unit.hpp +++ b/include/formula-cpp/unit.hpp @@ -619,6 +619,12 @@ enum class BoundsCheck : std::uint8_t return "unknown bounds outcome"; } +namespace detail +{ +template <> +inline constexpr bool formats_by_describe = true; +} // namespace detail + /// Checks @p magnitude, expressed in @p unitOfValue, against that unit's bounds. [[nodiscard]] constexpr std::expected checked_within_bounds(Rational magnitude, Unit unitOfValue) noexcept diff --git a/include/formula-cpp/vocabulary.hpp b/include/formula-cpp/vocabulary.hpp index 671e52b8..62bd571c 100644 --- a/include/formula-cpp/vocabulary.hpp +++ b/include/formula-cpp/vocabulary.hpp @@ -422,9 +422,10 @@ template concept Vocabulary = detail::isVocabulary>; /// How @p Q is written under @p vocabulary -- the one call every surface that -/// writes a quantity's symbol goes through. -template -[[nodiscard]] constexpr std::string_view symbol_of(V const& vocabulary) noexcept +/// writes a quantity's symbol goes through. Without one, `DefaultVocabulary`: +/// `Describe::symbol`. +template +[[nodiscard]] constexpr std::string_view symbol_of(V const& vocabulary = V {}) noexcept { return vocabulary.template symbol(); } diff --git a/test/consumer_globals_run_tests.cpp b/test/consumer_globals_run_tests.cpp index 68ec205f..0038b8a6 100644 --- a/test/consumer_globals_run_tests.cpp +++ b/test/consumer_globals_run_tests.cpp @@ -13,7 +13,7 @@ TEST_CASE("a consumer's ordinary globals do not break a build that includes every header", "[hygiene]") { ConsumerGlobalsProbe const probe = probe_consumer_globals(); - REQUIRE(probe.checks.size() == 90); + REQUIRE(probe.checks.size() == 93); for (std::size_t index = 0; index < probe.checks.size(); ++index) { INFO("check " << index); diff --git a/test/consumer_globals_tests.cpp b/test/consumer_globals_tests.cpp index 43b18d38..5adda68c 100644 --- a/test/consumer_globals_tests.cpp +++ b/test/consumer_globals_tests.cpp @@ -1225,6 +1225,30 @@ ConsumerGlobalsProbe probe_consumer_globals() && std::format("{:>10~HalfEven}", thirdEdge) == " \xe2\x89\x88" "150.7 mm" && std::format("{:/}", thirdEdge) == "452/3 mm"); + // An outcome, a unit, a dimension and an enumeration written by + // std::format, and the default vocabulary and render options taken + // without a vocabulary. + probe.checks.push_back( + std::format("{}", formula::Outcome::value(thirdEdge, formula::ValueSource::Derived)) == "452/3 mm" + && std::format("{:>17}", formula::Outcome::verdict({ "repeat the test" })) == " repeat the test" + && std::format("{}", formula::unit::Millimetre) == "mm" + && std::format("{}", formula::dim::Density) == "L^-3 M^1" + && std::format("{}", formula::ArithmeticError::Overflow) == "overflow in exact arithmetic"); + probe.checks.push_back( + formula::symbol_of() == formula::Describe::symbol + && formula::render(formula::var * formula::Rational { 3, 5 }, + formula::RenderOptions { .numbers = formula::NumberStyle::exact_decimal() }) + == formula::render(formula::var * formula::Rational { 3, 5 }, + formula::DefaultVocabulary {}, + formula::RenderOptions { .numbers = formula::NumberStyle::exact_decimal() })); + + // The words of the enumerations a constraint, a retry and a series + // failure report, which a consumer's own function of the same name must + // not make ambiguous. + probe.checks.push_back(formula::describe(formula::ConstraintOutcomeKind::Violated) == "violated" + && !formula::describe(formula::RetryEnd::Accepted).empty() + && !formula::describe(formula::FailureSite::ResultElement).empty()); + // The exact decimal literal: 27.3 is 273/10, not the double nearest it. { using namespace formula::literals; diff --git a/test/document_tests.cpp b/test/document_tests.cpp index 68848d9d..5fd7060d 100644 --- a/test/document_tests.cpp +++ b/test/document_tests.cpp @@ -1267,6 +1267,10 @@ TEST_CASE("document: RenderOptions writes every number the page states in its st formula::Documentation const latex = formula::document( var * rat(863, 1000), formula::DefaultVocabulary {}, exactDecimals); CHECK(latex.formula == "f \\cdot 0.863"); + // Without a vocabulary, which is the default one. + CHECK(formula::document(var * rat(863, 1000), exactDecimals).formula == "f * 0.863"); + CHECK(formula::document(var * rat(863, 1000), exactDecimals).formula + == "f \\cdot 0.863"); // A derived quantity's derivation. formula::Documentation const derivedPage = formula::document(sizedStrength); diff --git a/test/format_tests.cpp b/test/format_tests.cpp index 136eeed3..10b5fb85 100644 --- a/test/format_tests.cpp +++ b/test/format_tests.cpp @@ -305,3 +305,30 @@ TEST_CASE("the spec parser reads each form, at compile time", "[format]") STATIC_REQUIRE(parse_number_format(".1TowardZero").roundingMode == RoundingMode::TowardZero); STATIC_REQUIRE(parse_number_format(".1AwayFromZero").roundingMode == RoundingMode::AwayFromZero); } + +TEST_CASE("an outcome writes its value, or says why it has none", "[format]") +{ + using Held = formula::Outcome; + Measured const fivePointTwo { Rational { 26, 5 } }; + CHECK(std::format("{}", Held::value(fivePointTwo, formula::ValueSource::Derived)) == "5.2 kJ"); + CHECK(std::format("{:.3HalfEven}", Held::value(fivePointTwo, formula::ValueSource::Derived)) == "5.200 kJ"); + CHECK(std::format("{}", Held::empty()) == "(not measured)"); + CHECK(std::format("{:>18}", Held::verdict({ "repeat the test" })) == " repeat the test"); + CHECK(std::format("{:.2HalfEven}", Held::invalid({ "discarded" })) == "discarded"); +} + +TEST_CASE("a unit is its symbol, a dimension its exponents", "[format]") +{ + CHECK(std::format("{}", unit::Kilojoule) == "kJ"); + CHECK(std::format("[{:>4}]", unit::Kilojoule) == "[ kJ]"); + CHECK(std::format("{}", formula::dim::Mass / formula::dim::Volume) == "L^-3 M^1"); + CHECK(std::format("{}", formula::nth_root(formula::dim::Length, 2)) == "L^(1/2)"); + CHECK(std::format("{}", formula::dim::Scalar) == "(dimensionless)"); +} + +TEST_CASE("a described enumeration is its words, aligned like a string", "[format]") +{ + CHECK(std::format("{}", formula::ArithmeticError::Overflow) == "overflow in exact arithmetic"); + CHECK(std::format("[{:<12}]", formula::ConstraintOutcomeKind::Violated) == "[violated ]"); + CHECK(std::format("{}", formula::ValueSource::ManuallyEntered) == "manually entered"); +} diff --git a/test/render_tests.cpp b/test/render_tests.cpp index 0c81699e..aeddfa04 100644 --- a/test/render_tests.cpp +++ b/test/render_tests.cpp @@ -2280,6 +2280,17 @@ TEST_CASE("render: RenderOptions writes a constant as an exact decimal, in every CHECK(formula::render(var * rat(1, 3), formula::DefaultVocabulary {}, exactDecimals) == "f * 1/3"); } +TEST_CASE("render: RenderOptions without a vocabulary is the default vocabulary", "[render][decimals]") +{ + CHECK(formula::render(scaledStrength, exactDecimals) == "f * 0.863"); + CHECK(formula::render(scaledStrength, exactDecimals) + == formula::render(scaledStrength, formula::DefaultVocabulary {}, exactDecimals)); + CHECK(formula::render(scaledStrength, exactDecimals) == "`f` * 0.863"); + CHECK(formula::render(scaledStrength, exactDecimals) == "f \\cdot 0.863"); + // Options that change nothing write what render() without them writes. + CHECK(formula::render(scaledStrength, formula::RenderOptions {}) == formula::render(scaledStrength)); +} + TEST_CASE("render: a typed number is never approximated, whatever the style", "[render][decimals]") { // Under an approximating style a trace would round 1/3 to ≈0.333 in diff --git a/test/vocabulary_tests.cpp b/test/vocabulary_tests.cpp index ef87a1c0..c2aaaba8 100644 --- a/test/vocabulary_tests.cpp +++ b/test/vocabulary_tests.cpp @@ -1507,3 +1507,10 @@ TEST_CASE("a sum of a series traces inside an overlaid method, in the sink's wor "5. round(#4, in %) = 50 % [rounded to 1 dp (method default); nearest, ties away from zero]\n" "6. #5 = 50 % [variant OfEachElement (2nd of 2), selected by tag]\n"); } + +TEST_CASE("a symbol asked without a vocabulary is the declared one", "[vocabulary]") +{ + STATIC_REQUIRE(formula::symbol_of() == formula::symbol_of(formula::DefaultVocabulary {})); + STATIC_REQUIRE(formula::symbol_of() == formula::Describe::symbol); + STATIC_REQUIRE(formula::symbol_of() == "E_m"); +} From e93c018f12f21f290eb27b51ab8d67fa0b965be9 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 18:06:29 +0200 Subject: [PATCH 09/59] feat(measured): add throwing twins and refuse a conversion across dimensions where it is written Callers who would only rethrow the error of checked_convert_to, checked_round_to_declared or checked_within_bounds on a Measured had no short spelling: convert_to, round_to_declared and within_bounds now throw ArithmeticException where the checked form returns an error. checked_convert_to also knew both dimensions where the call is written but only refused a mismatch at run time, as DomainError. It now refuses a conversion between quantities of different dimensions, with or without a value present, when the call is compiled, with one message. The tests that pinned the run-time error become negative compile tests. Signed-off-by: Christian Parpart --- CHANGELOG.md | 9 +++ docs/quantities.md | 19 +++++- include/formula-cpp/measured.hpp | 65 ++++++++++++++++-- test/CMakeLists.txt | 9 +++ test/consumer_globals_run_tests.cpp | 2 +- test/consumer_globals_tests.cpp | 7 +- test/measured_tests.cpp | 68 +++++++++++-------- .../measured_convert_currency_mismatch.cpp | 26 +++++++ .../measured_convert_dimension_mismatch.cpp | 19 ++++++ ...ured_convert_dimension_mismatch_absent.cpp | 19 ++++++ 10 files changed, 205 insertions(+), 38 deletions(-) create mode 100644 test/negative/measured_convert_currency_mismatch.cpp create mode 100644 test/negative/measured_convert_dimension_mismatch.cpp create mode 100644 test/negative/measured_convert_dimension_mismatch_absent.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index a8702bb4..fc383fe5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ change is recorded here. ### Added +- `convert_to`, `round_to_declared` and `within_bounds` for a `Measured`: the throwing twins + of `checked_convert_to`, `checked_round_to_declared` and `checked_within_bounds`, for callers who + would only rethrow. They throw `ArithmeticException` where the `checked_` form returns an error. + Every earlier spelling stays. - `_r`, an exact decimal literal: `27.3_r` is the `Rational` 273/10, where `27.3` is the double nearest it. It reads integers, fractions, a leading or trailing point (`.5_r`, `5._r`), an exponent (`1.5e-3_r` is 3/2000) and digit separators, and a minus sign is `Rational`'s own @@ -37,6 +41,11 @@ change is recorded here. ### Changed +- `checked_convert_to` refuses a conversion between measured quantities of different dimensions + where the call is written, with or without a value present. Code that converted, say, a volume + into a mass, or euros into yen, used to compile and get `ArithmeticError::DomainError` at run + time; it no longer compiles, and the message names the two quantities. A conversion between + quantities of one dimension is unchanged. - An unqualified call of `describe` with a `ConstraintOutcomeKind`, a `RetryEnd`, a `ValueSource`, an `OutcomeKind` or a `FailureSite` now finds the library's function by argument-dependent lookup. A consumer's own `describe` for one of these enums -- a `describe(ConstraintOutcomeKind)` diff --git a/docs/quantities.md b/docs/quantities.md index 2af3a61d..6fd037c0 100644 --- a/docs/quantities.md +++ b/docs/quantities.md @@ -274,8 +274,13 @@ a present volume combined with an absent mass: absent `formula::checked_convert_to` converts a `Measured` into a `Measured` and keeps this rule too -- an absent input converts to an -absent output, and the dimension check runs regardless, so a conversion -nobody could perform is refused even when there was no value to get wrong: +absent output. The two dimensions are checked where the call is written, +with or without a value: converting a volume into a mass, or euros into yen, +does not compile, and it draws one message, so a conversion nobody could +perform cannot look like it succeeded merely because there was no value to +get wrong. (Before this check moved to compile time, such a call compiled +and returned `ArithmeticError::DomainError`.) With no value present the +result is absent: ``` an absent measurement, converted: still absent @@ -338,7 +343,7 @@ substitutes for the other.** `NotChecked` means the unit declares no bounds at all -- there is a value, but nothing to check it against. `NotMeasured` means there is no value in the first place, regardless of whether the unit declares bounds. A reading nobody took and a range nobody declared are -different facts. `test/measured_tests.cpp:198-227` pins all five +different facts. `test/measured_tests.cpp:206-268` pins all five `BoundsCheck` outcomes side by side -- `WithinBounds`, `BelowMinimum` and `AboveMaximum` for present values against a bounded unit, `NotMeasured` for an absent value regardless of whether its unit declares bounds, and @@ -353,6 +358,14 @@ unit rather than needing one passed alongside it. From the worked example, 450 l converted to m3 = 9/20 ``` +The conversion, the rounding and the bounds check each have a throwing twin, +for callers who would only rethrow the error: `formula::convert_to`, `formula::round_to_declared` and +`formula::within_bounds`, which take the same arguments and return the value +itself, and throw `ArithmeticException` where the `checked_` form returns an +error. Absence behaves as above -- an absent measurement converts and rounds to +an absent one and is `NotMeasured` for its bounds -- and a conversion across +dimensions does not compile in either spelling. + ## Limits `formula::detail::FixedString`, which gives a quantity's symbol and diff --git a/include/formula-cpp/measured.hpp b/include/formula-cpp/measured.hpp index 09dc5750..da872d2f 100644 --- a/include/formula-cpp/measured.hpp +++ b/include/formula-cpp/measured.hpp @@ -170,16 +170,41 @@ template return Measured { function(lhs.value(), rhs.value()) }; } +namespace detail +{ + + /// Fails to compile when a measurement is converted into a quantity of + /// another dimension: no such conversion exists, and both dimensions are + /// known where the call is written. + /// + /// Only `::value`, `sizeof(...)` or a variable of this type runs the + /// `static_assert`; see `RequireSameUnitDimension`. + template + struct RequireConvertibleQuantities + { + static_assert(Describe::dimension == Describe::dimension, + "formula: these two quantities measure different dimensions, so no conversion " + "between them exists; the two quantities appear in this diagnostic as the " + "template arguments of RequireConvertibleQuantities"); + + /// Always `true` once reached -- the `static_assert` above already failed + /// compilation otherwise. + static constexpr bool value = true; + }; + +} // namespace detail + /// Converts a measurement of `Q` into one of `R`, exactly. /// -/// The dimensions are checked even when the value is absent: a conversion nobody -/// could perform must not look like it succeeded merely because there was no -/// number to get wrong. +/// The dimensions are checked where the call is written, whether or not a value +/// is present: a conversion nobody could perform does not compile, and so cannot +/// look like it succeeded merely because there was no number to get wrong. A +/// refused conversion draws that one message: the unit conversion in the body is +/// an ordinary run-time call and adds none (measured with cl and clang-cl). template [[nodiscard]] constexpr std::expected, ArithmeticError> checked_convert_to(Measured value) noexcept { - if (!(Describe::dimension == Describe::dimension)) - return std::unexpected { ArithmeticError::DomainError }; + static_assert(detail::RequireConvertibleQuantities::value); if (value.is_absent()) return Measured {}; @@ -190,6 +215,16 @@ template return Measured { *inTargetUnit }; } +/// Throwing spelling of `checked_convert_to`, for callers who would only rethrow. +/// +/// Throws `ArithmeticException` where `checked_convert_to` returns an error; a +/// conversion across dimensions does not compile, as there. +template +[[nodiscard]] constexpr Measured convert_to(Measured measured) +{ + return detail::or_throw(checked_convert_to(measured)); +} + /// Checks a measurement against its quantity's unit's declared bounds. template [[nodiscard]] constexpr std::expected checked_within_bounds( @@ -215,4 +250,24 @@ template return Measured { *rounded }; } +/// Throwing spelling of `checked_within_bounds`, for callers who would only rethrow. +/// +/// An absent measurement is `BoundsCheck::NotMeasured`, as there. Throws +/// `ArithmeticException` where `checked_within_bounds` returns an error. +template +[[nodiscard]] constexpr BoundsCheck within_bounds(Measured measured) +{ + return detail::or_throw(checked_within_bounds(measured)); +} + +/// Throwing spelling of `checked_round_to_declared`, for callers who would only rethrow. +/// +/// An absent measurement stays absent, as there. Throws `ArithmeticException` +/// where `checked_round_to_declared` returns an error. +template +[[nodiscard]] constexpr Measured round_to_declared(Measured measured, RoundingMode roundingMode) +{ + return detail::or_throw(checked_round_to_declared(measured, roundingMode)); +} + } // namespace formula diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 620def38..64eacc5f 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -334,6 +334,15 @@ 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 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 + "formula: these two quantities measure different dimensions, so no conversion between them exists" EXPECT_COUNT 1) +formula_add_negative_test(measured_convert_dimension_mismatch_absent + "formula: these two quantities measure different dimensions, so no conversion between them exists" EXPECT_COUNT 1) +formula_add_negative_test(measured_convert_currency_mismatch + "formula: these two quantities measure different dimensions, so no conversion between them exists" EXPECT_COUNT 1) + # Same NAME-not-message case as the exponent sentinels above, for the same # reason: symbol() rejects an over-long symbol by calling a deliberately # non-`constexpr` sentinel rather than truncating it. diff --git a/test/consumer_globals_run_tests.cpp b/test/consumer_globals_run_tests.cpp index 0038b8a6..9ea08c9b 100644 --- a/test/consumer_globals_run_tests.cpp +++ b/test/consumer_globals_run_tests.cpp @@ -13,7 +13,7 @@ TEST_CASE("a consumer's ordinary globals do not break a build that includes every header", "[hygiene]") { ConsumerGlobalsProbe const probe = probe_consumer_globals(); - REQUIRE(probe.checks.size() == 93); + REQUIRE(probe.checks.size() == 94); for (std::size_t index = 0; index < probe.checks.size(); ++index) { INFO("check " << index); diff --git a/test/consumer_globals_tests.cpp b/test/consumer_globals_tests.cpp index 5adda68c..10b65608 100644 --- a/test/consumer_globals_tests.cpp +++ b/test/consumer_globals_tests.cpp @@ -28,7 +28,8 @@ // and of a replaced variant, and `check_method`, with `RecordingSink` and // with a sink of its own; `apply` with every overlay operation; `Outcome`'s // factories; `checked_convert_to`, `checked_within_bounds`, -// `checked_round_to_declared`, `transform` and `combine`; `entered`, +// `checked_round_to_declared`, their throwing twins `convert_to`, +// `within_bounds` and `round_to_declared`, `transform` and `combine`; `entered`, // `Environment::get` and `source_of`; // `measured_series`, `entered` of a series, `Environment::get_series` and // `checked_evaluate_series` of a series variable, derived and entered; @@ -588,6 +589,10 @@ ConsumerGlobalsProbe probe_consumer_globals() auto const summed = formula::combine( edge, edge, [](formula::Rational augend, formula::Rational addend) { return augend + addend; }); probe.checks.push_back(inMetres.has_value() && withinBounds.has_value() && declared.has_value()); + auto const convertedEdge = formula::convert_to(edge); + auto const roundedEdge = formula::round_to_declared(edge, formula::RoundingMode::HalfAwayFromZero); + probe.checks.push_back(convertedEdge == *inMetres && roundedEdge == *declared + && formula::within_bounds(edge) == *withinBounds); probe.checks.push_back(doubled.value() == formula::Rational { 300 } && summed.value() == formula::Rational { 300 }); // A series: built, entered, read from an environment and evaluated both diff --git a/test/measured_tests.cpp b/test/measured_tests.cpp index 2e49cfda..8f71201a 100644 --- a/test/measured_tests.cpp +++ b/test/measured_tests.cpp @@ -200,19 +200,9 @@ TEST_CASE("absence survives a conversion instead of becoming a number", "[measur CHECK(absent->is_absent()); } -TEST_CASE("converting to a quantity of another dimension is refused", "[measured]") -{ - auto const wrong = formula::checked_convert_to(measured(450, 1)); - REQUIRE_FALSE(wrong.has_value()); - CHECK(wrong.error() == ArithmeticError::DomainError); - - // And it is refused for an ABSENT value too. A conversion nobody could - // perform must not look like it succeeded merely because there was no number - // to get wrong. - auto const wrongAndAbsent = formula::checked_convert_to(Measured {}); - REQUIRE_FALSE(wrongAndAbsent.has_value()); - CHECK(wrongAndAbsent.error() == ArithmeticError::DomainError); -} +// Converting to a quantity of another dimension does not compile, present value or +// not; test/negative/measured_convert_dimension_mismatch.cpp and +// measured_convert_dimension_mismatch_absent.cpp pin the refusal. namespace { @@ -462,9 +452,6 @@ inline constexpr formula::Unit EuroCent { .dimension = formula::base_dimension(" .magnitudeDenominator = 100, .symbolText = formula::symbol("ct"), .decimals = 0 }; -inline constexpr formula::Unit Yen { .dimension = formula::base_dimension("JPY"), - .symbolText = formula::symbol("JPY"), - .decimals = 0 }; struct PriceInEuros: formula::Quantity { @@ -472,29 +459,54 @@ struct PriceInEuros: formula::Quantity { }; -struct PriceInYen: formula::Quantity -{ -}; } // namespace -TEST_CASE("a price converts from cents to euros exactly and never from euros to yen", "[measured][money]") +TEST_CASE("a price converts from cents to euros exactly", "[measured][money]") { auto const inEuros = formula::checked_convert_to(Measured { Rational { 250 } }); REQUIRE(inEuros.has_value()); REQUIRE(inEuros->has_value()); CHECK(inEuros->value() == *Rational::make(5, 2)); - auto const inYen = formula::checked_convert_to(Measured { Rational { 10 } }); - REQUIRE_FALSE(inYen.has_value()); - CHECK(inYen.error() == ArithmeticError::DomainError); - - // Refused even with no number, like any conversion between dimensions. - auto const absentInYen = formula::checked_convert_to(Measured {}); - REQUIRE_FALSE(absentInYen.has_value()); - CHECK(absentInYen.error() == ArithmeticError::DomainError); + // Euros into yen does not compile: test/negative/measured_convert_currency_mismatch.cpp. } TEST_CASE("Measured: an integer is a present value without spelling Rational", "[measured]") { STATIC_REQUIRE(formula::Measured { 139 }.value() == formula::Rational { 139 }); } + +TEST_CASE("convert_to: the throwing twin of checked_convert_to", "[measured]") +{ + STATIC_REQUIRE(formula::convert_to(measured(450, 1)) + == *formula::checked_convert_to(measured(450, 1))); + STATIC_REQUIRE(formula::convert_to(measured(450, 1)).value() == *Rational::make(9, 20)); + STATIC_REQUIRE(formula::convert_to(Measured::absent()).is_absent()); +} + +TEST_CASE("round_to_declared and within_bounds: throwing twins", "[measured]") +{ + // Litre declares one decimal place, and 2.25 is a tie: HalfEven gives 2.2, + // HalfAwayFromZero 2.3, so a twin that ignored its mode would be caught. + CHECK(formula::round_to_declared(measured(225, 100), formula::RoundingMode::HalfEven).value() + == *Rational::make(22, 10)); + CHECK(formula::round_to_declared(measured(225, 100), formula::RoundingMode::HalfAwayFromZero).value() + == *Rational::make(23, 10)); + CHECK(formula::round_to_declared(measured(225, 100), formula::RoundingMode::HalfEven) + == *formula::checked_round_to_declared(measured(225, 100), formula::RoundingMode::HalfEven)); + CHECK(formula::round_to_declared(Measured::absent(), formula::RoundingMode::HalfEven).is_absent()); + + CHECK(formula::within_bounds(Measured::absent()) == formula::BoundsCheck::NotMeasured); + CHECK(formula::within_bounds(Measured { *Rational::make(42, 1) }) == formula::BoundsCheck::WithinBounds); + CHECK(formula::within_bounds(Measured { *Rational::make(101, 1) }) + == formula::BoundsCheck::AboveMaximum); +} + +TEST_CASE("the throwing twins throw the error their checked form returns", "[measured]") +{ + Measured const huge { *Rational::make(4611686018427387903LL, 1) }; + CHECK_THROWS_AS(formula::convert_to(huge), formula::ArithmeticException); + Measured const unroundable { *Rational::make(1, 3) }; + CHECK_THROWS_AS(formula::round_to_declared(unroundable, formula::RoundingMode::HalfEven), + formula::ArithmeticException); +} diff --git a/test/negative/measured_convert_currency_mismatch.cpp b/test/negative/measured_convert_currency_mismatch.cpp new file mode 100644 index 00000000..be597ba0 --- /dev/null +++ b/test/negative/measured_convert_currency_mismatch.cpp @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: these two quantities measure different dimensions, so no conversion between them exists +// +// Euros do not convert into yen: each currency is its own named base +// dimension, and the two units' SI exponents -- all zero -- are the same, so +// only the named base can tell them apart. This must not compile, and draws one message. +#include + +inline constexpr formula::Unit Euro { .dimension = formula::base_dimension("EUR"), + .symbolText = formula::symbol("EUR"), + .decimals = 2 }; +inline constexpr formula::Unit Yen { .dimension = formula::base_dimension("JPY"), + .symbolText = formula::symbol("JPY"), + .decimals = 0 }; + +struct PriceInEuros: formula::Quantity +{ +}; +struct PriceInYen: formula::Quantity +{ +}; + +int main() +{ + return formula::checked_convert_to(formula::Measured { 10 }).has_value() ? 1 : 0; +} diff --git a/test/negative/measured_convert_dimension_mismatch.cpp b/test/negative/measured_convert_dimension_mismatch.cpp new file mode 100644 index 00000000..a3503d46 --- /dev/null +++ b/test/negative/measured_convert_dimension_mismatch.cpp @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: these two quantities measure different dimensions, so no conversion between them exists +// +// A measured volume converted into a mass. The dimensions are known where the +// call is written, so this is refused when it is compiled, not returned as a +// DomainError at run time. This must not compile, and draws one message. +#include + +struct WaterVolume: formula::Quantity +{ +}; +struct SpecimenMass: formula::Quantity +{ +}; + +int main() +{ + return formula::checked_convert_to(formula::Measured { 450 }).has_value() ? 1 : 0; +} diff --git a/test/negative/measured_convert_dimension_mismatch_absent.cpp b/test/negative/measured_convert_dimension_mismatch_absent.cpp new file mode 100644 index 00000000..e271a797 --- /dev/null +++ b/test/negative/measured_convert_dimension_mismatch_absent.cpp @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: these two quantities measure different dimensions, so no conversion between them exists +// +// The same mistake with no value present. An absent measurement must not make +// a conversion nobody could perform look like it succeeded, so it is refused +// exactly as when a value is there. This must not compile, and draws one message. +#include + +struct WaterVolume: formula::Quantity +{ +}; +struct SpecimenMass: formula::Quantity +{ +}; + +int main() +{ + return formula::checked_convert_to(formula::Measured {}).has_value() ? 1 : 0; +} From 4c9594e9df42b8cfa8c3c7e14ed42bbb152270fc Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 18:12:27 +0200 Subject: [PATCH 10/59] fix(format): cover every formatter in the header's rules and pin each one format.hpp's overview still described two formatters, so the rule to include it wherever a type is formatted, and the warning that a consumer's own std::formatter defines the same entity twice, did not reach Outcome, Unit, Dimension or the described enumerations. It now names all of them, the Unit, Dimension and enumeration formatters carry the ownership sentence, and the changelog records that a consumer's own formatter for these types collides with the library's. The vocabulary tripwire could not see the two spellings that resolve the default vocabulary without naming it, symbol_of() and a two-argument render or document call, so it refuses both. The enumeration formatter reads describe() through a helper inside formula::detail, so a consumer's global named describe cannot hide it. Tests pin all twelve enumeration rows and the absence of one for an enumeration with no describe(), the padding and default alignment of an outcome's label, a named base in a dimension, and the refusal of a bad spec for an outcome. The probe now covers document with options. The five headers that specialise formats_by_describe include error.hpp, and the new rows share the existing detail blocks in series.hpp and snap.hpp. Signed-off-by: Christian Parpart --- CHANGELOG.md | 5 ++ cmake/CheckPublicHeaderIncludes.cmake | 5 +- cmake/CheckVocabularyReach.cmake | 14 +++++- docs/display.md | 4 +- include/formula-cpp/constraint.hpp | 1 + include/formula-cpp/format.hpp | 69 +++++++++++++++++++-------- include/formula-cpp/outcome.hpp | 1 + include/formula-cpp/series.hpp | 7 +-- include/formula-cpp/snap.hpp | 8 ++-- include/formula-cpp/trace.hpp | 1 + include/formula-cpp/unit.hpp | 1 + test/consumer_globals_run_tests.cpp | 2 +- test/consumer_globals_tests.cpp | 17 +++++-- test/format_tests.cpp | 40 ++++++++++++++++ 14 files changed, 136 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc383fe5..5c6f18d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,11 @@ change is recorded here. ### Changed +- `` now specialises `std::formatter` for `formula::Outcome`, + `formula::Unit`, `formula::Dimension` and every enumeration that has a `describe()`, as it + already did for `Rational` and `Measured`. A program that defines its own `std::formatter` + for one of these types now defines it twice, and a generic `std::formatter` for every + enumeration is ambiguous for them; remove it and use the library's. - `checked_convert_to` refuses a conversion between measured quantities of different dimensions where the call is written, with or without a value present. Code that converted, say, a volume into a mass, or euros into yen, used to compile and get `ArithmeticError::DomainError` at run diff --git a/cmake/CheckPublicHeaderIncludes.cmake b/cmake/CheckPublicHeaderIncludes.cmake index 1f93a927..05852478 100644 --- a/cmake/CheckPublicHeaderIncludes.cmake +++ b/cmake/CheckPublicHeaderIncludes.cmake @@ -14,8 +14,9 @@ # detail/latex_math.hpp qualifies as a part of render.hpp: it builds the # `std::string` render.hpp puts inside a LaTeX `\mathrm{...}`, and render.hpp # is its only includer, which check 2 keeps true. format.hpp qualifies too -- -# it specialises std::formatter, which needs , and writes its output -# with a plain loop, so that it includes nothing else from that list itself. +# it specialises std::formatter, which needs , and spells a dimension +# as a std::string, so it names too. It includes neither +# nor , and writes a number with a plain loop. # # Being named here does not, by itself, permit anything: check 2 below # enforces that no other header may reach one of these, which is what stops diff --git a/cmake/CheckVocabularyReach.cmake b/cmake/CheckVocabularyReach.cmake index fe6da023..d9b89162 100644 --- a/cmake/CheckVocabularyReach.cmake +++ b/cmake/CheckVocabularyReach.cmake @@ -25,7 +25,13 @@ # the default vocabulary has thrown away the one it was given; # - a one-argument `render<...>(x)` call, outside the public plain-text # overloads that forward to their dialect counterparts -- a -# sub-expression rendered that way is in the declared symbols. +# sub-expression rendered that way is in the declared symbols; +# - `symbol_of<...>()` with no argument, which reads `Describe::symbol` +# through the default vocabulary and ignores the one the surface was given; +# - a two-argument `render<...>(x, renderOptions)` or +# `document<...>(x, renderOptions)` call, outside the public overloads that +# forward `RenderOptions` -- it names no vocabulary, so it resolves the +# default one. # # What it cannot see: a spelling none of these match. That is why it is not # the guarantee. @@ -68,6 +74,12 @@ foreach(file IN LISTS surfaces) if(code MATCHES "[^_A-Za-z0-9]render<[^<>()]*>[(][^,()]*[)]") string(APPEND offenders "\n ${rel}: ${CMAKE_MATCH_0} -- rendered without the vocabulary") endif() + if(code MATCHES "symbol_of<[^()]*>[(][ \t]*[)]") + string(APPEND offenders "\n ${rel}: ${CMAKE_MATCH_0} -- a symbol read through the default vocabulary") + endif() + if(code MATCHES "[^_A-Za-z0-9](render|document)(<[^<>()]*>)?[(][^,()]*, renderOptions[)]") + string(APPEND offenders "\n ${rel}: ${CMAKE_MATCH_0} -- rendered without naming the vocabulary") + endif() endforeach() if(offenders) diff --git a/docs/display.md b/docs/display.md index 16006430..2e9417e4 100644 --- a/docs/display.md +++ b/docs/display.md @@ -583,8 +583,8 @@ before the call stack of the evaluation: ``` test\negative\format_places_without_mode.cpp(17): error C7595: 'std::basic_format_string::basic_format_string': call to immediate function is not a constant expression -include\formula-cpp/format.hpp(336): note: failure was caused by call of undefined function or one not declared 'constexpr' -include\formula-cpp/format.hpp(336): note: see usage of 'formula::detail::formula_number_format_needs_a_rounding_mode' +include\formula-cpp/format.hpp(344): note: failure was caused by call of undefined function or one not declared 'constexpr' +include\formula-cpp/format.hpp(344): note: see usage of 'formula::detail::formula_number_format_needs_a_rounding_mode' ``` clang and g++ name the same function, in their own words. diff --git a/include/formula-cpp/constraint.hpp b/include/formula-cpp/constraint.hpp index e60e077e..89fd1f7d 100644 --- a/include/formula-cpp/constraint.hpp +++ b/include/formula-cpp/constraint.hpp @@ -21,6 +21,7 @@ /// by `documented()`. #include +#include #include #include #include diff --git a/include/formula-cpp/format.hpp b/include/formula-cpp/format.hpp index 3deabf94..7335dbd5 100644 --- a/include/formula-cpp/format.hpp +++ b/include/formula-cpp/format.hpp @@ -2,9 +2,11 @@ #pragma once /// @file -/// `std::format` for a `Rational` and a `Measured`: `std::format("{}", -/// Rational { 3, 5 })` is `0.6`, and a measured 5.2 in a unit whose symbol is -/// `kJ` formats as `5.2 kJ`. +/// `std::format` for a `Rational`, a `Measured`, an `Outcome`, a `Unit`, +/// a `Dimension` and every enumeration that has a `describe()`: +/// `std::format("{}", Rational { 3, 5 })` is `0.6`, a measured 5.2 in a unit +/// whose symbol is `kJ` formats as `5.2 kJ`, `dim::Density` as `L^-3 M^1`, and +/// `ArithmeticError::Overflow` as `overflow in exact arithmetic`. /// /// **Opt-in.** This header is not included by `formula.hpp`: it includes /// ``, which the umbrella deliberately keeps out, so that a consumer @@ -12,14 +14,15 @@ /// /// #include /// -/// **Include it in every translation unit that formats a `Rational` or a -/// `Measured`, or asks whether it can** (`std::formattable`). What it -/// declares are explicit specialisations of `std::formatter`, and an explicit -/// specialisation must be seen before any use that would otherwise -/// instantiate the primary template. A translation unit that asks without it -/// gets `std::formatter`'s disabled primary for a type another translation -/// unit formats, and a program whose translation units disagree on that is -/// ill-formed, with no diagnostic required. +/// **Include it in every translation unit that formats any of these types, or +/// asks whether it can** (`std::formattable`). What it declares are +/// specialisations of `std::formatter`, and a specialisation must be seen +/// before any use that would otherwise instantiate the primary template. A +/// translation unit that asks without it gets `std::formatter`'s disabled +/// primary for a type another translation unit formats, and a program whose +/// translation units disagree on that is ill-formed, with no diagnostic +/// required. That holds for an enumeration too: `std::formattable` is true only where this header is included. /// /// **One rule, the library's throughout** (`number_text.hpp`): a decimal is /// written only when it is the exact value, and a rounded one only when the @@ -28,20 +31,25 @@ /// `≈` saying it was rounded; `{:.3HalfEven}` is `0.333`, a rounding the /// format asked for outright. /// -/// **The reference is on the two specialisations**, +/// **The reference is on the specialisations for a number**, /// `std::formatter` and -/// `std::formatter, char>`: the spec's grammar, one +/// `std::formatter, char>`, which `Outcome` follows: +/// the spec's grammar, one /// example per form with the text it writes, the seven rounding-mode names /// and why no mode is assumed, how the width counts, how a spec the grammar /// does not allow fails, and when writing a value throws. The guide /// `docs/display.md`, section "Formatting with `std::format`", sets it out /// for a reader with the output of a real program beside each form. /// -/// **The library owns these two specialisations of `std::formatter`.** A -/// consumer who specialises `std::formatter` or -/// `std::formatter, char>` as well defines one entity -/// twice, which breaks the one-definition rule. Only `char` formatting is -/// provided: a unit's symbol is UTF-8 bytes. +/// **The library owns these specialisations of `std::formatter`.** A consumer +/// who specialises `std::formatter` for `formula::Rational`, +/// `formula::Measured`, `formula::Outcome`, `formula::Unit` or +/// `formula::Dimension` as well defines one entity twice, which breaks the +/// one-definition rule. A consumer's own `std::formatter` for an +/// enumeration listed in `detail::formats_by_describe` does the same, and a +/// generic one constrained on `std::is_enum_v` is ambiguous for those +/// enumerations. Only `char` formatting is provided: a unit's symbol is UTF-8 +/// bytes. #include #include @@ -456,6 +464,16 @@ template return write_formatted_number(spelled.view(), view(shownIn.symbolText), formatSpec, destination); } +/// @p shown's `describe()` words. Called from inside `formula::detail`, so +/// that ordinary lookup stops at `formula::describe` and never reaches a +/// consumer's global of the same name; the enumeration's own overload is found +/// by argument-dependent lookup where the formatter is instantiated. +template +[[nodiscard]] std::string_view described_words(E shown) +{ + return describe(shown); +} + /// Appends @p baseName and @p exponentValue to @p spelled as `L^2` or /// `L^(1/2)`, after a space when @p spelled is not empty; nothing when the /// exponent is zero. @@ -691,7 +709,9 @@ struct formatter, char> /// an empty outcome as `(not measured)`, and a verdict or an invalid outcome /// as its label, filled, aligned and padded to the spec's width. A rounding in /// the spec does not apply to words, but a spec the grammar does not allow is -/// refused as it is for a `Measured`. +/// refused as it is for a `Measured`. A label is right-aligned by default, +/// as a number is; the `Unit`, `Dimension` and enumeration formatters align +/// left by default, as a string does. /// /// std::format("{}", Outcome::value(Measured { Rational { 26, 5 } }, ValueSource::Derived)) 5.2 kJ /// std::format("{}", Outcome::empty()) (not measured) @@ -729,6 +749,9 @@ struct formatter, char> /// `std::format` of a `formula::Unit`: its symbol, filled and aligned as a /// string is. +/// +/// Owned by this library: a consumer's own specialisation of it would define +/// it twice, which breaks the one-definition rule. template <> struct formatter: formatter { @@ -744,6 +767,9 @@ struct formatter: formatter /// `L^2 M^-3` or `L^(1/2)` (base names `L`, `M`, `T`, `I`, `Theta`, `N`, `J`, /// each left out at exponent 0), then any named base by its name, and /// `(dimensionless)` for a pure number. Filled and aligned as a string is. +/// +/// Owned by this library: a consumer's own specialisation of it would define +/// it twice, which breaks the one-definition rule. template <> struct formatter: formatter { @@ -759,6 +785,9 @@ struct formatter: formatter /// `std::format` of a formula enumeration that `detail::formats_by_describe` /// lists: its `describe()` words, filled and aligned as a string is. The /// enumeration's own header must be included. +/// +/// Owned by this library: a consumer's own `std::formatter` for one of those +/// enumerations, or a generic one for every enumeration, collides with it. template requires formula::detail::formats_by_describe struct formatter: formatter @@ -767,7 +796,7 @@ struct formatter: formatter template auto format(E shown, FormatContext& formatContext) const { - return formatter::format(describe(shown), formatContext); + return formatter::format(formula::detail::described_words(shown), formatContext); } }; } // namespace std diff --git a/include/formula-cpp/outcome.hpp b/include/formula-cpp/outcome.hpp index cb4536b3..e0570f30 100644 --- a/include/formula-cpp/outcome.hpp +++ b/include/formula-cpp/outcome.hpp @@ -17,6 +17,7 @@ /// vocabulary that produces them arrives with constraints, and nothing in this /// header needs to know it. +#include #include #include #include diff --git a/include/formula-cpp/series.hpp b/include/formula-cpp/series.hpp index 379964e8..09256370 100644 --- a/include/formula-cpp/series.hpp +++ b/include/formula-cpp/series.hpp @@ -555,12 +555,9 @@ enum class CumulativeDirection : std::uint8_t namespace detail { -template <> -inline constexpr bool formats_by_describe = true; -} // namespace detail + template <> + inline constexpr bool formats_by_describe = true; -namespace detail -{ /// Fails to compile when `sum` is given a single value. Named so the /// operand prints. template diff --git a/include/formula-cpp/snap.hpp b/include/formula-cpp/snap.hpp index a5d6d6f9..1ccd8696 100644 --- a/include/formula-cpp/snap.hpp +++ b/include/formula-cpp/snap.hpp @@ -20,6 +20,7 @@ /// `RequireValidBreakpointTable` -- strictly ascending, every key a number -- /// plus one refusal of its own: it must not be empty. +#include #include #include #include @@ -60,12 +61,9 @@ enum class SnapTie : std::uint8_t namespace detail { -template <> -inline constexpr bool formats_by_describe = true; -} // namespace detail + template <> + inline constexpr bool formats_by_describe = true; -namespace detail -{ /// Fails to compile when a snap's permitted set is empty: there is nothing /// to snap to, and every value would miss. Named so the table prints. template diff --git a/include/formula-cpp/trace.hpp b/include/formula-cpp/trace.hpp index 74328d3c..65589520 100644 --- a/include/formula-cpp/trace.hpp +++ b/include/formula-cpp/trace.hpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include diff --git a/include/formula-cpp/unit.hpp b/include/formula-cpp/unit.hpp index d46c7074..92e85eae 100644 --- a/include/formula-cpp/unit.hpp +++ b/include/formula-cpp/unit.hpp @@ -13,6 +13,7 @@ /// public fields; the convenient types appear at the point of use. #include +#include #include #include diff --git a/test/consumer_globals_run_tests.cpp b/test/consumer_globals_run_tests.cpp index 9ea08c9b..f9666b8d 100644 --- a/test/consumer_globals_run_tests.cpp +++ b/test/consumer_globals_run_tests.cpp @@ -13,7 +13,7 @@ TEST_CASE("a consumer's ordinary globals do not break a build that includes every header", "[hygiene]") { ConsumerGlobalsProbe const probe = probe_consumer_globals(); - REQUIRE(probe.checks.size() == 94); + REQUIRE(probe.checks.size() == 95); for (std::size_t index = 0; index < probe.checks.size(); ++index) { INFO("check " << index); diff --git a/test/consumer_globals_tests.cpp b/test/consumer_globals_tests.cpp index 10b65608..67951dde 100644 --- a/test/consumer_globals_tests.cpp +++ b/test/consumer_globals_tests.cpp @@ -74,7 +74,10 @@ // a measured value spelled by `number_text` in each notation, with // `checked_number_text`, `decimal_text`, `fraction_text` and // `exact_decimal_text`; a `Rational` and a measured value written by -// `std::format`, aligned and rounded; and a quantity declared by alias at +// `std::format`, aligned and rounded, and an `Outcome`, a `Unit`, a +// `Dimension` and an enumeration written the same way; `symbol_of` with no +// vocabulary, and `render` and `document` given `RenderOptions` and none; +// and a quantity declared by alias at // global scope, so that its tag is one more global. A template it does // not reach is not guarded by it. // `consumer_globals_run_tests.cpp` checks that each of these computed what @@ -1246,10 +1249,18 @@ ConsumerGlobalsProbe probe_consumer_globals() == formula::render(formula::var * formula::Rational { 3, 5 }, formula::DefaultVocabulary {}, formula::RenderOptions { .numbers = formula::NumberStyle::exact_decimal() })); + probe.checks.push_back( + formula::document(formula::var * formula::Rational { 3, 5 }, + formula::RenderOptions { .numbers = formula::NumberStyle::exact_decimal() }) + .formula + == formula::document(formula::var * formula::Rational { 3, 5 }, + formula::DefaultVocabulary {}, + formula::RenderOptions { .numbers = formula::NumberStyle::exact_decimal() }) + .formula); // The words of the enumerations a constraint, a retry and a series - // failure report, which a consumer's own function of the same name must - // not make ambiguous. + // failure report, called qualified: this checks that they answer, and + // that the header declaring them compiles beside the consumer's globals. probe.checks.push_back(formula::describe(formula::ConstraintOutcomeKind::Violated) == "violated" && !formula::describe(formula::RetryEnd::Accepted).empty() && !formula::describe(formula::FailureSite::ResultElement).empty()); diff --git a/test/format_tests.cpp b/test/format_tests.cpp index 10b5fb85..df673df7 100644 --- a/test/format_tests.cpp +++ b/test/format_tests.cpp @@ -6,6 +6,7 @@ // same value and style. #include #include +#include #include @@ -315,6 +316,21 @@ TEST_CASE("an outcome writes its value, or says why it has none", "[format]") CHECK(std::format("{}", Held::empty()) == "(not measured)"); CHECK(std::format("{:>18}", Held::verdict({ "repeat the test" })) == " repeat the test"); CHECK(std::format("{:.2HalfEven}", Held::invalid({ "discarded" })) == "discarded"); + // A label is filled and aligned like a number: right by default, and the + // fill the spec names; a rounding in the spec does not touch it. + CHECK(std::format("{:*<12.2HalfEven}", Held::invalid({ "discarded" })) == "discarded***"); + CHECK(std::format("{:18}", Held::verdict({ "repeat the test" })) == " repeat the test"); + CHECK(std::format("{:>16}", Held::empty()) == " (not measured)"); +} + +TEST_CASE("an outcome refuses a spec the grammar does not allow, whatever it holds", "[format]") +{ + using Held = formula::Outcome; + Held const verdict = Held::verdict({ "repeat the test" }); + Held const value = Held::value(Measured { Rational { 26, 5 } }, formula::ValueSource::Derived); + CHECK(refusalOf("{:.2}", verdict).starts_with("formula: this number format rounds but names no rounding mode")); + CHECK(refusalOf("{:.2}", value).starts_with("formula: this number format rounds but names no rounding mode")); + CHECK(refusalOf("{:x}", verdict).starts_with("formula: this number format is not one formula-cpp understands")); } TEST_CASE("a unit is its symbol, a dimension its exponents", "[format]") @@ -324,6 +340,10 @@ TEST_CASE("a unit is its symbol, a dimension its exponents", "[format]") CHECK(std::format("{}", formula::dim::Mass / formula::dim::Volume) == "L^-3 M^1"); CHECK(std::format("{}", formula::nth_root(formula::dim::Length, 2)) == "L^(1/2)"); CHECK(std::format("{}", formula::dim::Scalar) == "(dimensionless)"); + // A named base follows the seven SI exponents, by its name. + CHECK(std::format("{}", formula::base_dimension("EUR") / formula::dim::Energy) == "L^-2 M^-1 T^2 EUR^1"); + CHECK(std::format("{}", formula::base_dimension("EUR")) == "EUR^1"); + CHECK(std::format("[{:<17}]", formula::dim::Scalar) == "[(dimensionless) ]"); } TEST_CASE("a described enumeration is its words, aligned like a string", "[format]") @@ -331,4 +351,24 @@ TEST_CASE("a described enumeration is its words, aligned like a string", "[forma CHECK(std::format("{}", formula::ArithmeticError::Overflow) == "overflow in exact arithmetic"); CHECK(std::format("[{:<12}]", formula::ConstraintOutcomeKind::Violated) == "[violated ]"); CHECK(std::format("{}", formula::ValueSource::ManuallyEntered) == "manually entered"); + CHECK(std::format("{}", formula::RetryEnd::Accepted) == formula::describe(formula::RetryEnd::Accepted)); +} + +TEST_CASE("every enumeration with a describe() is formattable, and no other", "[format]") +{ + STATIC_REQUIRE(std::formattable); + STATIC_REQUIRE(std::formattable); + STATIC_REQUIRE(std::formattable); + STATIC_REQUIRE(std::formattable); + STATIC_REQUIRE(std::formattable); + STATIC_REQUIRE(std::formattable); + STATIC_REQUIRE(std::formattable); + STATIC_REQUIRE(std::formattable); + STATIC_REQUIRE(std::formattable); + STATIC_REQUIRE(std::formattable); + STATIC_REQUIRE(std::formattable); + STATIC_REQUIRE(std::formattable); + // An enumeration with no describe() is not written: the formatter is not + // a blanket one for every enumeration. + STATIC_REQUIRE(!std::formattable); } From 4d953b3a032b08e9574f41d09f180751eb693ed7 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 18:17:57 +0200 Subject: [PATCH 11/59] feat(trace): trace any evaluation with traced, and give every verb an explain twin Only formulas, series and retries could be traced in one call; a method, a curve, a rejection, a constraint or a conformity check needed a hand-built Trace and RecordingSink around the verb, which every caller repeated. traced runs any evaluation that takes a sink with a RecordingSink and returns what it returned, failure included, beside the steps recorded. Seven twins (explain_method, explain_check_method, explain_curve, explain_rejection, explain_check, explain_check_all, explain_conformity) give each remaining verb the same one-call form, and explain_series and explain_retry are now written through traced with the same result types as before. Signed-off-by: Christian Parpart --- CHANGELOG.md | 8 ++ docs/tracing.md | 36 ++++++++ include/formula-cpp/trace.hpp | 129 ++++++++++++++++++++++++++-- test/conformity_tests.cpp | 16 ++++ test/constraint_tests.cpp | 45 ++++++++++ test/consumer_globals_run_tests.cpp | 2 +- test/consumer_globals_tests.cpp | 14 ++- test/curve_tests.cpp | 27 ++++++ test/method_tests.cpp | 54 ++++++++++++ test/rejection_tests.cpp | 23 +++++ test/trace_tests.cpp | 47 ++++++++++ 11 files changed, 391 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c6f18d3..99db3917 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,14 @@ change is recorded here. - `symbol_of()` without a vocabulary is `Describe::symbol`, and `render(x, options)`, `render(x, options)`, `document(x, options)` and `document(x, options)` take `RenderOptions` without a vocabulary that renames nothing. +- `traced(evaluation)`, which runs any evaluation that takes a sink with a `RecordingSink` and + returns `{ outcome, trace }`: what it returned, failure included, and every step it recorded. + `explain_method`, `explain_check_method`, `explain_curve`, + `explain_rejection`, `explain_check`, `explain_check_all` and `explain_conformity` are the + traced twins of `evaluate_method`, `check_method`, `checked_evaluate_curve`, + `checked_evaluate_rejection`, `check`, `check_all` and `check_conformity`, each with the + vocabulary as an optional last argument. `explain_series` and `explain_retry` return the same + shape as before. ### Changed diff --git a/docs/tracing.md b/docs/tracing.md index 99c6581f..e2bc863b 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -197,6 +197,42 @@ note: see usage of 'formula::explain' Evaluate at compile time when you can; `explain` is a run-time-only way to see the working. +## Tracing any evaluation + +`explain` traces a formula. The other verbs that take a sink -- a method, a +curve, a rejection, a constraint, a conformity check -- have a twin of their +own that returns the verb's result together with the trace it recorded: + +```cpp +auto const derived = formula::explain_method(strengthMethod, specimen); +auto const verdicts = formula::explain_check_all(constraintSet, specimen); +``` + +`derived.outcome` is exactly what `evaluate_method` returns, and +`derived.trace` is the `Trace` a `RecordingSink` recorded while it did. +`explain_method`, `explain_check_method`, `explain_curve`, `explain_rejection`, +`explain_check`, `explain_check_all` and `explain_conformity` are the twins of +`evaluate_method`, `check_method`, `checked_evaluate_curve`, +`checked_evaluate_rejection`, `check`, `check_all` and `check_conformity`, and +each takes the vocabulary to write the symbols in as an optional last +argument, as `explain` does. + +A verb without a twin -- or one of your own that takes a sink -- goes through +`traced`, which gives the evaluation a `RecordingSink` and returns what the +evaluation returned beside what the sink recorded: + +```cpp +auto const run = formula::traced([&](auto recordingSink) + { return formula::checked_evaluate(densityFormula, env, recordingSink); }); +``` + +`explain_series` and `explain_retry` share the shape: `outcome`, then `trace`. +A failure is in `outcome`, and `trace` holds the steps up to it; a value that +was typed in rather than derived leaves `trace` empty, as it does for +`explain`. The sink records in `Rational`, so an evaluation that computes in +`double` is traced by calling its `checked_evaluate_si` with your own +`RecordingSink`. + ## Reading a derivation `examples/tracing.cpp` builds the same water/cement ratio diff --git a/include/formula-cpp/trace.hpp b/include/formula-cpp/trace.hpp index 65589520..a497f987 100644 --- a/include/formula-cpp/trace.hpp +++ b/include/formula-cpp/trace.hpp @@ -4331,6 +4331,41 @@ template return std::nullopt; } +/// What an evaluation returned, together with how it was reached. +/// +/// The shape `explain_series`, `explain_retry` and every `explain_*` twin of an +/// evaluation verb share: `outcome`, then `trace`. +template +struct Traced +{ + /// Exactly what the evaluation returned, failure included. + R outcome; + /// Every step the evaluation recorded -- empty when nothing was derived, + /// as for an entered value; see `explain`. + Trace trace {}; +}; + +/// Runs @p evaluation with a `RecordingSink` writing every symbol as +/// @p vocabulary says, and returns what it returned with the trace it +/// recorded -- so any verb that takes a sink can be traced in one call: +/// `traced([&](auto recordingSink) { return check_method(methodGiven, environmentGiven, recordingSink); })`. +/// +/// The result is the evaluation's own: tracing observes, it does not +/// participate. A failure the evaluation returns is in `outcome`, with the +/// steps recorded up to it in `trace`. +/// +/// The sink records in `Rational`, so an evaluation that hands its sink an +/// `Evaluated` cannot be traced here; see `explain`. +template + requires std::invocable> +[[nodiscard]] auto traced(F&& evaluation, V const& vocabulary = V {}) + -> Traced>>> +{ + Trace recorded {}; + auto evaluated = std::invoke(evaluation, RecordingSink { recorded, vocabulary }); + return { std::move(evaluated), std::move(recorded) }; +} + /// An outcome together with the derivation that produced it. template struct Explained @@ -4419,10 +4454,9 @@ template recorded {}; - std::expected, SeriesFailure> seriesOutcome = - checked_evaluate_series(expression, environment, RecordingSink { recorded, vocabulary }); - return ExplainedSeries { std::move(seriesOutcome), std::move(recorded) }; + auto run = traced([&](auto recordingSink) { return checked_evaluate_series(expression, environment, recordingSink); }, + vocabulary); + return ExplainedSeries { std::move(run.outcome), std::move(run.trace) }; } /// Why `checked_explain` has no outcome: the arithmetic error, and the @@ -4507,10 +4541,89 @@ template ::value); - Trace recorded {}; - std::expected, RetryFailure> retryOutcome = - checked_evaluate_retry(retrying, environment, RecordingSink { recorded, vocabulary }); - return ExplainedRetry { std::move(retryOutcome), std::move(recorded) }; + auto run = traced([&](auto recordingSink) { return checked_evaluate_retry(retrying, environment, recordingSink); }, + vocabulary); + return ExplainedRetry { std::move(run.outcome), std::move(run.trace) }; +} + +/// Evaluates @p methodGiven for @p Tag and records how -- `evaluate_method`'s +/// traced twin: its `Evaluated` in `outcome`, the trace in `trace`. +template +[[nodiscard]] auto explain_method(M const& methodGiven, Env const& environmentGiven, V const& vocabulary = V {}) +{ + return traced([&](auto recordingSink) { return evaluate_method(methodGiven, environmentGiven, recordingSink); }, + vocabulary); +} + +/// Checks the constraints of @p methodGiven and records how -- `check_method`'s +/// traced twin: one `ConstraintOutcome` per constraint in `outcome`, the trace +/// in `trace`. +template +[[nodiscard]] auto explain_check_method(M const& methodGiven, Env const& environmentGiven, V const& vocabulary = V {}) +{ + return traced([&](auto recordingSink) { return check_method(methodGiven, environmentGiven, recordingSink); }, vocabulary); +} + +/// Evaluates the curve @p curveGiven for its domain quantity @p DomainResult +/// and its value quantity @p ValueResult and records how -- +/// `checked_evaluate_curve`'s traced twin, failure included in `outcome`. +template +[[nodiscard]] auto explain_curve(C const& curveGiven, Env const& environmentGiven, V const& vocabulary = V {}) +{ + return traced( + [&](auto recordingSink) + { return checked_evaluate_curve(curveGiven, environmentGiven, recordingSink); }, + vocabulary); +} + +/// Evaluates the rejection @p rejectionGiven for @p Result and records how -- +/// `checked_evaluate_rejection`'s traced twin, failure included in `outcome`. +template +[[nodiscard]] auto explain_rejection(RejectionNode const& rejectionGiven, + Env const& environmentGiven, + V const& vocabulary = V {}) +{ + return traced([&](auto recordingSink) + { return checked_evaluate_rejection(rejectionGiven, environmentGiven, recordingSink); }, + vocabulary); +} + +/// Checks @p constraintGiven and records how -- `check`'s traced twin: the +/// `ConstraintOutcome` in `outcome`, the trace in `trace`. +template +[[nodiscard]] auto explain_check(Constraint

const& constraintGiven, Env const& environmentGiven, V const& vocabulary = V {}) +{ + return traced([&](auto recordingSink) { return check(constraintGiven, environmentGiven, recordingSink); }, vocabulary); +} + +/// Checks every constraint of @p constraintsGiven and records how -- +/// `check_all`'s traced twin: one `ConstraintOutcome` per constraint, in +/// declaration order, in `outcome`, and the trace in `trace`. +template +[[nodiscard]] auto explain_check_all(ConstraintSet const& constraintsGiven, + Env const& environmentGiven, + V const& vocabulary = V {}) +{ + return traced([&](auto recordingSink) { return check_all(constraintsGiven, environmentGiven, recordingSink); }, vocabulary); +} + +/// Checks @p conformityGiven and records how -- `check_conformity`'s traced +/// twin: one `ConstraintOutcome` per element in `outcome`, the trace in `trace`. +template +[[nodiscard]] auto explain_conformity(Conformity const& conformityGiven, + Env const& environmentGiven, + V const& vocabulary = V {}) +{ + return traced([&](auto recordingSink) { return check_conformity(conformityGiven, environmentGiven, recordingSink); }, + vocabulary); } /// What a block of a worksheet's derivation stands for. diff --git a/test/conformity_tests.cpp b/test/conformity_tests.cpp index faef45d0..de990cad 100644 --- a/test/conformity_tests.cpp +++ b/test/conformity_tests.cpp @@ -244,6 +244,22 @@ TEST_CASE("a conformity check documents its citation and its subject's rows", "[ CHECK(formula::document(measuredCheck).citations.empty()); } +TEST_CASE("explain_conformity: the elements' outcomes and the trace a RecordingSink records", "[conformity][trace]") +{ + formula::Trace<> handBuilt {}; + auto const direct = formula::check_conformity(measuredCheck, measuredPassing, formula::RecordingSink<> { handBuilt }); + auto const explained = formula::explain_conformity(measuredCheck, measuredPassing); + CHECK(explained.outcome == direct); + // Elements 2 and 5 are outside their rows, 1, 3 and 4 inside: the mix a + // twin checking nothing or everything as satisfied would not reproduce. + CHECK(explained.outcome[0].is_satisfied()); + CHECK(explained.outcome[1].is_violated()); + CHECK(explained.outcome[4].is_violated()); + CHECK(formula::render_trace(explained.trace, { .maxSteps = 100 }) == formula::render_trace(handBuilt, { .maxSteps = 100 })); + REQUIRE(explained.trace.steps.size() == 2); + CHECK(explained.trace.steps[1].kind == formula::StepKind::ConformityChecked); +} + TEST_CASE("a conformity check is one step with one outcome per element", "[conformity][trace]") { formula::Trace<> trace {}; diff --git a/test/constraint_tests.cpp b/test/constraint_tests.cpp index cb5b75dd..2a4812cb 100644 --- a/test/constraint_tests.cpp +++ b/test/constraint_tests.cpp @@ -1,5 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 #include +#include +#include #include @@ -233,6 +235,49 @@ TEST_CASE("constraint set: a not-checked constraint does not suppress a violated } } +// ------------------------------------------------------ tracing a check + +TEST_CASE("explain_check: the constraint's outcome and the trace a RecordingSink records", "[constraint][trace]") +{ + // 20 violates minimumStrength and 45 satisfies it, so a twin that checked + // another constraint or dropped the trace differs on one of the two. + formula::Trace<> handBuilt {}; + auto const direct = formula::check(minimumStrength, strengthOf(20), formula::RecordingSink<> { handBuilt }); + auto const explained = formula::explain_check(minimumStrength, strengthOf(20)); + CHECK(explained.outcome == direct); + CHECK(explained.outcome.is_violated()); + CHECK(formula::render_trace(explained.trace, { .maxSteps = 100 }) == formula::render_trace(handBuilt, { .maxSteps = 100 })); + CHECK(!explained.trace.empty()); + CHECK(explained.trace.steps.back().kind == formula::StepKind::Constraint); + + auto const satisfied = formula::explain_check(minimumStrength, strengthOf(45)); + CHECK(satisfied.outcome.is_satisfied()); + CHECK(formula::render_trace(satisfied.trace, { .maxSteps = 100 }) + != formula::render_trace(explained.trace, { .maxSteps = 100 })); +} + +TEST_CASE("explain_check_all: every constraint's outcome, in order, and the trace a RecordingSink records", + "[constraint][trace]") +{ + auto const bothViolated = strengthAndDiameter(20, 163); + auto const set = formula::constraints(minimumStrength, maximumDiameter); + formula::Trace<> handBuilt {}; + auto const direct = formula::check_all(set, bothViolated, formula::RecordingSink<> { handBuilt }); + auto const explained = formula::explain_check_all(set, bothViolated); + CHECK(explained.outcome == direct); + REQUIRE(explained.outcome.size() == 2); + CHECK(explained.outcome[0].verdict()->label == std::string_view { "reject the specimen" }); + CHECK(explained.outcome[1].verdict()->label == std::string_view { "specimen exceeds diameter tolerance" }); + CHECK(formula::render_trace(explained.trace, { .maxSteps = 100 }) == formula::render_trace(handBuilt, { .maxSteps = 100 })); + CHECK(!explained.trace.empty()); + + // Declared the other way around, the outcomes and the trace swap too. + auto const reversed = formula::explain_check_all(formula::constraints(maximumDiameter, minimumStrength), bothViolated); + CHECK(reversed.outcome[0] == explained.outcome[1]); + CHECK(formula::render_trace(reversed.trace, { .maxSteps = 100 }) + != formula::render_trace(explained.trace, { .maxSteps = 100 })); +} + TEST_CASE("constraint outcome kinds describe themselves in lowercase words", "[constraint]") { STATIC_REQUIRE(formula::describe(formula::ConstraintOutcomeKind::Satisfied) == "satisfied"); diff --git a/test/consumer_globals_run_tests.cpp b/test/consumer_globals_run_tests.cpp index f9666b8d..033ed2a9 100644 --- a/test/consumer_globals_run_tests.cpp +++ b/test/consumer_globals_run_tests.cpp @@ -13,7 +13,7 @@ TEST_CASE("a consumer's ordinary globals do not break a build that includes every header", "[hygiene]") { ConsumerGlobalsProbe const probe = probe_consumer_globals(); - REQUIRE(probe.checks.size() == 95); + REQUIRE(probe.checks.size() == 98); for (std::size_t index = 0; index < probe.checks.size(); ++index) { INFO("check " << index); diff --git a/test/consumer_globals_tests.cpp b/test/consumer_globals_tests.cpp index 67951dde..56011117 100644 --- a/test/consumer_globals_tests.cpp +++ b/test/consumer_globals_tests.cpp @@ -24,7 +24,8 @@ // untraced and traced, with `explain`; `render` and `document` in all three // dialects, with and without a vocabulary, of that formula, of a constraint // and its predicate, and of formulas an overlay fixed, derived and replaced; -// `render_trace`; `check` and `check_all`; `evaluate_method` of an original +// `render_trace`; `traced`, `explain_conformity` and `explain_method`; +// `check` and `check_all`; `evaluate_method` of an original // and of a replaced variant, and `check_method`, with `RecordingSink` and // with a sink of its own; `apply` with every overlay operation; `Outcome`'s // factories; `checked_convert_to`, `checked_within_bounds`, @@ -672,6 +673,17 @@ ConsumerGlobalsProbe probe_consumer_globals() .find("[1 satisfied, 150 mm (from 139 to 163 mm); " "2 violated, 103 mm (at least 127 mm): reject the edge]") != std::string::npos); + // Tracing any evaluation, and two of the explain twins: the same outcome + // and the same steps as the hand-built sink above. + auto const tracedEdgeCheck = formula::traced( + [&](auto recordingSink) { return formula::check_conformity(edgeCheck, bothScreens, recordingSink); }, north); + auto const explainedEdgeCheck = formula::explain_conformity(edgeCheck, bothScreens, north); + auto const explainedStrength = formula::explain_method(overlaid, specimen, north); + probe.checks.push_back(tracedEdgeCheck.outcome == edgeOutcomes && !tracedEdgeCheck.trace.empty()); + probe.checks.push_back(explainedEdgeCheck.outcome == edgeOutcomes + && formula::render_trace(explainedEdgeCheck.trace, { .maxSteps = 20 }) + == formula::render_trace(conformityTrace, { .maxSteps = 20 })); + probe.checks.push_back(explainedStrength.outcome == strength && !explainedStrength.trace.empty()); // A snap: 150 mm among 137, 149 and 151 mm is a tie, decided toward the // higher. auto const snappedEdge = formula::snapped(var); diff --git a/test/curve_tests.cpp b/test/curve_tests.cpp index 3332b1a7..5d748394 100644 --- a/test/curve_tests.cpp +++ b/test/curve_tests.cpp @@ -624,6 +624,33 @@ TEST_CASE("a splice step shows the union, and a failure names its element counte CHECK(failed.steps.back().curveBreak == formula::CurveBreak::AgainstDirection); } +TEST_CASE("explain_curve: the curve's outcome and the trace a RecordingSink records", "[curve][trace]") +{ + constexpr auto spliced = formula::splice(curveA, curveB); + formula::Trace<> handBuilt {}; + auto const direct = formula::checked_evaluate_curve(spliced, noInputs, formula::RecordingSink<> { handBuilt }); + auto const explained = formula::explain_curve(spliced, noInputs); + REQUIRE(direct.has_value()); + CHECK(explained.outcome == direct); + CHECK(formula::render_trace(explained.trace, { .maxSteps = 100 }) == formula::render_trace(handBuilt, { .maxSteps = 100 })); + CHECK(explained.trace.steps.back().kind == formula::StepKind::CurveSplice); + + // A failing curve: the failure is the outcome, and the trace still holds + // the step that broke, so a twin that dropped either would differ. + constexpr auto raised = formula::curve(formula::domain, + formula::series_constant(rat(31, 10), rat(84, 10), rat(40))); + constexpr auto broken = formula::splice(curveA, raised); + formula::Trace<> failedByHand {}; + auto const failedDirect = + formula::checked_evaluate_curve(broken, noInputs, formula::RecordingSink<> { failedByHand }); + auto const failed = formula::explain_curve(broken, noInputs); + REQUIRE(!failed.outcome.has_value()); + CHECK(failed.outcome == failedDirect); + CHECK(failed.outcome.error() == formula::SeriesFailure { formula::ArithmeticError::DomainError, 3 }); + CHECK(formula::render_trace(failed.trace, { .maxSteps = 100 }) == formula::render_trace(failedByHand, { .maxSteps = 100 })); + CHECK(failed.trace.steps.back().failedElement == std::optional { 3 }); +} + TEST_CASE("a splice's failure line names the point and the rule, in either order", "[curve][trace]") { for (bool const xFirst: { true, false }) diff --git a/test/method_tests.cpp b/test/method_tests.cpp index 90c1f91b..283cff90 100644 --- a/test/method_tests.cpp +++ b/test/method_tests.cpp @@ -335,6 +335,40 @@ TEST_CASE("a method applies its own rounding rule to the variant it selects", "[ STATIC_REQUIRE(notATie->value() == formula::Rational { 6'000'000 }); } +TEST_CASE("explain_method: the method's value and the trace a RecordingSink records", "[method][trace]") +{ + constexpr auto methodGiven = formula::method( + formula::variants(formula::variant(var / (var * var) ), + formula::variant(var / (var * var) )), + formula::rounding_rule(), + formula::constraints()); + // Cylinder squares EdgeX, Cube multiplies EdgeX by EdgeY: 6.1 MPa and 0.6 MPa + // on this specimen, so a twin evaluating the other tag has another value and + // another trace. + auto const specimenGiven = specimen(60'500, 100, 999); + + formula::Trace<> handBuilt {}; + formula::Evaluated const direct = + formula::evaluate_method(methodGiven, specimenGiven, formula::RecordingSink<> { handBuilt }); + auto const explained = formula::explain_method(methodGiven, specimenGiven); + CHECK(explained.outcome == direct); + REQUIRE(explained.outcome.has_value()); + CHECK(explained.outcome->value() == formula::Rational { 6'100'000 }); + CHECK(formula::render_trace(explained.trace, { .maxSteps = 100 }) == formula::render_trace(handBuilt, { .maxSteps = 100 })); + CHECK(!explained.trace.empty()); + + auto const otherTag = formula::explain_method(methodGiven, specimenGiven); + CHECK(otherTag.outcome != direct); + CHECK(formula::render_trace(otherTag.trace, { .maxSteps = 100 }) + != formula::render_trace(explained.trace, { .maxSteps = 100 })); + + // The vocabulary is the one the trace is written in. + auto const renamed = formula::explain_method( + methodGiven, specimenGiven, formula::vocabulary(formula::renames("F_max"))); + CHECK(formula::render_trace(renamed.trace, { .maxSteps = 100 }).find("F_max") != std::string::npos); + CHECK(formula::render_trace(explained.trace, { .maxSteps = 100 }).find("F_max") == std::string::npos); +} + TEST_CASE("a sink is told whose constraints they are around the checks, or not at all", "[method][constraint]") { // Both of the pair: told once before the verdicts and once after, and @@ -414,6 +448,26 @@ TEST_CASE("a precision check joins a method's constraints and is checked by chec CHECK(trace.steps[acceptance.operands[0]].kind == formula::StepKind::Constraint); } +TEST_CASE("explain_check_method: the constraints' verdicts and the trace a RecordingSink records", "[method][trace]") +{ + // 1/50 satisfies the precision check and 1/60 violates it, so the verdicts + // and the steps both differ between the two. + formula::Trace<> handBuilt {}; + auto const direct = formula::check_method(pairMethod, pairInputs(ratio(1, 60)), formula::RecordingSink<> { handBuilt }); + auto const explained = formula::explain_check_method(pairMethod, pairInputs(ratio(1, 60))); + CHECK(explained.outcome == direct); + REQUIRE(explained.outcome.size() == 1); + CHECK(explained.outcome[0].is_violated()); + CHECK(formula::render_trace(explained.trace, { .maxSteps = 100 }) == formula::render_trace(handBuilt, { .maxSteps = 100 })); + CHECK(!explained.trace.empty()); + CHECK(explained.trace.steps.back().kind == formula::StepKind::AcceptanceChecked); + + auto const satisfied = formula::explain_check_method(pairMethod, pairInputs(ratio(1, 50))); + CHECK(satisfied.outcome[0].is_satisfied()); + CHECK(formula::render_trace(satisfied.trace, { .maxSteps = 100 }) + != formula::render_trace(explained.trace, { .maxSteps = 100 })); +} + TEST_CASE("with_constant reaches a coefficient inside a precision limit's limit expression", "[method][precision][overlay]") { // A jurisdiction fixes k_r at 1/60. The environment holds no k_r at all, diff --git a/test/rejection_tests.cpp b/test/rejection_tests.cpp index e16256ea..45815c08 100644 --- a/test/rejection_tests.cpp +++ b/test/rejection_tests.cpp @@ -427,6 +427,29 @@ TEST_CASE("a rejection over a unit with no symbol reads its means and deviations "7. settled: 1 rejected, 3 remain\n"); } +TEST_CASE("explain_rejection: the rejection's outcome and the trace a RecordingSink records", "[rejection][trace]") +{ + // rejectionA settles after two rejections; rejectionA1 is stopped by its + // limit after one, so the outcome and the steps differ between the two. + formula::Trace<> handBuilt {}; + auto const direct = + formula::checked_evaluate_rejection(rejectionA, fixtureA, formula::RecordingSink<> { handBuilt }); + REQUIRE(direct.has_value()); + auto const explained = formula::explain_rejection(rejectionA, fixtureA); + REQUIRE(explained.outcome.has_value()); + CHECK(explained.outcome->outcome() == direct->outcome()); + CHECK(explained.outcome->rejected().size() == 2); + CHECK(explained.outcome->rejected().size() == direct->rejected().size()); + CHECK(explained.outcome->passes() == direct->passes()); + CHECK(formula::render_trace(explained.trace, { .maxSteps = 100 }) == formula::render_trace(handBuilt, { .maxSteps = 100 })); + CHECK(!explained.trace.empty()); + + auto const stopped = formula::explain_rejection(rejectionA1, fixtureA); + REQUIRE(stopped.outcome.has_value()); + CHECK(stopped.outcome->rejected().size() == 1); + CHECK(formula::render_trace(stopped.trace, { .maxSteps = 100 }) != formula::render_trace(explained.trace, { .maxSteps = 100 })); +} + TEST_CASE("only the library builds a RejectionOutcome", "[rejection]") { using Built = formula::RejectionOutcome; diff --git a/test/trace_tests.cpp b/test/trace_tests.cpp index 40bfc8a0..412ca083 100644 --- a/test/trace_tests.cpp +++ b/test/trace_tests.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include @@ -1986,3 +1987,49 @@ TEST_CASE("explain_series keeps a failure and its element, and the step that fai REQUIRE(explained.trace.steps.size() == 1); CHECK(explained.trace.steps[0].failedElement == std::optional { 1 }); } + +TEST_CASE("traced returns what the evaluation returned with the steps it recorded", "[trace]") +{ + constexpr auto density = var / var; + auto const environmentGiven = environmentOf(6, 3); + + auto const recordedRun = formula::traced([&](auto recordingSink) + { return formula::checked_evaluate(density, environmentGiven, recordingSink); }); + REQUIRE(recordedRun.outcome.has_value()); + CHECK(recordedRun.outcome == formula::checked_evaluate(density, environmentGiven)); + CHECK(recordedRun.outcome->measurement().value() == formula::Rational { 2 }); + + // The steps are the ones a hand-built sink records: m, V, then the division. + formula::Trace<> handBuilt {}; + (void) formula::checked_evaluate(density, environmentGiven, formula::RecordingSink<> { handBuilt }); + REQUIRE(recordedRun.trace.steps.size() == 3); + CHECK(recordedRun.trace.steps[2].kind == formula::StepKind::Divide); + CHECK(formula::render_trace(recordedRun.trace, { .maxSteps = 100 }) == formula::render_trace(handBuilt, { .maxSteps = 100 })); + + // Another evaluation gives another value and another trace. + auto const other = formula::traced([&](auto recordingSink) + { return formula::checked_evaluate(density, environmentOf(9, 3), recordingSink); }); + CHECK(other.outcome != recordedRun.outcome); + CHECK(formula::render_trace(other.trace, { .maxSteps = 100 }) != formula::render_trace(recordedRun.trace, { .maxSteps = 100 })); + + // Every symbol is written as the vocabulary given says, the default one otherwise. + auto const renamed = formula::traced( + [&](auto recordingSink) { return formula::checked_evaluate(density, environmentGiven, recordingSink); }, + formula::vocabulary(formula::renames("M"))); + CHECK(renamed.trace.steps[0].symbol == "M"); + CHECK(recordedRun.trace.steps[0].symbol == "m"); +} + +TEST_CASE("traced keeps a failure in the outcome and the steps up to it in the trace", "[trace]") +{ + constexpr auto bad = var / formula::number(formula::Rational { 0 }); + auto const environmentGiven = environmentOf(6, 3); + + auto const failed = formula::traced([&](auto recordingSink) + { return formula::checked_evaluate(bad, environmentGiven, recordingSink); }); + REQUIRE(!failed.outcome.has_value()); + CHECK(failed.outcome.error() == formula::ArithmeticError::DivisionByZero); + REQUIRE(!failed.trace.empty()); + CHECK(failed.trace.steps[failed.trace.root()].kind == formula::StepKind::Divide); + CHECK(failed.trace.steps[failed.trace.root()].error == formula::ArithmeticError::DivisionByZero); +} From a38f8a96aa675dc2f1e828cb0d0f985bee11582f Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 18:27:38 +0200 Subject: [PATCH 12/59] feat(rounding): name a rounding once with DecimalRounding A method that rounds the same way in several places repeated the same three arguments -- a unit, a number of places, a mode -- at every use. DecimalRounding and SignificantRounding name them once, and declared_rounding takes the places a unit already declares. Every public factory that took the three arguments separately gains an overload taking the named value and building the very same type: rounded, rounded_to_digits, rounding_rule, with_rounding (with and without a citation, the latter still refused), rounded_output, rounded_sqrt and rounded_elementwise. For rounded_elementwise the named places apply to every element of the series. Every earlier spelling stays, and a unit of the wrong dimension still draws the rounding node's one message. Signed-off-by: Christian Parpart --- CHANGELOG.md | 8 ++++ docs/rounding-and-conditionals.md | 39 +++++++++++++++++ include/formula-cpp/method.hpp | 7 ++++ include/formula-cpp/opaque.hpp | 8 ++++ include/formula-cpp/overlay.hpp | 20 +++++++++ include/formula-cpp/rounded_root.hpp | 8 ++++ include/formula-cpp/rounding_node.hpp | 14 +++++++ include/formula-cpp/series.hpp | 30 +++++++++++++ include/formula-cpp/unit.hpp | 33 +++++++++++++++ test/CMakeLists.txt | 6 +++ test/consumer_globals_run_tests.cpp | 2 +- test/consumer_globals_tests.cpp | 11 +++++ test/method_tests.cpp | 12 ++++++ ...cimal_rounding_unit_dimension_mismatch.cpp | 23 ++++++++++ ...verlay_named_rounding_without_citation.cpp | 22 ++++++++++ test/overlay_tests.cpp | 14 +++++++ test/rounded_output_tests.cpp | 10 +++++ test/rounded_root_tests.cpp | 9 ++++ test/rounding_node_tests.cpp | 42 +++++++++++++++++++ test/series_tests.cpp | 17 ++++++++ 20 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 test/negative/decimal_rounding_unit_dimension_mismatch.cpp create mode 100644 test/negative/overlay_named_rounding_without_citation.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 99db3917..217f529e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,14 @@ change is recorded here. `checked_evaluate_rejection`, `check`, `check_all` and `check_conformity`, each with the vocabulary as an optional last argument. `explain_series` and `explain_retry` return the same shape as before. +- `DecimalRounding` and `SignificantRounding` name a rounding once -- a unit, how many places or + digits, and a `RoundingMode` -- where the three arguments were repeated at every use: + `constexpr DecimalRounding tenthMpa { unit::Megapascal, DecimalPlaces { 1 }, RoundingMode::HalfAwayFromZero };` + then `rounded(x)`. `rounded`, `rounded_to_digits`, `rounding_rule`, `with_rounding`, + `rounded_output`, `rounded_sqrt` and `rounded_elementwise` each take one, and build the same + type as the three arguments do. `rounded_elementwise(series)` rounds every element to the same + places. Every earlier spelling stays. +- `declared_rounding(unit, mode)`, a `DecimalRounding` in the places `unit` declares. ### Changed diff --git a/docs/rounding-and-conditionals.md b/docs/rounding-and-conditionals.md index dc3f8d56..22a3abb7 100644 --- a/docs/rounding-and-conditionals.md +++ b/docs/rounding-and-conditionals.md @@ -48,6 +48,45 @@ library is one. (see [Numbers](numbers.md)), and a rounding node is simply that mode exposed as a position in the tree rather than a call you make on a number you already hold. +## Naming a rounding once + +A method that rounds the same way in several places repeats the same three +arguments each time. `DecimalRounding` (`unit.hpp`) names them once: which unit +the places are counted in, how many, and which way to go. + +```cpp +constexpr formula::DecimalRounding tenthMillimetre { unit::Millimetre, + DecimalPlaces { 1 }, + RoundingMode::HalfAwayFromZero }; +constexpr auto edge = formula::rounded(var); +``` + +`edge` is the very node `rounded(var)` builds -- the same type, not an +equivalent one. Every factory that takes the three arguments separately also +takes the named value: `rounded_to_digits` takes a `SignificantRounding`, and +`rounding_rule`, `with_rounding`, `rounded_output`, `rounded_sqrt` and +`rounded_elementwise` take a `DecimalRounding`. + +```cpp +constexpr auto rule = formula::rounding_rule(); +constexpr auto rootedEdge = formula::rounded_sqrt(var * var); +``` + +`declared_rounding(unit, mode)` builds a `DecimalRounding` from the places a +unit itself declares, so a rounding "to what a millimetre is shown to" does not +restate the number: + +```cpp +constexpr auto asDeclared = formula::declared_rounding(unit::Millimetre, RoundingMode::HalfAwayFromZero); +``` + +A unit that does not measure the operand's dimension is refused as it is when +the arguments are written out: `rounded(var)`, with +`wholeGrams` in `unit::Gram`, draws the rounding node's one message. +`rounded_elementwise(...)` rounds every element of a series to +the same places; a `PlacesTable` still gives each element its own. + ## The reason this is a node at all A method may specify "round the diameter to the nearest millimetre before diff --git a/include/formula-cpp/method.hpp b/include/formula-cpp/method.hpp index ff4ca034..a9eff629 100644 --- a/include/formula-cpp/method.hpp +++ b/include/formula-cpp/method.hpp @@ -1138,6 +1138,13 @@ template return {}; } +/// The rounding rule @p R names: `rounding_rule()`. +template +[[nodiscard]] constexpr RoundingRule rounding_rule() noexcept +{ + return {}; +} + /// Whose constraints a method checks: its own, or a jurisdiction's that /// replaced them. Recorded for the reason `RoundingProvenance` is: a verdict /// saying only that a specimen was rejected is true whether the method's diff --git a/include/formula-cpp/opaque.hpp b/include/formula-cpp/opaque.hpp index 11bcccb2..b09df7ea 100644 --- a/include/formula-cpp/opaque.hpp +++ b/include/formula-cpp/opaque.hpp @@ -1063,6 +1063,14 @@ template (fit)`. +template +[[nodiscard]] constexpr auto rounded_output(OpaqueCall call) noexcept +{ + return rounded_output(call); +} + namespace detail { /// The output @p node rounds, as `opaque_output` builds it: for the walks diff --git a/include/formula-cpp/overlay.hpp b/include/formula-cpp/overlay.hpp index 474b2456..201f0960 100644 --- a/include/formula-cpp/overlay.hpp +++ b/include/formula-cpp/overlay.hpp @@ -729,6 +729,14 @@ template return RoundingOverride { source }; } +/// Replaces the rounding rule as `with_rounding(source)` does, +/// with the rounding @p R names, cited as @p citedAs: `with_rounding(citation)`. +template +[[nodiscard]] constexpr RoundingOverride with_rounding(Citation citedAs) noexcept +{ + return RoundingOverride { citedAs }; +} + /// Refuses a rounding rule with no citation, as `with_constant`'s overload /// refuses a constant. template @@ -741,6 +749,18 @@ template return RoundingOverride {}; } +/// Refuses a rounding rule with no citation, as the three-argument overload +/// does. +template +[[nodiscard]] constexpr RoundingOverride with_rounding() noexcept +{ + static_assert(Stated, + "formula: with_rounding() was given no citation; a rounding rule is a " + "jurisdiction's decision, and a trace must say whose -- pass the Citation of the clause that " + "states it"); + return RoundingOverride {}; +} + /// The operation `replace_variant(expression, source)` builds: replace the /// formula of the variant tagged `Tag` wholesale. /// diff --git a/include/formula-cpp/rounded_root.hpp b/include/formula-cpp/rounded_root.hpp index ccdcbaf0..c597154e 100644 --- a/include/formula-cpp/rounded_root.hpp +++ b/include/formula-cpp/rounded_root.hpp @@ -327,6 +327,14 @@ template return RoundedRootNode { {}, radicand }; } +/// The square root of `radicand`, rounded as @p R names: +/// `rounded_sqrt(var)`. +template +[[nodiscard]] constexpr auto rounded_sqrt(Radicand radicand) noexcept +{ + return rounded_sqrt(radicand); +} + /// Evaluates the radicand, then rounds its square root in `U`. /// /// Under `Rational` this is `detail::rounded_square_root`, exact. Under any diff --git a/include/formula-cpp/rounding_node.hpp b/include/formula-cpp/rounding_node.hpp index 1dc0f609..0e194d54 100644 --- a/include/formula-cpp/rounding_node.hpp +++ b/include/formula-cpp/rounding_node.hpp @@ -104,6 +104,13 @@ template return RoundNode { {}, operand }; } +/// `operand` rounded as @p R names: `rounded(var)`. +template +[[nodiscard]] constexpr auto rounded(Operand toRound) noexcept +{ + return rounded(toRound); +} + /// `operand` rounded to `Digits` significant digits of `U`. template [[nodiscard]] constexpr auto rounded_to_digits(Operand operand) noexcept @@ -111,6 +118,13 @@ template return RoundSignificantNode { {}, operand }; } +/// `operand` rounded as @p S names: `rounded_to_digits(var)`. +template +[[nodiscard]] constexpr auto rounded_to_digits(Operand toRound) noexcept +{ + return rounded_to_digits(toRound); +} + /// Rounding per representation, including the unit conversion it needs. /// /// A **public extension point**, for the same reason as `RepTraits` and diff --git a/include/formula-cpp/series.hpp b/include/formula-cpp/series.hpp index 09256370..919e2e0a 100644 --- a/include/formula-cpp/series.hpp +++ b/include/formula-cpp/series.hpp @@ -523,6 +523,27 @@ template return ElementwiseRoundNode { {}, seriesOperand }; } +namespace detail +{ + /// A `PlacesTable` of @p N entries, every one @p everyElement. + template + [[nodiscard]] constexpr PlacesTable uniform_places(DecimalPlaces everyElement) noexcept + { + PlacesTable tabulated {}; + tabulated.fill(everyElement); + return tabulated; + } +} // namespace detail + +/// @p seriesOperand rounded as @p R names, every element to `R.places` +/// decimal places of `R.unit`: the per-element form with one table entry +/// repeated, `rounded_elementwise(series)`. +template +[[nodiscard]] constexpr auto rounded_elementwise(S seriesOperand) noexcept +{ + return rounded_elementwise(R.places), R.mode>(seriesOperand); +} + /// Which end of a series a running total starts from. /// /// An `enum class` rather than a `bool`, and never defaulted: "a total running @@ -677,6 +698,15 @@ template return detail::RefusedSeries {}; } +/// A single value handed to `rounded_elementwise`: refused as the +/// three-argument form's is. +template +[[nodiscard]] constexpr auto rounded_elementwise(N) noexcept +{ + static_assert(detail::RequireRoundElementwiseOfSeries::value); + return detail::RefusedSeries {}; +} + /// The total of every element of a series: **one value**, and so a `Node`, /// which stands wherever a number stands -- inside a method's variant, beside /// a `var`, or broadcast back over the series it came from (`m_r(i) / diff --git a/include/formula-cpp/unit.hpp b/include/formula-cpp/unit.hpp index 92e85eae..a27f9b6b 100644 --- a/include/formula-cpp/unit.hpp +++ b/include/formula-cpp/unit.hpp @@ -87,6 +87,39 @@ struct Unit [[nodiscard]] constexpr bool operator==(Unit const&) const noexcept = default; }; +/// A rounding to decimal places, named once and used wherever a method rounds +/// the same way: which unit the places are of, how many, and which way to go. +/// `constexpr DecimalRounding tenthMpa { unit::Megapascal, DecimalPlaces { 1 }, RoundingMode::HalfAwayFromZero };` +/// then `rounded(x)`, `rounding_rule()`. Every factory +/// that takes the three arguments separately also takes this. +struct DecimalRounding +{ + /// The unit the places are counted in. + Unit unit; + /// How many decimal places of `unit` to keep. + DecimalPlaces places; + /// Which way to break ties, and which way to go. + RoundingMode mode; +}; + +/// A rounding to significant digits, named once -- `DecimalRounding`'s +/// counterpart for `rounded_to_digits`. +struct SignificantRounding +{ + /// The unit the digits are counted in. + Unit unit; + /// How many significant digits to keep. + SignificantDigits digits; + /// Which way to break ties, and which way to go. + RoundingMode mode; +}; + +/// A rounding to the decimal places @p roundedIn declares, under @p roundingMode. +[[nodiscard]] constexpr DecimalRounding declared_rounding(Unit roundedIn, RoundingMode roundingMode) noexcept +{ + return DecimalRounding { roundedIn, DecimalPlaces { roundedIn.decimals }, roundingMode }; +} + /// Named units. The `decimals` values are ordinary engineering defaults, not /// requirements from any standard; a caller that needs a different precision /// states it at the point of use. diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 64eacc5f..77cf4e90 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -727,6 +727,10 @@ formula_add_negative_test(series_round_places_count_mismatch formula_add_negative_test(series_round_unit_dimension_mismatch "this rounding node names a unit that does not measure the dimension of the expression it rounds" REJECT "are not a PlacesTable of one DecimalPlaces per element of its series") +# A rounding named once as a DecimalRounding, in a unit of another dimension: +# the rounding node's own refusal, once. +formula_add_negative_test(decimal_rounding_unit_dimension_mismatch + "this rounding node names a unit that does not measure the dimension of the expression it rounds" EXPECT_COUNT 1) # Past a refused table nothing indexes or loops over it: evaluated in a # constant expression, rendered, documented and traced, a table of the wrong # count, and a lone DecimalPlaces, each draw the one message; a table of the @@ -1533,6 +1537,8 @@ formula_add_negative_test(overlay_replacement_without_citation "formula: replace_variant(expression) was given no citation") formula_add_negative_test(overlay_rounding_without_citation "formula: with_rounding() was given no citation") +formula_add_negative_test(overlay_named_rounding_without_citation + "formula: with_rounding() was given no citation") formula_add_negative_test(overlay_constraints_without_citation "formula: with_constraints(constraints(...)) was given no citation") diff --git a/test/consumer_globals_run_tests.cpp b/test/consumer_globals_run_tests.cpp index 033ed2a9..2e642686 100644 --- a/test/consumer_globals_run_tests.cpp +++ b/test/consumer_globals_run_tests.cpp @@ -13,7 +13,7 @@ TEST_CASE("a consumer's ordinary globals do not break a build that includes every header", "[hygiene]") { ConsumerGlobalsProbe const probe = probe_consumer_globals(); - REQUIRE(probe.checks.size() == 98); + REQUIRE(probe.checks.size() == 99); for (std::size_t index = 0; index < probe.checks.size(); ++index) { INFO("check " << index); diff --git a/test/consumer_globals_tests.cpp b/test/consumer_globals_tests.cpp index 56011117..e7f7996e 100644 --- a/test/consumer_globals_tests.cpp +++ b/test/consumer_globals_tests.cpp @@ -1303,5 +1303,16 @@ ConsumerGlobalsProbe probe_consumer_globals() && !formula::number_of(std::expected, formula::ArithmeticError> { std::unexpected { formula::ArithmeticError::Overflow } }) .has_value()); + // A rounding named once, and one that takes the places a unit declares: + // 12.36 mm is 12.4 to the tenth of a millimetre a millimetre declares. + { + constexpr formula::DecimalRounding declaredMillimetre = + formula::declared_rounding(unit::Millimetre, formula::RoundingMode::HalfAwayFromZero); + auto const declaredEdge = formula::evaluate( + formula::rounded(var), + formula::environment(formula::Measured { formula::Rational { 1'236, 100 } })); + probe.checks.push_back(declaredEdge.is_value() && declaredEdge.measurement().value() == formula::Rational { 62, 5 } + && declaredMillimetre.places == formula::DecimalPlaces { 1 }); + } return probe; } diff --git a/test/method_tests.cpp b/test/method_tests.cpp index 283cff90..5195777c 100644 --- a/test/method_tests.cpp +++ b/test/method_tests.cpp @@ -507,3 +507,15 @@ TEST_CASE("a vocabulary renames the results in a precision check on every surfac CHECK(text.find("level = 16181/400 g [bound by #") != std::string::npos); CHECK(text.find("x_A") == std::string::npos); } + +TEST_CASE("DecimalRounding: the same rounding rule as the three arguments it names", "[method]") +{ + constexpr formula::DecimalRounding tenthMpa { unit::Megapascal, formula::DecimalPlaces { 1 }, formula::RoundingMode::HalfAwayFromZero }; + using ByValue = decltype(formula::rounding_rule()); + using ByTriple = decltype(formula::rounding_rule()); + STATIC_REQUIRE(std::is_same_v); + constexpr formula::DecimalRounding hundredthMpa { unit::Megapascal, formula::DecimalPlaces { 2 }, formula::RoundingMode::HalfAwayFromZero }; + STATIC_REQUIRE_FALSE(std::is_same_v()), ByValue>); +} diff --git a/test/negative/decimal_rounding_unit_dimension_mismatch.cpp b/test/negative/decimal_rounding_unit_dimension_mismatch.cpp new file mode 100644 index 00000000..c2c59f87 --- /dev/null +++ b/test/negative/decimal_rounding_unit_dimension_mismatch.cpp @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: this rounding node names a unit that does not measure the dimension of the expression it rounds +// +// A rounding named once, in grams, applied to a length: the rounding node's own +// refusal, in its own words (RequireRoundingUnitMatches), exactly as the three +// separate arguments draw it. Naming the rounding as a value adds no second +// message, because the value only forwards to the node. +#include + +struct Diameter: formula::Quantity +{ +}; + +inline constexpr formula::DecimalRounding wholeGrams { formula::unit::Gram, + formula::DecimalPlaces { 0 }, + formula::RoundingMode::HalfAwayFromZero }; + +inline constexpr auto node = formula::rounded(formula::var); + +int main() +{ + return static_cast(sizeof(node)); +} diff --git a/test/negative/overlay_named_rounding_without_citation.cpp b/test/negative/overlay_named_rounding_without_citation.cpp new file mode 100644 index 00000000..8165f623 --- /dev/null +++ b/test/negative/overlay_named_rounding_without_citation.cpp @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: with_rounding() was given no citation +// +// A rounding rule named as a DecimalRounding and stated with no citation: refused +// in the same words as the three-argument spelling, since a trace that says "by +// jurisdiction overlay" with nothing to check says nothing. +// +// This must not compile. +#include + +namespace +{ +inline constexpr formula::DecimalRounding tenthMpa { formula::unit::Megapascal, + formula::DecimalPlaces { 1 }, + formula::RoundingMode::HalfAwayFromZero }; +} // namespace + +int main() +{ + constexpr auto operation = formula::with_rounding(); + return sizeof(operation) > 0 ? 0 : 1; +} diff --git a/test/overlay_tests.cpp b/test/overlay_tests.cpp index 70b5363b..ddb7d30f 100644 --- a/test/overlay_tests.cpp +++ b/test/overlay_tests.cpp @@ -1445,3 +1445,17 @@ TEST_CASE("an operation given an empty citation says so in every clause", "[over != std::string::npos); CHECK(acceptance.ends_with(" [jurisdiction overlay (no citation given)]\n")); } + +TEST_CASE("DecimalRounding: the same overlay operation as the three arguments it names", "[overlay]") +{ + constexpr formula::DecimalRounding hundredthMpa { unit::Megapascal, formula::DecimalPlaces { 2 }, formula::RoundingMode::HalfAwayFromZero }; + using ByValue = decltype(formula::with_rounding(formula::Citation {})); + using ByTriple = decltype(formula::with_rounding(formula::Citation {})); + STATIC_REQUIRE(std::is_same_v); + + // The citation is carried through, not dropped on the way. + constexpr auto cited = formula::with_rounding(roundingAnnex); + STATIC_REQUIRE(cited.source.section == roundingAnnex.section); +} diff --git a/test/rounded_output_tests.cpp b/test/rounded_output_tests.cpp index a429d9ef..25707e09 100644 --- a/test/rounded_output_tests.cpp +++ b/test/rounded_output_tests.cpp @@ -708,3 +708,13 @@ TEST_CASE("rounded output: a step built by hand without its row still says the i CHECK(formula::render_trace(recorded, { .maxSteps = 5 }) == "1. round(an opaque output, to 4 dp) = (not measured) [nearest, ties away from zero] [inside not shown]\n"); } + +TEST_CASE("DecimalRounding: the same rounded output as the three arguments it names", "[rounded-output]") +{ + constexpr formula::DecimalRounding tenthGram { unit::Gram, formula::DecimalPlaces { 1 }, formula::RoundingMode::HalfEven }; + using ByValue = decltype(formula::rounded_output<"span", tenthGram>(spanCall)); + using ByTriple = + decltype(formula::rounded_output<"span", unit::Gram, formula::DecimalPlaces { 1 }, formula::RoundingMode::HalfEven>( + spanCall)); + STATIC_REQUIRE(std::is_same_v); +} diff --git a/test/rounded_root_tests.cpp b/test/rounded_root_tests.cpp index f305973d..7c5df36f 100644 --- a/test/rounded_root_tests.cpp +++ b/test/rounded_root_tests.cpp @@ -266,3 +266,12 @@ TEST_CASE("the exact algorithm scales only the remainder, so a large denominator .value() == Rational { 22'252'283, 200'000 }); } + +TEST_CASE("DecimalRounding: the same rounded root as the three arguments it names", "[rounded-root]") +{ + constexpr formula::DecimalRounding hundredthGram { unit::Gram, DecimalPlaces { 2 }, RoundingMode::HalfAwayFromZero }; + using ByValue = decltype(formula::rounded_sqrt(var)); + using ByTriple = + decltype(formula::rounded_sqrt(var)); + STATIC_REQUIRE(std::is_same_v); +} diff --git a/test/rounding_node_tests.cpp b/test/rounding_node_tests.cpp index 2dccd11c..2976e384 100644 --- a/test/rounding_node_tests.cpp +++ b/test/rounding_node_tests.cpp @@ -162,3 +162,45 @@ TEST_CASE("every rounding mode reaches the node, at a value that lands exactly o STATIC_REQUIRE(toOneDecimalPlace(belowZero) == -twoTwo); STATIC_REQUIRE(toOneDecimalPlace(belowZero) == -twoThree); } + +TEST_CASE("DecimalRounding: the same node as the three arguments it names", "[rounding-node]") +{ + constexpr formula::DecimalRounding tenthMillimetre { unit::Millimetre, + DecimalPlaces { 1 }, + RoundingMode::HalfAwayFromZero }; + using ByValue = decltype(formula::rounded(var)); + using ByTriple = + decltype(formula::rounded(var)); + STATIC_REQUIRE(std::is_same_v); + + // Each of the three members is read: another mode or another places is another node. + constexpr formula::DecimalRounding floored { unit::Millimetre, DecimalPlaces { 1 }, RoundingMode::Floor }; + constexpr formula::DecimalRounding wholeMillimetre { unit::Millimetre, DecimalPlaces { 0 }, RoundingMode::HalfAwayFromZero }; + STATIC_REQUIRE_FALSE(std::is_same_v(var)), ByValue>); + STATIC_REQUIRE_FALSE(std::is_same_v(var)), ByValue>); +} + +TEST_CASE("SignificantRounding: the same node as the three arguments it names", "[rounding-node]") +{ + constexpr formula::SignificantRounding twoFigures { unit::Millimetre, + formula::SignificantDigits { 2 }, + RoundingMode::HalfAwayFromZero }; + using ByValue = decltype(formula::rounded_to_digits(var)); + using ByTriple = decltype(formula::rounded_to_digits(var)); + STATIC_REQUIRE(std::is_same_v); +} + +TEST_CASE("declared_rounding: the places a unit declares", "[rounding-node]") +{ + constexpr formula::DecimalRounding declared = formula::declared_rounding(unit::Kilogram, RoundingMode::HalfEven); + STATIC_REQUIRE(declared.places == DecimalPlaces { unit::Kilogram.decimals }); + STATIC_REQUIRE(declared.unit == unit::Kilogram); + STATIC_REQUIRE(declared.mode == RoundingMode::HalfEven); + // A unit declaring another number of places gives another rounding. + STATIC_REQUIRE(unit::Kilogram.decimals != unit::Millimetre.decimals); + STATIC_REQUIRE(formula::declared_rounding(unit::Millimetre, RoundingMode::HalfEven).places + == DecimalPlaces { unit::Millimetre.decimals }); + STATIC_REQUIRE_FALSE(formula::declared_rounding(unit::Millimetre, RoundingMode::HalfEven).places == declared.places); +} diff --git a/test/series_tests.cpp b/test/series_tests.cpp index ca3d5994..ce2cebdc 100644 --- a/test/series_tests.cpp +++ b/test/series_tests.cpp @@ -958,3 +958,20 @@ TEST_CASE("failure sites describe themselves in lowercase words", "[series]") STATIC_REQUIRE(formula::describe(formula::FailureSite::ResultElement) == "result element"); STATIC_REQUIRE(formula::describe(formula::FailureSite::InputObservation) == "input observation"); } + +TEST_CASE("DecimalRounding: a per-element rounding with the same places for every element", "[series]") +{ + constexpr formula::DecimalRounding tenthPercent { formula::unit::Percent, + formula::DecimalPlaces { 1 }, + formula::RoundingMode::Floor }; + constexpr formula::PlacesTable<5> everyElementOne { formula::DecimalPlaces { 1 }, + formula::DecimalPlaces { 1 }, + formula::DecimalPlaces { 1 }, + formula::DecimalPlaces { 1 }, + formula::DecimalPlaces { 1 } }; + using ByValue = decltype(formula::rounded_elementwise(formula::series)); + using ByTable = decltype(formula::rounded_elementwise( + formula::series)); + STATIC_REQUIRE(std::is_same_v); + STATIC_REQUIRE(ByValue::length == 5); +} From 0a4e88fc05db5f5ba0193aa44d5092b30547b660 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 18:31:57 +0200 Subject: [PATCH 13/59] test(trace): prove every explain twin writes its trace in the vocabulary it is given Only explain_method was tested with a renamed quantity, so a twin that dropped the vocabulary from its traced call would have written the default symbols and passed. Each remaining twin now has a case comparing its rendered trace with a hand-built RecordingSink over the same vocabulary, and asserting that the renamed symbol appears there and not in the default one. Also require a verdict to be present before explain_check_all's test reads its label. Signed-off-by: Christian Parpart --- test/conformity_tests.cpp | 12 ++++++++++++ test/constraint_tests.cpp | 26 ++++++++++++++++++++++++++ test/curve_tests.cpp | 12 ++++++++++++ test/method_tests.cpp | 12 ++++++++++++ test/rejection_tests.cpp | 12 ++++++++++++ 5 files changed, 74 insertions(+) diff --git a/test/conformity_tests.cpp b/test/conformity_tests.cpp index de990cad..777d9938 100644 --- a/test/conformity_tests.cpp +++ b/test/conformity_tests.cpp @@ -260,6 +260,18 @@ TEST_CASE("explain_conformity: the elements' outcomes and the trace a RecordingS CHECK(explained.trace.steps[1].kind == formula::StepKind::ConformityChecked); } +TEST_CASE("explain_conformity writes its trace in the vocabulary it is given", "[conformity][trace][vocabulary]") +{ + constexpr auto south = formula::vocabulary(formula::renames("p_s")); + formula::Trace<> handBuilt {}; + (void) formula::check_conformity(measuredCheck, measuredPassing, formula::RecordingSink { handBuilt, south }); + auto const renamed = formula::explain_conformity(measuredCheck, measuredPassing, south); + auto const plain = formula::explain_conformity(measuredCheck, measuredPassing); + CHECK(formula::render_trace(renamed.trace, { .maxSteps = 100 }) == formula::render_trace(handBuilt, { .maxSteps = 100 })); + CHECK(formula::render_trace(renamed.trace, { .maxSteps = 100 }).starts_with("1. p_s = ")); + CHECK(formula::render_trace(plain.trace, { .maxSteps = 100 }).find("p_s") == std::string::npos); +} + TEST_CASE("a conformity check is one step with one outcome per element", "[conformity][trace]") { formula::Trace<> trace {}; diff --git a/test/constraint_tests.cpp b/test/constraint_tests.cpp index 2a4812cb..6308eb34 100644 --- a/test/constraint_tests.cpp +++ b/test/constraint_tests.cpp @@ -266,6 +266,8 @@ TEST_CASE("explain_check_all: every constraint's outcome, in order, and the trac auto const explained = formula::explain_check_all(set, bothViolated); CHECK(explained.outcome == direct); REQUIRE(explained.outcome.size() == 2); + REQUIRE(explained.outcome[0].verdict().has_value()); + REQUIRE(explained.outcome[1].verdict().has_value()); CHECK(explained.outcome[0].verdict()->label == std::string_view { "reject the specimen" }); CHECK(explained.outcome[1].verdict()->label == std::string_view { "specimen exceeds diameter tolerance" }); CHECK(formula::render_trace(explained.trace, { .maxSteps = 100 }) == formula::render_trace(handBuilt, { .maxSteps = 100 })); @@ -278,6 +280,30 @@ TEST_CASE("explain_check_all: every constraint's outcome, in order, and the trac != formula::render_trace(explained.trace, { .maxSteps = 100 })); } +TEST_CASE("explain_check and explain_check_all write their trace in the vocabulary they are given", + "[constraint][trace][vocabulary]") +{ + constexpr auto south = formula::vocabulary(formula::renames("f_s")); + auto const set = formula::constraints(minimumStrength, maximumDiameter); + + formula::Trace<> singleByHand {}; + (void) formula::check(minimumStrength, strengthOf(20), formula::RecordingSink { singleByHand, south }); + auto const single = formula::explain_check(minimumStrength, strengthOf(20), south); + CHECK(formula::render_trace(single.trace, { .maxSteps = 100 }) == formula::render_trace(singleByHand, { .maxSteps = 100 })); + CHECK(formula::render_trace(single.trace, { .maxSteps = 100 }).find("f_s = ") != std::string::npos); + CHECK(formula::render_trace(formula::explain_check(minimumStrength, strengthOf(20)).trace, { .maxSteps = 100 }).find("f_s") + == std::string::npos); + + formula::Trace<> allByHand {}; + (void) formula::check_all(set, strengthAndDiameter(20, 163), formula::RecordingSink { allByHand, south }); + auto const all = formula::explain_check_all(set, strengthAndDiameter(20, 163), south); + CHECK(formula::render_trace(all.trace, { .maxSteps = 100 }) == formula::render_trace(allByHand, { .maxSteps = 100 })); + CHECK(formula::render_trace(all.trace, { .maxSteps = 100 }).find("f_s = ") != std::string::npos); + CHECK(formula::render_trace(formula::explain_check_all(set, strengthAndDiameter(20, 163)).trace, { .maxSteps = 100 }) + .find("f_s") + == std::string::npos); +} + TEST_CASE("constraint outcome kinds describe themselves in lowercase words", "[constraint]") { STATIC_REQUIRE(formula::describe(formula::ConstraintOutcomeKind::Satisfied) == "satisfied"); diff --git a/test/curve_tests.cpp b/test/curve_tests.cpp index 5d748394..994d5313 100644 --- a/test/curve_tests.cpp +++ b/test/curve_tests.cpp @@ -651,6 +651,18 @@ TEST_CASE("explain_curve: the curve's outcome and the trace a RecordingSink reco CHECK(failed.trace.steps.back().failedElement == std::optional { 3 }); } +TEST_CASE("explain_curve writes its trace in the vocabulary it is given", "[curve][trace][vocabulary]") +{ + constexpr auto south = formula::vocabulary(formula::renames("P_s")); + formula::Trace<> handBuilt {}; + (void) formula::checked_evaluate_curve(grading, screened, formula::RecordingSink { handBuilt, south }); + auto const renamed = formula::explain_curve(grading, screened, south); + auto const plain = formula::explain_curve(grading, screened); + CHECK(formula::render_trace(renamed.trace, { .maxSteps = 100 }) == formula::render_trace(handBuilt, { .maxSteps = 100 })); + CHECK(formula::render_trace(renamed.trace, { .maxSteps = 100 }).find("P_s = ") != std::string::npos); + CHECK(formula::render_trace(plain.trace, { .maxSteps = 100 }).find("P_s") == std::string::npos); +} + TEST_CASE("a splice's failure line names the point and the rule, in either order", "[curve][trace]") { for (bool const xFirst: { true, false }) diff --git a/test/method_tests.cpp b/test/method_tests.cpp index 5195777c..fc438c57 100644 --- a/test/method_tests.cpp +++ b/test/method_tests.cpp @@ -468,6 +468,18 @@ TEST_CASE("explain_check_method: the constraints' verdicts and the trace a Recor != formula::render_trace(explained.trace, { .maxSteps = 100 })); } +TEST_CASE("explain_check_method writes its trace in the vocabulary it is given", "[method][trace][vocabulary]") +{ + constexpr auto south = formula::vocabulary(formula::renames("m_1")); + formula::Trace<> handBuilt {}; + (void) formula::check_method(pairMethod, pairInputs(ratio(1, 50)), formula::RecordingSink { handBuilt, south }); + auto const renamed = formula::explain_check_method(pairMethod, pairInputs(ratio(1, 50)), south); + auto const plain = formula::explain_check_method(pairMethod, pairInputs(ratio(1, 50))); + CHECK(formula::render_trace(renamed.trace, { .maxSteps = 100 }) == formula::render_trace(handBuilt, { .maxSteps = 100 })); + CHECK(formula::render_trace(renamed.trace, { .maxSteps = 100 }).find("m_1 = 40 g") != std::string::npos); + CHECK(formula::render_trace(plain.trace, { .maxSteps = 100 }).find("m_1") == std::string::npos); +} + TEST_CASE("with_constant reaches a coefficient inside a precision limit's limit expression", "[method][precision][overlay]") { // A jurisdiction fixes k_r at 1/60. The environment holds no k_r at all, diff --git a/test/rejection_tests.cpp b/test/rejection_tests.cpp index 45815c08..1107172f 100644 --- a/test/rejection_tests.cpp +++ b/test/rejection_tests.cpp @@ -450,6 +450,18 @@ TEST_CASE("explain_rejection: the rejection's outcome and the trace a RecordingS CHECK(formula::render_trace(stopped.trace, { .maxSteps = 100 }) != formula::render_trace(explained.trace, { .maxSteps = 100 })); } +TEST_CASE("explain_rejection writes its trace in the vocabulary it is given", "[rejection][trace][vocabulary]") +{ + constexpr auto south = formula::vocabulary(formula::renames("m_s")); + formula::Trace<> handBuilt {}; + (void) formula::checked_evaluate_rejection(rejectionA, fixtureA, formula::RecordingSink { handBuilt, south }); + auto const renamed = formula::explain_rejection(rejectionA, fixtureA, south); + auto const plain = formula::explain_rejection(rejectionA, fixtureA); + CHECK(formula::render_trace(renamed.trace, { .maxSteps = 100 }) == formula::render_trace(handBuilt, { .maxSteps = 100 })); + CHECK(formula::render_trace(renamed.trace, { .maxSteps = 100 }).starts_with("1. m_s = ")); + CHECK(formula::render_trace(plain.trace, { .maxSteps = 100 }).find("m_s") == std::string::npos); +} + TEST_CASE("only the library builds a RejectionOutcome", "[rejection]") { using Built = formula::RejectionOutcome; From 53a90728a4246ca8981055d021ed1fb7ed3ffb84 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 18:37:42 +0200 Subject: [PATCH 14/59] fix(rounding): probe every named-rounding overload and pin its refusals The consumer-globals probe instantiated only rounded and declared_rounding, so a parameter name in any other new overload could shadow a consumer's global unseen. It now instantiates each of them, and doing so found one: filling a value-initialised PlacesTable made cl instantiate a helper that warned about a hidden global `i`. The table for rounded_elementwise is now built from an index pack. The single-value refusal of rounded_elementwise gets its own negative case, so deleting it or splitting its message is noticed. with_rounding() forwards to the three-argument refusal instead of repeating its text, declared_rounding reuses declared_decimals and says what a unit declaring decimals outside -18 to 18 does, and the tests for rounded_sqrt, rounded_output and with_rounding also check a second, different rounding so an overload that ignored its value would fail. Signed-off-by: Christian Parpart --- include/formula-cpp/overlay.hpp | 6 +-- include/formula-cpp/series.hpp | 16 +++++-- include/formula-cpp/unit.hpp | 15 +++--- test/CMakeLists.txt | 3 ++ test/consumer_globals_run_tests.cpp | 2 +- test/consumer_globals_tests.cpp | 46 +++++++++++++++++++ .../series_round_named_of_single_value.cpp | 27 +++++++++++ test/overlay_tests.cpp | 2 + test/rounded_output_tests.cpp | 2 + test/rounded_root_tests.cpp | 2 + 10 files changed, 106 insertions(+), 15 deletions(-) create mode 100644 test/negative/series_round_named_of_single_value.cpp diff --git a/include/formula-cpp/overlay.hpp b/include/formula-cpp/overlay.hpp index 201f0960..de602432 100644 --- a/include/formula-cpp/overlay.hpp +++ b/include/formula-cpp/overlay.hpp @@ -754,11 +754,7 @@ template template [[nodiscard]] constexpr RoundingOverride with_rounding() noexcept { - static_assert(Stated, - "formula: with_rounding() was given no citation; a rounding rule is a " - "jurisdiction's decision, and a trace must say whose -- pass the Citation of the clause that " - "states it"); - return RoundingOverride {}; + return with_rounding(); } /// The operation `replace_variant(expression, source)` builds: replace the diff --git a/include/formula-cpp/series.hpp b/include/formula-cpp/series.hpp index 919e2e0a..0b417be9 100644 --- a/include/formula-cpp/series.hpp +++ b/include/formula-cpp/series.hpp @@ -53,6 +53,7 @@ #include #include #include +#include namespace formula { @@ -525,13 +526,22 @@ template namespace detail { + /// A `PlacesTable` of @p N entries, every one @p everyElement. Built from + /// an index pack and not by filling a value-initialised array: on cl that + /// instantiates a compiler-internal helper, which warned (C4459) that its + /// `i` hides a consumer's global of that name. + template + [[nodiscard]] constexpr PlacesTable uniform_places_of(DecimalPlaces everyElement, + std::index_sequence) noexcept + { + return PlacesTable { (static_cast(Indices), everyElement)... }; + } + /// A `PlacesTable` of @p N entries, every one @p everyElement. template [[nodiscard]] constexpr PlacesTable uniform_places(DecimalPlaces everyElement) noexcept { - PlacesTable tabulated {}; - tabulated.fill(everyElement); - return tabulated; + return uniform_places_of(everyElement, std::make_index_sequence {}); } } // namespace detail diff --git a/include/formula-cpp/unit.hpp b/include/formula-cpp/unit.hpp index a27f9b6b..da5c5860 100644 --- a/include/formula-cpp/unit.hpp +++ b/include/formula-cpp/unit.hpp @@ -114,12 +114,6 @@ struct SignificantRounding RoundingMode mode; }; -/// A rounding to the decimal places @p roundedIn declares, under @p roundingMode. -[[nodiscard]] constexpr DecimalRounding declared_rounding(Unit roundedIn, RoundingMode roundingMode) noexcept -{ - return DecimalRounding { roundedIn, DecimalPlaces { roundedIn.decimals }, roundingMode }; -} - /// Named units. The `decimals` values are ordinary engineering defaults, not /// requirements from any standard; a caller that needs a different precision /// states it at the point of use. @@ -694,6 +688,15 @@ inline constexpr bool formats_by_describe = true; return DecimalPlaces { unitOfValue.decimals }; } +/// A rounding to the decimal places @p roundedIn declares, under @p roundingMode. +/// A unit whose declared decimals lie outside -18 to 18 is not refused here: +/// `checked_round` returns `ArithmeticError::Overflow` for that many places, so +/// evaluating the rounding does. +[[nodiscard]] constexpr DecimalRounding declared_rounding(Unit roundedIn, RoundingMode roundingMode) noexcept +{ + return DecimalRounding { roundedIn, declared_decimals(roundedIn), roundingMode }; +} + /// Rounds @p magnitude to the precision its unit declares. [[nodiscard]] constexpr std::expected checked_round_to_declared( Rational magnitude, Unit unitOfValue, RoundingMode roundingMode) noexcept diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 77cf4e90..85dc73a6 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -718,6 +718,9 @@ formula_add_negative_test(series_cumulative_of_single_value_as_series formula_add_negative_test(series_round_of_single_value "rounded_elementwise rounds each element of a series, and this is a single value, not a series" REJECT "no matching" "this expression is a series, not a single value") +formula_add_negative_test(series_round_named_of_single_value + "rounded_elementwise rounds each element of a series, and this is a single value, not a series" EXPECT_COUNT 1 + REJECT "no matching" "this expression is a series, not a single value") # A per-element rounding with a table of the wrong length, which # gates the unit check off, and one with a unit of the wrong dimension. diff --git a/test/consumer_globals_run_tests.cpp b/test/consumer_globals_run_tests.cpp index 2e642686..10e2fd69 100644 --- a/test/consumer_globals_run_tests.cpp +++ b/test/consumer_globals_run_tests.cpp @@ -13,7 +13,7 @@ TEST_CASE("a consumer's ordinary globals do not break a build that includes every header", "[hygiene]") { ConsumerGlobalsProbe const probe = probe_consumer_globals(); - REQUIRE(probe.checks.size() == 99); + REQUIRE(probe.checks.size() == 104); for (std::size_t index = 0; index < probe.checks.size(); ++index) { INFO("check " << index); diff --git a/test/consumer_globals_tests.cpp b/test/consumer_globals_tests.cpp index e7f7996e..5d619966 100644 --- a/test/consumer_globals_tests.cpp +++ b/test/consumer_globals_tests.cpp @@ -1314,5 +1314,51 @@ ConsumerGlobalsProbe probe_consumer_globals() probe.checks.push_back(declaredEdge.is_value() && declaredEdge.measurement().value() == formula::Rational { 62, 5 } && declaredMillimetre.places == formula::DecimalPlaces { 1 }); } + // Every other spelling that takes a rounding named once, beside its + // three-argument form. + { + constexpr formula::DecimalRounding tenthEdge { unit::Millimetre, + formula::DecimalPlaces { 1 }, + formula::RoundingMode::HalfAwayFromZero }; + constexpr formula::SignificantRounding twoFigureEdge { unit::Millimetre, + formula::SignificantDigits { 2 }, + formula::RoundingMode::HalfAwayFromZero }; + constexpr formula::DecimalRounding hundredthPlain { unit::One, + formula::DecimalPlaces { 2 }, + formula::RoundingMode::HalfAwayFromZero }; + auto const namedEnvironment = formula::environment(formula::Measured { formula::Rational { 1'236, 100 } }); + // 12.36 mm to two significant digits is 12 mm. + auto const inFigures = formula::evaluate(formula::rounded_to_digits(var), namedEnvironment); + probe.checks.push_back(inFigures.is_value() && inFigures.measurement().value() == formula::Rational { 12 }); + probe.checks.push_back( + std::is_same_v()), + decltype(formula::rounding_rule())> + && std::is_same_v(formula::Citation { .reference = "Example Standard 3" })), + decltype(formula::with_rounding( + formula::Citation { .reference = "Example Standard 3" }))>); + // The span of 163 and 127 mm is 36 mm, whole under a tenth's rounding. + auto const namedSpan = formula::checked_evaluate( + formula::rounded_output<"span", tenthEdge>( + formula::opaque({ .reference = "Example Standard 3" }, formula::series)), + spanEdges); + probe.checks.push_back(namedSpan.has_value() && namedSpan->measurement().value() == formula::Rational { 36 }); + // The root of 2 to 0.01 is 1.41. + auto const namedRoot = formula::evaluate( + formula::rounded_sqrt(var * formula::Rational { 2 }), specimen); + probe.checks.push_back(namedRoot.is_value() && namedRoot.measurement().value() == formula::Rational { 141, 100 }); + auto const everyEdge = formula::rounded_elementwise(formula::series); + probe.checks.push_back(formula::checked_evaluate_series(everyEdge, spanEdges).has_value() + && std::is_same_v, + decltype(formula::rounded_elementwise< + unit::Millimetre, + formula::PlacesTable<2> { formula::DecimalPlaces { 1 }, + formula::DecimalPlaces { 1 } }, + formula::RoundingMode::HalfAwayFromZero>( + formula::series))>); + } return probe; } diff --git a/test/negative/series_round_named_of_single_value.cpp b/test/negative/series_round_named_of_single_value.cpp new file mode 100644 index 00000000..f2ea32c6 --- /dev/null +++ b/test/negative/series_round_named_of_single_value.cpp @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: rounded_elementwise rounds each element of a series, and this is a single value, not a series +// REJECT: no matching +// REJECT: this expression is a series, not a single value +// +// A per-element rounding, named once as a DecimalRounding, given a single +// value: refused in the same words as the three-argument spelling, through the +// overload that takes a Node for no other purpose, and not by an overload list. +#include + +struct TotalMass: formula::Quantity +{ +}; + +inline constexpr formula::DecimalRounding wholeGrams { formula::unit::Gram, + formula::DecimalPlaces { 0 }, + formula::RoundingMode::HalfEven }; +inline constexpr auto inputs = formula::environment(formula::Measured { formula::Rational { 1249 } }); + +int main() +{ + return formula::checked_evaluate_series(formula::rounded_elementwise(formula::var), + inputs) + .has_value() + ? 0 + : 1; +} diff --git a/test/overlay_tests.cpp b/test/overlay_tests.cpp index ddb7d30f..d06a205b 100644 --- a/test/overlay_tests.cpp +++ b/test/overlay_tests.cpp @@ -1454,6 +1454,8 @@ TEST_CASE("DecimalRounding: the same overlay operation as the three arguments it formula::DecimalPlaces { 2 }, formula::RoundingMode::HalfAwayFromZero>(formula::Citation {})); STATIC_REQUIRE(std::is_same_v); + constexpr formula::DecimalRounding tenthMpa { unit::Megapascal, formula::DecimalPlaces { 1 }, formula::RoundingMode::HalfAwayFromZero }; + STATIC_REQUIRE_FALSE(std::is_same_v(formula::Citation {})), ByValue>); // The citation is carried through, not dropped on the way. constexpr auto cited = formula::with_rounding(roundingAnnex); diff --git a/test/rounded_output_tests.cpp b/test/rounded_output_tests.cpp index 25707e09..b82730e6 100644 --- a/test/rounded_output_tests.cpp +++ b/test/rounded_output_tests.cpp @@ -717,4 +717,6 @@ TEST_CASE("DecimalRounding: the same rounded output as the three arguments it na decltype(formula::rounded_output<"span", unit::Gram, formula::DecimalPlaces { 1 }, formula::RoundingMode::HalfEven>( spanCall)); STATIC_REQUIRE(std::is_same_v); + constexpr formula::DecimalRounding hundredthGram { unit::Gram, formula::DecimalPlaces { 2 }, formula::RoundingMode::HalfEven }; + STATIC_REQUIRE_FALSE(std::is_same_v(spanCall)), ByValue>); } diff --git a/test/rounded_root_tests.cpp b/test/rounded_root_tests.cpp index 7c5df36f..18b48ddb 100644 --- a/test/rounded_root_tests.cpp +++ b/test/rounded_root_tests.cpp @@ -274,4 +274,6 @@ TEST_CASE("DecimalRounding: the same rounded root as the three arguments it name using ByTriple = decltype(formula::rounded_sqrt(var)); STATIC_REQUIRE(std::is_same_v); + constexpr formula::DecimalRounding tenthGram { unit::Gram, DecimalPlaces { 1 }, RoundingMode::HalfAwayFromZero }; + STATIC_REQUIRE_FALSE(std::is_same_v(var)), ByValue>); } From 5ed4f6236f1419b56c307dd3a2ca663dc78bbb7d Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 19:10:52 +0200 Subject: [PATCH 15/59] feat: bind a formula to its result quantity with yields Every verb that evaluates a formula is told its result quantity at every call, because the library never deduces one from an expression: a dimension does not name a quantity. So the quantity was repeated at every call site, where a call could name another quantity of the same dimension and nothing would notice. yields(expression) names the quantity once, where the formula is written. evaluate, checked_evaluate, checked_evaluate_series, checked_evaluate_rejection, explain, checked_explain, explain_series, explain_rejection and define take the bound formula and take Q from it; render and document write the formula it holds. Nothing is deduced from the expression. Q is still the author's, checked against the dimension the expression computes where it is bound, with checked_evaluate's own message, and a quantity named again at a call is accepted only when it is Q. A verb handed a refused bound formula asks nothing further, so one mistake draws one message. Where a verb's own check would add a second, differently worded one -- define and the rejection's evaluation and trace -- a negative case pins the gate. Signed-off-by: Christian Parpart --- CHANGELOG.md | 10 + CMakeLists.txt | 3 +- docs/calculations.md | 2 +- docs/expressions.md | 63 ++++++ include/formula-cpp/calculation.hpp | 19 ++ include/formula-cpp/document.hpp | 11 ++ include/formula-cpp/formula.hpp | 1 + include/formula-cpp/rejection.hpp | 27 +++ include/formula-cpp/render.hpp | 11 ++ include/formula-cpp/series.hpp | 15 ++ include/formula-cpp/trace.hpp | 84 ++++++++ include/formula-cpp/yields.hpp | 137 +++++++++++++ test/CMakeLists.txt | 24 +++ test/consumer_globals_run_tests.cpp | 2 +- test/consumer_globals_tests.cpp | 18 ++ ...ds_rejection_result_dimension_mismatch.cpp | 39 ++++ test/negative/yields_relabelled.cpp | 32 ++++ .../yields_result_dimension_mismatch.cpp | 31 +++ test/negative/yields_series_as_single.cpp | 24 +++ test/yields_tests.cpp | 180 ++++++++++++++++++ 20 files changed, 730 insertions(+), 3 deletions(-) create mode 100644 include/formula-cpp/yields.hpp create mode 100644 test/negative/yields_rejection_result_dimension_mismatch.cpp create mode 100644 test/negative/yields_relabelled.cpp create mode 100644 test/negative/yields_result_dimension_mismatch.cpp create mode 100644 test/negative/yields_series_as_single.cpp create mode 100644 test/yields_tests.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 217f529e..ea675e9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,16 @@ change is recorded here. type as the three arguments do. `rounded_elementwise(series)` rounds every element to the same places. Every earlier spelling stays. - `declared_rounding(unit, mode)`, a `DecimalRounding` in the places `unit` declares. +- `yields(expression)` names a formula's result quantity once, where the formula is written: + `constexpr auto ratio = yields(var / var);` then + `evaluate(ratio, environment)`. `evaluate`, `checked_evaluate`, `checked_evaluate_series`, + `checked_evaluate_rejection`, `explain`, `checked_explain`, `explain_series`, `explain_rejection` + and `define` take it, and return what they return for the formula it holds and `Q`; `render` and + `document` write the formula. The result is still never deduced from the expression: `Q` is + checked against the dimension the expression computes where it is written, with + `checked_evaluate`'s message, and a result named at the call as well is accepted only when it is + `Q`. Nest `documented()` inside it, and reuse the formula in another through `.expression`. + Every earlier spelling stays. ### Changed diff --git a/CMakeLists.txt b/CMakeLists.txt index 36754eea..712defc3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -103,7 +103,8 @@ target_sources(formula-cpp INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}/include/formula-cpp/trace_render.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/include/formula-cpp/unit.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/include/formula-cpp/version.hpp" - "${CMAKE_CURRENT_SOURCE_DIR}/include/formula-cpp/vocabulary.hpp") + "${CMAKE_CURRENT_SOURCE_DIR}/include/formula-cpp/vocabulary.hpp" + "${CMAKE_CURRENT_SOURCE_DIR}/include/formula-cpp/yields.hpp") # These three are workarounds for cl's non-conformance and are REQUIRED to # compile this library's headers at all, so they are INTERFACE, not PRIVATE: diff --git a/docs/calculations.md b/docs/calculations.md index 2e1e9c5b..2f950c60 100644 --- a/docs/calculations.md +++ b/docs/calculations.md @@ -842,7 +842,7 @@ static assertion failed: formula: these definitions read one another in a cycle, cl 19.51 says the same, at the check's own line: ``` -include\formula-cpp/calculation.hpp(550): error C2338: static assertion failed: 'formula: these definitions read one another in a cycle, so none of them can be calculated first -- the quantities on the cycle appear in this diagnostic as the template arguments of RequireAcyclicDefinitions' +include\formula-cpp/calculation.hpp(569): error C2338: static assertion failed: 'formula: these definitions read one another in a cycle, so none of them can be calculated first -- the quantities on the cycle appear in this diagnostic as the template arguments of RequireAcyclicDefinitions' ``` **A worksheet missing an input.** Here the environment has no price: diff --git a/docs/expressions.md b/docs/expressions.md index 2f0bd1c4..c1feb3c9 100644 --- a/docs/expressions.md +++ b/docs/expressions.md @@ -596,3 +596,66 @@ remembers it. When the named parts are values in their own right -- a bill or a report of many values, each built on the ones before -- define each once instead, and let a worksheet calculate each once and recalculate only what a change reaches: [Calculations and worksheets](calculations.md). + +## Naming the result once + +`checked_evaluate` is told its result quantity at every call, and never +works it out, because an expression's dimension does not name a quantity. A +volume over a volume is *a* ratio; whether it is the water/cement ratio or an +air content is the author's decision, and a library that picked one would +sooner or later label a number with another quantity's symbol and +description. `formula::yields` keeps that rule. Nothing is deduced: the +author still names the quantity, but once, where the formula is written, +instead of at every call: + +```cpp +constexpr auto boundRatio = formula::yields(var / var); + +auto const evaluated = formula::evaluate(boundRatio, batch); // an Outcome +auto const explained = formula::explain(boundRatio, batch); // its outcome and its trace +std::string const written = formula::render(boundRatio); // "V_w / V_c" +constexpr auto definition = formula::define(boundRatio); // Ratio, defined by the formula +``` + +The name is checked where it is written. `yields` holds `Q` to the +dimension the expression computes, as `checked_evaluate` does, and refuses +a quantity of another dimension with the same message: *this result quantity +does not measure the dimension this expression computes*. A verb handed the +refused formula adds nothing to it. + +`evaluate`, `checked_evaluate`, `checked_evaluate_series`, +`checked_evaluate_rejection`, `explain`, `checked_explain`, `explain_series`, +`explain_rejection` and `define` each take a bound formula, and return what +they return for the formula it holds and the quantity it names. `render` and +`document` take one too, and write the formula it holds: they name no result. +Naming the quantity again at a call is allowed when it is the same one -- + +```cpp +auto const again = formula::checked_evaluate(boundRatio, batch); +``` + +-- and refused when it is another, even one of the same dimension: *this +formula names its result quantity with yields; evaluate it for that +quantity, or name none*. Two dimensionless quantities are exactly the case +this is for, since their dimensions agree and nothing else would notice. + +**`documented()` goes inside.** A bound formula is not a node: it is the top +of a formula, not a part of one. So it wraps a documented formula, whose +citation stays with the formula, and not the other way round: + +```cpp +constexpr auto citedRatio = formula::yields(formula::documented( + var / var, { .title = "Water/cement ratio", .reference = "Example Standard 1:2020" })); +``` + +`documented(yields(...), ...)` does not compile, because `documented` +takes a node. + +**Reuse goes through `.expression`.** For the same reason, a bound formula is +not an operand of another formula. The formula it holds is, as any formula is +([Composing a formula from other formulas](#composing-a-formula-from-other-formulas)): + +```cpp +// The water a mix of another cement content needs at the same ratio. +constexpr auto mixWater = formula::yields(var * boundRatio.expression); +``` diff --git a/include/formula-cpp/calculation.hpp b/include/formula-cpp/calculation.hpp index 7c308922..91b8fed2 100644 --- a/include/formula-cpp/calculation.hpp +++ b/include/formula-cpp/calculation.hpp @@ -143,6 +143,7 @@ #include #include +#include #include #include @@ -457,6 +458,24 @@ template return Definition { Placeholder {} }; } +/// `define(boundFormula.expression)`, `Q` taken from the `Yields` +/// (`yields.hpp`): a `Definition`, and a series refused as `define` +/// refuses one. `Result` is `Q`'s place for a caller who names it anyway; +/// any other quantity is refused. A refused call defines `Q` as a constant +/// of its dimension, as the series overload above does, so that nothing +/// built on it adds a second message. +template +[[nodiscard]] constexpr auto define(Yields const& boundFormula) noexcept +{ + if constexpr (!detail::RequireYieldsResult::value || !Yields::valid) + { + using Placeholder = ConstantNode::dimension)>; + return Definition { Placeholder {} }; + } + else + return define(boundFormula.expression); +} + // ------------------------------------------------------------ calculations template diff --git a/include/formula-cpp/document.hpp b/include/formula-cpp/document.hpp index c363e2c1..9167251f 100644 --- a/include/formula-cpp/document.hpp +++ b/include/formula-cpp/document.hpp @@ -29,6 +29,7 @@ #include #include #include +#include #include #include @@ -1576,6 +1577,16 @@ template { return document(node, DefaultVocabulary {}); } + +/// Documents the formula @p boundFormula holds (`yields.hpp`) as +/// `document(boundFormula.expression, vocabulary)` does: the same page, +/// every symbol as @p vocabulary says. The result quantity is not added to +/// it -- rendering names no result. +template +[[nodiscard]] Documentation document(Yields const& boundFormula, V const& vocabulary = V {}) +{ + return document(boundFormula.expression, vocabulary); +} /// Documents @p node as `document(node, vocabulary)` does, with every /// number the page writes -- in the formula's text, a derived quantity's /// derivation and a criterion's limit -- written as @p renderOptions says diff --git a/include/formula-cpp/formula.hpp b/include/formula-cpp/formula.hpp index a5d86466..b4072439 100644 --- a/include/formula-cpp/formula.hpp +++ b/include/formula-cpp/formula.hpp @@ -57,3 +57,4 @@ #include #include #include +#include diff --git a/include/formula-cpp/rejection.hpp b/include/formula-cpp/rejection.hpp index 2672ef46..e8f3dbfc 100644 --- a/include/formula-cpp/rejection.hpp +++ b/include/formula-cpp/rejection.hpp @@ -84,6 +84,7 @@ #include #include #include +#include #include #include @@ -1468,4 +1469,30 @@ checked_evaluate_rejection(RejectionNode::value(Measured { *inDeclaredUnit }, ValueSource::Derived), run); } +/// `checked_evaluate_rejection(boundFormula.expression, environmentGiven, +/// recordingSink)`, `Q` taken from the `Yields` (`yields.hpp`). `Result` is +/// `Q`'s place for a caller who names it anyway; any other quantity is +/// refused. +template +[[nodiscard]] constexpr std::expected>, SeriesFailure> +checked_evaluate_rejection(Yields> const& boundFormula, + Env const& environmentGiven, + Sink recordingSink = {}) noexcept +{ + if constexpr (!detail::RequireYieldsResult::value + || !Yields>::valid) + return std::unexpected { SeriesFailure { ArithmeticError::DomainError, std::nullopt } }; // refused already + else + return checked_evaluate_rejection(boundFormula.expression, environmentGiven, recordingSink); +} + } // namespace formula diff --git a/include/formula-cpp/render.hpp b/include/formula-cpp/render.hpp index 6c92a835..ff188e82 100644 --- a/include/formula-cpp/render.hpp +++ b/include/formula-cpp/render.hpp @@ -61,6 +61,7 @@ #include #include #include +#include #include #include @@ -2381,6 +2382,16 @@ template return render(node); } +/// Renders the formula @p boundFormula holds (`yields.hpp`) as +/// `render(boundFormula.expression, vocabulary)` does: plain text unless a +/// dialect is named, every symbol as @p vocabulary says. The result quantity +/// is not written -- rendering names no result. +template +[[nodiscard]] std::string render(Yields const& boundFormula, V const& vocabulary = V {}) +{ + return render(boundFormula.expression, vocabulary); +} + namespace detail { /// One row of an envelope as the range it permits, the unit after the diff --git a/include/formula-cpp/series.hpp b/include/formula-cpp/series.hpp index 0b417be9..6dad19f9 100644 --- a/include/formula-cpp/series.hpp +++ b/include/formula-cpp/series.hpp @@ -44,6 +44,7 @@ #include #include #include +#include #include #include @@ -1392,4 +1393,18 @@ template (boundFormula.expression, environmentGiven, +/// recordingSink)`, `Q` taken from the `Yields` (`yields.hpp`). `Result` is +/// `Q`'s place for a caller who names it anyway; any other quantity is +/// refused. +template +[[nodiscard]] constexpr std::expected, SeriesFailure> checked_evaluate_series( + Yields const& boundFormula, Env const& environmentGiven, Sink recordingSink = {}) noexcept +{ + if constexpr (!detail::RequireYieldsResult::value || !Yields::valid) + return std::unexpected { SeriesFailure { ArithmeticError::DomainError, std::nullopt } }; // refused already + else + return checked_evaluate_series(boundFormula.expression, environmentGiven, recordingSink); +} + } // namespace formula diff --git a/include/formula-cpp/trace.hpp b/include/formula-cpp/trace.hpp index a497f987..c9330caa 100644 --- a/include/formula-cpp/trace.hpp +++ b/include/formula-cpp/trace.hpp @@ -39,6 +39,7 @@ #include #include #include +#include #include #include @@ -4427,6 +4428,25 @@ template (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. +template +[[nodiscard]] Explained explain(Yields const& boundFormula, + Env const& environmentGiven, + V const& vocabulary = V {}) +{ + if constexpr (!detail::RequireYieldsResult::value || !Yields::valid) + return Explained {}; // refused already, where the mistake is + else + return explain(boundFormula.expression, environmentGiven, vocabulary); +} + /// A series outcome together with the derivation that produced it -- the /// series counterpart of `Explained`. /// @@ -4459,6 +4479,26 @@ template { std::move(run.outcome), std::move(run.trace) }; } +/// `explain_series(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. +template +[[nodiscard]] ExplainedSeries explain_series(Yields const& boundFormula, + Env const& environmentGiven, + V const& vocabulary = V {}) +{ + if constexpr (!detail::RequireYieldsResult::value || !Yields::valid) + return ExplainedSeries { + std::unexpected { SeriesFailure { ArithmeticError::DomainError, std::nullopt } }, Trace {} + }; // refused already + else + return explain_series(boundFormula.expression, environmentGiven, vocabulary); +} + /// Why `checked_explain` has no outcome: the arithmetic error, and the /// derivation recorded up to it. A refusal without the steps that led to it /// -- which attribute of a lineage requirement disagreed, say -- would say @@ -4510,6 +4550,25 @@ checked_explain(Expression const& expression, Env const& environment, V const& v return Explained { *checked, std::move(recorded) }; } +/// `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. +template +[[nodiscard]] std::expected, CheckedExplainFailure> checked_explain(Yields const& boundFormula, + Env const& environmentGiven, + V const& vocabulary = V {}) +{ + if constexpr (!detail::RequireYieldsResult::value || !Yields::valid) + return Explained {}; // refused already, where the mistake is + else + return checked_explain(boundFormula.expression, environmentGiven, vocabulary); +} + /// A retry's result together with every attempt that produced it. template struct ExplainedRetry @@ -4596,6 +4655,31 @@ template (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. +template +[[nodiscard]] Traced>, SeriesFailure>> explain_rejection( + Yields> const& boundFormula, + Env const& environmentGiven, + V const& vocabulary = V {}) +{ + if constexpr (!detail::RequireYieldsResult::value + || !Yields>::valid) + return { std::unexpected { SeriesFailure { ArithmeticError::DomainError, std::nullopt } }, Trace {} }; + else + return explain_rejection(boundFormula.expression, environmentGiven, vocabulary); +} + /// Checks @p constraintGiven and records how -- `check`'s traced twin: the /// `ConstraintOutcome` in `outcome`, the trace in `trace`. template diff --git a/include/formula-cpp/yields.hpp b/include/formula-cpp/yields.hpp new file mode 100644 index 00000000..28e4589e --- /dev/null +++ b/include/formula-cpp/yields.hpp @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +/// @file +/// A formula bound to the quantity it computes, named once where the formula +/// is written: `constexpr auto ratio = yields(var / var);` +/// then `evaluate(ratio, environment)`. The author still names the result -- +/// nothing is deduced from the expression, whose dimension does not name a +/// quantity (`evaluate.hpp`) -- but only once. A `Yields` is not a node: it is +/// the top of a formula. Nest `documented()` inside it, not around it, and +/// reuse the formula inside another through `.expression`. +/// +/// Every verb that is told a result quantity takes a `Yields` in place of the +/// expression and the quantity: `evaluate` and `checked_evaluate` here, +/// `checked_evaluate_series` (`series.hpp`), `checked_evaluate_rejection` +/// (`rejection.hpp`), `explain`, `checked_explain`, `explain_series` and +/// `explain_rejection` (`trace.hpp`), and `define` (`calculation.hpp`). Each +/// returns what it returns for `boundFormula.expression` and the quantity the +/// `Yields` names. A result named at the call as well is accepted when it is +/// that quantity, and refused when it is another +/// (`detail::RequireYieldsResult`). `render` (`render.hpp`) and `document` +/// (`document.hpp`) take one too, and write the formula it holds: they name +/// no result. +/// +/// **Refused where the `Yields` is written:** a quantity that does not +/// measure the dimension the expression computes, in `checked_evaluate`'s +/// words (`detail::RequireResultDimension`). A verb given a refused `Yields` +/// adds no second message (`Yields::valid`). + +#include +#include + +#include +#include + +namespace formula +{ + +namespace detail +{ + /// The result a verb is asked for when a `Yields` supplies it: the + /// default of every verb's `Result` for a `Yields`, which no quantity is. + struct ResultOfYields + { + }; + + /// Whether @p E computes @p Q's dimension; true for an expression that + /// publishes none, which its verb checks instead, and for one refused + /// already, whose dimension is a stand-in. + template + [[nodiscard]] consteval bool yields_measures() noexcept + { + if constexpr (requires { E::dimension; }) + return refused_already() || E::dimension == Describe::dimension; + else + return true; + } + + /// Fails to compile when a `Yields` is evaluated for another quantity than + /// the one it names. + template + struct RequireYieldsResult + { + static_assert(std::is_same_v || std::is_same_v, + "formula: this formula names its result quantity with yields; evaluate it for that quantity, or " + "name none -- the two quantities appear in this diagnostic as the template arguments of " + "RequireYieldsResult"); + + /// Whether the result asked for is the one the `Yields` names, or + /// none: what every verb gates on, so that a refused call adds no + /// second message. + static constexpr bool value = std::is_same_v || std::is_same_v; + }; +} // namespace detail + +/// A formula and the quantity it computes -- built by `yields(expression)`. +/// +/// A public aggregate: its check sits in the class body, so one spelled +/// without `yields` is refused as well. It claims nothing a verb could not +/// be told directly: its `Q` is held to the dimension the expression +/// computes, as `checked_evaluate` holds the result it is given. +template +struct Yields +{ + // `RequireResultDimension` is named only as the type `conditional_t` + // picks, so an expression that publishes no dimension -- a retry, say -- + // never instantiates it; behind a `||`, its `::value` would instantiate + // it whatever the left side said. Asked through a `consteval` helper + // instead, clang-cl 22.1.8 adds two errors to the one message: the helper's + // call does not evaluate once the check has failed, and neither does + // the `yields` call. + static_assert( + std::conditional_t, std::true_type>::value); + + /// The quantity this formula computes. + using quantity = Q; + + /// Whether the check above holds, asked without firing it, so that a verb + /// given a refused `Yields` adds no second message. + static constexpr bool valid = detail::yields_measures(); + + /// The formula. Deliberately no `{}` default member initialiser -- see + /// `Corrections` (`lookup.hpp`). + E expression; +}; + +/// @p formulaExpression, bound to the quantity @p Q it computes. +template +[[nodiscard]] constexpr Yields yields(E formulaExpression) noexcept +{ + return Yields { formulaExpression }; +} + +/// `checked_evaluate(boundFormula.expression, environmentGiven, recordingSink)`, +/// `Q` taken from the `Yields`. `Result` is `Q`'s place for a caller who +/// names it anyway; any other quantity is refused. +template +[[nodiscard]] constexpr std::expected, ArithmeticError> checked_evaluate(Yields const& boundFormula, + Env const& environmentGiven, + Sink recordingSink = {}) noexcept +{ + if constexpr (!detail::RequireYieldsResult::value || !Yields::valid) + return Outcome::empty(); // refused already, where the mistake is + else + return checked_evaluate(boundFormula.expression, environmentGiven, recordingSink); +} + +/// Throwing spelling of the overload above, for callers who would only rethrow. +template +[[nodiscard]] constexpr Outcome evaluate(Yields const& boundFormula, + Env const& environmentGiven, + Sink recordingSink = {}) +{ + return detail::or_throw(checked_evaluate(boundFormula, environmentGiven, recordingSink)); +} + +} // namespace formula diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 85dc73a6..2129eec6 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -104,6 +104,7 @@ add_executable(formula-cpp-tests wide_int_tests.cpp wide_rounding_tests.cpp rounded_output_tests.cpp + yields_tests.cpp "${PROJECT_SOURCE_DIR}/support/fail_without_dialogs.cpp") target_link_libraries(formula-cpp-tests PRIVATE formula-cpp::formula-cpp Catch2::Catch2WithMain) formula_apply_warnings(formula-cpp-tests) @@ -488,6 +489,29 @@ formula_add_negative_test(evaluate_series_result_dimension_mismatch # arrived). cl, clang-cl and clang++ give one message either way, so only # the gcc legs can see this REJECT fire. +# A formula bound to its result quantity (`yields.hpp`). Each REJECT on the +# first two pins a verb's gate on Yields::valid: without it, define, +# checked_evaluate_rejection and explain_rejection each add their own +# message for the same mistake (measured on clang-cl 22.1.8, 2 errors with +# any one gate deleted). The gates of checked_evaluate (which evaluate goes +# through), checked_evaluate_series, explain, checked_explain and +# explain_series cannot be seen this way -- each forwards to a verb that +# asks the same RequireResultDimension specialisation, instantiated once, so +# deleting any one of them left clang-cl 22.1.8's count unchanged -- and no +# count pins them. +formula_add_negative_test(yields_result_dimension_mismatch + "formula: this result quantity does not measure the dimension this expression computes" EXPECT_COUNT 1 + REJECT "formula: this definition's expression measures a different dimension") +formula_add_negative_test(yields_rejection_result_dimension_mismatch + "formula: this result quantity does not measure the dimension this expression computes" EXPECT_COUNT 1 + REJECT "formula: this result quantity does not measure what the rejection's determinations measure") +formula_add_negative_test(yields_relabelled + "formula: this formula names its result quantity with yields" EXPECT_COUNT 1 + REJECT "no matching") +formula_add_negative_test(yields_series_as_single + "this expression is a series, not a single value; evaluate it with checked_evaluate_series" EXPECT_COUNT 1 + REJECT "formula: this formula names its result quantity with yields") + # A braced list std::array would pad with absent elements: every spelling # that could reach a std::array parameter, refused because the constructor # taking one is deduced. In the compiler's words, which differ by compiler diff --git a/test/consumer_globals_run_tests.cpp b/test/consumer_globals_run_tests.cpp index 10e2fd69..b6795c49 100644 --- a/test/consumer_globals_run_tests.cpp +++ b/test/consumer_globals_run_tests.cpp @@ -13,7 +13,7 @@ TEST_CASE("a consumer's ordinary globals do not break a build that includes every header", "[hygiene]") { ConsumerGlobalsProbe const probe = probe_consumer_globals(); - REQUIRE(probe.checks.size() == 104); + REQUIRE(probe.checks.size() == 107); for (std::size_t index = 0; index < probe.checks.size(); ++index) { INFO("check " << index); diff --git a/test/consumer_globals_tests.cpp b/test/consumer_globals_tests.cpp index 5d619966..d2b6fc3a 100644 --- a/test/consumer_globals_tests.cpp +++ b/test/consumer_globals_tests.cpp @@ -78,6 +78,9 @@ // `std::format`, aligned and rounded, and an `Outcome`, a `Unit`, a // `Dimension` and an enumeration written the same way; `symbol_of` with no // vocabulary, and `render` and `document` given `RenderOptions` and none; +// `yields` of the formula touching every node kind, and `evaluate`, +// `checked_evaluate`, `explain`, `render`, `document` and `define` of what +// it binds; // and a quantity declared by alias at // global scope, so that its tag is one more global. A template it does // not reach is not guarded by it. @@ -210,6 +213,7 @@ int index; #include #include #include +#include // A quantity declared by alias at global scope, as a consumer would: the // elaborated type specifier declares its tag, `AliasEdgeTag`, as one more @@ -1360,5 +1364,19 @@ ConsumerGlobalsProbe probe_consumer_globals() formula::RoundingMode::HalfAwayFromZero>( formula::series))>); } + // The formula touching every node kind, bound to its result quantity + // once, then evaluated, checked, traced, rendered, documented and defined + // without naming the quantity again. + { + constexpr auto boundStrength = formula::yields(everything); + probe.checks.push_back(formula::evaluate(boundStrength, specimen) == plain + && formula::checked_evaluate(boundStrength, specimen) == checked); + probe.checks.push_back(formula::explain(boundStrength, specimen, north).outcome == explained.outcome + && formula::render(boundStrength, north) == formula::render(everything, north) + && formula::document(boundStrength).formula == formula::document(everything).formula); + constexpr auto boundDefinition = formula::define(boundStrength); + probe.checks.push_back( + std::is_same_v, decltype(formula::define(everything))>); + } return probe; } diff --git a/test/negative/yields_rejection_result_dimension_mismatch.cpp b/test/negative/yields_rejection_result_dimension_mismatch.cpp new file mode 100644 index 00000000..9581b12f --- /dev/null +++ b/test/negative/yields_rejection_result_dimension_mismatch.cpp @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: this result quantity does not measure the dimension this expression computes +// REJECT: formula: this result quantity does not measure what the rejection's determinations measure +// +// A rejection of masses bound to a length: refused where it is bound, once. +// Evaluated and traced, it adds nothing: the rejection's own check of its +// result, which would say the same thing in other words, is not asked of a +// refused Yields. +#include +#include + +struct Mass: formula::Quantity +{ +}; +struct Length: formula::Quantity +{ +}; + +// Invented determinations, in grams. +inline constexpr auto fixture = formula::environment(formula::measured_series(formula::Measured { 41 }, + formula::Measured { 43 }, + formula::Measured { 47 }, + formula::Measured { 53 }, + formula::Measured { 59 })); + +int main() +{ + constexpr auto mostExtreme = formula::PerPass::MostExtreme; + constexpr auto keep = formula::OnLimit::Keep; + constexpr auto rejection = formula::without_outliers, formula::KeepAtLeast<3>>( + formula::series, + formula::deviation_from_mean(formula::Rational { 6, 100 } * formula::pass_mean), + formula::Verdict { "repeat the determinations" }, + formula::Citation { .title = "Example Standard" }); + constexpr auto refused = formula::yields(rejection); + auto const evaluated = formula::checked_evaluate_rejection(refused, fixture); + auto const explained = formula::explain_rejection(refused, fixture); + return evaluated.has_value() && explained.outcome.has_value() ? 0 : 1; +} diff --git a/test/negative/yields_relabelled.cpp b/test/negative/yields_relabelled.cpp new file mode 100644 index 00000000..1b7796f4 --- /dev/null +++ b/test/negative/yields_relabelled.cpp @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: this formula names its result quantity with yields +// REJECT: no matching +// +// A formula bound to the water/cement ratio, evaluated for another +// dimensionless quantity. The dimensions agree, so nothing else could refuse +// it: the formula says which quantity it computes, and the call names +// another. Refused by the overload that takes a bound formula, in this +// library's words, rather than as an overload nobody matched. +#include + +struct WaterVolume: formula::Quantity +{ +}; +struct CementVolume: formula::Quantity +{ +}; +struct WaterCementRatio: formula::Quantity +{ +}; +struct AirContent: formula::Quantity +{ +}; + +inline constexpr auto ratio = formula::yields(formula::var / formula::var); +inline constexpr auto inputs = + formula::environment(formula::Measured { 163 }, formula::Measured { 307 }); + +int main() +{ + return formula::checked_evaluate(ratio, inputs).has_value() ? 0 : 1; +} diff --git a/test/negative/yields_result_dimension_mismatch.cpp b/test/negative/yields_result_dimension_mismatch.cpp new file mode 100644 index 00000000..9e902b99 --- /dev/null +++ b/test/negative/yields_result_dimension_mismatch.cpp @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: this result quantity does not measure the dimension this expression computes +// REJECT: formula: this definition's expression measures a different dimension +// +// A formula bound to a quantity that does not measure what it computes: +// refused where the formula is written, in checked_evaluate's words. The +// refused formula is then evaluated and defined, and neither adds a second +// message: each verb given a refused Yields asks nothing more. +#include + +struct WaterVolume: formula::Quantity +{ +}; +struct CementVolume: formula::Quantity +{ +}; +struct Length: formula::Quantity +{ +}; + +inline constexpr auto inputs = + formula::environment(formula::Measured { 163 }, formula::Measured { 307 }); + +int main() +{ + // The expression is dimensionless; `Length` is not. + constexpr auto refused = formula::yields(formula::var / formula::var); + auto const evaluated = formula::checked_evaluate(refused, inputs); + auto const defined = formula::define(refused); + return evaluated.has_value() && decltype(defined)::valid ? 0 : 1; +} diff --git a/test/negative/yields_series_as_single.cpp b/test/negative/yields_series_as_single.cpp new file mode 100644 index 00000000..4a61a39a --- /dev/null +++ b/test/negative/yields_series_as_single.cpp @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: this expression is a series, not a single value; evaluate it with checked_evaluate_series +// REJECT: formula: this formula names its result quantity with yields +// +// A series bound to its result quantity, handed to checked_evaluate, which +// answers with one value. Refused as the series itself is refused there, +// pointing at checked_evaluate_series; the bound quantity is the right one, +// so the Yields has nothing to add. +#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 { 97 } })); + +int main() +{ + constexpr auto retained = formula::yields(formula::series); + return formula::checked_evaluate(retained, inputs).has_value() ? 0 : 1; +} diff --git a/test/yields_tests.cpp b/test/yields_tests.cpp new file mode 100644 index 00000000..3dff21ee --- /dev/null +++ b/test/yields_tests.cpp @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +namespace +{ +namespace unit = formula::unit; +using formula::var; +using WaterVolume = formula::Quantity; +using CementVolume = formula::Quantity; +using WaterCementRatio = formula::Quantity; + +constexpr auto ratio = formula::yields(var / var); +constexpr auto batch = formula::environment(formula::Measured { 163 }, formula::Measured { 307 }); + +[[nodiscard]] constexpr formula::Rational rat(std::int64_t numerator, std::int64_t denominator = 1) +{ + return formula::Rational { numerator, denominator }; +} + +// The series fixture of `series_tests.cpp`: retained masses of 130, 210, 95, +// 340 and 28 g, with the 95 g screen left unmeasured, so that every element +// differs from every other. The result is asked for in kilograms, so that a +// result left in the expression's grams shows in the numbers. +struct Retained: formula::Quantity +{ +}; +struct RetainedKilograms: + formula::Quantity +{ +}; +constexpr auto inputs = formula::environment(formula::measured_series(formula::Measured { rat(130) }, + formula::Measured { rat(210) }, + formula::Measured::absent(), + formula::Measured { rat(340) }, + formula::Measured { rat(28) })); + +// The rejection fixture of `rejection_tests.cpp`: sample A, 40.2, 39.8, 40.5, +// 44.0, 40.0 and 43.3 g, rejected by a 6 % deviation from each pass's mean, +// which settles at 321/8 g after rejecting elements 3 and 5. +struct Mass: formula::Quantity +{ +}; + +template +[[nodiscard]] constexpr auto sampleOf(Values... values) +{ + return formula::environment(formula::measured_series(formula::Measured { values }...)); +} + +inline constexpr auto fixtureA = sampleOf(rat(402, 10), rat(398, 10), rat(405, 10), rat(44), rat(40), rat(433, 10)); +inline constexpr formula::Verdict repeatTest { "discard the determinations and repeat the test" }; +inline constexpr formula::Citation exampleCited { .title = "Example Standard", .section = "7.4" }; +inline constexpr auto sixPercent = formula::deviation_from_mean(rat(6, 100) * formula::pass_mean); +constexpr auto MostExtreme = formula::PerPass::MostExtreme; +constexpr auto Keep = formula::OnLimit::Keep; +inline constexpr auto rejectionA = formula::without_outliers, formula::KeepAtLeast<4>>( + formula::series, sixPercent, repeatTest, exampleCited); + +// The retry fixture of `retry_tests.cpp`, w_k = 6.08 g + w_{k-1} / 2 from +// 0 g, accepted once it rises by at most 0.76 g: a value that publishes no +// dimension. +struct Estimate: formula::Quantity +{ +}; +inline constexpr auto fourAttempts = formula::retry( + formula::starting_from(formula::constant(rat(0))), + formula::constant(rat(152, 25)) + formula::previous_attempt / rat(2), + formula::previous_attempt - formula::this_attempt >= formula::constant(rat(-19, 25)), + formula::Verdict { "repeat the determination" }, + formula::Citation { .title = "Settled estimate", .reference = "Example Standard 12", .section = "6" }); +} // namespace + +TEST_CASE("yields: the result quantity is named once, where the formula is written", "[yields]") +{ + STATIC_REQUIRE(std::is_same_v, formula::ArithmeticError>>); + STATIC_REQUIRE(formula::checked_evaluate(ratio, batch) + == formula::checked_evaluate(ratio.expression, batch)); + STATIC_REQUIRE(formula::checked_evaluate(ratio, batch) == formula::checked_evaluate(ratio, batch)); + STATIC_REQUIRE(formula::number_of(formula::evaluate(ratio, batch)) == formula::Rational { 163, 307 }); + STATIC_REQUIRE(formula::evaluate(ratio, batch) == formula::evaluate(ratio, batch)); + STATIC_REQUIRE(std::is_same_v); +} + +TEST_CASE("yields: explain, render and document see the formula itself", "[yields]") +{ + auto const explained = formula::explain(ratio, batch); + CHECK(explained.outcome == formula::explain(ratio.expression, batch).outcome); + CHECK(formula::render(ratio) == formula::render(ratio.expression)); + CHECK(formula::document(ratio).formula == formula::document(ratio.expression).formula); + + // The trace is the formula's own, and so is every other spelling: a + // result named again, a dialect, a vocabulary and number options. + CHECK(explained.trace.steps.size() == formula::explain(ratio.expression, batch).trace.steps.size()); + CHECK(formula::explain(ratio, batch).outcome == explained.outcome); + auto const checkedExplained = formula::checked_explain(ratio, batch); + REQUIRE(checkedExplained.has_value()); + CHECK(checkedExplained->outcome == explained.outcome); + CHECK(formula::checked_explain(ratio, batch)->outcome == explained.outcome); + CHECK(formula::render(ratio) == "V_w / V_c"); + CHECK(formula::render(ratio) == formula::render(ratio.expression)); + constexpr auto renamedWater = formula::vocabulary(formula::renames("W")); + CHECK(formula::render(ratio, renamedWater) == "W / V_c"); + CHECK(formula::render(ratio, formula::RenderOptions {}) == "V_w / V_c"); + CHECK(formula::document(ratio, renamedWater).formula == "W / V_c"); + CHECK(formula::document(ratio).formula + == formula::document(ratio.expression).formula); + + // A value that publishes no dimension is not asked about one where it is + // bound: the verb it is handed to judges it. + constexpr auto boundRetry = formula::yields(fourAttempts); + STATIC_REQUIRE(decltype(boundRetry)::valid); + CHECK(formula::render(boundRetry) == formula::render(fourAttempts)); +} + +TEST_CASE("yields: around documented(), and as a calculation's definition", "[yields]") +{ + constexpr auto cited = formula::yields(formula::documented( + var / var, { .title = "Water/cement ratio", .reference = "Example Standard 1:2020" })); + STATIC_REQUIRE(formula::number_of(formula::checked_evaluate(cited, batch)) == formula::Rational { 163, 307 }); + constexpr auto definition = formula::define(ratio); + STATIC_REQUIRE(std::is_same_v); + + // The citation inside is the formula's; the definition holds the formula + // itself, as `define` of it does. + CHECK(formula::document(cited).citations.size() == 1); + STATIC_REQUIRE(std::is_same_v, + decltype(formula::define(ratio.expression))>); + STATIC_REQUIRE( + std::is_same_v(ratio)), std::remove_const_t>); +} + +TEST_CASE("yields: a series is evaluated for the quantity it is bound to", "[yields][series]") +{ + constexpr auto retainedInKilograms = formula::yields(formula::series); + constexpr auto evaluated = formula::checked_evaluate_series(retainedInKilograms, inputs); + STATIC_REQUIRE(std::is_same_v, + std::expected, formula::SeriesFailure>>); + // 130 g is 13/100 kg, and the unmeasured element stays unmeasured. + STATIC_REQUIRE(evaluated->element(0).value() == rat(13, 100)); + STATIC_REQUIRE(evaluated->element(2).is_absent()); + STATIC_REQUIRE(evaluated->element(4).value() == rat(7, 250)); + STATIC_REQUIRE(evaluated == formula::checked_evaluate_series(formula::series, inputs)); + STATIC_REQUIRE(formula::checked_evaluate_series(retainedInKilograms, inputs) == evaluated); + + auto const explained = formula::explain_series(retainedInKilograms, inputs); + CHECK(explained.outcome == evaluated); + CHECK(explained.trace.steps.size() + == formula::explain_series(formula::series, inputs).trace.steps.size()); + CHECK(formula::explain_series(retainedInKilograms, inputs).outcome == evaluated); +} + +TEST_CASE("yields: a rejection of outliers is evaluated for the quantity it is bound to", "[yields][rejection]") +{ + constexpr auto settledMass = formula::yields(rejectionA); + constexpr auto rejected = formula::checked_evaluate_rejection(settledMass, fixtureA); + constexpr auto unbound = formula::checked_evaluate_rejection(rejectionA, fixtureA); + STATIC_REQUIRE(rejected->outcome().measurement().value() == rat(321, 8)); + STATIC_REQUIRE(rejected->outcome() == unbound->outcome()); + STATIC_REQUIRE(rejected->passes() == 3); + STATIC_REQUIRE(rejected->rejected().size() == 2); + STATIC_REQUIRE(rejected->rejected()[1] == formula::RejectedElement { 5, 2 }); + STATIC_REQUIRE(formula::checked_evaluate_rejection(settledMass, fixtureA)->outcome() == unbound->outcome()); + + auto const explained = formula::explain_rejection(settledMass, fixtureA); + REQUIRE(explained.outcome.has_value()); + CHECK(explained.outcome->outcome() == unbound->outcome()); + CHECK(explained.trace.steps.size() == formula::explain_rejection(rejectionA, fixtureA).trace.steps.size()); + CHECK(formula::explain_rejection(settledMass, fixtureA).outcome->outcome() == unbound->outcome()); +} From 17b2671de9fd51c85207ae0121027338dc13f504 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 19:15:54 +0200 Subject: [PATCH 16/59] refactor: print with std::print in tools, support code and tests The project prints with std::print and std::println everywhere outside the CRT-failure handler, so the gallery generator, the census report and the census tests move off printf. Their output stays byte-identical. support/fail_without_dialogs.cpp keeps std::fputs, now with a comment saying why: its invalid-parameter handler is noexcept and must neither allocate nor throw, which std::print may do. The package consumer under test/package keeps printf: the Package workflow builds it on ubuntu-24.04 with the default g++ 13, whose standard library has no . Signed-off-by: Christian Parpart --- support/census_report.cpp | 15 ++++----- support/fail_without_dialogs.cpp | 1 + test/overflow_census_tests.cpp | 4 +-- tools/gallery/main.cpp | 54 ++++++++++++++++---------------- 4 files changed, 38 insertions(+), 36 deletions(-) diff --git a/support/census_report.cpp b/support/census_report.cpp index baaae568..a2c67601 100644 --- a/support/census_report.cpp +++ b/support/census_report.cpp @@ -6,6 +6,7 @@ #include "census_tally.hpp" #include +#include namespace { @@ -21,13 +22,13 @@ struct ReportAtExit ~ReportAtExit() { using formula::detail::CensusRole; - std::printf("overflow census: numerator %d bits, denominator %d bits, intermediate %d bits, unsigned %d " - "bits; headroom %d of 63\n", - formula_census::bits_used(CensusRole::Numerator), - formula_census::bits_used(CensusRole::Denominator), - formula_census::bits_used(CensusRole::Intermediate), - formula_census::bits_used(CensusRole::Unsigned), - 63 - formula_census::signed_bits_used()); + std::println("overflow census: numerator {} bits, denominator {} bits, intermediate {} bits, unsigned {} " + "bits; headroom {} of 63", + formula_census::bits_used(CensusRole::Numerator), + formula_census::bits_used(CensusRole::Denominator), + formula_census::bits_used(CensusRole::Intermediate), + formula_census::bits_used(CensusRole::Unsigned), + 63 - formula_census::signed_bits_used()); std::fflush(stdout); } }; diff --git a/support/fail_without_dialogs.cpp b/support/fail_without_dialogs.cpp index 617d38ac..151112cc 100644 --- a/support/fail_without_dialogs.cpp +++ b/support/fail_without_dialogs.cpp @@ -56,6 +56,7 @@ void report_invalid_parameter(wchar_t const* expression, (void) file; (void) line; (void) reserved; + // fputs, not std::print: this handler is noexcept and must neither allocate nor throw, which std::print may do. std::fputs("formula: the C runtime rejected an invalid parameter; exiting without a dialog\n", stderr); std::fflush(stderr); // _Exit, not abort: abort would re-enter the very handling this file is diff --git a/test/overflow_census_tests.cpp b/test/overflow_census_tests.cpp index 06891d12..e04a7faf 100644 --- a/test/overflow_census_tests.cpp +++ b/test/overflow_census_tests.cpp @@ -22,9 +22,9 @@ #include #include #include -#include #include #include +#include #include #include #include @@ -73,7 +73,7 @@ template /// cmake/CheckCensusPage.cmake to collect. void emit(char const* table, std::string const& line) { - std::printf("@census:%s:%s\n", table, line.c_str()); + std::println("@census:{}:{}", table, line); } /// Prints one row of the statistics table, in the page's shape. diff --git a/tools/gallery/main.cpp b/tools/gallery/main.cpp index 5173e294..a685f745 100644 --- a/tools/gallery/main.cpp +++ b/tools/gallery/main.cpp @@ -28,9 +28,9 @@ #include #include -#include #include #include +#include #include #include #include @@ -596,7 +596,7 @@ template if (plain.citations.empty()) { - std::fprintf(stderr, "formula-cpp-gallery: a gallery constraint must be cited, and this one is not\n"); + std::println(stderr, "formula-cpp-gallery: a gallery constraint must be cited, and this one is not"); return false; } formula::Citation const& citation = plain.citations.front(); @@ -647,14 +647,14 @@ int main(int argc, char** argv) { if (argc != 2) { - std::fprintf(stderr, "usage: formula-cpp-gallery \n"); + std::println(stderr, "usage: formula-cpp-gallery "); return 1; } std::ofstream out { argv[1], std::ios::trunc }; if (!out) { - std::fprintf(stderr, "formula-cpp-gallery: could not open '%s' for writing\n", argv[1]); + std::println(stderr, "formula-cpp-gallery: could not open '{}' for writing", argv[1]); return 1; } @@ -694,7 +694,7 @@ int main(int argc, char** argv) auto const outcome = formula::checked_evaluate(waterCementRatio, inputs); if (!outcome.has_value() || !outcome->is_value()) { - std::fprintf(stderr, "formula-cpp-gallery: the worked evaluation did not produce a value\n"); + std::println(stderr, "formula-cpp-gallery: the worked evaluation did not produce a value"); return 1; } formula::Rational const result = outcome->measurement().value(); @@ -717,7 +717,7 @@ int main(int argc, char** argv) formula::Explained const explained = formula::explain(density, densityInputs); if (!explained.outcome.is_value()) { - std::fprintf(stderr, "formula-cpp-gallery: the worked derivation did not produce a value\n"); + std::println(stderr, "formula-cpp-gallery: the worked derivation did not produce a value"); return 1; } @@ -738,7 +738,7 @@ int main(int argc, char** argv) formula::explain(compactionAdjustedDensity, compactionInputs); if (!explainedCompaction.outcome.is_value()) { - std::fprintf(stderr, "formula-cpp-gallery: the worked conditional did not produce a value\n"); + std::println(stderr, "formula-cpp-gallery: the worked conditional did not produce a value"); return 1; } @@ -763,7 +763,7 @@ int main(int argc, char** argv) formula::ConstraintOutcome const diameterOutcome = formula::check(maximumDiameter, oversizedSpecimen, constraintSink); if (!diameterOutcome.is_violated()) { - std::fprintf(stderr, "formula-cpp-gallery: the worked constraint did not violate as expected\n"); + std::println(stderr, "formula-cpp-gallery: the worked constraint did not violate as expected"); return 1; } @@ -791,7 +791,7 @@ int main(int argc, char** argv) formula::explain(correctedStrength, correctedInputs); if (!explainedCorrected.outcome.is_value()) { - std::fprintf(stderr, "formula-cpp-gallery: the worked lookup derivation did not produce a value\n"); + std::println(stderr, "formula-cpp-gallery: the worked lookup derivation did not produce a value"); return 1; } @@ -821,7 +821,7 @@ int main(int argc, char** argv) formula::checked_evaluate(sizeAllowance, uncoveredSpecimen, missSink); if (missed.has_value() || missed.error() != formula::ArithmeticError::DomainError) { - std::fprintf(stderr, "formula-cpp-gallery: the uncovered diameter did not report a domain error\n"); + std::println(stderr, "formula-cpp-gallery: the uncovered diameter did not report a domain error"); return 1; } @@ -850,7 +850,7 @@ int main(int argc, char** argv) formula::evaluate_method(cubeStrengthMethod, cubeSpecimen, formula::RecordingSink<> { methodTrace }); if (!baseStrength.has_value() || !baseStrength->has_value()) { - std::fprintf(stderr, "formula-cpp-gallery: the worked method did not produce a value\n"); + std::println(stderr, "formula-cpp-gallery: the worked method did not produce a value"); return 1; } @@ -868,7 +868,7 @@ int main(int argc, char** argv) formula::evaluate_method(overlaidStrengthMethod, cubeSpecimen, formula::RecordingSink<> { overlaidTrace }); if (!overlaidStrength.has_value() || !overlaidStrength->has_value()) { - std::fprintf(stderr, "formula-cpp-gallery: the worked overlaid method did not produce a value\n"); + std::println(stderr, "formula-cpp-gallery: the worked overlaid method did not produce a value"); return 1; } @@ -890,7 +890,7 @@ int main(int argc, char** argv) formula::check_method(cubeStrengthMethod, cubeSpecimen, formula::RecordingSink<> { ownAcceptance }); if (ownOutcomes.size() != 1 || !ownOutcomes[0].is_satisfied()) { - std::fprintf(stderr, "formula-cpp-gallery: the method's own check did not hold\n"); + std::println(stderr, "formula-cpp-gallery: the method's own check did not hold"); return 1; } @@ -906,7 +906,7 @@ int main(int argc, char** argv) formula::check_method(overlaidStrengthMethod, cubeSpecimen, formula::RecordingSink<> { overlaidAcceptance }); if (overlaidOutcomes.size() != 2 || !overlaidOutcomes[0].is_violated() || !overlaidOutcomes[1].is_satisfied()) { - std::fprintf(stderr, "formula-cpp-gallery: the overlay's checks did not answer as expected\n"); + std::println(stderr, "formula-cpp-gallery: the overlay's checks did not answer as expected"); return 1; } @@ -937,7 +937,7 @@ int main(int argc, char** argv) passingEachScreen, screenAnalysis, formula::RecordingSink<> { seriesTrace }); if (!passingValues.has_value()) { - std::fprintf(stderr, "formula-cpp-gallery: the worked series did not evaluate\n"); + std::println(stderr, "formula-cpp-gallery: the worked series did not evaluate"); return 1; } @@ -959,7 +959,7 @@ int main(int argc, char** argv) formula::checked_evaluate(passingAtOpening, screenAnalysis, formula::RecordingSink<> { curveTrace }); if (!readOff.has_value() || !readOff->is_value()) { - std::fprintf(stderr, "formula-cpp-gallery: the worked curve did not produce a value\n"); + std::println(stderr, "formula-cpp-gallery: the worked curve did not produce a value"); return 1; } @@ -989,7 +989,7 @@ int main(int argc, char** argv) formula::checked_evaluate_series(classShares, sieved, formula::RecordingSink<> { binningTrace }); if (!shared.has_value()) { - std::fprintf(stderr, "formula-cpp-gallery: the worked binning did not evaluate\n"); + std::println(stderr, "formula-cpp-gallery: the worked binning did not evaluate"); return 1; } @@ -1014,7 +1014,7 @@ int main(int argc, char** argv) formula::checked_evaluate_series(classShares, oversized, formula::RecordingSink<> { binningMissTrace }); if (missedShares.has_value()) { - std::fprintf(stderr, "formula-cpp-gallery: the oversized particle did not miss\n"); + std::println(stderr, "formula-cpp-gallery: the oversized particle did not miss"); return 1; } @@ -1041,7 +1041,7 @@ int main(int argc, char** argv) formula::checked_evaluate(massMean, sixDeterminations, formula::RecordingSink<> { meanTrace }); if (!meanValue.has_value() || meanValue->measurement().value() != formula::Rational { 413, 10 }) { - std::fprintf(stderr, "formula-cpp-gallery: the worked mean did not come to 41.3 g\n"); + std::println(stderr, "formula-cpp-gallery: the worked mean did not come to 41.3 g"); return 1; } out << "```\n" << formula::render_trace(meanTrace, { .maxSteps = 20 }) << "```\n\n"; @@ -1053,7 +1053,7 @@ int main(int argc, char** argv) formula::checked_evaluate(massSpread, sixDeterminations, formula::RecordingSink<> { spreadTrace }); if (!spreadValue.has_value() || spreadValue->measurement().value() != formula::Rational { 37, 20 }) { - std::fprintf(stderr, "formula-cpp-gallery: the worked spread did not come to 1.85 g\n"); + std::println(stderr, "formula-cpp-gallery: the worked spread did not come to 1.85 g"); return 1; } out << "```\n" << formula::render_trace(spreadTrace, { .maxSteps = 20 }) << "```\n\n"; @@ -1066,7 +1066,7 @@ int main(int argc, char** argv) meanWithoutOutliers, sixDeterminations, formula::RecordingSink<> { settledTrace }); if (!settledMean.has_value() || settledMean->measurement().value() != formula::Rational { 321, 8 }) { - std::fprintf(stderr, "formula-cpp-gallery: the worked rejection did not settle at 321/8 g\n"); + std::println(stderr, "formula-cpp-gallery: the worked rejection did not settle at 321/8 g"); return 1; } out << "```\n" << formula::render_trace(settledTrace, { .maxSteps = 30 }) << "```\n\n"; @@ -1080,7 +1080,7 @@ int main(int argc, char** argv) meanAtMostOne, sixDeterminations, formula::RecordingSink<> { abortedTrace }); if (abortedMean.has_value()) { - std::fprintf(stderr, "formula-cpp-gallery: the worked rejection did not abort\n"); + std::println(stderr, "formula-cpp-gallery: the worked rejection did not abort"); return 1; } out << "```\n" << formula::render_trace(abortedTrace, { .maxSteps = 30 }) << "```\n\n"; @@ -1097,7 +1097,7 @@ int main(int argc, char** argv) formula::check(repeatabilityCheck, twoDeterminations, formula::RecordingSink<> { precisionTrace }); if (!agreed.is_satisfied()) { - std::fprintf(stderr, "formula-cpp-gallery: the worked precision check did not hold\n"); + std::println(stderr, "formula-cpp-gallery: the worked precision check did not hold"); return 1; } out << "```\n" << formula::render_trace(precisionTrace, { .maxSteps = 30 }) << "```\n\n"; @@ -1131,7 +1131,7 @@ int main(int argc, char** argv) relativeStrength, sameBatch, formula::RecordingSink<> { relativeTrace }); if (!relative.has_value() || !relative->has_value()) { - std::fprintf(stderr, "formula-cpp-gallery: the relative strength did not produce a value\n"); + std::println(stderr, "formula-cpp-gallery: the relative strength did not produce a value"); return 1; } @@ -1159,7 +1159,7 @@ int main(int argc, char** argv) gatedRelativeStrength, otherBatch, formula::RecordingSink<> { gatedTrace }); if (gated.has_value() || gated.error() != formula::ArithmeticError::DomainError) { - std::fprintf(stderr, "formula-cpp-gallery: the gated read was not refused\n"); + std::println(stderr, "formula-cpp-gallery: the gated read was not refused"); return 1; } @@ -1194,7 +1194,7 @@ int main(int argc, char** argv) settlementSlope, settlementReadings, formula::RecordingSink<> { fitTrace }); if (!fitted.has_value() || !fitted->has_value()) { - std::fprintf(stderr, "formula-cpp-gallery: the least-squares fit did not produce a slope\n"); + std::println(stderr, "formula-cpp-gallery: the least-squares fit did not produce a slope"); return 1; } out << "```\n" << formula::render_trace(fitTrace, { .maxSteps = 20 }) << "```\n\n"; @@ -1209,7 +1209,7 @@ int main(int argc, char** argv) auto const settling = formula::explain_retry(settledEstimate, formula::environment()); if (!settling.outcome.has_value() || settling.outcome->end() != formula::RetryEnd::Accepted) { - std::fprintf(stderr, "formula-cpp-gallery: the retry was not accepted\n"); + std::println(stderr, "formula-cpp-gallery: the retry was not accepted"); return 1; } out << "```\n" << formula::render_trace(settling.trace, { .maxSteps = 60 }) << "```\n\n"; From 3a238778e6144e0f4d2bfcd73ec91ecf73cbf7ad Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 19:17:51 +0200 Subject: [PATCH 17/59] fix: include where stderr is used The gallery generator names the stderr macro, which defines, so it includes that header itself instead of relying on to pull it in. Signed-off-by: Christian Parpart --- tools/gallery/main.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/gallery/main.cpp b/tools/gallery/main.cpp index a685f745..9190fdb0 100644 --- a/tools/gallery/main.cpp +++ b/tools/gallery/main.cpp @@ -28,6 +28,7 @@ #include #include +#include #include #include #include From a7c9108ce0cc11ef5d9563cee70da7ddea616787 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 19:19:59 +0200 Subject: [PATCH 18/59] fix(trace): include the headers traced() uses traced() calls std::invoke and constrains on std::invocable, but trace.hpp included neither nor . MSVC reached them through other headers; libstdc++ and libc++ do not, so g++ 14 and clang 22 failed with "invoke is not a member of std". std::invoke_result_t, std::remove_cvref_t and std::move were already covered by and . Signed-off-by: Christian Parpart --- include/formula-cpp/trace.hpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/include/formula-cpp/trace.hpp b/include/formula-cpp/trace.hpp index c9330caa..cea65f3e 100644 --- a/include/formula-cpp/trace.hpp +++ b/include/formula-cpp/trace.hpp @@ -41,9 +41,11 @@ #include #include +#include #include #include #include +#include #include #include #include From 93aa0e26fbac1478869fdc7842527102e746505b Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 19:26:55 +0200 Subject: [PATCH 19/59] build: support only the newest GCC and print with std::println in the package test The library supports only the newest GCC, the one the main build pins (g++ 14), so there is no reason to keep older GCC working. The Package workflow's Linux leg now installs g++-14 and builds both the library and the consumer with it, and the README and changelog say that older GCC is not supported. With that, the package consumer prints with std::println like every other program; its output is unchanged. Signed-off-by: Christian Parpart --- .github/workflows/package.yml | 5 +++++ CHANGELOG.md | 2 ++ README.md | 5 +++-- test/package/main.cpp | 10 +++++----- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index e4bf8320..deb7f19c 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -24,6 +24,11 @@ jobs: uses: ilammy/msvc-dev-cmd@v1 with: { arch: x64 } + # The library supports only the newest GCC, the same one build.yml pins. + - name: Install GCC 14 + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y g++-14 && echo "CXX=g++-14" >> "$GITHUB_ENV" + # Configured exactly as the vcpkg port does: tests and examples OFF. If the # library ever needs Catch2 in order to CONFIGURE, this step fails -- which # is the point, because that failure is the port breaking, caught here. diff --git a/CHANGELOG.md b/CHANGELOG.md index ea675e9b..1ea52489 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,8 @@ change is recorded here. ### Changed +- GCC 14 is the oldest supported GCC; older GCC is not supported. The install-and-consume check + now builds with it on Linux. - `` now specialises `std::formatter` for `formula::Outcome`, `formula::Unit`, `formula::Dimension` and every enumeration that has a `describe()`, as it already did for `Rational` and `Measured`. A program that defines its own `std::formatter` diff --git a/README.md b/README.md index ff00f452..aa340988 100644 --- a/README.md +++ b/README.md @@ -309,10 +309,11 @@ and still growing. The public API may change until 1.0. - C++23 - CMake 3.23 or newer +- GCC 14 or newer, if you build with GCC; older GCC is not supported CI builds and tests every push on MSVC `cl`, `clang-cl`, Clang and GCC 14 on Linux, and -AppleClang on macOS. Minimum compiler versions are not settled yet; earlier ones may work but -are untested. +AppleClang on macOS. The other compilers' minimum versions are not settled yet; earlier ones may +work but are untested. ## Installation diff --git a/test/package/main.cpp b/test/package/main.cpp index 85dec785..18c2bbde 100644 --- a/test/package/main.cpp +++ b/test/package/main.cpp @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 #include -#include +#include // Proves cxx_std_23 propagated from the exported target: `if consteval` is C++23. constexpr int probe(int value) noexcept @@ -28,9 +28,9 @@ int main() formula::Rational const rounded = formula::round(measured, formula::DecimalPlaces { 1 }, formula::RoundingMode::HalfAwayFromZero); - std::printf("formula-cpp %s consumed successfully: %lld/%lld\n", - FORMULA_VERSION_STRING, - static_cast(rounded.numerator()), - static_cast(rounded.denominator())); + std::println("formula-cpp {} consumed successfully: {}/{}", + FORMULA_VERSION_STRING, + rounded.numerator(), + rounded.denominator()); return 0; } From 721336bf10b0b46497b09a5d9929d2ab1e08c75a Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 19:48:42 +0200 Subject: [PATCH 20/59] test: pin every refusal of a relabelled bound formula A bound formula may be evaluated for the quantity it names and no other, and nine verbs each enforce that on their own. Only one of them was compiled by a negative case, so eight refusals could have been deleted without any test noticing. One case now asks each verb for a different wrong quantity, and counts one message per verb. Compiling those refused calls found a second message. Each verb gated on the value of the check that had just failed, and clang-cl then compiled both branches of define's gate, whose two returns deduce different types. Each verb now states the refusal, and gates on a plain predicate that no failed check can spoil. A formula bound twice was not refused, and the verbs forwarded it to the inner binding, whose answer is for another quantity: one mistake gave a cascade of conversion errors. It is now refused where it is written, even for the same quantity, since a bound formula is the top of a formula. Tests also observe that the sink and the vocabulary handed to a bound formula's verb reach the formula, and that define of a bound series draws the series refusal. The consumer-globals probe now calls every bound overload, and yields.hpp includes what it uses directly. Signed-off-by: Christian Parpart --- CHANGELOG.md | 4 +- docs/calculations.md | 2 +- docs/expressions.md | 9 +- include/formula-cpp/calculation.hpp | 3 +- include/formula-cpp/document.hpp | 1 + include/formula-cpp/rejection.hpp | 3 +- include/formula-cpp/series.hpp | 3 +- include/formula-cpp/trace.hpp | 12 ++- include/formula-cpp/yields.hpp | 86 +++++++++++++++---- test/CMakeLists.txt | 15 ++++ test/consumer_globals_run_tests.cpp | 2 +- test/consumer_globals_tests.cpp | 20 ++++- test/negative/yields_around_yields.cpp | 40 +++++++++ test/negative/yields_define_series.cpp | 19 ++++ .../negative/yields_relabelled_every_verb.cpp | 63 ++++++++++++++ test/yields_tests.cpp | 61 +++++++++++++ 16 files changed, 310 insertions(+), 33 deletions(-) create mode 100644 test/negative/yields_around_yields.cpp create mode 100644 test/negative/yields_define_series.cpp create mode 100644 test/negative/yields_relabelled_every_verb.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ea52489..336bd6ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,8 +62,8 @@ change is recorded here. `document` write the formula. The result is still never deduced from the expression: `Q` is checked against the dimension the expression computes where it is written, with `checked_evaluate`'s message, and a result named at the call as well is accepted only when it is - `Q`. Nest `documented()` inside it, and reuse the formula in another through `.expression`. - Every earlier spelling stays. + `Q`. Nest `documented()` inside it, and reuse the formula in another through `.expression`; a + `yields` around a bound formula is refused where it is written. Every earlier spelling stays. ### Changed diff --git a/docs/calculations.md b/docs/calculations.md index 2f950c60..689e648c 100644 --- a/docs/calculations.md +++ b/docs/calculations.md @@ -842,7 +842,7 @@ static assertion failed: formula: these definitions read one another in a cycle, cl 19.51 says the same, at the check's own line: ``` -include\formula-cpp/calculation.hpp(569): error C2338: static assertion failed: 'formula: these definitions read one another in a cycle, so none of them can be calculated first -- the quantities on the cycle appear in this diagnostic as the template arguments of RequireAcyclicDefinitions' +include\formula-cpp/calculation.hpp(570): error C2338: static assertion failed: 'formula: these definitions read one another in a cycle, so none of them can be calculated first -- the quantities on the cycle appear in this diagnostic as the template arguments of RequireAcyclicDefinitions' ``` **A worksheet missing an input.** Here the environment has no price: diff --git a/docs/expressions.md b/docs/expressions.md index c1feb3c9..10c845ef 100644 --- a/docs/expressions.md +++ b/docs/expressions.md @@ -649,11 +649,16 @@ constexpr auto citedRatio = formula::yields(formula::documented( ``` `documented(yields(...), ...)` does not compile, because `documented` -takes a node. +takes a node. Nor does a bound formula go inside another bound formula: +`yields(boundRatio)` is refused where it is written, even for the same +quantity -- *this formula is bound to its result quantity already; bind the +formula it holds (.expression), or use it as it is*. **Reuse goes through `.expression`.** For the same reason, a bound formula is not an operand of another formula. The formula it holds is, as any formula is -([Composing a formula from other formulas](#composing-a-formula-from-other-formulas)): +([Composing a formula from other formulas](#composing-a-formula-from-other-formulas)). +Here `MixWater` and `MixCement` are volumes in litres, as `WaterVolume` and +`CementVolume` are: ```cpp // The water a mix of another cement content needs at the same ratio. diff --git a/include/formula-cpp/calculation.hpp b/include/formula-cpp/calculation.hpp index 91b8fed2..ad1699a2 100644 --- a/include/formula-cpp/calculation.hpp +++ b/include/formula-cpp/calculation.hpp @@ -467,7 +467,8 @@ template template [[nodiscard]] constexpr auto define(Yields const& boundFormula) noexcept { - if constexpr (!detail::RequireYieldsResult::value || !Yields::valid) + static_assert(detail::RequireYieldsResult::value); + if constexpr (!detail::names_yields_result || !Yields::valid) { using Placeholder = ConstantNode::dimension)>; return Definition { Placeholder {} }; diff --git a/include/formula-cpp/document.hpp b/include/formula-cpp/document.hpp index 9167251f..c6fbfe94 100644 --- a/include/formula-cpp/document.hpp +++ b/include/formula-cpp/document.hpp @@ -1587,6 +1587,7 @@ template (boundFormula.expression, vocabulary); } + /// Documents @p node as `document(node, vocabulary)` does, with every /// number the page writes -- in the formula's text, a derived quantity's /// derivation and a criterion's limit -- written as @p renderOptions says diff --git a/include/formula-cpp/rejection.hpp b/include/formula-cpp/rejection.hpp index e8f3dbfc..30d78d62 100644 --- a/include/formula-cpp/rejection.hpp +++ b/include/formula-cpp/rejection.hpp @@ -1488,7 +1488,8 @@ checked_evaluate_rejection(Yields::value + static_assert(detail::RequireYieldsResult::value); + if constexpr (!detail::names_yields_result || !Yields>::valid) return std::unexpected { SeriesFailure { ArithmeticError::DomainError, std::nullopt } }; // refused already else diff --git a/include/formula-cpp/series.hpp b/include/formula-cpp/series.hpp index 6dad19f9..c4c465c7 100644 --- a/include/formula-cpp/series.hpp +++ b/include/formula-cpp/series.hpp @@ -1401,7 +1401,8 @@ template , SeriesFailure> checked_evaluate_series( Yields const& boundFormula, Env const& environmentGiven, Sink recordingSink = {}) noexcept { - if constexpr (!detail::RequireYieldsResult::value || !Yields::valid) + static_assert(detail::RequireYieldsResult::value); + if constexpr (!detail::names_yields_result || !Yields::valid) return std::unexpected { SeriesFailure { ArithmeticError::DomainError, std::nullopt } }; // refused already else return checked_evaluate_series(boundFormula.expression, environmentGiven, recordingSink); diff --git a/include/formula-cpp/trace.hpp b/include/formula-cpp/trace.hpp index cea65f3e..690c1410 100644 --- a/include/formula-cpp/trace.hpp +++ b/include/formula-cpp/trace.hpp @@ -4443,7 +4443,8 @@ template ::value || !Yields::valid) + static_assert(detail::RequireYieldsResult::value); + if constexpr (!detail::names_yields_result || !Yields::valid) return Explained {}; // refused already, where the mistake is else return explain(boundFormula.expression, environmentGiven, vocabulary); @@ -4493,7 +4494,8 @@ template ::value || !Yields::valid) + static_assert(detail::RequireYieldsResult::value); + if constexpr (!detail::names_yields_result || !Yields::valid) return ExplainedSeries { std::unexpected { SeriesFailure { ArithmeticError::DomainError, std::nullopt } }, Trace {} }; // refused already @@ -4565,7 +4567,8 @@ template ::value || !Yields::valid) + static_assert(detail::RequireYieldsResult::value); + if constexpr (!detail::names_yields_result || !Yields::valid) return Explained {}; // refused already, where the mistake is else return checked_explain(boundFormula.expression, environmentGiven, vocabulary); @@ -4675,7 +4678,8 @@ template ::value + static_assert(detail::RequireYieldsResult::value); + if constexpr (!detail::names_yields_result || !Yields>::valid) return { std::unexpected { SeriesFailure { ArithmeticError::DomainError, std::nullopt } }, Trace {} }; else diff --git a/include/formula-cpp/yields.hpp b/include/formula-cpp/yields.hpp index 28e4589e..8cd530a9 100644 --- a/include/formula-cpp/yields.hpp +++ b/include/formula-cpp/yields.hpp @@ -22,13 +22,20 @@ /// (`document.hpp`) take one too, and write the formula it holds: they name /// no result. /// -/// **Refused where the `Yields` is written:** a quantity that does not -/// measure the dimension the expression computes, in `checked_evaluate`'s -/// words (`detail::RequireResultDimension`). A verb given a refused `Yields` -/// adds no second message (`Yields::valid`). +/// **Refused where the `Yields` is written:** +/// - a formula bound already -- a `Yields` around a `Yields`, even for the +/// same quantity (`detail::RequireFormulaNotBound`); +/// - a quantity that does not measure the dimension the expression +/// computes, in `checked_evaluate`'s words +/// (`detail::RequireResultDimension`). +/// +/// A verb given a refused `Yields` adds no second message (`Yields::valid`). +#include #include +#include #include +#include #include #include @@ -36,6 +43,9 @@ namespace formula { +template +struct Yields; + namespace detail { /// The result a verb is asked for when a `Yields` supplies it: the @@ -44,44 +54,83 @@ namespace detail { }; - /// Whether @p E computes @p Q's dimension; true for an expression that - /// publishes none, which its verb checks instead, and for one refused - /// already, whose dimension is a stand-in. + /// Whether @p T is a `Yields`: a formula bound to its result quantity. + template + inline constexpr bool is_yields = false; + + template + inline constexpr bool is_yields> = true; + + /// Fails to compile when a `Yields` is given a formula bound already. A + /// `Yields` is the top of a formula, not a part of one; around another, + /// every verb would forward to the inner one's answer, for the inner + /// one's quantity, where the outer one's was promised. + template + struct RequireFormulaNotBound + { + static_assert(!is_yields, + "formula: this formula is bound to its result quantity already; bind the formula it holds " + "(.expression), or use it as it is -- the bound formula appears in this diagnostic as the " + "template argument of RequireFormulaNotBound"); + + /// Always true: the refusal is the `static_assert` above. + static constexpr bool value = true; + }; + + /// Whether a `Yields` of @p E for @p Q passes its checks: false for a + /// formula bound already; otherwise whether @p E computes @p Q's + /// dimension, true for an expression that publishes none, which its verb + /// checks instead, and for one refused already, whose dimension is a + /// stand-in. template [[nodiscard]] consteval bool yields_measures() noexcept { - if constexpr (requires { E::dimension; }) + if constexpr (is_yields) + return false; + else if constexpr (requires { E::dimension; }) return refused_already() || E::dimension == Describe::dimension; else return true; } + /// Whether @p Result is the quantity @p Q a `Yields` names, or none: what + /// every verb gates on, so that a refused call adds no second message. + /// + /// Asked apart from `RequireYieldsResult`, never through its `value`: + /// once that check had failed, clang-cl 22.1.8 compiled both branches of + /// a gate that asked the value -- `define`'s, whose two branches return + /// different types, so it added an error of its own. + template + inline constexpr bool names_yields_result = std::is_same_v || std::is_same_v; + /// Fails to compile when a `Yields` is evaluated for another quantity than /// the one it names. template struct RequireYieldsResult { - static_assert(std::is_same_v || std::is_same_v, + static_assert(names_yields_result, "formula: this formula names its result quantity with yields; evaluate it for that quantity, or " "name none -- the two quantities appear in this diagnostic as the template arguments of " "RequireYieldsResult"); - /// Whether the result asked for is the one the `Yields` names, or - /// none: what every verb gates on, so that a refused call adds no - /// second message. - static constexpr bool value = std::is_same_v || std::is_same_v; + /// Always true: the refusal is the `static_assert` above. + static constexpr bool value = true; }; } // namespace detail /// A formula and the quantity it computes -- built by `yields(expression)`. /// -/// A public aggregate: its check sits in the class body, so one spelled +/// A public aggregate: its checks sit in the class body, so one spelled /// without `yields` is refused as well. It claims nothing a verb could not /// be told directly: its `Q` is held to the dimension the expression -/// computes, as `checked_evaluate` holds the result it is given. +/// computes, as `checked_evaluate` holds the result it is given. It holds +/// no `Yields`: a formula bound already is refused, even for the same +/// quantity. template struct Yields { + static_assert(detail::RequireFormulaNotBound::value); + // `RequireResultDimension` is named only as the type `conditional_t` // picks, so an expression that publishes no dimension -- a retry, say -- // never instantiates it; behind a `||`, its `::value` would instantiate @@ -95,8 +144,8 @@ struct Yields /// The quantity this formula computes. using quantity = Q; - /// Whether the check above holds, asked without firing it, so that a verb - /// given a refused `Yields` adds no second message. + /// Whether the checks above hold, asked without firing them, so that a + /// verb given a refused `Yields` adds no second message. static constexpr bool valid = detail::yields_measures(); /// The formula. Deliberately no `{}` default member initialiser -- see @@ -119,7 +168,8 @@ template ::value || !Yields::valid) + static_assert(detail::RequireYieldsResult::value); + if constexpr (!detail::names_yields_result || !Yields::valid) return Outcome::empty(); // refused already, where the mistake is else return checked_evaluate(boundFormula.expression, environmentGiven, recordingSink); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 2129eec6..81938e7c 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -508,9 +508,24 @@ formula_add_negative_test(yields_rejection_result_dimension_mismatch formula_add_negative_test(yields_relabelled "formula: this formula names its result quantity with yields" EXPECT_COUNT 1 REJECT "no matching") +# Nine verbs, each asked for its own wrong quantity: one refusal each. Each +# verb gates on names_yields_result, never on RequireYieldsResult's value: +# with the gate reading that value, clang-cl 22.1.8 compiled both branches +# of define's gate once the check had failed, and its two returns deduce +# different types. The last two REJECTs name that error: clang's words, as +# clang-cl 22.1.8 printed them, and g++'s, not yet measured. +formula_add_negative_test(yields_relabelled_every_verb + "formula: this formula names its result quantity with yields" EXPECT_COUNT 9 + REJECT "no matching" "in return type deduced as" "inconsistent deduction for auto return type") formula_add_negative_test(yields_series_as_single "this expression is a series, not a single value; evaluate it with checked_evaluate_series" EXPECT_COUNT 1 REJECT "formula: this formula names its result quantity with yields") +formula_add_negative_test(yields_around_yields + "formula: this formula is bound to its result quantity already" EXPECT_COUNT 1 + REJECT "no viable conversion" "no matching") +formula_add_negative_test(yields_define_series + "formula: define takes an expression of one value, and this is a series" EXPECT_COUNT 1 + REJECT "no matching") # A braced list std::array would pad with absent elements: every spelling # that could reach a std::array parameter, refused because the constructor diff --git a/test/consumer_globals_run_tests.cpp b/test/consumer_globals_run_tests.cpp index b6795c49..d540d4c7 100644 --- a/test/consumer_globals_run_tests.cpp +++ b/test/consumer_globals_run_tests.cpp @@ -13,7 +13,7 @@ TEST_CASE("a consumer's ordinary globals do not break a build that includes every header", "[hygiene]") { ConsumerGlobalsProbe const probe = probe_consumer_globals(); - REQUIRE(probe.checks.size() == 107); + REQUIRE(probe.checks.size() == 110); for (std::size_t index = 0; index < probe.checks.size(); ++index) { INFO("check " << index); diff --git a/test/consumer_globals_tests.cpp b/test/consumer_globals_tests.cpp index d2b6fc3a..1ba8838c 100644 --- a/test/consumer_globals_tests.cpp +++ b/test/consumer_globals_tests.cpp @@ -79,8 +79,10 @@ // `Dimension` and an enumeration written the same way; `symbol_of` with no // vocabulary, and `render` and `document` given `RenderOptions` and none; // `yields` of the formula touching every node kind, and `evaluate`, -// `checked_evaluate`, `explain`, `render`, `document` and `define` of what -// it binds; +// `checked_evaluate`, `explain`, `checked_explain`, `render`, `document` and +// `define` of what it binds, and `checked_evaluate_series`, +// `explain_series`, `checked_evaluate_rejection` and `explain_rejection` of +// a series and a rejection bound the same way; // and a quantity declared by alias at // global scope, so that its tag is one more global. A template it does // not reach is not guarded by it. @@ -1377,6 +1379,20 @@ ConsumerGlobalsProbe probe_consumer_globals() constexpr auto boundDefinition = formula::define(boundStrength); probe.checks.push_back( std::is_same_v, decltype(formula::define(everything))>); + // The other verbs that name a result: the checked trace, and the + // series and the rejection above, each evaluated and traced. + auto const checkedBound = formula::checked_explain(boundStrength, specimen, north); + probe.checks.push_back(checkedBound.has_value() && checkedBound->outcome == explained.outcome); + constexpr auto boundScreens = formula::yields(formula::series); + probe.checks.push_back(formula::checked_evaluate_series(boundScreens, seriesInputs) == readSeries + && formula::explain_series(boundScreens, seriesInputs, north).outcome + == explainedSeries.outcome); + auto const boundTrimmed = formula::yields(trimmed); + auto const trimmedAgain = formula::checked_evaluate_rejection(boundTrimmed, bothScreens); + auto const trimmedExplained = formula::explain_rejection(boundTrimmed, bothScreens, north); + probe.checks.push_back(trimmedAgain.has_value() && trimmedAgain->outcome() == trimmedOutcome->outcome() + && trimmedExplained.outcome.has_value() + && trimmedExplained.outcome->outcome() == trimmedOutcome->outcome()); } return probe; } diff --git a/test/negative/yields_around_yields.cpp b/test/negative/yields_around_yields.cpp new file mode 100644 index 00000000..754acd3c --- /dev/null +++ b/test/negative/yields_around_yields.cpp @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: this formula is bound to its result quantity already +// REJECT: no viable conversion +// REJECT: no matching +// +// A formula bound to the water/cement ratio, bound again to an air content. +// A bound formula is the top of a formula, not a part of one, so the second +// binding is refused where it is written, once. Evaluated, traced, defined, +// rendered and documented, it adds nothing: each verb given the refused +// binding asks nothing more of it. Without that, each verb would forward to +// the inner binding, whose answer is for the water/cement ratio, where an +// air content was promised. +#include +#include +#include +#include + +using WaterVolume = formula::Quantity; +using CementVolume = formula::Quantity; +using WaterCementRatio = formula::Quantity; +using AirContent = formula::Quantity; + +inline constexpr auto inputs = + formula::environment(formula::Measured { 163 }, formula::Measured { 307 }); + +int main() +{ + constexpr auto ratio = formula::yields(formula::var / formula::var); + constexpr auto rebound = formula::yields(ratio); + auto const evaluated = formula::evaluate(rebound, inputs); + auto const checked = formula::checked_evaluate(rebound, inputs); + auto const explained = formula::explain(rebound, inputs); + auto const checkedExplained = formula::checked_explain(rebound, inputs); + auto const defined = formula::define(rebound); + auto const written = formula::render(rebound) + formula::document(rebound).formula; + return evaluated.is_value() && checked.has_value() && explained.outcome.is_value() && checkedExplained.has_value() + && decltype(defined)::valid && !written.empty() + ? 0 + : 1; +} diff --git a/test/negative/yields_define_series.cpp b/test/negative/yields_define_series.cpp new file mode 100644 index 00000000..ff719b57 --- /dev/null +++ b/test/negative/yields_define_series.cpp @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: define takes an expression of one value, and this is a series +// REJECT: no matching +// +// A series bound to its result quantity, defined as a calculation's value. A +// calculation holds single values, so it is refused as define refuses a +// series itself, once, in the library's words -- not as a define nobody +// matched. +#include + +struct Retained: formula::Quantity +{ +}; + +int main() +{ + [[maybe_unused]] constexpr auto defined = formula::define(formula::yields(formula::series)); + return 0; +} diff --git a/test/negative/yields_relabelled_every_verb.cpp b/test/negative/yields_relabelled_every_verb.cpp new file mode 100644 index 00000000..29a6acc6 --- /dev/null +++ b/test/negative/yields_relabelled_every_verb.cpp @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: this formula names its result quantity with yields +// REJECT: no matching +// +// Every verb that names a result, each asked for a quantity other than the +// one its bound formula names -- a different one at each call, so that each +// call is its own refusal and the case counts nine messages, one per verb. +// Each quantity measures what its formula computes, so nothing but the +// Yields could refuse it, and a verb that stopped refusing lowers the count. +#include +#include + +using WaterVolume = formula::Quantity; +using CementVolume = formula::Quantity; +using WaterCementRatio = formula::Quantity; +using Retained = formula::Quantity; +using Mass = formula::Quantity; + +// Nine quantities none of the formulas names: five ratios, two masses of a +// screen and two masses of a determination. +using AirContent = formula::Quantity; +using Porosity = formula::Quantity; +using Absorption = formula::Quantity; +using MoistureContent = formula::Quantity; +using Shrinkage = formula::Quantity; +using Passing = formula::Quantity; +using Sieved = formula::Quantity; +using Tare = formula::Quantity; +using DryMass = formula::Quantity; + +inline constexpr auto inputs = formula::environment(formula::Measured { 163 }, + formula::Measured { 307 }, + formula::measured_series(131, 211), + formula::measured_series(41, 43, 47, 53, 59)); + +int main() +{ + constexpr auto ratio = formula::yields(formula::var / formula::var); + constexpr auto retained = formula::yields(formula::series); + constexpr auto mostExtreme = formula::PerPass::MostExtreme; + constexpr auto keep = formula::OnLimit::Keep; + constexpr auto settled = + formula::yields(formula::without_outliers, formula::KeepAtLeast<3>>( + formula::series, + formula::deviation_from_mean(formula::Rational { 6, 100 } * formula::pass_mean), + formula::Verdict { "repeat the determinations" }, + formula::Citation { .title = "Example Standard" })); + + auto const evaluated = formula::evaluate(ratio, inputs); + auto const checked = formula::checked_evaluate(ratio, inputs); + auto const explained = formula::explain(ratio, inputs); + auto const checkedExplained = formula::checked_explain(ratio, inputs); + auto const defined = formula::define(ratio); + auto const series = formula::checked_evaluate_series(retained, inputs); + auto const explainedSeries = formula::explain_series(retained, inputs); + auto const rejection = formula::checked_evaluate_rejection(settled, inputs); + auto const explainedRejection = formula::explain_rejection(settled, inputs); + return evaluated.is_value() && checked.has_value() && explained.outcome.is_value() && checkedExplained.has_value() + && decltype(defined)::valid && series.has_value() && explainedSeries.outcome.has_value() + && rejection.has_value() && explainedRejection.outcome.has_value() + ? 0 + : 1; +} diff --git a/test/yields_tests.cpp b/test/yields_tests.cpp index 3dff21ee..921a591b 100644 --- a/test/yields_tests.cpp +++ b/test/yields_tests.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include @@ -178,3 +179,63 @@ TEST_CASE("yields: a rejection of outliers is evaluated for the quantity it is b CHECK(explained.trace.steps.size() == formula::explain_rejection(rejectionA, fixtureA).trace.steps.size()); CHECK(formula::explain_rejection(settledMass, fixtureA).outcome->outcome() == unbound->outcome()); } + +TEST_CASE("yields: every verb hands on the sink and the vocabulary it is given", "[yields][trace]") +{ + auto const written = [](formula::Trace<> const& recorded) { + return formula::render_trace(recorded, { .maxSteps = 100 }); + }; + + // A sink handed to a bound formula's verb hears what the unbound verb + // tells it: the same steps, written the same way. + auto const unbound = formula::traced([&](auto recordingSink) { + return formula::checked_evaluate(ratio.expression, batch, recordingSink); + }); + REQUIRE(!unbound.trace.steps.empty()); + auto const checked = + formula::traced([&](auto recordingSink) { return formula::checked_evaluate(ratio, batch, recordingSink); }); + CHECK(checked.outcome == unbound.outcome); + CHECK(written(checked.trace) == written(unbound.trace)); + auto const thrown = formula::traced([&](auto recordingSink) { return formula::evaluate(ratio, batch, recordingSink); }); + CHECK(thrown.outcome == *unbound.outcome); + CHECK(written(thrown.trace) == written(unbound.trace)); + + constexpr auto retainedInKilograms = formula::yields(formula::series); + auto const seriesUnbound = formula::traced([&](auto recordingSink) { + return formula::checked_evaluate_series(formula::series, inputs, recordingSink); + }); + REQUIRE(!seriesUnbound.trace.steps.empty()); + auto const seriesBound = formula::traced( + [&](auto recordingSink) { return formula::checked_evaluate_series(retainedInKilograms, inputs, recordingSink); }); + CHECK(seriesBound.outcome == seriesUnbound.outcome); + CHECK(written(seriesBound.trace) == written(seriesUnbound.trace)); + + constexpr auto settledMass = formula::yields(rejectionA); + auto const rejectionUnbound = formula::traced( + [&](auto recordingSink) { return formula::checked_evaluate_rejection(rejectionA, fixtureA, recordingSink); }); + REQUIRE(!rejectionUnbound.trace.steps.empty()); + auto const rejectionBound = formula::traced( + [&](auto recordingSink) { return formula::checked_evaluate_rejection(settledMass, fixtureA, recordingSink); }); + CHECK(written(rejectionBound.trace) == written(rejectionUnbound.trace)); + + // A vocabulary handed to an explain twin writes the trace as it does for + // the unbound formula -- and each renaming shows in the trace, so a + // vocabulary left behind would too. + constexpr auto renamedWater = formula::vocabulary(formula::renames("W")); + auto const renamed = written(formula::explain(ratio.expression, batch, renamedWater).trace); + REQUIRE(renamed != written(formula::explain(ratio.expression, batch).trace)); + CHECK(written(formula::explain(ratio, batch, renamedWater).trace) == renamed); + CHECK(written(formula::checked_explain(ratio, batch, renamedWater)->trace) == renamed); + + constexpr auto renamedRetained = formula::vocabulary(formula::renames("R")); + auto const renamedSeries = + written(formula::explain_series(formula::series, inputs, renamedRetained).trace); + REQUIRE(renamedSeries + != written(formula::explain_series(formula::series, inputs).trace)); + CHECK(written(formula::explain_series(retainedInKilograms, inputs, renamedRetained).trace) == renamedSeries); + + constexpr auto renamedMass = formula::vocabulary(formula::renames("m_s")); + auto const renamedRejection = written(formula::explain_rejection(rejectionA, fixtureA, renamedMass).trace); + REQUIRE(renamedRejection != written(formula::explain_rejection(rejectionA, fixtureA).trace)); + CHECK(written(formula::explain_rejection(settledMass, fixtureA, renamedMass).trace) == renamedRejection); +} From 2a83216d35eb820d6673901709dabbbddb797542 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 19:53:36 +0200 Subject: [PATCH 21/59] fix: refuse a bound formula around a bound formula with one message A bound formula around a bound formula is refused where it is written, and every verb given it is meant to say nothing more. The series and rejection verbs only take a bound series or a bound rejection, so a nested one reached none of their overloads: the refusal was followed by "no matching function", two messages for one mistake. Each of the four -- checked_evaluate_series, explain_series, checked_evaluate_rejection and explain_rejection -- now takes the nested formula too, and answers it with a placeholder that is never seen, so the refusal is the only message. Two negative cases, one for a series and one for a rejection, hand the nesting to those verbs and count one message. Signed-off-by: Christian Parpart --- include/formula-cpp/rejection.hpp | 15 +++++++++ include/formula-cpp/series.hpp | 15 +++++++++ include/formula-cpp/trace.hpp | 30 +++++++++++++++++ test/CMakeLists.txt | 6 ++++ .../yields_around_yields_rejection.cpp | 33 +++++++++++++++++++ test/negative/yields_around_yields_series.cpp | 24 ++++++++++++++ 6 files changed, 123 insertions(+) create mode 100644 test/negative/yields_around_yields_rejection.cpp create mode 100644 test/negative/yields_around_yields_series.cpp diff --git a/include/formula-cpp/rejection.hpp b/include/formula-cpp/rejection.hpp index 30d78d62..7e713c6b 100644 --- a/include/formula-cpp/rejection.hpp +++ b/include/formula-cpp/rejection.hpp @@ -1496,4 +1496,19 @@ checked_evaluate_rejection(Yields(boundFormula.expression, environmentGiven, recordingSink); } +/// A bound formula around a bound formula, refused where it is written +/// (`detail::RequireFormulaNotBound`, `yields.hpp`). Taken here only so that +/// the refusal is the one message; what it returns is never seen. +template +[[nodiscard]] constexpr std::expected, SeriesFailure> checked_evaluate_rejection( + Yields> const&, Env const&, Sink = {}) noexcept +{ + return std::unexpected { SeriesFailure { ArithmeticError::DomainError, std::nullopt } }; +} + } // namespace formula diff --git a/include/formula-cpp/series.hpp b/include/formula-cpp/series.hpp index c4c465c7..f62dc614 100644 --- a/include/formula-cpp/series.hpp +++ b/include/formula-cpp/series.hpp @@ -1408,4 +1408,19 @@ template (boundFormula.expression, environmentGiven, recordingSink); } +/// A bound formula around a bound formula, refused where it is written +/// (`detail::RequireFormulaNotBound`, `yields.hpp`). Taken here only so that +/// the refusal is the one message; what it returns is never seen. +template +[[nodiscard]] constexpr std::expected, SeriesFailure> checked_evaluate_series( + Yields> const&, Env const&, Sink = {}) noexcept +{ + return std::unexpected { SeriesFailure { ArithmeticError::DomainError, std::nullopt } }; +} + } // namespace formula diff --git a/include/formula-cpp/trace.hpp b/include/formula-cpp/trace.hpp index 690c1410..b408e425 100644 --- a/include/formula-cpp/trace.hpp +++ b/include/formula-cpp/trace.hpp @@ -4503,6 +4503,21 @@ template (boundFormula.expression, environmentGiven, vocabulary); } +/// A bound formula around a bound formula, refused where it is written +/// (`detail::RequireFormulaNotBound`, `yields.hpp`). Taken here only so that +/// the refusal is the one message; what it returns is never seen. +template +[[nodiscard]] ExplainedSeries explain_series(Yields> const&, Env const&, V const& = V {}) +{ + return ExplainedSeries { std::unexpected { SeriesFailure { ArithmeticError::DomainError, std::nullopt } }, + Trace {} }; +} + /// Why `checked_explain` has no outcome: the arithmetic error, and the /// derivation recorded up to it. A refusal without the steps that led to it /// -- which attribute of a lineage requirement disagreed, say -- would say @@ -4686,6 +4701,21 @@ template (boundFormula.expression, environmentGiven, vocabulary); } +/// A bound formula around a bound formula, refused where it is written +/// (`detail::RequireFormulaNotBound`, `yields.hpp`). Taken here only so that +/// the refusal is the one message; what it returns is never seen. +template +[[nodiscard]] Traced, SeriesFailure>> explain_rejection( + Yields> const&, Env const&, V const& = V {}) +{ + return { std::unexpected { SeriesFailure { ArithmeticError::DomainError, std::nullopt } }, Trace {} }; +} + /// Checks @p constraintGiven and records how -- `check`'s traced twin: the /// `ConstraintOutcome` in `outcome`, the trace in `trace`. template diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 81938e7c..aeb9fc9c 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -523,6 +523,12 @@ formula_add_negative_test(yields_series_as_single formula_add_negative_test(yields_around_yields "formula: this formula is bound to its result quantity already" EXPECT_COUNT 1 REJECT "no viable conversion" "no matching") +formula_add_negative_test(yields_around_yields_series + "formula: this formula is bound to its result quantity already" EXPECT_COUNT 1 + REJECT "no viable conversion" "no matching") +formula_add_negative_test(yields_around_yields_rejection + "formula: this formula is bound to its result quantity already" EXPECT_COUNT 1 + REJECT "no viable conversion" "no matching") formula_add_negative_test(yields_define_series "formula: define takes an expression of one value, and this is a series" EXPECT_COUNT 1 REJECT "no matching") diff --git a/test/negative/yields_around_yields_rejection.cpp b/test/negative/yields_around_yields_rejection.cpp new file mode 100644 index 00000000..8efda696 --- /dev/null +++ b/test/negative/yields_around_yields_rejection.cpp @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: this formula is bound to its result quantity already +// REJECT: no viable conversion +// REJECT: no matching +// +// A rejection of outliers bound to its result quantity, bound again. +// Refused where it is written, once. Evaluated and traced as a rejection, it +// adds nothing: the rejection's verbs take a bound formula around a bound +// formula only to stay silent about it, where they would otherwise find no +// overload to take it. +#include +#include + +using Mass = formula::Quantity; + +// Invented determinations, in grams. +inline constexpr auto inputs = formula::environment(formula::measured_series(41, 43, 47, 53, 59)); + +int main() +{ + constexpr auto mostExtreme = formula::PerPass::MostExtreme; + constexpr auto keep = formula::OnLimit::Keep; + constexpr auto settled = + formula::yields(formula::without_outliers, formula::KeepAtLeast<3>>( + formula::series, + formula::deviation_from_mean(formula::Rational { 6, 100 } * formula::pass_mean), + formula::Verdict { "repeat the determinations" }, + formula::Citation { .title = "Example Standard" })); + constexpr auto rebound = formula::yields(settled); + auto const evaluated = formula::checked_evaluate_rejection(rebound, inputs); + auto const explained = formula::explain_rejection(rebound, inputs); + return evaluated.has_value() && explained.outcome.has_value() ? 0 : 1; +} diff --git a/test/negative/yields_around_yields_series.cpp b/test/negative/yields_around_yields_series.cpp new file mode 100644 index 00000000..ed04c279 --- /dev/null +++ b/test/negative/yields_around_yields_series.cpp @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: this formula is bound to its result quantity already +// REJECT: no viable conversion +// REJECT: no matching +// +// A series bound to its result quantity, bound again. Refused where it is +// written, once. Evaluated and traced as a series, it adds nothing: the +// series verbs take a bound formula around a bound formula only to stay +// silent about it, where they would otherwise find no overload to take it. +#include +#include + +using Retained = formula::Quantity; + +inline constexpr auto inputs = formula::environment(formula::measured_series(131, 211)); + +int main() +{ + constexpr auto retained = formula::yields(formula::series); + constexpr auto rebound = formula::yields(retained); + auto const evaluated = formula::checked_evaluate_series(rebound, inputs); + auto const explained = formula::explain_series(rebound, inputs); + return evaluated.has_value() && explained.outcome.has_value() ? 0 : 1; +} From d429893a7764a7fb51ea680ded078390a87d5f7c Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 20:21:03 +0200 Subject: [PATCH 22/59] docs(examples): write the small examples and the README in the short spellings The seven small examples now read the way the library is meant to be written. A value is supplied as `Measured { 180 }` or `0.5_r`, a formula evaluated more than once names its result once with `yields`, a result is checked with `number_of`, and a conversion that cannot fail uses the throwing twin. Every line prints with std::println, formatting the library's own values: a Rational, a Measured with its unit, an Outcome, a Unit and an enumeration's words. Printing the values themselves changes some lines, each on purpose. The water/cement ratio prints as 0.6 rather than 0.600000 and says whether it was derived or manually entered, the circular area is marked as rounded, a converted volume carries its unit, and an absent measurement reads "(not measured)". exact_numbers gains the self-check it lacked, and its test now pins its closing "all checks passed: yes" beside "ten tenths == one: yes". Every README snippet is now consecutive lines of an example, beside that example's real output, which also ends the README calling evaluate<> where citations.cpp calls checked_evaluate. The documentation site's front page opens with the same two blocks and output. The guides quote the new lines, name what their snippets use, and show the short spellings where they documented the long ones. docs/numeric-headroom.md's census table is regenerated from the rewritten examples. Signed-off-by: Christian Parpart --- README.md | 100 +++++++++++++++++++++++-------------- docs/citations.md | 2 +- docs/expressions.md | 66 ++++++++++++++++-------- docs/index.md | 37 +++++++++----- docs/numbers.md | 45 ++++++++++------- docs/numeric-headroom.md | 11 ++-- docs/quantities.md | 37 +++++++++----- docs/tracing.md | 48 +++++++++++------- examples/CMakeLists.txt | 4 +- examples/citations.cpp | 68 ++++++++----------------- examples/composition.cpp | 81 ++++++++++++++---------------- examples/exact_numbers.cpp | 48 +++++++++++------- examples/expressions.cpp | 67 ++++++++++++------------- examples/quantities.cpp | 86 +++++++++++++++---------------- examples/simple.cpp | 13 +++-- examples/tracing.cpp | 27 +++++----- 16 files changed, 404 insertions(+), 336 deletions(-) diff --git a/README.md b/README.md index aa340988..b6eaf5f8 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,6 @@ Write a formula once, with ordinary operators. Get back a number, a rendering, a documentation page — from the same declaration. ```cpp -#include -#include -#include - namespace unit = formula::unit; using formula::var; @@ -18,30 +14,44 @@ using WaterVolume = formula::Quantity; using WaterCementRatio = formula::Quantity; -// The formula, and where it comes from, declared together. +// The formula and its citation, declared together: documented() attaches the +// citation to the division, and forwards that division's dimension unchanged. constexpr auto ratio = formula::documented(var / var, { .title = "Water/cement ratio", .reference = "Example Standard 1:2020", .section = "5.4.2", - .equation = "(3)" }); + .equation = "(3)", + .text = "Ratio of water content to cement content." }); ``` -That single declaration answers four different questions: +That single declaration answers four different questions: what the formula +is, as plain text and as LaTeX; what its symbols mean and where it comes from; +and what it computes. ```cpp -formula::render(ratio); // "V_w / V_c" -formula::render(ratio); // "\frac{V_w}{V_c}" +std::string const plain = formula::render(ratio); +std::string const latex = formula::render(ratio); +formula::Documentation const documentation = formula::document(ratio); +auto const inputs = formula::environment(formula::Measured { 180 }, + formula::Measured { 300 }); +auto const result = formula::checked_evaluate(ratio, inputs); +``` -formula::document(ratio); // the rendered formula, its citation, and a symbol table: - // V_w = effective water content [l] - // V_c = cement content [l] +`examples/citations.cpp` prints the four answers: -auto const environment = formula::environment(formula::Measured { formula::Rational { 180 } }, - formula::Measured { formula::Rational { 300 } }); -formula::evaluate(ratio, environment); // 0.6, and it knows it computed it +``` +plain: V_w / V_c +latex: \frac{V_w}{V_c} +symbol: V_w = effective water content [l] +symbol: V_c = cement content [l] +citation: Water/cement ratio, Example Standard 1:2020, 5.4.2, (3) +w/c = 0.6 (derived) ``` -The output above is what `examples/citations.cpp` actually prints. +`0.6` is exact, and `derived` says the library computed it rather than a +person typing it in. The text comes from `render.hpp` and `document.hpp`, and +the printing from `format.hpp`; the umbrella header `formula.hpp` holds the +rest (see [Copy the headers](#copy-the-headers)). A quantity can also be declared as a struct deriving from `formula::Quantity`, `struct WaterVolume: formula::Quantity {};`. Both spellings @@ -51,12 +61,13 @@ guide](docs/quantities.md#declaring-a-quantity) says what each costs. ## See it work Every block below is real code from `examples/`, with the output those programs -actually print. +actually print. The one mistake that must not compile is shown from +`test/negative/`, which pins it. ### A dimensional mistake is a compile error, not a wrong number ```cpp -constexpr auto broken = formula::var + formula::var; +inline constexpr auto broken = formula::var + formula::var; ``` ``` @@ -83,24 +94,31 @@ diagnostic as the template arguments of RequireProvided' formula mentions either — the conversion is part of what the declaration means. ```cpp -constexpr auto circularArea = formula::pi * formula::pow<2>(var) / formula::Rational { 4 }; +constexpr auto circularArea = formula::yields(formula::pi * formula::pow<2>(var) / 4); +``` -constexpr auto known = formula::environment(formula::Measured { formula::Rational { 103 } }); -constexpr auto area = formula::checked_evaluate(circularArea, known); +`yields` names the result where the formula is written, so the call that +evaluates it names none: + +```cpp +constexpr auto diameterKnown = formula::environment(formula::Measured { 103 }); +constexpr auto area = formula::checked_evaluate(circularArea, diameterKnown); ``` ``` -circular area of a 103 mm diameter = 0.008332 m2 (computed) -2500 g reported as m = 2.500000 kg +circular area of a 103 mm diameter = ≈0.008332 m2 (derived) +2500 g reported as m = 2.5 kg ``` -Note `constexpr`: that area was computed at compile time. +Note `constexpr`: that area was computed at compile time. The area is held +exactly, with `formula::pi` an exact fraction close to pi, and has no short +decimal, so it is printed rounded to six places and marked `≈`. ### A measurement nobody took stays missing ```cpp -constexpr auto unknown = formula::environment(formula::Measured::absent()); -constexpr auto empty = formula::checked_evaluate(circularArea, unknown); +constexpr auto diameterUnknown = formula::environment(formula::Measured::absent()); +constexpr auto emptyArea = formula::checked_evaluate(circularArea, diameterUnknown); ``` ``` @@ -114,21 +132,22 @@ from "this is zero", and the difference matters when someone signs off on it. ### A number a person typed in never masquerades as a computed one ```cpp -auto const batch = formula::environment( - formula::Measured { formula::Rational { 180 } }, - formula::Measured { formula::Rational { 300 } }, - formula::entered(formula::Measured { formula::Rational { 1, 2 } })); - -auto const ratio = formula::checked_evaluate(waterCementRatio, batch); +auto const batch = formula::environment(formula::Measured { 180 }, + formula::Measured { 300 }, + formula::entered(formula::Measured { 0.5_r })); +auto const ratio = formula::checked_evaluate(waterCementRatio, batch); ``` ``` -w/c = 0.500000 (entered) +w/c = 0.5 (manually entered) ``` -The formula would have computed 0.6. A person entered 0.5, so that is the -answer — and `ratio->source()` says `ManuallyEntered`, so a report can show -which numbers were derived and which were asserted. +Here `waterCementRatio` is +`formula::yields(var / var)`, and +`0.5_r` is the exact decimal one half, never a `double`. The formula would +have computed 0.6. A person entered 0.5, so that is the answer — and +`ratio->source()` is `ValueSource::ManuallyEntered`, printed above, so a report +can show which numbers were derived and which were asserted. ### Arithmetic that does not drift @@ -185,8 +204,13 @@ a `Trace` — one step per node, each naming the earlier steps it consumed. `render_trace()` turns that into text, bounded by a limit you choose: ```cpp -formula::Explained const explained = formula::explain(ratio, inputs); -std::string const trace = formula::render_trace(explained.trace, { .maxSteps = 10 }); +auto const explained = formula::explain(ratio, inputs); + +// render_trace has no default for maxSteps: TraceRenderOptions::maxSteps +// is a StepLimit, which has no default constructor, so a caller who +// writes render_trace(explained.trace, {}) does not compile, rather than +// risking an unbounded dump of a derivation many times this size. +std::print("{}", formula::render_trace(explained.trace, { .maxSteps = 10 })); ``` ``` diff --git a/docs/citations.md b/docs/citations.md index c7934fee..5c4d3f5a 100644 --- a/docs/citations.md +++ b/docs/citations.md @@ -121,7 +121,7 @@ than a boolean -- the wrapped formula evaluates to exactly the ratio the bare division would have produced: ``` -w/c = 0.600000 (computed) +w/c = 0.6 (derived) ``` This is not merely an absence of a check; there is a check, and it still diff --git a/docs/expressions.md b/docs/expressions.md index 10c845ef..ecfcb8ce 100644 --- a/docs/expressions.md +++ b/docs/expressions.md @@ -116,7 +116,8 @@ comment rather than as compiled code, since it must not fail the build: // // constexpr auto broken = formula::var + formula::var; // -// Uncommenting the line above does not compile. +// Uncommenting the line above does not compile. See docs/expressions.md for +// the exact diagnostic text a real build prints for this mistake. ``` Multiplication and division impose nothing on the operands' dimensions -- a @@ -243,35 +244,49 @@ quantity being evaluated, `checked_evaluate` returns that value with `ValueSource::ManuallyEntered` **without evaluating the formula at all** -- proven in `test/evaluate_tests.cpp` by an override whose formula would divide by zero: the override still wins, because the formula is never -reached. `examples/expressions.cpp` overrides a computed ratio and prints -both the value and the fact that it was entered: +reached. `examples/expressions.cpp` overrides a computed ratio -- the +environment `batch` holds the two volumes and a ratio a person entered, 0.5 +(`0.5_r`, an exact decimal, from `using namespace formula::literals;`) -- and +prints both the value and where it came from: + +```cpp +auto const batch = formula::environment(formula::Measured { 180 }, + formula::Measured { 300 }, + formula::entered(formula::Measured { 0.5_r })); +auto const ratio = formula::checked_evaluate(waterCementRatio, batch); +std::println("{} = {} ({})", formula::symbol_of(), *ratio, ratio->source()); +``` ``` -w/c = 0.500000 (entered) +w/c = 0.5 (manually entered) ``` +`waterCementRatio` names its result quantity where it is declared, so the call +names none ([Naming the result once](#naming-the-result-once)). `{}` of an +`Outcome` writes its number in its quantity's unit (a ratio has no symbol), +and `{}` of a `ValueSource` its words; see [Displaying numbers](display.md). + ## Reading a result Most of the time a caller wants only the number, and needs to know that there may be none. `formula::number_of(x)` returns a `std::optional`: the number `x` holds, or nothing. It reads a `Measured`, an `Outcome`, the `std::expected` that `checked_evaluate` returns, an `Evaluated`, a -`RetryOutcome` and a `RejectionOutcome`: +`RetryOutcome` and a `RejectionOutcome`. `examples/expressions.cpp` checks the +`ratio` above with it: ```cpp -using namespace formula::literals; - -// 0.5 when the formula evaluates to a number; nothing when an input was never -// measured, when the arithmetic failed, and for a verdict or an invalid result. -bool const isHalf = formula::number_of(formula::checked_evaluate(ratio, batch)) == 0.5_r; +bool const overrideWinsOutright = ratio && ratio->is_overridden() && formula::number_of(ratio) == 0.5_r; ``` It is an `optional` and not a zero because zero is a measurement: a specimen that weighed nothing and a specimen never weighed are different results. -`optional == Rational` is false when the optional is empty, so the comparison -above is a complete check -- an absent number, an error and a verdict all -compare unequal to every number. `number_of` says nothing about *why* there is -no number; ask `Outcome::kind()` or the error for that. +`optional == Rational` is false when the optional is empty, so +`number_of(ratio) == 0.5_r` is a complete check on its own -- an absent +number, an error and a verdict all compare unequal to every number. The +`ratio &&` in front guards only the `->` that follows it. `number_of` says +nothing about *why* there is no number; ask `Outcome::kind()` or the error +for that. ## Choosing a representation @@ -333,21 +348,24 @@ produce a fractional exponent -- exactly why `Dimension`'s exponents are rational rather than integer (see [`docs/dimensions.md`](dimensions.md)). `examples/expressions.cpp` computes a circular area from a constant (`pi`) and a power (`d^2`), with the result declared in a different unit -(`SquareMetre`) from the input (`Millimetre`): +(`SquareMetre`) from the input (`Millimetre`). A bare number in a formula, +the `4` here, is a dimensionless coefficient: ```cpp -constexpr auto circularArea = formula::pi * formula::pow<2>(var) / formula::Rational { 4 }; +constexpr auto circularArea = formula::yields(formula::pi * formula::pow<2>(var) / 4); ``` ``` -circular area of a 103 mm diameter = 0.008332 m2 (computed) +circular area of a 103 mm diameter = ≈0.008332 m2 (derived) ``` `formula::Pi` is a documented rational convergent -- `245850922/78256779`, which differs from pi by less than 8e-17 -- and is deliberately **not** pi itself: it is the one approximation the exact layer makes on purpose, written once so every caller gets the same number and the trace states which number -it was. +it was. The area is exact over that fraction, and has no short decimal, so +the example prints it rounded to six places, which `≈` marks: +`{:~.6HalfAwayFromZero}` ([Displaying numbers](display.md)). `checked_exact_nth_root` answers only when the root **is** a rational number. The root of 4 is 2 and the root of 9/4 is 3/2, but the root of 2 is @@ -540,14 +558,17 @@ constexpr auto waterCementRatio = // The second formula uses the first by name. Nothing about the first // declaration anticipated being reused. constexpr auto mixCost = - formula::documented(var * waterCementRatio, - { .title = "Cost of a mix at a given water/cement ratio", ... }); + formula::yields(formula::documented(var * waterCementRatio, + { .title = "Cost of a mix at a given water/cement ratio", ... })); ``` There is no separate composition step and no wrapper type. The outer formula is simply a larger expression tree, so the dimension check, evaluation, rendering, tracing and `document()` all treat the reused sub-tree the way -they treat any other node. +they treat any other node. Only the outer formula names its result with +`yields`, because only it is evaluated: a formula bound to its result is the +top of a formula, not an operand of one +([Naming the result once](#naming-the-result-once)). **Provenance travels upward through the seam.** The outer formula was never told about the inner one's citation, but `document()` walks the whole tree @@ -617,6 +638,9 @@ std::string const written = formula::render(boundRatio); // "V_w / V_c" constexpr auto definition = formula::define(boundRatio); // Ratio, defined by the formula ``` +`examples/expressions.cpp` binds its circular area and its water/cement ratio +this way, and `examples/composition.cpp` its mix cost. + The name is checked where it is written. `yields` holds `Q` to the dimension the expression computes, as `checked_evaluate` does, and refuses a quantity of another dimension with the same message: *this result quantity diff --git a/docs/index.md b/docs/index.md index 8a812e38..50831ea9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,10 +6,6 @@ Write a formula once, with ordinary operators. Get back a number, a rendering, a documentation page — from the same declaration. ```cpp -#include -#include -#include - namespace unit = formula::unit; using formula::var; @@ -18,25 +14,42 @@ using WaterVolume = formula::Quantity; using WaterCementRatio = formula::Quantity; -// The formula, and where it comes from, declared together. +// The formula and its citation, declared together: documented() attaches the +// citation to the division, and forwards that division's dimension unchanged. constexpr auto ratio = formula::documented(var / var, { .title = "Water/cement ratio", .reference = "Example Standard 1:2020", .section = "5.4.2", - .equation = "(3)" }); + .equation = "(3)", + .text = "Ratio of water content to cement content." }); ``` One declaration, four answers: ```cpp -formula::render(ratio); // "V_w / V_c" -formula::render(ratio); // "\frac{V_w}{V_c}" -formula::document(ratio); // text, citation and symbol table together +std::string const plain = formula::render(ratio); +std::string const latex = formula::render(ratio); +formula::Documentation const documentation = formula::document(ratio); +auto const inputs = formula::environment(formula::Measured { 180 }, + formula::Measured { 300 }); +auto const result = formula::checked_evaluate(ratio, inputs); +``` + +`examples/citations.cpp` prints them: -auto const environment = formula::environment(formula::Measured { formula::Rational { 180 } }, - formula::Measured { formula::Rational { 300 } }); -formula::evaluate(ratio, environment); // 0.6, and it knows it computed it ``` +plain: V_w / V_c +latex: \frac{V_w}{V_c} +symbol: V_w = effective water content [l] +symbol: V_c = cement content [l] +citation: Water/cement ratio, Example Standard 1:2020, 5.4.2, (3) +w/c = 0.6 (derived) +``` + +`0.6` is exact, and `derived` says the library computed it rather than a +person typing it in. The text comes from `render.hpp` and `document.hpp`, and +the printing from `format.hpp`; the umbrella header `formula.hpp` holds the +rest. A quantity can also be declared as a struct deriving from `formula::Quantity`, `struct WaterVolume: formula::Quantity {};`. Both spellings diff --git a/docs/numbers.md b/docs/numbers.md index 817e7138..f941c337 100644 --- a/docs/numbers.md +++ b/docs/numbers.md @@ -20,13 +20,17 @@ That is not a rare edge case; it is what binary floating point does with decimal input in general. A quantity such as 450 millilitres, stored as 0,45 litres in a `double` and converted back, is not reliably 450 again -- the round trip is lossy because 0,45 is not exactly representable in base 2. -`Rational` makes that round trip exact: +`Rational` makes that round trip exact. From `examples/exact_numbers.cpp`, +where `450_r` is the exact number 450 ([Writing an exact +decimal](#writing-an-exact-decimal)): ```cpp -Rational const volumeInMillilitres = *Rational::from_decimal(45, 1); // 450/1 -Rational const volumeInLitres = volumeInMillilitres / Rational { 1000 }; // 9/20 -Rational const roundTripped = volumeInLitres * Rational { 1000 }; -// roundTripped == volumeInMillilitres, exactly +Rational const volumeInMillilitres = 450_r; + +// Convert to litres by an exact integer factor: multiply, then divide. +// 450 ml -> 9/20 l, and back to 450 ml with nothing lost. +Rational const volumeInLitres = volumeInMillilitres / 1000; +Rational const roundTripped = volumeInLitres * 1000; ``` The second reason is more fundamental than accumulated error: rounding rules @@ -44,8 +48,9 @@ is the only place precision is deliberately given up. |---|---|---| | an integer | `Rational { 7 }` | 7/1 | | a fraction | `Rational { 3, 4 }` | 3/4 | -| an exact decimal | `Rational::from_decimal(45, -2)` | 9/20 | -| a whole number of tens | `Rational::from_decimal(45, 1)` | 450/1 | +| an exact decimal | `0.45_r` | 9/20 | +| an exact decimal from digits known only at run time | `Rational::from_decimal(45, -2)` | 9/20 | +| a whole number of tens, the same way | `Rational::from_decimal(45, 1)` | 450/1 | | the exact value of a `double` | `Rational::from_double_exact(0.45)` | a power-of-two denominator | | a measured `double` on a known scale | `rational_from_double(0.45, DecimalPlaces { 2 }, mode)` | 9/20 | @@ -54,13 +59,19 @@ Spelled out, as runnable code: ```cpp Rational const a { 7 }; // 7/1 Rational const b { 3, 4 }; // 3/4 -Rational const c = *Rational::from_decimal(45, -2); // 9/20 -Rational const d = *Rational::from_decimal(45, 1); // 450/1 -Rational const e = *Rational::from_double_exact(0.45); // a power-of-two denominator -Rational const f = +Rational const c = 0.45_r; // 9/20 +Rational const d = *Rational::from_decimal(45, -2); // 9/20 +Rational const e = *Rational::from_decimal(45, 1); // 450/1 +Rational const f = *Rational::from_double_exact(0.45); // a power-of-two denominator +Rational const g = *formula::rational_from_double(0.45, DecimalPlaces { 2 }, RoundingMode::HalfAwayFromZero); // 9/20 ``` +`0.45_r` is read from its spelling at compile time +([Writing an exact decimal](#writing-an-exact-decimal)). `from_decimal` +writes the same number from a mantissa and a power of ten that may be known +only at run time. + `from_decimal`, `from_double_exact` and `rational_from_double` all return `std::expected` because the conversion can fail -- `from_decimal`'s scale factor can overflow, and `from_double_exact` can be @@ -187,7 +198,7 @@ Beyond rounding to a whole number, three forms round to a place: ```cpp // Decimal places: 45,67 rounded to one decimal place is 45,7, i.e. 457/10. -Rational const value = *Rational::from_decimal(4567, -2); +Rational const value = 45.67_r; Rational const toOneDecimal = formula::round(value, DecimalPlaces { 1 }, RoundingMode::HalfAwayFromZero); // Significant digits: the same 45,67 rounded to two significant digits is 46 -- a @@ -195,7 +206,7 @@ Rational const toOneDecimal = formula::round(value, DecimalPlaces { 1 }, Roundin Rational const toTwoSignificant = formula::round(value, SignificantDigits { 2 }, RoundingMode::HalfAwayFromZero); // Rounding to an arbitrary step, the primitive the two forms above are built on. -Rational const snapped = formula::round_to_multiple(Rational { 7 }, Rational { 5 }, RoundingMode::HalfAwayFromZero); // 5 +Rational const snapped = formula::round_to_multiple(7, 5, RoundingMode::HalfAwayFromZero); // 5 ``` A `Rational` is written as text as a fraction by default. To write it as a @@ -213,13 +224,11 @@ methods, and a library that rounds only on output cannot express the difference: ```cpp -Rational const mean = Rational { 302, 3 }; // 100,666... +Rational const mean { 302, 3 }; // 100,666... -Rational const roundedFirst = - formula::round(mean, DecimalPlaces { 0 }, RoundingMode::HalfAwayFromZero) * Rational { 2 }; // 202 +Rational const roundedFirst = formula::round(mean, DecimalPlaces { 0 }, RoundingMode::HalfAwayFromZero) * 2; // 202 -Rational const roundedLast = - formula::round(mean * Rational { 2 }, DecimalPlaces { 0 }, RoundingMode::HalfAwayFromZero); // 201 +Rational const roundedLast = formula::round(mean * 2, DecimalPlaces { 0 }, RoundingMode::HalfAwayFromZero); // 201 // roundedFirst != roundedLast ``` diff --git a/docs/numeric-headroom.md b/docs/numeric-headroom.md index 0ae6bba9..5fe35013 100644 --- a/docs/numeric-headroom.md +++ b/docs/numeric-headroom.md @@ -119,13 +119,16 @@ The census does not see evaluations that happen at compile time an overflow there is still a refused result -- but their headroom is not measured. Three examples evaluate some of their formulas that way: `expressions` three, `rounding_and_conditionals` six and `constraints` five. +`expressions` evaluates one formula at run time, and that evaluation returns +a value a person entered without computing it, so its row reports no integer +and the full 63 bits. The figures are deterministic: the census program prints the same on cl 19.51 and gcc 13.3, and the clang and gcc presets hold it to the same pins. The examples table below is cl's. clang and gcc evaluate a `const` local's constant initialiser at compile time, where cl runs it, so under them a -program can report fewer integers -- today `expressions` leaves one bit more -headroom. The test holds every compiler to at least this table's headroom. +program can report fewer integers and leave more headroom. The test holds +every compiler to at least this table's headroom. ## The census @@ -144,8 +147,8 @@ Each program's largest integers over everything it evaluates at run time. | example `simple` | 4 | 10 | 6 | 53 | | example `exact_numbers` | 9 | 10 | 9 | 53 | | example `dimensions_and_units` | 22 | 10 | 22 | 41 | -| example `quantities` | 4 | 10 | 9 | 53 | -| example `expressions` | 3 | 2 | 0 | 60 | +| example `quantities` | 4 | 10 | 5 | 53 | +| example `expressions` | 0 | 0 | 0 | 63 | | example `citations` | 4 | 10 | 6 | 53 | | example `composition` | 10 | 10 | 9 | 53 | | example `electricity_bill` | 31 | 26 | 31 | 32 | diff --git a/docs/quantities.md b/docs/quantities.md index 6fd037c0..db6b2008 100644 --- a/docs/quantities.md +++ b/docs/quantities.md @@ -266,10 +266,11 @@ density: a wrong label on a right number, worse than a wrong number because it looks authoritative. Write `formula::combine(mass, volume, [](Rational m, Rational v) { return m / v; })` instead. From the worked example, a present volume combined with an absent mass, into a `Density` -that shares neither operand's tag, symbol or unit: +that shares neither operand's tag, symbol or unit, printed as `std::format` +writes an absent `Measured`: ``` -a present volume combined with an absent mass: absent +a present volume combined with an absent mass: (not measured) ``` `formula::checked_convert_to` converts a `Measured` into a @@ -280,10 +281,12 @@ does not compile, and it draws one message, so a conversion nobody could perform cannot look like it succeeded merely because there was no value to get wrong. (Before this check moved to compile time, such a call compiled and returned `ArithmeticError::DomainError`.) With no value present the -result is absent: +result is absent. The worked example converts one with `convert_to`, the +throwing twin described [below](#bounds-precision-and-conversion), since +nothing in it can fail: ``` -an absent measurement, converted: still absent +an absent measurement, converted: (not measured) ``` ## Supplying values @@ -328,7 +331,7 @@ are overloaded for `Measured` alongside the `Rational`-and-`Unit` forms of rounding an absent measurement leaves it absent, ``` -an absent measurement, rounded: still absent +an absent measurement, rounded: (not measured) ``` and checking an absent measurement against its unit's declared bounds @@ -355,16 +358,26 @@ unit rather than needing one passed alongside it. From the worked example, 450 l converted to m³: ``` -450 l converted to m3 = 9/20 +450 l converted to m3 = 0.45 m3 ``` The conversion, the rounding and the bounds check each have a throwing twin, -for callers who would only rethrow the error: `formula::convert_to`, `formula::round_to_declared` and -`formula::within_bounds`, which take the same arguments and return the value -itself, and throw `ArithmeticException` where the `checked_` form returns an -error. Absence behaves as above -- an absent measurement converts and rounds to -an absent one and is `NotMeasured` for its bounds -- and a conversion across -dimensions does not compile in either spelling. +for callers who would only rethrow the error: `formula::convert_to`, +`formula::round_to_declared` and `formula::within_bounds`, which take the +same arguments and return the value itself, and throw `ArithmeticException` +where the `checked_` form returns an error. Absence behaves as above -- an +absent measurement converts and rounds to an absent one and is `NotMeasured` +for its bounds -- and a conversion across dimensions does not compile in +either spelling. The worked example uses the twins, since nothing in it can +fail. Its absent measurement is converted, rounded and checked by these +lines: + +```cpp +Measured const absentVolume {}; +auto const convertedAbsent = formula::convert_to(absentVolume); +auto const roundedAbsent = formula::round_to_declared(absentVolume, RoundingMode::HalfAwayFromZero); +auto const boundsOfAbsent = formula::within_bounds(absentVolume); +``` ## Limits diff --git a/docs/tracing.md b/docs/tracing.md index e2bc863b..a0cb2731 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -180,10 +180,11 @@ cannot survive past that evaluation into a runtime object: the standard requires every allocation a constant expression makes to be released again before the expression finishes. `explain`'s whole purpose is to hand back a `Trace` that keeps its steps, which is exactly the kind of surviving -allocation a constant expression is not allowed to produce. Writing +allocation a constant expression is not allowed to produce. Writing the +test's call above as a constant, ```cpp -constexpr auto explained = formula::explain(densityFormula, env); +constexpr auto explained = formula::explain(density, environment); ``` fails to compile, verified with cl 19.51: @@ -201,11 +202,13 @@ the working. `explain` traces a formula. The other verbs that take a sink -- a method, a curve, a rejection, a constraint, a conformity check -- have a twin of their -own that returns the verb's result together with the trace it recorded: +own that returns the verb's result together with the trace it recorded. Over +`compressiveStrength`, the method of three variants that +`examples/methods_and_overlays.cpp` declares, and that example's `specimen`: ```cpp -auto const derived = formula::explain_method(strengthMethod, specimen); -auto const verdicts = formula::explain_check_all(constraintSet, specimen); +auto const derived = formula::explain_method(compressiveStrength, specimen); +auto const verdicts = formula::explain_check_all(compressiveStrength.constraintSet, specimen); ``` `derived.outcome` is exactly what `evaluate_method` returns, and @@ -219,13 +222,17 @@ argument, as `explain` does. A verb without a twin -- or one of your own that takes a sink -- goes through `traced`, which gives the evaluation a `RecordingSink` and returns what the -evaluation returned beside what the sink recorded: +evaluation returned beside what the sink recorded. Over the `ratio` and the +`inputs` of `examples/tracing.cpp` ([Reading a derivation](#reading-a-derivation)): ```cpp auto const run = formula::traced([&](auto recordingSink) - { return formula::checked_evaluate(densityFormula, env, recordingSink); }); + { return formula::checked_evaluate(ratio, inputs, recordingSink); }); ``` +`run.outcome` is the `std::expected` that `checked_evaluate` returned, and +`run.trace` the four steps below. + `explain_series` and `explain_retry` share the shape: `outcome`, then `trace`. A failure is in `outcome`, and `trace` holds the steps up to it; a value that was typed in rather than derived leaves `trace` empty, as it does for @@ -240,9 +247,13 @@ was typed in rather than derived leaves `trace` empty, as it does for same invented citation attached by `documented()` -- and prints its trace: ```cpp -formula::Explained const explained = formula::explain(ratio, inputs); -std::string const trace = formula::render_trace(explained.trace, { .maxSteps = 10 }); -std::printf("%s", trace.c_str()); +auto const explained = formula::explain(ratio, inputs); + +// render_trace has no default for maxSteps: TraceRenderOptions::maxSteps +// is a StepLimit, which has no default constructor, so a caller who +// writes render_trace(explained.trace, {}) does not compile, rather than +// risking an unbounded dump of a derivation many times this size. +std::print("{}", formula::render_trace(explained.trace, { .maxSteps = 10 })); ``` which prints, verbatim: @@ -396,10 +407,8 @@ of its own, `StepKind::VariantSelected`, and a derivation says which variant fired and on what: ```cpp -formula::Trace<> trace {}; -formula::RecordingSink<> sink { trace }; -(void) formula::evaluate_method(compressiveStrength, inputs, sink); -std::printf("%s", formula::render_trace(trace, { .maxSteps = 20 }).c_str()); +auto const derived = formula::explain_method(compressiveStrength, inputs); +std::print("{}", formula::render_trace(derived.trace, { .maxSteps = 20 })); ``` ``` @@ -681,11 +690,11 @@ quantity's symbol **when the formula is evaluated**, and `render_trace` only reads it back. So a jurisdiction's vocabulary (see [Citations and rendering](citations.md)) has to be given to the sink, not only to `render()` -- a page rendered in one vocabulary and a trace recorded in another would name one quantity with two -different letters: +different letters. An `explain_*` twin hands the vocabulary it is given to +the sink it builds: ```cpp -formula::Trace<> southern {}; -(void) formula::check(limit, crossedInputs, formula::RecordingSink { southern, south }); +auto const southern = formula::explain_check(limit, crossedInputs, south); ``` ``` @@ -695,8 +704,9 @@ formula::Trace<> southern {}; ``` (`test/vocabulary_tests.cpp`, `"a constraint's trace names quantities in the -sink's vocabulary"`.) `explain` takes the vocabulary as an optional third -argument. Those three step kinds are the only ones that name a quantity. +sink's vocabulary"`, which gives `south` to a `RecordingSink` of its own.) +`explain` takes the vocabulary as an optional third argument, and every +`explain_*` twin and `traced` as an optional last one. Those three step kinds are the only ones that name a quantity. Every other step names none -- arithmetic, a lookup, a rounding rule, a constraint, a method's constraints, a variant selection and a replaced variant refer to their operands by number -- and so reaches the vocabulary through the steps beneath diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index afd4eb00..f439dbd5 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -40,8 +40,8 @@ function(formula_add_example name source expectedOutput) PASS_REGULAR_EXPRESSION "${expectedOutput}.*overflow census: ") endfunction() -formula_add_example(simple simple.cpp "w/c = 0.600000 \\(computed\\)") -formula_add_example(exact_numbers exact_numbers.cpp "ten tenths == one: yes") +formula_add_example(simple simple.cpp "w/c = 0.6 \\(derived\\)") +formula_add_example(exact_numbers exact_numbers.cpp "ten tenths == one: yes.*all checks passed: yes") formula_add_example(dimensions_and_units dimensions_and_units.cpp "all checks passed: yes") # Every ```text block of docs/dimensions.md must be a run of lines the example diff --git a/examples/citations.cpp b/examples/citations.cpp index b3a0e94a..eb180b2a 100644 --- a/examples/citations.cpp +++ b/examples/citations.cpp @@ -12,10 +12,11 @@ // copyrighted material in a public repository. See docs/citations.md. #include +#include #include #include -#include +#include #include namespace @@ -23,6 +24,7 @@ namespace namespace unit = formula::unit; using formula::var; +// A quantity carries its own symbol, description and unit, and its tag makes it a type of its own. using WaterVolume = formula::Quantity; using CementVolume = formula::Quantity; using WaterCementRatio = formula::Quantity; @@ -40,70 +42,44 @@ constexpr auto ratio = formula::documented(var / var, int main() { - // Plain and LaTeX renderings of the same wrapped formula. The citation - // does not appear in either: render() answers only what the formula is, - // not where it comes from. + using namespace formula::literals; + + // One declaration, four questions. render() answers what the formula is, + // as plain text or LaTeX, and the citation appears in neither; document() + // walks the same tree for what render() leaves out, the symbol table and + // the citation; and checked_evaluate() gives the number, exactly what the + // bare division would have produced, because wrapping is invisible to + // arithmetic. std::string const plain = formula::render(ratio); std::string const latex = formula::render(ratio); - std::printf("plain: %s\n", plain.c_str()); - std::printf("latex: %s\n", latex.c_str()); - - // document() walks the same tree for what render() leaves out: the - // symbol table and the citation. formula::Documentation const documentation = formula::document(ratio); + auto const inputs = formula::environment(formula::Measured { 180 }, + formula::Measured { 300 }); + auto const result = formula::checked_evaluate(ratio, inputs); - // entry.symbol and entry.description are std::string_view, not owning, - // null-terminated strings -- citation.hpp permits a Citation (and, the - // same way, a SymbolEntry) built from a runtime std::string, for which - // .data() handed to printf's "%s" would read past the view looking for a - // terminator that need not be there. "%.*s" with the view's own length - // is correct regardless of what the view was built from. + std::println("plain: {}", plain); + std::println("latex: {}", latex); for (formula::SymbolEntry const& entry: documentation.symbols) - std::printf("symbol: %.*s = %.*s [%s]\n", - static_cast(entry.symbol.size()), - entry.symbol.data(), - static_cast(entry.description.size()), - entry.description.data(), - std::string { formula::view(entry.unit.symbolText) }.c_str()); - + std::println("symbol: {} = {} [{}]", entry.symbol, entry.description, entry.unit); formula::Citation const& citation = documentation.citations.at(0); - std::printf("citation: %.*s, %.*s, %.*s, %.*s\n", - static_cast(citation.title.size()), - citation.title.data(), - static_cast(citation.reference.size()), - citation.reference.data(), - static_cast(citation.section.size()), - citation.section.data(), - static_cast(citation.equation.size()), - citation.equation.data()); + std::println("citation: {}, {}, {}, {}", citation.title, citation.reference, citation.section, citation.equation); - // Evaluating the wrapped formula: the number is exactly what the bare - // formula would have produced, because wrapping is invisible to - // arithmetic. - auto const inputs = formula::environment(formula::Measured { formula::Rational { 180 } }, - formula::Measured { formula::Rational { 300 } }); - auto const result = formula::checked_evaluate(ratio, inputs); // Checked before dereferencing: result is a std::expected, and calling // operator-> on one that holds an error is undefined behaviour. Nothing // in this program can make checked_evaluate fail here, but an example is // teaching material, and the check costs nothing to show. if (!result.has_value()) { - std::printf("evaluation failed\n"); + std::println("evaluation failed"); return 1; } - std::printf("%.*s = %f (%s)\n", - static_cast(formula::Describe::symbol.size()), - formula::Describe::symbol.data(), - result->measurement().value().to_double(), - result->is_value() ? "computed" : "no value"); + std::println("{} = {} ({})", formula::symbol_of(), *result, result->source()); bool const renderedCorrectly = plain == "V_w / V_c" && latex == "\\frac{V_w}{V_c}"; bool const documentedCorrectly = documentation.symbols.size() == 2 && documentation.citations.size() == 1; - bool const evaluatedCorrectly = - result.has_value() && result->is_value() && result->measurement().value() == formula::Rational { 3, 5 }; + bool const evaluatedCorrectly = formula::number_of(result) == 0.6_r; bool const allChecksPassed = renderedCorrectly && documentedCorrectly && evaluatedCorrectly; - std::printf("all checks passed: %s\n", allChecksPassed ? "yes" : "no"); + std::println("all checks passed: {}", allChecksPassed ? "yes" : "no"); return allChecksPassed ? 0 : 1; } diff --git a/examples/composition.cpp b/examples/composition.cpp index da633dac..17593b5c 100644 --- a/examples/composition.cpp +++ b/examples/composition.cpp @@ -24,12 +24,13 @@ // appear once. #include +#include #include #include #include #include -#include +#include #include namespace @@ -47,9 +48,8 @@ inline constexpr formula::Unit Euro { .dimension = formula::base_dimension("EUR" .symbolText = formula::symbol("EUR"), .decimals = 2 }; -// Money is not a bare number. `var + formula::Rational { 1, 2 }` -// does not compile -- test/negative/money_plus_number.cpp pins the library's -// message for it. +// Money is not a bare number. `var + 0.5_r` does not compile -- +// test/negative/money_plus_number.cpp pins the library's message for it. static_assert(!formula::SameDimension); using WaterVolume = formula::Quantity; @@ -74,11 +74,15 @@ constexpr auto waterCementRatio = formula::documented(var / var * waterCementRatio, - { .title = "Cost of a mix at a given water/cement ratio", - .reference = "Example Standard 9:2021", - .section = "2.1", - .text = "Cost scales linearly with the water/cement ratio." }); +// +// This one is evaluated and explained below, so `yields` names what +// it computes once, here. The citation goes inside, on the formula it cites. +constexpr auto mixCost = + formula::yields(formula::documented(var * waterCementRatio, + { .title = "Cost of a mix at a given water/cement ratio", + .reference = "Example Standard 9:2021", + .section = "2.1", + .text = "Cost scales linearly with the water/cement ratio." })); // The same sub-formula used twice in one tree, for the asymmetry noted at the // top of this file. @@ -90,16 +94,16 @@ int main() { bool ok = true; auto check = [&ok](char const* what, bool condition) { - std::printf("%-46s %s\n", what, condition ? "yes" : "NO"); + std::println("{:<46} {}", what, condition ? "yes" : "NO"); ok = ok && condition; }; // ---- 1. The composed formula renders as one expression ---- std::string const inner = formula::render(waterCementRatio); std::string const outer = formula::render(mixCost); - std::printf("inner formula : %s\n", inner.c_str()); - std::printf("outer formula : %s\n", outer.c_str()); - std::printf("outer in LaTeX: %s\n", formula::render(mixCost).c_str()); + std::println("inner formula : {}", inner); + std::println("outer formula : {}", outer); + std::println("outer in LaTeX: {}", formula::render(mixCost)); check("inner renders as its own expression", inner == "V_w / V_c"); @@ -111,26 +115,23 @@ int main() check("outer renders the whole composed tree", outer == "c_u * V_w / V_c"); // ---- 2. It evaluates, exactly ---- - auto const inputs = formula::environment(formula::Measured { formula::Rational { 180 } }, - formula::Measured { formula::Rational { 300 } }, - formula::Measured { formula::Rational { 250 } }); - - auto const outcome = formula::checked_evaluate(mixCost, inputs); - check("the composed formula evaluates", outcome.has_value() && outcome->is_value()); - if (!outcome.has_value() || !outcome->is_value()) + auto const inputs = formula::environment(formula::Measured { 180 }, + formula::Measured { 300 }, + formula::Measured { 250 }); + + auto const outcome = formula::checked_evaluate(mixCost, inputs); + auto const cost = formula::number_of(outcome); + check("the composed formula evaluates", cost.has_value()); + if (!cost) { - std::printf("all checks passed: no\n"); + std::println("all checks passed: no"); return 1; } // 250 EUR * (180 l / 300 l) = 250 * 3/5 = 150, with no rounding anywhere: // 3/5 is held as 3/5, not as 0.59999999999999998. - formula::Rational const cost = outcome->measurement().value(); - std::printf("cost : %lld/%lld = %.2f EUR\n", - static_cast(cost.numerator()), - static_cast(cost.denominator()), - cost.to_double()); - check("the result is exactly 150", cost == formula::Rational { 150 }); + std::println("cost : {}", *outcome); + check("the result is exactly 150", cost == 150); // ---- 3. Provenance travels upward through the seam ---- // @@ -138,25 +139,17 @@ int main() // comes back because document() walks the whole tree, and the wrapped // sub-tree is part of that tree. formula::Documentation const documentation = formula::document(mixCost); - std::printf("citations on the outer formula: %zu\n", documentation.citations.size()); + std::println("citations on the outer formula: {}", documentation.citations.size()); for (formula::Citation const& citation: documentation.citations) - std::printf(" - %.*s [%.*s]\n", - static_cast(citation.title.size()), - citation.title.data(), - static_cast(citation.reference.size()), - citation.reference.data()); + std::println(" - {} [{}]", citation.title, citation.reference); check("both citations reach the outer formula", documentation.citations.size() == 2); // Three symbols, each once, although V_w and V_c are reached through the // inner formula rather than written in the outer one. - std::printf("symbols on the outer formula : %zu\n", documentation.symbols.size()); + std::println("symbols on the outer formula : {}", documentation.symbols.size()); for (formula::SymbolEntry const& entry: documentation.symbols) - std::printf(" - %.*s (%.*s)\n", - static_cast(entry.symbol.size()), - entry.symbol.data(), - static_cast(entry.description.size()), - entry.description.data()); + std::println(" - {} ({})", entry.symbol, entry.description); check("the symbol table merges both formulas", documentation.symbols.size() == 3); // ---- 4. The trace shows the inner formula as its own step ---- @@ -165,8 +158,8 @@ int main() // water/cement ratio, carrying its own citation, and step 6 consumes it. // An auditor reading the trace sees the sub-result the outer formula was // built on, not just the final number. - formula::Explained const explained = formula::explain(mixCost, inputs); - std::printf("trace:\n%s", formula::render_trace(explained.trace, { .maxSteps = 20 }).c_str()); + auto const explained = formula::explain(mixCost, inputs); + std::print("trace:\n{}", formula::render_trace(explained.trace, { .maxSteps = 20 })); // ---- 5. The asymmetry, stated because it is easy to be surprised by ---- // @@ -175,11 +168,11 @@ int main() // symbol once. If you are building a reference list from .citations, // collapse duplicates yourself. formula::Documentation const twice = formula::document(quadraticSurcharge); - std::printf("citations when the same formula is used twice: %zu\n", twice.citations.size()); - std::printf("symbols when the same formula is used twice: %zu\n", twice.symbols.size()); + std::println("citations when the same formula is used twice: {}", twice.citations.size()); + std::println("symbols when the same formula is used twice: {}", twice.symbols.size()); check("a doubly used citation is listed twice", twice.citations.size() == 2); check("a doubly used symbol is still listed once", twice.symbols.size() == 3); - std::printf("all checks passed: %s\n", ok ? "yes" : "no"); + std::println("all checks passed: {}", ok ? "yes" : "no"); return ok ? 0 : 1; } diff --git a/examples/exact_numbers.cpp b/examples/exact_numbers.cpp index 3c9b7463..2cffc35b 100644 --- a/examples/exact_numbers.cpp +++ b/examples/exact_numbers.cpp @@ -7,53 +7,67 @@ // things binary floating point cannot do -- exact unit conversion that round // trips, and rounding that is part of the calculation rather than of the output. +#include #include -#include +#include int main() { using formula::DecimalPlaces; using formula::Rational; using formula::RoundingMode; + using namespace formula::literals; - // 450 millilitres, written exactly. from_decimal(45, 1) is 450, not a double. - Rational const volumeInMillilitres = *Rational::from_decimal(45, 1); + // 450 millilitres, written exactly. 450_r is the number 450, never a double. + Rational const volumeInMillilitres = 450_r; // Convert to litres by an exact integer factor: multiply, then divide. // 450 ml -> 9/20 l, and back to 450 ml with nothing lost. - Rational const volumeInLitres = volumeInMillilitres / Rational { 1000 }; - Rational const roundTripped = volumeInLitres * Rational { 1000 }; + Rational const volumeInLitres = volumeInMillilitres / 1000; + Rational const roundTripped = volumeInLitres * 1000; - std::cout << "volume = " << volumeInMillilitres.numerator() << " ml" - << " = " << volumeInLitres.numerator() << '/' << volumeInLitres.denominator() << " l\n"; - std::cout << "round trip exact: " << (roundTripped == volumeInMillilitres ? "yes" : "no") << '\n'; + // `{:/}` writes a Rational as its fraction; `{}` writes the exact decimal + // where there is one, and the fraction otherwise. + std::println("volume = {} ml = {:/} l", volumeInMillilitres, volumeInLitres); + std::println("round trip exact: {}", roundTripped == volumeInMillilitres ? "yes" : "no"); // 0.4 of a minute, exactly. - Rational const durationInMinutes { 2, 5 }; + Rational const durationInMinutes = 0.4_r; Rational const flowRate = volumeInLitres / durationInMinutes; // 9/8 l/min, i.e. 1.125 // The method says: report to two decimal places, rounding half away from zero. // That rounding is part of the method, so it happens here, not at print time. Rational const reported = formula::round(flowRate, DecimalPlaces { 2 }, RoundingMode::HalfAwayFromZero); - std::cout << "flow rate = " << flowRate.numerator() << '/' << flowRate.denominator() << " l/min\n"; - std::cout << "reported = " << reported.to_double() << " l/min\n"; + std::println("flow rate = {:/} l/min", flowRate); + std::println("reported = {} l/min", reported); // Rounding up is a separate instruction from rounding to nearest, and the // two disagree on exactly the values where it matters. Rational const roundedUp = formula::round(flowRate, DecimalPlaces { 1 }, RoundingMode::Ceiling); Rational const roundedNearest = formula::round(flowRate, DecimalPlaces { 1 }, RoundingMode::HalfAwayFromZero); - std::cout << "one decimal, ceiling = " << roundedUp.to_double() << '\n'; - std::cout << "one decimal, nearest = " << roundedNearest.to_double() << '\n'; - std::cout << "they differ: " << (roundedUp != roundedNearest ? "yes" : "no") << '\n'; + std::println("one decimal, ceiling = {}", roundedUp); + std::println("one decimal, nearest = {}", roundedNearest); + std::println("they differ: {}", roundedUp != roundedNearest ? "yes" : "no"); // Ten tenths are exactly one. In double arithmetic they are not. Rational sum {}; for (int step = 0; step < 10; ++step) - sum += *Rational::from_decimal(1, -1); + sum += 0.1_r; - std::cout << "ten tenths == one: " << (sum == Rational { 1 } ? "yes" : "no") << '\n'; - return 0; + std::println("ten tenths == one: {}", sum == 1 ? "yes" : "no"); + + // Every number printed above is checked here; nothing is printed that this + // bool does not also cover. + bool const conversionRoundTrips = + volumeInMillilitres == 450 && volumeInLitres == 0.45_r && roundTripped == volumeInMillilitres; + bool const roundingIsPartOfTheMethod = flowRate == 1.125_r && reported == 1.13_r; + bool const theModesDisagree = roundedUp == 1.2_r && roundedNearest == 1.1_r; + bool const tenTenthsAreOne = sum == 1; + + bool const allChecksPassed = conversionRoundTrips && roundingIsPartOfTheMethod && theModesDisagree && tenTenthsAreOne; + std::println("all checks passed: {}", allChecksPassed ? "yes" : "no"); + return allChecksPassed ? 0 : 1; } diff --git a/examples/expressions.cpp b/examples/expressions.cpp index 34bc2a1f..733d84d1 100644 --- a/examples/expressions.cpp +++ b/examples/expressions.cpp @@ -9,14 +9,16 @@ // not compiled, below), and a manually entered result that replaces what the // formula would have computed while saying so honestly. +#include #include -#include +#include namespace { namespace unit = formula::unit; using formula::var; +using namespace formula::literals; // ---- 1 & 3: a circular area -- a constant, a power, and later an absence ---- @@ -27,7 +29,8 @@ using Area = formula::Quantity` a power node -- the result, Area, is declared in square metres // while Diameter is declared in millimetres, so this one formula also // demonstrates a result reported in a different unit from its input. -constexpr auto circularArea = formula::pi * formula::pow<2>(var) / formula::Rational { 4 }; +// `yields` names that result once, here, for both evaluations below. +constexpr auto circularArea = formula::yields(formula::pi * formula::pow<2>(var) / 4); // ---- 2: the same point made without a power in the way, for its own line ---- @@ -51,58 +54,52 @@ using WaterVolume = formula::Quantity; using WaterCementRatio = formula::Quantity; -constexpr auto waterCementRatio = var / var; +constexpr auto waterCementRatio = formula::yields(var / var); } // namespace int main() { // ---- 1. A formula with a constant and a power ---- - constexpr auto diameterKnown = formula::environment(formula::Measured { formula::Rational { 103 } }); - constexpr auto area = formula::checked_evaluate(circularArea, diameterKnown); - std::printf("circular area of a 103 mm diameter = %f m2 (%s)\n", - area->measurement().value().to_double(), - area->is_value() ? "computed" : "no value"); + // + // The exact area has no short decimal -- pi is a rational convergent -- so + // it is printed rounded to six places, and marked as rounded. + constexpr auto diameterKnown = formula::environment(formula::Measured { 103 }); + constexpr auto area = formula::checked_evaluate(circularArea, diameterKnown); + std::println("circular area of a 103 mm diameter = {:~.6HalfAwayFromZero} ({})", *area, area->source()); // ---- 2. A result quantity in a different unit from its input ---- - constexpr auto massInGrams = formula::environment(formula::Measured { formula::Rational { 2500 } }); + constexpr auto massInGrams = formula::environment(formula::Measured { 2500 }); constexpr auto massConverted = formula::checked_evaluate(var, massInGrams); - std::printf("2500 g reported as %s = %f kg\n", - formula::Describe::symbol.data(), - massConverted->measurement().value().to_double()); + std::println("2500 g reported as {} = {}", formula::symbol_of(), *massConverted); // ---- 3. An absent input propagates to an empty result, not a zero ---- constexpr auto diameterUnknown = formula::environment(formula::Measured::absent()); - constexpr auto emptyArea = formula::checked_evaluate(circularArea, diameterUnknown); - std::printf("area with no diameter measured: %s\n", emptyArea->is_empty() ? "empty" : "a number"); + constexpr auto emptyArea = formula::checked_evaluate(circularArea, diameterUnknown); + std::println("area with no diameter measured: {}", emptyArea->kind()); // ---- 4. A dimensional error is a compile error, not a runtime one ---- - std::printf("a diameter plus an area does not compile: see the comment above main() and docs/expressions.md\n"); + std::println("a diameter plus an area does not compile: see the comment above main() and docs/expressions.md"); // ---- 5. A manually entered result replaces the computed one ---- - auto const batch = - formula::environment(formula::Measured { formula::Rational { 180 } }, - formula::Measured { formula::Rational { 300 } }, - formula::entered(formula::Measured { formula::Rational { 1, 2 } })); - auto const ratio = formula::checked_evaluate(waterCementRatio, batch); - std::printf("%s = %f (%s)\n", - formula::Describe::symbol.data(), - ratio->measurement().value().to_double(), - ratio->is_overridden() ? "entered" : "computed"); + auto const batch = formula::environment(formula::Measured { 180 }, + formula::Measured { 300 }, + formula::entered(formula::Measured { 0.5_r })); + auto const ratio = formula::checked_evaluate(waterCementRatio, batch); + std::println("{} = {} ({})", formula::symbol_of(), *ratio, ratio->source()); // Every number printed above is checked here; nothing is printed that this - // bool does not also cover. - bool const circularAreaIsCorrect = area.has_value() && area->is_value() - && area->measurement().value().to_double() > 0.00833228 - && area->measurement().value().to_double() < 0.00833229; - bool const massConvertsExactly = massConverted.has_value() && massConverted->is_value() - && massConverted->measurement().value() == formula::Rational { 5, 2 }; - bool const absenceStaysEmpty = emptyArea.has_value() && emptyArea->is_empty(); - bool const overrideWinsOutright = ratio.has_value() && ratio->is_overridden() - && ratio->source() == formula::ValueSource::ManuallyEntered - && ratio->measurement().value() == formula::Rational { 1, 2 }; + // bool does not also cover. number_of is empty for an error and for a + // result that is not a number, so comparing it is a complete check. + auto const areaInSquareMetres = formula::number_of(area); + bool const circularAreaIsCorrect = areaInSquareMetres && *areaInSquareMetres > 0.00833228_r + && *areaInSquareMetres < 0.00833229_r + && area->source() == formula::ValueSource::Derived; + bool const massConvertsExactly = formula::number_of(massConverted) == 2.5_r; + bool const absenceStaysEmpty = emptyArea && emptyArea->is_empty(); + bool const overrideWinsOutright = ratio && ratio->is_overridden() && formula::number_of(ratio) == 0.5_r; bool const allChecksPassed = circularAreaIsCorrect && massConvertsExactly && absenceStaysEmpty && overrideWinsOutright; - std::printf("all checks passed: %s\n", allChecksPassed ? "yes" : "no"); + std::println("all checks passed: {}", allChecksPassed ? "yes" : "no"); return allChecksPassed ? 0 : 1; } diff --git a/examples/quantities.cpp b/examples/quantities.cpp index 91671b56..78fd21da 100644 --- a/examples/quantities.cpp +++ b/examples/quantities.cpp @@ -12,9 +12,10 @@ // either absent input makes the result absent, in a result quantity the // caller names rather than one inherited from either operand. +#include #include -#include +#include #include namespace @@ -73,75 +74,70 @@ int main() using formula::Measured; using formula::Rational; using formula::RoundingMode; + using namespace formula::literals; // ---- 1. Declaring two quantities and reading their metadata through Describe ---- - std::printf("WaterVolume: symbol=%s description=\"%s\" unit=%s\n", - Describe::symbol.data(), - Describe::description.data(), - formula::view(Describe::unit.symbolText).data()); - std::printf("SpecimenMass: symbol=%s description=\"%s\" unit=%s\n", - Describe::symbol.data(), - Describe::description.data(), - formula::view(Describe::unit.symbolText).data()); + std::println("WaterVolume: symbol={} description=\"{}\" unit={}", + Describe::symbol, + Describe::description, + Describe::unit); + std::println("SpecimenMass: symbol={} description=\"{}\" unit={}", + Describe::symbol, + Describe::description, + Describe::unit); bool const metadataReadsBackAsDeclared = - Describe::symbol == std::string_view { "V_w" } - && Describe::description == std::string_view { "volume of water added" } - && Describe::unit == unit::Litre && Describe::symbol == std::string_view { "m" } - && Describe::description == std::string_view { "mass of the specimen" } - && Describe::unit == unit::Kilogram; - std::printf("metadata reads back exactly as declared: %s\n", metadataReadsBackAsDeclared ? "yes" : "no"); + Describe::symbol == "V_w" && Describe::description == "volume of water added" + && Describe::unit == unit::Litre && Describe::symbol == "m" + && Describe::description == "mass of the specimen" && Describe::unit == unit::Kilogram; + std::println("metadata reads back exactly as declared: {}", metadataReadsBackAsDeclared ? "yes" : "no"); // ---- 2. Two quantities alike in symbol, description and unit, distinct in type ---- bool const metadataCoincides = Describe::symbol == Describe::symbol && Describe::description == Describe::description && Describe::unit == Describe::unit; bool const tagKeepsThemDistinct = !std::is_same_v; - std::printf("WaterVolume and CementVolume share symbol, description and unit: %s\n", metadataCoincides ? "yes" : "no"); - std::printf("...but the tag keeps them different types: %s\n", tagKeepsThemDistinct ? "yes" : "no"); + std::println("WaterVolume and CementVolume share symbol, description and unit: {}", metadataCoincides ? "yes" : "no"); + std::println("...but the tag keeps them different types: {}", tagKeepsThemDistinct ? "yes" : "no"); // ---- 3. A foreign type, joined by specialising Describe ---- - std::printf("ForeignTemperature: symbol=%s dimension is temperature: %s\n", - Describe::symbol.data(), - Describe::dimension == formula::dim::Temperature ? "yes" : "no"); + std::println("ForeignTemperature: symbol={} dimension is temperature: {}", + Describe::symbol, + Describe::dimension == formula::dim::Temperature ? "yes" : "no"); bool const foreignTypeJoinsTheSameWay = formula::Described - && Describe::symbol == std::string_view { "theta" } + && Describe::symbol == "theta" && Describe::dimension == formula::dim::Temperature; // ---- 4. A present measurement, converted exactly between quantities (450 l to m3) ---- - Measured const presentVolume { *Rational::from_decimal(450, 0) }; - auto const convertedPresent = formula::checked_convert_to(presentVolume); - Rational const convertedValue = convertedPresent->value(); - std::printf("450 l converted to m3 = %lld/%lld\n", - static_cast(convertedValue.numerator()), - static_cast(convertedValue.denominator())); - bool const presentValueConvertsExactly = - convertedPresent.has_value() && convertedPresent->has_value() && convertedValue == *Rational::make(9, 20); + // + // Nothing in this program can make a conversion fail, so it uses the + // throwing spellings: convert_to, round_to_declared and within_bounds + // return the value itself, where their checked_ twins return a + // std::expected for a caller that handles the error. + Measured const presentVolume { 450 }; + auto const convertedPresent = formula::convert_to(presentVolume); + std::println("{} converted to {} = {}", presentVolume, Describe::unit, convertedPresent); + bool const presentValueConvertsExactly = formula::number_of(convertedPresent) == 0.45_r; // ---- 5. An absent measurement surviving conversion, rounding and a bounds check ---- Measured const absentVolume {}; - auto const convertedAbsent = formula::checked_convert_to(absentVolume); - auto const roundedAbsent = formula::checked_round_to_declared(absentVolume, RoundingMode::HalfAwayFromZero); - auto const boundsOfAbsent = formula::checked_within_bounds(absentVolume); + auto const convertedAbsent = formula::convert_to(absentVolume); + auto const roundedAbsent = formula::round_to_declared(absentVolume, RoundingMode::HalfAwayFromZero); + auto const boundsOfAbsent = formula::within_bounds(absentVolume); - std::printf("an absent measurement, converted: %s\n", - convertedAbsent.has_value() && convertedAbsent->is_absent() ? "still absent" : "a number"); - std::printf("an absent measurement, rounded: %s\n", - roundedAbsent.has_value() && roundedAbsent->is_absent() ? "still absent" : "a number"); - std::printf("an absent measurement, bounds-checked: %s\n", - boundsOfAbsent.has_value() ? formula::describe(*boundsOfAbsent).data() : "conversion failed"); + std::println("an absent measurement, converted: {}", convertedAbsent); + std::println("an absent measurement, rounded: {}", roundedAbsent); + std::println("an absent measurement, bounds-checked: {}", boundsOfAbsent); - bool const absenceSurvivesEveryOperation = convertedAbsent.has_value() && convertedAbsent->is_absent() - && roundedAbsent.has_value() && roundedAbsent->is_absent() - && boundsOfAbsent.has_value() && *boundsOfAbsent == BoundsCheck::NotMeasured; + bool const absenceSurvivesEveryOperation = convertedAbsent.is_absent() && roundedAbsent.is_absent() + && boundsOfAbsent == BoundsCheck::NotMeasured; // ---- 6. combine: absent if EITHER input is, not only if both are, and the // RESULT is named by the caller, not inherited from either operand ---- Measured const absentMass {}; - Measured const combinedWithAnAbsentInput = formula::combine( + auto const combinedWithAnAbsentInput = formula::combine( presentVolume, absentMass, [](Rational volume, Rational mass) { return volume * mass; }); - std::printf("a present volume combined with an absent mass: %s\n", - combinedWithAnAbsentInput.is_absent() ? "absent" : "a number"); + std::println("a present volume combined with an absent mass: {}", combinedWithAnAbsentInput); bool const combineIsAbsentWhenEitherInputIs = combinedWithAnAbsentInput.is_absent(); // The static TYPE is Measured -- neither the volume's nor the // mass's own quantity. An earlier signature deduced the result as the @@ -157,6 +153,6 @@ int main() bool const allChecksPassed = metadataReadsBackAsDeclared && metadataCoincides && tagKeepsThemDistinct && foreignTypeJoinsTheSameWay && presentValueConvertsExactly && absenceSurvivesEveryOperation && combineIsAbsentWhenEitherInputIs; - std::printf("all checks passed: %s\n", allChecksPassed ? "yes" : "no"); + std::println("all checks passed: {}", allChecksPassed ? "yes" : "no"); return allChecksPassed ? 0 : 1; } diff --git a/examples/simple.cpp b/examples/simple.cpp index 2fd67ce2..80aab177 100644 --- a/examples/simple.cpp +++ b/examples/simple.cpp @@ -2,6 +2,7 @@ /// The shortest complete formula this library can express: two measured inputs, /// one formula, one traceable result. +#include #include #include @@ -20,14 +21,12 @@ inline constexpr auto waterCementRatio = formula::var / formula::va int main() { - auto const batch = formula::environment(formula::Measured { formula::Rational { 180 } }, - formula::Measured { formula::Rational { 300 } }); + auto const batch = formula::environment(formula::Measured { 180 }, formula::Measured { 300 }); - formula::Outcome const result = formula::evaluate(waterCementRatio, batch); + auto const result = formula::evaluate(waterCementRatio, batch); - std::println("{} = {:f} ({})", - formula::Describe::symbol, - result.measurement().value().to_double(), - result.is_value() ? "computed" : "no value"); + // The result prints as its number, in its quantity's unit, and says where + // that number came from. + std::println("{} = {} ({})", formula::symbol_of(), result, result.source()); return 0; } diff --git a/examples/tracing.cpp b/examples/tracing.cpp index 95325a5e..987d3739 100644 --- a/examples/tracing.cpp +++ b/examples/tracing.cpp @@ -19,13 +19,13 @@ #include #include -#include -#include +#include namespace { namespace unit = formula::unit; using formula::var; +using namespace formula::literals; using WaterVolume = formula::Quantity; using CementVolume = formula::Quantity; @@ -45,34 +45,31 @@ constexpr auto ratio = formula::documented(var / var, int main() { - auto const inputs = formula::environment(formula::Measured { formula::Rational { 180 } }, - formula::Measured { formula::Rational { 300 } }); + auto const inputs = formula::environment(formula::Measured { 180 }, + formula::Measured { 300 }); // explain(expression, environment) returns exactly what // evaluate(expression, environment) would have -- an // Outcome -- plus a Trace of every step the evaluator took to // reach it. Tracing observes; it does not change the answer. - formula::Explained const explained = formula::explain(ratio, inputs); + auto const explained = formula::explain(ratio, inputs); // render_trace has no default for maxSteps: TraceRenderOptions::maxSteps // is a StepLimit, which has no default constructor, so a caller who // writes render_trace(explained.trace, {}) does not compile, rather than // risking an unbounded dump of a derivation many times this size. - std::string const trace = formula::render_trace(explained.trace, { .maxSteps = 10 }); - std::printf("%s", trace.c_str()); + std::print("{}", formula::render_trace(explained.trace, { .maxSteps = 10 })); - // Checked before use: is_value() and measurement() cover Value, but an - // Outcome may also be Empty, Verdict or Invalid, and reading measurement() - // on one of those would read a default-constructed Measured rather than - // fail loudly. Nothing in this program can make it anything but Value; - // an example is teaching material, and the check costs nothing to show. - bool const evaluatedCorrectly = - explained.outcome.is_value() && explained.outcome.measurement().value() == formula::Rational { 3, 5 }; + // An Outcome may also be Empty, Verdict or Invalid, and number_of is + // empty for each of those, so comparing it is a complete check. Nothing in + // this program can make it anything but a value; an example is teaching + // material, and the check costs nothing to show. + bool const evaluatedCorrectly = formula::number_of(explained.outcome) == 0.6_r; // One step per node: the two variables, the division, and the documented // wrapper around it. bool const tracedCorrectly = explained.trace.steps.size() == 4; bool const allChecksPassed = evaluatedCorrectly && tracedCorrectly; - std::printf("all checks passed: %s\n", allChecksPassed ? "yes" : "no"); + std::println("all checks passed: {}", allChecksPassed ? "yes" : "no"); return allChecksPassed ? 0 : 1; } From 14b02f8f89f3cd60b48a1d86946cf929c2d458ce Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 20:41:38 +0200 Subject: [PATCH 23/59] docs(examples): write the dimensions and display examples in the short spellings dimensions_and_units now prints every dimension, number and bounds verdict with std::println. The formatters in format.hpp for a Dimension, a Rational and an enumeration replace its own print_dimension and its printf calls. Its inputs are _r decimals. It rounds to the kilogram's declared precision with the throwing round_to_declared, because nothing in it can fail. Every line of its output is unchanged. display now writes its values as 25.5_r and Measured { 144 }. It supplies the dish's weighings through measured_series. It traces with explain and explain_conformity, instead of assembling a Trace and a RecordingSink. It renders and documents with RenderOptions alone, and prints with std::println. A new sixth section formats an Outcome, a Unit, a Dimension and an enumeration. The Outcome rows cover a value, an empty outcome and a verdict, whose label is right-aligned. Each row is printed beside its call and checked. The first five sections print exactly what they printed before. docs/display.md quotes the new lines. Its new section, "Formatting outcomes, units, dimensions and enumerations", says that format.hpp must be included wherever these are formatted. Its table and output block are checked against the program, like the rest of the page. docs/dimensions.md quotes the new rounding call and says what formats a Dimension. Signed-off-by: Christian Parpart --- docs/dimensions.md | 24 ++-- docs/display.md | 162 ++++++++++++++++------- examples/dimensions_and_units.cpp | 155 ++++++++-------------- examples/display.cpp | 213 +++++++++++++++--------------- 4 files changed, 292 insertions(+), 262 deletions(-) diff --git a/docs/dimensions.md b/docs/dimensions.md index ce09ebd9..b2b9d277 100644 --- a/docs/dimensions.md +++ b/docs/dimensions.md @@ -284,8 +284,7 @@ range (`bounds`), and both apply to a *computed* value, not just to a literal: ```cpp Rational const computedMass = genericDensity * volumeInCubicMetres; -Rational const roundedMass = - *formula::checked_round_to_declared(computedMass, unit::Kilogram, RoundingMode::HalfAwayFromZero); +Rational const roundedMass = formula::round_to_declared(computedMass, unit::Kilogram, RoundingMode::HalfAwayFromZero); ``` ```text @@ -296,10 +295,12 @@ rounded to kg's declared precision (3 places) = 64.286 kg `formula::declared_decimals` returns the rounding layer's own `DecimalPlaces` type, not a bare `int`, so it plugs directly into `formula::round` / `formula::checked_round` (see [`docs/numbers.md`](numbers.md)). -`checked_round_to_declared` is the two calls composed, and returns a -`std::expected` like every other `checked_` function here; -`round_to_declared` is the same thing spelled to throw, as `convert` is to -`checked_convert`. +`round_to_declared` is the two calls composed. It throws +`ArithmeticException` where the rounding cannot be represented, as `convert` +does, and the example uses it because nothing in it can fail. +`checked_round_to_declared` is the same thing spelled to return a +`std::expected`, like every other `checked_` function here, for a caller +that handles the error. `formula::checked_within_bounds` checks a value, in the unit's own scale, against that unit's declared `bounds`, and returns one of five @@ -344,10 +345,13 @@ tariff (EUR / energy) = L^-2 M^-1 T^2 EUR^1 tariff * energy = EUR^1 ``` -The example prints a named base after the seven SI exponents, by its name. A -tariff is euros over an energy -- `L^-2 M^-1 T^2` from the joule, `EUR^1` from -the base -- and times an energy it is euros again: the same value as -`base_dimension("EUR")` itself, which the example checks. +The example prints every dimension with `std::println`. `{}` of a `Dimension` +(``) writes each exponent that is not zero, in the +order L, M, T, I, Theta, N, J, and then each named base by its name; a pure +number reads `(dimensionless)`. A tariff is euros over an energy -- +`L^-2 M^-1 T^2` from the joule, `EUR^1` from the base -- and times an energy +it is euros again: the same value as `base_dimension("EUR")` itself, which the +example checks. **Identity is the name, byte for byte.** Two parts of a program, or two libraries, that both write `base_dimension("EUR")` get the same dimension -- diff --git a/docs/display.md b/docs/display.md index 2e9417e4..f4d09428 100644 --- a/docs/display.md +++ b/docs/display.md @@ -17,7 +17,8 @@ The four ways a number reaches text, each covered below: - **`number_text()`** and **`decimal_text()`**, which need neither `` nor an allocation; - **`std::format`**, for a `Rational` or a `Measured`, once - `` is included. + `` is included -- which also formats an + `Outcome`, a `Unit`, a `Dimension` and an enumeration's words. The worked example is `examples/display.cpp`: a soil specimen's moisture content, from its wet and dried masses in a dish. **Program output** on this @@ -60,33 +61,33 @@ dish's 25.5 g taken off: ```cpp inline constexpr auto moistureContent = - (var - var) / (var - formula::constant(rat(51, 2))); + (var - var) / (var - formula::constant(25.5_r)); inline constexpr auto specimen = - formula::environment(formula::Measured { rat(787, 5) }, formula::Measured { rat(144) }); + formula::environment(formula::Measured { 157.4_r }, formula::Measured { 144 }); ``` -`rat(n, d)` is the example's shorthand for `Rational { n, d }`. The example -evaluates the formula and records every step into a `Trace` -([Tracing](tracing.md)): +`25.5_r` is the exact number its digits spell, 51/2 +(`using namespace formula::literals;`, +[Writing an exact decimal](numbers.md#writing-an-exact-decimal)). `explain` +evaluates the formula and returns its outcome together with a `Trace` of +every step ([Tracing](tracing.md)): ```cpp -formula::Trace<> trace {}; -auto const moisture = - formula::checked_evaluate(moistureContent, specimen, formula::RecordingSink<> { trace }); +auto const moisture = formula::explain(moistureContent, specimen); ``` `render_trace` takes the style in `TraceRenderOptions::numbers`. One trace, four ways: ```cpp -NumberStyle const exactStyle = NumberStyle::exact_decimal(); -NumberStyle const roundedStyle = NumberStyle::approximate_decimal(RoundingMode::HalfEven); -NumberStyle const paddedStyle = NumberStyle::approximate_decimal(RoundingMode::HalfEven, DecimalPadding::Padded); -std::string const fractions = formula::render_trace(trace, { .maxSteps = 20 }); -std::string const exactDecimals = formula::render_trace(trace, { .maxSteps = 20, .numbers = exactStyle }); -std::string const rounded = formula::render_trace(trace, { .maxSteps = 20, .numbers = roundedStyle }); -std::string const padded = formula::render_trace(trace, { .maxSteps = 20, .numbers = paddedStyle }); +auto const exactStyle = NumberStyle::exact_decimal(); +auto const roundedStyle = NumberStyle::approximate_decimal(RoundingMode::HalfEven); +auto const paddedStyle = NumberStyle::approximate_decimal(RoundingMode::HalfEven, DecimalPadding::Padded); +std::string const fractions = formula::render_trace(moisture.trace, { .maxSteps = 20 }); +std::string const exactDecimals = formula::render_trace(moisture.trace, { .maxSteps = 20, .numbers = exactStyle }); +std::string const rounded = formula::render_trace(moisture.trace, { .maxSteps = 20, .numbers = roundedStyle }); +std::string const padded = formula::render_trace(moisture.trace, { .maxSteps = 20, .numbers = paddedStyle }); ``` ```text @@ -179,7 +180,7 @@ mean of three weighings in grams, is computed in kilograms: ```cpp // The mean of three weighings: their sum times a typed 1/3, which has no exact decimal. -inline constexpr auto dishMass = formula::sum(formula::series) * formula::number(rat(1, 3)); +inline constexpr auto dishMass = formula::sum(formula::series) * formula::number(Rational { 1, 3 }); ``` Its trace, rendered in the rounded and padded style: @@ -286,10 +287,11 @@ would. ## Decimals in a rendered formula and its documentation -`render()` and `document()` take the style in `RenderOptions`, beside the +`render()` and `document()` take the style in `RenderOptions`, after the [vocabulary](citations.md#whose-symbols-a-jurisdictions-vocabulary) that says -how each quantity's symbol is written (`DefaultVocabulary {}` renames nothing). -They take it for a formula and for a +how each quantity's symbol is written, or in its place: `render(x, options)` +and `document(x, options)` write every symbol as its quantity declares it, as +`DefaultVocabulary {}` does. They take it for a formula and for a [calculation](calculations.md#defining-named-values) alike: `render(calculation, vocabulary, options)` writes each definition's numbers in it, and `document(calculation, vocabulary, options)` its formula and each @@ -300,10 +302,9 @@ For the moisture content: ```cpp formula::RenderOptions const decimals { .numbers = NumberStyle::exact_decimal() }; std::string const defaultText = formula::render(moistureContent); -std::string const decimalText = formula::render(moistureContent, formula::DefaultVocabulary {}, decimals); -std::string const latexText = - formula::render(moistureContent, formula::DefaultVocabulary {}, decimals); -formula::Documentation const page = formula::document(moistureContent, formula::DefaultVocabulary {}, decimals); +std::string const decimalText = formula::render(moistureContent, decimals); +std::string const latexText = formula::render(moistureContent, decimals); +formula::Documentation const page = formula::document(moistureContent, decimals); ``` ```text @@ -331,7 +332,7 @@ inline constexpr auto dishMean = formula::sample_mean( formula::AtMost<1>, formula::KeepAtLeast<2>>( formula::series, - formula::deviation_from_mean(rat(1, 30) * formula::pass_mean), + formula::deviation_from_mean(Rational { 1, 30 } * formula::pass_mean), formula::Verdict { "weigh the dish again" })); ``` @@ -339,8 +340,8 @@ Under the rounding style, both stay fractions: ```cpp formula::RenderOptions const rounding { .numbers = roundedStyle }; -std::string const dishFormula = formula::render(dishMass, formula::DefaultVocabulary {}, rounding); -formula::Documentation const dishPage = formula::document(dishMean, formula::DefaultVocabulary {}, rounding); +std::string const dishFormula = formula::render(dishMass, rounding); +formula::Documentation const dishPage = formula::document(dishMean, rounding); ``` ```text @@ -394,8 +395,8 @@ The example spells the specimen's moisture content, `w`, and a moisture content nobody measured: ```cpp -formula::Measured const w = moisture->measurement(); -formula::Measured const notMeasured = formula::Measured::absent(); +formula::Measured const w = moisture.outcome.measurement(); +formula::Measured const notMeasured {}; formula::NumberText const measuredText = formula::number_text(w, roundedStyle); formula::NumberText const absentText = formula::number_text(notMeasured, roundedStyle); formula::NumberText const twoPlaces = @@ -408,18 +409,17 @@ not measured: (not measured) two places: 11.31 ``` -A `Measured` value that is absent reads `(not measured)`, in every style. +A `Measured` value that is absent -- here one constructed with nothing, `{}` +-- reads `(not measured)`, in every style. -A `NumberText`'s characters are read through `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: +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: ```cpp -/// Prints @p label and @p spelled, a number `number_text` or `decimal_text` wrote. -void print_spelled(char const* label, formula::NumberText const& spelled) -{ - std::printf("%s%.*s\n", label, static_cast(spelled.view().size()), spelled.view().data()); -} +std::println("measured: {}", measuredText.view()); +std::println("not measured: {}", absentText.view()); +std::println("two places: {}\n", twoPlaces.view()); ``` **When a number cannot be spelled.** `decimal_text` throws @@ -451,14 +451,16 @@ so that a report and its trace spell each value alike. Reach for `formula.hpp` does not include it: it includes ``, which a consumer who only evaluates numbers should not compile in every translation unit. **Include it in every translation unit that formats a `Rational` or a -`Measured`, or asks whether it can** (`std::formattable`). The header declares -explicit specialisations of `std::formatter`, and an explicit specialisation -must be seen before any use that would otherwise instantiate the primary -template; translation units that disagree about it make the program -ill-formed, with no diagnostic required. **The library owns these two -specialisations** -- `std::formatter` and -`std::formatter, char>` -- so a consumer must not -specialise them too. Only `char` is supported: a unit's symbol is UTF-8. +`Measured`, or asks whether it can** (`std::formattable`) -- and the same holds +for the outcomes, units, dimensions and enumerations it also formats +([below](#formatting-outcomes-units-dimensions-and-enumerations)). The header +declares specialisations of `std::formatter`, and a specialisation must be +seen before any use that would otherwise instantiate the primary template; +translation units that disagree about it make the program ill-formed, with no +diagnostic required. **The library owns these specialisations** -- +`std::formatter` and +`std::formatter, char>` among them -- so a consumer must +not specialise them too. Only `char` is supported: a unit's symbol is UTF-8. ### The spec @@ -467,9 +469,9 @@ In the table and the reference below, `w` is the specimen's moisture content, section; `wetMass`, `oven` and `grain` are measured here: ```cpp -formula::Measured const wetMass { rat(787, 5) }; -formula::Measured const oven { rat(583, 10) }; -formula::Measured const grain { rat(217) }; +formula::Measured const wetMass { 157.4_r }; +formula::Measured const oven { 58.3_r }; +formula::Measured const grain { 217 }; ``` | Spec | Meaning | Example | Output | @@ -602,7 +604,7 @@ try } catch (std::format_error const& refusal) { - std::printf("\nstd::vformat(\"{:.2}\", ...) throws std::format_error:\n%s\n\n", refusal.what()); + std::println("\nstd::vformat(\"{{:.2}}\", ...) throws std::format_error:\n{}\n", refusal.what()); check(std::string_view { refusal.what() }.starts_with("formula: "), "the refusal starts formula: "); } ``` @@ -626,3 +628,63 @@ thousands. Write `{:~.0HalfEven}` instead to round it to whole units -- a rounding to 0 to 18 places is spelled by long division, which cannot overflow, so `from_double_exact(0.1)` reads `≈0` -- or `{:/}` for its exact fraction, or catch the `std::format_error`. + +## Formatting outcomes, units, dimensions and enumerations + +The same header formats four more kinds of value, so that a report prints the +library's own values rather than taking them apart: an `Outcome`, a `Unit`, +a `Dimension`, and every enumeration of the library that has a `describe()`. +**Include `` in every translation unit that formats +one of them, or asks whether it can**, for the reason +[Opting in](#opting-in) gives. That holds for an enumeration too: +`std::formattable` is true only where the header +is included. The library owns these specialisations as well: a consumer's own +`std::formatter` for `Outcome`, `Unit`, `Dimension` or one of these +enumerations defines it twice, and a generic one for every enumeration is +ambiguous for them. + +`moisture.outcome` is the moisture content's outcome from the first section. +The example adds a verdict and an empty outcome: + +```cpp +// What a rejection of the dish's weighings gives when it cannot settle, and +// a dish nobody weighed. +auto const reweigh = formula::Outcome::verdict({ "weigh the dish again" }); +auto const unweighed = formula::Outcome::empty(); +``` + +| Value | Written as | Example | Output | +|---|---|---|---| +| an `Outcome` holding a value | its `Measured`, in the same spec | `std::format("{:~HalfEven}", moisture.outcome)` | `≈11.3 %` | +| an empty `Outcome` | `(not measured)`, whatever the body | `std::format("{}", unweighed)` | `(not measured)` | +| a verdict or an invalid `Outcome` | its label, right-aligned by default | `std::format("{:22}", reweigh)` | `" weigh the dish again"` | +| a `Unit` | its symbol, left-aligned by default | `std::format("{:4}", unit::Gram)` | `"g "` | +| a `Dimension` | its exponents, `(dimensionless)` for a pure number | `std::format("{}", unit::Gram.dimension)` | `M^1` | +| an enumeration | its `describe()` words | `std::format("{}", RoundingMode::HalfEven)` | `nearest, ties to even` | + +The example prints every row, and checks each against the text in its +source: + +```text +std::format("{}", moisture.outcome) 2680/237 % +std::format("{:~HalfEven}", moisture.outcome) ≈11.3 % +std::format("{}", unweighed) (not measured) +std::format("{:22}", reweigh) " weigh the dish again" +std::format("{:4}", unit::Gram) "g " +std::format("{}", unit::Gram.dimension) M^1 +std::format("{}", unit::Percent.dimension) (dimensionless) +std::format("{}", moisture.outcome.source()) derived +std::format("{}", RoundingMode::HalfEven) nearest, ties to even +``` + +- **An `Outcome`** takes the spec of a `Measured`. A value is written as + its `Measured` is, rounding included, and an empty outcome reads + `(not measured)`. A verdict writes its label, and an invalid outcome the + reason's; a rounding in the spec does not apply to words, but the fill, the + alignment and the width do, and the label is **right-aligned by default**, + as a number is. +- **A `Unit`** writes its symbol, **a `Dimension`** its exponents as + [Dimensions and units](dimensions.md) prints them, and **an enumeration** its + `describe()` words -- `ValueSource`, `OutcomeKind`, `RoundingMode`, + `ArithmeticError`, `BoundsCheck` and the others that have one. These take a + string's spec, and are **left-aligned by default**, as a string is. diff --git a/examples/dimensions_and_units.cpp b/examples/dimensions_and_units.cpp index c144d9c3..080f36a6 100644 --- a/examples/dimensions_and_units.cpp +++ b/examples/dimensions_and_units.cpp @@ -10,62 +10,23 @@ // computed value, a bounds check that tells "never checked" apart from // "checked and passed", and a base dimension the SI does not have: money. +#include #include -#include #include +#include #include -namespace -{ -using formula::Dimension; -using formula::Exponent; - -/// Prints one base dimension's exponent as "^p" when integral, "^(p/q)" when -/// not, and nothing at all when the exponent is zero. -void print_exponent(std::string_view baseName, Exponent value) -{ - if (formula::is_zero(value)) - return; - int const nameLength = static_cast(baseName.size()); - if (formula::is_integer(value)) - std::printf(" %.*s^%d", nameLength, baseName.data(), value.numerator); - else - std::printf(" %.*s^(%d/%d)", nameLength, baseName.data(), value.numerator, value.denominator); -} - -/// Prints a Dimension as its seven-exponent vector and then its named base -/// dimensions, each omitted when its exponent is zero. There is no -/// formula::operator<<: the library keeps / out of its public -/// headers, so a consumer that wants to print a Dimension writes this itself, -/// as this example does. -void print_dimension(char const* label, Dimension value) -{ - std::printf("%s =", label); - print_exponent("L", value.length); - print_exponent("M", value.mass); - print_exponent("T", value.time); - print_exponent("I", value.current); - print_exponent("Theta", value.temperature); - print_exponent("N", value.amount); - print_exponent("J", value.luminosity); - // A slot not in use holds a zero exponent, which prints nothing. - for (formula::NamedBase const& base: value.namedBases) - print_exponent(formula::view(base.name), base.exponent); - if (formula::is_dimensionless(value)) - std::printf(" (dimensionless)"); - std::printf("\n"); -} -} // namespace - int main() { namespace dim = formula::dim; namespace unit = formula::unit; using formula::BoundsCheck; + using formula::Dimension; using formula::Rational; using formula::RoundingMode; using formula::Unit; + using namespace formula::literals; // ---- 1. Composing dimensions from named constants ---- // @@ -75,12 +36,14 @@ int main() Dimension const volume = area * dim::Length; Dimension const density = dim::Mass / volume; - print_dimension("area (length * length)", area); - print_dimension("volume (area * length)", volume); - print_dimension("density (mass / volume)", density); + // `{}` of a Dimension (format.hpp) writes each base whose exponent is not + // zero, in the order L, M, T, I, Theta, N, J, then each named base by name. + std::println("area (length * length) = {}", area); + std::println("volume (area * length) = {}", volume); + std::println("density (mass / volume) = {}", density); bool const compositionMatches = area == dim::Area && volume == dim::Volume && density == dim::Density; - std::printf("composed dimensions match the named constants: %s\n", compositionMatches ? "yes" : "no"); + std::println("composed dimensions match the named constants: {}", compositionMatches ? "yes" : "no"); // ---- 2. A dimension only a rational exponent can express ---- // @@ -88,87 +51,82 @@ int main() // The square root of a LENGTH is length to the one half, which no integer // exponent can name at all. Norm-style size formulas do take such roots. Dimension const rootOfLength = formula::nth_root(dim::Length, 2); - print_dimension("sqrt(length)", rootOfLength); + std::println("sqrt(length) = {}", rootOfLength); bool const rootIsHalfPower = rootOfLength.length == formula::exponent(1, 2); - std::printf("sqrt(length) has exponent one half: %s\n", rootIsHalfPower ? "yes" : "no"); + std::println("sqrt(length) has exponent one half: {}", rootIsHalfPower ? "yes" : "no"); // ---- 3. Exact conversion that round-trips: 450 l to m3 and back ---- - Rational const volumeInLitres = *Rational::from_decimal(450, 0); + // + // `{:/}` writes a Rational as its fraction; `{}` writes the exact decimal + // where there is one, and the fraction otherwise. + Rational const volumeInLitres = 450_r; Rational const volumeInCubicMetres = formula::convert(volumeInLitres, unit::Litre, unit::CubicMetre); Rational const volumeBackInLitres = formula::convert(volumeInCubicMetres, unit::CubicMetre, unit::Litre); - std::printf("450 l = %lld/%lld m3\n", - static_cast(volumeInCubicMetres.numerator()), - static_cast(volumeInCubicMetres.denominator())); - std::printf("... converted back = %lld l\n", static_cast(volumeBackInLitres.numerator())); + std::println("{} l = {:/} m3", volumeInLitres, volumeInCubicMetres); + std::println("... converted back = {} l", volumeBackInLitres); bool const volumeRoundTrips = volumeBackInLitres == volumeInLitres; - std::printf("volume round trip exact: %s\n", volumeRoundTrips ? "yes" : "no"); + std::println("volume round trip exact: {}", volumeRoundTrips ? "yes" : "no"); // ---- 4. The affine case: 100 degC to K and back, then degF to degC ---- // // Conversion moves a POINT on a scale, not a difference: 100 degC is not // 100 K, it is 100 K above the offset between the two scales. - Rational const tempInCelsius = *Rational::make(100, 1); + Rational const tempInCelsius = 100_r; Rational const tempInKelvin = formula::convert(tempInCelsius, unit::Celsius, unit::Kelvin); Rational const tempBackInCelsius = formula::convert(tempInKelvin, unit::Kelvin, unit::Celsius); - std::printf("100 degC = %lld/%lld K\n", - static_cast(tempInKelvin.numerator()), - static_cast(tempInKelvin.denominator())); - std::printf("... converted back = %lld degC\n", static_cast(tempBackInCelsius.numerator())); + std::println("{} degC = {:/} K", tempInCelsius, tempInKelvin); + std::println("... converted back = {} degC", tempBackInCelsius); bool const temperatureRoundTrips = tempBackInCelsius == tempInCelsius; - std::printf("temperature round trip exact: %s\n", temperatureRoundTrips ? "yes" : "no"); + std::println("temperature round trip exact: {}", temperatureRoundTrips ? "yes" : "no"); // The second affine scale. -40 is where degrees Fahrenheit and degrees // Celsius meet, so it converts to itself. 100 degF is a number of degrees // Celsius that is a fraction, 340/9, not a terminating decimal, and it is // kept as that fraction: converting divides by 9 and rounds nothing. - Rational const minusFortyInFahrenheit = *Rational::make(-40, 1); + Rational const minusFortyInFahrenheit = -40_r; Rational const minusFortyInCelsius = formula::convert(minusFortyInFahrenheit, unit::Fahrenheit, unit::Celsius); - Rational const hundredInFahrenheit = *Rational::make(100, 1); + Rational const hundredInFahrenheit = 100_r; Rational const hundredFahrenheitInCelsius = formula::convert(hundredInFahrenheit, unit::Fahrenheit, unit::Celsius); - std::printf("-40 degF = %lld degC\n", static_cast(minusFortyInCelsius.numerator())); - std::printf("100 degF = %lld/%lld degC\n", - static_cast(hundredFahrenheitInCelsius.numerator()), - static_cast(hundredFahrenheitInCelsius.denominator())); - bool const fahrenheitConvertsExactly = minusFortyInCelsius == *Rational::make(-40, 1) - && hundredFahrenheitInCelsius == *Rational::make(340, 9); + std::println("{} degF = {} degC", minusFortyInFahrenheit, minusFortyInCelsius); + std::println("{} degF = {:/} degC", hundredInFahrenheit, hundredFahrenheitInCelsius); + bool const fahrenheitConvertsExactly = + minusFortyInCelsius == -40_r && hundredFahrenheitInCelsius == Rational { 340, 9 }; // ---- 5. Power and energy: a kilowatt-hour is exactly 3600000 joules ---- // // A watt-hour is the energy of one watt sustained for an hour, 3600 // joules, and a kilowatt-hour is a thousand of them: the factor is a whole // number, so the conversion needs no rounded constant. - Rational const oneKilowattHour = *Rational::make(1, 1); + Rational const oneKilowattHour = 1_r; Rational const kilowattHourInJoules = formula::convert(oneKilowattHour, unit::KilowattHour, unit::Joule); - std::printf("1 kWh = %lld J\n", static_cast(kilowattHourInJoules.numerator())); - bool const kilowattHourIsExact = kilowattHourInJoules == *Rational::make(3600000, 1); + std::println("{} kWh = {} J", oneKilowattHour, kilowattHourInJoules); + bool const kilowattHourIsExact = kilowattHourInJoules == 3600000_r; // ---- 6. A unit's declared precision applied to a computed value ---- // // A generic density and a generic volume, multiplied to a mass -- the // point is that the RESULT of a calculation, not a literal, gets rounded // to the unit it will be reported in. unit::Kilogram declares 3 decimals. - Rational const genericDensity = *Rational::make(1000, 7); // an arbitrary density, in kg/m3 + // Nothing here can make the rounding fail, so this uses the throwing + // round_to_declared rather than its checked_ twin. + Rational const genericDensity { 1000, 7 }; // an arbitrary density, in kg/m3 Rational const computedMass = genericDensity * volumeInCubicMetres; - Rational const roundedMass = - *formula::checked_round_to_declared(computedMass, unit::Kilogram, RoundingMode::HalfAwayFromZero); + Rational const roundedMass = formula::round_to_declared(computedMass, unit::Kilogram, RoundingMode::HalfAwayFromZero); - std::printf("computed mass = %lld/%lld kg\n", - static_cast(computedMass.numerator()), - static_cast(computedMass.denominator())); - std::printf("rounded to kg's declared precision (%d places) = %.*f kg\n", - formula::declared_decimals(unit::Kilogram).value, - formula::declared_decimals(unit::Kilogram).value, - roundedMass.to_double()); + std::println("computed mass = {:/} kg", computedMass); + std::println("rounded to kg's declared precision ({} places) = {} kg", + formula::declared_decimals(unit::Kilogram).value, + roundedMass); // 450/7 kg is 64.2857..., which at kilogram's three declared places is // 64.286. Asserted, not merely printed: the documentation quotes this // number, and without a check here changing the rounding mode silently // changes it while the example still reports success. - bool const massRoundsAsDocumented = roundedMass == *Rational::from_decimal(64286, -3); + bool const massRoundsAsDocumented = roundedMass == 64.286_r; // ---- 7. Bounds: NotChecked is not a verdict, WithinBounds is ---- constexpr Unit BoundedGauge { .dimension = dim::Scalar, @@ -178,11 +136,14 @@ int main() .decimals = 1, .bounds = formula::bounds(0, 1, 100, 1) }; - BoundsCheck const unboundedVerdict = *formula::checked_within_bounds(*Rational::make(1000000, 1), unit::Litre); - BoundsCheck const boundedVerdict = *formula::checked_within_bounds(*Rational::make(42, 1), BoundedGauge); + // Neither unit declares a malformed range, the one thing that makes this + // check fail, so its std::expected is read directly. + BoundsCheck const unboundedVerdict = *formula::checked_within_bounds(1000000_r, unit::Litre); + BoundsCheck const boundedVerdict = *formula::checked_within_bounds(42_r, BoundedGauge); - std::printf("unbounded unit (litre) reports: %s\n", formula::describe(unboundedVerdict).data()); - std::printf("bounded gauge at 42%%: %s\n", formula::describe(boundedVerdict).data()); + // `{}` of a BoundsCheck writes its describe() words. + std::println("unbounded unit (litre) reports: {}", unboundedVerdict); + std::println("bounded gauge at 42%: {}", boundedVerdict); bool const boundsBehaveAsDocumented = unboundedVerdict == BoundsCheck::NotChecked && boundedVerdict == BoundsCheck::WithinBounds; @@ -197,8 +158,8 @@ int main() Dimension const euros = formula::base_dimension("EUR"); Dimension const tariff = euros / dim::Energy; Dimension const tariffTimesEnergy = tariff * dim::Energy; - print_dimension("tariff (EUR / energy)", tariff); - print_dimension("tariff * energy", tariffTimesEnergy); + std::println("tariff (EUR / energy) = {}", tariff); + std::println("tariff * energy = {}", tariffTimesEnergy); constexpr Unit Euro { .dimension = formula::base_dimension("EUR"), .symbolText = formula::symbol("EUR"), @@ -212,18 +173,18 @@ int main() .symbolText = formula::symbol("JPY"), .decimals = 0 }; - Rational const priceInEuros = *Rational::make(250, 1); + Rational const priceInEuros = 250_r; Rational const priceInCents = formula::convert(priceInEuros, Euro, EuroCent); Rational const priceBackInEuros = formula::convert(priceInCents, EuroCent, Euro); std::expected const priceInYen = formula::checked_convert(priceInEuros, Euro, Yen); - std::printf("250 EUR = %lld ct\n", static_cast(priceInCents.numerator())); - std::printf("... converted back = %lld EUR\n", static_cast(priceBackInEuros.numerator())); - std::printf("250 EUR to JPY: %s\n", - priceInYen.has_value() ? "converted" : formula::describe(priceInYen.error()).data()); - bool const moneyBehavesAsDocumented = tariffTimesEnergy == euros && !(tariff == euros) - && priceInCents == *Rational::make(25000, 1) + std::println("{} EUR = {} ct", priceInEuros, priceInCents); + std::println("... converted back = {} EUR", priceBackInEuros); + std::println("{} EUR to JPY: {}", + priceInEuros, + priceInYen.has_value() ? std::string_view { "converted" } : formula::describe(priceInYen.error())); + bool const moneyBehavesAsDocumented = tariffTimesEnergy == euros && tariff != euros && priceInCents == 25000_r && priceBackInEuros == priceInEuros && !priceInYen.has_value() && priceInYen.error() == formula::ArithmeticError::DomainError; @@ -231,6 +192,6 @@ int main() bool const allChecksPassed = compositionMatches && rootIsHalfPower && volumeRoundTrips && temperatureRoundTrips && fahrenheitConvertsExactly && kilowattHourIsExact && massRoundsAsDocumented && boundsBehaveAsDocumented && moneyBehavesAsDocumented; - std::printf("all checks passed: %s\n", allChecksPassed ? "yes" : "no"); + std::println("all checks passed: {}", allChecksPassed ? "yes" : "no"); return allChecksPassed ? 0 : 1; } diff --git a/examples/display.cpp b/examples/display.cpp index 666e7243..e4e4b2e6 100644 --- a/examples/display.cpp +++ b/examples/display.cpp @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // // Displaying numbers: a decimal wherever it is the exact value, a rounding -// only where one is asked for, and std::format for Rational and Measured. +// only where one is asked for, and std::format for the library's own values. // // 1. A trace of a soil specimen's moisture content, in the default // fractions, as exact decimals, rounded where no decimal ends, and padded @@ -14,6 +14,7 @@ // compile time. // 5. std::format: every form of the spec, the width in code points, and a // spec refused at run time. +// 6. std::format of an outcome, a unit, a dimension and an enumeration. // // Every number here is invented. @@ -24,9 +25,8 @@ #include #include -#include -#include #include +#include #include #include @@ -38,11 +38,7 @@ using formula::NumberStyle; using formula::Rational; using formula::RoundingMode; using formula::var; - -[[nodiscard]] constexpr Rational rat(std::int64_t numerator, std::int64_t denominator = 1) -{ - return Rational { numerator, denominator }; -} +using namespace formula::literals; // ---- Quantities ------------------------------------------------------------------- using WetMass = formula::Quantity; @@ -56,33 +52,30 @@ using GrainSize = formula::Quantity - var) / (var - formula::constant(rat(51, 2))); + (var - var) / (var - formula::constant(25.5_r)); inline constexpr auto specimen = - formula::environment(formula::Measured { rat(787, 5) }, formula::Measured { rat(144) }); + formula::environment(formula::Measured { 157.4_r }, formula::Measured { 144 }); // ---- 2. A value in a unit nobody declared, and a comparison ------------------------ // The mean of three weighings: their sum times a typed 1/3, which has no exact decimal. -inline constexpr auto dishMass = formula::sum(formula::series) * formula::number(rat(1, 3)); +inline constexpr auto dishMass = formula::sum(formula::series) * formula::number(Rational { 1, 3 }); -inline constexpr auto weighings = formula::environment( - formula::measured_series(formula::Measured { rat(421, 100) }, - formula::Measured { rat(423, 100) }, - formula::Measured { rat(426, 100) })); +inline constexpr auto weighings = formula::environment(formula::measured_series(4.21_r, 4.23_r, 4.26_r)); inline constexpr formula::Envelope<2> atMostTwelve { - formula::LimitRow { formula::unbounded, formula::limit(rat(12)) }, - formula::LimitRow { formula::unbounded, formula::limit(rat(12)) }, + formula::LimitRow { formula::unbounded, formula::limit(12_r) }, + formula::LimitRow { formula::unbounded, formula::limit(12_r) }, }; inline constexpr auto moistureLimit = formula::conformity( formula::series, atMostTwelve, formula::Verdict { "dry the specimen again" }); -inline constexpr auto twoSpecimens = formula::environment(formula::measured_series( - formula::Measured { rat(67, 6) }, formula::Measured { rat(289, 24) })); +inline constexpr auto twoSpecimens = + formula::environment(formula::measured_series(Rational { 67, 6 }, Rational { 289, 24 })); // ---- 3. The formula's text --------------------------------------------------------- // A tare typed as a whole 24 g, to set a formula's text beside a trace's. -inline constexpr auto wholeTare = var - formula::constant(rat(24)); +inline constexpr auto wholeTare = var - formula::constant(24_r); // The dish's mean without a weighing further than a typed 1/30 of the pass's // mean from it: a rejection, whose limit a documentation page states. @@ -92,7 +85,7 @@ inline constexpr auto dishMean = formula::sample_mean( formula::AtMost<1>, formula::KeepAtLeast<2>>( formula::series, - formula::deviation_from_mean(rat(1, 30) * formula::pass_mean), + formula::deviation_from_mean(Rational { 1, 30 } * formula::pass_mean), formula::Verdict { "weigh the dish again" })); // ---- 4. number_text at compile time -------------------------------------------------- @@ -120,12 +113,6 @@ struct FormatRow bool const quoted = !row.written.empty() && (row.written.front() == ' ' || row.written.back() == ' '); return std::format("{:<62} {}", row.call, quoted ? "\"" + row.written + "\"" : row.written); } - -/// Prints @p label and @p spelled, a number `number_text` or `decimal_text` wrote. -void print_spelled(char const* label, formula::NumberText const& spelled) -{ - std::printf("%s%.*s\n", label, static_cast(spelled.view().size()), spelled.view().data()); -} } // namespace int main() @@ -134,111 +121,103 @@ int main() auto const check = [&allPassed](bool condition, char const* what) { if (!condition) { - std::printf("CHECK FAILED: %s\n", what); + std::println("CHECK FAILED: {}", what); allPassed = false; } }; // ---- 1. A trace in every style -------------------------------------------------- - std::printf("== 1. A trace in every style ==\n\n"); - - formula::Trace<> trace {}; - auto const moisture = - formula::checked_evaluate(moistureContent, specimen, formula::RecordingSink<> { trace }); - check(moisture.has_value() && moisture->measurement().value() == rat(2680, 237), "the moisture content is 2680/237 %"); - - NumberStyle const exactStyle = NumberStyle::exact_decimal(); - NumberStyle const roundedStyle = NumberStyle::approximate_decimal(RoundingMode::HalfEven); - NumberStyle const paddedStyle = NumberStyle::approximate_decimal(RoundingMode::HalfEven, DecimalPadding::Padded); - std::string const fractions = formula::render_trace(trace, { .maxSteps = 20 }); - std::string const exactDecimals = formula::render_trace(trace, { .maxSteps = 20, .numbers = exactStyle }); - std::string const rounded = formula::render_trace(trace, { .maxSteps = 20, .numbers = roundedStyle }); - std::string const padded = formula::render_trace(trace, { .maxSteps = 20, .numbers = paddedStyle }); - std::printf("-- fractions, the default --\n%s\n", fractions.c_str()); - std::printf("-- exact decimals --\n%s\n", exactDecimals.c_str()); - std::printf("-- rounded where no decimal ends --\n%s\n", rounded.c_str()); - std::printf("-- rounded and padded --\n%s\n", padded.c_str()); - check(exactDecimals.find("#3 / #6 = 134/1185\n") != std::string::npos, "no exact decimal, so a fraction"); - check(rounded.find("#3 / #6 = \xe2\x89\x88" "0.113\n") != std::string::npos, "rounded, and marked"); - check(padded.find("m_d = 144.0 g\n") != std::string::npos, "padded to the gram's one decimal"); + std::println("== 1. A trace in every style ==\n"); + + auto const moisture = formula::explain(moistureContent, specimen); + check(formula::number_of(moisture.outcome) == Rational { 2680, 237 }, "the moisture content is 2680/237 %"); + + auto const exactStyle = NumberStyle::exact_decimal(); + auto const roundedStyle = NumberStyle::approximate_decimal(RoundingMode::HalfEven); + auto const paddedStyle = NumberStyle::approximate_decimal(RoundingMode::HalfEven, DecimalPadding::Padded); + std::string const fractions = formula::render_trace(moisture.trace, { .maxSteps = 20 }); + std::string const exactDecimals = formula::render_trace(moisture.trace, { .maxSteps = 20, .numbers = exactStyle }); + std::string const rounded = formula::render_trace(moisture.trace, { .maxSteps = 20, .numbers = roundedStyle }); + std::string const padded = formula::render_trace(moisture.trace, { .maxSteps = 20, .numbers = paddedStyle }); + std::println("-- fractions, the default --\n{}", fractions); + std::println("-- exact decimals --\n{}", exactDecimals); + std::println("-- rounded where no decimal ends --\n{}", rounded); + std::println("-- rounded and padded --\n{}", padded); + check(exactDecimals.contains("#3 / #6 = 134/1185\n"), "no exact decimal, so a fraction"); + check(rounded.contains("#3 / #6 = \xe2\x89\x88" "0.113\n"), "rounded, and marked"); + check(padded.contains("m_d = 144.0 g\n"), "padded to the gram's one decimal"); // ---- 2. A unit nobody declared, and a comparison --------------------------------- - std::printf("== 2. A unit nobody declared, and a comparison ==\n\n"); - - formula::Trace<> dishTrace {}; - auto const dish = formula::checked_evaluate(dishMass, weighings, formula::RecordingSink<> { dishTrace }); - check(dish.has_value(), "the dish's mass is a value"); - std::string const dishTraceText = formula::render_trace(dishTrace, { .maxSteps = 20, .numbers = paddedStyle }); - std::printf("%s\n", dishTraceText.c_str()); - formula::NumberText const dishText = formula::number_text(dish->measurement(), roundedStyle); - print_spelled("the dish's mass in its declared grams: ", dishText); - std::printf("\n"); - check(dishTraceText.find("t = 4.21 g; 4.23 g; 4.26 g\n") != std::string::npos, "padding cuts no decimal short"); - check(dishTraceText.find("3. 1/3\n") != std::string::npos, "a typed number is never rounded"); + std::println("== 2. A unit nobody declared, and a comparison ==\n"); + + auto const dish = formula::explain(dishMass, weighings); + check(dish.outcome.is_value(), "the dish's mass is a value"); + std::string const dishTraceText = formula::render_trace(dish.trace, { .maxSteps = 20, .numbers = paddedStyle }); + std::println("{}", dishTraceText); + formula::NumberText const dishText = formula::number_text(dish.outcome.measurement(), roundedStyle); + std::println("the dish's mass in its declared grams: {}\n", dishText.view()); + check(dishTraceText.contains("t = 4.21 g; 4.23 g; 4.26 g\n"), "padding cuts no decimal short"); + check(dishTraceText.contains("3. 1/3\n"), "a typed number is never rounded"); check(dishText == "\xe2\x89\x88" "4.2 g", "the declared result rounds at the gram's one decimal"); - formula::Trace<> limitTrace {}; - (void) formula::check_conformity(moistureLimit, twoSpecimens, formula::RecordingSink<> { limitTrace }); - std::string const limitText = formula::render_trace(limitTrace, { .maxSteps = 20, .numbers = roundedStyle }); - std::printf("%s\n", limitText.c_str()); - check(limitText.find("67/6 % (at most 12 %)") != std::string::npos, "a compared value is stated exactly"); + auto const limitCheck = formula::explain_conformity(moistureLimit, twoSpecimens); + std::string const limitText = formula::render_trace(limitCheck.trace, { .maxSteps = 20, .numbers = roundedStyle }); + std::println("{}", limitText); + check(limitText.contains("67/6 % (at most 12 %)"), "a compared value is stated exactly"); // ---- 3. The formula's text ---------------------------------------------------------- - std::printf("== 3. The formula's text ==\n\n"); + std::println("== 3. The formula's text ==\n"); formula::RenderOptions const decimals { .numbers = NumberStyle::exact_decimal() }; std::string const defaultText = formula::render(moistureContent); - std::string const decimalText = formula::render(moistureContent, formula::DefaultVocabulary {}, decimals); - std::string const latexText = - formula::render(moistureContent, formula::DefaultVocabulary {}, decimals); - formula::Documentation const page = formula::document(moistureContent, formula::DefaultVocabulary {}, decimals); - std::printf("default: %s\n", defaultText.c_str()); - std::printf("exact decimals: %s\n", decimalText.c_str()); - std::printf("LaTeX: %s\n", latexText.c_str()); - std::printf("document(): %s\n\n", page.formula.c_str()); + std::string const decimalText = formula::render(moistureContent, decimals); + std::string const latexText = formula::render(moistureContent, decimals); + formula::Documentation const page = formula::document(moistureContent, decimals); + std::println("default: {}", defaultText); + std::println("exact decimals: {}", decimalText); + std::println("LaTeX: {}", latexText); + std::println("document(): {}\n", page.formula); check(decimalText == "(m_w - m_d) / (m_d - 25.5 g)", "the typed tare as the decimal it is"); formula::RenderOptions const rounding { .numbers = roundedStyle }; - std::string const dishFormula = formula::render(dishMass, formula::DefaultVocabulary {}, rounding); - formula::Documentation const dishPage = formula::document(dishMean, formula::DefaultVocabulary {}, rounding); - std::printf("formula, rounded style: %s\n", dishFormula.c_str()); - std::printf("rejection's limit, rounded style: %s\n\n", dishPage.rejections.front().limit.c_str()); - check(dishFormula.find("1/3") != std::string::npos, "a typed number is never rounded in a formula's text"); - check(dishPage.rejections.front().limit.find("1/30") != std::string::npos, "nor in a documentation page's limit"); - - NumberStyle const paddedDecimals = NumberStyle::exact_decimal(DecimalPadding::Padded); - formula::Trace<> tareTrace {}; - (void) formula::checked_evaluate(wholeTare, specimen, formula::RecordingSink<> { tareTrace }); - std::string const tareFormula = formula::render(wholeTare, formula::DefaultVocabulary {}, { .numbers = paddedDecimals }); - std::string const tareTraceText = formula::render_trace(tareTrace, { .maxSteps = 20, .numbers = paddedDecimals }); - std::printf("formula, padded style: %s\n", tareFormula.c_str()); - std::printf("trace, padded style:\n%s\n", tareTraceText.c_str()); - check(tareFormula == "m_d - 24 g" && tareTraceText.find("2. 24.0 g\n") != std::string::npos, + std::string const dishFormula = formula::render(dishMass, rounding); + formula::Documentation const dishPage = formula::document(dishMean, rounding); + std::println("formula, rounded style: {}", dishFormula); + std::println("rejection's limit, rounded style: {}\n", dishPage.rejections.front().limit); + check(dishFormula.contains("1/3"), "a typed number is never rounded in a formula's text"); + check(dishPage.rejections.front().limit.contains("1/30"), "nor in a documentation page's limit"); + + auto const paddedDecimals = NumberStyle::exact_decimal(DecimalPadding::Padded); + auto const tare = formula::explain(wholeTare, specimen); + std::string const tareFormula = formula::render(wholeTare, { .numbers = paddedDecimals }); + std::string const tareTraceText = formula::render_trace(tare.trace, { .maxSteps = 20, .numbers = paddedDecimals }); + std::println("formula, padded style: {}", tareFormula); + std::println("trace, padded style:\n{}", tareTraceText); + check(tareFormula == "m_d - 24 g" && tareTraceText.contains("2. 24.0 g\n"), "a formula states the typed 24 g, a trace pads it"); - check(tareTraceText.find("3. #1 - #2 = 0.12\n") != std::string::npos, "a unit nobody declared is not padded"); + check(tareTraceText.contains("3. #1 - #2 = 0.12\n"), "a unit nobody declared is not padded"); // ---- 4. number_text and decimal_text ------------------------------------------------ - std::printf("== 4. number_text and decimal_text ==\n\n"); + std::println("== 4. number_text and decimal_text ==\n"); - formula::Measured const w = moisture->measurement(); - formula::Measured const notMeasured = formula::Measured::absent(); + formula::Measured const w = moisture.outcome.measurement(); + formula::Measured const notMeasured {}; formula::NumberText const measuredText = formula::number_text(w, roundedStyle); formula::NumberText const absentText = formula::number_text(notMeasured, roundedStyle); formula::NumberText const twoPlaces = formula::decimal_text(w.value(), formula::DecimalPlaces { 2 }, RoundingMode::HalfEven, DecimalPadding::Padded); - print_spelled("measured: ", measuredText); - print_spelled("not measured: ", absentText); - print_spelled("two places: ", twoPlaces); - std::printf("\n"); + std::println("measured: {}", measuredText.view()); + std::println("not measured: {}", absentText.view()); + std::println("two places: {}\n", twoPlaces.view()); check(measuredText == "\xe2\x89\x88" "11.3 %" && absentText == "(not measured)" && twoPlaces == "11.31", "number_text and decimal_text spell the moisture content"); // ---- 5. std::format ------------------------------------------------------------------- - std::printf("== 5. std::format ==\n\n"); + std::println("== 5. std::format ==\n"); - formula::Measured const wetMass { rat(787, 5) }; - formula::Measured const oven { rat(583, 10) }; - formula::Measured const grain { rat(217) }; + formula::Measured const wetMass { 157.4_r }; + formula::Measured const oven { 58.3_r }; + formula::Measured const grain { 217 }; FormatRow const reference[] = { FORMAT_ROW("0.6", std::format("{}", Rational { 3, 5 })), FORMAT_ROW("1/3", std::format("{}", Rational { 1, 3 })), @@ -261,7 +240,7 @@ int main() }; for (FormatRow const& row: reference) { - std::printf("%s\n", reference_line(row).c_str()); + std::println("{}", reference_line(row)); check(row.written == row.expected, "a std::format reference row"); } @@ -273,10 +252,34 @@ int main() } catch (std::format_error const& refusal) { - std::printf("\nstd::vformat(\"{:.2}\", ...) throws std::format_error:\n%s\n\n", refusal.what()); + std::println("\nstd::vformat(\"{{:.2}}\", ...) throws std::format_error:\n{}\n", refusal.what()); check(std::string_view { refusal.what() }.starts_with("formula: "), "the refusal starts formula: "); } - std::printf("all checks passed: %s\n", allPassed ? "yes" : "no"); + // ---- 6. Outcomes, units, dimensions and enumerations ------------------------------- + std::println("== 6. Outcomes, units, dimensions and enumerations ==\n"); + + // What a rejection of the dish's weighings gives when it cannot settle, and + // a dish nobody weighed. + auto const reweigh = formula::Outcome::verdict({ "weigh the dish again" }); + auto const unweighed = formula::Outcome::empty(); + FormatRow const words[] = { + FORMAT_ROW("2680/237 %", std::format("{}", moisture.outcome)), + FORMAT_ROW("\xe2\x89\x88" "11.3 %", std::format("{:~HalfEven}", moisture.outcome)), + FORMAT_ROW("(not measured)", std::format("{}", unweighed)), + FORMAT_ROW(" weigh the dish again", std::format("{:22}", reweigh)), + FORMAT_ROW("g ", std::format("{:4}", unit::Gram)), + FORMAT_ROW("M^1", std::format("{}", unit::Gram.dimension)), + FORMAT_ROW("(dimensionless)", std::format("{}", unit::Percent.dimension)), + FORMAT_ROW("derived", std::format("{}", moisture.outcome.source())), + FORMAT_ROW("nearest, ties to even", std::format("{}", RoundingMode::HalfEven)), + }; + for (FormatRow const& row: words) + { + std::println("{}", reference_line(row)); + check(row.written == row.expected, "a std::format row for an outcome, a unit, a dimension or an enumeration"); + } + + std::println("\nall checks passed: {}", allPassed ? "yes" : "no"); return allPassed ? 0 : 1; } From 9e8bf8f08a34fc39a45a6e5e7df575345ca0a0f1 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 20:47:45 +0200 Subject: [PATCH 24/59] fix: refuse a floating-point breakpoint key or band bound instead of truncating it `breakpoint(1.5)` compiled and meant `breakpoint(1)`: a conversion from double to std::int64_t beats the user-defined conversion to Rational, so the integer overload took the call and dropped the fraction without a word. `breakpoint(1.5, 2)` and the four-argument `band(12.7, 1, 17.3, 1)` did the same. A key or bound that reads as an exact decimal and silently becomes a different number is the one thing this library must not do. Each form now has an exact-match overload for floating-point arguments that refuses in the library's own words and names the spellings that work (`12.7_r`, `Rational { 127, 10 }`, `breakpoint(127, 10)`). Integer calls do not satisfy the constraint and still take the old overloads. The line numbers the documentation quotes from band.hpp follow the move. Signed-off-by: Christian Parpart --- CHANGELOG.md | 4 +++ docs/lookup-tables.md | 6 ++-- include/formula-cpp/band.hpp | 29 ++++++++++++++++ include/formula-cpp/lookup.hpp | 34 +++++++++++++++++++ test/CMakeLists.txt | 12 +++++++ test/band_tests.cpp | 6 ++++ test/lookup_tests.cpp | 7 ++++ test/negative/band_from_floating_point.cpp | 13 +++++++ .../breakpoint_from_floating_point.cpp | 13 +++++++ .../breakpoint_pair_from_floating_point.cpp | 13 +++++++ 10 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 test/negative/band_from_floating_point.cpp create mode 100644 test/negative/breakpoint_from_floating_point.cpp create mode 100644 test/negative/breakpoint_pair_from_floating_point.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 336bd6ac..8b456696 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,10 @@ change is recorded here. ### Changed +- A floating-point key given to `breakpoint`, or a floating-point bound given to the + four-argument `band`, used to be truncated silently (`breakpoint(1.5)` was `breakpoint(1)`). It is + now refused at compile time, with a message that names the exact spellings: `12.7_r`, + `Rational { 127, 10 }` or `breakpoint(127, 10)`. - GCC 14 is the oldest supported GCC; older GCC is not supported. The install-and-consume check now builds with it on Linux. - `` now specialises `std::formatter` for `formula::Outcome`, diff --git a/docs/lookup-tables.md b/docs/lookup-tables.md index e7416744..d354fc67 100644 --- a/docs/lookup-tables.md +++ b/docs/lookup-tables.md @@ -178,10 +178,10 @@ compiler), with the rest of the instantiation backtrace below these lines: ``` In file included from test\negative\lookup_band_gap.cpp:10: In file included from include\formula-cpp/lookup.hpp:474: -include\formula-cpp/band.hpp(226,19): error: static assertion failed due to requirement 'bands_are_adjacent(formula::Band{103, 1, 197, 1}, formula::Band{241, 1, 331, 1})': formula: this band table has a gap or overlap between two adjacent bands; the earlier band's declared high bound and the later band's declared low bound do not match exactly, and the two offending Band values appear in this diagnostic as the template arguments First and Second of RequireBandsAdjacent - 226 | static_assert(bands_are_adjacent(First, Second), +include\formula-cpp/band.hpp(255,19): error: static assertion failed due to requirement 'bands_are_adjacent(formula::Band{103, 1, 197, 1}, formula::Band{241, 1, 331, 1})': formula: this band table has a gap or overlap between two adjacent bands; the earlier band's declared high bound and the later band's declared low bound do not match exactly, and the two offending Band values appear in this diagnostic as the template arguments First and Second of RequireBandsAdjacent + 255 | static_assert(bands_are_adjacent(First, Second), | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -include\formula-cpp/band.hpp(267,29): note: in instantiation of template class 'formula::RequireBandsAdjacent' requested here +include\formula-cpp/band.hpp(296,29): note: in instantiation of template class 'formula::RequireBandsAdjacent' requested here ``` The message names **both offending rows**, as the values you typed: the one diff --git a/include/formula-cpp/band.hpp b/include/formula-cpp/band.hpp index e82cba8b..af2dca70 100644 --- a/include/formula-cpp/band.hpp +++ b/include/formula-cpp/band.hpp @@ -77,6 +77,7 @@ #include #include #include +#include #include namespace formula @@ -117,6 +118,34 @@ struct Band return { lowBound.numerator(), lowBound.denominator(), highBound.numerator(), highBound.denominator() }; } +namespace detail +{ + /// Fails to compile when a band's bound is given as a floating-point value, + /// which the integer overload would silently truncate. + template + struct RequireExactBandBound + { + static_assert(!std::is_floating_point_v, + "formula: a band's bounds are exact numbers, and this is a floating-point value that " + "would be truncated; write band(12.7_r, 17.3_r) or band(127, 10, 173, 10)"); + + static constexpr bool value = true; + }; +} // namespace detail + +/// Refused: a floating-point numerator or denominator -- see +/// `detail::RequireExactBandBound`. +template + requires(std::is_floating_point_v || std::is_floating_point_v || std::is_floating_point_v || std::is_floating_point_v) +[[nodiscard]] constexpr Band band(A, B, C, D) noexcept +{ + static_assert(detail::RequireExactBandBound< + std::conditional_t, + A, + std::conditional_t, B, std::conditional_t, C, D>>>>::value); + return {}; +} + /// 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 diff --git a/include/formula-cpp/lookup.hpp b/include/formula-cpp/lookup.hpp index 98699f32..19d244dc 100644 --- a/include/formula-cpp/lookup.hpp +++ b/include/formula-cpp/lookup.hpp @@ -1333,6 +1333,40 @@ struct Breakpoint return { keyValue.numerator(), keyValue.denominator() }; } +namespace detail +{ + /// Fails to compile when a breakpoint's key is given as a floating-point + /// value, which the integer overloads would silently truncate. + template + struct RequireExactBreakpointKey + { + static_assert(!std::is_floating_point_v, + "formula: a breakpoint's key is an exact number, and this is a floating-point value that " + "would be truncated; write 12.7_r, Rational { 127, 10 } or breakpoint(127, 10)"); + + static constexpr bool value = true; + }; +} // namespace detail + +/// Refused: a floating-point key -- see `detail::RequireExactBreakpointKey`. +template + requires std::is_floating_point_v +[[nodiscard]] constexpr Breakpoint breakpoint(T) noexcept +{ + static_assert(detail::RequireExactBreakpointKey::value); + return {}; +} + +/// Refused: a floating-point numerator or denominator -- see +/// `detail::RequireExactBreakpointKey`. +template + requires(std::is_floating_point_v || std::is_floating_point_v) +[[nodiscard]] constexpr Breakpoint breakpoint(N, D) noexcept +{ + static_assert(detail::RequireExactBreakpointKey, N, D>>::value); + return {}; +} + /// 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 diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index aeb9fc9c..a79b0bb0 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -1123,6 +1123,18 @@ formula_add_negative_test(band_zero_width "formula: this band is not well-formed" REJECT "must be initialized by a constant expression") +# A floating-point key or bound would be truncated by the integer overloads; +# each form is refused once, by the library's own message. +formula_add_negative_test(breakpoint_from_floating_point + "formula: a breakpoint's key is an exact number" EXPECT_COUNT 1 + REJECT "no matching" "ambiguous") +formula_add_negative_test(breakpoint_pair_from_floating_point + "formula: a breakpoint's key is an exact number" EXPECT_COUNT 1 + REJECT "no matching" "ambiguous") +formula_add_negative_test(band_from_floating_point + "formula: a band's bounds are exact numbers" EXPECT_COUNT 1 + REJECT "no matching" "ambiguous") + formula_add_negative_test(lookup_band_gap "formula: this band table has a gap or overlap between two adjacent bands" REJECT "must be initialized by a constant expression") diff --git a/test/band_tests.cpp b/test/band_tests.cpp index 2d093042..08fc5fce 100644 --- a/test/band_tests.cpp +++ b/test/band_tests.cpp @@ -273,3 +273,9 @@ TEST_CASE("band: bounds given as exact numbers", "[band]") STATIC_REQUIRE(formula::band(83.7_r, 97.3_r) == formula::band(837, 10, 973, 10)); STATIC_REQUIRE(formula::band(0, 127) == formula::band(0, 1, 127, 1)); } + +TEST_CASE("band: integer bounds still take the integer overload", "[band]") +{ + STATIC_REQUIRE(formula::band(0, 1, 127, 1) == formula::Band { 0, 1, 127, 1 }); + STATIC_REQUIRE(formula::band(837, 10, 973, 10).highDenominator == 10); +} diff --git a/test/lookup_tests.cpp b/test/lookup_tests.cpp index b5b4ad6f..06f5d85a 100644 --- a/test/lookup_tests.cpp +++ b/test/lookup_tests.cpp @@ -1174,3 +1174,10 @@ TEST_CASE("breakpoint: a key given as an exact number", "[lookup]") STATIC_REQUIRE(formula::breakpoint(12.7_r) == formula::breakpoint(127, 10)); STATIC_REQUIRE(formula::breakpoint(127) == formula::Breakpoint { 127, 1 }); // the integer overload still wins } + +TEST_CASE("breakpoint: integer keys still take the integer overload", "[lookup]") +{ + STATIC_REQUIRE(formula::breakpoint(127) == formula::Breakpoint { 127, 1 }); + STATIC_REQUIRE(formula::breakpoint(127, 10) == formula::Breakpoint { 127, 10 }); + STATIC_REQUIRE(formula::breakpoint(std::int64_t { 3 }, std::int64_t { 4 }) == formula::Breakpoint { 3, 4 }); +} diff --git a/test/negative/band_from_floating_point.cpp b/test/negative/band_from_floating_point.cpp new file mode 100644 index 00000000..e95ee4f7 --- /dev/null +++ b/test/negative/band_from_floating_point.cpp @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: a band's bounds are exact numbers +// +// A band whose low numerator is a double. The integer overload would truncate +// 12.7 to 12 and say nothing; it is refused, once, in the library's words. +#include + +inline constexpr auto declared = formula::band(12.7, 1, 17, 1); + +int main() +{ + return declared.lowNumerator == 12 ? 1 : 0; +} diff --git a/test/negative/breakpoint_from_floating_point.cpp b/test/negative/breakpoint_from_floating_point.cpp new file mode 100644 index 00000000..9d69f2af --- /dev/null +++ b/test/negative/breakpoint_from_floating_point.cpp @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: a breakpoint's key is an exact number +// +// A key written as a double. The integer overload would truncate 1.5 to 1 and +// say nothing; it is refused, once, in the library's words. +#include + +inline constexpr auto key = formula::breakpoint(1.5); + +int main() +{ + return key.numerator == 1 ? 1 : 0; +} diff --git a/test/negative/breakpoint_pair_from_floating_point.cpp b/test/negative/breakpoint_pair_from_floating_point.cpp new file mode 100644 index 00000000..d8182dbd --- /dev/null +++ b/test/negative/breakpoint_pair_from_floating_point.cpp @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: a breakpoint's key is an exact number +// +// A key written as a double numerator over an integer denominator. The integer +// overload would truncate it; it is refused, once, in the library's words. +#include + +inline constexpr auto key = formula::breakpoint(1.5, 2); + +int main() +{ + return key.numerator == 1 ? 1 : 0; +} From 8cde9ac629c285ed5cde4ac6c7cb2912c6dc8d79 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 20:56:33 +0200 Subject: [PATCH 25/59] docs(examples): check every checked result before use in the small examples A std::expected that holds an error must not be dereferenced, and an example is where a reader learns how to read one. The expressions example now checks each checked_evaluate before it reads the outcome: its three compile-time results with a static_assert, so an error stops the build, and the water/cement ratio with an if that prints the error's words and fails. The quantities example goes back to checked_convert_to, checked_round_to_declared and checked_within_bounds, and checks each the same way, rather than trading the check for the throwing spellings to be shorter. Nothing they print changes. The README and the guides quote the guarded lines, and the numbers guide says that std::format writes a Rational's exact decimal where it has one, and gives each block that uses _r its using-directive. The tracing guide names the fixtures its vocabulary snippet uses, and the census test says how the expressions example now writes the circular area it copies. Signed-off-by: Christian Parpart --- README.md | 15 ++++++++-- docs/expressions.md | 21 ++++++++++---- docs/numbers.md | 17 ++++++++--- docs/quantities.md | 27 ++++++++--------- docs/tracing.md | 22 +++++++------- examples/expressions.cpp | 21 +++++++++++--- examples/quantities.cpp | 53 ++++++++++++++++++++++++---------- test/overflow_census_tests.cpp | 5 +++- 8 files changed, 125 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index b6eaf5f8..b1ffb058 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ evaluates it names none: ```cpp constexpr auto diameterKnown = formula::environment(formula::Measured { 103 }); constexpr auto area = formula::checked_evaluate(circularArea, diameterKnown); +static_assert(area.has_value()); ``` ``` @@ -110,7 +111,9 @@ circular area of a 103 mm diameter = ≈0.008332 m2 (derived) 2500 g reported as m = 2.5 kg ``` -Note `constexpr`: that area was computed at compile time. The area is held +Note `constexpr`: that area was computed at compile time, so the check that +the arithmetic did not fail is a `static_assert` — `checked_evaluate` returns +the outcome or the error, and neither is read unchecked. The area is held exactly, with `formula::pi` an exact fraction close to pi, and has no short decimal, so it is printed rounded to six places and marked `≈`. @@ -119,6 +122,7 @@ decimal, so it is printed rounded to six places and marked `≈`. ```cpp constexpr auto diameterUnknown = formula::environment(formula::Measured::absent()); constexpr auto emptyArea = formula::checked_evaluate(circularArea, diameterUnknown); +static_assert(emptyArea.has_value()); ``` ``` @@ -136,13 +140,20 @@ auto const batch = formula::environment(formula::Measured { 180 }, formula::Measured { 300 }, formula::entered(formula::Measured { 0.5_r })); auto const ratio = formula::checked_evaluate(waterCementRatio, batch); +if (!ratio) +{ + std::println("water/cement ratio: {}", ratio.error()); + return 1; +} +std::println("{} = {} ({})", formula::symbol_of(), *ratio, ratio->source()); ``` ``` w/c = 0.5 (manually entered) ``` -Here `waterCementRatio` is +The evaluation is checked before its outcome is read: `ratio.error()` would +say, in words, what arithmetic failed. Here `waterCementRatio` is `formula::yields(var / var)`, and `0.5_r` is the exact decimal one half, never a `double`. The formula would have computed 0.6. A person entered 0.5, so that is the answer — and diff --git a/docs/expressions.md b/docs/expressions.md index ecfcb8ce..4b2da8e7 100644 --- a/docs/expressions.md +++ b/docs/expressions.md @@ -254,6 +254,11 @@ auto const batch = formula::environment(formula::Measured { 180 }, formula::Measured { 300 }, formula::entered(formula::Measured { 0.5_r })); auto const ratio = formula::checked_evaluate(waterCementRatio, batch); +if (!ratio) +{ + std::println("water/cement ratio: {}", ratio.error()); + return 1; +} std::println("{} = {} ({})", formula::symbol_of(), *ratio, ratio->source()); ``` @@ -262,8 +267,12 @@ w/c = 0.5 (manually entered) ``` `waterCementRatio` names its result quantity where it is declared, so the call -names none ([Naming the result once](#naming-the-result-once)). `{}` of an -`Outcome` writes its number in its quantity's unit (a ratio has no symbol), +names none ([Naming the result once](#naming-the-result-once)). The +`std::expected` is checked before `*ratio` or `ratio->` reads it: +dereferencing one that holds an error is undefined behaviour, and +`ratio.error()` says in words what failed. A result computed at compile time is checked the same way by a +`static_assert(area.has_value())`, as the example does for its area. `{}` of +an `Outcome` writes its number in its quantity's unit (a ratio has no symbol), and `{}` of a `ValueSource` its words; see [Displaying numbers](display.md). ## Reading a result @@ -276,7 +285,7 @@ number `x` holds, or nothing. It reads a `Measured`, an `Outcome`, the `ratio` above with it: ```cpp -bool const overrideWinsOutright = ratio && ratio->is_overridden() && formula::number_of(ratio) == 0.5_r; +bool const overrideWinsOutright = ratio->is_overridden() && formula::number_of(ratio) == 0.5_r; ``` It is an `optional` and not a zero because zero is a measurement: a specimen @@ -284,9 +293,9 @@ that weighed nothing and a specimen never weighed are different results. `optional == Rational` is false when the optional is empty, so `number_of(ratio) == 0.5_r` is a complete check on its own -- an absent number, an error and a verdict all compare unequal to every number. The -`ratio &&` in front guards only the `->` that follows it. `number_of` says -nothing about *why* there is no number; ask `Outcome::kind()` or the error -for that. +`ratio->` in front is safe because `ratio` was checked above. `number_of` +says nothing about *why* there is no number; ask `Outcome::kind()` or the +error for that. ## Choosing a representation diff --git a/docs/numbers.md b/docs/numbers.md index f941c337..f145f56c 100644 --- a/docs/numbers.md +++ b/docs/numbers.md @@ -20,11 +20,14 @@ That is not a rare edge case; it is what binary floating point does with decimal input in general. A quantity such as 450 millilitres, stored as 0,45 litres in a `double` and converted back, is not reliably 450 again -- the round trip is lossy because 0,45 is not exactly representable in base 2. -`Rational` makes that round trip exact. From `examples/exact_numbers.cpp`, -where `450_r` is the exact number 450 ([Writing an exact +`Rational` makes that round trip exact. From `examples/exact_numbers.cpp` +(`_r` is the exact-decimal literal of [Writing an exact decimal](#writing-an-exact-decimal)): ```cpp +using namespace formula::literals; + +// 450 millilitres, written exactly. 450_r is the number 450, never a double. Rational const volumeInMillilitres = 450_r; // Convert to litres by an exact integer factor: multiply, then divide. @@ -57,6 +60,8 @@ is the only place precision is deliberately given up. Spelled out, as runnable code: ```cpp +using namespace formula::literals; + Rational const a { 7 }; // 7/1 Rational const b { 3, 4 }; // 3/4 Rational const c = 0.45_r; // 9/20 @@ -197,6 +202,8 @@ Rational::Int const nearest = formula::round_to_int(Rational { 7, 4 }, RoundingM Beyond rounding to a whole number, three forms round to a place: ```cpp +using namespace formula::literals; + // Decimal places: 45,67 rounded to one decimal place is 45,7, i.e. 457/10. Rational const value = 45.67_r; Rational const toOneDecimal = formula::round(value, DecimalPlaces { 1 }, RoundingMode::HalfAwayFromZero); @@ -211,8 +218,10 @@ Rational const snapped = formula::round_to_multiple(7, 5, RoundingMode::HalfAway A `Rational` is written as text as a fraction by default. To write it as a decimal -- exactly where it has one, rounded in a mode you name and marked `≈` -where it does not -- in a trace, a rendered formula, `number_text()` or -`std::format`, see [Displaying numbers](display.md). +where it does not -- in a trace, a rendered formula or `number_text()`, see +[Displaying numbers](display.md). `std::format` is the other way round: `{}` +writes the exact decimal where there is one and the fraction where there is +not, and `{:/}` always the fraction. ## Rounding is part of the calculation diff --git a/docs/quantities.md b/docs/quantities.md index db6b2008..3db50512 100644 --- a/docs/quantities.md +++ b/docs/quantities.md @@ -281,9 +281,17 @@ does not compile, and it draws one message, so a conversion nobody could perform cannot look like it succeeded merely because there was no value to get wrong. (Before this check moved to compile time, such a call compiled and returned `ArithmeticError::DomainError`.) With no value present the -result is absent. The worked example converts one with `convert_to`, the -throwing twin described [below](#bounds-precision-and-conversion), since -nothing in it can fail: +result is absent. The worked example checks the `std::expected` before it +reads the measurement inside: + +```cpp +auto const convertedAbsent = formula::checked_convert_to(absentVolume); +if (!convertedAbsent) +{ + std::println("converting an absent measurement: {}", convertedAbsent.error()); + return 1; +} +``` ``` an absent measurement, converted: (not measured) @@ -368,16 +376,9 @@ same arguments and return the value itself, and throw `ArithmeticException` where the `checked_` form returns an error. Absence behaves as above -- an absent measurement converts and rounds to an absent one and is `NotMeasured` for its bounds -- and a conversion across dimensions does not compile in -either spelling. The worked example uses the twins, since nothing in it can -fail. Its absent measurement is converted, rounded and checked by these -lines: - -```cpp -Measured const absentVolume {}; -auto const convertedAbsent = formula::convert_to(absentVolume); -auto const roundedAbsent = formula::round_to_declared(absentVolume, RoundingMode::HalfAwayFromZero); -auto const boundsOfAbsent = formula::within_bounds(absentVolume); -``` +either spelling. The worked example keeps the `checked_` forms, and checks +each result before it reads it, as shown [above](#measurements-that-may-be-absent) +for the conversion. ## Limits diff --git a/docs/tracing.md b/docs/tracing.md index a0cb2731..6f094bd4 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -687,11 +687,13 @@ them by hand, or fills in a `Step` by hand, writes whatever trace it likes. A `Variable`, `OverriddenConstant` or `DerivedQuantity` step records its quantity's symbol **when the formula is evaluated**, and `render_trace` only -reads it back. So a jurisdiction's vocabulary (see [Citations and rendering](citations.md)) has to -be given to the sink, not only to `render()` -- a page rendered in one -vocabulary and a trace recorded in another would name one quantity with two -different letters. An `explain_*` twin hands the vocabulary it is given to -the sink it builds: +reads it back. So a jurisdiction's vocabulary (see +[Citations and rendering](citations.md)) has to be given to the sink, not +only to `render()` -- a page rendered in one vocabulary and a trace recorded +in another would name one quantity with two different letters. An +`explain_*` twin hands the vocabulary it is given to the sink it builds. Over +`limit`, `crossedInputs` and `south`, the fixtures of +`test/vocabulary_tests.cpp`: ```cpp auto const southern = formula::explain_check(limit, crossedInputs, south); @@ -706,11 +708,11 @@ auto const southern = formula::explain_check(limit, crossedInputs, south); (`test/vocabulary_tests.cpp`, `"a constraint's trace names quantities in the sink's vocabulary"`, which gives `south` to a `RecordingSink` of its own.) `explain` takes the vocabulary as an optional third argument, and every -`explain_*` twin and `traced` as an optional last one. Those three step kinds are the only ones that name a quantity. -Every other step names none -- arithmetic, a lookup, a rounding rule, a -constraint, a method's constraints, a variant selection and a replaced -variant refer to their operands by number -- and so reaches the vocabulary through the steps beneath -it. +`explain_*` twin and `traced` as an optional last one. Those three step kinds +are the only ones that name a quantity. Every other step names none -- +arithmetic, a lookup, a rounding rule, a constraint, a method's constraints, +a variant selection and a replaced variant refer to their operands by number -- +and so reaches the vocabulary through the steps beneath it. The sink keeps its own copy of the vocabulary -- plain data holding views of string literals -- so, unlike the `Trace`, the vocabulary need not outlive diff --git a/examples/expressions.cpp b/examples/expressions.cpp index 733d84d1..6ceb616e 100644 --- a/examples/expressions.cpp +++ b/examples/expressions.cpp @@ -60,22 +60,30 @@ constexpr auto waterCementRatio = formula::yields(var { 103 }); constexpr auto area = formula::checked_evaluate(circularArea, diameterKnown); + static_assert(area.has_value()); std::println("circular area of a 103 mm diameter = {:~.6HalfAwayFromZero} ({})", *area, area->source()); // ---- 2. A result quantity in a different unit from its input ---- constexpr auto massInGrams = formula::environment(formula::Measured { 2500 }); constexpr auto massConverted = formula::checked_evaluate(var, massInGrams); + static_assert(massConverted.has_value()); std::println("2500 g reported as {} = {}", formula::symbol_of(), *massConverted); // ---- 3. An absent input propagates to an empty result, not a zero ---- constexpr auto diameterUnknown = formula::environment(formula::Measured::absent()); constexpr auto emptyArea = formula::checked_evaluate(circularArea, diameterUnknown); + static_assert(emptyArea.has_value()); std::println("area with no diameter measured: {}", emptyArea->kind()); // ---- 4. A dimensional error is a compile error, not a runtime one ---- @@ -86,18 +94,23 @@ int main() formula::Measured { 300 }, formula::entered(formula::Measured { 0.5_r })); auto const ratio = formula::checked_evaluate(waterCementRatio, batch); + if (!ratio) + { + std::println("water/cement ratio: {}", ratio.error()); + return 1; + } std::println("{} = {} ({})", formula::symbol_of(), *ratio, ratio->source()); // Every number printed above is checked here; nothing is printed that this - // bool does not also cover. number_of is empty for an error and for a - // result that is not a number, so comparing it is a complete check. + // bool does not also cover. number_of is empty for a result that is not a + // number, so comparing it is a complete check. auto const areaInSquareMetres = formula::number_of(area); bool const circularAreaIsCorrect = areaInSquareMetres && *areaInSquareMetres > 0.00833228_r && *areaInSquareMetres < 0.00833229_r && area->source() == formula::ValueSource::Derived; bool const massConvertsExactly = formula::number_of(massConverted) == 2.5_r; - bool const absenceStaysEmpty = emptyArea && emptyArea->is_empty(); - bool const overrideWinsOutright = ratio && ratio->is_overridden() && formula::number_of(ratio) == 0.5_r; + bool const absenceStaysEmpty = emptyArea->is_empty(); + bool const overrideWinsOutright = ratio->is_overridden() && formula::number_of(ratio) == 0.5_r; bool const allChecksPassed = circularAreaIsCorrect && massConvertsExactly && absenceStaysEmpty && overrideWinsOutright; std::println("all checks passed: {}", allChecksPassed ? "yes" : "no"); diff --git a/examples/quantities.cpp b/examples/quantities.cpp index 78fd21da..d946d3a4 100644 --- a/examples/quantities.cpp +++ b/examples/quantities.cpp @@ -110,27 +110,48 @@ int main() // ---- 4. A present measurement, converted exactly between quantities (450 l to m3) ---- // - // Nothing in this program can make a conversion fail, so it uses the - // throwing spellings: convert_to, round_to_declared and within_bounds - // return the value itself, where their checked_ twins return a - // std::expected for a caller that handles the error. + // A conversion, a rounding and a bounds check each return a std::expected + // -- the value, or the arithmetic error that stopped it -- and each is + // checked before it is read. Nothing in this program can make one fail, + // but dereferencing a std::expected that holds an error is undefined + // behaviour. Measured const presentVolume { 450 }; - auto const convertedPresent = formula::convert_to(presentVolume); - std::println("{} converted to {} = {}", presentVolume, Describe::unit, convertedPresent); + auto const convertedPresent = formula::checked_convert_to(presentVolume); + if (!convertedPresent) + { + std::println("converting {}: {}", presentVolume, convertedPresent.error()); + return 1; + } + std::println("{} converted to {} = {}", presentVolume, Describe::unit, *convertedPresent); bool const presentValueConvertsExactly = formula::number_of(convertedPresent) == 0.45_r; // ---- 5. An absent measurement surviving conversion, rounding and a bounds check ---- Measured const absentVolume {}; - auto const convertedAbsent = formula::convert_to(absentVolume); - auto const roundedAbsent = formula::round_to_declared(absentVolume, RoundingMode::HalfAwayFromZero); - auto const boundsOfAbsent = formula::within_bounds(absentVolume); - - std::println("an absent measurement, converted: {}", convertedAbsent); - std::println("an absent measurement, rounded: {}", roundedAbsent); - std::println("an absent measurement, bounds-checked: {}", boundsOfAbsent); - - bool const absenceSurvivesEveryOperation = convertedAbsent.is_absent() && roundedAbsent.is_absent() - && boundsOfAbsent == BoundsCheck::NotMeasured; + auto const convertedAbsent = formula::checked_convert_to(absentVolume); + if (!convertedAbsent) + { + std::println("converting an absent measurement: {}", convertedAbsent.error()); + return 1; + } + auto const roundedAbsent = formula::checked_round_to_declared(absentVolume, RoundingMode::HalfAwayFromZero); + if (!roundedAbsent) + { + std::println("rounding an absent measurement: {}", roundedAbsent.error()); + return 1; + } + auto const boundsOfAbsent = formula::checked_within_bounds(absentVolume); + if (!boundsOfAbsent) + { + std::println("bounds-checking an absent measurement: {}", boundsOfAbsent.error()); + return 1; + } + + std::println("an absent measurement, converted: {}", *convertedAbsent); + std::println("an absent measurement, rounded: {}", *roundedAbsent); + std::println("an absent measurement, bounds-checked: {}", *boundsOfAbsent); + + bool const absenceSurvivesEveryOperation = convertedAbsent->is_absent() && roundedAbsent->is_absent() + && *boundsOfAbsent == BoundsCheck::NotMeasured; // ---- 6. combine: absent if EITHER input is, not only if both are, and the // RESULT is named by the caller, not inherited from either operand ---- diff --git a/test/overflow_census_tests.cpp b/test/overflow_census_tests.cpp index e04a7faf..7e541373 100644 --- a/test/overflow_census_tests.cpp +++ b/test/overflow_census_tests.cpp @@ -162,7 +162,10 @@ struct Strength: formula::Quantity(Rational { 4 }) * formula::var / (formula::pi * formula::pow<2>(formula::var)); -/// examples/expressions.cpp's circular area, as written there. +/// examples/expressions.cpp's circular area, which that example writes as +/// `yields(formula::pi * formula::pow<2>(var) / 4)`: the same +/// tree, since its bare `4` is the dimensionless coefficient `Rational { 4 }` +/// spelled here. inline constexpr auto circularArea = formula::pi * formula::pow<2>(formula::var) / formula::Rational { 4 }; template From 64028f302f201f113a94efc53637dd29f01012a3 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 21:01:01 +0200 Subject: [PATCH 26/59] test: pin the floating-point refusal of every band and breakpoint argument The refusal of a floating-point key or bound was pinned only for the first argument of each form. The other positions are separate arms of the constraint, so dropping one of them would have let a double be truncated again without any test noticing. One case per remaining position is added, plus one with two floating-point arguments of different types, which must still draw a single message; a guard instantiated once per argument would draw two. The changelog entry now says the message names the exact spelling for the call that was made, which is true of both messages, and each refusing overload's comment says that only a floating-point type is refused. The line numbers the documentation quotes from band.hpp follow the comments. Signed-off-by: Christian Parpart --- CHANGELOG.md | 5 +++-- docs/lookup-tables.md | 6 +++--- include/formula-cpp/band.hpp | 2 ++ include/formula-cpp/lookup.hpp | 4 ++++ test/CMakeLists.txt | 16 ++++++++++++++++ .../negative/band_from_floating_point_fourth.cpp | 14 ++++++++++++++ .../negative/band_from_floating_point_second.cpp | 14 ++++++++++++++ .../band_from_floating_point_several.cpp | 14 ++++++++++++++ test/negative/band_from_floating_point_third.cpp | 14 ++++++++++++++ ...oint_pair_denominator_from_floating_point.cpp | 14 ++++++++++++++ 10 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 test/negative/band_from_floating_point_fourth.cpp create mode 100644 test/negative/band_from_floating_point_second.cpp create mode 100644 test/negative/band_from_floating_point_several.cpp create mode 100644 test/negative/band_from_floating_point_third.cpp create mode 100644 test/negative/breakpoint_pair_denominator_from_floating_point.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b456696..0b313bcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,8 +69,9 @@ change is recorded here. - A floating-point key given to `breakpoint`, or a floating-point bound given to the four-argument `band`, used to be truncated silently (`breakpoint(1.5)` was `breakpoint(1)`). It is - now refused at compile time, with a message that names the exact spellings: `12.7_r`, - `Rational { 127, 10 }` or `breakpoint(127, 10)`. + now refused at compile time, with a message that names the exact spelling for that call: + `12.7_r` or `Rational { 127, 10 }` for `breakpoint`, `breakpoint(127, 10)`, `band(12.7_r, 17.3_r)` + or `band(127, 10, 173, 10)`. - GCC 14 is the oldest supported GCC; older GCC is not supported. The install-and-consume check now builds with it on Linux. - `` now specialises `std::formatter` for `formula::Outcome`, diff --git a/docs/lookup-tables.md b/docs/lookup-tables.md index d354fc67..f83776ae 100644 --- a/docs/lookup-tables.md +++ b/docs/lookup-tables.md @@ -178,10 +178,10 @@ compiler), with the rest of the instantiation backtrace below these lines: ``` In file included from test\negative\lookup_band_gap.cpp:10: In file included from include\formula-cpp/lookup.hpp:474: -include\formula-cpp/band.hpp(255,19): error: static assertion failed due to requirement 'bands_are_adjacent(formula::Band{103, 1, 197, 1}, formula::Band{241, 1, 331, 1})': formula: this band table has a gap or overlap between two adjacent bands; the earlier band's declared high bound and the later band's declared low bound do not match exactly, and the two offending Band values appear in this diagnostic as the template arguments First and Second of RequireBandsAdjacent - 255 | static_assert(bands_are_adjacent(First, Second), +include\formula-cpp/band.hpp(257,19): error: static assertion failed due to requirement 'bands_are_adjacent(formula::Band{103, 1, 197, 1}, formula::Band{241, 1, 331, 1})': formula: this band table has a gap or overlap between two adjacent bands; the earlier band's declared high bound and the later band's declared low bound do not match exactly, and the two offending Band values appear in this diagnostic as the template arguments First and Second of RequireBandsAdjacent + 257 | static_assert(bands_are_adjacent(First, Second), | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -include\formula-cpp/band.hpp(296,29): note: in instantiation of template class 'formula::RequireBandsAdjacent' requested here +include\formula-cpp/band.hpp(298,29): note: in instantiation of template class 'formula::RequireBandsAdjacent' requested here ``` The message names **both offending rows**, as the values you typed: the one diff --git a/include/formula-cpp/band.hpp b/include/formula-cpp/band.hpp index af2dca70..a4049f2c 100644 --- a/include/formula-cpp/band.hpp +++ b/include/formula-cpp/band.hpp @@ -135,6 +135,8 @@ namespace detail /// Refused: a floating-point numerator or denominator -- see /// `detail::RequireExactBandBound`. +/// Only a floating-point type is refused; every other arithmetic type converts as it +/// did before. template requires(std::is_floating_point_v || std::is_floating_point_v || std::is_floating_point_v || std::is_floating_point_v) [[nodiscard]] constexpr Band band(A, B, C, D) noexcept diff --git a/include/formula-cpp/lookup.hpp b/include/formula-cpp/lookup.hpp index 19d244dc..bedce6b4 100644 --- a/include/formula-cpp/lookup.hpp +++ b/include/formula-cpp/lookup.hpp @@ -1349,6 +1349,8 @@ namespace detail } // namespace detail /// Refused: a floating-point key -- see `detail::RequireExactBreakpointKey`. +/// Only a floating-point type is refused; every other arithmetic type converts as it +/// did before. template requires std::is_floating_point_v [[nodiscard]] constexpr Breakpoint breakpoint(T) noexcept @@ -1359,6 +1361,8 @@ template /// Refused: a floating-point numerator or denominator -- see /// `detail::RequireExactBreakpointKey`. +/// Only a floating-point type is refused; every other arithmetic type converts as it +/// did before. template requires(std::is_floating_point_v || std::is_floating_point_v) [[nodiscard]] constexpr Breakpoint breakpoint(N, D) noexcept diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index a79b0bb0..6d7ffa31 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -1135,6 +1135,22 @@ formula_add_negative_test(band_from_floating_point "formula: a band's bounds are exact numbers" EXPECT_COUNT 1 REJECT "no matching" "ambiguous") +formula_add_negative_test(band_from_floating_point_second + "formula: a band's bounds are exact numbers" EXPECT_COUNT 1 + REJECT "no matching" "ambiguous") +formula_add_negative_test(band_from_floating_point_third + "formula: a band's bounds are exact numbers" EXPECT_COUNT 1 + REJECT "no matching" "ambiguous") +formula_add_negative_test(band_from_floating_point_fourth + "formula: a band's bounds are exact numbers" EXPECT_COUNT 1 + REJECT "no matching" "ambiguous") +formula_add_negative_test(band_from_floating_point_several + "formula: a band's bounds are exact numbers" EXPECT_COUNT 1 + REJECT "no matching" "ambiguous") +formula_add_negative_test(breakpoint_pair_denominator_from_floating_point + "formula: a breakpoint's key is an exact number" EXPECT_COUNT 1 + REJECT "no matching" "ambiguous") + formula_add_negative_test(lookup_band_gap "formula: this band table has a gap or overlap between two adjacent bands" REJECT "must be initialized by a constant expression") diff --git a/test/negative/band_from_floating_point_fourth.cpp b/test/negative/band_from_floating_point_fourth.cpp new file mode 100644 index 00000000..234fa331 --- /dev/null +++ b/test/negative/band_from_floating_point_fourth.cpp @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: a band's bounds are exact numbers +// +// A floating-point value in the fourth argument (the high denominator) of `band(0, 1, 17, 1.5)`. +// The integer overload would truncate it and say nothing; it is refused, once, +// in the library's words. +#include + +inline constexpr auto declared = formula::band(0, 1, 17, 1.5); + +int main() +{ + return declared.lowNumerator == 12 ? 1 : 0; +} diff --git a/test/negative/band_from_floating_point_second.cpp b/test/negative/band_from_floating_point_second.cpp new file mode 100644 index 00000000..c06b2ca6 --- /dev/null +++ b/test/negative/band_from_floating_point_second.cpp @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: a band's bounds are exact numbers +// +// A floating-point value in the second argument (the low denominator) of `band(0, 1.5, 17, 1)`. +// The integer overload would truncate it and say nothing; it is refused, once, +// in the library's words. +#include + +inline constexpr auto declared = formula::band(0, 1.5, 17, 1); + +int main() +{ + return declared.lowNumerator == 12 ? 1 : 0; +} diff --git a/test/negative/band_from_floating_point_several.cpp b/test/negative/band_from_floating_point_several.cpp new file mode 100644 index 00000000..f1861f2e --- /dev/null +++ b/test/negative/band_from_floating_point_several.cpp @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: a band's bounds are exact numbers +// +// A floating-point value in the first (double) and third (float) arguments, so two different types; two mistakes of one kind are still one message of `band(12.7, 1, 17.3f, 1)`. +// The integer overload would truncate it and say nothing; it is refused, once, +// in the library's words. +#include + +inline constexpr auto declared = formula::band(12.7, 1, 17.3f, 1); + +int main() +{ + return declared.lowNumerator == 12 ? 1 : 0; +} diff --git a/test/negative/band_from_floating_point_third.cpp b/test/negative/band_from_floating_point_third.cpp new file mode 100644 index 00000000..0da7487e --- /dev/null +++ b/test/negative/band_from_floating_point_third.cpp @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: a band's bounds are exact numbers +// +// A floating-point value in the third argument (the high numerator) of `band(0, 1, 12.7, 1)`. +// The integer overload would truncate it and say nothing; it is refused, once, +// in the library's words. +#include + +inline constexpr auto declared = formula::band(0, 1, 12.7, 1); + +int main() +{ + return declared.lowNumerator == 12 ? 1 : 0; +} diff --git a/test/negative/breakpoint_pair_denominator_from_floating_point.cpp b/test/negative/breakpoint_pair_denominator_from_floating_point.cpp new file mode 100644 index 00000000..bdc98598 --- /dev/null +++ b/test/negative/breakpoint_pair_denominator_from_floating_point.cpp @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: a breakpoint's key is an exact number +// +// A floating-point value in the denominator of `breakpoint(1, 2.5)`. +// The integer overload would truncate it and say nothing; it is refused, once, +// in the library's words. +#include + +inline constexpr auto declared = formula::breakpoint(1, 2.5); + +int main() +{ + return declared.numerator == 1 ? 1 : 0; +} From 41678dae01cc2e402374db78a6cbc5a231fc8f38 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 21:07:26 +0200 Subject: [PATCH 27/59] docs(examples): check every checked result before use in the dimensions and display examples The examples handle errors the way the library means them to be handled. A std::expected is checked before it is read, and nothing swaps in a throwing spelling to be shorter. The display example now traces with checked_explain, and checks each of its three evaluations before it reads the outcome or the trace. On an error it prints the error's words and fails. The dimensions example rounds with checked_round_to_declared and bounds-checks with checked_within_bounds, and guards each result the same way. It no longer unwraps the bounds checks unchecked. The display example's outcome rows now name the checked result as moisture->outcome. The program prints each call as its source spells it, so those three reference lines change with it. Nothing else either program prints changes. The guides quote the guarded lines and say what the std::expected holds. The display guide says that its verdict outcome is built directly, as a rejection would yield it, and why some outputs are quoted. The dimensions example writes its million litres as 1'000'000_r. The README names the using-directive that its 0.5_r needs. Signed-off-by: Christian Parpart --- README.md | 3 ++ docs/dimensions.md | 27 ++++++++++------ docs/display.md | 45 ++++++++++++++++----------- examples/dimensions_and_units.cpp | 38 +++++++++++++++-------- examples/display.cpp | 51 ++++++++++++++++++++----------- 5 files changed, 107 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index b1ffb058..c1ffc956 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,9 @@ from "this is zero", and the difference matters when someone signs off on it. ### A number a person typed in never masquerades as a computed one +The `0.5_r` below needs `using namespace formula::literals;` in scope, as the +example has it: + ```cpp auto const batch = formula::environment(formula::Measured { 180 }, formula::Measured { 300 }, diff --git a/docs/dimensions.md b/docs/dimensions.md index b2b9d277..dd1aac9e 100644 --- a/docs/dimensions.md +++ b/docs/dimensions.md @@ -284,7 +284,13 @@ range (`bounds`), and both apply to a *computed* value, not just to a literal: ```cpp Rational const computedMass = genericDensity * volumeInCubicMetres; -Rational const roundedMass = formula::round_to_declared(computedMass, unit::Kilogram, RoundingMode::HalfAwayFromZero); +auto const roundedMass = + formula::checked_round_to_declared(computedMass, unit::Kilogram, RoundingMode::HalfAwayFromZero); +if (!roundedMass) +{ + std::println("rounding the computed mass: {}", roundedMass.error()); + return 1; +} ``` ```text @@ -295,17 +301,18 @@ rounded to kg's declared precision (3 places) = 64.286 kg `formula::declared_decimals` returns the rounding layer's own `DecimalPlaces` type, not a bare `int`, so it plugs directly into `formula::round` / `formula::checked_round` (see [`docs/numbers.md`](numbers.md)). -`round_to_declared` is the two calls composed. It throws -`ArithmeticException` where the rounding cannot be represented, as `convert` -does, and the example uses it because nothing in it can fail. -`checked_round_to_declared` is the same thing spelled to return a -`std::expected`, like every other `checked_` function here, for a caller -that handles the error. +`checked_round_to_declared` is the two calls composed, and returns a +`std::expected` like every other `checked_` function here: the rounded value, +or the `ArithmeticError` that stopped it, which `{}` writes in words. The +example checks it before reading the value. `round_to_declared` is the same +thing spelled to throw, as `convert` is to `checked_convert`. `formula::checked_within_bounds` checks a value, in the unit's own scale, -against that unit's declared `bounds`, and returns one of five -`BoundsCheck` values: `WithinBounds`, `BelowMinimum`, `AboveMaximum`, -`NotChecked`, or `NotMeasured`. **`NotChecked` is deliberately not the same thing as +against that unit's declared `bounds`, and returns, in a `std::expected` the +example checks before reading it, one of five `BoundsCheck` values: +`WithinBounds`, `BelowMinimum`, `AboveMaximum`, `NotChecked`, or +`NotMeasured` -- or an `ArithmeticError`, for a unit whose declared range is +malformed. **`NotChecked` is deliberately not the same thing as `WithinBounds`.** A unit that declares no bounds at all has not validated anything, and reporting it as "within bounds" would make an unvalidated value indistinguishable from one that was actually checked and passed: diff --git a/docs/display.md b/docs/display.md index f4d09428..842c8bfa 100644 --- a/docs/display.md +++ b/docs/display.md @@ -69,12 +69,19 @@ inline constexpr auto specimen = `25.5_r` is the exact number its digits spell, 51/2 (`using namespace formula::literals;`, -[Writing an exact decimal](numbers.md#writing-an-exact-decimal)). `explain` -evaluates the formula and returns its outcome together with a `Trace` of -every step ([Tracing](tracing.md)): +[Writing an exact decimal](numbers.md#writing-an-exact-decimal)). +`checked_explain` evaluates the formula and returns a `std::expected`: the +outcome together with a `Trace` of every step ([Tracing](tracing.md)), or the +arithmetic error with the steps recorded up to it. The example checks it +before reading either, as it checks every `std::expected` on this page: ```cpp -auto const moisture = formula::explain(moistureContent, specimen); +auto const moisture = formula::checked_explain(moistureContent, specimen); +if (!moisture) +{ + std::println("moisture content: {}", moisture.error().error); + return 1; +} ``` `render_trace` takes the style in `TraceRenderOptions::numbers`. One trace, @@ -84,10 +91,10 @@ four ways: auto const exactStyle = NumberStyle::exact_decimal(); auto const roundedStyle = NumberStyle::approximate_decimal(RoundingMode::HalfEven); auto const paddedStyle = NumberStyle::approximate_decimal(RoundingMode::HalfEven, DecimalPadding::Padded); -std::string const fractions = formula::render_trace(moisture.trace, { .maxSteps = 20 }); -std::string const exactDecimals = formula::render_trace(moisture.trace, { .maxSteps = 20, .numbers = exactStyle }); -std::string const rounded = formula::render_trace(moisture.trace, { .maxSteps = 20, .numbers = roundedStyle }); -std::string const padded = formula::render_trace(moisture.trace, { .maxSteps = 20, .numbers = paddedStyle }); +std::string const fractions = formula::render_trace(moisture->trace, { .maxSteps = 20 }); +std::string const exactDecimals = formula::render_trace(moisture->trace, { .maxSteps = 20, .numbers = exactStyle }); +std::string const rounded = formula::render_trace(moisture->trace, { .maxSteps = 20, .numbers = roundedStyle }); +std::string const padded = formula::render_trace(moisture->trace, { .maxSteps = 20, .numbers = paddedStyle }); ``` ```text @@ -395,7 +402,7 @@ The example spells the specimen's moisture content, `w`, and a moisture content nobody measured: ```cpp -formula::Measured const w = moisture.outcome.measurement(); +formula::Measured const w = moisture->outcome.measurement(); formula::Measured const notMeasured {}; formula::NumberText const measuredText = formula::number_text(w, roundedStyle); formula::NumberText const absentText = formula::number_text(notMeasured, roundedStyle); @@ -643,19 +650,23 @@ is included. The library owns these specialisations as well: a consumer's own enumerations defines it twice, and a generic one for every enumeration is ambiguous for them. -`moisture.outcome` is the moisture content's outcome from the first section. -The example adds a verdict and an empty outcome: +`moisture->outcome` is the moisture content's outcome from the first section, +read after its check. The example adds a verdict as a rejection yields it, +built directly here with `Outcome::verdict`, and an empty outcome: ```cpp -// What a rejection of the dish's weighings gives when it cannot settle, and -// a dish nobody weighed. +// A verdict as a rejection of the dish's weighings yields it when it cannot +// settle, built directly here, and a dish nobody weighed. auto const reweigh = formula::Outcome::verdict({ "weigh the dish again" }); auto const unweighed = formula::Outcome::empty(); ``` +An output is quoted, in the table and in the program's output below, where a +leading or trailing space would be missed. + | Value | Written as | Example | Output | |---|---|---|---| -| an `Outcome` holding a value | its `Measured`, in the same spec | `std::format("{:~HalfEven}", moisture.outcome)` | `≈11.3 %` | +| an `Outcome` holding a value | its `Measured`, in the same spec | `std::format("{:~HalfEven}", moisture->outcome)` | `≈11.3 %` | | an empty `Outcome` | `(not measured)`, whatever the body | `std::format("{}", unweighed)` | `(not measured)` | | a verdict or an invalid `Outcome` | its label, right-aligned by default | `std::format("{:22}", reweigh)` | `" weigh the dish again"` | | a `Unit` | its symbol, left-aligned by default | `std::format("{:4}", unit::Gram)` | `"g "` | @@ -666,14 +677,14 @@ The example prints every row, and checks each against the text in its source: ```text -std::format("{}", moisture.outcome) 2680/237 % -std::format("{:~HalfEven}", moisture.outcome) ≈11.3 % +std::format("{}", moisture->outcome) 2680/237 % +std::format("{:~HalfEven}", moisture->outcome) ≈11.3 % std::format("{}", unweighed) (not measured) std::format("{:22}", reweigh) " weigh the dish again" std::format("{:4}", unit::Gram) "g " std::format("{}", unit::Gram.dimension) M^1 std::format("{}", unit::Percent.dimension) (dimensionless) -std::format("{}", moisture.outcome.source()) derived +std::format("{}", moisture->outcome.source()) derived std::format("{}", RoundingMode::HalfEven) nearest, ties to even ``` diff --git a/examples/dimensions_and_units.cpp b/examples/dimensions_and_units.cpp index 080f36a6..76d8c2fb 100644 --- a/examples/dimensions_and_units.cpp +++ b/examples/dimensions_and_units.cpp @@ -111,22 +111,28 @@ int main() // A generic density and a generic volume, multiplied to a mass -- the // point is that the RESULT of a calculation, not a literal, gets rounded // to the unit it will be reported in. unit::Kilogram declares 3 decimals. - // Nothing here can make the rounding fail, so this uses the throwing - // round_to_declared rather than its checked_ twin. + // The rounding returns a std::expected -- the rounded value, or the + // arithmetic error that stopped it -- and it is checked before it is read. Rational const genericDensity { 1000, 7 }; // an arbitrary density, in kg/m3 Rational const computedMass = genericDensity * volumeInCubicMetres; - Rational const roundedMass = formula::round_to_declared(computedMass, unit::Kilogram, RoundingMode::HalfAwayFromZero); + auto const roundedMass = + formula::checked_round_to_declared(computedMass, unit::Kilogram, RoundingMode::HalfAwayFromZero); + if (!roundedMass) + { + std::println("rounding the computed mass: {}", roundedMass.error()); + return 1; + } std::println("computed mass = {:/} kg", computedMass); std::println("rounded to kg's declared precision ({} places) = {} kg", formula::declared_decimals(unit::Kilogram).value, - roundedMass); + *roundedMass); // 450/7 kg is 64.2857..., which at kilogram's three declared places is // 64.286. Asserted, not merely printed: the documentation quotes this // number, and without a check here changing the rounding mode silently // changes it while the example still reports success. - bool const massRoundsAsDocumented = roundedMass == 64.286_r; + bool const massRoundsAsDocumented = *roundedMass == 64.286_r; // ---- 7. Bounds: NotChecked is not a verdict, WithinBounds is ---- constexpr Unit BoundedGauge { .dimension = dim::Scalar, @@ -136,16 +142,24 @@ int main() .decimals = 1, .bounds = formula::bounds(0, 1, 100, 1) }; - // Neither unit declares a malformed range, the one thing that makes this - // check fail, so its std::expected is read directly. - BoundsCheck const unboundedVerdict = *formula::checked_within_bounds(1000000_r, unit::Litre); - BoundsCheck const boundedVerdict = *formula::checked_within_bounds(42_r, BoundedGauge); + auto const unboundedVerdict = formula::checked_within_bounds(1'000'000_r, unit::Litre); + if (!unboundedVerdict) + { + std::println("bounds-checking a litre: {}", unboundedVerdict.error()); + return 1; + } + auto const boundedVerdict = formula::checked_within_bounds(42_r, BoundedGauge); + if (!boundedVerdict) + { + std::println("bounds-checking the gauge: {}", boundedVerdict.error()); + return 1; + } // `{}` of a BoundsCheck writes its describe() words. - std::println("unbounded unit (litre) reports: {}", unboundedVerdict); - std::println("bounded gauge at 42%: {}", boundedVerdict); + std::println("unbounded unit (litre) reports: {}", *unboundedVerdict); + std::println("bounded gauge at 42%: {}", *boundedVerdict); bool const boundsBehaveAsDocumented = - unboundedVerdict == BoundsCheck::NotChecked && boundedVerdict == BoundsCheck::WithinBounds; + *unboundedVerdict == BoundsCheck::NotChecked && *boundedVerdict == BoundsCheck::WithinBounds; // ---- 8. A base dimension the SI does not have: money ---- // diff --git a/examples/display.cpp b/examples/display.cpp index e4e4b2e6..b1380eb2 100644 --- a/examples/display.cpp +++ b/examples/display.cpp @@ -129,16 +129,21 @@ int main() // ---- 1. A trace in every style -------------------------------------------------- std::println("== 1. A trace in every style ==\n"); - auto const moisture = formula::explain(moistureContent, specimen); - check(formula::number_of(moisture.outcome) == Rational { 2680, 237 }, "the moisture content is 2680/237 %"); + auto const moisture = formula::checked_explain(moistureContent, specimen); + if (!moisture) + { + std::println("moisture content: {}", moisture.error().error); + return 1; + } + check(formula::number_of(moisture->outcome) == Rational { 2680, 237 }, "the moisture content is 2680/237 %"); auto const exactStyle = NumberStyle::exact_decimal(); auto const roundedStyle = NumberStyle::approximate_decimal(RoundingMode::HalfEven); auto const paddedStyle = NumberStyle::approximate_decimal(RoundingMode::HalfEven, DecimalPadding::Padded); - std::string const fractions = formula::render_trace(moisture.trace, { .maxSteps = 20 }); - std::string const exactDecimals = formula::render_trace(moisture.trace, { .maxSteps = 20, .numbers = exactStyle }); - std::string const rounded = formula::render_trace(moisture.trace, { .maxSteps = 20, .numbers = roundedStyle }); - std::string const padded = formula::render_trace(moisture.trace, { .maxSteps = 20, .numbers = paddedStyle }); + std::string const fractions = formula::render_trace(moisture->trace, { .maxSteps = 20 }); + std::string const exactDecimals = formula::render_trace(moisture->trace, { .maxSteps = 20, .numbers = exactStyle }); + std::string const rounded = formula::render_trace(moisture->trace, { .maxSteps = 20, .numbers = roundedStyle }); + std::string const padded = formula::render_trace(moisture->trace, { .maxSteps = 20, .numbers = paddedStyle }); std::println("-- fractions, the default --\n{}", fractions); std::println("-- exact decimals --\n{}", exactDecimals); std::println("-- rounded where no decimal ends --\n{}", rounded); @@ -150,11 +155,16 @@ int main() // ---- 2. A unit nobody declared, and a comparison --------------------------------- std::println("== 2. A unit nobody declared, and a comparison ==\n"); - auto const dish = formula::explain(dishMass, weighings); - check(dish.outcome.is_value(), "the dish's mass is a value"); - std::string const dishTraceText = formula::render_trace(dish.trace, { .maxSteps = 20, .numbers = paddedStyle }); + auto const dish = formula::checked_explain(dishMass, weighings); + if (!dish) + { + std::println("the dish's mass: {}", dish.error().error); + return 1; + } + check(dish->outcome.is_value(), "the dish's mass is a value"); + std::string const dishTraceText = formula::render_trace(dish->trace, { .maxSteps = 20, .numbers = paddedStyle }); std::println("{}", dishTraceText); - formula::NumberText const dishText = formula::number_text(dish.outcome.measurement(), roundedStyle); + formula::NumberText const dishText = formula::number_text(dish->outcome.measurement(), roundedStyle); std::println("the dish's mass in its declared grams: {}\n", dishText.view()); check(dishTraceText.contains("t = 4.21 g; 4.23 g; 4.26 g\n"), "padding cuts no decimal short"); check(dishTraceText.contains("3. 1/3\n"), "a typed number is never rounded"); @@ -188,9 +198,14 @@ int main() check(dishPage.rejections.front().limit.contains("1/30"), "nor in a documentation page's limit"); auto const paddedDecimals = NumberStyle::exact_decimal(DecimalPadding::Padded); - auto const tare = formula::explain(wholeTare, specimen); + auto const tare = formula::checked_explain(wholeTare, specimen); + if (!tare) + { + std::println("the dry mass less the tare: {}", tare.error().error); + return 1; + } std::string const tareFormula = formula::render(wholeTare, { .numbers = paddedDecimals }); - std::string const tareTraceText = formula::render_trace(tare.trace, { .maxSteps = 20, .numbers = paddedDecimals }); + std::string const tareTraceText = formula::render_trace(tare->trace, { .maxSteps = 20, .numbers = paddedDecimals }); std::println("formula, padded style: {}", tareFormula); std::println("trace, padded style:\n{}", tareTraceText); check(tareFormula == "m_d - 24 g" && tareTraceText.contains("2. 24.0 g\n"), @@ -200,7 +215,7 @@ int main() // ---- 4. number_text and decimal_text ------------------------------------------------ std::println("== 4. number_text and decimal_text ==\n"); - formula::Measured const w = moisture.outcome.measurement(); + formula::Measured const w = moisture->outcome.measurement(); formula::Measured const notMeasured {}; formula::NumberText const measuredText = formula::number_text(w, roundedStyle); formula::NumberText const absentText = formula::number_text(notMeasured, roundedStyle); @@ -259,19 +274,19 @@ int main() // ---- 6. Outcomes, units, dimensions and enumerations ------------------------------- std::println("== 6. Outcomes, units, dimensions and enumerations ==\n"); - // What a rejection of the dish's weighings gives when it cannot settle, and - // a dish nobody weighed. + // A verdict as a rejection of the dish's weighings yields it when it cannot + // settle, built directly here, and a dish nobody weighed. auto const reweigh = formula::Outcome::verdict({ "weigh the dish again" }); auto const unweighed = formula::Outcome::empty(); FormatRow const words[] = { - FORMAT_ROW("2680/237 %", std::format("{}", moisture.outcome)), - FORMAT_ROW("\xe2\x89\x88" "11.3 %", std::format("{:~HalfEven}", moisture.outcome)), + FORMAT_ROW("2680/237 %", std::format("{}", moisture->outcome)), + FORMAT_ROW("\xe2\x89\x88" "11.3 %", std::format("{:~HalfEven}", moisture->outcome)), FORMAT_ROW("(not measured)", std::format("{}", unweighed)), FORMAT_ROW(" weigh the dish again", std::format("{:22}", reweigh)), FORMAT_ROW("g ", std::format("{:4}", unit::Gram)), FORMAT_ROW("M^1", std::format("{}", unit::Gram.dimension)), FORMAT_ROW("(dimensionless)", std::format("{}", unit::Percent.dimension)), - FORMAT_ROW("derived", std::format("{}", moisture.outcome.source())), + FORMAT_ROW("derived", std::format("{}", moisture->outcome.source())), FORMAT_ROW("nearest, ties to even", std::format("{}", RoundingMode::HalfEven)), }; for (FormatRow const& row: words) From 02859bbf4aeead8ac09ca105862863e746693619 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 21:17:03 +0200 Subject: [PATCH 28/59] docs(examples): write the constraints, rounding and lookup-table examples in the short spellings The three examples and the guides that quote them now read the way the library is meant to be used: exact decimals with _r, inputs as Measured { n }, bands as band(low, high), a rounding named once as a DecimalRounding or SignificantRounding, a formula evaluated repeatedly bound with yields, and values checked with number_of. The hand-built trace in the constraints example is explain_check, and the lookup example's trace keeps a miss's derivation through checked_explain. Every checked result is tested before it is read, and everything prints with std::println. Values that used to print through to_double() or as a fraction now print as the exact decimal the library formats (26 mm, 0.448, 1.051, 36.28052 MPa); the guides quote the new lines. The rounding guide gains a SignificantRounding example and one declaration style per snippet, and the README and lookup-table guide show the two-argument band(). Signed-off-by: Christian Parpart --- README.md | 6 +- docs/constraints.md | 87 +++---- docs/lookup-tables.md | 83 ++++--- docs/rounding-and-conditionals.md | 84 ++++--- examples/constraints.cpp | 143 ++++------- examples/lookup_tables.cpp | 320 +++++++++++-------------- examples/rounding_and_conditionals.cpp | 122 +++++----- 7 files changed, 402 insertions(+), 443 deletions(-) diff --git a/README.md b/README.md index c1ffc956..d5eae7c2 100644 --- a/README.md +++ b/README.md @@ -252,9 +252,9 @@ library targets. See [the tracing guide](docs/tracing.md). ```cpp inline constexpr formula::BandTable<3> SizeBands { - formula::band(0, 1, 127, 1), // 0 to under 127 mm - formula::band(127, 1, 173, 1), // 127 to under 173 mm - formula::band(173, 1, 211, 1), // 173 to under 211 mm -- 211 mm itself is NOT in it + formula::band(0, 127), // 0 to under 127 mm + formula::band(127, 173), // 127 to under 173 mm + formula::band(173, 211), // 173 to under 211 mm -- 211 mm itself is NOT in it }; ``` diff --git a/docs/constraints.md b/docs/constraints.md index 9f84e2f4..03054bd0 100644 --- a/docs/constraints.md +++ b/docs/constraints.md @@ -34,12 +34,11 @@ that must hold, not as the failure -- so the declaration reads the way the standard reads: ```cpp -constexpr auto minimumStrength = - formula::constraint(var >= formula::constant(formula::Rational { 273, 10 }), - formula::Verdict { "reject the specimen" }, - formula::Citation { .title = "Minimum compressive strength", - .reference = "Example Standard 7:2020", - .section = "5.1" }); +constexpr auto minimumStrength = formula::constraint(var >= formula::constant(27.3_r), + formula::Verdict { "reject the specimen" }, + formula::Citation { .title = "Minimum compressive strength", + .reference = "Example Standard 7:2020", + .section = "5.1" }); ``` There is deliberately no separate verdict for success: a satisfied @@ -77,9 +76,22 @@ strength was measured at 45 MPa, one at 20 MPa, and one where strength was never measured at all: ```cpp -constexpr auto satisfied = formula::check(minimumStrength, strengthOf(45)); -constexpr auto violated = formula::check(minimumStrength, strengthOf(20)); -constexpr auto notChecked = formula::check(minimumStrength, nothingMeasured()); +constexpr auto satisfied = formula::check(minimumStrength, strength45); +constexpr auto violated = formula::check(minimumStrength, strength20); +constexpr auto notChecked = formula::check(minimumStrength, nothingMeasured); +``` + +where the environments are plain constants -- the last one names both +quantities and measures neither: + +```cpp +constexpr auto strength45 = formula::environment(formula::Measured { 45 }); +constexpr auto strength20 = formula::environment(formula::Measured { 20 }); +constexpr auto strength0 = formula::environment(formula::Measured { 0 }); + +// Neither quantity measured -- the case this whole example exists to show. +constexpr auto nothingMeasured = + formula::environment(formula::Measured::absent(), formula::Measured::absent()); ``` which report: @@ -126,8 +138,7 @@ error instead of ever comparing anything: ```cpp constexpr auto dividesByZero = - formula::constraint((var / formula::number(formula::Rational { 0 })) - > formula::constant(formula::Rational { 1 }), + formula::constraint((var / formula::number(0_r)) > formula::constant(1_r), formula::Verdict { "specimen result is unusable" }); ``` @@ -150,8 +161,8 @@ A constraint renders as its rule alone, `require `, never its verdict: ```cpp -std::printf("rendered: %s\n", formula::render(minimumStrength).c_str()); -std::printf("rendered (LaTeX): %s\n", formula::render(minimumStrength).c_str()); +std::println("rendered: {}", formula::render(minimumStrength)); +std::println("rendered (LaTeX): {}", formula::render(minimumStrength)); ``` ``` @@ -178,21 +189,10 @@ for `Node`, so a constraint documents exactly the way a formula does: ```cpp formula::Documentation const documentation = formula::document(minimumStrength); formula::Citation const& citation = documentation.citations.front(); -std::printf("documented: %s\n", documentation.formula.c_str()); -std::printf("cited: %.*s, %.*s, %.*s\n", - static_cast(citation.title.size()), - citation.title.data(), - static_cast(citation.reference.size()), - citation.reference.data(), - static_cast(citation.section.size()), - citation.section.data()); +std::println("documented: {}", documentation.formula); +std::println("cited: {}, {}, {}", citation.title, citation.reference, citation.section); for (formula::SymbolEntry const& entry: documentation.symbols) - std::printf("symbol: %.*s = %.*s [%s]\n", - static_cast(entry.symbol.size()), - entry.symbol.data(), - static_cast(entry.description.size()), - entry.description.data(), - std::string { formula::view(entry.unit.symbolText) }.c_str()); + std::println("symbol: {} = {} [{}]", entry.symbol, entry.description, entry.unit); ``` ``` @@ -216,18 +216,18 @@ rendering above, with a bracketed suffix naming what checking it concluded -- present for every one of the four outcomes, because nothing else in the line carries that distinction: +`formula::explain_check(constraint, environment)` is `check()` with a recording +sink: it returns the outcome and the trace it recorded. The example renders the +trace once per outcome above: + ```cpp -template -[[nodiscard]] std::string tracedCheck(formula::Constraint

const& subject, Env const& environment) -{ - formula::Trace<> trace {}; - formula::RecordingSink<> sink { trace }; - [[maybe_unused]] auto const outcome = formula::check(subject, environment, sink); - return formula::render_trace(trace, { .maxSteps = 5 }); -} +std::print("{}", formula::render_trace(formula::explain_check(minimumStrength, strength45).trace, { .maxSteps = 5 })); +std::print("{}", formula::render_trace(formula::explain_check(minimumStrength, strength20).trace, { .maxSteps = 5 })); +std::print("{}", formula::render_trace(formula::explain_check(minimumStrength, nothingMeasured).trace, { .maxSteps = 5 })); +std::print("{}", formula::render_trace(formula::explain_check(dividesByZero, strength0).trace, { .maxSteps = 5 })); ``` -Called once per outcome above, this prints: +which prints: ``` 1. f = 45 MPa @@ -280,14 +280,21 @@ Alongside `minimumStrength`, a second, independent constraint over a different quantity: ```cpp -constexpr auto maximumDiameter = - formula::constraint(var <= formula::constant(formula::Rational { 139 }), - formula::Verdict { "specimen exceeds diameter tolerance" }); +constexpr auto maximumDiameter = formula::constraint(var <= formula::constant(139_r), + formula::Verdict { "specimen exceeds diameter tolerance" }); ``` ```cpp constexpr auto setOutcomes = - formula::check_all(formula::constraints(minimumStrength, maximumDiameter), strengthOnly(20)); + formula::check_all(formula::constraints(minimumStrength, maximumDiameter), strength20Only); +``` + +with `strength20Only` an environment that measures the strength and leaves the +diameter unmeasured: + +```cpp +constexpr auto strength20Only = + formula::environment(formula::Measured { 20 }, formula::Measured::absent()); ``` Here strength (20 MPa) violates `minimumStrength`, and diameter was never diff --git a/docs/lookup-tables.md b/docs/lookup-tables.md index f83776ae..265b5a53 100644 --- a/docs/lookup-tables.md +++ b/docs/lookup-tables.md @@ -39,11 +39,16 @@ table's data, so they are an ordinary runtime member, handed to the factory: ```cpp [[nodiscard]] constexpr auto sizeFactor() { - return formula::banded_lookup( - var, { rat(913, 10), rat(1051, 10), rat(1127, 10) }); + return formula::yields( + formula::banded_lookup(var, { 91.3_r, 105.1_r, 112.7_r })); } ``` +`formula::yields` binds the lookup to the quantity it produces, +so each later render, evaluation and trace of it names no result type (see +[Expressions](expressions.md)); a formula that uses it as an operand takes its +`.expression`. + Everything in the **template argument list** — the unit the keys are stated in, the bands themselves, the unit the values are stated in — is the method: fixed, published once, and part of what this formula *is*. The **braced list** is the @@ -61,18 +66,20 @@ compile-time template argument is the one place they cannot live. ## Banded: a measured value falls in an interval A band table is declared as `formula::BandTable`, each row a low bound -(inclusive) and a high bound (exclusive), both as exact numerator/denominator -pairs: +(inclusive) and a high bound (exclusive), each an exact number -- a whole +number, or a decimal written with the `_r` suffix, as in `band(83.7_r, 97.3_r)`: ```cpp inline constexpr formula::BandTable<3> SizeBands { - formula::band(0, 1, 127, 1), // 0 to under 127 mm - formula::band(127, 1, 173, 1), // 127 to under 173 mm - formula::band(173, 1, 211, 1), // 173 to under 211 mm -- 211 mm itself is NOT in it + formula::band(0, 127), // 0 to under 127 mm + formula::band(127, 173), // 127 to under 173 mm + formula::band(173, 211), // 173 to under 211 mm -- 211 mm itself is NOT in it }; ``` -`int64` pairs rather than `Rational`, for the reason +`band(low, high)` takes the two numbers as `Rational`s and keeps each as an +`int64` numerator and denominator (`band(127, 10, 173, 10)` states the pairs +directly), rather than keeping a `Rational`, for the reason [Dimensions and units](dimensions.md) gives for `Unit`'s own magnitude and offset fields: `Rational` keeps its members private, so it is not a *structural* type and cannot be a non-type template parameter. An aggregate of @@ -89,11 +96,11 @@ banded: lookup(d, 0 to under 127 mm gives 913/10 %, 127 to under 173 mm g and looking a diameter up in it gives back that row's correction: ``` -d = 139 mm: 1051/1000 +d = 139 mm: 1.051 ``` -`1051/1000`, exactly: 139 mm falls in the middle band, whose correction the -table states as `1051/10 %` (105.1 %), converted into the dimensionless unit the +`1.051`, exactly: 139 mm falls in the middle band, whose correction the +table states as `105.1 %`, converted into the dimensionless unit the result quantity declares. Both sides of a table are converted — the key into the unit the bands are stated in, and the value out of the unit the rows are stated in — so a table may be written in whatever units the published document uses. @@ -105,7 +112,7 @@ value sitting exactly on a boundary belongs to the band whose *low* bound it is, never the band whose high bound it is: ``` -d = 127 mm: 1051/1000 (the band above the boundary, never the one below) +d = 127 mm: 1.051 (the band above the boundary, never the one below) ``` A published table that writes one row as "31.7 to 43.9" and the next as "43.9 @@ -123,7 +130,7 @@ to 211 mm inclusive" is written with its high bound at 211.1 mm: ```cpp inline constexpr formula::BandTable<1> TopRowInclusive { - formula::band(173, 1, 2111, 10), // 173 to under 211.1 mm -- 211 mm IS in it + formula::band(173, 211.1_r), // 173 to under 211.1 mm -- 211 mm IS in it }; ``` @@ -132,7 +139,7 @@ Both spellings, evaluated at exactly 211 mm, side by side: ``` d = 211 mm: argument outside the domain of the operation inclusive top: lookup(d, 173 to under 2111/10 mm gives 1127/10 %) -d = 211 mm: 1127/1000 +d = 211 mm: 1.127 ``` The first table's last row stops under 211 mm, so 211 mm is in no band and @@ -485,7 +492,8 @@ key varies per specimen is a *function of the key*: ```cpp [[nodiscard]] constexpr auto shapeFactor(LookupExampleShape shape) { - return formula::exact_lookup(shape, { rat(1013, 10), rat(863, 10), rat(931, 10) }); + return formula::yields( + formula::exact_lookup(shape, { 101.3_r, 86.3_r, 93.1_r })); } ``` @@ -519,7 +527,7 @@ interpolating: interpolate(d, at 127 mm gives 913/10 %, at 173 mm gives 1051/10 and between two rows it produces a number that appears in neither: ``` -d = 139 mm: 949/1000 (between two rows -- in neither of them) +d = 139 mm: 0.949 (between two rows -- in neither of them) ``` Every step of that is `Rational`'s own checked arithmetic — `y0 + (x - x0)(y1 - @@ -542,7 +550,7 @@ breakpoint is included.** Evaluated at the very same 211 mm: d = 211 mm: argument outside the domain of the operation ``` ``` -d = 211 mm: 1127/1000 (the last row, reached -- where the band table missed) +d = 211 mm: 1.127 (the last row, reached -- where the band table missed) ``` This is not an inconsistency and it is not an oversight. A band's high bound is @@ -588,8 +596,8 @@ method actually applies. ```cpp [[nodiscard]] constexpr auto classFactor() { - return formula::banded_lookup(sizeCurveFactor(), - { rat(919, 10), rat(1013, 10), rat(1087, 10) }); + return formula::yields(formula::banded_lookup( + sizeCurveFactor().expression, { 91.9_r, 101.3_r, 108.7_r })); } ``` @@ -603,7 +611,7 @@ nested: lookup(interpolate(d, at 127 mm gives 913/10 %, at 173 mm gives 1 it evaluates, the inner answer becoming the outer key: ``` -d = 139 mm: 919/1000 (curve gives 94.9 %, which falls in the 83.7-to-under-97.3 % band) +d = 139 mm: 0.919 (curve gives 94.9 %, which falls in the 83.7-to-under-97.3 % band) ``` it documents, the symbol table reaching through both tables to the one quantity @@ -667,13 +675,14 @@ it wraps anything else — there is nothing special to do: ```cpp [[nodiscard]] constexpr auto correctedStrength(LookupExampleShape shape) { - return formula::documented(var * sizeFactor() * shapeFactor(shape), - { .title = "Corrected compressive strength", - .reference = "Example Standard 8:2020", - .section = "7.3", - .equation = "(5)", - .text = "The measured strength is corrected for specimen size and for specimen " - "shape, each factor taken from the table the method publishes for it." }); + return formula::yields( + formula::documented(var * sizeFactor().expression * shapeFactor(shape).expression, + { .title = "Corrected compressive strength", + .reference = "Example Standard 8:2020", + .section = "7.3", + .equation = "(5)", + .text = "The measured strength is corrected for specimen size and for specimen " + "shape, each factor taken from the table the method publishes for it." })); } ``` @@ -764,20 +773,24 @@ misses throws `formula::ArithmeticException` — carrying `argument outside the domain of the operation` — instead of handing back the derivation that says why it missed. (Measured, with the other direction as a control: the same `explain()` call over a value the table *does* cover returns -normally.) Build the sink yourself: +normally.) Use `formula::checked_explain()` instead. It returns a +`std::expected`: the outcome and its trace on success, and on a miss the +`ArithmeticError` together with the trace recorded up to it, in `error()`: ```cpp -template -[[nodiscard]] std::string tracedEvaluation(N const& node, Env const& environment) +auto const at211Missed = formula::checked_explain(sizeFactor(), diameter211); +if (at211Missed) { - formula::Trace<> trace {}; - formula::RecordingSink<> sink { trace }; - [[maybe_unused]] auto const outcome = formula::checked_evaluate(node, environment, sink); - return formula::render_trace(trace, { .maxSteps = 10 }); + std::println("d = 211 mm: the band table gave a value, where it must miss"); + return 1; } ``` -`checked_evaluate` reports the miss in its return value and leaves you the +```cpp +std::print("{}", formula::render_trace(at211Missed.error().trace, { .maxSteps = 10 })); +``` + +`checked_explain` reports the miss in its return value and leaves you the trace, which is the whole point of having one. ## One representation: `Rational` diff --git a/docs/rounding-and-conditionals.md b/docs/rounding-and-conditionals.md index 22a3abb7..ce0ce634 100644 --- a/docs/rounding-and-conditionals.md +++ b/docs/rounding-and-conditionals.md @@ -12,6 +12,12 @@ this page formatted as program output is copied verbatim from that program's actual output, the same way [Tracing and audit trails](tracing.md) does for `examples/tracing.cpp`. +The snippets on this page are written the way the example writes them, with +`namespace unit = formula::unit;`, `using formula::var;` and `using +formula::DecimalPlaces`, `RoundingMode` and `SignificantDigits` in effect: a +unit is `unit::Millimetre`, a variable is `var`, and every other +name the library offers keeps its `formula::` prefix. + `RoundingMode`, `DecimalPlaces`, `SignificantDigits` and the plain-`Rational` `formula::round()` free function already exist -- [Exact numbers](numbers.md) covers them, including the intermediate-versus-final rounding argument made @@ -28,10 +34,8 @@ places, or `Digits` significant digits, of the unit `U`, under the tie-break rule `Mode`: ```cpp -constexpr auto coarseInput = - formula::rounded(var); -constexpr auto toTwoSignificantDigits = - formula::rounded_to_digits(var); +formula::rounded(var) +formula::rounded_to_digits(var) ``` The unit is not decoration. "To one decimal place" means nothing about a @@ -54,19 +58,36 @@ A method that rounds the same way in several places repeats the same three arguments each time. `DecimalRounding` (`unit.hpp`) names them once: which unit the places are counted in, how many, and which way to go. +The example names the two it uses for decimal places, then uses them: + +```cpp +constexpr formula::DecimalRounding wholeMillimetre { unit::Millimetre, DecimalPlaces { 0 }, RoundingMode::HalfAwayFromZero }; +constexpr formula::DecimalRounding tenthMillimetre { unit::Millimetre, DecimalPlaces { 1 }, RoundingMode::HalfAwayFromZero }; +``` + +`rounded(var)` is the very node `rounded(var)` builds -- +the same type, not an equivalent one. Every factory that takes the three +arguments separately also takes the named value: `rounded_to_digits` takes a +`SignificantRounding`, and `rounding_rule`, `with_rounding`, `rounded_output`, +`rounded_sqrt` and `rounded_elementwise` take a `DecimalRounding`. + +A `SignificantRounding` names a unit, a count of significant digits and a tie +rule, the way a `DecimalRounding` names a unit, a count of places and a tie +rule. The example states two significant digits of a millimetre once, and +`rounded_to_digits` takes it as it would the three arguments: + +```cpp +constexpr formula::SignificantRounding twoDigitsOfMillimetre { + unit::Millimetre, SignificantDigits { 2 }, RoundingMode::HalfAwayFromZero +}; +``` + ```cpp -constexpr formula::DecimalRounding tenthMillimetre { unit::Millimetre, - DecimalPlaces { 1 }, - RoundingMode::HalfAwayFromZero }; -constexpr auto edge = formula::rounded(var); +constexpr auto toTwoSignificantDigits = formula::rounded_to_digits(var); ``` -`edge` is the very node `rounded(var)` builds -- the same type, not an -equivalent one. Every factory that takes the three arguments separately also -takes the named value: `rounded_to_digits` takes a `SignificantRounding`, and -`rounding_rule`, `with_rounding`, `rounded_output`, `rounded_sqrt` and -`rounded_elementwise` take a `DecimalRounding`. +The other factories take the named rounding the same way: ```cpp constexpr auto rule = formula::rounding_rule(); @@ -98,11 +119,9 @@ all; it can only ever give you the second answer, silently, regardless of which one the method actually calls for. ```cpp -constexpr auto coarseInput = - formula::rounded(var); +constexpr auto coarseInput = formula::rounded(var); constexpr auto roundThenDouble = coarseInput + coarseInput; -constexpr auto doubleThenRound = formula::rounded( - var + var); +constexpr auto doubleThenRound = formula::rounded(var + var); ``` `roundThenDouble` rounds the input to a whole millimetre first and doubles @@ -112,8 +131,8 @@ one addition -- and, on 12.50 mm: ``` rendered: round(d + d, to 1 dp of mm) -12.50 mm, round to 0 dp then double = 26.000000 mm -12.50 mm, double then round to 1 dp = 25.000000 mm +12.50 mm, round to 0 dp then double = 26 mm +12.50 mm, double then round to 1 dp = 25 mm ``` 26 and 25 are not close-enough-to-agree; they are two different numbers, from @@ -128,7 +147,7 @@ decimal places -- the two can and do disagree, exactly as [Exact numbers](numbers.md) already shows for plain `Rational` values: ``` -12.34 mm to 2 significant digits = 12.000000 mm +12.34 mm to 2 significant digits = 12 mm ``` ## A numeric threshold selects between two formulas @@ -155,18 +174,19 @@ formula rather than in an `if`/`else` a caller has to remember to apply the same way every time: ```cpp -constexpr auto sizeAdjustedDiameter = - formula::when(var > formula::constant(formula::Rational { 173, 10 }), - formula::rounded(var), - formula::rounded(var)); +constexpr auto sizeAdjustedDiameter = formula::yields(formula::when( + var > formula::constant(17.3_r), coarseInput, formula::rounded(var))); ``` -which renders, and evaluates on both sides of its own threshold, as: +`formula::yields` binds the formula to the quantity it produces, so +that the example can render, evaluate and explain it without naming `Diameter` +again at each call (see [Expressions](expressions.md)). The formula renders, +and evaluates on both sides of its own threshold, as: ``` rendered: if d > 173/10 mm then round(d, to 0 dp of mm) else round(d, to 1 dp of mm) -12.34 mm, size-adjusted rounding = 12.300000 mm -25.40 mm, size-adjusted rounding = 25.000000 mm +12.34 mm, size-adjusted rounding = 12.3 mm +25.40 mm, size-adjusted rounding = 25 mm ``` One caveat worth knowing before it surprises you: `document()` walks **both** @@ -276,19 +296,19 @@ without ever converting it back, because converting it back is exactly what it is built not to do. ```cpp -constexpr auto empiricalCorrection = +constexpr auto empiricalCorrection = formula::yields( formula::numeric_value_of(var) - * formula::Rational { 213, 10000 } - - formula::Rational { 1043, 1000 }; + * 0.0213_r + - 1.043_r); ``` which renders and traces as: ``` rendered: numeric(f, in MPa) * 213/10000 - 1043/1000 -empirical correction factor at 70 MPa = 0.448000 +empirical correction factor at 70 MPa = 0.448 1. f = 70 MPa 2. numeric(#1, in MPa) = 70 (Example Standard 9:2020 states this empirical coefficient over the numeric value of strength in MPa) 3. 213/10000 diff --git a/examples/constraints.cpp b/examples/constraints.cpp index 596494c7..f60b0b3d 100644 --- a/examples/constraints.cpp +++ b/examples/constraints.cpp @@ -15,87 +15,64 @@ // differ on purpose rather than being inconsistent. // // Every citation here is invented -- generic physics with fictional Example -// Standard references, exactly as every other example in this repository is. #include +#include #include #include #include #include -#include -#include +#include namespace { namespace unit = formula::unit; using formula::var; +using namespace formula::literals; using Strength = formula::Quantity; using Diameter = formula::Quantity; // "reject the specimen below 27.3 MPa" -- an invented threshold, given an // invented citation here. -constexpr auto minimumStrength = - formula::constraint(var >= formula::constant(formula::Rational { 273, 10 }), - formula::Verdict { "reject the specimen" }, - formula::Citation { .title = "Minimum compressive strength", - .reference = "Example Standard 7:2020", - .section = "5.1" }); +constexpr auto minimumStrength = formula::constraint(var >= formula::constant(27.3_r), + formula::Verdict { "reject the specimen" }, + formula::Citation { .title = "Minimum compressive strength", + .reference = "Example Standard 7:2020", + .section = "5.1" }); -constexpr auto maximumDiameter = - formula::constraint(var <= formula::constant(formula::Rational { 139 }), - formula::Verdict { "specimen exceeds diameter tolerance" }); +constexpr auto maximumDiameter = formula::constraint(var <= formula::constant(139_r), + formula::Verdict { "specimen exceeds diameter tolerance" }); // Divides a measured value by zero while checking, so the predicate can // never resolve at all -- Invalid, distinct from NotChecked: this one broke // while checking, rather than never having its input measured in the first // place. constexpr auto dividesByZero = - formula::constraint((var / formula::number(formula::Rational { 0 })) - > formula::constant(formula::Rational { 1 }), + formula::constraint((var / formula::number(0_r)) > formula::constant(1_r), formula::Verdict { "specimen result is unusable" }); -[[nodiscard]] constexpr auto strengthOf(long long megapascals) -{ - return formula::environment(formula::Measured { formula::Rational { megapascals } }); -} +constexpr auto strength45 = formula::environment(formula::Measured { 45 }); +constexpr auto strength20 = formula::environment(formula::Measured { 20 }); +constexpr auto strength0 = formula::environment(formula::Measured { 0 }); // Neither quantity measured -- the case this whole example exists to show. -[[nodiscard]] constexpr auto nothingMeasured() -{ - return formula::environment(formula::Measured::absent(), formula::Measured::absent()); -} +constexpr auto nothingMeasured = + formula::environment(formula::Measured::absent(), formula::Measured::absent()); // Strength measured, diameter never measured -- so checking a set spanning // both quantities resolves one and leaves the other not checked. -[[nodiscard]] constexpr auto strengthOnly(long long megapascals) -{ - return formula::environment(formula::Measured { formula::Rational { megapascals } }, - formula::Measured::absent()); -} - -// Checks @p subject against @p environment through a fresh RecordingSink and -// renders the one-step trace it produced -- the same shape -// examples/rounding_and_conditionals.cpp uses for a Node's own trace, just -// built by hand here because Constraint::check takes a sink parameter -// directly rather than going through explain(), which only accepts a Node. -template -[[nodiscard]] std::string tracedCheck(formula::Constraint

const& subject, Env const& environment) -{ - formula::Trace<> trace {}; - formula::RecordingSink<> sink { trace }; - [[maybe_unused]] auto const outcome = formula::check(subject, environment, sink); - return formula::render_trace(trace, { .maxSteps = 5 }); -} +constexpr auto strength20Only = + formula::environment(formula::Measured { 20 }, formula::Measured::absent()); } // namespace int main() { // ---- 1. A constraint renders as its rule, never its verdict ----------- - std::printf("rendered: %s\n", formula::render(minimumStrength).c_str()); - std::printf("rendered (LaTeX): %s\n", formula::render(minimumStrength).c_str()); + std::println("rendered: {}", formula::render(minimumStrength)); + std::println("rendered (LaTeX): {}", formula::render(minimumStrength)); // ---- 2. document() walks it for its citation and symbol table --------- // @@ -106,47 +83,26 @@ int main() // formula does: formula::Documentation const documentation = formula::document(minimumStrength); formula::Citation const& citation = documentation.citations.front(); - std::printf("documented: %s\n", documentation.formula.c_str()); - std::printf("cited: %.*s, %.*s, %.*s\n", - static_cast(citation.title.size()), - citation.title.data(), - static_cast(citation.reference.size()), - citation.reference.data(), - static_cast(citation.section.size()), - citation.section.data()); + std::println("documented: {}", documentation.formula); + std::println("cited: {}, {}, {}", citation.title, citation.reference, citation.section); for (formula::SymbolEntry const& entry: documentation.symbols) - std::printf("symbol: %.*s = %.*s [%s]\n", - static_cast(entry.symbol.size()), - entry.symbol.data(), - static_cast(entry.description.size()), - entry.description.data(), - std::string { formula::view(entry.unit.symbolText) }.c_str()); + std::println("symbol: {} = {} [{}]", entry.symbol, entry.description, entry.unit); // ---- 3. The four outcomes, checked one at a time ----------------------- - constexpr auto satisfied = formula::check(minimumStrength, strengthOf(45)); - constexpr auto violated = formula::check(minimumStrength, strengthOf(20)); - constexpr auto notChecked = formula::check(minimumStrength, nothingMeasured()); - constexpr auto invalid = formula::check(dividesByZero, strengthOf(0)); - - std::string_view const satisfiedWord = describe(satisfied.kind()); - std::string_view const violatedWord = describe(violated.kind()); - std::string_view const notCheckedWord = describe(notChecked.kind()); - std::string_view const invalidWord = describe(invalid.kind()); - std::string_view const violatedVerdict = violated.verdict()->label; - std::string_view const invalidReason = formula::describe(*invalid.error()); - - std::printf("45 MPa: %.*s\n", static_cast(satisfiedWord.size()), satisfiedWord.data()); - std::printf("20 MPa: %.*s (%.*s)\n", - static_cast(violatedWord.size()), - violatedWord.data(), - static_cast(violatedVerdict.size()), - violatedVerdict.data()); - std::printf("no strength measured: %.*s\n", static_cast(notCheckedWord.size()), notCheckedWord.data()); - std::printf("divides by zero: %.*s (%.*s)\n", - static_cast(invalidWord.size()), - invalidWord.data(), - static_cast(invalidReason.size()), - invalidReason.data()); + constexpr auto satisfied = formula::check(minimumStrength, strength45); + constexpr auto violated = formula::check(minimumStrength, strength20); + constexpr auto notChecked = formula::check(minimumStrength, nothingMeasured); + constexpr auto invalid = formula::check(dividesByZero, strength0); + + // Each outcome is the one it was built to be, or the build stops here -- + // before the verdict and the error below are read. + static_assert(satisfied.is_satisfied() && violated.is_violated() && violated.verdict().has_value() + && notChecked.is_not_checked() && invalid.is_invalid() && invalid.error().has_value()); + + std::println("45 MPa: {}", satisfied.kind()); + std::println("20 MPa: {} ({})", violated.kind(), violated.verdict()->label); + 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 // than only as a printed word: an unresolved check is neither satisfied @@ -154,14 +110,13 @@ int main() bool const notCheckedIsHonest = notChecked.is_not_checked() && !notChecked.is_satisfied() && !notChecked.is_violated(); // ---- 4. Each outcome as its own trace step ------------------------------ - std::string const satisfiedTrace = tracedCheck(minimumStrength, strengthOf(45)); - std::string const violatedTrace = tracedCheck(minimumStrength, strengthOf(20)); - std::string const notCheckedTrace = tracedCheck(minimumStrength, nothingMeasured()); - std::string const invalidTrace = tracedCheck(dividesByZero, strengthOf(0)); - std::printf("%s", satisfiedTrace.c_str()); - std::printf("%s", violatedTrace.c_str()); - std::printf("%s", notCheckedTrace.c_str()); - std::printf("%s", invalidTrace.c_str()); + // + // explain_check() is check() with a recording sink: the outcome, and the + // trace it recorded. + std::print("{}", formula::render_trace(formula::explain_check(minimumStrength, strength45).trace, { .maxSteps = 5 })); + std::print("{}", formula::render_trace(formula::explain_check(minimumStrength, strength20).trace, { .maxSteps = 5 })); + std::print("{}", formula::render_trace(formula::explain_check(minimumStrength, nothingMeasured).trace, { .maxSteps = 5 })); + std::print("{}", formula::render_trace(formula::explain_check(dividesByZero, strength0).trace, { .maxSteps = 5 })); // ---- 5. Checking a SET of constraints never short-circuits ------------- // @@ -176,11 +131,9 @@ int main() // someone back for a second round of testing they should not have // needed. constexpr auto setOutcomes = - formula::check_all(formula::constraints(minimumStrength, maximumDiameter), strengthOnly(20)); - std::string_view const setWord0 = describe(setOutcomes[0].kind()); - std::string_view const setWord1 = describe(setOutcomes[1].kind()); - std::printf("set[0] (minimumStrength): %.*s\n", static_cast(setWord0.size()), setWord0.data()); - std::printf("set[1] (maximumDiameter): %.*s\n", static_cast(setWord1.size()), setWord1.data()); + formula::check_all(formula::constraints(minimumStrength, maximumDiameter), strength20Only); + std::println("set[0] (minimumStrength): {}", setOutcomes[0].kind()); + std::println("set[1] (maximumDiameter): {}", setOutcomes[1].kind()); bool const setCheckedBothWithoutShortCircuit = setOutcomes[0].is_violated() && setOutcomes[1].is_not_checked(); @@ -196,6 +149,6 @@ int main() bool const allChecksPassed = renderedCorrectly && documentedCorrectly && fourOutcomesCorrect && notCheckedIsHonest && setCheckedBothWithoutShortCircuit; - std::printf("all checks passed: %s\n", allChecksPassed ? "yes" : "no"); + std::println("all checks passed: {}", allChecksPassed ? "yes" : "no"); return allChecksPassed ? 0 : 1; } diff --git a/examples/lookup_tables.cpp b/examples/lookup_tables.cpp index 7a930e6d..e8835c64 100644 --- a/examples/lookup_tables.cpp +++ b/examples/lookup_tables.cpp @@ -29,21 +29,21 @@ // Standard references, exactly as every other example in this repository is. #include +#include #include #include #include #include #include -#include -#include -#include +#include #include namespace { namespace unit = formula::unit; using formula::var; +using namespace formula::literals; using Diameter = formula::Quantity; using MeasuredStrength = @@ -52,32 +52,27 @@ using CorrectedStrength = formula::Quantity; using SizeCorrection = formula::Quantity; -[[nodiscard]] constexpr formula::Rational rat(std::int64_t numerator, std::int64_t denominator = 1) -{ - return formula::Rational { numerator, denominator }; -} - // ---- The banded table ------------------------------------------------------- // -// Three bands in millimetres, each declared as a numerator/denominator pair -// for its low (inclusive) and high (EXCLUSIVE) bound. A gap or an overlap -// anywhere in here is a compile error naming the two offending bands -- see -// docs/lookup-tables.md, which shows the diagnostic. +// Three bands in millimetres, each declared as its low (inclusive) and high +// (EXCLUSIVE) bound. A gap or an overlap anywhere in here is a compile error +// naming the two offending bands -- see docs/lookup-tables.md, which shows +// the diagnostic. inline constexpr formula::BandTable<3> SizeBands { - formula::band(0, 1, 127, 1), // 0 to under 127 mm - formula::band(127, 1, 173, 1), // 127 to under 173 mm - formula::band(173, 1, 211, 1), // 173 to under 211 mm -- 211 mm itself is NOT in it + formula::band(0, 127), // 0 to under 127 mm + formula::band(127, 173), // 127 to under 173 mm + formula::band(173, 211), // 173 to under 211 mm -- 211 mm itself is NOT in it }; // The same top row, written the way a published table meaning "173 mm to 211 // mm inclusive" must be written: the high bound at the next tick the domain // can actually take on. `unit::Millimetre` declares one decimal, so that tick -// is 211.1 mm = 2111/10 -- a real, exact number, not an approximation. +// is 211.1 mm -- a real, exact number, not an approximation. // // This is the caller's reconciliation to do, and there is deliberately no // closed-upper-bound flag on `Band` to do it with (band.hpp says why). inline constexpr formula::BandTable<1> TopRowInclusive { - formula::band(173, 1, 2111, 10), // 173 to under 211.1 mm -- 211 mm IS in it + formula::band(173, 211.1_r), // 173 to under 211.1 mm -- 211 mm IS in it }; // ---- The exact table -------------------------------------------------------- @@ -182,9 +177,9 @@ inline constexpr formula::BreakpointTable<3> SizeCurve { // into the published class the method actually applies. Both tables are keyed // in percent here, which is what the curve produces. inline constexpr formula::BandTable<3> ClassBands { - formula::band(837, 10, 973, 10), // 83.7 to under 97.3 % - formula::band(973, 10, 1041, 10), // 97.3 to under 104.1 % - formula::band(1041, 10, 1179, 10), // 104.1 to under 117.9 % + formula::band(83.7_r, 97.3_r), // 83.7 to under 97.3 % + formula::band(97.3_r, 104.1_r), // 97.3 to under 104.1 % + formula::band(104.1_r, 117.9_r), // 104.1 to under 117.9 % }; // The corrections are declared in PERCENT while the factor a formula consumes @@ -194,36 +189,39 @@ inline constexpr formula::BandTable<3> ClassBands { // bands, the keys, the breakpoints, and both units -- lives in the type. [[nodiscard]] constexpr auto sizeFactor() { - return formula::banded_lookup( - var, { rat(913, 10), rat(1051, 10), rat(1127, 10) }); + return formula::yields( + formula::banded_lookup(var, { 91.3_r, 105.1_r, 112.7_r })); } [[nodiscard]] constexpr auto topRowInclusiveFactor() { - return formula::banded_lookup(var, { rat(1127, 10) }); + return formula::yields( + formula::banded_lookup(var, { 112.7_r })); } [[nodiscard]] constexpr auto shapeFactor(LookupExampleShape shape) { - return formula::exact_lookup(shape, { rat(1013, 10), rat(863, 10), rat(931, 10) }); + return formula::yields( + formula::exact_lookup(shape, { 101.3_r, 86.3_r, 93.1_r })); } [[nodiscard]] constexpr auto curingFactor(LookupExampleCuring curing) { - return formula::exact_lookup(curing, { rat(1043, 10), rat(937, 10), rat(881, 10) }); + return formula::yields( + formula::exact_lookup(curing, { 104.3_r, 93.7_r, 88.1_r })); } [[nodiscard]] constexpr auto sizeCurveFactor() { - return formula::interpolating_lookup( - var, { rat(913, 10), rat(1051, 10), rat(1127, 10) }); + return formula::yields( + formula::interpolating_lookup(var, { 91.3_r, 105.1_r, 112.7_r })); } // The nested shape: a banded lookup whose operand is an interpolating lookup. [[nodiscard]] constexpr auto classFactor() { - return formula::banded_lookup(sizeCurveFactor(), - { rat(919, 10), rat(1013, 10), rat(1087, 10) }); + return formula::yields(formula::banded_lookup( + sizeCurveFactor().expression, { 91.9_r, 101.3_r, 108.7_r })); } // The whole method: a measured strength corrected by two tables at once. The @@ -233,64 +231,23 @@ inline constexpr formula::BandTable<3> ClassBands { // node is a cheap aggregate. [[nodiscard]] constexpr auto correctedStrength(LookupExampleShape shape) { - return formula::documented(var * sizeFactor() * shapeFactor(shape), - { .title = "Corrected compressive strength", - .reference = "Example Standard 8:2020", - .section = "7.3", - .equation = "(5)", - .text = "The measured strength is corrected for specimen size and for specimen " - "shape, each factor taken from the table the method publishes for it." }); -} - -[[nodiscard]] constexpr auto diameterOf(std::int64_t millimetres) -{ - return formula::environment(formula::Measured { rat(millimetres) }); -} - -/// Evaluates @p node for @p Result and returns the exact rational it produced, -/// or nothing when it did not produce one. -template -[[nodiscard]] constexpr std::optional valueOf(N const& node, Env const& environment) -{ - auto const outcome = formula::checked_evaluate(node, environment); - if (!outcome.has_value() || !outcome->is_value()) - return std::nullopt; - return outcome->measurement().value(); + return formula::yields( + formula::documented(var * sizeFactor().expression * shapeFactor(shape).expression, + { .title = "Corrected compressive strength", + .reference = "Example Standard 8:2020", + .section = "7.3", + .equation = "(5)", + .text = "The measured strength is corrected for specimen size and for specimen " + "shape, each factor taken from the table the method publishes for it." })); } -/// The `ArithmeticError` @p node produced, or nothing when it produced a value. -template -[[nodiscard]] constexpr std::optional errorOf(N const& node, Env const& environment) -{ - auto const outcome = formula::checked_evaluate(node, environment); - if (outcome.has_value()) - return std::nullopt; - return outcome.error(); -} - -/// Evaluates @p node through a fresh `RecordingSink` and renders the trace. -/// -/// Built by hand rather than through `formula::explain()`, and the reason is -/// worth knowing: `explain()` goes through the **throwing** `evaluate()`, so a -/// miss -- which is an ordinary outcome for a lookup, not a defect -- would -/// throw instead of handing back the derivation that says why it missed. -/// `checked_evaluate` with your own sink reports the miss and keeps the trace. -template -[[nodiscard]] std::string tracedEvaluation(N const& node, Env const& environment) -{ - formula::Trace<> trace {}; - formula::RecordingSink<> sink { trace }; - [[maybe_unused]] auto const outcome = formula::checked_evaluate(node, environment, sink); - return formula::render_trace(trace, { .maxSteps = 10 }); -} - -/// An exact rational as text: `4`, or `41/40` when it is not whole. -[[nodiscard]] std::string exact_text(formula::Rational value) -{ - if (value.denominator() == 1) - return std::to_string(value.numerator()); - return std::to_string(value.numerator()) + "/" + std::to_string(value.denominator()); -} +constexpr auto diameter127 = formula::environment(formula::Measured { 127 }); +constexpr auto diameter139 = formula::environment(formula::Measured { 139 }); +constexpr auto diameter211 = formula::environment(formula::Measured { 211 }); +constexpr auto diameter233 = formula::environment(formula::Measured { 233 }); +constexpr auto specimen = + formula::environment(formula::Measured { 40 }, formula::Measured { 139 }); +constexpr auto noInputs = formula::environment(); } // namespace @@ -305,19 +262,21 @@ int main() // project's own documentation. The Markdown rendering below carries no // square bracket at all, and the assertion at the bottom of this file // checks that rather than trusting it. - std::printf("banded: %s\n", formula::render(sizeFactor()).c_str()); - std::string const bandedMarkdown = formula::render(sizeFactor()); - std::printf("banded (md): %s\n", bandedMarkdown.c_str()); + std::println("banded: {}", formula::render(sizeFactor())); + auto const bandedMarkdown = formula::render(sizeFactor()); + std::println("banded (md): {}", bandedMarkdown); - std::optional const at139 = valueOf(sizeFactor(), diameterOf(139)); - std::printf("d = 139 mm: %s\n", exact_text(*at139).c_str()); + constexpr auto at139 = formula::checked_evaluate(sizeFactor(), diameter139); + static_assert(at139.has_value()); + std::println("d = 139 mm: {}", *at139); // ---- 2. Bands are half-open, and the boundary belongs to the band above -- // // 127 mm is the boundary the first two bands share. It belongs to // the band whose LOW bound it is, never the band whose high bound it is. - std::optional const at127 = valueOf(sizeFactor(), diameterOf(127)); - std::printf("d = 127 mm: %s (the band above the boundary, never the one below)\n", exact_text(*at127).c_str()); + constexpr auto at127 = formula::checked_evaluate(sizeFactor(), diameter127); + static_assert(at127.has_value()); + std::println("d = 127 mm: {} (the band above the boundary, never the one below)", *at127); // ---- 3. The table's own top bound is excluded, and that is the caller's -- // reconciliation to do @@ -326,31 +285,42 @@ int main() // published row meaning "173 mm to 211 mm inclusive" is written with its // high bound at the next tick past 211 -- 211.1 mm, one decimal being what // unit::Millimetre declares. - std::optional const at211Missed = errorOf(sizeFactor(), diameterOf(211)); - std::optional const at211Inclusive = - valueOf(topRowInclusiveFactor(), diameterOf(211)); - std::string_view const missText = formula::describe(*at211Missed); - std::printf("d = 211 mm: %.*s\n", static_cast(missText.size()), missText.data()); - std::printf("inclusive top: %s\n", formula::render(topRowInclusiveFactor()).c_str()); - std::printf("d = 211 mm: %s\n", exact_text(*at211Inclusive).c_str()); + auto const at211Missed = formula::checked_explain(sizeFactor(), diameter211); + if (at211Missed) + { + std::println("d = 211 mm: the band table gave a value, where it must miss"); + return 1; + } + constexpr auto at211Inclusive = formula::checked_evaluate(topRowInclusiveFactor(), diameter211); + static_assert(at211Inclusive.has_value()); + std::println("d = 211 mm: {}", at211Missed.error().error); + std::println("inclusive top: {}", formula::render(topRowInclusiveFactor())); + std::println("d = 211 mm: {}", *at211Inclusive); // ---- 4. An interpolating table computes a number no row contains -------- // // ... and its domain is CLOSED at both ends, deliberately unlike a band // table's. The same 211 mm that missed above is this table's last row, and // a row is a value the table states, not a boundary between two of them. - std::printf("interpolating: %s\n", formula::render(sizeCurveFactor()).c_str()); - - std::optional const curveAt139 = valueOf(sizeCurveFactor(), diameterOf(139)); - std::optional const curveAt211 = valueOf(sizeCurveFactor(), diameterOf(211)); - std::optional const curveAt233 = errorOf(sizeCurveFactor(), diameterOf(233)); - std::string_view const curveMissText = formula::describe(*curveAt233); - std::printf("d = 139 mm: %s (between two rows -- in neither of them)\n", exact_text(*curveAt139).c_str()); - std::printf("d = 211 mm: %s (the last row, reached -- where the band table missed)\n", - exact_text(*curveAt211).c_str()); - std::printf("d = 233 mm: %.*s (no extrapolation past the last row)\n", - static_cast(curveMissText.size()), - curveMissText.data()); + std::println("interpolating: {}", formula::render(sizeCurveFactor())); + + auto const curveAt139 = formula::checked_explain(sizeCurveFactor(), diameter139); + if (!curveAt139) + { + std::println("interpolating at 139 mm: {}", curveAt139.error().error); + return 1; + } + auto const curveAt211 = formula::checked_explain(sizeCurveFactor(), diameter211); + if (!curveAt211) + { + std::println("interpolating at 211 mm: {}", curveAt211.error().error); + return 1; + } + constexpr auto curveAt233 = formula::checked_evaluate(sizeCurveFactor(), diameter233); + static_assert(!curveAt233.has_value()); + std::println("d = 139 mm: {} (between two rows -- in neither of them)", curveAt139->outcome); + std::println("d = 211 mm: {} (the last row, reached -- where the band table missed)", curveAt211->outcome); + std::println("d = 233 mm: {} (no extrapolation past the last row)", curveAt233.error()); // ---- 5. An exact lookup: a category key names a row --------------------- // @@ -358,48 +328,55 @@ int main() // compile time. Only a key that names no row of the table -- the miss -- // falls back to its underlying value (`key 13`), and a trace of the miss // says the same. - std::printf("exact: %s\n", formula::render(shapeFactor(LookupExampleShape::Cylinder)).c_str()); - std::printf("exact miss: %s\n", formula::render(shapeFactor(LookupExampleShape::DrilledCore)).c_str()); - std::printf( - "%s", - tracedEvaluation(shapeFactor(LookupExampleShape::DrilledCore), formula::environment()).c_str()); - - std::optional const cylinder = - valueOf(shapeFactor(LookupExampleShape::Cylinder), formula::environment()); - std::optional const core = - errorOf(shapeFactor(LookupExampleShape::DrilledCore), formula::environment()); - std::string_view const coreMissText = formula::describe(*core); - std::printf("Cylinder: %s\n", exact_text(*cylinder).c_str()); - std::printf("DrilledCore: %.*s (a key no row of the table names)\n", - static_cast(coreMissText.size()), - coreMissText.data()); + std::println("exact: {}", formula::render(shapeFactor(LookupExampleShape::Cylinder))); + std::println("exact miss: {}", formula::render(shapeFactor(LookupExampleShape::DrilledCore))); + auto const coreMissed = formula::checked_explain(shapeFactor(LookupExampleShape::DrilledCore), noInputs); + if (coreMissed) + { + std::println("DrilledCore: the exact table gave a value, where it must miss"); + return 1; + } + std::print("{}", formula::render_trace(coreMissed.error().trace, { .maxSteps = 10 })); + + constexpr auto cylinder = formula::checked_evaluate(shapeFactor(LookupExampleShape::Cylinder), noInputs); + static_assert(cylinder.has_value()); + std::println("Cylinder: {}", *cylinder); + std::println("DrilledCore: {} (a key no row of the table names)", coreMissed.error().error); // ---- 5a. An exact lookup whose keys the author spells --------------------- // // `EnumeratorName` words two rows the way the // published table does and leaves `Air` under its own name. render() and // the trace both follow it. - std::printf("customized: %s\n", formula::render(curingFactor(LookupExampleCuring::Sealed)).c_str()); - std::printf("%s", - tracedEvaluation(curingFactor(LookupExampleCuring::Sealed), formula::environment()).c_str()); - std::optional const sealed = - valueOf(curingFactor(LookupExampleCuring::Sealed), formula::environment()); + std::println("customized: {}", formula::render(curingFactor(LookupExampleCuring::Sealed))); + auto const sealed = formula::checked_explain(curingFactor(LookupExampleCuring::Sealed), noInputs); + if (!sealed) + { + std::println("customized: {}", sealed.error().error); + return 1; + } + std::print("{}", formula::render_trace(sealed->trace, { .maxSteps = 10 })); // ---- 6. A lookup nested inside another lookup's operand ------------------ // // A curve produces a continuous factor; a band table buckets it into the - // published class. All four surfaces at once: it renders, it documents, it - // evaluates, and it traces. - std::printf("nested: %s\n", formula::render(classFactor()).c_str()); + // published class. All four surfaces at once: it renders, it documents, + // it evaluates, and it traces. + std::println("nested: {}", formula::render(classFactor())); - std::optional const nestedAt139 = valueOf(classFactor(), diameterOf(139)); - std::printf("d = 139 mm: %s (curve gives 94.9 %%, which falls in the 83.7-to-under-97.3 %% band)\n", - exact_text(*nestedAt139).c_str()); + auto const nestedAt139 = formula::checked_explain(classFactor(), diameter139); + if (!nestedAt139) + { + std::println("nested at 139 mm: {}", nestedAt139.error().error); + return 1; + } + std::println("d = 139 mm: {} (curve gives 94.9 %, which falls in the 83.7-to-under-97.3 % band)", + nestedAt139->outcome); formula::Documentation const nestedDocumentation = formula::document(classFactor()); - std::printf("nested symbols: %zu\n", nestedDocumentation.symbols.size()); + std::println("nested symbols: {}", nestedDocumentation.symbols.size()); - std::printf("%s", tracedEvaluation(classFactor(), diameterOf(139)).c_str()); + std::print("{}", formula::render_trace(nestedAt139->trace, { .maxSteps = 10 })); // ---- 6a. An interpolating lookup's own trace clause, in both its forms --- // @@ -410,38 +387,28 @@ int main() // rows the answer appears in neither and a reader has an interpolation to // check; on a row the table stated the number directly and there is // nothing to check. - std::printf("%s", tracedEvaluation(sizeCurveFactor(), diameterOf(139)).c_str()); - std::printf("%s", tracedEvaluation(sizeCurveFactor(), diameterOf(211)).c_str()); + std::print("{}", formula::render_trace(curveAt139->trace, { .maxSteps = 10 })); + std::print("{}", formula::render_trace(curveAt211->trace, { .maxSteps = 10 })); // ---- 7. The whole method, rendered and documented ------------------------ auto const method = correctedStrength(LookupExampleShape::Cylinder); formula::Documentation const documentation = formula::document(method); formula::Citation const& citation = documentation.citations.front(); - std::printf("method: %s\n", documentation.formula.c_str()); - std::printf("cited: %.*s, %.*s, %.*s %.*s\n", - static_cast(citation.title.size()), - citation.title.data(), - static_cast(citation.reference.size()), - citation.reference.data(), - static_cast(citation.section.size()), - citation.section.data(), - static_cast(citation.equation.size()), - citation.equation.data()); + std::println("method: {}", documentation.formula); + std::println("cited: {}, {}, {} {}", citation.title, citation.reference, citation.section, citation.equation); for (formula::SymbolEntry const& entry: documentation.symbols) - std::printf("symbol: %.*s = %.*s [%s]\n", - static_cast(entry.symbol.size()), - entry.symbol.data(), - static_cast(entry.description.size()), - entry.description.data(), - std::string { formula::view(entry.unit.symbolText) }.c_str()); + std::println("symbol: {} = {} [{}]", entry.symbol, entry.description, entry.unit); // ---- 8. The method evaluated, and its derivation ------------------------- - auto const specimen = - formula::environment(formula::Measured { rat(40) }, formula::Measured { rat(139) }); - std::optional const corrected = valueOf(method, specimen); - std::printf("f_c: %s MPa\n", exact_text(*corrected).c_str()); - std::printf("%s", tracedEvaluation(method, specimen).c_str()); + auto const corrected = formula::checked_explain(method, specimen); + if (!corrected) + { + std::println("f_c: {}", corrected.error().error); + return 1; + } + std::println("f_c: {}", corrected->outcome); + std::print("{}", formula::render_trace(corrected->trace, { .maxSteps = 10 })); // ---- 9. A miss, traced --------------------------------------------------- // @@ -449,21 +416,22 @@ int main() // bracketed clause says the value fell in no band AND what the table // actually covers, so a reader can tell a miss from a failure relayed // upward from the operand. - std::printf("%s", tracedEvaluation(sizeFactor(), diameterOf(211)).c_str()); + std::print("{}", formula::render_trace(at211Missed.error().trace, { .maxSteps = 10 })); // ---- Every claim printed above, verified in code ------------------------- - bool const bandedSelects = at139 == rat(1051, 1000) && at127 == rat(1051, 1000); - bool const markdownCarriesNoBracket = bandedMarkdown.find('[') == std::string::npos; - bool const topBoundExcluded = at211Missed == formula::ArithmeticError::DomainError; - bool const nextTickReachesIt = at211Inclusive == rat(1127, 1000); - bool const curveComputes = curveAt139 == rat(949, 1000); - bool const curveTopIncluded = curveAt211 == rat(1127, 1000); - bool const noExtrapolation = curveAt233 == formula::ArithmeticError::DomainError; - bool const exactSelects = cylinder == rat(863, 1000); - bool const absentKeyMisses = core == formula::ArithmeticError::DomainError; - bool const customizedSelects = sealed == rat(937, 1000); - bool const nestedComposes = nestedAt139 == rat(919, 1000) && nestedDocumentation.symbols.size() == 1; - bool const methodEvaluates = corrected == rat(907013, 25000); + bool const bandedSelects = formula::number_of(at139) == 1.051_r && formula::number_of(at127) == 1.051_r; + bool const markdownCarriesNoBracket = !bandedMarkdown.contains('['); + bool const topBoundExcluded = at211Missed.error().error == formula::ArithmeticError::DomainError; + bool const nextTickReachesIt = formula::number_of(at211Inclusive) == 1.127_r; + bool const curveComputes = formula::number_of(curveAt139->outcome) == 0.949_r; + bool const curveTopIncluded = formula::number_of(curveAt211->outcome) == 1.127_r; + bool const noExtrapolation = curveAt233.error() == formula::ArithmeticError::DomainError; + bool const exactSelects = formula::number_of(cylinder) == 0.863_r; + bool const absentKeyMisses = coreMissed.error().error == formula::ArithmeticError::DomainError; + bool const customizedSelects = formula::number_of(sealed->outcome) == 0.937_r; + bool const nestedComposes = + formula::number_of(nestedAt139->outcome) == 0.919_r && nestedDocumentation.symbols.size() == 1; + bool const methodEvaluates = formula::number_of(corrected->outcome) == 36.28052_r; // The two domains disagree at 211 mm, and that disagreement is the point: // a band's top is excluded, a curve's last row is a row. @@ -472,6 +440,6 @@ int main() bool const allChecksPassed = bandedSelects && markdownCarriesNoBracket && topBoundExcluded && nextTickReachesIt && curveComputes && curveTopIncluded && noExtrapolation && exactSelects && absentKeyMisses && customizedSelects && nestedComposes && methodEvaluates && domainsDisagreeOnPurpose; - std::printf("all checks passed: %s\n", allChecksPassed ? "yes" : "no"); + std::println("all checks passed: {}", allChecksPassed ? "yes" : "no"); return allChecksPassed ? 0 : 1; } diff --git a/examples/rounding_and_conditionals.cpp b/examples/rounding_and_conditionals.cpp index cad247e9..056ddd0b 100644 --- a/examples/rounding_and_conditionals.cpp +++ b/examples/rounding_and_conditionals.cpp @@ -4,8 +4,8 @@ // // Three additions to the node vocabulary, in one program: // -// - rounded() and -// rounded_to_digits() round at a +// - rounded() and rounded_to_digits(), +// each rounding named once (a unit, a count, a tie rule), round at a // stated POSITION in a formula, not only on the printed result -- so // rounding an intermediate value and rounding only at the end are two // different formulas, and in general two different answers, even though @@ -27,8 +27,13 @@ #include #include -#include -#include +#include +#include +#include +#include +#include + +#include namespace { @@ -37,36 +42,39 @@ using formula::DecimalPlaces; using formula::RoundingMode; using formula::SignificantDigits; using formula::var; +using namespace formula::literals; using Diameter = formula::Quantity; using Strength = formula::Quantity; using CorrectionFactor = formula::Quantity; +// ---- The roundings the method states, each named once ----------------------- +constexpr formula::DecimalRounding wholeMillimetre { unit::Millimetre, DecimalPlaces { 0 }, RoundingMode::HalfAwayFromZero }; +constexpr formula::DecimalRounding tenthMillimetre { unit::Millimetre, DecimalPlaces { 1 }, RoundingMode::HalfAwayFromZero }; +constexpr formula::SignificantRounding twoDigitsOfMillimetre { + unit::Millimetre, SignificantDigits { 2 }, RoundingMode::HalfAwayFromZero +}; + // ---- 1: intermediate vs. final rounding, same formula shape, same input --- // // A method may say "round the diameter to the nearest millimetre before // doubling it" -- a coarse instrument that only ever reads whole millimetres // -- or "double the diameter, then round the result to one decimal place". // Both are legitimate specifications, and they are not the same formula. -constexpr auto coarseInput = - formula::rounded(var); +constexpr auto coarseInput = formula::rounded(var); constexpr auto roundThenDouble = coarseInput + coarseInput; -constexpr auto doubleThenRound = - formula::rounded(var + var); +constexpr auto doubleThenRound = formula::rounded(var + var); // ---- 2: rounding to significant digits, rather than decimal places -------- -constexpr auto toTwoSignificantDigits = - formula::rounded_to_digits(var); +constexpr auto toTwoSignificantDigits = formula::rounded_to_digits(var); // ---- 3: a numeric threshold selects between two formulas ------------------- // // A method that reports a large specimen to the nearest millimetre and a // small one to one decimal place: the threshold is itself part of the // formula, not an if/else the caller has to remember to apply consistently. -constexpr auto sizeAdjustedDiameter = - formula::when(var > formula::constant(formula::Rational { 173, 10 }), - formula::rounded(var), - formula::rounded(var)); +constexpr auto sizeAdjustedDiameter = formula::yields(formula::when( + var > formula::constant(17.3_r), coarseInput, formula::rounded(var))); // ---- 4: the traced escape hatch -------------------------------------------- // @@ -74,81 +82,71 @@ constexpr auto sizeAdjustedDiameter = // against the strength's numeric value in megapascals -- a rule that cannot // be stated over the Strength quantity itself without lying about what makes // it work in the first place. -constexpr auto empiricalCorrection = +constexpr auto empiricalCorrection = formula::yields( formula::numeric_value_of(var) - * formula::Rational { 213, 10000 } - - formula::Rational { 1043, 1000 }; + * 0.0213_r + - 1.043_r); -[[nodiscard]] constexpr auto millimetres(long long hundredths) -{ - return formula::environment(formula::Measured { formula::Rational { hundredths, 100 } }); -} +constexpr auto diameter12_50 = formula::environment(formula::Measured { 12.5_r }); +constexpr auto diameter12_34 = formula::environment(formula::Measured { 12.34_r }); // at or below the threshold +constexpr auto diameter25_40 = formula::environment(formula::Measured { 25.4_r }); // above the threshold +constexpr auto strength70 = formula::environment(formula::Measured { 70 }); } // namespace int main() { // ---- 1. Same formula shape, same input, two different answers -------- - std::printf("rendered: %s\n", formula::render(doubleThenRound).c_str()); + std::println("rendered: {}", formula::render(doubleThenRound)); - constexpr auto measured = millimetres(1250); // 12.50 mm - constexpr auto early = formula::checked_evaluate(roundThenDouble, measured); - constexpr auto late = formula::checked_evaluate(doubleThenRound, measured); - std::printf("%-36s= %f mm\n", "12.50 mm, round to 0 dp then double", early->measurement().value().to_double()); - std::printf("%-36s= %f mm\n", "12.50 mm, double then round to 1 dp", late->measurement().value().to_double()); + constexpr auto early = formula::checked_evaluate(roundThenDouble, diameter12_50); + constexpr auto late = formula::checked_evaluate(doubleThenRound, diameter12_50); + static_assert(early.has_value() && late.has_value()); + std::println("{:<36}= {}", "12.50 mm, round to 0 dp then double", *early); + std::println("{:<36}= {}", "12.50 mm, double then round to 1 dp", *late); // ---- 2. Rounding to significant digits, not decimal places ------------ - constexpr auto sigFigInput = millimetres(1234); // 12.34 mm - constexpr auto sigFigResult = formula::checked_evaluate(toTwoSignificantDigits, sigFigInput); - std::printf("%-36s= %f mm\n", "12.34 mm to 2 significant digits", sigFigResult->measurement().value().to_double()); + constexpr auto sigFigResult = formula::checked_evaluate(toTwoSignificantDigits, diameter12_34); + static_assert(sigFigResult.has_value()); + std::println("{:<36}= {}", "12.34 mm to 2 significant digits", *sigFigResult); // ---- 3. A numeric threshold selects between two formulas -------------- - std::printf("rendered: %s\n", formula::render(sizeAdjustedDiameter).c_str()); + std::println("rendered: {}", formula::render(sizeAdjustedDiameter)); - constexpr auto smallSpecimen = millimetres(1234); // 12.34 mm -- at or below the threshold - constexpr auto largeSpecimen = millimetres(2540); // 25.40 mm -- above the threshold - constexpr auto smallResult = formula::checked_evaluate(sizeAdjustedDiameter, smallSpecimen); - constexpr auto largeResult = formula::checked_evaluate(sizeAdjustedDiameter, largeSpecimen); - std::printf("%-36s= %f mm\n", "12.34 mm, size-adjusted rounding", smallResult->measurement().value().to_double()); - std::printf("%-36s= %f mm\n", "25.40 mm, size-adjusted rounding", largeResult->measurement().value().to_double()); + constexpr auto smallResult = formula::checked_evaluate(sizeAdjustedDiameter, diameter12_34); + constexpr auto largeResult = formula::checked_evaluate(sizeAdjustedDiameter, diameter25_40); + static_assert(smallResult.has_value() && largeResult.has_value()); + std::println("{:<36}= {}", "12.34 mm, size-adjusted rounding", *smallResult); + std::println("{:<36}= {}", "25.40 mm, size-adjusted rounding", *largeResult); // The trace names which branch a when() took -- here, the "then" branch, // because 25.40 mm is above the 17.3 mm threshold. - formula::Explained const explainedLarge = formula::explain(sizeAdjustedDiameter, largeSpecimen); - std::string const conditionalTrace = formula::render_trace(explainedLarge.trace, { .maxSteps = 10 }); - std::printf("%s", conditionalTrace.c_str()); + auto const explainedLarge = formula::explain(sizeAdjustedDiameter, diameter25_40); + std::print("{}", formula::render_trace(explainedLarge.trace, { .maxSteps = 10 })); // ---- 4. The traced escape hatch ---------------------------------------- - std::printf("rendered: %s\n", formula::render(empiricalCorrection).c_str()); + std::println("rendered: {}", formula::render(empiricalCorrection)); - constexpr auto strengthKnown = formula::environment(formula::Measured { formula::Rational { 70 } }); - constexpr auto correction = formula::checked_evaluate(empiricalCorrection, strengthKnown); - std::printf("empirical correction factor at 70 MPa = %f\n", correction->measurement().value().to_double()); + constexpr auto correction = formula::checked_evaluate(empiricalCorrection, strength70); + static_assert(correction.has_value()); + std::println("empirical correction factor at 70 MPa = {}", *correction); - formula::Explained const explainedCorrection = - formula::explain(empiricalCorrection, strengthKnown); - std::string const escapeTrace = formula::render_trace(explainedCorrection.trace, { .maxSteps = 10 }); - std::printf("%s", escapeTrace.c_str()); + auto const explainedCorrection = formula::explain(empiricalCorrection, strength70); + std::print("{}", formula::render_trace(explainedCorrection.trace, { .maxSteps = 10 })); // Every number printed above is checked here; nothing is printed that // this bool does not also cover. - bool const intermediateAndFinalRoundingDiffer = early.has_value() && early->is_value() && late.has_value() - && late->is_value() - && early->measurement().value() == formula::Rational { 26 } - && late->measurement().value() == formula::Rational { 25 } - && early->measurement().value() != late->measurement().value(); - bool const significantDigitsCorrect = sigFigResult.has_value() && sigFigResult->is_value() - && sigFigResult->measurement().value() == formula::Rational { 12 }; - bool const conditionalPickedTheRightBranch = smallResult.has_value() && smallResult->is_value() - && largeResult.has_value() && largeResult->is_value() - && smallResult->measurement().value() == formula::Rational { 123, 10 } - && largeResult->measurement().value() == formula::Rational { 25 }; - bool const escapeHatchCorrect = - correction.has_value() && correction->is_value() && correction->measurement().value() == formula::Rational { 56, 125 }; + bool const intermediateAndFinalRoundingDiffer = + formula::number_of(early) == 26_r && formula::number_of(late) == 25_r + && formula::number_of(early) != formula::number_of(late); + bool const significantDigitsCorrect = formula::number_of(sigFigResult) == 12_r; + bool const conditionalPickedTheRightBranch = + formula::number_of(smallResult) == 12.3_r && formula::number_of(largeResult) == 25_r; + bool const escapeHatchCorrect = formula::number_of(correction) == 0.448_r; bool const allChecksPassed = intermediateAndFinalRoundingDiffer && significantDigitsCorrect && conditionalPickedTheRightBranch && escapeHatchCorrect; - std::printf("all checks passed: %s\n", allChecksPassed ? "yes" : "no"); + std::println("all checks passed: {}", allChecksPassed ? "yes" : "no"); return allChecksPassed ? 0 : 1; } From 8d1a95f4eb0f3a706e3059f7c2c4cc38b498a2c8 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 21:27:36 +0200 Subject: [PATCH 29/59] docs(examples): write the methods, records and series examples in the short spellings The three examples and the guides that quote them now read the way the library is meant to be used: exact decimals with _r, inputs as Measured { n }, series as measured_series(130, 210, 95), bands as band(low, high), the method's rounding named once as a DecimalRounding, and a formula evaluated repeatedly bound with yields. The hand-built trace blocks are explain_method, explain_check_method, explain_series, explain_curve and explain_conformity, or traced around a verb that has no twin, and each evaluation is run once and read for both its value and its derivation. The local rat, m, exact, trace and outcome_word helpers are gone; constraint outcomes print with the library's own formatting. Every checked result is tested before it is read, and everything prints with std::println. The programs print exactly what they printed before, so the guides' quoted output is unchanged; only their quoted code moved. Signed-off-by: Christian Parpart --- docs/methods-and-overlays.md | 47 ++-- docs/records.md | 16 +- docs/series.md | 21 +- examples/methods_and_overlays.cpp | 275 ++++++++++--------- examples/records.cpp | 227 ++++++++------- examples/series.cpp | 440 +++++++++++++++--------------- 6 files changed, 547 insertions(+), 479 deletions(-) diff --git a/docs/methods-and-overlays.md b/docs/methods-and-overlays.md index 553cb077..88c15b0f 100644 --- a/docs/methods-and-overlays.md +++ b/docs/methods-and-overlays.md @@ -33,16 +33,24 @@ the check skips. No code block on this page carries one. ## A method: variants, tags, and one rounding rule A method is built from three parts, always in this order: the variants, the -rounding rule, and the constraints. +rounding rule, and the constraints. The rounding rule is named once, as a +`DecimalRounding`: a unit, how many decimal places of it to keep, and which way +to break ties. + +```cpp +inline constexpr formula::DecimalRounding tenthMpa { unit::Megapascal, + formula::DecimalPlaces { 1 }, + formula::RoundingMode::HalfAwayFromZero }; +``` ```cpp inline constexpr auto compressiveStrength = formula::method( formula::variants(formula::variant(var / (var * var)), formula::variant(var * var / (var * var)), - formula::variant(formula::constant(rat(4)) * var + formula::variant(formula::constant(4_r) * var / (formula::pi * formula::pow<2>(var)))), - formula::rounding_rule(), - formula::constraints(formula::constraint(var >= formula::constant(rat(473, 10)), + formula::rounding_rule(), + formula::constraints(formula::constraint(var >= formula::constant(47.3_r), formula::Verdict { "the load at failure is below 47.3 kN" }))); ``` @@ -63,8 +71,12 @@ inline constexpr auto compressiveStrength = formula::method( never deduced: which variant applies is a property of the specimen, and the caller says what the specimen is. +The example calls `explain_method`, which is `evaluate_method` with a +recording sink. It returns what `evaluate_method` returned in `outcome`, and +the derivation in `trace`, from one evaluation: + ```cpp -auto const cube = formula::evaluate_method(compressiveStrength, specimen); +auto const cube = formula::explain_method(compressiveStrength, specimen); ``` ```text @@ -247,7 +259,7 @@ static assertion failed: formula: pin_variant() was given no citation; whic ```cpp inline constexpr auto north = - formula::overlay(formula::with_constant(rat(863, 1000), northConstant), + formula::overlay(formula::with_constant(0.863_r, northConstant), formula::with_rounding(northRounding)); @@ -291,7 +303,7 @@ measurements](quantities.md)), so a number can never disagree with its label. ```cpp inline constexpr auto south = formula::overlay( formula::replace_variant( - var / (formula::constant(rat(1127, 1000)) * formula::pow<2>(var)), southReplacement), + var / (formula::constant(1.127_r) * formula::pow<2>(var)), southReplacement), formula::add_derived(var / var, southDefinition), formula::prune_variant(southScope)); ``` @@ -501,9 +513,9 @@ replaces it with two checks of its own: ```cpp inline constexpr auto west = formula::overlay(formula::with_constraints( - formula::constraints(formula::constraint(var >= formula::constant(rat(973, 10)), + formula::constraints(formula::constraint(var >= formula::constant(97.3_r), formula::Verdict { "the load at failure is below 97.3 kN" }), - formula::constraint(var <= formula::number(rat(173, 100)) * var, + formula::constraint(var <= formula::number(1.73_r) * var, formula::Verdict { "the loaded face is more than 1.73 times as long as wide" })), westAcceptance)); ``` @@ -514,10 +526,14 @@ it. It never stops at the first failure, for the reason [Constraints and verdicts](constraints.md) gives: ```cpp -auto const baseOutcomes = formula::check_method(compressiveStrength, specimen); -auto const westOutcomes = formula::check_method(western, specimen); +auto const baseCheck = formula::explain_check_method(compressiveStrength, specimen); +auto const westCheck = formula::explain_check_method(western, specimen); ``` +`explain_check_method` is `check_method` with a recording sink: it returns the +outcomes in `outcome` and the derivation in `trace`, from one run. Here are the +outcomes: + ```text base: 1 constraint(s) [0] satisfied @@ -656,7 +672,7 @@ Two limits, stated here so that nobody mistakes them for supported cases: /// A later revision of the west's annex, applied on top of the west's method: /// its one constraint is all the stacked method checks. inline constexpr auto westRevised = formula::overlay(formula::with_constraints( - formula::constraints(formula::constraint(var >= formula::constant(rat(831, 10)), + formula::constraints(formula::constraint(var >= formula::constant(83.1_r), formula::Verdict { "the load at failure is below 83.1 kN" })), westRevision)); @@ -744,12 +760,11 @@ the southern page's symbol table: ``` The symbol moves and the meaning stays: in the south, `b` is the first loaded -edge. A trace records symbols **when the method is evaluated**, so the sink -must be given the same vocabulary as the page: +edge. A trace records symbols **when the method is evaluated**, so the trace +must be recorded with the same vocabulary as the page: ```cpp -formula::Trace<> trace {}; -(void) formula::evaluate_method(compressiveStrength, specimen, formula::RecordingSink { trace, southernWords }); +auto const southernRun = formula::explain_method(compressiveStrength, specimen, southernWords); ``` ```text diff --git a/docs/records.md b/docs/records.md index 776bdd9e..1999addd 100644 --- a/docs/records.md +++ b/docs/records.md @@ -51,14 +51,14 @@ using EdgeY = formula::Quantity { formula::Rational { 30 } }, - formula::Measured { formula::Rational { 579'630 } }, - formula::Measured { formula::Rational { 139 } }, - formula::Measured { formula::Rational { 139 } }); -constexpr auto there = formula::environment(formula::entered(formula::Measured { formula::Rational { 20 } }), - formula::Measured { formula::Rational { 386'420 } }, - formula::Measured { formula::Rational { 139 } }, - formula::Measured { formula::Rational { 139 } }); +constexpr auto here = formula::environment(formula::Measured { 30 }, + formula::Measured { 579'630 }, + formula::Measured { 139 }, + formula::Measured { 139 }); +constexpr auto there = formula::environment(formula::entered(formula::Measured { 20 }), + formula::Measured { 386'420 }, + formula::Measured { 139 }, + formula::Measured { 139 }); ``` The formula that divides this specimen's strength by the reference's reads diff --git a/docs/series.md b/docs/series.md index 2ed3a485..46da5c60 100644 --- a/docs/series.md +++ b/docs/series.md @@ -29,11 +29,16 @@ The percentage passing each screen is everything not retained on that screen or on a coarser one: ```cpp -inline constexpr auto passing = - formula::constant(rat(100)) - - formula::cumulative(formula::series) / var; +inline constexpr auto passing = formula::yields( + formula::constant(100_r) + - formula::cumulative(formula::series) / var); ``` +`yields` names the quantity the formula computes, once, where it is +written. Everything that evaluates or traces the formula then takes it as it +is, with no result to repeat, and a formula built on it reuses it through +`.expression`. + A series is its own family of expressions. It is deliberately **not** a `Node`, the library's name for an expression that yields one value ([Expressions and evaluation](expressions.md)). Every `Node` promises one @@ -241,11 +246,11 @@ a `Node`: it produces verdicts, not a quantity. ```cpp inline constexpr formula::Envelope<5> gradingEnvelope { - formula::LimitRow { formula::limit(rat(31)), formula::limit(rat(43)) }, - formula::LimitRow { formula::limit(rat(47)), formula::limit(rat(59)) }, - formula::LimitRow { formula::limit(rat(1574, 25)), formula::unbounded }, - formula::LimitRow { formula::limit(rat(61)), formula::limit(rat(79)) }, - formula::LimitRow { formula::limit(rat(83)), formula::limit(rat(99)) } + formula::LimitRow { formula::limit(31_r), formula::limit(43_r) }, + formula::LimitRow { formula::limit(47_r), formula::limit(59_r) }, + formula::LimitRow { formula::limit(62.96_r), formula::unbounded }, + formula::LimitRow { formula::limit(61_r), formula::limit(79_r) }, + formula::LimitRow { formula::limit(83_r), formula::limit(99_r) } }; ``` diff --git a/examples/methods_and_overlays.cpp b/examples/methods_and_overlays.cpp index 942fc2fa..f59b6fee 100644 --- a/examples/methods_and_overlays.cpp +++ b/examples/methods_and_overlays.cpp @@ -26,6 +26,7 @@ // Standard references, exactly as every other example in this repository is. #include +#include #include #include #include @@ -34,15 +35,18 @@ #include #include #include -#include +#include +#include #include #include #include +#include namespace { namespace unit = formula::unit; using formula::var; +using namespace formula::literals; // ---- Tags: what a variant applies to ---------------------------------------- // @@ -65,11 +69,6 @@ using EdgeA = formula::Quantity; using Diameter = formula::Quantity; using ShapeFactor = formula::Quantity; - -[[nodiscard]] constexpr formula::Rational rat(std::int64_t numerator, std::int64_t denominator = 1) -{ - return formula::Rational { numerator, denominator }; -} } // namespace // A tag is shown under its own name by default -- `Cube` -- and a published @@ -94,6 +93,11 @@ namespace // are different expressions of different types; what they must share is the // dimension they report, and a pack whose variants disagree does not compile. // +// The method rounds to a tenth of a megapascal, a rule named once. +inline constexpr formula::DecimalRounding tenthMpa { unit::Megapascal, + formula::DecimalPlaces { 1 }, + formula::RoundingMode::HalfAwayFromZero }; + // Kept out of clang-format's hands: it reads `var * var` // as a pointer declaration and writes `var* var`, and the // guide quotes this declaration verbatim. @@ -101,21 +105,21 @@ namespace inline constexpr auto compressiveStrength = formula::method( formula::variants(formula::variant(var / (var * var)), formula::variant(var * var / (var * var)), - formula::variant(formula::constant(rat(4)) * var + formula::variant(formula::constant(4_r) * var / (formula::pi * formula::pow<2>(var)))), - formula::rounding_rule(), - formula::constraints(formula::constraint(var >= formula::constant(rat(473, 10)), + formula::rounding_rule(), + formula::constraints(formula::constraint(var >= formula::constant(47.3_r), formula::Verdict { "the load at failure is below 47.3 kN" }))); // clang-format on // A 163 x 103 mm cube face loaded to 89.3 kN with a measured shape factor of // 1.043: 5.5477... MPa, so a rounding rule's granularity shows in the number -- // 5.5 MPa to one decimal, 5.55 to two. -inline constexpr auto specimen = formula::environment(formula::Measured { rat(89'300) }, - formula::Measured { rat(163) }, - formula::Measured { rat(103) }, - formula::Measured { rat(135) }, - formula::Measured { rat(1043, 1000) }); +inline constexpr auto specimen = formula::environment(formula::Measured { 89'300 }, + formula::Measured { 163 }, + formula::Measured { 103 }, + formula::Measured { 135 }, + formula::Measured { 1.043_r }); // ---- 2. Overlays ----------------------------------------------------------------- inline constexpr formula::Citation northConstant { .title = "Shape factor", @@ -133,7 +137,7 @@ inline constexpr formula::Citation eastScope { .reference = "Example Standard 3: /// rule's unit is the unit a jurisdiction REPORTS in, and it must measure the /// method's dimension. inline constexpr auto north = - formula::overlay(formula::with_constant(rat(863, 1000), northConstant), + formula::overlay(formula::with_constant(0.863_r, northConstant), formula::with_rounding(northRounding)); @@ -144,7 +148,7 @@ inline constexpr auto north = /// is listed first so that nothing listed after it is missed inside it. inline constexpr auto south = formula::overlay( formula::replace_variant( - var / (formula::constant(rat(1127, 1000)) * formula::pow<2>(var)), southReplacement), + var / (formula::constant(1.127_r) * formula::pow<2>(var)), southReplacement), formula::add_derived(var / var, southDefinition), formula::prune_variant(southScope)); @@ -200,16 +204,16 @@ inline constexpr formula::Citation westRevision { .title = "Acceptance", /// has one, and neither of them the base method's. `with_constraints` replaces /// the method's constraints wholesale -- it does not add to them. inline constexpr auto west = formula::overlay(formula::with_constraints( - formula::constraints(formula::constraint(var >= formula::constant(rat(973, 10)), + formula::constraints(formula::constraint(var >= formula::constant(97.3_r), formula::Verdict { "the load at failure is below 97.3 kN" }), - formula::constraint(var <= formula::number(rat(173, 100)) * var, + formula::constraint(var <= formula::number(1.73_r) * var, formula::Verdict { "the loaded face is more than 1.73 times as long as wide" })), westAcceptance)); /// A later revision of the west's annex, applied on top of the west's method: /// its one constraint is all the stacked method checks. inline constexpr auto westRevised = formula::overlay(formula::with_constraints( - formula::constraints(formula::constraint(var >= formula::constant(rat(831, 10)), + formula::constraints(formula::constraint(var >= formula::constant(83.1_r), formula::Verdict { "the load at failure is below 83.1 kN" })), westRevision)); @@ -219,14 +223,13 @@ inline constexpr auto westernRevised = formula::apply(westRevised, western); template void print_outcomes(char const* method, std::array const& outcomes) { - std::printf("%s: %zu constraint(s)\n", method, N); + std::println("{}: {} constraint(s)", method, N); for (std::size_t index = 0; index < N; ++index) { - std::string_view const word = describe(outcomes[index].kind()); - std::printf(" [%zu] %.*s", index, static_cast(word.size()), word.data()); + std::print(" [{}] {}", index, outcomes[index].kind()); if (std::optional const verdict = outcomes[index].verdict()) - std::printf(": %.*s", static_cast(verdict->label.size()), verdict->label.data()); - std::printf("\n"); + std::print(": {}", verdict->label); + std::println(""); } } @@ -241,49 +244,16 @@ template + std::string { origin.source().section }; } -template -[[nodiscard]] std::string acceptanceOf(M const& m) -{ - formula::Trace<> trace {}; - (void) formula::check_method(m, specimen, formula::RecordingSink { trace }); - return formula::render_trace(trace, { .maxSteps = 30 }); -} - -[[nodiscard]] std::string exact(formula::Evaluated const& result) -{ - if (!result.has_value() || !result->has_value()) - return "no value"; - formula::Rational const value = **result; - std::string text = std::to_string(value.numerator()); - if (value.denominator() != 1) - text += "/" + std::to_string(value.denominator()); - return text + " Pa"; -} - -template -[[nodiscard]] std::string derivationOf(M const& m, V const&... vocabulary) -{ - formula::Trace<> trace {}; - (void) formula::evaluate_method(m, specimen, formula::RecordingSink { trace, vocabulary... }); - return formula::render_trace(trace, { .maxSteps = 30 }); -} - void print_symbols(formula::Documentation const& documentation) { for (formula::SymbolEntry const& row: documentation.symbols) { - std::printf(" %.*s: %.*s", - static_cast(row.symbol.size()), - row.symbol.data(), - static_cast(row.description.size()), - row.description.data()); + std::print(" {}: {}", row.symbol, row.description); if (row.fixedValue.has_value()) - std::printf(" -- fixed at %lld/%lld", - static_cast(row.fixedValue->numerator()), - static_cast(row.fixedValue->denominator())); + std::print(" -- fixed at {:/}", *row.fixedValue); if (row.derivedAs.has_value()) - std::printf(" -- derived as %s", row.derivedAs->c_str()); - std::printf("\n"); + std::print(" -- derived as {}", *row.derivedAs); + std::println(""); } } } // namespace @@ -294,122 +264,167 @@ int main() auto const check = [&allPassed](bool condition, char const* what) { if (!condition) { - std::printf("CHECK FAILED: %s\n", what); + std::println("CHECK FAILED: {}", what); allPassed = false; } }; // ---- 1. Selecting a variant --------------------------------------------------- - std::printf("== 1. A method selects a variant by tag ==\n\n"); - - auto const cube = formula::evaluate_method(compressiveStrength, specimen); - std::printf("cube: %s\n", exact(cube).c_str()); - std::printf("\n%s\n", derivationOf(compressiveStrength).c_str()); - check(exact(cube) == "5500000 Pa", "the cube's strength, rounded to 5.5 MPa and answered in pascals"); + std::println("== 1. A method selects a variant by tag ==\n"); - std::string const cylinderTrace = derivationOf(compressiveStrength); - std::printf("%s\n", cylinderTrace.c_str()); - check(cylinderTrace.find("[variant cylinder 135 x 271 mm (3rd of 3), selected by tag]") != std::string::npos, + auto const cube = formula::explain_method(compressiveStrength, specimen); + if (!cube.outcome) + { + std::println("cube: {}", cube.outcome.error()); + return 1; + } + auto const cubeStrength = formula::number_of(cube.outcome); + if (!cubeStrength) + { + std::println("cube: no value"); + return 1; + } + std::println("cube: {} Pa", *cubeStrength); + std::println("\n{}", formula::render_trace(cube.trace, { .maxSteps = 30 })); + check(cubeStrength == 5500000_r, "the cube's strength, rounded to 5.5 MPa and answered in pascals"); + + auto const cylinder = formula::explain_method(compressiveStrength, specimen); + std::string const cylinderTrace = formula::render_trace(cylinder.trace, { .maxSteps = 30 }); + std::println("{}", cylinderTrace); + check(cylinderTrace.contains("[variant cylinder 135 x 271 mm (3rd of 3), selected by tag]"), "the cylinder variant is named as its TagName spells it, at its published position"); // ---- 2. Overlays --------------------------------------------------------------- - std::printf("== 2. A jurisdiction's overlay yields a method ==\n\n"); + std::println("== 2. A jurisdiction's overlay yields a method ==\n"); - auto const northCube = formula::evaluate_method(northern, specimen); - std::printf("north cube: %s\n\n%s\n", exact(northCube).c_str(), derivationOf(northern).c_str()); - check(exact(northCube) == "4590000 Pa", "the north's fixed 0.863, rounded to 4.59 N/mm2 by its own rule"); + auto const northCube = formula::explain_method(northern, specimen); + if (!northCube.outcome) + { + std::println("north cube: {}", northCube.outcome.error()); + return 1; + } + auto const northStrength = formula::number_of(northCube.outcome); + if (!northStrength) + { + std::println("north cube: no value"); + return 1; + } + std::println("north cube: {} Pa\n\n{}", *northStrength, formula::render_trace(northCube.trace, { .maxSteps = 30 })); + check(northStrength == 4590000_r, "the north's fixed 0.863, rounded to 4.59 N/mm2 by its own rule"); - auto const southCube = formula::evaluate_method(southern, specimen); - std::printf("south cube: %s\n\n%s\n", exact(southCube).c_str(), derivationOf(southern).c_str()); - check(exact(southCube) == "3400000 Pa", "the south's derived shape factor b / a = 103/163"); + auto const southCube = formula::explain_method(southern, specimen); + if (!southCube.outcome) + { + std::println("south cube: {}", southCube.outcome.error()); + return 1; + } + auto const southStrength = formula::number_of(southCube.outcome); + if (!southStrength) + { + std::println("south cube: no value"); + return 1; + } + std::println("south cube: {} Pa\n\n{}", *southStrength, formula::render_trace(southCube.trace, { .maxSteps = 30 })); + check(southStrength == 3400000_r, "the south's derived shape factor b / a = 103/163"); - std::string const southCylinder = derivationOf(southern); - std::printf("%s\n", southCylinder.c_str()); - check(southCylinder.find("[replaced by jurisdiction overlay: Example Standard 7:2019 A, A.5]") != std::string::npos, + auto const southCylinder = formula::explain_method(southern, specimen); + std::string const southCylinderTrace = formula::render_trace(southCylinder.trace, { .maxSteps = 30 }); + std::println("{}", southCylinderTrace); + check(southCylinderTrace.contains("[replaced by jurisdiction overlay: Example Standard 7:2019 A, A.5]"), "the south's cylinder formula is marked as the south's"); - check(exact(formula::evaluate_method(southern, specimen)) == "4300000 Pa" - && exact(formula::evaluate_method(compressiveStrength, specimen)) == "6200000 Pa", + check(formula::number_of(southCylinder.outcome) == 4300000_r && formula::number_of(cylinder.outcome) == 6200000_r, "the south's replacement formula is the one that ran: 4.3 MPa, not the base method's 6.2"); - check(southCylinder.find("(3rd of 3), selected by tag; 1 of 3 pruned by jurisdiction overlay: Example Standard " - "7:2019 A, A.1]") - != std::string::npos, + check(southCylinderTrace.contains("(3rd of 3), selected by tag; 1 of 3 pruned by jurisdiction overlay: Example Standard " + "7:2019 A, A.1]"), "a variant keeps its published position after one before it is pruned, and the prune is said with its " "citation"); - std::string const eastCube = derivationOf(eastern); - std::printf("%s\n", eastCube.c_str()); - check(eastCube.find("[variant Cube (2nd of 3), selected by tag; pinned by jurisdiction overlay: Example Standard " - "3:2023 E, E.1]") - != std::string::npos, + std::string const eastCube = + formula::render_trace(formula::explain_method(eastern, specimen).trace, { .maxSteps = 30 }); + std::println("{}", eastCube); + check(eastCube.contains("[variant Cube (2nd of 3), selected by tag; pinned by jurisdiction overlay: Example Standard " + "3:2023 E, E.1]"), "a pinned variant is still counted in the method as published, and the pin is said with its citation"); - std::printf("east: %zu variant(s) left after the pin\n\n", std::tuple_size_v); + std::println("east: {} variant(s) left after the pin\n", std::tuple_size_v); - std::printf("documentation of the south's cube:\n"); + std::println("documentation of the south's cube:"); auto const southCubeFormula = std::get<0>(southern.variantSet.cases).expression; // the prism is pruned formula::Documentation const southPage = formula::document(southCubeFormula); - std::printf(" %s\n", southPage.formula.c_str()); + std::println(" {}", southPage.formula); print_symbols(southPage); - std::printf("\n"); + std::println(""); check(southPage.symbols.front().derivedAs == std::optional { "b / a" }, "the page says how the south derives the shape factor"); // ---- 3. A runtime choice among compiled jurisdictions ---------------------------- - std::printf("== 3. Which jurisdiction applies is a runtime value ==\n\n"); + std::println("== 3. Which jurisdiction applies is a runtime value ==\n"); for (Jurisdiction const jurisdiction: { Jurisdiction::Base, Jurisdiction::North, Jurisdiction::South }) - std::printf("jurisdiction %d: %s\n", static_cast(jurisdiction), exact(cubeStrengthIn(jurisdiction)).c_str()); - std::printf("\n"); - check(exact(cubeStrengthIn(Jurisdiction::North)) == exact(northCube), "the runtime choice reaches the north"); + { + auto const chosen = cubeStrengthIn(jurisdiction); + if (!chosen) + { + std::println("jurisdiction {}: {}", std::to_underlying(jurisdiction), chosen.error()); + return 1; + } + auto const chosenStrength = formula::number_of(chosen); + if (!chosenStrength) + { + std::println("jurisdiction {}: no value", std::to_underlying(jurisdiction)); + return 1; + } + std::println("jurisdiction {}: {} Pa", std::to_underlying(jurisdiction), *chosenStrength); + } + std::println(""); + check(formula::number_of(cubeStrengthIn(Jurisdiction::North)) == northStrength, "the runtime choice reaches the north"); // ---- 4. A vocabulary -------------------------------------------------------------- - std::printf("== 4. The same formula in two jurisdictions' words ==\n\n"); + std::println("== 4. The same formula in two jurisdictions' words ==\n"); auto const baseCubeFormula = std::get<1>(compressiveStrength.variantSet.cases).expression; std::string const inNorth = formula::render(baseCubeFormula, northernWords); std::string const inSouth = formula::render(baseCubeFormula, southernWords); - std::printf("north: %s\nsouth: %s\n\n", inNorth.c_str(), inSouth.c_str()); + std::println("north: {}\nsouth: {}\n", inNorth, inSouth); check(inNorth == "k_s * F / (a * b)" && inSouth == "k_s * F / (b * a)", "the two edges swap letters"); formula::Documentation const southernPage = formula::document(baseCubeFormula, southernWords); - std::printf("the southern page's symbol table:\n"); + std::println("the southern page's symbol table:"); print_symbols(southernPage); - std::printf("\n"); + std::println(""); - formula::Trace<> trace {}; - (void) formula::evaluate_method(compressiveStrength, specimen, formula::RecordingSink { trace, southernWords }); - std::string const southernTrace = formula::render_trace(trace, { .maxSteps = 30 }); - std::printf("%s\n", southernTrace.c_str()); - check(southernTrace.find("4. b = 163 mm\n") != std::string::npos, "the trace writes the 163 mm edge as the south does"); + auto const southernRun = formula::explain_method(compressiveStrength, specimen, southernWords); + std::string const southernTrace = formula::render_trace(southernRun.trace, { .maxSteps = 30 }); + std::println("{}", southernTrace); + check(southernTrace.contains("4. b = 163 mm\n"), "the trace writes the 163 mm edge as the south does"); // ---- 5. Constraints ---------------------------------------------------------------- - std::printf("== 5. Whose acceptance logic ==\n\n"); - - auto const baseOutcomes = formula::check_method(compressiveStrength, specimen); - auto const westOutcomes = formula::check_method(western, specimen); - print_outcomes("base", baseOutcomes); - print_outcomes("west", westOutcomes); - std::printf("\n"); - check(baseOutcomes.size() == 1 && baseOutcomes[0].is_satisfied(), "the base method's one check: 89.3 kN >= 47.3 kN"); - check(westOutcomes.size() == 2 && westOutcomes[0].is_violated() && westOutcomes[1].is_satisfied(), + std::println("== 5. Whose acceptance logic ==\n"); + + auto const baseCheck = formula::explain_check_method(compressiveStrength, specimen); + auto const westCheck = formula::explain_check_method(western, specimen); + print_outcomes("base", baseCheck.outcome); + print_outcomes("west", westCheck.outcome); + std::println(""); + check(baseCheck.outcome.size() == 1 && baseCheck.outcome[0].is_satisfied(), + "the base method's one check: 89.3 kN >= 47.3 kN"); + check(westCheck.outcome.size() == 2 && westCheck.outcome[0].is_violated() && westCheck.outcome[1].is_satisfied(), "the west's two checks, each at its own index: 89.3 kN < 97.3 kN, and 163 mm <= 1.73 x 103 mm"); - std::printf("base constraints: %s\n", whoseConstraints(compressiveStrength).c_str()); - std::printf("west constraints: %s\n\n", whoseConstraints(western).c_str()); - - std::string const baseAcceptance = acceptanceOf(compressiveStrength); - std::string const westAcceptanceTrace = acceptanceOf(western); - std::printf("%s\n%s\n", baseAcceptance.c_str(), westAcceptanceTrace.c_str()); - check(baseAcceptance.find("[satisfied; the method's own constraint]") != std::string::npos, - "the base method's verdict is the method's own"); - check(westAcceptanceTrace.find("[the load at failure is below 97.3 kN; jurisdiction overlay: Acceptance, " - "Example Standard 9:2022 B, B.2]") - != std::string::npos, + std::println("base constraints: {}", whoseConstraints(compressiveStrength)); + std::println("west constraints: {}\n", whoseConstraints(western)); + + std::string const baseAcceptance = formula::render_trace(baseCheck.trace, { .maxSteps = 30 }); + std::string const westAcceptanceTrace = formula::render_trace(westCheck.trace, { .maxSteps = 30 }); + std::println("{}\n{}", baseAcceptance, westAcceptanceTrace); + check(baseAcceptance.contains("[satisfied; the method's own constraint]"), "the base method's verdict is the method's own"); + check(westAcceptanceTrace.contains("[the load at failure is below 97.3 kN; jurisdiction overlay: Acceptance, " + "Example Standard 9:2022 B, B.2]"), "the west's verdict names the west's annex"); auto const revisedOutcomes = formula::check_method(westernRevised, specimen); print_outcomes("west, revised on top", revisedOutcomes); - std::printf("revised constraints: %s\n\n", whoseConstraints(westernRevised).c_str()); + std::println("revised constraints: {}\n", whoseConstraints(westernRevised)); check(revisedOutcomes.size() == 1 && revisedOutcomes[0].is_satisfied(), "stacked overlays: the later overlay's one constraint holds, and the earlier two are gone"); - std::printf("all checks passed: %s\n", allPassed ? "yes" : "no"); + std::println("all checks passed: {}", allPassed ? "yes" : "no"); return allPassed ? 0 : 1; } diff --git a/examples/records.cpp b/examples/records.cpp index 1c0d9716..ede90a68 100644 --- a/examples/records.cpp +++ b/examples/records.cpp @@ -23,12 +23,13 @@ // repository; nothing here cites a standard. #include +#include #include #include #include #include -#include +#include #include #include @@ -36,6 +37,7 @@ namespace { namespace unit = formula::unit; using formula::var; +using namespace formula::literals; // ---- Roles: which record a formula reads from -------------------------------- // @@ -79,14 +81,14 @@ namespace // ---- 1. The records, and the context that holds them ------------------------- // This specimen, and the reference specimen, whose strength was typed in. -constexpr auto here = formula::environment(formula::Measured { formula::Rational { 30 } }, - formula::Measured { formula::Rational { 579'630 } }, - formula::Measured { formula::Rational { 139 } }, - formula::Measured { formula::Rational { 139 } }); -constexpr auto there = formula::environment(formula::entered(formula::Measured { formula::Rational { 20 } }), - formula::Measured { formula::Rational { 386'420 } }, - formula::Measured { formula::Rational { 139 } }, - formula::Measured { formula::Rational { 139 } }); +constexpr auto here = formula::environment(formula::Measured { 30 }, + formula::Measured { 579'630 }, + formula::Measured { 139 }, + formula::Measured { 139 }); +constexpr auto there = formula::environment(formula::entered(formula::Measured { 20 }), + formula::Measured { 386'420 }, + formula::Measured { 139 }, + formula::Measured { 139 }); constexpr auto records = formula::record_context( formula::record(formula::record_key(formula::sample_id(17), formula::test_id(5)), here, @@ -103,12 +105,8 @@ constexpr auto referenceStrength = formula::from_record(var / // The masses retained on three screens, here and on the reference, whose // masses were typed in. A series is reduced inside the read: the read holds // one value. -constexpr auto screensHere = formula::environment(formula::measured_series( - formula::Measured { formula::Rational { 163 } }, formula::Measured { formula::Rational { 241 } }, - formula::Measured { formula::Rational { 127 } })); -constexpr auto screensThere = formula::environment(formula::entered(formula::measured_series( - formula::Measured { formula::Rational { 139 } }, formula::Measured { formula::Rational { 197 } }, - formula::Measured { formula::Rational { 103 } }))); +constexpr auto screensHere = formula::environment(formula::measured_series(163, 241, 127)); +constexpr auto screensThere = formula::environment(formula::entered(formula::measured_series(139, 197, 103))); constexpr auto screenRecords = formula::record_context( formula::record(formula::record_key(formula::sample_id(17), formula::test_id(5)), screensHere), formula::record(formula::record_key(formula::sample_id(23), formula::test_id(3)), screensThere)); @@ -130,32 +128,6 @@ constexpr auto recordsWith(Batch batch, Method method) formula::record(formula::record_key(formula::sample_id(23), formula::test_id(3)), there, batch, method)); } - -/// A value in the coherent unit, exactly, with its unit: `30000000 Pa` or `3/2`; -/// `no answer` when it is absent, and `refused` when it is an error. -std::string exact(formula::Evaluated const& evaluated, std::string_view unitText) -{ - if (!evaluated.has_value()) - return "refused"; - if (!evaluated->has_value()) - return "no answer"; - formula::Rational const value = **evaluated; - std::string text = std::to_string(value.numerator()); - if (value.denominator() != 1) - text += "/" + std::to_string(value.denominator()); - if (!unitText.empty()) - text += " " + std::string { unitText }; - return text; -} - -/// The trace of @p expression over @p context. -template -std::string traceOf(Expression const& expression, Context const& context) -{ - formula::Trace<> recorded {}; - (void) formula::checked_evaluate_si(expression, context, formula::RecordingSink { recorded }); - return formula::render_trace(recorded, { .maxSteps = 20 }); -} } // namespace int main() @@ -164,74 +136,124 @@ int main() auto const check = [&allPassed](bool condition, char const* what) { if (!condition) { - std::printf("CHECK FAILED: %s\n", what); + std::println("CHECK FAILED: {}", what); allPassed = false; } }; - std::printf("== 1. A role is code, a record is data ==\n\n"); - - std::string const ratioText = formula::render(ratio); - std::string const ratioValue = exact(formula::checked_evaluate_si(ratio, records), ""); - std::printf("%s = %s\n", ratioText.c_str(), ratioValue.c_str()); - check(ratioValue == "3/2", "30 MPa here over the reference's 20 MPa"); + std::println("== 1. A role is code, a record is data ==\n"); + + // Evaluated once, with the trace of how: the value is printed here, the + // trace in the next part. + auto const ratioRun = formula::traced( + [](auto recordingSink) { return formula::checked_evaluate_si(ratio, records, recordingSink); }); + if (!ratioRun.outcome) + { + std::println("the ratio of the strengths: {}", ratioRun.outcome.error()); + return 1; + } + auto const ratioValue = formula::number_of(ratioRun.outcome); + if (!ratioValue) + { + std::println("the ratio of the strengths: no answer"); + return 1; + } + std::println("{} = {:/}", formula::render(ratio), *ratioValue); + check(ratioValue == 3_r / 2, "30 MPa here over the reference's 20 MPa"); // The context is this record's environment: anything that takes one takes // the context, and reads this record's values from it. - std::string const viaContext = exact(formula::checked_evaluate_si(var, records), "Pa"); - std::string const viaEnvironment = exact(formula::checked_evaluate_si(var, here), "Pa"); - std::printf("f_c through the context: %s\n", viaContext.c_str()); - std::printf("f_c through this record's environment: %s\n\n", viaEnvironment.c_str()); - check(viaContext == viaEnvironment, "the context reads this record's own values"); - - std::printf("== 2. Reading a value, or computing over another specimen ==\n\n"); - - std::printf("%s = %s\n", formula::render(referenceStrength).c_str(), - exact(formula::checked_evaluate_si(referenceStrength, records), "Pa").c_str()); - std::printf("%s\n\n", formula::render(referenceStrength).c_str()); + auto const viaContext = formula::checked_evaluate_si(var, records); + if (!viaContext) + { + std::println("f_c through the context: {}", viaContext.error()); + return 1; + } + auto const viaEnvironment = formula::checked_evaluate_si(var, here); + if (!viaEnvironment) + { + std::println("f_c through this record's environment: {}", viaEnvironment.error()); + return 1; + } + auto const strengthViaContext = formula::number_of(viaContext); + auto const strengthViaEnvironment = formula::number_of(viaEnvironment); + if (!strengthViaContext || !strengthViaEnvironment) + { + std::println("f_c: no answer"); + return 1; + } + std::println("f_c through the context: {} Pa", *strengthViaContext); + std::println("f_c through this record's environment: {} Pa\n", *strengthViaEnvironment); + check(strengthViaContext == strengthViaEnvironment, "the context reads this record's own values"); + + std::println("== 2. Reading a value, or computing over another specimen ==\n"); + + auto const referenceRead = formula::checked_evaluate_si(referenceStrength, records); + if (!referenceRead) + { + std::println("the reference's strength from its load and edges: {}", referenceRead.error()); + return 1; + } + auto const referenceValue = formula::number_of(referenceRead); + if (!referenceValue) + { + std::println("the reference's strength from its load and edges: no answer"); + return 1; + } + std::println("{} = {} Pa", formula::render(referenceStrength), *referenceValue); + std::println("{}\n", formula::render(referenceStrength)); check(formula::render(referenceStrength) == "(F / (x_m * y_m)) of Reference", "a compound read is bracketed, so the role qualifies the whole computation"); - std::string const ratioTrace = traceOf(ratio, records); - std::printf("%s\n", ratioTrace.c_str()); - check(ratioTrace.find("f_c = 20 MPa, from record Reference (sample 23, test 3), entered by hand") - != std::string::npos, + std::string const ratioTrace = formula::render_trace(ratioRun.trace, { .maxSteps = 20 }); + std::println("{}", ratioTrace); + check(ratioTrace.contains("f_c = 20 MPa, from record Reference (sample 23, test 3), entered by hand"), "the reference's typed-in strength, and whose it is"); formula::Documentation const page = formula::document(ratio); for (formula::SymbolEntry const& row: page.symbols) - std::printf(" %.*s: %.*s, %s\n", static_cast(row.symbol.size()), row.symbol.data(), - static_cast(row.description.size()), row.description.data(), - row.record.empty() ? "this record" : ("record " + std::string { row.record }).c_str()); - std::printf("\n"); + { + if (row.record.empty()) + std::println(" {}: {}, this record", row.symbol, row.description); + else + std::println(" {}: {}, record {}", row.symbol, row.description, row.record); + } + std::println(""); check(page.symbols.size() == 2, "one row per record a quantity is read from"); - std::string const seriesTrace = traceOf(retainedRatio, screenRecords); - std::printf("%s\n", seriesTrace.c_str()); - check(seriesTrace.find("m_r = 139 g; 197 g; 103 g, from record Reference (sample 23, test 3), entered by hand\n") - != std::string::npos, + std::string const seriesTrace = formula::render_trace( + formula::traced([](auto recordingSink) + { return formula::checked_evaluate_si(retainedRatio, screenRecords, recordingSink); }) + .trace, + { .maxSteps = 20 }); + std::println("{}", seriesTrace); + check(seriesTrace.contains("m_r = 139 g; 197 g; 103 g, from record Reference (sample 23, test 3), entered by hand\n"), "a series read from the reference names the record after its elements, and was typed in"); - std::printf("== 3. Lineage is a gate ==\n\n"); + std::println("== 3. Lineage is a gate ==\n"); - std::string const agreed = traceOf(gated, records); - std::printf("%s\n", agreed.c_str()); - check(agreed.find("same TestMethod as this record: 12 for this record, 12 for Reference, satisfied") != std::string::npos, + std::string const agreed = formula::render_trace( + formula::traced([](auto recordingSink) + { return formula::checked_evaluate_si(gated, records, recordingSink); }) + .trace, + { .maxSteps = 20 }); + std::println("{}", agreed); + check(agreed.contains("same TestMethod as this record: 12 for this record, 12 for Reference, satisfied"), "both attributes agree, and the value is read"); auto const otherMethod = recordsWith(formula::lineage(4411), formula::lineage(13)); auto const refused = formula::checked_explain(gated, otherMethod); if (!refused.has_value()) - std::printf("%s\n", formula::render_trace(refused.error().trace, { .maxSteps = 20 }).c_str()); + std::println("{}", formula::render_trace(refused.error().trace, { .maxSteps = 20 })); check(!refused.has_value() && refused.error().error == formula::ArithmeticError::DomainError, "a different method refuses the read"); - auto const batchUnknown = - recordsWith(formula::unknown_lineage(), formula::lineage(12)); - std::string const notChecked = traceOf(gated, batchUnknown); - std::printf("%s\n", notChecked.c_str()); - check(!formula::checked_evaluate_si(gated, batchUnknown)->has_value(), - "an unknown batch gives no answer"); + auto const batchUnknown = recordsWith(formula::unknown_lineage(), formula::lineage(12)); + auto const notChecked = formula::traced( + [&](auto recordingSink) + { return formula::checked_evaluate_si(gated, batchUnknown, recordingSink); }); + std::println("{}", formula::render_trace(notChecked.trace, { .maxSteps = 20 })); + check(notChecked.outcome.has_value() && !notChecked.outcome->has_value(), "an unknown batch gives no answer"); // A disagreement refuses the read even when another key is unknown: an // unknown key gives no answer only when nothing disagrees. @@ -239,47 +261,52 @@ int main() recordsWith(formula::unknown_lineage(), formula::lineage(13)); auto const refusedDespiteUnknown = formula::checked_explain(gated, unknownAndOtherMethod); if (!refusedDespiteUnknown.has_value()) - std::printf("%s\n", formula::render_trace(refusedDespiteUnknown.error().trace, { .maxSteps = 20 }).c_str()); + std::println("{}", formula::render_trace(refusedDespiteUnknown.error().trace, { .maxSteps = 20 })); check(!refusedDespiteUnknown.has_value() && refusedDespiteUnknown.error().error == formula::ArithmeticError::DomainError, "a different method refuses the read, though the batch is unknown"); - std::printf("== 4. A record not yet made ==\n\n"); + std::println("== 4. A record not yet made ==\n"); auto const notYetTested = formula::record_context( formula::record(formula::record_key(formula::sample_id(17), formula::test_id(5)), here, formula::lineage(4411), formula::lineage(12)), formula::Record, formula::LineageEntry>::unbound()); - std::string const unbound = traceOf(ratio, notYetTested); - std::printf("%s\n", unbound.c_str()); - check(!formula::checked_evaluate_si(ratio, notYetTested)->has_value(), - "no answer, never zero"); + auto const unbound = formula::traced( + [&](auto recordingSink) + { return formula::checked_evaluate_si(ratio, notYetTested, recordingSink); }); + std::println("{}", formula::render_trace(unbound.trace, { .maxSteps = 20 })); + check(unbound.outcome.has_value() && !unbound.outcome->has_value(), "no answer, never zero"); // The same record behind the gated read: every attribute it declares is // unknown, so no lineage is compared, and there is no answer. - std::string const unboundGated = traceOf(gated, notYetTested); - std::printf("%s\n", unboundGated.c_str()); - check(!formula::checked_evaluate_si(gated, notYetTested)->has_value() - && unboundGated.find("same ") == std::string::npos, + auto const unboundGated = formula::traced( + [&](auto recordingSink) + { return formula::checked_evaluate_si(gated, notYetTested, recordingSink); }); + std::string const unboundGatedTrace = formula::render_trace(unboundGated.trace, { .maxSteps = 20 }); + std::println("{}", unboundGatedTrace); + check(unboundGated.outcome.has_value() && !unboundGated.outcome->has_value() && !unboundGatedTrace.contains("same "), "a gated read over a record not yet made checks no lineage, and gives no answer"); auto const typedInEmpty = formula::record_context( formula::record(formula::record_key(formula::sample_id(17), formula::test_id(5)), here), formula::record(formula::record_key(formula::sample_id(23), formula::test_id(3)), formula::environment(formula::entered(formula::Measured::absent())))); - std::string const leftEmpty = traceOf(ratio, typedInEmpty); - std::printf("%s\n", leftEmpty.c_str()); - check(leftEmpty.find("f_c = (entered by hand as empty), from record Reference") != std::string::npos, - "an entry left empty by hand says so"); + std::string const leftEmpty = formula::render_trace( + formula::traced([&](auto recordingSink) + { return formula::checked_evaluate_si(ratio, typedInEmpty, recordingSink); }) + .trace, + { .maxSteps = 20 }); + std::println("{}", leftEmpty); + check(leftEmpty.contains("f_c = (entered by hand as empty), from record Reference"), "an entry left empty by hand says so"); - std::printf("== 5. A role's name reads as a name ==\n\n"); + std::println("== 5. A role's name reads as a name ==\n"); constexpr auto secondRead = formula::from_record(var); - std::printf("%s\n%s\n\n", formula::render(secondRead).c_str(), - formula::render(secondRead).c_str()); + std::println("{}\n{}\n", formula::render(secondRead), formula::render(secondRead)); check(formula::render(secondRead) == "f_c of second reference", "a role spelt through TagName"); - std::printf("all checks passed: %s\n", allPassed ? "yes" : "no"); + std::println("all checks passed: {}", allPassed ? "yes" : "no"); return allPassed ? 0 : 1; } diff --git a/examples/series.cpp b/examples/series.cpp index 245be123..ca55429c 100644 --- a/examples/series.cpp +++ b/examples/series.cpp @@ -25,28 +25,22 @@ // real specification. #include +#include #include #include #include #include -#include #include -#include -#include #include +#include #include -#include namespace { namespace unit = formula::unit; using formula::var; - -[[nodiscard]] constexpr formula::Rational rat(std::int64_t numerator, std::int64_t denominator = 1) -{ - return formula::Rational { numerator, denominator }; -} +using namespace formula::literals; // ---- Quantities --------------------------------------------------------------- using Retained = formula::Quantity; @@ -59,12 +53,6 @@ using Count = formula::Quantity; using Kelvins = formula::Quantity; -template -[[nodiscard]] constexpr formula::Measured m(std::int64_t numerator, std::int64_t denominator = 1) -{ - return formula::Measured { rat(numerator, denominator) }; -} - // ---- 1. A series, and the reductions that bring it back to one value ---------- // // The screens, declared once, in metres, ascending. @@ -76,37 +64,32 @@ inline constexpr formula::BreakpointTable<5> screens { formula::breakpoint(103), // The percentage passing each screen: everything not retained on it or on a // coarser one. `cumulative` runs from the coarsest screen down. -inline constexpr auto passing = - formula::constant(rat(100)) - - formula::cumulative(formula::series) / var; +inline constexpr auto passing = formula::yields( + formula::constant(100_r) + - formula::cumulative(formula::series) / var); // 130, 210, 95, 340 and 28 g retained of 1250 g. -inline constexpr auto analysis = - formula::environment(formula::measured_series( - m(130), m(210), m(95), m(340), m(28)), - m(1250)); +inline constexpr auto analysis = formula::environment(formula::measured_series(130, 210, 95, 340, 28), + formula::Measured { 1250 }); // A series reduced to one value: what the screens held in all. -inline constexpr auto retainedInAll = formula::sum(formula::series); +inline constexpr auto retainedInAll = formula::yields(formula::sum(formula::series)); // The grading curve: the percentage passing at each screen, and read at a // point between two of them. -inline constexpr auto grading = formula::curve(formula::domain, passing); -inline constexpr auto passingAt173 = formula::interpolate_at(grading, formula::constant(rat(173))); +inline constexpr auto grading = formula::curve(formula::domain, passing.expression); +inline constexpr auto passingAt173 = + formula::yields(formula::interpolate_at(grading, formula::constant(173_r))); // ---- 3. Absence: the operations of the table -------------------------------- // // Each element's share of the total, a plain fraction, and each mass rounded // to a whole gram in grams. -inline constexpr auto shareOfTotal = formula::series / var; -inline constexpr formula::PlacesTable<5> wholeGrams { formula::DecimalPlaces { 0 }, - formula::DecimalPlaces { 0 }, +inline constexpr auto shareOfTotal = formula::yields(formula::series / var); +inline constexpr formula::DecimalRounding wholeGram { unit::Gram, formula::DecimalPlaces { 0 }, - formula::DecimalPlaces { 0 }, - formula::DecimalPlaces { 0 } }; -inline constexpr auto roundedMasses = - formula::rounded_elementwise( - formula::series); + formula::RoundingMode::HalfAwayFromZero }; +inline constexpr auto roundedMasses = formula::rounded_elementwise(formula::series); // ---- 5. Conformity: each element against its own row -------------------------- // @@ -115,14 +98,15 @@ inline constexpr auto roundedMasses = // specification -- master data, registered per customer -- and never part of // a formula. inline constexpr formula::Envelope<5> gradingEnvelope { - formula::LimitRow { formula::limit(rat(31)), formula::limit(rat(43)) }, - formula::LimitRow { formula::limit(rat(47)), formula::limit(rat(59)) }, - formula::LimitRow { formula::limit(rat(1574, 25)), formula::unbounded }, - formula::LimitRow { formula::limit(rat(61)), formula::limit(rat(79)) }, - formula::LimitRow { formula::limit(rat(83)), formula::limit(rat(99)) } + formula::LimitRow { formula::limit(31_r), formula::limit(43_r) }, + formula::LimitRow { formula::limit(47_r), formula::limit(59_r) }, + formula::LimitRow { formula::limit(62.96_r), formula::unbounded }, + formula::LimitRow { formula::limit(61_r), formula::limit(79_r) }, + formula::LimitRow { formula::limit(83_r), formula::limit(99_r) } }; -inline constexpr auto gradingCheck = - formula::conformity(passing, gradingEnvelope, formula::Verdict { "outside the grading envelope" }); +inline constexpr auto gradingCheck = formula::conformity(passing.expression, + gradingEnvelope, + formula::Verdict { "outside the grading envelope" }); // ---- 6. Snapping, and splicing two curves -------------------------------------- // @@ -130,20 +114,19 @@ inline constexpr auto gradingCheck = // round, and snapped to the nearest declared screen. inline constexpr auto halfPassing = formula::snapped(formula::interpolate_at( - formula::curve(passing, formula::domain), formula::constant(rat(50)))); + formula::curve(passing.expression, formula::domain), formula::constant(50_r))); // A coarse analysis and a fine one, at invented openings of their own. inline constexpr formula::BreakpointTable<3> coarseScreens { formula::breakpoint(103), formula::breakpoint(127), formula::breakpoint(163) }; -inline constexpr formula::BreakpointTable<3> fineScreens { formula::breakpoint(103, 10), - formula::breakpoint(137, 10), - formula::breakpoint(163, 10) }; -inline constexpr auto coarse = - formula::curve(formula::domain, - formula::series_constant(rat(894, 25), rat(1154, 25), rat(1574, 25))); +inline constexpr formula::BreakpointTable<3> fineScreens { formula::breakpoint(10.3_r), + formula::breakpoint(13.7_r), + formula::breakpoint(16.3_r) }; +inline constexpr auto coarse = formula::curve(formula::domain, + formula::series_constant(35.76_r, 46.16_r, 62.96_r)); inline constexpr auto fine = formula::curve(formula::domain, - formula::series_constant(rat(31, 10), rat(84, 10), rat(142, 10))); + formula::series_constant(3.1_r, 8.4_r, 14.2_r)); // The fine analysis as measured, for the absence table. inline constexpr auto fineMeasured = formula::curve(formula::domain, formula::series); @@ -151,11 +134,12 @@ inline constexpr auto fineMeasured = formula::curve(formula::domain sizeClasses { formula::band(0, 1, 127, 1), - formula::band(127, 1, 197, 1), - formula::band(197, 1, 331, 1) }; -inline constexpr auto counted = formula::binned(formula::observations); -inline constexpr auto shares = counted / formula::sum(counted); +inline constexpr formula::BandTable<3> sizeClasses { formula::band(0, 127), + formula::band(127, 197), + formula::band(197, 331) }; +inline constexpr auto counted = + formula::yields(formula::binned(formula::observations)); +inline constexpr auto shares = counted.expression / formula::sum(counted.expression); // ---- Printing -------------------------------------------------------------------- @@ -166,36 +150,10 @@ void check(bool holds, char const* what) if (!holds) { allPassed = false; - std::printf("CHECK FAILED: %s\n", what); + std::println("CHECK FAILED: {}", what); } } -// The derivation of a series, a curve or a single value, as `render_trace` -// gives it. -template -[[nodiscard]] std::string series_trace(S const& seriesExpression, Env const& inputs, std::size_t maxSteps = 40) -{ - formula::Trace<> trace {}; - (void) formula::checked_evaluate_series(seriesExpression, inputs, formula::RecordingSink<> { trace }); - return formula::render_trace(trace, { .maxSteps = maxSteps }); -} - -template -[[nodiscard]] std::string value_trace(N const& expression, Env const& inputs) -{ - formula::Trace<> trace {}; - (void) formula::checked_evaluate(expression, inputs, formula::RecordingSink<> { trace }); - return formula::render_trace(trace, { .maxSteps = 80 }); -} - -template -[[nodiscard]] std::string curve_trace(C const& curveExpression, Env const& inputs) -{ - formula::Trace<> trace {}; - (void) formula::checked_evaluate_curve(curveExpression, inputs, formula::RecordingSink<> { trace }); - return formula::render_trace(trace, { .maxSteps = 40 }); -} - // The last line of a derivation: the step that answered. [[nodiscard]] std::string last_line(std::string const& derivation) { @@ -203,116 +161,133 @@ template std::size_t const start = derivation.rfind('\n', end - 1); return derivation.substr(start == std::string::npos ? 0 : start + 1, end - (start == std::string::npos ? 0 : start + 1)); } - -[[nodiscard]] char const* outcome_word(formula::ConstraintOutcome const& outcome) -{ - if (outcome.is_satisfied()) - return "satisfied"; - if (outcome.is_violated()) - return "violated"; - if (outcome.is_not_checked()) - return "not checked"; - return "invalid"; -} } // namespace int main() { - std::printf("== 1. A series, and the reductions that bring it back to one value ==\n\n"); - std::printf("%s\n", formula::render(passing).c_str()); - std::printf("%s\n", formula::render(retainedInAll).c_str()); - std::printf("%s\n\n", formula::render(passingAt173).c_str()); - check(formula::render(passing) == "100 % - cumulative(m_r(i), from last) / m_t", - "the series marked, the total unmarked"); - - auto const inAll = formula::checked_evaluate(retainedInAll, analysis); - check(inAll.has_value() && inAll->measurement().value() == rat(803), "sum: 803 g retained in all"); - auto const at173 = formula::checked_evaluate(passingAt173, analysis); - check(at173.has_value() && at173->measurement().value() == rat(27708, 425), "173 m reads 27708/425 %"); - std::printf("%s\n", value_trace(passingAt173, analysis).c_str()); + std::println("== 1. A series, and the reductions that bring it back to one value ==\n"); + std::println("{}", formula::render(passing)); + std::println("{}", formula::render(retainedInAll)); + std::println("{}\n", formula::render(passingAt173)); + check(formula::render(passing) == "100 % - cumulative(m_r(i), from last) / m_t", "the series marked, the total unmarked"); + + auto const inAll = formula::checked_evaluate(retainedInAll, analysis); + if (!inAll) + { + std::println("sum of the retained masses: {}", inAll.error()); + return 1; + } + check(formula::number_of(inAll) == 803_r, "sum: 803 g retained in all"); + auto const at173 = formula::checked_explain(passingAt173, analysis); + if (!at173) + { + std::println("the curve read at 173 m: {}", at173.error().error); + return 1; + } + check(formula::number_of(at173->outcome) == formula::Rational { 27708, 425 }, "173 m reads 27708/425 %"); + std::println("{}", formula::render_trace(at173->trace, { .maxSteps = 80 })); formula::Documentation const page = formula::document(passing); for (formula::SymbolEntry const& row: page.symbols) - std::printf(" %.*s: %s, %zu value(s)\n", - static_cast(row.symbol.size()), - row.symbol.data(), - row.shape == formula::ValueShape::Series ? "series" : "single value", - row.length); - std::printf("\n"); + std::println(" {}: {}, {} value(s)", + row.symbol, + row.shape == formula::ValueShape::Series ? "series" : "single value", + row.length); + std::println(""); // Celsius readings: a sum and a range are no readings, and read in the // coherent unit; a mean is one, and reads in degrees Celsius. - auto const readings = formula::environment( - formula::measured_series(m(237, 10), m(413, 10), m(379, 10))); + auto const readings = formula::environment(formula::measured_series(23.7_r, 41.3_r, 37.9_r)); constexpr auto threeReadings = formula::series; - std::string const sumTrace = value_trace(formula::sum(threeReadings), readings); + std::string const sumTrace = formula::render_trace( + formula::traced([&](auto recordingSink) + { return formula::checked_evaluate(formula::sum(threeReadings), readings, recordingSink); }) + .trace, + { .maxSteps = 80 }); std::string const readingsLine = sumTrace.substr(0, sumTrace.find('\n')); std::string const sumLine = last_line(sumTrace); - std::string const rangeLine = last_line(value_trace(formula::sample_range(threeReadings), readings)); - std::string const meanLine = last_line(value_trace(formula::sample_mean(threeReadings), readings)); - std::printf("the readings: %s\ntheir sum: %s\ntheir range: %s\ntheir mean: %s\n\n", - readingsLine.c_str(), - sumLine.c_str(), - rangeLine.c_str(), - meanLine.c_str()); + std::string const rangeLine = last_line(formula::render_trace( + formula::traced( + [&](auto recordingSink) + { return formula::checked_evaluate(formula::sample_range(threeReadings), readings, recordingSink); }) + .trace, + { .maxSteps = 80 })); + std::string const meanLine = last_line(formula::render_trace( + formula::traced( + [&](auto recordingSink) + { return formula::checked_evaluate(formula::sample_mean(threeReadings), readings, recordingSink); }) + .trace, + { .maxSteps = 80 })); + std::println("the readings: {}\ntheir sum: {}\ntheir range: {}\ntheir mean: {}\n", readingsLine, sumLine, rangeLine, meanLine); check(sumLine == "2. sum(#1) = 18447/20", "922.35 K, no reading"); check(rangeLine == "2. sample_range(#1) = 88/5", "17.6 K, no reading"); check(meanLine == "2. sample_mean(#1) = 343/10 \xc2\xb0" "C", "a mean of readings is a reading, 34.3 degC"); - std::printf("== 2. Elementwise arithmetic: one step per operation ==\n\n"); - std::string const passingTrace = series_trace(passing, analysis); - std::printf("%s\n", passingTrace.c_str()); + std::println("== 2. Elementwise arithmetic: one step per operation ==\n"); + std::string const passingTrace = formula::render_trace(formula::explain_series(passing, analysis).trace, { .maxSteps = 40 }); + std::println("{}", passingTrace); check(passingTrace.find("3. cumulative(#2, from last) = 803 g; 673 g; 463 g; 368 g; 28 g\n") != std::string::npos, "the running total from the coarsest screen"); // A computed step has no declared unit, so it reads in the coherent one: // 447/1250 is 35.76 %. check(passingTrace.ends_with("6. #1 - #5 = 447/1250; 577/1250; 787/1250; 441/625; 611/625\n"), "35.76, 46.16, 62.96, 70.56 and 97.76 % passing"); - std::printf("the same, within a budget of 8:\n%s\n", series_trace(passing, analysis, 8).c_str()); + std::println("the same, within a budget of 8:\n{}", + formula::render_trace(formula::explain_series(passing, analysis).trace, { .maxSteps = 8 })); // A series scaled by a pure number is still in its series' unit. - auto const threeScreens = formula::environment( - formula::measured_series(m(137), m(213), m(293))); - std::string const scaledTrace = - series_trace(formula::series * formula::number(rat(3, 2)), threeScreens); - std::printf("%s\n", scaledTrace.c_str()); + auto const threeScreens = formula::environment(formula::measured_series(137, 213, 293)); + std::string const scaledTrace = formula::render_trace( + formula::explain_series(formula::series * formula::number(1.5_r), threeScreens).trace, + { .maxSteps = 40 }); + std::println("{}", scaledTrace); check(scaledTrace.ends_with("3. #1 * #2 = 411/2 g; 639/2 g; 879/2 g\n"), "grams times 3/2 are grams"); - std::printf("== 3. Absence, decided at the size of what is produced ==\n\n"); + std::println("== 3. Absence, decided at the size of what is produced ==\n"); // The third screen's mass was not recorded; in the last row, the total. auto const oneUnrecorded = formula::environment( - formula::measured_series( - m(130), m(210), formula::Measured::absent(), m(340), m(28)), - m(1250)); - auto const noTotal = - formula::environment(formula::measured_series( - m(130), m(210), m(95), m(340), m(28)), - formula::Measured::absent()); + formula::measured_series(130, 210, formula::not_measured, 340, 28), formula::Measured { 1250 }); + auto const noTotal = formula::environment(formula::measured_series(130, 210, 95, 340, 28), + formula::Measured::absent()); // The fine analysis with its second value unrecorded. - auto const fineGap = formula::environment( - formula::measured_series(m(31, 10), formula::Measured::absent(), m(142, 10))); - - std::string const elementwise = last_line(series_trace(shareOfTotal, oneUnrecorded)); - std::string const rounding = last_line(series_trace(roundedMasses, oneUnrecorded)); - std::string const running = last_line(series_trace( - formula::cumulative(formula::series), oneUnrecorded)); - std::string const reduced = last_line(value_trace(retainedInAll, oneUnrecorded)); - std::string const readOff = last_line(value_trace(passingAt173, oneUnrecorded)); - std::string const spliced = last_line( - curve_trace(formula::splice(coarse, fineMeasured), fineGap)); + auto const fineGap = formula::environment(formula::measured_series(3.1_r, formula::not_measured, 14.2_r)); + + std::string const elementwise = + last_line(formula::render_trace(formula::explain_series(shareOfTotal, oneUnrecorded).trace, { .maxSteps = 40 })); + std::string const rounding = last_line( + formula::render_trace(formula::explain_series(roundedMasses, oneUnrecorded).trace, { .maxSteps = 40 })); + std::string const running = last_line(formula::render_trace( + formula::explain_series( + formula::cumulative(formula::series), oneUnrecorded) + .trace, + { .maxSteps = 40 })); + std::string const reduced = last_line(formula::render_trace( + formula::traced([&](auto recordingSink) { return formula::checked_evaluate(retainedInAll, oneUnrecorded, recordingSink); }) + .trace, + { .maxSteps = 80 })); + std::string const readOff = last_line(formula::render_trace( + formula::traced([&](auto recordingSink) { return formula::checked_evaluate(passingAt173, oneUnrecorded, recordingSink); }) + .trace, + { .maxSteps = 80 })); + std::string const spliced = last_line(formula::render_trace( + formula::explain_curve(formula::splice(coarse, fineMeasured), + fineGap) + .trace, + { .maxSteps = 40 })); auto const judgedWithGap = formula::check_conformity(gradingCheck, oneUnrecorded); - std::string const broadcast = last_line(series_trace(shareOfTotal, noTotal)); - std::printf("m_r / m_t, third screen unrecorded: %s\n", elementwise.c_str()); - std::printf("round to whole grams, third screen unrecorded: %s\n", rounding.c_str()); - std::printf("running total from the last, third screen unrecorded: %s\n", running.c_str()); - std::printf("sum, third screen unrecorded: %s\n", reduced.c_str()); - std::printf("the curve read at 173 m, third screen unrecorded: %s\n", readOff.c_str()); - std::printf("splice, the fine analysis's second value unrecorded: %s\n", spliced.c_str()); - std::printf("conformity of the passing, third screen unrecorded:"); + std::string const broadcast = + last_line(formula::render_trace(formula::explain_series(shareOfTotal, noTotal).trace, { .maxSteps = 40 })); + std::println("m_r / m_t, third screen unrecorded: {}", elementwise); + std::println("round to whole grams, third screen unrecorded: {}", rounding); + std::println("running total from the last, third screen unrecorded: {}", running); + std::println("sum, third screen unrecorded: {}", reduced); + std::println("the curve read at 173 m, third screen unrecorded: {}", readOff); + std::println("splice, the fine analysis's second value unrecorded: {}", spliced); + std::print("conformity of the passing, third screen unrecorded:"); for (formula::ConstraintOutcome const& outcome: judgedWithGap) - std::printf(" %s;", outcome_word(outcome)); - std::printf("\n"); - std::printf("m_r / m_t, the total unrecorded: %s\n\n", broadcast.c_str()); + std::print(" {};", outcome.kind()); + std::println(""); + std::println("m_r / m_t, the total unrecorded: {}\n", broadcast); check(elementwise == "3. #1 / #2 = 13/125; 21/125; (not measured); 34/125; 14/625", "only that element absent"); check(rounding.starts_with("2. round(#1, to 0/0/0/0/0 dp of g) = 130 g; 210 g; (not measured); 340 g; 28 g"), "rounding: only that element absent, in grams"); @@ -326,88 +301,119 @@ int main() check(broadcast.ends_with("(not measured); (not measured); (not measured); (not measured); (not measured)"), "an absent total makes every element absent"); - std::printf("== 4. A failed element fails the series, and names itself ==\n\n"); + std::println("== 4. A failed element fails the series, and names itself ==\n"); // The coarsest screen held nothing: dividing the total by it fails there. - auto const emptyScreen = - formula::environment(formula::measured_series( - m(130), m(210), m(95), m(340), m(0)), - m(1250)); - std::string const failedTrace = series_trace(var / formula::series, emptyScreen); - std::printf("%s\n", failedTrace.c_str()); + auto const emptyScreen = formula::environment(formula::measured_series(130, 210, 95, 340, 0), + formula::Measured { 1250 }); + auto const failed = formula::explain_series(var / formula::series, emptyScreen); + std::string const failedTrace = formula::render_trace(failed.trace, { .maxSteps = 40 }); + std::println("{}", failedTrace); check(failedTrace.ends_with("3. #1 / #2 = division by zero at element 5\n"), "the fifth element, counted from one"); - auto const failed = formula::checked_evaluate_series(var / formula::series, emptyScreen); - check(!failed.has_value() && failed.error().element == std::optional { 4 }, "zero-based 4 in the API"); - if (!failed.has_value() && failed.error().element.has_value()) - std::printf("SeriesFailure: division by zero, element %zu, counted from zero, a result element: %s\n\n", - *failed.error().element, - failed.error().site == formula::FailureSite::ResultElement ? "yes" : "no"); - - std::printf("== 5. Conformity: each element against its own row ==\n\n"); - std::printf("%s\n\n", formula::render(gradingCheck).c_str()); - auto const judged = formula::check_conformity(gradingCheck, analysis); - for (std::size_t at = 0; at < judged.size(); ++at) - std::printf(" screen %zu: %s\n", at + 1, outcome_word(judged[at])); - std::printf("\n"); - check(judged[1].is_violated() && judged[2].is_satisfied(), "46.16 % below 47 %; 62.96 % on its closed lower limit"); - formula::Trace<> conformityTrace {}; - (void) formula::check_conformity(gradingCheck, analysis, formula::RecordingSink<> { conformityTrace }); - std::string const conformityLine = last_line(formula::render_trace(conformityTrace, { .maxSteps = 40 })); - std::printf("%s\n\n", conformityLine.c_str()); - - std::printf("== 6. Snapping, and splicing two curves ==\n\n"); - std::printf("%s\n", formula::render(halfPassing).c_str()); - std::string const snapTrace = value_trace(halfPassing, analysis); - std::printf("%s\n", snapTrace.c_str()); + check(!failed.outcome.has_value() && failed.outcome.error().element == std::optional { 4 }, + "zero-based 4 in the API"); + if (!failed.outcome.has_value() && failed.outcome.error().element.has_value()) + std::println("SeriesFailure: division by zero, element {}, counted from zero, a result element: {}\n", + *failed.outcome.error().element, + failed.outcome.error().site == formula::FailureSite::ResultElement ? "yes" : "no"); + + std::println("== 5. Conformity: each element against its own row ==\n"); + std::println("{}\n", formula::render(gradingCheck)); + auto const conformity = formula::explain_conformity(gradingCheck, analysis); + for (std::size_t at = 0; at < conformity.outcome.size(); ++at) + std::println(" screen {}: {}", at + 1, conformity.outcome[at].kind()); + std::println(""); + check(conformity.outcome[1].is_violated() && conformity.outcome[2].is_satisfied(), + "46.16 % below 47 %; 62.96 % on its closed lower limit"); + std::println("{}\n", last_line(formula::render_trace(conformity.trace, { .maxSteps = 40 }))); + + std::println("== 6. Snapping, and splicing two curves ==\n"); + std::println("{}", formula::render(halfPassing)); + std::string const snapTrace = formula::render_trace( + formula::traced([&](auto recordingSink) { return formula::checked_evaluate(halfPassing, analysis, recordingSink); }) + .trace, + { .maxSteps = 80 }); + std::println("{}", snapTrace); check(snapTrace.find("[127 m to 163 m; nearer 127 m]") != std::string::npos, "4733/35 m snaps to 127 m, the nearer"); - auto const midway = formula::constant(rat(145)); - std::string const towardLower = last_line( - value_trace(formula::snapped(midway), analysis)); - std::string const towardHigher = last_line( - value_trace(formula::snapped(midway), analysis)); - std::string const beyond = last_line(value_trace( - formula::snapped(formula::constant(rat(251))), - analysis)); - std::printf("%s\n%s\n%s\n\n", towardLower.c_str(), towardHigher.c_str(), beyond.c_str()); + auto const midway = formula::constant(145_r); + std::string const towardLower = last_line(formula::render_trace( + formula::traced( + [&](auto recordingSink) + { + return formula::checked_evaluate( + formula::snapped(midway), analysis, recordingSink); + }) + .trace, + { .maxSteps = 80 })); + std::string const towardHigher = last_line(formula::render_trace( + formula::traced( + [&](auto recordingSink) + { + return formula::checked_evaluate( + formula::snapped(midway), analysis, recordingSink); + }) + .trace, + { .maxSteps = 80 })); + std::string const beyond = last_line(formula::render_trace( + formula::traced( + [&](auto recordingSink) + { + return formula::checked_evaluate( + formula::snapped(formula::constant(251_r)), + analysis, + recordingSink); + }) + .trace, + { .maxSteps = 80 })); + std::println("{}\n{}\n{}\n", towardLower, towardHigher, beyond); check(towardLower.ends_with("= 127 m [127 m to 163 m; tie, toward lower]"), "a tie, decided lower"); check(towardHigher.ends_with("= 163 m [127 m to 163 m; tie, toward higher]"), "a tie, decided higher"); check(beyond.ends_with("[outside the permitted set, 103 m to 241 m]"), "past the last screen, a miss"); auto const coarseFirst = formula::splice(coarse, fine); auto const fineFirst = formula::splice(fine, coarse); - std::string const coarseFirstLine = last_line(curve_trace(coarseFirst, analysis)); - std::string const fineFirstLine = last_line(curve_trace(fineFirst, analysis)); - std::printf("%s\n%s\n", formula::render(coarseFirst).c_str(), coarseFirstLine.c_str()); - std::printf("%s\n%s\n\n", formula::render(fineFirst).c_str(), fineFirstLine.c_str()); + std::string const coarseFirstLine = last_line( + formula::render_trace(formula::explain_curve(coarseFirst, analysis).trace, { .maxSteps = 40 })); + std::string const fineFirstLine = last_line( + formula::render_trace(formula::explain_curve(fineFirst, analysis).trace, { .maxSteps = 40 })); + std::println("{}\n{}", formula::render(coarseFirst), coarseFirstLine); + std::println("{}\n{}\n", formula::render(fineFirst), fineFirstLine); check(coarseFirstLine.substr(coarseFirstLine.find('=')) == fineFirstLine.substr(fineFirstLine.find('=')), "the same curve, whichever is written first"); // The fine analysis's last point raised to 40 %: each curve rises, the // union does not. auto const raised = formula::curve(formula::domain, - formula::series_constant(rat(31, 10), rat(84, 10), rat(40))); - std::string const brokenLine = last_line( - curve_trace(formula::splice(coarse, raised), analysis)); - std::printf("%s\n\n", brokenLine.c_str()); + formula::series_constant(3.1_r, 8.4_r, 40_r)); + std::string const brokenLine = last_line(formula::render_trace( + formula::explain_curve(formula::splice(coarse, raised), analysis) + .trace, + { .maxSteps = 40 })); + std::println("{}\n", brokenLine); check(brokenLine.ends_with("at element 4 [breaks non-decreasing at 103 m]"), "the union breaks where the curves join"); - std::printf("== 7. Binning raw observations into classes ==\n\n"); - std::printf("%s\n\n", formula::render(shares).c_str()); - auto const sample = formula::environment(formula::MeasuredObservations( - rat(103), rat(127), rat(163), rat(277), rat(113), rat(197), rat(241))); - std::string const binningTrace = series_trace(counted, sample); - std::printf("%s\n", binningTrace.c_str()); + std::println("== 7. Binning raw observations into classes ==\n"); + std::println("{}\n", formula::render(shares)); + auto const sample = formula::environment( + formula::MeasuredObservations(103_r, 127_r, 163_r, 277_r, 113_r, 197_r, 241_r)); + std::string const binningTrace = formula::render_trace(formula::explain_series(counted, sample).trace, { .maxSteps = 40 }); + std::println("{}", binningTrace); check(binningTrace.ends_with("2. bin(#1) = 2; 2; 3\n"), "127 and 197 m counted in the upper class"); auto const shared = formula::checked_evaluate_series(shares, sample); - check(shared.has_value() && shared->elements()[2].value() == rat(3, 7), "the coarsest class holds 3/7"); + if (!shared) + { + std::println("the shares of the classes: {}", shared.error().error); + return 1; + } + check(formula::number_of(shared->element(2)) == 3_r / 7, "the coarsest class holds 3/7"); - auto const oneTooLarge = formula::environment(formula::MeasuredObservations( - rat(103), rat(127), rat(163), rat(331), rat(113), rat(197), rat(241))); - std::string const missTrace = series_trace(counted, oneTooLarge); - std::printf("%s\n", missTrace.c_str()); + auto const oneTooLarge = formula::environment( + formula::MeasuredObservations(103_r, 127_r, 163_r, 331_r, 113_r, 197_r, 241_r)); + std::string const missTrace = formula::render_trace(formula::explain_series(counted, oneTooLarge).trace, { .maxSteps = 40 }); + std::println("{}", missTrace); check(missTrace.ends_with("at observation 4 [331 m in no class; the classes cover 0 to under 331 m]\n"), "331 m, the last class's high bound, is in no class"); - std::printf("all checks passed: %s\n", allPassed ? "yes" : "no"); + std::println("all checks passed: {}", allPassed ? "yes" : "no"); return allPassed ? 0 : 1; } From e5ad20dc656baab89a85be11b1ef88a5d7dfdb3d Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 21:29:24 +0200 Subject: [PATCH 30/59] docs(examples): restore a truncated header comment and tidy two spellings The constraints example's header comment lost its last line, and the rounding example carried its include block twice. The top-row band is now band(173_r, 211.1_r), like every other decimal bound, and the lookup guide says why its gap test spells the bounds as integer pairs. Signed-off-by: Christian Parpart --- docs/lookup-tables.md | 6 ++++-- examples/constraints.cpp | 1 + examples/lookup_tables.cpp | 2 +- examples/rounding_and_conditionals.cpp | 5 ----- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/docs/lookup-tables.md b/docs/lookup-tables.md index 265b5a53..14a6a749 100644 --- a/docs/lookup-tables.md +++ b/docs/lookup-tables.md @@ -130,7 +130,7 @@ to 211 mm inclusive" is written with its high bound at 211.1 mm: ```cpp inline constexpr formula::BandTable<1> TopRowInclusive { - formula::band(173, 211.1_r), // 173 to under 211.1 mm -- 211 mm IS in it + formula::band(173_r, 211.1_r), // 173 to under 211.1 mm -- 211 mm IS in it }; ``` @@ -160,7 +160,9 @@ claim). Both are refused at compile time. This is not a claim about the library; it is a file in it. `test/negative/lookup_band_gap.cpp` declares a four-row table with a gap in its *middle* pair — a defect at either end is the easy case — and CI asserts both -that it fails to build and that it fails for the stated reason: +that it fails to build and that it fails for the stated reason. The test spells +the bounds as integer pairs, which is what `band(0, 127)` stores; it is written +the long way on purpose, to exercise that overload: ```cpp inline constexpr formula::BandTable<4> GappedTable { diff --git a/examples/constraints.cpp b/examples/constraints.cpp index f60b0b3d..2b926048 100644 --- a/examples/constraints.cpp +++ b/examples/constraints.cpp @@ -15,6 +15,7 @@ // differ on purpose rather than being inconsistent. // // Every citation here is invented -- generic physics with fictional Example +// Standard references, exactly as every other example in this repository is. #include #include diff --git a/examples/lookup_tables.cpp b/examples/lookup_tables.cpp index e8835c64..6e0cdb35 100644 --- a/examples/lookup_tables.cpp +++ b/examples/lookup_tables.cpp @@ -72,7 +72,7 @@ inline constexpr formula::BandTable<3> SizeBands { // This is the caller's reconciliation to do, and there is deliberately no // closed-upper-bound flag on `Band` to do it with (band.hpp says why). inline constexpr formula::BandTable<1> TopRowInclusive { - formula::band(173, 211.1_r), // 173 to under 211.1 mm -- 211 mm IS in it + formula::band(173_r, 211.1_r), // 173 to under 211.1 mm -- 211 mm IS in it }; // ---- The exact table -------------------------------------------------------- diff --git a/examples/rounding_and_conditionals.cpp b/examples/rounding_and_conditionals.cpp index 056ddd0b..7cc8a7a4 100644 --- a/examples/rounding_and_conditionals.cpp +++ b/examples/rounding_and_conditionals.cpp @@ -22,11 +22,6 @@ // fictional Example Standard references, exactly as every other example in // this repository is. -#include -#include -#include -#include - #include #include #include From 315a4b44f770e9be234d20e6907ce8b065ade102 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 21:43:41 +0200 Subject: [PATCH 31/59] docs(examples): write statistics, opaque-and-retry and electricity-bill in the short spellings The last three examples now use decimal literals, Measured { n }, bound formulas, named roundings and the explain_* twins, and print with std::println. Every checked result is checked before it is used. Each retry is explained once and the check reads that result. A retry's end is printed with `{}` of RetryEnd, so the six ends read "accepted", "exhausted", "not judgeable", "not recorded", "failed" and "manually entered"; the guide and the output pin quote the new words. All other output is unchanged. The rejection header's snippet now states the literals namespace it uses. Signed-off-by: Christian Parpart --- docs/calculations.md | 116 +++++---- docs/opaque-and-retry.md | 57 ++-- docs/statistics.md | 36 ++- examples/CMakeLists.txt | 2 +- examples/electricity_bill.cpp | 201 +++++++------- examples/opaque_and_retry.cpp | 420 ++++++++++++------------------ examples/statistics.cpp | 379 ++++++++++++++------------- include/formula-cpp/rejection.hpp | 2 + 8 files changed, 587 insertions(+), 626 deletions(-) diff --git a/docs/calculations.md b/docs/calculations.md index 689e648c..2be618c8 100644 --- a/docs/calculations.md +++ b/docs/calculations.md @@ -64,8 +64,8 @@ and spells three names short: ```cpp namespace unit = formula::unit; -using formula::Rational; using formula::var; +using namespace formula::literals; ``` ## Money of its own @@ -154,11 +154,21 @@ two plain numbers -- the share of the solar yield the household uses itself, and the rate of the tax -- are constants: ```cpp -inline constexpr Rational selfUseShare { 4, 5 }; -inline constexpr Rational vatRate { 19, 100 }; +inline constexpr auto selfUseShare = 0.8_r; +inline constexpr auto vatRate = 0.19_r; ``` -and every other value is defined once, by what it is calculated from: +The rounding of a bill to whole cents is stated once as well, and every +rounding of the calculation names it: + +```cpp +// A bill in whole cents: the euro cent's own decimals, rounded half away from +// zero. +inline constexpr formula::DecimalRounding wholeCents = + formula::declared_rounding(EuroCent, formula::RoundingMode::HalfAwayFromZero); +``` + +Every other value is defined once, by what it is calculated from: ```cpp inline constexpr auto bill = formula::calculation( @@ -167,7 +177,7 @@ inline constexpr auto bill = formula::calculation( formula::define(var * var), formula::define(var * var), formula::define(var + var + var), - formula::define(var * Rational { 30 }), + formula::define(var * 30_r), formula::define(var * selfUseShare), formula::define(var - var), formula::define(var - var), @@ -176,9 +186,7 @@ inline constexpr auto bill = formula::calculation( formula::define(var - var), formula::define(var + var), formula::define(var * vatRate), - formula::define( - formula::rounded( - var + var))); + formula::define(formula::rounded(var + var))); ``` A definition is an ordinary formula of single values, and may hold what such @@ -200,12 +208,11 @@ numbers as decimals where that is their exact value ([Displaying numbers](display.md)): ```cpp -formula::NumberStyle const decimals = formula::NumberStyle::exact_decimal(); +auto const decimals = formula::NumberStyle::exact_decimal(); ``` ```cpp -std::printf("the calculation, in the order it calculates:\n%s\n\n", - formula::render(bill, formula::DefaultVocabulary {}, { .numbers = decimals }).c_str()); +std::println("the calculation, in the order it calculates:\n{}\n", formula::render(bill, { .numbers = decimals })); ``` ```text @@ -249,12 +256,12 @@ first, in the order the definitions first read them, then the calculated values in the order the calculation calculates them: ```cpp -std::printf("inputs : %s\n", listed(formula::inputs_of(bill)).c_str()); -std::printf("calculation order : %s\n", listed(formula::calculation_order(bill)).c_str()); -std::printf("grid_cost reads : %s\n", listed(formula::dependencies_of(bill)).c_str()); -std::printf("affected by price : %s\n", listed(formula::affected_by(bill)).c_str()); -std::printf("upstream of net_draw : %s\n", listed(formula::upstream_of(bill)).c_str()); -std::printf("read by self_used : %s\n", listed(formula::dependents_of(bill)).c_str()); +std::println("inputs : {}", listed(formula::inputs_of(bill))); +std::println("calculation order : {}", listed(formula::calculation_order(bill))); +std::println("grid_cost reads : {}", listed(formula::dependencies_of(bill))); +std::println("affected by price : {}", listed(formula::affected_by(bill))); +std::println("upstream of net_draw : {}", listed(formula::upstream_of(bill))); +std::println("read by self_used : {}", listed(formula::dependents_of(bill))); ``` ```text @@ -285,7 +292,7 @@ Graphviz, for `dot -Tsvg` to draw, the inputs as boxes and the calculated values as ellipses: ```cpp -std::printf("its graph:\n%s\n", formula::describe_graph(bill).c_str()); +std::println("its graph:\n{}", formula::describe_graph(bill)); ``` ```text @@ -335,7 +342,7 @@ and ends with the arrows into the tax and the total: too: ```cpp -formula::Documentation const page = formula::document(bill, formula::DefaultVocabulary {}, { .numbers = decimals }); +formula::Documentation const page = formula::document(bill, { .numbers = decimals }); ``` Its [symbol table](citations.md#the-symbol-table-and-its-ordering-rule), @@ -364,16 +371,16 @@ exactly the environment a formula is evaluated in ```cpp auto sheet = formula::worksheet(bill, - formula::environment(formula::Measured { Rational { 200 } }, - formula::Measured { Rational { 24 } }, - formula::Measured { Rational { 5, 2 } }, - formula::Measured { Rational { 1 } }, - formula::Measured { Rational { 3, 2 } }, - formula::Measured { Rational { 4 } }, - formula::Measured { Rational { 150 } }, - formula::Measured { Rational { 8, 25 } }, - formula::Measured { Rational { 2, 25 } }, - formula::Measured { Rational { 25, 2 } })); + formula::environment(formula::Measured { 200 }, + formula::Measured { 24 }, + formula::Measured { 2.5_r }, + formula::Measured { 1 }, + formula::Measured { 1.5_r }, + formula::Measured { 4 }, + formula::Measured { 150 }, + formula::Measured { 0.32_r }, + formula::Measured { 0.08_r }, + formula::Measured { 12.5_r })); ``` Every input must be given. One nobody measured is given as @@ -394,21 +401,21 @@ typed in by hand. Asked for several values at once, it answers with a auto const [total, netDraw] = sheet.calculate(); ``` -The example spells each answer with `std::format` (`format.hpp`). +The example prints each answer with `std::println` (`format.hpp`). `.2HalfAwayFromZero` rounds to two places and pads to them: the total is whole -cents already, so only the padding shows, and the mode -- which `std::format` +cents already, so only the padding shows, and the mode -- which `std::println` requires on every rounding ([Displaying numbers](display.md#rounding-modes-and-why-none-is-assumed)) -- is the bill's own. `{}` writes the net draw's exact decimal. Each comes with its unit: ```cpp -std::format("{:<26} total {:.2HalfAwayFromZero}, net draw {}, recomputed {}, reused {}", - step, - total.measurement(), - netDraw.measurement(), - counted.recomputed, - counted.reused) +std::println("{:<26} total {:.2HalfAwayFromZero}, net draw {}, recomputed {}, reused {}", + step, + total.measurement(), + netDraw.measurement(), + counted.recomputed, + counted.reused); ``` ```text @@ -445,7 +452,7 @@ is not calculated at all. `set(...)` gives an input a new value: ```cpp -sheet.set(formula::Measured { Rational { 1, 4 } }); +sheet.set(formula::Measured { 0.25_r }); ``` ```text @@ -462,7 +469,7 @@ five values, and the total is 95.02 EUR. second time marks nothing, and the next question calculates nothing: ```cpp -sheet.set(formula::Measured { Rational { 1, 4 } }); +sheet.set(formula::Measured { 0.25_r }); ``` ```text @@ -472,7 +479,7 @@ the same price again: total 95.02 EUR, net draw 279 kWh, recomputed 0, reus A new base fee reaches three values, the subtotal, the tax and the total: ```cpp -sheet.set(formula::Measured { Rational { 15 } }); +sheet.set(formula::Measured { 15 }); ``` ```text @@ -483,7 +490,7 @@ base fee 15 EUR: total 98.00 EUR, net draw 279 kWh, recomputed 3, reus can be set at once; here the fridge draws twice the power for half the time: ```cpp -sheet.set(formula::Measured { Rational { 400 } }, formula::Measured { Rational { 12 } }); +sheet.set(formula::Measured { 400 }, formula::Measured { 12 }); ``` ```text @@ -504,7 +511,7 @@ and all, leaves what reads it alone. was asked of as it was: ```cpp -auto sunnier = sheet.with(formula::Measured { Rational { 200 } }); +auto sunnier = sheet.with(formula::Measured { 200 }); ``` The copy holds everything the worksheet had calculated, so a question to it @@ -604,7 +611,7 @@ reading, say, in place of the net draw worked out from the appliances. Set it with `entered(...)`, as a value typed in: ```cpp -sheet.set(formula::entered(formula::Measured { Rational { 250 } })); +sheet.set(formula::entered(formula::Measured { 250 })); ``` ```text @@ -660,9 +667,7 @@ whole cents. ```cpp inline constexpr auto sharing = formula::calculation( formula::define(var / var), - formula::define( - formula::rounded( - var))); + formula::define(formula::rounded(var))); ``` The bill's total is the second calculation's input -- one calculation's @@ -676,16 +681,13 @@ number re-wrapped, `Measured { total.value() }`, would do none of that, and would be wrong the moment the two units differ: ```cpp -std::expected, formula::ArithmeticError> const sharedCost = - formula::checked_convert_to(sheet.calculate().measurement()); +auto const sharedCost = formula::checked_convert_to(sheet.calculate().measurement()); if (!sharedCost.has_value()) { - std::printf("the total is not a cost to share: %s\n", - std::string { formula::describe(sharedCost.error()) }.c_str()); + std::println("the total is not a cost to share: {}", sharedCost.error()); return 1; } -auto shares = formula::worksheet( - sharing, formula::environment(*sharedCost, formula::Measured { Rational { 3 } })); +auto shares = formula::worksheet(sharing, formula::environment(*sharedCost, formula::Measured { 3 })); ``` ```text @@ -696,7 +698,7 @@ With nobody to share it, the share divides by zero, and the share in cents, which reads it, fails with it: ```cpp -shares.set(formula::Measured { Rational { 0 } }); +shares.set(formula::Measured { 0 }); auto const [share, shareInCents] = shares.checked_calculate(); ``` @@ -720,10 +722,10 @@ try } catch (formula::ArithmeticException const& failure) { - std::printf("calculate() threw: %s\n", failure.what()); + std::println("calculate() threw: {}", failure.what()); thrown = failure.code() == formula::ArithmeticError::DivisionByZero; } -std::printf("asked again: recomputed %zu\n", shares.recomputed() - failed.recomputed); +std::println("asked again: recomputed {}", shares.recomputed() - failed.recomputed); ``` ```text @@ -736,8 +738,8 @@ the step that read it: ```cpp auto const failedShare = formula::explain_worksheet(shares); -std::printf("\nhow the failure was reached:\n%s", - formula::render_derivation(failedShare, { .maxSteps = 12, .numbers = decimals }).c_str()); +std::print("\nhow the failure was reached:\n{}", + formula::render_derivation(failedShare, { .maxSteps = 12, .numbers = decimals })); ``` ```text diff --git a/docs/opaque-and-retry.md b/docs/opaque-and-retry.md index 6bb2d9a7..1795591a 100644 --- a/docs/opaque-and-retry.md +++ b/docs/opaque-and-retry.md @@ -234,7 +234,8 @@ does not need the exact fraction: it needs the decimal that fraction rounds to. `rounded_output` states that precision, as `rounded<>` does. For `linear_least_squares`, which computes in wider integers, the library computes that decimal exactly, even where the exact fit leaves `Rational`'s -range: +range. The example names the slope's precision once, the unit's own decimals, +because it rounds the slope in three places: ```cpp constexpr formula::Unit millimetrePerSecond { .dimension = formula::dim::Velocity, @@ -242,9 +243,9 @@ constexpr formula::Unit millimetrePerSecond { .dimension = formula::dim::Velocit .magnitudeDenominator = 1000, .symbolText = formula::symbol("mm/s"), .decimals = 4 }; -constexpr auto roundedSlope = - formula::rounded_output<"slope", millimetrePerSecond, formula::DecimalPlaces { 4 }, formula::RoundingMode::HalfEven>( - fit); +constexpr formula::DecimalRounding slopeRounding = + formula::declared_rounding(millimetrePerSecond, formula::RoundingMode::HalfEven); +constexpr auto roundedSlope = formula::rounded_output<"slope", slopeRounding>(fit); ``` ```text @@ -338,9 +339,8 @@ explains values the exact layer cannot hold. The example declares the slope this way: ```cpp -constexpr auto observedSlope = - formula::rounded_output<"slope", millimetrePerSecond, formula::DecimalPlaces { 4 }, formula::RoundingMode::HalfEven>( - observedFit); +constexpr auto observedLine = formula::yields(formula::opaque_output<"slope">(observedFit)); +constexpr auto observedSlope = formula::yields(formula::rounded_output<"slope", slopeRounding>(observedFit)); ``` ```text @@ -368,7 +368,7 @@ rounding never lift a fit over the line: ```cpp constexpr auto closeEnough = formula::constraint( formula::rounded_output<"r squared", unit::One, formula::DecimalPlaces { 4 }, formula::RoundingMode::Floor>(observedFit) - >= formula::constant(formula::Rational { 998, 1000 }), + >= formula::constant(0.998_r), formula::Verdict { "repeat the readings" }); ``` @@ -426,7 +426,7 @@ constexpr auto lengthAtZeroCelsius = formula::rounded( formula::opaque_output<"constant">(byTemperatureAndContent) + formula::opaque_output<"coefficient 1">(byTemperatureAndContent) - * formula::constant(formula::Rational { 27315, 100 })); + * formula::constant(273.15_r)); ``` ```text @@ -487,20 +487,31 @@ most 0.76 g. The sequence rises, so the acceptance is written `w(k-1) - w(k) >= -0.76 g`: ```cpp -constexpr auto halving = formula::constant(formula::Rational { 152, 25 }) - + formula::previous_attempt / formula::Rational { 2 }; +constexpr auto halving = formula::constant(6.08_r) + formula::previous_attempt / 2_r; constexpr auto settled = formula::previous_attempt - formula::this_attempt - >= formula::constant(formula::Rational { -19, 25 }); -constexpr auto fromZero = formula::starting_from(formula::constant(formula::Rational { 0 })); + >= formula::constant(-0.76_r); +constexpr auto fromZero = formula::starting_from(formula::constant(0_r)); constexpr formula::Verdict repeatDetermination { "repeat the determination" }; constexpr formula::Citation settledCitation { .title = "Settled estimate", .reference = "Example Standard 12", .section = "6" }; -constexpr auto fourAttempts = formula::retry( - fromZero, halving, settled, repeatDetermination, settledCitation); +/// The method's retry of the estimate: at most @p Max attempts, judged from the +/// first, the determination repeated when none is settled. +template +constexpr auto estimating(Steps... steps) +{ + return formula::retry( + steps..., repeatDetermination, settledCitation); +} + +constexpr auto fourAttempts = estimating<4>(fromZero, halving, settled); ``` +`estimating` states what the example's retries share -- the estimate, the +first attempt judged, the verdict and the citation -- so each retry names only +its attempts and its steps. + - **`previous_attempt`** is the attempt before, or the starting value at the first attempt; **`this_attempt`** is the value just produced, and is read only in the acceptance; **`attempt_number`** is the method's own `k`, @@ -547,17 +558,17 @@ A retry ends in exactly one of six ways (`RetryEnd`), and the example runs each. Each line below says how it ended, and the line its trace ends with: ```text -allowed four: Accepted after 4 attempt(s) +allowed four: accepted after 4 attempt(s) 42. w = retry: accepted at attempt 4 of 4 = 57/5 g [Settled estimate, Example Standard 12, 6] -allowed three: Exhausted after 3 attempt(s) +allowed three: exhausted after 3 attempt(s) 32. w = retry: exhausted after 3 of 3: repeat the determination [Settled estimate, Example Standard 12, 6] -tolerance not measured: NotJudgeable after 1 attempt(s) +tolerance not measured: not judgeable after 1 attempt(s) 14. w = retry: not judgeable at attempt 1 [Settled estimate, Example Standard 12, 6] -third determination missing: NotRecorded after 3 attempt(s) +third determination missing: not recorded after 3 attempt(s) 12. d_a = retry: attempt 3 not recorded [Agreed determination, Example Standard 12, 7] -divides by k - 1: Failed, division by zero at attempt 1 +divides by k - 1: failed, division by zero at attempt 1 12. w = retry: failed at attempt 1: division by zero [Settled estimate, Example Standard 12, 6] -typed in by a person: ManuallyEntered after 0 attempt(s); nothing traced +typed in by a person: manually entered after 0 attempt(s); nothing traced ``` - **Accepted:** the acceptance held; the outcome is that attempt's value. @@ -605,7 +616,7 @@ within 1.27 g" compares the absolute difference, written with `abs`. A ```cpp constexpr auto agree = formula::abs(formula::this_attempt - formula::previous_attempt) - <= formula::constant(formula::Rational { 127, 100 }); + <= formula::constant(1.27_r); constexpr auto successive = formula::retry( formula::attempt_input, @@ -616,7 +627,7 @@ constexpr auto successive = formula::retry; -inline constexpr auto mean = formula::sample_mean(determinations); +inline constexpr auto mean = formula::yields(formula::sample_mean(determinations)); inline constexpr auto count = formula::sample_count(determinations); inline constexpr auto variance = formula::sample_variance(determinations); inline constexpr auto range = formula::sample_range(determinations); @@ -94,8 +94,12 @@ square root itself, exactly: `rounded_sqrt` finds the correctly rounded decimal without ever forming an inexact root. ```cpp -inline constexpr auto spread = - formula::rounded_sqrt(variance); +/// The spread is reported to 2 dp of g. +inline constexpr formula::DecimalRounding spreadRounding { unit::Gram, + formula::DecimalPlaces { 2 }, + formula::RoundingMode::HalfAwayFromZero }; +/// The spread reported exactly: the variance's square root, rounded to 2 dp of g. +inline constexpr auto spread = formula::rounded_sqrt(variance); ``` ```text @@ -125,11 +129,22 @@ mean again, and repeats until a pass rejects nothing -- a value inside the limit at first can be outside it once another has gone. ```cpp -inline constexpr auto sixPercent = formula::deviation_from_mean(rat(6, 100) * formula::pass_mean); +template +[[nodiscard]] constexpr auto rejecting(Sample sample, Criterion criterion) +{ + return formula::without_outliers( + sample, criterion, repeatTest, rejectionRule); +} +``` + +The example states what its method fixes once, in `rejecting`, and each +rejection below names only what differs: + +```cpp +inline constexpr auto sixPercent = formula::deviation_from_mean(0.06_r * formula::pass_mean); inline constexpr auto withoutOutliers = - formula::without_outliers, formula::KeepAtLeast<4>>( - determinations, sixPercent, repeatTest, rejectionRule); + rejecting, formula::KeepAtLeast<4>>(determinations, sixPercent); ``` Every parameter that shapes the result is required, none defaulted: @@ -233,7 +248,7 @@ limit² × s² instead, which is the same decision for a limit of zero or more, and the trace shows the squares it compared: ```cpp -inline constexpr auto sevenQuarters = formula::deviation_in_stddevs(formula::number(rat(7, 4))); +inline constexpr auto sevenQuarters = formula::deviation_in_stddevs(formula::number(7_r / 4)); ``` ```text @@ -254,9 +269,8 @@ limits could not be exceeded by any sample: ```cpp inline constexpr formula::SampleSizeTable<5> declaredSizes { 3, 4, 5, 6, 8 }; inline constexpr auto gapLimit = formula::gap_to_range( - formula::critical_value(formula::pass_count, - { rat(900), rat(700), rat(30), rat(45), rat(5) }) - * rat(1, 100)); + formula::critical_value(formula::pass_count, { 900_r, 700_r, 30_r, 45_r, 5_r }) + * 0.01_r); ``` ```text @@ -288,7 +302,7 @@ changes the symbol and the trace's words, never the arithmetic. ```cpp inline constexpr auto limitAtLevel = - formula::constant(rat(1, 10)) + rat(1, 50) * formula::precision_level; + formula::constant(0.1_r) + 0.02_r * formula::precision_level; inline constexpr auto agreement = formula::constraint( formula::abs(var - var) diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index f439dbd5..f99ed1f7 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -277,7 +277,7 @@ add_test(NAME docs.records-snippets # kelvin and per percent, with a design with one regressor twice another # refused. No literal `;`: # PASS_REGULAR_EXPRESSION is a list property (see the lookup_tables entry). -formula_add_example(opaque_and_retry opaque_and_retry.cpp [==[series span\(r\(i\)\)\.span.*span = 88 g \[inside not shown\] \[Spread of readings, Example Standard 12, 4\.2\].*operation: series span, outputs: lowest highest span.*operation runs: 2.*slope = 19/28 mm/s \[inside not shown\].*one point: argument outside the domain of the operation.*fifteen distinct denominators: overflow in exact arithmetic.*round\(linear least squares\(t\(i\), L\(i\)\)\.slope, to 4 dp of mm/s\).*4\. linear least squares\(#3\) = intercept, slope: rounded where used \[inside not shown\].*5\. round\(slope of #4, to 4 dp of mm/s\) = 3393/5000 mm/s \[nearest, ties to even\].*fifteen distinct denominators, rounded where used: 116\.232 mm/min.*\[inside not shown\] \(no citation given\).*up to 4 attempts: w\(k\) = 152/25 g \+ w\(k-1\) / 2, starting from w\(0\) = 0 g.*accepted at attempt 4 of 4 = 57/5 g.*exhausted after 3 of 3: repeat the determination.*not judgeable at attempt 1.*d_a = retry: attempt 3 not recorded.*failed at attempt 1: division by zero.*ManuallyEntered after 0 attempt\(s\).*w\(k-1\) = previous attempt: none before attempt 1.*accept from attempt 2 when abs\(d_a\(k\) - d_a\(k-1\)\) <= 127/100 g.*accepted at attempt 3 of 4 = 427/10 g.*linear least squares\(#1, #2\) = intercept = 19/2 mm. slope = 19/28 mm/s. r squared = 1083/1085. points = 4 \[inside not shown\].*rounded where used.*r squared at 4 dp, floored, at least 0\.998: satisfied.*flat lengths: argument outside the domain of the operation.*fifty readings at 4 decimals, exact: overflow in exact arithmetic.*fifty readings at 4 decimals, rounded: slope 3\.1707 mm/s, intercept 2406\.6455 mm, r squared 0\.999996.*multiple least squares\(#1, #2, #3\) = constant = 22365154943/276592800 mm.*coefficient 1: 0\.0751 mm/K, coefficient 2: 0\.5557 mm/%, length at 0 degrees Celsius: 101\.39 mm.*a delay twice the elapsed time on every row: argument outside the domain of the operation.*all checks passed: yes]==]) +formula_add_example(opaque_and_retry opaque_and_retry.cpp [==[series span\(r\(i\)\)\.span.*span = 88 g \[inside not shown\] \[Spread of readings, Example Standard 12, 4\.2\].*operation: series span, outputs: lowest highest span.*operation runs: 2.*slope = 19/28 mm/s \[inside not shown\].*one point: argument outside the domain of the operation.*fifteen distinct denominators: overflow in exact arithmetic.*round\(linear least squares\(t\(i\), L\(i\)\)\.slope, to 4 dp of mm/s\).*4\. linear least squares\(#3\) = intercept, slope: rounded where used \[inside not shown\].*5\. round\(slope of #4, to 4 dp of mm/s\) = 3393/5000 mm/s \[nearest, ties to even\].*fifteen distinct denominators, rounded where used: 116\.232 mm/min.*\[inside not shown\] \(no citation given\).*up to 4 attempts: w\(k\) = 152/25 g \+ w\(k-1\) / 2, starting from w\(0\) = 0 g.*accepted at attempt 4 of 4 = 57/5 g.*exhausted after 3 of 3: repeat the determination.*not judgeable at attempt 1.*d_a = retry: attempt 3 not recorded.*failed at attempt 1: division by zero.*manually entered after 0 attempt\(s\).*w\(k-1\) = previous attempt: none before attempt 1.*accept from attempt 2 when abs\(d_a\(k\) - d_a\(k-1\)\) <= 127/100 g.*accepted at attempt 3 of 4 = 427/10 g.*linear least squares\(#1, #2\) = intercept = 19/2 mm. slope = 19/28 mm/s. r squared = 1083/1085. points = 4 \[inside not shown\].*rounded where used.*r squared at 4 dp, floored, at least 0\.998: satisfied.*flat lengths: argument outside the domain of the operation.*fifty readings at 4 decimals, exact: overflow in exact arithmetic.*fifty readings at 4 decimals, rounded: slope 3\.1707 mm/s, intercept 2406\.6455 mm, r squared 0\.999996.*multiple least squares\(#1, #2, #3\) = constant = 22365154943/276592800 mm.*coefficient 1: 0\.0751 mm/K, coefficient 2: 0\.5557 mm/%, length at 0 degrees Celsius: 101\.39 mm.*a delay twice the elapsed time on every row: argument outside the domain of the operation.*all checks passed: yes]==]) # Every output block of docs/opaque-and-retry.md must be a run of lines the # example really prints, and every code block must appear in its source. diff --git a/examples/electricity_bill.cpp b/examples/electricity_bill.cpp index 29c63646..1583de73 100644 --- a/examples/electricity_bill.cpp +++ b/examples/electricity_bill.cpp @@ -36,9 +36,7 @@ #include #include -#include -#include -#include +#include #include #include #include @@ -46,8 +44,8 @@ namespace { namespace unit = formula::unit; -using formula::Rational; using formula::var; +using namespace formula::literals; // ---- Money ---- // @@ -158,8 +156,13 @@ struct Total: formula::Quantity // Two numbers the bill states: the share of the solar yield the household // uses itself, and the rate of the tax. Both are pure numbers. -inline constexpr Rational selfUseShare { 4, 5 }; -inline constexpr Rational vatRate { 19, 100 }; +inline constexpr auto selfUseShare = 0.8_r; +inline constexpr auto vatRate = 0.19_r; + +// A bill in whole cents: the euro cent's own decimals, rounded half away from +// zero. +inline constexpr formula::DecimalRounding wholeCents = + formula::declared_rounding(EuroCent, formula::RoundingMode::HalfAwayFromZero); // ---- The calculation ---- // @@ -172,7 +175,7 @@ inline constexpr auto bill = formula::calculation( formula::define(var * var), formula::define(var * var), formula::define(var + var + var), - formula::define(var * Rational { 30 }), + formula::define(var * 30_r), formula::define(var * selfUseShare), formula::define(var - var), formula::define(var - var), @@ -181,9 +184,7 @@ inline constexpr auto bill = formula::calculation( formula::define(var - var), formula::define(var + var), formula::define(var * vatRate), - formula::define( - formula::rounded( - var + var))); + formula::define(formula::rounded(var + var))); // What reads what is known while the program compiles. static_assert(formula::depends_on(bill)); @@ -209,9 +210,7 @@ struct ShareInCents: formula::Quantity(var / var), - formula::define( - formula::rounded( - var))); + formula::define(formula::rounded(var))); /// @p names, comma-separated. template @@ -242,25 +241,24 @@ int main() { bool ok = true; auto check = [&ok](char const* what, bool condition) { - std::printf("%-58s %s\n", what, condition ? "yes" : "NO"); + std::println("{:<58} {}", what, condition ? "yes" : "NO"); ok = ok && condition; }; // Every number shown as a decimal where that is its exact value: the // self-use share 0.8, the fridge's 4.8 kWh a day. - formula::NumberStyle const decimals = formula::NumberStyle::exact_decimal(); + auto const decimals = formula::NumberStyle::exact_decimal(); // ---- 1. The calculation, and what reads what ---- - std::printf("the calculation, in the order it calculates:\n%s\n\n", - formula::render(bill, formula::DefaultVocabulary {}, { .numbers = decimals }).c_str()); - std::printf("its graph:\n%s\n", formula::describe_graph(bill).c_str()); - - std::printf("inputs : %s\n", listed(formula::inputs_of(bill)).c_str()); - std::printf("calculation order : %s\n", listed(formula::calculation_order(bill)).c_str()); - std::printf("grid_cost reads : %s\n", listed(formula::dependencies_of(bill)).c_str()); - std::printf("affected by price : %s\n", listed(formula::affected_by(bill)).c_str()); - std::printf("upstream of net_draw : %s\n", listed(formula::upstream_of(bill)).c_str()); - std::printf("read by self_used : %s\n", listed(formula::dependents_of(bill)).c_str()); + std::println("the calculation, in the order it calculates:\n{}\n", formula::render(bill, { .numbers = decimals })); + std::println("its graph:\n{}", formula::describe_graph(bill)); + + std::println("inputs : {}", listed(formula::inputs_of(bill))); + std::println("calculation order : {}", listed(formula::calculation_order(bill))); + std::println("grid_cost reads : {}", listed(formula::dependencies_of(bill))); + std::println("affected by price : {}", listed(formula::affected_by(bill))); + std::println("upstream of net_draw : {}", listed(formula::upstream_of(bill))); + std::println("read by self_used : {}", listed(formula::dependents_of(bill))); check("ten inputs, fifteen calculated, and grid_cost reads two", formula::inputs_of(bill).size() == 10 && formula::calculation_order(bill).size() == 15 && formula::dependencies_of(bill).size() == 2); @@ -268,20 +266,18 @@ int main() formula::affected_by(bill).size() == 5 && formula::dependents_of(bill).size() == 2); std::string const drawn = formula::to_dot(bill); - std::printf("\nfor Graphviz:\n%s\n", drawn.c_str()); + std::println("\nfor Graphviz:\n{}", drawn); // Its documentation page: a row per calculated value, with its definition, // then a row per input. - formula::Documentation const page = formula::document(bill, formula::DefaultVocabulary {}, { .numbers = decimals }); - std::printf("its symbol table:\n"); + formula::Documentation const page = formula::document(bill, { .numbers = decimals }); + std::println("its symbol table:"); for (formula::SymbolEntry const& symbolRow: page.symbols) - std::printf("%s\n", - std::format("{:<14} {:<35} {}", - symbolRow.symbol, - symbolRow.description, - symbolRow.calculatedAs.has_value() ? "calculated as " + *symbolRow.calculatedAs - : std::string { "an input" }) - .c_str()); + std::println("{:<14} {:<35} {}", + symbolRow.symbol, + symbolRow.description, + symbolRow.calculatedAs.has_value() ? "calculated as " + *symbolRow.calculatedAs + : std::string { "an input" }); check("25 rows, the self-use share in decimals", page.symbols.size() == 25 && page.symbols[6].calculatedAs == "solar * 0.8"); @@ -289,16 +285,16 @@ int main() // // The inputs, as an environment of measurements. Nothing is calculated yet. auto sheet = formula::worksheet(bill, - formula::environment(formula::Measured { Rational { 200 } }, - formula::Measured { Rational { 24 } }, - formula::Measured { Rational { 5, 2 } }, - formula::Measured { Rational { 1 } }, - formula::Measured { Rational { 3, 2 } }, - formula::Measured { Rational { 4 } }, - formula::Measured { Rational { 150 } }, - formula::Measured { Rational { 8, 25 } }, - formula::Measured { Rational { 2, 25 } }, - formula::Measured { Rational { 25, 2 } })); + formula::environment(formula::Measured { 200 }, + formula::Measured { 24 }, + formula::Measured { 2.5_r }, + formula::Measured { 1 }, + formula::Measured { 1.5_r }, + formula::Measured { 4 }, + formula::Measured { 150 }, + formula::Measured { 0.32_r }, + formula::Measured { 0.08_r }, + formula::Measured { 12.5_r })); Counters before = counters_of(sheet); // Asks for the total and the net draw, and says how many values that @@ -310,44 +306,42 @@ int main() .reused = after.reused - before.reused }; before = after; // The total is in whole cents already; .2 pads it to them: 98.00 EUR. - std::printf("%s\n", - std::format("{:<26} total {:.2HalfAwayFromZero}, net draw {}, recomputed {}, reused {}", - step, - total.measurement(), - netDraw.measurement(), - counted.recomputed, - counted.reused) - .c_str()); - return std::pair { total.measurement().value(), counted }; + std::println("{:<26} total {:.2HalfAwayFromZero}, net draw {}, recomputed {}, reused {}", + step, + total.measurement(), + netDraw.measurement(), + counted.recomputed, + counted.reused); + return std::pair { formula::number_of(total), counted }; }; auto const [firstTotal, firstCount] = report("first run:"); check("118.26 EUR, every value calculated once", - firstTotal == Rational { 11826, 100 } && firstCount.recomputed == 15 && firstCount.reused == 0); - check("279 kWh drawn from the grid", sheet.calculate().measurement().value() == Rational { 279 }); + firstTotal == 118.26_r && firstCount.recomputed == 15 && firstCount.reused == 0); + check("279 kWh drawn from the grid", formula::number_of(sheet.calculate()) == 279_r); // ---- 3. Changes, and what each recalculates ---- // // A new price reaches the grid cost and what is built on it: five values. - sheet.set(formula::Measured { Rational { 1, 4 } }); + sheet.set(formula::Measured { 0.25_r }); auto const [cheaperTotal, cheaperCount] = report("price 0.25 EUR/kWh:"); check("95.02 EUR, five values recalculated", - cheaperTotal == Rational { 9502, 100 } && cheaperCount.recomputed == 5 && cheaperCount.reused == 0); + cheaperTotal == 95.02_r && cheaperCount.recomputed == 5 && cheaperCount.reused == 0); // The same price again changes nothing, and nothing is recalculated. - sheet.set(formula::Measured { Rational { 1, 4 } }); + sheet.set(formula::Measured { 0.25_r }); auto const [samePriceTotal, samePriceCount] = report("the same price again:"); check("nothing recalculated", samePriceTotal == cheaperTotal && samePriceCount.recomputed == 0 && samePriceCount.reused == 0); - sheet.set(formula::Measured { Rational { 15 } }); + sheet.set(formula::Measured { 15 }); auto const [feeTotal, feeCount] = report("base fee 15 EUR:"); check("98.00 EUR, three values recalculated", - feeTotal == Rational { 9800, 100 } && feeCount.recomputed == 3 && feeCount.reused == 0); + feeTotal == 98_r && feeCount.recomputed == 3 && feeCount.reused == 0); // Twice the power for half the time: the fridge's energy a day is the // same, so what reads it is reused rather than recalculated. - sheet.set(formula::Measured { Rational { 400 } }, formula::Measured { Rational { 12 } }); + sheet.set(formula::Measured { 400 }, formula::Measured { 12 }); auto const [fridgeTotal, fridgeCount] = report("fridge 400 W for 12 h:"); check("two recalculated, eight reused, the total unchanged", fridgeTotal == feeTotal && fridgeCount.recomputed == 2 && fridgeCount.reused == 8); @@ -361,28 +355,25 @@ int main() Counters const beforeExplaining = counters_of(sheet); auto const dailyLoad = formula::explain_worksheet(sheet); std::string const dailyText = formula::render_derivation(dailyLoad, { .maxSteps = 30, .numbers = decimals }); - std::printf("\nhow the daily load was reached:\n%s", dailyText.c_str()); + std::print("\nhow the daily load was reached:\n{}", dailyText); check("the reused daily load reads the fridge's 400 W, and not 200 W", - dailyText.find("fridge_w = 400 W") != std::string::npos && dailyText.find("200 W") == std::string::npos); + dailyText.contains("fridge_w = 400 W") && !dailyText.contains("200 W")); check("recording it calculated nothing", sheet.recomputed() == beforeExplaining.recomputed && sheet.reused() == beforeExplaining.reused); // ---- 5. What if the sun shone more? ---- // // with() answers on a copy; the worksheet itself is left as it was. - auto sunnier = sheet.with(formula::Measured { Rational { 200 } }); + auto sunnier = sheet.with(formula::Measured { 200 }); Counters const copied = counters_of(sunnier); auto const [sunnierTotal, sunnierDraw] = sunnier.calculate(var, var); - std::printf("\n%s\n", - std::format("{:<26} total {:.2HalfAwayFromZero}, net draw {}, recomputed {}", - "with 200 kWh of sun:", - sunnierTotal.measurement(), - sunnierDraw.measurement(), - sunnier.recomputed() - copied.recomputed) - .c_str()); + std::println("\n{:<26} total {:.2HalfAwayFromZero}, net draw {}, recomputed {}", + "with 200 kWh of sun:", + sunnierTotal.measurement(), + sunnierDraw.measurement(), + sunnier.recomputed() - copied.recomputed); check("85.14 EUR and 239 kWh on the copy, nine recalculated", - sunnierTotal.measurement().value() == Rational { 8514, 100 } - && sunnierDraw.measurement().value() == Rational { 239 } + formula::number_of(sunnierTotal) == 85.14_r && formula::number_of(sunnierDraw) == 239_r && sunnier.recomputed() - copied.recomputed == 9); auto const [originalTotal, originalCount] = report("the worksheet itself:"); check("the worksheet itself unchanged, nothing recalculated", @@ -392,10 +383,10 @@ int main() // // A meter reading of 250 kWh overrides the calculated net draw: what reads // it is recalculated, and what it was calculated from is no longer read. - sheet.set(formula::entered(formula::Measured { Rational { 250 } })); + sheet.set(formula::entered(formula::Measured { 250 })); auto const [overriddenTotal, overriddenCount] = report("net draw typed in:"); check("89.37 EUR from 250 kWh typed in", - overriddenTotal == Rational { 8937, 100 } && sheet.is_overridden() + overriddenTotal == 89.37_r && sheet.is_overridden() && sheet.calculate().source() == formula::ValueSource::ManuallyEntered && overriddenCount.recomputed == 5); @@ -404,10 +395,9 @@ int main() // many it left out. auto const gridCost = formula::explain_worksheet(sheet); std::string const gridText = formula::render_derivation(gridCost, { .maxSteps = 5, .numbers = decimals }); - std::printf("\nhow the grid cost was reached, in five lines:\n%s", gridText.c_str()); + std::print("\nhow the grid cost was reached, in five lines:\n{}", gridText); check("the override in place of its definition, one line left out", - gridText.find("net_draw = 250 kWh, entered by hand in place of monthly_load - self_used\n") - != std::string::npos + gridText.contains("net_draw = 250 kWh, entered by hand in place of monthly_load - self_used\n") && gridText.ends_with("... 1 further step not shown\n")); sheet.clear_override(); @@ -421,31 +411,26 @@ int main() // cents: the bill's total, read as the second calculation's input. It is // converted, not re-wrapped: exactly into the input's unit, an absent // total staying absent, and a total of another dimension refused. - std::expected, formula::ArithmeticError> const sharedCost = - formula::checked_convert_to(sheet.calculate().measurement()); + auto const sharedCost = formula::checked_convert_to(sheet.calculate().measurement()); if (!sharedCost.has_value()) { - std::printf("the total is not a cost to share: %s\n", - std::string { formula::describe(sharedCost.error()) }.c_str()); + std::println("the total is not a cost to share: {}", sharedCost.error()); return 1; } - auto shares = formula::worksheet( - sharing, formula::environment(*sharedCost, formula::Measured { Rational { 3 } })); - formula::Measured const eachInCents = shares.calculate().measurement(); - std::printf("\n%s\n", - std::format("{:.2HalfAwayFromZero} shared by 3: {} each", *sharedCost, eachInCents).c_str()); - check("32.67 EUR each", eachInCents.value() == Rational { 3267, 100 }); + auto shares = formula::worksheet(sharing, formula::environment(*sharedCost, formula::Measured { 3 })); + auto const eachInCents = shares.calculate().measurement(); + std::println("\n{:.2HalfAwayFromZero} shared by 3: {} each", *sharedCost, eachInCents); + check("32.67 EUR each", formula::number_of(eachInCents) == 32.67_r); // ---- 8. A failure, and what reads it ---- // // Nobody to share it: the share divides by zero, and the share in cents, // which reads it, fails with it. - shares.set(formula::Measured { Rational { 0 } }); + shares.set(formula::Measured { 0 }); auto const [share, shareInCents] = shares.checked_calculate(); - std::printf("shared by nobody: share: %s, in cents: %s\n", - share.has_value() ? "a value" : std::string { formula::describe(share.error()) }.c_str(), - shareInCents.has_value() ? "a value" - : std::string { formula::describe(shareInCents.error()) }.c_str()); + std::println("shared by nobody: share: {}, in cents: {}", + share.has_value() ? "a value" : formula::describe(share.error()), + shareInCents.has_value() ? "a value" : formula::describe(shareInCents.error())); check("a division by zero, and the value reading it fails with it", !share.has_value() && share.error() == formula::ArithmeticError::DivisionByZero && !shareInCents.has_value() && shareInCents.error() == formula::ArithmeticError::DivisionByZero); @@ -460,31 +445,29 @@ int main() } catch (formula::ArithmeticException const& failure) { - std::printf("calculate() threw: %s\n", failure.what()); + std::println("calculate() threw: {}", failure.what()); thrown = failure.code() == formula::ArithmeticError::DivisionByZero; } - std::printf("asked again: recomputed %zu\n", shares.recomputed() - failed.recomputed); + std::println("asked again: recomputed {}", shares.recomputed() - failed.recomputed); check("calculate() throws what checked_calculate() returns", thrown); check("the failure was not calculated again", shares.recomputed() == failed.recomputed); auto const failedShare = formula::explain_worksheet(shares); - std::printf("\nhow the failure was reached:\n%s", - formula::render_derivation(failedShare, { .maxSteps = 12, .numbers = decimals }).c_str()); + std::print("\nhow the failure was reached:\n{}", + formula::render_derivation(failedShare, { .maxSteps = 12, .numbers = decimals })); // A new cost, and still nobody to share it: the share is calculated again // and fails with the same error, which counts as unchanged, so the share // in cents, which reads only the share, is reused. Counters const beforeNewCost = counters_of(shares); - formula::Measured const newCost { Rational { 100 } }; + formula::Measured const newCost { 100 }; shares.set(newCost); auto const stillShared = shares.checked_calculate(); Counters const afterNewCost = counters_of(shares); - std::printf("\n%s\n", - std::format("{:.2HalfAwayFromZero}, still shared by nobody: recomputed {}, reused {}", - newCost, - afterNewCost.recomputed - beforeNewCost.recomputed, - afterNewCost.reused - beforeNewCost.reused) - .c_str()); + std::println("\n{:.2HalfAwayFromZero}, still shared by nobody: recomputed {}, reused {}", + newCost, + afterNewCost.recomputed - beforeNewCost.recomputed, + afterNewCost.reused - beforeNewCost.reused); check("failing again the same way counts as unchanged", !stillShared.has_value() && stillShared.error() == formula::ArithmeticError::DivisionByZero && afterNewCost.recomputed - beforeNewCost.recomputed == 1 @@ -496,13 +479,9 @@ int main() // is every share -- not zero, and not a failure. shares.set(formula::Measured::absent()); auto const [uncountedShare, uncountedInCents] = shares.calculate(); - std::printf("\n%s\n", - std::format("occupants not counted: share {}, in cents {}", - uncountedShare.measurement(), - uncountedInCents.measurement()) - .c_str()); + std::println("\noccupants not counted: share {}, in cents {}", uncountedShare.measurement(), uncountedInCents.measurement()); check("an absent input leaves what reads it empty", uncountedShare.is_empty() && uncountedInCents.is_empty()); - std::printf("\nall checks passed: %s\n", ok ? "yes" : "no"); + std::println("\nall checks passed: {}", ok ? "yes" : "no"); return ok ? 0 : 1; } diff --git a/examples/opaque_and_retry.cpp b/examples/opaque_and_retry.cpp index f22be01f..82b925b3 100644 --- a/examples/opaque_and_retry.cpp +++ b/examples/opaque_and_retry.cpp @@ -28,6 +28,7 @@ // repository; nothing here cites a standard. #include +#include #include #include #include @@ -36,9 +37,9 @@ #include #include #include -#include #include #include +#include #include #include #include @@ -47,6 +48,7 @@ namespace { namespace unit = formula::unit; using formula::var; +using namespace formula::literals; // ---- 1. An opaque operation ---------------------------------------------------- @@ -88,11 +90,7 @@ struct SeriesSpan } }; -constexpr auto readings = - formula::environment(formula::measured_series(formula::Measured { formula::Rational { 127 } }, - formula::Measured { formula::Rational { 103 } }, - formula::Measured { formula::Rational { 191 } }, - formula::Measured { formula::Rational { 139 } })); +constexpr auto readings = formula::environment(formula::measured_series(127, 103, 191, 139)); constexpr auto spanCall = formula::opaque( { .title = "Spread of readings", .reference = "Example Standard 12", .section = "4.2" }, formula::series); @@ -108,15 +106,8 @@ using Elapsed = formula::Quantity; using Rate = formula::Quantity; -constexpr auto points = - formula::environment(formula::measured_series(formula::Measured { formula::Rational { 1 } }, - formula::Measured { formula::Rational { 2 } }, - formula::Measured { formula::Rational { 4 } }, - formula::Measured { formula::Rational { 7 } }), - formula::measured_series(formula::Measured { formula::Rational { 102, 10 } }, - formula::Measured { formula::Rational { 109, 10 } }, - formula::Measured { formula::Rational { 121, 10 } }, - formula::Measured { formula::Rational { 143, 10 } })); +constexpr auto points = formula::environment(formula::measured_series(1, 2, 4, 7), + formula::measured_series(10.2_r, 10.9_r, 12.1_r, 14.3_r)); constexpr auto fit = formula::linear_least_squares(formula::curve(formula::series, formula::series), @@ -127,9 +118,9 @@ constexpr formula::Unit millimetrePerSecond { .dimension = formula::dim::Velocit .magnitudeDenominator = 1000, .symbolText = formula::symbol("mm/s"), .decimals = 4 }; -constexpr auto roundedSlope = - formula::rounded_output<"slope", millimetrePerSecond, formula::DecimalPlaces { 4 }, formula::RoundingMode::HalfEven>( - fit); +constexpr formula::DecimalRounding slopeRounding = + formula::declared_rounding(millimetrePerSecond, formula::RoundingMode::HalfEven); +constexpr auto roundedSlope = formula::rounded_output<"slope", slopeRounding>(fit); /// Fifteen points, each on a different denominator: point k at /// ((k + 1)/(k + 2) s, (2k + 3)/(k + 3) mm). @@ -155,20 +146,26 @@ using Tolerance = formula::Quantity= -0.76 g, since the sequence rises. -constexpr auto halving = formula::constant(formula::Rational { 152, 25 }) - + formula::previous_attempt / formula::Rational { 2 }; +constexpr auto halving = formula::constant(6.08_r) + formula::previous_attempt / 2_r; constexpr auto settled = formula::previous_attempt - formula::this_attempt - >= formula::constant(formula::Rational { -19, 25 }); -constexpr auto fromZero = formula::starting_from(formula::constant(formula::Rational { 0 })); + >= formula::constant(-0.76_r); +constexpr auto fromZero = formula::starting_from(formula::constant(0_r)); constexpr formula::Verdict repeatDetermination { "repeat the determination" }; constexpr formula::Citation settledCitation { .title = "Settled estimate", .reference = "Example Standard 12", .section = "6" }; -constexpr auto fourAttempts = formula::retry( - fromZero, halving, settled, repeatDetermination, settledCitation); -constexpr auto threeAttempts = formula::retry( - fromZero, halving, settled, repeatDetermination, settledCitation); +/// The method's retry of the estimate: at most @p Max attempts, judged from the +/// first, the determination repeated when none is settled. +template +constexpr auto estimating(Steps... steps) +{ + return formula::retry( + steps..., repeatDetermination, settledCitation); +} + +constexpr auto fourAttempts = estimating<4>(fromZero, halving, settled); +constexpr auto threeAttempts = estimating<3>(fromZero, halving, settled); // ---- 6. Two successive results agree ----------------------------------------------------- @@ -176,7 +173,7 @@ using Determination = formula::Quantity; constexpr auto agree = formula::abs(formula::this_attempt - formula::previous_attempt) - <= formula::constant(formula::Rational { 127, 100 }); + <= formula::constant(1.27_r); constexpr auto successive = formula::retry( formula::attempt_input, @@ -184,32 +181,6 @@ constexpr auto successive = formula::retry grams(std::int64_t tenths) -{ - return formula::Measured { formula::Rational { tenths, 10 } }; -} - -/// How a retry ended, in the enumerator's own name. -std::string_view endName(formula::RetryEnd ended) -{ - switch (ended) - { - case formula::RetryEnd::Accepted: - return "Accepted"; - case formula::RetryEnd::Exhausted: - return "Exhausted"; - case formula::RetryEnd::NotJudgeable: - return "NotJudgeable"; - case formula::RetryEnd::NotRecorded: - return "NotRecorded"; - case formula::RetryEnd::Failed: - return "Failed"; - case formula::RetryEnd::ManuallyEntered: - return "ManuallyEntered"; - } - return "unknown"; -} - /// The last line of @p text, without its newline. std::string lastLine(std::string const& text) { @@ -220,26 +191,27 @@ std::string lastLine(std::string const& text) return lineStart == std::string::npos ? trimmed : trimmed.substr(lineStart + 1); } -/// One line saying how @p retrying ended over @p environment, and the line -/// its trace ends with -- or the failure, when it failed. -template -std::string ending(char const* label, Retrying const& retrying, Env const& environment) +/// One line saying how a retry ended, and the line its trace ends with -- or +/// the failure, when it failed. +template +void printEnding(char const* label, formula::ExplainedRetry const& explained) { - auto const explained = formula::explain_retry(retrying, environment); - std::string line = std::string { label } + ": "; if (!explained.outcome.has_value()) { formula::RetryFailure const failure = explained.outcome.error(); - line += "Failed, " + std::string { formula::describe(failure.error) } - + (failure.attempt == formula::RetryFailure::atStartingValue - ? std::string { " at its starting value" } - : " at attempt " + std::to_string(failure.attempt + 1)); + std::print("{}: {}, {}", label, formula::RetryEnd::Failed, failure.error); + if (failure.attempt == formula::RetryFailure::atStartingValue) + std::print(" at its starting value"); + else + std::print(" at attempt {}", failure.attempt + 1); } else - line += std::string { endName(explained.outcome->end()) } + " after " - + std::to_string(explained.outcome->attempts_made()) + " attempt(s)"; + std::print("{}: {} after {} attempt(s)", label, explained.outcome->end(), explained.outcome->attempts_made()); std::string const traced = formula::render_trace(explained.trace, { .maxSteps = 200 }); - return line + (traced.empty() ? std::string { "; nothing traced" } : "\n " + lastLine(traced)); + if (traced.empty()) + std::println("; nothing traced"); + else + std::println("\n {}", lastLine(traced)); } // ---- 7. A line through observations ------------------------------------------------- @@ -252,21 +224,16 @@ constexpr auto observedFit = formula::linear_least_squares(formula::observations, formula::observations, { .title = "Rate of change", .reference = "Example Standard 12", .section = "5.1" }); -constexpr auto observedSlope = - formula::rounded_output<"slope", millimetrePerSecond, formula::DecimalPlaces { 4 }, formula::RoundingMode::HalfEven>( - observedFit); +constexpr auto observedLine = formula::yields(formula::opaque_output<"slope">(observedFit)); +constexpr auto observedSlope = formula::yields(formula::rounded_output<"slope", slopeRounding>(observedFit)); constexpr auto closeEnough = formula::constraint( formula::rounded_output<"r squared", unit::One, formula::DecimalPlaces { 4 }, formula::RoundingMode::Floor>(observedFit) - >= formula::constant(formula::Rational { 998, 1000 }), + >= formula::constant(0.998_r), formula::Verdict { "repeat the readings" }); -constexpr auto observedPoints = formula::environment( - formula::MeasuredObservations( - formula::Rational { 1 }, formula::Rational { 2 }, formula::Rational { 4 }, formula::Rational { 7 }), - formula::MeasuredObservations(formula::Rational { 102, 10 }, - formula::Rational { 109, 10 }, - formula::Rational { 121, 10 }, - formula::Rational { 143, 10 })); +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 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. @@ -280,17 +247,10 @@ auto fiftyReadings() times[k] = formula::Rational { 10'000 * (position + 1) + (7919 * position) % 997, 10'000 }; lengths[k] = formula::Rational { 24'100'000 + 31'700 * position + (3217 * position) % 1009 - 504, 10'000 }; } - return formula::environment(*formula::MeasuredObservations::from(times), - *formula::MeasuredObservations::from(lengths)); -} - -/// @p shown as its exact decimal, with its unit. -template -std::string decimalText(formula::Measured const& shown) -{ - // Held first: `view()` of a temporary is deleted, since the view would dangle. - formula::NumberText const spelled = formula::number_text(shown, formula::NumberStyle::exact_decimal()); - return std::string { spelled.view() }; + return formula::MeasuredObservations::from(times).and_then([&](auto const& timesMade) { + return formula::MeasuredObservations::from(lengths).transform( + [&](auto const& lengthsMade) { return formula::environment(timesMade, lengthsMade); }); + }); } // ---- 8. Several regressors ------------------------------------------------------------ @@ -319,31 +279,17 @@ constexpr auto byTemperatureAndContent = formula::multiple_least_squares( formula::observations, { .title = "Length by temperature and content", .reference = "Example Standard 12", .section = "5.3" }); -constexpr auto sixRows = formula::environment(formula::MeasuredObservations(formula::Rational { 113, 10 }, - formula::Rational { 137, 10 }, - formula::Rational { 179, 10 }, - formula::Rational { 191, 10 }, - formula::Rational { 233, 10 }, - formula::Rational { 297, 10 }), - formula::MeasuredObservations(formula::Rational { 23, 10 }, - formula::Rational { 31, 10 }, - formula::Rational { 29, 10 }, - formula::Rational { 41, 10 }, - formula::Rational { 37, 10 }, - formula::Rational { 43, 10 }), - formula::MeasuredObservations(formula::Rational { 2588, 25 }, - formula::Rational { 10413, 100 }, - formula::Rational { 10433, 100 }, - formula::Rational { 1051, 10 }, - formula::Rational { 10521, 100 }, - formula::Rational { 106 })); +constexpr auto sixRows = formula::environment( + formula::MeasuredObservations(11.3_r, 13.7_r, 17.9_r, 19.1_r, 23.3_r, 29.7_r), + formula::MeasuredObservations(2.3_r, 3.1_r, 2.9_r, 4.1_r, 3.7_r, 4.3_r), + formula::MeasuredObservations(103.52_r, 104.13_r, 104.33_r, 105.1_r, 105.21_r, 106_r)); // The length at 0 degC: the constant is the length at 0 K. constexpr auto lengthAtZeroCelsius = formula::rounded( formula::opaque_output<"constant">(byTemperatureAndContent) + formula::opaque_output<"coefficient 1">(byTemperatureAndContent) - * formula::constant(formula::Rational { 27315, 100 })); + * formula::constant(273.15_r)); } // namespace int main() @@ -352,256 +298,236 @@ int main() auto const check = [&allPassed](bool condition, char const* what) { if (!condition) { - std::printf("CHECK FAILED: %s\n", what); + std::println("CHECK FAILED: {}", what); allPassed = false; } }; - std::printf("== 1. An opaque operation ==\n\n"); + std::println("== 1. An opaque operation ==\n"); - std::printf("%s\n%s\n", formula::render(span).c_str(), formula::render(span).c_str()); + std::println("{}\n{}", formula::render(span), formula::render(span)); auto const spread = formula::explain(span, readings); - std::printf("%s\n", formula::render_trace(spread.trace, { .maxSteps = 20 }).c_str()); - check(spread.outcome.measurement().value() == formula::Rational { 88 }, "191 g less 103 g"); - check(formula::render_trace(spread.trace, { .maxSteps = 20 }).find("[inside not shown]") != std::string::npos, + std::println("{}", formula::render_trace(spread.trace, { .maxSteps = 20 })); + check(formula::number_of(spread.outcome) == 88_r, "191 g less 103 g"); + check(formula::render_trace(spread.trace, { .maxSteps = 20 }).contains("[inside not shown]"), "the trace says the operation's inside is not shown"); formula::Documentation const page = formula::document(span); for (formula::OpaqueOperationEntry const& operation: page.opaqueOperations) { - std::printf("operation: %.*s, outputs:", static_cast(operation.name.size()), operation.name.data()); + std::print("operation: {}, outputs:", operation.name); for (std::string_view const output: operation.outputs) - std::printf(" %.*s", static_cast(output.size()), output.data()); - std::printf("\n"); + std::print(" {}", output); + std::println(); } - std::printf("\n"); + std::println(); check(page.opaqueOperations.size() == 1, "one operation on the page"); - std::printf("== 2. Two outputs, two runs ==\n\n"); + std::println("== 2. Two outputs, two runs ==\n"); auto const twoOutputs = formula::explain(highestLessLowest, readings); - std::printf("%s\n", formula::render_trace(twoOutputs.trace, { .maxSteps = 40 }).c_str()); + std::println("{}", formula::render_trace(twoOutputs.trace, { .maxSteps = 40 })); std::size_t runs = 0; for (formula::Step<> const& recorded: twoOutputs.trace.steps) if (recorded.kind == formula::StepKind::OpaqueOperation) ++runs; - std::printf("operation runs: %zu\n\n", runs); + std::println("operation runs: {}\n", runs); check(runs == 2, "two outputs, two runs"); - check(twoOutputs.outcome.measurement().value() == formula::Rational { 88 }, "the same 88 g"); + check(formula::number_of(twoOutputs.outcome) == 88_r, "the same 88 g"); - std::printf("== 3. A least-squares line ==\n\n"); + std::println("== 3. A least-squares line ==\n"); - std::printf("%s\n", formula::render(slope).c_str()); + std::println("{}", formula::render(slope)); auto const rate = formula::explain(slope, points); - std::printf("%s\n", formula::render_trace(rate.trace, { .maxSteps = 20 }).c_str()); - check(rate.outcome.measurement().value() == formula::Rational { 285, 7 }, "19/28 mm/s is 285/7 mm/min"); + std::println("{}", formula::render_trace(rate.trace, { .maxSteps = 20 })); + check(formula::number_of(rate.outcome) == formula::Rational { 285, 7 }, "19/28 mm/s is 285/7 mm/min"); - constexpr auto onePoint = - formula::environment(formula::measured_series(formula::Measured { formula::Rational { 3 } }), - formula::measured_series(formula::Measured { formula::Rational { 103, 10 } })); + constexpr auto onePoint = formula::environment(formula::measured_series(3), + formula::measured_series(10.3_r)); constexpr auto single = formula::linear_least_squares( formula::curve(formula::series, formula::series), { .reference = "Example Standard 12" }); auto const noLine = formula::checked_evaluate(formula::opaque_output<"slope">(single), onePoint); - std::printf("one point: %s\n", - noLine.has_value() ? "a line" : std::string { formula::describe(noLine.error()) }.c_str()); + std::println("one point: {}", noLine.has_value() ? "a line" : formula::describe(noLine.error())); check(!noLine.has_value() && noLine.error() == formula::ArithmeticError::DomainError, "no line through one point"); constexpr auto fifteen = formula::linear_least_squares( formula::curve(formula::series, formula::series), { .reference = "Example Standard 12" }); auto const tooWide = formula::checked_evaluate(formula::opaque_output<"slope">(fifteen), distinctDenominators()); - std::printf("fifteen distinct denominators: %s\n", - tooWide.has_value() ? "a line" : std::string { formula::describe(tooWide.error()) }.c_str()); + std::println("fifteen distinct denominators: {}", tooWide.has_value() ? "a line" : formula::describe(tooWide.error())); check(!tooWide.has_value() && tooWide.error() == formula::ArithmeticError::Overflow, "Overflow, never a wrong line"); - std::printf("%s\n", formula::render(roundedSlope).c_str()); + std::println("{}", formula::render(roundedSlope)); auto const roundedRate = formula::explain(roundedSlope, points); - std::printf("%s\n", formula::render_trace(roundedRate.trace, { .maxSteps = 20 }).c_str()); - check(roundedRate.outcome.measurement().value() == formula::Rational { 10179, 250 }, "0.6786 mm/s is 40.716 mm/min"); + std::println("{}", formula::render_trace(roundedRate.trace, { .maxSteps = 20 })); + check(formula::number_of(roundedRate.outcome) == 40.716_r, "0.6786 mm/s is 40.716 mm/min"); - constexpr auto roundedFifteen = - formula::rounded_output<"slope", millimetrePerSecond, formula::DecimalPlaces { 4 }, formula::RoundingMode::HalfEven>( - fifteen); + constexpr auto roundedFifteen = formula::rounded_output<"slope", slopeRounding>(fifteen); auto const roundedWide = formula::checked_evaluate(roundedFifteen, distinctDenominators()); - check(roundedWide.has_value() && roundedWide->measurement().value() == formula::Rational { 14529, 125 }, + check(formula::number_of(roundedWide) == 116.232_r, "rounded where used, fifteen distinct denominators answer: 1.9372 mm/s"); if (roundedWide.has_value()) - { - formula::NumberText const wideSlope = - formula::number_text(roundedWide->measurement(), formula::NumberStyle::exact_decimal()); - std::printf("fifteen distinct denominators, rounded where used: %.*s\n\n", - static_cast(wideSlope.view().size()), - wideSlope.view().data()); - } + std::println("fifteen distinct denominators, rounded where used: {}\n", roundedWide->measurement()); - std::printf("== 4. A citation is required ==\n\n"); + std::println("== 4. A citation is required ==\n"); constexpr auto uncited = formula::opaque_output<"span">(formula::opaque({}, formula::series)); auto const uncitedSpread = formula::explain(uncited, readings); std::string const uncitedTrace = formula::render_trace(uncitedSpread.trace, { .maxSteps = 20 }); - std::printf("%s\n", uncitedTrace.c_str()); - check(uncitedTrace.find("(no citation given)") != std::string::npos, "an empty citation says so"); + std::println("{}", uncitedTrace); + check(uncitedTrace.contains("(no citation given)"), "an empty citation says so"); - std::printf("== 5. A retry ends in one of six ways ==\n\n"); + std::println("== 5. A retry ends in one of six ways ==\n"); - std::printf("%s\n\n", formula::render(fourAttempts).c_str()); + std::println("{}\n", formula::render(fourAttempts)); auto const accepted = formula::explain_retry(fourAttempts, formula::environment()); - std::printf("%s\n", formula::render_trace(accepted.trace, { .maxSteps = 60 }).c_str()); + std::println("{}", formula::render_trace(accepted.trace, { .maxSteps = 60 })); check(accepted.outcome.has_value() && accepted.outcome->end() == formula::RetryEnd::Accepted - && accepted.outcome->outcome().measurement().value() == formula::Rational { 57, 5 }, + && formula::number_of(accepted.outcome) == 11.4_r, "accepted at the fourth attempt, 11.4 g"); - std::printf("%s\n", ending("allowed four", fourAttempts, formula::environment()).c_str()); - std::printf("%s\n", ending("allowed three", threeAttempts, formula::environment()).c_str()); - auto const exhausted = formula::checked_evaluate_retry(threeAttempts, formula::environment()); - check(exhausted.has_value() && exhausted->end() == formula::RetryEnd::Exhausted && exhausted->outcome().is_verdict() - && exhausted->outcome().verdict_label() == repeatDetermination.label, + printEnding("allowed four", accepted); + auto const exhausted = formula::explain_retry(threeAttempts, formula::environment()); + printEnding("allowed three", exhausted); + check(exhausted.outcome.has_value() && exhausted.outcome->end() == formula::RetryEnd::Exhausted + && exhausted.outcome->outcome().is_verdict() + && exhausted.outcome->outcome().verdict_label() == repeatDetermination.label, "running out is the method's verdict"); constexpr auto withinTolerance = formula::previous_attempt - formula::this_attempt - >= formula::constant(formula::Rational { 0 }) - var; - constexpr auto againstTolerance = formula::retry( - fromZero, halving, withinTolerance, repeatDetermination, settledCitation); + >= formula::constant(0_r) - var; + constexpr auto againstTolerance = estimating<4>(fromZero, halving, withinTolerance); constexpr auto noTolerance = formula::environment(formula::Measured::absent()); - std::printf("%s\n", ending("tolerance not measured", againstTolerance, noTolerance).c_str()); - check(formula::checked_evaluate_retry(againstTolerance, noTolerance)->end() == formula::RetryEnd::NotJudgeable, + auto const untold = formula::explain_retry(againstTolerance, noTolerance); + printEnding("tolerance not measured", untold); + check(untold.outcome.has_value() && untold.outcome->end() == formula::RetryEnd::NotJudgeable, "an absent comparison cannot tell"); - constexpr auto thirdMissing = formula::environment(formula::measured_series( - grams(413), grams(439), formula::Measured::absent(), grams(457))); - std::printf("%s\n", ending("third determination missing", successive, thirdMissing).c_str()); - check(formula::checked_evaluate_retry(successive, thirdMissing)->end() == formula::RetryEnd::NotRecorded, + constexpr auto thirdMissing = + formula::environment(formula::measured_series(41.3_r, 43.9_r, formula::not_measured, 45.7_r)); + auto const unrecorded = formula::explain_retry(successive, thirdMissing); + printEnding("third determination missing", unrecorded); + check(unrecorded.outcome.has_value() && unrecorded.outcome->end() == formula::RetryEnd::NotRecorded, "a determination nobody recorded"); constexpr auto dividing = - formula::previous_attempt / formula::Rational { 2 } - + formula::constant(formula::Rational { 1 }) / (formula::attempt_number - formula::Rational { 1 }); - constexpr auto failing = formula::retry( - fromZero, dividing, settled, repeatDetermination, settledCitation); - std::printf("%s\n", ending("divides by k - 1", failing, formula::environment()).c_str()); - check(!formula::checked_evaluate_retry(failing, formula::environment()).has_value(), "an arithmetic failure"); - - constexpr auto typedIn = - formula::environment(formula::entered(formula::Measured { formula::Rational { 113, 10 } })); - std::printf("%s\n\n", ending("typed in by a person", fourAttempts, typedIn).c_str()); - check(formula::checked_evaluate_retry(fourAttempts, typedIn)->end() == formula::RetryEnd::ManuallyEntered, + formula::previous_attempt / 2_r + formula::constant(1_r) / (formula::attempt_number - 1_r); + constexpr auto failing = estimating<4>(fromZero, dividing, settled); + auto const divided = formula::explain_retry(failing, formula::environment()); + printEnding("divides by k - 1", divided); + check(!divided.outcome.has_value(), "an arithmetic failure"); + + constexpr auto typedIn = formula::environment(formula::entered(formula::Measured { 11.3_r })); + auto const entered = formula::explain_retry(fourAttempts, typedIn); + printEnding("typed in by a person", entered); + std::println(); + check(entered.outcome.has_value() && entered.outcome->end() == formula::RetryEnd::ManuallyEntered, "a person's entry is never replaced"); // No starting value, and previous_attempt read at the first attempt: the // author's mistake, which the trace names. - constexpr auto noStart = formula::retry( - halving, settled, repeatDetermination, settledCitation); + constexpr auto noStart = estimating<4>(halving, settled); auto const mistaken = formula::explain_retry(noStart, formula::environment()); std::string const mistakenTrace = formula::render_trace(mistaken.trace, { .maxSteps = 20 }); - std::printf("%s\n", mistakenTrace.c_str()); - check(mistakenTrace.find("previous attempt: none before attempt 1") != std::string::npos, "no attempt before the first"); + std::println("{}", mistakenTrace); + check(mistakenTrace.contains("previous attempt: none before attempt 1"), "no attempt before the first"); - std::printf("== 6. Two successive results agree ==\n\n"); + std::println("== 6. Two successive results agree ==\n"); - std::printf("%s\n", formula::render(successive).c_str()); + std::println("{}", formula::render(successive)); constexpr auto allFour = - formula::environment(formula::measured_series(grams(413), grams(439), grams(427), grams(457))); - auto const agreed = formula::checked_evaluate_retry(successive, allFour); - std::printf("%s\n\n", ending("41.3, 43.9, 42.7, 45.7 g", successive, allFour).c_str()); - check(agreed.has_value() && agreed->end() == formula::RetryEnd::Accepted - && agreed->accepted_at() == std::optional { 2 } - && agreed->outcome().measurement().value() == formula::Rational { 427, 10 }, + formula::environment(formula::measured_series(41.3_r, 43.9_r, 42.7_r, 45.7_r)); + auto const agreed = formula::explain_retry(successive, allFour); + printEnding("41.3, 43.9, 42.7, 45.7 g", agreed); + std::println(); + check(agreed.outcome.has_value() && agreed.outcome->end() == formula::RetryEnd::Accepted + && agreed.outcome->accepted_at() == std::optional { 2 } + && formula::number_of(agreed.outcome) == 42.7_r, "42.7 g, at the third attempt"); - std::printf("== 7. A line through observations ==\n\n"); + std::println("== 7. A line through observations ==\n"); - auto const exactLine = formula::explain(formula::opaque_output<"slope">(observedFit), observedPoints); - std::printf("%s\n", formula::render_trace(exactLine.trace, { .maxSteps = 30 }).c_str()); - check(exactLine.outcome.measurement().value() == formula::Rational { 285, 7 }, "19/28 mm/s through observations"); + auto const exactLine = formula::explain(observedLine, observedPoints); + std::println("{}", formula::render_trace(exactLine.trace, { .maxSteps = 30 })); + check(formula::number_of(exactLine.outcome) == formula::Rational { 285, 7 }, "19/28 mm/s through observations"); - std::printf("%s\n", formula::render(observedSlope).c_str()); - auto const roundedLine = formula::explain(observedSlope, observedPoints); - std::printf("%s\n", formula::render_trace(roundedLine.trace, { .maxSteps = 30 }).c_str()); - check(roundedLine.outcome.measurement().value() == formula::Rational { 3393, 5000 }, "0.6786 mm/s"); + std::println("{}", formula::render(observedSlope)); + auto const roundedLine = formula::explain(observedSlope, observedPoints); + std::println("{}", formula::render_trace(roundedLine.trace, { .maxSteps = 30 })); + check(formula::number_of(roundedLine.outcome) == 0.6786_r, "0.6786 mm/s"); bool const fitAccepted = formula::check(closeEnough, observedPoints).is_satisfied(); - std::printf("r squared at 4 dp, floored, at least 0.998: %s\n", fitAccepted ? "satisfied" : "not satisfied"); + std::println("r squared at 4 dp, floored, at least 0.998: {}", fitAccepted ? "satisfied" : "not satisfied"); check(fitAccepted, "0.9981 is at least 0.998"); constexpr auto flatLengths = formula::environment( - formula::MeasuredObservations( - formula::Rational { 1 }, formula::Rational { 2 }, formula::Rational { 4 }, formula::Rational { 7 }), - formula::MeasuredObservations(formula::Rational { 127, 10 }, - formula::Rational { 127, 10 }, - formula::Rational { 127, 10 }, - formula::Rational { 127, 10 })); - auto const flatLine = formula::checked_evaluate(formula::opaque_output<"slope">(observedFit), flatLengths); - std::printf("flat lengths: %s\n", - flatLine.has_value() ? "a line" : std::string { formula::describe(flatLine.error()) }.c_str()); + formula::MeasuredObservations(1_r, 2_r, 4_r, 7_r), + formula::MeasuredObservations(12.7_r, 12.7_r, 12.7_r, 12.7_r)); + auto const flatLine = formula::checked_evaluate(observedLine, flatLengths); + std::println("flat lengths: {}", flatLine.has_value() ? "a line" : formula::describe(flatLine.error())); check(!flatLine.has_value() && flatLine.error() == formula::ArithmeticError::DomainError, "a flat response has no R²"); auto const fifty = fiftyReadings(); - auto const exactFifty = formula::checked_evaluate(formula::opaque_output<"slope">(observedFit), fifty); - std::printf("fifty readings at 4 decimals, exact: %s\n", - exactFifty.has_value() ? "a line" : std::string { formula::describe(exactFifty.error()) }.c_str()); - auto const slopeOfFifty = formula::checked_evaluate(observedSlope, fifty); + if (!fifty) + { + std::println("fifty readings: {} observations for {} places", fifty.error().given, fifty.error().capacity); + return 1; + } + auto const exactFifty = formula::checked_evaluate(observedLine, *fifty); + std::println("fifty readings at 4 decimals, exact: {}", + exactFifty.has_value() ? "a line" : formula::describe(exactFifty.error())); + auto const slopeOfFifty = formula::checked_evaluate(observedSlope, *fifty); auto const startOfFifty = formula::checked_evaluate( formula:: rounded_output<"intercept", unit::Millimetre, formula::DecimalPlaces { 4 }, formula::RoundingMode::HalfEven>( observedFit), - fifty); + *fifty); auto const qualityOfFifty = formula::checked_evaluate( formula::rounded_output<"r squared", unit::One, formula::DecimalPlaces { 6 }, formula::RoundingMode::Floor>( observedFit), - fifty); + *fifty); check(slopeOfFifty.has_value() && startOfFifty.has_value() && qualityOfFifty.has_value(), "fifty readings, rounded"); if (slopeOfFifty.has_value() && startOfFifty.has_value() && qualityOfFifty.has_value()) - std::printf("fifty readings at 4 decimals, rounded: slope %s, intercept %s, r squared %s\n\n", - decimalText(slopeOfFifty->measurement()).c_str(), - decimalText(startOfFifty->measurement()).c_str(), - decimalText(qualityOfFifty->measurement()).c_str()); - check(slopeOfFifty.has_value() && slopeOfFifty->measurement().value() == formula::Rational { 31707, 10'000 }, - "3.1707 mm/s"); - std::printf("== 8. Several regressors ==\n\n"); + std::println("fifty readings at 4 decimals, rounded: slope {}, intercept {}, r squared {}\n", + slopeOfFifty->measurement(), + startOfFifty->measurement(), + qualityOfFifty->measurement()); + check(formula::number_of(slopeOfFifty) == 3.1707_r, "3.1707 mm/s"); + std::println("== 8. Several regressors ==\n"); auto const expansion = formula::explain(formula::opaque_output<"coefficient 1">(byTemperatureAndContent), sixRows); - std::printf("%s\n", formula::render_trace(expansion.trace, { .maxSteps = 40 }).c_str()); + std::println("{}", formula::render_trace(expansion.trace, { .maxSteps = 40 })); auto const perKelvin = formula::checked_evaluate( - formula::rounded_output<"coefficient 1", - millimetrePerKelvin, - formula::DecimalPlaces { 4 }, - formula::RoundingMode::HalfEven>(byTemperatureAndContent), + formula::rounded_output<"coefficient 1", formula::declared_rounding(millimetrePerKelvin, formula::RoundingMode::HalfEven)>( + byTemperatureAndContent), sixRows); auto const perPercent = formula::checked_evaluate( - formula::rounded_output<"coefficient 2", - millimetrePerPercent, - formula::DecimalPlaces { 4 }, - formula::RoundingMode::HalfEven>(byTemperatureAndContent), + formula::rounded_output<"coefficient 2", formula::declared_rounding(millimetrePerPercent, formula::RoundingMode::HalfEven)>( + byTemperatureAndContent), sixRows); auto const atZero = formula::checked_evaluate(lengthAtZeroCelsius, sixRows); check(perKelvin.has_value() && perPercent.has_value() && atZero.has_value(), "two regressors, rounded"); if (perKelvin.has_value() && perPercent.has_value() && atZero.has_value()) - std::printf("coefficient 1: %s, coefficient 2: %s, length at 0 degrees Celsius: %s\n", - decimalText(perKelvin->measurement()).c_str(), - decimalText(perPercent->measurement()).c_str(), - decimalText(atZero->measurement()).c_str()); - check(perPercent.has_value() && perPercent->measurement().value() == formula::Rational { 5557, 10'000 }, - "0.5557 mm per percent"); + std::println("coefficient 1: {}, coefficient 2: {}, length at 0 degrees Celsius: {}", + perKelvin->measurement(), + perPercent->measurement(), + atZero->measurement()); + check(formula::number_of(perPercent) == 0.5557_r, "0.5557 mm per percent"); constexpr auto collinear = formula::multiple_least_squares( formula::regressors(formula::observations, formula::observations), formula::observations, { .reference = "Example Standard 12" }); constexpr auto twiceAsLate = formula::environment( - formula::MeasuredObservations( - formula::Rational { 1 }, formula::Rational { 2 }, formula::Rational { 4 }, formula::Rational { 7 }), - formula::MeasuredObservations( - formula::Rational { 2 }, formula::Rational { 4 }, formula::Rational { 8 }, formula::Rational { 14 }), - formula::MeasuredObservations(formula::Rational { 102, 10 }, - formula::Rational { 109, 10 }, - formula::Rational { 121, 10 }, - formula::Rational { 143, 10 })); + formula::MeasuredObservations(1_r, 2_r, 4_r, 7_r), + formula::MeasuredObservations(2_r, 4_r, 8_r, 14_r), + formula::MeasuredObservations(10.2_r, 10.9_r, 12.1_r, 14.3_r)); auto const unsolvable = formula::checked_evaluate(formula::opaque_output<"constant">(collinear), twiceAsLate); - std::printf("a delay twice the elapsed time on every row: %s\n\n", - unsolvable.has_value() ? "a fit" : std::string { formula::describe(unsolvable.error()) }.c_str()); + std::println("a delay twice the elapsed time on every row: {}\n", + unsolvable.has_value() ? "a fit" : formula::describe(unsolvable.error())); check(!unsolvable.has_value() && unsolvable.error() == formula::ArithmeticError::DomainError, "a singular design is refused"); - std::printf("all checks passed: %s\n", allPassed ? "yes" : "no"); + std::println("all checks passed: {}", allPassed ? "yes" : "no"); return allPassed ? 0 : 1; } diff --git a/examples/statistics.cpp b/examples/statistics.cpp index 8fc5386d..a25defd5 100644 --- a/examples/statistics.cpp +++ b/examples/statistics.cpp @@ -24,35 +24,21 @@ // No critical value here comes from any published table. #include +#include #include #include #include #include #include -#include -#include -#include -#include +#include namespace { namespace unit = formula::unit; using formula::Rational; using formula::var; - -[[nodiscard]] constexpr Rational rat(std::int64_t numerator, std::int64_t denominator = 1) -{ - return Rational { numerator, denominator }; -} - -/// @p value as `formula::fraction_text` spells it -- `numerator/denominator`, -/// or the whole number -- in a `std::string`, for `printf`. -[[nodiscard]] std::string fraction_string(Rational value) -{ - formula::NumberText const spelled = formula::fraction_text(value); - return std::string { spelled.view() }; -} +using namespace formula::literals; // ---- Quantities ------------------------------------------------------------------- using Mass = formula::Quantity; @@ -73,21 +59,19 @@ using MassVariance = formula::Quantity(formula::Measured { rat(402, 10) }, - formula::Measured { rat(398, 10) }, - formula::Measured { rat(405, 10) }, - formula::Measured { rat(44) }, - formula::Measured { rat(40) }, - formula::Measured { rat(433, 10) })); + formula::environment(formula::measured_series(40.2_r, 39.8_r, 40.5_r, 44, 40, 43.3_r)); inline constexpr auto determinations = formula::series; -inline constexpr auto mean = formula::sample_mean(determinations); +inline constexpr auto mean = formula::yields(formula::sample_mean(determinations)); inline constexpr auto count = formula::sample_count(determinations); inline constexpr auto variance = formula::sample_variance(determinations); inline constexpr auto range = formula::sample_range(determinations); +/// The spread is reported to 2 dp of g. +inline constexpr formula::DecimalRounding spreadRounding { unit::Gram, + formula::DecimalPlaces { 2 }, + formula::RoundingMode::HalfAwayFromZero }; /// The spread reported exactly: the variance's square root, rounded to 2 dp of g. -inline constexpr auto spread = - formula::rounded_sqrt(variance); +inline constexpr auto spread = formula::rounded_sqrt(variance); // ---- 2. Rejecting outliers --------------------------------------------------------- inline constexpr formula::Verdict repeatTest { "discard the determinations and repeat the test" }; @@ -95,50 +79,47 @@ inline constexpr formula::Citation rejectionRule { .title = "Outliers", .reference = "Example Standard 5:2022", .section = "7.4" }; -/// A determination more than 6 % of the pass's mean from it is an outlier. +/// The method's rejection of @p criterion's outliers from @p sample: the most +/// extreme of each pass, a determination on the limit kept, at most `Rejected` +/// in all and at least `Kept` left -- and, when that cannot be kept, the +/// author's verdict and citation. // Kept out of clang-format's hands: docs/statistics.md quotes it verbatim. // clang-format off -inline constexpr auto sixPercent = formula::deviation_from_mean(rat(6, 100) * formula::pass_mean); +template +[[nodiscard]] constexpr auto rejecting(Sample sample, Criterion criterion) +{ + return formula::without_outliers( + sample, criterion, repeatTest, rejectionRule); +} +// clang-format on + +/// A determination more than 6 % of the pass's mean from it is an outlier. +inline constexpr auto sixPercent = formula::deviation_from_mean(0.06_r * formula::pass_mean); inline constexpr auto withoutOutliers = - formula::without_outliers, formula::KeepAtLeast<4>>( - determinations, sixPercent, repeatTest, rejectionRule); -// clang-format on + rejecting, formula::KeepAtLeast<4>>(determinations, sixPercent); /// The same rule, allowed one rejection. -inline constexpr auto atMostOne = formula:: - without_outliers, formula::KeepAtLeast<4>>( - determinations, sixPercent, repeatTest, rejectionRule); +inline constexpr auto atMostOne = rejecting, formula::KeepAtLeast<4>>(determinations, sixPercent); // Five determinations with a tie: 40, 40, 44, 40 and 36 g. 44 and 36 g are // equally far from the mean. -inline constexpr auto tiedMasses = formula::environment(formula::measured_series(formula::Measured { rat(40) }, - formula::Measured { rat(40) }, - formula::Measured { rat(44) }, - formula::Measured { rat(40) }, - formula::Measured { rat(36) })); -inline constexpr auto tieRejection = formula:: - without_outliers, formula::KeepAtLeast<3>>( - formula::series, sixPercent, repeatTest, rejectionRule); +inline constexpr auto tiedMasses = formula::environment(formula::measured_series(40, 40, 44, 40, 36)); +inline constexpr auto tieRejection = + rejecting, formula::KeepAtLeast<3>>(formula::series, sixPercent); // The rule over raw observations, room for eight: how many were made is data. -inline constexpr auto observedWithoutOutliers = formula:: - without_outliers, formula::KeepAtLeast<4>>( - formula::observations, sixPercent, repeatTest, rejectionRule); +inline constexpr auto observedWithoutOutliers = + rejecting, formula::KeepAtLeast<4>>(formula::observations, sixPercent); // ---- 3. The criteria ----------------------------------------------------------------- // // Six determinations with two low values: 40.2, 39.8, 40.5, 45.2, 40.0 and 37.2 g. inline constexpr auto spreadMasses = - formula::environment(formula::measured_series(formula::Measured { rat(402, 10) }, - formula::Measured { rat(398, 10) }, - formula::Measured { rat(405, 10) }, - formula::Measured { rat(452, 10) }, - formula::Measured { rat(40) }, - formula::Measured { rat(372, 10) })); + formula::environment(formula::measured_series(40.2_r, 39.8_r, 40.5_r, 45.2_r, 40, 37.2_r)); /// More than 7/4 sample standard deviations from the pass's mean. -inline constexpr auto sevenQuarters = formula::deviation_in_stddevs(formula::number(rat(7, 4))); +inline constexpr auto sevenQuarters = formula::deviation_in_stddevs(formula::number(7_r / 4)); // The author's table of critical values, by sample size. Invented, and plainly // so: a gap ratio never exceeds 1, and this table's first two limits, 9 and @@ -148,18 +129,13 @@ inline constexpr auto sevenQuarters = formula::deviation_in_stddevs(formula::num // clang-format off inline constexpr formula::SampleSizeTable<5> declaredSizes { 3, 4, 5, 6, 8 }; inline constexpr auto gapLimit = formula::gap_to_range( - formula::critical_value(formula::pass_count, - { rat(900), rat(700), rat(30), rat(45), rat(5) }) - * rat(1, 100)); + formula::critical_value(formula::pass_count, { 900_r, 700_r, 30_r, 45_r, 5_r }) + * 0.01_r); // clang-format on -template -[[nodiscard]] constexpr auto rejectionBy(Criterion criterion) -{ - return formula:: - without_outliers, formula::KeepAtLeast<3>>( - determinations, criterion, repeatTest, rejectionRule); -} +inline constexpr auto bySevenQuarters = + rejecting, formula::KeepAtLeast<3>>(determinations, sevenQuarters); +inline constexpr auto byGapToRange = rejecting, formula::KeepAtLeast<3>>(determinations, gapLimit); // ---- 4. Precision ---------------------------------------------------------------------- using FirstResult = formula::Quantity; @@ -170,16 +146,16 @@ struct Tag // Two determinations: 40.0 and 40.905 g, 0.905 g apart. inline constexpr auto twoResults = - formula::environment(formula::Measured { rat(40) }, formula::Measured { rat(40905, 1000) }); + formula::environment(formula::Measured { 40 }, formula::Measured { 40.905_r }); -inline constexpr auto pairMean = (var + var) / rat(2); +inline constexpr auto pairMean = (var + var) / 2_r; /// The repeatability limit at a level: r = 0.1 g + level / 50. The level is a /// placeholder; the precision limit binds it. // Kept out of clang-format's hands: docs/statistics.md quotes it verbatim. // clang-format off inline constexpr auto limitAtLevel = - formula::constant(rat(1, 10)) + rat(1, 50) * formula::precision_level; + formula::constant(0.1_r) + 0.02_r * formula::precision_level; inline constexpr auto agreement = formula::constraint( formula::abs(var - var) @@ -197,15 +173,6 @@ inline constexpr auto pairMethod = formula::method( formula::variants(formula::variant(pairMean)), formula::rounding_rule(), formula::constraints(agreement)); - -/// The trace of evaluating @p node for @p Result. -template -[[nodiscard]] std::string traceOf(Node const& node, Env const& environment) -{ - formula::Trace<> trace {}; - (void) formula::checked_evaluate(node, environment, formula::RecordingSink<> { trace }); - return formula::render_trace(trace, { .maxSteps = 40 }); -} } // namespace int main() @@ -214,148 +181,208 @@ int main() auto const check = [&allPassed](bool condition, char const* what) { if (!condition) { - std::printf("CHECK FAILED: %s\n", what); + std::println("CHECK FAILED: {}", what); allPassed = false; } }; // ---- 1. A sample -------------------------------------------------------------------- - std::printf("== 1. A sample and its statistics ==\n\n"); - - auto const meanValue = formula::checked_evaluate(mean, sixMasses); + std::println("== 1. A sample and its statistics ==\n"); + + auto const meanValue = formula::checked_evaluate(mean, sixMasses); + if (!meanValue) + { + std::println("the mean of six masses: {}", meanValue.error()); + return 1; + } auto const countValue = formula::checked_evaluate(count, sixMasses); + if (!countValue) + { + std::println("the count of six masses: {}", countValue.error()); + return 1; + } auto const varianceValue = formula::checked_evaluate(variance, sixMasses); + if (!varianceValue) + { + std::println("the variance of six masses: {}", varianceValue.error()); + return 1; + } auto const rangeValue = formula::checked_evaluate(range, sixMasses); - auto const spreadValue = formula::checked_evaluate(spread, sixMasses); - check(meanValue && countValue && varianceValue && rangeValue && spreadValue, "every statistic of six masses is a value"); - std::printf("%s = %s g\n", formula::render(mean).c_str(), fraction_string(meanValue->measurement().value()).c_str()); - std::printf("%s = %s\n", formula::render(count).c_str(), fraction_string(countValue->measurement().value()).c_str()); - std::printf( - "%s = %s g2\n", formula::render(variance).c_str(), fraction_string(varianceValue->measurement().value()).c_str()); - std::printf("%s = %s g\n", formula::render(range).c_str(), fraction_string(rangeValue->measurement().value()).c_str()); - std::printf("%s = %s g\n", formula::render(spread).c_str(), fraction_string(spreadValue->measurement().value()).c_str()); - std::printf("LaTeX: %s\n\n", formula::render(spread).c_str()); - check(meanValue->measurement().value() == rat(413, 10), "the mean is 41.3 g"); - check(varianceValue->measurement().value() == rat(427, 125), "the variance divides by n - 1: 427/125 g2"); - check(spreadValue->measurement().value() == rat(37, 20), "the spread, sqrt(427/125) = 1.848... g, reported as 1.85 g"); - - std::printf("%s\n", traceOf(spread, sixMasses).c_str()); + if (!rangeValue) + { + std::println("the range of six masses: {}", rangeValue.error()); + return 1; + } + auto const spreadValue = formula::checked_explain(spread, sixMasses); + if (!spreadValue) + { + std::println("the spread of six masses: {}", spreadValue.error().error); + return 1; + } + std::println("{} = {:/}", formula::render(mean), meanValue->measurement()); + std::println("{} = {:/}", formula::render(count), countValue->measurement()); + std::println("{} = {:/}", formula::render(variance), varianceValue->measurement()); + std::println("{} = {:/}", formula::render(range), rangeValue->measurement()); + std::println("{} = {:/}", formula::render(spread), spreadValue->outcome.measurement()); + std::println("LaTeX: {}\n", formula::render(spread)); + check(formula::number_of(meanValue) == 41.3_r, "the mean is 41.3 g"); + check(formula::number_of(varianceValue) == 3.416_r, "the variance divides by n - 1: 427/125 g2"); + check(formula::number_of(spreadValue->outcome) == 1.85_r, + "the spread, sqrt(427/125) = 1.848... g, reported as 1.85 g"); + + std::println("{}", formula::render_trace(spreadValue->trace, { .maxSteps = 40 })); // The same six masses as observations, in room for eight: the capacity is // a bound, and every statistic reads the six made. - auto const observed = formula::environment( - formula::MeasuredObservations(rat(402, 10), rat(398, 10), rat(405, 10), rat(44), rat(40), rat(433, 10))); + auto const observed = + formula::environment(formula::MeasuredObservations(40.2_r, 39.8_r, 40.5_r, 44_r, 40_r, 43.3_r)); auto const observedMean = formula::checked_evaluate(formula::sample_mean(formula::observations), observed); + if (!observedMean) + { + std::println("the mean of the observations: {}", observedMean.error()); + return 1; + } auto const observedCount = formula::checked_evaluate(formula::sample_count(formula::observations), observed); - check(observedMean && observedCount, "the observations' statistics are values"); - std::printf("observations of 8 at most, 6 made: mean %s g, count %s\n", - fraction_string(observedMean->measurement().value()).c_str(), - fraction_string(observedCount->measurement().value()).c_str()); - check(observedCount->measurement().value() == rat(6), "the count is the six made, not the capacity"); + if (!observedCount) + { + std::println("the count of the observations: {}", observedCount.error()); + return 1; + } + std::println("observations of 8 at most, 6 made: mean {:/}, count {:/}", + observedMean->measurement(), + observedCount->measurement()); + check(formula::number_of(observedCount) == 6_r, "the count is the six made, not the capacity"); // Nine for eight places: refused, never truncated to fit. std::array nine {}; - nine.fill(rat(40)); + nine.fill(40_r); auto const tooMany = formula::MeasuredObservations::from(nine); - check(!tooMany.has_value() && tooMany.error() == formula::ObservationsOverCapacity { .given = 9, .capacity = 8 }, + if (tooMany.has_value()) + { + std::println("nine observations were taken for eight places"); + return 1; + } + check(tooMany.error() == formula::ObservationsOverCapacity { .given = 9, .capacity = 8 }, "more observations than the capacity are refused, with both counts"); - std::printf("%zu observations for %zu places: refused\n\n", tooMany.error().given, tooMany.error().capacity); + std::println("{} observations for {} places: refused\n", tooMany.error().given, tooMany.error().capacity); // One determination not made: no mean, and no count either. - auto const oneMissing = formula::environment(formula::measured_series(formula::Measured { rat(402, 10) }, - formula::Measured { rat(398, 10) }, - formula::Measured::absent(), - formula::Measured { rat(44) }, - formula::Measured { rat(40) }, - formula::Measured { rat(433, 10) })); - std::printf("%s\n", traceOf(mean, oneMissing).c_str()); - check(formula::checked_evaluate(mean, oneMissing)->is_empty(), "one missing determination, no mean"); + auto const oneMissing = + formula::environment(formula::measured_series(40.2_r, 39.8_r, formula::not_measured, 44, 40, 43.3_r)); + auto const meanOfMissing = formula::checked_explain(mean, oneMissing); + if (!meanOfMissing) + { + std::println("the mean with one determination missing: {}", meanOfMissing.error().error); + return 1; + } + std::println("{}", formula::render_trace(meanOfMissing->trace, { .maxSteps = 40 })); + check(meanOfMissing->outcome.is_empty(), "one missing determination, no mean"); // ---- 2. Rejecting outliers --------------------------------------------------------- - std::printf("== 2. Rejecting outliers ==\n\n"); + std::println("== 2. Rejecting outliers ==\n"); - std::printf("%s\n\n", formula::render(withoutOutliers).c_str()); + std::println("{}\n", formula::render(withoutOutliers)); auto const settled = formula::checked_evaluate_rejection(withoutOutliers, sixMasses); - check(settled.has_value(), "the rejection settles"); - std::printf("result: %s g, %zu rejected in %zu passes\n\n", - fraction_string(settled->outcome().measurement().value()).c_str(), - settled->rejected().size(), - settled->passes()); - check(settled->outcome().measurement().value() == rat(321, 8), "the mean of the four kept, 321/8 g"); + if (!settled) + { + std::println("the rejection of six masses: {}", settled.error().error); + return 1; + } + std::println("result: {:/}, {} rejected in {} passes\n", + settled->outcome().measurement(), + settled->rejected().size(), + settled->passes()); + check(formula::number_of(settled) == 40.125_r, "the mean of the four kept, 321/8 g"); check(settled->rejected().size() == 2 && settled->passes() == 3, "44.0 g in pass 1, then 43.3 g in pass 2"); - formula::Trace<> settledTrace {}; - (void) formula::checked_evaluate( - formula::sample_mean(withoutOutliers), sixMasses, formula::RecordingSink<> { settledTrace }); - std::string const settledText = formula::render_trace(settledTrace, { .maxSteps = 40 }); - std::printf("%s\n", settledText.c_str()); - - formula::Trace<> abortedTrace {}; - (void) formula::checked_evaluate( - formula::sample_mean(atMostOne), sixMasses, formula::RecordingSink<> { abortedTrace }); - std::string const abortedText = formula::render_trace(abortedTrace, { .maxSteps = 40 }); - std::printf("%s\n", abortedText.c_str()); - check(formula::checked_evaluate_rejection(atMostOne, sixMasses)->outcome().is_verdict(), - "one rejection too many is the author's verdict"); + auto const settledMean = formula::checked_explain(formula::sample_mean(withoutOutliers), sixMasses); + if (!settledMean) + { + std::println("the mean of the four kept: {}", settledMean.error().error); + return 1; + } + std::println("{}", formula::render_trace(settledMean->trace, { .maxSteps = 40 })); + + auto const abortedMean = formula::checked_explain(formula::sample_mean(atMostOne), sixMasses); + if (abortedMean) + { + std::println("one rejection too many still reduced to a mean"); + return 1; + } + std::println("{}", formula::render_trace(abortedMean.error().trace, { .maxSteps = 40 })); + auto const aborted = formula::checked_evaluate_rejection(atMostOne, sixMasses); + if (!aborted) + { + std::println("the rejection allowed one: {}", aborted.error().error); + return 1; + } + check(aborted->outcome().is_verdict(), "one rejection too many is the author's verdict"); auto const tied = formula::checked_evaluate_rejection(tieRejection, tiedMasses); - check(tied && tied->rejected().size() == 2 && tied->rejected()[0].pass == tied->rejected()[1].pass, + if (!tied) + { + std::println("the rejection of a tie: {}", tied.error().error); + return 1; + } + check(tied->rejected().size() == 2 && tied->rejected()[0].pass == tied->rejected()[1].pass, "a tie rejects both, in the same pass"); - std::printf("a tie: elements %zu and %zu rejected together in pass %zu, result %s g\n\n", - tied->rejected()[0].position + 1, - tied->rejected()[1].position + 1, - tied->rejected()[0].pass, - fraction_string(tied->outcome().measurement().value()).c_str()); - - auto const threeMade = formula::environment(formula::MeasuredObservations(rat(40), rat(40), rat(41))); - formula::Trace<> shortTrace {}; - (void) formula::checked_evaluate_rejection( - observedWithoutOutliers, threeMade, formula::RecordingSink<> { shortTrace }); - std::printf("%s\n", formula::render_trace(shortTrace, { .maxSteps = 20 }).c_str()); - check(formula::checked_evaluate_rejection(observedWithoutOutliers, threeMade)->outcome().is_verdict(), - "three made, to keep at least four: the verdict before pass 1"); + std::println("a tie: elements {} and {} rejected together in pass {}, result {:/}\n", + tied->rejected()[0].position + 1, + tied->rejected()[1].position + 1, + tied->rejected()[0].pass, + tied->outcome().measurement()); + + auto const threeMade = formula::environment(formula::MeasuredObservations(40_r, 40_r, 41_r)); + auto const tooFew = formula::explain_rejection(observedWithoutOutliers, threeMade); + if (!tooFew.outcome) + { + std::println("the rejection of three observations: {}", tooFew.outcome.error().error); + return 1; + } + std::println("{}", formula::render_trace(tooFew.trace, { .maxSteps = 20 })); + check(tooFew.outcome->outcome().is_verdict(), "three made, to keep at least four: the verdict before pass 1"); // ---- 3. The criteria ------------------------------------------------------------------ - std::printf("== 3. Three criteria ==\n\n"); - - std::printf("%s\n", formula::render(rejectionBy(sevenQuarters)).c_str()); - formula::Trace<> stddevTrace {}; - (void) formula::checked_evaluate_rejection( - rejectionBy(sevenQuarters), spreadMasses, formula::RecordingSink<> { stddevTrace }); - std::printf("%s\n", formula::render_trace(stddevTrace, { .maxSteps = 40 }).c_str()); - - std::printf("%s\n", formula::render(rejectionBy(gapLimit)).c_str()); - formula::Trace<> gapTrace {}; - (void) formula::checked_evaluate_rejection( - rejectionBy(gapLimit), spreadMasses, formula::RecordingSink<> { gapTrace }); - std::string const gapText = formula::render_trace(gapTrace, { .maxSteps = 40 }); - std::printf("%s\n", gapText.c_str()); - check(formula::checked_evaluate_rejection(rejectionBy(gapLimit), spreadMasses)->outcome().measurement().value() - == rat(321, 8), - "the gap table's three passes settle at 321/8 g"); + std::println("== 3. Three criteria ==\n"); + + std::println("{}", formula::render(bySevenQuarters)); + auto const byStddevs = formula::explain_rejection(bySevenQuarters, spreadMasses); + if (!byStddevs.outcome) + { + std::println("the rejection in standard deviations: {}", byStddevs.outcome.error().error); + return 1; + } + std::println("{}", formula::render_trace(byStddevs.trace, { .maxSteps = 40 })); + + std::println("{}", formula::render(byGapToRange)); + auto const byGap = formula::explain_rejection(byGapToRange, spreadMasses); + if (!byGap.outcome) + { + std::println("the rejection by the gap to the range: {}", byGap.outcome.error().error); + return 1; + } + std::println("{}", formula::render_trace(byGap.trace, { .maxSteps = 40 })); + check(formula::number_of(byGap.outcome) == 40.125_r, "the gap table's three passes settle at 321/8 g"); // ---- 4. Precision ----------------------------------------------------------------------- - std::printf("== 4. Precision ==\n\n"); + std::println("== 4. Precision ==\n"); - std::printf("%s\n", formula::render(agreement).c_str()); - std::printf("LaTeX: %s\n\n", formula::render(agreement).c_str()); + std::println("{}", formula::render(agreement)); + std::println("LaTeX: {}\n", formula::render(agreement)); - formula::Trace<> precisionTrace {}; - formula::ConstraintOutcome const atMean = - formula::check(agreement, twoResults, formula::RecordingSink<> { precisionTrace }); - std::printf("%s\n", formula::render_trace(precisionTrace, { .maxSteps = 40 }).c_str()); - formula::ConstraintOutcome const atRoundedLevel = formula::check(agreementAtRoundedLevel, twoResults); - std::printf("level = the mean: %s\nlevel = the mean rounded to 1 g: %s\n\n", - atMean.is_satisfied() ? "satisfied" : "violated", - atRoundedLevel.is_satisfied() ? "satisfied" : "violated"); - check(atMean.is_satisfied() && atRoundedLevel.is_violated(), "rounding the level first flips the verdict"); + auto const atMean = formula::explain_check(agreement, twoResults); + std::println("{}", formula::render_trace(atMean.trace, { .maxSteps = 40 })); + auto const atRoundedLevel = formula::check(agreementAtRoundedLevel, twoResults); + std::println( + "level = the mean: {}\nlevel = the mean rounded to 1 g: {}\n", atMean.outcome.kind(), atRoundedLevel.kind()); + check(atMean.outcome.is_satisfied() && atRoundedLevel.is_violated(), "rounding the level first flips the verdict"); auto const accepted = formula::check_method(pairMethod, twoResults); - std::printf("the method's acceptance check: %s\n\n", accepted[0].is_satisfied() ? "satisfied" : "violated"); + std::println("the method's acceptance check: {}\n", accepted[0].kind()); check(accepted[0].is_satisfied(), "the precision check is one of the method's constraints"); - std::printf("all checks passed: %s\n", allPassed ? "yes" : "no"); + std::println("all checks passed: {}", allPassed ? "yes" : "no"); return allPassed ? 0 : 1; } diff --git a/include/formula-cpp/rejection.hpp b/include/formula-cpp/rejection.hpp index 7e713c6b..f10d03f7 100644 --- a/include/formula-cpp/rejection.hpp +++ b/include/formula-cpp/rejection.hpp @@ -6,6 +6,8 @@ /// declared criterion finds too far from the rest, re-running the mean until /// nothing more is rejected or a declared bound aborts it. /// +/// using namespace formula::literals; +/// /// formula::without_outliers, formula::KeepAtLeast<4>>( /// formula::series, From 0e636492cf2ec4d4f5ce79d4fb2977d26271aedc Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 21:48:49 +0200 Subject: [PATCH 32/59] feat(trace): give the trace of an evaluation in one call with trace_of Code that only shows how a number was reached paid for it with a traced lambda, or with checked_explain and a guard for the trace on success and in the failure on error. The throwing explain is no shortcut, because errors are handled explicitly. trace_of(expression, env), trace_of(boundFormula, env) and trace_of_si(expression, env) return the Trace alone, for a failing evaluation as for a succeeding one, each through traced and with the vocabulary as an optional last argument. The bound-formula overload keeps the relabelling refusal of the other verbs that take one. No outcome is returned: a caller who needs it reads it with checked_evaluate or checked_explain. Signed-off-by: Christian Parpart --- CHANGELOG.md | 4 + .../2026-09-30-concise-spellings-design.md | 1 + docs/tracing.md | 25 ++++++ include/formula-cpp/trace.hpp | 46 ++++++++++ test/CMakeLists.txt | 5 ++ test/consumer_globals_run_tests.cpp | 2 +- test/consumer_globals_tests.cpp | 7 +- .../trace_of_result_dimension_mismatch.cpp | 26 ++++++ test/negative/trace_of_yields_relabelled.cpp | 33 +++++++ test/trace_tests.cpp | 86 +++++++++++++++++++ 10 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 test/negative/trace_of_result_dimension_mismatch.cpp create mode 100644 test/negative/trace_of_yields_relabelled.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b313bcd..4709ba43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,10 @@ change is recorded here. `checked_evaluate_rejection`, `check`, `check_all` and `check_conformity`, each with the vocabulary as an optional last argument. `explain_series` and `explain_retry` return the same shape as before. +- `trace_of(expression, env)`, `trace_of(boundFormula, env)` and `trace_of_si(expression, env)`: + the trace of an evaluation in one call, whether it succeeds or fails, for code that only shows how + a number was reached. They return no outcome; read that with `checked_evaluate` or + `checked_explain` where it is used. Each takes the vocabulary as an optional last argument. - `DecimalRounding` and `SignificantRounding` name a rounding once -- a unit, how many places or digits, and a `RoundingMode` -- where the three arguments were repeated at every use: `constexpr DecimalRounding tenthMpa { unit::Megapascal, DecimalPlaces { 1 }, RoundingMode::HalfAwayFromZero };` diff --git a/docs/superpowers/specs/2026-09-30-concise-spellings-design.md b/docs/superpowers/specs/2026-09-30-concise-spellings-design.md index 7d67956b..943fa6c0 100644 --- a/docs/superpowers/specs/2026-09-30-concise-spellings-design.md +++ b/docs/superpowers/specs/2026-09-30-concise-spellings-design.md @@ -23,6 +23,7 @@ The 19 programs in `examples/` repeat a few shapes, and those shapes make the li - All four addition groups are in scope: literals and inputs; reading and printing; a trace from every verb; stating a rule once. - **Bound formulas are in scope.** `yields(expr)` names the result once. The author still names it; nothing is deduced from the expression. - **`std::print` / `std::println` replace printf and iostream** throughout the examples, tools, support code and docs, not only in the examples. +- `trace_of` gives the trace of an evaluation, success or failure, in one call; it returns no outcome, so a caller who needs one still reads it with `checked_evaluate` or `checked_explain`. ## Rules nothing here may bend diff --git a/docs/tracing.md b/docs/tracing.md index 6f094bd4..e8adabb6 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -240,6 +240,31 @@ 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`. +## Just the trace + +Code that only shows how a number was reached has no use for the outcome, and +`traced` spells the lambda out each time. `trace_of` gives the `Trace` alone, +whether the evaluation succeeded or failed -- a failure is the trace's last +step: + +```cpp +using formula::var; + +auto const steps = formula::trace_of(var / var, measurements); +auto const text = formula::render_trace(steps, { .maxSteps = 100 }); +``` + +A bound formula names its quantity already, so `trace_of(boundFormula, +measurements)` needs none, and `trace_of_si(expression, measurements)` traces +the evaluation in SI units with no result quantity named. All three take the +vocabulary to write the symbols in as an optional last argument. + +The outcome is deliberately not returned. A caller who needs it reads it with +`checked_evaluate`, and one who needs it together with its trace uses +`checked_explain`, which holds the trace on success and in its failure's +`trace` on error. `trace_of` is for display; a number that matters is read +where the failure can be handled. + ## Reading a derivation `examples/tracing.cpp` builds the same water/cement ratio diff --git a/include/formula-cpp/trace.hpp b/include/formula-cpp/trace.hpp index b408e425..65c65627 100644 --- a/include/formula-cpp/trace.hpp +++ b/include/formula-cpp/trace.hpp @@ -4589,6 +4589,52 @@ template (boundFormula.expression, environmentGiven, vocabulary); } +/// The trace of evaluating @p expression for @p Result in @p environmentGiven -- +/// what a `RecordingSink` records during `checked_evaluate(expression, +/// environmentGiven, sink)` -- whether the evaluation succeeds or fails; a +/// failure is the trace's last step. For showing how a number was reached, or +/// where it could not be: the outcome is not returned, so read it with +/// `checked_evaluate` or `checked_explain` where it is used. +/// +/// Every step naming a quantity writes its symbol as @p vocabulary says. +template +[[nodiscard]] Trace trace_of(Expression const& expression, Env const& environmentGiven, V const& vocabulary = V {}) +{ + return traced([&](auto recordingSink) { return checked_evaluate(expression, environmentGiven, recordingSink); }, + vocabulary) + .trace; +} + +/// `trace_of(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. +template +[[nodiscard]] Trace trace_of(Yields const& boundFormula, Env const& environmentGiven, V const& vocabulary = V {}) +{ + static_assert(detail::RequireYieldsResult::value); + if constexpr (!detail::names_yields_result || !Yields::valid) + return Trace {}; // refused already, where the mistake is + else + return trace_of(boundFormula.expression, environmentGiven, vocabulary); +} + +/// The trace of `checked_evaluate_si(expression, environmentGiven, +/// sink)`: the evaluation in SI units, with no result quantity named. +template +[[nodiscard]] Trace trace_of_si(Expression const& expression, + Env const& environmentGiven, + V const& vocabulary = V {}) +{ + return traced([&](auto recordingSink) + { return checked_evaluate_si(expression, environmentGiven, recordingSink); }, + vocabulary) + .trace; +} + /// A retry's result together with every attempt that produced it. template struct ExplainedRetry diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 6d7ffa31..36934bd8 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -508,6 +508,11 @@ formula_add_negative_test(yields_rejection_result_dimension_mismatch formula_add_negative_test(yields_relabelled "formula: this formula names its result quantity with yields" EXPECT_COUNT 1 REJECT "no matching") +formula_add_negative_test(trace_of_result_dimension_mismatch + "formula: this result quantity does not measure the dimension this expression computes" EXPECT_COUNT 1) +formula_add_negative_test(trace_of_yields_relabelled + "formula: this formula names its result quantity with yields" EXPECT_COUNT 1 + REJECT "no matching") # Nine verbs, each asked for its own wrong quantity: one refusal each. Each # verb gates on names_yields_result, never on RequireYieldsResult's value: # with the gate reading that value, clang-cl 22.1.8 compiled both branches diff --git a/test/consumer_globals_run_tests.cpp b/test/consumer_globals_run_tests.cpp index d540d4c7..6dabfd44 100644 --- a/test/consumer_globals_run_tests.cpp +++ b/test/consumer_globals_run_tests.cpp @@ -13,7 +13,7 @@ TEST_CASE("a consumer's ordinary globals do not break a build that includes every header", "[hygiene]") { ConsumerGlobalsProbe const probe = probe_consumer_globals(); - REQUIRE(probe.checks.size() == 110); + REQUIRE(probe.checks.size() == 113); for (std::size_t index = 0; index < probe.checks.size(); ++index) { INFO("check " << index); diff --git a/test/consumer_globals_tests.cpp b/test/consumer_globals_tests.cpp index 1ba8838c..66f247f9 100644 --- a/test/consumer_globals_tests.cpp +++ b/test/consumer_globals_tests.cpp @@ -24,7 +24,8 @@ // untraced and traced, with `explain`; `render` and `document` in all three // dialects, with and without a vocabulary, of that formula, of a constraint // and its predicate, and of formulas an overlay fixed, derived and replaced; -// `render_trace`; `traced`, `explain_conformity` and `explain_method`; +// `render_trace`; `traced`, `trace_of`, `trace_of_si`, `explain_conformity` and +// `explain_method`; // `check` and `check_all`; `evaluate_method` of an original // and of a replaced variant, and `check_method`, with `RecordingSink` and // with a sink of its own; `apply` with every overlay operation; `Outcome`'s @@ -1383,6 +1384,10 @@ ConsumerGlobalsProbe probe_consumer_globals() // series and the rejection above, each evaluated and traced. auto const checkedBound = formula::checked_explain(boundStrength, specimen, north); probe.checks.push_back(checkedBound.has_value() && checkedBound->outcome == explained.outcome); + // Just the trace: of the formula, of the bound formula, and in SI units. + probe.checks.push_back(!formula::trace_of(everything, specimen, north).empty()); + probe.checks.push_back(!formula::trace_of(boundStrength, specimen, north).empty()); + probe.checks.push_back(!formula::trace_of_si(everything, specimen, north).empty()); constexpr auto boundScreens = formula::yields(formula::series); probe.checks.push_back(formula::checked_evaluate_series(boundScreens, seriesInputs) == readSeries && formula::explain_series(boundScreens, seriesInputs, north).outcome diff --git a/test/negative/trace_of_result_dimension_mismatch.cpp b/test/negative/trace_of_result_dimension_mismatch.cpp new file mode 100644 index 00000000..830c5b24 --- /dev/null +++ b/test/negative/trace_of_result_dimension_mismatch.cpp @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: this result quantity does not measure the dimension this expression computes +// +// The trace of a dimensionless ratio asked for as a length: refused in +// checked_evaluate's words, once, although trace_of reaches it through a +// lambda that traced() both names in its return type and runs. +#include +#include + +struct WaterVolume: formula::Quantity +{ +}; +struct CementVolume: formula::Quantity +{ +}; +struct Length: formula::Quantity +{ +}; + +int main() +{ + auto const inputs = formula::environment(formula::Measured { 163 }, formula::Measured { 307 }); + // The expression is dimensionless; `Length` is not. + auto const recorded = formula::trace_of(formula::var / formula::var, inputs); + return recorded.empty() ? 1 : 0; +} diff --git a/test/negative/trace_of_yields_relabelled.cpp b/test/negative/trace_of_yields_relabelled.cpp new file mode 100644 index 00000000..724a0375 --- /dev/null +++ b/test/negative/trace_of_yields_relabelled.cpp @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: this formula names its result quantity with yields +// REJECT: no matching +// +// A formula bound to the water/cement ratio, traced for another dimensionless +// quantity. The dimensions agree, so nothing else could refuse it: the formula +// says which quantity it computes, and the call names another. Refused by the +// overload that takes a bound formula, in this library's words, rather than +// as an overload nobody matched. +#include +#include + +struct WaterVolume: formula::Quantity +{ +}; +struct CementVolume: formula::Quantity +{ +}; +struct WaterCementRatio: formula::Quantity +{ +}; +struct AirContent: formula::Quantity +{ +}; + +inline constexpr auto ratio = formula::yields(formula::var / formula::var); +inline constexpr auto inputs = + formula::environment(formula::Measured { 163 }, formula::Measured { 307 }); + +int main() +{ + return formula::trace_of(ratio, inputs).empty() ? 1 : 0; +} diff --git a/test/trace_tests.cpp b/test/trace_tests.cpp index 412ca083..4ac03fba 100644 --- a/test/trace_tests.cpp +++ b/test/trace_tests.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -2033,3 +2034,88 @@ TEST_CASE("traced keeps a failure in the outcome and the steps up to it in the t CHECK(failed.trace.steps[failed.trace.root()].kind == formula::StepKind::Divide); CHECK(failed.trace.steps[failed.trace.root()].error == formula::ArithmeticError::DivisionByZero); } + +namespace +{ +/// Doubled strength: a result in megapascals, so that its SI evaluation and its +/// evaluation for `Strength` are told apart by whatever the trace records. +constexpr auto doubledStrength = var * formula::Rational { 2 }; +constexpr auto boundDoubled = formula::yields(doubledStrength); + +[[nodiscard]] std::string shown(formula::Trace<> const& recorded) +{ + return formula::render_trace(recorded, { .maxSteps = 100 }); +} +} // namespace + +TEST_CASE("trace_of is the trace traced records, for a success and for a failure", "[trace]") +{ + auto const environmentGiven = strengthOf(formula::Rational { 30 }); + auto const viaTraced = formula::traced([&](auto recordingSink) + { return formula::checked_evaluate(doubledStrength, environmentGiven, recordingSink); }) + .trace; + auto const direct = formula::trace_of(doubledStrength, environmentGiven); + CHECK(!direct.empty()); + CHECK(shown(direct) == shown(viaTraced)); + // Another environment, another trace: a stub returning a fixed trace fails here. + CHECK(shown(formula::trace_of(doubledStrength, strengthOf(formula::Rational { 31 }))) != shown(direct)); + + // A failure still gives the trace, and the failing step is its last. + constexpr auto bad = var / formula::number(formula::Rational { 0 }); + auto const massGiven = environmentOf(6, 3); + auto const failedVia = formula::traced([&](auto recordingSink) + { return formula::checked_evaluate(bad, massGiven, recordingSink); }) + .trace; + auto const failed = formula::trace_of(bad, massGiven); + REQUIRE(!failed.empty()); + CHECK(shown(failed) == shown(failedVia)); + CHECK(failed.steps.back().kind == formula::StepKind::Divide); + CHECK(failed.steps.back().error == formula::ArithmeticError::DivisionByZero); +} + +TEST_CASE("trace_of a bound formula is trace_of for the quantity it names, named or not", "[trace][yields]") +{ + auto const environmentGiven = strengthOf(formula::Rational { 30 }); + auto const expected = shown(formula::trace_of(doubledStrength, environmentGiven)); + CHECK(!expected.empty()); + CHECK(shown(formula::trace_of(boundDoubled, environmentGiven)) == expected); + CHECK(shown(formula::trace_of(boundDoubled, environmentGiven)) == expected); +} + +TEST_CASE("trace_of_si is the trace of the evaluation in SI units, whatever was entered for a result", "[trace]") +{ + constexpr auto density = var / var; + auto const derived = environmentOf(6, 3); + auto const viaTraced = formula::traced([&](auto recordingSink) + { return formula::checked_evaluate_si(density, derived, recordingSink); }) + .trace; + auto const inSi = formula::trace_of_si(density, derived); + REQUIRE(!inSi.empty()); + CHECK(shown(inSi) == shown(viaTraced)); + CHECK(shown(inSi) == shown(formula::trace_of(density, derived))); + + // A density typed in for the result is not derived: the evaluation for + // `Density` returns it and records nothing, where the SI evaluation has no + // result to read it for and still derives the quotient. That tells the two + // verbs apart, which a trace_of_si that named a result would not. + auto const overridden = formula::environment(formula::Measured { formula::Rational { 6 } }, + formula::Measured { formula::Rational { 3 } }, + formula::entered(formula::Measured { formula::Rational { 999 } })); + CHECK(formula::trace_of(density, overridden).empty()); + auto const stillDerived = formula::trace_of_si(density, overridden); + CHECK(shown(stillDerived) == shown(inSi)); +} + +TEST_CASE("trace_of writes every symbol as the vocabulary it is given says", "[trace][vocabulary]") +{ + constexpr auto south = formula::vocabulary(formula::renames("f_s")); + auto const environmentGiven = strengthOf(formula::Rational { 30 }); + auto const plain = shown(formula::trace_of(doubledStrength, environmentGiven)); + CHECK(plain.find("f_s") == std::string::npos); + + auto const named = shown(formula::trace_of(doubledStrength, environmentGiven, south)); + CHECK(named.starts_with("1. f_s = ")); + CHECK(shown(formula::trace_of(boundDoubled, environmentGiven, south)) == named); + CHECK(shown(formula::trace_of_si(doubledStrength, environmentGiven, south)).starts_with("1. f_s = ")); + CHECK(shown(formula::trace_of_si(doubledStrength, environmentGiven)).find("f_s") == std::string::npos); +} From bf44106c0e2b7bce112e0b14dea090b9598d79af Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 21:50:49 +0200 Subject: [PATCH 33/59] docs(examples): read traces with trace_of and tighten the series, records and methods examples Sites that only read a trace now call trace_of, trace_of or trace_of_si instead of wrapping a checked evaluation in traced. The gated reads in the records example use checked_explain, which gives the same trace and keeps the outcome for the no-answer check. The series example runs its passing series once and renders it at both step budgets, tests presence with contains, and writes its one fraction as 27708_r / 425. The methods example says why it runs the north a second time. The three programs print exactly what they printed before. Signed-off-by: Christian Parpart --- examples/methods_and_overlays.cpp | 1 + examples/records.cpp | 45 ++++++++--------- examples/series.cpp | 80 +++++++++---------------------- 3 files changed, 43 insertions(+), 83 deletions(-) diff --git a/examples/methods_and_overlays.cpp b/examples/methods_and_overlays.cpp index f59b6fee..2bc1fa49 100644 --- a/examples/methods_and_overlays.cpp +++ b/examples/methods_and_overlays.cpp @@ -375,6 +375,7 @@ int main() std::println("jurisdiction {}: {} Pa", std::to_underlying(jurisdiction), *chosenStrength); } std::println(""); + // The one deliberate second run of the north: the runtime choice must reach the same method. check(formula::number_of(cubeStrengthIn(Jurisdiction::North)) == northStrength, "the runtime choice reaches the north"); // ---- 4. A vocabulary -------------------------------------------------------------- diff --git a/examples/records.cpp b/examples/records.cpp index ede90a68..024debb3 100644 --- a/examples/records.cpp +++ b/examples/records.cpp @@ -221,22 +221,15 @@ int main() std::println(""); check(page.symbols.size() == 2, "one row per record a quantity is read from"); - std::string const seriesTrace = formula::render_trace( - formula::traced([](auto recordingSink) - { return formula::checked_evaluate_si(retainedRatio, screenRecords, recordingSink); }) - .trace, - { .maxSteps = 20 }); + std::string const seriesTrace = + formula::render_trace(formula::trace_of_si(retainedRatio, screenRecords), { .maxSteps = 20 }); std::println("{}", seriesTrace); check(seriesTrace.contains("m_r = 139 g; 197 g; 103 g, from record Reference (sample 23, test 3), entered by hand\n"), "a series read from the reference names the record after its elements, and was typed in"); std::println("== 3. Lineage is a gate ==\n"); - std::string const agreed = formula::render_trace( - formula::traced([](auto recordingSink) - { return formula::checked_evaluate_si(gated, records, recordingSink); }) - .trace, - { .maxSteps = 20 }); + std::string const agreed = formula::render_trace(formula::trace_of_si(gated, records), { .maxSteps = 20 }); std::println("{}", agreed); check(agreed.contains("same TestMethod as this record: 12 for this record, 12 for Reference, satisfied"), "both attributes agree, and the value is read"); @@ -249,11 +242,14 @@ int main() "a different method refuses the read"); auto const batchUnknown = recordsWith(formula::unknown_lineage(), formula::lineage(12)); - auto const notChecked = formula::traced( - [&](auto recordingSink) - { return formula::checked_evaluate_si(gated, batchUnknown, recordingSink); }); - std::println("{}", formula::render_trace(notChecked.trace, { .maxSteps = 20 })); - check(notChecked.outcome.has_value() && !notChecked.outcome->has_value(), "an unknown batch gives no answer"); + auto const notChecked = formula::checked_explain(gated, batchUnknown); + if (!notChecked) + { + std::println("the gated read, batch unknown: {}", notChecked.error().error); + return 1; + } + std::println("{}", formula::render_trace(notChecked->trace, { .maxSteps = 20 })); + check(notChecked->outcome.is_empty(), "an unknown batch gives no answer"); // A disagreement refuses the read even when another key is unknown: an // unknown key gives no answer only when nothing disagrees. @@ -281,23 +277,22 @@ int main() // The same record behind the gated read: every attribute it declares is // unknown, so no lineage is compared, and there is no answer. - auto const unboundGated = formula::traced( - [&](auto recordingSink) - { return formula::checked_evaluate_si(gated, notYetTested, recordingSink); }); - std::string const unboundGatedTrace = formula::render_trace(unboundGated.trace, { .maxSteps = 20 }); + auto const unboundGated = formula::checked_explain(gated, notYetTested); + if (!unboundGated) + { + std::println("the gated read, record not yet made: {}", unboundGated.error().error); + return 1; + } + std::string const unboundGatedTrace = formula::render_trace(unboundGated->trace, { .maxSteps = 20 }); std::println("{}", unboundGatedTrace); - check(unboundGated.outcome.has_value() && !unboundGated.outcome->has_value() && !unboundGatedTrace.contains("same "), + check(unboundGated->outcome.is_empty() && !unboundGatedTrace.contains("same "), "a gated read over a record not yet made checks no lineage, and gives no answer"); auto const typedInEmpty = formula::record_context( formula::record(formula::record_key(formula::sample_id(17), formula::test_id(5)), here), formula::record(formula::record_key(formula::sample_id(23), formula::test_id(3)), formula::environment(formula::entered(formula::Measured::absent())))); - std::string const leftEmpty = formula::render_trace( - formula::traced([&](auto recordingSink) - { return formula::checked_evaluate_si(ratio, typedInEmpty, recordingSink); }) - .trace, - { .maxSteps = 20 }); + std::string const leftEmpty = formula::render_trace(formula::trace_of_si(ratio, typedInEmpty), { .maxSteps = 20 }); std::println("{}", leftEmpty); check(leftEmpty.contains("f_c = (entered by hand as empty), from record Reference"), "an entry left empty by hand says so"); diff --git a/examples/series.cpp b/examples/series.cpp index ca55429c..1414061c 100644 --- a/examples/series.cpp +++ b/examples/series.cpp @@ -184,7 +184,7 @@ int main() std::println("the curve read at 173 m: {}", at173.error().error); return 1; } - check(formula::number_of(at173->outcome) == formula::Rational { 27708, 425 }, "173 m reads 27708/425 %"); + check(formula::number_of(at173->outcome) == 27708_r / 425, "173 m reads 27708/425 %"); std::println("{}", formula::render_trace(at173->trace, { .maxSteps = 80 })); formula::Documentation const page = formula::document(passing); @@ -200,40 +200,29 @@ int main() auto const readings = formula::environment(formula::measured_series(23.7_r, 41.3_r, 37.9_r)); constexpr auto threeReadings = formula::series; std::string const sumTrace = formula::render_trace( - formula::traced([&](auto recordingSink) - { return formula::checked_evaluate(formula::sum(threeReadings), readings, recordingSink); }) - .trace, - { .maxSteps = 80 }); + formula::trace_of(formula::sum(threeReadings), readings), { .maxSteps = 80 }); std::string const readingsLine = sumTrace.substr(0, sumTrace.find('\n')); std::string const sumLine = last_line(sumTrace); - std::string const rangeLine = last_line(formula::render_trace( - formula::traced( - [&](auto recordingSink) - { return formula::checked_evaluate(formula::sample_range(threeReadings), readings, recordingSink); }) - .trace, - { .maxSteps = 80 })); - std::string const meanLine = last_line(formula::render_trace( - formula::traced( - [&](auto recordingSink) - { return formula::checked_evaluate(formula::sample_mean(threeReadings), readings, recordingSink); }) - .trace, - { .maxSteps = 80 })); + std::string const rangeLine = last_line( + formula::render_trace(formula::trace_of(formula::sample_range(threeReadings), readings), { .maxSteps = 80 })); + std::string const meanLine = last_line( + formula::render_trace(formula::trace_of(formula::sample_mean(threeReadings), readings), { .maxSteps = 80 })); std::println("the readings: {}\ntheir sum: {}\ntheir range: {}\ntheir mean: {}\n", readingsLine, sumLine, rangeLine, meanLine); check(sumLine == "2. sum(#1) = 18447/20", "922.35 K, no reading"); check(rangeLine == "2. sample_range(#1) = 88/5", "17.6 K, no reading"); check(meanLine == "2. sample_mean(#1) = 343/10 \xc2\xb0" "C", "a mean of readings is a reading, 34.3 degC"); std::println("== 2. Elementwise arithmetic: one step per operation ==\n"); - std::string const passingTrace = formula::render_trace(formula::explain_series(passing, analysis).trace, { .maxSteps = 40 }); + auto const passingRun = formula::explain_series(passing, analysis); + std::string const passingTrace = formula::render_trace(passingRun.trace, { .maxSteps = 40 }); std::println("{}", passingTrace); - check(passingTrace.find("3. cumulative(#2, from last) = 803 g; 673 g; 463 g; 368 g; 28 g\n") != std::string::npos, + check(passingTrace.contains("3. cumulative(#2, from last) = 803 g; 673 g; 463 g; 368 g; 28 g\n"), "the running total from the coarsest screen"); // A computed step has no declared unit, so it reads in the coherent one: // 447/1250 is 35.76 %. check(passingTrace.ends_with("6. #1 - #5 = 447/1250; 577/1250; 787/1250; 441/625; 611/625\n"), "35.76, 46.16, 62.96, 70.56 and 97.76 % passing"); - std::println("the same, within a budget of 8:\n{}", - formula::render_trace(formula::explain_series(passing, analysis).trace, { .maxSteps = 8 })); + std::println("the same, within a budget of 8:\n{}", formula::render_trace(passingRun.trace, { .maxSteps = 8 })); // A series scaled by a pure number is still in its series' unit. auto const threeScreens = formula::environment(formula::measured_series(137, 213, 293)); @@ -261,14 +250,10 @@ int main() formula::cumulative(formula::series), oneUnrecorded) .trace, { .maxSteps = 40 })); - std::string const reduced = last_line(formula::render_trace( - formula::traced([&](auto recordingSink) { return formula::checked_evaluate(retainedInAll, oneUnrecorded, recordingSink); }) - .trace, - { .maxSteps = 80 })); - std::string const readOff = last_line(formula::render_trace( - formula::traced([&](auto recordingSink) { return formula::checked_evaluate(passingAt173, oneUnrecorded, recordingSink); }) - .trace, - { .maxSteps = 80 })); + std::string const reduced = + last_line(formula::render_trace(formula::trace_of(retainedInAll, oneUnrecorded), { .maxSteps = 80 })); + std::string const readOff = + last_line(formula::render_trace(formula::trace_of(passingAt173, oneUnrecorded), { .maxSteps = 80 })); std::string const spliced = last_line(formula::render_trace( formula::explain_curve(formula::splice(coarse, fineMeasured), fineGap) @@ -295,7 +280,7 @@ int main() "that total and every later one absent"); check(reduced.ends_with("sum(#1) = (not measured)"), "the whole sum absent"); check(readOff.ends_with("interpolate(#8, at #9) = (not measured)"), "the curve absent, and no range stated"); - check(spliced.find("= (not measured): (not measured);") != std::string::npos, "the whole splice absent"); + check(spliced.contains("= (not measured): (not measured);"), "the whole splice absent"); check(judgedWithGap[0].is_not_checked() && judgedWithGap[2].is_not_checked() && judgedWithGap[3].is_satisfied(), "the passing at the three finest screens not checked, the rest judged"); check(broadcast.ends_with("(not measured); (not measured); (not measured); (not measured); (not measured)"), @@ -328,42 +313,21 @@ int main() std::println("== 6. Snapping, and splicing two curves ==\n"); std::println("{}", formula::render(halfPassing)); - std::string const snapTrace = formula::render_trace( - formula::traced([&](auto recordingSink) { return formula::checked_evaluate(halfPassing, analysis, recordingSink); }) - .trace, - { .maxSteps = 80 }); + std::string const snapTrace = formula::render_trace(formula::trace_of(halfPassing, analysis), { .maxSteps = 80 }); std::println("{}", snapTrace); - check(snapTrace.find("[127 m to 163 m; nearer 127 m]") != std::string::npos, "4733/35 m snaps to 127 m, the nearer"); + check(snapTrace.contains("[127 m to 163 m; nearer 127 m]"), "4733/35 m snaps to 127 m, the nearer"); auto const midway = formula::constant(145_r); std::string const towardLower = last_line(formula::render_trace( - formula::traced( - [&](auto recordingSink) - { - return formula::checked_evaluate( - formula::snapped(midway), analysis, recordingSink); - }) - .trace, + formula::trace_of(formula::snapped(midway), analysis), { .maxSteps = 80 })); std::string const towardHigher = last_line(formula::render_trace( - formula::traced( - [&](auto recordingSink) - { - return formula::checked_evaluate( - formula::snapped(midway), analysis, recordingSink); - }) - .trace, + formula::trace_of(formula::snapped(midway), analysis), { .maxSteps = 80 })); std::string const beyond = last_line(formula::render_trace( - formula::traced( - [&](auto recordingSink) - { - return formula::checked_evaluate( - formula::snapped(formula::constant(251_r)), - analysis, - recordingSink); - }) - .trace, + formula::trace_of( + formula::snapped(formula::constant(251_r)), + analysis), { .maxSteps = 80 })); std::println("{}\n{}\n{}\n", towardLower, towardHigher, beyond); check(towardLower.ends_with("= 127 m [127 m to 163 m; tie, toward lower]"), "a tie, decided lower"); From b8500ad9f0c40d798de62275cc3efc2e9ceeacb4 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 21:52:51 +0200 Subject: [PATCH 34/59] docs(examples): read traces with trace_of and check constant results at compile time Where an example shows only a trace, it now asks for the trace alone. The statistics of a constant sample are checked with static_assert instead of a runtime guard, and a trace rendered twice is rendered once. Signed-off-by: Christian Parpart --- examples/opaque_and_retry.cpp | 17 ++++---- examples/statistics.cpp | 73 ++++++++++------------------------- 2 files changed, 30 insertions(+), 60 deletions(-) diff --git a/examples/opaque_and_retry.cpp b/examples/opaque_and_retry.cpp index 82b925b3..4fcfd864 100644 --- a/examples/opaque_and_retry.cpp +++ b/examples/opaque_and_retry.cpp @@ -307,9 +307,10 @@ int main() std::println("{}\n{}", formula::render(span), formula::render(span)); auto const spread = formula::explain(span, readings); - std::println("{}", formula::render_trace(spread.trace, { .maxSteps = 20 })); + std::string const spreadTrace = formula::render_trace(spread.trace, { .maxSteps = 20 }); + std::println("{}", spreadTrace); check(formula::number_of(spread.outcome) == 88_r, "191 g less 103 g"); - check(formula::render_trace(spread.trace, { .maxSteps = 20 }).contains("[inside not shown]"), + check(spreadTrace.contains("[inside not shown]"), "the trace says the operation's inside is not shown"); formula::Documentation const page = formula::document(span); @@ -370,8 +371,8 @@ int main() std::println("== 4. A citation is required ==\n"); constexpr auto uncited = formula::opaque_output<"span">(formula::opaque({}, formula::series)); - auto const uncitedSpread = formula::explain(uncited, readings); - std::string const uncitedTrace = formula::render_trace(uncitedSpread.trace, { .maxSteps = 20 }); + std::string const uncitedTrace = + formula::render_trace(formula::trace_of(uncited, readings), { .maxSteps = 20 }); std::println("{}", uncitedTrace); check(uncitedTrace.contains("(no citation given)"), "an empty citation says so"); @@ -492,11 +493,13 @@ int main() startOfFifty->measurement(), qualityOfFifty->measurement()); check(formula::number_of(slopeOfFifty) == 3.1707_r, "3.1707 mm/s"); + std::println("== 8. Several regressors ==\n"); - auto const expansion = - formula::explain(formula::opaque_output<"coefficient 1">(byTemperatureAndContent), sixRows); - std::println("{}", formula::render_trace(expansion.trace, { .maxSteps = 40 })); + std::println("{}", + formula::render_trace( + formula::trace_of(formula::opaque_output<"coefficient 1">(byTemperatureAndContent), sixRows), + { .maxSteps = 40 })); auto const perKelvin = formula::checked_evaluate( formula::rounded_output<"coefficient 1", formula::declared_rounding(millimetrePerKelvin, formula::RoundingMode::HalfEven)>( diff --git a/examples/statistics.cpp b/examples/statistics.cpp index a25defd5..5e61dc16 100644 --- a/examples/statistics.cpp +++ b/examples/statistics.cpp @@ -59,7 +59,7 @@ using MassVariance = formula::Quantity(40.2_r, 39.8_r, 40.5_r, 44, 40, 43.3_r)); + formula::environment(formula::measured_series(40.2_r, 39.8_r, 40.5_r, 44_r, 40_r, 43.3_r)); inline constexpr auto determinations = formula::series; inline constexpr auto mean = formula::yields(formula::sample_mean(determinations)); @@ -189,30 +189,14 @@ int main() // ---- 1. A sample -------------------------------------------------------------------- std::println("== 1. A sample and its statistics ==\n"); - auto const meanValue = formula::checked_evaluate(mean, sixMasses); - if (!meanValue) - { - std::println("the mean of six masses: {}", meanValue.error()); - return 1; - } - auto const countValue = formula::checked_evaluate(count, sixMasses); - if (!countValue) - { - std::println("the count of six masses: {}", countValue.error()); - return 1; - } - auto const varianceValue = formula::checked_evaluate(variance, sixMasses); - if (!varianceValue) - { - std::println("the variance of six masses: {}", varianceValue.error()); - return 1; - } - auto const rangeValue = formula::checked_evaluate(range, sixMasses); - if (!rangeValue) - { - std::println("the range of six masses: {}", rangeValue.error()); - return 1; - } + constexpr auto meanValue = formula::checked_evaluate(mean, sixMasses); + static_assert(meanValue.has_value()); + constexpr auto countValue = formula::checked_evaluate(count, sixMasses); + static_assert(countValue.has_value()); + constexpr auto varianceValue = formula::checked_evaluate(variance, sixMasses); + static_assert(varianceValue.has_value()); + constexpr auto rangeValue = formula::checked_evaluate(range, sixMasses); + static_assert(rangeValue.has_value()); auto const spreadValue = formula::checked_explain(spread, sixMasses); if (!spreadValue) { @@ -234,22 +218,14 @@ int main() // The same six masses as observations, in room for eight: the capacity is // a bound, and every statistic reads the six made. - auto const observed = + constexpr auto observed = formula::environment(formula::MeasuredObservations(40.2_r, 39.8_r, 40.5_r, 44_r, 40_r, 43.3_r)); - auto const observedMean = + constexpr auto observedMean = formula::checked_evaluate(formula::sample_mean(formula::observations), observed); - if (!observedMean) - { - std::println("the mean of the observations: {}", observedMean.error()); - return 1; - } - auto const observedCount = + static_assert(observedMean.has_value()); + constexpr auto observedCount = formula::checked_evaluate(formula::sample_count(formula::observations), observed); - if (!observedCount) - { - std::println("the count of the observations: {}", observedCount.error()); - return 1; - } + static_assert(observedCount.has_value()); std::println("observations of 8 at most, 6 made: mean {:/}, count {:/}", observedMean->measurement(), observedCount->measurement()); @@ -297,21 +273,12 @@ int main() check(formula::number_of(settled) == 40.125_r, "the mean of the four kept, 321/8 g"); check(settled->rejected().size() == 2 && settled->passes() == 3, "44.0 g in pass 1, then 43.3 g in pass 2"); - auto const settledMean = formula::checked_explain(formula::sample_mean(withoutOutliers), sixMasses); - if (!settledMean) - { - std::println("the mean of the four kept: {}", settledMean.error().error); - return 1; - } - std::println("{}", formula::render_trace(settledMean->trace, { .maxSteps = 40 })); - - auto const abortedMean = formula::checked_explain(formula::sample_mean(atMostOne), sixMasses); - if (abortedMean) - { - std::println("one rejection too many still reduced to a mean"); - return 1; - } - std::println("{}", formula::render_trace(abortedMean.error().trace, { .maxSteps = 40 })); + std::println("{}", + formula::render_trace(formula::trace_of(formula::sample_mean(withoutOutliers), sixMasses), + { .maxSteps = 40 })); + std::println("{}", + formula::render_trace(formula::trace_of(formula::sample_mean(atMostOne), sixMasses), + { .maxSteps = 40 })); auto const aborted = formula::checked_evaluate_rejection(atMostOne, sixMasses); if (!aborted) { From 80f87fe5d0080f35199258b03eb5941b5deda70e Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 21:58:49 +0200 Subject: [PATCH 35/59] docs(rejection): spell the deviation_in_stddevs example with a Node deviation_in_stddevs takes a Node, so the bare 1.75_r in its documentation did not compile; wrap it in number(). Signed-off-by: Christian Parpart --- include/formula-cpp/rejection.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/include/formula-cpp/rejection.hpp b/include/formula-cpp/rejection.hpp index f10d03f7..79b830cb 100644 --- a/include/formula-cpp/rejection.hpp +++ b/include/formula-cpp/rejection.hpp @@ -230,7 +230,7 @@ template return DeviationFromMean { limitExpression }; } -/// abs(x - pass mean) / s against @p limitExpression: `deviation_in_stddevs(1.75_r)`. +/// abs(x - pass mean) / s against @p limitExpression: `deviation_in_stddevs(number(1.75_r))`. template [[nodiscard]] constexpr DeviationInStddevs deviation_in_stddevs(Limit limitExpression) noexcept { From 4b16e676a3636605a67e705bc6a3d9ff072b7cf1 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 21:59:52 +0200 Subject: [PATCH 36/59] docs: drop the stale explain body from the tracing guide and use plain integers in the statistics example The tracing guide quoted an older body of explain; point at trace.hpp instead. The double-trace sentence now says it is explain that records in Rational. sixMasses spells 44 and 40 as integers like its siblings. Signed-off-by: Christian Parpart --- docs/tracing.md | 27 +++++++-------------------- examples/statistics.cpp | 2 +- 2 files changed, 8 insertions(+), 21 deletions(-) diff --git a/docs/tracing.md b/docs/tracing.md index e8adabb6..b34a4f9b 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -133,25 +133,12 @@ plus the derivation"`.) `formula::evaluate` (and [Writing formulas](expressions.md) for the two of those) take a sink parameter that defaults to `NullSink`, so calling either without a sink argument is the untraced path: no `Trace` is built, and nothing is allocated -for one. `formula::explain` builds a `RecordingSink` for you and -evaluates through it: - -```cpp -template -[[nodiscard]] Explained explain(Expression const& expression, Env const& environment) -{ - static_assert(std::is_same_v, /* ... */); - - Explained explained {}; - RecordingSink sink { explained.trace }; - explained.outcome = evaluate(expression, environment, sink); - return explained; -} -``` - -(`trace.hpp`.) `explained.outcome` is exactly what `evaluate(expression, -environment)` would have returned -- tracing observes, it does not -participate -- and `explained.trace` is the derivation. Reach for `evaluate` or +for one. `formula::explain` builds a `RecordingSink` +for you, evaluates through it, and returns the outcome beside the trace it +recorded (`trace.hpp` has the four-line body). `explained.outcome` is exactly +what `evaluate(expression, environment)` would have returned -- +tracing observes, it does not participate -- and `explained.trace` is the +derivation. Reach for `evaluate` or `checked_evaluate` on a path that runs often and never shows its work to anyone; reach for `explain` at the point a derivation needs to be shown to a person -- a report, a review, a place where "here is the number" is not @@ -236,7 +223,7 @@ auto const run = formula::traced([&](auto recordingSink) `explain_series` and `explain_retry` share the shape: `outcome`, then `trace`. A failure is in `outcome`, and `trace` holds the steps up to it; a value that was typed in rather than derived leaves `trace` empty, as it does for -`explain`. The sink records in `Rational`, so an evaluation that computes in +`explain`, which records in `Rational`: an evaluation that computes in `double` is traced by calling its `checked_evaluate_si` with your own `RecordingSink`. diff --git a/examples/statistics.cpp b/examples/statistics.cpp index 5e61dc16..b10cc853 100644 --- a/examples/statistics.cpp +++ b/examples/statistics.cpp @@ -59,7 +59,7 @@ using MassVariance = formula::Quantity(40.2_r, 39.8_r, 40.5_r, 44_r, 40_r, 43.3_r)); + formula::environment(formula::measured_series(40.2_r, 39.8_r, 40.5_r, 44, 40, 43.3_r)); inline constexpr auto determinations = formula::series; inline constexpr auto mean = formula::yields(formula::sample_mean(determinations)); From 5fc7dbad8df0cac8a0a4b4f51b4f737b045e5085 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 22:03:29 +0200 Subject: [PATCH 37/59] test: pin the literal exponent cap and a binary literal, tidy the band and breakpoint negatives Add negatives for 1e1001_r and 0b101_r, a BelowMinimum check for within_bounds, and widen the vocabulary-reach tripwire so it also sees symbol_of(). The band and breakpoint negatives now return 0 and say plainly why their floating-point types differ. Explain in rational.hpp why hasPoint is also true for an exponent, and rewrap a long comment in fail_without_dialogs.cpp. Signed-off-by: Christian Parpart --- cmake/CheckVocabularyReach.cmake | 2 +- include/formula-cpp/rational.hpp | 1 + support/fail_without_dialogs.cpp | 3 ++- test/CMakeLists.txt | 2 ++ test/measured_tests.cpp | 2 ++ test/negative/band_from_floating_point.cpp | 2 +- test/negative/band_from_floating_point_fourth.cpp | 8 ++++---- test/negative/band_from_floating_point_second.cpp | 8 ++++---- test/negative/band_from_floating_point_several.cpp | 12 ++++++++---- test/negative/band_from_floating_point_third.cpp | 8 ++++---- ...akpoint_pair_denominator_from_floating_point.cpp | 2 +- .../breakpoint_pair_from_floating_point.cpp | 2 +- test/negative/rational_literal_binary.cpp | 13 +++++++++++++ .../rational_literal_exponent_above_cap.cpp | 13 +++++++++++++ 14 files changed, 57 insertions(+), 21 deletions(-) create mode 100644 test/negative/rational_literal_binary.cpp create mode 100644 test/negative/rational_literal_exponent_above_cap.cpp diff --git a/cmake/CheckVocabularyReach.cmake b/cmake/CheckVocabularyReach.cmake index d9b89162..e2940158 100644 --- a/cmake/CheckVocabularyReach.cmake +++ b/cmake/CheckVocabularyReach.cmake @@ -74,7 +74,7 @@ foreach(file IN LISTS surfaces) if(code MATCHES "[^_A-Za-z0-9]render<[^<>()]*>[(][^,()]*[)]") string(APPEND offenders "\n ${rel}: ${CMAKE_MATCH_0} -- rendered without the vocabulary") endif() - if(code MATCHES "symbol_of<[^()]*>[(][ \t]*[)]") + if(code MATCHES "symbol_of<([^<>()]|[(][^()]*[)])*>[(][ \t]*[)]") string(APPEND offenders "\n ${rel}: ${CMAKE_MATCH_0} -- a symbol read through the default vocabulary") endif() if(code MATCHES "[^_A-Za-z0-9](render|document)(<[^<>()]*>)?[(][^,()]*, renderOptions[)]") diff --git a/include/formula-cpp/rational.hpp b/include/formula-cpp/rational.hpp index c0259cf2..42eb8ce2 100644 --- a/include/formula-cpp/rational.hpp +++ b/include/formula-cpp/rational.hpp @@ -542,6 +542,7 @@ namespace detail consteval Rational rational_from_spelling(char const* spelling) { std::size_t at = 0; + // Also true for an exponent: that is how `0x1E` gets past the leading guard, to be refused at its `x`. bool const hasPoint = [&] { for (std::size_t probe = 0; spelling[probe] != '\0'; ++probe) if (spelling[probe] == '.' || spelling[probe] == 'e' || spelling[probe] == 'E') diff --git a/support/fail_without_dialogs.cpp b/support/fail_without_dialogs.cpp index 151112cc..aee96b6d 100644 --- a/support/fail_without_dialogs.cpp +++ b/support/fail_without_dialogs.cpp @@ -56,7 +56,8 @@ void report_invalid_parameter(wchar_t const* expression, (void) file; (void) line; (void) reserved; - // fputs, not std::print: this handler is noexcept and must neither allocate nor throw, which std::print may do. + // fputs, not std::print: this handler is noexcept and must neither allocate nor throw, and std::print + // may do either. std::fputs("formula: the C runtime rejected an invalid parameter; exiting without a dialog\n", stderr); std::fflush(stderr); // _Exit, not abort: abort would re-enter the very handling this file is diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 36934bd8..66d0af93 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -300,6 +300,8 @@ formula_add_negative_test(rational_literal_not_a_decimal "formula_rational_liter formula_add_negative_test(rational_literal_octal "formula_rational_literal_not_a_decimal") formula_add_negative_test(rational_literal_hex_with_exponent_letter "formula_rational_literal_not_a_decimal") formula_add_negative_test(rational_literal_exponent_out_of_range "formula_rational_literal_out_of_range") +formula_add_negative_test(rational_literal_exponent_above_cap "formula_rational_literal_out_of_range") +formula_add_negative_test(rational_literal_binary "formula_rational_literal_not_a_decimal") formula_add_negative_test(dimension_mismatch "formula: these two dimensions are not the same") diff --git a/test/measured_tests.cpp b/test/measured_tests.cpp index 8f71201a..78defa0d 100644 --- a/test/measured_tests.cpp +++ b/test/measured_tests.cpp @@ -498,6 +498,8 @@ TEST_CASE("round_to_declared and within_bounds: throwing twins", "[measured]") CHECK(formula::within_bounds(Measured::absent()) == formula::BoundsCheck::NotMeasured); CHECK(formula::within_bounds(Measured { *Rational::make(42, 1) }) == formula::BoundsCheck::WithinBounds); + CHECK(formula::within_bounds(Measured { *Rational::make(-1, 1) }) + == formula::BoundsCheck::BelowMinimum); CHECK(formula::within_bounds(Measured { *Rational::make(101, 1) }) == formula::BoundsCheck::AboveMaximum); } diff --git a/test/negative/band_from_floating_point.cpp b/test/negative/band_from_floating_point.cpp index e95ee4f7..9be67c57 100644 --- a/test/negative/band_from_floating_point.cpp +++ b/test/negative/band_from_floating_point.cpp @@ -9,5 +9,5 @@ inline constexpr auto declared = formula::band(12.7, 1, 17, 1); int main() { - return declared.lowNumerator == 12 ? 1 : 0; + return 0; } diff --git a/test/negative/band_from_floating_point_fourth.cpp b/test/negative/band_from_floating_point_fourth.cpp index 234fa331..bb2ff19b 100644 --- a/test/negative/band_from_floating_point_fourth.cpp +++ b/test/negative/band_from_floating_point_fourth.cpp @@ -1,14 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 // EXPECT: formula: a band's bounds are exact numbers // -// A floating-point value in the fourth argument (the high denominator) of `band(0, 1, 17, 1.5)`. -// The integer overload would truncate it and say nothing; it is refused, once, -// in the library's words. +// A floating-point value in the fourth argument (the high denominator) of +// `band(0, 1, 17, 1.5)`. The integer overload would truncate it and say +// nothing; it is refused, once, in the library's words. #include inline constexpr auto declared = formula::band(0, 1, 17, 1.5); int main() { - return declared.lowNumerator == 12 ? 1 : 0; + return 0; } diff --git a/test/negative/band_from_floating_point_second.cpp b/test/negative/band_from_floating_point_second.cpp index c06b2ca6..7b364d33 100644 --- a/test/negative/band_from_floating_point_second.cpp +++ b/test/negative/band_from_floating_point_second.cpp @@ -1,14 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 // EXPECT: formula: a band's bounds are exact numbers // -// A floating-point value in the second argument (the low denominator) of `band(0, 1.5, 17, 1)`. -// The integer overload would truncate it and say nothing; it is refused, once, -// in the library's words. +// A floating-point value in the second argument (the low denominator) of +// `band(0, 1.5, 17, 1)`. The integer overload would truncate it and say +// nothing; it is refused, once, in the library's words. #include inline constexpr auto declared = formula::band(0, 1.5, 17, 1); int main() { - return declared.lowNumerator == 12 ? 1 : 0; + return 0; } diff --git a/test/negative/band_from_floating_point_several.cpp b/test/negative/band_from_floating_point_several.cpp index f1861f2e..095f7b5d 100644 --- a/test/negative/band_from_floating_point_several.cpp +++ b/test/negative/band_from_floating_point_several.cpp @@ -1,14 +1,18 @@ // SPDX-License-Identifier: Apache-2.0 // EXPECT: formula: a band's bounds are exact numbers // -// A floating-point value in the first (double) and third (float) arguments, so two different types; two mistakes of one kind are still one message of `band(12.7, 1, 17.3f, 1)`. -// The integer overload would truncate it and say nothing; it is refused, once, -// in the library's words. +// A floating-point value in the first argument (a double) and in the third (a +// float) of `band(12.7, 1, 17.3f, 1)`. The two types are different on purpose: +// the guard is keyed on the type, so it gives one message per distinct type, +// and two doubles could not tell a broken gate from a working one. Two +// mistakes of one kind are still one message. The integer overload would +// truncate them and say nothing; they are refused, once, in the library's +// words. #include inline constexpr auto declared = formula::band(12.7, 1, 17.3f, 1); int main() { - return declared.lowNumerator == 12 ? 1 : 0; + return 0; } diff --git a/test/negative/band_from_floating_point_third.cpp b/test/negative/band_from_floating_point_third.cpp index 0da7487e..5fd7362c 100644 --- a/test/negative/band_from_floating_point_third.cpp +++ b/test/negative/band_from_floating_point_third.cpp @@ -1,14 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 // EXPECT: formula: a band's bounds are exact numbers // -// A floating-point value in the third argument (the high numerator) of `band(0, 1, 12.7, 1)`. -// The integer overload would truncate it and say nothing; it is refused, once, -// in the library's words. +// A floating-point value in the third argument (the high numerator) of +// `band(0, 1, 12.7, 1)`. The integer overload would truncate it and say +// nothing; it is refused, once, in the library's words. #include inline constexpr auto declared = formula::band(0, 1, 12.7, 1); int main() { - return declared.lowNumerator == 12 ? 1 : 0; + return 0; } diff --git a/test/negative/breakpoint_pair_denominator_from_floating_point.cpp b/test/negative/breakpoint_pair_denominator_from_floating_point.cpp index bdc98598..b3949734 100644 --- a/test/negative/breakpoint_pair_denominator_from_floating_point.cpp +++ b/test/negative/breakpoint_pair_denominator_from_floating_point.cpp @@ -10,5 +10,5 @@ inline constexpr auto declared = formula::breakpoint(1, 2.5); int main() { - return declared.numerator == 1 ? 1 : 0; + return 0; } diff --git a/test/negative/breakpoint_pair_from_floating_point.cpp b/test/negative/breakpoint_pair_from_floating_point.cpp index d8182dbd..9fad502d 100644 --- a/test/negative/breakpoint_pair_from_floating_point.cpp +++ b/test/negative/breakpoint_pair_from_floating_point.cpp @@ -9,5 +9,5 @@ inline constexpr auto key = formula::breakpoint(1.5, 2); int main() { - return key.numerator == 1 ? 1 : 0; + return 0; } diff --git a/test/negative/rational_literal_binary.cpp b/test/negative/rational_literal_binary.cpp new file mode 100644 index 00000000..dd70c42c --- /dev/null +++ b/test/negative/rational_literal_binary.cpp @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Apache-2.0 +// A binary spelling is not a decimal either. +// This must not compile. +#include + +using namespace formula::literals; + +constexpr formula::Rational refused = 0b101_r; + +int main() +{ + return 0; +} diff --git a/test/negative/rational_literal_exponent_above_cap.cpp b/test/negative/rational_literal_exponent_above_cap.cpp new file mode 100644 index 00000000..1d878ef4 --- /dev/null +++ b/test/negative/rational_literal_exponent_above_cap.cpp @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Apache-2.0 +// A decimal exponent above the cap of 1000 is refused while it is being read, before any value is built. +// This must not compile. +#include + +using namespace formula::literals; + +constexpr formula::Rational refused = 1e1001_r; + +int main() +{ + return 0; +} From 2b74f12b695efbc117aba9301eb75efcebdc9671 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 22:07:15 +0200 Subject: [PATCH 38/59] fix(trace): say what trace_of does and does not record, and refuse what it should The documentation implied trace_of_si differs from trace_of by units, which it does not; the real difference is that trace_of is empty where a value was typed in for the result and trace_of_si traces the derivation anyway. Both docstrings and the guide now say so, narrow "a failure is the last step" to failures while the expression is evaluated (a failure converting into the result's unit comes after the trace), and use names the guide already has. trace_of now reaches checked_evaluate's series refusal instead of an overload list, and trace_of_si goes through the same dispatch as checked_evaluate so a consumer's own node at the root still traces. The nested-Yields gate, the verb lists that say "every" and the dimension negative's second-message guard are now pinned, each with a deletion check. Signed-off-by: Christian Parpart --- docs/tracing.md | 24 ++++++++----- include/formula-cpp/trace.hpp | 35 ++++++++++++++----- include/formula-cpp/yields.hpp | 5 +-- test/CMakeLists.txt | 10 ++++-- test/consumer_globals_tests.cpp | 2 +- test/negative/trace_of_series_as_single.cpp | 23 ++++++++++++ test/negative/yields_around_yields.cpp | 3 +- .../negative/yields_relabelled_every_verb.cpp | 8 +++-- test/trace_tests.cpp | 3 +- 9 files changed, 84 insertions(+), 29 deletions(-) create mode 100644 test/negative/trace_of_series_as_single.cpp diff --git a/docs/tracing.md b/docs/tracing.md index b34a4f9b..8f9a0fac 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -231,20 +231,26 @@ was typed in rather than derived leaves `trace` empty, as it does for Code that only shows how a number was reached has no use for the outcome, and `traced` spells the lambda out each time. `trace_of` gives the `Trace` alone, -whether the evaluation succeeded or failed -- a failure is the trace's last -step: +whether the evaluation succeeded or failed. With `densityFormula` and `env` as +in the `explain` example above: ```cpp -using formula::var; - -auto const steps = formula::trace_of(var / var, measurements); +auto const steps = formula::trace_of(densityFormula, env); auto const text = formula::render_trace(steps, { .maxSteps = 100 }); ``` -A bound formula names its quantity already, so `trace_of(boundFormula, -measurements)` needs none, and `trace_of_si(expression, measurements)` traces -the evaluation in SI units with no result quantity named. All three take the -vocabulary to write the symbols in as an optional last argument. +A failure while the formula is evaluated is the trace's last step. One +converting the result into `Density`'s unit comes after it and is not in the +trace, so read the outcome where that matters. When `env` holds a value typed in +for `Density`, that value is returned without evaluating and the trace is empty, +as it is for `explain`. + +A bound formula names its quantity already, so `trace_of(boundFormula, env)` +needs none. `trace_of_si(densityFormula, env)` traces the evaluation in SI units +with no result quantity named: it records the same steps for a derived result, +and since it consults no typed-in value it traces the derivation even where +`trace_of` is empty. All three take the vocabulary to write the symbols +in as an optional last argument. The outcome is deliberately not returned. A caller who needs it reads it with `checked_evaluate`, and one who needs it together with its trace uses diff --git a/include/formula-cpp/trace.hpp b/include/formula-cpp/trace.hpp index 65c65627..c42600d3 100644 --- a/include/formula-cpp/trace.hpp +++ b/include/formula-cpp/trace.hpp @@ -4591,13 +4591,23 @@ template (expression, -/// environmentGiven, sink)` -- whether the evaluation succeeds or fails; a -/// failure is the trace's last step. For showing how a number was reached, or -/// where it could not be: the outcome is not returned, so read it with -/// `checked_evaluate` or `checked_explain` where it is used. +/// environmentGiven, sink)` -- whether the evaluation succeeds or fails. A +/// failure while the expression is evaluated is the trace's last step; one +/// converting the result into `Result`'s unit comes after it and is not in the +/// trace, so read the outcome where that matters. For showing how a number was +/// reached, or where it could not be: the outcome is not returned, so read it +/// with `checked_evaluate` or `checked_explain` where it is used. +/// +/// **Empty when @p environmentGiven holds an `entered` value for `Result`:** +/// that value is returned without evaluating, as `explain` says, so nothing +/// is recorded. /// /// Every step naming a quantity writes its symbol as @p vocabulary says. -template +/// +/// A series is accepted here only to be refused in this library's words, as +/// `checked_evaluate` refuses it. +template + requires(Node || SeriesNode) [[nodiscard]] Trace trace_of(Expression const& expression, Env const& environmentGiven, V const& vocabulary = V {}) { return traced([&](auto recordingSink) { return checked_evaluate(expression, environmentGiven, recordingSink); }, @@ -4622,15 +4632,24 @@ template (boundFormula.expression, environmentGiven, vocabulary); } -/// The trace of `checked_evaluate_si(expression, environmentGiven, -/// sink)`: the evaluation in SI units, with no result quantity named. +/// The trace of the evaluation of @p expression in SI units with no result +/// quantity named: the steps `trace_of` records for a derived result, and a +/// failure is the last of them, since nothing is converted afterwards. +/// +/// Naming no result, it consults no `entered` value: it traces the expression +/// even where `checked_evaluate` returns a typed-in value and records +/// nothing, so a page showing it beside that value shows a derivation of a +/// number that is not the one reported. For display, as `trace_of` is: read +/// the outcome with `checked_evaluate_si` where it is used. +/// +/// Every step naming a quantity writes its symbol as @p vocabulary says. template [[nodiscard]] Trace trace_of_si(Expression const& expression, Env const& environmentGiven, V const& vocabulary = V {}) { return traced([&](auto recordingSink) - { return checked_evaluate_si(expression, environmentGiven, recordingSink); }, + { return detail::dispatch(expression, environmentGiven, recordingSink); }, vocabulary) .trace; } diff --git a/include/formula-cpp/yields.hpp b/include/formula-cpp/yields.hpp index 8cd530a9..04b8bf40 100644 --- a/include/formula-cpp/yields.hpp +++ b/include/formula-cpp/yields.hpp @@ -13,8 +13,9 @@ /// Every verb that is told a result quantity takes a `Yields` in place of the /// expression and the quantity: `evaluate` and `checked_evaluate` here, /// `checked_evaluate_series` (`series.hpp`), `checked_evaluate_rejection` -/// (`rejection.hpp`), `explain`, `checked_explain`, `explain_series` and -/// `explain_rejection` (`trace.hpp`), and `define` (`calculation.hpp`). Each +/// (`rejection.hpp`), `explain`, `checked_explain`, `trace_of`, +/// `explain_series` and `explain_rejection` (`trace.hpp`), and `define` +/// (`calculation.hpp`). Each /// returns what it returns for `boundFormula.expression` and the quantity the /// `Yields` names. A result named at the call as well is accepted when it is /// that quantity, and refused when it is another diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 66d0af93..aa108c4d 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -511,7 +511,8 @@ formula_add_negative_test(yields_relabelled "formula: this formula names its result quantity with yields" EXPECT_COUNT 1 REJECT "no matching") formula_add_negative_test(trace_of_result_dimension_mismatch - "formula: this result quantity does not measure the dimension this expression computes" EXPECT_COUNT 1) + "formula: this result quantity does not measure the dimension this expression computes" EXPECT_COUNT 1 + REJECT "no matching") formula_add_negative_test(trace_of_yields_relabelled "formula: this formula names its result quantity with yields" EXPECT_COUNT 1 REJECT "no matching") @@ -522,14 +523,17 @@ formula_add_negative_test(trace_of_yields_relabelled # different types. The last two REJECTs name that error: clang's words, as # clang-cl 22.1.8 printed them, and g++'s, not yet measured. formula_add_negative_test(yields_relabelled_every_verb - "formula: this formula names its result quantity with yields" EXPECT_COUNT 9 + "formula: this formula names its result quantity with yields" EXPECT_COUNT 10 REJECT "no matching" "in return type deduced as" "inconsistent deduction for auto return type") formula_add_negative_test(yields_series_as_single "this expression is a series, not a single value; evaluate it with checked_evaluate_series" EXPECT_COUNT 1 REJECT "formula: this formula names its result quantity with yields") +formula_add_negative_test(trace_of_series_as_single + "this expression is a series, not a single value; evaluate it with checked_evaluate_series" EXPECT_COUNT 1 + REJECT "no matching") formula_add_negative_test(yields_around_yields "formula: this formula is bound to its result quantity already" EXPECT_COUNT 1 - REJECT "no viable conversion" "no matching") + REJECT "no viable conversion" "no matching" "formula: this formula names its result quantity with yields") formula_add_negative_test(yields_around_yields_series "formula: this formula is bound to its result quantity already" EXPECT_COUNT 1 REJECT "no viable conversion" "no matching") diff --git a/test/consumer_globals_tests.cpp b/test/consumer_globals_tests.cpp index 66f247f9..964b9589 100644 --- a/test/consumer_globals_tests.cpp +++ b/test/consumer_globals_tests.cpp @@ -80,7 +80,7 @@ // `Dimension` and an enumeration written the same way; `symbol_of` with no // vocabulary, and `render` and `document` given `RenderOptions` and none; // `yields` of the formula touching every node kind, and `evaluate`, -// `checked_evaluate`, `explain`, `checked_explain`, `render`, `document` and +// `checked_evaluate`, `explain`, `checked_explain`, `trace_of`, `render`, `document` and // `define` of what it binds, and `checked_evaluate_series`, // `explain_series`, `checked_evaluate_rejection` and `explain_rejection` of // a series and a rejection bound the same way; diff --git a/test/negative/trace_of_series_as_single.cpp b/test/negative/trace_of_series_as_single.cpp new file mode 100644 index 00000000..4f23b8a4 --- /dev/null +++ b/test/negative/trace_of_series_as_single.cpp @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: this expression is a series, not a single value; evaluate it with checked_evaluate_series +// REJECT: no matching +// +// A series handed to trace_of, which traces a single value. Refused as the +// series itself is refused by checked_evaluate, pointing at +// checked_evaluate_series, 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 { 97 } })); + +int main() +{ + return formula::trace_of(formula::series, inputs).empty() ? 1 : 0; +} diff --git a/test/negative/yields_around_yields.cpp b/test/negative/yields_around_yields.cpp index 754acd3c..d27a64d7 100644 --- a/test/negative/yields_around_yields.cpp +++ b/test/negative/yields_around_yields.cpp @@ -31,10 +31,11 @@ int main() auto const checked = formula::checked_evaluate(rebound, inputs); auto const explained = formula::explain(rebound, inputs); auto const checkedExplained = formula::checked_explain(rebound, inputs); + auto const traced = formula::trace_of(rebound, inputs); auto const defined = formula::define(rebound); auto const written = formula::render(rebound) + formula::document(rebound).formula; return evaluated.is_value() && checked.has_value() && explained.outcome.is_value() && checkedExplained.has_value() - && decltype(defined)::valid && !written.empty() + && !traced.empty() && decltype(defined)::valid && !written.empty() ? 0 : 1; } diff --git a/test/negative/yields_relabelled_every_verb.cpp b/test/negative/yields_relabelled_every_verb.cpp index 29a6acc6..2f5b456c 100644 --- a/test/negative/yields_relabelled_every_verb.cpp +++ b/test/negative/yields_relabelled_every_verb.cpp @@ -4,7 +4,7 @@ // // Every verb that names a result, each asked for a quantity other than the // one its bound formula names -- a different one at each call, so that each -// call is its own refusal and the case counts nine messages, one per verb. +// call is its own refusal and the case counts ten messages, one per verb. // Each quantity measures what its formula computes, so nothing but the // Yields could refuse it, and a verb that stopped refusing lowers the count. #include @@ -16,13 +16,14 @@ using WaterCementRatio = formula::Quantity; using Mass = formula::Quantity; -// Nine quantities none of the formulas names: five ratios, two masses of a +// Ten quantities none of the formulas names: six ratios, two masses of a // screen and two masses of a determination. using AirContent = formula::Quantity; using Porosity = formula::Quantity; using Absorption = formula::Quantity; using MoistureContent = formula::Quantity; using Shrinkage = formula::Quantity; +using Saturation = formula::Quantity; using Passing = formula::Quantity; using Sieved = formula::Quantity; using Tare = formula::Quantity; @@ -50,13 +51,14 @@ int main() auto const checked = formula::checked_evaluate(ratio, inputs); auto const explained = formula::explain(ratio, inputs); auto const checkedExplained = formula::checked_explain(ratio, inputs); + auto const traced = formula::trace_of(ratio, inputs); auto const defined = formula::define(ratio); auto const series = formula::checked_evaluate_series(retained, inputs); auto const explainedSeries = formula::explain_series(retained, inputs); auto const rejection = formula::checked_evaluate_rejection(settled, inputs); auto const explainedRejection = formula::explain_rejection(settled, inputs); return evaluated.is_value() && checked.has_value() && explained.outcome.is_value() && checkedExplained.has_value() - && decltype(defined)::valid && series.has_value() && explainedSeries.outcome.has_value() + && !traced.empty() && decltype(defined)::valid && series.has_value() && explainedSeries.outcome.has_value() && rejection.has_value() && explainedRejection.outcome.has_value() ? 0 : 1; diff --git a/test/trace_tests.cpp b/test/trace_tests.cpp index 4ac03fba..e0381da5 100644 --- a/test/trace_tests.cpp +++ b/test/trace_tests.cpp @@ -2037,8 +2037,7 @@ TEST_CASE("traced keeps a failure in the outcome and the steps up to it in the t namespace { -/// Doubled strength: a result in megapascals, so that its SI evaluation and its -/// evaluation for `Strength` are told apart by whatever the trace records. +/// Doubled strength, a formula whose result is also a quantity it reads. constexpr auto doubledStrength = var * formula::Rational { 2 }; constexpr auto boundDoubled = formula::yields(doubledStrength); From b7104b33011efcd31d6740e4fb64142b9c2f2915 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 22:10:20 +0200 Subject: [PATCH 39/59] fix: keep angle brackets in the symbol_of tripwire, pin the exponent cap itself, mark the refused lookup snippet The widened symbol_of pattern dropped angle brackets, so symbol_of>() slipped through; it now accepts them and one level of parentheses, and stops at a statement end so it cannot run on into later code. The cap negative now uses an exponent too large for an int, so it fails when the cap is deleted (the old 1e1001 case was also refused by a later guard); 1e1001_r stays as its own case. The lookup comment says its snippet is now refused. Signed-off-by: Christian Parpart --- cmake/CheckVocabularyReach.cmake | 7 +++++-- include/formula-cpp/lookup.hpp | 9 +++++---- test/CMakeLists.txt | 1 + .../rational_literal_exponent_above_cap.cpp | 7 ++++--- .../rational_literal_exponent_just_above_cap.cpp | 13 +++++++++++++ 5 files changed, 28 insertions(+), 9 deletions(-) create mode 100644 test/negative/rational_literal_exponent_just_above_cap.cpp diff --git a/cmake/CheckVocabularyReach.cmake b/cmake/CheckVocabularyReach.cmake index e2940158..2f18fe03 100644 --- a/cmake/CheckVocabularyReach.cmake +++ b/cmake/CheckVocabularyReach.cmake @@ -27,7 +27,10 @@ # overloads that forward to their dialect counterparts -- a # sub-expression rendered that way is in the declared symbols; # - `symbol_of<...>()` with no argument, which reads `Describe::symbol` -# through the default vocabulary and ignores the one the surface was given; +# through the default vocabulary and ignores the one the surface was given +# (angle brackets and one level of parentheses inside the template argument +# are matched, as in `Wrapper` and `decltype(x)`; deeper parentheses, as +# in `decltype(f(x))`, are not caught); # - a two-argument `render<...>(x, renderOptions)` or # `document<...>(x, renderOptions)` call, outside the public overloads that # forward `RenderOptions` -- it names no vocabulary, so it resolves the @@ -74,7 +77,7 @@ foreach(file IN LISTS surfaces) if(code MATCHES "[^_A-Za-z0-9]render<[^<>()]*>[(][^,()]*[)]") string(APPEND offenders "\n ${rel}: ${CMAKE_MATCH_0} -- rendered without the vocabulary") endif() - if(code MATCHES "symbol_of<([^<>()]|[(][^()]*[)])*>[(][ \t]*[)]") + if(code MATCHES "symbol_of<([^();{}\n]|[(][^();{}\n]*[)])*>[(][ \t]*[)]") string(APPEND offenders "\n ${rel}: ${CMAKE_MATCH_0} -- a symbol read through the default vocabulary") endif() if(code MATCHES "[^_A-Za-z0-9](render|document)(<[^<>()]*>)?[(][^,()]*, renderOptions[)]") diff --git a/include/formula-cpp/lookup.hpp b/include/formula-cpp/lookup.hpp index bedce6b4..4eb02f12 100644 --- a/include/formula-cpp/lookup.hpp +++ b/include/formula-cpp/lookup.hpp @@ -609,10 +609,11 @@ namespace detail /// inline constexpr ExactLookupNode node { /// {}, { 0.781_r }, Shape::Prism }; /// -/// compiled, linked, and evaluated the two rows nobody typed as `0` -- checked -/// against the installed package on all three node kinds, all three of which -/// did it. The factory's parameter type cannot see that call, because there is -/// no call. Making the member itself a `Corrections` is what closes it: the +/// compiled (it is now refused), linked, and evaluated the two rows nobody +/// typed as `0` -- checked against the installed package on all three node +/// kinds, all three of which did it. The factory's parameter type cannot see +/// that call, because there is no call. Making the member itself a +/// `Corrections` is what closes it: the /// braced list now initialises this type, a short one selects the /// arity-mismatch constructor below, and its `static_assert` names both counts /// at the offending line. `lookup_short_corrections_no_factory.cpp` and its diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index aa108c4d..ee69fc06 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -301,6 +301,7 @@ formula_add_negative_test(rational_literal_octal "formula_rational_literal_not_a formula_add_negative_test(rational_literal_hex_with_exponent_letter "formula_rational_literal_not_a_decimal") formula_add_negative_test(rational_literal_exponent_out_of_range "formula_rational_literal_out_of_range") formula_add_negative_test(rational_literal_exponent_above_cap "formula_rational_literal_out_of_range") +formula_add_negative_test(rational_literal_exponent_just_above_cap "formula_rational_literal_out_of_range") formula_add_negative_test(rational_literal_binary "formula_rational_literal_not_a_decimal") formula_add_negative_test(dimension_mismatch diff --git a/test/negative/rational_literal_exponent_above_cap.cpp b/test/negative/rational_literal_exponent_above_cap.cpp index 1d878ef4..702a6ab6 100644 --- a/test/negative/rational_literal_exponent_above_cap.cpp +++ b/test/negative/rational_literal_exponent_above_cap.cpp @@ -1,11 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 -// A decimal exponent above the cap of 1000 is refused while it is being read, before any value is built. -// This must not compile. +// An exponent too large for an int is refused by the cap on the exponent, while it is being read. +// Without the cap, the exponent would overflow an int during the compile-time evaluation and the +// compiler would give its own diagnostic, not the library's. This must not compile. #include using namespace formula::literals; -constexpr formula::Rational refused = 1e1001_r; +constexpr formula::Rational refused = 1e99999999999_r; int main() { diff --git a/test/negative/rational_literal_exponent_just_above_cap.cpp b/test/negative/rational_literal_exponent_just_above_cap.cpp new file mode 100644 index 00000000..be0f0c30 --- /dev/null +++ b/test/negative/rational_literal_exponent_just_above_cap.cpp @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Apache-2.0 +// An exponent just above the cap of 1000 is refused. +// This must not compile. +#include + +using namespace formula::literals; + +constexpr formula::Rational refused = 1e1001_r; + +int main() +{ + return 0; +} From ab306b6b2e4e536eac30b108e0a1c6157bf963a6 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 22:43:30 +0200 Subject: [PATCH 40/59] fix: refuse a bound series, rejection, retry or opaque call handed to a single-value verb A bound formula is meant to be refused in the library's words when it is misused, with one message. But the verbs that answer with one value -- evaluate, checked_evaluate, explain, checked_explain, trace_of and define -- forwarded whatever the Yields held to an overload that takes a single expression. A bound rejection reached none of them, nor did a bound series, retry or whole opaque call handed to explain, checked_explain, trace_of or define: the mistake was reported as "no matching function" inside the library, which reads as a library defect. Each of those verbs now refuses a bound formula that is not an expression of one value, and forwards only one that is, so the refusal is the only message. A series is refused as checked_evaluate already refuses one, a retry and an opaque call as checked_evaluate refuses them, and a rejection in new words that name checked_evaluate_rejection and explain_rejection. define keeps its own words for a series. trace_of_si refuses a series as trace_of does. Signed-off-by: Christian Parpart --- CHANGELOG.md | 4 +- docs/calculations.md | 2 +- include/formula-cpp/calculation.hpp | 13 ++-- include/formula-cpp/opaque.hpp | 7 ++ include/formula-cpp/rejection.hpp | 26 ++++++++ include/formula-cpp/retry.hpp | 8 +++ include/formula-cpp/trace.hpp | 20 +++++- include/formula-cpp/yields.hpp | 53 ++++++++++++++- test/CMakeLists.txt | 18 +++++ .../negative/trace_of_si_series_as_single.cpp | 18 +++++ test/negative/yields_not_a_value.cpp | 22 +++++++ test/negative/yields_opaque_call_evaluate.cpp | 65 +++++++++++++++++++ test/negative/yields_rejection_evaluate.cpp | 45 +++++++++++++ test/negative/yields_retry_evaluate.cpp | 46 +++++++++++++ test/negative/yields_series_explain.cpp | 27 ++++++++ 15 files changed, 363 insertions(+), 11 deletions(-) create mode 100644 test/negative/trace_of_si_series_as_single.cpp create mode 100644 test/negative/yields_not_a_value.cpp create mode 100644 test/negative/yields_opaque_call_evaluate.cpp create mode 100644 test/negative/yields_rejection_evaluate.cpp create mode 100644 test/negative/yields_retry_evaluate.cpp create mode 100644 test/negative/yields_series_explain.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 4709ba43..cd15c2b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,7 +67,9 @@ change is recorded here. checked against the dimension the expression computes where it is written, with `checked_evaluate`'s message, and a result named at the call as well is accepted only when it is `Q`. Nest `documented()` inside it, and reuse the formula in another through `.expression`; a - `yields` around a bound formula is refused where it is written. Every earlier spelling stays. + `yields` around a bound formula is refused where it is written. A bound series, rejection of + outliers, retry or whole opaque call handed to a verb that answers with one value is refused in + words that name the verbs that take it. Every earlier spelling stays. ### Changed diff --git a/docs/calculations.md b/docs/calculations.md index 2be618c8..93faa4a1 100644 --- a/docs/calculations.md +++ b/docs/calculations.md @@ -844,7 +844,7 @@ static assertion failed: formula: these definitions read one another in a cycle, cl 19.51 says the same, at the check's own line: ``` -include\formula-cpp/calculation.hpp(570): error C2338: static assertion failed: 'formula: these definitions read one another in a cycle, so none of them can be calculated first -- the quantities on the cycle appear in this diagnostic as the template arguments of RequireAcyclicDefinitions' +include\formula-cpp/calculation.hpp(573): error C2338: static assertion failed: 'formula: these definitions read one another in a cycle, so none of them can be calculated first -- the quantities on the cycle appear in this diagnostic as the template arguments of RequireAcyclicDefinitions' ``` **A worksheet missing an input.** Here the environment has no price: diff --git a/include/formula-cpp/calculation.hpp b/include/formula-cpp/calculation.hpp index ad1699a2..83937150 100644 --- a/include/formula-cpp/calculation.hpp +++ b/include/formula-cpp/calculation.hpp @@ -460,15 +460,18 @@ template /// `define(boundFormula.expression)`, `Q` taken from the `Yields` /// (`yields.hpp`): a `Definition`, and a series refused as `define` -/// refuses one. `Result` is `Q`'s place for a caller who names it anyway; -/// any other quantity is refused. A refused call defines `Q` as a constant -/// of its dimension, as the series overload above does, so that nothing -/// built on it adds a second message. +/// refuses one. Anything else that is not an expression of one value -- a +/// rejection of outliers, a retry, a whole opaque call -- is refused in the +/// words `detail::RequireSingleValueBound` gives it. `Result` is `Q`'s place +/// for a caller who names it anyway; any other quantity is refused. A +/// refused call defines `Q` as a constant of its dimension, as the series +/// overload above does, so that nothing built on it adds a second message. template [[nodiscard]] constexpr auto define(Yields const& boundFormula) noexcept { static_assert(detail::RequireYieldsResult::value); - if constexpr (!detail::names_yields_result || !Yields::valid) + static_assert(std::conditional_t, std::true_type, detail::SingleValueBoundCheck>::value); + if constexpr (!(Node || SeriesNode) || !detail::names_valid_bound) { using Placeholder = ConstantNode::dimension)>; return Definition { Placeholder {} }; diff --git a/include/formula-cpp/opaque.hpp b/include/formula-cpp/opaque.hpp index b09df7ea..2564f280 100644 --- a/include/formula-cpp/opaque.hpp +++ b/include/formula-cpp/opaque.hpp @@ -908,6 +908,13 @@ namespace detail static constexpr bool value = true; }; + /// A bound whole opaque call handed to a verb that answers with one value + /// (`yields.hpp`): refused as `checked_evaluate` refuses the call itself. + template + struct RequireSingleValueBound>: RequireOpaqueOutputChosen> + { + }; + /// Fails to compile when the unit a `rounded_output` is stated in does not /// measure the dimension of the output it rounds. Silent over a refused /// call or an output of no declared name: @p Output is refused already. diff --git a/include/formula-cpp/rejection.hpp b/include/formula-cpp/rejection.hpp index 79b830cb..63f0aff1 100644 --- a/include/formula-cpp/rejection.hpp +++ b/include/formula-cpp/rejection.hpp @@ -611,6 +611,32 @@ namespace detail template inline constexpr bool is_rejection_node> = true; + /// Fails to compile when a rejection is handed, bound to its result + /// quantity (`yields.hpp`), to a verb that answers with one value. Its + /// result is more than a value -- the survivors' mean, or the author's + /// verdict, with what was rejected and the passes that ran -- and has + /// verbs of its own. Named so the rejection prints. + template + struct RequireRejectionAsSuch + { + static_assert(!std::is_same_v, + "formula: this is a rejection of outliers, not a single value; evaluate it with " + "checked_evaluate_rejection, trace it with explain_rejection, or reduce it to one value first " + "(sample_mean) -- the rejection appears in this diagnostic as the template argument of " + "RequireRejectionAsSuch"); + + /// Always true: the refusal is the `static_assert` above. + static constexpr bool value = true; + }; + + /// A bound rejection handed to a verb that answers with one value: see + /// `RequireRejectionAsSuch`. + template + struct RequireSingleValueBound>: + RequireRejectionAsSuch> + { + }; + /// Fails to compile when `without_outliers` is given a single value. template struct RequireRejectionOfSample diff --git a/include/formula-cpp/retry.hpp b/include/formula-cpp/retry.hpp index fff18a8e..42ad32bd 100644 --- a/include/formula-cpp/retry.hpp +++ b/include/formula-cpp/retry.hpp @@ -54,6 +54,7 @@ #include #include #include +#include #include #include @@ -1677,6 +1678,13 @@ namespace detail static constexpr bool value = true; }; + /// A bound retry handed to a verb that answers with one value + /// (`yields.hpp`): refused as `checked_evaluate` refuses a retry. + template + struct RequireSingleValueBound>: RequireRetryAtTop> + { + }; + /// What arithmetic over a retry gives, once refused: a node of the /// retry's result's dimension that is refused already /// (`refused_already`), so nothing over it asks again, and that is never diff --git a/include/formula-cpp/trace.hpp b/include/formula-cpp/trace.hpp index c42600d3..a2614cd1 100644 --- a/include/formula-cpp/trace.hpp +++ b/include/formula-cpp/trace.hpp @@ -4444,7 +4444,8 @@ template ::value); - if constexpr (!detail::names_yields_result || !Yields::valid) + static_assert(detail::SingleValueBoundCheck::value); + if constexpr (!detail::evaluates_bound_value) return Explained {}; // refused already, where the mistake is else return explain(boundFormula.expression, environmentGiven, vocabulary); @@ -4583,7 +4584,8 @@ template ::value); - if constexpr (!detail::names_yields_result || !Yields::valid) + static_assert(detail::SingleValueBoundCheck::value); + if constexpr (!detail::evaluates_bound_value) return Explained {}; // refused already, where the mistake is else return checked_explain(boundFormula.expression, environmentGiven, vocabulary); @@ -4626,7 +4628,8 @@ template trace_of(Yields const& boundFormula, Env const& environmentGiven, V const& vocabulary = V {}) { static_assert(detail::RequireYieldsResult::value); - if constexpr (!detail::names_yields_result || !Yields::valid) + static_assert(detail::SingleValueBoundCheck::value); + if constexpr (!detail::evaluates_bound_value) return Trace {}; // refused already, where the mistake is else return trace_of(boundFormula.expression, environmentGiven, vocabulary); @@ -4654,6 +4657,17 @@ template .trace; } +/// A series handed to `trace_of_si`: refused as `checked_evaluate` refuses +/// one, pointing at `checked_evaluate_series` -- `explain_series` gives its +/// trace. The body is the refusal and nothing else; what it returns is never +/// seen. +template +[[nodiscard]] Trace trace_of_si(S const&, Env const&, V const& = V {}) +{ + static_assert(detail::RequireSingleValueExpression::value); + return Trace {}; +} + /// A retry's result together with every attempt that produced it. template struct ExplainedRetry diff --git a/include/formula-cpp/yields.hpp b/include/formula-cpp/yields.hpp index 04b8bf40..55161ac5 100644 --- a/include/formula-cpp/yields.hpp +++ b/include/formula-cpp/yields.hpp @@ -31,6 +31,13 @@ /// (`detail::RequireResultDimension`). /// /// A verb given a refused `Yields` adds no second message (`Yields::valid`). +/// +/// **Refused where it is evaluated:** a bound series, rejection of outliers, +/// retry or whole opaque call handed to a verb that answers with one value -- +/// `evaluate`, `checked_evaluate`, `explain`, `checked_explain`, `trace_of` or +/// `define` -- in words naming the verbs that take it +/// (`detail::RequireSingleValueBound`), once. `define` refuses a series in +/// its own words, as `define` does. #include #include @@ -117,6 +124,49 @@ namespace detail /// Always true: the refusal is the `static_assert` above. static constexpr bool value = true; }; + + /// Fails to compile when a bound formula that is not an expression of one + /// value is handed to a verb that answers with one: `evaluate`, + /// `checked_evaluate`, `explain`, `checked_explain`, `trace_of` or + /// `define`. A series is refused below; a rejection of outliers, a retry + /// and a whole opaque call where each is declared (`rejection.hpp`, + /// `retry.hpp`, `opaque.hpp`), each naming the verbs that take it. + template + struct RequireSingleValueBound + { + static_assert(Node, + "formula: this bound formula is not an expression of one value, which is what this verb " + "evaluates -- the formula appears in this diagnostic as the template argument of " + "RequireSingleValueBound"); + + /// Always true: the refusal is the `static_assert` above. + static constexpr bool value = true; + }; + + /// A bound series: refused as `checked_evaluate` refuses a series + /// (`RequireSingleValueExpression`, `evaluate.hpp`). + template + struct RequireSingleValueBound: RequireSingleValueExpression + { + }; + + /// Whether a `Yields` is asked for its own quantity (@p Result) and + /// passes its checks: one that is not has had its one message. + template + inline constexpr bool names_valid_bound = names_yields_result && Yields::valid; + + /// `RequireSingleValueBound`, asked only of a `Yields` for which + /// `names_valid_bound` holds. + template + using SingleValueBoundCheck = + std::conditional_t, RequireSingleValueBound, std::true_type>; + + /// Whether a verb that answers with one value evaluates a `Yields` + /// asked for @p Result: `names_valid_bound`, and it holds an expression of + /// one value. What those verbs gate on, so that a refused call adds no + /// second message. + template + inline constexpr bool evaluates_bound_value = names_valid_bound && Node; } // namespace detail /// A formula and the quantity it computes -- built by `yields(expression)`. @@ -170,7 +220,8 @@ template ::value); - if constexpr (!detail::names_yields_result || !Yields::valid) + static_assert(detail::SingleValueBoundCheck::value); + if constexpr (!detail::evaluates_bound_value) return Outcome::empty(); // refused already, where the mistake is else return checked_evaluate(boundFormula.expression, environmentGiven, recordingSink); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index ee69fc06..17b56766 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -532,6 +532,24 @@ formula_add_negative_test(yields_series_as_single formula_add_negative_test(trace_of_series_as_single "this expression is a series, not a single value; evaluate it with checked_evaluate_series" EXPECT_COUNT 1 REJECT "no matching") +formula_add_negative_test(trace_of_si_series_as_single + "this expression is a series, not a single value; evaluate it with checked_evaluate_series" EXPECT_COUNT 1 + REJECT "no matching") +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") +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") +formula_add_negative_test(yields_retry_evaluate + "formula: a retry is evaluated at the top, by checked_evaluate_retry" EXPECT_COUNT 6 + REJECT "no matching") +formula_add_negative_test(yields_opaque_call_evaluate + "formula: an opaque call is not a value, since an operation may have several outputs" EXPECT_COUNT 6 + REJECT "no matching") +formula_add_negative_test(yields_not_a_value + "formula: this bound formula is not an expression of one value" EXPECT_COUNT 1 + REJECT "no matching") formula_add_negative_test(yields_around_yields "formula: this formula is bound to its result quantity already" EXPECT_COUNT 1 REJECT "no viable conversion" "no matching" "formula: this formula names its result quantity with yields") diff --git a/test/negative/trace_of_si_series_as_single.cpp b/test/negative/trace_of_si_series_as_single.cpp new file mode 100644 index 00000000..816a84f5 --- /dev/null +++ b/test/negative/trace_of_si_series_as_single.cpp @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: this expression is a series, not a single value; evaluate it with checked_evaluate_series +// REJECT: no matching +// +// A series handed to trace_of_si, which traces a single value. Refused as +// the series itself is refused by checked_evaluate, pointing at +// checked_evaluate_series, rather than as an overload nobody matched. +#include +#include + +using Retained = formula::Quantity; + +inline constexpr auto inputs = formula::environment(formula::measured_series(131, 211, 97)); + +int main() +{ + return formula::trace_of_si(formula::series, inputs).empty() ? 1 : 0; +} diff --git a/test/negative/yields_not_a_value.cpp b/test/negative/yields_not_a_value.cpp new file mode 100644 index 00000000..d5d0dddc --- /dev/null +++ b/test/negative/yields_not_a_value.cpp @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: this bound formula is not an expression of one value +// REJECT: no matching +// +// A comparison bound to a quantity as though it were a formula, and +// evaluated. A comparison is a predicate, not a value, and none of the +// library's other kinds: refused once, in general words, rather than as an +// overload nobody matched. +#include + +using WaterVolume = formula::Quantity; +using CementVolume = formula::Quantity; +using WaterCementRatio = formula::Quantity; + +inline constexpr auto inputs = + formula::environment(formula::Measured { 163 }, formula::Measured { 307 }); + +int main() +{ + constexpr auto compared = formula::yields(formula::var > formula::var); + return formula::checked_evaluate(compared, inputs).has_value() ? 0 : 1; +} diff --git a/test/negative/yields_opaque_call_evaluate.cpp b/test/negative/yields_opaque_call_evaluate.cpp new file mode 100644 index 00000000..387dde7a --- /dev/null +++ b/test/negative/yields_opaque_call_evaluate.cpp @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: an opaque call is not a value, since an operation may have several outputs +// REJECT: no matching +// +// A bound whole opaque call handed to each verb that answers with one value: +// evaluate, checked_evaluate, explain, checked_explain, trace_of and define. +// Refused as checked_evaluate refuses the call itself, pointing at +// opaque_output -- rather than an overload nobody matched. A call over a +// series of its own length for each verb, so that each refusal is its own +// message: six. +#include +#include + +#include +#include +#include +#include +#include +#include + +using Retained = formula::Quantity; + +/// A consumer's operation: the first element of a series. +struct FirstElement +{ + static constexpr std::string_view name = "first element"; + static constexpr std::array shapes { formula::InputShape::Series }; + static constexpr std::array outputs { "first" }; + + static consteval std::optional> output_dimensions( + std::array declared) noexcept + { + return std::array { declared[0] }; + } + + template + static constexpr std::expected, formula::ArithmeticError> compute( + std::span elements) noexcept + { + return std::array { elements[0] }; + } +}; + +inline constexpr auto inputs = formula::environment(); + +template +[[nodiscard]] constexpr auto bound_call() +{ + return formula::yields( + formula::opaque({ .reference = "Example Standard 3" }, formula::series)); +} + +int main() +{ + auto const evaluated = formula::evaluate(bound_call<1>(), inputs); + auto const checked = formula::checked_evaluate(bound_call<2>(), inputs); + auto const explained = formula::explain(bound_call<3>(), inputs); + auto const checkedExplained = formula::checked_explain(bound_call<4>(), inputs); + auto const traced = formula::trace_of(bound_call<5>(), inputs); + auto const defined = formula::define(bound_call<6>()); + return evaluated.is_value() && checked.has_value() && explained.outcome.is_value() && checkedExplained.has_value() + && !traced.empty() && decltype(defined)::valid + ? 0 + : 1; +} diff --git a/test/negative/yields_rejection_evaluate.cpp b/test/negative/yields_rejection_evaluate.cpp new file mode 100644 index 00000000..0b427748 --- /dev/null +++ b/test/negative/yields_rejection_evaluate.cpp @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: this is a rejection of outliers, not a single value +// REJECT: no matching +// +// A bound rejection of outliers handed to each verb that answers with one +// value: evaluate, checked_evaluate, explain, checked_explain, trace_of and +// define. Its result is more than a value, and it has verbs of its own, which +// the refusal names -- rather than an overload nobody matched. A rejection of +// its own bound for each verb, so that each refusal is its own message: six. +#include +#include + +#include + +using Mass = formula::Quantity; + +// Invented determinations, in grams. +inline constexpr auto inputs = formula::environment(formula::measured_series(41, 43, 47, 53, 59, 61)); + +// A 6 % rejection that rejects at most K of the six determinations. +template +[[nodiscard]] constexpr auto bound_rejection() +{ + constexpr auto mostExtreme = formula::PerPass::MostExtreme; + constexpr auto keep = formula::OnLimit::Keep; + return formula::yields(formula::without_outliers, formula::KeepAtLeast<1>>( + formula::series, + formula::deviation_from_mean(formula::Rational { 6, 100 } * formula::pass_mean), + formula::Verdict { "repeat the determinations" }, + formula::Citation { .title = "Example Standard" })); +} + +int main() +{ + auto const evaluated = formula::evaluate(bound_rejection<1>(), inputs); + auto const checked = formula::checked_evaluate(bound_rejection<2>(), inputs); + auto const explained = formula::explain(bound_rejection<3>(), inputs); + auto const checkedExplained = formula::checked_explain(bound_rejection<4>(), inputs); + auto const traced = formula::trace_of(bound_rejection<5>(), inputs); + auto const defined = formula::define(bound_rejection<6>()); + return evaluated.is_value() && checked.has_value() && explained.outcome.is_value() && checkedExplained.has_value() + && !traced.empty() && decltype(defined)::valid + ? 0 + : 1; +} diff --git a/test/negative/yields_retry_evaluate.cpp b/test/negative/yields_retry_evaluate.cpp new file mode 100644 index 00000000..f07dae4a --- /dev/null +++ b/test/negative/yields_retry_evaluate.cpp @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: a retry is evaluated at the top, by checked_evaluate_retry +// REJECT: no matching +// +// A bound retry handed to each verb that answers with one value: evaluate, +// checked_evaluate, explain, checked_explain, trace_of and define. Refused as +// checked_evaluate refuses a retry, pointing at checked_evaluate_retry -- +// rather than an overload nobody matched. A retry of its own number of +// attempts for each verb, so that each refusal is its own message: six. +#include +#include + +#include + +using Estimate = formula::Quantity; + +inline constexpr auto inputs = formula::environment(); + +// w_k = 6.08 g + w_{k-1} / 2 from 0 g, accepted once it rises by at most +// 0.76 g, within Max attempts. +template +[[nodiscard]] constexpr auto bound_retry() +{ + return formula::yields(formula::retry( + formula::starting_from(formula::constant(formula::Rational { 0 })), + formula::constant(formula::Rational { 152, 25 }) + + formula::previous_attempt / formula::Rational { 2 }, + formula::previous_attempt - formula::this_attempt + >= formula::constant(formula::Rational { -19, 25 }), + formula::Verdict { "repeat the determination" }, + formula::Citation { .reference = "Example Standard 12" })); +} + +int main() +{ + auto const evaluated = formula::evaluate(bound_retry<1>(), inputs); + auto const checked = formula::checked_evaluate(bound_retry<2>(), inputs); + auto const explained = formula::explain(bound_retry<3>(), inputs); + auto const checkedExplained = formula::checked_explain(bound_retry<4>(), inputs); + auto const traced = formula::trace_of(bound_retry<5>(), inputs); + auto const defined = formula::define(bound_retry<6>()); + return evaluated.is_value() && checked.has_value() && explained.outcome.is_value() && checkedExplained.has_value() + && !traced.empty() && decltype(defined)::valid + ? 0 + : 1; +} diff --git a/test/negative/yields_series_explain.cpp b/test/negative/yields_series_explain.cpp new file mode 100644 index 00000000..20e5e8d2 --- /dev/null +++ b/test/negative/yields_series_explain.cpp @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: this expression is a series, not a single value; evaluate it with checked_evaluate_series +// REJECT: no matching +// +// A bound series handed to explain, checked_explain and trace_of, which trace +// a single value: each refused as checked_evaluate refuses a series, pointing +// at checked_evaluate_series, rather than as an overload nobody matched. A +// series of its own quantity for each verb, so that each refusal is its own +// message: three. +#include +#include + +using Retained = formula::Quantity; +using Passing = formula::Quantity; +using Sieved = formula::Quantity; + +inline constexpr auto inputs = formula::environment(formula::measured_series(131, 211, 97), + formula::measured_series(41, 43, 47), + formula::measured_series(53, 59, 61)); + +int main() +{ + auto const explained = formula::explain(formula::yields(formula::series), inputs); + auto const checkedExplained = formula::checked_explain(formula::yields(formula::series), inputs); + auto const traced = formula::trace_of(formula::yields(formula::series), inputs); + return explained.outcome.is_value() && checkedExplained.has_value() && !traced.empty() ? 0 : 1; +} From 968984234fb74d3a5f54ecf6e22ca8ae0f7dc0a6 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 22:46:47 +0200 Subject: [PATCH 41/59] fix: refuse a bound formula used as an operand, in the library's words A bound formula is the top of a formula, not a part of one; the formula it holds, .expression, is the operand. Written as an operand anyway -- var * ratio -- it matched no operator, and the mistake came back as a list of twenty to thirty candidate operators. Arithmetic with a bound formula on either side, and its negation, is now refused in one sentence that names .expression. The operators take part only when an operand is a bound formula, so arithmetic over formulas resolves exactly as before. As for a retry, each gives a node refused already, so an evaluation of the result asks nothing more, and names its return type, so a concept asking whether a bound formula can be added is answered without the refusal. Signed-off-by: Christian Parpart --- CHANGELOG.md | 3 +- include/formula-cpp/yields.hpp | 121 ++++++++++++++++++++++++++++ test/CMakeLists.txt | 3 + test/negative/yields_as_operand.cpp | 25 ++++++ test/yields_tests.cpp | 25 ++++++ 5 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 test/negative/yields_as_operand.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index cd15c2b2..b0459a90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,7 +69,8 @@ change is recorded here. `Q`. Nest `documented()` inside it, and reuse the formula in another through `.expression`; a `yields` around a bound formula is refused where it is written. A bound series, rejection of outliers, retry or whole opaque call handed to a verb that answers with one value is refused in - words that name the verbs that take it. Every earlier spelling stays. + words that name the verbs that take it, and a bound formula used as an operand is refused in + favour of its `.expression`. Every earlier spelling stays. ### Changed diff --git a/include/formula-cpp/yields.hpp b/include/formula-cpp/yields.hpp index 55161ac5..466df036 100644 --- a/include/formula-cpp/yields.hpp +++ b/include/formula-cpp/yields.hpp @@ -38,6 +38,9 @@ /// `define` -- in words naming the verbs that take it /// (`detail::RequireSingleValueBound`), once. `define` refuses a series in /// its own words, as `define` does. +/// +/// **Refused as an operand:** a `Yields` on either side of `+`, `-`, `*` or +/// `/`, or negated (`detail::RequireBoundNotOperand`). Use its `.expression`. #include #include @@ -236,4 +239,122 @@ template (boundFormula, environmentGiven, recordingSink)); } +namespace detail +{ + /// Fails to compile when a bound formula is an operand of `+`, `-`, `*` + /// or `/`. A bound formula is the top of a formula, not a part of one; + /// the formula it holds is an operand as any formula is. Named so the + /// bound formula prints. + template + struct RequireBoundNotOperand + { + static_assert(!is_yields, + "formula: a bound formula is not an operand; use its .expression -- the bound formula appears " + "in this diagnostic as the template argument of RequireBoundNotOperand"); + + /// Always true: the refusal is the `static_assert` above. + static constexpr bool value = true; + }; + + /// The dimension of whichever of @p L and @p R is a bound formula: its + /// quantity's. + template + [[nodiscard]] consteval Dimension bound_operand_dimension() noexcept + { + if constexpr (is_yields) + return Describe::dimension; + else + return Describe::dimension; + } + + /// What arithmetic over a bound formula gives, once refused: a node of + /// the bound quantity's dimension that is refused already + /// (`refused_already`), so nothing over it asks again, and that is never + /// evaluated but to a `DomainError`. + template + struct RefusedBoundValue: NodeBase + { + /// The bound quantity's dimension -- a stand-in no check reads. + static constexpr Dimension dimension = D; + /// Always refused: see above. + static constexpr RefusedFlag refused = true; + }; + + /// The type an arithmetic operator over @p L and @p R returns when one of + /// them is a bound formula; none otherwise. The operators name it, so that + /// asking whether a bound formula can be added, as a concept does, is + /// answered without instantiating their bodies -- the refusal. A class, so + /// that over two operands neither of which is bound it has no `type`, a + /// substitution failure, as for a retry (`retry.hpp`). + template || is_yields> + struct RefusedBoundResult + { + }; + + template + struct RefusedBoundResult + { + /// The refused value. + using type = RefusedBoundValue()>; + }; +} // namespace detail + +/// A refused bound operand evaluates to nothing but `DomainError`; a program +/// holding one never compiles, so this is never seen. +template +[[nodiscard]] constexpr Evaluated checked_evaluate_si(detail::RefusedBoundValue const&, + Env const&, + Sink = {}) noexcept +{ + return Evaluated { std::unexpected { ArithmeticError::DomainError } }; +} + +/// A bound formula in arithmetic, on either side of `+`, `-`, `*` or `/`, or +/// negated: refused in this library's words, giving a node refused already. +/// Only an operand that is a `Yields` reaches these, so arithmetic over +/// formulas is untouched. +template + requires(detail::is_yields || detail::is_yields) +[[nodiscard]] constexpr auto operator+(L, R) noexcept -> typename detail::RefusedBoundResult::type +{ + static_assert(detail::RequireBoundNotOperand, L, R>>::value); + return {}; +} + +/// See `operator+` over a bound formula. +template + requires(detail::is_yields || detail::is_yields) +[[nodiscard]] constexpr auto operator-(L, R) noexcept -> typename detail::RefusedBoundResult::type +{ + static_assert(detail::RequireBoundNotOperand, L, R>>::value); + return {}; +} + +/// See `operator+` over a bound formula. +template + requires(detail::is_yields || detail::is_yields) +[[nodiscard]] constexpr auto operator*(L, R) noexcept -> typename detail::RefusedBoundResult::type +{ + static_assert(detail::RequireBoundNotOperand, L, R>>::value); + return {}; +} + +/// See `operator+` over a bound formula. +template + requires(detail::is_yields || detail::is_yields) +[[nodiscard]] constexpr auto operator/(L, R) noexcept -> typename detail::RefusedBoundResult::type +{ + static_assert(detail::RequireBoundNotOperand, L, R>>::value); + return {}; +} + +/// See `operator+` over a bound formula. +template + requires(detail::is_yields) +[[nodiscard]] constexpr auto operator-(Operand) noexcept -> typename detail::RefusedBoundResult::type +{ + static_assert(detail::RequireBoundNotOperand::value); + return {}; +} + } // namespace formula diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 17b56766..18b67d56 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -547,6 +547,9 @@ formula_add_negative_test(yields_retry_evaluate formula_add_negative_test(yields_opaque_call_evaluate "formula: an opaque call is not a value, since an operation may have several outputs" EXPECT_COUNT 6 REJECT "no matching") +formula_add_negative_test(yields_as_operand + "formula: a bound formula is not an operand; use its .expression" EXPECT_COUNT 1 + REJECT "no matching" "invalid operands" "no match for" "RequireResultDimension") formula_add_negative_test(yields_not_a_value "formula: this bound formula is not an expression of one value" EXPECT_COUNT 1 REJECT "no matching") diff --git a/test/negative/yields_as_operand.cpp b/test/negative/yields_as_operand.cpp new file mode 100644 index 00000000..40123193 --- /dev/null +++ b/test/negative/yields_as_operand.cpp @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: a bound formula is not an operand; use its .expression +// REJECT: no matching +// REJECT: invalid operands +// REJECT: no match for +// REJECT: RequireResultDimension +// +// A formula bound to the water/cement ratio, used as an operand of another +// formula and evaluated: refused once, in this library's words, and the +// refused value asks nothing more. The formula it holds, .expression, is the +// operand to use. +#include + +using WaterVolume = formula::Quantity; +using CementVolume = formula::Quantity; +using WaterCementRatio = formula::Quantity; + +inline constexpr auto inputs = + formula::environment(formula::Measured { 163 }, formula::Measured { 307 }); + +int main() +{ + constexpr auto ratio = formula::yields(formula::var / formula::var); + return formula::checked_evaluate(formula::var * ratio, inputs).has_value() ? 0 : 1; +} diff --git a/test/yields_tests.cpp b/test/yields_tests.cpp index 921a591b..93638b87 100644 --- a/test/yields_tests.cpp +++ b/test/yields_tests.cpp @@ -11,6 +11,7 @@ #include #include #include +#include namespace { @@ -239,3 +240,27 @@ TEST_CASE("yields: every verb hands on the sink and the vocabulary it is given", REQUIRE(renamedRejection != written(formula::explain_rejection(rejectionA, fixtureA).trace)); CHECK(written(formula::explain_rejection(settledMass, fixtureA, renamedMass).trace) == renamedRejection); } + +TEST_CASE("yields: a bound formula is not an operand, and arithmetic over formulas is untouched", "[yields]") +{ + // Asked of a type, arithmetic over a bound formula is answered without + // the refusal firing: the refused operators name their return type. + using Bound = std::remove_const_t; + using Refused = formula::detail::RefusedBoundValue::dimension>; + STATIC_REQUIRE(std::is_same_v * std::declval()), Refused>); + STATIC_REQUIRE(std::is_same_v() + formula::Rational { 1 }), Refused>); + STATIC_REQUIRE(std::is_same_v()), Refused>); + STATIC_REQUIRE(formula::detail::refused_already()); + + // The formula it holds is an operand as any formula is, and so is every + // other operand those operators could have taken. + using Held = std::remove_const_t; + STATIC_REQUIRE( + std::is_same_v * ratio.expression), + formula::BinaryNode, Held>>); + STATIC_REQUIRE( + std::is_same_v>>); + STATIC_REQUIRE(formula::number_of(formula::checked_evaluate(var * ratio.expression, batch)) + == formula::Rational { 163 }); +} From 4c36b53510204f7e0cf738a7ea7199d809acb38d Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 22:53:33 +0200 Subject: [PATCH 42/59] fix: name the rounding refusal's spelling, and correct stale comments The refusal of a rounding rule with no citation is shared by both spellings of with_rounding, yet named only the three-argument one; it now names with_rounding<...>(). The unit conversion's note no longer cites the compilers it was measured with, the every-verb negative counts ten verbs and says which REJECT is g++'s, a rewrapped paragraph in lookup.hpp is reflowed, and the vocabulary check says it misses a template argument split across lines. Signed-off-by: Christian Parpart --- cmake/CheckVocabularyReach.cmake | 3 ++- include/formula-cpp/lookup.hpp | 12 ++++++------ include/formula-cpp/measured.hpp | 2 +- include/formula-cpp/overlay.hpp | 5 ++--- test/CMakeLists.txt | 10 +++++----- .../overlay_named_rounding_without_citation.cpp | 2 +- test/negative/overlay_rounding_without_citation.cpp | 2 +- 7 files changed, 18 insertions(+), 18 deletions(-) diff --git a/cmake/CheckVocabularyReach.cmake b/cmake/CheckVocabularyReach.cmake index 2f18fe03..b5e6bafe 100644 --- a/cmake/CheckVocabularyReach.cmake +++ b/cmake/CheckVocabularyReach.cmake @@ -30,7 +30,8 @@ # through the default vocabulary and ignores the one the surface was given # (angle brackets and one level of parentheses inside the template argument # are matched, as in `Wrapper` and `decltype(x)`; deeper parentheses, as -# in `decltype(f(x))`, are not caught); +# in `decltype(f(x))`, are not caught, and nor is a template argument split +# across lines, since the match stops at a line's end); # - a two-argument `render<...>(x, renderOptions)` or # `document<...>(x, renderOptions)` call, outside the public overloads that # forward `RenderOptions` -- it names no vocabulary, so it resolves the diff --git a/include/formula-cpp/lookup.hpp b/include/formula-cpp/lookup.hpp index 4eb02f12..d298c4c0 100644 --- a/include/formula-cpp/lookup.hpp +++ b/include/formula-cpp/lookup.hpp @@ -613,12 +613,12 @@ namespace detail /// typed as `0` -- checked against the installed package on all three node /// kinds, all three of which did it. The factory's parameter type cannot see /// that call, because there is no call. Making the member itself a -/// `Corrections` is what closes it: the -/// braced list now initialises this type, a short one selects the -/// arity-mismatch constructor below, and its `static_assert` names both counts -/// at the offending line. `lookup_short_corrections_no_factory.cpp` and its -/// two siblings pin exactly that, one per node kind, and reverting any one -/// member to a raw array fails that kind's case alone. +/// `Corrections` is what closes it: the braced list now initialises this +/// type, a short one selects the arity-mismatch constructor below, and its +/// `static_assert` names both counts at the offending line. +/// `lookup_short_corrections_no_factory.cpp` and its two siblings pin exactly +/// that, one per node kind, and reverting any one member to a raw array fails +/// that kind's case alone. /// /// Nodes therefore declare `Corrections corrections;` with **no default /// member initialiser**, and that omission is load bearing: `{}` for a table diff --git a/include/formula-cpp/measured.hpp b/include/formula-cpp/measured.hpp index da872d2f..9953e160 100644 --- a/include/formula-cpp/measured.hpp +++ b/include/formula-cpp/measured.hpp @@ -200,7 +200,7 @@ namespace detail /// is present: a conversion nobody could perform does not compile, and so cannot /// look like it succeeded merely because there was no number to get wrong. A /// refused conversion draws that one message: the unit conversion in the body is -/// an ordinary run-time call and adds none (measured with cl and clang-cl). +/// an ordinary run-time call and adds none. template [[nodiscard]] constexpr std::expected, ArithmeticError> checked_convert_to(Measured value) noexcept { diff --git a/include/formula-cpp/overlay.hpp b/include/formula-cpp/overlay.hpp index de602432..cc012ae8 100644 --- a/include/formula-cpp/overlay.hpp +++ b/include/formula-cpp/overlay.hpp @@ -743,9 +743,8 @@ template [[nodiscard]] constexpr RoundingOverride with_rounding() noexcept { static_assert(Stated, - "formula: with_rounding() was given no citation; a rounding rule is a " - "jurisdiction's decision, and a trace must say whose -- pass the Citation of the clause that " - "states it"); + "formula: with_rounding<...>() was given no citation; a rounding rule is a jurisdiction's " + "decision, and a trace must say whose -- pass the Citation of the clause that states it"); return RoundingOverride {}; } diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 18b67d56..29a3359d 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -517,12 +517,12 @@ formula_add_negative_test(trace_of_result_dimension_mismatch formula_add_negative_test(trace_of_yields_relabelled "formula: this formula names its result quantity with yields" EXPECT_COUNT 1 REJECT "no matching") -# Nine verbs, each asked for its own wrong quantity: one refusal each. Each +# Ten verbs, each asked for its own wrong quantity: one refusal each. Each # verb gates on names_yields_result, never on RequireYieldsResult's value: # with the gate reading that value, clang-cl 22.1.8 compiled both branches # of define's gate once the check had failed, and its two returns deduce -# different types. The last two REJECTs name that error: clang's words, as -# clang-cl 22.1.8 printed them, and g++'s, not yet measured. +# different types. The last two REJECTs name that error: the first in +# clang's words, as clang-cl 22.1.8 printed them, the other in g++'s. formula_add_negative_test(yields_relabelled_every_verb "formula: this formula names its result quantity with yields" EXPECT_COUNT 10 REJECT "no matching" "in return type deduced as" "inconsistent deduction for auto return type") @@ -1645,9 +1645,9 @@ formula_add_negative_test(overlay_derived_without_citation formula_add_negative_test(overlay_replacement_without_citation "formula: replace_variant(expression) was given no citation") formula_add_negative_test(overlay_rounding_without_citation - "formula: with_rounding() was given no citation") + "formula: with_rounding<...>() was given no citation") formula_add_negative_test(overlay_named_rounding_without_citation - "formula: with_rounding() was given no citation") + "formula: with_rounding<...>() was given no citation") formula_add_negative_test(overlay_constraints_without_citation "formula: with_constraints(constraints(...)) was given no citation") diff --git a/test/negative/overlay_named_rounding_without_citation.cpp b/test/negative/overlay_named_rounding_without_citation.cpp index 8165f623..881411d3 100644 --- a/test/negative/overlay_named_rounding_without_citation.cpp +++ b/test/negative/overlay_named_rounding_without_citation.cpp @@ -1,5 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -// EXPECT: formula: with_rounding() was given no citation +// EXPECT: formula: with_rounding<...>() was given no citation // // A rounding rule named as a DecimalRounding and stated with no citation: refused // in the same words as the three-argument spelling, since a trace that says "by diff --git a/test/negative/overlay_rounding_without_citation.cpp b/test/negative/overlay_rounding_without_citation.cpp index d5a5d83a..773e2bf6 100644 --- a/test/negative/overlay_rounding_without_citation.cpp +++ b/test/negative/overlay_rounding_without_citation.cpp @@ -1,5 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -// EXPECT: formula: with_rounding() was given no citation +// EXPECT: formula: with_rounding<...>() was given no citation // // A rounding rule an overlay states with no citation. The trace exists to say why // a number is what it is, and "by jurisdiction overlay" with no citation says From 04c0b0e58ab33150b7d936b70bfc80ca685a940c Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 22:59:08 +0200 Subject: [PATCH 43/59] docs(trace): say which entered value trace_of_si ignores, and pin its root The guide's trace_of example now uses the names of the explain example it points back to. trace_of_si consults no entered value for a result, while an input typed in is read as any other, and a failure is the trace's last step only at a node that reports to its sink, as each of this library's does. A test traces a consumer's two-parameter node at the root of trace_of_si, which compiles only through detail::dispatch. Signed-off-by: Christian Parpart --- docs/tracing.md | 32 ++++++++++++++++++-------------- include/formula-cpp/trace.hpp | 26 +++++++++++++++----------- test/sink_tests.cpp | 13 +++++++++++++ 3 files changed, 46 insertions(+), 25 deletions(-) diff --git a/docs/tracing.md b/docs/tracing.md index 8f9a0fac..792fecd3 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -231,26 +231,30 @@ was typed in rather than derived leaves `trace` empty, as it does for Code that only shows how a number was reached has no use for the outcome, and `traced` spells the lambda out each time. `trace_of` gives the `Trace` alone, -whether the evaluation succeeded or failed. With `densityFormula` and `env` as +whether the evaluation succeeded or failed. With `density` and `environment` as in the `explain` example above: ```cpp -auto const steps = formula::trace_of(densityFormula, env); +auto const steps = formula::trace_of(density, environment); auto const text = formula::render_trace(steps, { .maxSteps = 100 }); ``` -A failure while the formula is evaluated is the trace's last step. One -converting the result into `Density`'s unit comes after it and is not in the -trace, so read the outcome where that matters. When `env` holds a value typed in -for `Density`, that value is returned without evaluating and the trace is empty, -as it is for `explain`. - -A bound formula names its quantity already, so `trace_of(boundFormula, env)` -needs none. `trace_of_si(densityFormula, env)` traces the evaluation in SI units -with no result quantity named: it records the same steps for a derived result, -and since it consults no typed-in value it traces the derivation even where -`trace_of` is empty. All three take the vocabulary to write the symbols -in as an optional last argument. +A failure while the formula is evaluated is the trace's last step (at every +node that reports to its sink, as each of this library's does; see +[The extension point](#the-extension-point-your-node-evaluates-but-is-it-traced)). +One converting the result into `Density`'s unit comes after it and is not in +the trace, so read the outcome where that matters. When `environment` holds a +value typed in for `Density`, that value is returned without evaluating and the +trace is empty, as it is for `explain`. + +A bound formula names its quantity already, so +`trace_of(boundFormula, environment)` needs none. +`trace_of_si(density, environment)` traces the evaluation in SI units with no +result quantity named: it records the same steps for a derived result, and +since it consults no value typed in for a result -- an input typed in is read +as any other -- it traces the derivation even where `trace_of` is +empty. All three take the vocabulary to write the symbols in as an optional +last argument. The outcome is deliberately not returned. A caller who needs it reads it with `checked_evaluate`, and one who needs it together with its trace uses diff --git a/include/formula-cpp/trace.hpp b/include/formula-cpp/trace.hpp index a2614cd1..a62711f8 100644 --- a/include/formula-cpp/trace.hpp +++ b/include/formula-cpp/trace.hpp @@ -4594,11 +4594,13 @@ template (expression, /// environmentGiven, sink)` -- whether the evaluation succeeds or fails. A -/// failure while the expression is evaluated is the trace's last step; one -/// converting the result into `Result`'s unit comes after it and is not in the -/// trace, so read the outcome where that matters. For showing how a number was -/// reached, or where it could not be: the outcome is not returned, so read it -/// with `checked_evaluate` or `checked_explain` where it is used. +/// failure while the expression is evaluated is the trace's last step (at +/// every node that reports to its sink, as each of this library's does; see +/// `docs/tracing.md`, section "The extension point"); one converting the +/// result into `Result`'s unit comes after it and is not in the trace, so read +/// the outcome where that matters. For showing how a number was reached, or +/// where it could not be: the outcome is not returned, so read it with +/// `checked_evaluate` or `checked_explain` where it is used. /// /// **Empty when @p environmentGiven holds an `entered` value for `Result`:** /// that value is returned without evaluating, as `explain` says, so nothing @@ -4637,13 +4639,15 @@ template ` records for a derived result, and a -/// failure is the last of them, since nothing is converted afterwards. +/// failure is the last of them (at every node that reports to its sink, as +/// `trace_of` says), since nothing is converted afterwards. /// -/// Naming no result, it consults no `entered` value: it traces the expression -/// even where `checked_evaluate` returns a typed-in value and records -/// nothing, so a page showing it beside that value shows a derivation of a -/// number that is not the one reported. For display, as `trace_of` is: read -/// the outcome with `checked_evaluate_si` where it is used. +/// Naming no result, it consults no `entered` value for a result; an input +/// typed in is read as any other. It traces the expression even where +/// `checked_evaluate` returns a typed-in value and records nothing, so a +/// page showing it beside that value shows a derivation of a number that is +/// not the one reported. For display, as `trace_of` is: read the outcome with +/// `checked_evaluate_si` where it is used. /// /// Every step naming a quantity writes its symbol as @p vocabulary says. template diff --git a/test/sink_tests.cpp b/test/sink_tests.cpp index 2c734060..751c72ee 100644 --- a/test/sink_tests.cpp +++ b/test/sink_tests.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include @@ -170,6 +171,18 @@ TEST_CASE("a two-parameter extension-point node also works as the root of an exp CHECK(*measurement.stored() == formula::Rational { 7 }); } +TEST_CASE("trace_of_si traces a two-parameter extension-point node at the root, as checked_evaluate does", "[sink][trace]") +{ + // trace_of_si evaluates through detail::dispatch, as checked_evaluate + // does, so a consumer's node that only learned two parameters is found at + // the root as well as nested. Called with the recording sink directly, + // the root would have no overload to match. The node reports nothing, so + // it records no step of its own; the sum around it records the two that + // do. + CHECK(formula::trace_of_si(LegacyNode {}, environmentOf(5, 1)).empty()); + CHECK(formula::trace_of_si(LegacyNode {} + var, environmentOf(5, 1)).steps.size() == 2); +} + // --------------------------------------------------------------------------- // Constant evaluation // From 98007f84519fcafb81eb7b8b97a2de2ded07bf1c Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 23:01:31 +0200 Subject: [PATCH 44/59] test(consumer-globals): instantiate the explain twins the probe missed The probe now instantiates explain_check, explain_check_all, explain_check_method, explain_curve and the unbound explain_rejection, so a consumer's global hidden by one of their locals breaks its build, and checks each against the outcome of its untraced verb. Signed-off-by: Christian Parpart --- test/consumer_globals_run_tests.cpp | 2 +- test/consumer_globals_tests.cpp | 29 ++++++++++++++++++++++++----- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/test/consumer_globals_run_tests.cpp b/test/consumer_globals_run_tests.cpp index 6dabfd44..c9c8620e 100644 --- a/test/consumer_globals_run_tests.cpp +++ b/test/consumer_globals_run_tests.cpp @@ -13,7 +13,7 @@ TEST_CASE("a consumer's ordinary globals do not break a build that includes every header", "[hygiene]") { ConsumerGlobalsProbe const probe = probe_consumer_globals(); - REQUIRE(probe.checks.size() == 113); + REQUIRE(probe.checks.size() == 118); for (std::size_t index = 0; index < probe.checks.size(); ++index) { INFO("check " << index); diff --git a/test/consumer_globals_tests.cpp b/test/consumer_globals_tests.cpp index 964b9589..d179358c 100644 --- a/test/consumer_globals_tests.cpp +++ b/test/consumer_globals_tests.cpp @@ -24,8 +24,9 @@ // untraced and traced, with `explain`; `render` and `document` in all three // dialects, with and without a vocabulary, of that formula, of a constraint // and its predicate, and of formulas an overlay fixed, derived and replaced; -// `render_trace`; `traced`, `trace_of`, `trace_of_si`, `explain_conformity` and -// `explain_method`; +// `render_trace`; `traced`, `trace_of`, `trace_of_si`, `explain_conformity`, +// `explain_method`, `explain_check_method`, `explain_check` and +// `explain_check_all`; // `check` and `check_all`; `evaluate_method` of an original // and of a replaced variant, and `check_method`, with `RecordingSink` and // with a sink of its own; `apply` with every overlay operation; `Outcome`'s @@ -41,12 +42,14 @@ // from either end, a per-element rounding and `sum`, inside a method an // overlay's constant rewrote, evaluated, rendered, documented and traced; a // conformity check against a limit envelope, a snap, and curves -- a declared -// domain, a pairing, a splice and an interpolation -- on the same surfaces; +// domain, a pairing, a splice and an interpolation -- on the same surfaces, +// with `explain_curve`; // raw observations, `from` and `get_observations`, binned into classes and // divided by their sum, on the same surfaces; // a sample's count, mean, variance and range, and a rounded root of the -// variance, on the same surfaces; a rejection of outliers, evaluated alone -// and under a mean, on the same surfaces, and one by gap to range; a mean +// variance, on the same surfaces; a rejection of outliers, evaluated alone, +// with `explain_rejection` and under a mean, on the same surfaces, and one by +// gap to range; a mean // and a rejection of raw observations, on the same surfaces; a consumer's // opaque operation's output, evaluated exactly and in double, and rounded // where it is used, traced, rendered and documented; a least-squares fit, @@ -691,6 +694,14 @@ ConsumerGlobalsProbe probe_consumer_globals() && formula::render_trace(explainedEdgeCheck.trace, { .maxSteps = 20 }) == formula::render_trace(conformityTrace, { .maxSteps = 20 })); probe.checks.push_back(explainedStrength.outcome == strength && !explainedStrength.trace.empty()); + // The constraint twins and the method's: each the outcome of its untraced + // verb above, with the steps it recorded. + auto const explainedLimit = formula::explain_check(forceLimit, specimen, north); + auto const explainedLimits = formula::explain_check_all(formula::constraints(forceLimit), specimen, north); + auto const explainedVerdicts = formula::explain_check_method(overlaid, specimen, north); + probe.checks.push_back(explainedLimit.outcome == checkedLimit && !explainedLimit.trace.empty()); + probe.checks.push_back(explainedLimits.outcome == setOutcomes && !explainedLimits.trace.empty()); + probe.checks.push_back(explainedVerdicts.outcome == verdicts && !explainedVerdicts.trace.empty()); // A snap: 150 mm among 137, 149 and 151 mm is a tie, decided toward the // higher. auto const snappedEdge = formula::snapped(var); @@ -724,6 +735,8 @@ ConsumerGlobalsProbe probe_consumer_globals() && formula::document(readEdge).formula.find("interpolate") != std::string::npos && formula::document(edgeCurve, north).symbols.empty() && formula::render_trace(curveTrace, { .maxSteps = 40 }).find("[between 139 and 161 mm]") != std::string::npos); + auto const explainedCurve = formula::explain_curve(edgeCurve, specimen, north); + probe.checks.push_back(explainedCurve.outcome == splicedEdge && !explainedCurve.trace.empty()); // Raw observations, from a span, binned into two classes: 163 mm sits on // the boundary and is counted in the upper class. std::array const edgeReadings { formula::Rational { 103 }, @@ -798,6 +811,12 @@ ConsumerGlobalsProbe probe_consumer_globals() && formula::document(formula::sample_mean(trimmed), north).rejections.size() == 1 && formula::render_trace(trimmedTrace, { .maxSteps = 20 }).find("settled: 0 rejected, 2 remain") != std::string::npos); + auto const explainedRejection = formula::explain_rejection(trimmed, bothScreens, north); + probe.checks.push_back( + explainedRejection.outcome.has_value() && trimmedOutcome.has_value() + && explainedRejection.outcome->outcome() == trimmedOutcome->outcome() + && formula::render_trace(explainedRejection.trace, { .maxSteps = 20 }).find("settled: 0 rejected, 2 remain") + != std::string::npos); // Gap to range at 3/4: 150 and 103 mm are each other's neighbour, a gap // of the whole range, so both are past it -- and rejecting both would // leave none of at least 1, so it aborts with the verdict. From 67b8fbe740a84462a24cbdfb2f9a7285cd68683d Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 23:03:09 +0200 Subject: [PATCH 45/59] docs(changelog): record every name argument-dependent lookup now reaches The entry named only describe, whose clash is loud. An unqualified call with library arguments now also finds number_of, convert_to, round_to_declared, within_bounds, traced, trace_of, trace_of_si and the explain twins; a consumer's template of the same shape is displaced silently, and a non-template or differently typed one is ambiguous. using namespace formula also brings _r into scope. Signed-off-by: Christian Parpart --- CHANGELOG.md | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0459a90..7e4e8b5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,11 +91,22 @@ change is recorded here. into a mass, or euros into yen, used to compile and get `ArithmeticError::DomainError` at run time; it no longer compiles, and the message names the two quantities. A conversion between quantities of one dimension is unchanged. -- An unqualified call of `describe` with a `ConstraintOutcomeKind`, a `RetryEnd`, a `ValueSource`, - an `OutcomeKind` or a `FailureSite` now finds the library's function by argument-dependent - lookup. A consumer's own `describe` for one of these enums -- a `describe(ConstraintOutcomeKind)` - helper, say -- now makes such a call ambiguous, and has to be renamed or removed, as - `examples/constraints.cpp`'s was, or called by a qualified name such as `::describe`. +- An unqualified call with arguments of this library's types now also finds, by argument-dependent + lookup, the functions this release adds: `describe` of a `ConstraintOutcomeKind`, a `RetryEnd`, a + `ValueSource`, an `OutcomeKind` or a `FailureSite`; `number_of`, `convert_to`, `round_to_declared` + and `within_bounds`; `traced`, `trace_of` and `trace_of_si`; and the `explain_*` twins above. A + consumer's own function of one of these names, visible where the call is written, meets the + library's in one of two ways. A function template of the same name and shape -- a + `template Measured convert_to(Measured)` helper, say -- is + displaced **silently**: the library's is more constrained, so it is chosen, the helper no longer + runs, and where the helper returned an absent value on failure the library's `convert_to` throws + `ArithmeticException`. A non-template function, or one taking other parameter types -- a + `describe(ConstraintOutcomeKind)` helper, or a + `template Rational number_of(Measured)` that takes its argument by value -- makes + the call ambiguous. Either way, rename the helper, as `examples/constraints.cpp`'s `describe` was, + or call it by a qualified name such as `::convert_to`. And since `_r` is declared in an inline + namespace of `formula`, `using namespace formula;` now brings it into scope, where a consumer's + own `_r` is ambiguous with it. ## [0.2.0] - 2026-09-30 From ef614186c9496a0d205b8b28f38f35385afe6db5 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 23:04:19 +0200 Subject: [PATCH 46/59] docs(plan): drop the controller's local tracking from the committed plan The plan named a local status file, its board and the configuration that remembers the board's address, none of which is in the repository or means anything to its readers. Where a sentence carried a rule, it now says that the controller keeps local progress notes. Signed-off-by: Christian Parpart --- .../plans/2026-09-30-concise-spellings.md | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/docs/superpowers/plans/2026-09-30-concise-spellings.md b/docs/superpowers/plans/2026-09-30-concise-spellings.md index 9003c800..f8451450 100644 --- a/docs/superpowers/plans/2026-09-30-concise-spellings.md +++ b/docs/superpowers/plans/2026-09-30-concise-spellings.md @@ -80,7 +80,7 @@ The 19 programs in `examples/` repeat a few shapes, and those shapes make the li These bind every task. - **C++23, header-only**, no dependency beyond the standard library in `include/`. -- **Worktree.** All work happens in `D:\formula-cpp\.claude\worktrees\concise-spellings` on `feature/concise-spellings`, branched from master `eb5eed8`. Never touch `D:\formula-cpp` itself or another worktree. Only `STATUS.md`, in the main tree, is written by the controller (see *Status board*). +- **Worktree.** All work happens in `D:\formula-cpp\.claude\worktrees\concise-spellings` on `feature/concise-spellings`, branched from master `eb5eed8`. Never touch `D:\formula-cpp` itself or another worktree. Only the controller writes outside the worktree, where it keeps local progress notes (see *Execution*). - **Verification per task: the Windows compilers only, MSVC `cl` and `clang-cl`** (owner, 2026-09-30, for speed; this replaces the earlier MSVC + g++-14 rule). No WSL build runs during Tasks 1–15. The full suite (all eight presets, g++-14 and clang++ under WSL, Doxygen 1.9.8, `mkdocs build --strict`) runs once, in Task 16, and is driven to green there. Set `$S = C:\Users\c.parpart\AppData\Local\Temp\claude\D--formula-cpp\b0c0e78c-1b2d-4d4c-a792-0760adeb4a03\scratchpad` and `$T = D:\formula-cpp\.claude\worktrees\concise-spellings`. - **Verify(``)**, the per-task gate, is two commands, and both must print `ALL OK`: 1. `pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Exclude "^negative\."`: the full cl-debug build, then every test except the negative ones (unit, compile-time, hygiene, example, docs, census). @@ -113,13 +113,13 @@ These bind every task. - every ```` ```cpp ```` block must be consecutive source lines of the example, and every ```` ```text ```` block consecutive lines of its real output; - **so a checked guide changes only in the same task as its example** (Tasks 11–15). Library tasks document in Doxygen, in `CHANGELOG.md`, and in the *unchecked* guides named in each task; - a refusal quoted in `docs/` must be a header's message verbatim (`hygiene.documented-diagnostic-text`). -- **No internal labels in public text.** No task, phase, plan, lane or reviewer names in code, docs, commit messages or the PR. Every sentence must make sense to a reader who never saw this plan. Never cite `STATUS.md` or its URL. +- **No internal labels in public text.** No task, phase, plan, lane or reviewer names in code, docs, commit messages or the PR. Every sentence must make sense to a reader who never saw this plan. Never cite the controller's local progress notes. - **No third-party standard content.** Cite only `Example Standard N:YYYY` (`hygiene.no-real-standards`). Fixture values are plainly invented, and a size-like value is not a Renard R40 number (100, 106, 112, … 450, 475, 500, …). Primes such as 103, 127, 139, 163, 197 work. - **Do not run clang-format** on existing files; match the surrounding style by hand. A Doxygen `///` comment goes on every new public entity and member. - **Printing:** new or touched code prints with `std::print` / `std::println`. No new `printf`, `puts` or iostream anywhere. The one exception is `support/fail_without_dialogs.cpp`, whose CRT-failure handler must neither allocate nor throw. - **CHANGELOG.md:** entries go under `## [Unreleased]` (`CHANGELOG.md:7`), in `### Added` / `### Changed` subsections that Task 1 creates, in the task that changes public behaviour. - **Commits:** a conventional subject, a body that says why, and the last line exactly `Signed-off-by: Christian Parpart `. One commit per task. Every commit builds and passes on its own. Never `--no-verify`, never amend another task's commit. -- **Status board:** the controller only (see *Execution*). Implementers report to the controller and never edit `STATUS.md`. +- **Progress notes:** the controller keeps local progress notes (see *Execution*). Implementers report to the controller and never edit them. ## Review Focus @@ -154,12 +154,8 @@ These are the five inputs most likely to bite a user that no task's happy-path t ## Execution -- **The controller** (this session) owns the worktree, dispatch, review gates and the status board. -- **Status board** (`/contour-workflows:status-board`). `STATUS.md` at `D:\formula-cpp` already exists and is excluded (`.git/info/exclude:9`). - - In Task 0, archive its finished plan into `.superpowers/status-archive-2026-09-30.md` (`.superpowers/` is git-ignored, `.gitignore:17`). - - Replace the plan section with this one: goal, decisions, and a `| Phase | Tasks done | State | Where it is |` table with one row per group (Setup 0; Literals and inputs 1–2; Reading and printing 3–5; Traces 6; Rules stated once 7–8; Bound formulas 9; Printing 10; Examples 11–15; Finish 16). - - Then render and publish. There is no remembered URL (`git config --local status-board.url` is unset), so the first publish creates the artifact. Store its URL and give the owner the link once. - - **Update and republish at every state change, in the same turn**: dispatched, reported, reviewed, fixed, landed, blocked. Take the stamp from `date`. +- **The controller** (this session) owns the worktree, dispatch and review gates, and keeps local progress notes. +- **Progress notes.** The controller keeps local progress notes outside the repository, with one row per group of tasks (Setup 0; Literals and inputs 1–2; Reading and printing 3–5; Traces 6; Rules stated once 7–8; Bound formulas 9; Printing 10; Examples 11–15; Finish 16), and updates them at every state change, in the same turn: dispatched, reported, reviewed, fixed, landed, blocked. - **Speed (owner, 2026-09-30: as fast as possible).** - Per-task gates run on Windows compilers only (*Global Constraints*). - A task's review may run while the next task's implementer starts, when the next task touches none of the reviewed task's files. `CHANGELOG.md`, `test/CMakeLists.txt` and the consumer-globals files are shared, so the implementer appends to them after the review's fixes land. A review fix is then its own commit on top, never an amend. @@ -168,14 +164,14 @@ These are the five inputs most likely to bite a user that no task's happy-path t --- -### Task 0: Setup, baseline, board, and the `` probe +### Task 0: Setup, baseline, progress notes, and the `` probe **Files:** - Create: `docs/superpowers/specs/2026-09-30-concise-spellings-design.md` (the *Design* section, verbatim) - Create: `docs/superpowers/plans/2026-09-30-concise-spellings.md` (this file, verbatim) - Modify: `examples/simple.cpp` (probe only) -**Interfaces:** Produces the worktree, the scripts in `$S`, both baselines, and the board URL. +**Interfaces:** Produces the worktree, the scripts in `$S` and both baselines. - [ ] **Step 1: Create the worktree.** Use `superpowers:using-git-worktrees`: `git -C D:\formula-cpp worktree add .claude/worktrees/concise-spellings -b feature/concise-spellings eb5eed8`. - [ ] **Step 2: Copy the scripts.** Copy `cl.ps1`, `gcc14.sh`, `verify.ps1`, `windows-matrix.ps1`, `posix-matrix.sh` and `docs-pages.sh` from `C:\Users\c.parpart\AppData\Local\Temp\claude\D--formula-cpp\6031eb20-da56-4aa2-bb6a-de8c11d53bfc\scratchpad\` into `$S`. @@ -211,7 +207,7 @@ exit 0 Prove it can fail. Run `neg.ps1 -Filter "rational_from_floating_point"` with that case's expected text temporarily wrong in the build tree's generated `negative/.expect.cmake`. Expected: `TESTS FAILED`. Restore the text; expected: `ALL OK`. - [ ] **Step 3: Baseline.** Run `pwsh -NoProfile -File $S\cl.ps1 -Tree $T` (the full cl-debug suite, negatives included) and `neg.ps1 -Filter ".*"` once for clang-cl's negatives. Expected: `ALL OK` from both. Record the cl-debug total. -- [ ] **Step 4: Board.** Archive, rewrite and publish `STATUS.md` as *Execution* says. Give the owner the link. +- [ ] **Step 4: Progress notes.** Start the controller's local progress notes for this plan, as *Execution* says. - [ ] **Step 5: Probe `` on every CI leg.** Rewrite `examples/simple.cpp`'s output line to use `std::println`, and change nothing else: ```cpp @@ -1489,7 +1485,7 @@ This is the first time the non-Windows compilers see the branch; the task's job - [ ] **Step 2a: Whole-branch review.** Dispatch one fresh reviewer, on the most capable model, over `git diff eb5eed8...HEAD`, against this plan's *Design*, *Global Constraints* and *Review Focus*. Run it in parallel with Step 1, since it reads code and builds nothing. Fix what it finds with one commit per finding group, then finish with Step 2's full run. - [ ] **Step 3: Measure.** Totals over `examples/`, before (`eb5eed8`) and after: lines, and the counts of `Rational {`, `Measured<`, `measurement().value()`, `%.*s`, `RecordingSink` and local `rat(`. - [ ] **Step 4: PR.** Update the draft PR's title and body (`/contour-workflows:update-pr`) with what changed, the before/after counts, and the two *Changed* entries (the ADL rule for `describe`, and the compile-time conversion refusal). Mark it ready once every CI job is green (`/contour-workflows:fix-ci` for any that is not). **Do not merge** without the owner. -- [ ] **Step 5: Board.** Mark every row landed or done, state what waits for the owner, and republish. +- [ ] **Step 5: Progress notes.** Mark every row of the progress notes landed or done, and state what waits for the owner. --- From e206c68d32a3ba633bcfb3fffc259629dba6af31 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 23:09:55 +0200 Subject: [PATCH 47/59] fix(examples): print empty lines with std::println(""), as C++23 requires Signed-off-by: Christian Parpart --- examples/opaque_and_retry.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/opaque_and_retry.cpp b/examples/opaque_and_retry.cpp index 4fcfd864..297a1e02 100644 --- a/examples/opaque_and_retry.cpp +++ b/examples/opaque_and_retry.cpp @@ -319,9 +319,9 @@ int main() std::print("operation: {}, outputs:", operation.name); for (std::string_view const output: operation.outputs) std::print(" {}", output); - std::println(); + std::println(""); } - std::println(); + std::println(""); check(page.opaqueOperations.size() == 1, "one operation on the page"); std::println("== 2. Two outputs, two runs ==\n"); @@ -420,7 +420,7 @@ int main() constexpr auto typedIn = formula::environment(formula::entered(formula::Measured { 11.3_r })); auto const entered = formula::explain_retry(fourAttempts, typedIn); printEnding("typed in by a person", entered); - std::println(); + std::println(""); check(entered.outcome.has_value() && entered.outcome->end() == formula::RetryEnd::ManuallyEntered, "a person's entry is never replaced"); @@ -439,7 +439,7 @@ int main() formula::environment(formula::measured_series(41.3_r, 43.9_r, 42.7_r, 45.7_r)); auto const agreed = formula::explain_retry(successive, allFour); printEnding("41.3, 43.9, 42.7, 45.7 g", agreed); - std::println(); + std::println(""); check(agreed.outcome.has_value() && agreed.outcome->end() == formula::RetryEnd::Accepted && agreed.outcome->accepted_at() == std::optional { 2 } && formula::number_of(agreed.outcome) == 42.7_r, From e7a9697be3ca2bc2b38dfc3a5ad4e54215421db5 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 23:11:05 +0200 Subject: [PATCH 48/59] fix(examples): print the arithmetic error when a guarded evaluation fails citations.cpp now says which error stopped the evaluation, and composition.cpp guards the evaluation outcome itself, printing its error, before reading the number from it. Signed-off-by: Christian Parpart --- examples/citations.cpp | 2 +- examples/composition.cpp | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/citations.cpp b/examples/citations.cpp index eb180b2a..7eb9d547 100644 --- a/examples/citations.cpp +++ b/examples/citations.cpp @@ -70,7 +70,7 @@ int main() // teaching material, and the check costs nothing to show. if (!result.has_value()) { - std::println("evaluation failed"); + std::println("{}: {}", formula::symbol_of(), result.error()); return 1; } std::println("{} = {} ({})", formula::symbol_of(), *result, result->source()); diff --git a/examples/composition.cpp b/examples/composition.cpp index 17593b5c..70e868e8 100644 --- a/examples/composition.cpp +++ b/examples/composition.cpp @@ -120,13 +120,13 @@ int main() formula::Measured { 250 }); auto const outcome = formula::checked_evaluate(mixCost, inputs); - auto const cost = formula::number_of(outcome); - check("the composed formula evaluates", cost.has_value()); - if (!cost) + if (!outcome) { - std::println("all checks passed: no"); + std::println("the composed formula failed: {}", outcome.error()); return 1; } + auto const cost = formula::number_of(outcome); + check("the composed formula evaluates", cost.has_value()); // 250 EUR * (180 l / 300 l) = 250 * 3/5 = 150, with no rounding anywhere: // 3/5 is held as 3/5, not as 0.59999999999999998. From 38e9b25b8cc75b324b8a119ce8eef47b129dd5d5 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 23:11:05 +0200 Subject: [PATCH 49/59] refactor(examples): show a trace with trace_of where only the trace is read composition.cpp, rounding_and_conditionals.cpp and display.cpp no longer explain or checked_explain a formula just to take the trace out of it. composition.cpp also stops calling the throwing explain. Signed-off-by: Christian Parpart --- examples/composition.cpp | 3 +-- examples/display.cpp | 9 ++------- examples/rounding_and_conditionals.cpp | 6 ++---- 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/examples/composition.cpp b/examples/composition.cpp index 70e868e8..a8964997 100644 --- a/examples/composition.cpp +++ b/examples/composition.cpp @@ -158,8 +158,7 @@ int main() // water/cement ratio, carrying its own citation, and step 6 consumes it. // An auditor reading the trace sees the sub-result the outer formula was // built on, not just the final number. - auto const explained = formula::explain(mixCost, inputs); - std::print("trace:\n{}", formula::render_trace(explained.trace, { .maxSteps = 20 })); + std::print("trace:\n{}", formula::render_trace(formula::trace_of(mixCost, inputs), { .maxSteps = 20 })); // ---- 5. The asymmetry, stated because it is easy to be surprised by ---- // diff --git a/examples/display.cpp b/examples/display.cpp index b1380eb2..c0d5a8af 100644 --- a/examples/display.cpp +++ b/examples/display.cpp @@ -198,14 +198,9 @@ int main() check(dishPage.rejections.front().limit.contains("1/30"), "nor in a documentation page's limit"); auto const paddedDecimals = NumberStyle::exact_decimal(DecimalPadding::Padded); - auto const tare = formula::checked_explain(wholeTare, specimen); - if (!tare) - { - std::println("the dry mass less the tare: {}", tare.error().error); - return 1; - } std::string const tareFormula = formula::render(wholeTare, { .numbers = paddedDecimals }); - std::string const tareTraceText = formula::render_trace(tare->trace, { .maxSteps = 20, .numbers = paddedDecimals }); + std::string const tareTraceText = formula::render_trace(formula::trace_of(wholeTare, specimen), + { .maxSteps = 20, .numbers = paddedDecimals }); std::println("formula, padded style: {}", tareFormula); std::println("trace, padded style:\n{}", tareTraceText); check(tareFormula == "m_d - 24 g" && tareTraceText.contains("2. 24.0 g\n"), diff --git a/examples/rounding_and_conditionals.cpp b/examples/rounding_and_conditionals.cpp index 7cc8a7a4..9d940205 100644 --- a/examples/rounding_and_conditionals.cpp +++ b/examples/rounding_and_conditionals.cpp @@ -117,8 +117,7 @@ int main() // The trace names which branch a when() took -- here, the "then" branch, // because 25.40 mm is above the 17.3 mm threshold. - auto const explainedLarge = formula::explain(sizeAdjustedDiameter, diameter25_40); - std::print("{}", formula::render_trace(explainedLarge.trace, { .maxSteps = 10 })); + std::print("{}", formula::render_trace(formula::trace_of(sizeAdjustedDiameter, diameter25_40), { .maxSteps = 10 })); // ---- 4. The traced escape hatch ---------------------------------------- std::println("rendered: {}", formula::render(empiricalCorrection)); @@ -127,8 +126,7 @@ int main() static_assert(correction.has_value()); std::println("empirical correction factor at 70 MPa = {}", *correction); - auto const explainedCorrection = formula::explain(empiricalCorrection, strength70); - std::print("{}", formula::render_trace(explainedCorrection.trace, { .maxSteps = 10 })); + std::print("{}", formula::render_trace(formula::trace_of(empiricalCorrection, strength70), { .maxSteps = 10 })); // Every number printed above is checked here; nothing is printed that // this bool does not also cover. From 4c791dc589c0fcd17f51d63a1a4ae58b2b6993ee Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 23:11:53 +0200 Subject: [PATCH 50/59] refactor(examples): write a whole number plainly where an integer converts _r stays where only a Rational is accepted, such as the first argument of MeasuredObservations and the operands that must stay Rational. The guides quote the same lines and follow them. Signed-off-by: Christian Parpart --- docs/calculations.md | 2 +- docs/constraints.md | 4 ++-- docs/methods-and-overlays.md | 2 +- docs/opaque-and-retry.md | 2 +- docs/series.md | 10 +++++----- docs/statistics.md | 2 +- examples/constraints.cpp | 4 ++-- examples/dimensions_and_units.cpp | 12 ++++++------ examples/display.cpp | 6 +++--- examples/electricity_bill.cpp | 2 +- examples/methods_and_overlays.cpp | 2 +- examples/opaque_and_retry.cpp | 6 +++--- examples/series.cpp | 18 +++++++++--------- examples/statistics.cpp | 4 ++-- 14 files changed, 38 insertions(+), 38 deletions(-) diff --git a/docs/calculations.md b/docs/calculations.md index 93faa4a1..ee8e8ef5 100644 --- a/docs/calculations.md +++ b/docs/calculations.md @@ -177,7 +177,7 @@ inline constexpr auto bill = formula::calculation( formula::define(var * var), formula::define(var * var), formula::define(var + var + var), - formula::define(var * 30_r), + formula::define(var * 30), formula::define(var * selfUseShare), formula::define(var - var), formula::define(var - var), diff --git a/docs/constraints.md b/docs/constraints.md index 03054bd0..e3c4f3dc 100644 --- a/docs/constraints.md +++ b/docs/constraints.md @@ -138,7 +138,7 @@ error instead of ever comparing anything: ```cpp constexpr auto dividesByZero = - formula::constraint((var / formula::number(0_r)) > formula::constant(1_r), + formula::constraint((var / formula::number(0)) > formula::constant(1), formula::Verdict { "specimen result is unusable" }); ``` @@ -280,7 +280,7 @@ Alongside `minimumStrength`, a second, independent constraint over a different quantity: ```cpp -constexpr auto maximumDiameter = formula::constraint(var <= formula::constant(139_r), +constexpr auto maximumDiameter = formula::constraint(var <= formula::constant(139), formula::Verdict { "specimen exceeds diameter tolerance" }); ``` diff --git a/docs/methods-and-overlays.md b/docs/methods-and-overlays.md index 88c15b0f..7c26c5b5 100644 --- a/docs/methods-and-overlays.md +++ b/docs/methods-and-overlays.md @@ -47,7 +47,7 @@ inline constexpr formula::DecimalRounding tenthMpa { unit::Megapascal, inline constexpr auto compressiveStrength = formula::method( formula::variants(formula::variant(var / (var * var)), formula::variant(var * var / (var * var)), - formula::variant(formula::constant(4_r) * var + formula::variant(formula::constant(4) * var / (formula::pi * formula::pow<2>(var)))), formula::rounding_rule(), formula::constraints(formula::constraint(var >= formula::constant(47.3_r), diff --git a/docs/opaque-and-retry.md b/docs/opaque-and-retry.md index 1795591a..f65fb6d6 100644 --- a/docs/opaque-and-retry.md +++ b/docs/opaque-and-retry.md @@ -490,7 +490,7 @@ most 0.76 g. The sequence rises, so the acceptance is written constexpr auto halving = formula::constant(6.08_r) + formula::previous_attempt / 2_r; constexpr auto settled = formula::previous_attempt - formula::this_attempt >= formula::constant(-0.76_r); -constexpr auto fromZero = formula::starting_from(formula::constant(0_r)); +constexpr auto fromZero = formula::starting_from(formula::constant(0)); constexpr formula::Verdict repeatDetermination { "repeat the determination" }; constexpr formula::Citation settledCitation { .title = "Settled estimate", .reference = "Example Standard 12", diff --git a/docs/series.md b/docs/series.md index 46da5c60..9bf42a6b 100644 --- a/docs/series.md +++ b/docs/series.md @@ -30,7 +30,7 @@ or on a coarser one: ```cpp inline constexpr auto passing = formula::yields( - formula::constant(100_r) + formula::constant(100) - formula::cumulative(formula::series) / var); ``` @@ -246,11 +246,11 @@ a `Node`: it produces verdicts, not a quantity. ```cpp inline constexpr formula::Envelope<5> gradingEnvelope { - formula::LimitRow { formula::limit(31_r), formula::limit(43_r) }, - formula::LimitRow { formula::limit(47_r), formula::limit(59_r) }, + formula::LimitRow { formula::limit(31), formula::limit(43) }, + formula::LimitRow { formula::limit(47), formula::limit(59) }, formula::LimitRow { formula::limit(62.96_r), formula::unbounded }, - formula::LimitRow { formula::limit(61_r), formula::limit(79_r) }, - formula::LimitRow { formula::limit(83_r), formula::limit(99_r) } + formula::LimitRow { formula::limit(61), formula::limit(79) }, + formula::LimitRow { formula::limit(83), formula::limit(99) } }; ``` diff --git a/docs/statistics.md b/docs/statistics.md index cdd19088..51d203a6 100644 --- a/docs/statistics.md +++ b/docs/statistics.md @@ -269,7 +269,7 @@ limits could not be exceeded by any sample: ```cpp inline constexpr formula::SampleSizeTable<5> declaredSizes { 3, 4, 5, 6, 8 }; inline constexpr auto gapLimit = formula::gap_to_range( - formula::critical_value(formula::pass_count, { 900_r, 700_r, 30_r, 45_r, 5_r }) + formula::critical_value(formula::pass_count, { 900, 700, 30, 45, 5 }) * 0.01_r); ``` diff --git a/examples/constraints.cpp b/examples/constraints.cpp index 2b926048..08749181 100644 --- a/examples/constraints.cpp +++ b/examples/constraints.cpp @@ -43,7 +43,7 @@ constexpr auto minimumStrength = formula::constraint(var >= formula::c .reference = "Example Standard 7:2020", .section = "5.1" }); -constexpr auto maximumDiameter = formula::constraint(var <= formula::constant(139_r), +constexpr auto maximumDiameter = formula::constraint(var <= formula::constant(139), formula::Verdict { "specimen exceeds diameter tolerance" }); // Divides a measured value by zero while checking, so the predicate can @@ -51,7 +51,7 @@ constexpr auto maximumDiameter = formula::constraint(var <= formula::c // while checking, rather than never having its input measured in the first // place. constexpr auto dividesByZero = - formula::constraint((var / formula::number(0_r)) > formula::constant(1_r), + formula::constraint((var / formula::number(0)) > formula::constant(1), formula::Verdict { "specimen result is unusable" }); constexpr auto strength45 = formula::environment(formula::Measured { 45 }); diff --git a/examples/dimensions_and_units.cpp b/examples/dimensions_and_units.cpp index 76d8c2fb..a6f0c49f 100644 --- a/examples/dimensions_and_units.cpp +++ b/examples/dimensions_and_units.cpp @@ -59,7 +59,7 @@ int main() // // `{:/}` writes a Rational as its fraction; `{}` writes the exact decimal // where there is one, and the fraction otherwise. - Rational const volumeInLitres = 450_r; + Rational const volumeInLitres = 450; Rational const volumeInCubicMetres = formula::convert(volumeInLitres, unit::Litre, unit::CubicMetre); Rational const volumeBackInLitres = formula::convert(volumeInCubicMetres, unit::CubicMetre, unit::Litre); @@ -72,7 +72,7 @@ int main() // // Conversion moves a POINT on a scale, not a difference: 100 degC is not // 100 K, it is 100 K above the offset between the two scales. - Rational const tempInCelsius = 100_r; + Rational const tempInCelsius = 100; Rational const tempInKelvin = formula::convert(tempInCelsius, unit::Celsius, unit::Kelvin); Rational const tempBackInCelsius = formula::convert(tempInKelvin, unit::Kelvin, unit::Celsius); @@ -85,9 +85,9 @@ int main() // Celsius meet, so it converts to itself. 100 degF is a number of degrees // Celsius that is a fraction, 340/9, not a terminating decimal, and it is // kept as that fraction: converting divides by 9 and rounds nothing. - Rational const minusFortyInFahrenheit = -40_r; + Rational const minusFortyInFahrenheit = -40; Rational const minusFortyInCelsius = formula::convert(minusFortyInFahrenheit, unit::Fahrenheit, unit::Celsius); - Rational const hundredInFahrenheit = 100_r; + Rational const hundredInFahrenheit = 100; Rational const hundredFahrenheitInCelsius = formula::convert(hundredInFahrenheit, unit::Fahrenheit, unit::Celsius); std::println("{} degF = {} degC", minusFortyInFahrenheit, minusFortyInCelsius); @@ -100,7 +100,7 @@ int main() // A watt-hour is the energy of one watt sustained for an hour, 3600 // joules, and a kilowatt-hour is a thousand of them: the factor is a whole // number, so the conversion needs no rounded constant. - Rational const oneKilowattHour = 1_r; + Rational const oneKilowattHour = 1; Rational const kilowattHourInJoules = formula::convert(oneKilowattHour, unit::KilowattHour, unit::Joule); std::println("{} kWh = {} J", oneKilowattHour, kilowattHourInJoules); @@ -187,7 +187,7 @@ int main() .symbolText = formula::symbol("JPY"), .decimals = 0 }; - Rational const priceInEuros = 250_r; + Rational const priceInEuros = 250; Rational const priceInCents = formula::convert(priceInEuros, Euro, EuroCent); Rational const priceBackInEuros = formula::convert(priceInCents, EuroCent, Euro); std::expected const priceInYen = diff --git a/examples/display.cpp b/examples/display.cpp index c0d5a8af..d9a98f46 100644 --- a/examples/display.cpp +++ b/examples/display.cpp @@ -64,8 +64,8 @@ inline constexpr auto dishMass = formula::sum(formula::series) inline constexpr auto weighings = formula::environment(formula::measured_series(4.21_r, 4.23_r, 4.26_r)); inline constexpr formula::Envelope<2> atMostTwelve { - formula::LimitRow { formula::unbounded, formula::limit(12_r) }, - formula::LimitRow { formula::unbounded, formula::limit(12_r) }, + formula::LimitRow { formula::unbounded, formula::limit(12) }, + formula::LimitRow { formula::unbounded, formula::limit(12) }, }; inline constexpr auto moistureLimit = formula::conformity( formula::series, atMostTwelve, formula::Verdict { "dry the specimen again" }); @@ -75,7 +75,7 @@ inline constexpr auto twoSpecimens = // ---- 3. The formula's text --------------------------------------------------------- // A tare typed as a whole 24 g, to set a formula's text beside a trace's. -inline constexpr auto wholeTare = var - formula::constant(24_r); +inline constexpr auto wholeTare = var - formula::constant(24); // The dish's mean without a weighing further than a typed 1/30 of the pass's // mean from it: a rejection, whose limit a documentation page states. diff --git a/examples/electricity_bill.cpp b/examples/electricity_bill.cpp index 1583de73..db5e36c7 100644 --- a/examples/electricity_bill.cpp +++ b/examples/electricity_bill.cpp @@ -175,7 +175,7 @@ inline constexpr auto bill = formula::calculation( formula::define(var * var), formula::define(var * var), formula::define(var + var + var), - formula::define(var * 30_r), + formula::define(var * 30), formula::define(var * selfUseShare), formula::define(var - var), formula::define(var - var), diff --git a/examples/methods_and_overlays.cpp b/examples/methods_and_overlays.cpp index 2bc1fa49..7ef8a1f1 100644 --- a/examples/methods_and_overlays.cpp +++ b/examples/methods_and_overlays.cpp @@ -105,7 +105,7 @@ inline constexpr formula::DecimalRounding tenthMpa { unit::Megapascal, inline constexpr auto compressiveStrength = formula::method( formula::variants(formula::variant(var / (var * var)), formula::variant(var * var / (var * var)), - formula::variant(formula::constant(4_r) * var + formula::variant(formula::constant(4) * var / (formula::pi * formula::pow<2>(var)))), formula::rounding_rule(), formula::constraints(formula::constraint(var >= formula::constant(47.3_r), diff --git a/examples/opaque_and_retry.cpp b/examples/opaque_and_retry.cpp index 297a1e02..2cd8f40d 100644 --- a/examples/opaque_and_retry.cpp +++ b/examples/opaque_and_retry.cpp @@ -149,7 +149,7 @@ using Tolerance = formula::Quantity(6.08_r) + formula::previous_attempt / 2_r; constexpr auto settled = formula::previous_attempt - formula::this_attempt >= formula::constant(-0.76_r); -constexpr auto fromZero = formula::starting_from(formula::constant(0_r)); +constexpr auto fromZero = formula::starting_from(formula::constant(0)); constexpr formula::Verdict repeatDetermination { "repeat the determination" }; constexpr formula::Citation settledCitation { .title = "Settled estimate", .reference = "Example Standard 12", @@ -395,7 +395,7 @@ int main() "running out is the method's verdict"); constexpr auto withinTolerance = formula::previous_attempt - formula::this_attempt - >= formula::constant(0_r) - var; + >= formula::constant(0) - var; constexpr auto againstTolerance = estimating<4>(fromZero, halving, withinTolerance); constexpr auto noTolerance = formula::environment(formula::Measured::absent()); auto const untold = formula::explain_retry(againstTolerance, noTolerance); @@ -411,7 +411,7 @@ int main() "a determination nobody recorded"); constexpr auto dividing = - formula::previous_attempt / 2_r + formula::constant(1_r) / (formula::attempt_number - 1_r); + formula::previous_attempt / 2 + formula::constant(1) / (formula::attempt_number - 1); constexpr auto failing = estimating<4>(fromZero, dividing, settled); auto const divided = formula::explain_retry(failing, formula::environment()); printEnding("divides by k - 1", divided); diff --git a/examples/series.cpp b/examples/series.cpp index 1414061c..748bb84e 100644 --- a/examples/series.cpp +++ b/examples/series.cpp @@ -65,7 +65,7 @@ inline constexpr formula::BreakpointTable<5> screens { formula::breakpoint(103), // The percentage passing each screen: everything not retained on it or on a // coarser one. `cumulative` runs from the coarsest screen down. inline constexpr auto passing = formula::yields( - formula::constant(100_r) + formula::constant(100) - formula::cumulative(formula::series) / var); // 130, 210, 95, 340 and 28 g retained of 1250 g. @@ -79,7 +79,7 @@ inline constexpr auto retainedInAll = formula::yields(formula::sum(for // point between two of them. inline constexpr auto grading = formula::curve(formula::domain, passing.expression); inline constexpr auto passingAt173 = - formula::yields(formula::interpolate_at(grading, formula::constant(173_r))); + formula::yields(formula::interpolate_at(grading, formula::constant(173))); // ---- 3. Absence: the operations of the table -------------------------------- // @@ -98,11 +98,11 @@ inline constexpr auto roundedMasses = formula::rounded_elementwise(fo // specification -- master data, registered per customer -- and never part of // a formula. inline constexpr formula::Envelope<5> gradingEnvelope { - formula::LimitRow { formula::limit(31_r), formula::limit(43_r) }, - formula::LimitRow { formula::limit(47_r), formula::limit(59_r) }, + formula::LimitRow { formula::limit(31), formula::limit(43) }, + formula::LimitRow { formula::limit(47), formula::limit(59) }, formula::LimitRow { formula::limit(62.96_r), formula::unbounded }, - formula::LimitRow { formula::limit(61_r), formula::limit(79_r) }, - formula::LimitRow { formula::limit(83_r), formula::limit(99_r) } + formula::LimitRow { formula::limit(61), formula::limit(79) }, + formula::LimitRow { formula::limit(83), formula::limit(99) } }; inline constexpr auto gradingCheck = formula::conformity(passing.expression, gradingEnvelope, @@ -114,7 +114,7 @@ inline constexpr auto gradingCheck = formula::conformity(passing. // round, and snapped to the nearest declared screen. inline constexpr auto halfPassing = formula::snapped(formula::interpolate_at( - formula::curve(passing.expression, formula::domain), formula::constant(50_r))); + formula::curve(passing.expression, formula::domain), formula::constant(50))); // A coarse analysis and a fine one, at invented openings of their own. inline constexpr formula::BreakpointTable<3> coarseScreens { formula::breakpoint(103), @@ -317,7 +317,7 @@ int main() std::println("{}", snapTrace); check(snapTrace.contains("[127 m to 163 m; nearer 127 m]"), "4733/35 m snaps to 127 m, the nearer"); - auto const midway = formula::constant(145_r); + auto const midway = formula::constant(145); std::string const towardLower = last_line(formula::render_trace( formula::trace_of(formula::snapped(midway), analysis), { .maxSteps = 80 })); @@ -326,7 +326,7 @@ int main() { .maxSteps = 80 })); std::string const beyond = last_line(formula::render_trace( formula::trace_of( - formula::snapped(formula::constant(251_r)), + formula::snapped(formula::constant(251)), analysis), { .maxSteps = 80 })); std::println("{}\n{}\n{}\n", towardLower, towardHigher, beyond); diff --git a/examples/statistics.cpp b/examples/statistics.cpp index b10cc853..eb26de34 100644 --- a/examples/statistics.cpp +++ b/examples/statistics.cpp @@ -129,7 +129,7 @@ inline constexpr auto sevenQuarters = formula::deviation_in_stddevs(formula::num // clang-format off inline constexpr formula::SampleSizeTable<5> declaredSizes { 3, 4, 5, 6, 8 }; inline constexpr auto gapLimit = formula::gap_to_range( - formula::critical_value(formula::pass_count, { 900_r, 700_r, 30_r, 45_r, 5_r }) + formula::critical_value(formula::pass_count, { 900, 700, 30, 45, 5 }) * 0.01_r); // clang-format on @@ -148,7 +148,7 @@ struct Tag inline constexpr auto twoResults = formula::environment(formula::Measured { 40 }, formula::Measured { 40.905_r }); -inline constexpr auto pairMean = (var + var) / 2_r; +inline constexpr auto pairMean = (var + var) / 2; /// The repeatability limit at a level: r = 0.1 g + level / 50. The level is a /// placeholder; the precision limit binds it. From 31a8aea80e4e873c3ba41156e213483ef4bb30cc Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 23:15:02 +0200 Subject: [PATCH 51/59] refactor(examples): print an Outcome itself instead of its measurement An Outcome formats as the Measured it holds, with the same specs, so the .measurement() call in front of each print only spelled the same text longer. The output is unchanged, and docs/calculations.md quotes the same lines. Signed-off-by: Christian Parpart --- docs/calculations.md | 4 ++-- examples/electricity_bill.cpp | 12 ++++++------ examples/opaque_and_retry.cpp | 14 +++++++------- examples/statistics.cpp | 18 +++++++++--------- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/docs/calculations.md b/docs/calculations.md index ee8e8ef5..be164d03 100644 --- a/docs/calculations.md +++ b/docs/calculations.md @@ -412,8 +412,8 @@ its unit: ```cpp std::println("{:<26} total {:.2HalfAwayFromZero}, net draw {}, recomputed {}, reused {}", step, - total.measurement(), - netDraw.measurement(), + total, + netDraw, counted.recomputed, counted.reused); ``` diff --git a/examples/electricity_bill.cpp b/examples/electricity_bill.cpp index db5e36c7..12205c3b 100644 --- a/examples/electricity_bill.cpp +++ b/examples/electricity_bill.cpp @@ -308,8 +308,8 @@ int main() // The total is in whole cents already; .2 pads it to them: 98.00 EUR. std::println("{:<26} total {:.2HalfAwayFromZero}, net draw {}, recomputed {}, reused {}", step, - total.measurement(), - netDraw.measurement(), + total, + netDraw, counted.recomputed, counted.reused); return std::pair { formula::number_of(total), counted }; @@ -369,8 +369,8 @@ int main() auto const [sunnierTotal, sunnierDraw] = sunnier.calculate(var, var); std::println("\n{:<26} total {:.2HalfAwayFromZero}, net draw {}, recomputed {}", "with 200 kWh of sun:", - sunnierTotal.measurement(), - sunnierDraw.measurement(), + sunnierTotal, + sunnierDraw, sunnier.recomputed() - copied.recomputed); check("85.14 EUR and 239 kWh on the copy, nine recalculated", formula::number_of(sunnierTotal) == 85.14_r && formula::number_of(sunnierDraw) == 239_r @@ -418,7 +418,7 @@ int main() return 1; } auto shares = formula::worksheet(sharing, formula::environment(*sharedCost, formula::Measured { 3 })); - auto const eachInCents = shares.calculate().measurement(); + auto const eachInCents = shares.calculate(); std::println("\n{:.2HalfAwayFromZero} shared by 3: {} each", *sharedCost, eachInCents); check("32.67 EUR each", formula::number_of(eachInCents) == 32.67_r); @@ -479,7 +479,7 @@ int main() // is every share -- not zero, and not a failure. shares.set(formula::Measured::absent()); auto const [uncountedShare, uncountedInCents] = shares.calculate(); - std::println("\noccupants not counted: share {}, in cents {}", uncountedShare.measurement(), uncountedInCents.measurement()); + std::println("\noccupants not counted: share {}, in cents {}", uncountedShare, uncountedInCents); check("an absent input leaves what reads it empty", uncountedShare.is_empty() && uncountedInCents.is_empty()); std::println("\nall checks passed: {}", ok ? "yes" : "no"); diff --git a/examples/opaque_and_retry.cpp b/examples/opaque_and_retry.cpp index 2cd8f40d..f5a2826f 100644 --- a/examples/opaque_and_retry.cpp +++ b/examples/opaque_and_retry.cpp @@ -366,7 +366,7 @@ int main() check(formula::number_of(roundedWide) == 116.232_r, "rounded where used, fifteen distinct denominators answer: 1.9372 mm/s"); if (roundedWide.has_value()) - std::println("fifteen distinct denominators, rounded where used: {}\n", roundedWide->measurement()); + std::println("fifteen distinct denominators, rounded where used: {}\n", *roundedWide); std::println("== 4. A citation is required ==\n"); @@ -489,9 +489,9 @@ int main() check(slopeOfFifty.has_value() && startOfFifty.has_value() && qualityOfFifty.has_value(), "fifty readings, rounded"); if (slopeOfFifty.has_value() && startOfFifty.has_value() && qualityOfFifty.has_value()) std::println("fifty readings at 4 decimals, rounded: slope {}, intercept {}, r squared {}\n", - slopeOfFifty->measurement(), - startOfFifty->measurement(), - qualityOfFifty->measurement()); + *slopeOfFifty, + *startOfFifty, + *qualityOfFifty); check(formula::number_of(slopeOfFifty) == 3.1707_r, "3.1707 mm/s"); std::println("== 8. Several regressors ==\n"); @@ -513,9 +513,9 @@ int main() check(perKelvin.has_value() && perPercent.has_value() && atZero.has_value(), "two regressors, rounded"); if (perKelvin.has_value() && perPercent.has_value() && atZero.has_value()) std::println("coefficient 1: {}, coefficient 2: {}, length at 0 degrees Celsius: {}", - perKelvin->measurement(), - perPercent->measurement(), - atZero->measurement()); + *perKelvin, + *perPercent, + *atZero); check(formula::number_of(perPercent) == 0.5557_r, "0.5557 mm per percent"); constexpr auto collinear = formula::multiple_least_squares( diff --git a/examples/statistics.cpp b/examples/statistics.cpp index eb26de34..42d90156 100644 --- a/examples/statistics.cpp +++ b/examples/statistics.cpp @@ -203,11 +203,11 @@ int main() std::println("the spread of six masses: {}", spreadValue.error().error); return 1; } - std::println("{} = {:/}", formula::render(mean), meanValue->measurement()); - std::println("{} = {:/}", formula::render(count), countValue->measurement()); - std::println("{} = {:/}", formula::render(variance), varianceValue->measurement()); - std::println("{} = {:/}", formula::render(range), rangeValue->measurement()); - std::println("{} = {:/}", formula::render(spread), spreadValue->outcome.measurement()); + std::println("{} = {:/}", formula::render(mean), *meanValue); + std::println("{} = {:/}", formula::render(count), *countValue); + std::println("{} = {:/}", formula::render(variance), *varianceValue); + std::println("{} = {:/}", formula::render(range), *rangeValue); + std::println("{} = {:/}", formula::render(spread), spreadValue->outcome); std::println("LaTeX: {}\n", formula::render(spread)); check(formula::number_of(meanValue) == 41.3_r, "the mean is 41.3 g"); check(formula::number_of(varianceValue) == 3.416_r, "the variance divides by n - 1: 427/125 g2"); @@ -227,8 +227,8 @@ int main() formula::checked_evaluate(formula::sample_count(formula::observations), observed); static_assert(observedCount.has_value()); std::println("observations of 8 at most, 6 made: mean {:/}, count {:/}", - observedMean->measurement(), - observedCount->measurement()); + *observedMean, + *observedCount); check(formula::number_of(observedCount) == 6_r, "the count is the six made, not the capacity"); // Nine for eight places: refused, never truncated to fit. @@ -267,7 +267,7 @@ int main() return 1; } std::println("result: {:/}, {} rejected in {} passes\n", - settled->outcome().measurement(), + settled->outcome(), settled->rejected().size(), settled->passes()); check(formula::number_of(settled) == 40.125_r, "the mean of the four kept, 321/8 g"); @@ -299,7 +299,7 @@ int main() tied->rejected()[0].position + 1, tied->rejected()[1].position + 1, tied->rejected()[0].pass, - tied->outcome().measurement()); + tied->outcome()); auto const threeMade = formula::environment(formula::MeasuredObservations(40_r, 40_r, 41_r)); auto const tooFew = formula::explain_rejection(observedWithoutOutliers, threeMade); From 3ab8370eab95e74215ca7d17e6e84ff8f7b5d5fb Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 23:15:02 +0200 Subject: [PATCH 52/59] refactor(examples): guard each method result once, printing the error or the absence The four results in methods_and_overlays.cpp were each checked twice, for an error and then for no value. One guard on number_of now prints the error's words when there is an error and "no value" otherwise. Signed-off-by: Christian Parpart --- examples/methods_and_overlays.cpp | 29 +++++------------------------ 1 file changed, 5 insertions(+), 24 deletions(-) diff --git a/examples/methods_and_overlays.cpp b/examples/methods_and_overlays.cpp index 7ef8a1f1..6ef1050c 100644 --- a/examples/methods_and_overlays.cpp +++ b/examples/methods_and_overlays.cpp @@ -273,15 +273,10 @@ int main() std::println("== 1. A method selects a variant by tag ==\n"); auto const cube = formula::explain_method(compressiveStrength, specimen); - if (!cube.outcome) - { - std::println("cube: {}", cube.outcome.error()); - return 1; - } auto const cubeStrength = formula::number_of(cube.outcome); if (!cubeStrength) { - std::println("cube: no value"); + std::println("cube: {}", cube.outcome ? "no value" : formula::describe(cube.outcome.error())); return 1; } std::println("cube: {} Pa", *cubeStrength); @@ -298,30 +293,20 @@ int main() std::println("== 2. A jurisdiction's overlay yields a method ==\n"); auto const northCube = formula::explain_method(northern, specimen); - if (!northCube.outcome) - { - std::println("north cube: {}", northCube.outcome.error()); - return 1; - } auto const northStrength = formula::number_of(northCube.outcome); if (!northStrength) { - std::println("north cube: no value"); + std::println("north cube: {}", northCube.outcome ? "no value" : formula::describe(northCube.outcome.error())); return 1; } std::println("north cube: {} Pa\n\n{}", *northStrength, formula::render_trace(northCube.trace, { .maxSteps = 30 })); check(northStrength == 4590000_r, "the north's fixed 0.863, rounded to 4.59 N/mm2 by its own rule"); auto const southCube = formula::explain_method(southern, specimen); - if (!southCube.outcome) - { - std::println("south cube: {}", southCube.outcome.error()); - return 1; - } auto const southStrength = formula::number_of(southCube.outcome); if (!southStrength) { - std::println("south cube: no value"); + std::println("south cube: {}", southCube.outcome ? "no value" : formula::describe(southCube.outcome.error())); return 1; } std::println("south cube: {} Pa\n\n{}", *southStrength, formula::render_trace(southCube.trace, { .maxSteps = 30 })); @@ -361,15 +346,11 @@ int main() for (Jurisdiction const jurisdiction: { Jurisdiction::Base, Jurisdiction::North, Jurisdiction::South }) { auto const chosen = cubeStrengthIn(jurisdiction); - if (!chosen) - { - std::println("jurisdiction {}: {}", std::to_underlying(jurisdiction), chosen.error()); - return 1; - } auto const chosenStrength = formula::number_of(chosen); if (!chosenStrength) { - std::println("jurisdiction {}: no value", std::to_underlying(jurisdiction)); + std::println("jurisdiction {}: {}", std::to_underlying(jurisdiction), + chosen ? "no value" : formula::describe(chosen.error())); return 1; } std::println("jurisdiction {}: {} Pa", std::to_underlying(jurisdiction), *chosenStrength); From 66aaf643ed13e04bae4b7d1913cba8b865665eb3 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 23:15:02 +0200 Subject: [PATCH 53/59] refactor(examples): check constant results with static_assert instead of a runtime guard records.cpp, quantities.cpp, statistics.cpp, dimensions_and_units.cpp and series.cpp evaluate, convert or bounds-check constants, so the std::expected is constexpr and static_assert(r.has_value()) checks it: an error stops the build. docs/quantities.md quotes the changed lines and follows them, and the census page's operation counts for the quantities example follow its source. Signed-off-by: Christian Parpart --- docs/numeric-headroom.md | 2 +- docs/quantities.md | 14 +++++------ examples/dimensions_and_units.cpp | 15 +++-------- examples/quantities.cpp | 41 +++++++++---------------------- examples/records.cpp | 40 +++++++----------------------- examples/series.cpp | 8 ++---- examples/statistics.cpp | 22 +++-------------- 7 files changed, 36 insertions(+), 106 deletions(-) diff --git a/docs/numeric-headroom.md b/docs/numeric-headroom.md index 5fe35013..ea602aa2 100644 --- a/docs/numeric-headroom.md +++ b/docs/numeric-headroom.md @@ -147,7 +147,7 @@ Each program's largest integers over everything it evaluates at run time. | example `simple` | 4 | 10 | 6 | 53 | | example `exact_numbers` | 9 | 10 | 9 | 53 | | example `dimensions_and_units` | 22 | 10 | 22 | 41 | -| example `quantities` | 4 | 10 | 5 | 53 | +| example `quantities` | 0 | 0 | 0 | 63 | | example `expressions` | 0 | 0 | 0 | 63 | | example `citations` | 4 | 10 | 6 | 53 | | example `composition` | 10 | 10 | 9 | 53 | diff --git a/docs/quantities.md b/docs/quantities.md index 3db50512..1ccb9d23 100644 --- a/docs/quantities.md +++ b/docs/quantities.md @@ -281,16 +281,14 @@ does not compile, and it draws one message, so a conversion nobody could perform cannot look like it succeeded merely because there was no value to get wrong. (Before this check moved to compile time, such a call compiled and returned `ArithmeticError::DomainError`.) With no value present the -result is absent. The worked example checks the `std::expected` before it -reads the measurement inside: +result is absent. The worked example converts a constant, so it checks the +`std::expected` with a `static_assert`, and an error would stop the build: ```cpp -auto const convertedAbsent = formula::checked_convert_to(absentVolume); -if (!convertedAbsent) -{ - std::println("converting an absent measurement: {}", convertedAbsent.error()); - return 1; -} +constexpr auto convertedAbsent = formula::checked_convert_to(absentVolume); +constexpr auto roundedAbsent = formula::checked_round_to_declared(absentVolume, RoundingMode::HalfAwayFromZero); +constexpr auto boundsOfAbsent = formula::checked_within_bounds(absentVolume); +static_assert(convertedAbsent.has_value() && roundedAbsent.has_value() && boundsOfAbsent.has_value()); ``` ``` diff --git a/examples/dimensions_and_units.cpp b/examples/dimensions_and_units.cpp index a6f0c49f..53430847 100644 --- a/examples/dimensions_and_units.cpp +++ b/examples/dimensions_and_units.cpp @@ -142,18 +142,9 @@ int main() .decimals = 1, .bounds = formula::bounds(0, 1, 100, 1) }; - auto const unboundedVerdict = formula::checked_within_bounds(1'000'000_r, unit::Litre); - if (!unboundedVerdict) - { - std::println("bounds-checking a litre: {}", unboundedVerdict.error()); - return 1; - } - auto const boundedVerdict = formula::checked_within_bounds(42_r, BoundedGauge); - if (!boundedVerdict) - { - std::println("bounds-checking the gauge: {}", boundedVerdict.error()); - return 1; - } + constexpr auto unboundedVerdict = formula::checked_within_bounds(1'000'000_r, unit::Litre); + constexpr auto boundedVerdict = formula::checked_within_bounds(42_r, BoundedGauge); + static_assert(unboundedVerdict.has_value() && boundedVerdict.has_value()); // `{}` of a BoundsCheck writes its describe() words. std::println("unbounded unit (litre) reports: {}", *unboundedVerdict); diff --git a/examples/quantities.cpp b/examples/quantities.cpp index d946d3a4..8eae0f56 100644 --- a/examples/quantities.cpp +++ b/examples/quantities.cpp @@ -111,40 +111,21 @@ int main() // ---- 4. A present measurement, converted exactly between quantities (450 l to m3) ---- // // A conversion, a rounding and a bounds check each return a std::expected - // -- the value, or the arithmetic error that stopped it -- and each is - // checked before it is read. Nothing in this program can make one fail, - // but dereferencing a std::expected that holds an error is undefined - // behaviour. - Measured const presentVolume { 450 }; - auto const convertedPresent = formula::checked_convert_to(presentVolume); - if (!convertedPresent) - { - std::println("converting {}: {}", presentVolume, convertedPresent.error()); - return 1; - } + // -- the value, or the arithmetic error that stopped it. These run over + // constants, so a static_assert checks each and an error would stop the + // build. + constexpr Measured presentVolume { 450 }; + constexpr auto convertedPresent = formula::checked_convert_to(presentVolume); + static_assert(convertedPresent.has_value()); std::println("{} converted to {} = {}", presentVolume, Describe::unit, *convertedPresent); bool const presentValueConvertsExactly = formula::number_of(convertedPresent) == 0.45_r; // ---- 5. An absent measurement surviving conversion, rounding and a bounds check ---- - Measured const absentVolume {}; - auto const convertedAbsent = formula::checked_convert_to(absentVolume); - if (!convertedAbsent) - { - std::println("converting an absent measurement: {}", convertedAbsent.error()); - return 1; - } - auto const roundedAbsent = formula::checked_round_to_declared(absentVolume, RoundingMode::HalfAwayFromZero); - if (!roundedAbsent) - { - std::println("rounding an absent measurement: {}", roundedAbsent.error()); - return 1; - } - auto const boundsOfAbsent = formula::checked_within_bounds(absentVolume); - if (!boundsOfAbsent) - { - std::println("bounds-checking an absent measurement: {}", boundsOfAbsent.error()); - return 1; - } + constexpr Measured absentVolume {}; + constexpr auto convertedAbsent = formula::checked_convert_to(absentVolume); + constexpr auto roundedAbsent = formula::checked_round_to_declared(absentVolume, RoundingMode::HalfAwayFromZero); + constexpr auto boundsOfAbsent = formula::checked_within_bounds(absentVolume); + static_assert(convertedAbsent.has_value() && roundedAbsent.has_value() && boundsOfAbsent.has_value()); std::println("an absent measurement, converted: {}", *convertedAbsent); std::println("an absent measurement, rounded: {}", *roundedAbsent); diff --git a/examples/records.cpp b/examples/records.cpp index 024debb3..f93bc0b3 100644 --- a/examples/records.cpp +++ b/examples/records.cpp @@ -163,43 +163,21 @@ int main() // The context is this record's environment: anything that takes one takes // the context, and reads this record's values from it. - auto const viaContext = formula::checked_evaluate_si(var, records); - if (!viaContext) - { - std::println("f_c through the context: {}", viaContext.error()); - return 1; - } - auto const viaEnvironment = formula::checked_evaluate_si(var, here); - if (!viaEnvironment) - { - std::println("f_c through this record's environment: {}", viaEnvironment.error()); - return 1; - } - auto const strengthViaContext = formula::number_of(viaContext); - auto const strengthViaEnvironment = formula::number_of(viaEnvironment); - if (!strengthViaContext || !strengthViaEnvironment) - { - std::println("f_c: no answer"); - return 1; - } + constexpr auto viaContext = formula::checked_evaluate_si(var, records); + constexpr auto viaEnvironment = formula::checked_evaluate_si(var, here); + constexpr auto strengthViaContext = formula::number_of(viaContext); + constexpr auto strengthViaEnvironment = formula::number_of(viaEnvironment); + static_assert(viaContext.has_value() && viaEnvironment.has_value()); + static_assert(strengthViaContext.has_value() && strengthViaEnvironment.has_value()); std::println("f_c through the context: {} Pa", *strengthViaContext); std::println("f_c through this record's environment: {} Pa\n", *strengthViaEnvironment); check(strengthViaContext == strengthViaEnvironment, "the context reads this record's own values"); std::println("== 2. Reading a value, or computing over another specimen ==\n"); - auto const referenceRead = formula::checked_evaluate_si(referenceStrength, records); - if (!referenceRead) - { - std::println("the reference's strength from its load and edges: {}", referenceRead.error()); - return 1; - } - auto const referenceValue = formula::number_of(referenceRead); - if (!referenceValue) - { - std::println("the reference's strength from its load and edges: no answer"); - return 1; - } + constexpr auto referenceRead = formula::checked_evaluate_si(referenceStrength, records); + constexpr auto referenceValue = formula::number_of(referenceRead); + static_assert(referenceRead.has_value() && referenceValue.has_value()); std::println("{} = {} Pa", formula::render(referenceStrength), *referenceValue); std::println("{}\n", formula::render(referenceStrength)); check(formula::render(referenceStrength) == "(F / (x_m * y_m)) of Reference", diff --git a/examples/series.cpp b/examples/series.cpp index 748bb84e..d1836783 100644 --- a/examples/series.cpp +++ b/examples/series.cpp @@ -171,12 +171,8 @@ int main() std::println("{}\n", formula::render(passingAt173)); check(formula::render(passing) == "100 % - cumulative(m_r(i), from last) / m_t", "the series marked, the total unmarked"); - auto const inAll = formula::checked_evaluate(retainedInAll, analysis); - if (!inAll) - { - std::println("sum of the retained masses: {}", inAll.error()); - return 1; - } + constexpr auto inAll = formula::checked_evaluate(retainedInAll, analysis); + static_assert(inAll.has_value()); check(formula::number_of(inAll) == 803_r, "sum: 803 g retained in all"); auto const at173 = formula::checked_explain(passingAt173, analysis); if (!at173) diff --git a/examples/statistics.cpp b/examples/statistics.cpp index 42d90156..e230ef20 100644 --- a/examples/statistics.cpp +++ b/examples/statistics.cpp @@ -260,12 +260,10 @@ int main() std::println("== 2. Rejecting outliers ==\n"); std::println("{}\n", formula::render(withoutOutliers)); - auto const settled = formula::checked_evaluate_rejection(withoutOutliers, sixMasses); - if (!settled) - { - std::println("the rejection of six masses: {}", settled.error().error); - return 1; - } + constexpr auto settled = formula::checked_evaluate_rejection(withoutOutliers, sixMasses); + constexpr auto aborted = formula::checked_evaluate_rejection(atMostOne, sixMasses); + constexpr auto tied = formula::checked_evaluate_rejection(tieRejection, tiedMasses); + static_assert(settled.has_value() && aborted.has_value() && tied.has_value()); std::println("result: {:/}, {} rejected in {} passes\n", settled->outcome(), settled->rejected().size(), @@ -279,20 +277,8 @@ int main() std::println("{}", formula::render_trace(formula::trace_of(formula::sample_mean(atMostOne), sixMasses), { .maxSteps = 40 })); - auto const aborted = formula::checked_evaluate_rejection(atMostOne, sixMasses); - if (!aborted) - { - std::println("the rejection allowed one: {}", aborted.error().error); - return 1; - } check(aborted->outcome().is_verdict(), "one rejection too many is the author's verdict"); - auto const tied = formula::checked_evaluate_rejection(tieRejection, tiedMasses); - if (!tied) - { - std::println("the rejection of a tie: {}", tied.error().error); - return 1; - } check(tied->rejected().size() == 2 && tied->rejected()[0].pass == tied->rejected()[1].pass, "a tie rejects both, in the same pass"); std::println("a tie: elements {} and {} rejected together in pass {}, result {:/}\n", From b4cabef6e694184e72fd575e092bd259bc3f5433 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 23:17:27 +0200 Subject: [PATCH 54/59] docs(changelog): state exactly when a consumer's helper meets the library's The entry said any non-template of the same name made the call ambiguous. That holds beside a non-template such as describe; beside one of the library's templates, a non-template taking exactly its parameter types is preferred and keeps working. The list of names now says it gives some of them, adds yields and declared_rounding, and says the example's describe was removed rather than renamed. Signed-off-by: Christian Parpart --- CHANGELOG.md | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e4e8b5e..6a3b38d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,21 +92,24 @@ change is recorded here. time; it no longer compiles, and the message names the two quantities. A conversion between quantities of one dimension is unchanged. - An unqualified call with arguments of this library's types now also finds, by argument-dependent - lookup, the functions this release adds: `describe` of a `ConstraintOutcomeKind`, a `RetryEnd`, a - `ValueSource`, an `OutcomeKind` or a `FailureSite`; `number_of`, `convert_to`, `round_to_declared` - and `within_bounds`; `traced`, `trace_of` and `trace_of_si`; and the `explain_*` twins above. A - consumer's own function of one of these names, visible where the call is written, meets the - library's in one of two ways. A function template of the same name and shape -- a + lookup, the functions this release adds, among them: `describe` of a `ConstraintOutcomeKind`, a + `RetryEnd`, a `ValueSource`, an `OutcomeKind` or a `FailureSite`; `number_of`, `convert_to`, + `round_to_declared` and `within_bounds`; `traced`, `trace_of` and `trace_of_si`; the `explain_*` + twins above; `yields`, given an expression; and `declared_rounding`, given a `Unit`. A consumer's + own function of one of these names, visible where the call is written, meets the library's in one + of three ways. A function template of the same name and shape -- a `template Measured convert_to(Measured)` helper, say -- is displaced **silently**: the library's is more constrained, so it is chosen, the helper no longer runs, and where the helper returned an absent value on failure the library's `convert_to` throws - `ArithmeticException`. A non-template function, or one taking other parameter types -- a - `describe(ConstraintOutcomeKind)` helper, or a - `template Rational number_of(Measured)` that takes its argument by value -- makes - the call ambiguous. Either way, rename the helper, as `examples/constraints.cpp`'s `describe` was, - or call it by a qualified name such as `::convert_to`. And since `_r` is declared in an inline - namespace of `formula`, `using namespace formula;` now brings it into scope, where a consumer's - own `_r` is ambiguous with it. + `ArithmeticException`. A function of another shape -- a non-template beside the library's + non-template `describe`, such as a `describe(ConstraintOutcomeKind)` helper, or a template taking + its argument by value, such as `template Rational number_of(Measured)` -- makes + the call ambiguous. A non-template taking exactly a library template's parameter types is + preferred over it and keeps working. Where a helper is displaced or a call is ambiguous, rename + the helper, remove it where the library's does the same (as `examples/constraints.cpp`'s + `describe` was removed), or call it by a qualified name such as `::convert_to`. And since `_r` is + declared in an inline namespace of `formula`, `using namespace formula;` now brings it into scope, + where a consumer's own `_r` is ambiguous with it. ## [0.2.0] - 2026-09-30 From 8bf9ae47476b73516e284b7e8b77ff8d7bffa0f1 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 23:17:54 +0200 Subject: [PATCH 55/59] docs(changelog): record the reworded with_rounding refusal The refusal of a rounding rule with no citation shipped in 0.2.0 opening "formula: with_rounding()", and a consumer's own negative test may match that opening. It now names with_rounding<...>() for either spelling, which is a change to tested text. Signed-off-by: Christian Parpart --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a3b38d0..019fb9de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -110,6 +110,9 @@ change is recorded here. `describe` was removed), or call it by a qualified name such as `::convert_to`. And since `_r` is declared in an inline namespace of `formula`, `using namespace formula;` now brings it into scope, where a consumer's own `_r` is ambiguous with it. +- The refusal of `with_rounding` with no citation now opens + `formula: with_rounding<...>() was given no citation`, for either spelling, the three arguments or + a `DecimalRounding`; it opened `formula: with_rounding() was given no citation`. ## [0.2.0] - 2026-09-30 From f2a80c40db2980af95e68fd644eae11f00ef248d Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 23:21:25 +0200 Subject: [PATCH 56/59] fix: render and document a refused operand without a second error A bound formula or a retry used as an operand is refused once, and the refusal gives a stand-in so that nothing over it asks again. render and document had no case for either stand-in, so each added a compiler's "no matching function" after the refusal. Each now renders as "(refused)" and documents nothing, as the other refused stand-ins do, and both operand negatives render and document the refused value and expect one error. Signed-off-by: Christian Parpart --- include/formula-cpp/document.hpp | 21 +++++++++++++++++++++ include/formula-cpp/render.hpp | 19 +++++++++++++++++++ test/CMakeLists.txt | 2 +- test/negative/retry_in_arithmetic.cpp | 15 ++++++++++----- test/negative/yields_as_operand.cpp | 15 +++++++++++---- 5 files changed, 62 insertions(+), 10 deletions(-) diff --git a/include/formula-cpp/document.hpp b/include/formula-cpp/document.hpp index c6fbfe94..69c5ced9 100644 --- a/include/formula-cpp/document.hpp +++ b/include/formula-cpp/document.hpp @@ -546,6 +546,12 @@ namespace detail template void collect(Walk& walk, RefusedSeriesScope const& node); + template + void collect(Walk& walk, RefusedRetryValue const& node); + + template + void collect(Walk& walk, RefusedBoundValue const& node); + template void collect(Walk& walk, OpaqueOutputNode, Origin> const& node); @@ -1217,6 +1223,21 @@ namespace detail { } + /// A refused retry in arithmetic or a comparison names nothing, as a + /// refused series names nothing: it only keeps `document` from adding a + /// second error to the refusal that produced it. + template + void collect(Walk&, RefusedRetryValue const&) + { + } + + /// A refused bound formula used as an operand names nothing, as a refused + /// retry names nothing. + template + void collect(Walk&, RefusedBoundValue const&) + { + } + /// An opaque output lists its call -- once per call, however many of its /// outputs are used: one call is one call type with one citation -- and /// walks the call's inputs, which name its variables. diff --git a/include/formula-cpp/render.hpp b/include/formula-cpp/render.hpp index ff188e82..112c5ebc 100644 --- a/include/formula-cpp/render.hpp +++ b/include/formula-cpp/render.hpp @@ -2123,6 +2123,25 @@ template return "(refused)"; } +/// A refused retry in arithmetic or a comparison (`detail::RefusedRetryValue`, +/// `retry.hpp`) renders as a refused series does, as nothing a reader could +/// take for a formula. A program holding one never compiles; this only keeps +/// a `render` of it from adding a second, compiler-worded error. +template +[[nodiscard]] std::string render_node(detail::RefusedRetryValue const&, V const&) +{ + return "(refused)"; +} + +/// A refused bound formula used as an operand (`detail::RefusedBoundValue`, +/// `yields.hpp`) renders as a refused retry does; this only keeps a `render` +/// of it from adding a second, compiler-worded error. +template +[[nodiscard]] std::string render_node(detail::RefusedBoundValue const&, V const&) +{ + return "(refused)"; +} + namespace detail { /// Renders @p node through the `render_node` it has, and refuses a node of diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 29a3359d..d68aec81 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -2640,7 +2640,7 @@ formula_add_negative_test(retry_as_variant "formula: a retry is evaluated at the top, by checked_evaluate_retry; it cannot stand in a formula" REJECT "no matching" "RequireVariantsAgree" "RequireRoundingRuleMeasuresVariants") formula_add_negative_test(retry_in_arithmetic - "formula: a retry is evaluated at the top, by checked_evaluate_retry; it cannot stand in a formula" + "formula: a retry is evaluated at the top, by checked_evaluate_retry; it cannot stand in a formula" EXPECT_COUNT 1 REJECT "no matching" "invalid operands" "no match for" "RequireResultDimension") formula_add_negative_test(retry_render_other_quantity "formula: previous_attempt and this_attempt name the retry's own result quantity; this one names another" diff --git a/test/negative/retry_in_arithmetic.cpp b/test/negative/retry_in_arithmetic.cpp index 9077aa09..d57f55ca 100644 --- a/test/negative/retry_in_arithmetic.cpp +++ b/test/negative/retry_in_arithmetic.cpp @@ -5,9 +5,12 @@ // REJECT: no match for // REJECT: RequireResultDimension // -// A retry in arithmetic, evaluated: refused once, in this library's words, -// and the refused value asks nothing more. +// A retry in arithmetic, then evaluated, rendered and documented: refused +// once, in this library's words, and the refused value asks nothing more of +// any of the three. +#include #include +#include #include namespace @@ -44,9 +47,11 @@ struct Fitted int main() { - return formula::checked_evaluate(four + formula::constant(formula::Rational { 1 }), - formula::environment()) - .has_value() + constexpr auto misused = four + formula::constant(formula::Rational { 1 }); + auto const shown = formula::render(misused); + auto const written = formula::document(misused); + return formula::checked_evaluate(misused, formula::environment()).has_value() && !shown.empty() + && !written.formula.empty() ? 0 : 1; } diff --git a/test/negative/yields_as_operand.cpp b/test/negative/yields_as_operand.cpp index 40123193..779cef0c 100644 --- a/test/negative/yields_as_operand.cpp +++ b/test/negative/yields_as_operand.cpp @@ -6,10 +6,12 @@ // REJECT: RequireResultDimension // // A formula bound to the water/cement ratio, used as an operand of another -// formula and evaluated: refused once, in this library's words, and the -// refused value asks nothing more. The formula it holds, .expression, is the -// operand to use. +// formula, then evaluated, rendered and documented: refused once, in this +// library's words, and the refused value asks nothing more of any of the +// three. The formula it holds, .expression, is the operand to use. +#include #include +#include using WaterVolume = formula::Quantity; using CementVolume = formula::Quantity; @@ -21,5 +23,10 @@ inline constexpr auto inputs = int main() { constexpr auto ratio = formula::yields(formula::var / formula::var); - return formula::checked_evaluate(formula::var * ratio, inputs).has_value() ? 0 : 1; + constexpr auto misused = formula::var * ratio; + auto const shown = formula::render(misused); + auto const written = formula::document(misused); + return formula::checked_evaluate(misused, inputs).has_value() && !shown.empty() && !written.formula.empty() + ? 0 + : 1; } From 78ffcbc9c25bc1776e84febc726ea30294eb2309 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 23:26:18 +0200 Subject: [PATCH 57/59] fix: refuse a comparison over a bound formula, in the library's words A bound formula compared with a limit, as a constraint compares, drew the compiler's list of comparison operators none of which takes a Yields. It is now refused as a bound operand is, with the same message, and gives a comparison of refused values, so that a constraint, check, render or document over it adds nothing. The operators are constrained to a Yields operand, so comparisons of formulas are untouched, and a bound formula stays not equality-comparable. Signed-off-by: Christian Parpart --- CHANGELOG.md | 4 +- include/formula-cpp/yields.hpp | 106 +++++++++++++++++++++++++++--- test/CMakeLists.txt | 3 + test/negative/yields_compared.cpp | 33 ++++++++++ test/yields_tests.cpp | 23 +++++++ 5 files changed, 159 insertions(+), 10 deletions(-) create mode 100644 test/negative/yields_compared.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 019fb9de..fb23db3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,8 +69,8 @@ change is recorded here. `Q`. Nest `documented()` inside it, and reuse the formula in another through `.expression`; a `yields` around a bound formula is refused where it is written. A bound series, rejection of outliers, retry or whole opaque call handed to a verb that answers with one value is refused in - words that name the verbs that take it, and a bound formula used as an operand is refused in - favour of its `.expression`. Every earlier spelling stays. + words that name the verbs that take it, and a bound formula used as an operand or compared is + refused in favour of its `.expression`. Every earlier spelling stays. ### Changed diff --git a/include/formula-cpp/yields.hpp b/include/formula-cpp/yields.hpp index 466df036..a2561684 100644 --- a/include/formula-cpp/yields.hpp +++ b/include/formula-cpp/yields.hpp @@ -40,11 +40,13 @@ /// its own words, as `define` does. /// /// **Refused as an operand:** a `Yields` on either side of `+`, `-`, `*` or -/// `/`, or negated (`detail::RequireBoundNotOperand`). Use its `.expression`. +/// `/`, negated, or on either side of `<`, `<=`, `>`, `>=`, `==` or `!=` +/// (`detail::RequireBoundNotOperand`). Use its `.expression`. #include #include #include +#include #include #include @@ -242,9 +244,9 @@ template struct RequireBoundNotOperand { @@ -267,10 +269,10 @@ namespace detail return Describe::dimension; } - /// What arithmetic over a bound formula gives, once refused: a node of - /// the bound quantity's dimension that is refused already - /// (`refused_already`), so nothing over it asks again, and that is never - /// evaluated but to a `DomainError`. + /// What arithmetic over a bound formula gives, once refused, and what a + /// comparison over one compares: a node of the bound quantity's dimension + /// that is refused already (`refused_already`), so nothing over it asks + /// again, and that is never evaluated but to a `DomainError`. template struct RefusedBoundValue: NodeBase { @@ -357,4 +359,92 @@ template return {}; } +namespace detail +{ + /// The type a comparison operator over @p L and @p R returns when one of + /// them is a bound formula; none otherwise: a comparison of two refused + /// values of the bound quantity's dimension, so that nothing it is used + /// in -- an acceptance, a constraint -- asks again. The operators name + /// it, so that asking whether a bound formula can be compared -- as + /// `std::equality_comparable` does -- answers no, and is not the refusal; + /// a class for the reason `RefusedBoundResult` is one. + template || is_yields> + struct RefusedBoundComparison + { + }; + + template + struct RefusedBoundComparison + { + /// The refused comparison. + using type = PredicateNode()>, + RefusedBoundValue()>>; + }; +} // namespace detail + +/// A bound formula compared, on either side of `<`, `<=`, `>`, `>=`, `==` +/// or `!=`: refused in this library's words, as arithmetic over it is -- an +/// acceptance or a constraint is a comparison, so this is a likely place to +/// write one. Only an operand that is a `Yields` reaches these, so comparisons +/// of formulas are untouched. +template + requires(detail::is_yields || detail::is_yields) +[[nodiscard]] constexpr auto operator<(L, R) noexcept -> + typename detail::RefusedBoundComparison::type +{ + static_assert(detail::RequireBoundNotOperand, L, R>>::value); + return { {}, {} }; +} + +/// See `operator<` over a bound formula. +template + requires(detail::is_yields || detail::is_yields) +[[nodiscard]] constexpr auto operator<=(L, R) noexcept -> + typename detail::RefusedBoundComparison::type +{ + static_assert(detail::RequireBoundNotOperand, L, R>>::value); + return { {}, {} }; +} + +/// See `operator<` over a bound formula. +template + requires(detail::is_yields || detail::is_yields) +[[nodiscard]] constexpr auto operator>(L, R) noexcept -> + typename detail::RefusedBoundComparison::type +{ + static_assert(detail::RequireBoundNotOperand, L, R>>::value); + return { {}, {} }; +} + +/// See `operator<` over a bound formula. +template + requires(detail::is_yields || detail::is_yields) +[[nodiscard]] constexpr auto operator>=(L, R) noexcept -> + typename detail::RefusedBoundComparison::type +{ + static_assert(detail::RequireBoundNotOperand, L, R>>::value); + return { {}, {} }; +} + +/// See `operator<` over a bound formula. +template + requires(detail::is_yields || detail::is_yields) +[[nodiscard]] constexpr auto operator==(L, R) noexcept -> + typename detail::RefusedBoundComparison::type +{ + static_assert(detail::RequireBoundNotOperand, L, R>>::value); + return { {}, {} }; +} + +/// See `operator<` over a bound formula. +template + requires(detail::is_yields || detail::is_yields) +[[nodiscard]] constexpr auto operator!=(L, R) noexcept -> + typename detail::RefusedBoundComparison::type +{ + static_assert(detail::RequireBoundNotOperand, L, R>>::value); + return { {}, {} }; +} + } // namespace formula diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index d68aec81..4686f05c 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -550,6 +550,9 @@ formula_add_negative_test(yields_opaque_call_evaluate formula_add_negative_test(yields_as_operand "formula: a bound formula is not an operand; use its .expression" EXPECT_COUNT 1 REJECT "no matching" "invalid operands" "no match for" "RequireResultDimension") +formula_add_negative_test(yields_compared + "formula: a bound formula is not an operand; use its .expression" EXPECT_COUNT 1 + REJECT "no matching" "invalid operands" "no match for") formula_add_negative_test(yields_not_a_value "formula: this bound formula is not an expression of one value" EXPECT_COUNT 1 REJECT "no matching") diff --git a/test/negative/yields_compared.cpp b/test/negative/yields_compared.cpp new file mode 100644 index 00000000..d11b1d1d --- /dev/null +++ b/test/negative/yields_compared.cpp @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: a bound formula is not an operand; use its .expression +// REJECT: no matching +// REJECT: invalid operands +// REJECT: no match for +// +// A formula bound to the water/cement ratio, compared with a limit as a +// constraint compares, then checked, rendered and documented: refused once, in +// this library's words rather than the compiler's missing operator, and the +// refused comparison asks nothing more of any of the three. The formula it +// holds, .expression, is the one to compare. +#include +#include +#include +#include + +using WaterVolume = formula::Quantity; +using CementVolume = formula::Quantity; +using WaterCementRatio = formula::Quantity; + +inline constexpr auto inputs = + formula::environment(formula::Measured { 163 }, formula::Measured { 307 }); + +int main() +{ + using namespace formula::literals; + constexpr auto ratio = formula::yields(formula::var / formula::var); + constexpr auto tooWet = formula::constraint(ratio >= formula::constant(0.45_r), + formula::Verdict { "too much water for the cement" }); + auto const shown = formula::render(tooWet); + auto const written = formula::document(tooWet); + return formula::check(tooWet, inputs).is_satisfied() && !shown.empty() && !written.formula.empty() ? 0 : 1; +} diff --git a/test/yields_tests.cpp b/test/yields_tests.cpp index 93638b87..91a22b18 100644 --- a/test/yields_tests.cpp +++ b/test/yields_tests.cpp @@ -7,6 +7,7 @@ #include +#include #include #include #include @@ -264,3 +265,25 @@ TEST_CASE("yields: a bound formula is not an operand, and arithmetic over formul STATIC_REQUIRE(formula::number_of(formula::checked_evaluate(var * ratio.expression, batch)) == formula::Rational { 163 }); } + +TEST_CASE("yields: a bound formula is not a comparand, and comparisons of formulas are untouched", "[yields]") +{ + // Asked of a type, a comparison over a bound formula is answered without + // the refusal firing, and it is no equality a concept can use. + using Bound = std::remove_const_t; + using Refused = formula::detail::RefusedBoundValue::dimension>; + constexpr auto limit = formula::constant(formula::Rational { 9, 20 }); + using Limit = std::remove_const_t; + STATIC_REQUIRE(std::is_same_v() >= limit), + formula::PredicateNode>); + STATIC_REQUIRE(!std::equality_comparable); + STATIC_REQUIRE(!std::equality_comparable_with); + + // The formula it holds is compared as any formula is: 163/307 is above + // 9/20. + using Held = std::remove_const_t; + STATIC_REQUIRE(std::is_same_v= limit), + formula::PredicateNode>); + constexpr auto tooWet = formula::constraint(ratio.expression >= limit, formula::Verdict { "too much water" }); + STATIC_REQUIRE(formula::check(tooWet, batch).is_satisfied()); +} From a21fc986cc3667673b4a65c46475a4af2c579a19 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Wed, 30 Sep 2026 23:39:41 +0200 Subject: [PATCH 58/59] docs: list every example that evaluates at compile time, and finish the whole-number spellings The census page named three examples that evaluate formulas at compile time; nine do now, and a row reading 0 | 0 | 0 is explained. The expressions guide says a comparison over a bound formula is refused in the same words as an operand. Two more whole numbers are written plainly in opaque_and_retry.cpp and dimensions_and_units.cpp, with the guide quote that follows the first. Signed-off-by: Christian Parpart --- docs/expressions.md | 3 ++- docs/numeric-headroom.md | 13 ++++++++----- docs/opaque-and-retry.md | 2 +- examples/dimensions_and_units.cpp | 4 ++-- examples/opaque_and_retry.cpp | 2 +- 5 files changed, 14 insertions(+), 10 deletions(-) diff --git a/docs/expressions.md b/docs/expressions.md index 4b2da8e7..34d1d19c 100644 --- a/docs/expressions.md +++ b/docs/expressions.md @@ -688,7 +688,8 @@ quantity -- *this formula is bound to its result quantity already; bind the formula it holds (.expression), or use it as it is*. **Reuse goes through `.expression`.** For the same reason, a bound formula is -not an operand of another formula. The formula it holds is, as any formula is +not an operand of another formula, and a comparison over one is refused in the +same words. The formula it holds is an operand, as any formula is ([Composing a formula from other formulas](#composing-a-formula-from-other-formulas)). Here `MixWater` and `MixCement` are volumes in litres, as `WaterVolume` and `CementVolume` are: diff --git a/docs/numeric-headroom.md b/docs/numeric-headroom.md index ea602aa2..d22dcef0 100644 --- a/docs/numeric-headroom.md +++ b/docs/numeric-headroom.md @@ -117,11 +117,14 @@ census, and only the rounded decimal it answers is counted, made by The census does not see evaluations that happen at compile time (`constexpr`): a constant evaluation cannot report to a tally. They fit -- an overflow there is still a refused result -- but their headroom is not -measured. Three examples evaluate some of their formulas that way: -`expressions` three, `rounding_and_conditionals` six and `constraints` five. -`expressions` evaluates one formula at run time, and that evaluation returns -a value a person entered without computing it, so its row reports no integer -and the full 63 bits. +measured. Nine examples evaluate some of their formulas that way: +`constraints`, `dimensions_and_units`, `expressions`, `lookup_tables`, +`quantities`, `records`, `rounding_and_conditionals`, `series` and `statistics`. +A row that reads 0 | 0 | 0 and the full 63 bits means the program counted no +integer at run time. For `quantities` that is because every check it makes is +a compile-time one, so there is nothing for the census to tally. `expressions` +evaluates one formula at run time, and that evaluation returns a value a +person entered without computing it, so its row reports no integer either. The figures are deterministic: the census program prints the same on cl 19.51 and gcc 13.3, and the clang and gcc presets hold it to the same pins. diff --git a/docs/opaque-and-retry.md b/docs/opaque-and-retry.md index f65fb6d6..829b5b91 100644 --- a/docs/opaque-and-retry.md +++ b/docs/opaque-and-retry.md @@ -487,7 +487,7 @@ most 0.76 g. The sequence rises, so the acceptance is written `w(k-1) - w(k) >= -0.76 g`: ```cpp -constexpr auto halving = formula::constant(6.08_r) + formula::previous_attempt / 2_r; +constexpr auto halving = formula::constant(6.08_r) + formula::previous_attempt / 2; constexpr auto settled = formula::previous_attempt - formula::this_attempt >= formula::constant(-0.76_r); constexpr auto fromZero = formula::starting_from(formula::constant(0)); diff --git a/examples/dimensions_and_units.cpp b/examples/dimensions_and_units.cpp index 53430847..42899c96 100644 --- a/examples/dimensions_and_units.cpp +++ b/examples/dimensions_and_units.cpp @@ -142,8 +142,8 @@ int main() .decimals = 1, .bounds = formula::bounds(0, 1, 100, 1) }; - constexpr auto unboundedVerdict = formula::checked_within_bounds(1'000'000_r, unit::Litre); - constexpr auto boundedVerdict = formula::checked_within_bounds(42_r, BoundedGauge); + constexpr auto unboundedVerdict = formula::checked_within_bounds(1'000'000, unit::Litre); + constexpr auto boundedVerdict = formula::checked_within_bounds(42, BoundedGauge); static_assert(unboundedVerdict.has_value() && boundedVerdict.has_value()); // `{}` of a BoundsCheck writes its describe() words. diff --git a/examples/opaque_and_retry.cpp b/examples/opaque_and_retry.cpp index f5a2826f..b30bea4a 100644 --- a/examples/opaque_and_retry.cpp +++ b/examples/opaque_and_retry.cpp @@ -146,7 +146,7 @@ using Tolerance = formula::Quantity= -0.76 g, since the sequence rises. -constexpr auto halving = formula::constant(6.08_r) + formula::previous_attempt / 2_r; +constexpr auto halving = formula::constant(6.08_r) + formula::previous_attempt / 2; constexpr auto settled = formula::previous_attempt - formula::this_attempt >= formula::constant(-0.76_r); constexpr auto fromZero = formula::starting_from(formula::constant(0)); From 20b7dc972cdfccd10a35195b0209caaf46939eea Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Thu, 1 Oct 2026 00:09:12 +0200 Subject: [PATCH 59/59] docs: quote the refusal a compared bound formula gets, and say what quantities does at run time The guide said a comparison over a bound formula "is refused in the same words" without quoting any refusal nearby, so a reader looking for the words found the one for a bound formula inside another, which is a different message. It now quotes the refusal both uses get. The census page said every check the quantities example makes is a compile-time one. One is not: it combines an absent input at run time. That computes no integer, which is why its row reads zero. Signed-off-by: Christian Parpart --- docs/expressions.md | 4 ++-- docs/numeric-headroom.md | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/expressions.md b/docs/expressions.md index 34d1d19c..cf8aae5e 100644 --- a/docs/expressions.md +++ b/docs/expressions.md @@ -688,8 +688,8 @@ quantity -- *this formula is bound to its result quantity already; bind the formula it holds (.expression), or use it as it is*. **Reuse goes through `.expression`.** For the same reason, a bound formula is -not an operand of another formula, and a comparison over one is refused in the -same words. The formula it holds is an operand, as any formula is +not an operand of another formula, nor a side of a comparison; either use is +refused with *a bound formula is not an operand; use its .expression*. The formula it holds is an operand, as any formula is ([Composing a formula from other formulas](#composing-a-formula-from-other-formulas)). Here `MixWater` and `MixCement` are volumes in litres, as `WaterVolume` and `CementVolume` are: diff --git a/docs/numeric-headroom.md b/docs/numeric-headroom.md index d22dcef0..77582c33 100644 --- a/docs/numeric-headroom.md +++ b/docs/numeric-headroom.md @@ -121,8 +121,9 @@ measured. Nine examples evaluate some of their formulas that way: `constraints`, `dimensions_and_units`, `expressions`, `lookup_tables`, `quantities`, `records`, `rounding_and_conditionals`, `series` and `statistics`. A row that reads 0 | 0 | 0 and the full 63 bits means the program counted no -integer at run time. For `quantities` that is because every check it makes is -a compile-time one, so there is nothing for the census to tally. `expressions` +integer at run time. For `quantities` that is because it evaluates its +formulas at compile time; the one thing it does at run time, combining an +absent input, computes no integer, so there is nothing for the census to tally. `expressions` evaluates one formula at run time, and that evaluation returns a value a person entered without computing it, so its row reports no integer either.