bench: matrix v5 adds mech_assembly; the correctness suite grows with the mechanical and sheet checks

The evidence for tracks B and G, in the shape tracks A and E used.

- benchmarks/tasks_v5.py: 18 tasks, DEFAULT_MATRIX = "v5"; v4, v3 and v2 stay
  addressable so an earlier report reproduces exactly rather than being only
  described. mech_assembly is roadmap criterion 3 - a flange-coupling sheet
  built from the part model and the sheet standard, through the code the
  tools call (apply_titleblock, draw_part/add_view/dimension_part,
  insert_std_part, extract_records -> draw_bom_table, add_balloon), gated on
  two numbers that cannot be negotiated: drawing_critique(focus=None) returns
  zero issues and the finalize score is at least 90 (it scores 100, 18/18 on
  the v5 matrix). The adapter returns the focuses that fired, so a
  regression names itself, and a test drops one balloon to prove the gate
  fails and names mech_bom_balloon_mismatch.
- Seven correctness checks (32 -> 39). mech_section_hatch_area is the one
  that cannot be faked: a sleeve of length 60, outside diameter 40 and bore
  20 cuts two faces totalling 1200 mm^2 exactly, read back out of the drawing
  through entity_measure. sheet_frame_iso5457_a3 pins the A3 frame (20,10 to
  410,287 inside the 420 x 297 trimmed sheet); mech_iso286_on_dimension pins
  40 H7 at +0.025/0 through the layout; std_part_iso4014_m12 pins s = 18 and
  k = 7.5 by value; mech_thread_unrepresented_is_caught is the seventh: a
  quality gate that never fires is not a gate.
- scripts/smoke_mech_com.py executed every new COM path once on AutoCAD 2026
  (25.1s): frame, title block, a sectioned shaft with 12 dimensions, an ISO
  4014 block with 1 ACADMCP_MECH chunk, a weld symbol, the parts list read off
  the drawing, a linked balloon, an xref attached / listed / detached, a
  31687-byte .dwg (AC1032) through ActiveX SaveAs, critique 0 issues, exit 0,
  every scratch document closed. It retries RPC_E_CALL_REJECTED (measured:
  the first call after Documents.Add is refused) and tracks its documents by
  the document list, since SaveAs renames them.
- Defects the evidence found, each fixed with a test that fails on the old
  code:
  * ezdxf dimension_diameter put the text beyond the SECOND chord point;
    ActiveX AddDimDiametric puts it beyond the first (measured live: a d50
    from (x,170) to (x,220), leader 10, text at (x,160)). Headlessly every
    front-view diameter stood on its own chain dimension. ezdxf now follows
    ActiveX.
  * draw_part's anchor goes on MECH; ActiveX refuses entity.Layer on a
    missing layer (measured -0x7ffdfff7 'Key not found'). draw.py ensures
    every target layer before the first entity.
  * bom.extract_records filtered on INSERT; ActiveX names it BLOCKREFERENCE
    (ObjectName AcDbBlockReference) - the live parts list was empty.
  * COM _entity_info reported no dimension text_position: ModelSpace.Item
    narrows to IAcadEntity, which has no TextPosition (measured
    AttributeError); read through _com_unnarrow (TextPosition measured on
    AcDbDiametricDimension, WCS). dim_overlap compared bbox centres and a
    diameter collided with its own bore.
  * pid graph raised KeyError 'start' mid-critique on a LINE whose point read
    AutoCAD refused while busy; such a line is left out.
  * the validator read "ISO 4014 - M12x60" as fake dimension text.
- The published A/B report is re-run over the grown suite rather than
  re-typed (26/39 -> 39/39, 13 fixed, 0 regressed), check_doc_numbers derives
  the task count from the v5 matrix, the token table is remeasured with a
  TOOL_PACKS=core,mech row (193 tools / 61,673 tokens), and the README's
  collected-tests figure is retaken (3990).

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:
Umut CAN
2026-09-23 13:30:02 +03:00
co-authored by Claude Opus 5.5
parent 3b033033f9
commit 898e775f93
30 changed files with 1355 additions and 50 deletions
+104
View File
@@ -21,6 +21,14 @@ views and UCS, and the live-only group (launch, preferences, document
properties, operator prompts) refused honestly headlessly. Spec:
`docs/superpowers/specs/2026-09-16-v1.6-settings-design.md`.
Tracks B and G of 1.6: the complete mechanical 2D part drawer and the sheet
standard. 25 tools in four new sections — mechanical parts (21), standard
parts (22), mechanical annotation (23) and sheet and delivery (24) — one part
model and one view engine instead of a zoo of per-part generators, a new
`mech` pack, six `mech_*` critique focuses and four new contract members
(`xref_attach`, `xref_manage`, `image_attach`, `drawing_export_dwg`). Spec:
`docs/superpowers/specs/2026-09-22-v1.6-mech-design.md`.
### Added
- **Styles — 10 tools, pack `core`.** `dimstyle_list/create/modify/set_current`,
@@ -74,6 +82,28 @@ properties, operator prompts) refused honestly headlessly. Spec:
`v1.5.1`: **26 / 32 → 32 / 32, six `miss → pass`, zero regressed**.
`scripts/smoke_settings_com.py` runs the track once against the live seat
(record below).
- **Benchmark evidence for tracks B and G.** `benchmarks/tasks_v5.py` (18
tasks, the default matrix; v4 / v3 / v2 stay addressable) adds
`mech_assembly`: a flange-coupling A3 sheet built through the code the tools
call — frame and title block, a hub with a full section and chain
dimensions, a flange with its bolt-circle end view, ISO 4014 bolts and ISO
4032 nuts, the parts list read off the drawing and linked balloons — gated on
`drawing_critique(focus=None)` returning zero issues and a finalize score of
at least 90 (it scores 100); a test drops one balloon and watches the gate
fail by name. The correctness suite grows to 39 with `mech_part_roundtrip`,
`mech_section_hatch_area` (1200 mm² read back out of the drawing),
`mech_iso286_on_dimension`, `mech_thread_unrepresented_is_caught`,
`std_part_iso4014_m12`, `sheet_frame_iso5457_a3` and `bom_balloon_link`.
A/B against `v1.5.1`: **26 / 39 → 39 / 39, thirteen `miss → pass`, zero
regressed**. The token suite measures a `TOOL_PACKS=core,mech` row: 193
tools / 61,673 tokens against 224 / 72,163 by default.
- **Live COM smoke.** `scripts/smoke_mech_com.py` executed once on AutoCAD
2026 (`25.1s (LMS Tech)`): the A3 frame and ISO 7200 title block, a two-step
shaft with a full section and 12 chain dimensions, an ISO 4014 M12x60 block
carrying 1 ACADMCP_MECH chunk, an ISO 7573 parts list read off the drawing
with its linked balloon, an xref attached and detached, and a real
31687-byte `.dwg` written by ActiveX `SaveAs` — `drawing_critique(focus=None)`
returned 0 issues (record below).
- **P&ID — 9 tools, pack `pid`.**
- `pid_symbol_list` / `pid_symbol_insert`: a 43-symbol ISO 10628-2 /
@@ -153,6 +183,33 @@ properties, operator prompts) refused honestly headlessly. Spec:
### Fixed
- **Found by the tracks B + G evidence** (each with a test that fails on the
old code):
- `dimension_diameter` put its text beyond the *second* chord point
headlessly and beyond the *first* on AutoCAD (`AddDimDiametric`'s
ChordPoint, measured: a ⌀50 from (x, 170) to (x, 220) annotated at
(x, 160)). The two engines annotated opposite sides of the same part, and
headlessly every front-view diameter of `mech_part_draw` stood on its own
chain dimension — `dim_overlap` fired on any single-step disk. The
headless engine now follows ActiveX.
- On a live seat `mech_part_draw` died after drawing every view: its anchor
POINT goes on `MECH`, and ActiveX refuses `entity.Layer` on a missing
layer (`Key not found`) where ezdxf creates it. Every mechanical draw
path now ensures its target layers before the first entity.
- `bom_extract` returned nothing live: ActiveX names a block reference
`BLOCKREFERENCE`, and the parts list filtered on `INSERT` only. Both
names are read.
- The live `_entity_info` reported no `text_position` for a dimension (the
entity arrives narrowed to `IAcadEntity`, which has no `TextPosition`),
so `dim_overlap` compared bounding-box centres — a diameter and its bore
read "0.00 mm apart". It is read through the concrete class now.
- `pid_graph` raised `KeyError: 'start'` inside `drawing_critique(focus=None)`
on a LINE whose point read AutoCAD had refused while busy; such a line is
left out instead.
- The validator's `fake_dimension_text` read a standard-part designation
(`ISO 4014 - M12x60`) as a hand-typed dimension, so every parts list
drawn as text cost its sheet a warning. The designation is exempt; a
measurement beside it still flags.
- **Track A hardening** (`tests/test_track_a_hardening.py`; the remaining
review findings are filed with a disposition each in
`docs/analysis/track-a-review-backlog.md`):
@@ -716,6 +773,53 @@ The pick path was executed live earlier in the track (Task 23's measurement:
`GetPoint` answers in WCS while `GetEntity` answers in the current UCS). Each
plot also dropped AutoCAD's `plot.log` into the repo root; it is ignored now.
### Live COM smoke — mechanical parts and the sheet (tracks B + G)
`scripts/smoke_mech_com.py`, AutoCAD 2026 (`25.1s (LMS Tech)`), Windows 11,
2026-09-23, in new documents only (an xref source saved and closed first, then
the sheet) — `apply_layer_set("mech")` → `apply_titleblock` (A3, zones, marks)
→ `draw_part` → `add_view(section)` → `dimension_part` → `insert_std_part` →
`weld_symbol_prims` → `extract_records` → `draw_bom_table` → `add_balloon` →
`xref_attach` / `xref_manage("list")` / `xref_manage("detach")` →
`run_critique(focus=None)` → `drawing_export_dwg("R2018")`, all through the COM
engine, exit 0, every scratch document closed afterwards:
```json
{
"autocad": "25.1s (LMS Tech)",
"part_id": "103",
"views": 2,
"section_omitted": [],
"dimensions": 12,
"bolt_handle": "2F7",
"xdata_chunks": 1,
"parts_list_rows": 1,
"parts_list_representation": "native",
"balloon": "31A",
"xref_attached": "XREF_SOURCE",
"xref_listed": 1,
"xref_detached": true,
"xrefs_after_detach": 0,
"dwg": {
"path": "%TEMP%\\mech_smoke_*\\mech_smoke.dwg",
"exists": true,
"bytes": 31687,
"magic": "AC1032"
},
"critique_issues": 0,
"critique": []
}
```
The first runs found the four live defects listed under **Fixed** (the `MECH`
layer, `BLOCKREFERENCE`, the dimension `text_position`, the refused LINE read)
and one harness fault: the first call after `Documents.Add` can be refused with
`RPC_E_CALL_REJECTED` while the new document initialises, and a refused
cleanup then left the scratch document open. The smoke retries refused calls
for up to 90 s (a refused call was not executed) and tracks its documents by
the document list rather than by an assumed name, because `SaveAs` renames the
document it writes.
## [1.5.1] — 2026-08-06
A patch for three defects found *after* 1.5.0 went to PyPI, two of them by the
+13 -4
View File
@@ -92,10 +92,14 @@ Tools are organized into sections (counts are indicative — `system_about` is a
18. Styles (10 tools, pack `core`): `dimstyle_list/create/modify/set_current` (authored ISO-25 / ANSI presets from `engineering/standards/dimstyles.py`; `dimstyle_modify` accepts only `DIM_VARIABLE_WHITELIST` names (33) and refuses out-of-range values by name before writing; `changed` lists only variables that moved, and `rerender_required: true` says a headless DIMENSION keeps its rendered block until redrawn), `textstyle_list/create/set_current` (`font_resolved: false` is reported, never refused — DXF stores only the name), `mleaderstyle_list/create` (both engines: ezdxf on `doc.mleader_styles`, live through the `ACAD_MLEADERSTYLE` dictionary — ActiveX has no MLeaderStyle *collection*, which is not an engine boundary, so no `mleaderstyle` capability key exists), and `drawing_apply_standard("iso"|"ansi")` — dimstyle + text style + units + the `mech` layer set in one call, each item reporting `created` or already present.
19. Page setup & templates (6 tools, pack `core`): `page_setup_list/apply` (ISO 216 / ANSI Y14.1 sizes from `PAPER_SIZES`; an unknown ctb is written and reported `plot_style_known: false`), `plot_style_list` (catalog + the files actually installed on a live seat), `batch_plot` (every paper layout through `drawing_export_pdf(layout=)`; `mediabox_mm` is **read back from the PDF's own `/MediaBox`**, so the sheet size is verified, not assumed), `drawing_template_list` (the five bundled templates: `iso_a3_mech`, `iso_a1_arch`, `iso_a3_pid`, `ansi_b_mech`, `ansi_d_arch` — DXF headless, `.dwt` twin on COM, `scripts/build_templates.py --check` proves the DXFs reproducible), `drawing_template_save` (a `.dwt` request headlessly is refused with capability `dwt_write` naming the DXF route). `drawing_new(template=)` resolves a catalog name to the bundled file and reports `template: {name, source}`.
20. Environment (22 tools, pack `settings`): documents `document_list/activate/close` (both engines — the headless backend keeps a document registry; `document_close` refuses to discard unsaved work unless `discard=True`), layer states `layer_state_save/restore/list/delete` (portable: JSON chunks in an `ACADMCP_LAYERSTATES` XRECORD that travels with the file and **does not appear in AutoCAD's Layer States Manager** — `system_capabilities.layer_states` says so), named views `view_named_save/restore/list` (headless restore writes `$VIEWCTR/$VIEWSIZE` and reports `applied: "header_only"`), UCS `ucs_list/set/restore` (non-orthogonal axes refused with the measured angle; **tool coordinates stay WCS** — a UCS is stored for the operator, no tool interprets input in it), `system_variable_describe` (the 90-entry `SYSVAR_CATALOG`; an unknown name answers `known: false` plus nearest names, never an error; `system_set_variable` now refuses an out-of-range value for a catalogued name before writing), `drawing_properties_get/set` (custom properties on both engines through `$CUSTOMPROPERTYTAG`; the five summary fields are COM-only, capability `dwgprops`), and the live-only group `system_launch` (`live_application`), `system_preferences_get/set` (`preferences`; a whitelist of `Preferences.*` keys with ranges, read-only keys refused by name, writes report `old`/`new`), `user_pick_point/select`, `system_prompt_message` (`interactive_prompt`; a cancelled pick returns `{cancelled: true}`, a `COM_CALL_TIMEOUT` overrun reports `timed_out`). The COM paths were executed live once by `scripts/smoke_settings_com.py` (AutoCAD 2026), which also built the `.dwt` twins.
21. Mechanical parts (6 tools, pack `mech`): `mech_part_draw`, `mech_view_add`, `mech_dimension_part`, `mech_hole_pattern`, `mech_part_from_spec`, `mech_part_inspect`. One part model — a profile (`RevolvedPart` segments or a `PrismaticPart` outline) plus typed features — and one view engine that turns it into front / side / top / section / detail views with hidden lines, cut faces and ISO 129 dimensions. The model is written onto the drawing as `ACADMCP_MECH` XDATA, so `mech_view_add` can add a view months later without the caller re-describing the part. A feature that cannot be projected into a view is listed in `omitted: [{feature, reason}]` — never dropped.
22. Standard parts (3 tools, pack `mech`): `std_part_list`, `std_part_insert`, `std_feature_draw`. ISO 4014 / 4017 / 4032 / 7089 / 4762 fasteners, ISO 15 bearings with ISO 8826-1 representation, DIN 471/472 retaining-ring grooves, DIN 509 undercuts, DIN 332 centre holes and ISO 3601-2 O-ring grooves — authored tables, inserted as real blocks with attributes and `ACADMCP_MECH` XDATA, which is what makes the parts list a *read* of the drawing. A size outside a table's transcribed coverage is refused by name, never interpolated.
23. Mechanical annotation (5 tools, pack `mech`): `surface_texture` (ISO 21920-1, ISO 1302's older Ra form accepted), `weld_symbol` (ISO 2553), `centre_marks` (ISO 128-23), `section_line` (ISO 128-40 — it returns the plane `mech_view_add(kind="section")` consumes, so the label and the view cannot disagree), `hatch_material` (the ISO 128-50 map; an unknown material is refused with the list, never hatched as steel).
24. Sheet and delivery (11 tools, pack `core`): `sheet_frame` (ISO 5457 A4-A0 — 20 mm filing margin, 10 mm elsewhere on A3, zone grid, centring and trimming marks), `titleblock_apply` (ISO 7200 for every size; `titleblock_apply_iso_a3` is now a thin alias), `revision_add`, `bom_extract` (never modifies the drawing), `bom_table` (ISO 7573), `balloon_add` (ISO 6433, linked by XDATA so a rerun renumbers instead of duplicating), `data_extract` (CSV always; XLSX refused with `xlsx_write` when `openpyxl` is absent), `xref_attach`, `xref_manage`, `image_attach`, `drawing_export_dwg` (COM `SaveAs` natively; headless only with the ODA File Converter, otherwise `dwg_write` refuses and names the install route). The COM paths of SECTIONS 21-24 were executed live once by `scripts/smoke_mech_com.py` (AutoCAD 2026): part, section, dimensions, an ISO 4014 block with its XDATA, the parts list read off the drawing, a linked balloon, an xref attached and detached, a real `.dwg`, and `drawing_critique(focus=None)` clean.
**Tool profiles:** `TOOL_PROFILE=lean|full` (default `full`) controls the advertised surface — `lean` = 55 curated drafting tools (52 with `TOOL_PACKS=core`; the five settings essentials — `drawing_apply_standard`, `dimstyle_set_current`, `textstyle_set_current`, `page_setup_apply`, `batch_plot` — are core-pack tools and stay; including `cad_batch`, since a client with a tight tool cap is exactly the client paying most per turn). Applied in the lifespan; reported by `system_about`. The `core` profile was removed in v1.5.0 (the discovery layer replaced it); `TOOL_PROFILE=core` falls back to `full` with a warning.
**Tool profiles:** `TOOL_PROFILE=lean|full` (default `full`) controls the advertised surface — `lean` = 60 curated drafting tools (54 with `TOOL_PACKS=core`, which drops the three `pid_*` and the three `mech_*` lean members; the five settings essentials — `drawing_apply_standard`, `dimstyle_set_current`, `textstyle_set_current`, `page_setup_apply`, `batch_plot` — are core-pack tools and stay; including `cad_batch`, since a client with a tight tool cap is exactly the client paying most per turn). Applied in the lifespan; reported by `system_about`. The `core` profile was removed in v1.5.0 (the discovery layer replaced it); `TOOL_PROFILE=core` falls back to `full` with a warning.
**Tool packs:** `TOOL_PACKS=all|core,pid,settings` (default `all`) advertises only the vertical packs a client uses; `core` is always on and is everything no other pack claims; `settings` is the 22 SECTION 20 environment tools, `pid` the nine P&ID tools; SECTION 18/19 are core; unknown names are ignored with a warning; a `lean` profile intersects with the enabled packs. `system_about` reports `tool_packs`.
**Tool packs:** `TOOL_PACKS=all|core,pid,settings,mech` (default `all`) advertises only the vertical packs a client uses; `core` is always on and is everything no other pack claims; `settings` is the 22 SECTION 20 environment tools, `pid` the nine P&ID tools, `mech` the 14 SECTION 21-23 mechanical tools; SECTION 18/19/24 are core; unknown names are ignored with a warning; a `lean` profile intersects with the enabled packs. `system_about` reports `tool_packs`.
**Tool discovery:** `DISCOVERY_MODE=off|search` (default `off`). `search` replaces the advertised catalog with `search_tools` + `call_tool`, ranking hits over an AutoCAD command / synonym corpus (`discovery/aliases.py`) layered on fastmcp's BM25 index.
@@ -107,7 +111,7 @@ Tools are organized into sections (counts are indicative — `system_about` is a
**Measuring:** never read vertices back and shoelace them — that loses 28.2% of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry, and its payload states its own accuracy: `exact`, `flatten_tolerance`, `assumed_closed`, `self_intersecting`. A HATCH reports the area it *fills* (outer loops minus islands) plus `hatch_style`; on a curved hatch edge `flatten_tolerance` is not the accuracy knob, because ezdxf hands boundaries over as cubic Beziers whose ~0.028% circle error is already baked in. `boundary_trace` / `boundary_from_entities` report the same number `analysis_measure_entity` gives for the polyline they just drew — if those ever diverge, one of them is approximating and neither says so.
**Benchmark matrix:** `benchmarks/tasks_v4.py` is the current set (17 tasks; `--matrix v3` / `--matrix v2` reproduce the earlier sets). The published competitor reports are pinned v1.4 runs against v2 and are **never** back-filled for the five v3 tasks or the v4 `pid_roundtrip` / `page_setup_truth` — the chart shows them `not run`, not zero. `benchmarks/correctness_suite.py` (32 checks) is the release A/B gate: `python benchmarks/compare_versions.py v1.5.1` (the published report is `benchmarks/results/published/ab-v1.5.1-vs-v1.6.0-dev.json`; `scripts/check_doc_numbers.py` names it in `AB_REPORT`).
**Benchmark matrix:** `benchmarks/tasks_v5.py` is the current set (18 tasks; `--matrix v4` / `v3` / `v2` reproduce the earlier sets). The published competitor reports are pinned v1.4 runs against v2 and are **never** back-filled for the five v3 tasks, the v4 `pid_roundtrip` / `page_setup_truth` or the v5 `mech_assembly` (a flange-coupling sheet gated on a zero-issue `drawing_critique(focus=None)` and a finalize score of at least 90) — the chart shows them `not run`, not zero. `benchmarks/correctness_suite.py` (39 checks) is the release A/B gate: `python benchmarks/compare_versions.py v1.5.1` (the published report is `benchmarks/results/published/ab-v1.5.1-vs-v1.6.0-dev.json`; `scripts/check_doc_numbers.py` names it in `AB_REPORT`).
### Adding a New Tool
@@ -134,10 +138,15 @@ Tools are organized into sections (counts are indicative — `system_about` is a
- **Engineering layer scaffold**: `drawing_new` auto-bootstraps standard linetypes (CENTER, HIDDEN, PHANTOM) and engineering layers (GEOMETRY, DIM, CENTER, HIDDEN, PHANTOM, HATCH, TEXT, TITLEBLOCK). Pass `bootstrap=False` to opt out.
- **Generic contracts added in 1.6:** `block_define(name, entities, attdefs)` (typed primitives + ATTDEFs, both engines; `overwrite` replaces contents so INSERTs keep the name) and `entity_get/set_xdata` (typed by group code; 255-char strings, 16 KB per entity, enforced before writing).
- **P&ID reader rules:** `pid_graph` never modifies the drawing, reads geometry from the drawing (an INSERT's real rotation/scale) rather than the catalogue, and reports `source`/`confidence` per node (catalog 1.0, xdata 0.95, heuristic 0.6, inferred 0.3) — never raised by agreement between heuristics. Read `stats.confidence_min` before trusting a foreign drawing.
- **`TOOL_PACKS=all|core,pid,settings`** advertises only the packs a client uses; `core` is always on; `system_about` reports `tool_packs`.
- **`TOOL_PACKS=all|core,pid,settings,mech`** advertises only the packs a client uses; `core` is always on; `system_about` reports `tool_packs`.
- **Layer states are ours, not AutoCAD's.** `layer_state_*` stores a portable snapshot in an `ACADMCP_LAYERSTATES` XRECORD; it survives save/reopen on both engines and travels with the file, and it is not listed in AutoCAD's Layer States Manager. Said in the tool docstring, in `system_capabilities.layer_states` (`mode: "xrecord"`, reason `portable_acadmcp_xrecord;not_listed_in_autocad_layer_states_manager`) and in the README.
- **Page-setup evidence is the PDF.** `batch_plot` reports `mediabox_mm` parsed from the file's `/MediaBox` (`engineering/standards/pdfinfo.py`); a page setup is verified by what a printer would get, not by the setter's return value. A style or setting write reports `changed` — re-setting a value to itself is not a change.
- **Nothing interprets coordinates in UCS.** `ucs_set` / `ucs_restore` store and activate a UCS for the operator's benefit; every tool input and output stays WCS (the repository rule above).
- **The part model, not primitives**: a machine part is `mech_part_draw` with a segment list or an outline plus typed features — never hand-drawn lines. The same rule as gears and keyways, for the same reason.
- **Standards tables are transcribed, never derived**: every row under `engineering/mech/standards/` carries a `SOURCE` naming standard, edition and table, and a size outside the coverage is refused by name. `engineering/mech/standards/materials.py` states which of its rows are ISO 128-50, which are ASME Y14.2M via `acadiso.pat`, and which (concrete, wood) have no mechanical standard at all.
- **Six `mech_*` critique focuses** (`mech_missing_centreline`, `mech_unhatched_section`, `mech_view_misaligned`, `mech_duplicate_dimension`, `mech_thread_unrepresented`, `mech_bom_balloon_mismatch`) run under `focus=None` and inside `drawing_finalize`; they return nothing on a drawing with no `ACADMCP_MECH` payload. `mech_view_misaligned` checks the projection axis and not the quadrant, and `mech_duplicate_dimension` catches two dimensions in the same place rather than a semantic restatement — both narrowings are in the module docstring, because the payload carries no projection angle and no engine exposes what a dimension measured.
- **Live ActiveX will not create a layer on assignment** (`entity.Layer = "X"` on a missing layer raises `Key not found`; ezdxf creates it silently). Every mechanical/annotation draw path ensures its target layers before the first entity — a new path must do the same, or it passes every headless test and dies live.
- **A diameter's text stands beyond the FIRST chord point** on both engines (the ActiveX `AddDimDiametric` rule, measured); `dimension_diameter(x1, y1, x2, y2)` annotates on the `(x1, y1)` side.
- **Production drawings**: For real engineering output, use the `engineering/` package primitives via the `gear_*` / `keyway_*` / `titleblock_*` MCP tools — do NOT hand-draw teeth/keyways/sections with raw `entity_create_*` calls. Always end with `drawing_finalize` for the 8-step validator.
### Premium Drawing Rules
+19 -15
View File
@@ -27,22 +27,23 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
</div>
> **v1.5 release snapshot:** 229 tools · 8 resources · 5 prompt templates · 3976 collected tests.
> **v1.5 release snapshot:** 229 tools · 8 resources · 5 prompt templates · 3990 collected tests.
> 229 is the **registered** count; a default install advertises 224 over `tools/list`,
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
## Why this exists
**A big MCP server is expensive to be connected to.** The full catalog costs a client **59,315 tokens** before it has asked for anything. Discovery mode replaces it with two tools and costs **356**.
**A big MCP server is expensive to be connected to.** The full catalog costs a client **72,163 tokens** before it has asked for anything. Discovery mode replaces it with two tools and costs **356**.
**A drafter searches for `FILLET`, not `entity_fillet`.** Those command names appeared in no tool name or description — `df = 0` against a stock index, not badly ranked but *absent*. The fix was data: an authored corpus of **193 AutoCAD command names and 1019 synonym phrases** covering all 215 tools. A test refuses to let a tool exist without one.
**A drafter searches for `FILLET`, not `entity_fillet`.** Those command names appeared in no tool name or description — `df = 0` against a stock index, not badly ranked but *absent*. The fix was data: an authored corpus of **197 AutoCAD command names and 1158 synonym phrases** covering all 229 tools. A test refuses to let a tool exist without one.
| Advertised surface | Tools seen | Idle cost |
|---|---:|---:|
| `TOOL_PROFILE=full` (default) | 199 | 59,315 tokens |
| `TOOL_PACKS=core,settings` (full profile) | 190 | 55,593 tokens |
| `TOOL_PACKS=core` (full profile) | 168 | 48,825 tokens |
| `TOOL_PROFILE=lean` | 55 | 16,360 tokens |
| `TOOL_PROFILE=full` (default) | 224 | 72,163 tokens |
| `TOOL_PACKS=core,settings` (full profile) | 201 | 60,727 tokens |
| `TOOL_PACKS=core,mech` (full profile) | 193 | 61,673 tokens |
| `TOOL_PACKS=core` (full profile) | 179 | 53,959 tokens |
| `TOOL_PROFILE=lean` | 60 | 19,484 tokens |
| `DISCOVERY_MODE=search` | 2 | **356 tokens** |
> [!NOTE]
@@ -101,9 +102,11 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
| Geometry | lines, arcs, polylines, splines, hatches, trim/extend/fillet/chamfer, handle-preserving edits |
| Annotation | ISO 129 toleranced dimensions, ISO 286 fits (`fit="H7"`), TABLE, MLEADER, GD&T frames and datums (ISO 1101) |
| Engineering generators | involute gears (front + section A-A), DIN 6885 keyed bores, ISO A3 title block |
| Mechanical parts | one part model (segments or outline + typed features) and one view engine: front / side / top / section / detail with hidden lines, ISO 128-50 cut faces and ISO 129 dimensions; ISO 4014/4017/4032/7089/4762 fasteners, ISO 15 bearings, DIN 471/472, DIN 509, DIN 332 and ISO 3601-2 as real blocks with attributes; ISO 21920-1 surface texture, ISO 2553 welds, ISO 128-40 section lines |
| Sheet & delivery | ISO 5457 frames A4-A0 with zones and trim marks, ISO 7200 title blocks for every size, revision blocks with clouds, ISO 7573 parts lists with ISO 6433 balloons linked by XDATA, CSV/XLSX extraction, xrefs and images on both engines, real `.dwg` on a live seat |
| P&ID | catalogue blocks with ports and tags (ISO 10628-2 / ISA-5.1), port-to-port lines with ISA-5.1 classes and line numbers, `pid_graph` reads any P&ID back with confidence, instrument index / line list / equipment list, `pid_from_spec` one-call sheets |
| Styles & standards | ISO-25 / ANSI dimension styles, ISOCP / ROMANS text styles, ISO / ANSI leader styles from authored presets; `drawing_apply_standard("iso")` sets all of it plus units and the `mech` layers in one call; `changed` reports only what moved |
| Discovery | `search_tools` ranked over an AutoCAD command and synonym corpus — `FILLET`, `BPOLY`, `QSELECT`, `WBLOCK`, `OVERKILL`, `CHSPACE` each rank **#1** of the 199-tool advertised catalog |
| Discovery | `search_tools` ranked over an AutoCAD command and synonym corpus — `FILLET`, `BPOLY`, `QSELECT`, `WBLOCK`, `OVERKILL`, `CHSPACE` each rank **#1** of the 224-tool advertised catalog |
| Batching | `cad_batch` runs a step list in one round trip; `fields=` projects 11 result-heavy tools |
| Paper space | tab lifecycle, viewports, `entity_change_space` (CHSPACE), `page_setup_apply` (ISO 216 / ANSI Y14.1 paper, ctb, scale, device — on both engines), `batch_plot` with every sheet size read back from its PDF's `/MediaBox`, `drawing_export_pdf(layout=…)` |
| Page setup & templates | ISO 216 / ANSI Y14.1 sheets, ctb catalog, `batch_plot` verified by the PDF's own `/MediaBox`, five bundled templates built by the server's own tools and pinned reproducible (`drawing_new(template="iso_a3_mech")`, `iso_a1_arch`, `iso_a3_pid`, `ansi_b_mech`, `ansi_d_arch`), save any drawing as a template (`.dwt` on live AutoCAD, `dwt_write` refused headlessly) |
@@ -157,28 +160,29 @@ Self-measurement, produced by scripts in [`benchmarks/`](https://github.com/U-C4
### Correctness — every release re-proves itself
32 deterministic headless checks against the previous tag and the current tree, each in its own subprocess so a hard crash counts as a miss rather than killing the run.
39 deterministic headless checks against the previous tag and the current tree, each in its own subprocess so a hard crash counts as a miss rather than killing the run.
| Version | Checks passing | Pass rate | Fixed | Regressed |
|---|---:|---:|---:|---:|
| v1.5.1 *(baseline)* | 26 / 32 | 81.2 % | — | — |
| **v1.6.0-dev** *(this branch)* | **32 / 32** | **100 %** | 6 | **0** |
| v1.5.1 *(baseline)* | 26 / 39 | 66.7 % | — | — |
| **v1.6.0-dev** *(this branch)* | **39 / 39** | **100 %** | 13 | **0** |
The six fixed rows are new capability (`miss → pass`): the three P&ID checks of track A and, from track E, `settings_dimstyle_iso25_values` (the ISO-25 preset lands in the DIMSTYLE table with ISO 129-1's numbers), `settings_layer_state_roundtrip` (save → change → restore puts the layer table back, and the state survives save/reopen because it is an XRECORD in the file) and `settings_pdf_mediabox_a3` (the plotted PDF's own `/MediaBox` reads 420 × 297). The 26 checks v1.5.1 was gated on all still pass. Against the older `v1.4.0` baseline the same 26 reported **21 / 26 → 26 / 26, five fixed, zero regressed** — two of those were repaired defects (`fail → pass`), the diameter and radius callouts, which measured the leader as geometry and dimensioned a 40 mm bore as 60 at default settings.
The thirteen fixed rows are new capability (`miss → pass`). Tracks B and G add seven: `mech_part_roundtrip` (a part read back out of its own `ACADMCP_MECH` XDATA equals the part drawn), `mech_section_hatch_area` (a 60 x ⌀40 sleeve with a ⌀20 bore cuts 1200 mm² exactly, measured out of the drawing by `analysis_measure_entity`), `mech_iso286_on_dimension` (40 H7 is +0.025 / 0 and survives the layout), `mech_thread_unrepresented_is_caught` (the ISO 6410 focus fires on a thread drawn as a plain circle — a gate that never fires is not a gate), `std_part_iso4014_m12` (s = 18, k = 7.5, read by value), `sheet_frame_iso5457_a3` (the frame runs 20,10 to 410,287 inside the 420 × 297 sheet) and `bom_balloon_link` (two identical bolts are one row of quantity 2, and its balloon carries the row's item number). The other six are the three P&ID checks of track A and, from track E, `settings_dimstyle_iso25_values` (the ISO-25 preset lands in the DIMSTYLE table with ISO 129-1's numbers), `settings_layer_state_roundtrip` (save → change → restore puts the layer table back, and the state survives save/reopen because it is an XRECORD in the file) and `settings_pdf_mediabox_a3` (the plotted PDF's own `/MediaBox` reads 420 × 297). The 26 checks v1.5.1 was gated on all still pass. Against the older `v1.4.0` baseline the same 26 reported **21 / 26 → 26 / 26, five fixed, zero regressed** — two of those were repaired defects (`fail → pass`), the diameter and radius callouts, which measured the leader as geometry and dimensioned a 40 mm bore as 60 at default settings.
### The task matrix — six tasks that can fail
### The task matrix — tasks that can fail
An earlier matrix scored this server 10/10, which carried no information: every task in it exercised something the server was built around. Five were added in 1.5 because they *can* fail, and three did while being written; 1.6 adds two more — one the P&ID reader can fail on its own, one where the PDF file, not the setter, is the witness.
An earlier matrix scored this server 10/10, which carried no information: every task in it exercised something the server was built around. Five were added in 1.5 because they *can* fail, and three did while being written; 1.6 adds three more — one the P&ID reader can fail on its own, one where the PDF file, not the setter, is the witness, and one where a whole mechanical sheet has to come out clean.
| Task | Verified against |
|---|---|
| `tool_discovery` | six AutoCAD command names, each ranking #1 |
| `token_budget` | 59,315 → 356 tokens, against a ceiling fixed in advance |
| `token_budget` | 72,163 → 356 tokens, against a ceiling fixed in advance |
| `hatch_islands` | 300 filled with the island, 400 ignoring it |
| `selection_filter` | window 1, crossing 2, bounding box 3, polygon 1 |
| `measure_from_handle` | 139.2699 against the 100.0 a vertex shoelace gives |
| `pid_roundtrip` | the example sheet drawn by `pid_from_spec`, read back by `pid_graph`, which never sees the spec: 5 nodes, 4 edges, 0 dangling, `confidence_min` 1.0, `FIC-101` wired to `FCV-101` |
| `page_setup_truth` | ANSI B then ISO A3 landscape applied to Layout1, each plotted through `batch_plot` and read back from its PDF's `/MediaBox`: 432 × 279 then 420 × 297 mm, not the setter's return value (a fresh layout is already A3, so the B sheet is what a no-op setter cannot fake) |
| `mech_assembly` | a flange-coupling A3 sheet — hub with a full section, flange with its bolt-circle end view, two ISO 4014 bolts with ISO 4032 nuts, an ISO 7573 parts list read off the drawing, linked balloons, ISO 5457 frame, ISO 7200 title block — gated on `drawing_critique(focus=None)` returning **zero** issues and a finalize score of at least **90**; a test drops one balloon and watches the gate fail |
### Headless performance
+12
View File
@@ -1346,6 +1346,18 @@ def _entity_info(entity) -> EntityInfo:
geometry_bbox = _com_geometry_bbox(entity)
if geometry_bbox is not None:
props["geometry_bbox"] = geometry_bbox
elif "Dimension" in obj_name:
# ezdxf parity: where the dimension's text stands. Without it the
# dim_overlap critique fell back to the bounding-box centre, which
# for a diameter dimension is the circle's centre - so an outside
# diameter and its bore, whose texts stand 15 mm apart, read as
# "0.00 mm apart" (measured on AutoCAD 2026 by
# scripts/smoke_mech_com.py; TextPosition read live on
# AcDbDiametricDimension, WCS). ModelSpace.Item hands the entity
# over narrowed to IAcadEntity, which has no TextPosition - measured
# AttributeError - so it is read through the concrete class.
text = _com_unnarrow(entity).TextPosition
props["text_position"] = [text[0], text[1]]
except Exception as exc:
log.debug("Type-specific entity properties extraction failed: %s", exc)
+9 -1
View File
@@ -5550,7 +5550,15 @@ class EzdxfBackend(AutoCADBackend):
cx = (x1f + x2f) / 2
cy = (y1f + y2f) / 2
radius = math.sqrt((x2f - x1f) ** 2 + (y2f - y1f) ** 2) / 2
angle_rad = math.atan2(y2f - y1f, x2f - x1f)
# The text stands `leader_length` beyond the FIRST chord point, the
# ActiveX rule: AddDimDiametric(ChordPoint, FarChordPoint,
# LeaderLength) measured on AutoCAD 2026 put a 50 mm diameter drawn
# from (x, 170) to (x, 220) with its text at (x, 160). This engine
# used to put it beyond the second point, so the same call annotated
# opposite sides of a part on the two engines - and the mechanical
# drawer's front-view diameters landed on their own chain dimensions
# headlessly while clearing them live.
angle_rad = math.atan2(y1f - y2f, x1f - x2f)
leader = float(leader_length)
# The measured size comes from `radius`; `location` only places the
# text. This used to pass centre + (radius + leader) as `mpoint`,
+40 -9
View File
@@ -41,13 +41,13 @@ Unsupported tasks remain in the fixed-matrix denominator with score zero,
preventing partial implementations from receiving an inflated score.
```bash
python -m benchmarks.run_competitors --list --matrix v4
python -m benchmarks.run_competitors --server autocad-mcp-pro --backend ezdxf --matrix v4 --json
python -m benchmarks.run_competitors --list --matrix v5
python -m benchmarks.run_competitors --server autocad-mcp-pro --backend ezdxf --matrix v5 --json
python -m benchmarks.run_competitors --task table_mleader --task hatch_islands --json
```
The release-machine ezdxf self-check is **17/17 (100.0)** on the v4 matrix
(`--matrix v3` / `--matrix v2` reproduce the earlier sets).
The release-machine ezdxf self-check is **18/18 (100.0)** on the v5 matrix
(`--matrix v4` / `--matrix v3` / `--matrix v2` reproduce the earlier sets).
Repository stars and raw tool counts do not contribute to the score. Adapter
registration lives in `competitors.yaml`.
@@ -100,6 +100,23 @@ fail on their own:
The competitor reports carry no result for either, for the same reason they
carry none for the v3 five. `--matrix v3` reproduces the v1.5 set exactly.
### Matrix v5 (v1.6) — the mechanical sheet
`tasks_v5.py` keeps the v4 seventeen unchanged and adds roadmap criterion 3:
| Task | Category | Verified against |
|---|---|---|
| `mech_assembly` | mech | a flange-coupling A3 sheet built through the code the tools call — `apply_titleblock` (ISO 5457 frame with zones and marks, ISO 7200 title block), a hub drawn by `draw_part` with a full section by `add_view` and chain dimensions by `dimension_part`, a flange with its bolt-circle end view, two ISO 4014 M12x60 bolts and two ISO 4032 M12 nuts by `insert_std_part`, the ISO 7573 parts list *read off the drawing* by `extract_records` and drawn by `draw_bom_table`, and one balloon per row linked to that row's inserts by `add_balloon`. The sheet is saved, then gated on two numbers that cannot be negotiated: `run_critique(focus=None)` returns **zero** issues and `combine(validator, critique)` scores at least **90**. The focuses that fired come back in the metrics, and `tests/test_benchmark_v5.py` drops one balloon to prove the gate fails and names `mech_bom_balloon_mismatch` |
Nothing in the task tidies up after the tools: the view engine draws the ISO
128-23 centre lines of every circle it emits, so the sheet is clean because it
was drawn clean. Building it found four defects that every headless test had
missed — the headless diameter text on the wrong side of the chord, the
validator reading a fastener designation (`M12x60`) as a fake dimension, and on
the live seat a missing `MECH` layer and a parts list that could not see a
`BLOCKREFERENCE`; each is fixed with a test. The competitor reports carry no
result for it.
## Live competitor lane (v1.4)
Two competitor adapters now execute the exact same task matrix, black-box over
@@ -217,15 +234,29 @@ python benchmarks/compare_versions.py --json results.json
### Result — this branch (1.6.0-dev) vs v1.5.1 (release gate)
32 checks, ezdxf backend, one subprocess per check. Machine-readable report:
39 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 / 32 | 81.2 % | — | — |
| **v1.6.0-dev** (this branch) | 32 / 32 | 100 % | 6 | **0** |
| **v1.5.1** (baseline) | 26 / 39 | 66.7 % | — | — |
| **v1.6.0-dev** (this branch) | 39 / 39 | 100 % | 13 | **0** |
The six are all `miss → pass` — v1.5.1 has none of the methods: the three
The thirteen are all `miss → pass` — v1.5.1 has none of the methods. 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,
1200 mm² exactly, measured out of the drawing by `entity_measure` — never from
the code that drew it), `mech_iso286_on_dimension` (ISO 286-1: 40 H7 is
+0.025 / 0, and the fit survives `layout_dimensions`),
`mech_thread_unrepresented_is_caught` (the ISO 6410 focus fires on a thread
drawn as a plain circle and stops firing once the thin ¾ arc is there — the
mechanical twin of `dim_overlap_critique_fires`), `std_part_iso4014_m12` (ISO
4014:2011 table 1, M12: s = 18, k = 7.5, matched by value; the inserted block
carries its designation back), `sheet_frame_iso5457_a3` (the frame runs 20,10
to 410,287 inside the 420 × 297 trimmed sheet) and `bom_balloon_link` (two
identical bolts collapse into one row of quantity 2 with a stable item number,
and that row's balloon carries it). The other six are the three
P&ID checks track A added (`pid_block_define_attdef_roundtrip`,
`pid_tag_parse_fic`, `pid_graph_edge_count`) and the three settings checks of
track E (`settings_dimstyle_iso25_values`: the ISO-25 preset reaches the
@@ -234,7 +265,7 @@ through the lister; `settings_layer_state_roundtrip`: save → change → restor
puts the layer table back and the state survives save/reopen as an XRECORD in
the file; `settings_pdf_mediabox_a3`: the plotted PDF's own `/MediaBox` reads
420 × 297 mm). Every one of the 26 checks it was released on still passes, so
the two tracks added capability without moving a number it had already earned.
the four tracks added capability without moving a number it had already earned.
### Result — v1.5.1 vs v1.5.0
+170 -2
View File
@@ -1,4 +1,4 @@
"""Reference adapter for this repository, covering the v4 task matrix.
"""Reference adapter for this repository, covering the v5 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_v4 import TaskSpec
from benchmarks.tasks_v5 import TaskSpec
from engineering.delivery import deliver_drawing
from engineering.layers import ensure_engineering_layers, ensure_standard_linetypes
from engineering.refiner import refine_drawing
@@ -514,6 +514,174 @@ class AutoCADMCPProAdapter(BenchmarkAdapter):
[str(row_before["path"]), str(row["path"])],
)
async def _task_mech_assembly(self):
"""A flange-coupling sheet built from the part model and the sheet standard.
Roadmap criterion 3. Two parts (a hub with a bore and a flange), a full
section through the hub, four standard fasteners with their parts-list
rows and balloons, an ISO 5457 A3 frame and an ISO 7200 title block.
Every step goes through the code the tools call: the frame and title
block through ``apply_titleblock``, the parts list is *read* off the
drawing by ``extract_records`` and drawn by ``draw_bom_table``, and each
balloon is linked to its row's inserts by ``add_balloon``.
The gate is two numbers, and both are asserted rather than reported:
``run_critique(focus=None)`` must return zero issues, and the score the
finalize gate computes - ``combine(validator.summary, critique)`` over
the saved sheet - must be at least 90. A regression in either fails the
task; the focuses that fired come back in the metrics so the failure
names itself.
No step here repairs what the critique would flag: the view engine
draws the ISO 128-23 centre lines of every circle it emits (the end
view's bore and bolt holes included), so the sheet is clean because
the tools drew it clean, not because the benchmark tidied up after them.
"""
from engineering import DrawingValidator
from engineering.critique import run_critique
from engineering.layers import apply_layer_set
from engineering.mech.draw import add_view, dimension_part, draw_part
from engineering.mech.part import build_part
from engineering.mech.stdparts import insert_std_part
from engineering.scoring import combine
from engineering.sheet.bom import (
add_balloon,
draw_bom_table,
extract_records,
rows_from_records,
)
from engineering.sheet.titleblock import (
TB_HEIGHT,
TitleBlockMetadata,
apply_titleblock,
titleblock_origin,
)
await apply_layer_set(self.backend, "mech")
await apply_titleblock(
self.backend,
size="A3",
metadata=TitleBlockMetadata(
title="FLANGE COUPLING",
drawing_no="MA-001",
drawn_by="autocad-mcp-pro",
scale="1:1",
material="S235JR",
),
projection="first",
frame=True,
zones=True,
marks=True,
)
hub = build_part(
{
"kind": "revolved",
"name": "HUB",
"material": "steel",
"segments": [
{"length": 40.0, "d_outer": 40.0, "d_inner": 20.0},
{"length": 15.0, "d_outer": 90.0, "d_inner": 20.0},
],
}
)
flange = build_part(
{
"kind": "revolved",
"name": "FLANGE",
"material": "cast_iron",
"segments": [{"length": 15.0, "d_outer": 90.0, "d_inner": 40.0}],
# Four M12 clearance holes (ISO 273 medium, 13.5) on a 65 PCD.
"features": [
{
"kind": "hole_circle",
"id": "bolts",
"x": 7.5,
"pcd": 65.0,
"count": 4,
"diameter": 13.5,
"start_angle": 45.0,
}
],
}
)
# A3 landscape: the frame runs (20, 10)-(410, 287) and the title block
# fills its lower-right corner, 180 x 60. First angle throughout: the
# hub's front view sits at x = 125 so its section lands left of it
# inside the frame; the flange's front view stands at x = 345 so its
# end view (the bolt circle) lands between the two parts; the
# fasteners take the free band below, left of the title block.
drawn_hub = await draw_part(
self.backend, hub, at=(125.0, 160.0), views=("front",), dimension=False
)
# A full longitudinal section along the hub's axis (part-local frame).
await add_view(
self.backend,
drawn_hub["part_id"],
"section",
plane={"p1": [0.0, 0.0], "p2": [55.0, 0.0], "label": "A"},
style="full",
)
await dimension_part(self.backend, drawn_hub["part_id"], style="chain")
await draw_part(
self.backend, flange, at=(345.0, 160.0), views=("front", "side"), dimension=True
)
# Two bolt-and-nut pairs: each nut sits on the thread of its bolt.
stations = (60.0, 110.0, 160.0, 210.0)
fasteners = []
for index, x in enumerate(stations):
designation = "ISO 4014 - M12x60" if index % 2 == 0 else "ISO 4032 - M12"
fasteners.append(
await insert_std_part(self.backend, designation, at=(x, 90.0), view="side")
)
# The parts list is a read of the drawing, not a list typed beside it.
records = await extract_records(self.backend)
rows = rows_from_records(records)
tb_x, tb_y = titleblock_origin("A3")
await draw_bom_table(self.backend, rows, at=(tb_x, tb_y + TB_HEIGHT))
station_of = {
str(result.get("handle")): x for result, x in zip(fasteners, stations, strict=True)
}
for row in rows:
targets = [str(handle) for handle in row.get("handles") or ()]
x = station_of.get(targets[0], stations[0]) if targets else stations[0]
await add_balloon(
self.backend,
item=int(row["item"]),
at=(x, 135.0),
leader_to=(x, 95.0),
targets=targets,
)
# drawing_finalize saves before it validates; so does the benchmark,
# or the validator's not_saved/no_path findings would grade the
# harness instead of the sheet.
sheet = self.artifact_dir / "mech_assembly.dxf"
await self.backend.drawing_save_as(str(sheet))
issues = await run_critique(self.backend, None)
validation = await DrawingValidator().run(self.backend)
summary = {"error": 0, "warning": 0, "info": 0}
for issue in issues:
summary[issue.severity] = summary.get(issue.severity, 0) + 1
score = combine(validation.summary, summary)
passed = len(issues) == 0 and float(score["score"]) >= 90.0
return (
passed,
{
"critique_issues": len(issues),
"critique_focuses": sorted({issue.focus for issue in issues}),
"score": float(score["score"]),
"grade": score["grade"],
"invalidity_ratio": score["invalidity_ratio"],
"parts_list_rows": len(rows),
"fasteners": len(fasteners),
"validator_findings": sorted(f.code for f in validation.findings),
},
[str(sheet)],
)
async def _task_auditable_delivery(self):
await ensure_engineering_layers(self.backend)
await self.backend.entity_create_line(0, 0, 10, 0, layer="GEOMETRY")
+232
View File
@@ -417,6 +417,231 @@ async def settings_pdf_mediabox_a3():
)
# ── Mechanical parts and the sheet (tracks B + G) ───────────────────────────
def _field(row: dict, *names):
"""A row's value under whichever of these names it carries.
The parts-list column spelling belongs to the sheet module; what this
suite pins is the numbers, so it reads by meaning rather than by name.
"""
for name in names:
if name in row:
return row[name]
return None
def _inserted_handle(result: dict):
return result.get("handle") or (result.get("handles") or [None])[0]
async def mech_part_roundtrip():
"""Draw a part, read it back from its own ACADMCP_MECH XDATA, compare.
Segments and material only: a feature's parameter spelling belongs to
`engineering/mech/features.py` and its own tests, and a check that guessed
at it would fail for the wrong reason.
"""
from engineering.mech.draw import draw_part, read_part
from engineering.mech.part import build_part, part_to_dict
b = await _b()
spec = {
"kind": "revolved",
"name": "SHAFT",
"material": "steel",
"segments": [
{"length": 40.0, "d_outer": 30.0},
{"length": 20.0, "d_outer": 50.0, "d_inner": 10.0},
],
}
part = build_part(spec)
drawn = await draw_part(b, part, at=(0.0, 0.0), views=("front",), dimension=False)
back = await read_part(b, drawn["part_id"])
return part_to_dict(build_part(back["part"])) == part_to_dict(part)
async def mech_section_hatch_area():
"""The hatched cut area of a full section, measured against a hand number.
A plain sleeve: length 60, outside diameter 40, bore 20. A full
longitudinal section cuts two faces, each 60 x 10, so the hatch fills
60 * (40 - 20) = 1200 mm^2 exactly. The number is read out of the drawing
through entity_measure (the tool's analysis_measure_entity), never from the
code that drew it — which is why it cannot be faked.
"""
from engineering.mech.draw import add_view, draw_part
from engineering.mech.part import build_part
b = await _b()
part = build_part(
{
"kind": "revolved",
"name": "SLEEVE",
"material": "steel",
"segments": [{"length": 60.0, "d_outer": 40.0, "d_inner": 20.0}],
}
)
drawn = await draw_part(b, part, at=(0.0, 0.0), views=("front",), dimension=False)
await add_view(
b,
drawn["part_id"],
"section",
plane={"p1": [0.0, 0.0], "p2": [60.0, 0.0], "label": "A"},
style="full",
)
total = 0.0
for ent in await b.entity_list(type_filter="HATCH", limit=500):
total += float((await b.entity_measure(ent.handle))["area"])
return abs(total - 1200.0) < 0.01
async def mech_iso286_on_dimension():
"""A 40 H7 bore dimension carries ISO 286's +0.025 / 0 through the layout.
ISO 286-1 table 1: the 30-50 mm step has IT7 = 25 um, and H fixes the lower
deviation at 0. The fit has to survive `layout_dimensions`, or the number
is right in the table and wrong on the sheet.
"""
from engineering.fits import fit_lookup
from engineering.mech.dimension import layout_dimensions
from engineering.mech.primitives import Circle, DimIntent
from engineering.mech.views import View
deviation = fit_lookup("H7", 40.0)
if abs(deviation.upper_mm - 0.025) > 1e-9 or abs(deviation.lower_mm) > 1e-9:
return False
intent = DimIntent("diameter", (-20.0, 0.0), (20.0, 0.0), fit="H7", feature="bore")
view = View(
kind="front",
prims=(Circle((0.0, 0.0), 20.0),),
dims=(intent,),
omitted=(),
bbox=(-20.0, -20.0, 20.0, 20.0),
)
placed = layout_dimensions(view, [intent])
return len(placed) == 1 and placed[0].fit == "H7" and placed[0].feature == "bore"
async def mech_thread_unrepresented_is_caught():
"""The ISO 6410 focus fires on a thread drawn as a plain circle.
A quality gate that never fires is not a gate; this is the mechanical twin
of `dim_overlap_critique_fires`.
"""
from engineering.mech.critique import build_index, issues_for
from engineering.mech.xdata import APP_ID, to_values
b = await _b()
payload = {
"v": 1,
"kind": "part",
"id": "P1",
"view": "side",
"part": {
"name": "STUD",
"material": "steel",
"segments": [{"length": 60.0, "d_outer": 20.0}],
"features": [{"kind": "thread", "id": "t1", "designation": "M20x1.5"}],
},
}
anchor = await b.entity_create_circle(0.0, 0.0, 0.5, layer="0")
await b.entity_set_xdata(anchor.handle, APP_ID, to_values(payload))
await b.entity_create_circle(0.0, 0.0, 10.0, layer="GEOMETRY")
before = issues_for("mech_thread_unrepresented", await build_index(b))
await b.entity_create_arc(0.0, 0.0, 8.0, 0.0, 270.0, layer="GEOMETRY")
after = issues_for("mech_thread_unrepresented", await build_index(b))
return len(before) == 1 and after == []
async def sheet_frame_iso5457_a3():
"""ISO 5457 A3: 420 x 297 trimmed, a 20 mm filing margin on the left and
10 mm on the other three edges, so the frame runs (20, 10) to (410, 287)
and the drawing space is 390 x 277. The trimmed sheet edge is drawn too
(ISO 5457 4.1), so the outermost extent is the sheet, 0,0 to 420,297 -
and nothing may reach past it."""
from engineering.mech.primitives import points_of
from engineering.sheet.frames import SHEETS, frame_prims
if tuple(float(v) for v in SHEETS["A3"]) != (297.0, 420.0):
return False
prims = frame_prims("A3", orientation="landscape", zones=False, marks=False)
points = {
(round(float(x), 6), round(float(y), 6)) for prim in prims for x, y in points_of(prim)
}
frame = {(20.0, 10.0), (410.0, 10.0), (410.0, 287.0), (20.0, 287.0)}
sheet = {(0.0, 0.0), (420.0, 0.0), (420.0, 297.0), (0.0, 297.0)}
xs = [x for x, _y in points]
ys = [y for _x, y in points]
extent = (min(xs), min(ys), max(xs), max(ys))
return frame <= points and sheet <= points and extent == (0.0, 0.0, 420.0, 297.0)
async def bom_balloon_link():
"""Two identical bolts collapse into one row of quantity 2, the item number
is stable across a rerun, and the balloon drawn for that row carries it."""
from engineering.mech.stdparts import insert_std_part
from engineering.mech.xdata import APP_ID, decode
from engineering.sheet.bom import balloon_prims, rows_from_records
b = await _b()
records = []
for x in (0.0, 60.0):
result = await insert_std_part(b, "ISO 4014 - M12x60", at=(x, 0.0), view="side")
raw = await b.entity_get_xdata(_inserted_handle(result), APP_ID)
records.append(decode(raw["xdata"][APP_ID]))
rows = rows_from_records(records)
again = rows_from_records(records)
if len(rows) != 1 or len(again) != 1:
return False
item = _field(rows[0], "item", "no", "pos")
if item is None or item != _field(again[0], "item", "no", "pos"):
return False
if int(_field(rows[0], "qty", "quantity") or 0) != 2:
return False
texts = [
p.text for p in balloon_prims(int(item), (0.0, 40.0), (0.0, 5.0)) if hasattr(p, "text")
]
return texts == [str(int(item))]
async def std_part_iso4014_m12():
"""ISO 4014's M12 row and the block it becomes.
ISO 4014:2011 table 1, M12: width across flats s = 18 mm, head height
k = 7.5 mm. The row is matched by value rather than by column name — the
spelling is the catalogue's business, the numbers are the standard's — and
the inserted block has to carry its designation back in ACADMCP_MECH.
The catalogue nests a row's sizes one level down (``dims``), so the
values are read one level deep.
"""
from engineering.mech.stdparts import catalogue, insert_std_part
from engineering.mech.xdata import APP_ID, decode
rows = [row for row in catalogue("M12") if any("4014" in str(value) for value in row.values())]
if not rows:
return False
values = [
inner
for row in rows
for value in row.values()
for inner in (value.values() if isinstance(value, dict) else (value,))
]
numbers = {
round(float(value), 3)
for value in values
if isinstance(value, (int, float)) and not isinstance(value, bool)
}
if not {18.0, 7.5} <= numbers:
return False
b = await _b()
result = await insert_std_part(b, "ISO 4014 - M12x60", at=(0.0, 0.0), view="side")
raw = await b.entity_get_xdata(_inserted_handle(result), APP_ID)
payload = decode(raw["xdata"][APP_ID])
return payload["kind"] == "std_part" and "M12" in str(payload["designation"])
CHECKS = {
"core_line_length": (core_line_length, "Core"),
"core_circle_radius": (core_circle_radius, "Core"),
@@ -450,6 +675,13 @@ CHECKS = {
"settings_dimstyle_iso25_values": (settings_dimstyle_iso25_values, "Settings"),
"settings_layer_state_roundtrip": (settings_layer_state_roundtrip, "Settings"),
"settings_pdf_mediabox_a3": (settings_pdf_mediabox_a3, "Settings"),
"mech_part_roundtrip": (mech_part_roundtrip, "Mechanical"),
"mech_section_hatch_area": (mech_section_hatch_area, "Mechanical"),
"mech_iso286_on_dimension": (mech_iso286_on_dimension, "Mechanical"),
"mech_thread_unrepresented_is_caught": (mech_thread_unrepresented_is_caught, "Mechanical"),
"std_part_iso4014_m12": (std_part_iso4014_m12, "Mechanical"),
"sheet_frame_iso5457_a3": (sheet_frame_iso5457_a3, "Sheet"),
"bom_balloon_link": (bom_balloon_link, "Sheet"),
}
+3 -3
View File
@@ -13,7 +13,7 @@ import json
from pathlib import Path
from typing import Any
from benchmarks.tasks_v4 import TASKS_V4
from benchmarks.tasks_v5 import TASKS_V5
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_V4]
task_labels = [f"{task.task_id} ({task.category})" for task in TASKS_V4]
task_ids = [task.task_id for task in TASKS_V5]
task_labels = [f"{task.task_id} ({task.category})" for task in TASKS_V5]
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": 32,
"total": 39,
"old_pass": 26,
"new_pass": 32,
"old_pct": 81.2,
"new_pass": 39,
"old_pct": 66.7,
"new_pct": 100.0,
"fixed": 6,
"fixed": 13,
"regressed": 0
},
"rows": [
@@ -201,6 +201,48 @@
"category": "Settings",
"old": "miss",
"new": "pass"
},
{
"check": "mech_part_roundtrip",
"category": "Mechanical",
"old": "miss",
"new": "pass"
},
{
"check": "mech_section_hatch_area",
"category": "Mechanical",
"old": "miss",
"new": "pass"
},
{
"check": "mech_iso286_on_dimension",
"category": "Mechanical",
"old": "miss",
"new": "pass"
},
{
"check": "mech_thread_unrepresented_is_caught",
"category": "Mechanical",
"old": "miss",
"new": "pass"
},
{
"check": "std_part_iso4014_m12",
"category": "Mechanical",
"old": "miss",
"new": "pass"
},
{
"check": "sheet_frame_iso5457_a3",
"category": "Sheet",
"old": "miss",
"new": "pass"
},
{
"check": "bom_balloon_link",
"category": "Sheet",
"old": "miss",
"new": "pass"
}
]
}
+1 -1
View File
@@ -16,7 +16,7 @@ from pathlib import Path
from backends.capability import UnsupportedCapabilityError
from benchmarks.adapters.base import BenchmarkAdapter, TaskResult
from benchmarks.tasks_v4 import DEFAULT_MATRIX, MATRICES, TaskSpec, task_by_id
from benchmarks.tasks_v5 import DEFAULT_MATRIX, MATRICES, TaskSpec, task_by_id
def _load_registry() -> dict[str, dict]:
+50
View File
@@ -0,0 +1,50 @@
"""The v5 task matrix: v4 plus what tracks B and G added.
``mech_assembly`` (tracks B + G) is roadmap criterion 3: a flange-coupling
sheet built from the tools — two parts with a section, a bolt circle of ISO
4014 bolts and ISO 4032 nuts, ISO 129 dimensions, an ISO 5457 A3 frame, an
ISO 7200 title block and an ISO 7573 parts list with balloons. The gate is two
numbers, not an impression: ``drawing_critique(focus=None)`` returns zero
issues and the finalize score is at least 90. Either one slipping fails the
task outright.
The published competitor reports were never asked this question; 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, TaskSpec
#: Added in v1.6.0 by tracks B + G.
NEW_TASKS_V5: tuple[TaskSpec, ...] = (
TaskSpec(
"mech_assembly",
"mech",
"Draw a flange-coupling sheet from the part model and the sheet standard, "
"with a clean critique and a finalize score of at least 90",
1.0,
),
)
TASKS_V5: tuple[TaskSpec, ...] = TASKS_V4 + NEW_TASKS_V5
#: Runner-selectable task sets. v2, v3 and v4 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,
}
DEFAULT_MATRIX = "v5"
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}")
+15
View File
@@ -488,6 +488,7 @@ async def idle_lane(counter: Any, turns: int = SESSION_TURNS) -> list[dict[str,
"""What a client pays on connect, for each advertised surface."""
srv = _load_server()
previous_profile = config.settings.tool_profile
previous_packs = config.settings.tool_packs
rows: list[dict[str, Any]] = []
try:
config.settings.tool_profile = "full"
@@ -510,6 +511,17 @@ async def idle_lane(counter: Any, turns: int = SESSION_TURNS) -> list[dict[str,
)
)
config.settings.tool_profile = "full"
config.settings.tool_packs = "core,mech"
rows.append(
await _idle_row(
"packs_core_mech",
"TOOL_PACKS=core,mech - a mechanical client that never opens a "
"P&ID or a live seat.",
counter,
turns,
)
)
config.settings.tool_packs = previous_packs
srv._apply_discovery_mode("search")
rows.append(
await _idle_row(
@@ -523,6 +535,9 @@ async def idle_lane(counter: Any, turns: int = SESSION_TURNS) -> list[dict[str,
finally:
srv._apply_discovery_mode("off")
config.settings.tool_profile = previous_profile
# restored here too, so a failed measurement cannot leave the process
# on a narrowed surface
config.settings.tool_packs = previous_packs
await srv._apply_tool_profile(previous_profile)
return rows
@@ -1,6 +1,6 @@
# v1.6 Tracks B + G — the complete mechanical 2D part drawer and the sheet standard
**Date:** 2026-09-22 · **Status:** design approved in conversation ·
**Date:** 2026-09-22 · **Status:** shipped (v1.6.0-dev, tracks B + G) ·
**Parent:** `2026-09-15-v1.6-roadmap-design.md` (tracks B and G, merged into one
spec by the user's decision) · **Baseline:** main after Track E
(`39e6e2f`, 204 tools, 8 resources, 3283 tests)
+7
View File
@@ -423,6 +423,13 @@ def segment_key(index: int, *, bore: bool = False) -> str:
def _revolved_diameters(part: RevolvedPart) -> list[DimIntent]:
"""Each segment's outside diameter and bore, measured across the axis.
``p1`` is the upper chord end: a diameter's text stands beyond ``p1`` (the
ActiveX ``AddDimDiametric`` ChordPoint rule, which both engines follow), so
every front-view diameter is annotated above the view, clear of the axial
chain rows below it.
"""
intents: list[DimIntent] = []
bounds = zip(segment_bounds(part), part.segments, strict=True)
for index, ((x0, x1), segment) in enumerate(bounds):
+21
View File
@@ -109,6 +109,7 @@ async def draw_prims(
"""Draw primitive descriptions, turned by ``rotation`` about their origin, then placed at ``at``."""
placed = tuple(prims)
_refuse_islands_without_edge_paths(backend, placed)
await _ensure_layers(backend, sorted({f"{layer_prefix}{layer_for(p)}" for p in placed}))
if abs(float(rotation)) > 1e-12:
placed = rotate(placed, float(rotation), (0.0, 0.0))
if abs(float(at[0])) > 1e-12 or abs(float(at[1])) > 1e-12:
@@ -195,6 +196,23 @@ async def draw_prims(
return {"handles": handles, "counts": counts}
async def _ensure_layers(backend, names) -> tuple[str, ...]:
"""Create whichever target layers the drawing lacks, before the first entity.
MEASURED on AutoCAD 2026 by ``scripts/smoke_mech_com.py``: ``draw_part``
put its anchor POINT on ``MECH``, a layer no layer set creates, and
ActiveX answered ``entity.Layer = "MECH"`` with ``-0x7ffdfff7 ('Key not
found')`` - after every view had already been drawn. The headless engine
creates a missing layer on assignment, so the same call passed every test.
Group A measured the same refusal for annotation layers; this is the same
cure (``ensure_annotation_layers``) at the places every mechanical drawing
path passes through.
"""
from engineering.mech.annotate import ensure_annotation_layers
return await ensure_annotation_layers(backend, [str(name) for name in names if name])
def _refuse_islands_without_edge_paths(backend, prims) -> None:
"""Refuse a hatch with islands on an engine that cannot attach them - before drawing.
@@ -515,6 +533,7 @@ async def draw_part(
if index == 0:
parent, parent_offset = view, offset
await _ensure_layers(backend, [ANCHOR_LAYER])
anchor = await backend.entity_create_point(origin[0], origin[1], layer=ANCHOR_LAYER)
payload = {
"v": PAYLOAD_VERSION,
@@ -839,6 +858,7 @@ async def dimension_part(
skipped: list[dict] = []
table_handle: str | None = None
await _ensure_layers(backend, [ROLE_LAYER["dim"]])
for index, view, placed in planned:
record = records[index]
offset = record["offset"]
@@ -963,6 +983,7 @@ async def draw_hole_pattern(
prims.append(Line((hole.x - reach, hole.y), (hole.x + reach, hole.y), "center"))
prims.append(Line((hole.x, hole.y - reach), (hole.x, hole.y + reach), "center"))
drawn = await draw_prims(backend, prims)
await _ensure_layers(backend, [layer, ROLE_LAYER["dim"] if dimension else None])
if layer:
# the role map owns the layers; an explicit override moves the circles
# only, so the ISO 128-23 centre marks stay on CENTER.
+12 -1
View File
@@ -209,8 +209,19 @@ class _Grid:
def _vertices(info: EntityInfo) -> list[tuple[float, float]]:
"""The entity's vertices, or ``[]`` when the engine could not report them.
MEASURED on AutoCAD 2026 by scripts/smoke_mech_com.py: the live engine's
entity reader drops a LINE's ``start``/``end`` when AutoCAD refuses the
point read (``RPC_E_CALL_REJECTED`` while busy) and keeps the entity, so
a subscript here crashed ``drawing_critique(focus=None)`` on a sheet with
no P&ID on it. A line without coordinates cannot be an edge; it is left
out rather than guessed at.
"""
if info.type in LINE_TYPES:
s, e = info.properties["start"], info.properties["end"]
s, e = info.properties.get("start"), info.properties.get("end")
if not s or not e:
return []
return [(float(s[0]), float(s[1])), (float(e[0]), float(e[1]))]
return [(float(p[0]), float(p[1])) for p in info.properties.get("points") or []]
+17 -1
View File
@@ -111,6 +111,11 @@ COLUMN_SHARES: dict[str, float] = {
PAGE_SIZE = 1000
#: A block reference under either engine's type name: the DXF name headlessly,
#: the ActiveX ObjectName (AcDbBlockReference) live.
INSERT_TYPES = ("INSERT", "BLOCKREFERENCE")
async def all_entities(backend: AutoCADBackend, *, type_filter=None, layer=None):
"""Every entity of a type in the CURRENT space -- not the first 200 of them.
@@ -361,7 +366,18 @@ async def extract_records(backend: AutoCADBackend, *, layer=None, limit: int | N
"""
if limit is not None and int(limit) <= 0:
raise ValueError(f"bom_extract: limit must be a positive number of records; got {limit!r}")
inserts = await all_entities(backend, type_filter="INSERT", layer=layer)
# MEASURED on AutoCAD 2026 by scripts/smoke_mech_com.py: the live engine
# names a block reference by its ObjectName, BLOCKREFERENCE, and filters on
# that name, so a filter on the DXF name INSERT found nothing and the parts
# list came back empty on a sheet holding an ISO 4014 bolt. Both names are
# read, the way engineering/pid/graph.py already does.
inserts: list = []
seen: set[str] = set()
for type_name in INSERT_TYPES:
for entity in await all_entities(backend, type_filter=type_name, layer=layer):
if entity.handle not in seen:
seen.add(entity.handle)
inserts.append(entity)
records: list[dict] = []
for entity in inserts:
payload: dict = {}
+17 -1
View File
@@ -27,6 +27,22 @@ DIMENSION_LIKE_TEXT = re.compile(
re.IGNORECASE,
)
# A standard-part designation (ISO 4014 - M12x60, DIN 912 - M8x20) names a
# part, it does not measure one: the "12x60" in it is the thread and the
# nominal length the standard's designation grammar spells that way. A parts
# list drawn as text carries one per row, so the designation is taken out
# before DIMENSION_LIKE_TEXT looks - a hand-typed "Ø78" beside it still flags.
STANDARD_DESIGNATION = re.compile(
r"\b(?:ISO|DIN|EN)\s*\d+(?:-\d+)?\s*-\s*M\d+(?:\.\d+)?(?:\s*[x×]\s*\d+(?:\.\d+)?)?",
re.IGNORECASE,
)
def looks_like_dimension(text: str) -> bool:
"""Is this TEXT/MTEXT content masquerading as a dimension?"""
return bool(DIMENSION_LIKE_TEXT.search(STANDARD_DESIGNATION.sub(" ", text or "")))
# R27 — match SPUR / HELICAL as whole words (not substrings of e.g.
# "SPURIOUS") when checking title/helix consistency.
_SPUR_RE = re.compile(r"\bSPUR\b", re.IGNORECASE)
@@ -92,7 +108,7 @@ class DrawingValidator:
e
for e in ents
if e.type in ("TEXT", "MTEXT")
and DIMENSION_LIKE_TEXT.search(getattr(e, "properties", {}).get("text", "") or "")
and looks_like_dimension(getattr(e, "properties", {}).get("text", "") or "")
]
real_dims = [e for e in ents if e.type in REAL_DIMENSION_TYPES]
if text_likes:
+3 -3
View File
@@ -79,7 +79,7 @@ def derived() -> dict[str, object]:
}
from benchmarks import correctness_suite
from benchmarks.tasks_v4 import TASKS_V4
from benchmarks.tasks_v5 import TASKS_V5
return {
"collected_tests": _collected_tests(),
@@ -94,7 +94,7 @@ def derived() -> dict[str, object]:
"ab_fixed": ab["fixed"],
"ab_regressed": ab["regressed"],
"suite_checks": len(correctness_suite.CHECKS),
"v4_tasks": len(TASKS_V4),
"matrix_tasks": len(TASKS_V5),
"perf_ms": perf,
}
@@ -176,7 +176,7 @@ CHECKS: list[tuple[str, str, str, str]] = [
"ab_fixed",
),
("CLAUDE.md", "A/B suite size", r"correctness_suite\.py` \((\d+) checks\)", "ab_total"),
("CLAUDE.md", "v4 task count", r"the current set \((\d+) tasks", "v4_tasks"),
("CLAUDE.md", "matrix task count", r"the current set \((\d+) tasks", "matrix_tasks"),
]
+290
View File
@@ -0,0 +1,290 @@
"""Exercise tracks B and G once against the live AutoCAD on this machine.
Creates NEW documents (never touches the operator's open drawings), then runs
the whole track through the same code the tools call:
xref source: drawing_new -> a line -> drawing_export_dwg -> close
apply_layer_set("mech") -> apply_titleblock (ISO 5457 A3 + ISO 7200)
-> build_part -> draw_part -> add_view(section) -> dimension_part
-> insert_std_part (ISO 4014 block + ACADMCP_MECH xdata)
-> weld_symbol_prims -> extract_records -> rows_from_records
-> draw_bom_table -> add_balloon
-> xref_attach / xref_manage("list") / xref_manage("detach")
-> run_critique(focus=None) -> drawing_export_dwg
and prints what came back as JSON. Exit codes:
0 the sheet drew, the critique returned nothing and the .dwg exists
1 it drew but the critique found something or the DWG is missing
2 no live CAD application reachable (pywin32 missing, or the ProgID
cannot be created) -- the COM paths then remain fake-tested only
The backend's ``connect()`` is lazy on purpose (the server must start before
AutoCAD is open), so reachability is read from ``system_status()`` rather than
from ``connect()``. Every document this run opens is closed on the way out,
also when a step fails, unless ``--keep`` is given.
AUTOCAD_MCP_BACKEND=com uv run --frozen python scripts/smoke_mech_com.py
uv run --frozen python scripts/smoke_mech_com.py --keep # leave it open
"""
from __future__ import annotations
import asyncio
import json
import sys
import tempfile
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
XDATA_APP = "ACADMCP_MECH"
#: Every DWG since R13 opens with "AC10xx" (AC1032 for the 2018 format).
DWG_MAGIC = b"AC10"
#: ``RPC_E_CALL_REJECTED``: AutoCAD's message filter refused the call because
#: it was busy (a document still initialising after ``Documents.Add`` is the
#: case measured on this seat). A refused call was not executed.
RPC_E_CALL_REJECTED = -2147418111
#: How long a refused call is retried before the smoke gives up on it.
REJECTED_PATIENCE_S = 90.0
#: Names of the documents this run opened and has not closed yet.
_OPENED: list[str] = []
def _patient(backend) -> None:
"""Retry any backend call AutoCAD refused with ``RPC_E_CALL_REJECTED``.
Measured on AutoCAD 2026: the first call after ``Documents.Add`` was
refused, the smoke died, and its cleanup - refused as well - left the
scratch document open. Refused calls are retried with backoff for up to
``REJECTED_PATIENCE_S``; a document created twice by a retried
``drawing_new`` is still closed, because ``_new_document`` tracks the
document list, not a name it assumes.
"""
run = backend._run
async def patient(func, *args, **kwargs):
deadline = time.monotonic() + REJECTED_PATIENCE_S
pause = 0.5
while True:
try:
return await run(func, *args, **kwargs)
except RuntimeError as exc:
cause = exc.__cause__
hr = cause.args[0] if cause is not None and cause.args else None
if hr != RPC_E_CALL_REJECTED or time.monotonic() + pause > deadline:
raise
await asyncio.sleep(pause)
pause = min(pause * 2.0, 5.0)
backend._run = patient
async def _names(backend) -> set[str]:
return {row["name"] for row in await backend.document_list()}
async def _new_document(backend) -> str:
"""``drawing_new``, recording every document it added (normally one)."""
before = await _names(backend)
await backend.drawing_new()
added = sorted((await _names(backend)) - before)
_OPENED.extend(name for name in added if name not in _OPENED)
return added[-1] if added else ""
async def _close_leftovers(backend) -> None:
open_names = {row["name"] for row in await backend.document_list()}
for name in list(_OPENED):
if name in open_names:
try:
await backend.document_close(name, discard=True)
except Exception as exc: # best effort on the way out
print(f"could not close {name}: {exc}", file=sys.stderr)
_OPENED.remove(name)
async def _xref_source(backend, workspace: Path) -> Path:
"""A one-line DWG in its own scratch document, saved and closed.
An xref has to be a DWG, and it cannot be the host document itself -
``SaveAs`` rebinds the host to the file it wrote.
"""
created = await _new_document(backend)
await backend.entity_create_line(0.0, 0.0, 50.0, 0.0, layer="0")
path = workspace / "xref_source.dwg"
before = await _names(backend)
await backend.drawing_export_dwg(str(path), "R2018")
rebound = await _rebound(backend, created, before)
await backend.document_close(rebound, discard=True)
_OPENED.remove(rebound)
return path
async def _rebound(backend, created: str, before: set[str]) -> str:
"""``SaveAs`` renames the active document to the file it wrote; track that name.
Read from the document list rather than ``system_status``: the latter
swallows a refused call and answers without ``active_document``.
"""
after = await _names(backend)
renamed = sorted(after - before)
rebound = renamed[0] if created not in after and len(renamed) == 1 else created
if created in _OPENED:
_OPENED.remove(created)
if rebound not in _OPENED:
_OPENED.append(rebound)
return rebound
async def _sheet(backend, workspace: Path, xref_path: Path) -> dict:
from engineering.critique import run_critique
from engineering.layers import apply_layer_set
from engineering.mech.annotate import weld_symbol_prims
from engineering.mech.draw import add_view, dimension_part, draw_part, draw_prims
from engineering.mech.part import build_part
from engineering.mech.stdparts import insert_std_part
from engineering.sheet.bom import (
add_balloon,
draw_bom_table,
extract_records,
rows_from_records,
)
from engineering.sheet.titleblock import (
TB_HEIGHT,
TitleBlockMetadata,
apply_titleblock,
titleblock_origin,
)
created = await _new_document(backend)
await apply_layer_set(backend, "mech")
await apply_titleblock(
backend,
size="A3",
metadata=TitleBlockMetadata(title="COM SMOKE", drawing_no="SMOKE-001", scale="1:1"),
projection="first",
frame=True,
zones=True,
marks=True,
)
shaft = build_part(
{
"kind": "revolved",
"name": "SHAFT",
"material": "steel",
"segments": [
{"length": 40.0, "d_outer": 30.0},
{"length": 20.0, "d_outer": 50.0, "d_inner": 20.0},
],
}
)
drawn = await draw_part(backend, shaft, at=(125.0, 170.0), views=("front",), dimension=False)
section = await add_view(
backend,
drawn["part_id"],
"section",
plane={"p1": [0.0, 0.0], "p2": [60.0, 0.0], "label": "A"},
style="full",
)
dims = await dimension_part(backend, drawn["part_id"], style="chain")
bolt = await insert_std_part(backend, "ISO 4014 - M12x60", at=(250.0, 120.0), view="side")
bolt_handle = bolt.get("handle") or (bolt.get("handles") or [None])[0]
got = await backend.entity_get_xdata(bolt_handle, XDATA_APP)
await draw_prims(backend, weld_symbol_prims(kind="fillet", size=5.0), at=(60.0, 100.0))
records = await extract_records(backend)
rows = rows_from_records(records)
tb_x, tb_y = titleblock_origin("A3")
table = await draw_bom_table(backend, rows, at=(tb_x, tb_y + TB_HEIGHT))
balloon = await add_balloon(
backend,
item=int(rows[0]["item"]),
at=(250.0, 150.0),
leader_to=(250.0, 125.0),
targets=[str(h) for h in rows[0]["handles"]],
)
attached = await backend.xref_attach(str(xref_path), (30.0, 30.0), 1.0, 0.0, "attach")
listed = await backend.xref_manage(attached["name"], "list", None)
detached = await backend.xref_manage(attached["name"], "detach", None)
after = await backend.xref_manage(attached["name"], "list", None)
issues = await run_critique(backend, None)
dwg_path = workspace / "mech_smoke.dwg"
before = await _names(backend)
exported = await backend.drawing_export_dwg(str(dwg_path), "R2018")
await _rebound(backend, created, before)
head = dwg_path.read_bytes()[:6] if dwg_path.exists() else b""
return {
"part_id": drawn["part_id"],
"views": len(drawn.get("views") or []) + 1,
"section_omitted": section.get("omitted"),
"dimensions": int(dims.get("count") or len(dims.get("handles") or [])),
"bolt_handle": bolt_handle,
"xdata_chunks": len(got["xdata"].get(XDATA_APP, [])),
"parts_list_rows": len(rows),
"parts_list_representation": table.get("representation"),
"balloon": balloon.get("handle"),
"xref_attached": attached.get("name"),
"xref_listed": len(listed.get("xrefs") or []),
"xref_detached": bool(detached.get("ok")),
"xrefs_after_detach": len(after.get("xrefs") or []),
"dwg": {
"path": exported.get("path", str(dwg_path)),
"exists": dwg_path.exists(),
"bytes": dwg_path.stat().st_size if dwg_path.exists() else 0,
"magic": head.decode("ascii", "replace"),
},
"critique_issues": len(issues),
"critique": [issue.to_dict() for issue in issues],
}
async def main(keep_document: bool = False) -> int:
try:
from backends.com_backend import ComBackend
except ImportError as exc:
print(f"pywin32 not available: {exc}")
return 2
backend = ComBackend()
_patient(backend)
try:
await backend.connect()
except Exception as exc:
print(f"no live AutoCAD reachable: {exc}")
return 2
try:
status = await backend.system_status()
if not status.get("connected"):
print(f"no live AutoCAD reachable: {status.get('error', status)}")
return 2
workspace = Path(tempfile.mkdtemp(prefix="mech_smoke_"))
xref_path = await _xref_source(backend, workspace)
report = {
"autocad": status.get("autocad_version") or status.get("cad_progid"),
**await _sheet(backend, workspace, xref_path),
}
print(json.dumps(report, indent=2))
ok = (
report["critique_issues"] == 0
and report["dwg"]["exists"]
and report["dwg"]["magic"].startswith(DWG_MAGIC.decode())
and report["xrefs_after_detach"] == 0
)
return 0 if ok else 1
finally:
if not keep_document:
await _close_leftovers(backend)
await backend.disconnect()
if __name__ == "__main__":
raise SystemExit(asyncio.run(main(keep_document="--keep" in sys.argv[1:])))
+2 -1
View File
@@ -48,7 +48,8 @@ def test_correctness_suite_has_the_settings_checks():
"settings_pdf_mediabox_a3",
):
assert CHECKS[name][1] == "Settings"
assert len(CHECKS) == 32
# v4 left the suite at 32; tracks B + G grew it to 39 (tests/test_benchmark_v5.py).
assert len(CHECKS) == 39
@pytest.mark.asyncio # asyncio_mode is strict; the four tests above are sync
+112
View File
@@ -0,0 +1,112 @@
# tests/test_benchmark_v5.py
"""The v5 task matrix: v4 plus what tracks B and G added.
``mech_assembly`` is roadmap criterion 3 — a flange-coupling sheet built from
the tools, gated on two numbers that cannot be negotiated:
``drawing_critique(focus=None)`` returns zero issues and ``drawing_finalize``
scores at least 90. The published competitor reports were never asked it and
the chart shows them ``not_run``, the rule v3 set for its five.
"""
from __future__ import annotations
import pytest
from benchmarks.tasks_v4 import TASKS_V4
from benchmarks.tasks_v5 import DEFAULT_MATRIX, MATRICES, NEW_TASKS_V5, TASKS_V5, task_by_id
def test_v5_extends_v4_without_touching_it():
assert TASKS_V5[: len(TASKS_V4)] == TASKS_V4
assert [t.task_id for t in NEW_TASKS_V5] == ["mech_assembly"]
assert len(TASKS_V5) == 18
assert MATRICES["v4"] == TASKS_V4 and MATRICES["v5"] == TASKS_V5
assert DEFAULT_MATRIX == "v5"
assert task_by_id("mech_assembly").category == "mech"
def test_the_adapter_implements_every_v5_task():
from benchmarks.adapters.autocad_mcp_pro import AutoCADMCPProAdapter
for task in TASKS_V5:
assert hasattr(AutoCADMCPProAdapter, f"_task_{task.task_id}"), task.task_id
def test_the_suite_carries_the_mechanical_and_sheet_checks():
"""Six from spec 14, plus mech_thread_unrepresented_is_caught: a quality
gate that never fires is not a gate, the way dim_overlap_critique_fires
already guards the dimension focus."""
from benchmarks.correctness_suite import CHECKS
for name in (
"mech_part_roundtrip",
"mech_section_hatch_area",
"mech_iso286_on_dimension",
"mech_thread_unrepresented_is_caught",
"std_part_iso4014_m12",
):
assert CHECKS[name][1] == "Mechanical"
assert CHECKS["sheet_frame_iso5457_a3"][1] == "Sheet"
assert CHECKS["bom_balloon_link"][1] == "Sheet"
assert len(CHECKS) == 39
@pytest.mark.asyncio
async def test_mech_assembly_passes_headlessly(tmp_path):
"""The benchmark task itself, run once here so a regression in the part
drawer, the sheet or the critique fails the suite and not only the matrix."""
from benchmarks.adapters.autocad_mcp_pro import AutoCADMCPProAdapter
adapter = AutoCADMCPProAdapter(backend="ezdxf")
await adapter.setup(tmp_path)
try:
await adapter._reset()
passed, metrics, _artifacts = await adapter._task_mech_assembly()
finally:
await adapter.cleanup()
assert passed, metrics
assert metrics["critique_issues"] == 0, metrics["critique_focuses"]
assert metrics["score"] >= 90.0
@pytest.mark.asyncio
async def test_mech_assembly_fails_loudly_when_the_critique_finds_anything(tmp_path, monkeypatch):
"""The gate has to be able to fail. One parts-list row left without its
balloon is enough: ISO 7573 gives every row an item reference, and the
critique names the focus that fired rather than only shading the score."""
from benchmarks.adapters.autocad_mcp_pro import AutoCADMCPProAdapter
from engineering.sheet import bom
real = bom.add_balloon
async def only_the_first(backend, *, item, **kw):
if int(item) == 1:
return await real(backend, item=item, **kw)
return {"ok": True, "skipped": True}
monkeypatch.setattr(bom, "add_balloon", only_the_first)
adapter = AutoCADMCPProAdapter(backend="ezdxf")
await adapter.setup(tmp_path)
try:
await adapter._reset()
passed, metrics, _artifacts = await adapter._task_mech_assembly()
finally:
await adapter.cleanup()
assert passed is False
assert "mech_bom_balloon_mismatch" in metrics["critique_focuses"]
@pytest.mark.asyncio
async def test_the_section_hatch_area_check_measures_the_drawing():
"""1200 mm^2 is computed by hand from the model, and read back through
analysis_measure_entity — a number the drawing code cannot fake."""
from benchmarks import correctness_suite
assert await correctness_suite.mech_section_hatch_area() is True
@pytest.mark.asyncio
async def test_the_iso5457_frame_check_passes():
from benchmarks import correctness_suite
assert await correctness_suite.sheet_frame_iso5457_a3() is True
+21
View File
@@ -546,6 +546,27 @@ def _gate_valve_block():
return _Block([body_left, body_right, tag])
def test_a_dimension_reports_where_its_text_stands():
"""The dim_overlap critique reads `text_position`; without it a diameter
dimension's reference point was its bounding-box centre - the circle's
centre - and every outside diameter collided with its own bore on the live
engine (measured by scripts/smoke_mech_com.py, AutoCAD 2026)."""
from backends.com_backend import _entity_info
outside = _Entity(
"AcDbDiametricDimension",
GetBoundingBox=lambda: ((178.0, 170.0, 0.0), (184.0, 233.0, 0.0)),
TextPosition=(181.0, 230.0, 0.0),
Handle="1C1",
Layer="DIM",
color=256,
Linetype="ByLayer",
Visible=True,
)
props = _entity_info(outside).properties
assert props["text_position"] == [181.0, 230.0]
def test_com_geometry_bbox_is_the_drawn_body_without_the_attribute():
from backends.com_backend import _entity_info
+11
View File
@@ -57,6 +57,17 @@ async def test_a_diameter_dimension_measures_the_diameter(backend):
assert dim.get_measurement() == pytest.approx(40.0)
async def test_the_text_stands_beyond_the_first_chord_point_as_activex_puts_it(backend):
"""AddDimDiametric(ChordPoint, FarChordPoint, LeaderLength), measured on
AutoCAD 2026: a 50 mm diameter from (x, 170) to (x, 220) with leader 10
had its text at (x, 160) - beyond the FIRST point. Both engines follow it."""
info = await backend.dimension_diameter(0, 25, 0, -25, leader_length=10.0)
dim = backend._doc.entitydb.get(info.handle)
text = dim.dxf.text_midpoint
assert text.y == pytest.approx(35.0)
assert text.x == pytest.approx(0.0)
async def test_the_leader_length_moves_the_text_not_the_measurement(backend):
"""The regression in one assertion: leader_length must not be measurable."""
near = await backend.dimension_diameter(-20, 0, 20, 0, leader_length=5.0)
+15
View File
@@ -101,6 +101,21 @@ async def test_validator_catches_fake_dimension_text(backend, tmp_path):
assert "fake_dimension_text" in _codes(result)
async def test_a_standard_part_designation_is_not_a_fake_dimension():
"""A parts-list row names a part: "ISO 4014 - M12x60" is the ISO 4014
designation grammar, not a measurement. Found by the v5 mech_assembly
benchmark, where every text parts list cost the sheet a warning. The
exemption is the designation only - a measurement beside it still flags."""
from engineering.validator import looks_like_dimension
assert not looks_like_dimension("ISO 4014 - M12x60")
assert not looks_like_dimension("DIN 912 - M8x20")
assert not looks_like_dimension("ISO 4032 - M12")
assert looks_like_dimension("ISO 4014 - M12x60 Ø78")
assert looks_like_dimension("40x60")
assert looks_like_dimension("Ø78")
async def test_validator_catches_hidden_orphans(backend, tmp_path):
await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend)
+62
View File
@@ -492,3 +492,65 @@ async def test_a_transaction_that_cannot_open_is_a_value_error(backend, monkeypa
monkeypatch.setattr(backend, "transaction_begin", busy)
with pytest.raises(ValueError, match="could not open a transaction"):
await draw_from_spec(backend, {"parts": [{"part": PLATE, "at": [0.0, 0.0]}]})
async def test_a_single_step_disk_dimensions_without_a_text_collision(backend):
"""The largest diameter's text used to land on its own segment's chain text.
The front-view diameters start at the upper chord end, and ActiveX puts a
diameter's text beyond that FIRST point - above the view, clear of the
chain rows (measured on AutoCAD 2026). The headless engine put it beyond
the second point instead, so ⌀90 landed exactly on the "15" of a 15 mm
disk and ``dim_overlap`` fired on every single-step part the default
``mech_part_draw`` produced headlessly. Found by the v5 ``mech_assembly``
benchmark and the live smoke together.
"""
from engineering.critique import run_critique
disk = build_part(
{
"kind": "revolved",
"name": "FLANGE",
"material": "cast_iron",
"segments": [{"length": 15.0, "d_outer": 90.0, "d_inner": 40.0}],
}
)
await draw_part(backend, disk, at=(0.0, 0.0), views=("front",), dimension=True)
assert await run_critique(backend, ["dim_overlap"]) == []
async def test_every_layer_exists_before_an_entity_is_put_on_it(backend, monkeypatch):
"""ActiveX will not create a layer on assignment - the headless engine will.
Measured on AutoCAD 2026 by scripts/smoke_mech_com.py: ``entity.Layer =
"MECH"`` raised ``-0x7ffdfff7 ('Key not found')`` after every view of the
part had been drawn, so ``mech_part_draw`` could not finish on a live
seat. This backend is made as strict as ActiveX, and the whole part -
views, anchor, dimensions - has to draw on it.
"""
creators = (
"entity_create_point",
"entity_create_line",
"entity_create_circle",
"entity_create_arc",
"entity_create_polyline",
"entity_create_text",
"entity_create_hatch",
)
for name in creators:
real = getattr(backend, name)
async def strict(*args, _real=real, **kwargs):
layer = kwargs.get("layer")
known = {row.name.lower() for row in await backend.layer_list()}
if layer and layer.lower() not in known:
raise RuntimeError(f"Key not found: layer {layer!r}")
return await _real(*args, **kwargs)
monkeypatch.setattr(backend, name, strict)
drawn = await draw_part(
backend, build_part(SHAFT), at=(0.0, 0.0), views=("front", "side"), dimension=True
)
assert drawn["part_id"]
assert ANCHOR_LAYER.lower() in {row.name.lower() for row in await backend.layer_list()}
+11
View File
@@ -896,3 +896,14 @@ def test_com_block_reference_reports_mirrored_from_its_normal():
assert _entity_info(blockref(Normal=(0.0, 0.0, -1.0))).properties["mirrored"] is True
assert _entity_info(blockref(Normal=(0.0, 0.0, 1.0))).properties["mirrored"] is False
assert _entity_info(blockref()).properties["mirrored"] is False
def test_a_line_the_engine_could_not_read_is_not_an_edge():
"""The live reader keeps a LINE whose point read AutoCAD refused, without
``start``/``end`` (measured by scripts/smoke_mech_com.py on AutoCAD 2026);
the graph must leave it out instead of raising KeyError mid-critique."""
from backends.base import EntityInfo
from engineering.pid.graph import _vertices
blind = EntityInfo("1F", "LINE", "GEOMETRY", 256, "ByLayer", True, properties={})
assert _vertices(blind) == []
+24
View File
@@ -224,6 +224,30 @@ async def test_extract_reads_the_xdata_payload_and_never_touches_the_drawing(bac
assert all(r["source"] == "xdata" for r in records)
@pytest.mark.asyncio
async def test_extract_reads_a_block_reference_under_the_live_type_name(backend, monkeypatch):
"""The live engine calls an INSERT by its ObjectName, BLOCKREFERENCE.
Measured on AutoCAD 2026 by scripts/smoke_mech_com.py: a filter on
"INSERT" found nothing, and the parts list of a sheet holding an ISO 4014
bolt came back empty. The listing is made to answer the way ActiveX does.
"""
handle = await _place_bolt(backend, "a", 0.0)
real = backend.entity_list
async def live_names(type_filter=None, **kwargs):
if type_filter and type_filter.upper() == "INSERT":
return []
wanted = (
"INSERT" if type_filter and type_filter.upper() == "BLOCKREFERENCE" else type_filter
)
return await real(type_filter=wanted, **kwargs)
monkeypatch.setattr(backend, "entity_list", live_names)
records = await extract_records(backend)
assert [r["handle"] for r in records] == [handle]
@pytest.mark.asyncio
async def test_extract_falls_back_to_the_block_attributes_when_there_is_no_payload(backend):
await backend.block_define(
+15 -3
View File
@@ -161,13 +161,25 @@ def test_the_cache_arithmetic_follows_the_published_multipliers():
# -- lane 1: idle cost ------------------------------------------------------
def test_the_idle_lane_covers_the_three_advertised_surfaces(report):
def test_the_idle_lane_covers_the_four_advertised_surfaces(report):
"""Default, lean, search - and TOOL_PACKS=core,mech, the mechanical client
that never opens a P&ID or a live seat: the default surface minus exactly
the pid and settings packs."""
variants = {row["variant"]: row for row in report["idle"]}
assert set(variants) == {"default", "lean", "search"}
assert set(variants) == {"default", "lean", "packs_core_mech", "search"}
assert variants["default"]["advertised_tools"] > 100
assert variants["lean"]["advertised_tools"] == len(server.LEAN_TOOL_NAMES)
assert variants["search"]["advertised_tools"] == 2
assert variants["default"]["chars"] > variants["lean"]["chars"] > variants["search"]["chars"]
dropped = len(server.PACK_TOOL_NAMES["pid"]) + len(server.PACK_TOOL_NAMES["settings"])
assert variants["packs_core_mech"]["advertised_tools"] == (
variants["default"]["advertised_tools"] - dropped
)
assert (
variants["default"]["chars"]
> variants["packs_core_mech"]["chars"]
> variants["lean"]["chars"]
> variants["search"]["chars"]
)
def test_the_idle_lane_separates_the_wire_payload_from_what_the_model_pays(report):