morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
quantity_equation.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4
5/// @file detail/quantity_equation.hpp
6/// @brief `Quantity::equation()` renderer (provenance builds only).
7///
8/// Included by `morph/util/quantity.hpp` when `MORPH_QUANTITY_PROVENANCE` is on.
9/// Walks the shared derivation DAG and produces the print-ready `equation()`
10/// lines: formula, substitution, result, and a `where` legend for reused
11/// values. See `docs/spec/util/quantity_type.md` for the output contract.
12
13#include <cstdint>
14#include <format>
15#include <optional>
16#include <string>
17#include <unordered_map>
18#include <unordered_set>
19#include <utility>
20#include <vector>
21
22#include "../attributes.hpp"
23// Included back deliberately, and not circular: `util/quantity.hpp` includes
24// this file at the very end, `#pragma once` makes the inner visit a no-op, and
25// nothing in that header past the include point needs anything defined here.
26// It is what makes this file self-contained, which matters because a tool that
27// opens a header on its own -- clang-tidy analysing a changed header, an IDE,
28// include-what-you-use -- otherwise sees `unknown type name 'ASTNode'` on
29// every line and reports a cascade of findings about code that compiles fine.
30//
31// misc-header-include-cycle sees the back-edge and is, as a graph statement,
32// correct: quantity.hpp -> quantity_equation.hpp -> quantity.hpp is a cycle.
33// What it cannot see is that the cycle is closed by `#pragma once` on the
34// second visit, which is the mechanism the paragraph above relies on rather
35// than an accident it survives. The check's remedy -- break the edge -- is the
36// state this file was deliberately moved away from, and it would restore the
37// standalone-analysis failure the paragraph describes. Suppressed here rather
38// than at the definition because this one include is the only cycle in the
39// tree; if a second appears, it should be argued for on its own.
40// NOLINTNEXTLINE(misc-header-include-cycle)
41#include "../util/quantity.hpp"
42#include "../util/rational.hpp"
43
44namespace morph::units::detail {
45
46/// @brief Formats an optional value as `equation()` prints numbers (decimals).
47/// @param value The optional payload.
48/// @return The formatted number, or `"N/A"` when empty.
49[[nodiscard]] inline std::string formatOptional(const std::optional<morph::math::Rational>& value) {
50 return formatOptionalDecimal(value);
51}
52
53/// @brief Whether a node is a plain leaf (no recorded operation).
54/// @param node The node.
55/// @return `true` for a leaf.
56[[nodiscard]] inline bool isLeafNode(const ASTNode& node) { return node.current.operation.empty(); }
57
58/// @brief Whether a node is a unit-conversion step.
59/// @param node The node.
60/// @return `true` for a conversion.
61[[nodiscard]] inline bool isConversionNode(const ASTNode& node) {
62 return node.current.operation.starts_with("convert");
63}
64
65/// @brief Whether a node renders as a single atom (leaf or conversion value).
66/// @param node The node.
67/// @return `true` when atomic.
68[[nodiscard]] inline bool isAtomNode(const ASTNode& node) { return isLeafNode(node) || isConversionNode(node); }
69
70/// @brief The value a node contributes (leaf value, else the step result).
71/// @param node The node. Borrowed: the returned reference points into it, so it
72/// must outlive every use of the result.
73/// @return The node's optional value.
74[[nodiscard]] inline const std::optional<morph::math::Rational>& nodeValue(const ASTNode& node MORPH_LIFETIMEBOUND) {
75 return isLeafNode(node) ? node.current.lhs : node.current.result;
76}
77
78/// @brief A rendered subexpression plus the precedence of its top operator.
79struct Rendered {
80 /// @brief The rendered text.
81 std::string text;
82
83 /// @brief Precedence of the outermost operator (100 for an atom).
84 int precedence{100};
85};
86
87/// @brief Which of the two renderings `EquationRenderer::render` produces.
88enum class RenderMode : std::uint8_t {
89 /// @brief Symbolic: a named node prints as its name and a reused node as
90 /// its `cK` placeholder, neither expanded.
91 symbolic,
92
93 /// @brief Substituted: a named node and a reused node both print as their
94 /// value.
95 substituted,
96};
97
98/// @brief One suspended visit in the iterative renderer — what a recursive
99/// `renderSymbolic` call would have kept in its stack frame.
100struct RenderFrame {
101 /// @brief The node being rendered.
102 const ASTNode* node{nullptr};
103
104 /// @brief Whether to expand @ref node itself rather than label it.
105 bool expandThis{false};
106
107 /// @brief 0 = not started, 1 = left operand rendered, 2 = both rendered.
108 int stage{0};
109
110 /// @brief The left operand's rendering, once stage 1 is reached.
111 Rendered left;
112};
113
114/// @brief One suspended visit in the iterative `assignLabels` walk.
115struct LabelFrame {
116 /// @brief The node to label or descend through.
117 const ASTNode* node{nullptr};
118
119 /// @brief Whether to expand @ref node itself rather than label it.
120 bool expandThis{false};
121};
122
123/// @brief Stateful renderer for one `equation()` call.
124struct EquationRenderer {
125 /// @brief Builds a renderer with a step limit.
126 /// @param steps How many derivation steps may be written out in total.
127 explicit EquationRenderer(std::size_t steps) : maxSteps(steps), stepBudget(steps) {}
128
129 /// @brief The limit this render was asked for (quoted in the legend).
130 std::size_t maxSteps;
131
132 /// @brief How many steps `assignLabels` may still spend expanding.
133 std::size_t stepBudget;
134
135 /// @brief Placeholder number per reused, unnamed node (1-based).
136 std::unordered_map<const ASTNode*, std::size_t> labelIndex;
137
138 /// @brief In-edge count per node across displayed (non-opaque) paths.
139 std::unordered_map<const ASTNode*, int> refCount;
140
141 /// @brief Nodes visited during ref counting (dedup).
142 std::unordered_set<const ASTNode*> seen;
143
144 /// @brief Reused-node placeholders, in first-appearance order.
145 std::vector<const ASTNode*> placeholderOrder;
146
147 /// @brief Nodes `assignLabels` has already settled (label, elision, or
148 /// neither). A node's second visit can only repeat the first one's
149 /// verdict, so it is skipped.
150 std::unordered_set<const ASTNode*> settled;
151
152 /// @brief Elision number per node the step limit cut (1-based).
153 std::unordered_map<const ASTNode*, std::size_t> elisionIndex;
154
155 /// @brief Elided nodes, in first-appearance order.
156 std::vector<const ASTNode*> elisionOrder;
157
158 /// @brief Whether a node earns a placeholder (reused). Only ever called on
159 /// unnamed nodes recorded by `countRefs` (callers return early on
160 /// named nodes), so the lookup is total.
161 /// @param node The node (never null, never named at any call site).
162 /// @return `true` when it is a placeholder.
163 [[nodiscard]] bool isPlaceholder(const ASTNode* node) const { return refCount.at(node) >= 2; }
164
165 /// @brief Counts in-edges through displayed paths (stops at opaque nodes).
166 ///
167 /// Iterative, over an explicit worklist, for the reason spelled out on
168 /// `render` below: the derivation of a running total is a linear chain and
169 /// a recursive walk of it overflows the stack. Each node is
170 /// counted exactly once (the `seen` set), so the worklist order does not
171 /// affect the counts.
172 /// @param root The node to start from (may be null).
173 void countRefs(const ASTNode* root) {
174 std::vector<const ASTNode*> pending;
175 pending.push_back(root);
176 while (!pending.empty()) {
177 const ASTNode* node = pending.back();
178 pending.pop_back();
179 // Children are pushed unconditionally and the null case handled
180 // here, exactly as the recursive form handled it on entry: the
181 // absent operand of a leaf, unary or scalar step is a null child.
182 if (node == nullptr || seen.contains(node)) {
183 continue;
184 }
185 seen.insert(node);
186 if (node->name.has_value() || isAtomNode(*node)) {
187 continue;
188 }
189 if (node->left) {
190 ++refCount[node->left.get()];
191 }
192 if (node->right) {
193 ++refCount[node->right.get()];
194 }
195 pending.push_back(node->left.get());
196 pending.push_back(node->right.get());
197 }
198 }
199
200 /// @brief Assigns placeholder labels, and decides where the step limit cuts.
201 ///
202 /// Iterative for the same reason as `countRefs`. First appearance is a
203 /// left-before-right pre-order, so the right child is pushed first and the
204 /// left one popped first — the order a recursive walk would have visited
205 /// them in.
206 ///
207 /// This pass is also where `maxSteps` is spent, and it is spent **once**
208 /// for the whole `equation()` call rather than per rendering. The two
209 /// renderings and every legend line then consult the one `elisionIndex`
210 /// they all share, so the formula, the substitution and the legend agree
211 /// on which sub-derivations were written out — which a per-rendering
212 /// budget could not guarantee, since the legend renders subtrees the
213 /// formula stops short of.
214 ///
215 /// A step is one **operation node expanded**: atoms (leaves, conversions)
216 /// and named nodes render as a single token and cost nothing. Past the
217 /// budget a node is *elided* — it takes an `eK` label and its children are
218 /// not walked — and elision wins over a `cK` placeholder, because a
219 /// placeholder's legend line would expand the very subtree the limit just
220 /// declined to render.
221 /// @param root The node to start from.
222 /// @param expandRoot Whether to expand @p root itself (rather than label it).
223 void assignLabels(const ASTNode* root, bool expandRoot) {
224 std::vector<LabelFrame> pending;
225 pending.push_back(LabelFrame{.node = root, .expandThis = expandRoot});
226 while (!pending.empty()) {
227 LabelFrame const frame = pending.back();
228 pending.pop_back();
229 const ASTNode* node = frame.node;
230 if (node == nullptr || node->name.has_value()) {
231 continue;
232 }
233 // A node reachable by several displayed paths is settled by its
234 // first visit; re-walking it would assign nothing new and, on a
235 // DAG, costs one walk per path rather than per node. Measured: 31
236 // nodes built by repeated `q = q + q` have 2^30 paths and take
237 // 10.3 s to render 33 short lines without this test.
238 if (!settled.insert(node).second) {
239 continue;
240 }
241 bool const labelIt = !frame.expandThis && isPlaceholder(node);
242 // An atom is a token in every rendering, so it is not a step and
243 // the budget does not apply to it — only its placeholder does.
244 if (isAtomNode(*node)) {
245 if (labelIt) {
246 labelIndex.emplace(node, placeholderOrder.size() + 1);
247 placeholderOrder.push_back(node);
248 }
249 continue;
250 }
251 if (stepBudget == 0) {
252 elisionIndex.emplace(node, elisionOrder.size() + 1);
253 elisionOrder.push_back(node);
254 continue;
255 }
256 --stepBudget;
257 if (labelIt) {
258 labelIndex.emplace(node, placeholderOrder.size() + 1);
259 placeholderOrder.push_back(node);
260 }
261 pending.push_back(LabelFrame{.node = node->right.get(), .expandThis = false});
262 pending.push_back(LabelFrame{.node = node->left.get(), .expandThis = false});
263 }
264 }
265
266 /// @brief Parenthesises and joins a binary subexpression.
267 ///
268 /// Takes its left operand **by value** and appends to it rather than
269 /// concatenating both sides into a fresh string. The left operand of a
270 /// left-leaning chain — the shape `total = total + row` records — is the
271 /// whole expression rendered so far, so copying it once per level makes
272 /// rendering quadratic in the depth — measured at 27.7 s for a
273 /// 350,001-character line at 70,000 steps. Appending makes that shape
274 /// linear, amortised. A **right**-leaning chain (`a + (b + (c + …))`) is
275 /// still quadratic — the big operand is on the copied side — and so is a
276 /// chain of unary negations, which has to prepend; `equation()`'s step
277 /// limit is what bounds those, not this.
278 /// @param op The operator token.
279 /// @param left Rendered left operand; moved from, so callers pass an
280 /// operand they are done with.
281 /// @param right Rendered right operand.
282 /// @return The combined rendering.
283 [[nodiscard]] static Rendered combine(const std::string& op, Rendered left, const Rendered& right) {
284 int const precedence = (op == "*" || op == "/") ? 2 : 1;
285 std::string text = std::move(left.text);
286 if (left.precedence < precedence) {
287 text.insert(0, 1, '(');
288 text += ')';
289 }
290 text += ' ';
291 text += op;
292 text += ' ';
293 bool const rightNeedsParens =
294 (right.precedence < precedence) || (right.precedence == precedence && (op == "-" || op == "/"));
295 if (rightNeedsParens) {
296 text += '(';
297 text += right.text;
298 text += ')';
299 } else {
300 text += right.text;
301 }
302 return Rendered{.text = std::move(text), .precedence = precedence};
303 }
304
305 /// @brief Renders a unary-negation subexpression.
306 /// @param operand Rendered operand.
307 /// @return The combined rendering.
308 [[nodiscard]] static Rendered combineUnary(const Rendered& operand) {
309 std::string const text = (operand.precedence <= 1) ? "-(" + operand.text + ")" : "-" + operand.text;
310 return Rendered{.text = text, .precedence = 3};
311 }
312
313 /// @brief The rendering of a node that stops the walk — a name, a `cK`
314 /// placeholder, a leaf value or a conversion result.
315 /// @param node The node.
316 /// @param expandThis Whether to expand @p node even if named/placeholder.
317 /// @param mode Symbolic or substituted.
318 /// @return The atom's rendering, or `std::nullopt` when @p node has
319 /// operands that must be rendered first.
320 [[nodiscard]] std::optional<Rendered> atomRendering(const ASTNode* node, bool expandThis, RenderMode mode) const {
321 // Checked first, and before `isPlaceholder`: a node can be both reused
322 // and past the limit, and `assignLabels` gives such a node an `eK` and
323 // no `cK` — so asking about the placeholder first would look up a
324 // label that was deliberately never assigned. An elided node is never
325 // named and never the root, so `expandThis` cannot be set on it.
326 if (auto const elided = elisionIndex.find(node); elided != elisionIndex.end()) {
327 if (mode == RenderMode::symbolic) {
328 return Rendered{.text = "e" + std::to_string(elided->second), .precedence = 100};
329 }
330 return Rendered{.text = formatOptional(nodeValue(*node)), .precedence = 100};
331 }
332 if (mode == RenderMode::symbolic) {
333 if (node->name.has_value()) {
334 return Rendered{.text = "\"" + *node->name + "\"", .precedence = 100};
335 }
336 if (!expandThis && isPlaceholder(node)) {
337 return Rendered{.text = "c" + std::to_string(labelIndex.at(node)), .precedence = 100};
338 }
339 } else if (!expandThis && (node->name.has_value() || isPlaceholder(node))) {
340 return Rendered{.text = formatOptional(nodeValue(*node)), .precedence = 100};
341 }
342 if (isLeafNode(*node)) {
343 return Rendered{.text = formatOptional(node->current.lhs), .precedence = 100};
344 }
345 if (isConversionNode(*node)) {
346 return Rendered{.text = formatOptional(node->current.result), .precedence = 100};
347 }
348 return std::nullopt;
349 }
350
351 /// @brief Renders a node, iteratively.
352 ///
353 /// The walk is an explicit stack rather than recursion because the
354 /// derivation of a running total (`total = total + x` in a loop) is a
355 /// linear chain one node deep per iteration, and a recursive walk of it
356 /// runs the stack out. Measured on a recursive walk with an 8 MiB stack:
357 /// `equation()` returned at 24,000 nodes and segfaulted inside
358 /// `renderSymbolic` at 25,000 under clang `-O0`, returned at 50,000 and
359 /// segfaulted at 60,000 under clang `-O2`, and segfaulted already at
360 /// 40,000 under gcc `-O2`. Optimisation only moved the limit: unlike the
361 /// operand destructor chain, this recursion cannot be rewritten into a
362 /// loop, because the frames hold live `Rendered` strings across the call.
363 ///
364 /// Each frame resumes where its recursive twin would have: stage 0 renders
365 /// the node or descends left, stage 1 takes the left result and descends
366 /// right, stage 2 combines. `finished` carries the rendering of the frame
367 /// that just popped, in place of a return value.
368 /// @param root The node to render.
369 /// @param expandRoot Whether to expand @p root even if named/placeholder.
370 /// @param mode Symbolic or substituted.
371 /// @return The rendering of @p root.
372 [[nodiscard]] Rendered render(const ASTNode* root, bool expandRoot, RenderMode mode) const {
373 std::vector<RenderFrame> stack;
374 stack.push_back(RenderFrame{.node = root, .expandThis = expandRoot});
375 Rendered finished;
376 while (!stack.empty()) {
377 RenderFrame& top = stack.back();
378 if (top.stage == 0) {
379 if (std::optional<Rendered> atom = atomRendering(top.node, top.expandThis, mode)) {
380 finished = *std::move(atom);
381 stack.pop_back();
382 continue;
383 }
384 top.stage = 1;
385 if (top.node->left) {
386 const ASTNode* child = top.node->left.get();
387 stack.push_back(RenderFrame{.node = child, .expandThis = false});
388 continue;
389 }
390 finished = Rendered{.text = formatOptional(top.node->current.lhs), .precedence = 100};
391 continue;
392 }
393 if (top.stage == 1) {
394 top.left = std::move(finished);
395 // Put the slot back into a known state rather than leaving it
396 // moved-from: it is read again below, on the pop that follows.
397 finished = Rendered{};
398 bool const hasRight = top.node->right || top.node->current.rhs.has_value();
399 if (!hasRight) {
400 finished = combineUnary(top.left);
401 stack.pop_back();
402 continue;
403 }
404 top.stage = 2;
405 if (top.node->right) {
406 const ASTNode* child = top.node->right.get();
407 stack.push_back(RenderFrame{.node = child, .expandThis = false});
408 continue;
409 }
410 finished = Rendered{.text = formatOptional(top.node->current.rhs), .precedence = 100};
411 continue;
412 }
413 finished = combine(top.node->current.operation, std::move(top.left), finished);
414 stack.pop_back();
415 }
416 return finished;
417 }
418
419 /// @brief Renders a node symbolically (names, placeholders, inlined ops).
420 /// @param node The node.
421 /// @param expandThis Whether to expand @p node even if it is a placeholder.
422 /// @return The symbolic rendering.
423 [[nodiscard]] Rendered renderSymbolic(const ASTNode* node, bool expandThis) const {
424 return render(node, expandThis, RenderMode::symbolic);
425 }
426
427 /// @brief Renders a node with values substituted for symbols/placeholders.
428 /// @param node The node.
429 /// @param expandThis Whether to expand @p node even if named/placeholder.
430 /// @return The substituted rendering.
431 [[nodiscard]] Rendered renderSubstituted(const ASTNode* node, bool expandThis) const {
432 return render(node, expandThis, RenderMode::substituted);
433 }
434
435 /// @brief Builds one `where`-legend line for a placeholder.
436 /// @param node The placeholder node.
437 /// @return The legend body (`cK = ...`).
438 [[nodiscard]] std::string legendLine(const ASTNode* node) const {
439 std::string const label = "c" + std::to_string(labelIndex.at(node));
440 if (isAtomNode(*node)) {
441 return label + " = " + formatOptional(nodeValue(*node));
442 }
443 return label + " = " + renderSymbolic(node, true).text + " = " + renderSubstituted(node, true).text + " = " +
444 formatOptional(node->current.result);
445 }
446
447 /// @brief Builds one `where`-legend line for an elided sub-derivation.
448 ///
449 /// Value only, and self-describing: an `eK` stands for work that was *not*
450 /// written out, so the line says so and names the limit that cut it rather
451 /// than leaving a caller to wonder why a number appeared where a formula
452 /// was expected.
453 /// @param node The elided node.
454 /// @return The legend body (`eK = <value> (elided at the <N>-step limit)`).
455 [[nodiscard]] std::string elisionLine(const ASTNode* node) const {
456 return "e" + std::to_string(elisionIndex.at(node)) + " = " + formatOptional(nodeValue(*node)) +
457 " (elided at the " + std::to_string(maxSteps) + "-step limit)";
458 }
459};
460
461} // namespace morph::units::detail
462
463namespace morph::units {
464
465template <auto U, std::uint32_t DeclaredDecimals>
466 requires UnitEnum<decltype(U)>
467std::vector<std::string> Quantity<U, DeclaredDecimals>::equation(std::size_t maxSteps) const {
468 if (!payload) {
469 return {detail::formatOptional(payload)};
470 }
471 // Asked for no steps at all: the same one-element answer a build with
472 // tracing compiled out gives, since nothing of the derivation may be shown.
473 if (maxSteps == 0) {
474 return {detail::formatOptional(payload)};
475 }
476 const detail::ASTNode* root = _ctx.node.get();
477 if (root == nullptr) {
478 return {detail::formatOptional(payload)};
479 }
480 if (root->name.has_value()) {
481 return {"\"" + *root->name + "\""};
482 }
483 if (detail::isLeafNode(*root)) {
484 return {detail::formatOptional(root->current.lhs)};
485 }
486 if (detail::isConversionNode(*root)) {
487 return {detail::formatOptional(root->current.result)};
488 }
489
490 // The reference count still walks the whole DAG: reuse is a property of
491 // the derivation, not of how much of it gets printed, and a value shown
492 // once must not be given a placeholder just because the limit hid its
493 // other uses. That walk is linear and iterative; only the *rendering* is
494 // what `maxSteps` bounds.
495 detail::EquationRenderer renderer{maxSteps};
496 renderer.countRefs(root);
497 renderer.assignLabels(root, true);
498
499 std::vector<std::string> lines;
500 lines.push_back(renderer.renderSymbolic(root, true).text);
501 lines.push_back(" = " + renderer.renderSubstituted(root, true).text);
502 lines.push_back(" = " + detail::formatOptional(root->current.result));
503 // Placeholders first, then elisions: both are things the formula referred
504 // to by label, in the order the formula introduced them. The first legend
505 // line of either kind carries the `where `, the rest align under it.
506 bool firstLegendLine = true;
507 auto const appendLegend = [&lines, &firstLegendLine](const std::string& body) {
508 lines.push_back((firstLegendLine ? "where " : " ") + body);
509 firstLegendLine = false;
510 };
511 for (const detail::ASTNode* node : renderer.placeholderOrder) {
512 appendLegend(renderer.legendLine(node));
513 }
514 for (const detail::ASTNode* node : renderer.elisionOrder) {
515 appendLegend(renderer.elisionLine(node));
516 }
517 return lines;
518}
519
520} // namespace morph::units
Node text(TextOptions const &options)
A text node.
Definition author.hpp:3126
Node steps(AnyHandle const &items, AnyHandle const &current={})
A steps node.
Definition author.hpp:4034
Concept: an enum with a UnitTraits specialisation (template param E).
Definition quantity.hpp:278
Unit-tagged, optionally-empty exact values with a derivation trail.
Exact rational arithmetic for morph model/action values.
std::vector< std::string > equation(std::size_t maxSteps=kDefaultEquationSteps) const
The worked derivation as print-ready lines (see docs/spec/util/quantity_type.md).
Definition quantity.hpp:894