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,95 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Act II figure: the Quack pushdown server runs queries with FEWER effective
|
|
4
|
+
threads than an in-process DuckDB connection. We sweep in-process `duckdb.threads`
|
|
5
|
+
(1,2,4,8) and overlay pushdown (which ignores that knob — it runs in the server's
|
|
6
|
+
own execution context) as a horizontal reference. Where the pushdown line meets
|
|
7
|
+
the in-process curve is the server's *effective* thread count.
|
|
8
|
+
|
|
9
|
+
Reads only the capsule's sealed fragments. Output: scratch/figures/threads_<id>.png
|
|
10
|
+
|
|
11
|
+
Usage: python scripts/tools/plot_threads.py [<experiment_id>] (default 25b0e134)
|
|
12
|
+
"""
|
|
13
|
+
import glob
|
|
14
|
+
import json
|
|
15
|
+
import math
|
|
16
|
+
import os
|
|
17
|
+
import statistics
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
import matplotlib
|
|
21
|
+
matplotlib.use("Agg")
|
|
22
|
+
import matplotlib.pyplot as plt
|
|
23
|
+
|
|
24
|
+
from _plotlib import REPO_ROOT, RESULTS_DIR, style
|
|
25
|
+
|
|
26
|
+
OUT = os.path.join(REPO_ROOT, "scratch", "figures")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load(exp_id):
|
|
30
|
+
s = {}
|
|
31
|
+
for p in glob.glob(os.path.join(RESULTS_DIR, exp_id, "fragments", "*.json")):
|
|
32
|
+
d = json.load(open(p))
|
|
33
|
+
par, m = d.get("parameters", {}), d.get("metrics", {})
|
|
34
|
+
raw = m.get("durations_raw") or (
|
|
35
|
+
[m["duration_seconds"]] if m.get("duration_seconds") is not None else [])
|
|
36
|
+
if not raw:
|
|
37
|
+
continue
|
|
38
|
+
th = par.get("threads") or par.get("duckdb.threads")
|
|
39
|
+
s.setdefault((d["meta"]["engine"], par.get("rows")), {}).setdefault(int(th), []).extend(raw)
|
|
40
|
+
return s
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def effective_threads(threads, durs, target):
|
|
44
|
+
"""log-log interpolate the in-process curve to find threads where dur==target."""
|
|
45
|
+
xs = [math.log2(t) for t in threads]
|
|
46
|
+
ys = [math.log10(d) for d in durs]
|
|
47
|
+
ty = math.log10(target)
|
|
48
|
+
for i in range(len(xs) - 1):
|
|
49
|
+
if (ys[i] - ty) * (ys[i + 1] - ty) <= 0 and ys[i] != ys[i + 1]:
|
|
50
|
+
f = (ty - ys[i]) / (ys[i + 1] - ys[i])
|
|
51
|
+
return 2 ** (xs[i] + f * (xs[i + 1] - xs[i]))
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main(exp_id="25b0e134"):
|
|
56
|
+
s = load(exp_id)
|
|
57
|
+
os.makedirs(OUT, exist_ok=True)
|
|
58
|
+
rows_vals = sorted({r for (_, r) in s}, key=lambda x: int(x))
|
|
59
|
+
plt.figure(figsize=(7.5, 5))
|
|
60
|
+
colors = plt.cm.viridis([0.2, 0.7])
|
|
61
|
+
for c, rows in zip(colors, rows_vals):
|
|
62
|
+
dd = s.get(("duckdb", rows), {})
|
|
63
|
+
pd = s.get(("quack_pushdown", rows), {})
|
|
64
|
+
if not dd or not pd:
|
|
65
|
+
continue
|
|
66
|
+
threads = sorted(dd)
|
|
67
|
+
durs = [statistics.median(dd[t]) * 1000 for t in threads]
|
|
68
|
+
push = statistics.median([v for vals in pd.values() for v in vals]) * 1000
|
|
69
|
+
label_n = f"{int(rows):,} rows"
|
|
70
|
+
plt.plot(threads, durs, marker="o", color=c, label=f"{style('duckdb')[0]} — {label_n}")
|
|
71
|
+
plt.axhline(push, ls="--", color=c, alpha=0.8)
|
|
72
|
+
eff = effective_threads(threads, durs, push)
|
|
73
|
+
if eff:
|
|
74
|
+
plt.annotate(f"pushdown ≈ {eff:.1f} threads", xy=(eff, push), xytext=(eff, push * 1.35),
|
|
75
|
+
color=c, fontsize=9, ha="center",
|
|
76
|
+
arrowprops=dict(arrowstyle="->", color=c, alpha=0.7))
|
|
77
|
+
plt.text(8.1, push, f"pushdown {label_n}", va="center", fontsize=8, color=c)
|
|
78
|
+
plt.xscale("log", base=2)
|
|
79
|
+
plt.yscale("log")
|
|
80
|
+
plt.xticks([1, 2, 4, 8], ["1", "2", "4", "8"])
|
|
81
|
+
plt.xlabel("in-process DuckDB threads (of 8 cores)")
|
|
82
|
+
plt.ylabel("Median duration, ms (log scale)")
|
|
83
|
+
plt.title(f"Act II — the pushdown server runs at ~2–4 effective threads, not 8\n"
|
|
84
|
+
f"(dashed = pushdown, which ignores the thread knob; capsule {exp_id})")
|
|
85
|
+
plt.legend(loc="upper right", fontsize=8)
|
|
86
|
+
plt.grid(True, which="both", ls=":", alpha=0.4)
|
|
87
|
+
out = os.path.join(OUT, f"threads_{exp_id}.png")
|
|
88
|
+
plt.tight_layout()
|
|
89
|
+
plt.savefig(out, dpi=150)
|
|
90
|
+
plt.close()
|
|
91
|
+
print(f"wrote {out}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if __name__ == "__main__":
|
|
95
|
+
main(sys.argv[1] if len(sys.argv) > 1 else "25b0e134")
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
|
|
2
|
+
import sys
|
|
3
|
+
import os
|
|
4
|
+
import logging
|
|
5
|
+
from dagster import build_asset_context
|
|
6
|
+
|
|
7
|
+
# Setup paths (Robust)
|
|
8
|
+
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
9
|
+
ROOT_DIR = os.path.dirname(SCRIPT_DIR)
|
|
10
|
+
if ROOT_DIR not in sys.path:
|
|
11
|
+
sys.path.append(ROOT_DIR)
|
|
12
|
+
|
|
13
|
+
from sql_benchmarks.assets.reporting import performance_dashboard
|
|
14
|
+
from sql_benchmarks.utils import common as common_utils
|
|
15
|
+
|
|
16
|
+
import sql_benchmarks.assets.reporting
|
|
17
|
+
print(f"[DEBUG] Loaded reporting from: {sql_benchmarks.assets.reporting.__file__}")
|
|
18
|
+
|
|
19
|
+
# Configure logging
|
|
20
|
+
logging.basicConfig(level=logging.INFO, format='[%(levelname)s] %(message)s')
|
|
21
|
+
|
|
22
|
+
def regenerate(exp_id):
|
|
23
|
+
print(f"Regenerating Dashboard for: {exp_id}")
|
|
24
|
+
|
|
25
|
+
# 1. Mock Context for Experiment ID
|
|
26
|
+
import sql_benchmarks.assets.reporting
|
|
27
|
+
|
|
28
|
+
def mock_load():
|
|
29
|
+
return {"meta": {"experiment_id": exp_id}}
|
|
30
|
+
|
|
31
|
+
sql_benchmarks.assets.reporting.load_context = mock_load
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
# 2. Build Asset Context
|
|
35
|
+
context = build_asset_context()
|
|
36
|
+
# Redirect Dagster log to stdout
|
|
37
|
+
context.log.setLevel(logging.INFO)
|
|
38
|
+
context.log.addHandler(logging.StreamHandler(sys.stdout))
|
|
39
|
+
|
|
40
|
+
# 3. Run Asset Logic
|
|
41
|
+
result = performance_dashboard(context)
|
|
42
|
+
|
|
43
|
+
if result:
|
|
44
|
+
print("Dashboard Generated Successfully!")
|
|
45
|
+
except Exception as e:
|
|
46
|
+
print(f"Failed: {e}")
|
|
47
|
+
import traceback
|
|
48
|
+
traceback.print_exc()
|
|
49
|
+
finally:
|
|
50
|
+
# Restore (Optional, script is ending anyway)
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
if len(sys.argv) < 2:
|
|
55
|
+
print("Usage: python regenerate_dashboard.py <experiment_id>")
|
|
56
|
+
sys.exit(1)
|
|
57
|
+
|
|
58
|
+
regenerate(sys.argv[1])
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Verify that every capsule reference in the committed docs resolves to a real,
|
|
4
|
+
git-tracked, sealed capsule. Catches the recurring error class: a doc citing a
|
|
5
|
+
capsule ID that doesn't exist (fabricated or typo) or isn't committed (broken
|
|
6
|
+
link) — e.g. the fabricated `3e2fe152` IDs an early article draft carried.
|
|
7
|
+
|
|
8
|
+
Scans: README.md, AGENTS.md, and docs/**/*.md.
|
|
9
|
+
Reference forms recognized (the house conventions for citing a capsule):
|
|
10
|
+
- path: .../experiments/results/<8hex>/
|
|
11
|
+
- inline: `<8hex>` (backtick-quoted ID)
|
|
12
|
+
|
|
13
|
+
Stdlib only — safe to run from a bare `python3` in a git pre-commit hook.
|
|
14
|
+
|
|
15
|
+
Usage: python scripts/tools/verify_doc_claims.py [--check]
|
|
16
|
+
--check : exit 1 if any reference is unresolved (for the hook / CI)
|
|
17
|
+
"""
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
|
|
23
|
+
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
24
|
+
RESULTS_REL = "sql_benchmarks/experiments/results"
|
|
25
|
+
TOP_DOCS = ["README.md", "AGENTS.md"]
|
|
26
|
+
PATH_RE = re.compile(r"results/([0-9a-f]{8})(?:/|\b)")
|
|
27
|
+
TICK_RE = re.compile(r"`([0-9a-f]{8})`")
|
|
28
|
+
|
|
29
|
+
# 8-hex tokens that are NOT capsule IDs (e.g. a short commit SHA cited in prose).
|
|
30
|
+
# Add here if the verifier ever false-positives on a legitimate non-capsule hex.
|
|
31
|
+
IGNORE = {
|
|
32
|
+
"48c92f31", # illustrative example ID in README's Naming table — not a real capsule
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def tracked_capsule_ids() -> set:
|
|
37
|
+
out = subprocess.run(
|
|
38
|
+
["git", "ls-files", RESULTS_REL + "/"],
|
|
39
|
+
cwd=REPO_ROOT, capture_output=True, text=True,
|
|
40
|
+
)
|
|
41
|
+
ids = set()
|
|
42
|
+
for line in out.stdout.splitlines():
|
|
43
|
+
parts = line.split("/")
|
|
44
|
+
if len(parts) > 4: # .../results/<ID>/<file>
|
|
45
|
+
ids.add(parts[3])
|
|
46
|
+
return ids
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def sealed(exp_id: str) -> bool:
|
|
50
|
+
return os.path.exists(os.path.join(REPO_ROOT, RESULTS_REL, exp_id, "integrity.seal"))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def doc_files() -> list:
|
|
54
|
+
files = [os.path.join(REPO_ROOT, d) for d in TOP_DOCS
|
|
55
|
+
if os.path.exists(os.path.join(REPO_ROOT, d))]
|
|
56
|
+
for root, _, fs in os.walk(os.path.join(REPO_ROOT, "docs")):
|
|
57
|
+
files += [os.path.join(root, f) for f in fs if f.endswith(".md")]
|
|
58
|
+
return files
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main() -> None:
|
|
62
|
+
check = "--check" in sys.argv[1:]
|
|
63
|
+
tracked = tracked_capsule_ids()
|
|
64
|
+
errors, ok = [], 0
|
|
65
|
+
for path in doc_files():
|
|
66
|
+
with open(path, encoding="utf-8") as f:
|
|
67
|
+
text = f.read()
|
|
68
|
+
refs = (set(PATH_RE.findall(text)) | set(TICK_RE.findall(text))) - IGNORE
|
|
69
|
+
rel = os.path.relpath(path, REPO_ROOT)
|
|
70
|
+
for rid in sorted(refs):
|
|
71
|
+
if rid not in tracked:
|
|
72
|
+
errors.append(f"{rel}: `{rid}` — referenced but NOT a git-tracked capsule "
|
|
73
|
+
"(fabricated, typo, or uncommitted)")
|
|
74
|
+
elif not sealed(rid):
|
|
75
|
+
errors.append(f"{rel}: `{rid}` — tracked but has no integrity.seal")
|
|
76
|
+
else:
|
|
77
|
+
ok += 1
|
|
78
|
+
if errors:
|
|
79
|
+
print("DOC CLAIM CHECK — unresolved capsule references:")
|
|
80
|
+
for e in errors:
|
|
81
|
+
print(" ✗ " + e)
|
|
82
|
+
print(f"{ok} reference(s) resolve to sealed, tracked capsules; {len(errors)} problem(s).")
|
|
83
|
+
if check and errors:
|
|
84
|
+
sys.exit(1)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from fastapi import FastAPI
|
|
2
|
+
|
|
3
|
+
from .routers import catalog, experiments, recommend, results
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def create_app() -> FastAPI:
|
|
7
|
+
app = FastAPI(
|
|
8
|
+
title="SQL Benchmarks API",
|
|
9
|
+
description=(
|
|
10
|
+
"Ground-truth SQL performance data for researchers and AI agents. "
|
|
11
|
+
"Query pre-computed benchmark results across PostgreSQL, DuckDB, and Actian Vector, "
|
|
12
|
+
"or submit new experiments to run against the lab.\n\n"
|
|
13
|
+
"See [AGENTS.md](https://github.com/rctruta/sql-benchmarks-dagster/blob/main/AGENTS.md) for the agentic protocol."
|
|
14
|
+
),
|
|
15
|
+
version="1.0.0",
|
|
16
|
+
docs_url="/docs",
|
|
17
|
+
openapi_url="/openapi.json",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
app.include_router(catalog.router)
|
|
21
|
+
app.include_router(results.router)
|
|
22
|
+
app.include_router(recommend.router)
|
|
23
|
+
app.include_router(experiments.router)
|
|
24
|
+
|
|
25
|
+
@app.get("/health", tags=["meta"])
|
|
26
|
+
def health():
|
|
27
|
+
"""Health check."""
|
|
28
|
+
return {"status": "ok"}
|
|
29
|
+
|
|
30
|
+
return app
|
|
File without changes
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Dict, List
|
|
3
|
+
|
|
4
|
+
from ...constants import KNOWN_ENGINES, SQL_DIR
|
|
5
|
+
from ..models.catalog import CatalogEnginesResponse, CatalogSuitesResponse, EngineInfo, SuiteDetail
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CatalogReader:
|
|
9
|
+
"""Reads SQL catalog from the filesystem."""
|
|
10
|
+
|
|
11
|
+
def _suite_names(self) -> List[str]:
|
|
12
|
+
if not os.path.isdir(SQL_DIR):
|
|
13
|
+
return []
|
|
14
|
+
return [
|
|
15
|
+
d for d in os.listdir(SQL_DIR)
|
|
16
|
+
if os.path.isdir(os.path.join(SQL_DIR, d))
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
def _suite_detail(self, suite_name: str) -> SuiteDetail:
|
|
20
|
+
suite_dir = os.path.join(SQL_DIR, suite_name)
|
|
21
|
+
sql_content: Dict[str, Dict[str, str]] = {}
|
|
22
|
+
engines_present = []
|
|
23
|
+
|
|
24
|
+
for engine in os.listdir(suite_dir):
|
|
25
|
+
engine_dir = os.path.join(suite_dir, engine)
|
|
26
|
+
if not os.path.isdir(engine_dir):
|
|
27
|
+
continue
|
|
28
|
+
sql_files = [f for f in os.listdir(engine_dir) if f.endswith(".sql")]
|
|
29
|
+
if not sql_files:
|
|
30
|
+
continue
|
|
31
|
+
engines_present.append(engine)
|
|
32
|
+
sql_content[engine] = {}
|
|
33
|
+
for fname in sql_files:
|
|
34
|
+
name = os.path.splitext(fname)[0]
|
|
35
|
+
with open(os.path.join(engine_dir, fname)) as f:
|
|
36
|
+
sql_content[engine][name] = f.read()
|
|
37
|
+
|
|
38
|
+
benchmark_names = sorted({
|
|
39
|
+
name
|
|
40
|
+
for engine_files in sql_content.values()
|
|
41
|
+
for name in engine_files
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
return SuiteDetail(
|
|
45
|
+
name=suite_name,
|
|
46
|
+
engines=sorted(engines_present),
|
|
47
|
+
benchmark_names=benchmark_names,
|
|
48
|
+
sql_content=sql_content,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def get_suites_response(self) -> CatalogSuitesResponse:
|
|
52
|
+
suites = [self._suite_detail(s) for s in self._suite_names()]
|
|
53
|
+
return CatalogSuitesResponse(suites=suites)
|
|
54
|
+
|
|
55
|
+
def get_engines_response(self) -> CatalogEnginesResponse:
|
|
56
|
+
suite_names = self._suite_names()
|
|
57
|
+
engine_suites: Dict[str, List[str]] = {e: [] for e in KNOWN_ENGINES}
|
|
58
|
+
|
|
59
|
+
for suite_name in suite_names:
|
|
60
|
+
suite_dir = os.path.join(SQL_DIR, suite_name)
|
|
61
|
+
for engine in KNOWN_ENGINES:
|
|
62
|
+
engine_dir = os.path.join(suite_dir, engine)
|
|
63
|
+
if os.path.isdir(engine_dir) and any(
|
|
64
|
+
f.endswith(".sql") for f in os.listdir(engine_dir)
|
|
65
|
+
):
|
|
66
|
+
engine_suites[engine].append(suite_name)
|
|
67
|
+
|
|
68
|
+
engines = [
|
|
69
|
+
EngineInfo(name=e, available_suites=sorted(suites))
|
|
70
|
+
for e, suites in engine_suites.items()
|
|
71
|
+
if suites
|
|
72
|
+
]
|
|
73
|
+
return CatalogEnginesResponse(engines=engines)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import glob
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
from ...constants import CONFIG_ARCHIVE_DIR, EXPERIMENTS_DIR, RESULTS_DIR
|
|
9
|
+
from ..models.results import (
|
|
10
|
+
ExperimentSummary,
|
|
11
|
+
Fragment,
|
|
12
|
+
FragmentMeta,
|
|
13
|
+
FragmentMetrics,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ResultReader:
|
|
18
|
+
"""Reads benchmark results from the filesystem. Stateless — reads on every call."""
|
|
19
|
+
|
|
20
|
+
def list_experiment_ids(self) -> List[str]:
|
|
21
|
+
if not os.path.isdir(RESULTS_DIR):
|
|
22
|
+
return []
|
|
23
|
+
return [
|
|
24
|
+
d for d in os.listdir(RESULTS_DIR)
|
|
25
|
+
if os.path.isdir(os.path.join(RESULTS_DIR, d))
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
def get_metadata(self, exp_id: str) -> Optional[Dict]:
|
|
29
|
+
path = os.path.join(RESULTS_DIR, exp_id, f"metadata_{exp_id}.json")
|
|
30
|
+
if not os.path.exists(path):
|
|
31
|
+
return None
|
|
32
|
+
with open(path) as f:
|
|
33
|
+
return json.load(f)
|
|
34
|
+
|
|
35
|
+
def get_config(self, exp_id: str) -> Optional[Dict]:
|
|
36
|
+
path = os.path.join(CONFIG_ARCHIVE_DIR, f"config_{exp_id}.yaml")
|
|
37
|
+
if not os.path.exists(path):
|
|
38
|
+
return None
|
|
39
|
+
with open(path) as f:
|
|
40
|
+
return yaml.safe_load(f)
|
|
41
|
+
|
|
42
|
+
def get_fragments(self, exp_id: str) -> List[Fragment]:
|
|
43
|
+
pattern = os.path.join(RESULTS_DIR, exp_id, "fragments", "*.json")
|
|
44
|
+
fragments = []
|
|
45
|
+
for fpath in glob.glob(pattern):
|
|
46
|
+
try:
|
|
47
|
+
with open(fpath) as f:
|
|
48
|
+
data = json.load(f)
|
|
49
|
+
if data.get("meta", {}).get("experiment_id") != exp_id:
|
|
50
|
+
continue
|
|
51
|
+
meta = data.get("meta", {})
|
|
52
|
+
# Extract partition from filename: asset__PARTITION.json
|
|
53
|
+
filename = os.path.splitext(os.path.basename(fpath))[0]
|
|
54
|
+
partition = filename.split("__")[-1] if "__" in filename else "default"
|
|
55
|
+
fragments.append(Fragment(
|
|
56
|
+
meta=FragmentMeta(
|
|
57
|
+
timestamp=meta.get("timestamp", ""),
|
|
58
|
+
experiment_id=meta.get("experiment_id", exp_id),
|
|
59
|
+
dagster_run_id=meta.get("dagster_run_id", ""),
|
|
60
|
+
engine=meta.get("engine", "unknown"),
|
|
61
|
+
asset=meta.get("asset", ""),
|
|
62
|
+
partition=partition,
|
|
63
|
+
),
|
|
64
|
+
metrics=FragmentMetrics(
|
|
65
|
+
duration_seconds=float(data.get("metrics", {}).get("duration_seconds", 0.0)),
|
|
66
|
+
replication_factor=int(data.get("metrics", {}).get("replication_factor", 1)),
|
|
67
|
+
durations_raw=data.get("metrics", {}).get("durations_raw"),
|
|
68
|
+
),
|
|
69
|
+
parameters=data.get("parameters", {}),
|
|
70
|
+
))
|
|
71
|
+
except Exception:
|
|
72
|
+
continue
|
|
73
|
+
return fragments
|
|
74
|
+
|
|
75
|
+
def has_csv(self, exp_id: str) -> bool:
|
|
76
|
+
return os.path.exists(os.path.join(RESULTS_DIR, exp_id, f"{exp_id}.csv"))
|
|
77
|
+
|
|
78
|
+
def has_dashboard(self, exp_id: str) -> bool:
|
|
79
|
+
return os.path.exists(os.path.join(RESULTS_DIR, exp_id, f"{exp_id}.html"))
|
|
80
|
+
|
|
81
|
+
def build_summary(self, exp_id: str) -> ExperimentSummary:
|
|
82
|
+
fragments = self.get_fragments(exp_id)
|
|
83
|
+
config = self.get_config(exp_id)
|
|
84
|
+
metadata = self.get_metadata(exp_id)
|
|
85
|
+
|
|
86
|
+
engines = list({f.meta.engine for f in fragments})
|
|
87
|
+
partitions = list({f.meta.partition for f in fragments})
|
|
88
|
+
suite = None
|
|
89
|
+
if config:
|
|
90
|
+
suite = config.get("execution", {}).get("test_suite")
|
|
91
|
+
|
|
92
|
+
return ExperimentSummary(
|
|
93
|
+
experiment_id=exp_id,
|
|
94
|
+
suite=suite,
|
|
95
|
+
engines=engines,
|
|
96
|
+
partition_count=len(partitions),
|
|
97
|
+
fragment_count=len(fragments),
|
|
98
|
+
has_csv=self.has_csv(exp_id),
|
|
99
|
+
has_dashboard=self.has_dashboard(exp_id),
|
|
100
|
+
created_at=metadata.get("timestamp") if metadata else None,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def filter_experiments(
|
|
104
|
+
self,
|
|
105
|
+
suite: Optional[str] = None,
|
|
106
|
+
engine: Optional[str] = None,
|
|
107
|
+
) -> List[ExperimentSummary]:
|
|
108
|
+
summaries = []
|
|
109
|
+
for exp_id in self.list_experiment_ids():
|
|
110
|
+
summary = self.build_summary(exp_id)
|
|
111
|
+
if suite and summary.suite != suite:
|
|
112
|
+
continue
|
|
113
|
+
if engine and engine not in summary.engines:
|
|
114
|
+
continue
|
|
115
|
+
summaries.append(summary)
|
|
116
|
+
return summaries
|
|
117
|
+
|
|
118
|
+
def is_queued(self, exp_id: str) -> bool:
|
|
119
|
+
return os.path.exists(os.path.join(EXPERIMENTS_DIR, "queue", f"{exp_id}.yaml"))
|
|
120
|
+
|
|
121
|
+
def is_complete(self, exp_id: str) -> bool:
|
|
122
|
+
return os.path.exists(os.path.join(CONFIG_ARCHIVE_DIR, f"config_{exp_id}.yaml"))
|
|
123
|
+
|
|
124
|
+
def results_exist(self, exp_id: str) -> bool:
|
|
125
|
+
return os.path.isdir(os.path.join(RESULTS_DIR, exp_id))
|
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from ..data.reader import ResultReader
|
|
4
|
+
from ..logic.ranker import score_engines
|
|
5
|
+
from ..models.results import CompareResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def compare_experiment(
|
|
9
|
+
exp_id: str,
|
|
10
|
+
reader: ResultReader,
|
|
11
|
+
partition: Optional[str] = None,
|
|
12
|
+
) -> CompareResult:
|
|
13
|
+
fragments = reader.get_fragments(exp_id)
|
|
14
|
+
config = reader.get_config(exp_id)
|
|
15
|
+
suite = config.get("execution", {}).get("test_suite") if config else None
|
|
16
|
+
|
|
17
|
+
rankings = score_engines(fragments, partition_filter=partition)
|
|
18
|
+
|
|
19
|
+
if not rankings:
|
|
20
|
+
return CompareResult(
|
|
21
|
+
experiment_id=exp_id,
|
|
22
|
+
suite=suite,
|
|
23
|
+
partition=partition,
|
|
24
|
+
rankings=[],
|
|
25
|
+
winner="unknown",
|
|
26
|
+
speedup_vs_slowest=1.0,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
winner = rankings[0]
|
|
30
|
+
slowest = rankings[-1]
|
|
31
|
+
speedup = (
|
|
32
|
+
slowest.mean_duration_seconds / winner.mean_duration_seconds
|
|
33
|
+
if winner.mean_duration_seconds > 0
|
|
34
|
+
else 1.0
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
return CompareResult(
|
|
38
|
+
experiment_id=exp_id,
|
|
39
|
+
suite=suite,
|
|
40
|
+
partition=partition,
|
|
41
|
+
rankings=rankings,
|
|
42
|
+
winner=winner.engine,
|
|
43
|
+
speedup_vs_slowest=round(speedup, 2),
|
|
44
|
+
)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import statistics
|
|
2
|
+
from typing import Dict, List, Optional
|
|
3
|
+
|
|
4
|
+
from ..data.reader import ResultReader
|
|
5
|
+
from ..models.recommend import RecommendResponse
|
|
6
|
+
from ..models.results import EngineRanking, Fragment
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def score_engines(
|
|
10
|
+
fragments: List[Fragment],
|
|
11
|
+
partition_filter: Optional[str] = None,
|
|
12
|
+
) -> List[EngineRanking]:
|
|
13
|
+
"""
|
|
14
|
+
Groups fragments by engine, computes mean/median/p95 duration.
|
|
15
|
+
Returns rankings sorted ascending (rank 1 = fastest).
|
|
16
|
+
"""
|
|
17
|
+
by_engine: Dict[str, List[float]] = {}
|
|
18
|
+
for f in fragments:
|
|
19
|
+
if partition_filter and partition_filter not in f.meta.partition:
|
|
20
|
+
continue
|
|
21
|
+
engine = f.meta.engine
|
|
22
|
+
by_engine.setdefault(engine, []).append(f.metrics.duration_seconds)
|
|
23
|
+
|
|
24
|
+
if not by_engine:
|
|
25
|
+
return []
|
|
26
|
+
|
|
27
|
+
stats = []
|
|
28
|
+
for engine, durations in by_engine.items():
|
|
29
|
+
sorted_d = sorted(durations)
|
|
30
|
+
n = len(sorted_d)
|
|
31
|
+
p95_idx = max(0, int(n * 0.95) - 1)
|
|
32
|
+
stats.append({
|
|
33
|
+
"engine": engine,
|
|
34
|
+
"mean": statistics.mean(sorted_d),
|
|
35
|
+
"median": statistics.median(sorted_d),
|
|
36
|
+
"p95": sorted_d[p95_idx],
|
|
37
|
+
"count": n,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
stats.sort(key=lambda x: x["mean"])
|
|
41
|
+
|
|
42
|
+
return [
|
|
43
|
+
EngineRanking(
|
|
44
|
+
engine=s["engine"],
|
|
45
|
+
mean_duration_seconds=round(s["mean"], 6),
|
|
46
|
+
median_duration_seconds=round(s["median"], 6),
|
|
47
|
+
p95_duration_seconds=round(s["p95"], 6),
|
|
48
|
+
sample_count=s["count"],
|
|
49
|
+
rank=i + 1,
|
|
50
|
+
)
|
|
51
|
+
for i, s in enumerate(stats)
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def recommend_engine(
|
|
56
|
+
reader: ResultReader,
|
|
57
|
+
suite: Optional[str],
|
|
58
|
+
scale: Optional[str],
|
|
59
|
+
) -> RecommendResponse:
|
|
60
|
+
summaries = reader.filter_experiments(suite=suite)
|
|
61
|
+
exp_ids = [s.experiment_id for s in summaries]
|
|
62
|
+
|
|
63
|
+
all_fragments: List[Fragment] = []
|
|
64
|
+
for exp_id in exp_ids:
|
|
65
|
+
all_fragments.extend(reader.get_fragments(exp_id))
|
|
66
|
+
|
|
67
|
+
if scale:
|
|
68
|
+
all_fragments = [f for f in all_fragments if scale in f.meta.partition]
|
|
69
|
+
|
|
70
|
+
rankings = score_engines(all_fragments)
|
|
71
|
+
caveats = []
|
|
72
|
+
known_engines = {"postgres", "duckdb", "actian"}
|
|
73
|
+
|
|
74
|
+
if not rankings:
|
|
75
|
+
engines_with_data = set()
|
|
76
|
+
else:
|
|
77
|
+
engines_with_data = {r.engine for r in rankings}
|
|
78
|
+
|
|
79
|
+
for e in known_engines:
|
|
80
|
+
if e not in engines_with_data:
|
|
81
|
+
caveats.append(f"No data for '{e}'" + (f" in suite '{suite}'" if suite else ""))
|
|
82
|
+
|
|
83
|
+
if not rankings:
|
|
84
|
+
return RecommendResponse(
|
|
85
|
+
recommended_engine="unknown",
|
|
86
|
+
confidence="low",
|
|
87
|
+
reasoning="No benchmark data found for the requested filters.",
|
|
88
|
+
supporting_experiments=exp_ids,
|
|
89
|
+
engine_scores={},
|
|
90
|
+
caveats=caveats,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
winner = rankings[0]
|
|
94
|
+
total_samples = sum(r.sample_count for r in rankings)
|
|
95
|
+
|
|
96
|
+
if len(rankings) > 1:
|
|
97
|
+
second = rankings[1]
|
|
98
|
+
speedup = second.mean_duration_seconds / winner.mean_duration_seconds
|
|
99
|
+
else:
|
|
100
|
+
speedup = 1.0
|
|
101
|
+
|
|
102
|
+
if winner.sample_count >= 10 and speedup >= 1.20:
|
|
103
|
+
confidence = "high"
|
|
104
|
+
elif winner.sample_count >= 3:
|
|
105
|
+
confidence = "medium"
|
|
106
|
+
else:
|
|
107
|
+
confidence = "low"
|
|
108
|
+
|
|
109
|
+
suite_label = f"suite '{suite}'" if suite else "available suites"
|
|
110
|
+
scale_label = f" at scale '{scale}'" if scale else ""
|
|
111
|
+
reasoning = (
|
|
112
|
+
f"Based on {total_samples} samples across {suite_label}{scale_label}, "
|
|
113
|
+
f"{winner.engine} is fastest with mean {winner.mean_duration_seconds:.4f}s"
|
|
114
|
+
)
|
|
115
|
+
if len(rankings) > 1:
|
|
116
|
+
reasoning += f" vs {rankings[1].engine} at {rankings[1].mean_duration_seconds:.4f}s ({speedup:.1f}x speedup)."
|
|
117
|
+
else:
|
|
118
|
+
reasoning += "."
|
|
119
|
+
|
|
120
|
+
return RecommendResponse(
|
|
121
|
+
recommended_engine=winner.engine,
|
|
122
|
+
confidence=confidence,
|
|
123
|
+
reasoning=reasoning,
|
|
124
|
+
supporting_experiments=exp_ids,
|
|
125
|
+
engine_scores={r.engine: r.mean_duration_seconds for r in rankings},
|
|
126
|
+
caveats=caveats,
|
|
127
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from typing import Dict, List
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class EngineInfo(BaseModel):
|
|
6
|
+
name: str
|
|
7
|
+
available_suites: List[str]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SuiteDetail(BaseModel):
|
|
11
|
+
name: str
|
|
12
|
+
engines: List[str]
|
|
13
|
+
benchmark_names: List[str]
|
|
14
|
+
sql_content: Dict[str, Dict[str, str]] # {engine: {benchmark_name: sql_text}}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CatalogEnginesResponse(BaseModel):
|
|
18
|
+
engines: List[EngineInfo]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CatalogSuitesResponse(BaseModel):
|
|
22
|
+
suites: List[SuiteDetail]
|