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