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/report_json.py
ADDED
|
@@ -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")
|
lockwire/review_draft.py
ADDED
|
@@ -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
|
+
)
|