ProvSQL C/C++ API
Adding support for provenance and uncertainty management to PostgreSQL databases
Loading...
Searching...
No Matches
RangeCheck.cpp
Go to the documentation of this file.
1/**
2 * @file RangeCheck.cpp
3 * @brief Implementation of the support-based bound check pass.
4 * See @c RangeCheck.h for the full docstring.
5 */
6#include "RangeCheck.h"
7
8#include <algorithm>
9#include <cmath>
10#include <limits>
11#include <stack>
12#include <unordered_map>
13#include <unordered_set>
14#include <vector>
15
16#include "Aggregation.h" // ComparisonOperator + cmpOpFromOid
17#include "AnalyticEvaluator.h" // cdfAt for shape_mass under truncation
18#include "CircuitFromMMap.h" // getGenericCircuit
19#include "ConjugatePosterior.h" // conjugatePosterior (observe-evidence shapes)
20#include "Expectation.h" // lift_conditioning
21#include "RandomVariable.h" // parse_distribution_spec
22#include "distributions/Distribution.h" // makeDistribution -> per-family support()
23#include "provsql_utils_cpp.h" // uuid2string
24
25#include <type_traits> // std::is_same_v in truncateShape
26#include <variant>
27extern "C" {
28#include "postgres.h"
29#include "fmgr.h"
30#include "funcapi.h" // get_call_result_type, BlessTupleDesc
31#include "access/htup_details.h" // heap_form_tuple (PG 10 declares it here;
32 // funcapi.h pulls it in transitively from
33 // PG 11 onwards, but not on 10)
34#include "utils/uuid.h"
35#include "provsql_utils.h" // gate_type, provsql_arith_op
36#include "provsql_error.h"
37
38PG_FUNCTION_INFO_V1(rv_support);
39}
40
41namespace provsql {
42
43namespace {
44
45/**
46 * @brief Closed interval @c [lo, hi] on the extended real line.
47 *
48 * @c -INFINITY / @c +INFINITY are used for unbounded ends (e.g. the
49 * support of a normal RV is @c {-INF, +INF}). Empty intervals are
50 * not generated by any constructor below; comparators against an
51 * empty interval would be vacuous and we consider them undecidable.
52 */
53struct Interval {
54 double lo;
55 double hi;
56
57 static Interval point(double v) { return {v, v}; }
58 static Interval all() { return {-std::numeric_limits<double>::infinity(),
59 +std::numeric_limits<double>::infinity()}; }
60 bool isAll() const {
61 return std::isinf(lo) && lo < 0 && std::isinf(hi) && hi > 0;
62 }
63};
64
65Interval add(Interval a, Interval b) { return {a.lo + b.lo, a.hi + b.hi}; }
66Interval sub(Interval a, Interval b) { return {a.lo - b.hi, a.hi - b.lo}; }
67Interval neg(Interval a) { return {-a.hi, -a.lo}; }
68
69/* Interval product: take the min/max of the four corner products.
70 * Handles signed bounds correctly (no special case for negative). */
71Interval mul(Interval a, Interval b)
72{
73 double p1 = a.lo * b.lo, p2 = a.lo * b.hi;
74 double p3 = a.hi * b.lo, p4 = a.hi * b.hi;
75 return {std::min({p1, p2, p3, p4}), std::max({p1, p2, p3, p4})};
76}
77
78/* Interval division: if the divisor straddles zero, the result is
79 * unbounded in both directions; otherwise compute via @c mul(a, 1/b).
80 * The conservative all-real fallback is correct (any real value is
81 * possible) but throws away precision &ndash; division by an interval
82 * crossing zero is rare in our tests. */
83/* exp is monotone increasing and total (exp(-inf) = 0, exp(inf) = inf). */
84Interval expInt(Interval a) { return {std::exp(a.lo), std::exp(a.hi)}; }
85
86/* ln is monotone increasing on [0, inf); the part of an interval below
87 * 0 is a domain violation the sampler raises on, so the bound covers
88 * the draws that do evaluate (lo <= 0 maps to -inf via ln 0). */
89Interval lnInt(Interval a)
90{
91 const double lo = a.lo > 0.0 ? std::log(a.lo)
92 : -std::numeric_limits<double>::infinity();
93 const double hi = a.hi > 0.0 ? std::log(a.hi)
94 : -std::numeric_limits<double>::infinity();
95 return {lo, hi};
96}
97
98/* x^y over the interval box. For a base interval entirely >= 0, x^y is
99 * monotone in each variable separately (in x for fixed y, in y for
100 * fixed x), so the extrema sit at the corners; 0^negative diverges to
101 * +inf, which std::pow reports directly. A base interval extending
102 * below 0 keeps the conservative all-real bound: integer-exponent draws
103 * are legitimate there, and non-integer ones raise in the sampler. */
104Interval powInt(Interval b, Interval e)
105{
106 if (!(b.lo >= 0.0))
107 return Interval::all();
108 double lo = std::numeric_limits<double>::infinity();
109 double hi = -std::numeric_limits<double>::infinity();
110 for (double x : {b.lo, b.hi})
111 for (double y : {e.lo, e.hi}) {
112 const double v = std::pow(x, y);
113 if (std::isnan(v))
114 return Interval::all();
115 lo = std::min(lo, v);
116 hi = std::max(hi, v);
117 }
118 return {lo, hi};
119}
120
121Interval divInt(Interval a, Interval b)
122{
123 if (b.lo <= 0.0 && b.hi >= 0.0)
124 return Interval::all();
125 Interval inv = {1.0 / b.hi, 1.0 / b.lo};
126 return mul(a, inv);
127}
128
129/**
130 * @brief Recursively compute the interval of @p g's value across
131 * worlds. Memoised in @p cache.
132 *
133 * Recognised gate types:
134 * - @c gate_value: point interval on the parsed scalar.
135 * - @c gate_rv: distribution support (uniform exact, exponential
136 * on @c [0, +∞), normal on @c (-∞, +∞)).
137 * - @c gate_arith: propagated via the interval-arith helpers above.
138 *
139 * Anything else (e.g. an aggregate gate reached via a HAVING cmp)
140 * yields the all-real interval, which downstream conservatively
141 * treats as undecidable.
142 */
143Interval intervalOf(const GenericCircuit &gc, gate_t g,
144 std::unordered_map<gate_t, Interval> &cache)
145{
146 auto it = cache.find(g);
147 if (it != cache.end()) return it->second;
148
149 Interval result = Interval::all();
150 auto type = gc.getGateType(g);
151
152 switch (type) {
153 case gate_value:
154 /* A value RangeCheck cannot read as a double (e.g. a text constant
155 * from an agg_token = text comparison) carries no numeric interval
156 * constraint: leave it unconstrained rather than aborting the whole
157 * load-time pass. Mirrors the "undecidable -> all()" default. */
158 try {
159 result = Interval::point(parseDoubleStrict(gc.getExtra(g)));
160 } catch (const CircuitException &) {
161 result = Interval::all();
162 }
163 break;
164 case gate_rv: {
165 auto spec = parse_distribution_spec(gc.getExtra(g));
166 if (!spec) break;
167 // Natural support per family (Normal ℝ, Uniform [a,b], Exp/Erlang [0,∞)).
168 const DistSupport s = makeDistribution(*spec)->support();
169 result = {s.lo, s.hi};
170 break;
171 }
172 case gate_arith: {
173 auto op = static_cast<provsql_arith_op>(gc.getInfos(g).first);
174 const auto &wires = gc.getWires(g);
175 if (wires.empty()) break;
176 Interval first = intervalOf(gc, wires[0], cache);
177 switch (op) {
179 result = first;
180 for (std::size_t i = 1; i < wires.size(); ++i)
181 result = add(result, intervalOf(gc, wires[i], cache));
182 break;
184 result = first;
185 for (std::size_t i = 1; i < wires.size(); ++i)
186 result = mul(result, intervalOf(gc, wires[i], cache));
187 break;
189 if (wires.size() != 2) break;
190 result = sub(first, intervalOf(gc, wires[1], cache));
191 break;
193 if (wires.size() != 2) break;
194 result = divInt(first, intervalOf(gc, wires[1], cache));
195 break;
197 if (wires.size() != 1) break;
198 result = neg(first);
199 break;
201 /* max/min are monotone in each argument, so the support propagates
202 * directly and soundly (independent of any correlation between the
203 * children): [max(lo_i), max(hi_i)] resp. [min(lo_i), min(hi_i)]. */
204 result = first;
205 for (std::size_t i = 1; i < wires.size(); ++i) {
206 Interval o = intervalOf(gc, wires[i], cache);
207 result = { std::max(result.lo, o.lo), std::max(result.hi, o.hi) };
208 }
209 break;
211 result = first;
212 for (std::size_t i = 1; i < wires.size(); ++i) {
213 Interval o = intervalOf(gc, wires[i], cache);
214 result = { std::min(result.lo, o.lo), std::min(result.hi, o.hi) };
215 }
216 break;
218 if (wires.size() != 2) break;
219 result = powInt(first, intervalOf(gc, wires[1], cache));
220 break;
221 case PROVSQL_ARITH_LN:
222 if (wires.size() != 1) break;
223 result = lnInt(first);
224 break;
226 if (wires.size() != 1) break;
227 result = expInt(first);
228 break;
230 /* Continuous percentile over interleaved [ind, x, ...] wires:
231 * every draw interpolates within the present values, so the hull
232 * of the value wires' supports is a sound bound regardless of
233 * which subset is present. */
234 if (wires.size() < 2 || wires.size() % 2 != 0) break;
235 result = intervalOf(gc, wires[1], cache);
236 for (std::size_t i = 3; i < wires.size(); i += 2) {
237 Interval o = intervalOf(gc, wires[i], cache);
238 result = { std::min(result.lo, o.lo), std::max(result.hi, o.hi) };
239 }
240 break;
241 }
242 break;
243 }
244 case gate_semimod: {
245 /* HAVING-style constant wrapper: semimod(gate_one, value). The
246 * semiring action of gate_one (always true) on a scalar leaves
247 * the scalar unchanged in every world, so the interval of the
248 * semimod equals the interval of its value child. Other
249 * semimod shapes (non-trivial k_gate) keep the conservative
250 * all-real default here; @c support_intervalOf widens it to
251 * a sampling-time support for rv_* callers. */
252 const auto &wires = gc.getWires(g);
253 if (wires.size() == 2 && gc.getGateType(wires[0]) == gate_one)
254 result = intervalOf(gc, wires[1], cache);
255 break;
256 }
257 case gate_mixture: {
258 /* Support of a mixture is the union of its branch supports.
259 * Two shapes:
260 * - Classic 3-wire [p_token, x_token, y_token]: the Bernoulli
261 * is a Boolean leaf and contributes nothing to the scalar
262 * interval.
263 * - Categorical N-wire [key, mul_1, ..., mul_n]: each mulinput
264 * carries its outcome value in extra; the support is the
265 * [min, max] of those values. */
266 const auto &wires = gc.getWires(g);
267 if (gc.isCategoricalMixture(g)) {
268 double lo = std::numeric_limits<double>::infinity();
269 double hi = -std::numeric_limits<double>::infinity();
270 bool any = false;
271 for (std::size_t i = 1; i < wires.size(); ++i) {
272 double v;
273 try { v = parseDoubleStrict(gc.getExtra(wires[i])); }
274 catch (const CircuitException &) { any = false; break; }
275 lo = std::min(lo, v);
276 hi = std::max(hi, v);
277 any = true;
278 }
279 if (any) result = {lo, hi};
280 } else if (wires.size() == 3) {
281 Interval ix = intervalOf(gc, wires[1], cache);
282 Interval iy = intervalOf(gc, wires[2], cache);
283 result = {std::min(ix.lo, iy.lo), std::max(ix.hi, iy.hi)};
284 }
285 break;
286 }
287 case gate_case: {
288 /* Guarded selection [g_1, v_1, ..., g_k, v_k, default]: the result is
289 * always one of the value branches, so the support is the union of the
290 * values' supports (the guards only decide which one). Values are the
291 * odd-indexed wires plus the final default. */
292 const auto &wires = gc.getWires(g);
293 if (wires.empty()) break;
294 result = intervalOf(gc, wires.back(), cache); /* default */
295 for (std::size_t i = 1; i + 1 < wires.size(); i += 2) {
296 Interval o = intervalOf(gc, wires[i], cache);
297 result = { std::min(result.lo, o.lo), std::max(result.hi, o.hi) };
298 }
299 break;
300 }
301 default:
302 /* gate_agg is intentionally not handled here -- the empty-subset
303 * NULL semantics make a flat interval misleading, so the
304 * runRangeCheck loop dispatches agg-bearing cmps to a separate
305 * decider that knows the asymmetry between sound FALSE and
306 * unsound TRUE decisions for SUM / MIN / MAX. All other gate
307 * types fall through to the all-real default. */
308 break;
309 }
310
311 cache[g] = result;
312 return result;
313}
314
315/**
316 * @brief Decide a @c gate_cmp from the interval of @c (lhs - rhs).
317 *
318 * Returns @c NaN when the comparator cannot be decided from interval
319 * bounds alone (e.g. the difference straddles zero, or the comparator
320 * is @c = / @c <> on overlapping continuous supports &ndash; both of
321 * which need a CDF, which a downstream analytic pass can supply).
322 * Otherwise returns the certain probability @c 0.0 or @c 1.0.
323 */
324double decideCmp(const Interval &diff, ComparisonOperator op)
325{
326 switch (op) {
328 if (diff.hi < 0.0) return 1.0;
329 if (diff.lo >= 0.0) return 0.0;
330 break;
332 if (diff.hi <= 0.0) return 1.0;
333 if (diff.lo > 0.0) return 0.0;
334 break;
336 if (diff.lo > 0.0) return 1.0;
337 if (diff.hi <= 0.0) return 0.0;
338 break;
340 if (diff.lo >= 0.0) return 1.0;
341 if (diff.hi < 0.0) return 0.0;
342 break;
344 /* Disjoint supports ⇒ certainly false. Overlapping supports of
345 * continuous RVs would have probability zero in the measure-
346 * theoretic sense, but the interval pass alone cannot tell
347 * whether either side is continuous; leave that to a downstream
348 * analytic-CDF pass when one is available. */
349 if (diff.hi < 0.0 || diff.lo > 0.0) return 0.0;
350 break;
352 if (diff.hi < 0.0 || diff.lo > 0.0) return 1.0;
353 break;
354 }
355 return std::numeric_limits<double>::quiet_NaN();
356}
357
358/**
359 * @brief Decide a @c gate_cmp where one side is a @c gate_agg, the
360 * other is a scalar constant.
361 *
362 * Computes a value-interval for the aggregate from its semimod
363 * children's per-row values, then folds the comparator like the
364 * non-agg path &ndash; but accepts only FALSE decisions, never
365 * TRUE. The reason is structural to ProvSQL's HAVING semantics:
366 * the per-aggregator subset enumerators in @c subset.cpp
367 * (@c count_enum, @c sum_dp, @c enumerate_exhaustive) all skip
368 * the empty subset, matching SQL's "no group, no HAVING" rule.
369 * So a HAVING cmp's value is the OR over the @em non-empty subsets
370 * where the predicate holds.
371 *
372 * - When no non-empty subset satisfies the predicate (the bound is
373 * strictly disjoint from the threshold on the right side of the
374 * comparator), the cmp value is exactly @c 0 = @c gate_zero.
375 * FALSE decision: sound.
376 * - When every non-empty subset satisfies the predicate, the cmp
377 * value equals "the group is non-empty" &ndash; the OR over the
378 * children's k_gates &ndash; which is a non-constant Boolean
379 * expression, @em not @c gate_one. Returning TRUE here would
380 * replace the cmp with @c gate_one and over-count probability
381 * mass from the empty world (where the group does not exist),
382 * so TRUE decisions are blocked uniformly across all aggregators.
383 *
384 * Aggregators we don't bound (@c AVG, @c AND, @c OR, @c CHOOSE,
385 * @c ARRAY_AGG, @c NONE) fall through to undecidable.
386 *
387 * @return @c 0.0 if decided to FALSE, @c NaN otherwise.
388 */
389double decideAggVsConstCmp(const GenericCircuit &gc, gate_t agg_gate,
390 ComparisonOperator op, double const_val,
391 bool agg_on_lhs,
392 bool *out_always_true = nullptr)
393{
394 AggregationOperator aop = getAggregationOperator(gc.getInfos(agg_gate).first);
395
396 /* Extract per-child scalar values from the semimod children. */
397 std::vector<double> values;
398 for (gate_t child : gc.getWires(agg_gate)) {
399 if (gc.getGateType(child) != gate_semimod)
400 return std::numeric_limits<double>::quiet_NaN();
401 const auto &sw = gc.getWires(child);
402 if (sw.size() != 2)
403 return std::numeric_limits<double>::quiet_NaN();
404 gate_t value_gate = sw[1];
405 if (gc.getGateType(value_gate) != gate_value)
406 return std::numeric_limits<double>::quiet_NaN();
407 try {
408 values.push_back(parseDoubleStrict(gc.getExtra(value_gate)));
409 } catch (const CircuitException &) {
410 return std::numeric_limits<double>::quiet_NaN();
411 }
412 }
413
414 Interval val_interval = Interval::all();
415
416 switch (aop) {
418 val_interval = {0.0, static_cast<double>(values.size())};
419 break;
421 double sum_neg = 0.0, sum_pos = 0.0;
422 for (double v : values) {
423 if (v < 0.0) sum_neg += v;
424 else sum_pos += v;
425 }
426 val_interval = {std::min(0.0, sum_neg), std::max(0.0, sum_pos)};
427 break;
428 }
431 if (values.empty())
432 return std::numeric_limits<double>::quiet_NaN();
433 val_interval = {*std::min_element(values.begin(), values.end()),
434 *std::max_element(values.begin(), values.end())};
435 break;
436 default:
437 /* AVG / AND / OR / CHOOSE / ARRAY_AGG / NONE: not decidable
438 * with this pass. */
439 return std::numeric_limits<double>::quiet_NaN();
440 }
441
442 Interval lhs = agg_on_lhs ? val_interval : Interval::point(const_val);
443 Interval rhs = agg_on_lhs ? Interval::point(const_val) : val_interval;
444 Interval diff = sub(lhs, rhs);
445 double p = decideCmp(diff, op);
446
447 /* Only FALSE decisions are universally sound (see doc comment).
448 * gate_one would over-credit the empty subset, which provsql_having
449 * deliberately excludes from valid worlds; the safe TRUE rewrite
450 * is "the group is non-empty" = OR over the agg's K-gates, and is
451 * sound only in absorptive semirings. The TRUE signal is therefore
452 * reported via @p out_always_true rather than the return value;
453 * the universal load-time @c runRangeCheck caller ignores it,
454 * while the probability-side @c runHavingAlwaysTrueRewriter caller
455 * acts on it. */
456 if (p == 0.0) return 0.0;
457 if (p == 1.0 && out_always_true != nullptr) *out_always_true = true;
458 return std::numeric_limits<double>::quiet_NaN();
459}
460
461/**
462 * @brief Try to extract a scalar constant from a cmp's child.
463 *
464 * Recognises two shapes:
465 * - bare @c gate_value: parse its @c extra as a double;
466 * - HAVING-style @c gate_semimod with @c k=gate_one and
467 * @c value=gate_value: parse the value's extra.
468 *
469 * Returns @c NaN on any other shape.
470 */
471double extractScalarConst(const GenericCircuit &gc, gate_t g)
472{
473 auto t = gc.getGateType(g);
474 if (t == gate_value) {
475 try { return parseDoubleStrict(gc.getExtra(g)); }
476 catch (const CircuitException &) {
477 return std::numeric_limits<double>::quiet_NaN();
478 }
479 }
480 if (t == gate_semimod) {
481 const auto &w = gc.getWires(g);
482 if (w.size() != 2) return std::numeric_limits<double>::quiet_NaN();
483 if (gc.getGateType(w[0]) != gate_one)
484 return std::numeric_limits<double>::quiet_NaN();
485 if (gc.getGateType(w[1]) != gate_value)
486 return std::numeric_limits<double>::quiet_NaN();
487 try { return parseDoubleStrict(gc.getExtra(w[1])); }
488 catch (const CircuitException &) {
489 return std::numeric_limits<double>::quiet_NaN();
490 }
491 }
492 return std::numeric_limits<double>::quiet_NaN();
493}
494
495/**
496 * @brief Flip the sides of a comparison operator.
497 *
498 * @c (a op b) is equivalent to @c (b flip(op) a). Used to normalise
499 * a cmp so the random-variable side is always on the left.
500 */
502{
503 switch (op) {
510 }
511 return op;
512}
513
514/**
515 * @brief Interpret a @c gate_cmp as a per-RV constraint @c rv op c.
516 *
517 * Returns @c true and fills @p rv_out, @p op_out, @p const_out when
518 * exactly one side of the cmp is a @c gate_rv and the other a
519 * @c gate_value with a parseable scalar; @c false otherwise (both
520 * sides are RVs, both constants, an @c arith subtree appears, etc.).
521 *
522 * Strict-vs-non-strict inequalities are preserved as the operator;
523 * the caller decides whether to treat the boundary as inclusive
524 * (continuous distributions: measure-zero, irrelevant for
525 * feasibility verdicts).
526 */
527bool asRvVsConstCmp(const GenericCircuit &gc, gate_t cmp_gate,
528 gate_t &rv_out, ComparisonOperator &op_out,
529 double &const_out)
530{
531 bool ok = false;
532 ComparisonOperator op = cmpOpFromOid(gc.getInfos(cmp_gate).first, ok);
533 if (!ok) return false;
534 const auto &wires = gc.getWires(cmp_gate);
535 if (wires.size() != 2) return false;
536
537 /* Recognise scalar-vs-constant cmps where the scalar side is a
538 * bare gate_rv (the original use case for the per-cmp resolution
539 * pass) or a gate_mixture (so the conditioning walker can extract
540 * intervals on mixture / categorical variables – value-vs-value
541 * cmps are folded upstream by RangeCheck before they reach this
542 * walker). Dirac (gate_value) is never the scalar side of a
543 * non-trivial cmp at this point; the value-vs-value pair would have
544 * been resolved upstream. */
545 auto isScalarRv = [](gate_type t) {
546 return t == gate_rv || t == gate_mixture;
547 };
548 auto t0 = gc.getGateType(wires[0]);
549 auto t1 = gc.getGateType(wires[1]);
550 if (isScalarRv(t0) && t1 == gate_value) {
551 try { const_out = parseDoubleStrict(gc.getExtra(wires[1])); }
552 catch (const CircuitException &) { return false; }
553 rv_out = wires[0];
554 op_out = op;
555 return true;
556 }
557 if (t0 == gate_value && isScalarRv(t1)) {
558 try { const_out = parseDoubleStrict(gc.getExtra(wires[0])); }
559 catch (const CircuitException &) { return false; }
560 rv_out = wires[1];
561 op_out = flipCmpOp(op);
562 return true;
563 }
564 return false;
565}
566
567/**
568 * @brief Apply a single @c rv-op-constant constraint to a running
569 * interval for the RV.
570 *
571 * Strict vs non-strict inequalities collapse onto the same closed
572 * interval: continuous distributions assign zero mass to the
573 * boundary, so the joint-feasibility verdict is unchanged whether
574 * we use @c < or @c <=. @c <> (NE) cannot be represented as a
575 * single interval and is left to the per-cmp pass.
576 */
577Interval intersectRvConstraint(Interval current, ComparisonOperator op,
578 double c)
579{
580 switch (op) {
583 current.hi = std::min(current.hi, c);
584 break;
587 current.lo = std::max(current.lo, c);
588 break;
590 current.lo = std::max(current.lo, c);
591 current.hi = std::min(current.hi, c);
592 break;
594 /* Cannot represent the complement of a point as a single
595 * interval; leave the running interval unchanged. */
596 break;
597 }
598 return current;
599}
600
601bool intervalEmpty(Interval i) { return i.lo > i.hi; }
602
603/**
604 * @brief Walk an AND-conjunct tree collecting per-RV interval
605 * constraints from its @c gate_cmp leaves.
606 *
607 * Shared between @c isAndJointlyInfeasible (which checks for an empty
608 * intersection) and the public @c collectRvConstraints / conditional
609 * @c compute_support paths. Descends through @c gate_times,
610 * collecting every @c gate_cmp interpretable as `rv op const` and
611 * intersecting its constraint into a running interval for that RV.
612 *
613 * @p complete is set to @c true on entry and cleared if the walk
614 * encounters any structure other than the AND-friendly set
615 * (@c gate_times, @c gate_cmp, @c gate_input, @c gate_one,
616 * @c gate_zero) whose footprint *might* constrain an RV
617 * (i.e. excluding bare Bernoulli factors). Callers that need a
618 * tight bound (the closed-form moment shortcut) must check it; the
619 * support intersection caller can use the result unconditionally
620 * because dropping a disjunctive factor only loosens the interval,
621 * which is sound for a superset bound on the conditional support.
622 *
623 * Cmps that do not interpret as `rv op const` (RV vs RV, arith on
624 * either side, agg…) are silently ignored; they belong to the
625 * conditioning event but don't constrain a single RV's interval.
626 */
627void walkAndConjunctIntervals(
628 const GenericCircuit &gc, gate_t root,
629 std::unordered_map<gate_t, Interval> &rv_intervals,
630 std::unordered_map<gate_t, Interval> &support_cache,
631 bool &complete)
632{
633 std::unordered_set<gate_t> seen;
634 std::stack<gate_t> stk;
635 stk.push(root);
636 complete = true;
637
638 while (!stk.empty()) {
639 gate_t g = stk.top(); stk.pop();
640 if (!seen.insert(g).second) continue;
641
642 auto t = gc.getGateType(g);
643 if (t == gate_cmp) {
644 gate_t rv = static_cast<gate_t>(0);
646 double c = 0.0;
647 if (!asRvVsConstCmp(gc, g, rv, op, c)) {
648 /* Cmp shape we don't interpret (RV vs RV, arith involved).
649 * Conservatively mark the walk incomplete: this cmp belongs
650 * to the event AND could constrain an RV in a way we can't
651 * fold into a single interval. */
652 complete = false;
653 continue;
654 }
655 auto it = rv_intervals.find(rv);
656 Interval current = (it == rv_intervals.end())
657 ? intervalOf(gc, rv, support_cache)
658 : it->second;
659 current = intersectRvConstraint(current, op, c);
660 rv_intervals[rv] = current;
661 continue; /* never descend into a cmp's operands */
662 }
663 if (t == gate_times || t == gate_delta || g == root) {
664 /* gate_delta wraps a single child as the δ-semiring identity on
665 * Booleans, so the AND-conjunct walker is sound to descend
666 * through it -- the wrapper carries no constraint of its own.
667 * Skipping the descent would mark the walk incomplete and force
668 * the moment caller to fall back to MC even when the inner
669 * cmps are decidable closed-form. */
670 for (gate_t c : gc.getWires(g)) stk.push(c);
671 continue;
672 }
673 if (t == gate_input || t == gate_update || t == gate_one ||
674 t == gate_zero) {
675 /* Bernoulli leaf / constants: shift P(event), don't truncate
676 * any continuous RV. Skipping is sound and the walk stays
677 * complete. */
678 continue;
679 }
680 /* gate_plus (OR), gate_monus (set diff), gate_arith, gate_rv, ...:
681 * could affect an RV's conditional distribution in ways that
682 * don't reduce to an interval intersection. Mark the walk
683 * incomplete so a moment closed-form caller falls through to MC. */
684 complete = false;
685 }
686}
687
688/**
689 * @brief Walk @p root's AND-conjunct cmps and decide whether the
690 * conjunction is jointly infeasible by per-RV interval
691 * intersection.
692 *
693 * For every @c gate_cmp reachable through a chain of @c gate_times
694 * starting at @p root, that is interpretable as @c rv-op-constant,
695 * intersect the constraint with the running interval for that RV
696 * (initialised to the RV's distribution support). As soon as any
697 * RV's interval becomes empty, the AND is infeasible.
698 *
699 * Descends only through @c gate_times: @c gate_plus is OR (the
700 * disjuncts could individually be feasible even when each is a
701 * narrow constraint on the RV, so they do not contribute to the
702 * conjunction's infeasibility), @c gate_monus is set difference
703 * (likewise), and other gate types break the AND chain.
704 *
705 * Cmps that this pass cannot interpret (RV vs RV, arith on either
706 * side, agg…) are simply ignored: skipping them is sound &ndash; we
707 * just have fewer constraints, so we never falsely declare
708 * infeasibility we cannot prove.
709 */
710bool isAndJointlyInfeasible(const GenericCircuit &gc, gate_t root)
711{
712 std::unordered_map<gate_t, Interval> rv_intervals;
713 std::unordered_map<gate_t, Interval> support_cache;
714 bool complete;
715 walkAndConjunctIntervals(gc, root, rv_intervals, support_cache, complete);
716 for (const auto &kv : rv_intervals) {
717 if (intervalEmpty(kv.second)) return true;
718 }
719 return false;
720}
721
722/**
723 * @brief Memoised recursive predicate: does @p g's sub-circuit
724 * produce a continuous random variable (no point-mass /
725 * Dirac component)?
726 *
727 * Used to widen the EQ / NE = 0 / 1 shortcut at the cmp resolution
728 * site below the bare-@c gate_rv test, so multi-gate composites like
729 * <tt>Exp(0.4) + Exp(0.3) = c</tt> (heterogeneous-rate exponential
730 * sum, no closed-form Erlang fold) or
731 * <tt>mixture(p, Normal, Uniform) = c</tt> (Bernoulli mixture over
732 * two continuous arms) also resolve at load time. Without this the
733 * cmp falls through to AnalyticEvaluator (which returns NaN for
734 * EQ / NE) and then to the MC marginalisation, which in finite
735 * precision estimates @c P(X = Y) at 0 anyway -- but costs
736 * @c provsql.rv_mc_samples iterations to do so.
737 *
738 * Recursion:
739 * - @c gate_rv -> true (Normal / Uniform / Exp / Erlang all have
740 * continuous densities, no point masses).
741 * - @c gate_value -> false (Dirac at the literal).
742 * - @c gate_arith -> true iff every wire has only-continuous
743 * support. Sums, products, negations, divisions of continuous
744 * RVs stay continuous in distribution; a @c gate_value sibling
745 * poisons the result (e.g. @c X + 2 is continuous, but
746 * @c X * 0 = 0 has a Dirac at zero -- handled by the existing
747 * constant-fold pre-pass, but defensive here).
748 * - @c gate_mixture, Bernoulli 3-wire <tt>[p, X, Y]</tt> -> true
749 * iff X and Y are both continuous; the Boolean @c p only chooses
750 * an arm, so it does not affect the support type.
751 * - @c gate_mixture, categorical
752 * <tt>[key, mul_1, ..., mul_n]</tt> -> false (point masses at
753 * each mulinput's outcome value).
754 * - Any other gate type -> false (defensive: gate_plus / gate_times
755 * / gate_cmp / gate_agg are not continuous-RV containers).
756 *
757 * The cache is keyed on @c gate_t and may be shared across multiple
758 * cmp gates inside a single @c runRangeCheck invocation.
759 */
760bool hasOnlyContinuousSupport(const GenericCircuit &gc, gate_t g,
761 std::unordered_map<gate_t, bool> &cache)
762{
763 auto it = cache.find(g);
764 if (it != cache.end()) return it->second;
765 /* Memoise pessimistically before recursing so a malformed cyclic
766 * sub-circuit (shouldn't happen on well-formed input) returns
767 * @c false rather than blowing the stack. */
768 cache[g] = false;
769
770 bool result = false;
771 auto t = gc.getGateType(g);
772 switch (t) {
773 case gate_rv: {
774 /* A discrete family (Poisson, Binomial) has point masses, so a point
775 * event X = c carries positive mass and must NOT take the continuous
776 * EQ/NE measure-zero shortcut. isDiscrete() is the authoritative
777 * per-family flag (parse_distribution_template handles a latent /
778 * parametric leaf too); a malformed spec falls back to continuous. */
779 auto tmpl = parse_distribution_template(gc.getExtra(g));
780 result = !(tmpl && tmpl->family->factory(0.0, 0.0)->isDiscrete());
781 break;
782 }
783 case gate_value:
784 result = false;
785 break;
786 case gate_arith: {
787 result = true;
788 for (gate_t w : gc.getWires(g)) {
789 if (!hasOnlyContinuousSupport(gc, w, cache)) { result = false; break; }
790 }
791 break;
792 }
793 case gate_mixture: {
794 if (gc.isCategoricalMixture(g)) { result = false; break; }
795 const auto &w = gc.getWires(g);
796 if (w.size() != 3) { result = false; break; }
797 result = hasOnlyContinuousSupport(gc, w[1], cache)
798 && hasOnlyContinuousSupport(gc, w[2], cache);
799 break;
800 }
801 default:
802 result = false;
803 break;
804 }
805
806 cache[g] = result;
807 return result;
808}
809
810/**
811 * @brief Recursive collection of the @c gate_rv and @c gate_input
812 * leaves reachable from @p g.
813 *
814 * The result is a sub-circuit's "random-source footprint": two
815 * sub-circuits are independent iff their random-source sets are
816 * disjoint. Used to gate the exact-EQ Dirac sum-product below: the
817 * factoring @c P(X = Y) = Σ_v @c P(X=v)·P(Y=v) is only valid when
818 * @c X and @c Y are independent, otherwise the per-row coupling
819 * (e.g. two mixtures sharing a Bernoulli @c p_token) breaks the
820 * factoring and the sum-product silently produces the wrong
821 * probability.
822 *
823 * Descent rules: @c gate_arith and @c gate_mixture descend into all
824 * children (Bernoulli @c p_token, categorical key, mulinputs all
825 * contribute to the random footprint). @c gate_value is a
826 * deterministic literal and contributes no random source. Other
827 * gate types (Boolean / agg / etc.) don't appear under a continuous
828 * cmp side in well-formed circuits; defensively, they contribute
829 * nothing.
830 */
831const std::unordered_set<gate_t> &
832collectRandomLeaves(const GenericCircuit &gc, gate_t g,
833 std::unordered_map<gate_t, std::unordered_set<gate_t>> &cache)
834{
835 auto it = cache.find(g);
836 if (it != cache.end()) return it->second;
837 /* Insert an empty entry first so a recursive call on a cyclic
838 * sub-circuit returns early. std::unordered_map insertion does
839 * not invalidate references to existing elements, but it MAY
840 * rehash on growth (invalidating ALL references, including the
841 * one we're about to capture). Build the result locally, then
842 * write it back in one shot at the end. */
843 cache.emplace(g, std::unordered_set<gate_t>{});
844
845 std::unordered_set<gate_t> out;
846 auto t = gc.getGateType(g);
847 if (t == gate_rv || t == gate_input) {
848 out.insert(g);
849 } else if (t == gate_arith || t == gate_mixture) {
850 for (gate_t w : gc.getWires(g)) {
851 const auto &child = collectRandomLeaves(gc, w, cache);
852 out.insert(child.begin(), child.end());
853 }
854 }
855
856 /* Overwrite the placeholder; locate by find() to avoid a fresh
857 * insertion that could rehash and invalidate other iterators in
858 * upstream frames. */
859 auto fit = cache.find(g);
860 fit->second = std::move(out);
861 return fit->second;
862}
863
864using DiracMap = std::unordered_map<double, double>;
865using DiracMapOpt = std::optional<DiracMap>;
866
867/**
868 * @brief Recursive extraction of @p g's Dirac mass map (value -> mass).
869 *
870 * Returns @c std::nullopt when the sub-circuit's discrete component
871 * is not statically extractable (e.g. an opaque @c gate_arith over
872 * mixtures, a Bernoulli mixture whose @c p_token is a compound
873 * Boolean, etc.). When the sub-circuit is purely continuous the
874 * map is well-defined but empty (no Diracs, no masses).
875 *
876 * Used by the exact EQ shortcut below: for independent @c X, @c Y
877 * with extractable mass maps @c M_X, @c M_Y:
878 * <tt>P(X = Y) = Σ_{v ∈ M_X ∩ M_Y} M_X[v] · M_Y[v]</tt>. Continuous
879 * components contribute zero by measure-zero arguments (Dirac vs
880 * continuous and continuous vs continuous), so they need not appear
881 * in the sum.
882 *
883 * Shape rules:
884 * - @c gate_value:v: a Dirac at the literal with mass @c 1.
885 * - @c gate_rv: continuous in every supported family, empty map.
886 * - categorical @c gate_mixture <tt>[key, mul_1, ..., mul_n]</tt>:
887 * sum @c getProb(mul_i) into @c map[parseDouble(extra(mul_i))].
888 * Multiple mulinputs at the same outcome (which the constructor
889 * doesn't produce but is sound to handle) merge masses.
890 * - Bernoulli @c gate_mixture <tt>[p_token, X, Y]</tt> with
891 * @c p_token a bare @c gate_input: pull @c π = @c getProb(p_token)
892 * and recurse into X, Y to get @c M_X, @c M_Y; result is
893 * <tt>π·M_X[v] + (1-π)·M_Y[v]</tt> per outcome value. Compound
894 * Boolean @c p_tokens (whose probability would have to come from
895 * a recursive @c probability_evaluate call) bail.
896 * - Anything else: @c std::nullopt.
897 */
898DiracMapOpt
899collectDiracMassMap(const GenericCircuit &gc, gate_t g,
900 std::unordered_map<gate_t, DiracMapOpt> &cache)
901{
902 auto it = cache.find(g);
903 if (it != cache.end()) return it->second;
904 /* Pessimistic cycle guard, same reasoning as @c collectRandomLeaves. */
905 cache.emplace(g, std::nullopt);
906
907 DiracMapOpt result;
908 auto t = gc.getGateType(g);
909 switch (t) {
910 case gate_value: {
911 try {
912 DiracMap m;
913 m[parseDoubleStrict(gc.getExtra(g))] = 1.0;
914 result = std::move(m);
915 } catch (const CircuitException &) {
916 /* unparseable extra: bail */
917 }
918 break;
919 }
920 case gate_rv: {
921 /* A CONTINUOUS leaf has no point masses (an empty map is exact: the
922 * Dirac sum-product then contributes zero, correct by measure zero).
923 * A DISCRETE leaf (Poisson, Binomial) DOES have point masses, but
924 * they are not statically enumerable (infinite / large support), so
925 * decline (nullopt) -- claiming an empty map here would make the
926 * sum-product read "no overlap" and wrongly fold X = c to false.
927 * isDiscrete() is the authoritative per-family flag. */
928 auto tmpl = parse_distribution_template(gc.getExtra(g));
929 if (tmpl && tmpl->family->factory(0.0, 0.0)->isDiscrete())
930 result = std::nullopt;
931 else
932 result = DiracMap{};
933 break;
934 }
935 case gate_mixture: {
936 const auto &w = gc.getWires(g);
937 if (gc.isCategoricalMixture(g)) {
938 DiracMap m;
939 bool ok = true;
940 for (std::size_t i = 1; i < w.size(); ++i) {
941 double v;
942 try { v = parseDoubleStrict(gc.getExtra(w[i])); }
943 catch (const CircuitException &) { ok = false; break; }
944 const double p = gc.getProb(w[i]);
945 if (!std::isfinite(p) || p < 0.0 || p > 1.0) { ok = false; break; }
946 m[v] += p;
947 }
948 if (ok) result = std::move(m);
949 } else if (w.size() == 3
950 && gc.getGateType(w[0]) == gate_input) {
951 const double pi = gc.getProb(w[0]);
952 if (std::isfinite(pi) && pi >= 0.0 && pi <= 1.0) {
953 auto mx = collectDiracMassMap(gc, w[1], cache);
954 auto my = collectDiracMassMap(gc, w[2], cache);
955 if (mx && my) {
956 DiracMap m;
957 for (const auto &[v, mass] : *mx) m[v] += pi * mass;
958 for (const auto &[v, mass] : *my) m[v] += (1.0 - pi) * mass;
959 result = std::move(m);
960 }
961 }
962 }
963 break;
964 }
965 default:
966 break;
967 }
968
969 auto fit = cache.find(g);
970 fit->second = result;
971 return result;
972}
973
974} // namespace
975
977{
978 std::unordered_map<gate_t, Interval> cache;
979 /* Shared across all cmp gates in this @c runRangeCheck invocation.
980 * Keyed on gate_t and immutable across cmp iterations because
981 * resolving one cmp only changes the cmp's own gate type, not
982 * the sub-circuit underneath @c wires[0..1] of other cmps. */
983 std::unordered_map<gate_t, bool> continuous_support_cache;
984 std::unordered_map<gate_t, DiracMapOpt> dirac_cache;
985 std::unordered_map<gate_t, std::unordered_set<gate_t>> leaf_cache;
986 unsigned resolved = 0;
987
988 /* Snapshot the cmp gate ids before we start mutating: in-place
989 * resolution turns a @c gate_cmp into a @c gate_input, but
990 * @c getNbGates only grows, never shrinks, so iterating by index
991 * over the original count is safe. We re-check the type at each
992 * step to skip already-resolved slots. */
993 const auto nb = gc.getNbGates();
994 std::vector<gate_t> cmps;
995 for (std::size_t i = 0; i < nb; ++i) {
996 auto g = static_cast<gate_t>(i);
997 if (gc.getGateType(g) == gate_cmp)
998 cmps.push_back(g);
999 }
1000
1001 for (gate_t c : cmps) {
1002 if (gc.getGateType(c) != gate_cmp) continue; /* defensive */
1003
1004 bool ok = false;
1005 ComparisonOperator op = cmpOpFromOid(gc.getInfos(c).first, ok);
1006 if (!ok) continue;
1007
1008 const auto &wires = gc.getWires(c);
1009 if (wires.size() != 2) continue;
1010
1011 /* Identity shortcut: when both sides of the cmp are the same
1012 * gate (same UUID), the sampler's per-iteration memoisation
1013 * guarantees both reads return identical values, so the
1014 * comparator collapses to a constant. Universal across gate
1015 * types and semirings; runs first so neither the continuous
1016 * EQ/NE shortcut nor the interval-based path needs an explicit
1017 * @c lhs != rhs guard. */
1018 if (wires[0] == wires[1]) {
1019 double p = std::numeric_limits<double>::quiet_NaN();
1020 switch (op) {
1024 p = 1.0; break;
1028 p = 0.0; break;
1029 }
1030 gc.resolveCmpToBernoulli(c, p);
1031 ++resolved;
1032 continue;
1033 }
1034
1035 /* Continuous EQ / NE shortcut: P(X = c) = 0 and P(X != c) = 1
1036 * exactly when at least one side has a continuous distribution
1037 * (point equality has measure zero under any continuous
1038 * distribution). Universal across semirings: the gate_zero /
1039 * gate_one rewrite is meaningful in every semiring (not just
1040 * probability), so the resolution belongs here rather than in
1041 * AnalyticEvaluator.
1042 *
1043 * @c hasOnlyContinuousSupport widens the test beyond a bare
1044 * @c gate_rv leaf: heterogeneous-rate exponential sums, products
1045 * of independent continuous RVs, and Bernoulli mixtures over
1046 * two continuous arms all qualify because their distribution
1047 * has no point-mass component. Categorical mixtures (point
1048 * masses at each outcome value) and pure-deterministic
1049 * @c gate_value sub-circuits do NOT qualify and fall through to
1050 * the agg / interval / AnalyticEvaluator paths.
1051 *
1052 * The @c wires[0] == @c wires[1] case is already handled by the
1053 * identity shortcut above. */
1054 if (op == ComparisonOperator::EQ ||
1055 op == ComparisonOperator::NE) {
1056 bool lhs_continuous = hasOnlyContinuousSupport(gc, wires[0],
1057 continuous_support_cache);
1058 bool rhs_continuous = hasOnlyContinuousSupport(gc, wires[1],
1059 continuous_support_cache);
1060 if (lhs_continuous || rhs_continuous) {
1061 double p = (op == ComparisonOperator::EQ) ? 0.0 : 1.0;
1062 gc.resolveCmpToBernoulli(c, p);
1063 ++resolved;
1064 continue;
1065 }
1066
1067 /* Exact Dirac sum-product. When both sides have extractable
1068 * @c (value -> mass) maps AND the two sub-circuits are
1069 * independent (random-leaf footprints disjoint), the
1070 * convolution at zero of @c (X - Y) has support exactly on
1071 * @c Dirac(X) ∩ Dirac(Y) with mass
1072 * <tt>M_X(v) · M_Y(v)</tt> per overlapping value; the
1073 * continuous and continuous-vs-Dirac contributions vanish by
1074 * measure zero. This generalises the bare-disjoint case to
1075 * any pair of statically-known discrete distributions:
1076 * <tt>P(categorical(a) = categorical(b))</tt> with overlapping
1077 * outcomes, mixtures with @c as_random branches, etc.
1078 *
1079 * The independence test is essential: two mixtures sharing a
1080 * Bernoulli @c p_token are correlated and the sum-product
1081 * factoring breaks (the actual @c P(X=Y) cannot be recovered
1082 * from the marginals alone). @c collectRandomLeaves'
1083 * footprint-disjoint check is the gate.
1084 *
1085 * When both maps are empty (purely continuous on both sides)
1086 * the existing branch above already fired, so the sum-product
1087 * path here only runs for at-least-one-discrete shapes. */
1088 auto m_l = collectDiracMassMap(gc, wires[0], dirac_cache);
1089 auto m_r = collectDiracMassMap(gc, wires[1], dirac_cache);
1090 if (m_l && m_r) {
1091 const auto &leaves_l = collectRandomLeaves(gc, wires[0], leaf_cache);
1092 const auto &leaves_r = collectRandomLeaves(gc, wires[1], leaf_cache);
1093 bool independent = true;
1094 for (gate_t leaf : leaves_l) {
1095 if (leaves_r.count(leaf)) { independent = false; break; }
1096 }
1097 if (independent) {
1098 double p_eq = 0.0;
1099 /* Iterate over the smaller map to keep the sum at
1100 * O(min(|M_l|, |M_r|)) lookups. */
1101 const DiracMap *small = (m_l->size() <= m_r->size()) ? &*m_l : &*m_r;
1102 const DiracMap *large = (m_l->size() <= m_r->size()) ? &*m_r : &*m_l;
1103 for (const auto &[v, mass] : *small) {
1104 auto fit = large->find(v);
1105 if (fit != large->end()) p_eq += mass * fit->second;
1106 }
1107 /* Clamp into @c [0, 1] defensively: floating-point summation
1108 * of masses (each in [0, 1]) might overshoot by an ULP, and
1109 * @c resolveCmpToBernoulli requires a strict probability. */
1110 if (p_eq < 0.0) p_eq = 0.0;
1111 if (p_eq > 1.0) p_eq = 1.0;
1112 double p = (op == ComparisonOperator::EQ) ? p_eq : 1.0 - p_eq;
1113 gc.resolveCmpToBernoulli(c, p);
1114 ++resolved;
1115 continue;
1116 }
1117 }
1118 }
1119
1120 /* HAVING-style cmp: agg on one side, scalar constant on the
1121 * other. Decide via the agg-aware path which is cheaper than
1122 * intervalOf + decideCmp and which knows the empty-subset NULL
1123 * semantics for SUM / MIN / MAX (see decideAggVsConstCmp). */
1124 bool lhs_is_agg = gc.getGateType(wires[0]) == gate_agg;
1125 bool rhs_is_agg = gc.getGateType(wires[1]) == gate_agg;
1126 if (lhs_is_agg != rhs_is_agg) {
1127 gate_t agg_side = lhs_is_agg ? wires[0] : wires[1];
1128 gate_t const_side = lhs_is_agg ? wires[1] : wires[0];
1129 double const_val = extractScalarConst(gc, const_side);
1130 if (!std::isnan(const_val)) {
1131 double p = decideAggVsConstCmp(gc, agg_side, op, const_val,
1132 lhs_is_agg);
1133 if (!std::isnan(p)) {
1134 gc.resolveCmpToBernoulli(c, p);
1135 ++resolved;
1136 continue;
1137 }
1138 }
1139 }
1140
1141 /* Interval-based path for non-agg cmps (RV, gate_arith, value). */
1142 Interval lhs = intervalOf(gc, wires[0], cache);
1143 Interval rhs = intervalOf(gc, wires[1], cache);
1144 /* Skip if both sides are unbounded; @c decideCmp would never
1145 * return a decision and the work is wasted. */
1146 if (lhs.isAll() && rhs.isAll()) continue;
1147
1148 Interval diff = sub(lhs, rhs);
1149 double p = decideCmp(diff, op);
1150 if (!std::isnan(p)) {
1151 gc.resolveCmpToBernoulli(c, p);
1152 ++resolved;
1153 }
1154 }
1155
1156 /* Joint-conjunction pass: walk every @c gate_times and check
1157 * whether its AND-conjunct cmps, viewed together, constrain some
1158 * shared RV to an empty interval. Catches the joint-infeasibility
1159 * case the per-cmp pass above cannot see (each cmp individually
1160 * leaves a non-empty range, but their intersection is empty).
1161 *
1162 * Snapshot the gate_times indices first: @c resolveGateToZero
1163 * mutates the type, so iterating the live vector while resolving
1164 * would skip slots. The post-snapshot type re-check guards against
1165 * a @c gate_times that the per-cmp pass somehow already collapsed
1166 * (currently not possible, but cheap insurance for future passes). */
1167 const auto nb_after = gc.getNbGates();
1168 std::vector<gate_t> times_gates;
1169 for (std::size_t i = 0; i < nb_after; ++i) {
1170 auto g = static_cast<gate_t>(i);
1171 if (gc.getGateType(g) == gate_times)
1172 times_gates.push_back(g);
1173 }
1174 for (gate_t t : times_gates) {
1175 if (gc.getGateType(t) != gate_times) continue; /* defensive */
1176 if (isAndJointlyInfeasible(gc, t)) {
1177 gc.resolveGateToZero(t);
1178 ++resolved;
1179 }
1180 }
1181
1182 return resolved;
1183}
1184
1185/**
1186 * @brief Probability-side pre-pass: rewrite HAVING-style @c gate_cmp
1187 * gates that are provably TRUE on the agg's value-interval
1188 * into an OR over the agg's per-row K-gates.
1189 *
1190 * Companion to @c runCountCmpEvaluator's Poisson-binomial pre-pass:
1191 * where that one resolves @c COUNT op C to a closed-form Bernoulli,
1192 * this one catches the always-true sub-case (e.g. @c COUNT <= K with
1193 * @c K >= N inputs, or any aggregator whose value-interval entirely
1194 * satisfies the predicate) and replaces the cmp with @c gate_plus
1195 * over the agg's K-gates -- the "group is non-empty" indicator.
1196 *
1197 * Why a separate pass: @c runRangeCheck deliberately blocks TRUE
1198 * decisions because @c gate_one is universally unsound for HAVING
1199 * (it would credit the empty world). The safe TRUE rewrite
1200 * "OR of K-gates" requires absorptive @c gate_plus semantics
1201 * (probability, Boolean, formula, why, which, max-min, max-max), so
1202 * the pass is restricted to the probability-evaluate path where
1203 * absorption is guaranteed by the downstream BoolExpr translation.
1204 *
1205 * Fires regardless of @c provsql.cmp_probability_evaluation: when
1206 * the Poisson-binomial path is disabled (developer A/B testing),
1207 * this lighter shortcut still catches the always-true case and
1208 * spares the d-DNNF compiler the 2^N-clause DNF that
1209 * @c provsql_having's @c enumerate_valid_worlds would otherwise emit.
1210 *
1211 * Same matching contract as @c decideAggVsConstCmp for the agg side:
1212 * cmp wires must be {gate_agg, scalar-const-encoded-as-semimod}, the
1213 * agg's children must all be @c gate_semimod, and the agg kind must
1214 * be one of COUNT / SUM / MIN / MAX (the only kinds with an
1215 * interval). Mismatches leave the cmp untouched.
1216 *
1217 * @param gc Circuit to mutate in place.
1218 * @return Number of comparators rewritten to gate_plus.
1219 */
1221{
1222 unsigned resolved = 0;
1223 const auto nb = gc.getNbGates();
1224
1225 std::vector<gate_t> cmps;
1226 cmps.reserve(nb / 8); /* rough guess */
1227 for (std::size_t i = 0; i < nb; ++i) {
1228 auto g = static_cast<gate_t>(i);
1229 if (gc.getGateType(g) == gate_cmp)
1230 cmps.push_back(g);
1231 }
1232
1233 for (gate_t c : cmps) {
1234 if (gc.getGateType(c) != gate_cmp) continue; /* defensive */
1235
1236 bool ok = false;
1237 ComparisonOperator op = cmpOpFromOid(gc.getInfos(c).first, ok);
1238 if (!ok) continue;
1239
1240 const auto &wires = gc.getWires(c);
1241 if (wires.size() != 2) continue;
1242
1243 bool lhs_is_agg = gc.getGateType(wires[0]) == gate_agg;
1244 bool rhs_is_agg = gc.getGateType(wires[1]) == gate_agg;
1245 if (lhs_is_agg == rhs_is_agg) continue; /* both agg or neither */
1246
1247 gate_t agg_side = lhs_is_agg ? wires[0] : wires[1];
1248 gate_t const_side = lhs_is_agg ? wires[1] : wires[0];
1249
1250 double const_val = extractScalarConst(gc, const_side);
1251 if (std::isnan(const_val)) continue;
1252
1253 bool always_true = false;
1254 double p = decideAggVsConstCmp(gc, agg_side, op, const_val,
1255 lhs_is_agg, &always_true);
1256 if (!always_true) {
1257 /* p might be 0.0 (already handled by runRangeCheck at load time
1258 * if simplify_on_load is on); skip either way. */
1259 (void)p;
1260 continue;
1261 }
1262
1263 /* Scalar aggregation (no GROUP BY): the single result row always exists, so
1264 * a tautological predicate (count >= 0, count > -K, ...) is gate_one --
1265 * probability 1, including the empty-input world. The "group is non-empty"
1266 * rewrite below is the grouped semantics (the empty group is no row), which
1267 * is exactly the empty-world over-credit the doc comment on
1268 * decideAggVsConstCmp warns against; for a scalar agg that world is real. */
1269 if ((gc.getInfos(agg_side).second & PROVSQL_AGG_SCALAR_FLAG) != 0) {
1270 gc.resolveCmpToBernoulli(c, 1.0);
1271 ++resolved;
1272 continue;
1273 }
1274
1275 /* Gather the per-row K-gates from the agg's semimod children. */
1276 std::vector<gate_t> ks;
1277 bool shape_ok = true;
1278 ks.reserve(gc.getWires(agg_side).size());
1279 for (gate_t ch : gc.getWires(agg_side)) {
1280 if (gc.getGateType(ch) != gate_semimod) { shape_ok = false; break; }
1281 const auto &sw = gc.getWires(ch);
1282 if (sw.size() != 2) { shape_ok = false; break; }
1283 ks.push_back(sw[0]); /* K side; M side is sw[1] = gate_value */
1284 }
1285 if (!shape_ok || ks.empty()) continue;
1286
1287 gc.resolveCmpToPlusOfKGates(c, ks);
1288 ++resolved;
1289 }
1290
1291 return resolved;
1292}
1293
1294namespace {
1295
1296/* Sampling-time support for aggregation gates. Unlike @c intervalOf
1297 * (shared with the @c runRangeCheck cmp dispatcher, which must stay
1298 * conservative on agg to respect SQL NULL semantics), this widens
1299 * gate_agg and non-trivial gate_semimod to the actual range of
1300 * scalar MC samples @c MonteCarloSampler::evalScalar can produce.
1301 * Used only by @c compute_support, called from rv_support /
1302 * rv_histogram / rv_moment fallbacks, so the cmp decider remains
1303 * untouched.
1304 *
1305 * Empty-group convention (see test/sql/continuous_aggregation §5):
1306 * COUNT and SUM yield 0; MIN / MAX / AVG yield NaN. NaN sits
1307 * outside any real interval, so callers binning samples drop those
1308 * worlds automatically; the moment averagers in
1309 * Expectation::mc_raw_moment also skip them.
1310 */
1311Interval aggSupportOf(const GenericCircuit &gc, gate_t root,
1312 std::unordered_map<gate_t, Interval> &cache)
1313{
1314 const auto type = gc.getGateType(root);
1315 if (type == gate_semimod) {
1316 const auto &wires = gc.getWires(root);
1317 if (wires.size() != 2) return Interval::all();
1318 Interval vi = intervalOf(gc, wires[1], cache);
1319 if (gc.getGateType(wires[0]) == gate_one) return vi;
1320 /* Boolean k child: per-iteration scalar is value · 1_{k fires},
1321 * so the support is the union of {0} and the value's range. */
1322 return Interval{std::min(0.0, vi.lo), std::max(0.0, vi.hi)};
1323 }
1324 if (type != gate_agg) return intervalOf(gc, root, cache);
1325
1326 const auto &wires = gc.getWires(root);
1328 getAggregationOperator(gc.getInfos(root).first);
1329
1330 std::vector<gate_t> sm_children;
1331 sm_children.reserve(wires.size());
1332 for (gate_t c : wires)
1333 if (gc.getGateType(c) == gate_semimod) sm_children.push_back(c);
1334
1335 auto child_value_iv = [&](gate_t sm) -> Interval {
1336 const auto &sw = gc.getWires(sm);
1337 if (sw.size() != 2) return Interval::all();
1338 return intervalOf(gc, sw[1], cache);
1339 };
1340 auto child_always_fires = [&](gate_t sm) -> bool {
1341 const auto &sw = gc.getWires(sm);
1342 return sw.size() == 2 && gc.getGateType(sw[0]) == gate_one;
1343 };
1344
1345 const auto inf = std::numeric_limits<double>::infinity();
1346 switch (op) {
1348 /* [0, n_rows]. Each semimod contributes 0 or 1 to the count. */
1349 return Interval{0.0, static_cast<double>(sm_children.size())};
1351 /* Per row, contribution is value if k fires, else 0; sum the
1352 * per-row support intervals. Always-firing rows contribute
1353 * their value interval verbatim; possibly-firing rows
1354 * contribute [min(0, v.lo), max(0, v.hi)]. */
1355 double lo = 0.0, hi = 0.0;
1356 for (gate_t sm : sm_children) {
1357 Interval vi = child_value_iv(sm);
1358 if (vi.isAll()) return Interval::all();
1359 if (child_always_fires(sm)) {
1360 lo += vi.lo;
1361 hi += vi.hi;
1362 } else {
1363 lo += std::min(0.0, vi.lo);
1364 hi += std::max(0.0, vi.hi);
1365 }
1366 }
1367 return Interval{lo, hi};
1368 }
1371 /* MIN / MAX of values where k_i fires. The actual MIN (or MAX)
1372 * is some value from one of the firing rows, so the support is
1373 * the union of the children's value intervals. Empty-group
1374 * worlds finalise to NaN, which sits outside the real
1375 * interval. */
1376 if (sm_children.empty()) return Interval::all();
1377 double lo = inf;
1378 double hi = -inf;
1379 for (gate_t sm : sm_children) {
1380 Interval vi = child_value_iv(sm);
1381 if (vi.isAll()) return Interval::all();
1382 lo = std::min(lo, vi.lo);
1383 hi = std::max(hi, vi.hi);
1384 }
1385 if (lo > hi) return Interval::all();
1386 return Interval{lo, hi};
1387 }
1388 default:
1389 /* AVG: ratio depends on the world's row count. AND / OR /
1390 * CHOOSE / ARRAY_AGG: not numeric carriers rv_* surfaces.
1391 * Keep the conservative all-real default. */
1392 return Interval::all();
1393 }
1394}
1395
1396} // namespace
1397
1398std::pair<double, double>
1400 std::optional<gate_t> event_root)
1401{
1402 std::unordered_map<gate_t, Interval> cache;
1403 Interval iv = aggSupportOf(gc, root, cache);
1404
1405 /* Conditional path: intersect with the event's AND-conjunct
1406 * constraints on @p root. Walks event_root collecting `rv op c`
1407 * cmps; non-target constraints are ignored (they affect P(event)
1408 * but not the truncation of root's distribution). Even if the
1409 * walk is "incomplete" (gate_plus / gate_monus / arith encountered)
1410 * the result is sound: we're computing a SUPERSET bound on the
1411 * conditional support, and the unconditional support is already a
1412 * superset, so the intersection of the collected constraints with
1413 * the unconditional is also a superset. */
1414 if (event_root.has_value()) {
1415 std::unordered_map<gate_t, Interval> rv_intervals;
1416 bool complete;
1417 walkAndConjunctIntervals(gc, *event_root, rv_intervals, cache, complete);
1418 auto it = rv_intervals.find(root);
1419 if (it != rv_intervals.end()) {
1420 iv.lo = std::max(iv.lo, it->second.lo);
1421 iv.hi = std::min(iv.hi, it->second.hi);
1422 /* Defensively clamp to avoid an inverted interval if a buggy
1423 * walker produced one; should not happen but cheap. */
1424 if (iv.lo > iv.hi) iv.lo = iv.hi;
1425 }
1426 }
1427
1428 return {iv.lo, iv.hi};
1429}
1430
1431std::optional<std::pair<double, double>>
1433 gate_t target_rv)
1434{
1435 std::unordered_map<gate_t, Interval> rv_intervals;
1436 std::unordered_map<gate_t, Interval> support_cache;
1437 bool complete;
1438 walkAndConjunctIntervals(gc, event_root, rv_intervals, support_cache,
1439 complete);
1440 if (!complete) return std::nullopt;
1441 /* If the walk found no cmp constraining target_rv, the conditional
1442 * support is the unconditional support (the event is independent
1443 * of target_rv along the recognised structure). Returning the
1444 * unconditional interval lets the moment closed-form path
1445 * short-circuit to the unconditional moment, matching the
1446 * mathematical truth. */
1447 auto it = rv_intervals.find(target_rv);
1448 Interval iv;
1449 if (it != rv_intervals.end()) {
1450 iv = it->second;
1451 /* Intersect with the RV's own support to be safe (event may
1452 * over-constrain past the support, e.g. `Exp(λ) < -1`). */
1453 Interval base = intervalOf(gc, target_rv, support_cache);
1454 iv.lo = std::max(iv.lo, base.lo);
1455 iv.hi = std::min(iv.hi, base.hi);
1456 if (iv.lo > iv.hi) iv.lo = iv.hi;
1457 } else {
1458 iv = intervalOf(gc, target_rv, support_cache);
1459 }
1460 return std::make_pair(iv.lo, iv.hi);
1461}
1462
1463/**
1464 * @brief Parse a @c gate_value's @c extra as a finite @c float8.
1465 *
1466 * Sibling of @c extract_constant_string in @c having_semantics.cpp but
1467 * parsing a double, with a const @c GenericCircuit ref (used in the
1468 * closed-form shape detector path). Bails on @c NaN / @c ±Infinity so a downstream
1469 * stem renderer never sees a non-finite x coordinate.
1470 */
1472 double &out)
1473{
1474 if (gc.getGateType(x) != gate_value) return false;
1475 const std::string &s = gc.getExtra(x);
1476 if (s.empty()) return false;
1477 try {
1478 size_t idx = 0;
1479 double v = std::stod(s, &idx);
1480 if (idx != s.size() || !std::isfinite(v)) return false;
1481 out = v;
1482 return true;
1483 } catch (...) {
1484 return false;
1485 }
1486}
1487
1488/** @brief Same parsing applied to a mulinput's outcome label (categorical). */
1490 double &out)
1491{
1492 if (gc.getGateType(mul) != gate_mulinput) return false;
1493 const std::string &s = gc.getExtra(mul);
1494 if (s.empty()) return false;
1495 try {
1496 size_t idx = 0;
1497 double v = std::stod(s, &idx);
1498 if (idx != s.size() || !std::isfinite(v)) return false;
1499 out = v;
1500 return true;
1501 } catch (...) {
1502 return false;
1503 }
1504}
1505
1506std::optional<TruncatedSingleRv>
1508 std::optional<gate_t> event_root)
1509{
1510 if (gc.getGateType(root) != gate_rv) return std::nullopt;
1511 auto spec = parse_distribution_spec(gc.getExtra(root));
1512 if (!spec) return std::nullopt;
1513
1514 /* Natural support per family. Normal is unbounded both sides;
1515 * Uniform sits exactly on its parameters; Exp / Erlang on
1516 * [0, +inf). Used both as the unconditional case and as the
1517 * intersection seed for collectRvConstraints (which already
1518 * intersects internally, but the bare-natural case still needs
1519 * a baseline). */
1520 const DistSupport nat_support = makeDistribution(*spec)->support();
1521 double nat_lo = nat_support.lo;
1522 double nat_hi = nat_support.hi;
1523
1524 /* Unconditional path: return natural support, mark untruncated. */
1525 if (!event_root.has_value()
1526 || gc.getGateType(*event_root) == gate_one) {
1527 return TruncatedSingleRv{*spec, nat_lo, nat_hi, /*truncated=*/false};
1528 }
1529
1530 /* Infeasible event resolved upstream by RangeCheck: the cmp was
1531 * folded to gate_zero, the conditional distribution is undefined.
1532 * @c collectRvConstraints would silently fall back to the natural
1533 * support here (its walker skips gate_zero like gate_one), so we
1534 * have to detect this explicitly. */
1535 if (gc.getGateType(*event_root) == gate_zero) return std::nullopt;
1536
1537 auto iv = collectRvConstraints(gc, *event_root, root);
1538 if (!iv.has_value()) return std::nullopt;
1539 if (!(iv->first < iv->second)) return std::nullopt;
1540
1541 return TruncatedSingleRv{*spec, iv->first, iv->second, /*truncated=*/true};
1542}
1543
1545 std::optional<gate_t> event_root)
1546{
1547 if (!event_root.has_value()) return false;
1548 const auto et = gc.getGateType(*event_root);
1549 if (et == gate_one) return false;
1550 /* RangeCheck folded the event to false upstream – universal
1551 * signal, independent of root gate type (a constant scalar
1552 * value paired with an impossible cmp lands here too). */
1553 if (et == gate_zero) return true;
1554 /* Walk the event's AND-conjuncts; an empty intersection with the
1555 * RV's natural support is the second infeasibility signal that
1556 * @c matchTruncatedSingleRv collapses into @c std::nullopt. Only
1557 * applicable when the root is itself a bare gate_rv that the
1558 * walker recognises. */
1559 if (gc.getGateType(root) != gate_rv) return false;
1560 auto iv = collectRvConstraints(gc, *event_root, root);
1561 if (!iv.has_value()) return false;
1562 return !(iv->first < iv->second);
1563}
1564
1565/**
1566 * @brief Unconditional probability mass of a shape over the
1567 * interval @c [lo, hi].
1568 *
1569 * @c TruncatedSingleRv arms supplied here must carry
1570 * @c truncated == @c false (the unconditional shape); the helper
1571 * uses the natural support to compute the CDF endpoints, so calling
1572 * with an already-truncated input would double-truncate.
1573 *
1574 * Recursive: a Bernoulli mixture's mass is the Bernoulli-weighted
1575 * combination of its arms' masses. Categorical mass is the sum of
1576 * outcome masses falling in the interval. Dirac mass is 1 iff the
1577 * Dirac value sits in the interval, else 0. Returns @c std::nullopt
1578 * when a leaf's spec defeats the closed-form CDF (e.g. non-integer
1579 * Erlang shape – @c cdfAt returns NaN there).
1580 */
1581static std::optional<double>
1582shape_mass(const ClosedFormShape &s, double lo, double hi)
1583{
1584 return std::visit([&](const auto &v) -> std::optional<double> {
1585 using T = std::decay_t<decltype(v)>;
1586 if constexpr (std::is_same_v<T, TruncatedSingleRv>) {
1587 const double a = std::max(lo, v.lo);
1588 const double b = std::min(hi, v.hi);
1589 if (!(a < b)) return 0.0;
1590 const double cl = std::isfinite(a) ? cdfAt(v.spec, a) : 0.0;
1591 const double ch = std::isfinite(b) ? cdfAt(v.spec, b) : 1.0;
1592 if (std::isnan(cl) || std::isnan(ch)) return std::nullopt;
1593 return ch - cl;
1594 } else if constexpr (std::is_same_v<T, DiracShape>) {
1595 return (v.value >= lo && v.value <= hi) ? 1.0 : 0.0;
1596 } else if constexpr (std::is_same_v<T, CategoricalShape>) {
1597 double m = 0.0;
1598 for (const auto &pr : v.outcomes)
1599 if (pr.first >= lo && pr.first <= hi) m += pr.second;
1600 return m;
1601 } else if constexpr (std::is_same_v<T, BernoulliMixtureShape>) {
1602 auto L = shape_mass(*v.left, lo, hi);
1603 auto R = shape_mass(*v.right, lo, hi);
1604 if (!L || !R) return std::nullopt;
1605 return v.p * (*L) + (1.0 - v.p) * (*R);
1606 }
1607 return std::nullopt;
1608 }, s);
1609}
1610
1611/**
1612 * @brief Conditional shape after truncating the underlying variable
1613 * to @c [lo, hi].
1614 *
1615 * Bare-RV arm: intersects its natural / current truncation with
1616 * @c [lo, hi] and marks the result truncated so downstream
1617 * @c shape_pdf renormalises by the truncated CDF. Dirac: keep iff
1618 * value ∈ interval, otherwise nullopt (infeasible). Categorical:
1619 * keep outcomes in interval, renormalise masses. Bernoulli mixture:
1620 * recursively truncate each arm and reweight the Bernoulli by the
1621 * ratio of arm masses (the standard
1622 * @f$ \pi' = \pi Z_L / (\pi Z_L + (1-\pi) Z_R) @f$ update); a
1623 * fully-eliminated arm degenerates to the surviving one. Returns
1624 * @c nullopt when the truncated shape has zero mass (caller can
1625 * raise infeasibility).
1626 */
1627static std::optional<ClosedFormShape>
1628truncateShape(const ClosedFormShape &s, double lo, double hi)
1629{
1630 return std::visit([&](const auto &v) -> std::optional<ClosedFormShape> {
1631 using T = std::decay_t<decltype(v)>;
1632 if constexpr (std::is_same_v<T, TruncatedSingleRv>) {
1633 const double a = std::max(lo, v.lo);
1634 const double b = std::min(hi, v.hi);
1635 if (!(a < b)) return std::nullopt;
1636 return ClosedFormShape{TruncatedSingleRv{v.spec, a, b, /*trunc=*/true}};
1637 } else if constexpr (std::is_same_v<T, DiracShape>) {
1638 if (v.value < lo || v.value > hi) return std::nullopt;
1639 return ClosedFormShape{v};
1640 } else if constexpr (std::is_same_v<T, CategoricalShape>) {
1641 CategoricalShape out;
1642 double total = 0.0;
1643 for (const auto &pr : v.outcomes) {
1644 if (pr.first >= lo && pr.first <= hi) {
1645 out.outcomes.emplace_back(pr.first, pr.second);
1646 total += pr.second;
1647 }
1648 }
1649 if (out.outcomes.empty() || !(total > 0.0)) return std::nullopt;
1650 for (auto &pr : out.outcomes) pr.second /= total;
1651 return ClosedFormShape{std::move(out)};
1652 } else if constexpr (std::is_same_v<T, BernoulliMixtureShape>) {
1653 auto mL = shape_mass(*v.left, lo, hi);
1654 auto mR = shape_mass(*v.right, lo, hi);
1655 if (!mL || !mR) return std::nullopt;
1656 const double pL = v.p * (*mL);
1657 const double pR = (1.0 - v.p) * (*mR);
1658 const double Z = pL + pR;
1659 if (!(Z > 0.0)) return std::nullopt;
1660 auto Lt = truncateShape(*v.left, lo, hi);
1661 auto Rt = truncateShape(*v.right, lo, hi);
1662 /* Either arm eliminated by the truncation collapses to the
1663 * surviving arm (its mass was already 0 in shape_mass, so the
1664 * reweighted p_arm is 1). */
1665 if (!Lt && !Rt) return std::nullopt;
1666 if (!Lt) return Rt;
1667 if (!Rt) return Lt;
1669 m.p = pL / Z;
1670 m.left = std::make_shared<ClosedFormShape>(std::move(*Lt));
1671 m.right = std::make_shared<ClosedFormShape>(std::move(*Rt));
1672 return ClosedFormShape{std::move(m)};
1673 }
1674 return std::nullopt;
1675 }, s);
1676}
1677
1678std::optional<ClosedFormShape>
1680 std::optional<gate_t> event_root)
1681{
1682 /* Test "event is trivial true": either absent, or resolved to
1683 * gate_one by load-time simplification. */
1684 const bool event_trivial = !event_root.has_value()
1685 || gc.getGateType(*event_root) == gate_one;
1686
1687 /* Bare gate_rv root: delegate to the existing single-RV matcher
1688 * so the truncation logic (collectRvConstraints) is the single
1689 * source of truth across the closed-form-shape surface. */
1690 if (gc.getGateType(root) == gate_rv) {
1691 /* Conjugate observe-evidence: the posterior is itself a bare
1692 * distribution of the prior's family, so the shape is the
1693 * (untruncated) posterior -- exact pdf/CDF for the histogram and
1694 * curve renderers. Declines to the truncation matcher on any
1695 * mismatch (whose AND-conjunct walker treats a gate_observe as an
1696 * uninterpretable factor and declines in turn). */
1697 if (!event_trivial)
1698 if (auto post = conjugatePosterior(gc, root, *event_root)) {
1699 const DistSupport sup = makeDistribution(*post)->support();
1700 return ClosedFormShape{
1701 TruncatedSingleRv{*post, sup.lo, sup.hi, /*truncated=*/false}};
1702 }
1703 auto m = matchTruncatedSingleRv(gc, root, event_root);
1704 if (!m) return std::nullopt;
1705 return ClosedFormShape{*m};
1706 }
1707
1708 /* Helper: match the shape unconditionally first, then if the event
1709 * is non-trivial extract an interval via collectRvConstraints and
1710 * apply truncateShape. Used by the Dirac / categorical / mixture
1711 * branches below so all three honour conditioning through the same
1712 * pipeline. */
1713 auto with_optional_truncation =
1714 [&](std::optional<ClosedFormShape> unc)
1715 -> std::optional<ClosedFormShape> {
1716 if (!unc) return std::nullopt;
1717 if (event_trivial) return unc;
1718 auto iv = collectRvConstraints(gc, *event_root, root);
1719 if (!iv.has_value()) return std::nullopt;
1720 if (!(iv->first < iv->second)) return std::nullopt;
1721 return truncateShape(*unc, iv->first, iv->second);
1722 };
1723
1724 /* Dirac point: a gate_value with extra parseable as a finite
1725 * float8 (the underlying form of as_random(c)). Conditioning on
1726 * a constant is normally folded upstream by RangeCheck to
1727 * gate_one / gate_zero, but a probabilistic event whose footprint
1728 * doesn't constrain the constant lands here untouched (the cmp
1729 * walker returns the unconditional support); truncateShape then
1730 * keeps the Dirac iff its value falls in the recognised interval. */
1731 if (gc.getGateType(root) == gate_value) {
1732 double v;
1733 if (!extract_finite_double(gc, root, v)) return std::nullopt;
1734 return with_optional_truncation(ClosedFormShape{DiracShape{v}});
1735 }
1736
1737 /* gate_mixture: either the explicit categorical form
1738 * (isCategoricalMixture) or the classic Bernoulli triple
1739 * [p_token, x_token, y_token]. */
1740 if (gc.getGateType(root) == gate_mixture) {
1741 const auto &w = gc.getWires(root);
1742
1743 if (gc.isCategoricalMixture(root)) {
1745 cs.outcomes.reserve(w.size() - 1);
1746 for (std::size_t i = 1; i < w.size(); ++i) {
1747 double v;
1748 if (!extract_mulinput_value(gc, w[i], v)) return std::nullopt;
1749 double p = gc.getProb(w[i]);
1750 if (!std::isfinite(p) || p < 0.0 || p > 1.0) return std::nullopt;
1751 cs.outcomes.emplace_back(v, p);
1752 }
1753 if (cs.outcomes.empty()) return std::nullopt;
1754 return with_optional_truncation(ClosedFormShape{std::move(cs)});
1755 }
1756
1757 /* Classic Bernoulli mixture: 3 wires, [p_token, x_token, y_token]
1758 * with p_token a bare gate_input; compound Boolean p bails (the
1759 * generic path would need a probability-over-Boolean-circuit
1760 * pre-pass we deliberately do not run here). */
1761 if (w.size() != 3) return std::nullopt;
1762 if (gc.getGateType(w[0]) != gate_input) return std::nullopt;
1763 double p = gc.getProb(w[0]);
1764 if (!std::isfinite(p) || p < 0.0 || p > 1.0) return std::nullopt;
1765
1766 auto left = matchClosedFormDistribution(gc, w[1], std::nullopt);
1767 auto right = matchClosedFormDistribution(gc, w[2], std::nullopt);
1768 if (!left || !right) return std::nullopt;
1769
1771 m.p = p;
1772 m.left = std::make_shared<ClosedFormShape>(std::move(*left));
1773 m.right = std::make_shared<ClosedFormShape>(std::move(*right));
1774 return with_optional_truncation(ClosedFormShape{std::move(m)});
1775 }
1776
1777 return std::nullopt;
1778}
1779
1780} // namespace provsql
1781
1782extern "C" {
1783
1784/**
1785 * @brief SQL: rv_support(token uuid, prov uuid, OUT lo float8, OUT hi float8)
1786 *
1787 * Loads the persisted circuit rooted at @p token, intersects with the
1788 * AND-conjunct cmps in @p prov constraining @p token, and returns the
1789 * resulting @c [lo, hi] support interval. When @p prov resolves to
1790 * @c gate_one (the unconditional default after load-time
1791 * simplification), the conditional path is skipped and the bare
1792 * unconditional support of @p token is returned.
1793 *
1794 * @c -Infinity / @c +Infinity float8 represent unbounded ends (e.g.
1795 * the support of a normal RV is @c [-Infinity, +Infinity]).
1796 */
1797Datum rv_support(PG_FUNCTION_ARGS)
1798{
1799 try {
1800 pg_uuid_t *token = PG_GETARG_UUID_P(0);
1801 pg_uuid_t *prov = PG_GETARG_UUID_P(1);
1802
1803 gate_t root_gate, event_gate;
1804 auto gc = getJointCircuit(*token, *prov, root_gate, event_gate);
1805
1806 /* gate_one as event-side means the conditioning is the trivial
1807 * "always true" event (either the user passed gate_one() directly
1808 * or load-time simplification collapsed the event to it). Take
1809 * the unconditional path. */
1810 std::optional<gate_t> event_opt;
1811 if (gc.getGateType(event_gate) != gate_one)
1812 event_opt = event_gate;
1813
1814 /* A stored "X | C" arrives here as a conditioned root: peel it to the
1815 * bare target and fold the condition into the event, so the support is
1816 * the conditional (truncated) one rather than the unconditional. */
1817 root_gate = provsql::lift_conditioning(gc, root_gate, event_opt);
1818
1819 auto iv = provsql::compute_support(gc, root_gate, event_opt);
1820
1821 TupleDesc tupdesc;
1822 Datum values[2];
1823 bool nulls[2] = {false, false};
1824
1825 if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE)
1826 provsql_error("rv_support: expected composite return type");
1827 tupdesc = BlessTupleDesc(tupdesc);
1828
1829 values[0] = Float8GetDatum(iv.first);
1830 values[1] = Float8GetDatum(iv.second);
1831
1832 PG_RETURN_DATUM(HeapTupleGetDatum(heap_form_tuple(tupdesc, values, nulls)));
1833 } catch (const std::exception &e) {
1834 provsql_error("rv_support: %s", e.what());
1835 } catch (...) {
1836 provsql_error("rv_support: unknown exception");
1837 }
1838 PG_RETURN_NULL();
1839}
1840
1841} // extern "C"
ComparisonOperator cmpOpFromOid(Oid op_oid, bool &ok)
Map a PostgreSQL comparison-operator OID to a ComparisonOperator.
AggregationOperator getAggregationOperator(Oid oid)
Map a PostgreSQL aggregate function OID to an AggregationOperator.
Typed aggregation value, operator, and aggregator abstractions.
AggregationOperator
SQL aggregation functions tracked by ProvSQL.
Definition Aggregation.h:51
@ MAX
MAX → input type.
Definition Aggregation.h:55
@ COUNT
COUNT(*) or COUNT(expr) → integer.
Definition Aggregation.h:52
@ SUM
SUM → integer or float.
Definition Aggregation.h:53
@ MIN
MIN → input type.
Definition Aggregation.h:54
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.
static CircuitCache cache
Process-local singleton circuit gate cache.
GenericCircuit getJointCircuit(const std::vector< pg_uuid_t > &tokens, std::vector< gate_t > &gates)
Multi-root variant of getJointCircuit.
Build in-memory circuits from the mmap-backed persistent store.
gate_t
Strongly-typed gate identifier.
Definition Circuit.h:49
Exact conjugate-prior posteriors for observe-evidence circuits.
Per-family polymorphic view over a continuous gate_rv distribution (§F.1 class hierarchy).
Analytical expectation / variance / moment evaluator over RV circuits.
Continuous random-variable helpers (distribution parsing, moments).
Datum rv_support(PG_FUNCTION_ARGS)
SQL: rv_support(token uuid, prov uuid, OUT lo float8, OUT hi float8).
Support-based bound check for continuous-RV comparators.
iterator end()
Past-the-end iterator for the cache.
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 resolveGateToZero(gate_t g)
Replace an arbitrary gate (typically gate_times) by gate_zero.
void resolveCmpToPlusOfKGates(gate_t g, const std::vector< gate_t > &ks)
Replace a gate_cmp by a gate_plus over the given per-row K-gates (the OR of the agg's row-presence in...
bool isCategoricalMixture(gate_t g) const
Test whether g is a categorical-form gate_mixture (the explicit provsql.categorical output).
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...
std::pair< unsigned, unsigned > getInfos(gate_t g) const
Return the integer annotation pair for gate g.
std::pair< double, double > compute_support(const GenericCircuit &gc, gate_t root, std::optional< gate_t > event_root)
Compute the [lo, hi] support interval of a scalar sub-circuit rooted at root.
static std::optional< ClosedFormShape > truncateShape(const ClosedFormShape &s, double lo, double hi)
Conditional shape after truncating the underlying variable to [lo, hi].
std::optional< ClosedFormShape > matchClosedFormDistribution(const GenericCircuit &gc, gate_t root, std::optional< gate_t > event_root)
Detect any of the closed-form shapes supported by rv_analytical_curves.
std::variant< TruncatedSingleRv, DiracShape, CategoricalShape, BernoulliMixtureShape > ClosedFormShape
One of the closed-form shapes the analytical-curves payload can render: bare RV (continuous PDF/CDF),...
Definition RangeCheck.h:201
gate_t lift_conditioning(GenericCircuit &gc, gate_t root, std::optional< gate_t > &event_opt)
Lift conditioning out of a scalar arithmetic expression.
static bool extract_mulinput_value(const GenericCircuit &gc, gate_t mul, double &out)
Same parsing applied to a mulinput's outcome label (categorical).
static bool extract_finite_double(const GenericCircuit &gc, gate_t x, double &out)
Parse a gate_value's extra as a finite float8.
double parseDoubleStrict(const std::string &s)
Strictly parse s as a double.
unsigned runRangeCheck(GenericCircuit &gc)
Run the support-based pruning pass over gc.
bool eventIsProvablyInfeasible(const GenericCircuit &gc, gate_t root, std::optional< gate_t > event_root)
True iff the conditioning event is provably infeasible for a bare gate_rv root.
std::unique_ptr< Distribution > makeDistribution(const DistributionSpec &spec)
Construct the per-family Distribution for a parsed spec.
static std::optional< double > shape_mass(const ClosedFormShape &s, double lo, double hi)
Unconditional probability mass of a shape over the interval [lo, hi].
std::optional< DistributionSpec > conjugatePosterior(const GenericCircuit &gc, gate_t target, gate_t evidence)
The exact posterior of target given evidence, as a resolved distribution spec, when the circuit match...
std::optional< DistributionSpec > parse_distribution_spec(const std::string &s)
Parse the on-disk text encoding of a gate_rv distribution.
std::optional< std::pair< double, double > > collectRvConstraints(const GenericCircuit &gc, gate_t event_root, gate_t target_rv)
Walk event_root collecting rv op c constraints on target_rv.
std::optional< DistributionTemplate > parse_distribution_template(const std::string &s)
Parse the on-disk text encoding of a gate_rv distribution, keeping wired (token) parameters as wire r...
std::optional< TruncatedSingleRv > matchTruncatedSingleRv(const GenericCircuit &gc, gate_t root, std::optional< gate_t > event_root)
Detect a closed-form, optionally-truncated single-RV shape.
double cdfAt(const DistributionSpec &d, double c)
Closed-form CDF for a basic continuous distribution.
unsigned runHavingAlwaysTrueRewriter(GenericCircuit &gc)
Probability-side pre-pass: rewrite HAVING-style gate_cmp gates that are provably TRUE on the agg's va...
Uniform error-reporting macros for ProvSQL.
#define provsql_error(fmt,...)
Report a fatal ProvSQL error and abort the current transaction.
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_case
N-ary guarded selection over scalar (RV) children: wires are [guard_1, value_1, .....
@ 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)
#define PROVSQL_AGG_SCALAR_FLAG
Scalar-aggregation flag, stored in the upper bit of a gate_agg's info2 (whose low 31 bits hold the ag...
C++ utility functions for UUID manipulation.
UUID structure.
Bernoulli mixture (gate_mixture with the [p_token, x_token, y_token] shape).
Definition RangeCheck.h:218
std::shared_ptr< ClosedFormShape > right
Definition RangeCheck.h:221
std::shared_ptr< ClosedFormShape > left
Definition RangeCheck.h:220
Categorical distribution over a finite outcome set.
Definition RangeCheck.h:189
std::vector< std::pair< double, double > > outcomes
(value, mass) pairs
Definition RangeCheck.h:190
Point mass at a finite scalar value (a gate_value root, or an as_random(c) leaf surfaced as a gate_va...
Definition RangeCheck.h:173
A closed support interval [lo, hi] (±infinity for unbounded).
Detection result for a closed-form, optionally-truncated single-RV shape.
Definition RangeCheck.h:102