Continuous Distributions
This page describes the architecture of ProvSQL’s continuous
random-variable surface: the on-disk gates, the per-family
Distribution class hierarchy and its rule registries, the SQL
composite type, the planner-hook rewriter’s classifier, the Monte
Carlo sampler, the RangeCheck / AnalyticEvaluator / Expectation
chain, the HybridEvaluator’s simplifier and island decomposer, the
conditional-inference path, the aggregate dispatch, and the Studio
rendering hooks. The user-facing description lives in
Continuous Distributions.
Gate Types
Four gate types described below back the continuous surface, plus
the gate_observe evidence gate covered in Latent variables
further down; all are appended to the
gate_type enum in provsql_utils.h before the
gate_invalid sentinel, with no renumbering of the existing
values. The companion provenance_gate SQL enum in
sql/provsql.common.sql mirrors the C enum identically. (The
conditioning marker gate_conditioned is shared with the
Boolean surface and described in Probability Evaluation;
its interaction with the moment evaluators is covered in
Conditional Evaluation below.)
gate_rvRandom-variable leaf. The gate’s
extrablob carries the distribution as text,<family>:<p1>[,<p2>]– e.g."normal:2.5,0.5"fornormal(2.5, 0.5)or"exponential:2"forexponential(2). The family token is not an enum: it is resolved at parse time against the distribution registry (see The Distribution Class Hierarchy below), so the set of valid blobs grows with the registered families – currentlynormal,uniform,exponential,erlang,gamma,lognormal,weibull,pareto,beta,logistic,inverse_gamma,inverse_gaussian, plus the discrete count familiespoisson,binomial,geometric,negative_binomial(whose blobs appear asgate_rvleaves only in the parametric/latent form – see Latent variables below).Categorical random variables share no
gate_rvencoding; they are encoded as a block ofgate_mulinputgates under agate_mixture(see below). The literal discrete count constructors (Poisson, Binomial, …) are SQL-level constructors over that categorical encoding, not gate types.gate_arithN-ary arithmetic over scalar children. The operator tag lives ininfo1of the gate’sGateInformation:provsql_arith_opisPLUS = 0,TIMES = 1,MINUS = 2,DIV = 3,NEG = 4,MAX = 5,MIN = 6,POW = 7,LN = 8,EXP = 9,PERCENTILE = 10(thepercentile_contorder statistic, see Statistic aggregates below).MAX/MINare the n-ary order statistics behindgreatest/leastand themax/minaggregates.POW(binary, real branch only) andLNcarry evaluation-time domain guards (see the sampler section);EXPis total. The enum is append-only: the values are persisted on disk and must not be renumbered.gate_mixtureProbabilistic mixture. The wire vector is
[p, x, y]for a Bernoulli mixture (withpa Boolean gate andx,yscalar RV roots) or[key, mul_1, …, mul_n]for a categorical block (withkeya freshgate_inputanchoring the block and eachmul_iagate_mulinputcarrying the outcome value in itsextraand its probability viaset_prob).gate_caseN-ary guarded selection over scalar children, with first-match semantics: the wire vector is
[guard_1, value_1, …, guard_k, value_k, default](odd length2k + 1), and the gate’s value is thevalue_iof the first guard (a Boolean event, typically agate_cmp) that holds, else thedefault. It carries data only in its wires – noinfo/extra, following thegate_conditionedprecedent. Minted byprovenance_case(and itsrandom_variablewrapperrv_case), the target of the planner hook’s lowering of SQLCASEoverrandom_variablebranches. It is a real arm in the MC sampler, in RangeCheck (support = union of the value branches), and in the Expectation evaluator (closed-form moments via the guard-partition integrator below, Monte Carlo otherwise); like every measure-carrier gate it is refused by the generalsr_*semirings (a guarded selection is not a semiring operation).
In addition, gate_value gains a float8 mode: the
extra blob is parsed as a double by the RV evaluators
(parseDoubleStrict in the MC sampler,
extract_finite_double in RangeCheck), coexisting with
having_semantics.cpp’s string-based
extract_constant_string path, so gate_value covers both
the deterministic mode used in HAVING sub-circuits and the
random-variable-constant mode used by as_random.
The Distribution Class Hierarchy
src/distributions/ holds the per-family class hierarchy that
every evaluator dispatches through; no evaluator names a family.
The abstract interface is distributions/Distribution.h;
the registries live in distributions/Distribution.cpp;
each family is one self-contained implementation file
(normal.cpp, uniform.cpp, exponential.cpp,
erlang.cpp, gamma.cpp, lognormal.cpp,
weibull.cpp, pareto.cpp, beta.cpp, logistic.cpp,
inverse_gamma.cpp, inverse_gaussian.cpp, and the discrete
poisson.cpp, binomial.cpp, geometric.cpp,
negative_binomial.cpp) sharing only the
internal header DistributionCommon.h. The logistic family
is the location-scale logistic(μ, s) whose CDF is the logistic
sigmoid and whose quantile is
the logit
, so it realises the
logit-link selection noise exactly; its mean is affine in
μ
(meanIsAffine).
A Distribution is a transient per-family view constructed
from a parsed spec by makeDistribution. Its interface
groups into:
identity:
family()returns the internedDistributionFamilydescriptor (name token, parameter count, display label, parameter symbols, factory) – descriptor-pointer equality is family identity; there is no family enum anywhere;closed-form moments:
mean(),variance(),rawMoment(k), plus the optionaltruncatedRawMoment(lo, hi, k)andiidOrderStatMean(n, isMax);density / distribution:
pdf(x),cdf(x)– a family that has no closed form returns NaN (the NaN-as-undecided contract: callers treat NaN as “fall back”, never as a value); the optionalquantile(p)returnsnulloptwhen there is no elementary inverse;ranges:
support(),integrationRange()(a finite quadrature window),plotRange()(for Studio’s density previews);sampling:
sample(rng), plus the optionalsampleTruncated(rng, lo, hi, n)for rejection-free conditioned draws;structure:
affine(a, b)(the family’s image undera·X + b, ornullptrwhen the image leaves the family),asDirac()(degenerate point masses), andserialise()(the on-diskextratext, inverse ofparse_distribution_spec).
Alongside the family table, four rule registries capture the pairwise closed forms, all keyed by family-name tokens and all populated at static initialisation:
the comparator registry maps a family pair to a
P(X < Y)closed form, consulted bycomparatorPairLess(used by the AnalyticEvaluator; a miss falls through to the quadrature described below);the sum-closure registry maps a family pair to a rule folding a list of
a·Z + bterms into a single distribution, consulted byclosePlusTerms(used by the HybridEvaluator; this is how a same-rate Exponential / Erlang chain folds into one Erlang, and any linear combination of independent normals into one normal);the product-closure registry does the same for TIMES wires via
closeProductFactors(independent lognormals multiply in log space);the transform registry maps a
(transform, family)pair – transform names are the opcode-free strings"ln"/"exp"– to the image distribution, consulted bycloseTransform(exp(Normal) → Lognormal,ln(Lognormal) → Normal).
numericQuantile (same file) is the family-agnostic fallback
inverse CDF: a monotone bisection of cdf() over the family’s
integrationRange, used whenever quantile() declines
(Erlang, Gamma, Beta, Inverse-Gamma, Inverse-Gaussian).
A family file self-registers: it defines one static
DistributionFamily descriptor plus a
DistributionFamilyRegistrar (and any
comparator/closure/product/transform registrar objects), all of
which run at static initialisation. Adding a family is one new
self-registering src/distributions/<name>.cpp plus its
SQL constructor in sql/provsql.common.sql – no shared
header, enum, parser, or evaluator is touched, and every readout
(moments, quantiles, sampling, comparisons, Studio rendering)
picks the family up through the registries.
DistributionSpec (RandomVariable.h) is the parsed
form of a gate_rv’s extra blob: an interned
family-descriptor pointer plus up to two parameters.
parse_distribution_spec (RandomVariable.cpp)
splits the blob on :, resolves the token through the family
registry, and parses the comma-separated parameters per the
descriptor’s arity; serialise() is its inverse. The
analytical_mean / analytical_variance /
analytical_raw_moment helpers there are thin wrappers over
makeDistribution(spec)->….
Not every family implements every optional capability, and the
evaluators degrade per capability, not per family: exact
truncated moments need truncatedRawMoment (Normal, Uniform,
Exponential, Lognormal, Weibull, Pareto, Beta); rejection-free
conditioned sampling needs sampleTruncated (the same list
minus Beta, plus Logistic); elementary quantiles need
quantile() (Normal –
a Beasley-Springer-Moro start polished to machine precision by
two Newton steps – Uniform, Exponential, Lognormal, Weibull,
Pareto, Logistic, and the discrete families; the rest bisect);
closed-form i.i.d. order-statistic
means need iidOrderStatMean (Uniform, Exponential, Weibull,
Pareto). A missing capability falls through to quadrature,
bisection, or Monte Carlo as appropriate.
The guard-partition integrator
gate_case moments and conjunction conditioning are evaluated in
closed form (Expectation.cpp), not sampled, whenever the shape
reduces to a one-dimensional pivot-conjunction integral
, where each factor
is a partner variable’s CDF
(for
) or its complement, and constant comparisons clip the
integration window (
pivotConjunctionIntegral, composite Simpson,
exact for the polynomial uniform integrands). The shared partner
variables are marginalised analytically because distinct bare
gate_rv leaves are independent. Three gate_case tiers feed it:
single-pivot piecewise (
singlePivotCaseRawMoment): every guard compares one pivot RV to a constant and every branch is affine in it (abs/clamp/ ReLU); partitions the pivot’s support at the thresholds and accumulatestruncatedRawMomentover each interval;two-arm two-RV (
twoArmCaseRawMoment): a single guard between two distinct RVs, each branch affine in one operand (min / max pair);order statistic (
orderStatCaseRawMoment): a first-match tournament recognised asmax/minof the branch RVs by simulating the selection over every strict ordering (n!, capped), then summed as one pivot-conjunction integral per RV-is-extremum term.
Conjunction conditioning E[X^k | ∧_j (X op Y_j)] is the ratio
of two such integrals
(
matchPivotConjunctionConditional /
try_pivotConjunction_conditional_moment), slotting into the
conditional-moment chain after the single-comparison truncation and
rv-vs-rv paths.
The probability side of the same shape – a correlated island of
comparisons all sharing one pivot RV, e.g. probability((x>y)|(x>z))
– is resolved by an analytic joint table in
HybridEvaluator.cpp (detect_shared_pivot_rv /
inline_analytic_pivot_joint_table): each outcome word’s cell
probability is one pivot-conjunction integral, installed as mulinputs
under a shared key exactly like the Monte-Carlo inline_joint_table.
This is the RV-vs-RV analogue of the monotone-shared-scalar fast path
(detect_shared_scalar), which handles the all-constant-threshold
case; both keep correlated comparison joints exact at
rv_mc_samples = 0 instead of collapsing to a product of marginals.
SQL Surface
The type random_variable is a thin wrapper around the UUID of
the provenance gate behind the variable: the UUID is the single
source of truth, and every downstream evaluator (MonteCarloSampler,
AnalyticEvaluator, Expectation, RangeCheck,
HybridEvaluator) dispatches on the gate it points at, parsing
the distribution from the gate’s extra blob. Its IO functions
live in random_variable_type.c; the C++-side parsing
helpers live in RandomVariable.cpp (see the previous
section).
Constructors are PL/pgSQL functions in
sql/provsql.common.sql: normal,
uniform, exponential, erlang,
gamma (with chi_squared sugar; an integer
shape routes through erlang), lognormal,
weibull (k = 1 routes through
exponential), pareto, beta
(beta(1,1) routes through uniform),
logistic, inverse_gamma,
inverse_gaussian,
categorical, mixture (two overloads), and
as_random (three numeric overloads via the
double precision form). They validate parameters and mint
the appropriate gate via create_gate, set_extra,
set_prob, set_infos.
The discrete count families are pure-SQL constructors over the
categorical encoding: poisson, binomial,
geometric, hypergeometric, and
negative_binomial each enumerate their pmf by a
log-space recurrence (numerically stable at large parameters, no
lgamma dependency) into
categorical_from_log_pmf, which subtracts the maximum
log-mass, drops outcomes below a 1e-15 relative tail,
renormalises, and calls categorical.
categorical_from_log_pmf is itself public – the “arbitrary
user-defined discrete density” surface. Infinite supports are
truncated at the same relative tail; a 10000-outcome cap raises
with the suggested continuous approximation; degenerate
parameters route through as_random.
Three further constructors package common fitted-density shapes, each minting no new gate – they decompose into the existing mixture / categorical machinery so every evaluator handles them for free:
gmm(weights, means, stddevs)is a Gaussian mixture: a stick-breaking cascade of Bernoulligate_mixturenodes overgate_rvNormal leaves (componentiselected with conditional probabilityw_i / (w_i + … + w_n)so the joint selection probabilities are exactlyweights). Moments are closed-form through the mixture recursion, sampling is exact; zero-weight components are skipped and a single positive-weight component returns its Normal directly. Validation mirrorscategorical(same-length non-empty arrays, weights in[0, 1]summing to1within1e-9).empirical_samples(samples)loads a sample bundle (Monte Carlo / MCMC / bootstrap draws) as its ecdf: the discrete distribution putting mass1/non each draw (duplicates merge). It reduces entirely tocategorical, so the exact discrete surface applies – sample moments, analytic “fraction belowc” comparisons, and exact empirical quantiles – subject to the same 10000-distinct-value cap.empirical_cdf(grid, cdf)loads a tabulated piecewise-linear CDF (percentile tables, risk models, elicited forecasts) as a stick-breaking cascade of Bernoullimixturenodes overuniformcomponents – masscdf[i+1] − cdf[i]spread uniformly over(grid[i], grid[i+1])– plus an optionalas_randomatom of masscdf[1]atgrid[1]whencdf[1] > 0. Moments and sampling are exact through the mixture machinery; comparisons ride Monte Carlo.
rv_families (rv_families.cpp) exposes the
family registry as a SRF – one row per registered family with
its on-disk name token, parameter count, parameter-symbol array,
and display label. UI clients (Studio) read it so newly added
families render without a client release.
The fresh-randomness constructors (every distribution
constructor that mints an anonymous gate) are VOLATILE to
prevent constant-folding under STABLE or IMMUTABLE from
collapsing two independent draws into a single shared gate. The
deterministic constructors (as_random and the
three-argument mixture(p uuid, x random_variable,
y random_variable) overload, both of which mint a v5-derived
UUID keyed on their inputs) are IMMUTABLE.
Arithmetic operators + - * / - on
(random_variable, random_variable) resolve to
random_variable_plus and siblings, each a one-line SQL
function that calls provenance_arith with the appropriate
provsql_arith_op tag. The transform surface follows the same
pattern: the ^ operator and pow / power
resolve to random_variable_pow (opcode POW),
ln and exp to their opcodes, and
sqrt is pure x ^ 0.5 sugar with no opcode of its
own. greatest / least (quoted – they shadow
keywords) build MAX / MIN gates over their variadic
arguments, de-duplicating identical child gates first and
collapsing a single survivor to itself. Comparison operators
< <= = <> >= > resolve to placeholder procedures that
raise if executed; the planner hook intercepts every such
OpExpr and rewrites it before the executor sees it (see the
classifier section below).
Implicit casts integer → random_variable,
numeric → random_variable, double precision →
random_variable are declared explicitly so that
WHERE rv > 2 and WHERE 2.5 > rv resolve uniformly via the
(rv, rv) operator declarations – and so a scalar exponent in
x ^ 0.5 lifts to the (rv, rv) operator.
Planner-Hook Rewriting
The transformation that lifts WHERE and join predicates on
random_variable columns into the row’s provenance circuit
lives in the same provsql_planner hook in
provsql.c that handles deterministic
provenance tracking and the agg_token HAVING surface.
The central walker is migrate_probabilistic_quals. It
walks every qual in the input query and routes each into one of
four mutually-exclusive classes (the qual_class enum):
QUAL_PURE_AGG: the qual is built only fromagg_tokencomparators (the pre-existing HAVING pathway).QUAL_PURE_RV: the qual is built only fromrandom_variablecomparators.QUAL_DETERMINISTIC: the qual contains no probabilistic comparator and stays in the WHERE clause as ordinary SQL.A short tail of mixed-error classes flagged so the rewriter raises a clean diagnostic rather than producing a malformed circuit (e.g. a qual that conjoins a
random_variablecomparator and anagg_tokencomparator in the same node).
For QUAL_PURE_RV quals, the rewriter mints a
gate_cmp per comparator and conjoins its UUID into the
row’s provenance via provenance_times. The
comparator’s float8-comparator OID is recovered via
random_variable_cmp_oid. The original
OpExpr is dropped from the WHERE so the executor never reaches
the placeholder procedure.
For QUAL_PURE_AGG quals, the existing HAVING pathway
(make_aggregation_expression, dispatched on
aggtype) is reused with one extension: when the aggregate’s
result type is OID_TYPE_RANDOM_VARIABLE, the rewriter routes
through make_rv_aggregate_expression so the per-row
argument is wrapped in rv_aggregate_semimod (a
mixture over the row’s provenance and the
identity for the aggregate, see Aggregate Dispatch below).
Two further rewrites piggyback on the same hook: an SQL CASE
whose result branches are random_variable lowers to
rv_case (a gate_case whose guards are the lifted
comparison events), and a call to the Boolean
probability overload whose argument carries a
probabilistic comparison is rewritten to
probability_evaluate over the comparison’s event
token (a purely deterministic argument falls through to the SQL
body, predicate::integer::double precision).
Conditioning two comparison events, (A) | (B), is handled by
the same rewrite_cond_predicate_mutator that lowers the
carrier | (predicate) placeholders. An random_variable /
agg_token comparison is statically boolean-typed, so
(A) | (B) is a boolean | boolean operator (backed by the
raising placeholder predicate_cond_predicate, registered as
OID_FUNCTION_PREDICATE_COND_PREDICATE); unlike the
carrier-passthrough cond_predicate shape, both operands are
predicates. The mutator lowers each through
predicate_to_condition_gate and emits
cond(target_gate, evidence_gate), whose
probability_evaluate is the correlation-aware
. Because the operator returns
uuid, (A) | (B) is a first-class event token in every
position – a probability argument, a projected
column, or the left operand of a further |.
A short-cut handles the corner case of WHERE rv > 2 on a
FROM-less query (which touches no provenance-tracked relation):
there is nothing to conjoin into, so the rewriter runs only the
qual migration and splices a synthesised provenance column onto
the single result row, and the
result is a circuit that probability_evaluate reads
directly.
Monte Carlo Sampler
The sampler implementation lives in
MonteCarloSampler.cpp. The entry point
monteCarloRV runs N iterations over a
GenericCircuit; per iteration it draws every reachable
gate_rv leaf once (memoised in scalar_cache_) and
evaluates every reachable gate_input Bernoulli once
(memoised in bool_cache_). The two per-iteration caches
ensure that shared leaves are correctly coupled within an
iteration. A third, cross-iteration cache (dist_cache_)
holds the per-gate Distribution object, constructed once via
makeDistribution and reused for every draw, so the
blob-parsing and factory cost is paid once per gate rather than
once per sample.
The RNG is std::mt19937_64, seeded from the
provsql.monte_carlo_seed GUC: -1 seeds from
std::random_device; any other value (including 0) is
used as a literal seed for reproducibility. The same RNG drives
the Bernoulli and continuous (gate_rv) sampling paths, so a
pinned seed reproduces both the discrete and continuous
components of a circuit’s randomness.
Sampler::evalScalar is the scalar dispatcher: it knows
how to sample gate_rv (via Distribution::sample),
gate_value (float8 mode parsed via
parseDoubleStrict), gate_arith (recursing on
children and combining per info1, including MAX /
MIN folds – shared base RVs stay coupled through the caches,
so max(x, y) with correlated x, y is sampled
jointly), gate_mixture (sampling the Boolean selector once
via evalBool, then recursing into the chosen branch), and
gate_case (evaluating guards via evalBool and values via
evalScalar in the same iteration; the first true guard wins,
else the default wire). The gate_agg arm calls back into the
aggregate evaluator with the per-iteration sampled values; this
is what unlocks HAVING+RV under Monte Carlo.
The transform opcodes carry evaluation-time domain guards: a
negative draw flowing into LN, or a negative base drawn
together with a non-integer exponent under POW, raises an
actionable error naming the greatest(x, 0) clamp – never a
silently dropped NaN, which would bias the estimate. Integer
exponents are total; ln(0) legitimately yields -∞; NaN
operands (from an upstream guard-free source) propagate.
Sampler::evalBool is the Boolean dispatcher: it walks the
Boolean wrappers (plus / times / monus / cmp / input / mulinput /
project / eq), and treats gate_delta as transparent: the
gate exists for the structural δ-semiring algebra but adds
no event to the rv_* event walker. The same transparency is
asserted in walkAndConjunctIntervals so the AND-conjunct
pass that backs RangeCheck behaves consistently.
RangeCheck
RangeCheck.cpp propagates support intervals
through gate_arith and tests every gate_cmp
against the propagated interval. A comparator that is decidable
from the support alone (e.g. a Normal restricted to
x > μ + 10σ: the support of the LHS is
but every realisation is
overwhelmingly to the right of the RHS; or a bounded uniform
x > b: the support cap is b, so the cmp is identically
false) collapses to a Bernoulli gate_input with
probability 0 or 1, transparent to every downstream
consumer.
Interval propagation covers all eleven arith opcodes: MAX /
MIN take the corner max / min of the child intervals;
EXP and LN map interval endpoints through the monotone
function (LN clamping the interval at zero from below);
POW over a non-negative base does corner analysis on the
(base, exponent) box, and widens to all-real when the base
interval crosses zero. This keeps support readouts
and interval-decidable comparisons exact through the transform
surface. A gate_case’s interval is the union of its value
branches (odd wires plus the default).
The AND-conjunction pass walkAndConjunctIntervals walks a
WHERE clause’s conjunction and intersects the per-RV intervals
across conjuncts before running the per-comparator decision.
reading > 1 AND reading < 3 thus constrains a single normal
once, with the analytic CDF call evaluating both endpoints in a
single pass.
compute_support is exposed as rv_support for
SQL-side use (support polymorphically dispatches on
type and routes random_variable here).
AnalyticEvaluator
AnalyticEvaluator.cpp computes the exact probability of
a decidable gate_cmp. runAnalyticEvaluator snapshots
every gate_cmp in the circuit, calls tryAnalyticDecide
per comparator, and on a non-NaN result collapses the gate to a
Bernoulli gate_input with that probability.
tryAnalyticDecide recognises three shapes:
RV vs constant (either order):
cdfDecideevaluates the family CDF at the constant –F(c)for</<=,1 − F(c)for the mirrored operators. Equality on a continuous distribution is RangeCheck’s job (below).categorical vs constant:
categoricalDecidesumsget_probover the mulinputs satisfying the predicate; being discrete,=/<>are decided here, exactly.RV vs RV (two bare leaves):
rvVsRvDecideconsults the comparator registry throughcomparatorPairLess. Same-family closed forms are registered for Normal (via the difference normal), Uniform, Exponential, Lognormal, Weibull (same shape), and Pareto (any parameter pair – the comparison the heavy-tailed quadrature grid handles worst). On a registry miss – including every mixed-family pair – the generic fallbackquadraturePairLesscomputesby composite Simpson quadrature (4000 panels) over X’s
integrationRange. A NaN anywhere (no finite integration window, a declined pdf/cdf) leaves the comparator to the Monte Carlo net.
Equality and inequality on continuous distributions collapse in
RangeCheck: X = X is identically true (a zero-width
interval identity), X = Y for any two sub-circuits of which
at least one has purely continuous support is identically false.
hasOnlyContinuousSupport (in RangeCheck.cpp) is the
predicate behind the second case: a recursive walk that returns
true on gate_rv leaves, on gate_arith whose every wire is
continuous, and on Bernoulli mixtures whose two branches are both
continuous; false on gate_value (Dirac), on categorical
mixtures (point masses at each outcome), and on Boolean / agg
gates. The widened test catches heterogeneous-rate exponential
sums (Exp(λ_1) + Exp(λ_2) with λ_1 ≠ λ_2, no Erlang
closure), products of two independent continuous RVs, and mixed
gate_arith composites that the simplifier cannot fold to a
single gate_rv – their equality predicate would otherwise
have flowed all the way down to the MC marginalisation only to
return 0 in finite precision anyway.
When neither side is purely continuous, a second analytical path
in RangeCheck fires: collectDiracMassMap extracts the
(value → mass) map from each side (recursing into
categoricals and Bernoulli mixtures of as_random /
gate_value branches), and the cmp resolves exactly via the
independent-Dirac sum-product
P(X = Y) = Σ_{v ∈ M_X ∩ M_Y} M_X(v) · M_Y(v).
Continuous components on either side contribute zero by
measure-zero arguments (continuous vs Dirac and continuous vs
continuous), so they need not appear in the sum. Independence is
required for the factoring to hold; collectRandomLeaves walks
both sides for the union of gate_rv + gate_input leaves
and the shortcut bails on any overlap (e.g. two mixtures sharing
a Bernoulli p_token). Bernoulli mixtures whose p_token is
a compound Boolean (whose static probability would require a
recursive probability_evaluate call) also bail. The
sum-product subsumes the disjoint-Dirac case as its boundary
(empty intersection ⇒ P(X = Y) = 0).
Analytical Moment Evaluator
Expectation.cpp implements the closed-form moment
evaluator for continuous-RV circuits. It is not a
Semiring subclass: the
provenance_evaluate_compiled_internal dispatcher
special-cases semiring == "expectation" and calls
compute_expectation directly on the
GenericCircuit, bypassing the template-based
GenericCircuit::evaluate<S> machinery used by the proper
semirings. The same entry point is reached by
expected over a random_variable and by the
rv_moment C helper.
The algorithm runs analytical moment computation per distribution
at leaves (Distribution::mean / variance /
rawMoment), then propagates through gate_arith by
closed-form rules:
E[X + Y] = E[X] + E[Y](always),E[X − Y] = E[X] − E[Y](always),E[a · X] = a · E[X](when one operand is a constant),E[X · Y] = E[X] · E[Y](only whenXandYare structurally independent),Var[X + Y] = Var[X] + Var[Y](independent),etc.
Divergent moments are reported honestly: a family whose k-th
moment does not exist (a Pareto with α ≤ k) returns
Infinity, never an estimate.
The MAX / MIN order statistics get their own mean rules:
when the children are independent bare RVs of the same family
and parameters, Distribution::iidOrderStatMean supplies the
closed form (Uniform, Exponential, Weibull, Pareto implement
it); otherwise mixedOrderStatMean integrates the layer-cake
identities /
by composite
Simpson quadrature over a window covering every child’s support
– still exact-grade for independent bare-RV children of any
family mix. Shared leaves, non-leaf children, or an undefined
CDF fall through to MC, as do order-statistic variances and
higher moments.
Two read-only registry consultations extend the closed-form
reach without rewriting the circuit (safe under shared-RV
identity): transform_image maps LN / EXP gates over
a single bare RV through the transform registry, and
product_image maps a TIMES over independent same-family
leaves through the product registry; both the moment and the
quantile evaluators then read the image distribution’s closed
forms directly.
Structural independence is detected via a per-evaluation
FootprintCache that memoises, per gate, the set of base
gate_rv leaves reachable from it (a gate_case
contributes all its wires, like a gate_arith). Two gates
whose footprints are disjoint are independent; the cache speeds
up the check from quadratic to linear by sharing the leaf-set
computation across the recursion.
When no closed form applies, compute_expectation falls
back to a Monte-Carlo estimate using MonteCarloSampler. The
sample count is provsql.rv_mc_samples; setting it to 0
turns the fallback into an exception so callers that need
analytical answers can detect the silent fallback.
Quantiles
The polymorphic quantile dispatcher routes a
random_variable to rv_quantile, whose C implementation
compute_quantile (Expectation.cpp) dispatches on
the root shape: a gate_value returns its constant; a bare
gate_rv goes to analytic_dist_quantile; a categorical
mixture takes the generalised inverse
over its enumerated
outcomes; an
LN / EXP / foldable TIMES root reads
its registry image (see above); anything else – and any
conditioned shape outside the truncated closed form – draws
provsql.rv_mc_samples samples and interpolates the empirical
quantile with the same type-7 linear interpolation PostgreSQL’s
percentile_cont uses.
analytic_dist_quantile handles the edges and truncation
uniformly: p ≤ 0 / p ≥ 1 return the (possibly truncated)
support edges; under interval conditioning the probability is
rescaled to before
inverting (a conditioning mass below
1e-12 falls to MC); the
inversion itself tries Distribution::quantile first and
numericQuantile bisection second.
Covariance, correlation, standard deviation
covariance (x, y [, prov]) and
correlation are C readouts
(RvCovariance.cpp) over a joint circuit loaded through
the multi-root getJointCircuit (the three roots x, y,
prov share one gate_t per common leaf).
stddev stays pure SQL, sqrt(variance(x)).
Exact tiers first: two identical roots route to the variance
evaluator; structurally independent arguments (disjoint
stochastic-leaf footprints, given the event) give an exact 0;
and when E[XY], E[X], E[Y] (plus both variances, for
correlation) all decompose analytically – probed with the MC
fallback temporarily disabled – the closed-form
E[XY] − E[X]·E[Y] subtraction is returned. Otherwise a
single coupled Monte-Carlo pass draws (x, y) pairs (a
leaf shared between the roots and/or the event produces one draw
per iteration that all observe; conditioning is
rejection-sampled through monteCarloConditionalScalarPairSamples)
and the sample covariance is returned, with
correlation reading
off the same pass. The naive
three-run
expected(x·y) − expected(x)·expected(y)
subtraction is deliberately avoided on the MC path: its noise
scales with (a catastrophic cancellation when
the means dominate the coupling), whereas the paired estimator’s
scales with
.
correlation returns NULL on a degenerate (zero-variance)
argument in every tier.
Collapsed aggregate moments
CollapsedAggMoment.cpp is the Rao-Blackwellised fast
path for the recurring latent-variable relational shape: an
aggregate over probabilistically-selected rows whose per-row
selection events are coupled through one shared continuous
latent. Conditional on that latent the row indicators are
independent, so the aggregate’s moments collapse to a 1-D
quadrature over the shared latent instead of an
tuple enumeration or a degenerating importance sampler. It is
exact up to the quadrature grid and works at
provsql.rv_mc_samples = 0; complexity
(
G grid points, K per-point cost). This is distinct from
the foldDegenerateMixtures Bernoulli-arm collapse (see
HybridEvaluator): that pass folds a certain () Bernoulli selector to its surviving arm, whereas this
one marginalises a fractional, shared continuous latent
analytically.
Two entry points, both consulted by Expectation.cpp
before the generic MC fallback and exposed as SQL:
aggCollapsedRawMoment(SQLagg_collapsed_moment(token, k)and the pairedagg_collapsed_moments(token)that returns both{E[C], E[C²]}from a single circuit load) computes the raw moment(
kin{1, 2}) of a correlated COUNT / SUM by a Poisson-binomial 1-D quadrature over the shared latent, with the closed-form per-row CDF given it. It returnsstd::nullopt/ SQLNULLwhen the circuit does not match the shared-latent shape, so the caller falls back to the exactenumeration.
varianceuses the paired form so a mean-plus-variance readout traverses the circuit once.collapsedConditionalMomentcomputes the exact posterior raw momentof a latent
Rconditioned (through an equalityevent) on a discrete rvY– parametrised byR– equalling a correlated COUNTC. Theeventmust be agate_cmpwith=whose operands are a parametric discretegate_rvoverRand agate_aggcount; the count’s pmfcomes from the collapse, and the posterior is the second 1-D quadrature
with likelihood
. A shape mismatch returns
nulloptand the caller falls back to importance sampling.
HybridEvaluator
HybridEvaluator.cpp is the orchestrator. Given a
circuit, it runs:
Universal peephole pass (
runHybridSimplifier) that folds family-preserving combinations into a single leaf. The fold rules are registry-driven:try_sum_closurecollects a PLUS gate’s wires asa·Z + bterms and consults the sum-closure registry (any linear combination of independent normals and constants into a single normal; a same-rate Exponential / Erlang chain intoerlang(Σk, λ); same-rate Gammas into a Gamma);try_product_closureconsults the product registry (independent lognormals fold in log space);try_transform_closureconsults the transform registry (exp(Normal) → Lognormal,ln(Lognormal) → Normal– so a chain likeexp(N_1 + N_2)collapses to one lognormal leaf). Scalar shifts, scalings, and negations of a single RV fold throughDistribution::affine; a family whose affine image leaves the family declines the fold and the shape stays agate_arith(shifting or negating an exponential, for instance, flips or displaces its support). Around the registry rules sit the structural canonicalisations: MINUS-to-PLUS (so subtraction shapes flow through the same PLUS pipeline), DIV-to-TIMES for division by a constant, shift-and-scale pushed through mixtures and categoricals, single-child arith roots, semiring-identity drops (gate_onein TIMES,gate_zeroin PLUS…), and constant folding of deterministic subtrees – includingPOW/LN/EXP/MAX/MINover constants, with domain-violating constants deliberately left unfolded so the sampler’s guard fires. The pass is invariant-preserving: every transformation produces a semantically equivalent circuit. Out of scope: combinations with no registered rule – the sum of two distinct uniforms (triangular, not uniform), differing-rate exponential sums, min / max of RVs (only their means have closed forms, see above); these shapes stay asgate_arithand the MC sampler handles them per-iteration.Peephole is borrowed from compiler engineering (McKeeman, CACM 8(7), 1965): a small sliding window over consecutive instructions / gates, a fixed list of local pattern -> replacement rules, iterated to a fixed point. Each rule here looks at one
gate_arithplus its immediate children, never further, matching the original scope. Contrast withRangeCheck(RangeCheck.cpp), which propagates a data-flow fact (the support interval) through the whole circuit, and with the island decomposer below, which uses a global union-find over base-RV footprints.Island decomposition (
runHybridDecomposer) that splits a multi-cmp query into independent islands (connected components on a union-find over base-RV footprints). A single-cmp island marginalises to a Bernoulligate_inputviaAnalyticEvaluator. A multi-cmp island whose cmps share base RVs is enumerated via the joint table (the joint distribution of the shared base RVs is evaluated explicitly).Monotone-shared-scalar fast path for the common shape of a single
gate_rvshared across multiple monotone comparators (typical of range queries on a single column): the joint event reduces to an interval on the underlying scalar and one analytical CDF call per endpoint.This fast path is analytical when the shared scalar is a bare
gate_rvwith a closed-form CDF, so the decomposer deliberately does not short-circuit onprovsql.rv_mc_samples = 0: skipping it would leave the correlated cmps forAnalyticEvaluatorto collapse one at a time, silently returning the product of the marginals for events that share a leaf (e.g.Pr(x ≥ 2000 ∧ x ≥ 1000)coming back asPr(x ≥ 2000)·Pr(x ≥ 1000)rather thanPr(x ≥ 2000)). Only the genuinely MC-bound arms – a composite (non-gate_rv) shared scalar, an RV-vs-RV joint table, a non-analytic singleton – are gated onrv_mc_samples > 0; under0a correlated island with no closed form raises (aCircuitExceptionsurfaced as a clean error) rather than falling back to the independent product. This is what makesprobability((A) | (B))over two shared-leaf comparisons exact even with the MC fallback disabled.Universal semiring-identity collapse after RangeCheck has decided every decidable cmp.
The simplifier is gated by provsql.simplify_on_load for the
universal pass run at load time, and by the debug-only
provsql.hybrid_evaluation (GUC_NO_SHOW_ALL) for the
in-evaluator hybrid path. End users have no reason to flip
hybrid_evaluation; it exists for developer A/B against the
unfolded path and as a bisection knob.
Conditional Evaluation
expected, variance, moment,
central_moment, quantile,
support, rv_sample, and
rv_histogram all accept an optional prov uuid
DEFAULT gate_one() argument. When prov resolves to anything
other than gate_one(), evaluation routes through the
joint-circuit loader.
getJointCircuit (MMappedCircuit.cpp)
builds a multi-rooted BFS over the union of the reachable gates
from both input and prov so shared gate_rv
leaves between the two are loaded into a single
GenericCircuit and consequently couple correctly in the
Monte Carlo sampler’s per-iteration caches. This is the
shared-atom coupling invariant: a conditioning event prov
is only meaningful relative to the random variables it
references, and those must be the same leaves the moment’s
evaluator sees.
The closed-form table for truncated (interval-conditioned)
moments is a per-family capability, not a hard-coded list: a
family implementing Distribution::truncatedRawMoment gets
exact conditional moments (Normal via the Mills-ratio formula
and integration by parts, Uniform trivially on the intersected
interval, Exponential by memorylessness plus the lower
incomplete gamma, Lognormal, Weibull, Pareto – the latter via
tail self-similarity – and Beta via the incomplete-beta ratio).
Families that decline (Erlang, Gamma) fall through to the MC
path below. Extending the truncated surface to a new family
means implementing truncatedRawMoment (and, for exact
conditioned sampling, sampleTruncated) in that family’s
file – no detection or dispatch site changes.
For shapes outside the closed-form table, the conditional moment
is estimated by rejection sampling at provsql.rv_mc_samples;
rv_sample emits a NOTICE (and
rv_histogram / expected raise) when the
acceptance rate drops below the requested n within the
budget, so the caller can either widen the budget or loosen the
conditioning.
matchTruncatedSingleRv (in RangeCheck.cpp) is the
single-RV shape-detection helper used by the moment surface
(try_truncated_closed_form in Expectation.cpp), the
quantile evaluator, and the rejection-free sampler
(try_truncated_closed_form_sample). It
runs the four common gates – gate_rv root check,
parse_distribution_spec, collectRvConstraints, and the
empty-intersection / gate_zero event guards – so the
supported-shape set stays in sync between moments, quantiles,
and sampling.
matchClosedFormDistribution (same file) generalises the
single-RV matcher to the four-arm variant
std::variant<TruncatedSingleRv, DiracShape, CategoricalShape,
BernoulliMixtureShape> consumed by
rv_analytical_curves. The variant covers, in addition
to the bare-RV case, as_random(c) Diracs (gate_value
roots), categorical-form gate_mixture roots, and classic
Bernoulli gate_mixture roots over any recursively-matched
shape. A bare-RV root under conjugate observe-evidence resolves
first through conjugatePosterior (the posterior is itself a
bare distribution, returned as an untruncated single-RV shape –
this is what makes rv_histogram and the curve
renderers exact for recognised posteriors).
Conditioning is honoured uniformly across all four arms:
non-trivial events are routed through collectRvConstraints
to extract a [lo, hi] interval on the root variable, then
truncateShape is applied recursively – bare RVs intersect
their bounds and renormalise via the truncated CDF; Diracs are
kept iff the value falls inside the interval (otherwise the
event is infeasible); categoricals drop outcomes outside the
interval and renormalise surviving masses; Bernoulli mixtures
recursively truncate their arms and reweight the Bernoulli by
the ratio of arm masses
(), with the
arm masses computed by
shape_mass (a parallel recursive
pass that integrates the unconditional CDF over the interval).
An arm with zero post-truncation mass is eliminated and the
mixture degenerates to the surviving arm.
eventIsProvablyInfeasible (also RangeCheck.cpp) is
the conditional-moment dispatcher’s pre-MC short-circuit:
gate_zero events are detected for every root type, and
gate_rv roots additionally surface
collectRvConstraints-empty intersections. The dispatcher in
conditional_raw_moment / conditional_central_moment
(Expectation.cpp) calls it after
try_truncated_closed_form returns nullopt and raises a
“conditioning event is infeasible” error directly when the
predicate fires, avoiding a full rv_mc_samples MC round whose
acceptance probability is exactly zero.
rv_sample and rv_histogram share
MonteCarloSampler::try_truncated_closed_form_sample: a
direct exact-sampling fast path that fires on a bare gate_rv
with an interval-extractable event whenever the family
implements Distribution::sampleTruncated (Uniform draws
U(lo, hi) on the intersected truncation; Exponential
one-sided uses memorylessness (X | X > c = c + Exp(λ)),
two-sided uses inverse-CDF via std::log1p / std::expm1
for numerical accuracy near the support boundary; Normal uses
the inverse-CDF transform; Lognormal, Weibull, and Pareto invert
their exact quantiles). The fast path delivers exactly n
samples with 100% acceptance even for tight tail events that
would otherwise starve the rejection budget. Families without
sampleTruncated (Erlang, Gamma, Beta, Inverse-Gamma,
Inverse-Gaussian) and gate_arith
composite roots fall through to MC rejection unchanged.
rv_analytical_curves (in RvAnalyticalCurves.cpp)
exposes the closed-form PDF, CDF, and discrete stems as sampled
data for ProvSQL Studio’s Distribution profile overlay. Returns
NULL when the root sub-circuit is not a closed-form shape, so
callers can dispatch it in parallel with rv_histogram
without a structural pre-check. The payload has three optional
fields:
pdf– evenly-spaced{x, p}samples of the continuous density. Absent when the shape has no continuous component (pure Dirac, pure categorical, or nested mixture of those).cdf– same grid aspdf, cumulative probability. Always emitted (well-defined for any supported shape; a pure- discrete shape produces a staircase, a continuous shape a smooth curve, and a mixed shape a smooth curve with jumps at the stem positions).stems–{x, p}point masses produced by Dirac roots, categorical roots, or Dirac / categorical arms inside a Bernoulli mixture. Bernoulli weights propagate down the path (a Dirac insidemixture(0.3, X, c)appears at(c, 0.7)).
The supported shape set is the union of
matchClosedFormDistribution ‘s variant arms (see above):
a bare gate_rv of any registered family – the plot window,
density, and distribution come from Distribution::plotRange
/ pdf / cdf, so a new family gets curves for free; a
family that declines pdf/cdf on the grid (NaN) returns NULL –
plus as_random(c) Diracs, categorical mixtures, and
Bernoulli mixtures over any recursively-matched shape, all four
arms accepting a non-trivial conditioning event. gate_arith
composites return NULL; the frontend renders histogram-only in
those cases.
Before matching, rv_analytical_curves runs
runHybridSimplifier so the curves see the same folded tree
that simplified_circuit_subgraph exposes to Studio’s circuit
view: c·Exp(λ) folded to Exp(λ/c), sums of independent
normals folded to a single normal, exp(Normal) folded to its
lognormal image, c + mixture(p, X, Y)
pushed inside the mixture, etc. Without this pass a circuit
that displays as a single Exp(0.5) node would still be
seen by the matcher as a gate_arith composite of
value(2) and gate_rv:Exp(1) and would silently fall
back to histogram-only.
Truncation under a bare RV normalises the PDF by
and
rescales the CDF to
[0, 1] over the conditioning interval.
Under a Bernoulli mixture the truncated PDF is
with the per-arm normalisers
computed by
shape_mass. Under a categorical
the conditional masses are .
A Dirac is invariant under any feasible event.
The load-time pass runConstantFold (in HybridEvaluator.cpp,
invoked from CircuitFromMMap::applyLoadTimeSimplification
alongside runRangeCheck and foldSemiringIdentities)
folds deterministic gate_arith subtrees to gate_value
at load time. This lifts the common parser shape
arith(NEG, value:c) (produced when SQL parses
-c::random_variable as -(c::random_variable)) into a
clean value:-c, so asRvVsConstCmp and friends recognise
the comparator’s constant side without callers having to
parenthesise. The pass runs only the constant-fold rule from the
hybrid simplifier, never the family closures or identity drops,
because the result is always a gate_value that carries no
random identity, so no shared-RV coupling is decoupled by the
rewrite. The family closures stay behind the separate
provsql.hybrid_evaluation GUC, which gates runHybridSimplifier
inside the probability and view paths where the simplifier owns
the rewritten subtree.
Immediately before runConstantFold in the same load-time
sequence, foldDegenerateMixtures collapses a classic Bernoulli
mixture(p, X, Y) whose selector p is certainly true or
false to the surviving arm: X when ,
Y
when . The weight is read only where it is known
without a probability computation – a resolved
gate_one /
gate_zero selector, or a bare gate_input whose pinned
probability is exactly 1 or 0 (the default 1 of a
non-probabilistic tuple included). The collapse rewrites the
mixture as a single-wire gate_arith PLUS passthrough to
the survivor (liftConditionedToTarget), which references
rather than copies it, so a shared survivor keeps its single gate
identity and single Monte-Carlo draw; foldSemiringIdentities
then unwraps the passthrough. Ordering it before the constant fold
is what lets a provenance-weighted count of certain tuples (the
denominator of the avg rewrite, see Aggregate Dispatch)
reduce to a gate_value. The fold is exact and
correlation-safe precisely at : a
deterministic selector shared across several mixtures couples
none of them, whereas a fractional selector genuinely does, so
the pass admits only the two endpoints and leaves every
fractional Bernoulli for the Monte-Carlo sampler. A compound
Boolean selector – whose
would need a (possibly
#P-hard) evaluation and depends on mutable input probabilities –
is left intact for the probability-aware evaluators.
Information Theory
InformationTheory.cpp implements the entropy /
Kullback-Leibler / mutual-information readouts, all in nats,
over scalar RV sub-circuits. The exact paths resolve a gate to a
closed density view – a bare gate_rv (its family pdf
over the integration range), a gate_value / categorical
mixture (a finite pmf), or a Bernoulli mixture tree over
independent such arms (e.g. the gmm cascade) – and
evaluate the defining integral or sum directly. Entropy of a
discrete view is Shannon entropy (a point mass has entropy 0);
of a continuous view, differential entropy (quadrature of
over the family’s integration range). The three
C entry points are
computeEntropy, computeKL,
computeMutualInformation, bound to the SQL
entropy (x [, prov]), kl (p, q), and
mutual_information (x, y).
Entropy. Shapes with no closed density (arithmetic composites) and the conditional form (
provother thangate_one()) fall back to a Monte Carlo histogram plug-in estimate at theprovsql.rv_mc_samplesbudget.KL divergence. Exact only: the defining sum for two discrete views (outcomes matched by value) and the defining integral (quadrature over
P’s integration window) for two continuous ones, including independent-arm mixture trees. ReturnsInfinitywhenPis not absolutely continuous with respect toQ(mismatched kinds, an atom or region ofPoutsideQ’s support). KL has no density-free estimator, so an arithmetic composite or conditioned argument raises rather than falling back to Monte Carlo.Mutual information. Exactly
0for structurally independent roots (disjoint stochastic-leaf footprints, the sameFootprintCachetest the moment evaluators use);H(X)for a discrete variable paired with itself andInfinityfor a continuous one (I(X; X)diverges); a genuinely correlated pair (shared leaves) is the 2-D histogram plug-in over coupled joint draws – both roots evaluated against the same per-iteration cache so shared leaves keep their joint law – at theprovsql.rv_mc_samplesbudget.
Aggregate Dispatch
The sum, avg, product,
max, and min aggregates over
random_variable all share sum_rv_sfunc as their
state-transition function (a uuid[] accumulator) and an
INITCOND = '{}' so the FFUNC runs even on an empty group.
The row-absence identity is baked into each per-row contribution
upstream, by the planner hook: make_rv_aggregate_expression
wraps the per-row argument in rv_aggregate_semimod – a
mixture (prov_i, X_i, as_random(identity)) – with
the identity dispatched per aggregate: 0 for sum
(the two-argument form), and, through the three-argument
identity-parameterised form, 1 for product,
-Infinity for max, +Infinity for
min – a row absent in a world must not perturb the
fold, so it contributes the fold’s identity. avg is
rewritten at the same site into the “AVG = SUM / COUNT”
identity, rv_sum_or_null(rv_aggregate_semimod(prov, x)) /
sum(rv_aggregate_indicator(prov)): the numerator is the usual
provenance-weighted sum (rv_sum_or_null differs from
sum only in returning NULL on an empty group so
the division propagates standard AVG semantics), and the
denominator sums per-row mixture(prov_i, 1, 0) indicators –
the count of included rows as a random variable.
That denominator is a genuine random variable only when some row
is uncertainly present. When every contributing tuple is
certain – the default for a provenance-tracked but
non-probabilistic table, where each input gate carries the
default probability 1 – the count is deterministic, and
expected(avg(x)) is simply E[SUM] / n. The load-time
foldDegenerateMixtures pass (see HybridEvaluator) turns
this into an analytic result: a Bernoulli
mixture whose selector is certainly true () or
false (
) is collapsed to the surviving arm, so
each denominator
mixture(prov_i, 1, 0) becomes the constant
1 and constant folding reduces the whole sum to the
gate_value that the analytic DIV-by-constant arm
divides by (each numerator
mixture(prov_i, X_i, 0) likewise
unwraps to X_i, leaving a plain sum of the row RVs). A
fractional presence probability genuinely couples the numerator’s
random sum to the random count, so its mixtures are left intact
and the ratio is estimated by Monte Carlo – the fold admits only
the exact cases, where a deterministic
selector carries no coupling to lose.
With the identities pre-baked, the FFUNCs are plain folds with
no gate inspection: a single gate_arith root (PLUS /
TIMES / MAX / MIN, the extremum pair through the
shared extremum_rv_ffunc) over the accumulated UUIDs, a
singleton group collapsing to its single child, and per-aggregate
empty-group identities (as_random(0) for sum,
as_random(1) for product, as_random(∓∞) for
the extrema, SQL NULL for avg). On an
untracked call (no provenance to weight by) the direct
aggregates still work: every row is unconditionally present and
the raw per-row RVs are folded as-is.
The dispatch is keyed on aggtype (the aggregate’s result
type OID) rather than aggfnoid so the same routing works for
any future RV-returning aggregate.
An earlier design considered an M-polymorphic gate_agg
that would carry the full semimodule lift directly. We rejected
it because the mixture-of-mixtures shape composes through every
existing gate_arith / gate_mixture rule, while a new
M-polymorphic gate would have required a parallel evaluation
path in every analytical evaluator. The semimodule-of-mixtures
shape reuses what’s there.
Statistic aggregates
The SQL-standard statistic aggregates (covar_pop /
covar_samp / corr two-argument, stddev_pop /
stddev_samp one-argument, and the ordered-set
percentile_cont) use a different wrap: instead of baking a
fold identity into each row’s mixture, they carry the row’s
presence indicator explicitly. The public aggregates append
the certain indicator as_random(1) per row; a
provenance-tracked query is rewritten by
make_rv_aggregate_expression to the internal rv_*_impl
aggregates, whose extra leading argument is
rv_aggregate_indicator(prov) (the mixture(prov, 1, 0)
indicator avg already uses), so absent rows drop out of
every sum, the count, and the percentile member set.
The moment statistics are then pure circuit arithmetic over
indicator-weighted power sums – rv_stat_sum_tokens mints
,
,
(and
for the two-argument forms) as
gate_arith PLUS-of-TIMES trees, sharing each row’s
indicator gate between and every product it weighs so
the Monte Carlo per-iteration cache keeps presence coupled
across the sums (and a repeated wire
[ind, x, x] reuses the
same draw of x, giving ). No new opcode is
needed: e.g.
covar_pop is MINUS(DIV(SXY, N),
TIMES(DIV(SX, N), DIV(SY, N))), and the stddevs clamp the
variance with MAX(v, 0) before POW(·, 0.5) so
floating-point error can never trip the pow domain guard.
Undefined worlds (;
for the sample
forms; zero variance for
corr) evaluate to NaN, the
established convention the moment estimators skip.
percentile_cont is the one statistic arithmetic cannot
express: it mints the appended PROVSQL_ARITH_PERCENTILE
(= 10) gate_arith whose wires are the interleaved
[ind_1, x_1, ..., ind_n, x_n] pairs and whose fraction is
text-encoded in extra (and participates in the token UUID,
so two fractions over the same group are distinct gates). The
Monte Carlo sampler arm collects the values whose indicator
draws 1, sorts, and linearly interpolates; RangeCheck
propagates the hull of the value wires’ supports; the moment
evaluators route it straight to the sampler (no closed form).
Because the planner rewrite replaces the ordered-set Aggref
with the normal three-argument rv_percentile_impl before
planning, the executor never sorts random_variable rows –
the sort order is irrelevant to the gate – which is also why
the untracked public form is unusable: its input sort funnels
into random_variable_btree_cmp and raises. The fraction
travels as the leading impl argument into a composite
transition state (rv_percentile_state), since a normal
aggregate’s final function sees only the state.
Studio Rendering
ProvSQL Studio’s circuit canvas is class-based: every node
carries a node--<type> CSS class derived from the gate kind,
and the stylesheet at studio/provsql_studio/static/app.css
gives each class its colour, glyph, and inline-text layout.
The continuous gate types map to:
one entry per type in the server-side JSON serialiser at
studio/provsql_studio/circuit.py(thelabelandchildrenfields, plus the distribution-blob parsing for the per-leaf inline glyph);one branch in the client renderer at
studio/provsql_studio/static/circuit.jsto pick thenode--rv/node--arith/node--mixture/node--caseclass and to emit the correct edge labels (p/x/yfor mixtures; theprovsql_arith_opglyph for arith, withpow(x, 0.5)rendered as√; guard / value / default forgate_case, whose node glyph is⇢);a Circuit-mode fetch that consumes the
simplified_circuit_subgraphfunction (SimplifiedSubgraph.cpp), which runs the universal peephole pass on a sub-BFS and returns the result as a singlejsonbvalue, with theprovsql.simplify_on_loadConfig-panel toggle switching between the raw and folded views.
The RV-family rendering is registry-driven end to end: Studio
reads rv_families for each family’s display label and
parameter symbols, and fetches its density preview as a
server-computed grid from rv_analytical_curves – so a
newly registered family renders correctly with no Studio change.
The eval-strip Distribution profile, Sample, Moment, and
Support entries call rv_histogram,
rv_sample, rv_moment and
rv_support directly. The Condition on row-prov
auto-preset is a client-side feature: clicking a result cell
stamps the row’s provenance UUID into the input and toggles the
Conditioned by badge active. Manual edits stick within a row;
row navigation resets the input to the new row’s prov.
Latent variables and posterior inference
A distribution parameter may be a scalar provenance token instead of a literal double, making the leaf a compound (hierarchical) distribution; conditioning such a leaf on observed data is likelihood-weighting posterior inference. Two mechanisms implement this.
Parameter wires on gate_rv. A gate_rv gained the ability to
carry wires (it never did before, so no mmap format bump is needed). A
parameter slot in the extra text is either a literal or a wire
reference $i (0-based index into the gate’s wire vector), e.g.
"normal:$0,1.0". DistributionSpec (the resolved
{family, double, double} POD every analytic call site consumes) is
unchanged; a parallel template parser
(parse_distribution_template in src/RandomVariable.cpp, returning
DistributionTemplate with per-slot DistributionParam) keeps the
literal-or-wire distinction, and parse_distribution_spec is now that
template parse followed by an all-literal check – so it declines a
parametric leaf and every analytic path (Expectation.cpp,
AnalyticEvaluator.cpp) falls through to Monte Carlo unchanged. The
sampler’s gate_rv arm (src/MonteCarloSampler.cpp) resolves wired
parameters per iteration through evalScalar (so a shared latent lands
in scalar_cache_ and couples the leaves) and builds the family
instance for that draw; integrationRange() is the family-agnostic
domain guard for a drawn-out-of-support parameter. The
FootprintCache unions a latent leaf’s parameter-wire footprints into
its own, so two leaves sharing a latent are flagged dependent and the
independence shortcuts defeat correctly. The DistributionFamily
factory and the Distribution interface are untouched – only the
parameters’ source (sampled wire vs literal) changes.
The gate_observe evidence gate and importance sampling.
gate_observe (append-only enum addition; one wire → the observed
gate_rv leaf, the datum in extra) is an evidence node: it
composes into an evidence circuit by gate_times exactly like a
Boolean conditioning event, but contributes a continuous density factor.
Sampler::evalWeight walks the evidence circuit to a weight rather
than a bool or scalar: a gate_observe returns its leaf’s pdf at the
datum (resolving the leaf’s latent parameters through evalScalar, so
the weight and the value couple), a gate_times multiplies its
children’s weights, and any other subtree falls to evalBool for a
0/1 weight – so a purely Boolean evidence tree reproduces the
rejection conditioning. importanceSampleConditional draws latents
from the prior, weights each by evalWeight(evidence), and returns a
WeightedPosterior of (value, weight) particles plus the marginal
likelihood P(data) (mean weight) and the effective sample size
(Σw)² / Σw². The moment / quantile / sample dispatchers route to it
whenever circuitHasObserve finds a gate_observe in the evidence
and the exact conjugate recogniser below has declined, computing
weighted posterior statistics (rv_sample resamples the particles,
SIR). The whole readout goes through getJointCircuit so a latent
shared between the root and the evidence is a single gate_t.
Exact conjugate posteriors. ConjugatePosterior.cpp is the
closed-form fast path over the same evidence surface: when the target is
a bare all-literal gate_rv (the prior), the evidence flattens
through the gate_times spine into gate_observe atoms only
(gate_one factors are skipped; any other factor declines), and every
observed leaf’s DistributionTemplate has exactly one wired slot
whose wire is the target gate itself, the posterior is folded one
observation at a time through the conjugate-update registry
(registerConjugateRule in distributions/Distribution.h,
keyed on (likelihood family, wired parameter position, running
posterior family) – the same self-registering name-token pattern as
the comparator / closure / transform registries, one rule per likelihood
family file). The result is a first-class DistributionSpec of the
prior’s family, so ONE recognition upgrades every readout at once:
conditional_raw_moment/conditional_central_moment(Expectation.cpp) return the family’s closed-form moments – the attempt slots betweencollapsedConditionalMomentand thecircuitHasObserveimportance-sampling fallback, preserving the invisible-fallback guarantee (anynulloptleaves the ladder unchanged);compute_quantileinverts the posterior CDF exactly;rv_sampledraws i.i.d. from the posterior distribution (no weighted-particle resampling), seeded through the sharedseedRng;matchClosedFormDistribution(RangeCheck.cpp) returns the posterior as an untruncatedTruncatedSingleRvshape, makingrv_histogramandrv_analytical_curvesexact;computeEntropy(InformationTheory.cpp) integrates the posterior pdf exactly;evidencereturnsexp(Σ log m(dᵢ | ...)), the exact marginal likelihood from each rule’slog_predictive(the sequential chain-rule factorisation, accumulated in log space); a rule without a predictive declines the whole evidence recognition.
Everything works at provsql.rv_mc_samples = 0. Each rule’s
update guards its own domain (an out-of-support datum, an invalid
literal slot, a non-integer count outside the family pmf’s 1e-9
rounding tolerance) and declines rather than raises – the
zero-weight / ESS diagnostics of importance sampling remain the UX for
contradictory evidence. The fold canonicalises an erlang or
exponential prior into the gamma carrier (identical
distributions; the SQL gamma constructor stores an integer-shape
prior as an erlang leaf). Because conjugacy is checked per observation
against the running posterior family, mixed likelihoods sharing one
conjugate prior compose (Poisson counts and Exponential gaps over one
Gamma-prior rate). Correctness is by construction the importance-
sampling estimand: the IS weight is exactly ∏ f(dᵢ | θ)
(evalWeight) and the conjugate posterior is the prior times that
product renormalised, so recognition changes the method, never the
semantics. The MVP rule table covers Normal-Normal (mean slot),
Normal-LogNormal (log-location), Gamma-Exponential / -Poisson /
-Gamma / -Erlang / -Pareto (rate and tail-shape slots),
Beta-Binomial / -Geometric / -NegativeBinomial (success-probability
slots, in each family’s own support convention), and Pareto-Uniform
(upper bound, zero lower bound only – 1/(θ−a₀) is Pareto-shaped in
θ only for a₀ = 0). Deliberately absent, because the exact
posterior leaves the registered families (so it cannot ride the
DistributionSpec carrier): a prior on a Normal’s σ slot
(conjugacy is on the precision, not σ), a Beta likelihood with a
latent shape (the 1/B(α, β) normaliser puts Gamma-function factors
in the posterior kernel), Weibull’s scale slot, and multi-latent /
hierarchical posteriors. All decline to importance sampling.
The regression file is continuous_conjugate.sql (exact pairs at
rv_mc_samples = 0, plus decline coverage validated against IS).
The discrete families (poisson, binomial, geometric,
negative_binomial) are ordinary self-registering Distribution
subclasses (isDiscrete() == true) that happen to be integer-valued:
sample() draws from the matching std::…_distribution, pdf()
is the pmf (the observe likelihood weight), and
integrationRange() doubles as the parameter-domain gate (λ > 0;
0 ≤ p ≤ 1; 0 < p ≤ 1). They are only ever instantiated for a
latent leaf – poisson(random_variable),
binomial(integer, random_variable),
geometric(random_variable),
negative_binomial(r, random_variable) – because the literal
constructors still enumerate an exact categorical; and a parametric leaf
is declined by parse_distribution_spec, so no continuous-analytic
path ever integrates their pmf as a density. They reuse the parametric-
leaf mechanism wholesale, which is why the discrete conjugate posteriors
(Gamma-Poisson, Beta-Binomial) fall out with no evaluator change.
hypergeometric stays literal-only: its three parameters do not fit
the two-parameter Distribution ABI, so there is no parametric-leaf
form – the constructor always enumerates its exact categorical.
The equality-form surface. The user writes the natural
conditional-equality X | (Y = c) (single) or given(Y = c) folded
by and_agg (a table); observe is the internal primitive.
The bridge is evidence_as_observation (SQL): when a conditioning event
is a gate_cmp with the = operator, one side a bare gate_rv leaf
and the other a constant, it is rewritten to a gate_observe at
construction time – so the stored evidence never reaches the load-time
measure-zero fold. random_variable_cond (the | operator) and
given both apply it; given(boolean) (the renamed given_predicate
placeholder) is planner-rewritten from both the | (predicate) operator
and the given(predicate) function call.
This is where the Distribution::isDiscrete() flag earns its keep. A
point event Y = c is measure-zero for a continuous leaf (folded to
gate_zero by RangeCheck’s continuous EQ/NE shortcut and Dirac
sum-product) but positive-mass for a discrete one; isDiscrete() (an
authoritative per-family flag, propagated through transforms by
hasOnlyContinuousSupport / collectDiracMassMap) keeps the discrete
equality from folding, so a discrete selection (probability(poisson =
5)) stays exact while a discrete/continuous conditioning Y = c is
routed to likelihood weighting through the observe rewrite.
The rest of the SQL surface is evidence /
shapley_observe, the token-accepting constructor overloads, and
the provsql.ess_warn_fraction GUC. shapley_observe is
connecting code: it enumerates the observation subsets, calls rv_moment
for each coalition’s posterior value, and combines them into the Shapley
attribution – exact, so capped at 12 observations. Sequential Monte Carlo (for the many-observations
regime where the ESS collapses) and MCMC are deferred: the sampler is
stateless (resetIteration() wipes state each draw) and has no
joint-density-at-an-assignment evaluation mode, which MCMC’s acceptance
ratio needs.