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.py
ADDED
|
@@ -0,0 +1,1216 @@
|
|
|
1
|
+
"""The public library door: ``import lockwire.api``.
|
|
2
|
+
|
|
3
|
+
lockwire answers the same questions through four doors — the command line, the
|
|
4
|
+
MCP server, the HTTP sidecar (``serve.py``) and this module. The project allows
|
|
5
|
+
exactly one implementation behind them, so this file **wraps** what already
|
|
6
|
+
exists and computes nothing of its own:
|
|
7
|
+
|
|
8
|
+
- ``check`` -> ``checks.run_record_checks``
|
|
9
|
+
- ``plan`` -> ``plan.validate_plan``
|
|
10
|
+
- ``preflight`` -> ``preflight.runner.run_preflight`` + ``report_json.preflight_document``
|
|
11
|
+
- ``describe`` -> ``describe.describe_all``
|
|
12
|
+
- ``health`` -> ``health.read_ledger_with_skipped`` + ``health.own_and_foreign`` + ``health.summary``
|
|
13
|
+
- ``health_document`` -> ``health.build_document`` (the whole document every door reads)
|
|
14
|
+
- ``declare`` -> ``edit.apply_changes`` (which owns the source/witness gate)
|
|
15
|
+
- ``diff`` -> ``factdiff.fact_diff`` (ref resolution through ``gitref``, git's one door)
|
|
16
|
+
- ``add`` -> ``edit.add_entry`` (one-submit device/family/model path included)
|
|
17
|
+
- ``upgrade`` -> the pinned, non-overwriting legacy-file migration below
|
|
18
|
+
|
|
19
|
+
Each read function returns **exactly the document the CLI's ``--json`` writes**;
|
|
20
|
+
``verdict_document`` is that shape, and the CLI, the MCP server and the sidecar
|
|
21
|
+
all call it rather than assembling their own.
|
|
22
|
+
|
|
23
|
+
Importing this module must not drag in a command line or a web server: no
|
|
24
|
+
``typer``, ``rich``, ``fastapi``, ``uvicorn`` or ``mcp``. That is asserted by
|
|
25
|
+
``tests/test_api_library.py::test_library_import_pulls_no_server_or_cli_deps``,
|
|
26
|
+
and it is why ``report.py`` imports ``rich`` lazily.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import datetime as dt
|
|
32
|
+
import hashlib
|
|
33
|
+
import os
|
|
34
|
+
import tempfile
|
|
35
|
+
from collections.abc import Iterable, Mapping
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
from ruamel.yaml import YAMLError
|
|
40
|
+
|
|
41
|
+
from lockwire import blame as blame_history
|
|
42
|
+
from lockwire import gitref
|
|
43
|
+
from lockwire.checks import run_record_checks
|
|
44
|
+
from lockwire.describe import describe_all
|
|
45
|
+
from lockwire.edit import (
|
|
46
|
+
Change,
|
|
47
|
+
add_entry,
|
|
48
|
+
apply_changes,
|
|
49
|
+
apply_review,
|
|
50
|
+
contract_revision,
|
|
51
|
+
move as _move,
|
|
52
|
+
place as _place,
|
|
53
|
+
reparent as _reparent,
|
|
54
|
+
rotate as _rotate,
|
|
55
|
+
set_mount_shape as _set_mount_shape,
|
|
56
|
+
)
|
|
57
|
+
from lockwire.factdiff import fact_diff, load_contract_text
|
|
58
|
+
from lockwire.forces import force_document
|
|
59
|
+
from lockwire.plan import Plan, load_plan
|
|
60
|
+
from lockwire.plan import validate_plan as _validate_plan
|
|
61
|
+
from lockwire.plan_payload import PlanPayload
|
|
62
|
+
from lockwire.report import (
|
|
63
|
+
Finding,
|
|
64
|
+
ScoredRecord,
|
|
65
|
+
verdict,
|
|
66
|
+
verdict_headline,
|
|
67
|
+
verdict_word,
|
|
68
|
+
)
|
|
69
|
+
from lockwire.report_json import (
|
|
70
|
+
CHECK_ARTIFACT_TYPE,
|
|
71
|
+
FORMAT_VERSION,
|
|
72
|
+
PLAN_ARTIFACT_TYPE,
|
|
73
|
+
envelope,
|
|
74
|
+
)
|
|
75
|
+
from lockwire.schema import (
|
|
76
|
+
ArtifactRef,
|
|
77
|
+
Contract,
|
|
78
|
+
UnsupportedSchemaVersion,
|
|
79
|
+
load_contract,
|
|
80
|
+
load_contract_bytes,
|
|
81
|
+
upgrade_contract_text,
|
|
82
|
+
)
|
|
83
|
+
from lockwire.scene_urdf import load_scene_any
|
|
84
|
+
|
|
85
|
+
#: The document's own version, separate from the contract ``SCHEMA_VERSION`` and
|
|
86
|
+
#: from any transport wrapper. Shared with ``report_json`` so
|
|
87
|
+
#: the preflight document and the check document never version apart.
|
|
88
|
+
DOCUMENT_VERSION = FORMAT_VERSION
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def contract_sha(contract_path: Path) -> str:
|
|
92
|
+
"""Short sha of the contract file; the same 12 hex ``health.contract_sha`` returns.
|
|
93
|
+
|
|
94
|
+
Duplicated (one line) so the free library never imports ``health``.
|
|
95
|
+
"""
|
|
96
|
+
return hashlib.sha256(contract_path.read_bytes()).hexdigest()[:12]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def start(
|
|
100
|
+
urdf: Path | str, *, overwrite: bool = False, expected_revision: str | None = None
|
|
101
|
+
) -> dict[str, str | bool]:
|
|
102
|
+
"""Start beside the selected model, with revision-bound replacement."""
|
|
103
|
+
from lockwire.start_operation import start_contract
|
|
104
|
+
|
|
105
|
+
return start_contract(urdf, overwrite=overwrite, expected_revision=expected_revision)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def api_schema(out: Path | str) -> None:
|
|
109
|
+
"""Write the payload union's JSON schema — ``api_payload.export_openapi``.
|
|
110
|
+
|
|
111
|
+
``api_payload`` is imported here rather than at module scope so
|
|
112
|
+
``import lockwire.api`` stays as thin as
|
|
113
|
+
``test_library_import_pulls_no_server_or_cli_deps`` requires: the schema
|
|
114
|
+
export is the one operation nothing else on the read path needs.
|
|
115
|
+
"""
|
|
116
|
+
from lockwire.api_payload import export_openapi
|
|
117
|
+
|
|
118
|
+
export_openapi(out)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def api_types(out: Path | str) -> None:
|
|
122
|
+
"""Write the payload union's TypeScript types — ``emit_ts.write`` (e1-d3).
|
|
123
|
+
|
|
124
|
+
``emit_ts`` is imported here rather than at module scope for the same
|
|
125
|
+
reason ``api_schema`` imports ``api_payload`` locally:
|
|
126
|
+
``test_library_import_pulls_no_server_or_cli_deps`` keeps ``import
|
|
127
|
+
lockwire.api`` thin.
|
|
128
|
+
"""
|
|
129
|
+
from lockwire.emit_ts import write
|
|
130
|
+
|
|
131
|
+
write(out)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def declare_form(contract: Path | str | Contract) -> dict[str, Any]:
|
|
135
|
+
"""The Declare form's payload (E4-df): every quantity ``declare`` could
|
|
136
|
+
write in `contract` — present or not — plus a pre-filled witness box.
|
|
137
|
+
``page.build_declare_form`` — read-only, nothing here writes.
|
|
138
|
+
|
|
139
|
+
Not in ``FUNCTIONS``: there is no ``lockwire declare-form`` command, only
|
|
140
|
+
the three face boxes (e4-b/c/d) reading this document (same "not a
|
|
141
|
+
seventh operation" reasoning as ``health_document`` above).
|
|
142
|
+
"""
|
|
143
|
+
loaded = contract if isinstance(contract, Contract) else load(contract)
|
|
144
|
+
from lockwire.page import build_declare_form # noqa: PLC0415 - keeps `import lockwire.api` thin
|
|
145
|
+
|
|
146
|
+
document = build_declare_form(loaded)
|
|
147
|
+
if not isinstance(contract, Contract):
|
|
148
|
+
document["revision"] = contract_revision(contract)
|
|
149
|
+
from lockwire.cli import _find_scene # noqa: PLC0415 - deferred, as load_scene
|
|
150
|
+
from lockwire.scene_urdf import urdf_visual_parents # noqa: PLC0415
|
|
151
|
+
|
|
152
|
+
scene_path, _ = _find_scene(Path(contract), None)
|
|
153
|
+
if scene_path is not None and scene_path.suffix.lower() == ".urdf":
|
|
154
|
+
try:
|
|
155
|
+
up = urdf_visual_parents(scene_path)
|
|
156
|
+
except (OSError, ValueError, SyntaxError): # SyntaxError: ET.ParseError
|
|
157
|
+
up = {}
|
|
158
|
+
for row in document["rows"]:
|
|
159
|
+
seg = row["path"].split("/")
|
|
160
|
+
if seg[:2] == ["model", "links"] and seg[2] in up:
|
|
161
|
+
row["part"] = up[seg[2]]
|
|
162
|
+
return document
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def page(
|
|
166
|
+
contract: Path | str | Contract,
|
|
167
|
+
*,
|
|
168
|
+
preflight: tuple[list[Finding], str] | None = None,
|
|
169
|
+
scene: Any = None,
|
|
170
|
+
scene_source: str | None = None,
|
|
171
|
+
contract_path: Path | str | None = None,
|
|
172
|
+
plan_path: Path | str | None = None,
|
|
173
|
+
plan_outcomes: list[Any] | None = None,
|
|
174
|
+
cli_line: str | None = None,
|
|
175
|
+
) -> dict[str, Any]:
|
|
176
|
+
"""The page dict ``page.build_payload`` computes, wrapped unchanged —
|
|
177
|
+
the root gap e1-pg closes: ``PagePayload`` (``api_payload.py``) was
|
|
178
|
+
defined with nothing behind it. This does not recompute anything
|
|
179
|
+
``build_payload`` does not already compute; it only loads the contract the
|
|
180
|
+
way every other door in this file does.
|
|
181
|
+
|
|
182
|
+
``contract_path`` is a separate override (defaulting to ``contract`` when
|
|
183
|
+
that is a path, ``None`` when it is already a ``Contract``) because
|
|
184
|
+
``build_payload`` itself takes a parsed ``Contract`` *and* its path as two
|
|
185
|
+
fields — the path drives the health summary and the describe faces, not
|
|
186
|
+
just the loader — so a caller that already parsed the contract (the
|
|
187
|
+
sidecar, which also needs it for scene/preflight/plan lookups first) can
|
|
188
|
+
pass both without a second parse.
|
|
189
|
+
|
|
190
|
+
Not in ``FUNCTIONS``/``__all__``: there is no ``lockwire page`` CLI
|
|
191
|
+
command, and ``test_every_cli_command_has_a_library_twin`` refuses a
|
|
192
|
+
``FUNCTIONS`` entry with no CLI leaf of the same name. So this sits beside
|
|
193
|
+
``load_scene``/``passed_baseline_channels`` above — a computation more
|
|
194
|
+
than one door needs (the sidecar's ``page`` command) without being the
|
|
195
|
+
twin of its own CLI command. the page's CLI command remains the one that
|
|
196
|
+
writes this same payload to a file; this returns the dict itself for a
|
|
197
|
+
caller — the sidecar, today — that wants the document.
|
|
198
|
+
"""
|
|
199
|
+
from lockwire.page import build_payload as _build_payload # noqa: PLC0415
|
|
200
|
+
|
|
201
|
+
loaded = contract if isinstance(contract, Contract) else load(contract)
|
|
202
|
+
if contract_path is not None:
|
|
203
|
+
resolved_path = Path(contract_path)
|
|
204
|
+
else:
|
|
205
|
+
resolved_path = None if isinstance(contract, Contract) else Path(contract)
|
|
206
|
+
document = _build_payload(
|
|
207
|
+
loaded,
|
|
208
|
+
preflight=preflight,
|
|
209
|
+
scene=scene,
|
|
210
|
+
scene_source=scene_source,
|
|
211
|
+
contract_path=resolved_path,
|
|
212
|
+
plan_path=Path(plan_path) if plan_path is not None else None,
|
|
213
|
+
plan_outcomes=plan_outcomes,
|
|
214
|
+
cli_line=cli_line,
|
|
215
|
+
)
|
|
216
|
+
if resolved_path is not None:
|
|
217
|
+
document["revision"] = contract_revision(resolved_path)
|
|
218
|
+
return document
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
#: The free library operations. Every name here has a CLI command of the same
|
|
222
|
+
#: name. ``api_pro.FUNCTIONS`` is the complete inventory with the paid ones.
|
|
223
|
+
FUNCTIONS = (
|
|
224
|
+
"start",
|
|
225
|
+
"load",
|
|
226
|
+
"check",
|
|
227
|
+
"plan",
|
|
228
|
+
"describe",
|
|
229
|
+
"declare",
|
|
230
|
+
"review",
|
|
231
|
+
"diff",
|
|
232
|
+
"log",
|
|
233
|
+
"blame",
|
|
234
|
+
"what-if",
|
|
235
|
+
"add",
|
|
236
|
+
"move",
|
|
237
|
+
"rotate",
|
|
238
|
+
"reparent",
|
|
239
|
+
"place",
|
|
240
|
+
"mount-shape",
|
|
241
|
+
"upgrade",
|
|
242
|
+
"api-schema",
|
|
243
|
+
"api-types",
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
__all__ = [
|
|
247
|
+
"start",
|
|
248
|
+
"DOCUMENT_VERSION",
|
|
249
|
+
"FUNCTIONS",
|
|
250
|
+
"add",
|
|
251
|
+
"api_schema",
|
|
252
|
+
"api_types",
|
|
253
|
+
"blame",
|
|
254
|
+
"what_if",
|
|
255
|
+
"check",
|
|
256
|
+
"check_document",
|
|
257
|
+
"declare",
|
|
258
|
+
"describe",
|
|
259
|
+
"diff",
|
|
260
|
+
"is_stale",
|
|
261
|
+
"load",
|
|
262
|
+
"mount_shape",
|
|
263
|
+
"move",
|
|
264
|
+
"log",
|
|
265
|
+
"plan",
|
|
266
|
+
"reparent",
|
|
267
|
+
"review",
|
|
268
|
+
"rotate",
|
|
269
|
+
"upgrade",
|
|
270
|
+
"verdict_document",
|
|
271
|
+
]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def verdict_document(
|
|
275
|
+
contract: Contract, findings: list[Finding], artifact_type: str
|
|
276
|
+
) -> dict[str, Any]:
|
|
277
|
+
"""The canonical findings document, shared by every door.
|
|
278
|
+
|
|
279
|
+
The verdict is not recomputed here any more than it is in ``report_json``:
|
|
280
|
+
``report.verdict`` decides once and this reports what it decided. ``scored``
|
|
281
|
+
and ``outcomes`` ride along when the report carries them (a plain list does
|
|
282
|
+
not) rather than being invented.
|
|
283
|
+
"""
|
|
284
|
+
decision = verdict(findings)
|
|
285
|
+
document = envelope(
|
|
286
|
+
artifact_type,
|
|
287
|
+
{
|
|
288
|
+
"robot": contract.robot.name,
|
|
289
|
+
# Hyphenated: byte-identical to report_json's and the
|
|
290
|
+
# terminal's spelling, not the raw underscored VerdictCode.
|
|
291
|
+
"verdict": verdict_word(decision.code),
|
|
292
|
+
"exit_code": decision.exit_code,
|
|
293
|
+
"counts": {
|
|
294
|
+
"violation": decision.violations,
|
|
295
|
+
"warning": decision.warnings,
|
|
296
|
+
"not_scored": decision.not_scored,
|
|
297
|
+
},
|
|
298
|
+
"scored": decision.scored,
|
|
299
|
+
"findings": [finding.model_dump(mode="json") for finding in findings],
|
|
300
|
+
},
|
|
301
|
+
)
|
|
302
|
+
outcomes = getattr(findings, "outcomes", None)
|
|
303
|
+
if outcomes is not None:
|
|
304
|
+
document["outcomes"] = [outcome.model_dump(mode="json") for outcome in outcomes]
|
|
305
|
+
if artifact_type == CHECK_ARTIFACT_TYPE:
|
|
306
|
+
# the force panel gets no command of its own -- the numbers ride in
|
|
307
|
+
# the check document the CLI and the library already produce, so every
|
|
308
|
+
# figure on the panel has a CLI twin. The CG is reused, never recomputed
|
|
309
|
+
# `information` is exactly what compute_system_cg returned.
|
|
310
|
+
document["forces"] = force_document(contract, getattr(findings, "information", []))
|
|
311
|
+
return document
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def load(contract: Path | str) -> Contract:
|
|
315
|
+
"""Load and validate a contract file — ``schema.load_contract``, nothing more."""
|
|
316
|
+
return load_contract(Path(contract))
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def check_document(
|
|
320
|
+
contract: Contract,
|
|
321
|
+
findings: list[Finding],
|
|
322
|
+
*,
|
|
323
|
+
revision: str | None = None,
|
|
324
|
+
actuator_evidence: Mapping[str, ArtifactRef] | None = None,
|
|
325
|
+
) -> dict[str, Any]:
|
|
326
|
+
"""The `check` document: `verdict_document` plus `headline`.
|
|
327
|
+
|
|
328
|
+
A thin wrapper, not a second document builder — `cli.py`'s ``check``
|
|
329
|
+
command (it and this file must not be able to disagree about one
|
|
330
|
+
run) already computes ``findings`` itself for its own console render, so
|
|
331
|
+
it calls this rather than duplicating the ``headline`` line; ``api.check``
|
|
332
|
+
below is the same wrapper for a caller that has not computed ``findings``
|
|
333
|
+
yet.
|
|
334
|
+
"""
|
|
335
|
+
document = verdict_document(contract, findings, CHECK_ARTIFACT_TYPE)
|
|
336
|
+
# The plain-words sentence for the verdict, `report.verdict_headline` -- the
|
|
337
|
+
# same producer `gui.py`'s HTML page reuses, not a second
|
|
338
|
+
# implementation. `adds_up` is `findings.scored_records`, byte-identical to
|
|
339
|
+
# `gui.build_payload`'s own source for that count.
|
|
340
|
+
scored_records = getattr(findings, "scored_records", [])
|
|
341
|
+
document["headline"] = verdict_headline(
|
|
342
|
+
document["counts"]["violation"],
|
|
343
|
+
document["counts"]["warning"],
|
|
344
|
+
document["counts"]["not_scored"],
|
|
345
|
+
len(scored_records),
|
|
346
|
+
)
|
|
347
|
+
# Only the paid check slot produces case reports; without it the key is absent.
|
|
348
|
+
case_reports = getattr(findings, "actuator_case_reports", [])
|
|
349
|
+
if case_reports:
|
|
350
|
+
document["actuator_cases"] = [
|
|
351
|
+
{
|
|
352
|
+
"case_id": report.case_id,
|
|
353
|
+
"evidence": (
|
|
354
|
+
actuator_evidence[report.case_id].model_dump(mode="json")
|
|
355
|
+
if actuator_evidence is not None and report.case_id in actuator_evidence
|
|
356
|
+
else None
|
|
357
|
+
),
|
|
358
|
+
"conformance": _actuator_claim_document(
|
|
359
|
+
report.case_id,
|
|
360
|
+
report.conformance,
|
|
361
|
+
report.findings,
|
|
362
|
+
"conformance",
|
|
363
|
+
report.comparisons,
|
|
364
|
+
report=report,
|
|
365
|
+
evidence=(actuator_evidence or {}).get(report.case_id),
|
|
366
|
+
revision=revision,
|
|
367
|
+
),
|
|
368
|
+
"simulation_validity": _actuator_claim_document(
|
|
369
|
+
report.case_id,
|
|
370
|
+
report.simulation_validity,
|
|
371
|
+
report.findings,
|
|
372
|
+
"simulation_validity",
|
|
373
|
+
report.comparisons,
|
|
374
|
+
report=report,
|
|
375
|
+
evidence=(actuator_evidence or {}).get(report.case_id),
|
|
376
|
+
revision=revision,
|
|
377
|
+
),
|
|
378
|
+
}
|
|
379
|
+
for report in case_reports
|
|
380
|
+
]
|
|
381
|
+
document["revision"] = revision
|
|
382
|
+
return document
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _actuator_claim_document(
|
|
386
|
+
case_id: str,
|
|
387
|
+
decision: Any,
|
|
388
|
+
findings: tuple[Finding, ...],
|
|
389
|
+
lane: str,
|
|
390
|
+
comparisons: tuple[ScoredRecord, ...] = (),
|
|
391
|
+
*,
|
|
392
|
+
report: Any = None,
|
|
393
|
+
evidence: ArtifactRef | None = None,
|
|
394
|
+
revision: str | None = None,
|
|
395
|
+
) -> dict[str, Any]:
|
|
396
|
+
case_prefix = f"actuator_cases/{case_id}/"
|
|
397
|
+
generic_evidence_gap = case_prefix + "evidence"
|
|
398
|
+
simulation_prefix = f"actuator_cases/{case_id}/simulation_validity"
|
|
399
|
+
admitted = (
|
|
400
|
+
(report.physical_evidence if lane == "conformance" else report.simulation_evidence)
|
|
401
|
+
if report is not None
|
|
402
|
+
else None
|
|
403
|
+
)
|
|
404
|
+
artifacts = (
|
|
405
|
+
(report.physical_artifacts if lane == "conformance" else report.simulation_artifacts)
|
|
406
|
+
if report is not None
|
|
407
|
+
else ()
|
|
408
|
+
)
|
|
409
|
+
sources = {
|
|
410
|
+
(row.provenance.source, row.provenance.witness)
|
|
411
|
+
for row in (admitted.signals if admitted is not None else ())
|
|
412
|
+
if row.provenance is not None
|
|
413
|
+
and row.provenance.source.strip()
|
|
414
|
+
and row.provenance.witness.strip()
|
|
415
|
+
}
|
|
416
|
+
evidence_class = "unverified"
|
|
417
|
+
if admitted is not None and decision.scored and evidence is not None:
|
|
418
|
+
evidence_class = (
|
|
419
|
+
"physical_measurement"
|
|
420
|
+
if lane == "conformance"
|
|
421
|
+
else "prediction_with_held_out_reference"
|
|
422
|
+
)
|
|
423
|
+
reference = (
|
|
424
|
+
admitted.validation_reference if lane == "simulation_validity" and admitted else None
|
|
425
|
+
)
|
|
426
|
+
return {
|
|
427
|
+
"comparisons": [
|
|
428
|
+
record.model_dump(mode="json")
|
|
429
|
+
for record in comparisons
|
|
430
|
+
if record.quantity.startswith(case_prefix)
|
|
431
|
+
and (
|
|
432
|
+
(
|
|
433
|
+
lane == "simulation_validity"
|
|
434
|
+
and record.quantity.startswith(simulation_prefix + "/")
|
|
435
|
+
)
|
|
436
|
+
or (
|
|
437
|
+
lane == "conformance"
|
|
438
|
+
and record.quantity != simulation_prefix
|
|
439
|
+
and not record.quantity.startswith(simulation_prefix + "/")
|
|
440
|
+
)
|
|
441
|
+
)
|
|
442
|
+
],
|
|
443
|
+
"label": "physical conformance" if lane == "conformance" else "simulation validity",
|
|
444
|
+
"verdict": verdict_word(decision.code),
|
|
445
|
+
"exit_code": decision.exit_code,
|
|
446
|
+
"scored": decision.scored,
|
|
447
|
+
"context": {
|
|
448
|
+
"case_id": case_id,
|
|
449
|
+
"claim_id": lane,
|
|
450
|
+
"contract_revision": revision,
|
|
451
|
+
"selected_evidence": evidence.model_dump(mode="json") if evidence else None,
|
|
452
|
+
"evidence_class": evidence_class,
|
|
453
|
+
"source_witnesses": [
|
|
454
|
+
{"source": source, "witness": witness} for source, witness in sorted(sources)
|
|
455
|
+
],
|
|
456
|
+
"uncertainty": "unknown",
|
|
457
|
+
"dependencies": [
|
|
458
|
+
{"path": path, "sha256": sha256}
|
|
459
|
+
for path, sha256 in dict.fromkeys((item.path, item.sha256) for item in artifacts)
|
|
460
|
+
],
|
|
461
|
+
"freshness": "bound_to_revision"
|
|
462
|
+
if evidence_class != "unverified" and revision
|
|
463
|
+
else "unverified",
|
|
464
|
+
"validation_reference": (
|
|
465
|
+
reference.artifact.model_dump(mode="json") if reference is not None else None
|
|
466
|
+
),
|
|
467
|
+
"reference_held_out": reference.held_out if reference is not None else None,
|
|
468
|
+
"calibration_artifacts": [
|
|
469
|
+
item.model_dump(mode="json")
|
|
470
|
+
for item in (
|
|
471
|
+
report.simulation_calibration_artifacts
|
|
472
|
+
if lane == "simulation_validity" and report is not None
|
|
473
|
+
else ()
|
|
474
|
+
)
|
|
475
|
+
],
|
|
476
|
+
"draft_binding": None,
|
|
477
|
+
},
|
|
478
|
+
"gaps": [
|
|
479
|
+
finding.model_dump(mode="json")
|
|
480
|
+
for finding in findings
|
|
481
|
+
if finding.quantity.startswith(case_prefix)
|
|
482
|
+
and (
|
|
483
|
+
f"/{lane}/" in finding.quantity
|
|
484
|
+
or finding.quantity == generic_evidence_gap
|
|
485
|
+
or (
|
|
486
|
+
lane == "conformance"
|
|
487
|
+
and finding.quantity != simulation_prefix
|
|
488
|
+
and not finding.quantity.startswith(simulation_prefix + "/")
|
|
489
|
+
)
|
|
490
|
+
)
|
|
491
|
+
],
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _actuator_check_context(
|
|
496
|
+
contract: Contract,
|
|
497
|
+
contract_path: Path | None,
|
|
498
|
+
actuator_evidence: Mapping[str, ArtifactRef] | None,
|
|
499
|
+
evidence_root: Path | str | None,
|
|
500
|
+
expected_revision: str | None,
|
|
501
|
+
) -> tuple[Mapping[str, ArtifactRef] | None, Path | None, str | None]:
|
|
502
|
+
"""Admit one path-backed actuator evidence selection for every Check door."""
|
|
503
|
+
supplied = (
|
|
504
|
+
actuator_evidence is not None,
|
|
505
|
+
evidence_root is not None,
|
|
506
|
+
expected_revision is not None,
|
|
507
|
+
)
|
|
508
|
+
if any(supplied) and not all(supplied):
|
|
509
|
+
raise ValueError(
|
|
510
|
+
"actuator evidence requires actuator_evidence, evidence_root, and expected_revision together"
|
|
511
|
+
)
|
|
512
|
+
if not any(supplied):
|
|
513
|
+
return None, None, contract_revision(contract_path) if contract_path is not None else None
|
|
514
|
+
if contract_path is None:
|
|
515
|
+
raise ValueError("actuator evidence requires a path-backed contract")
|
|
516
|
+
assert (
|
|
517
|
+
actuator_evidence is not None
|
|
518
|
+
and evidence_root is not None
|
|
519
|
+
and expected_revision is not None
|
|
520
|
+
)
|
|
521
|
+
if (
|
|
522
|
+
len(expected_revision) != 64
|
|
523
|
+
or expected_revision != expected_revision.lower()
|
|
524
|
+
or any(character not in "0123456789abcdef" for character in expected_revision)
|
|
525
|
+
):
|
|
526
|
+
raise ValueError("expected_revision must be the full lowercase raw contract sha256")
|
|
527
|
+
revision = contract_revision(contract_path)
|
|
528
|
+
if expected_revision != revision:
|
|
529
|
+
raise ValueError("contract changed since evidence was selected; reload before checking")
|
|
530
|
+
declared_case_ids = {case.id for case in contract.actuator_cases}
|
|
531
|
+
foreign = sorted(set(actuator_evidence) - declared_case_ids)
|
|
532
|
+
if foreign:
|
|
533
|
+
raise ValueError("actuator evidence names undeclared case(s): " + ", ".join(foreign))
|
|
534
|
+
return actuator_evidence, Path(evidence_root), revision
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def check(
|
|
538
|
+
contract: Path | str | Contract,
|
|
539
|
+
*,
|
|
540
|
+
actuator_evidence: Mapping[str, ArtifactRef] | None = None,
|
|
541
|
+
evidence_root: Path | str | None = None,
|
|
542
|
+
expected_revision: str | None = None,
|
|
543
|
+
contract_path: Path | str | None = None,
|
|
544
|
+
) -> dict[str, Any]:
|
|
545
|
+
"""Record-level checks: the document ``lockwire check --json`` writes.
|
|
546
|
+
|
|
547
|
+
``contract_path`` retains raw-revision and path-backed evidence semantics when
|
|
548
|
+
a transport has already loaded ``contract`` through its shared refusal boundary.
|
|
549
|
+
"""
|
|
550
|
+
loaded = contract if isinstance(contract, Contract) else load(contract)
|
|
551
|
+
path = (
|
|
552
|
+
Path(contract_path)
|
|
553
|
+
if isinstance(contract, Contract) and contract_path is not None
|
|
554
|
+
else None
|
|
555
|
+
if isinstance(contract, Contract)
|
|
556
|
+
else Path(contract)
|
|
557
|
+
)
|
|
558
|
+
admitted_evidence, admitted_root, revision = _actuator_check_context(
|
|
559
|
+
loaded, path, actuator_evidence, evidence_root, expected_revision
|
|
560
|
+
)
|
|
561
|
+
findings = run_record_checks(
|
|
562
|
+
loaded,
|
|
563
|
+
contract_path=path,
|
|
564
|
+
actuator_evidence=admitted_evidence,
|
|
565
|
+
evidence_root=admitted_root,
|
|
566
|
+
contract_sha256=revision,
|
|
567
|
+
)
|
|
568
|
+
return check_document(
|
|
569
|
+
loaded,
|
|
570
|
+
findings,
|
|
571
|
+
revision=revision,
|
|
572
|
+
actuator_evidence=admitted_evidence,
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def is_stale(document: Mapping[str, Any], contract: Path | str) -> bool:
|
|
577
|
+
"""Whether ``document`` — any check/edit result carrying ``revision``
|
|
578
|
+
was computed against contract bytes that are no longer the ones
|
|
579
|
+
on disk, e.g. after ``vcs.apply_undo`` restores an earlier revision.
|
|
580
|
+
|
|
581
|
+
Reuses the existing ``revision`` field rather than a second document
|
|
582
|
+
shape: a caller holding a cached result re-checks it against the current
|
|
583
|
+
file instead of trusting it silently.
|
|
584
|
+
"""
|
|
585
|
+
return document.get("revision") != contract_revision(Path(contract))
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def plan(
|
|
589
|
+
contract: Path | str | Contract, plan: Path | str | dict[str, Any] | Plan
|
|
590
|
+
) -> dict[str, Any]:
|
|
591
|
+
"""Score an intended plan: the document ``lockwire plan --json`` writes.
|
|
592
|
+
|
|
593
|
+
``plan`` is a plan.yaml path (what the CLI has) or an already-parsed plan
|
|
594
|
+
(what MCP and the sidecar have, inline in the request body).
|
|
595
|
+
"""
|
|
596
|
+
loaded = contract if isinstance(contract, Contract) else load(contract)
|
|
597
|
+
if isinstance(plan, Plan):
|
|
598
|
+
parsed = plan
|
|
599
|
+
elif isinstance(plan, dict):
|
|
600
|
+
parsed = Plan.model_validate(plan)
|
|
601
|
+
else:
|
|
602
|
+
parsed = load_plan(Path(plan))
|
|
603
|
+
report = _validate_plan(loaded, parsed)
|
|
604
|
+
# Joined into the typed shape (steps, finding_labels) so every install writes one document.
|
|
605
|
+
document = verdict_document(loaded, report, PLAN_ARTIFACT_TYPE)
|
|
606
|
+
return PlanPayload.model_validate(document).model_dump(mode="json")
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def describe(contract: Path | str | Contract) -> dict[str, Any]:
|
|
610
|
+
"""Every generated reference file, keyed by filename — ``describe.describe_all``."""
|
|
611
|
+
loaded = contract if isinstance(contract, Contract) else load(contract)
|
|
612
|
+
return envelope(
|
|
613
|
+
"lockwire-describe",
|
|
614
|
+
{"robot": loaded.robot.name, "files": describe_all(loaded)},
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def declare(
|
|
619
|
+
contract: Path | str,
|
|
620
|
+
path: str,
|
|
621
|
+
value: Any,
|
|
622
|
+
*,
|
|
623
|
+
source: str,
|
|
624
|
+
witness: str,
|
|
625
|
+
note: str | None = None,
|
|
626
|
+
locked_at: dt.date | None = None,
|
|
627
|
+
dry_run: bool = False,
|
|
628
|
+
expected_revision: str | None = None,
|
|
629
|
+
) -> dict[str, Any]:
|
|
630
|
+
"""Declare one fact — the write door, through the same gate as the CLI.
|
|
631
|
+
|
|
632
|
+
``source`` and ``witness`` are not defaulted and not filled in here: they go
|
|
633
|
+
to ``edit.apply_changes``, which refuses an empty one. A wrapper that
|
|
634
|
+
substituted a placeholder would be a write with no provenance behind it.
|
|
635
|
+
"""
|
|
636
|
+
contract_path = Path(contract)
|
|
637
|
+
result = apply_changes(
|
|
638
|
+
contract_path,
|
|
639
|
+
[
|
|
640
|
+
Change(
|
|
641
|
+
path=path,
|
|
642
|
+
value=value,
|
|
643
|
+
source=source,
|
|
644
|
+
witness=witness,
|
|
645
|
+
note=note,
|
|
646
|
+
locked_at=locked_at,
|
|
647
|
+
)
|
|
648
|
+
],
|
|
649
|
+
dry_run=dry_run,
|
|
650
|
+
expected_revision=expected_revision,
|
|
651
|
+
)
|
|
652
|
+
# ``apply_changes`` above is the commit point (same shape as
|
|
653
|
+
# ``api.add``): by the time this line runs,
|
|
654
|
+
# ``result.written`` already names whether the write landed. The
|
|
655
|
+
# ``contract_sha`` call below still has to re-read the file from disk —
|
|
656
|
+
# a value computed before the write would name the pre-write file, not
|
|
657
|
+
# what a caller asking "what did I just declare" wants — so the read
|
|
658
|
+
# stays after the write, but it can no longer raise bare: an unhandled
|
|
659
|
+
# ``OSError`` there (e.g. the contract becoming unreadable in the window
|
|
660
|
+
# between the write and this re-read) used to surface as an ordinary
|
|
661
|
+
# exception, indistinguishable from nothing having been written, for a
|
|
662
|
+
# declare that had already landed on disk. Catch it and report a
|
|
663
|
+
# distinct, named disposition instead.
|
|
664
|
+
body: dict[str, Any] = {
|
|
665
|
+
"written": result.written,
|
|
666
|
+
"diff": result.diff,
|
|
667
|
+
"lines": list(result.lines),
|
|
668
|
+
}
|
|
669
|
+
try:
|
|
670
|
+
body["contract_sha"] = contract_sha(contract_path)
|
|
671
|
+
body["revision"] = contract_revision(contract_path)
|
|
672
|
+
except OSError as error:
|
|
673
|
+
body["contract_sha"] = None
|
|
674
|
+
body["revision"] = None
|
|
675
|
+
body["contract_sha_error"] = str(error)
|
|
676
|
+
return envelope("lockwire-edit-result", body)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def add(
|
|
680
|
+
contract: Path | str,
|
|
681
|
+
kind: str,
|
|
682
|
+
*,
|
|
683
|
+
id: str | None = None,
|
|
684
|
+
source: str,
|
|
685
|
+
witness: str,
|
|
686
|
+
note: str | None = None,
|
|
687
|
+
locked_at: dt.date | None = None,
|
|
688
|
+
dry_run: bool = False,
|
|
689
|
+
expected_revision: str | None = None,
|
|
690
|
+
**fields: Any,
|
|
691
|
+
) -> dict[str, Any]:
|
|
692
|
+
"""Add a device/rail/bus/family/model/budget/battery block — the write
|
|
693
|
+
door behind ``lockwire add``, through ``edit.add_entry``.
|
|
694
|
+
|
|
695
|
+
``fields`` carries the same keys the CLI builds (``model``, ``owner``,
|
|
696
|
+
``family``, ``physical_mass``, ...) — see ``add_entry``'s docstring.
|
|
697
|
+
The one-submit path: for ``kind="device"``, passing both ``family``
|
|
698
|
+
and ``model`` among ``fields`` creates the family and/or model alongside
|
|
699
|
+
the device, atomically, when either does not already exist.
|
|
700
|
+
"""
|
|
701
|
+
contract_path = Path(contract)
|
|
702
|
+
result = add_entry(
|
|
703
|
+
contract_path,
|
|
704
|
+
kind,
|
|
705
|
+
{"id": id, **fields},
|
|
706
|
+
source=source,
|
|
707
|
+
witness=witness,
|
|
708
|
+
note=note,
|
|
709
|
+
locked_at=locked_at,
|
|
710
|
+
dry_run=dry_run,
|
|
711
|
+
expected_revision=expected_revision,
|
|
712
|
+
)
|
|
713
|
+
# ``add_entry`` above is the commit point: by the time this
|
|
714
|
+
# line runs, ``result.written`` already names whether the write landed.
|
|
715
|
+
# ``contract_sha`` still has to re-read the file from disk to answer "sha
|
|
716
|
+
# of what's there now" — a value computed *before* the write would name
|
|
717
|
+
# the pre-write file, which is not what a caller asking "what did I just
|
|
718
|
+
# write" wants — so the read stays after the write, but it can no longer
|
|
719
|
+
# be allowed to raise bare: an unhandled ``OSError`` here (e.g. the
|
|
720
|
+
# contract becoming unreadable in the window between the write and this
|
|
721
|
+
# re-read) surfaced as an ordinary exception, indistinguishable from
|
|
722
|
+
# nothing having been written, for an add that had already landed on
|
|
723
|
+
# disk. The obvious caller response to an exception is to retry the add,
|
|
724
|
+
# and a retry after a real write is a second write. Catch it and report a
|
|
725
|
+
# distinct, named disposition instead — ``written`` stays exactly what
|
|
726
|
+
# ``add_entry`` returned, and ``contract_sha`` is ``None`` with
|
|
727
|
+
# ``contract_sha_error`` explaining why, never a bare raise.
|
|
728
|
+
document: dict[str, Any] = {
|
|
729
|
+
"written": result.written,
|
|
730
|
+
"diff": result.diff,
|
|
731
|
+
"lines": list(result.lines),
|
|
732
|
+
}
|
|
733
|
+
try:
|
|
734
|
+
document["contract_sha"] = contract_sha(contract_path)
|
|
735
|
+
document["revision"] = contract_revision(contract_path)
|
|
736
|
+
except OSError as error:
|
|
737
|
+
document["contract_sha"] = None
|
|
738
|
+
document["revision"] = None
|
|
739
|
+
document["contract_sha_error"] = str(error)
|
|
740
|
+
return envelope("lockwire-edit-result", document)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def review(
|
|
744
|
+
contract: Path | str,
|
|
745
|
+
path: str,
|
|
746
|
+
state: str,
|
|
747
|
+
*,
|
|
748
|
+
witness: str,
|
|
749
|
+
note: str | None = None,
|
|
750
|
+
locked_at: dt.date | None = None,
|
|
751
|
+
dry_run: bool = False,
|
|
752
|
+
expected_revision: str | None = None,
|
|
753
|
+
) -> dict[str, Any]:
|
|
754
|
+
"""Approve or question one declared value, writing the reviewer's note into
|
|
755
|
+
the file — the same document ``--json`` prints.
|
|
756
|
+
|
|
757
|
+
Wraps ``edit.apply_review`` exactly the way ``declare`` wraps
|
|
758
|
+
``apply_changes``; the witness/questioned-needs-a-note gate lives in
|
|
759
|
+
``edit.apply_review``, not here. ``locked_at`` mirrors ``declare``'s own
|
|
760
|
+
parameter — tests only, so a review's date can be pinned.
|
|
761
|
+
"""
|
|
762
|
+
contract_path = Path(contract)
|
|
763
|
+
result = apply_review(
|
|
764
|
+
contract_path,
|
|
765
|
+
path,
|
|
766
|
+
state,
|
|
767
|
+
witness=witness,
|
|
768
|
+
note=note,
|
|
769
|
+
locked_at=locked_at,
|
|
770
|
+
dry_run=dry_run,
|
|
771
|
+
expected_revision=expected_revision,
|
|
772
|
+
)
|
|
773
|
+
return envelope(
|
|
774
|
+
"lockwire-edit-result",
|
|
775
|
+
{
|
|
776
|
+
"contract_sha": contract_sha(contract_path),
|
|
777
|
+
"revision": contract_revision(contract_path),
|
|
778
|
+
"written": result.written,
|
|
779
|
+
"diff": result.diff,
|
|
780
|
+
"lines": list(result.lines),
|
|
781
|
+
},
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _edit_document(contract_path: Path, result: Any) -> dict[str, Any]:
|
|
786
|
+
"""Use ``declare``'s result envelope for every pose and mount write."""
|
|
787
|
+
document: dict[str, Any] = {
|
|
788
|
+
"written": result.written,
|
|
789
|
+
"diff": result.diff,
|
|
790
|
+
"lines": list(result.lines),
|
|
791
|
+
}
|
|
792
|
+
try:
|
|
793
|
+
document["contract_sha"] = contract_sha(contract_path)
|
|
794
|
+
document["revision"] = contract_revision(contract_path)
|
|
795
|
+
except OSError as error:
|
|
796
|
+
document["contract_sha"] = None
|
|
797
|
+
document["revision"] = None
|
|
798
|
+
document["contract_sha_error"] = str(error)
|
|
799
|
+
return envelope("lockwire-edit-result", document)
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def place(
|
|
803
|
+
contract: Path | str,
|
|
804
|
+
target: str,
|
|
805
|
+
on: str,
|
|
806
|
+
offset: tuple[float, float, float],
|
|
807
|
+
*,
|
|
808
|
+
unit: str,
|
|
809
|
+
source: str,
|
|
810
|
+
witness: str,
|
|
811
|
+
note: str | None = None,
|
|
812
|
+
locked_at: dt.date | None = None,
|
|
813
|
+
dry_run: bool = False,
|
|
814
|
+
expected_revision: str | None = None,
|
|
815
|
+
) -> dict[str, Any]:
|
|
816
|
+
"""Place a device on a link, with frame and offset committed atomically."""
|
|
817
|
+
path = Path(contract)
|
|
818
|
+
return _edit_document(
|
|
819
|
+
path,
|
|
820
|
+
_place(
|
|
821
|
+
path,
|
|
822
|
+
target,
|
|
823
|
+
on,
|
|
824
|
+
offset,
|
|
825
|
+
unit=unit,
|
|
826
|
+
source=source,
|
|
827
|
+
witness=witness,
|
|
828
|
+
note=note,
|
|
829
|
+
locked_at=locked_at,
|
|
830
|
+
dry_run=dry_run,
|
|
831
|
+
expected_revision=expected_revision,
|
|
832
|
+
),
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def mount_shape(
|
|
837
|
+
contract: Path | str,
|
|
838
|
+
target: str,
|
|
839
|
+
shape: dict[str, Any],
|
|
840
|
+
*,
|
|
841
|
+
source: str,
|
|
842
|
+
witness: str,
|
|
843
|
+
note: str | None = None,
|
|
844
|
+
locked_at: dt.date | None = None,
|
|
845
|
+
dry_run: bool = False,
|
|
846
|
+
expected_revision: str | None = None,
|
|
847
|
+
) -> dict[str, Any]:
|
|
848
|
+
"""Set a device's mount geometry through the contract writer."""
|
|
849
|
+
path = Path(contract)
|
|
850
|
+
return _edit_document(
|
|
851
|
+
path,
|
|
852
|
+
_set_mount_shape(
|
|
853
|
+
path,
|
|
854
|
+
target,
|
|
855
|
+
shape,
|
|
856
|
+
source=source,
|
|
857
|
+
witness=witness,
|
|
858
|
+
note=note,
|
|
859
|
+
locked_at=locked_at,
|
|
860
|
+
dry_run=dry_run,
|
|
861
|
+
expected_revision=expected_revision,
|
|
862
|
+
),
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def move(
|
|
867
|
+
contract: Path | str,
|
|
868
|
+
target: str,
|
|
869
|
+
offset: tuple[float, float, float],
|
|
870
|
+
*,
|
|
871
|
+
unit: str,
|
|
872
|
+
source: str,
|
|
873
|
+
witness: str,
|
|
874
|
+
note: str | None = None,
|
|
875
|
+
locked_at: dt.date | None = None,
|
|
876
|
+
dry_run: bool = False,
|
|
877
|
+
expected_revision: str | None = None,
|
|
878
|
+
) -> dict[str, Any]:
|
|
879
|
+
"""Move a device in its mount frame, or a link in its parent's — `edit.move`."""
|
|
880
|
+
path = Path(contract)
|
|
881
|
+
return _edit_document(
|
|
882
|
+
path,
|
|
883
|
+
_move(
|
|
884
|
+
path,
|
|
885
|
+
target,
|
|
886
|
+
offset,
|
|
887
|
+
unit=unit,
|
|
888
|
+
source=source,
|
|
889
|
+
witness=witness,
|
|
890
|
+
note=note,
|
|
891
|
+
locked_at=locked_at,
|
|
892
|
+
dry_run=dry_run,
|
|
893
|
+
expected_revision=expected_revision,
|
|
894
|
+
),
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def rotate(
|
|
899
|
+
contract: Path | str,
|
|
900
|
+
target: str,
|
|
901
|
+
rpy: tuple[float, float, float],
|
|
902
|
+
*,
|
|
903
|
+
unit: str,
|
|
904
|
+
source: str,
|
|
905
|
+
witness: str,
|
|
906
|
+
note: str | None = None,
|
|
907
|
+
locked_at: dt.date | None = None,
|
|
908
|
+
dry_run: bool = False,
|
|
909
|
+
expected_revision: str | None = None,
|
|
910
|
+
) -> dict[str, Any]:
|
|
911
|
+
"""Set a link's orientation, unit required, never defaulted — `edit.rotate`."""
|
|
912
|
+
path = Path(contract)
|
|
913
|
+
return _edit_document(
|
|
914
|
+
path,
|
|
915
|
+
_rotate(
|
|
916
|
+
path,
|
|
917
|
+
target,
|
|
918
|
+
rpy,
|
|
919
|
+
unit=unit,
|
|
920
|
+
source=source,
|
|
921
|
+
witness=witness,
|
|
922
|
+
note=note,
|
|
923
|
+
locked_at=locked_at,
|
|
924
|
+
dry_run=dry_run,
|
|
925
|
+
expected_revision=expected_revision,
|
|
926
|
+
),
|
|
927
|
+
)
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def reparent(
|
|
931
|
+
contract: Path | str,
|
|
932
|
+
target: str,
|
|
933
|
+
to: str,
|
|
934
|
+
*,
|
|
935
|
+
source: str,
|
|
936
|
+
witness: str,
|
|
937
|
+
note: str | None = None,
|
|
938
|
+
locked_at: dt.date | None = None,
|
|
939
|
+
dry_run: bool = False,
|
|
940
|
+
expected_revision: str | None = None,
|
|
941
|
+
) -> dict[str, Any]:
|
|
942
|
+
"""Change a device's mount link or a link's joint parent — `edit.reparent`.
|
|
943
|
+
|
|
944
|
+
The cycle and orphan refusals are `edit.py`'s, not re-implemented here; the
|
|
945
|
+
structure-hash move arrives in `lines`, the same text the CLI prints.
|
|
946
|
+
"""
|
|
947
|
+
path = Path(contract)
|
|
948
|
+
return _edit_document(
|
|
949
|
+
path,
|
|
950
|
+
_reparent(
|
|
951
|
+
path,
|
|
952
|
+
target,
|
|
953
|
+
to,
|
|
954
|
+
source=source,
|
|
955
|
+
witness=witness,
|
|
956
|
+
note=note,
|
|
957
|
+
locked_at=locked_at,
|
|
958
|
+
dry_run=dry_run,
|
|
959
|
+
expected_revision=expected_revision,
|
|
960
|
+
),
|
|
961
|
+
)
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def _diff_side(spec: str, contract: Path, repo: Path | None) -> Contract:
|
|
965
|
+
"""One side of ``diff``: a contract file on disk, or the contract at a git ref.
|
|
966
|
+
|
|
967
|
+
Mirrors ``cli._diff_side``'s resolution rules (a spec naming an existing file
|
|
968
|
+
is read straight off disk and never touches git) without its console/typer
|
|
969
|
+
parts, which belong to the CLI door, not the library.
|
|
970
|
+
"""
|
|
971
|
+
on_disk = Path(spec)
|
|
972
|
+
if on_disk.exists() and on_disk.is_file():
|
|
973
|
+
return load_contract(on_disk)
|
|
974
|
+
if repo is None:
|
|
975
|
+
raise ValueError("not a git repository — nothing to compare against")
|
|
976
|
+
if gitref.rev_parse(spec, repo=repo) is None:
|
|
977
|
+
raise ValueError(f"no such ref {spec!r}")
|
|
978
|
+
relpath = gitref.relpath_in_repo(contract, repo)
|
|
979
|
+
if relpath is None:
|
|
980
|
+
raise ValueError(f"{contract} is outside the repository at {repo}")
|
|
981
|
+
text = gitref.show(spec, relpath, repo=repo)
|
|
982
|
+
if text is None:
|
|
983
|
+
raise FileNotFoundError(f"{contract.name} did not exist at {spec}")
|
|
984
|
+
return load_contract_text(text)
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def diff(contract: Path | str, a: str = "HEAD", b: str | None = None) -> dict[str, Any]:
|
|
988
|
+
"""What changed in the facts between two refs: the document ``lockwire diff --json`` writes.
|
|
989
|
+
|
|
990
|
+
``a`` and ``b`` are refs (or contract paths), the same as the CLI's arguments;
|
|
991
|
+
``b`` defaults to the contract file on disk, same as ``lockwire diff`` does.
|
|
992
|
+
"""
|
|
993
|
+
contract_path = Path(contract)
|
|
994
|
+
if b is None and not contract_path.exists():
|
|
995
|
+
raise FileNotFoundError(f"no contract at {contract_path}")
|
|
996
|
+
repo = gitref.repo_root(contract_path if contract_path.exists() else Path.cwd())
|
|
997
|
+
before = _diff_side(a, contract_path, repo)
|
|
998
|
+
after = _diff_side(b if b is not None else str(contract_path), contract_path, repo)
|
|
999
|
+
return fact_diff(before, after).as_json()
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def _contract_at(ref: str, relpath: str, *, repo: Path) -> Contract | None:
|
|
1003
|
+
text = gitref.show(ref, relpath, repo=repo)
|
|
1004
|
+
if text is None:
|
|
1005
|
+
return None
|
|
1006
|
+
try:
|
|
1007
|
+
return load_contract_text(text)
|
|
1008
|
+
except UnsupportedSchemaVersion:
|
|
1009
|
+
raise
|
|
1010
|
+
except (OSError, TypeError, ValueError, YAMLError):
|
|
1011
|
+
return None
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def log(contract: Path | str, revision_range: str | None = None) -> list[dict[str, Any]]:
|
|
1015
|
+
"""The ``lockwire log --json`` rows for commits touching ``contract``."""
|
|
1016
|
+
contract_path = Path(contract)
|
|
1017
|
+
repo = gitref.repo_root(contract_path if contract_path.exists() else Path.cwd())
|
|
1018
|
+
if repo is None:
|
|
1019
|
+
return []
|
|
1020
|
+
relpath = gitref.relpath_in_repo(contract_path, repo)
|
|
1021
|
+
if relpath is None:
|
|
1022
|
+
raise ValueError(f"{contract_path} is outside the repository at {repo}")
|
|
1023
|
+
commits = gitref.log(revision_range, [relpath], repo=repo, follow=revision_range is None)
|
|
1024
|
+
if commits is None:
|
|
1025
|
+
raise ValueError(f"no such ref {revision_range!r}")
|
|
1026
|
+
|
|
1027
|
+
entries: list[dict[str, Any]] = []
|
|
1028
|
+
historical_relpath = relpath
|
|
1029
|
+
for sha, author, date, message in commits:
|
|
1030
|
+
row = {"sha": sha, "author": author, "date": date, "message": message}
|
|
1031
|
+
parent_relpath = gitref.parent_relpath_after_rename(sha, historical_relpath, repo=repo)
|
|
1032
|
+
try:
|
|
1033
|
+
current = _contract_at(sha, historical_relpath, repo=repo)
|
|
1034
|
+
except UnsupportedSchemaVersion:
|
|
1035
|
+
entries.append({**row, "verdict": "REFUSED", "fact_diff": None})
|
|
1036
|
+
historical_relpath = parent_relpath
|
|
1037
|
+
continue
|
|
1038
|
+
if current is None:
|
|
1039
|
+
historical_relpath = parent_relpath
|
|
1040
|
+
continue
|
|
1041
|
+
try:
|
|
1042
|
+
parent = _contract_at(f"{sha}^", parent_relpath, repo=repo)
|
|
1043
|
+
except UnsupportedSchemaVersion:
|
|
1044
|
+
parent = None
|
|
1045
|
+
entries.append(
|
|
1046
|
+
{
|
|
1047
|
+
**row,
|
|
1048
|
+
"verdict": verdict_word(verdict(run_record_checks(current)).code),
|
|
1049
|
+
"fact_diff": fact_diff(parent, current).as_json() if parent else None,
|
|
1050
|
+
}
|
|
1051
|
+
)
|
|
1052
|
+
historical_relpath = parent_relpath
|
|
1053
|
+
return entries
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def blame(contract: Path | str) -> tuple[list[dict[str, Any]], bool]:
|
|
1057
|
+
"""Per-declared-fact provenance rows for ``lockwire blame``.
|
|
1058
|
+
|
|
1059
|
+
``blame.rows`` owns validation, source positions, and git's follow-aware
|
|
1060
|
+
attribution. This public door transports that result without deriving a
|
|
1061
|
+
second history view.
|
|
1062
|
+
"""
|
|
1063
|
+
return blame_history.rows(Path(contract))
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def what_if(
|
|
1067
|
+
contract: Path | str,
|
|
1068
|
+
changes: list[Change],
|
|
1069
|
+
*,
|
|
1070
|
+
recorded_health: Any = None,
|
|
1071
|
+
watch: Any = (),
|
|
1072
|
+
) -> dict[str, Any]:
|
|
1073
|
+
"""Non-mutating current/proposed record comparison; no robot or simulation claim.
|
|
1074
|
+
|
|
1075
|
+
``recorded_health`` and ``watch`` are ``whatif.what_if``'s optional health
|
|
1076
|
+
provider; ``api_pro.what_if`` supplies them. Without them the recorded-health
|
|
1077
|
+
part is empty, exactly as for a contract with no ledger.
|
|
1078
|
+
"""
|
|
1079
|
+
from dataclasses import asdict
|
|
1080
|
+
from lockwire.whatif import what_if as compare
|
|
1081
|
+
|
|
1082
|
+
loaded = load_contract(Path(contract))
|
|
1083
|
+
result = compare(contract, changes, recorded_health=recorded_health, watch=watch)
|
|
1084
|
+
sides = {}
|
|
1085
|
+
for name in ("current", "proposed"):
|
|
1086
|
+
side = getattr(result, name)
|
|
1087
|
+
sides[name] = verdict_document(loaded, side.report, "lockwire-record-comparison")
|
|
1088
|
+
sides[name]["facts"] = [asdict(fact) for fact in side.facts]
|
|
1089
|
+
sides[name]["comparisons"] = [
|
|
1090
|
+
row.model_dump(mode="json") for row in side.report.scored_records
|
|
1091
|
+
]
|
|
1092
|
+
document = {**sides, "diff": result.diff}
|
|
1093
|
+
if recorded_health is not None: # only the paid what-if supplies a ledger
|
|
1094
|
+
document["recorded_health"] = {
|
|
1095
|
+
"baseline": [row.model_dump(mode="json") for row in result.recorded_health.baseline],
|
|
1096
|
+
"invalidated": [
|
|
1097
|
+
row.model_dump(mode="json") for row in result.recorded_health.invalidated
|
|
1098
|
+
],
|
|
1099
|
+
}
|
|
1100
|
+
return envelope("lockwire-what-if-report", document)
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
def upgrade(
|
|
1104
|
+
contract: Path | str,
|
|
1105
|
+
*,
|
|
1106
|
+
out: Path | str,
|
|
1107
|
+
expected_revision: str,
|
|
1108
|
+
) -> None:
|
|
1109
|
+
"""Write a new 0.4 contract from an exact legacy revision without changing it.
|
|
1110
|
+
|
|
1111
|
+
The destination is published only by a hard link from a fully fsynced sibling
|
|
1112
|
+
temporary file, so an occupied target is refused and the selected legacy
|
|
1113
|
+
bytes are checked both before and immediately before publication.
|
|
1114
|
+
"""
|
|
1115
|
+
contract_path = Path(contract)
|
|
1116
|
+
out_path = Path(out)
|
|
1117
|
+
if len(expected_revision) != 64 or any(
|
|
1118
|
+
character not in "0123456789abcdef" for character in expected_revision
|
|
1119
|
+
):
|
|
1120
|
+
raise ValueError("expected_revision must be the full lowercase raw contract sha256")
|
|
1121
|
+
if contract_path.resolve() == out_path.resolve():
|
|
1122
|
+
raise ValueError("upgrade output must differ from the legacy contract")
|
|
1123
|
+
raw = contract_path.read_bytes()
|
|
1124
|
+
if hashlib.sha256(raw).hexdigest() != expected_revision:
|
|
1125
|
+
raise ValueError("legacy contract changed since its revision was selected")
|
|
1126
|
+
upgraded = upgrade_contract_text(raw.decode("utf-8")).encode("utf-8")
|
|
1127
|
+
legacy = load_contract_bytes(raw)
|
|
1128
|
+
if legacy.schema_version not in {"0.2", "0.3"}:
|
|
1129
|
+
raise ValueError("upgrade requires an explicit legacy schema_version 0.2 or 0.3")
|
|
1130
|
+
load_contract_bytes(upgraded)
|
|
1131
|
+
if contract_path.read_bytes() != raw:
|
|
1132
|
+
raise ValueError("legacy contract changed during upgrade")
|
|
1133
|
+
|
|
1134
|
+
fd, temporary_name = tempfile.mkstemp(
|
|
1135
|
+
prefix=f".{out_path.name}.", suffix=".tmp", dir=out_path.parent
|
|
1136
|
+
)
|
|
1137
|
+
try:
|
|
1138
|
+
with os.fdopen(fd, "wb") as handle:
|
|
1139
|
+
handle.write(upgraded)
|
|
1140
|
+
handle.flush()
|
|
1141
|
+
os.fsync(handle.fileno())
|
|
1142
|
+
if contract_path.read_bytes() != raw:
|
|
1143
|
+
raise ValueError("legacy contract changed during upgrade")
|
|
1144
|
+
os.link(temporary_name, out_path)
|
|
1145
|
+
finally:
|
|
1146
|
+
Path(temporary_name).unlink(missing_ok=True)
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
# ---------------------------------------------------------------------------
|
|
1150
|
+
# Shared pieces below ``FUNCTIONS``: not CLI-command twins of their own (no
|
|
1151
|
+
# ``lockwire load-scene`` or ``lockwire passed-baseline-channels``), so they
|
|
1152
|
+
# stay out of ``FUNCTIONS``/``__all__`` — but each is a computation more than
|
|
1153
|
+
# one door needs, so it lives once. Both were duplicated verbatim
|
|
1154
|
+
# between ``cli.py`` and ``serve.py``; the
|
|
1155
|
+
# sidecar now calls these instead of carrying its own copy. ``cli.py`` keeps
|
|
1156
|
+
# its own long-standing version (``_load_scene_for_cli``), a pre-existing
|
|
1157
|
+
# duplicate this narrows rather than clears.
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
def _selected_external_meshes(
|
|
1161
|
+
contract: Contract, contract_path: Path, scene_path: Path
|
|
1162
|
+
) -> Mapping[Path, str] | None:
|
|
1163
|
+
"""Return declared mesh bytes only for the contract's selected model root."""
|
|
1164
|
+
|
|
1165
|
+
if contract.model is None:
|
|
1166
|
+
return None
|
|
1167
|
+
declared = Path(contract.model.source)
|
|
1168
|
+
if not declared.is_absolute():
|
|
1169
|
+
declared = contract_path.parent / declared
|
|
1170
|
+
if declared.resolve() != scene_path.resolve():
|
|
1171
|
+
return None
|
|
1172
|
+
from lockwire.scene_urdf import mesh_root # noqa: PLC0415 - same root `start` records against
|
|
1173
|
+
|
|
1174
|
+
root = mesh_root(declared, contract_path.parent)
|
|
1175
|
+
declared_assets: dict[Path, str] = {}
|
|
1176
|
+
for asset in contract.model.external_assets:
|
|
1177
|
+
resolved = (root / asset.path).resolve()
|
|
1178
|
+
if not resolved.is_relative_to(root):
|
|
1179
|
+
from lockwire.scene import SceneRefusal # noqa: PLC0415 - public scene error
|
|
1180
|
+
|
|
1181
|
+
raise SceneRefusal(
|
|
1182
|
+
f"{contract_path}: declared external asset {asset.path!r} escapes the robot's package folder."
|
|
1183
|
+
)
|
|
1184
|
+
declared_assets[resolved] = asset.sha256
|
|
1185
|
+
return declared_assets
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
def load_scene(
|
|
1189
|
+
contract_path: Path | str, scene: Path | str | None, package_root: Iterable[Path | str] = ()
|
|
1190
|
+
) -> tuple[Any, str | None]:
|
|
1191
|
+
"""Find and load the scene named by ``--scene``, or discovered beside the
|
|
1192
|
+
contract — the one "which scene, from where" decision ``report`` and
|
|
1193
|
+
``gui`` both need. Returns ``(None, None)`` when there is nothing to load;
|
|
1194
|
+
raises ``lockwire.scene.SceneRefusal`` on a scene that fails to parse.
|
|
1195
|
+
|
|
1196
|
+
Scene *discovery* (``_find_scene``) is CLI-argument shaped and stays in
|
|
1197
|
+
``cli.py``; imported here deferred so importing ``lockwire.api`` alone never
|
|
1198
|
+
pulls in typer/rich (``test_library_import_pulls_no_server_or_cli_deps``).
|
|
1199
|
+
"""
|
|
1200
|
+
from lockwire.cli import _find_scene # noqa: PLC0415 - deferred, see docstring
|
|
1201
|
+
|
|
1202
|
+
scene_path, discovered = _find_scene(Path(contract_path), Path(scene) if scene else None)
|
|
1203
|
+
if scene_path is None:
|
|
1204
|
+
return None, None
|
|
1205
|
+
loaded_contract = load_contract(Path(contract_path))
|
|
1206
|
+
loaded_scene = load_scene_any(
|
|
1207
|
+
scene_path,
|
|
1208
|
+
package_roots=tuple(Path(p) for p in package_root),
|
|
1209
|
+
external_assets=_selected_external_meshes(loaded_contract, Path(contract_path), scene_path),
|
|
1210
|
+
)
|
|
1211
|
+
from lockwire.scene_view import scene_asset_identity
|
|
1212
|
+
|
|
1213
|
+
scene_asset_identity(loaded_contract, loaded_scene, Path(contract_path))
|
|
1214
|
+
if discovered:
|
|
1215
|
+
return loaded_scene, f"{scene_path.name} (found beside the contract)"
|
|
1216
|
+
return loaded_scene, f"{scene_path.name} (given)"
|