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,88 @@
1
+ import os
2
+ import yaml
3
+ from dagster import asset, AssetExecutionContext
4
+ from typing import List
5
+ from ..constants import DATA_DIR, EXPERIMENTS_DIR, ACTIVE_CONFIG_PATH
6
+ from ..partitions import partitions_def
7
+ from ..utils.common import load_context, get_engine_asset_prefix, get_scoped_asset_name
8
+
9
+ CTX = load_context()
10
+ ACTIVE_ENGINES = CTX.get('engines', [])
11
+ EXP_ID = CTX['meta'].get("experiment_id", "unknown")
12
+
13
+ def load_dataset_config():
14
+ """Reads the active experiment YAML to find the tables configured for the dataset."""
15
+ config_path = ACTIVE_CONFIG_PATH
16
+ if not os.path.exists(config_path):
17
+ return {}
18
+ with open(config_path, "r") as f:
19
+ conf = yaml.safe_load(f) or {}
20
+ return conf.get("dataset", {}).get("tables", {})
21
+
22
+ TABLES_CONFIG = load_dataset_config()
23
+
24
+ ingestion_assets: List[object] = []
25
+
26
+ def make_ingestion_asset(engine: str, table_name: str):
27
+ """
28
+ Creates a single ingestion asset for a given engine and table,
29
+ delegating execution to the engine's bulk_load method.
30
+ """
31
+ prefix = get_engine_asset_prefix(engine)
32
+ base_asset_name = f"{prefix}{table_name}_table"
33
+ scoped_name = get_scoped_asset_name(base_asset_name, EXP_ID)
34
+
35
+ tags = {}
36
+ tags["experiment_scope"] = "partitioned"
37
+
38
+ deps = [
39
+ get_scoped_asset_name(f"{table_name}_parquet", EXP_ID),
40
+ get_scoped_asset_name(f"{table_name}_quality", EXP_ID)
41
+ ]
42
+
43
+ @asset(
44
+ name=scoped_name,
45
+ group_name="ingestion",
46
+ partitions_def=partitions_def,
47
+ deps=deps,
48
+ required_resource_keys={engine},
49
+ op_tags=tags
50
+ )
51
+ def _ingest(context: AssetExecutionContext):
52
+ partition_key = context.partition_key
53
+
54
+ # 1. Dynamic Resource Retrieval (Polymorphic)
55
+ db = getattr(context.resources, engine)
56
+
57
+ # 2. FIND THE EXACT FILE
58
+ filename = f"{table_name}_{partition_key}.parquet"
59
+ parquet_path = os.path.join(DATA_DIR, "staging", filename)
60
+
61
+ if not os.path.exists(parquet_path):
62
+ raise FileNotFoundError(f"Parquet file not found: {parquet_path}")
63
+
64
+ # 3. DEFINE TARGET TABLE NAME
65
+ target_table_name = f"{table_name}_{partition_key}"
66
+
67
+ context.log.info(f"Ingesting {parquet_path} into {engine} table '{target_table_name}'...")
68
+
69
+ # 4. Use the polymorphic bulk_load method
70
+ db.bulk_load(
71
+ filepath=parquet_path,
72
+ table_name=target_table_name,
73
+ partition_key=partition_key,
74
+ table_def=TABLES_CONFIG.get(table_name),
75
+ )
76
+
77
+ return None
78
+
79
+ return _ingest
80
+
81
+
82
+ # --- Dynamic Asset Creation ---
83
+ # Loop 1: Iterate over the configured engines (e.g., duckdb, postgres)
84
+ for engine in ACTIVE_ENGINES:
85
+ # Loop 2: Iterate over the correct list of table names from your verified config logic
86
+ for table_name in TABLES_CONFIG.keys():
87
+ asset_obj = make_ingestion_asset(engine, table_name)
88
+ ingestion_assets.append(asset_obj)
@@ -0,0 +1,63 @@
1
+ import shutil
2
+ import os
3
+ from dagster import asset
4
+ from ..constants import DATA_DIR
5
+
6
+ # We define this asset to depend on the LAST step of the pipeline.
7
+ # Usually, this is the Reporting/Dashboard asset, or the Benchmarks themselves.
8
+ # Since reporting aggregates everything, depending on 'performance_dashboard'
9
+ # ensures the run is fully complete before we wipe the source data.
10
+ # Note: You need to import the upstream asset key or use string reference.
11
+
12
+ @asset(
13
+ group_name="maintenance",
14
+ deps=["performance_dashboard"], # Runs ONLY after the dashboard is updated
15
+ description="Deletes the heavy staging Parquet files after a successful run."
16
+ )
17
+ def cleanup_staging_data(context):
18
+ """
19
+ Removes the generated parquet files to prevent disk overflow.
20
+ This runs per-partition if your job is partitioned, or once per run.
21
+ """
22
+ # 1. Identify the Target Directory
23
+ # We need to be careful not to delete the 'results' folder, only 'staging'.
24
+ # Assuming DATA_DIR points to 'experiments/data' or similar.
25
+
26
+ # If we are in a partitioned run, we might want to delete only that partition's data.
27
+ if context.has_partition_key:
28
+ partition = context.partition_key
29
+ # Assuming data_factory creates a folder named after the partition or table
30
+ # This requires knowing exactly how data_factory names its output.
31
+ # Based on previous code: output_dir = os.path.join(DATA_DIR, partition_key)
32
+
33
+ target_dir = os.path.join(DATA_DIR, partition)
34
+
35
+ if os.path.exists(target_dir):
36
+ context.log.info(f"Partitioned Run: Deleting staging data at {target_dir}")
37
+ shutil.rmtree(target_dir)
38
+ else:
39
+ context.log.warning(f"Target dir {target_dir} not found. Already cleaned?")
40
+
41
+ else:
42
+ # Non-partitioned run (Standard cleanup)
43
+ # We assume standard structure creates subfolders in DATA_DIR
44
+ # DANGER: Be specific. Don't just wipe DATA_DIR if it contains results.
45
+
46
+ # Let's list the subdirectories in DATA_DIR and assume they are data folders
47
+ # generated by the factory.
48
+ if os.path.exists(DATA_DIR):
49
+ context.log.info(f"Full Run: Cleaning up staging directory {DATA_DIR}")
50
+
51
+ # Option A: Delete everything inside DATA_DIR
52
+ # shutil.rmtree(DATA_DIR)
53
+ # os.makedirs(DATA_DIR) # Recreate empty folder
54
+
55
+ # Option B (Safer): Delete specific known subfolders if possible.
56
+ # For now, since this is staging, Option A is acceptable IF results are stored elsewhere.
57
+ # (Your results are likely in 'experiments/results', right?)
58
+
59
+ for item in os.listdir(DATA_DIR):
60
+ item_path = os.path.join(DATA_DIR, item)
61
+ if os.path.isdir(item_path):
62
+ shutil.rmtree(item_path)
63
+ context.log.info(f"Deleted {item}")
@@ -0,0 +1,385 @@
1
+ import os
2
+ import polars as pl
3
+ import pandas as pd
4
+ import plotly.express as px
5
+ from dagster import asset, AssetExecutionContext, MetadataValue, MaterializeResult, DagsterEventType, EventRecordsFilter
6
+
7
+ from ..constants import RESULTS_DIR, REPORTS_DIR
8
+ from ..utils.common import load_context
9
+ from .benchmark_factory import benchmark_assets
10
+ import glob
11
+ import json
12
+
13
+ all_benchmark_keys = [k.key for k in benchmark_assets]
14
+
15
+ # ==========================================
16
+ # 1. PURE LOGIC (Testable)
17
+ # ==========================================
18
+ # Use environment-aware path resolution
19
+ def parse_fragments_to_records(experiment_id):
20
+ """
21
+ Scans the results directory for the given experiment_id
22
+ and parses all fragment JSONs into a flat list of records.
23
+ """
24
+ # Look into the isolated experiment folder as per specification
25
+ fragments_pattern = os.path.join(RESULTS_DIR, experiment_id, "fragments", "*.json")
26
+ fragment_files = glob.glob(fragments_pattern)
27
+
28
+ records = []
29
+
30
+ for fpath in fragment_files:
31
+ try:
32
+ with open(fpath, "r") as f:
33
+ data = json.load(f)
34
+
35
+ meta = data.get("meta", {})
36
+ metrics = data.get("metrics", {})
37
+ params = data.get("parameters", {})
38
+
39
+ if meta.get("experiment_id") != experiment_id:
40
+ continue
41
+
42
+ asset_name = meta.get("asset", "unknown_asset")
43
+
44
+ # Extract Partition from Filename: asset_name__PARTITION.json
45
+ filename = os.path.basename(fpath)
46
+ partition_name = "default"
47
+
48
+ # Logic: Split by asset_name + "__"
49
+ # It's safer to use the known separator "__"
50
+ if "__" in filename:
51
+ # Remove extension
52
+ name_no_ext = os.path.splitext(filename)[0]
53
+ parts = name_no_ext.split("__")
54
+ if len(parts) >= 2:
55
+ partition_name = parts[-1]
56
+
57
+ # DNF fragments have duration_seconds=None; preserve as None in the CSV
58
+ # so downstream analysis can distinguish "timed out" from "0ms".
59
+ raw_duration = metrics.get("duration_seconds")
60
+ duration_val = float(raw_duration) if raw_duration is not None else None
61
+
62
+ # Per-replication spread (older fragments lack durations_raw)
63
+ raw_list = metrics.get("durations_raw") or []
64
+ duration_min = min(raw_list) if raw_list else duration_val
65
+ duration_max = max(raw_list) if raw_list else duration_val
66
+
67
+ row = {
68
+ "Asset": asset_name,
69
+ "Partition": partition_name,
70
+ "Duration": duration_val,
71
+ "Duration_Min": duration_min,
72
+ "Duration_Max": duration_max,
73
+ "DNF": bool(metrics.get("dnf", False)),
74
+ "Engine": str(meta.get("engine", "Unknown")),
75
+ # None (blank in CSV) when the experiment has no 'rows'
76
+ # dimension (e.g. TPC-H uses scale_factor) — 0 must never
77
+ # be the disguise for "absent".
78
+ "Rows": int(params["rows"]) if "rows" in params else None,
79
+ "Selectivity": float(params.get("derived_selectivity", 0.0) or 0.0)
80
+ }
81
+ # Note: the old 'System' column (engine + disk_type label) was
82
+ # removed as a duplicate of 'Engine'; disk_type survives as its
83
+ # own column via the parameter merge below.
84
+
85
+ # Merge ALL parameters into the row (Dynamic Columns)
86
+ # This ensures 'null_probability' etc appear in CSV.
87
+ # Case-insensitive guard: 'rows' must not duplicate 'Rows'.
88
+ existing = {c.lower() for c in row}
89
+ for k, v in params.items():
90
+ if k.lower() not in existing:
91
+ row[k] = v
92
+
93
+ records.append(row)
94
+
95
+ except Exception as e:
96
+ print(f"Skipping malformed fragment {fpath}: {e}")
97
+ continue
98
+
99
+ return records
100
+
101
+ # ==========================================
102
+ # 2. THE ASSET (Orchestration)
103
+ # ==========================================
104
+ @asset(
105
+ deps=all_benchmark_keys,
106
+ group_name="reporting",
107
+ description="Generates a Multi-View Dashboard."
108
+ )
109
+ def performance_dashboard(context: AssetExecutionContext):
110
+ instance = context.instance
111
+ try:
112
+ CTX = load_context()
113
+ EXP_ID = CTX['meta'].get("experiment_id", "unknown")
114
+ except Exception:
115
+ context.log.warning("Could not read active context.")
116
+ return
117
+
118
+ # 1. FETCH & PARSE (New Logic)
119
+ records = parse_fragments_to_records(EXP_ID)
120
+
121
+ if not records:
122
+ context.log.info(f"No records found for experiment: {EXP_ID}")
123
+ return
124
+
125
+ # 3. PREPARE DATA
126
+ df = pl.DataFrame(records)
127
+
128
+ # Deduplicate: Keep last run for same (Asset, Partition, Engine)
129
+ # Rows might technically differ but Partition should cover it.
130
+ unique_keys = ["Asset", "Partition", "Engine", "Rows"]
131
+ # Filter keys that actually exist (to be safe if Partition is missing in legacy)
132
+ unique_keys = [k for k in unique_keys if k in df.columns]
133
+
134
+ df = df.unique(subset=unique_keys, keep="last").sort("Rows")
135
+ pldf = df.to_pandas()
136
+
137
+ # 3.5 SANITIZE DATA (The "Gas in the Trunk")
138
+ # ------------------------------------------
139
+ # Drop columns that are effectively "Null" or "Default" across the entire dataset
140
+ # This prevents "Selectivity=0.0" from appearing in titles when it wasn't used.
141
+
142
+ cols_to_drop = []
143
+ for col in pldf.columns:
144
+ if col in ["Asset", "Engine", "Partition", "Duration"]:
145
+ continue
146
+
147
+ # Check if column is all null or all default (0.0 for float, 0 for int)
148
+ is_all_null = pldf[col].isnull().all()
149
+ is_all_zero = (pldf[col] == 0).all() and pd.api.types.is_numeric_dtype(pldf[col])
150
+ is_single_value = pldf[col].nunique() <= 1
151
+
152
+ # Heuristic: If it's 0 everywhere, it's likely a parser default, UNLESS it's "Rows" (which shouldn't be 0)
153
+ # or if the user explicitly set 0. But for Selectivity/Skew, 0 is often "Not Used".
154
+ if col != "Rows" and (is_all_null or is_all_zero):
155
+ cols_to_drop.append(col)
156
+ # Also drop constant columns from the "Matrix Params" consideration (but keep in DF for reference if needed?)
157
+ # Actually, simpler to just drop them from the DF used for plotting considerations.
158
+
159
+ if cols_to_drop:
160
+ context.log.info(f"Dropping irrelevant/default columns: {cols_to_drop}")
161
+ pldf = pldf.drop(columns=cols_to_drop)
162
+
163
+ # Fill NaNs in remaining parameters to prevent fragmentation
164
+ # e.g. if 'null_probability' is present in some rows but NaN in others, fill with 0 via Polars upstream or Pandas here.
165
+ # For numeric params, 0 is usually safe default for "parameter not present".
166
+ numeric_cols = pldf.select_dtypes(include=['number']).columns
167
+ pldf[numeric_cols] = pldf[numeric_cols].fillna(0)
168
+
169
+ # 4. RENDER (Visualization - Matrix Explorer)
170
+ figures_html = []
171
+
172
+ # Identify Matrix Parameters (numeric columns that aren't fixed labels)
173
+ excluded_cols = {"Asset", "Partition", "Duration", "Duration_Min", "Duration_Max", "Engine"}
174
+ matrix_params = [c for c in pldf.columns if c not in excluded_cols and pd.api.types.is_numeric_dtype(pldf[c])]
175
+
176
+ context.log.info(f"Matrix Params Detected: {matrix_params}")
177
+ if "null_probability" in pldf.columns:
178
+ context.log.info(f"Unique Null Probs: {pldf['null_probability'].unique().tolist()}")
179
+
180
+ # -------------------------------------------------------------------------
181
+ # 1. Comparison by Engine (The Basics)
182
+ # -------------------------------------------------------------------------
183
+ try:
184
+ # Side-by-Side Engine Comparison (Fixed Rows Scaling)
185
+ # Check if "Rows" is a parameter, as it's the standard X-axis
186
+ if "Rows" in matrix_params:
187
+ fig_compare = px.line(
188
+ pldf,
189
+ x="Rows",
190
+ y="Duration",
191
+ color="Engine",
192
+ line_dash="Asset",
193
+ symbol="Engine",
194
+ log_x=True,
195
+ log_y=True,
196
+ markers=True,
197
+ title="<b>Global Comparison</b>: Engine Scaling (Rows vs Duration)"
198
+ )
199
+ figures_html.append(fig_compare.to_html(full_html=False, include_plotlyjs='cdn'))
200
+ except Exception as e:
201
+ context.log.warning(f"Global Plot Error: {e}")
202
+
203
+ # -------------------------------------------------------------------------
204
+ # 2. DYNAMIC DISCOVERY ENGINE (The "Smart Logic")
205
+ # -------------------------------------------------------------------------
206
+ unique_systems = sorted(pldf["Engine"].unique())
207
+
208
+ for system in unique_systems:
209
+ system_df = pldf[pldf["Engine"] == system].copy()
210
+
211
+ # A. Discover Roles
212
+ # -----------------
213
+ # Candidates for X-Axis: Numeric params with > 1 unique value
214
+ x_candidates = []
215
+ for p in matrix_params:
216
+ if system_df[p].nunique() > 1:
217
+ x_candidates.append(p)
218
+
219
+ # Heuristic: Prefer "Rows" if available, else param with max cardinality
220
+ if "Rows" in x_candidates:
221
+ x_axis = "Rows"
222
+ elif x_candidates:
223
+ # Pick max cardinality
224
+ x_axis = max(x_candidates, key=lambda c: system_df[c].nunique())
225
+ else:
226
+ # Fallback if nothing varies (single point)
227
+ x_axis = matrix_params[0] if matrix_params else "Asset"
228
+
229
+ # B. Classify Remaining Parameters (Series vs Slices)
230
+ # ---------------------------------------------------
231
+ other_params = [p for p in matrix_params if p != x_axis]
232
+
233
+ # Slices: High Cardinality OR Orthogonal Dimensions we want to isolate
234
+ # Series: Low Cardinality dimensions we want to compare on one chart
235
+
236
+ slice_params = []
237
+ series_params = ["Asset"] # Always compare Assets (Logic) on same chart
238
+
239
+ for p in other_params:
240
+ # Exclude parameters that are just case variants of X-Axis (Rows vs rows)
241
+ if p.lower() == x_axis.lower():
242
+ continue
243
+
244
+ unique_count = system_df[p].nunique()
245
+ # If it has only 1 value, it doesn't matter (it's fixed context),
246
+ # but we can treat it as a Slice to be safe/explicit in title.
247
+ # If it has many values (>5), slice it to avoid clutter.
248
+ # If it has few values (2-5), add to SERIES (lines).
249
+ if unique_count > 5:
250
+ slice_params.append(p)
251
+ elif unique_count > 1:
252
+ series_params.append(p)
253
+ else:
254
+ # It's a fixed value, add to slice context implicitly
255
+ slice_params.append(p)
256
+
257
+ # C. Generate Plots via Slicing
258
+ # -----------------------------
259
+ # Group by all slice parameters to create distinct scenarios
260
+ if slice_params:
261
+ # Sort to ensure consistent grouping order
262
+ slice_params = sorted(slice_params)
263
+ grouped = system_df.groupby(slice_params)
264
+ else:
265
+ grouped = [((), system_df)]
266
+
267
+ for group_keys, slice_df in grouped:
268
+ if not isinstance(group_keys, tuple):
269
+ group_keys = (group_keys,)
270
+
271
+ # 1. Build Title
272
+ title_parts = [f"<b>{system}</b>: Varying <b>{x_axis}</b>"]
273
+ if slice_params:
274
+ ctx_str = ", ".join([f"{k}={v}" for k, v in zip(slice_params, group_keys)])
275
+ title_parts.append(f"<span style='font-size:12px'>({ctx_str})</span>")
276
+ title = "<br>".join(title_parts)
277
+
278
+ # 2. Construct Series Column (Legend)
279
+ # Combine all series params into one string column "Series"
280
+ # e.g. "2VL - 10% Nulls"
281
+ slice_df = slice_df.copy()
282
+
283
+ if len(series_params) > 1:
284
+ # We have multiple dimensions (Asset + NullProb + ...)
285
+ # Create a composite key
286
+ def make_series_label(row):
287
+ parts = []
288
+ for sp in series_params:
289
+ val = row[sp]
290
+ # Beautify: If val is float, format it?
291
+ # For now, simplistic str()
292
+ parts.append(str(val))
293
+ return " / ".join(parts)
294
+
295
+ slice_df["_Series_"] = slice_df.apply(make_series_label, axis=1)
296
+ color_col = "_Series_"
297
+ symbol_col = "Asset" # Keep using Asset for symbol if present
298
+ else:
299
+ # Simple case: Only Asset varies
300
+ slice_df["_Series_"] = slice_df[series_params[0]].astype(str)
301
+ color_col = "_Series_"
302
+ symbol_col = "_Series_"
303
+
304
+ try:
305
+ # 3. Plot
306
+ scenario_df = slice_df
307
+ scenario_df = scenario_df.sort_values(by=x_axis)
308
+
309
+ fig = px.line(
310
+ scenario_df,
311
+ x=x_axis,
312
+ y="Duration",
313
+ color=color_col,
314
+ symbol=symbol_col if symbol_col in slice_df.columns else None,
315
+ markers=True,
316
+ log_y=True,
317
+ log_x=True if "Rows" in x_axis or "null" in x_axis.lower() else False,
318
+ title=title,
319
+ labels={x_axis: x_axis, "Duration": "Duration (s)", "_Series_": " / ".join(series_params)}
320
+ )
321
+ figures_html.append(fig.to_html(full_html=False, include_plotlyjs=False))
322
+ except Exception as e:
323
+ context.log.warning(f"Dynamic Plot Error ({system}): {e}")
324
+
325
+ # -------------------------------------------------------------------------
326
+ # 3. 3D Landscape (The "Bonus")
327
+ # -------------------------------------------------------------------------
328
+ if "null_probability" in matrix_params and "Rows" in matrix_params:
329
+ try:
330
+ fig_3d = px.scatter_3d(
331
+ pldf,
332
+ x="Rows",
333
+ y="null_probability",
334
+ z="Duration",
335
+ color="Engine",
336
+ symbol="Asset",
337
+ log_x=True,
338
+ log_z=True,
339
+ title="<b>3D Landscape</b>: Rows x Nulls x Duration",
340
+ height=800
341
+ )
342
+ figures_html.append(fig_3d.to_html(full_html=False, include_plotlyjs=False))
343
+ except Exception as e:
344
+ context.log.warning(f"3D Plot Error: {e}")
345
+
346
+ # -------------------------------------------------------------------------
347
+ # 4. SAVE (Side Effect)
348
+ # -------------------------------------------------------------------------
349
+ exp_dir = os.path.join(RESULTS_DIR, EXP_ID)
350
+ os.makedirs(exp_dir, exist_ok=True)
351
+
352
+ html_path = os.path.join(exp_dir, f"{EXP_ID}.html")
353
+ csv_path = os.path.join(exp_dir, f"{EXP_ID}.csv")
354
+
355
+ with open(html_path, "w") as f:
356
+ f.write(f"<h1>Benchmark: {EXP_ID}</h1>")
357
+ f.write(f"<p>Generated at: {pd.Timestamp.now()}</p><hr>")
358
+ f.write("<br><hr><br>".join(figures_html))
359
+
360
+ # -------------------------------------------------------------------------
361
+ # 5. WRITE CSV
362
+ # -------------------------------------------------------------------------
363
+ df.write_csv(csv_path)
364
+
365
+ # -------------------------------------------------------------------------
366
+ # 6. SCALING LAW (auto-analysis, sealed with the capsule)
367
+ # Fit each engine's power-law exponent from the raw fragments. Written
368
+ # before the coordinator seals the capsule, so the exponents are
369
+ # tamper-protected alongside the timings. Absent for experiments without
370
+ # a row-scaled matrix (e.g. TPC-H scale_factor) — that's expected.
371
+ # -------------------------------------------------------------------------
372
+ from ..utils.scaling import analyze_capsule
373
+ scaling = analyze_capsule(RESULTS_DIR, EXP_ID)
374
+ if scaling:
375
+ with open(os.path.join(exp_dir, "scaling.json"), "w") as f:
376
+ json.dump(scaling, f, indent=2)
377
+ alphas = {e: r["alpha"] for e, r in scaling.items()}
378
+ context.log.info(f"Scaling law (alpha per engine): {alphas}")
379
+
380
+ return MaterializeResult(
381
+ metadata={
382
+ "dashboard_path": MetadataValue.path(html_path),
383
+ "results_csv_path": MetadataValue.path(csv_path),
384
+ "experiment_id": EXP_ID
385
+ })
@@ -0,0 +1,76 @@
1
+ import os
2
+ import json
3
+ from dagster import asset, AssetExecutionContext, MaterializeResult, MetadataValue
4
+ from ..constants import RESULTS_DIR, VIOLATIONS_DIR
5
+ from ..partitions import partitions_def
6
+ from ..utils.common import load_context, get_scoped_asset_name
7
+ from ..utils.semantic_auditor import SemanticAuditor
8
+
9
+ CTX = load_context()
10
+ EXP_ID = CTX['meta'].get("experiment_id", "unknown")
11
+
12
+ def make_semantic_gate_asset(benchmark_asset_key):
13
+ """
14
+ Creates a SemanticGate asset that validates the results of a benchmark.
15
+ """
16
+ # The gate name should be semantic_gate_<benchmark_name>
17
+ benchmark_name = benchmark_asset_key.path[-1]
18
+ base_gate_name = f"semantic_gate_{benchmark_name.replace(f'e_{EXP_ID}__', '')}"
19
+ asset_name = get_scoped_asset_name(base_gate_name, EXP_ID)
20
+
21
+ @asset(
22
+ name=asset_name,
23
+ group_name="semantic_firewall",
24
+ partitions_def=partitions_def,
25
+ deps=[benchmark_asset_key],
26
+ description=f"Audits benchmark results for {benchmark_name} for hallucinations."
27
+ )
28
+ def _gate(context: AssetExecutionContext):
29
+ partition_key = context.partition_key
30
+
31
+ # 1. Locate the fragment produced by the dependency
32
+ # matches: benchmark_factory.py:30
33
+ from ..utils.common import load_context
34
+ ctx = load_context()
35
+ fragment_path = os.path.join(
36
+ RESULTS_DIR,
37
+ "fragments",
38
+ f"{benchmark_name}__{partition_key}.json"
39
+ )
40
+
41
+ if not os.path.exists(fragment_path):
42
+ context.log.warning(f"Fragment not found for audit: {fragment_path}")
43
+ return MaterializeResult(metadata={"status": "missing_fragment"})
44
+
45
+ # 2. Audit
46
+ with open(fragment_path, "r") as f:
47
+ data = json.load(f)
48
+
49
+ auditor = SemanticAuditor()
50
+ report = auditor.audit_fragment(data)
51
+
52
+ # 3. Handle Violations
53
+ if not report["success"]:
54
+ context.log.error(f"SEMANTIC HALLUCINATION DETECTED: {report['violations']}")
55
+ # In a real system, we might raise an error or mark it with a high-severity tag.
56
+ # For this PoC, we add metadata.
57
+ metadata = {
58
+ "audit_status": "FAILED",
59
+ "violations": MetadataValue.json(report["violations"]),
60
+ "fragment_path": MetadataValue.path(fragment_path)
61
+ }
62
+ else:
63
+ metadata = {
64
+ "audit_status": "PASSED",
65
+ "fragment_path": MetadataValue.path(fragment_path)
66
+ }
67
+
68
+ return MaterializeResult(metadata=metadata)
69
+
70
+ return _gate
71
+
72
+ def get_semantic_gate_assets(benchmark_assets):
73
+ """
74
+ Generates gate assets based on a list of benchmark assets.
75
+ """
76
+ return [make_semantic_gate_asset(b.key) for b in benchmark_assets]
sql_benchmarks/cli.py ADDED
@@ -0,0 +1,69 @@
1
+ """Command-line entry point for running experiments.
2
+
3
+ Exposed as the `sqlbench` console script (see pyproject.toml). The top-level
4
+ run_experiment.py is a thin shim over this so `./run.sh` keeps working.
5
+ """
6
+ import os
7
+ import argparse
8
+ import sys
9
+
10
+ from .coordinator import ExperimentCoordinator
11
+ from .constants import EXPERIMENTS_DIR, EXPERIMENT_EXTENSIONS, PROCESSED_SUFFIX
12
+
13
+
14
+ def _is_safe_path(path: str, base: str) -> bool:
15
+ """Returns True only if path is within base (prevents directory traversal)."""
16
+ return os.path.realpath(path).startswith(os.path.realpath(base) + os.sep)
17
+
18
+
19
+ def resolve_targets(target_input: str) -> list:
20
+ """Resolves input string to a list of YAML files."""
21
+ # Absolute or relative path to a specific file
22
+ if os.path.isfile(target_input):
23
+ abs_path = os.path.abspath(target_input)
24
+ # Must live within the experiments directory
25
+ if not _is_safe_path(abs_path, EXPERIMENTS_DIR):
26
+ print(f"[ERROR] Path '{target_input}' is outside the experiments directory.")
27
+ return []
28
+ return [abs_path]
29
+
30
+ # Symbolic name (e.g. "queue", "archive") — resolved relative to EXPERIMENTS_DIR only
31
+ symbolic_path = os.path.join(EXPERIMENTS_DIR, target_input)
32
+ if os.path.isdir(symbolic_path) and _is_safe_path(symbolic_path, EXPERIMENTS_DIR):
33
+ return sorted([
34
+ os.path.join(symbolic_path, f)
35
+ for f in os.listdir(symbolic_path)
36
+ if f.endswith(EXPERIMENT_EXTENSIONS) and not f.endswith(PROCESSED_SUFFIX)
37
+ ])
38
+
39
+ return []
40
+
41
+
42
+ def main():
43
+ parser = argparse.ArgumentParser(description="SQL Benchmarks Experiment Runner (Thin Wrapper)")
44
+ parser.add_argument("target", help="YAML file or directory (e.g., 'queue')")
45
+ parser.add_argument("--auto", action="store_true", help="Automated mode")
46
+ args = parser.parse_args()
47
+
48
+ targets = resolve_targets(args.target)
49
+ if not targets:
50
+ print(f"[ERROR] No valid targets found for '{args.target}'")
51
+ sys.exit(1)
52
+
53
+ print(f"[RUNNER] Processing {len(targets)} targets...")
54
+
55
+ overall_success = True
56
+ for target in targets:
57
+ print(f"\n>>> COORDINATING: {os.path.basename(target)}")
58
+ coordinator = ExperimentCoordinator(target, headless=args.auto)
59
+ if not coordinator.run():
60
+ overall_success = False
61
+ if not args.auto:
62
+ break # Stop on first failure in interactive mode
63
+
64
+ if not overall_success:
65
+ sys.exit(1)
66
+
67
+
68
+ if __name__ == "__main__":
69
+ main()