Files
autocad-mcp-pro/scripts/check_doc_numbers.py
T
Umut CANandClaude Opus 5.5 898e775f93 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
2026-09-23 13:30:02 +03:00

233 lines
7.5 KiB
Python

"""Hold the numbers in the documents equal to the numbers in the tree.
Every figure this checks has been wrong at least once, and the expensive one was
wrong in three files at the same time: `benchmarks/README.md` and `CLAUDE.md`
described the A/B suite as 24 checks at 87.5% while the suite held 26 and the
published report said 80.8%. Nothing caught it because each file was edited on
its own and nobody re-read the other two.
The rule is the one the release snapshot already follows: a number a human
types is a number that rots, so derive it and fail when the prose disagrees.
python scripts/check_doc_numbers.py # report and exit non-zero on drift
python scripts/check_doc_numbers.py --list # show every derived value
`tests/test_release_consistency.py` runs this as a gate.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
#: The published A/B report the README and benchmarks/README quote. Bump this,
#: the two row labels below and the prose together, or the gate reports drift.
AB_REPORT = "benchmarks/results/published/ab-v1.5.1-vs-v1.6.0-dev.json"
AB_BASELINE_LABEL = r"v1\.5\.1"
AB_CURRENT_LABEL = r"v1\.6\.0-dev"
def _read(relative: str) -> str:
return (ROOT / relative).read_text(encoding="utf-8")
def _json(relative: str) -> dict:
return json.loads(_read(relative))
def _collected_tests() -> int:
"""How many tests pytest actually collects.
On the same README line as tools/resources/prompts, and the only number on
it that nothing derived -- so it drifted by 95 while its neighbours stayed
exact. Collection only; running the suite here would make the gate cost a
minute.
"""
env = dict(os.environ, AUTOCAD_MCP_BACKEND="ezdxf")
result = subprocess.run(
[sys.executable, "-m", "pytest", "--collect-only", "-q"],
cwd=ROOT,
capture_output=True,
text=True,
env=env,
timeout=300,
)
match = re.search(r"(\d+) tests collected", result.stdout)
if not match:
raise SystemExit(
"check_doc_numbers: could not read a collected-test count from pytest.\n"
+ result.stdout[-2000:]
)
return int(match.group(1))
def derived() -> dict[str, object]:
"""Every number a document is allowed to quote, computed from the tree."""
sys.path.insert(0, str(ROOT))
inventory = _json("docs/tool-inventory.json")["totals"]
ab = _json(AB_REPORT)["summary"]
perf = {
w["workload_id"]: w["wall_ms"]
for w in _json("benchmarks/results/published/perf-ezdxf.json")["workloads"]
}
from benchmarks import correctness_suite
from benchmarks.tasks_v5 import TASKS_V5
return {
"collected_tests": _collected_tests(),
"tools": inventory["tools"],
"resources": inventory["resources"],
"prompts": inventory["prompts"],
"groups": inventory["groups"],
"ab_baseline_ref": ab["baseline_ref"],
"ab_total": ab["total"],
"ab_old_pass": ab["old_pass"],
"ab_old_pct": ab["old_pct"],
"ab_fixed": ab["fixed"],
"ab_regressed": ab["regressed"],
"suite_checks": len(correctness_suite.CHECKS),
"matrix_tasks": len(TASKS_V5),
"perf_ms": perf,
}
#: (relative path, human label, regex over the file, callable -> expected value)
#:
#: Each pattern captures the number as group 1. A pattern that matches nothing is
#: itself a failure: the sentence it was written for has moved, and a silent
#: no-op gate is worse than no gate.
CHECKS: list[tuple[str, str, str, str]] = [
("README.md", "release snapshot tools", r"release snapshot:\*\*\s*(\d+) tools", "tools"),
(
"README.md",
"release snapshot resources",
r"release snapshot:.*?(\d+) resources",
"resources",
),
(
"README.md",
"release snapshot prompts",
r"release snapshot:.*?(\d+) prompt templates",
"prompts",
),
("README.md", "A/B suite size", r"(\d+) deterministic headless checks", "ab_total"),
(
"README.md",
"A/B baseline pass",
AB_BASELINE_LABEL + r" \*\(baseline\)\* \| (\d+) / \d+",
"ab_old_pass",
),
(
"README.md",
"A/B suite denominator",
AB_BASELINE_LABEL + r" \*\(baseline\)\* \| \d+ / (\d+)",
"ab_total",
),
("benchmarks/README.md", "A/B suite size", r"(\d+) checks, ezdxf backend", "ab_total"),
(
"benchmarks/README.md",
"A/B baseline pass",
r"\*\*" + AB_BASELINE_LABEL + r"\*\* \(baseline\)\s*\| (\d+) / \d+",
"ab_old_pass",
),
(
"benchmarks/README.md",
"A/B suite denominator",
r"\*\*" + AB_BASELINE_LABEL + r"\*\* \(baseline\)\s*\| \d+ / (\d+)",
"ab_total",
),
(
"README.md",
"release snapshot collected tests",
r"release snapshot:.*?(\d+) collected tests",
"collected_tests",
),
(
"README.md",
"A/B baseline pass rate",
AB_BASELINE_LABEL + r" \*\(baseline\)\* \| \d+ / \d+ \| ([\d.]+) %",
"ab_old_pct",
),
(
"README.md",
"A/B fixed count",
AB_CURRENT_LABEL
+ r"\*\* \*\(this branch\)\* \| \*\*\d+ / \d+\*\* \| \*\*\d+ %\*\* \| (\d+)",
"ab_fixed",
),
(
"benchmarks/README.md",
"A/B baseline pass rate",
r"\*\*" + AB_BASELINE_LABEL + r"\*\* \(baseline\)\s*\| \d+ / \d+ \| ([\d.]+) %",
"ab_old_pct",
),
(
"benchmarks/README.md",
"A/B fixed count",
r"\*\*" + AB_CURRENT_LABEL + r"\*\* \(this branch\) \| \d+ / \d+ \| \d+ % \| (\d+)",
"ab_fixed",
),
("CLAUDE.md", "A/B suite size", r"correctness_suite\.py` \((\d+) checks\)", "ab_total"),
("CLAUDE.md", "matrix task count", r"the current set \((\d+) tasks", "matrix_tasks"),
]
def run(verbose: bool = False) -> int:
values = derived()
if values["suite_checks"] != values["ab_total"]:
print(
f"DRIFT correctness_suite.py has {values['suite_checks']} checks but the published "
f"A/B report was run over {values['ab_total']} -- regenerate the report:\n"
f" python benchmarks/compare_versions.py {values['ab_baseline_ref']} "
f"--json {AB_REPORT}"
)
return 1
if verbose:
for key, value in values.items():
print(f" {key:20s} {value}")
print()
failures = 0
for relative, label, pattern, key in CHECKS:
text = _read(relative)
match = re.search(pattern, text, re.DOTALL)
expected = values[key]
if match is None:
print(
f"DRIFT {relative}: the sentence carrying '{label}' no longer matches its pattern"
)
failures += 1
continue
found = type(expected)(match.group(1))
if found != expected:
print(f"DRIFT {relative}: {label} says {found}, the tree says {expected}")
failures += 1
elif verbose:
print(f" ok {relative:24s} {label:24s} {found}")
if failures:
print(f"\n{failures} document number(s) disagree with the tree.")
return 1
print(f"ok: {len(CHECKS)} document numbers match the tree")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--list", action="store_true", help="Print every derived value")
return run(verbose=parser.parse_args().list)
if __name__ == "__main__":
raise SystemExit(main())