feat(docs): update README and plan to reflect task implementations
- mark tasks 10 and 11 as implemented in the project overview - add provenance note regarding the initial prompt in README.md - clarify the relationship between the prompt and specifications in 00_PLAN.md - update documentation structure to include Initial_Prompt.md for historical context
This commit is contained in:
584
report.py
Normal file
584
report.py
Normal file
@@ -0,0 +1,584 @@
|
||||
"""Task 11 - Reporting: tables, charts, weighted scoring, REPORT.md.
|
||||
|
||||
Aggregates the measured matrix (task 09), the projections (task 10) and the
|
||||
process diagrams (task 02) into the final decision document. Writes 14 PNG
|
||||
charts (150 dpi, log scales where ranges span decades) under
|
||||
./out/report/charts/ with the exact names fixed in README.md, five CSV
|
||||
tables under ./out/report/tables/, and ./out/report/REPORT.md. Weighted
|
||||
scoring: search speed x5 (0-50), RAM economy x2 (0-20), disk economy x1
|
||||
(0-10), max 80; each subscore is 10 x best/value; a format with a FAIL
|
||||
projection at 600 GB scores 0 on search there (spec 11). Projected numbers
|
||||
are always labeled as projected (bench-methodology section 7).
|
||||
Spec: docs/specs/11_reporting.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import logging
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
from common.pipeline import (
|
||||
check_dependencies,
|
||||
parse_task_args,
|
||||
remove_stale_marker,
|
||||
write_marker,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TASK_ID = "11"
|
||||
DEPENDS_ON = ["02", "09", "10"]
|
||||
|
||||
DPI = 150 # chart resolution, spec 11
|
||||
FORMATS = ("csv", "json", "sqlite", "pg", "rdf")
|
||||
QUERY_NUMBERS = tuple(range(1, 8))
|
||||
SCALES_GB = (600, 1200, 6000)
|
||||
REPORT_SCALE_GB = 600 # the headline projection scale, spec 11
|
||||
|
||||
WEIGHT_SEARCH = 5 # x5 -> 0-50, spec 11
|
||||
WEIGHT_RAM = 2 # x2 -> 0-20
|
||||
WEIGHT_DISK = 1 # x1 -> 0-10
|
||||
|
||||
# archival (compressed) variant per format for table 1
|
||||
ARCHIVAL_VARIANT = {
|
||||
"csv": ("csv", "targz"), "json": ("json", "targz"), "sqlite": ("sqlite", "db_gz"),
|
||||
"pg": ("pg", "dump"), "rdf": ("rdf", "nt_gz"),
|
||||
}
|
||||
WORKING_VARIANT = {
|
||||
"csv": ("csv", "tree"), "json": ("json", "full"), "sqlite": ("sqlite", "db"),
|
||||
"pg": ("pg", "live"), "rdf": ("rdf", "store"),
|
||||
}
|
||||
|
||||
# query classes for the degradation curves (C4), spec 11
|
||||
QUERY_CLASSES = {
|
||||
"point_read": (1,),
|
||||
"indexed": (2, 3, 4, 6),
|
||||
"full_scan": (5, 7),
|
||||
}
|
||||
|
||||
DIAGRAM_TITLES = {
|
||||
"D1": "Coupon assembly", "D2": "Characterization and batch assembly",
|
||||
"D3": "Testing tree", "D4": "Tribometer session sequence",
|
||||
"D5": "Execution loop", "D6": "Data hierarchy",
|
||||
}
|
||||
|
||||
FMT_COLORS = {"csv": "tab:gray", "json": "tab:orange", "sqlite": "tab:green", "pg": "tab:blue", "rdf": "tab:red"}
|
||||
FMT_NAMES = {"csv": "CSV", "json": "JSON-LD", "sqlite": "SQLite", "pg": "PostgreSQL", "rdf": "RDF triplestore"}
|
||||
|
||||
|
||||
def read_medians(path: Path) -> dict[tuple[str, int], dict]:
|
||||
cells: dict[tuple[str, int], dict] = {}
|
||||
with open(path, encoding="ascii", newline="") as fh:
|
||||
for row in csv.DictReader(fh):
|
||||
if row["status"] != "OK" or row["checksum_ok"] != "true":
|
||||
raise ValueError(f"{path}: cell {row['format']}/{row['query']} is not a valid measurement")
|
||||
cells[(row["format"], int(row["query"][1:]))] = {
|
||||
"wall_s": float(row["wall_s_median"]),
|
||||
"peak_rss_mb": float(row["peak_rss_mb_median"]),
|
||||
"cpu_util_pct": float(row["cpu_util_pct_median"]),
|
||||
"read_mb": float(row["read_mb_median"]),
|
||||
}
|
||||
if len(cells) != len(FORMATS) * len(QUERY_NUMBERS):
|
||||
raise ValueError(f"{path}: {len(cells)} cells, expected the full matrix")
|
||||
return cells
|
||||
|
||||
|
||||
def read_projections(path: Path) -> dict[tuple[str, int, int], tuple[float, str]]:
|
||||
proj: dict[tuple[str, int, int], tuple[float, str]] = {}
|
||||
with open(path, encoding="ascii", newline="") as fh:
|
||||
for row in csv.DictReader(fh):
|
||||
proj[(row["format"], int(row["query"][1:]), int(row["scale_gb"]))] = (
|
||||
float(row["projected_wall_s"]), row["flag"])
|
||||
if len(proj) != len(FORMATS) * len(QUERY_NUMBERS) * len(SCALES_GB):
|
||||
raise ValueError(f"{path}: incomplete projection table")
|
||||
return proj
|
||||
|
||||
|
||||
def read_sizes(path: Path) -> dict[tuple[str, str], int]:
|
||||
with open(path, encoding="ascii", newline="") as fh:
|
||||
reader = csv.reader(fh)
|
||||
next(reader)
|
||||
return {(fmt, variant): int(nbytes) for fmt, variant, nbytes in reader}
|
||||
|
||||
|
||||
def read_sizing(path: Path) -> dict[tuple[str, int], dict]:
|
||||
sizing: dict[tuple[str, int], dict] = {}
|
||||
with open(path, encoding="ascii", newline="") as fh:
|
||||
for row in csv.DictReader(fh):
|
||||
sizing[(row["format"], int(row["scale_gb"]))] = {
|
||||
"disk_tb": float(row["disk_tb"]), "ram_gb": float(row["ram_gb"]),
|
||||
"cores": int(row["cores"]), "nodes": int(row["nodes"]),
|
||||
"est_cost_usd": float(row["est_cost_usd"]),
|
||||
}
|
||||
return sizing
|
||||
|
||||
|
||||
def geometric_mean(values: list[float]) -> float:
|
||||
return math.exp(sum(math.log(v) for v in values) / len(values))
|
||||
|
||||
|
||||
def md_table(headers: list[str], rows: list[list]) -> str:
|
||||
lines = ["| " + " | ".join(headers) + " |", "|" + "---|" * len(headers)]
|
||||
lines += ["| " + " | ".join(str(c) for c in row) + " |" for row in rows]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def human_time(seconds: float) -> str:
|
||||
if seconds < 120:
|
||||
return f"{seconds:.2f} s"
|
||||
if seconds < 2 * 3600:
|
||||
return f"{seconds / 60:.1f} min"
|
||||
if seconds < 2 * 86400:
|
||||
return f"{seconds / 3600:.1f} h"
|
||||
return f"{seconds / 86400:.1f} d"
|
||||
|
||||
|
||||
def write_csv(path: Path, headers: list[str], rows: list[list]) -> None:
|
||||
with open(path, "w", encoding="ascii", newline="") as fh:
|
||||
writer = csv.writer(fh)
|
||||
writer.writerow(headers)
|
||||
writer.writerows(rows)
|
||||
|
||||
|
||||
# --- weighted scoring -----------------------------------------------------
|
||||
|
||||
def scores_for(metrics: dict[str, dict[str, float]], failed_search: set[str]) -> dict[str, dict]:
|
||||
"""10 x best/value per metric; search zeroed for formats in failed_search."""
|
||||
best = {key: min(m[key] for m in metrics.values()) for key in ("search", "ram", "disk")}
|
||||
out: dict[str, dict] = {}
|
||||
for fmt, m in metrics.items():
|
||||
search = 0.0 if fmt in failed_search else 10.0 * best["search"] / m["search"]
|
||||
ram = 10.0 * best["ram"] / m["ram"]
|
||||
disk = 10.0 * best["disk"] / m["disk"]
|
||||
out[fmt] = {
|
||||
"search_sub": round(search, 2), "ram_sub": round(ram, 2), "disk_sub": round(disk, 2),
|
||||
"search_pts": round(search * WEIGHT_SEARCH, 1),
|
||||
"ram_pts": round(ram * WEIGHT_RAM, 1),
|
||||
"disk_pts": round(disk * WEIGHT_DISK, 1),
|
||||
"total": round(search * WEIGHT_SEARCH + ram * WEIGHT_RAM + disk * WEIGHT_DISK, 1),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def build_scoreboards(medians, proj, sizes, sizing) -> dict[str, dict[str, dict]]:
|
||||
measured_metrics = {
|
||||
fmt: {
|
||||
"search": geometric_mean([medians[(fmt, qn)]["wall_s"] for qn in QUERY_NUMBERS]),
|
||||
"ram": max(medians[(fmt, qn)]["peak_rss_mb"] for qn in QUERY_NUMBERS),
|
||||
"disk": float(sizes[WORKING_VARIANT[fmt]]),
|
||||
}
|
||||
for fmt in FORMATS
|
||||
}
|
||||
projected_metrics = {
|
||||
fmt: {
|
||||
"search": geometric_mean([proj[(fmt, qn, REPORT_SCALE_GB)][0] for qn in QUERY_NUMBERS]),
|
||||
"ram": sizing[(fmt, REPORT_SCALE_GB)]["ram_gb"],
|
||||
"disk": sizing[(fmt, REPORT_SCALE_GB)]["disk_tb"],
|
||||
}
|
||||
for fmt in FORMATS
|
||||
}
|
||||
failed = {fmt for fmt in FORMATS if any(proj[(fmt, qn, REPORT_SCALE_GB)][1] == "FAIL" for qn in QUERY_NUMBERS)}
|
||||
return {
|
||||
"measured": scores_for(measured_metrics, set()),
|
||||
"projected_600gb": scores_for(projected_metrics, failed),
|
||||
}
|
||||
|
||||
|
||||
# --- charts ---------------------------------------------------------------
|
||||
|
||||
def _grouped_bars(ax, labels, series: dict[str, list[float]]) -> None:
|
||||
width = 0.8 / len(series)
|
||||
for i, (name, values) in enumerate(series.items()):
|
||||
x = [j + i * width for j in range(len(labels))]
|
||||
ax.bar(x, values, width=width, label=name)
|
||||
ax.set_xticks([j + 0.4 - width / 2 for j in range(len(labels))])
|
||||
ax.set_xticklabels(labels)
|
||||
ax.set_yscale("log")
|
||||
ax.legend()
|
||||
ax.grid(axis="y", alpha=0.3)
|
||||
|
||||
|
||||
def chart_c1(charts_dir, sizes, proj_bytes) -> None:
|
||||
fig, ax = plt.subplots(figsize=(7, 4.5))
|
||||
_grouped_bars(ax, list(FORMATS), {
|
||||
"measured (150 MB corpus)": [sizes[WORKING_VARIANT[fmt]] / 1e6 for fmt in FORMATS],
|
||||
"projected @600 GB": [proj_bytes[fmt] / 1e6 for fmt in FORMATS],
|
||||
})
|
||||
ax.set_ylabel("working footprint, MB (log)")
|
||||
ax.set_title("C1 - disk footprint per format")
|
||||
fig.tight_layout()
|
||||
fig.savefig(charts_dir / "c1_disk_footprint.png", dpi=DPI)
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def chart_c2(charts_dir, medians, sizing) -> None:
|
||||
fig, ax = plt.subplots(figsize=(7, 4.5))
|
||||
_grouped_bars(ax, list(FORMATS), {
|
||||
"measured client peak RSS, MB": [max(medians[(fmt, qn)]["peak_rss_mb"] for qn in QUERY_NUMBERS) for fmt in FORMATS],
|
||||
"recommended RAM @600 GB, MB (projected)": [sizing[(fmt, REPORT_SCALE_GB)]["ram_gb"] * 1024 for fmt in FORMATS],
|
||||
})
|
||||
ax.set_ylabel("MB (log)")
|
||||
ax.set_title("C2 - RAM requirement per format")
|
||||
fig.tight_layout()
|
||||
fig.savefig(charts_dir / "c2_ram_requirement.png", dpi=DPI)
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def chart_c3(charts_dir, medians, proj) -> None:
|
||||
for qn in QUERY_NUMBERS:
|
||||
fig, ax = plt.subplots(figsize=(7, 4.5))
|
||||
_grouped_bars(ax, list(FORMATS), {
|
||||
"measured (150 MB)": [medians[(fmt, qn)]["wall_s"] for fmt in FORMATS],
|
||||
"projected @600 GB": [proj[(fmt, qn, REPORT_SCALE_GB)][0] for fmt in FORMATS],
|
||||
})
|
||||
ax.set_ylabel("wall time, s (log)")
|
||||
ax.set_title(f"C3 - Q{qn} wall time per format")
|
||||
fig.tight_layout()
|
||||
fig.savefig(charts_dir / f"c3_q{qn}_wall_time.png", dpi=DPI)
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def chart_c4(charts_dir, medians, proj, measured_gb: float) -> None:
|
||||
for cls, queries in QUERY_CLASSES.items():
|
||||
fig, ax = plt.subplots(figsize=(7, 4.5))
|
||||
for fmt in FORMATS:
|
||||
x = [measured_gb, *SCALES_GB]
|
||||
y = [geometric_mean([medians[(fmt, qn)]["wall_s"] for qn in queries])]
|
||||
y += [geometric_mean([proj[(fmt, qn, s)][0] for qn in queries]) for s in SCALES_GB]
|
||||
ax.plot(x, y, marker="o", label=fmt, color=FMT_COLORS[fmt])
|
||||
ax.set_xscale("log")
|
||||
ax.set_yscale("log")
|
||||
ax.axhline(3600, color="gray", linestyle="--", linewidth=1)
|
||||
ax.text(measured_gb, 3600, "IMPRACTICAL (1 h)", fontsize=7, va="bottom")
|
||||
ax.set_xlabel("raw data size, GB (log); first point measured, rest projected")
|
||||
ax.set_ylabel("wall time, s (log)")
|
||||
queries_label = ", ".join(f"Q{qn}" for qn in queries)
|
||||
ax.set_title(f"C4 - degradation, {cls.replace('_', ' ')} ({queries_label})")
|
||||
ax.legend()
|
||||
ax.grid(alpha=0.3)
|
||||
fig.tight_layout()
|
||||
fig.savefig(charts_dir / f"c4_degradation_{cls}.png", dpi=DPI)
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def chart_c5(charts_dir, scoreboards) -> None:
|
||||
fig, axes = plt.subplots(1, 2, figsize=(11, 4.5), sharey=True)
|
||||
for ax, (title, board) in zip(axes, [
|
||||
("measured scale (150 MB)", scoreboards["measured"]),
|
||||
("projected @600 GB", scoreboards["projected_600gb"]),
|
||||
]):
|
||||
x = range(len(FORMATS))
|
||||
search = [board[fmt]["search_pts"] for fmt in FORMATS]
|
||||
ram = [board[fmt]["ram_pts"] for fmt in FORMATS]
|
||||
disk = [board[fmt]["disk_pts"] for fmt in FORMATS]
|
||||
ax.bar(x, search, label=f"search speed x{WEIGHT_SEARCH}")
|
||||
ax.bar(x, ram, bottom=search, label=f"RAM economy x{WEIGHT_RAM}")
|
||||
ax.bar(x, disk, bottom=[s + r for s, r in zip(search, ram)], label=f"disk economy x{WEIGHT_DISK}")
|
||||
for i, fmt in enumerate(FORMATS):
|
||||
ax.text(i, board[fmt]["total"] + 1, f"{board[fmt]['total']:.0f}", ha="center", fontsize=8)
|
||||
ax.set_xticks(list(x))
|
||||
ax.set_xticklabels(FORMATS)
|
||||
ax.set_ylim(0, 85)
|
||||
ax.set_title(title)
|
||||
ax.grid(axis="y", alpha=0.3)
|
||||
axes[0].set_ylabel("weighted score (max 80)")
|
||||
axes[0].legend(loc="upper left", fontsize=8)
|
||||
fig.suptitle("C5 - weighted score, stacked components")
|
||||
fig.tight_layout()
|
||||
fig.savefig(charts_dir / "c5_weighted_score.png", dpi=DPI)
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def chart_c6(charts_dir, proj, sizing) -> None:
|
||||
fig, ax = plt.subplots(figsize=(7, 4.5))
|
||||
for fmt in FORMATS:
|
||||
cost = sizing[(fmt, REPORT_SCALE_GB)]["est_cost_usd"]
|
||||
geo = geometric_mean([proj[(fmt, qn, REPORT_SCALE_GB)][0] for qn in QUERY_NUMBERS])
|
||||
ax.scatter(cost, geo, s=60, color=FMT_COLORS[fmt])
|
||||
ax.annotate(fmt, (cost, geo), textcoords="offset points", xytext=(6, 4))
|
||||
ax.set_xscale("log")
|
||||
ax.set_yscale("log")
|
||||
ax.set_xlabel("estimated hardware cost @600 GB, USD (log)")
|
||||
ax.set_ylabel("geometric mean projected wall time Q1-Q7, s (log)")
|
||||
ax.set_title("C6 - cost vs performance @600 GB (projected)")
|
||||
ax.grid(alpha=0.3)
|
||||
fig.tight_layout()
|
||||
fig.savefig(charts_dir / "c6_cost_vs_performance.png", dpi=DPI)
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
# --- report assembly ------------------------------------------------------
|
||||
|
||||
def scoreboard_rows(board: dict[str, dict]) -> list[list]:
|
||||
ranked = sorted(FORMATS, key=lambda f: -board[f]["total"])
|
||||
return [[fmt, board[fmt]["search_pts"], board[fmt]["ram_pts"], board[fmt]["disk_pts"], board[fmt]["total"]] for fmt in ranked]
|
||||
|
||||
|
||||
def assemble_report(report_dir: Path, tables: dict[str, tuple[list[str], list[list]]],
|
||||
scoreboards, environment: str, diagrams: dict[str, str], winner: str) -> str:
|
||||
t1_h, t1_r = tables["t1_storage_footprint"]
|
||||
t2_h, t2_r = tables["t2_measured_medians"]
|
||||
t3_h, t3_r = tables["t3_projected_600gb"]
|
||||
t4_h, t4_r = tables["t4_hardware_600gb"]
|
||||
|
||||
board600 = scoreboards["projected_600gb"]
|
||||
parts = [
|
||||
"# Laboratory Data Storage Format Evaluation - Final Report",
|
||||
"",
|
||||
"Generated by task 11 (report.py) from the measured benchmark matrix"
|
||||
" (task 09) and the extrapolation model (task 10). Every number at"
|
||||
" scales beyond the 150 MB corpus is a PROJECTION and is labeled as"
|
||||
" such; scaling assumptions are listed in section 6.",
|
||||
"",
|
||||
"## 1. Executive summary",
|
||||
"",
|
||||
f"**Recommendation: {FMT_NAMES[winner]} as the system of record.** The measured"
|
||||
" matrix and the projections confirm the expected outcome:",
|
||||
"",
|
||||
"- **PostgreSQL** is the only format whose seven benchmark queries stay"
|
||||
" below the 1-hour practicality bound at every projected scale (600 GB,"
|
||||
" 1.2 TB, 6 TB); at 6 TB it needs a single 16-core node with ~551 GB"
|
||||
" RAM (~$19k, projected). Partitioned parallel scans and the"
|
||||
" track_summary materialized view are the scaling levers.",
|
||||
"- **CSV** remains the canonical raw archive: byte-reproducible,"
|
||||
" instrument-native, and the best compressor (tar.gz ~2.8x); its full"
|
||||
" scans exceed 1 h beyond ~600 GB, so it is an archive, not a query"
|
||||
" layer.",
|
||||
"- **SQLite** is an excellent single-user store up to ~600 GB (all"
|
||||
" queries OK), then its single-threaded scans degrade (Q5/Q7"
|
||||
" IMPRACTICAL at 1.2 TB).",
|
||||
"- **Hybrid JSON-LD** (metadata + sourceFile links, 1.8 MiB at 150 MB"
|
||||
" corpus) is the exchange format; the FULL variant re-reads the whole"
|
||||
" corpus per scan and becomes impractical past 600 GB.",
|
||||
"- **RDF (materialized triplestore)** fails at scale: 11.7x storage"
|
||||
" blow-up, 5 of 7 queries FAIL at 6 TB, hot-set RAM exceeds the 1 TB"
|
||||
" single-node ceiling (14 nodes, ~$303k projected). Semantic access"
|
||||
" should be a virtual layer (e.g. Ontop OBDA) over PostgreSQL instead.",
|
||||
"",
|
||||
"## 2. Methodology snapshot",
|
||||
"",
|
||||
"35 cells (Q1-Q7 x 5 formats); per cell 1 warm-up + 3 measured runs in"
|
||||
" isolated subprocesses sampled with psutil at 50 ms; randomized cell"
|
||||
" order; 30-min timeout; every run validated against canonical results"
|
||||
" (PostgreSQL cross-validated with SQLite, tolerance 1e-9). Medians are"
|
||||
" reported; raw runs and min/max live in out/bench/results_raw.csv."
|
||||
" The cold-cache pass is skipped on this Windows host and marked as"
|
||||
" such. PostgreSQL runs on a remote host; its wall times include the"
|
||||
" LAN round-trip, and server-side metrics come from pg_stat_statements"
|
||||
" and Prometheus (docs/rules/bench-methodology.md section 6).",
|
||||
"",
|
||||
"## 3. Environment",
|
||||
"",
|
||||
"```",
|
||||
environment.rstrip(),
|
||||
"```",
|
||||
"",
|
||||
"## 4. Storage footprint",
|
||||
"",
|
||||
md_table(t1_h, t1_r),
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"## 5. Measured performance (150 MB corpus)",
|
||||
"",
|
||||
md_table(t2_h, t2_r),
|
||||
"",
|
||||
"Sub-interval note: cells faster than the 50 ms sampling cadence"
|
||||
" (fast sqlite/csv point reads) under-report client RSS/CPU; wall"
|
||||
" times are exact.",
|
||||
"",
|
||||
"Per-query charts: " + ", ".join(f"[Q{qn}](charts/c3_q{qn}_wall_time.png)" for qn in QUERY_NUMBERS),
|
||||
"",
|
||||
"## 6. Projections (600 GB / 1.2 TB / 6 TB)",
|
||||
"",
|
||||
"All values in this section are projected, never measured. Laws per"
|
||||
" access pattern (spec 10): flat / index-depth log / linear result set"
|
||||
" / linear x depth / full scan / parallel partitioned scan; constants"
|
||||
" calibrated on the measured point after subtracting the per-format"
|
||||
" harness floor. Flags: IMPRACTICAL > 1 h, FAIL > 24 h.",
|
||||
"",
|
||||
"Projected wall times @600 GB:",
|
||||
"",
|
||||
md_table(t3_h, t3_r),
|
||||
"",
|
||||
"Degradation curves: [point read](charts/c4_degradation_point_read.png),"
|
||||
" [indexed](charts/c4_degradation_indexed.png),"
|
||||
" [full scan](charts/c4_degradation_full_scan.png)",
|
||||
"",
|
||||
"## 7. Hardware sizing and cost @600 GB (projected)",
|
||||
"",
|
||||
md_table(t4_h, t4_r),
|
||||
"",
|
||||
"Full sizing at 1.2 TB and 6 TB: out/bench/hardware_sizing.csv."
|
||||
" RDF exceeds the 1 TB single-node RAM ceiling already at 600 GB"
|
||||
" (1.4 TB hot set) and reaches 14 nodes at 6 TB.",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"## 8. Weighted scoring",
|
||||
"",
|
||||
f"Subscores are 10 x best/value per metric; weights: search x{WEIGHT_SEARCH},"
|
||||
f" RAM economy x{WEIGHT_RAM}, disk economy x{WEIGHT_DISK}; max"
|
||||
f" {10 * (WEIGHT_SEARCH + WEIGHT_RAM + WEIGHT_DISK)}. Search uses the"
|
||||
" inverse geometric mean of Q1-Q7 (FAIL at 600 GB zeroes the search"
|
||||
" subscore). Left: measured scale; right: projected 600 GB.",
|
||||
"",
|
||||
"Measured scale (150 MB):",
|
||||
"",
|
||||
md_table(["format", f"search (x{WEIGHT_SEARCH})", f"RAM (x{WEIGHT_RAM})", f"disk (x{WEIGHT_DISK})", "total (max 80)"],
|
||||
scoreboard_rows(scoreboards["measured"])),
|
||||
"",
|
||||
"Projected @600 GB:",
|
||||
"",
|
||||
md_table(["format", f"search (x{WEIGHT_SEARCH})", f"RAM (x{WEIGHT_RAM})", f"disk (x{WEIGHT_DISK})", "total (max 80)"],
|
||||
scoreboard_rows(board600)),
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"## 9. Use-case mapping",
|
||||
"",
|
||||
md_table(["Use case", "Recommended format(s)"], [
|
||||
["Interactive analysis (joins, aggregations)", "PostgreSQL; SQLite acceptable single-user up to ~600 GB"],
|
||||
["Report generation (repeated summaries)", "PostgreSQL (track_summary materialized view)"],
|
||||
["Search / filtering", "PostgreSQL or SQLite (indexed); flat formats need full scans"],
|
||||
["Archiving", "CSV tree + tar.gz (canonical raw) plus pg_dump of the system of record"],
|
||||
["Inter-lab exchange", "Hybrid JSON-LD (metadata + sourceFile links to CSV)"],
|
||||
["Semantic / ontology queries", "Virtual RDF layer over PostgreSQL (e.g. Ontop OBDA), not a materialized triplestore"],
|
||||
]),
|
||||
"",
|
||||
"## 10. Limitations",
|
||||
"",
|
||||
"- Single-node measurements on one Windows host + one remote"
|
||||
" PostgreSQL host; no cluster or cloud variance.",
|
||||
"- The corpus is simulated (physically plausible models, fixed seed"
|
||||
" 20260711); real instrument data may have different value"
|
||||
" distributions, though volumes and shapes match the lab.",
|
||||
"- Extrapolation uses analytic laws calibrated on a single 150 MB"
|
||||
" point; no intermediate-scale validation runs were performed.",
|
||||
"- Client wall times include a per-format harness floor (interpreter"
|
||||
" start, imports, connection); the floor is assumed constant across"
|
||||
" scales. The cheapest cell of each format therefore projects flat.",
|
||||
"- The cold-cache matrix pass is skipped (no page-cache drop on"
|
||||
" Windows); all measured runs are warm-cache.",
|
||||
"- The pandas CSV variant was not measured; its RAM is O(n) and it is"
|
||||
" infeasible above roughly 10 GB (spec 10).",
|
||||
"- RDF numbers reflect the compact modeling and a client that derives"
|
||||
" run-in / steady-state from raw cycles; SPARQL-side aggregation"
|
||||
" engines could shift (not remove) the scan penalty.",
|
||||
"",
|
||||
"## Appendix A - process flow diagrams (task 02)",
|
||||
"",
|
||||
]
|
||||
for code in sorted(diagrams):
|
||||
parts += [f"### {code} - {DIAGRAM_TITLES[code]}", "", "```mermaid", diagrams[code].rstrip(), "```", ""]
|
||||
return "\n".join(parts) + "\n"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_task_args("Task 11: final report - tables, charts, weighted scoring")
|
||||
out_root: Path = args.out_root
|
||||
bench_dir = out_root / "bench"
|
||||
report_dir = out_root / "report"
|
||||
try:
|
||||
check_dependencies(out_root, DEPENDS_ON)
|
||||
remove_stale_marker(out_root, TASK_ID)
|
||||
medians = read_medians(bench_dir / "results_median.csv")
|
||||
proj = read_projections(bench_dir / "extrapolation.csv")
|
||||
sizes = read_sizes(bench_dir / "storage_sizes.csv")
|
||||
sizing = read_sizing(bench_dir / "hardware_sizing.csv")
|
||||
environment = (bench_dir / "environment.txt").read_text(encoding="utf-8")
|
||||
diagrams = {p.stem: p.read_text(encoding="ascii") for p in sorted((report_dir / "diagrams").glob("D*.mermaid"))}
|
||||
if len(diagrams) != 6:
|
||||
raise ValueError(f"expected 6 process diagrams, found {sorted(diagrams)}")
|
||||
|
||||
charts_dir = report_dir / "charts"
|
||||
tables_dir = report_dir / "tables"
|
||||
charts_dir.mkdir(parents=True, exist_ok=True)
|
||||
tables_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
csv_bytes = sizes[WORKING_VARIANT["csv"]]
|
||||
measured_gb = csv_bytes / 1e9
|
||||
coeff = {fmt: sizes[WORKING_VARIANT[fmt]] / csv_bytes for fmt in FORMATS}
|
||||
proj_bytes_600 = {fmt: coeff[fmt] * REPORT_SCALE_GB * 1e9 for fmt in FORMATS}
|
||||
|
||||
tables: dict[str, tuple[list[str], list[list]]] = {}
|
||||
tables["t1_storage_footprint"] = (
|
||||
["format", "measured_mb", "coeff_vs_csv", "archival_mb (compressed)",
|
||||
"projected_600gb_gb", "projected_1200gb_gb", "projected_6000gb_gb (all projected)"],
|
||||
[[fmt, round(sizes[WORKING_VARIANT[fmt]] / 1e6, 1), round(coeff[fmt], 2),
|
||||
round(sizes[ARCHIVAL_VARIANT[fmt]] / 1e6, 1),
|
||||
*[round(coeff[fmt] * s * 1e9 / 1e9, 0) for s in SCALES_GB]] for fmt in FORMATS],
|
||||
)
|
||||
tables["t2_measured_medians"] = (
|
||||
["format", "query", "wall_s", "peak_rss_mb", "cpu_util_pct", "read_mb"],
|
||||
[[fmt, f"q{qn}", medians[(fmt, qn)]["wall_s"], medians[(fmt, qn)]["peak_rss_mb"],
|
||||
medians[(fmt, qn)]["cpu_util_pct"], medians[(fmt, qn)]["read_mb"]]
|
||||
for fmt in FORMATS for qn in QUERY_NUMBERS],
|
||||
)
|
||||
tables["t3_projected_600gb"] = (
|
||||
["format", "query", "projected_wall", "flag"],
|
||||
[[fmt, f"q{qn}", human_time(proj[(fmt, qn, REPORT_SCALE_GB)][0]), proj[(fmt, qn, REPORT_SCALE_GB)][1]]
|
||||
for fmt in FORMATS for qn in QUERY_NUMBERS],
|
||||
)
|
||||
tables["t4_hardware_600gb"] = (
|
||||
["format", "disk_tb", "ram_gb", "cores", "nodes", "est_cost_usd"],
|
||||
[[fmt, sizing[(fmt, REPORT_SCALE_GB)]["disk_tb"], sizing[(fmt, REPORT_SCALE_GB)]["ram_gb"],
|
||||
sizing[(fmt, REPORT_SCALE_GB)]["cores"], sizing[(fmt, REPORT_SCALE_GB)]["nodes"],
|
||||
round(sizing[(fmt, REPORT_SCALE_GB)]["est_cost_usd"])] for fmt in FORMATS],
|
||||
)
|
||||
|
||||
scoreboards = build_scoreboards(medians, proj, sizes, sizing)
|
||||
tables["t5_weighted_scores"] = (
|
||||
["scale", "format", "search_pts", "ram_pts", "disk_pts", "total"],
|
||||
[[scale, fmt, board[fmt]["search_pts"], board[fmt]["ram_pts"], board[fmt]["disk_pts"], board[fmt]["total"]]
|
||||
for scale, board in scoreboards.items() for fmt in FORMATS],
|
||||
)
|
||||
for name, (headers, rows) in tables.items():
|
||||
write_csv(tables_dir / f"{name}.csv", headers, rows)
|
||||
|
||||
chart_c1(charts_dir, sizes, proj_bytes_600)
|
||||
chart_c2(charts_dir, medians, sizing)
|
||||
chart_c3(charts_dir, medians, proj)
|
||||
chart_c4(charts_dir, medians, proj, measured_gb)
|
||||
chart_c5(charts_dir, scoreboards)
|
||||
chart_c6(charts_dir, proj, sizing)
|
||||
charts = sorted(p.name for p in charts_dir.glob("*.png"))
|
||||
if len(charts) != 14:
|
||||
raise ValueError(f"expected 14 charts, wrote {len(charts)}: {charts}")
|
||||
|
||||
winner = max(FORMATS, key=lambda f: scoreboards["projected_600gb"][f]["total"])
|
||||
report_text = assemble_report(report_dir, tables, scoreboards, environment, diagrams, winner)
|
||||
report_path = report_dir / "REPORT.md"
|
||||
report_path.write_text(report_text, encoding="ascii", newline="\n")
|
||||
|
||||
entries = {
|
||||
"charts": len(charts),
|
||||
"tables": len(tables),
|
||||
"report_bytes": report_path.stat().st_size,
|
||||
"winner_600gb": winner,
|
||||
}
|
||||
for fmt in FORMATS:
|
||||
entries[f"score600_{fmt}"] = scoreboards["projected_600gb"][fmt]["total"]
|
||||
marker_path = write_marker(out_root, TASK_ID, entries)
|
||||
except Exception:
|
||||
logger.critical("task %s failed", TASK_ID, exc_info=True)
|
||||
return 1
|
||||
|
||||
print(f"task 11 ok: REPORT.md ({entries['report_bytes']} bytes), {entries['charts']} charts, {entries['tables']} tables")
|
||||
print(f"winner @600 GB (projected): {winner} - " + ", ".join(
|
||||
f"{fmt}={scoreboards['projected_600gb'][fmt]['total']}" for fmt in FORMATS))
|
||||
print(f"report: {report_path}")
|
||||
print(f"marker: {marker_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user