docs(benchmarks): reproducible v1.0.0 -> v1.1.0 correctness A/B suite

- benchmarks/correctness_suite.py: 21 deterministic headless (ezdxf) checks
  across core ops, dimensions, arrays, geometry, query/parity, render, the
  quality gate, and engineering.
- benchmarks/compare_versions.py: one-command A/B runner — checks out an older
  ref into a throwaway worktree and runs the same suite against both, each check
  in its own subprocess so a crash is a miss, not a run-killer.
- benchmarks/README.md: methodology, results table, honesty caveats.
- README: Benchmarks section now reports the measured result —
  v1.0.0 8/21 (38.1%) -> v1.1.0 21/21 (100%); 13 fixed, 0 regressions.
This commit is contained in:
Umutcan Edizsalan
2026-06-19 12:31:03 +03:00
parent b797d4c9f1
commit 5583730a79
4 changed files with 441 additions and 1 deletions
+27 -1
View File
@@ -299,7 +299,33 @@ If you find a security issue, please open a private contact rather than a public
## Benchmarks
A reproducible benchmark suite is in active development. Targets:
### Correctness — v1.1.0 vs v1.0.0
A reproducible A/B suite ([`benchmarks/`](benchmarks/)) runs the **same** 21
deterministic, headless (ezdxf) checks against the public v1.0.0 release
(`origin/main`) and v1.1.0 — each check in its own subprocess, so a hard crash is
recorded as a miss instead of taking down the run.
| Version | Checks passing | Pass rate |
|---------|----------------|-----------|
| **v1.0.0** (public release) | 8 / 21 | **38.1 %** |
| **v1.1.0** (this release) | 21 / 21 | **100 %** |
**13 defects fixed · 0 regressions · 2.6× higher correctness pass-rate (+61.9 pts).**
Fixes span dimensions (aligned/angular no longer raise), full-circle arrays, deterministic
geometry (`point_intersection`/`point_tangent`), ARC selection, property parity, MTEXT
rotation, the headless screenshot crash, the now-live `dim_overlap` critique, ISO-13567
layer routing, and gear-outline geometry. Six core operations pass on **both** versions, so
the suite is not a cherry-picked failure list — see [`benchmarks/README.md`](benchmarks/README.md)
for the full table, methodology, and honesty caveats.
```bash
python benchmarks/compare_versions.py # reproduce: current tree vs origin/main
```
### Performance (throughput) — coming
A scale/throughput suite is in active development. Targets:
- **Drawing scale** — 1k / 10k / 100k entity workloads
- **Workload classes** — bulk creation, mass-modify, region select, render-and-screenshot, end-to-end design pass
+69
View File
@@ -0,0 +1,69 @@
# Benchmarks
## Correctness A/B suite
`correctness_suite.py` is a set of deterministic, headless (ezdxf-backend) checks
that exercise real drawing operations. `compare_versions.py` runs the **same**
suite against an older git ref and the current checkout, each check in its own
subprocess so a hard crash (e.g. a matplotlib-in-thread `SIGSEGV`) is recorded as
a miss rather than taking down the run.
Reproduce:
```bash
python benchmarks/compare_versions.py # current tree vs origin/main
python benchmarks/compare_versions.py v1.0.0 # vs a tag/ref
python benchmarks/compare_versions.py --json results.json
```
### Result — v1.1.0 vs v1.0.0 (`origin/main`, commit 15fa2bc)
21 checks, ezdxf backend, CPython 3.14, one subprocess per check.
| Version | Checks passing | Pass rate |
|---------|----------------|-----------|
| **v1.0.0** (public release) | 8 / 21 | **38.1 %** |
| **v1.1.0** (this release) | 21 / 21 | **100 %** |
**13 defects fixed, 0 regressions** — a 2.6× higher correctness pass-rate (+61.9 pts).
Fixed (failed on v1.0.0, pass on v1.1.0):
| Check | Category | What it proves |
|-------|----------|----------------|
| `dim_aligned_no_error` | Dimensions | aligned dim no longer raises `TypeError` |
| `dim_angular_no_error` | Dimensions | angular dim no longer raises `TypeError` |
| `array_polar_360_distinct` | Modify | full-circle array no longer duplicates the original |
| `point_intersection_line_line` | Geometry | deterministic line/line intersection exists & is correct |
| `point_tangent_external` | Geometry | external-point tangent is perpendicular & on-circle |
| `arc_has_length` | Query | ARC carries a `length` property |
| `arc_select_by_length` | Query | `entity_select_smart` length_range selects arcs |
| `ezdxf_bounding_box` | Query | `bounding_box` populated (COM/ezdxf parity) |
| `mtext_rotation_roundtrip` | Entities | MTEXT honors caller rotation |
| `screenshot_png` | Render | headless render returns a valid PNG (no GUI-thread crash) |
| `dim_overlap_critique_fires` | Quality gate | `dim_overlap` critique is live (was a no-op) |
| `iso13567_dim_layer` | Quality gate | `dimension_auto` lands dims on the active set's layer |
| `gear_no_self_overlap` | Engineering | gear outline never dips inside the root circle (z ≥ 42) |
Passing on **both** versions (6 core ops + 2 others) keep the suite honest — it is
not a cherry-picked list of failures; the baseline genuinely does basic CAD work.
### Caveats / honesty notes
- The baseline is `origin/main` (the public v1.0.0). All four audited fix sprints
land between it and v1.1.0 — see `docs/analysis/`.
- `screenshot_png` on the baseline is host-dependent: it is recorded as a miss
here because the default matplotlib backend is a GUI backend and the render runs
off the main thread. On a host where matplotlib defaults to `Agg`, the baseline
may not crash. v1.1.0 forces `Agg` and is deterministic.
- `center_linetype_applied` passes on both because it only checks the linetype
**attribute** string (set on both); the v1.0.0 "renders as Continuous because the
linetype was never loaded" defect is not observable through `EntityInfo`.
- COM-backend behaviour is not covered here (no live AutoCAD); see the mocked-COM
suites in `tests/`.
## Performance (throughput) — coming
Scale / throughput numbers (1k–100k entities, render timings, model-by-model
tool-call success rate) are tracked in the main README and will be published here
with a hardware footprint.
+107
View File
@@ -0,0 +1,107 @@
"""A/B correctness benchmark — compare this working tree against an older ref.
Runs every check in ``correctness_suite.py`` against two checkouts. Each check
runs in its OWN subprocess, so a crash (e.g. a matplotlib-in-thread SIGSEGV) is
recorded as a miss instead of taking down the whole run.
python benchmarks/compare_versions.py # vs origin/main
python benchmarks/compare_versions.py v1.0.0 # vs a tag/ref
python benchmarks/compare_versions.py --json out.json # also dump JSON
The older ref is checked out into a throwaway git worktree, benchmarked, and
removed. The "new" side is always the current repository checkout.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE)
SUITE = os.path.join(HERE, "correctness_suite.py")
PY = sys.executable
def _run(cmd, cwd, repo_on_path):
env = {**os.environ, "PYTHONPATH": repo_on_path}
return subprocess.run(cmd, cwd=cwd, env=env, capture_output=True,
text=True, timeout=120)
def list_checks():
out = _run([PY, SUITE, "--list"], REPO, REPO)
checks = []
for line in out.stdout.splitlines():
if "\t" in line:
name, cat = line.split("\t", 1)
checks.append((name.strip(), cat.strip()))
return checks
def run_one(repo, suite, name):
try:
r = subprocess.run([PY, suite, name], cwd=repo,
env={**os.environ, "PYTHONPATH": repo},
capture_output=True, text=True, timeout=120)
except subprocess.TimeoutExpired:
return "timeout"
last = (r.stdout.strip().splitlines() or [""])[-1].strip()
if r.returncode == 0 and last in ("PASS", "FAIL"):
return "pass" if last == "PASS" else "fail"
if r.returncode in (-11, 139):
return "crash"
return "miss"
def main():
args = [a for a in sys.argv[1:] if not a.startswith("--")]
ref = args[0] if args else "origin/main"
json_out = None
if "--json" in sys.argv:
json_out = sys.argv[sys.argv.index("--json") + 1]
checks = list_checks()
wt = tempfile.mkdtemp(prefix="acadmcp_bench_")
subprocess.run(["git", "-C", REPO, "worktree", "add", "--quiet", wt, ref], check=True)
# The OLD worktree runs the CURRENT suite file (kept outside both trees on
# PYTHONPATH would be cleaner, but the suite only imports `backends.*`, so we
# pass an absolute suite path and point cwd/PYTHONPATH at the old repo).
try:
rows = []
for name, cat in checks:
o = run_one(wt, SUITE, name)
n = run_one(REPO, SUITE, name)
rows.append({"check": name, "category": cat, "old": o, "new": n})
finally:
subprocess.run(["git", "-C", REPO, "worktree", "remove", "--force", wt])
total = len(rows)
op = sum(r["old"] == "pass" for r in rows)
npass = sum(r["new"] == "pass" for r in rows)
summary = {
"baseline_ref": ref, "total": total,
"old_pass": op, "new_pass": npass,
"old_pct": round(100 * op / total, 1), "new_pct": round(100 * npass / total, 1),
"fixed": sum(r["old"] != "pass" and r["new"] == "pass" for r in rows),
"regressed": sum(r["old"] == "pass" and r["new"] != "pass" for r in rows),
}
print(f"{'CHECK':<32} {'CATEGORY':<13} {'baseline':<8} {'current':<8}")
print("-" * 66)
for r in rows:
mark = " ->FIXED" if (r["old"] != "pass" and r["new"] == "pass") else (
" !!REGRESS" if (r["old"] == "pass" and r["new"] != "pass") else "")
print(f"{r['check']:<32} {r['category']:<13} {r['old']:<8} {r['new']:<8}{mark}")
print("-" * 66)
print(f"baseline {ref}: {op}/{total} ({summary['old_pct']}%) "
f"current: {npass}/{total} ({summary['new_pct']}%) "
f"fixed: {summary['fixed']} regressed: {summary['regressed']}")
if json_out:
with open(json_out, "w") as fh:
json.dump({"summary": summary, "rows": rows}, fh, indent=2)
if __name__ == "__main__":
main()
+238
View File
@@ -0,0 +1,238 @@
"""Version-agnostic correctness checks for the AutoCAD MCP ezdxf backend.
Driven by ``compare_versions.py`` (the A/B runner). Run ONE check in isolation:
python benchmarks/correctness_suite.py <check_name>
python benchmarks/correctness_suite.py --list
Prints 'PASS' or 'FAIL' on the last line; a crash (e.g. SIGSEGV) or an
unsupported method => non-zero exit, which the runner counts as a miss.
The same file is executed against both the baseline worktree and the current
checkout (PYTHONPATH + cwd point at each repo), so a check that exercises a
method the old version lacks simply raises AttributeError -> miss.
"""
from __future__ import annotations
import asyncio
import math
import os
import sys
import tempfile
async def _b():
from backends.ezdxf_backend import EzdxfBackend
be = EzdxfBackend()
await be.connect()
await be.drawing_new()
return be
# ── Core capability (expected to pass on BOTH versions — sanity / not rigged) ──
async def core_line_length():
b = await _b()
ln = await b.entity_create_line(0, 0, 3, 4)
info = await b.entity_get(ln.handle)
return abs(float(info.properties["length"]) - 5.0) < 1e-6
async def core_circle_radius():
b = await _b()
c = await b.entity_create_circle(0, 0, 10)
info = await b.entity_get(c.handle)
return abs(float(info.properties["radius"]) - 10.0) < 1e-6
async def core_layer_create():
b = await _b()
await b.layer_create("BENCH", color=1)
names = {lyr.name for lyr in await b.layer_list()}
return "BENCH" in names
async def core_polyline_closed():
b = await _b()
p = await b.entity_create_polyline([[0, 0], [10, 0], [10, 10]], closed=True)
info = await b.entity_get(p.handle)
return bool(info.properties.get("closed"))
async def core_linear_dim():
b = await _b()
d = await b.dimension_linear(0, 0, 100, 0, 50, 20)
info = await b.entity_get(d.handle)
return "DIM" in info.type.upper()
async def core_save_dxf_roundtrip():
b = await _b()
await b.entity_create_line(0, 0, 10, 10)
path = os.path.join(tempfile.mkdtemp(), "rt.dxf")
await b.drawing_save_as(path, "dxf")
import ezdxf
ezdxf.readfile(path) # raises if not a valid DXF
return os.path.getsize(path) > 0
# ── Dimensions (I16) ──────────────────────────────────────────────────────────
async def dim_aligned_no_error():
b = await _b()
d = await b.dimension_aligned(0, 0, 50, 50, 30, 70)
return d is not None
async def dim_angular_no_error():
b = await _b()
d = await b.dimension_angular(0, 0, 100, 0, 0, 100, 60, 60)
return d is not None
# ── Polar array full circle (R9) ──────────────────────────────────────────────
async def array_polar_360_distinct():
b = await _b()
c = await b.entity_create_circle(10, 0, 1)
res = await b.entity_array_polar(c.handle, 4, 360.0, 0, 0)
# 3 copies + original = 4 distinct centers, none coincident with the original.
centers = {(10.0, 0.0)}
for e in res:
info = await b.entity_get(e.handle if hasattr(e, "handle") else e)
cx, cy = info.properties["center"]
centers.add((round(cx, 3), round(cy, 3)))
return len(centers) == 4
# ── Deterministic geometry (I9) ────────────────────────────────────────────────
async def point_intersection_line_line():
b = await _b()
l1 = await b.entity_create_line(0, 0, 10, 0)
l2 = await b.entity_create_line(5, -5, 5, 5)
pt = await b.point_intersection(l1.handle, l2.handle)
return abs(pt[0] - 5.0) < 1e-6 and abs(pt[1] - 0.0) < 1e-6
async def point_tangent_external():
b = await _b()
c = await b.entity_create_circle(0, 0, 3)
tx, ty = await b.point_tangent(c.handle, 5, 0, ref_y=10)
# tangency: (T-F).(T-C) == 0 and |T-C| == r
fx, fy = 5.0, 0.0
dot = (tx - fx) * (tx - 0) + (ty - fy) * (ty - 0)
return abs(dot) < 1e-4 and abs(math.hypot(tx, ty) - 3.0) < 1e-4
# ── Selection / property parity (N3, N5) ───────────────────────────────────────
async def arc_has_length():
b = await _b()
a = await b.entity_create_arc(0, 0, 10, 0, 90)
info = await b.entity_get(a.handle)
return abs(float(info.properties["length"]) - (10 * math.pi / 2)) < 1e-3
async def arc_select_by_length():
b = await _b()
await b.entity_create_arc(0, 0, 10, 0, 90) # length ~15.7
sel = await b.entity_select_smart({"type": "ARC", "length_range": [15, 16]})
return len(sel) == 1
async def ezdxf_bounding_box():
b = await _b()
ln = await b.entity_create_line(0, 0, 30, 40)
info = await b.entity_get(ln.handle)
bb = info.properties.get("bounding_box")
return isinstance(bb, dict) and set(bb) == {"min", "max"}
async def mtext_rotation_roundtrip():
b = await _b()
mt = await b.entity_create_mtext("r", 0, 0, width=50, height=2.5, rotation=30.0)
info = await b.entity_get(mt.handle)
return abs(float(info.properties.get("rotation", 0.0)) - 30.0) < 1e-6
# ── Rendering (screenshot Agg / SIGSEGV) ───────────────────────────────────────
async def screenshot_png():
b = await _b()
await b.entity_create_line(0, 0, 100, 100)
png = await b.view_screenshot()
return png is not None and png[:4] == b"\x89PNG"
# ── Quality gate / critique (R4, N4) ───────────────────────────────────────────
async def dim_overlap_critique_fires():
b = await _b()
await b.drawing_apply_iso_layers("mech")
await b.dimension_linear(0, 0, 100, 0, 50, 20, layer="DIM")
await b.dimension_linear(0, 0, 100, 0, 50, 20, layer="DIM")
issues = await b.drawing_critique(focus=["dim_overlap"])
return len(issues) >= 1
async def iso13567_dim_layer():
b = await _b()
await b.drawing_apply_iso_layers("iso13567")
ln = await b.entity_create_line(0, 0, 80, 0, layer="M-GEOMET-E-N")
dims = await b.dimension_auto([ln.handle], style="chain")
return dims[0].layer == "M-DIMEN-T-N"
async def construction_left_iso_caught():
b = await _b()
await b.drawing_apply_iso_layers("iso13567")
await b.construction_xline(0, 0, 90)
issues = await b.drawing_critique(focus=["construction_left"])
return len(issues) == 1
# ── Engineering (gear) ──────────────────────────────────────────────────────────
async def gear_no_self_overlap():
from engineering.gear import generate_full_gear_outline
pts = generate_full_gear_outline(2, 50, 20.0) # z=50 -> root_r >= base_r
root_r = (2 * 50 / 2.0) - 1.25 * 2
return min(math.hypot(x, y) for x, y in pts) >= root_r - 1e-6
# ── Linetype on demand (R24) ───────────────────────────────────────────────────
async def center_linetype_applied():
b = await _b()
ln = await b.entity_create_line(0, 0, 50, 0, linetype="CENTER")
info = await b.entity_get(ln.handle)
return info.linetype.upper() == "CENTER"
CHECKS = {
"core_line_length": (core_line_length, "Core"),
"core_circle_radius": (core_circle_radius, "Core"),
"core_layer_create": (core_layer_create, "Core"),
"core_polyline_closed": (core_polyline_closed, "Core"),
"core_linear_dim": (core_linear_dim, "Core"),
"core_save_dxf_roundtrip": (core_save_dxf_roundtrip, "Core"),
"dim_aligned_no_error": (dim_aligned_no_error, "Dimensions"),
"dim_angular_no_error": (dim_angular_no_error, "Dimensions"),
"array_polar_360_distinct": (array_polar_360_distinct, "Modify"),
"point_intersection_line_line": (point_intersection_line_line, "Geometry"),
"point_tangent_external": (point_tangent_external, "Geometry"),
"arc_has_length": (arc_has_length, "Query"),
"arc_select_by_length": (arc_select_by_length, "Query"),
"ezdxf_bounding_box": (ezdxf_bounding_box, "Query"),
"mtext_rotation_roundtrip": (mtext_rotation_roundtrip, "Entities"),
"screenshot_png": (screenshot_png, "Render"),
"dim_overlap_critique_fires": (dim_overlap_critique_fires, "Quality gate"),
"iso13567_dim_layer": (iso13567_dim_layer, "Quality gate"),
"construction_left_iso_caught": (construction_left_iso_caught, "Quality gate"),
"gear_no_self_overlap": (gear_no_self_overlap, "Engineering"),
"center_linetype_applied": (center_linetype_applied, "Entities"),
}
def main():
if len(sys.argv) == 2 and sys.argv[1] == "--list":
for k, (_fn, cat) in CHECKS.items():
print(f"{k}\t{cat}")
return
name = sys.argv[1]
fn, _cat = CHECKS[name]
ok = asyncio.run(fn())
print("PASS" if ok else "FAIL")
if __name__ == "__main__":
main()