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/bench.py ADDED
@@ -0,0 +1,182 @@
1
+ """`lockwire bench`: score detection and forwarding separately, on a corpus.
2
+
3
+ Two measurements, never merged into one number (detection and forwarding):
4
+
5
+ 1. **Detection** — for each generated out-of-bound plan, does the *existing*
6
+ evaluator (`plan.validate_plan`, reused — this module never
7
+ re-walks a plan) flag it? Categorized as ``detected`` / ``missed`` (a
8
+ violating case not flagged; "leaked" is a detection
9
+ metric only, and a correctly-forwarded violation is a detection, not a
10
+ leak) / ``false_alarm`` (a compliant case wrongly flagged) /
11
+ ``unscorable_correct`` / ``unscorable_mismatch``.
12
+ 2. **Forwarding** — every case reaches the simulated driver byte-identical,
13
+ in order, exactly once, *regardless of verdict* (lockwire forwards
14
+ the command unchanged, never originates one, never alters one, never
15
+ withholds one — lockwire has no gate to leak past; scoring forwarding on
16
+ the verdict would build the wrong reading).
17
+
18
+ Self-proving: a corpus that scores well against an evaluator with
19
+ the checks deleted is measuring itself, not the product. `run_bench` takes
20
+ the evaluator as a parameter for exactly this reason — see
21
+ `tests/test_bench.py::test_checks_deleted_detection_rate_collapses`.
22
+
23
+ Ceiling, published in `format_report` itself, not only here: this corpus
24
+ (`tests/fixtures/bench/corpus.yaml`) is hand-authored by the same project
25
+ that wrote the checks it exercises. A bench that only scores cases it wrote
26
+ to be catchable is marketing, not measurement.
27
+ """
28
+
29
+ from dataclasses import dataclass
30
+ from pathlib import Path
31
+ from typing import Callable, Literal
32
+
33
+ from lockwire.plan import Plan, PlanReport, Step, validate_plan
34
+ from lockwire.report import Verdict, verdict
35
+ from lockwire.schema import Contract, _load_yaml_guarded, _reject_yaml_anchors_aliases, _yaml
36
+
37
+ DEFAULT_CORPUS_PATH = Path(__file__).resolve().parents[2] / "tests" / "fixtures" / "bench" / "corpus.yaml"
38
+
39
+ Category = Literal["violating", "compliant", "unscorable"]
40
+ Evaluator = Callable[[Contract, Plan], PlanReport]
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class BenchCase:
45
+ id: str
46
+ category: Category
47
+ step: Step
48
+
49
+
50
+ def load_corpus(path: Path) -> list[BenchCase]:
51
+ """Load the corpus YAML (id/category/step per case). Same guarded loader
52
+ as `plan.load_plan` and `schema.load_contract` — a corpus file is trusted
53
+ exactly as much as any other checked-in lockfile-shaped document."""
54
+ with open(path, encoding="utf-8") as f:
55
+ data = _load_yaml_guarded(_yaml(), f)
56
+ _reject_yaml_anchors_aliases(data)
57
+ cases = []
58
+ for raw in data["cases"]:
59
+ # Reuse the plan's own discriminated Step union to validate the case
60
+ # instead of a second step parser.
61
+ step = Plan.model_validate({"steps": [raw["step"]]}).steps[0]
62
+ cases.append(BenchCase(id=raw["id"], category=raw["category"], step=step))
63
+ return cases
64
+
65
+
66
+ def _score(contract: Contract, step: Step, evaluator: Evaluator) -> Verdict:
67
+ return verdict(evaluator(contract, Plan(steps=[step])))
68
+
69
+
70
+ def _classify(case: BenchCase, v: Verdict) -> str:
71
+ if case.category == "violating":
72
+ return "detected" if v.code == "NO_GO" else "missed"
73
+ if case.category == "compliant":
74
+ return "false_alarm" if v.code == "NO_GO" else "correct_pass"
75
+ return "unscorable_correct" if v.code == "NOT_SCORED" else "unscorable_mismatch"
76
+
77
+
78
+ def score_and_forward(
79
+ contract: Contract, step: Step, evaluator: Evaluator = validate_plan
80
+ ) -> tuple[Verdict, Step]:
81
+ """Score one step and forward it, unaltered, to the simulated driver.
82
+
83
+ The forward always happens: lockwire has no gate, so nothing it
84
+ scores is ever withheld, including a step scored NO-GO or NOT SCORED.
85
+ Returns ``(verdict, the forwarded step)``; the caller appends the
86
+ forwarded step to its sink so byte-identity is checkable independently
87
+ of the verdict.
88
+ """
89
+ forwarded = step.model_copy(deep=True)
90
+ return _score(contract, step, evaluator), forwarded
91
+
92
+
93
+ @dataclass(frozen=True, slots=True)
94
+ class BenchResult:
95
+ corpus_size: int
96
+ detected: int
97
+ missed: int
98
+ false_alarms: int
99
+ correct_pass: int
100
+ unscorable_correct: int
101
+ unscorable_mismatch: int
102
+ forwarded: int
103
+
104
+ @property
105
+ def violating_total(self) -> int:
106
+ return self.detected + self.missed
107
+
108
+ @property
109
+ def detection_rate(self) -> float | None:
110
+ if self.violating_total == 0:
111
+ return None
112
+ return self.detected / self.violating_total
113
+
114
+ @property
115
+ def exit_code(self) -> int:
116
+ # The bench tool's own exit reflects whether a hole was caught, not
117
+ # the verdict words of the individual plans it scored (accept line).
118
+ return 1 if self.missed else 0
119
+
120
+
121
+ _BUCKETS = (
122
+ "detected",
123
+ "missed",
124
+ "false_alarm",
125
+ "correct_pass",
126
+ "unscorable_correct",
127
+ "unscorable_mismatch",
128
+ )
129
+
130
+
131
+ def run_bench(
132
+ contract: Contract, cases: list[BenchCase], evaluator: Evaluator = validate_plan
133
+ ) -> tuple[BenchResult, list[Step]]:
134
+ """Run every case: score it (detection) and forward it (always). Returns
135
+ the tallied result plus the simulated driver's received-step sink, in
136
+ the order the corpus was walked."""
137
+ counts = dict.fromkeys(_BUCKETS, 0)
138
+ sink: list[Step] = []
139
+ for case in cases:
140
+ v, forwarded = score_and_forward(contract, case.step, evaluator)
141
+ sink.append(forwarded)
142
+ counts[_classify(case, v)] += 1
143
+ result = BenchResult(
144
+ corpus_size=len(cases),
145
+ detected=counts["detected"],
146
+ missed=counts["missed"],
147
+ false_alarms=counts["false_alarm"],
148
+ correct_pass=counts["correct_pass"],
149
+ unscorable_correct=counts["unscorable_correct"],
150
+ unscorable_mismatch=counts["unscorable_mismatch"],
151
+ forwarded=len(sink),
152
+ )
153
+ return result, sink
154
+
155
+
156
+ def format_report(result: BenchResult, corpus_path: Path) -> str:
157
+ """The published detection rate, plain words — what the corpus is, how
158
+ many cases, what a catch counts as, and the self-scoring ceiling, all in
159
+ the output itself (not only in a docstring)."""
160
+ rate = result.detection_rate
161
+ rate_str = "n/a (no violating cases in the corpus)" if rate is None else f"{rate:.0%}"
162
+ return "\n".join(
163
+ [
164
+ f"corpus: {corpus_path} — {result.corpus_size} hand-authored cases",
165
+ f"detection rate: {rate_str} "
166
+ f"({result.detected}/{result.violating_total} violating cases flagged NO-GO; "
167
+ 'a "catch" = the existing plan evaluator scores the case NO_GO)',
168
+ f"missed (leaked — a violating case the evaluator did not flag): {result.missed}",
169
+ f"false alarms (a compliant case wrongly flagged NO-GO): {result.false_alarms}",
170
+ f"unscorable cases correctly NOT SCORED: {result.unscorable_correct}"
171
+ + (
172
+ f" ({result.unscorable_mismatch} scored when they should not have been)"
173
+ if result.unscorable_mismatch
174
+ else ""
175
+ ),
176
+ f"forwarded to the simulated driver: {result.forwarded}/{result.corpus_size}, "
177
+ "unchanged, in order, once each, regardless of verdict (never withheld)",
178
+ "ceiling: this corpus is hand-authored by the same project that wrote the checks "
179
+ "it exercises; a bench that scores only cases it authored to be catchable is "
180
+ "marketing, not an independent measurement.",
181
+ ]
182
+ )
lockwire/blame.py ADDED
@@ -0,0 +1,100 @@
1
+ """One provenance row per declared record, optionally attributed by git."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ from collections.abc import Iterator
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from ruamel.yaml import YAML
11
+
12
+ from lockwire import gitref
13
+ from lockwire.schema import load_contract
14
+
15
+
16
+ def _path_part(item: Any, index: int) -> str:
17
+ if isinstance(item, dict):
18
+ for key in ("id", "name"):
19
+ if key in item:
20
+ return str(item[key])
21
+ return str(index)
22
+
23
+
24
+ def _plain(value: Any) -> Any:
25
+ if isinstance(value, dict):
26
+ return {str(key): _plain(child) for key, child in value.items()}
27
+ if isinstance(value, list):
28
+ return [_plain(child) for child in value]
29
+ if isinstance(value, (dt.date, dt.datetime)):
30
+ return value.isoformat()
31
+ return value
32
+
33
+
34
+ def _declared(node: Any, path: tuple[str, ...] = ()) -> Iterator[dict[str, Any]]:
35
+ if isinstance(node, dict):
36
+ provenance = node.get("provenance")
37
+ if isinstance(provenance, dict):
38
+ lines = {node.lc.key("provenance")[0] + 1}
39
+ lines.update(provenance.lc.key(key)[0] + 1 for key in provenance)
40
+ row = {
41
+ "path": "/".join(path),
42
+ "value": _plain({key: value for key, value in node.items() if key != "provenance"}),
43
+ "witness": str(provenance["witness"]),
44
+ "source": str(provenance["source"]),
45
+ "locked_at": str(provenance["locked_at"]),
46
+ "lines": lines,
47
+ }
48
+ if provenance.get("compared_from") is not None:
49
+ row["compared_from"] = str(provenance["compared_from"])
50
+ yield row
51
+ for key, child in node.items():
52
+ if key != "provenance":
53
+ yield from _declared(child, (*path, str(key)))
54
+ elif isinstance(node, list):
55
+ for index, child in enumerate(node):
56
+ yield from _declared(child, (*path, _path_part(child, index)))
57
+
58
+
59
+ def _commits_by_line(porcelain: str) -> dict[int, str]:
60
+ """Per-line commit shas from a ``git blame --porcelain`` transcript."""
61
+ commits: dict[int, str] = {}
62
+ for line in porcelain.splitlines():
63
+ fields = line.split()
64
+ if (
65
+ len(fields) >= 3
66
+ and len(fields[0]) == 40
67
+ and all(c in "0123456789abcdef" for c in fields[0])
68
+ ):
69
+ commits[int(fields[2])] = fields[0]
70
+ return commits
71
+
72
+
73
+ def rows(contract: Path) -> tuple[list[dict[str, Any]], bool]:
74
+ """Validated provenance rows and whether the path belongs to a repository.
75
+
76
+ ``last_changing_commit`` is omitted when git has no honest attribution (for
77
+ example an untracked file). The stored witness is rendered verbatim: a
78
+ persisted ``" (default)"`` marker is evidence; machine configuration is not.
79
+ """
80
+ load_contract(contract)
81
+ document = YAML(typ="rt").load(contract)
82
+ result = list(_declared(document))
83
+
84
+ repo = gitref.repo_root(contract)
85
+ relpath = gitref.relpath_in_repo(contract, repo) if repo is not None else None
86
+ porcelain = gitref.blame(relpath, repo=repo) if relpath and repo else None
87
+ commits = _commits_by_line(porcelain or "")
88
+ history = (
89
+ gitref.log(None, [relpath], repo=repo, follow=True)
90
+ if porcelain and relpath and repo
91
+ else None
92
+ )
93
+ order = {sha: index for index, (sha, _, _, _) in enumerate(history or [])}
94
+ for row in result:
95
+ candidates = {commits[line] for line in row.pop("lines") if line in commits}
96
+ candidates.discard("0" * 40)
97
+ attributed = [(order[sha], sha) for sha in candidates if sha in order]
98
+ if attributed:
99
+ row["last_changing_commit"] = min(attributed)[1]
100
+ return result, repo is not None
lockwire/catalog.py ADDED
@@ -0,0 +1,100 @@
1
+ """The parts catalog — a library of parts a user might pick from.
2
+
3
+ Deliberately NOT part of the contract schema (`schema.py`, frozen). A contract is a
4
+ declared, witnessed statement about the robot you have; a catalog is a library of parts you might
5
+ use to build one. They answer different questions and don't share a lifecycle — a catalog entry
6
+ changes when a vendor revises a datasheet, a contract entry changes when this robot's build
7
+ changes. Keeping the catalog its own model in its own file means the freeze on `schema.py` is
8
+ never touched: no field here is a contract field, so there is nothing to validate against
9
+ the contract schema.
10
+
11
+ Every number on a catalog part carries a `kind` and a `source`: a real
12
+ number quotes where it came from; a placeholder says plainly that it is a placeholder. Nothing on
13
+ a catalog part is presented as a real number without one of these two badges — an unlabelled
14
+ number is a bug in the seed data, not a permitted state (see `_source_not_blank` below and
15
+ `tests/test_catalog.py::test_every_seeded_number_has_a_kind_and_a_source`).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from pathlib import Path
21
+ from typing import Literal
22
+
23
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
24
+ from ruamel.yaml import YAML
25
+
26
+ _SEED_PATH = Path(__file__).parent / "catalog_seed.yaml"
27
+
28
+ # "datasheet": a manufacturer datasheet, spec page or product-listing figure, cited by URL/name.
29
+ # "source_file": a real number read out of a file already in this repo (e.g. a vendor URDF's
30
+ # <mass> tag) — real, but the citation is a file, not a published datasheet.
31
+ # "assumed": fabricated for seeding, no real part backs it; the source string must say so.
32
+ NumberKind = Literal["datasheet", "source_file", "assumed"]
33
+
34
+
35
+ class SourcedNumber(BaseModel):
36
+ """One number on a catalog part, badged with where it came from."""
37
+
38
+ model_config = ConfigDict(extra="forbid")
39
+
40
+ value: float
41
+ unit: str
42
+ kind: NumberKind
43
+ source: str
44
+
45
+ @field_validator("source")
46
+ @classmethod
47
+ def _source_not_blank(cls, v: str) -> str:
48
+ if not v.strip():
49
+ raise ValueError("source must not be blank — a number without a source is not allowed")
50
+ return v
51
+
52
+
53
+ class CatalogPart(BaseModel):
54
+ """One entry in the catalog: a part a user might pick, not a claim about a robot they have.
55
+
56
+ Reconciliation with `schema.py`'s `DeviceModel`: two vocabularies for "a part"
57
+ now exist and they are deliberately not one. `CatalogPart` is the SHOPPING vocabulary — a part
58
+ you might pick, sourced numbers, no robot attached. `DeviceModel` is the REGISTRY vocabulary —
59
+ checks, hard limits and known failure modes for a device on a robot you actually have. They
60
+ share only `id` and `family`; nothing else lines up because the two answer different
61
+ questions and don't share a lifecycle (see the module docstring). A consumer that needs both
62
+ must go through one door (the one-door rule), not read both models directly — `grep -rln
63
+ CatalogPart src/lockwire/ tests/` names every module that may reference this class, and
64
+ `test_no_module_straddles_catalog_and_registry_vocabularies` below fails the day that set
65
+ grows to include a module that also references `DeviceModel`. Merging the two, if ever wanted,
66
+ is a `schema.py` change and therefore a maintainer's call — not a refactor to do
67
+ on a reading of "decide the reconciliation."
68
+ """
69
+
70
+ model_config = ConfigDict(extra="forbid")
71
+
72
+ id: str
73
+ family: str
74
+ name: str
75
+ numbers: dict[str, SourcedNumber] = Field(default_factory=dict)
76
+ note: str | None = None
77
+
78
+
79
+ class Catalog(BaseModel):
80
+ model_config = ConfigDict(extra="forbid")
81
+
82
+ parts: list[CatalogPart]
83
+
84
+ @field_validator("parts")
85
+ @classmethod
86
+ def _ids_unique(cls, v: list[CatalogPart]) -> list[CatalogPart]:
87
+ seen: set[str] = set()
88
+ for part in v:
89
+ if part.id in seen:
90
+ raise ValueError(f"duplicate catalog part id: {part.id}")
91
+ seen.add(part.id)
92
+ return v
93
+
94
+
95
+ def load_catalog(path: Path | str | None = None) -> Catalog:
96
+ """Read the catalog from `path` (default: the seeded catalog shipped with lockwire)."""
97
+ yaml = YAML(typ="safe")
98
+ p = Path(path) if path is not None else _SEED_PATH
99
+ data = yaml.load(p.read_text())
100
+ return Catalog.model_validate(data)