An ISA-5.1 signal line has no pipe line number, yet every pneumatic / electric line got the bare sequence as its number and a label, and ate a process sequence number. Signal classes now leave line_number None (a verbatim one is still honoured), seq None, no label. A radial port aimed at the far end even with waypoints, so the first segment cut through the bubble; aim_points (lines.py) aims at the first/last waypoint for both draw_line and the dry run. Merge note: benchmarks/adapters/autocad_mcp_pro.py (final group's file) had its two _task_pid_roundtrip assertion lines moved to the new truth — the signal row is unnumbered, the connector row is 3. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
446 lines
19 KiB
Python
446 lines
19 KiB
Python
"""Reference adapter for this repository, covering the v4 task matrix.
|
|
|
|
Every task verifies against a value worked out independently of the code under
|
|
test — a closed-form area, a count of entities placed on purpose, a token
|
|
ceiling fixed before the measurement. A task that only checks the server agrees
|
|
with itself is a task that cannot fail.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from pathlib import Path
|
|
|
|
from benchmarks.adapters.base import BenchmarkAdapter, TaskResult
|
|
from benchmarks.tasks_v4 import TaskSpec
|
|
from engineering.delivery import deliver_drawing
|
|
from engineering.layers import ensure_engineering_layers, ensure_standard_linetypes
|
|
from engineering.refiner import refine_drawing
|
|
|
|
#: AutoCAD command names a drafter types, none of which appeared in any tool
|
|
#: name or description before v1.5.0 — measured `df = 0` against the stock BM25
|
|
#: index, so no amount of tuning could have surfaced them.
|
|
#: Each command is answered by a *set*, because more than one tool can be a
|
|
#: correct answer -- QSELECT is both "filter by properties" and "select
|
|
#: similar", and picking a winner between them would test the tie-break rather
|
|
#: than the discovery.
|
|
DISCOVERY_QUERIES: tuple[tuple[str, frozenset[str]], ...] = (
|
|
("FILLET", frozenset({"entity_fillet"})),
|
|
("BPOLY", frozenset({"boundary_trace"})),
|
|
("QSELECT", frozenset({"selection_filter", "entity_select_smart"})),
|
|
("WBLOCK", frozenset({"block_create_from_entities"})),
|
|
("OVERKILL", frozenset({"drawing_refine"})),
|
|
("CHSPACE", frozenset({"entity_change_space"})),
|
|
)
|
|
|
|
#: How far down the ranking still counts as found. A drafter reads the top few.
|
|
DISCOVERY_TOP_N = 3
|
|
|
|
#: The advertised catalog must stay under this in discovery mode. Fixed here
|
|
#: rather than derived from the measurement, so the measurement can miss it.
|
|
TOKEN_CEILING = 2_000
|
|
|
|
|
|
class AutoCADMCPProAdapter(BenchmarkAdapter):
|
|
name = "autocad-mcp-pro"
|
|
|
|
def __init__(self, *, backend: str = "ezdxf"):
|
|
self.backend_name = backend
|
|
self.backend = None
|
|
self.artifact_dir = Path()
|
|
|
|
async def setup(self, artifact_dir: Path) -> None:
|
|
if self.backend_name == "ezdxf":
|
|
from backends.ezdxf_backend import EzdxfBackend
|
|
|
|
self.backend = EzdxfBackend()
|
|
elif self.backend_name == "com":
|
|
from backends.com_backend import ComBackend
|
|
|
|
self.backend = ComBackend()
|
|
else:
|
|
raise ValueError(f"Unknown backend: {self.backend_name}")
|
|
self.artifact_dir = artifact_dir
|
|
await self.backend.connect()
|
|
|
|
async def cleanup(self) -> None:
|
|
if self.backend is not None:
|
|
await self.backend.disconnect()
|
|
|
|
def metadata(self) -> dict:
|
|
return {
|
|
"backend": self.backend_name,
|
|
"capabilities": self.backend.capabilities().to_dict() if self.backend else None,
|
|
}
|
|
|
|
async def _reset(self) -> None:
|
|
await self.backend.drawing_new()
|
|
|
|
async def run_task(self, task: TaskSpec) -> TaskResult:
|
|
await self._reset()
|
|
handler = getattr(self, f"_task_{task.task_id}", None)
|
|
if handler is None:
|
|
return TaskResult(task.task_id, "unsupported", 0.0, "No adapter implementation")
|
|
passed, metrics, artifacts = await handler()
|
|
return TaskResult(
|
|
task.task_id,
|
|
"pass" if passed else "fail",
|
|
100.0 if passed else 0.0,
|
|
metrics=metrics,
|
|
artifacts=artifacts,
|
|
)
|
|
|
|
async def _task_core_geometry(self):
|
|
line = await self.backend.entity_create_line(0, 0, 3, 4)
|
|
circle = await self.backend.entity_create_circle(10, 10, 2)
|
|
info = await self.backend.entity_get(line.handle)
|
|
passed = abs(float(info.properties["length"]) - 5.0) < 1e-6
|
|
return passed and bool(circle.handle), {"line_length": info.properties["length"]}, []
|
|
|
|
async def _task_modify_query(self):
|
|
line = await self.backend.entity_create_line(0, 0, 10, 0)
|
|
await self.backend.entity_move(line.handle, 5, 2)
|
|
moved = await self.backend.entity_get(line.handle)
|
|
return moved.properties["start"][:2] == [5.0, 2.0], {}, []
|
|
|
|
async def _task_layers_linetypes(self):
|
|
await ensure_standard_linetypes(self.backend)
|
|
await ensure_engineering_layers(self.backend)
|
|
layers = {item.name for item in await self.backend.layer_list()}
|
|
linetypes = {item.upper() for item in await self.backend.linetype_list()}
|
|
return "GEOMETRY" in layers and "CENTER" in linetypes, {}, []
|
|
|
|
async def _task_dimensions(self):
|
|
await ensure_engineering_layers(self.backend)
|
|
dimension = await self.backend.dimension_linear(0, 0, 50, 0, 25, 10, layer="DIM")
|
|
return "DIM" in dimension.type.upper(), {"entity_type": dimension.type}, []
|
|
|
|
async def _task_table_mleader(self):
|
|
await ensure_engineering_layers(self.backend)
|
|
table = await self.backend.entity_create_table(
|
|
0, 20, [["A", "1"]], headers=["ITEM", "QTY"], layer="TEXT"
|
|
)
|
|
leader = await self.backend.leader_create_mleader([[0, 0], [10, 10]], "NOTE", layer="DIM")
|
|
representations = [
|
|
table.properties.get("representation"),
|
|
leader.properties.get("representation"),
|
|
]
|
|
return all(representations), {"representations": representations}, []
|
|
|
|
async def _task_transactions(self):
|
|
await self.backend.transaction_begin()
|
|
await self.backend.entity_create_circle(0, 0, 1)
|
|
await self.backend.transaction_rollback()
|
|
entities = await self.backend.entity_list(limit=100)
|
|
return len(entities) == 0, {"entity_count": len(entities)}, []
|
|
|
|
async def _task_preflight(self):
|
|
result = await self.backend.drawing_preflight(
|
|
"Benchmark plate",
|
|
{
|
|
"units": "mm",
|
|
"part_type": "plate",
|
|
"dimensions": {"width": 50, "height": 25},
|
|
"tolerance_policy": "ISO 2768-m",
|
|
},
|
|
)
|
|
return result.ready and result.spec_hash.startswith("sha256:"), {}, []
|
|
|
|
async def _task_quality_refiner(self):
|
|
await ensure_engineering_layers(self.backend)
|
|
await self.backend.entity_create_line(0, 0, 10, 0, layer="GEOMETRY")
|
|
await self.backend.entity_create_line(0, 0, 10, 0, layer="GEOMETRY")
|
|
result = await refine_drawing(self.backend, focus=["duplicate_entities"])
|
|
return result.final_score > result.initial_score, result.to_dict(), []
|
|
|
|
async def _task_dxf_roundtrip(self):
|
|
await self.backend.entity_create_line(0, 0, 10, 10)
|
|
path = self.artifact_dir / "roundtrip.dxf"
|
|
await self.backend.drawing_export_dxf(str(path))
|
|
from backends.ezdxf_backend import EzdxfBackend
|
|
|
|
reopened = EzdxfBackend()
|
|
await reopened.connect()
|
|
try:
|
|
await reopened.drawing_open(str(path))
|
|
count = (await reopened.drawing_info()).entity_count
|
|
finally:
|
|
await reopened.disconnect()
|
|
return count == 1, {"entity_count": count}, [str(path)]
|
|
|
|
# ── v3 (v1.5.0) ─────────────────────────────────────────────────────────
|
|
|
|
async def _task_tool_discovery(self):
|
|
"""Can a drafter find the tool by typing the command they know?
|
|
|
|
Ranked through the real transform, not a stub: the corpus is what makes
|
|
these commands findable at all, and testing anything else would test
|
|
the fixture.
|
|
"""
|
|
import server
|
|
from discovery.transform import CadSearchTransform
|
|
|
|
tools = list(await server.mcp._list_tools())
|
|
transform = CadSearchTransform()
|
|
|
|
ranks: dict[str, int | None] = {}
|
|
for command, accepted in DISCOVERY_QUERIES:
|
|
hits = [
|
|
tool.name for tool in transform.rank(tools, command, limit=DISCOVERY_TOP_N).hits
|
|
]
|
|
found = next((index for index, name in enumerate(hits) if name in accepted), None)
|
|
ranks[command] = None if found is None else found + 1
|
|
|
|
passed = all(rank is not None for rank in ranks.values())
|
|
return passed, {"ranks": ranks, "top_n": DISCOVERY_TOP_N}, []
|
|
|
|
async def _task_token_budget(self):
|
|
"""What the client pays before it has asked for anything.
|
|
|
|
The ceiling is a constant, so a regression that doubles the catalog
|
|
fails this rather than quietly reporting a bigger number.
|
|
"""
|
|
import server
|
|
from benchmarks.token_suite import (
|
|
FORMAT_JSON_SCHEMA,
|
|
_client,
|
|
_wire_payload,
|
|
build_counter,
|
|
)
|
|
|
|
async def _advertised(mode: str) -> tuple[int, int]:
|
|
"""Tokens on the wire for `tools/list`, and how many tools that is.
|
|
|
|
Measured through a real client rather than the registry: the
|
|
registry is what the transform hides, so counting it would make
|
|
discovery mode look free.
|
|
"""
|
|
server._apply_discovery_mode(mode)
|
|
async with _client() as client:
|
|
tools = await client.list_tools()
|
|
return counter.count(_wire_payload(tools), FORMAT_JSON_SCHEMA), len(tools)
|
|
|
|
counter = build_counter("ratio")
|
|
try:
|
|
full, catalog_count = await _advertised("off")
|
|
discovery, advertised_count = await _advertised("search")
|
|
finally:
|
|
server._apply_discovery_mode("off")
|
|
|
|
passed = discovery < TOKEN_CEILING and discovery < full
|
|
return (
|
|
passed,
|
|
{
|
|
"catalog_tokens": full,
|
|
"discovery_tokens": discovery,
|
|
"ceiling": TOKEN_CEILING,
|
|
"reduction_x": round(full / discovery, 1) if discovery else None,
|
|
"catalog_tools": catalog_count,
|
|
"advertised_tools": advertised_count,
|
|
# Named, because "40,305 tokens" reads like a count and is not
|
|
# one. `benchmarks/token_suite.py --tokenizer anthropic` counts
|
|
# for real; this lane has to stay offline and deterministic.
|
|
"tokenizer": f"{counter.name} (estimate)" if counter.estimated else counter.name,
|
|
},
|
|
[],
|
|
)
|
|
|
|
async def _task_hatch_islands(self):
|
|
"""A section view is mostly holes, so the island is the measurement.
|
|
|
|
20x20 outer square with a 10x10 island: the filled area is 300, and a
|
|
server that records the island without subtracting it reports 400 while
|
|
looking entirely correct on screen.
|
|
"""
|
|
await ensure_engineering_layers(self.backend)
|
|
hatch = await self.backend.entity_create_hatch(
|
|
"ANSI31", [[0, 0], [20, 0], [20, 20], [0, 20]], layer="HATCH"
|
|
)
|
|
island = [
|
|
{"type": "line", "start": [5, 5], "end": [15, 5]},
|
|
{"type": "line", "start": [15, 5], "end": [15, 15]},
|
|
{"type": "line", "start": [15, 15], "end": [5, 15]},
|
|
{"type": "line", "start": [5, 15], "end": [5, 5]},
|
|
]
|
|
added = await self.backend.hatch_add_boundary(hatch.handle, island)
|
|
measured = await self.backend.entity_measure(hatch.handle)
|
|
ignored = await self.backend.hatch_edit(hatch.handle, style="ignore")
|
|
over_island = await self.backend.entity_measure(hatch.handle)
|
|
|
|
passed = (
|
|
added["path_count"] == 2
|
|
and abs(measured["area"] - 300.0) < 1e-6
|
|
and measured["hatch_style"] == "outer" # what entity_create_hatch writes
|
|
and ignored["changed"] == ["style"]
|
|
and abs(over_island["area"] - 400.0) < 1e-6
|
|
)
|
|
return (
|
|
passed,
|
|
{
|
|
"path_count": added["path_count"],
|
|
"filled_area": measured["area"],
|
|
"area_ignoring_islands": over_island["area"],
|
|
"expected_filled": 300.0,
|
|
"hatch_style": measured["hatch_style"],
|
|
},
|
|
[],
|
|
)
|
|
|
|
async def _task_selection_filter(self):
|
|
"""Window is not crossing, and a polygon is not its bounding box.
|
|
|
|
Three circles placed so each distinction has exactly one right answer:
|
|
one wholly inside, one straddling the edge, one out in the corner that
|
|
the bounding box catches and the triangle does not.
|
|
"""
|
|
await ensure_engineering_layers(self.backend)
|
|
await self.backend.entity_create_circle(5, 5, 1, layer="GEOMETRY")
|
|
await self.backend.entity_create_circle(10, 5, 3, layer="GEOMETRY")
|
|
await self.backend.entity_create_circle(18, 18, 0.5, layer="GEOMETRY")
|
|
await self.backend.entity_create_circle(40, 40, 1, layer="TEXT")
|
|
|
|
window = await self.backend.selection_window(0, 0, 10, 10, mode="window")
|
|
crossing = await self.backend.selection_window(0, 0, 10, 10, mode="crossing")
|
|
bbox = await self.backend.selection_window(0, 0, 20, 20, mode="window")
|
|
triangle = await self.backend.selection_polygon([[0, 0], [20, 0], [0, 20]], mode="window")
|
|
by_layer = await self.backend.selection_filter(entity_type="CIRCLE", layer="TEXT")
|
|
|
|
passed = (
|
|
window["count"] == 1
|
|
and crossing["count"] == 2
|
|
and bbox["count"] == 3
|
|
and triangle["count"] == 1
|
|
and by_layer["count"] == 1
|
|
and by_layer["filtered_by"] == ["entity_type", "layer"]
|
|
)
|
|
return (
|
|
passed,
|
|
{
|
|
"window": window["count"],
|
|
"crossing": crossing["count"],
|
|
"bounding_box": bbox["count"],
|
|
"polygon": triangle["count"],
|
|
"by_layer": by_layer["count"],
|
|
},
|
|
[],
|
|
)
|
|
|
|
async def _task_measure_from_handle(self):
|
|
"""The 28.2% that reading coordinates back loses.
|
|
|
|
A 10x10 square whose top edge is a semicircle encloses 139.2699. Chain
|
|
the loose edges into a boundary, then measure that boundary by handle;
|
|
shoelacing the four corners the model would have remembered gives 100.
|
|
"""
|
|
await ensure_engineering_layers(self.backend)
|
|
await self.backend.entity_create_line(0, 0, 10, 0, layer="GEOMETRY")
|
|
await self.backend.entity_create_line(10, 0, 10, 10, layer="GEOMETRY")
|
|
await self.backend.entity_create_line(0, 10, 0, 0, layer="GEOMETRY")
|
|
await self.backend.entity_create_arc(5, 10, 5, 0, 180, layer="GEOMETRY")
|
|
|
|
loop = await self.backend.boundary_trace(5.0, 5.0)
|
|
measured = await self.backend.entity_measure(loop["handle"])
|
|
naive = await self.backend.analysis_measure_area([[0, 0], [10, 0], [10, 10], [0, 10]])
|
|
|
|
true_area = 100.0 + math.pi * 25.0 / 2.0
|
|
lost = (true_area - naive) / true_area
|
|
passed = (
|
|
loop["ok"] is True
|
|
and abs(measured["area"] - true_area) < 1e-5
|
|
and abs(loop["area"] - measured["area"]) < 1e-9
|
|
and measured["method"] == "analytic_bulge"
|
|
and measured["exact"] is True
|
|
and 0.28 < lost < 0.29
|
|
)
|
|
return (
|
|
passed,
|
|
{
|
|
"measured_area": measured["area"],
|
|
"true_area": round(true_area, 6),
|
|
"vertex_shoelace_area": naive,
|
|
"fraction_lost_by_reading_points_back": round(lost, 4),
|
|
"boundary_agrees": abs(loop["area"] - measured["area"]) < 1e-9,
|
|
},
|
|
[],
|
|
)
|
|
|
|
async def _task_pid_roundtrip(self):
|
|
"""Five symbols, four lines, read back with nothing dangling.
|
|
|
|
The spec is drawn through the same code ``pid_from_spec`` calls; the
|
|
graph, the instrument index and the line list are then read back from
|
|
the drawing by the reader, which never sees the spec. Every expected
|
|
number below is counted from the spec by hand.
|
|
"""
|
|
from engineering.layers import apply_layer_set
|
|
from engineering.pid.deliverables import instrument_index, line_list
|
|
from engineering.pid.spec import EXAMPLE_SPEC, run_spec
|
|
|
|
await apply_layer_set(self.backend, "pid")
|
|
result = await run_spec(self.backend, EXAMPLE_SPEC)
|
|
graph = result["graph"]
|
|
index = instrument_index(graph)
|
|
lines = line_list(graph)
|
|
# The spec numbers its two process lines; the connector line is left
|
|
# to the default ``number_format``, which with no size/service/spec/
|
|
# insulation collapses to the bare sequence number, and it must come
|
|
# back through XDATA, not a label search. The electric signal line is
|
|
# unnumbered by rule (ISA-5.1 signal lines carry no pipe line number)
|
|
# and must not have consumed a sequence number: the connector is "3".
|
|
authored = "100-P-1001-CS1"
|
|
numbers = {(r["from_tag"], r["to_tag"]): r["line_number"] for r in lines}
|
|
authored_ok = (
|
|
numbers.get(("P-101", "FCV-101")) == authored
|
|
and numbers.get(("FCV-101", "V-201")) == authored
|
|
)
|
|
others = [r for r in lines if r["line_number"] != authored]
|
|
signal = [r for r in others if r["line_class"] == "electric"]
|
|
sequenced = [r for r in others if r["line_class"] != "electric"]
|
|
sequenced_ok = (
|
|
len(signal) == 1
|
|
and signal[0]["line_number"] is None
|
|
and len(sequenced) == 1
|
|
and sequenced[0]["line_number"] == "3"
|
|
and sequenced[0]["number_source"] == "xdata"
|
|
)
|
|
passed = (
|
|
len(graph["nodes"]) == 5
|
|
and len(graph["edges"]) == 4
|
|
and graph["stats"]["dangling"] == 0
|
|
and graph["stats"]["confidence_min"] == 1.0
|
|
and result["critique"] == []
|
|
and [r["tag"] for r in index] == ["FIC-101"]
|
|
and index[0]["connected_to"] == "FCV-101"
|
|
and len(lines) == 4
|
|
and authored_ok
|
|
and sequenced_ok
|
|
)
|
|
return (
|
|
passed,
|
|
{
|
|
"nodes": len(graph["nodes"]),
|
|
"edges": len(graph["edges"]),
|
|
"dangling": graph["stats"]["dangling"],
|
|
"confidence_min": graph["stats"]["confidence_min"],
|
|
"critique_issues": len(result["critique"]),
|
|
"instrument_index_rows": len(index),
|
|
"line_list_rows": len(lines),
|
|
"authored_line_numbers_kept": authored_ok,
|
|
"unnumbered_lines_sequenced": sequenced_ok,
|
|
},
|
|
[],
|
|
)
|
|
|
|
async def _task_auditable_delivery(self):
|
|
await ensure_engineering_layers(self.backend)
|
|
await self.backend.entity_create_line(0, 0, 10, 0, layer="GEOMETRY")
|
|
destination = self.artifact_dir / "delivery"
|
|
result = await deliver_drawing(
|
|
self.backend,
|
|
destination,
|
|
formats=["dxf"],
|
|
min_score=0,
|
|
strict_critique=False,
|
|
)
|
|
return result.status == "success", {"score": result.score}, [result.manifest_path]
|