lockwire 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lockwire/__init__.py +9 -0
- lockwire/__main__.py +9 -0
- lockwire/advice.py +119 -0
- lockwire/api.py +1216 -0
- lockwire/api_payload.py +985 -0
- lockwire/bench.py +182 -0
- lockwire/blame.py +100 -0
- lockwire/catalog.py +100 -0
- lockwire/catalog_seed.yaml +545 -0
- lockwire/checks.py +3483 -0
- lockwire/cli.py +1799 -0
- lockwire/cli_line.py +73 -0
- lockwire/constraints.py +313 -0
- lockwire/copy.py +302 -0
- lockwire/describe.py +682 -0
- lockwire/edit.py +2098 -0
- lockwire/emit_ts.py +150 -0
- lockwire/export_mhs.py +115 -0
- lockwire/factdiff.py +254 -0
- lockwire/forces.py +334 -0
- lockwire/gitref.py +188 -0
- lockwire/hw.py +388 -0
- lockwire/mcp_server.py +276 -0
- lockwire/mechanism.py +379 -0
- lockwire/mesh_dae.py +555 -0
- lockwire/mesh_simple.py +198 -0
- lockwire/page.py +1485 -0
- lockwire/plan.py +849 -0
- lockwire/plan_payload.py +151 -0
- lockwire/plugins.py +70 -0
- lockwire/report.py +848 -0
- lockwire/report_html.py +707 -0
- lockwire/report_json.py +168 -0
- lockwire/review_draft.py +315 -0
- lockwire/scan.py +732 -0
- lockwire/scene.py +535 -0
- lockwire/scene_import.py +309 -0
- lockwire/scene_urdf.py +750 -0
- lockwire/scene_view.py +376 -0
- lockwire/schema.py +3616 -0
- lockwire/sdf.py +240 -0
- lockwire/serve.py +1256 -0
- lockwire/start_operation.py +92 -0
- lockwire/statics.py +77 -0
- lockwire/units.py +115 -0
- lockwire/urdf.py +348 -0
- lockwire/vcs.py +402 -0
- lockwire/whatif.py +161 -0
- lockwire-0.2.0.dist-info/METADATA +192 -0
- lockwire-0.2.0.dist-info/RECORD +52 -0
- lockwire-0.2.0.dist-info/WHEEL +4 -0
- lockwire-0.2.0.dist-info/entry_points.txt +3 -0
lockwire/api_payload.py
ADDED
|
@@ -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
|
+
)
|