release: v1.5.0 — discovery layer, honesty debt closed, Wave A

Tool count 131 -> 154; 474 -> 1242 collected tests.

The release exists for two measured reasons. A 149-tool catalog costs a client
~40,300 tokens before it has asked for anything, and a drafter searching the way
drafters think -- FILLET, BPOLY, QSELECT, WBLOCK -- scored df=0 against the stock
index, because those words were simply not in the data.

Discovery and batching
- DISCOVERY_MODE=search replaces the catalog with search_tools + call_tool,
  ranked over an AutoCAD command and synonym corpus (discovery/aliases.py) on
  top of fastmcp's BM25 index. Measured: 40,305 -> 356 tokens advertised.
- cad_batch runs a step list in one round trip; fields= projects result-heavy
  responses. The `core` profile is gone -- discovery replaced it.

Honesty debt (Tier 0)
- OCS/ECS: `extrusion` appeared nowhere in this codebase, so mirrored and
  rotated geometry reported coordinates that were quietly wrong. backends/ocs.py
  translates at the backend boundary; every tool coordinate is WCS on both
  engines, and a tilted plane refuses rather than answering with a number that
  looks fine.
- drawing_save_as(fmt="dwg") wrote DXF bytes to a .dwg path. drawing_audit never
  repaired. analysis_measure_area shoelaced points the caller typed in.
- ezdxf calls gained a per-call timeout, so one hung call can no longer wedge a
  server whose document lock is a single asyncio.Lock.

Architecture
- The 1854-line backend ABC is 19 contract modules; @capability(key, reason=)
  makes optional methods declarative and a gate fails on undeclared holes.
- 8 layout/viewport tools, CHSPACE, CAD_PROGID escape hatch.
- Wave A: selection filters, boundary tracing, hatch depth, annotation objects,
  measurement -- 13 tools of a planned 39. The REGION and 2D-boolean family was
  cut on evidence: add_region() yields zero ACIS bytes and the greiner_hormann
  substitute loses 28.2% of the area on a square with one semicircular edge.

Verified against a live AutoCAD 2026, which found four COM defects no amount of
reading would have caught -- AcadPViewport has no ViewCenter, and the line that
set it had been swallowed by a bare except since v1.4.

Benchmark v3 (15 tasks) and what it caught
- v2 scored this server 10/10, which carried no information: every task in it
  exercised something the server was built around. Five tasks were added because
  they can fail, and three did while being written -- a hatch could not be
  measured though the refusal listed HATCH as measurable, the boundary tools and
  the measure tool disagreed about the same shape (139.2177 vs 139.2699), and a
  circle stored as two bulged vertices measured 0.0.
- Competitor reports are NOT re-scored. They are pinned v1.4 runs against v2 and
  were never asked these five questions; the chart shows those rows `not run`.
- Correctness A/B: 21/24 -> 24/24, zero regressions.

Performance, stated plainly
The published v1.4 perf report was recorded on CPython 3.14 and the new one on
3.11.15, so the apparent 2-6x speedup is the interpreter, not this release.
Re-measured on one interpreter, v1.5.0 is 28% slower creating entities and 22%
slower on the 10k roundtrip. Fully attributed: EZDXF_CALL_TIMEOUT=0 restores
v1.4.0's exact numbers. That is the deadlock fix being paid for, the knob is
documented, and winning it back is on the 1.6 roadmap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Umut CAN
2026-08-06 09:45:20 +03:00
co-authored by Claude Opus 5
parent d5a0ff5109
commit b03a7615b1
107 changed files with 32640 additions and 4810 deletions
+211 -2
View File
@@ -65,8 +65,10 @@ def test_parse_fit_code():
@pytest.mark.parametrize(
"code,nominal",
[
("K7", 20.0), # transition hole letters need the delta-rule — excluded
("P7", 20.0),
# K7/P7 used to be here: the delta rule derives them from tables already
# in the module, so they are authored as of 1.5.0 and pinned against
# published values above.
("t6", 20.0), # ISO 286 does not define shaft t below 24 mm
("x6", 20.0), # letter outside the authored subset (regex rejects)
("H3", 20.0), # grade below authored range
("H12", 20.0), # grade above authored range
@@ -116,3 +118,210 @@ def test_fit_passthrough_without_code():
"deviation",
"x",
)
# ── ISO 286 transition / interference HOLES (K, M, N, P) ────────────────────
#
# These were refused by name until 1.5.0. They are not new table data: ISO 286
# derives an upper-letter hole from the same-letter shaft by the delta rule,
#
# ES = -ei + Δ, Δ = IT(n) − IT(n−1)
#
# so every value below comes from tables already authored in this module. The
# expected numbers are the published ISO 286 ones, cross-checked by hand — the
# point of pinning them is that the *rule* is right, not that the arithmetic runs.
@pytest.mark.parametrize(
("code", "nominal", "upper_um", "lower_um"),
[
("K7", 20.0, 6, -15),
("M7", 20.0, 0, -21),
("N7", 20.0, -7, -28),
("P7", 20.0, -14, -35),
("K7", 50.0, 7, -18),
("N7", 50.0, -8, -33),
("P7", 50.0, -17, -42),
("K6", 20.0, 2, -11),
("N6", 20.0, -11, -24),
("P6", 20.0, -18, -31),
],
)
def test_transition_hole_letters_match_published_iso286(code, nominal, upper_um, lower_um):
fit = fit_lookup(code, nominal)
assert fit.upper_mm == pytest.approx(upper_um / 1000.0, abs=1e-9)
assert fit.lower_mm == pytest.approx(lower_um / 1000.0, abs=1e-9)
def test_h7_over_p6_is_a_genuine_interference_fit():
"""The pairing check the tables exist for: the shaft must be bigger."""
hole = fit_lookup("H7", 20.0)
shaft = fit_lookup("p6", 20.0)
assert shaft.lower_mm > hole.upper_mm - hole.it_value_mm, "min shaft > min hole"
assert shaft.lower_mm > 0 and hole.lower_mm == 0
def test_the_delta_collapses_above_the_grade_the_rule_covers():
"""ISO 286 applies Δ only to IT<=8 for K/M/N and IT<=7 for P; then ES = -ei."""
from engineering.fits import _SHAFT_EI, _size_index
index = _size_index(20.0)
n9 = fit_lookup("N9", 20.0)
assert n9.upper_mm == pytest.approx(-_SHAFT_EI["n"][index] / 1000.0, abs=1e-9)
p8 = fit_lookup("P8", 20.0)
assert p8.upper_mm == pytest.approx(-_SHAFT_EI["p"][index] / 1000.0, abs=1e-9)
def test_grade_4_is_refused_rather_than_guessed():
"""Δ needs IT3, which is outside the authored IT4-IT11 range."""
with pytest.raises(ValueError, match="IT5"):
fit_lookup("K4", 20.0)
# ── ISO 286 interference SHAFTS (r, s, t, u) ────────────────────────────────
#
# These need their own fundamental deviations, sub-stepped more finely than the
# main size steps above 50 mm, so unlike K/M/N/P they are table data rather than
# a rule. Authored table data is only as good as its provenance, so every value
# is cross-checked below against ISO 286-1's own derivation formulae — an
# independent source that agrees or does not. What survives is stated; what
# diverges is listed by name, so a reader with the standard checks five numbers
# instead of ninety-five.
@pytest.mark.parametrize(
("code", "nominal", "upper_um", "lower_um"),
[
# ei + IT above the nominal — these are all interference shafts.
("r6", 20.0, 41, 28),
("s6", 20.0, 48, 35),
("u6", 20.0, 54, 41),
("s7", 50.0, 68, 43),
("u6", 30.0, 61, 48), # 24-30 sub-step: u jumps 41 -> 48 inside 18-30
("s6", 60.0, 72, 53), # 50-65 sub-step, IT6 from the main 50-80 step
("t6", 60.0, 85, 66),
],
)
def test_interference_shafts_match_published_iso286(code, nominal, upper_um, lower_um):
fit = fit_lookup(code, nominal)
assert fit.upper_mm == pytest.approx(upper_um / 1000.0, abs=1e-9)
assert fit.lower_mm == pytest.approx(lower_um / 1000.0, abs=1e-9)
def test_the_sub_steps_are_real_and_not_flattened():
"""Above 50 mm these letters split the main steps; collapsing them is a
silent error of tens of microns."""
assert fit_lookup("u6", 55.0).lower_mm != fit_lookup("u6", 70.0).lower_mm
assert fit_lookup("u6", 20.0).lower_mm != fit_lookup("u6", 28.0).lower_mm
def test_t_is_refused_below_24_mm_because_iso_does_not_define_it():
with pytest.raises(ValueError, match="24"):
fit_lookup("t6", 20.0)
assert fit_lookup("t6", 30.0).lower_mm > 0
# -- provenance: the table against ISO 286-1's own formulae ------------------
def test_r_is_the_geometric_mean_of_p_and_s():
"""ISO 286 *defines* r that way, and p is already pinned in this module.
24/24 sub-steps agree within 1 um, which validates r and s together against
data that was trusted before either was authored.
"""
import math
from engineering.fits import _FINE_SIZE_STEPS, _SHAFT_EI, _SHAFT_EI_FINE, _size_index
for index, step in enumerate(_FINE_SIZE_STEPS):
mid = (step[0] + step[1]) / 2.0
p = _SHAFT_EI["p"][_size_index(mid)]
s = _SHAFT_EI_FINE["s"][index]
r = _SHAFT_EI_FINE["r"][index]
assert abs(round(math.sqrt(p * s)) - r) <= 1, f"r at {step}: table {r}, sqrt(p*s) {p}/{s}"
#: Steps where ISO 286's rounding rules put the published value more than 1 um
#: from the formula. Listed rather than hidden: these are the only values a
#: reader with the standard needs to check by hand.
KNOWN_FORMULA_DIVERGENCES = {
("u", (250, 280)),
("u", (400, 450)),
("u", (450, 500)),
("t", (24, 30)),
("t", (450, 500)),
}
#: Below this the formulae do not describe the published tables at all — ISO
#: tabulates the small sizes directly. Measured for u: the formula is 6 um low at
#: 1-3 mm and only converges from 18 mm up (-6, -7, -5, -2, +1). Scoping the
#: check here is a statement about where the cross-check has authority, not a
#: threshold moved until the test passed.
FORMULA_VALID_FROM_MM = 18
@pytest.mark.parametrize(("letter", "coefficient"), [("t", 0.63), ("u", 1.0)])
def test_t_and_u_track_their_iso_formula(letter, coefficient):
"""ISO 286-1: ei = IT7 + k*D, D the geometric mean of the step bounds.
The standard rounds its published tables, so exact equality is not the
claim — agreement within 1 um everywhere except a listed handful is.
"""
import math
from engineering.fits import _FINE_SIZE_STEPS, _SHAFT_EI_FINE, _it_um, _size_index
checked = 0
for index, step in enumerate(_FINE_SIZE_STEPS):
ei = _SHAFT_EI_FINE[letter][index]
if ei is None or step[0] < FORMULA_VALID_FROM_MM:
continue
checked += 1
mid = (step[0] + step[1]) / 2.0
it7 = _it_um(7, _size_index(mid))
formula = it7 + coefficient * math.sqrt(step[0] * step[1])
drift = abs(round(formula) - ei)
if (letter, step) in KNOWN_FORMULA_DIVERGENCES:
assert 1 < drift <= 3, f"{letter} at {step} drifted {drift} um — re-check the table"
else:
assert drift <= 1, f"{letter} at {step}: table {ei}, formula {formula:.1f}"
assert checked >= 18, f"only {checked} steps cross-checked for {letter}"
def test_s_above_50_tracks_its_iso_formula():
import math
from engineering.fits import _FINE_SIZE_STEPS, _SHAFT_EI_FINE, _it_um, _size_index
for index, step in enumerate(_FINE_SIZE_STEPS):
if step[0] < 50:
continue
mid = (step[0] + step[1]) / 2.0
formula = _it_um(7, _size_index(mid)) + 0.4 * math.sqrt(step[0] * step[1])
assert abs(round(formula) - _SHAFT_EI_FINE["s"][index]) <= 1, f"s at {step}"
def test_s_below_50_sits_in_the_iso_band_above_it8():
"""ISO 286: for D <= 50, s is IT8 plus a small step. Monotone, 0..4 um."""
from engineering.fits import _FINE_SIZE_STEPS, _SHAFT_EI_FINE, _it_um, _size_index
deltas = []
for index, step in enumerate(_FINE_SIZE_STEPS):
if step[0] >= 50:
break
mid = (step[0] + step[1]) / 2.0
deltas.append(_SHAFT_EI_FINE["s"][index] - _it_um(8, _size_index(mid)))
assert all(0 <= d <= 4 for d in deltas), deltas
assert deltas == sorted(deltas), f"the step above IT8 must not decrease: {deltas}"
def test_h7_over_s6_is_tighter_than_h7_over_p6():
"""The reason these letters exist: progressively heavier interference."""
p6 = fit_lookup("p6", 20.0).lower_mm
r6 = fit_lookup("r6", 20.0).lower_mm
s6 = fit_lookup("s6", 20.0).lower_mm
u6 = fit_lookup("u6", 20.0).lower_mm
assert p6 < r6 < s6 < u6