From d80ae1741165769c9ebf1e93c085b8d63bbce827 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Wed, 16 Sep 2026 12:10:14 +0300 Subject: [PATCH] =?UTF-8?q?feat(pid):=20pid=5Ffrom=5Fspec=20=E2=80=94=20a?= =?UTF-8?q?=20whole=20P&ID=20in=20one=20transaction,=20graph=20and=20criti?= =?UTF-8?q?que=20returned?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- README.md | 2 +- discovery/aliases.py | 9 + docs/assets/pid-showcase.png | Bin 0 -> 26889 bytes docs/tool-inventory.json | 19 +- engineering/pid/spec.py | 436 +++++++++++++++++++++++++++++++++++ scripts/render_readme_pid.py | 61 +++++ server.py | 34 ++- tests/test_pid_from_spec.py | 87 +++++++ tests/test_tool_registry.py | 9 +- 9 files changed, 649 insertions(+), 8 deletions(-) create mode 100644 docs/assets/pid-showcase.png create mode 100644 engineering/pid/spec.py create mode 100644 scripts/render_readme_pid.py create mode 100644 tests/test_pid_from_spec.py diff --git a/README.md b/README.md index 72b2189..bfd25ff 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co -> **v1.5 release snapshot:** 164 tools · 6 resources · 5 prompt templates · 1369 collected tests. +> **v1.5 release snapshot:** 165 tools · 6 resources · 5 prompt templates · 1369 collected tests. > 154 is the **registered** count; a default install advertises 149 over `tools/list`, > because `ENABLE_3D` is unset. `system_about` is the runtime authority. diff --git a/discovery/aliases.py b/discovery/aliases.py index bbc8ba2..fec4ebe 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -1408,6 +1408,15 @@ TOOL_ALIASES: dict[str, ToolAliases] = { acad=(), synonyms=("equipment list", "equipment schedule", "valve list"), ), + "pid_from_spec": ToolAliases( + acad=(), + synonyms=( + "whole p&id in one call", + "p&id from json", + "draw the process from a spec", + "batch p&id", + ), + ), } diff --git a/docs/assets/pid-showcase.png b/docs/assets/pid-showcase.png new file mode 100644 index 0000000000000000000000000000000000000000..a05d5c3a2745f86de9e6fda99929d8d9e4f3e8fb GIT binary patch literal 26889 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 literal 0 HcmV?d00001 diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index 685dd20..cdd5895 100644 --- a/docs/tool-inventory.json +++ b/docs/tool-inventory.json @@ -3,7 +3,7 @@ "schema_version": 1, "package_version": "1.5.1", "totals": { - "tools": 164, + "tools": 165, "resources": 6, "prompts": 5, "groups": 20 @@ -21,7 +21,7 @@ "entity_query": 9, "layers": 14, "layouts": 12, - "pid": 7, + "pid": 8, "premium": 12, "solids": 5, "system": 7, @@ -1731,6 +1731,21 @@ "readOnlyHint": true } }, + { + "name": "pid_from_spec", + "group": "pid", + "cost": "mutate", + "summary": "Draw a whole P&ID from one declarative spec in one transaction; returns graph + critique.", + "tags": [ + "batch", + "create", + "pid" + ], + "annotations": { + "title": "P&ID: From Spec", + "readOnlyHint": false + } + }, { "name": "pid_graph", "group": "pid", diff --git a/engineering/pid/spec.py b/engineering/pid/spec.py new file mode 100644 index 0000000..1f5d01b --- /dev/null +++ b/engineering/pid/spec.py @@ -0,0 +1,436 @@ +"""``pid_from_spec``: a whole P&ID from one declarative document, in one transaction.""" + +from __future__ import annotations + +import copy +from typing import TYPE_CHECKING + +from .critique import PID_FOCUSES, issues_for +from .drawlines import draw_line +from .graph import build_graph +from .insert import place_symbol +from .lines import AXIS, LINE_CLASSES, count_crossings, route, snap_axis +from .symbols import resolve, transform_port + +if TYPE_CHECKING: + from backends.base import AutoCADBackend + +EXAMPLE_SPEC: dict = { + "sheet": { + "layer_set": "pid", + "sheet_size": "A3", + "scale": 1.0, + "intent": "Feed section P&ID", + }, + "equipment": [ + { + "id": "P-101", + "symbol": "centrifugal_pump", + "x": 100, + "y": 100, + "rotation": 0, + "tag": "P-101", + "desc": "Feed pump", + }, + { + "id": "V-201", + "symbol": "vertical_vessel", + "x": 220, + "y": 120, + "tag": "V-201", + "params": { + "width": 20, + "height": 40, + "nozzles": [ + {"name": "N1", "side": "top", "fraction": 0.5}, + {"name": "N2", "side": "left", "fraction": 0.3}, + ], + }, + }, + ], + "valves": [ + { + "id": "FCV-101", + "symbol": "globe", + "actuator": "diaphragm", + "x": 160, + "y": 104, + "tag": "FCV-101", + "fail": "FC", + }, + ], + "instruments": [ + { + "id": "FIC-101", + "type": "dcs", + "location": "primary", + "x": 160, + "y": 140, + "tag": "FIC-101", + }, + ], + "lines": [ + { + "from": "P-101.discharge", + "to": "FCV-101.in", + "class": "process_major", + "number": "100-P-1001-CS1", + }, + { + "from": "FCV-101.out", + "to": "V-201.N2", + "class": "process_major", + "number": "100-P-1001-CS1", + }, + {"from": "FIC-101", "to": "FCV-101.signal", "class": "electric"}, + ], + "connectors": [ + { + "id": "OP-1", + "direction": "out", + "x": 300, + "y": 120, + "tag": "TO P&ID-002", + "link": "L-17", + "from": "V-201.N1", + "class": "process_major", + }, + ], +} + +_SECTIONS = ("equipment", "valves", "instruments", "connectors") +_TOP_KEYS = {"sheet", "lines", *_SECTIONS} +_ITEM_OPTIONS = { + "equipment": {"symbol", "x", "y", "rotation", "scale", "tag", "desc", "params", "shape"}, + "valves": {"symbol", "x", "y", "rotation", "scale", "tag", "desc", "actuator", "fail"}, + "instruments": {"x", "y", "rotation", "scale", "tag", "type", "location"}, + "connectors": { + "x", + "y", + "rotation", + "scale", + "tag", + "link", + "direction", + "from", + "to", + "class", + "number", + }, +} +_LINE_KEYS = { + "from", + "to", + "class", + "number", + "size", + "service", + "spec", + "insulation", + "route", + "stub", + "label", + "arrow", +} + + +def _num(value, where: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{where} must be a number") + return float(value) + + +def _ref(value, where: str, ids: set[str]) -> tuple[str, str | None]: + if not isinstance(value, str) or not value: + raise ValueError(f"{where} must be 'ID' or 'ID.port'") + item_id, _, port = value.partition(".") + if item_id not in ids: + raise ValueError(f"{where}: unknown id {item_id!r}") + return item_id, port or None + + +def _line_class(line: dict, where: str) -> str: + cls = line.get("class", "process_major") + if cls not in LINE_CLASSES: + raise ValueError(f"{where}.class must be one of {', '.join(LINE_CLASSES)}") + return cls + + +def validate_spec(spec: dict) -> dict: + """Normalise a spec document; every error names the offending path. + + Nothing is created here, so a malformed spec never reaches the drawing. + """ + if not isinstance(spec, dict): + raise ValueError("spec must be an object") + unknown = set(spec) - _TOP_KEYS + if unknown: + raise ValueError(f"unknown top-level key(s): {', '.join(sorted(unknown))}") + out = {key: copy.deepcopy(spec.get(key) or ([] if key != "sheet" else {})) for key in _TOP_KEYS} + if not isinstance(out["sheet"], dict): + raise ValueError("sheet must be an object") + ids: set[str] = set() + for section in _SECTIONS: + items = out[section] + if not isinstance(items, list): + raise ValueError(f"{section} must be a list") + for i, item in enumerate(items): + where = f"{section}[{i}]" + if not isinstance(item, dict): + raise ValueError(f"{where} must be an object") + item_id = item.get("id") + if not isinstance(item_id, str) or not item_id: + raise ValueError(f"{where}.id must be a non-empty string") + if item_id in ids: + raise ValueError(f"{where}.id {item_id!r} is repeated") + ids.add(item_id) + bad = set(item) - _ITEM_OPTIONS[section] - {"id"} + if bad: + raise ValueError(f"{where}: unknown key(s) {', '.join(sorted(bad))}") + for key in ("x", "y"): + if key not in item: + raise ValueError(f"{where}.{key} is required") + item[key] = _num(item[key], f"{where}.{key}") + for key in ("rotation", "scale"): + if key in item: + item[key] = _num(item[key], f"{where}.{key}") + if section in ("equipment", "valves") and not isinstance(item.get("symbol"), str): + raise ValueError(f"{where}.symbol is required") + if section == "connectors": + if item.get("direction") not in ("in", "out"): + raise ValueError(f"{where}.direction must be 'in' or 'out'") + has_from, has_to = "from" in item, "to" in item + if item["direction"] == "out" and (not has_from or has_to): + raise ValueError(f"{where}: an 'out' connector carries exactly 'from'") + if item["direction"] == "in" and (not has_to or has_from): + raise ValueError(f"{where}: an 'in' connector carries exactly 'to'") + lines = out["lines"] + if not isinstance(lines, list): + raise ValueError("lines must be a list") + for i, line in enumerate(lines): + where = f"lines[{i}]" + if not isinstance(line, dict): + raise ValueError(f"{where} must be an object") + bad = set(line) - _LINE_KEYS + if bad: + raise ValueError(f"{where}: unknown key(s) {', '.join(sorted(bad))}") + for key in ("from", "to"): + if key not in line: + raise ValueError(f"{where}.{key} is required") + _ref(line[key], f"{where}.{key}", ids) + line["class"] = _line_class(line, where) + if "stub" in line: + line["stub"] = _num(line["stub"], f"{where}.stub") + for i, conn in enumerate(out["connectors"]): + key = "from" if conn["direction"] == "out" else "to" + _ref(conn[key], f"connectors[{i}].{key}", ids) + conn["class"] = _line_class(conn, f"connectors[{i}]") + return out + + +def _spec_for(section: str, item: dict): + """Resolve the SymbolSpec an item would place (no backend).""" + if section == "instruments": + return resolve("instrument", type=item.get("type"), location=item.get("location")) + if section == "connectors": + return resolve("offpage", direction=item["direction"]) + return resolve( + item["symbol"], + actuator=item.get("actuator"), + params=item.get("params"), + shape=item.get("shape"), + ) + + +def _connector_lines(norm: dict) -> list[tuple[str, dict]]: + """The implicit line each connector carries, as ``(where, line)``.""" + out = [] + for i, conn in enumerate(norm["connectors"]): + if conn["direction"] == "out": + line = {"from": conn["from"], "to": conn["id"]} + else: + line = {"from": conn["id"], "to": conn["to"]} + line.update({"class": conn["class"], "number": conn.get("number")}) + out.append((f"connectors[{i}]", line)) + return out + + +def _all_lines(norm: dict) -> list[tuple[str, dict]]: + return [(f"lines[{i}]", line) for i, line in enumerate(norm["lines"])] + _connector_lines(norm) + + +def _plan_lines(norm: dict) -> list[dict]: + """Route every line in memory from catalogue ports — the dry run.""" + placed: dict[str, dict] = {} + for section in _SECTIONS: + for i, item in enumerate(norm[section]): + try: + spec = _spec_for(section, item) + except ValueError as exc: + raise ValueError(f"{section}[{i}]: {exc}") from exc + ports = { + p.name: transform_port( + p, item["x"], item["y"], item.get("rotation", 0.0), item.get("scale", 1.0) + ) + for p in spec.ports + } + placed[item["id"]] = {"family": spec.family, "ports": ports} + + def endpoint(ref: str) -> dict: + item_id, _, port = ref.partition(".") + entry = placed[item_id] + if not port: + if len(entry["ports"]) == 1: + port = next(iter(entry["ports"])) + elif entry["family"] == "instrument" and "signal" in entry["ports"]: + port = "signal" + else: + raise ValueError(f"{ref}: choose a port: {', '.join(entry['ports'])}") + if port not in entry["ports"]: + raise ValueError(f"{ref}: no port {port!r}; ports: {', '.join(entry['ports'])}") + return entry["ports"][port] + + def anchor(ep: dict, other: dict): + if ep["radius"] > 0: + axis = snap_axis(other["x"] - ep["x"], other["y"] - ep["y"]) + ux, uy = AXIS[axis] + return (ep["x"] + ux * ep["radius"], ep["y"] + uy * ep["radius"]), axis + return (ep["x"], ep["y"]), ep["direction_deg"] + + planned: list[dict] = [] + for index, (where, line) in enumerate(_all_lines(norm)): + try: + start, end = endpoint(line["from"]), endpoint(line["to"]) + s, sd = anchor(start, end) + e, ed = anchor(end, start) + vertices = route( + s, sd, e, ed, stub=float(line.get("stub", 5.0)), mode=line.get("route", "auto") + ) + except ValueError as exc: + raise ValueError(f"{where}: {exc}") from exc + crossings = count_crossings(vertices, [p["vertices"] for p in planned]) + planned.append( + { + "index": index, + "where": where, + "from": line["from"], + "to": line["to"], + "class": line["class"], + "vertices": [[x, y] for x, y in vertices], + "crossings": crossings, + } + ) + return planned + + +async def _place(backend: AutoCADBackend, section: str, item: dict) -> dict: + common = ( + item["x"], + item["y"], + item.get("rotation", 0.0), + item.get("scale", 1.0), + item.get("tag"), + ) + if section == "instruments": + return await place_symbol( + backend, + "instrument", + *common, + type=item.get("type"), + location=item.get("location"), + ) + if section == "connectors": + return await place_symbol( + backend, + "offpage", + *common, + link=item.get("link"), + direction=item["direction"], + ) + return await place_symbol( + backend, + item["symbol"], + *common, + desc=item.get("desc"), + fail=item.get("fail"), + actuator=item.get("actuator"), + params=item.get("params"), + shape=item.get("shape"), + ) + + +async def run_spec(backend: AutoCADBackend, spec: dict, dry_run: bool = False) -> dict: + """Validate, then place every item and draw every line inside one transaction. + + ``dry_run`` routes from catalogue ports in memory and touches nothing. + """ + norm = validate_spec(spec) + if dry_run: + planned = _plan_lines(norm) + return { + "dry_run": True, + "items": sum(len(norm[s]) for s in _SECTIONS), + "lines": planned, + "crossings_total": sum(p["crossings"] for p in planned), + } + sheet = norm["sheet"] + if backend.get_plan_spec() is None: + await backend.drawing_plan( + sheet.get("intent", "P&ID"), + sheet.get("sheet_size", "A3"), + float(sheet.get("scale", 1.0)), + sheet.get("layer_set", "pid"), + ) + begun = await backend.transaction_begin() + if not isinstance(begun, dict) or not begun.get("ok"): + raise RuntimeError(f"pid_from_spec: could not open a transaction: {begun}") + handles: dict[str, str] = {} + crossings_total = 0 + try: + for section in _SECTIONS: + for i, item in enumerate(norm[section]): + try: + placed = await _place(backend, section, item) + except ValueError as exc: + raise ValueError(f"{section}[{i}]: {exc}") from exc + handles[item["id"]] = placed["handle"] + + def ref(value: str) -> dict: + item_id, _, port = value.partition(".") + if port: + return {"handle": handles[item_id], "port": port} + return {"handle": handles[item_id]} + + for where, line in _all_lines(norm): + try: + drawn = await draw_line( + backend, + ref(line["from"]), + ref(line["to"]), + line["class"], + line.get("route", "auto"), + float(line.get("stub", 5.0)), + line.get("number"), + line.get("size"), + line.get("service"), + line.get("spec"), + line.get("insulation"), + None, + bool(line.get("label", True)), + line.get("arrow"), + ) + except ValueError as exc: + raise ValueError(f"{where}: {exc}") from exc + crossings_total += drawn["crossings"] + except Exception: + await backend.transaction_rollback() + raise + await backend.transaction_commit() + graph = await build_graph(backend) + critique = [issue.to_dict() for focus in PID_FOCUSES for issue in issues_for(focus, graph)] + return { + "handles": handles, + "graph": graph, + "critique": critique, + "crossings_total": crossings_total, + } diff --git a/scripts/render_readme_pid.py b/scripts/render_readme_pid.py new file mode 100644 index 0000000..099a806 --- /dev/null +++ b/scripts/render_readme_pid.py @@ -0,0 +1,61 @@ +"""Render the P&ID the README shows, using only the server's own tools. + +The sheet is ``EXAMPLE_SPEC`` from ``engineering/pid/spec.py`` -- the same +document ``pid_from_spec`` draws in one transaction -- placed and routed +through the same backend methods the MCP tools call, then rendered headlessly. +If a symbol builder or the router regresses, the picture changes with it. + +Reproduce: + + AUTOCAD_MCP_BACKEND=ezdxf uv run --frozen python scripts/render_readme_pid.py +""" + +from __future__ import annotations + +import argparse +import asyncio +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +DEFAULT_OUTPUT = ROOT / "docs" / "assets" / "pid-showcase.png" + + +async def render(output: Path) -> dict: + from backends.ezdxf_backend import EzdxfBackend + from engineering.layers import apply_layer_set + from engineering.pid.spec import EXAMPLE_SPEC, run_spec + + backend = EzdxfBackend() + await backend.connect() + try: + await backend.drawing_new() + await apply_layer_set(backend, "pid") + result = await run_spec(backend, EXAMPLE_SPEC) + png = await backend.view_screenshot() + if not png: + raise RuntimeError("view_screenshot returned no PNG (is matplotlib installed?)") + output.parent.mkdir(parents=True, exist_ok=True) + output.write_bytes(png) + return { + "output": str(output), + "bytes": len(png), + "dangling": result["graph"]["stats"]["dangling"], + "critique": len(result["critique"]), + } + finally: + await backend.disconnect() + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) + args = parser.parse_args() + print(asyncio.run(render(args.output))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/server.py b/server.py index 5d2bcf8..6916f80 100644 --- a/server.py +++ b/server.py @@ -6369,7 +6369,7 @@ async def solid_boolean( # --------------------------------------------------------------------------- -# ── SECTION 17: P&ID (7 tools) ────────────────────────────────────────────── +# ── SECTION 17: P&ID (8 tools) ────────────────────────────────────────────── # --------------------------------------------------------------------------- @@ -6681,6 +6681,38 @@ async def pid_equipment_list( ) +@cad_tool( + summary=( + "Draw a whole P&ID from one declarative spec in one transaction; returns graph + critique." + ), + cost="mutate", +) +@mcp.tool( + annotations={"title": "P&ID: From Spec", "readOnlyHint": False}, + tags={"pid", "create", "batch"}, +) +async def pid_from_spec( + spec: Annotated[ + dict, + "{sheet, equipment[], valves[], instruments[], lines[], connectors[]} — see the P&ID prompt", + ], + dry_run: Annotated[bool, "Validate and route in memory; draw nothing"] = False, + ctx: Context = None, +) -> dict: + """Equipment, valves, instruments and connectors placed, then every line + drawn port-to-port, inside one transaction: any bad item rolls the whole + sheet back and the error names it (`lines[2].to`). The response carries the + graph read back from the drawing and the P&ID critique issues, so the + caller sees dangling ends or duplicate tags in the same round trip. + `dry_run` returns the planned vertices and crossings without touching the + drawing. + """ + from engineering.pid.spec import run_spec + + await ctx.info("P&ID from spec" + (" (dry run)" if dry_run else "")) + return await run_spec(_backend(ctx), spec, dry_run) + + # --------------------------------------------------------------------------- # ── RESOURCES ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/tests/test_pid_from_spec.py b/tests/test_pid_from_spec.py new file mode 100644 index 0000000..d0bc871 --- /dev/null +++ b/tests/test_pid_from_spec.py @@ -0,0 +1,87 @@ +"""pid_from_spec: one call, one transaction, graph + critique back.""" + +from __future__ import annotations + +import copy + +import pytest +import pytest_asyncio +from fastmcp import Client + +import server +from engineering.pid.spec import EXAMPLE_SPEC, run_spec, validate_spec + +pytestmark = pytest.mark.asyncio + + +async def test_example_draws_a_connected_pid(backend): + result = await run_spec(backend, EXAMPLE_SPEC) + assert set(result["handles"]) == {"P-101", "V-201", "FCV-101", "FIC-101", "OP-1"} + graph = result["graph"] + assert graph["stats"]["nodes_by_kind"] == { + "connector": 1, + "equipment": 2, + "instrument": 1, + "valve": 1, + } + assert len(graph["edges"]) == 4 and graph["stats"]["dangling"] == 0 + assert result["critique"] == [] and result["crossings_total"] == 0 + assert backend.get_plan_spec()["layer_set_id"] == "pid" + + +async def test_bad_reference_rolls_back(backend): + before = await backend.entity_count() + bad = copy.deepcopy(EXAMPLE_SPEC) + bad["lines"].append({"from": "P-101.discharge", "to": "V-201.NOPE", "class": "process_major"}) + with pytest.raises(ValueError, match=r"lines\[3\]"): + await run_spec(backend, bad) + assert await backend.entity_count() == before + + +async def test_dry_run_routes_in_memory_and_writes_nothing(backend): + before = await backend.entity_count() + result = await run_spec(backend, EXAMPLE_SPEC, dry_run=True) + assert result["dry_run"] is True and len(result["lines"]) == 4 + assert all(len(line["vertices"]) >= 2 for line in result["lines"]) + assert await backend.entity_count() == before + + +@pytest.mark.parametrize( + "mutate, fragment", + [ + ( + lambda s: s["equipment"].append({"id": "P-101", "symbol": "fan", "x": 0, "y": 0}), + "equipment[2].id", + ), + (lambda s: s["lines"][0].update({"class": "steam"}), "lines[0].class"), + (lambda s: s["instruments"][0].pop("x"), "instruments[0].x"), + (lambda s: s.update({"bogus": []}), "bogus"), + (lambda s: s["connectors"][0].update({"to": "V-201.N1"}), "connectors[0]"), + ], +) +async def test_validation_names_the_path(mutate, fragment): + spec = copy.deepcopy(EXAMPLE_SPEC) + mutate(spec) + with pytest.raises(ValueError) as exc: + validate_spec(spec) + assert fragment in str(exc.value) + + +@pytest_asyncio.fixture +async def client(monkeypatch): + monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf") + async with Client(server.mcp) as connected: + await connected.call_tool("drawing_new", {}) + yield connected + + +async def test_finalize_scores_the_example_at_ninety_or_better(client, tmp_path): + result = (await client.call_tool("pid_from_spec", {"spec": EXAMPLE_SPEC})).structured_content + assert result["graph"]["stats"]["dangling"] == 0 + # The validator's first step refuses an unsaved drawing (`not_saved`), so + # finalize saves first — the same shape `tests/test_pid_critique.py` uses. + final = ( + await client.call_tool("drawing_finalize", {"save_path": str(tmp_path / "pid.dxf")}) + ).structured_content + assert final["score"]["score"] >= 90, final["score"] + assert not [i for i in final.get("critique", []) if str(i.get("focus", "")).startswith("pid_")] diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index 708f0f1..2218e9b 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -512,8 +512,9 @@ async def test_tool_group_sizes_are_unchanged(): `pid_symbol_insert`, then 2 -> 3 when `pid_line_draw` joined, 3 -> 4 when the reader `pid_graph` joined and 4 -> 7 when the three graph deliverables (`pid_instrument_index` / `pid_line_list` / - `pid_equipment_list`) joined; the `pid` tag is ranked first in - `_GROUP_TAG_PRIORITY` so those tools file under + `pid_equipment_list`) joined and 7 -> 8 when `pid_from_spec` (a whole + sheet from one declarative spec, in one transaction) joined; the `pid` + tag is ranked first in `_GROUP_TAG_PRIORITY` so those tools file under `pid` rather than under their secondary `query` / `create` tags. Every other number here has been unchanged since the snapshot was taken. """ @@ -531,7 +532,7 @@ async def test_tool_group_sizes_are_unchanged(): "entity_query": 9, "layers": 14, "layouts": 12, - "pid": 7, + "pid": 8, "premium": 12, "solids": 5, "system": 7, @@ -540,4 +541,4 @@ async def test_tool_group_sizes_are_unchanged(): "validation": 1, "view": 4, } - assert sum(sizes.values()) == 164 + assert sum(sizes.values()) == 165