gdsdiff 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gdsdiff/__init__.py +148 -0
- gdsdiff/__main__.py +7 -0
- gdsdiff/_environment.py +12 -0
- gdsdiff/_version.py +34 -0
- gdsdiff/acceptance_evidence.py +530 -0
- gdsdiff/api.py +2955 -0
- gdsdiff/backends/__init__.py +26 -0
- gdsdiff/backends/base.py +77 -0
- gdsdiff/backends/boundary_loop_ctypes.py +191 -0
- gdsdiff/backends/cpu.py +20 -0
- gdsdiff/backends/cpu_ctypes.py +255 -0
- gdsdiff/backends/cuda.py +40 -0
- gdsdiff/backends/cuda_ctypes.py +1297 -0
- gdsdiff/backends/exact.py +424 -0
- gdsdiff/backends/geometry_ctypes.py +252 -0
- gdsdiff/backends/identity.py +53 -0
- gdsdiff/backends/metal.py +198 -0
- gdsdiff/backends/metal_ctypes.py +866 -0
- gdsdiff/backends/polygon_extract_ctypes.py +233 -0
- gdsdiff/backends/structural_ctypes.py +130 -0
- gdsdiff/boundary_loops.py +616 -0
- gdsdiff/cache.py +544 -0
- gdsdiff/canonicalize.py +176 -0
- gdsdiff/cli.py +352 -0
- gdsdiff/config.py +257 -0
- gdsdiff/cuda_setup.py +174 -0
- gdsdiff/design_history.py +65 -0
- gdsdiff/diagnostics.py +42 -0
- gdsdiff/diff_gds.py +66 -0
- gdsdiff/exact_diff_markdown.py +979 -0
- gdsdiff/exact_oracle.py +649 -0
- gdsdiff/extract.py +376 -0
- gdsdiff/gds_metadata.py +43 -0
- gdsdiff/geometry.py +112 -0
- gdsdiff/grid.py +143 -0
- gdsdiff/hierarchy.py +215 -0
- gdsdiff/hierarchy_extract.py +263 -0
- gdsdiff/inventory.py +153 -0
- gdsdiff/multiprocessing_support.py +39 -0
- gdsdiff/native.py +135 -0
- gdsdiff/native_cache.py +265 -0
- gdsdiff/native_src/cpu_prefilter.cpp +349 -0
- gdsdiff/native_src/gds_boundary_loops.cpp +505 -0
- gdsdiff/native_src/gds_geometry_scan.cpp +601 -0
- gdsdiff/native_src/gds_polygon_extract.cpp +594 -0
- gdsdiff/native_src/gds_structural_scan.cpp +335 -0
- gdsdiff/native_src/gdsdiff/CMakeLists.txt +74 -0
- gdsdiff/native_src/gdsdiff/core.cpp +191 -0
- gdsdiff/native_src/gdsdiff/core.hpp +96 -0
- gdsdiff/native_src/gdsdiff/cuda_assignment.cu +304 -0
- gdsdiff/native_src/gdsdiff/cuda_assignment.cuh +33 -0
- gdsdiff/native_src/gdsdiff/cuda_candidates.cu +133 -0
- gdsdiff/native_src/gdsdiff/cuda_candidates.cuh +17 -0
- gdsdiff/native_src/gdsdiff/cuda_ctypes.cu +4528 -0
- gdsdiff/native_src/gdsdiff/cuda_memory.cu +194 -0
- gdsdiff/native_src/gdsdiff/cuda_memory.cuh +34 -0
- gdsdiff/native_src/gdsdiff/metal_ctypes.mm +2791 -0
- gdsdiff/native_src/gdsdiff/python_bindings.cpp +21 -0
- gdsdiff/native_src/gdsdiff/test_core.cpp +93 -0
- gdsdiff/native_src/gdsdiff/test_cuda_assignment.cu +109 -0
- gdsdiff/native_src/gdsdiff/test_cuda_candidates.cu +94 -0
- gdsdiff/native_src/gdsdiff/test_cuda_memory.cu +97 -0
- gdsdiff/policy.py +305 -0
- gdsdiff/polygon_components.py +860 -0
- gdsdiff/profiles.py +152 -0
- gdsdiff/py.typed +1 -0
- gdsdiff/rect_coverage.py +384 -0
- gdsdiff/report.py +354 -0
- gdsdiff/schemas/gdsdiff_report-v1.schema.json +92 -0
- gdsdiff/semantics.py +75 -0
- gdsdiff/source_edge_diff.py +1120 -0
- gdsdiff/spatial.py +71 -0
- gdsdiff/structural_guard.py +161 -0
- gdsdiff/surplus_coverage.py +255 -0
- gdsdiff/synthetic_suite.py +1643 -0
- gdsdiff/templates.py +709 -0
- gdsdiff/tiling.py +153 -0
- gdsdiff/windowed_exact.py +270 -0
- gdsdiff/windows.py +184 -0
- gdsdiff-0.1.0.dist-info/METADATA +135 -0
- gdsdiff-0.1.0.dist-info/RECORD +85 -0
- gdsdiff-0.1.0.dist-info/WHEEL +5 -0
- gdsdiff-0.1.0.dist-info/entry_points.txt +4 -0
- gdsdiff-0.1.0.dist-info/licenses/LICENSE +674 -0
- gdsdiff-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,979 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from collections import Counter, defaultdict
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Mapping
|
|
10
|
+
|
|
11
|
+
from .exact_oracle import format_twice_area
|
|
12
|
+
from .geometry import PolygonBuffer, polygon_bbox, polygon_twice_area
|
|
13
|
+
from .policy import ClassifiedComponent, PolicyClassificationResult
|
|
14
|
+
from .semantics import DifferenceDirection
|
|
15
|
+
|
|
16
|
+
DEFAULT_VERTEX_INLINE_LIMIT = 8
|
|
17
|
+
DEFAULT_INVENTORY_RECORD_LIMIT = None
|
|
18
|
+
DEFAULT_COMPONENT_RECORD_LIMIT = 100
|
|
19
|
+
DEFAULT_COMPONENT_DETAIL_MODE = "compact"
|
|
20
|
+
DEFAULT_DIGEST_COMPONENT_LIMIT = 8
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ExactDiffMarkdownWriteResult:
|
|
25
|
+
path: Path
|
|
26
|
+
component_count: int
|
|
27
|
+
vertex_sidecar_path: Path | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class PolygonInventoryRecord:
|
|
32
|
+
layer: int
|
|
33
|
+
datatype: int
|
|
34
|
+
direction: DifferenceDirection
|
|
35
|
+
points: tuple[tuple[int, int], ...]
|
|
36
|
+
bbox: tuple[int, int, int, int]
|
|
37
|
+
twice_area: int
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def area_dbu2_string(self) -> str:
|
|
41
|
+
return format_twice_area(self.twice_area)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class PolygonInventoryDelta:
|
|
46
|
+
removed: tuple[PolygonInventoryRecord, ...]
|
|
47
|
+
added: tuple[PolygonInventoryRecord, ...]
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def changed_count(self) -> int:
|
|
51
|
+
return len(self.removed) + len(self.added)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def layers(self) -> tuple[tuple[int, int], ...]:
|
|
55
|
+
return tuple(sorted({(record.layer, record.datatype) for record in self.removed + self.added}))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_polygon_inventory_delta(old_buffer: PolygonBuffer, new_buffer: PolygonBuffer) -> PolygonInventoryDelta:
|
|
59
|
+
old_counter, old_indices_by_key = _polygon_inventory_index(old_buffer)
|
|
60
|
+
new_counter, new_indices_by_key = _polygon_inventory_index(new_buffer)
|
|
61
|
+
removed: list[PolygonInventoryRecord] = []
|
|
62
|
+
added: list[PolygonInventoryRecord] = []
|
|
63
|
+
for key in sorted(set(old_counter) | set(new_counter)):
|
|
64
|
+
removed_count = max(0, old_counter[key] - new_counter[key])
|
|
65
|
+
added_count = max(0, new_counter[key] - old_counter[key])
|
|
66
|
+
removed.extend(
|
|
67
|
+
_polygon_inventory_record(old_buffer, index, DifferenceDirection.REMOVAL)
|
|
68
|
+
for index in old_indices_by_key[key][:removed_count]
|
|
69
|
+
)
|
|
70
|
+
added.extend(
|
|
71
|
+
_polygon_inventory_record(new_buffer, index, DifferenceDirection.ADDITION)
|
|
72
|
+
for index in new_indices_by_key[key][:added_count]
|
|
73
|
+
)
|
|
74
|
+
return PolygonInventoryDelta(
|
|
75
|
+
removed=tuple(sorted(removed, key=_inventory_sort_key)),
|
|
76
|
+
added=tuple(sorted(added, key=_inventory_sort_key)),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def build_polygon_inventory_delta_from_record_delta(
|
|
81
|
+
old_buffer: PolygonBuffer,
|
|
82
|
+
new_buffer: PolygonBuffer,
|
|
83
|
+
record_delta,
|
|
84
|
+
) -> PolygonInventoryDelta:
|
|
85
|
+
removed: list[PolygonInventoryRecord] = []
|
|
86
|
+
added: list[PolygonInventoryRecord] = []
|
|
87
|
+
for row in range(record_delta.record_count):
|
|
88
|
+
count = int(record_delta.counts[row])
|
|
89
|
+
representative_index = int(record_delta.representative_indices[row])
|
|
90
|
+
if int(record_delta.directions[row]) == 1:
|
|
91
|
+
removed.extend(
|
|
92
|
+
_polygon_inventory_record(old_buffer, representative_index, DifferenceDirection.REMOVAL)
|
|
93
|
+
for _ in range(count)
|
|
94
|
+
)
|
|
95
|
+
else:
|
|
96
|
+
added.extend(
|
|
97
|
+
_polygon_inventory_record(new_buffer, representative_index, DifferenceDirection.ADDITION)
|
|
98
|
+
for _ in range(count)
|
|
99
|
+
)
|
|
100
|
+
return _polygon_inventory_delta_from_records(removed, added)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _polygon_inventory_delta_from_records(
|
|
104
|
+
removed_records: list[PolygonInventoryRecord],
|
|
105
|
+
added_records: list[PolygonInventoryRecord],
|
|
106
|
+
) -> PolygonInventoryDelta:
|
|
107
|
+
old_counter = Counter(_inventory_key(record) for record in removed_records)
|
|
108
|
+
new_counter = Counter(_inventory_key(record) for record in added_records)
|
|
109
|
+
old_by_key = defaultdict(list)
|
|
110
|
+
new_by_key = defaultdict(list)
|
|
111
|
+
for record in removed_records:
|
|
112
|
+
old_by_key[_inventory_key(record)].append(record)
|
|
113
|
+
for record in added_records:
|
|
114
|
+
new_by_key[_inventory_key(record)].append(record)
|
|
115
|
+
removed: list[PolygonInventoryRecord] = []
|
|
116
|
+
added: list[PolygonInventoryRecord] = []
|
|
117
|
+
for key in sorted(set(old_counter) | set(new_counter)):
|
|
118
|
+
removed_count = max(0, old_counter[key] - new_counter[key])
|
|
119
|
+
added_count = max(0, new_counter[key] - old_counter[key])
|
|
120
|
+
removed.extend(old_by_key[key][:removed_count])
|
|
121
|
+
added.extend(new_by_key[key][:added_count])
|
|
122
|
+
return PolygonInventoryDelta(
|
|
123
|
+
removed=tuple(sorted(removed, key=_inventory_sort_key)),
|
|
124
|
+
added=tuple(sorted(added, key=_inventory_sort_key)),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def render_exact_diff_markdown(
|
|
129
|
+
policy_result: PolicyClassificationResult,
|
|
130
|
+
*,
|
|
131
|
+
title: str = "GDS exact diff",
|
|
132
|
+
source_mode: str = "exact",
|
|
133
|
+
exact_verification_complete: bool = True,
|
|
134
|
+
polygon_inventory_delta: PolygonInventoryDelta | None = None,
|
|
135
|
+
component_limit: int | None = DEFAULT_COMPONENT_RECORD_LIMIT,
|
|
136
|
+
vertex_limit: int | None = DEFAULT_VERTEX_INLINE_LIMIT,
|
|
137
|
+
inventory_limit: int | None = DEFAULT_INVENTORY_RECORD_LIMIT,
|
|
138
|
+
component_detail_mode: str = DEFAULT_COMPONENT_DETAIL_MODE,
|
|
139
|
+
vertex_sidecar_path: str | Path | None = None,
|
|
140
|
+
sidecar_refs: Mapping[object, str] | None = None,
|
|
141
|
+
generated_at: datetime | None = None,
|
|
142
|
+
) -> str:
|
|
143
|
+
"""Render a deterministic, LLM-friendly Markdown diff from classified exact components."""
|
|
144
|
+
generated_at = generated_at or datetime.now(timezone.utc)
|
|
145
|
+
components = _components(policy_result)
|
|
146
|
+
shown_components = components if component_limit is None else components[: max(component_limit, 0)]
|
|
147
|
+
coverage_twice_area = _coverage_twice_area(components)
|
|
148
|
+
inventory_changed = polygon_inventory_delta is not None and polygon_inventory_delta.changed_count > 0
|
|
149
|
+
evidence_status = _neutral_evidence_status(
|
|
150
|
+
coverage_twice_area=coverage_twice_area,
|
|
151
|
+
inventory_changed=inventory_changed,
|
|
152
|
+
exact_verification_complete=exact_verification_complete,
|
|
153
|
+
)
|
|
154
|
+
sidecar_path_text = str(vertex_sidecar_path) if vertex_sidecar_path is not None else "not-written"
|
|
155
|
+
lines = [
|
|
156
|
+
f"# {title}",
|
|
157
|
+
"",
|
|
158
|
+
"## Comparator Evidence",
|
|
159
|
+
"",
|
|
160
|
+
f"- evidence_status: `{evidence_status}`",
|
|
161
|
+
f"- differences_found: `{str(evidence_status == 'differences-found').lower()}`",
|
|
162
|
+
f"- source_mode: `{source_mode}`",
|
|
163
|
+
f"- exact_verification_complete: `{str(exact_verification_complete).lower()}`",
|
|
164
|
+
f"- generated_at_utc: `{generated_at.astimezone(timezone.utc).isoformat()}`",
|
|
165
|
+
f"- coverage_changed: `{str(coverage_twice_area != 0).lower()}`",
|
|
166
|
+
f"- inventory_changed: `{str(inventory_changed).lower()}`",
|
|
167
|
+
f"- coverage_equal_but_inventory_changed: `{str(coverage_twice_area == 0 and inventory_changed).lower()}`",
|
|
168
|
+
f"- coverage_before_only_area_dbu2: `{format_twice_area(_coverage_direction_twice_area(components, DifferenceDirection.REMOVAL))}`",
|
|
169
|
+
f"- coverage_after_only_area_dbu2: `{format_twice_area(_coverage_direction_twice_area(components, DifferenceDirection.ADDITION))}`",
|
|
170
|
+
f"- coverage_xor_area_dbu2: `{format_twice_area(coverage_twice_area)}`",
|
|
171
|
+
f"- layer_count: `{len(policy_result.layers)}`",
|
|
172
|
+
f"- coverage_component_count: `{len(components)}`",
|
|
173
|
+
f"- component_count: `{len(components)}`",
|
|
174
|
+
f"- polygon_inventory_delta_count: `{polygon_inventory_delta.changed_count if polygon_inventory_delta is not None else 'not-recorded'}`",
|
|
175
|
+
f"- vertex_sidecar_path: `{sidecar_path_text}`",
|
|
176
|
+
"",
|
|
177
|
+
]
|
|
178
|
+
if not exact_verification_complete:
|
|
179
|
+
lines.extend(
|
|
180
|
+
[
|
|
181
|
+
"## Evidence Completeness Warning",
|
|
182
|
+
"",
|
|
183
|
+
"This file does not prove complete full-file polygon evidence because the comparator run did not complete exact verification. Use an exact run, or a GPU run explicitly verified by the exact oracle, for acceptance review.",
|
|
184
|
+
"",
|
|
185
|
+
]
|
|
186
|
+
)
|
|
187
|
+
lines.extend(
|
|
188
|
+
_render_agent_minimal_read(
|
|
189
|
+
policy_result,
|
|
190
|
+
components,
|
|
191
|
+
polygon_inventory_delta=polygon_inventory_delta,
|
|
192
|
+
digest_component_limit=DEFAULT_DIGEST_COMPONENT_LIMIT,
|
|
193
|
+
exact_verification_complete=exact_verification_complete,
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
lines.extend(_render_agent_review_checklist())
|
|
197
|
+
lines.extend(_render_layer_summary(policy_result))
|
|
198
|
+
lines.extend(
|
|
199
|
+
_render_components(
|
|
200
|
+
shown_components,
|
|
201
|
+
vertex_limit=vertex_limit,
|
|
202
|
+
detail_mode=component_detail_mode,
|
|
203
|
+
sidecar_refs=sidecar_refs,
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
if polygon_inventory_delta is not None:
|
|
207
|
+
lines.extend(
|
|
208
|
+
_render_polygon_inventory_delta(
|
|
209
|
+
polygon_inventory_delta,
|
|
210
|
+
policy_result=policy_result,
|
|
211
|
+
inventory_limit=inventory_limit,
|
|
212
|
+
vertex_limit=vertex_limit,
|
|
213
|
+
detail_mode=component_detail_mode,
|
|
214
|
+
sidecar_refs=sidecar_refs,
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
if component_limit is not None and len(components) > len(shown_components):
|
|
218
|
+
lines.extend(
|
|
219
|
+
[
|
|
220
|
+
"",
|
|
221
|
+
"## Truncation",
|
|
222
|
+
"",
|
|
223
|
+
f"- omitted_component_count: `{len(components) - len(shown_components)}`",
|
|
224
|
+
"- note: rerun with no component limit to obtain every polygon-level component.",
|
|
225
|
+
]
|
|
226
|
+
)
|
|
227
|
+
return "\n".join(lines) + "\n"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _render_agent_minimal_read(
|
|
231
|
+
policy_result: PolicyClassificationResult,
|
|
232
|
+
components: tuple[ClassifiedComponent, ...],
|
|
233
|
+
*,
|
|
234
|
+
polygon_inventory_delta: PolygonInventoryDelta | None,
|
|
235
|
+
digest_component_limit: int,
|
|
236
|
+
exact_verification_complete: bool,
|
|
237
|
+
) -> list[str]:
|
|
238
|
+
coverage_twice_area = policy_result.expected_twice_area + policy_result.unexpected_twice_area
|
|
239
|
+
inventory_changed = polygon_inventory_delta is not None and polygon_inventory_delta.changed_count > 0
|
|
240
|
+
evidence_status = _neutral_evidence_status(
|
|
241
|
+
coverage_twice_area=coverage_twice_area,
|
|
242
|
+
inventory_changed=inventory_changed,
|
|
243
|
+
exact_verification_complete=exact_verification_complete,
|
|
244
|
+
)
|
|
245
|
+
count_by_direction = Counter(_coverage_direction_label(component.direction) for component in components)
|
|
246
|
+
largest = tuple(sorted(components, key=lambda component: (-abs(component.twice_area), _component_sort_key(component)))[:digest_component_limit])
|
|
247
|
+
lines = [
|
|
248
|
+
"## Agent Minimal Read",
|
|
249
|
+
"",
|
|
250
|
+
"- purpose: `default first section for LLM review; read this before expanding detail tables`",
|
|
251
|
+
"- recommended_minimum_sections: `Comparator Evidence`, `Agent Minimal Read`, `Agent Review Checklist`, `Layer Summary`",
|
|
252
|
+
"- expand_polygon_components_when: `need exact bbox/area/hash/sidecar id for every changed coverage component`",
|
|
253
|
+
"- expand_inventory_delta_when: `coverage_changed is false but inventory_changed is true, or polygon formation changed`",
|
|
254
|
+
f"- evidence_status: `{evidence_status}`",
|
|
255
|
+
f"- coverage_changed: `{str(coverage_twice_area != 0).lower()}`",
|
|
256
|
+
f"- coverage_xor_area_dbu2: `{format_twice_area(coverage_twice_area)}`",
|
|
257
|
+
f"- inventory_changed: `{str(inventory_changed).lower()}`",
|
|
258
|
+
f"- coverage_equal_but_inventory_changed: `{str(coverage_twice_area == 0 and inventory_changed).lower()}`",
|
|
259
|
+
f"- inventory_delta_count: `{polygon_inventory_delta.changed_count if polygon_inventory_delta is not None else 'not-recorded'}`",
|
|
260
|
+
f"- coverage_component_count: `{len(components)}`",
|
|
261
|
+
]
|
|
262
|
+
lines.append(f"- coverage_component_counts_by_direction: `{_coverage_direction_count_text(count_by_direction)}`")
|
|
263
|
+
lines.append("")
|
|
264
|
+
lines.extend(_render_agent_layer_digest(policy_result))
|
|
265
|
+
lines.extend(_render_agent_largest_component_digest(largest, total_count=len(components)))
|
|
266
|
+
lines.extend(_render_agent_largest_directional_component_digest(components))
|
|
267
|
+
return lines
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _render_agent_layer_digest(policy_result: PolicyClassificationResult) -> list[str]:
|
|
271
|
+
lines = [
|
|
272
|
+
"### Affected Layer Digest",
|
|
273
|
+
"",
|
|
274
|
+
"| layer | datatype | coverage_xor_area_dbu2 | coverage_before_only_area_dbu2 | coverage_after_only_area_dbu2 | before_only_components | after_only_components | before_only_bbox_union_dbu | after_only_bbox_union_dbu | coverage_xor_bbox_union_dbu | components |",
|
|
275
|
+
"|---:|---:|---:|---:|---:|---:|---:|---|---|---|---:|",
|
|
276
|
+
]
|
|
277
|
+
for layer in policy_result.layers:
|
|
278
|
+
exact_twice_area = _coverage_twice_area(layer.components)
|
|
279
|
+
before_components = _coverage_direction_components(layer.components, DifferenceDirection.REMOVAL)
|
|
280
|
+
after_components = _coverage_direction_components(layer.components, DifferenceDirection.ADDITION)
|
|
281
|
+
bboxes = [component.bbox for component in layer.components]
|
|
282
|
+
bbox_union = _bbox_union(bboxes)
|
|
283
|
+
lines.append(
|
|
284
|
+
"| {layer} | {datatype} | {area} | {before_area} | {after_area} | {before_count} | {after_count} | `{before_bbox}` | `{after_bbox}` | `{bbox}` | {count} |".format(
|
|
285
|
+
layer=layer.layer.layer,
|
|
286
|
+
datatype=layer.layer.datatype,
|
|
287
|
+
area=format_twice_area(exact_twice_area),
|
|
288
|
+
before_area=format_twice_area(_coverage_twice_area(before_components)),
|
|
289
|
+
after_area=format_twice_area(_coverage_twice_area(after_components)),
|
|
290
|
+
before_count=len(before_components),
|
|
291
|
+
after_count=len(after_components),
|
|
292
|
+
before_bbox=_bbox_json(_bbox_union(tuple(component.bbox for component in before_components))),
|
|
293
|
+
after_bbox=_bbox_json(_bbox_union(tuple(component.bbox for component in after_components))),
|
|
294
|
+
count=len(layer.components),
|
|
295
|
+
bbox=_bbox_json(bbox_union),
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
if not policy_result.layers:
|
|
299
|
+
lines.append("| - | - | 0 | 0 | 0 | 0 | 0 | `none` | `none` | `none` | 0 |")
|
|
300
|
+
lines.append("")
|
|
301
|
+
return lines
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _render_agent_largest_component_digest(
|
|
305
|
+
components: tuple[ClassifiedComponent, ...],
|
|
306
|
+
*,
|
|
307
|
+
total_count: int,
|
|
308
|
+
) -> list[str]:
|
|
309
|
+
lines = [
|
|
310
|
+
"### Largest Component Digest",
|
|
311
|
+
"",
|
|
312
|
+
"| rank | direction | layer | datatype | bbox_dbu | area_dbu2 | vertex_count | points_sha256 |",
|
|
313
|
+
"|---:|---|---:|---:|---|---:|---:|---|",
|
|
314
|
+
]
|
|
315
|
+
for rank, component in enumerate(components, start=1):
|
|
316
|
+
lines.append(
|
|
317
|
+
"| {rank} | `{direction}` | {layer} | {datatype} | `{bbox}` | `{area}` | {vertex_count} | `{sha}` |".format(
|
|
318
|
+
rank=rank,
|
|
319
|
+
direction=_coverage_direction_label(component.direction),
|
|
320
|
+
layer=component.layer.layer,
|
|
321
|
+
datatype=component.layer.datatype,
|
|
322
|
+
bbox=json.dumps(list(component.bbox), separators=(",", ":")),
|
|
323
|
+
area=component.area_dbu2_string,
|
|
324
|
+
vertex_count=len(component.points),
|
|
325
|
+
sha=_points_hash(component),
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
if not components:
|
|
329
|
+
lines.append("| - | - | - | - | `none` | 0 | 0 | `none` |")
|
|
330
|
+
if total_count > len(components):
|
|
331
|
+
lines.append(
|
|
332
|
+
"| ... | `see Polygon Components` | - | - | `-` | `-` | - | `-` |"
|
|
333
|
+
)
|
|
334
|
+
lines.append("")
|
|
335
|
+
return lines
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _render_agent_largest_directional_component_digest(
|
|
339
|
+
components: tuple[ClassifiedComponent, ...],
|
|
340
|
+
) -> list[str]:
|
|
341
|
+
lines = [
|
|
342
|
+
"### Largest Directional Components",
|
|
343
|
+
"",
|
|
344
|
+
"| direction | layer | datatype | bbox_dbu | area_dbu2 | vertex_count | points_sha256 |",
|
|
345
|
+
"|---|---:|---:|---|---:|---:|---|",
|
|
346
|
+
]
|
|
347
|
+
rows = []
|
|
348
|
+
for direction in (DifferenceDirection.REMOVAL, DifferenceDirection.ADDITION):
|
|
349
|
+
direction_components = _coverage_direction_components(components, direction)
|
|
350
|
+
if direction_components:
|
|
351
|
+
rows.append(
|
|
352
|
+
sorted(
|
|
353
|
+
direction_components,
|
|
354
|
+
key=lambda component: (-abs(component.twice_area), _component_sort_key(component)),
|
|
355
|
+
)[0]
|
|
356
|
+
)
|
|
357
|
+
for component in rows:
|
|
358
|
+
lines.append(
|
|
359
|
+
"| `{direction}` | {layer} | {datatype} | `{bbox}` | `{area}` | {vertex_count} | `{sha}` |".format(
|
|
360
|
+
direction=_coverage_direction_label(component.direction),
|
|
361
|
+
layer=component.layer.layer,
|
|
362
|
+
datatype=component.layer.datatype,
|
|
363
|
+
bbox=json.dumps(list(component.bbox), separators=(",", ":")),
|
|
364
|
+
area=component.area_dbu2_string,
|
|
365
|
+
vertex_count=len(component.points),
|
|
366
|
+
sha=_points_hash(component),
|
|
367
|
+
)
|
|
368
|
+
)
|
|
369
|
+
if not rows:
|
|
370
|
+
lines.append("| - | - | - | `none` | 0 | 0 | `none` |")
|
|
371
|
+
lines.append("")
|
|
372
|
+
return lines
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _render_agent_review_checklist() -> list[str]:
|
|
376
|
+
return [
|
|
377
|
+
"## Agent Review Checklist",
|
|
378
|
+
"",
|
|
379
|
+
"- intent_contract_required: `record intended feature location or bbox, affected layers, intended direction such as add/remove/grow/shrink/move, and forbidden layers or regions before accepting the diff`",
|
|
380
|
+
"- locality_check: `compare affected layer and directional bbox evidence against the intended feature region; expand Polygon Components when a bbox is outside that region or too coarse to judge`",
|
|
381
|
+
"- directionality_check: `coverage_before_only is old-only material and coverage_after_only is new-only material; grow, shrink, and move edits should have directional bboxes and areas consistent with that intent`",
|
|
382
|
+
"- outside_region_check: `look for changed layers or component bboxes outside the intended feature area; if a precise window is known, optionally rerun with --allow-region as a stricter second pass`",
|
|
383
|
+
"- forbidden_layer_check: `nonzero coverage on layers outside the intended layer set requires explicit design justification before acceptance`",
|
|
384
|
+
"- readback_invariant_check: `verify decisive dimensions, counts, overlaps, and containment from GDS coordinates; total layer area and polygon count are triage only`",
|
|
385
|
+
"- reviewer_classification_required: `classify each nontrivial component as expected, unexpected, or unresolved in the closing report or design history, tied to layer, direction, bbox, and area`",
|
|
386
|
+
"",
|
|
387
|
+
]
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def write_exact_diff_markdown(
|
|
391
|
+
policy_result: PolicyClassificationResult,
|
|
392
|
+
path: str | Path,
|
|
393
|
+
*,
|
|
394
|
+
title: str = "GDS exact diff",
|
|
395
|
+
source_mode: str = "exact",
|
|
396
|
+
exact_verification_complete: bool = True,
|
|
397
|
+
polygon_inventory_delta: PolygonInventoryDelta | None = None,
|
|
398
|
+
component_limit: int | None = DEFAULT_COMPONENT_RECORD_LIMIT,
|
|
399
|
+
vertex_limit: int | None = DEFAULT_VERTEX_INLINE_LIMIT,
|
|
400
|
+
inventory_limit: int | None = DEFAULT_INVENTORY_RECORD_LIMIT,
|
|
401
|
+
component_detail_mode: str = DEFAULT_COMPONENT_DETAIL_MODE,
|
|
402
|
+
vertex_sidecar_path: str | Path | None = None,
|
|
403
|
+
generated_at: datetime | None = None,
|
|
404
|
+
) -> ExactDiffMarkdownWriteResult:
|
|
405
|
+
path = Path(path)
|
|
406
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
407
|
+
sidecar_path = Path(vertex_sidecar_path) if vertex_sidecar_path is not None else _default_vertex_sidecar_path(path)
|
|
408
|
+
sidecar_refs = _write_vertex_sidecar(
|
|
409
|
+
policy_result,
|
|
410
|
+
polygon_inventory_delta=polygon_inventory_delta,
|
|
411
|
+
path=sidecar_path,
|
|
412
|
+
)
|
|
413
|
+
path.write_text(
|
|
414
|
+
render_exact_diff_markdown(
|
|
415
|
+
policy_result,
|
|
416
|
+
title=title,
|
|
417
|
+
source_mode=source_mode,
|
|
418
|
+
exact_verification_complete=exact_verification_complete,
|
|
419
|
+
polygon_inventory_delta=polygon_inventory_delta,
|
|
420
|
+
component_limit=component_limit,
|
|
421
|
+
vertex_limit=vertex_limit,
|
|
422
|
+
inventory_limit=inventory_limit,
|
|
423
|
+
component_detail_mode=component_detail_mode,
|
|
424
|
+
vertex_sidecar_path=sidecar_path,
|
|
425
|
+
sidecar_refs=sidecar_refs,
|
|
426
|
+
generated_at=generated_at,
|
|
427
|
+
),
|
|
428
|
+
encoding="utf-8",
|
|
429
|
+
)
|
|
430
|
+
return ExactDiffMarkdownWriteResult(path=path, component_count=len(_components(policy_result)), vertex_sidecar_path=sidecar_path)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _render_layer_summary(policy_result: PolicyClassificationResult) -> list[str]:
|
|
434
|
+
lines = ["## Layer Summary", ""]
|
|
435
|
+
if not policy_result.layers:
|
|
436
|
+
lines.append("- no differences")
|
|
437
|
+
lines.append("")
|
|
438
|
+
return lines
|
|
439
|
+
lines.extend(
|
|
440
|
+
[
|
|
441
|
+
"| layer | datatype | coverage_before_only_area_dbu2 | coverage_after_only_area_dbu2 | before_only_components | after_only_components | before_only_bbox_union_dbu | after_only_bbox_union_dbu | components |",
|
|
442
|
+
"|---:|---:|---:|---:|---:|---:|---|---|---:|",
|
|
443
|
+
]
|
|
444
|
+
)
|
|
445
|
+
for layer in policy_result.layers:
|
|
446
|
+
before_components = _coverage_direction_components(layer.components, DifferenceDirection.REMOVAL)
|
|
447
|
+
after_components = _coverage_direction_components(layer.components, DifferenceDirection.ADDITION)
|
|
448
|
+
lines.append(
|
|
449
|
+
"| {layer} | {datatype} | {before} | {after} | {before_count} | {after_count} | `{before_bbox}` | `{after_bbox}` | {count} |".format(
|
|
450
|
+
layer=layer.layer.layer,
|
|
451
|
+
datatype=layer.layer.datatype,
|
|
452
|
+
before=format_twice_area(_coverage_twice_area(before_components)),
|
|
453
|
+
after=format_twice_area(_coverage_twice_area(after_components)),
|
|
454
|
+
before_count=len(before_components),
|
|
455
|
+
after_count=len(after_components),
|
|
456
|
+
before_bbox=_bbox_json(_bbox_union(tuple(component.bbox for component in before_components))),
|
|
457
|
+
after_bbox=_bbox_json(_bbox_union(tuple(component.bbox for component in after_components))),
|
|
458
|
+
count=len(layer.components),
|
|
459
|
+
)
|
|
460
|
+
)
|
|
461
|
+
lines.append("")
|
|
462
|
+
return lines
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _render_polygon_inventory_delta(
|
|
466
|
+
delta: PolygonInventoryDelta,
|
|
467
|
+
*,
|
|
468
|
+
policy_result: PolicyClassificationResult,
|
|
469
|
+
inventory_limit: int | None,
|
|
470
|
+
vertex_limit: int | None,
|
|
471
|
+
detail_mode: str,
|
|
472
|
+
sidecar_refs: Mapping[object, str] | None,
|
|
473
|
+
) -> list[str]:
|
|
474
|
+
records = tuple(sorted(delta.removed + delta.added, key=_inventory_sort_key))
|
|
475
|
+
shown_records = records if inventory_limit is None else records[: max(inventory_limit, 0)]
|
|
476
|
+
coverage_area = policy_result.expected_twice_area + policy_result.unexpected_twice_area
|
|
477
|
+
lines = [
|
|
478
|
+
"## Input Polygon Inventory Delta",
|
|
479
|
+
"",
|
|
480
|
+
"This section compares source polygon records before boolean union/XOR. It is intended to expose split, merge, fracture, or vertex-order-level input changes that may have zero physical coverage difference.",
|
|
481
|
+
"",
|
|
482
|
+
f"- removed_polygon_records: `{len(delta.removed)}`",
|
|
483
|
+
f"- added_polygon_records: `{len(delta.added)}`",
|
|
484
|
+
f"- changed_polygon_records: `{delta.changed_count}`",
|
|
485
|
+
f"- exact_coverage_xor_area_dbu2: `{format_twice_area(coverage_area)}`",
|
|
486
|
+
]
|
|
487
|
+
lines.append(f"- coverage_equal_but_inventory_changed: `{str(delta.changed_count > 0 and coverage_area == 0).lower()}`")
|
|
488
|
+
lines.append("")
|
|
489
|
+
lines.extend(_render_inventory_layer_summary(delta))
|
|
490
|
+
if not records:
|
|
491
|
+
lines.extend(["No input polygon inventory changes were found.", ""])
|
|
492
|
+
return lines
|
|
493
|
+
lines.extend(["### Polygon Record Changes", ""])
|
|
494
|
+
if detail_mode == "compact":
|
|
495
|
+
lines.extend(
|
|
496
|
+
[
|
|
497
|
+
"| index | direction | layer | datatype | bbox_dbu | area_dbu2 | vertex_count | sidecar_id | points_sha256 | omitted_vertices | vertices_preview_dbu |",
|
|
498
|
+
"|---:|---|---:|---:|---|---:|---:|---|---|---:|---|",
|
|
499
|
+
]
|
|
500
|
+
)
|
|
501
|
+
for index, record in enumerate(shown_records, start=1):
|
|
502
|
+
points = record.points if vertex_limit is None else record.points[: max(vertex_limit, 0)]
|
|
503
|
+
omitted = len(record.points) - len(points)
|
|
504
|
+
lines.append(
|
|
505
|
+
"| {index} | `{direction}` | {layer} | {datatype} | `{bbox}` | `{area}` | {vertex_count} | `{sidecar}` | `{sha}` | {omitted} | `{vertices}` |".format(
|
|
506
|
+
index=index,
|
|
507
|
+
direction=_inventory_direction_label(record.direction),
|
|
508
|
+
layer=record.layer,
|
|
509
|
+
datatype=record.datatype,
|
|
510
|
+
bbox=json.dumps(list(record.bbox), separators=(",", ":")),
|
|
511
|
+
area=record.area_dbu2_string,
|
|
512
|
+
vertex_count=len(record.points),
|
|
513
|
+
sidecar=_sidecar_ref(sidecar_refs, record),
|
|
514
|
+
sha=_inventory_points_hash(record),
|
|
515
|
+
omitted=omitted,
|
|
516
|
+
vertices=json.dumps([list(point) for point in points], separators=(",", ":")),
|
|
517
|
+
)
|
|
518
|
+
)
|
|
519
|
+
lines.append("")
|
|
520
|
+
elif detail_mode == "full":
|
|
521
|
+
for index, record in enumerate(shown_records, start=1):
|
|
522
|
+
points = record.points if vertex_limit is None else record.points[: max(vertex_limit, 0)]
|
|
523
|
+
truncated_vertices = len(record.points) - len(points)
|
|
524
|
+
lines.extend(
|
|
525
|
+
[
|
|
526
|
+
f"#### Polygon Record {index}",
|
|
527
|
+
"",
|
|
528
|
+
f"- direction: `{_inventory_direction_label(record.direction)}`",
|
|
529
|
+
f"- layer: `{record.layer}`",
|
|
530
|
+
f"- datatype: `{record.datatype}`",
|
|
531
|
+
f"- bbox_dbu: `{list(record.bbox)}`",
|
|
532
|
+
f"- area_dbu2: `{record.area_dbu2_string}`",
|
|
533
|
+
f"- vertex_count: `{len(record.points)}`",
|
|
534
|
+
f"- sidecar_id: `{_sidecar_ref(sidecar_refs, record)}`",
|
|
535
|
+
f"- points_sha256: `{_inventory_points_hash(record)}`",
|
|
536
|
+
]
|
|
537
|
+
)
|
|
538
|
+
if truncated_vertices:
|
|
539
|
+
lines.append(f"- omitted_vertex_count: `{truncated_vertices}`")
|
|
540
|
+
lines.extend(
|
|
541
|
+
[
|
|
542
|
+
"",
|
|
543
|
+
"```json",
|
|
544
|
+
json.dumps(
|
|
545
|
+
{
|
|
546
|
+
"direction": _inventory_direction_label(record.direction),
|
|
547
|
+
"layer": record.layer,
|
|
548
|
+
"datatype": record.datatype,
|
|
549
|
+
"bbox_dbu": list(record.bbox),
|
|
550
|
+
"area_dbu2": record.area_dbu2_string,
|
|
551
|
+
"sidecar_id": _sidecar_ref(sidecar_refs, record),
|
|
552
|
+
"vertices_dbu": [list(point) for point in points],
|
|
553
|
+
},
|
|
554
|
+
indent=2,
|
|
555
|
+
sort_keys=True,
|
|
556
|
+
),
|
|
557
|
+
"```",
|
|
558
|
+
"",
|
|
559
|
+
]
|
|
560
|
+
)
|
|
561
|
+
else:
|
|
562
|
+
raise ValueError(f"unsupported component detail mode: {detail_mode!r}")
|
|
563
|
+
if inventory_limit is not None and len(records) > len(shown_records):
|
|
564
|
+
lines.extend(
|
|
565
|
+
[
|
|
566
|
+
"### Inventory Truncation",
|
|
567
|
+
"",
|
|
568
|
+
f"- omitted_polygon_record_count: `{len(records) - len(shown_records)}`",
|
|
569
|
+
"- note: rerun with no inventory limit to obtain every source polygon record delta.",
|
|
570
|
+
"",
|
|
571
|
+
]
|
|
572
|
+
)
|
|
573
|
+
return lines
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _render_inventory_layer_summary(delta: PolygonInventoryDelta) -> list[str]:
|
|
577
|
+
lines = [
|
|
578
|
+
"### Inventory Layer Summary",
|
|
579
|
+
"",
|
|
580
|
+
"| layer | datatype | removed_records | added_records | removed_area_dbu2 | added_area_dbu2 |",
|
|
581
|
+
"|---:|---:|---:|---:|---:|---:|",
|
|
582
|
+
]
|
|
583
|
+
for layer, datatype in delta.layers:
|
|
584
|
+
removed = tuple(record for record in delta.removed if record.layer == layer and record.datatype == datatype)
|
|
585
|
+
added = tuple(record for record in delta.added if record.layer == layer and record.datatype == datatype)
|
|
586
|
+
lines.append(
|
|
587
|
+
"| {layer} | {datatype} | {removed_count} | {added_count} | {removed_area} | {added_area} |".format(
|
|
588
|
+
layer=layer,
|
|
589
|
+
datatype=datatype,
|
|
590
|
+
removed_count=len(removed),
|
|
591
|
+
added_count=len(added),
|
|
592
|
+
removed_area=format_twice_area(sum(record.twice_area for record in removed)),
|
|
593
|
+
added_area=format_twice_area(sum(record.twice_area for record in added)),
|
|
594
|
+
)
|
|
595
|
+
)
|
|
596
|
+
if not delta.layers:
|
|
597
|
+
lines.append("| - | - | 0 | 0 | 0 | 0 |")
|
|
598
|
+
lines.append("")
|
|
599
|
+
return lines
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _render_components(
|
|
603
|
+
components: tuple[ClassifiedComponent, ...],
|
|
604
|
+
*,
|
|
605
|
+
vertex_limit: int | None,
|
|
606
|
+
detail_mode: str,
|
|
607
|
+
sidecar_refs: Mapping[object, str] | None = None,
|
|
608
|
+
) -> list[str]:
|
|
609
|
+
lines = ["## Polygon Components", ""]
|
|
610
|
+
if not components:
|
|
611
|
+
lines.extend(["No polygon-level differences were found.", ""])
|
|
612
|
+
return lines
|
|
613
|
+
if detail_mode == "compact":
|
|
614
|
+
lines.extend(
|
|
615
|
+
[
|
|
616
|
+
"| index | direction | layer | datatype | bbox_dbu | area_dbu2 | vertex_count | sidecar_id | omitted_vertices | points_sha256 | vertices_preview_dbu |",
|
|
617
|
+
"|---:|---|---:|---:|---|---:|---:|---|---:|---|---|",
|
|
618
|
+
]
|
|
619
|
+
)
|
|
620
|
+
for index, component in enumerate(components, start=1):
|
|
621
|
+
points = component.points if vertex_limit is None else component.points[: max(vertex_limit, 0)]
|
|
622
|
+
omitted = len(component.points) - len(points)
|
|
623
|
+
lines.append(
|
|
624
|
+
"| {index} | `{direction}` | {layer} | {datatype} | `{bbox}` | `{area}` | {vertex_count} | `{sidecar}` | {omitted} | `{sha}` | `{vertices}` |".format(
|
|
625
|
+
index=index,
|
|
626
|
+
direction=_coverage_direction_label(component.direction),
|
|
627
|
+
layer=component.layer.layer,
|
|
628
|
+
datatype=component.layer.datatype,
|
|
629
|
+
bbox=json.dumps(list(component.bbox), separators=(",", ":")),
|
|
630
|
+
area=component.area_dbu2_string,
|
|
631
|
+
vertex_count=len(component.points),
|
|
632
|
+
sidecar=_sidecar_ref(sidecar_refs, component),
|
|
633
|
+
omitted=omitted,
|
|
634
|
+
sha=_points_hash(component),
|
|
635
|
+
vertices=json.dumps([list(point) for point in points], separators=(",", ":")),
|
|
636
|
+
)
|
|
637
|
+
)
|
|
638
|
+
lines.append("")
|
|
639
|
+
return lines
|
|
640
|
+
if detail_mode != "full":
|
|
641
|
+
raise ValueError(f"unsupported component detail mode: {detail_mode!r}")
|
|
642
|
+
for index, component in enumerate(components, start=1):
|
|
643
|
+
points = component.points if vertex_limit is None else component.points[: max(vertex_limit, 0)]
|
|
644
|
+
truncated_vertices = len(component.points) - len(points)
|
|
645
|
+
lines.extend(
|
|
646
|
+
[
|
|
647
|
+
f"### Component {index}",
|
|
648
|
+
"",
|
|
649
|
+
f"- classification: `{component.classification}`",
|
|
650
|
+
f"- direction: `{_coverage_direction_label(component.direction)}`",
|
|
651
|
+
f"- layer: `{component.layer.layer}`",
|
|
652
|
+
f"- datatype: `{component.layer.datatype}`",
|
|
653
|
+
f"- bbox_dbu: `{list(component.bbox)}`",
|
|
654
|
+
f"- area_dbu2: `{component.area_dbu2_string}`",
|
|
655
|
+
f"- vertex_count: `{len(component.points)}`",
|
|
656
|
+
f"- sidecar_id: `{_sidecar_ref(sidecar_refs, component)}`",
|
|
657
|
+
f"- points_sha256: `{_points_hash(component)}`",
|
|
658
|
+
]
|
|
659
|
+
)
|
|
660
|
+
if truncated_vertices:
|
|
661
|
+
lines.append(f"- omitted_vertex_count: `{truncated_vertices}`")
|
|
662
|
+
lines.extend(
|
|
663
|
+
[
|
|
664
|
+
"",
|
|
665
|
+
"```json",
|
|
666
|
+
json.dumps(
|
|
667
|
+
{
|
|
668
|
+
"classification": component.classification,
|
|
669
|
+
"direction": _coverage_direction_label(component.direction),
|
|
670
|
+
"layer": component.layer.layer,
|
|
671
|
+
"datatype": component.layer.datatype,
|
|
672
|
+
"bbox_dbu": list(component.bbox),
|
|
673
|
+
"area_dbu2": component.area_dbu2_string,
|
|
674
|
+
"sidecar_id": _sidecar_ref(sidecar_refs, component),
|
|
675
|
+
"vertices_dbu": [list(point) for point in points],
|
|
676
|
+
},
|
|
677
|
+
indent=2,
|
|
678
|
+
sort_keys=True,
|
|
679
|
+
),
|
|
680
|
+
"```",
|
|
681
|
+
"",
|
|
682
|
+
]
|
|
683
|
+
)
|
|
684
|
+
return lines
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _components(policy_result: PolicyClassificationResult) -> tuple[ClassifiedComponent, ...]:
|
|
688
|
+
return tuple(
|
|
689
|
+
sorted(
|
|
690
|
+
(component for layer in policy_result.layers for component in layer.components),
|
|
691
|
+
key=_component_sort_key,
|
|
692
|
+
)
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _component_sort_key(component: ClassifiedComponent) -> tuple[object, ...]:
|
|
697
|
+
return (
|
|
698
|
+
component.layer.layer,
|
|
699
|
+
component.layer.datatype,
|
|
700
|
+
component.classification,
|
|
701
|
+
component.direction.value,
|
|
702
|
+
component.bbox,
|
|
703
|
+
component.points,
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _bbox_union(bboxes: list[tuple[int, int, int, int]] | tuple[tuple[int, int, int, int], ...]) -> tuple[int, int, int, int] | None:
|
|
708
|
+
if not bboxes:
|
|
709
|
+
return None
|
|
710
|
+
return (
|
|
711
|
+
min(bbox[0] for bbox in bboxes),
|
|
712
|
+
min(bbox[1] for bbox in bboxes),
|
|
713
|
+
max(bbox[2] for bbox in bboxes),
|
|
714
|
+
max(bbox[3] for bbox in bboxes),
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _bbox_json(bbox: tuple[int, int, int, int] | None) -> str:
|
|
719
|
+
if bbox is None:
|
|
720
|
+
return "none"
|
|
721
|
+
return json.dumps(list(bbox), separators=(",", ":"))
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _coverage_direction_components(
|
|
725
|
+
components: tuple[ClassifiedComponent, ...],
|
|
726
|
+
direction: DifferenceDirection,
|
|
727
|
+
) -> tuple[ClassifiedComponent, ...]:
|
|
728
|
+
return tuple(component for component in components if component.direction == direction)
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _coverage_direction_count_text(counts: Counter[str]) -> str:
|
|
732
|
+
if not counts:
|
|
733
|
+
return "none"
|
|
734
|
+
preferred_order = (
|
|
735
|
+
_coverage_direction_label(DifferenceDirection.REMOVAL),
|
|
736
|
+
_coverage_direction_label(DifferenceDirection.ADDITION),
|
|
737
|
+
)
|
|
738
|
+
parts = [f"{label}={counts[label]}" for label in preferred_order if counts[label]]
|
|
739
|
+
parts.extend(f"{label}={counts[label]}" for label in sorted(set(counts) - set(preferred_order)))
|
|
740
|
+
return ", ".join(parts)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _points_hash(component: ClassifiedComponent) -> str:
|
|
744
|
+
payload = json.dumps([list(point) for point in component.points], separators=(",", ":")).encode("utf-8")
|
|
745
|
+
return hashlib.sha256(payload).hexdigest()
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _polygon_inventory_index(buffer: PolygonBuffer) -> tuple[Counter[tuple[object, ...]], dict[tuple[object, ...], list[int]]]:
|
|
749
|
+
counter: Counter[tuple[object, ...]] = Counter()
|
|
750
|
+
indices_by_key: dict[tuple[object, ...], list[int]] = defaultdict(list)
|
|
751
|
+
for index in range(buffer.polygon_count):
|
|
752
|
+
layer = buffer.layer_table.layers[int(buffer.layer_ids[index])]
|
|
753
|
+
vertices = buffer.polygon_vertices(index)
|
|
754
|
+
if len(vertices) == 0:
|
|
755
|
+
continue
|
|
756
|
+
key = _buffer_polygon_inventory_key(buffer, index, layer.layer, layer.datatype, vertices)
|
|
757
|
+
counter[key] += 1
|
|
758
|
+
indices_by_key[key].append(index)
|
|
759
|
+
return counter, indices_by_key
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _buffer_polygon_inventory_key(
|
|
763
|
+
buffer: PolygonBuffer,
|
|
764
|
+
index: int,
|
|
765
|
+
layer: int,
|
|
766
|
+
datatype: int,
|
|
767
|
+
vertices,
|
|
768
|
+
) -> tuple[object, ...]:
|
|
769
|
+
if len(vertices) == 4 and _is_axis_aligned_rectangle_vertices(vertices):
|
|
770
|
+
x0, y0, x1, y1 = (int(value) for value in buffer.bboxes[index])
|
|
771
|
+
return layer, datatype, "rect", x0, y0, x1, y1
|
|
772
|
+
points = tuple((int(x), int(y)) for x, y in vertices)
|
|
773
|
+
return layer, datatype, "poly", _canonical_inventory_points(points)
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _polygon_inventory_record(
|
|
777
|
+
buffer: PolygonBuffer,
|
|
778
|
+
index: int,
|
|
779
|
+
direction: DifferenceDirection,
|
|
780
|
+
) -> PolygonInventoryRecord:
|
|
781
|
+
layer = buffer.layer_table.layers[int(buffer.layer_ids[index])]
|
|
782
|
+
points = tuple((int(x), int(y)) for x, y in buffer.polygon_vertices(index))
|
|
783
|
+
return PolygonInventoryRecord(
|
|
784
|
+
layer=layer.layer,
|
|
785
|
+
datatype=layer.datatype,
|
|
786
|
+
direction=direction,
|
|
787
|
+
points=points,
|
|
788
|
+
bbox=polygon_bbox(points),
|
|
789
|
+
twice_area=polygon_twice_area(points),
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _polygon_inventory_records(buffer: PolygonBuffer, direction: DifferenceDirection) -> tuple[PolygonInventoryRecord, ...]:
|
|
794
|
+
return tuple(
|
|
795
|
+
_polygon_inventory_record(buffer, index, direction)
|
|
796
|
+
for index in range(buffer.polygon_count)
|
|
797
|
+
if len(buffer.polygon_vertices(index)) > 0
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def _inventory_key(record: PolygonInventoryRecord) -> tuple[object, ...]:
|
|
802
|
+
if len(record.points) == 4 and _is_axis_aligned_rectangle_points(record.points):
|
|
803
|
+
return record.layer, record.datatype, "rect", *record.bbox
|
|
804
|
+
return record.layer, record.datatype, "poly", _canonical_inventory_points(record.points)
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _inventory_sort_key(record: PolygonInventoryRecord) -> tuple[object, ...]:
|
|
808
|
+
return record.layer, record.datatype, record.direction.value, record.bbox, _canonical_inventory_points(record.points)
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def _inventory_points_hash(record: PolygonInventoryRecord) -> str:
|
|
812
|
+
payload = json.dumps([list(point) for point in record.points], separators=(",", ":")).encode("utf-8")
|
|
813
|
+
return hashlib.sha256(payload).hexdigest()
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def _canonical_inventory_points(points: tuple[tuple[int, int], ...]) -> tuple[tuple[int, int], ...]:
|
|
817
|
+
if not points:
|
|
818
|
+
return points
|
|
819
|
+
forward = _min_cyclic_rotation(points)
|
|
820
|
+
reverse = _min_cyclic_rotation(tuple(reversed(points)))
|
|
821
|
+
return min(forward, reverse)
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _min_cyclic_rotation(points: tuple[tuple[int, int], ...]) -> tuple[tuple[int, int], ...]:
|
|
825
|
+
index = _least_cyclic_rotation_index(points)
|
|
826
|
+
return points[index:] + points[:index]
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _least_cyclic_rotation_index(points: tuple[tuple[int, int], ...]) -> int:
|
|
830
|
+
count = len(points)
|
|
831
|
+
if count <= 1:
|
|
832
|
+
return 0
|
|
833
|
+
doubled = points + points
|
|
834
|
+
first = 0
|
|
835
|
+
second = 1
|
|
836
|
+
offset = 0
|
|
837
|
+
while first < count and second < count and offset < count:
|
|
838
|
+
a = doubled[first + offset]
|
|
839
|
+
b = doubled[second + offset]
|
|
840
|
+
if a == b:
|
|
841
|
+
offset += 1
|
|
842
|
+
continue
|
|
843
|
+
if a > b:
|
|
844
|
+
first = first + offset + 1
|
|
845
|
+
if first <= second:
|
|
846
|
+
first = second + 1
|
|
847
|
+
else:
|
|
848
|
+
second = second + offset + 1
|
|
849
|
+
if second <= first:
|
|
850
|
+
second = first + 1
|
|
851
|
+
offset = 0
|
|
852
|
+
return min(first, second)
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _is_axis_aligned_rectangle_vertices(vertices) -> bool:
|
|
856
|
+
return len({int(value) for value in vertices[:, 0]}) == 2 and len({int(value) for value in vertices[:, 1]}) == 2
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _is_axis_aligned_rectangle_points(points: tuple[tuple[int, int], ...]) -> bool:
|
|
860
|
+
return len({x for x, _ in points}) == 2 and len({y for _, y in points}) == 2
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def _coverage_twice_area(components: tuple[ClassifiedComponent, ...]) -> int:
|
|
864
|
+
return sum(component.twice_area for component in components)
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def _coverage_direction_twice_area(
|
|
868
|
+
components: tuple[ClassifiedComponent, ...],
|
|
869
|
+
direction: DifferenceDirection,
|
|
870
|
+
) -> int:
|
|
871
|
+
return sum(component.twice_area for component in components if component.direction == direction)
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _neutral_evidence_status(
|
|
875
|
+
*,
|
|
876
|
+
coverage_twice_area: int,
|
|
877
|
+
inventory_changed: bool,
|
|
878
|
+
exact_verification_complete: bool,
|
|
879
|
+
) -> str:
|
|
880
|
+
if not exact_verification_complete:
|
|
881
|
+
return "incomplete-evidence"
|
|
882
|
+
if coverage_twice_area or inventory_changed:
|
|
883
|
+
return "differences-found"
|
|
884
|
+
return "identical"
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def _coverage_direction_label(direction: DifferenceDirection) -> str:
|
|
888
|
+
if direction == DifferenceDirection.REMOVAL:
|
|
889
|
+
return "coverage_before_only"
|
|
890
|
+
if direction == DifferenceDirection.ADDITION:
|
|
891
|
+
return "coverage_after_only"
|
|
892
|
+
return f"coverage_{direction.value}"
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def _inventory_direction_label(direction: DifferenceDirection) -> str:
|
|
896
|
+
if direction == DifferenceDirection.REMOVAL:
|
|
897
|
+
return "inventory_before_only"
|
|
898
|
+
if direction == DifferenceDirection.ADDITION:
|
|
899
|
+
return "inventory_after_only"
|
|
900
|
+
return f"inventory_{direction.value}"
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def _sidecar_ref(sidecar_refs: Mapping[object, str] | None, record: object) -> str:
|
|
904
|
+
if sidecar_refs is None:
|
|
905
|
+
return "not-written"
|
|
906
|
+
return sidecar_refs.get(record, "not-written")
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def _default_vertex_sidecar_path(path: Path) -> Path:
|
|
910
|
+
stem = path.stem
|
|
911
|
+
if stem == "exact_diff":
|
|
912
|
+
return path.with_name("diff_vertices.jsonl")
|
|
913
|
+
if stem.endswith("_exact_diff"):
|
|
914
|
+
stem = stem[: -len("_exact_diff")]
|
|
915
|
+
return path.with_name(f"{stem}_diff_vertices.jsonl")
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def _write_vertex_sidecar(
|
|
919
|
+
policy_result: PolicyClassificationResult,
|
|
920
|
+
*,
|
|
921
|
+
polygon_inventory_delta: PolygonInventoryDelta | None,
|
|
922
|
+
path: Path,
|
|
923
|
+
) -> dict[object, str]:
|
|
924
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
925
|
+
refs: dict[object, str] = {}
|
|
926
|
+
components = _components(policy_result)
|
|
927
|
+
inventory_records = (
|
|
928
|
+
tuple(sorted(polygon_inventory_delta.removed + polygon_inventory_delta.added, key=_inventory_sort_key))
|
|
929
|
+
if polygon_inventory_delta is not None
|
|
930
|
+
else ()
|
|
931
|
+
)
|
|
932
|
+
with path.open("w", encoding="utf-8") as handle:
|
|
933
|
+
for index, component in enumerate(components, start=1):
|
|
934
|
+
record_id = f"coverage-{index:06d}"
|
|
935
|
+
refs[component] = record_id
|
|
936
|
+
handle.write(
|
|
937
|
+
json.dumps(
|
|
938
|
+
{
|
|
939
|
+
"id": record_id,
|
|
940
|
+
"kind": "coverage_component",
|
|
941
|
+
"direction": _coverage_direction_label(component.direction),
|
|
942
|
+
"layer": component.layer.layer,
|
|
943
|
+
"datatype": component.layer.datatype,
|
|
944
|
+
"bbox_dbu": list(component.bbox),
|
|
945
|
+
"area_dbu2": component.area_dbu2_string,
|
|
946
|
+
"twice_area_dbu2": component.twice_area,
|
|
947
|
+
"vertex_count": len(component.points),
|
|
948
|
+
"points_sha256": _points_hash(component),
|
|
949
|
+
"vertices_dbu": [list(point) for point in component.points],
|
|
950
|
+
},
|
|
951
|
+
sort_keys=True,
|
|
952
|
+
separators=(",", ":"),
|
|
953
|
+
)
|
|
954
|
+
+ "\n"
|
|
955
|
+
)
|
|
956
|
+
for index, record in enumerate(inventory_records, start=1):
|
|
957
|
+
record_id = f"inventory-{index:06d}"
|
|
958
|
+
refs[record] = record_id
|
|
959
|
+
handle.write(
|
|
960
|
+
json.dumps(
|
|
961
|
+
{
|
|
962
|
+
"id": record_id,
|
|
963
|
+
"kind": "source_polygon_inventory",
|
|
964
|
+
"direction": _inventory_direction_label(record.direction),
|
|
965
|
+
"layer": record.layer,
|
|
966
|
+
"datatype": record.datatype,
|
|
967
|
+
"bbox_dbu": list(record.bbox),
|
|
968
|
+
"area_dbu2": record.area_dbu2_string,
|
|
969
|
+
"twice_area_dbu2": record.twice_area,
|
|
970
|
+
"vertex_count": len(record.points),
|
|
971
|
+
"points_sha256": _inventory_points_hash(record),
|
|
972
|
+
"vertices_dbu": [list(point) for point in record.points],
|
|
973
|
+
},
|
|
974
|
+
sort_keys=True,
|
|
975
|
+
separators=(",", ":"),
|
|
976
|
+
)
|
|
977
|
+
+ "\n"
|
|
978
|
+
)
|
|
979
|
+
return refs
|