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.
#include <formula-cpp/formula.hpp>
#include <formula-cpp/document.hpp>
#include <formula-cpp/render.hpp>
namespace unit = formula::unit;
using formula::var;
// A quantity carries its own symbol, description and unit, and its tag makes it a type of its own.
using WaterVolume = formula::Quantity<struct WaterVolumeTag, "V_w", "effective water content", unit::Litre>;
using CementVolume = formula::Quantity<struct CementVolumeTag, "V_c", "cement content", unit::Litre>;
using WaterCementRatio = formula::Quantity<struct WaterCementRatioTag, "w/c", "ratio of water to cement", unit::One>;
// The formula, and where it comes from, declared together.
constexpr auto ratio = formula::documented(var<WaterVolume> / var<CementVolume>,
{ .title = "Water/cement ratio",
.reference = "Example Standard 1:2020",
.section = "5.4.2",
.equation = "(3)" });That single declaration answers four different questions:
formula::render(ratio); // "V_w / V_c"
formula::render<formula::Dialect::LaTeX>(ratio); // "\frac{V_w}{V_c}"
formula::document(ratio); // the rendered formula, its citation, and a symbol table:
// V_w = effective water content [l]
// V_c = cement content [l]
auto const environment = formula::environment(formula::Measured<WaterVolume> { formula::Rational { 180 } },
formula::Measured<CementVolume> { formula::Rational { 300 } });
formula::evaluate<WaterCementRatio>(ratio, environment); // 0.6, and it knows it computed itThe output above is what examples/citations.cpp actually prints.
A quantity can also be declared as a struct deriving from formula::Quantity,
struct WaterVolume: formula::Quantity<WaterVolume, ...> {};. Both spellings
are supported, and mix in one formula; the quantities
guide says what each costs.
Every block below is real code from examples/, with the output those programs
actually print.
constexpr auto broken = formula::var<Volume> + formula::var<Length>;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'
Diameter is declared in millimetres, Area in square metres. Nothing in the
formula mentions either — the conversion is part of what the declaration means.
constexpr auto circularArea = formula::pi * formula::pow<2>(var<Diameter>) / formula::Rational { 4 };
constexpr auto known = formula::environment(formula::Measured<Diameter> { formula::Rational { 103 } });
constexpr auto area = formula::checked_evaluate<Area>(circularArea, known);circular area of a 103 mm diameter = 0.008332 m2 (computed)
2500 g reported as m = 2.500000 kg
Note constexpr: that area was computed at compile time.
constexpr auto unknown = formula::environment(formula::Measured<Diameter>::absent());
constexpr auto empty = formula::checked_evaluate<Area>(circularArea, unknown);area with no diameter measured: empty
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.
auto const batch = formula::environment(
formula::Measured<WaterVolume> { formula::Rational { 180 } },
formula::Measured<CementVolume> { formula::Rational { 300 } },
formula::entered(formula::Measured<WaterCementRatio> { formula::Rational { 1, 2 } }));
auto const ratio = formula::checked_evaluate<WaterCementRatio>(waterCementRatio, batch);w/c = 0.500000 (entered)
The formula would have computed 0.6. A person entered 0.5, so that is the
answer — and ratio->source() says ManuallyEntered, so a report can show
which numbers were derived and which were asserted.
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.
#include <formula-cpp/format.hpp>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
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.
document() walks a formula for its rendered text, its citations and its
symbol table. 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.
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:
formula::Explained<WaterCementRatio> const explained = formula::explain<WaterCementRatio>(ratio, inputs);
std::string const trace = formula::render_trace(explained.trace, { .maxSteps = 10 });1. V_w = 180 l
2. V_c = 300 l
3. #1 / #2 = 3/5
4. #3 = 3/5 [Water/cement ratio, Example Standard 1:2020, 5.4.2, (3)]
Every value is shown in the unit it was declared in, not the coherent unit
the arithmetic actually ran on — that is 9/50 cubic metres above, and nobody
typed cubic metres. 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.
See the tracing guide for the detail. Tracing costs
nothing when nobody asks for it: a sink is
passed by value, and the untraced path — evaluate(), checked_evaluate() —
defaults to one that does nothing, adding no instruction the evaluator would
not already emit once the call inlines, measured on all four compilers this
library targets. See the tracing guide.
inline constexpr formula::BandTable<3> SizeBands {
formula::band(0, 1, 127, 1), // 0 to under 127 mm
formula::band(127, 1, 173, 1), // 127 to under 173 mm
formula::band(173, 1, 211, 1), // 173 to under 211 mm -- 211 mm itself is NOT in it
};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.
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.
https://lastrada-software.github.io/formula-cpp/ — guides and the generated API reference.
| Guide | What it covers |
|---|---|
| Exact numbers | Rational, the rounding modes, why exactness is the default |
| Dimensions and units | Compile-time dimensional analysis, exact unit conversion, and base dimensions the SI does not have, such as money |
| Quantities | Declaring a quantity, Describe, measurements that may be absent |
| Writing formulas | Operators, evaluation, environments, overrides, and logarithms and exponentials, exact or rounded to declared places |
| Citations and rendering | documented(), the three dialects, generated documentation |
| Tracing and audit trails | explain(), render_trace(), sinks, and the zero-cost untraced path |
| Displaying numbers | 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 | 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 | Rounding as a node, when(), and the traced numeric_value_of escape hatch |
| Constraints and verdicts | Validating a result with constraint() and check(), the four-state outcome, and checking a set without short-circuit |
| Lookup tables | The three table kinds, validation that refuses a gap, and why a miss is not a number |
| Methods and overlays | 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 | 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 | 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 | 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 | 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 | A documentation page the library generated about itself |
Every example in the documentation uses generic physics with invented Example Standard
citations. Real standards are copyrighted, so none of their content appears in this repository.
Each guide has a matching runnable program under examples/.
0.2.0 is the latest release (CHANGELOG). 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 |
- C++23
- CMake 3.23 or newer
CI builds and tests every push on MSVC cl, clang-cl, Clang and GCC 14 on Linux, and
AppleClang on macOS. Minimum compiler versions are not settled yet; earlier ones may work but
are untested.
The primary consumption path.
cmake -S . -B build -DFORMULA_INSTALL=ON -DCMAKE_INSTALL_PREFIX=/your/prefix
cmake --install buildThen, in the consuming project:
find_package(formula-cpp CONFIG REQUIRED)
target_link_libraries(your_target PRIVATE formula-cpp::formula-cpp)include/ is self-contained and depends on nothing outside the standard library.
formula.hpp is the umbrella header. render.hpp, document.hpp, trace.hpp and
trace_render.hpp are deliberately left out of it: they need <string> and/or <vector>, 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 for trace.hpp and trace_render.hpp specifically.
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.
| Option | Default | Effect |
|---|---|---|
FORMULA_BUILD_TESTS |
ON when top-level | Build the test suite (fetches Catch2) |
FORMULA_BUILD_EXAMPLES |
ON when top-level | Build the examples |
FORMULA_BUILD_DOCS |
ON when top-level | Configure the Doxygen API-reference target (never in the default build) |
FORMULA_TOOLS |
ON when top-level | Build the project's own tooling |
FORMULA_INSTALL |
ON when top-level | Generate install and export rules |
FORMULA_PEDANTIC |
ON | Strict warnings on the project's own targets |
FORMULA_WERROR |
OFF | Treat warnings as errors |
Apache-2.0. See LICENSE.