fix(task-16): a P&ID line ending on an unknown_block is dangling — a 0.3 guess suppresses no finding either

715c8ef applied the evidence rule in one direction only: an `unknown_block`
(any INSERT a plain LINE happened to touch, filed at 0.3) could not *create*
a pid_dangling_line, but it could still *suppress* one. build_graph attaches
a line end that lands on such a box and invents port `p1` at the line's own
end, so `graph["dangling"]` is empty and `_dangling`, which only read that
list, called the line connected. A process line from P-101's discharge to
the sheet frame (a CTO title block is a block reference) passed
drawing_critique and the drawing_finalize gate; the same line stopping 2 mm
short of the frame was an error. Spec §11.1 says the end resolves to nothing
in both cases — a frame, a logo or a note symbol is nothing the drawing
vouches for.

`_dangling` now reads two sources with one rule: the graph's own dangling
rows (filtered to evidenced edges, as before) plus every end of an evidenced
edge whose ref points at an `unknown_block`, at that inferred port's x/y.
The `nearest` hint is computed the way the graph computes it for a free end
(nearest port within 10·tolerance, same space), over recognised nodes' ports
only, so the invented `p1` can never be the suggested fix; `detail["touches"]`
names the block the end sits on. The other direction is unchanged: a note
leader whose only anchor is an unknown_block is not evidenced and stays out.

Tests: the reviewer's repro (landing on the frame → error; graph attaches it,
dangling=[]), the stop-short control, the finalize gate raising on it, and
the nearest-port hint naming a catalogue valve's port 1 mm away rather than
the frame's inferred port.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-16 11:51:22 +03:00
co-authored by Claude Opus 5
parent 715c8efca4
commit 3c4fbf6c42
2 changed files with 165 additions and 12 deletions
+96
View File
@@ -232,6 +232,102 @@ async def test_a_note_leader_off_an_unknown_block_on_a_pid_is_not_dangling(backe
assert len(issues) == 1 and issues[0].detail["x"] == pytest.approx(220.0)
async def _frame_and_pump(backend):
"""A sheet frame as a block reference (a CTO title block is one) and a
catalogue pump: the shape that files the frame as an ``unknown_block`` at
0.3 the moment a P&ID line stops on its border."""
await backend.drawing_apply_iso_layers("pid")
await backend.block_define(
"FRAME_A3",
[{"type": "polyline", "points": [[0, 0], [420, 0], [420, 297], [0, 297]], "closed": True}],
)
frame = await backend.block_insert("FRAME_A3", 0, 0, layer="TITLEBLOCK")
pump = await place_symbol(backend, "centrifugal_pump", 100, 100, tag="P-101")
return frame.handle, pump
async def test_a_pid_line_ending_on_a_sheet_frame_insert_is_dangling(backend):
"""Spec §11.1: an edge end that resolves to nothing is dangling. The graph
attaches the process line to the frame's box and invents port ``p1`` at
the line's own end, so its ``dangling`` list is empty — but a 0.3 touch
guess is nothing the drawing vouches for, and it can no more suppress a
finding than create one. Landing on the frame and stopping 2 mm short of
it must be the same error."""
frame, pump = await _frame_and_pump(backend)
line = await draw_line(
backend, {"handle": pump["handle"], "port": "discharge"}, {"x": 420, "y": 106}
)
graph = await build_graph(backend, include_foreign=True)
node = next(n for n in graph["nodes"] if n["id"] == frame)
assert node["kind"] == "unknown_block" and node["ports"]["p1"]["inferred"] is True
edge = next(e for e in graph["edges"] if e["id"] == line["handle"])
assert edge["to"] == {"node": frame, "port": "p1"} and graph["dangling"] == []
issues = await backend.drawing_critique(list(PID_FOCUSES))
assert [(i.severity, i.focus, i.handles, i.detail["end"]) for i in issues] == [
("error", "pid_dangling_line", [line["handle"]], "to")
]
assert issues[0].detail["x"] == pytest.approx(420.0)
assert issues[0].detail["y"] == pytest.approx(106.0)
assert issues[0].detail["touches"] == frame
assert issues[0].detail["nearest"] is None and "hint" in issues[0].detail
async def test_stopping_short_of_the_frame_is_the_same_finding(backend):
_frame, pump = await _frame_and_pump(backend)
line = await draw_line(
backend, {"handle": pump["handle"], "port": "discharge"}, {"x": 418, "y": 106}
)
issues = await backend.drawing_critique(["pid_dangling_line"])
assert [(i.severity, i.handles, i.detail["end"]) for i in issues] == [
("error", [line["handle"]], "to")
]
async def test_finalize_gate_fails_a_pid_line_that_ends_on_the_frame(backend, tmp_path):
"""The gate runs ``focus=None``; a process line "connected" to the title
block must not pass it."""
import server
class _Ctx:
def __init__(self, backend):
self.lifespan_context = {"backend": backend}
async def warning(self, message):
pass
from fastmcp.exceptions import ToolError
_frame, pump = await _frame_and_pump(backend)
await draw_line(backend, {"handle": pump["handle"], "port": "discharge"}, {"x": 420, "y": 106})
with pytest.raises(ToolError, match="pid_dangling_line"):
await server.drawing_finalize(save_path=str(tmp_path / "pid.dxf"), ctx=_Ctx(backend))
async def test_unknown_block_end_hint_names_the_nearest_recognised_port(backend):
"""The hint a free end gets from the graph (nearest port within
10·tolerance) is computed the same way for an end the graph hung on an
``unknown_block``: over ports the drawing vouches for, never the invented
``p1``."""
frame, pump = await _frame_and_pump(backend)
cv = await place_symbol(backend, "globe", 418, 106, tag="FCV-101")
port = cv["ports"]["in"]
await backend.entity_move(frame, port["x"] - 420, 0)
out = pump["ports"]["discharge"]
line = await backend.entity_create_line(
out["x"], out["y"], port["x"], port["y"] + 1.0, layer="PROCESS-PIPING-MAIN"
)
graph = await build_graph(backend, include_foreign=True)
edge = next(e for e in graph["edges"] if e["id"] == line.handle)
assert edge["to"]["node"] == frame, "the end sits on the frame's border, 1 mm off the port"
issues = await backend.drawing_critique(["pid_dangling_line"])
assert [i.handles for i in issues] == [[line.handle]]
nearest = issues[0].detail["nearest"]
assert nearest["node"] == cv["handle"] and nearest["port"] == "in"
assert nearest["distance"] == pytest.approx(1.0)
assert f"'{cv['handle']}'" in issues[0].detail["hint"]
async def test_readme_gear_sheet_is_untouched_by_pid_focuses(backend):
"""The spec's own negative: all six return [] on the README gear sheet."""
import importlib.util