formula-cpp provides a way to attach where a formula comes from --
formula::documented(), from citation.hpp -- and two ways to get that
formula back out as something a reader can use: formula::render(), from
render.hpp, turns a formula into text in one of three dialects, and
formula::document(), from document.hpp, walks a formula for its rendered
text, its citations and its symbol table together. This page explains how to
attach a citation, why the wrapper it creates is invisible to arithmetic, what
order citations come back in when formulas are nested, how the same formula
looks in each dialect, how the symbol table is built and deduplicated, and how
a whole documentation page is generated from formulas like these. The worked
example is examples/citations.cpp; every block on this page formatted as
program output is copied verbatim from that program's actual output, exactly
as docs/expressions.md does for evaluation. For a finished page built this
way from several formulas at once, see the gallery.
Neither render.hpp nor document.hpp is included by the umbrella header,
formula.hpp. Both pull in <string> -- document.hpp also pulls in
<vector> -- to turn a formula into text, and a consumer who only evaluates
numbers must not compile a string formatter or a symbol table into every
translation unit for a feature it never asked for. Include whichever you
need, by name:
#include <formula-cpp/document.hpp>
#include <formula-cpp/render.hpp>A three-part CMake check (cmake/CheckPublicHeaderIncludes.cmake) enforces
this: no other public header may include <string>, <vector>, <format>
or <iostream> directly, no other public header may reach render.hpp or
document.hpp either (which would smuggle those same headers in one hop
removed), and each of those two opt-in headers is checked against exactly the
standard headers it declares needing, so one of them quietly growing a third
dependency fails the same build.
formula::documented(expr, citation) wraps an expression in a node that
carries a formula::Citation alongside it. A citation is five
std::string_view fields, every one of them optional in the sense that it
defaults to empty rather than being required:
constexpr auto ratio = formula::documented(var<WaterVolume> / var<CementVolume>,
{ .title = "Water/cement ratio",
.reference = "Example Standard 1:2020",
.section = "5.4.2",
.equation = "(3)",
.text = "Ratio of water content to cement content." });(test/citation_tests.cpp, and the same declaration in
examples/citations.cpp.) A citation naming only the fields that apply is
just as valid:
constexpr auto sparse = formula::documented(var<WaterVolume>, { .title = "A volume" });and every field left unnamed reads back empty, not absent --
sparse.citation.reference.empty() holds, there is no separate "was this
field given" flag (test/citation_tests.cpp,
"citation: a field not named is empty, not absent").
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.
DocumentedNode<Inner> does not declare a dimension of its own; it forwards
Inner's:
static constexpr Dimension dimension = Inner::dimension;and evaluating one evaluates what it wraps and nothing else:
template <typename Rep = Rational, Node Inner, typename Env, typename Sink = NullSink>
[[nodiscard]] constexpr Evaluated<Rep> checked_evaluate_si(DocumentedNode<Inner> const& node,
Env const& environment,
Sink sink = {}) noexcept
{
sink.entered(node);
Evaluated<Rep> const result = detail::dispatch<Rep>(node.inner, environment, sink);
sink.produced(node, result);
return result;
}(both from citation.hpp). Wrapping a formula therefore changes neither its
dimension nor the number it produces:
STATIC_REQUIRE(decltype(ratio)::dimension == formula::dim::Scalar);
STATIC_REQUIRE(decltype(formula::documented(var<WaterVolume>, {}))::dimension == formula::dim::Volume);constexpr auto wrapped = formula::checked_evaluate<Ratio>(ratio, inputs);
constexpr auto bare = formula::checked_evaluate<Ratio>(var<WaterVolume> / var<CementVolume>, inputs);
STATIC_REQUIRE(wrapped.has_value());
STATIC_REQUIRE(wrapped->measurement().value() == rat(3, 5));
STATIC_REQUIRE(wrapped->measurement() == bare->measurement());(test/citation_tests.cpp, "citation: wrapping does not change the dimension" and "citation: a wrapped formula evaluates to what it wrapped".) examples/citations.cpp prints the same fact as a number rather
than a boolean -- the wrapped formula evaluates to exactly the ratio the bare
division would have produced:
w/c = 0.600000 (computed)
This is not merely an absence of a check; there is a check, and it still
runs. test/negative/documented_dimension_mismatch.cpp wraps a volume in a
citation and adds it to a length:
struct Volume: formula::Quantity<Volume, "V", "a volume", formula::unit::Litre>
{
};
struct Length: formula::Quantity<Length, "L", "a length", formula::unit::Metre>
{
};
// Wrapping must not smuggle a dimensional error past the check: the wrapper
// forwards the dimension, so the addition is still refused.
inline constexpr auto broken = formula::documented(formula::var<Volume>, { .title = "A volume" }) + formula::var<Length>;which gives, verbatim but for the paths, shown relative to the repository,
on MSVC's cl.exe (19.51, cl-debug preset):
include\formula-cpp/expression.hpp(186): error C2338: static assertion failed: 'formula: the two sides of this addition or subtraction measure different dimensions; the offending operands appear in this diagnostic as the template arguments of RequireAddendsAgree'
include\formula-cpp/expression.hpp(186): note: the template instantiation context (the oldest one first) is
test\negative\documented_dimension_mismatch.cpp(14): note: see reference to function template instantiation 'auto formula::operator +<formula::DocumentedNode<formula::VarNode<Volume>>,formula::VarNode<Length>>(Left,Right) noexcept' being compiled
with
[
Left=formula::DocumentedNode<formula::VarNode<Volume>>,
Right=formula::VarNode<Length>
]
include\formula-cpp/expression.hpp(271): note: see reference to class template instantiation 'formula::BinaryNode<formula::BinaryOperator::Add,formula::DocumentedNode<formula::VarNode<Volume>>,formula::VarNode<Length>>' being compiled
include\formula-cpp/expression.hpp(244): note: see reference to class template instantiation 'formula::detail::AdditiveDimensionsAgree<formula::BinaryOperator::Add,Left,Right>' being compiled
with
[
Left=formula::DocumentedNode<formula::VarNode<Volume>>,
Right=formula::VarNode<Length>
]
include\formula-cpp/expression.hpp(203): note: see reference to class template instantiation 'formula::detail::RequireAddendsAgree<Left,Right>' being compiled
with
[
Left=formula::DocumentedNode<formula::VarNode<Volume>>,
Right=formula::VarNode<Length>
]
The left operand the diagnostic names is formula::DocumentedNode<formula::VarNode<Volume>>,
not formula::VarNode<Volume> -- the wrapper is right there in the type the
check refused, which is exactly the proof that it forwarded the dimension
rather than hiding it.
A documented expression also composes like any other node -- ratio * 100
type-checks and carries the right dimension, because ratio is, dimension
and all, still a Node (test/citation_tests.cpp, "citation: a documented expression is still an expression") -- and a citation survives being
wrapped a second time, each copy keeping its own fields:
constexpr auto outer = formula::documented(ratio, { .title = "Water/cement ratio, per cent" });
STATIC_REQUIRE(outer.citation.title == std::string_view { "Water/cement ratio, per cent" });
STATIC_REQUIRE(outer.inner.citation.title == std::string_view { "Water/cement ratio" });
STATIC_REQUIRE(decltype(outer)::dimension == formula::dim::Scalar);(test/citation_tests.cpp, "citation: a citation survives being wrapped again".)
A formula built from an already-documented formula carries two citations,
one nested inside the other. test/document_tests.cpp builds a water/cement
ratio, then multiplies it by 100 and documents that:
constexpr auto perCent = formula::documented(
ratio * rat(100),
{ .title = "Water/cement ratio, per cent", .reference = "Example Standard 1:2020", .section = "5.4.3" });formula::document() walks the tree and returns both citations, outermost
first:
formula::Documentation const documentation = formula::document(perCent);
REQUIRE(documentation.citations.size() == 2);
CHECK(documentation.citations[0].title == std::string_view { "Water/cement ratio, per cent" });
CHECK(documentation.citations[0].section == std::string_view { "5.4.3" });
CHECK(documentation.citations[1].title == std::string_view { "Water/cement ratio" });
CHECK(documentation.citations[1].section == std::string_view { "5.4.2" });(test/document_tests.cpp, "document: citations come back outermost first".) That order falls out of when the walk records a citation, not
out of any sorting afterwards: it pushes the citation it is looking at before
it recurses into what that citation wraps, so the node nearest the root of
the tree -- the outermost wrapper -- is visited, and therefore pushed, first.
A formula with no citation at all simply returns an empty list rather than a
placeholder entry (test/document_tests.cpp, "document: an undocumented formula yields an empty citation list").
formula::Dialect (render.hpp) has three values -- Plain, Markdown and
LaTeX -- and formula::render<D>(node) spells the same formula three
different ways depending on which is asked for. The plain and LaTeX
renderings below are examples/citations.cpp's own output for ratio, the
water/cement formula from the section above:
plain: V_w / V_c
latex: \frac{V_w}{V_c}
Markdown wraps every symbol in backticks, so a symbol containing an
underscore -- V_w, in this formula -- is not read as emphasis by a Markdown
renderer:
CHECK(formula::render<Dialect::Markdown>(var<WaterVolume> / var<CementVolume>) == "`V_w` / `V_c`");(test/render_tests.cpp, "render: the Markdown dialect emphasises the symbols".) A citation attached with documented() never appears in any of
the three renderings -- render() answers only what the formula is, not
where it comes from; document() is what surfaces the citation alongside
the rendered text (test/render_tests.cpp, "render: a citation does not appear in the rendered formula").
Two further rules, true of every dialect, keep the rendering from bracketing
a formula in a way a reader would find surprising. First, wrapping a formula
in a citation changes none of its brackets -- documented() binds exactly
as tightly as what it wraps, at both the type level and, for a wrapped node
whose bracketing depends on data the type does not carry (a constant's sign
or unit symbol), at the runtime level too -- so a documented sum inside a
quotient is still bracketed exactly as the bare sum would be:
constexpr auto documented = formula::documented(var<WaterVolume> + var<CementVolume>, { .title = "Total volume" });
CHECK(formula::render(documented / var<Diameter>) == "(V_w + V_c) / d");(test/render_tests.cpp, "render: a documented sum inside a quotient keeps its brackets".) Second, a negative constant is bracketed wherever its
leading - would otherwise be misread as a unary minus -- which is not
quite everywhere that could happen: -(-5) renders as --5, not -(-5),
because a UnaryNode is not itself a context a Unary-precedence child
needs bracketing against. As the base of a power, though, where -5^2
would mean -(5^2) to a reader while the tree means (-5)^2, it is
bracketed --
CHECK(formula::render(formula::pow<2>(formula::number(rat(-5)))) == "(-5)^2");-- but as a factor, where there is no such ambiguity, it is not:
CHECK(formula::render(formula::number(rat(-5)) * var<Diameter>) == "-5 * d");(test/render_tests.cpp, "render: a negative constant as the base of a power keeps its bracket" and "render: a negative constant as a factor stays unbracketed".)
formula::document() also walks a formula for every variable it reads,
returned as documentation.symbols: each entry's symbol, description and
unit, exactly as formula::Describe<Q> gives them for that quantity.
examples/citations.cpp prints one row per variable in ratio:
symbol: V_w = effective water content [l]
symbol: V_c = cement content [l]
Two rules govern that list. First, it is ordered by first appearance,
reading the formula left to right -- not alphabetically, which would put A
before d in a formula that reads pi * d^2 / 4 and would not match how
anyone reads it:
formula::Documentation const documentation =
formula::document(formula::pi * formula::pow<2>(var<Diameter>) / var<WaterVolume>);
REQUIRE(documentation.symbols.size() == 2);
CHECK(documentation.symbols[0].symbol == std::string_view { "d" });
CHECK(documentation.symbols[1].symbol == std::string_view { "V_w" });(test/document_tests.cpp, "document: symbols come back in first-appearance order".) Second, it is deduplicated by quantity type, not by the symbol
that quantity happens to render as. A quantity used twice contributes one row
(test/document_tests.cpp, "document: a quantity used twice appears once"), but two distinct quantities that happen to share a rendered symbol
are not the same quantity, and each still gets its own row:
// Diameter is millimetres, ExcavationDepth is metres: two unrelated
// quantities that happen to render the same letter. Collapsing them would
// silently attribute one's description and unit to the other's uses.
formula::Documentation const documentation = formula::document(var<Diameter> + var<ExcavationDepth>);
REQUIRE(documentation.symbols.size() == 2);
CHECK(documentation.symbols[0].symbol == std::string_view { "d" });
CHECK(documentation.symbols[0].description == std::string_view { "specimen diameter" });
CHECK(documentation.symbols[0].unit == formula::unit::Millimetre);
CHECK(documentation.symbols[1].symbol == std::string_view { "d" });
CHECK(documentation.symbols[1].description == std::string_view { "excavation depth" });
CHECK(documentation.symbols[1].unit == formula::unit::Metre);(test/document_tests.cpp, "document: two quantities that share a symbol both get a row".) A literal coefficient and formula::pi name no variable
and contribute nothing to the table (test/document_tests.cpp, "document: a constant contributes no symbol").
A symbol is not a quantity. The same letter can name different quantities in
different countries -- in the case that made this necessary, two
jurisdictions use one pair of symbols crossed over, each one's word
meaning the other's quantity. A page rendered in the wrong jurisdiction's
words then states the wrong formula, so render() and document() both take
an optional vocabulary (vocabulary.hpp) saying which symbol names which
quantity there:
inline constexpr auto north = formula::vocabulary(formula::renames<Strength>("R"), formula::renames<Modulus>("E"));
inline constexpr auto south = formula::vocabulary(formula::renames<Strength>("E"), formula::renames<Modulus>("R"));
inline constexpr auto f = var<Strength> / var<Modulus>;CHECK(formula::render(f, north) == "R / E");
CHECK(formula::render(f, south) == "E / R");(test/vocabulary_tests.cpp, "two jurisdictions cross over one pair of symbols".) formula::render(f) is still f_c / E_m, the symbols the two
quantities declare: every surface that takes a vocabulary defaults to
formula::DefaultVocabulary, which renames nothing, and a quantity a
vocabulary does not name keeps its declared symbol.
A vocabulary renames how a quantity is written, never what it is. In
document(f, south) the first row's symbol is E and its description is
still compressive strength: the word moved, the quantity it names did not
("the documentation renames the symbol and keeps the meaning"). The
description and the unit stay Describe<Q>'s. A quantity a jurisdiction derives
(add_derived) has its definition, derivedAs, rendered in the same
vocabulary as the formula beside it, and a variant it replaced renders its
replacement in it too ("every node kind documents in the vocabulary, in every dialect").
Three things a vocabulary does not do:
- It does not reach the trace by itself. A trace's symbols are written
while the formula is evaluated, by the sink, so a sink records in the
vocabulary it is given -- see Tracing. Give
render(),document()and the sink the same one. - It renames quantities only. A variant's tag (
TagName) and a lookup key's name (EnumeratorName) name a case or a row, not a quantity, and have customisation traits of their own; a vocabulary leaves them alone. - It does not reach inside a node kind of your own that renders through
the one-argument
render_nodeextension point. Such a node still renders, and the vocabulary still reaches the library's nodes around it, but anything it renders itself is in the declared symbols. Write the two-argument form,render_node(TheirNode const&, V const& vocabulary), and handvocabularyon toformula::render<D>(operand, vocabulary)to 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<Q>-- keeps its own one-argumentrender_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.
A vocabulary renaming one quantity twice does not compile, and neither does
renames<Q>(""), which would leave a blank where the quantity stands; nor
does a symbol that is all whitespace or holds a NUL, nor one holding a square
bracket or a control character such as a newline, with which a vocabulary
could write a trace line's [fixed by jurisdiction overlay] clause, or a
whole trace line, itself. renames is consteval
and takes a const character array, so it accepts a string literal and
refuses a buffer on the stack or one that is not const: a trace keeps a
view of the symbol for as long as it lives.
Everything above -- the rendered formula, its citations, its symbol table --
is exactly what formula::Documentation (document.hpp) holds, and exactly
what a documentation page needs. docs/gallery.md is one,
built by running tools/gallery/main.cpp over a handful of formulas and
writing each one's document() result out as a Markdown section: the
rendered formula in a code block, the LaTeX rendering as a $$ ... $$ block
for MathJax, the symbol table as a Markdown table, and the citation fields
as a short list. That page is generated and checked in, so it reads on
GitHub with nothing built at all, and gallery.is-current
(cmake/CheckGalleryIsCurrent.cmake) fails CI the moment the checked-in page
and the generator disagree. Building a documentation page for your own
formulas is the same walk over your own tree; the gallery is the worked
example for it, so this guide points there rather than repeating it.
Every citation in this repository -- in the tests, in examples/citations.cpp
and in the gallery -- names a fictional Example Standard, never a real one.
Publishing this repository with a real standard's clause numbers and
equations transcribed into it would put copyrighted material in a public
repository, so the library's own documentation of its citation feature is,
deliberately, the one place that feature is never used for its intended
purpose.