lockwire 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. lockwire/__init__.py +9 -0
  2. lockwire/__main__.py +9 -0
  3. lockwire/advice.py +119 -0
  4. lockwire/api.py +1216 -0
  5. lockwire/api_payload.py +985 -0
  6. lockwire/bench.py +182 -0
  7. lockwire/blame.py +100 -0
  8. lockwire/catalog.py +100 -0
  9. lockwire/catalog_seed.yaml +545 -0
  10. lockwire/checks.py +3483 -0
  11. lockwire/cli.py +1799 -0
  12. lockwire/cli_line.py +73 -0
  13. lockwire/constraints.py +313 -0
  14. lockwire/copy.py +302 -0
  15. lockwire/describe.py +682 -0
  16. lockwire/edit.py +2098 -0
  17. lockwire/emit_ts.py +150 -0
  18. lockwire/export_mhs.py +115 -0
  19. lockwire/factdiff.py +254 -0
  20. lockwire/forces.py +334 -0
  21. lockwire/gitref.py +188 -0
  22. lockwire/hw.py +388 -0
  23. lockwire/mcp_server.py +276 -0
  24. lockwire/mechanism.py +379 -0
  25. lockwire/mesh_dae.py +555 -0
  26. lockwire/mesh_simple.py +198 -0
  27. lockwire/page.py +1485 -0
  28. lockwire/plan.py +849 -0
  29. lockwire/plan_payload.py +151 -0
  30. lockwire/plugins.py +70 -0
  31. lockwire/report.py +848 -0
  32. lockwire/report_html.py +707 -0
  33. lockwire/report_json.py +168 -0
  34. lockwire/review_draft.py +315 -0
  35. lockwire/scan.py +732 -0
  36. lockwire/scene.py +535 -0
  37. lockwire/scene_import.py +309 -0
  38. lockwire/scene_urdf.py +750 -0
  39. lockwire/scene_view.py +376 -0
  40. lockwire/schema.py +3616 -0
  41. lockwire/sdf.py +240 -0
  42. lockwire/serve.py +1256 -0
  43. lockwire/start_operation.py +92 -0
  44. lockwire/statics.py +77 -0
  45. lockwire/units.py +115 -0
  46. lockwire/urdf.py +348 -0
  47. lockwire/vcs.py +402 -0
  48. lockwire/whatif.py +161 -0
  49. lockwire-0.2.0.dist-info/METADATA +192 -0
  50. lockwire-0.2.0.dist-info/RECORD +52 -0
  51. lockwire-0.2.0.dist-info/WHEEL +4 -0
  52. lockwire-0.2.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,168 @@
1
+ """The machine-readable preflight document.
2
+
3
+ `lockwire preflight --json <path>` writes one JSON file describing the same run
4
+ the terminal just rendered. It exists because an out-of-process consumer — the
5
+ `lockwire_ros` gate node is the first — must read a verdict as *data*, and the
6
+ alternative is scraping rich-rendered prose. A gate whose consumer parses
7
+ colour codes is a gate that breaks silently the first time a message is reworded.
8
+
9
+ Three properties this file is responsible for, each load-bearing:
10
+
11
+ - **The verdict is not recomputed here.** ``report.verdict`` decides GO/NO-GO
12
+ once; this module reports what it decided. A second implementation of the
13
+ exit map would eventually disagree with the terminal, and a gate that
14
+ disagrees with itself is worse than no gate.
15
+ - **`not_scored` findings are carried, not filtered.** They are the half the
16
+ product exists to say out loud (`docs/gate_coverage.md`). A document that
17
+ emitted only violations would let a consumer render a clean green screen over
18
+ an admitted coverage hole.
19
+ - **A vacuous pass is visibly distinct.** ``NOT_SCORED`` (exit 3) and ``scored``
20
+ travel together so no consumer can read "nothing was checked" as clearance.
21
+
22
+ Writing this file never changes the verdict — like ``--html``, it is an
23
+ additional output of a run that already happened.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ from lockwire.report import Finding, RecordReport, limit_raised_mark, verdict, verdict_word
33
+ from lockwire.schema import SCHEMA_VERSION, Contract
34
+
35
+ ARTIFACT_TYPE = "lockwire-preflight-report"
36
+ HEALTH_ARTIFACT_TYPE = "lockwire-health-report"
37
+ CHECK_ARTIFACT_TYPE = "lockwire-check-report"
38
+ PLAN_ARTIFACT_TYPE = "lockwire-plan-report"
39
+ BASELINE_CHECK_ARTIFACT_TYPE = "lockwire-baseline-check-report"
40
+ #: Bumped only when the document's shape changes in a way a consumer must notice.
41
+ #: Independent of the lockfile ``SCHEMA_VERSION``, which is also reported below.
42
+ #: "2": ``verdict`` now spells NO-GO hyphenated, byte-identical to the
43
+ #: terminal, instead of the raw ``NO_GO`` Python identifier — a breaking change
44
+ #: for any consumer comparing the raw string.
45
+ FORMAT_VERSION = "2"
46
+
47
+
48
+ #: The keys ``envelope`` owns. A body may not use them — see the guard below.
49
+ HEADER_KEYS = ("artifact_type", "format_version", "schema_version")
50
+
51
+
52
+ def envelope(artifact_type: str, body: dict[str, Any]) -> dict[str, Any]:
53
+ """Lead any machine-readable document with the same three header fields.
54
+
55
+ ``artifact_type`` says which document this is, and is the only key a
56
+ consumer needs to read before it knows how to read the rest; the two
57
+ versions say what may have changed under it — ``FORMAT_VERSION`` the
58
+ envelope and body shape, ``SCHEMA_VERSION`` the lockfile the run used.
59
+ One helper, so a second machine door cannot be added unversioned: the
60
+ body is merged flat behind the header rather than nested, because
61
+ ``preflight``'s document is already flat and a consumer already reads it.
62
+
63
+ The flat merge is what makes a colliding body key dangerous: a body with
64
+ its own ``format_version`` would silently take the header's place (or be
65
+ taken by it, depending which side won), and the consumers that read the
66
+ header would be reading a body field without knowing. So a collision is
67
+ refused loudly at the one point every document passes through, rather than
68
+ left for whoever notices the wrong number months later.
69
+ """
70
+ clash = sorted(set(body) & set(HEADER_KEYS))
71
+ if clash:
72
+ raise ValueError(
73
+ f"{artifact_type}: body key(s) {clash} collide with the envelope header; "
74
+ f"the header owns {list(HEADER_KEYS)} and the body is merged flat behind it"
75
+ )
76
+ return {
77
+ "artifact_type": artifact_type,
78
+ "format_version": FORMAT_VERSION,
79
+ "schema_version": SCHEMA_VERSION,
80
+ **body,
81
+ }
82
+
83
+
84
+ def preflight_document(
85
+ contract: Contract,
86
+ findings: list[Finding],
87
+ recording: str,
88
+ ) -> dict[str, Any]:
89
+ """Build the JSON-ready document for one preflight run.
90
+
91
+ ``findings`` is whatever ``run_preflight`` returned; when that is a
92
+ ``PreflightReport`` the per-stage scored counts ride along, and when it is a
93
+ plain list they are simply absent rather than invented.
94
+ """
95
+ decision = verdict(findings)
96
+ scored_by_stage = getattr(findings, "scored_by_stage", None)
97
+ document = envelope(
98
+ ARTIFACT_TYPE,
99
+ {
100
+ "robot": contract.robot.name,
101
+ "source": recording,
102
+ # Hyphenated: byte-identical to the terminal's spelling, not
103
+ # the raw underscored VerdictCode.
104
+ "verdict": verdict_word(decision.code),
105
+ "exit_code": decision.exit_code,
106
+ "counts": {
107
+ "violation": decision.violations,
108
+ "warning": decision.warnings,
109
+ "not_scored": decision.not_scored,
110
+ },
111
+ "scored": decision.scored,
112
+ "profile": getattr(findings, "profile", "analysis"),
113
+ "required_evidence": getattr(findings, "required_evidence", []),
114
+ "missing_required_evidence": getattr(findings, "missing_required_evidence", []),
115
+ # every limit raised to clear a violation, as data. A cleared
116
+ # budget emits no finding, so without this key the one edit that can make
117
+ # a robot look safe by moving the goalposts is invisible to a consumer.
118
+ # An annotation only: the verdict and exit code above are untouched.
119
+ # rendered regardless of the budget's current severity — the
120
+ # mark must survive a later re-breach, not vanish exactly when a raised
121
+ # limit turns out not to have held (see report.limit_raised_mark).
122
+ "limits_raised": [
123
+ {"budget": budget.id, "mark": mark}
124
+ for budget in contract.budgets
125
+ if (mark := limit_raised_mark(budget.provenance)) is not None
126
+ ],
127
+ "findings": [finding.model_dump(mode="json") for finding in findings],
128
+ },
129
+ )
130
+ if scored_by_stage is not None:
131
+ # JSON object keys are strings; stage identity stays an integer in Python.
132
+ document["scored_by_stage"] = {
133
+ str(stage): n for stage, n in sorted(scored_by_stage.items())
134
+ }
135
+ return document
136
+
137
+
138
+ def baseline_check_document(report: RecordReport, recording: str) -> dict[str, Any]:
139
+ """Build the JSON document for the baseline-check report already rendered."""
140
+ decision = verdict(report)
141
+ return envelope(
142
+ BASELINE_CHECK_ARTIFACT_TYPE,
143
+ {
144
+ "source": recording,
145
+ "verdict": verdict_word(decision.code),
146
+ "exit_code": decision.exit_code,
147
+ "counts": {
148
+ "violation": decision.violations,
149
+ "warning": decision.warnings,
150
+ "not_scored": decision.not_scored,
151
+ },
152
+ "scored": decision.scored,
153
+ "findings": [finding.model_dump(mode="json") for finding in report],
154
+ },
155
+ )
156
+
157
+
158
+ def write_preflight_json(
159
+ contract: Contract,
160
+ findings: list[Finding],
161
+ recording: str,
162
+ out: Path,
163
+ ) -> None:
164
+ """Write one preflight run as JSON, replacing ``out``, re-running nothing."""
165
+ document = preflight_document(contract, findings, recording)
166
+ # sort_keys is off on purpose: the top-level order above reads verdict-first,
167
+ # which is what a human debugging a node's input wants to see.
168
+ out.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
@@ -0,0 +1,315 @@
1
+ """Read-only, evidence-bound correction drafts for engineer review.
2
+
3
+ Drafting is deliberately not an edit, a command, or evidence of an applied
4
+ correction. An engineer must take any later write through the existing edit
5
+ door with its own current revision check.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import datetime as dt
11
+ import hashlib
12
+ import json
13
+ import re
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Literal
17
+
18
+ from pydantic import BaseModel, ConfigDict
19
+
20
+ from lockwire.edit import Change, EditProposal, EditRefusal, propose_changes_from_bytes
21
+ from lockwire.schema import ArtifactRef, read_artifact
22
+
23
+
24
+ CorrectionCategory = Literal["physical", "model_calibration", "spec_design"]
25
+ NonSpecTarget = Literal["physical", "firmware", "configuration", "model", "calibration"]
26
+
27
+
28
+ class ReviewSpecChange(BaseModel):
29
+ """One authored, non-applying actuator spec change at the public draft door."""
30
+
31
+ model_config = ConfigDict(extra="forbid", strict=True)
32
+
33
+ path: str
34
+ value: str
35
+ source: str
36
+ witness: str
37
+ locked_at: str
38
+ unit: str | None = None
39
+ note: str | None = None
40
+
41
+ def to_change(self) -> Change:
42
+ locked_at = dt.date.fromisoformat(self.locked_at)
43
+ if locked_at.isoformat() != self.locked_at:
44
+ raise ValueError("proposed change locked_at must be YYYY-MM-DD")
45
+ return Change(
46
+ self.path,
47
+ self.value,
48
+ self.source,
49
+ self.witness,
50
+ note=self.note,
51
+ locked_at=locked_at,
52
+ unit=self.unit,
53
+ )
54
+
55
+
56
+ def required_correction_category(proposal_kind: str) -> CorrectionCategory | None:
57
+ """The correction classes supported by an actual validated proposal kind."""
58
+ if proposal_kind == "lockwire_spec":
59
+ return "spec_design"
60
+ if proposal_kind == "physical":
61
+ return "physical"
62
+ if proposal_kind in {"model", "calibration"}:
63
+ return "model_calibration"
64
+ return None
65
+
66
+ _IDENTITY = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*\Z")
67
+
68
+
69
+ class ReviewDraftRefusal(ValueError):
70
+ """A draft lacks reviewable, bound information."""
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class EvidenceCitation:
75
+ """One hash-verified artifact bound to identified results and claims."""
76
+
77
+ artifact: ArtifactRef
78
+ provenance: str
79
+ result_id: str
80
+ claim_id: str
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class AffectedResult:
85
+ """A result that cannot support the named current claims until rechecked."""
86
+
87
+ result_id: str
88
+ claim_ids: tuple[str, ...]
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class NonSpecProposal:
93
+ """A typed, non-applying patch for state outside the Lockwire spec."""
94
+
95
+ target: NonSpecTarget
96
+ patch: str
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class ReviewDraft:
101
+ """A deterministic proposal only; it has no application operation."""
102
+
103
+ base_raw_revision: str
104
+ discrepancy: str
105
+ evidence: tuple[EvidenceCitation, ...]
106
+ alternatives: tuple[str, ...]
107
+ proposed_patch: EditProposal | NonSpecProposal
108
+ rationale: str
109
+ correction_category: CorrectionCategory
110
+ affected_results: tuple[AffectedResult, ...]
111
+ required_recheck: tuple[str, ...]
112
+
113
+ def to_json(self) -> str:
114
+ """Stable review payload, preserving no caller-dependent ordering."""
115
+ if isinstance(self.proposed_patch, EditProposal):
116
+ proposed_patch = {
117
+ "kind": "lockwire_spec",
118
+ "patch": self.proposed_patch.result.diff,
119
+ }
120
+ else:
121
+ proposed_patch = {
122
+ "kind": self.proposed_patch.target,
123
+ "patch": self.proposed_patch.patch,
124
+ }
125
+ return json.dumps(
126
+ {
127
+ "affected_results": [
128
+ {"claim_ids": item.claim_ids, "result_id": item.result_id}
129
+ for item in self.affected_results
130
+ ],
131
+ "alternatives": self.alternatives,
132
+ "base_raw_revision": self.base_raw_revision,
133
+ "correction_category": self.correction_category,
134
+ "discrepancy": self.discrepancy,
135
+ "evidence": [
136
+ {
137
+ "artifact": {"path": item.artifact.path, "sha256": item.artifact.sha256},
138
+ "claim_id": item.claim_id,
139
+ "provenance": item.provenance,
140
+ "result_id": item.result_id,
141
+ }
142
+ for item in self.evidence
143
+ ],
144
+ "proposed_patch": proposed_patch,
145
+ "rationale": self.rationale,
146
+ "required_recheck": self.required_recheck,
147
+ },
148
+ separators=(",", ":"),
149
+ sort_keys=True,
150
+ )
151
+
152
+
153
+ def _required(value: str, *, name: str) -> str:
154
+ if not isinstance(value, str) or not value.strip():
155
+ raise ReviewDraftRefusal(f"{name} is required")
156
+ return value.strip()
157
+
158
+
159
+ def _named(values: tuple[str, ...], *, name: str) -> tuple[str, ...]:
160
+ if not values:
161
+ raise ReviewDraftRefusal(f"{name} is required")
162
+ result = tuple(sorted({_required(value, name=name) for value in values}))
163
+ if not result:
164
+ raise ReviewDraftRefusal(f"{name} is required")
165
+ return result
166
+
167
+
168
+ def _identities(values: tuple[str, ...], *, name: str) -> tuple[str, ...]:
169
+ result = _named(values, name=name)
170
+ if any(_IDENTITY.fullmatch(value) is None for value in result):
171
+ raise ReviewDraftRefusal(f"{name} must contain stable identifiers")
172
+ return result
173
+
174
+
175
+ def prepare_review_draft(
176
+ contract_path: Path | str,
177
+ *,
178
+ base_raw_revision: str,
179
+ discrepancy: str,
180
+ evidence: tuple[EvidenceCitation, ...],
181
+ alternatives: tuple[str, ...],
182
+ proposed_patch: list[Change] | NonSpecProposal,
183
+ rationale: str,
184
+ correction_category: CorrectionCategory,
185
+ affected_results: tuple[AffectedResult, ...],
186
+ required_recheck: tuple[str, ...],
187
+ evidence_root: Path | str,
188
+ ) -> ReviewDraft:
189
+ """Validate a cited correction draft without writing any product state."""
190
+ contract = Path(contract_path)
191
+ raw_contract = contract.read_bytes()
192
+ if hashlib.sha256(raw_contract).hexdigest() != base_raw_revision:
193
+ raise ReviewDraftRefusal(
194
+ "base_raw_revision is stale or does not name the current raw contract bytes"
195
+ )
196
+ if correction_category not in {"physical", "model_calibration", "spec_design"}:
197
+ raise ReviewDraftRefusal(
198
+ "correction_category must be physical, model_calibration, or spec_design"
199
+ )
200
+ if not evidence:
201
+ raise ReviewDraftRefusal("discrepancy requires cited evidence")
202
+
203
+ results: list[AffectedResult] = []
204
+ for affected in affected_results:
205
+ if not isinstance(affected, AffectedResult):
206
+ raise ReviewDraftRefusal("affected_results must contain AffectedResult values")
207
+ results.append(
208
+ AffectedResult(
209
+ result_id=_identities((affected.result_id,), name="result_id")[0],
210
+ claim_ids=_identities(affected.claim_ids, name="claim_ids"),
211
+ )
212
+ )
213
+ ordered_results = tuple(sorted(results, key=lambda item: item.result_id))
214
+ if not ordered_results:
215
+ raise ReviewDraftRefusal("affected_results is required")
216
+ if len({item.result_id for item in ordered_results}) != len(ordered_results):
217
+ raise ReviewDraftRefusal("affected result_ids must be unique")
218
+ claims_by_result = {item.result_id: set(item.claim_ids) for item in ordered_results}
219
+
220
+ citations: list[EvidenceCitation] = []
221
+ for citation in evidence:
222
+ if not isinstance(citation, EvidenceCitation):
223
+ raise ReviewDraftRefusal("evidence must contain EvidenceCitation values")
224
+ provenance = _required(citation.provenance, name="evidence provenance")
225
+ result_id = _identities((citation.result_id,), name="evidence result_id")[0]
226
+ claim_id = _identities((citation.claim_id,), name="evidence claim_id")[0]
227
+ if result_id not in claims_by_result:
228
+ raise ReviewDraftRefusal("evidence result_id must name an affected result")
229
+ if claim_id not in claims_by_result[result_id]:
230
+ raise ReviewDraftRefusal("evidence claim_id must name a claim of its affected result")
231
+ try:
232
+ read_artifact(citation.artifact, Path(evidence_root))
233
+ except (OSError, ValueError) as error:
234
+ raise ReviewDraftRefusal(
235
+ f"evidence {citation.artifact.path!r} is not admissible: {error}"
236
+ ) from error
237
+ citations.append(EvidenceCitation(citation.artifact, provenance, result_id, claim_id))
238
+
239
+ ordered_evidence = tuple(
240
+ sorted(
241
+ citations,
242
+ key=lambda item: (
243
+ item.artifact.path,
244
+ item.artifact.sha256,
245
+ item.provenance,
246
+ item.result_id,
247
+ item.claim_id,
248
+ ),
249
+ )
250
+ )
251
+ citation_keys = {
252
+ (
253
+ item.artifact.path,
254
+ item.artifact.sha256,
255
+ item.provenance,
256
+ item.result_id,
257
+ item.claim_id,
258
+ )
259
+ for item in ordered_evidence
260
+ }
261
+ if len(citation_keys) != len(ordered_evidence):
262
+ raise ReviewDraftRefusal("evidence citations must be unique")
263
+
264
+ if isinstance(proposed_patch, list):
265
+ if correction_category != required_correction_category("lockwire_spec"):
266
+ raise ReviewDraftRefusal(
267
+ "a Lockwire spec proposal requires correction_category spec_design"
268
+ )
269
+ if any(not isinstance(change, Change) for change in proposed_patch):
270
+ raise ReviewDraftRefusal("Lockwire spec proposal must contain Change values")
271
+ if any(change.locked_at is None for change in proposed_patch):
272
+ raise ReviewDraftRefusal(
273
+ "Lockwire spec proposal changes require locked_at for deterministic review"
274
+ )
275
+ try:
276
+ proposal: EditProposal | NonSpecProposal = propose_changes_from_bytes(
277
+ contract, raw_contract, proposed_patch
278
+ )
279
+ except EditRefusal as error:
280
+ raise ReviewDraftRefusal(f"Lockwire spec proposal is not valid: {error}") from error
281
+ elif isinstance(proposed_patch, NonSpecProposal):
282
+ if proposed_patch.target not in {
283
+ "physical",
284
+ "firmware",
285
+ "configuration",
286
+ "model",
287
+ "calibration",
288
+ }:
289
+ raise ReviewDraftRefusal("non-spec proposal target is not supported")
290
+ category_for_target = required_correction_category(proposed_patch.target)
291
+ if category_for_target is None:
292
+ raise ReviewDraftRefusal(
293
+ f"{proposed_patch.target} proposal has no supported correction_category binding"
294
+ )
295
+ if correction_category != category_for_target:
296
+ raise ReviewDraftRefusal(
297
+ f"{proposed_patch.target} proposal requires correction_category {category_for_target}"
298
+ )
299
+ proposal = NonSpecProposal(
300
+ proposed_patch.target,
301
+ _required(proposed_patch.patch, name="proposed_patch"),
302
+ )
303
+ else:
304
+ raise ReviewDraftRefusal("proposed_patch must be a list of Change or NonSpecProposal")
305
+ return ReviewDraft(
306
+ base_raw_revision=base_raw_revision,
307
+ discrepancy=_required(discrepancy, name="discrepancy"),
308
+ evidence=ordered_evidence,
309
+ alternatives=_named(alternatives, name="alternatives"),
310
+ proposed_patch=proposal,
311
+ rationale=_required(rationale, name="rationale"),
312
+ correction_category=correction_category,
313
+ affected_results=ordered_results,
314
+ required_recheck=_named(required_recheck, name="required_recheck"),
315
+ )