bench: matrix v7 adds takeoff_roundtrip and understand_foreign; five understanding checks; the live smoke and the field-test script
The evidence for track H, in the shape tracks A, E, B + G and F used.
- benchmarks/tasks_v7.py: 21 tasks, DEFAULT_MATRIX = "v7"; v6 to v2 stay
addressable. Both new tasks read the synthetic plant pair and nothing
else. takeoff_roundtrip: the P&ID called schematic, every routable run's
layout length equal to the rectilinear MST of its tags, sizes and services
as drawn (a return drawn on the supply layer included), the cable metres
rounded up after the allowance, an unstated power left empty.
understand_foreign: inches over millimetres flagged, the stray entity found
by handle, two plan copies separated, five layers classified. A test breaks
each input and watches its gate fail by name.
- Five correctness checks, every number worked out by hand in its docstring;
the published A/B report is re-run over 48 checks: 26 / 48 -> 48 / 48,
zero regressed.
- scripts/smoke_understand_com.py reads the pair through the live engine -
snapshots, the DWG route, both takeoffs and a diff - and closes every
document it opens; the CHANGELOG records what it returned.
scripts/field_test_takeoff.py runs the takeoffs on a user's own files,
refuses to write inside the repository and prints no quantity by default.
- The README token table is remeasured; check_doc_numbers derives the task
count from the v7 matrix; CLAUDE.md, both READMEs, the CHANGELOG and the
spec status line say what shipped.
One reader defect found on the way: describe.tag_occurrences read a wiring
callout ("Wiring to CP-2") as a place its panel is written, so the scale
check called CP-2 ambiguous and read "Wiring to CP1" as a phantom tag CP1 -
10 tags and 45 pairs (13.3 % within 10 %) where the generator placed 11 and
55 (21.8 %). A callout is no longer a tag occurrence, the rule
network.tag_occurrences already kept; two tests failed before the fix.
The smoke works only in documents it opened from its own temporary folder,
known by full path; it attaches with GetActiveObject only (the backend's
Dispatch fallback would launch AutoCAD), checks in the same COM-thread call
that the active document is its own before each snapshot, the DWG save and
the move, closes its documents with Close(False) and re-activates the
document that was active when it started. No new ActiveX member: Documents,
Item, FullName, ActiveDocument, Activate and Close are the ones the backend
already calls. Measured on AutoCAD 2026 (25.1s): exit 0, model-space types
equal file / live / DWG, export 0.104 s, no document of the run left open.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
835bc4b65e
commit
ba41428c88
+15
-4
@@ -127,6 +127,17 @@ result for it.
|
||||
|
||||
The competitor reports carry no result for it.
|
||||
|
||||
### Matrix v7 (v1.6) — understanding foreign drawings
|
||||
|
||||
`tasks_v7.py` keeps the v6 nineteen unchanged and adds track H's evidence, both on the synthetic plant pair of `tests/fixtures/plant_pair.py` (invented names; its truth is computed from the positions it placed, never typed):
|
||||
|
||||
| Task | Category | Verified against |
|
||||
|---|---|---|
|
||||
| `takeoff_roundtrip` | plant | `scale_check(pid, layout)` must say **schematic** (room 1 is a 1.3× stretch, room 2 is rearranged: 21.8 % of the 55 tag pairs within ±10 % of the median ratio); `pipe_rows(..., length_source="auto")` must report all nine runs with the truth's status, each routable run's layout length equal to the rectilinear MST of its tags to a micron (PR1 5500, PR2 13200, CS1 20700 mm, …), its size and service as drawn — the CIP return drawn on the supply layer included — and both sizes across the reducer; `cable_rows` must give M11 / T102 / M12 / T202 their panels, 6900 / 14100 / 15800 / 8000 mm and 9 / 17 / 19 / 10 m after the 20 % allowance, the heater's power empty. `tests/test_benchmark_v7.py` deletes the wiring callouts and the gate names all four loads |
|
||||
| `understand_foreign` | understand | `describe(layout)` must infer millimetres and warn about `INSUNITS` (the file says inches), name the stray line's handle in its extents and report two clusters (the plan is drawn twice, 100 m apart); `describe(pid)` must classify `P_product piping`, `P_cipsupplyline`, `P_cipreturnline`, `P_ijswater` and `E_power` as product, CIP supply, CIP return, ice water and electrical. `tests/test_benchmark_v7.py` makes the layout declare millimetres and the units gate fails alone |
|
||||
|
||||
The competitor reports carry no result for them.
|
||||
|
||||
## Live competitor lane (v1.4)
|
||||
|
||||
Two competitor adapters now execute the exact same task matrix, black-box over
|
||||
@@ -244,15 +255,15 @@ python benchmarks/compare_versions.py --json results.json
|
||||
|
||||
### Result — this branch (1.6.0-dev) vs v1.5.1 (release gate)
|
||||
|
||||
43 checks, ezdxf backend, one subprocess per check. Machine-readable report:
|
||||
48 checks, ezdxf backend, one subprocess per check. Machine-readable report:
|
||||
[`results/published/ab-v1.5.1-vs-v1.6.0-dev.json`](results/published/ab-v1.5.1-vs-v1.6.0-dev.json).
|
||||
|
||||
| Version | Checks passing | Pass rate | Fixed | Regressed |
|
||||
|---------|----------------|-----------|-------|-----------|
|
||||
| **v1.5.1** (baseline) | 26 / 43 | 60.5 % | — | — |
|
||||
| **v1.6.0-dev** (this branch) | 43 / 43 | 100 % | 17 | **0** |
|
||||
| **v1.5.1** (baseline) | 26 / 48 | 54.2 % | — | — |
|
||||
| **v1.6.0-dev** (this branch) | 48 / 48 | 100 % | 22 | **0** |
|
||||
|
||||
The seventeen are all `miss → pass` — v1.5.1 has none of the methods. Four are track F: `arch_junction_l_t_x` (L, T and X junction outlines against hand-computed corners and wall areas), `arch_room_area_net` (a 4 × 5 m room between 200 mm walls is labelled 20.00 m², not its 21.84 m² axis area), `arch_opening_cuts_wall` (both faces interrupted across a door, two jambs) and `arch_rooms_detect_foreign` (plain lines on a WALLS layer read as 20 and 15 m² at confidence 0.6, nothing written). Seven
|
||||
The twenty-two are all `miss → pass` — v1.5.1 has none of the methods. Five are track H: `scale_check_detects_schematic` (the synthetic P&ID is schematic against its layout; a uniform 2× copy is to scale with factor 2), `pipe_takeoff_rmst_exact` (eight routable runs, each exactly the rectilinear MST of its tags), `cable_takeoff_roundup` (9 / 17 / 19 / 10 m, the unstated power left empty), `diff_detects_known_edits` (five known edits and nothing else) and `topology_known_defects` (one near miss, one crossing, a clean T). Four are track F: `arch_junction_l_t_x` (L, T and X junction outlines against hand-computed corners and wall areas), `arch_room_area_net` (a 4 × 5 m room between 200 mm walls is labelled 20.00 m², not its 21.84 m² axis area), `arch_opening_cuts_wall` (both faces interrupted across a door, two jambs) and `arch_rooms_detect_foreign` (plain lines on a WALLS layer read as 20 and 15 m² at confidence 0.6, nothing written). Seven
|
||||
are tracks B and G: `mech_part_roundtrip` (a part read back from its own
|
||||
`ACADMCP_MECH` XDATA equals the part drawn), `mech_section_hatch_area` (a
|
||||
sleeve 60 long, ⌀40 outside, ⌀20 bore: a full section cuts two 60 × 10 faces,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Reference adapter for this repository, covering the v6 task matrix.
|
||||
"""Reference adapter for this repository, covering the v7 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
|
||||
@@ -12,7 +12,7 @@ import math
|
||||
from pathlib import Path
|
||||
|
||||
from benchmarks.adapters.base import BenchmarkAdapter, TaskResult
|
||||
from benchmarks.tasks_v6 import TaskSpec
|
||||
from benchmarks.tasks_v7 import TaskSpec
|
||||
from engineering.delivery import deliver_drawing
|
||||
from engineering.layers import ensure_engineering_layers, ensure_standard_linetypes
|
||||
from engineering.refiner import refine_drawing
|
||||
@@ -779,6 +779,147 @@ class AutoCADMCPProAdapter(BenchmarkAdapter):
|
||||
[str(sheet)],
|
||||
)
|
||||
|
||||
async def _task_takeoff_roundtrip(self):
|
||||
"""Pipe and cable quantities off the synthetic plant pair, against its truth.
|
||||
|
||||
Track H's evidence (spec §15). The pair is written fresh into the
|
||||
artifact folder by ``tests/fixtures/plant_pair.py``, whose truth is
|
||||
computed from the positions it placed. Then only the two DXFs are read:
|
||||
|
||||
* ``scale_check(pid, layout)`` must say ``schematic`` - room 1 is the
|
||||
layout stretched 1.3x, room 2 is rearranged, and 21.8 % of the 55 tag
|
||||
pairs lie within ±10 % of the median ratio;
|
||||
* ``pipe_rows(..., length_source="auto")`` must report every run of the
|
||||
P&ID (the ice-water utility included, because ``services`` names it)
|
||||
with the truth's status; a routable run's layout length must equal the
|
||||
rectilinear MST of its tags exactly, its sizes (both across the
|
||||
reducer) and its service must be the drawn ones (the CIP return drawn
|
||||
on the supply layer included); the run ending on T105, which the
|
||||
layout lacks, is status C with no layout length;
|
||||
* ``cable_rows(pid, layout)`` must give every load its panel from the
|
||||
layout's wiring callouts, its stated power (the heater's stays empty),
|
||||
the Manhattan length to the panel and the metres rounded up after the
|
||||
20 % allowance.
|
||||
|
||||
Any one slipping fails the task, and the metrics name what slipped.
|
||||
"""
|
||||
from engineering.understand.network import build_network
|
||||
from engineering.understand.scale import scale_check
|
||||
from engineering.understand.snapshot import read_snapshot
|
||||
from engineering.understand.takeoff import cable_rows, pipe_rows
|
||||
from tests.fixtures.plant_pair import build_plant_pair
|
||||
|
||||
truth = build_plant_pair(self.artifact_dir / "plant_pair")
|
||||
pid = read_snapshot(truth["pid"])
|
||||
layout = read_snapshot(truth["layout"])
|
||||
scale = scale_check(pid, layout)
|
||||
services = tuple(sorted({run["service"] for run in truth["pipe_runs"]}))
|
||||
pipe = pipe_rows(
|
||||
build_network(pid), pid, layout, scale=scale, services=services, length_source="auto"
|
||||
)
|
||||
found = {(run["service"], tuple(sorted(run["tags"]))): run for run in pipe["runs"]}
|
||||
pipe_mismatch = []
|
||||
for run in truth["pipe_runs"]:
|
||||
got = found.get((run["service"], tuple(run["tags"])))
|
||||
if got is None:
|
||||
pipe_mismatch.append({"run": run["id"], "reason": "no run with these tags"})
|
||||
continue
|
||||
reasons = []
|
||||
if got["status"] != run["status"]:
|
||||
reasons.append(f"status {got['status']} != {run['status']}")
|
||||
want, have = run["layout_length_mm"], got.get("layout_m")
|
||||
if want is None and have is not None:
|
||||
reasons.append(f"a layout length ({have} m) for an unroutable run")
|
||||
elif want is not None and (have is None or abs(float(have) * 1000.0 - want) > 1e-6):
|
||||
reasons.append(f"layout length {have} m != {want} mm")
|
||||
if set(got["diameters"]) != set(run["diameters"]):
|
||||
reasons.append(f"sizes {sorted(got['diameters'])} != {sorted(run['diameters'])}")
|
||||
if reasons:
|
||||
pipe_mismatch.append({"run": run["id"], "reason": "; ".join(reasons)})
|
||||
|
||||
cable = {row["tag"]: row for row in cable_rows(pid, layout)["rows"]}
|
||||
cable_mismatch = []
|
||||
for want in truth["cable_rows"]:
|
||||
got = cable.get(want["tag"])
|
||||
if got is None:
|
||||
cable_mismatch.append({"tag": want["tag"], "wrong": ["missing"]})
|
||||
continue
|
||||
wrong = [key for key in ("panel", "kw") if got.get(key) != want[key]]
|
||||
if got.get("cable_m") != want["roundup_m"]:
|
||||
wrong.append("cable_m")
|
||||
length = got.get("length_m")
|
||||
if length is None or abs(float(length) * 1000.0 - want["length_mm"]) > 1e-6:
|
||||
wrong.append("length_m")
|
||||
if wrong:
|
||||
cable_mismatch.append({"tag": want["tag"], "wrong": wrong})
|
||||
|
||||
passed = scale["verdict"] == truth["scale_verdict"] and not pipe_mismatch
|
||||
passed = passed and not cable_mismatch
|
||||
return (
|
||||
passed,
|
||||
{
|
||||
"scale_verdict": scale["verdict"],
|
||||
"within_10pct": scale.get("within_10pct"),
|
||||
"length_source": pipe.get("length_source"),
|
||||
"runs_checked": len(truth["pipe_runs"]),
|
||||
"pipe_mismatch": pipe_mismatch,
|
||||
"loads_checked": len(truth["cable_rows"]),
|
||||
"cable_mismatch": cable_mismatch,
|
||||
},
|
||||
[truth["pid"], truth["layout"]],
|
||||
)
|
||||
|
||||
async def _task_understand_foreign(self):
|
||||
"""One call on a foreign layout reports what is wrong with it, by evidence.
|
||||
|
||||
Track H's evidence (spec §5, §15), on the synthetic plant pair: the
|
||||
layout declares inches (``$INSUNITS = 1``) over millimetre geometry, a
|
||||
stray LINE 5,000 km away stretches its declared extents, and the plan is
|
||||
drawn twice, 100 m apart. ``describe`` of the layout must infer
|
||||
millimetres, report that they disagree with the declared unit and warn
|
||||
about ``INSUNITS``, name the stray entity's handle in its extents, and
|
||||
report exactly two clusters; ``describe`` of the P&ID must classify its
|
||||
four service layers and the electrical layer as the generator names them
|
||||
(English and Dutch names). A warning or an extents report is searched as
|
||||
JSON, so the gate holds whatever shape the evidence takes.
|
||||
"""
|
||||
import json
|
||||
|
||||
from engineering.understand.describe import describe
|
||||
from engineering.understand.snapshot import read_snapshot
|
||||
from tests.fixtures.plant_pair import build_plant_pair
|
||||
|
||||
truth = build_plant_pair(self.artifact_dir / "plant_pair")
|
||||
layout = describe(read_snapshot(truth["layout"]))
|
||||
pid = describe(read_snapshot(truth["pid"]))
|
||||
units = layout["units"]
|
||||
units_flagged = (
|
||||
str(units.get("inferred")) == truth["layout_true_unit"]
|
||||
and units.get("agree") is False
|
||||
and "INSUNITS" in json.dumps(layout["warnings"], ensure_ascii=False)
|
||||
)
|
||||
outlier_found = f'"{truth["outlier_handle"]}"' in json.dumps(layout["extents"])
|
||||
copies_separated = len(layout["clusters"]) == truth["cluster_count"]
|
||||
read = {row["name"]: row["service"] for row in pid["layers"]}
|
||||
misread = {
|
||||
name: read.get(name)
|
||||
for name, service in truth["layer_services"].items()
|
||||
if read.get(name) != service
|
||||
}
|
||||
passed = units_flagged and outlier_found and copies_separated and not misread
|
||||
return (
|
||||
passed,
|
||||
{
|
||||
"units": units,
|
||||
"units_flagged": units_flagged,
|
||||
"outlier_found": outlier_found,
|
||||
"clusters": len(layout["clusters"]),
|
||||
"copies_separated": copies_separated,
|
||||
"misread_layers": misread,
|
||||
},
|
||||
[truth["layout"], truth["pid"]],
|
||||
)
|
||||
|
||||
async def _task_auditable_delivery(self):
|
||||
await ensure_engineering_layers(self.backend)
|
||||
await self.backend.entity_create_line(0, 0, 10, 0, layer="GEOMETRY")
|
||||
|
||||
@@ -818,6 +818,214 @@ async def arch_rooms_detect_foreign():
|
||||
)
|
||||
|
||||
|
||||
# ── Understanding foreign drawings (track H) ───────────────────────────────
|
||||
|
||||
|
||||
def _plant_pair() -> dict:
|
||||
"""The synthetic plant pair, written to a fresh temporary folder."""
|
||||
from tests.fixtures.plant_pair import build_plant_pair
|
||||
|
||||
return build_plant_pair(tempfile.mkdtemp(prefix="acadmcp_plant_"))
|
||||
|
||||
|
||||
def _quoted(handle: str) -> str:
|
||||
"""A handle as it appears in a JSON dump - quoted, so '2A' never matches '12A3'."""
|
||||
return f'"{handle}"'
|
||||
|
||||
|
||||
async def scale_check_detects_schematic():
|
||||
"""The synthetic P&ID is schematic against its layout; a uniform 2x copy is not.
|
||||
|
||||
The pair: room 1 of the P&ID is the layout stretched 1.3x, room 2 is
|
||||
rearranged, and of the 55 pairs of the 11 shared tags 21.8 % lie within
|
||||
±10 % of the median ratio - at most 50 % is ``schematic`` (spec §6).
|
||||
The control: six tags at (0, 0), (4000, 0), (8000, 0), (0, 3000),
|
||||
(4000, 3000), (8000, 6000) and the same tags at twice those coordinates -
|
||||
the nearest pair is 3000 mm apart, every ratio is exactly 2.0, so the
|
||||
doubled drawing is ``to_scale`` against the first with factor 2.
|
||||
"""
|
||||
import ezdxf
|
||||
|
||||
from engineering.understand.scale import scale_check
|
||||
from engineering.understand.snapshot import read_snapshot
|
||||
|
||||
truth = _plant_pair()
|
||||
pair = scale_check(read_snapshot(truth["pid"]), read_snapshot(truth["layout"]))
|
||||
folder = tempfile.mkdtemp(prefix="acadmcp_scale_")
|
||||
positions = [(0, 0), (4000, 0), (8000, 0), (0, 3000), (4000, 3000), (8000, 6000)]
|
||||
paths = []
|
||||
for factor in (1.0, 2.0):
|
||||
doc = ezdxf.new("R2018")
|
||||
msp = doc.modelspace()
|
||||
for number, (x, y) in enumerate(positions, start=1):
|
||||
msp.add_circle((x * factor, y * factor), 500.0)
|
||||
msp.add_text(f"T{number}", dxfattribs={"insert": (x * factor, y * factor)})
|
||||
path = os.path.join(folder, f"x{factor:g}.dxf")
|
||||
doc.saveas(path)
|
||||
paths.append(path)
|
||||
control = scale_check(read_snapshot(paths[1]), read_snapshot(paths[0]))
|
||||
return (
|
||||
pair["verdict"] == "schematic"
|
||||
and control["verdict"] == "to_scale"
|
||||
and abs(float(control["factor"]) - 2.0) < 1e-9
|
||||
)
|
||||
|
||||
|
||||
async def pipe_takeoff_rmst_exact():
|
||||
"""Every routable pipe run's layout length is the rectilinear MST of its tags.
|
||||
|
||||
Measured on the synthetic pair with ``length_source="layout"``. The truth
|
||||
is the generator's own Manhattan / Prim arithmetic over the tag positions it
|
||||
placed, hand-checked in tests/test_plant_pair.py - PR1 5500 (5000 + 500),
|
||||
PR2 13200 (the three distances 6000 / 7200 / 13200, spanning tree 6000 +
|
||||
7200), CS1 20700 (20500 + 200). All eight routable runs must agree to a
|
||||
micron; the run ending on T105, which the layout lacks, is not routable.
|
||||
"""
|
||||
from engineering.understand.network import build_network
|
||||
from engineering.understand.snapshot import read_snapshot
|
||||
from engineering.understand.takeoff import pipe_rows
|
||||
|
||||
truth = _plant_pair()
|
||||
pid, layout = read_snapshot(truth["pid"]), read_snapshot(truth["layout"])
|
||||
services = tuple(sorted({run["service"] for run in truth["pipe_runs"]}))
|
||||
result = pipe_rows(build_network(pid), pid, layout, services=services, length_source="layout")
|
||||
found = {(r["service"], tuple(sorted(r["tags"]))): r for r in result["runs"]}
|
||||
routable = [run for run in truth["pipe_runs"] if run["layout_length_mm"] is not None]
|
||||
for run in routable:
|
||||
got = found.get((run["service"], tuple(run["tags"])))
|
||||
if got is None or got.get("layout_m") is None:
|
||||
return False
|
||||
if abs(float(got["layout_m"]) * 1000.0 - run["layout_length_mm"]) > 1e-6:
|
||||
return False
|
||||
return len(routable) == 8
|
||||
|
||||
|
||||
async def cable_takeoff_roundup():
|
||||
"""Cable rows: the panel from the callout, Manhattan length, metres rounded up.
|
||||
|
||||
The truth rows of the synthetic pair, by hand: M11 (5500, 8200) to CP-1
|
||||
(1200, 10800) is 6900 mm, 6.9 m x 1.2 = 8.28 -> 9; T102 (8000, 3500) to CP-1
|
||||
is 14100 mm -> 16.92 -> 17; M12 (26300, 8400) to CP-2 (35000, 1300) is
|
||||
15800 mm -> 18.96 -> 19; T202 (28700, 3000) to CP-2 is 8000 mm -> 9.6 -> 10.
|
||||
The heater on T202 states no power, and its row must keep an empty one.
|
||||
"""
|
||||
from engineering.understand.snapshot import read_snapshot
|
||||
from engineering.understand.takeoff import cable_rows
|
||||
|
||||
truth = _plant_pair()
|
||||
rows = {
|
||||
row["tag"]: row
|
||||
for row in cable_rows(read_snapshot(truth["pid"]), read_snapshot(truth["layout"]))["rows"]
|
||||
}
|
||||
expected = {"M11": ("CP-1", 9), "T102": ("CP-1", 17), "M12": ("CP-2", 19), "T202": ("CP-2", 10)}
|
||||
for tag, (panel, metres) in expected.items():
|
||||
row = rows.get(tag)
|
||||
if row is None or row.get("panel") != panel or row.get("cable_m") != metres:
|
||||
return False
|
||||
return rows["T202"].get("kw") is None and rows["M11"].get("kw") == 5.5
|
||||
|
||||
|
||||
async def diff_detects_known_edits():
|
||||
"""Two revisions differing by exactly five edits - and the diff says so.
|
||||
|
||||
A move (a LINE shifted 10 mm), a text change (PUMP A -> PUMP B), an
|
||||
attribute change (TAG T101 -> T102), an add (a new LINE) and a delete (a
|
||||
LINE removed); a CIRCLE stays untouched. The edits are made on a copy read
|
||||
back from the first file, so every surviving entity keeps its handle. The
|
||||
diff must list the added line as added, the removed line as removed, the
|
||||
three edited entities as changed - and nothing else, the circle included.
|
||||
"""
|
||||
import json
|
||||
|
||||
import ezdxf
|
||||
|
||||
from engineering.understand.diff import diff_snapshots
|
||||
from engineering.understand.snapshot import read_snapshot
|
||||
|
||||
folder = tempfile.mkdtemp(prefix="acadmcp_diff_")
|
||||
old_path, new_path = os.path.join(folder, "rev_a.dxf"), os.path.join(folder, "rev_b.dxf")
|
||||
doc = ezdxf.new("R2018")
|
||||
msp = doc.modelspace()
|
||||
moved = msp.add_line((0, 0), (100, 0), dxfattribs={"layer": "PIPE"})
|
||||
text = msp.add_text("PUMP A", dxfattribs={"insert": (0, 50), "height": 2.5})
|
||||
block = doc.blocks.new("TAGGED")
|
||||
block.add_circle((0, 0), 10)
|
||||
block.add_attdef("TAG", (0, 0), dxfattribs={"height": 2.5})
|
||||
insert = msp.add_blockref("TAGGED", (200, 0))
|
||||
insert.add_auto_attribs({"TAG": "T101"})
|
||||
gone = msp.add_line((0, 100), (100, 100), dxfattribs={"layer": "PIPE"})
|
||||
kept = msp.add_circle((300, 300), 20)
|
||||
doc.saveas(old_path)
|
||||
|
||||
revision = ezdxf.readfile(old_path)
|
||||
revision.entitydb[moved.dxf.handle].translate(10, 0, 0)
|
||||
revision.entitydb[text.dxf.handle].dxf.text = "PUMP B"
|
||||
revision.entitydb[insert.dxf.handle].get_attrib("TAG").dxf.text = "T102"
|
||||
revision.modelspace().delete_entity(revision.entitydb[gone.dxf.handle])
|
||||
added = revision.modelspace().add_line((500, 500), (600, 500), dxfattribs={"layer": "PIPE"})
|
||||
revision.saveas(new_path)
|
||||
|
||||
diff = diff_snapshots(read_snapshot(old_path), read_snapshot(new_path))
|
||||
changed = json.dumps(diff["changed"])
|
||||
dumped = changed + json.dumps(diff["added"]) + json.dumps(diff["removed"])
|
||||
return (
|
||||
len(diff["added"]) == 1
|
||||
and len(diff["removed"]) == 1
|
||||
and len(diff["changed"]) == 3
|
||||
and _quoted(added.dxf.handle) in json.dumps(diff["added"])
|
||||
and _quoted(gone.dxf.handle) in json.dumps(diff["removed"])
|
||||
and all(_quoted(e.dxf.handle) in changed for e in (moved, text, insert))
|
||||
and _quoted(kept.dxf.handle) not in dumped
|
||||
)
|
||||
|
||||
|
||||
async def topology_known_defects():
|
||||
"""Dangling ends, a near miss and a crossing, placed on purpose - and nothing else.
|
||||
|
||||
On layer PIPE: a closed 1000 x 1000 square (no free end, no defect); a LINE
|
||||
from (500, -500) that stops at (500, -5), 5 mm short of the square's bottom
|
||||
edge (a near miss at gap 10, and a dangling end); two LINEs crossing at
|
||||
(2500, 500), away from all four of their ends (one interior crossing); and a
|
||||
clean T - a LINE ending on the middle of another at (4500, 0), which is a
|
||||
junction, not a crossing and not a near miss.
|
||||
"""
|
||||
import json
|
||||
|
||||
import ezdxf
|
||||
|
||||
from engineering.understand.snapshot import read_snapshot
|
||||
from engineering.understand.topology import topology_findings
|
||||
|
||||
doc = ezdxf.new("R2018")
|
||||
msp = doc.modelspace()
|
||||
pipe = {"layer": "PIPE"}
|
||||
ring = msp.add_lwpolyline(
|
||||
[(0, 0), (1000, 0), (1000, 1000), (0, 1000)], close=True, dxfattribs=pipe
|
||||
)
|
||||
short = msp.add_line((500, -500), (500, -5), dxfattribs=pipe)
|
||||
cross_a = msp.add_line((2000, 0), (3000, 1000), dxfattribs=pipe)
|
||||
cross_b = msp.add_line((2000, 1000), (3000, 0), dxfattribs=pipe)
|
||||
bar = msp.add_line((4000, 0), (5000, 0), dxfattribs=pipe)
|
||||
stem = msp.add_line((4500, 0), (4500, 500), dxfattribs=pipe)
|
||||
path = os.path.join(tempfile.mkdtemp(prefix="acadmcp_topo_"), "topology.dxf")
|
||||
doc.saveas(path)
|
||||
|
||||
found = topology_findings(read_snapshot(path), layers=["PIPE"], gap=10.0)
|
||||
near = json.dumps(found["near_miss"])
|
||||
crossing = json.dumps(found["crossing"])
|
||||
dangling = json.dumps(found["dangling"])
|
||||
return (
|
||||
_quoted(short.dxf.handle) in near
|
||||
and _quoted(short.dxf.handle) in dangling
|
||||
and _quoted(ring.dxf.handle) not in dangling
|
||||
and _quoted(ring.dxf.handle) not in crossing
|
||||
and len(found["crossing"]) == 1
|
||||
and _quoted(cross_a.dxf.handle) in crossing
|
||||
and _quoted(cross_b.dxf.handle) in crossing
|
||||
and all(_quoted(e.dxf.handle) not in crossing + near for e in (bar, stem))
|
||||
)
|
||||
|
||||
|
||||
CHECKS = {
|
||||
"core_line_length": (core_line_length, "Core"),
|
||||
"core_circle_radius": (core_circle_radius, "Core"),
|
||||
@@ -862,6 +1070,11 @@ CHECKS = {
|
||||
"arch_room_area_net": (arch_room_area_net, "Architecture"),
|
||||
"arch_opening_cuts_wall": (arch_opening_cuts_wall, "Architecture"),
|
||||
"arch_rooms_detect_foreign": (arch_rooms_detect_foreign, "Architecture"),
|
||||
"scale_check_detects_schematic": (scale_check_detects_schematic, "Understanding"),
|
||||
"pipe_takeoff_rmst_exact": (pipe_takeoff_rmst_exact, "Understanding"),
|
||||
"cable_takeoff_roundup": (cable_takeoff_roundup, "Understanding"),
|
||||
"diff_detects_known_edits": (diff_detects_known_edits, "Understanding"),
|
||||
"topology_known_defects": (topology_known_defects, "Understanding"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from benchmarks.tasks_v6 import TASKS_V6
|
||||
from benchmarks.tasks_v7 import TASKS_V7
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_INPUT_DIR = ROOT / "benchmarks" / "results" / "published"
|
||||
@@ -66,8 +66,8 @@ def render_chart(reports: list[dict[str, Any]], output: Path) -> None:
|
||||
|
||||
matplotlib.rcParams["svg.hashsalt"] = "autocad-mcp-taskmatrix-v1"
|
||||
|
||||
task_ids = [task.task_id for task in TASKS_V6]
|
||||
task_labels = [f"{task.task_id} ({task.category})" for task in TASKS_V6]
|
||||
task_ids = [task.task_id for task in TASKS_V7]
|
||||
task_labels = [f"{task.task_id} ({task.category})" for task in TASKS_V7]
|
||||
servers = [report["adapter"] for report in reports]
|
||||
status_by_server = {
|
||||
report["adapter"]: {item["task_id"]: item["status"] for item in report["results"]}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"summary": {
|
||||
"baseline_ref": "v1.5.1",
|
||||
"total": 43,
|
||||
"total": 48,
|
||||
"old_pass": 26,
|
||||
"new_pass": 43,
|
||||
"old_pct": 60.5,
|
||||
"new_pass": 48,
|
||||
"old_pct": 54.2,
|
||||
"new_pct": 100.0,
|
||||
"fixed": 17,
|
||||
"fixed": 22,
|
||||
"regressed": 0
|
||||
},
|
||||
"rows": [
|
||||
@@ -267,6 +267,36 @@
|
||||
"category": "Architecture",
|
||||
"old": "miss",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "scale_check_detects_schematic",
|
||||
"category": "Understanding",
|
||||
"old": "miss",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "pipe_takeoff_rmst_exact",
|
||||
"category": "Understanding",
|
||||
"old": "miss",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "cable_takeoff_roundup",
|
||||
"category": "Understanding",
|
||||
"old": "miss",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "diff_detects_known_edits",
|
||||
"category": "Understanding",
|
||||
"old": "miss",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "topology_known_defects",
|
||||
"category": "Understanding",
|
||||
"old": "miss",
|
||||
"new": "pass"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -16,7 +16,7 @@ from pathlib import Path
|
||||
|
||||
from backends.capability import UnsupportedCapabilityError
|
||||
from benchmarks.adapters.base import BenchmarkAdapter, TaskResult
|
||||
from benchmarks.tasks_v6 import DEFAULT_MATRIX, MATRICES, TaskSpec, task_by_id
|
||||
from benchmarks.tasks_v7 import DEFAULT_MATRIX, MATRICES, TaskSpec, task_by_id
|
||||
|
||||
|
||||
def _load_registry() -> dict[str, dict]:
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""The v7 task matrix: v6 plus what track H added.
|
||||
|
||||
Track H teaches the server to read drawings somebody else made, so its two
|
||||
tasks are gated on a drawing pair whose answers are known in advance - the
|
||||
synthetic plant pair of ``tests/fixtures/plant_pair.py``, which reproduces
|
||||
every field finding of the design spec with invented names:
|
||||
|
||||
* ``takeoff_roundtrip`` - the scale check calls the P&ID ``schematic``; every
|
||||
routable pipe run's layout length equals the rectilinear minimum spanning
|
||||
tree of its tags exactly; its size, service and status match the generator's
|
||||
truth; the cable rows equal the Manhattan lengths from each load to its panel
|
||||
with the allowance rounded up to the metre.
|
||||
* ``understand_foreign`` - one ``describe`` of the layout flags inches declared
|
||||
over millimetre geometry, finds the stray entity by handle and separates the
|
||||
two plan copies; one ``describe`` of the P&ID classifies its English and
|
||||
Dutch service layers.
|
||||
|
||||
The published competitor reports were never asked these questions; the chart
|
||||
shows ``not_run`` for them, not zero.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from benchmarks.tasks_v2 import TASKS_V2
|
||||
from benchmarks.tasks_v3 import TASKS_V3
|
||||
from benchmarks.tasks_v4 import TASKS_V4
|
||||
from benchmarks.tasks_v5 import TASKS_V5
|
||||
from benchmarks.tasks_v6 import TASKS_V6, TaskSpec
|
||||
|
||||
#: Added in v1.6.0 by track H.
|
||||
NEW_TASKS_V7: tuple[TaskSpec, ...] = (
|
||||
TaskSpec(
|
||||
"takeoff_roundtrip",
|
||||
"plant",
|
||||
"Take pipe and cable quantities off a schematic P&ID and its layout: the P&ID "
|
||||
"called schematic, every run's layout length equal to the rectilinear MST of "
|
||||
"its tags, sizes and services as drawn, cable metres rounded up after the "
|
||||
"allowance",
|
||||
1.0,
|
||||
),
|
||||
TaskSpec(
|
||||
"understand_foreign",
|
||||
"understand",
|
||||
"Read a foreign layout in one call: inches declared over millimetres flagged, "
|
||||
"the stray entity found, both plan copies separated, the service layers of its "
|
||||
"P&ID classified",
|
||||
1.0,
|
||||
),
|
||||
)
|
||||
|
||||
TASKS_V7: tuple[TaskSpec, ...] = TASKS_V6 + NEW_TASKS_V7
|
||||
|
||||
#: Runner-selectable task sets. v2 to v6 stay addressable so an earlier report
|
||||
#: can be reproduced exactly rather than only described.
|
||||
MATRICES: dict[str, tuple[TaskSpec, ...]] = {
|
||||
"v2": TASKS_V2,
|
||||
"v3": TASKS_V3,
|
||||
"v4": TASKS_V4,
|
||||
"v5": TASKS_V5,
|
||||
"v6": TASKS_V6,
|
||||
"v7": TASKS_V7,
|
||||
}
|
||||
|
||||
DEFAULT_MATRIX = "v7"
|
||||
|
||||
|
||||
def task_by_id(task_id: str, matrix: str = DEFAULT_MATRIX) -> TaskSpec:
|
||||
for task in MATRICES[matrix]:
|
||||
if task.task_id == task_id:
|
||||
return task
|
||||
raise KeyError(f"{task_id} is not in matrix {matrix}")
|
||||
Reference in New Issue
Block a user