ProvSQL C/C++ API
Adding support for provenance and uncertainty management to PostgreSQL databases
Loading...
Searching...
No Matches
Expectation.cpp
Go to the documentation of this file.
1/**
2 * @file Expectation.cpp
3 * @brief Implementation of the analytical expectation / variance / moment
4 * evaluator over scalar RV sub-circuits.
5 */
6#include "Expectation.h"
7
8#include "AggMarginalEvaluator.h" // aggAvgRawMomentExact
9#include "AnalyticEvaluator.h"
10#include "Aggregation.h" // ComparisonOperator + cmpOpFromOid
11#include "BooleanCircuit.h"
12#include "Circuit.h"
13#include "CircuitFromMMap.h"
14#include "CollapsedAggMoment.h" // collapsedConditionalMoment
15#include "ComparatorResolution.h" // resolveComparators
16#include "ConjugatePosterior.h" // conjugatePosterior / conjugateLogEvidence
17#include "ProbabilityMethod.h" // booleanSubcircuitProbability
18#include "MonteCarloSampler.h"
19#include "RandomVariable.h"
20#include "distributions/Distribution.h" // makeDistribution -> integrationRange
21#include "PivotIntegration.h" // simpsonIntegrate / binomial / centralFromRaw
22#include "RangeCheck.h"
23#include "provsql_utils_cpp.h"
24#include "semiring/BoolExpr.h"
25
26extern "C" {
27#include "postgres.h"
28#include "fmgr.h"
29#include "utils/uuid.h"
30#include "provsql_utils.h"
31#include "provsql_error.h"
32
33PG_FUNCTION_INFO_V1(rv_moment);
34PG_FUNCTION_INFO_V1(rv_quantile);
35PG_FUNCTION_INFO_V1(rv_evidence);
36PG_FUNCTION_INFO_V1(agg_avg_moment_exact);
37}
38
39#include <algorithm>
40#include <cmath>
41#include <set>
42#include <stack>
43#include <string>
44#include <unordered_map>
45#include <unordered_set>
46#include <vector>
47
48namespace provsql {
49
50namespace {
51/// Copy the subtree rooted at @p root of @p gc into a fresh GenericCircuit
52/// @p sub (mirrors @c MMappedCircuit::createGenericCircuit, but from an
53/// in-memory circuit). Lets the moment path resolve a mixture selector's
54/// comparators in isolation -- without mutating the surrounding moment
55/// circuit, whose gate_case guards and native RV comparisons must keep
56/// their raw comparator structure.
57void extractSubcircuit(const GenericCircuit &gc, gate_t root,
58 GenericCircuit &sub, gate_t &sub_root)
59{
60 std::unordered_set<gate_t> seen;
61 std::stack<gate_t> stk;
62 stk.push(root);
63 while (!stk.empty()) {
64 gate_t g = stk.top(); stk.pop();
65 if (!seen.insert(g).second) continue;
66 const std::string u = gc.getUUID(g);
67 const gate_type t = gc.getGateType(g);
68 const gate_t id = sub.setGate(u, t);
69 const double pr = gc.getProb(g);
70 if (!std::isnan(pr)) sub.setProb(id, pr);
71 if (t == gate_mulinput || t == gate_eq || t == gate_agg || t == gate_cmp
72 || t == gate_arith) {
73 const auto infos = gc.getInfos(g);
74 sub.setInfos(id, infos.first, infos.second);
75 } else if (t == gate_plus || t == gate_times) {
76 const auto infos = gc.getInfos(g);
77 if (infos.first != 0 || infos.second != 0)
78 sub.setInfos(id, infos.first, infos.second);
79 }
80 if (t == gate_project || t == gate_value || t == gate_agg || t == gate_rv
81 || t == gate_mulinput || t == gate_annotation || t == gate_assumed
82 || t == gate_mobius || t == gate_arith || t == gate_observe)
83 sub.setExtra(id, gc.getExtra(g));
84 for (gate_t c : gc.getWires(g)) {
85 sub.addWire(id, sub.getGate(gc.getUUID(c)));
86 stk.push(c);
87 }
88 }
89 sub_root = sub.getGate(gc.getUUID(root));
90}
91} // namespace
92
94{
95 // Route through the single central Boolean-probability entry point
96 // (getBooleanCircuit + MethodCatalog::chooseAndRun, MC fallback), rather
97 // than a hand-rolled Boolean build + independentEvaluation. Any RV
98 // comparator in this Boolean function is resolved on an extracted COPY of
99 // the boolRoot subtree, so the resolution never touches the surrounding
100 // moment circuit (whose gate_case guards / native RV comparisons must
101 // keep their raw comparator for the correlation-aware value moment).
102 GenericCircuit sub;
103 gate_t sub_root;
104 extractSubcircuit(gc, boolRoot, sub, sub_root);
105 resolveComparators(sub, sub_root, /*simplify=*/false, /*decompose=*/false);
106 return booleanSubcircuitProbability(sub, sub_root);
107}
108
109namespace {
110
111using RvSet = std::set<gate_t>;
112
113/// Mixing weight π = P(p = true) for a mixture's Bernoulli wire.
114/// For a bare @c gate_input, the probability is the leaf's pinned
115/// @c set_prob; for any compound Boolean gate, defer to
116/// @c evaluateBooleanProbability.
117double mixturePi(const GenericCircuit &gc, gate_t p)
118{
119 return (gc.getGateType(p) == gate_input)
120 ? gc.getProb(p)
122}
123
124/// True iff @p g is a @c gate_rv whose distribution has a wired (token)
125/// parameter -- a latent / compound leaf with no constant-parameter
126/// closed form, so every analytic path must fall through to Monte Carlo.
127bool rvIsParametric(const GenericCircuit &gc, gate_t g)
128{
129 if (gc.getGateType(g) != gate_rv) return false;
130 auto tmpl = parse_distribution_template(gc.getExtra(g));
131 return tmpl && tmpl->parametric();
132}
133
134/// Whether the family of @p tmpl has a mean affine in its parameters, so the
135/// compound-leaf expectation is exact via @c mean(E[θ]) (see the
136/// @c meanIsAffine() doc). The flag is authoritative, per-family.
137bool familyMeanIsAffine(const DistributionTemplate &tmpl)
138{
139 return tmpl.family->factory(0.0, 0.0)->meanIsAffine();
140}
141
142/// Cache of the base-@c gate_rv UUID footprints reachable below each
143/// scalar gate, used as the structural-independence witness. Two
144/// children of an arithmetic gate are independent iff their footprints
145/// are disjoint -- and therefore the variance and TIMES expectation
146/// shortcuts apply.
147class FootprintCache {
148public:
149 explicit FootprintCache(const GenericCircuit &gc) : gc_(gc) {}
150
151 const RvSet &of(gate_t g) {
152 auto it = cache_.find(g);
153 if (it != cache_.end()) return it->second;
154 RvSet s;
155 auto type = gc_.getGateType(g);
156 if (type == gate_rv) {
157 // The leaf itself is a distinct random source (two independent
158 // normal(M,1) leaves share M but draw independently given it), so
159 // its own gate_t is always in the footprint. A latent (parametric)
160 // leaf ALSO carries the footprints of its parameter wires, so two
161 // leaves sharing a latent M overlap on M and are correctly flagged
162 // dependent -- defeating the closed-form independence shortcuts and
163 // routing the whole expression to the MC path that couples them.
164 // A non-parametric leaf has no wires, so this is a no-op there.
165 s.insert(g);
166 for (gate_t c : gc_.getWires(g)) {
167 const auto &cs = of(c);
168 s.insert(cs.begin(), cs.end());
169 }
170 } else if (type == gate_value) {
171 // empty -- no RV reached
172 } else if (type == gate_arith || type == gate_case) {
173 // gate_arith: all children are scalar operands. gate_case: the
174 // selected value depends on both the guards (which RVs they compare)
175 // and the value branches, so the footprint is the union over every
176 // wire -- a value and a guard sharing a leaf make the selection
177 // correlated, which must defeat the independence shortcuts.
178 for (gate_t c : gc_.getWires(g)) {
179 const auto &cs = of(c);
180 s.insert(cs.begin(), cs.end());
181 }
182 } else if (type == gate_mixture) {
183 const auto &wires = gc_.getWires(g);
184 if (gc_.isCategoricalMixture(g)) {
185 // Categorical-form mixture. Footprint = union of the
186 // mulinputs' footprints (each contributes {self, key} via the
187 // mulinput branch below), so two categoricals sharing a key
188 // overlap on it and are correctly flagged dependent.
189 for (std::size_t i = 1; i < wires.size(); ++i) {
190 const auto &fm = of(wires[i]);
191 s.insert(fm.begin(), fm.end());
192 }
193 } else if (wires.size() == 3) {
194 // Classic 3-wire mixture. Footprint = footprint(p) ∪
195 // footprint(x) ∪ footprint(y). The Boolean wire is included
196 // as a discrete random source so two mixtures whose p's share
197 // an atom are correctly recognised as dependent (their branch
198 // selection is correlated), bypassing the closed-form
199 // independence shortcut. Recursing into wires[0] (rather than
200 // inserting its gate_t directly) generalises that recognition
201 // from bare-input Bernoullis to compound Boolean wires.
202 const auto &fp = of(wires[0]);
203 s.insert(fp.begin(), fp.end());
204 const auto &fx = of(wires[1]);
205 s.insert(fx.begin(), fx.end());
206 const auto &fy = of(wires[2]);
207 s.insert(fy.begin(), fy.end());
208 }
209 } else if (type == gate_mulinput) {
210 // A mulinput is a state-carrying atom (so its own gate_t is in
211 // its footprint -- two mulinputs of the same group are distinct
212 // atoms even though they share a key) *and* references a shared
213 // key gate at wires[0] (whose footprint is added so the shared
214 // key makes the two mulinputs overlap on it, flagging them as
215 // dependent for pairwise_disjoint).
216 s.insert(g);
217 const auto &wires = gc_.getWires(g);
218 if (!wires.empty()) {
219 const auto &fk = of(wires[0]);
220 s.insert(fk.begin(), fk.end());
221 }
222 } else if (type == gate_input) {
223 // Atomic Boolean leaf. Use the gate's own UUID as its footprint
224 // so two Boolean expressions sharing this input collide on it.
225 s.insert(g);
226 } else if (type == gate_plus || type == gate_times || type == gate_monus
227 || type == gate_project || type == gate_eq
228 || type == gate_cmp || type == gate_update
229 || type == gate_annotation || type == gate_conditioned) {
230 // Boolean gates: footprint is the union of children's footprints.
231 // Lets a compound `p` wire feeding a mixture propagate its atom
232 // dependencies to FootprintCache for the disjoint-children
233 // shortcuts in rec_expectation / rec_variance / rec_raw_moment.
234 for (gate_t c : gc_.getWires(g)) {
235 const auto &cs = of(c);
236 s.insert(cs.begin(), cs.end());
237 }
238 } else if (type == gate_zero || type == gate_one) {
239 // Empty footprint -- a constant-true / constant-false Boolean
240 // contributes no shared atoms.
241 } else {
242 // Unknown scalar gate type: return an empty footprint. Callers
243 // will trip the analytical-decomposition switch and route the
244 // gate to the MC fallback (or raise if the fallback is disabled),
245 // which is the right behaviour for any unanticipated leaf shape.
246 }
247 return cache_.emplace(g, std::move(s)).first->second;
248 }
249
250private:
251 const GenericCircuit &gc_;
252 std::unordered_map<gate_t, RvSet> cache_;
253};
254
255bool pairwise_disjoint(FootprintCache &fp, const std::vector<gate_t> &children)
256{
257 RvSet seen;
258 for (gate_t c : children) {
259 const auto &fpc = fp.of(c);
260 for (gate_t r : fpc) {
261 if (!seen.insert(r).second) return false;
262 }
263 }
264 return true;
265}
266
267/* Closed-form E[max] / E[min] of a gate_arith MAX/MIN whose children are
268 * independent bare gate_rv leaves of the SAME family with the SAME parameters
269 * (the i.i.d. order statistic). Returns std::nullopt when the shape is not
270 * i.i.d. bare-RV (mixture-wrapped aggregate children, mixed families, shared
271 * leaves, or a family without an elementary order-statistic mean) so the
272 * caller falls back to Monte Carlo.
273 *
274 * The per-family closed forms live in @c Distribution::iidOrderStatMean
275 * (Uniform, Exponential; Normal and Erlang i.i.d. maxima have no elementary
276 * closed form -- they need the 1-D order-statistic quadrature -- so they
277 * decline there).
278 */
279std::optional<double>
280iidOrderStatMean(const GenericCircuit &gc, gate_t g, bool isMax,
281 FootprintCache &fp)
282{
283 const auto &raw_wires = gc.getWires(g);
284 if (raw_wires.empty())
285 return std::nullopt;
286 /* Idempotence: max / min ignore repeats, so a child appearing more than
287 * once (the same gate) counts once. De-duplicating here generalises the
288 * closed form to any MAX/MIN gate -- without it a repeated child would
289 * collide with itself in pairwise_disjoint and silently drop to MC. */
290 std::vector<gate_t> wires;
291 wires.reserve(raw_wires.size());
292 {
293 std::set<gate_t> seen;
294 for (gate_t c : raw_wires)
295 if (seen.insert(c).second)
296 wires.push_back(c);
297 }
298 if (!pairwise_disjoint(fp, wires))
299 return std::nullopt; /* shared leaves -> correlated -> MC */
300
301 std::vector<DistributionSpec> specs;
302 specs.reserve(wires.size());
303 for (gate_t c : wires) {
304 if (gc.getGateType(c) != gate_rv)
305 return std::nullopt; /* not a bare RV (e.g. a mixture wrap) */
306 auto s = parse_distribution_spec(gc.getExtra(c));
307 if (!s)
308 return std::nullopt;
309 specs.push_back(*s);
310 }
311
312 /* i.i.d.: identical family and parameters across all children (the
313 * family descriptor is interned, so pointer comparison is family
314 * identity). */
315 for (std::size_t i = 1; i < specs.size(); ++i)
316 if (specs[i].family != specs[0].family ||
317 specs[i].p1 != specs[0].p1 || specs[i].p2 != specs[0].p2)
318 return std::nullopt;
319
320 return makeDistribution(specs[0])->iidOrderStatMean(specs.size(), isMax);
321}
322
323/* Closed-form (quadrature) E[max] / E[min] of a gate_arith MAX/MIN whose
324 * children are independent bare gate_rv leaves of *any* families (mixed or
325 * not-identical), generalising @c iidOrderStatMean. Uses the layer-cake
326 * identity over a window [lo, hi] covering every child's support:
327 * E[max] = lo + ∫ (1 − ∏ F_i(t)) dt, E[min] = lo + ∫ ∏ (1 − F_i(t)) dt.
328 * Composite Simpson. std::nullopt on shared leaves, a non-bare-RV child, or
329 * a distribution whose CDF is undefined (e.g. non-integer Erlang), so the
330 * caller falls back to Monte Carlo. */
331std::optional<double>
332mixedOrderStatMean(const GenericCircuit &gc, gate_t g, bool isMax,
333 FootprintCache &fp)
334{
335 const auto &raw_wires = gc.getWires(g);
336 if (raw_wires.empty())
337 return std::nullopt;
338 std::vector<gate_t> wires;
339 {
340 std::set<gate_t> seen;
341 for (gate_t c : raw_wires)
342 if (seen.insert(c).second)
343 wires.push_back(c);
344 }
345 if (!pairwise_disjoint(fp, wires))
346 return std::nullopt;
347
348 // Construct each child's Distribution once; the Simpson loop below calls
349 // cdf on them per point (never re-constructing per point).
350 std::vector<std::unique_ptr<Distribution>> dists;
351 double lo = 0.0, hi = 0.0;
352 bool first = true;
353 for (gate_t c : wires) {
354 if (gc.getGateType(c) != gate_rv)
355 return std::nullopt;
356 auto s = parse_distribution_spec(gc.getExtra(c));
357 if (!s)
358 return std::nullopt;
359 auto d = makeDistribution(*s);
360 double clo, chi;
361 if (!d->integrationRange(clo, chi))
362 return std::nullopt;
363 if (first) { lo = clo; hi = chi; first = false; }
364 else { lo = std::min(lo, clo); hi = std::max(hi, chi); }
365 dists.push_back(std::move(d));
366 }
367 if (!(hi > lo))
368 return std::nullopt;
369
370 const double integral = simpsonIntegrate(lo, hi, kSimpsonPanels,
371 [&](double t) {
372 if (isMax) {
373 double prodF = 1.0; /* ∏ F_i(t) = P(max ≤ t) */
374 for (const auto &d : dists) {
375 const double F = d->cdf(t);
376 if (std::isnan(F)) return std::numeric_limits<double>::quiet_NaN();
377 prodF *= F;
378 }
379 return 1.0 - prodF; /* P(max > t) */
380 }
381 double prod1mF = 1.0; /* ∏ (1 − F_i(t)) = P(min > t) */
382 for (const auto &d : dists) {
383 const double F = d->cdf(t);
384 if (std::isnan(F)) return std::numeric_limits<double>::quiet_NaN();
385 prod1mF *= (1.0 - F);
386 }
387 return prod1mF;
388 });
389 if (std::isnan(integral))
390 return std::nullopt;
391 return lo + integral;
392}
393
394unsigned mc_samples_or_throw(const std::string &what)
395{
396 const int n = provsql_rv_mc_samples;
397 if (n <= 0) {
398 throw CircuitException(
399 what + " could not be decomposed analytically and "
400 "provsql.rv_mc_samples = 0 disables the Monte Carlo fallback");
401 }
402 // Transparency: the analytic moment surface is about to return a Monte Carlo
403 // ESTIMATE, not a closed-form moment. Signal it (at the same verbose>=5
404 // evaluation tier as the probability-side approximation NOTICEs, so Studio
405 // and verbose users can tell an estimate from an exact value) -- the
406 // continuous-RV surface is approximate by nature, but never *silently* so.
407 // Set provsql.rv_mc_samples = 0 to require an exact result instead.
408 if (provsql_verbose >= 5)
410 "%s: no closed form found; estimating by Monte Carlo over %d samples "
411 "(an approximation, not an exact moment) -- set provsql.rv_mc_samples = 0 "
412 "to require an exact result instead", what.c_str(), n);
413 return static_cast<unsigned>(n);
414}
415
416double mc_raw_moment(const GenericCircuit &gc, gate_t g, unsigned k,
417 const std::string &what)
418{
419 auto samples = monteCarloScalarSamples(gc, g, mc_samples_or_throw(what));
420 if (samples.empty()) return 0.0;
421 // NaN samples come from sampling-undefined worlds, e.g. an
422 // agg(SUM/AVG/MIN/MAX) over an empty group (SQL NULL). Treat them
423 // as missing observations of the moment rather than poisoning the
424 // mean; only return NaN if every sample was undefined.
425 double total = 0.0;
426 std::size_t finite_count = 0;
427 for (double x : samples) {
428 if (std::isnan(x)) continue;
429 total += std::pow(x, static_cast<double>(k));
430 ++finite_count;
431 }
432 if (finite_count == 0) return std::numeric_limits<double>::quiet_NaN();
433 return total / static_cast<double>(finite_count);
434}
435
436double mc_central_moment(const GenericCircuit &gc, gate_t g, unsigned k,
437 double mu, const std::string &what)
438{
439 auto samples = monteCarloScalarSamples(gc, g, mc_samples_or_throw(what));
440 if (samples.empty()) return 0.0;
441 double total = 0.0;
442 std::size_t finite_count = 0;
443 for (double x : samples) {
444 if (std::isnan(x)) continue;
445 const double d = x - mu;
446 total += std::pow(d, static_cast<double>(k));
447 ++finite_count;
448 }
449 if (finite_count == 0) return std::numeric_limits<double>::quiet_NaN();
450 return total / static_cast<double>(finite_count);
451}
452
453/// Minimum accepted-sample count for conditional MC moments. Below
454/// this floor we'd be reporting a moment from a handful of accepted
455/// draws and the variance of the estimator would be enormous; raise
456/// rather than silently return a noisy number.
457unsigned min_accepted_floor(unsigned attempted)
458{
459 unsigned floor = attempted / 1000;
460 return floor < 5 ? 5 : floor;
461}
462
463void check_acceptance_or_throw(const ConditionalScalarSamples &cs,
464 const std::string &what)
465{
466 if (cs.accepted.empty()) {
467 /* 0-of-N accepted is the unmistakable signature of an infeasible
468 * conditioning event: raising rv_mc_samples cannot help (the
469 * acceptance probability is exactly 0). Surface that directly
470 * rather than the generic "raise samples or check satisfiability"
471 * advice that applies to merely under-sampled events. */
472 throw CircuitException(
473 what + ": conditioning event is infeasible (0 of " +
474 std::to_string(cs.attempted) +
475 " Monte Carlo samples satisfied it)");
476 }
477 const unsigned floor = min_accepted_floor(cs.attempted);
478 if (cs.accepted.size() < floor) {
479 throw CircuitException(
480 what + ": conditional MC accepted only " +
481 std::to_string(cs.accepted.size()) + " out of " +
482 std::to_string(cs.attempted) +
483 " samples (need >= " + std::to_string(floor) +
484 "); raise provsql.rv_mc_samples or tighten the event.");
485 }
486}
487
488double mc_conditional_raw_moment(const GenericCircuit &gc, gate_t g,
489 unsigned k, gate_t event_root,
490 const std::string &what)
491{
493 gc, g, event_root, mc_samples_or_throw(what));
494 check_acceptance_or_throw(cs, what);
495 // Mirror the unconditional path: NaN observations (sampling-
496 // undefined worlds, typically empty-group SQL NULLs from
497 // gate_agg) are excluded from the mean.
498 double total = 0.0;
499 std::size_t finite_count = 0;
500 for (double x : cs.accepted) {
501 if (std::isnan(x)) continue;
502 total += std::pow(x, static_cast<double>(k));
503 ++finite_count;
504 }
505 if (finite_count == 0) return std::numeric_limits<double>::quiet_NaN();
506 return total / static_cast<double>(finite_count);
507}
508
509double mc_conditional_central_moment(const GenericCircuit &gc, gate_t g,
510 unsigned k, double mu,
511 gate_t event_root,
512 const std::string &what)
513{
515 gc, g, event_root, mc_samples_or_throw(what));
516 check_acceptance_or_throw(cs, what);
517 double total = 0.0;
518 std::size_t finite_count = 0;
519 for (double x : cs.accepted) {
520 if (std::isnan(x)) continue;
521 const double d = x - mu;
522 total += std::pow(d, static_cast<double>(k));
523 ++finite_count;
524 }
525 if (finite_count == 0) return std::numeric_limits<double>::quiet_NaN();
526 return total / static_cast<double>(finite_count);
527}
528
529double rec_expectation(const GenericCircuit &gc, gate_t g, FootprintCache &fp);
530double rec_variance(const GenericCircuit &gc, gate_t g, FootprintCache &fp);
531double rec_raw_moment(const GenericCircuit &gc, gate_t g, unsigned k,
532 FootprintCache &fp);
533
534/* -----------------------------------------------------------------------
535 * Likelihood-weighting (importance-sampling) posterior readouts.
536 *
537 * When the conditioning event is continuous-density evidence (it contains
538 * a gate_observe), the analytic / rejection conditional paths do not apply:
539 * draw latents from the prior and weight each draw by the observations'
540 * densities, then report the weighted posterior statistic. These helpers
541 * turn one importance-sampling pass (WeightedPosterior) into a posterior
542 * raw moment / central moment / quantile.
543 * -------------------------------------------------------------------- */
544
545/* Self-normalised weighted raw moment Σ w x^k / Σ w. NaN particle values
546 * (sampling-undefined worlds, e.g. an empty-group aggregate) are skipped,
547 * mirroring the unconditional MC path. */
548double weightedRawMoment(const WeightedPosterior &post, unsigned k)
549{
550 double sw = 0.0, swx = 0.0;
551 for (const auto &[x, w] : post.particles) {
552 if (std::isnan(x)) continue;
553 sw += w;
554 swx += w * std::pow(x, static_cast<double>(k));
555 }
556 if (sw <= 0.0) return std::numeric_limits<double>::quiet_NaN();
557 return swx / sw;
558}
559
560/* Self-normalised weighted central moment Σ w (x-mu)^k / Σ w. */
561double weightedCentralMoment(const WeightedPosterior &post, unsigned k,
562 double mu)
563{
564 double sw = 0.0, swd = 0.0;
565 for (const auto &[x, w] : post.particles) {
566 if (std::isnan(x)) continue;
567 sw += w;
568 swd += w * std::pow(x - mu, static_cast<double>(k));
569 }
570 if (sw <= 0.0) return std::numeric_limits<double>::quiet_NaN();
571 return swd / sw;
572}
573
574/* Weighted empirical p-quantile: sort particles by value, walk the
575 * cumulative weight, linearly interpolate at p·(Σw) (the percentile_cont
576 * convention generalised to weights). */
577double weightedQuantile(WeightedPosterior post, double p)
578{
579 auto &pts = post.particles;
580 pts.erase(std::remove_if(pts.begin(), pts.end(),
581 [](const std::pair<double, double> &pw) {
582 return std::isnan(pw.first);
583 }),
584 pts.end());
585 if (pts.empty()) return std::numeric_limits<double>::quiet_NaN();
586 std::sort(pts.begin(), pts.end(),
587 [](const auto &a, const auto &b) { return a.first < b.first; });
588 double total = 0.0;
589 for (const auto &pw : pts) total += pw.second;
590 if (!(total > 0.0)) return pts.front().first;
591 const double target = p * total;
592 double cum = 0.0;
593 for (std::size_t i = 0; i < pts.size(); ++i) {
594 const double prev = cum;
595 cum += pts[i].second;
596 if (cum >= target) {
597 if (i == 0) return pts[0].first;
598 /* Linear interpolation between the two straddling particles at their
599 * cumulative-weight midpoints (matches the empirical MC quantile). */
600 const double frac = (pts[i].second > 0.0)
601 ? (target - prev) / pts[i].second
602 : 0.0;
603 return pts[i - 1].first + frac * (pts[i].first - pts[i - 1].first);
604 }
605 }
606 return pts.back().first;
607}
608
609/* Guard a posterior pass: raise on infeasible (no positive-weight draw)
610 * evidence, and warn when the effective sample size is degenerating. */
611void checkPosteriorOrThrow(const WeightedPosterior &post,
612 const std::string &what)
613{
614 if (post.particles.empty() || post.weight_sum <= 0.0) {
615 throw CircuitException(
616 what + ": evidence is infeasible (no positive-weight draw among " +
617 std::to_string(post.attempted) +
618 " Monte Carlo samples); the observations may contradict the prior, "
619 "or raise provsql.rv_mc_samples");
620 }
621 const double ess = post.effectiveSampleSize();
622 const double nonzero = static_cast<double>(post.particles.size());
623 if (provsql_ess_warn_fraction > 0.0 &&
624 ess < provsql_ess_warn_fraction * nonzero) {
626 "%s: posterior effective sample size low (%.1f of %u accepted); "
627 "likelihood weighting is degenerating -- raise provsql.rv_mc_samples, "
628 "or the model has many observations per latent (defer to SMC)",
629 what.c_str(), ess, static_cast<unsigned>(post.particles.size()));
630 }
631}
632
633/**
634 * @brief Try to evaluate @f$E[X^k \mid A]@f$ in closed form.
635 *
636 * Fires only when @p root is a bare @c gate_rv whose family has a
637 * closed-form truncated moment (@c Distribution::truncatedRawMoment)
638 * and the event walk under @p event_root collects a sound interval
639 * constraint on it.
640 * Otherwise returns @c std::nullopt and the caller falls through to
641 * MC rejection.
642 *
643 * For @p central, returns @f$E[(X - \mu_A)^k \mid A]@f$ where
644 * @f$\mu_A@f$ is the closed-form conditional mean obtained by
645 * recursing on @c k = 1, then binomially expanding the central
646 * moment in terms of the raw moments.
647 */
648std::optional<double>
649try_truncated_closed_form(const GenericCircuit &gc, gate_t root,
650 gate_t event_root, unsigned k, bool central)
651{
652 auto m = matchTruncatedSingleRv(gc, root, event_root);
653 if (!m) return std::nullopt;
654 const double lo = m->lo, hi = m->hi;
655
656 /* Closed-form raw moment of the truncated distribution; constructed
657 * once, then queried per moment order. A family without a closed
658 * form (Erlang: needs the regularised lower incomplete gamma) returns
659 * nullopt and the caller falls through to MC. */
660 const auto dist = makeDistribution(m->spec);
661 auto raw = [&](unsigned q) -> std::optional<double> {
662 if (q == 0) return 1.0;
663 return dist->truncatedRawMoment(lo, hi, q);
664 };
665
666 if (!central) return raw(k);
667 /* Central: E[(X - μ_A)^k | A] via the binomial expansion. */
668 return centralFromRaw(k, raw);
669}
670
671/* A conditioning event of the shape @c "X op Y", where the target @c X and the
672 * other operand @c Y are two independent bare @c gate_rv leaves. */
673struct RvVsRvCond {
674 DistributionSpec targetSpec; /* X (the moment's target) */
675 DistributionSpec otherSpec; /* Y */
676 bool targetGreater; /* true for X > Y, false for X < Y */
677};
678
679/* Match @p event_root as a single @c gate_cmp comparing the target @p root
680 * (a bare RV X) with an independent bare RV Y. Returns std::nullopt for any
681 * other shape (constant threshold -- handled by the truncation path;
682 * conjunctions; agg comparisons; shared operand) so the caller falls through. */
683std::optional<RvVsRvCond>
684matchRvVsRvConditional(const GenericCircuit &gc, gate_t root, gate_t event_root)
685{
686 if (gc.getGateType(root) != gate_rv || gc.getGateType(event_root) != gate_cmp)
687 return std::nullopt;
688 auto specX = parse_distribution_spec(gc.getExtra(root));
689 if (!specX) return std::nullopt;
690
691 const auto &wires = gc.getWires(event_root);
692 if (wires.size() != 2) return std::nullopt;
693 bool ok = false;
694 ComparisonOperator op = cmpOpFromOid(gc.getInfos(event_root).first, ok);
695 if (!ok || op == ComparisonOperator::EQ || op == ComparisonOperator::NE)
696 return std::nullopt;
697
698 gate_t other;
699 bool targetLeft;
700 if (wires[0] == root) { other = wires[1]; targetLeft = true; }
701 else if (wires[1] == root) { other = wires[0]; targetLeft = false; }
702 else return std::nullopt; /* target not an operand */
703 if (other == root || gc.getGateType(other) != gate_rv)
704 return std::nullopt; /* X op X, or Y not a bare RV */
705 auto specY = parse_distribution_spec(gc.getExtra(other));
706 if (!specY) return std::nullopt;
707
708 /* targetGreater: does the event assert X > Y? If X is the left operand,
709 * that is op in {GT,GE}; if X is the right operand (event Y op X), it is
710 * op in {LT,LE}. */
711 const bool greaterOp = (op == ComparisonOperator::GT ||
713 const bool lessOp = (op == ComparisonOperator::LT ||
715 bool targetGreater;
716 if (targetLeft) targetGreater = greaterOp;
717 else targetGreater = lessOp;
718 return RvVsRvCond{*specX, *specY, targetGreater};
719}
720
721/* E[X^k | X op Y] for independent X, Y via a 1-D quadrature:
722 * E[X^k | X>Y] = (∫ x^k f_X(x) F_Y(x) dx) / (∫ f_X(x) F_Y(x) dx),
723 * and the X<Y case swaps F_Y for 1-F_Y. Composite Simpson over X's support;
724 * exact for the Uniform-Uniform case (the integrands are low-degree
725 * polynomials), high-accuracy otherwise. Returns NaN if the event mass is
726 * negligible or a density/CDF is undefined. */
727double rvVsRvConditionalMoment(const DistributionSpec &X,
728 const DistributionSpec &Y,
729 bool targetGreater, unsigned k)
730{
731 // Construct both distributions once; the Simpson loop calls pdf/cdf on
732 // them per point (never re-constructing per point).
733 const auto dX = makeDistribution(X);
734 const auto dY = makeDistribution(Y);
735 double lo, hi;
736 if (!dX->integrationRange(lo, hi))
737 return std::numeric_limits<double>::quiet_NaN();
738
739 auto base = [&](double x) {
740 const double fX = dX->pdf(x);
741 const double FY = dY->cdf(x);
742 if (std::isnan(fX) || std::isnan(FY))
743 return std::numeric_limits<double>::quiet_NaN();
744 const double w = targetGreater ? FY : (1.0 - FY); /* P(Y<x) / P(Y>x) */
745 return fX * w;
746 };
747 const double den = simpsonIntegrate(lo, hi, kSimpsonPanels, base);
748 if (std::isnan(den) || !(den > 1e-12))
749 return std::numeric_limits<double>::quiet_NaN();
750 const double num = simpsonIntegrate(lo, hi, kSimpsonPanels,
751 [&](double x) {
752 return std::pow(x, static_cast<double>(k)) * base(x);
753 });
754 if (std::isnan(num))
755 return std::numeric_limits<double>::quiet_NaN();
756 return num / den;
757}
758
759/* Closed-form (quadrature) E[X^k | X op Y] / central moment for an
760 * RV-vs-RV conditioning event. Mirrors @c try_truncated_closed_form for the
761 * constant-threshold case. */
762std::optional<double>
763try_rvVsRv_conditional_moment(const GenericCircuit &gc, gate_t root,
764 gate_t event_root, unsigned k, bool central)
765{
766 auto m = matchRvVsRvConditional(gc, root, event_root);
767 if (!m) return std::nullopt;
768
769 auto raw = [&](unsigned q) -> std::optional<double> {
770 if (q == 0) return 1.0;
771 double r = rvVsRvConditionalMoment(m->targetSpec, m->otherSpec,
772 m->targetGreater, q);
773 if (std::isnan(r)) return std::nullopt;
774 return r;
775 };
776
777 if (!central) return raw(k);
778 return centralFromRaw(k, raw);
779}
780
781/* One comparison of a pivot RV X against another operand, a factor in the
782 * pivot-conjunction integrand. `other` is an independent bare RV (its CDF
783 * weights the integrand) or, when `isConst`, a constant threshold that clips
784 * the integration window. `pivotGreater` is true when the factor asserts
785 * X > operand. */
786struct PivotFactor {
787 bool isConst;
788 DistributionSpec other; /* valid iff !isConst */
789 double konst; /* valid iff isConst */
790 bool pivotGreater;
791};
792
793/* ∫ x^k f_X(x) Π_j W_j(x) dx over X's support, where each RV factor contributes
794 * W_j(x) = F_{Y_j}(x) for X>Y_j and 1-F_{Y_j}(x) for X<Y_j, and each
795 * constant factor clips the window to {x : x>c} / {x : x<c}. With k=0 this is
796 * the joint probability P(∧_j comparisons). Because the comparisons all share
797 * the single pivot X and the other operands are independent, marginalising each
798 * Y_j analytically collapses the joint to this 1-D integral. Composite Simpson
799 * (exact for the polynomial integrands of the Uniform case). Returns NaN if a
800 * density / CDF is undefined. */
801double pivotConjunctionIntegral(const DistributionSpec &X,
802 const std::vector<PivotFactor> &factors,
803 unsigned k)
804{
805 const auto dX = makeDistribution(X);
806 double lo, hi;
807 if (!dX->integrationRange(lo, hi))
808 return std::numeric_limits<double>::quiet_NaN();
809 for (const auto &f : factors)
810 if (f.isConst) {
811 if (f.pivotGreater) lo = std::max(lo, f.konst);
812 else hi = std::min(hi, f.konst);
813 }
814 if (!(hi > lo)) return 0.0;
815
816 std::vector<std::unique_ptr<Distribution>> others;
817 std::vector<bool> greater;
818 for (const auto &f : factors)
819 if (!f.isConst) {
820 others.push_back(makeDistribution(f.other));
821 greater.push_back(f.pivotGreater);
822 }
823
824 return simpsonIntegrate(lo, hi, kSimpsonPanels, [&](double x) {
825 const double fX = dX->pdf(x);
826 if (std::isnan(fX)) return std::numeric_limits<double>::quiet_NaN();
827 double w = fX;
828 for (std::size_t j = 0; j < others.size(); ++j) {
829 const double FY = others[j]->cdf(x);
830 if (std::isnan(FY)) return std::numeric_limits<double>::quiet_NaN();
831 w *= greater[j] ? FY : (1.0 - FY);
832 }
833 return std::pow(x, static_cast<double>(k)) * w;
834 });
835}
836
837/* A conditioning event that is a conjunction of comparisons all sharing the
838 * target bare RV X, each against an independent bare RV or a constant. */
839struct PivotConjunctionCond {
840 DistributionSpec targetSpec;
841 std::vector<PivotFactor> factors;
842};
843
844/* Match @p event_root as a @c gate_times (AND) of >=2 @c gate_cmp, each
845 * comparing the target @p root (bare RV X) with an independent bare RV or a
846 * constant. Returns nullopt for a single comparison (the truncation / rvVsRv
847 * paths own that), an agg comparison, a cmp not involving X, a non-bare other
848 * operand, or a repeated / shared other operand (would break independence). */
849std::optional<PivotConjunctionCond>
850matchPivotConjunctionConditional(const GenericCircuit &gc, gate_t root,
851 gate_t event_root)
852{
853 if (gc.getGateType(root) != gate_rv ||
854 gc.getGateType(event_root) != gate_times)
855 return std::nullopt;
856 auto specX = parse_distribution_spec(gc.getExtra(root));
857 if (!specX) return std::nullopt;
858
859 const auto &kids = gc.getWires(event_root);
860 if (kids.size() < 2) return std::nullopt;
861 std::vector<PivotFactor> factors;
862 std::set<gate_t> othersSeen;
863 for (gate_t c : kids) {
864 if (gc.getGateType(c) != gate_cmp) return std::nullopt;
865 const auto &w = gc.getWires(c);
866 if (w.size() != 2) return std::nullopt;
867 bool ok = false;
868 ComparisonOperator op = cmpOpFromOid(gc.getInfos(c).first, ok);
869 if (!ok || op == ComparisonOperator::EQ || op == ComparisonOperator::NE)
870 return std::nullopt;
871 gate_t other; bool targetLeft;
872 if (w[0] == root) { other = w[1]; targetLeft = true; }
873 else if (w[1] == root) { other = w[0]; targetLeft = false; }
874 else return std::nullopt;
875 const bool greaterOp = (op == ComparisonOperator::GT ||
877 const bool lessOp = (op == ComparisonOperator::LT ||
879 const bool pivotGreater = targetLeft ? greaterOp : lessOp;
880
881 PivotFactor f;
882 if (gc.getGateType(other) == gate_value) {
883 f = {true, DistributionSpec{}, parseDoubleStrict(gc.getExtra(other)),
884 pivotGreater};
885 } else if (gc.getGateType(other) == gate_rv && other != root) {
886 if (!othersSeen.insert(other).second) return std::nullopt;
887 auto specY = parse_distribution_spec(gc.getExtra(other));
888 if (!specY) return std::nullopt;
889 f = {false, *specY, 0.0, pivotGreater};
890 } else return std::nullopt;
891 factors.push_back(std::move(f));
892 }
893 return PivotConjunctionCond{*specX, std::move(factors)};
894}
895
896/* Closed-form (quadrature) E[X^k | ∧_j (X op Y_j)] for a conjunction of
897 * comparisons sharing the target X. E[X^k | A] = I_k / I_0 with
898 * I_q = ∫ x^q f_X Π_j W_j dx; central via the usual binomial expansion. */
899std::optional<double>
900try_pivotConjunction_conditional_moment(const GenericCircuit &gc, gate_t root,
901 gate_t event_root, unsigned k,
902 bool central)
903{
904 auto m = matchPivotConjunctionConditional(gc, root, event_root);
905 if (!m) return std::nullopt;
906
907 const double den = pivotConjunctionIntegral(m->targetSpec, m->factors, 0);
908 if (std::isnan(den) || !(den > 1e-12))
909 return std::nullopt; /* negligible / undefined event -> infeasible or MC */
910 auto raw = [&](unsigned q) -> std::optional<double> {
911 if (q == 0) return 1.0;
912 const double num = pivotConjunctionIntegral(m->targetSpec, m->factors, q);
913 if (std::isnan(num)) return std::nullopt;
914 return num / den;
915 };
916
917 if (!central) return raw(k);
918 return centralFromRaw(k, raw);
919}
920
921/* Closed-form image of a unary LN / EXP transform over a bare gate_rv
922 * child, via the TransformRuleRegistry (exp(normal) is lognormal,
923 * ln(lognormal) is normal). Read-only: unlike the hybrid simplifier's
924 * fold of the same shape, nothing is rewritten, so no shared-RV
925 * identity can be decoupled -- which is why the moment path may use it
926 * even though it deliberately does not run the simplifier. nullptr
927 * when the child is not a bare parseable rv or no rule covers the
928 * family; the caller falls to MC. */
929std::unique_ptr<Distribution> transform_image(const GenericCircuit &gc,
930 gate_t g,
932{
933 const char *transform = op == PROVSQL_ARITH_LN ? "ln"
934 : op == PROVSQL_ARITH_EXP ? "exp"
935 : nullptr;
936 if (!transform) return nullptr;
937 const auto &wires = gc.getWires(g);
938 if (wires.size() != 1 || gc.getGateType(wires[0]) != gate_rv)
939 return nullptr;
940 auto spec = parse_distribution_spec(gc.getExtra(wires[0]));
941 if (!spec) return nullptr;
942 return closeTransform(transform, *makeDistribution(*spec));
943}
944
945/* Closed-form distribution of a TIMES product over bare, pairwise
946 * distinct gate_rv factors (plus gate_value scalars), via the
947 * ProductRuleRegistry -- read-only, like transform_image, so no shared
948 * identity is disturbed. Quantiles need it (they do not factor the way
949 * the disjoint-product moment shortcuts do); nullptr when the shape or
950 * family combination is outside the registered closures. */
951std::unique_ptr<Distribution> product_image(const GenericCircuit &gc,
952 gate_t g)
953{
954 const auto &wires = gc.getWires(g);
955 if (wires.empty()) return nullptr;
956 double c_total = 1.0;
957 std::vector<std::unique_ptr<Distribution>> dists;
958 std::vector<const Distribution *> factors;
959 std::set<gate_t> seen;
960 for (gate_t w : wires) {
961 const auto t = gc.getGateType(w);
962 if (t == gate_value) {
963 try { c_total *= parseDoubleStrict(gc.getExtra(w)); }
964 catch (const CircuitException &) { return nullptr; }
965 continue;
966 }
967 if (t != gate_rv) return nullptr;
968 if (!seen.insert(w).second) return nullptr; /* dependent */
969 auto spec = parse_distribution_spec(gc.getExtra(w));
970 if (!spec) return nullptr;
971 dists.push_back(makeDistribution(*spec));
972 factors.push_back(dists.back().get());
973 }
974 if (factors.empty()) return nullptr;
975 /* A single RV with scalar factors is just the affine image; two or
976 * more dispatch through the product registry. */
977 std::unique_ptr<Distribution> combined;
978 if (factors.size() == 1)
979 combined = std::move(dists.front());
980 else
981 combined = closeProductFactors(factors);
982 if (!combined) return nullptr;
983 if (c_total != 1.0) return combined->scale(c_total);
984 return combined;
985}
986
987/* ------------------------------------------------------------------------
988 * Tier A of the gate_case guard-partition integrator: a CASE that is a
989 * piecewise function of a SINGLE pivot random variable X. Every guard is a
990 * bare gate_cmp comparing X against a constant, and every arm value (and the
991 * default) is affine in X -- a constant, or a*X + b. This is exactly the
992 * abs / ReLU / clamp piecewise-sugar shape.
993 *
994 * Since the whole CASE depends on one RV, E[case^k] is a 1-D integral that
995 * partitions X's support at the guard thresholds: on each sub-interval a
996 * single arm fires (first-match, reproducing MonteCarloSampler's order), and
997 * its affine value's k-th moment over that interval is a binomial combination
998 * of the truncated raw moments
999 * ∫_lo^hi x^j f(x) dx = truncatedRawMoment(lo,hi,j) · (F(hi)-F(lo)).
1000 * Returns std::nullopt when the shape is not single-pivot-affine or the family
1001 * lacks a closed-form truncated moment, so the caller falls back to MC. */
1002
1003struct AffineArm { double a, b; }; /* value = a*X + b */
1004
1005std::optional<AffineArm>
1006affineInPivot(const GenericCircuit &gc, gate_t arm, gate_t pivot)
1007{
1008 const auto t = gc.getGateType(arm);
1009 if (t == gate_value)
1010 return AffineArm{0.0, parseDoubleStrict(gc.getExtra(arm))};
1011 if (t == gate_rv)
1012 return (arm == pivot) ? std::optional<AffineArm>(AffineArm{1.0, 0.0})
1013 : std::nullopt;
1014 if (t == gate_arith) {
1015 const auto op = static_cast<provsql_arith_op>(gc.getInfos(arm).first);
1016 const auto &w = gc.getWires(arm);
1017 auto isPivot = [&](gate_t x) {
1018 return gc.getGateType(x) == gate_rv && x == pivot;
1019 };
1020 auto constVal = [&](gate_t x, double &out) {
1021 if (gc.getGateType(x) != gate_value) return false;
1022 out = parseDoubleStrict(gc.getExtra(x));
1023 return true;
1024 };
1025 double c;
1026 if (op == PROVSQL_ARITH_NEG && w.size() == 1 && isPivot(w[0]))
1027 return AffineArm{-1.0, 0.0};
1028 if (op == PROVSQL_ARITH_PLUS && w.size() == 2) {
1029 if (isPivot(w[0]) && constVal(w[1], c)) return AffineArm{1.0, c};
1030 if (isPivot(w[1]) && constVal(w[0], c)) return AffineArm{1.0, c};
1031 }
1032 if (op == PROVSQL_ARITH_MINUS && w.size() == 2) {
1033 if (isPivot(w[0]) && constVal(w[1], c)) return AffineArm{1.0, -c}; /* X - c */
1034 if (isPivot(w[1]) && constVal(w[0], c)) return AffineArm{-1.0, c}; /* c - X */
1035 }
1036 if (op == PROVSQL_ARITH_TIMES && w.size() == 2) {
1037 if (isPivot(w[0]) && constVal(w[1], c)) return AffineArm{c, 0.0};
1038 if (isPivot(w[1]) && constVal(w[0], c)) return AffineArm{c, 0.0};
1039 }
1040 }
1041 return std::nullopt;
1042}
1043
1044std::optional<double>
1045singlePivotCaseRawMoment(const GenericCircuit &gc, gate_t g, unsigned k)
1046{
1047 if (gc.getGateType(g) != gate_case) return std::nullopt;
1048 const auto &wires = gc.getWires(g);
1049 if (wires.size() < 3 || wires.size() % 2 == 0) return std::nullopt;
1050 const std::size_t m = wires.size() / 2; /* guard/value pairs */
1051
1052 /* ---- identify the common pivot RV and each guard's constant threshold ---- */
1053 gate_t pivot{}; bool havePivot = false;
1054 struct Guard { double c; ComparisonOperator op; bool pivotLeft; };
1055 std::vector<Guard> guards;
1056 guards.reserve(m);
1057 for (std::size_t i = 0; i < m; ++i) {
1058 gate_t gd = wires[2 * i];
1059 if (gc.getGateType(gd) != gate_cmp) return std::nullopt;
1060 const auto &gw = gc.getWires(gd);
1061 if (gw.size() != 2) return std::nullopt;
1062 bool ok = false;
1063 ComparisonOperator op = cmpOpFromOid(gc.getInfos(gd).first, ok);
1064 if (!ok || op == ComparisonOperator::EQ || op == ComparisonOperator::NE)
1065 return std::nullopt;
1066 gate_t rvSide, constSide; bool pivotLeft;
1067 if (gc.getGateType(gw[0]) == gate_rv && gc.getGateType(gw[1]) == gate_value) {
1068 rvSide = gw[0]; constSide = gw[1]; pivotLeft = true;
1069 } else if (gc.getGateType(gw[1]) == gate_rv &&
1070 gc.getGateType(gw[0]) == gate_value) {
1071 rvSide = gw[1]; constSide = gw[0]; pivotLeft = false;
1072 } else return std::nullopt;
1073 if (!havePivot) { pivot = rvSide; havePivot = true; }
1074 else if (rvSide != pivot) return std::nullopt; /* multi-pivot -> Tier B */
1075 guards.push_back({parseDoubleStrict(gc.getExtra(constSide)), op, pivotLeft});
1076 }
1077 if (!havePivot) return std::nullopt;
1078
1079 auto spec = parse_distribution_spec(gc.getExtra(pivot));
1080 if (!spec) return std::nullopt;
1081 const auto dist = makeDistribution(*spec);
1082
1083 /* ---- classify each arm (values then default) as affine in the pivot ---- */
1084 std::vector<AffineArm> arms; /* arms[0..m-1] value branches, arms[m] default */
1085 arms.reserve(m + 1);
1086 for (std::size_t i = 0; i < m; ++i) {
1087 auto af = affineInPivot(gc, wires[2 * i + 1], pivot);
1088 if (!af) return std::nullopt;
1089 arms.push_back(*af);
1090 }
1091 {
1092 auto af = affineInPivot(gc, wires.back(), pivot);
1093 if (!af) return std::nullopt;
1094 arms.push_back(*af);
1095 }
1096
1097 /* First-match arm selection at a pivot value x (guards are half-lines, so
1098 * this is constant across the open interior of every partition segment). */
1099 auto guardTrue = [](const Guard &gd, double x) -> bool {
1100 const double lhs = gd.pivotLeft ? x : gd.c;
1101 const double rhs = gd.pivotLeft ? gd.c : x;
1102 switch (gd.op) {
1103 case ComparisonOperator::LT: return lhs < rhs;
1104 case ComparisonOperator::LE: return lhs <= rhs;
1105 case ComparisonOperator::GT: return lhs > rhs;
1106 case ComparisonOperator::GE: return lhs >= rhs;
1107 default: return false;
1108 }
1109 };
1110 auto pickArm = [&](double x) -> const AffineArm & {
1111 for (std::size_t i = 0; i < m; ++i)
1112 if (guardTrue(guards[i], x)) return arms[i];
1113 return arms[m];
1114 };
1115
1116 /* ---- partition the pivot's support at the (sorted, deduped) thresholds ---- */
1117 std::vector<double> cuts;
1118 for (const auto &gd : guards) cuts.push_back(gd.c);
1119 std::sort(cuts.begin(), cuts.end());
1120 cuts.erase(std::unique(cuts.begin(), cuts.end()), cuts.end());
1121
1122 const double NINF = -std::numeric_limits<double>::infinity();
1123 const double PINF = std::numeric_limits<double>::infinity();
1124
1125 double total = 0.0;
1126 const std::size_t nseg = cuts.size() + 1;
1127 for (std::size_t s = 0; s < nseg; ++s) {
1128 const double lo = (s == 0) ? NINF : cuts[s - 1];
1129 const double hi = (s == cuts.size()) ? PINF : cuts[s];
1130 if (lo == hi) continue;
1131
1132 double t; /* interior test point of (lo, hi) */
1133 if (lo == NINF && hi == PINF) t = 0.0;
1134 else if (lo == NINF) t = hi - 1.0;
1135 else if (hi == PINF) t = lo + 1.0;
1136 else t = 0.5 * (lo + hi);
1137 const AffineArm &arm = pickArm(t);
1138
1139 const double dF = dist->cdf(hi) - dist->cdf(lo);
1140 if (std::isnan(dF)) return std::nullopt;
1141 if (dF <= 0.0) continue;
1142
1143 /* E[(aX+b)^k · 1(lo<X<hi)] = Σ_j C(k,j) a^j b^{k-j} ∫ x^j f dx. */
1144 for (unsigned j = 0; j <= k; ++j) {
1145 const double aj = std::pow(arm.a, static_cast<double>(j));
1146 if (aj == 0.0) continue; /* constant arm: only the j=0 term */
1147 const double coef = binomial(k, j) * aj
1148 * std::pow(arm.b, static_cast<double>(k - j));
1149 if (coef == 0.0) continue;
1150 double intj;
1151 if (j == 0) {
1152 intj = dF;
1153 } else {
1154 auto trm = dist->truncatedRawMoment(lo, hi, j);
1155 if (!trm) return std::nullopt; /* family lacks the closed form -> MC */
1156 intj = *trm * dF;
1157 }
1158 total += coef * intj;
1159 }
1160 }
1161 return total;
1162}
1163
1164/* Tier B of the guard-partition integrator: a two-arm CASE
1165 * CASE WHEN (a op b) THEN A ELSE B
1166 * whose single guard compares two DISTINCT independent bare RVs a, b, and whose
1167 * arms A (under the guard) and B (under its complement) are each affine in one
1168 * of a, b. Each arm's contribution is a 1-D integral over its value's pivot
1169 * RV, the other operand marginalised to a CDF weight -- exactly the pivot-
1170 * conjunction integral with a single factor. nullopt for any other shape
1171 * (guard vs a constant is Tier A; >2 arms; an arm not affine in a comparison
1172 * operand; a family without a usable pdf/cdf), so the caller falls to MC. */
1173std::optional<double>
1174twoArmCaseRawMoment(const GenericCircuit &gc, gate_t g, unsigned k)
1175{
1176 if (gc.getGateType(g) != gate_case) return std::nullopt;
1177 const auto &wires = gc.getWires(g);
1178 if (wires.size() != 3) return std::nullopt; /* one guard/value + default */
1179 gate_t guard = wires[0];
1180 if (gc.getGateType(guard) != gate_cmp) return std::nullopt;
1181 const auto &gw = gc.getWires(guard);
1182 if (gw.size() != 2) return std::nullopt;
1183 bool ok = false;
1184 ComparisonOperator op = cmpOpFromOid(gc.getInfos(guard).first, ok);
1185 if (!ok || op == ComparisonOperator::EQ || op == ComparisonOperator::NE)
1186 return std::nullopt;
1187 gate_t a = gw[0], b = gw[1];
1188 if (gc.getGateType(a) != gate_rv || gc.getGateType(b) != gate_rv || a == b)
1189 return std::nullopt;
1190 auto specA = parse_distribution_spec(gc.getExtra(a));
1191 auto specB = parse_distribution_spec(gc.getExtra(b));
1192 if (!specA || !specB) return std::nullopt;
1193 /* op GT/GE with a as the left operand => the guard asserts a > b. */
1194 const bool guard_a_gt_b = (op == ComparisonOperator::GT ||
1196
1197 /* E[value^k · 1(region)] for an arm whose value is affine in a or b, where
1198 * `regionAgtB` says the arm's region is (a > b). */
1199 auto armContribution = [&](gate_t armGate, bool regionAgtB)
1200 -> std::optional<double> {
1201 for (int which = 0; which < 2; ++which) {
1202 gate_t pv = (which == 0) ? a : b;
1203 auto af = affineInPivot(gc, armGate, pv);
1204 if (!af) continue;
1205 const DistributionSpec &pvSpec = (which == 0) ? *specA : *specB;
1206 const DistributionSpec &otSpec = (which == 0) ? *specB : *specA;
1207 /* Weight orientation: with pivot a the region a>b integrates b to F_b(a);
1208 * with pivot b the same region means b<a, i.e. 1-F_a(b). */
1209 const bool pivotGreater = (which == 0) ? regionAgtB : !regionAgtB;
1210 const PivotFactor f{false, otSpec, 0.0, pivotGreater};
1211 double total = 0.0;
1212 for (unsigned j = 0; j <= k; ++j) {
1213 const double aj = std::pow(af->a, static_cast<double>(j));
1214 if (aj == 0.0) continue;
1215 const double coef = binomial(k, j) * aj
1216 * std::pow(af->b, static_cast<double>(k - j));
1217 if (coef == 0.0) continue;
1218 const double I = pivotConjunctionIntegral(pvSpec, {f}, j);
1219 if (std::isnan(I)) return std::nullopt;
1220 total += coef * I;
1221 }
1222 return total;
1223 }
1224 return std::nullopt; /* arm not affine in either comparison operand */
1225 };
1226
1227 auto c0 = armContribution(wires[1], guard_a_gt_b); /* value under guard */
1228 if (!c0) return std::nullopt;
1229 auto c1 = armContribution(wires[2], !guard_a_gt_b); /* default under ¬guard */
1230 if (!c1) return std::nullopt;
1231 return *c0 + *c1;
1232}
1233
1234/* Evaluate a gate_case guard (a bare gate_cmp, or an AND/OR tree of them over
1235 * value RVs) under a strict ordering `rank` of the RVs (higher rank = larger
1236 * value). Returns nullopt if the guard references a constant, an RV outside
1237 * `rank`, or an unsupported gate -- i.e. the CASE is not a pure RV tournament. */
1238std::optional<bool>
1239evalGuardUnderOrder(const GenericCircuit &gc, gate_t guard,
1240 const std::unordered_map<gate_t, int> &rank)
1241{
1242 const auto t = gc.getGateType(guard);
1243 if (t == gate_cmp) {
1244 const auto &w = gc.getWires(guard);
1245 if (w.size() != 2) return std::nullopt;
1246 bool ok = false;
1247 ComparisonOperator op = cmpOpFromOid(gc.getInfos(guard).first, ok);
1248 if (!ok) return std::nullopt;
1249 auto ia = rank.find(w[0]), ib = rank.find(w[1]);
1250 if (ia == rank.end() || ib == rank.end()) return std::nullopt;
1251 const int ra = ia->second, rb = ib->second; /* distinct RVs -> ra != rb */
1252 switch (op) {
1254 case ComparisonOperator::LE: return ra < rb;
1256 case ComparisonOperator::GE: return ra > rb;
1257 case ComparisonOperator::EQ: return false; /* a.s. for continuous RVs */
1258 case ComparisonOperator::NE: return true;
1259 }
1260 return std::nullopt;
1261 }
1262 if (t == gate_times || t == gate_plus) {
1263 const bool isAnd = (t == gate_times);
1264 bool acc = isAnd;
1265 for (gate_t c : gc.getWires(guard)) {
1266 auto v = evalGuardUnderOrder(gc, c, rank);
1267 if (!v) return std::nullopt;
1268 acc = isAnd ? (acc && *v) : (acc || *v);
1269 }
1270 return acc;
1271 }
1272 return std::nullopt;
1273}
1274
1275/* Tier C: a first-match gate_case that computes the max or min of a set of
1276 * independent bare RVs. Recognised by simulating the first-match selection
1277 * over every strict ordering of the value RVs (continuous RVs tie with
1278 * probability 0, so strict orders capture the a.s. behaviour): if the selected
1279 * value is always the maximum (resp. minimum), the CASE is that order
1280 * statistic, whose k-th moment is
1281 * Σ_i ∫ x^k f_{X_i}(x) Π_{j≠i} F_{X_j}(x) dx (max; min flips F to 1-F),
1282 * a sum of pivot-conjunction integrals. Guards may be AND/OR trees of RV-vs-RV
1283 * comparisons among the value RVs; a constant or an outside RV in a guard, a
1284 * non-bare-RV arm, or too many RVs (the n! simulation is capped) decline. */
1285std::optional<double>
1286orderStatCaseRawMoment(const GenericCircuit &gc, gate_t g, unsigned k)
1287{
1288 if (gc.getGateType(g) != gate_case) return std::nullopt;
1289 const auto &wires = gc.getWires(g);
1290 if (wires.size() < 3 || wires.size() % 2 == 0) return std::nullopt;
1291 const std::size_t m = wires.size() / 2;
1292
1293 /* Arms (values + default) must all be bare RVs; collect the distinct set. */
1294 std::vector<gate_t> armRV(m + 1);
1295 std::vector<gate_t> uniq;
1296 std::unordered_map<gate_t, DistributionSpec> specOf;
1297 auto noteRV = [&](gate_t v) -> bool {
1298 if (gc.getGateType(v) != gate_rv) return false;
1299 if (specOf.find(v) == specOf.end()) {
1300 auto sp = parse_distribution_spec(gc.getExtra(v));
1301 if (!sp) return false;
1302 specOf.emplace(v, *sp);
1303 uniq.push_back(v);
1304 }
1305 return true;
1306 };
1307 for (std::size_t i = 0; i < m; ++i) {
1308 armRV[i] = wires[2 * i + 1];
1309 if (!noteRV(armRV[i])) return std::nullopt;
1310 }
1311 armRV[m] = wires.back();
1312 if (!noteRV(armRV[m])) return std::nullopt;
1313
1314 const std::size_t n = uniq.size();
1315 if (n < 2 || n > 7) return std::nullopt; /* n! simulation cap */
1316
1317 /* Guards must reference only the value RVs (checked implicitly by
1318 * evalGuardUnderOrder, which declines on any leaf outside `rank`). */
1319 std::vector<gate_t> guards(m);
1320 for (std::size_t i = 0; i < m; ++i) guards[i] = wires[2 * i];
1321
1322 /* Simulate first-match over every strict ordering of the RVs. */
1323 std::vector<std::size_t> perm(n);
1324 for (std::size_t i = 0; i < n; ++i) perm[i] = i;
1325 bool alwaysMax = true, alwaysMin = true;
1326 do {
1327 std::unordered_map<gate_t, int> rank;
1328 for (std::size_t i = 0; i < n; ++i) rank[uniq[perm[i]]] = static_cast<int>(i);
1329 /* max / min RV under this ordering (highest / lowest rank). */
1330 gate_t maxRV = uniq[perm[n - 1]], minRV = uniq[perm[0]];
1331
1332 gate_t selected = armRV[m]; /* default if no guard fires */
1333 for (std::size_t i = 0; i < m; ++i) {
1334 auto gv = evalGuardUnderOrder(gc, guards[i], rank);
1335 if (!gv) return std::nullopt;
1336 if (*gv) { selected = armRV[i]; break; }
1337 }
1338 if (selected != maxRV) alwaysMax = false;
1339 if (selected != minRV) alwaysMin = false;
1340 if (!alwaysMax && !alwaysMin) return std::nullopt;
1341 } while (std::next_permutation(perm.begin(), perm.end()));
1342
1343 const bool isMax = alwaysMax; /* prefer max if (degenerately) both hold */
1344
1345 /* Σ_i ∫ x^k f_{X_i} Π_{j≠i} (F_{X_j} or 1-F_{X_j}) dx. */
1346 double total = 0.0;
1347 for (std::size_t i = 0; i < n; ++i) {
1348 std::vector<PivotFactor> factors;
1349 factors.reserve(n - 1);
1350 for (std::size_t j = 0; j < n; ++j) {
1351 if (j == i) continue;
1352 factors.push_back({false, specOf.at(uniq[j]), 0.0, isMax});
1353 }
1354 const double I = pivotConjunctionIntegral(specOf.at(uniq[i]), factors, k);
1355 if (std::isnan(I)) return std::nullopt;
1356 total += I;
1357 }
1358 return total;
1359}
1360
1361/* Analytic k-th raw moment of a gate_case, trying each guard-partition tier. */
1362std::optional<double>
1363caseAnalyticRawMoment(const GenericCircuit &gc, gate_t g, unsigned k)
1364{
1365 if (auto v = singlePivotCaseRawMoment(gc, g, k)) return v;
1366 if (auto v = twoArmCaseRawMoment(gc, g, k)) return v;
1367 if (auto v = orderStatCaseRawMoment(gc, g, k)) return v;
1368 return std::nullopt;
1369}
1370
1371double rec_expectation(const GenericCircuit &gc, gate_t g, FootprintCache &fp)
1372{
1373 const auto type = gc.getGateType(g);
1374 switch (type) {
1375 case gate_value:
1376 return parseDoubleStrict(gc.getExtra(g));
1377 case gate_rv: {
1378 // A latent (parametric) leaf -- a parameter is itself a random
1379 // variable -- has no constant-parameter closed form. But the MEAN
1380 // still decomposes exactly when the family's mean is affine in its
1381 // parameters (Normal mean = μ, Uniform mean = (a+b)/2, inverse-
1382 // Gaussian mean = μ): E[X] = E[mean(θ)] = mean(E[θ]) by linearity of
1383 // expectation (no independence assumption), so recurse into the
1384 // parameter wires -- no MC. Nonlinear means (Exponential 1/λ,
1385 // Gamma k/λ, ...) keep meanIsAffine() = false and fall through to MC.
1386 if (rvIsParametric(gc, g)) {
1387 auto tmpl = parse_distribution_template(gc.getExtra(g));
1388 if (tmpl && familyMeanIsAffine(*tmpl)) {
1389 const auto &w = gc.getWires(g);
1390 auto param_mean = [&](const DistributionParam &p) {
1391 return p.wire_slot < 0 ? p.literal
1392 : rec_expectation(gc, w[p.wire_slot], fp);
1393 };
1394 return tmpl->family
1395 ->factory(param_mean(tmpl->p1), param_mean(tmpl->p2))
1396 ->mean();
1397 }
1398 return mc_raw_moment(gc, g, 1, "Expectation of a latent gate_rv");
1399 }
1400 auto spec = parse_distribution_spec(gc.getExtra(g));
1401 if (!spec)
1402 throw CircuitException(
1403 "Expectation: malformed gate_rv extra: " + gc.getExtra(g));
1404 return analytical_mean(*spec);
1405 }
1406 case gate_arith: {
1407 const auto op = static_cast<provsql_arith_op>(gc.getInfos(g).first);
1408 const auto &wires = gc.getWires(g);
1409 switch (op) {
1410 case PROVSQL_ARITH_PLUS: {
1411 double s = 0.0;
1412 for (gate_t c : wires) s += rec_expectation(gc, c, fp);
1413 return s;
1414 }
1415 case PROVSQL_ARITH_MINUS: {
1416 if (wires.size() != 2)
1417 throw CircuitException("gate_arith MINUS must be binary");
1418 return rec_expectation(gc, wires[0], fp)
1419 - rec_expectation(gc, wires[1], fp);
1420 }
1421 case PROVSQL_ARITH_NEG: {
1422 if (wires.size() != 1)
1423 throw CircuitException("gate_arith NEG must be unary");
1424 return -rec_expectation(gc, wires[0], fp);
1425 }
1426 case PROVSQL_ARITH_TIMES: {
1427 if (pairwise_disjoint(fp, wires)) {
1428 double p = 1.0;
1429 for (gate_t c : wires) p *= rec_expectation(gc, c, fp);
1430 return p;
1431 }
1432 return mc_raw_moment(gc, g, 1,
1433 "Expectation of gate_arith TIMES with shared random variables");
1434 }
1435 case PROVSQL_ARITH_DIV: {
1436 if (wires.size() != 2)
1437 throw CircuitException("gate_arith DIV must be binary");
1438 if (gc.getGateType(wires[1]) == gate_value) {
1439 const double divisor = parseDoubleStrict(gc.getExtra(wires[1]));
1440 return rec_expectation(gc, wires[0], fp) / divisor;
1441 }
1442 return mc_raw_moment(gc, g, 1,
1443 "Expectation of gate_arith DIV with non-constant divisor");
1444 }
1445 case PROVSQL_ARITH_MAX:
1446 case PROVSQL_ARITH_MIN: {
1447 // Order statistics have no linearity to push through. Exact
1448 // closed form for i.i.d. Uniform / Exponential; the layer-cake
1449 // 1-D quadrature for any other independent bare-RV mix (mixed
1450 // families, non-identical parameters, Normal); Monte Carlo only
1451 // when leaves are shared / correlated.
1452 const bool isMax = (op == PROVSQL_ARITH_MAX);
1453 if (auto v = iidOrderStatMean(gc, g, isMax, fp))
1454 return *v;
1455 if (auto v = mixedOrderStatMean(gc, g, isMax, fp))
1456 return *v;
1457 return mc_raw_moment(gc, g, 1,
1458 "Expectation of gate_arith " + std::string(isMax ? "MAX" : "MIN"));
1459 }
1460 case PROVSQL_ARITH_POW:
1461 case PROVSQL_ARITH_LN:
1462 case PROVSQL_ARITH_EXP:
1463 // Nonlinear transforms: expectation does not commute with the
1464 // map, so there is no linearity to push through. A registered
1465 // closed-form image (exp(normal) is lognormal, ln(lognormal)
1466 // is normal) gives the exact answer; otherwise the empirical
1467 // estimate is the general one.
1468 if (auto image = transform_image(gc, g, op))
1469 return image->mean();
1470 return mc_raw_moment(gc, g, 1,
1471 "Expectation of a gate_arith nonlinear transform");
1473 // Order-statistic aggregate over a random member set: no closed
1474 // form; the sampler sorts and interpolates each draw.
1475 return mc_raw_moment(gc, g, 1,
1476 "Expectation of a gate_arith PERCENTILE");
1477 }
1478 throw CircuitException(
1479 "Expectation: unknown gate_arith op tag: " +
1480 std::to_string(static_cast<unsigned>(op)));
1481 }
1482 case gate_mixture: {
1483 const auto &wires = gc.getWires(g);
1484 if (gc.isCategoricalMixture(g)) {
1485 // Categorical mixture: E[M] = Σ π_i · v_i, where each mulinput
1486 // mul_i carries π_i in set_prob and v_i in extra.
1487 double s = 0.0;
1488 for (std::size_t i = 1; i < wires.size(); ++i) {
1489 s += gc.getProb(wires[i])
1490 * parseDoubleStrict(gc.getExtra(wires[i]));
1491 }
1492 return s;
1493 }
1494 // E[mixture(p, X, Y)] = π·E[X] + (1-π)·E[Y], where π = P(p = true).
1495 // For a bare gate_input p, π is the leaf's pinned set_prob. For
1496 // a compound Boolean p, route through evaluateBooleanProbability
1497 // so π honors the tuple-independent semantics of the Boolean DAG.
1498 if (wires.size() != 3)
1499 throw CircuitException(
1500 "Expectation: gate_mixture must have exactly three children");
1501 const double pi = mixturePi(gc, wires[0]);
1502 return pi * rec_expectation(gc, wires[1], fp)
1503 + (1.0 - pi) * rec_expectation(gc, wires[2], fp);
1504 }
1505 case gate_case:
1506 if (auto v = caseAnalyticRawMoment(gc, g, 1))
1507 return *v;
1508 return mc_raw_moment(gc, g, 1, "Expectation of gate type gate_case");
1509 default:
1510 return mc_raw_moment(gc, g, 1,
1511 "Expectation of gate type " + std::string(gate_type_name[type]));
1512 }
1513}
1514
1515double rec_variance(const GenericCircuit &gc, gate_t g, FootprintCache &fp)
1516{
1517 const auto type = gc.getGateType(g);
1518 switch (type) {
1519 case gate_value:
1520 return 0.0;
1521 case gate_rv: {
1522 // Latent (parametric) leaf: no constant-parameter closed form.
1523 // Var(normal(M,1)) = 1 + Var(M) etc. is exact in expectation under
1524 // MC (the sampler draws the latent then the leaf per iteration).
1525 if (rvIsParametric(gc, g)) {
1526 const std::string what = "Variance of a latent gate_rv";
1527 const double mu = mc_raw_moment(gc, g, 1, what);
1528 return mc_central_moment(gc, g, 2, mu, what);
1529 }
1530 auto spec = parse_distribution_spec(gc.getExtra(g));
1531 if (!spec)
1532 throw CircuitException(
1533 "Variance: malformed gate_rv extra: " + gc.getExtra(g));
1534 return analytical_variance(*spec);
1535 }
1536 case gate_arith: {
1537 const auto op = static_cast<provsql_arith_op>(gc.getInfos(g).first);
1538 const auto &wires = gc.getWires(g);
1539 auto mc_var = [&](const std::string &what) {
1540 const double mu = mc_raw_moment(gc, g, 1, what);
1541 return mc_central_moment(gc, g, 2, mu, what);
1542 };
1543 switch (op) {
1544 case PROVSQL_ARITH_PLUS: {
1545 if (pairwise_disjoint(fp, wires)) {
1546 double s = 0.0;
1547 for (gate_t c : wires) s += rec_variance(gc, c, fp);
1548 return s;
1549 }
1550 return mc_var(
1551 "Variance of gate_arith PLUS with shared random variables");
1552 }
1553 case PROVSQL_ARITH_MINUS: {
1554 if (wires.size() != 2)
1555 throw CircuitException("gate_arith MINUS must be binary");
1556 if (pairwise_disjoint(fp, wires)) {
1557 return rec_variance(gc, wires[0], fp)
1558 + rec_variance(gc, wires[1], fp);
1559 }
1560 return mc_var(
1561 "Variance of gate_arith MINUS with shared random variables");
1562 }
1563 case PROVSQL_ARITH_NEG: {
1564 if (wires.size() != 1)
1565 throw CircuitException("gate_arith NEG must be unary");
1566 return rec_variance(gc, wires[0], fp);
1567 }
1568 case PROVSQL_ARITH_TIMES: {
1569 if (pairwise_disjoint(fp, wires)) {
1570 // Var(prod Xi) = prod E[Xi^2] - (prod E[Xi])^2
1571 // = prod (Var[Xi] + E[Xi]^2) - (prod E[Xi])^2
1572 double prod_e2 = 1.0;
1573 double prod_e1 = 1.0;
1574 for (gate_t c : wires) {
1575 const double mu_c = rec_expectation(gc, c, fp);
1576 const double v_c = rec_variance(gc, c, fp);
1577 prod_e2 *= (v_c + mu_c * mu_c);
1578 prod_e1 *= mu_c;
1579 }
1580 return prod_e2 - prod_e1 * prod_e1;
1581 }
1582 return mc_var(
1583 "Variance of gate_arith TIMES with shared random variables");
1584 }
1585 case PROVSQL_ARITH_DIV: {
1586 if (wires.size() != 2)
1587 throw CircuitException("gate_arith DIV must be binary");
1588 if (gc.getGateType(wires[1]) == gate_value) {
1589 const double divisor = parseDoubleStrict(gc.getExtra(wires[1]));
1590 return rec_variance(gc, wires[0], fp) / (divisor * divisor);
1591 }
1592 return mc_var(
1593 "Variance of gate_arith DIV with non-constant divisor");
1594 }
1595 case PROVSQL_ARITH_MAX:
1596 case PROVSQL_ARITH_MIN:
1597 // No closed-form variance decomposition for order statistics; MC.
1598 return mc_var(
1599 "Variance of gate_arith " +
1600 std::string(op == PROVSQL_ARITH_MAX ? "MAX" : "MIN"));
1601 case PROVSQL_ARITH_POW:
1602 case PROVSQL_ARITH_LN:
1603 case PROVSQL_ARITH_EXP:
1604 if (auto image = transform_image(gc, g, op))
1605 return image->variance();
1606 return mc_var("Variance of a gate_arith nonlinear transform");
1608 return mc_var("Variance of a gate_arith PERCENTILE");
1609 }
1610 throw CircuitException(
1611 "Variance: unknown gate_arith op tag: " +
1612 std::to_string(static_cast<unsigned>(op)));
1613 }
1614 case gate_mixture: {
1615 const auto &wires = gc.getWires(g);
1616 if (gc.isCategoricalMixture(g)) {
1617 // Categorical mixture: Var(M) = Σ π_i v_i² − (Σ π_i v_i)².
1618 double e1 = 0.0, e2 = 0.0;
1619 for (std::size_t i = 1; i < wires.size(); ++i) {
1620 const double p = gc.getProb(wires[i]);
1621 const double v = parseDoubleStrict(gc.getExtra(wires[i]));
1622 e1 += p * v;
1623 e2 += p * v * v;
1624 }
1625 return e2 - e1 * e1;
1626 }
1627 // Var(M) = π·(Var(X) + E[X]²) + (1-π)·(Var(Y) + E[Y]²) - E[M]²
1628 // (law of total variance specialised to a Bernoulli mixture).
1629 if (wires.size() != 3)
1630 throw CircuitException(
1631 "Variance: gate_mixture must have exactly three children");
1632 const double pi = mixturePi(gc, wires[0]);
1633 const double ex = rec_expectation(gc, wires[1], fp);
1634 const double ey = rec_expectation(gc, wires[2], fp);
1635 const double vx = rec_variance(gc, wires[1], fp);
1636 const double vy = rec_variance(gc, wires[2], fp);
1637 const double em = pi * ex + (1.0 - pi) * ey;
1638 return pi * (vx + ex * ex)
1639 + (1.0 - pi) * (vy + ey * ey)
1640 - em * em;
1641 }
1642 case gate_case: {
1643 if (auto m2 = caseAnalyticRawMoment(gc, g, 2))
1644 if (auto m1 = caseAnalyticRawMoment(gc, g, 1))
1645 return *m2 - (*m1) * (*m1);
1646 const std::string what = "Variance of gate type gate_case";
1647 const double mu = mc_raw_moment(gc, g, 1, what);
1648 return mc_central_moment(gc, g, 2, mu, what);
1649 }
1650 default: {
1651 const std::string what =
1652 "Variance of gate type " + std::string(gate_type_name[type]);
1653 const double mu = mc_raw_moment(gc, g, 1, what);
1654 return mc_central_moment(gc, g, 2, mu, what);
1655 }
1656 }
1657}
1658
1659double rec_raw_moment(const GenericCircuit &gc, gate_t g, unsigned k,
1660 FootprintCache &fp)
1661{
1662 if (k == 0) return 1.0;
1663 if (k == 1) return rec_expectation(gc, g, fp);
1664
1665 const auto type = gc.getGateType(g);
1666 switch (type) {
1667 case gate_value:
1668 return std::pow(parseDoubleStrict(gc.getExtra(g)),
1669 static_cast<double>(k));
1670 case gate_rv: {
1671 // Latent (parametric) leaf: no constant-parameter closed form.
1672 if (rvIsParametric(gc, g))
1673 return mc_raw_moment(gc, g, k, "Raw moment of a latent gate_rv");
1674 auto spec = parse_distribution_spec(gc.getExtra(g));
1675 if (!spec)
1676 throw CircuitException(
1677 "Moment: malformed gate_rv extra: " + gc.getExtra(g));
1678 return analytical_raw_moment(*spec, k);
1679 }
1680 case gate_arith: {
1681 const auto op = static_cast<provsql_arith_op>(gc.getInfos(g).first);
1682 const auto &wires = gc.getWires(g);
1683 switch (op) {
1684 case PROVSQL_ARITH_NEG: {
1685 if (wires.size() != 1)
1686 throw CircuitException("gate_arith NEG must be unary");
1687 const double v = rec_raw_moment(gc, wires[0], k, fp);
1688 return ((k % 2 == 0) ? 1.0 : -1.0) * v;
1689 }
1690 case PROVSQL_ARITH_PLUS: {
1691 if (pairwise_disjoint(fp, wires)) {
1692 // Fold-left: m_acc[i] holds E[(X1 + ... + Xj)^i] for the
1693 // first j children processed; combining with the next
1694 // independent child Y uses the binomial theorem.
1695 std::vector<double> m_acc(k + 1, 0.0);
1696 for (unsigned i = 0; i <= k; ++i)
1697 m_acc[i] = rec_raw_moment(gc, wires[0], i, fp);
1698 for (size_t w = 1; w < wires.size(); ++w) {
1699 std::vector<double> next(k + 1, 0.0);
1700 std::vector<double> moments_y(k + 1, 0.0);
1701 for (unsigned i = 0; i <= k; ++i)
1702 moments_y[i] = rec_raw_moment(gc, wires[w], i, fp);
1703 for (unsigned kp = 0; kp <= k; ++kp) {
1704 double total = 0.0;
1705 for (unsigned i = 0; i <= kp; ++i) {
1706 total += binomial(kp, i) * m_acc[i] * moments_y[kp - i];
1707 }
1708 next[kp] = total;
1709 }
1710 m_acc = std::move(next);
1711 }
1712 return m_acc[k];
1713 }
1714 return mc_raw_moment(gc, g, k,
1715 "Raw moment of gate_arith PLUS with shared random variables");
1716 }
1717 case PROVSQL_ARITH_MINUS: {
1718 if (wires.size() != 2)
1719 throw CircuitException("gate_arith MINUS must be binary");
1720 if (pairwise_disjoint(fp, wires)) {
1721 double total = 0.0;
1722 for (unsigned i = 0; i <= k; ++i) {
1723 const double sign = ((k - i) % 2 == 0) ? 1.0 : -1.0;
1724 total += binomial(k, i)
1725 * rec_raw_moment(gc, wires[0], i, fp)
1726 * sign
1727 * rec_raw_moment(gc, wires[1], k - i, fp);
1728 }
1729 return total;
1730 }
1731 return mc_raw_moment(gc, g, k,
1732 "Raw moment of gate_arith MINUS with shared random variables");
1733 }
1734 case PROVSQL_ARITH_TIMES: {
1735 if (pairwise_disjoint(fp, wires)) {
1736 // (prod Xi)^k = prod Xi^k; under independence E factors.
1737 double p = 1.0;
1738 for (gate_t c : wires) p *= rec_raw_moment(gc, c, k, fp);
1739 return p;
1740 }
1741 return mc_raw_moment(gc, g, k,
1742 "Raw moment of gate_arith TIMES with shared random variables");
1743 }
1744 case PROVSQL_ARITH_DIV: {
1745 if (wires.size() != 2)
1746 throw CircuitException("gate_arith DIV must be binary");
1747 if (gc.getGateType(wires[1]) == gate_value) {
1748 const double divisor = parseDoubleStrict(gc.getExtra(wires[1]));
1749 return rec_raw_moment(gc, wires[0], k, fp)
1750 / std::pow(divisor, static_cast<double>(k));
1751 }
1752 return mc_raw_moment(gc, g, k,
1753 "Raw moment of gate_arith DIV with non-constant divisor");
1754 }
1755 case PROVSQL_ARITH_MAX:
1756 case PROVSQL_ARITH_MIN:
1757 // Order-statistic raw moments have no elementary decomposition; MC.
1758 return mc_raw_moment(gc, g, k,
1759 "Raw moment of gate_arith " +
1760 std::string(op == PROVSQL_ARITH_MAX ? "MAX" : "MIN"));
1761 case PROVSQL_ARITH_POW:
1762 case PROVSQL_ARITH_LN:
1763 case PROVSQL_ARITH_EXP:
1764 if (auto image = transform_image(gc, g, op))
1765 return image->rawMoment(k);
1766 return mc_raw_moment(gc, g, k,
1767 "Raw moment of a gate_arith nonlinear transform");
1769 return mc_raw_moment(gc, g, k,
1770 "Raw moment of a gate_arith PERCENTILE");
1771 }
1772 throw CircuitException(
1773 "Moment: unknown gate_arith op tag: " +
1774 std::to_string(static_cast<unsigned>(op)));
1775 }
1776 case gate_mixture: {
1777 const auto &wires = gc.getWires(g);
1778 if (gc.isCategoricalMixture(g)) {
1779 // Categorical mixture: E[M^k] = Σ π_i v_i^k.
1780 double s = 0.0;
1781 for (std::size_t i = 1; i < wires.size(); ++i) {
1782 const double v = parseDoubleStrict(gc.getExtra(wires[i]));
1783 s += gc.getProb(wires[i])
1784 * std::pow(v, static_cast<double>(k));
1785 }
1786 return s;
1787 }
1788 // E[M^k] = π·E[X^k] + (1-π)·E[Y^k].
1789 if (wires.size() != 3)
1790 throw CircuitException(
1791 "Moment: gate_mixture must have exactly three children");
1792 const double pi = mixturePi(gc, wires[0]);
1793 return pi * rec_raw_moment(gc, wires[1], k, fp)
1794 + (1.0 - pi) * rec_raw_moment(gc, wires[2], k, fp);
1795 }
1796 case gate_case:
1797 if (auto v = caseAnalyticRawMoment(gc, g, k))
1798 return *v;
1799 return mc_raw_moment(gc, g, k, "Raw moment of gate type gate_case");
1800 default:
1801 return mc_raw_moment(gc, g, k,
1802 "Raw moment of gate type " + std::string(gate_type_name[type]));
1803 }
1804}
1805
1806} // namespace
1807
1808/* Conditional dispatch helpers: try closed-form first, fall through
1809 * to MC rejection. Used by all four public compute_* entries to keep
1810 * the conditional logic in one place and the unconditional path
1811 * unchanged. */
1812namespace {
1813
1814[[noreturn]] void raise_infeasible_event(const GenericCircuit &gc, gate_t root)
1815{
1816 (void)gc; (void)root;
1817 throw CircuitException(
1818 "conditioning event is infeasible (empty intersection with the "
1819 "random variable's support)");
1820}
1821
1822double conditional_raw_moment(const GenericCircuit &gc, gate_t root,
1823 unsigned k, gate_t event_root)
1824{
1825 if (k == 0) return 1.0;
1826 /* Collapsed exact posterior: a latent conditioned on a discrete rv over it
1827 * equalling a correlated COUNT (Y(R) = C). Rao-Blackwellises the count to a
1828 * pmf by 1-D quadrature over its shared latent, then closes the R posterior
1829 * by a second 1-D quadrature -- replacing the degenerating rejection sampler
1830 * with an exact quadrature. Declines (nullopt) on any shape mismatch. */
1831 if (auto cf = collapsedConditionalMoment(gc, root, event_root, k))
1832 return *cf;
1833 /* Conjugate prior/likelihood shape: the posterior is a first-class
1834 * distribution of the prior's family, so the raw moment is its family
1835 * closed form -- exact, deterministic, works at rv_mc_samples = 0.
1836 * Declines (nullopt) on any shape mismatch. */
1837 if (auto post = conjugatePosterior(gc, root, event_root))
1838 return makeDistribution(*post)->rawMoment(k);
1839 /* Continuous-density evidence (latent-variable posterior): likelihood
1840 * weighting. The closed-form / rejection paths below assume a bare-rv
1841 * truncation event, so they do not apply. */
1842 if (circuitHasObserve(gc, event_root)) {
1843 const std::string what = "Posterior raw moment";
1844 auto post = importanceSampleConditional(
1845 gc, root, event_root, mc_samples_or_throw(what));
1846 checkPosteriorOrThrow(post, what);
1847 return weightedRawMoment(post, k);
1848 }
1849 if (auto cf = try_truncated_closed_form(gc, root, event_root, k, false))
1850 return *cf;
1851 if (auto cf = try_rvVsRv_conditional_moment(gc, root, event_root, k, false))
1852 return *cf;
1853 if (auto cf = try_pivotConjunction_conditional_moment(gc, root, event_root,
1854 k, false))
1855 return *cf;
1856 if (eventIsProvablyInfeasible(gc, root, event_root))
1857 raise_infeasible_event(gc, root);
1858 return mc_conditional_raw_moment(
1859 gc, root, k, event_root,
1860 "Conditional raw moment of gate type " +
1861 std::string(gate_type_name[gc.getGateType(root)]));
1862}
1863
1864double conditional_central_moment(const GenericCircuit &gc, gate_t root,
1865 unsigned k, gate_t event_root)
1866{
1867 if (k == 0) return 1.0;
1868 if (k == 1) return 0.0;
1869 /* Collapsed exact posterior variance from the collapsed raw moments
1870 * (Var = E[R^2|C] - E[R|C]^2); declines together with the mean. */
1871 if (k == 2) {
1872 auto m1 = collapsedConditionalMoment(gc, root, event_root, 1);
1873 auto m2 = collapsedConditionalMoment(gc, root, event_root, 2);
1874 if (m1 && m2) return *m2 - (*m1) * (*m1);
1875 }
1876 /* Conjugate shape: exact central moment of the posterior distribution
1877 * (family variance for k = 2, binomial expansion over the family raw
1878 * moments above that). */
1879 if (auto post = conjugatePosterior(gc, root, event_root)) {
1880 auto dist = makeDistribution(*post);
1881 if (k == 2) return dist->variance();
1882 const double mu = dist->mean();
1883 double total = 0.0;
1884 for (unsigned i = 0; i <= k; ++i) {
1885 const double mu_pow = std::pow(-mu, static_cast<double>(k - i));
1886 total += binomial(k, i) * mu_pow * dist->rawMoment(i);
1887 }
1888 return total;
1889 }
1890 /* Continuous-density evidence: one importance-sampling pass yields both
1891 * the posterior mean and the central moment (no resampling). */
1892 if (circuitHasObserve(gc, event_root)) {
1893 const std::string what = "Posterior central moment";
1894 auto post = importanceSampleConditional(
1895 gc, root, event_root, mc_samples_or_throw(what));
1896 checkPosteriorOrThrow(post, what);
1897 const double mu = weightedRawMoment(post, 1);
1898 return weightedCentralMoment(post, k, mu);
1899 }
1900 if (auto cf = try_truncated_closed_form(gc, root, event_root, k, true))
1901 return *cf;
1902 if (auto cf = try_rvVsRv_conditional_moment(gc, root, event_root, k, true))
1903 return *cf;
1904 if (auto cf = try_pivotConjunction_conditional_moment(gc, root, event_root,
1905 k, true))
1906 return *cf;
1907 if (eventIsProvablyInfeasible(gc, root, event_root))
1908 raise_infeasible_event(gc, root);
1909 /* MC central: need μ_A first. */
1910 const double mu = conditional_raw_moment(gc, root, 1, event_root);
1911 return mc_conditional_central_moment(
1912 gc, root, k, mu, event_root,
1913 "Conditional central moment of gate type " +
1914 std::string(gate_type_name[gc.getGateType(root)]));
1915}
1916
1917} // namespace
1918
1920 std::optional<gate_t> event_root)
1921{
1922 if (event_root.has_value())
1923 return conditional_raw_moment(gc, root, 1, *event_root);
1924 FootprintCache fp(gc);
1925 return rec_expectation(gc, root, fp);
1926}
1927
1928double compute_raw_moment(const GenericCircuit &gc, gate_t root, unsigned k,
1929 std::optional<gate_t> event_root)
1930{
1931 if (event_root.has_value())
1932 return conditional_raw_moment(gc, root, k, *event_root);
1933 FootprintCache fp(gc);
1934 return rec_raw_moment(gc, root, k, fp);
1935}
1936
1937double compute_central_moment(const GenericCircuit &gc, gate_t root, unsigned k,
1938 std::optional<gate_t> event_root)
1939{
1940 if (event_root.has_value())
1941 return conditional_central_moment(gc, root, k, *event_root);
1942 if (k == 0) return 1.0;
1943 if (k == 1) return 0.0;
1944 FootprintCache fp(gc);
1945 if (k == 2) return rec_variance(gc, root, fp);
1946 // E[(X - mu)^k] = sum_{i=0}^{k} C(k, i) (-mu)^(k-i) E[X^i]
1947 const double mu = rec_expectation(gc, root, fp);
1948 double total = 0.0;
1949 for (unsigned i = 0; i <= k; ++i) {
1950 const double mu_pow = std::pow(-mu, static_cast<double>(k - i));
1951 total += binomial(k, i) * mu_pow * rec_raw_moment(gc, root, i, fp);
1952 }
1953 return total;
1954}
1955
1956/* ─────────────────────── quantiles (§B.1) ─────────────────────── */
1957
1958namespace {
1959
1960/* Empirical p-quantile with the linear-interpolation convention
1961 * PostgreSQL's percentile_cont uses (type 7: h = p·(n-1)). NaN
1962 * observations (sampling-undefined worlds, e.g. empty-group SQL NULLs
1963 * from gate_agg) are dropped like the MC moment estimators do; NaN if
1964 * every sample was undefined. */
1965double empirical_quantile(std::vector<double> xs, double p)
1966{
1967 xs.erase(std::remove_if(xs.begin(), xs.end(),
1968 [](double x) { return std::isnan(x); }),
1969 xs.end());
1970 if (xs.empty()) return std::numeric_limits<double>::quiet_NaN();
1971 std::sort(xs.begin(), xs.end());
1972 if (p <= 0.0) return xs.front();
1973 if (p >= 1.0) return xs.back();
1974 const double h = p * static_cast<double>(xs.size() - 1);
1975 const std::size_t i = static_cast<std::size_t>(h);
1976 if (i + 1 >= xs.size()) return xs.back();
1977 const double frac = h - static_cast<double>(i);
1978 return xs[i] + frac * (xs[i + 1] - xs[i]);
1979}
1980
1981/* Exact quantile of a categorical-form gate_mixture: the generalised
1982 * inverse F⁻¹(p) = min{v : F(v) >= p} over the (value, mass) outcomes.
1983 * nullopt if an outcome's value fails to parse (falls to MC). */
1984std::optional<double> categorical_quantile(const GenericCircuit &gc,
1985 gate_t mix, double p)
1986{
1987 const auto &wires = gc.getWires(mix);
1988 std::vector<std::pair<double, double>> outcomes;
1989 outcomes.reserve(wires.size());
1990 for (std::size_t i = 1; i < wires.size(); ++i) {
1991 double v;
1992 try { v = parseDoubleStrict(gc.getExtra(wires[i])); }
1993 catch (const CircuitException &) { return std::nullopt; }
1994 outcomes.emplace_back(v, gc.getProb(wires[i]));
1995 }
1996 if (outcomes.empty()) return std::nullopt;
1997 std::sort(outcomes.begin(), outcomes.end());
1998 double cum = 0.0;
1999 for (const auto &vp : outcomes) {
2000 cum += vp.second;
2001 if (cum >= p && cum > 0.0) return vp.first;
2002 }
2003 return outcomes.back().first; /* p ≈ 1 vs. mass-sum roundoff */
2004}
2005
2006/* Closed-form (or numerically inverted) quantile of a bare gate_rv,
2007 * optionally truncated to [lo, hi] by a conditioning event: the
2008 * truncated quantile is Q(F(lo) + p·(F(hi) − F(lo))). Tries the
2009 * family's elementary inverse CDF first, then the generic monotone
2010 * CDF bisection (Erlang / Gamma); nullopt when neither decides, so
2011 * the caller falls to MC. */
2012std::optional<double> analytic_dist_quantile(const Distribution &dist,
2013 double p, double lo, double hi);
2014
2015std::optional<double> analytic_rv_quantile(const DistributionSpec &spec,
2016 double p, double lo, double hi)
2017{
2018 return analytic_dist_quantile(*makeDistribution(spec), p, lo, hi);
2019}
2020
2021std::optional<double> analytic_dist_quantile(const Distribution &dist,
2022 double p, double lo, double hi)
2023{
2024 if (p <= 0.0 || p >= 1.0) {
2025 /* Quantile limits are the (truncated) support edges. */
2026 const auto sup = dist.support();
2027 return (p <= 0.0) ? std::max(sup.lo, lo) : std::min(sup.hi, hi);
2028 }
2029 double u = p;
2030 if (std::isfinite(lo) || std::isfinite(hi)) {
2031 const double f_lo = std::isfinite(lo) ? dist.cdf(lo) : 0.0;
2032 const double f_hi = std::isfinite(hi) ? dist.cdf(hi) : 1.0;
2033 if (std::isnan(f_lo) || std::isnan(f_hi)) return std::nullopt;
2034 const double mass = f_hi - f_lo;
2035 if (mass < 1e-12) return std::nullopt; /* vanishing mass: MC's call */
2036 u = f_lo + p * mass;
2037 }
2038 double q = std::numeric_limits<double>::quiet_NaN();
2039 if (auto cf = dist.quantile(u)) q = *cf;
2040 if (std::isnan(q)) q = numericQuantile(dist, u);
2041 if (std::isnan(q)) return std::nullopt;
2042 /* Clamp defensively into the truncation interval (roundoff in u). */
2043 if (q < lo) q = lo;
2044 if (q > hi) q = hi;
2045 return q;
2046}
2047
2048} // namespace
2049
2050double compute_quantile(const GenericCircuit &gc, gate_t root, double p,
2051 std::optional<gate_t> event_root)
2052{
2053 const double inf = std::numeric_limits<double>::infinity();
2054
2055 if (event_root.has_value()) {
2056 /* Conjugate shape: exact quantile of the posterior distribution
2057 * (elementary inverse CDF or the monotone CDF bisection). */
2058 if (auto post = conjugatePosterior(gc, root, *event_root))
2059 if (auto q = analytic_rv_quantile(*post, p, -inf, inf))
2060 return *q;
2061 /* Continuous-density evidence: weighted empirical posterior quantile. */
2062 if (circuitHasObserve(gc, *event_root)) {
2063 const std::string what = "Posterior quantile";
2064 auto post = importanceSampleConditional(
2065 gc, root, *event_root, mc_samples_or_throw(what));
2066 checkPosteriorOrThrow(post, what);
2067 return weightedQuantile(std::move(post), p);
2068 }
2069 /* Bare RV under an interval event: exact truncated quantile. */
2070 if (auto m = matchTruncatedSingleRv(gc, root, *event_root)) {
2071 if (auto q = analytic_rv_quantile(m->spec, p, m->lo, m->hi))
2072 return *q;
2073 }
2074 if (eventIsProvablyInfeasible(gc, root, *event_root))
2075 raise_infeasible_event(gc, root);
2077 gc, root, *event_root,
2078 mc_samples_or_throw("Conditional quantile"));
2079 check_acceptance_or_throw(cs, "Conditional quantile");
2080 return empirical_quantile(std::move(cs.accepted), p);
2081 }
2082
2083 const auto type = gc.getGateType(root);
2084 if (type == gate_value) {
2085 /* Dirac at c: every quantile is c. */
2086 try { return parseDoubleStrict(gc.getExtra(root)); }
2087 catch (const CircuitException &) { /* fall through to MC */ }
2088 } else if (type == gate_rv) {
2089 if (auto spec = parse_distribution_spec(gc.getExtra(root)))
2090 if (auto q = analytic_rv_quantile(*spec, p, -inf, inf))
2091 return *q;
2092 } else if (type == gate_mixture && gc.isCategoricalMixture(root)) {
2093 if (auto q = categorical_quantile(gc, root, p))
2094 return *q;
2095 } else if (type == gate_arith) {
2096 /* A unary LN / EXP transform, or a product of independent factors,
2097 * with a registered closed-form image (exp(normal) is lognormal,
2098 * lognormal products are lognormal, ...) has an exact quantile
2099 * through the image distribution. */
2100 const auto op = static_cast<provsql_arith_op>(gc.getInfos(root).first);
2101 std::unique_ptr<Distribution> image =
2102 (op == PROVSQL_ARITH_TIMES) ? product_image(gc, root)
2103 : transform_image(gc, root, op);
2104 if (image)
2105 if (auto q = analytic_dist_quantile(*image, p, -inf, inf))
2106 return *q;
2107 }
2108
2109 /* Compound scalar circuits (arith trees, Bernoulli mixtures, ...):
2110 * quantiles do not decompose like moments, so estimate from the
2111 * empirical distribution at the rv_mc_samples budget. */
2112 return empirical_quantile(
2113 monteCarloScalarSamples(gc, root, mc_samples_or_throw("Quantile")),
2114 p);
2115}
2116
2117/**
2118 * @brief Lift conditioning out of a scalar arithmetic expression.
2119 *
2120 * Implements @c "f(X|A, Y|B, …) = f(X, Y, …) | (A ∧ B ∧ …)": walks the scalar
2121 * tree rooted at @p root, replaces every nested @c gate_conditioned by a
2122 * transparent passthrough to its target (so the tree becomes the plain
2123 * arithmetic over the unconditioned distributions), collects the evidence
2124 * children, and conjoins them -- together with any pre-existing @p event_opt
2125 * -- into a single conditioning event. The conjunction is built as an
2126 * in-memory @c gate_times over the evidence gates, all of which already live
2127 * in the (joint) circuit, so a base @c gate_rv shared between a value and its
2128 * evidence keeps a single draw under the MC sampler. A conditioned ROOT is
2129 * peeled to its bare target (returned), so a stored "X | C" reaching any
2130 * low-level RV entry point keeps the closed-form scalar path; the (possibly
2131 * new) root is returned. Leaves @p event_opt untouched and returns @p root
2132 * unchanged when the expression carries no conditioning.
2133 */
2135 std::optional<gate_t> &event_opt)
2136{
2137 std::vector<gate_t> evidences;
2138
2139 // 1. Peel a conditioned ROOT to its bare target. A root has no parent
2140 // wires, so it is replaced by its target directly rather than the
2141 // single-child gate_arith passthrough the buried case below needs;
2142 // keeping a bare gate_rv root preserves the closed-form truncation
2143 // path for "X | (X > c)". Handles the 2-child rv/agg carrier
2144 // [target, condition] and (defensively) the 3-child uuid carrier
2145 // [target, evidence, joint]; iterates in case of nested conditioning.
2146 while (gc.getGateType(root) == gate_conditioned) {
2147 const auto &w = gc.getWires(root);
2148 if (w.size() < 2)
2149 throw CircuitException("malformed conditioned gate in scalar expression");
2150 evidences.push_back(w[1]);
2151 root = w[0];
2152 }
2153
2154 // 2. Replace every BURIED gate_conditioned by an arith passthrough to its
2155 // target, collecting evidence as well.
2156 std::set<gate_t> seen;
2157 std::vector<gate_t> stack{root};
2158 while (!stack.empty()) {
2159 gate_t g = stack.back();
2160 stack.pop_back();
2161 if (!seen.insert(g).second) continue;
2162 if (gc.getGateType(g) == gate_conditioned) {
2163 const auto &w = gc.getWires(g);
2164 if (w.size() < 2)
2165 throw CircuitException("malformed conditioned gate in scalar expression");
2166 gate_t target = w[0];
2167 evidences.push_back(w[1]);
2168 gc.liftConditionedToTarget(g, target); // g becomes arith PLUS [target]
2169 stack.push_back(target);
2170 } else {
2171 for (gate_t c : gc.getWires(g))
2172 stack.push_back(c);
2173 }
2174 }
2175 if (evidences.empty())
2176 return root;
2177 if (event_opt.has_value())
2178 evidences.push_back(*event_opt);
2179 gate_t cond;
2180 if (evidences.size() == 1)
2181 cond = evidences[0];
2182 else {
2183 cond = gc.setGate(gate_times); // AND of all evidence (and prior event)
2184 auto &cw = gc.getWires(cond);
2185 for (gate_t e : evidences)
2186 cw.push_back(e);
2187 }
2188 event_opt = cond;
2189 return root;
2190}
2191
2192} // namespace provsql
2193
2194extern "C" {
2195
2196/**
2197 * @brief SQL: rv_moment(token uuid, k integer, central boolean,
2198 * prov uuid DEFAULT gate_one()) -> float8
2199 *
2200 * Single C entry point shared by the @c expected / @c variance /
2201 * @c moment / @c central_moment SQL functions. The SQL wrappers
2202 * select the (k, central) pair that matches their semantics:
2203 * - @c expected(rv, prov): k=1, central=false.
2204 * - @c variance(rv, prov): k=2, central=true.
2205 * - @c moment(rv, k, prov): central=false.
2206 * - @c central_moment(rv, k, prov): central=true.
2207 *
2208 * The @p prov argument carries the conditioning event: typically the
2209 * row's @c provenance() gate after a @c WHERE predicate folded a
2210 * @c gate_cmp into it. When @p prov resolves to @c gate_one (the
2211 * default, or the load-time simplification of any always-true
2212 * sub-circuit) the unconditional path runs unchanged. Otherwise we
2213 * load a JOINT circuit reaching both roots, so shared @c gate_rv
2214 * leaves collapse to a single @c gate_t -- the property the
2215 * conditional MC sampler relies on to couple the indicator's draw
2216 * with the value's draw.
2217 */
2218/**
2219 * @brief SQL: agg_avg_moment_exact(token uuid, k integer) -> float8
2220 *
2221 * The exact independent-rows arm behind @c agg_raw_moment's @c avg
2222 * dispatch: E[AVG^k | COUNT >= 1] from the joint (sum, count) PMF over
2223 * pairwise leaf-disjoint contributors (@c aggAvgRawMomentExact).
2224 * Returns NULL when the shape is out of scope -- shared leaves, compound
2225 * contributors, unset probabilities -- and the SQL caller falls back to
2226 * the Monte-Carlo scalar path.
2227 */
2228Datum agg_avg_moment_exact(PG_FUNCTION_ARGS)
2229{
2230 try {
2231 pg_uuid_t *token = PG_GETARG_UUID_P(0);
2232 const int32 k_signed = PG_GETARG_INT32(1);
2233
2234 if (k_signed < 0)
2235 provsql_error("agg_avg_moment_exact: k must be non-negative (got %d)",
2236 k_signed);
2237
2238 auto gc = getGenericCircuit(*token);
2239 gate_t root = gc.getGate(uuid2string(*token));
2240 bool ok = false;
2241 const double r = provsql::aggAvgRawMomentExact(
2242 gc, root, static_cast<unsigned>(k_signed), ok);
2243 if (!ok)
2244 PG_RETURN_NULL();
2245 return Float8GetDatum(r);
2246 } catch (const std::exception &e) {
2247 provsql_error("agg_avg_moment_exact: %s", e.what());
2248 } catch (...) {
2249 provsql_error("agg_avg_moment_exact: unknown exception");
2250 }
2251 PG_RETURN_NULL();
2252}
2253
2254Datum rv_moment(PG_FUNCTION_ARGS)
2255{
2256 try {
2257 pg_uuid_t *token = PG_GETARG_UUID_P(0);
2258 const int32 k_signed = PG_GETARG_INT32(1);
2259 const bool central = PG_GETARG_BOOL(2);
2260 pg_uuid_t *prov = PG_GETARG_UUID_P(3);
2261
2262 if (k_signed < 0)
2263 provsql_error("rv_moment: k must be non-negative (got %d)", k_signed);
2264 const unsigned k = static_cast<unsigned>(k_signed);
2265
2266 gate_t root_gate, event_gate;
2267 auto gc = getJointCircuit(*token, *prov, root_gate, event_gate);
2268
2269 /* gate_one event = unconditional after load-time simplification. */
2270 std::optional<gate_t> event_opt;
2271 if (gc.getGateType(event_gate) != gate_one)
2272 event_opt = event_gate;
2273
2274 /* Arithmetic over conditioned distributions: peel a conditioned ROOT to
2275 * its bare target (keeping the closed-form truncation path for the
2276 * bare-rv case) and lift any nested gate_conditioned out of the scalar
2277 * expression, folding its evidence into the conditioning event
2278 * (f(X|A, Y|B) = f(X, Y) | (A ∧ B)). Works whether the token arrives
2279 * already unpacked by the SQL dispatcher or as a raw conditioned root
2280 * (Studio's distribution panel calls this low-level binding directly). */
2281 root_gate = provsql::lift_conditioning(gc, root_gate, event_opt);
2282
2283 double result;
2284 if (central)
2285 result = provsql::compute_central_moment(gc, root_gate, k, event_opt);
2286 else if (k == 1)
2287 result = provsql::compute_expectation(gc, root_gate, event_opt);
2288 else
2289 result = provsql::compute_raw_moment(gc, root_gate, k, event_opt);
2290 return Float8GetDatum(result);
2291 } catch (const std::exception &e) {
2292 provsql_error("rv_moment: %s", e.what());
2293 } catch (...) {
2294 provsql_error("rv_moment: unknown exception");
2295 }
2296 PG_RETURN_NULL();
2297}
2298
2299/**
2300 * @brief SQL: rv_quantile(token uuid, p float8,
2301 * prov uuid DEFAULT gate_one()) -> float8
2302 *
2303 * C entry point behind the polymorphic @c quantile SQL dispatcher.
2304 * Same conditioning plumbing as @c rv_moment (joint circuit, nested
2305 * @c gate_conditioned lifting); the evaluation itself is
2306 * @c compute_quantile: closed-form / numerically inverted CDF for a
2307 * (possibly truncated) bare @c gate_rv, exact generalised inverse for
2308 * a categorical mixture, empirical MC quantile for compound circuits.
2309 */
2310Datum rv_quantile(PG_FUNCTION_ARGS)
2311{
2312 try {
2313 pg_uuid_t *token = PG_GETARG_UUID_P(0);
2314 const double p = PG_GETARG_FLOAT8(1);
2315 pg_uuid_t *prov = PG_GETARG_UUID_P(2);
2316
2317 if (std::isnan(p) || p < 0.0 || p > 1.0)
2318 provsql_error("rv_quantile: p must be in [0, 1] (got %g)", p);
2319
2320 gate_t root_gate, event_gate;
2321 auto gc = getJointCircuit(*token, *prov, root_gate, event_gate);
2322
2323 /* gate_one event = unconditional after load-time simplification. */
2324 std::optional<gate_t> event_opt;
2325 if (gc.getGateType(event_gate) != gate_one)
2326 event_opt = event_gate;
2327
2328 root_gate = provsql::lift_conditioning(gc, root_gate, event_opt);
2329
2330 return Float8GetDatum(
2331 provsql::compute_quantile(gc, root_gate, p, event_opt));
2332 } catch (const std::exception &e) {
2333 provsql_error("rv_quantile: %s", e.what());
2334 } catch (...) {
2335 provsql_error("rv_quantile: unknown exception");
2336 }
2337 PG_RETURN_NULL();
2338}
2339
2340/**
2341 * @brief SQL: rv_evidence(evidence uuid) -> float8
2342 *
2343 * The marginal likelihood @c P(data) of an evidence circuit: the mean raw
2344 * importance weight over @c provsql.rv_mc_samples prior draws (the same
2345 * quantity rejection conditioning computes as @c P(C), now a product of the
2346 * observations' densities). Backs @c provsql.evidence.
2347 */
2348Datum rv_evidence(PG_FUNCTION_ARGS)
2349{
2350 try {
2351 pg_uuid_t *token = PG_GETARG_UUID_P(0);
2352 auto gc = getGenericCircuit(*token);
2353 gate_t root = gc.getGate(uuid2string(*token));
2354 /* Conjugate shape with predictive densities registered for every
2355 * observation in the fold: the marginal likelihood is the exact
2356 * product of the sequential predictives (chain rule), accumulated
2357 * in log space. */
2358 if (auto le = provsql::conjugateLogEvidence(gc, root))
2359 return Float8GetDatum(std::exp(*le));
2360 if (provsql_rv_mc_samples == 0)
2362 "rv_evidence: provsql.rv_mc_samples is 0 (the marginal likelihood is "
2363 "estimated by Monte Carlo); set it to a positive sample budget");
2364 const double e = provsql::importanceEvidence(
2365 gc, root, static_cast<unsigned>(provsql_rv_mc_samples));
2366 return Float8GetDatum(e);
2367 } catch (const std::exception &ex) {
2368 provsql_error("rv_evidence: %s", ex.what());
2369 } catch (...) {
2370 provsql_error("rv_evidence: unknown exception");
2371 }
2372 PG_RETURN_NULL();
2373}
2374
2375} // extern "C"
Exact closed-form HAVING COUNT(*) op C probability over safe-join lineage – the recursive marginal-ve...
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.
Boolean-expression (lineage formula) semiring.
Boolean provenance circuit with support for knowledge compilation.
GenericCircuit getJointCircuit(const std::vector< pg_uuid_t > &tokens, std::vector< gate_t > &gates)
Multi-root variant of getJointCircuit.
GenericCircuit getGenericCircuit(pg_uuid_t token)
Build a GenericCircuit from the mmap store rooted at token.
Build in-memory circuits from the mmap-backed persistent store.
Generic directed-acyclic-graph circuit template and gate identifier.
gate_t
Strongly-typed gate identifier.
Definition Circuit.h:49
Rao-Blackwellised (collapsed) evaluation of a correlated COUNT / SUM and of a latent conditioned on s...
The single comparator-resolution pipeline and the single Boolean-subcircuit probability entry point,...
Exact conjugate-prior posteriors for observe-evidence circuits.
Per-family polymorphic view over a continuous gate_rv distribution (§F.1 class hierarchy).
Datum rv_quantile(PG_FUNCTION_ARGS)
SQL: rv_quantile(token uuid, p float8, prov uuid DEFAULT gate_one()) -> float8...
Datum rv_moment(PG_FUNCTION_ARGS)
Datum agg_avg_moment_exact(PG_FUNCTION_ARGS)
SQL: rv_moment(token uuid, k integer, central boolean, prov uuid DEFAULT gate_on...
Datum rv_evidence(PG_FUNCTION_ARGS)
SQL: rv_evidence(evidence uuid) -> float8.
Analytical expectation / variance / moment evaluator over RV circuits.
Monte Carlo sampling over a GenericCircuit, RV-aware.
Shared 1-D quadrature core for the pivot-conjunction and order-statistic closed forms.
Catalog of probability-evaluation methods (Strategy + registry).
Continuous random-variable helpers (distribution parsing, moments).
Support-based bound check for continuous-RV comparators.
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
void addWire(gate_t f, gate_t t)
Add a directed wire from gate f (parent) to gate t (child).
Definition Circuit.hpp:81
uuid getUUID(gate_t g) const
Return the UUID string associated with gate g.
Definition Circuit.hpp:46
gate_t getGate(const uuid &u)
Return (or create) the gate associated with UUID u.
Definition Circuit.hpp:33
In-memory provenance circuit with semiring-generic evaluation.
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.
gate_t setGate(gate_type type) override
Allocate a new gate with type type and no UUID.
double getProb(gate_t g) const
Return the probability for gate g.
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...
void setExtra(gate_t g, const std::string &ex)
Attach a string extra to gate g.
void setProb(gate_t g, double p)
Set the probability for gate g.
Abstract per-family continuous distribution.
double compute_raw_moment(const GenericCircuit &gc, gate_t root, unsigned k, std::optional< gate_t > event_root)
Compute the raw moment (or if event_root is set) for k >= 0.
std::optional< double > conjugateLogEvidence(const GenericCircuit &gc, gate_t evidence)
The exact log marginal likelihood of a conjugate-shaped evidence circuit; std::nullopt on any shape ...
double compute_quantile(const GenericCircuit &gc, gate_t root, double p, std::optional< gate_t > event_root)
Compute the p-quantile of the scalar rooted at root (of the truncated distribution if event_root is ...
double aggAvgRawMomentExact(GenericCircuit &gc, gate_t g, unsigned k, bool &ok)
Exact k-th raw moment of AVG = SUM/COUNT over independent rows, conditional on COUNT >= 1.
gate_t lift_conditioning(GenericCircuit &gc, gate_t root, std::optional< gate_t > &event_opt)
Lift conditioning out of a scalar arithmetic expression.
double analytical_variance(const DistributionSpec &d)
Closed-form variance Var(X) for a basic distribution.
double importanceEvidence(const GenericCircuit &gc, gate_t evidence, unsigned samples)
Marginal likelihood P(data) of evidence: the mean raw importance weight over samples prior draws.
double booleanSubcircuitProbability(GenericCircuit &gc, gate_t root, const std::string &method, const std::string &args, bool inv_free_cert, const Tolerance &tol, bool mc_fallback, std::string *actual_method_out)
Probability of the Boolean function rooted at root in gc – THE single entry point over the method por...
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::optional< double > centralFromRaw(unsigned k, Raw &&raw)
Central moment of order k from a raw-moment closure: .
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.
double compute_central_moment(const GenericCircuit &gc, gate_t root, unsigned k, std::optional< gate_t > event_root)
Compute the central moment (or if event_root is set).
double simpsonIntegrate(double lo, double hi, int N, F &&f)
Composite-Simpson with N panels.
ConditionalScalarSamples monteCarloConditionalScalarSamples(const GenericCircuit &gc, gate_t root, gate_t event_root, unsigned samples)
Rejection-sample root conditioned on event_root.
double binomial(unsigned n, unsigned k)
Binomial coefficient as a double (exact for the small orders the moment expansions use).
std::optional< double > collapsedConditionalMoment(const GenericCircuit &gc, gate_t target, gate_t event, unsigned k)
Collapsed exact posterior raw moment E[R^k | Y = C] for a latent target R conditioned (through the eq...
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...
double evaluateBooleanProbability(const GenericCircuit &gc, gate_t boolRoot)
Probability that the Boolean subcircuit rooted at boolRoot evaluates to true under the tuple-independ...
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::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.
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...
double numericQuantile(const Distribution &d, double p)
Numeric inverse CDF: monotone bisection of cdf() over the family's integration window.
double analytical_mean(const DistributionSpec &d)
Closed-form expectation E[X] for a basic distribution.
constexpr int kSimpsonPanels
Panel count shared by every composite-Simpson quadrature over a distribution's integration range: exa...
WeightedPosterior importanceSampleConditional(const GenericCircuit &gc, gate_t root, gate_t evidence, unsigned samples)
Self-normalised importance sampling of root given evidence.
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 compute_expectation(const GenericCircuit &gc, gate_t root, std::optional< gate_t > event_root)
Compute (or if event_root is set) over the scalar sub-circuit rooted at root.
double analytical_raw_moment(const DistributionSpec &d, unsigned k)
Closed-form raw moment for a basic distribution.
bool circuitHasObserve(const GenericCircuit &gc, gate_t root)
Whether the circuit reachable from root contains a gate_observe – the signal that a conditioning even...
void resolveComparators(GenericCircuit &gc, gate_t root, bool simplify, bool decompose)
Run the comparator-resolution pipeline on gc, rewriting every gate_cmp (RV comparison,...
int provsql_verbose
Verbosity level; controlled by the provsql.verbose_level GUC.
Definition provsql.c:93
double provsql_ess_warn_fraction
Effective-sample-size warning threshold for likelihood weighting: warn when the posterior ESS falls b...
Definition provsql.c:101
int provsql_rv_mc_samples
Default sample count for analytical-evaluator MC fallbacks; 0 disables fallback (callers raise instea...
Definition provsql.c:100
Uniform error-reporting macros for ProvSQL.
#define provsql_error(fmt,...)
Report a fatal ProvSQL error and abort the current transaction.
#define provsql_warning(fmt,...)
Emit a ProvSQL warning message (execution continues).
#define provsql_notice(fmt,...)
Emit a ProvSQL informational notice (execution continues).
const char * gate_type_name[]
Names of gate types.
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_observe
Latent-variable observation (likelihood-weighting evidence): one wire → an observed bare gate_rv leaf...
@ 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_annotation
Transparent single-child wrapper carrying a query-level annotation in extra (inversion-free certifica...
@ gate_mobius
Signed Möbius combination: a MEASURE-only gate carrying one integer coefficient per child (in extra,...
@ gate_conditioned
Conditioning marker with two children [target, evidence]: measure-only, probability_evaluate returns ...
@ 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)
@ gate_assumed
Structural marker over a single child whose sub-circuit was computed under a Boolean-provenance assum...
string uuid2string(pg_uuid_t uuid)
Format a pg_uuid_t as a std::string.
C++ utility functions for UUID manipulation.
UUID structure.
Outcome of a conditional Monte Carlo sampling pass.
One parameter slot of a gate_rv, either a literal or a wire.
Parsed distribution spec (family + up to two parameters).
A gate_rv distribution spec that may carry wired (token) parameters – the parse-time counterpart of D...
Outcome of a likelihood-weighting (importance-sampling) pass.