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/emit_ts.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Emit `app/src/shared/api.gen.ts` directly from the payload union (e1-d3).
|
|
2
|
+
|
|
3
|
+
Plan v8 line 265, verbatim: *"There is no TypeScript generator in the list,
|
|
4
|
+
deliberately: the mainstream generator's own documentation says a
|
|
5
|
+
discriminated union like ours comes out in a shape TypeScript cannot narrow.
|
|
6
|
+
The Python step that builds the union emits the TypeScript directly (about
|
|
7
|
+
forty lines), and CI fails if the committed file is stale."*
|
|
8
|
+
|
|
9
|
+
Measured 2026-09-08: the alternative (`api-schema`'s JSON Schema piped through
|
|
10
|
+
the `openapi-typescript` npm package, per `packaging/generate-types.md`) does
|
|
11
|
+
not run on this repo's toolchain today — `openapi-typescript@7.13.0` declares
|
|
12
|
+
a peer on `typescript@^5.x` and crashes (`Cannot read properties of undefined
|
|
13
|
+
(reading 'createKeywordTypeNode')`) against `app/package.json`'s already-pinned
|
|
14
|
+
`typescript@7.0.2`. That is a second, independent reason (beyond v8 line 265)
|
|
15
|
+
this box adds no npm dependency.
|
|
16
|
+
|
|
17
|
+
this walks `api_payload.ALL_MEMBERS` and each model's own
|
|
18
|
+
`model_json_schema()` — the same schema `api_payload.export_openapi` already
|
|
19
|
+
assembles for `lockwire api-schema` — rather than re-enumerating fields or
|
|
20
|
+
commands by hand. One walk, reused.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import re
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from lockwire import plugins
|
|
31
|
+
from lockwire.api_payload import ALL_MEMBERS, MEMBERS
|
|
32
|
+
from lockwire.report import VERDICT_EXIT_CODES, verdict_word
|
|
33
|
+
|
|
34
|
+
#: Every consumer must see this before touching the file by hand.
|
|
35
|
+
SENTINEL = (
|
|
36
|
+
"// GENERATED — do not edit by hand.\n"
|
|
37
|
+
"// `uv run lockwire api-types --out app/src/shared/api.gen.ts` regenerates this\n"
|
|
38
|
+
"// file from `src/lockwire/api_payload.py`. CI regenerates and diffs on every run;\n"
|
|
39
|
+
"// a stale committed file fails the build (packaging/generate-types.md).\n"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
PLUGIN_SENTINEL = (
|
|
44
|
+
"// GENERATED — do not edit by hand.\n"
|
|
45
|
+
"// Written next to `api.gen.ts` by the same `lockwire api-types` run, from the payload\n"
|
|
46
|
+
"// members a plugin registers. A stale committed file fails the build.\n"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _schemas(models: tuple[type, ...] = ALL_MEMBERS) -> dict[str, Any]:
|
|
51
|
+
"""The same `{name: schema}` map `api_payload.export_openapi` builds — one
|
|
52
|
+
call per union member, `$defs` folded in beside it."""
|
|
53
|
+
schemas: dict[str, Any] = {}
|
|
54
|
+
for model in models:
|
|
55
|
+
schema = model.model_json_schema(ref_template="#/components/schemas/{model}")
|
|
56
|
+
schemas.update(schema.pop("$defs", {}))
|
|
57
|
+
schemas[model.__name__] = schema
|
|
58
|
+
return schemas
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _ts_type(node: dict[str, Any]) -> str:
|
|
62
|
+
"""One JSON-Schema node -> one TypeScript type expression."""
|
|
63
|
+
if "$ref" in node:
|
|
64
|
+
return node["$ref"].rsplit("/", 1)[-1]
|
|
65
|
+
if "const" in node:
|
|
66
|
+
return json.dumps(node["const"])
|
|
67
|
+
if "enum" in node:
|
|
68
|
+
return " | ".join(json.dumps(v) for v in node["enum"])
|
|
69
|
+
if "anyOf" in node:
|
|
70
|
+
return " | ".join(_ts_type(n) for n in node["anyOf"])
|
|
71
|
+
kind = node.get("type")
|
|
72
|
+
if kind == "string":
|
|
73
|
+
return "string"
|
|
74
|
+
if kind in ("integer", "number"):
|
|
75
|
+
return "number"
|
|
76
|
+
if kind == "boolean":
|
|
77
|
+
return "boolean"
|
|
78
|
+
if kind == "null":
|
|
79
|
+
return "null"
|
|
80
|
+
if kind == "array":
|
|
81
|
+
return f"({_ts_type(node['items'])})[]"
|
|
82
|
+
if kind == "object":
|
|
83
|
+
values = node.get("additionalProperties")
|
|
84
|
+
return f"Record<string, {_ts_type(values)}>" if isinstance(values, dict) else "Record<string, unknown>"
|
|
85
|
+
return "unknown" # pragma: no cover — no union member emits an untyped leaf today
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _interface(name: str, schema: dict[str, Any]) -> str:
|
|
89
|
+
required = set(schema.get("required", []))
|
|
90
|
+
lines = [f"export interface {name} {{"]
|
|
91
|
+
for field, node in schema.get("properties", {}).items():
|
|
92
|
+
optional = "" if field in required else "?"
|
|
93
|
+
lines.append(f" {field}{optional}: {_ts_type(node)};")
|
|
94
|
+
lines.append("}")
|
|
95
|
+
return "\n".join(lines)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _declaration(name: str, schema: dict[str, Any]) -> str:
|
|
99
|
+
"""Emit objects as interfaces and root schemas as named type aliases."""
|
|
100
|
+
if schema.get("type") == "object" or "properties" in schema:
|
|
101
|
+
return _interface(name, schema)
|
|
102
|
+
return f"export type {name} = {_ts_type(schema)};"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def emit() -> str:
|
|
106
|
+
"""The whole file, as text — every union member's interface plus the
|
|
107
|
+
discriminated `MachineDocument` alias TypeScript narrows on `artifact_type`."""
|
|
108
|
+
schemas = _schemas()
|
|
109
|
+
parts = [SENTINEL]
|
|
110
|
+
for name in sorted(schemas):
|
|
111
|
+
parts.append(_declaration(name, schemas[name]))
|
|
112
|
+
verdict_exit_codes = {
|
|
113
|
+
verdict_word(code): exit_code for code, exit_code in VERDICT_EXIT_CODES.items()
|
|
114
|
+
}
|
|
115
|
+
parts.append(
|
|
116
|
+
"export const VERDICT_EXIT_CODES = "
|
|
117
|
+
+ json.dumps(verdict_exit_codes, indent=2)
|
|
118
|
+
+ ' as const satisfies Record<CheckPayload["verdict"], number>;'
|
|
119
|
+
)
|
|
120
|
+
members = " | ".join(model.__name__ for model in MEMBERS.values())
|
|
121
|
+
parts.append(f"export type MachineDocument = {members};")
|
|
122
|
+
return "\n\n".join(parts) + "\n"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def emit_plugin() -> str | None:
|
|
126
|
+
"""The file for members a plugin registered (`plugins.PAYLOAD_*`), or None when
|
|
127
|
+
there are none. Types the free file already defines are imported from it."""
|
|
128
|
+
plugins.ensure()
|
|
129
|
+
models = (*plugins.PAYLOAD_MEMBERS.values(), *plugins.PAYLOAD_EXTRA)
|
|
130
|
+
if not models:
|
|
131
|
+
return None
|
|
132
|
+
free = _schemas()
|
|
133
|
+
own = {name: s for name, s in _schemas(models).items() if name not in free}
|
|
134
|
+
refs = set(re.findall(r'"\$ref": "#/components/schemas/(\w+)"', json.dumps(own)))
|
|
135
|
+
parts = [PLUGIN_SENTINEL]
|
|
136
|
+
if used := sorted(refs & set(free)):
|
|
137
|
+
parts.append("import type { " + ", ".join(used) + ' } from "../api.gen";')
|
|
138
|
+
parts += [_declaration(name, own[name]) for name in sorted(own)]
|
|
139
|
+
members = " | ".join(model.__name__ for model in plugins.PAYLOAD_MEMBERS.values())
|
|
140
|
+
parts.append(f"export type MachineDocumentPlugin = {members};")
|
|
141
|
+
return "\n\n".join(parts) + "\n"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def write(path: Path | str) -> None:
|
|
145
|
+
"""The free file at `path`; a plugin's members, if any, at `<dir>/pro/<name>`."""
|
|
146
|
+
Path(path).write_text(emit(), encoding="utf-8")
|
|
147
|
+
if (extra := emit_plugin()) is not None:
|
|
148
|
+
out = Path(path).parent / "pro" / Path(path).name
|
|
149
|
+
out.parent.mkdir(exist_ok=True)
|
|
150
|
+
out.write_text(extra, encoding="utf-8")
|
lockwire/export_mhs.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""The MHS bridge, outward only.
|
|
2
|
+
|
|
3
|
+
Renders each declared device into the converging device-manifest shape five independent
|
|
4
|
+
third-party designs settled on (``device_id``, ``capabilities[]``, ``safety_limits[]`` with
|
|
5
|
+
``min``/``max``): what it measures, what it adjusts, what it refuses. ``min``/``max`` are
|
|
6
|
+
aliased straight from ``Band.lo``/``Band.hi`` — nothing here is invented, predicted or guessed.
|
|
7
|
+
Read-only, derived from the contract, never the truth.
|
|
8
|
+
|
|
9
|
+
Import of a third-party manifest into a contract is refused elsewhere (``lockwire import``): the inbound direction is refused until
|
|
10
|
+
an open standard is published, because import is where lock-in happens. This module
|
|
11
|
+
builds the outward direction only — no reader for the inbound shape lives here.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from lockwire.checks import power_rollup
|
|
17
|
+
from lockwire.describe import _role
|
|
18
|
+
from lockwire.schema import Contract, DeviceInstance, Provenance
|
|
19
|
+
|
|
20
|
+
# ponytail: the schema's witness enum (`Provenance.witness`, schema.py:191) is
|
|
21
|
+
# "w0..w6 | live | human" — no literal "fixture"/"sim" value exists yet. "human"
|
|
22
|
+
# (author-asserted, not measured off real hardware or a bag) is the closest proxy for
|
|
23
|
+
# "this number is a stand-in, not a real measurement". Revisit if a real witness value
|
|
24
|
+
# for fixture/sim data lands.
|
|
25
|
+
_SIMULATED_WITNESSES = {"human"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _provenance_fields(provenance: Provenance | None) -> dict[str, object]:
|
|
29
|
+
if provenance is None:
|
|
30
|
+
return {"witness": None, "simulated": False}
|
|
31
|
+
return {
|
|
32
|
+
"witness": provenance.witness,
|
|
33
|
+
"simulated": provenance.witness in _SIMULATED_WITNESSES,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _safety_limits(instance: DeviceInstance, model) -> list[dict[str, object]]:
|
|
38
|
+
"""Every band this device declares, each tagged with where it came from.
|
|
39
|
+
|
|
40
|
+
A datasheet rating (``model.hard_limits``) never reads the same as a fitted band
|
|
41
|
+
(statistically evidenced, ``envelope.fitted_bands``) or an inferred one (observed,
|
|
42
|
+
no fit evidence) — collapsing the three into one field is a loss this module refuses.
|
|
43
|
+
"""
|
|
44
|
+
limits: list[dict[str, object]] = []
|
|
45
|
+
if model is not None and model.hard_limits:
|
|
46
|
+
prov = _provenance_fields(model.provenance)
|
|
47
|
+
for channel, band in sorted(model.hard_limits.items()):
|
|
48
|
+
limits.append(
|
|
49
|
+
{
|
|
50
|
+
"channel": channel,
|
|
51
|
+
"min": band.lo,
|
|
52
|
+
"max": band.hi,
|
|
53
|
+
"unit": band.unit,
|
|
54
|
+
"kind": "datasheet",
|
|
55
|
+
**prov,
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
for envelope in instance.envelopes:
|
|
59
|
+
prov = _provenance_fields(envelope.provenance)
|
|
60
|
+
for name, signal in sorted(envelope.signals.items()):
|
|
61
|
+
kind = "fitted" if f"signals/{name}" in envelope.fitted_bands else "inferred"
|
|
62
|
+
limits.append(
|
|
63
|
+
{
|
|
64
|
+
"channel": f"{envelope.context}/{name}",
|
|
65
|
+
"min": signal.band.lo,
|
|
66
|
+
"max": signal.band.hi,
|
|
67
|
+
"unit": signal.band.unit,
|
|
68
|
+
"kind": kind,
|
|
69
|
+
**prov,
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
return limits
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _capabilities(model, instance: DeviceInstance) -> list[str]:
|
|
76
|
+
role = _role(model, instance) # reuse describe.py's walk rather than re-derive it
|
|
77
|
+
if role == "measures and adjusts":
|
|
78
|
+
return ["measures", "adjusts"]
|
|
79
|
+
if role in ("measures", "adjusts"):
|
|
80
|
+
return [role]
|
|
81
|
+
return []
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def render_device(contract: Contract, instance: DeviceInstance) -> dict[str, object]:
|
|
85
|
+
"""One device's converging-manifest entry."""
|
|
86
|
+
model = contract.device_model(instance.model)
|
|
87
|
+
prov = _provenance_fields(instance.provenance)
|
|
88
|
+
return {
|
|
89
|
+
"device_id": instance.id,
|
|
90
|
+
"capabilities": _capabilities(model, instance),
|
|
91
|
+
"safety_limits": _safety_limits(instance, model),
|
|
92
|
+
**prov,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _loss_report(contract: Contract) -> list[str]:
|
|
97
|
+
"""What a per-device manifest cannot carry, named rather than generic."""
|
|
98
|
+
rail_totals, *_ = power_rollup(contract, "nominal")
|
|
99
|
+
rails = ", ".join(sorted(rail_totals)) if rail_totals else "none declared"
|
|
100
|
+
return [
|
|
101
|
+
f"shared-rail power budgets are not carried: this contract rolls up {rails}; "
|
|
102
|
+
"a per-device manifest has no field for a rail's capacity or headroom.",
|
|
103
|
+
"frame references are not carried: joint/link membership and the URDF frame tree "
|
|
104
|
+
"(mount frames, drives_joint pairings) have no place in a per-device manifest.",
|
|
105
|
+
"provenance/enforcement metadata beyond witness/simulated is not carried: bag "
|
|
106
|
+
"hashes, lock dates, and the write-layer verdicts are dropped.",
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def export_mhs(contract: Contract) -> dict[str, object]:
|
|
111
|
+
"""Render the whole contract into the converging device-manifest shape. Outward only."""
|
|
112
|
+
return {
|
|
113
|
+
"devices": [render_device(contract, device) for device in contract.devices],
|
|
114
|
+
"loss_report": _loss_report(contract),
|
|
115
|
+
}
|
lockwire/factdiff.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""The fact diff: what changed in the facts, not in the text.
|
|
2
|
+
|
|
3
|
+
Two loaded contracts in, a diff document out. Nothing here reads git (that is
|
|
4
|
+
``gitref.py``) and nothing here prints (``cli.py`` does that) — this module
|
|
5
|
+
only compares.
|
|
6
|
+
|
|
7
|
+
The comparison is over the *validated* contract, not the YAML text, so a
|
|
8
|
+
reordered device list, a re-indented block or a rewritten comment is not a
|
|
9
|
+
change. Every declared scalar is flattened to a ``/``-separated path
|
|
10
|
+
(``topology/rails/rail.5v/capacity/value``), list rows keyed by their own
|
|
11
|
+
``id``/``name``/``context`` rather than by position.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from tempfile import NamedTemporaryFile
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from lockwire.checks import run_record_checks
|
|
20
|
+
from lockwire.schema import Contract, load_contract
|
|
21
|
+
|
|
22
|
+
# Path segments whose value *is* a declared ceiling. A number growing under one
|
|
23
|
+
# of these is a limit being raised — the edit that makes a finding go away
|
|
24
|
+
# without the robot changing, so it is called out rather than listed as one
|
|
25
|
+
# more field change.
|
|
26
|
+
LIMIT_SEGMENTS = frozenset(
|
|
27
|
+
{
|
|
28
|
+
"capacity",
|
|
29
|
+
"limit",
|
|
30
|
+
"hard_limits",
|
|
31
|
+
"current_limit",
|
|
32
|
+
"effort",
|
|
33
|
+
"velocity",
|
|
34
|
+
"torque_peak",
|
|
35
|
+
"torque_continuous",
|
|
36
|
+
"fade_margin_required",
|
|
37
|
+
"position_tracking_max_error",
|
|
38
|
+
"feedback_deadline_max",
|
|
39
|
+
"feedback_loss_max",
|
|
40
|
+
"clock_alignment_max_error",
|
|
41
|
+
"prediction_position_rmse_max",
|
|
42
|
+
"prediction_position_peak_max",
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# The entity collections a person thinks in. An added or removed row here is
|
|
47
|
+
# reported once, by id — not as forty field changes underneath it.
|
|
48
|
+
ENTITY_KINDS = (
|
|
49
|
+
"devices",
|
|
50
|
+
"device_models",
|
|
51
|
+
"families",
|
|
52
|
+
"budgets",
|
|
53
|
+
"assumptions",
|
|
54
|
+
"rails",
|
|
55
|
+
"buses",
|
|
56
|
+
"wires",
|
|
57
|
+
"links",
|
|
58
|
+
"actuator_cases",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class FieldChange:
|
|
64
|
+
path: str
|
|
65
|
+
old: Any # None when the field was not declared before
|
|
66
|
+
new: Any # None when the field is no longer declared
|
|
67
|
+
witness: str | None = None
|
|
68
|
+
source: str | None = None
|
|
69
|
+
limit_raised: bool = False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class EntityChange:
|
|
74
|
+
kind: str # one of ENTITY_KINDS
|
|
75
|
+
id: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class FindingRef:
|
|
80
|
+
severity: str
|
|
81
|
+
quantity: str
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class FactDiff:
|
|
86
|
+
changes: list[FieldChange] = field(default_factory=list)
|
|
87
|
+
added: list[EntityChange] = field(default_factory=list)
|
|
88
|
+
removed: list[EntityChange] = field(default_factory=list)
|
|
89
|
+
findings_new: list[FindingRef] = field(default_factory=list)
|
|
90
|
+
findings_gone: list[FindingRef] = field(default_factory=list)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def changed(self) -> bool:
|
|
94
|
+
return bool(self.changes or self.added or self.removed)
|
|
95
|
+
|
|
96
|
+
def as_json(self) -> dict[str, Any]:
|
|
97
|
+
return {
|
|
98
|
+
"changed": self.changed,
|
|
99
|
+
"changes": [
|
|
100
|
+
{
|
|
101
|
+
"path": c.path,
|
|
102
|
+
"old": c.old,
|
|
103
|
+
"new": c.new,
|
|
104
|
+
"witness": c.witness,
|
|
105
|
+
"source": c.source,
|
|
106
|
+
"limit_raised": c.limit_raised,
|
|
107
|
+
}
|
|
108
|
+
for c in self.changes
|
|
109
|
+
],
|
|
110
|
+
"added": [{"kind": e.kind, "id": e.id} for e in self.added],
|
|
111
|
+
"removed": [{"kind": e.kind, "id": e.id} for e in self.removed],
|
|
112
|
+
"findings_new": [
|
|
113
|
+
{"severity": f.severity, "quantity": f.quantity} for f in self.findings_new
|
|
114
|
+
],
|
|
115
|
+
"findings_gone": [
|
|
116
|
+
{"severity": f.severity, "quantity": f.quantity} for f in self.findings_gone
|
|
117
|
+
],
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _key_for(row: Any, index: int) -> str:
|
|
122
|
+
if isinstance(row, dict):
|
|
123
|
+
for field_name in ("id", "name", "context"):
|
|
124
|
+
value = row.get(field_name)
|
|
125
|
+
if isinstance(value, str) and value:
|
|
126
|
+
return value
|
|
127
|
+
return str(index)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _flatten(node: Any, prefix: str, out: dict[str, Any]) -> None:
|
|
131
|
+
if isinstance(node, dict):
|
|
132
|
+
for key, value in node.items():
|
|
133
|
+
_flatten(value, f"{prefix}/{key}" if prefix else str(key), out)
|
|
134
|
+
elif isinstance(node, list):
|
|
135
|
+
for index, row in enumerate(node):
|
|
136
|
+
_flatten(row, f"{prefix}/{_key_for(row, index)}", out)
|
|
137
|
+
else:
|
|
138
|
+
out[prefix] = node
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def flatten(contract: Contract) -> dict[str, Any]:
|
|
142
|
+
"""Every declared scalar in the contract, by path."""
|
|
143
|
+
out: dict[str, Any] = {}
|
|
144
|
+
_flatten(contract.model_dump(mode="json", exclude_none=True), "", out)
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _entities(contract: Contract) -> dict[str, set[str]]:
|
|
149
|
+
topology = contract.topology
|
|
150
|
+
model = contract.model
|
|
151
|
+
return {
|
|
152
|
+
"devices": {row.id for row in contract.devices},
|
|
153
|
+
"device_models": {row.id for row in contract.device_models},
|
|
154
|
+
"families": {row.id for row in contract.families},
|
|
155
|
+
"budgets": {row.id for row in contract.budgets},
|
|
156
|
+
"assumptions": {row.id for row in contract.assumptions},
|
|
157
|
+
"rails": {row.id for row in topology.rails} if topology else set(),
|
|
158
|
+
"buses": {row.id for row in topology.buses} if topology else set(),
|
|
159
|
+
"wires": {row.id for row in topology.wires} if topology else set(),
|
|
160
|
+
"links": {row.name for row in model.links} if model else set(),
|
|
161
|
+
"actuator_cases": {row.id for row in contract.actuator_cases},
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _limit_raised(path: str, old: Any, new: Any) -> bool:
|
|
166
|
+
segments = path.split("/")
|
|
167
|
+
if segments[-1] == "lo": # raising a floor narrows the band, it does not relax it
|
|
168
|
+
return False
|
|
169
|
+
if not LIMIT_SEGMENTS.intersection(segments):
|
|
170
|
+
return False
|
|
171
|
+
if not isinstance(old, (int, float)) or not isinstance(new, (int, float)):
|
|
172
|
+
return False
|
|
173
|
+
return bool(new > old)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _provenance_for(path: str, flat: dict[str, Any]) -> tuple[str | None, str | None]:
|
|
177
|
+
"""Witness and source of the nearest enclosing entry that declares provenance."""
|
|
178
|
+
segments = path.split("/")
|
|
179
|
+
for cut in range(len(segments) - 1, 0, -1):
|
|
180
|
+
stem = "/".join(segments[:cut])
|
|
181
|
+
witness = flat.get(f"{stem}/provenance/witness")
|
|
182
|
+
if witness is not None:
|
|
183
|
+
return witness, flat.get(f"{stem}/provenance/source")
|
|
184
|
+
return None, None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _findings(contract: Contract) -> set[FindingRef]:
|
|
188
|
+
return {FindingRef(f.severity, f.quantity) for f in run_record_checks(contract)}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def fact_diff(before: Contract, after: Contract) -> FactDiff:
|
|
192
|
+
"""Compare two contracts: declared values, entries, and the verdict delta."""
|
|
193
|
+
diff = FactDiff()
|
|
194
|
+
|
|
195
|
+
old_entities, new_entities = _entities(before), _entities(after)
|
|
196
|
+
touched: set[str] = set()
|
|
197
|
+
for kind in ENTITY_KINDS:
|
|
198
|
+
for entity_id in sorted(new_entities[kind] - old_entities[kind]):
|
|
199
|
+
diff.added.append(EntityChange(kind, entity_id))
|
|
200
|
+
touched.add(entity_id)
|
|
201
|
+
for entity_id in sorted(old_entities[kind] - new_entities[kind]):
|
|
202
|
+
diff.removed.append(EntityChange(kind, entity_id))
|
|
203
|
+
touched.add(entity_id)
|
|
204
|
+
|
|
205
|
+
flat_old, flat_new = flatten(before), flatten(after)
|
|
206
|
+
for path in sorted(set(flat_old) | set(flat_new)):
|
|
207
|
+
old, new = flat_old.get(path), flat_new.get(path)
|
|
208
|
+
if old == new:
|
|
209
|
+
continue
|
|
210
|
+
if touched.intersection(path.split("/")):
|
|
211
|
+
continue # already reported once, as an added/removed entry
|
|
212
|
+
witness, source = _provenance_for(path, flat_new if new is not None else flat_old)
|
|
213
|
+
diff.changes.append(
|
|
214
|
+
FieldChange(path, old, new, witness, source, _limit_raised(path, old, new))
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
old_findings, new_findings = _findings(before), _findings(after)
|
|
218
|
+
key = lambda finding: (finding.severity, finding.quantity) # noqa: E731
|
|
219
|
+
diff.findings_new = sorted(new_findings - old_findings, key=key)
|
|
220
|
+
diff.findings_gone = sorted(old_findings - new_findings, key=key)
|
|
221
|
+
return diff
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def render_lines(diff: FactDiff) -> list[str]:
|
|
225
|
+
"""The fact diff as plain lines, in the order a person reads them."""
|
|
226
|
+
if not diff.changed and not diff.findings_new and not diff.findings_gone:
|
|
227
|
+
return ["no fact changes"]
|
|
228
|
+
lines: list[str] = []
|
|
229
|
+
for change in diff.changes:
|
|
230
|
+
provenance = ""
|
|
231
|
+
if change.witness or change.source:
|
|
232
|
+
provenance = f" [{change.witness or '?'} · {change.source or '?'}]"
|
|
233
|
+
flag = " limit raised" if change.limit_raised else ""
|
|
234
|
+
lines.append(f"~ {change.path}: {change.old} -> {change.new}{provenance}{flag}")
|
|
235
|
+
for entry in diff.added:
|
|
236
|
+
lines.append(f"+ {entry.kind}/{entry.id}")
|
|
237
|
+
for entry in diff.removed:
|
|
238
|
+
lines.append(f"- {entry.kind}/{entry.id}")
|
|
239
|
+
for finding in diff.findings_new:
|
|
240
|
+
lines.append(f"! new {finding.severity}: {finding.quantity}")
|
|
241
|
+
for finding in diff.findings_gone:
|
|
242
|
+
lines.append(f"o gone {finding.severity}: {finding.quantity}")
|
|
243
|
+
return lines
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def load_contract_text(text: str) -> Contract:
|
|
247
|
+
"""Load a contract held in memory (the version git handed back)."""
|
|
248
|
+
with NamedTemporaryFile("w", suffix=".yaml", encoding="utf-8", delete=False) as handle:
|
|
249
|
+
handle.write(text)
|
|
250
|
+
temp = Path(handle.name)
|
|
251
|
+
try:
|
|
252
|
+
return load_contract(temp)
|
|
253
|
+
finally:
|
|
254
|
+
temp.unlink(missing_ok=True)
|