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 of any registered family (window, density,
24 * and distribution via @c Distribution::plotRange / @c pdf /
25 * @c cdf; a NaN pdf/cdf on the grid returns @c NULL), optionally
26 * truncated by an AND-conjunct event extracted via
27 * @c collectRvConstraints;
28 * - Dirac point (@c gate_value with finite extra, surfaced by
29 * @c provsql.as_random);
30 * - categorical-form @c gate_mixture (one @c {key, mul_1..n});
31 * - classic Bernoulli @c gate_mixture (@c [p_token, x, y]) over any
32 * two recursively-matched shapes; @c p_token must be a bare
33 * @c gate_input (compound Boolean @c p bails).
34 *
35 * A non-trivial conditioning event is honoured on all four arms via
36 * @c truncateShape (see @c matchClosedFormDistribution).
37 *
38 * @see provsql::matchClosedFormDistribution in RangeCheck.h
39 */
40extern "C" {
41#include "postgres.h"
42#include "fmgr.h"
43#include "utils/jsonb.h"
44#include "utils/fmgrprotos.h"
45#include "utils/uuid.h"
46#include "provsql_utils.h"
47#include "provsql_error.h"
48
49PG_FUNCTION_INFO_V1(rv_analytical_curves);
50}
51
52#include "AnalyticEvaluator.h" // pdfAt, cdfAt
53#include "CircuitFromMMap.h" // getJointCircuit
54#include "Expectation.h" // lift_conditioning
55#include "GenericCircuit.h"
56#include "HybridEvaluator.h" // runHybridSimplifier
57#include "RandomVariable.h" // DistributionSpec
58#include "distributions/Distribution.h" // makeDistribution -> plotRange
59#include "RangeCheck.h" // matchClosedFormDistribution + variant
60#include "provsql_utils_cpp.h"
61
62#include <algorithm>
63#include <cmath>
64#include <iomanip>
65#include <limits>
66#include <optional>
67#include <sstream>
68#include <tuple>
69#include <type_traits>
70#include <utility>
71#include <variant>
72#include <vector>
73
74namespace {
75
76/**
77 * @brief Choose a sensible x-range for the continuous curve given a
78 * single-RV spec and an optional truncation.
79 *
80 * Unbounded distributions (Normal) get a heuristic window around the
81 * mean; bounded distributions (Uniform) get a slight padding so the
82 * support boundary doesn't sit flush with the SVG edge; one-sided
83 * supports (Exponential, Erlang) get @c 6/λ on the right.
84 *
85 * Truncation clamps the window: the curve never extends past the
86 * conditioning event's interval.
87 */
88std::pair<double, double>
89bare_x_range(const provsql::DistributionSpec &spec,
90 double trunc_lo, double trunc_hi)
91{
92 return provsql::makeDistribution(spec)->plotRange(trunc_lo, trunc_hi);
93}
94
95/**
96 * @brief Per-sample truncated PDF for a single-RV arm. Returns the
97 * unconditional value when @c truncated == @c false. Yields
98 * @c NaN when the closed-form PDF doesn't cover the spec
99 * (e.g. non-integer Erlang shape, propagated from
100 * @c provsql::pdfAt).
101 */
102double bare_pdf(const provsql::TruncatedSingleRv &t, double x)
103{
104 double p = provsql::pdfAt(t.spec, x);
105 if (std::isnan(p)) return std::numeric_limits<double>::quiet_NaN();
106 if (!t.truncated) return p;
107 if (x < t.lo || x > t.hi) return 0.0;
108 const double cdf_lo = std::isfinite(t.lo) ? provsql::cdfAt(t.spec, t.lo) : 0.0;
109 const double cdf_hi = std::isfinite(t.hi) ? provsql::cdfAt(t.spec, t.hi) : 1.0;
110 const double Z = cdf_hi - cdf_lo;
111 if (!(Z > 0.0)) return std::numeric_limits<double>::quiet_NaN();
112 return p / Z;
113}
114
115double bare_cdf(const provsql::TruncatedSingleRv &t, double x)
116{
117 double c = provsql::cdfAt(t.spec, x);
118 if (std::isnan(c)) return std::numeric_limits<double>::quiet_NaN();
119 if (!t.truncated) return c;
120 if (x < t.lo) return 0.0;
121 if (x > t.hi) return 1.0;
122 const double cdf_lo = std::isfinite(t.lo) ? provsql::cdfAt(t.spec, t.lo) : 0.0;
123 const double cdf_hi = std::isfinite(t.hi) ? provsql::cdfAt(t.spec, t.hi) : 1.0;
124 const double Z = cdf_hi - cdf_lo;
125 if (!(Z > 0.0)) return std::numeric_limits<double>::quiet_NaN();
126 return (c - cdf_lo) / Z;
127}
128
129/**
130 * @brief Recursive @c pdf(x) over the @c ClosedFormShape variant.
131 * Dirac / categorical arms contribute 0 (point masses live in
132 * the @c stems channel, not in the continuous PDF). Mixtures
133 * combine arms linearly with the Bernoulli weight.
134 */
135double shape_pdf(const provsql::ClosedFormShape &s, double x);
136double shape_cdf(const provsql::ClosedFormShape &s, double x);
137
138double shape_pdf(const provsql::ClosedFormShape &s, double x)
139{
140 return std::visit([&](const auto &v) -> double {
141 using T = std::decay_t<decltype(v)>;
142 if constexpr (std::is_same_v<T, provsql::TruncatedSingleRv>) {
143 return bare_pdf(v, x);
144 } else if constexpr (std::is_same_v<T, provsql::DiracShape>) {
145 (void)x;
146 return 0.0;
147 } else if constexpr (std::is_same_v<T, provsql::CategoricalShape>) {
148 (void)x;
149 return 0.0;
150 } else if constexpr (std::is_same_v<T, provsql::BernoulliMixtureShape>) {
151 const double pl = shape_pdf(*v.left, x);
152 const double pr = shape_pdf(*v.right, x);
153 if (std::isnan(pl) || std::isnan(pr))
154 return std::numeric_limits<double>::quiet_NaN();
155 return v.p * pl + (1.0 - v.p) * pr;
156 }
157 return std::numeric_limits<double>::quiet_NaN();
158 }, s);
159}
160
161double shape_cdf(const provsql::ClosedFormShape &s, double x)
162{
163 return std::visit([&](const auto &v) -> double {
164 using T = std::decay_t<decltype(v)>;
165 if constexpr (std::is_same_v<T, provsql::TruncatedSingleRv>) {
166 return bare_cdf(v, x);
167 } else if constexpr (std::is_same_v<T, provsql::DiracShape>) {
168 return (x >= v.value) ? 1.0 : 0.0;
169 } else if constexpr (std::is_same_v<T, provsql::CategoricalShape>) {
170 double sum = 0.0;
171 for (const auto &pr : v.outcomes) if (pr.first <= x) sum += pr.second;
172 return sum;
173 } else if constexpr (std::is_same_v<T, provsql::BernoulliMixtureShape>) {
174 const double cl = shape_cdf(*v.left, x);
175 const double cr = shape_cdf(*v.right, x);
176 if (std::isnan(cl) || std::isnan(cr))
177 return std::numeric_limits<double>::quiet_NaN();
178 return v.p * cl + (1.0 - v.p) * cr;
179 }
180 return std::numeric_limits<double>::quiet_NaN();
181 }, s);
182}
183
184bool shape_has_continuous(const provsql::ClosedFormShape &s)
185{
186 return std::visit([](const auto &v) -> bool {
187 using T = std::decay_t<decltype(v)>;
188 if constexpr (std::is_same_v<T, provsql::TruncatedSingleRv>) return true;
189 else if constexpr (std::is_same_v<T, provsql::DiracShape>) return false;
190 else if constexpr (std::is_same_v<T, provsql::CategoricalShape>) return false;
191 else if constexpr (std::is_same_v<T, provsql::BernoulliMixtureShape>)
192 return shape_has_continuous(*v.left) || shape_has_continuous(*v.right);
193 return false;
194 }, s);
195}
196
197/**
198 * @brief Walk the shape collecting weighted stem points. @p weight
199 * is the running Bernoulli product from the path root; the
200 * leaf-level mass is multiplied by it so e.g. a Dirac inside
201 * @c mixture(0.3, X, c) appears at @c (c, 0.7).
202 */
203void shape_stems(const provsql::ClosedFormShape &s, double weight,
204 std::vector<std::pair<double, double>> &out)
205{
206 std::visit([&](const auto &v) {
207 using T = std::decay_t<decltype(v)>;
208 if constexpr (std::is_same_v<T, provsql::TruncatedSingleRv>) {
209 (void)v; // continuous arm: contributes no stems
210 } else if constexpr (std::is_same_v<T, provsql::DiracShape>) {
211 out.emplace_back(v.value, weight);
212 } else if constexpr (std::is_same_v<T, provsql::CategoricalShape>) {
213 for (const auto &pr : v.outcomes)
214 out.emplace_back(pr.first, weight * pr.second);
215 } else if constexpr (std::is_same_v<T, provsql::BernoulliMixtureShape>) {
216 shape_stems(*v.left, weight * v.p, out);
217 shape_stems(*v.right, weight * (1.0 - v.p), out);
218 }
219 }, s);
220}
221
222std::pair<double, double> shape_x_range(const provsql::ClosedFormShape &s)
223{
224 return std::visit([](const auto &v) -> std::pair<double, double> {
225 using T = std::decay_t<decltype(v)>;
226 if constexpr (std::is_same_v<T, provsql::TruncatedSingleRv>) {
227 return bare_x_range(v.spec, v.lo, v.hi);
228 } else if constexpr (std::is_same_v<T, provsql::DiracShape>) {
229 /* Pure Dirac: pad ±1 around the point so the disc isn't flush
230 * against the SVG edge. When this Dirac is nested under a
231 * mixture, the sibling's range usually dominates. */
232 return {v.value - 1.0, v.value + 1.0};
233 } else if constexpr (std::is_same_v<T, provsql::CategoricalShape>) {
234 double mn = std::numeric_limits<double>::infinity();
235 double mx = -std::numeric_limits<double>::infinity();
236 for (const auto &pr : v.outcomes) {
237 mn = std::min(mn, pr.first);
238 mx = std::max(mx, pr.first);
239 }
240 const double range = mx - mn;
241 const double pad = range > 0.0 ? 0.1 * range : 1.0;
242 return {mn - pad, mx + pad};
243 } else if constexpr (std::is_same_v<T, provsql::BernoulliMixtureShape>) {
244 const auto L = shape_x_range(*v.left);
245 const auto R = shape_x_range(*v.right);
246 return {std::min(L.first, R.first), std::max(L.second, R.second)};
247 }
248 return {0.0, 1.0};
249 }, s);
250}
251
252} // namespace
253
254namespace provsql {
255
256// Exact histogram of a closed-form shape: `bins` equal-width bins over the
257// shape's natural plotting range, each carrying the analytical probability
258// mass cdf(hi) - cdf(lo). Lets rv_histogram answer for a closed-form
259// distribution (e.g. a truncated Gaussian) without sampling -- in
260// particular under provsql.rv_mc_samples = 0. Returns nullopt when the
261// range is degenerate or the CDF is unavailable (non-integer Erlang, an
262// unmatched arm), so the caller can fall back to Monte Carlo.
263std::optional<std::vector<std::tuple<double, double, double>>>
265{
266 if (bins <= 0) return std::nullopt;
267 const auto [xlo, xhi] = shape_x_range(shape);
268 if (!(xlo < xhi) || !std::isfinite(xlo) || !std::isfinite(xhi))
269 return std::nullopt;
270 const double w = (xhi - xlo) / bins;
271 std::vector<std::tuple<double, double, double>> out;
272 out.reserve(bins);
273 for (int i = 0; i < bins; ++i) {
274 const double lo = xlo + i * w;
275 const double hi = (i == bins - 1) ? xhi : lo + w;
276 const double cl = shape_cdf(shape, lo);
277 const double ch = shape_cdf(shape, hi);
278 if (std::isnan(cl) || std::isnan(ch)) return std::nullopt;
279 out.emplace_back(lo, hi, std::max(0.0, ch - cl));
280 }
281 return out;
282}
283
284} // namespace provsql
285
286extern "C" Datum
287rv_analytical_curves(PG_FUNCTION_ARGS)
288{
289 pg_uuid_t *token = (pg_uuid_t *) PG_GETARG_POINTER(0);
290 int32 samples = PG_GETARG_INT32(1);
291 pg_uuid_t *prov = (pg_uuid_t *) PG_GETARG_POINTER(2);
292
293 if (samples < 2)
295 "rv_analytical_curves: samples must be at least 2 (got %d)",
296 samples);
297
298 try {
299 gate_t root_gate, event_gate;
301 try {
302 gc = getJointCircuit(*token, *prov, root_gate, event_gate);
303 } catch (const CircuitException &) {
304 PG_RETURN_NULL();
305 }
306
307 /* A stored "X | C" arrives as a conditioned root: peel it to the bare
308 * scalar target and fold the condition into the event, so the closed-form
309 * match below sees the bare distribution truncated by the event rather
310 * than a gate_conditioned it cannot match (which would drop to
311 * histogram-only). */
312 std::optional<gate_t> event_opt;
313 if (gc.getGateType(event_gate) != gate_one) event_opt = event_gate;
314 root_gate = provsql::lift_conditioning(gc, root_gate, event_opt);
315
316 /* Run the hybrid-evaluator simplifier so the analytical curves
317 * see the same folded tree Studio's circuit view shows via
318 * simplified_circuit_subgraph: c·Exp(λ) → Exp(λ/c), N(μ,σ)+N(...)
319 * → single normal, Erlang sums, etc. Without this pass the
320 * c·Exp(λ) root would be a gate_arith composite that
321 * matchClosedFormDistribution does not match, so the panel would
322 * silently fall back to histogram-only on a circuit that looks
323 * like a single Exp node. */
326
327 /* Generalised closed-form match: bare RV (with optional
328 * truncation), Dirac, categorical, or Bernoulli mixture over any
329 * recursively-matched shape. Non-matched shapes (gate_arith
330 * composites, mismatched Erlang shapes, ...) fall through to
331 * NULL so the front-end renders histogram-only without a
332 * structural pre-check. */
334 gc, root_gate, event_opt);
335 if (!shape) PG_RETURN_NULL();
336
337 std::vector<std::pair<double, double>> stems;
338 shape_stems(*shape, 1.0, stems);
339 const bool has_cont = shape_has_continuous(*shape);
340
341 /* Nothing to render: shouldn't normally happen (shape matched
342 * but produced neither continuous nor discrete output), but
343 * guards against an empty stem list from a categorical with all
344 * zero-mass outcomes etc. */
345 if (!has_cont && stems.empty()) PG_RETURN_NULL();
346
347 /* x-range chosen over the full shape; for a mixture this is the
348 * union of branch ranges, so the curve covers both modes. A
349 * pure-stems shape still gets a small window for the chart axis. */
350 auto [x_lo, x_hi] = shape_x_range(*shape);
351 if (!(x_lo < x_hi)) PG_RETURN_NULL();
352
353 std::ostringstream out;
354 /* setprecision(17) keeps each sample bit-round-trippable through
355 * jsonb_in's parser, matching the convention used by rv_histogram
356 * for its bin_lo / bin_hi fields. */
357 out << std::setprecision(17);
358 out << '{';
359 bool first_field = true;
360
361 /* CDF is well-defined for every supported shape (a staircase for
362 * pure-discrete, a smooth curve for continuous, a curve-with-
363 * jumps for mixed), so emit it unconditionally. PDF is only
364 * meaningful when there's a continuous component; for pure
365 * point-mass shapes the pdf samples would all be zero and the
366 * smooth overlay path would be meaningless. */
367 std::ostringstream pdf_out;
368 pdf_out << std::setprecision(17);
369 if (has_cont) pdf_out << "\"pdf\":[";
370 out << "\"cdf\":[";
371 for (int i = 0; i < samples; ++i) {
372 const double t = static_cast<double>(i) / (samples - 1);
373 const double x = x_lo + t * (x_hi - x_lo);
374 const double cdf_x = shape_cdf(*shape, x);
375 if (std::isnan(cdf_x)) PG_RETURN_NULL();
376 if (i > 0) out << ',';
377 out << "{\"x\":" << x << ",\"p\":" << cdf_x << '}';
378 if (has_cont) {
379 const double pdf_x = shape_pdf(*shape, x);
380 if (std::isnan(pdf_x)) PG_RETURN_NULL();
381 if (i > 0) pdf_out << ',';
382 pdf_out << "{\"x\":" << x << ",\"p\":" << pdf_x << '}';
383 }
384 }
385 out << ']';
386 if (has_cont) {
387 pdf_out << ']';
388 out << ',' << pdf_out.str();
389 }
390 first_field = false;
391
392 if (!stems.empty()) {
393 if (!first_field) out << ',';
394 out << "\"stems\":[";
395 for (std::size_t i = 0; i < stems.size(); ++i) {
396 if (i > 0) out << ',';
397 out << "{\"x\":" << stems[i].first
398 << ",\"p\":" << stems[i].second << '}';
399 }
400 out << ']';
401 }
402 out << '}';
403
404 Datum json = DirectFunctionCall1(
405 jsonb_in, CStringGetDatum(pstrdup(out.str().c_str())));
406 PG_RETURN_DATUM(json);
407 } catch (const std::exception &e) {
408 provsql_error("rv_analytical_curves: %s", e.what());
409 } catch (...) {
410 provsql_error("rv_analytical_curves: unknown exception");
411 }
412 PG_RETURN_NULL();
413}
Closed-form CDF resolution for trivial gate_cmp shapes.
GenericCircuit getJointCircuit(const std::vector< pg_uuid_t > &tokens, std::vector< gate_t > &gates)
Multi-root variant of getJointCircuit.
Build in-memory circuits from the mmap-backed persistent store.
gate_t
Strongly-typed gate identifier.
Definition Circuit.h:49
Per-family polymorphic view over a continuous gate_rv distribution (§F.1 class hierarchy).
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.
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...
std::unique_ptr< Distribution > makeDistribution(const DistributionSpec &spec)
Construct the per-family Distribution for a parsed spec.
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:110
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 (family + up to two parameters).
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 family + 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