Tool count 131 -> 154; 474 -> 1242 collected tests. The release exists for two measured reasons. A 149-tool catalog costs a client ~40,300 tokens before it has asked for anything, and a drafter searching the way drafters think -- FILLET, BPOLY, QSELECT, WBLOCK -- scored df=0 against the stock index, because those words were simply not in the data. Discovery and batching - DISCOVERY_MODE=search replaces the catalog with search_tools + call_tool, ranked over an AutoCAD command and synonym corpus (discovery/aliases.py) on top of fastmcp's BM25 index. Measured: 40,305 -> 356 tokens advertised. - cad_batch runs a step list in one round trip; fields= projects result-heavy responses. The `core` profile is gone -- discovery replaced it. Honesty debt (Tier 0) - OCS/ECS: `extrusion` appeared nowhere in this codebase, so mirrored and rotated geometry reported coordinates that were quietly wrong. backends/ocs.py translates at the backend boundary; every tool coordinate is WCS on both engines, and a tilted plane refuses rather than answering with a number that looks fine. - drawing_save_as(fmt="dwg") wrote DXF bytes to a .dwg path. drawing_audit never repaired. analysis_measure_area shoelaced points the caller typed in. - ezdxf calls gained a per-call timeout, so one hung call can no longer wedge a server whose document lock is a single asyncio.Lock. Architecture - The 1854-line backend ABC is 19 contract modules; @capability(key, reason=) makes optional methods declarative and a gate fails on undeclared holes. - 8 layout/viewport tools, CHSPACE, CAD_PROGID escape hatch. - Wave A: selection filters, boundary tracing, hatch depth, annotation objects, measurement -- 13 tools of a planned 39. The REGION and 2D-boolean family was cut on evidence: add_region() yields zero ACIS bytes and the greiner_hormann substitute loses 28.2% of the area on a square with one semicircular edge. Verified against a live AutoCAD 2026, which found four COM defects no amount of reading would have caught -- AcadPViewport has no ViewCenter, and the line that set it had been swallowed by a bare except since v1.4. Benchmark v3 (15 tasks) and what it caught - v2 scored this server 10/10, which carried no information: every task in it exercised something the server was built around. Five tasks were added because they can fail, and three did while being written -- a hatch could not be measured though the refusal listed HATCH as measurable, the boundary tools and the measure tool disagreed about the same shape (139.2177 vs 139.2699), and a circle stored as two bulged vertices measured 0.0. - Competitor reports are NOT re-scored. They are pinned v1.4 runs against v2 and were never asked these five questions; the chart shows those rows `not run`. - Correctness A/B: 21/24 -> 24/24, zero regressions. Performance, stated plainly The published v1.4 perf report was recorded on CPython 3.14 and the new one on 3.11.15, so the apparent 2-6x speedup is the interpreter, not this release. Re-measured on one interpreter, v1.5.0 is 28% slower creating entities and 22% slower on the 10k roundtrip. Fully attributed: EZDXF_CALL_TIMEOUT=0 restores v1.4.0's exact numbers. That is the deadlock fix being paid for, the knob is documented, and winning it back is on the 1.6 roadmap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
170 lines
6.3 KiB
Python
170 lines
6.3 KiB
Python
"""M9 — the v3 task matrix.
|
|
|
|
v2 returned 10/10 for this server. That was not a good sign: every task in it
|
|
tested something the server was designed around, so the score could only ever
|
|
have been full marks. The five tasks added here were chosen because they can
|
|
fail, and three of them did fail while being written — a hatch could not be
|
|
measured at all, and the boundary tools disagreed with the measurement tool
|
|
about the same shape.
|
|
|
|
Two assertions in this file exist purely to keep the matrix honest rather than
|
|
to test behaviour:
|
|
|
|
* the plan's ``tool_discovery_bilingual`` and ``measurement_massprops`` must
|
|
**not** appear, because neither capability shipped; and
|
|
* the published competitor reports must not carry the new task ids, because
|
|
those servers were never run against them and a zero we invented would read
|
|
exactly like a zero we measured.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from backends.base import UnsupportedCapabilityError
|
|
from benchmarks.adapters.autocad_mcp_pro import AutoCADMCPProAdapter
|
|
from benchmarks.adapters.base import BenchmarkAdapter
|
|
from benchmarks.run_competitors import run_tasks
|
|
from benchmarks.tasks_v2 import TASKS_V2
|
|
from benchmarks.tasks_v3 import MATRICES, NEW_TASKS_V3, TASKS_V3, task_by_id
|
|
|
|
PUBLISHED = Path(__file__).resolve().parents[1] / "benchmarks" / "results" / "published"
|
|
|
|
|
|
# ── shape of the matrix ─────────────────────────────────────────────────────
|
|
|
|
|
|
def test_v3_extends_v2_without_disturbing_it():
|
|
"""A renumbered or reweighted v2 task would silently invalidate the
|
|
published v1.4 reports, which are keyed by task id."""
|
|
assert TASKS_V3[: len(TASKS_V2)] == TASKS_V2
|
|
assert len(NEW_TASKS_V3) == 5
|
|
assert len(TASKS_V3) == 15
|
|
|
|
|
|
def test_every_task_id_is_unique():
|
|
ids = [task.task_id for task in TASKS_V3]
|
|
assert len(ids) == len(set(ids))
|
|
|
|
|
|
def test_the_two_capabilities_that_did_not_ship_are_not_advertised_as_tasks():
|
|
ids = {task.task_id for task in TASKS_V3}
|
|
|
|
assert "tool_discovery_bilingual" not in ids, (
|
|
"the Turkish normalizer was built and removed; this repo is English "
|
|
"throughout, so a bilingual task would be a benchmark for nothing"
|
|
)
|
|
assert "measurement_massprops" not in ids, (
|
|
"no centroid or moment-of-inertia tool ships — F2 was cut to entity_measure"
|
|
)
|
|
assert "tool_discovery" in ids
|
|
assert "measure_from_handle" in ids
|
|
|
|
|
|
def test_task_by_id_can_still_address_the_v2_matrix():
|
|
assert task_by_id("core_geometry", "v2").category == "creation"
|
|
with pytest.raises(KeyError):
|
|
task_by_id("hatch_islands", "v2")
|
|
|
|
|
|
def test_matrices_registry_exposes_both_sets():
|
|
assert MATRICES["v2"] == TASKS_V2
|
|
assert MATRICES["v3"] == TASKS_V3
|
|
|
|
|
|
# ── the reference adapter actually answers them ─────────────────────────────
|
|
|
|
|
|
def test_the_reference_adapter_implements_every_v3_task():
|
|
"""An unimplemented task scores `unsupported` and is easy to not notice."""
|
|
missing = [
|
|
task.task_id
|
|
for task in TASKS_V3
|
|
if not hasattr(AutoCADMCPProAdapter, f"_task_{task.task_id}")
|
|
]
|
|
assert missing == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("task", NEW_TASKS_V3, ids=lambda t: t.task_id)
|
|
async def test_each_new_task_passes_on_the_headless_backend(task, tmp_path):
|
|
report = await run_tasks(AutoCADMCPProAdapter(), [task], tmp_path, timeout=120.0)
|
|
|
|
result = report["results"][0]
|
|
assert result["status"] == "pass", result["message"]
|
|
assert result["metrics"], "a task that proves nothing reports no evidence"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_the_full_v3_matrix_runs_green(tmp_path):
|
|
report = await run_tasks(AutoCADMCPProAdapter(), TASKS_V3, tmp_path, timeout=180.0)
|
|
|
|
assert report["summary"]["attempted"] == 15
|
|
assert report["summary"]["passed"] == 15, [
|
|
(item["task_id"], item["status"], item["message"]) for item in report["results"]
|
|
]
|
|
assert report["matrix"] == "v3"
|
|
|
|
|
|
# ── a capability refusal is not a failure ───────────────────────────────────
|
|
|
|
|
|
class RefusingAdapter(BenchmarkAdapter):
|
|
name = "refusing"
|
|
|
|
async def setup(self, artifact_dir):
|
|
return None
|
|
|
|
async def run_task(self, task):
|
|
raise UnsupportedCapabilityError("chspace", "no change-space member on this engine")
|
|
|
|
async def cleanup(self):
|
|
return None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_capability_refusal_is_reported_unsupported_not_failed(tmp_path):
|
|
"""`fail` and `unsupported` are different claims about a competitor.
|
|
|
|
"Your server got this wrong" and "your engine cannot reach this" are not
|
|
the same finding, and the runner used to collapse the second into the
|
|
first because a refusal is still an exception.
|
|
"""
|
|
report = await run_tasks(RefusingAdapter(), [NEW_TASKS_V3[0]], tmp_path)
|
|
|
|
result = report["results"][0]
|
|
assert result["status"] == "unsupported"
|
|
assert "chspace" in result["message"]
|
|
assert report["summary"]["unsupported"] == 1
|
|
assert report["summary"]["score"] == 0.0
|
|
|
|
|
|
# ── nothing was invented for the competitors ────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"filename", ["beiming183-autocad-mcp.json", "puran-water-autocad-mcp.json"]
|
|
)
|
|
def test_competitor_reports_carry_no_result_for_the_new_tasks(filename):
|
|
report = json.loads((PUBLISHED / filename).read_text(encoding="utf-8"))
|
|
scored = {item["task_id"] for item in report["results"]}
|
|
new_ids = {task.task_id for task in NEW_TASKS_V3}
|
|
|
|
assert scored & new_ids == set(), (
|
|
"these servers were pinned and run against the v2 matrix at v1.4; a "
|
|
"result here would be fabricated"
|
|
)
|
|
assert report.get("matrix", "v2") == "v2"
|
|
|
|
|
|
def test_the_published_reference_report_covers_the_whole_v3_matrix():
|
|
report = json.loads((PUBLISHED / "autocad-mcp-pro.json").read_text(encoding="utf-8"))
|
|
scored = {item["task_id"] for item in report["results"]}
|
|
|
|
assert scored == {task.task_id for task in TASKS_V3}
|
|
assert report["matrix"] == "v3"
|
|
assert report["summary"]["score"] == 100.0
|