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/page.py
ADDED
|
@@ -0,0 +1,1485 @@
|
|
|
1
|
+
"""The lockwire GUI: one self-contained page per robot, written for the person holding it.
|
|
2
|
+
|
|
3
|
+
`report_html` renders two engineer-facing documents and is unchanged. This module
|
|
4
|
+
builds the page payload the desktop app and the sidecar serve: the same contract graph, the same
|
|
5
|
+
findings, the same numbers — said in words a robotics student can act on, with the
|
|
6
|
+
exact machine text one click away so nothing is softened out of existence.
|
|
7
|
+
|
|
8
|
+
No screen here predicts anything. Every sentence is a comparison between two
|
|
9
|
+
records that already exist.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import types
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Union, get_args, get_origin
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel
|
|
19
|
+
|
|
20
|
+
from lockwire import plugins
|
|
21
|
+
from lockwire.checks import _ScoreBook, check_scene_placement, run_record_checks
|
|
22
|
+
from lockwire.constraints import constraint_rows
|
|
23
|
+
from lockwire.describe import describe_all, render_power_tree
|
|
24
|
+
from lockwire.edit import resolve_witness
|
|
25
|
+
from lockwire.report import (
|
|
26
|
+
Finding,
|
|
27
|
+
format_magnitude,
|
|
28
|
+
RecordReport,
|
|
29
|
+
VerdictCode,
|
|
30
|
+
limit_raised_mark,
|
|
31
|
+
verdict,
|
|
32
|
+
verdict_headline,
|
|
33
|
+
_SEVERITY_RANK,
|
|
34
|
+
)
|
|
35
|
+
from lockwire.report_html import (
|
|
36
|
+
_budget_rows,
|
|
37
|
+
_graph,
|
|
38
|
+
_redact_path,
|
|
39
|
+
_redact_witness,
|
|
40
|
+
document_identity,
|
|
41
|
+
)
|
|
42
|
+
from lockwire.scene import Scene
|
|
43
|
+
from lockwire.scene_view import _rail_consumers
|
|
44
|
+
from lockwire.scene_view import scene_payload as _scene_payload
|
|
45
|
+
from lockwire.copy import check_label, fix_text, group_title, human_name, instead_row, value_name
|
|
46
|
+
from lockwire.schema import Contract, Provenance, Quantity
|
|
47
|
+
|
|
48
|
+
_KIND_PREFIXES = ("dev.", "rail.", "bus.", "bud.", "host.")
|
|
49
|
+
|
|
50
|
+
_TAGS = {
|
|
51
|
+
"violation": "Stop",
|
|
52
|
+
"warning": "Worth a look",
|
|
53
|
+
"not_scored": "Couldn't check",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def short_name(identifier: str) -> str:
|
|
58
|
+
"""Drop the machine prefix from a contract id, keeping everything that names it."""
|
|
59
|
+
for prefix in _KIND_PREFIXES:
|
|
60
|
+
if identifier.startswith(prefix):
|
|
61
|
+
return identifier[len(prefix) :]
|
|
62
|
+
return identifier
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _names(raw: str | None) -> list[str]:
|
|
66
|
+
if not raw:
|
|
67
|
+
return []
|
|
68
|
+
return [short_name(part.strip()) for part in raw.split(",") if part.strip()]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _join(names: list[str]) -> str:
|
|
72
|
+
if not names:
|
|
73
|
+
return ""
|
|
74
|
+
if len(names) == 1:
|
|
75
|
+
return names[0]
|
|
76
|
+
return ", ".join(names[:-1]) + " and " + names[-1]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_COLLECTIONS = frozenset(
|
|
80
|
+
{
|
|
81
|
+
"scene",
|
|
82
|
+
"devices",
|
|
83
|
+
"buses",
|
|
84
|
+
"rails",
|
|
85
|
+
"hosts",
|
|
86
|
+
"budgets",
|
|
87
|
+
"signals",
|
|
88
|
+
"envelopes",
|
|
89
|
+
"links",
|
|
90
|
+
"joints",
|
|
91
|
+
"cross_signal",
|
|
92
|
+
"coverage",
|
|
93
|
+
"stage",
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _shape(finding: Finding) -> str:
|
|
99
|
+
"""A grouping key: the contract path with the members of a collection blanked out.
|
|
100
|
+
|
|
101
|
+
``devices/dev.imu/signals/accel_x/variance`` and its five siblings become
|
|
102
|
+
``devices/*/signals/*/variance``, so one blind spot repeated across six channels
|
|
103
|
+
reads as one line instead of six near-identical ones. Only the member segment is
|
|
104
|
+
blanked — the kind of check it was is what remains, and that is what the reader
|
|
105
|
+
is being told.
|
|
106
|
+
"""
|
|
107
|
+
parts = finding.quantity.split("/")
|
|
108
|
+
shaped = []
|
|
109
|
+
for index, part in enumerate(parts):
|
|
110
|
+
member = "." in part or (index > 0 and parts[index - 1] in _COLLECTIONS)
|
|
111
|
+
shaped.append("*" if member else part)
|
|
112
|
+
return "/".join(shaped)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def finding_label(finding: Finding) -> str:
|
|
116
|
+
"""A plain 1-4 word name for the check a finding came from (copy.CHECK_LABELS)."""
|
|
117
|
+
return check_label(finding.quantity)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _instead(alternatives: Any) -> list[dict[str, str]]:
|
|
121
|
+
return [instead_row(a.text, a.kind) for a in alternatives]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def finding_labels(contract: Contract, findings: list[Finding]) -> list[dict[str, Any]]:
|
|
125
|
+
"""Per finding (same order): its label and typed targets, for Plan and Test rows."""
|
|
126
|
+
return [
|
|
127
|
+
{
|
|
128
|
+
"label": finding_label(f),
|
|
129
|
+
"targets": _targets(contract, [f]),
|
|
130
|
+
"whole": _whole(contract, f),
|
|
131
|
+
"instead": _instead(f.alternatives),
|
|
132
|
+
"fix": fix_text(f.action),
|
|
133
|
+
}
|
|
134
|
+
for f in findings
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _subject(finding: Finding) -> str:
|
|
139
|
+
"""The thing a finding is about: the last id-looking segment of its path."""
|
|
140
|
+
for part in reversed(finding.quantity.split("/")):
|
|
141
|
+
if "." in part:
|
|
142
|
+
return short_name(part)
|
|
143
|
+
return finding.quantity.split("/")[-1]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _budget(contract: Contract, quantity: str) -> Any | None:
|
|
147
|
+
"""The budget a ``budgets/<id>`` finding is about, so we can name its rail."""
|
|
148
|
+
if not quantity.startswith("budgets/"):
|
|
149
|
+
return None
|
|
150
|
+
wanted = quantity.removeprefix("budgets/").split("/", 1)[0]
|
|
151
|
+
for budget in contract.budgets:
|
|
152
|
+
if budget.id == wanted:
|
|
153
|
+
return budget
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _magnitude(finding: Finding, which: str) -> str:
|
|
158
|
+
"""The bare number, without the parenthetical notes the console line carries."""
|
|
159
|
+
quantity = getattr(finding, f"{which}_quantity", None)
|
|
160
|
+
if quantity is None:
|
|
161
|
+
return getattr(finding, which)
|
|
162
|
+
return format_magnitude(quantity.value, quantity.unit)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _upper_first(text: str) -> str:
|
|
166
|
+
"""Capitalise a machine fragment so it can open a sentence. `str.capitalize()`
|
|
167
|
+
would lower-case the rest, which destroys ids and units like `V` or `NaN`."""
|
|
168
|
+
return text[:1].upper() + text[1:] if text else text
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _sentence(contract: Contract, group: list[Finding]) -> tuple[str, str]:
|
|
172
|
+
"""Return (title, body) for one card, in words that name the physical thing."""
|
|
173
|
+
first = group[0]
|
|
174
|
+
shape = _shape(first)
|
|
175
|
+
seen: list[str] = []
|
|
176
|
+
for finding in group:
|
|
177
|
+
name = _subject(finding)
|
|
178
|
+
if name not in seen:
|
|
179
|
+
seen.append(name)
|
|
180
|
+
subjects = _join(seen)
|
|
181
|
+
stopped = first.severity == "violation"
|
|
182
|
+
if first.severity == "not_scored":
|
|
183
|
+
# A blind spot is not a measurement, so it never gets a measurement's grammar.
|
|
184
|
+
# `observed` is written as a machine fragment and starts lower-case; dropped
|
|
185
|
+
# straight after a full stop it reads as a sentence that lost its opening.
|
|
186
|
+
measured = (
|
|
187
|
+
f"{len(group)} checks here could not run, on {subjects}. Nothing failed - "
|
|
188
|
+
"but nothing was verified either. The exact reasons are below."
|
|
189
|
+
if len(group) > 1
|
|
190
|
+
else f"Nothing failed here - the check could not run. {_upper_first(first.observed)}."
|
|
191
|
+
)
|
|
192
|
+
else:
|
|
193
|
+
measured = f"Measured {first.observed}. Needs {first.expected}."
|
|
194
|
+
if first.delta:
|
|
195
|
+
measured += f" Off by {first.delta}."
|
|
196
|
+
|
|
197
|
+
if shape == "budgets/*":
|
|
198
|
+
budget = _budget(contract, first.quantity)
|
|
199
|
+
kind = budget.kind if budget is not None else None
|
|
200
|
+
label = short_name(budget.rail) if budget is not None and budget.rail else _subject(first)
|
|
201
|
+
if kind == "rail_power":
|
|
202
|
+
# A contract can budget the same rail in more than one draw mode; two
|
|
203
|
+
# cards titled identically read as a duplicate, so name the mode.
|
|
204
|
+
mode = getattr(budget, "draw_mode", "nominal") if budget is not None else "nominal"
|
|
205
|
+
in_mode = f" in the {mode} mode" if mode and mode != "nominal" else ""
|
|
206
|
+
if stopped:
|
|
207
|
+
loads = _join(_names(first.dependent))
|
|
208
|
+
on_it = f" On it: {loads}." if loads else ""
|
|
209
|
+
return (
|
|
210
|
+
f"{human_name(label)} rail overloaded{in_mode}",
|
|
211
|
+
f"It can give {_magnitude(first, 'expected')}, and what's plugged into it "
|
|
212
|
+
f"wants {_magnitude(first, 'observed')}{in_mode}.{on_it} Power this on and "
|
|
213
|
+
f"everything on that rail browns out together.",
|
|
214
|
+
)
|
|
215
|
+
return (f"{human_name(label)} rail load{in_mode}", measured)
|
|
216
|
+
if kind == "mass_total":
|
|
217
|
+
# G2 audit 2 §5: this total is summed from the model's own YAML
|
|
218
|
+
# declarations (URDF link masses + device masses) — no scale was
|
|
219
|
+
# ever read. "Measured" implies a recording; say what it actually is.
|
|
220
|
+
declared = f"Declared {first.observed} (sum of the model's links and devices)."
|
|
221
|
+
declared += f" Needs {first.expected}."
|
|
222
|
+
if first.delta:
|
|
223
|
+
declared += f" Off by {first.delta}."
|
|
224
|
+
return (
|
|
225
|
+
"Robot over weight limit" if stopped else "Total weight",
|
|
226
|
+
declared,
|
|
227
|
+
)
|
|
228
|
+
if kind == "endurance":
|
|
229
|
+
return (
|
|
230
|
+
"Runs shorter than asked" if stopped else "Run time estimate",
|
|
231
|
+
measured,
|
|
232
|
+
)
|
|
233
|
+
return (
|
|
234
|
+
f"{human_name(label)} over budget" if stopped else f"{human_name(label)} budget",
|
|
235
|
+
measured,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
if shape == "topology/buses/*/bandwidth":
|
|
239
|
+
if stopped:
|
|
240
|
+
return (
|
|
241
|
+
f"{human_name(_subject(first))} bus overloaded",
|
|
242
|
+
f"{measured} Messages will start arriving late or not at all.",
|
|
243
|
+
)
|
|
244
|
+
if first.observed == "no declared traffic on this bus":
|
|
245
|
+
return (
|
|
246
|
+
f"{len(group)} {'cable' if len(group) == 1 else 'cables'}: traffic not declared",
|
|
247
|
+
f"{subjects} — a bitrate is written down, but nothing says what's "
|
|
248
|
+
f"actually sent over them, so I can't tell you whether it fits. "
|
|
249
|
+
f"That's a blind spot, not a fault.",
|
|
250
|
+
)
|
|
251
|
+
return (
|
|
252
|
+
f"{len(group)} {'cable' if len(group) == 1 else 'cables'}: speed not declared",
|
|
253
|
+
f"{subjects} — without a bitrate I can't tell you whether the messages "
|
|
254
|
+
f"you're sending fit down them. That's a blind spot, not a fault.",
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
if shape == "check/scored":
|
|
258
|
+
# `scored` is a coverage disclosure, not a part of the robot. Left to the
|
|
259
|
+
# generic path it came out as "I couldn't check scored", which names an
|
|
260
|
+
# internal quantity to a student as though it were something on the bench.
|
|
261
|
+
return (
|
|
262
|
+
"Nothing could be compared yet",
|
|
263
|
+
"Every check here compares two numbers that were written down separately - "
|
|
264
|
+
"what a part draws against what its rail can give, a joint's limit against "
|
|
265
|
+
"its motor. This lockfile doesn't have a single pair like that yet, so "
|
|
266
|
+
"nothing was actually checked, and a pass would not have meant anything.",
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
if shape == "model/structure_hash":
|
|
270
|
+
return (
|
|
271
|
+
"CAD file not linked",
|
|
272
|
+
"No URDF file is recorded against this lockfile, so if someone moves a mount "
|
|
273
|
+
"or swaps the frame, nothing here notices.",
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
if shape == "model/links/*/mass":
|
|
277
|
+
# G2 audit 2 §5: a URDF link with no `<inertial>` is usually a fixed
|
|
278
|
+
# reference frame (a camera parent, an optical frame), not a physical
|
|
279
|
+
# part — wording it as "N parts have no weight recorded" reads as N
|
|
280
|
+
# real components left unweighed.
|
|
281
|
+
thing = "frame declares" if len(group) == 1 else "frames declare"
|
|
282
|
+
return (
|
|
283
|
+
f"{len(group)} {'frame' if len(group) == 1 else 'frames'} missing mass",
|
|
284
|
+
f"{subjects} — likely fixed/reference frames in the URDF rather than "
|
|
285
|
+
"physical parts; missing from your weight total and your centre of gravity.",
|
|
286
|
+
)
|
|
287
|
+
if shape.endswith("/physical/mass") or shape.endswith("/mass"):
|
|
288
|
+
thing = "part has" if len(group) == 1 else "parts have"
|
|
289
|
+
return (
|
|
290
|
+
f"{len(group)} {'part' if len(group) == 1 else 'parts'} missing mass",
|
|
291
|
+
f"{subjects} — missing from your weight total and your centre of gravity.",
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
if shape.endswith("/identity") or "collision" in shape:
|
|
295
|
+
return (f"{human_name(_subject(first))}: address clash", measured)
|
|
296
|
+
|
|
297
|
+
if shape == "devices/*/placement":
|
|
298
|
+
if first.severity == "not_scored":
|
|
299
|
+
return (
|
|
300
|
+
f"{len(group)} {'position' if len(group) == 1 else 'positions'} not declared",
|
|
301
|
+
measured,
|
|
302
|
+
)
|
|
303
|
+
return (
|
|
304
|
+
f"{human_name(_subject(first))} off position"
|
|
305
|
+
if len(group) == 1
|
|
306
|
+
else f"{len(group)} parts off position",
|
|
307
|
+
measured,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
if shape == "scene/*/unclaimed":
|
|
311
|
+
# H1 (2026-09-06): the raw quantity ("unclaimed") used to leak straight
|
|
312
|
+
# into the generic title below, once per scene-only id — collapse them
|
|
313
|
+
# into one plain-word card naming every part.
|
|
314
|
+
thing = "part" if len(group) == 1 else "parts"
|
|
315
|
+
return (
|
|
316
|
+
f"{len(group)} {thing} not declared",
|
|
317
|
+
f"{_join([finding.quantity.split('/')[1] for finding in group])}: "
|
|
318
|
+
"the model names parts the contract doesn't declare — declare them "
|
|
319
|
+
"(lockwire add device …), or give the model extras.lockwire.id for "
|
|
320
|
+
"the parts that are devices, or leave them as structure by removing the id",
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
who = human_name(_subject(first))
|
|
324
|
+
if first.stage is not None and stopped:
|
|
325
|
+
return (f"{who}: recording differs from lockfile", measured)
|
|
326
|
+
if stopped:
|
|
327
|
+
return (f"{who} differs from lockfile", measured)
|
|
328
|
+
if len(group) > 1:
|
|
329
|
+
return (f"{len(group)} checks couldn't run", measured)
|
|
330
|
+
return (f"Couldn't check {who}", measured)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _raw_text(group: list[Finding], *, redact_paths: bool = False) -> str:
|
|
334
|
+
"""The exact machine wording, unchanged, for anyone who wants to see it."""
|
|
335
|
+
blocks = []
|
|
336
|
+
for finding in group:
|
|
337
|
+
lines = [f"{finding.severity.upper()} {finding.quantity}"]
|
|
338
|
+
lines.append(f" observed {finding.observed}")
|
|
339
|
+
lines.append(f" expected {finding.expected}")
|
|
340
|
+
owner = finding.owner
|
|
341
|
+
source = finding.source
|
|
342
|
+
if redact_paths:
|
|
343
|
+
owner = _redact_witness(owner)
|
|
344
|
+
source = _redact_path(source)
|
|
345
|
+
for label, value in (
|
|
346
|
+
("delta", finding.delta),
|
|
347
|
+
("dependent", finding.dependent),
|
|
348
|
+
("owner", owner),
|
|
349
|
+
("source", source),
|
|
350
|
+
("action", finding.action),
|
|
351
|
+
):
|
|
352
|
+
if value:
|
|
353
|
+
lines.append(f" {label:<10} {value}")
|
|
354
|
+
blocks.append("\n".join(lines))
|
|
355
|
+
return "\n\n".join(blocks)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _faults(contract: Contract, group: list[Finding]) -> list[str]:
|
|
359
|
+
"""The ids a finding is actually *about* — the thing that is wrong.
|
|
360
|
+
|
|
361
|
+
A budget's own id is never a graph node, so a rail-power card resolves to the
|
|
362
|
+
rail it constrains — otherwise the headline finding highlights nothing.
|
|
363
|
+
|
|
364
|
+
Dependents are deliberately excluded: a device drawing exactly its datasheet
|
|
365
|
+
figure off an overloaded rail is the casualty, not the fault, and painting it
|
|
366
|
+
in fault colour tells the reader something untrue. See ``_affected``.
|
|
367
|
+
"""
|
|
368
|
+
found: list[str] = []
|
|
369
|
+
|
|
370
|
+
def add(value: str | None) -> None:
|
|
371
|
+
if value and value not in found:
|
|
372
|
+
found.append(value)
|
|
373
|
+
|
|
374
|
+
for finding in group:
|
|
375
|
+
budget = _budget(contract, finding.quantity)
|
|
376
|
+
if budget is not None:
|
|
377
|
+
add(budget.rail)
|
|
378
|
+
for part in finding.quantity.split("/"):
|
|
379
|
+
if "." in part and not part.startswith("bud."):
|
|
380
|
+
add(part)
|
|
381
|
+
return found
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _whole(contract: Contract, finding: Finding) -> bool:
|
|
385
|
+
"""A robot-wide budget (mass, endurance): about no single part and no rail."""
|
|
386
|
+
budget = _budget(contract, finding.quantity)
|
|
387
|
+
return finding.quantity.startswith("budgets/") and not (budget is not None and budget.rail)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _targets(contract: Contract, group: list[Finding]) -> list[dict[str, Any]]:
|
|
391
|
+
"""What a card is about, as typed ids (joint, link, device, rail), read from the finding's
|
|
392
|
+
quantity path and dependent list, never from its text. A device also targets the joint
|
|
393
|
+
it drives. Order is first mention; no duplicates."""
|
|
394
|
+
model = contract.model
|
|
395
|
+
joints = {j.name for j in model.joints} if model else set()
|
|
396
|
+
links = {link.name for link in model.links} if model else set()
|
|
397
|
+
devices = {d.id: d for d in contract.devices}
|
|
398
|
+
rails = {r.id for r in contract.topology.rails} if contract.topology else set()
|
|
399
|
+
found: list[dict[str, Any]] = []
|
|
400
|
+
|
|
401
|
+
def add(kind: str, id_: str | None) -> None:
|
|
402
|
+
if id_ and not any(t["kind"] == kind and t["id"] == id_ for t in found):
|
|
403
|
+
# The scene parts it lights: a rail lights its own part (if drawn) and every device
|
|
404
|
+
# on it; anything else is its own part. Ids are contract ids, as scene_view joins them.
|
|
405
|
+
parts = [id_, *sorted(_rail_consumers(contract, id_))] if kind == "rail" else [id_]
|
|
406
|
+
if kind == "joint": # mesh robots key meshes by the moving link, not the joint
|
|
407
|
+
parts += [j.child for j in (model.joints if model else []) if j.name == id_]
|
|
408
|
+
found.append({"kind": kind, "id": id_, "label": human_name(id_), "parts": parts})
|
|
409
|
+
|
|
410
|
+
def device(id_: str) -> None:
|
|
411
|
+
add("device", id_)
|
|
412
|
+
add("joint", devices[id_].drives_joint)
|
|
413
|
+
|
|
414
|
+
for finding in group:
|
|
415
|
+
budget = _budget(contract, finding.quantity)
|
|
416
|
+
if budget is not None and budget.rail:
|
|
417
|
+
add("rail", budget.rail)
|
|
418
|
+
elif _whole(contract, finding):
|
|
419
|
+
continue # a whole-robot total (mass, endurance) is about no single part
|
|
420
|
+
parts = finding.quantity.split("/")
|
|
421
|
+
if len(parts) >= 3 and parts[0] == "model" and parts[1] == "joints" and parts[2] in joints:
|
|
422
|
+
add("joint", parts[2])
|
|
423
|
+
elif len(parts) >= 3 and parts[0] == "model" and parts[1] == "links" and parts[2] in links:
|
|
424
|
+
add("link", parts[2])
|
|
425
|
+
elif len(parts) >= 2 and parts[0] == "devices" and parts[1] in devices:
|
|
426
|
+
device(parts[1])
|
|
427
|
+
elif len(parts) >= 3 and parts[0] == "topology" and parts[1] == "rails" and parts[2] in rails:
|
|
428
|
+
add("rail", parts[2])
|
|
429
|
+
elif len(parts) >= 3 and parts[0] == "topology" and parts[1] == "buses":
|
|
430
|
+
for d in devices.values(): # a bus finding is about the parts that talk on it
|
|
431
|
+
if d.identity and d.identity.bus_ref == parts[2]:
|
|
432
|
+
device(d.id)
|
|
433
|
+
else: # preflight/... and other paths: first known id among the segments and dependents
|
|
434
|
+
names = [*parts, *(n.strip() for n in (finding.dependent or "").split(","))]
|
|
435
|
+
for known, kind in ((devices, "device"), (joints, "joint"), (links, "link"), (rails, "rail")):
|
|
436
|
+
match = next((n for n in names if n in known), None)
|
|
437
|
+
if match:
|
|
438
|
+
device(match) if kind == "device" else add(kind, match)
|
|
439
|
+
break
|
|
440
|
+
for name in (finding.dependent or "").split(","):
|
|
441
|
+
name = name.strip()
|
|
442
|
+
if name in devices:
|
|
443
|
+
device(name)
|
|
444
|
+
elif name in rails:
|
|
445
|
+
add("rail", name)
|
|
446
|
+
return found
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def step_targets(contract: Contract, plan_path: str | Path) -> list[list[dict[str, Any]]]:
|
|
450
|
+
"""Typed targets per plan step (index-aligned), through ``_targets`` so a rail lights its
|
|
451
|
+
consumers exactly as a finding does. A step naming an id the contract lacks gets []."""
|
|
452
|
+
from lockwire.plan import load_plan # noqa: PLC0415
|
|
453
|
+
|
|
454
|
+
def path(step: Any) -> str:
|
|
455
|
+
return {
|
|
456
|
+
"rail_load": f"topology/rails/{getattr(step, 'rail', '')}",
|
|
457
|
+
"set_power_mode": f"devices/{getattr(step, 'device', '')}",
|
|
458
|
+
"joint_target": f"model/joints/{getattr(step, 'joint', '')}",
|
|
459
|
+
}.get(step.kind, "")
|
|
460
|
+
|
|
461
|
+
return [
|
|
462
|
+
_targets(contract, [Finding(severity="not_scored", quantity=q, observed="", expected="", action="")])
|
|
463
|
+
if (q := path(step)) else []
|
|
464
|
+
for step in load_plan(Path(plan_path)).steps
|
|
465
|
+
]
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _robot_model(contract: Contract) -> dict[str, list[dict[str, Any]]]:
|
|
469
|
+
"""The model's joints and links with world-frame positions (parent chain composed), so the
|
|
470
|
+
robot view draws it with no kinematics and no mesh files. Link visuals are not recorded in
|
|
471
|
+
the contract, so none are emitted (the renderer draws capsules between joint origins)."""
|
|
472
|
+
from lockwire.scene_urdf import _IDENTITY_POSE, _apply_r, _compose # noqa: PLC0415
|
|
473
|
+
|
|
474
|
+
model = contract.model
|
|
475
|
+
if model is None:
|
|
476
|
+
return {"joints": [], "links": []}
|
|
477
|
+
kids: dict[str, list[Any]] = {}
|
|
478
|
+
for joint in model.joints:
|
|
479
|
+
kids.setdefault(joint.parent, []).append(joint)
|
|
480
|
+
children = {joint.child for joint in model.joints}
|
|
481
|
+
link_pose = {
|
|
482
|
+
link.name: _IDENTITY_POSE for link in model.links if link.name not in children
|
|
483
|
+
}
|
|
484
|
+
joint_rows: dict[str, dict[str, Any]] = {}
|
|
485
|
+
stack = list(link_pose)
|
|
486
|
+
while stack:
|
|
487
|
+
name = stack.pop()
|
|
488
|
+
for joint in kids.get(name, []):
|
|
489
|
+
frame = _compose(link_pose[name], joint.origin.xyz, joint.origin.rpy)
|
|
490
|
+
if joint.child in link_pose:
|
|
491
|
+
continue # a cycle or second parent: first one wins
|
|
492
|
+
link_pose[joint.child] = frame
|
|
493
|
+
axis = joint.axis or (1.0, 0.0, 0.0)
|
|
494
|
+
limits = joint.limits
|
|
495
|
+
joint_rows[joint.name] = {
|
|
496
|
+
"name": joint.name,
|
|
497
|
+
"type": joint.type,
|
|
498
|
+
"parent": joint.parent,
|
|
499
|
+
"child": joint.child,
|
|
500
|
+
"position": list(frame[1]),
|
|
501
|
+
"axis": list(_apply_r(frame[0], axis)),
|
|
502
|
+
"lower": limits.lower if limits else None,
|
|
503
|
+
"upper": limits.upper if limits else None,
|
|
504
|
+
"effort": limits.effort if limits else None,
|
|
505
|
+
}
|
|
506
|
+
stack.append(joint.child)
|
|
507
|
+
return {
|
|
508
|
+
"joints": [joint_rows[j.name] for j in model.joints if j.name in joint_rows],
|
|
509
|
+
"links": [
|
|
510
|
+
{"name": link.name, "position": list(link_pose[link.name][1])}
|
|
511
|
+
for link in model.links
|
|
512
|
+
if link.name in link_pose
|
|
513
|
+
],
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _affected(contract: Contract, group: list[Finding]) -> list[str]:
|
|
518
|
+
"""The ids dragged in by someone else's fault — casualties, not causes.
|
|
519
|
+
|
|
520
|
+
These still light up when the card is selected (they are part of the story),
|
|
521
|
+
but they are drawn differently, because the legend promises the reader that
|
|
522
|
+
fault colour means *this is what is wrong*.
|
|
523
|
+
"""
|
|
524
|
+
blamed = set(_faults(contract, group))
|
|
525
|
+
found: list[str] = []
|
|
526
|
+
|
|
527
|
+
def add(value: str | None) -> None:
|
|
528
|
+
if value and value not in blamed and value not in found:
|
|
529
|
+
found.append(value)
|
|
530
|
+
|
|
531
|
+
for finding in group:
|
|
532
|
+
for name in (finding.dependent or "").split(","):
|
|
533
|
+
add(name.strip() or None)
|
|
534
|
+
for member in finding.cluster_members:
|
|
535
|
+
add(member)
|
|
536
|
+
return found
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _hits(contract: Contract, group: list[Finding]) -> list[str]:
|
|
540
|
+
"""Everything a card points at: what is wrong, plus what it drags down with it."""
|
|
541
|
+
return _faults(contract, group) + _affected(contract, group)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _consumer_files(contract: Contract, group: list[Finding]) -> list[str] | None:
|
|
545
|
+
"""The repo file(s) a `DependencyEdge` names as depending on a finding's quantity
|
|
546
|
+
. Display only: this is exactly `DependencyEdge.code`, keyed off the exact
|
|
547
|
+
quantity path an edge declares — never a filename inferred from a device id or a
|
|
548
|
+
naming convention. ``None`` when no edge names this quantity: not every
|
|
549
|
+
finding comes from an edge, and that absence must render as absence, not a guess.
|
|
550
|
+
"""
|
|
551
|
+
by_quantity = {edge.quantity: edge for edge in contract.edges}
|
|
552
|
+
found: list[str] = []
|
|
553
|
+
for finding in group:
|
|
554
|
+
edge = by_quantity.get(finding.quantity)
|
|
555
|
+
if edge is None:
|
|
556
|
+
continue
|
|
557
|
+
for path in edge.code:
|
|
558
|
+
if path not in found:
|
|
559
|
+
found.append(path)
|
|
560
|
+
return found or None
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _grouped(findings: list[Finding]) -> list[list[Finding]]:
|
|
564
|
+
"""One group per card: identical blind spots collapse; violations never do."""
|
|
565
|
+
ordered = sorted(
|
|
566
|
+
findings,
|
|
567
|
+
key=lambda finding: (_SEVERITY_RANK[finding.severity], finding.quantity),
|
|
568
|
+
)
|
|
569
|
+
groups: list[list[Finding]] = []
|
|
570
|
+
index: dict[tuple[str, str], list[Finding]] = {}
|
|
571
|
+
for finding in ordered:
|
|
572
|
+
if finding.severity == "violation":
|
|
573
|
+
groups.append([finding])
|
|
574
|
+
continue
|
|
575
|
+
shape = _shape(finding)
|
|
576
|
+
# A bus's bandwidth can be not_scored two different, non-overlapping ways
|
|
577
|
+
# (no bitrate at all vs. a bitrate with no declared traffic) — same shape,
|
|
578
|
+
# different blind spot, so they must not collapse into one card (B67).
|
|
579
|
+
key = (
|
|
580
|
+
(finding.severity, shape, finding.observed)
|
|
581
|
+
if finding.severity == "not_scored" and shape == "topology/buses/*/bandwidth"
|
|
582
|
+
else (finding.severity, shape)
|
|
583
|
+
)
|
|
584
|
+
if key in index:
|
|
585
|
+
index[key].append(finding)
|
|
586
|
+
continue
|
|
587
|
+
bucket = [finding]
|
|
588
|
+
index[key] = bucket
|
|
589
|
+
groups.append(bucket)
|
|
590
|
+
return groups
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _finding_key(finding: Finding) -> tuple[str, str, str]:
|
|
594
|
+
"""Identity of a finding for cross-section dedup: same quantity, same
|
|
595
|
+
observed-vs-expected pair. Two findings this equal on are the same fact,
|
|
596
|
+
not two."""
|
|
597
|
+
return (finding.quantity, finding.observed, finding.expected)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _dedupe_run_findings(
|
|
601
|
+
record_findings: list[Finding], run_findings: list[Finding]
|
|
602
|
+
) -> tuple[list[Finding], set[tuple[str, str, str]]]:
|
|
603
|
+
"""G2 audit 2 §8: a finding present in both the record checks and the run's
|
|
604
|
+
cross-record stage (e.g. the mass budget, scored again at stage 4) is one
|
|
605
|
+
fact, not two — it stays in the record section and drops out of the run
|
|
606
|
+
section, so neither the headline count nor the run's own count double it.
|
|
607
|
+
Returns the run findings with duplicates removed, and the keys that were
|
|
608
|
+
echoed (so the surviving record card can say "also seen in the run").
|
|
609
|
+
"""
|
|
610
|
+
record_keys = {_finding_key(finding) for finding in record_findings}
|
|
611
|
+
kept: list[Finding] = []
|
|
612
|
+
echoed: set[tuple[str, str, str]] = set()
|
|
613
|
+
for finding in run_findings:
|
|
614
|
+
key = _finding_key(finding)
|
|
615
|
+
if key in record_keys:
|
|
616
|
+
echoed.add(key)
|
|
617
|
+
continue
|
|
618
|
+
kept.append(finding)
|
|
619
|
+
return kept, echoed
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _card(
|
|
623
|
+
*,
|
|
624
|
+
severity: str,
|
|
625
|
+
title: str,
|
|
626
|
+
body: str,
|
|
627
|
+
fix: str,
|
|
628
|
+
raw: str,
|
|
629
|
+
hits: list[str],
|
|
630
|
+
faults: list[str],
|
|
631
|
+
affected: list[str],
|
|
632
|
+
count: int,
|
|
633
|
+
observed: dict[str, Any] | None,
|
|
634
|
+
expected: dict[str, Any] | None,
|
|
635
|
+
alternatives: list[dict[str, Any]],
|
|
636
|
+
figure_note: str | None,
|
|
637
|
+
shape: str,
|
|
638
|
+
also_in_run: bool,
|
|
639
|
+
label: str = "",
|
|
640
|
+
group_title: str = "",
|
|
641
|
+
instead: list[dict[str, str]] | None = None,
|
|
642
|
+
consumer: list[str] | None = None,
|
|
643
|
+
targets: list[dict[str, Any]] | None = None,
|
|
644
|
+
whole: bool = False,
|
|
645
|
+
count_by: str = "gui._cards",
|
|
646
|
+
) -> dict[str, Any]:
|
|
647
|
+
"""The one constructor for every `api_payload.Card`-shaped dict `gui.py` emits.
|
|
648
|
+
|
|
649
|
+
e3-cf3 (e3-cf2's flag): `_cards()` and `_empty_state_card()` used to build this
|
|
650
|
+
shape by hand, independently — an asymmetric field addition between them is
|
|
651
|
+
exactly what put `consumer` on one and not the other. Routing both through this
|
|
652
|
+
function makes that impossible: a field added here reaches both producers by
|
|
653
|
+
construction, not by someone remembering to touch the second call site.
|
|
654
|
+
"""
|
|
655
|
+
return {
|
|
656
|
+
"severity": severity,
|
|
657
|
+
"tag": _TAGS[severity],
|
|
658
|
+
"title": title,
|
|
659
|
+
"body": body,
|
|
660
|
+
"fix": fix,
|
|
661
|
+
"raw": raw,
|
|
662
|
+
"hits": hits,
|
|
663
|
+
"faults": faults,
|
|
664
|
+
"affected": affected,
|
|
665
|
+
"targets": targets or [],
|
|
666
|
+
"whole": whole,
|
|
667
|
+
"consumer": consumer,
|
|
668
|
+
"count": count,
|
|
669
|
+
"count_figure": {
|
|
670
|
+
"value": count,
|
|
671
|
+
"unit": "findings",
|
|
672
|
+
"kind": "derived",
|
|
673
|
+
"by": count_by,
|
|
674
|
+
},
|
|
675
|
+
"observed": observed,
|
|
676
|
+
"expected": expected,
|
|
677
|
+
"alternatives": alternatives,
|
|
678
|
+
"figure_note": figure_note,
|
|
679
|
+
"shape": shape,
|
|
680
|
+
"label": label,
|
|
681
|
+
"group_title": group_title,
|
|
682
|
+
"instead": instead or [],
|
|
683
|
+
"also_in_run": also_in_run,
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _cards(
|
|
688
|
+
contract: Contract,
|
|
689
|
+
findings: list[Finding],
|
|
690
|
+
*,
|
|
691
|
+
echoed: set[tuple[str, str, str]] = frozenset(),
|
|
692
|
+
redact_paths: bool = False,
|
|
693
|
+
) -> list[dict[str, Any]]:
|
|
694
|
+
"""One card per problem. Identical blind spots collapse; violations never do."""
|
|
695
|
+
cards = []
|
|
696
|
+
for group in _grouped(findings):
|
|
697
|
+
title, body = _sentence(contract, group)
|
|
698
|
+
observed, expected, figure_note = _card_figures(group[0])
|
|
699
|
+
cards.append(
|
|
700
|
+
_card(
|
|
701
|
+
severity=group[0].severity,
|
|
702
|
+
title=title,
|
|
703
|
+
body=body,
|
|
704
|
+
fix=fix_text(group[0].action),
|
|
705
|
+
raw=_raw_text(group, redact_paths=redact_paths),
|
|
706
|
+
hits=_hits(contract, group),
|
|
707
|
+
faults=_faults(contract, group),
|
|
708
|
+
affected=_affected(contract, group),
|
|
709
|
+
targets=_targets(contract, group),
|
|
710
|
+
whole=_whole(contract, group[0]),
|
|
711
|
+
consumer=_consumer_files(contract, group),
|
|
712
|
+
count=len(group),
|
|
713
|
+
observed=observed,
|
|
714
|
+
expected=expected,
|
|
715
|
+
alternatives=[
|
|
716
|
+
alternative.model_dump()
|
|
717
|
+
for finding in group
|
|
718
|
+
for alternative in finding.alternatives
|
|
719
|
+
],
|
|
720
|
+
figure_note=figure_note,
|
|
721
|
+
shape=_shape(group[0]),
|
|
722
|
+
label=finding_label(group[0]),
|
|
723
|
+
group_title=group_title(_shape(group[0])),
|
|
724
|
+
instead=_instead([a for f in group for a in f.alternatives]),
|
|
725
|
+
also_in_run=any(_finding_key(finding) in echoed for finding in group),
|
|
726
|
+
)
|
|
727
|
+
)
|
|
728
|
+
return cards
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _card_figures(
|
|
732
|
+
finding: Finding,
|
|
733
|
+
) -> tuple[dict[str, Any] | None, dict[str, Any] | None, str | None]:
|
|
734
|
+
"""Keep kinds only where this boundary can name the value's real producer.
|
|
735
|
+
|
|
736
|
+
``Finding`` carries typed quantities but no per-instance number-kind provenance. A
|
|
737
|
+
column-wide label would lie for computed preflight bounds, so cards preserve
|
|
738
|
+
the budget check's two known sources and disclose every other typed pair as
|
|
739
|
+
unavailable until its producer supplies kinds of its own.
|
|
740
|
+
"""
|
|
741
|
+
if finding.observed_quantity is None and finding.expected_quantity is None:
|
|
742
|
+
return None, None, None
|
|
743
|
+
if finding.stage is None and finding.quantity.startswith("budgets/"):
|
|
744
|
+
if finding.quantity.endswith("/limit"):
|
|
745
|
+
return (
|
|
746
|
+
_figure(
|
|
747
|
+
finding.observed_quantity,
|
|
748
|
+
"declared",
|
|
749
|
+
"schema.Contract.budgets.limit",
|
|
750
|
+
),
|
|
751
|
+
_figure(
|
|
752
|
+
finding.expected_quantity,
|
|
753
|
+
"declared",
|
|
754
|
+
"schema.Contract.topology.rails.capacity",
|
|
755
|
+
),
|
|
756
|
+
"Numeric finding prose withheld; the figures below carry recorded kinds.",
|
|
757
|
+
)
|
|
758
|
+
return (
|
|
759
|
+
_figure(finding.observed_quantity, "derived", "checks.check_budgets"),
|
|
760
|
+
_figure(
|
|
761
|
+
finding.expected_quantity,
|
|
762
|
+
"declared",
|
|
763
|
+
"schema.Contract.budgets.limit",
|
|
764
|
+
),
|
|
765
|
+
"Numeric finding prose withheld; the figures below carry recorded kinds.",
|
|
766
|
+
)
|
|
767
|
+
return None, None, "Numeric finding prose withheld because its source kind is unavailable."
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _figure(quantity: Quantity | None, kind: str, by: str) -> dict[str, Any] | None:
|
|
771
|
+
"""One `api_payload.Figure` as the plain dict `build_payload` deals in.
|
|
772
|
+
|
|
773
|
+
A number on screen is typed here and formatted by
|
|
774
|
+
the renderer. This door never hands the page a preformatted numeric string.
|
|
775
|
+
"""
|
|
776
|
+
if quantity is None:
|
|
777
|
+
return None
|
|
778
|
+
return {"value": quantity.value, "unit": quantity.unit, "kind": kind, "by": by}
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _comparator(expected: str) -> str:
|
|
782
|
+
"""The bound `Finding.expected` opens with, if any — a symbol, not a number."""
|
|
783
|
+
for symbol in ("<=", ">="):
|
|
784
|
+
if expected.startswith(symbol):
|
|
785
|
+
return symbol
|
|
786
|
+
return ""
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _scene_labels(contract: Contract, findings: list[Finding]) -> list[dict[str, Any]]:
|
|
790
|
+
"""One pinned label per violation/warning card: which part, how bad, in plain figures.
|
|
791
|
+
|
|
792
|
+
Pinned WITHOUT a click — these are the labels the scene draws by default.
|
|
793
|
+
The two magnitudes are typed (`api_payload.Figure`), not the
|
|
794
|
+
`"35.20 W vs <= 15.60 W"` string this emitted until 2026-09-08: a formatted
|
|
795
|
+
number carries no number-kind and nothing notices. `scene_js` formats them.
|
|
796
|
+
"""
|
|
797
|
+
labels: list[dict[str, Any]] = []
|
|
798
|
+
for group in _grouped(findings):
|
|
799
|
+
first = group[0]
|
|
800
|
+
if first.severity not in ("violation", "warning"):
|
|
801
|
+
continue
|
|
802
|
+
faults = _faults(contract, group)
|
|
803
|
+
if not faults:
|
|
804
|
+
continue
|
|
805
|
+
title, _body = _sentence(contract, group)
|
|
806
|
+
labels.append(
|
|
807
|
+
{
|
|
808
|
+
"part": faults[0],
|
|
809
|
+
"severity": first.severity,
|
|
810
|
+
"title": title,
|
|
811
|
+
"observed": _figure(
|
|
812
|
+
first.observed_quantity, "derived", "checks.run_record_checks"
|
|
813
|
+
),
|
|
814
|
+
"comparator": _comparator(first.expected),
|
|
815
|
+
"expected": _figure(first.expected_quantity, "declared", "schema.Contract"),
|
|
816
|
+
}
|
|
817
|
+
)
|
|
818
|
+
return labels
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _setup_gaps(contract: Contract) -> list[dict[str, str]]:
|
|
822
|
+
"""What this lockfile still doesn't say — the first-run screen, told honestly.
|
|
823
|
+
|
|
824
|
+
These are not findings. A brand-new lockfile is mostly empty by definition, and
|
|
825
|
+
the GUI's job on day one is to say what to fill in next rather than to look green.
|
|
826
|
+
"""
|
|
827
|
+
gaps: list[dict[str, str]] = []
|
|
828
|
+
topology = contract.topology
|
|
829
|
+
|
|
830
|
+
if contract.model is None or not contract.model.links:
|
|
831
|
+
gaps.append(
|
|
832
|
+
{
|
|
833
|
+
"title": "No robot model yet",
|
|
834
|
+
"why": "Nothing describes the frame, so weights and mounts can't be checked.",
|
|
835
|
+
"snippet": "lockwire init path/to/robot.urdf",
|
|
836
|
+
}
|
|
837
|
+
)
|
|
838
|
+
elif contract.model.zero_mass_links:
|
|
839
|
+
count = len(contract.model.zero_mass_links)
|
|
840
|
+
names = ", ".join(contract.model.zero_mass_links[:6])
|
|
841
|
+
more = "" if count <= 6 else " (and more)"
|
|
842
|
+
weigh = "part of the frame weighs" if count == 1 else "parts of the frame weigh"
|
|
843
|
+
gaps.append(
|
|
844
|
+
{
|
|
845
|
+
"title": f"{count} {weigh} nothing",
|
|
846
|
+
"why": f"{names}{more} came out of the URDF with zero mass, so your weight "
|
|
847
|
+
"total is wrong by however much they really weigh.",
|
|
848
|
+
"snippet": '# in your URDF, give each link a real <mass value="..."/>',
|
|
849
|
+
}
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
if topology is None or not topology.rails:
|
|
853
|
+
gaps.append(
|
|
854
|
+
{
|
|
855
|
+
"title": "No power rails declared",
|
|
856
|
+
"why": "Until rails exist I can't tell you whether your parts overload them — "
|
|
857
|
+
"the check that finds most real build defects.",
|
|
858
|
+
"snippet": (
|
|
859
|
+
"topology:\n"
|
|
860
|
+
" rails:\n"
|
|
861
|
+
" - id: rail.5v\n"
|
|
862
|
+
" voltage: {value: 5.0, unit: V}\n"
|
|
863
|
+
" capacity: {value: 15.6, unit: W}\n"
|
|
864
|
+
" upstream: battery\n"
|
|
865
|
+
" efficiency: 0.9"
|
|
866
|
+
),
|
|
867
|
+
}
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
if topology is None or not topology.buses:
|
|
871
|
+
gaps.append(
|
|
872
|
+
{
|
|
873
|
+
"title": "No cables declared",
|
|
874
|
+
"why": "Without buses I can't check bandwidth or spot two devices claiming "
|
|
875
|
+
"the same address.",
|
|
876
|
+
"snippet": (
|
|
877
|
+
"topology:\n"
|
|
878
|
+
" buses:\n"
|
|
879
|
+
" - id: bus.can.main\n"
|
|
880
|
+
" kind: can\n"
|
|
881
|
+
" host: flight-controller\n"
|
|
882
|
+
" bitrate: 1000000"
|
|
883
|
+
),
|
|
884
|
+
}
|
|
885
|
+
)
|
|
886
|
+
|
|
887
|
+
if not contract.devices:
|
|
888
|
+
gaps.append(
|
|
889
|
+
{
|
|
890
|
+
"title": "No parts declared",
|
|
891
|
+
"why": "The lockfile knows the frame but not what's bolted to it, so almost "
|
|
892
|
+
"nothing can be checked yet.",
|
|
893
|
+
"snippet": (
|
|
894
|
+
"devices:\n"
|
|
895
|
+
" - id: dev.camera.mine\n"
|
|
896
|
+
" power: {rail: rail.5v, draw: {value: 5.0, unit: W}}"
|
|
897
|
+
),
|
|
898
|
+
}
|
|
899
|
+
)
|
|
900
|
+
|
|
901
|
+
if not contract.budgets:
|
|
902
|
+
gaps.append(
|
|
903
|
+
{
|
|
904
|
+
"title": "No limits set",
|
|
905
|
+
"why": "Budgets are the lines you don't want to cross — rail power, total "
|
|
906
|
+
"weight, runtime. Without them there's nothing to fail against.",
|
|
907
|
+
"snippet": (
|
|
908
|
+
"budgets:\n"
|
|
909
|
+
" - id: bud.power.5v\n"
|
|
910
|
+
" kind: rail_power\n"
|
|
911
|
+
" rail: rail.5v\n"
|
|
912
|
+
" limit: {value: 15.6, unit: W}"
|
|
913
|
+
),
|
|
914
|
+
}
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
return gaps
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
_STATE_BY_VERDICT_CODE: dict[VerdictCode, str] = {
|
|
921
|
+
"REFUSED": "stop",
|
|
922
|
+
"NO_GO": "stop",
|
|
923
|
+
"GO_WITH_NOTES": "warn",
|
|
924
|
+
"NOT_SCORED": "warn",
|
|
925
|
+
"GO": "go",
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def _headline(
|
|
930
|
+
code: VerdictCode, violations: int, warnings: int, unchecked: int, adds_up: int = 0
|
|
931
|
+
) -> tuple[str, str, str]:
|
|
932
|
+
"""The one line at the top: state, headline, and the qualifier under it.
|
|
933
|
+
|
|
934
|
+
``state`` comes from ``report.verdict()``'s decision (``code``), not a local
|
|
935
|
+
recount of severities — the same decision `lockwire check` and the JSON/MCP
|
|
936
|
+
surfaces already render. The headline sentence itself is
|
|
937
|
+
``report.verdict_headline`` (E2-hl): the same text `api.check`'s
|
|
938
|
+
`CheckPayload.headline` now carries, read once and shared rather than
|
|
939
|
+
reimplemented here a second time. The wording below is unchanged.
|
|
940
|
+
"""
|
|
941
|
+
state = _STATE_BY_VERDICT_CODE[code]
|
|
942
|
+
headline = verdict_headline(violations, warnings, unchecked, adds_up)
|
|
943
|
+
adds_up_phrase = f"{adds_up} thing{' adds' if adds_up == 1 else 's add'} up" if adds_up else ""
|
|
944
|
+
couldnt_check_phrase = (
|
|
945
|
+
f"{unchecked} thing{'' if unchecked == 1 else 's'} I couldn't check" if unchecked else ""
|
|
946
|
+
)
|
|
947
|
+
tail = " · ".join(phrase for phrase in (adds_up_phrase, couldnt_check_phrase) if phrase)
|
|
948
|
+
if violations:
|
|
949
|
+
return (state, headline, tail)
|
|
950
|
+
if adds_up == 0:
|
|
951
|
+
# Presence floor (B110): a zero denominator is never a pass, and a
|
|
952
|
+
# warning over nothing compared ("Nothing stops you") reads as one.
|
|
953
|
+
# Amber, not green — the verdict code (decision.code) is unchanged;
|
|
954
|
+
# only this display collapses to "nothing to show yet" (A2, usage
|
|
955
|
+
# audit finding 4). NOT SCORED is amber, not grey ("neutral").
|
|
956
|
+
return ("warn", headline, couldnt_check_phrase)
|
|
957
|
+
if warnings:
|
|
958
|
+
return (state, headline, tail)
|
|
959
|
+
if unchecked:
|
|
960
|
+
return (state, headline, f"{tail} - a pass does not cover those")
|
|
961
|
+
return (state, headline, tail)
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
_NOTHING_DECLARED_NEXT_STEP = (
|
|
965
|
+
"nothing declared yet — add what is on it: lockwire add device|rail|bus …, "
|
|
966
|
+
"or declare from the model: lockwire scene import"
|
|
967
|
+
)
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def _nothing_declared(contract: Contract) -> bool:
|
|
971
|
+
"""the empty-state face. True when the lockfile has no devices, rails,
|
|
972
|
+
buses or budgets — the honest state of a lockfile straight out of `start`,
|
|
973
|
+
which knows only the frame."""
|
|
974
|
+
topology = contract.topology
|
|
975
|
+
return (
|
|
976
|
+
not contract.devices
|
|
977
|
+
and not contract.budgets
|
|
978
|
+
and (topology is None or (not topology.rails and not topology.buses))
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def _empty_state_card() -> dict[str, Any]:
|
|
983
|
+
"""The one card `_cards` cannot produce on its own: there is nothing to check
|
|
984
|
+
yet, so there is no finding to name. Built through `_card()`, the same
|
|
985
|
+
constructor `_cards()` uses, so it is the same shape by construction rather
|
|
986
|
+
than by two hand-written dict literals staying in sync."""
|
|
987
|
+
return _card(
|
|
988
|
+
severity="not_scored",
|
|
989
|
+
title="Nothing to check yet",
|
|
990
|
+
body=_NOTHING_DECLARED_NEXT_STEP,
|
|
991
|
+
fix=_NOTHING_DECLARED_NEXT_STEP,
|
|
992
|
+
raw="",
|
|
993
|
+
hits=[],
|
|
994
|
+
faults=[],
|
|
995
|
+
affected=[],
|
|
996
|
+
count=0,
|
|
997
|
+
observed=None,
|
|
998
|
+
expected=None,
|
|
999
|
+
alternatives=[],
|
|
1000
|
+
figure_note=None,
|
|
1001
|
+
shape="setup/nothing_declared",
|
|
1002
|
+
also_in_run=False,
|
|
1003
|
+
count_by="gui._empty_state_card",
|
|
1004
|
+
)
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def _scored_name(quantity: str) -> str:
|
|
1008
|
+
"""Plain name for a scored comparison: the id it's about, prefix stripped."""
|
|
1009
|
+
skip = {"topology", "budgets", "devices", "wires", "buses", "rails"}
|
|
1010
|
+
for part in quantity.split("/"):
|
|
1011
|
+
if part not in skip:
|
|
1012
|
+
return short_name(part)
|
|
1013
|
+
return short_name(quantity)
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def _scored_title(contract: Contract, record: Any) -> str:
|
|
1017
|
+
"""Plain-word row title for a passed comparison, in `_sentence()`'s voice: name
|
|
1018
|
+
the physical thing, what it measured against what it's allowed, then the margin.
|
|
1019
|
+
The exact quantity path and machine text stay behind the "Show the exact
|
|
1020
|
+
finding" toggle (``_adds_up_rows``' ``raw`` field) — this is only the title.
|
|
1021
|
+
"""
|
|
1022
|
+
quantity = record.quantity
|
|
1023
|
+
budget = _budget(contract, quantity)
|
|
1024
|
+
if budget is not None and budget.kind == "rail_power":
|
|
1025
|
+
rail_name = short_name(budget.rail) if budget.rail else _scored_name(quantity)
|
|
1026
|
+
mode = f", in its {budget.draw_mode} mode" if budget.draw_mode != "nominal" else ""
|
|
1027
|
+
limit = record.expected.removeprefix("<= ")
|
|
1028
|
+
return f"{rail_name} rail{mode}: {record.observed} of {limit} — {record.margin}"
|
|
1029
|
+
if budget is not None and budget.kind == "mass_total":
|
|
1030
|
+
limit = record.expected.removeprefix("<= ")
|
|
1031
|
+
return f"Payload mass: {record.observed} of {limit} allowed — {record.margin}"
|
|
1032
|
+
if budget is not None and budget.kind == "endurance":
|
|
1033
|
+
required = record.expected.removeprefix(">= ")
|
|
1034
|
+
return f"Flight time on the declared pack: {record.observed}, {required} required"
|
|
1035
|
+
if quantity.endswith("/power/rail"):
|
|
1036
|
+
device_id = quantity.split("/")[1]
|
|
1037
|
+
return f"{short_name(device_id)} on the {record.observed} rail: {record.margin}"
|
|
1038
|
+
if quantity.startswith("devices/") and quantity.endswith("/placement"):
|
|
1039
|
+
device_id = quantity.split("/")[1]
|
|
1040
|
+
return f"{short_name(device_id)} sits where the contract says — {record.margin}"
|
|
1041
|
+
# ampacity / bandwidth / link budget / torque / thermal, and anything else
|
|
1042
|
+
# scored: the same "observed of limit — margin" shape, named by its subject.
|
|
1043
|
+
subject = _scored_name(quantity)
|
|
1044
|
+
limit = record.expected.removeprefix("<= ").removeprefix(">= ")
|
|
1045
|
+
return f"{subject}: {record.observed} of {limit} — {record.margin}"
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def _scored_mark(contract: Contract, record: Any) -> str | None:
|
|
1049
|
+
"""the standing "limit raised" mark on the budget this pass is about.
|
|
1050
|
+
|
|
1051
|
+
A budget cleared by raising its limit passes, so it reaches the reader only
|
|
1052
|
+
here — as a row in "what adds up". Without the mark the row is a plain green
|
|
1053
|
+
pass and nothing on the page says the goalposts moved.
|
|
1054
|
+
"""
|
|
1055
|
+
budget = _budget(contract, record.quantity)
|
|
1056
|
+
return limit_raised_mark(budget.provenance) if budget is not None else None
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
def _adds_up_rows(contract: Contract, records: list[Any]) -> list[dict[str, str | None]]:
|
|
1060
|
+
"""One row per passed comparison: a plain-word title, the two compared values,
|
|
1061
|
+
and the margin. The quantity path and machine text stay behind ``raw``."""
|
|
1062
|
+
return [
|
|
1063
|
+
{
|
|
1064
|
+
"name": _scored_title(contract, record),
|
|
1065
|
+
"text": f"{record.observed} vs {record.expected}",
|
|
1066
|
+
"margin": record.margin,
|
|
1067
|
+
"mark": _scored_mark(contract, record),
|
|
1068
|
+
"raw": (
|
|
1069
|
+
f"{record.quantity}\n"
|
|
1070
|
+
f" observed: {record.observed}\n"
|
|
1071
|
+
f" expected: {record.expected}\n"
|
|
1072
|
+
f" margin: {record.margin}"
|
|
1073
|
+
),
|
|
1074
|
+
}
|
|
1075
|
+
for record in records
|
|
1076
|
+
]
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
# ------------------------------------------------------------- the window shell
|
|
1080
|
+
#
|
|
1081
|
+
# Mode bar + live health strip + CLI line + export. `lockwire --help` lists 20
|
|
1082
|
+
# commands (measured 2026-09-08: add, apply, baseline, baseline-check,
|
|
1083
|
+
# baseline-review, check, declare, describe, health, hw, init, mcp, plan,
|
|
1084
|
+
# preflight, report, sample, scan, scene, start); the parity test walks that list
|
|
1085
|
+
# against `_COMMAND_MODES` below and fails the day a command has no mode.
|
|
1086
|
+
|
|
1087
|
+
_MODE_ORDER = ["design", "declare", "check", "plan", "reference"]
|
|
1088
|
+
_MODE_LABELS = {
|
|
1089
|
+
"design": "Design",
|
|
1090
|
+
"declare": "Declare",
|
|
1091
|
+
"check": "Check",
|
|
1092
|
+
"plan": "Plan",
|
|
1093
|
+
"reference": "Reference",
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
_COMMAND_MODES = {
|
|
1097
|
+
"add": "design",
|
|
1098
|
+
"gearbox": "design",
|
|
1099
|
+
"scene": "design",
|
|
1100
|
+
# "E5's Design mode places, moves, rotates, re-parents parts and edits
|
|
1101
|
+
# mounting geometry." These commands are that mode's write door.
|
|
1102
|
+
"place": "design",
|
|
1103
|
+
"mount-shape": "design",
|
|
1104
|
+
"move": "design",
|
|
1105
|
+
"rotate": "design",
|
|
1106
|
+
"reparent": "design",
|
|
1107
|
+
"declare": "declare",
|
|
1108
|
+
"apply": "declare",
|
|
1109
|
+
"review": "declare", # approve/question a declared value, same mode group
|
|
1110
|
+
"check": "check",
|
|
1111
|
+
"what-if": "check",
|
|
1112
|
+
"hw": "check",
|
|
1113
|
+
"plan": "plan",
|
|
1114
|
+
"mcp": "plan",
|
|
1115
|
+
"scan": "reference",
|
|
1116
|
+
"start": "reference",
|
|
1117
|
+
"init": "reference",
|
|
1118
|
+
"upgrade": "reference", # pinned legacy->0.4 migration, writes and retains, no mode face of its own
|
|
1119
|
+
"report": "reference",
|
|
1120
|
+
"describe": "reference",
|
|
1121
|
+
"serve": "reference", # the sidecar launcher: infra, not a mode of its own
|
|
1122
|
+
# A detection/forwarding corpus report, not part of the contract-findings
|
|
1123
|
+
# page (eb-05 brief's paths table: "page: no") — reference like report/describe.
|
|
1124
|
+
"bench": "reference",
|
|
1125
|
+
"export": "reference", # writes an artefact for someone else to consume
|
|
1126
|
+
"import": "reference", # measured: refuses unconditionally, never reads a manifest
|
|
1127
|
+
# into a contract, so it doesn't contradict the outward-only rule and isn't a
|
|
1128
|
+
# declare-side round trip either — a refusal-only command, grouped with export
|
|
1129
|
+
"diff": "reference", # a read-only comparison of two facts, no design/check/plan face
|
|
1130
|
+
"log": "reference", # read-only contract history, served without a new app mode
|
|
1131
|
+
"blame": "reference", # read-only declared-fact provenance, alongside contract history
|
|
1132
|
+
"api-schema": "reference", # E1-b: the payload union's JSON schema
|
|
1133
|
+
"api-types": "reference", # e1-d3: the app's TypeScript types, generated from the same union
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
def _design_face(contract: Contract) -> dict[str, Any]:
|
|
1137
|
+
"""Render the Design face showing declared parts with their power info.
|
|
1138
|
+
|
|
1139
|
+
The design mode governs; this function shows what's declared in the
|
|
1140
|
+
contract (devices, rails, power draw) as the base for editing."""
|
|
1141
|
+
parts = []
|
|
1142
|
+
for device in contract.devices:
|
|
1143
|
+
power = device.power
|
|
1144
|
+
rail = short_name(power.rail) if power is not None else "—"
|
|
1145
|
+
draw = _figure(power.draw if power is not None else None, "declared", "schema.Contract")
|
|
1146
|
+
parts.append({"id": short_name(device.id), "rail": rail, "draw": draw})
|
|
1147
|
+
return {
|
|
1148
|
+
"title": "What's declared",
|
|
1149
|
+
"parts": parts,
|
|
1150
|
+
"cli": "lockwire add device --model <model-id> --rail <rail-id> <contract>",
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
def _declare_face(contract_path: Path | None) -> dict[str, Any]:
|
|
1155
|
+
path = str(contract_path) if contract_path is not None else "lockwire.yaml"
|
|
1156
|
+
return {
|
|
1157
|
+
"title": "Declare a fact",
|
|
1158
|
+
"lines": [
|
|
1159
|
+
f"lockwire declare {path} devices.dev.<id>.physical.mass 0.5 "
|
|
1160
|
+
f'--source "datasheet" --witness "you"',
|
|
1161
|
+
"lockwire apply changes.yaml",
|
|
1162
|
+
f"lockwire add device --model <model-id> --rail <rail-id> {path}",
|
|
1163
|
+
# a reviewer approves or questions a declared value, in the
|
|
1164
|
+
# same face — the notes land beside the fact they judge, not in a
|
|
1165
|
+
# section of their own.
|
|
1166
|
+
f"lockwire review {path} devices.dev.<id>.physical.mass "
|
|
1167
|
+
f'--approve --witness "your name"',
|
|
1168
|
+
],
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
_UNION_ORIGINS = (Union, types.UnionType)
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
def _unwrap_optional(annotation: Any) -> Any:
|
|
1176
|
+
"""Strip ``Annotated``/``Optional`` wrappers down to the underlying type,
|
|
1177
|
+
e.g. ``Optional[Annotated[Quantity, AfterValidator(...)]]`` -> ``Quantity``.
|
|
1178
|
+
|
|
1179
|
+
Handles both ``Optional[X]``/``Union[X, None]`` (origin ``typing.Union``)
|
|
1180
|
+
and the ``X | None`` syntax the schema mostly writes (origin
|
|
1181
|
+
``types.UnionType``) — pydantic does not normalise one to the other in
|
|
1182
|
+
``FieldInfo.annotation``, and checking only ``typing.Union`` silently
|
|
1183
|
+
stopped the walk at every ``SomeModel | None`` field (measured: it never
|
|
1184
|
+
reached a single device's ``physical``/``power``/``thermal`` block).
|
|
1185
|
+
"""
|
|
1186
|
+
ann = annotation
|
|
1187
|
+
while True:
|
|
1188
|
+
if getattr(ann, "__metadata__", None) is not None: # Annotated[X, ...]
|
|
1189
|
+
ann = get_args(ann)[0]
|
|
1190
|
+
continue
|
|
1191
|
+
if get_origin(ann) in _UNION_ORIGINS:
|
|
1192
|
+
args = [a for a in get_args(ann) if a is not type(None)]
|
|
1193
|
+
if len(args) == 1:
|
|
1194
|
+
ann = args[0]
|
|
1195
|
+
continue
|
|
1196
|
+
break
|
|
1197
|
+
return ann
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
def _declare_rows(
|
|
1201
|
+
model_cls: type[BaseModel],
|
|
1202
|
+
obj: BaseModel | None,
|
|
1203
|
+
path: str,
|
|
1204
|
+
label_parts: tuple[str, ...],
|
|
1205
|
+
provenance: Provenance | None,
|
|
1206
|
+
group: str | None = None,
|
|
1207
|
+
short_parts: tuple[str, ...] = (),
|
|
1208
|
+
) -> list[dict[str, Any]]:
|
|
1209
|
+
"""Every ``schema.Quantity`` reachable from ``model_cls``, present or not
|
|
1210
|
+
(E4-df). Walked from the model's own fields — same recursion `declare`'s
|
|
1211
|
+
own path navigation uses (dict-shaped blocks by field name, id/name-keyed
|
|
1212
|
+
lists by entry) — so a path this emits is a path `declare` accepts, and
|
|
1213
|
+
nothing here re-derives a value the contract does not already hold.
|
|
1214
|
+
|
|
1215
|
+
``mount_xyz``-shaped fields (``tuple[Quantity, Quantity, Quantity]``) are
|
|
1216
|
+
ONE declarable quantity, not three — `declare` writes the whole tuple in
|
|
1217
|
+
one call (`edit.parse_value`'s ``mount_xyz`` special case), so splitting
|
|
1218
|
+
it into per-axis rows would offer a path `declare` cannot accept.
|
|
1219
|
+
|
|
1220
|
+
Scoped to `schema.Quantity`-typed fields only: a bare unitless number
|
|
1221
|
+
(``efficiency``, ``gear_ratio``, ``capacity_factor``) is not a "quantity"
|
|
1222
|
+
in the sense and is out of this box's charter.
|
|
1223
|
+
"""
|
|
1224
|
+
rows: list[dict[str, Any]] = []
|
|
1225
|
+
if "provenance" in model_cls.model_fields:
|
|
1226
|
+
provenance = getattr(obj, "provenance", None) if obj is not None else None
|
|
1227
|
+
|
|
1228
|
+
def names(name: str) -> dict[str, str]:
|
|
1229
|
+
# The short pair the list shows ("Base" / "mass"); `label` stays the full one.
|
|
1230
|
+
if group is not None:
|
|
1231
|
+
owner, rest = group, (*short_parts, name) # `group` arrives already human
|
|
1232
|
+
elif label_parts:
|
|
1233
|
+
owner, rest = human_name(label_parts[0]), (*label_parts[1:], name)
|
|
1234
|
+
else:
|
|
1235
|
+
owner, rest = human_name(name), ()
|
|
1236
|
+
return {"group": owner, "short": value_name(rest) if rest else owner}
|
|
1237
|
+
|
|
1238
|
+
for name, field in model_cls.model_fields.items():
|
|
1239
|
+
if name == "provenance":
|
|
1240
|
+
continue
|
|
1241
|
+
base = _unwrap_optional(field.annotation)
|
|
1242
|
+
value = getattr(obj, name, None) if obj is not None else None
|
|
1243
|
+
child_path = name if not path else f"{path}/{name}"
|
|
1244
|
+
origin = get_origin(base)
|
|
1245
|
+
if isinstance(base, type) and issubclass(base, Quantity):
|
|
1246
|
+
rows.append(
|
|
1247
|
+
{
|
|
1248
|
+
"path": child_path,
|
|
1249
|
+
"label": _humanize_path((*label_parts, name)),
|
|
1250
|
+
**names(name),
|
|
1251
|
+
"unit": value.unit if isinstance(value, Quantity) else None,
|
|
1252
|
+
"kind": "declared",
|
|
1253
|
+
"value": value.value if isinstance(value, Quantity) else None,
|
|
1254
|
+
"provenance": provenance,
|
|
1255
|
+
}
|
|
1256
|
+
)
|
|
1257
|
+
elif origin is tuple:
|
|
1258
|
+
inner = [_unwrap_optional(a) for a in get_args(base)]
|
|
1259
|
+
if inner and all(isinstance(t, type) and issubclass(t, Quantity) for t in inner):
|
|
1260
|
+
rows.append(
|
|
1261
|
+
{
|
|
1262
|
+
"path": child_path,
|
|
1263
|
+
"label": _humanize_path((*label_parts, name)),
|
|
1264
|
+
**names(name),
|
|
1265
|
+
"unit": value[0].unit if value else None,
|
|
1266
|
+
"kind": "declared",
|
|
1267
|
+
"value": [q.value for q in value] if value else None,
|
|
1268
|
+
"provenance": provenance,
|
|
1269
|
+
}
|
|
1270
|
+
)
|
|
1271
|
+
elif origin is list:
|
|
1272
|
+
args = get_args(base)
|
|
1273
|
+
item_type = args[0] if args else None
|
|
1274
|
+
if isinstance(item_type, type) and issubclass(item_type, BaseModel):
|
|
1275
|
+
for item in value or []:
|
|
1276
|
+
ident = getattr(item, "id", None) or getattr(item, "name", None)
|
|
1277
|
+
if ident is None:
|
|
1278
|
+
continue # not id/name-addressable -> `declare` cannot reach it either
|
|
1279
|
+
rows.extend(_declare_rows(item_type, item, f"{child_path}/{ident}", (*label_parts, str(ident)), provenance, f"{group} · {human_name(str(ident))}" if group else human_name(str(ident)), ()))
|
|
1280
|
+
elif isinstance(base, type) and issubclass(base, BaseModel):
|
|
1281
|
+
rows.extend(_declare_rows(base, value, child_path, (*label_parts, name), provenance, group, (*short_parts, name) if group is not None else ()))
|
|
1282
|
+
return rows
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
def _humanize_path(parts: tuple[str, ...]) -> str:
|
|
1286
|
+
return " ".join(part.replace("_", " ") for part in parts)
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
def build_declare_form(contract: Contract) -> dict[str, Any]:
|
|
1290
|
+
"""The Declare form's payload (E4-df): every declarable quantity in
|
|
1291
|
+
`contract`, plus a pre-filled witness box. this box's face (E4-b/c/d)
|
|
1292
|
+
renders and computes nothing, so every word and number it shows must
|
|
1293
|
+
already be here as a string or a kinded figure."""
|
|
1294
|
+
return {
|
|
1295
|
+
"rows": _declare_rows(Contract, contract, "", (), None),
|
|
1296
|
+
"witness": {"prefill": resolve_witness(None)},
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
def _plan_face(
|
|
1301
|
+
contract_path: Path | None,
|
|
1302
|
+
plan_path: Path | None,
|
|
1303
|
+
outcomes: list[Any] | None,
|
|
1304
|
+
) -> dict[str, Any]:
|
|
1305
|
+
cpath = str(contract_path) if contract_path is not None else "lockwire.yaml"
|
|
1306
|
+
face: dict[str, Any] = {
|
|
1307
|
+
"title": "Check a plan before it runs",
|
|
1308
|
+
"cli": f"lockwire plan {cpath} plan.yaml",
|
|
1309
|
+
"outcomes": None,
|
|
1310
|
+
}
|
|
1311
|
+
if plan_path is not None and outcomes is not None:
|
|
1312
|
+
face["cli"] = f"lockwire plan {cpath} {plan_path}"
|
|
1313
|
+
face["outcomes"] = [{"path": o.path, "status": o.status} for o in outcomes]
|
|
1314
|
+
return face
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
def _reference_face(contract: Contract, contract_path: Path | None) -> dict[str, Any]:
|
|
1318
|
+
"""The Reference face: four drawers over what the CLI already produces —
|
|
1319
|
+
describe, report, scan — plus the per-device reference sheets
|
|
1320
|
+
. Each drawer's ``cli_line`` uses the command's real flags
|
|
1321
|
+
(``report``'s printed line is ``-o/--out`` + ``--no-open``, never
|
|
1322
|
+
``--html`` — measured, that flag does not exist)."""
|
|
1323
|
+
cpath = str(contract_path) if contract_path is not None else "lockwire.yaml"
|
|
1324
|
+
files = describe_all(contract, human=True)
|
|
1325
|
+
devices = [
|
|
1326
|
+
{"id": name.removesuffix(".md"), "text": text}
|
|
1327
|
+
for name, text in files.items()
|
|
1328
|
+
if name != "SYSTEM.md"
|
|
1329
|
+
]
|
|
1330
|
+
drawers = [
|
|
1331
|
+
{
|
|
1332
|
+
"title": "Reference sheets",
|
|
1333
|
+
"content": files["SYSTEM.md"],
|
|
1334
|
+
"provenance": "generated from the contract",
|
|
1335
|
+
"cli_line": f"lockwire describe {cpath}",
|
|
1336
|
+
},
|
|
1337
|
+
{
|
|
1338
|
+
"title": "Engineer report",
|
|
1339
|
+
"content": "A kept, versioned, print-ready document: an identity line and "
|
|
1340
|
+
"provenance on every page — what a student hands a supervisor.",
|
|
1341
|
+
"provenance": "generated from the contract, deterministic",
|
|
1342
|
+
"cli_line": f"lockwire report {cpath} -o report.html --no-open",
|
|
1343
|
+
},
|
|
1344
|
+
{
|
|
1345
|
+
"title": "Hardware scan",
|
|
1346
|
+
"content": "Enumerates this machine's own hardware into a draft — never "
|
|
1347
|
+
"authoritative, always reviewed before it is locked.",
|
|
1348
|
+
"provenance": "from this machine, unreviewed",
|
|
1349
|
+
"cli_line": "lockwire scan --robot <robot-name>",
|
|
1350
|
+
},
|
|
1351
|
+
]
|
|
1352
|
+
tree = render_power_tree(contract, human=True)
|
|
1353
|
+
return {
|
|
1354
|
+
"title": "Reference",
|
|
1355
|
+
"devices": devices,
|
|
1356
|
+
"drawers": drawers,
|
|
1357
|
+
"power_tree": "\n".join(tree) if tree else None,
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
def _faces(
|
|
1362
|
+
contract: Contract,
|
|
1363
|
+
*,
|
|
1364
|
+
contract_path: Path | None,
|
|
1365
|
+
plan_path: Path | None,
|
|
1366
|
+
plan_outcomes: list[Any] | None,
|
|
1367
|
+
) -> dict[str, Any]:
|
|
1368
|
+
return {
|
|
1369
|
+
"design": _design_face(contract),
|
|
1370
|
+
"declare": _declare_face(contract_path),
|
|
1371
|
+
"plan": _plan_face(contract_path, plan_path, plan_outcomes),
|
|
1372
|
+
"reference": _reference_face(contract, contract_path),
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
def build_payload(
|
|
1377
|
+
contract: Contract,
|
|
1378
|
+
*,
|
|
1379
|
+
preflight: tuple[list[Finding], str] | None = None,
|
|
1380
|
+
scene: Scene | None = None,
|
|
1381
|
+
scene_source: str | None = None,
|
|
1382
|
+
contract_path: Path | None = None,
|
|
1383
|
+
plan_path: Path | None = None,
|
|
1384
|
+
plan_outcomes: list[Any] | None = None,
|
|
1385
|
+
cli_line: str | None = None,
|
|
1386
|
+
redact_paths: bool = False,
|
|
1387
|
+
) -> dict[str, Any]:
|
|
1388
|
+
"""Everything the page draws, in one deterministic dict."""
|
|
1389
|
+
record = run_record_checks(contract, contract_path=contract_path)
|
|
1390
|
+
record_findings = list(record)
|
|
1391
|
+
scored_records = list(record.scored_records) if isinstance(record, RecordReport) else []
|
|
1392
|
+
|
|
1393
|
+
placement_scores: _ScoreBook | None = None
|
|
1394
|
+
if scene is not None:
|
|
1395
|
+
placement_scores = _ScoreBook()
|
|
1396
|
+
record_findings += check_scene_placement(contract, scene, scores=placement_scores)
|
|
1397
|
+
# A placement pass ("the model agrees with the declared mount point") is
|
|
1398
|
+
# a thing that adds up exactly like any other passed comparison — it must
|
|
1399
|
+
# land in scored/adds_up/the drawer, not just colour a part green.
|
|
1400
|
+
scored_records += placement_scores.records
|
|
1401
|
+
|
|
1402
|
+
run_findings_raw = list(preflight[0]) if preflight is not None else []
|
|
1403
|
+
run_findings, echoed = _dedupe_run_findings(record_findings, run_findings_raw)
|
|
1404
|
+
everything = record_findings + run_findings
|
|
1405
|
+
|
|
1406
|
+
decision = verdict(everything)
|
|
1407
|
+
violations = decision.violations
|
|
1408
|
+
warnings = decision.warnings
|
|
1409
|
+
unchecked = decision.not_scored
|
|
1410
|
+
scored = record.scored if isinstance(record, RecordReport) else None
|
|
1411
|
+
if placement_scores is not None:
|
|
1412
|
+
scored = (scored or 0) + len(placement_scores)
|
|
1413
|
+
adds_up = len(scored_records)
|
|
1414
|
+
state, headline, qualifier = _headline(decision.code, violations, warnings, unchecked, adds_up)
|
|
1415
|
+
cards = _cards(contract, record_findings, echoed=echoed, redact_paths=redact_paths)
|
|
1416
|
+
if not cards and _nothing_declared(contract):
|
|
1417
|
+
# a lockfile fresh out of `start` has nothing to compare, so the
|
|
1418
|
+
# generic per-severity grammar above produces no card at all — the page
|
|
1419
|
+
# then falls back to "Nothing disagrees", which reads as a pass over a
|
|
1420
|
+
# page that checked nothing. Say what to do next instead.
|
|
1421
|
+
qualifier = _NOTHING_DECLARED_NEXT_STEP
|
|
1422
|
+
cards = [_empty_state_card()]
|
|
1423
|
+
|
|
1424
|
+
payload: dict[str, Any] = {
|
|
1425
|
+
"robot": contract.robot.name,
|
|
1426
|
+
"contract_id": contract.robot.id or "contract",
|
|
1427
|
+
"state": state,
|
|
1428
|
+
"headline": headline,
|
|
1429
|
+
"qualifier": qualifier,
|
|
1430
|
+
"scored": scored,
|
|
1431
|
+
"violations": violations,
|
|
1432
|
+
"warnings": warnings,
|
|
1433
|
+
"unchecked": unchecked,
|
|
1434
|
+
"adds_up": _adds_up_rows(contract, scored_records),
|
|
1435
|
+
"cards": cards,
|
|
1436
|
+
**_robot_model(contract),
|
|
1437
|
+
"graph": _graph(contract),
|
|
1438
|
+
"budgets": _budget_rows(contract, record_findings, redact_paths=redact_paths),
|
|
1439
|
+
"setup": _setup_gaps(contract),
|
|
1440
|
+
"scene": None,
|
|
1441
|
+
"modes": _MODE_ORDER,
|
|
1442
|
+
"mode_labels": _MODE_LABELS,
|
|
1443
|
+
"command_modes": _COMMAND_MODES,
|
|
1444
|
+
"faces": _faces(
|
|
1445
|
+
contract,
|
|
1446
|
+
contract_path=contract_path,
|
|
1447
|
+
plan_path=plan_path,
|
|
1448
|
+
plan_outcomes=plan_outcomes,
|
|
1449
|
+
),
|
|
1450
|
+
"cli_line": cli_line,
|
|
1451
|
+
"document_identity": document_identity(contract_path),
|
|
1452
|
+
}
|
|
1453
|
+
plugins.ensure()
|
|
1454
|
+
for extra in plugins.PAGE_EXTRAS:
|
|
1455
|
+
payload.update(
|
|
1456
|
+
extra(
|
|
1457
|
+
contract=contract,
|
|
1458
|
+
contract_path=contract_path,
|
|
1459
|
+
preflight=preflight,
|
|
1460
|
+
run_findings=run_findings,
|
|
1461
|
+
redact_paths=redact_paths,
|
|
1462
|
+
payload=payload,
|
|
1463
|
+
)
|
|
1464
|
+
)
|
|
1465
|
+
if scene is not None:
|
|
1466
|
+
# scored_records already carries the placement passes (merged above).
|
|
1467
|
+
payload["scene"] = _scene_payload(
|
|
1468
|
+
contract,
|
|
1469
|
+
scene,
|
|
1470
|
+
source=scene_source or scene.source,
|
|
1471
|
+
contract_path=contract_path,
|
|
1472
|
+
cards=cards,
|
|
1473
|
+
scored_records=scored_records,
|
|
1474
|
+
labels=_scene_labels(contract, record_findings),
|
|
1475
|
+
)
|
|
1476
|
+
payload["constraints"] = constraint_rows(
|
|
1477
|
+
contract,
|
|
1478
|
+
payload["budgets"],
|
|
1479
|
+
health_devices=payload.get("health_devices"),
|
|
1480
|
+
scene_part_ids={p["id"] for p in payload["scene"]["parts"] if p["id"]}
|
|
1481
|
+
if payload["scene"] is not None
|
|
1482
|
+
else None,
|
|
1483
|
+
)
|
|
1484
|
+
return payload
|
|
1485
|
+
|