sqlbenchdag 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- infrastructure/debug_aws.py +40 -0
- scripts/dev/backfill_queries.py +44 -0
- scripts/dev/timestamp_capsule.py +49 -0
- scripts/dev/upgrade_capsule.py +63 -0
- scripts/dev/verify_capsule.py +79 -0
- scripts/dev/verify_portability.py +103 -0
- scripts/tools/_plotlib.py +43 -0
- scripts/tools/analyze_scaling.py +39 -0
- scripts/tools/demo_breach_detection.py +61 -0
- scripts/tools/extract_results.py +57 -0
- scripts/tools/find_kink.py +104 -0
- scripts/tools/gen_experiment_catalog.py +103 -0
- scripts/tools/load_parquet_to_local.py +98 -0
- scripts/tools/plot_execution_modes.py +82 -0
- scripts/tools/plot_scaling.py +91 -0
- scripts/tools/plot_selectivity.py +139 -0
- scripts/tools/plot_threads.py +95 -0
- scripts/tools/regenerate_dashboard.py +58 -0
- scripts/tools/verify_doc_claims.py +88 -0
- sql_benchmarks/__init__.py +3 -0
- sql_benchmarks/api/__init__.py +0 -0
- sql_benchmarks/api/app.py +30 -0
- sql_benchmarks/api/data/__init__.py +0 -0
- sql_benchmarks/api/data/catalog_reader.py +73 -0
- sql_benchmarks/api/data/reader.py +125 -0
- sql_benchmarks/api/logic/__init__.py +0 -0
- sql_benchmarks/api/logic/comparator.py +44 -0
- sql_benchmarks/api/logic/ranker.py +127 -0
- sql_benchmarks/api/models/__init__.py +0 -0
- sql_benchmarks/api/models/catalog.py +22 -0
- sql_benchmarks/api/models/experiments.py +20 -0
- sql_benchmarks/api/models/recommend.py +11 -0
- sql_benchmarks/api/models/results.py +66 -0
- sql_benchmarks/api/routers/__init__.py +0 -0
- sql_benchmarks/api/routers/catalog.py +19 -0
- sql_benchmarks/api/routers/experiments.py +79 -0
- sql_benchmarks/api/routers/recommend.py +22 -0
- sql_benchmarks/api/routers/results.py +51 -0
- sql_benchmarks/assets/__init__.py +4 -0
- sql_benchmarks/assets/benchmark_factory.py +222 -0
- sql_benchmarks/assets/data_factory.py +54 -0
- sql_benchmarks/assets/data_quality.py +91 -0
- sql_benchmarks/assets/ingestion_factory.py +88 -0
- sql_benchmarks/assets/maintenance.py +63 -0
- sql_benchmarks/assets/reporting.py +385 -0
- sql_benchmarks/assets/semantic_gate.py +76 -0
- sql_benchmarks/cli.py +69 -0
- sql_benchmarks/config_loader.py +104 -0
- sql_benchmarks/constants.py +60 -0
- sql_benchmarks/coordinator.py +282 -0
- sql_benchmarks/definitions.py +115 -0
- sql_benchmarks/experiments/templates/experiment_template.yaml +99 -0
- sql_benchmarks/harness.py +84 -0
- sql_benchmarks/jobs.py +8 -0
- sql_benchmarks/partitions.py +17 -0
- sql_benchmarks/plugins/data_sources/declarative_gen.py +211 -0
- sql_benchmarks/plugins/data_sources/local_file_loader.py +62 -0
- sql_benchmarks/plugins/data_sources/tpc_h.py +61 -0
- sql_benchmarks/resources/__init__.py +0 -0
- sql_benchmarks/resources/actian.py +234 -0
- sql_benchmarks/resources/actian_client.py +231 -0
- sql_benchmarks/resources/base_engine.py +58 -0
- sql_benchmarks/resources/base_schema_client.py +104 -0
- sql_benchmarks/resources/duckdb.py +58 -0
- sql_benchmarks/resources/duckdb_client.py +157 -0
- sql_benchmarks/resources/postgres.py +188 -0
- sql_benchmarks/resources/postgres_client.py +184 -0
- sql_benchmarks/resources/quack.py +56 -0
- sql_benchmarks/resources/quack_client.py +169 -0
- sql_benchmarks/resources/typedb_client.py +370 -0
- sql_benchmarks/resources/typedb_engine.py +303 -0
- sql_benchmarks/scripts/__init__.py +0 -0
- sql_benchmarks/scripts/sql/acid_test/duckdb/test.sql +1 -0
- sql_benchmarks/scripts/sql/acid_test/postgres/test.sql +1 -0
- sql_benchmarks/scripts/sql/analytical_wall/actian/analytical_wall.sql +15 -0
- sql_benchmarks/scripts/sql/analytical_wall/analytical_wall.sql.template +19 -0
- sql_benchmarks/scripts/sql/analytical_wall/duckdb/analytical_wall.sql +15 -0
- sql_benchmarks/scripts/sql/analytical_wall/postgres/analytical_wall.sql +15 -0
- sql_benchmarks/scripts/sql/group_by/duckdb/groupby_antipattern.sql +11 -0
- sql_benchmarks/scripts/sql/group_by/duckdb/groupby_recommended_cte.sql +19 -0
- sql_benchmarks/scripts/sql/group_by/postgres/groupby_antipattern.sql +13 -0
- sql_benchmarks/scripts/sql/group_by/postgres/groupby_recommended_pk.sql +11 -0
- sql_benchmarks/scripts/sql/hypergraph/duckdb/q_1hop.sql +5 -0
- sql_benchmarks/scripts/sql/hypergraph/duckdb/q_2hop.sql +6 -0
- sql_benchmarks/scripts/sql/hypergraph/duckdb/q_full.sql +9 -0
- sql_benchmarks/scripts/sql/hypergraph/typedb/q_1hop.sql +4 -0
- sql_benchmarks/scripts/sql/hypergraph/typedb/q_2hop.sql +5 -0
- sql_benchmarks/scripts/sql/hypergraph/typedb/q_full.sql +6 -0
- sql_benchmarks/scripts/sql/joins/duckdb/join_antipattern.sql +5 -0
- sql_benchmarks/scripts/sql/joins/duckdb/join_recommended.sql +4 -0
- sql_benchmarks/scripts/sql/joins/postgres/join_antipattern.sql +5 -0
- sql_benchmarks/scripts/sql/joins/postgres/join_recommended.sql +4 -0
- sql_benchmarks/scripts/sql/null_logic/duckdb/2VL_identity.sql +14 -0
- sql_benchmarks/scripts/sql/null_logic/duckdb/3VL_identity.sql +5 -0
- sql_benchmarks/scripts/sql/null_logic/postgres/2VL_identity.sql +14 -0
- sql_benchmarks/scripts/sql/null_logic/postgres/3VL_identity.sql +5 -0
- sql_benchmarks/scripts/sql/null_sentinel/duckdb/2VL_identity.sql +14 -0
- sql_benchmarks/scripts/sql/null_sentinel/duckdb/3VL_identity.sql +5 -0
- sql_benchmarks/scripts/sql/null_sentinel/duckdb/sentinel_optimization.sql +16 -0
- sql_benchmarks/scripts/sql/null_sentinel/postgres/2VL_identity.sql +14 -0
- sql_benchmarks/scripts/sql/null_sentinel/postgres/3VL_identity.sql +5 -0
- sql_benchmarks/scripts/sql/null_sentinel/postgres/sentinel_optimization.sql +21 -0
- sql_benchmarks/scripts/sql/recursion/duckdb/recursive_cte.sql +26 -0
- sql_benchmarks/scripts/sql/recursion/postgres/recursive_cte.sql +30 -0
- sql_benchmarks/scripts/sql/recursive_graph/duckdb/q_bounded_3hop.sql +17 -0
- sql_benchmarks/scripts/sql/recursive_graph/duckdb/q_fixed_2hop.sql +6 -0
- sql_benchmarks/scripts/sql/recursive_graph/duckdb/q_transitive_closure.sql +17 -0
- sql_benchmarks/scripts/sql/recursive_graph/typedb/q_bounded_3hop.sql +6 -0
- sql_benchmarks/scripts/sql/recursive_graph/typedb/q_fixed_2hop.sql +5 -0
- sql_benchmarks/scripts/sql/recursive_graph/typedb/q_transitive_closure.sql +7 -0
- sql_benchmarks/scripts/sql/security/acid_resilience/duckdb/test.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/duckdb/q_0_1_percent.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/duckdb/q_10_percent.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/duckdb/q_1_percent.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/duckdb/q_20_percent.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/duckdb/q_5_percent.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/duckdb/q_filler.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/postgres/q_0_1_percent.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/postgres/q_10_percent.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/postgres/q_1_percent.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/postgres/q_20_percent.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/postgres/q_5_percent.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/postgres/q_filler.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/typedb/q_0_1_percent.sql +4 -0
- sql_benchmarks/scripts/sql/selectivity/typedb/q_10_percent.sql +4 -0
- sql_benchmarks/scripts/sql/selectivity/typedb/q_1_percent.sql +4 -0
- sql_benchmarks/scripts/sql/selectivity/typedb/q_20_percent.sql +4 -0
- sql_benchmarks/scripts/sql/selectivity/typedb/q_5_percent.sql +4 -0
- sql_benchmarks/scripts/sql/selectivity/typedb/q_filler.sql +4 -0
- sql_benchmarks/scripts/sql/sort_spill/duckdb/sort_full.sql +25 -0
- sql_benchmarks/scripts/sql/sort_spill/duckdb/sort_wide_rows.sql +25 -0
- sql_benchmarks/scripts/sql/sort_spill/postgres/sort_full.sql +25 -0
- sql_benchmarks/scripts/sql/sort_spill/postgres/sort_wide_rows.sql +26 -0
- sql_benchmarks/scripts/sql/tpch/duckdb/q3_shipping_priority.sql +25 -0
- sql_benchmarks/scripts/sql/tpch/postgres/q3_shipping_priority.sql +25 -0
- sql_benchmarks/utils/__init__.py +0 -0
- sql_benchmarks/utils/common.py +260 -0
- sql_benchmarks/utils/ddl.py +47 -0
- sql_benchmarks/utils/hasher.py +124 -0
- sql_benchmarks/utils/integrity_monitor.py +55 -0
- sql_benchmarks/utils/providers.py +184 -0
- sql_benchmarks/utils/scaling.py +88 -0
- sql_benchmarks/utils/schema.py +118 -0
- sql_benchmarks/utils/semantic_auditor.py +81 -0
- sql_benchmarks/utils/system.py +100 -0
- sql_benchmarks/validator.py +60 -0
- sqlbenchdag-0.1.0.dist-info/METADATA +221 -0
- sqlbenchdag-0.1.0.dist-info/RECORD +153 -0
- sqlbenchdag-0.1.0.dist-info/WHEEL +5 -0
- sqlbenchdag-0.1.0.dist-info/entry_points.txt +2 -0
- sqlbenchdag-0.1.0.dist-info/licenses/LICENSE +202 -0
- sqlbenchdag-0.1.0.dist-info/top_level.txt +4 -0
- venv/bin/activate_this.py +59 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Locate the scale 'kink' in a capsule: the row-count interval where an engine's
|
|
4
|
+
per-decade slowdown spikes. That spike is the mathematical flare — it points
|
|
5
|
+
EXPLAIN ANALYZE at the exact interval where the engine changes strategy (a sort/
|
|
6
|
+
hash spilling to disk, an index scan flipping to seq/bitmap, a parallelism wall).
|
|
7
|
+
|
|
8
|
+
Two corrections over a naive tmax/tmin reading:
|
|
9
|
+
* per-decade normalization — scale points are not evenly spaced (1K->100K is
|
|
10
|
+
TWO decades), so each interval's multiplier is taken as ratio**(1/decades);
|
|
11
|
+
a raw ratio would flag a false kink on the wide interval.
|
|
12
|
+
* noise awareness — each interval also reports the replication spread at its
|
|
13
|
+
endpoints, so a spike can be judged against measurement noise, not trusted
|
|
14
|
+
blindly. With few scale points a kink LOCATES where to look; EXPLAIN confirms.
|
|
15
|
+
|
|
16
|
+
Reads sealed fragments only. Computes nothing that feeds the Experiment ID, so
|
|
17
|
+
it never changes an ID or a seal.
|
|
18
|
+
|
|
19
|
+
Usage: python scripts/tools/find_kink.py <experiment_id> [<id> ...]
|
|
20
|
+
"""
|
|
21
|
+
import glob
|
|
22
|
+
import json
|
|
23
|
+
import math
|
|
24
|
+
import os
|
|
25
|
+
import statistics
|
|
26
|
+
import sys
|
|
27
|
+
|
|
28
|
+
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
29
|
+
RESULTS = os.path.join(REPO_ROOT, "sql_benchmarks", "experiments", "results")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def raw_points(capsule: str) -> dict:
|
|
33
|
+
"""engine -> {rows: [raw durations]} from fragments with a numeric rows axis."""
|
|
34
|
+
series: dict = {}
|
|
35
|
+
for path in glob.glob(os.path.join(capsule, "fragments", "*.json")):
|
|
36
|
+
with open(path) as f:
|
|
37
|
+
data = json.load(f)
|
|
38
|
+
params = data.get("parameters", {})
|
|
39
|
+
if "rows" not in params:
|
|
40
|
+
continue
|
|
41
|
+
metrics = data.get("metrics", {})
|
|
42
|
+
raw = metrics.get("durations_raw") or (
|
|
43
|
+
[metrics["duration_seconds"]] if metrics.get("duration_seconds") is not None else []
|
|
44
|
+
)
|
|
45
|
+
if not raw:
|
|
46
|
+
continue
|
|
47
|
+
eng = data["meta"]["engine"]
|
|
48
|
+
series.setdefault(eng, {}).setdefault(int(params["rows"]), []).extend(raw)
|
|
49
|
+
return series
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def ladder(points: dict):
|
|
53
|
+
"""points: {rows: [raw]} -> (median_by_n, [(n1, n2, per_decade_mult, noise)])."""
|
|
54
|
+
ns = sorted(points)
|
|
55
|
+
med = {n: statistics.median(points[n]) for n in ns}
|
|
56
|
+
rungs = []
|
|
57
|
+
for n1, n2 in zip(ns, ns[1:]):
|
|
58
|
+
decades = math.log10(n2 / n1)
|
|
59
|
+
mult = (med[n2] / med[n1]) ** (1 / decades) if decades and med[n1] else float("nan")
|
|
60
|
+
spread = max(
|
|
61
|
+
(max(points[n1]) - min(points[n1])) / med[n1] if med[n1] else 0.0,
|
|
62
|
+
(max(points[n2]) - min(points[n2])) / med[n2] if med[n2] else 0.0,
|
|
63
|
+
)
|
|
64
|
+
rungs.append((n1, n2, mult, spread))
|
|
65
|
+
return med, rungs
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def report(exp_id: str) -> None:
|
|
69
|
+
capsule = os.path.join(RESULTS, exp_id)
|
|
70
|
+
if not os.path.isdir(capsule):
|
|
71
|
+
print(f"{exp_id}: MISSING\n")
|
|
72
|
+
return
|
|
73
|
+
series = raw_points(capsule)
|
|
74
|
+
if not series:
|
|
75
|
+
print(f"{exp_id}: no row-scaled fragments (needs a numeric 'rows' axis)\n")
|
|
76
|
+
return
|
|
77
|
+
print(f"=== {exp_id} ===")
|
|
78
|
+
for eng, pts in sorted(series.items()):
|
|
79
|
+
if len(pts) < 3:
|
|
80
|
+
print(f" {eng}: {len(pts)} scale point(s) — need >=3 (>=2 intervals) to locate a kink")
|
|
81
|
+
continue
|
|
82
|
+
med, rungs = ladder(pts)
|
|
83
|
+
mults = [m for *_, m, _ in rungs]
|
|
84
|
+
typical = statistics.median(mults)
|
|
85
|
+
kink = max(rungs, key=lambda r: r[2])
|
|
86
|
+
print(f" {eng}:")
|
|
87
|
+
print(f" {'interval':>20} {'per-decade':>11} {'noise(±%)':>10}")
|
|
88
|
+
for n1, n2, mult, spread in rungs:
|
|
89
|
+
mark = " <-- KINK" if (n1, n2) == (kink[0], kink[1]) else ""
|
|
90
|
+
print(f" {n1:>8,}->{n2:<9,} {mult:>10.2f}x {spread*100:>9.0f}%{mark}")
|
|
91
|
+
spike = kink[2] / typical if typical else float("nan")
|
|
92
|
+
verdict = (
|
|
93
|
+
f" spike {spike:.1f}x over typical"
|
|
94
|
+
+ (" — within endpoint noise, treat as soft" if kink[2] - 1 <= kink[3] else "")
|
|
95
|
+
)
|
|
96
|
+
print(verdict)
|
|
97
|
+
print(f" -> EXPLAIN ANALYZE {eng} at rows={kink[0]:,} vs {kink[1]:,}\n")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
if __name__ == "__main__":
|
|
101
|
+
if len(sys.argv) < 2:
|
|
102
|
+
sys.exit(__doc__)
|
|
103
|
+
for exp_id in sys.argv[1:]:
|
|
104
|
+
report(exp_id)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Generate the experiment catalog from the sealed capsules.
|
|
4
|
+
|
|
5
|
+
Reads every results/<ID>/experiment_config.yaml and emits a Markdown table
|
|
6
|
+
(ID, name, engines, suite, sealed) into the CATALOG region of docs/experiments.md.
|
|
7
|
+
The catalog is DERIVED from the capsules, so it cannot drift from what was
|
|
8
|
+
actually run — regenerate it after publishing a new capsule.
|
|
9
|
+
|
|
10
|
+
Usage: python scripts/tools/gen_experiment_catalog.py [--check]
|
|
11
|
+
--check : exit non-zero if the file is out of date (for CI/pre-commit)
|
|
12
|
+
"""
|
|
13
|
+
import glob
|
|
14
|
+
import os
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import yaml
|
|
18
|
+
|
|
19
|
+
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
20
|
+
RESULTS = os.path.join(REPO_ROOT, "sql_benchmarks", "experiments", "results")
|
|
21
|
+
DOC = os.path.join(REPO_ROOT, "docs", "experiments.md")
|
|
22
|
+
|
|
23
|
+
START = "<!-- CATALOG:START (generated by scripts/tools/gen_experiment_catalog.py — do not edit by hand) -->"
|
|
24
|
+
END = "<!-- CATALOG:END -->"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def row_for(capsule_dir: str):
|
|
28
|
+
cfg_path = os.path.join(capsule_dir, "experiment_config.yaml")
|
|
29
|
+
if not os.path.exists(cfg_path):
|
|
30
|
+
return None
|
|
31
|
+
with open(cfg_path) as f:
|
|
32
|
+
cfg = yaml.safe_load(f) or {}
|
|
33
|
+
exp_id = os.path.basename(capsule_dir)
|
|
34
|
+
meta = cfg.get("meta") or {}
|
|
35
|
+
execution = cfg.get("execution") or {}
|
|
36
|
+
name = (meta.get("name") or "—").strip().strip("'\"")
|
|
37
|
+
engines = ", ".join(execution.get("engines", [])) or "—"
|
|
38
|
+
suite = execution.get("test_suite") or "—"
|
|
39
|
+
sealed = "✓" if os.path.exists(os.path.join(capsule_dir, "integrity.seal")) else "—"
|
|
40
|
+
link = f"[`{exp_id}`](../sql_benchmarks/experiments/results/{exp_id}/)"
|
|
41
|
+
return (name, f"| {link} | {name} | {engines} | {suite} | {sealed} |")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def tracked_capsule_ids() -> set:
|
|
45
|
+
"""Capsule IDs that git actually tracks — the PUBLISHED set. results/ is
|
|
46
|
+
gitignored except a committed allowlist, so the filesystem holds local
|
|
47
|
+
scratch runs too; the catalog must list only what a cloner can see, or its
|
|
48
|
+
links 404 and it advertises experiments that aren't in the repo."""
|
|
49
|
+
out = subprocess.run(
|
|
50
|
+
["git", "ls-files", "sql_benchmarks/experiments/results/"],
|
|
51
|
+
cwd=REPO_ROOT, capture_output=True, text=True,
|
|
52
|
+
)
|
|
53
|
+
if out.returncode != 0:
|
|
54
|
+
raise RuntimeError(f"git ls-files failed; cannot determine published set:\n{out.stderr}")
|
|
55
|
+
ids = set()
|
|
56
|
+
for line in out.stdout.splitlines():
|
|
57
|
+
parts = line.split("/")
|
|
58
|
+
if len(parts) > 4: # sql_benchmarks/experiments/results/<ID>/...
|
|
59
|
+
ids.add(parts[3])
|
|
60
|
+
return ids
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def build_table() -> str:
|
|
64
|
+
published = tracked_capsule_ids()
|
|
65
|
+
rows = [r for d in sorted(glob.glob(os.path.join(RESULTS, "*")))
|
|
66
|
+
if os.path.isdir(d) and os.path.basename(d) in published
|
|
67
|
+
for r in [row_for(d)] if r]
|
|
68
|
+
rows.sort(key=lambda nr: nr[0].lower()) # by experiment name
|
|
69
|
+
header = ("| Capsule | Name | Engines | Suite | Sealed |\n"
|
|
70
|
+
"|---|---|---|---|---|")
|
|
71
|
+
body = "\n".join(line for _, line in rows)
|
|
72
|
+
return f"{header}\n{body}\n\n*{len(rows)} capsules. Generated from the sealed configs — regenerate with `python scripts/tools/gen_experiment_catalog.py`.*"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def render(existing: str, table: str) -> str:
|
|
76
|
+
block = f"{START}\n{table}\n{END}"
|
|
77
|
+
if START in existing and END in existing:
|
|
78
|
+
pre = existing.split(START)[0]
|
|
79
|
+
post = existing.split(END, 1)[1]
|
|
80
|
+
return f"{pre}{block}{post}"
|
|
81
|
+
# No markers yet — append.
|
|
82
|
+
return f"{existing.rstrip()}\n\n{block}\n"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def main():
|
|
86
|
+
check = "--check" in sys.argv[1:]
|
|
87
|
+
table = build_table()
|
|
88
|
+
existing = open(DOC).read() if os.path.exists(DOC) else "# Experiments\n"
|
|
89
|
+
updated = render(existing, table)
|
|
90
|
+
n_rows = table.count("\n| [") # data rows (header uses "| Capsule")
|
|
91
|
+
if check:
|
|
92
|
+
if existing != updated:
|
|
93
|
+
print("docs/experiments.md catalog is OUT OF DATE — run the generator.")
|
|
94
|
+
sys.exit(1)
|
|
95
|
+
print("catalog up to date ✓")
|
|
96
|
+
return
|
|
97
|
+
with open(DOC, "w") as f:
|
|
98
|
+
f.write(updated)
|
|
99
|
+
print(f"catalog written to docs/experiments.md ({n_rows} capsules)")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
main()
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import glob
|
|
5
|
+
|
|
6
|
+
# 1. SETUP PATHS INDEPENDENT OF EXECUTION CONTEXT
|
|
7
|
+
# Get the directory where this script lives (Project Root)
|
|
8
|
+
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
9
|
+
|
|
10
|
+
# Ensure module imports work regardless of where script is run from
|
|
11
|
+
if SCRIPT_DIR not in sys.path:
|
|
12
|
+
sys.path.append(SCRIPT_DIR)
|
|
13
|
+
|
|
14
|
+
from sql_benchmarks.resources.postgres_client import PostgresClient
|
|
15
|
+
# We define path manually to avoid circular dependeny or context issues
|
|
16
|
+
DATA_DIR = os.path.join(SCRIPT_DIR, "data")
|
|
17
|
+
|
|
18
|
+
def load_local(target_pattern="*.parquet"):
|
|
19
|
+
"""
|
|
20
|
+
Loads Parquet files from data/staging into local Postgres.
|
|
21
|
+
"""
|
|
22
|
+
# 2. Configuration
|
|
23
|
+
# Allow simple override via env var
|
|
24
|
+
PG_CONN = os.getenv("PG_CONN", "postgresql://postgres:password@localhost:5432/postgres")
|
|
25
|
+
STAGING_DIR = os.path.join(DATA_DIR, "staging")
|
|
26
|
+
|
|
27
|
+
print(f"\n[INFO] 🔌 Target Database: {PG_CONN}")
|
|
28
|
+
print(f" (Override with 'export PG_CONN=postgresql://...')")
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
client = PostgresClient(PG_CONN)
|
|
32
|
+
except Exception as e:
|
|
33
|
+
print(f"[ERROR] Connection failed: {e}")
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
# 2. Find Files
|
|
37
|
+
search_path = os.path.join(STAGING_DIR, target_pattern)
|
|
38
|
+
files = glob.glob(search_path)
|
|
39
|
+
|
|
40
|
+
if not files:
|
|
41
|
+
print(f"[WARN] No files found matching: {search_path}")
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
print(f"[INFO] Found {len(files)} files to ingest.")
|
|
45
|
+
|
|
46
|
+
success_count = 0
|
|
47
|
+
|
|
48
|
+
# 3. Load
|
|
49
|
+
for f in files:
|
|
50
|
+
basename = os.path.basename(f)
|
|
51
|
+
table_name = basename.replace(".parquet", "").replace(".", "_")
|
|
52
|
+
|
|
53
|
+
# Cleanup name to be SQL friendly
|
|
54
|
+
table_name = table_name.lower()
|
|
55
|
+
|
|
56
|
+
print(f"\n---> Ingesting: {basename} -> 📦 Table: {table_name}")
|
|
57
|
+
try:
|
|
58
|
+
# We use a custom loading logic here to force lowercase columns
|
|
59
|
+
# because standard PostgresClient preserves case with quotes.
|
|
60
|
+
# 1. Read Schema
|
|
61
|
+
import polars as pl
|
|
62
|
+
df = pl.scan_parquet(f).limit(1).collect()
|
|
63
|
+
|
|
64
|
+
# 2. Rename columns to lowercase
|
|
65
|
+
new_cols = {c: c.lower() for c in df.columns}
|
|
66
|
+
|
|
67
|
+
# 3. Drop/Create Table with strict lowercase
|
|
68
|
+
# We can't easily use client.bulk_load directly because it infers schema internally from file.
|
|
69
|
+
# Workaround: We let client.bulk_load do its thing, BUT we patch the _create_schema method on the instance?
|
|
70
|
+
# Or better: We subclass it for this script.
|
|
71
|
+
|
|
72
|
+
class FriendlyPostgresClient(PostgresClient):
|
|
73
|
+
def _create_schema(self, table_name, sample_df):
|
|
74
|
+
# Rename columns in the sample DF before passing to super or custom logic
|
|
75
|
+
# Polars rename is easy
|
|
76
|
+
lower_map = {c: c.lower() for c in sample_df.columns}
|
|
77
|
+
sample_df = sample_df.rename(lower_map)
|
|
78
|
+
super()._create_schema(table_name, sample_df)
|
|
79
|
+
|
|
80
|
+
# Re-init client with friendly version
|
|
81
|
+
friendly_client = FriendlyPostgresClient(PG_CONN)
|
|
82
|
+
friendly_client.bulk_load(f, table_name)
|
|
83
|
+
|
|
84
|
+
print(" [SUCCESS] Table created.")
|
|
85
|
+
success_count += 1
|
|
86
|
+
except Exception as e:
|
|
87
|
+
print(f" [FAILED] {e}")
|
|
88
|
+
|
|
89
|
+
if success_count > 0:
|
|
90
|
+
print("\n[TIP] Check your tables with:")
|
|
91
|
+
print(f" psql '{PG_CONN}' -c '\\dt'")
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__":
|
|
94
|
+
target = "*.parquet"
|
|
95
|
+
if len(sys.argv) > 1:
|
|
96
|
+
target = sys.argv[1]
|
|
97
|
+
|
|
98
|
+
load_local(target)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generate the execution-modes figure from a result capsule.
|
|
3
|
+
|
|
4
|
+
Reads raw per-replication durations from the capsule's fragments (not the
|
|
5
|
+
CSV means), so the bands show the true replication spread.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
python scripts/tools/plot_execution_modes.py <experiment_id> [output.png]
|
|
9
|
+
|
|
10
|
+
Requires matplotlib (optional tooling dependency: `uv pip install matplotlib`).
|
|
11
|
+
"""
|
|
12
|
+
import glob
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
import matplotlib.pyplot as plt
|
|
18
|
+
|
|
19
|
+
from _plotlib import RESULTS_DIR, style, bench_string
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_fragments(exp_id):
|
|
23
|
+
pattern = os.path.join(RESULTS_DIR, exp_id, "fragments", "*.json")
|
|
24
|
+
series = {} # engine -> [(rows, raw_durations_ms)]
|
|
25
|
+
for path in glob.glob(pattern):
|
|
26
|
+
with open(path) as f:
|
|
27
|
+
data = json.load(f)
|
|
28
|
+
engine = data["meta"]["engine"]
|
|
29
|
+
rows = int(data["parameters"]["rows"])
|
|
30
|
+
raw = data["metrics"].get("durations_raw") or []
|
|
31
|
+
if not raw:
|
|
32
|
+
continue # DNF
|
|
33
|
+
series.setdefault(engine, []).append((rows, [d * 1000 for d in raw]))
|
|
34
|
+
for engine in series:
|
|
35
|
+
series[engine].sort()
|
|
36
|
+
return series
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main():
|
|
40
|
+
if len(sys.argv) < 2:
|
|
41
|
+
sys.exit(__doc__)
|
|
42
|
+
exp_id = sys.argv[1]
|
|
43
|
+
out = sys.argv[2] if len(sys.argv) > 2 else os.path.join("docs", "figures", f"execution_modes_{exp_id}.png")
|
|
44
|
+
|
|
45
|
+
series = load_fragments(exp_id)
|
|
46
|
+
if not series:
|
|
47
|
+
sys.exit(f"No fragments with raw durations found for '{exp_id}'")
|
|
48
|
+
|
|
49
|
+
meta_path = os.path.join(RESULTS_DIR, exp_id, f"metadata_{exp_id}.json")
|
|
50
|
+
env = {}
|
|
51
|
+
if os.path.exists(meta_path):
|
|
52
|
+
with open(meta_path) as f:
|
|
53
|
+
env = json.load(f).get("environment", {})
|
|
54
|
+
|
|
55
|
+
fig, ax = plt.subplots(figsize=(8, 5))
|
|
56
|
+
for engine, points in series.items():
|
|
57
|
+
label, color, marker = style(engine)
|
|
58
|
+
xs = [rows for rows, _ in points]
|
|
59
|
+
med = [sorted(raw)[len(raw) // 2] for _, raw in points]
|
|
60
|
+
lo = [min(raw) for _, raw in points]
|
|
61
|
+
hi = [max(raw) for _, raw in points]
|
|
62
|
+
ax.plot(xs, med, marker=marker, color=color, label=label, linewidth=2)
|
|
63
|
+
ax.fill_between(xs, lo, hi, color=color, alpha=0.18)
|
|
64
|
+
|
|
65
|
+
ax.set_xscale("log")
|
|
66
|
+
ax.set_yscale("log")
|
|
67
|
+
ax.set_xlabel("Table rows (log scale)")
|
|
68
|
+
ax.set_ylabel("Query duration, ms (log scale, median of replications)")
|
|
69
|
+
bench = bench_string(env, engines=series.keys())
|
|
70
|
+
ax.set_title(f"Query cost by engine — experiment {exp_id}\n"
|
|
71
|
+
f"cold cache, bands = replication min–max · {bench}", fontsize=10)
|
|
72
|
+
ax.legend(frameon=False)
|
|
73
|
+
ax.grid(True, which="both", alpha=0.25)
|
|
74
|
+
fig.tight_layout()
|
|
75
|
+
|
|
76
|
+
os.makedirs(os.path.dirname(out), exist_ok=True)
|
|
77
|
+
fig.savefig(out, dpi=160)
|
|
78
|
+
print(f"Figure written: {out}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
main()
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Log-log scaling figure (median duration vs row count) for a row-scaled capsule,
|
|
4
|
+
built ONLY from the capsule's sealed fragments — regenerable, can't drift.
|
|
5
|
+
Error bars show the replication spread where more than one rep was retained.
|
|
6
|
+
|
|
7
|
+
Usage: python scripts/tools/plot_scaling.py <experiment_id> [<id> ...]
|
|
8
|
+
Output: scratch/figures/scaling_<id>.png (gitignored; for the article)
|
|
9
|
+
"""
|
|
10
|
+
import glob
|
|
11
|
+
import json
|
|
12
|
+
import math
|
|
13
|
+
import os
|
|
14
|
+
import statistics
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
import matplotlib
|
|
18
|
+
matplotlib.use("Agg")
|
|
19
|
+
import matplotlib.pyplot as plt
|
|
20
|
+
|
|
21
|
+
from _plotlib import REPO_ROOT, RESULTS_DIR, style
|
|
22
|
+
|
|
23
|
+
OUT = os.path.join(REPO_ROOT, "scratch", "figures")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def series(capsule: str) -> dict:
|
|
27
|
+
s: dict = {}
|
|
28
|
+
for p in glob.glob(os.path.join(capsule, "fragments", "*.json")):
|
|
29
|
+
with open(p) as f:
|
|
30
|
+
d = json.load(f)
|
|
31
|
+
par, m = d.get("parameters", {}), d.get("metrics", {})
|
|
32
|
+
if "rows" not in par:
|
|
33
|
+
continue
|
|
34
|
+
raw = m.get("durations_raw") or (
|
|
35
|
+
[m["duration_seconds"]] if m.get("duration_seconds") is not None else []
|
|
36
|
+
)
|
|
37
|
+
if raw:
|
|
38
|
+
s.setdefault(d["meta"]["engine"], {}).setdefault(int(par["rows"]), []).extend(raw)
|
|
39
|
+
return s
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def alpha(points: dict) -> float:
|
|
43
|
+
"""log-log least-squares slope (power-law exponent)."""
|
|
44
|
+
xs = [math.log10(n) for n in points]
|
|
45
|
+
ys = [math.log10(statistics.median(points[n])) for n in points]
|
|
46
|
+
k = len(xs)
|
|
47
|
+
mx, my = sum(xs) / k, sum(ys) / k
|
|
48
|
+
sxx = sum((x - mx) ** 2 for x in xs)
|
|
49
|
+
return sum((xs[i] - mx) * (ys[i] - my) for i in range(k)) / sxx if sxx else 0.0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def plot(exp_id: str) -> None:
|
|
53
|
+
capsule = os.path.join(RESULTS_DIR, exp_id)
|
|
54
|
+
s = series(capsule)
|
|
55
|
+
if not s:
|
|
56
|
+
print(f"{exp_id}: no row-scaled fragments — skipped")
|
|
57
|
+
return
|
|
58
|
+
os.makedirs(OUT, exist_ok=True)
|
|
59
|
+
plt.figure(figsize=(7, 5))
|
|
60
|
+
for eng in sorted(s):
|
|
61
|
+
pts = s[eng]
|
|
62
|
+
ns = sorted(pts)
|
|
63
|
+
med = [statistics.median(pts[n]) * 1000 for n in ns]
|
|
64
|
+
a = alpha(pts)
|
|
65
|
+
label, color, marker = style(eng)
|
|
66
|
+
lo = [(statistics.median(pts[n]) - min(pts[n])) * 1000 for n in ns]
|
|
67
|
+
hi = [(max(pts[n]) - statistics.median(pts[n])) * 1000 for n in ns]
|
|
68
|
+
if any(l > 0 or h > 0 for l, h in zip(lo, hi)):
|
|
69
|
+
plt.errorbar(ns, med, yerr=[lo, hi], marker=marker, color=color,
|
|
70
|
+
capsize=3, label=f"{label} (α≈{a:.2f})")
|
|
71
|
+
else:
|
|
72
|
+
plt.plot(ns, med, marker=marker, color=color, label=f"{label} (α≈{a:.2f})")
|
|
73
|
+
plt.xscale("log")
|
|
74
|
+
plt.yscale("log")
|
|
75
|
+
plt.xlabel("Rows (log scale)")
|
|
76
|
+
plt.ylabel("Median duration, ms (log scale)")
|
|
77
|
+
plt.title(f"Scaling: duration vs rows — {exp_id}")
|
|
78
|
+
plt.legend()
|
|
79
|
+
plt.grid(True, which="both", ls=":", alpha=0.4)
|
|
80
|
+
out = os.path.join(OUT, f"scaling_{exp_id}.png")
|
|
81
|
+
plt.tight_layout()
|
|
82
|
+
plt.savefig(out, dpi=150)
|
|
83
|
+
plt.close()
|
|
84
|
+
print(f"wrote {out}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
if len(sys.argv) < 2:
|
|
89
|
+
sys.exit(__doc__)
|
|
90
|
+
for exp_id in sys.argv[1:]:
|
|
91
|
+
plot(exp_id)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generate the selectivity-sweep figure from the selectivity capsules.
|
|
3
|
+
|
|
4
|
+
Plots query time vs predicate selectivity at a fixed scale (default 10M rows),
|
|
5
|
+
overlaying five lanes: in-process DuckDB, Quack attach, Quack pushdown, and
|
|
6
|
+
PostgreSQL with and without a B-tree index on the filtered column. The two
|
|
7
|
+
Postgres lanes come from two capsules (indexed vs no-index); the three
|
|
8
|
+
columnar/Quack lanes are read from the indexed capsule.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
python scripts/tools/plot_selectivity.py [indexed_id] [noindex_id] [partition] [out.png]
|
|
12
|
+
# defaults: 461beee8 28f7aa1c large docs/figures/selectivity_461beee8.png
|
|
13
|
+
|
|
14
|
+
Requires matplotlib (optional tooling dependency: `uv pip install matplotlib`).
|
|
15
|
+
"""
|
|
16
|
+
import csv
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
import matplotlib.pyplot as plt
|
|
22
|
+
|
|
23
|
+
RESULTS_DIR = os.path.join("sql_benchmarks", "experiments", "results")
|
|
24
|
+
|
|
25
|
+
# selectivity token -> (label, x-position)
|
|
26
|
+
SEL = [("0_1", "0.1%"), ("1", "1%"), ("5", "5%"),
|
|
27
|
+
("10", "10%"), ("20", "20%"), ("filler", "scan")]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _token(asset):
|
|
31
|
+
for tok, _ in SEL:
|
|
32
|
+
suffix = "q_filler" if tok == "filler" else f"q_{tok}_percent"
|
|
33
|
+
if asset.endswith(suffix):
|
|
34
|
+
return tok
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load(cid, partition):
|
|
39
|
+
"""engine -> {sel_token: (median_ms, min_ms, max_ms)}"""
|
|
40
|
+
out = {}
|
|
41
|
+
path = os.path.join(RESULTS_DIR, cid, f"{cid}.csv")
|
|
42
|
+
with open(path) as f:
|
|
43
|
+
for r in csv.DictReader(f):
|
|
44
|
+
if r["Partition"] != partition:
|
|
45
|
+
continue
|
|
46
|
+
tok = _token(r["Asset"])
|
|
47
|
+
if tok is None:
|
|
48
|
+
continue
|
|
49
|
+
out.setdefault(r["Engine"], {})[tok] = (
|
|
50
|
+
float(r["Duration"]) * 1000,
|
|
51
|
+
float(r["Duration_Min"]) * 1000,
|
|
52
|
+
float(r["Duration_Max"]) * 1000,
|
|
53
|
+
)
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def series(data, engine):
|
|
58
|
+
xs, ys, lo, hi = [], [], [], []
|
|
59
|
+
for i, (tok, _) in enumerate(SEL):
|
|
60
|
+
if engine in data and tok in data[engine]:
|
|
61
|
+
m, a, b = data[engine][tok]
|
|
62
|
+
xs.append(i); ys.append(m); lo.append(m - a); hi.append(b - m)
|
|
63
|
+
return xs, ys, lo, hi
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def main():
|
|
67
|
+
indexed = sys.argv[1] if len(sys.argv) > 1 else "461beee8"
|
|
68
|
+
noindex = sys.argv[2] if len(sys.argv) > 2 else "28f7aa1c"
|
|
69
|
+
partition = sys.argv[3] if len(sys.argv) > 3 else "large"
|
|
70
|
+
out = sys.argv[4] if len(sys.argv) > 4 else os.path.join(
|
|
71
|
+
"docs", "figures", f"selectivity_{indexed}.png")
|
|
72
|
+
|
|
73
|
+
idx = load(indexed, partition)
|
|
74
|
+
noi = load(noindex, partition)
|
|
75
|
+
|
|
76
|
+
meta_path = os.path.join(RESULTS_DIR, indexed, f"metadata_{indexed}.json")
|
|
77
|
+
env = {}
|
|
78
|
+
if os.path.exists(meta_path):
|
|
79
|
+
with open(meta_path) as f:
|
|
80
|
+
env = json.load(f).get("environment", {})
|
|
81
|
+
|
|
82
|
+
# lane -> (source data, engine key, label, color, marker, linestyle)
|
|
83
|
+
lanes = [
|
|
84
|
+
(idx, "duckdb", "DuckDB in-process", "#2f6f4f", "o", "-"),
|
|
85
|
+
(idx, "quack_pushdown", "Quack pushdown", "#3b6ea5", "^", "-"),
|
|
86
|
+
(idx, "quack", "Quack attach", "#b3402a", "s", "-"),
|
|
87
|
+
(idx, "postgres", "PostgreSQL + index", "#7a5ea8", "D", "-"),
|
|
88
|
+
(noi, "postgres", "PostgreSQL no index", "#999999", "v", "--"),
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
fig, ax = plt.subplots(figsize=(9, 5.5))
|
|
92
|
+
for data, eng, label, color, marker, ls in lanes:
|
|
93
|
+
xs, ys, lo, hi = series(data, eng)
|
|
94
|
+
if not xs:
|
|
95
|
+
continue
|
|
96
|
+
ax.errorbar(xs, ys, yerr=[lo, hi], label=label, color=color,
|
|
97
|
+
marker=marker, linestyle=ls, capsize=3, linewidth=1.8,
|
|
98
|
+
markersize=6)
|
|
99
|
+
|
|
100
|
+
ax.set_yscale("log")
|
|
101
|
+
ax.set_xticks(range(len(SEL)))
|
|
102
|
+
ax.set_xticklabels([lbl for _, lbl in SEL])
|
|
103
|
+
ax.set_xlabel("predicate selectivity (fraction of rows matched)")
|
|
104
|
+
ax.set_ylabel("query time (ms, log scale) — cold cache, 5 reps")
|
|
105
|
+
rows = "10M" if partition == "large" else "1M"
|
|
106
|
+
ax.set_title(f"Selectivity sweep @ {rows} rows: where does the index help — and hurt?")
|
|
107
|
+
|
|
108
|
+
# Annotate the Postgres-index plan transitions (EXPLAIN-verified).
|
|
109
|
+
if "postgres" in idx:
|
|
110
|
+
ax.annotate("Index-Only Scan", xy=(0, idx["postgres"]["0_1"][0]),
|
|
111
|
+
xytext=(0.1, idx["postgres"]["0_1"][0] * 0.45),
|
|
112
|
+
fontsize=8, color="#7a5ea8")
|
|
113
|
+
if "5" in idx["postgres"]:
|
|
114
|
+
ax.annotate("Bitmap Heap Scan\n(index becomes a liability)",
|
|
115
|
+
xy=(2, idx["postgres"]["5"][0]),
|
|
116
|
+
xytext=(2.35, idx["postgres"]["5"][0] * 0.62),
|
|
117
|
+
fontsize=8, color="#7a5ea8",
|
|
118
|
+
arrowprops=dict(arrowstyle="->", color="#7a5ea8", lw=0.8))
|
|
119
|
+
|
|
120
|
+
ax.grid(True, which="both", axis="y", alpha=0.25)
|
|
121
|
+
ax.legend(frameon=False, fontsize=9, loc="center right")
|
|
122
|
+
|
|
123
|
+
sub = f"capsules {indexed} (indexed) + {noindex} (no index)"
|
|
124
|
+
if env:
|
|
125
|
+
cores = env.get("cpu_count_logical")
|
|
126
|
+
bits = [env.get("os", ""), env.get("machine", "")]
|
|
127
|
+
if cores:
|
|
128
|
+
bits.append(f"{cores} cores")
|
|
129
|
+
sub += " · " + ", ".join(b for b in bits if b)
|
|
130
|
+
fig.text(0.5, 0.005, sub, ha="center", fontsize=7.5, color="#666666")
|
|
131
|
+
|
|
132
|
+
fig.tight_layout(rect=(0, 0.02, 1, 1))
|
|
133
|
+
os.makedirs(os.path.dirname(out), exist_ok=True)
|
|
134
|
+
fig.savefig(out, dpi=150)
|
|
135
|
+
print(f"wrote {out}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
if __name__ == "__main__":
|
|
139
|
+
main()
|