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.
Files changed (153) hide show
  1. infrastructure/debug_aws.py +40 -0
  2. scripts/dev/backfill_queries.py +44 -0
  3. scripts/dev/timestamp_capsule.py +49 -0
  4. scripts/dev/upgrade_capsule.py +63 -0
  5. scripts/dev/verify_capsule.py +79 -0
  6. scripts/dev/verify_portability.py +103 -0
  7. scripts/tools/_plotlib.py +43 -0
  8. scripts/tools/analyze_scaling.py +39 -0
  9. scripts/tools/demo_breach_detection.py +61 -0
  10. scripts/tools/extract_results.py +57 -0
  11. scripts/tools/find_kink.py +104 -0
  12. scripts/tools/gen_experiment_catalog.py +103 -0
  13. scripts/tools/load_parquet_to_local.py +98 -0
  14. scripts/tools/plot_execution_modes.py +82 -0
  15. scripts/tools/plot_scaling.py +91 -0
  16. scripts/tools/plot_selectivity.py +139 -0
  17. scripts/tools/plot_threads.py +95 -0
  18. scripts/tools/regenerate_dashboard.py +58 -0
  19. scripts/tools/verify_doc_claims.py +88 -0
  20. sql_benchmarks/__init__.py +3 -0
  21. sql_benchmarks/api/__init__.py +0 -0
  22. sql_benchmarks/api/app.py +30 -0
  23. sql_benchmarks/api/data/__init__.py +0 -0
  24. sql_benchmarks/api/data/catalog_reader.py +73 -0
  25. sql_benchmarks/api/data/reader.py +125 -0
  26. sql_benchmarks/api/logic/__init__.py +0 -0
  27. sql_benchmarks/api/logic/comparator.py +44 -0
  28. sql_benchmarks/api/logic/ranker.py +127 -0
  29. sql_benchmarks/api/models/__init__.py +0 -0
  30. sql_benchmarks/api/models/catalog.py +22 -0
  31. sql_benchmarks/api/models/experiments.py +20 -0
  32. sql_benchmarks/api/models/recommend.py +11 -0
  33. sql_benchmarks/api/models/results.py +66 -0
  34. sql_benchmarks/api/routers/__init__.py +0 -0
  35. sql_benchmarks/api/routers/catalog.py +19 -0
  36. sql_benchmarks/api/routers/experiments.py +79 -0
  37. sql_benchmarks/api/routers/recommend.py +22 -0
  38. sql_benchmarks/api/routers/results.py +51 -0
  39. sql_benchmarks/assets/__init__.py +4 -0
  40. sql_benchmarks/assets/benchmark_factory.py +222 -0
  41. sql_benchmarks/assets/data_factory.py +54 -0
  42. sql_benchmarks/assets/data_quality.py +91 -0
  43. sql_benchmarks/assets/ingestion_factory.py +88 -0
  44. sql_benchmarks/assets/maintenance.py +63 -0
  45. sql_benchmarks/assets/reporting.py +385 -0
  46. sql_benchmarks/assets/semantic_gate.py +76 -0
  47. sql_benchmarks/cli.py +69 -0
  48. sql_benchmarks/config_loader.py +104 -0
  49. sql_benchmarks/constants.py +60 -0
  50. sql_benchmarks/coordinator.py +282 -0
  51. sql_benchmarks/definitions.py +115 -0
  52. sql_benchmarks/experiments/templates/experiment_template.yaml +99 -0
  53. sql_benchmarks/harness.py +84 -0
  54. sql_benchmarks/jobs.py +8 -0
  55. sql_benchmarks/partitions.py +17 -0
  56. sql_benchmarks/plugins/data_sources/declarative_gen.py +211 -0
  57. sql_benchmarks/plugins/data_sources/local_file_loader.py +62 -0
  58. sql_benchmarks/plugins/data_sources/tpc_h.py +61 -0
  59. sql_benchmarks/resources/__init__.py +0 -0
  60. sql_benchmarks/resources/actian.py +234 -0
  61. sql_benchmarks/resources/actian_client.py +231 -0
  62. sql_benchmarks/resources/base_engine.py +58 -0
  63. sql_benchmarks/resources/base_schema_client.py +104 -0
  64. sql_benchmarks/resources/duckdb.py +58 -0
  65. sql_benchmarks/resources/duckdb_client.py +157 -0
  66. sql_benchmarks/resources/postgres.py +188 -0
  67. sql_benchmarks/resources/postgres_client.py +184 -0
  68. sql_benchmarks/resources/quack.py +56 -0
  69. sql_benchmarks/resources/quack_client.py +169 -0
  70. sql_benchmarks/resources/typedb_client.py +370 -0
  71. sql_benchmarks/resources/typedb_engine.py +303 -0
  72. sql_benchmarks/scripts/__init__.py +0 -0
  73. sql_benchmarks/scripts/sql/acid_test/duckdb/test.sql +1 -0
  74. sql_benchmarks/scripts/sql/acid_test/postgres/test.sql +1 -0
  75. sql_benchmarks/scripts/sql/analytical_wall/actian/analytical_wall.sql +15 -0
  76. sql_benchmarks/scripts/sql/analytical_wall/analytical_wall.sql.template +19 -0
  77. sql_benchmarks/scripts/sql/analytical_wall/duckdb/analytical_wall.sql +15 -0
  78. sql_benchmarks/scripts/sql/analytical_wall/postgres/analytical_wall.sql +15 -0
  79. sql_benchmarks/scripts/sql/group_by/duckdb/groupby_antipattern.sql +11 -0
  80. sql_benchmarks/scripts/sql/group_by/duckdb/groupby_recommended_cte.sql +19 -0
  81. sql_benchmarks/scripts/sql/group_by/postgres/groupby_antipattern.sql +13 -0
  82. sql_benchmarks/scripts/sql/group_by/postgres/groupby_recommended_pk.sql +11 -0
  83. sql_benchmarks/scripts/sql/hypergraph/duckdb/q_1hop.sql +5 -0
  84. sql_benchmarks/scripts/sql/hypergraph/duckdb/q_2hop.sql +6 -0
  85. sql_benchmarks/scripts/sql/hypergraph/duckdb/q_full.sql +9 -0
  86. sql_benchmarks/scripts/sql/hypergraph/typedb/q_1hop.sql +4 -0
  87. sql_benchmarks/scripts/sql/hypergraph/typedb/q_2hop.sql +5 -0
  88. sql_benchmarks/scripts/sql/hypergraph/typedb/q_full.sql +6 -0
  89. sql_benchmarks/scripts/sql/joins/duckdb/join_antipattern.sql +5 -0
  90. sql_benchmarks/scripts/sql/joins/duckdb/join_recommended.sql +4 -0
  91. sql_benchmarks/scripts/sql/joins/postgres/join_antipattern.sql +5 -0
  92. sql_benchmarks/scripts/sql/joins/postgres/join_recommended.sql +4 -0
  93. sql_benchmarks/scripts/sql/null_logic/duckdb/2VL_identity.sql +14 -0
  94. sql_benchmarks/scripts/sql/null_logic/duckdb/3VL_identity.sql +5 -0
  95. sql_benchmarks/scripts/sql/null_logic/postgres/2VL_identity.sql +14 -0
  96. sql_benchmarks/scripts/sql/null_logic/postgres/3VL_identity.sql +5 -0
  97. sql_benchmarks/scripts/sql/null_sentinel/duckdb/2VL_identity.sql +14 -0
  98. sql_benchmarks/scripts/sql/null_sentinel/duckdb/3VL_identity.sql +5 -0
  99. sql_benchmarks/scripts/sql/null_sentinel/duckdb/sentinel_optimization.sql +16 -0
  100. sql_benchmarks/scripts/sql/null_sentinel/postgres/2VL_identity.sql +14 -0
  101. sql_benchmarks/scripts/sql/null_sentinel/postgres/3VL_identity.sql +5 -0
  102. sql_benchmarks/scripts/sql/null_sentinel/postgres/sentinel_optimization.sql +21 -0
  103. sql_benchmarks/scripts/sql/recursion/duckdb/recursive_cte.sql +26 -0
  104. sql_benchmarks/scripts/sql/recursion/postgres/recursive_cte.sql +30 -0
  105. sql_benchmarks/scripts/sql/recursive_graph/duckdb/q_bounded_3hop.sql +17 -0
  106. sql_benchmarks/scripts/sql/recursive_graph/duckdb/q_fixed_2hop.sql +6 -0
  107. sql_benchmarks/scripts/sql/recursive_graph/duckdb/q_transitive_closure.sql +17 -0
  108. sql_benchmarks/scripts/sql/recursive_graph/typedb/q_bounded_3hop.sql +6 -0
  109. sql_benchmarks/scripts/sql/recursive_graph/typedb/q_fixed_2hop.sql +5 -0
  110. sql_benchmarks/scripts/sql/recursive_graph/typedb/q_transitive_closure.sql +7 -0
  111. sql_benchmarks/scripts/sql/security/acid_resilience/duckdb/test.sql +1 -0
  112. sql_benchmarks/scripts/sql/selectivity/duckdb/q_0_1_percent.sql +1 -0
  113. sql_benchmarks/scripts/sql/selectivity/duckdb/q_10_percent.sql +1 -0
  114. sql_benchmarks/scripts/sql/selectivity/duckdb/q_1_percent.sql +1 -0
  115. sql_benchmarks/scripts/sql/selectivity/duckdb/q_20_percent.sql +1 -0
  116. sql_benchmarks/scripts/sql/selectivity/duckdb/q_5_percent.sql +1 -0
  117. sql_benchmarks/scripts/sql/selectivity/duckdb/q_filler.sql +1 -0
  118. sql_benchmarks/scripts/sql/selectivity/postgres/q_0_1_percent.sql +1 -0
  119. sql_benchmarks/scripts/sql/selectivity/postgres/q_10_percent.sql +1 -0
  120. sql_benchmarks/scripts/sql/selectivity/postgres/q_1_percent.sql +1 -0
  121. sql_benchmarks/scripts/sql/selectivity/postgres/q_20_percent.sql +1 -0
  122. sql_benchmarks/scripts/sql/selectivity/postgres/q_5_percent.sql +1 -0
  123. sql_benchmarks/scripts/sql/selectivity/postgres/q_filler.sql +1 -0
  124. sql_benchmarks/scripts/sql/selectivity/typedb/q_0_1_percent.sql +4 -0
  125. sql_benchmarks/scripts/sql/selectivity/typedb/q_10_percent.sql +4 -0
  126. sql_benchmarks/scripts/sql/selectivity/typedb/q_1_percent.sql +4 -0
  127. sql_benchmarks/scripts/sql/selectivity/typedb/q_20_percent.sql +4 -0
  128. sql_benchmarks/scripts/sql/selectivity/typedb/q_5_percent.sql +4 -0
  129. sql_benchmarks/scripts/sql/selectivity/typedb/q_filler.sql +4 -0
  130. sql_benchmarks/scripts/sql/sort_spill/duckdb/sort_full.sql +25 -0
  131. sql_benchmarks/scripts/sql/sort_spill/duckdb/sort_wide_rows.sql +25 -0
  132. sql_benchmarks/scripts/sql/sort_spill/postgres/sort_full.sql +25 -0
  133. sql_benchmarks/scripts/sql/sort_spill/postgres/sort_wide_rows.sql +26 -0
  134. sql_benchmarks/scripts/sql/tpch/duckdb/q3_shipping_priority.sql +25 -0
  135. sql_benchmarks/scripts/sql/tpch/postgres/q3_shipping_priority.sql +25 -0
  136. sql_benchmarks/utils/__init__.py +0 -0
  137. sql_benchmarks/utils/common.py +260 -0
  138. sql_benchmarks/utils/ddl.py +47 -0
  139. sql_benchmarks/utils/hasher.py +124 -0
  140. sql_benchmarks/utils/integrity_monitor.py +55 -0
  141. sql_benchmarks/utils/providers.py +184 -0
  142. sql_benchmarks/utils/scaling.py +88 -0
  143. sql_benchmarks/utils/schema.py +118 -0
  144. sql_benchmarks/utils/semantic_auditor.py +81 -0
  145. sql_benchmarks/utils/system.py +100 -0
  146. sql_benchmarks/validator.py +60 -0
  147. sqlbenchdag-0.1.0.dist-info/METADATA +221 -0
  148. sqlbenchdag-0.1.0.dist-info/RECORD +153 -0
  149. sqlbenchdag-0.1.0.dist-info/WHEEL +5 -0
  150. sqlbenchdag-0.1.0.dist-info/entry_points.txt +2 -0
  151. sqlbenchdag-0.1.0.dist-info/licenses/LICENSE +202 -0
  152. sqlbenchdag-0.1.0.dist-info/top_level.txt +4 -0
  153. 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()