-- The centroids of a t-digest are expected to be sorted by mean, but a digest -- with the centroids in an arbitrary order is a perfectly valid value - the -- incremental API keeps the digests uncompacted (and thus unsorted), and such -- digests may get stored on disk, dumped, replicated, etc. The input functions -- therefore accept unsorted digests, and all the places that need the sorted -- order are expected to do the sort themselves. -- -- This checks that really is the case - for each digest we build a couple of -- variants with the centroids reordered (reversed, and then shuffled in a -- deterministic but arbitrary way), and then compare the results of -- -- tdigest_percentile() (both the scalar and the array variant) -- tdigest_percentile_of() (both the scalar and the array variant) -- tdigest_sum() -- tdigest_avg() -- tdigest_digest_sum() -- tdigest_digest_avg() -- -- to the results for the original (sorted) digest. The results have to match -- exactly, not just approximately - reordering the centroids does not lose any -- information, so the sort has to reconstruct exactly the same digest. \set VERBOSITY terse SET extra_float_digits = 0; -- Decompose a digest into individual centroids. The digest has no API to do -- this directly, so we go through the text representation. The functions set -- extra_float_digits, so that the means survive the round trip exactly. CREATE FUNCTION tdigest_unsorted_centroids(d tdigest) RETURNS TABLE (ord bigint, mean double precision, cnt bigint) LANGUAGE sql SET extra_float_digits = 3 AS $$ SELECT o, split_part(btrim(m[1], ' ()'), ',', 1)::double precision, split_part(btrim(m[1], ' ()'), ',', 2)::bigint FROM regexp_matches(d::text, ' \([^)]*\)', 'g') WITH ORDINALITY AS x(m, o); $$; -- Rebuild the digest with the centroids in a different order. Seed 0 simply -- reverses the centroids (so that the means are in a descending order), any -- other seed shuffles them - the order is arbitrary, but it's derived from -- md5() so it's stable and does not depend on the PRNG of the server. CREATE FUNCTION tdigest_unsorted_reorder(d tdigest, seed int) RETURNS tdigest LANGUAGE sql SET extra_float_digits = 3 AS $$ SELECT (rtrim(substring(d::text FROM '^[^(]*')) || coalesce(string_agg(m[1], '' ORDER BY CASE WHEN seed = 0 THEN -o ELSE 0 END, md5(seed::text || ':' || o::text)), ''))::tdigest FROM regexp_matches(d::text, ' \([^)]*\)', 'g') WITH ORDINALITY AS x(m, o); $$; -- Are the centroids of the digest sorted by mean? CREATE FUNCTION tdigest_unsorted_is_sorted(d tdigest) RETURNS boolean LANGUAGE sql AS $$ SELECT coalesce(bool_and(mean >= prev_mean), true) FROM (SELECT mean, lag(mean) OVER (ORDER BY ord) AS prev_mean FROM tdigest_unsorted_centroids(d)) x WHERE prev_mean IS NOT NULL; $$; -- The digests to check. A couple of hand-written ones first, so that we know -- exactly what centroids they consist of. CREATE TABLE tdigest_unsorted_digests (id int, descr text, d tdigest); INSERT INTO tdigest_unsorted_digests VALUES (1, 'two centroids', 'flags 1 count 10 compression 10000 centroids 2 (0, 7) (100, 3)'), (2, 'three centroids, mixed counts', 'flags 1 count 12 compression 10000 centroids 3 (0, 3) (50, 4) (100, 5)'), (3, 'negative and positive means', 'flags 1 count 8 compression 10000 centroids 4 (-100, 2) (-1, 2) (1, 2) (100, 2)'), (4, 'repeated means (the sort has to rebalance those)', 'flags 1 count 12 compression 10000 centroids 5 (0, 1) (50, 3) (50, 4) (50, 3) (100, 1)'), (5, 'a large centroid in the middle', 'flags 1 count 22 compression 10000 centroids 3 (0, 1) (10, 20) (20, 1)'), (6, 'a single centroid', 'flags 1 count 10 compression 10000 centroids 1 (50, 10)'); -- And then digests built by the aggregate from actual data. The first one is -- small enough not to be compacted at all (so each centroid holds a single -- item), the others are compacted with different compression values. INSERT INTO tdigest_unsorted_digests SELECT 7, 'built from 1000 distinct values, no compaction', tdigest(i::double precision, 10000) FROM generate_series(1, 1000) s(i); INSERT INTO tdigest_unsorted_digests SELECT 8, 'built from 10000 rows, compression 100', tdigest((i % 1000)::double precision, 100) FROM generate_series(1, 10000) s(i); INSERT INTO tdigest_unsorted_digests SELECT 9, 'built from 10000 rows, compression 10', tdigest((i % 1000)::double precision, 10) FROM generate_series(1, 10000) s(i); INSERT INTO tdigest_unsorted_digests SELECT 10, 'built from 10000 random values, compression 100', tdigest(1000 * v, 100) FROM prng(10000) s(v); INSERT INTO tdigest_unsorted_digests SELECT 11, 'built from 10000 values with duplicities, compression 50', tdigest((10 * v)::int::double precision, 50) FROM prng(10000) s(v); -- All of them have to be sorted, otherwise the whole test is pointless. SELECT id, descr FROM tdigest_unsorted_digests WHERE NOT tdigest_unsorted_is_sorted(d) ORDER BY id; -- Build the variants. Variant 0 is the original (sorted) digest, variant 1 has -- the centroids in the reverse order, and variants 2-6 have them shuffled. CREATE TABLE tdigest_unsorted_variants (id int, variant int, d tdigest); INSERT INTO tdigest_unsorted_variants SELECT id, 0, d FROM tdigest_unsorted_digests; INSERT INTO tdigest_unsorted_variants SELECT id, k + 1, tdigest_unsorted_reorder(d, k) FROM tdigest_unsorted_digests, generate_series(0, 5) s(k); -- The reordering must not change the contents of the digest in any way - the -- header has to be the same, and the centroids have to be the very same -- multiset, just in a different order. So both the "differs" columns have to -- be 0. The last column says how many of the variants really ended up with -- the centroids out of order - it's less than the number of variants for the -- digests with very few centroids (and it's 0 for the single centroid digest, -- which can't be unsorted at all). SELECT v.id, g.descr, count(*) AS variants, count(*) FILTER (WHERE substring(v.d::text FROM '^[^(]*') IS DISTINCT FROM substring(g.d::text FROM '^[^(]*')) AS header_differs, count(*) FILTER (WHERE EXISTS (SELECT mean, cnt FROM tdigest_unsorted_centroids(v.d) EXCEPT ALL SELECT mean, cnt FROM tdigest_unsorted_centroids(g.d)) OR EXISTS (SELECT mean, cnt FROM tdigest_unsorted_centroids(g.d) EXCEPT ALL SELECT mean, cnt FROM tdigest_unsorted_centroids(v.d))) AS centroids_differ, count(*) FILTER (WHERE NOT tdigest_unsorted_is_sorted(v.d)) AS actually_unsorted FROM tdigest_unsorted_variants v JOIN tdigest_unsorted_digests g ON (g.id = v.id) WHERE v.variant > 0 GROUP BY v.id, g.descr ORDER BY v.id; -- The percentiles / values / trim ranges to probe the digests with. The digests -- cover fairly different ranges of values, but that does not matter - we only -- compare the results to each other, not to some expected value. CREATE TABLE tdigest_unsorted_percentiles (id int, p double precision); INSERT INTO tdigest_unsorted_percentiles SELECT i, i / 20.0 FROM generate_series(0, 20) s(i); CREATE TABLE tdigest_unsorted_values (id int, v double precision); INSERT INTO tdigest_unsorted_values (id, v) SELECT row_number() OVER (ORDER BY v), v FROM unnest(ARRAY[-1000, -100.5, -100, -50, -1, -0.5, 0, 0.5, 1, 10, 20, 49.5, 50, 99.5, 100, 250, 500, 750, 999, 1000, 5000]::double precision[]) AS v; CREATE TABLE tdigest_unsorted_trims (id int, low double precision, high double precision); INSERT INTO tdigest_unsorted_trims VALUES (1, 0.0, 1.0), (2, 0.1, 0.9), (3, 0.0, 0.5), (4, 0.5, 1.0), (5, 0.25, 0.75), (6, 0.05, 0.15), (7, 0.9, 0.95), (8, 0.33, 0.34); -- Evaluate all the functions for all the digests / variants / probes. CREATE TABLE tdigest_unsorted_results (func text, id int, variant int, probe int, res double precision); INSERT INTO tdigest_unsorted_results SELECT 'tdigest_percentile', v.id, v.variant, p.id, tdigest_percentile(v.d, p.p) FROM tdigest_unsorted_variants v, tdigest_unsorted_percentiles p GROUP BY v.id, v.variant, p.id, p.p; INSERT INTO tdigest_unsorted_results SELECT 'tdigest_percentile (array)', r.id, r.variant, o::int, x FROM (SELECT v.id, v.variant, tdigest_percentile(v.d, p.arr) AS res FROM tdigest_unsorted_variants v, (SELECT array_agg(p ORDER BY id) AS arr FROM tdigest_unsorted_percentiles) p GROUP BY v.id, v.variant, p.arr) r, unnest(r.res) WITH ORDINALITY AS u(x, o); INSERT INTO tdigest_unsorted_results SELECT 'tdigest_percentile_of', v.id, v.variant, p.id, tdigest_percentile_of(v.d, p.v) FROM tdigest_unsorted_variants v, tdigest_unsorted_values p GROUP BY v.id, v.variant, p.id, p.v; INSERT INTO tdigest_unsorted_results SELECT 'tdigest_percentile_of (array)', r.id, r.variant, o::int, x FROM (SELECT v.id, v.variant, tdigest_percentile_of(v.d, p.arr) AS res FROM tdigest_unsorted_variants v, (SELECT array_agg(v ORDER BY id) AS arr FROM tdigest_unsorted_values) p GROUP BY v.id, v.variant, p.arr) r, unnest(r.res) WITH ORDINALITY AS u(x, o); INSERT INTO tdigest_unsorted_results SELECT 'tdigest_sum', v.id, v.variant, t.id, tdigest_sum(v.d, t.low, t.high) FROM tdigest_unsorted_variants v, tdigest_unsorted_trims t GROUP BY v.id, v.variant, t.id, t.low, t.high; INSERT INTO tdigest_unsorted_results SELECT 'tdigest_avg', v.id, v.variant, t.id, tdigest_avg(v.d, t.low, t.high) FROM tdigest_unsorted_variants v, tdigest_unsorted_trims t GROUP BY v.id, v.variant, t.id, t.low, t.high; -- the same two, but as plain functions operating on a single digest INSERT INTO tdigest_unsorted_results SELECT 'tdigest_digest_sum', v.id, v.variant, t.id, tdigest_digest_sum(v.d, t.low, t.high) FROM tdigest_unsorted_variants v, tdigest_unsorted_trims t; INSERT INTO tdigest_unsorted_results SELECT 'tdigest_digest_avg', v.id, v.variant, t.id, tdigest_digest_avg(v.d, t.low, t.high) FROM tdigest_unsorted_variants v, tdigest_unsorted_trims t; -- How many results did we compare, and how many of them do not match the -- result for the sorted digest? The number of mismatches has to be 0. SELECT r.func, count(*) AS comparisons, count(*) FILTER (WHERE r.res IS DISTINCT FROM s.res) AS mismatches FROM tdigest_unsorted_results r JOIN tdigest_unsorted_results s ON (s.func = r.func AND s.id = r.id AND s.probe = r.probe AND s.variant = 0) WHERE r.variant > 0 GROUP BY r.func ORDER BY r.func; -- And the details of the mismatches, so that a failure is readable. This has -- to produce no rows at all. SELECT r.func, r.id, g.descr, r.variant, r.probe, s.res AS sorted, r.res AS unsorted FROM tdigest_unsorted_results r JOIN tdigest_unsorted_results s ON (s.func = r.func AND s.id = r.id AND s.probe = r.probe AND s.variant = 0) JOIN tdigest_unsorted_digests g ON (g.id = r.id) WHERE r.variant > 0 AND r.res IS DISTINCT FROM s.res ORDER BY r.func, r.id, r.variant, r.probe; -- Finally a couple of explicit checks on a small digest with the centroids in -- the reverse order, spelled out - just to make sure the comparison above is -- not comparing something trivially identical. SELECT tdigest_unsorted_reorder('flags 1 count 12 compression 10000 centroids 3 (0, 3) (50, 4) (100, 5)'::tdigest, 0); WITH d(descr, d) AS ( VALUES ('sorted', 'flags 1 count 12 compression 10000 centroids 3 (0, 3) (50, 4) (100, 5)'::tdigest), ('reversed', 'flags 1 count 12 compression 10000 centroids 3 (100, 5) (50, 4) (0, 3)'::tdigest) ) SELECT descr, tdigest_percentile(d, 0.25) AS "percentile 0.25", tdigest_percentile(d, 0.75) AS "percentile 0.75", tdigest_percentile_of(d, 25.0) AS "percentile of 25", tdigest_percentile_of(d, 75.0) AS "percentile of 75", tdigest_sum(d, 0.1, 0.9) AS "sum 0.1-0.9", tdigest_avg(d, 0.1, 0.9) AS "avg 0.1-0.9" FROM d GROUP BY descr ORDER BY descr; WITH d(descr, d) AS ( VALUES ('sorted', 'flags 1 count 12 compression 10000 centroids 3 (0, 3) (50, 4) (100, 5)'::tdigest), ('reversed', 'flags 1 count 12 compression 10000 centroids 3 (100, 5) (50, 4) (0, 3)'::tdigest) ) SELECT descr, tdigest_digest_sum(d, 0.1, 0.9) AS "digest_sum 0.1-0.9", tdigest_digest_avg(d, 0.1, 0.9) AS "digest_avg 0.1-0.9" FROM d ORDER BY descr; -- The old on-disk format, where the centroids store sums instead of means, is -- accepted with the centroids unsorted too. And the sums being sorted does not -- mean the means are - all three digests below hold the very same centroids -- (means 20, 50 and 200, with counts 5, 6 and 1). WITH d(descr, d) AS ( VALUES ('sorted by mean', 'flags 0 count 12 compression 10000 centroids 3 (100, 5) (300, 6) (200, 1)'::tdigest), ('reversed', 'flags 0 count 12 compression 10000 centroids 3 (200, 1) (300, 6) (100, 5)'::tdigest), ('sorted by sum, not by mean', 'flags 0 count 12 compression 10000 centroids 3 (100, 5) (200, 1) (300, 6)'::tdigest) ) SELECT descr, tdigest_digest_sum(d, 0.1, 0.9) AS "digest_sum 0.1-0.9", tdigest_digest_avg(d, 0.1, 0.9) AS "digest_avg 0.1-0.9" FROM d ORDER BY descr; DROP TABLE tdigest_unsorted_results; DROP TABLE tdigest_unsorted_trims; DROP TABLE tdigest_unsorted_values; DROP TABLE tdigest_unsorted_percentiles; DROP TABLE tdigest_unsorted_variants; DROP TABLE tdigest_unsorted_digests; DROP FUNCTION tdigest_unsorted_is_sorted(tdigest); DROP FUNCTION tdigest_unsorted_reorder(tdigest, int); DROP FUNCTION tdigest_unsorted_centroids(tdigest);