ProvSQL C/C++ API
Adding support for provenance and uncertainty management to PostgreSQL databases
Loading...
Searching...
No Matches
HybridEvaluator.cpp
Go to the documentation of this file.
1/**
2 * @file HybridEvaluator.cpp
3 * @brief Implementation of the peephole simplifier.
4 * See @c HybridEvaluator.h for the full docstring.
5 */
6#include "HybridEvaluator.h"
7
8#include <array>
9#include <charconv>
10#include <cmath>
11#include <iomanip>
12#include <limits>
13#include <optional>
14#include <sstream>
15#include <stack>
16#include <string>
17#include <system_error>
18#include <unordered_set>
19#include <utility>
20#include <vector>
21
22#include "Aggregation.h" // ComparisonOperator, cmpOpFromOid
23#include "AnalyticEvaluator.h" // cdfAt
24#include "Expectation.h" // evaluateBooleanProbability
25#include "MonteCarloSampler.h" // monteCarloRV, monteCarloScalarSamples
26#include "RandomVariable.h" // parse_distribution_spec, parseDoubleStrict, DistKind
27extern "C" {
28#include "provsql_utils.h" // gate_type, provsql_arith_op
29}
30#include <algorithm> // std::sort, std::unique, std::upper_bound
31
32namespace provsql {
33
34namespace {
35
36constexpr double NaN = std::numeric_limits<double>::quiet_NaN();
37
38/**
39 * @brief Format a double back into the canonical text form used by
40 * @c gate_value extras.
41 *
42 * @c std::to_chars produces the shortest decimal representation that
43 * round-trips through @c std::from_chars / @c std::stod, so round
44 * cases like @c 0.2 = 0.4/2 print as @c "0.2" rather than
45 * @c "0.20000000000000001" while irrational values fall back to
46 * whatever length is needed for exact recovery. The legacy
47 * @c std::ostringstream @c << @c setprecision(17) path is kept as a
48 * defensive fallback in case @c to_chars fails (range / buffer).
49 *
50 * @c std::ostringstream is used rather than @c std::snprintf in the
51 * fallback because including @c <cstdio> after PostgreSQL's @c port.h
52 * would expand @c std::snprintf to the non-existent
53 * @c std::pg_snprintf via the @c #define snprintf macro.
54 */
55std::string double_to_text(double v)
56{
57 std::array<char, 32> buf;
58 auto [ptr, ec] = std::to_chars(buf.data(), buf.data() + buf.size(), v);
59 if (ec == std::errc{}) return std::string(buf.data(), ptr);
60 std::ostringstream oss;
61 oss << std::setprecision(17) << v;
62 return oss.str();
63}
64
65/**
66 * @brief Try to evaluate a @c gate_arith subtree to a scalar constant.
67 *
68 * Recurses over the @c gate_arith ops, parsing @c gate_value leaves
69 * via @c parseDoubleStrict. Returns @c NaN if any leaf is not a
70 * @c gate_value (or fails to parse), if a binary op has the wrong
71 * arity, or if any arith op is unknown. Successful constants of any
72 * value (including @c 0 and @c NaN-shaped values via division) are
73 * returned as @c double literals; the caller distinguishes
74 * "couldn't fold" from "folded to NaN" via @c std::isnan on the
75 * input gate's children, not on the result. In practice provsql
76 * @c gate_value extras never carry @c NaN, so the @c NaN-as-sentinel
77 * convention is unambiguous.
78 */
79double try_eval_constant(const GenericCircuit &gc, gate_t g)
80{
81 auto t = gc.getGateType(g);
82 if (t == gate_value) {
83 try { return parseDoubleStrict(gc.getExtra(g)); }
84 catch (const CircuitException &) { return NaN; }
85 }
86 if (t != gate_arith) return NaN;
87
88 auto op = static_cast<provsql_arith_op>(gc.getInfos(g).first);
89 const auto &wires = gc.getWires(g);
90 if (wires.empty()) return NaN;
91
92 double first = try_eval_constant(gc, wires[0]);
93 if (std::isnan(first)) return NaN;
94
95 switch (op) {
96 case PROVSQL_ARITH_PLUS: {
97 double r = first;
98 for (std::size_t i = 1; i < wires.size(); ++i) {
99 double v = try_eval_constant(gc, wires[i]);
100 if (std::isnan(v)) return NaN;
101 r += v;
102 }
103 return r;
104 }
105 case PROVSQL_ARITH_TIMES: {
106 double r = first;
107 for (std::size_t i = 1; i < wires.size(); ++i) {
108 double v = try_eval_constant(gc, wires[i]);
109 if (std::isnan(v)) return NaN;
110 r *= v;
111 }
112 return r;
113 }
114 case PROVSQL_ARITH_MINUS: {
115 if (wires.size() != 2) return NaN;
116 double v = try_eval_constant(gc, wires[1]);
117 if (std::isnan(v)) return NaN;
118 return first - v;
119 }
120 case PROVSQL_ARITH_DIV: {
121 if (wires.size() != 2) return NaN;
122 double v = try_eval_constant(gc, wires[1]);
123 if (std::isnan(v)) return NaN;
124 return first / v;
125 }
127 if (wires.size() != 1) return NaN;
128 return -first;
129 }
130 return NaN;
131}
132
133/**
134 * @brief Whether the subtree rooted at @p g contains a @c gate_agg.
135 *
136 * The hybrid simplifier is RV-oriented; aggregate arithmetic
137 * (@c gate_arith over @c gate_agg) is a separate feature whose
138 * comparisons are resolved by the HAVING possible-worlds enumeration,
139 * which must see the original operators to apply the correct (integer
140 * floor vs real) division semantics. Rewrites that are sound for
141 * continuous RVs but not for aggregates (notably the DIV-by-constant to
142 * TIMES-by-reciprocal canonicalisation, which discards integer-division
143 * flooring) consult this to leave aggregate subtrees untouched.
144 */
145bool subtree_contains_agg(const GenericCircuit &gc, gate_t g)
146{
147 std::unordered_set<gate_t> seen;
148 std::stack<gate_t> stk;
149 stk.push(g);
150 while (!stk.empty()) {
151 gate_t cur = stk.top(); stk.pop();
152 if (!seen.insert(cur).second) continue;
153 if (gc.getGateType(cur) == gate_agg) return true;
154 for (gate_t ch : gc.getWires(cur)) stk.push(ch);
155 }
156 return false;
157}
158
159/**
160 * @brief Rewrite @p g in place as a @c gate_value carrying @p c.
161 *
162 * Clears wires and infos; the old children become orphans (no parent
163 * reaches them via @p g anymore). This is the same pattern
164 * @c resolveCmpToBernoulli uses for resolved comparators.
165 */
166void replace_with_value(GenericCircuit &gc, gate_t g, double c)
167{
168 gc.resolveToValue(g, double_to_text(c));
169}
170
171/**
172 * @brief Rewrite @p g in place as a normal @c gate_rv with parameters
173 * @p mean and @p sigma.
174 *
175 * Used by the normal-family closure when a PLUS over linear
176 * combinations of independent normals folds to a single normal.
177 * Sigma is the standard deviation (consistent with the on-disk
178 * @c "normal:μ,σ" encoding).
179 */
180void replace_with_normal_rv(GenericCircuit &gc, gate_t g,
181 double mean, double sigma)
182{
183 gc.resolveToRv(g, "normal:" + double_to_text(mean)
184 + "," + double_to_text(sigma));
185}
186
187/**
188 * @brief Rewrite @p g in place as an Erlang @c gate_rv with shape
189 * @p k and rate @p lambda.
190 */
191void replace_with_erlang_rv(GenericCircuit &gc, gate_t g,
192 unsigned long k, double lambda)
193{
194 gc.resolveToRv(g, "erlang:" + std::to_string(k)
195 + "," + double_to_text(lambda));
196}
197
198/**
199 * @brief Rewrite @p g in place as a uniform @c gate_rv on @c [lo, hi].
200 *
201 * Used by the uniform-family closure (additive offset on a single
202 * uniform term inside a PLUS, possibly with a negative scalar
203 * coefficient that flips the support bounds) and by @c try_neg_rv
204 * when @c -U(a,b) folds to @c U(-b,-a).
205 *
206 * Caller is responsible for ordering @c lo <= @c hi (we don't sort
207 * defensively, so a swap-bounds bug elsewhere shows up immediately).
208 */
209void replace_with_uniform_rv(GenericCircuit &gc, gate_t g,
210 double lo, double hi)
211{
212 gc.resolveToRv(g, "uniform:" + double_to_text(lo)
213 + "," + double_to_text(hi));
214}
215
216/**
217 * @brief Test whether wire @p g is a @c gate_value parseable to
218 * scalar @p target (within bit-exact equality).
219 */
220bool is_value_equal_to(const GenericCircuit &gc, gate_t g, double target)
221{
222 if (gc.getGateType(g) != gate_value) return false;
223 try { return parseDoubleStrict(gc.getExtra(g)) == target; }
224 catch (const CircuitException &) { return false; }
225}
226
227/**
228 * @brief Identity-element drop for @c PLUS / @c TIMES.
229 *
230 * - @c PLUS: drop @c gate_value:0 wires. If 0 wires remain, fold to
231 * @c gate_value:0.
232 * - @c TIMES: if any wire is @c gate_value:0, fold to @c gate_value:0
233 * (multiplicative absorber, even if other wires are non-constant).
234 * Otherwise drop @c gate_value:1 wires; if 0 wires remain, fold to
235 * @c gate_value:1.
236 *
237 * Returns @c true if @p g was mutated. After a mutation that leaves
238 * @p g as @c gate_arith, the per-gate fixed-point loop in @c simplify
239 * re-runs the rules: a @c PLUS that had three wires reduced to one
240 * looks the same as the original input to the simplifier, so we just
241 * need to terminate when no rule fires.
242 */
243bool try_identity_drop(GenericCircuit &gc, gate_t g)
244{
245 auto op = static_cast<provsql_arith_op>(gc.getInfos(g).first);
246 auto &wires = gc.getWires(g);
247
248 if (op == PROVSQL_ARITH_PLUS) {
249 std::vector<gate_t> kept;
250 kept.reserve(wires.size());
251 for (gate_t w : wires) {
252 if (!is_value_equal_to(gc, w, 0.0)) kept.push_back(w);
253 }
254 if (kept.size() == wires.size()) return false; /* nothing to drop */
255 if (kept.empty()) {
256 replace_with_value(gc, g, 0.0);
257 return true;
258 }
259 wires = std::move(kept);
260 return true;
261 }
262
263 if (op == PROVSQL_ARITH_TIMES) {
264 for (gate_t w : wires) {
265 if (is_value_equal_to(gc, w, 0.0)) {
266 replace_with_value(gc, g, 0.0);
267 return true;
268 }
269 }
270 std::vector<gate_t> kept;
271 kept.reserve(wires.size());
272 for (gate_t w : wires) {
273 if (!is_value_equal_to(gc, w, 1.0)) kept.push_back(w);
274 }
275 if (kept.size() == wires.size()) return false;
276 if (kept.empty()) {
277 replace_with_value(gc, g, 1.0);
278 return true;
279 }
280 wires = std::move(kept);
281 return true;
282 }
283
284 return false;
285}
286
287/**
288 * @brief Decomposition of a PLUS-wire as @c a*Z + b for the
289 * normal-family closure.
290 *
291 * - @c rv_gate == invalid (sentinel @c (gate_t)-1) ⇒ pure constant
292 * wire: contributes @p b to the total mean, 0 to the total
293 * variance, and no RV to the footprint.
294 * - @c rv_gate != invalid ⇒ scalar-multiple-of-normal wire:
295 * contributes @c a*μ + b to the total mean, @c a²σ² to the total
296 * variance, and @p rv_gate to the footprint.
297 */
298struct LinearTerm {
299 gate_t rv_gate; ///< Base normal gate_rv, or invalid for constants.
300 double a; ///< Scalar multiplier (0 for pure constants).
301 double b; ///< Additive offset (0 for pure RV wires).
302};
303
304constexpr gate_t INVALID_GATE = static_cast<gate_t>(-1);
305
306bool is_invalid(gate_t g) { return g == INVALID_GATE; }
307
308/**
309 * @brief Try to interpret @p g as @c a*Z + b for a single base RV.
310 *
311 * Recognised shapes:
312 * - bare @c gate_rv (any distribution): @c (Z=g, a=1, b=0)
313 * - bare @c gate_value: @c (Z=invalid, a=0, b=value)
314 * - @c arith(NEG, child): negate the child's decomposition
315 * - @c arith(TIMES, value:c, child): scale the child's decomposition
316 * by @c c (and symmetrically @c arith(TIMES, child, value:c)).
317 * Only 2-wire @c TIMES with exactly one @c gate_value side is
318 * recognised; other shapes fall through to "not decomposable".
319 *
320 * Nested @c arith(PLUS, ...) children of the outer PLUS are not
321 * decomposed by this routine: the bottom-up simplifier already
322 * folded them before the outer PLUS is processed, so by the time
323 * we examine the outer PLUS its children are either leaves or
324 * non-foldable arith. An undecomposable wire causes the caller to
325 * bail.
326 *
327 * Distribution-kind filtering is the caller's responsibility:
328 * @c try_normal_closure additionally requires every @p rv_gate to be
329 * @c DistKind::Normal, while @c try_plus_aggregate is kind-agnostic
330 * because the aggregation rewrite preserves the base-RV identity.
331 */
332std::optional<LinearTerm>
333decompose_linear_term(const GenericCircuit &gc, gate_t g)
334{
335 auto t = gc.getGateType(g);
336
337 if (t == gate_value) {
338 double v;
339 try { v = parseDoubleStrict(gc.getExtra(g)); }
340 catch (const CircuitException &) { return std::nullopt; }
341 return LinearTerm{INVALID_GATE, 0.0, v};
342 }
343
344 if (t == gate_rv) {
345 /* Any RV kind: aggregation only depends on identity, not on
346 * closed-form scaling. The normal-family closure filters to
347 * @c DistKind::Normal externally. */
348 return LinearTerm{g, 1.0, 0.0};
349 }
350
351 if (t == gate_mixture) {
352 /* A @c gate_mixture (3-wire Bernoulli or categorical N-wire) is a
353 * scalar-RV leaf: two references to the same @c gate_t produce
354 * perfectly-correlated draws of the same RV. Treat it like a
355 * @c gate_rv so the PLUS aggregator can collapse same-mixture
356 * terms (e.g. @c X+X to @c 2·X, @c X-X to @c 0). The in-place
357 * op-change to TIMES then triggers @c try_mixture_lift to push the
358 * scalar inside the branches (3-wire) or the mulinputs'
359 * value text (categorical). The normal- and Erlang-family closures
360 * filter on the rv leaf's kind via @c parse_distribution_spec, which
361 * returns @c nullopt on a mixture's empty extra, so they
362 * automatically bail when the LHS-RV side is a mixture. */
363 return LinearTerm{g, 1.0, 0.0};
364 }
365
366 if (t != gate_arith) return std::nullopt;
367
368 auto op = static_cast<provsql_arith_op>(gc.getInfos(g).first);
369 const auto &wires = gc.getWires(g);
370
371 /* After an identity-element drop, a PLUS or TIMES gate can be left
372 * with a single wire that semantically passes through. Recurse so
373 * the outer closure can still see the underlying term. We can't
374 * fold the singleton wrapper away in place (rewriting it as the
375 * child's type / extra would mint a fresh RV identity and break
376 * per-iteration MC memoisation across other parents of the child),
377 * but the outer closure rewrites the OUTER gate, which is safe. */
378 if ((op == PROVSQL_ARITH_PLUS || op == PROVSQL_ARITH_TIMES)
379 && wires.size() == 1) {
380 return decompose_linear_term(gc, wires[0]);
381 }
382
383 if (op == PROVSQL_ARITH_NEG) {
384 if (wires.size() != 1) return std::nullopt;
385 auto inner = decompose_linear_term(gc, wires[0]);
386 if (!inner) return std::nullopt;
387 return LinearTerm{inner->rv_gate, -inner->a, -inner->b};
388 }
389
390 if (op == PROVSQL_ARITH_TIMES) {
391 if (wires.size() != 2) return std::nullopt;
392 /* Identify the constant side and the variable side. */
393 double c = NaN;
394 gate_t var_side = INVALID_GATE;
395 if (gc.getGateType(wires[0]) == gate_value) {
396 try { c = parseDoubleStrict(gc.getExtra(wires[0])); }
397 catch (const CircuitException &) { return std::nullopt; }
398 var_side = wires[1];
399 } else if (gc.getGateType(wires[1]) == gate_value) {
400 try { c = parseDoubleStrict(gc.getExtra(wires[1])); }
401 catch (const CircuitException &) { return std::nullopt; }
402 var_side = wires[0];
403 } else {
404 return std::nullopt;
405 }
406 auto inner = decompose_linear_term(gc, var_side);
407 if (!inner) return std::nullopt;
408 return LinearTerm{inner->rv_gate, c * inner->a, c * inner->b};
409 }
410
411 return std::nullopt;
412}
413
414/**
415 * @brief Normal-family closure on a @c PLUS gate.
416 *
417 * If every wire decomposes to @c a*Z + b for an independent normal
418 * @c Z, replaces the gate with a single normal @c gate_rv whose
419 * parameters are the closed-form combinations. Independence is
420 * tested by collecting the base-RV footprint of each contributing
421 * normal and requiring pairwise-disjoint footprints; the
422 * @c decompose_normal_term restriction to bare normal leaves makes
423 * the footprint just @c {Z_i} for each non-constant wire, so the
424 * test reduces to "all @c Z_i are distinct UUIDs".
425 *
426 * When every wire is a pure constant (all RV-side empty), the closure
427 * is just the constant fold and we let the dedicated path handle it
428 * &ndash; this routine returns @c false so the fixed-point loop
429 * re-runs and the constant fold fires next.
430 */
431bool try_normal_closure(GenericCircuit &gc, gate_t g)
432{
433 const auto &wires = gc.getWires(g);
434 if (wires.size() < 2) return false;
435
436 std::vector<LinearTerm> terms;
437 terms.reserve(wires.size());
438 for (gate_t w : wires) {
439 auto term = decompose_linear_term(gc, w);
440 if (!term) return false;
441 /* The closure produces a single normal, so every non-constant
442 * term's base RV must itself be normal. The generic decomposer
443 * does not filter by kind; we apply the filter here. */
444 if (!is_invalid(term->rv_gate)) {
445 auto spec = parse_distribution_spec(gc.getExtra(term->rv_gate));
446 if (!spec || spec->kind != DistKind::Normal) return false;
447 }
448 terms.push_back(*term);
449 }
450
451 /* Independence test: every non-constant term must have a distinct
452 * Z gate_t. We also need at least one non-constant term (otherwise
453 * this is the pure-constant case and constant folding handles it). */
454 std::unordered_set<gate_t> seen_rvs;
455 bool has_rv = false;
456 for (const auto &t : terms) {
457 if (is_invalid(t.rv_gate)) continue;
458 has_rv = true;
459 if (!seen_rvs.insert(t.rv_gate).second) return false; /* dependent */
460 }
461 if (!has_rv) return false;
462
463 double total_mean = 0.0;
464 double total_var = 0.0;
465 for (const auto &t : terms) {
466 total_mean += t.b;
467 if (is_invalid(t.rv_gate)) continue;
468 auto spec = parse_distribution_spec(gc.getExtra(t.rv_gate));
469 if (!spec || spec->kind != DistKind::Normal) return false;
470 const double mu = spec->p1;
471 const double sigma = spec->p2;
472 total_mean += t.a * mu;
473 total_var += t.a * t.a * sigma * sigma;
474 }
475
476 /* Degenerate variance ⇒ the closure produces a Dirac at total_mean.
477 * We can keep this as a normal with σ=0, but the existing constructor
478 * silently routes σ=0 through @c as_random, and downstream consumers
479 * may not all handle σ=0 gracefully. Skip and let other passes deal
480 * with it (in practice this branch is unreachable: we required at
481 * least one a≠0 term, and σ=0 normals are constructed as gate_value
482 * by @c provsql.normal, so total_var > 0 whenever the closure fires). */
483 if (total_var <= 0.0) return false;
484
485 replace_with_normal_rv(gc, g, total_mean, std::sqrt(total_var));
486 return true;
487}
488
489/**
490 * @brief Erlang-family closure on a @c PLUS gate.
491 *
492 * Fires only on the strict shape <tt>PLUS(E1, ..., Ek)</tt> with
493 * k ≥ 2, each @c Ei a bare exponential @c gate_rv leaf, all rates
494 * equal, all UUIDs distinct. Replaces the gate with a single
495 * Erlang(k, λ) @c gate_rv. Mixed exponential/non-exponential wires
496 * or different rates leave the gate untouched (hypoexponential is
497 * outside the simplifier's family-closure scope; the sampler handles
498 * those via per-iteration draws).
499 */
500bool try_erlang_closure(GenericCircuit &gc, gate_t g)
501{
502 const auto &wires = gc.getWires(g);
503 if (wires.size() < 2) return false;
504
505 /* Accept any mix of bare Exp(λ) and Erlang(k, λ) gate_rv leaves
506 * with the same λ and pairwise-distinct UUIDs. Left-associative
507 * parsing of `a + b + c` builds `(a+b)+c` which bottom-up
508 * simplifies to Erlang(2)+c, so the closure has to recognise the
509 * Erlang+Exp shape to close the chain. Erlang(k1) + Erlang(k2) =
510 * Erlang(k1+k2) for the same rate; Exp is the k=1 case. */
511 double lambda = NaN;
512 unsigned long total_shape = 0;
513 std::unordered_set<gate_t> seen;
514 for (gate_t w : wires) {
515 if (gc.getGateType(w) != gate_rv) return false;
516 auto spec = parse_distribution_spec(gc.getExtra(w));
517 if (!spec) return false;
518 double w_lambda;
519 unsigned long w_shape;
520 if (spec->kind == DistKind::Exponential) {
521 w_lambda = spec->p1;
522 w_shape = 1;
523 } else if (spec->kind == DistKind::Erlang) {
524 /* Integer k stored in p1; non-integer is rejected upstream by
525 * the constructor, but guard defensively here so a corrupted
526 * extra cannot trigger an invalid shape sum. */
527 if (spec->p1 < 1.0 || spec->p1 != std::floor(spec->p1)) return false;
528 w_lambda = spec->p2;
529 w_shape = static_cast<unsigned long>(spec->p1);
530 } else {
531 return false;
532 }
533 if (!seen.insert(w).second) return false; /* shared UUID */
534 if (std::isnan(lambda)) lambda = w_lambda;
535 else if (lambda != w_lambda) return false; /* different rate */
536 total_shape += w_shape;
537 }
538
539 replace_with_erlang_rv(gc, g, total_shape, lambda);
540 return true;
541}
542
543/**
544 * @brief Uniform-family closure on a @c PLUS gate.
545 *
546 * Fires when every wire decomposes (via @c decompose_linear_term) to
547 * @c a*Z + b with at most one non-constant term whose @c gate_rv is a
548 * Uniform. The closure is @b not @c U + U: a sum of two distinct
549 * uniforms is not uniform (it's a triangle / trapezoidal density), so
550 * we bail when more than one Uniform term is present. Any number of
551 * pure-constant terms is fine (they collapse into a single additive
552 * offset).
553 *
554 * For a single Uniform term <tt>a*U(p1, p2) + b_total</tt>:
555 * - @c a > 0 ⇒ <tt>U(a*p1 + b_total, a*p2 + b_total)</tt>;
556 * - @c a < 0 ⇒ <tt>U(a*p2 + b_total, a*p1 + b_total)</tt> (sign flip
557 * reorders the bounds).
558 *
559 * @c a == 0 is unreachable here: @c decompose_linear_term only assigns
560 * @c a == 0 to pure-constant wires (where @c rv_gate is invalid), so a
561 * Uniform-bearing term always has @c a != 0. Same coupling caveat as
562 * @c try_normal_closure: replacing @p g with a fresh @c gate_rv mints
563 * a new RV identity, but @c try_plus_aggregate runs first and already
564 * collapsed any shared-UUID U references, so by the time this rule
565 * runs the surviving Uniform term has no sibling sharing its base RV.
566 */
567bool try_uniform_closure(GenericCircuit &gc, gate_t g)
568{
569 const auto &wires = gc.getWires(g);
570 if (wires.size() < 2) return false;
571
572 std::vector<LinearTerm> terms;
573 terms.reserve(wires.size());
574 for (gate_t w : wires) {
575 auto term = decompose_linear_term(gc, w);
576 if (!term) return false;
577 if (!is_invalid(term->rv_gate)) {
578 auto spec = parse_distribution_spec(gc.getExtra(term->rv_gate));
579 if (!spec || spec->kind != DistKind::Uniform) return false;
580 }
581 terms.push_back(*term);
582 }
583
584 /* Exactly one Uniform term (U + U is not closed). All other wires
585 * must be pure constants. We also need at least one Uniform
586 * (otherwise the constant-fold path is responsible). */
587 const LinearTerm *uniform = nullptr;
588 for (const auto &t : terms) {
589 if (is_invalid(t.rv_gate)) continue;
590 if (uniform) return false; /* second Uniform term */
591 uniform = &t;
592 }
593 if (!uniform) return false;
594
595 /* Sum every wire's additive offset. The Uniform term's @c b is
596 * an additive offset on the same wire as the RV; the closure
597 * adds it to the global offset since (a*U + b_term) + offsets =
598 * a*U + (b_term + offsets). */
599 double b_total = 0.0;
600 for (const auto &t : terms) b_total += t.b;
601
602 auto spec = parse_distribution_spec(gc.getExtra(uniform->rv_gate));
603 if (!spec || spec->kind != DistKind::Uniform) return false;
604 const double a = uniform->a;
605 const double p1 = spec->p1;
606 const double p2 = spec->p2;
607 const double new_lo = (a > 0.0) ? a * p1 + b_total : a * p2 + b_total;
608 const double new_hi = (a > 0.0) ? a * p2 + b_total : a * p1 + b_total;
609
610 replace_with_uniform_rv(gc, g, new_lo, new_hi);
611 return true;
612}
613
614/**
615 * @brief Negation closure on a bare @c gate_rv: rewrite @c arith(NEG, Z)
616 * as a closed-form-negated @c gate_rv when @c Z's family admits
617 * one.
618 *
619 * Supported families:
620 * - <tt>-Normal(μ, σ) = Normal(-μ, σ)</tt> (sign flip on mean, σ ≥ 0
621 * unchanged).
622 * - <tt>-Uniform(a, b) = Uniform(-b, -a)</tt> (sign flip reorders the
623 * bounds).
624 *
625 * Not closed (rule bails):
626 * - <tt>-Exponential(λ)</tt>: support flips to @c (-∞, 0], no longer
627 * exponential.
628 * - <tt>-Erlang(k, λ)</tt>: same support-flip issue.
629 *
630 * Coupling discipline: same as @c try_times_scalar_rv. Pass-2 gated
631 * so a parent PLUS containing @c NEG(Z) and a sibling reference to the
632 * same @c Z is folded first by @c try_plus_aggregate (which recognises
633 * @c NEG via @c decompose_linear_term's coefficient @c -1) before we
634 * mint a fresh @c gate_rv at the NEG.
635 */
636bool try_neg_rv(GenericCircuit &gc, gate_t g)
637{
638 if (gc.getGateType(g) != gate_arith) return false;
639 auto op = static_cast<provsql_arith_op>(gc.getInfos(g).first);
640 if (op != PROVSQL_ARITH_NEG) return false;
641 const auto &wires = gc.getWires(g);
642 if (wires.size() != 1) return false;
643 if (gc.getGateType(wires[0]) != gate_rv) return false;
644
645 auto spec = parse_distribution_spec(gc.getExtra(wires[0]));
646 if (!spec) return false;
647
648 switch (spec->kind) {
649 case DistKind::Normal:
650 replace_with_normal_rv(gc, g, -spec->p1, spec->p2);
651 return true;
653 replace_with_uniform_rv(gc, g, -spec->p2, -spec->p1);
654 return true;
656 case DistKind::Erlang:
657 return false;
658 }
659 return false;
660}
661
662/**
663 * @brief Mixture-lift rewrite: push @c PLUS / @c TIMES inside a
664 * single @c gate_mixture child.
665 *
666 * Fires on a @c gate_arith with op @c PLUS or @c TIMES whose children
667 * contain exactly one @c gate_mixture. Replaces the parent with a
668 * @c gate_mixture sharing the same Bernoulli (so the original
669 * <tt>p_token</tt> identity is preserved and any other gate that
670 * referenced it continues to see it):
671 *
672 * <tt>a + mixture(p, X, Y) → mixture(p, a + X, a + Y)</tt>
673 *
674 * The two new branches are fresh @c gate_arith children built via
675 * @c addAnonymousArithGate; each is then re-fed to @c apply_rules so
676 * the existing normal-family / erlang-family closures get a chance
677 * to collapse them. This is the source of the headline simplifier
678 * gain for compound RV expressions: <tt>3 + mixture(p, N(0,1), N(2,1))</tt>
679 * folds to <tt>mixture(p, N(3,1), N(5,1))</tt> in a single bottom-up
680 * pass.
681 *
682 * Multi-mixture lifts (two or more @c gate_mixture children of the
683 * same arith) are out of scope: each would multiply the branch count
684 * by 2 and the lifted form would couple the resulting branches
685 * through their Bernoullis, which the current closures cannot
686 * collapse further. @c MINUS / @c DIV / @c NEG lifts are also out of
687 * scope (the user requested only @c PLUS and @c TIMES); they can be
688 * added in a follow-up once the @c try_normal_closure handles
689 * subtraction.
690 *
691 * Returns @c true if @p g was mutated.
692 */
693unsigned apply_rules(GenericCircuit &gc, gate_t g,
694 bool include_scalar_fold); /* forward decl */
695
696/**
697 * @brief Categorical-mixture lift helper.
698 *
699 * Pushes a constant scaling (@c TIMES) or offset (@c PLUS) inside the
700 * N-wire categorical-form @c gate_mixture <tt>[key, mul_1, ..., mul_n]</tt>
701 * by minting a fresh categorical mixture sharing the same @p key gate
702 * and one new @c gate_mulinput per outcome with an updated value text.
703 *
704 * Sharing the key preserves the semantic that the new mixture is a
705 * deterministic function of the same underlying categorical draw (so
706 * <tt>c · X</tt> and @c X stay perfectly correlated downstream via
707 * FootprintCache key-overlap dependency tracking). All other arith
708 * wires must be @c gate_value constants; an RV factor / offset cannot
709 * be pushed into a mulinput's scalar @c extra so the rule bails.
710 *
711 * Returns @c true if @p g was mutated.
712 */
713bool try_categorical_mixture_lift(GenericCircuit &gc, gate_t g,
715 gate_t mix_gate,
716 const std::vector<gate_t> &others)
717{
718 if (op != PROVSQL_ARITH_PLUS && op != PROVSQL_ARITH_TIMES) return false;
719
720 /* Combine the non-mixture wires into a single scalar offset (PLUS)
721 * or factor (TIMES). Bail on any non-value wire: an RV factor /
722 * offset cannot be pushed into a mulinput's value text. */
723 double offset = 0.0;
724 double factor = 1.0;
725 for (gate_t w : others) {
726 if (gc.getGateType(w) != gate_value) return false;
727 double v;
728 try { v = parseDoubleStrict(gc.getExtra(w)); }
729 catch (const CircuitException &) { return false; }
730 if (op == PROVSQL_ARITH_PLUS) offset += v;
731 else factor *= v;
732 }
733
734 /* Build the new wire list: same key (preserves correlation with the
735 * original categorical) and one fresh mulinput per outcome with the
736 * transformed value text. Snapshot the mixture's wires by value:
737 * @c addAnonymousMulinputGateWithValue below calls @c addGate, which
738 * does @c wires.push_back({}) on the circuit's outer wire vector,
739 * and that can reallocate -- invalidating any reference returned by
740 * @c getWires. Reads of the reference after the first iteration
741 * then return garbage gate ids, which surfaces either as wrong
742 * outcome values or as a backend crash. */
743 const std::vector<gate_t> mw = gc.getWires(mix_gate);
744 const gate_t key = mw[0];
745 std::vector<gate_t> new_wires;
746 new_wires.reserve(mw.size());
747 new_wires.push_back(key);
748 for (std::size_t i = 1; i < mw.size(); ++i) {
749 const gate_t old_mul = mw[i];
750 double old_v;
751 try { old_v = parseDoubleStrict(gc.getExtra(old_mul)); }
752 catch (const CircuitException &) { return false; }
753 const double new_v = (op == PROVSQL_ARITH_PLUS)
754 ? (offset + old_v)
755 : (factor * old_v);
756 const double p = gc.getProb(old_mul);
757 const auto vi = static_cast<unsigned>(gc.getInfos(old_mul).first);
759 key, p, vi, double_to_text(new_v));
760 new_wires.push_back(new_mul);
761 }
762 gc.resolveToCategoricalMixture(g, std::move(new_wires));
763 return true;
764}
765
766bool try_mixture_lift(GenericCircuit &gc, gate_t g,
767 bool include_scalar_fold)
768{
769 auto op = static_cast<provsql_arith_op>(gc.getInfos(g).first);
770 if (op != PROVSQL_ARITH_PLUS && op != PROVSQL_ARITH_TIMES) return false;
771
772 const auto &wires = gc.getWires(g);
773 if (wires.size() < 2) return false; /* nothing to lift */
774
775 /* Find exactly one mixture child. */
776 std::size_t mix_idx = static_cast<std::size_t>(-1);
777 for (std::size_t i = 0; i < wires.size(); ++i) {
778 if (gc.getGateType(wires[i]) == gate_mixture) {
779 if (mix_idx != static_cast<std::size_t>(-1)) return false;
780 mix_idx = i;
781 }
782 }
783 if (mix_idx == static_cast<std::size_t>(-1)) return false;
784
785 const auto mix_gate = wires[mix_idx];
786
787 /* Snapshot the remaining wires. We need a copy because the
788 * resolveToMixture / resolveToCategoricalMixture calls below clear
789 * the parent's wire vector. */
790 std::vector<gate_t> others;
791 others.reserve(wires.size() - 1);
792 for (std::size_t i = 0; i < wires.size(); ++i) {
793 if (i != mix_idx) others.push_back(wires[i]);
794 }
795
796 /* Categorical N-wire form: push the constant offset / factor into
797 * each mulinput's value text. RV factors / offsets cannot be pushed
798 * into mulinput leaves so the rule bails on those. */
799 if (gc.isCategoricalMixture(mix_gate)) {
800 return try_categorical_mixture_lift(gc, g, op, mix_gate, others);
801 }
802
803 /* Classic 3-wire Bernoulli mixture. */
804 const auto &mw = gc.getWires(mix_gate);
805 if (mw.size() != 3) return false;
806 const gate_t p_tok = mw[0];
807 const gate_t x_tok = mw[1];
808 const gate_t y_tok = mw[2];
809
810 /* Build two new arith children: one with x in the mixture slot,
811 * one with y. Order matters for non-commutative ops, but PLUS /
812 * TIMES are both commutative so we just append the branch RV to
813 * the others. */
814 std::vector<gate_t> new_x_wires = others; new_x_wires.push_back(x_tok);
815 std::vector<gate_t> new_y_wires = others; new_y_wires.push_back(y_tok);
816 gate_t new_x = gc.addAnonymousArithGate(op, std::move(new_x_wires));
817 gate_t new_y = gc.addAnonymousArithGate(op, std::move(new_y_wires));
818
819 /* Rewrite g as gate_mixture(p, new_x, new_y). This clears g's
820 * old wires / infos / extra and installs the new structure. */
821 gc.resolveToMixture(g, p_tok, new_x, new_y);
822
823 /* Recursively fold the two new arith children so they get a chance
824 * to collapse via normal-family / erlang-family closures. Each is
825 * itself a gate_arith of the same op, with at least 2 wires (the
826 * "others" we copied plus the branch RV), so apply_rules's
827 * PLUS/TIMES path is the correct entry point. The scalar-fold flag
828 * is propagated so pass-2's scalar-times-RV closure stays the only
829 * place that mints a fresh @c gate_rv at a scaled-RV TIMES site
830 * (avoids losing shared-RV identity in front of a sibling PLUS). */
831 apply_rules(gc, new_x, include_scalar_fold);
832 apply_rules(gc, new_y, include_scalar_fold);
833
834 return true;
835}
836
837/**
838 * @brief Scalar-times-RV closure: fold @c arith(TIMES, value:c, Z) to
839 * a single closed-form-scaled @c gate_rv.
840 *
841 * Fires on a 2-wire @c TIMES whose wires are exactly one @c gate_value
842 * (the scalar @c c) and one @c gate_rv leaf @c Z whose distribution
843 * admits a closed-form scale transform:
844 *
845 * - <tt>c · Normal(μ, σ) = Normal(c·μ, |c|·σ)</tt> (any non-zero c).
846 * - <tt>c · Uniform(a, b)</tt>: @c Uniform(c·a, c·b) for @c c > 0;
847 * @c Uniform(c·b, c·a) for @c c < 0.
848 * - <tt>c · Exponential(λ) = Exponential(λ/c)</tt> for @c c > 0 only
849 * (negative scaling flips support to (-∞, 0] and is no longer
850 * exponential).
851 * - <tt>c · Erlang(k, λ) = Erlang(k, λ/c)</tt> for @c c > 0 only.
852 *
853 * The c=0 absorber and c=1 identity are handled by
854 * @c try_identity_drop, so this rule defensively bails on them to
855 * avoid a duplicate rewrite path. RV kinds without a closed-form
856 * scaling fall through.
857 *
858 * Coupling caveat (shared with @c try_normal_closure): replacing the
859 * TIMES with a fresh @c gate_rv mints a new RV identity at @p g, so
860 * any other path that references @c Z and shares a downstream consumer
861 * with @p g will see decoupled draws after the fold. In practice the
862 * rewrite path produces per-row orphan subtrees, so this is consistent
863 * with the existing normal-family closure behaviour.
864 *
865 * Returns @c true if @p g was mutated.
866 */
867bool try_times_scalar_rv(GenericCircuit &gc, gate_t g)
868{
869 auto op = static_cast<provsql_arith_op>(gc.getInfos(g).first);
870 if (op != PROVSQL_ARITH_TIMES) return false;
871 const auto &wires = gc.getWires(g);
872 if (wires.size() != 2) return false;
873
874 /* Identify the value side and the rv side. */
875 double c = NaN;
876 gate_t rv_side = INVALID_GATE;
877 if (gc.getGateType(wires[0]) == gate_value
878 && gc.getGateType(wires[1]) == gate_rv) {
879 try { c = parseDoubleStrict(gc.getExtra(wires[0])); }
880 catch (const CircuitException &) { return false; }
881 rv_side = wires[1];
882 } else if (gc.getGateType(wires[1]) == gate_value
883 && gc.getGateType(wires[0]) == gate_rv) {
884 try { c = parseDoubleStrict(gc.getExtra(wires[1])); }
885 catch (const CircuitException &) { return false; }
886 rv_side = wires[0];
887 } else {
888 return false;
889 }
890
891 /* c=0 / c=1 are the identity-drop's job; bailing here keeps the
892 * two rules' responsibilities disjoint. */
893 if (c == 0.0 || c == 1.0) return false;
894
895 auto spec = parse_distribution_spec(gc.getExtra(rv_side));
896 if (!spec) return false;
897
898 switch (spec->kind) {
899 case DistKind::Normal: {
900 const double new_mu = c * spec->p1;
901 const double new_sigma = std::fabs(c) * spec->p2;
902 /* Defensive: a zero-σ normal collapses to a Dirac. σ=0 normals
903 * are normally constructed via @c as_random by @c provsql.normal,
904 * but if one slipped through (e.g. a future closure produced
905 * σ=0 from the linear combination), route it through value. */
906 if (new_sigma == 0.0) {
907 replace_with_value(gc, g, new_mu);
908 } else {
909 replace_with_normal_rv(gc, g, new_mu, new_sigma);
910 }
911 return true;
912 }
913 case DistKind::Uniform: {
914 const double a = spec->p1;
915 const double b = spec->p2;
916 const double lo = (c > 0.0) ? c * a : c * b;
917 const double hi = (c > 0.0) ? c * b : c * a;
918 replace_with_uniform_rv(gc, g, lo, hi);
919 return true;
920 }
922 if (c <= 0.0) return false;
923 const double new_lambda = spec->p1 / c;
924 gc.resolveToRv(g, "exponential:" + double_to_text(new_lambda));
925 return true;
926 }
927 case DistKind::Erlang: {
928 if (c <= 0.0) return false;
929 /* spec->p1 is integer-valued by construction (the SQL constructor
930 * enforces k >= 1); guard defensively. */
931 if (spec->p1 < 1.0 || spec->p1 != std::floor(spec->p1)) return false;
932 const auto k = static_cast<unsigned long>(spec->p1);
933 const double new_lambda = spec->p2 / c;
934 replace_with_erlang_rv(gc, g, k, new_lambda);
935 return true;
936 }
937 }
938 return false;
939}
940
941/**
942 * @brief PLUS coefficient aggregation: collapse same-base-RV terms
943 * in a sum.
944 *
945 * For a @c PLUS gate whose every wire decomposes via
946 * @c decompose_linear_term to <tt>a·Z + b</tt>, sums the coefficients
947 * per @c rv_gate UUID and accumulates all the constant offsets into a
948 * single @c b_total. Rebuilds the wire list as one @c TIMES per
949 * surviving RV (or a bare RV wire when its coefficient is exactly @c 1)
950 * plus a single @c value wire for @c b_total when non-zero.
951 *
952 * Fires when at least one of the following holds:
953 * - some @c rv_gate appears in more than one wire (the X+X case);
954 * - more than one constant wire is present (consolidates them).
955 *
956 * Without these triggers the rebuild would be a no-op or worse
957 * (minting fresh @c TIMES wrappers identical in shape to existing
958 * input wires), so the rule bails to keep the simplifier idempotent.
959 *
960 * Unlike @c try_normal_closure / @c try_times_scalar_rv, this rule is
961 * @b safe under shared base-RV identity: the rebuild preserves every
962 * @c rv_gate as a wire (wrapped in @c TIMES when its coefficient is
963 * non-unit), so any other path that referenced @c Z continues to see
964 * the same gate. The subsequent fold of <tt>arith(TIMES, value:a, Z)</tt>
965 * by @c try_times_scalar_rv inherits the same coupling caveat as the
966 * existing normal-family closure (see its docstring).
967 *
968 * Returns @c true if @p g was mutated.
969 */
970bool try_plus_aggregate(GenericCircuit &gc, gate_t g,
971 bool include_scalar_fold)
972{
973 auto op = static_cast<provsql_arith_op>(gc.getInfos(g).first);
974 if (op != PROVSQL_ARITH_PLUS) return false;
975 const auto &wires_in = gc.getWires(g);
976 if (wires_in.size() < 2) return false;
977
978 std::vector<LinearTerm> terms;
979 terms.reserve(wires_in.size());
980 for (gate_t w : wires_in) {
981 auto t = decompose_linear_term(gc, w);
982 if (!t) return false;
983 terms.push_back(*t);
984 }
985
986 /* Aggregate per rv_gate. A vector preserves insertion order so the
987 * rebuilt wire list is deterministic across runs; the per-PLUS
988 * arity is small enough that O(n²) lookup is fine. */
989 std::vector<std::pair<gate_t, double>> coeffs;
990 double b_total = 0.0;
991 unsigned constants_in = 0;
992 for (const auto &t : terms) {
993 b_total += t.b;
994 if (is_invalid(t.rv_gate)) {
995 ++constants_in;
996 continue;
997 }
998 bool found = false;
999 for (auto &p : coeffs) {
1000 if (p.first == t.rv_gate) {
1001 p.second += t.a;
1002 found = true;
1003 break;
1004 }
1005 }
1006 if (!found) coeffs.emplace_back(t.rv_gate, t.a);
1007 }
1008
1009 /* Fire only when there's actual consolidation to do. Without a
1010 * duplicate RV (or multiple constants) the rebuild would mint
1011 * shape-equivalent TIMES wrappers for input wires like
1012 * arith(TIMES, value:a, Z), oscillating the gate vector. */
1013 const bool has_duplicate = (coeffs.size() < terms.size() - constants_in);
1014 const bool many_constants = (constants_in >= 2);
1015 if (!has_duplicate && !many_constants) return false;
1016
1017 /* Drop zero-coefficient RVs (X + (-X) survivors). */
1018 std::vector<std::pair<gate_t, double>> kept;
1019 kept.reserve(coeffs.size());
1020 for (const auto &p : coeffs) {
1021 if (p.second != 0.0) kept.push_back(p);
1022 }
1023
1024 /* All RVs canceled: fold g to a value gate carrying b_total. */
1025 if (kept.empty()) {
1026 replace_with_value(gc, g, b_total);
1027 return true;
1028 }
1029
1030 /* Single surviving RV term with no constant offset. Rewrite g
1031 * directly in place as the simplest representation:
1032 * - a == 1 ⇒ singleton PLUS([Z]) (we can't safely dissolve to Z
1033 * in place because that would mint a fresh RV identity at g).
1034 * - a != 1 ⇒ in-place op-change from PLUS to TIMES with wires
1035 * [value:a, Z]. When @p include_scalar_fold is set the fixed-point
1036 * loop then re-enters apply_rules on g (now a TIMES), giving
1037 * try_times_scalar_rv a chance to fold the scaled RV. Pass 1
1038 * runs with @p include_scalar_fold = false (deferring the fold so
1039 * the outer aggregator sees @c c·X-shaped children with intact
1040 * RV identity); pass 2 then folds the surviving TIMES wrapper.
1041 * Either way, the in-place op-change avoids the PLUS([TIMES(..)])
1042 * double wrapper that would otherwise hide the bare-RV shape from
1043 * @c AnalyticEvaluator's @c bareRv lookup. */
1044 if (kept.size() == 1 && b_total == 0.0) {
1045 const auto &only = kept.front();
1046 if (only.second == 1.0) {
1047 gc.setWires(g, {only.first});
1048 } else {
1049 const gate_t cv = gc.addAnonymousValueGate(
1050 double_to_text(only.second));
1051 gc.setInfos(g, static_cast<unsigned>(PROVSQL_ARITH_TIMES), 0);
1052 gc.setWires(g, {cv, only.first});
1053 }
1054 return true;
1055 }
1056
1057 /* General case: rebuild g as a multi-wire PLUS. */
1058 std::vector<gate_t> new_wires;
1059 new_wires.reserve(kept.size() + 1);
1060 for (const auto &p : kept) {
1061 if (p.second == 1.0) {
1062 new_wires.push_back(p.first);
1063 } else {
1064 const gate_t cv = gc.addAnonymousValueGate(double_to_text(p.second));
1066 {cv, p.first});
1067 new_wires.push_back(tm);
1068 }
1069 }
1070 if (b_total != 0.0) {
1071 new_wires.push_back(gc.addAnonymousValueGate(double_to_text(b_total)));
1072 }
1073
1074 gc.setWires(g, std::move(new_wires));
1075
1076 /* Recurse into freshly-minted TIMES children so try_times_scalar_rv
1077 * gets a chance to fold them within the same bottom-up pass when
1078 * @p include_scalar_fold is set. Same pattern as try_mixture_lift. */
1079 for (gate_t w : gc.getWires(g)) {
1080 if (gc.getGateType(w) == gate_arith) {
1081 apply_rules(gc, w, include_scalar_fold);
1082 }
1083 }
1084 return true;
1085}
1086
1087/**
1088 * @brief Run the per-gate fixed-point loop.
1089 *
1090 * After each rule succeeds the gate is re-evaluated under every rule,
1091 * so a single bottom-up pass collapses nested foldable structures
1092 * (e.g. <tt>arith(NEG, arith(PLUS, value, value))</tt>) in one go.
1093 *
1094 * @return Number of rewrites performed on this gate.
1095 */
1096unsigned apply_rules(GenericCircuit &gc, gate_t g,
1097 bool include_scalar_fold)
1098{
1099 unsigned local = 0;
1100 /* Iteration bound: each rule strictly shrinks the gate (fewer wires
1101 * or simpler type), so the loop terminates in O(#initial wires)
1102 * iterations. The bound is defensive insurance against an
1103 * unintended infinite loop. */
1104 for (unsigned iter = 0; iter < 32; ++iter) {
1105 if (gc.getGateType(g) != gate_arith) break;
1106
1107 /* 1. Constant folding (collapses any all-gate_value arith). */
1108 {
1109 double c = try_eval_constant(gc, g);
1110 if (!std::isnan(c)) {
1111 replace_with_value(gc, g, c);
1112 ++local;
1113 break;
1114 }
1115 }
1116
1117 /* 1b. MINUS-to-PLUS canonicalisation. Rewrites
1118 * @c arith(MINUS, A, B) as @c arith(PLUS, A, arith(NEG, B))
1119 * so every downstream rule -- PLUS aggregation, family
1120 * closures, mixture-lift -- only needs to handle PLUS.
1121 * @c decompose_linear_term already recognises @c NEG as a
1122 * coefficient @c -1, so the rewritten parent's
1123 * @c decompose_linear_term yields the same linear-term shape
1124 * as the original MINUS would have, modulo one extra
1125 * gate_arith level for the NEG. Runs after constant fold so
1126 * a fully-constant @c MINUS(value, value) collapses to a
1127 * @c value gate without minting an interim NEG. */
1128 {
1129 auto op = static_cast<provsql_arith_op>(gc.getInfos(g).first);
1130 if (op == PROVSQL_ARITH_MINUS) {
1131 const auto &wires_in = gc.getWires(g);
1132 if (wires_in.size() == 2) {
1133 const gate_t a = wires_in[0];
1134 const gate_t b = wires_in[1];
1136 {b});
1137 gc.setInfos(g, static_cast<unsigned>(PROVSQL_ARITH_PLUS), 0);
1138 gc.setWires(g, {a, neg_b});
1139 ++local;
1140 continue;
1141 }
1142 }
1143 }
1144
1145 /* 1c. DIV-by-constant to TIMES-by-reciprocal canonicalisation.
1146 * Rewrites @c arith(DIV, X, value:c) as
1147 * @c arith(TIMES, X, value:1/c) (c != 0) so the existing
1148 * scalar-times-RV closure (@c try_times_scalar_rv) and every
1149 * other downstream TIMES rule fold @c X/c uniformly with
1150 * @c c*X. DIV-by-non-constant is left alone (no closure to
1151 * apply); fully-constant @c DIV(value, value) is handled by
1152 * the constant fold above so we never see @c c=0 here.
1153 * Aggregate divisions (an @c X bearing a @c gate_agg) are left
1154 * intact: their HAVING possible-worlds enumeration applies the
1155 * correct integer-floor / real division on the original DIV,
1156 * which a TIMES-by-reciprocal would silently discard. */
1157 {
1158 auto op = static_cast<provsql_arith_op>(gc.getInfos(g).first);
1159 if (op == PROVSQL_ARITH_DIV) {
1160 const auto &wires_in = gc.getWires(g);
1161 if (wires_in.size() == 2 && !subtree_contains_agg(gc, wires_in[0])) {
1162 const double c = try_eval_constant(gc, wires_in[1]);
1163 if (!std::isnan(c) && c != 0.0) {
1164 const gate_t x = wires_in[0];
1165 const gate_t inv = gc.addAnonymousValueGate(
1166 double_to_text(1.0 / c));
1167 gc.setInfos(g, static_cast<unsigned>(PROVSQL_ARITH_TIMES), 0);
1168 gc.setWires(g, {x, inv});
1169 ++local;
1170 continue;
1171 }
1172 }
1173 }
1174 }
1175
1176 /* 2. Identity / absorber drops on PLUS and TIMES. */
1177 if (try_identity_drop(gc, g)) {
1178 ++local;
1179 continue;
1180 }
1181
1182 /* 3. Mixture lift: push PLUS / TIMES inside a single mixture
1183 * child. Runs BEFORE the normal / erlang closures so the
1184 * branch arith children get to try those closures themselves
1185 * after the lift. Once the lift fires the parent is no
1186 * longer gate_arith, so the loop terminates on the next
1187 * iteration via the gate_arith guard above. */
1188 if (try_mixture_lift(gc, g, include_scalar_fold)) {
1189 ++local;
1190 break;
1191 }
1192
1193 auto op = static_cast<provsql_arith_op>(gc.getInfos(g).first);
1194
1195 /* 4. PLUS coefficient aggregation: collapse X+X, X-X, multiple
1196 * constants, etc. Runs BEFORE the family closures so they see
1197 * a sum with distinct RV identities (which they assume), and
1198 * so X+X folds through the scalar-times-RV closure on the
1199 * minted 2*X child. */
1200 if (op == PROVSQL_ARITH_PLUS) {
1201 if (try_plus_aggregate(gc, g, include_scalar_fold)) {
1202 ++local;
1203 continue;
1204 }
1205 }
1206
1207 /* 5. Scalar-times-RV closure on TIMES: c · gate_rv folds to a
1208 * closed-form-scaled gate_rv for the supported families. Gated
1209 * by @p include_scalar_fold: the bottom-up DFS visits children
1210 * before parents, and folding @c c·X to a fresh @c gate_rv at
1211 * the TIMES gate would lose @c X's identity, which an outer
1212 * @c PLUS-aggregation sibling like @c x in @c 2·x+x relies on
1213 * to recognise the shared base RV. Pass 1 runs all other rules
1214 * so the aggregator gets first crack at @c c·X-shaped wires;
1215 * pass 2 then folds the remaining TIMES gates with this rule
1216 * via @c runHybridSimplifier's post-pass. */
1217 if (op == PROVSQL_ARITH_TIMES && include_scalar_fold) {
1218 if (try_times_scalar_rv(gc, g)) {
1219 ++local;
1220 break;
1221 }
1222 }
1223
1224 /* 6. Family closures on PLUS. Order:
1225 * - normal (handles every-wire-normal sums);
1226 * - erlang (handles every-wire-exp/erlang same-rate sums);
1227 * - uniform (handles at-most-one-Uniform + pure-constant
1228 * sums, including the post-MINUS-canonicalisation shapes
1229 * @c c + (-U) and @c (-U) + c).
1230 * The three families are mutually exclusive on the underlying
1231 * spec (a Uniform-bearing wire fails the normal- and Erlang-
1232 * closure filters), so order does not matter for correctness;
1233 * we try normal-closure, then Erlang-closure, then
1234 * uniform-closure. */
1235 if (op == PROVSQL_ARITH_PLUS) {
1236 if (try_normal_closure(gc, g)) { ++local; break; }
1237 if (try_erlang_closure(gc, g)) { ++local; break; }
1238 if (try_uniform_closure(gc, g)) { ++local; break; }
1239 }
1240
1241 break; /* no rule fired this iteration */
1242 }
1243 return local;
1244}
1245
1246/**
1247 * @brief Post-order DFS that simplifies every reachable gate.
1248 *
1249 * Children are simplified before parents so by the time a gate is
1250 * examined its wires already reflect any rewrites: the bottom-up
1251 * order is essential for cascading folds (a parent PLUS over a child
1252 * arith that just folded to a gate_value gets a chance to fold that
1253 * constant away).
1254 */
1255void simplify(GenericCircuit &gc, gate_t g,
1256 std::unordered_set<gate_t> &done, unsigned &counter,
1257 bool include_scalar_fold)
1258{
1259 /* Iterative DFS with an explicit stack: the natural recursive form
1260 * blew the host stack on deeply-nested arith chains in early
1261 * experiments; iteration with a small per-node bookkeeping triple
1262 * (gate, child-cursor, processed-flag) keeps the cost in heap. */
1263 std::stack<std::pair<gate_t, std::size_t>> stk;
1264 if (!done.insert(g).second) return;
1265 stk.emplace(g, 0);
1266
1267 while (!stk.empty()) {
1268 auto &frame = stk.top();
1269 gate_t cur = frame.first;
1270 const auto &wires = gc.getWires(cur);
1271 if (frame.second < wires.size()) {
1272 gate_t child = wires[frame.second++];
1273 if (done.insert(child).second) stk.emplace(child, 0);
1274 continue;
1275 }
1276 /* All children processed; apply rules to cur. */
1277 if (gc.getGateType(cur) == gate_arith)
1278 counter += apply_rules(gc, cur, include_scalar_fold);
1279 stk.pop();
1280 }
1281}
1282
1283} // namespace
1284
1286{
1287 unsigned counter = 0;
1288 /* Walk every gate in order: @c try_eval_constant recurses through
1289 * @c gate_arith children itself (via @c try_eval_constant's own
1290 * recursion on @c gate_arith ops + base case at @c gate_value),
1291 * so a single linear pass over the gate indices is sufficient.
1292 * No DFS bookkeeping needed because the rewrite produces a
1293 * @c gate_value (terminal), never another @c gate_arith. */
1294 const auto nb = gc.getNbGates();
1295 for (std::size_t i = 0; i < nb; ++i) {
1296 auto g = static_cast<gate_t>(i);
1297 if (gc.getGateType(g) != gate_arith) continue;
1298 double c = try_eval_constant(gc, g);
1299 if (!std::isnan(c)) {
1300 replace_with_value(gc, g, c);
1301 ++counter;
1302 }
1303 }
1304 return counter;
1305}
1306
1308{
1309 unsigned counter = 0;
1310
1311 /* Pass 1: bottom-up DFS applying every rule EXCEPT the scalar-times-RV
1312 * fold. Deferring that one rule lets @c try_plus_aggregate see
1313 * @c arith(TIMES, value:c, X) shapes inside a parent PLUS -- the
1314 * decomposer recognises them as @c c·X with @c rv_gate=X, so a
1315 * sibling @c x in @c 2·x + x correctly aggregates to coefficient
1316 * three on the shared base RV. If the scalar fold had fired bottom-up
1317 * on the inner TIMES first it would have minted a fresh @c gate_rv
1318 * there, decoupling its identity from the sibling @c x and forcing
1319 * the outer normal-closure path which assumes independence. */
1320 {
1321 std::unordered_set<gate_t> done;
1322 const auto nb = gc.getNbGates();
1323 for (std::size_t i = 0; i < nb; ++i) {
1324 simplify(gc, static_cast<gate_t>(i), done, counter,
1325 /*include_scalar_fold=*/false);
1326 }
1327 }
1328
1329 /* Pass 2: scalar-times-RV fold and NEG-of-RV fold on every
1330 * remaining @c gate_arith. Pass 1's aggregator and family closures
1331 * have already consumed the shapes where these folds would have
1332 * lost shared-RV identity; any surviving 2-wire
1333 * <tt>arith(TIMES, value:c, gate_rv)</tt> or 1-wire
1334 * <tt>arith(NEG, gate_rv)</tt> is now either standalone (no sibling
1335 * to couple with) or the leftover wrapper from a single-RV
1336 * aggregation result. No DFS is needed -- the rules are local and
1337 * idempotent, and walking the gate range with the post-pass-1
1338 * @c getNbGates() picks up the freshly minted wrappers from
1339 * @c try_plus_aggregate, @c try_mixture_lift, and the
1340 * MINUS-to-PLUS canonicalisation. */
1341 {
1342 const auto nb = gc.getNbGates();
1343 for (std::size_t i = 0; i < nb; ++i) {
1344 auto g = static_cast<gate_t>(i);
1345 if (gc.getGateType(g) == gate_arith) {
1346 if (try_times_scalar_rv(gc, g)) ++counter;
1347 else if (try_neg_rv(gc, g)) ++counter;
1348 }
1349 }
1350 }
1351
1352 return counter;
1353}
1354
1355namespace {
1356
1357/**
1358 * @brief Test whether both sides of @p cmp_gate are a continuous-only
1359 * island (subtree of @c gate_value / @c gate_rv / @c gate_arith).
1360 *
1361 * A continuous island has no Boolean / aggregate / IO gates underneath
1362 * the cmp; the only outward edge is the cmp itself. This is the
1363 * shape monteCarloRV's @c evalScalar can integrate over, so per-cmp
1364 * MC marginalisation is sound on these and these alone.
1365 */
1366bool is_continuous_island_cmp(const GenericCircuit &gc, gate_t cmp_gate)
1367{
1368 const auto &wires = gc.getWires(cmp_gate);
1369 if (wires.size() != 2) return false;
1370
1371 std::unordered_set<gate_t> seen;
1372 std::stack<gate_t> stk;
1373 stk.push(wires[0]);
1374 stk.push(wires[1]);
1375 while (!stk.empty()) {
1376 gate_t g = stk.top(); stk.pop();
1377 if (!seen.insert(g).second) continue;
1378 auto t = gc.getGateType(g);
1379 if (t == gate_value || t == gate_rv || t == gate_arith) {
1380 for (gate_t c : gc.getWires(g)) stk.push(c);
1381 continue;
1382 }
1383 if (t == gate_mixture) {
1384 /* Categorical-form mixture (from @c provsql.categorical): a
1385 * discrete scalar leaf with no continuous identities below.
1386 * Treat it as a black-box scalar leaf and don't descend. */
1387 if (gc.isCategoricalMixture(g)) continue;
1388 /* Classic 3-wire mixture: first wire is a gate_input Bernoulli;
1389 * the rest of the island walker would reject it as
1390 * non-continuous, but the Monte-Carlo sampler handles it
1391 * correctly via per-iteration coupling. Treat the mixture as
1392 * a black-box scalar leaf in the island shape: do NOT descend
1393 * into wires[0], only into the scalar branches wires[1] /
1394 * wires[2]. */
1395 const auto &mw = gc.getWires(g);
1396 if (mw.size() != 3) return false;
1397 stk.push(mw[1]);
1398 stk.push(mw[2]);
1399 continue;
1400 }
1401 return false;
1402 }
1403 return true;
1404}
1405
1406/**
1407 * @brief Collect the base @c gate_rv leaves reachable from @p root
1408 * through @c gate_arith composition.
1409 *
1410 * The set is the cmp's "RV footprint": two cmps share an island iff
1411 * their footprints overlap (a shared base RV is the only way their
1412 * sampled values can be correlated, given the island shape).
1413 */
1414void collect_cmp_rv_footprint(const GenericCircuit &gc, gate_t cmp_gate,
1415 std::unordered_set<gate_t> &fp)
1416{
1417 std::unordered_set<gate_t> seen;
1418 std::stack<gate_t> stk;
1419 for (gate_t w : gc.getWires(cmp_gate)) stk.push(w);
1420 while (!stk.empty()) {
1421 gate_t g = stk.top(); stk.pop();
1422 if (!seen.insert(g).second) continue;
1423 auto t = gc.getGateType(g);
1424 if (t == gate_rv) { fp.insert(g); continue; }
1425 if (t == gate_arith) {
1426 for (gate_t c : gc.getWires(g)) stk.push(c);
1427 continue;
1428 }
1429 if (t == gate_mixture) {
1430 /* Categorical-form mixture (from @c provsql.categorical):
1431 * discrete leaves, no continuous identities below. Stop. */
1432 if (gc.isCategoricalMixture(g)) continue;
1433 /* Classic 3-wire mixture: descend into the scalar branches but
1434 * NOT into the Bernoulli (wires[0] is a gate_input, not a
1435 * continuous RV identity). Two cmps that share a mixture's
1436 * continuous RVs still need to be grouped together; sharing the
1437 * Bernoulli alone does too, but that coupling is captured at
1438 * the sampler level rather than here -- the joint-table sampler
1439 * hits both cmps in the same MC iteration and the shared
1440 * bool_cache_ produces coherent draws. */
1441 const auto &mw = gc.getWires(g);
1442 if (mw.size() == 3) { stk.push(mw[1]); stk.push(mw[2]); }
1443 continue;
1444 }
1445 /* gate_value contributes no RV identity; other types should not
1446 * appear here (is_continuous_island_cmp gates that path), but if
1447 * they did we'd simply ignore them in the footprint &ndash; the
1448 * decomposer's safety relies on the island-shape pre-check, not
1449 * on this routine. */
1450 }
1451}
1452
1453} // namespace
1454
1455namespace {
1456
1457/* Joint-table cap. 2^k mulinput leaves are materialised per group;
1458 * 256 cells is more than ample for HAVING/WHERE workloads while
1459 * keeping the in-memory footprint and the per-cell MC variance
1460 * (samples / 2^k counts per cell) bounded. Groups exceeding the
1461 * cap fall through to whole-circuit MC by leaving their cmps as
1462 * gate_cmp; the dispatch in probability_evaluate then routes
1463 * through monteCarloRV. */
1464constexpr std::size_t JOINT_TABLE_K_MAX = 8;
1465
1466/**
1467 * @brief Test whether @c AnalyticEvaluator would resolve @p cmp_gate
1468 * analytically on its own.
1469 *
1470 * The decomposer now runs before @c AnalyticEvaluator (so shared
1471 * bare-RV cmps reach the grouping logic and the fast path's
1472 * analytical CDF can fire), but it must leave isolated bare-RV cmps
1473 * untouched: marginalising those via MC would waste samples on a
1474 * case the closed-form CDF handles exactly. Mirror the shape match
1475 * in @c tryAnalyticDecide (bare RV vs gate_value either way around;
1476 * two bare normal RVs).
1477 */
1478bool is_analytic_singleton_cmp(const GenericCircuit &gc, gate_t cmp_gate)
1479{
1480 const auto &wires = gc.getWires(cmp_gate);
1481 if (wires.size() != 2) return false;
1482 auto t0 = gc.getGateType(wires[0]);
1483 auto t1 = gc.getGateType(wires[1]);
1484
1485 /* X cmp c / c cmp X: AnalyticEvaluator resolves any supported
1486 * distribution kind via the closed-form CDF. */
1487 if ((t0 == gate_rv && t1 == gate_value) ||
1488 (t0 == gate_value && t1 == gate_rv))
1489 return true;
1490
1491 /* Categorical-form mixture cmp constant: AnalyticEvaluator's
1492 * @c categoricalDecide computes the exact mass sum over the
1493 * mulinputs satisfying the predicate, so the decomposer should not
1494 * pre-empt with per-cmp MC. Also picks up the
1495 * @c try_categorical_mixture_lift output (a constant scaled / offset
1496 * categorical), keeping the analytical path end-to-end for
1497 * <tt>c · X cmp k</tt> shapes over categorical RVs. */
1498 if ((gc.isCategoricalMixture(wires[0]) && t1 == gate_value) ||
1499 (gc.isCategoricalMixture(wires[1]) && t0 == gate_value))
1500 return true;
1501
1502 /* X cmp Y both bare normals: AnalyticEvaluator's normal-diff
1503 * shortcut applies. */
1504 if (t0 == gate_rv && t1 == gate_rv) {
1505 auto sx = parse_distribution_spec(gc.getExtra(wires[0]));
1506 auto sy = parse_distribution_spec(gc.getExtra(wires[1]));
1507 if (sx && sy && sx->kind == DistKind::Normal
1508 && sy->kind == DistKind::Normal)
1509 return true;
1510 }
1511 return false;
1512}
1513
1514/**
1515 * @brief Information needed by @c inline_fast_path: the shared scalar
1516 * plus, for each cmp, the comparison operator and the
1517 * constant rhs threshold (after flipping for cmps shaped
1518 * @c c @c op @c X).
1519 */
1520struct FastPathInfo {
1521 gate_t scalar;
1522 std::vector<ComparisonOperator> ops; /* one per cmp, oriented as `scalar op c` */
1523 std::vector<double> thresholds; /* one per cmp */
1524};
1525
1527{
1528 switch (op) {
1535 }
1536 return op;
1537}
1538
1539bool apply_cmp(double l, ComparisonOperator op, double r)
1540{
1541 switch (op) {
1542 case ComparisonOperator::LT: return l < r;
1543 case ComparisonOperator::LE: return l <= r;
1544 case ComparisonOperator::EQ: return l == r;
1545 case ComparisonOperator::NE: return l != r;
1546 case ComparisonOperator::GE: return l >= r;
1547 case ComparisonOperator::GT: return l > r;
1548 }
1549 return false;
1550}
1551
1552/**
1553 * @brief Detect the monotone-shared-scalar fast path on a group of
1554 * comparators.
1555 *
1556 * Fires when every cmp in @p cmps has one side equal to a single
1557 * shared gate_t @c s and the other side a @c gate_value: the k cmps
1558 * then jointly partition the @c s-line into at most k+1 intervals,
1559 * with each interval producing a deterministic k-bit outcome. This
1560 * shape is common in HAVING / WHERE with multiple thresholds on the
1561 * same aggregate / column: e.g.
1562 * <tt>count(*) > 10 OR count(*) < 5</tt>.
1563 *
1564 * Returns @c std::nullopt when any cmp has both non-constant sides,
1565 * when the cmps don't all share the same @c s gate_t, when a
1566 * comparator OID is unrecognised, or when @c EQ / @c NE appears (the
1567 * interval representation can't express a measure-zero point).
1568 */
1569std::optional<FastPathInfo>
1570detect_shared_scalar(const GenericCircuit &gc,
1571 const std::vector<gate_t> &cmps)
1572{
1573 FastPathInfo info;
1574 info.ops.reserve(cmps.size());
1575 info.thresholds.reserve(cmps.size());
1576 bool first = true;
1577
1578 for (gate_t c : cmps) {
1579 const auto &wires = gc.getWires(c);
1580 if (wires.size() != 2) return std::nullopt;
1581
1582 bool ok = false;
1583 ComparisonOperator op = cmpOpFromOid(gc.getInfos(c).first, ok);
1584 if (!ok) return std::nullopt;
1585 /* EQ / NE on continuous RVs have measure zero / one and were
1586 * already resolved by RangeCheck; if we still see one we don't
1587 * know how to fit it into an interval partition. Bail. */
1589 return std::nullopt;
1590
1591 gate_t scalar_side = static_cast<gate_t>(-1);
1592 double threshold = std::numeric_limits<double>::quiet_NaN();
1593 ComparisonOperator effective_op = op;
1594 if (gc.getGateType(wires[1]) == gate_value) {
1595 scalar_side = wires[0];
1596 try { threshold = parseDoubleStrict(gc.getExtra(wires[1])); }
1597 catch (const CircuitException &) { return std::nullopt; }
1598 } else if (gc.getGateType(wires[0]) == gate_value) {
1599 scalar_side = wires[1];
1600 try { threshold = parseDoubleStrict(gc.getExtra(wires[0])); }
1601 catch (const CircuitException &) { return std::nullopt; }
1602 effective_op = flip_cmp_op(op);
1603 } else {
1604 return std::nullopt;
1605 }
1606
1607 if (first) {
1608 info.scalar = scalar_side;
1609 first = false;
1610 } else if (info.scalar != scalar_side) {
1611 return std::nullopt;
1612 }
1613 info.ops.push_back(effective_op);
1614 info.thresholds.push_back(threshold);
1615 }
1616 return info;
1617}
1618
1619/**
1620 * @brief Inline a fast-path joint table for a monotone-shared-scalar
1621 * group.
1622 *
1623 * The k cmps partition the scalar line into at most k+1 intervals
1624 * (one per pair of consecutive sorted distinct thresholds plus the
1625 * two infinite tails). Each interval gets a single mulinput with
1626 * probability equal to the scalar's mass on the interval; the
1627 * comparator outcomes are deterministic per interval (evaluated at
1628 * a strictly-interior representative point) and the k cmps are
1629 * rewritten as @c gate_plus over the mulinputs whose interval makes
1630 * them true.
1631 *
1632 * Interval probabilities are computed analytically via @c cdfAt when
1633 * the scalar is a bare @c gate_rv with a CDF the helper supports;
1634 * otherwise (a @c gate_arith composite, or an Erlang with
1635 * non-integer shape) we fall back to MC by sampling the scalar
1636 * @p samples times and binning into intervals.
1637 */
1638void inline_fast_path(GenericCircuit &gc,
1639 const std::vector<gate_t> &cmps,
1640 const FastPathInfo &info,
1641 unsigned samples)
1642{
1643 /* Sort + dedup thresholds; the resulting m distinct boundaries
1644 * partition R into m+1 open intervals
1645 * (-∞, t_0), (t_0, t_1), ..., (t_{m-1}, +∞). */
1646 std::vector<double> ts = info.thresholds;
1647 std::sort(ts.begin(), ts.end());
1648 ts.erase(std::unique(ts.begin(), ts.end()), ts.end());
1649 const std::size_t m = ts.size();
1650 const std::size_t nb_intervals = m + 1;
1651
1652 /* Compute interval probabilities. Try the analytical CDF first:
1653 * when the shared scalar is a bare @c gate_rv with a CDF
1654 * @c cdfAt understands, the interval probability is
1655 * @c F(t_{i+1}) - F(t_i) exactly &ndash; no MC noise, no sampling.
1656 * This is the headline benefit of the fast path: shared bare-RV
1657 * groups land on the exact dependent truth and the resulting
1658 * Bernoulli probabilities propagate through tree-decomposition /
1659 * compilation without any sampling noise contributed by the
1660 * decomposer. Fall back to MC binning over @p samples scalar
1661 * draws when the scalar is a @c gate_arith composite (no CDF) or
1662 * when @c cdfAt returns NaN on a boundary (Erlang with
1663 * non-integer shape, etc.). */
1664 std::vector<double> interval_probs(nb_intervals, 0.0);
1665 bool analytical = false;
1666 if (gc.getGateType(info.scalar) == gate_rv) {
1667 auto spec = parse_distribution_spec(gc.getExtra(info.scalar));
1668 if (spec) {
1669 std::vector<double> cdf_at_boundary(m);
1670 bool all_ok = true;
1671 for (std::size_t i = 0; i < m; ++i) {
1672 cdf_at_boundary[i] = cdfAt(*spec, ts[i]);
1673 if (std::isnan(cdf_at_boundary[i])) { all_ok = false; break; }
1674 }
1675 if (all_ok) {
1676 interval_probs[0] = cdf_at_boundary[0];
1677 for (std::size_t i = 1; i < m; ++i)
1678 interval_probs[i] = cdf_at_boundary[i] - cdf_at_boundary[i - 1];
1679 interval_probs[m] = 1.0 - cdf_at_boundary[m - 1];
1680 analytical = true;
1681 }
1682 }
1683 }
1684 if (!analytical) {
1685 auto draws = monteCarloScalarSamples(gc, info.scalar, samples);
1686 for (double s : draws) {
1687 auto it = std::upper_bound(ts.begin(), ts.end(), s);
1688 std::size_t idx = static_cast<std::size_t>(it - ts.begin());
1689 ++interval_probs[idx];
1690 }
1691 for (auto &p : interval_probs) p /= samples;
1692 }
1693
1694 /* For each interval, determine the k-bit cmp outcome word. Pick
1695 * a representative point strictly inside the interval: the
1696 * midpoint for finite intervals, t_0 - 1 / t_{m-1} + 1 for the
1697 * infinite tails. Continuous distributions assign zero mass to
1698 * the boundaries, so the choice of interior point doesn't
1699 * affect any cmp's outcome on the open interval. */
1700 std::vector<unsigned long> outcome_word(nb_intervals, 0);
1701 for (std::size_t i = 0; i < nb_intervals; ++i) {
1702 double point;
1703 if (i == 0) point = ts[0] - 1.0;
1704 else if (i == m) point = ts[m - 1] + 1.0;
1705 else point = 0.5 * (ts[i - 1] + ts[i]);
1706 unsigned long w = 0;
1707 for (std::size_t j = 0; j < info.thresholds.size(); ++j) {
1708 if (apply_cmp(point, info.ops[j], info.thresholds[j]))
1709 w |= (1ul << j);
1710 }
1711 outcome_word[i] = w;
1712 }
1713
1714 /* Allocate key + per-interval mulinputs (skipping zero-prob
1715 * intervals to keep the materialised circuit lean). */
1716 gate_t key = gc.addAnonymousInputGate(1.0);
1717 std::vector<gate_t> mul_for_interval(nb_intervals,
1718 static_cast<gate_t>(-1));
1719 for (std::size_t i = 0; i < nb_intervals; ++i) {
1720 if (interval_probs[i] <= 0.0) continue;
1721 mul_for_interval[i] =
1722 gc.addAnonymousMulinputGate(key, interval_probs[i],
1723 static_cast<unsigned>(i));
1724 }
1725
1726 /* Rewrite each cmp as gate_plus over the mulinputs whose
1727 * interval-outcome word has the cmp's bit set. */
1728 for (std::size_t j = 0; j < cmps.size(); ++j) {
1729 std::vector<gate_t> plus_wires;
1730 plus_wires.reserve(nb_intervals);
1731 for (std::size_t i = 0; i < nb_intervals; ++i) {
1732 if (!(outcome_word[i] & (1ul << j))) continue;
1733 gate_t mw = mul_for_interval[i];
1734 if (mw == static_cast<gate_t>(-1)) continue;
1735 plus_wires.push_back(mw);
1736 }
1737 gc.resolveToPlus(cmps[j], std::move(plus_wires));
1738 }
1739}
1740
1741/**
1742 * @brief Inline a joint-distribution table over a group of k cmps
1743 * sharing an island.
1744 *
1745 * Materialises 2^k - z mulinput leaves (where z is the number of
1746 * outcomes with empirical probability zero, omitted to keep the
1747 * circuit lean), all sharing a fresh anonymous key gate. Each
1748 * comparator @c cmps[i] is rewritten in place as @c gate_plus over
1749 * the mulinputs whose joint outcome word has bit @c i set; the
1750 * combined probability is the marginal P(cmp_i = 1) and shared bits
1751 * across different cmps reuse the same mulinput leaf so the OR over
1752 * cmps at downstream sites correctly observes the joint distribution
1753 * (mutually exclusive over the joint outcomes).
1754 *
1755 * Sound when the per-iteration sampler memoisation in
1756 * @c monteCarloRV / @c monteCarloJointDistribution gives all k cmps
1757 * a consistent draw of the shared island - which is precisely the
1758 * is_continuous_island_cmp + shared-footprint precondition the
1759 * caller has already enforced.
1760 */
1761void inline_joint_table(GenericCircuit &gc,
1762 const std::vector<gate_t> &cmps,
1763 unsigned samples)
1764{
1765 const unsigned k = static_cast<unsigned>(cmps.size());
1766 auto probs = monteCarloJointDistribution(gc, cmps, samples);
1767
1768 /* Fresh key gate (the anonymous block anchor for these mulinputs).
1769 * Probability 1.0 because the key itself is not a sampled choice;
1770 * the mutually-exclusive outcomes among the mulinputs are what
1771 * carries the joint mass. */
1772 gate_t key = gc.addAnonymousInputGate(1.0);
1773
1774 /* Allocate one mulinput per joint outcome with positive probability.
1775 * Zero-probability outcomes are pruned: the cmp gate_plus
1776 * rewrites below would have included them as wires with prob 0,
1777 * which is a no-op in OR (gate_zero is the additive identity).
1778 * value_index = w gives independentEvaluation's mulin_seen dedup
1779 * a stable key (group, info) per outcome. */
1780 const std::size_t nb_outcomes = std::size_t{1} << k;
1781 std::vector<gate_t> mul_for_outcome(nb_outcomes,
1782 static_cast<gate_t>(-1));
1783 for (std::size_t w = 0; w < nb_outcomes; ++w) {
1784 if (probs[w] <= 0.0) continue;
1785 mul_for_outcome[w] =
1786 gc.addAnonymousMulinputGate(key, probs[w],
1787 static_cast<unsigned>(w));
1788 }
1789
1790 /* Rewrite each cmp as gate_plus over the mulinputs whose joint
1791 * outcome word has the cmp's bit set. */
1792 for (unsigned i = 0; i < k; ++i) {
1793 std::vector<gate_t> plus_wires;
1794 plus_wires.reserve(nb_outcomes / 2);
1795 for (std::size_t w = 0; w < nb_outcomes; ++w) {
1796 if ((w & (std::size_t{1} << i)) == 0) continue;
1797 gate_t m = mul_for_outcome[w];
1798 if (m == static_cast<gate_t>(-1)) continue;
1799 plus_wires.push_back(m);
1800 }
1801 gc.resolveToPlus(cmps[i], std::move(plus_wires));
1802 }
1803}
1804
1805} // namespace
1806
1807unsigned runHybridDecomposer(GenericCircuit &gc, unsigned samples)
1808{
1809 if (samples == 0) return 0;
1810
1811 /* Snapshot all gate_cmp ids that look like continuous islands.
1812 * Each call later mutates a snapshot entry from @c gate_cmp to
1813 * @c gate_input via @c resolveCmpToBernoulli (singleton group)
1814 * or to @c gate_plus via @c resolveToPlus (multi-cmp group), but
1815 * the snapshot vector is unaffected. The defensive type re-check
1816 * at iteration time guards against intervening mutations. */
1817 const auto nb = gc.getNbGates();
1818 std::vector<gate_t> cmps;
1819 for (std::size_t i = 0; i < nb; ++i) {
1820 auto g = static_cast<gate_t>(i);
1821 if (gc.getGateType(g) == gate_cmp && is_continuous_island_cmp(gc, g))
1822 cmps.push_back(g);
1823 }
1824
1825 /* Compute the per-cmp footprint up front so the pairwise-overlap
1826 * check is O(C * C * F) rather than O(C * C * tree_size). */
1827 std::unordered_map<gate_t, std::unordered_set<gate_t>> footprints;
1828 footprints.reserve(cmps.size());
1829 for (gate_t c : cmps) {
1830 collect_cmp_rv_footprint(gc, c, footprints[c]);
1831 }
1832
1833 /* Group cmps into connected components by base-RV footprint
1834 * overlap (union-find via parent[]). Linear-probe path
1835 * compression keeps the asymptotics near-linear in the number of
1836 * pairwise overlap checks. */
1837 std::vector<std::size_t> parent(cmps.size());
1838 for (std::size_t i = 0; i < cmps.size(); ++i) parent[i] = i;
1839 auto find = [&](std::size_t x) {
1840 while (parent[x] != x) {
1841 parent[x] = parent[parent[x]];
1842 x = parent[x];
1843 }
1844 return x;
1845 };
1846 auto unite = [&](std::size_t a, std::size_t b) {
1847 a = find(a); b = find(b);
1848 if (a != b) parent[a] = b;
1849 };
1850 for (std::size_t i = 0; i < cmps.size(); ++i) {
1851 for (std::size_t j = i + 1; j < cmps.size(); ++j) {
1852 if (find(i) == find(j)) continue;
1853 const auto &fp_i = footprints[cmps[i]];
1854 const auto &fp_j = footprints[cmps[j]];
1855 const auto &small = fp_i.size() < fp_j.size() ? fp_i : fp_j;
1856 const auto &big = fp_i.size() < fp_j.size() ? fp_j : fp_i;
1857 for (gate_t rv : small) {
1858 if (big.count(rv)) { unite(i, j); break; }
1859 }
1860 }
1861 }
1862
1863 /* Collect cmps by component root. */
1864 std::unordered_map<std::size_t, std::vector<gate_t>> groups;
1865 for (std::size_t i = 0; i < cmps.size(); ++i)
1866 groups[find(i)].push_back(cmps[i]);
1867
1868 unsigned resolved = 0;
1869 for (auto &[root, group] : groups) {
1870 (void) root;
1871 /* Defensive: re-check every cmp is still gate_cmp. Nothing in
1872 * the pipeline should have mutated them since the snapshot, but
1873 * the check is cheap. */
1874 bool all_pristine = true;
1875 for (gate_t c : group) {
1876 if (gc.getGateType(c) != gate_cmp) { all_pristine = false; break; }
1877 }
1878 if (!all_pristine) continue;
1879
1880 if (group.size() == 1) {
1881 /* Singleton island. If AnalyticEvaluator would resolve this
1882 * cmp exactly on its own (bare gate_rv vs gate_value, or two
1883 * bare normals), leave it untouched and let the closed-form
1884 * pass below handle it - no point burning MC samples on a
1885 * case with an analytical answer. Otherwise MC-marginalise
1886 * into a Bernoulli leaf here. */
1887 if (is_analytic_singleton_cmp(gc, group[0])) continue;
1888 double p = monteCarloRV(gc, group[0], samples);
1889 gc.resolveCmpToBernoulli(group[0], p);
1890 ++resolved;
1891 continue;
1892 }
1893
1894 /* Multi-cmp shared island. Try the monotone-shared-scalar fast
1895 * path first: when every cmp has shape `s op c` for a common
1896 * scalar gate_t s, the joint table is built from k+1 intervals
1897 * (analytical when s is a bare gate_rv with a known CDF, MC
1898 * binning otherwise) instead of 2^k cells, and the test
1899 * 14-style shared bare-RV case (`X > 0 OR X > 1`) lands on the
1900 * exact answer with no MC noise. When detection fails, fall
1901 * through to the generic 2^k MC joint table iff k is small
1902 * enough; larger groups keep their cmps as gate_cmp and fall
1903 * through to whole-circuit MC. */
1904 if (auto info = detect_shared_scalar(gc, group)) {
1905 inline_fast_path(gc, group, *info, samples);
1906 resolved += static_cast<unsigned>(group.size());
1907 continue;
1908 }
1909
1910 if (group.size() > JOINT_TABLE_K_MAX) continue;
1911
1912 inline_joint_table(gc, group, samples);
1913 resolved += static_cast<unsigned>(group.size());
1914 }
1915
1916 return resolved;
1917}
1918
1919} // namespace provsql
ComparisonOperator cmpOpFromOid(Oid op_oid, bool &ok)
Map a PostgreSQL comparison-operator OID to a ComparisonOperator.
Typed aggregation value, operator, and aggregator abstractions.
ComparisonOperator
SQL comparison operators used in gate_cmp circuit gates.
Definition Aggregation.h:39
@ LT
Less than (<).
Definition Aggregation.h:43
@ GT
Greater than (>).
Definition Aggregation.h:45
@ LE
Less than or equal (<=).
Definition Aggregation.h:42
@ NE
Not equal (<>).
Definition Aggregation.h:41
@ GE
Greater than or equal (>=).
Definition Aggregation.h:44
Closed-form CDF resolution for trivial gate_cmp shapes.
gate_t
Strongly-typed gate identifier.
Definition Circuit.h:49
Analytical expectation / variance / moment evaluator over RV circuits.
Peephole simplifier for continuous gate_arith sub-circuits.
Monte Carlo sampling over a GenericCircuit, RV-aware.
Continuous random-variable helpers (distribution parsing, moments).
std::vector< gate_t > & getWires(gate_t g)
Return a mutable reference to the child-wire list of gate g.
Definition Circuit.h:140
gateType getGateType(gate_t g) const
Return the type of gate g.
Definition Circuit.h:130
std::vector< gate_t >::size_type getNbGates() const
Return the total number of gates in the circuit.
Definition Circuit.h:103
In-memory provenance circuit with semiring-generic evaluation.
void resolveToPlus(gate_t g, std::vector< gate_t > w)
Rewrite an arbitrary gate as a gate_plus over w.
void resolveToCategoricalMixture(gate_t g, std::vector< gate_t > wires_)
Rewrite g in place as a categorical-form gate_mixture over wires ([key, mul_1, ......
void setWires(gate_t g, std::vector< gate_t > w)
Replace the wires of g with w.
gate_t addAnonymousMulinputGateWithValue(gate_t key, double p, unsigned value_index, const std::string &value_text)
Allocate a fresh gate_mulinput labelled with a numeric outcome value carried in extra.
void resolveToRv(gate_t g, const std::string &s)
Rewrite an arbitrary gate as a gate_rv carrying the distribution-spec extra s.
void resolveToMixture(gate_t g, gate_t p_token, gate_t x_token, gate_t y_token)
Rewrite g in place as a gate_mixture over the wires [p_token, x_token, y_token].
gate_t addAnonymousArithGate(provsql_arith_op op, std::vector< gate_t > wires_)
Allocate a fresh gate_arith gate with operator tag op and the given wires.
gate_t addAnonymousValueGate(const std::string &text)
Allocate a fresh gate_value gate carrying the textual scalar text.
bool isCategoricalMixture(gate_t g) const
Test whether g is a categorical-form gate_mixture (the explicit provsql.categorical output).
void setInfos(gate_t g, unsigned info1, unsigned info2)
Set the integer annotation pair for gate g.
std::string getExtra(gate_t g) const
Return the string extra for gate g.
double getProb(gate_t g) const
Return the probability for gate g.
void resolveCmpToBernoulli(gate_t g, double p)
Replace a gate_cmp by a constant Boolean leaf (gate_one for p == 1, gate_zero for p == 0) or by a Ber...
gate_t addAnonymousInputGate(double p)
Allocate a fresh gate_input gate carrying probability p, with a unique synthetic UUID so subsequent B...
std::pair< unsigned, unsigned > getInfos(gate_t g) const
Return the integer annotation pair for gate g.
gate_t addAnonymousMulinputGate(gate_t key, double p, unsigned value_index)
Allocate a fresh gate_mulinput gate with key key, probability p, and value index value_index.
void resolveToValue(gate_t g, const std::string &s)
Rewrite an arbitrary gate as a gate_value carrying the textual extra s.
@ Normal
Normal (Gaussian): p1=μ, p2=σ
@ Exponential
Exponential: p1=λ, p2 unused.
@ Uniform
Uniform on [a,b]: p1=a, p2=b.
@ Erlang
Erlang: p1=k (positive integer), p2=λ.
unsigned runConstantFold(GenericCircuit &gc)
Constant-fold pass over every gate_arith in gc.
double parseDoubleStrict(const std::string &s)
Strictly parse s as a double.
std::vector< double > monteCarloJointDistribution(const GenericCircuit &gc, const std::vector< gate_t > &cmps, unsigned samples)
Estimate the joint distribution of cmps via Monte Carlo.
unsigned runHybridSimplifier(GenericCircuit &gc)
Run the peephole simplifier over gc.
std::vector< double > monteCarloScalarSamples(const GenericCircuit &gc, gate_t root, unsigned samples)
Sample a scalar sub-circuit samples times and return the draws.
std::optional< DistributionSpec > parse_distribution_spec(const std::string &s)
Parse the on-disk text encoding of a gate_rv distribution.
double monteCarloRV(const GenericCircuit &gc, gate_t root, unsigned samples)
Run Monte Carlo on a circuit that may contain gate_rv leaves.
double cdfAt(const DistributionSpec &d, double c)
Closed-form CDF for a basic continuous distribution.
unsigned runHybridDecomposer(GenericCircuit &gc, unsigned samples)
Marginalise unresolved continuous-island gate_cmp gates into Bernoulli gate_input leaves.
Core types, constants, and utilities shared across ProvSQL.
provsql_arith_op
Arithmetic operator tags used by gate_arith.
@ PROVSQL_ARITH_DIV
binary, child0 / child1
@ PROVSQL_ARITH_PLUS
n-ary, sum of children
@ PROVSQL_ARITH_NEG
unary, -child0
@ PROVSQL_ARITH_MINUS
binary, child0 - child1
@ PROVSQL_ARITH_TIMES
n-ary, product of children
@ gate_rv
Continuous random-variable leaf (extra encodes distribution).
@ gate_mixture
Probabilistic mixture: three wires [p_token (gate_input Bernoulli), x_token, y_token]; samples x when...
@ gate_arith
n-ary arithmetic gate over scalar-valued children (info1 holds operator tag)