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
@@ -0,0 +1,985 @@
1
+ """The payload **union** — one typed member per document lockwire's doors emit.
2
+
3
+ A union is the easiest place in a codebase to grow a second implementation, so
4
+ the rule here is absolute: **every member describes a dict an
5
+ existing producer already builds.** Nothing in this file computes a document,
6
+ re-walks a contract, or re-derives a verdict. The producers, measured
7
+ 2026-09-08 on `wt-lockwire-review`:
8
+
9
+ | member | producer | discriminator |
10
+ |---|---|---|
11
+ | `CheckPayload` | `api.check` -> `api.check_document` -> `api.verdict_document(..., CHECK_ARTIFACT_TYPE)` plus `headline` (E2-hl) | `lockwire-check-report` |
12
+ | `PlanPayload` | `api.plan` -> the same `api.verdict_document`, `PLAN_ARTIFACT_TYPE` | `lockwire-plan-report` |
13
+ | `DescribePayload` | `api.describe` -> `describe.describe_all` behind `report_json.envelope` | `lockwire-describe` |
14
+ | `EditPayload` | `api.declare` -> `edit.apply_changes` behind `report_json.envelope` | `lockwire-edit-result` |
15
+ | `PagePayload` | `gui.build_payload` | *(none — see the seams)* |
16
+ | `DiffPayload` | `api.diff` -> `factdiff.FactDiff.as_json` | *(none — see the seams)* |
17
+ | `LogPayload` | `api.log` -> git-backed history rows | *(none — see the seams)* |
18
+
19
+ `CheckPayload` and `PlanPayload` are one shape, because they are one function:
20
+ they share `_VerdictDocument` and differ only in the `artifact_type` literal
21
+ that discriminates them. Two independent copies of those fields would be the
22
+ second implementation this file exists to prevent.
23
+
24
+ The findings and outcomes inside a member are **not re-modelled**: they are
25
+ `report.Finding` and `plan.AssertionOutcome`, imported. Both already carry
26
+ `extra="forbid"`.
27
+
28
+ `extra="forbid"` is on every model here. That is the drift test's teeth: the
29
+ day a producer grows a key this file does not know about,
30
+ `model_validate(...)` fails here rather than the app, the sidecar or the
31
+ generated TypeScript silently reading whatever happens to be there. It has
32
+ already earned its keep once — `gui.build_payload` grew `health_words` after
33
+ the first version of this file was written, and the test went red.
34
+
35
+ Number kinds — every number carries its kind
36
+ --------------------------------------------
37
+ The rule, verbatim: *"Every number on screen is declared, measured, or derived by
38
+ a named check; a catalog or projected figure is shown only with its own visual
39
+ mark and word (\"catalog\", \"projected\"), never in the same style as a
40
+ measured one."*
41
+
42
+ `NUMBER_KINDS` below is that table: one row per numeric field reachable from
43
+ any union member, keyed `Model.field`. It is a **declaration**, not a second
44
+ computation — a numeric field's kind is a property of which producer filled it
45
+ in, and this file is where the union says so.
46
+ `test_every_number_in_the_union_carries_its_kind` walks every member, finds
47
+ every numeric field, and fails on any the table does not cover; `export_openapi`
48
+ ships the table inside the schema document so the app and the TypeScript
49
+ emitter render the mark rather than re-deriving it.
50
+
51
+ Open seams — named, not silently picked (this file is a freeze point)
52
+ ---------------------------------------------------------------------
53
+ 1. **The page member has no discriminator.** `gui.build_payload` predates
54
+ `report_json.envelope` (E0-25) and emits no `artifact_type`, so
55
+ `MEMBERS` (keyed by `artifact_type`) discriminates the enveloped members and `PagePayload`
56
+ is validated by name beside them. Giving the page dict an envelope is a
57
+ `gui.py` change and `gui.py` is the GUI lane's (e1-b3/b4/b5), not this box's.
58
+ 2. **`scan` has no member and cannot have one yet.** The brief asks for one;
59
+ measured, `cli.scan` dumps YAML straight to stdout (`cli.py:1167`) and has
60
+ no `api` twin and no document producer. A member here would mean *writing*
61
+ that producer, which is the re-implementation this module forbids. It needs an
62
+ `api.scan` first.
63
+ 3. **MCP's three tool-only shapes are not members.** `describe_device`,
64
+ `describe_system` and `list_devices` (`mcp_server.py`) return bare dicts
65
+ with no envelope and no `api` twin. They are transport shapes, not
66
+ documents; when they route through `api`, they join the union.
67
+ 4. **`catalog` has no producer today; `projected` does, and it is marked.**
68
+ Measured: `uv run python -c "from lockwire import api; ..."` against
69
+ `tests/fixtures/x500/contract.yaml` — `api.check` returns two
70
+ `kind='projected'` alternatives (`advice.build_rail_alternatives`, reached
71
+ through `checks.check_budgets`), both carrying `mark='projected'`.
72
+ `advice.Alternative` is self-kinded (`SELF_KINDED`, in tests/payload_sweep.py), so this file's
73
+ `_build_kind_declaration`/`_assert_marked_kind_has_mark` never sees it; the enforcement lives on
74
+ `Alternative` itself, next to `Figure`'s copy of the same guard (a projected figure reaching a door
75
+ carries its mark and its word, not a corrected sentence saying it is
76
+ unenforced). `catalog` genuinely has no producer yet; this file does not
77
+ invent the catalog (that is `e1-cat`/O35).
78
+ **The standing rule this seam earns: any `Measured:` claim in shipped
79
+ source names the command that measured it.** A measurement claim with no
80
+ command behind it is the same defect class as a test that asserts a
81
+ document against itself.
82
+ 5. **O33 — the accessibility strings are NOT in this union.** Plan v8 line 1259
83
+ records no default for whether screen-reader words live in the payload, and
84
+ inventing their shape at the freeze point is worse than leaving the seam
85
+ open. `EXTENSION_POINTS` names the empty slot; nothing is modelled.
86
+ 6. **No `STOP` vocabulary.** The verdict words here are exactly
87
+ `report.verdict_word`'s (`GO`, `GO_WITH_NOTES`, `NO-GO`, `NOT_SCORED`,
88
+ `REFUSED`). `PagePayload.state`'s `"stop"` is `gui._headline`'s existing
89
+ CSS-state key, not a word on screen; this file does not add one.
90
+ 7. **`cli_line` is optional on every machine member** because
91
+ `mcp_server._with_cli_line` adds it to `api`'s document in place. Without
92
+ that field `extra="forbid"` would reject the MCP door's own output, which is
93
+ the same document — so the union covers both doors, and
94
+ `test_mcp_documents_validate_as_the_same_union_members` holds it.
95
+ 8. **`diff` has no envelope either.** `api.diff` (e0-34) returns
96
+ `FactDiff.as_json()` straight, with no `artifact_type`, so `DiffPayload` is
97
+ keyed by name beside the page in `UNENVELOPED`. Enveloping it is a change to
98
+ `factdiff`/`api.diff`, which is e0-63's lane (*"the library leg of diff is
99
+ anchored by nothing"*), not this file's.
100
+ 9. **`log` is a top-level list.** `api.log` returns history rows directly, so
101
+ `LogPayload` is a named root-list member beside `diff` in `UNENVELOPED`.
102
+ The caller names the member because a list has no discriminator field.
103
+ 10. **Not every preformatted number could become a `Figure`.** Measured:
104
+ `scripts/pytest_slot.sh uv run pytest -q tests/test_api_payload.py -k
105
+ test_no_door_emits_a_preformatted_number_outside_the_named_exemptions`
106
+ (2026-09-08, `1 passed`) — the figures this door owns and holds typed
107
+ data for are typed (`SceneLabel`, `DesignFacePart.draw`). The rest are
108
+ assembled with words inside `checks.py`, `report.py` and `report_html.py` —
109
+ typing them means rewriting a producer, which is the re-implementation this
110
+ module forbids here. Each is named in `STRING_FIGURE_EXEMPTIONS` with its reason
111
+ rather than left silent, and the exemption list is pinned both ways.
112
+ """
113
+
114
+ from __future__ import annotations
115
+
116
+ import json
117
+ from pathlib import Path
118
+ from typing import Annotated, Any, Literal
119
+
120
+ from pydantic import BaseModel, ConfigDict, Field, RootModel, model_validator
121
+
122
+ from lockwire import plugins
123
+ from lockwire.advice import Alternative
124
+ from lockwire.plan import AssertionOutcome
125
+ from lockwire.plan_payload import (
126
+ Instead,
127
+ PlanPayload,
128
+ Target,
129
+ VerdictWord,
130
+ _Document,
131
+ _Strict,
132
+ _VerdictDocument,
133
+ )
134
+ from lockwire.report import ScoredRecord
135
+ from lockwire.report_json import (
136
+ CHECK_ARTIFACT_TYPE,
137
+ PLAN_ARTIFACT_TYPE,
138
+ )
139
+ from lockwire.schema import Provenance
140
+
141
+ DESCRIBE_ARTIFACT_TYPE = "lockwire-describe"
142
+ EDIT_ARTIFACT_TYPE = "lockwire-edit-result"
143
+
144
+
145
+ # --------------------------------------------------------------------- number kinds
146
+
147
+
148
+ #: the five kinds. ``derived`` names its check in the table's second column.
149
+ NumberKind = Literal["declared", "measured", "derived", "catalog", "projected"]
150
+
151
+ #: The two kinds the number-kind rule says may never be rendered in the measured style. A row
152
+ #: with one of these must carry the word to render beside the figure.
153
+ MARKED_KINDS = ("catalog", "projected")
154
+
155
+
156
+ def _assert_marked_kind_has_mark(kind: str, mark: str | None) -> None:
157
+ """Asserts marked kinds carry their required word. This checks that catalog
158
+ and projected figures declare their mark; it does NOT check that the kind is
159
+ true of the value, because NUMBER_KINDS is keyed on field name and cannot
160
+ distinguish which producer filled it when multiple producers write the same
161
+ field. One function for both the static table (``_build_kind_declaration``)
162
+ and runtime records (``Figure``) prevents drift between two implementations
163
+ of the number-kind rule."""
164
+ if kind in MARKED_KINDS and not mark:
165
+ raise ValueError(f"a {kind!r} number must carry its own word, e.g. mark={kind!r}")
166
+
167
+
168
+ def _build_kind_declaration(kind: NumberKind, by: str, mark: str | None = None) -> dict[str, str]:
169
+ """Builds one ``NUMBER_KINDS`` row, asserting marked kinds carry required marks.
170
+
171
+ This declares that a numeric field has a kind from the allowed vocabulary
172
+ (declared, measured, derived, catalog, projected) and that marked kinds carry
173
+ their required word. It does NOT validate that the kind is true of the value —
174
+ the table is keyed on field name and cannot see which producer filled it.
175
+ When multiple producers write the same field, this table asserts only the
176
+ first producer's kind; resolving which producer is correct requires runtime
177
+ context the table cannot have.
178
+
179
+ The guard is here rather than in a test because this file is what six other
180
+ boxes add rows to: a box that adds the product's first ``catalog`` figure
181
+ without the word gets an ImportError, not a review comment.
182
+ """
183
+ _assert_marked_kind_has_mark(kind, mark)
184
+ row = {"kind": kind, "by": by}
185
+ if mark:
186
+ row["mark"] = mark
187
+ return row
188
+
189
+
190
+ class Figure(_Strict):
191
+ """**One number on screen, typed** — the shape every figure a door renders
192
+ as a number takes, so the number kind is a property of the value rather than of a
193
+ lookup table someone forgot to extend.
194
+
195
+ ``NUMBER_KINDS`` (below) covers numeric-*typed* fields. It cannot cover a
196
+ number a producer has already formatted into a ``str``: measured on the
197
+ x500 fixture, ``SceneLabel.figure`` read ``"35.20 W vs <= 15.60 W"`` — two
198
+ numbers on screen, the exact words, carrying no kind, with nothing to
199
+ notice. *A number stopped being a number, for that guard, the moment
200
+ someone formatted it.* **The number kind is a claim about
201
+ numbers on screen, so a preformatted string figure defeats it.**
202
+
203
+ A ``Figure`` carries its own kind, so a field typed as one needs no
204
+ ``NUMBER_KINDS`` row — ``numeric_fields()`` skips it deliberately.
205
+ **The renderer formats it** (``value:.2f`` + unit, ``report.format_magnitude``'s
206
+ one rule, one line of JS in ``gui``/``scene_js``); no producer here emits a
207
+ preformatted numeric string, and
208
+ ``test_no_door_emits_a_preformatted_number_outside_the_named_exemptions``
209
+ holds that against every document the doors actually emit.
210
+ """
211
+
212
+ value: float
213
+ unit: str
214
+ kind: NumberKind
215
+ #: The producer that filled it in — ``derived``'s "named check" in the number-kind rule.
216
+ by: str
217
+ #: the word for a ``catalog``/``projected`` figure. Required for those.
218
+ mark: str | None = None
219
+
220
+ @model_validator(mode="after")
221
+ def _carries_its_word(self) -> Figure:
222
+ _assert_marked_kind_has_mark(self.kind, self.mark)
223
+ return self
224
+
225
+
226
+ #: ``Model.field`` -> its number-kind and the producer that fills it in. Every
227
+ #: numeric field reachable from a union member has a row; the walker below
228
+ #: enumerates them from the models, so this table cannot quietly fall behind.
229
+ #:
230
+ #: Foreign models (``report.Finding``, ``plan.AssertionOutcome``,
231
+ #: ``schema.Quantity``) get their rows here too — their fields live in
232
+ #: ``report.py``/``plan.py``/``schema.py``, which this box may not touch
233
+ #: (``schema.py`` is frozen), so the union declares their kinds rather
234
+ #: than annotating them at the source. When those modules carry kinds
235
+ #: themselves, these rows move there.
236
+ NUMBER_KINDS: dict[str, dict[str, str]] = {
237
+ # --- the verdict documents: every count is report.verdict's arithmetic
238
+ "_VerdictDocument.exit_code": _build_kind_declaration("derived", "report.verdict"),
239
+ "_VerdictDocument.scored": _build_kind_declaration("derived", "report.verdict"),
240
+ "Counts.violation": _build_kind_declaration("derived", "report.verdict"),
241
+ "Counts.warning": _build_kind_declaration("derived", "report.verdict"),
242
+ "Counts.not_scored": _build_kind_declaration("derived", "report.verdict"),
243
+ # --- one finding's numbers
244
+ "Finding.stage": _build_kind_declaration("declared", "schema.Contract (contract section 3.2)"),
245
+ "Quantity.value": _build_kind_declaration("declared", "schema.Contract"),
246
+ # --- the robot view: model frames composed up the parent chain; limits as declared
247
+ "RobotJoint.position": _build_kind_declaration("derived", "gui._robot_model"),
248
+ "RobotJoint.axis": _build_kind_declaration("derived", "gui._robot_model"),
249
+ "RobotJoint.lower": _build_kind_declaration("declared", "schema.Joint"),
250
+ "RobotJoint.upper": _build_kind_declaration("declared", "schema.Joint"),
251
+ "RobotJoint.effort": _build_kind_declaration("declared", "schema.Joint"),
252
+ "RobotLink.position": _build_kind_declaration("derived", "gui._robot_model"),
253
+ # --- the page dict
254
+ "PagePayload.scored": _build_kind_declaration("derived", "checks.run_record_checks"),
255
+ "PagePayload.violations": _build_kind_declaration("derived", "report.verdict"),
256
+ "PagePayload.warnings": _build_kind_declaration("derived", "report.verdict"),
257
+ "PagePayload.unchecked": _build_kind_declaration("derived", "report.verdict"),
258
+ "Card.count": _build_kind_declaration("derived", "gui._cards"),
259
+ "BudgetRow.value": _build_kind_declaration("derived", "gui._budget_rows"),
260
+ "BudgetRow.limit": _build_kind_declaration("declared", "schema.Contract"),
261
+ "Constraint.used": _build_kind_declaration("derived", "constraints.constraint_rows"),
262
+ "Constraint.limit": _build_kind_declaration("declared", "constraints.constraint_rows"),
263
+ "Constraint.margin_pct": _build_kind_declaration("derived", "constraints.constraint_rows"),
264
+ # --- the 3D scene: measured off the model file, not off the contract
265
+ "PackedVectors.scale": _build_kind_declaration("measured", "scene.load_scene"),
266
+ "PackedVectors.offset": _build_kind_declaration("measured", "scene.load_scene"),
267
+ "ScenePart.color": _build_kind_declaration("derived", "gui._scene_payload"),
268
+ "ScenePart.translation": _build_kind_declaration("measured", "scene.load_scene"),
269
+ "ScenePayload.bounds": _build_kind_declaration("measured", "scene.load_scene"),
270
+ "ScenePayload.triangles": _build_kind_declaration("measured", "scene.load_scene"),
271
+ "ScenePayload.vertices": _build_kind_declaration("measured", "scene.load_scene"),
272
+ "ScenePayload.display_triangles": _build_kind_declaration("measured", "scene.load_scene"),
273
+ # --- the declare form (E4-df): every row is, by definition, a fact the
274
+ # contract itself would hold — the own example row.
275
+ "DeclareRow.value": _build_kind_declaration("declared", "schema.Contract"),
276
+ # `Provenance.conditions` reaches the union for the first time through
277
+ # `DeclareRow.provenance` — an env channel reading recorded at lock time,
278
+ # not typed anywhere until now.
279
+ "Provenance.conditions": _build_kind_declaration(
280
+ "measured", "schema.Provenance (env channel at lock time)"
281
+ ),
282
+ "ForceFigure.value": _build_kind_declaration("derived", "forces.force_document"),
283
+ "ForceFigure.reaction_n": _build_kind_declaration("derived", "forces.force_document"),
284
+ "ForceFigure.moment_nm": _build_kind_declaration("derived", "forces.force_document"),
285
+ }
286
+
287
+ #: Named, empty. O33 (screen-reader words) has no decided wording and no default;
288
+ #: the slot is named so six downstream boxes do not each invent one.
289
+ EXTENSION_POINTS: tuple[str, ...] = ("accessibility_strings",)
290
+
291
+
292
+ # ---------------------------------------------------------------- envelope
293
+
294
+
295
+ class WhatIfFact(_Strict):
296
+ quantity: str
297
+ kind: Literal["finding", "passed_comparison"]
298
+ severity: str | None
299
+ source: str | None
300
+ witness: str | None
301
+ provenance_state: Literal["recorded", "missing_evidence"]
302
+
303
+
304
+ class RecordComparison(_VerdictDocument):
305
+ artifact_type: Literal["lockwire-record-comparison"]
306
+ facts: list[WhatIfFact]
307
+ comparisons: list[ScoredRecord]
308
+
309
+
310
+ class WhatIfPayload(_Document):
311
+ artifact_type: Literal["lockwire-what-if-report"]
312
+ current: RecordComparison
313
+ proposed: RecordComparison
314
+ diff: str
315
+
316
+
317
+ class ForceFigure(_Strict):
318
+ """One self-described number from ``forces.force_document``."""
319
+
320
+ value: float | list[float] | None
321
+ unit: str
322
+ kind: str
323
+ not_scored_because: str | None = None
324
+ id: str | None = None
325
+ quantity: str | None = None
326
+ complete: bool | None = None
327
+ limitation: str | None = None
328
+ frame: str | None = None
329
+ note: str | None = None
330
+ reaction_n: float | None = None
331
+ moment_nm: float | None = None
332
+ span_axis: str | None = None
333
+
334
+
335
+ class ForceMountShape(_Strict):
336
+ """One mount shape read from ``Physical`` by the force document."""
337
+
338
+ id: str
339
+ kind: Literal["plate", "bracket", "tube", "custom-mesh"]
340
+ dimensions: ForceFigure
341
+ fastener_pattern: str | None
342
+ mesh_path: str | None
343
+
344
+
345
+ class ForceDocument(_Strict):
346
+ """The force lane carried by the check document."""
347
+
348
+ gravity: ForceFigure
349
+ mass_total: ForceFigure
350
+ cg: ForceFigure
351
+ masses: list[ForceFigure]
352
+ mounts: list[ForceFigure]
353
+ mount_shapes: list[ForceMountShape]
354
+ not_scored: list[ForceFigure]
355
+
356
+
357
+ class CheckPayload(_VerdictDocument):
358
+ """``api.check`` / ``lockwire check --json``.
359
+
360
+ ``scene`` is a sidecar-only addition (e1-b2, not a CLI field — T2 keeps
361
+ ``lockwire check`` scene-free by design, ``cli.py:454``): present only on
362
+ the ``/v1/run`` reply, and only when a scene sits beside the contract.
363
+ ``None`` when there is no scene to offer; ``SceneUnchanged`` when the
364
+ caller's ``scene.have`` already matches the current hash.
365
+
366
+ ``headline`` (E2-hl) is the plain-words sentence for the verdict --
367
+ ``api.check``'s own field, not shared with ``PlanPayload``
368
+ (its door does not fill it), computed once by ``report.verdict_headline``
369
+ and reused, never re-derived by a consumer.
370
+
371
+ ``graph`` rides beside ``scene`` for the same reason and on the same
372
+ condition (E2-j2): the app's Design tab needs the lens bar's words
373
+ (``GraphLens.label``/``.caption``) next to the scene it labels, and
374
+ ``report_html._graph`` already computes exactly this dict for the page
375
+ payload (``gui.py:1019``) — this is the same call, not a second one.
376
+ ``None`` when there is no contract to derive it from (a refusal never
377
+ reaches this far) or no scene sits beside the contract, matching
378
+ ``scene``'s own condition so the two never disagree about whether a
379
+ Design tab has anything to show.
380
+ """
381
+
382
+ artifact_type: Literal["lockwire-check-report"]
383
+ scene: ScenePayload | SceneUnchanged | None = None
384
+ headline: Annotated[str, Field(min_length=1, pattern=r"\S")]
385
+ graph: Graph | None = None
386
+ forces: ForceDocument
387
+ revision: str | None = None
388
+
389
+
390
+ class DescribePayload(_Document):
391
+ """``api.describe`` — filename -> Markdown. The member that carries prose,
392
+ not the page dict: ``files`` is ``describe.describe_all``'s output."""
393
+
394
+ artifact_type: Literal["lockwire-describe"]
395
+ robot: str
396
+ files: dict[str, str]
397
+
398
+
399
+ class EditPayload(_Document):
400
+ """``api.declare`` — the write door's document. This is the member that
401
+ carries a diff; ``written`` is False under ``--dry-run``.
402
+
403
+ ``contract_sha`` is ``None`` with ``contract_sha_error`` set (E5-D3, same
404
+ shape as e5-d-r2's fix on ``api.add``) when the write landed but the
405
+ post-write disk re-read that names its sha then faulted — ``written``
406
+ still reports what the writer did, never the re-read's outcome."""
407
+
408
+ artifact_type: Literal["lockwire-edit-result"]
409
+ contract_sha: str | None
410
+ revision: str | None = None
411
+ contract_sha_error: str | None = None
412
+ written: bool
413
+ diff: str
414
+ lines: list[str]
415
+
416
+
417
+ # --------------------------------------------------------------------------- cards
418
+
419
+
420
+ class RobotJoint(_Strict):
421
+ """A model joint with its frame in the single robot frame (parent chain composed)."""
422
+
423
+ name: str
424
+ type: Literal["fixed", "revolute", "continuous", "prismatic", "floating", "planar"]
425
+ parent: str
426
+ child: str
427
+ position: list[float] # metres, robot frame
428
+ axis: list[float] # joint axis in the robot frame
429
+ lower: float | None
430
+ upper: float | None
431
+ effort: float | None
432
+
433
+
434
+ class RobotLink(_Strict):
435
+ name: str
436
+ position: list[float] # link origin, metres, robot frame
437
+
438
+
439
+ class Card(_Strict):
440
+ severity: Literal["violation", "warning", "not_scored"]
441
+ tag: str
442
+ title: str
443
+ body: str
444
+ fix: str
445
+ raw: str
446
+ hits: list[str]
447
+ faults: list[str]
448
+ affected: list[str]
449
+ targets: list[Target]
450
+ whole: bool = False
451
+ count: int
452
+ count_figure: Figure
453
+ observed: Figure | None
454
+ expected: Figure | None
455
+ alternatives: list[Alternative]
456
+ figure_note: str | None
457
+ shape: str
458
+ #: 1-3 word name of the check (gui.finding_label), for grouped rows.
459
+ label: str
460
+ #: Row title for N repeats of this card, "{n}" standing for the count ("{n} drives unmapped").
461
+ group_title: str
462
+ #: The alternatives as short rows (the full ones stay in ``alternatives``).
463
+ instead: list[Instead]
464
+ also_in_run: bool
465
+ consumer: list[str] | None = None
466
+
467
+
468
+ class AddsUpRow(_Strict):
469
+ name: str
470
+ text: str
471
+ margin: str
472
+ raw: str
473
+ # The standing mark when this pass is a budget cleared by
474
+ # raising its limit — the only card a raised-then-passing budget reaches.
475
+ mark: str | None
476
+
477
+
478
+ class SetupGap(_Strict):
479
+ title: str
480
+ why: str
481
+ snippet: str
482
+
483
+
484
+ class BudgetRow(_Strict):
485
+ id: str
486
+ kind: str
487
+ value: float | None
488
+ limit: float
489
+ unit: str
490
+ direction: str
491
+ state: str
492
+ note: str | None
493
+ # E1-f2, landed since this model's first version: a limit carries the same
494
+ # provenance trio the findings do, read off `budget.provenance`.
495
+ source: str | None
496
+ witness: str | None
497
+ locked_at: str | None
498
+ # The standing mark when this limit was raised to clear a
499
+ # violation. Rendered, never scored — no verdict or exit code moves.
500
+ limit_raised: str | None
501
+
502
+
503
+ class Constraint(_Strict):
504
+ """one constraint instance the engine evaluates, for badges and margin bars
505
+ (``constraints.constraint_rows``). ``domain`` maps the graph lenses: mechanical ->
506
+ mechanical, power -> electrical, data -> data, health -> health, plus thermal (no lens).
507
+ ``used``/``limit`` are null when the engine has no number (status "unset"); each carries
508
+ its number-kind in ``used_kind``/``limit_kind`` (per row; NUMBER_KINDS is per field)."""
509
+
510
+ id: str
511
+ label: str
512
+ domain: Literal["mechanical", "electrical", "data", "thermal", "health"]
513
+ used: float | None
514
+ limit: float | None
515
+ unit: str
516
+ margin_pct: float | None
517
+ status: Literal["ok", "warn", "bad", "unset"]
518
+ used_kind: NumberKind | None
519
+ limit_kind: NumberKind | None
520
+ #: Scene part ids (contract ids that have geometry) / every contract id involved.
521
+ parts: list[str]
522
+ components: list[str]
523
+
524
+
525
+ # --------------------------------------------------------------------------- graph
526
+
527
+
528
+ class GraphNode(_Strict):
529
+ id: str
530
+ label: str
531
+ sub: str
532
+ kind: Literal["link", "device", "rail", "source", "host", "bus"]
533
+
534
+
535
+ class GraphEdge(_Strict):
536
+ from_: str = Field(alias="from")
537
+ to: str
538
+ label: str
539
+
540
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
541
+
542
+
543
+ #: The full lens namespace (E1-ln) — every place a lens is drawn, colored or
544
+ #: joined against a scene part uses this set. the words that go with
545
+ #: each member (``GraphLens.label``/``.caption``) are the payload's, never
546
+ #: composed in the app/GUI JS.
547
+ LensId = Literal["off", "mechanical", "power", "data", "health"]
548
+
549
+
550
+ class GraphLens(_Strict):
551
+ nodes: list[GraphNode]
552
+ edges: list[GraphEdge]
553
+ #: Button text ("Power") — payload-owned so the face never derives it
554
+ #: from the key.
555
+ label: str
556
+ #: The one-line hint under the lens bar ("What feeds what, and how many
557
+ #: watts down each wire").
558
+ caption: str
559
+
560
+
561
+ class Graph(_Strict):
562
+ off: GraphLens
563
+ mechanical: GraphLens
564
+ power: GraphLens
565
+ data: GraphLens
566
+ health: GraphLens
567
+
568
+
569
+ # --------------------------------------------------------------------------- scene
570
+ # scene.py/scene_view.py pack positions/normals/indices as base64 dicts — modelled
571
+ # as they are, not decoded.
572
+
573
+
574
+ class PackedVectors(_Strict):
575
+ type: Literal["i16"]
576
+ data: str
577
+ scale: float | None = None
578
+ offset: list[float] | None = None
579
+
580
+
581
+ class PackedIndices(_Strict):
582
+ type: Literal["u16", "u32"]
583
+ data: str
584
+
585
+
586
+ class SceneGroup(_Strict):
587
+ """the component drop-down a part is listed under (id, 1-3 word label, kind)."""
588
+
589
+ id: str
590
+ label: str
591
+ kind: Literal["device", "rail", "bus", "link", "other"]
592
+
593
+
594
+ class ScenePart(_Strict):
595
+ id: str | None
596
+ node: str
597
+ #: Plain name for the row (copy.human_name of node); node stays the tooltip.
598
+ name: str
599
+ state: Literal["fault", "warn", "affected", "ok", "unchecked", "plain"]
600
+ positions: PackedVectors
601
+ normals: PackedVectors | None
602
+ indices: PackedIndices
603
+ color: list[float]
604
+ translation: list[float]
605
+ #: Every lens this part belongs to (E1-ln) — computed from the
606
+ #: contract join (`scene_view.part_lenses`), never guessed by the face.
607
+ #: `off` and `health` are whole-scene overlays so every part carries
608
+ #: them; `mechanical`/`power`/`data` are earned by the part's contract
609
+ #: role, same join `report_html._graph` uses for the wiring diagram.
610
+ lenses: list[LensId]
611
+ group: SceneGroup
612
+
613
+
614
+ class SceneLabel(_Strict):
615
+ """The pinned label the 3D scene draws on a faulty part.
616
+
617
+ ``figure: str`` until 2026-09-08 — ``"35.20 W vs <= 15.60 W"``, the very
618
+ string that showed a formatted number defeats ``NUMBER_KINDS``. Now the two
619
+ numbers are typed and the callout in ``scene_js`` formats them.
620
+ Numeric findings carry typed magnitudes. Structural findings can carry
621
+ text operands without numeric quantities, so either figure remains
622
+ ``| None``; the renderer omits an absent figure instead of inventing one.
623
+ """
624
+
625
+ part: str
626
+ severity: str
627
+ title: str
628
+ observed: Figure | None
629
+ #: How ``expected`` bounds ``observed`` — ``"<="``, ``">="``, or ``""``.
630
+ #: A comparator is a symbol, not a number, so it stays a string.
631
+ comparator: str
632
+ expected: Figure | None
633
+
634
+
635
+ class ScenePayload(_Strict):
636
+ asset_path: str | None = None
637
+ asset_sha256: str | None = None
638
+ expected_asset_sha256: str | None = None
639
+ source: str
640
+ #: sha256 of the source geometry (``scene_view.scene_hash``) — same scene,
641
+ #: same hash, regardless of display decimation. What a caller's next
642
+ #: ``scene.have`` is compared against (e1-b2).
643
+ hash: str
644
+ parts: list[ScenePart]
645
+ labels: list[SceneLabel]
646
+ scene_only: list[str]
647
+ contract_only: list[str]
648
+ bounds: list[list[float]]
649
+ triangles: int
650
+ vertices: int
651
+ display_triangles: int
652
+ honesty: str
653
+
654
+
655
+ class SceneUnchanged(_Strict):
656
+ """The sidecar's reply when a caller's ``scene.have`` already matches —
657
+ ships no geometry (e1-b2). ``unchanged`` is always ``True``; the shape
658
+ exists so ``{"unchanged": true}`` is a typed member, not a bare dict."""
659
+
660
+ unchanged: Literal[True]
661
+ asset_path: str | None = None
662
+ asset_sha256: str | None = None
663
+ expected_asset_sha256: str | None = None
664
+
665
+
666
+ # --------------------------------------------------------------------------- faces
667
+
668
+
669
+ class DesignFacePart(_Strict):
670
+ id: str
671
+ rail: str
672
+ #: ``"0.40 W"`` until 2026-09-08; a declared draw is a number on screen, so
673
+ #: it is typed. ``None`` when the device declares no power at all — the
674
+ #: renderer draws the em dash it used to be handed.
675
+ draw: Figure | None
676
+
677
+
678
+ class DesignFace(_Strict):
679
+ title: str
680
+ parts: list[DesignFacePart]
681
+ cli: str
682
+
683
+
684
+ class DeclareFace(_Strict):
685
+ title: str
686
+ lines: list[str]
687
+
688
+
689
+ class PlanFace(_Strict):
690
+ title: str
691
+ cli: str
692
+ # `gui._plan_face` emits `{"path", "status"}` per outcome, which is
693
+ # `plan.AssertionOutcome` field for field — the same model the plan
694
+ # document uses, not a second copy of it.
695
+ outcomes: list[AssertionOutcome] | None
696
+
697
+
698
+ class ReferenceFaceDevice(_Strict):
699
+ id: str
700
+ text: str
701
+
702
+
703
+ class ReferenceDrawer(_Strict):
704
+ """One of the Reference face's four drawers — a thing the
705
+ CLI already produces (``describe``/``report``/``baseline``/``scan``),
706
+ named with its content, its provenance and the exact CLI line that
707
+ reproduces it in a terminal. Nothing here is computed twice: ``content``
708
+ is always a producer's own output (``describe_all`` for the describe
709
+ drawer; a fixed description for the other three, which need inputs the
710
+ page does not have — a recording, this machine)."""
711
+
712
+ title: str
713
+ content: str
714
+ provenance: str
715
+ cli_line: str
716
+
717
+
718
+ class ReferenceFace(_Strict):
719
+ title: str
720
+ devices: list[ReferenceFaceDevice]
721
+ drawers: list[ReferenceDrawer]
722
+ #: The rail roll-up rendered as a power tree (source ->
723
+ #: converter -> rail -> loads), plain pre-formatted text — not a number-kind
724
+ #: number, the same treatment `ReferenceDrawer.cli_line` above gets.
725
+ #: `None` when the contract has no rails to draw (`describe.render_power_tree`
726
+ #: returns `[]`).
727
+ power_tree: str | None
728
+
729
+
730
+ class Faces(_Strict):
731
+ design: DesignFace
732
+ declare: DeclareFace
733
+ plan: PlanFace
734
+ reference: ReferenceFace
735
+
736
+
737
+ # --------------------------------------------------------------------- declare form
738
+
739
+
740
+ class DeclareRow(_Strict):
741
+ """One quantity ``lockwire declare`` could write, present or not (E4-df).
742
+
743
+ Walked from ``schema.Contract``'s own models (nothing here re-derives
744
+ a value or invents a path) — ``path`` is the exact ``/``-separated string
745
+ ``declare``'s own ``path`` argument takes. ``kind`` is always ``"declared"``:
746
+ every one of these is, by definition, a fact the contract itself would
747
+ hold (the own example row is ``Quantity.value`` -> ``declared``). Scoped
748
+ to ``schema.Quantity``-typed fields only — a bare unitless number
749
+ (``efficiency``, ``gear_ratio``) is not a "quantity" in the sense and is
750
+ out of this box's charter.
751
+
752
+ ``value``/``unit`` are ``None`` when the fact has never been declared;
753
+ ``provenance`` is the nearest ancestor block's own ``provenance`` (schema's
754
+ per-block provenance, same rule ``edit.provenance_owner_path`` already
755
+ encodes), ``None`` when nothing has stamped one yet.
756
+ """
757
+
758
+ path: str
759
+ label: str
760
+ #: The part/device the quantity belongs to ("Base") and what it is ("mass"):
761
+ #: the short pair the Declare list shows, so the app never trims `label`.
762
+ group: str
763
+ short: str
764
+ unit: str | None
765
+ kind: NumberKind
766
+ value: float | list[float] | None
767
+ provenance: Provenance | None
768
+ #: The drawn part to light when the row's own link has no geometry (inertial-only).
769
+ part: str | None = None
770
+
771
+
772
+ class DeclareWitnessBox(_Strict):
773
+ """The declare form's witness field, pre-filled exactly as a submit with no
774
+ ``--witness`` would resolve it (``edit.resolve_witness``) — a config
775
+ default arrives already marked ``"(default)"``; no default is `""`, which
776
+ still refuses on submit exactly like the CLI (O8's refusal side)."""
777
+
778
+ prefill: str
779
+
780
+
781
+ class DeclareFormPayload(_Strict):
782
+ """``gui.build_declare_form`` — every declarable quantity plus the witness
783
+ box (E4-df). Unenveloped like the page (seam 1): this box's face render is
784
+ the app's own, not this door's, so there is no ``artifact_type`` to
785
+ discriminate on yet."""
786
+
787
+ rows: list[DeclareRow]
788
+ witness: DeclareWitnessBox
789
+ revision: str | None = None
790
+
791
+
792
+ # --------------------------------------------------------------------- the page
793
+
794
+
795
+ class StartPayload(_Strict):
796
+ path: str
797
+ revision: str
798
+ written: bool
799
+ note: str | None = None
800
+
801
+
802
+ class PagePayload(_Strict):
803
+ """``gui.build_payload``'s dict, field for field — the union's largest
804
+ member, and the only one with no envelope (seam 1 in the module docstring).
805
+ """
806
+
807
+ robot: str
808
+ revision: str | None = None
809
+ contract_id: str
810
+ #: `gui._headline`'s CSS state key, not a word on screen. The word STOP is
811
+ #: reserved for a future change; nothing here spells it.
812
+ state: Literal["stop", "warn", "neutral", "go"]
813
+ headline: str
814
+ qualifier: str
815
+ scored: int | None
816
+ violations: int
817
+ warnings: int
818
+ unchecked: int
819
+ adds_up: list[AddsUpRow]
820
+ cards: list[Card]
821
+ joints: list[RobotJoint]
822
+ links: list[RobotLink]
823
+ graph: Graph
824
+ budgets: list[BudgetRow]
825
+ constraints: list[Constraint]
826
+ setup: list[SetupGap]
827
+ scene: ScenePayload | None
828
+ modes: list[str]
829
+ mode_labels: dict[str, str]
830
+ command_modes: dict[str, str]
831
+ faces: Faces
832
+ cli_line: str | None
833
+ #: E0-30/`report_html.document_identity` — contract path, commit/hash,
834
+ #: lockwire version, generated timestamp, the same footer `report` renders.
835
+ document_identity: str
836
+
837
+
838
+ # ---------------------------------------------------------------------- diff
839
+ # `api.diff` (e0-34, merged after this file's first version) returns
840
+ # `factdiff.FactDiff.as_json()` — a document with no `report_json.envelope`, so
841
+ # like the page it is keyed by name rather than discriminated. Seam 8 below.
842
+
843
+
844
+ class FieldChangeRow(_Strict):
845
+ path: str
846
+ #: A declared value of whatever type the contract field holds — a float, a
847
+ #: string, a list. `None` when the field was not declared on that side.
848
+ old: Any
849
+ new: Any
850
+ witness: str | None
851
+ source: str | None
852
+ limit_raised: bool
853
+
854
+
855
+ class EntityChangeRow(_Strict):
856
+ kind: str
857
+ id: str
858
+
859
+
860
+ class FindingRefRow(_Strict):
861
+ severity: str
862
+ quantity: str
863
+
864
+
865
+ class DiffPayload(_Strict):
866
+ """`api.diff` / `lockwire diff --json` — what changed in the facts."""
867
+
868
+ changed: bool
869
+ changes: list[FieldChangeRow]
870
+ added: list[EntityChangeRow]
871
+ removed: list[EntityChangeRow]
872
+ findings_new: list[FindingRefRow]
873
+ findings_gone: list[FindingRefRow]
874
+
875
+
876
+ class LogEntry(_Strict):
877
+ """One git-backed contract-history row from ``api.log``."""
878
+
879
+ sha: str
880
+ author: str
881
+ date: str
882
+ message: str
883
+ verdict: VerdictWord
884
+ fact_diff: DiffPayload | None
885
+
886
+
887
+ class LogPayload(RootModel[list[LogEntry]]):
888
+ """``api.log`` / ``lockwire log --json`` history, newest first."""
889
+
890
+
891
+ class BlameRow(_Strict):
892
+ """One unchanged declared fact row from the public blame producer."""
893
+
894
+ path: str
895
+ value: dict[str, Any]
896
+ source: str
897
+ witness: str
898
+ locked_at: str
899
+ compared_from: str | None = None
900
+ last_changing_commit: str | None = None
901
+
902
+
903
+ class BlamePayload(RootModel[list[BlameRow]]):
904
+ """``lockwire blame --json`` rows; api.blame retains its tuple ABI."""
905
+
906
+
907
+ # ------------------------------------------------------------------ the union
908
+
909
+ #: ``artifact_type`` -> the member that describes that document. The six
910
+ #: enveloped members; the page dict is keyed separately because it has none.
911
+ MEMBERS: dict[str, type[_Document]] = {
912
+ "lockwire-what-if-report": WhatIfPayload,
913
+ CHECK_ARTIFACT_TYPE: CheckPayload,
914
+ PLAN_ARTIFACT_TYPE: PlanPayload,
915
+ DESCRIBE_ARTIFACT_TYPE: DescribePayload,
916
+ EDIT_ARTIFACT_TYPE: EditPayload,
917
+ }
918
+
919
+ #: Every member, enveloped and un-enveloped, in one tuple — what the sweep and
920
+ #: the export walk.
921
+ ALL_MEMBERS: tuple[type[BaseModel], ...] = (
922
+ *MEMBERS.values(),
923
+ PagePayload,
924
+ DiffPayload,
925
+ LogPayload,
926
+ BlamePayload,
927
+ DeclareFormPayload,
928
+ StartPayload,
929
+ )
930
+
931
+
932
+ # --------------------------------------------------------------------- export
933
+
934
+
935
+ def openapi_document(
936
+ all_members: tuple[type[BaseModel], ...],
937
+ members: dict[str, type[_Document]],
938
+ number_kinds: dict[str, dict[str, str]],
939
+ ) -> dict[str, Any]:
940
+ schemas: dict[str, Any] = {}
941
+ for model in all_members:
942
+ schema = model.model_json_schema(ref_template="#/components/schemas/{model}")
943
+ schemas.update(schema.pop("$defs", {}))
944
+ schemas[model.__name__] = schema
945
+ return {
946
+ "openapi": "3.1.0",
947
+ "info": {"title": "lockwire", "version": "0.1.0"},
948
+ "paths": {},
949
+ "components": {"schemas": schemas},
950
+ "x-lockwire-members": {kind: model.__name__ for kind, model in members.items()},
951
+ "x-lockwire-number-kinds": number_kinds,
952
+ "x-lockwire-extension-points": list(EXTENSION_POINTS),
953
+ }
954
+
955
+
956
+ def _write_document(path: Path | str, document: dict[str, Any]) -> None:
957
+ Path(path).write_text(json.dumps(document, indent=2, sort_keys=True), encoding="utf-8")
958
+
959
+
960
+ def export_openapi(path: Path | str) -> None:
961
+ """Write the whole union as one OpenAPI-shaped schema document.
962
+
963
+ Not a served OpenAPI document — there are no paths here; it is the schema
964
+ map ``openapi-typescript`` reads (``packaging/generate-types.md``) to emit
965
+ ``app/src/shared/api.gen.ts`` (e1-d3). Every member is named, so a member
966
+ added without a discriminator entry cannot silently generate a missing TS
967
+ type, and ``x-lockwire-number-kinds`` ships the table so the app renders
968
+ a catalog or projected figure's mark rather than re-deriving it.
969
+
970
+ Members a plugin registers (``plugins.PAYLOAD_*``) go to a sibling
971
+ ``pro/<name>``; the free document never carries them.
972
+ """
973
+ _write_document(path, openapi_document(ALL_MEMBERS, MEMBERS, NUMBER_KINDS))
974
+ plugins.ensure()
975
+ if plugins.PAYLOAD_MEMBERS or plugins.PAYLOAD_EXTRA:
976
+ out = Path(path).parent / "pro" / Path(path).name
977
+ out.parent.mkdir(exist_ok=True)
978
+ _write_document(
979
+ out,
980
+ openapi_document(
981
+ (*plugins.PAYLOAD_MEMBERS.values(), *plugins.PAYLOAD_EXTRA),
982
+ plugins.PAYLOAD_MEMBERS,
983
+ plugins.PAYLOAD_NUMBER_KINDS,
984
+ ),
985
+ )