ProvSQL C/C++ API
Adding support for provenance and uncertainty management to PostgreSQL databases
Loading...
Searching...
No Matches
RvAnalyticalCurves.cpp
Go to the documentation of this file.
1/**
2 * @file RvAnalyticalCurves.cpp
3 * @brief SQL function `provsql.rv_analytical_curves(token, samples, prov)`.
4 *
5 * Returns a JSON object with closed-form curves for the (possibly
6 * conditional) distribution rooted at @p token, or @c NULL when no
7 * closed form applies. The payload has up to three fields:
8 *
9 * - @c pdf – @p samples evenly-spaced @c {x, p} points covering the
10 * continuous part of the distribution. Absent for pure-discrete
11 * shapes (Dirac / categorical).
12 * - @c cdf – same x grid as @c pdf, with cumulative probability.
13 * - @c stems – point masses @c {x, p} produced by Dirac (@c gate_value
14 * wrapped as an @c as_random) or categorical roots, or by Dirac/
15 * categorical arms inside a Bernoulli mixture. Weights propagate
16 * through nested mixtures (each ancestor's @c p / 1-p applies).
17 *
18 * Used by ProvSQL Studio's Distribution profile panel to overlay
19 * analytical curves and point-mass discs on the empirical histogram
20 * drawn from @c rv_histogram.
21 *
22 * Supported shapes:
23 * - bare @c gate_rv root (Normal / Uniform / Exponential / Erlang
24 * with integer shape), optionally truncated by an AND-conjunct
25 * event extracted via @c collectRvConstraints;
26 * - Dirac point (@c gate_value with finite extra, surfaced by
27 * @c provsql.as_random);
28 * - categorical-form @c gate_mixture (one @c {key, mul_1..n});
29 * - classic Bernoulli @c gate_mixture (@c [p_token, x, y]) over any
30 * two recursively-matched shapes; @c p_token must be a bare
31 * @c gate_input (compound Boolean @c p bails).
32 *
33 * Truncation is honoured only on the bare-RV path; mixtures /
34 * categoricals / Diracs are matched only with a trivial event.
35 *
36 * @see provsql::matchClosedFormDistribution in RangeCheck.h
37 */
38extern "C" {
39#include "postgres.h"
40#include "fmgr.h"
41#include "utils/jsonb.h"
42#include "utils/fmgrprotos.h"
43#include "utils/uuid.h"
44#include "provsql_utils.h"
45#include "provsql_error.h"
46
47PG_FUNCTION_INFO_V1(rv_analytical_curves);
48}
49
50#include "AnalyticEvaluator.h" // pdfAt, cdfAt
51#include "CircuitFromMMap.h" // getJointCircuit
52#include "Expectation.h" // lift_conditioning
53#include "GenericCircuit.h"
54#include "HybridEvaluator.h" // runHybridSimplifier
55#include "RandomVariable.h" // DistKind
56#include "RangeCheck.h" // matchClosedFormDistribution + variant
57#include "provsql_utils_cpp.h"
58
59#include <algorithm>
60#include <cmath>
61#include <iomanip>
62#include <limits>
63#include <optional>
64#include <sstream>
65#include <tuple>
66#include <type_traits>
67#include <utility>
68#include <variant>
69#include <vector>
70
71namespace {
72
73/**
74 * @brief Choose a sensible x-range for the continuous curve given a
75 * single-RV spec and an optional truncation.
76 *
77 * Unbounded distributions (Normal) get a heuristic window around the
78 * mean; bounded distributions (Uniform) get a slight padding so the
79 * support boundary doesn't sit flush with the SVG edge; one-sided
80 * supports (Exponential, Erlang) get @c 6/λ on the right.
81 *
82 * Truncation clamps the window: the curve never extends past the
83 * conditioning event's interval.
84 */
85std::pair<double, double>
86bare_x_range(const provsql::DistributionSpec &spec,
87 double trunc_lo, double trunc_hi)
88{
89 double lo = trunc_lo, hi = trunc_hi;
90 switch (spec.kind) {
92 const double mu = spec.p1, sigma = spec.p2;
93 if (!std::isfinite(lo)) lo = mu - 4.0 * sigma;
94 if (!std::isfinite(hi)) hi = mu + 4.0 * sigma;
95 break;
96 }
98 const double a = spec.p1, b = spec.p2;
99 const double pad = 0.15 * (b - a);
100 if (!std::isfinite(lo)) lo = a - pad;
101 else lo = std::max(lo, a - pad);
102 if (!std::isfinite(hi)) hi = b + pad;
103 else hi = std::min(hi, b + pad);
104 break;
105 }
107 const double lambda = spec.p1;
108 if (!std::isfinite(lo)) lo = 0.0;
109 if (!std::isfinite(hi)) hi = 6.0 / lambda;
110 break;
111 }
113 const double k = spec.p1, lambda = spec.p2;
114 if (!std::isfinite(lo)) lo = 0.0;
115 if (!std::isfinite(hi)) hi = std::max(2.0 * k / lambda, 6.0 / lambda);
116 break;
117 }
118 }
119 return {lo, hi};
120}
121
122/**
123 * @brief Per-sample truncated PDF for a single-RV arm. Returns the
124 * unconditional value when @c truncated == @c false. Yields
125 * @c NaN when the closed-form PDF doesn't cover the spec
126 * (e.g. non-integer Erlang shape, propagated from
127 * @c provsql::pdfAt).
128 */
129double bare_pdf(const provsql::TruncatedSingleRv &t, double x)
130{
131 double p = provsql::pdfAt(t.spec, x);
132 if (std::isnan(p)) return std::numeric_limits<double>::quiet_NaN();
133 if (!t.truncated) return p;
134 if (x < t.lo || x > t.hi) return 0.0;
135 const double cdf_lo = std::isfinite(t.lo) ? provsql::cdfAt(t.spec, t.lo) : 0.0;
136 const double cdf_hi = std::isfinite(t.hi) ? provsql::cdfAt(t.spec, t.hi) : 1.0;
137 const double Z = cdf_hi - cdf_lo;
138 if (!(Z > 0.0)) return std::numeric_limits<double>::quiet_NaN();
139 return p / Z;
140}
141
142double bare_cdf(const provsql::TruncatedSingleRv &t, double x)
143{
144 double c = provsql::cdfAt(t.spec, x);
145 if (std::isnan(c)) return std::numeric_limits<double>::quiet_NaN();
146 if (!t.truncated) return c;
147 if (x < t.lo) return 0.0;
148 if (x > t.hi) return 1.0;
149 const double cdf_lo = std::isfinite(t.lo) ? provsql::cdfAt(t.spec, t.lo) : 0.0;
150 const double cdf_hi = std::isfinite(t.hi) ? provsql::cdfAt(t.spec, t.hi) : 1.0;
151 const double Z = cdf_hi - cdf_lo;
152 if (!(Z > 0.0)) return std::numeric_limits<double>::quiet_NaN();
153 return (c - cdf_lo) / Z;
154}
155
156/**
157 * @brief Recursive @c pdf(x) over the @c ClosedFormShape variant.
158 * Dirac / categorical arms contribute 0 (point masses live in
159 * the @c stems channel, not in the continuous PDF). Mixtures
160 * combine arms linearly with the Bernoulli weight.
161 */
162double shape_pdf(const provsql::ClosedFormShape &s, double x);
163double shape_cdf(const provsql::ClosedFormShape &s, double x);
164
165double shape_pdf(const provsql::ClosedFormShape &s, double x)
166{
167 return std::visit([&](const auto &v) -> double {
168 using T = std::decay_t<decltype(v)>;
169 if constexpr (std::is_same_v<T, provsql::TruncatedSingleRv>) {
170 return bare_pdf(v, x);
171 } else if constexpr (std::is_same_v<T, provsql::DiracShape>) {
172 (void)x;
173 return 0.0;
174 } else if constexpr (std::is_same_v<T, provsql::CategoricalShape>) {
175 (void)x;
176 return 0.0;
177 } else if constexpr (std::is_same_v<T, provsql::BernoulliMixtureShape>) {
178 const double pl = shape_pdf(*v.left, x);
179 const double pr = shape_pdf(*v.right, x);
180 if (std::isnan(pl) || std::isnan(pr))
181 return std::numeric_limits<double>::quiet_NaN();
182 return v.p * pl + (1.0 - v.p) * pr;
183 }
184 return std::numeric_limits<double>::quiet_NaN();
185 }, s);
186}
187
188double shape_cdf(const provsql::ClosedFormShape &s, double x)
189{
190 return std::visit([&](const auto &v) -> double {
191 using T = std::decay_t<decltype(v)>;
192 if constexpr (std::is_same_v<T, provsql::TruncatedSingleRv>) {
193 return bare_cdf(v, x);
194 } else if constexpr (std::is_same_v<T, provsql::DiracShape>) {
195 return (x >= v.value) ? 1.0 : 0.0;
196 } else if constexpr (std::is_same_v<T, provsql::CategoricalShape>) {
197 double sum = 0.0;
198 for (const auto &pr : v.outcomes) if (pr.first <= x) sum += pr.second;
199 return sum;
200 } else if constexpr (std::is_same_v<T, provsql::BernoulliMixtureShape>) {
201 const double cl = shape_cdf(*v.left, x);
202 const double cr = shape_cdf(*v.right, x);
203 if (std::isnan(cl) || std::isnan(cr))
204 return std::numeric_limits<double>::quiet_NaN();
205 return v.p * cl + (1.0 - v.p) * cr;
206 }
207 return std::numeric_limits<double>::quiet_NaN();
208 }, s);
209}
210
211bool shape_has_continuous(const provsql::ClosedFormShape &s)
212{
213 return std::visit([](const auto &v) -> bool {
214 using T = std::decay_t<decltype(v)>;
215 if constexpr (std::is_same_v<T, provsql::TruncatedSingleRv>) return true;
216 else if constexpr (std::is_same_v<T, provsql::DiracShape>) return false;
217 else if constexpr (std::is_same_v<T, provsql::CategoricalShape>) return false;
218 else if constexpr (std::is_same_v<T, provsql::BernoulliMixtureShape>)
219 return shape_has_continuous(*v.left) || shape_has_continuous(*v.right);
220 return false;
221 }, s);
222}
223
224/**
225 * @brief Walk the shape collecting weighted stem points. @p weight
226 * is the running Bernoulli product from the path root; the
227 * leaf-level mass is multiplied by it so e.g. a Dirac inside
228 * @c mixture(0.3, X, c) appears at @c (c, 0.7).
229 */
230void shape_stems(const provsql::ClosedFormShape &s, double weight,
231 std::vector<std::pair<double, double>> &out)
232{
233 std::visit([&](const auto &v) {
234 using T = std::decay_t<decltype(v)>;
235 if constexpr (std::is_same_v<T, provsql::TruncatedSingleRv>) {
236 (void)v; // continuous arm: contributes no stems
237 } else if constexpr (std::is_same_v<T, provsql::DiracShape>) {
238 out.emplace_back(v.value, weight);
239 } else if constexpr (std::is_same_v<T, provsql::CategoricalShape>) {
240 for (const auto &pr : v.outcomes)
241 out.emplace_back(pr.first, weight * pr.second);
242 } else if constexpr (std::is_same_v<T, provsql::BernoulliMixtureShape>) {
243 shape_stems(*v.left, weight * v.p, out);
244 shape_stems(*v.right, weight * (1.0 - v.p), out);
245 }
246 }, s);
247}
248
249std::pair<double, double> shape_x_range(const provsql::ClosedFormShape &s)
250{
251 return std::visit([](const auto &v) -> std::pair<double, double> {
252 using T = std::decay_t<decltype(v)>;
253 if constexpr (std::is_same_v<T, provsql::TruncatedSingleRv>) {
254 return bare_x_range(v.spec, v.lo, v.hi);
255 } else if constexpr (std::is_same_v<T, provsql::DiracShape>) {
256 /* Pure Dirac: pad ±1 around the point so the disc isn't flush
257 * against the SVG edge. When this Dirac is nested under a
258 * mixture, the sibling's range usually dominates. */
259 return {v.value - 1.0, v.value + 1.0};
260 } else if constexpr (std::is_same_v<T, provsql::CategoricalShape>) {
261 double mn = std::numeric_limits<double>::infinity();
262 double mx = -std::numeric_limits<double>::infinity();
263 for (const auto &pr : v.outcomes) {
264 mn = std::min(mn, pr.first);
265 mx = std::max(mx, pr.first);
266 }
267 const double range = mx - mn;
268 const double pad = range > 0.0 ? 0.1 * range : 1.0;
269 return {mn - pad, mx + pad};
270 } else if constexpr (std::is_same_v<T, provsql::BernoulliMixtureShape>) {
271 const auto L = shape_x_range(*v.left);
272 const auto R = shape_x_range(*v.right);
273 return {std::min(L.first, R.first), std::max(L.second, R.second)};
274 }
275 return {0.0, 1.0};
276 }, s);
277}
278
279} // namespace
280
281namespace provsql {
282
283// Exact histogram of a closed-form shape: `bins` equal-width bins over the
284// shape's natural plotting range, each carrying the analytical probability
285// mass cdf(hi) - cdf(lo). Lets rv_histogram answer for a closed-form
286// distribution (e.g. a truncated Gaussian) without sampling -- in
287// particular under provsql.rv_mc_samples = 0. Returns nullopt when the
288// range is degenerate or the CDF is unavailable (non-integer Erlang, an
289// unmatched arm), so the caller can fall back to Monte Carlo.
290std::optional<std::vector<std::tuple<double, double, double>>>
292{
293 if (bins <= 0) return std::nullopt;
294 const auto [xlo, xhi] = shape_x_range(shape);
295 if (!(xlo < xhi) || !std::isfinite(xlo) || !std::isfinite(xhi))
296 return std::nullopt;
297 const double w = (xhi - xlo) / bins;
298 std::vector<std::tuple<double, double, double>> out;
299 out.reserve(bins);
300 for (int i = 0; i < bins; ++i) {
301 const double lo = xlo + i * w;
302 const double hi = (i == bins - 1) ? xhi : lo + w;
303 const double cl = shape_cdf(shape, lo);
304 const double ch = shape_cdf(shape, hi);
305 if (std::isnan(cl) || std::isnan(ch)) return std::nullopt;
306 out.emplace_back(lo, hi, std::max(0.0, ch - cl));
307 }
308 return out;
309}
310
311} // namespace provsql
312
313extern "C" Datum
314rv_analytical_curves(PG_FUNCTION_ARGS)
315{
316 pg_uuid_t *token = (pg_uuid_t *) PG_GETARG_POINTER(0);
317 int32 samples = PG_GETARG_INT32(1);
318 pg_uuid_t *prov = (pg_uuid_t *) PG_GETARG_POINTER(2);
319
320 if (samples < 2)
322 "rv_analytical_curves: samples must be at least 2 (got %d)",
323 samples);
324
325 try {
326 gate_t root_gate, event_gate;
328 try {
329 gc = getJointCircuit(*token, *prov, root_gate, event_gate);
330 } catch (const CircuitException &) {
331 PG_RETURN_NULL();
332 }
333
334 /* A stored "X | C" arrives as a conditioned root: peel it to the bare
335 * scalar target and fold the condition into the event, so the closed-form
336 * match below sees the bare distribution truncated by the event rather
337 * than a gate_conditioned it cannot match (which would drop to
338 * histogram-only). */
339 std::optional<gate_t> event_opt;
340 if (gc.getGateType(event_gate) != gate_one) event_opt = event_gate;
341 root_gate = provsql::lift_conditioning(gc, root_gate, event_opt);
342
343 /* Run the hybrid-evaluator simplifier so the analytical curves
344 * see the same folded tree Studio's circuit view shows via
345 * simplified_circuit_subgraph: c·Exp(λ) → Exp(λ/c), N(μ,σ)+N(...)
346 * → single normal, Erlang sums, etc. Without this pass the
347 * c·Exp(λ) root would be a gate_arith composite that
348 * matchClosedFormDistribution does not match, so the panel would
349 * silently fall back to histogram-only on a circuit that looks
350 * like a single Exp node. */
353
354 /* Generalised closed-form match: bare RV (with optional
355 * truncation), Dirac, categorical, or Bernoulli mixture over any
356 * recursively-matched shape. Non-matched shapes (gate_arith
357 * composites, mismatched Erlang shapes, ...) fall through to
358 * NULL so the front-end renders histogram-only without a
359 * structural pre-check. */
361 gc, root_gate, event_opt);
362 if (!shape) PG_RETURN_NULL();
363
364 std::vector<std::pair<double, double>> stems;
365 shape_stems(*shape, 1.0, stems);
366 const bool has_cont = shape_has_continuous(*shape);
367
368 /* Nothing to render: shouldn't normally happen (shape matched
369 * but produced neither continuous nor discrete output), but
370 * guards against an empty stem list from a categorical with all
371 * zero-mass outcomes etc. */
372 if (!has_cont && stems.empty()) PG_RETURN_NULL();
373
374 /* x-range chosen over the full shape; for a mixture this is the
375 * union of branch ranges, so the curve covers both modes. A
376 * pure-stems shape still gets a small window for the chart axis. */
377 auto [x_lo, x_hi] = shape_x_range(*shape);
378 if (!(x_lo < x_hi)) PG_RETURN_NULL();
379
380 std::ostringstream out;
381 /* setprecision(17) keeps each sample bit-round-trippable through
382 * jsonb_in's parser, matching the convention used by rv_histogram
383 * for its bin_lo / bin_hi fields. */
384 out << std::setprecision(17);
385 out << '{';
386 bool first_field = true;
387
388 /* CDF is well-defined for every supported shape (a staircase for
389 * pure-discrete, a smooth curve for continuous, a curve-with-
390 * jumps for mixed), so emit it unconditionally. PDF is only
391 * meaningful when there's a continuous component; for pure
392 * point-mass shapes the pdf samples would all be zero and the
393 * smooth overlay path would be meaningless. */
394 std::ostringstream pdf_out;
395 pdf_out << std::setprecision(17);
396 if (has_cont) pdf_out << "\"pdf\":[";
397 out << "\"cdf\":[";
398 for (int i = 0; i < samples; ++i) {
399 const double t = static_cast<double>(i) / (samples - 1);
400 const double x = x_lo + t * (x_hi - x_lo);
401 const double cdf_x = shape_cdf(*shape, x);
402 if (std::isnan(cdf_x)) PG_RETURN_NULL();
403 if (i > 0) out << ',';
404 out << "{\"x\":" << x << ",\"p\":" << cdf_x << '}';
405 if (has_cont) {
406 const double pdf_x = shape_pdf(*shape, x);
407 if (std::isnan(pdf_x)) PG_RETURN_NULL();
408 if (i > 0) pdf_out << ',';
409 pdf_out << "{\"x\":" << x << ",\"p\":" << pdf_x << '}';
410 }
411 }
412 out << ']';
413 if (has_cont) {
414 pdf_out << ']';
415 out << ',' << pdf_out.str();
416 }
417 first_field = false;
418
419 if (!stems.empty()) {
420 if (!first_field) out << ',';
421 out << "\"stems\":[";
422 for (std::size_t i = 0; i < stems.size(); ++i) {
423 if (i > 0) out << ',';
424 out << "{\"x\":" << stems[i].first
425 << ",\"p\":" << stems[i].second << '}';
426 }
427 out << ']';
428 }
429 out << '}';
430
431 Datum json = DirectFunctionCall1(
432 jsonb_in, CStringGetDatum(pstrdup(out.str().c_str())));
433 PG_RETURN_DATUM(json);
434 } catch (const std::exception &e) {
435 provsql_error("rv_analytical_curves: %s", e.what());
436 } catch (...) {
437 provsql_error("rv_analytical_curves: unknown exception");
438 }
439 PG_RETURN_NULL();
440}
Closed-form CDF resolution for trivial gate_cmp shapes.
GenericCircuit getJointCircuit(pg_uuid_t root_token, pg_uuid_t event_token, gate_t &root_gate, gate_t &event_gate)
Build a GenericCircuit containing the closures of two roots, with shared subgraphs unified.
Build in-memory circuits from the mmap-backed persistent store.
gate_t
Strongly-typed gate identifier.
Definition Circuit.h:49
Analytical expectation / variance / moment evaluator over RV circuits.
Semiring-agnostic in-memory provenance circuit.
Peephole simplifier for continuous gate_arith sub-circuits.
Continuous random-variable helpers (distribution parsing, moments).
Support-based bound check for continuous-RV comparators.
Datum rv_analytical_curves(PG_FUNCTION_ARGS)
Exception type thrown by circuit operations on invalid input.
Definition Circuit.h:206
gateType getGateType(gate_t g) const
Return the type of gate g.
Definition Circuit.h:130
In-memory provenance circuit with semiring-generic evaluation.
@ Normal
Normal (Gaussian): p1=μ, p2=σ
@ Exponential
Exponential: p1=λ, p2 unused.
@ Uniform
Uniform on [a,b]: p1=a, p2=b.
@ Erlang
Erlang: p1=k (positive integer), p2=λ.
std::optional< ClosedFormShape > matchClosedFormDistribution(const GenericCircuit &gc, gate_t root, std::optional< gate_t > event_root)
Detect any of the closed-form shapes supported by rv_analytical_curves.
std::variant< TruncatedSingleRv, DiracShape, CategoricalShape, BernoulliMixtureShape > ClosedFormShape
One of the closed-form shapes the analytical-curves payload can render: bare RV (continuous PDF/CDF),...
Definition RangeCheck.h:201
gate_t lift_conditioning(GenericCircuit &gc, gate_t root, std::optional< gate_t > &event_opt)
Lift conditioning out of a scalar arithmetic expression.
std::optional< std::vector< std::tuple< double, double, double > > > analyticalHistogram(const ClosedFormShape &shape, int bins)
Exact histogram (bin_lo, bin_hi, probability mass) of a closed-form shape, in bins equal-width bins o...
unsigned runHybridSimplifier(GenericCircuit &gc)
Run the peephole simplifier over gc.
double pdfAt(const DistributionSpec &d, double c)
Closed-form probability density for a basic distribution.
double cdfAt(const DistributionSpec &d, double c)
Closed-form CDF for a basic continuous distribution.
bool provsql_hybrid_evaluation
Run the hybrid-evaluator simplifier inside probability_evaluate; controlled by the provsql....
Definition provsql.c:104
Uniform error-reporting macros for ProvSQL.
#define provsql_error(fmt,...)
Report a fatal ProvSQL error and abort the current transaction.
Core types, constants, and utilities shared across ProvSQL.
C++ utility functions for UUID manipulation.
UUID structure.
Parsed distribution spec (kind + up to two parameters).
double p2
Second parameter (σ or b; unused for Exponential).
double p1
First parameter (μ, a, or λ).
Detection result for a closed-form, optionally-truncated single-RV shape.
Definition RangeCheck.h:102
double lo
Lower bound (-INF if unbounded).
Definition RangeCheck.h:104
DistributionSpec spec
Parsed kind + parameters.
Definition RangeCheck.h:103
double hi
Upper bound (+INF if unbounded).
Definition RangeCheck.h:105
bool truncated
True iff the bounds came from a non-trivial event_root.
Definition RangeCheck.h:106