lockwire 0.2.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.
- lockwire/__init__.py +9 -0
- lockwire/__main__.py +9 -0
- lockwire/advice.py +119 -0
- lockwire/api.py +1216 -0
- lockwire/api_payload.py +985 -0
- lockwire/bench.py +182 -0
- lockwire/blame.py +100 -0
- lockwire/catalog.py +100 -0
- lockwire/catalog_seed.yaml +545 -0
- lockwire/checks.py +3483 -0
- lockwire/cli.py +1799 -0
- lockwire/cli_line.py +73 -0
- lockwire/constraints.py +313 -0
- lockwire/copy.py +302 -0
- lockwire/describe.py +682 -0
- lockwire/edit.py +2098 -0
- lockwire/emit_ts.py +150 -0
- lockwire/export_mhs.py +115 -0
- lockwire/factdiff.py +254 -0
- lockwire/forces.py +334 -0
- lockwire/gitref.py +188 -0
- lockwire/hw.py +388 -0
- lockwire/mcp_server.py +276 -0
- lockwire/mechanism.py +379 -0
- lockwire/mesh_dae.py +555 -0
- lockwire/mesh_simple.py +198 -0
- lockwire/page.py +1485 -0
- lockwire/plan.py +849 -0
- lockwire/plan_payload.py +151 -0
- lockwire/plugins.py +70 -0
- lockwire/report.py +848 -0
- lockwire/report_html.py +707 -0
- lockwire/report_json.py +168 -0
- lockwire/review_draft.py +315 -0
- lockwire/scan.py +732 -0
- lockwire/scene.py +535 -0
- lockwire/scene_import.py +309 -0
- lockwire/scene_urdf.py +750 -0
- lockwire/scene_view.py +376 -0
- lockwire/schema.py +3616 -0
- lockwire/sdf.py +240 -0
- lockwire/serve.py +1256 -0
- lockwire/start_operation.py +92 -0
- lockwire/statics.py +77 -0
- lockwire/units.py +115 -0
- lockwire/urdf.py +348 -0
- lockwire/vcs.py +402 -0
- lockwire/whatif.py +161 -0
- lockwire-0.2.0.dist-info/METADATA +192 -0
- lockwire-0.2.0.dist-info/RECORD +52 -0
- lockwire-0.2.0.dist-info/WHEEL +4 -0
- lockwire-0.2.0.dist-info/entry_points.txt +3 -0
lockwire/report.py
ADDED
|
@@ -0,0 +1,848 @@
|
|
|
1
|
+
"""Record-check findings and severity-free derived information.
|
|
2
|
+
|
|
3
|
+
Finding severities:
|
|
4
|
+
- violation -> no-go (exit code 1)
|
|
5
|
+
- warning -> go-with-note (exit code 0)
|
|
6
|
+
- not_scored -> visible coverage gap; does not affect GO/NO-GO or warning counts
|
|
7
|
+
Every finding renders in the same grammar regardless of which check produced it.
|
|
8
|
+
|
|
9
|
+
Derived information is deliberately a separate type: it describes the locked
|
|
10
|
+
robot but cannot affect GO/NO-GO. In particular, CG has no defensible envelope
|
|
11
|
+
yet and therefore must never be represented as a Finding.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import datetime as dt
|
|
17
|
+
from collections import defaultdict
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import TYPE_CHECKING, Any, Literal, Self
|
|
20
|
+
|
|
21
|
+
from pydantic import BaseModel, ConfigDict, model_validator
|
|
22
|
+
|
|
23
|
+
from lockwire.advice import Alternative
|
|
24
|
+
from lockwire.schema import Quantity, Provenance
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING: # rich is the terminal door's dependency, not the library's
|
|
27
|
+
from rich.console import Console # (`import lockwire.api` must not pull it)
|
|
28
|
+
|
|
29
|
+
Severity = Literal["violation", "warning", "not_scored"]
|
|
30
|
+
LARGE_EVIDENCE_GAP = 10
|
|
31
|
+
_SEVERITY_RANK = {"violation": 0, "warning": 1, "not_scored": 2}
|
|
32
|
+
|
|
33
|
+
VerdictCode = Literal["REFUSED", "NO_GO", "GO_WITH_NOTES", "GO", "NOT_SCORED"]
|
|
34
|
+
#: The machine verdict/exit contract. ``verdict`` consumes it and ``emit_ts``
|
|
35
|
+
#: exports the same values for the app boundary; human-facing help remains in
|
|
36
|
+
#: ``EXIT_CODE_TABLE`` below and is never parsed as protocol data.
|
|
37
|
+
VERDICT_EXIT_CODES: dict[VerdictCode, int] = {
|
|
38
|
+
"REFUSED": 2,
|
|
39
|
+
"NO_GO": 1,
|
|
40
|
+
"GO_WITH_NOTES": 0,
|
|
41
|
+
"GO": 0,
|
|
42
|
+
"NOT_SCORED": 3,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def verdict_word(code: VerdictCode) -> str:
|
|
47
|
+
"""The terminal spelling of a verdict code, for anything a human or an
|
|
48
|
+
external consumer reads as prose (the JSON document's ``verdict`` field
|
|
49
|
+
included). Only ``NO_GO`` differs from its Python identifier — the
|
|
50
|
+
display layer (``report_html.py``, the terminal itself) has always
|
|
51
|
+
hyphenated it; this is that same spelling, applied at every JSON boundary."""
|
|
52
|
+
return "NO-GO" if code == "NO_GO" else code
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _console() -> Console:
|
|
56
|
+
"""A default rich Console, imported at call time.
|
|
57
|
+
|
|
58
|
+
Every rendering entry point takes an explicit ``console``; only the terminal
|
|
59
|
+
path falls through to here. Importing rich at module scope would put it on
|
|
60
|
+
``import lockwire.api``'s path, which is forbidden.
|
|
61
|
+
"""
|
|
62
|
+
from rich.console import Console
|
|
63
|
+
|
|
64
|
+
return Console()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def format_magnitude(value: float, unit: str) -> str:
|
|
68
|
+
"""Canonical Finding magnitude display for humans.
|
|
69
|
+
|
|
70
|
+
Checkers populate typed ``Quantity`` fields; this owns the prose form so
|
|
71
|
+
machine consumers never have to re-parse the rendered string.
|
|
72
|
+
"""
|
|
73
|
+
return f"{value:.2f} {unit}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
#: A `declare` that clears a violation by RAISING the limit rather
|
|
77
|
+
#: than lowering the draw leaves this standing line in the entry's provenance
|
|
78
|
+
#: note. It is an annotation only — verdict words and exit codes never move.
|
|
79
|
+
LIMIT_RAISED_MARK = "cleared by re-declaring the limit on "
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def limit_raised_mark(provenance: object | None) -> str | None:
|
|
83
|
+
"""The standing mark on an entry's provenance note, if one was written.
|
|
84
|
+
|
|
85
|
+
One reader for every surface that renders the mark (the GUI card, the HTML
|
|
86
|
+
report's budget rows, the preflight JSON) so a raised limit cannot be
|
|
87
|
+
visible on one screen and invisible on another.
|
|
88
|
+
|
|
89
|
+
The mark must not be gated on the budget's CURRENT severity (returning None
|
|
90
|
+
whenever the budget was back in breach, on the reasoning that "cleared by
|
|
91
|
+
re-declaring the limit" beside `state: breach` reads as two contradicting
|
|
92
|
+
present-tense claims). Such a gate would make the most alarming case in the
|
|
93
|
+
set ("someone widened a limit and the robot breached regardless anyway")
|
|
94
|
+
render identically to the most ordinary one (a limit nobody ever touched,
|
|
95
|
+
in plain breach). The mark is the standing line in the file, so it renders
|
|
96
|
+
whenever the provenance note carries it, regardless of current state; the
|
|
97
|
+
state/verdict fields beside it already carry the live truth.
|
|
98
|
+
"""
|
|
99
|
+
note = getattr(provenance, "note", None)
|
|
100
|
+
if not note:
|
|
101
|
+
return None
|
|
102
|
+
marks = [
|
|
103
|
+
line.strip()
|
|
104
|
+
for line in str(note).splitlines()
|
|
105
|
+
if line.strip().startswith(LIMIT_RAISED_MARK)
|
|
106
|
+
]
|
|
107
|
+
return marks[-1] if marks else None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Finding(BaseModel):
|
|
111
|
+
model_config = ConfigDict(extra="forbid")
|
|
112
|
+
|
|
113
|
+
severity: Severity
|
|
114
|
+
quantity: str # contract path, '/'-separated
|
|
115
|
+
observed: str
|
|
116
|
+
expected: str
|
|
117
|
+
# Typed magnitudes when the finding is about a physical quantity. Optional:
|
|
118
|
+
# many findings are structural prose (missing refs, unrated bitrate, …).
|
|
119
|
+
# Rendered ``observed``/``expected`` stay for humans; these are the
|
|
120
|
+
# machine-readable form.
|
|
121
|
+
observed_quantity: Quantity | None = None
|
|
122
|
+
expected_quantity: Quantity | None = None
|
|
123
|
+
delta: str | None = None
|
|
124
|
+
dependent: str | None = None # code paths / downstream entries affected
|
|
125
|
+
owner: str | None = None
|
|
126
|
+
source: str | None = None
|
|
127
|
+
witness: str | None = None
|
|
128
|
+
locked_at: dt.date | None = None
|
|
129
|
+
action: str
|
|
130
|
+
cluster_root: str | None = None
|
|
131
|
+
cluster_members: tuple[str, ...] = ()
|
|
132
|
+
# Preflight stage identity (contract §3.2). Record-check findings leave this
|
|
133
|
+
# None; render groups/labels only when set. Additive to report.py — not a
|
|
134
|
+
# lockfile/schema change, no further sign-off.
|
|
135
|
+
stage: int | None = None
|
|
136
|
+
# The alternatives half of the advice sentence.
|
|
137
|
+
# Additive to report.py, same pattern as cluster_root/stage above — not a
|
|
138
|
+
# lockfile/schema change. Empty for every finding except a rail_power
|
|
139
|
+
# budget violation.
|
|
140
|
+
alternatives: tuple[Alternative, ...] = ()
|
|
141
|
+
|
|
142
|
+
@model_validator(mode="after")
|
|
143
|
+
def _keep_compared_magnitudes_visibly_distinct(self) -> Self:
|
|
144
|
+
"""Expand colliding prose magnitudes using lossless float rendering."""
|
|
145
|
+
observed = self.observed_quantity
|
|
146
|
+
expected = self.expected_quantity
|
|
147
|
+
if (
|
|
148
|
+
observed is None
|
|
149
|
+
or expected is None
|
|
150
|
+
or observed.unit != expected.unit
|
|
151
|
+
or observed.value == expected.value
|
|
152
|
+
):
|
|
153
|
+
return self
|
|
154
|
+
|
|
155
|
+
observed_compact = format_magnitude(observed.value, observed.unit)
|
|
156
|
+
expected_compact = format_magnitude(expected.value, expected.unit)
|
|
157
|
+
if observed_compact != expected_compact:
|
|
158
|
+
return self
|
|
159
|
+
|
|
160
|
+
self.observed = self.observed.replace(
|
|
161
|
+
observed_compact,
|
|
162
|
+
f"{observed.value!r} {observed.unit}",
|
|
163
|
+
1,
|
|
164
|
+
)
|
|
165
|
+
self.expected = self.expected.replace(
|
|
166
|
+
expected_compact,
|
|
167
|
+
f"{expected.value!r} {expected.unit}",
|
|
168
|
+
1,
|
|
169
|
+
)
|
|
170
|
+
return self
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class ScoredRecord(BaseModel):
|
|
174
|
+
"""A comparison that PASSED: two populated operands, no violation.
|
|
175
|
+
|
|
176
|
+
Findings only ever carry problems and blind spots; this is the missing
|
|
177
|
+
"it agrees" half. ``quantity`` is the contract path (exact-text toggle
|
|
178
|
+
only); ``observed``/``expected``/``margin`` are already plain words for
|
|
179
|
+
direct rendering — never a quantity path or code identifier.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
model_config = ConfigDict(extra="forbid")
|
|
183
|
+
|
|
184
|
+
quantity: str
|
|
185
|
+
observed: str
|
|
186
|
+
expected: str
|
|
187
|
+
margin: str
|
|
188
|
+
provenance: Provenance | None = None
|
|
189
|
+
# Typed magnitudes, optional (mirrors Finding.observed_quantity/expected_quantity):
|
|
190
|
+
# a preflight band/limit pass can carry the compared numbers so a reader
|
|
191
|
+
# (health.py) can show the band without re-deriving it. Record checks never
|
|
192
|
+
# set these — plain strings remain their whole story.
|
|
193
|
+
observed_quantity: Quantity | None = None
|
|
194
|
+
expected_quantity: Quantity | None = None
|
|
195
|
+
uncertainty: Quantity | None = None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class CGContributor(BaseModel):
|
|
199
|
+
"""One mass term in the CG sum, retained so the result is explainable."""
|
|
200
|
+
|
|
201
|
+
model_config = ConfigDict(extra="forbid")
|
|
202
|
+
|
|
203
|
+
quantity: str
|
|
204
|
+
mass_kg: float
|
|
205
|
+
position_m: tuple[float, float, float] | None
|
|
206
|
+
moment_kgm: tuple[float, float, float] | None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class CGReport(BaseModel):
|
|
210
|
+
"""Severity-free CG information; absence of ``severity`` is load-bearing."""
|
|
211
|
+
|
|
212
|
+
model_config = ConfigDict(extra="forbid")
|
|
213
|
+
|
|
214
|
+
quantity: str
|
|
215
|
+
sources: Literal["devices", "links", "links+devices"]
|
|
216
|
+
frame: str | None
|
|
217
|
+
cg_m: tuple[float, float, float] | None
|
|
218
|
+
complete: bool
|
|
219
|
+
total_mass_kg: float
|
|
220
|
+
positioned_mass_kg: float
|
|
221
|
+
contributors: list[CGContributor]
|
|
222
|
+
limitation: str | None = None
|
|
223
|
+
# The budget's own declared total. Without it, coverage can only be measured
|
|
224
|
+
# against the model — a denominator that is itself the thing in question, so
|
|
225
|
+
# a lockfile holding 3% of an aeroplane reported "100% positioned" and GO.
|
|
226
|
+
budget_limit_kg: float | None = None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class RecordReport(list[Finding]):
|
|
230
|
+
"""Findings plus a separate informational lane.
|
|
231
|
+
|
|
232
|
+
Remaining list-like preserves the existing check API: callers filtering or
|
|
233
|
+
comparing findings still see findings only, while ``render`` can display
|
|
234
|
+
derived information without smuggling it into a warning.
|
|
235
|
+
|
|
236
|
+
``scored`` is the presence-floor denominator: how many
|
|
237
|
+
checks compared populated operands and reached a pass or violation.
|
|
238
|
+
``None`` means the caller did not compute a denominator. ``scored_keys``
|
|
239
|
+
names record-check declarations when set — a violation whose quantity is
|
|
240
|
+
in ``scored_keys`` is a comparison (it counts toward ``scored``); a
|
|
241
|
+
violation whose quantity is NOT in ``scored_keys`` (reference integrity,
|
|
242
|
+
duplicate-identity, structure drift…) was never compared to a second
|
|
243
|
+
source and does not. The identity ``scored == len(scored_records) +
|
|
244
|
+
comparison_violations`` holds — reference/structure
|
|
245
|
+
violations are reported on their own line, never folded into "do not
|
|
246
|
+
add up".
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
information: list[CGReport]
|
|
250
|
+
scored: int | None
|
|
251
|
+
scored_keys: frozenset[str] | None
|
|
252
|
+
scored_records: list[ScoredRecord]
|
|
253
|
+
actuator_case_reports: list[
|
|
254
|
+
Any
|
|
255
|
+
] # items are actuator_case.ActuatorCaseReport; untyped so this module stays free of it
|
|
256
|
+
|
|
257
|
+
def __init__(
|
|
258
|
+
self,
|
|
259
|
+
findings: list[Finding] | None = None,
|
|
260
|
+
information: list[CGReport] | None = None,
|
|
261
|
+
scored: int | None = None,
|
|
262
|
+
scored_keys: frozenset[str] | None = None,
|
|
263
|
+
scored_records: list[ScoredRecord] | None = None,
|
|
264
|
+
actuator_case_reports: list[Any] | None = None,
|
|
265
|
+
) -> None:
|
|
266
|
+
super().__init__(findings or [])
|
|
267
|
+
self.information = information or []
|
|
268
|
+
self.scored = scored
|
|
269
|
+
self.scored_keys = scored_keys
|
|
270
|
+
self.scored_records = scored_records or []
|
|
271
|
+
self.actuator_case_reports = list(actuator_case_reports or [])
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _vector(values: tuple[float, float, float]) -> str:
|
|
275
|
+
return "(" + ", ".join(f"{value:.3f}" for value in values) + ")"
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _cg_coverage(report: CGReport) -> str:
|
|
279
|
+
"""State what fraction of the DECLARED mass the CG was computed from.
|
|
280
|
+
|
|
281
|
+
``complete`` only ever meant "every contributor I have could be positioned",
|
|
282
|
+
so it divides the model by itself and reads 100% on a lockfile that holds a
|
|
283
|
+
fraction of the real machine. The declared budget total is the independent
|
|
284
|
+
denominator, and it is the one that decides whether a CG means anything.
|
|
285
|
+
"""
|
|
286
|
+
positioned = report.positioned_mass_kg
|
|
287
|
+
placed = sum(1 for c in report.contributors if c.position_m is not None)
|
|
288
|
+
tally = (
|
|
289
|
+
f"all {len(report.contributors)} contributors positioned"
|
|
290
|
+
if report.complete
|
|
291
|
+
else f"{placed} of {len(report.contributors)} contributors positioned"
|
|
292
|
+
)
|
|
293
|
+
if report.budget_limit_kg is None or report.budget_limit_kg <= 0:
|
|
294
|
+
return f"{positioned:.3f} kg positioned; no declared total to measure against; {tally}"
|
|
295
|
+
share = positioned / report.budget_limit_kg
|
|
296
|
+
return f"{positioned:.3f} of {report.budget_limit_kg:.3f} kg declared ({share:.1%}); {tally}"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _render_cg(report: CGReport, console: Console) -> None:
|
|
300
|
+
"""Render CG as information, never with a judgement tag or action."""
|
|
301
|
+
frame = report.frame or "unresolved root frame"
|
|
302
|
+
positioned = any(contributor.position_m is not None for contributor in report.contributors)
|
|
303
|
+
if report.cg_m is None and not positioned:
|
|
304
|
+
count = len(report.contributors)
|
|
305
|
+
console.print(
|
|
306
|
+
f"[cyan]INFO[/] {report.quantity} — not computable: none of the {count} "
|
|
307
|
+
f"contributors declares a mount position (sources: {report.sources})"
|
|
308
|
+
)
|
|
309
|
+
if report.limitation:
|
|
310
|
+
console.print(f" limitation: {report.limitation}")
|
|
311
|
+
return
|
|
312
|
+
if report.cg_m is None:
|
|
313
|
+
result = "not computable"
|
|
314
|
+
elif report.complete:
|
|
315
|
+
result = f"{_vector(report.cg_m)} m in {frame}"
|
|
316
|
+
else:
|
|
317
|
+
result = (
|
|
318
|
+
f"partial {_vector(report.cg_m)} m in {frame} "
|
|
319
|
+
f"from {report.positioned_mass_kg:.3f}/{report.total_mass_kg:.3f} kg"
|
|
320
|
+
)
|
|
321
|
+
console.print(f"[cyan]INFO[/] {report.quantity} — {result} (sources: {report.sources})")
|
|
322
|
+
if report.cg_m is not None:
|
|
323
|
+
console.print(f" coverage: {_cg_coverage(report)}")
|
|
324
|
+
for contributor in report.contributors:
|
|
325
|
+
if contributor.position_m is None:
|
|
326
|
+
position = "position unresolved"
|
|
327
|
+
moment = ""
|
|
328
|
+
else:
|
|
329
|
+
position = f"at {_vector(contributor.position_m)} m"
|
|
330
|
+
moment = f", m·r {_vector(contributor.moment_kgm or (0.0, 0.0, 0.0))} kg·m"
|
|
331
|
+
console.print(
|
|
332
|
+
f" contributor: {contributor.quantity} — "
|
|
333
|
+
f"{contributor.mass_kg:.3f} kg {position}{moment}"
|
|
334
|
+
)
|
|
335
|
+
if report.limitation:
|
|
336
|
+
console.print(f" limitation: {report.limitation}")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _severity_tag(severity: Severity) -> str:
|
|
340
|
+
if severity == "violation":
|
|
341
|
+
return "[bold red]VIOLATION[/]"
|
|
342
|
+
if severity == "warning":
|
|
343
|
+
return "[yellow]warning[/]"
|
|
344
|
+
return "[cyan]not scored[/]"
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _presentation_groups(findings: list[Finding]) -> list[tuple[Finding, ...]]:
|
|
348
|
+
"""Group correlated findings for display without changing the underlying list."""
|
|
349
|
+
clustered: dict[tuple[Severity, str], list[Finding]] = defaultdict(list)
|
|
350
|
+
groups: list[tuple[Finding, ...]] = []
|
|
351
|
+
for finding in sorted(
|
|
352
|
+
findings,
|
|
353
|
+
key=lambda item: (_SEVERITY_RANK[item.severity], item.quantity),
|
|
354
|
+
):
|
|
355
|
+
if finding.cluster_root is None:
|
|
356
|
+
groups.append((finding,))
|
|
357
|
+
else:
|
|
358
|
+
clustered[(finding.severity, finding.cluster_root)].append(finding)
|
|
359
|
+
groups.extend(tuple(group) for group in clustered.values())
|
|
360
|
+
return sorted(
|
|
361
|
+
groups,
|
|
362
|
+
key=lambda group: (
|
|
363
|
+
_SEVERITY_RANK[group[0].severity],
|
|
364
|
+
group[0].cluster_root or group[0].quantity,
|
|
365
|
+
),
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _cluster_members(group: tuple[Finding, ...]) -> tuple[str, ...]:
|
|
370
|
+
return tuple(
|
|
371
|
+
sorted(
|
|
372
|
+
{
|
|
373
|
+
member
|
|
374
|
+
for finding in group
|
|
375
|
+
for member in (finding.cluster_members or (finding.quantity,))
|
|
376
|
+
}
|
|
377
|
+
)
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _cluster_root_finding(group: tuple[Finding, ...]) -> Finding | None:
|
|
382
|
+
"""Select the finding about the physical root, independent of member sort order."""
|
|
383
|
+
cluster_root = group[0].cluster_root
|
|
384
|
+
if cluster_root is None:
|
|
385
|
+
return None
|
|
386
|
+
return next(
|
|
387
|
+
(
|
|
388
|
+
finding
|
|
389
|
+
for finding in group
|
|
390
|
+
if finding.dependent == cluster_root or cluster_root in finding.cluster_members
|
|
391
|
+
),
|
|
392
|
+
None,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _render_finding_detail(
|
|
397
|
+
finding: Finding,
|
|
398
|
+
console: Console,
|
|
399
|
+
*,
|
|
400
|
+
observed_indent: str = "",
|
|
401
|
+
metadata_indent: str = " ",
|
|
402
|
+
) -> None:
|
|
403
|
+
line = f"{observed_indent}observed {finding.observed}, expected {finding.expected}"
|
|
404
|
+
if finding.delta:
|
|
405
|
+
line += f" (Δ {finding.delta})"
|
|
406
|
+
console.print(line)
|
|
407
|
+
if finding.dependent:
|
|
408
|
+
console.print(f"{metadata_indent}dependent: {finding.dependent}")
|
|
409
|
+
if finding.owner:
|
|
410
|
+
console.print(f"{metadata_indent}owner: {finding.owner}")
|
|
411
|
+
if finding.source:
|
|
412
|
+
console.print(f"{metadata_indent}source: {finding.source}")
|
|
413
|
+
console.print(f"{metadata_indent}action: {finding.action}")
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _render_finding_groups(groups: list[tuple[Finding, ...]], console: Console) -> None:
|
|
417
|
+
for group in groups:
|
|
418
|
+
finding = group[0]
|
|
419
|
+
tag = _severity_tag(finding.severity)
|
|
420
|
+
members = _cluster_members(group)
|
|
421
|
+
if finding.cluster_root is not None and len(members) > 1:
|
|
422
|
+
console.print(
|
|
423
|
+
f"{tag} {finding.cluster_root} subsystem: {len(members)} members affected"
|
|
424
|
+
f" — {', '.join(members)}"
|
|
425
|
+
)
|
|
426
|
+
for member_finding in group:
|
|
427
|
+
console.print(f" {member_finding.quantity}:")
|
|
428
|
+
_render_finding_detail(
|
|
429
|
+
member_finding,
|
|
430
|
+
console,
|
|
431
|
+
observed_indent=" ",
|
|
432
|
+
metadata_indent=" ",
|
|
433
|
+
)
|
|
434
|
+
else:
|
|
435
|
+
console.print(f"{tag} {finding.quantity} — ", end="")
|
|
436
|
+
_render_finding_detail(finding, console)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
@dataclass(frozen=True, slots=True)
|
|
440
|
+
class Verdict:
|
|
441
|
+
"""The one GO/NO-GO decision, computed once and read by every consumer.
|
|
442
|
+
|
|
443
|
+
``render`` prints ``summary`` and returns ``exit_code``; the JSON document
|
|
444
|
+
(``report_json``) reports ``code`` and these same counts. Deriving the
|
|
445
|
+
verdict twice — once for a terminal and once for a machine — is how a gate
|
|
446
|
+
ends up disagreeing with itself, so it is decided in exactly one place.
|
|
447
|
+
|
|
448
|
+
``NOT_SCORED`` is kept distinct from ``GO`` deliberately: a zero denominator
|
|
449
|
+
is not clearance, and a consumer that cannot tell
|
|
450
|
+
the two apart will read vacuity as health. ``NOT_SCORED`` exits **3** —
|
|
451
|
+
distinct from GO 0, NO-GO 1 and REFUSED 2 — so a script can tell "nothing
|
|
452
|
+
compared" from pass and fail without parsing the summary text.
|
|
453
|
+
"""
|
|
454
|
+
|
|
455
|
+
code: VerdictCode
|
|
456
|
+
exit_code: int
|
|
457
|
+
summary: str # rich markup, exactly as render has always printed it
|
|
458
|
+
violations: int
|
|
459
|
+
warnings: int
|
|
460
|
+
not_scored: int
|
|
461
|
+
scored: int | None
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def verdict(findings: list[Finding]) -> Verdict:
|
|
465
|
+
"""Decide GO/NO-GO and the summary line, without printing anything."""
|
|
466
|
+
violations = sum(f.severity == "violation" for f in findings)
|
|
467
|
+
warnings = sum(f.severity == "warning" for f in findings)
|
|
468
|
+
not_scored = sum(f.severity == "not_scored" for f in findings)
|
|
469
|
+
scored = findings.scored if isinstance(findings, RecordReport) else None
|
|
470
|
+
errors = getattr(findings, "errors", 0)
|
|
471
|
+
|
|
472
|
+
def decided(code: VerdictCode, summary: str) -> Verdict:
|
|
473
|
+
return Verdict(
|
|
474
|
+
code=code,
|
|
475
|
+
exit_code=VERDICT_EXIT_CODES[code],
|
|
476
|
+
summary=summary,
|
|
477
|
+
violations=violations,
|
|
478
|
+
warnings=warnings,
|
|
479
|
+
not_scored=not_scored,
|
|
480
|
+
scored=scored,
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
if errors:
|
|
484
|
+
return decided(
|
|
485
|
+
"REFUSED",
|
|
486
|
+
f"\n[bold red]REFUSED[/] — {errors} plan assertion error(s)",
|
|
487
|
+
)
|
|
488
|
+
if violations:
|
|
489
|
+
return decided(
|
|
490
|
+
"NO_GO",
|
|
491
|
+
f"\n[bold red]NO-GO[/] — {violations} violation(s), {warnings} warning(s)",
|
|
492
|
+
)
|
|
493
|
+
# Presence floor: a zero denominator is never a pass, even
|
|
494
|
+
# with a warning riding along — "GO with notes" over nothing compared
|
|
495
|
+
# reads as clearance to a consumer that only checks for the word GO. This
|
|
496
|
+
# must be decided before the warnings branch below, not after: box-A
|
|
497
|
+
# review (b06f38d) caught a run with scored == 0 and a warning printing
|
|
498
|
+
# "GO with notes", exit 0.
|
|
499
|
+
#
|
|
500
|
+
# ``scored`` is only ever an int here for a `RecordReport` (or subclass:
|
|
501
|
+
# `PreflightReport`, `PlanReport`) instance — each sets a real count in
|
|
502
|
+
# its own ``__init__``, never ``None``. ``scored`` is ``None`` only when
|
|
503
|
+
# ``findings`` is a *plain* ``list[Finding]``, not a `RecordReport`:
|
|
504
|
+
# `cli.baseline_findings_as_report` (a blessed-baseline diff has no
|
|
505
|
+
# scored/not_scored denominator to begin with — not a "nothing compared"
|
|
506
|
+
# case, there is nothing to score there ever) and `gui.build_payload`'s
|
|
507
|
+
# ``everything = record_findings + run_findings`` (list concatenation
|
|
508
|
+
# drops the `RecordReport`/`PreflightReport` type even though both
|
|
509
|
+
# operands carry real counts — this loses information regardless of
|
|
510
|
+
# whether anything was actually scored, so it is not provably a
|
|
511
|
+
# nothing-compared case and is left as today's behaviour).
|
|
512
|
+
if scored == 0:
|
|
513
|
+
warning_phrase = f"{warnings} warning(s); " if warnings else ""
|
|
514
|
+
return decided(
|
|
515
|
+
"NOT_SCORED",
|
|
516
|
+
f"\n[bold yellow]NOT SCORED[/] — nothing compared; {warning_phrase}"
|
|
517
|
+
f"{not_scored} check(s) not scored",
|
|
518
|
+
)
|
|
519
|
+
missing = getattr(findings, "missing_required_evidence", [])
|
|
520
|
+
if getattr(findings, "profile", "analysis") == "powered-release" and missing:
|
|
521
|
+
return decided(
|
|
522
|
+
"REFUSED",
|
|
523
|
+
"\n[bold red]REFUSED[/] — required evidence missing: " + ", ".join(missing),
|
|
524
|
+
)
|
|
525
|
+
if warnings:
|
|
526
|
+
return decided("GO_WITH_NOTES", f"\n[bold green]GO[/] with notes — {warnings} warning(s)")
|
|
527
|
+
if scored is not None:
|
|
528
|
+
if not_scored:
|
|
529
|
+
return decided(
|
|
530
|
+
"GO",
|
|
531
|
+
f"\n[bold green]GO[/] — records agree ({scored} scored); "
|
|
532
|
+
f"{not_scored} check(s) not scored",
|
|
533
|
+
)
|
|
534
|
+
return decided("GO", f"\n[bold green]GO[/] — records agree ({scored} scored), no findings")
|
|
535
|
+
if not_scored:
|
|
536
|
+
return decided(
|
|
537
|
+
"GO",
|
|
538
|
+
f"\n[bold green]GO[/] — records agree; {not_scored} check(s) not scored",
|
|
539
|
+
)
|
|
540
|
+
return decided("GO", "\n[bold green]GO[/] — records agree, no findings")
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def verdict_headline(violations: int, warnings: int, unchecked: int, adds_up: int = 0) -> str:
|
|
544
|
+
"""The one plain-words sentence for a verdict — shared by every door
|
|
545
|
+
that renders it: `gui.py`'s HTML page (`_headline`) and `api.check`'s
|
|
546
|
+
`CheckPayload.headline`. Reports what `verdict` already decided; does not
|
|
547
|
+
recompute a verdict itself, and takes no `VerdictCode` because the wording
|
|
548
|
+
below depends only on these counts, never on the code.
|
|
549
|
+
"""
|
|
550
|
+
if violations:
|
|
551
|
+
thing = "thing" if violations == 1 else "things"
|
|
552
|
+
return (
|
|
553
|
+
f"Don't power it on yet - {violations} {thing} "
|
|
554
|
+
f"{'does' if violations == 1 else 'do'} not add up"
|
|
555
|
+
)
|
|
556
|
+
if adds_up == 0:
|
|
557
|
+
return "Nothing could be compared yet"
|
|
558
|
+
if warnings:
|
|
559
|
+
note = "note" if warnings == 1 else "notes"
|
|
560
|
+
return f"Nothing stops you - {warnings} {note} worth reading"
|
|
561
|
+
if unchecked:
|
|
562
|
+
return "Nothing I checked disagrees"
|
|
563
|
+
return "Everything I checked agrees"
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
# The one exit-code table: `--help` for each gated command and README's table
|
|
567
|
+
# (README.md:162-166) both render from this, so the two surfaces cannot drift
|
|
568
|
+
# apart the way they did before — `preflight --help` naming 0/1/2 while
|
|
569
|
+
# its own NOT_SCORED path exits 3.
|
|
570
|
+
EXIT_CODE_TABLE: dict[str, dict[int, str]] = {
|
|
571
|
+
"upgrade": {
|
|
572
|
+
0: "new 0.4 contract written; legacy file retained",
|
|
573
|
+
2: "refused source revision, version, contents, or output path",
|
|
574
|
+
},
|
|
575
|
+
"what-if": {
|
|
576
|
+
0: "comparison produced (see each side verdict)",
|
|
577
|
+
2: "refused proposal or contract",
|
|
578
|
+
},
|
|
579
|
+
"check": {
|
|
580
|
+
0: "GO / GO with notes",
|
|
581
|
+
1: "NO-GO",
|
|
582
|
+
2: "contract could not be found, read, or parsed",
|
|
583
|
+
3: "NOT SCORED (nothing compared)",
|
|
584
|
+
},
|
|
585
|
+
"plan": {
|
|
586
|
+
0: "GO / GO with notes",
|
|
587
|
+
1: "NO-GO",
|
|
588
|
+
2: "REFUSED (a plan assertion could not be evaluated), or plan/contract "
|
|
589
|
+
"file could not be read or parsed",
|
|
590
|
+
3: "NOT SCORED (nothing compared)",
|
|
591
|
+
},
|
|
592
|
+
"preflight": {
|
|
593
|
+
0: "GO variants",
|
|
594
|
+
1: "NO-GO",
|
|
595
|
+
2: "refused run (recording or contract unusable)",
|
|
596
|
+
3: "NOT SCORED (nothing compared)",
|
|
597
|
+
},
|
|
598
|
+
"declare": {
|
|
599
|
+
0: "declared",
|
|
600
|
+
2: "refused (no contract, or a bad --source/--witness/value)",
|
|
601
|
+
},
|
|
602
|
+
"review": {
|
|
603
|
+
0: "reviewed",
|
|
604
|
+
2: "refused (no contract, nothing declared there yet, or a bad --witness/--note)",
|
|
605
|
+
},
|
|
606
|
+
# Pose and mount edits. Same shape as `declare` — they are the same
|
|
607
|
+
# writer with a narrower door — plus the refusals only these edits can hit.
|
|
608
|
+
"place": {
|
|
609
|
+
0: "placed",
|
|
610
|
+
2: "refused (no contract, an unknown device/link, a missing/wrong --unit, "
|
|
611
|
+
"or a bad --source/--witness)",
|
|
612
|
+
},
|
|
613
|
+
"mount-shape": {
|
|
614
|
+
0: "mount shape written",
|
|
615
|
+
2: "refused (no contract, unknown device, invalid geometry/mesh path, "
|
|
616
|
+
"or a bad --source/--witness)",
|
|
617
|
+
},
|
|
618
|
+
"move": {
|
|
619
|
+
0: "moved",
|
|
620
|
+
2: "refused (no contract, an unknown target, a missing/wrong --unit, "
|
|
621
|
+
"or a bad --source/--witness)",
|
|
622
|
+
},
|
|
623
|
+
"rotate": {
|
|
624
|
+
0: "rotated",
|
|
625
|
+
2: "refused (no contract, a device or root-link target that records no "
|
|
626
|
+
"orientation, a missing/wrong --unit, or a bad --source/--witness)",
|
|
627
|
+
},
|
|
628
|
+
"reparent": {
|
|
629
|
+
0: "re-parented",
|
|
630
|
+
2: "refused (no contract, a parent that does not resolve, a cycle, an "
|
|
631
|
+
"orphaned subtree, or a bad --source/--witness)",
|
|
632
|
+
},
|
|
633
|
+
"import": {
|
|
634
|
+
2: "REFUSED (the inbound direction is refused until Anthropic publishes the "
|
|
635
|
+
"standard)",
|
|
636
|
+
},
|
|
637
|
+
"hw": {
|
|
638
|
+
0: "recorded (including honest undetermined routing)",
|
|
639
|
+
2: "refused (no contract, or the event could not be recorded)",
|
|
640
|
+
},
|
|
641
|
+
"health": {
|
|
642
|
+
0: "GO",
|
|
643
|
+
1: "NO-GO (a device is faulty)",
|
|
644
|
+
2: "contract could not be read",
|
|
645
|
+
3: "NOT SCORED (no rows in the ledger)",
|
|
646
|
+
},
|
|
647
|
+
"bench": {
|
|
648
|
+
0: "clean corpus run (no missed detections)",
|
|
649
|
+
1: "a detection hole: at least one violating case in the corpus was not flagged",
|
|
650
|
+
2: "contract or corpus file could not be found, read, or parsed",
|
|
651
|
+
},
|
|
652
|
+
"scene import": {
|
|
653
|
+
2: "REFUSED (missing --witness/--source-person, unreadable contract or scene, "
|
|
654
|
+
"or the import itself could not be built or written)",
|
|
655
|
+
},
|
|
656
|
+
"start": {
|
|
657
|
+
0: "the contract was written (or an existing one kept); no page is written",
|
|
658
|
+
1: "the URDF/SDF could not be parsed, or the contract could not be read or written",
|
|
659
|
+
},
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def exit_code_help(command: str) -> str:
|
|
664
|
+
"""Render `command`'s exit-code line from `EXIT_CODE_TABLE`, for `--help`."""
|
|
665
|
+
codes = EXIT_CODE_TABLE[command]
|
|
666
|
+
parts = " · ".join(f"{code} {meaning}" for code, meaning in sorted(codes.items()))
|
|
667
|
+
return f"Exit codes: {parts}."
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def render(findings: list[Finding], console: Console | None = None) -> int:
|
|
671
|
+
"""Print information and findings; only violation/warning affect the exit path."""
|
|
672
|
+
console = console or _console()
|
|
673
|
+
|
|
674
|
+
# stage=None findings (record checks) keep the historical unlabelled grammar.
|
|
675
|
+
# Staged preflight findings are grouped and labelled by stage identity.
|
|
676
|
+
# Findings lead (violations/warnings/not-scored — what someone came to see),
|
|
677
|
+
# then the information lane, then the verdict — never buried under INFO noise.
|
|
678
|
+
unstaged = [f for f in findings if f.stage is None]
|
|
679
|
+
staged = [f for f in findings if f.stage is not None]
|
|
680
|
+
_render_finding_groups(_presentation_groups(unstaged), console)
|
|
681
|
+
for stage_id in sorted({f.stage for f in staged if f.stage is not None}):
|
|
682
|
+
console.print(f"[bold]stage {stage_id}[/]")
|
|
683
|
+
_render_finding_groups(
|
|
684
|
+
_presentation_groups([f for f in staged if f.stage == stage_id]),
|
|
685
|
+
console,
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
if isinstance(findings, RecordReport):
|
|
689
|
+
for information in sorted(findings.information, key=lambda item: item.quantity):
|
|
690
|
+
_render_cg(information, console)
|
|
691
|
+
|
|
692
|
+
decision = verdict(findings)
|
|
693
|
+
# Record checks only (not staged preflight): one line stating how many
|
|
694
|
+
# comparisons agreed, how many didn't, and how many couldn't be scored.
|
|
695
|
+
if type(findings) is RecordReport and findings.scored is not None:
|
|
696
|
+
scored_keys = findings.scored_keys or frozenset()
|
|
697
|
+
# only a violation that IS a comparison (its quantity was
|
|
698
|
+
# actually scored against a second source) belongs in "do not add up".
|
|
699
|
+
# A dangling reference or a structure drift never compared anything, so
|
|
700
|
+
# it cannot have "not added up" — it is reported on its own line below.
|
|
701
|
+
comparison_violations = sum(
|
|
702
|
+
1
|
|
703
|
+
for finding in findings
|
|
704
|
+
if finding.severity == "violation" and finding.quantity in scored_keys
|
|
705
|
+
)
|
|
706
|
+
reference_violations = decision.violations - comparison_violations
|
|
707
|
+
if findings.scored == 0:
|
|
708
|
+
# A warning (an empty budget, say) is not a failed comparison — with
|
|
709
|
+
# a zero denominator there is nothing to have agreed or disagreed,
|
|
710
|
+
# so never render it as "do not add up".
|
|
711
|
+
warning = "warning" if decision.warnings == 1 else "warnings"
|
|
712
|
+
console.print(
|
|
713
|
+
f"scored 0: nothing compared · {decision.warnings} {warning} · "
|
|
714
|
+
f"{decision.not_scored} not scored"
|
|
715
|
+
)
|
|
716
|
+
else:
|
|
717
|
+
adds_up = len(findings.scored_records)
|
|
718
|
+
console.print(
|
|
719
|
+
f"scored {findings.scored}: {adds_up} add up, {comparison_violations} do not · "
|
|
720
|
+
f"{decision.not_scored} not scored"
|
|
721
|
+
)
|
|
722
|
+
if reference_violations:
|
|
723
|
+
noun = "reference" if reference_violations == 1 else "references"
|
|
724
|
+
console.print(f"{noun}: {reference_violations} broken")
|
|
725
|
+
console.print(decision.summary)
|
|
726
|
+
return decision.exit_code
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
#: The graph-layout JS shared by this page and the HTML report (report_html.py) —
|
|
730
|
+
#: one BFS-banded, parent-grouped, wrapping layout and one box-avoiding edge
|
|
731
|
+
#: router, so a graph never looks different — or overlaps — depending on which
|
|
732
|
+
#: page drew it. Embedded verbatim into both documents' __GRAPH_LAYOUT_JS__ slot.
|
|
733
|
+
GRAPH_LAYOUT_JS = """function layout(src,W,H){
|
|
734
|
+
const nodes=src.nodes.map(n=>({...n})),edges=src.edges;
|
|
735
|
+
const byId={};nodes.forEach(n=>byId[n.id]=n);
|
|
736
|
+
/* Both lines have to fit. The sub line is monospace and is usually the longer of the
|
|
737
|
+
two — sizing off the title alone puts "12.6 V . cap 1000.00 W" outside its own box
|
|
738
|
+
on an ordinary rail. Ratios are the measured advances of the two faces, rounded up. */
|
|
739
|
+
nodes.forEach(n=>{
|
|
740
|
+
const title=n.label.length*7.2, sub=(n.sub||"").length*6.9;
|
|
741
|
+
n.w=Math.max(140,Math.min(300,Math.max(title,sub)+30));n.h=52});
|
|
742
|
+
const inc={};nodes.forEach(n=>inc[n.id]=0);
|
|
743
|
+
edges.forEach(e=>{if(e.to in inc)inc[e.to]++});
|
|
744
|
+
const lvl={};let front=nodes.filter(n=>!inc[n.id]).map(n=>n.id);
|
|
745
|
+
if(!front.length&&nodes.length)front=[nodes[0].id];
|
|
746
|
+
let depth=0;const seen=new Set();
|
|
747
|
+
while(front.length){const next=[];front.forEach(id=>{if(seen.has(id))return;seen.add(id);
|
|
748
|
+
lvl[id]=depth;edges.forEach(e=>{if(e.from===id)next.push(e.to)})});front=next;depth++}
|
|
749
|
+
nodes.forEach(n=>{if(!(n.id in lvl))lvl[n.id]=depth});
|
|
750
|
+
const bands={};nodes.forEach(n=>{(bands[lvl[n.id]]=bands[lvl[n.id]]||[]).push(n)});
|
|
751
|
+
const keys=Object.keys(bands).map(Number).sort((a,b)=>a-b);
|
|
752
|
+
const PADX=34,PADY=34,GAPX=26,NH=52,rows=[];
|
|
753
|
+
/* every child's first parent, so a band can be ordered and wrapped by who feeds it */
|
|
754
|
+
const parentOf={};edges.forEach(e=>{if(!(e.to in parentOf))parentOf[e.to]=e.from});
|
|
755
|
+
let prevOrder=[];
|
|
756
|
+
keys.forEach(k=>{
|
|
757
|
+
const isRoot=k===0;
|
|
758
|
+
const bandNodes=bands[k].slice();
|
|
759
|
+
if(!isRoot){
|
|
760
|
+
const prevIndex={};prevOrder.forEach((id,i)=>{prevIndex[id]=i});
|
|
761
|
+
bandNodes.sort((a,b)=>{
|
|
762
|
+
const pa=parentOf[a.id] in prevIndex?prevIndex[parentOf[a.id]]:prevOrder.length;
|
|
763
|
+
const pb=parentOf[b.id] in prevIndex?prevIndex[parentOf[b.id]]:prevOrder.length;
|
|
764
|
+
if(pa!==pb)return pa-pb;
|
|
765
|
+
return a.label<b.label?-1:a.label>b.label?1:0});
|
|
766
|
+
}
|
|
767
|
+
/* group by parent: a parent's children move (and wrap) together */
|
|
768
|
+
const groups=[];
|
|
769
|
+
bandNodes.forEach(n=>{const p=isRoot?n.id:(n.id in parentOf?parentOf[n.id]:null);
|
|
770
|
+
const last=groups[groups.length-1];
|
|
771
|
+
if(last&&last.parent===p)last.nodes.push(n);else groups.push({parent:p,nodes:[n]})});
|
|
772
|
+
/* wrap per group, not per band: a row holds whole groups; an over-wide group wraps itself */
|
|
773
|
+
let row=[];
|
|
774
|
+
groups.forEach(g=>{
|
|
775
|
+
const gw=g.nodes.reduce((a,n)=>a+n.w,0)+GAPX*(g.nodes.length-1);
|
|
776
|
+
if(gw>W-PADX*2){
|
|
777
|
+
if(row.length){rows.push(row);row=[]}
|
|
778
|
+
let sub=[],subW=0;
|
|
779
|
+
g.nodes.forEach(n=>{const need=(sub.length?GAPX:0)+n.w;
|
|
780
|
+
if(sub.length&&subW+need>W-PADX*2){rows.push({isRoot:isRoot,groups:[{parent:g.parent,nodes:sub}]});sub=[];subW=0}
|
|
781
|
+
sub.push(n);subW+=(sub.length>1?GAPX:0)+n.w});
|
|
782
|
+
if(sub.length)rows.push({isRoot:isRoot,groups:[{parent:g.parent,nodes:sub}]});
|
|
783
|
+
return}
|
|
784
|
+
const rowW=row.reduce((a,gr)=>a+gr.nodes.reduce((s,n)=>s+n.w,0)+GAPX*(gr.nodes.length-1),0)+
|
|
785
|
+
(row.length?GAPX:0);
|
|
786
|
+
if(row.length&&rowW+gw>W-PADX*2){rows.push({isRoot:isRoot,groups:row});row=[]}
|
|
787
|
+
row.push(g)});
|
|
788
|
+
if(row.length)rows.push({isRoot:isRoot,groups:row});
|
|
789
|
+
prevOrder=bandNodes.map(n=>n.id)});
|
|
790
|
+
const pitch=rows.length>1?Math.max(88,Math.min(165,(H-PADY*2-NH)/(rows.length-1))):0;
|
|
791
|
+
const blockH=(rows.length-1)*pitch+NH;
|
|
792
|
+
const startY=rows.length>1?Math.max(PADY,(H-blockH)/2)+NH/2:H/2;
|
|
793
|
+
rows.forEach((rowInfo,ri)=>{
|
|
794
|
+
const y=startY+ri*pitch,row=rowInfo.groups.flatMap(g=>g.nodes);
|
|
795
|
+
if(rowInfo.isRoot){
|
|
796
|
+
const sum=row.reduce((a,n)=>a+n.w,0);
|
|
797
|
+
const gap=row.length>1?Math.max(GAPX,Math.min(96,(W-PADX*2-sum)/(row.length-1))):0;
|
|
798
|
+
let x=(W-(sum+gap*(row.length-1)))/2;
|
|
799
|
+
row.forEach(n=>{n.x=x+n.w/2;n.y=y;x+=n.w+gap});
|
|
800
|
+
return}
|
|
801
|
+
/* centre each group under its parent's x; shift right only to clear the group before it */
|
|
802
|
+
let cursor=PADX;const placed=[];
|
|
803
|
+
rowInfo.groups.forEach(g=>{
|
|
804
|
+
const gw=g.nodes.reduce((a,n)=>a+n.w,0)+GAPX*(g.nodes.length-1);
|
|
805
|
+
const parentNode=g.parent?byId[g.parent]:null;
|
|
806
|
+
let left=parentNode&&parentNode.x!==undefined?parentNode.x-gw/2:cursor;
|
|
807
|
+
left=Math.max(left,cursor);
|
|
808
|
+
placed.push({g:g,left:left,gw:gw});cursor=left+gw+GAPX});
|
|
809
|
+
if(cursor-GAPX>W-PADX){
|
|
810
|
+
const shift=(cursor-GAPX)-(W-PADX);let c=PADX;
|
|
811
|
+
placed.forEach(p=>{const left=Math.max(p.left-shift,c);p.left=left;c=left+p.gw+GAPX})}
|
|
812
|
+
placed.forEach(p=>{let x=p.left;p.g.nodes.forEach(n=>{n.x=x+n.w/2;n.y=y;x+=n.w+GAPX})})});
|
|
813
|
+
return {nodes:nodes,edges:edges,byId:byId};
|
|
814
|
+
}
|
|
815
|
+
/* The straight parent-to-child line skips over whatever band sits between them —
|
|
816
|
+
a rail feeding a device two rows down runs straight through a sibling's box on
|
|
817
|
+
the row in between. Route around it: for every row the edge's y-span crosses,
|
|
818
|
+
hold the path at a clear x (outside every box on that row, widened by 6px) for
|
|
819
|
+
the row's whole height, so the detour itself never grazes the box it dodges. */
|
|
820
|
+
function edgePath(a,b,nodes){
|
|
821
|
+
const y1=a.y+a.h/2,y2=b.y-b.h/2;
|
|
822
|
+
const lo=Math.min(y1,y2),hi=Math.max(y1,y2),down=y2>=y1;
|
|
823
|
+
const rowsMap={};
|
|
824
|
+
nodes.forEach(n=>{if(n===a||n===b)return;
|
|
825
|
+
const top=n.y-n.h/2,bot=n.y+n.h/2;
|
|
826
|
+
if(top<hi&&bot>lo)(rowsMap[n.y]=rowsMap[n.y]||[]).push(n)});
|
|
827
|
+
const rowYs=Object.keys(rowsMap).map(Number).sort((p,q)=>down?p-q:q-p);
|
|
828
|
+
const pts=[{x:a.x,y:y1}];
|
|
829
|
+
rowYs.forEach(ry=>{
|
|
830
|
+
const rowNodes=rowsMap[ry];
|
|
831
|
+
const top=Math.min(...rowNodes.map(n=>n.y-n.h/2))-6,
|
|
832
|
+
bot=Math.max(...rowNodes.map(n=>n.y+n.h/2))+6;
|
|
833
|
+
const enterY=down?top:bot,exitY=down?bot:top;
|
|
834
|
+
const t=(enterY-y1)/(y2-y1);
|
|
835
|
+
let x=a.x+(b.x-a.x)*t,moved=true,guard=0;
|
|
836
|
+
while(moved&&guard++<10){moved=false;
|
|
837
|
+
rowNodes.forEach(n=>{const margin=n.w/2+6;
|
|
838
|
+
if(Math.abs(x-n.x)<margin){x=x<n.x?n.x-margin:n.x+margin;moved=true}})}
|
|
839
|
+
pts.push({x:x,y:enterY});pts.push({x:x,y:exitY})});
|
|
840
|
+
pts.push({x:b.x,y:y2});
|
|
841
|
+
let d="M"+pts[0].x+" "+pts[0].y;
|
|
842
|
+
for(let i=1;i<pts.length;i++){
|
|
843
|
+
const p0=pts[i-1],p1=pts[i];
|
|
844
|
+
if(p0.x===p1.x){d+=" L "+p1.x+" "+p1.y;continue}
|
|
845
|
+
const midY=(p0.y+p1.y)/2;
|
|
846
|
+
d+=" C "+p0.x+" "+midY+", "+p1.x+" "+midY+", "+p1.x+" "+p1.y}
|
|
847
|
+
return d;
|
|
848
|
+
}"""
|