diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 00000000..08cdd614 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,132 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# Measures how much of the library the test suite runs, and reports it to +# Codecov. Only include/ is counted: the tests, examples and fetched +# dependencies are what does the running, not what is measured. The report is +# informational (codecov.yml): it never fails a pull request. +# +# Uploading needs the CODECOV_TOKEN repository secret, which the repository +# owner adds once on codecov.io and in Settings > Secrets. Without it -- in a +# repository that has not set it up, or a pull request from a fork, which gets +# no secrets -- the upload is skipped with a notice and the job still shows +# whether the suite passed. With it, a failed upload fails the job. +name: Coverage +on: + push: + branches: [ master ] + pull_request: + +concurrency: + group: coverage-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +env: + CPM_SOURCE_CACHE: ${{ github.workspace }}/.cache/CPM + # The same Clang as build.yml's Linux legs. + CLANG_VERSION: "22" + +jobs: + coverage: + name: Linux-clang-coverage + runs-on: ubuntu-24.04 + env: + # A step's `if:` cannot read `secrets`; it reads this instead. + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + steps: + - uses: actions/checkout@v4 + + - name: Restore CPM cache + uses: actions/cache/restore@v4 + with: + path: ${{ env.CPM_SOURCE_CACHE }} + key: cpm-${{ runner.os }}-${{ hashFiles('test/CMakeLists.txt') }} + restore-keys: cpm-${{ runner.os }}- + + - uses: seanmiddleditch/gha-setup-ninja@v6 + + # Retried as a whole, for the reason build.yml gives. llvm-N carries + # llvm-profdata-N and llvm-cov-N, which clang-N does not pull in. + - name: Install Clang ${{ env.CLANG_VERSION }} + run: | + for attempt in 1 2 3; do + if wget -q https://apt.llvm.org/llvm.sh \ + && chmod +x llvm.sh \ + && sudo ./llvm.sh ${{ env.CLANG_VERSION }} \ + && sudo apt-get install -y clang-${{ env.CLANG_VERSION }} llvm-${{ env.CLANG_VERSION }}; then + exit 0 + fi + echo "::warning::Clang install attempt $attempt failed; retrying" + sleep $((attempt * 15)) + done + echo "::error::Clang ${{ env.CLANG_VERSION }} could not be installed after 3 attempts" + exit 1 + + - name: Configure + run: cmake --preset clang-coverage -DCMAKE_CXX_COMPILER=clang++-${{ env.CLANG_VERSION }} -DCMAKE_C_COMPILER=clang-${{ env.CLANG_VERSION }} + + - name: Build + run: cmake --build --preset clang-coverage + + # ctest runs every test case in a process of its own. %m merges all the + # processes of one binary into one raw profile, rather than leaving one + # profile per process (%p), each as large as the binary's counters. + - name: Test + env: + LLVM_PROFILE_FILE: ${{ github.workspace }}/out/profiles/%m.profraw + run: ctest --preset clang-coverage + + - name: Export the coverage of include/ + run: | + set -euo pipefail + shopt -s nullglob + profiles=(out/profiles/*.profraw) + if (( ${#profiles[@]} == 0 )); then + echo "::error::the tests wrote no raw profile under out/profiles" + exit 1 + fi + llvm-profdata-${{ env.CLANG_VERSION }} merge -sparse "${profiles[@]}" -o out/coverage.profdata + + # Every test and example executable, and nothing else: CMake's + # scripts and any shared object are left out by the ELF check and + # the name. + binaries=() + while IFS= read -r -d '' file; do + if cmp -s -n 4 "$file" <(printf '\177ELF'); then + binaries+=("$file") + fi + done < <(find out/build/clang-coverage -type f -perm -u+x -name 'formula-cpp-*' \ + ! -name '*.so' ! -name '*.so.*' -print0 | sort -z) + if (( ${#binaries[@]} == 0 )); then + echo "::error::no formula-cpp-* executable under out/build/clang-coverage" + exit 1 + fi + printf 'measuring %s\n' "${binaries[@]}" + + # llvm-cov takes the first binary positionally and every other one + # after -object. System headers are left out with the rest. + others=() + for exe in "${binaries[@]:1}"; do others+=(-object "$exe"); done + llvm-cov-${{ env.CLANG_VERSION }} export -format=lcov -instr-profile=out/coverage.profdata \ + "${binaries[0]}" "${others[@]}" \ + -ignore-filename-regex='^/usr/|(^|/)(test|examples|tools|support|_deps|\.cache)/' \ + > out/coverage.lcov + if [[ ! -s out/coverage.lcov ]]; then + echo "::error::llvm-cov wrote an empty LCOV report" + exit 1 + fi + + - name: Upload to Codecov + if: env.CODECOV_TOKEN != '' + uses: codecov/codecov-action@v5 + with: + files: out/coverage.lcov + disable_search: true + token: ${{ env.CODECOV_TOKEN }} + fail_ci_if_error: true + + - name: Skip the upload without a Codecov token + if: env.CODECOV_TOKEN == '' + run: echo "::notice::Coverage was measured but not uploaded -- this repository has no CODECOV_TOKEN secret, or this pull request comes from a fork, which gets no secrets." diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index dd5dc951..8307a17a 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,10 +1,8 @@ # SPDX-License-Identifier: Apache-2.0 # # Publishes the MkDocs Material guides and the Doxygen API reference (under -# /api) to GitHub Pages. On push to master only -- a PR preview of the -# published site is not part of this phase, and publishing from anywhere but -# the branch that just passed Build (build.yml) would let an unreviewed page -# reach readers. +# /api) to GitHub Pages. Built on every push to master and every pull request; +# deployed only from master, so an unreviewed page never reaches readers. # # This workflow only builds and deploys the site; it never touches the # repository's Pages configuration. GitHub Pages must already be set, in the @@ -16,6 +14,10 @@ name: Pages on: push: branches: [ master ] + # Built on every pull request, so a page whose included code or output went + # missing fails before it merges. Deployed only from master (the deploy + # job's `if`). + pull_request: # Only one Pages deployment should ever be in flight: two overlapping # `deploy-pages` runs racing for the same environment is exactly what this @@ -24,7 +26,9 @@ on: # newer push waits for the current deployment to finish rather than # interrupting it. concurrency: - group: pages + # One group per ref: deployments from master stay serialised, as before, + # and a pull request's build never queues behind one. + group: pages-${{ github.ref }} cancel-in-progress: false permissions: @@ -74,6 +78,7 @@ jobs: run: cmake --build out/build/pages --target formula-cpp-docs-api - name: Upload the built site + if: github.event_name == 'push' uses: actions/upload-pages-artifact@v3 with: path: site @@ -81,6 +86,7 @@ jobs: deploy: name: Deploy to GitHub Pages needs: build + if: github.event_name == 'push' runs-on: ubuntu-24.04 permissions: pages: write diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f4e7abc..f09bc613 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,14 @@ change is recorded here. `transform` or `combine`. Nor does one that cannot be called with a `Rational`, which is refused with `formula: a checked_transform callback must be callable with a Rational`, or, for `checked_combine`, with `formula: a checked_combine callback must be callable with two Rationals`. +- **A tutorial** on the documentation site, in two tracks: nine chapters that build one + compressive-strength test step by step, and six independent chapters on the rest of the library. + Its code and output are included from programs the test suite builds and runs. +- `cmake/CheckExpectedOutput.cmake` pins each tutorial program's whole output, and the README + program's: a test compares what the program prints with the `.expected.txt` beside it, ignoring + only line endings, so a page cannot show output its program does not print. +- **Coverage reporting**: a workflow measures the test suite's coverage of `include/` and reports + it to Codecov; the upload runs once the repository has a `CODECOV_TOKEN` secret. ### Changed @@ -86,15 +94,27 @@ change is recorded here. `at_most()` or designated initialisers instead, which are unaffected. Code that read `present` reads `lowPresent || highPresent`, or each end on its own. A unit that declared bounds with `bounds()` gives the same answers as before. -- The README and the documentation home page now lead with the cyclist's speed from power. The - guides and the other examples use a road gradient, `s = h / L`, wherever they need a simple exact - division. +- **A bound formula is an operand**: a formula bound with `yields(...)` can be used inside another formula, where + it stands for the formula it holds, with its type and value: `yields(var / loadedArea)`. That holds + on either side of an arithmetic operator or a comparison, and for what a function that builds a formula is given + -- `sqrt`, `rounded`, `documented`, `sum`, a lookup's key and the rest. Such a use was refused in favour of + `.expression`, which still compiles and means the same. `yields` around a bound formula is still refused. +- The guides and the examples that need a simple exact division use a road gradient, + `s = h / L`. The documentation home page opens with the README's example, a concrete + specimen's compressive strength, and links `examples/cycling_speed.cpp` as a larger example. - `_r` literals take a 128-bit mantissa, so every integer up to 2^127 - 1 in magnitude can be written as one (`12'345'678'901'234'567'890_r` compiles), and `Rational::from_decimal` and `_r` scale by powers of ten from 10^-38 to 10^38 rather than stopping at 10^18. `from_decimal` folds a mantissa's trailing zeros into a negative exponent first, so `from_decimal(10, -39)` is 1/10^38. A zero literal with an exponent beyond ±1000, such as `0e1001_r`, now reads as 0 rather than being refused, as the same text does at run time. Only refusals turn into answers: every value that answered before is unchanged. +- The README is a short landing page: badges, a link to the documentation, a minimal example, + and installation with CPM, `find_package` or the headers alone. The vcpkg section is removed, + as no port is published. +- The documentation describes the library as it is now; what changed, and when, is recorded only + in this changelog. A test enforces it. +- `LICENSE` is the Apache License 2.0 text verbatim. The previous file differed from it in its + terms, not only its layout; the copyright line is unchanged. ## [0.4.0] - 2026-10-05 diff --git a/CMakePresets.json b/CMakePresets.json index 85997dd2..c6e3ff28 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -46,6 +46,14 @@ "CMAKE_CXX_COMPILER": "clang++", "CMAKE_CXX_FLAGS": "-fsanitize=undefined -fno-sanitize-recover=undefined -fno-omit-frame-pointer", "CMAKE_EXE_LINKER_FLAGS": "-fsanitize=undefined" + } }, + { "name": "clang-coverage", "displayName": "clang++ source-based coverage", "inherits": "posix", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "CMAKE_C_COMPILER": "clang", + "CMAKE_CXX_COMPILER": "clang++", + "CMAKE_CXX_FLAGS": "-fprofile-instr-generate -fcoverage-mapping", + "CMAKE_EXE_LINKER_FLAGS": "-fprofile-instr-generate" } } ], "buildPresets": [ @@ -56,7 +64,8 @@ { "name": "clang-debug", "configurePreset": "clang-debug" }, { "name": "clang-release", "configurePreset": "clang-release" }, { "name": "gcc-release", "configurePreset": "gcc-release" }, - { "name": "clang-ubsan", "configurePreset": "clang-ubsan" } + { "name": "clang-ubsan", "configurePreset": "clang-ubsan" }, + { "name": "clang-coverage", "configurePreset": "clang-coverage" } ], "testPresets": [ { "name": "cl-debug", "configurePreset": "cl-debug", "output": { "outputOnFailure": true } }, @@ -66,6 +75,7 @@ { "name": "clang-debug", "configurePreset": "clang-debug", "output": { "outputOnFailure": true } }, { "name": "clang-release", "configurePreset": "clang-release", "output": { "outputOnFailure": true } }, { "name": "gcc-release", "configurePreset": "gcc-release", "output": { "outputOnFailure": true } }, - { "name": "clang-ubsan", "configurePreset": "clang-ubsan", "output": { "outputOnFailure": true } } + { "name": "clang-ubsan", "configurePreset": "clang-ubsan", "output": { "outputOnFailure": true } }, + { "name": "clang-coverage", "configurePreset": "clang-coverage", "output": { "outputOnFailure": true } } ] } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 57c8ed4a..d41fe895 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,6 +55,53 @@ because the alternative was measured and failed. 7. **The version is a committed literal.** Never derive it from `git describe`: `vcpkg_from_github` extracts a tarball with no `.git`. +## Documentation + +1. **User documentation describes the library as it is now.** That is + `README.md`, everything under `docs/` except `docs/superpowers/`, and the + doc comments in `include/` that form the API reference. It never says what + the library used to do, what changed, or in which release something + appeared; that belongs in `CHANGELOG.md`. + `ctest -R hygiene.docs-current-state` enforces it. A line that matches a + history phrase but describes data goes in + `cmake/docs-current-state-allowlist.txt`, one entry per line in the form + `path|exact trimmed line|reason`; an entry that allows no reported line + fails the check, so remove it when its line is reworded. + +2. **The tutorial** has a page in `docs/tutorial/` and a program in + `examples/tutorial/` for each chapter. The page includes code with + `--8<-- "examples/tutorial/.cpp:"` and output with + `--8<-- "examples/tutorial/.expected.txt"`; regions are marked in the + program with `// --8<-- [start:]` and `// --8<-- [end:]`. + Each program is registered in `examples/CMakeLists.txt` with + `formula_add_pinned_example`, whose test `example..output` compares + the program's output with its `.expected.txt`. When a program's output + changes, update its `.expected.txt` (`example..output` fails until you + do) and re-read the page. A new program also needs `docs/numeric-headroom.md` + regenerated: build the target `formula-cpp-census-page` with cl (the page's + examples table is cl's) and commit the result. The target exists when + `FORMULA_BUILD_TESTS`, `FORMULA_BUILD_EXAMPLES` and `FORMULA_TOOLS` are all + on, as they are by default in a top-level build. `mkdocs build --strict` + fails on a missing file or region; the `Pages` workflow runs it on every + pull request. + +3. **The README** shows `examples/readme.cpp` and its output verbatim; + `docs.readme-snippets` and `docs.readme-output` check them. The CPM tag in + the README and in tutorial chapter 1 (`docs/tutorial/01-first-formula.md`) + must equal the project version; `hygiene.version` checks both. + +4. **The consumer-globals test.** `test/consumer_globals_tests.cpp` declares + 309 ordinary globals (308 under compilers other than cl and clang-cl, as + glibc declares `index`) such as `result`, `value`, `x` and `index` before + including every header, and builds under cl `/W4 /WX` and g++ + `-Wshadow -Werror`: no header's local or parameter hides one of them in + anything that test instantiates -- evaluation of every node kind, + `render`, `document` and the trace in every dialect, constraints, methods + and every overlay operation (the test lists them). cl reports a template's + local only in a template that is instantiated, and never a function + template's parameter, so a template the test does not reach is not covered + by it. + ## Linting `.clang-tidy` is present but not yet enforced: no CI job runs clang-tidy or diff --git a/LICENSE b/LICENSE index a537ca6a..85d35df1 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,4 @@ + Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ @@ -32,9 +33,10 @@ not limited to compiled object code, generated documentation, and conversions to other media types. - "Work" shall mean the work of authorship made available under - the License, as indicated by a copyright notice that is included in - or attached to the work (an example is provided in the Appendix below). + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the @@ -44,21 +46,23 @@ separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. - "Contribution" shall mean, as submitted to the Licensor for inclusion - in the Work by the copyright owner or by an individual or Legal Entity - authorized to submit on behalf of the copyright owner. For the purposes - of this definition, "submitted" means any form of electronic, verbal, - or written communication sent to the Licensor or its representatives, - including but not limited to communication on electronic mailing lists, - source code control systems, and issue tracking systems that are managed - by, or on behalf of, the Licensor for the purpose of discussing and - improving the Work, but excluding communication that is conspicuously - marked or designated in writing by the copyright owner as "Not a - Contribution." - - "Contributor" shall mean Licensor and any Legal Entity on behalf of - whom a Contribution has been received by the Licensor and included - within the Work. + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, @@ -74,22 +78,22 @@ use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their - Contribution(s) alone or by the combination of their Contribution(s) + Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a cross-claim - or counterclaim in a lawsuit) alleging that the Work or any other - Contribution embodied within the Work constitutes patent or contributory - patent infringement, then any patent licenses granted to You under - this License for that Work shall terminate as of the date such - litigation is filed. + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: - (a) You must give any other recipients of the Work or Derivative - Works a copy of this License; and + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and @@ -101,25 +105,28 @@ the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its - distribution, You must include a readable copy of the - attribution notices contained within such NOTICE file, in - at least one of the following places: within a NOTICE text - file distributed as part of the Derivative Works; within - the Source form or documentation, if provided along with the - Derivative Works; or, within a display generated by the - Derivative Works, if and wherever such third-party notices - normally appear. The contents of the NOTICE file are for - informational purposes only and do not modify the License. - You may add Your own attribution notices within Derivative - Works that You distribute, alongside or in addition to the - NOTICE text from the Work, provided that such additional - attribution notices cannot be construed as modifying the - License. - - You may add Your own license statement for Your modifications and - may provide additional grant of rights to use, copy, modify, merge, - publish, distribute, sublicense, and/or sell copies of the - Contribution, either before or after the above terms and conditions. + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work @@ -141,7 +148,7 @@ implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or reproducing the Work and assume any + appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, @@ -149,20 +156,20 @@ unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, - incidental, or exemplary damages of any character arising as a + incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or all other - commercial damages or losses), even if such Contributor has been - advised of the possibility of such damages. + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may offer, and charge a - fee for, acceptance of support, warranty, indemnity, or other - liability obligations and/or rights consistent with this License. - However, in accepting such obligations, You may offer such terms - only on Your own behalf and on Your sole responsibility, not on - behalf of any other Contributor, and only if You agree to indemnify, + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. @@ -175,7 +182,10 @@ boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. Copyright 2026 Yaraslau Tamashevich diff --git a/README.md b/README.md index 02a6b0e8..05886d07 100644 --- a/README.md +++ b/README.md @@ -1,484 +1,91 @@ # formula-cpp -Declarative, traceable, self-documenting formulas for C++23. Header-only, no dependencies. - -Write a formula once, with ordinary operators. Get back a number, a rendering, and a -documentation page — from the same declaration. - -How fast does a cyclist ride on 250 W? `examples/cycling_speed.cpp` works out -the steady-state speed from power. Each step is a formula, named for the -quantity it yields — a type carrying its own symbol, description and unit — -and the speed's formula carries its citation: - -```cpp -// ---- The steps ---- -inline constexpr auto gradient = formula::yields(var / var); -inline constexpr auto totalMass = formula::yields(var + var); -inline constexpr auto resistingForce = - formula::yields(var * gravity * (var + var) ); -inline constexpr auto dragFactor = formula::yields(formula::Rational { 1, 2 } * var * var); -inline constexpr auto powerTerm = formula::yields(var / (2 * var) ); -inline constexpr auto forceTerm = formula::yields(var / (3 * var) ); - -// The speed, read from a and b by name, so its rendering stays one line. -inline constexpr auto discriminant = formula::pow<2>(var) + formula::pow<3>(var); -inline constexpr auto speed = formula::yields(formula::documented( - formula::cbrt(var + formula::sqrt(discriminant)) - + formula::cbrt(var - formula::sqrt(discriminant)), - { .title = "Validation of a mathematical model for road cycling power", - .reference = "J. C. Martin, D. L. Milliken, J. E. Cobb, K. L. McFadden and A. R. Coggan, " - "Journal of Applied Biomechanics, 1998", - .text = "A simplified form of the model, without drivetrain or bearing losses: the power a rider holds " - "balances rolling resistance, gravity and air drag; in steady state, with no wind and a small " - "gradient, P = v * (m * g * (C_rr + s) + 1/2 * rho * C_dA * v^2), solved here for v." })); -``` - -`ride(surface)` puts them into one `formula::calculation`, after a step that -looks up the rolling coefficient by the road surface. Rendered, the lookup -spells out its whole table on one line; the steps after it read: - -``` -s = h / L -m = m_r + m_b -F = m * 9.80665 m/s2 * (C_rr + s) -k = 0.5 * rho * C_dA -a = P / (2 * k) -b = F / (3 * k) -v = root3(a + sqrt(a^2 + b^3)) + root3(a - sqrt(a^2 + b^3)) -``` - -The speeds it calculates at 250 W: - -``` -flat road, asphalt, 250 W: 10.332 m/s (37.2 km/h) -3 % climb, asphalt, 250 W: 6.783 m/s (24.4 km/h) -flat road, cobbles, 250 W: 8.335 m/s (30.0 km/h) -``` - -Every step up to `a` and `b` is an exact lookup, sum, product or quotient: the -gradient (`3/100` on the climb), the force, `a` and `b` are exact rationals, -never rounded. The cube roots have no exact value, so the speed is evaluated -in `double` (`formula::checked_evaluate_si`) from the exact `a` and -`b`, by Cardano's formula, which solves the cubic for the speed in closed -form. On an 8 % descent at no power, Cardano's square root has no real -answer, and the program reports a `DomainError` instead of a speed. The same -calculation also says what its symbols mean and where it comes from: see -[One calculation, four answers](#one-calculation-four-answers). - -A quantity can also be declared as a struct deriving from `formula::Quantity`, -`struct RiderMass: formula::Quantity {};`. -Both spellings are supported, and can be used together in one formula; [the -quantities 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. The one mistake that must not compile is shown from -`test/negative/`, which pins it. - -### One calculation, four answers - -The inputs of the cycling example are quantities, as every value it -calculates is: - -```cpp -// ---- The inputs ---- -using RiderMass = formula::Quantity; -using BikeMass = formula::Quantity; -using DragArea = formula::Quantity; -using AirDensity = formula::Quantity; -using Power = formula::Quantity; -using Rise = formula::Quantity; -using Run = formula::Quantity; -``` - -`ride(RoadSurface::Asphalt)` is the calculation for an asphalt road. Rendering -it, as plain text and as LaTeX, and documenting it, are three calls; the -documentation, `page`, holds the symbol table the program prints, and the -citation: - -```cpp -auto const decimals = formula::NumberStyle::exact_decimal(); -auto const onAsphalt = ride(RoadSurface::Asphalt); - -// ---- 1. The calculation, one step a line ---- -std::println("the calculation, in the order it calculates:\n{}\n", formula::render(onAsphalt, { .numbers = decimals })); -std::println("in LaTeX:\n{}\n", - formula::render(onAsphalt, latexSymbols, { .numbers = decimals })); - -// ---- 2. Its symbol table and its citation ---- -formula::Documentation const page = formula::document(onAsphalt, { .numbers = decimals }); -std::println("its symbol table:"); -for (formula::SymbolEntry const& entry: page.symbols) - std::println(" {:<5} {:<6} {}", entry.symbol, entry.unit, entry.description); -``` - -The plain rendering is shown above. In LaTeX, the speed's line reads: - -``` -v = \sqrt[3]{a + \sqrt{a^{2} + b^{3}}} + \sqrt[3]{a - \sqrt{a^{2} + b^{3}}} -``` - -The symbol table lists the eight calculated values first, then the seven -inputs; its calculated rows read: - -``` -its symbol table: - C_rr rolling resistance coefficient - s road gradient - m kg mass of rider and bike - F N rolling resistance and gravity together - k kg/m air drag per square of speed - a m3/s3 power over twice the drag factor - b m2/s2 force over three times the drag factor - v m/s steady-state speed -``` - -and the citation, from `page.citations`: - -``` -the speed, after: - Validation of a mathematical model for road cycling power - J. C. Martin, D. L. Milliken, J. E. Cobb, K. L. McFadden and A. R. Coggan, Journal of Applied Biomechanics, 1998 -``` - -The value comes from the example's `ride_on(surface, inputs)`. `riding(watts, -rise)` is the environment of the inputs: a rider of 75 kg on a bike of -8.5 kg, with a drag area of 0.32 m², in air of 1.225 kg/m³, holding `watts` on -a road rising `rise` over 1000 m. `ride_on` calculates every step up to `a` and -`b` exactly on a `formula::worksheet` of `ride(surface)` and those inputs, -evaluates the speed's formula in `double` from them, rounds it to the -millimetre a second and converts it to km/h. Each result is checked before it -is read: - -```cpp -auto const flat = ride_on(RoadSurface::Asphalt, riding(250, 0)); -if (!flat) -{ - std::println("flat road at 250 W: {}", formula::describe(flat.error())); - return 1; -} -std::println("\nflat road, asphalt, 250 W: {} ({:.1HalfEven})", flat->inMetresPerSecond, flat->inKilometresPerHour); -``` - -`riding(250, 30)` is the 3 % climb, 30 m over 1000 m. - -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 dimensional mistake is a compile error, not a wrong number - -```cpp -inline constexpr auto broken = formula::var + formula::var; -``` - -``` -error C2338: static assertion failed: 'formula: the two sides of this addition -or subtraction measure different dimensions; the offending operands appear in -this diagnostic as the template arguments of RequireAddendsAgree' -``` - -The diagnostic names the two quantities and points at the line that wrote the -formula. Not at evaluation, not at a failing test, and not at a support ticket -six months later. - -Asking an environment for a quantity it was never given fails the same way: - -``` -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' -``` - -### Units convert themselves, across the whole formula - -`Diameter` is declared in millimetres, `Area` in square metres. Nothing in the -formula mentions either — the conversion is part of what the declaration means. +**[Documentation](https://lastrada-software.github.io/formula-cpp/)** · +[Tutorial](https://lastrada-software.github.io/formula-cpp/tutorial/) · +[API reference](https://lastrada-software.github.io/formula-cpp/api/) + +[![Build](https://github.com/LASTRADA-Software/formula-cpp/actions/workflows/build.yml/badge.svg?branch=master)](https://github.com/LASTRADA-Software/formula-cpp/actions/workflows/build.yml) +[![Package](https://github.com/LASTRADA-Software/formula-cpp/actions/workflows/package.yml/badge.svg?branch=master)](https://github.com/LASTRADA-Software/formula-cpp/actions/workflows/package.yml) +[![Pages](https://github.com/LASTRADA-Software/formula-cpp/actions/workflows/pages.yml/badge.svg?branch=master)](https://github.com/LASTRADA-Software/formula-cpp/actions/workflows/pages.yml) +[![codecov](https://codecov.io/gh/LASTRADA-Software/formula-cpp/branch/master/graph/badge.svg)](https://codecov.io/gh/LASTRADA-Software/formula-cpp) +[![Release](https://img.shields.io/github/v/release/LASTRADA-Software/formula-cpp)](https://github.com/LASTRADA-Software/formula-cpp/releases/latest) +[![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) +[![C++23](https://img.shields.io/badge/C%2B%2B-23-blue.svg)](https://en.cppreference.com/w/cpp/23) +[![Header-only](https://img.shields.io/badge/header--only-yes-brightgreen.svg)](#installation) +[![Compilers](https://img.shields.io/badge/compilers-MSVC%20%7C%20clang--cl%20%7C%20Clang%20%7C%20GCC%2014%20%7C%20AppleClang-informational.svg)](#requirements) +[![Docs](https://img.shields.io/badge/docs-online-blue.svg)](https://lastrada-software.github.io/formula-cpp/) -```cpp -constexpr auto circularArea = formula::yields(formula::pi * formula::pow<2>(var) / 4); -``` - -`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); -static_assert(area.has_value()); -``` - -``` -circular area of a 103 mm diameter = ≈0.008332 m2 (derived) -2500 g reported as m = 2.5 kg -``` +Declarative, traceable, self-documenting formulas for C++23. Header-only, no dependencies. -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 `≈`. +Software that implements a test method (a standard's procedure for testing a material) usually +keeps the formula in one place, its units in another, where it comes from in a comment, and its +audit trail in code written afterwards. Those drift apart. In formula-cpp they are one +declaration: write the formula once, with ordinary operators, and get the number, its units, its +derivation and its documentation from it. -To serialise a unit — a JSON annotation, a database column — write its stable -ASCII key, `formula::view_ascii(someUnit)`, not its display symbol, which may -be restyled. A unit whose symbol is not ASCII declares the key as `.asciiText` -(`unit::Micrometre`, shown `µm`, is keyed `um`); one that does not is refused -where the library takes it as a quantity's, constant's, rounding's, table's or -other formula node's unit. +## Example -### A measurement nobody took stays missing +The compressive strength of a concrete specimen: the load that crushed it, 675 kN, over the area +that carried it, 22500 mm². ```cpp -constexpr auto diameterUnknown = formula::environment(formula::Measured::absent()); -constexpr auto emptyArea = formula::checked_evaluate(circularArea, diameterUnknown); -static_assert(emptyArea.has_value()); -``` - -``` -area with no diameter measured: empty -``` +#include +#include -Not `0.0`. An absence propagates through every operator and arrives at the -result still saying "nobody measured this" — which is a different statement -from "this is zero", and the difference matters when someone signs off on it. +#include -### A number a person typed in never masquerades as a computed one +// A quantity is a type: a symbol, a description and a unit. +using Load = formula::Quantity; +using Area = formula::Quantity; +using Strength = formula::Quantity; -The `0.05_r` below needs `using namespace formula::literals;` in scope, as the -example has it: +// The formula, written once with ordinary operators. +constexpr auto strength = formula::yields(formula::var / formula::var); -```cpp -auto const climb = formula::environment(formula::Measured { 90 }, - formula::Measured { 3000 }, - formula::entered(formula::Measured { 0.05_r })); -auto const slope = formula::checked_evaluate(gradient, climb); -if (!slope) +int main() { - std::println("road gradient: {}", slope.error()); - return 1; + auto const specimen = formula::environment(formula::Measured { 675 }, formula::Measured { 22500 }); + + auto const result = formula::checked_evaluate(strength, specimen); + if (!result) + { + std::println("cannot calculate: {}", result.error()); + return 1; + } + std::println("{} = {}", formula::symbol_of(), *result); } -std::println("{} = {} ({})", formula::symbol_of(), *slope, slope->source()); -``` - -``` -s = 0.05 (manually entered) -``` - -The evaluation is checked before its outcome is read: `slope.error()` would -say, in words, what arithmetic failed. Here `gradient` is -`formula::yields(var / var)`, and `0.05_r` is the exact -decimal one twentieth, never a `double`. The formula would have computed -90 m / 3000 m = 0.03. A surveyor entered 0.05, so that is the answer — and -`slope->source()` is `ValueSource::ManuallyEntered`, printed above, so a report -can show which numbers were derived and which were asserted. - -### Arithmetic that does not drift - -``` -volume = 450 ml = 9/20 l -round trip exact: yes -flow rate = 9/8 l/min -reported = 1.13 l/min -one decimal, ceiling = 1.2 -one decimal, nearest = 1.1 -they differ: yes -ten tenths == one: yes -``` - -`9/20` is exact, not 0.450000000000000011. Ten tenths really do sum to one. -Rounding happens once, where you ask for it, in the mode you name — and the -last two lines are the reason that matters: the same number rounds to 1.2 or -1.1 depending on the rule the method specifies, and the library makes you say -which. - -### A decimal only where it is exact, a rounding only where you ask - -```cpp -#include ``` ```text -std::format("{}", Rational { 3, 5 }) 0.6 -std::format("{}", Rational { 1, 3 }) 1/3 -std::format("{:/}", Rational { 3, 5 }) 3/5 -std::format("{:.2HalfEven}", Rational { 23653, 200 }) 118.26 -std::format("{:.2HalfAwayFromZero}", Rational { 23653, 200 }) 118.27 -std::format("{:.2HalfEven}", Rational { 4 }) 4.00 -std::format("{:~.3HalfEven}", Rational { 1, 3 }) ≈0.333 -std::format("{:~.3HalfEven}", Rational { 3, 5 }) 0.6 +f_c = 30 MPa ``` -`1/3` has no decimal, so it stays `1/3`: `0.333` would be a different number. -A rounding names its mode — there is no default — and `~` marks it `≈`. -Traces and rendered formulas take the same choice; see -[Displaying numbers](docs/display.md). - -### The library documents itself +- **Units are part of the type.** Kilonewtons over square millimetres arrive in megapascals with no + conversion written. ([Units and dimensions](https://lastrada-software.github.io/formula-cpp/tutorial/02-units-and-dimensions/)) +- **Mistakes are compile errors.** `formula::var + formula::var` does not compile. + ([Units and dimensions](https://lastrada-software.github.io/formula-cpp/tutorial/02-units-and-dimensions/)) +- **Arithmetic is exact.** The 30 is an exact rational, not a `double`. + ([Exact numbers](https://lastrada-software.github.io/formula-cpp/tutorial/03-exact-numbers/)) -`document()` walks a formula for its rendered text, its citations and its -symbol table. [`docs/gallery.md`](docs/gallery.md) is a page generated that way -from several formulas at once — checked into the repository, and a CI test -fails if it ever stops matching what the generator produces. +## What you get -### A derived number can show how it was reached +- **Dimensional analysis at compile time**, with exact unit conversion. ([Dimensions and units](https://lastrada-software.github.io/formula-cpp/dimensions/)) +- **Exact arithmetic**, rounded only where you say, in the mode you name. ([Exact numbers](https://lastrada-software.github.io/formula-cpp/numbers/)) +- **Missing and entered values** that never pass for computed ones. ([Quantities and measurements](https://lastrada-software.github.io/formula-cpp/quantities/), [Missing and entered values](https://lastrada-software.github.io/formula-cpp/tutorial/04-missing-and-entered/)) +- **The method's own rounding, constraints and tables**, as part of the formula. ([Rounding and conditionals](https://lastrada-software.github.io/formula-cpp/rounding-and-conditionals/), [Constraints](https://lastrada-software.github.io/formula-cpp/constraints/), [Lookup tables](https://lastrada-software.github.io/formula-cpp/lookup-tables/)) +- **Traces** that show how every number was reached. ([Tracing](https://lastrada-software.github.io/formula-cpp/tracing/)) +- **Rendering and generated documentation**: text, LaTeX, symbol tables and citations from the same declaration. ([Citations and rendering](https://lastrada-software.github.io/formula-cpp/citations/)) -`explain()` evaluates a formula exactly as `evaluate()` does and also returns -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. -`examples/tracing.cpp` explains a road gradient, `s = h / L`, with a citation -attached, from a rise of 90 m over a run entered as 3 km: - -```cpp -auto const explained = formula::explain(gradient, 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 rendered = formula::render_trace(explained.trace, { .maxSteps = 10 }); -std::print("{}", rendered); -``` - -``` -1. h = 90 m -2. L = 3 km -3. #1 / #2 = 3/100 -4. #3 = 3/100 [Road gradient, Example Standard 1:2020, 5.4.2, (3)] -``` - -Every value is shown in the unit written after it: an input in the unit it was -declared in, not the coherent unit the arithmetic actually ran on — the run is -declared in kilometres, so it reads `3 km`, though the division worked on -3000 m. A computed value borrows the unit of the values it was computed from -where that is safe, and is otherwise shown in the coherent unit, spelt from the -base units (`kg/m^3`); only a dimensionless value, like the gradient above, is -a bare number. When the environment overrides the result instead of letting the -formula derive it, `explained.trace` comes back empty — nothing ran, so nothing -was recorded — and `explained.outcome.is_overridden()` says so instead: an -overridden number shows *that a person entered it*, a different fact from how -it was reached and arguably a more important one. Tracing costs nothing when -nobody asks for it: a sink is passed by value, and the untraced path — -`evaluate()`, `checked_evaluate()` — defaults to one that does nothing, adding -no instruction the evaluator would not already emit once the call inlines, -measured on all four compilers this library targets. See [the tracing -guide](docs/tracing.md). - -### A published table that a value falls outside of gives no number at all - -```cpp -inline constexpr formula::BandTable<3> SizeBands { - 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 -}; -``` - -``` -1. d = 211 mm -2. lookup(#1) = argument outside the domain of the operation [in no band; the bands cover 0 to under 211 mm] -``` - -Not zero, not the nearest band, not the last row. A method that defined no -correction at 211 mm has defined none, and inventing one would put a number in -a test report that nothing downstream could tell apart from a number the method -actually published. A table with a **gap** in it does not even compile, and the -diagnostic names the two rows that do not meet. Three table kinds — banded, -exact and interpolating — are covered in -[the lookup-tables guide](docs/lookup-tables.md). - -## What this is for - -Test-method standards are written as prose with formulas in them, and software -that implements them usually ends up with the formula in one place, its units -in another, its provenance in a comment, and its audit trail bolted on -afterwards. Those four drift apart. - -Here they are one declaration. The formula is the documentation is the audit -trail. A reviewer reading a report can be shown the equation, the clause it -came from, the values that went in, and whether a human overrode the result — -because all of it came from the same line of code. - -**No macros.** None of this is preprocessor machinery. - -## Documentation - -**** — guides and the generated API reference. - -| Guide | What it covers | -|---|---| -| [Exact numbers](docs/numbers.md) | `Rational`, the rounding modes, why exactness is the default | -| [Dimensions and units](docs/dimensions.md) | Compile-time dimensional analysis, exact unit conversion, and base dimensions the SI does not have, such as money | -| [Quantities](docs/quantities.md) | Declaring a quantity, `Describe`, measurements that may be absent | -| [Writing formulas](docs/expressions.md) | Operators, evaluation, environments, overrides, and logarithms and exponentials, exact or rounded to declared places | -| [Citations and rendering](docs/citations.md) | `documented()`, the three dialects, generated documentation | -| [Tracing and audit trails](docs/tracing.md) | `explain()`, `render_trace()`, sinks, and the zero-cost untraced path | -| [Displaying numbers](docs/display.md) | Decimals in traces and rendered formulas, exact unless an approximation is asked for, std::format for Rational and Measured, and values the exact layer cannot hold, written as the rounding their formula declares | -| [Calculations and worksheets](docs/calculations.md) | Named values defined by expressions, a dependency graph checked at compile time, a worksheet that recalculates only what a change reaches, what-if copies, overrides, and a derivation per named value | -| [Rounding and conditionals](docs/rounding-and-conditionals.md) | Rounding as a node, `when()`, and the traced `numeric_value_of` escape hatch | -| [Constraints and verdicts](docs/constraints.md) | Validating a result with `constraint()` and `check()`, the four-state outcome, and checking a set without short-circuit | -| [Lookup tables](docs/lookup-tables.md) | The three table kinds, validation that refuses a gap, and why a miss is not a number | -| [Methods and overlays](docs/methods-and-overlays.md) | Variants selected by tag, a method's own rounding rule and constraints, jurisdiction overlays and their provenance in the trace, a jurisdiction's own acceptance logic, and jurisdiction-scoped vocabularies | -| [Series and grading curves](docs/series.md) | One quantity at each point of a method's domain, the index marker, elementwise arithmetic, absence and failure per element, conformity against a limit envelope, snapping, grading curves and splicing, and binning raw observations | -| [Statistics, outliers and precision](docs/statistics.md) | Counts, means, variances and ranges of a sample -- a series or raw observations -- the spread rounded exactly, outliers rejected pass by pass with the author's verdict on an abort, critical values from the author's table, and precision limits at the level they check | -| [Other samples and other tests](docs/records.md) | Reading from a reference sample or a prior test by role, computing over another specimen, the record each value came from in the trace, lineage as a gate, and a record not yet made | -| [Opaque operations and bounded retry](docs/opaque-and-retry.md) | A named operation such as a least-squares line through a curve, or through raw observations with R², and a regression on several regressors, traced by its inputs and outputs with its inside marked as not shown, and a step repeated until it is accepted, at most a fixed number of times, ending in exactly one of six ways -- the method's verdict when it runs out | -| [Gallery](docs/gallery.md) | A documentation page the library generated about itself | - -Every citation of a standard in the documentation is an invented `Example Standard`. Real -standards are copyrighted, so none of their content appears in this repository. The one real -reference, in `examples/cycling_speed.cpp`, cites a published paper by its title, authors, -journal and year only, and quotes none of its text. - -Each guide has a matching runnable program under `examples/`. - -## Status - -0.4.0 is the latest release ([CHANGELOG](CHANGELOG.md)). Usable for what is listed as shipped, -and still growing. The public API may change until 1.0. - -| Area | State | -|---|---| -| Exact rational arithmetic, rounding modes | shipped | -| Dimensions with rational exponents, units, exact conversion | shipped | -| Quantities, metadata, absent measurements | shipped | -| Formulas, operators, environments, evaluation | shipped | -| Citations, rendering dialects, generated documentation | shipped | -| Calculation tracing and audit trails | shipped | -| Rounding nodes (decimal places, significant digits), conditionals (`when()`) | shipped | -| Constraints, verdicts, checking a set without short-circuit | shipped | -| Lookup tables: banded, exact and interpolating | shipped | -| Methods: variants, rounding rules, constraints, jurisdiction overlays, vocabularies | shipped | -| Series and grading curves, binning | shipped | -| Statistics, precision limits, outlier rejection | shipped | -| Other samples and other tests: records, context, lineage | shipped | -| Opaque operations (least squares), bounded retry | shipped | -| Values the exact layer cannot hold, reported at a declared precision (`rounded_output`) | shipped | -| Logarithms and exponentials, rounded exactly to declared places | shipped | -| Least squares over observations, with R², and several regressors | shipped | -| Power, energy and Fahrenheit units | shipped | -| Named base dimensions such as money | shipped | -| Decimals in traces, rendered formulas and `std::format` | shipped | -| Calculations: definitions, dependency graph, incremental worksheets | shipped | -| Short spellings: exact decimal literals, bound formulas, `number_of`, `trace_of`, `std::format` of results | shipped | -| 128-bit exact numbers, and a unit named on every value a trace or formula writes | shipped | - -## Requirements - -- 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. The other compilers' minimum versions are not settled yet; earlier ones may -work but are untested. +New to the library? Start with the [tutorial](https://lastrada-software.github.io/formula-cpp/tutorial/). ## Installation -### vcpkg +### CPM -The primary consumption path. +```cmake +CPMAddPackage("gh:LASTRADA-Software/formula-cpp@0.4.0") +target_link_libraries(your_target PRIVATE formula-cpp::formula-cpp) +``` ### CMake, from an install tree @@ -487,8 +94,6 @@ cmake -S . -B build -DFORMULA_INSTALL=ON -DCMAKE_INSTALL_PREFIX=/your/prefix cmake --install build ``` -Then, in the consuming project: - ```cmake find_package(formula-cpp CONFIG REQUIRED) target_link_libraries(your_target PRIVATE formula-cpp::formula-cpp) @@ -497,20 +102,18 @@ target_link_libraries(your_target PRIVATE formula-cpp::formula-cpp) ### Copy the headers `include/` is self-contained and depends on nothing outside the standard library. +`formula.hpp` is the umbrella header. `format.hpp`, `render.hpp`, `document.hpp`, `trace.hpp` and +`trace_render.hpp` are separate, because they need ``, `` or ``: include +them by name when you print, render, document or trace. -`formula.hpp` is the umbrella header. `render.hpp`, `document.hpp`, `trace.hpp` and -`trace_render.hpp` are deliberately left out of it: they need `` and/or ``, and a -consumer who only evaluates numbers should not compile those into every translation unit. Include -them by name when you want text, or a trace, or both — see -[the tracing guide](docs/tracing.md) for `trace.hpp` and `trace_render.hpp` specifically. +## Requirements -`test/consumer_globals_tests.cpp` declares 258 ordinary globals such as `result`, `value`, `x` and -`index` before including every header, and builds under cl `/W4 /WX` and g++ `-Wshadow -Werror`: -no header's local or parameter hides one of them in anything that test instantiates -- evaluation -of every node kind, `render`, `document` and the trace in every dialect, constraints, methods and -every overlay operation (the test lists them). cl reports a template's local only in a template -that is instantiated, and never a function template's parameter, so a template the test does not -reach is not covered by it. +- C++23 +- CMake 3.23 or newer +- GCC 14 or newer, if you build with GCC + +CI builds and tests every push to master and every pull request with MSVC `cl` and `clang-cl` +on Windows, Clang and GCC 14 on Linux, and AppleClang on macOS. ## Build options @@ -524,6 +127,10 @@ reach is not covered by it. | `FORMULA_PEDANTIC` | ON | Strict warnings on the project's own targets | | `FORMULA_WERROR` | OFF | Treat warnings as errors | +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md). + ## Licence Apache-2.0. See [`LICENSE`](LICENSE). diff --git a/cmake/CheckDocsCurrentState.cmake b/cmake/CheckDocsCurrentState.cmake new file mode 100644 index 00000000..86bc34b7 --- /dev/null +++ b/cmake/CheckDocsCurrentState.cmake @@ -0,0 +1,234 @@ +# SPDX-License-Identifier: Apache-2.0 +# User documentation describes the library as it is now. What it did before, +# what changed and in which release belongs in CHANGELOG.md, and nowhere else: +# a reader who meets "no longer compiles" or "new in 0.4.0" in a guide is told +# about a library they never used, and the sentence goes stale with the next +# release. This check refuses that wording. +# +# Scanned: README.md, every docs/**/*.md except docs/superpowers/ (plans, not +# user documentation), and the comment lines of include/**/*.hpp -- a line +# whose first non-blank characters are `//`, `/*` or `*`. Code lines are not +# scanned: an identifier is not prose. +# +# Each scanned line is lower-cased and searched for these phrases, each as +# whole words: +# +# - no longer, previously, formerly, once meant, was renamed, deprecated, +# new in; +# - used to, except after is, are, was, were, be, been or being: "the factor +# is used to round" is the passive, not history; +# - before ... existed, within one sentence; +# - a release reference: since or as of and a version of two or three parts +# ("since 0.3"), or in, until, before or after and a version of three parts +# ("in 0.4.0"). A two-part number after "in" is a measurement, "in 2.5 mm", +# and is not matched. +# +# Every match is reported as `:: `, the path relative to +# SOURCE_DIR with forward slashes, sorted by file and then by line. +# +# The allow-list, cmake/docs-current-state-allowlist.txt (or the file ALLOWLIST +# names), holds the lines that match a phrase but describe data rather than +# history -- "the two determinations no longer agree" -- one per line: +# +# || +# +# Lines starting with `#` and blank lines are ignored; an entry without a +# reason is an error. An absent allow-list is an empty one. An entry allows the +# line it spells, every phrase in it, and nothing else. An entry that allows no +# reported line -- the line was reworded, or the path is wrong -- is an error +# too, so that the list holds only places the check really does not look. +# +# A scan that examined no file fails: a check that examines nothing is a check +# that lies. +# +# Nothing here becomes a CMake list: the lines hold `[`, `]` and `;`, each of +# which CMake's list handling mangles. Text is walked with string(FIND) and +# string(SUBSTRING) only, as CheckGuideOutput.cmake does. + +# A script run with `cmake -P` starts with every policy at its OLD setting; the +# project's own minimum sets them all (see CheckGuideOutput.cmake for what the +# old CMP0012 did to `while(TRUE)`). +cmake_minimum_required(VERSION 3.23) + +if(NOT DEFINED SOURCE_DIR) + message(FATAL_ERROR "CheckDocsCurrentState.cmake: SOURCE_DIR is not set") +endif() +get_filename_component(SOURCE_DIR "${SOURCE_DIR}" ABSOLUTE) +if(NOT DEFINED ALLOWLIST) + set(ALLOWLIST "${SOURCE_DIR}/cmake/docs-current-state-allowlist.txt") +endif() + +set(files "") +if(EXISTS "${SOURCE_DIR}/README.md") + list(APPEND files "README.md") +endif() +file(GLOB_RECURSE docs RELATIVE "${SOURCE_DIR}" "${SOURCE_DIR}/docs/*.md") +list(FILTER docs EXCLUDE REGEX "^docs/superpowers/") +file(GLOB_RECURSE headers RELATIVE "${SOURCE_DIR}" "${SOURCE_DIR}/include/*.hpp") +list(APPEND files ${docs} ${headers}) +list(SORT files) +list(LENGTH files scanned) +if(scanned EQUAL 0) + message(FATAL_ERROR + "docs current-state check examined no files. SOURCE_DIR=\"${SOURCE_DIR}\" is wrong, or it holds no " + "README.md, docs/**/*.md or include/**/*.hpp. A check that examines nothing is a check that lies.") +endif() + +# The allowed lines, each as `|` followed by a newline, after a +# leading newline: one string, searched with string(FIND). +set(allowed "\n") +if(EXISTS "${ALLOWLIST}") + file(READ "${ALLOWLIST}" rest) + string(REPLACE "\r\n" "\n" rest "${rest}") + set(allowLineNumber 0) + while(NOT rest STREQUAL "") + string(FIND "${rest}" "\n" newline) + if(newline EQUAL -1) + set(entry "${rest}") + set(rest "") + else() + string(SUBSTRING "${rest}" 0 ${newline} entry) + math(EXPR next "${newline} + 1") + string(SUBSTRING "${rest}" ${next} -1 rest) + endif() + math(EXPR allowLineNumber "${allowLineNumber} + 1") + string(STRIP "${entry}" entry) + if(entry STREQUAL "" OR entry MATCHES "^#") + continue() + endif() + # The line itself may hold `|` (a table row): the path ends at the + # first `|`, the reason starts after the last. + string(FIND "${entry}" "|" first) + string(FIND "${entry}" "|" last REVERSE) + set(reason "") + if(NOT first EQUAL last) + math(EXPR reasonStart "${last} + 1") + string(SUBSTRING "${entry}" ${reasonStart} -1 reason) + string(STRIP "${reason}" reason) + endif() + if(reason STREQUAL "") + message(FATAL_ERROR + "docs current-state check: ${ALLOWLIST}:${allowLineNumber} has no reason; write it as " + "`||`.") + endif() + string(SUBSTRING "${entry}" 0 ${first} allowedPath) + string(STRIP "${allowedPath}" allowedPath) + math(EXPR lineStart "${first} + 1") + math(EXPR lineLength "${last} - ${lineStart}") + string(SUBSTRING "${entry}" ${lineStart} ${lineLength} allowedLine) + string(STRIP "${allowedLine}" allowedLine) + string(APPEND allowed "${allowedPath}|${allowedLine}\n") + endwhile() +endif() + +# The phrases, as one alternation, each bounded by a character that is neither +# a letter nor a digit: "new in" is not "new instance", "used to" is not "used +# together". `_` bounds a phrase, so that Markdown emphasis, `_no longer_` or +# `__deprecated__`, does not hide one. The whole file is searched at once, so +# nothing may cross a line. +set(phrases "no longer|previously|formerly|once meant|was renamed|deprecated|new in|used to") +string(APPEND phrases "|before [^.\n]* existed") +string(APPEND phrases "|(since|as of) v?[0-9]+\\.[0-9]+(\\.[0-9]+)?") +string(APPEND phrases "|(in|until|before|after) v?[0-9]+\\.[0-9]+\\.[0-9]+") +set(phrasePattern "[^a-z0-9](${phrases})[^a-z0-9]") + +set(offences "") +set(offenceCount 0) +# The allow-list entries that allowed a reported line, in the form `allowed` +# holds them. +set(allowedUsed "\n") +foreach(relative IN LISTS files) + file(READ "${SOURCE_DIR}/${relative}" original) + string(REPLACE "\r\n" "\n" original "${original}") + # A newline either side, so that a phrase at the start or end of the file + # has a character to bound it, and so that the newlines before a phrase + # count its line from one. + set(original "\n${original}\n") + string(TOLOWER "${original}" rest) + set(isHeader FALSE) + if(relative MATCHES "\\.hpp$") + set(isHeader TRUE) + endif() + + # `rest` is the lower-cased text from offset `consumed` of `original`; + # lower-casing leaves every offset where it was. + set(consumed 0) + set(lineNumber 0) + set(countedTo 0) + while(rest MATCHES "${phrasePattern}") + set(phrase "${CMAKE_MATCH_1}") + string(FIND "${rest}" "${CMAKE_MATCH_0}" at) + math(EXPR next "${at} + 1") + string(SUBSTRING "${rest}" ${next} -1 rest) + math(EXPR consumed "${consumed} + ${next}") + # `rest` now starts at the phrase, at offset `consumed` of `original`. + + math(EXPR span "${consumed} - ${countedTo}") + string(SUBSTRING "${original}" ${countedTo} ${span} between) + string(REGEX REPLACE "[^\n]+" "" between "${between}") + string(LENGTH "${between}" newlines) + math(EXPR lineNumber "${lineNumber} + ${newlines}") + set(countedTo ${consumed}) + + string(SUBSTRING "${original}" 0 ${consumed} head) + string(FIND "${head}" "\n" lineStart REVERSE) + math(EXPR lineStart "${lineStart} + 1") + string(FIND "${rest}" "\n" lineEnd) + math(EXPR lineLength "${consumed} + ${lineEnd} - ${lineStart}") + string(SUBSTRING "${original}" ${lineStart} ${lineLength} line) + + if(isHeader AND NOT line MATCHES "^[ \t]*(//|/\\*|\\*)") + continue() + endif() + if(phrase STREQUAL "used to") + math(EXPR column "${consumed} - ${lineStart}") + string(SUBSTRING "${line}" 0 ${column} before) + string(TOLOWER "${before}" before) + if(before MATCHES "(^|[^a-z0-9])(is|are|was|were|be|been|being)[ \t]+$") + continue() + endif() + endif() + string(STRIP "${line}" trimmed) + string(FIND "${allowed}" "\n${relative}|${trimmed}\n" allowedAt) + if(NOT allowedAt EQUAL -1) + string(APPEND allowedUsed "${relative}|${trimmed}\n") + continue() + endif() + + string(APPEND offences "\n ${relative}:${lineNumber}: ${phrase}") + math(EXPR offenceCount "${offenceCount} + 1") + endwhile() +endforeach() + +# Every allow-list entry must have allowed a reported line. +set(unused "") +string(SUBSTRING "${allowed}" 1 -1 rest) +while(NOT rest STREQUAL "") + string(FIND "${rest}" "\n" newline) + string(SUBSTRING "${rest}" 0 ${newline} entry) + math(EXPR next "${newline} + 1") + string(SUBSTRING "${rest}" ${next} -1 rest) + string(FIND "${allowedUsed}" "\n${entry}\n" usedAt) + if(usedAt EQUAL -1) + string(APPEND unused "\n ${entry}") + endif() +endwhile() + +set(problems "") +if(offenceCount GREATER 0) + string(APPEND problems + "${offenceCount} history phrases in user documentation:${offences}\n" + "User documentation describes the library as it is now; say what it does today, or record the change in " + "CHANGELOG.md. A line that describes data rather than history goes in cmake/docs-current-state-allowlist.txt " + "with its reason.\n") +endif() +if(NOT unused STREQUAL "") + string(APPEND problems + "allow-list entries in ${ALLOWLIST} that allow no reported line -- the line was reworded, or the path is " + "wrong; remove them:${unused}\n") +endif() +if(NOT problems STREQUAL "") + message(FATAL_ERROR "docs current-state check: ${problems}") +endif() + +message(STATUS "docs current-state check: ${scanned} files scanned, no history wording") diff --git a/cmake/CheckDocumentedDiagnosticText.cmake b/cmake/CheckDocumentedDiagnosticText.cmake index 4dcb9344..80b0688b 100644 --- a/cmake/CheckDocumentedDiagnosticText.cmake +++ b/cmake/CheckDocumentedDiagnosticText.cmake @@ -76,7 +76,11 @@ set(opening "formula: ") set(checked 0) set(problems "") -file(GLOB documents "${SOURCE_DIR}/docs/*.md") +# Every page of the documentation, the tutorial's included, but not the +# internal planning documents under docs/superpowers/, which the site does not +# publish (mkdocs.yml, exclude_docs). +file(GLOB_RECURSE documents "${SOURCE_DIR}/docs/*.md") +list(FILTER documents EXCLUDE REGEX "/docs/superpowers/") foreach(document IN LISTS documents) file(RELATIVE_PATH documentName "${SOURCE_DIR}" "${document}") file(READ "${document}" rest) diff --git a/cmake/CheckDocumentedDiagnostics.cmake b/cmake/CheckDocumentedDiagnostics.cmake index 7190c3d5..5e246382 100644 --- a/cmake/CheckDocumentedDiagnostics.cmake +++ b/cmake/CheckDocumentedDiagnostics.cmake @@ -189,7 +189,11 @@ endif() set(problems "") set(checked 0) -file(GLOB documents "${SOURCE_DIR}/docs/*.md") +# Every page of the documentation, the tutorial's included, but not the +# internal planning documents under docs/superpowers/, which the site does not +# publish (mkdocs.yml, exclude_docs). +file(GLOB_RECURSE documents "${SOURCE_DIR}/docs/*.md") +list(FILTER documents EXCLUDE REGEX "/docs/superpowers/") if(documents STREQUAL "") message(FATAL_ERROR "CheckDocumentedDiagnostics.cmake: found no documents under ${SOURCE_DIR}/docs") endif() diff --git a/cmake/CheckExpectedOutput.cmake b/cmake/CheckExpectedOutput.cmake new file mode 100644 index 00000000..93674ece --- /dev/null +++ b/cmake/CheckExpectedOutput.cmake @@ -0,0 +1,59 @@ +# SPDX-License-Identifier: Apache-2.0 +# A program whose output a page includes must print exactly that output. The +# page includes `.expected.txt` verbatim (pymdownx.snippets), so this +# is the check that makes the published output the program's real output: it +# runs the program and compares its standard output with that file, whole, +# byte for byte. +# +# Line endings are normalised on both sides first: std::println writes CRLF on +# Windows, and .gitattributes checks text files out with LF. Nothing else is +# normalised -- a trailing space, a missing final newline or a changed line +# is a difference. +# +# On a difference it fails naming the file, and prints both texts, so the fix +# -- the program or the expected file -- can be chosen by reading them. +# +# `cmake_minimum_required` for the reason CheckGuideOutput.cmake gives: under +# `cmake -P`'s default OLD policies, CMake 3.28.3 misreads `if()` constructs +# the project's own minimum reads correctly. +cmake_minimum_required(VERSION 3.23) + +foreach(variable EXAMPLE_EXE EXPECTED) + if(NOT DEFINED ${variable}) + message(FATAL_ERROR "CheckExpectedOutput.cmake: ${variable} is not set") + endif() +endforeach() + +if(NOT EXISTS "${EXPECTED}") + message(FATAL_ERROR "CheckExpectedOutput.cmake: ${EXPECTED} does not exist") +endif() + +# ENCODING UTF-8: on Windows, without it, the output is decoded by the console +# code page while the expected file is read as raw bytes, so a program printing +# `≈`, `²` or `µ` would falsely mismatch. +execute_process(COMMAND "${EXAMPLE_EXE}" + ENCODING UTF-8 + OUTPUT_VARIABLE actual + ERROR_VARIABLE errors + RESULT_VARIABLE exitCode) +if(NOT exitCode EQUAL 0) + message(FATAL_ERROR "CheckExpectedOutput.cmake: ${EXAMPLE_EXE} exited with ${exitCode}:\n${errors}") +endif() + +file(READ "${EXPECTED}" expected) +if(expected STREQUAL "") + message(FATAL_ERROR + "CheckExpectedOutput.cmake: ${EXPECTED} is empty. An empty expected output matches only a " + "program that prints nothing, which no page includes.") +endif() + +string(REPLACE "\r\n" "\n" actual "${actual}") +string(REPLACE "\r\n" "\n" expected "${expected}") + +if(NOT actual STREQUAL expected) + message(FATAL_ERROR + "CheckExpectedOutput.cmake: the output of ${EXAMPLE_EXE} differs from ${EXPECTED}.\n" + "--- expected\n${expected}--- actual\n${actual}--- end") +endif() + +message(STATUS "CheckExpectedOutput.cmake: output matches ${EXPECTED}") diff --git a/cmake/CheckVersionConsistency.cmake b/cmake/CheckVersionConsistency.cmake index 3f34cc96..2b1fff45 100644 --- a/cmake/CheckVersionConsistency.cmake +++ b/cmake/CheckVersionConsistency.cmake @@ -26,4 +26,25 @@ if(NOT CMAKE_MATCH_1 STREQUAL fromHeader) "version drift: FORMULA_VERSION_STRING is \"${CMAKE_MATCH_1}\", macros say ${fromHeader}") endif() +# The README and the tutorial tell a consumer which release to fetch with CPM. +# A version bump that leaves either behind sends every new user to an old +# release, so each must name exactly this version, at least once, and never +# another. +foreach(document IN ITEMS README TUTORIAL) + if(NOT DEFINED ${document}) + message(FATAL_ERROR "CheckVersionConsistency.cmake: ${document} is not set") + endif() + file(READ "${${document}}" text) + string(REGEX MATCHALL "gh:LASTRADA-Software/formula-cpp@[0-9]+\\.[0-9]+\\.[0-9]+" tags "${text}") + if(NOT tags) + message(FATAL_ERROR "version drift: ${${document}} names no CPM tag gh:LASTRADA-Software/formula-cpp@") + endif() + foreach(tag IN LISTS tags) + string(REGEX REPLACE "^.*@" "" tagVersion "${tag}") + if(NOT tagVersion STREQUAL fromHeader) + message(FATAL_ERROR "version drift: ${${document}} fetches ${tagVersion} with CPM, the project is ${fromHeader}") + endif() + endforeach() +endforeach() + message(STATUS "version consistent: ${fromHeader}") diff --git a/cmake/TestDocsCurrentState.cmake b/cmake/TestDocsCurrentState.cmake new file mode 100644 index 00000000..aa159355 --- /dev/null +++ b/cmake/TestDocsCurrentState.cmake @@ -0,0 +1,122 @@ +# SPDX-License-Identifier: Apache-2.0 +# The current-state check must fail on history wording, name every offence, +# and accept a line its allow-list names. This builds a small tree in WORK_DIR +# and runs the check on it, so the check is tested on text written to break it, +# not only on a tree that happens to pass. +cmake_minimum_required(VERSION 3.23) + +foreach(variable CHECK_SCRIPT WORK_DIR) + if(NOT DEFINED ${variable}) + message(FATAL_ERROR "TestDocsCurrentState.cmake: ${variable} is not set") + endif() +endforeach() + +# Runs the check on `sourceDir` with the allow-list `allowList` (none when +# empty), and sets `exitCode` and `report`, and `flatReport`: the report with +# every run of whitespace made one space, because CMake wraps a message's prose +# wherever the line grows long, and the paths in it differ in length per tree. +function(run_check sourceDir allowList) + set(arguments -D "SOURCE_DIR=${sourceDir}") + if(NOT allowList STREQUAL "") + list(APPEND arguments -D "ALLOWLIST=${allowList}") + endif() + execute_process(COMMAND "${CMAKE_COMMAND}" ${arguments} -P "${CHECK_SCRIPT}" + RESULT_VARIABLE result OUTPUT_VARIABLE out ERROR_VARIABLE err) + string(REGEX REPLACE "[ \t\r\n]+" " " flat "${out}${err}") + set(exitCode "${result}" PARENT_SCOPE) + set(report "${out}${err}" PARENT_SCOPE) + set(flatReport "${flat}" PARENT_SCOPE) +endfunction() + +file(REMOVE_RECURSE "${WORK_DIR}") +# CRLF line endings, and a first line holding `;`, `[` and `]`, which a CMake +# list would mangle: the phrase must still be reported on line 2. +file(WRITE "${WORK_DIR}/README.md" "x; [a]\r\nIt no longer compiles.\r\n") +file(WRITE "${WORK_DIR}/docs/guide.md" + "This was previously a warning.\n" + "The two determinations no longer agree.\n" + "New in 0.4.0: literals.\n" + "The factor is used to round the strength, in 2.5 mm steps.\n" + "It used to return a double.\n" + "The flag once meant the opposite.\n" + "The type was renamed.\n" + "The overload is deprecated.\n" + "Text written before the option existed.\n" + "Since 0.3 and as of v0.2 it rounds; until 1.2.3 it did not.\n" + "This is _no longer_ true.\n" + "A new instance, used together, within 1.2.3 of it.\n" + "The value is\tused to round.\n") +file(WRITE "${WORK_DIR}/docs/superpowers/plan.md" "This used to be ignored, and is.\n") +file(WRITE "${WORK_DIR}/include/formula-cpp/x.hpp" + "/// Formerly named old_name.\n" + "int previously_named = 0; // code, not a comment line: not scanned\n") +file(WRITE "${WORK_DIR}/allow.txt" + "docs/guide.md|The two determinations no longer agree.|describes data, not history\n") + +run_check("${WORK_DIR}" "${WORK_DIR}/allow.txt") +if(exitCode EQUAL 0) + message(FATAL_ERROR "the check passed a tree full of history wording:\n${report}") +endif() +foreach(expected IN ITEMS "README.md:2: no longer" "docs/guide.md:1: previously" "docs/guide.md:3: new in" + "docs/guide.md:3: in 0.4.0" "docs/guide.md:5: used to" "docs/guide.md:6: once meant" + "docs/guide.md:7: was renamed" "docs/guide.md:8: deprecated" + "docs/guide.md:9: before the option existed" "docs/guide.md:10: since 0.3" + "docs/guide.md:10: as of v0.2" "docs/guide.md:10: until 1.2.3" + "docs/guide.md:11: no longer" "include/formula-cpp/x.hpp:1: formerly") + string(FIND "${report}" "${expected}" at) + if(at EQUAL -1) + message(FATAL_ERROR "the check did not report '${expected}':\n${report}") + endif() +endforeach() +foreach(unexpected IN ITEMS "README.md:1:" "docs/guide.md:2:" "docs/guide.md:4:" "docs/guide.md:12:" + "docs/guide.md:13:" "docs/superpowers/" "x.hpp:2:") + string(FIND "${report}" "${unexpected}" at) + if(NOT at EQUAL -1) + message(FATAL_ERROR "the check reported '${unexpected}', which it must not:\n${report}") + endif() +endforeach() + +# An allow-list entry without a reason is refused. +file(WRITE "${WORK_DIR}/no-reason.txt" "docs/guide.md|x\n") +run_check("${WORK_DIR}" "${WORK_DIR}/no-reason.txt") +string(FIND "${flatReport}" "has no reason" at) +if(exitCode EQUAL 0 OR at EQUAL -1) + message(FATAL_ERROR "the check did not refuse an allow-list entry without a reason:\n${report}") +endif() + +# A directory with nothing to scan fails: a check that examines nothing lies. +file(MAKE_DIRECTORY "${WORK_DIR}/empty") +run_check("${WORK_DIR}/empty" "") +string(FIND "${flatReport}" "examined no files" at) +if(exitCode EQUAL 0 OR at EQUAL -1) + message(FATAL_ERROR "the check did not fail on a directory with nothing to scan:\n${report}") +endif() + +# With every offending line removed, the same tree passes. +file(WRITE "${WORK_DIR}/README.md" "A Bounds is written with designated initialisers.\n") +file(WRITE "${WORK_DIR}/docs/guide.md" "The two determinations no longer agree.\n") +file(WRITE "${WORK_DIR}/include/formula-cpp/x.hpp" "/// Named new_name.\n") + +# An allow-list entry that allows no reported line -- a reworded line, a wrong +# path -- fails the clean tree, and is named; the entry still in use is not. +file(WRITE "${WORK_DIR}/stale.txt" + "docs/guide.md|The two determinations no longer agree.|describes data, not history\n" + "docs/gide.md|The two determinations no longer agree.|a typo in the path\n") +run_check("${WORK_DIR}" "${WORK_DIR}/stale.txt") +if(exitCode EQUAL 0) + message(FATAL_ERROR "the check passed an allow-list entry that allows no line:\n${report}") +endif() +string(FIND "${report}" " docs/gide.md|The two determinations no longer agree." at) +if(at EQUAL -1) + message(FATAL_ERROR "the check did not name the allow-list entry that allows no line:\n${report}") +endif() +string(FIND "${report}" " docs/guide.md|" at) +if(NOT at EQUAL -1) + message(FATAL_ERROR "the check named an allow-list entry that is in use:\n${report}") +endif() + +run_check("${WORK_DIR}" "${WORK_DIR}/allow.txt") +if(NOT exitCode EQUAL 0) + message(FATAL_ERROR "the check failed a clean tree:\n${report}") +endif() +message(STATUS "TestDocsCurrentState: the check fails on history wording and passes a clean tree") diff --git a/cmake/docs-current-state-allowlist.txt b/cmake/docs-current-state-allowlist.txt new file mode 100644 index 00000000..0ecd0b46 --- /dev/null +++ b/cmake/docs-current-state-allowlist.txt @@ -0,0 +1,8 @@ +# Lines in user documentation that match a history phrase but describe the +# library as it is now, read by cmake/CheckDocsCurrentState.cmake. One per line: +# +# || +# +# Keep it short: every entry is a place the check does not look. + +docs/statistics.md|40 g, the limit 0.9 g, and the same two determinations no longer agree:|compares two computations on one set of data: with the level rounded first, the two determinations fail the limit they meet unrounded diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..22b4edbf --- /dev/null +++ b/codecov.yml @@ -0,0 +1,17 @@ +# SPDX-License-Identifier: Apache-2.0 +# Coverage is reported on every pull request but never blocks one. +coverage: + status: + project: + default: + informational: true + patch: + default: + informational: true +ignore: + - "test/**" + - "examples/**" + - "tools/**" + - "support/**" + - "**/_deps/**" + - ".cache/**" diff --git a/docs/calculations.md b/docs/calculations.md index bacf5f33..4ea10983 100644 --- a/docs/calculations.md +++ b/docs/calculations.md @@ -616,7 +616,7 @@ net draw typed in: total 89.37 EUR, net draw 250 kWh, recomputed 5, reus The value typed in stands in place of the calculated one: what is built on it is calculated again from it -- five values, for 89.37 EUR -- its source reads -`ManuallyEntered`, and what the net draw was calculated from is no longer +`ManuallyEntered`, and what the net draw was calculated from is not read. `is_overridden()` says whether it stands. The grid cost's derivation reads it as typed in, and the net draw's own block is one line saying what it stands in place of. Cut short at five lines, the derivation diff --git a/docs/citations.md b/docs/citations.md index 22d45a5d..9577e637 100644 --- a/docs/citations.md +++ b/docs/citations.md @@ -71,12 +71,12 @@ sparse citation: title "A height", reference empty: yes The braced designated initialiser on the second argument works because that argument is a plain `formula::Citation`, not a deduced template parameter -- -verified on cl 19.51, clang-cl 22 and g++ 13.3 before `documented()` was -written this way. And because every field is `std::string_view` rather than -an owning string, a `Citation` built entirely from string literals -- as -every example in this repository is -- is usable inside a `constexpr` tree -for free. A citation built from a runtime `std::string` is legal too, but -that string must outlive every node holding the view onto it. +verified on cl 19.51, clang-cl 22 and g++ 13.3. And because every field is +`std::string_view` rather than an owning string, a `Citation` built entirely +from string literals -- as every example in this repository is -- is usable +inside a `constexpr` tree for free. A citation built from a runtime +`std::string` is legal too, but that string must outlive every node holding +the view onto it. ## Why the wrapper is invisible to arithmetic @@ -416,9 +416,9 @@ Three things a vocabulary does not do: opt in (`"a consumer's two-argument render_node receives the vocabulary"`). A node of yours that derives from one of the library's -- `struct Labelled: formula::VarNode` -- keeps its own one-argument - `render_node`, as before, rather than rendering as the node it derives - from; to receive the vocabulary it defines the two-argument form instead - of the one-argument one, not beside it. + `render_node`, rather than rendering as the node it derives from; to + receive the vocabulary it defines the two-argument form instead of the + one-argument one, not beside it. A vocabulary renaming one quantity twice does not compile, and neither does `renames("")`, which would leave a blank where the quantity stands; nor diff --git a/docs/constraints.md b/docs/constraints.md index e3c4f3dc..c63c3079 100644 --- a/docs/constraints.md +++ b/docs/constraints.md @@ -329,7 +329,7 @@ then says beside each verdict that it was the jurisdiction's; see ## Every citation here is invented -Every citation used to demonstrate constraints on this page and in +Every citation that demonstrates constraints on this page and in `examples/constraints.cpp` names a fictional `Example Standard`, never a real one, for the reason [Citations and rendering](citations.md) gives in full: a real standard's clause numbers and thresholds are copyrighted diff --git a/docs/dimensions.md b/docs/dimensions.md index 96246e4c..5d23aa98 100644 --- a/docs/dimensions.md +++ b/docs/dimensions.md @@ -89,9 +89,9 @@ composed dimensions match the named constants: yes ``` Composing from constants rather than writing exponents by hand keeps the -representation swappable, and that has been put to the test: `Dimension` has -since grown past the seven SI base quantities, to hold the named base -dimensions described below, and not one of these call sites had to change. +representation swappable: `Dimension` holds more than the seven SI base +quantities -- the named base dimensions described below as well -- and a call +site composed from constants does not depend on how many there are. ## Rational exponents @@ -412,12 +412,11 @@ lowDenominator, highNumerator, highDenominator)` declares both, `formula::at_least(numerator, denominator)` a minimum only, and `formula::at_most(numerator, denominator)` a maximum only. Both ends are inclusive, and a unit that declares neither reports `NotChecked`. Each flag is -a `BoundsEnd`, which reads as a `bool` but only a `bool` sets, so a `Bounds` -written positionally before the two flags existed no longer compiles: in -`{ true, 0, 1, 100, 1 }`, once 0 to 100, the 0 lands on `highPresent`. Only -`{}` and `{ false }`, which declare no bounds, and `{ true }`, which once meant -0 to 0 and now declares a minimum of 0, still compile. Write `bounds()`, -`at_least()`, `at_most()` or designated initialisers. +a `BoundsEnd`, which reads as a `bool` but can be set only from a `bool`, so a +positional initialiser that puts a number where a flag belongs does not +compile: in `{ true, 0, 1, 100, 1 }` the 0 would land on `highPresent`. `{}` +and `{ false }` declare no bounds, and `{ true }` declares a minimum of 0. +Write `bounds()`, `at_least()`, `at_most()` or designated initialisers. Limits known only at run time -- a specification row, a catalogue entry -- need no unit to carry them. `formula::checked_within(value, lowEnd, highEnd)` takes diff --git a/docs/display.md b/docs/display.md index 57156b82..b956f692 100644 --- a/docs/display.md +++ b/docs/display.md @@ -50,9 +50,9 @@ value is **always marked**: `≈0.113`, never `0.113`, so it cannot pass for the exact one. Only a rounding you ask for outright, to a number of places (`decimal_text`, `std::format`'s `.N`), is written unmarked, as you asked. -Traces, rendered formulas and documentation keep fractions unless asked. Text -a program wrote before these options existed -- an archived audit trail, a -pinned test -- reads exactly as it did, unless the program asks for decimals. +Traces, rendered formulas and documentation keep fractions unless asked, so +the text a program writes without asking for decimals -- an archived audit +trail, a pinned test -- stays in fractions. ## Decimals in a trace diff --git a/docs/expressions.md b/docs/expressions.md index 7ead7019..81381ed2 100644 --- a/docs/expressions.md +++ b/docs/expressions.md @@ -596,9 +596,9 @@ constexpr auto earthworksCost = formula::yields( 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. 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 +they treat any other node. A formula bound to its result with `yields` is an +operand too, and stands for the formula it holds; the outer formula names its +own result with its own `yields` ([Naming the result once](#naming-the-result-once)). **Provenance travels upward through the seam.** The outer formula was never @@ -694,29 +694,38 @@ 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: +**`documented()` goes inside the binding.** A bound formula wraps a +documented formula, whose citation stays with the formula: ```cpp constexpr auto citedGradient = formula::yields(formula::documented( var / var, { .title = "Road gradient", .reference = "Example Standard 1:2020" })); ``` -`documented(yields(...), ...)` does not compile, because `documented` -takes a node. Nor does a bound formula go inside another bound formula: -`yields(boundGradient)` 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*. +The other order, `documented(yields(...), ...)`, documents the +formula the bound one holds and drops the binding: the result is a documented +formula, which names its result quantity as any formula does. +A bound formula is not bound again: `yields(boundGradient)` 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, 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 +**A bound formula is an operand.** Inside another formula -- an operand, a side +of a comparison, or what a function such as `sqrt`, `rounded` or `sum` is +given -- a bound formula stands for the formula it holds, with its type and +value ([Composing a formula from other formulas](#composing-a-formula-from-other-formulas)). -Here `OtherRise` and `OtherRun` are lengths in metres, as `Rise` and `Run` -are: +The outer formula's result is named by its own `yields`. The inner binding's +quantity is not part of the outer formula: its rendering and its trace show the +formula the bound one holds, not the quantity it names. Here `OtherRise` and +`OtherRun` are lengths in metres, as `Rise` and `Run` are: ```cpp // The height a climb of another length gains at the same gradient. -constexpr auto otherRise = formula::yields(var * boundGradient.expression); +constexpr auto otherRise = formula::yields(var * boundGradient); ``` + +`.expression` is the formula a bound formula holds. A verb given the bound +formula evaluates it for the quantity it names, so evaluating the same formula +for another quantity takes `.expression`: +`checked_evaluate(boundGradient.expression, climb)`. diff --git a/docs/index.md b/docs/index.md index b1eceb50..84ed9eab 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,178 +2,30 @@ Declarative, traceable, self-documenting formulas for C++23. Header-only, no dependencies. -Write a formula once, with ordinary operators. Get back a number, a rendering, and a -documentation page — from the same declaration. +Software that implements a test method usually keeps the formula in one place, its units in +another, where it comes from in a comment, and its audit trail in code written afterwards. Those +drift apart. In formula-cpp they are one declaration: write the formula once, with ordinary +operators, and get the number, its units, its derivation and its documentation from it. -How fast does a cyclist ride on 250 W? `examples/cycling_speed.cpp` works out -the steady-state speed from power. Each step is a formula, named for the -quantity it yields — a type carrying its own symbol, description and unit — -and the speed's formula carries its citation: +## Example -```cpp -// ---- The steps ---- -inline constexpr auto gradient = formula::yields(var / var); -inline constexpr auto totalMass = formula::yields(var + var); -inline constexpr auto resistingForce = - formula::yields(var * gravity * (var + var) ); -inline constexpr auto dragFactor = formula::yields(formula::Rational { 1, 2 } * var * var); -inline constexpr auto powerTerm = formula::yields(var / (2 * var) ); -inline constexpr auto forceTerm = formula::yields(var / (3 * var) ); - -// The speed, read from a and b by name, so its rendering stays one line. -inline constexpr auto discriminant = formula::pow<2>(var) + formula::pow<3>(var); -inline constexpr auto speed = formula::yields(formula::documented( - formula::cbrt(var + formula::sqrt(discriminant)) - + formula::cbrt(var - formula::sqrt(discriminant)), - { .title = "Validation of a mathematical model for road cycling power", - .reference = "J. C. Martin, D. L. Milliken, J. E. Cobb, K. L. McFadden and A. R. Coggan, " - "Journal of Applied Biomechanics, 1998", - .text = "A simplified form of the model, without drivetrain or bearing losses: the power a rider holds " - "balances rolling resistance, gravity and air drag; in steady state, with no wind and a small " - "gradient, P = v * (m * g * (C_rr + s) + 1/2 * rho * C_dA * v^2), solved here for v." })); -``` - -`ride(surface)` puts them into one `formula::calculation`, after a step that -looks up the rolling coefficient by the road surface. Rendered, the lookup -spells out its whole table on one line; the steps after it read: - -``` -s = h / L -m = m_r + m_b -F = m * 9.80665 m/s2 * (C_rr + s) -k = 0.5 * rho * C_dA -a = P / (2 * k) -b = F / (3 * k) -v = root3(a + sqrt(a^2 + b^3)) + root3(a - sqrt(a^2 + b^3)) -``` - -The speeds it calculates at 250 W: - -``` -flat road, asphalt, 250 W: 10.332 m/s (37.2 km/h) -3 % climb, asphalt, 250 W: 6.783 m/s (24.4 km/h) -flat road, cobbles, 250 W: 8.335 m/s (30.0 km/h) -``` - -Every step up to `a` and `b` is an exact lookup, sum, product or quotient: the -gradient (`3/100` on the climb), the force, `a` and `b` are exact rationals, -never rounded. The cube roots have no exact value, so the speed is evaluated -in `double` (`formula::checked_evaluate_si`) from the exact `a` and -`b`, by Cardano's formula, which solves the cubic for the speed in closed -form. On an 8 % descent at no power, Cardano's square root has no real -answer, and the program reports a `DomainError` instead of a speed. The same -calculation also says what its symbols mean and where it comes from: see -[One calculation, four answers](#one-calculation-four-answers). - -A quantity can also be declared as a struct deriving from `formula::Quantity`, -`struct RiderMass: formula::Quantity {};`. -Both spellings are supported, and can be used together in one formula; -[Quantities and measurements](quantities.md#declaring-a-quantity) says what -each costs. - -## One calculation, four answers - -The inputs of the cycling example are quantities, as every value it -calculates is: +The compressive strength of a concrete specimen: the load that crushed it over the area that +carried it. ```cpp -// ---- The inputs ---- -using RiderMass = formula::Quantity; -using BikeMass = formula::Quantity; -using DragArea = formula::Quantity; -using AirDensity = formula::Quantity; -using Power = formula::Quantity; -using Rise = formula::Quantity; -using Run = formula::Quantity; +--8<-- "examples/readme.cpp:program" ``` -`ride(RoadSurface::Asphalt)` is the calculation for an asphalt road. Rendering -it, as plain text and as LaTeX, and documenting it, are three calls; the -documentation, `page`, holds the symbol table the program prints, and the -citation: - -```cpp -auto const decimals = formula::NumberStyle::exact_decimal(); -auto const onAsphalt = ride(RoadSurface::Asphalt); - -// ---- 1. The calculation, one step a line ---- -std::println("the calculation, in the order it calculates:\n{}\n", formula::render(onAsphalt, { .numbers = decimals })); -std::println("in LaTeX:\n{}\n", - formula::render(onAsphalt, latexSymbols, { .numbers = decimals })); - -// ---- 2. Its symbol table and its citation ---- -formula::Documentation const page = formula::document(onAsphalt, { .numbers = decimals }); -std::println("its symbol table:"); -for (formula::SymbolEntry const& entry: page.symbols) - std::println(" {:<5} {:<6} {}", entry.symbol, entry.unit, entry.description); +```text +--8<-- "examples/readme.expected.txt" ``` -The plain rendering is shown above. In LaTeX, the speed's line reads: +## Start here -``` -v = \sqrt[3]{a + \sqrt{a^{2} + b^{3}}} + \sqrt[3]{a - \sqrt{a^{2} + b^{3}}} -``` +New to the library? The [tutorial](tutorial/index.md) builds a complete test step by step. The +guides below are the reference: each covers one part of the library in depth. -The symbol table lists the eight calculated values first, then the seven -inputs; its calculated rows read: - -``` -its symbol table: - C_rr rolling resistance coefficient - s road gradient - m kg mass of rider and bike - F N rolling resistance and gravity together - k kg/m air drag per square of speed - a m3/s3 power over twice the drag factor - b m2/s2 force over three times the drag factor - v m/s steady-state speed -``` - -and the citation, from `page.citations`: - -``` -the speed, after: - Validation of a mathematical model for road cycling power - J. C. Martin, D. L. Milliken, J. E. Cobb, K. L. McFadden and A. R. Coggan, Journal of Applied Biomechanics, 1998 -``` - -The value comes from the example's `ride_on(surface, inputs)`. `riding(watts, -rise)` is the environment of the inputs: a rider of 75 kg on a bike of -8.5 kg, with a drag area of 0.32 m², in air of 1.225 kg/m³, holding `watts` on -a road rising `rise` over 1000 m. `ride_on` calculates every step up to `a` and -`b` exactly on a `formula::worksheet` of `ride(surface)` and those inputs, -evaluates the speed's formula in `double` from them, rounds it to the -millimetre a second and converts it to km/h. Each result is checked before it -is read: - -```cpp -auto const flat = ride_on(RoadSurface::Asphalt, riding(250, 0)); -if (!flat) -{ - std::println("flat road at 250 W: {}", formula::describe(flat.error())); - return 1; -} -std::println("\nflat road, asphalt, 250 W: {} ({:.1HalfEven})", flat->inMetresPerSecond, flat->inKilometresPerHour); -``` - -`riding(250, 30)` is the 3 % climb, 30 m over 1000 m. - -The text comes from `render.hpp` and `document.hpp`, and the printing from -`format.hpp`; the umbrella header `formula.hpp` holds the rest. - -## Three things worth knowing before you read further - -**A dimensional mistake is a compile error.** `var + var` does not compile, and the -diagnostic names both quantities and points at the line that wrote the formula. - -**Nothing is silently zero.** An evaluation returns a value, an *absence*, or a *manual override*. -A quantity nobody measured stays absent through every operator, and the result says so — which is -a different statement from "this is zero". - -**Arithmetic is exact by default.** `Rational` does not drift. Rounding happens once, where you ask -for it, in the mode the method specifies. - -## Where to start +## Guides | Guide | What it covers | |---|---| @@ -195,13 +47,18 @@ for it, in the mode the method specifies. | [Opaque operations and bounded retry](opaque-and-retry.md) | A named operation such as a least-squares line through a curve, or through raw observations with R², and a regression on several regressors, traced by its inputs and outputs with its inside marked as not shown, and a step repeated until it is accepted, at most a fixed number of times, ending in exactly one of six ways -- the method's verdict when it runs out | | [Gallery](gallery.md) | A documentation page the library generated about itself | -New here? Read **[Expressions and evaluation](expressions.md)** first — it is the layer the library -exists for. The [API reference](https://lastrada-software.github.io/formula-cpp/api/) documents -every public entity. - Each guide has a matching runnable program under `examples/` in the repository, and every snippet on this site is taken from code that compiles, so nothing here can drift from what the library -actually does. +actually does. The [API reference](https://lastrada-software.github.io/formula-cpp/api/) documents +every public entity. + +## A larger example + +[`examples/cycling_speed.cpp`](https://github.com/LASTRADA-Software/formula-cpp/blob/master/examples/cycling_speed.cpp) +works out a cyclist's steady-state speed from the power the rider holds. Each step is a formula in +one `formula::calculation`. The exact steps are evaluated on a worksheet; the speed, whose roots +have no exact value, is evaluated in `double` from them. The speed's formula carries a citation, +and the program renders the calculation as plain text and as LaTeX and prints its symbol table. ## A note on the examples @@ -212,34 +69,4 @@ nor their clause numbering. Real standards are implemented in downstream librari licence to them. The one real reference, in `examples/cycling_speed.cpp`, cites a published paper by its title, authors, journal and year only, and quotes none of its text. -## Status - -Usable for what is listed as shipped, and still growing. The public API may change until 1.0. - -| Area | State | -|---|---| -| Exact rational arithmetic, rounding modes | shipped | -| Dimensions with rational exponents, units, exact conversion | shipped | -| Quantities, metadata, absent measurements | shipped | -| Formulas, operators, environments, evaluation | shipped | -| Citations, rendering dialects, generated documentation | shipped | -| Calculation tracing and audit trails | shipped | -| Rounding nodes (decimal places, significant digits), conditionals (`when()`) | shipped | -| Constraints, verdicts, checking a set without short-circuit | shipped | -| Lookup tables: banded, exact and interpolating | shipped | -| Methods: variants, rounding rules, constraints, jurisdiction overlays, vocabularies | shipped | -| Series and grading curves, binning | shipped | -| Statistics, precision limits, outlier rejection | shipped | -| Other samples and other tests: records, context, lineage | shipped | -| Opaque operations (least squares), bounded retry | shipped | -| Values the exact layer cannot hold, reported at a declared precision (`rounded_output`) | shipped | -| Logarithms and exponentials, rounded exactly to declared places | shipped | -| Least squares over observations, with R², and several regressors | shipped | -| Power, energy and Fahrenheit units | shipped | -| Named base dimensions such as money | shipped | -| Decimals in traces, rendered formulas and `std::format` | shipped | -| Calculations: definitions, dependency graph, incremental worksheets | shipped | -| Short spellings: exact decimal literals, bound formulas, `number_of`, `trace_of`, `std::format` of results | shipped | -| 128-bit exact numbers, and a unit named on every value a trace or formula writes | shipped | - Apache-2.0. Source at [github.com/LASTRADA-Software/formula-cpp](https://github.com/LASTRADA-Software/formula-cpp). diff --git a/docs/lookup-tables.md b/docs/lookup-tables.md index 3499c043..410d043b 100644 --- a/docs/lookup-tables.md +++ b/docs/lookup-tables.md @@ -46,8 +46,8 @@ table's data, so they are an ordinary runtime member, handed to the factory: `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`. +[Expressions](expressions.md)). Another formula uses it directly -- as an +operand, or as another lookup's key -- where it stands for the lookup it holds. 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, @@ -226,10 +226,10 @@ to zero, and a row whose correction you forgot to type would then answer `0`, confidently, indistinguishable from a deliberate zero. It is the **node's own member type**, not only the factory's parameter type, -and that distinction was bought the hard way. A lookup node is a public -aggregate with public members, so it can be declared without calling a factory -at all — and while the member was a raw array, that route bypassed the check -entirely and the untyped rows evaluated to `0` on all three kinds. The factory's +and the distinction matters. A lookup node is a public aggregate with public +members, so it can be declared without calling a factory at all — and with a +raw-array member that route would bypass the check entirely and evaluate the +untyped rows to `0` on all three kinds. The factory's parameter cannot see a call that never happens. A consequence worth knowing: a lookup node has no default constructor, because `{}` for a table of three rows is a count of zero, which is exactly the mistake being refused. The @@ -608,8 +608,8 @@ method actually applies. ```cpp [[nodiscard]] constexpr auto classFactor() { - return formula::yields(formula::banded_lookup( - sizeCurveFactor().expression, { 91.9_r, 101.3_r, 108.7_r })); + return formula::yields( + formula::banded_lookup(sizeCurveFactor(), { 91.9_r, 101.3_r, 108.7_r })); } ``` @@ -648,12 +648,12 @@ half-open interval everywhere in this library — in the plain rendering, in Markdown, in LaTeX, and in the trace. **It is spelled that way because the obvious mathematical notation is Markdown -link syntax.** An earlier draft of this library rendered a rounding step as -`round[to 1 dp of mm](d)`; in CommonMark that is `[text](url)`, and renderers -silently dropped the operand and published a broken line. A test now asserts -that no Markdown rendering contains `](` or a bare `[`, and a bracketed interval -is exactly the character sequence that would defeat it. The wording chosen -carries no punctuation at all, so it survives every Markdown flavour untouched: +link syntax.** A rounding step rendered as `round[to 1 dp of mm](d)` would be +`[text](url)` in CommonMark, and renderers would silently drop the operand and +publish a broken line. A test asserts that no Markdown rendering contains `](` +or a bare `[`, and a bracketed interval is exactly the character sequence that +would defeat it. The wording chosen carries no punctuation at all, so it +survives every Markdown flavour untouched: ``` banded (md): lookup(`d`, 0 to under 127 mm gives 913/10 %, 127 to under 173 mm gives 1051/10 %, 173 to under 211 mm gives 1127/10 %) @@ -688,7 +688,7 @@ it wraps anything else — there is nothing special to do: [[nodiscard]] constexpr auto correctedStrength(LookupExampleShape shape) { return formula::yields( - formula::documented(var * sizeFactor().expression * shapeFactor(shape).expression, + formula::documented(var * sizeFactor() * shapeFactor(shape), { .title = "Corrected compressive strength", .reference = "Example Standard 8:2020", .section = "7.3", diff --git a/docs/methods-and-overlays.md b/docs/methods-and-overlays.md index 20e309ad..a9d5ece6 100644 --- a/docs/methods-and-overlays.md +++ b/docs/methods-and-overlays.md @@ -548,7 +548,7 @@ west: 2 constraint(s) ``` **`with_constraints` replaces the constraints, it does not add to them.** The -west's method no longer checks the base method's 47.3 kN. A jurisdiction that +west's method does not check the base method's 47.3 kN. A jurisdiction that keeps a base check restates it in its own set, with its own citation. An overlay can also leave fewer constraints than the method had, or none: `with_constraints(formula::constraints(), citation)` is accepted, because a diff --git a/docs/numeric-headroom.md b/docs/numeric-headroom.md index 6105bea1..f3f24541 100644 --- a/docs/numeric-headroom.md +++ b/docs/numeric-headroom.md @@ -64,11 +64,12 @@ of their evaluations overflowed. Three findings decided the remedy: below). Storing them in the declared unit would change the evaluator's rule that every leaf is converted to SI, and a variance node has no declared unit to work in. -- **So the stored integer was widened.** `Rational` stores its numerator and - denominator in `formula::Int128`, 128 bits: the compiler's own 128-bit +- **So the stored integer is 128 bits wide.** `Rational` stores its numerator + and denominator in `formula::Int128`, 128 bits: the compiler's own 128-bit integer computes where it has one (GCC, Clang), and portable `constexpr` - code everywhere else (cl, clang-cl). A computation that answered at 64 bits - gives the same answer; some that were refused with `Overflow` now answer. + code everywhere else (cl, clang-cl). A computation that fits in 64 bits + gives the same answer at 128; some that 64-bit integers would refuse with + `Overflow` answer. ## Why a fraction's integers grow @@ -155,6 +156,22 @@ Each program's largest integers over everything it evaluates at run time. | program | numerator bits | denominator bits | intermediate bits | headroom | |---|---|---|---|---| +| example `readme` | 25 | 20 | 25 | 102 | +| example `tutorial_01_first_formula` | 25 | 20 | 25 | 102 | +| example `tutorial_02_units_and_dimensions` | 25 | 20 | 25 | 102 | +| example `tutorial_03_exact_numbers` | 44 | 25 | 44 | 83 | +| example `tutorial_04_missing_and_entered` | 25 | 20 | 25 | 102 | +| example `tutorial_05_rounding` | 29 | 20 | 29 | 98 | +| example `tutorial_06_constraints` | 10 | 13 | 13 | 114 | +| example `tutorial_07_documentation` | 25 | 20 | 25 | 102 | +| example `tutorial_08_tracing` | 44 | 25 | 44 | 83 | +| example `tutorial_09_worksheets` | 30 | 30 | 25 | 97 | +| example `tutorial_10_lookup_tables` | 25 | 20 | 25 | 102 | +| example `tutorial_11_methods_and_overlays` | 52 | 35 | 52 | 75 | +| example `tutorial_12_series` | 25 | 25 | 25 | 102 | +| example `tutorial_13_statistics` | 28 | 20 | 28 | 99 | +| example `tutorial_14_records` | 25 | 25 | 25 | 102 | +| example `tutorial_15_opaque_and_retry` | 27 | 20 | 27 | 100 | | example `simple` | 12 | 12 | 12 | 115 | | example `exact_numbers` | 9 | 10 | 9 | 117 | | example `dimensions_and_units` | 22 | 10 | 22 | 105 | diff --git a/docs/quantities.md b/docs/quantities.md index 9efc1322..9d266b76 100644 --- a/docs/quantities.md +++ b/docs/quantities.md @@ -187,7 +187,7 @@ diagnostic readable. its dimension (`unit.dimension`), so a separate dimension parameter would state it a second time and let the two disagree. That is not a hypothetical risk: a spelling with a dimension parameter, with `dim::Mass` paired against -`unit::Litre`, compiled without a diagnostic on every compiler it was tried +`unit::Litre`, compiles without a diagnostic on every compiler it was tried on. `Quantity::dimension` is derived from the unit instead, so there is no second place for it to disagree with, and no spelling that lets a caller write the contradiction at all. @@ -302,16 +302,15 @@ happen to be present is the wrong number this layer exists to prevent. **`Result` is named by the caller and is not deduced from either operand.** Combining two quantities generally produces a third -- a mass and a volume combine into a density, not into either operand's own quantity -- and there -is no honest default `combine` could deduce instead. An earlier signature -deduced the result as the right-hand operand's quantity, so -`combine(mass, volume, divide)` was statically a measurement of *volume*, -reporting a volume's symbol and unit for a value that was actually a -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, printed as `std::format` -writes an absent `Measured`: +is no honest default `combine` could deduce instead. Deducing the result as +the right-hand operand's quantity would make `combine(mass, volume, divide)` +statically a measurement of *volume*, reporting a volume's symbol and unit +for a value that is actually a 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, +printed as `std::format` writes an absent `Measured`: ``` a present volume combined with an absent mass: (not measured) diff --git a/docs/rounding-and-conditionals.md b/docs/rounding-and-conditionals.md index 36b97abf..f0d11c45 100644 --- a/docs/rounding-and-conditionals.md +++ b/docs/rounding-and-conditionals.md @@ -207,17 +207,16 @@ with an offset by its size and its zero, `to 1 dp of 1 K from 5463/20 K`, so that the places say what they count in; only a dimensionless unit at scale 1 writes no unit clause, `round(x, to 2 dp)`. The operand comes first and the granularity second, comma-separated, deliberately -- not because it looks -tidier, but because the alternative shapes both have a real failure mode a -review actually caught. A trailing suffix with nothing separating it from -the operand (`round( to 1 dp of mm)`) misattaches to whichever -branch of a `when()` operand happens to render last, with no closing -delimiter of its own to stop it; and the fix that was tried before this one, -a `[...]` prefix (`round[to 1 dp of mm](d)`), collided with CommonMark's -inline-link syntax and made a Markdown renderer drop the operand from the -visible page entirely. The comma form is safe against both at once. See -[Citations and rendering](citations.md) for the other rule every dialect -follows the same way, for the same kind of reason -- wrapping a symbol -containing an underscore in backticks so Markdown does not read it as +tidier, but because the alternative shapes both have a real failure mode. +A trailing suffix with nothing separating it from the operand +(`round( to 1 dp of mm)`) misattaches to whichever branch of a +`when()` operand happens to render last, with no closing delimiter of its +own to stop it; and a `[...]` prefix (`round[to 1 dp of mm](d)`) collides +with CommonMark's inline-link syntax and makes a Markdown renderer drop the +operand from the visible page entirely. The comma form is safe against both +at once. See [Citations and rendering](citations.md) for the other rule every +dialect follows the same way, for the same kind of reason -- wrapping a +symbol containing an underscore in backticks so Markdown does not read it as emphasis. A conditional renders as `if then else ` -- seen diff --git a/docs/series.md b/docs/series.md index 2e830601..fe474524 100644 --- a/docs/series.md +++ b/docs/series.md @@ -36,8 +36,8 @@ inline constexpr auto passing = formula::yields( `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`. +is, with no result to repeat, and a formula built on it uses it directly, +where it stands for the formula it holds. 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 diff --git a/docs/superpowers/plans/2026-10-06-approachable-docs.md b/docs/superpowers/plans/2026-10-06-approachable-docs.md new file mode 100644 index 00000000..bdcbf66e --- /dev/null +++ b/docs/superpowers/plans/2026-10-06-approachable-docs.md @@ -0,0 +1,1718 @@ +# Approachable README, Tutorial and Current-State Docs 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 approachable: a short README with badges and a minimal example, a two-track tutorial on the documentation site whose code and output cannot drift, user documentation that describes only the current library, and coverage reported to Codecov. + +**Architecture:** Every program a page shows lives under `examples/` and is built and run by CTest; its exact output is checked in as `.expected.txt` and compared byte for byte by a new `cmake/CheckExpectedOutput.cmake`. Tutorial pages include code regions and output files with `pymdownx.snippets` (`check_paths: true`), so `mkdocs build --strict` fails on a missing file or section; the Pages workflow builds the site on pull requests too. The README, which GitHub renders directly, is pinned to `examples/readme.cpp` by the existing snippet and output checks. A new hygiene test refuses history wording in user documentation. + +**Tech Stack:** C++23 header-only library, CMake 3.23+ with CTest, MkDocs Material with pymdownx, GitHub Actions, Clang 22 source-based coverage, Codecov. + +**Spec:** `docs/superpowers/specs/2026-10-06-approachable-docs-design.md` + +## Global Constraints + +- Every new file starts with `SPDX-License-Identifier: Apache-2.0` in the file's comment syntax (C++ `//`, CMake/YAML `#`); `.md` and `.expected.txt` files carry none. +- All output in examples, tests and tools uses `std::print` / `std::println`; never `printf`, `puts` or iostream. +- Error handling is explicit: keep the `checked_` forms, check every `std::expected` before reading it, print or propagate the error. Never unwrap unchecked; never switch to a throwing twin (`evaluate`, `explain`, `convert_to`) to shorten code. A `constexpr` result may be checked with `static_assert(r.has_value())`. +- Every standard cited anywhere is an invented `Example Standard N:2020`. No real standard is named (`hygiene.no-real-standards` enforces it). +- User documentation (`README.md`, `docs/` except `docs/superpowers/`, doc comments in `include/`) describes the library as it is now. Never "no longer", "previously", "used to", "formerly", "once meant", "was renamed", "new in", "since 0.x". History goes only in `CHANGELOG.md`. +- Professional, neutral tone. Page and chapter titles are plain ("Tutorial", "Units and dimensions"). The phrase "zero to hero" appears nowhere. +- The README writes every library name with `formula::`; no `using namespace` in it. +- Committed text never refers to `STATUS.md`, session-only labels or reviewer IDs. +- Only GCC 14 is supported for GCC; no workarounds for older GCC. +- Per-task verification runs the Windows compilers only: `cl-debug` build and full `ctest`, plus any new negative test under `clangcl-debug`. Every compiler and preset runs once at the end, driven to full green. +- Commits end with `Signed-off-by: Christian Parpart `. + +## Review Focus + +1. **Windows line endings in program output.** `std::println` on Windows writes `\r\n`; `.expected.txt` is checked out LF (`.gitattributes` has `* text=auto eol=lf`). The output check must normalise both sides, and a mismatch must still be caught: Task 1's self-test pins it. +2. **A snippet marker misspelt or removed.** A page including `file.cpp:evaluate` after the region was renamed must fail the site build, not publish an empty code block. Task 2 proves `mkdocs build --strict` fails on a missing section, and Task 2 makes that build run on pull requests. +3. **A diagnostic quoted in `docs/tutorial/`.** The two existing diagnostic checks glob only `docs/*.md`; a stale quote in a subdirectory would pass. Task 2 extends both to `docs/**` (excluding `docs/superpowers/`), and Task 4's quote is covered by them. +4. **Legitimate prose that looks like history.** "the two determinations no longer agree" describes data. The current-state check must allow it by an explicit allow-list entry, not by weakening the pattern; Task 15's self-test pins both a flagged phrase and an allow-listed line. +5. **The CPM tag going stale on a release bump.** The README and tutorial chapter 1 name `@0.4.0`; Task 13 extends `hygiene.version` so a bump that misses either fails. + +--- + +## File Structure + +| Path | Responsibility | +|---|---| +| `cmake/CheckExpectedOutput.cmake` (new) | Runs a program, compares its stdout with a checked-in `.expected.txt` byte for byte after normalising CRLF | +| `test/fixtures/readme-wrong.expected.txt` (new) | Fixture proving the output check detects a difference | +| `examples/CMakeLists.txt` | `formula_add_pinned_example()`; registers `examples/readme.cpp` and every tutorial program; README snippet/output checks | +| `examples/readme.cpp`, `examples/readme.expected.txt` (new) | The README's minimal program and its output | +| `examples/tutorial/NN_.cpp`, `.expected.txt` (new) | One program per tutorial chapter | +| `test/negative/tutorial_load_plus_area.cpp` (new) | The chapter 2 mistake that must not compile | +| `docs/tutorial/index.md`, `docs/tutorial/NN-.md` (new) | Tutorial pages | +| `mkdocs.yml` | `pymdownx.snippets`, `navigation.indexes`, Tutorial and Guides nav sections | +| `.github/workflows/pages.yml` | Build the site on pull requests; deploy only from `master` | +| `cmake/CheckDocumentedDiagnostics.cmake`, `cmake/CheckDocumentedDiagnosticText.cmake` | Scan `docs/**` except `docs/superpowers/` | +| `README.md` | Landing page | +| `docs/index.md` | Site home | +| `cmake/CheckVersionConsistency.cmake`, `test/CMakeLists.txt` | CPM tag in README and chapter 1 equals the project version | +| `cmake/CheckDocsCurrentState.cmake`, `cmake/docs-current-state-allowlist.txt`, `cmake/TestDocsCurrentState.cmake` (new) | The current-state rule, its allow-list, its self-test | +| `docs/*.md`, `include/formula-cpp/*.hpp` | The current-state sweep | +| `CMakePresets.json`, `.github/workflows/coverage.yml`, `codecov.yml` | Coverage | +| `CONTRIBUTING.md`, `CHANGELOG.md` | Documentation rules; the changelog entry | + +## Tutorial conventions (apply to Tasks 3-10) + +**Programs.** `examples/tutorial/NN_.cpp`, two-digit chapter number, snake_case name. Each program: + +- starts with the SPDX line and a two-line comment naming the chapter; +- is complete and builds on its own; core chapters 2-9 start as a copy of the previous chapter's program and add to it; +- prints only what its page shows, and ends `return 0;` on success, `1` on any unexpected error (it prints the error first); +- marks every region its page includes with `// --8<-- [start:]` and `// --8<-- [end:]` on their own lines, region names in lower-kebab-case. Markers inside a function are indented like the code around them. + +**Expected output.** Before running the program, write `NN_.expected.txt` from the values computed by hand (listed per task). Run the program; where the library's spelling differs from your guess (a source marker, a unit symbol, a `≈`), take the program's spelling **only after** confirming the numeric value matches the hand-computed one. The reviewer re-derives every number. + +**Registration.** In `examples/CMakeLists.txt`, after the README block from Task 1: + +```cmake +formula_add_pinned_example(tutorial_01_first_formula tutorial/01_first_formula.cpp) +``` + +**Pages.** `docs/tutorial/NN-.md`, two-digit number, kebab-case name, appended to the Tutorial section of `mkdocs.yml` nav in chapter order. Each page has exactly these sections: + +```markdown +# . + +<What this chapter covers: two or three sentences.> + +## <one heading per step, as many as the chapter needs> + +<prose> + +```cpp +--8<-- "examples/tutorial/NN_<name>.cpp:<region>" +``` + +<prose explaining the region> + +## Output + +```text +--8<-- "examples/tutorial/NN_<name>.expected.txt" +``` + +## Summary + +- `<API introduced>` -- one line on what it does. + +## Further reading + +- [<Guide title>](../<guide>.md#<anchor>) +- [API reference](https://lastrada-software.github.io/formula-cpp/api/) +``` + +The page shows code only through includes; the one exception is chapter 1's consumer CMake block (Task 3). Core chapters 2-9 open with one sentence saying what is added to the previous chapter's program. + +**Verification per tutorial task:** + +```bash +cmake --build --preset cl-debug +ctest --preset cl-debug -R "tutorial|docs\.|hygiene\." +mkdocs build --strict +``` + +All must pass; then the full `ctest --preset cl-debug` before committing. + +--- + +### Task 1: Pinned output, and the README's program + +**Files:** +- Create: `cmake/CheckExpectedOutput.cmake` +- Create: `test/fixtures/readme-wrong.expected.txt` +- Create: `examples/readme.cpp` +- Create: `examples/readme.expected.txt` +- Modify: `examples/CMakeLists.txt` (add the function after `formula_add_example`, register `readme`) + +**Interfaces:** +- Produces: CMake function `formula_add_pinned_example(<name> <source>)`. Builds `<source>` as target `formula-cpp-example-<name>` via `formula_add_example`, and adds CTest `example.<name>.output`, which runs `cmake/CheckExpectedOutput.cmake` with `EXAMPLE_EXE` = that target's file and `EXPECTED` = `<source>` with its extension replaced by `.expected.txt`. +- Produces: `examples/readme.cpp` with region `program` (from its first `#include` to the closing brace of `main`), used by Tasks 13 and 14. + +- [ ] **Step 1: Write the fixture and the expected output by hand** + +`examples/readme.expected.txt` (675 kN / 22500 mm² = 675000 N / 0.0225 m² = 30 000 000 Pa = 30 MPa): + +```text +f_c = 30 MPa +``` + +`test/fixtures/readme-wrong.expected.txt`: + +```text +f_c = 31 MPa +``` + +- [ ] **Step 2: Write `cmake/CheckExpectedOutput.cmake`** + +```cmake +# SPDX-License-Identifier: Apache-2.0 +# A program whose output a page includes must print exactly that output. The +# page includes `<program>.expected.txt` verbatim (pymdownx.snippets), so this +# is the check that makes the published output the program's real output: it +# runs the program and compares its standard output with that file, whole, +# byte for byte. +# +# Line endings are normalised on both sides first: std::println writes CRLF on +# Windows, and .gitattributes checks text files out with LF. Nothing else is +# normalised -- a trailing space, a missing final newline or a changed line +# is a difference. +# +# On a difference it fails naming the file, and prints both texts, so the fix +# -- the program or the expected file -- can be chosen by reading them. +# +# `cmake_minimum_required` for the reason CheckGuideOutput.cmake gives: under +# `cmake -P`'s default OLD policies, CMake 3.28.3 misreads `if()` constructs +# the project's own minimum reads correctly. +cmake_minimum_required(VERSION 3.23) + +foreach(variable EXAMPLE_EXE EXPECTED) + if(NOT DEFINED ${variable}) + message(FATAL_ERROR "CheckExpectedOutput.cmake: ${variable} is not set") + endif() +endforeach() + +if(NOT EXISTS "${EXPECTED}") + message(FATAL_ERROR "CheckExpectedOutput.cmake: ${EXPECTED} does not exist") +endif() + +execute_process(COMMAND "${EXAMPLE_EXE}" + OUTPUT_VARIABLE actual + ERROR_VARIABLE errors + RESULT_VARIABLE exitCode) +if(NOT exitCode EQUAL 0) + message(FATAL_ERROR "CheckExpectedOutput.cmake: ${EXAMPLE_EXE} exited with ${exitCode}:\n${errors}") +endif() + +file(READ "${EXPECTED}" expected) +if(expected STREQUAL "") + message(FATAL_ERROR + "CheckExpectedOutput.cmake: ${EXPECTED} is empty. An empty expected output matches only a " + "program that prints nothing, which no page includes.") +endif() + +string(REPLACE "\r\n" "\n" actual "${actual}") +string(REPLACE "\r\n" "\n" expected "${expected}") + +if(NOT actual STREQUAL expected) + message(FATAL_ERROR + "CheckExpectedOutput.cmake: the output of ${EXAMPLE_EXE} differs from ${EXPECTED}.\n" + "--- expected\n${expected}--- actual\n${actual}--- end") +endif() + +message(STATUS "CheckExpectedOutput.cmake: output matches ${EXPECTED}") +``` + +- [ ] **Step 3: Add the function and its self-test to `examples/CMakeLists.txt`** + +Directly after the closing `endfunction()` of `formula_add_example`: + +```cmake +# An example whose whole output a page includes: built and run as every +# example is, and its output compared, byte for byte, with the +# `<name>.expected.txt` beside its source. The pass regex given to +# formula_add_example is "." -- any output -- because the comparison is the +# real check, and restating the output as a regex would be a second copy to +# keep in step. +function(formula_add_pinned_example name source) + formula_add_example(${name} "${source}" ".") + string(REGEX REPLACE "\\.cpp$" ".expected.txt" expected "${source}") + add_test(NAME "example.${name}.output" + COMMAND "${CMAKE_COMMAND}" + -D "EXAMPLE_EXE=$<TARGET_FILE:formula-cpp-example-${name}>" + -D "EXPECTED=${CMAKE_CURRENT_SOURCE_DIR}/${expected}" + -P "${PROJECT_SOURCE_DIR}/cmake/CheckExpectedOutput.cmake") +endfunction() + +# The README's program: the shortest complete use of the library. README.md +# shows its source and output, pinned below; docs/index.md includes both. +formula_add_pinned_example(readme readme.cpp) + +# The output check must catch a difference, not only pass on a match: run +# against an expected file that says 31 MPa, it must report one. +add_test(NAME example.readme.output-detects-a-difference + COMMAND "${CMAKE_COMMAND}" + -D "EXAMPLE_EXE=$<TARGET_FILE:formula-cpp-example-readme>" + -D "EXPECTED=${PROJECT_SOURCE_DIR}/test/fixtures/readme-wrong.expected.txt" + -P "${PROJECT_SOURCE_DIR}/cmake/CheckExpectedOutput.cmake") +set_tests_properties(example.readme.output-detects-a-difference PROPERTIES + PASS_REGULAR_EXPRESSION "differs from") +``` + +- [ ] **Step 4: Run to verify it fails (no program yet)** + +Run: `cmake --preset cl-debug` +Expected: configure FAILS: `Cannot find source file: readme.cpp`. + +- [ ] **Step 5: Write `examples/readme.cpp`** + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// The README's example: the compressive strength of a specimen, the maximum +// load it carried over the area that carried it. README.md shows it whole. + +// --8<-- [start:program] +#include <formula-cpp/format.hpp> +#include <formula-cpp/formula.hpp> + +#include <print> + +// A quantity is a type: a symbol, a description and a unit. +using Load = formula::Quantity<struct LoadTag, "F", "maximum load", formula::unit::Kilonewton>; +using Area = formula::Quantity<struct AreaTag, "A_c", "loaded area", formula::unit::SquareMillimetre>; +using Strength = formula::Quantity<struct StrengthTag, "f_c", "compressive strength", formula::unit::Megapascal>; + +// The formula, written once with ordinary operators. +constexpr auto strength = formula::yields<Strength>(formula::var<Load> / formula::var<Area>); + +int main() +{ + auto const specimen = formula::environment(formula::Measured<Load> { 675 }, formula::Measured<Area> { 22500 }); + + auto const result = formula::checked_evaluate(strength, specimen); + if (!result) + { + std::println("cannot calculate: {}", result.error()); + return 1; + } + std::println("{} = {}", formula::symbol_of<Strength>(), *result); +} +// --8<-- [end:program] +``` + +The spec wrote `formula::describe(result.error())`; the evaluation error is printed with `{}` directly instead, the form `examples/expressions.cpp` uses, because `describe()` is defined for `ArithmeticError` and `SymbolError`, not for the evaluation error type. + +- [ ] **Step 6: Build and run the new tests** + +Run: +```bash +cmake --preset cl-debug +cmake --build --preset cl-debug --target formula-cpp-example-readme +ctest --preset cl-debug -R "example\.readme" +``` +Expected: `example.readme`, `example.readme.output`, `example.readme.output-detects-a-difference` and `census.example.readme` PASS. If `example.readme.output` fails only because the program prints a source marker or a different unit spelling, confirm the value is still 30 MPa, then copy the program's exact line into `readme.expected.txt`. + +- [ ] **Step 7: Full suite and commit** + +Run: `cmake --build --preset cl-debug && ctest --preset cl-debug`. Expected: all pass. + +```bash +git add cmake/CheckExpectedOutput.cmake test/fixtures/readme-wrong.expected.txt examples/readme.cpp examples/readme.expected.txt examples/CMakeLists.txt +git commit -m "test: pin an example's whole output to a checked-in file" +``` + +--- + +### Task 2: Site plumbing for included code + +**Files:** +- Modify: `mkdocs.yml` +- Modify: `.github/workflows/pages.yml` +- Modify: `cmake/CheckDocumentedDiagnostics.cmake:192` and `cmake/CheckDocumentedDiagnosticText.cmake:79` +- Create: `docs/tutorial/index.md` + +**Interfaces:** +- Produces: the nav section `Tutorial` whose first entry is `tutorial/index.md`; later tasks append chapter pages after it, in order. +- Produces: snippet includes resolved relative to the repository root, e.g. `--8<-- "examples/readme.cpp:program"`. + +- [ ] **Step 1: Enable snippets and restructure the nav in `mkdocs.yml`** + +Add `navigation.indexes` to `theme.features`. Append to `markdown_extensions`: + +```yaml + # The tutorial includes its code and output from the programs CTest builds + # and runs (examples/tutorial/), so a published page cannot drift from what + # compiles. Paths resolve from the repository root, where `mkdocs build` + # runs; check_paths turns a missing file or a missing + # `--8<-- [start:name]` section into a build error, which --strict in + # .github/workflows/pages.yml then fails on. + - pymdownx.snippets: + base_path: ["."] + check_paths: true +``` + +Replace `nav:` with: + +```yaml +nav: + - Home: index.md + - Tutorial: + - tutorial/index.md + - Guides: + - Numbers: numbers.md + - Numeric headroom: numeric-headroom.md + - Dimensions and units: dimensions.md + - Quantities and measurements: quantities.md + - Expressions and evaluation: expressions.md + - Citations and rendering: citations.md + - Tracing and audit trails: tracing.md + - Displaying numbers: display.md + - Calculations and worksheets: calculations.md + - Rounding and conditionals: rounding-and-conditionals.md + - Constraints and verdicts: constraints.md + - Lookup tables: lookup-tables.md + - Methods and overlays: methods-and-overlays.md + - Series and grading curves: series.md + - Statistics, outliers and precision: statistics.md + - Other samples and other tests: records.md + - Opaque operations and bounded retry: opaque-and-retry.md + - Gallery: gallery.md + # Doxygen output, dropped into the built site's api/ directory by + # .github/workflows/pages.yml after `mkdocs build` runs -- not a page + # MkDocs itself builds, so it is linked as an external-style URL rather + # than a page in docs/. + - API reference: https://lastrada-software.github.io/formula-cpp/api/ +``` + +- [ ] **Step 2: Write `docs/tutorial/index.md`** + +```markdown +# Tutorial + +This tutorial teaches formula-cpp step by step. It assumes you know modern +C++ -- C++20 and C++23, `constexpr`, `std::expected`, class-type template +arguments -- and nothing about this library. + +## Before you start + +You need a C++23 compiler (MSVC, clang-cl, Clang, GCC 14 or AppleClang), +CMake 3.23 or newer, and [CPM.cmake](https://github.com/cpm-cmake/CPM.cmake) +to fetch the library. Chapter 1 shows the CMake lines. + +## Two tracks + +**The core track** builds one program: the calculation of a concrete +specimen's compressive strength, from the load that crushed it and the +specimen's size. Each chapter extends the previous chapter's program, so read +the core track in order. + +**The advanced track** covers the rest of the library in independent +chapters. Read the ones you need, in any order, once you have finished the +core track. + +Every program in this tutorial is built and run by the library's test suite, +and every output shown is what that program prints. Every standard cited is +an invented `Example Standard`. + +## Core track + +## Advanced track +``` + +The two track headings get their chapter lists in Tasks 3-10. + +- [ ] **Step 3: Extend the two diagnostic checks to `docs/**`** + +In both scripts, replace the `file(GLOB documents "${SOURCE_DIR}/docs/*.md")` line with: + +```cmake +# Every page of the documentation, the tutorial's included, but not the +# internal planning documents under docs/superpowers/, which the site does not +# publish (mkdocs.yml, exclude_docs). +file(GLOB_RECURSE documents "${SOURCE_DIR}/docs/*.md") +list(FILTER documents EXCLUDE REGEX "/docs/superpowers/") +``` + +- [ ] **Step 4: Build the site on pull requests in `pages.yml`** + +Change the trigger and gate the deploy: + +```yaml +on: + push: + branches: [ master ] + # Built on every pull request, so a page whose included code or output went + # missing fails before it merges. Deployed only from master (the deploy + # job's `if`). + pull_request: + +concurrency: + # One group per ref: deployments from master stay serialised, as before, + # and a pull request's build never queues behind one. + group: pages-${{ github.ref }} + cancel-in-progress: false +``` + +On the `Upload the built site` step add `if: github.event_name == 'push'`, and on the `deploy` job add `if: github.event_name == 'push'`. Update the file's header comment: replace the sentence "On push to master only -- a PR preview of the published site is not part of this phase, and publishing from anywhere but the branch that just passed Build (build.yml) would let an unreviewed page reach readers." with "Built on every push to master and every pull request; deployed only from master, so an unreviewed page never reaches readers." + +- [ ] **Step 5: Verify the site builds, and that a missing section fails it** + +Run: `mkdocs build --strict` +Expected: PASS. + +Then temporarily append to `docs/tutorial/index.md`: + +````markdown +```cpp +--8<-- "examples/readme.cpp:no-such-region" +``` +```` + +Run: `mkdocs build --strict` +Expected: FAIL with `Snippet section 'no-such-region' could not be located`. +Change it to `examples/no-such-file.cpp`; expected: FAIL with `could not be found`. Remove the temporary block; `mkdocs build --strict` PASSES again. Record both failure messages in the task report. + +- [ ] **Step 6: Run the diagnostic checks** + +Run: `ctest --preset cl-debug -R "hygiene\.documented-diagnostic"` +Expected: PASS (no tutorial page quotes a diagnostic yet). + +- [ ] **Step 7: Commit** + +```bash +git add mkdocs.yml .github/workflows/pages.yml cmake/CheckDocumentedDiagnostics.cmake cmake/CheckDocumentedDiagnosticText.cmake docs/tutorial/index.md +git commit -m "docs: include tutorial code from compiled programs, and build the site on PRs" +``` + +--- + +### Task 3: Chapter 1, First formula + +**Files:** +- Create: `examples/tutorial/01_first_formula.cpp`, `examples/tutorial/01_first_formula.expected.txt` +- Create: `docs/tutorial/01-first-formula.md` +- Modify: `examples/CMakeLists.txt`, `mkdocs.yml` (nav), `docs/tutorial/index.md` (core track list) + +**Interfaces:** +- Consumes: `formula_add_pinned_example` (Task 1); the Tutorial nav section (Task 2). +- Produces: quantities `Load` (`F`, kN), `Area` (`A_c`, mm²), `Strength` (`f_c`, MPa) and the formula `strength`, which chapter 2 copies; regions `includes`, `quantities`, `formula`, `environment`, `evaluate`. +- Produces: the consumer CMake block containing exactly `CPMAddPackage("gh:LASTRADA-Software/formula-cpp@0.4.0")`, which Task 13's version check reads. + +- [ ] **Step 1: Write the expected output by hand** + +`examples/tutorial/01_first_formula.expected.txt`: + +```text +f_c = 30 MPa (derived) +``` + +- [ ] **Step 2: Write the program** + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// Tutorial, chapter 1: a first formula. A specimen's compressive strength is +// the maximum load it carried over the area that carried it. + +// --8<-- [start:includes] +#include <formula-cpp/format.hpp> +#include <formula-cpp/formula.hpp> + +#include <print> +// --8<-- [end:includes] + +namespace +{ +// --8<-- [start:quantities] +using Load = formula::Quantity<struct LoadTag, "F", "maximum load", formula::unit::Kilonewton>; +using Area = formula::Quantity<struct AreaTag, "A_c", "loaded area", formula::unit::SquareMillimetre>; +using Strength = formula::Quantity<struct StrengthTag, "f_c", "compressive strength", formula::unit::Megapascal>; +// --8<-- [end:quantities] + +// --8<-- [start:formula] +constexpr auto strength = formula::yields<Strength>(formula::var<Load> / formula::var<Area>); +// --8<-- [end:formula] +} // namespace + +int main() +{ + // --8<-- [start:environment] + auto const specimen = formula::environment(formula::Measured<Load> { 675 }, formula::Measured<Area> { 22500 }); + // --8<-- [end:environment] + + // --8<-- [start:evaluate] + auto const result = formula::checked_evaluate(strength, specimen); + if (!result) + { + std::println("cannot calculate the strength: {}", result.error()); + return 1; + } + std::println("{} = {} ({})", formula::symbol_of<Strength>(), *result, result->source()); + // --8<-- [end:evaluate] + return 0; +} +``` + +- [ ] **Step 3: Register, build, run** + +Add `formula_add_pinned_example(tutorial_01_first_formula tutorial/01_first_formula.cpp)` to `examples/CMakeLists.txt`. Run the verification commands from *Tutorial conventions*. Expected: `example.tutorial_01_first_formula.output` PASSES (adjust only spelling, never the value, as the conventions say). + +- [ ] **Step 4: Write `docs/tutorial/01-first-formula.md`** + +Sections, in the page template's order: + +1. Opening: the chapter calculates one specimen's compressive strength from its maximum load and loaded area, and introduces the four ideas everything else builds on: a quantity, a formula, an environment of measurements, a checked evaluation. +2. `## Add the library to a project` -- the one block not included from a file: + ````markdown + ```cmake + include(cmake/CPM.cmake) + CPMAddPackage("gh:LASTRADA-Software/formula-cpp@0.4.0") + + add_executable(strength main.cpp) + target_compile_features(strength PRIVATE cxx_std_23) + target_link_libraries(strength PRIVATE formula-cpp::formula-cpp) + ``` + ```` + Then the `includes` region, and one paragraph: `formula.hpp` is the library; `format.hpp` lets `std::format` and `std::print` write its values; rendering, documentation and tracing headers are separate and introduced in chapters 7 and 8. +3. `## Declare the quantities` -- region `quantities`. Explain: a quantity is a type; its template arguments are a tag that makes it unique, its symbol, its description and the unit its values are stated in. Two quantities with the same unit are still different types. +4. `## Write the formula` -- region `formula`. `var<Q>` stands for the value of `Q`; ordinary operators build the formula; `yields<Strength>` names what it calculates. The formula is a compile-time object: declaring it computes nothing. +5. `## Provide the measurements` -- region `environment`. `Measured<Q>` holds a value in `Q`'s declared unit (675 is kN, 22500 is mm²); `environment()` collects them. +6. `## Evaluate, and check the result` -- region `evaluate`. `checked_evaluate` returns `std::expected`; an arithmetic failure (a division by zero) is an error value, never an exception or a silent zero. The result prints as number and unit; `source()` says it was derived. +7. `## Output`, `## Summary` (`Quantity`, `var`, `yields`, `Measured`, `environment`, `checked_evaluate`, `symbol_of`, `source()`), `## Further reading` (Quantities and measurements `../quantities.md`; Expressions and evaluation `../expressions.md`; API reference). + +Note on the page, after the output, one sentence: kN over mm² came out in MPa with no conversion written; chapter 2 explains why. + +- [ ] **Step 5: Nav and index** + +`mkdocs.yml`, under `Tutorial:` after `tutorial/index.md`: `- 1. First formula: tutorial/01-first-formula.md`. In `docs/tutorial/index.md` under `## Core track`: `1. [First formula](01-first-formula.md) -- one formula, evaluated and checked.` + +- [ ] **Step 6: Verify and commit** + +Run the verification commands from *Tutorial conventions*, then full `ctest --preset cl-debug`. Expected: all pass. + +```bash +git add examples/tutorial/01_first_formula.cpp examples/tutorial/01_first_formula.expected.txt docs/tutorial/01-first-formula.md examples/CMakeLists.txt mkdocs.yml docs/tutorial/index.md +git commit -m "docs: add tutorial chapter 1, a first formula" +``` + +--- + +### Task 4: Chapters 2 and 3, Units and dimensions; Exact numbers + +**Files:** +- Create: `examples/tutorial/02_units_and_dimensions.cpp` + `.expected.txt`, `examples/tutorial/03_exact_numbers.cpp` + `.expected.txt` +- Create: `test/negative/tutorial_load_plus_area.cpp` +- Create: `docs/tutorial/02-units-and-dimensions.md`, `docs/tutorial/03-exact-numbers.md` +- Modify: `examples/CMakeLists.txt`, `test/CMakeLists.txt`, `mkdocs.yml`, `docs/tutorial/index.md` + +**Interfaces:** +- Consumes: chapter 1's program. +- Produces (chapter 3's program, copied by chapter 4): quantities `SideA` (`a`, mm), `SideB` (`b`, mm), `Load`, `Area`, `Strength`; formulas `loadedArea = formula::yields<Area>(var<SideA> * var<SideB>)` and `strength = formula::yields<Strength>(var<Load> / loadedArea)`; `using formula::var; namespace unit = formula::unit; using namespace formula::literals;` in the anonymous namespace (introduced in chapter 2 and explained there). + +**Chapter 2.** Copy chapter 1. Replace the `Area` input with two side lengths and compute the area: + +```cpp +// --8<-- [start:sides] +using SideA = formula::Quantity<struct SideATag, "a", "first side of the loaded face", unit::Millimetre>; +using SideB = formula::Quantity<struct SideBTag, "b", "second side of the loaded face", unit::Millimetre>; +// --8<-- [end:sides] + +// --8<-- [start:formulas] +constexpr auto loadedArea = formula::yields<Area>(var<SideA> * var<SideB>); +constexpr auto strength = formula::yields<Strength>(var<Load> / loadedArea); +// --8<-- [end:formulas] +``` + +A second result quantity shows the same formula evaluated into another unit: + +```cpp +// --8<-- [start:other-unit] +using StrengthInNewtons = formula::Quantity<struct StrengthInNewtonsTag, "f_c", "compressive strength", + unit::NewtonPerSquareMillimetre>; +// --8<-- [end:other-unit] +``` + +evaluated with `formula::checked_evaluate<StrengthInNewtons>(var<Load> / loadedArea, specimen)` (the `<Result>` form, see `examples/rounding_and_conditionals.cpp:103`). Inputs: `Measured<SideA> { 150 }`, `Measured<SideB> { 150 }`, `Measured<Load> { 675 }`. Every result is checked before it is read. + +Hand-computed expected output (one line each, in this order): + +```text +A_c = 22500 mm2 (derived) +f_c = 30 MPa (derived) +f_c = 30 N/mm2 (derived) +``` + +The page sections: "Compute the area from the sides" (`sides`, `formulas`: a formula can use another formula; its unit, mm × mm, is mm²); "Units convert themselves" (`other-unit` and its evaluation region: 30 MPa and 30 N/mm² are one value; the declared unit of the result decides how it is stated; conversion factors are exact); "A dimensional mistake does not compile" (below). Summary: composing formulas, `checked_evaluate<Result>`, declared units. Further reading: `../dimensions.md`, `../expressions.md`. + +**The mistake that must not compile.** `test/negative/tutorial_load_plus_area.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// Tutorial, chapter 2: a load plus an area measures nothing, so it does not +// compile. docs/tutorial/02-units-and-dimensions.md includes the line below. +#include <formula-cpp/formula.hpp> + +using Load = formula::Quantity<struct LoadTag, "F", "maximum load", formula::unit::Kilonewton>; +using Area = formula::Quantity<struct AreaTag, "A_c", "loaded area", formula::unit::SquareMillimetre>; + +// --8<-- [start:mistake] +constexpr auto broken = formula::var<Load> + formula::var<Area>; +// --8<-- [end:mistake] + +int main() +{ + return 0; +} +``` + +Register in `test/CMakeLists.txt`, beside the other dimension negatives (near line 338): + +```cmake +# docs/tutorial/02-units-and-dimensions.md includes this file's mistake. +formula_add_negative_test(tutorial_load_plus_area + "formula: the two sides of this addition or subtraction measure different dimensions" + EXPECT_COUNT 1) +``` + +Before committing, follow the negative-test protocol: first register it with a wrong expected text (`"formula: wrong text"`) and confirm the test FAILS; then restore the right text and confirm it PASSES under `cl-debug` and `clangcl-debug`; then temporarily delete the `broken` line and confirm it FAILS (the file now compiles); restore it. + +The page includes it with `--8<-- "test/negative/tutorial_load_plus_area.cpp:mistake"` and quotes the diagnostic in a plain ``` block as g++ writes it, with no file or line: + +``` +static assertion failed: formula: the two sides of this addition or subtraction measure different dimensions +``` + +`hygiene.documented-diagnostic-text` (extended in Task 2) checks that quote; run it. + +**Chapter 3.** Copy chapter 2 (drop `StrengthInNewtons`). Measure the specimen as it really is, with exact decimal literals: + +```cpp +// --8<-- [start:measured] +auto const specimen = formula::environment(formula::Measured<SideA> { 150.2_r }, + formula::Measured<SideB> { 149.8_r }, + formula::Measured<Load> { 675.4_r }); +// --8<-- [end:measured] +``` + +Print the area, the strength exactly, and the strength rounded for reading with the format spec `{:~.2HalfEven}` (see `docs/display.md`); and one line showing `0.1_r + 0.2_r == 0.3_r` is true. Hand-computed values: + +- area = 150.2 × 149.8 = 22499.96 mm² (exact decimal); +- strength = 675 400 N / 22 499.96 mm² = 16885000/562499 MPa ≈ 30.0178311 MPa: no terminating decimal, so the exact value prints as a fraction; +- rounded for reading: ≈30.02 MPa. + +Expected output (fraction spelling and `≈` as the library prints them): + +```text +A_c = 22499.96 mm2 (derived) +f_c = 16885000/562499 MPa (derived) +f_c, for reading = ≈30.02 MPa +0.1 + 0.2 == 0.3: yes +``` + +Page sections: "Exact decimal literals" (`_r` makes an exact `Rational`, never a `double`); "An exact result that has no decimal" (a fraction is printed rather than a rounded decimal that would be a different number); "Rounding for reading" (the format spec names places and a rounding mode; `~` marks the approximation; rounding as part of the method comes in chapter 5). Summary: `_r`, `Rational`, `{:~.NMode}`. Further reading: `../numbers.md`, `../display.md`. + +- [ ] **Step 1:** Write both `.expected.txt` files from the values above. +- [ ] **Step 2:** Write `02_units_and_dimensions.cpp`, register it, build, run its output test. Expected: PASS after spelling-only adjustments. +- [ ] **Step 3:** Write and register `tutorial_load_plus_area` with the wrong-text / right-text / deletion protocol above. +- [ ] **Step 4:** Write `03_exact_numbers.cpp`, register it, run its output test. Expected: PASS after spelling-only adjustments. +- [ ] **Step 5:** Write both pages; nav entries `- 2. Units and dimensions: tutorial/02-units-and-dimensions.md` and `- 3. Exact numbers: tutorial/03-exact-numbers.md`; index lines `2. [Units and dimensions](02-units-and-dimensions.md) -- sides, areas and unit conversion; a mistake that does not compile.` and `3. [Exact numbers](03-exact-numbers.md) -- why the results are exact, and how to round them for reading.` +- [ ] **Step 6:** Verification commands from *Tutorial conventions*, plus `ctest --preset clangcl-debug -R tutorial_load_plus_area`, then full `ctest --preset cl-debug`. Expected: all pass. +- [ ] **Step 7: Commit** + +```bash +git add examples/tutorial/02_* examples/tutorial/03_* test/negative/tutorial_load_plus_area.cpp docs/tutorial/02-units-and-dimensions.md docs/tutorial/03-exact-numbers.md examples/CMakeLists.txt test/CMakeLists.txt mkdocs.yml docs/tutorial/index.md +git commit -m "docs: add tutorial chapters on units and on exact numbers" +``` + +--- + +### Task 5: Chapters 4 and 5, Missing and entered values; Rounding + +**Files:** +- Create: `examples/tutorial/04_missing_and_entered.cpp` + `.expected.txt`, `examples/tutorial/05_rounding.cpp` + `.expected.txt` +- Create: `docs/tutorial/04-missing-and-entered.md`, `docs/tutorial/05-rounding.md` +- Modify: `examples/CMakeLists.txt`, `mkdocs.yml`, `docs/tutorial/index.md` + +**Interfaces:** +- Consumes: chapter 3's program. +- Produces (chapter 5, copied by chapter 6): `constexpr formula::DecimalRounding tenthMpa { unit::Megapascal, formula::DecimalPlaces { 1 }, formula::RoundingMode::HalfAwayFromZero };` and `strength = formula::yields<Strength>(formula::rounded<tenthMpa>(var<Load> / loadedArea))`. + +**Chapter 4.** Copy chapter 3. Three environments, each evaluated and checked: + +1. all measured: sides 150, 150, load 675 → `f_c = 30 MPa (derived)`; +2. side b never measured, `formula::Measured<SideB>::absent()` → the evaluation succeeds and its outcome holds no number; show this with `formula::number_of(*result)` (empty `std::optional`) and print the outcome as the library prints an empty one (see `examples/expressions.cpp:84-90`); +3. the strength entered by hand: `formula::entered(formula::Measured<Strength> { 31 })` added to the all-measured environment → `f_c = 31 MPa (manually entered)`, with `result->is_overridden()` true. + +Hand-computed expected output (empty-outcome spelling as the library prints it): + +```text +measured: f_c = 30 MPa (derived) +b not measured: f_c = empty +entered by hand: f_c = 31 MPa (manually entered) +``` + +Page sections: "A measurement nobody took" (absence propagates through every operator; empty is not zero); "A value entered by hand" (`entered()` overrides the formula; `source()` says so, so a report can show which numbers were derived and which asserted). Summary: `Measured<Q>::absent()`, `entered()`, `number_of`, `ValueSource`, `is_overridden()`. Further reading: `../quantities.md`, `../expressions.md`. + +**Chapter 5.** Copy chapter 4's all-measured case only. Add `tenthMpa` and the rounded `strength` above, plus a second rounding that differs only in mode: + +```cpp +// --8<-- [start:roundings] +constexpr formula::DecimalRounding tenthMpa { unit::Megapascal, formula::DecimalPlaces { 1 }, + formula::RoundingMode::HalfAwayFromZero }; +constexpr formula::DecimalRounding tenthMpaHalfEven { unit::Megapascal, formula::DecimalPlaces { 1 }, + formula::RoundingMode::HalfEven }; +// --8<-- [end:roundings] +``` + +Evaluate with sides 150 × 150 and loads 675.4 kN and 676.125 kN. Hand-computed: 675 400 / 22 500 = 30.0177… → 30.0 in both modes; 676 125 / 22 500 = 30.05 exactly → 30.1 half away from zero, 30.0 half to even. Expected output (decimal spelling as printed; a rounded exact value prints its declared places): + +```text +675.4 kN, half away from zero: f_c = 30.0 MPa +676.125 kN, half away from zero: f_c = 30.1 MPa +676.125 kN, half to even: f_c = 30.0 MPa +``` + +Page sections: "Rounding is part of the formula" (`rounded<R>` rounds at that position; the rounding names its unit, places and mode, and there is no default mode); "The mode matters" (30.05 → 30.1 or 30.0). Summary: `DecimalRounding`, `DecimalPlaces`, `RoundingMode`, `rounded<R>`. Further reading: `../rounding-and-conditionals.md`, `../numbers.md`. + +- [ ] **Step 1:** Write both `.expected.txt` files from the values above. +- [ ] **Step 2:** Write, register and run `04_missing_and_entered.cpp`. Expected: output test PASSES after spelling-only adjustments. +- [ ] **Step 3:** Write, register and run `05_rounding.cpp`. Expected: PASS after spelling-only adjustments. +- [ ] **Step 4:** Write both pages; nav `- 4. Missing and entered values: tutorial/04-missing-and-entered.md`, `- 5. Rounding: tutorial/05-rounding.md`; index lines `4. [Missing and entered values](04-missing-and-entered.md) -- a measurement nobody took, and a value typed in.` and `5. [Rounding](05-rounding.md) -- rounding where the method says, in the mode it names.` +- [ ] **Step 5:** Verification commands; full `ctest --preset cl-debug`. Expected: all pass. +- [ ] **Step 6: Commit** + +```bash +git add examples/tutorial/04_* examples/tutorial/05_* docs/tutorial/04-missing-and-entered.md docs/tutorial/05-rounding.md examples/CMakeLists.txt mkdocs.yml docs/tutorial/index.md +git commit -m "docs: add tutorial chapters on missing values and on rounding" +``` + +--- + +### Task 6: Chapters 6 and 7, Constraints; Citations, rendering and documentation + +**Files:** +- Create: `examples/tutorial/06_constraints.cpp` + `.expected.txt`, `examples/tutorial/07_documentation.cpp` + `.expected.txt` +- Create: `docs/tutorial/06-constraints.md`, `docs/tutorial/07-documentation.md` +- Modify: `examples/CMakeLists.txt`, `mkdocs.yml`, `docs/tutorial/index.md` + +**Interfaces:** +- Consumes: chapter 5's program (`tenthMpa`, rounded `strength`). +- Produces (chapter 7, copied by chapter 8): `strength` wrapped in `formula::documented(...)` with the citation `{ .title = "Compressive strength", .reference = "Example Standard 12:2020", .section = "6.1", .equation = "(1)", .text = "The maximum load divided by the area of the loaded face." }`, i.e. `formula::yields<Strength>(formula::documented(formula::rounded<tenthMpa>(var<Load> / loadedArea), { ... }))`. + +**Chapter 6.** Copy chapter 5. Add constraints on the specimen's size, an invented tolerance of 150 mm ± 1 mm per side, following `examples/constraints.cpp:40-47` and `:135` (`formula::constraint`, `formula::Verdict`, `formula::constant<unit::Millimetre>(...)`, `formula::check`, `formula::check_all(formula::constraints(...), environment)`): + +```cpp +// --8<-- [start:constraints] +constexpr auto sideAWithinTolerance = formula::constraint( + var<SideA> >= formula::constant<unit::Millimetre>(149) && var<SideA> <= formula::constant<unit::Millimetre>(151), + formula::Verdict { "reject the specimen: side a out of tolerance" }); +// --8<-- [end:constraints] +``` + +If the predicate language has no `&&`, write two constraints per side (`a >= 149 mm`, `a <= 151 mm`) and check all four with `check_all`; record which form was used in the task report. Do the same for side b. Three specimens: + +1. 150.2 × 149.8 → every constraint satisfied; +2. 150.2 × 152.5 → side b violated, verdict "reject the specimen: side b out of tolerance"; +3. side b absent → not checked. + +Print each outcome as `examples/constraints.cpp` prints them. Expected output: one block per specimen naming each constraint's outcome word (`satisfied`, `violated`, `not checked`) and the verdict for the violation. Write it by hand from these cases, then adjust spelling only. + +Page sections: "A rule the result must meet" (a constraint pairs a predicate with a verdict); "Four outcomes, not two" (satisfied, violated, not checked -- an unmeasured side is never reported as satisfied -- and invalid, when checking itself fails, with a link to the guide for it); "Checking several rules" (`check_all` checks every rule, without stopping at the first failure). Summary: `constraint`, `Verdict`, `constant`, `check`, `check_all`, `constraints`. Further reading: `../constraints.md`. + +**Chapter 7.** Copy chapter 5's all-measured program (not chapter 6's constraints). Wrap `strength` in `documented()` as in *Interfaces* (see `examples/citations.cpp:37-43`). Include `<formula-cpp/render.hpp>` and `<formula-cpp/document.hpp>`. Print: + +- `formula::render(strength)` -- the plain-text rendering; +- `formula::render<formula::Dialect::LaTeX>(strength)`; +- from `formula::Documentation const page = formula::document(strength);`, each `page.symbols` entry as `symbol unit description` (the loop in `docs/index.md`'s cycling section: `for (formula::SymbolEntry const& entry: page.symbols)`), and each citation's title and reference from `page.citations`; +- the strength value, 30.0 MPa for 150 × 150 at 675 kN. + +Expected output: write by hand (the text rendering will read like `f_c = round(F / (a * b), 0.1 MPa)` -- take the library's exact spelling from the run; the symbol table has rows for `f_c`, `F`, `a`, `b` with units `MPa`, `kN`, `mm`, `mm`; the citation reads `Compressive strength` / `Example Standard 12:2020`). The reviewer checks the rows and the citation against the declarations. + +Page sections: "Cite where the formula comes from" (`documented()` attaches a citation and changes nothing that is calculated); "Render it" (text and LaTeX from the same declaration); "Generate its documentation" (`document()` returns the rendering, the symbol table and the citations: everything a report's methods section needs). Summary: `documented`, `Citation` fields, `render`, `Dialect::LaTeX`, `document`, `Documentation`, `SymbolEntry`. Further reading: `../citations.md`, `../gallery.md`. + +- [ ] **Step 1:** Write both `.expected.txt` files by hand from the cases above. +- [ ] **Step 2:** Write, register and run `06_constraints.cpp`. Expected: PASS after spelling-only adjustments. +- [ ] **Step 3:** Write, register and run `07_documentation.cpp`. Expected: PASS after spelling-only adjustments. +- [ ] **Step 4:** Pages; nav `- 6. Constraints: tutorial/06-constraints.md`, `- 7. Citations, rendering and documentation: tutorial/07-documentation.md`; index lines `6. [Constraints](06-constraints.md) -- rules a specimen must meet, and the four outcomes of checking one.` and `7. [Citations, rendering and documentation](07-documentation.md) -- where a formula comes from, written out as text, LaTeX and a symbol table.` +- [ ] **Step 5:** Verification commands; full `ctest --preset cl-debug`. Expected: all pass. +- [ ] **Step 6: Commit** + +```bash +git add examples/tutorial/06_* examples/tutorial/07_* docs/tutorial/06-constraints.md docs/tutorial/07-documentation.md examples/CMakeLists.txt mkdocs.yml docs/tutorial/index.md +git commit -m "docs: add tutorial chapters on constraints and on documentation" +``` + +--- + +### Task 7: Chapters 8 and 9, Tracing; Calculations and worksheets + +**Files:** +- Create: `examples/tutorial/08_tracing.cpp` + `.expected.txt`, `examples/tutorial/09_worksheets.cpp` + `.expected.txt` +- Create: `docs/tutorial/08-tracing.md`, `docs/tutorial/09-worksheets.md` +- Modify: `examples/CMakeLists.txt`, `mkdocs.yml`, `docs/tutorial/index.md` + +**Interfaces:** +- Consumes: chapter 7's documented `strength`. + +**Chapter 8.** Copy chapter 7 (drop the rendering and documentation printing). Include `<formula-cpp/trace.hpp>` and `<formula-cpp/trace_render.hpp>`. Use `checked_explain`, never the throwing `explain`, following `examples/display.cpp:151-163`: + +```cpp +// --8<-- [start:explain] +auto const explained = formula::checked_explain<Strength>(strength, specimen); +if (!explained) +{ + std::println("cannot explain the strength: {}", explained.error().error); + std::print("{}", formula::render_trace(explained.error().trace, { .maxSteps = 10 })); + return 1; +} +std::print("{}", formula::render_trace(explained->trace, { .maxSteps = 10 })); +// --8<-- [end:explain] +``` + +`checked_explain` returns `std::expected<Explained<Strength>, CheckedExplainFailure>`: on success the outcome and its trace; on an arithmetic error the error and the trace recorded up to it. Then explain with the strength entered by hand (checked the same way) and show that `explained->trace` is empty and `explained->outcome.is_overridden()` is true. Inputs: sides 150.2 and 149.8 mm, load 675.4 kN. Hand-computed steps: `a = 150.2 mm`, `b = 149.8 mm`, the product `22499.96 mm2`, `F = 675.4 kN`, the quotient `16885000/562499 MPa`, the rounding to `30.0 MPa`, and the documented wrapper citing `Example Standard 12:2020, 6.1, (1)`. Write the expected output in `render_trace`'s numbered-step form (see `docs/tracing.md`), then adjust spelling only. + +Page sections: "How a number was reached" (`checked_explain` returns what `checked_evaluate` would, plus one step per node, each naming the steps it consumed; on failure, the steps up to the failure); "Reading a trace" (inputs in their declared units, the citation on its step); "A value entered by hand has no derivation" (empty trace; `is_overridden()`); one sentence that tracing costs nothing when not asked for. Summary: `checked_explain`, `Explained`, `Trace`, `render_trace`, `TraceRenderOptions::maxSteps`. Further reading: `../tracing.md`. + +**Chapter 9.** Copy chapter 8 (drop tracing). Following `examples/electricity_bill.cpp:122-139, 229-260, 309-335`, put the whole test into one calculation: + +```cpp +// --8<-- [start:quantities] +using Height = formula::Quantity<struct HeightTag, "h", "height of the specimen", unit::Millimetre>; +using Mass = formula::Quantity<struct MassTag, "m", "mass of the specimen", unit::Kilogram>; +using Volume = formula::Quantity<struct VolumeTag, "V", "volume of the specimen", unit::CubicMillimetre>; +using Density = formula::Quantity<struct DensityTag, "rho", "density of the specimen", unit::KilogramPerCubicMetre>; +// --8<-- [end:quantities] + +// --8<-- [start:calculation] +inline constexpr auto test = formula::calculation( + formula::define<Area>(var<SideA> * var<SideB>), + formula::define<Volume>(var<Area> * var<Height>), + formula::define<Density>(var<Mass> / var<Volume>), + formula::define<Strength>(formula::documented(formula::rounded<tenthMpa>(var<Load> / var<Area>), + { .title = "Compressive strength", + .reference = "Example Standard 12:2020", + .section = "6.1", + .equation = "(1)", + .text = "The maximum load divided by the area of the loaded face." }))); +// --8<-- [end:calculation] +``` + +Then a worksheet over sides 150 and 150 mm, height 150 mm, mass 8.1 kg, load 675 kN; `sheet.calculate<Density, Strength>()`; then `sheet.set(formula::Measured<Load> { 676.125_r })` and calculate again, printing `sheet.recomputed()` / `sheet.reused()` deltas as `examples/electricity_bill.cpp` does; then a what-if copy `sheet.with(formula::Measured<Mass> { 8.25_r })` and the original left unchanged. Check every value before reading it. + +Hand-computed: +- area 22500 mm², volume 3 375 000 mm³ = 0.003375 m³, density 8.1 / 0.003375 = 2400 kg/m³, strength 30.0 MPa; +- after the load change: strength 30.1 MPa (30.05 half away from zero); only the strength is recalculated (area, volume, density reused); +- what-if mass 8.25 kg: density 2444.444… = 22000/9 kg/m³ (no terminating decimal; printed as a fraction unless rounded for reading with `{:~.1HalfEven}` → ≈2444.4); the original worksheet still reports 2400 kg/m³. + +Write the expected output from these values, one line per step, then adjust spelling only. The reviewer checks the recomputed/reused counts against the dependency graph: a load change reaches only `Strength`. + +Page sections: "One calculation, several steps" (definitions in any order; the calculation works out what reads what, at compile time); "A worksheet" (calculates on request, keeps results); "Change an input" (only what the change reaches is recalculated); "What if" (`with()` answers on a copy). Summary: `calculation`, `define<Q>`, `worksheet`, `calculate<...>`, `set`, `with`, `recomputed`, `reused`. Further reading: `../calculations.md`. + +- [ ] **Step 1:** Write both `.expected.txt` files by hand from the values above. +- [ ] **Step 2:** Write, register and run `08_tracing.cpp`. Expected: PASS after spelling-only adjustments. +- [ ] **Step 3:** Write, register and run `09_worksheets.cpp`. Expected: PASS after spelling-only adjustments. +- [ ] **Step 4:** Pages; nav `- 8. Tracing: tutorial/08-tracing.md`, `- 9. Calculations and worksheets: tutorial/09-worksheets.md`; index lines `8. [Tracing](08-tracing.md) -- how each number was reached, step by step.` and `9. [Calculations and worksheets](09-worksheets.md) -- the whole test as one calculation, recalculated as inputs change.` +- [ ] **Step 5:** Verification commands; full `ctest --preset cl-debug`. Expected: all pass. +- [ ] **Step 6: Commit** + +```bash +git add examples/tutorial/08_* examples/tutorial/09_* docs/tutorial/08-tracing.md docs/tutorial/09-worksheets.md examples/CMakeLists.txt mkdocs.yml docs/tutorial/index.md +git commit -m "docs: add tutorial chapters on tracing and on worksheets" +``` + +--- + +### Task 8: Advanced chapters 10 and 11, Lookup tables; Methods and overlays + +**Files:** +- Create: `examples/tutorial/10_lookup_tables.cpp` + `.expected.txt`, `examples/tutorial/11_methods_and_overlays.cpp` + `.expected.txt` +- Create: `docs/tutorial/10-lookup-tables.md`, `docs/tutorial/11-methods-and-overlays.md` +- Modify: `examples/CMakeLists.txt`, `mkdocs.yml` (nav after chapter 9), `docs/tutorial/index.md` (`## Advanced track`) + +**Interfaces:** +- Consumes: nothing from earlier chapters' programs; each advanced program is self-contained and may redeclare `Load`, `Strength` and so on. + +Advanced pages use the same template, open by saying what problem the feature solves in the strength test, and end their Further reading with the reference guide, which goes deeper. + +**Chapter 10.** A size correction factor for the strength, looked up by the specimen's edge length from an invented banded table, following `examples/lookup_tables.cpp:61-70` and the lookup it evaluates there: + +- bands: 0 to under 100 mm → 1.05; 100 to under 200 mm → 1.00; 200 to under 300 mm → 0.95; +- corrected strength = strength × factor; +- edge 150 mm, strength 30 MPa → factor 1.00 → 30 MPa; edge 100 mm (on a band boundary, lower edge inclusive) → 1.00 → 30 MPa; edge 250 mm → 0.95 → 28.5 MPa; edge 300 mm → outside every band → the lookup reports an error (domain), and the program prints it rather than a number. + +Page sections: why a published table belongs in the formula; band boundaries (lower inclusive, upper exclusive; spelt "0 to under 100 mm"); a miss is not a number; a gap between bands does not compile (link to the guide). Further reading: `../lookup-tables.md`. + +**Chapter 11.** One method with two variants, cube and cylinder, selected by the specimen's shape, and one jurisdiction overlay, following `examples/methods_and_overlays.cpp` (its variants by tag and its overlay that changes a rounding rule): + +- cube: `f_c = F / (a * b)`; cylinder: `f_c = F / (pi * d^2 / 4)` with `formula::pi`; +- the base method rounds to 0.1 MPa half away from zero; the overlay "Example jurisdiction" rounds to 0.5 MPa; +- cube 150 × 150 at 675 kN → 30.0 MPa under the base method, 30.0 under the overlay; cylinder d = 150 mm at 530 kN → 530 000 / (π × 5625) with the library's exact π; compute it by hand with the same rational π the library uses (`formula::pi`, see `include/formula-cpp/constant.hpp` or the guide) before running; the overlay rounds the same value to the nearest 0.5; +- print which variant ran and whose rounding rule applied, as the trace states it. + +Page sections: one method, several variants; an overlay changes a method for a jurisdiction and says so in the trace. Further reading: `../methods-and-overlays.md`. + +- [ ] **Step 1:** Compute every expected value by hand and write both `.expected.txt` files. +- [ ] **Step 2:** Write, register and run `10_lookup_tables.cpp`. Expected: PASS after spelling-only adjustments. +- [ ] **Step 3:** Write, register and run `11_methods_and_overlays.cpp`. Expected: PASS after spelling-only adjustments. +- [ ] **Step 4:** Pages; nav `- 10. Lookup tables: tutorial/10-lookup-tables.md`, `- 11. Methods and overlays: tutorial/11-methods-and-overlays.md`; index lines under `## Advanced track`: `10. [Lookup tables](10-lookup-tables.md) -- values from a published table, and what happens outside it.` and `11. [Methods and overlays](11-methods-and-overlays.md) -- one method, several variants, and a jurisdiction's changes.` +- [ ] **Step 5:** Verification commands; full `ctest --preset cl-debug`. Expected: all pass. +- [ ] **Step 6: Commit** + +```bash +git add examples/tutorial/10_* examples/tutorial/11_* docs/tutorial/10-lookup-tables.md docs/tutorial/11-methods-and-overlays.md examples/CMakeLists.txt mkdocs.yml docs/tutorial/index.md +git commit -m "docs: add tutorial chapters on lookup tables and on methods" +``` + +--- + +### Task 9: Advanced chapters 12 and 13, Series; Statistics + +**Files:** +- Create: `examples/tutorial/12_series.cpp` + `.expected.txt`, `examples/tutorial/13_statistics.cpp` + `.expected.txt` +- Create: `docs/tutorial/12-series.md`, `docs/tutorial/13-statistics.md` +- Modify: `examples/CMakeLists.txt`, `mkdocs.yml`, `docs/tutorial/index.md` + +**Chapter 12.** One mix's strength at 7, 14 and 28 days as a series, following `examples/series.cpp` (a `MeasuredSeries`, elementwise arithmetic, `explain_series` or evaluation as that example does): + +- strengths 21, 26 and 30 MPa; +- each as a share of the 28-day strength: 21/30 = 0.7, 26/30 = 13/15 (prints as a fraction, or ≈0.867 when rounded for reading), 30/30 = 1; +- the 14-day strength not recorded (absent): that element is absent, the others are still calculated. + +Page sections: one quantity at several points; arithmetic element by element; an absent element stays absent and does not stop the others. Further reading: `../series.md`. + +**Chapter 13.** The strengths of a set of specimens, following `examples/statistics.cpp` (`formula::sample_mean`, a spread, and outlier rejection with `formula::without_outliers` as that example and `examples/display.cpp:101-108` use it): + +- three specimens 30.2, 29.8 and 31.0 MPa: mean 91/3 ≈ 30.33 MPa, range 1.2 MPa; +- five specimens 30.2, 29.8, 31.0, 30.4 and 36.0 MPa with an invented rule "reject a value more than 3 MPa from the mean of the others, one per pass": 36.0 is rejected (the mean of the other four is 30.35); then mean of the remaining four = 30.35 MPa, nothing further rejected; +- print the rejected value and the final mean as the library traces them. + +If the library's outlier API cannot express "from the mean of the others" exactly, use the nearest rule its guide documents, state it in one sentence on the page, and recompute the expected values by hand for that rule. + +Page sections: summary statistics over a sample; outliers rejected pass by pass, and the trace says which. Further reading: `../statistics.md`. + +- [ ] **Step 1:** Compute every expected value by hand and write both `.expected.txt` files. +- [ ] **Step 2:** Write, register and run `12_series.cpp`. Expected: PASS after spelling-only adjustments. +- [ ] **Step 3:** Write, register and run `13_statistics.cpp`. Expected: PASS after spelling-only adjustments. +- [ ] **Step 4:** Pages; nav `- 12. Series: tutorial/12-series.md`, `- 13. Statistics: tutorial/13-statistics.md`; index lines `12. [Series](12-series.md) -- one quantity at several ages, calculated element by element.` and `13. [Statistics](13-statistics.md) -- means and spreads of several specimens, and outliers rejected.` +- [ ] **Step 5:** Verification commands; full `ctest --preset cl-debug`. Expected: all pass. +- [ ] **Step 6: Commit** + +```bash +git add examples/tutorial/12_* examples/tutorial/13_* docs/tutorial/12-series.md docs/tutorial/13-statistics.md examples/CMakeLists.txt mkdocs.yml docs/tutorial/index.md +git commit -m "docs: add tutorial chapters on series and on statistics" +``` + +--- + +### Task 10: Advanced chapters 14 and 15, Records; Opaque operations and bounded retry + +**Files:** +- Create: `examples/tutorial/14_records.cpp` + `.expected.txt`, `examples/tutorial/15_opaque_and_retry.cpp` + `.expected.txt` +- Create: `docs/tutorial/14-records.md`, `docs/tutorial/15-opaque-and-retry.md` +- Modify: `examples/CMakeLists.txt`, `mkdocs.yml`, `docs/tutorial/index.md` + +**Chapter 14.** Comparing a specimen with a reference sample's strength read by role, following `examples/records.cpp` (records, reading by role, the record each value came from in the trace): + +- the specimen's strength 30.0 MPa; the reference sample's 32.0 MPa, from a record; +- the ratio 30/32 = 15/16 = 0.9375 (exact decimal); +- the trace names the record the reference value came from; +- a reference record not yet made: the ratio is absent, not zero. + +Page sections: a value from another sample or an earlier test; where each value came from; a record not yet made. Further reading: `../records.md`. + +**Chapter 15.** Following `examples/opaque_and_retry.cpp`: + +- a least-squares line through load and displacement readings (invented, chosen to lie exactly on load = 100 kN/mm × displacement + 5 kN: (0.1, 15), (0.2, 25), (0.3, 35), (0.4, 45)) → slope 100 kN/mm, intercept 5 kN, and R² = 1 if the observation form is used; the trace shows the operation's inputs and outputs and marks its inside as not shown; +- a retest repeated at most 3 times until two consecutive results differ by at most 0.5 MPa: invented results 30.0, 31.2, 31.0 → accepted on the third attempt (|31.0 - 31.2| = 0.2 ≤ 0.5); print how the retry ended, as the library names it. + +Compute the regression by hand before running (exact data, so the fit is exact). Page sections: a named operation whose inside is not traced; a step repeated a bounded number of times, ending in exactly one of a fixed set of ways. Further reading: `../opaque-and-retry.md`. + +- [ ] **Step 1:** Compute every expected value by hand and write both `.expected.txt` files. +- [ ] **Step 2:** Write, register and run `14_records.cpp`. Expected: PASS after spelling-only adjustments. +- [ ] **Step 3:** Write, register and run `15_opaque_and_retry.cpp`. Expected: PASS after spelling-only adjustments. +- [ ] **Step 4:** Pages; nav `- 14. Records: tutorial/14-records.md`, `- 15. Opaque operations and bounded retry: tutorial/15-opaque-and-retry.md`; index lines `14. [Records](14-records.md) -- values from a reference sample or an earlier test.` and `15. [Opaque operations and bounded retry](15-opaque-and-retry.md) -- a least-squares fit, and a retest repeated at most a fixed number of times.` +- [ ] **Step 5:** Verification commands; full `ctest --preset cl-debug`. Expected: all pass. +- [ ] **Step 6: Commit** + +```bash +git add examples/tutorial/14_* examples/tutorial/15_* docs/tutorial/14-records.md docs/tutorial/15-opaque-and-retry.md examples/CMakeLists.txt mkdocs.yml docs/tutorial/index.md +git commit -m "docs: add tutorial chapters on records and on opaque operations" +``` + +--- + +### Task 11: The README + +**Files:** +- Modify: `README.md` (rewrite) +- Modify: `examples/CMakeLists.txt` (README snippet and output checks) + +**Interfaces:** +- Consumes: `examples/readme.cpp` region `program`, `examples/readme.expected.txt` (Task 1); tutorial page URLs (Tasks 3-7). + +- [ ] **Step 1: Pin the README to its program** + +Add after the `readme` registration in `examples/CMakeLists.txt`: + +```cmake +# README.md shows examples/readme.cpp: its one ```cpp block must be a run of +# that source's lines, and its one ```text block a run of the lines it prints. +# GitHub renders the README directly, so it cannot include them as the site's +# pages do. +add_test(NAME docs.readme-snippets + COMMAND "${CMAKE_COMMAND}" + -D "EXAMPLE_SOURCE=${CMAKE_CURRENT_SOURCE_DIR}/readme.cpp" + -D "GUIDE=${PROJECT_SOURCE_DIR}/README.md" + -P "${PROJECT_SOURCE_DIR}/cmake/CheckGuideSnippets.cmake") +add_test(NAME docs.readme-output + COMMAND "${CMAKE_COMMAND}" + -D "EXAMPLE_EXE=$<TARGET_FILE:formula-cpp-example-readme>" + -D "GUIDE=${PROJECT_SOURCE_DIR}/README.md" + -P "${PROJECT_SOURCE_DIR}/cmake/CheckGuideOutput.cmake") +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cmake --preset cl-debug && ctest --preset cl-debug -R "docs\.readme"` +Expected: `docs.readme-snippets` FAILS (the current README's ```cpp blocks are from other examples). + +- [ ] **Step 3: Rewrite `README.md`** + +Exactly this structure; the code block is the `program` region of `examples/readme.cpp` copied verbatim (without the marker lines), the output block is `examples/readme.expected.txt` verbatim. No other ```cpp or ```text block may appear in the README (install snippets use ```cmake and ```bash). + +````markdown +# formula-cpp + +**[Documentation](https://lastrada-software.github.io/formula-cpp/)** · +[Tutorial](https://lastrada-software.github.io/formula-cpp/tutorial/) · +[API reference](https://lastrada-software.github.io/formula-cpp/api/) + +[![Build](https://github.com/LASTRADA-Software/formula-cpp/actions/workflows/build.yml/badge.svg?branch=master)](https://github.com/LASTRADA-Software/formula-cpp/actions/workflows/build.yml) +[![Package](https://github.com/LASTRADA-Software/formula-cpp/actions/workflows/package.yml/badge.svg?branch=master)](https://github.com/LASTRADA-Software/formula-cpp/actions/workflows/package.yml) +[![Pages](https://github.com/LASTRADA-Software/formula-cpp/actions/workflows/pages.yml/badge.svg?branch=master)](https://github.com/LASTRADA-Software/formula-cpp/actions/workflows/pages.yml) +[![codecov](https://codecov.io/gh/LASTRADA-Software/formula-cpp/branch/master/graph/badge.svg)](https://codecov.io/gh/LASTRADA-Software/formula-cpp) +[![Release](https://img.shields.io/github/v/release/LASTRADA-Software/formula-cpp)](https://github.com/LASTRADA-Software/formula-cpp/releases/latest) +[![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) +[![C++23](https://img.shields.io/badge/C%2B%2B-23-blue.svg)](https://en.cppreference.com/w/cpp/23) +[![Header-only](https://img.shields.io/badge/header--only-yes-brightgreen.svg)](#installation) +[![Compilers](https://img.shields.io/badge/compilers-MSVC%20%7C%20clang--cl%20%7C%20Clang%20%7C%20GCC%2014%20%7C%20AppleClang-informational.svg)](#requirements) +[![Docs](https://img.shields.io/badge/docs-online-blue.svg)](https://lastrada-software.github.io/formula-cpp/) + +Declarative, traceable, self-documenting formulas for C++23. Header-only, no dependencies. + +Software that implements a test method usually keeps the formula in one place, its units in +another, where it comes from in a comment, and its audit trail in code written afterwards. Those +drift apart. In formula-cpp they are one declaration: write the formula once, with ordinary +operators, and get the number, its units, its derivation and its documentation from it. + +## Example + +The compressive strength of a concrete specimen: the load that crushed it over the area that +carried it. + +```cpp +<examples/readme.cpp, region program, verbatim> +``` + +```text +<examples/readme.expected.txt, verbatim> +``` + +- **Units are part of the type.** Kilonewtons over square millimetres arrive in megapascals with no + conversion written. ([Units and dimensions](https://lastrada-software.github.io/formula-cpp/tutorial/02-units-and-dimensions/)) +- **Mistakes are compile errors.** `formula::var<Load> + formula::var<Area>` does not compile. + ([Units and dimensions](https://lastrada-software.github.io/formula-cpp/tutorial/02-units-and-dimensions/)) +- **Arithmetic is exact.** The 30 is an exact rational, not a `double`. + ([Exact numbers](https://lastrada-software.github.io/formula-cpp/tutorial/03-exact-numbers/)) + +## What you get + +- **Dimensional analysis at compile time**, with exact unit conversion. ([Dimensions and units](https://lastrada-software.github.io/formula-cpp/dimensions/)) +- **Exact arithmetic**, rounded only where you say, in the mode you name. ([Exact numbers](https://lastrada-software.github.io/formula-cpp/numbers/)) +- **Missing and entered values** that never pass for computed ones. ([Quantities and measurements](https://lastrada-software.github.io/formula-cpp/quantities/)) +- **The method's own rounding, constraints and tables**, as part of the formula. ([Rounding and conditionals](https://lastrada-software.github.io/formula-cpp/rounding-and-conditionals/), [Constraints](https://lastrada-software.github.io/formula-cpp/constraints/), [Lookup tables](https://lastrada-software.github.io/formula-cpp/lookup-tables/)) +- **Traces** that show how every number was reached. ([Tracing](https://lastrada-software.github.io/formula-cpp/tracing/)) +- **Rendering and generated documentation**: text, LaTeX, symbol tables and citations from the same declaration. ([Citations and rendering](https://lastrada-software.github.io/formula-cpp/citations/)) + +New to the library? Start with the [tutorial](https://lastrada-software.github.io/formula-cpp/tutorial/). + +## Installation + +### CPM + +```cmake +CPMAddPackage("gh:LASTRADA-Software/formula-cpp@0.4.0") +target_link_libraries(your_target PRIVATE formula-cpp::formula-cpp) +``` + +### CMake, from an install tree + +```bash +cmake -S . -B build -DFORMULA_INSTALL=ON -DCMAKE_INSTALL_PREFIX=/your/prefix +cmake --install build +``` + +```cmake +find_package(formula-cpp CONFIG REQUIRED) +target_link_libraries(your_target PRIVATE formula-cpp::formula-cpp) +``` + +### Copy the headers + +`include/` is self-contained and depends on nothing outside the standard library. +`formula.hpp` is the umbrella header. `format.hpp`, `render.hpp`, `document.hpp`, `trace.hpp` and +`trace_render.hpp` are separate, because they need `<string>`, `<vector>` or `<format>`: include +them by name when you print, render, document or trace. + +## Requirements + +- C++23 +- CMake 3.23 or newer +- GCC 14 or newer, if you build with GCC + +CI builds and tests every push with MSVC `cl`, `clang-cl`, Clang and GCC 14 on Linux, and +AppleClang on macOS. + +## Build options + +<the existing "Build options" table, unchanged> + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md). + +## Licence + +Apache-2.0. See [`LICENSE`](LICENSE). +```` + +Before committing, check the `format.hpp` claim against `CONTRIBUTING.md` invariant 4 and `include/formula-cpp/formula.hpp`: list exactly the headers the umbrella header leaves out. + +- [ ] **Step 4: Verify** + +Run: `ctest --preset cl-debug -R "docs\.readme|example\.readme|hygiene\."` +Expected: all PASS. Open the README in a Markdown preview and confirm every badge and link resolves (the Codecov badge shows "unknown" until the owner enables the repository; that is expected). + +- [ ] **Step 5: Commit** + +```bash +git add README.md examples/CMakeLists.txt +git commit -m "docs: rewrite the README as a short landing page" +``` + +--- + +### Task 12: The site's home page + +**Files:** +- Modify: `docs/index.md` (rewrite) + +**Interfaces:** +- Consumes: `examples/readme.cpp:program`, `examples/readme.expected.txt` (Task 1); `tutorial/index.md` (Task 2). + +- [ ] **Step 1: Rewrite `docs/index.md`** + +Sections, in order: + +1. `# formula-cpp`, the one-line description, and the README's pitch paragraph. +2. `## Example` -- the README's sentence, then + ````markdown + ```cpp + --8<-- "examples/readme.cpp:program" + ``` + + ```text + --8<-- "examples/readme.expected.txt" + ``` + ```` +3. `## Start here` -- "New to the library? The [tutorial](tutorial/index.md) builds a complete test step by step. The guides below are the reference: each covers one part of the library in depth." +4. `## Guides` -- the existing guide table, unchanged, plus the line that every guide has a runnable program under `examples/`. +5. `## A larger example` -- one paragraph: `examples/cycling_speed.cpp` works out a cyclist's steady-state speed from power as a calculation on a worksheet, with a citation, LaTeX rendering and a symbol table; link to it on GitHub. +6. `## A note on the examples` -- the existing paragraph, unchanged. +7. The closing licence and source line. + +Remove: the cycling walkthrough, "One calculation, four answers", "Three things worth knowing", the "Status" table. + +- [ ] **Step 2: Verify** + +Run: `mkdocs build --strict` and `ctest --preset cl-debug -R "docs\.|hygiene\."`. Expected: PASS. Open `site/index.html` and confirm the example and its output render. + +- [ ] **Step 3: Commit** + +```bash +git add docs/index.md +git commit -m "docs: make the site's home page a short entry point" +``` + +--- + +### Task 13: The CPM tag follows the version + +**Files:** +- Modify: `cmake/CheckVersionConsistency.cmake` +- Modify: `test/CMakeLists.txt:2946-2950` (`hygiene.version`) + +**Interfaces:** +- Consumes: `README.md` and `docs/tutorial/01-first-formula.md`, each containing `CPMAddPackage("gh:LASTRADA-Software/formula-cpp@<version>")`. + +- [ ] **Step 1: Write the failing check** + +Append to `cmake/CheckVersionConsistency.cmake`, before the final `message(STATUS ...)`: + +```cmake +# The README and the tutorial tell a consumer which release to fetch with CPM. +# A version bump that leaves either behind sends every new user to an old +# release, so each must name exactly this version, at least once, and never +# another. +foreach(document IN ITEMS README TUTORIAL) + if(NOT DEFINED ${document}) + message(FATAL_ERROR "CheckVersionConsistency.cmake: ${document} is not set") + endif() + file(READ "${${document}}" text) + string(REGEX MATCHALL "gh:LASTRADA-Software/formula-cpp@[0-9]+\\.[0-9]+\\.[0-9]+" tags "${text}") + if(NOT tags) + message(FATAL_ERROR "version drift: ${${document}} names no CPM tag gh:LASTRADA-Software/formula-cpp@<version>") + endif() + foreach(tag IN LISTS tags) + string(REGEX REPLACE "^.*@" "" tagVersion "${tag}") + if(NOT tagVersion STREQUAL fromHeader) + message(FATAL_ERROR "version drift: ${${document}} fetches ${tagVersion} with CPM, the project is ${fromHeader}") + endif() + endforeach() +endforeach() +``` + +Add to the `hygiene.version` command in `test/CMakeLists.txt`: + +```cmake + -D "README=${PROJECT_SOURCE_DIR}/README.md" + -D "TUTORIAL=${PROJECT_SOURCE_DIR}/docs/tutorial/01-first-formula.md" +``` + +- [ ] **Step 2: Prove it fails on drift** + +Temporarily change the README's tag to `@0.3.0`. Run: `cmake --preset cl-debug && ctest --preset cl-debug -R hygiene.version`. Expected: FAIL with `fetches 0.3.0 with CPM, the project is 0.4.0`. Restore `@0.4.0`; expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add cmake/CheckVersionConsistency.cmake test/CMakeLists.txt +git commit -m "test: fail when the README or tutorial fetches another release" +``` + +--- + +### Task 14: The current-state check + +**Files:** +- Create: `cmake/CheckDocsCurrentState.cmake` +- Create: `cmake/docs-current-state-allowlist.txt` +- Create: `cmake/TestDocsCurrentState.cmake` +- Modify: `test/CMakeLists.txt` (register the self-test only; the real-tree test comes in Task 15) + +**Interfaces:** +- Produces: `cmake -D SOURCE_DIR=<dir> -P cmake/CheckDocsCurrentState.cmake` -- scans `<dir>/README.md`, `<dir>/docs/**/*.md` except `docs/superpowers/`, and the comment lines (`//`, `///`, `/*`, ` *`) of `<dir>/include/**/*.hpp`; fails listing every `file:line: phrase` not allowed by `<dir>/cmake/docs-current-state-allowlist.txt`. An optional `ALLOWLIST` variable overrides the allow-list path. + +- [ ] **Step 1: Write the self-test first** + +`cmake/TestDocsCurrentState.cmake` builds a fixture tree in `WORK_DIR` and runs the check on it twice: + +```cmake +# SPDX-License-Identifier: Apache-2.0 +# The current-state check must fail on history wording, name every offence, +# and accept a line its allow-list names. This builds a small tree in WORK_DIR +# and runs the check on it, so the check is tested on text written to break it, +# not only on a tree that happens to pass. +cmake_minimum_required(VERSION 3.23) + +foreach(variable CHECK_SCRIPT WORK_DIR) + if(NOT DEFINED ${variable}) + message(FATAL_ERROR "TestDocsCurrentState.cmake: ${variable} is not set") + endif() +endforeach() + +file(REMOVE_RECURSE "${WORK_DIR}") +file(WRITE "${WORK_DIR}/README.md" "A Bounds written positionally no longer compiles.\n") +file(WRITE "${WORK_DIR}/docs/guide.md" + "This was previously a warning.\n" + "The two determinations no longer agree.\n" + "New in 0.4.0: literals.\n" + "The factor is used to round the strength, in 2.5 mm steps.\n" + "It used to return a double.\n") +file(WRITE "${WORK_DIR}/docs/superpowers/plan.md" "This used to be ignored, and is.\n") +file(WRITE "${WORK_DIR}/include/formula-cpp/x.hpp" + "/// Formerly named old_name.\n" + "int previously_named = 0; // code, not a comment line: not scanned\n") +file(WRITE "${WORK_DIR}/allow.txt" + "docs/guide.md|The two determinations no longer agree.|describes data, not history\n") + +execute_process(COMMAND "${CMAKE_COMMAND}" -D "SOURCE_DIR=${WORK_DIR}" -D "ALLOWLIST=${WORK_DIR}/allow.txt" + -P "${CHECK_SCRIPT}" + RESULT_VARIABLE exitCode OUTPUT_VARIABLE out ERROR_VARIABLE err) +set(report "${out}${err}") + +if(exitCode EQUAL 0) + message(FATAL_ERROR "the check passed a tree full of history wording:\n${report}") +endif() +foreach(expected IN ITEMS "README.md:1: no longer" "docs/guide.md:1: previously" "docs/guide.md:3: new in" + "docs/guide.md:3: in 0.4.0" "docs/guide.md:5: used to" "include/formula-cpp/x.hpp:1: formerly") + string(FIND "${report}" "${expected}" at) + if(at EQUAL -1) + message(FATAL_ERROR "the check did not report '${expected}':\n${report}") + endif() +endforeach() +foreach(unexpected IN ITEMS "docs/guide.md:2:" "docs/guide.md:4:" "superpowers" "x.hpp:2:") + string(FIND "${report}" "${unexpected}" at) + if(NOT at EQUAL -1) + message(FATAL_ERROR "the check reported '${unexpected}', which it must not:\n${report}") + endif() +endforeach() + +# With every offending line removed, the same tree passes. +file(WRITE "${WORK_DIR}/README.md" "A Bounds is written with designated initialisers.\n") +file(WRITE "${WORK_DIR}/docs/guide.md" "The two determinations no longer agree.\n") +file(WRITE "${WORK_DIR}/include/formula-cpp/x.hpp" "/// Named new_name.\n") +execute_process(COMMAND "${CMAKE_COMMAND}" -D "SOURCE_DIR=${WORK_DIR}" -D "ALLOWLIST=${WORK_DIR}/allow.txt" + -P "${CHECK_SCRIPT}" + RESULT_VARIABLE exitCode OUTPUT_VARIABLE out ERROR_VARIABLE err) +if(NOT exitCode EQUAL 0) + message(FATAL_ERROR "the check failed a clean tree:\n${out}${err}") +endif() +message(STATUS "TestDocsCurrentState: the check fails on history wording and passes a clean tree") +``` + +Register it in `test/CMakeLists.txt` next to `hygiene.no-real-standards-fallback`: + +```cmake +# The current-state check, tested on a tree written to break it. +add_test(NAME hygiene.docs-current-state-self-test + COMMAND "${CMAKE_COMMAND}" + -D "CHECK_SCRIPT=${PROJECT_SOURCE_DIR}/cmake/CheckDocsCurrentState.cmake" + -D "WORK_DIR=${CMAKE_CURRENT_BINARY_DIR}/docs-current-state-self-test" + -P "${PROJECT_SOURCE_DIR}/cmake/TestDocsCurrentState.cmake") +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cmake --preset cl-debug && ctest --preset cl-debug -R docs-current-state-self-test` +Expected: FAIL (the check script does not exist). + +- [ ] **Step 3: Write `cmake/CheckDocsCurrentState.cmake`** + +Requirements, in this order: + +- Header comment: the rule (user documentation describes the library as it is now; history belongs in `CHANGELOG.md`), what is scanned, the allow-list format, and that a scan that examined no file fails. +- `cmake_minimum_required(VERSION 3.23)`; require `SOURCE_DIR`; `ALLOWLIST` defaults to `${SOURCE_DIR}/cmake/docs-current-state-allowlist.txt` and may be absent (an absent file means an empty list). +- Files: `README.md` if it exists; `file(GLOB_RECURSE ... "${SOURCE_DIR}/docs/*.md")` filtered to exclude `/docs/superpowers/`; `file(GLOB_RECURSE ... "${SOURCE_DIR}/include/*.hpp")`. Fail if none was found. +- Read each file with `file(STRINGS ... )` is unsafe for `;` and `[`; read with `file(READ)`, normalise `\r\n`, and walk lines with `string(FIND)` / `string(SUBSTRING)` as `CheckGuideOutput.cmake` does. Never turn the text into a CMake list. +- For `.hpp` files only lines whose first non-blank characters are `//`, `/*` or `*` are scanned. +- Lower-case each scanned line, then test these regexes; each match reports `<path relative to SOURCE_DIR>:<line number>: <the phrase as matched, lower-cased>`: + - `no longer`, `previously`, `formerly`, `once meant`, `was renamed`, `deprecated`, `new in` + - `used to`, **except** when the word before it is `is`, `are`, `was`, `were`, `be`, `been` or `being` ("the factor is used to round" is the passive, not history); check the preceding word with `string(FIND)` on the lower-cased line, not a lookbehind, which CMake's regex lacks + - `before [^.]* existed` + - `(since|as of) v?[0-9]+\.[0-9]+(\.[0-9]+)?` and `(in|until|before|after) v?[0-9]+\.[0-9]+\.[0-9]+` (a release reference: "since 0.3", "in 0.4.0"; a two-part number after "in" is a measurement, "in 2.5 mm", and is not matched) +- Skip a line when the allow-list has an entry `<relative path>|<exact line text, trimmed>|<reason>` for it. Allow-list lines starting with `#` and blank lines are ignored; an entry without a reason is itself an error. +- Report every offence, sorted by file then line, then `FATAL_ERROR` with the count and one sentence: "User documentation describes the library as it is now; say what it does today, or record the change in CHANGELOG.md. A line that describes data rather than history goes in cmake/docs-current-state-allowlist.txt with its reason." + +`cmake/docs-current-state-allowlist.txt`, initial content: + +```text +# Lines in user documentation that match a history phrase but describe the +# library as it is now, read by cmake/CheckDocsCurrentState.cmake. One per line: +# +# <path relative to the repository root>|<the line, exactly, trimmed>|<why it is not history> +# +# Keep it short: every entry is a place the check does not look. +``` + +- [ ] **Step 4: Run to verify the self-test passes** + +Run: `ctest --preset cl-debug -R docs-current-state-self-test` +Expected: PASS. Then confirm the patterns are narrow enough on real prose by running the check on the real tree (`cmake -D SOURCE_DIR=. -P cmake/CheckDocsCurrentState.cmake`) and recording the number of hits it reports. It is expected to FAIL there; Task 15 clears it. + +- [ ] **Step 5: Commit** + +```bash +git add cmake/CheckDocsCurrentState.cmake cmake/docs-current-state-allowlist.txt cmake/TestDocsCurrentState.cmake test/CMakeLists.txt +git commit -m "test: add a check that user documentation describes only the present" +``` + +--- + +### Task 15: The current-state sweep + +**Files:** +- Modify: every file `cmake/CheckDocsCurrentState.cmake` reports (expected: several `docs/*.md` guides, `README.md` if any remains, and doc comments in `include/formula-cpp/*.hpp`) +- Modify: `cmake/docs-current-state-allowlist.txt` +- Modify: `test/CMakeLists.txt` (register `hygiene.docs-current-state`) + +**Interfaces:** +- Consumes: the check (Task 14). + +- [ ] **Step 1: Register the real-tree test and watch it fail** + +```cmake +# User documentation -- README.md, docs/ (not docs/superpowers/) and the doc +# comments that form the API reference -- describes the library as it is now. +# History belongs in CHANGELOG.md. See CONTRIBUTING.md, "Documentation". +add_test(NAME hygiene.docs-current-state + COMMAND "${CMAKE_COMMAND}" + -D "SOURCE_DIR=${PROJECT_SOURCE_DIR}" + -P "${PROJECT_SOURCE_DIR}/cmake/CheckDocsCurrentState.cmake") +``` + +Run: `cmake --preset cl-debug && ctest --preset cl-debug -R "hygiene.docs-current-state$" --output-on-failure` +Expected: FAIL, listing every hit. Save the list in the task report. + +- [ ] **Step 2: Rewrite or allow-list each hit** + +For each reported line, decide: + +- **History** (says what the library did before, what changed, or when): rewrite it to state current behaviour only. Example -- `docs/dimensions.md` around line 412-419: + + Before: "Each flag is a `BoundsEnd`, which reads as a `bool` but only a `bool` sets, so a `Bounds` written positionally before the two flags existed no longer compiles: in `{ true, 0, 1, 100, 1 }`, once 0 to 100, the 0 lands on `highPresent`. Only `{}` and `{ false }`, which declare no bounds, and `{ true }`, which once meant 0 to 0 and now declares a minimum of 0, still compile. Write `bounds()`, `at_least()`, `at_most()` or designated initialisers." + + After: "Each flag is a `BoundsEnd`, which reads as a `bool` but can be set only from a `bool`, so a positional initialiser that puts a number where a flag belongs does not compile: in `{ true, 0, 1, 100, 1 }` the 0 would land on `highPresent`. `{}` and `{ false }` declare no bounds, and `{ true }` declares a minimum of 0. Write `bounds()`, `at_least()`, `at_most()` or designated initialisers." + + A doc comment in `include/` that names a former spelling is rewritten the same way; if the history it carries is not yet in `CHANGELOG.md`, add it there under `## [Unreleased]`. +- **Not history** (the words describe data or a process: "the two determinations no longer agree", "the net draw is no longer read" in a worksheet's recalculation): add an allow-list entry with its reason. + +Rewrites must stay accurate: re-read the surrounding paragraph and the code it describes. Where a guide's ```text or ```cpp block changes, run that guide's `docs.*` tests. + +- [ ] **Step 3: Run to verify it passes** + +Run: `ctest --preset cl-debug -R "hygiene\.|docs\."` +Expected: all PASS, `hygiene.docs-current-state` included. + +- [ ] **Step 4: Full suite, Doxygen, site** + +Run: `ctest --preset cl-debug`, `cmake --build out/build/cl-debug --target formula-cpp-docs-api` (if the doc comments changed; expect no new Doxygen warnings), `mkdocs build --strict`. Expected: all pass. + +- [ ] **Step 5: Commit** + +```bash +git add docs include cmake/docs-current-state-allowlist.txt test/CMakeLists.txt CHANGELOG.md README.md +git commit -m "docs: describe the library as it is now, and enforce it" +``` + +--- + +### Task 16: Coverage and Codecov + +**Files:** +- Modify: `CMakePresets.json` +- Create: `.github/workflows/coverage.yml` +- Create: `codecov.yml` + +- [ ] **Step 1: Add the preset** + +In `configurePresets`, after `clang-ubsan`: + +```json + { "name": "clang-coverage", "displayName": "clang++ source-based coverage", "inherits": "posix", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "CMAKE_C_COMPILER": "clang", + "CMAKE_CXX_COMPILER": "clang++", + "CMAKE_CXX_FLAGS": "-fprofile-instr-generate -fcoverage-mapping", + "CMAKE_EXE_LINKER_FLAGS": "-fprofile-instr-generate" + } } +``` + +and matching entries in `buildPresets` and `testPresets` (same shape as `clang-ubsan`). + +- [ ] **Step 2: Write `.github/workflows/coverage.yml`** + +```yaml +# SPDX-License-Identifier: Apache-2.0 +# +# Measures how much of the library the test suite runs, and reports it to +# Codecov. Only include/ is counted: the tests, examples and fetched +# dependencies are what does the running, not what is measured. The report is +# informational (codecov.yml): it never fails a pull request. +# +# Uploading needs the CODECOV_TOKEN repository secret, which the repository +# owner adds once on codecov.io and in Settings > Secrets. Without it the +# upload step fails and the job is red; the build and tests above it still +# show whether the suite passed. +name: Coverage +on: + push: + branches: [ master ] + pull_request: + +concurrency: + group: coverage-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +env: + CPM_SOURCE_CACHE: ${{ github.workspace }}/.cache/CPM + # The same Clang as build.yml's Linux legs. + CLANG_VERSION: "22" + +jobs: + coverage: + name: Linux-clang-coverage + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Restore CPM cache + uses: actions/cache/restore@v4 + with: + path: ${{ env.CPM_SOURCE_CACHE }} + key: cpm-${{ runner.os }}-${{ hashFiles('test/CMakeLists.txt') }} + restore-keys: cpm-${{ runner.os }}- + + - uses: seanmiddleditch/gha-setup-ninja@v6 + + # Retried as a whole, for the reason build.yml gives. + - name: Install Clang ${{ env.CLANG_VERSION }} + run: | + for attempt in 1 2 3; do + if wget -q https://apt.llvm.org/llvm.sh \ + && chmod +x llvm.sh \ + && sudo ./llvm.sh ${{ env.CLANG_VERSION }} \ + && sudo apt-get install -y clang-${{ env.CLANG_VERSION }} llvm-${{ env.CLANG_VERSION }}; then + exit 0 + fi + echo "::warning::Clang install attempt $attempt failed; retrying" + sleep $((attempt * 15)) + done + echo "::error::Clang ${{ env.CLANG_VERSION }} could not be installed after 3 attempts" + exit 1 + + - name: Configure + run: cmake --preset clang-coverage -DCMAKE_CXX_COMPILER=clang++-${{ env.CLANG_VERSION }} -DCMAKE_C_COMPILER=clang-${{ env.CLANG_VERSION }} + + - name: Build + run: cmake --build --preset clang-coverage + + # Every test process writes its own raw profile (%p is its process id). + - name: Test + env: + LLVM_PROFILE_FILE: ${{ github.workspace }}/out/profiles/%p.profraw + run: ctest --preset clang-coverage + + - name: Export the coverage of include/ + run: | + set -euo pipefail + llvm-profdata-${{ env.CLANG_VERSION }} merge -sparse out/profiles/*.profraw -o out/coverage.profdata + # llvm-cov takes the first binary positionally and every other one + # after -object. + mapfile -t binaries < <(find out/build/clang-coverage -type f -perm -u+x -name 'formula-cpp-*' | sort) + test "${#binaries[@]}" -gt 0 + others=() + for exe in "${binaries[@]:1}"; do others+=(-object "$exe"); done + llvm-cov-${{ env.CLANG_VERSION }} export -format=lcov -instr-profile=out/coverage.profdata \ + "${binaries[0]}" "${others[@]}" \ + -ignore-filename-regex='(^|/)(test|examples|tools|support|_deps|\.cache)/' \ + > out/coverage.lcov + test -s out/coverage.lcov + + - name: Upload to Codecov + uses: codecov/codecov-action@v5 + with: + files: out/coverage.lcov + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: true +``` + +Before relying on the `find` expression, list the executables the build produces (`find out/build/clang-coverage -type f -perm -u+x`) in the first CI run and adjust the name pattern so every test and example binary is passed and nothing else (test executables may be named differently from examples' `formula-cpp-example-*`; check `test/CMakeLists.txt`'s `add_executable` names). Negative tests are never built (`EXCLUDE_FROM_ALL`, and expected not to compile). + +- [ ] **Step 3: Write `codecov.yml`** + +```yaml +# SPDX-License-Identifier: Apache-2.0 +# Coverage is reported on every pull request but never blocks one. +coverage: + status: + project: + default: + informational: true + patch: + default: + informational: true +ignore: + - "test/**" + - "examples/**" + - "tools/**" + - "support/**" +``` + +- [ ] **Step 4: Verify** + +Run on Windows: `cmake --list-presets` (the new preset is hidden there, by its `posix` condition) and `ctest --preset cl-debug -R hygiene.spdx` (the new YAML files carry the SPDX line). The workflow itself is verified by its first CI run on the pull request; record in the task report that it has not run locally. + +- [ ] **Step 5: Commit** + +```bash +git add CMakePresets.json .github/workflows/coverage.yml codecov.yml +git commit -m "ci: measure the library's test coverage and report it to Codecov" +``` + +--- + +### Task 17: CONTRIBUTING.md and the changelog + +**Files:** +- Modify: `CONTRIBUTING.md` +- Modify: `CHANGELOG.md` (`## [Unreleased]`) + +- [ ] **Step 1: Add the "Documentation" section to `CONTRIBUTING.md`** + +After the "Invariants" list, a new `## Documentation` section with: + +1. **The current-state rule**: user documentation -- `README.md`, everything under `docs/` except `docs/superpowers/`, and the doc comments in `include/` that form the API reference -- describes the library as it is now. It never says what the library used to do, what changed, or in which release something appeared; that belongs in `CHANGELOG.md`. `ctest -R hygiene.docs-current-state` enforces it; a line that matches a history phrase but describes data goes in `cmake/docs-current-state-allowlist.txt` with its reason. +2. **The tutorial**: each chapter is a page in `docs/tutorial/` and a program in `examples/tutorial/`. The page includes code with `--8<-- "examples/tutorial/<file>.cpp:<region>"` and output with `--8<-- "examples/tutorial/<file>.expected.txt"`; regions are marked in the program with `// --8<-- [start:<region>]` and `// --8<-- [end:<region>]`. When a program's output changes, update its `.expected.txt` (`example.<name>.output` fails until you do) and re-read the page. `mkdocs build --strict` fails on a missing file or region; it runs on every pull request. +3. **The README** shows `examples/readme.cpp` and its output verbatim; `docs.readme-snippets` and `docs.readme-output` check them. The CPM tag in the README and tutorial chapter 1 must equal the project version (`hygiene.version`). +4. **The consumer-globals test** -- move here, verbatim apart from tense, the README paragraph beginning "`test/consumer_globals_tests.cpp` declares 258 ordinary globals". + +- [ ] **Step 2: Changelog** + +Under `## [Unreleased]`, `### Added`: + +```markdown +- **A tutorial** on the documentation site, in two tracks: nine chapters that build one + compressive-strength test step by step, and six independent chapters on the rest of the library. + Its code and output are included from programs the test suite builds and runs. +- **Coverage reporting**: a workflow measures the test suite's coverage of `include/` and reports + it to Codecov. +``` + +and under `### Changed` (create the heading if absent): + +```markdown +- The README is a short landing page: badges, a link to the documentation, a minimal example, + and installation with CPM, `find_package` or the headers alone. The vcpkg section is removed, + as no port is published. +- The documentation describes the library as it is now; what changed, and when, is recorded only + in this changelog. A test enforces it. +``` + +- [ ] **Step 3: Verify and commit** + +Run: `ctest --preset cl-debug -R "hygiene\."` and `mkdocs build --strict`. Expected: PASS. + +```bash +git add CONTRIBUTING.md CHANGELOG.md +git commit -m "docs: document the documentation rules, and record this change" +``` + +--- + +### Task 18: Licence detection (requires the owner's decision before it runs) + +**Files:** +- Modify: `LICENSE` + +The repository's `LICENSE` differs from the Apache License 2.0 text in its terms, not only its layout: compared word by word with the canonical text (https://www.apache.org/licenses/LICENSE-2.0.txt), it differs in dozens of places -- for example, section 2's "direct or contributory patent infringement" wording, a grant to "sell copies" that the canonical text does not contain, and the notice-exclusion clause of section 4(d). That is why GitHub reports "Other". The appendix's copyright line (`Copyright 2026 Yaraslau Tamashevich`) is the only intended difference. + +This task changes the legal text of the project's licence. **Do not run it until the owner has explicitly confirmed** that the canonical Apache-2.0 text is what the project is licensed under. If confirmed: + +- [ ] **Step 1:** Replace `LICENSE` with the canonical text from https://www.apache.org/licenses/LICENSE-2.0.txt, byte for byte (LF line endings), keeping the appendix exactly as the canonical file has it and appending nothing else. +- [ ] **Step 2:** Verify: a whitespace-normalised word diff against the canonical file is empty. After the branch is pushed, `gh api repos/LASTRADA-Software/formula-cpp/license --jq .license.spdx_id` on the branch's PR head reports `Apache-2.0` once merged. +- [ ] **Step 3: Commit** + +```bash +git add LICENSE +git commit -m "chore: use the Apache License 2.0 text verbatim" +``` + +If the owner decides otherwise, this task is dropped; the README's licence badge is static and correct either way. + +--- + +### Task 19: Finish + +- [ ] **Step 1:** Whole-branch review (`sdd-reviewer`) against the spec and this plan. +- [ ] **Step 2:** Every preset green: `cl-debug`, `cl-release`, `clangcl-debug`, `clangcl-release` on Windows; `clang-debug`, `clang-release`, `gcc-release` (g++-14), `clang-ubsan` on Linux; every negative test on cl and clang-cl. +- [ ] **Step 3:** `mkdocs build --strict` and the Doxygen target with no new warnings. +- [ ] **Step 4:** Push the branch and open the PR as a draft; drive CI to green, including the new Coverage and Pages builds; mark it ready (`gh pr ready`). The owner reviews and merges. +- [ ] **Step 5:** Tell the owner the two settings outside the repository: enable the repository on codecov.io and add the `CODECOV_TOKEN` secret. diff --git a/docs/superpowers/specs/2026-10-06-approachable-docs-design.md b/docs/superpowers/specs/2026-10-06-approachable-docs-design.md new file mode 100644 index 00000000..1c3a4bf4 --- /dev/null +++ b/docs/superpowers/specs/2026-10-06-approachable-docs-design.md @@ -0,0 +1,298 @@ +# Approachable README, a tutorial, and documentation of the current state only + +## Goal + +A C++ developer who has never seen formula-cpp should understand what it is +for within a minute of opening the repository, and be able to learn it step by +step from there. Concretely: + +1. `README.md` becomes a short landing page with a minimal example, status + badges and a link to the user documentation at the very top. +2. A new tutorial on the documentation site teaches the library incrementally, + from the first formula to the advanced features. +3. The user documentation describes the library as it is now, and never its + past. History belongs only in `CHANGELOG.md`. A test enforces this. + +## Readers + +Experienced modern C++ developers: fluent in C++20/23, `std::expected`, +`constexpr` and class-type template arguments, but new to this library and to +test-method standards. The tutorial explains the library's concepts, not the +language. + +The tone is professional and neutral throughout. Page and chapter titles are +plain and descriptive ("Tutorial", "Units and dimensions"). Informal labels for +the tutorial do not appear in any page. + +## 1. README.md + +The README becomes a landing page of roughly 120 lines and stops duplicating the +documentation site. In order: + +1. **A link line** at the very top: **Documentation** + (<https://lastrada-software.github.io/formula-cpp/>) · Tutorial · API + reference. +2. **Badges.** + - Live: the Build, Package and Pages workflows (GitHub workflow badges), + Codecov, and the latest GitHub release. + - Static: licence (Apache-2.0, linking to `LICENSE`), C++23, header-only, and + the supported compilers (MSVC, clang-cl, Clang, GCC 14, AppleClang). + - A "docs" badge linking to the documentation site. +3. **A one-paragraph pitch**: what the library is, and the problem it solves -- + a test method's formula, its units, its provenance and its audit trail + drifting apart in ordinary code. +4. **A minimal example** (below), with the output it prints. +5. **Three one-line points** under the example, each linking to its tutorial + chapter: kN over mm² arrives in MPa with no conversion written; `var<Load> + + var<Area>` does not compile; the result is an exact rational, not a + `double`. +6. **What you get**: about six one-line bullets, each linking to its guide -- + compile-time units, exact arithmetic, absent versus entered values, the + method's rounding, traces, rendering and generated documentation. +7. **Installation**, three paths, all of which work today: + - CPM: `CPMAddPackage("gh:LASTRADA-Software/formula-cpp@0.4.0")`; + - `find_package(formula-cpp CONFIG REQUIRED)` from an install tree; + - copying `include/`, with the note on the opt-in headers (`render.hpp`, + `document.hpp`, `trace.hpp`, `trace_render.hpp`, `format.hpp`). + The vcpkg section is removed: no port is published. + The CPM line names the current release. `cmake/CheckVersionConsistency.cmake` + is extended to fail when that tag differs from the project version, in the + README and in tutorial chapter 1, so a version bump cannot leave it behind. +8. **Requirements** and **build options** (the existing tables), and a link to + `CONTRIBUTING.md`. +9. **Licence.** + +Removed from the README: the cycling walkthrough, the "See it work" sections, +the "shipped" status table, the sentence naming the latest release (the release +badge shows it), and the consumer-globals paragraph, which moves to +`CONTRIBUTING.md`. + +### The minimal example + +The simplest complete program the library allows, in the domain the tutorial +opens with. It is a real program under `examples/` (`examples/readme.cpp`), +built and run by CTest. Every name is written with `formula::`; there is no +`using namespace`. The result is checked before it is read. + +```cpp +#include <formula-cpp/format.hpp> +#include <formula-cpp/formula.hpp> + +#include <print> + +// A quantity is a type: a symbol, a description and a unit. +using Load = formula::Quantity<struct LoadTag, "F", "maximum load", formula::unit::Kilonewton>; +using Area = formula::Quantity<struct AreaTag, "A_c", "loaded area", formula::unit::SquareMillimetre>; +using Strength = formula::Quantity<struct StrengthTag, "f_c", "compressive strength", formula::unit::Megapascal>; + +// The formula, written once with ordinary operators. +constexpr auto strength = formula::yields<Strength>(formula::var<Load> / formula::var<Area>); + +int main() +{ + auto const specimen = formula::environment(formula::Measured<Load> { 675 }, formula::Measured<Area> { 22500 }); + + auto const result = formula::checked_evaluate(strength, specimen); + if (!result) + { + std::println("cannot calculate: {}", formula::describe(result.error())); + return 1; + } + std::println("{} = {}", formula::symbol_of<Strength>(), *result); +} +``` + +The README shows exactly what the program prints (expected `f_c = 30 MPa`; +the implementation pins the real text, including any source marker). The +README is rendered by GitHub, so it cannot include files: its code block is +pinned by the existing `cmake/CheckGuideSnippets.cmake`, and its output block +by `cmake/CheckGuideOutput.cmake`, both run against `examples/readme.cpp`. + +## 2. The tutorial + +### Files + +- Pages: `docs/tutorial/index.md` and one page per chapter, + `docs/tutorial/NN-<name>.md`. +- Programs: `examples/tutorial/NN_<name>.cpp`, each with its expected output + checked in beside it as `examples/tutorial/NN_<name>.expected.txt`. +- CTest builds and runs every program; a new `cmake/CheckExpectedOutput.cmake` + fails the test when the program's output differs from its `.expected.txt`, + byte for byte after normalising line endings. + +### Code and output are included, never copied + +The tutorial pages include their code and output from the files above with +`pymdownx.snippets`, so the published pages cannot drift from what compiles and +runs. + +- A program marks each region a page shows with comment markers: + `// --8<-- [start:evaluate]` and `// --8<-- [end:evaluate]`. +- A page includes a region with `--8<-- "examples/tutorial/01_first_formula.cpp:evaluate"`, + and the output with `--8<-- "examples/tutorial/01_first_formula.expected.txt"`. +- `mkdocs.yml` enables `pymdownx.snippets` with `base_path: ["."]` and + `check_paths: true`. With the existing `mkdocs build --strict`, a missing + file fails the Pages build. The implementation verifies that a missing + *section* fails it too, and adds a check of its own if it does not. +- On GitHub the tutorial pages show the raw `--8<--` lines; they are written to + be read on the documentation site, which the README links to. + +The existing guides keep their current checks; moving them to includes is out of +scope. + +The one block in the tutorial that is not included from a compiled file is the +consumer's CMake setup in chapter 1 (`CPMAddPackage` and +`target_link_libraries`), because the repository builds the tutorial programs as +its own targets. Its version tag is covered by the version check in section 1. + +### Chapter structure + +Every chapter page has the same sections: + +1. **What this chapter covers** -- two or three sentences. +2. **The code** -- included regions, each followed by its explanation. +3. **The output** -- included from `.expected.txt`. +4. **Summary** -- the API this chapter introduced, as a short list. +5. **Further reading** -- the matching reference guide and the API reference. + +### Core track: one compressive-strength test + +Each core chapter's program is complete and compiles on its own, and extends the +previous chapter's program, so a reader can diff two consecutive chapters to see +exactly what was added. Every standard cited is an invented `Example Standard`. + +| # | Chapter | Introduces | +|---|---|---| +| 1 | First formula | Setup with CPM; `Quantity`, `var`, `yields`, `environment`, `checked_evaluate`; handling the `std::expected` result | +| 2 | Units and dimensions | The area from the side lengths; kN over mm² as MPa; a dimensional mistake as a compile error | +| 3 | Exact numbers | `Rational`, `_r` literals, exact versus approximate printing | +| 4 | Missing and entered values | A measurement nobody took, a value entered by hand, `ValueSource` | +| 5 | Rounding as the method specifies | A rounding node; named rounding modes | +| 6 | Constraints and verdicts | A tolerance on the specimen's size; the four-state outcome | +| 7 | Citations, rendering, documentation | `documented()`; plain-text and LaTeX rendering; `document()` and its symbol table | +| 8 | Tracing | `explain()`, `render_trace()` | +| 9 | Calculations and worksheets | Density and strength as one `formula::calculation`; recalculating what a change reaches; what-if copies | + +Chapter 2's compile error is quoted by its `static_assert` message, which is +stable, tested API, not by one compiler's full output. The implementation +confirms that `hygiene.documented-diagnostic-text` scans `docs/tutorial/`, and +extends it if it does not. + +### Advanced track: independent chapters + +Each is one self-contained program. It uses the strength test where that fits +naturally and its own small example otherwise, and ends by pointing into its +reference guide. + +| # | Chapter | Example | +|---|---|---| +| 10 | Lookup tables | A correction factor looked up by specimen shape | +| 11 | Methods and overlays | Cube and cylinder variants; one jurisdiction overlay | +| 12 | Series | Strength at 7, 14 and 28 days | +| 13 | Statistics | Mean and spread of three specimens; outlier rejection | +| 14 | Records | Reading a reference sample and a prior test | +| 15 | Opaque operations and bounded retry | A least-squares fit; a retest repeated at most a fixed number of times | + +### Tutorial index page + +`docs/tutorial/index.md` states the prerequisites (a C++23 compiler, CMake 3.23 +or newer, CPM), describes the two tracks, and says the core track is meant to be +read in order. + +## 3. Documentation site + +- `mkdocs.yml` navigation becomes: Home · Tutorial (index, then the chapters) · + Guides (the existing reference pages) · Gallery · API reference. +- `docs/index.md` stops duplicating the README. It holds the pitch; the README's + minimal example, included from `examples/readme.cpp` and its expected output + via snippets; **Start here**, pointing to the tutorial; the guides table; and + the existing note that every standard cited is invented. The cycling + walkthrough and the "shipped" table are removed from it; + `examples/cycling_speed.cpp` stays, linked as a larger worked example. + +## 4. Documentation describes the current state only + +### The rule + +User documentation is `README.md`, everything under `docs/` except +`docs/superpowers/`, and the doc comments in `include/` that form the API +reference. It describes the library as it is now: what it does, what it +refuses, how to use it. It never says what the library used to do, what +changed, or in which release something appeared. That belongs only in +`CHANGELOG.md`. + +`CONTRIBUTING.md` gains a "Documentation" section stating this rule. + +### The check + +`cmake/CheckDocsCurrentState.cmake`, run as the CTest `hygiene.docs-current-state`: + +- Scans `README.md`, `docs/**/*.md` except `docs/superpowers/`, and the comment + lines of `include/**/*.hpp`. +- Fails on history wording, case-insensitively: "no longer", "previously", + "used to", "formerly", "once meant", "before … existed", "was renamed", + "deprecated", "new in", and release references such as "since 0.3", + "in 0.4.0" or "as of 0.2". +- Accepts a hit listed in `cmake/docs-current-state-allowlist.txt`, by file and + exact line text, the same pattern as `cmake/real-standards-allowlist.txt`. + Each entry carries a one-line reason. Legitimate uses -- "the two + determinations no longer agree" describes data, not history -- go there. +- Reports every offending file, line and phrase, not just the first. + +### The sweep + +Every existing hit is rewritten to describe current behaviour only, or +allow-listed with its reason. For example, the `Bounds` passage in +`docs/dimensions.md` that explains what a positional initialiser "written before +the two flags existed" does today is rewritten to state what compiles and what +does not. `CHANGELOG.md` is out of scope for both the check and the sweep. + +## 5. Coverage and Codecov + +- `CMakePresets.json` gains `clang-coverage`: Linux only, Clang 22, source-based + coverage (`-fprofile-instr-generate -fcoverage-mapping`), Debug. +- `.github/workflows/coverage.yml` runs on push to `master` and on pull + requests: installs Clang 22 the way `build.yml` does (with its retry), builds, + runs `ctest`, merges the raw profiles with `llvm-profdata`, exports LCOV with + `llvm-cov export` restricted to `include/` (tests, examples and `_deps` are + excluded), and uploads with `codecov/codecov-action@v5` using the + `CODECOV_TOKEN` secret. +- `codecov.yml` makes the project and patch statuses informational: coverage is + reported on pull requests but never blocks one. +- Owner setup, outside the repository: enable the repository on codecov.io and + add the `CODECOV_TOKEN` secret. + +## 6. Licence detection + +GitHub reports the repository's licence as "Other" although `LICENSE` is the +Apache-2.0 text with its appendix filled in. The implementation runs GitHub's +detector (`licensee`) against `LICENSE`, finds what stops the match, and fixes +it without changing the copyright line. The README's licence badge is static, +so it is correct independently of this. + +## 7. CONTRIBUTING.md + +Gains: + +- the "Documentation" section with the current-state rule and its check; +- how to add or change a tutorial chapter: the snippet markers, and updating + `.expected.txt` when a program's output changes; +- the consumer-globals paragraph moved from the README. + +## Testing + +- Every tutorial program and `examples/readme.cpp` builds on all CI compilers + and its output matches its `.expected.txt` (`cmake/CheckExpectedOutput.cmake`). +- The README's code and output blocks are pinned to `examples/readme.cpp`. +- `mkdocs build --strict` fails on a missing included file or section. +- `hygiene.docs-current-state` passes on the swept tree, and is shown to fail on + a fixture containing each flagged phrase. +- The coverage workflow produces an LCOV report limited to `include/` and + uploads it. + +## Out of scope + +- Moving the existing guides to snippet includes. +- A vcpkg port. +- Coverage thresholds that block a pull request. +- Rewriting `CHANGELOG.md`. diff --git a/docs/tracing.md b/docs/tracing.md index 22360882..cc3ea608 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -154,7 +154,7 @@ bug -- an overridden number was not derived, so there is nothing to trace -- but it means `explained.trace.steps[explained.trace.root()]`, the pattern the snippet above uses, reads past the end of an empty vector whenever the result happens to be an override. Check `empty()` before reading `root()`, the way -the snippet above now does. +the snippet above does. One difference is not about cost but about **where** the call can happen. `evaluate` and `checked_evaluate` are `constexpr` and remain usable in a @@ -832,7 +832,7 @@ constructor, so there is no zero for `{}` to produce; `{.maxSteps = 10}` and gets one -- `numbers`, the notation every value is written in, defaults to fractions, and [Displaying numbers](display.md) shows the decimal styles -- while this one does not, because a sensible default does not exist. An -unbounded render of a derivation with a hundred thousand steps once collapsed +unbounded render of a derivation with a hundred thousand steps would collapse into one wall of text long enough to be practically unusable -- the same failure mode `trace.hpp`'s flat, index-addressed arena exists to make representable without recursion, just at the rendering end instead of the @@ -895,11 +895,9 @@ are not sequenced. Walk a `Trace` in sequence, never concurrently. Evaluation has a two-parameter extension point -- a consumer writes their own node kind and a `checked_evaluate_si(node, environment)` overload for it, -found by ADL. Adding a sink parameter to every overload the library ships -could have broken every such overload by making it invisible to the -dispatcher; instead, `detail::dispatch` prefers a sink-aware, three-parameter -overload where one exists for a node and falls back to the older -two-parameter one where it does not: +found by ADL. `detail::dispatch` prefers a sink-aware, three-parameter +overload where one exists for a node and falls back to the two-parameter one +where it does not, so such an overload stays visible to the dispatcher: ```cpp template <typename Rep, typename N, typename Env, typename Sink> @@ -912,14 +910,13 @@ template <typename Rep, typename N, typename Env, typename Sink> } ``` -(`sink.hpp`.) A node written against the older, two-parameter extension point -therefore keeps evaluating correctly, with the right answer, composed with any -other node exactly as before. What it does **not** do is contribute anything -to a trace -- there is no overload to call the sink through, so `entered` and -`produced` are simply never called for that node. `test/sink_tests.cpp` proves -both halves of this at once, by counting: a `LegacyNode` added to a `Mass` -gets the right sum, `12`, but the sink only ever hears about the two nodes -that know it exists: +(`sink.hpp`.) A node with only the two-parameter overload therefore evaluates +correctly, with the right answer, composed with any other node. What it does +**not** do is contribute anything to a trace -- there is no overload to call +the sink through, so `entered` and `produced` are simply never called for that +node. `test/sink_tests.cpp` proves both halves of this at once, by counting: +a `LegacyNode` added to a `Mass` gets the right sum, `12`, but the sink only +ever hears about the two nodes that know it exists: ```cpp auto const result = formula::checked_evaluate_si<formula::Rational>(expression, environmentOf(5, 1), sink); @@ -965,7 +962,7 @@ traced, and its line says its inside is not shown -- see ## Every citation here is invented -Every citation used to demonstrate tracing on this page -- and in +Every citation that demonstrates tracing on this page -- and in `examples/tracing.cpp` and the gallery's derivation -- names a fictional `Example Standard`, never a real one, for the reason `docs/citations.md` gives in full: a real standard's clause numbers and equations are copyrighted diff --git a/docs/tutorial/01-first-formula.md b/docs/tutorial/01-first-formula.md new file mode 100644 index 00000000..3f673518 --- /dev/null +++ b/docs/tutorial/01-first-formula.md @@ -0,0 +1,109 @@ +# 1. First formula + +This chapter calculates one specimen's compressive strength from the maximum +load it carried and the area that carried it. Along the way it introduces the +four ideas everything else in the library builds on: a quantity, a formula, an +environment of measurements, and a checked evaluation. + +## Add the library to a project + +formula-cpp is header-only. With [CPM.cmake](https://github.com/cpm-cmake/CPM.cmake) +in `cmake/CPM.cmake`, these lines fetch it and link a program against it: + +```cmake +include(cmake/CPM.cmake) +CPMAddPackage("gh:LASTRADA-Software/formula-cpp@0.4.0") + +add_executable(strength main.cpp) +target_compile_features(strength PRIVATE cxx_std_23) +target_link_libraries(strength PRIVATE formula-cpp::formula-cpp) +``` + +The program starts with these includes: + +```cpp +--8<-- "examples/tutorial/01_first_formula.cpp:includes" +``` + +`formula.hpp` is the library. `format.hpp` lets `std::format` and +`std::print` write its values. The headers for rendering, documentation and +tracing are separate, and chapters 7 and 8 introduce them. + +## Declare the quantities + +```cpp +--8<-- "examples/tutorial/01_first_formula.cpp:quantities" +``` + +A quantity is a type. The four template arguments used here are a tag that +makes it unique, its symbol, its description, and the unit its values are +stated in. `Load` is stated in kilonewtons, `Area` in square millimetres +and `Strength` in megapascals. Because the tag makes each quantity its own +type, two quantities with the same unit are still different types: a load +can never be passed where another kilonewton quantity is expected. + +## Write the formula + +```cpp +--8<-- "examples/tutorial/01_first_formula.cpp:formula" +``` + +`formula::var<Q>` stands for the value of `Q`, and ordinary operators combine +such values into a formula. `formula::yields<Strength>` names the quantity +the formula calculates. The formula is a compile-time object: declaring it +computes nothing, and no value exists until it is evaluated. + +## Provide the measurements + +```cpp +--8<-- "examples/tutorial/01_first_formula.cpp:environment" +``` + +`formula::Measured<Q>` holds one value of `Q`, stated in `Q`'s declared unit: +675 is a load in kilonewtons and 22500 an area in square millimetres. +`formula::environment()` collects the measurements a formula is evaluated +against. + +## Evaluate, and check the result + +```cpp +--8<-- "examples/tutorial/01_first_formula.cpp:evaluate" +``` + +`formula::checked_evaluate` returns a `std::expected`. An arithmetic failure, +such as a division by zero, is an error value: never an exception, and never +a silent zero. The program checks the result before reading it, and prints +the error and returns 1 if there is one. + +The result prints as a number and its unit. `formula::symbol_of<Strength>()` +gives the quantity's symbol, and `source()` says where the value came from: +the library derived it from the formula. + +## Output + +```text +--8<-- "examples/tutorial/01_first_formula.expected.txt" +``` + +The formula divides kilonewtons by square millimetres, and the result came +out in megapascals with no conversion written; chapter 2 explains why. + +## Summary + +- `formula::Quantity` -- declares a quantity as a type; the four template + arguments used here are tag, symbol, description and unit. +- `formula::var<Q>` -- stands for the value of `Q` in a formula. +- `formula::yields<Q>` -- names the quantity a formula calculates. +- `formula::Measured<Q>` -- one measured value of `Q`, in `Q`'s declared unit. +- `formula::environment()` -- collects the measurements a formula is + evaluated against. +- `formula::checked_evaluate` -- evaluates a formula, returning a + `std::expected` that holds the result or the arithmetic error. +- `formula::symbol_of<Q>()` -- the symbol of `Q`. +- `source()` -- says where a result's value came from. + +## Further reading + +- [Quantities and measurements](../quantities.md#declaring-a-quantity) +- [Expressions and evaluation](../expressions.md#writing-a-formula) +- [API reference](https://lastrada-software.github.io/formula-cpp/api/) diff --git a/docs/tutorial/02-units-and-dimensions.md b/docs/tutorial/02-units-and-dimensions.md new file mode 100644 index 00000000..f797d34b --- /dev/null +++ b/docs/tutorial/02-units-and-dimensions.md @@ -0,0 +1,127 @@ +# 2. Units and dimensions + +This chapter calculates the loaded area from the specimen's two sides instead +of measuring it, states the strength in a second unit, and shows what happens +to a formula that adds quantities of different dimensions. + +The program is chapter 1's, with the area calculated by a formula of its own, +and the strength also evaluated in newtons per square millimetre. + +## Compute the area from the sides + +The program now opens its anonymous namespace with three declarations that +shorten what follows: + +```cpp +--8<-- "examples/tutorial/02_units_and_dimensions.cpp:names" +``` + +`var<Q>` can now be written without `formula::`, and the units as +`unit::Millimetre` rather than `formula::unit::Millimetre`. +`formula::literals` holds the `_r` literal, which chapter 3 uses. + +The specimen's loaded face is a rectangle, so the area is the product of its +two sides. Each side is a quantity of its own, stated in millimetres: + +```cpp +--8<-- "examples/tutorial/02_units_and_dimensions.cpp:sides" +``` + +The area becomes a formula, and the strength formula uses it: + +```cpp +--8<-- "examples/tutorial/02_units_and_dimensions.cpp:formulas" +``` + +A formula can use another formula. `loadedArea` is bound to its result, +`Area`, by `yields`, so evaluated on its own it gives an `Area`. The strength +formula divides the load by `loadedArea`, and there `loadedArea` stands for +the formula it holds, `var<SideA> * var<SideB>`: the strength is the load over +the product of the two sides. The strength formula names its own result, +`Strength`, with its own `yields` +([Naming the result once](../expressions.md#naming-the-result-once)). + +An expression has a dimension, not a unit. `var<SideA> * var<SideB>` +multiplies two lengths, so its dimension is an area, the dimension `Area` +declares; `yields<Area>` checks that when the program compiles. Evaluation +works in coherent SI units and converts the answer once, into the result's +declared unit, so the value is stated in `Area`'s unit, mm². + +The measurements are now the two sides and the load: + +```cpp +--8<-- "examples/tutorial/02_units_and_dimensions.cpp:environment" +``` + +Both formulas are evaluated against them, and each result is checked before +it is read: + +```cpp +--8<-- "examples/tutorial/02_units_and_dimensions.cpp:evaluate" +``` + +## Units convert themselves + +A second quantity states the same strength in newtons per square millimetre: + +```cpp +--8<-- "examples/tutorial/02_units_and_dimensions.cpp:other-unit" +``` + +`strength` is evaluated only for the quantity it names, so the program +evaluates the formula it holds, `strength.expression`, for this quantity: + +```cpp +--8<-- "examples/tutorial/02_units_and_dimensions.cpp:evaluate-other-unit" +``` + +`formula::checked_evaluate<StrengthInNewtons>` names the quantity to +evaluate for, as `yields` does for a bound formula. 30 MPa and 30 N/mm² are +one value: a megapascal is a newton per square millimetre. The declared unit +of the result quantity decides how the value is stated, and the program +converts nothing itself. The conversion factors between units are exact +ratios, so a conversion never adds a rounding error. + +## A dimensional mistake does not compile + +A load and an area measure different dimensions, so their sum measures +nothing. A formula that adds them is refused: + +```cpp +--8<-- "test/negative/tutorial_load_plus_area.cpp:mistake" +``` + +The program does not compile. g++ reports: + +``` +static assertion failed: formula: the two sides of this addition or subtraction measure different dimensions +``` + +The library's test suite compiles this line and checks that the compiler +refuses it with this message. The error is reported at a `static_assert` +inside the library, and the report names the line that adds the two as the +place it comes from, so the mistake is found where the formula is written, +before the program can run. + +## Output + +```text +--8<-- "examples/tutorial/02_units_and_dimensions.expected.txt" +``` + +## Summary + +- `.expression` -- the formula a bound formula holds; it is how that formula + is evaluated for a quantity other than the one bound. +- `formula::checked_evaluate<Q>` -- evaluates a formula for the quantity `Q`, + stated in `Q`'s declared unit. +- A quantity's declared unit -- decides how its value is stated; conversion + between units is exact and needs no code. +- Adding or subtracting quantities of different dimensions -- refused when + the program compiles. + +## Further reading + +- [Dimensions and units](../dimensions.md#exact-conversion) +- [Expressions and evaluation](../expressions.md#composing-a-formula-from-other-formulas) +- [API reference](https://lastrada-software.github.io/formula-cpp/api/) diff --git a/docs/tutorial/03-exact-numbers.md b/docs/tutorial/03-exact-numbers.md new file mode 100644 index 00000000..6fdbb28f --- /dev/null +++ b/docs/tutorial/03-exact-numbers.md @@ -0,0 +1,82 @@ +# 3. Exact numbers + +This chapter measures the specimen as it really is, to a tenth of a +millimetre and a tenth of a kilonewton. It shows why `checked_evaluate`'s +results are exact, what an exact result looks like when it has no decimal, +and how to round one for reading. + +The program is chapter 2's, without the second strength quantity, with +decimal measurements, and with the strength also printed rounded. + +## Exact decimal literals + +The specimen's sides measure 150.2 mm and 149.8 mm, and it carried 675.4 kN: + +```cpp +--8<-- "examples/tutorial/03_exact_numbers.cpp:measured" +``` + +The `_r` literal, from `formula::literals`, makes an exact `formula::Rational` +from its spelling: `150.2_r` is 751/5, exactly 150.2, never the `double` +nearest it. `checked_evaluate` calculates in `Rational`, a fraction of two +128-bit integers, so nothing is rounded on the way in or during the +calculation. A value too large for a `Rational` is reported as an error, +never rounded ([Limits](../numbers.md#limits)). The usual example of binary +floating point going wrong holds exactly: + +```cpp +--8<-- "examples/tutorial/03_exact_numbers.cpp:exact-sum" +``` + +With `double`, `0.1 + 0.2 == 0.3` is false. With `Rational` it is true. + +## An exact result that has no decimal + +The formulas and their evaluation are chapter 2's: + +```cpp +--8<-- "examples/tutorial/03_exact_numbers.cpp:evaluate" +``` + +The area, 150.2 mm × 149.8 mm, is 22499.96 mm², which has an exact decimal +and prints as one. The strength, 675.4 kN over 22499.96 mm², is +16885000/562499 MPa. That fraction has no terminating decimal, so the result +prints as the fraction. A rounded decimal would be a different number, and +the library does not print one unless asked to. + +## Rounding for reading + +A reader wants a decimal. The format spec asks for one: + +```cpp +--8<-- "examples/tutorial/03_exact_numbers.cpp:for-reading" +``` + +`{:~.2HalfEven}` names the number of decimal places, 2, and the rounding +mode, `HalfEven`. There is no default mode: the spec always names one. The +`~` writes the exact decimal where the value has one, and otherwise rounds it +and marks the rounded value with `≈`, so it cannot pass for the exact +result. The rounding happens only in the text printed; the result itself +stays exact. Rounding that is part of a method, where a standard prescribes +it, comes in chapter 5. + +## Output + +```text +--8<-- "examples/tutorial/03_exact_numbers.expected.txt" +``` + +## Summary + +- `_r` -- an exact decimal literal, read from its spelling; in + `formula::literals`. +- `formula::Rational` -- an exact fraction of two integers, the number type + `checked_evaluate` calculates in. +- `{:~.NMode}` -- formats a value rounded to N places in the rounding mode + named and marked `≈`; a value with an exact decimal prints unrounded. + +## Further reading + +- [Exact numbers and rounding](../numbers.md#writing-an-exact-decimal) +- [Displaying numbers](../display.md#the-spec) +- [API reference](https://lastrada-software.github.io/formula-cpp/api/) diff --git a/docs/tutorial/04-missing-and-entered.md b/docs/tutorial/04-missing-and-entered.md new file mode 100644 index 00000000..b1a64ceb --- /dev/null +++ b/docs/tutorial/04-missing-and-entered.md @@ -0,0 +1,82 @@ +# 4. Missing and entered values + +This chapter evaluates the strength three times: with every measurement +taken, with one side of the specimen never measured, and with the strength +typed in by hand. It shows what a missing measurement makes of a result, and +how a result records where its number came from. + +The program is chapter 3's, with whole-number measurements, without the +printed area, the rounding for reading and the `0.1 + 0.2` line, and with +two more environments. + +## Every measurement taken + +The specimen measures 150 mm by 150 mm and carried 675 kN: + +```cpp +--8<-- "examples/tutorial/04_missing_and_entered.cpp:measured" +``` + +675 kN over 22500 mm² is exactly 30 MPa. The library calculated it, so its +source is `derived`. + +## A measurement nobody took + +Side b was never measured. `formula::Measured<SideB>::absent()` says so: + +```cpp +--8<-- "examples/tutorial/04_missing_and_entered.cpp:absent" +``` + +The evaluation still succeeds: nothing went wrong in the arithmetic, so the +`std::expected` holds an outcome, not an error. That outcome holds no number. +An absent input makes the result of every operator it reaches absent, so the +area is absent, and with it the strength. `formula::number_of` returns the +number an outcome holds as a `std::optional`, here an empty one, and the +outcome's `kind()` prints as `empty`. + +Empty is not zero. Zero is a measurement, and a side of zero millimetres +would make the strength a division by zero, or, in another formula, a +confident wrong number. An empty result says that the number cannot be +known from the measurements given. + +## A value entered by hand + +Sometimes a person states a result instead of letting the formula calculate +it: a value taken from an earlier report, say. `formula::entered` marks a +measurement as typed in, and the environment carries it next to the +measurements: + +```cpp +--8<-- "examples/tutorial/04_missing_and_entered.cpp:entered" +``` + +The sides and the load would give 30 MPa, but the strength entered by hand, +31 MPa, overrides the formula, and the result is 31 MPa. Its `source()` is +`formula::ValueSource::ManuallyEntered`, printed as `manually entered`, and +`is_overridden()` is true. A report can therefore show which numbers the +library derived and which a person asserted. + +## Output + +```text +--8<-- "examples/tutorial/04_missing_and_entered.expected.txt" +``` + +## Summary + +- `formula::Measured<Q>::absent()` -- a measurement that was not taken; every + result that needs it is empty, never zero. +- `formula::entered(measurement)` -- marks a value as typed in by a person; + it overrides the formula that would calculate it. +- `formula::number_of(outcome)` -- the number an outcome holds, as a + `std::optional`; empty when it holds none. +- `formula::ValueSource` -- where a result's number came from: `Derived`, + `Measured` or `ManuallyEntered`. +- `is_overridden()` -- true when the result is a value entered by hand. + +## Further reading + +- [Quantities and measurements](../quantities.md#measurements-that-may-be-absent) +- [Expressions and evaluation](../expressions.md#absence-propagates) +- [API reference](https://lastrada-software.github.io/formula-cpp/api/) diff --git a/docs/tutorial/05-rounding.md b/docs/tutorial/05-rounding.md new file mode 100644 index 00000000..8535947b --- /dev/null +++ b/docs/tutorial/05-rounding.md @@ -0,0 +1,86 @@ +# 5. Rounding + +This chapter rounds the strength to a tenth of a megapascal as part of the +formula, the way a test method prescribes it. It shows how a rounding names +its unit, its places and its mode, and what the mode decides for a value +exactly halfway. + +The program is chapter 4's first case, with the strength formula rounded, +a second rounding that differs only in its mode, and two loads. + +## Rounding is part of the formula + +A test method states its rounding: here, the strength to 0.1 MPa, with a +value exactly halfway rounded away from zero. A `formula::DecimalRounding` +names such a rule once: the unit the places are counted in, how many places, +and the rounding mode. The program names two rules that differ only in the +mode: + +```cpp +--8<-- "examples/tutorial/05_rounding.cpp:roundings" +``` + +Always name the mode: no single mode is right for every method, so the +method's own rule belongs in the declaration. Places count in a unit, +because one decimal place of a megapascal and one decimal place of a pascal +are different roundings. + +`formula::rounded<R>(operand)` rounds its operand by the rule `R`, at that +position in the formula. The strength formula rounds the quotient: + +```cpp +--8<-- "examples/tutorial/05_rounding.cpp:formulas" +``` + +The quotient is calculated exactly, converted to megapascals, rounded to one +place, and the rounded value is the result. A rounding inside a formula is a +step of the calculation, unlike the rounding for reading in chapter 3, which +only changes the text printed. + +The specimen carried 675.4 kN: + +```cpp +--8<-- "examples/tutorial/05_rounding.cpp:evaluate" +``` + +675.4 kN over 22500 mm² is 30.0177… MPa, which rounds to 30.0. The result is +the exact number 30, and prints as `30 MPa`: `{}` writes an exact value +without trailing zeros. A format spec that asks for one place in a named mode, +`{:.1HalfAwayFromZero}`, would print `30.0 MPa` +([Displaying numbers](../display.md#exact-fraction-or)). + +## The mode matters + +A second specimen carried 676.125 kN. Its strength is 30.05 MPa exactly, +halfway between 30.0 and 30.1, and the two rules decide it differently: + +```cpp +--8<-- "examples/tutorial/05_rounding.cpp:halfway" +``` + +Half away from zero rounds the half up to 30.1. Half to even rounds it to +the neighbour whose last digit is even, 30.0. Only a value exactly halfway +tells the two modes apart, which is why the mode a method prescribes +belongs in its rounding. + +## Output + +```text +--8<-- "examples/tutorial/05_rounding.expected.txt" +``` + +## Summary + +- `formula::DecimalRounding` -- a rounding rule named once: a unit, a number + of decimal places and a rounding mode. +- `formula::DecimalPlaces` -- how many decimal places of the unit to keep. +- `formula::RoundingMode` -- which way to round, including what to do with a + value exactly halfway; name the one the method prescribes. +- `formula::rounded<R>(operand)` -- rounds the operand by the rule `R`, at + that position in the formula. + +## Further reading + +- [Rounding and conditionals](../rounding-and-conditionals.md#naming-a-rounding-once) +- [Exact numbers and rounding](../numbers.md#rounding) +- [API reference](https://lastrada-software.github.io/formula-cpp/api/) diff --git a/docs/tutorial/06-constraints.md b/docs/tutorial/06-constraints.md new file mode 100644 index 00000000..4d0d6d18 --- /dev/null +++ b/docs/tutorial/06-constraints.md @@ -0,0 +1,120 @@ +# 6. Constraints + +This chapter checks the specimen's size against a tolerance before its +strength is trusted. It shows how a rule pairs a condition with what to do +when it fails, the four outcomes of checking one, and how to check several +at once. + +The program keeps chapter 5's two sides, and adds four rules on them and +three specimens to check; it calculates no strength. + +## A rule the result must meet + +The test method requires each side of the loaded face to measure 150 mm +within 1 mm. A `formula::constraint` pairs a predicate, the condition that +must hold, with a `formula::Verdict`, what to do when it does not. +`formula::constant<unit::Millimetre>(149)` is a fixed length in the +predicate, with its unit, so it is compared with a side in the same +dimension: + +```cpp +--8<-- "examples/tutorial/06_constraints.cpp:constraints" +``` + +A predicate compares two values; it does not combine comparisons. The +tolerance on each side is therefore two rules, a lower and an upper limit, +with the same verdict. + +The program checks three specimens. The third has no measurement of side b: + +```cpp +--8<-- "examples/tutorial/06_constraints.cpp:specimens" +``` + +`formula::check(constraint, environment)` checks one rule and returns a +`formula::ConstraintOutcome`. The program checks the upper limit on side b +against the second specimen, whose side b measures 152.5 mm: + +```cpp +--8<-- "examples/tutorial/06_constraints.cpp:check" +``` + +The rule does not hold, so the outcome is `violated` and carries the rule's +verdict. + +## Four outcomes, not two + +Checking a rule has four outcomes, and `kind()` names which one it reached: + +- `satisfied` -- the predicate held. +- `violated` -- the predicate did not hold; `verdict()` returns the rule's + verdict. +- `not checked` -- the predicate never resolved, because a value it reads + was never measured. An unmeasured side is never reported as satisfied: a + record saying the specimen was verified when nothing verified it is worse + than no check at all. +- `invalid` -- checking itself failed, because evaluating the predicate + raised an arithmetic error; `error()` returns it. See + [A fourth state](../constraints.md#a-fourth-state-arithmetic-can-break-while-checking-too) + in the guide. + +`ConstraintOutcome` has no conversion to `bool`, because any answer it gave +for `not checked` or `invalid` would be wrong. The program prints each +outcome by asking for the verdict and the error, which are present only for +`violated` and `invalid`: + +```cpp +--8<-- "examples/tutorial/06_constraints.cpp:print" +``` + +None of the rules here can fail to evaluate, so an `invalid` outcome would +be a fault: the program prints it and exits with an error. + +## Checking several rules + +`formula::constraints(...)` bundles the rules into a set, and the program +names each rule for printing, in the same order: + +```cpp +--8<-- "examples/tutorial/06_constraints.cpp:set" +``` + +`formula::check_all(set, environment)` checks every rule in the set and +returns one outcome per rule, at the index the rule was given. It checks +every rule without stopping at the first failure: a specimen can fail two +rules at once, and a report naming only the first would send it back for a +second round of testing. + +```cpp +--8<-- "examples/tutorial/06_constraints.cpp:check-all" +``` + +The first specimen meets all four rules. The second fails the upper limit +on side b, and every other rule is still reported. The third meets both +rules on side a; both rules on side b are not checked, since nobody measured +it. + +## Output + +```text +--8<-- "examples/tutorial/06_constraints.expected.txt" +``` + +## Summary + +- `formula::constraint(predicate, verdict)` -- a rule: a condition that + must hold, and what to do when it does not. +- `formula::Verdict` -- what to do when a rule is violated, in words. +- `formula::constant<Unit>(value)` -- a fixed value with its unit, for use + in a predicate. +- `formula::check(constraint, environment)` -- checks one rule; the outcome + is satisfied, violated, not checked or invalid. +- `formula::check_all(set, environment)` -- checks every rule in a set, + without stopping at the first failure. +- `formula::constraints(...)` -- bundles rules into a set for `check_all`. + +## Further reading + +- [Constraints and verdicts](../constraints.md#a-predicate-paired-with-a-verdict) +- [Checking a set: no short-circuit](../constraints.md#checking-a-set-no-short-circuit) +- [API reference](https://lastrada-software.github.io/formula-cpp/api/) diff --git a/docs/tutorial/07-documentation.md b/docs/tutorial/07-documentation.md new file mode 100644 index 00000000..e5e3465c --- /dev/null +++ b/docs/tutorial/07-documentation.md @@ -0,0 +1,99 @@ +# 7. Citations, rendering and documentation + +This chapter records where the strength formula comes from, and writes the +formula out for a report: as text, as LaTeX, and as a table of the symbols +it reads. All three come from the same declaration that calculates the +strength. + +The program is chapter 5's, with its first rounding rule only, the strength +formula wrapped in a citation, and one specimen; the formula is rendered and +documented before it is evaluated. + +## Cite where the formula comes from + +`formula::documented(expression, citation)` attaches a `formula::Citation` +to an expression. A citation has five fields: `title`, `reference`, +`section`, `equation` and `text`. Each is optional, and a field left out +reads back empty. The strength formula cites the method it is taken from: + +```cpp +--8<-- "examples/tutorial/07_documentation.cpp:formulas" +``` + +The citation changes nothing that is calculated: the wrapped expression has +the same dimension and gives the same value as the expression alone. A +specimen 150 mm by 150 mm that carried 675 kN gives 30 MPa, as in chapter 4: + +```cpp +--8<-- "examples/tutorial/07_documentation.cpp:evaluate" +``` + +## Render it + +`formula::render(formula)` writes a formula as plain text, and +`formula::render<formula::Dialect::LaTeX>(formula)` writes the same formula +as LaTeX, from the same declaration: + +```cpp +--8<-- "examples/tutorial/07_documentation.cpp:render" +``` + +The rounding is written with its rule: `to 1 dp of MPa` in text, one +decimal place of a megapascal, and the subscript `1\,\mathrm{MPa}` in LaTeX, +the places followed by the unit they count in. + +`render` writes the formula a bound formula holds, and names no result: +`f_c` appears in neither line +([Naming the result once](../expressions.md#naming-the-result-once)). The +citation does not appear in either rendering: it describes the formula +rather than being part of it. + +## Generate its documentation + +`formula::document(formula)` returns a `formula::Documentation`: the +rendering, the symbol table and the citations, which is everything a +report's methods section needs: + +```cpp +--8<-- "examples/tutorial/07_documentation.cpp:document" +``` + +`page.symbols` holds one `formula::SymbolEntry` per quantity the formula +reads, in the order the formula reads them, each with the symbol, +description and unit its declaration gives. Like the rendering, the symbol +table describes what the formula reads and names no result, so `f_c` has no +row. The symbol table of a calculation lists the values it calculates +first ([The graph, known while the program +compiles](../calculations.md#the-graph-known-while-the-program-compiles)). +`page.citations` holds every citation in the formula, with all five fields; +the program prints the title and the reference. + +## Output + +```text +--8<-- "examples/tutorial/07_documentation.expected.txt" +``` + +## Summary + +- `formula::documented(expression, citation)` -- attaches a citation to an + expression, without changing what it calculates. +- `formula::Citation` -- where a formula comes from: `title`, `reference`, + `section`, `equation` and `text`, each optional. +- `formula::render(formula)` -- the formula as plain text. +- `formula::Dialect::LaTeX` -- `render<formula::Dialect::LaTeX>` writes the + formula as LaTeX. +- `formula::document(formula)` -- the formula's rendering, symbol table and + citations. +- `formula::Documentation` -- what `document` returns; its `formula`, + `symbols` and `citations` hold the rendering, the symbol table and the + citations. +- `formula::SymbolEntry` -- one row of the symbol table: `symbol`, `unit` + and `description`. + +## Further reading + +- [Citations, rendering and generated documentation](../citations.md#attaching-a-citation) +- [The symbol table and its ordering rule](../citations.md#the-symbol-table-and-its-ordering-rule) +- [Gallery](../gallery.md) +- [API reference](https://lastrada-software.github.io/formula-cpp/api/) diff --git a/docs/tutorial/08-tracing.md b/docs/tutorial/08-tracing.md new file mode 100644 index 00000000..a587a753 --- /dev/null +++ b/docs/tutorial/08-tracing.md @@ -0,0 +1,93 @@ +# 8. Tracing + +This chapter shows how a number was reached: the strength together with +every step that calculated it, each input in the unit it was entered in, and +the citation on the step it describes. A strength entered by hand has no +such steps, and the trace says so by being empty. + +The program is chapter 7's without the rendering and the documentation, with +the strength explained rather than evaluated, for the specimen of chapter 3: +150.2 mm by 149.8 mm, carrying 675.4 kN. + +## How a number was reached + +`formula::checked_explain<Strength>(formula, environment)` evaluates the +formula as `formula::checked_evaluate` does, and records each step on the +way: + +```cpp +--8<-- "examples/tutorial/08_tracing.cpp:explain" +``` + +It returns a `std::expected`. On success it holds a +`formula::Explained<Strength>`: its `outcome` is exactly what +`checked_evaluate` would have returned, and its `trace` is a `formula::Trace` +with one step per node of the formula, each naming the steps it consumed. +On an arithmetic error it holds the error and the trace recorded up to it, +the failing step included, so a failure can be shown together with the steps +that led to it. + +`formula::render_trace(trace, options)` writes a trace as one numbered line +per step. `options.maxSteps` is the most steps it writes; it has no default, +and a longer trace ends with one line saying how many steps were left out. + +## Reading a trace + +The trace is the numbered lines of the [output](#output). Its steps are +numbered in the order they finished, so an operand always appears above the step that +reads it: the load, the two sides, their product, the quotient, the rounding, +and the citation. + +Each input reads in its declared unit, as it was entered: `3377/5 kN` is +675.4 kN, and `751/5 mm` is 150.2 mm. A trace writes every number as an exact +fraction unless its options ask for another style. + +A step that calculates, such as `#2 * #3`, has no declared unit of its own. +Here it reads in the coherent SI unit of its dimension, spelt from the base +units: the product is 0.02249996 m^2, that is 22499.96 mm², and the quotient +`kg/(m s^2)` is the pascal, about 30.02 MPa. The rounding step names its +rule and its mode, and gives 30 MPa in the megapascal it rounds in. + +The citation appears on its own step, step 7, `#6 = 30 MPa`, the one +`formula::documented` added, and not on the division it wraps. + +## A value entered by hand has no derivation + +A strength entered by hand is returned as it was entered, without evaluating +the formula, so nothing is recorded: + +```cpp +--8<-- "examples/tutorial/08_tracing.cpp:entered" +``` + +`asEntered->trace` is empty, and `asEntered->outcome.is_overridden()` is +true: the number was not derived, so there is no derivation to show. + +Tracing costs nothing when it is not asked for: `checked_evaluate` without a +sink builds no trace and allocates nothing for one. + +## Output + +```text +--8<-- "examples/tutorial/08_tracing.expected.txt" +``` + +## Summary + +- `formula::checked_explain<Q>(formula, environment)` -- evaluates the + formula and records each step; on an arithmetic error, the error and the + steps up to it. +- `formula::Explained<Q>` -- what `checked_explain` returns on success: the + `outcome` `checked_evaluate` would have returned, and its `trace`. +- `formula::Trace` -- the recorded steps, one per node, each naming the steps + it consumed; empty for a value entered by hand. +- `formula::render_trace(trace, options)` -- the trace as one numbered line + per step. +- `formula::TraceRenderOptions::maxSteps` -- the most steps `render_trace` + writes; it has no default. + +## Further reading + +- [Two ways to evaluate, and when to reach for each](../tracing.md#two-ways-to-evaluate-and-when-to-reach-for-each) +- [Reading a derivation](../tracing.md#reading-a-derivation) +- [API reference](https://lastrada-software.github.io/formula-cpp/api/) diff --git a/docs/tutorial/09-worksheets.md b/docs/tutorial/09-worksheets.md new file mode 100644 index 00000000..aaa186d7 --- /dev/null +++ b/docs/tutorial/09-worksheets.md @@ -0,0 +1,132 @@ +# 9. Calculations and worksheets + +This chapter puts the whole test into one calculation: the loaded area, the +volume and density of the specimen, and its strength, each defined once and +read by name wherever another definition needs it. A worksheet holds the +measurements, calculates each value when it is asked for, and after a change +recalculates only what the change reaches. + +The program is chapter 8's without the tracing, with the two bound formulas +replaced by one calculation, and the specimen's height and mass added. + +## One calculation, several steps + +The specimen's height and mass are measured, and its volume and density are +calculated, so each is a quantity of its own: + +```cpp +--8<-- "examples/tutorial/09_worksheets.cpp:quantities" +``` + +`formula::define<Q>(expression)` says that the quantity `Q` is calculated by +`expression`, and `formula::calculation(...)` puts the definitions together: + +```cpp +--8<-- "examples/tutorial/09_worksheets.cpp:calculation" +``` + +`var<Area>` in the volume's and the strength's definitions reads the value +the area's definition calculates; it is not the area's formula written into +theirs, so the area is calculated once. A quantity that is read and never +defined is an input: here the two sides, the height, the mass and the load. + +The order the definitions are given in does not matter. The calculation works +out, while the program compiles, what each definition reads, and calculates +every value after the values it reads. Each definition is checked where it is +written: a definition whose expression does not measure what its quantity +measures does not compile. + +## A worksheet + +`formula::worksheet(calculation, environment)` holds a calculation's inputs, +given as an environment of measurements, as for a formula. Every input must +be given. Nothing is calculated when the worksheet is made: + +```cpp +--8<-- "examples/tutorial/09_worksheets.cpp:worksheet" +``` + +The program asks for values in one function, which it calls after each +change: + +```cpp +--8<-- "examples/tutorial/09_worksheets.cpp:report" +``` + +`sheet.checked_calculate(var<Density>, var<Strength>)` calculates what the +two answers need, each value once, and keeps every result. It returns a +`std::tuple` with one `std::expected` per value asked for, in the order +asked, and each is checked before it is read. `calculate` is the same +question in its throwing form. + +`recomputed()` is how many values the worksheet has calculated since it was +made, and `reused()` how many it found still up to date after a change, and +did not calculate again. Both are running totals, so the function reads them +before it asks and after, and prints how far each moved. + +The first question calculates four values: the area, 22500 mm²; the volume, +3375000 mm3; the density, 8.1 kg in 0.003375 m3, which is 2400 kg/m3; and +the strength, 30 MPa. `{:~.1HalfEven}` writes the density as its exact +decimal where it has one, and rounds it to one decimal place, marked `≈`, +where it has none. + +## Change an input + +`sheet.set(...)` gives an input a new value: + +```cpp +--8<-- "examples/tutorial/09_worksheets.cpp:change" +``` + +Setting an input calculates nothing. The worksheet marks the values the +change reaches, and the next question recalculates those it needs. A new +load reaches only the strength: 676.125 kN over 22500 mm² is 30.05 MPa, +rounded half away from zero to 30.1 MPa. One value is recalculated. The +area, the volume and the density read nothing that changed, so they are kept +as they are; `reused()` counts only values a change reached, so it does not +move. + +## What if + +`sheet.with(...)` answers a what-if question on a copy, and leaves the +worksheet it was asked of as it was: + +```cpp +--8<-- "examples/tutorial/09_worksheets.cpp:what-if" +``` + +The copy holds every value the worksheet has calculated, so it recalculates +only what the change reaches: the density, 8.25 kg in 0.003375 m3, which is +22000/9 kg/m3. That number has no exact decimal, so it is written rounded, +`≈2444.4 kg/m3`. Asked again, the worksheet itself still reports +2400 kg/m3, and calculates nothing. + +## Output + +```text +--8<-- "examples/tutorial/09_worksheets.expected.txt" +``` + +## Summary + +- `formula::calculation(...)` -- several definitions put together; what + reads what is worked out while the program compiles. +- `formula::define<Q>(expression)` -- the quantity `Q` is calculated by + `expression`, and read elsewhere as `var<Q>`. +- `formula::worksheet(calculation, environment)` -- a calculation's inputs, + and each value calculated once and kept. +- `checked_calculate(var<Q>...)` -- the values asked for, each a + `std::expected`; `calculate(var<Q>...)` is the throwing form. +- `set(...)` -- gives an input a new value; only what the change reaches is + recalculated. +- `with(...)` -- a copy with the change made, the worksheet left unchanged. +- `recomputed()` -- how many values the worksheet has calculated. +- `reused()` -- how many values a change reached that were still up to date. + +## Further reading + +- [Defining named values](../calculations.md#defining-named-values) +- [A worksheet](../calculations.md#a-worksheet) +- [A change, and what it reaches](../calculations.md#a-change-and-what-it-reaches) +- [What if?](../calculations.md#what-if) +- [API reference](https://lastrada-software.github.io/formula-cpp/api/) diff --git a/docs/tutorial/10-lookup-tables.md b/docs/tutorial/10-lookup-tables.md new file mode 100644 index 00000000..050bbbcf --- /dev/null +++ b/docs/tutorial/10-lookup-tables.md @@ -0,0 +1,125 @@ +# 10. Lookup tables + +A test method sometimes gives a number no formula calculates: it publishes a +table and says which row to read. This chapter corrects the strength of the +test for the size of the specimen, with a factor read from a table by the +specimen's edge length, and shows what happens to an edge the table does not +cover. + +The program is self-contained: it declares its own quantities, and takes the +strength as a measured 30 MPa rather than calculating it. + +## Why a published table belongs in the formula + +The size correction factor is a quantity of its own, without a unit, read by +the edge length of the specimen; the corrected strength is the strength +multiplied by that factor: + +```cpp +--8<-- "examples/tutorial/10_lookup_tables.cpp:quantities" +``` + +The table is part of the method, so it is part of the formula. A lookup +renders, evaluates and traces like any other part of a formula, and a reader +of the formula sees the table it reads. + +## Band boundaries + +The invented table has three bands of edge length, each with its own factor: +0 to under 100 mm gives 1.05, 100 to under 200 mm gives 1.00, and 200 to +under 300 mm gives 0.95. `formula::BandTable<N>` holds the bands, each +written `formula::band(low, high)`: + +```cpp +--8<-- "examples/tutorial/10_lookup_tables.cpp:bands" +``` + +A band includes its low bound and stops under its high bound. A value on a +boundary belongs to the band it starts, so an edge of exactly 100 mm is in +the second band, never the first. The library writes a band the same way, +"0 to under 100 mm", wherever it shows one. + +`formula::banded_lookup<KeyUnit, Bands, ValueUnit>(key, values)` reads the +row whose band holds `key`: + +```cpp +--8<-- "examples/tutorial/10_lookup_tables.cpp:lookup" +``` + +The template arguments are the table's structure: the unit its bands are +stated in, the bands, and the unit its values are stated in. The braced list +holds one value per band, in the order the bands are declared. The corrected +strength multiplies by `sizeFactor` as it is: a bound formula used inside +another stands for the formula it holds. + +Rendered, the lookup shows every band and the value it gives, each value as +an exact fraction: 21/20 is 1.05, and 19/20 is 0.95. + +```cpp +--8<-- "examples/tutorial/10_lookup_tables.cpp:render" +``` + +## Reading the table + +The program reads the table for one edge at a time, and prints the factor +and the corrected strength: + +```cpp +--8<-- "examples/tutorial/10_lookup_tables.cpp:report" +``` + +```cpp +--8<-- "examples/tutorial/10_lookup_tables.cpp:evaluate" +``` + +An edge of 150 mm is in the second band: the factor is 1 and the strength +stays 30 MPa. An edge of 100 mm sits on the boundary between the first two +bands and so is in the second: again 1, and 30 MPa. An edge of 250 mm is in +the third band: 30 MPa times 0.95 is 28.5 MPa. + +## A miss is not a number + +An edge of 300 mm is in no band: the last band stops under 300 mm. The +lookup does not answer with zero, with the nearest band, or with the last +row. It fails with `formula::ArithmeticError::DomainError`, through the +`std::expected` that `checked_evaluate` returns, and the program prints the +error: + +```cpp +--8<-- "examples/tutorial/10_lookup_tables.cpp:miss" +``` + +A number for an edge the table does not cover would be a number the method +never states, and nothing after it could tell it apart from one the method +does state. A formula that reads the lookup, such as the corrected strength, +fails the same way. + +## A gap between bands does not compile + +The high bound of each band must be the low bound of the next. A table with +a gap between two bands, or two bands that overlap, does not compile, and +the compiler's message names the two bands. The guide shows the message in +[A table with a gap does not compile](../lookup-tables.md#a-table-with-a-gap-does-not-compile-and-the-message-says-where). + +## Output + +```text +--8<-- "examples/tutorial/10_lookup_tables.expected.txt" +``` + +## Summary + +- `formula::BandTable<N>` -- a table of `N` bands, each from its low bound + to under its high bound. +- `formula::band(low, high)` -- one band; a value on `low` is in it, a value + on `high` is not. +- `formula::banded_lookup<KeyUnit, Bands, ValueUnit>(key, values)` -- the + value of the band that holds `key`; an error, not a number, when no band + holds it. + +## Further reading + +- [Banded: a measured value falls in an interval](../lookup-tables.md#banded-a-measured-value-falls-in-an-interval) +- [A miss is not a value](../lookup-tables.md#a-miss-is-not-a-value) +- [Lookup tables](../lookup-tables.md), which also covers exact and interpolating tables +- [API reference](https://lastrada-software.github.io/formula-cpp/api/) diff --git a/docs/tutorial/11-methods-and-overlays.md b/docs/tutorial/11-methods-and-overlays.md new file mode 100644 index 00000000..a028ff6f --- /dev/null +++ b/docs/tutorial/11-methods-and-overlays.md @@ -0,0 +1,142 @@ +# 11. Methods and overlays + +The strength of a cube and the strength of a cylinder are calculated by two +different formulas, and which one applies depends on the specimen, not on a +number. This chapter declares one method with a variant for each shape, and +then changes how the method rounds for one jurisdiction with an overlay. +Each trace says which variant ran and whose rounding rule applied. + +The program is self-contained: it declares its own quantities and does not +build on the previous chapters' programs. + +## One method, several variants + +A variant applies to a kind of specimen, and the kind is named by a tag: an +empty struct, never instantiated: + +```cpp +--8<-- "examples/tutorial/11_methods_and_overlays.cpp:tags" +``` + +The cube is measured by the two sides of its loaded face, the cylinder by its +diameter: + +```cpp +--8<-- "examples/tutorial/11_methods_and_overlays.cpp:quantities" +``` + +`formula::method(variants, rounding rule, constraints)` builds a method from +its three parts, in that order: + +```cpp +--8<-- "examples/tutorial/11_methods_and_overlays.cpp:method" +``` + +- `formula::variants(...)` lists the variants, each written + `formula::variant<Tag>(expression)`. The cube's strength is the load over + `a * b`, the cylinder's the load over `pi * d^2 / 4`. `formula::pi` is the + library's fixed fraction for pi, 245850922/78256779, within 8e-17 of it; + the trace shows which number was used. Every variant must + measure the same dimension, here a stress. +- `formula::rounding_rule<tenthMpa>()` rounds whichever variant ran, to one + decimal place of a megapascal, ties away from zero. +- `formula::constraints()` is the list of checks a result must pass; this + method has none. + +`formula::explain_method<Tag>(method, environment)` runs the variant `Tag` +names, rounds the result by the method's rule, and records each step. Its +`outcome` is a `std::expected` that holds an error or a `std::optional`, +empty when a measurement the variant reads is missing; a value is in the +coherent SI unit, here pascals. Its `trace` is the trace: + +```cpp +--8<-- "examples/tutorial/11_methods_and_overlays.cpp:show" +``` + +The tag is always stated by the caller, who knows what the specimen is. A tag +the method has no variant for does not compile. + +## The specimens + +A cube of 150 mm by 150 mm carried 675 kN, and a cylinder of 150 mm diameter +carried 540 kN: + +```cpp +--8<-- "examples/tutorial/11_methods_and_overlays.cpp:specimens" +``` + +Each environment holds only what its variant reads. + +```cpp +--8<-- "examples/tutorial/11_methods_and_overlays.cpp:base" +``` + +The cube's strength is 675 kN over 22500 mm², exactly 30 MPa. The cylinder's +loaded area is pi times 22500 mm² over 4, about 17671.5 mm², and its strength +540 kN over that area, about 30.56 MPa, which the method's rule rounds to +30.6 MPa; the trace writes it as the exact fraction 153/5 MPa. The last two +steps of each trace say whose rule +rounded the value, `(method default)`, and which variant ran, `variant Cube +(1st of 2), selected by tag`. + +## An overlay changes a method for a jurisdiction + +A jurisdiction's annex may change a method: fix a constant, replace a +variant's formula, drop a variant, or round differently. An overlay lists +those changes, each with the citation of the clause that makes it, and +`formula::apply(overlay, method)` returns a new method with the changes made. +The base method stays as it is. + +The invented "Example jurisdiction" rounds the strength to whole +megapascals. A rounding rule keeps a number of decimal places of a unit, so +a coarser step such as 0.5 MPa cannot be stated as one; whole megapascals is +the nearest coarser rule. + +```cpp +--8<-- "examples/tutorial/11_methods_and_overlays.cpp:overlay" +``` + +`formula::with_rounding<rounding>(citation)` replaces the method's rounding +rule. Every overlay operation needs a citation; an operation without one does +not compile. The overlaid method is run exactly like the base method: + +```cpp +--8<-- "examples/tutorial/11_methods_and_overlays.cpp:jurisdiction" +``` + +The cube's strength is a whole number of megapascals, so it is 30 MPa under +either rule. The cylinder's is not: about 30.56 MPa rounds to 31 MPa under +the jurisdiction's rule, where the base method gives 30.6 MPa. The rounding +step of each trace taken under the jurisdiction's rule reads +`rounded to 0 dp`, and names the jurisdiction's overlay and its citation in +place of `(method default)`, so a reader of the trace can see which rule +applied and where it comes from. + +## Output + +```text +--8<-- "examples/tutorial/11_methods_and_overlays.expected.txt" +``` + +## Summary + +- `formula::method(variants, rounding_rule, constraints)` -- one method, its + variants, the rule that rounds whichever ran, and its checks. +- `formula::variant<Tag>(expression)` -- the formula for the specimens `Tag` + names. +- `formula::explain_method<Tag>(method, environment)` -- runs the variant for + `Tag`, rounds it, and records the steps; the value is in the coherent SI + unit. +- `formula::overlay(...)` -- a jurisdiction's changes to a method, each with + its citation. +- `formula::with_rounding<rounding>(citation)` -- replaces the method's + rounding rule. +- `formula::apply(overlay, method)` -- the method with the overlay's changes + made; the base method is unchanged. + +## Further reading + +- [A method: variants, tags, and one rounding rule](../methods-and-overlays.md#a-method-variants-tags-and-one-rounding-rule) +- [An overlay yields a method](../methods-and-overlays.md#an-overlay-yields-a-method) +- [Methods and jurisdiction overlays](../methods-and-overlays.md), which also covers constants, replaced and pruned variants, and constraints +- [API reference](https://lastrada-software.github.io/formula-cpp/api/) diff --git a/docs/tutorial/12-series.md b/docs/tutorial/12-series.md new file mode 100644 index 00000000..0522c042 --- /dev/null +++ b/docs/tutorial/12-series.md @@ -0,0 +1,109 @@ +# 12. Series + +A strength test is not always one specimen crushed once. One mix is often +tested at several ages, 7, 14 and 28 days, and the method then states the +same quantity at each age. This chapter declares the strengths at the three +ages as one series, calculates each as a share of the 28-day strength +element by element, and shows what happens when one age was not recorded. + +The program is self-contained: it declares its own quantities and does not +build on the previous chapters' programs. + +## One quantity at several points + +The strength at each age is one quantity, the compressive strength, with a +value at each of three points. The shares are taken of the 28-day strength, +the series' third element, but a formula cannot read one element of a +series. So the 28-day strength is also entered on its own, as `f_c28`, and +must be the same value as the series' third element. Nothing checks the two +against each other, so keep them in step. The share is a pure number: + +```cpp +--8<-- "examples/tutorial/12_series.cpp:quantities" +``` + +`formula::series<Q, N>` is the quantity `Q` at `N` points. The length is part +of the type, because the ages are part of the method, not data: an age the +laboratory did not record is an absent element, never a shorter series. +A series is not a single value, and a series in a single value's place does +not compile. + +The share of each age's strength in the 28-day strength divides the series +by the single value: + +```cpp +--8<-- "examples/tutorial/12_series.cpp:share" +``` + +Rendered, the series variable is marked with `(i)`, and the single value is +not: + +```cpp +--8<-- "examples/tutorial/12_series.cpp:render" +``` + +## Arithmetic element by element + +Arithmetic on a series is applied to each element: here each of the three +strengths is divided by the 28-day strength, which is read once and used for +every element. `formula::explain_series` evaluates a series and records how, +as `formula::checked_explain` does for a single value. Its `outcome` is a +`std::expected`: the series, or a `formula::SeriesFailure` that names the +element that failed when the failure belongs to one. +`outcome->element(at)` is one element, at a zero-based position: + +```cpp +--8<-- "examples/tutorial/12_series.cpp:report" +``` + +The strengths 21, 26 and 30 MPa are measured at the three ages, and the +28-day strength is 30 MPa: + +```cpp +--8<-- "examples/tutorial/12_series.cpp:evaluate" +``` + +The trace records one step for the whole division, with every element's +result: 21/30 is 7/10, 26/30 is 13/15, and 30/30 is 1. The shares are pure +numbers, so the trace shows them without a unit. Rounded for reading, 7/10 +is the exact decimal 0.7 and 1 is 1; 13/15 has no exact decimal, so it is +rounded to three places and marked: ≈0.867. + +## An absent element stays absent + +`formula::not_measured` in place of a value marks one element absent: here +the 14-day strength was not recorded: + +```cpp +--8<-- "examples/tutorial/12_series.cpp:absent" +``` + +The share at 14 days is absent too, and printed `(not measured)`. The shares +at 7 and 28 days are still calculated, and are the same as before. An absent +element makes absent exactly the elements that depend on it, and the +division of one element depends on no other. A step that combines the +elements into one value, such as their sum, is absent as a whole when any +element is: a total of only the elements someone entered is not the total +the method states. + +## Output + +```text +--8<-- "examples/tutorial/12_series.expected.txt" +``` + +## Summary + +- `formula::series<Q, N>` -- the quantity `Q` at `N` points, written `(i)` + when rendered. +- `formula::measured_series<Q>(values...)` -- the measured values of a + series, one per point; `formula::not_measured` marks one absent. +- `formula::explain_series(formula, environment)` -- evaluates a series and + records how; `outcome->element(at)` is one element. + +## Further reading + +- ["Map" is elementwise arithmetic](../series.md#map-is-elementwise-arithmetic) +- [Absence is decided at the size of what is produced](../series.md#absence-is-decided-at-the-size-of-what-is-produced) +- [Series and grading curves](../series.md), which also covers sums, curves and conformity +- [API reference](https://lastrada-software.github.io/formula-cpp/api/) diff --git a/docs/tutorial/13-statistics.md b/docs/tutorial/13-statistics.md new file mode 100644 index 00000000..9c4aaeed --- /dev/null +++ b/docs/tutorial/13-statistics.md @@ -0,0 +1,113 @@ +# 13. Statistics + +A strength test usually crushes several specimens of one mix and reports one +value for them: their mean, often with their spread, and only after a +specimen far from the others has been rejected. This chapter reduces the +strengths of three specimens to a mean and a range, then rejects an outlier +from five specimens and takes the mean of those kept. The trace says which +specimen was rejected, in which pass, and why. + +The program is self-contained: it declares its own quantities and does not +build on the previous chapters' programs. + +## Summary statistics over a sample + +The strengths of the specimens are one quantity at several points, a series +([chapter 12](12-series.md)). Their range is a quantity of its own, in the +same unit: + +```cpp +--8<-- "examples/tutorial/13_statistics.cpp:quantities" +``` + +A statistic reduces a series to a single value, so it is an ordinary formula: +`formula::sample_mean` is the mean of the elements, and `formula::sample_range` +the highest less the lowest: + +```cpp +--8<-- "examples/tutorial/13_statistics.cpp:statistics" +``` + +The library also has `formula::sample_count` and `formula::sample_variance`. +A mean is taken only when every element is present: one specimen not +measured, and the mean is absent, never the mean of the rest. + +The three specimens have strengths of 30.2, 29.8 and 31.0 MPa: + +```cpp +--8<-- "examples/tutorial/13_statistics.cpp:evaluate" +``` + +The three add up to 91 MPa, so the mean is 91/3 MPa exactly. It has no exact +decimal, so it prints as a fraction, and `{:~.2HalfEven}` rounds it to two +places for reading: ≈30.33 MPa. The range is 31.0 less 29.8, 1.2 MPa. + +## Outliers rejected pass by pass + +Five specimens have strengths of 30.2, 29.8, 31.0, 30.4 and 36.0 MPa. An +invented rule rejects a specimen more than 3 MPa from the mean, one specimen +per pass. Some methods measure from the mean of the others; this one +measures from the mean of each pass, the strength itself included. With +these five strengths, both rules reject 36.0 MPa and nothing else. + +`formula::deviation_from_mean(limit)` is that criterion, the limit here a +constant 3 MPa. `formula::without_outliers` applies it, pass by pass, and is +itself a sample that a statistic takes as it takes a series: + +```cpp +--8<-- "examples/tutorial/13_statistics.cpp:rejection" +``` + +Every template argument shapes the result, so none has a default: + +- `formula::PerPass::MostExtreme` rejects only the specimen furthest from the + mean in each pass, and `formula::PerPass::EveryExceeding` every one past + the limit; +- `formula::OnLimit::Keep` keeps a specimen exactly on the limit, and + `formula::OnLimit::Reject` rejects it; +- `formula::AtMost<2>` rejects at most two specimens in all; +- `formula::KeepAtLeast<3>` keeps at least three. + +When a rejection would break `AtMost` or `KeepAtLeast`, nothing more is +rejected and the result is the verdict, here "test further specimens", not +a mean of whatever was left. + +The program prints the rejection, then the trace of the mean of the +specimens kept: + +```cpp +--8<-- "examples/tutorial/13_statistics.cpp:reject" +``` + +The limit is evaluated again in every pass, so the trace states it at the +start of each: lines 2 and 5, 3 MPa both times, since it is a constant. +In pass 1, the mean of the five is 787/25 MPa, 31.48 MPa. 36.0 MPa is +113/25 MPa, 4.52 MPa, from it, more than 3 MPa, and the furthest of the +five: the trace says it rejected element 5 of 5 in pass 1, and why. In +pass 2, the mean of the four left is 607/20 MPa, 30.35 MPa, and none of them +is more than 3 MPa from it, so the rejection settles with one rejected and +four kept. The mean of the specimens kept is 30.35 MPa. + +## Output + +```text +--8<-- "examples/tutorial/13_statistics.expected.txt" +``` + +## Summary + +- `formula::sample_mean(sample)` -- the mean of the sample's values. +- `formula::sample_range(sample)` -- the highest value less the lowest. +- `formula::deviation_from_mean(limit)` -- rejects a value further than + `limit` from the mean of its pass. +- `formula::without_outliers<PerPass, OnLimit, AtMost, KeepAtLeast>(sample, criterion, verdict)` + -- the sample with its outliers rejected, pass by pass, until a pass + rejects nothing, or until a rejection would break `AtMost` or + `KeepAtLeast` and the verdict is given. + +## Further reading + +- [A sample](../statistics.md#a-sample) +- [Rejecting outliers](../statistics.md#rejecting-outliers) +- [Statistics, outliers and precision](../statistics.md), which also covers the spread, other criteria and precision checks +- [API reference](https://lastrada-software.github.io/formula-cpp/api/) diff --git a/docs/tutorial/14-records.md b/docs/tutorial/14-records.md new file mode 100644 index 00000000..4925b6ac --- /dev/null +++ b/docs/tutorial/14-records.md @@ -0,0 +1,116 @@ +# 14. Records + +A strength is sometimes reported against another specimen's: as a share of a +reference sample's strength, or compared with an earlier test of the same +sample. The formula then reads a value from a record other than the one being +evaluated, and a trace that lists only numbers cannot say whose each one was. +This chapter divides a specimen's strength by a reference sample's, read from +that sample's record, and shows what the trace says about each value and what +happens when the reference has not been tested yet. + +The program is self-contained: it declares its own quantities and does not +build on the previous chapters' programs. + +## A value from another sample or an earlier test + +The specimen's strength and the reference sample's are the same quantity, +each held by its own record. The ratio of the two is a pure number. A formula +never names a sample: it names a **role**, a type the program declares, here +`Reference`. Which sample plays that role is data, decided when the records +are put together: + +```cpp +--8<-- "examples/tutorial/14_records.cpp:quantities" +``` + +`formula::from_record<Reference>(expression)` evaluates `expression` against +the record that plays `Reference`. Outside it, the formula reads the record +being evaluated: + +```cpp +--8<-- "examples/tutorial/14_records.cpp:ratio" +``` + +Each record holds its own environment. `formula::record<Role>(key, environment)` +makes one, and its key is a sample and a test, `formula::sample_id` and +`formula::test_id`: two separate types, so a swapped pair does not compile. +`formula::ThisRecord` is the library's role for the record being evaluated. +`formula::record_context` holds the records by role: + +```cpp +--8<-- "examples/tutorial/14_records.cpp:records" +``` + +The context is this record's environment as well, so `formula::checked_explain` +and everything else that takes an environment takes it. A formula that reads +from a role the context does not bind does not compile. An earlier test of +the same sample is read the same way: a second role, played by a record with +the same sample key and another test key. + +Rendered, the read from the other record names its role: + +```cpp +--8<-- "examples/tutorial/14_records.cpp:render" +``` + +## Where each value came from + +`report` explains the ratio over a context, prints the trace, then the ratio: + +```cpp +--8<-- "examples/tutorial/14_records.cpp:report" +``` + +The specimen's strength is 30 MPa and the reference sample's 32 MPa: + +```cpp +--8<-- "examples/tutorial/14_records.cpp:evaluate" +``` + +The trace keeps, on every step, the record it was read from. The specimen's +own strength is line 1, with no record named. Line 2 is the reference +sample's strength, and names the role and both keys, sample 23, test 3: two +tests of one sample share the sample key, so a sample alone could name +either. Line 3 is the read from the other record, and line 4 divides the +two: 30/32 is 15/16, the exact decimal 0.9375. + +## A record not yet made + +The reference sample may not have been tested yet. Its record then keeps its +role, and has no key and no values. `Record<Role, Environment>::unbound()` +makes it, and has the same type as the record +`formula::record<Reference>(...)` makes from `referenceSample`, so one +formula takes one context type whether the reference has been tested or not: + +```cpp +--8<-- "examples/tutorial/14_records.cpp:not-yet-made" +``` + +The read gives no value: line 2 says no record is bound, and the ratio is +`(not measured)`, never zero. A ratio of zero would read as a specimen with +no strength. + +## Output + +```text +--8<-- "examples/tutorial/14_records.expected.txt" +``` + +## Summary + +- `formula::from_record<Role>(expression)` -- `expression` evaluated against + the record that plays `Role`. +- `formula::record<Role>(formula::record_key(sample, test), environment)` -- + a record, its key and its values. +- `formula::record_context(records...)` -- the records by role; it is this + record's environment, so everything that takes an environment takes it. +- `formula::Record<Role, Environment>::unbound()` -- a record not yet made: a + read from it gives no value. + +## Further reading + +- [A role is code, a record is data](../records.md#a-role-is-code-a-record-is-data) +- [What the trace says](../records.md#what-the-trace-says) +- [A record not yet made](../records.md#a-record-not-yet-made) +- [Other samples and other tests](../records.md), which also covers computing over another specimen, series, and lineage +- [API reference](https://lastrada-software.github.io/formula-cpp/api/) diff --git a/docs/tutorial/15-opaque-and-retry.md b/docs/tutorial/15-opaque-and-retry.md new file mode 100644 index 00000000..513e9032 --- /dev/null +++ b/docs/tutorial/15-opaque-and-retry.md @@ -0,0 +1,146 @@ +# 15. Opaque operations and bounded retry + +Two things a strength test method states are not a formula over its inputs. +It may name a computation without spelling it out, such as the stiffness of +the loading as the slope of a straight line fitted by least squares through +the load and displacement readings. And it may repeat a step until it is +accepted, a bounded number of times, such as a retest repeated until two +results agree. This chapter fits that line and runs that retest, and shows +what the trace says about each. + +The program is self-contained: it declares its own quantities and does not +build on the previous chapters' programs. + +## A named operation whose inside is not traced + +The readings are a displacement in mm and a load in kN. The fit's outputs +are quantities of their own: the stiffness, the slope of the line, in kN/mm; +the load at zero displacement, its intercept, in kN; and the coefficient of +determination, R², a pure number. The library has no kN/mm, so the program +declares it, as one kN/mm is 1000000 N/m: + +```cpp +--8<-- "examples/tutorial/15_opaque_and_retry.cpp:quantities" +``` + +`formula::linear_least_squares` is an **opaque operation**: the call names it, +a citation and its inputs, and the trace shows its inputs and outputs but not +the sums inside. Its inputs here are `formula::observations<Q, Capacity>`, as +many readings as were made, up to the capacity, so how many points there are +is data. `formula::opaque_output<"name">` chooses one output, `intercept`, +`slope`, `r squared` or `points`, and a formula uses it like any other value: + +```cpp +--8<-- "examples/tutorial/15_opaque_and_retry.cpp:fit" +``` + +The citation has no default: the reference that defines the operation is +what a reader has instead of its inside. + +Four invented readings lie exactly on the line load = 100 kN/mm × +displacement + 5 kN: (0.1 mm, 15 kN), (0.2 mm, 25 kN), (0.3 mm, 35 kN) and +(0.4 mm, 45 kN). The program renders the stiffness, explains it, and prints +it: + +```cpp +--8<-- "examples/tutorial/15_opaque_and_retry.cpp:evaluate-fit" +``` + +Lines 1 and 2 of the trace are the readings, exact, so 0.1 mm is 1/10 mm. +Line 3 is the operation: every output it produced, then `[inside not shown]`, +then its citation. The library writes `[inside not shown]` for every opaque +call, rather than leave a reader to wonder whether a step is missing. Line 4 +chooses the slope. The fit is exact: the means are 0.25 mm and 30 kN, the +sum of the squared displacements about their mean is 0.05 mm², and the sum of +the products about the means 5 kN·mm, so the slope is 5/0.05 = 100 kN/mm and +the intercept 30 kN less 100 kN/mm × 0.25 mm, 5 kN. Every point lies on the +line, so R² is 1. + +The other two outputs are evaluated the same way: + +```cpp +--8<-- "examples/tutorial/15_opaque_and_retry.cpp:other-outputs" +``` + +Each output is its own value, so each evaluation runs the fit again; the +operation is pure, so every run gives the same line. + +## A step repeated a bounded number of times + +An invented rule retests a specimen at most 3 times, until two consecutive +results differ by at most 0.5 MPa; if none do, the method's verdict is "test +further specimens". `formula::retry<R, Max, FirstJudged>(attempt, acceptance, +verdict, citation)` states it: + +```cpp +--8<-- "examples/tutorial/15_opaque_and_retry.cpp:retry" +``` + +- `formula::attempt_input<Retest>` is the attempt: at attempt `k`, the `k`th + element of a series of retest results, one per attempt allowed. +- `formula::this_attempt<AgreedStrength>` is the value just produced, and + `formula::previous_attempt<AgreedStrength>` the one before; the acceptance + compares the two with `formula::abs`. +- `3` is the most attempts: a template argument, so no run makes more. +- `formula::FirstJudged::AtSecondAttempt` judges from the second attempt, + since at the first there is only one result to compare. + +The results are 30.0, 31.2 and 31.0 MPa. `formula::explain_retry` runs the +retry and records how; its `outcome` is a `std::expected`, the outcome or the +arithmetic failure that stopped it, and is checked before it is read: + +```cpp +--8<-- "examples/tutorial/15_opaque_and_retry.cpp:evaluate-retry" +``` + +The render writes 0.5 MPa as the exact 1/2 MPa. Attempt 1 is 30 MPa and is +not judged. Attempt 2, 31.2 MPa, differs from it by 1.2 MPa, more than +0.5 MPa, and is rejected. Attempt 3, 31.0 MPa, differs from 31.2 MPa by +0.2 MPa, and is accepted: the retry's value is 31 MPa. + +## Ending in exactly one of a fixed set of ways + +A retry ends in exactly one of the six ways `formula::RetryEnd` names. +`outcome->end()` reports five of them; a failed retry has no outcome, only +the failure: + +- **accepted:** the acceptance held; the value is that attempt's, as here. +- **exhausted:** the acceptance never held in the attempts allowed; the + outcome is the verdict, "test further specimens", not the last result. +- **not judgeable:** an attempt's value, or its judgement, was absent. +- **not recorded:** a retest result an attempt needed was not recorded. +- **failed:** an attempt failed arithmetically; there is no outcome, only + the failure, naming the attempt. +- **manually entered:** a person typed in the result; no attempt runs. + +Had the third result been 30.4 MPa, 0.8 MPa from the second, the retry would +have ended exhausted, with the verdict as its outcome. + +## Output + +```text +--8<-- "examples/tutorial/15_opaque_and_retry.expected.txt" +``` + +## Summary + +- `formula::linear_least_squares(x, y, citation)` -- a straight line fitted + by least squares, an opaque operation with the outputs `intercept`, + `slope`, `r squared` and `points`. +- `formula::opaque_output<"name">(call)` -- one output of an opaque + operation, used like any other value. +- `formula::observations<Q, Capacity>` -- as many readings of `Q` as were + made, up to `Capacity`. +- `formula::retry<R, Max, FirstJudged>(attempt, acceptance, verdict, citation)` + -- a step repeated at most `Max` times until `acceptance` holds. +- `formula::explain_retry(retry, environment)` -- runs a retry and records + how; `outcome->end()` says how it ended. + +## Further reading + +- [An opaque operation](../opaque-and-retry.md#an-opaque-operation) +- [A line through observations](../opaque-and-retry.md#a-line-through-observations) +- [A retry](../opaque-and-retry.md#a-retry) +- [Six ways to end](../opaque-and-retry.md#six-ways-to-end) +- [Opaque operations and bounded retry](../opaque-and-retry.md), which also covers operations of your own, rounded outputs and several regressors +- [API reference](https://lastrada-software.github.io/formula-cpp/api/) diff --git a/docs/tutorial/index.md b/docs/tutorial/index.md new file mode 100644 index 00000000..2ad4a69b --- /dev/null +++ b/docs/tutorial/index.md @@ -0,0 +1,47 @@ +# Tutorial + +This tutorial teaches formula-cpp step by step. It assumes you know modern +C++ -- C++20 and C++23, `constexpr`, `std::expected`, class-type template +arguments -- and nothing about this library. + +## Before you start + +You need a C++23 compiler (MSVC, clang-cl, Clang, GCC 14 or AppleClang), +CMake 3.23 or newer, and [CPM.cmake](https://github.com/cpm-cmake/CPM.cmake) +to fetch the library. Chapter 1 shows the CMake lines. + +## Two tracks + +**The core track** builds one program: the calculation of a concrete +specimen's compressive strength, from the load that crushed it and the +specimen's size. Each chapter starts from the previous chapter's program, so +read the core track in order. + +**The advanced track** covers the rest of the library in independent +chapters. Read the ones you need, in any order, once you have finished the +core track. + +Every program in this tutorial is built and run by the library's test suite, +and every output shown is what that program prints. Every standard cited is +an invented `Example Standard`. + +## Core track + +1. [First formula](01-first-formula.md) -- one formula, evaluated and checked. +2. [Units and dimensions](02-units-and-dimensions.md) -- sides, areas and unit conversion; a mistake that does not compile. +3. [Exact numbers](03-exact-numbers.md) -- why the results are exact, and how to round them for reading. +4. [Missing and entered values](04-missing-and-entered.md) -- a measurement nobody took, and a value typed in. +5. [Rounding](05-rounding.md) -- rounding where the method says, in the mode it names. +6. [Constraints](06-constraints.md) -- rules a specimen must meet, and the four outcomes of checking one. +7. [Citations, rendering and documentation](07-documentation.md) -- where a formula comes from, written out as text, LaTeX and a symbol table. +8. [Tracing](08-tracing.md) -- how each number was reached, step by step. +9. [Calculations and worksheets](09-worksheets.md) -- the whole test as one calculation, recalculated as inputs change. + +## Advanced track + +- Chapter 10: [Lookup tables](10-lookup-tables.md) -- values from a published table, and what happens outside it. +- Chapter 11: [Methods and overlays](11-methods-and-overlays.md) -- one method, several variants, and a jurisdiction's changes. +- Chapter 12: [Series](12-series.md) -- one quantity at several ages, calculated element by element. +- Chapter 13: [Statistics](13-statistics.md) -- means and spreads of several specimens, and outliers rejected. +- Chapter 14: [Records](14-records.md) -- values from a reference sample or an earlier test. +- Chapter 15: [Opaque operations and bounded retry](15-opaque-and-retry.md) -- a least-squares fit, and a retest repeated at most a fixed number of times. diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index c2e93014..4630625b 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -40,6 +40,73 @@ function(formula_add_example name source expectedOutput) PASS_REGULAR_EXPRESSION "${expectedOutput}.*overflow census: ") endfunction() +# An example whose whole output a page includes: built and run as every +# example is, and its output compared with the file beside its source that +# has the source's own stem and `.expected.txt` (`tutorial/01_first_formula.cpp` +# is compared with `tutorial/01_first_formula.expected.txt`, whatever `name` +# is). The comparison normalises line endings and nothing else. The pass +# regex given to formula_add_example is "." -- any output -- because the +# comparison is the real check, and restating the output as a regex would be +# a second copy to keep in step. +function(formula_add_pinned_example name source) + formula_add_example(${name} "${source}" ".") + string(REGEX REPLACE "\\.cpp$" ".expected.txt" expected "${source}") + if(expected STREQUAL source) + message(FATAL_ERROR "formula_add_pinned_example(${name}): ${source} is not a .cpp file, so it has no <stem>.expected.txt") + endif() + add_test(NAME "example.${name}.output" + COMMAND "${CMAKE_COMMAND}" + -D "EXAMPLE_EXE=$<TARGET_FILE:formula-cpp-example-${name}>" + -D "EXPECTED=${CMAKE_CURRENT_SOURCE_DIR}/${expected}" + -P "${PROJECT_SOURCE_DIR}/cmake/CheckExpectedOutput.cmake") +endfunction() + +# The README's program: the shortest complete use of the library. README.md +# shows its source and output, pinned below; docs/index.md includes both. +formula_add_pinned_example(readme readme.cpp) + +# The output check must catch a difference, not only pass on a match: run +# against an expected file that says 31 MPa, it must report one. +add_test(NAME example.readme.output-detects-a-difference + COMMAND "${CMAKE_COMMAND}" + -D "EXAMPLE_EXE=$<TARGET_FILE:formula-cpp-example-readme>" + -D "EXPECTED=${PROJECT_SOURCE_DIR}/test/fixtures/readme-wrong.expected.txt" + -P "${PROJECT_SOURCE_DIR}/cmake/CheckExpectedOutput.cmake") +set_tests_properties(example.readme.output-detects-a-difference PROPERTIES + PASS_REGULAR_EXPRESSION "differs from") + +# README.md shows examples/readme.cpp: its one ```cpp block must be a run of +# that source's lines, and its one ```text block a run of the lines it prints. +# GitHub renders the README directly, so it cannot include them as the site's +# pages do. +add_test(NAME docs.readme-snippets + COMMAND "${CMAKE_COMMAND}" + -D "EXAMPLE_SOURCE=${CMAKE_CURRENT_SOURCE_DIR}/readme.cpp" + -D "GUIDE=${PROJECT_SOURCE_DIR}/README.md" + -P "${PROJECT_SOURCE_DIR}/cmake/CheckGuideSnippets.cmake") +add_test(NAME docs.readme-output + COMMAND "${CMAKE_COMMAND}" + -D "EXAMPLE_EXE=$<TARGET_FILE:formula-cpp-example-readme>" + -D "GUIDE=${PROJECT_SOURCE_DIR}/README.md" + -P "${PROJECT_SOURCE_DIR}/cmake/CheckGuideOutput.cmake") + +# The tutorial's programs: docs/tutorial/ includes their code and output. +formula_add_pinned_example(tutorial_01_first_formula tutorial/01_first_formula.cpp) +formula_add_pinned_example(tutorial_02_units_and_dimensions tutorial/02_units_and_dimensions.cpp) +formula_add_pinned_example(tutorial_03_exact_numbers tutorial/03_exact_numbers.cpp) +formula_add_pinned_example(tutorial_04_missing_and_entered tutorial/04_missing_and_entered.cpp) +formula_add_pinned_example(tutorial_05_rounding tutorial/05_rounding.cpp) +formula_add_pinned_example(tutorial_06_constraints tutorial/06_constraints.cpp) +formula_add_pinned_example(tutorial_07_documentation tutorial/07_documentation.cpp) +formula_add_pinned_example(tutorial_08_tracing tutorial/08_tracing.cpp) +formula_add_pinned_example(tutorial_09_worksheets tutorial/09_worksheets.cpp) +formula_add_pinned_example(tutorial_10_lookup_tables tutorial/10_lookup_tables.cpp) +formula_add_pinned_example(tutorial_11_methods_and_overlays tutorial/11_methods_and_overlays.cpp) +formula_add_pinned_example(tutorial_12_series tutorial/12_series.cpp) +formula_add_pinned_example(tutorial_13_statistics tutorial/13_statistics.cpp) +formula_add_pinned_example(tutorial_14_records tutorial/14_records.cpp) +formula_add_pinned_example(tutorial_15_opaque_and_retry tutorial/15_opaque_and_retry.cpp) + formula_add_example(simple simple.cpp "s = 0.03 \\(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") @@ -311,23 +378,14 @@ add_test(NAME docs.display-output -D "GUIDE=${PROJECT_SOURCE_DIR}/docs/display.md" -D "CALL_TABLES=ON" -P "${PROJECT_SOURCE_DIR}/cmake/CheckGuideOutput.cmake") -# README.md's ```text blocks are judged against this example too: its -# std::format block is this program's output, and any ```text block added to -# README later must be as well. -add_test(NAME docs.readme-display-output - COMMAND "${CMAKE_COMMAND}" - -D "EXAMPLE_EXE=$<TARGET_FILE:formula-cpp-example-display>" - -D "GUIDE=${PROJECT_SOURCE_DIR}/README.md" - -P "${PROJECT_SOURCE_DIR}/cmake/CheckGuideOutput.cmake") add_test(NAME docs.display-snippets COMMAND "${CMAKE_COMMAND}" -D "EXAMPLE_SOURCE=${CMAKE_CURRENT_SOURCE_DIR}/display.cpp" -D "GUIDE=${PROJECT_SOURCE_DIR}/docs/display.md" -P "${PROJECT_SOURCE_DIR}/cmake/CheckGuideSnippets.cmake") -# README.md and docs/index.md quote this program's three speeds at 250 W -# verbatim, and say that the descent at no power has no real answer. Pinned -# here, in the order the program prints them, in a bracket argument for the -# reason the constraints example above gives: the literal `.` and `(...)` -# around each speed need escaping in the regex. +# The program's three speeds at 250 W, and the descent at no power that has +# no real answer, pinned in the order the program prints them, in a bracket +# argument for the reason the constraints example above gives: the literal +# `.` and `(...)` around each speed need escaping in the regex. formula_add_example(cycling_speed cycling_speed.cpp [==[flat road, asphalt, 250 W: +10\.332 m/s \(37\.2 km/h\).*3 % climb, asphalt, 250 W: +6\.783 m/s \(24\.4 km/h\).*flat road, cobbles, 250 W: +8\.335 m/s \(30\.0 km/h\).*8 % descent, asphalt, 0 W: +argument outside the domain.*all checks passed: yes]==]) diff --git a/examples/lookup_tables.cpp b/examples/lookup_tables.cpp index d0de7f8f..36cd4765 100644 --- a/examples/lookup_tables.cpp +++ b/examples/lookup_tables.cpp @@ -220,8 +220,8 @@ inline constexpr formula::BandTable<3> ClassBands { // The nested shape: a banded lookup whose operand is an interpolating lookup. [[nodiscard]] constexpr auto classFactor() { - return formula::yields<SizeCorrection>(formula::banded_lookup<unit::Percent, ClassBands, unit::Percent>( - sizeCurveFactor().expression, { 91.9_r, 101.3_r, 108.7_r })); + return formula::yields<SizeCorrection>( + formula::banded_lookup<unit::Percent, ClassBands, unit::Percent>(sizeCurveFactor(), { 91.9_r, 101.3_r, 108.7_r })); } // The whole method: a measured strength corrected by two tables at once. The @@ -232,7 +232,7 @@ inline constexpr formula::BandTable<3> ClassBands { [[nodiscard]] constexpr auto correctedStrength(LookupExampleShape shape) { return formula::yields<CorrectedStrength>( - formula::documented(var<MeasuredStrength> * sizeFactor().expression * shapeFactor(shape).expression, + formula::documented(var<MeasuredStrength> * sizeFactor() * shapeFactor(shape), { .title = "Corrected compressive strength", .reference = "Example Standard 8:2020", .section = "7.3", diff --git a/examples/readme.cpp b/examples/readme.cpp new file mode 100644 index 00000000..5847a0ab --- /dev/null +++ b/examples/readme.cpp @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Apache-2.0 +// The README's example: the compressive strength of a specimen, the maximum +// load it carried over the area that carried it. README.md shows it whole. + +// --8<-- [start:program] +#include <formula-cpp/format.hpp> +#include <formula-cpp/formula.hpp> + +#include <print> + +// A quantity is a type: a symbol, a description and a unit. +using Load = formula::Quantity<struct LoadTag, "F", "maximum load", formula::unit::Kilonewton>; +using Area = formula::Quantity<struct AreaTag, "A_c", "loaded area", formula::unit::SquareMillimetre>; +using Strength = formula::Quantity<struct StrengthTag, "f_c", "compressive strength", formula::unit::Megapascal>; + +// The formula, written once with ordinary operators. +constexpr auto strength = formula::yields<Strength>(formula::var<Load> / formula::var<Area>); + +int main() +{ + auto const specimen = formula::environment(formula::Measured<Load> { 675 }, formula::Measured<Area> { 22500 }); + + auto const result = formula::checked_evaluate(strength, specimen); + if (!result) + { + std::println("cannot calculate: {}", result.error()); + return 1; + } + std::println("{} = {}", formula::symbol_of<Strength>(), *result); +} +// --8<-- [end:program] diff --git a/examples/readme.expected.txt b/examples/readme.expected.txt new file mode 100644 index 00000000..8dad360e --- /dev/null +++ b/examples/readme.expected.txt @@ -0,0 +1 @@ +f_c = 30 MPa diff --git a/examples/series.cpp b/examples/series.cpp index 32b155c8..e9e0bceb 100644 --- a/examples/series.cpp +++ b/examples/series.cpp @@ -77,7 +77,7 @@ inline constexpr auto retainedInAll = formula::yields<Retained>(formula::sum(for // 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<unit::Metre, screens>, passing.expression); +inline constexpr auto grading = formula::curve(formula::domain<unit::Metre, screens>, passing); inline constexpr auto passingAt173 = formula::yields<Passing>(formula::interpolate_at(grading, formula::constant<unit::Metre>(173))); @@ -104,9 +104,8 @@ inline constexpr formula::Envelope<5> gradingEnvelope { formula::LimitRow { formula::limit(61), formula::limit(79) }, formula::LimitRow { formula::limit(83), formula::limit(99) } }; -inline constexpr auto gradingCheck = formula::conformity<unit::Percent>(passing.expression, - gradingEnvelope, - formula::Verdict { "outside the grading envelope" }); +inline constexpr auto gradingCheck = + formula::conformity<unit::Percent>(passing, gradingEnvelope, formula::Verdict { "outside the grading envelope" }); // ---- 6. Snapping, and splicing two curves -------------------------------------- // @@ -114,7 +113,7 @@ inline constexpr auto gradingCheck = formula::conformity<unit::Percent>(passing. // round, and snapped to the nearest declared screen. inline constexpr auto halfPassing = formula::snapped<unit::Metre, screens, formula::SnapTie::TowardLower>(formula::interpolate_at( - formula::curve(passing.expression, formula::domain<unit::Metre, screens>), formula::constant<unit::Percent>(50))); + formula::curve(passing, formula::domain<unit::Metre, screens>), formula::constant<unit::Percent>(50))); // A coarse analysis and a fine one, at invented openings of their own. inline constexpr formula::BreakpointTable<3> coarseScreens { formula::breakpoint(103), @@ -139,7 +138,7 @@ inline constexpr formula::BandTable<3> sizeClasses { formula::band(0, 127), formula::band(197, 331) }; inline constexpr auto counted = formula::yields<Count>(formula::binned<unit::Metre, sizeClasses>(formula::observations<ParticleSize, 8>)); -inline constexpr auto shares = counted.expression / formula::sum(counted.expression); +inline constexpr auto shares = counted / formula::sum(counted); // ---- Printing -------------------------------------------------------------------- diff --git a/examples/tutorial/01_first_formula.cpp b/examples/tutorial/01_first_formula.cpp new file mode 100644 index 00000000..f603515c --- /dev/null +++ b/examples/tutorial/01_first_formula.cpp @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 +// Tutorial, chapter 1: a first formula. A specimen's compressive strength is +// the maximum load it carried over the area that carried it. + +// --8<-- [start:includes] +#include <formula-cpp/format.hpp> +#include <formula-cpp/formula.hpp> + +#include <print> +// --8<-- [end:includes] + +namespace +{ +// --8<-- [start:quantities] +using Load = formula::Quantity<struct LoadTag, "F", "maximum load", formula::unit::Kilonewton>; +using Area = formula::Quantity<struct AreaTag, "A_c", "loaded area", formula::unit::SquareMillimetre>; +using Strength = formula::Quantity<struct StrengthTag, "f_c", "compressive strength", formula::unit::Megapascal>; +// --8<-- [end:quantities] + +// --8<-- [start:formula] +constexpr auto strength = formula::yields<Strength>(formula::var<Load> / formula::var<Area>); +// --8<-- [end:formula] +} // namespace + +int main() +{ + // --8<-- [start:environment] + auto const specimen = formula::environment(formula::Measured<Load> { 675 }, formula::Measured<Area> { 22500 }); + // --8<-- [end:environment] + + // --8<-- [start:evaluate] + auto const result = formula::checked_evaluate(strength, specimen); + if (!result) + { + std::println("cannot calculate the strength: {}", result.error()); + return 1; + } + std::println("{} = {} ({})", formula::symbol_of<Strength>(), *result, result->source()); + // --8<-- [end:evaluate] + return 0; +} diff --git a/examples/tutorial/01_first_formula.expected.txt b/examples/tutorial/01_first_formula.expected.txt new file mode 100644 index 00000000..09c44707 --- /dev/null +++ b/examples/tutorial/01_first_formula.expected.txt @@ -0,0 +1 @@ +f_c = 30 MPa (derived) diff --git a/examples/tutorial/02_units_and_dimensions.cpp b/examples/tutorial/02_units_and_dimensions.cpp new file mode 100644 index 00000000..809ddfff --- /dev/null +++ b/examples/tutorial/02_units_and_dimensions.cpp @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache-2.0 +// Tutorial, chapter 2: units and dimensions. The loaded area is calculated +// from the specimen's two sides, and the strength is stated in a second unit. + +#include <formula-cpp/format.hpp> +#include <formula-cpp/formula.hpp> + +#include <print> + +namespace +{ +// --8<-- [start:names] +using formula::var; +namespace unit = formula::unit; +using namespace formula::literals; +// --8<-- [end:names] + +using Load = formula::Quantity<struct LoadTag, "F", "maximum load", unit::Kilonewton>; +using Area = formula::Quantity<struct AreaTag, "A_c", "loaded area", unit::SquareMillimetre>; +using Strength = formula::Quantity<struct StrengthTag, "f_c", "compressive strength", unit::Megapascal>; + +// --8<-- [start:sides] +using SideA = formula::Quantity<struct SideATag, "a", "first side of the loaded face", unit::Millimetre>; +using SideB = formula::Quantity<struct SideBTag, "b", "second side of the loaded face", unit::Millimetre>; +// --8<-- [end:sides] + +// --8<-- [start:other-unit] +using StrengthInNewtons = + formula::Quantity<struct StrengthInNewtonsTag, "f_c", "compressive strength", unit::NewtonPerSquareMillimetre>; +// --8<-- [end:other-unit] + +// --8<-- [start:formulas] +constexpr auto loadedArea = formula::yields<Area>(var<SideA> * var<SideB>); +constexpr auto strength = formula::yields<Strength>(var<Load> / loadedArea); +// --8<-- [end:formulas] +} // namespace + +int main() +{ + // --8<-- [start:environment] + auto const specimen = formula::environment( + formula::Measured<SideA> { 150 }, formula::Measured<SideB> { 150 }, formula::Measured<Load> { 675 }); + // --8<-- [end:environment] + + // --8<-- [start:evaluate] + auto const area = formula::checked_evaluate(loadedArea, specimen); + if (!area) + { + std::println("cannot calculate the area: {}", area.error()); + return 1; + } + std::println("{} = {} ({})", formula::symbol_of<Area>(), *area, area->source()); + + auto const result = formula::checked_evaluate(strength, specimen); + if (!result) + { + std::println("cannot calculate the strength: {}", result.error()); + return 1; + } + std::println("{} = {} ({})", formula::symbol_of<Strength>(), *result, result->source()); + // --8<-- [end:evaluate] + + // --8<-- [start:evaluate-other-unit] + auto const inNewtons = formula::checked_evaluate<StrengthInNewtons>(strength.expression, specimen); + if (!inNewtons) + { + std::println("cannot calculate the strength in N/mm2: {}", inNewtons.error()); + return 1; + } + std::println("{} = {} ({})", formula::symbol_of<StrengthInNewtons>(), *inNewtons, inNewtons->source()); + // --8<-- [end:evaluate-other-unit] + return 0; +} diff --git a/examples/tutorial/02_units_and_dimensions.expected.txt b/examples/tutorial/02_units_and_dimensions.expected.txt new file mode 100644 index 00000000..4884fe44 --- /dev/null +++ b/examples/tutorial/02_units_and_dimensions.expected.txt @@ -0,0 +1,3 @@ +A_c = 22500 mm2 (derived) +f_c = 30 MPa (derived) +f_c = 30 N/mm2 (derived) diff --git a/examples/tutorial/03_exact_numbers.cpp b/examples/tutorial/03_exact_numbers.cpp new file mode 100644 index 00000000..607bd541 --- /dev/null +++ b/examples/tutorial/03_exact_numbers.cpp @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 +// Tutorial, chapter 3: exact numbers. The specimen is measured as it really +// is, the strength is calculated exactly, and then rounded for reading. + +#include <formula-cpp/format.hpp> +#include <formula-cpp/formula.hpp> + +#include <print> + +namespace +{ +using formula::var; +namespace unit = formula::unit; +using namespace formula::literals; + +using Load = formula::Quantity<struct LoadTag, "F", "maximum load", unit::Kilonewton>; +using Area = formula::Quantity<struct AreaTag, "A_c", "loaded area", unit::SquareMillimetre>; +using Strength = formula::Quantity<struct StrengthTag, "f_c", "compressive strength", unit::Megapascal>; + +using SideA = formula::Quantity<struct SideATag, "a", "first side of the loaded face", unit::Millimetre>; +using SideB = formula::Quantity<struct SideBTag, "b", "second side of the loaded face", unit::Millimetre>; + +constexpr auto loadedArea = formula::yields<Area>(var<SideA> * var<SideB>); +constexpr auto strength = formula::yields<Strength>(var<Load> / loadedArea); +} // namespace + +int main() +{ + // --8<-- [start:measured] + auto const specimen = formula::environment( + formula::Measured<SideA> { 150.2_r }, formula::Measured<SideB> { 149.8_r }, formula::Measured<Load> { 675.4_r }); + // --8<-- [end:measured] + + // --8<-- [start:evaluate] + auto const area = formula::checked_evaluate(loadedArea, specimen); + if (!area) + { + std::println("cannot calculate the area: {}", area.error()); + return 1; + } + std::println("{} = {} ({})", formula::symbol_of<Area>(), *area, area->source()); + + auto const result = formula::checked_evaluate(strength, specimen); + if (!result) + { + std::println("cannot calculate the strength: {}", result.error()); + return 1; + } + std::println("{} = {} ({})", formula::symbol_of<Strength>(), *result, result->source()); + // --8<-- [end:evaluate] + + // --8<-- [start:for-reading] + std::println("{}, for reading = {:~.2HalfEven}", formula::symbol_of<Strength>(), *result); + // --8<-- [end:for-reading] + + // --8<-- [start:exact-sum] + std::println("0.1 + 0.2 == 0.3: {}", 0.1_r + 0.2_r == 0.3_r ? "yes" : "no"); + // --8<-- [end:exact-sum] + return 0; +} diff --git a/examples/tutorial/03_exact_numbers.expected.txt b/examples/tutorial/03_exact_numbers.expected.txt new file mode 100644 index 00000000..4f1de131 --- /dev/null +++ b/examples/tutorial/03_exact_numbers.expected.txt @@ -0,0 +1,4 @@ +A_c = 22499.96 mm2 (derived) +f_c = 16885000/562499 MPa (derived) +f_c, for reading = ≈30.02 MPa +0.1 + 0.2 == 0.3: yes diff --git a/examples/tutorial/04_missing_and_entered.cpp b/examples/tutorial/04_missing_and_entered.cpp new file mode 100644 index 00000000..3b0d2d05 --- /dev/null +++ b/examples/tutorial/04_missing_and_entered.cpp @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: Apache-2.0 +// Tutorial, chapter 4: missing and entered values. One side of the specimen +// is never measured, and the strength is once typed in by hand. + +#include <formula-cpp/format.hpp> +#include <formula-cpp/formula.hpp> + +#include <print> + +namespace +{ +using formula::var; +namespace unit = formula::unit; +using namespace formula::literals; + +using Load = formula::Quantity<struct LoadTag, "F", "maximum load", unit::Kilonewton>; +using Area = formula::Quantity<struct AreaTag, "A_c", "loaded area", unit::SquareMillimetre>; +using Strength = formula::Quantity<struct StrengthTag, "f_c", "compressive strength", unit::Megapascal>; + +using SideA = formula::Quantity<struct SideATag, "a", "first side of the loaded face", unit::Millimetre>; +using SideB = formula::Quantity<struct SideBTag, "b", "second side of the loaded face", unit::Millimetre>; + +constexpr auto loadedArea = formula::yields<Area>(var<SideA> * var<SideB>); +constexpr auto strength = formula::yields<Strength>(var<Load> / loadedArea); +} // namespace + +int main() +{ + // --8<-- [start:measured] + auto const specimen = formula::environment( + formula::Measured<SideA> { 150 }, formula::Measured<SideB> { 150 }, formula::Measured<Load> { 675 }); + auto const result = formula::checked_evaluate(strength, specimen); + if (!result) + { + std::println("cannot calculate the strength: {}", result.error()); + return 1; + } + std::println("measured: {} = {} ({})", formula::symbol_of<Strength>(), *result, result->source()); + // --8<-- [end:measured] + + // --8<-- [start:absent] + auto const sideBMissing = formula::environment( + formula::Measured<SideA> { 150 }, formula::Measured<SideB>::absent(), formula::Measured<Load> { 675 }); + auto const withoutB = formula::checked_evaluate(strength, sideBMissing); + if (!withoutB) + { + std::println("cannot calculate the strength: {}", withoutB.error()); + return 1; + } + if (formula::number_of(*withoutB).has_value()) + { + std::println("a strength was calculated without side b: {}", *withoutB); + return 1; + } + std::println("b not measured: {} = {}", formula::symbol_of<Strength>(), withoutB->kind()); + // --8<-- [end:absent] + + // --8<-- [start:entered] + auto const typedIn = formula::environment(formula::Measured<SideA> { 150 }, + formula::Measured<SideB> { 150 }, + formula::Measured<Load> { 675 }, + formula::entered(formula::Measured<Strength> { 31 })); + auto const asEntered = formula::checked_evaluate(strength, typedIn); + if (!asEntered) + { + std::println("cannot calculate the strength: {}", asEntered.error()); + return 1; + } + if (!asEntered->is_overridden()) + { + std::println("the strength entered by hand was not used: {}", *asEntered); + return 1; + } + std::println("entered by hand: {} = {} ({})", formula::symbol_of<Strength>(), *asEntered, asEntered->source()); + // --8<-- [end:entered] + return 0; +} diff --git a/examples/tutorial/04_missing_and_entered.expected.txt b/examples/tutorial/04_missing_and_entered.expected.txt new file mode 100644 index 00000000..4208a336 --- /dev/null +++ b/examples/tutorial/04_missing_and_entered.expected.txt @@ -0,0 +1,3 @@ +measured: f_c = 30 MPa (derived) +b not measured: f_c = empty +entered by hand: f_c = 31 MPa (manually entered) diff --git a/examples/tutorial/05_rounding.cpp b/examples/tutorial/05_rounding.cpp new file mode 100644 index 00000000..228120ac --- /dev/null +++ b/examples/tutorial/05_rounding.cpp @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache-2.0 +// Tutorial, chapter 5: rounding. The method rounds the strength to a tenth of +// a megapascal, and the rounding mode decides a value exactly halfway. + +#include <formula-cpp/format.hpp> +#include <formula-cpp/formula.hpp> + +#include <print> + +namespace +{ +using formula::var; +namespace unit = formula::unit; +using namespace formula::literals; + +using Load = formula::Quantity<struct LoadTag, "F", "maximum load", unit::Kilonewton>; +using Area = formula::Quantity<struct AreaTag, "A_c", "loaded area", unit::SquareMillimetre>; +using Strength = formula::Quantity<struct StrengthTag, "f_c", "compressive strength", unit::Megapascal>; + +using SideA = formula::Quantity<struct SideATag, "a", "first side of the loaded face", unit::Millimetre>; +using SideB = formula::Quantity<struct SideBTag, "b", "second side of the loaded face", unit::Millimetre>; + +// --8<-- [start:roundings] +constexpr formula::DecimalRounding tenthMpa { unit::Megapascal, + formula::DecimalPlaces { 1 }, + formula::RoundingMode::HalfAwayFromZero }; +constexpr formula::DecimalRounding tenthMpaHalfEven { unit::Megapascal, + formula::DecimalPlaces { 1 }, + formula::RoundingMode::HalfEven }; +// --8<-- [end:roundings] + +// --8<-- [start:formulas] +constexpr auto loadedArea = formula::yields<Area>(var<SideA> * var<SideB>); +constexpr auto strength = formula::yields<Strength>(formula::rounded<tenthMpa>(var<Load> / loadedArea)); +constexpr auto strengthHalfEven = formula::yields<Strength>(formula::rounded<tenthMpaHalfEven>(var<Load> / loadedArea)); +// --8<-- [end:formulas] +} // namespace + +int main() +{ + // --8<-- [start:evaluate] + auto const specimen = formula::environment( + formula::Measured<SideA> { 150 }, formula::Measured<SideB> { 150 }, formula::Measured<Load> { 675.4_r }); + auto const result = formula::checked_evaluate(strength, specimen); + if (!result) + { + std::println("cannot calculate the strength: {}", result.error()); + return 1; + } + std::println("675.4 kN, half away from zero: {} = {}", formula::symbol_of<Strength>(), *result); + // --8<-- [end:evaluate] + + // --8<-- [start:halfway] + auto const halfway = formula::environment( + formula::Measured<SideA> { 150 }, formula::Measured<SideB> { 150 }, formula::Measured<Load> { 676.125_r }); + auto const awayFromZero = formula::checked_evaluate(strength, halfway); + if (!awayFromZero) + { + std::println("cannot calculate the strength: {}", awayFromZero.error()); + return 1; + } + std::println("676.125 kN, half away from zero: {} = {}", formula::symbol_of<Strength>(), *awayFromZero); + + auto const toEven = formula::checked_evaluate(strengthHalfEven, halfway); + if (!toEven) + { + std::println("cannot calculate the strength: {}", toEven.error()); + return 1; + } + std::println("676.125 kN, half to even: {} = {}", formula::symbol_of<Strength>(), *toEven); + // --8<-- [end:halfway] + return 0; +} diff --git a/examples/tutorial/05_rounding.expected.txt b/examples/tutorial/05_rounding.expected.txt new file mode 100644 index 00000000..b3aff9b2 --- /dev/null +++ b/examples/tutorial/05_rounding.expected.txt @@ -0,0 +1,3 @@ +675.4 kN, half away from zero: f_c = 30 MPa +676.125 kN, half away from zero: f_c = 30.1 MPa +676.125 kN, half to even: f_c = 30 MPa diff --git a/examples/tutorial/06_constraints.cpp b/examples/tutorial/06_constraints.cpp new file mode 100644 index 00000000..45018923 --- /dev/null +++ b/examples/tutorial/06_constraints.cpp @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: Apache-2.0 +// Tutorial, chapter 6: constraints. Each side of the specimen must measure +// 150 mm within 1 mm, and a side nobody measured is not checked. + +#include <formula-cpp/format.hpp> +#include <formula-cpp/formula.hpp> + +#include <array> +#include <cstddef> +#include <print> +#include <string_view> + +namespace +{ +using formula::var; +namespace unit = formula::unit; +using namespace formula::literals; + +using SideA = formula::Quantity<struct SideATag, "a", "first side of the loaded face", unit::Millimetre>; +using SideB = formula::Quantity<struct SideBTag, "b", "second side of the loaded face", unit::Millimetre>; + +// --8<-- [start:constraints] +constexpr auto sideANotTooShort = formula::constraint(var<SideA> >= formula::constant<unit::Millimetre>(149), + formula::Verdict { "reject the specimen: side a out of tolerance" }); +constexpr auto sideANotTooLong = formula::constraint(var<SideA> <= formula::constant<unit::Millimetre>(151), + formula::Verdict { "reject the specimen: side a out of tolerance" }); +constexpr auto sideBNotTooShort = formula::constraint(var<SideB> >= formula::constant<unit::Millimetre>(149), + formula::Verdict { "reject the specimen: side b out of tolerance" }); +constexpr auto sideBNotTooLong = formula::constraint(var<SideB> <= formula::constant<unit::Millimetre>(151), + formula::Verdict { "reject the specimen: side b out of tolerance" }); +// --8<-- [end:constraints] + +// --8<-- [start:set] +constexpr auto sideTolerances = formula::constraints(sideANotTooShort, sideANotTooLong, sideBNotTooShort, sideBNotTooLong); +constexpr std::array<std::string_view, 4> ruleNames { "a >= 149 mm", "a <= 151 mm", "b >= 149 mm", "b <= 151 mm" }; +// --8<-- [end:set] + +// --8<-- [start:print] +// Prints one rule's outcome on a line of its own. Returns false if checking +// the rule failed, after printing why. +bool print_outcome(std::string_view rule, formula::ConstraintOutcome const& outcome) +{ + if (auto const verdict = outcome.verdict()) + std::println(" {}: {} ({})", rule, outcome.kind(), verdict->label); + else if (auto const error = outcome.error()) + { + std::println(" {}: {} ({})", rule, outcome.kind(), *error); + return false; + } + else + std::println(" {}: {}", rule, outcome.kind()); + return true; +} +// --8<-- [end:print] +} // namespace + +int main() +{ + // --8<-- [start:specimens] + auto const withinTolerance = + formula::environment(formula::Measured<SideA> { 150.2_r }, formula::Measured<SideB> { 149.8_r }); + auto const sideBTooLong = + formula::environment(formula::Measured<SideA> { 150.2_r }, formula::Measured<SideB> { 152.5_r }); + auto const sideBMissing = formula::environment(formula::Measured<SideA> { 150.2_r }, formula::Measured<SideB>::absent()); + // --8<-- [end:specimens] + + // --8<-- [start:check] + std::println("specimen 2, one rule:"); + if (!print_outcome("b <= 151 mm", formula::check(sideBNotTooLong, sideBTooLong))) + return 1; + // --8<-- [end:check] + + // --8<-- [start:check-all] + auto const checkAndPrint = [](std::string_view name, auto const& specimen) { + std::array<formula::ConstraintOutcome, 4> const outcomes = formula::check_all(sideTolerances, specimen); + std::println("{}:", name); + bool checked = true; + for (std::size_t index = 0; index < outcomes.size(); ++index) + checked = print_outcome(ruleNames[index], outcomes[index]) && checked; + return checked; + }; + if (!checkAndPrint("specimen 1, 150.2 mm by 149.8 mm", withinTolerance) + || !checkAndPrint("specimen 2, 150.2 mm by 152.5 mm", sideBTooLong) + || !checkAndPrint("specimen 3, side b not measured", sideBMissing)) + return 1; + // --8<-- [end:check-all] + return 0; +} diff --git a/examples/tutorial/06_constraints.expected.txt b/examples/tutorial/06_constraints.expected.txt new file mode 100644 index 00000000..3276af9c --- /dev/null +++ b/examples/tutorial/06_constraints.expected.txt @@ -0,0 +1,17 @@ +specimen 2, one rule: + b <= 151 mm: violated (reject the specimen: side b out of tolerance) +specimen 1, 150.2 mm by 149.8 mm: + a >= 149 mm: satisfied + a <= 151 mm: satisfied + b >= 149 mm: satisfied + b <= 151 mm: satisfied +specimen 2, 150.2 mm by 152.5 mm: + a >= 149 mm: satisfied + a <= 151 mm: satisfied + b >= 149 mm: satisfied + b <= 151 mm: violated (reject the specimen: side b out of tolerance) +specimen 3, side b not measured: + a >= 149 mm: satisfied + a <= 151 mm: satisfied + b >= 149 mm: not checked + b <= 151 mm: not checked diff --git a/examples/tutorial/07_documentation.cpp b/examples/tutorial/07_documentation.cpp new file mode 100644 index 00000000..59b2468c --- /dev/null +++ b/examples/tutorial/07_documentation.cpp @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 +// Tutorial, chapter 7: citations, rendering and documentation. The strength +// formula cites its source, and is written out as text, LaTeX and a symbol table. + +#include <formula-cpp/document.hpp> +#include <formula-cpp/format.hpp> +#include <formula-cpp/formula.hpp> +#include <formula-cpp/render.hpp> + +#include <print> + +namespace +{ +using formula::var; +namespace unit = formula::unit; + +using Load = formula::Quantity<struct LoadTag, "F", "maximum load", unit::Kilonewton>; +using Area = formula::Quantity<struct AreaTag, "A_c", "loaded area", unit::SquareMillimetre>; +using Strength = formula::Quantity<struct StrengthTag, "f_c", "compressive strength", unit::Megapascal>; + +using SideA = formula::Quantity<struct SideATag, "a", "first side of the loaded face", unit::Millimetre>; +using SideB = formula::Quantity<struct SideBTag, "b", "second side of the loaded face", unit::Millimetre>; + +constexpr formula::DecimalRounding tenthMpa { unit::Megapascal, + formula::DecimalPlaces { 1 }, + formula::RoundingMode::HalfAwayFromZero }; + +// --8<-- [start:formulas] +constexpr auto loadedArea = formula::yields<Area>(var<SideA> * var<SideB>); +constexpr auto strength = + formula::yields<Strength>(formula::documented(formula::rounded<tenthMpa>(var<Load> / loadedArea), + { .title = "Compressive strength", + .reference = "Example Standard 12:2020", + .section = "6.1", + .equation = "(1)", + .text = "The maximum load divided by the area of the loaded face." })); +// --8<-- [end:formulas] +} // namespace + +int main() +{ + // --8<-- [start:render] + std::println("text: {}", formula::render(strength)); + std::println("LaTeX: {}", formula::render<formula::Dialect::LaTeX>(strength)); + // --8<-- [end:render] + + // --8<-- [start:document] + formula::Documentation const page = formula::document(strength); + std::println("symbols:"); + for (formula::SymbolEntry const& entry: page.symbols) + std::println(" {:<5} {:<6} {}", entry.symbol, entry.unit, entry.description); + for (formula::Citation const& citation: page.citations) + std::println("cited: {}, {}", citation.title, citation.reference); + // --8<-- [end:document] + + // --8<-- [start:evaluate] + auto const specimen = formula::environment( + formula::Measured<SideA> { 150 }, formula::Measured<SideB> { 150 }, formula::Measured<Load> { 675 }); + auto const result = formula::checked_evaluate(strength, specimen); + if (!result) + { + std::println("cannot calculate the strength: {}", result.error()); + return 1; + } + std::println("{} = {}", formula::symbol_of<Strength>(), *result); + // --8<-- [end:evaluate] + return 0; +} diff --git a/examples/tutorial/07_documentation.expected.txt b/examples/tutorial/07_documentation.expected.txt new file mode 100644 index 00000000..cd5715aa --- /dev/null +++ b/examples/tutorial/07_documentation.expected.txt @@ -0,0 +1,8 @@ +text: round(F / (a * b), to 1 dp of MPa) +LaTeX: \operatorname{round}_{1\,\mathrm{MPa}}(\frac{F}{a \cdot b}) +symbols: + F kN maximum load + a mm first side of the loaded face + b mm second side of the loaded face +cited: Compressive strength, Example Standard 12:2020 +f_c = 30 MPa diff --git a/examples/tutorial/08_tracing.cpp b/examples/tutorial/08_tracing.cpp new file mode 100644 index 00000000..3cead1b8 --- /dev/null +++ b/examples/tutorial/08_tracing.cpp @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: Apache-2.0 +// Tutorial, chapter 8: tracing. The strength is calculated together with each +// step that reached it, and a strength entered by hand has no steps. + +#include <formula-cpp/format.hpp> +#include <formula-cpp/formula.hpp> +#include <formula-cpp/trace.hpp> +#include <formula-cpp/trace_render.hpp> + +#include <print> + +namespace +{ +using formula::var; +namespace unit = formula::unit; +using namespace formula::literals; + +using Load = formula::Quantity<struct LoadTag, "F", "maximum load", unit::Kilonewton>; +using Area = formula::Quantity<struct AreaTag, "A_c", "loaded area", unit::SquareMillimetre>; +using Strength = formula::Quantity<struct StrengthTag, "f_c", "compressive strength", unit::Megapascal>; + +using SideA = formula::Quantity<struct SideATag, "a", "first side of the loaded face", unit::Millimetre>; +using SideB = formula::Quantity<struct SideBTag, "b", "second side of the loaded face", unit::Millimetre>; + +constexpr formula::DecimalRounding tenthMpa { unit::Megapascal, + formula::DecimalPlaces { 1 }, + formula::RoundingMode::HalfAwayFromZero }; + +constexpr auto loadedArea = formula::yields<Area>(var<SideA> * var<SideB>); +constexpr auto strength = + formula::yields<Strength>(formula::documented(formula::rounded<tenthMpa>(var<Load> / loadedArea), + { .title = "Compressive strength", + .reference = "Example Standard 12:2020", + .section = "6.1", + .equation = "(1)", + .text = "The maximum load divided by the area of the loaded face." })); +} // namespace + +int main() +{ + auto const specimen = formula::environment( + formula::Measured<SideA> { 150.2_r }, formula::Measured<SideB> { 149.8_r }, formula::Measured<Load> { 675.4_r }); + + // --8<-- [start:explain] + auto const explained = formula::checked_explain<Strength>(strength, specimen); + if (!explained) + { + std::println("cannot explain the strength: {}", explained.error().error); + std::print("{}", formula::render_trace(explained.error().trace, { .maxSteps = 10 })); + return 1; + } + std::println("{} = {}", formula::symbol_of<Strength>(), explained->outcome); + std::print("{}", formula::render_trace(explained->trace, { .maxSteps = 10 })); + // --8<-- [end:explain] + + // --8<-- [start:entered] + auto const typedIn = formula::environment(formula::Measured<SideA> { 150.2_r }, + formula::Measured<SideB> { 149.8_r }, + formula::Measured<Load> { 675.4_r }, + formula::entered(formula::Measured<Strength> { 31 })); + auto const asEntered = formula::checked_explain<Strength>(strength, typedIn); + if (!asEntered) + { + std::println("cannot explain the strength: {}", asEntered.error().error); + std::print("{}", formula::render_trace(asEntered.error().trace, { .maxSteps = 10 })); + return 1; + } + if (!asEntered->outcome.is_overridden() || !asEntered->trace.empty()) + { + std::println("the strength entered by hand was derived: {}", asEntered->outcome); + return 1; + } + std::println("entered by hand: {} = {} ({}), steps traced: {}", + formula::symbol_of<Strength>(), + asEntered->outcome, + asEntered->outcome.source(), + asEntered->trace.steps.size()); + // --8<-- [end:entered] + return 0; +} diff --git a/examples/tutorial/08_tracing.expected.txt b/examples/tutorial/08_tracing.expected.txt new file mode 100644 index 00000000..a6c738f9 --- /dev/null +++ b/examples/tutorial/08_tracing.expected.txt @@ -0,0 +1,9 @@ +f_c = 30 MPa +1. F = 3377/5 kN +2. a = 751/5 mm +3. b = 749/5 mm +4. #2 * #3 = 562499/25000000 m^2 +5. #1 / #4 = 16885000000000/562499 kg/(m s^2) +6. round(#5, to 1 dp of MPa) = 30 MPa [nearest, ties away from zero] +7. #6 = 30 MPa [Compressive strength, Example Standard 12:2020, 6.1, (1)] +entered by hand: f_c = 31 MPa (manually entered), steps traced: 0 diff --git a/examples/tutorial/09_worksheets.cpp b/examples/tutorial/09_worksheets.cpp new file mode 100644 index 00000000..f9f95a9e --- /dev/null +++ b/examples/tutorial/09_worksheets.cpp @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 +// Tutorial, chapter 9: calculations and worksheets. The whole test is one +// calculation, kept in a worksheet that recalculates only what a change reaches. + +#include <formula-cpp/format.hpp> +#include <formula-cpp/formula.hpp> + +#include <cstddef> +#include <print> + +namespace +{ +using formula::var; +namespace unit = formula::unit; +using namespace formula::literals; + +using Load = formula::Quantity<struct LoadTag, "F", "maximum load", unit::Kilonewton>; +using Area = formula::Quantity<struct AreaTag, "A_c", "loaded area", unit::SquareMillimetre>; +using Strength = formula::Quantity<struct StrengthTag, "f_c", "compressive strength", unit::Megapascal>; + +using SideA = formula::Quantity<struct SideATag, "a", "first side of the loaded face", unit::Millimetre>; +using SideB = formula::Quantity<struct SideBTag, "b", "second side of the loaded face", unit::Millimetre>; + +// --8<-- [start:quantities] +using Height = formula::Quantity<struct HeightTag, "h", "height of the specimen", unit::Millimetre>; +using Mass = formula::Quantity<struct MassTag, "m", "mass of the specimen", unit::Kilogram>; +using Volume = formula::Quantity<struct VolumeTag, "V", "volume of the specimen", unit::CubicMillimetre>; +using Density = formula::Quantity<struct DensityTag, "rho", "density of the specimen", unit::KilogramPerCubicMetre>; +// --8<-- [end:quantities] + +constexpr formula::DecimalRounding tenthMpa { unit::Megapascal, + formula::DecimalPlaces { 1 }, + formula::RoundingMode::HalfAwayFromZero }; + +// --8<-- [start:calculation] +inline constexpr auto test = formula::calculation( + formula::define<Area>(var<SideA> * var<SideB>), + formula::define<Volume>(var<Area> * var<Height>), + formula::define<Density>(var<Mass> / var<Volume>), + formula::define<Strength>(formula::documented(formula::rounded<tenthMpa>(var<Load> / var<Area>), + { .title = "Compressive strength", + .reference = "Example Standard 12:2020", + .section = "6.1", + .equation = "(1)", + .text = "The maximum load divided by the area of the loaded face." }))); +// --8<-- [end:calculation] + +// --8<-- [start:report] +// Asks the worksheet for the density and the strength, and prints both with +// how many values the question recalculated and reused. False when either +// calculation failed. +template <typename Sheet> +bool report(char const* step, Sheet& sheet) +{ + std::size_t const recomputedBefore = sheet.recomputed(); + std::size_t const reusedBefore = sheet.reused(); + auto const [density, strength] = sheet.checked_calculate(var<Density>, var<Strength>); + if (!density) + { + std::println("cannot calculate the density: {}", density.error()); + return false; + } + if (!strength) + { + std::println("cannot calculate the strength: {}", strength.error()); + return false; + } + std::println("{:<22} {} = {:~.1HalfEven}, {} = {}, recomputed {}, reused {}", + step, + formula::symbol_of<Density>(), + *density, + formula::symbol_of<Strength>(), + *strength, + sheet.recomputed() - recomputedBefore, + sheet.reused() - reusedBefore); + return true; +} +// --8<-- [end:report] +} // namespace + +int main() +{ + // --8<-- [start:worksheet] + auto sheet = formula::worksheet(test, + formula::environment(formula::Measured<SideA> { 150 }, + formula::Measured<SideB> { 150 }, + formula::Measured<Height> { 150 }, + formula::Measured<Mass> { 8.1_r }, + formula::Measured<Load> { 675 })); + if (!report("first run:", sheet)) + return 1; + // --8<-- [end:worksheet] + + // --8<-- [start:change] + sheet.set(formula::Measured<Load> { 676.125_r }); + if (!report("load 676.125 kN:", sheet)) + return 1; + // --8<-- [end:change] + + // --8<-- [start:what-if] + auto heavier = sheet.with(formula::Measured<Mass> { 8.25_r }); + if (!report("what if mass 8.25 kg:", heavier)) + return 1; + if (!report("the worksheet itself:", sheet)) + return 1; + // --8<-- [end:what-if] + return 0; +} diff --git a/examples/tutorial/09_worksheets.expected.txt b/examples/tutorial/09_worksheets.expected.txt new file mode 100644 index 00000000..c5e0d698 --- /dev/null +++ b/examples/tutorial/09_worksheets.expected.txt @@ -0,0 +1,4 @@ +first run: rho = 2400 kg/m3, f_c = 30 MPa, recomputed 4, reused 0 +load 676.125 kN: rho = 2400 kg/m3, f_c = 30.1 MPa, recomputed 1, reused 0 +what if mass 8.25 kg: rho = ≈2444.4 kg/m3, f_c = 30.1 MPa, recomputed 1, reused 0 +the worksheet itself: rho = 2400 kg/m3, f_c = 30.1 MPa, recomputed 0, reused 0 diff --git a/examples/tutorial/10_lookup_tables.cpp b/examples/tutorial/10_lookup_tables.cpp new file mode 100644 index 00000000..5f5bdfeb --- /dev/null +++ b/examples/tutorial/10_lookup_tables.cpp @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 +// Tutorial, chapter 10: lookup tables. A size correction factor is read from a +// published table by the specimen's edge length, and an edge outside it has none. + +#include <formula-cpp/format.hpp> +#include <formula-cpp/formula.hpp> +#include <formula-cpp/render.hpp> + +#include <print> + +namespace +{ +using formula::var; +namespace unit = formula::unit; +using namespace formula::literals; + +// --8<-- [start:quantities] +using Edge = formula::Quantity<struct EdgeTag, "a", "edge length of the specimen", unit::Millimetre>; +using Strength = formula::Quantity<struct StrengthTag, "f_c", "compressive strength", unit::Megapascal>; +using SizeFactor = formula::Quantity<struct SizeFactorTag, "k", "size correction factor", unit::One>; +using CorrectedStrength = + formula::Quantity<struct CorrectedStrengthTag, "f_cs", "compressive strength corrected for size", unit::Megapascal>; +// --8<-- [end:quantities] + +// --8<-- [start:bands] +inline constexpr formula::BandTable<3> EdgeBands { + formula::band(0, 100), // 0 to under 100 mm + formula::band(100, 200), // 100 to under 200 mm + formula::band(200, 300), // 200 to under 300 mm +}; +// --8<-- [end:bands] + +// --8<-- [start:lookup] +constexpr auto sizeFactor = formula::yields<SizeFactor>( + formula::banded_lookup<unit::Millimetre, EdgeBands, unit::One>(var<Edge>, { 1.05_r, 1, 0.95_r })); + +constexpr auto correctedStrength = formula::yields<CorrectedStrength>(var<Strength> * sizeFactor); +// --8<-- [end:lookup] + +// --8<-- [start:report] +/// Prints the size factor and the corrected strength of a specimen with an +/// edge of @p edge mm and a strength of 30 MPa. False when either has no value. +bool report(int edge) +{ + auto const specimen = formula::environment(formula::Measured<Edge> { edge }, formula::Measured<Strength> { 30 }); + auto const factor = formula::checked_evaluate(sizeFactor, specimen); + if (!factor) + { + std::println("a = {} mm: no size factor: {}", edge, factor.error()); + return false; + } + auto const corrected = formula::checked_evaluate(correctedStrength, specimen); + if (!corrected) + { + std::println("a = {} mm: no corrected strength: {}", edge, corrected.error()); + return false; + } + std::println("a = {} mm: {} = {}, {} = {}", + edge, + formula::symbol_of<SizeFactor>(), + *factor, + formula::symbol_of<CorrectedStrength>(), + *corrected); + return true; +} +// --8<-- [end:report] +} // namespace + +int main() +{ + // --8<-- [start:render] + std::println("{} = {}", formula::symbol_of<SizeFactor>(), formula::render(sizeFactor)); + // --8<-- [end:render] + + // --8<-- [start:evaluate] + if (!report(150) || !report(100) || !report(250)) + return 1; + // --8<-- [end:evaluate] + + // --8<-- [start:miss] + // 300 mm is in no band: the lookup must report an error, not a number. + if (report(300)) + return 1; + // --8<-- [end:miss] + return 0; +} diff --git a/examples/tutorial/10_lookup_tables.expected.txt b/examples/tutorial/10_lookup_tables.expected.txt new file mode 100644 index 00000000..35aa513d --- /dev/null +++ b/examples/tutorial/10_lookup_tables.expected.txt @@ -0,0 +1,5 @@ +k = lookup(a, 0 to under 100 mm gives 21/20, 100 to under 200 mm gives 1, 200 to under 300 mm gives 19/20) +a = 150 mm: k = 1, f_cs = 30 MPa +a = 100 mm: k = 1, f_cs = 30 MPa +a = 250 mm: k = 0.95, f_cs = 28.5 MPa +a = 300 mm: no size factor: argument outside the domain of the operation diff --git a/examples/tutorial/11_methods_and_overlays.cpp b/examples/tutorial/11_methods_and_overlays.cpp new file mode 100644 index 00000000..909ae234 --- /dev/null +++ b/examples/tutorial/11_methods_and_overlays.cpp @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: Apache-2.0 +// Tutorial, chapter 11: methods and overlays. One method gives the strength of a +// cube and of a cylinder, and a jurisdiction's overlay changes how it rounds. + +#include <formula-cpp/format.hpp> +#include <formula-cpp/formula.hpp> +#include <formula-cpp/trace.hpp> +#include <formula-cpp/trace_render.hpp> + +#include <print> + +namespace +{ +using formula::var; +namespace unit = formula::unit; + +// --8<-- [start:tags] +struct Cube +{ +}; +struct Cylinder +{ +}; +// --8<-- [end:tags] + +// --8<-- [start:quantities] +using Load = formula::Quantity<struct LoadTag, "F", "maximum load", unit::Kilonewton>; +using SideA = formula::Quantity<struct SideATag, "a", "first side of the loaded face", unit::Millimetre>; +using SideB = formula::Quantity<struct SideBTag, "b", "second side of the loaded face", unit::Millimetre>; +using Diameter = formula::Quantity<struct DiameterTag, "d", "diameter of the cylinder", unit::Millimetre>; +// --8<-- [end:quantities] + +// --8<-- [start:method] +constexpr formula::DecimalRounding tenthMpa { unit::Megapascal, + formula::DecimalPlaces { 1 }, + formula::RoundingMode::HalfAwayFromZero }; + +inline constexpr auto compressiveStrength = formula::method( + formula::variants( + formula::variant<Cube>(var<Load> / (var<SideA> * var<SideB>)), + formula::variant<Cylinder>(var<Load> / (formula::pi * formula::pow<2>(var<Diameter>) / formula::number(4)))), + formula::rounding_rule<tenthMpa>(), + formula::constraints()); +// --8<-- [end:method] + +// --8<-- [start:overlay] +constexpr formula::DecimalRounding wholeMpa { unit::Megapascal, + formula::DecimalPlaces { 0 }, + formula::RoundingMode::HalfAwayFromZero }; + +inline constexpr formula::Citation exampleRounding { .title = "Example jurisdiction", + .reference = "Example Standard 12:2020 NA", + .section = "NA.4" }; + +inline constexpr auto exampleJurisdiction = formula::overlay(formula::with_rounding<wholeMpa>(exampleRounding)); + +inline constexpr auto inExampleJurisdiction = formula::apply(exampleJurisdiction, compressiveStrength); +// --8<-- [end:overlay] + +// --8<-- [start:show] +/// Evaluates @p method for the variant @p Tag names, and prints @p heading and +/// the trace. False when the method fails or gives no value. +template <typename Tag, typename M, typename Env> +bool show(char const* heading, M const& method, Env const& specimen) +{ + auto const run = formula::explain_method<Tag>(method, specimen); + if (!run.outcome) + { + std::println("{}: {}", heading, run.outcome.error()); + return false; + } + if (!run.outcome->has_value()) + { + std::println("{}: no value", heading); + return false; + } + std::println("{}:", heading); + std::print("{}", formula::render_trace(run.trace, { .maxSteps = 20 })); + return true; +} +// --8<-- [end:show] +} // namespace + +int main() +{ + // --8<-- [start:specimens] + auto const cube = formula::environment( + formula::Measured<Load> { 675 }, formula::Measured<SideA> { 150 }, formula::Measured<SideB> { 150 }); + auto const cylinder = formula::environment(formula::Measured<Load> { 540 }, formula::Measured<Diameter> { 150 }); + // --8<-- [end:specimens] + + // --8<-- [start:base] + if (!show<Cube>("cube, base method", compressiveStrength, cube)) + return 1; + std::println(""); + if (!show<Cylinder>("cylinder, base method", compressiveStrength, cylinder)) + return 1; + // --8<-- [end:base] + std::println(""); + + // --8<-- [start:jurisdiction] + if (!show<Cube>("cube, Example jurisdiction", inExampleJurisdiction, cube)) + return 1; + std::println(""); + if (!show<Cylinder>("cylinder, Example jurisdiction", inExampleJurisdiction, cylinder)) + return 1; + // --8<-- [end:jurisdiction] + return 0; +} diff --git a/examples/tutorial/11_methods_and_overlays.expected.txt b/examples/tutorial/11_methods_and_overlays.expected.txt new file mode 100644 index 00000000..ffb012ff --- /dev/null +++ b/examples/tutorial/11_methods_and_overlays.expected.txt @@ -0,0 +1,41 @@ +cube, base method: +1. F = 675 kN +2. a = 150 mm +3. b = 150 mm +4. #2 * #3 = 9/400 m^2 +5. #1 / #4 = 30000000 kg/(m s^2) +6. round(#5, in MPa) = 30 MPa [rounded to 1 dp (method default); nearest, ties away from zero] +7. #6 = 30 MPa [variant Cube (1st of 2), selected by tag] + +cylinder, base method: +1. F = 540 kN +2. pi = 245850922/78256779 +3. d = 150 mm +4. #3^2 = 9/400 m^2 +5. #2 * #4 = 368776383/5217118600 m^2 +6. 4 +7. #5 / #6 = 368776383/20868474400 m^2 +8. #1 / #7 = 3756325392000000/122925461 kg/(m s^2) +9. round(#8, in MPa) = 153/5 MPa [rounded to 1 dp (method default); nearest, ties away from zero] +10. #9 = 153/5 MPa [variant Cylinder (2nd of 2), selected by tag] + +cube, Example jurisdiction: +1. F = 675 kN +2. a = 150 mm +3. b = 150 mm +4. #2 * #3 = 9/400 m^2 +5. #1 / #4 = 30000000 kg/(m s^2) +6. round(#5, in MPa) = 30 MPa [rounded to 0 dp (jurisdiction overlay: Example jurisdiction, Example Standard 12:2020 NA, NA.4); nearest, ties away from zero] +7. #6 = 30 MPa [variant Cube (1st of 2), selected by tag] + +cylinder, Example jurisdiction: +1. F = 540 kN +2. pi = 245850922/78256779 +3. d = 150 mm +4. #3^2 = 9/400 m^2 +5. #2 * #4 = 368776383/5217118600 m^2 +6. 4 +7. #5 / #6 = 368776383/20868474400 m^2 +8. #1 / #7 = 3756325392000000/122925461 kg/(m s^2) +9. round(#8, in MPa) = 31 MPa [rounded to 0 dp (jurisdiction overlay: Example jurisdiction, Example Standard 12:2020 NA, NA.4); nearest, ties away from zero] +10. #9 = 31 MPa [variant Cylinder (2nd of 2), selected by tag] diff --git a/examples/tutorial/12_series.cpp b/examples/tutorial/12_series.cpp new file mode 100644 index 00000000..37888cf8 --- /dev/null +++ b/examples/tutorial/12_series.cpp @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: Apache-2.0 +// Tutorial, chapter 12: series. One mix's strength at 7, 14 and 28 days is one +// quantity at three ages, and each is calculated as a share of the 28-day strength. + +#include <formula-cpp/format.hpp> +#include <formula-cpp/formula.hpp> +#include <formula-cpp/render.hpp> +#include <formula-cpp/trace.hpp> +#include <formula-cpp/trace_render.hpp> + +#include <array> +#include <cstddef> +#include <print> + +namespace +{ +using formula::var; +namespace unit = formula::unit; + +// --8<-- [start:quantities] +using Strength = formula::Quantity<struct StrengthTag, "f_c", "compressive strength at an age", unit::Megapascal>; +using FinalStrength = + formula::Quantity<struct FinalStrengthTag, "f_c28", "compressive strength at 28 days", unit::Megapascal>; +using Share = formula::Quantity<struct ShareTag, "s", "share of the 28-day strength", unit::One>; +// --8<-- [end:quantities] + +// --8<-- [start:share] +constexpr auto shareOfFinal = formula::yields<Share>(formula::series<Strength, 3> / var<FinalStrength>); +// --8<-- [end:share] + +// --8<-- [start:report] +/// The ages the strengths are taken at, in days, one per element. +constexpr std::array<int, 3> ages { 7, 14, 28 }; + +/// Prints how each share of @p specimens was calculated, then each share +/// rounded for reading. False when the series failed. +template <typename Env> +bool report(Env const& specimens) +{ + auto const shares = formula::explain_series(shareOfFinal, specimens); + std::print("{}", formula::render_trace(shares.trace, { .maxSteps = 10 })); + if (!shares.outcome) + { + std::println("no shares: {}", shares.outcome.error().error); + return false; + } + for (std::size_t at = 0; at < ages.size(); ++at) + std::println("{} days: {} = {:~.3HalfEven}", ages[at], formula::symbol_of<Share>(), shares.outcome->element(at)); + return true; +} +// --8<-- [end:report] +} // namespace + +int main() +{ + // --8<-- [start:render] + std::println("{} = {}", formula::symbol_of<Share>(), formula::render(shareOfFinal)); + // --8<-- [end:render] + + // --8<-- [start:evaluate] + auto const recorded = + formula::environment(formula::measured_series<Strength>(21, 26, 30), formula::Measured<FinalStrength> { 30 }); + std::println("all three recorded:"); + if (!report(recorded)) + return 1; + // --8<-- [end:evaluate] + + // --8<-- [start:absent] + auto const oneUnrecorded = formula::environment(formula::measured_series<Strength>(21, formula::not_measured, 30), + formula::Measured<FinalStrength> { 30 }); + std::println("the 14-day strength not recorded:"); + if (!report(oneUnrecorded)) + return 1; + // --8<-- [end:absent] + return 0; +} diff --git a/examples/tutorial/12_series.expected.txt b/examples/tutorial/12_series.expected.txt new file mode 100644 index 00000000..b2e08689 --- /dev/null +++ b/examples/tutorial/12_series.expected.txt @@ -0,0 +1,15 @@ +s = f_c(i) / f_c28 +all three recorded: +1. f_c = 21 MPa; 26 MPa; 30 MPa +2. f_c28 = 30 MPa +3. #1 / #2 = 7/10; 13/15; 1 +7 days: s = 0.7 +14 days: s = ≈0.867 +28 days: s = 1 +the 14-day strength not recorded: +1. f_c = 21 MPa; (not measured); 30 MPa +2. f_c28 = 30 MPa +3. #1 / #2 = 7/10; (not measured); 1 +7 days: s = 0.7 +14 days: s = (not measured) +28 days: s = 1 diff --git a/examples/tutorial/13_statistics.cpp b/examples/tutorial/13_statistics.cpp new file mode 100644 index 00000000..ff92285b --- /dev/null +++ b/examples/tutorial/13_statistics.cpp @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 +// Tutorial, chapter 13: statistics. The strengths of several specimens are +// reduced to a mean and a range, and an outlier is rejected before the mean. + +#include <formula-cpp/format.hpp> +#include <formula-cpp/formula.hpp> +#include <formula-cpp/render.hpp> +#include <formula-cpp/trace.hpp> +#include <formula-cpp/trace_render.hpp> + +#include <print> + +namespace +{ +namespace unit = formula::unit; +using namespace formula::literals; + +// --8<-- [start:quantities] +using Strength = formula::Quantity<struct StrengthTag, "f_c", "compressive strength", unit::Megapascal>; +using StrengthRange = formula::Quantity<struct StrengthRangeTag, "R", "range of the strengths", unit::Megapascal>; +// --8<-- [end:quantities] + +// --8<-- [start:statistics] +constexpr auto threeSpecimens = formula::series<Strength, 3>; +constexpr auto mean = formula::yields<Strength>(formula::sample_mean(threeSpecimens)); +constexpr auto range = formula::yields<StrengthRange>(formula::sample_range(threeSpecimens)); +// --8<-- [end:statistics] + +// Kept out of clang-format's hands, which would split `formula::` from +// `without_outliers`. +// clang-format off +// --8<-- [start:rejection] +/// More than 3 MPa from the mean of the pass, the value itself included. +constexpr auto threeMegapascals = formula::deviation_from_mean(formula::constant<unit::Megapascal>(3)); + +constexpr auto withoutOutliers = formula::without_outliers<formula::PerPass::MostExtreme, + formula::OnLimit::Keep, + formula::AtMost<2>, + formula::KeepAtLeast<3>>( + formula::series<Strength, 5>, threeMegapascals, formula::Verdict { "test further specimens" }); + +constexpr auto meanKept = formula::yields<Strength>(formula::sample_mean(withoutOutliers)); +// --8<-- [end:rejection] +// clang-format on +} // namespace + +int main() +{ + // --8<-- [start:evaluate] + auto const three = formula::environment(formula::measured_series<Strength>(30.2_r, 29.8_r, 31)); + auto const meanValue = formula::checked_evaluate(mean, three); + if (!meanValue) + { + std::println("no mean: {}", meanValue.error()); + return 1; + } + auto const rangeValue = formula::checked_evaluate(range, three); + if (!rangeValue) + { + std::println("no range: {}", rangeValue.error()); + return 1; + } + std::println("three specimens:"); + std::println("{} = {}, for reading {:~.2HalfEven}", formula::render(mean), *meanValue, *meanValue); + std::println("{} = {}", formula::render(range), *rangeValue); + // --8<-- [end:evaluate] + + // --8<-- [start:reject] + auto const five = formula::environment(formula::measured_series<Strength>(30.2_r, 29.8_r, 31, 30.4_r, 36)); + std::println("five specimens:"); + std::println("{}", formula::render(withoutOutliers)); + auto const kept = formula::checked_explain(meanKept, five); + if (!kept) + { + std::println("no mean of the specimens kept: {}", kept.error().error); + return 1; + } + std::print("{}", formula::render_trace(kept->trace, { .maxSteps = 20 })); + std::println("mean of the specimens kept = {}", kept->outcome); + // --8<-- [end:reject] + return 0; +} diff --git a/examples/tutorial/13_statistics.expected.txt b/examples/tutorial/13_statistics.expected.txt new file mode 100644 index 00000000..616cea81 --- /dev/null +++ b/examples/tutorial/13_statistics.expected.txt @@ -0,0 +1,14 @@ +three specimens: +sample_mean(f_c(i)) = 91/3 MPa, for reading ≈30.33 MPa +sample_range(f_c(i)) = 1.2 MPa +five specimens: +without outliers(f_c(i); abs(x - pass mean) > 3 MPa; most extreme per pass; keep on limit; at most 2; keep at least 3) +1. f_c = 151/5 MPa; 149/5 MPa; 31 MPa; 152/5 MPa; 36 MPa +2. 3 MPa +3. pass 1: 5 values, mean 787/25 MPa +4. rejected element 5 of 5 (36 MPa) in pass 1: abs(x - mean) = 113/25 MPa > 3 MPa (deviation from mean) +5. 3 MPa +6. pass 2: 4 values, mean 607/20 MPa +7. settled: 1 rejected, 4 remain +8. sample_mean(#7) = 607/20 MPa +mean of the specimens kept = 30.35 MPa diff --git a/examples/tutorial/14_records.cpp b/examples/tutorial/14_records.cpp new file mode 100644 index 00000000..f9c9c7df --- /dev/null +++ b/examples/tutorial/14_records.cpp @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 +// Tutorial, chapter 14: records. A specimen's strength is compared with a +// reference sample's, read from that sample's record by the role it plays. + +#include <formula-cpp/format.hpp> +#include <formula-cpp/formula.hpp> +#include <formula-cpp/render.hpp> +#include <formula-cpp/trace.hpp> +#include <formula-cpp/trace_render.hpp> + +#include <print> + +namespace +{ +using formula::var; +namespace unit = formula::unit; + +// --8<-- [start:quantities] +using Strength = formula::Quantity<struct StrengthTag, "f_c", "compressive strength", unit::Megapascal>; +using StrengthRatio = + formula::Quantity<struct StrengthRatioTag, "r", "strength as a share of the reference strength", unit::One>; + +/// The role the reference sample plays. +struct Reference +{ +}; +// --8<-- [end:quantities] + +// --8<-- [start:ratio] +constexpr auto ratio = formula::yields<StrengthRatio>(var<Strength> / formula::from_record<Reference>(var<Strength>)); +// --8<-- [end:ratio] + +// --8<-- [start:records] +constexpr auto specimen = formula::environment(formula::Measured<Strength> { 30 }); +constexpr auto referenceSample = formula::environment(formula::Measured<Strength> { 32 }); + +constexpr auto records = formula::record_context( + formula::record<formula::ThisRecord>(formula::record_key(formula::sample_id(17), formula::test_id(5)), specimen), + formula::record<Reference>(formula::record_key(formula::sample_id(23), formula::test_id(3)), referenceSample)); +// --8<-- [end:records] + +// --8<-- [start:report] +/// Prints how the ratio was calculated over @p context, then the ratio. +/// False when it failed. +template <typename Context> +bool report(Context const& context) +{ + auto const explained = formula::checked_explain(ratio, context); + if (!explained) + { + std::println("no ratio: {}", explained.error().error); + return false; + } + std::print("{}", formula::render_trace(explained->trace, { .maxSteps = 10 })); + std::println("{} = {}", formula::symbol_of<StrengthRatio>(), explained->outcome); + return true; +} +// --8<-- [end:report] +} // namespace + +int main() +{ + // --8<-- [start:render] + std::println("{} = {}", formula::symbol_of<StrengthRatio>(), formula::render(ratio)); + // --8<-- [end:render] + + // --8<-- [start:evaluate] + std::println("the reference sample tested:"); + if (!report(records)) + return 1; + // --8<-- [end:evaluate] + + // --8<-- [start:not-yet-made] + auto const notYetTested = formula::record_context( + formula::record<formula::ThisRecord>(formula::record_key(formula::sample_id(17), formula::test_id(5)), specimen), + formula::Record<Reference, decltype(referenceSample)>::unbound()); + std::println("the reference sample not yet tested:"); + if (!report(notYetTested)) + return 1; + // --8<-- [end:not-yet-made] + return 0; +} diff --git a/examples/tutorial/14_records.expected.txt b/examples/tutorial/14_records.expected.txt new file mode 100644 index 00000000..cbccfd41 --- /dev/null +++ b/examples/tutorial/14_records.expected.txt @@ -0,0 +1,12 @@ +r = f_c / (f_c of Reference) +the reference sample tested: +1. f_c = 30 MPa +2. f_c = 32 MPa, from record Reference (sample 23, test 3) +3. #2 from record Reference (sample 23, test 3) = 32 MPa +4. #1 / #3 = 15/16 +r = 0.9375 +the reference sample not yet tested: +1. f_c = 30 MPa +2. from record Reference (no record bound) = (not measured) +3. #1 / #2 = (not measured) +r = (not measured) diff --git a/examples/tutorial/15_opaque_and_retry.cpp b/examples/tutorial/15_opaque_and_retry.cpp new file mode 100644 index 00000000..7c1c06d3 --- /dev/null +++ b/examples/tutorial/15_opaque_and_retry.cpp @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: Apache-2.0 +// Tutorial, chapter 15: opaque operations and bounded retry. A least-squares line +// through load and displacement readings, and a retest repeated at most three times. + +#include <formula-cpp/format.hpp> +#include <formula-cpp/formula.hpp> +#include <formula-cpp/render.hpp> +#include <formula-cpp/trace.hpp> +#include <formula-cpp/trace_render.hpp> + +#include <print> + +namespace +{ +namespace unit = formula::unit; +using namespace formula::literals; + +// --8<-- [start:quantities] +/// Kilonewtons per millimetre: one is 1000000 N/m. +constexpr formula::Unit kilonewtonPerMillimetre { .dimension = formula::dim::ForcePerLength, + .magnitudeNumerator = 1'000'000, + .symbolText = formula::symbol("kN/mm"), + .decimals = 1 }; + +using Displacement = formula::Quantity<struct DisplacementTag, "s", "displacement", unit::Millimetre>; +using Load = formula::Quantity<struct LoadTag, "F", "load", unit::Kilonewton>; +using Stiffness = formula::Quantity<struct StiffnessTag, "k", "stiffness", kilonewtonPerMillimetre>; +using LoadAtZero = formula::Quantity<struct LoadAtZeroTag, "F_0", "load at zero displacement", unit::Kilonewton>; +using FitQuality = formula::Quantity<struct FitQualityTag, "R2", "coefficient of determination", unit::One>; +// --8<-- [end:quantities] + +// --8<-- [start:fit] +constexpr auto fit = + formula::linear_least_squares(formula::observations<Displacement, 8>, + formula::observations<Load, 8>, + { .title = "Stiffness", .reference = "Example Standard 12:2020", .section = "5.2" }); + +constexpr auto stiffness = formula::yields<Stiffness>(formula::opaque_output<"slope">(fit)); +constexpr auto loadAtZero = formula::yields<LoadAtZero>(formula::opaque_output<"intercept">(fit)); +constexpr auto fitQuality = formula::yields<FitQuality>(formula::opaque_output<"r squared">(fit)); +// --8<-- [end:fit] + +// --8<-- [start:retry] +using Retest = formula::Quantity<struct RetestTag, "f_t", "strength of one retest", unit::Megapascal>; +using AgreedStrength = formula::Quantity<struct AgreedStrengthTag, "f_a", "agreed strength", unit::Megapascal>; + +constexpr auto agree = formula::abs(formula::this_attempt<AgreedStrength> - formula::previous_attempt<AgreedStrength>) + <= formula::constant<unit::Megapascal>(0.5_r); + +constexpr auto retest = formula::retry<AgreedStrength, 3, formula::FirstJudged::AtSecondAttempt>( + formula::attempt_input<Retest>, + agree, + formula::Verdict { "test further specimens" }, + { .title = "Agreed strength", .reference = "Example Standard 12:2020", .section = "6" }); +// --8<-- [end:retry] +} // namespace + +int main() +{ + // --8<-- [start:evaluate-fit] + auto const readings = formula::environment(formula::MeasuredObservations<Displacement, 8>(0.1_r, 0.2_r, 0.3_r, 0.4_r), + formula::MeasuredObservations<Load, 8>(15_r, 25_r, 35_r, 45_r)); + + std::println("{} = {}", formula::symbol_of<Stiffness>(), formula::render(stiffness)); + auto const explained = formula::checked_explain(stiffness, readings); + if (!explained) + { + std::println("no stiffness: {}", explained.error().error); + return 1; + } + std::print("{}", formula::render_trace(explained->trace, { .maxSteps = 20 })); + std::println("{} = {}", formula::symbol_of<Stiffness>(), explained->outcome); + // --8<-- [end:evaluate-fit] + + // --8<-- [start:other-outputs] + auto const atZero = formula::checked_evaluate(loadAtZero, readings); + if (!atZero) + { + std::println("no load at zero displacement: {}", atZero.error()); + return 1; + } + auto const quality = formula::checked_evaluate(fitQuality, readings); + if (!quality) + { + std::println("no coefficient of determination: {}", quality.error()); + return 1; + } + std::println("{} = {}", formula::symbol_of<LoadAtZero>(), *atZero); + std::println("{} = {}", formula::symbol_of<FitQuality>(), *quality); + // --8<-- [end:other-outputs] + + // --8<-- [start:evaluate-retry] + std::println("{}", formula::render(retest)); + auto const results = formula::environment(formula::measured_series<Retest>(30_r, 31.2_r, 31_r)); + auto const retested = formula::explain_retry(retest, results); + std::print("{}", formula::render_trace(retested.trace, { .maxSteps = 40 })); + if (!retested.outcome) + { + std::println("the retest failed: {}", retested.outcome.error().error); + return 1; + } + std::println("the retest: {} after {} attempt(s), {} = {}", + retested.outcome->end(), + retested.outcome->attempts_made(), + formula::symbol_of<AgreedStrength>(), + retested.outcome->outcome()); + // --8<-- [end:evaluate-retry] + return 0; +} diff --git a/examples/tutorial/15_opaque_and_retry.expected.txt b/examples/tutorial/15_opaque_and_retry.expected.txt new file mode 100644 index 00000000..d9f16106 --- /dev/null +++ b/examples/tutorial/15_opaque_and_retry.expected.txt @@ -0,0 +1,27 @@ +k = linear least squares(s(i), F(i)).slope +1. s = 1/10 mm; 1/5 mm; 3/10 mm; 2/5 mm +2. F = 15 kN; 25 kN; 35 kN; 45 kN +3. linear least squares(#1, #2) = intercept = 5 kN; slope = 100 kN/mm; r squared = 1; points = 4 [inside not shown] [Stiffness, Example Standard 12:2020, 5.2] +4. slope of #3 = 100 kN/mm +k = 100 kN/mm +F_0 = 5 kN +R2 = 1 +up to 3 attempts: f_a(k) = f_t(k); accept from attempt 2 when abs(f_a(k) - f_a(k-1)) <= 1/2 MPa; otherwise: test further specimens +1. f_t(k) = 30 MPa +2. attempt 1: f_a(k) = #1 = 30 MPa; not judged +3. f_t(k) = 156/5 MPa +4. f_a(k) = 156/5 MPa +5. f_a(k-1) = 30 MPa +6. #4 - #5 = 6/5 MPa +7. abs(#6) = 6/5 MPa +8. 1/2 MPa +9. attempt 2: f_a(k) = #3 = 156/5 MPa; judged #7 <= #8: rejected +10. f_t(k) = 31 MPa +11. f_a(k) = 31 MPa +12. f_a(k-1) = 156/5 MPa +13. #11 - #12 = -1/5 MPa +14. abs(#13) = 1/5 MPa +15. 1/2 MPa +16. attempt 3: f_a(k) = #10 = 31 MPa; judged #14 <= #15: accepted +17. f_a = retry: accepted at attempt 3 of 3 = 31 MPa [Agreed strength, Example Standard 12:2020, 6] +the retest: accepted after 3 attempt(s), f_a = 31 MPa diff --git a/include/formula-cpp/band.hpp b/include/formula-cpp/band.hpp index 60e51fc8..38da9524 100644 --- a/include/formula-cpp/band.hpp +++ b/include/formula-cpp/band.hpp @@ -54,13 +54,13 @@ /// the interval *means*, to a reader of the API reference, where Doxygen sets /// these comments and the brackets are inert; the prose spelling is what the /// library *emits*, and it carries no punctuation at all because `[103, 197)` -/// opens CommonMark link syntax, which once silently dropped an operand from a -/// published page of this project's own documentation (`render.hpp`'s ruling, -/// and the guard test forbidding `](` and a bare `[` in any Markdown -/// rendering). **So anything a guide quotes must use the emitted spelling** -- -/// `examples/lookup_tables.cpp`'s band table is quoted into the guide -/// verbatim, its comments included, which makes those comments published text -/// and is why they say `to under` where the comments here say `[low, high)`. +/// opens CommonMark link syntax, which would silently drop an operand from a +/// published page (`render.hpp`'s ruling, and the guard test forbidding `](` +/// and a bare `[` in any Markdown rendering). **So anything a guide quotes +/// must use the emitted spelling** -- `examples/lookup_tables.cpp`'s band +/// table is quoted into the guide verbatim, its comments included, which +/// makes those comments published text and is why they say `to under` where +/// the comments here say `[low, high)`. /// /// An empty table (`BandTable<0>`) validates: there is no adjacent pair to /// check, so it is vacuously free of gaps and overlaps, and a lookup against diff --git a/include/formula-cpp/binning.hpp b/include/formula-cpp/binning.hpp index 988da493..bf542261 100644 --- a/include/formula-cpp/binning.hpp +++ b/include/formula-cpp/binning.hpp @@ -51,6 +51,7 @@ #include <formula-cpp/series.hpp> #include <formula-cpp/sink.hpp> #include <formula-cpp/unit.hpp> +#include <formula-cpp/yields.hpp> #include <array> #include <concepts> @@ -178,6 +179,16 @@ template <Unit KeyUnit, BandTable Classes, typename Obs> return BinnedNode<KeyUnit, Classes, detail::RefusedObservations> { {}, detail::RefusedObservations {} }; } +/// A bound formula as the observations `binned` counts: the formula it holds, +/// in its place (`yields.hpp`). Declared as the overload above is, and +/// constrained further, so that it is the one chosen for a bound formula. +template <Unit KeyUnit, BandTable Classes, typename Obs> + requires detail::AnyBound<Obs> +[[nodiscard]] constexpr auto binned(Obs source) noexcept +{ + return binned<KeyUnit, Classes>(source.expression); +} + /// Counts the observations into the classes: the observations first, once; /// a failure reading them is relayed at its observation. Then each /// observation, in the order made, is converted into `KeyUnit` and counted in diff --git a/include/formula-cpp/calculation.hpp b/include/formula-cpp/calculation.hpp index 83937150..ae7d9840 100644 --- a/include/formula-cpp/calculation.hpp +++ b/include/formula-cpp/calculation.hpp @@ -91,7 +91,7 @@ /// So a quantity defined twice, one definition reading itself, is refused /// only as defined twice; and a definition that reads itself and also sits /// on a longer cycle is refused as reading itself, the cycle judged once it -/// no longer does. Two definitions that each read themselves are two +/// stops reading itself. Two definitions that each read themselves are two /// mistakes, and draw a message each. /// /// A calculation holding a definition refused where it was written is asked diff --git a/include/formula-cpp/citation.hpp b/include/formula-cpp/citation.hpp index e0284e17..326434dc 100644 --- a/include/formula-cpp/citation.hpp +++ b/include/formula-cpp/citation.hpp @@ -17,6 +17,7 @@ #include <formula-cpp/evaluate.hpp> #include <formula-cpp/expression.hpp> #include <formula-cpp/sink.hpp> +#include <formula-cpp/yields.hpp> #include <string_view> @@ -79,13 +80,22 @@ struct DocumentedNode: NodeBase /// /// The `Citation` parameter is deliberately **not** deduced. That is what lets /// the call site write a braced designated initialiser, which was verified on -/// cl 19.51, clang-cl 22 and g++ 13.3 before this was written. +/// cl 19.51, clang-cl 22 and g++ 13.3. template <Node Inner> [[nodiscard]] constexpr DocumentedNode<Inner> documented(Inner inner, Citation citation) noexcept { return DocumentedNode<Inner> { {}, inner, citation }; } +/// A bound formula as `documented`'s formula: the formula it holds, in its +/// place (`yields.hpp`). +template <typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto documented(Bound boundFormula, Citation citation) noexcept +{ + return documented(boundFormula.expression, citation); +} + /// Evaluating a documented expression evaluates what it documents. The wrapper /// is invisible to arithmetic; only the documentation walk and the trace sink /// notice it. diff --git a/include/formula-cpp/conditional.hpp b/include/formula-cpp/conditional.hpp index 99f2d601..776813b8 100644 --- a/include/formula-cpp/conditional.hpp +++ b/include/formula-cpp/conditional.hpp @@ -44,6 +44,7 @@ #include <formula-cpp/expression.hpp> #include <formula-cpp/predicate.hpp> #include <formula-cpp/sink.hpp> +#include <formula-cpp/yields.hpp> namespace formula { @@ -104,6 +105,15 @@ template <Predicate P, Node Then, Node Else> return WhenNode<P, Then, Else> { {}, predicate, thenBranch, elseBranch }; } +/// A bound formula as either branch of `when`: the formula it holds, in its +/// place (`yields.hpp`). +template <Predicate P, typename Then, typename Else> + requires detail::AnyBound<Then, Else> +[[nodiscard]] constexpr auto when(P predicate, Then thenBranch, Else elseBranch) noexcept +{ + return when(predicate, detail::as_operand(thenBranch), detail::as_operand(elseBranch)); +} + /// Evaluates `predicate`; if it holds, evaluates and returns `thenBranch`, if /// it does not, evaluates and returns `elseBranch`, and if it is absent, /// returns absent without evaluating either. Exactly one branch is ever diff --git a/include/formula-cpp/conformity.hpp b/include/formula-cpp/conformity.hpp index 78cf260d..ce28cc29 100644 --- a/include/formula-cpp/conformity.hpp +++ b/include/formula-cpp/conformity.hpp @@ -37,6 +37,7 @@ #include <formula-cpp/series.hpp> #include <formula-cpp/sink.hpp> #include <formula-cpp/unit.hpp> +#include <formula-cpp/yields.hpp> #include <array> #include <concepts> @@ -390,6 +391,36 @@ template <Unit U, Node N> }; } +namespace detail +{ + /// The envelope a conformity check of @p Subject takes: one row per + /// element of a series, and whatever was written for a single value, + /// which is refused (`AnyEnvelope`). + template <typename Subject> + struct EnvelopeFor + { + using type = AnyEnvelope; + }; + + template <SeriesNode S> + struct EnvelopeFor<S> + { + using type = Envelope<S::length>; + }; +} // namespace detail + +/// A bound formula as `conformity`'s subject: the formula it holds, in its +/// place (`yields.hpp`). +template <Unit U, typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto conformity(Bound boundSubject, + typename detail::EnvelopeFor<decltype(Bound::expression)>::type envelope, + Verdict verdict, + Citation citation = {}) noexcept +{ + return conformity<U>(boundSubject.expression, envelope, verdict, citation); +} + namespace detail { /// Whether @p Sink wants to hear about the conformity check @p C: true diff --git a/include/formula-cpp/constraint.hpp b/include/formula-cpp/constraint.hpp index e21afb63..2dc9dd30 100644 --- a/include/formula-cpp/constraint.hpp +++ b/include/formula-cpp/constraint.hpp @@ -243,8 +243,9 @@ template <Predicate P> /// because `formula::constraint(...)` is a free function at namespace scope /// and a parameter of the same name would shadow it. `-Wshadow` does not /// catch a parameter shadowing a function, so nothing would fail to build, -/// but it is the same kind of name collision that once shipped a -/// `StepKind::Pi` enumerator shadowing `formula::Pi` and broke GCC alone. +/// but it is the same kind of name collision that makes the trace spell its +/// enumerator `StepKind::PiConstant`: an enumerator named `Pi` would shadow +/// `formula::Pi`, which GCC's `-Wshadow` reports and the build refuses. /// /// **Recorded in the trace as its own step**, the way spec sections 9 and /// 9.1 require. A constraint is not a `Node`, so it cannot go through diff --git a/include/formula-cpp/critical_value.hpp b/include/formula-cpp/critical_value.hpp index ee9bbd51..e7056de5 100644 --- a/include/formula-cpp/critical_value.hpp +++ b/include/formula-cpp/critical_value.hpp @@ -39,6 +39,7 @@ #include <formula-cpp/rational.hpp> #include <formula-cpp/sink.hpp> #include <formula-cpp/unit.hpp> +#include <formula-cpp/yields.hpp> #include <array> #include <cstddef> @@ -417,6 +418,15 @@ template <SampleSizeTable Sizes, Unit ResultUnit, Node Count> return SampleSizeLookupNode<Sizes, ResultUnit, Count> { {}, corrections, sampleCount }; } +/// A bound formula as `critical_value`'s count: the formula it holds, in its +/// place (`yields.hpp`). +template <SampleSizeTable Sizes, Unit ResultUnit, typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto critical_value(Bound boundCount, detail::SampleSizeCorrections<Sizes> corrections) noexcept +{ + return critical_value<Sizes, ResultUnit>(boundCount.expression, corrections); +} + /// Evaluates the count and reads the row whose size it is. Absence /// propagates; a count that is no declared size, or no whole non-negative /// number, is `ArithmeticError::DomainError`. diff --git a/include/formula-cpp/curve.hpp b/include/formula-cpp/curve.hpp index b3cfc94c..141d468f 100644 --- a/include/formula-cpp/curve.hpp +++ b/include/formula-cpp/curve.hpp @@ -66,6 +66,7 @@ #include <formula-cpp/series.hpp> #include <formula-cpp/sink.hpp> #include <formula-cpp/unit.hpp> +#include <formula-cpp/yields.hpp> #include <array> #include <concepts> @@ -196,6 +197,15 @@ template <SeriesNode D, SeriesNode V> return CurveNode<D, V> { {}, domainSeries, valueSeries }; } +/// A bound formula as either half of `curve`: the formula it holds, in its +/// place (`yields.hpp`). +template <typename D, typename V> + requires detail::AnyBound<D, V> +[[nodiscard]] constexpr auto curve(D domainHalf, V valueHalf) noexcept +{ + return curve(detail::as_operand(domainHalf), detail::as_operand(valueHalf)); +} + namespace detail { /// Fails to compile when `curve` is given a single value where a series @@ -369,6 +379,15 @@ template <Monotone M, CurveExpression A, CurveExpression B> return SpliceNode<M, A, B> { {}, firstCurve, secondCurve }; } +/// A bound formula as either curve `splice` joins: the formula it holds, in +/// its place (`yields.hpp`). +template <Monotone M, typename A, typename B> + requires detail::AnyBound<A, B> +[[nodiscard]] constexpr auto splice(A firstCurve, B secondCurve) noexcept +{ + return splice<M>(detail::as_operand(firstCurve), detail::as_operand(secondCurve)); +} + namespace detail { /// Fails to compile when `splice` is given a single value where a curve @@ -470,6 +489,15 @@ template <CurveExpression C, Node At> return InterpolateAlongNode<C, At> { {}, curveExpression, at }; } +/// A bound formula as the curve `interpolate_at` reads, or where it reads it: +/// the formula it holds, in its place (`yields.hpp`). +template <typename C, typename At> + requires detail::AnyBound<C, At> +[[nodiscard]] constexpr auto interpolate_at(C curveExpression, At at) noexcept +{ + return interpolate_at(detail::as_operand(curveExpression), detail::as_operand(at)); +} + /// An evaluated curve in the coherent units of its dimensions: each point /// and each value, either absent when it was never measured. template <typename Rep, std::size_t N> diff --git a/include/formula-cpp/detail/fixed_string.hpp b/include/formula-cpp/detail/fixed_string.hpp index 115f3921..0be128e4 100644 --- a/include/formula-cpp/detail/fixed_string.hpp +++ b/include/formula-cpp/detail/fixed_string.hpp @@ -47,8 +47,8 @@ struct FixedString // The parameter is any char array, not only a string literal, and // `view()` below drops the last byte on the assumption that it is a // terminator. Given `char const raw[] = {'a','b','c'}` that assumption - // is false and the 'c' disappears in silence -- measured on all three - // compilers before this check existed. Refuse instead. + // is false and, without this check, the 'c' disappears in silence -- + // measured on all three compilers. Refuse instead. if (literal[N - 1] != '\0') formula_fixed_string_must_be_null_terminated(); diff --git a/include/formula-cpp/document.hpp b/include/formula-cpp/document.hpp index 69c5ced9..15f45069 100644 --- a/include/formula-cpp/document.hpp +++ b/include/formula-cpp/document.hpp @@ -242,7 +242,7 @@ struct Documentation namespace detail { - /// A distinct address per @tparam Q, used to deduplicate the symbol table + /// A distinct address per @tparam Q, which deduplicates the symbol table /// by quantity *type* without reaching for RTTI (`typeid`, `<typeindex>`). /// /// **Writable, and deliberately not `constexpr` or `const`.** Identical @@ -269,8 +269,7 @@ namespace detail /// that would be one function with two behaviours. `inline` makes the /// object one per program and the question moot -- measured on cl 19.51, /// clang-cl 22 and g++ 13.3, which agree. Without it the object would be - /// one per translation unit on g++ and one per program on the other two, - /// a difference this library has already been bitten by once elsewhere. + /// one per translation unit on g++ and one per program on the other two. template <typename Q> inline bool quantityIdentity = false; @@ -366,17 +365,15 @@ namespace detail // Not load-bearing, just this file's convention: every collect() call's // first argument is a `Walk<V>&`, so `formula::detail` -- Walk's namespace -- // is always in ADL's search set, which is why every overload below is - // found regardless of declaration order. Re-measured when the three lookup - // overloads below were added: deleting all 17 declarations in this block - // and rebuilding the whole test suite succeeds on cl 19.51, clang-cl 22, - // clang 20.1.8 and g++ 14.2. Every one of those is a conformant two-phase - // lookup -- `CMakeLists.txt` puts `/permissive-` on every cl compile line - // as an INTERFACE requirement of the library, read off this file's own - // entry in `compile_commands.json` rather than assumed -- so no leg of - // that measurement rested on MSVC's permissive mode. (The earlier wording - // said "all five", which was the count when this was first measured.) - // That is an implementation detail, not a guarantee, so each overload - // stays declared here rather than relying on it. + // found regardless of declaration order. Deleting all 17 declarations in + // this block and rebuilding the whole test suite succeeds on cl 19.51, + // clang-cl 22, clang 20.1.8 and g++ 14.2. Every one of those is a + // conformant two-phase lookup -- `CMakeLists.txt` puts `/permissive-` on + // every cl compile line as an INTERFACE requirement of the library, read + // off this file's own entry in `compile_commands.json` rather than + // assumed -- so no leg of that measurement rested on MSVC's permissive + // mode. That is an implementation detail, not a guarantee, so each + // overload stays declared here rather than relying on it. template <Vocabulary V, Described Q> void collect(Walk<V>& walk, VarNode<Q> const& node); @@ -549,9 +546,6 @@ namespace detail template <Vocabulary V, Dimension Dim> void collect(Walk<V>& walk, RefusedRetryValue<Dim> const& node); - template <Vocabulary V, Dimension Dim> - void collect(Walk<V>& walk, RefusedBoundValue<Dim> const& node); - template <Vocabulary V, std::size_t I, typename Op, typename... Inputs, typename Origin> void collect(Walk<V>& walk, OpaqueOutputNode<I, OpaqueCall<Op, Inputs...>, Origin> const& node); @@ -886,7 +880,7 @@ namespace detail } /// The escape hatch still reads a variable, even though what it produces - /// no longer carries a dimension. + /// carries no dimension. template <Vocabulary V, Unit U, FixedString Justification, Node Operand> void collect(Walk<V>& walk, NumericValueNode<U, Justification, Operand> const& node) { @@ -1231,13 +1225,6 @@ namespace detail { } - /// A refused bound formula used as an operand names nothing, as a refused - /// retry names nothing. - template <Vocabulary V, Dimension Dim> - void collect(Walk<V>&, RefusedBoundValue<Dim> 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/enumerator.hpp b/include/formula-cpp/enumerator.hpp index 455a92c4..067044c4 100644 --- a/include/formula-cpp/enumerator.hpp +++ b/include/formula-cpp/enumerator.hpp @@ -268,12 +268,12 @@ struct RequireEnumeratorNameSpelling /// **`volatile` is deliberately not checked.** Asking whether /// `EnumeratorName<Shape volatile>` is specialized instantiates it, and for /// exactly that generic bridge that declares `of(Shape volatile)`: a -/// volatile-qualified parameter, which clang and clang-cl report as -/// deprecated (`-Wdeprecated-volatile`, on by default); g++ 13 and cl stay -/// quiet. The check would turn an ordinary bridge into a build failure under -/// `-Werror` on clang, to catch a specialization nobody writes. Measured on -/// all four; with this library's own warning flags it failed to compile the -/// bridge test in `enumerator_tests.cpp` on clang 20. +/// volatile-qualified parameter, which C++20 deprecates and clang and +/// clang-cl warn about (`-Wdeprecated-volatile`, on by default); g++ 13 and +/// cl stay quiet. The check would turn an ordinary bridge into a build +/// failure under `-Werror` on clang, to catch a specialization nobody writes. +/// Measured on all four; with this library's own warning flags it failed to +/// compile the bridge test in `enumerator_tests.cpp` on clang 20. /// /// Instantiated by `enumerator_name` for every enumerator it names, /// customized or not, since the point is to catch a specialization that diff --git a/include/formula-cpp/environment.hpp b/include/formula-cpp/environment.hpp index 70739d41..a2992306 100644 --- a/include/formula-cpp/environment.hpp +++ b/include/formula-cpp/environment.hpp @@ -104,7 +104,7 @@ class MeasuredSeries /// element nobody typed absent, silently, for `({ { a, b, c } })`, /// `{ { { a, b, c } } }` and `({})` alike, and through `entered(...)` and /// `EnteredSeries` too. A template parameter cannot be deduced from a - /// braced list, so every such spelling now fails to compile, in the + /// braced list, so every such spelling fails to compile, in the /// compiler's words, since no correct reading of them exists to point at. /// (For `({ { a, b, c } })` g++ 13.3 and 14.2 also reach the constructor /// below through the copy constructor, and add its count message, which diff --git a/include/formula-cpp/escape.hpp b/include/formula-cpp/escape.hpp index 2b5b04ec..b9312414 100644 --- a/include/formula-cpp/escape.hpp +++ b/include/formula-cpp/escape.hpp @@ -31,6 +31,7 @@ #include <formula-cpp/expression.hpp> #include <formula-cpp/sink.hpp> #include <formula-cpp/unit.hpp> +#include <formula-cpp/yields.hpp> namespace formula { @@ -108,7 +109,7 @@ struct NumericValueNode: NodeBase static constexpr std::string_view justification = Justification.view(); /// Dimensionless, by construction. That is the whole point: what comes out - /// is a bare number, and the type system now says so honestly rather than + /// is a bare number, and the type system says so honestly rather than /// carrying a dimension that the rule downstream will contradict. static constexpr Dimension dimension = dim::Scalar; /// Whether its operand was refused -- see `detail::refused_already`. @@ -125,6 +126,15 @@ template <Unit U, detail::FixedString Justification, Node Operand> return NumericValueNode<U, Justification, Operand> { {}, operand }; } +/// A bound formula as `numeric_value_of`'s operand: the formula it holds, in +/// its place (`yields.hpp`). +template <Unit U, detail::FixedString Justification, typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto numeric_value_of(Bound boundFormula) noexcept +{ + return numeric_value_of<U, Justification>(boundFormula.expression); +} + /// Evaluates the operand and converts its value from the coherent unit into /// `U`. The converted number is returned as-is, in `Rep`, and never converted /// back -- unlike every other node in this library, the whole point here is diff --git a/include/formula-cpp/function.hpp b/include/formula-cpp/function.hpp index bbb185ea..837f2024 100644 --- a/include/formula-cpp/function.hpp +++ b/include/formula-cpp/function.hpp @@ -19,6 +19,7 @@ #include <formula-cpp/expression.hpp> #include <formula-cpp/rational.hpp> #include <formula-cpp/sink.hpp> +#include <formula-cpp/yields.hpp> #include <cmath> #include <cstdint> @@ -194,6 +195,15 @@ template <int Exponent, Node Operand> return PowerNode<Exponent, Operand> { {}, operand }; } +/// A bound formula as `pow`'s operand: the formula it holds, in its place +/// (`yields.hpp`). +template <int Exponent, typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto pow(Bound boundFormula) noexcept +{ + return pow<Exponent>(boundFormula.expression); +} + /// The square root of `operand`. template <Node Operand> [[nodiscard]] constexpr auto sqrt(Operand operand) noexcept @@ -201,6 +211,15 @@ template <Node Operand> return RootNode<2, Operand> { {}, operand }; } +/// A bound formula as `sqrt`'s operand: the formula it holds, in its place +/// (`yields.hpp`). +template <typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto sqrt(Bound boundFormula) noexcept +{ + return sqrt(boundFormula.expression); +} + /// The cube root of `operand`. template <Node Operand> [[nodiscard]] constexpr auto cbrt(Operand operand) noexcept @@ -208,6 +227,15 @@ template <Node Operand> return RootNode<3, Operand> { {}, operand }; } +/// A bound formula as `cbrt`'s operand: the formula it holds, in its place +/// (`yields.hpp`). +template <typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto cbrt(Bound boundFormula) noexcept +{ + return cbrt(boundFormula.expression); +} + /// The `Degree`-th root of `operand`: `root<5>(var<Volume>)`. template <int Degree, Node Operand> [[nodiscard]] constexpr auto root(Operand operand) noexcept @@ -215,6 +243,15 @@ template <int Degree, Node Operand> return RootNode<Degree, Operand> { {}, operand }; } +/// A bound formula as `root`'s operand: the formula it holds, in its place +/// (`yields.hpp`). +template <int Degree, typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto root(Bound boundFormula) noexcept +{ + return root<Degree>(boundFormula.expression); +} + /// The spelling of pi in a formula. inline constexpr PiNode pi {}; @@ -226,6 +263,15 @@ template <Node Operand> return TranscendentalNode<Transcendental::NaturalLogarithm, Operand> { {}, operand }; } +/// A bound formula as `ln`'s operand: the formula it holds, in its place +/// (`yields.hpp`). +template <typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto ln(Bound boundFormula) noexcept +{ + return ln(boundFormula.expression); +} + /// The decimal logarithm of `operand`, a dimensionless expression. template <Node Operand> [[nodiscard]] constexpr auto log10(Operand operand) noexcept @@ -233,6 +279,15 @@ template <Node Operand> return TranscendentalNode<Transcendental::DecimalLogarithm, Operand> { {}, operand }; } +/// A bound formula as `log10`'s operand: the formula it holds, in its place +/// (`yields.hpp`). +template <typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto log10(Bound boundFormula) noexcept +{ + return log10(boundFormula.expression); +} + /// The exponential of `operand`, a dimensionless expression: e raised to it. template <Node Operand> [[nodiscard]] constexpr auto exp(Operand operand) noexcept @@ -240,6 +295,15 @@ template <Node Operand> return TranscendentalNode<Transcendental::Exponential, Operand> { {}, operand }; } +/// A bound formula as `exp`'s operand: the formula it holds, in its place +/// (`yields.hpp`). +template <typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto exp(Bound boundFormula) noexcept +{ + return exp(boundFormula.expression); +} + // ---------------------------------------------------------------- evaluation /// Powers and roots per representation. Kept here rather than in `RepTraits` diff --git a/include/formula-cpp/least_squares.hpp b/include/formula-cpp/least_squares.hpp index d002d09d..60035f56 100644 --- a/include/formula-cpp/least_squares.hpp +++ b/include/formula-cpp/least_squares.hpp @@ -94,6 +94,7 @@ #include <formula-cpp/observations.hpp> #include <formula-cpp/opaque.hpp> #include <formula-cpp/rational.hpp> +#include <formula-cpp/yields.hpp> #include <array> #include <concepts> @@ -519,9 +520,11 @@ template <typename Fitted> /// Anything but a curve handed to `linear_least_squares`: refused in this /// library's words. The return type is deduced, so that the refusal is -/// instantiated wherever the call is (`cumulative`, `series.hpp`). +/// instantiated wherever the call is (`cumulative`, `series.hpp`). A bound +/// formula is taken by its own overload below, which hands on the formula it +/// holds. template <typename NotCurve> - requires(!CurveExpression<NotCurve>) + requires(!CurveExpression<NotCurve>) && (!detail::AnyBound<NotCurve>) [[nodiscard]] constexpr auto linear_least_squares(NotCurve, Citation citation) noexcept { static_assert(detail::RequireFitOfCurve<NotCurve>::value); @@ -533,9 +536,10 @@ template <typename NotCurve> /// Two loose series handed to `linear_least_squares`: refused in this /// library's words. A curve pairs the domain with its values, which two -/// series would have to re-derive. +/// series would have to re-derive. A bound formula on either side is taken by +/// its own overload below, which hands on the formula it holds. template <typename Domain, typename Values> - requires(!(ObservationsNode<Domain> && ObservationsNode<Values>) ) + requires(!(ObservationsNode<Domain> && ObservationsNode<Values>) ) && (!detail::AnyBound<Domain, Values>) [[nodiscard]] constexpr auto linear_least_squares(Domain, Values, Citation citation) noexcept { static_assert(detail::RequireFitOfCurve<Domain, Values>::value); @@ -562,6 +566,33 @@ template <ObservationsNode X, ObservationsNode Y> return opaque<LinearLeastSquaresOfObservations>(citation, pointObservations, valueObservations); } +/// A bound formula as the curve `linear_least_squares` fits: the formula it +/// holds, in its place (`yields.hpp`). +template <typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto linear_least_squares(Bound boundCurve, Citation citation) noexcept +{ + return linear_least_squares(boundCurve.expression, citation); +} + +/// A bound formula as either observations `linear_least_squares` pairs: the +/// formula it holds, in its place (`yields.hpp`). +template <typename X, typename Y> + requires detail::AnyBound<X, Y> +[[nodiscard]] constexpr auto linear_least_squares(X pointObservations, Y valueObservations, Citation citation) noexcept +{ + return linear_least_squares(detail::as_operand(pointObservations), detail::as_operand(valueObservations), citation); +} + +/// Bound observations without a citation: refused as the observations they +/// hold are (`yields.hpp`). +template <typename X, typename Y> + requires detail::AnyBound<X, Y> && ObservationsNode<detail::operand_t<X>> && ObservationsNode<detail::operand_t<Y>> +[[nodiscard]] constexpr auto linear_least_squares(X pointObservations, Y valueObservations) noexcept +{ + return linear_least_squares(detail::as_operand(pointObservations), detail::as_operand(valueObservations)); +} + /// Raw observations handed to `linear_least_squares` without a citation: /// refused in this library's words, as a curve without one is. template <ObservationsNode X, ObservationsNode Y> @@ -727,6 +758,16 @@ template <typename... Xs> return Regressors<Xs...> { std::tuple<Xs...> { regressorInputs... } }; } +/// A bound formula as any regressor: the formula it holds, in its place +/// (`yields.hpp`). Declared as the overload above is, and constrained further, +/// so that it is the one chosen for a bound formula. +template <typename... Xs> + requires detail::AnyBound<Xs...> +[[nodiscard]] constexpr auto regressors(Xs... regressorInputs) noexcept +{ + return regressors(detail::as_operand(regressorInputs)...); +} + namespace detail { /// Fails to compile when `multiple_least_squares` is given no citation. @@ -900,6 +941,16 @@ template <typename... Xs, typename Y> return detail::refused_regression(citation); } +/// A bound formula as the values of a multiple regression: the formula it +/// holds, in its place (`yields.hpp`). Declared as the overload above is, and +/// constrained further, so that it is the one chosen for a bound formula. +template <typename... Xs, typename Y> + requires detail::AnyBound<Y> +[[nodiscard]] constexpr auto multiple_least_squares(Regressors<Xs...> regressorSet, Y valueInput, Citation citation) noexcept +{ + return multiple_least_squares(regressorSet, valueInput.expression, citation); +} + /// Without a citation: refused, as a fit without one is. template <typename... Xs, typename Y> [[nodiscard]] constexpr auto multiple_least_squares(Regressors<Xs...>, Y) noexcept diff --git a/include/formula-cpp/lookup.hpp b/include/formula-cpp/lookup.hpp index 65107f69..b7b3b434 100644 --- a/include/formula-cpp/lookup.hpp +++ b/include/formula-cpp/lookup.hpp @@ -404,16 +404,16 @@ /// /// It also means the **interpolation** does no arithmetic on a row hit, so no /// row can be reported as an overflow *of the interpolation*. That is the whole -/// of the guarantee, and an earlier revision of this comment claimed more: that -/// a row whose value is representable can never come back as an `Overflow` at -/// all. It can. `checked_evaluate_si` still hands the answer to -/// `detail::in_si`, which converts it out of `ResultUnit` into the coherent -/// unit, and **a unit conversion is arithmetic** -- a row stating `2^126` -/// kilometres is a perfectly representable `Rational` that overflows on the way -/// to metres. That path is shared with the banded and the exact lookup, which -/// have it for exactly the same reason, and nothing about it is particular to -/// interpolation. `lookup_tests.cpp` pins both halves: a row hit that overflows -/// in the conversion, and an interpolation that overflows in the interpolation. +/// of the guarantee: it does not mean a row whose value is representable can +/// never come back as an `Overflow` at all. It can. `checked_evaluate_si` hands +/// the answer to `detail::in_si`, which converts it out of `ResultUnit` into +/// the coherent unit, and **a unit conversion is arithmetic** -- a row stating +/// `2^126` kilometres is a perfectly representable `Rational` that overflows on +/// the way to metres. That path is shared with the banded and the exact +/// lookup, which have it for exactly the same reason, and nothing about it is +/// particular to interpolation. `lookup_tests.cpp` pins both halves: a row hit +/// that overflows in the conversion, and an interpolation that overflows in +/// the interpolation. /// /// **There is no extrapolation.** A value below the first row or above the last /// one is a miss -- `ArithmeticError::DomainError` through `Evaluated<Rep>`, @@ -476,6 +476,7 @@ #include <formula-cpp/expression.hpp> #include <formula-cpp/sink.hpp> #include <formula-cpp/unit.hpp> +#include <formula-cpp/yields.hpp> #include <array> #include <concepts> @@ -602,19 +603,18 @@ namespace detail /// /// **It is every node's own member type, not only the factories' parameter /// type, and the difference was measured rather than argued.** With a raw -/// array on the node and this wrapper only on the factory, the guard covered -/// every route *except the one that needs no factory*: every lookup node is a -/// public aggregate with public members, so +/// array on the node and this wrapper only on the factory, the guard would +/// cover every route *except the one that needs no factory*: every lookup node +/// is a public aggregate with public members, so /// /// inline constexpr ExactLookupNode<ThreeKeys, unit::One> node { /// {}, { 0.781_r }, Shape::Prism }; /// -/// 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 +/// would compile, link, and evaluate the two rows nobody typed as `0` -- +/// measured on all three node kinds. The factory's parameter type cannot see /// that call, because there is no call. Making the member itself a -/// `Corrections<N>` is what closes it: the braced list now initialises this -/// type, a short one selects the arity-mismatch constructor below, and its +/// `Corrections<N>` closes it: the braced list 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 @@ -623,10 +623,9 @@ namespace detail /// Nodes therefore declare `Corrections<N> corrections;` with **no default /// member initialiser**, and that omission is load bearing: `{}` for a table /// of three rows is a count of zero, which is the very mistake being refused, -/// so a node cannot be default-constructed and must state its contents. No -/// consumer noticed the change -- `operator[]` below keeps -/// `node.corrections[index]` meaning what it always meant in the renderer, the -/// tracer and the evaluator alike. +/// so a node cannot be default-constructed and must state its contents. +/// `operator[]` below lets the renderer, the tracer and the evaluator alike +/// read `node.corrections[index]` as an array index. /// /// **Asked whether a node is default-constructible, the traits and the /// concepts answer `false`, cleanly**, and so does anything holding a lookup @@ -666,14 +665,12 @@ namespace detail /// before it, refuses first. `method_lookup_tests.cpp` has a row for each /// member, and fails on cl too for every one but `Method::variantSet`. /// -/// **`corrections` is no longer a range, and `operator[]` is const and returns -/// by value.** So `for (auto& correction: node.corrections)`, +/// **`corrections` is not a range, and `operator[]` is const and returns by +/// value.** So `for (auto& correction: node.corrections)`, /// `auto& correction = node.corrections[index]` and -/// `node.corrections[index] = ...` no longer compile, where they did while the -/// member was a `std::array`. `values` stays public and is the route for all -/// three -- this is a transparent aggregate of a table's contents, not an -/// encapsulation. Written down because **no in-tree consumer needed changing**, -/// which is exactly why nothing in this repository will remind anybody. +/// `node.corrections[index] = ...` do not compile. `values` is public and is +/// the route for all three -- this is a transparent aggregate of a table's +/// contents, not an encapsulation. /// /// A named type with two arity-disjoint constructor templates rather than /// one constrained by `requires` alone: the *matching*-arity constructor @@ -738,13 +735,10 @@ struct Corrections std::array<Rational, N> values {}; /// The correction at @p rowIndex, so that every consumer reads a table's - /// contents the way it read them when this was a bare `std::array`. - /// - /// Present so that becoming a node's member type costs the rest of the - /// library nothing: `node.corrections[index]` means what it always meant, - /// in the renderer, the tracer and the evaluator alike. `values` stays - /// public alongside it -- this is a transparent aggregate of the table's - /// contents, not an encapsulation. + /// contents as it would an array's: `node.corrections[index]` reads the + /// same in the renderer, the tracer and the evaluator. `values` is public + /// alongside it -- this is a transparent aggregate of the table's contents, + /// not an encapsulation. [[nodiscard]] constexpr Rational operator[](std::size_t rowIndex) const noexcept { return values[rowIndex]; @@ -818,12 +812,11 @@ struct BandedLookupNode: NodeBase /// three non-operand parameters unstated at the call site's argument list: /// a table's structure is the author's declared intent, not something /// inferred from whatever `corrections` happens to look like. The braced -/// list at the call site still reads exactly as it did before `Corrections` -/// existed -- only its target type changed, from `std::array<Rational, N>` -/// to `Corrections<N>` -- because the call site's target type is already -/// known from the explicit template arguments, so list-initialisation finds -/// `Corrections`'s constructor the same way it found `std::array`'s -/// aggregate initialisation before. +/// list at the call site reads as a plain array initialiser would, although +/// its target type is `Corrections<N>`, because the call site's target type +/// is already known from the explicit template arguments, so +/// list-initialisation finds `Corrections`'s constructor the same way it +/// would find `std::array`'s aggregate initialisation. template <Unit KeyUnit, BandTable Bands, Unit ResultUnit, Node Operand> [[nodiscard]] constexpr BandedLookupNode<KeyUnit, Bands, ResultUnit, Operand> banded_lookup( Operand operand, Corrections<Bands.size()> corrections) noexcept @@ -831,6 +824,15 @@ template <Unit KeyUnit, BandTable Bands, Unit ResultUnit, Node Operand> return BandedLookupNode<KeyUnit, Bands, ResultUnit, Operand> { {}, corrections, operand }; } +/// A bound formula as `banded_lookup`'s key: the formula it holds, in its place +/// (`yields.hpp`). +template <Unit KeyUnit, BandTable Bands, Unit ResultUnit, typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto banded_lookup(Bound boundKey, Corrections<Bands.size()> corrections) noexcept +{ + return banded_lookup<KeyUnit, Bands, ResultUnit>(boundKey.expression, corrections); +} + /// Evaluates the operand, converts its value into `KeyUnit`, and looks up the /// band it falls in. Absence propagates, same as every other node; a value /// that falls in no band is reported as `ArithmeticError::DomainError` -- @@ -926,12 +928,11 @@ using KeyTable = std::array<Key, N>; /// bearing rather than a typo: **Doxygen 1.9.8 -- the version `pages.yml` /// installs -- reads a leading `::` as an explicit link request even inside a /// code span**, and fails the build under `WARN_AS_ERROR = FAIL_ON_WARNINGS` -/// when it cannot resolve the name. Newer Doxygen does not, which is exactly -/// how this reached the branch: it was verified against 1.18.0 on a -/// contributor's machine and was red for the one CI actually runs. The `%` is -/// stripped from the generated HTML, so nothing leaks onto the page. Same -/// treatment, same reason, on `Outcome`'s `::%value(...)` in this file's -/// comment above. +/// when it cannot resolve the name. Newer Doxygen does not, so a local build +/// with a newer version passes without the `%` and only the version CI +/// installs catches its absence. The `%` is stripped from the generated HTML, +/// so nothing leaks onto the page. Same treatment, same reason, on +/// `Outcome`'s `::%value(...)` in this file's comment above. template <KeyTable Keys> using KeyOf = typename std::remove_cvref_t<decltype(Keys)>::value_type; @@ -1152,15 +1153,14 @@ struct RequireValidKeyTable: detail::KeyChecks<Keys, std::make_index_sequence<Ke /// cannot reach the node through an operand or through the `Environment`. /// /// Both `static_assert`s sit in the class body rather than in the factory, -/// and the property that buys is narrower than it first looks -- stated -/// precisely here because an earlier revision of this comment claimed more -/// than it could deliver, and the difference was measured. Discarding -/// the factory's result is **not** what distinguishes the two placements: -/// `exact_lookup` returns `ExactLookupNode` *by value*, so calling it -/// completes the class whichever placement is chosen, and an assert in the -/// factory body fires on any call, discarded or not. What the class body -/// buys is this: `ExactLookupNode` is a public aggregate with public members, -/// so a caller can declare one **without ever calling the factory** -- +/// and the property that buys is narrower than it first looks, so it is +/// stated precisely here. Discarding the factory's result is **not** what +/// distinguishes the two placements: `exact_lookup` returns `ExactLookupNode` +/// *by value*, so calling it completes the class whichever placement is +/// chosen, and an assert in the factory body fires on any call, discarded or +/// not. What the class body buys is this: `ExactLookupNode` is a public +/// aggregate with public members, so a caller can declare one **without ever +/// calling the factory** -- /// /// inline constexpr ExactLookupNode<Duplicated, unit::One> node { /// {}, { 1.127_r, 0.863_r, 1.043_r }, Shape::Cube }; @@ -1607,8 +1607,7 @@ namespace detail /// choice affects is how large the intermediates get, which is to say how /// far the computation gets before it has to report `Overflow`. /// - /// **Neither order dominates**, and the comment that used to stand here - /// claimed one did. Both directions: + /// **Neither order dominates.** Both directions: /// /// - keys `{0, 10}` with values `{0, 2^126}`, asked at 5: dividing first /// answers `2^125` exactly; multiplying first would form `5 * 2^126` and @@ -1631,8 +1630,7 @@ namespace detail /// Both of those tables are in `lookup_tests.cpp`, asserted as behaviour -- /// one showing the chosen order returning a value the rejected order could /// not, one showing the chosen order refusing a table the rejected order - /// could have answered. The order was previously pinned by nothing at all: - /// the entire suite compiled unchanged under either. + /// could have answered. /// /// `highKey - lowKey` cannot be zero: `RequireValidBreakpointTable` has /// already refused a table whose rows do not strictly ascend, so there is @@ -1870,6 +1868,15 @@ template <Unit KeyUnit, BreakpointTable Points, Unit ResultUnit, Node Operand> return InterpolatingLookupNode<KeyUnit, Points, ResultUnit, Operand> { {}, corrections, operand }; } +/// A bound formula as `interpolating_lookup`'s key: the formula it holds, in +/// its place (`yields.hpp`). +template <Unit KeyUnit, BreakpointTable Points, Unit ResultUnit, typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto interpolating_lookup(Bound boundKey, Corrections<Points.size()> corrections) noexcept +{ + return interpolating_lookup<KeyUnit, Points, ResultUnit>(boundKey.expression, corrections); +} + /// Evaluates the operand, converts its value into `KeyUnit`, and answers from /// the table: the row's own value when the value sits exactly on a row, the /// interpolation of the two surrounding rows when it sits between them. diff --git a/include/formula-cpp/measured.hpp b/include/formula-cpp/measured.hpp index 4b89fb91..4b16317e 100644 --- a/include/formula-cpp/measured.hpp +++ b/include/formula-cpp/measured.hpp @@ -153,12 +153,11 @@ template <Described Q, typename F> /// the question this library cannot -- `combine<Density>(mass, volume, /// [](Rational m, Rational v) { return m / v; })`. /// -/// An earlier signature deduced the result as the right-hand operand's -/// quantity, so `combine(mass, volume, divide)` was statically a `Measured` -/// of volume reporting a volume's symbol and unit for a value that was a -/// density. Measured: the branch's own test and example were both written -/// against that signature and read as if correct -- a wrong label on a right -/// number, and worse than a wrong number because it looks authoritative. +/// Deducing the result from an operand would label it wrongly: taken as the +/// right-hand operand's quantity, `combine(mass, volume, divide)` would be +/// statically a `Measured` of volume, reporting a volume's symbol and unit for +/// a value that is a density -- a wrong label on a right number, and worse +/// than a wrong number because it looks authoritative and reads as correct. /// /// Not "absent if both": a formula with one missing input has no answer, and /// producing one from the inputs that happen to be present is precisely the diff --git a/include/formula-cpp/method.hpp b/include/formula-cpp/method.hpp index b5ce4566..44f0db7b 100644 --- a/include/formula-cpp/method.hpp +++ b/include/formula-cpp/method.hpp @@ -63,15 +63,15 @@ /// in the type to compare. So this header refuses repeated tags and does not /// attempt the general question. /// -/// The first two exist because `variants(...)` over a bare pack accepted -/// nonsense in silence. Measured on cl 19.51 at `/W4 /WX`, **exit 0, no -/// diagnostics**: `variants(var<EdgeX>, var<EdgeX>)` -- a pack with no tags -/// anywhere -- `variants(var<Force>)`, and `variants()`. The first slipped -/// through rule 3 because a `VarNode` happens to publish a `dimension`; the -/// second never reached it, a one-element pack having no pair; the third is -/// vacuous. All three are refused now, at the earliest point where the -/// mistake is still the author's own call rather than something several -/// layers away. +/// The first two exist because `variants(...)` over a bare pack would +/// otherwise accept nonsense in silence. Measured on cl 19.51 at `/W4 /WX` +/// without them, **exit 0, no diagnostics**: +/// `variants(var<EdgeX>, var<EdgeX>)` -- a pack with no tags anywhere -- +/// `variants(var<Force>)`, and `variants()`. The first would slip through +/// rule 3 because a `VarNode` happens to publish a `dimension`; the second +/// would never reach it, a one-element pack having no pair; the third is +/// vacuous. All three are refused, at the earliest point where the mistake is +/// still the author's own call rather than something several layers away. /// /// **Variants agree in the quantity they report, and at this layer that means /// the dimension.** Variants are heterogeneous by design -- the spec's own @@ -99,6 +99,7 @@ #include <formula-cpp/sink.hpp> #include <formula-cpp/tag.hpp> #include <formula-cpp/unit.hpp> +#include <formula-cpp/yields.hpp> #include <array> #include <cstddef> @@ -213,6 +214,15 @@ template <typename Tag, SeriesNode Expr> return VariantCase<Tag, ConstantNode<coherent(Expr::dimension)>> { ConstantNode<coherent(Expr::dimension)> {} }; } +/// A bound formula as `variant`'s expression: the formula it holds, in its +/// place (`yields.hpp`). +template <typename Tag, typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto variant(Bound boundFormula) noexcept +{ + return variant<Tag>(boundFormula.expression); +} + namespace detail { /// Whether a type is a `VariantCase` -- whatever produced it, whether @@ -466,7 +476,7 @@ namespace detail /// can be sequenced. The agreement rule is asked **only once every /// argument is a variant**: a non-variant has no `dimension` to compare, /// and asking anyway buries the one message that matters. Measured on cl - /// 19.51 before this gate existed, `variants(42, 43)` reported six errors + /// 19.51 without this gate, `variants(42, 43)` would report six errors /// -- `C2825`, `C2510` and `C2065`, once for `First` and once for `Other` /// -- every one of them the compiler's own wording for "that has no such /// member", and not one of them ours. `method_variants_agreement_gated.cpp` @@ -668,14 +678,14 @@ namespace detail /// **A layout is not stated from outside the library, except by /// copying one** (see below). The constructor that takes positions and a /// count is public, so that braces reach it, and refuses whenever it is - /// used, in the library's words. Once it checked the layout and accepted - /// any well-formed one, so `{ { 5, 7 }, 9 }` made a two-variant method + /// used, in the library's words. Were it to check the layout and accept + /// any well-formed one, `{ { 5, 7 }, 9 }` would make a two-variant method /// that no overlay touched report its variants as the 6th and 8th of 9. /// `select<Kept...>()` is public for the same reason and refuses the same - /// way: while it made the selection itself, `decltype(nine.published) - /// {}.select<5, 7>()`, taken from a throwaway pack of nine, did the same - /// thing with no prune on record. The checked constructor and `kept` are - /// private, reached through `PublishedLayoutAccess`, which the overlay + /// way: were it to make the selection itself, `decltype(nine.published) + /// {}.select<5, 7>()`, taken from a throwaway pack of nine, would do the + /// same thing with no prune on record. The checked constructor and `kept` + /// are private, reached through `PublishedLayoutAccess`, which the overlay /// operations and the negative cases pinning the check use. /// /// **Why a selection and not the checking constructor.** Which variants a pin @@ -913,7 +923,7 @@ namespace detail /// deliberately: this is a public aggregate with a public member, so a /// `Variants<...>` can be declared directly with no factory call anywhere, /// and a check placed only in the factory would let that route through. The -/// lookup tables were once open to the same mistake, and are checked the same +/// lookup tables would be open to the same mistake, and are checked the same /// way. template <typename... Cs> struct Variants @@ -1476,12 +1486,12 @@ namespace detail /// Fails to compile when a method's rounding rule rounds in a unit that /// does not measure the dimension its variants report. /// - /// A `Megapascal` rule on a method whose variants measure a length used to - /// be accepted by `method(...)` and refused only inside `evaluate_method`, - /// by the rounding node it builds -- so a method nobody evaluated in a test - /// would ship broken. Templated on the variants pack and the rule, the two - /// places the two dimensions come from, so that both appear in the - /// diagnostic. + /// A `Megapascal` rule on a method whose variants measure a length is + /// refused where the method is built, rather than only inside + /// `evaluate_method` by the rounding node it builds, which would let a + /// method nobody evaluated in a test ship broken. Templated on the variants + /// pack and the rule, the two places the two dimensions come from, so that + /// both appear in the diagnostic. template <typename Vs, typename Rounding> struct RequireRoundingRuleMeasuresVariants { @@ -1517,14 +1527,13 @@ namespace detail /// First its shape: the variants, the rounding rule and the constraints, /// each the kind of thing its factory builds. `method(...)` takes three /// arguments of unrelated types, so nothing stops an author passing them - /// in the wrong order. Before these rules, - /// `method(rounding_rule<...>(), variants(...), constraints())` compiled - /// on cl 19.51 and clang-cl 22, and failed only at `evaluate_method`, with - /// the compiler's own words for it: cl's `C2027: use of undefined type + /// in the wrong order. Without these rules, + /// `method(rounding_rule<...>(), variants(...), constraints())` would + /// compile on cl 19.51 and clang-cl 22 and fail only at `evaluate_method`, + /// in the compiler's own words: cl's `C2027: use of undefined type /// SelectVariant<...>`, clang-cl's "implicit instantiation of undefined - /// template". Each rule names the - /// part it refuses, so a swapped pair is reported as the two parts that - /// are wrong. + /// template". Each rule names the part it refuses, so a swapped pair is + /// reported as the two parts that are wrong. /// /// Then the rounding rule's dimension, only once there is a rule and an /// agreed dimension to compare -- see `canAskRoundingRule`. @@ -1828,10 +1837,9 @@ namespace detail /// /// **It rounds exactly as a `RoundNode` does**, and derives from one, so the /// unit, the places and the mode are stated once. What it adds is provenance. -/// A method's rule used to be applied through an ordinary `rounded<>` node, -/// traced as an ordinary `Round` step, which says to how many places a value -/// was rounded but not whose rule that was: the method's author's, or a -/// jurisdiction's. A trace records this node as a +/// An ordinary `rounded<>` node is traced as an ordinary `Round` step, which +/// says to how many places a value was rounded but not whose rule that was: +/// the method's author's, or a jurisdiction's. A trace records this node as a /// `StepKind::RoundingRuleApplied` step (`trace.hpp`), which says both. /// /// **Only `evaluate_method` builds one**, around the variant it selected and diff --git a/include/formula-cpp/opaque.hpp b/include/formula-cpp/opaque.hpp index befc7c6b..75280134 100644 --- a/include/formula-cpp/opaque.hpp +++ b/include/formula-cpp/opaque.hpp @@ -74,6 +74,7 @@ #include <formula-cpp/series.hpp> #include <formula-cpp/sink.hpp> #include <formula-cpp/unit.hpp> +#include <formula-cpp/yields.hpp> #include <array> #include <concepts> @@ -140,7 +141,7 @@ enum class OpaqueFailure : std::uint8_t enum class OpaqueValues : std::uint8_t { /// Every output is a `Rational`, exactly: `opaque_output`'s route. The zero - /// value, so a call described before this existed reads as it did. + /// value, so a call described without naming its values reads as exact. Exact, /// The call was evaluated for an output rounded where it is used /// (`rounded_output`): no output exists as a `Rational` until it is @@ -837,6 +838,16 @@ template <OpaqueOperation Op, typename... Inputs> return OpaqueCall<Op, Inputs...> { std::tuple<Inputs...> { inputs... }, citation }; } +/// A bound formula as any input of an opaque call: the formula it holds, in +/// its place (`yields.hpp`). Declared as the overload above is, and +/// constrained further, so that it is the one chosen for a bound formula. +template <OpaqueOperation Op, typename... Inputs> + requires detail::AnyBound<Inputs...> +[[nodiscard]] constexpr auto opaque(Citation citation, Inputs... inputs) noexcept +{ + return opaque<Op>(citation, detail::as_operand(inputs)...); +} + namespace detail { /// The position of the output named @p Name among @p Op's, or @@ -991,6 +1002,15 @@ template <detail::FixedString Name, OpaqueOperation Op, typename... Inputs> return OpaqueOutputNode<detail::unknownOutput, Call, detail::UnnamedOpaqueOutput> { {}, call }; } +/// A bound formula as `opaque_output`'s call: the formula it holds, in its +/// place (`yields.hpp`). +template <detail::FixedString Name, typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto opaque_output(Bound boundCall) noexcept +{ + return opaque_output<Name>(boundCall.expression); +} + /// Output @p I of the opaque call @p Call, rounded to @p Places decimal places /// of @p U under @p Mode -- the decimal the operation's true output rounds to, /// exact: the fused counterpart of `opaque_output`, as `rounded_sqrt` is of @@ -1080,6 +1100,23 @@ template <detail::FixedString Name, DecimalRounding R, OpaqueOperation Op, typen return rounded_output<Name, R.unit, R.places, R.mode>(call); } +/// A bound formula as `rounded_output`'s call: the formula it holds, in its +/// place (`yields.hpp`). +template <detail::FixedString Name, Unit U, DecimalPlaces Places, RoundingMode Mode, typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto rounded_output(Bound boundCall) noexcept +{ + return rounded_output<Name, U, Places, Mode>(boundCall.expression); +} + +/// See the overload above, rounded as @p R names. +template <detail::FixedString Name, DecimalRounding R, typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto rounded_output(Bound boundCall) noexcept +{ + return rounded_output<Name, R>(boundCall.expression); +} + 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 36264cb6..8c2fa19b 100644 --- a/include/formula-cpp/overlay.hpp +++ b/include/formula-cpp/overlay.hpp @@ -158,6 +158,7 @@ #include <formula-cpp/sink.hpp> #include <formula-cpp/snap.hpp> #include <formula-cpp/statistics.hpp> +#include <formula-cpp/yields.hpp> #include <array> #include <cstddef> @@ -573,7 +574,7 @@ struct ConstantOverride /// an `OverriddenConstantNode<Q>`, which evaluates to @p value without asking /// the environment, so a `Q` supplied there is ignored by the overlaid method /// and need not be supplied at all. That is what a jurisdiction fixing a -/// constant means: the value is no longer the specimen's to state. +/// constant means: the value is not the specimen's to state. /// /// @p source records where the value comes from -- a national annex, say -- /// and travels with every node the override leaves behind. Every operation @@ -786,7 +787,7 @@ struct VariantReplacement /// `ReplacedVariantNode`. /// /// Refused when no variant of the method declares `Tag`, when the method the -/// overlay produces no longer holds that variant (pinned or pruned away, in +/// overlay produces does not hold that variant (pinned or pruned away, in /// either order), when @p expression measures a different dimension from the /// method's, and when one overlay replaces the same variant twice: see the /// file comment. @@ -811,6 +812,24 @@ template <typename Tag, bool Stated = false, Node Expr> return VariantReplacement<Tag, Expr> { expression, {} }; } +/// A bound formula as `replace_variant`'s replacement: the formula it holds, in +/// its place (`yields.hpp`). +template <typename Tag, typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto replace_variant(Bound boundFormula, Citation source) noexcept +{ + return replace_variant<Tag>(boundFormula.expression, source); +} + +/// Refuses a bound replacement with no citation, as `replace_variant` refuses +/// the formula it holds. +template <typename Tag, typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto replace_variant(Bound boundFormula) noexcept +{ + return replace_variant<Tag>(boundFormula.expression); +} + /// The operation `with_constraints(constraints(...), source)` builds: replace the /// method's constraints wholesale. /// @@ -856,7 +875,7 @@ struct ConstraintsOverride /// and that includes a later overlay's replacing the only constraints an /// earlier overlay's constant or definition reached, which is accepted, as a /// later `replace_variant` of the only variant reading one is: the earlier -/// substitution then no longer applies. +/// substitution then does not apply. /// /// @p source cites whose constraints they are, and is required, as /// `with_constant`'s is. @@ -2999,6 +3018,24 @@ template <Described Q, bool Stated = false, Node Expr> return QuantityDerivation<Q, Expr> { expression, {} }; } +/// A bound formula as `add_derived`'s definition: the formula it holds, in its +/// place (`yields.hpp`). +template <Described Q, typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto add_derived(Bound boundFormula, Citation source) noexcept +{ + return add_derived<Q>(boundFormula.expression, source); +} + +/// Refuses a bound definition with no citation, as `add_derived` refuses the +/// formula it holds. +template <Described Q, typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto add_derived(Bound boundFormula) noexcept +{ + return add_derived<Q>(boundFormula.expression); +} + namespace detail { template <Described Q, Node Expr> @@ -3423,8 +3460,8 @@ namespace detail static constexpr bool value = true; }; - /// Fails to compile when the method an overlay produces no longer holds - /// the variant it replaced -- pruned, or pinned away, before or after. + /// Fails to compile when the method an overlay produces does not hold the + /// variant it replaced -- pruned, or pinned away, before or after. template <typename Tag, bool Held> struct RequireReplacementHeld { @@ -3440,8 +3477,8 @@ namespace detail /// `replace_variant<Tag>` is judged against the result, as `with_constant` /// is, and in two steps so that one mistake gets one message: a tag the /// method the overlay was applied to never declared is a mistaken name; - /// only a tag it did declare can then be one the produced method no longer - /// holds. A tag that is not a plain class type is `VariantReplacement`'s + /// only a tag it did declare can then be one the produced method does not + /// hold. A tag that is not a plain class type is `VariantReplacement`'s /// to refuse, and neither is asked of it. /// /// Judged here rather than where the replacement is applied, because the @@ -3624,7 +3661,7 @@ namespace detail /// `replace_variant` or `with_constraints` removes every node an earlier /// overlay's substitution left, and puts back a plain use, leaves this /// rule nothing to find: the later overlay's formula or constraints hold, - /// and the earlier constant or definition no longer applies. That is + /// and the earlier constant or definition does not apply. That is /// "across overlays, the later one holds", accepted rather than refused; /// within one overlay the same order is refused as bypassed /// (`RequireConstantNotBypassed`). diff --git a/include/formula-cpp/precision.hpp b/include/formula-cpp/precision.hpp index 96321cf4..7ffc8449 100644 --- a/include/formula-cpp/precision.hpp +++ b/include/formula-cpp/precision.hpp @@ -83,6 +83,7 @@ #include <formula-cpp/snap.hpp> #include <formula-cpp/statistics.hpp> #include <formula-cpp/unit.hpp> +#include <formula-cpp/yields.hpp> #include <cstddef> #include <cstdint> @@ -123,6 +124,15 @@ template <Node Operand> return AbsoluteValueNode<Operand> { {}, operand }; } +/// A bound formula as `abs`'s operand: the formula it holds, in its place +/// (`yields.hpp`). +template <typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto abs(Bound boundFormula) noexcept +{ + return abs(boundFormula.expression); +} + /// Evaluates the operand and takes its magnitude. Absence and errors pass /// through; negating the one value `Rational` cannot negate is `Overflow`. template <typename Rep = Rational, Node Operand, typename Env, typename Sink = NullSink> @@ -337,14 +347,13 @@ namespace detail /// g++ 13.3 and 14.2, clang++ 20.1 with libstdc++ and with libc++: one /// declared in namespace `formula` with no specialisation here is refused /// (`RequireLevelChildrenFor`), where a placeholder inside it would - /// otherwise hide from every check below without a word -- as - /// `DerivedQuantityNode`, and then the series `sum` and elementwise - /// nodes, once did. On a front end whose spelling of a type this cannot - /// read, every kind reaching the primary is refused rather than passed as - /// a consumer's. One divergence between those toolchains: a class - /// declared *inside a function* in `formula` spells as `formula::f()::X` - /// on cl and g++ but as `X` on clang, so there it would read as a - /// consumer's; the library declares no local node kinds. + /// otherwise hide from every check below without a word. On a front end + /// whose spelling of a type this cannot read, every kind reaching the + /// primary is refused rather than passed as a consumer's. One divergence + /// between those toolchains: a class declared *inside a function* in + /// `formula` spells as `formula::f()::X` on cl and g++ but as `X` on + /// clang, so there it would read as a consumer's; the library declares no + /// local node kinds. /// `level_check_sees_every_node` walks the vocabulary's every-kind method /// (`vocabulary_tests.cpp`) as a second net. /// @@ -842,6 +851,15 @@ template <PrecisionKind K, Node Level, Node Limit> return PrecisionLimitNode<K, Level, Limit> { {}, levelExpression, limitExpression }; } +/// A bound formula as the level or the limit of `precision_limit`: the +/// formula it holds, in its place (`yields.hpp`). +template <PrecisionKind K, typename Level, typename Limit> + requires detail::AnyBound<Level, Limit> +[[nodiscard]] constexpr auto precision_limit(Level levelExpression, Limit limitExpression) noexcept +{ + return precision_limit<K>(detail::as_operand(levelExpression), detail::as_operand(limitExpression)); +} + namespace detail { template <PrecisionKind K, Node Level, Node Limit> diff --git a/include/formula-cpp/quantity.hpp b/include/formula-cpp/quantity.hpp index a61a9419..b7062a9a 100644 --- a/include/formula-cpp/quantity.hpp +++ b/include/formula-cpp/quantity.hpp @@ -170,12 +170,12 @@ namespace detail /// static constexpr Dimension dimension = formula::unit::Celsius.dimension; /// }; /// **Empty on purpose.** It would be nicer for this to `static_assert` with a -/// helpful message, and that was the first design; it is wrong. A -/// `static_assert` failure is not in the immediate context, so it is a hard -/// error rather than a substitution failure -- which means the `Described` -/// concept below, whose whole job is to answer "is this type described?", -/// would fail to COMPILE for every type that is not, instead of answering no. -/// Measured on clang: `static_assert(!Described<int>)` did not compile. +/// helpful message; that would be wrong. A `static_assert` failure is not in +/// the immediate context, so it is a hard error rather than a substitution +/// failure -- which means the `Described` concept below, whose whole job is to +/// answer "is this type described?", would fail to COMPILE for every type that +/// is not, instead of answering no. With one, `static_assert(!Described<int>)` +/// does not compile (measured on clang). /// /// So the primary stays empty, `Described` works by member detection, and the /// helpful diagnostic lives in `RequireDescribed` below, where it can be asked @@ -209,17 +209,16 @@ struct Describe<T> /// True when `T` has metadata, however it was declared. /// /// Answers rather than explodes for a type that has none -- see the note on the -/// primary template above for why that took a redesign. +/// primary template above for why. /// /// All four members `Measured` and `checked_convert_to` actually read, not -/// merely the two that were enough to satisfy the concept's own author. A type -/// specialising only `symbol` and `unit` used to pass this concept, pass -/// `RequireDescribed`, and only then fail deep inside `checked_convert_to` with -/// the compiler's own "no member named 'dimension'" -- exactly the raw -/// diagnostic `RequireDescribed` exists to replace, delivered from the one -/// place it was supposed to be caught first. Measured: a `Describe` -/// specialisation naming only `symbol` and `unit` satisfied the two-member -/// concept and only broke three calls deeper. +/// merely two of them. Were the concept to ask only for `symbol` and `unit`, a +/// type specialising those two would pass it, pass `RequireDescribed`, and +/// only then fail deep inside `checked_convert_to` with the compiler's own "no +/// member named 'dimension'" -- exactly the raw diagnostic `RequireDescribed` +/// exists to replace, delivered from the one place it is supposed to be caught +/// first. Measured: a `Describe` specialisation naming only `symbol` and +/// `unit` satisfies a two-member concept and breaks only three calls deeper. template <typename T> concept Described = requires { { Describe<T>::symbol } -> std::convertible_to<std::string_view>; diff --git a/include/formula-cpp/record.hpp b/include/formula-cpp/record.hpp index a5196a8e..ba8e34a0 100644 --- a/include/formula-cpp/record.hpp +++ b/include/formula-cpp/record.hpp @@ -60,8 +60,7 @@ /// - `Record::lineage_of<Attr>` is a public member template, and explicitly /// specialising it for a record type can make a lineage check compare any /// key at all: outside the contract (see below), and not prevented; -/// - `Trace::steps` is a public arena any code may append to or edit, as it -/// has been since the trace was introduced; +/// - `Trace::steps` is a public arena any code may append to or edit; /// - a `RecordOrigin` the library built can be copied, and handed to a /// sink's `record_entered` by hand; /// - a `RecordOrigin` is trivially copyable, so `std::bit_cast` from a @@ -108,6 +107,7 @@ #include <formula-cpp/overlay.hpp> #include <formula-cpp/sink.hpp> #include <formula-cpp/tag.hpp> +#include <formula-cpp/yields.hpp> #include <array> #include <cstddef> @@ -1449,6 +1449,15 @@ from_record(Operand operand, LineageRequirement<Comparand, Attrs...>) noexcept return RecordScopeNode<Role, LineageRequirement<Comparand, Attrs...>, Operand> { {}, operand }; } +/// A bound formula as the operand `from_record` reads, with or without a +/// lineage requirement: the formula it holds, in its place (`yields.hpp`). +template <typename Role, typename Bound, typename... Requirement> + requires detail::AnyBound<Bound> && (sizeof...(Requirement) <= 1) +[[nodiscard]] constexpr auto from_record(Bound boundFormula, Requirement... requirement) noexcept +{ + return from_record<Role>(boundFormula.expression, requirement...); +} + /// Evaluates a scope: its operand against the environment of the record /// @p environment binds to the scope's role. /// diff --git a/include/formula-cpp/rejection.hpp b/include/formula-cpp/rejection.hpp index 63f0aff1..72a19834 100644 --- a/include/formula-cpp/rejection.hpp +++ b/include/formula-cpp/rejection.hpp @@ -230,6 +230,15 @@ template <Node Limit> return DeviationFromMean<Limit> { limitExpression }; } +/// A bound formula as `deviation_from_mean`'s limit: the formula it holds, in +/// its place (`yields.hpp`). +template <typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto deviation_from_mean(Bound boundLimit) noexcept +{ + return deviation_from_mean(boundLimit.expression); +} + /// abs(x - pass mean) / s against @p limitExpression: `deviation_in_stddevs(number(1.75_r))`. template <Node Limit> [[nodiscard]] constexpr DeviationInStddevs<Limit> deviation_in_stddevs(Limit limitExpression) noexcept @@ -237,6 +246,15 @@ template <Node Limit> return DeviationInStddevs<Limit> { limitExpression }; } +/// A bound formula as `deviation_in_stddevs`'s limit: the formula it holds, in +/// its place (`yields.hpp`). +template <typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto deviation_in_stddevs(Bound boundLimit) noexcept +{ + return deviation_in_stddevs(boundLimit.expression); +} + /// gap / range for the two extremes against @p limitExpression: /// `gap_to_range(critical_value<Sizes, unit::One>(pass_count, {...}) * 0.01_r)`. template <Node Limit> @@ -245,6 +263,15 @@ template <Node Limit> return GapToRange<Limit> { limitExpression }; } +/// A bound formula as `gap_to_range`'s limit: the formula it holds, in its +/// place (`yields.hpp`). +template <typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto gap_to_range(Bound boundLimit) noexcept +{ + return gap_to_range(boundLimit.expression); +} + namespace detail { /// Whether @p T is one of this header's criteria. @@ -696,6 +723,18 @@ template <PerPass P, OnLimit L, typename AtMostT, typename KeepAtLeastT, Node N, }; } +/// A bound formula as `without_outliers`'s sample: the formula it holds, in its +/// place (`yields.hpp`). +template <PerPass P, OnLimit L, typename AtMostT, typename KeepAtLeastT, typename Bound, typename Criterion> + requires detail::AnyBound<Bound> && detail::is_criterion<Criterion> +[[nodiscard]] constexpr auto without_outliers(Bound boundSample, + Criterion criterion, + Verdict declared, + Citation cited = {}) noexcept +{ + return without_outliers<P, L, AtMostT, KeepAtLeastT>(boundSample.expression, criterion, declared, cited); +} + // ---------------------------------------------------------------- outcome /// A rejected determination: its zero-based position in the sample as diff --git a/include/formula-cpp/render.hpp b/include/formula-cpp/render.hpp index e7c10371..c6460c8f 100644 --- a/include/formula-cpp/render.hpp +++ b/include/formula-cpp/render.hpp @@ -18,10 +18,10 @@ /// **Ruling, decided here once and binding on every other surface: a /// half-open interval is spelled `<low> to under <high>`, never `[low, /// high)`.** A band really is `[103, 197)` (`band.hpp`), and that is exactly -/// the character sequence CommonMark reads as a link label -- a defect once -/// published, when `round[to 1 dp of mm](d)` reached a page with its operand -/// silently dropped. The guard test in `render_tests.cpp` asserts -/// that no Markdown rendering contains `](` or a bare `[`, and a band written +/// the character sequence CommonMark reads as a link label -- the defect by +/// which `round[to 1 dp of mm](d)` would reach a page with its operand +/// silently dropped. The guard test in `render_tests.cpp` asserts that no +/// Markdown rendering contains `](` or a bare `[`, and a band written /// the mathematician's way would defeat it. `to under` is not a compromise /// spelling: it *says* the exclusion in words, where a reader has to know the /// bracket convention to see it, and it survives every Markdown flavour @@ -730,10 +730,10 @@ namespace detail /// /// **Not LaTeX.** LaTeX sets a key's name inside the `\mathrm{...}` of the /// row it stands in, and `lookup_words_in_dialect` escapes the whole row - /// with `latex_math_words` (`detail/latex_math.hpp`). This function once - /// escaped for LaTeX text mode, inside `\text{...}` -- `\_`, + /// with `latex_math_words` (`detail/latex_math.hpp`). This function does + /// not escape for LaTeX text mode, inside `\text{...}` -- `\_`, /// `\textbackslash{}`, `{\ttfamily\char34}` -- which a TeX engine reads - /// and the site's MathJax, without `textmacros`, shows backslash and all. + /// but the site's MathJax, without `textmacros`, shows backslash and all. /// /// **Markdown** backslash-escapes the six characters that open inline /// markup -- a backslash, a backtick, `*`, `_`, `[`, `]` -- and writes six @@ -1119,9 +1119,7 @@ namespace detail /// `$...$` and `\[...\]`, **and** wrapping these in amsmath's `multline*` /// does not help anyway (measured: 571pt worst, slightly *worse* than the /// plain display, because `multline` also breaks only at an explicit `\\` - /// and never at `\allowbreak`). An earlier revision of this comment - /// claimed `multline` fixed it; it does not, and the claim was reasoned - /// rather than measured. + /// and never at `\allowbreak`). /// /// What does help, for a caller who genuinely needs a wide table in a /// display, is `breqn`'s `dmath`: 5 overfull boxes over the same 22 @@ -1134,21 +1132,7 @@ namespace detail /// deleting it as tidy-up would quietly take the remedy away with it. /// /// The residual is named rather than rounded off to "clean": all 5 boxes - /// that survive `dmath` are **exact** lookups, 8.3pt to 13.3pt. An earlier - /// measurement of `dmath` sampled four renderings and happened to include - /// no exact lookup at all, which is the degenerate-fixture rule landing on - /// a measurement set rather than on a test fixture. - /// - /// **This paragraph has been wrong twice, both times the same way, and - /// that is the useful thing in it.** The first draft said amsmath's - /// `multline` was the remedy; typesetting it gave 571pt, slightly *worse* - /// than a plain display, because `multline` also breaks only at an - /// explicit `\\`. The draft that replaced it said `dmath` owed nothing to - /// `\allowbreak` -- measured against a "control" that turned out to be - /// byte-identical to the treatment, because the strip silently never - /// applied. An instrument that cannot report a difference will report no - /// difference. Anything added here comes from two files diffed before they - /// are trusted. + /// that survive `dmath` are **exact** lookups, 8.3pt to 13.3pt. /// /// `render_tests.cpp` pins what a unit test can pin: that every field /// separator carries `\allowbreak`, and that a lookup never emits `\\`. It @@ -2517,15 +2501,6 @@ template <Dialect D, Dimension Dim, Vocabulary V> 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 <Dialect D, Dimension Dim, Vocabulary V> -[[nodiscard]] std::string render_node(detail::RefusedBoundValue<Dim> const&, V const&) -{ - return "(refused)"; -} - namespace detail { /// Renders @p node through the `render_node` it has, and refuses a node of @@ -2559,8 +2534,8 @@ namespace detail /// /// Step 2 comes before step 3 for a consumer's node that **derives from /// one of this library's**, `struct Labelled: formula::VarNode<Q>`: it - /// renders through its own one-argument overload, as it did before - /// vocabularies existed, rather than as the base it derives from. That + /// renders through its own one-argument overload, which a vocabulary + /// leaves unchanged, rather than as the base it derives from. That /// node's own text is then in the declared symbols; to receive the /// vocabulary it defines the two-argument form **instead**. Were it to /// define both, the one-argument form would win, because its two-argument diff --git a/include/formula-cpp/retry.hpp b/include/formula-cpp/retry.hpp index 98e9da3c..06621103 100644 --- a/include/formula-cpp/retry.hpp +++ b/include/formula-cpp/retry.hpp @@ -173,6 +173,15 @@ template <Node E> return StartingValue<E> { expression }; } +/// A bound formula as `starting_from`'s starting value: the formula it holds, +/// in its place (`yields.hpp`). +template <typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto starting_from(Bound boundFormula) noexcept +{ + return starting_from(boundFormula.expression); +} + /// Which part of a retry is being evaluated: its starting value, before any /// attempt; an attempt expression; or the acceptance over what it produced. enum class AttemptPhase : std::uint8_t @@ -1122,6 +1131,25 @@ template <Described R, std::size_t Max, FirstJudged J, typename A, typename P> return Retry<R, Max, J, NoStartingValue, A, P> { NoStartingValue {}, attemptExpression, accept, onExhausted, citation }; } +/// A bound formula as a retry's attempt expression: the formula it holds, in +/// its place (`yields.hpp`). Declared as the overloads above are, and +/// constrained further, so that it is the one chosen for a bound formula. +template <Described R, std::size_t Max, FirstJudged J, Node E, typename A, typename P> + requires detail::AnyBound<A> +[[nodiscard]] constexpr auto retry( + StartingValue<E> start, A attemptExpression, P accept, Verdict onExhausted, Citation citation) noexcept +{ + return retry<R, Max, J>(start, attemptExpression.expression, accept, onExhausted, citation); +} + +/// See the overload above: the same, with no starting value. +template <Described R, std::size_t Max, FirstJudged J, typename A, typename P> + requires detail::AnyBound<A> +[[nodiscard]] constexpr auto retry(A attemptExpression, P accept, Verdict onExhausted, Citation citation) noexcept +{ + return retry<R, Max, J>(attemptExpression.expression, accept, onExhausted, citation); +} + /// Why a retry failed: the arithmetic error, and the attempt it arose at -- /// a **zero-based position**, as `SeriesFailure::element` is, so attempt 2 is /// `1`. Every text the library writes says attempts one-based. @@ -1653,14 +1681,29 @@ namespace detail template <Described R, std::size_t Max, FirstJudged J, typename Start, typename A, typename P> inline constexpr bool isRetry<Retry<R, Max, J, Start, A, P>> = true; - /// The dimension of whichever of @p L and @p Rt is a retry: its result's. + /// A retry is refused as an operand by the operators below, which see + /// through a bound formula; the operators over a bound formula + /// (`yields.hpp`) step aside for them. + template <Described R, std::size_t Max, FirstJudged J, typename Start, typename A, typename P> + inline constexpr bool refused_by_own_operators<Retry<R, Max, J, Start, A, P>> = true; + + /// Whether @p T is a retry as an operand: a retry, or a bound formula + /// that holds one. + template <typename T> + inline constexpr bool isRetryOperand = isRetry<operand_t<T>>; + + /// Whichever of @p L and @p Rt is a retry as an operand, as the retry + /// itself: what the operators below refuse, named as `.expression` would + /// name it. + template <typename L, typename Rt> + using RetriedOperand = std::conditional_t<isRetryOperand<L>, operand_t<L>, operand_t<Rt>>; + + /// The dimension of whichever of @p L and @p Rt is a retry as an operand: + /// its result's. template <typename L, typename Rt> [[nodiscard]] consteval Dimension retried_dimension() noexcept { - if constexpr (isRetry<L>) - return Describe<typename L::quantity>::dimension; - else - return Describe<typename Rt::quantity>::dimension; + return Describe<typename RetriedOperand<L, Rt>::quantity>::dimension; } /// Fails to compile when a retry is handed where a formula belongs: to @@ -1711,7 +1754,7 @@ namespace detail /// over two operands neither of which is a retry it has no `type`, a /// substitution failure: clang substitutes into the return type before /// it checks the operators' constraints. - template <typename L, typename Rt, bool = isRetry<L> || isRetry<Rt>> + template <typename L, typename Rt, bool = isRetryOperand<L> || isRetryOperand<Rt>> struct RefusedRetryResult { }; @@ -1782,48 +1825,51 @@ template <typename Tag, Described R, std::size_t Max, FirstJudged J, typename St } /// A retry in arithmetic, on either side of `+`, `-`, `*` or `/`, or negated: -/// refused in this library's words, giving a node refused already. +/// refused in this library's words, giving a node refused already. A bound +/// retry is refused here as the retry it holds is, and so is a retry beside a +/// bound formula: the operators over a bound formula (`yields.hpp`) step +/// aside for these. template <typename L, typename Rt> - requires(detail::isRetry<L> || detail::isRetry<Rt>) + requires(detail::isRetryOperand<L> || detail::isRetryOperand<Rt>) [[nodiscard]] constexpr auto operator+(L, Rt) noexcept -> typename detail::RefusedRetryResult<L, Rt>::type { - static_assert(detail::RequireRetryAtTop<std::conditional_t<detail::isRetry<L>, L, Rt>>::value); + static_assert(detail::RequireRetryAtTop<detail::RetriedOperand<L, Rt>>::value); return detail::refused_retry_value<L, Rt>(); } /// See `operator+` over a retry. template <typename L, typename Rt> - requires(detail::isRetry<L> || detail::isRetry<Rt>) + requires(detail::isRetryOperand<L> || detail::isRetryOperand<Rt>) [[nodiscard]] constexpr auto operator-(L, Rt) noexcept -> typename detail::RefusedRetryResult<L, Rt>::type { - static_assert(detail::RequireRetryAtTop<std::conditional_t<detail::isRetry<L>, L, Rt>>::value); + static_assert(detail::RequireRetryAtTop<detail::RetriedOperand<L, Rt>>::value); return detail::refused_retry_value<L, Rt>(); } /// See `operator+` over a retry. template <typename L, typename Rt> - requires(detail::isRetry<L> || detail::isRetry<Rt>) + requires(detail::isRetryOperand<L> || detail::isRetryOperand<Rt>) [[nodiscard]] constexpr auto operator*(L, Rt) noexcept -> typename detail::RefusedRetryResult<L, Rt>::type { - static_assert(detail::RequireRetryAtTop<std::conditional_t<detail::isRetry<L>, L, Rt>>::value); + static_assert(detail::RequireRetryAtTop<detail::RetriedOperand<L, Rt>>::value); return detail::refused_retry_value<L, Rt>(); } /// See `operator+` over a retry. template <typename L, typename Rt> - requires(detail::isRetry<L> || detail::isRetry<Rt>) + requires(detail::isRetryOperand<L> || detail::isRetryOperand<Rt>) [[nodiscard]] constexpr auto operator/(L, Rt) noexcept -> typename detail::RefusedRetryResult<L, Rt>::type { - static_assert(detail::RequireRetryAtTop<std::conditional_t<detail::isRetry<L>, L, Rt>>::value); + static_assert(detail::RequireRetryAtTop<detail::RetriedOperand<L, Rt>>::value); return detail::refused_retry_value<L, Rt>(); } /// See `operator+` over a retry. template <typename Operand> - requires(detail::isRetry<Operand>) + requires(detail::isRetryOperand<Operand>) [[nodiscard]] constexpr auto operator-(Operand) noexcept -> typename detail::RefusedRetryResult<Operand, Operand>::type { - static_assert(detail::RequireRetryAtTop<Operand>::value); + static_assert(detail::RequireRetryAtTop<detail::operand_t<Operand>>::value); return detail::refused_retry_value<Operand, Operand>(); } @@ -1848,7 +1894,7 @@ namespace detail /// whether a retry can be compared -- as `std::equality_comparable` /// does -- answers no, and is not the refusal; a class for the reason /// `RefusedRetryResult` is one. - template <Comparison Op, typename L, typename Rt, bool = isRetry<L> || isRetry<Rt>> + template <Comparison Op, typename L, typename Rt, bool = isRetryOperand<L> || isRetryOperand<Rt>> struct RefusedRetryComparison { }; @@ -1863,62 +1909,63 @@ namespace detail /// A retry compared, on either side of `<`, `<=`, `>`, `>=`, `==` or `!=`: /// refused in this library's words, as arithmetic over a retry is -- an /// acceptance is a comparison, so this is the likeliest place to write one. +/// A bound retry, and a retry beside a bound formula, as arithmetic over them. template <typename L, typename Rt> - requires(detail::isRetry<L> || detail::isRetry<Rt>) + requires(detail::isRetryOperand<L> || detail::isRetryOperand<Rt>) [[nodiscard]] constexpr auto operator<(L, Rt) noexcept -> typename detail::RefusedRetryComparison<Comparison::Less, L, Rt>::type { - static_assert(detail::RequireRetryAtTop<std::conditional_t<detail::isRetry<L>, L, Rt>>::value); + static_assert(detail::RequireRetryAtTop<detail::RetriedOperand<L, Rt>>::value); return detail::refused_retry_comparison<Comparison::Less, L, Rt>(); } /// See `operator<` over a retry. template <typename L, typename Rt> - requires(detail::isRetry<L> || detail::isRetry<Rt>) + requires(detail::isRetryOperand<L> || detail::isRetryOperand<Rt>) [[nodiscard]] constexpr auto operator<=(L, Rt) noexcept -> typename detail::RefusedRetryComparison<Comparison::LessOrEqual, L, Rt>::type { - static_assert(detail::RequireRetryAtTop<std::conditional_t<detail::isRetry<L>, L, Rt>>::value); + static_assert(detail::RequireRetryAtTop<detail::RetriedOperand<L, Rt>>::value); return detail::refused_retry_comparison<Comparison::LessOrEqual, L, Rt>(); } /// See `operator<` over a retry. template <typename L, typename Rt> - requires(detail::isRetry<L> || detail::isRetry<Rt>) + requires(detail::isRetryOperand<L> || detail::isRetryOperand<Rt>) [[nodiscard]] constexpr auto operator>(L, Rt) noexcept -> typename detail::RefusedRetryComparison<Comparison::Greater, L, Rt>::type { - static_assert(detail::RequireRetryAtTop<std::conditional_t<detail::isRetry<L>, L, Rt>>::value); + static_assert(detail::RequireRetryAtTop<detail::RetriedOperand<L, Rt>>::value); return detail::refused_retry_comparison<Comparison::Greater, L, Rt>(); } /// See `operator<` over a retry. template <typename L, typename Rt> - requires(detail::isRetry<L> || detail::isRetry<Rt>) + requires(detail::isRetryOperand<L> || detail::isRetryOperand<Rt>) [[nodiscard]] constexpr auto operator>=(L, Rt) noexcept -> typename detail::RefusedRetryComparison<Comparison::GreaterOrEqual, L, Rt>::type { - static_assert(detail::RequireRetryAtTop<std::conditional_t<detail::isRetry<L>, L, Rt>>::value); + static_assert(detail::RequireRetryAtTop<detail::RetriedOperand<L, Rt>>::value); return detail::refused_retry_comparison<Comparison::GreaterOrEqual, L, Rt>(); } /// See `operator<` over a retry. template <typename L, typename Rt> - requires(detail::isRetry<L> || detail::isRetry<Rt>) + requires(detail::isRetryOperand<L> || detail::isRetryOperand<Rt>) [[nodiscard]] constexpr auto operator==(L, Rt) noexcept -> typename detail::RefusedRetryComparison<Comparison::Equal, L, Rt>::type { - static_assert(detail::RequireRetryAtTop<std::conditional_t<detail::isRetry<L>, L, Rt>>::value); + static_assert(detail::RequireRetryAtTop<detail::RetriedOperand<L, Rt>>::value); return detail::refused_retry_comparison<Comparison::Equal, L, Rt>(); } /// See `operator<` over a retry. template <typename L, typename Rt> - requires(detail::isRetry<L> || detail::isRetry<Rt>) + requires(detail::isRetryOperand<L> || detail::isRetryOperand<Rt>) [[nodiscard]] constexpr auto operator!=(L, Rt) noexcept -> typename detail::RefusedRetryComparison<Comparison::NotEqual, L, Rt>::type { - static_assert(detail::RequireRetryAtTop<std::conditional_t<detail::isRetry<L>, L, Rt>>::value); + static_assert(detail::RequireRetryAtTop<detail::RetriedOperand<L, Rt>>::value); return detail::refused_retry_comparison<Comparison::NotEqual, L, Rt>(); } diff --git a/include/formula-cpp/rounded_root.hpp b/include/formula-cpp/rounded_root.hpp index 467c1b0a..62d98b12 100644 --- a/include/formula-cpp/rounded_root.hpp +++ b/include/formula-cpp/rounded_root.hpp @@ -38,6 +38,7 @@ #include <formula-cpp/rounding_node.hpp> #include <formula-cpp/sink.hpp> #include <formula-cpp/unit.hpp> +#include <formula-cpp/yields.hpp> #include <cstdint> #include <expected> @@ -324,6 +325,23 @@ template <DecimalRounding R, Node Radicand> return rounded_sqrt<R.unit, R.places, R.mode>(radicand); } +/// A bound formula as `rounded_sqrt`'s radicand: the formula it holds, in its +/// place (`yields.hpp`). +template <Unit U, DecimalPlaces Places, RoundingMode Mode, typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto rounded_sqrt(Bound boundRadicand) noexcept +{ + return rounded_sqrt<U, Places, Mode>(boundRadicand.expression); +} + +/// See the overload above, rounded as @p R names. +template <DecimalRounding R, typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto rounded_sqrt(Bound boundRadicand) noexcept +{ + return rounded_sqrt<R>(boundRadicand.expression); +} + /// 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/rounded_transcendental.hpp b/include/formula-cpp/rounded_transcendental.hpp index 89f6b4e4..b1ec3f16 100644 --- a/include/formula-cpp/rounded_transcendental.hpp +++ b/include/formula-cpp/rounded_transcendental.hpp @@ -29,6 +29,7 @@ #include <formula-cpp/rounding_node.hpp> #include <formula-cpp/sink.hpp> #include <formula-cpp/unit.hpp> +#include <formula-cpp/yields.hpp> #include <expected> #include <optional> @@ -133,6 +134,15 @@ template <DecimalPlaces Places, RoundingMode Mode, Node Operand> return RoundedTranscendentalNode<Transcendental::NaturalLogarithm, Places, Mode, Operand> { {}, operand }; } +/// A bound formula as `rounded_ln`'s operand: the formula it holds, in its +/// place (`yields.hpp`). +template <DecimalPlaces Places, RoundingMode Mode, typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto rounded_ln(Bound boundFormula) noexcept +{ + return rounded_ln<Places, Mode>(boundFormula.expression); +} + /// The decimal logarithm of `operand`, rounded exactly to `Places` decimal places. /// /// As for `rounded_ln`, every argument a `Rational` holds; a power of ten, 10^-38 up to 10^38, is answered @@ -143,6 +153,15 @@ template <DecimalPlaces Places, RoundingMode Mode, Node Operand> return RoundedTranscendentalNode<Transcendental::DecimalLogarithm, Places, Mode, Operand> { {}, operand }; } +/// A bound formula as `rounded_log10`'s operand: the formula it holds, in its +/// place (`yields.hpp`). +template <DecimalPlaces Places, RoundingMode Mode, typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto rounded_log10(Bound boundFormula) noexcept +{ + return rounded_log10<Places, Mode>(boundFormula.expression); +} + /// The exponential of `operand`, rounded exactly to `Places` decimal places. /// /// Checked in this order: an argument above 887/10 is `Overflow`, since e^x is then past the largest @@ -157,6 +176,15 @@ template <DecimalPlaces Places, RoundingMode Mode, Node Operand> return RoundedTranscendentalNode<Transcendental::Exponential, Places, Mode, Operand> { {}, operand }; } +/// A bound formula as `rounded_exp`'s operand: the formula it holds, in its +/// place (`yields.hpp`). +template <DecimalPlaces Places, RoundingMode Mode, typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto rounded_exp(Bound boundFormula) noexcept +{ + return rounded_exp<Places, Mode>(boundFormula.expression); +} + /// Evaluates the argument, then rounds `F` of it. Under `Rational` this is /// `detail::rounded_transcendental`, exact. Under any other representation it is that representation's /// function (`RepFunctions<Rep>`) followed by its own rounding (`RepRounding<Rep>::round_in`), which for diff --git a/include/formula-cpp/rounding_node.hpp b/include/formula-cpp/rounding_node.hpp index 7b4d8954..c3b40b5a 100644 --- a/include/formula-cpp/rounding_node.hpp +++ b/include/formula-cpp/rounding_node.hpp @@ -23,6 +23,7 @@ #include <formula-cpp/rounding.hpp> #include <formula-cpp/sink.hpp> #include <formula-cpp/unit.hpp> +#include <formula-cpp/yields.hpp> namespace formula { @@ -115,6 +116,23 @@ template <DecimalRounding R, Node Operand> return rounded<R.unit, R.places, R.mode>(toRound); } +/// A bound formula as `rounded`'s operand: the formula it holds, in its place +/// (`yields.hpp`). +template <Unit U, DecimalPlaces Places, RoundingMode Mode, typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto rounded(Bound boundFormula) noexcept +{ + return rounded<U, Places, Mode>(boundFormula.expression); +} + +/// See the overload above, rounded as @p R names. +template <DecimalRounding R, typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto rounded(Bound boundFormula) noexcept +{ + return rounded<R>(boundFormula.expression); +} + /// `operand` rounded to `Digits` significant digits of `U`. template <Unit U, SignificantDigits Digits, RoundingMode Mode, Node Operand> [[nodiscard]] constexpr auto rounded_to_digits(Operand operand) noexcept @@ -129,6 +147,23 @@ template <SignificantRounding S, Node Operand> return rounded_to_digits<S.unit, S.digits, S.mode>(toRound); } +/// A bound formula as `rounded_to_digits`'s operand: the formula it holds, in +/// its place (`yields.hpp`). +template <Unit U, SignificantDigits Digits, RoundingMode Mode, typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto rounded_to_digits(Bound boundFormula) noexcept +{ + return rounded_to_digits<U, Digits, Mode>(boundFormula.expression); +} + +/// See the overload above, rounded as @p S names. +template <SignificantRounding S, typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto rounded_to_digits(Bound boundFormula) noexcept +{ + return rounded_to_digits<S>(boundFormula.expression); +} + /// 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 bb9ca4c8..7d3e65a3 100644 --- a/include/formula-cpp/series.hpp +++ b/include/formula-cpp/series.hpp @@ -689,6 +689,15 @@ template <CumulativeDirection D, Node N> return detail::RefusedSeries<N::dimension> {}; } +/// A bound formula as `cumulative`'s series: the formula it holds, in its place +/// (`yields.hpp`). +template <CumulativeDirection D, typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto cumulative(Bound boundSeries) noexcept +{ + return cumulative<D>(boundSeries.expression); +} + namespace detail { /// Fails to compile when `rounded_elementwise` is given a single value. @@ -723,6 +732,23 @@ template <DecimalRounding R, Node N> return detail::RefusedSeries<N::dimension> {}; } +/// A bound formula as `rounded_elementwise`'s series: the formula it holds, in +/// its place (`yields.hpp`). +template <Unit U, auto Places, RoundingMode Mode, typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto rounded_elementwise(Bound boundSeries) noexcept +{ + return rounded_elementwise<U, Places, Mode>(boundSeries.expression); +} + +/// See the overload above, rounded as @p R names. +template <DecimalRounding R, typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto rounded_elementwise(Bound boundSeries) noexcept +{ + return rounded_elementwise<R>(boundSeries.expression); +} + /// 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) / @@ -762,6 +788,15 @@ template <Node N> return singleValue; } +/// A bound formula as `sum`'s series: the formula it holds, in its place +/// (`yields.hpp`). +template <typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto sum(Bound boundSeries) noexcept +{ + return sum(boundSeries.expression); +} + /// An evaluated series in the coherent unit of its dimension: one value per /// element, each absent when the element was never measured. template <typename Rep, std::size_t N> diff --git a/include/formula-cpp/sink.hpp b/include/formula-cpp/sink.hpp index a7d462c3..a903d7be 100644 --- a/include/formula-cpp/sink.hpp +++ b/include/formula-cpp/sink.hpp @@ -63,8 +63,8 @@ concept SinkFor = requires(S sink, N const& node, V const& value) { /// /// A sink defines both or neither: the evaluator asks for the pair in one /// `requires` (`detail::HearsSeries`), so a sink defining only one is told -/// nothing. This sink defines neither and pays nothing, and a sink written -/// before series existed keeps compiling and is told nothing about them. +/// nothing. This sink defines neither and pays nothing, and any sink that +/// defines neither compiles and is told nothing about series. /// `RecordingSink` (`trace.hpp`) defines both. /// /// **Optional hooks.** Each is asked for through its own `requires`, so a diff --git a/include/formula-cpp/snap.hpp b/include/formula-cpp/snap.hpp index cdea49dd..c3586e3c 100644 --- a/include/formula-cpp/snap.hpp +++ b/include/formula-cpp/snap.hpp @@ -27,6 +27,7 @@ #include <formula-cpp/rational.hpp> #include <formula-cpp/sink.hpp> #include <formula-cpp/unit.hpp> +#include <formula-cpp/yields.hpp> #include <cstddef> #include <cstdint> @@ -197,6 +198,15 @@ template <Unit KeyUnit, BreakpointTable Permitted, SnapTie Tie, Node Operand> return SnapNode<KeyUnit, Permitted, Tie, Operand> { {}, snappedOperand }; } +/// A bound formula as `snapped`'s operand: the formula it holds, in its place +/// (`yields.hpp`). +template <Unit KeyUnit, BreakpointTable Permitted, SnapTie Tie, typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto snapped(Bound boundFormula) noexcept +{ + return snapped<KeyUnit, Permitted, Tie>(boundFormula.expression); +} + /// Evaluates the operand, converts it into `KeyUnit`, and snaps it /// (`detail::locate_and_snap`). Absence and a failed operand pass through; a /// value outside the set is `DomainError`. `Rational` only: deciding a tie diff --git a/include/formula-cpp/statistics.hpp b/include/formula-cpp/statistics.hpp index 64c7e0dd..3212bf49 100644 --- a/include/formula-cpp/statistics.hpp +++ b/include/formula-cpp/statistics.hpp @@ -37,6 +37,7 @@ #include <formula-cpp/series.hpp> #include <formula-cpp/sink.hpp> #include <formula-cpp/unit.hpp> +#include <formula-cpp/yields.hpp> #include <array> #include <cstddef> @@ -254,6 +255,15 @@ template <Node N> return ConstantNode<unit::One> { {}, Rational { 1 } }; } +/// A bound formula as `sample_count`'s sample: the formula it holds, in its +/// place (`yields.hpp`). +template <typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto sample_count(Bound boundSample) noexcept +{ + return sample_count(boundSample.expression); +} + /// The mean of @p sampleSource: `sample_mean(series<Mass, 6>)`. template <SampleSource S> [[nodiscard]] constexpr auto sample_mean(S sampleSource) noexcept @@ -271,6 +281,15 @@ template <Node N> return singleValue; } +/// A bound formula as `sample_mean`'s sample: the formula it holds, in its +/// place (`yields.hpp`). +template <typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto sample_mean(Bound boundSample) noexcept +{ + return sample_mean(boundSample.expression); +} + /// The sample variance: the squared deviations from the mean, totalled and /// divided by n - 1 -- the **sample** variance, as the name says, not the /// population's. In the square of the determinations' dimension. Absent when @@ -329,6 +348,15 @@ template <Node N> return singleValue * singleValue; } +/// A bound formula as `sample_variance`'s sample: the formula it holds, in its +/// place (`yields.hpp`). +template <typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto sample_variance(Bound boundSample) noexcept +{ + return sample_variance(boundSample.expression); +} + /// The range of @p sampleSource: `sample_range(series<Mass, 6>)`. template <SampleSource S> [[nodiscard]] constexpr auto sample_range(S sampleSource) noexcept @@ -346,6 +374,15 @@ template <Node N> return singleValue; } +/// A bound formula as `sample_range`'s sample: the formula it holds, in its +/// place (`yields.hpp`). +template <typename Bound> + requires detail::AnyBound<Bound> +[[nodiscard]] constexpr auto sample_range(Bound boundSample) noexcept +{ + return sample_range(boundSample.expression); +} + namespace detail { /// Tells @p sink the zero-based position of the determination at which a diff --git a/include/formula-cpp/trace.hpp b/include/formula-cpp/trace.hpp index 3b16ce06..f7337ad4 100644 --- a/include/formula-cpp/trace.hpp +++ b/include/formula-cpp/trace.hpp @@ -578,7 +578,8 @@ inline constexpr bool formats_by_describe<Branch> = true; /// /// Zero-initialises to `Recorded`, which is right for both sides of every /// step with two operands, and for every step that is not binary; a step -/// built by hand therefore renders as it did before these existed. +/// built by hand that leaves both sides unset therefore renders every +/// operand from `Step::operands`. /// /// `RecordingSink` fills `Step::leftOperand` and `Step::rightOperand` in; /// like every other field of a `Step`, they are public data, and a side set @@ -1019,9 +1020,9 @@ struct Step /// A second unit this step needs to name, which is **not** the unit its /// own value is in. Kept separate from `unit` above for the reason /// documented there: this one deliberately does not share `dimension`, so - /// it is never used to convert `value` -- it is read only by the - /// renderer. A default-constructed `Unit` (dimension `Scalar`, empty - /// symbol) for every step that has no second unit to name. + /// nothing converts `value` with it -- it is read only by the renderer. A + /// default-constructed `Unit` (dimension `Scalar`, empty symbol) for every + /// step that has no second unit to name. /// /// - For `NumericValue`: the unit the escape hatch read its number in -- /// `Megapascal` for `numeric_value_of<Megapascal, "...">(...)`. @@ -3266,7 +3267,7 @@ class RecordingSink // Anything computed has no declared unit, so the coherent one is // the truthful answer -- until the rules below borrow one from the // operand steps; a variable overrides it with the unit its - // quantity is declared in. `requires { N::unit; }` now also selects + // quantity is declared in. `requires { N::unit; }` also selects // `ConstantNode<U>`, `RoundNode`, `RoundSignificantNode`, // `RoundedRootNode` and `RoundedOpaqueOutputNode` -- every one of them // declares a unit that is the single most load-bearing fact about the diff --git a/include/formula-cpp/trace_render.hpp b/include/formula-cpp/trace_render.hpp index 5ec7d66c..7c11d236 100644 --- a/include/formula-cpp/trace_render.hpp +++ b/include/formula-cpp/trace_render.hpp @@ -461,14 +461,14 @@ namespace detail /// A `Conditional` step, in the same infix shape `render()` gives the /// `WhenNode` it came from: `if #1 > #2 then #3`. /// - /// It used to read `when(#1, #2, #3)`, which is positionally identical to - /// the public `when(predicate, thenBranch, elseBranch)` and means - /// something else entirely -- `(predicate lhs, predicate rhs, the branch - /// that ran)`. A reader who had just met the API mapped the three slots - /// onto it and concluded the *then* value was the second one, then read a - /// `[then]` suffix next to a number that came from the third. A notation - /// that needs prose to decode is not a smaller version of the problem; it - /// is the problem. This shape needs none: it is the one `render()` + /// Not `when(#1, #2, #3)`, which is positionally identical to the public + /// `when(predicate, thenBranch, elseBranch)` and means something else + /// entirely -- `(predicate lhs, predicate rhs, the branch that ran)`. A + /// reader who has just met the API would map the three slots onto it and + /// conclude the *then* value is the second one, then would read a `[then]` + /// suffix next to a number that came from the third. A notation that needs + /// prose to decode is not a smaller version of the problem; it is the + /// problem. This shape needs none: it is the one `render()` /// already writes, minus the branch that did not run. /// /// **Three arities, not two.** `branch != Branch::Neither` exactly when a @@ -2202,8 +2202,7 @@ namespace detail /// A verdict's second clause, after a semicolon: `; the method's own /// constraint`, or `; jurisdiction overlay: ...`. Empty for a constraint /// checked outside any method, which is no one's -- so a trace of - /// `check` or `check_all` reads exactly as it did before methods had - /// constraints. + /// `check` or `check_all` carries no provenance clause at all. /// /// A semicolon, not a comma, for `rounding_rule_suffix`'s reason: a cited /// source has commas of its own. diff --git a/include/formula-cpp/vocabulary.hpp b/include/formula-cpp/vocabulary.hpp index 62bd571c..3d37706d 100644 --- a/include/formula-cpp/vocabulary.hpp +++ b/include/formula-cpp/vocabulary.hpp @@ -21,7 +21,7 @@ /// it: every quantity it does not rename is written as `Describe<Q>::symbol` /// says, and `DefaultVocabulary`, which renames nothing, is the default /// argument of every surface that takes one -- so a caller who never names a -/// vocabulary gets exactly the text they got before one existed. +/// vocabulary gets every symbol exactly as `Describe<Q>::symbol` spells it. /// /// **Only the symbol changes, never the meaning.** A vocabulary renames how a /// quantity is *written*; its description and its unit are properties of the diff --git a/include/formula-cpp/yields.hpp b/include/formula-cpp/yields.hpp index 491a124e..8280db80 100644 --- a/include/formula-cpp/yields.hpp +++ b/include/formula-cpp/yields.hpp @@ -6,9 +6,19 @@ /// is written: `constexpr auto ratio = yields<Gradient>(var<Rise> / var<Run>);` /// 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`. +/// quantity (`evaluate.hpp`) -- but only once. A `Yields` is not a node: a +/// verb takes it as the top of a formula. +/// +/// Used as part of another formula, a bound formula stands for the formula it +/// holds: `var<Load> / loadedArea` is `var<Load> / loadedArea.expression`, of +/// that type and value, on either side of every arithmetic operator and +/// comparison, and so is a bound formula handed to any function that builds a +/// formula -- `sqrt`, `rounded`, `documented`, `sum`, a lookup's key, ... -- +/// each of which has an overload for one that hands on `.expression`. The +/// outer formula renders, traces and documents as that spelling does: the +/// bound quantity's name is not part of it, and neither is the binding, so +/// `documented(boundFormula, citation)` documents the formula and is not +/// bound itself. /// /// Every verb that is told a result quantity takes a `Yields` in place of the /// expression and the quantity: `evaluate` and `checked_evaluate` here, @@ -38,10 +48,6 @@ /// `define` -- in words naming the verbs that take it /// (`detail::RequireSingleValueBound`), once. `define` refuses a series in /// its own words, as `define<Q>` does. -/// -/// **Refused as an operand:** a `Yields` on either side of `+`, `-`, `*` or -/// `/`, negated, or on either side of `<`, `<=`, `>`, `>=`, `==` or `!=` -/// (`detail::RequireBoundNotOperand`). Use its `.expression`. #include <formula-cpp/error.hpp> #include <formula-cpp/evaluate.hpp> @@ -75,9 +81,9 @@ namespace detail inline constexpr bool is_yields<Yields<Q, E>> = 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. + /// verb takes a `Yields` as the top of a formula; 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 <typename E> struct RequireFormulaNotBound { @@ -110,9 +116,9 @@ namespace detail /// 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. + /// once that check has failed, clang-cl 22.1.8 compiles both branches of + /// a gate that asks the value -- `define`'s, whose two branches return + /// different types, so asking it there would add an error of its own. template <typename Result, typename Q> inline constexpr bool names_yields_result = std::is_same_v<Result, ResultOfYields> || std::is_same_v<Result, Q>; @@ -243,208 +249,177 @@ template <typename Result = detail::ResultOfYields, Described Q, typename E, typ namespace detail { - /// Fails to compile when a bound formula is an operand of `+`, `-`, `*` - /// or `/`, or of a comparison. 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 <typename Bound> - struct RequireBoundNotOperand - { - static_assert(!is_yields<Bound>, - "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 <typename L, typename R> - [[nodiscard]] consteval Dimension bound_operand_dimension() noexcept + /// Whether any of @p Ts is a bound formula: what every function that + /// builds a formula asks before it takes the formula a bound one holds in + /// its place. + template <typename... Ts> + concept AnyBound = (is_yields<Ts> || ...); + + /// @p operand as part of another formula: the formula it holds, for a + /// bound formula, and anything else as it is. + template <typename T> + [[nodiscard]] constexpr auto const& as_operand(T const& operand) noexcept { - if constexpr (is_yields<L>) - return Describe<typename L::quantity>::dimension; + if constexpr (is_yields<T>) + return operand.expression; else - return Describe<typename R::quantity>::dimension; + return operand; } - /// 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 <Dimension D> - struct RefusedBoundValue: NodeBase + /// The type `as_operand` gives for @p T: the formula a bound formula + /// holds, and @p T itself otherwise. + template <typename T> + struct OperandOf { - /// 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; + using type = T; }; - /// 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 <typename L, typename R, bool = is_yields<L> || is_yields<R>> - struct RefusedBoundResult + template <Described Q, typename E> + struct OperandOf<Yields<Q, E>> { + using type = E; }; + /// `OperandOf<T>::type`. + template <typename T> + using operand_t = typename OperandOf<T>::type; + + /// Whether @p T, as an operand, is refused by operators of its own that + /// see through a bound formula: a retry (`retry.hpp`). The operators below + /// step aside for those, so that a bound retry, and a retry beside a bound + /// formula, are refused as the formula held would be, and asking whether + /// one can be compared answers no rather than firing the refusal. + template <typename T> + inline constexpr bool refused_by_own_operators = false; + + /// Whether the operators below take @p L and @p R: one is a bound + /// formula, and neither is, or holds, an operand refused by operators of + /// its own. template <typename L, typename R> - struct RefusedBoundResult<L, R, true> - { - /// The refused value. - using type = RefusedBoundValue<bound_operand_dimension<L, R>()>; + concept ForwardsBound = + AnyBound<L, R> && !refused_by_own_operators<operand_t<L>> && !refused_by_own_operators<operand_t<R>>; + + /// What `==` and `!=` over a bound formula require, spelled once so that + /// the two operators keep equivalent declarations: a comparison of the + /// formulas held that both operators over formulas take. + template <typename L, typename R> + concept ComparesBound = ForwardsBound<L, R> && requires(L const& lhs, R const& rhs) { + as_operand(lhs) == as_operand(rhs); + as_operand(lhs) != as_operand(rhs); }; } // namespace detail -/// A refused bound operand evaluates to nothing but `DomainError`; a program -/// holding one never compiles, so this is never seen. -template <typename Rep = Rational, Dimension D, typename Env, typename Sink = NullSink> -[[nodiscard]] constexpr Evaluated<Rep> checked_evaluate_si(detail::RefusedBoundValue<D> const&, - Env const&, - Sink = {}) noexcept -{ - return Evaluated<Rep> { 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. +/// negated: the formula it holds stands in its place, so the result is the +/// one `.expression` gives, of its type, and refused where that one is, in +/// its words. Only an operand that is a `Yields` reaches these, so +/// arithmetic over formulas is untouched. +/// +/// Each takes only what the operator over the formulas held takes, so asking +/// whether a bound formula combines with something those do not -- as a +/// concept does -- answers no, as it does for the formula held. The return +/// type is deduced, as the operators over formulas deduce theirs. template <typename L, typename R> - requires(detail::is_yields<L> || detail::is_yields<R>) -[[nodiscard]] constexpr auto operator+(L, R) noexcept -> typename detail::RefusedBoundResult<L, R>::type + requires detail::ForwardsBound<L, R> + && requires(L const& lhs, R const& rhs) { detail::as_operand(lhs) + detail::as_operand(rhs); } +[[nodiscard]] constexpr auto operator+(L lhs, R rhs) noexcept { - static_assert(detail::RequireBoundNotOperand<std::conditional_t<detail::is_yields<L>, L, R>>::value); - return {}; + return detail::as_operand(lhs) + detail::as_operand(rhs); } /// See `operator+` over a bound formula. template <typename L, typename R> - requires(detail::is_yields<L> || detail::is_yields<R>) -[[nodiscard]] constexpr auto operator-(L, R) noexcept -> typename detail::RefusedBoundResult<L, R>::type + requires detail::ForwardsBound<L, R> + && requires(L const& lhs, R const& rhs) { detail::as_operand(lhs) - detail::as_operand(rhs); } +[[nodiscard]] constexpr auto operator-(L lhs, R rhs) noexcept { - static_assert(detail::RequireBoundNotOperand<std::conditional_t<detail::is_yields<L>, L, R>>::value); - return {}; + return detail::as_operand(lhs) - detail::as_operand(rhs); } /// See `operator+` over a bound formula. template <typename L, typename R> - requires(detail::is_yields<L> || detail::is_yields<R>) -[[nodiscard]] constexpr auto operator*(L, R) noexcept -> typename detail::RefusedBoundResult<L, R>::type + requires detail::ForwardsBound<L, R> + && requires(L const& lhs, R const& rhs) { detail::as_operand(lhs) * detail::as_operand(rhs); } +[[nodiscard]] constexpr auto operator*(L lhs, R rhs) noexcept { - static_assert(detail::RequireBoundNotOperand<std::conditional_t<detail::is_yields<L>, L, R>>::value); - return {}; + return detail::as_operand(lhs) * detail::as_operand(rhs); } /// See `operator+` over a bound formula. template <typename L, typename R> - requires(detail::is_yields<L> || detail::is_yields<R>) -[[nodiscard]] constexpr auto operator/(L, R) noexcept -> typename detail::RefusedBoundResult<L, R>::type + requires detail::ForwardsBound<L, R> + && requires(L const& lhs, R const& rhs) { detail::as_operand(lhs) / detail::as_operand(rhs); } +[[nodiscard]] constexpr auto operator/(L lhs, R rhs) noexcept { - static_assert(detail::RequireBoundNotOperand<std::conditional_t<detail::is_yields<L>, L, R>>::value); - return {}; + return detail::as_operand(lhs) / detail::as_operand(rhs); } /// See `operator+` over a bound formula. template <typename Operand> - requires(detail::is_yields<Operand>) -[[nodiscard]] constexpr auto operator-(Operand) noexcept -> typename detail::RefusedBoundResult<Operand, Operand>::type + requires detail::ForwardsBound<Operand, Operand> && requires(Operand const& operand) { -detail::as_operand(operand); } +[[nodiscard]] constexpr auto operator-(Operand operand) noexcept { - static_assert(detail::RequireBoundNotOperand<Operand>::value); - return {}; + return -detail::as_operand(operand); } -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 <Comparison Op, typename L, typename R, bool = is_yields<L> || is_yields<R>> - struct RefusedBoundComparison - { - }; - - template <Comparison Op, typename L, typename R> - struct RefusedBoundComparison<Op, L, R, true> - { - /// The refused comparison. - using type = PredicateNode<Op, - RefusedBoundValue<bound_operand_dimension<L, R>()>, - RefusedBoundValue<bound_operand_dimension<L, R>()>>; - }; -} // 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. +/// or `!=`: the formula it holds is compared, as arithmetic over it takes +/// that formula -- so an acceptance or a constraint over a bound formula +/// checks what one over `.expression` checks. Only an operand that is a +/// `Yields` reaches these, so comparisons of formulas are untouched; and, as +/// for arithmetic, each takes only what the comparison of the formulas held +/// takes. template <typename L, typename R> - requires(detail::is_yields<L> || detail::is_yields<R>) -[[nodiscard]] constexpr auto operator<(L, R) noexcept -> - typename detail::RefusedBoundComparison<Comparison::Less, L, R>::type + requires detail::ForwardsBound<L, R> + && requires(L const& lhs, R const& rhs) { detail::as_operand(lhs) < detail::as_operand(rhs); } +[[nodiscard]] constexpr auto operator<(L lhs, R rhs) noexcept { - static_assert(detail::RequireBoundNotOperand<std::conditional_t<detail::is_yields<L>, L, R>>::value); - return { {}, {} }; + return detail::as_operand(lhs) < detail::as_operand(rhs); } /// See `operator<` over a bound formula. template <typename L, typename R> - requires(detail::is_yields<L> || detail::is_yields<R>) -[[nodiscard]] constexpr auto operator<=(L, R) noexcept -> - typename detail::RefusedBoundComparison<Comparison::LessOrEqual, L, R>::type + requires detail::ForwardsBound<L, R> + && requires(L const& lhs, R const& rhs) { detail::as_operand(lhs) <= detail::as_operand(rhs); } +[[nodiscard]] constexpr auto operator<=(L lhs, R rhs) noexcept { - static_assert(detail::RequireBoundNotOperand<std::conditional_t<detail::is_yields<L>, L, R>>::value); - return { {}, {} }; + return detail::as_operand(lhs) <= detail::as_operand(rhs); } /// See `operator<` over a bound formula. template <typename L, typename R> - requires(detail::is_yields<L> || detail::is_yields<R>) -[[nodiscard]] constexpr auto operator>(L, R) noexcept -> - typename detail::RefusedBoundComparison<Comparison::Greater, L, R>::type + requires detail::ForwardsBound<L, R> + && requires(L const& lhs, R const& rhs) { detail::as_operand(lhs) > detail::as_operand(rhs); } +[[nodiscard]] constexpr auto operator>(L lhs, R rhs) noexcept { - static_assert(detail::RequireBoundNotOperand<std::conditional_t<detail::is_yields<L>, L, R>>::value); - return { {}, {} }; + return detail::as_operand(lhs) > detail::as_operand(rhs); } /// See `operator<` over a bound formula. template <typename L, typename R> - requires(detail::is_yields<L> || detail::is_yields<R>) -[[nodiscard]] constexpr auto operator>=(L, R) noexcept -> - typename detail::RefusedBoundComparison<Comparison::GreaterOrEqual, L, R>::type + requires detail::ForwardsBound<L, R> + && requires(L const& lhs, R const& rhs) { detail::as_operand(lhs) >= detail::as_operand(rhs); } +[[nodiscard]] constexpr auto operator>=(L lhs, R rhs) noexcept { - static_assert(detail::RequireBoundNotOperand<std::conditional_t<detail::is_yields<L>, L, R>>::value); - return { {}, {} }; + return detail::as_operand(lhs) >= detail::as_operand(rhs); } -/// See `operator<` over a bound formula. +/// See `operator<` over a bound formula. `==` and `!=` share their +/// constraint, `detail::ComparesBound`, so that the two declarations stay +/// equivalent and `==` is never weighed reversed. template <typename L, typename R> - requires(detail::is_yields<L> || detail::is_yields<R>) -[[nodiscard]] constexpr auto operator==(L, R) noexcept -> - typename detail::RefusedBoundComparison<Comparison::Equal, L, R>::type + requires detail::ComparesBound<L, R> +[[nodiscard]] constexpr auto operator==(L lhs, R rhs) noexcept { - static_assert(detail::RequireBoundNotOperand<std::conditional_t<detail::is_yields<L>, L, R>>::value); - return { {}, {} }; + return detail::as_operand(lhs) == detail::as_operand(rhs); } -/// See `operator<` over a bound formula. +/// See `operator==` over a bound formula. template <typename L, typename R> - requires(detail::is_yields<L> || detail::is_yields<R>) -[[nodiscard]] constexpr auto operator!=(L, R) noexcept -> - typename detail::RefusedBoundComparison<Comparison::NotEqual, L, R>::type + requires detail::ComparesBound<L, R> +[[nodiscard]] constexpr auto operator!=(L lhs, R rhs) noexcept { - static_assert(detail::RequireBoundNotOperand<std::conditional_t<detail::is_yields<L>, L, R>>::value); - return { {}, {} }; + return detail::as_operand(lhs) != detail::as_operand(rhs); } } // namespace formula diff --git a/mkdocs.yml b/mkdocs.yml index b0126de5..245ee3a9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -11,6 +11,7 @@ theme: name: material features: - navigation.sections + - navigation.indexes - content.code.copy markdown_extensions: - admonition @@ -23,6 +24,18 @@ markdown_extensions: # configuration is set up to read. - pymdownx.arithmatex: generic: true + # The tutorial includes its code and output from the programs CTest builds + # and runs (examples/tutorial/), so a published page cannot drift from what + # compiles. Paths resolve from the repository root, where `mkdocs build` + # runs; check_paths turns a missing file or a missing + # `--8<-- [start:name]` section into a build error, which --strict in + # .github/workflows/pages.yml then fails on. dedent_subsections strips a + # section's common indentation, so a region taken from inside main() starts + # at the left margin rather than four spaces in. + - pymdownx.snippets: + base_path: ["."] + check_paths: true + dedent_subsections: true extra_javascript: - javascripts/mathjax.js # Pinned, in keeping with this repository's own habit of pinning its other @@ -30,23 +43,41 @@ extra_javascript: - https://cdn.jsdelivr.net/npm/mathjax@3.2.2/es5/tex-mml-chtml.js nav: - Home: index.md - - Numbers: numbers.md - - Numeric headroom: numeric-headroom.md - - Dimensions and units: dimensions.md - - Quantities and measurements: quantities.md - - Expressions and evaluation: expressions.md - - Citations and rendering: citations.md - - Tracing and audit trails: tracing.md - - Displaying numbers: display.md - - Calculations and worksheets: calculations.md - - Rounding and conditionals: rounding-and-conditionals.md - - Constraints and verdicts: constraints.md - - Lookup tables: lookup-tables.md - - Methods and overlays: methods-and-overlays.md - - Series and grading curves: series.md - - Statistics, outliers and precision: statistics.md - - Other samples and other tests: records.md - - Opaque operations and bounded retry: opaque-and-retry.md + - Tutorial: + - tutorial/index.md + - 1. First formula: tutorial/01-first-formula.md + - 2. Units and dimensions: tutorial/02-units-and-dimensions.md + - 3. Exact numbers: tutorial/03-exact-numbers.md + - 4. Missing and entered values: tutorial/04-missing-and-entered.md + - 5. Rounding: tutorial/05-rounding.md + - 6. Constraints: tutorial/06-constraints.md + - 7. Citations, rendering and documentation: tutorial/07-documentation.md + - 8. Tracing: tutorial/08-tracing.md + - 9. Calculations and worksheets: tutorial/09-worksheets.md + - 10. Lookup tables: tutorial/10-lookup-tables.md + - 11. Methods and overlays: tutorial/11-methods-and-overlays.md + - 12. Series: tutorial/12-series.md + - 13. Statistics: tutorial/13-statistics.md + - 14. Records: tutorial/14-records.md + - 15. Opaque operations and bounded retry: tutorial/15-opaque-and-retry.md + - Guides: + - Numbers: numbers.md + - Numeric headroom: numeric-headroom.md + - Dimensions and units: dimensions.md + - Quantities and measurements: quantities.md + - Expressions and evaluation: expressions.md + - Citations and rendering: citations.md + - Tracing and audit trails: tracing.md + - Displaying numbers: display.md + - Calculations and worksheets: calculations.md + - Rounding and conditionals: rounding-and-conditionals.md + - Constraints and verdicts: constraints.md + - Lookup tables: lookup-tables.md + - Methods and overlays: methods-and-overlays.md + - Series and grading curves: series.md + - Statistics, outliers and precision: statistics.md + - Other samples and other tests: records.md + - Opaque operations and bounded retry: opaque-and-retry.md - Gallery: gallery.md # Doxygen output, dropped into the built site's api/ directory by # .github/workflows/pages.yml after `mkdocs build` runs -- not a page diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index da658b07..e5d0b934 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -342,6 +342,11 @@ formula_add_negative_test(unit_dimension_mismatch formula_add_negative_test(unit_currency_mismatch "formula: these two units measure different dimensions" EXPECT_COUNT 1) +# docs/tutorial/02-units-and-dimensions.md includes this file's mistake. +formula_add_negative_test(tutorial_load_plus_area + "formula: the two sides of this addition or subtraction measure different dimensions" + EXPECT_COUNT 1) + # A dimensionless unit with a scale and no symbol: refused where a quantity # declared in it is read or measured, where a constant is stated in it, and # where a table is keyed in it -- once each. @@ -602,12 +607,29 @@ 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 +# A bound formula used as part of another stands for the formula it holds, so +# a dimensional mistake through one is refused once, in the words that +# formula draws. +formula_add_negative_test(yields_operand_dimension_mismatch + "formula: the two sides of this addition or subtraction measure different dimensions" 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 +formula_add_negative_test(yields_comparand_dimension_mismatch + "formula: the two sides of this comparison measure different dimensions" EXPECT_COUNT 1 REJECT "no matching" "invalid operands" "no match for") +# The same for the functions that build a formula, where the formula held is +# refused: a replacement with no citation, a fit of no curve, a fit of +# observations with no citation. +formula_add_negative_test(yields_replacement_without_citation + "formula: replace_variant<Tag>(expression) was given no citation" EXPECT_COUNT 1 + REJECT "no matching") +formula_add_negative_test(yields_fit_not_a_curve + "formula: linear_least_squares fits a curve, or points and values read as observations; pair a domain series and a value series with curve(domain, values), or read both with observations<Q, Capacity>" + EXPECT_COUNT 1 + REJECT "no matching overloaded function" "no matching function" "RequireResultDimension") +formula_add_negative_test(yields_fit_observations_uncited + "formula: linear_least_squares needs a citation, the reason the method fits a line here; pass {} when it gives none" + EXPECT_COUNT 1 + REJECT "no matching overloaded function" "no matching function" "RequireResultDimension" "has no output of that name") 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") @@ -2947,6 +2969,8 @@ add_test(NAME hygiene.version COMMAND "${CMAKE_COMMAND}" -D "VERSION_HEADER=${PROJECT_SOURCE_DIR}/include/formula-cpp/version.hpp" -D "PROJECT_VERSION=${PROJECT_VERSION}" + -D "README=${PROJECT_SOURCE_DIR}/README.md" + -D "TUTORIAL=${PROJECT_SOURCE_DIR}/docs/tutorial/01-first-formula.md" -P "${PROJECT_SOURCE_DIR}/cmake/CheckVersionConsistency.cmake") add_test(NAME hygiene.spdx @@ -3009,6 +3033,21 @@ add_test(NAME hygiene.no-real-standards-fallback -D "WORK_DIR=${CMAKE_CURRENT_BINARY_DIR}/no-real-standards-fallback" -P "${PROJECT_SOURCE_DIR}/cmake/TestNoRealStandardsFallback.cmake") +# The current-state check, tested on a tree written to break it. +add_test(NAME hygiene.docs-current-state-self-test + COMMAND "${CMAKE_COMMAND}" + -D "CHECK_SCRIPT=${PROJECT_SOURCE_DIR}/cmake/CheckDocsCurrentState.cmake" + -D "WORK_DIR=${CMAKE_CURRENT_BINARY_DIR}/docs-current-state-self-test" + -P "${PROJECT_SOURCE_DIR}/cmake/TestDocsCurrentState.cmake") + +# User documentation -- README.md, docs/ (not docs/superpowers/) and the doc +# comments that form the API reference -- describes the library as it is now. +# History belongs in CHANGELOG.md. See CONTRIBUTING.md, "Documentation". +add_test(NAME hygiene.docs-current-state + COMMAND "${CMAKE_COMMAND}" + -D "SOURCE_DIR=${PROJECT_SOURCE_DIR}" + -P "${PROJECT_SOURCE_DIR}/cmake/CheckDocsCurrentState.cmake") + # A compiler diagnostic quoted in docs/ names a header and a line number, and # the number goes stale the moment anyone inserts a line above it -- which # happened twice in this repository before anything checked. See the script's diff --git a/test/fixtures/readme-wrong.expected.txt b/test/fixtures/readme-wrong.expected.txt new file mode 100644 index 00000000..1ac30744 --- /dev/null +++ b/test/fixtures/readme-wrong.expected.txt @@ -0,0 +1 @@ +f_c = 31 MPa diff --git a/test/negative/tutorial_load_plus_area.cpp b/test/negative/tutorial_load_plus_area.cpp new file mode 100644 index 00000000..5b1d3958 --- /dev/null +++ b/test/negative/tutorial_load_plus_area.cpp @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 +// Tutorial, chapter 2: a load plus an area measures nothing, so it does not +// compile. docs/tutorial/02-units-and-dimensions.md includes the line below. +#include <formula-cpp/formula.hpp> + +using Load = formula::Quantity<struct LoadTag, "F", "maximum load", formula::unit::Kilonewton>; +using Area = formula::Quantity<struct AreaTag, "A_c", "loaded area", formula::unit::SquareMillimetre>; + +// --8<-- [start:mistake] +constexpr auto broken = formula::var<Load> + formula::var<Area>; +// --8<-- [end:mistake] + +int main() +{ + return 0; +} diff --git a/test/negative/yields_as_operand.cpp b/test/negative/yields_as_operand.cpp deleted file mode 100644 index 75782be4..00000000 --- a/test/negative/yields_as_operand.cpp +++ /dev/null @@ -1,32 +0,0 @@ -// 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 road gradient, used as an operand of another -// 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 <formula-cpp/document.hpp> -#include <formula-cpp/formula.hpp> -#include <formula-cpp/render.hpp> - -using Rise = formula::Quantity<struct RiseTag, "h", "height gained", formula::unit::Millimetre>; -using Run = formula::Quantity<struct RunTag, "L", "horizontal distance covered", formula::unit::Millimetre>; -using Gradient = formula::Quantity<struct GradientTag, "s", "road gradient", formula::unit::One>; - -inline constexpr auto inputs = - formula::environment(formula::Measured<Rise> { 163 }, formula::Measured<Run> { 307 }); - -int main() -{ - constexpr auto ratio = formula::yields<Gradient>(formula::var<Rise> / formula::var<Run>); - constexpr auto misused = formula::var<Rise> * ratio; - auto const shown = formula::render(misused); - auto const written = formula::document(misused); - return formula::checked_evaluate<Rise>(misused, inputs).has_value() && !shown.empty() && !written.formula.empty() - ? 0 - : 1; -} diff --git a/test/negative/yields_comparand_dimension_mismatch.cpp b/test/negative/yields_comparand_dimension_mismatch.cpp new file mode 100644 index 00000000..8d9feef0 --- /dev/null +++ b/test/negative/yields_comparand_dimension_mismatch.cpp @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: the two sides of this comparison measure different dimensions +// REJECT: no matching +// REJECT: invalid operands +// REJECT: no match for +// +// A bound area compared with a length, as a constraint compares, then +// checked, rendered and documented: refused once, in the words the formula it +// holds draws when compared with that length, and the comparison asks nothing +// more of any of the three. +#include <formula-cpp/constraint.hpp> +#include <formula-cpp/document.hpp> +#include <formula-cpp/formula.hpp> +#include <formula-cpp/render.hpp> + +using SideA = formula::Quantity<struct SideATag, "a", "first side of the loaded face", formula::unit::Millimetre>; +using SideB = formula::Quantity<struct SideBTag, "b", "second side of the loaded face", formula::unit::Millimetre>; +using Area = formula::Quantity<struct AreaTag, "A_c", "loaded area", formula::unit::SquareMillimetre>; + +inline constexpr auto inputs = formula::environment(formula::Measured<SideA> { 150 }, formula::Measured<SideB> { 150 }); + +int main() +{ + constexpr auto loadedArea = formula::yields<Area>(formula::var<SideA> * formula::var<SideB>); + constexpr auto tooSmall = + formula::constraint(loadedArea >= formula::var<SideA>, formula::Verdict { "too small a face to load" }); + auto const shown = formula::render(tooSmall); + auto const written = formula::document(tooSmall); + return formula::check(tooSmall, inputs).is_satisfied() && !shown.empty() && !written.formula.empty() ? 0 : 1; +} diff --git a/test/negative/yields_compared.cpp b/test/negative/yields_compared.cpp deleted file mode 100644 index 5edc5cc3..00000000 --- a/test/negative/yields_compared.cpp +++ /dev/null @@ -1,33 +0,0 @@ -// 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 road gradient, 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 <formula-cpp/constraint.hpp> -#include <formula-cpp/document.hpp> -#include <formula-cpp/formula.hpp> -#include <formula-cpp/render.hpp> - -using Rise = formula::Quantity<struct RiseTag, "h", "height gained", formula::unit::Millimetre>; -using Run = formula::Quantity<struct RunTag, "L", "horizontal distance covered", formula::unit::Millimetre>; -using Gradient = formula::Quantity<struct GradientTag, "s", "road gradient", formula::unit::One>; - -inline constexpr auto inputs = - formula::environment(formula::Measured<Rise> { 163 }, formula::Measured<Run> { 307 }); - -int main() -{ - using namespace formula::literals; - constexpr auto ratio = formula::yields<Gradient>(formula::var<Rise> / formula::var<Run>); - constexpr auto tooShallow = formula::constraint(ratio >= formula::constant<formula::unit::One>(0.45_r), - formula::Verdict { "too shallow a gradient for the climb" }); - auto const shown = formula::render(tooShallow); - auto const written = formula::document(tooShallow); - return formula::check(tooShallow, inputs).is_satisfied() && !shown.empty() && !written.formula.empty() ? 0 : 1; -} diff --git a/test/negative/yields_fit_not_a_curve.cpp b/test/negative/yields_fit_not_a_curve.cpp new file mode 100644 index 00000000..71c9543c --- /dev/null +++ b/test/negative/yields_fit_not_a_curve.cpp @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: linear_least_squares fits a curve, or points and values read as observations; pair a domain series and a +// value series with curve(domain, values), or read both with observations<Q, Capacity> REJECT: no matching overloaded +// function REJECT: no matching function REJECT: RequireResultDimension +// +// A bound series handed to the fit, which fits a curve: refused once, in the +// words the series it holds draws, and not as an ambiguous or a missing +// overload -- also when the fit's slope is taken and evaluated next. +#include <formula-cpp/least_squares.hpp> + +struct Length: formula::Quantity<Length, "L", "an invented length", formula::unit::Millimetre> +{ +}; +struct Lengths: formula::Quantity<Lengths, "L_s", "invented lengths", formula::unit::Millimetre> +{ +}; +struct Rate: formula::Quantity<Rate, "v", "an invented rate of change", formula::unit::MillimetrePerMinute> +{ +}; + +inline constexpr auto fitPoints = + formula::environment(formula::measured_series<Length>(formula::Measured<Length> { formula::Rational { 103, 10 } }, + formula::Measured<Length> { formula::Rational { 139, 10 } })); +inline constexpr auto lengths = formula::yields<Lengths>(formula::series<Length, 2>); +inline constexpr auto fit = formula::linear_least_squares(lengths, { .reference = "Example Standard 12" }); + +int main() +{ + return formula::checked_evaluate<Rate>(formula::opaque_output<"slope">(fit), fitPoints).has_value() ? 0 : 1; +} diff --git a/test/negative/yields_fit_observations_uncited.cpp b/test/negative/yields_fit_observations_uncited.cpp new file mode 100644 index 00000000..26fd41c8 --- /dev/null +++ b/test/negative/yields_fit_observations_uncited.cpp @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: linear_least_squares needs a citation, the reason the method fits a line here; pass {} when it gives none +// REJECT: no matching overloaded function +// REJECT: no matching function +// REJECT: RequireResultDimension +// REJECT: has no output of that name +// +// A line through bound raw observations with no citation: refused once, in +// the words the observations they hold draw, and not as a missing overload, +// a missing output or a wrong result dimension. +#include <formula-cpp/least_squares.hpp> + +struct Elapsed: formula::Quantity<Elapsed, "t", "an invented elapsed time", formula::unit::Second> +{ +}; +struct Length: formula::Quantity<Length, "L", "an invented length", formula::unit::Millimetre> +{ +}; +struct Rate: formula::Quantity<Rate, "v", "an invented rate of change", formula::unit::MillimetrePerMinute> +{ +}; + +inline constexpr auto twoObserved = formula::environment( + formula::MeasuredObservations<Elapsed, 4>(formula::Rational { 1 }, formula::Rational { 3 }), + formula::MeasuredObservations<Length, 4>(formula::Rational { 103, 10 }, formula::Rational { 139, 10 })); +inline constexpr auto times = formula::yields<Elapsed>(formula::observations<Elapsed, 4>); +inline constexpr auto lengths = formula::yields<Length>(formula::observations<Length, 4>); +inline constexpr auto fit = formula::linear_least_squares(times, lengths); + +int main() +{ + return formula::checked_evaluate<Rate>(formula::opaque_output<"slope">(fit), twoObserved).has_value() ? 0 : 1; +} diff --git a/test/negative/yields_operand_dimension_mismatch.cpp b/test/negative/yields_operand_dimension_mismatch.cpp new file mode 100644 index 00000000..c1d13390 --- /dev/null +++ b/test/negative/yields_operand_dimension_mismatch.cpp @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: the two sides of this addition or subtraction measure different dimensions +// REJECT: no matching +// REJECT: invalid operands +// REJECT: no match for +// REJECT: RequireResultDimension +// +// A bound area added to a length: refused once, in the words the formula it +// holds draws when added to that length, and the sum asks nothing more of the +// evaluation, the rendering or the documentation. +#include <formula-cpp/document.hpp> +#include <formula-cpp/formula.hpp> +#include <formula-cpp/render.hpp> + +using SideA = formula::Quantity<struct SideATag, "a", "first side of the loaded face", formula::unit::Millimetre>; +using SideB = formula::Quantity<struct SideBTag, "b", "second side of the loaded face", formula::unit::Millimetre>; +using Area = formula::Quantity<struct AreaTag, "A_c", "loaded area", formula::unit::SquareMillimetre>; + +inline constexpr auto inputs = formula::environment(formula::Measured<SideA> { 150 }, formula::Measured<SideB> { 150 }); + +int main() +{ + constexpr auto loadedArea = formula::yields<Area>(formula::var<SideA> * formula::var<SideB>); + constexpr auto misused = loadedArea + formula::var<SideA>; + auto const shown = formula::render(misused); + auto const written = formula::document(misused); + return formula::checked_evaluate<Area>(misused, inputs).has_value() && !shown.empty() && !written.formula.empty() ? 0 + : 1; +} diff --git a/test/negative/yields_replacement_without_citation.cpp b/test/negative/yields_replacement_without_citation.cpp new file mode 100644 index 00000000..36bf93fb --- /dev/null +++ b/test/negative/yields_replacement_without_citation.cpp @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: replace_variant<Tag>(expression) was given no citation +// REJECT: no matching +// +// A bound formula an overlay states as a replacement with no citation: refused +// once, in the words the formula it holds draws, and not as a missing +// overload. +#include <formula-cpp/constraint.hpp> +#include <formula-cpp/overlay.hpp> + +namespace +{ +struct Cube +{ +}; + +struct Factor: formula::Quantity<Factor, "k", "factor", formula::unit::One> +{ +}; +struct Other: formula::Quantity<Other, "o", "another factor", formula::unit::One> +{ +}; + +using formula::var; +} // namespace + +int main() +{ + constexpr auto replacement = formula::yields<Factor>(var<Other> / formula::Rational { 2 }); + constexpr auto operation = formula::replace_variant<Cube>(replacement); + return sizeof(operation) > 0 ? 0 : 1; +} diff --git a/test/yields_tests.cpp b/test/yields_tests.cpp index ef80db3e..55558eee 100644 --- a/test/yields_tests.cpp +++ b/test/yields_tests.cpp @@ -239,47 +239,436 @@ TEST_CASE("yields: every verb hands on the sink and the vocabulary it is given", 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]") +TEST_CASE("yields: a bound formula is an operand, standing for the formula it holds", "[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<decltype(ratio)>; - using Refused = formula::detail::RefusedBoundValue<formula::Describe<Gradient>::dimension>; - STATIC_REQUIRE(std::is_same_v<decltype(var<Rise> * std::declval<Bound>()), Refused>); - STATIC_REQUIRE(std::is_same_v<decltype(std::declval<Bound>() + formula::Rational { 1 }), Refused>); - STATIC_REQUIRE(std::is_same_v<decltype(-std::declval<Bound>()), Refused>); - STATIC_REQUIRE(formula::detail::refused_already<Refused>()); - - // 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<decltype(ratio.expression)>; - STATIC_REQUIRE(std::is_same_v<decltype(var<Rise> * ratio.expression), - formula::BinaryNode<formula::BinaryOperator::Multiply, formula::VarNode<Rise>, Held>>); + // On either side of each arithmetic operator, beside a formula, a bare + // `Rational` or another bound formula, and negated: the type the + // `.expression` spelling gives. + constexpr auto one = formula::number(rat(1)); + STATIC_REQUIRE(std::is_same_v<decltype(ratio + one), decltype(ratio.expression + one)>); + STATIC_REQUIRE(std::is_same_v<decltype(one + ratio), decltype(one + ratio.expression)>); + STATIC_REQUIRE(std::is_same_v<decltype(ratio - one), decltype(ratio.expression - one)>); + STATIC_REQUIRE(std::is_same_v<decltype(one - ratio), decltype(one - ratio.expression)>); + STATIC_REQUIRE(std::is_same_v<decltype(var<Rise> * ratio), decltype(var<Rise> * ratio.expression)>); + STATIC_REQUIRE(std::is_same_v<decltype(ratio * var<Rise>), decltype(ratio.expression * var<Rise>)>); + STATIC_REQUIRE(std::is_same_v<decltype(var<Rise> / ratio), decltype(var<Rise> / ratio.expression)>); + STATIC_REQUIRE(std::is_same_v<decltype(ratio / var<Rise>), decltype(ratio.expression / var<Rise>)>); + STATIC_REQUIRE(std::is_same_v<decltype(ratio + rat(1)), decltype(ratio.expression + rat(1))>); + STATIC_REQUIRE(std::is_same_v<decltype(rat(1) - ratio), decltype(rat(1) - ratio.expression)>); + STATIC_REQUIRE(std::is_same_v<decltype(ratio * rat(2)), decltype(ratio.expression * rat(2))>); + STATIC_REQUIRE(std::is_same_v<decltype(rat(2) / ratio), decltype(rat(2) / ratio.expression)>); + STATIC_REQUIRE(std::is_same_v<decltype(ratio * ratio), decltype(ratio.expression * ratio.expression)>); + STATIC_REQUIRE(std::is_same_v<decltype(-ratio), decltype(-ratio.expression)>); + + // Beside a series, a bound formula is broadcast as the formula it holds + // is, and a bound series combines element by element as its series does. + constexpr auto retainedInKilograms = formula::yields<RetainedKilograms>(formula::series<Retained, 5>); + STATIC_REQUIRE(std::is_same_v<decltype(formula::series<Retained, 5> * ratio), + decltype(formula::series<Retained, 5> * ratio.expression)>); STATIC_REQUIRE( - std::is_same_v<decltype(ratio.expression + formula::Rational { 1 }), - formula::BinaryNode<formula::BinaryOperator::Add, Held, formula::ConstantNode<unit::One>>>); - STATIC_REQUIRE(formula::number_of(formula::checked_evaluate<Rise>(var<Run> * ratio.expression, batch)) - == formula::Rational { 163 }); + std::is_same_v<decltype(retainedInKilograms / rat(2)), decltype(retainedInKilograms.expression / rat(2))>); + STATIC_REQUIRE(std::is_same_v<decltype(-retainedInKilograms), decltype(-retainedInKilograms.expression)>); + + // The value is the formula's: 307 mm times 163/307 is 163 mm. + STATIC_REQUIRE(formula::checked_evaluate<Rise>(var<Run> * ratio, batch) + == formula::checked_evaluate<Rise>(var<Run> * ratio.expression, batch)); + STATIC_REQUIRE(formula::number_of(formula::checked_evaluate<Rise>(var<Run> * ratio, batch)) == rat(163)); + + // Written as the formula it holds: the bound quantity is not named. + CHECK(formula::render(var<Run> * ratio) == formula::render(var<Run> * ratio.expression)); +} + +namespace +{ +using Load = formula::Quantity<struct YieldsLoadTag, "F", "maximum load", unit::Kilonewton>; +using Area = formula::Quantity<struct YieldsAreaTag, "A_c", "loaded area", unit::SquareMillimetre>; +using Strength = formula::Quantity<struct YieldsStrengthTag, "f_c", "compressive strength", unit::Megapascal>; +using SideA = formula::Quantity<struct YieldsSideATag, "a", "first side of the loaded face", unit::Millimetre>; +using SideB = formula::Quantity<struct YieldsSideBTag, "b", "second side of the loaded face", unit::Millimetre>; + +constexpr auto loadedArea = formula::yields<Area>(var<SideA> * var<SideB>); +constexpr auto strength = formula::yields<Strength>(var<Load> / loadedArea); +} // namespace + +TEST_CASE("yields: a bound formula inside another bound formula", "[yields]") +{ + STATIC_REQUIRE(std::is_same_v<decltype(strength.expression), decltype(var<Load> / loadedArea.expression)>); + + // 675 kN over a 150 mm by 150 mm face is 30 MPa. + constexpr auto specimen = formula::environment( + formula::Measured<SideA> { 150 }, formula::Measured<SideB> { 150 }, formula::Measured<Load> { 675 }); + constexpr auto result = formula::checked_evaluate(strength, specimen); + STATIC_REQUIRE(std::is_same_v<std::remove_const_t<decltype(result)>, + std::expected<formula::Outcome<Strength>, formula::ArithmeticError>>); + REQUIRE(result.has_value()); + CHECK(result->measurement().value() == rat(30)); + + // The same formula written in one piece. + constexpr auto nested = formula::yields<Strength>(var<Load> / formula::yields<Area>(var<SideA> * var<SideB>)); + STATIC_REQUIRE(std::is_same_v<decltype(nested), decltype(strength)>); + CHECK(formula::render(nested) == formula::render(var<Load> / (var<SideA> * var<SideB>) )); } -TEST_CASE("yields: a bound formula is not a comparand, and comparisons of formulas are untouched", "[yields]") +namespace { - // 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. +// Whether a formula of type @p T can be compared with a bare `Rational`: no, +// for a formula or a bound one -- the comparisons take two formulas. +template <typename T> +concept ComparesWithRational = requires(T const& formulaGiven) { formulaGiven < formula::Rational { 1 }; }; + +// Whether @p T can be added to @p U: no, for a series or a bound one beside +// something that is no formula. +template <typename T, typename U> +concept AddsTo = requires(T const& formulaGiven, U const& other) { formulaGiven + other; }; +} // namespace + +TEST_CASE("yields: a bound formula is a comparand, standing for the formula it holds", "[yields]") +{ + constexpr auto limit = formula::constant<unit::One>(rat(9, 20)); + STATIC_REQUIRE(std::is_same_v<decltype(ratio < limit), decltype(ratio.expression < limit)>); + STATIC_REQUIRE(std::is_same_v<decltype(limit < ratio), decltype(limit < ratio.expression)>); + STATIC_REQUIRE(std::is_same_v<decltype(ratio <= limit), decltype(ratio.expression <= limit)>); + STATIC_REQUIRE(std::is_same_v<decltype(limit <= ratio), decltype(limit <= ratio.expression)>); + STATIC_REQUIRE(std::is_same_v<decltype(ratio > limit), decltype(ratio.expression > limit)>); + STATIC_REQUIRE(std::is_same_v<decltype(limit > ratio), decltype(limit > ratio.expression)>); + STATIC_REQUIRE(std::is_same_v<decltype(ratio >= limit), decltype(ratio.expression >= limit)>); + STATIC_REQUIRE(std::is_same_v<decltype(limit >= ratio), decltype(limit >= ratio.expression)>); + STATIC_REQUIRE(std::is_same_v<decltype(ratio == limit), decltype(ratio.expression == limit)>); + STATIC_REQUIRE(std::is_same_v<decltype(limit == ratio), decltype(limit == ratio.expression)>); + STATIC_REQUIRE(std::is_same_v<decltype(ratio != limit), decltype(ratio.expression != limit)>); + STATIC_REQUIRE(std::is_same_v<decltype(limit != ratio), decltype(limit != ratio.expression)>); + STATIC_REQUIRE(std::is_same_v<decltype(ratio == ratio), decltype(ratio.expression == ratio.expression)>); + + // A comparison of formulas is a predicate, not a truth value, so neither + // a formula nor a bound one is equality-comparable in a concept's sense. using Bound = std::remove_const_t<decltype(ratio)>; - using Refused = formula::detail::RefusedBoundValue<formula::Describe<Gradient>::dimension>; - constexpr auto limit = formula::constant<unit::One>(formula::Rational { 9, 20 }); + using Held = std::remove_const_t<decltype(ratio.expression)>; using Limit = std::remove_const_t<decltype(limit)>; - STATIC_REQUIRE(std::is_same_v<decltype(std::declval<Bound>() >= limit), - formula::PredicateNode<formula::Comparison::GreaterOrEqual, Refused, Refused>>); + STATIC_REQUIRE(!std::equality_comparable<Held>); STATIC_REQUIRE(!std::equality_comparable<Bound>); + STATIC_REQUIRE(!std::equality_comparable_with<Held, Limit>); STATIC_REQUIRE(!std::equality_comparable_with<Bound, Limit>); - // The formula it holds is compared as any formula is: 163/307 is above - // 9/20. - using Held = std::remove_const_t<decltype(ratio.expression)>; - STATIC_REQUIRE(std::is_same_v<decltype(ratio.expression >= limit), - formula::PredicateNode<formula::Comparison::GreaterOrEqual, Held, Limit>>); - constexpr auto tooShallow = formula::constraint(ratio.expression >= limit, formula::Verdict { "too shallow" }); + // Asked of something the comparisons of formulas do not take, a bound + // formula answers no as the formula it holds does, without an error: a + // series compared with a series, a formula with a bare `Rational`. + using HeldSeries = std::remove_const_t<decltype(formula::series<Retained, 5>)>; + using BoundSeries = formula::Yields<RetainedKilograms, HeldSeries>; + STATIC_REQUIRE(!std::equality_comparable<HeldSeries>); + STATIC_REQUIRE(!std::equality_comparable<BoundSeries>); + STATIC_REQUIRE(!ComparesWithRational<Held>); + STATIC_REQUIRE(!ComparesWithRational<Bound>); + STATIC_REQUIRE(!AddsTo<HeldSeries, std::nullptr_t>); + STATIC_REQUIRE(!AddsTo<BoundSeries, std::nullptr_t>); + + // A bound retry is compared as its retry is -- refused, where it is + // written, and so no equality a concept can use -- and a retry beside a + // bound formula as beside the formula held. Asked of a type, neither + // fires the refusal. + using HeldRetry = std::remove_const_t<decltype(fourAttempts)>; + using BoundRetry = formula::Yields<Estimate, HeldRetry>; + STATIC_REQUIRE(!std::equality_comparable<HeldRetry>); + STATIC_REQUIRE(!std::equality_comparable<BoundRetry>); + STATIC_REQUIRE(std::is_same_v<decltype(std::declval<BoundRetry>() >= limit), decltype(fourAttempts >= limit)>); + STATIC_REQUIRE( + std::is_same_v<decltype(fourAttempts + std::declval<Bound>()), decltype(fourAttempts + ratio.expression)>); + STATIC_REQUIRE(std::is_same_v<decltype(-std::declval<BoundRetry>()), decltype(-fourAttempts)>); + + // A constraint over a bound formula checks what one over the formula + // checks: 163/307 is above 9/20. + constexpr auto tooShallow = formula::constraint(ratio >= limit, formula::Verdict { "too shallow" }); + constexpr auto heldTooShallow = formula::constraint(ratio.expression >= limit, formula::Verdict { "too shallow" }); + STATIC_REQUIRE(std::is_same_v<decltype(tooShallow), decltype(heldTooShallow)>); STATIC_REQUIRE(formula::check(tooShallow, batch).is_satisfied()); + STATIC_REQUIRE(formula::check(tooShallow, batch) == formula::check(heldTooShallow, batch)); +} + +namespace +{ +inline constexpr auto threePlaces = formula::DecimalPlaces { 3 }; +inline constexpr auto threeDigits = formula::SignificantDigits { 3 }; +inline constexpr auto halfEven = formula::RoundingMode::HalfEven; +inline constexpr formula::DecimalRounding thousandth { unit::One, threePlaces, halfEven }; +inline constexpr formula::SignificantRounding threeFigures { unit::One, threeDigits, halfEven }; +inline constexpr formula::Citation sourceCited { .title = "Example Standard", .section = "4.1" }; + +// A length bound to a quantity of its own, for the lookups and the snap: +// 163 + 307 = 470 mm. +using Span = formula::Quantity<struct YieldsSpanTag, "l", "span", unit::Millimetre>; +constexpr auto span = formula::yields<Span>(var<Rise> + var<Run>); +inline constexpr formula::BandTable<2> spanBands { formula::band(0, 1, 300, 1), formula::band(300, 1, 900, 1) }; +inline constexpr formula::BreakpointTable<2> spanPoints { formula::breakpoint(0), formula::breakpoint(900) }; +inline constexpr formula::SampleSizeTable<3> countSizes { 3, 4, 5 }; +} // namespace + +TEST_CASE("yields: a bound formula as the operand of a function, a rounding or an escape", "[yields]") +{ + // function.hpp + STATIC_REQUIRE(std::is_same_v<decltype(formula::pow<2>(ratio)), decltype(formula::pow<2>(ratio.expression))>); + STATIC_REQUIRE(std::is_same_v<decltype(formula::sqrt(ratio)), decltype(formula::sqrt(ratio.expression))>); + STATIC_REQUIRE(std::is_same_v<decltype(formula::cbrt(ratio)), decltype(formula::cbrt(ratio.expression))>); + STATIC_REQUIRE(std::is_same_v<decltype(formula::root<5>(ratio)), decltype(formula::root<5>(ratio.expression))>); + STATIC_REQUIRE(std::is_same_v<decltype(formula::ln(ratio)), decltype(formula::ln(ratio.expression))>); + STATIC_REQUIRE(std::is_same_v<decltype(formula::log10(ratio)), decltype(formula::log10(ratio.expression))>); + STATIC_REQUIRE(std::is_same_v<decltype(formula::exp(ratio)), decltype(formula::exp(ratio.expression))>); + + // precision.hpp + constexpr auto Repeatability = formula::PrecisionKind::Repeatability; + STATIC_REQUIRE(std::is_same_v<decltype(formula::abs(ratio)), decltype(formula::abs(ratio.expression))>); + STATIC_REQUIRE(std::is_same_v<decltype(formula::precision_limit<Repeatability>(span, var<Rise>)), + decltype(formula::precision_limit<Repeatability>(span.expression, var<Rise>))>); + STATIC_REQUIRE(std::is_same_v<decltype(formula::precision_limit<Repeatability>(var<Rise>, span)), + decltype(formula::precision_limit<Repeatability>(var<Rise>, span.expression))>); + + // rounding_node.hpp, rounded_root.hpp and rounded_transcendental.hpp + STATIC_REQUIRE(std::is_same_v<decltype(formula::rounded<unit::One, threePlaces, halfEven>(ratio)), + decltype(formula::rounded<unit::One, threePlaces, halfEven>(ratio.expression))>); + STATIC_REQUIRE(std::is_same_v<decltype(formula::rounded<thousandth>(ratio)), + decltype(formula::rounded<thousandth>(ratio.expression))>); + STATIC_REQUIRE(std::is_same_v<decltype(formula::rounded_to_digits<unit::One, threeDigits, halfEven>(ratio)), + decltype(formula::rounded_to_digits<unit::One, threeDigits, halfEven>(ratio.expression))>); + STATIC_REQUIRE(std::is_same_v<decltype(formula::rounded_to_digits<threeFigures>(ratio)), + decltype(formula::rounded_to_digits<threeFigures>(ratio.expression))>); + STATIC_REQUIRE(std::is_same_v<decltype(formula::rounded_sqrt<unit::One, threePlaces, halfEven>(ratio)), + decltype(formula::rounded_sqrt<unit::One, threePlaces, halfEven>(ratio.expression))>); + STATIC_REQUIRE(std::is_same_v<decltype(formula::rounded_sqrt<thousandth>(ratio)), + decltype(formula::rounded_sqrt<thousandth>(ratio.expression))>); + STATIC_REQUIRE(std::is_same_v<decltype(formula::rounded_ln<threePlaces, halfEven>(ratio)), + decltype(formula::rounded_ln<threePlaces, halfEven>(ratio.expression))>); + STATIC_REQUIRE(std::is_same_v<decltype(formula::rounded_log10<threePlaces, halfEven>(ratio)), + decltype(formula::rounded_log10<threePlaces, halfEven>(ratio.expression))>); + STATIC_REQUIRE(std::is_same_v<decltype(formula::rounded_exp<threePlaces, halfEven>(ratio)), + decltype(formula::rounded_exp<threePlaces, halfEven>(ratio.expression))>); + + // escape.hpp + STATIC_REQUIRE( + std::is_same_v<decltype(formula::numeric_value_of<unit::One, "a gradient is a bare number">(ratio)), + decltype(formula::numeric_value_of<unit::One, "a gradient is a bare number">(ratio.expression))>); + + // The value through a bound operand is the formula's: the square of + // 163/307 times (307/163) squared is 1. + STATIC_REQUIRE(formula::number_of(formula::checked_evaluate<Gradient>( + formula::pow<2>(ratio) * formula::pow<2>(var<Run> / var<Rise>), batch)) + == rat(1)); +} + +TEST_CASE("yields: a bound formula documented, and as a branch of when", "[yields]") +{ + // citation.hpp: the citation wraps the formula the bound one holds; the + // binding does not carry through. + STATIC_REQUIRE(std::is_same_v<decltype(formula::documented(ratio, sourceCited)), + decltype(formula::documented(ratio.expression, sourceCited))>); + constexpr auto cited = formula::documented(ratio, { .title = "Road gradient", .reference = "Example Standard 1:2020" }); + CHECK(formula::document(cited).citations.size() == 1); + + // conditional.hpp + constexpr auto half = formula::number(rat(1, 2)); + constexpr auto steep = ratio > half; + STATIC_REQUIRE( + std::is_same_v<decltype(formula::when(steep, ratio, half)), decltype(formula::when(steep, ratio.expression, half))>); + STATIC_REQUIRE( + std::is_same_v<decltype(formula::when(steep, half, ratio)), decltype(formula::when(steep, half, ratio.expression))>); + STATIC_REQUIRE(std::is_same_v<decltype(formula::when(steep, ratio, ratio)), + decltype(formula::when(steep, ratio.expression, ratio.expression))>); +} + +TEST_CASE("yields: a bound formula as a lookup's key, a count or a snapped value", "[yields]") +{ + // lookup.hpp + STATIC_REQUIRE( + std::is_same_v<decltype(formula::banded_lookup<unit::Millimetre, spanBands, unit::One>(span, { 1, 2 })), + decltype(formula::banded_lookup<unit::Millimetre, spanBands, unit::One>(span.expression, { 1, 2 }))>); + STATIC_REQUIRE( + std::is_same_v<decltype(formula::interpolating_lookup<unit::Millimetre, spanPoints, unit::One>(span, { 1, 2 })), + decltype(formula::interpolating_lookup<unit::Millimetre, spanPoints, unit::One>(span.expression, + { 1, 2 }))>); + + // critical_value.hpp + STATIC_REQUIRE(std::is_same_v<decltype(formula::critical_value<countSizes, unit::One>(ratio, { 1, 2, 3 })), + decltype(formula::critical_value<countSizes, unit::One>(ratio.expression, { 1, 2, 3 }))>); + + // snap.hpp + constexpr auto TowardLower = formula::SnapTie::TowardLower; + STATIC_REQUIRE(std::is_same_v<decltype(formula::snapped<unit::Millimetre, spanPoints, TowardLower>(span)), + decltype(formula::snapped<unit::Millimetre, spanPoints, TowardLower>(span.expression))>); + + // The key read is the formula's: 470 mm is in the second band. + STATIC_REQUIRE(formula::number_of(formula::checked_evaluate<Gradient>( + formula::banded_lookup<unit::Millimetre, spanBands, unit::One>(span, { 1, 2 }), batch)) + == rat(2)); +} + +TEST_CASE("yields: a bound series in the series, statistics and conformity builders", "[yields][series]") +{ + constexpr auto retainedInKilograms = formula::yields<RetainedKilograms>(formula::series<Retained, 5>); + constexpr auto held = retainedInKilograms.expression; + using Held = std::remove_const_t<decltype(held)>; + STATIC_REQUIRE(std::is_same_v<Held, std::remove_const_t<decltype(formula::series<Retained, 5>)>>); + + // series.hpp + constexpr auto FromLast = formula::CumulativeDirection::FromLast; + STATIC_REQUIRE(std::is_same_v<decltype(formula::cumulative<FromLast>(retainedInKilograms)), + decltype(formula::cumulative<FromLast>(held))>); + STATIC_REQUIRE(std::is_same_v<decltype(formula::sum(retainedInKilograms)), decltype(formula::sum(held))>); + constexpr formula::PlacesTable<5> places { threePlaces, threePlaces, threePlaces, threePlaces, threePlaces }; + STATIC_REQUIRE(std::is_same_v<decltype(formula::rounded_elementwise<unit::Gram, places, halfEven>(retainedInKilograms)), + decltype(formula::rounded_elementwise<unit::Gram, places, halfEven>(held))>); + constexpr formula::DecimalRounding thousandthGram { unit::Gram, threePlaces, halfEven }; + STATIC_REQUIRE(std::is_same_v<decltype(formula::rounded_elementwise<thousandthGram>(retainedInKilograms)), + decltype(formula::rounded_elementwise<thousandthGram>(held))>); + + // statistics.hpp + STATIC_REQUIRE( + std::is_same_v<decltype(formula::sample_count(retainedInKilograms)), decltype(formula::sample_count(held))>); + STATIC_REQUIRE( + std::is_same_v<decltype(formula::sample_mean(retainedInKilograms)), decltype(formula::sample_mean(held))>); + STATIC_REQUIRE( + std::is_same_v<decltype(formula::sample_variance(retainedInKilograms)), decltype(formula::sample_variance(held))>); + STATIC_REQUIRE( + std::is_same_v<decltype(formula::sample_range(retainedInKilograms)), decltype(formula::sample_range(held))>); + + // conformity.hpp + constexpr formula::LimitRow nonNegative { formula::limit(rat(0)), formula::unbounded }; + constexpr formula::Envelope<5> envelope { nonNegative, nonNegative, nonNegative, nonNegative, nonNegative }; + constexpr formula::Verdict negative { "a negative mass" }; + STATIC_REQUIRE(std::is_same_v<decltype(formula::conformity<unit::Gram>(retainedInKilograms, envelope, negative)), + decltype(formula::conformity<unit::Gram>(held, envelope, negative))>); + + // The total is the series' own: absent, as one screen was not measured. + STATIC_REQUIRE(formula::checked_evaluate<Retained>(formula::sum(retainedInKilograms), inputs) + == formula::checked_evaluate<Retained>(formula::sum(held), inputs)); +} + +TEST_CASE("yields: a bound formula in a rejection of outliers and a retry", "[yields][rejection][retry]") +{ + // rejection.hpp + constexpr auto sixPercentOfMean = formula::yields<Mass>(rat(6, 100) * formula::pass_mean<Mass>); + STATIC_REQUIRE(std::is_same_v<decltype(formula::deviation_from_mean(sixPercentOfMean)), + decltype(formula::deviation_from_mean(sixPercentOfMean.expression))>); + constexpr auto factor = formula::yields<Gradient>(formula::number(rat(7, 4))); + STATIC_REQUIRE(std::is_same_v<decltype(formula::deviation_in_stddevs(factor)), + decltype(formula::deviation_in_stddevs(factor.expression))>); + STATIC_REQUIRE( + std::is_same_v<decltype(formula::gap_to_range(factor)), decltype(formula::gap_to_range(factor.expression))>); + constexpr auto sample = formula::yields<Mass>(formula::series<Mass, 6>); + constexpr auto overBound = formula::without_outliers<MostExtreme, Keep, formula::AtMost<2>, formula::KeepAtLeast<4>>( + sample, sixPercent, repeatTest, exampleCited); + STATIC_REQUIRE(std::is_same_v<decltype(overBound), decltype(rejectionA)>); + + // The rejection over a bound sample settles where rejectionA does. + constexpr auto settled = formula::checked_evaluate_rejection<Mass>(overBound, fixtureA); + REQUIRE(settled.has_value()); + CHECK(settled->outcome().measurement().value() == rat(321, 8)); + + // retry.hpp + constexpr auto fromZero = formula::yields<Estimate>(formula::constant<unit::Gram>(rat(0))); + STATIC_REQUIRE( + std::is_same_v<decltype(formula::starting_from(fromZero)), decltype(formula::starting_from(fromZero.expression))>); + constexpr auto attempt = formula::yields<Estimate>(formula::constant<unit::Gram>(rat(152, 25)) + + formula::previous_attempt<Estimate> / rat(2)); + constexpr auto risesLittle = + formula::previous_attempt<Estimate> - formula::this_attempt<Estimate> >= formula::constant<unit::Gram>(rat(-19, 25)); + constexpr formula::Verdict repeat { "repeat the determination" }; + constexpr auto AtFirstAttempt = formula::FirstJudged::AtFirstAttempt; + constexpr auto bound = formula::retry<Estimate, 4, AtFirstAttempt>( + formula::starting_from(fromZero), attempt, risesLittle, repeat, exampleCited); + STATIC_REQUIRE(std::is_same_v<decltype(bound), decltype(fourAttempts)>); + STATIC_REQUIRE( + std::is_same_v<decltype(formula::retry<Estimate, 4, AtFirstAttempt>(attempt, risesLittle, repeat, exampleCited)), + decltype(formula::retry<Estimate, 4, AtFirstAttempt>( + attempt.expression, risesLittle, repeat, exampleCited))>); +} + +namespace +{ +inline constexpr formula::Citation fitCited { .reference = "Example Standard 12" }; +inline constexpr auto riseRun = formula::curve(formula::series<Rise, 4>, formula::series<Run, 4>); +inline constexpr formula::BandTable<2> runClasses { formula::band(0, 1, 300, 1), formula::band(300, 1, 900, 1) }; +} // namespace + +TEST_CASE("yields: a bound formula in a curve, an opaque call and a fit", "[yields][opaque]") +{ + // curve.hpp + constexpr auto runs = formula::yields<Run>(formula::series<Run, 4>); + STATIC_REQUIRE(std::is_same_v<decltype(formula::curve(formula::series<Rise, 4>, runs)), + decltype(formula::curve(formula::series<Rise, 4>, runs.expression))>); + constexpr auto rises = formula::yields<Rise>(formula::series<Rise, 4>); + STATIC_REQUIRE(std::is_same_v<decltype(formula::curve(rises, formula::series<Run, 4>)), + decltype(formula::curve(rises.expression, formula::series<Run, 4>))>); + constexpr auto boundCurve = formula::yields<Run>(riseRun); + STATIC_REQUIRE(std::is_same_v<decltype(formula::interpolate_at(boundCurve, span)), + decltype(formula::interpolate_at(riseRun, span.expression))>); + constexpr auto NonDecreasing = formula::Monotone::NonDecreasing; + STATIC_REQUIRE(std::is_same_v<decltype(formula::splice<NonDecreasing>(boundCurve, riseRun)), + decltype(formula::splice<NonDecreasing>(riseRun, riseRun))>); + STATIC_REQUIRE(std::is_same_v<decltype(formula::splice<NonDecreasing>(riseRun, boundCurve)), + decltype(formula::splice<NonDecreasing>(riseRun, riseRun))>); + + // opaque.hpp + STATIC_REQUIRE(std::is_same_v<decltype(formula::opaque<formula::LinearLeastSquares>(fitCited, boundCurve)), + decltype(formula::opaque<formula::LinearLeastSquares>(fitCited, riseRun))>); + constexpr auto boundFit = formula::yields<Run>(formula::linear_least_squares(riseRun, fitCited)); + STATIC_REQUIRE(std::is_same_v<decltype(formula::opaque_output<"intercept">(boundFit)), + decltype(formula::opaque_output<"intercept">(boundFit.expression))>); + constexpr auto twoPlaces = formula::DecimalPlaces { 2 }; + constexpr formula::DecimalRounding hundredthMillimetre { unit::Millimetre, twoPlaces, halfEven }; + STATIC_REQUIRE( + std::is_same_v<decltype(formula::rounded_output<"intercept", unit::Millimetre, twoPlaces, halfEven>(boundFit)), + decltype(formula::rounded_output<"intercept", unit::Millimetre, twoPlaces, halfEven>( + boundFit.expression))>); + STATIC_REQUIRE(std::is_same_v<decltype(formula::rounded_output<"intercept", hundredthMillimetre>(boundFit)), + decltype(formula::rounded_output<"intercept", hundredthMillimetre>(boundFit.expression))>); + + // least_squares.hpp + STATIC_REQUIRE(std::is_same_v<decltype(formula::linear_least_squares(boundCurve, fitCited)), + decltype(formula::linear_least_squares(riseRun, fitCited))>); + constexpr auto runObservations = formula::yields<Run>(formula::observations<Run, 8>); + constexpr auto riseObservations = formula::observations<Rise, 8>; + STATIC_REQUIRE( + std::is_same_v<decltype(formula::linear_least_squares(riseObservations, runObservations, fitCited)), + decltype(formula::linear_least_squares(riseObservations, runObservations.expression, fitCited))>); + STATIC_REQUIRE(std::is_same_v<decltype(formula::regressors(riseObservations, runObservations)), + decltype(formula::regressors(riseObservations, runObservations.expression))>); + STATIC_REQUIRE(std::is_same_v<decltype(formula::multiple_least_squares( + formula::regressors(riseObservations), runObservations, fitCited)), + decltype(formula::multiple_least_squares( + formula::regressors(riseObservations), runObservations.expression, fitCited))>); + + // binning.hpp + STATIC_REQUIRE(std::is_same_v<decltype(formula::binned<unit::Millimetre, runClasses>(runObservations)), + decltype(formula::binned<unit::Millimetre, runClasses>(runObservations.expression))>); +} + +namespace +{ +struct SteepVariant +{ +}; +struct YieldsReference +{ +}; +struct YieldsBatch +{ +}; +} // namespace + +TEST_CASE("yields: a bound formula as a variant, an overlay's formula and a read from a record", "[yields][method]") +{ + // method.hpp + STATIC_REQUIRE(std::is_same_v<decltype(formula::variant<SteepVariant>(ratio)), + decltype(formula::variant<SteepVariant>(ratio.expression))>); + + // overlay.hpp + STATIC_REQUIRE(std::is_same_v<decltype(formula::replace_variant<SteepVariant>(ratio, sourceCited)), + decltype(formula::replace_variant<SteepVariant>(ratio.expression, sourceCited))>); + constexpr auto halfRun = formula::yields<Run>(var<Run> / rat(2)); + STATIC_REQUIRE(std::is_same_v<decltype(formula::add_derived<Rise>(halfRun, sourceCited)), + decltype(formula::add_derived<Rise>(halfRun.expression, sourceCited))>); + + // record.hpp + STATIC_REQUIRE(std::is_same_v<decltype(formula::from_record<YieldsReference>(ratio)), + decltype(formula::from_record<YieldsReference>(ratio.expression))>); + constexpr auto sameBatch = formula::same_lineage<YieldsBatch>(); + STATIC_REQUIRE(std::is_same_v<decltype(formula::from_record<YieldsReference>(ratio, sameBatch)), + decltype(formula::from_record<YieldsReference>(ratio.expression, sameBatch))>); }