lockwire 0.2.0__py3-none-any.whl

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