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 <cmath>
9#include <limits>
10#include <memory>
11#include <optional>
12#include <stack>
13#include <string>
14#include <unordered_map>
15#include <unordered_set>
16#include <utility>
17#include <vector>
18
19#include "Aggregation.h" // ComparisonOperator, cmpOpFromOid
20#include "AnalyticEvaluator.h" // cdfAt
21#include "distributions/Distribution.h" // makeDistribution, affine, closePlusTerms
22#include "Expectation.h" // evaluateBooleanProbability
23#include "MonteCarloSampler.h" // monteCarloRV, monteCarloScalarSamples
24#include "PivotIntegration.h" // simpsonIntegrate, kSimpsonPanels
25#include "RandomVariable.h" // parse_distribution_spec, double_to_text
26extern "C" {
27#include "provsql_utils.h" // gate_type, provsql_arith_op
28}
29#include <algorithm> // std::sort, std::unique, std::upper_bound
30
31namespace provsql {
32
33namespace {
34
35constexpr double NaN = std::numeric_limits<double>::quiet_NaN();
36
37/* Base @c gate_rv leaves whose sampled value must stay coupled across
38 * distinct comparators. The identity-minting folds (@c try_sum_closure,
39 * @c try_times_scalar_rv, @c try_product_closure, @c try_transform_closure,
40 * @c try_neg_rv) replace their gate with a fresh @c gate_rv and orphan
41 * the base RV they consumed; that mints an independent draw. Sound when
42 * the base RV feeds a single comparator side (its marginal is unchanged),
43 * but WRONG when the same leaf feeds two comparator sides that must see
44 * the same draw -- e.g. a latent parameter b feeding both `0 + b` and
45 * `1 + b`, two perfectly correlated events; folding each into an
46 * independent Normal silently applies the independence approximation.
47 *
48 * The set holds every base RV reachable from two or more sibling subtrees
49 * combined non-additively: the two sides of a @c gate_cmp, or two arms of a
50 * non-additive @c gate_arith combinator (MIN / MAX / PERCENTILE / MINUS /
51 * TIMES / DIV / POW) -- e.g. the shared @c T0 in @c min(T0+T1, T0+T2). A
52 * leaf private to a single such subtree -- or one repeated inside an additive
53 * @c PLUS like `x + x`, which @c try_plus_aggregate folds while preserving the
54 * shared identity -- is absent, so those folds still fire. When a candidate base RV is in this set the fold bails,
55 * leaving the @c gate_arith intact so the island decomposer descends to
56 * the shared leaf and couples the comparators. Set for the duration of
57 * @c runHybridSimplifier; null (empty) elsewhere. */
58const std::unordered_set<gate_t> *g_shared_base_rvs = nullptr;
59
60inline bool is_shared_base_rv(gate_t rv)
61{
62 return g_shared_base_rvs != nullptr && g_shared_base_rvs->count(rv) != 0;
63}
64
65/* Collect the base @c gate_rv leaves reachable from @p start through
66 * @c gate_arith composition and @c gate_rv latent-parameter wires (a
67 * parametric RV couples through its parameter leaves). Mirrors the
68 * cmp-footprint walk but seeded from a single gate, so it can be run
69 * once per comparator wire. */
70void collect_reachable_base_rvs(const GenericCircuit &gc, gate_t start,
71 std::unordered_set<gate_t> &out)
72{
73 std::unordered_set<gate_t> seen;
74 std::stack<gate_t> stk;
75 stk.push(start);
76 while (!stk.empty()) {
77 gate_t g = stk.top(); stk.pop();
78 if (!seen.insert(g).second) continue;
79 auto t = gc.getGateType(g);
80 if (t == gate_rv) {
81 out.insert(g);
82 for (gate_t c : gc.getWires(g)) stk.push(c); /* latent params */
83 continue;
84 }
85 if (t == gate_arith) {
86 for (gate_t c : gc.getWires(g)) stk.push(c);
87 continue;
88 }
89 if (t == gate_mixture && !gc.isCategoricalMixture(g)) {
90 const auto &mw = gc.getWires(g);
91 if (mw.size() == 3) { stk.push(mw[1]); stk.push(mw[2]); }
92 }
93 /* gate_value / other: no base-RV identity below. */
94 }
95}
96
97/**
98 * @brief Try to evaluate a @c gate_arith subtree to a scalar constant.
99 *
100 * Recurses over the @c gate_arith ops, parsing @c gate_value leaves
101 * via @c parseDoubleStrict. Returns @c NaN if any leaf is not a
102 * @c gate_value (or fails to parse), if a binary op has the wrong
103 * arity, or if any arith op is unknown. Successful constants of any
104 * value (including @c 0 and @c NaN-shaped values via division) are
105 * returned as @c double literals; the caller distinguishes
106 * "couldn't fold" from "folded to NaN" via @c std::isnan on the
107 * input gate's children, not on the result. In practice provsql
108 * @c gate_value extras never carry @c NaN, so the @c NaN-as-sentinel
109 * convention is unambiguous.
110 */
111double try_eval_constant(const GenericCircuit &gc, gate_t g)
112{
113 auto t = gc.getGateType(g);
114 if (t == gate_value) {
115 try { return parseDoubleStrict(gc.getExtra(g)); }
116 catch (const CircuitException &) { return NaN; }
117 }
118 if (t != gate_arith) return NaN;
119
120 auto op = static_cast<provsql_arith_op>(gc.getInfos(g).first);
121 const auto &wires = gc.getWires(g);
122 if (wires.empty()) return NaN;
123
124 double first = try_eval_constant(gc, wires[0]);
125 if (std::isnan(first)) return NaN;
126
127 switch (op) {
128 case PROVSQL_ARITH_PLUS: {
129 double r = first;
130 for (std::size_t i = 1; i < wires.size(); ++i) {
131 double v = try_eval_constant(gc, wires[i]);
132 if (std::isnan(v)) return NaN;
133 r += v;
134 }
135 return r;
136 }
137 case PROVSQL_ARITH_TIMES: {
138 double r = first;
139 for (std::size_t i = 1; i < wires.size(); ++i) {
140 double v = try_eval_constant(gc, wires[i]);
141 if (std::isnan(v)) return NaN;
142 r *= v;
143 }
144 return r;
145 }
146 case PROVSQL_ARITH_MINUS: {
147 if (wires.size() != 2) return NaN;
148 double v = try_eval_constant(gc, wires[1]);
149 if (std::isnan(v)) return NaN;
150 return first - v;
151 }
152 case PROVSQL_ARITH_DIV: {
153 if (wires.size() != 2) return NaN;
154 double v = try_eval_constant(gc, wires[1]);
155 if (std::isnan(v)) return NaN;
156 return first / v;
157 }
159 if (wires.size() != 1) return NaN;
160 return -first;
161 case PROVSQL_ARITH_MAX: {
162 double r = first;
163 for (std::size_t i = 1; i < wires.size(); ++i) {
164 double v = try_eval_constant(gc, wires[i]);
165 if (std::isnan(v)) return NaN;
166 r = std::max(r, v);
167 }
168 return r;
169 }
170 case PROVSQL_ARITH_MIN: {
171 double r = first;
172 for (std::size_t i = 1; i < wires.size(); ++i) {
173 double v = try_eval_constant(gc, wires[i]);
174 if (std::isnan(v)) return NaN;
175 r = std::min(r, v);
176 }
177 return r;
178 }
179 case PROVSQL_ARITH_POW: {
180 if (wires.size() != 2) return NaN;
181 double e = try_eval_constant(gc, wires[1]);
182 if (std::isnan(e)) return NaN;
183 /* A domain-violating constant (negative base, non-integer
184 * exponent) folds to NaN, which the NaN-as-sentinel convention
185 * reads as "couldn't fold": the gate stays intact and the
186 * sampler raises its actionable domain error instead of a
187 * silent NaN constant appearing in the circuit. */
188 return std::pow(first, e);
189 }
190 case PROVSQL_ARITH_LN:
191 if (wires.size() != 1) return NaN;
192 /* ln of a negative constant is NaN -> stays unfolded, same as POW. */
193 return std::log(first);
195 if (wires.size() != 1) return NaN;
196 return std::exp(first);
198 /* An order-statistic aggregate over a random member set is never a
199 * constant: leave it for the sampler. */
200 return NaN;
201 }
202 return NaN;
203}
204
205/**
206 * @brief Whether the subtree rooted at @p g contains a @c gate_agg.
207 *
208 * The hybrid simplifier is RV-oriented; aggregate arithmetic
209 * (@c gate_arith over @c gate_agg) is a separate feature whose
210 * comparisons are resolved by the HAVING possible-worlds enumeration,
211 * which must see the original operators to apply the correct (integer
212 * floor vs real) division semantics. Rewrites that are sound for
213 * continuous RVs but not for aggregates (notably the DIV-by-constant to
214 * TIMES-by-reciprocal canonicalisation, which discards integer-division
215 * flooring) consult this to leave aggregate subtrees untouched.
216 */
217bool subtree_contains_agg(const GenericCircuit &gc, gate_t g)
218{
219 std::unordered_set<gate_t> seen;
220 std::stack<gate_t> stk;
221 stk.push(g);
222 while (!stk.empty()) {
223 gate_t cur = stk.top(); stk.pop();
224 if (!seen.insert(cur).second) continue;
225 if (gc.getGateType(cur) == gate_agg) return true;
226 for (gate_t ch : gc.getWires(cur)) stk.push(ch);
227 }
228 return false;
229}
230
231/**
232 * @brief Rewrite @p g in place as a @c gate_value carrying @p c.
233 *
234 * Clears wires and infos; the old children become orphans (no parent
235 * reaches them via @p g anymore). This is the same pattern
236 * @c resolveCmpToBernoulli uses for resolved comparators.
237 */
238void replace_with_value(GenericCircuit &gc, gate_t g, double c)
239{
241}
242
243/**
244 * @brief Test whether wire @p g is a @c gate_value parseable to
245 * scalar @p target (within bit-exact equality).
246 */
247bool is_value_equal_to(const GenericCircuit &gc, gate_t g, double target)
248{
249 if (gc.getGateType(g) != gate_value) return false;
250 try { return parseDoubleStrict(gc.getExtra(g)) == target; }
251 catch (const CircuitException &) { return false; }
252}
253
254/**
255 * @brief Identity-element drop for @c PLUS / @c TIMES.
256 *
257 * - @c PLUS: drop @c gate_value:0 wires. If 0 wires remain, fold to
258 * @c gate_value:0.
259 * - @c TIMES: if any wire is @c gate_value:0, fold to @c gate_value:0
260 * (multiplicative absorber, even if other wires are non-constant).
261 * Otherwise drop @c gate_value:1 wires; if 0 wires remain, fold to
262 * @c gate_value:1.
263 *
264 * Returns @c true if @p g was mutated. After a mutation that leaves
265 * @p g as @c gate_arith, the per-gate fixed-point loop in @c simplify
266 * re-runs the rules: a @c PLUS that had three wires reduced to one
267 * looks the same as the original input to the simplifier, so we just
268 * need to terminate when no rule fires.
269 */
270bool try_identity_drop(GenericCircuit &gc, gate_t g)
271{
272 auto op = static_cast<provsql_arith_op>(gc.getInfos(g).first);
273 auto &wires = gc.getWires(g);
274
275 if (op == PROVSQL_ARITH_PLUS) {
276 std::vector<gate_t> kept;
277 kept.reserve(wires.size());
278 for (gate_t w : wires) {
279 if (!is_value_equal_to(gc, w, 0.0)) kept.push_back(w);
280 }
281 if (kept.size() == wires.size()) return false; /* nothing to drop */
282 if (kept.empty()) {
283 replace_with_value(gc, g, 0.0);
284 return true;
285 }
286 wires = std::move(kept);
287 return true;
288 }
289
290 if (op == PROVSQL_ARITH_TIMES) {
291 for (gate_t w : wires) {
292 if (is_value_equal_to(gc, w, 0.0)) {
293 replace_with_value(gc, g, 0.0);
294 return true;
295 }
296 }
297 std::vector<gate_t> kept;
298 kept.reserve(wires.size());
299 for (gate_t w : wires) {
300 if (!is_value_equal_to(gc, w, 1.0)) kept.push_back(w);
301 }
302 if (kept.size() == wires.size()) return false;
303 if (kept.empty()) {
304 replace_with_value(gc, g, 1.0);
305 return true;
306 }
307 wires = std::move(kept);
308 return true;
309 }
310
311 return false;
312}
313
314/**
315 * @brief Decomposition of a PLUS-wire as @c a*Z + b for the
316 * family sum closure.
317 *
318 * - @c rv_gate == invalid (sentinel @c (gate_t)-1) ⇒ pure constant
319 * wire: contributes @p b to the total mean, 0 to the total
320 * variance, and no RV to the footprint.
321 * - @c rv_gate != invalid ⇒ scalar-multiple-of-normal wire:
322 * contributes @c a*μ + b to the total mean, @c a²σ² to the total
323 * variance, and @p rv_gate to the footprint.
324 */
325struct LinearTerm {
326 gate_t rv_gate; ///< Base gate_rv, or invalid for constants.
327 double a; ///< Scalar multiplier (0 for pure constants).
328 double b; ///< Additive offset (0 for pure RV wires).
329};
330
331constexpr gate_t INVALID_GATE = static_cast<gate_t>(-1);
332
333bool is_invalid(gate_t g) { return g == INVALID_GATE; }
334
335/**
336 * @brief Try to interpret @p g as @c a*Z + b for a single base RV.
337 *
338 * Recognised shapes:
339 * - bare @c gate_rv (any distribution): @c (Z=g, a=1, b=0)
340 * - bare @c gate_value: @c (Z=invalid, a=0, b=value)
341 * - @c arith(NEG, child): negate the child's decomposition
342 * - @c arith(TIMES, value:c, child): scale the child's decomposition
343 * by @c c (and symmetrically @c arith(TIMES, child, value:c)).
344 * Only 2-wire @c TIMES with exactly one @c gate_value side is
345 * recognised; other shapes fall through to "not decomposable".
346 *
347 * Nested @c arith(PLUS, ...) children of the outer PLUS are not
348 * decomposed by this routine: the bottom-up simplifier already
349 * folded them before the outer PLUS is processed, so by the time
350 * we examine the outer PLUS its children are either leaves or
351 * non-foldable arith. An undecomposable wire causes the caller to
352 * bail.
353 *
354 * Distribution-kind concerns are the caller's responsibility:
355 * @c try_sum_closure parses each base RV's spec and dispatches on the
356 * families present via the ClosureRuleRegistry, while
357 * @c try_plus_aggregate is kind-agnostic because the aggregation
358 * rewrite preserves the base-RV identity.
359 */
360std::optional<LinearTerm>
361decompose_linear_term(const GenericCircuit &gc, gate_t g)
362{
363 auto t = gc.getGateType(g);
364
365 if (t == gate_value) {
366 double v;
367 try { v = parseDoubleStrict(gc.getExtra(g)); }
368 catch (const CircuitException &) { return std::nullopt; }
369 return LinearTerm{INVALID_GATE, 0.0, v};
370 }
371
372 if (t == gate_rv) {
373 /* Any RV kind: aggregation only depends on identity, not on
374 * closed-form scaling. The sum closure dispatches on the family
375 * externally. */
376 return LinearTerm{g, 1.0, 0.0};
377 }
378
379 if (t == gate_mixture) {
380 /* A @c gate_mixture (3-wire Bernoulli or categorical N-wire) is a
381 * scalar-RV leaf: two references to the same @c gate_t produce
382 * perfectly-correlated draws of the same RV. Treat it like a
383 * @c gate_rv so the PLUS aggregator can collapse same-mixture
384 * terms (e.g. @c X+X to @c 2·X, @c X-X to @c 0). The in-place
385 * op-change to TIMES then triggers @c try_mixture_lift to push the
386 * scalar inside the branches (3-wire) or the mulinputs'
387 * value text (categorical). The sum closure parses the rv leaf's
388 * spec via @c parse_distribution_spec, which returns @c nullopt on
389 * a mixture's empty extra, so it automatically bails when the
390 * LHS-RV side is a mixture. */
391 return LinearTerm{g, 1.0, 0.0};
392 }
393
394 if (t != gate_arith) return std::nullopt;
395
396 auto op = static_cast<provsql_arith_op>(gc.getInfos(g).first);
397 const auto &wires = gc.getWires(g);
398
399 /* After an identity-element drop, a PLUS or TIMES gate can be left
400 * with a single wire that semantically passes through. Recurse so
401 * the outer closure can still see the underlying term. We can't
402 * fold the singleton wrapper away in place (rewriting it as the
403 * child's type / extra would mint a fresh RV identity and break
404 * per-iteration MC memoisation across other parents of the child),
405 * but the outer closure rewrites the OUTER gate, which is safe. */
406 if ((op == PROVSQL_ARITH_PLUS || op == PROVSQL_ARITH_TIMES)
407 && wires.size() == 1) {
408 return decompose_linear_term(gc, wires[0]);
409 }
410
411 if (op == PROVSQL_ARITH_NEG) {
412 if (wires.size() != 1) return std::nullopt;
413 auto inner = decompose_linear_term(gc, wires[0]);
414 if (!inner) return std::nullopt;
415 return LinearTerm{inner->rv_gate, -inner->a, -inner->b};
416 }
417
418 if (op == PROVSQL_ARITH_TIMES) {
419 if (wires.size() != 2) return std::nullopt;
420 /* Identify the constant side and the variable side. */
421 double c = NaN;
422 gate_t var_side = INVALID_GATE;
423 if (gc.getGateType(wires[0]) == gate_value) {
424 try { c = parseDoubleStrict(gc.getExtra(wires[0])); }
425 catch (const CircuitException &) { return std::nullopt; }
426 var_side = wires[1];
427 } else if (gc.getGateType(wires[1]) == gate_value) {
428 try { c = parseDoubleStrict(gc.getExtra(wires[1])); }
429 catch (const CircuitException &) { return std::nullopt; }
430 var_side = wires[0];
431 } else {
432 return std::nullopt;
433 }
434 auto inner = decompose_linear_term(gc, var_side);
435 if (!inner) return std::nullopt;
436 return LinearTerm{inner->rv_gate, c * inner->a, c * inner->b};
437 }
438
439 return std::nullopt;
440}
441
442/**
443 * @brief Family closure on a @c PLUS gate, driven by the
444 * @c ClosureRuleRegistry.
445 *
446 * Decomposes every wire to @c a*Z + b (via @c decompose_linear_term),
447 * parses each base RV's distribution, and hands the terms to
448 * @c closePlusTerms, which dispatches on the families present. The
449 * registered rules cover:
450 *
451 * - Normal: any linear combination of independent normals (plus
452 * constants) folds to a single normal;
453 * - Exponential / Erlang: an unscaled same-rate chain folds to
454 * Erlang(Σk, λ) -- left-associative parsing of <tt>a + b + c</tt>
455 * builds <tt>(a+b)+c</tt> which bottom-up simplifies to
456 * Erlang(2)+c, so the rule accepts the mixed Erlang+Exp shape to
457 * close the chain;
458 * - Uniform: a single (possibly scaled / negated) uniform plus
459 * constants folds to the affine-transformed uniform, including the
460 * post-MINUS-canonicalisation shapes @c c + (-U) and @c (-U) + c.
461 * @c U + @c U is @b not closed (triangular density), which the rule
462 * expresses by declining a second Uniform term.
463 *
464 * Independence is tested here, structurally: every non-constant term
465 * must have a distinct base-RV @c gate_t (each RV constructor mints a
466 * fresh UUID, so distinctness implies independence, and
467 * @c try_plus_aggregate runs first so shared-UUID terms were already
468 * consolidated). A @c gate_mixture leaf has no parseable distribution
469 * spec, so mixture-bearing sums bail (they are @c try_mixture_lift's
470 * job). When every wire is a pure constant the dispatch declines and
471 * the constant fold handles the gate on the next fixed-point iteration.
472 *
473 * Same coupling caveat as @c try_times_scalar_rv: replacing @p g with
474 * a fresh @c gate_rv mints a new RV identity.
475 */
476bool try_sum_closure(GenericCircuit &gc, gate_t g)
477{
478 const auto &wires = gc.getWires(g);
479 if (wires.size() < 2) return false;
480
481 std::vector<LinearTerm> lterms;
482 lterms.reserve(wires.size());
483 for (gate_t w : wires) {
484 auto term = decompose_linear_term(gc, w);
485 if (!term) return false;
486 lterms.push_back(*term);
487 }
488
489 /* Independence test + per-term distribution parse. */
490 std::vector<std::unique_ptr<Distribution>> dists(lterms.size());
491 std::vector<ClosureTerm> terms;
492 terms.reserve(lterms.size());
493 std::unordered_set<gate_t> seen_rvs;
494 for (std::size_t i = 0; i < lterms.size(); ++i) {
495 const auto &t = lterms[i];
496 if (is_invalid(t.rv_gate)) {
497 terms.push_back({nullptr, t.a, t.b});
498 continue;
499 }
500 if (!seen_rvs.insert(t.rv_gate).second) return false; /* dependent */
501 /* A base RV shared with sibling subtrees must stay a live wire so
502 * downstream coupling survives; folding it into a fresh identity
503 * here would decouple the correlated events. */
504 if (is_shared_base_rv(t.rv_gate)) return false;
505 auto spec = parse_distribution_spec(gc.getExtra(t.rv_gate));
506 if (!spec) return false; /* mixture / corrupted extra */
507 dists[i] = makeDistribution(*spec);
508 terms.push_back({dists[i].get(), t.a, t.b});
509 }
510
511 auto folded = closePlusTerms(terms);
512 if (!folded) return false;
513
514 gc.resolveToRv(g, folded->serialise());
515 return true;
516}
517
518/**
519 * @brief Product closure on a @c TIMES gate, driven by the
520 * @c ProductRuleRegistry.
521 *
522 * Wires must be @c gate_value factors (multiplied into one scalar) or
523 * bare @c gate_rv leaves with distinct UUIDs (independence, as in
524 * @c try_sum_closure); at least two RV factors, or the 2-wire
525 * scalar-times-RV shape is @c try_times_scalar_rv's job. The
526 * registered rules cover lognormal products (parameters add in log
527 * space); the accumulated scalar then applies through the family's
528 * @c affine. Same fresh-identity coupling caveat as the sum closure.
529 */
530bool try_product_closure(GenericCircuit &gc, gate_t g)
531{
532 const auto &wires = gc.getWires(g);
533 if (wires.size() < 2) return false;
534
535 double c_total = 1.0;
536 std::vector<std::unique_ptr<Distribution>> dists;
537 std::vector<const Distribution *> factors;
538 std::unordered_set<gate_t> seen_rvs;
539 for (gate_t w : wires) {
540 const auto t = gc.getGateType(w);
541 if (t == gate_value) {
542 try { c_total *= parseDoubleStrict(gc.getExtra(w)); }
543 catch (const CircuitException &) { return false; }
544 continue;
545 }
546 if (t != gate_rv) return false;
547 if (!seen_rvs.insert(w).second) return false; /* dependent */
548 if (is_shared_base_rv(w)) return false; /* shared: keep live */
549 auto spec = parse_distribution_spec(gc.getExtra(w));
550 if (!spec) return false;
551 dists.push_back(makeDistribution(*spec));
552 factors.push_back(dists.back().get());
553 }
554 if (factors.size() < 2) return false;
555
556 auto combined = closeProductFactors(factors);
557 if (!combined) return false;
558 if (c_total != 1.0) {
559 combined = combined->scale(c_total);
560 if (!combined) return false;
561 }
562 gc.resolveToRv(g, combined->serialise());
563 return true;
564}
565
566/**
567 * @brief Transform closure on a unary @c LN / @c EXP gate, driven by
568 * the @c TransformRuleRegistry.
569 *
570 * When the child is a bare @c gate_rv whose family registers a
571 * closed-form image (exp(normal) is lognormal, ln(lognormal) is
572 * normal), the gate folds to the image distribution -- the bottom-up
573 * pass has already folded the child, so chains like
574 * <tt>exp(normal + normal)</tt> collapse fully. Same fresh-identity
575 * coupling caveat as the sum closure.
576 */
577bool try_transform_closure(GenericCircuit &gc, gate_t g)
578{
579 const auto op = static_cast<provsql_arith_op>(gc.getInfos(g).first);
580 const char *transform = op == PROVSQL_ARITH_LN ? "ln"
581 : op == PROVSQL_ARITH_EXP ? "exp"
582 : nullptr;
583 if (!transform) return false;
584 const auto &wires = gc.getWires(g);
585 if (wires.size() != 1) return false;
586 if (gc.getGateType(wires[0]) != gate_rv) return false;
587 if (is_shared_base_rv(wires[0])) return false; /* shared: keep live */
588 auto spec = parse_distribution_spec(gc.getExtra(wires[0]));
589 if (!spec) return false;
590
591 auto image = closeTransform(transform, *makeDistribution(*spec));
592 if (!image) return false;
593 gc.resolveToRv(g, image->serialise());
594 return true;
595}
596
597/**
598 * @brief Negation closure on a bare @c gate_rv: rewrite @c arith(NEG, Z)
599 * as a closed-form-negated @c gate_rv when @c Z's family admits
600 * one.
601 *
602 * Delegates to @c Distribution::negate (@c affine(-1, 0)): Normal and
603 * Uniform fold (<tt>-N(μ, σ) = N(-μ, σ)</tt>,
604 * <tt>-U(a, b) = U(-b, -a)</tt>); Exponential / Erlang decline (the
605 * support flips to @c (-∞, 0], leaving the family).
606 *
607 * Coupling discipline: same as @c try_times_scalar_rv. Pass-2 gated
608 * so a parent PLUS containing @c NEG(Z) and a sibling reference to the
609 * same @c Z is folded first by @c try_plus_aggregate (which recognises
610 * @c NEG via @c decompose_linear_term's coefficient @c -1) before we
611 * mint a fresh @c gate_rv at the NEG.
612 */
613bool try_neg_rv(GenericCircuit &gc, gate_t g)
614{
615 if (gc.getGateType(g) != gate_arith) return false;
616 auto op = static_cast<provsql_arith_op>(gc.getInfos(g).first);
617 if (op != PROVSQL_ARITH_NEG) return false;
618 const auto &wires = gc.getWires(g);
619 if (wires.size() != 1) return false;
620 if (gc.getGateType(wires[0]) != gate_rv) return false;
621 if (is_shared_base_rv(wires[0])) return false; /* shared: keep live */
622
623 auto spec = parse_distribution_spec(gc.getExtra(wires[0]));
624 if (!spec) return false;
625
626 auto negated = makeDistribution(*spec)->negate();
627 if (!negated) return false;
628 gc.resolveToRv(g, negated->serialise());
629 return true;
630}
631
632/**
633 * @brief Mixture-lift rewrite: push @c PLUS / @c TIMES inside a
634 * single @c gate_mixture child.
635 *
636 * Fires on a @c gate_arith with op @c PLUS or @c TIMES whose children
637 * contain exactly one @c gate_mixture. Replaces the parent with a
638 * @c gate_mixture sharing the same Bernoulli (so the original
639 * <tt>p_token</tt> identity is preserved and any other gate that
640 * referenced it continues to see it):
641 *
642 * <tt>a + mixture(p, X, Y) → mixture(p, a + X, a + Y)</tt>
643 *
644 * The two new branches are fresh @c gate_arith children built via
645 * @c addAnonymousArithGate; each is then re-fed to @c apply_rules so
646 * the family sum closure gets a chance
647 * to collapse them. This is the source of the headline simplifier
648 * gain for compound RV expressions: <tt>3 + mixture(p, N(0,1), N(2,1))</tt>
649 * folds to <tt>mixture(p, N(3,1), N(5,1))</tt> in a single bottom-up
650 * pass.
651 *
652 * Multi-mixture lifts (two or more @c gate_mixture children of the
653 * same arith) are out of scope: each would multiply the branch count
654 * by 2 and the lifted form would couple the resulting branches
655 * through their Bernoullis, which the current closures cannot
656 * collapse further. @c MINUS / @c DIV / @c NEG lifts are also out of
657 * scope (the user requested only @c PLUS and @c TIMES); they can be
658 * added in a follow-up once the sum closure handles
659 * subtraction.
660 *
661 * Returns @c true if @p g was mutated.
662 */
663unsigned apply_rules(GenericCircuit &gc, gate_t g,
664 bool include_scalar_fold); /* forward decl */
665
666/**
667 * @brief Categorical-mixture lift helper.
668 *
669 * Pushes a constant scaling (@c TIMES) or offset (@c PLUS) inside the
670 * N-wire categorical-form @c gate_mixture <tt>[key, mul_1, ..., mul_n]</tt>
671 * by minting a fresh categorical mixture sharing the same @p key gate
672 * and one new @c gate_mulinput per outcome with an updated value text.
673 *
674 * Sharing the key preserves the semantic that the new mixture is a
675 * deterministic function of the same underlying categorical draw (so
676 * <tt>c · X</tt> and @c X stay perfectly correlated downstream via
677 * FootprintCache key-overlap dependency tracking). All other arith
678 * wires must be @c gate_value constants; an RV factor / offset cannot
679 * be pushed into a mulinput's scalar @c extra so the rule bails.
680 *
681 * Returns @c true if @p g was mutated.
682 */
683bool try_categorical_mixture_lift(GenericCircuit &gc, gate_t g,
685 gate_t mix_gate,
686 const std::vector<gate_t> &others)
687{
688 if (op != PROVSQL_ARITH_PLUS && op != PROVSQL_ARITH_TIMES) return false;
689
690 /* Combine the non-mixture wires into a single scalar offset (PLUS)
691 * or factor (TIMES). Bail on any non-value wire: an RV factor /
692 * offset cannot be pushed into a mulinput's value text. */
693 double offset = 0.0;
694 double factor = 1.0;
695 for (gate_t w : others) {
696 if (gc.getGateType(w) != gate_value) return false;
697 double v;
698 try { v = parseDoubleStrict(gc.getExtra(w)); }
699 catch (const CircuitException &) { return false; }
700 if (op == PROVSQL_ARITH_PLUS) offset += v;
701 else factor *= v;
702 }
703
704 /* Build the new wire list: same key (preserves correlation with the
705 * original categorical) and one fresh mulinput per outcome with the
706 * transformed value text. Snapshot the mixture's wires by value:
707 * @c addAnonymousMulinputGateWithValue below calls @c addGate, which
708 * does @c wires.push_back({}) on the circuit's outer wire vector,
709 * and that can reallocate -- invalidating any reference returned by
710 * @c getWires. Reads of the reference after the first iteration
711 * then return garbage gate ids, which surfaces either as wrong
712 * outcome values or as a backend crash. */
713 const std::vector<gate_t> mw = gc.getWires(mix_gate);
714 const gate_t key = mw[0];
715 std::vector<gate_t> new_wires;
716 new_wires.reserve(mw.size());
717 new_wires.push_back(key);
718 for (std::size_t i = 1; i < mw.size(); ++i) {
719 const gate_t old_mul = mw[i];
720 double old_v;
721 try { old_v = parseDoubleStrict(gc.getExtra(old_mul)); }
722 catch (const CircuitException &) { return false; }
723 const double new_v = (op == PROVSQL_ARITH_PLUS)
724 ? (offset + old_v)
725 : (factor * old_v);
726 const double p = gc.getProb(old_mul);
727 const auto vi = static_cast<unsigned>(gc.getInfos(old_mul).first);
729 key, p, vi, double_to_text(new_v));
730 new_wires.push_back(new_mul);
731 }
732 gc.resolveToCategoricalMixture(g, std::move(new_wires));
733 return true;
734}
735
736bool try_mixture_lift(GenericCircuit &gc, gate_t g,
737 bool include_scalar_fold)
738{
739 auto op = static_cast<provsql_arith_op>(gc.getInfos(g).first);
740 if (op != PROVSQL_ARITH_PLUS && op != PROVSQL_ARITH_TIMES) return false;
741
742 const auto &wires = gc.getWires(g);
743 if (wires.size() < 2) return false; /* nothing to lift */
744
745 /* Find exactly one mixture child. */
746 std::size_t mix_idx = static_cast<std::size_t>(-1);
747 for (std::size_t i = 0; i < wires.size(); ++i) {
748 if (gc.getGateType(wires[i]) == gate_mixture) {
749 if (mix_idx != static_cast<std::size_t>(-1)) return false;
750 mix_idx = i;
751 }
752 }
753 if (mix_idx == static_cast<std::size_t>(-1)) return false;
754
755 const auto mix_gate = wires[mix_idx];
756
757 /* Snapshot the remaining wires. We need a copy because the
758 * resolveToMixture / resolveToCategoricalMixture calls below clear
759 * the parent's wire vector. */
760 std::vector<gate_t> others;
761 others.reserve(wires.size() - 1);
762 for (std::size_t i = 0; i < wires.size(); ++i) {
763 if (i != mix_idx) others.push_back(wires[i]);
764 }
765
766 /* Categorical N-wire form: push the constant offset / factor into
767 * each mulinput's value text. RV factors / offsets cannot be pushed
768 * into mulinput leaves so the rule bails on those. */
769 if (gc.isCategoricalMixture(mix_gate)) {
770 return try_categorical_mixture_lift(gc, g, op, mix_gate, others);
771 }
772
773 /* Classic 3-wire Bernoulli mixture. */
774 const auto &mw = gc.getWires(mix_gate);
775 if (mw.size() != 3) return false;
776 const gate_t p_tok = mw[0];
777 const gate_t x_tok = mw[1];
778 const gate_t y_tok = mw[2];
779
780 /* Build two new arith children: one with x in the mixture slot,
781 * one with y. Order matters for non-commutative ops, but PLUS /
782 * TIMES are both commutative so we just append the branch RV to
783 * the others. */
784 std::vector<gate_t> new_x_wires = others; new_x_wires.push_back(x_tok);
785 std::vector<gate_t> new_y_wires = others; new_y_wires.push_back(y_tok);
786 gate_t new_x = gc.addAnonymousArithGate(op, std::move(new_x_wires));
787 gate_t new_y = gc.addAnonymousArithGate(op, std::move(new_y_wires));
788
789 /* Rewrite g as gate_mixture(p, new_x, new_y). This clears g's
790 * old wires / infos / extra and installs the new structure. */
791 gc.resolveToMixture(g, p_tok, new_x, new_y);
792
793 /* Recursively fold the two new arith children so they get a chance
794 * to collapse via the family sum closure. Each is
795 * itself a gate_arith of the same op, with at least 2 wires (the
796 * "others" we copied plus the branch RV), so apply_rules's
797 * PLUS/TIMES path is the correct entry point. The scalar-fold flag
798 * is propagated so pass-2's scalar-times-RV closure stays the only
799 * place that mints a fresh @c gate_rv at a scaled-RV TIMES site
800 * (avoids losing shared-RV identity in front of a sibling PLUS). */
801 apply_rules(gc, new_x, include_scalar_fold);
802 apply_rules(gc, new_y, include_scalar_fold);
803
804 return true;
805}
806
807/**
808 * @brief Scalar-times-RV closure: fold @c arith(TIMES, value:c, Z) to
809 * a single closed-form-scaled @c gate_rv.
810 *
811 * Fires on a 2-wire @c TIMES whose wires are exactly one @c gate_value
812 * (the scalar @c c) and one @c gate_rv leaf @c Z whose distribution
813 * admits a closed-form scale transform, per @c Distribution::scale
814 * (@c affine(c, 0)): Normal for any non-zero @c c, Uniform for any
815 * non-zero @c c (a negative @c c flips the bounds), Exponential /
816 * Erlang for @c c > 0 only (negative scaling flips the support).
817 *
818 * The c=0 absorber and c=1 identity are handled by
819 * @c try_identity_drop, so this rule defensively bails on them to
820 * avoid a duplicate rewrite path. RV kinds without a closed-form
821 * scaling fall through.
822 *
823 * Coupling caveat (shared with @c try_sum_closure): replacing the
824 * TIMES with a fresh @c gate_rv mints a new RV identity at @p g, so
825 * any other path that references @c Z and shares a downstream consumer
826 * with @p g will see decoupled draws after the fold. In practice the
827 * rewrite path produces per-row orphan subtrees, so this is consistent
828 * with the family sum closure's behaviour.
829 *
830 * Returns @c true if @p g was mutated.
831 */
832bool try_times_scalar_rv(GenericCircuit &gc, gate_t g)
833{
834 auto op = static_cast<provsql_arith_op>(gc.getInfos(g).first);
835 if (op != PROVSQL_ARITH_TIMES) return false;
836 const auto &wires = gc.getWires(g);
837 if (wires.size() != 2) return false;
838
839 /* Identify the value side and the rv side. */
840 double c = NaN;
841 gate_t rv_side = INVALID_GATE;
842 if (gc.getGateType(wires[0]) == gate_value
843 && gc.getGateType(wires[1]) == gate_rv) {
844 try { c = parseDoubleStrict(gc.getExtra(wires[0])); }
845 catch (const CircuitException &) { return false; }
846 rv_side = wires[1];
847 } else if (gc.getGateType(wires[1]) == gate_value
848 && gc.getGateType(wires[0]) == gate_rv) {
849 try { c = parseDoubleStrict(gc.getExtra(wires[1])); }
850 catch (const CircuitException &) { return false; }
851 rv_side = wires[0];
852 } else {
853 return false;
854 }
855
856 /* c=0 / c=1 are the identity-drop's job; bailing here keeps the
857 * two rules' responsibilities disjoint. */
858 if (c == 0.0 || c == 1.0) return false;
859
860 if (is_shared_base_rv(rv_side)) return false; /* shared: keep live */
861
862 auto spec = parse_distribution_spec(gc.getExtra(rv_side));
863 if (!spec) return false;
864
865 auto scaled = makeDistribution(*spec)->scale(c);
866 if (!scaled) return false;
867
868 /* Defensive: a zero-σ normal collapses to a Dirac. σ=0 normals
869 * are normally constructed via @c as_random by @c provsql.normal,
870 * but if one slipped through (e.g. a future closure produced
871 * σ=0 from the linear combination), route it through value. */
872 if (auto dirac = scaled->asDirac()) {
873 replace_with_value(gc, g, *dirac);
874 return true;
875 }
876
877 gc.resolveToRv(g, scaled->serialise());
878 return true;
879}
880
881/**
882 * @brief PLUS coefficient aggregation: collapse same-base-RV terms
883 * in a sum.
884 *
885 * For a @c PLUS gate whose every wire decomposes via
886 * @c decompose_linear_term to <tt>a·Z + b</tt>, sums the coefficients
887 * per @c rv_gate UUID and accumulates all the constant offsets into a
888 * single @c b_total. Rebuilds the wire list as one @c TIMES per
889 * surviving RV (or a bare RV wire when its coefficient is exactly @c 1)
890 * plus a single @c value wire for @c b_total when non-zero.
891 *
892 * Fires when at least one of the following holds:
893 * - some @c rv_gate appears in more than one wire (the X+X case);
894 * - more than one constant wire is present (consolidates them).
895 *
896 * Without these triggers the rebuild would be a no-op or worse
897 * (minting fresh @c TIMES wrappers identical in shape to existing
898 * input wires), so the rule bails to keep the simplifier idempotent.
899 *
900 * Unlike @c try_sum_closure / @c try_times_scalar_rv, this rule is
901 * @b safe under shared base-RV identity: the rebuild preserves every
902 * @c rv_gate as a wire (wrapped in @c TIMES when its coefficient is
903 * non-unit), so any other path that referenced @c Z continues to see
904 * the same gate. The subsequent fold of <tt>arith(TIMES, value:a, Z)</tt>
905 * by @c try_times_scalar_rv inherits the same coupling caveat as the
906 * family sum closure (see its docstring).
907 *
908 * Returns @c true if @p g was mutated.
909 */
910bool try_plus_aggregate(GenericCircuit &gc, gate_t g,
911 bool include_scalar_fold)
912{
913 auto op = static_cast<provsql_arith_op>(gc.getInfos(g).first);
914 if (op != PROVSQL_ARITH_PLUS) return false;
915 const auto &wires_in = gc.getWires(g);
916 if (wires_in.size() < 2) return false;
917
918 std::vector<LinearTerm> terms;
919 terms.reserve(wires_in.size());
920 for (gate_t w : wires_in) {
921 auto t = decompose_linear_term(gc, w);
922 if (!t) return false;
923 terms.push_back(*t);
924 }
925
926 /* Aggregate per rv_gate. A vector preserves insertion order so the
927 * rebuilt wire list is deterministic across runs; the per-PLUS
928 * arity is small enough that O(n²) lookup is fine. */
929 std::vector<std::pair<gate_t, double>> coeffs;
930 double b_total = 0.0;
931 unsigned constants_in = 0;
932 for (const auto &t : terms) {
933 b_total += t.b;
934 if (is_invalid(t.rv_gate)) {
935 ++constants_in;
936 continue;
937 }
938 bool found = false;
939 for (auto &p : coeffs) {
940 if (p.first == t.rv_gate) {
941 p.second += t.a;
942 found = true;
943 break;
944 }
945 }
946 if (!found) coeffs.emplace_back(t.rv_gate, t.a);
947 }
948
949 /* Fire only when there's actual consolidation to do. Without a
950 * duplicate RV (or multiple constants) the rebuild would mint
951 * shape-equivalent TIMES wrappers for input wires like
952 * arith(TIMES, value:a, Z), oscillating the gate vector. */
953 const bool has_duplicate = (coeffs.size() < terms.size() - constants_in);
954 const bool many_constants = (constants_in >= 2);
955 if (!has_duplicate && !many_constants) return false;
956
957 /* Drop zero-coefficient RVs (X + (-X) survivors). */
958 std::vector<std::pair<gate_t, double>> kept;
959 kept.reserve(coeffs.size());
960 for (const auto &p : coeffs) {
961 if (p.second != 0.0) kept.push_back(p);
962 }
963
964 /* All RVs canceled: fold g to a value gate carrying b_total. */
965 if (kept.empty()) {
966 replace_with_value(gc, g, b_total);
967 return true;
968 }
969
970 /* Single surviving RV term with no constant offset. Rewrite g
971 * directly in place as the simplest representation:
972 * - a == 1 ⇒ singleton PLUS([Z]) (we can't safely dissolve to Z
973 * in place because that would mint a fresh RV identity at g).
974 * - a != 1 ⇒ in-place op-change from PLUS to TIMES with wires
975 * [value:a, Z]. When @p include_scalar_fold is set the fixed-point
976 * loop then re-enters apply_rules on g (now a TIMES), giving
977 * try_times_scalar_rv a chance to fold the scaled RV. Pass 1
978 * runs with @p include_scalar_fold = false (deferring the fold so
979 * the outer aggregator sees @c c·X-shaped children with intact
980 * RV identity); pass 2 then folds the surviving TIMES wrapper.
981 * Either way, the in-place op-change avoids the PLUS([TIMES(..)])
982 * double wrapper that would otherwise hide the bare-RV shape from
983 * @c AnalyticEvaluator's @c bareRv lookup. */
984 if (kept.size() == 1 && b_total == 0.0) {
985 const auto &only = kept.front();
986 if (only.second == 1.0) {
987 gc.setWires(g, {only.first});
988 } else {
989 const gate_t cv = gc.addAnonymousValueGate(
990 double_to_text(only.second));
991 gc.setInfos(g, static_cast<unsigned>(PROVSQL_ARITH_TIMES), 0);
992 gc.setWires(g, {cv, only.first});
993 }
994 return true;
995 }
996
997 /* General case: rebuild g as a multi-wire PLUS. */
998 std::vector<gate_t> new_wires;
999 new_wires.reserve(kept.size() + 1);
1000 for (const auto &p : kept) {
1001 if (p.second == 1.0) {
1002 new_wires.push_back(p.first);
1003 } else {
1004 const gate_t cv = gc.addAnonymousValueGate(double_to_text(p.second));
1006 {cv, p.first});
1007 new_wires.push_back(tm);
1008 }
1009 }
1010 if (b_total != 0.0) {
1011 new_wires.push_back(gc.addAnonymousValueGate(double_to_text(b_total)));
1012 }
1013
1014 gc.setWires(g, std::move(new_wires));
1015
1016 /* Recurse into freshly-minted TIMES children so try_times_scalar_rv
1017 * gets a chance to fold them within the same bottom-up pass when
1018 * @p include_scalar_fold is set. Same pattern as try_mixture_lift. */
1019 for (gate_t w : gc.getWires(g)) {
1020 if (gc.getGateType(w) == gate_arith) {
1021 apply_rules(gc, w, include_scalar_fold);
1022 }
1023 }
1024 return true;
1025}
1026
1027/**
1028 * @brief Run the per-gate fixed-point loop.
1029 *
1030 * After each rule succeeds the gate is re-evaluated under every rule,
1031 * so a single bottom-up pass collapses nested foldable structures
1032 * (e.g. <tt>arith(NEG, arith(PLUS, value, value))</tt>) in one go.
1033 *
1034 * @return Number of rewrites performed on this gate.
1035 */
1036unsigned apply_rules(GenericCircuit &gc, gate_t g,
1037 bool include_scalar_fold)
1038{
1039 unsigned local = 0;
1040 /* Iteration bound: each rule strictly shrinks the gate (fewer wires
1041 * or simpler type), so the loop terminates in O(#initial wires)
1042 * iterations. The bound is defensive insurance against an
1043 * unintended infinite loop. */
1044 for (unsigned iter = 0; iter < 32; ++iter) {
1045 if (gc.getGateType(g) != gate_arith) break;
1046
1047 /* 1. Constant folding (collapses any all-gate_value arith). */
1048 {
1049 double c = try_eval_constant(gc, g);
1050 if (!std::isnan(c)) {
1051 replace_with_value(gc, g, c);
1052 ++local;
1053 break;
1054 }
1055 }
1056
1057 /* 1b. MINUS-to-PLUS canonicalisation. Rewrites
1058 * @c arith(MINUS, A, B) as @c arith(PLUS, A, arith(NEG, B))
1059 * so every downstream rule -- PLUS aggregation, family
1060 * closures, mixture-lift -- only needs to handle PLUS.
1061 * @c decompose_linear_term already recognises @c NEG as a
1062 * coefficient @c -1, so the rewritten parent's
1063 * @c decompose_linear_term yields the same linear-term shape
1064 * as the original MINUS would have, modulo one extra
1065 * gate_arith level for the NEG. Runs after constant fold so
1066 * a fully-constant @c MINUS(value, value) collapses to a
1067 * @c value gate without minting an interim NEG. */
1068 {
1069 auto op = static_cast<provsql_arith_op>(gc.getInfos(g).first);
1070 if (op == PROVSQL_ARITH_MINUS) {
1071 const auto &wires_in = gc.getWires(g);
1072 if (wires_in.size() == 2) {
1073 const gate_t a = wires_in[0];
1074 const gate_t b = wires_in[1];
1076 {b});
1077 gc.setInfos(g, static_cast<unsigned>(PROVSQL_ARITH_PLUS), 0);
1078 gc.setWires(g, {a, neg_b});
1079 ++local;
1080 continue;
1081 }
1082 }
1083 }
1084
1085 /* 1c. DIV-by-constant to TIMES-by-reciprocal canonicalisation.
1086 * Rewrites @c arith(DIV, X, value:c) as
1087 * @c arith(TIMES, X, value:1/c) (c != 0) so the existing
1088 * scalar-times-RV closure (@c try_times_scalar_rv) and every
1089 * other downstream TIMES rule fold @c X/c uniformly with
1090 * @c c*X. DIV-by-non-constant is left alone (no closure to
1091 * apply); fully-constant @c DIV(value, value) is handled by
1092 * the constant fold above so we never see @c c=0 here.
1093 * Aggregate divisions (an @c X bearing a @c gate_agg) are left
1094 * intact: their HAVING possible-worlds enumeration applies the
1095 * correct integer-floor / real division on the original DIV,
1096 * which a TIMES-by-reciprocal would silently discard. */
1097 {
1098 auto op = static_cast<provsql_arith_op>(gc.getInfos(g).first);
1099 if (op == PROVSQL_ARITH_DIV) {
1100 const auto &wires_in = gc.getWires(g);
1101 if (wires_in.size() == 2 && !subtree_contains_agg(gc, wires_in[0])) {
1102 const double c = try_eval_constant(gc, wires_in[1]);
1103 if (!std::isnan(c) && c != 0.0) {
1104 const gate_t x = wires_in[0];
1105 const gate_t inv = gc.addAnonymousValueGate(
1106 double_to_text(1.0 / c));
1107 gc.setInfos(g, static_cast<unsigned>(PROVSQL_ARITH_TIMES), 0);
1108 gc.setWires(g, {x, inv});
1109 ++local;
1110 continue;
1111 }
1112 }
1113 }
1114 }
1115
1116 /* 2. Identity / absorber drops on PLUS and TIMES. */
1117 if (try_identity_drop(gc, g)) {
1118 ++local;
1119 continue;
1120 }
1121
1122 /* 3. Mixture lift: push PLUS / TIMES inside a single mixture
1123 * child. Runs BEFORE the normal / erlang closures so the
1124 * branch arith children get to try those closures themselves
1125 * after the lift. Once the lift fires the parent is no
1126 * longer gate_arith, so the loop terminates on the next
1127 * iteration via the gate_arith guard above. */
1128 if (try_mixture_lift(gc, g, include_scalar_fold)) {
1129 ++local;
1130 break;
1131 }
1132
1133 auto op = static_cast<provsql_arith_op>(gc.getInfos(g).first);
1134
1135 /* 4. PLUS coefficient aggregation: collapse X+X, X-X, multiple
1136 * constants, etc. Runs BEFORE the family closures so they see
1137 * a sum with distinct RV identities (which they assume), and
1138 * so X+X folds through the scalar-times-RV closure on the
1139 * minted 2*X child. */
1140 if (op == PROVSQL_ARITH_PLUS) {
1141 if (try_plus_aggregate(gc, g, include_scalar_fold)) {
1142 ++local;
1143 continue;
1144 }
1145 }
1146
1147 /* 5. Scalar-times-RV closure on TIMES: c · gate_rv folds to a
1148 * closed-form-scaled gate_rv for the supported families. Gated
1149 * by @p include_scalar_fold: the bottom-up DFS visits children
1150 * before parents, and folding @c c·X to a fresh @c gate_rv at
1151 * the TIMES gate would lose @c X's identity, which an outer
1152 * @c PLUS-aggregation sibling like @c x in @c 2·x+x relies on
1153 * to recognise the shared base RV. Pass 1 runs all other rules
1154 * so the aggregator gets first crack at @c c·X-shaped wires;
1155 * pass 2 then folds the remaining TIMES gates with this rule
1156 * via @c runHybridSimplifier's post-pass. */
1157 if (op == PROVSQL_ARITH_TIMES && include_scalar_fold) {
1158 if (try_times_scalar_rv(gc, g)) {
1159 ++local;
1160 break;
1161 }
1162 }
1163
1164 /* 6. Family closures, dispatched on the families present through
1165 * the closure registries:
1166 * - PLUS: normal linear combinations, same-rate Exp/Erlang
1167 * chains, single-Uniform affine shapes;
1168 * - TIMES: lognormal products (parameters add in log space);
1169 * - LN / EXP: the normal <-> lognormal transform bridges. */
1170 if (op == PROVSQL_ARITH_PLUS) {
1171 if (try_sum_closure(gc, g)) { ++local; break; }
1172 }
1173 if (op == PROVSQL_ARITH_TIMES) {
1174 if (try_product_closure(gc, g)) { ++local; break; }
1175 }
1176 if (op == PROVSQL_ARITH_LN || op == PROVSQL_ARITH_EXP) {
1177 if (try_transform_closure(gc, g)) { ++local; break; }
1178 }
1179
1180 break; /* no rule fired this iteration */
1181 }
1182 return local;
1183}
1184
1185/**
1186 * @brief Post-order DFS that simplifies every reachable gate.
1187 *
1188 * Children are simplified before parents so by the time a gate is
1189 * examined its wires already reflect any rewrites: the bottom-up
1190 * order is essential for cascading folds (a parent PLUS over a child
1191 * arith that just folded to a gate_value gets a chance to fold that
1192 * constant away).
1193 */
1194void simplify(GenericCircuit &gc, gate_t g,
1195 std::unordered_set<gate_t> &done, unsigned &counter,
1196 bool include_scalar_fold)
1197{
1198 /* Iterative DFS with an explicit stack: the natural recursive form
1199 * blew the host stack on deeply-nested arith chains in early
1200 * experiments; iteration with a small per-node bookkeeping triple
1201 * (gate, child-cursor, processed-flag) keeps the cost in heap. */
1202 std::stack<std::pair<gate_t, std::size_t>> stk;
1203 if (!done.insert(g).second) return;
1204 stk.emplace(g, 0);
1205
1206 while (!stk.empty()) {
1207 auto &frame = stk.top();
1208 gate_t cur = frame.first;
1209 const auto &wires = gc.getWires(cur);
1210 if (frame.second < wires.size()) {
1211 gate_t child = wires[frame.second++];
1212 if (done.insert(child).second) stk.emplace(child, 0);
1213 continue;
1214 }
1215 /* All children processed; apply rules to cur. */
1216 if (gc.getGateType(cur) == gate_arith)
1217 counter += apply_rules(gc, cur, include_scalar_fold);
1218 stk.pop();
1219 }
1220}
1221
1222} // namespace
1223
1225{
1226 unsigned counter = 0;
1227 /* Walk every gate in order: @c try_eval_constant recurses through
1228 * @c gate_arith children itself (via @c try_eval_constant's own
1229 * recursion on @c gate_arith ops + base case at @c gate_value),
1230 * so a single linear pass over the gate indices is sufficient.
1231 * No DFS bookkeeping needed because the rewrite produces a
1232 * @c gate_value (terminal), never another @c gate_arith. */
1233 const auto nb = gc.getNbGates();
1234 for (std::size_t i = 0; i < nb; ++i) {
1235 auto g = static_cast<gate_t>(i);
1236 if (gc.getGateType(g) != gate_arith) continue;
1237 double c = try_eval_constant(gc, g);
1238 if (!std::isnan(c)) {
1239 replace_with_value(gc, g, c);
1240 ++counter;
1241 }
1242 }
1243 return counter;
1244}
1245
1247{
1248 unsigned counter = 0;
1249 const auto nb = gc.getNbGates();
1250 for (std::size_t i = 0; i < nb; ++i) {
1251 auto g = static_cast<gate_t>(i);
1252 if (gc.getGateType(g) != gate_mixture) continue;
1253 /* Categorical N-wire mixtures carry their masses in the mulinput
1254 * wires, not a single Bernoulli selector; the degenerate collapse
1255 * below is the classic 3-wire Bernoulli shape only. */
1256 if (gc.isCategoricalMixture(g)) continue;
1257 const auto &wires = gc.getWires(g);
1258 if (wires.size() != 3) continue;
1259
1260 /* The mixing weight pi = P(selector = true). We fold only when pi
1261 * is known to be exactly 1 or 0 without a probability computation:
1262 * a resolved identity selector (gate_one / gate_zero) or a bare
1263 * Bernoulli gate_input whose pinned probability is 1 or 0. In the
1264 * loaded circuit an un-set_prob'd input carries the default
1265 * probability 1 (GenericCircuit::setGate), which is exactly the
1266 * "provenance-tracked but not probabilistic" tuple: certainly
1267 * present. A compound Boolean selector is left intact -- its pi
1268 * would require a (possibly #P-hard) probability evaluation and
1269 * depends on mutable input probabilities, so it belongs to the
1270 * probability-aware evaluators, not this universal load-time pass. */
1271 const gate_t sel = wires[0];
1272 double pi;
1273 switch (gc.getGateType(sel)) {
1274 case gate_one: pi = 1.0; break;
1275 case gate_zero: pi = 0.0; break;
1276 case gate_input: pi = gc.getProb(sel); break;
1277 default: continue;
1278 }
1279
1280 /* A degenerate Bernoulli carries no coupling: collapsing the
1281 * mixture to its surviving arm is exact even when the selector is
1282 * shared with other mixtures (they too are deterministic). This is
1283 * unsound at any fractional pi, which is why the switch above admits
1284 * only the exact 0/1 cases. liftConditionedToTarget rewrites g as a
1285 * single-wire arith PLUS REFERENCING the survivor, so a shared
1286 * survivor RV keeps its single gate identity (and single MC draw);
1287 * foldSemiringIdentities then collapses the passthrough wrapper, and
1288 * a constant survivor lets runConstantFold reduce the enclosing sum
1289 * (e.g. avg's provenance-weighted count) to a gate_value. */
1290 if (pi == 1.0) {
1291 gc.liftConditionedToTarget(g, wires[1]);
1292 ++counter;
1293 } else if (pi == 0.0) {
1294 gc.liftConditionedToTarget(g, wires[2]);
1295 ++counter;
1296 }
1297 }
1298 return counter;
1299}
1300
1302{
1303 unsigned counter = 0;
1304
1305 /* Base RVs that couple two or more sibling subtrees must not be folded
1306 * into a fresh (independent) identity. Census once over the loaded
1307 * circuit. A leaf is coupling-critical when it is reachable (through
1308 * arith / latent-parameter edges) from two or more sibling contexts that
1309 * are combined NON-additively -- i.e. either
1310 * (A) two or more wires of a gate_cmp [the two sides of a cmp], or
1311 * (B) two or more arms of a non-additive gate_arith combinator
1312 * (MIN / MAX / PERCENTILE order statistics, MINUS / TIMES / DIV /
1313 * POW), e.g. the shared T0 in min(T0+T1, T0+T2).
1314 * Folding each such sibling arm into a fresh gate_rv would orphan the
1315 * shared leaf and silently apply the independence approximation to a
1316 * draw the order statistic / product / comparison must see jointly.
1317 * PLUS is deliberately excluded from (B): a leaf repeated across addends
1318 * (x + 2x) is consolidated by try_plus_aggregate while preserving its
1319 * identity, and try_sum_closure already bails on a within-sum repeat, so
1320 * an additive sum never decouples a shared leaf. Folds only ever remove
1321 * references to a leaf, so the load-time census is a sound conservative
1322 * guard for the whole run. The identity-minting folds consult
1323 * @c g_shared_base_rvs via @c is_shared_base_rv. */
1324 std::unordered_set<gate_t> shared_base_rvs;
1325 {
1326 const auto nb = gc.getNbGates();
1327 /* (A) sharing across the sides of a comparator. */
1328 std::unordered_map<gate_t, unsigned> cmp_footprint_count;
1329 for (std::size_t i = 0; i < nb; ++i) {
1330 auto g = static_cast<gate_t>(i);
1331 if (gc.getGateType(g) != gate_cmp) continue;
1332 for (gate_t w : gc.getWires(g)) {
1333 std::unordered_set<gate_t> fp;
1334 collect_reachable_base_rvs(gc, w, fp);
1335 for (gate_t rv : fp) ++cmp_footprint_count[rv];
1336 }
1337 }
1338 for (const auto &[rv, n] : cmp_footprint_count)
1339 if (n > 1) shared_base_rvs.insert(rv);
1340 /* (B) sharing across the arms of a non-additive arith combinator. */
1341 for (std::size_t i = 0; i < nb; ++i) {
1342 auto g = static_cast<gate_t>(i);
1343 if (gc.getGateType(g) != gate_arith) continue;
1344 if (static_cast<provsql_arith_op>(gc.getInfos(g).first) == PROVSQL_ARITH_PLUS)
1345 continue;
1346 const auto &arms = gc.getWires(g);
1347 if (arms.size() < 2) continue;
1348 std::unordered_map<gate_t, unsigned> arm_count;
1349 for (gate_t arm : arms) {
1350 std::unordered_set<gate_t> fp;
1351 collect_reachable_base_rvs(gc, arm, fp);
1352 for (gate_t rv : fp) ++arm_count[rv];
1353 }
1354 for (const auto &[rv, n] : arm_count)
1355 if (n > 1) shared_base_rvs.insert(rv);
1356 }
1357 }
1358 g_shared_base_rvs = &shared_base_rvs;
1359 struct SharedGuard {
1360 ~SharedGuard() { g_shared_base_rvs = nullptr; }
1361 } shared_guard;
1362
1363 /* Pass 1: bottom-up DFS applying every rule EXCEPT the scalar-times-RV
1364 * fold. Deferring that one rule lets @c try_plus_aggregate see
1365 * @c arith(TIMES, value:c, X) shapes inside a parent PLUS -- the
1366 * decomposer recognises them as @c c·X with @c rv_gate=X, so a
1367 * sibling @c x in @c 2·x + x correctly aggregates to coefficient
1368 * three on the shared base RV. If the scalar fold had fired bottom-up
1369 * on the inner TIMES first it would have minted a fresh @c gate_rv
1370 * there, decoupling its identity from the sibling @c x and forcing
1371 * the outer sum-closure path which assumes independence. */
1372 {
1373 std::unordered_set<gate_t> done;
1374 const auto nb = gc.getNbGates();
1375 for (std::size_t i = 0; i < nb; ++i) {
1376 simplify(gc, static_cast<gate_t>(i), done, counter,
1377 /*include_scalar_fold=*/false);
1378 }
1379 }
1380
1381 /* Pass 2: scalar-times-RV fold and NEG-of-RV fold on every
1382 * remaining @c gate_arith. Pass 1's aggregator and family closures
1383 * have already consumed the shapes where these folds would have
1384 * lost shared-RV identity; any surviving 2-wire
1385 * <tt>arith(TIMES, value:c, gate_rv)</tt> or 1-wire
1386 * <tt>arith(NEG, gate_rv)</tt> is now either standalone (no sibling
1387 * to couple with) or the leftover wrapper from a single-RV
1388 * aggregation result. No DFS is needed -- the rules are local and
1389 * idempotent, and walking the gate range with the post-pass-1
1390 * @c getNbGates() picks up the freshly minted wrappers from
1391 * @c try_plus_aggregate, @c try_mixture_lift, and the
1392 * MINUS-to-PLUS canonicalisation. */
1393 {
1394 const auto nb = gc.getNbGates();
1395 for (std::size_t i = 0; i < nb; ++i) {
1396 auto g = static_cast<gate_t>(i);
1397 if (gc.getGateType(g) == gate_arith) {
1398 if (try_times_scalar_rv(gc, g)) ++counter;
1399 else if (try_neg_rv(gc, g)) ++counter;
1400 }
1401 }
1402 }
1403
1404 return counter;
1405}
1406
1407namespace {
1408
1409/**
1410 * @brief Test whether both sides of @p cmp_gate are a continuous-only
1411 * island (subtree of @c gate_value / @c gate_rv / @c gate_arith).
1412 *
1413 * A continuous island has no Boolean / aggregate / IO gates underneath
1414 * the cmp; the only outward edge is the cmp itself. This is the
1415 * shape monteCarloRV's @c evalScalar can integrate over, so per-cmp
1416 * MC marginalisation is sound on these and these alone.
1417 */
1418bool is_continuous_island_cmp(const GenericCircuit &gc, gate_t cmp_gate)
1419{
1420 const auto &wires = gc.getWires(cmp_gate);
1421 if (wires.size() != 2) return false;
1422
1423 std::unordered_set<gate_t> seen;
1424 std::stack<gate_t> stk;
1425 stk.push(wires[0]);
1426 stk.push(wires[1]);
1427 while (!stk.empty()) {
1428 gate_t g = stk.top(); stk.pop();
1429 if (!seen.insert(g).second) continue;
1430 auto t = gc.getGateType(g);
1431 if (t == gate_value || t == gate_rv || t == gate_arith) {
1432 for (gate_t c : gc.getWires(g)) stk.push(c);
1433 continue;
1434 }
1435 if (t == gate_mixture) {
1436 /* Categorical-form mixture (from @c provsql.categorical): a
1437 * discrete scalar leaf with no continuous identities below.
1438 * Treat it as a black-box scalar leaf and don't descend. */
1439 if (gc.isCategoricalMixture(g)) continue;
1440 /* Classic 3-wire mixture: first wire is a gate_input Bernoulli;
1441 * the rest of the island walker would reject it as
1442 * non-continuous, but the Monte-Carlo sampler handles it
1443 * correctly via per-iteration coupling. Treat the mixture as
1444 * a black-box scalar leaf in the island shape: do NOT descend
1445 * into wires[0], only into the scalar branches wires[1] /
1446 * wires[2]. */
1447 const auto &mw = gc.getWires(g);
1448 if (mw.size() != 3) return false;
1449 stk.push(mw[1]);
1450 stk.push(mw[2]);
1451 continue;
1452 }
1453 return false;
1454 }
1455 return true;
1456}
1457
1458/**
1459 * @brief Collect the base @c gate_rv leaves reachable from @p root
1460 * through @c gate_arith composition.
1461 *
1462 * The set is the cmp's "RV footprint": two cmps share an island iff
1463 * their footprints overlap (a shared base RV is the only way their
1464 * sampled values can be correlated, given the island shape).
1465 */
1466void collect_cmp_rv_footprint(const GenericCircuit &gc, gate_t cmp_gate,
1467 std::unordered_set<gate_t> &fp)
1468{
1469 std::unordered_set<gate_t> seen;
1470 std::stack<gate_t> stk;
1471 for (gate_t w : gc.getWires(cmp_gate)) stk.push(w);
1472 while (!stk.empty()) {
1473 gate_t g = stk.top(); stk.pop();
1474 if (!seen.insert(g).second) continue;
1475 auto t = gc.getGateType(g);
1476 if (t == gate_rv) {
1477 fp.insert(g);
1478 /* A compound/latent RV carries its distribution parameters as
1479 * wires (e.g. normal($0, 1) with $0 a shared scalar leaf). Two
1480 * comparators over RVs that share a latent parameter are
1481 * correlated through it, so descend into the parameter subtrees
1482 * and collect their base RVs as well. A bare RV has no wires and
1483 * this is a no-op. */
1484 for (gate_t c : gc.getWires(g)) stk.push(c);
1485 continue;
1486 }
1487 if (t == gate_arith) {
1488 for (gate_t c : gc.getWires(g)) stk.push(c);
1489 continue;
1490 }
1491 if (t == gate_mixture) {
1492 /* Categorical-form mixture (from @c provsql.categorical):
1493 * discrete leaves, no continuous identities below. Stop. */
1494 if (gc.isCategoricalMixture(g)) continue;
1495 /* Classic 3-wire mixture: descend into the scalar branches but
1496 * NOT into the Bernoulli (wires[0] is a gate_input, not a
1497 * continuous RV identity). Two cmps that share a mixture's
1498 * continuous RVs still need to be grouped together; sharing the
1499 * Bernoulli alone does too, but that coupling is captured at
1500 * the sampler level rather than here -- the joint-table sampler
1501 * hits both cmps in the same MC iteration and the shared
1502 * bool_cache_ produces coherent draws. */
1503 const auto &mw = gc.getWires(g);
1504 if (mw.size() == 3) { stk.push(mw[1]); stk.push(mw[2]); }
1505 continue;
1506 }
1507 /* gate_value contributes no RV identity; other types should not
1508 * appear here (is_continuous_island_cmp gates that path), but if
1509 * they did we'd simply ignore them in the footprint &ndash; the
1510 * decomposer's safety relies on the island-shape pre-check, not
1511 * on this routine. */
1512 }
1513}
1514
1515/**
1516 * @brief Collect the classic 3-wire @c gate_mixture selector wires
1517 * (the Bernoulli @c wires[0]) reachable from @p cmp_gate through
1518 * @c gate_arith / @c gate_mixture composition.
1519 *
1520 * Mirrors @c collect_cmp_rv_footprint's walk but records each mixture's
1521 * Boolean SELECTOR rather than its continuous base RVs, descending into
1522 * the value arms so nested mixtures contribute their selectors too. A
1523 * mixture's selector is a latent Boolean whose sampled value the mixture
1524 * consumes; if it is shared with the rest of the circuit (another
1525 * mixture, or an external Boolean use such as conditioning), the cmp's
1526 * truth value is coupled to those sites. Marginalising the cmp into an
1527 * independent Bernoulli would then decorrelate the selector from its
1528 * other uses &ndash; a semantics change &ndash; so the decomposer must
1529 * leave such a cmp for the whole-circuit MC sampler (which couples the
1530 * selector across all its uses via the per-iteration @c bool_cache_).
1531 * Categorical-form mixtures carry no single Bernoulli selector and are
1532 * skipped. */
1533void collect_cmp_mixture_selectors(const GenericCircuit &gc, gate_t cmp_gate,
1534 std::unordered_set<gate_t> &sels)
1535{
1536 std::unordered_set<gate_t> seen;
1537 std::stack<gate_t> stk;
1538 for (gate_t w : gc.getWires(cmp_gate)) stk.push(w);
1539 while (!stk.empty()) {
1540 gate_t g = stk.top(); stk.pop();
1541 if (!seen.insert(g).second) continue;
1542 auto t = gc.getGateType(g);
1543 if (t == gate_arith) {
1544 for (gate_t c : gc.getWires(g)) stk.push(c);
1545 continue;
1546 }
1547 if (t == gate_mixture) {
1548 if (gc.isCategoricalMixture(g)) continue;
1549 const auto &mw = gc.getWires(g);
1550 if (mw.size() == 3) {
1551 sels.insert(mw[0]);
1552 stk.push(mw[1]);
1553 stk.push(mw[2]);
1554 }
1555 continue;
1556 }
1557 /* gate_rv / gate_value: no selector below. */
1558 }
1559}
1560
1561} // namespace
1562
1563namespace {
1564
1565/* Joint-table cap. 2^k mulinput leaves are materialised per group;
1566 * 256 cells is more than ample for HAVING/WHERE workloads while
1567 * keeping the in-memory footprint and the per-cell MC variance
1568 * (samples / 2^k counts per cell) bounded. Groups exceeding the
1569 * cap fall through to whole-circuit MC by leaving their cmps as
1570 * gate_cmp; the dispatch in probability_evaluate then routes
1571 * through monteCarloRV. */
1572constexpr std::size_t JOINT_TABLE_K_MAX = 8;
1573
1574/**
1575 * @brief Test whether @c AnalyticEvaluator would resolve @p cmp_gate
1576 * analytically on its own.
1577 *
1578 * The decomposer now runs before @c AnalyticEvaluator (so shared
1579 * bare-RV cmps reach the grouping logic and the fast path's
1580 * analytical CDF can fire), but it must leave isolated bare-RV cmps
1581 * untouched: marginalising those via MC would waste samples on a
1582 * case the closed-form CDF handles exactly. Mirror the shape match
1583 * in @c tryAnalyticDecide (bare RV vs gate_value either way around;
1584 * two bare normal RVs).
1585 */
1586bool is_analytic_singleton_cmp(const GenericCircuit &gc, gate_t cmp_gate)
1587{
1588 const auto &wires = gc.getWires(cmp_gate);
1589 if (wires.size() != 2) return false;
1590 auto t0 = gc.getGateType(wires[0]);
1591 auto t1 = gc.getGateType(wires[1]);
1592
1593 /* X cmp c / c cmp X: AnalyticEvaluator resolves any supported
1594 * distribution kind via the closed-form CDF. */
1595 if ((t0 == gate_rv && t1 == gate_value) ||
1596 (t0 == gate_value && t1 == gate_rv))
1597 return true;
1598
1599 /* Categorical-form mixture cmp constant: AnalyticEvaluator's
1600 * @c categoricalDecide computes the exact mass sum over the
1601 * mulinputs satisfying the predicate, so the decomposer should not
1602 * pre-empt with per-cmp MC. Also picks up the
1603 * @c try_categorical_mixture_lift output (a constant scaled / offset
1604 * categorical), keeping the analytical path end-to-end for
1605 * <tt>c · X cmp k</tt> shapes over categorical RVs. */
1606 if ((gc.isCategoricalMixture(wires[0]) && t1 == gate_value) ||
1607 (gc.isCategoricalMixture(wires[1]) && t0 == gate_value))
1608 return true;
1609
1610 /* X cmp Y, two distinct bare RVs: AnalyticEvaluator's @c rvVsRvDecide
1611 * decides it -- a same-family closed form (Normal-Normal, Exp-Exp,
1612 * Uniform-Uniform) or the mixed-family 1-D quadrature. Two distinct
1613 * bare-RV leaves are independent, so leaving them for that path is exact
1614 * (or high-accuracy), never a per-cmp MC. */
1615 if (t0 == gate_rv && t1 == gate_rv) {
1616 auto sx = parse_distribution_spec(gc.getExtra(wires[0]));
1617 auto sy = parse_distribution_spec(gc.getExtra(wires[1]));
1618 if (sx && sy)
1619 return true;
1620 }
1621 return false;
1622}
1623
1624/**
1625 * @brief Information needed by @c inline_fast_path: the shared scalar
1626 * plus, for each cmp, the comparison operator and the
1627 * constant rhs threshold (after flipping for cmps shaped
1628 * @c c @c op @c X).
1629 */
1630struct FastPathInfo {
1631 gate_t scalar;
1632 std::vector<ComparisonOperator> ops; /* one per cmp, oriented as `scalar op c` */
1633 std::vector<double> thresholds; /* one per cmp */
1634};
1635
1637{
1638 switch (op) {
1645 }
1646 return op;
1647}
1648
1649bool apply_cmp(double l, ComparisonOperator op, double r)
1650{
1651 switch (op) {
1652 case ComparisonOperator::LT: return l < r;
1653 case ComparisonOperator::LE: return l <= r;
1654 case ComparisonOperator::EQ: return l == r;
1655 case ComparisonOperator::NE: return l != r;
1656 case ComparisonOperator::GE: return l >= r;
1657 case ComparisonOperator::GT: return l > r;
1658 }
1659 return false;
1660}
1661
1662/**
1663 * @brief Detect the monotone-shared-scalar fast path on a group of
1664 * comparators.
1665 *
1666 * Fires when every cmp in @p cmps has one side equal to a single
1667 * shared gate_t @c s and the other side a @c gate_value: the k cmps
1668 * then jointly partition the @c s-line into at most k+1 intervals,
1669 * with each interval producing a deterministic k-bit outcome. This
1670 * shape is common in HAVING / WHERE with multiple thresholds on the
1671 * same aggregate / column: e.g.
1672 * <tt>count(*) > 10 OR count(*) < 5</tt>.
1673 *
1674 * Returns @c std::nullopt when any cmp has both non-constant sides,
1675 * when the cmps don't all share the same @c s gate_t, when a
1676 * comparator OID is unrecognised, or when @c EQ / @c NE appears (the
1677 * interval representation can't express a measure-zero point).
1678 */
1679std::optional<FastPathInfo>
1680detect_shared_scalar(const GenericCircuit &gc,
1681 const std::vector<gate_t> &cmps)
1682{
1683 FastPathInfo info;
1684 info.ops.reserve(cmps.size());
1685 info.thresholds.reserve(cmps.size());
1686 bool first = true;
1687
1688 for (gate_t c : cmps) {
1689 const auto &wires = gc.getWires(c);
1690 if (wires.size() != 2) return std::nullopt;
1691
1692 bool ok = false;
1693 ComparisonOperator op = cmpOpFromOid(gc.getInfos(c).first, ok);
1694 if (!ok) return std::nullopt;
1695 /* EQ / NE on continuous RVs have measure zero / one and were
1696 * already resolved by RangeCheck; if we still see one we don't
1697 * know how to fit it into an interval partition. Bail. */
1699 return std::nullopt;
1700
1701 gate_t scalar_side = static_cast<gate_t>(-1);
1702 double threshold = std::numeric_limits<double>::quiet_NaN();
1703 ComparisonOperator effective_op = op;
1704 if (gc.getGateType(wires[1]) == gate_value) {
1705 scalar_side = wires[0];
1706 try { threshold = parseDoubleStrict(gc.getExtra(wires[1])); }
1707 catch (const CircuitException &) { return std::nullopt; }
1708 } else if (gc.getGateType(wires[0]) == gate_value) {
1709 scalar_side = wires[1];
1710 try { threshold = parseDoubleStrict(gc.getExtra(wires[0])); }
1711 catch (const CircuitException &) { return std::nullopt; }
1712 effective_op = flip_cmp_op(op);
1713 } else {
1714 return std::nullopt;
1715 }
1716
1717 if (first) {
1718 info.scalar = scalar_side;
1719 first = false;
1720 } else if (info.scalar != scalar_side) {
1721 return std::nullopt;
1722 }
1723 info.ops.push_back(effective_op);
1724 info.thresholds.push_back(threshold);
1725 }
1726 return info;
1727}
1728
1729/**
1730 * @brief Inline a fast-path joint table for a monotone-shared-scalar
1731 * group.
1732 *
1733 * The k cmps partition the scalar line into at most k+1 intervals
1734 * (one per pair of consecutive sorted distinct thresholds plus the
1735 * two infinite tails). Each interval gets a single mulinput with
1736 * probability equal to the scalar's mass on the interval; the
1737 * comparator outcomes are deterministic per interval (evaluated at
1738 * a strictly-interior representative point) and the k cmps are
1739 * rewritten as @c gate_plus over the mulinputs whose interval makes
1740 * them true.
1741 *
1742 * Interval probabilities are computed analytically via @c cdfAt when
1743 * the scalar is a bare @c gate_rv with a CDF the helper supports;
1744 * otherwise (a @c gate_arith composite, or an Erlang with
1745 * non-integer shape) we fall back to MC by sampling the scalar
1746 * @p samples times and binning into intervals.
1747 *
1748 * Returns @c true when the group was resolved. When the analytical
1749 * CDF is unavailable and @p allow_mc is false (@c rv_mc_samples = 0),
1750 * returns @c false without touching the circuit: the caller then
1751 * raises rather than letting each cmp collapse to an independent
1752 * marginal (which would silently return the product of the marginals
1753 * for correlated events).
1754 */
1755bool inline_fast_path(GenericCircuit &gc,
1756 const std::vector<gate_t> &cmps,
1757 const FastPathInfo &info,
1758 unsigned samples,
1759 bool allow_mc)
1760{
1761 /* Sort + dedup thresholds; the resulting m distinct boundaries
1762 * partition R into m+1 open intervals
1763 * (-∞, t_0), (t_0, t_1), ..., (t_{m-1}, +∞). */
1764 std::vector<double> ts = info.thresholds;
1765 std::sort(ts.begin(), ts.end());
1766 ts.erase(std::unique(ts.begin(), ts.end()), ts.end());
1767 const std::size_t m = ts.size();
1768 const std::size_t nb_intervals = m + 1;
1769
1770 /* Compute interval probabilities. Try the analytical CDF first:
1771 * when the shared scalar is a bare @c gate_rv with a CDF
1772 * @c cdfAt understands, the interval probability is
1773 * @c F(t_{i+1}) - F(t_i) exactly &ndash; no MC noise, no sampling.
1774 * This is the headline benefit of the fast path: shared bare-RV
1775 * groups land on the exact dependent truth and the resulting
1776 * Bernoulli probabilities propagate through tree-decomposition /
1777 * compilation without any sampling noise contributed by the
1778 * decomposer. Fall back to MC binning over @p samples scalar
1779 * draws when the scalar is a @c gate_arith composite (no CDF) or
1780 * when @c cdfAt returns NaN on a boundary (Erlang with
1781 * non-integer shape, etc.). */
1782 std::vector<double> interval_probs(nb_intervals, 0.0);
1783 bool analytical = false;
1784 if (gc.getGateType(info.scalar) == gate_rv) {
1785 auto spec = parse_distribution_spec(gc.getExtra(info.scalar));
1786 if (spec) {
1787 std::vector<double> cdf_at_boundary(m);
1788 bool all_ok = true;
1789 for (std::size_t i = 0; i < m; ++i) {
1790 cdf_at_boundary[i] = cdfAt(*spec, ts[i]);
1791 if (std::isnan(cdf_at_boundary[i])) { all_ok = false; break; }
1792 }
1793 if (all_ok) {
1794 interval_probs[0] = cdf_at_boundary[0];
1795 for (std::size_t i = 1; i < m; ++i)
1796 interval_probs[i] = cdf_at_boundary[i] - cdf_at_boundary[i - 1];
1797 interval_probs[m] = 1.0 - cdf_at_boundary[m - 1];
1798 analytical = true;
1799 }
1800 }
1801 }
1802 if (!analytical) {
1803 /* No closed-form CDF for the shared scalar (a gate_arith composite,
1804 * or a non-integer-shape Erlang): the joint needs MC binning. With
1805 * MC disabled we cannot resolve it correctly -- decline so the caller
1806 * raises, rather than leaving the cmps for an independent per-cmp
1807 * collapse that would silently return the product of the marginals. */
1808 if (!allow_mc) return false;
1809 auto draws = monteCarloScalarSamples(gc, info.scalar, samples);
1810 for (double s : draws) {
1811 auto it = std::upper_bound(ts.begin(), ts.end(), s);
1812 std::size_t idx = static_cast<std::size_t>(it - ts.begin());
1813 ++interval_probs[idx];
1814 }
1815 for (auto &p : interval_probs) p /= samples;
1816 }
1817
1818 /* For each interval, determine the k-bit cmp outcome word. Pick
1819 * a representative point strictly inside the interval: the
1820 * midpoint for finite intervals, t_0 - 1 / t_{m-1} + 1 for the
1821 * infinite tails. Continuous distributions assign zero mass to
1822 * the boundaries, so the choice of interior point doesn't
1823 * affect any cmp's outcome on the open interval. */
1824 std::vector<unsigned long> outcome_word(nb_intervals, 0);
1825 for (std::size_t i = 0; i < nb_intervals; ++i) {
1826 double point;
1827 if (i == 0) point = ts[0] - 1.0;
1828 else if (i == m) point = ts[m - 1] + 1.0;
1829 else point = 0.5 * (ts[i - 1] + ts[i]);
1830 unsigned long w = 0;
1831 for (std::size_t j = 0; j < info.thresholds.size(); ++j) {
1832 if (apply_cmp(point, info.ops[j], info.thresholds[j]))
1833 w |= (1ul << j);
1834 }
1835 outcome_word[i] = w;
1836 }
1837
1838 /* Allocate key + per-interval mulinputs (skipping zero-prob
1839 * intervals to keep the materialised circuit lean). */
1840 gate_t key = gc.addAnonymousInputGate(1.0);
1841 std::vector<gate_t> mul_for_interval(nb_intervals,
1842 static_cast<gate_t>(-1));
1843 for (std::size_t i = 0; i < nb_intervals; ++i) {
1844 if (interval_probs[i] <= 0.0) continue;
1845 mul_for_interval[i] =
1846 gc.addAnonymousMulinputGate(key, interval_probs[i],
1847 static_cast<unsigned>(i));
1848 }
1849
1850 /* Rewrite each cmp as gate_plus over the mulinputs whose
1851 * interval-outcome word has the cmp's bit set. */
1852 for (std::size_t j = 0; j < cmps.size(); ++j) {
1853 std::vector<gate_t> plus_wires;
1854 plus_wires.reserve(nb_intervals);
1855 for (std::size_t i = 0; i < nb_intervals; ++i) {
1856 if (!(outcome_word[i] & (1ul << j))) continue;
1857 gate_t mw = mul_for_interval[i];
1858 if (mw == static_cast<gate_t>(-1)) continue;
1859 plus_wires.push_back(mw);
1860 }
1861 gc.resolveToPlus(cmps[j], std::move(plus_wires));
1862 }
1863 return true;
1864}
1865
1866/**
1867 * @brief Inline a joint-distribution table over a group of k cmps
1868 * sharing an island.
1869 *
1870 * Materialises 2^k - z mulinput leaves (where z is the number of
1871 * outcomes with empirical probability zero, omitted to keep the
1872 * circuit lean), all sharing a fresh anonymous key gate. Each
1873 * comparator @c cmps[i] is rewritten in place as @c gate_plus over
1874 * the mulinputs whose joint outcome word has bit @c i set; the
1875 * combined probability is the marginal P(cmp_i = 1) and shared bits
1876 * across different cmps reuse the same mulinput leaf so the OR over
1877 * cmps at downstream sites correctly observes the joint distribution
1878 * (mutually exclusive over the joint outcomes).
1879 *
1880 * Sound when the per-iteration sampler memoisation in
1881 * @c monteCarloRV / @c monteCarloJointDistribution gives all k cmps
1882 * a consistent draw of the shared island - which is precisely the
1883 * is_continuous_island_cmp + shared-footprint precondition the
1884 * caller has already enforced.
1885 */
1886void inline_joint_table(GenericCircuit &gc,
1887 const std::vector<gate_t> &cmps,
1888 unsigned samples)
1889{
1890 const unsigned k = static_cast<unsigned>(cmps.size());
1891 auto probs = monteCarloJointDistribution(gc, cmps, samples);
1892
1893 /* Fresh key gate (the anonymous block anchor for these mulinputs).
1894 * Probability 1.0 because the key itself is not a sampled choice;
1895 * the mutually-exclusive outcomes among the mulinputs are what
1896 * carries the joint mass. */
1897 gate_t key = gc.addAnonymousInputGate(1.0);
1898
1899 /* Allocate one mulinput per joint outcome with positive probability.
1900 * Zero-probability outcomes are pruned: the cmp gate_plus
1901 * rewrites below would have included them as wires with prob 0,
1902 * which is a no-op in OR (gate_zero is the additive identity).
1903 * value_index = w gives independentEvaluation's mulin_seen dedup
1904 * a stable key (group, info) per outcome. */
1905 const std::size_t nb_outcomes = std::size_t{1} << k;
1906 std::vector<gate_t> mul_for_outcome(nb_outcomes,
1907 static_cast<gate_t>(-1));
1908 for (std::size_t w = 0; w < nb_outcomes; ++w) {
1909 if (probs[w] <= 0.0) continue;
1910 mul_for_outcome[w] =
1911 gc.addAnonymousMulinputGate(key, probs[w],
1912 static_cast<unsigned>(w));
1913 }
1914
1915 /* Rewrite each cmp as gate_plus over the mulinputs whose joint
1916 * outcome word has the cmp's bit set. */
1917 for (unsigned i = 0; i < k; ++i) {
1918 std::vector<gate_t> plus_wires;
1919 plus_wires.reserve(nb_outcomes / 2);
1920 for (std::size_t w = 0; w < nb_outcomes; ++w) {
1921 if ((w & (std::size_t{1} << i)) == 0) continue;
1922 gate_t m = mul_for_outcome[w];
1923 if (m == static_cast<gate_t>(-1)) continue;
1924 plus_wires.push_back(m);
1925 }
1926 gc.resolveToPlus(cmps[i], std::move(plus_wires));
1927 }
1928}
1929
1930/**
1931 * @brief A group of comparisons all sharing one pivot bare RV X, each against
1932 * an independent bare RV or a constant. Unlike the monotone-shared-
1933 * scalar fast path (all thresholds constant on one line), the other
1934 * operands are themselves random, so the joint of the k comparisons is
1935 * a 2^k table of pivot-conjunction integrals rather than a partition of
1936 * one line into intervals. This is the RV-vs-RV correlated-join island,
1937 * e.g. @c "(x>y) AND (x>z)" or the conditioning @c "(x>y) | (x>z)".
1938 */
1939struct PivotIslandInfo {
1940 DistributionSpec pivotSpec;
1941 struct Factor {
1942 bool isConst;
1943 DistributionSpec other; /* valid iff !isConst */
1944 double konst; /* valid iff isConst */
1945 bool trueIsGreater; /* cmp true <=> X > operand */
1946 };
1947 std::vector<Factor> factors; /* one per cmp, index = bit position */
1948};
1949
1950/* Detect a shared-pivot-RV island: every cmp compares a common pivot bare RV X
1951 * against an independent bare RV (distinct leaf, appearing once) or a constant.
1952 * nullopt otherwise (no common pivot, a shared/repeated other RV, an agg cmp,
1953 * EQ/NE, or a non-bare operand). */
1954std::optional<PivotIslandInfo>
1955detect_shared_pivot_rv(const GenericCircuit &gc,
1956 const std::vector<gate_t> &cmps)
1957{
1958 gate_t pivot = static_cast<gate_t>(-1);
1959 bool havePivot = false;
1960 std::optional<DistributionSpec> pivotSpec;
1961 PivotIslandInfo info;
1962 std::unordered_set<gate_t> othersSeen;
1963
1964 for (gate_t c : cmps) {
1965 if (gc.getGateType(c) != gate_cmp) return std::nullopt;
1966 const auto &w = gc.getWires(c);
1967 if (w.size() != 2) return std::nullopt;
1968 bool ok = false;
1969 ComparisonOperator op = cmpOpFromOid(gc.getInfos(c).first, ok);
1970 if (!ok || op == ComparisonOperator::EQ || op == ComparisonOperator::NE)
1971 return std::nullopt;
1972
1973 /* Find which side is the pivot: a bare gate_rv consistent across cmps. */
1974 gate_t pv, other; bool pivotLeft;
1975 if (gc.getGateType(w[0]) == gate_rv &&
1976 (!havePivot || w[0] == pivot)) { pv = w[0]; other = w[1]; pivotLeft = true; }
1977 else if (gc.getGateType(w[1]) == gate_rv &&
1978 (!havePivot || w[1] == pivot)) { pv = w[1]; other = w[0]; pivotLeft = false; }
1979 else return std::nullopt;
1980
1981 if (!havePivot) {
1982 auto sp = parse_distribution_spec(gc.getExtra(pv));
1983 if (!sp) return std::nullopt;
1984 pivot = pv; pivotSpec = *sp; havePivot = true;
1985 }
1986
1987 const bool greaterOp = (op == ComparisonOperator::GT ||
1989 const bool lessOp = (op == ComparisonOperator::LT ||
1991 const bool trueIsGreater = pivotLeft ? greaterOp : lessOp;
1992
1993 PivotIslandInfo::Factor f;
1994 f.trueIsGreater = trueIsGreater;
1995 if (gc.getGateType(other) == gate_value) {
1996 f.isConst = true;
1997 try { f.konst = parseDoubleStrict(gc.getExtra(other)); }
1998 catch (const CircuitException &) { return std::nullopt; }
1999 } else if (gc.getGateType(other) == gate_rv && other != pivot) {
2000 if (!othersSeen.insert(other).second) return std::nullopt; /* shared -> dependent */
2001 auto sp = parse_distribution_spec(gc.getExtra(other));
2002 if (!sp) return std::nullopt;
2003 f.isConst = false;
2004 f.other = *sp;
2005 } else return std::nullopt;
2006 info.factors.push_back(std::move(f));
2007 }
2008 if (!havePivot) return std::nullopt;
2009 info.pivotSpec = *pivotSpec;
2010 return info;
2011}
2012
2013/* Install an analytic 2^k joint table for a shared-pivot-RV island. Cell
2014 * probability for outcome word w is P(∧_j cmp_j == bit_j(w)) computed as the
2015 * pivot-conjunction integral ∫ f_X(x) Π_j W_j(x) dx, where W_j weights the
2016 * region making cmp_j equal to its bit: an RV factor contributes F_Y(x) or
2017 * 1-F_Y(x), a constant factor clips the integration window. Mirrors
2018 * @c inline_joint_table's circuit surgery (one key, one mulinput per positive
2019 * outcome, each cmp -> gate_plus over the outcomes with its bit set), so the
2020 * downstream Boolean OR/AND observes the correct correlated joint. Returns
2021 * false (touching nothing) if a density/CDF is undefined, so the caller can
2022 * raise or fall back to MC. */
2023bool inline_analytic_pivot_joint_table(GenericCircuit &gc,
2024 const std::vector<gate_t> &cmps,
2025 const PivotIslandInfo &info)
2026{
2027 const unsigned k = static_cast<unsigned>(cmps.size());
2028 const std::size_t nb_outcomes = std::size_t{1} << k;
2029
2030 const auto dX = makeDistribution(info.pivotSpec);
2031 double lo0, hi0;
2032 if (!dX->integrationRange(lo0, hi0)) return false;
2033
2034 /* Construct each RV factor's distribution once. */
2035 std::vector<std::unique_ptr<Distribution>> otherDist(k);
2036 for (unsigned j = 0; j < k; ++j)
2037 if (!info.factors[j].isConst)
2038 otherDist[j] = makeDistribution(info.factors[j].other);
2039
2040 std::vector<double> probs(nb_outcomes, 0.0);
2041 for (std::size_t w = 0; w < nb_outcomes; ++w) {
2042 /* Constant factors clip the window for this cell. */
2043 double lo = lo0, hi = hi0;
2044 for (unsigned j = 0; j < k; ++j) {
2045 if (!info.factors[j].isConst) continue;
2046 const bool bit = (w >> j) & 1u;
2047 /* "X > c" holds in this cell iff (trueIsGreater == bit). */
2048 const bool greater = (info.factors[j].trueIsGreater == bit);
2049 if (greater) lo = std::max(lo, info.factors[j].konst);
2050 else hi = std::min(hi, info.factors[j].konst);
2051 }
2052 if (!(hi > lo)) { probs[w] = 0.0; continue; }
2053
2054 const double cell = simpsonIntegrate(lo, hi, kSimpsonPanels,
2055 [&](double x) {
2056 const double fX = dX->pdf(x);
2057 if (std::isnan(fX)) return std::numeric_limits<double>::quiet_NaN();
2058 double weight = fX;
2059 for (unsigned j = 0; j < k; ++j) {
2060 if (info.factors[j].isConst) continue;
2061 const bool bit = (w >> j) & 1u;
2062 const bool greater = (info.factors[j].trueIsGreater == bit);
2063 const double FY = otherDist[j]->cdf(x);
2064 if (std::isnan(FY)) return std::numeric_limits<double>::quiet_NaN();
2065 weight *= greater ? FY : (1.0 - FY);
2066 }
2067 return weight;
2068 });
2069 if (std::isnan(cell)) return false;
2070 probs[w] = cell;
2071 }
2072
2073 /* Circuit surgery: one shared key, one mulinput per positive outcome, each
2074 * cmp rewritten as gate_plus over the outcomes whose bit it owns. */
2075 gate_t key = gc.addAnonymousInputGate(1.0);
2076 std::vector<gate_t> mul_for_outcome(nb_outcomes, static_cast<gate_t>(-1));
2077 for (std::size_t w = 0; w < nb_outcomes; ++w) {
2078 if (probs[w] <= 0.0) continue;
2079 mul_for_outcome[w] =
2080 gc.addAnonymousMulinputGate(key, probs[w], static_cast<unsigned>(w));
2081 }
2082 for (unsigned i = 0; i < k; ++i) {
2083 std::vector<gate_t> plus_wires;
2084 for (std::size_t w = 0; w < nb_outcomes; ++w) {
2085 if ((w & (std::size_t{1} << i)) == 0) continue;
2086 gate_t mw = mul_for_outcome[w];
2087 if (mw == static_cast<gate_t>(-1)) continue;
2088 plus_wires.push_back(mw);
2089 }
2090 gc.resolveToPlus(cmps[i], std::move(plus_wires));
2091 }
2092 return true;
2093}
2094
2095} // namespace
2096
2097unsigned runHybridDecomposer(GenericCircuit &gc, unsigned samples)
2098{
2099 /* @c rv_mc_samples = 0 does NOT short-circuit the decomposer: the
2100 * monotone-shared-scalar fast path resolves a group of comparisons
2101 * against constants on one bare @c gate_rv analytically (via the CDF,
2102 * no sampling), which is exactly the correlation-aware joint a
2103 * conditioning like @c "(x >= 2000) | (x >= 1000)" needs. Skipping
2104 * the decomposer here would leave those cmps for @c runAnalyticEvaluator
2105 * to collapse one at a time, silently returning the product of the
2106 * marginals for correlated events. Only the genuinely MC-bound arms
2107 * (a composite shared scalar, an RV-vs-RV joint table, a non-analytic
2108 * singleton) are gated on @p allow_mc; under @c samples = 0 a correlated
2109 * island with no closed form raises rather than falling back silently. */
2110 const bool allow_mc = (samples > 0);
2111
2112 /* Snapshot all gate_cmp ids that look like continuous islands.
2113 * Each call later mutates a snapshot entry from @c gate_cmp to
2114 * @c gate_input via @c resolveCmpToBernoulli (singleton group)
2115 * or to @c gate_plus via @c resolveToPlus (multi-cmp group), but
2116 * the snapshot vector is unaffected. The defensive type re-check
2117 * at iteration time guards against intervening mutations. */
2118 const auto nb = gc.getNbGates();
2119 std::vector<gate_t> cmps;
2120 for (std::size_t i = 0; i < nb; ++i) {
2121 auto g = static_cast<gate_t>(i);
2122 if (gc.getGateType(g) == gate_cmp && is_continuous_island_cmp(gc, g))
2123 cmps.push_back(g);
2124 }
2125
2126 /* Compute the per-cmp footprint up front so the pairwise-overlap
2127 * check is O(C * C * F) rather than O(C * C * tree_size). */
2128 std::unordered_map<gate_t, std::unordered_set<gate_t>> footprints;
2129 footprints.reserve(cmps.size());
2130 for (gate_t c : cmps) {
2131 collect_cmp_rv_footprint(gc, c, footprints[c]);
2132 }
2133
2134 /* Wire in-degree census over the whole circuit: indeg[g] is the number
2135 * of parents that reference g anywhere. Compared per group against the
2136 * references seen WITHIN the group's own island, it tells whether a
2137 * mixture selector's Boolean sub-DAG is observed outside the group (a
2138 * coupling the island-local marginalisation cannot preserve). */
2139 std::unordered_map<gate_t, unsigned> indeg;
2140 {
2141 for (std::size_t i = 0; i < nb; ++i)
2142 for (gate_t c : gc.getWires(static_cast<gate_t>(i)))
2143 ++indeg[c];
2144 }
2145
2146 /* Group cmps into connected components by base-RV footprint
2147 * overlap (union-find via parent[]). Linear-probe path
2148 * compression keeps the asymptotics near-linear in the number of
2149 * pairwise overlap checks. */
2150 std::vector<std::size_t> parent(cmps.size());
2151 for (std::size_t i = 0; i < cmps.size(); ++i) parent[i] = i;
2152 auto find = [&](std::size_t x) {
2153 while (parent[x] != x) {
2154 parent[x] = parent[parent[x]];
2155 x = parent[x];
2156 }
2157 return x;
2158 };
2159 auto unite = [&](std::size_t a, std::size_t b) {
2160 a = find(a); b = find(b);
2161 if (a != b) parent[a] = b;
2162 };
2163 for (std::size_t i = 0; i < cmps.size(); ++i) {
2164 for (std::size_t j = i + 1; j < cmps.size(); ++j) {
2165 if (find(i) == find(j)) continue;
2166 const auto &fp_i = footprints[cmps[i]];
2167 const auto &fp_j = footprints[cmps[j]];
2168 const auto &small = fp_i.size() < fp_j.size() ? fp_i : fp_j;
2169 const auto &big = fp_i.size() < fp_j.size() ? fp_j : fp_i;
2170 for (gate_t rv : small) {
2171 if (big.count(rv)) { unite(i, j); break; }
2172 }
2173 }
2174 }
2175
2176 /* Collect cmps by component root. */
2177 std::unordered_map<std::size_t, std::vector<gate_t>> groups;
2178 for (std::size_t i = 0; i < cmps.size(); ++i)
2179 groups[find(i)].push_back(cmps[i]);
2180
2181 unsigned resolved = 0;
2182 for (auto &[root, group] : groups) {
2183 (void) root;
2184 /* Defensive: re-check every cmp is still gate_cmp. Nothing in
2185 * the pipeline should have mutated them since the snapshot, but
2186 * the check is cheap. */
2187 bool all_pristine = true;
2188 for (gate_t c : group) {
2189 if (gc.getGateType(c) != gate_cmp) { all_pristine = false; break; }
2190 }
2191 if (!all_pristine) continue;
2192
2193 /* Semantics guard: an island-local resolution (a singleton marginal
2194 * Bernoulli, or a 2^k joint table over the group) replaces the cmps
2195 * with leaves that are independent of everything OUTSIDE this group.
2196 * That is sound only when the group's island is self-contained. The
2197 * channel the footprint grouping does not cover is a Bernoulli
2198 * MIXTURE selector: if a selector's Boolean sub-DAG is also observed
2199 * outside this island (another group's mixture, or an external
2200 * Boolean such as conditioning on the selector), marginalising here
2201 * would decorrelate it from those uses -- a semantics change. A
2202 * selector shared only WITHIN the group is fine: the same MC draw
2203 * that marginalises the group couples it internally. When a group
2204 * is not self-contained, leave all its cmps as raw gate_cmp for the
2205 * whole-circuit MC sampler, which couples every selector across all
2206 * its uses via the per-iteration bool_cache_. */
2207 {
2208 /* Island of this group: every gate reachable from its cmps, with a
2209 * count of how many references each gate receives from within it. */
2210 std::unordered_set<gate_t> island;
2211 std::unordered_map<gate_t, unsigned> island_ref;
2212 {
2213 std::stack<gate_t> stk;
2214 for (gate_t c : group) stk.push(c);
2215 while (!stk.empty()) {
2216 gate_t g = stk.top(); stk.pop();
2217 if (!island.insert(g).second) continue;
2218 for (gate_t w : gc.getWires(g)) { ++island_ref[w]; stk.push(w); }
2219 }
2220 }
2221 /* A selector couples the group to the outside iff some gate in its
2222 * sub-DAG has a parent that is not part of this island (its global
2223 * in-degree exceeds the references seen within the island). */
2224 auto sub_dag_escapes_island = [&](gate_t s) {
2225 std::unordered_set<gate_t> seen;
2226 std::stack<gate_t> st; st.push(s);
2227 while (!st.empty()) {
2228 gate_t g = st.top(); st.pop();
2229 if (!seen.insert(g).second) continue;
2230 unsigned inside = island_ref.count(g) ? island_ref[g] : 0;
2231 unsigned total = indeg.count(g) ? indeg[g] : 0;
2232 if (total > inside) return true;
2233 for (gate_t w : gc.getWires(g)) st.push(w);
2234 }
2235 return false;
2236 };
2237 std::unordered_set<gate_t> sels;
2238 for (gate_t c : group) collect_cmp_mixture_selectors(gc, c, sels);
2239 bool group_couples_selector = false;
2240 for (gate_t s : sels)
2241 if (sub_dag_escapes_island(s)) { group_couples_selector = true; break; }
2242 if (group_couples_selector) continue;
2243 }
2244
2245 if (group.size() == 1) {
2246 /* Singleton island. If AnalyticEvaluator would resolve this
2247 * cmp exactly on its own (bare gate_rv vs gate_value, or two
2248 * bare normals), leave it untouched and let the closed-form
2249 * pass below handle it - no point burning MC samples on a
2250 * case with an analytical answer. Otherwise MC-marginalise
2251 * into a Bernoulli leaf here. */
2252 if (is_analytic_singleton_cmp(gc, group[0])) continue;
2253 /* A non-analytic singleton needs MC. With MC disabled leave the
2254 * cmp for the downstream "undecidable + rv_mc_samples = 0" raise
2255 * (a singleton has no cross-cmp correlation to lose). */
2256 if (!allow_mc) continue;
2257 double p = monteCarloRV(gc, group[0], samples);
2258 gc.resolveCmpToBernoulli(group[0], p);
2259 ++resolved;
2260 continue;
2261 }
2262
2263 /* Multi-cmp shared island. Try the monotone-shared-scalar fast
2264 * path first: when every cmp has shape `s op c` for a common
2265 * scalar gate_t s, the joint table is built from k+1 intervals
2266 * (analytical when s is a bare gate_rv with a known CDF, MC
2267 * binning otherwise) instead of 2^k cells, and the test
2268 * 14-style shared bare-RV case (`X > 0 OR X > 1`) lands on the
2269 * exact answer with no MC noise. When detection fails, fall
2270 * through to the generic 2^k MC joint table iff k is small
2271 * enough; larger groups keep their cmps as gate_cmp and fall
2272 * through to whole-circuit MC. */
2273 if (auto info = detect_shared_scalar(gc, group)) {
2274 if (inline_fast_path(gc, group, *info, samples, allow_mc)) {
2275 resolved += static_cast<unsigned>(group.size());
2276 continue;
2277 }
2278 /* Shared scalar with no closed-form CDF and MC disabled: raise
2279 * rather than let the cmps collapse to independent marginals. */
2280 throw CircuitException(
2281 "the joint probability of correlated comparison events over a "
2282 "composite quantity needs Monte Carlo, but provsql.rv_mc_samples "
2283 "= 0 disables it; set provsql.rv_mc_samples > 0 (comparisons "
2284 "against constants on a single distribution stay analytical)");
2285 }
2286
2287 /* Shared-pivot-RV island (e.g. `x>y AND x>z`, or the conditioning
2288 * `(x>y)|(x>z)`): the k comparisons share one pivot bare RV X against
2289 * independent operands, so the 2^k joint is a table of pivot-conjunction
2290 * integrals -- exact (quadrature), no MC. Preferred even when MC is
2291 * available (no sampling noise); resolves the `rv_mc_samples = 0` case
2292 * that would otherwise raise below. */
2293 if (auto pinfo = detect_shared_pivot_rv(gc, group)) {
2294 if (inline_analytic_pivot_joint_table(gc, group, *pinfo)) {
2295 resolved += static_cast<unsigned>(group.size());
2296 continue;
2297 }
2298 /* Integration declined (undefined density/CDF): fall through to MC or
2299 * the raise below. */
2300 }
2301
2302 /* Generic joint island (e.g. RV-vs-RV comparisons sharing a leaf):
2303 * only the 2^k MC joint table can evaluate it correctly. With MC
2304 * disabled, raise rather than leave the cmps for an independent
2305 * per-cmp collapse that silently returns the product of marginals. */
2306 if (!allow_mc)
2307 throw CircuitException(
2308 "the joint probability of correlated comparison events needs "
2309 "Monte Carlo, but provsql.rv_mc_samples = 0 disables it; set "
2310 "provsql.rv_mc_samples > 0 (comparisons against constants on a "
2311 "single distribution stay analytical)");
2312
2313 if (group.size() > JOINT_TABLE_K_MAX) continue;
2314
2315 inline_joint_table(gc, group, samples);
2316 resolved += static_cast<unsigned>(group.size());
2317 }
2318
2319 return resolved;
2320}
2321
2322} // 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
Per-family polymorphic view over a continuous gate_rv distribution (§F.1 class hierarchy).
Analytical expectation / variance / moment evaluator over RV circuits.
Peephole simplifier for continuous gate_arith sub-circuits.
Monte Carlo sampling over a GenericCircuit, RV-aware.
Shared 1-D quadrature core for the pivot-conjunction and order-statistic closed forms.
Continuous random-variable helpers (distribution parsing, moments).
Exception type thrown by circuit operations on invalid input.
Definition Circuit.h:206
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.
void liftConditionedToTarget(gate_t g, gate_t target)
Replace a gate_conditioned g by a transparent passthrough to its target child (a single-child gate_ar...
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.
unsigned runConstantFold(GenericCircuit &gc)
Constant-fold pass over every gate_arith in gc.
std::unique_ptr< Distribution > closeTransform(const char *transform, const Distribution &x)
The image distribution of transform applied to x, when a registered rule covers x's family; nullptr o...
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.
std::unique_ptr< Distribution > makeDistribution(const DistributionSpec &spec)
Construct the per-family Distribution for a parsed spec.
std::unique_ptr< Distribution > closePlusTerms(const std::vector< ClosureTerm > &terms)
Fold PLUS(terms) into a single distribution when a registered closure covers every family in the sum.
double simpsonIntegrate(double lo, double hi, int N, F &&f)
Composite-Simpson with N panels.
unsigned runHybridSimplifier(GenericCircuit &gc)
Run the peephole simplifier over gc.
std::unique_ptr< Distribution > closeProductFactors(const std::vector< const Distribution * > &factors)
Fold a product of independent factors into a single distribution when a registered closure covers eve...
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.
constexpr int kSimpsonPanels
Panel count shared by every composite-Simpson quadrature over a distribution's integration range: exa...
unsigned foldDegenerateMixtures(GenericCircuit &gc)
Collapse degenerate Bernoulli gate_mixture gates whose selector is certainly true (pi = 1) or certain...
double cdfAt(const DistributionSpec &d, double c)
Closed-form CDF for a basic continuous distribution.
std::string double_to_text(double v)
Format a double back into the canonical text form used by gate_value extras and gate_rv 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_PERCENTILE
continuous percentile (order-statistic aggregate): wires are interleaved [ind_1, x_1,...
@ PROVSQL_ARITH_DIV
binary, child0 / child1
@ PROVSQL_ARITH_LN
unary, natural logarithm of child0 (a negative draw raises at evaluation)
@ PROVSQL_ARITH_PLUS
n-ary, sum of children
@ PROVSQL_ARITH_POW
binary, child0 ^ child1 (real branch only: a negative base drawn with a non-integer exponent raises a...
@ PROVSQL_ARITH_NEG
unary, -child0
@ PROVSQL_ARITH_MINUS
binary, child0 - child1
@ PROVSQL_ARITH_EXP
unary, e^child0
@ PROVSQL_ARITH_TIMES
n-ary, product of children
@ PROVSQL_ARITH_MIN
n-ary, min of children (order statistic; least / min aggregate)
@ PROVSQL_ARITH_MAX
n-ary, max of children (order statistic; greatest / max aggregate)
@ 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)