\set ECHO none \pset format unaligned -- Case Study 6: The City Air-Quality Sensor Network -- Tests the random_variable surface end-to-end: gate_rv leaves for the -- four distribution families, the planner-hook lifting of WHERE -- comparators on RV columns (gate_cmp), Bernoulli mixtures via -- provsql.mixture, RV aggregates (sum / avg) lowered through -- rv_aggregate_semimod, expected() applied to a grouped RV via the -- subquery + outer-WHERE idiom, UNION ALL on RV columns, and an -- independent / tree-decomposition / monte-carlo cross-check. Backs -- doc/source/user/casestudy6.rst. -- Pin the MC RNG and sample budget so cases that fall back to MC -- (probabilities, conditional moments) are reproducible at a tolerance -- loose enough to absorb sampler noise but tight enough to be -- meaningful. SET provsql.monte_carlo_seed = 42; SET provsql.rv_mc_samples = 50000; -- --------------------------------------------------------------------- -- Setup: schema and seed, mirroring doc/casestudy6/setup.sql. -- --------------------------------------------------------------------- CREATE TABLE stations ( id text PRIMARY KEY, name text NOT NULL, district text NOT NULL ); INSERT INTO stations VALUES ('s1', 'City Centre', 'centre'), ('s2', 'Riverside Park', 'centre'), ('s3', 'Industrial Estate', 'east'), ('s4', 'Suburban Reference', 'east'); SELECT add_provenance('stations'); CREATE TABLE calibration_status ( station_id text PRIMARY KEY REFERENCES stations(id), p double precision NOT NULL ); INSERT INTO calibration_status VALUES ('s1', 0.95), ('s2', 0.70), ('s3', 0.60), ('s4', 1.00); SELECT add_provenance('calibration_status'); CREATE TABLE readings ( id integer PRIMARY KEY, station_id text NOT NULL REFERENCES stations(id), ts timestamp NOT NULL, pm25 random_variable -- NULL: sensor offline, no reading ); INSERT INTO readings (id, station_id, ts, pm25) VALUES (1, 's1', '2026-05-12 08:00', provsql.normal(28.0, 2.0)), (2, 's2', '2026-05-12 08:00', provsql.uniform(10.0, 22.0)), (3, 's3', '2026-05-12 08:00', provsql.exponential(0.04)), (4, 's4', '2026-05-12 08:00', 15.0), (5, 's1', '2026-05-12 09:00', provsql.normal(40.0, 4.0)), (6, 's2', '2026-05-12 09:00', provsql.uniform(12.0, 24.0)), (7, 's3', '2026-05-12 09:00', provsql.erlang(3, 0.1)), (8, 's4', '2026-05-12 09:00', 16.5); SELECT add_provenance('readings'); CREATE TABLE historical_readings ( id integer PRIMARY KEY, station_id text NOT NULL REFERENCES stations(id), ts timestamp NOT NULL, pm25 random_variable NOT NULL ); INSERT INTO historical_readings (id, station_id, ts, pm25) VALUES (1, 's1', '2026-05-11 08:00', provsql.normal(34.0, 2.5)), (2, 's2', '2026-05-11 08:00', provsql.uniform(15.0, 28.0)), (3, 's3', '2026-05-11 08:00', provsql.exponential(0.03)), (4, 's4', '2026-05-11 08:00', 18.0), (5, 's1', '2026-05-11 09:00', provsql.normal(42.0, 3.0)), (6, 's2', '2026-05-11 09:00', provsql.uniform(20.0, 35.0)), (7, 's3', '2026-05-11 09:00', provsql.erlang(3, 0.08)), (8, 's4', '2026-05-11 09:00', 19.5); SELECT add_provenance('historical_readings'); -- --------------------------------------------------------------------- -- Step 1: per-row pm25 gates. Rows 1 and 5 are Normal; row 3 is -- Exponential; row 4 is the deterministic reference station lifted via -- the implicit numeric -> random_variable cast (gate_value); row 7 is -- Erlang. One representative pick per shape. The materialise + -- remove_provenance dance hides the row's own auto-added provsql uuid -- column from the output (it's non-deterministic). -- --------------------------------------------------------------------- CREATE TABLE result_cs6_pm25_kind AS SELECT id, get_gate_type(pm25::uuid) AS pm25_kind FROM readings WHERE id IN (1, 3, 4, 7); SELECT remove_provenance('result_cs6_pm25_kind'); SELECT id, pm25_kind FROM result_cs6_pm25_kind ORDER BY id; DROP TABLE result_cs6_pm25_kind; -- --------------------------------------------------------------------- -- Step 2: WHERE pm25 > 35 lifts the comparator into a gate_cmp -- conjoined with each row's input via gate_times; the procedure body -- of random_variable_gt is never executed, so every row survives the -- WHERE. The pm25 column kind on rows 5 and 7 confirms that the lift -- did not strip the underlying distribution. -- --------------------------------------------------------------------- CREATE TABLE result_cs6_thresh AS SELECT id, station_id, get_gate_type(provenance()) AS prov_kind, get_gate_type(pm25::uuid) AS pm25_kind FROM readings WHERE pm25 > 35; SELECT remove_provenance('result_cs6_thresh'); SELECT id, station_id, prov_kind, pm25_kind FROM result_cs6_thresh ORDER BY id; DROP TABLE result_cs6_thresh; -- Probability that the event pm25 > 35 fires on each tracked row. For -- the s1 readings (Normal): -- row 1, N(28, 2): P(X > 35) = 1 - Phi( 3.5) ~= 2.3e-4 -- row 5, N(40, 4): P(X > 35) = 1 - Phi(-1.25) ~= 0.8944 -- The analytic 'independent' path is exact for a single gate_cmp under -- a deterministic input gate; MC at seed=42 must sit within 0.02 of it. CREATE TABLE result_cs6_thresh_prob AS SELECT id, abs(probability_evaluate(provenance(), 'monte-carlo', '50000') - probability_evaluate(provenance(), 'independent')) < 0.02 AS mc_matches_independent FROM readings WHERE pm25 > 35 AND station_id = 's1'; SELECT remove_provenance('result_cs6_thresh_prob'); SELECT id, mc_matches_independent FROM result_cs6_thresh_prob ORDER BY id; DROP TABLE result_cs6_thresh_prob; -- --------------------------------------------------------------------- -- Step 3: provsql.mixture(p, x, y) returns a gate_mixture; the -- pm25 / 1.2 arm (the calibration correction) is a gate_arith(DIV) -- over the per-row pm25 and a gate_value. The constructor result -- type is random_variable, so ::uuid grabs the gate root. -- --------------------------------------------------------------------- CREATE TABLE result_cs6_mixture AS SELECT r.id, get_gate_type( provsql.mixture(cs.p, r.pm25, r.pm25 / 1.2)::uuid ) AS mixture_kind, get_gate_type( (r.pm25 / 1.2)::uuid ) AS scaled_kind FROM readings r JOIN calibration_status cs USING (station_id) WHERE r.station_id = 's1'; SELECT remove_provenance('result_cs6_mixture'); SELECT id, mixture_kind, scaled_kind FROM result_cs6_mixture ORDER BY id; DROP TABLE result_cs6_mixture; -- --------------------------------------------------------------------- -- Step 4: sum(pm25) and avg(pm25) over a tracked RV column lower -- through rv_aggregate_semimod into gate_arith roots. Aggregate -- dispatch on the random_variable result type picks the provsql.sum / -- provsql.avg overloads from plain sum() / avg(), so the case study's -- SQL uses the bare names. -- --------------------------------------------------------------------- CREATE TABLE result_cs6_agg AS SELECT s.district, sum(r.pm25) AS total_pm25, avg(r.pm25) AS avg_pm25 FROM readings r JOIN stations s ON s.id = r.station_id GROUP BY s.district; SELECT remove_provenance('result_cs6_agg'); -- Structure: sum -> gate_arith(PLUS); avg -> gate_arith(DIV) over two -- gate_arith(PLUS) subtrees (numerator / denominator). SELECT district, get_gate_type(total_pm25::uuid) AS sum_root, get_gate_type(avg_pm25::uuid) AS avg_root FROM result_cs6_agg ORDER BY district; -- The 'centre' district has rows 1, 2, 5, 6 (N(28,2), U(10,22), -- N(40,4), U(12,24)) with E[sum] = 28 + 16 + 40 + 18 = 102. -- The 'east' district has rows 3, 4, 7, 8 (Exp(0.04), 15, Erl(3,0.1), -- 16.5) with E[sum] = 25 + 15 + 30 + 16.5 = 86.5. Linearity of -- expectation hits exactly under the analytical evaluator. SELECT district, abs(provsql.expected(total_pm25) - CASE district WHEN 'centre' THEN 102.0 WHEN 'east' THEN 86.5 END) < 1e-9 AS total_mean_exact FROM result_cs6_agg ORDER BY district; DROP TABLE result_cs6_agg; -- --------------------------------------------------------------------- -- Step 8: UNION across the two batches dedups by (station_id, id) and -- combines the two row provenances via gate_plus. Both batches share -- the same id space (1..8), so the keys line up directly. WHERE pm25 -- > 35 lifts the comparator into each contributing row's provenance, -- so the resulting gate_plus wraps two gate_times(input, gate_cmp) -- subtrees. -- --------------------------------------------------------------------- CREATE TABLE result_cs6_union AS SELECT station_id, id, get_gate_type(provenance()) AS prov_kind FROM ( (SELECT station_id, id FROM readings WHERE pm25 > 35) UNION (SELECT station_id, id FROM historical_readings WHERE pm25 > 35) ) t; SELECT remove_provenance('result_cs6_union'); SELECT station_id, id, prov_kind FROM result_cs6_union ORDER BY station_id, id; DROP TABLE result_cs6_union; -- --------------------------------------------------------------------- -- Step 9: filter the per-district aggregates by their expected average. -- The HAVING qual is deterministic from the planner-hook's perspective -- (no agg_token Var, no provenance_aggregate wrapper -- avg(rv) -- returns random_variable and expected() collapses it to a double), so -- needs_having_lift leaves it for PostgreSQL to evaluate natively while -- the per-group provenance still gets a delta(gate_agg) wrapper. -- E[avg(pm25)] is 102/4 = 25.5 for centre and 86.5/4 = 21.625 for -- east, both above 20. -- --------------------------------------------------------------------- CREATE TABLE result_cs6_having AS SELECT s.district, get_gate_type(provenance()) AS prov_kind FROM readings r JOIN stations s ON s.id = r.station_id GROUP BY s.district HAVING expected(avg(r.pm25)) > 20; SELECT remove_provenance('result_cs6_having'); SELECT district, prov_kind FROM result_cs6_having ORDER BY district; DROP TABLE result_cs6_having; -- --------------------------------------------------------------------- -- Step 10: independent vs monte-carlo on the threshold query. For -- each surviving row the closed-form 'independent' result is exact; -- MC at n=50000 must match within 0.02. tree-decomposition is also -- exact and must agree with 'independent' to full precision. -- --------------------------------------------------------------------- CREATE TABLE result_cs6_methods AS SELECT id, abs(probability_evaluate(provenance(), 'independent') - probability_evaluate(provenance(), 'tree-decomposition')) < 1e-9 AS ind_equals_td, abs(probability_evaluate(provenance(), 'independent') - probability_evaluate(provenance(), 'monte-carlo', '50000')) < 0.02 AS ind_close_to_mc FROM readings WHERE pm25 > 35; SELECT remove_provenance('result_cs6_methods'); SELECT id, ind_equals_td, ind_close_to_mc FROM result_cs6_methods ORDER BY id; DROP TABLE result_cs6_methods; -- --------------------------------------------------------------------- -- Step 11: order statistics. max(pm25) lowers to a gate_arith MAX root -- over per-row mixtures (absent-row identity -Infinity); the district -- means are MC at seed 42 (mixture children, no closed form): centre -- ~ 40.04 (dominated by its N(40,4)), east ~ 39.63 (heavy Exp / Erlang -- right tails). The same-row greatest() over two independent bare -- leaves is analytic (layer-cake quadrature), proven by running it -- under rv_mc_samples = 0. -- --------------------------------------------------------------------- CREATE TABLE result_cs6_worst AS SELECT s.district, get_gate_type(max(r.pm25)::uuid) AS worst_root, expected(max(r.pm25)) AS worst_mean FROM readings r JOIN stations s ON s.id = r.station_id GROUP BY s.district; SELECT remove_provenance('result_cs6_worst'); SELECT district, worst_root, worst_mean BETWEEN 39 AND 41 AS worst_mean_plausible FROM result_cs6_worst ORDER BY district; DROP TABLE result_cs6_worst; SET provsql.rv_mc_samples = 0; CREATE TABLE result_cs6_greatest AS SELECT abs(expected(provsql.greatest(a.pm25, b.pm25)) - 28.00003) < 1e-3 AS pairwise_max_analytic FROM readings a, readings b WHERE a.id = 1 AND b.id = 2; SELECT remove_provenance('result_cs6_greatest'); SELECT pairwise_max_analytic FROM result_cs6_greatest; DROP TABLE result_cs6_greatest; -- --------------------------------------------------------------------- -- Step 12: quantiles, all closed-form (per-family inverse CDF; Erlang -- bisected on its regularised-incomplete-gamma CDF), still under -- rv_mc_samples = 0. p95 references: N(28,2) -> 28 + 2 z_95, Exp(0.04) -- -> -ln(0.05)/0.04, N(40,4) -> 40 + 4 z_95, Erl(3,0.1) ~ 62.9579. -- The conditional median under pm25 > 35 is the truncated-normal -- inverse CDF at the rescaled u = F(35) + 0.5 (1 - F(35)). -- --------------------------------------------------------------------- CREATE TABLE result_cs6_p95 AS SELECT id, quantile(pm25, 0.95) AS p95 FROM readings WHERE id IN (1, 3, 5, 7); SELECT remove_provenance('result_cs6_p95'); SELECT id, abs(p95 - CASE id WHEN 1 THEN 31.289707253902947 WHEN 3 THEN 74.89330683884975 WHEN 5 THEN 46.57941450780589 WHEN 7 THEN 62.957936218719865 END) < 1e-6 AS p95_exact FROM result_cs6_p95 ORDER BY id; DROP TABLE result_cs6_p95; CREATE TABLE result_cs6_cond_median AS SELECT id, quantile(pm25, 0.5, provenance()) AS median_given_high FROM readings WHERE pm25 > 35 AND station_id = 's1'; SELECT remove_provenance('result_cs6_cond_median'); SELECT id, abs(median_given_high - CASE id WHEN 1 THEN 35.36132427785059 WHEN 5 THEN 40.531206716705924 END) < 1e-6 AS cond_median_exact FROM result_cs6_cond_median ORDER BY id; DROP TABLE result_cs6_cond_median; RESET provsql.rv_mc_samples; SET provsql.rv_mc_samples = 50000; -- --------------------------------------------------------------------- -- Step 13: expected excess over the threshold via CASE (gate_case, -- evaluated per-draw by MC; guard and branch share the pm25 leaf). -- Closed-form partial expectation for N(40,4): sigma phi(alpha) + -- (mu-35) Phi(-alpha) ~ 5.2024; N(28,2) essentially never crosses. -- The ELSE branch needs an explicit lift (as_random(0)): CASE branch -- types resolve within one type category. -- --------------------------------------------------------------------- CREATE TABLE result_cs6_excess AS SELECT id, expected(CASE WHEN pm25 > 35 THEN pm25 - 35 ELSE as_random(0) END) AS expected_excess FROM readings WHERE station_id = 's1'; SELECT remove_provenance('result_cs6_excess'); SELECT id, CASE id WHEN 1 THEN expected_excess BETWEEN 0 AND 0.01 WHEN 5 THEN abs(expected_excess - 5.2024) < 0.1 END AS excess_close_to_closed_form FROM result_cs6_excess ORDER BY id; DROP TABLE result_cs6_excess; -- --------------------------------------------------------------------- -- Step 14: covariance / correlation / stddev. Disjoint footprints give -- an exact 0 covariance and exact stddev (no sampling: proven under -- rv_mc_samples = 0); a shared plume leaf makes the pair correlated, -- theory Cov = Var(dust) = 9 and rho = 9/sqrt(13*21) ~ 0.545, estimated -- by MC at seed 42 (E[xy] over a shared leaf has no closed form). -- --------------------------------------------------------------------- SET provsql.rv_mc_samples = 0; CREATE TABLE result_cs6_indep AS SELECT covariance(a.pm25, b.pm25) = 0 AS cov_indep_exact, stddev(a.pm25) = 2 AS sd_exact FROM readings a, readings b WHERE a.id = 1 AND b.id = 2; SELECT remove_provenance('result_cs6_indep'); SELECT cov_indep_exact, sd_exact FROM result_cs6_indep; DROP TABLE result_cs6_indep; RESET provsql.rv_mc_samples; SET provsql.rv_mc_samples = 100000; -- Bands are deliberately loose: the MC estimate converges to cov=9, corr~0.545 -- on every platform, but the exact draw stream differs across C++ stdlibs -- (libstdc++ vs libc++), so a tight band is not portable. This still rejects a -- wrong implementation (independent plume -> cov~0, corr~0). CREATE TABLE result_cs6_plume AS WITH plume AS (SELECT provsql.normal(0, 3) AS dust) SELECT abs(covariance(a.pm25 + p.dust, b.pm25 + p.dust) - 9) < 3 AS cov_shared_close, abs(correlation(a.pm25 + p.dust, b.pm25 + p.dust) - 0.545) < 0.2 AS corr_shared_close FROM readings a, readings b, plume p WHERE a.id = 1 AND b.id = 2; SELECT remove_provenance('result_cs6_plume'); SELECT cov_shared_close, corr_shared_close FROM result_cs6_plume; DROP TABLE result_cs6_plume; -- --------------------------------------------------------------------- -- Step 15: Gamma / Weibull / Pareto, all analytic (rv_mc_samples = 0 -- throughout). Gamma moments exact; chi-squared quantile bisected; -- Weibull min-stability gives the two-unit first failure in closed -- form; Pareto's alpha = 1 mean is honestly Infinity; the mixed-family -- Pareto-vs-Normal comparison routes through the generic quadrature. -- --------------------------------------------------------------------- SET provsql.rv_mc_samples = 0; SELECT expected(provsql.gamma(2.5, 0.5)) = 5 AS gamma_mean_exact, variance(provsql.gamma(2.5, 0.5)) = 10 AS gamma_var_exact, abs(quantile(provsql.chi_squared(4), 0.95) - 9.48772903678115) < 1e-6 AS chi2_crit_exact; SELECT abs(expected(provsql.least(provsql.weibull(1.5, 4.0), provsql.weibull(1.5, 4.0))) - 2.2747755945647903) < 1e-9 AS weibull_first_failure_exact, abs(expected(provsql.weibull(1.5, 4.0)) - 3.6109811718037346) < 1e-9 AS weibull_lifetime_exact; SELECT expected(provsql.pareto(30, 2.5)) = 50 AS pareto_mean_exact, abs(quantile(provsql.pareto(30, 2.5), 0.95) - 99.43362052019958) < 1e-6 AS pareto_p95_exact, expected(provsql.pareto(30, 1.0)) = 'Infinity'::double precision AS pareto_divergent_mean_honest; SELECT abs(probability(x > y) - 0.38396729360204274) < 1e-6 AS mixed_family_quadrature_exact FROM (SELECT provsql.pareto(30, 2.5) AS x, provsql.normal(45, 5) AS y) t; RESET provsql.rv_mc_samples; SET provsql.rv_mc_samples = 50000; -- --------------------------------------------------------------------- -- Step 16: SQL-standard statistic aggregates over RV rows under sensor -- dropout. Row presence is pinned to the station's calibration -- probability; the certain reference pair (s4, values 15.0 / 16.5, p=1) -- gives exact Dirac statistics (stddev_pop = 0.75, corr with a -- rescaling = 1), and the district median's member set responds to -- dropout (banded MC assertion). -- --------------------------------------------------------------------- DO $$ BEGIN PERFORM set_prob(r.provsql, cs.p) FROM readings r JOIN calibration_status cs USING (station_id); END $$; CREATE TABLE result_cs6_statagg AS SELECT expected(stddev_pop(pm25)) AS sd_ref, expected(corr(pm25, pm25 * 2)) AS corr_ref FROM readings WHERE station_id = 's4'; SELECT remove_provenance('result_cs6_statagg'); SELECT sd_ref = 0.75 AS sd_ref_exact, corr_ref = 1 AS corr_ref_exact FROM result_cs6_statagg; DROP TABLE result_cs6_statagg; CREATE TABLE result_cs6_median AS SELECT s.district, expected(percentile_cont(0.5) WITHIN GROUP (ORDER BY r.pm25)) AS median_pm25 FROM readings r JOIN stations s ON s.id = r.station_id GROUP BY s.district; SELECT remove_provenance('result_cs6_median'); -- Bands are loose (cross-stdlib draw streams); they still reject a -- median that ignores dropout or the member set. SELECT district, CASE district WHEN 'centre' THEN median_pm25 BETWEEN 20 AND 32 WHEN 'east' THEN median_pm25 BETWEEN 12 AND 25 END AS median_in_band FROM result_cs6_median ORDER BY district; DROP TABLE result_cs6_median; -- --------------------------------------------------------------------- -- Step 17: maintenance-triage headline via CASE over aggregates -- (agg_case). Guards and branches are aggregates over the tracked -- calibration log (min / sum branches: exact possible-worlds machinery; -- an avg branch would take the Monte-Carlo path, its exact joint -- (sum, count) arm being unconditional-only); -- the display cell carries the actual-world CASE value, and the -- expectation is exact (rv_mc_samples = 0 throughout). -- One record per district is certain, so the group always exists and -- every world is enumerable by hand: -- centre: rows p-col {.95,.70}, probs {1.0,.8}: -- {both}.8 -> min .70 <= .8 -> min .70; {s1}.2 -> min .95 > .8 -> -- sum .95; E = .8*.7 + .2*.95 = .75 -- east: rows p-col {.60,1.00}, probs {.7,1.0}: -- {both}.7 -> min .6 -> .6; {s4}.3 -> min 1.0 > .8 -> sum 1.0 -- E = .7*.6 + .3*1 = .72 -- --------------------------------------------------------------------- DO $$ BEGIN PERFORM set_prob(provenance(), 1.0) FROM stations; PERFORM set_prob(provenance(), CASE station_id WHEN 's1' THEN 1.0 WHEN 's2' THEN 0.8 WHEN 's3' THEN 0.7 ELSE 1.0 END) FROM calibration_status; END $$; SET provsql.rv_mc_samples = 0; CREATE TABLE result_cs6_headline AS SELECT s.district, CASE WHEN min(cs.p) > 0.8 THEN sum(cs.p) ELSE min(cs.p) END AS confidence_headline FROM calibration_status cs JOIN stations s ON s.id = cs.station_id GROUP BY s.district; SET provsql.active = off; SELECT district, get_gate_type(confidence_headline::uuid) AS headline_gate, confidence_headline AS display FROM result_cs6_headline ORDER BY district; SELECT district, CASE district WHEN 'centre' THEN abs(expected(confidence_headline) - 0.75) < 1e-9 WHEN 'east' THEN abs(expected(confidence_headline) - 0.72) < 1e-9 END AS expected_headline_exact FROM result_cs6_headline ORDER BY district; SET provsql.active = on; DROP TABLE result_cs6_headline; RESET provsql.rv_mc_samples; SET provsql.rv_mc_samples = 50000; -- --------------------------------------------------------------------- -- Step 18: mutual information. Disjoint stochastic-leaf footprints -- give an exact 0 (no sampling: proven under rv_mc_samples = 0); the -- shared-plume pair is estimated by the 2-D histogram plug-in over -- coupled joint draws (Gaussian-pair theory ~ -ln(1-rho^2)/2 ~ 0.176 -- at rho ~ .545; banded loosely for cross-stdlib portability). -- --------------------------------------------------------------------- SET provsql.rv_mc_samples = 0; CREATE TABLE result_cs6_mi_indep AS SELECT mutual_information(a.pm25, b.pm25) = 0 AS mi_indep_exact FROM readings a, readings b WHERE a.id = 1 AND b.id = 2; SELECT remove_provenance('result_cs6_mi_indep'); SELECT mi_indep_exact FROM result_cs6_mi_indep; DROP TABLE result_cs6_mi_indep; RESET provsql.rv_mc_samples; SET provsql.rv_mc_samples = 100000; CREATE TABLE result_cs6_mi_shared AS WITH plume AS (SELECT provsql.normal(0, 3) AS dust) SELECT mutual_information(a.pm25 + p.dust, b.pm25 + p.dust) AS mi_shared FROM readings a, readings b, plume p WHERE a.id = 1 AND b.id = 2; SELECT remove_provenance('result_cs6_mi_shared'); SELECT mi_shared BETWEEN 0.05 AND 0.5 AS mi_shared_in_band FROM result_cs6_mi_shared; DROP TABLE result_cs6_mi_shared; -- --------------------------------------------------------------------- -- Step 19: an offline sensor reports NULL. A comparison over the NULL -- reading is unknown in every world (probability exactly 0, a gate_zero -- conjunct -- never silently certain); IS NULL is an ordinary -- deterministic predicate; and aggregates skip the NULL reading in both -- their sum and their count. -- --------------------------------------------------------------------- INSERT INTO readings (id, station_id, ts, pm25) VALUES (9, 's2', '2026-05-12 10:00', NULL); CREATE TABLE result_cs6_null_thresh AS SELECT id, ROUND(probability_evaluate(provenance())::numeric, 4) AS p FROM readings WHERE pm25 > 35 AND ts = '2026-05-12 10:00'; SELECT remove_provenance('result_cs6_null_thresh'); SELECT id, p FROM result_cs6_null_thresh; DROP TABLE result_cs6_null_thresh; CREATE TABLE result_cs6_null_isnull AS SELECT id, station_id FROM readings WHERE pm25 IS NULL; SELECT remove_provenance('result_cs6_null_isnull'); SELECT id, station_id FROM result_cs6_null_isnull; DROP TABLE result_cs6_null_isnull; -- avg skips the NULL reading: same expected value with or without row 9. CREATE TABLE result_cs6_null_avg AS SELECT round(expected(avg(pm25))::numeric, 4) AS with_null FROM readings WHERE station_id = 's2'; SELECT remove_provenance('result_cs6_null_avg'); CREATE TABLE result_cs6_null_avg2 AS SELECT round(expected(avg(pm25))::numeric, 4) AS without_null FROM readings WHERE station_id = 's2' AND id <> 9; SELECT remove_provenance('result_cs6_null_avg2'); SELECT a.with_null = b.without_null AS avg_skips_null FROM result_cs6_null_avg a, result_cs6_null_avg2 b; DROP TABLE result_cs6_null_avg; DROP TABLE result_cs6_null_avg2; DELETE FROM readings WHERE id = 9; -- --------------------------------------------------------------------- -- Cleanup. -- --------------------------------------------------------------------- DROP TABLE historical_readings; DROP TABLE readings; DROP TABLE calibration_status; DROP TABLE stations; RESET provsql.monte_carlo_seed; RESET provsql.rv_mc_samples;