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.
Files changed (52) hide show
  1. lockwire/__init__.py +9 -0
  2. lockwire/__main__.py +9 -0
  3. lockwire/advice.py +119 -0
  4. lockwire/api.py +1216 -0
  5. lockwire/api_payload.py +985 -0
  6. lockwire/bench.py +182 -0
  7. lockwire/blame.py +100 -0
  8. lockwire/catalog.py +100 -0
  9. lockwire/catalog_seed.yaml +545 -0
  10. lockwire/checks.py +3483 -0
  11. lockwire/cli.py +1799 -0
  12. lockwire/cli_line.py +73 -0
  13. lockwire/constraints.py +313 -0
  14. lockwire/copy.py +302 -0
  15. lockwire/describe.py +682 -0
  16. lockwire/edit.py +2098 -0
  17. lockwire/emit_ts.py +150 -0
  18. lockwire/export_mhs.py +115 -0
  19. lockwire/factdiff.py +254 -0
  20. lockwire/forces.py +334 -0
  21. lockwire/gitref.py +188 -0
  22. lockwire/hw.py +388 -0
  23. lockwire/mcp_server.py +276 -0
  24. lockwire/mechanism.py +379 -0
  25. lockwire/mesh_dae.py +555 -0
  26. lockwire/mesh_simple.py +198 -0
  27. lockwire/page.py +1485 -0
  28. lockwire/plan.py +849 -0
  29. lockwire/plan_payload.py +151 -0
  30. lockwire/plugins.py +70 -0
  31. lockwire/report.py +848 -0
  32. lockwire/report_html.py +707 -0
  33. lockwire/report_json.py +168 -0
  34. lockwire/review_draft.py +315 -0
  35. lockwire/scan.py +732 -0
  36. lockwire/scene.py +535 -0
  37. lockwire/scene_import.py +309 -0
  38. lockwire/scene_urdf.py +750 -0
  39. lockwire/scene_view.py +376 -0
  40. lockwire/schema.py +3616 -0
  41. lockwire/sdf.py +240 -0
  42. lockwire/serve.py +1256 -0
  43. lockwire/start_operation.py +92 -0
  44. lockwire/statics.py +77 -0
  45. lockwire/units.py +115 -0
  46. lockwire/urdf.py +348 -0
  47. lockwire/vcs.py +402 -0
  48. lockwire/whatif.py +161 -0
  49. lockwire-0.2.0.dist-info/METADATA +192 -0
  50. lockwire-0.2.0.dist-info/RECORD +52 -0
  51. lockwire-0.2.0.dist-info/WHEEL +4 -0
  52. 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
+