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/plan.py ADDED
@@ -0,0 +1,849 @@
1
+ """An agent's intended plan, checked against the contract before anything executes.
2
+
3
+ Mirrors the contract loader's guards (size cap, no YAML anchors/aliases) so a
4
+ plan file is trusted exactly as much as a lockfile. See ``validate_plan`` for
5
+ the scoring rules, and ``docs/plan_validation.md`` for the product view.
6
+ """
7
+
8
+ from pathlib import Path
9
+ from collections.abc import Callable
10
+ from typing import Annotated, Any, Literal
11
+
12
+ from pydantic import Field
13
+
14
+ from lockwire import units
15
+ from lockwire.advice import Alternative
16
+ from lockwire.copy import human_name
17
+ from lockwire.checks import (
18
+ _worst_rail_power,
19
+ power_rollup,
20
+ unknown_draw_message,
21
+ unusable_effort_finding,
22
+ )
23
+ from lockwire.report import Finding, RecordReport
24
+ from lockwire.schema import (
25
+ MAX_CONTRACT_BYTES,
26
+ Base,
27
+ Band,
28
+ Contract,
29
+ DeviceInstance,
30
+ PowerDraw,
31
+ PowerQuantity,
32
+ Rail,
33
+ _load_yaml_guarded,
34
+ _reject_yaml_anchors_aliases,
35
+ _yaml,
36
+ )
37
+ from lockwire.units import UnitError
38
+
39
+
40
+ class JointTarget(Base):
41
+ kind: Literal["joint_target"] = "joint_target"
42
+ joint: str
43
+ position: float | None = None # rad or m
44
+ velocity: float | None = None # rad/s or m/s
45
+ effort: float | None = None # N*m or N
46
+
47
+
48
+ class RailLoad(Base):
49
+ """An additional draw placed on a rail."""
50
+
51
+ kind: Literal["rail_load"] = "rail_load"
52
+ rail: str # -> Rail.id
53
+ draw: PowerQuantity
54
+
55
+
56
+ class SetPowerMode(Base):
57
+ """Switch a device to a declared power mode."""
58
+
59
+ kind: Literal["set_power_mode"] = "set_power_mode"
60
+ device: str # -> DeviceInstance.id
61
+ mode: str # -> PowerMode.name (or "nominal")
62
+
63
+
64
+ class Publish(Base):
65
+ """A ROS message an agent intends to send."""
66
+
67
+ kind: Literal["publish"] = "publish"
68
+ topic: str
69
+ msg_type: str
70
+ msg: dict[str, Any] = {}
71
+
72
+
73
+ Step = Annotated[
74
+ JointTarget | RailLoad | SetPowerMode | Publish,
75
+ Field(discriminator="kind"),
76
+ ]
77
+
78
+
79
+ class Plan(Base):
80
+ steps: list[Step] = []
81
+
82
+
83
+ class AssertionOutcome(Base):
84
+ """Machine-readable disposition for one assertion submitted in a plan."""
85
+
86
+ path: str
87
+ status: Literal["PASS", "FAIL", "UNSCORED", "ERROR"]
88
+
89
+
90
+ class PlanReport(RecordReport):
91
+ """Findings plus complete, list-compatible assertion coverage."""
92
+
93
+ outcomes: list[AssertionOutcome]
94
+ errors: int
95
+
96
+ def __init__(
97
+ self,
98
+ findings: list[Finding] | None = None,
99
+ outcomes: list[AssertionOutcome] | None = None,
100
+ ) -> None:
101
+ self.outcomes = outcomes or []
102
+ self.errors = sum(outcome.status == "ERROR" for outcome in self.outcomes)
103
+ scored = sum(outcome.status in {"PASS", "FAIL"} for outcome in self.outcomes)
104
+ super().__init__(findings, scored=scored)
105
+
106
+
107
+ def load_plan(path: Path) -> Plan:
108
+ """Load a plan.yaml with the same size and anchor guards as ``load_contract``."""
109
+ size = path.stat().st_size
110
+ if size > MAX_CONTRACT_BYTES:
111
+ raise ValueError(f"plan file exceeds size limit ({size} bytes > {MAX_CONTRACT_BYTES})")
112
+ with open(path, encoding="utf-8") as f:
113
+ data = _load_yaml_guarded(_yaml(), f)
114
+ _reject_yaml_anchors_aliases(data)
115
+ return Plan.model_validate(data)
116
+
117
+
118
+ # ---------------------------------------------------------------- power helpers
119
+ #
120
+ # Rail base watts is the chain-inclusive rollup checks._worst_rail_power uses
121
+ # for `lockwire check` (via ampacity): it climbs child rails through
122
+ # `upstream`/`efficiency` up to `rail_id`, maximized over nominal and every
123
+ # declared PowerMode. Reusing it here (instead of a plan-local direct-rail
124
+ # sum) keeps `lockwire plan` and `lockwire check` agreeing on one number per
125
+ # rail — see tests/test_plan.py's agreement test.
126
+
127
+
128
+ def _rail_worst_w_with_device_pinned(
129
+ contract: Contract, rail_id: str, device_id: str, w: float
130
+ ) -> tuple[float, set[str]]:
131
+ """``_worst_rail_power(rail_id)`` with ``device_id`` pinned to ``w`` watts.
132
+
133
+ Used for a SetPowerMode step: the agent is committing this one device to
134
+ a named mode, so its contribution should not also vary across the
135
+ rollup's own worst-mode search. Swap the device's declared draw for a
136
+ single fixed value (no other modes) in a shallow contract copy, then call
137
+ the same helper again — the efficiency chain from the device's rail up to
138
+ ``rail_id`` is whatever _worst_rail_power already does, not reimplemented.
139
+ Returns ``(watts, unknown_consumers)`` — the caller must not score a peak
140
+ when unknown_consumers is non-empty.
141
+ """
142
+ device = contract.device(device_id)
143
+ assert device is not None and device.power is not None
144
+ pinned_power = device.power.model_copy(
145
+ update={"draw": PowerQuantity(value=w, unit="W"), "modes": []}
146
+ )
147
+ pinned_device = device.model_copy(update={"power": pinned_power})
148
+ devices = [pinned_device if d.id == device_id else d for d in contract.devices]
149
+ swapped = contract.model_copy(update={"devices": devices})
150
+ watts, _, _, unknown = _worst_rail_power(swapped, rail_id)
151
+ return watts, unknown
152
+
153
+
154
+ def _unknown_finding_for(contract: Contract, path: str, unknown: set[str]) -> Finding:
155
+ """One not_scored Finding naming an unknown-draw consumer found while
156
+ walking a rail chain (never silently score a peak around a gap)."""
157
+ device_id = sorted(unknown)[0]
158
+ device = contract.device(device_id)
159
+ device_rail = device.power.rail if device is not None and device.power is not None else "?"
160
+ return Finding(
161
+ severity="not_scored",
162
+ quantity=path,
163
+ observed=unknown_draw_message(device_id, device_rail),
164
+ expected="a declared draw for this device",
165
+ action=f"declare {device_id}'s power draw, or accept this step as not scored",
166
+ )
167
+
168
+
169
+ def _rail_capacity_chain(
170
+ contract: Contract, rail_id: str, rail_extra: dict[str, float]
171
+ ) -> list[tuple[Rail, float, float, set[str]]]:
172
+ """The named rail plus every ancestor up to the battery, each with its own
173
+ rolled-up watts (``_worst_rail_power``, which already includes descendant
174
+ devices) plus the plan-time extra load propagated onto it. Callers walk
175
+ this list and stop at the first unknown-consumer gap or capacity breach."""
176
+ out: list[tuple[Rail, float, float, set[str]]] = []
177
+ seen: set[str] = set()
178
+ current = contract.rail(rail_id)
179
+ while current is not None and current.id not in seen:
180
+ seen.add(current.id)
181
+ base_w, _, _, unknown = _worst_rail_power(contract, current.id)
182
+ total_w = base_w + rail_extra.get(current.id, 0.0)
183
+ out.append((current, total_w, current.capacity.base(), unknown))
184
+ if current.upstream in (None, "battery"):
185
+ break
186
+ current = contract.rail(current.upstream)
187
+ return out
188
+
189
+
190
+ def _with_enforcement_provenance(expected: str, *limits: tuple[str, str | None]) -> str:
191
+ """Annotate an existing checked limit without changing plan scoring."""
192
+ declarations = [f"{value} ({name})" for name, value in limits if value is not None]
193
+ if not declarations:
194
+ return expected
195
+ return (
196
+ f"{expected} (declared enforcement provenance: {', '.join(declarations)}; "
197
+ "metadata only; does not prove physical protection)"
198
+ )
199
+
200
+
201
+ def _resolve_field(msg: dict[str, Any], field: str) -> tuple[float | None, str | None]:
202
+ """Resolve a dotted path, distinguishing an absent value from a non-number."""
203
+ node: Any = msg
204
+ for part in field.split("."):
205
+ if not isinstance(node, dict) or part not in node:
206
+ return None, "missing"
207
+ node = node[part]
208
+ if isinstance(node, bool) or not isinstance(node, int | float):
209
+ return None, "nonnumeric"
210
+ return float(node), None
211
+
212
+
213
+ def _score_max_rate(
214
+ out: list[Finding],
215
+ record: "Callable[[str, Literal['PASS', 'FAIL', 'UNSCORED', 'ERROR']], None]",
216
+ *,
217
+ path: str,
218
+ band: Band,
219
+ previous: float | None,
220
+ current: float,
221
+ rate_hz: float | None,
222
+ ) -> None:
223
+ """Score one published value's rate of change against ``Band.max_rate``.
224
+
225
+ A step whose endpoints are both inside the band but
226
+ whose rate of change between them exceeds the declared bound is a finding.
227
+
228
+ Compare now, predict eventually, never actuate: this
229
+ scores and reports only; nothing here reaches a device.
230
+
231
+ Where the rate cannot be computed it is said out loud. A plan carries no
232
+ timestamps, so the elapsed time between two publishes on a topic comes from
233
+ the contract's own ``driver.nominal_rate_hz``. Missing either operand — the
234
+ previous value or the declared rate — is ``not_scored`` naming which one is
235
+ missing, never a silent pass: a rate limit that quietly stops applying is a
236
+ rate limit that has stopped biting.
237
+ """
238
+ if previous is None:
239
+ out.append(
240
+ Finding(
241
+ severity="not_scored",
242
+ quantity=path,
243
+ observed=f"{current:g} {band.unit} is the first value for this signal in the plan",
244
+ expected=f"two published values, to compare against max_rate {band.max_rate:g} {band.unit}/s",
245
+ action="add the preceding publish step, or accept this rate bound as not scored",
246
+ )
247
+ )
248
+ record(path, "UNSCORED")
249
+ return
250
+ if rate_hz is None:
251
+ out.append(
252
+ Finding(
253
+ severity="not_scored",
254
+ quantity=path,
255
+ observed="the driver declares no nominal_rate_hz, so the time between steps is unknown",
256
+ expected=f"a declared publish rate, to compare against max_rate {band.max_rate:g} {band.unit}/s",
257
+ action="declare driver.nominal_rate_hz for this topic to score the rate of change",
258
+ )
259
+ )
260
+ record(path, "UNSCORED")
261
+ return
262
+ rate = abs(current - previous) * rate_hz
263
+ assert band.max_rate is not None
264
+ if rate > band.max_rate:
265
+ out.append(
266
+ Finding(
267
+ severity="violation",
268
+ quantity=path,
269
+ observed=(
270
+ f"{rate:g} {band.unit}/s "
271
+ f"({previous:g} -> {current:g} {band.unit} at {rate_hz:g} Hz)"
272
+ ),
273
+ expected=f"<= max_rate {band.max_rate:g} {band.unit}/s{band.why_suffix}",
274
+ action=(
275
+ "both endpoints are inside the band but the move between them is "
276
+ "faster than the declared rate — slow the step or split it"
277
+ ),
278
+ )
279
+ )
280
+ record(path, "FAIL")
281
+ else:
282
+ record(path, "PASS")
283
+
284
+
285
+ def validate_plan(contract: Contract, plan: "Plan") -> PlanReport:
286
+ """Check an agent's intended plan against the contract before anything executes.
287
+
288
+ Never invents a limit: anything the contract does not declare comes back
289
+ ``not_scored``, with ``expected`` naming what is missing.
290
+ """
291
+ if not plan.steps:
292
+ return PlanReport(
293
+ [
294
+ Finding(
295
+ severity="not_scored",
296
+ quantity="plan/steps",
297
+ observed="no steps",
298
+ expected="at least one step",
299
+ action="add steps to the plan before validating",
300
+ )
301
+ ],
302
+ [AssertionOutcome(path="plan/steps", status="UNSCORED")],
303
+ )
304
+
305
+ # Whole-plan RailLoad totals, keyed by rail id (D-accumulate: two steps on
306
+ # the same rail add up, not just the last one).
307
+ railload_totals: dict[str, float] = {}
308
+ for step in plan.steps:
309
+ if isinstance(step, RailLoad) and contract.rail(step.rail) is not None:
310
+ railload_totals[step.rail] = railload_totals.get(step.rail, 0.0) + step.draw.base()
311
+
312
+ # Propagate every named rail's whole-plan RailLoad total up through the
313
+ # rail tree once (a load on a child rail must also be checked
314
+ # against every ancestor's capacity, and two individually-legal loads on
315
+ # sibling children must combine at a shared parent). Each total is
316
+ # expressed as one synthetic device on its rail and handed to
317
+ # `power_rollup`, whose upstream/efficiency walk is the only copy of that
318
+ # walk — same shallow-model_copy pattern as
319
+ # _rail_worst_w_with_device_pinned. Real devices are swapped out because
320
+ # their watts are already in `_worst_rail_power`'s base figure.
321
+ synthetic = [
322
+ DeviceInstance(
323
+ id=f"plan.railload.{rail_id}",
324
+ model="plan.railload",
325
+ owner="lockwire plan",
326
+ power=PowerDraw(rail=rail_id, draw=PowerQuantity(value=extra_w, unit="W")),
327
+ )
328
+ for rail_id, extra_w in railload_totals.items()
329
+ ]
330
+ rail_extra, *_ = power_rollup(contract.model_copy(update={"devices": synthetic}), "nominal")
331
+
332
+ out: list[Finding] = []
333
+ outcomes: list[AssertionOutcome] = []
334
+
335
+ def record(path: str, status: Literal["PASS", "FAIL", "UNSCORED", "ERROR"]) -> None:
336
+ outcomes.append(AssertionOutcome(path=path, status=status))
337
+
338
+ joints = {joint.name: joint for joint in contract.model.joints} if contract.model else {}
339
+
340
+ # The value a signal last carried in this plan, in its
341
+ # hard limit's unit, keyed by (topic, signal) — the left operand a
342
+ # `Band.max_rate` comparison needs. A step can sit inside the band at both
343
+ # ends and still be destructive in between; nothing else in the plan
344
+ # remembers the previous value.
345
+ #
346
+ # Compare now, predict eventually, never actuate. The rate
347
+ # bound is *scored* here and never enforced on a device — a crossing is a
348
+ # Finding, exactly like every other lockwire comparison.
349
+ previous_published: dict[tuple[str, str], float] = {}
350
+
351
+ for i, step in enumerate(plan.steps):
352
+ base = f"plan/steps/{i}"
353
+
354
+ if isinstance(step, JointTarget):
355
+ joint = joints.get(step.joint)
356
+ if joint is None:
357
+ path = f"{base}/joint_target/joint"
358
+ out.append(
359
+ Finding(
360
+ severity="not_scored",
361
+ quantity=path,
362
+ observed=f"joint {step.joint!r} not found",
363
+ expected="a joint declared in the contract model",
364
+ action="check the joint name against the contract",
365
+ alternatives=tuple(
366
+ Alternative(
367
+ text=f"use declared model joint {candidate!r}",
368
+ value=1.0,
369
+ unit="count",
370
+ kind="model",
371
+ )
372
+ for candidate in sorted(joints)
373
+ ),
374
+ )
375
+ )
376
+ record(path, "UNSCORED")
377
+ continue
378
+
379
+ limits = joint.limits
380
+ if step.position is not None:
381
+ path = f"{base}/joint_target/position"
382
+ if limits is None or limits.lower is None or limits.upper is None:
383
+ out.append(
384
+ Finding(
385
+ severity="not_scored",
386
+ quantity=path,
387
+ observed=f"{step.position}",
388
+ expected="the contract declares no position limits for this joint",
389
+ action="declare limits.lower/upper for this joint to score position",
390
+ )
391
+ )
392
+ record(path, "UNSCORED")
393
+ elif not (limits.lower <= step.position <= limits.upper):
394
+ out.append(
395
+ Finding(
396
+ severity="violation",
397
+ quantity=path,
398
+ observed=f"{step.position}",
399
+ expected=_with_enforcement_provenance(
400
+ f"inside [{limits.lower}, {limits.upper}]",
401
+ ("lower", limits.lower_enforced_by),
402
+ ("upper", limits.upper_enforced_by),
403
+ ),
404
+ action="pick a position inside the joint's declared limits",
405
+ )
406
+ )
407
+ record(path, "FAIL")
408
+ else:
409
+ record(path, "PASS")
410
+
411
+ if step.velocity is not None:
412
+ path = f"{base}/joint_target/velocity"
413
+ if limits is None or limits.velocity is None:
414
+ out.append(
415
+ Finding(
416
+ severity="not_scored",
417
+ quantity=path,
418
+ observed=f"{step.velocity}",
419
+ expected="the contract declares no velocity limit for this joint",
420
+ action="declare limits.velocity for this joint to score velocity",
421
+ )
422
+ )
423
+ record(path, "UNSCORED")
424
+ elif abs(step.velocity) > limits.velocity:
425
+ out.append(
426
+ Finding(
427
+ severity="violation",
428
+ quantity=path,
429
+ observed=f"{step.velocity}",
430
+ expected=_with_enforcement_provenance(
431
+ f"<= {limits.velocity}",
432
+ ("velocity", limits.velocity_enforced_by),
433
+ ),
434
+ action="reduce the commanded velocity",
435
+ )
436
+ )
437
+ record(path, "FAIL")
438
+ else:
439
+ record(path, "PASS")
440
+
441
+ if step.effort is not None:
442
+ path = f"{base}/joint_target/effort"
443
+ if limits is None or limits.effort is None:
444
+ out.append(
445
+ Finding(
446
+ severity="not_scored",
447
+ quantity=path,
448
+ observed=f"{step.effort}",
449
+ expected="the contract declares no effort limit for this joint",
450
+ action="declare limits.effort for this joint to score effort",
451
+ )
452
+ )
453
+ record(path, "UNSCORED")
454
+ elif limits.effort_limit is None:
455
+ out.append(unusable_effort_finding(path, limits.effort))
456
+ # no outcome row; the warning alone says torque is not checked
457
+ elif abs(step.effort) > limits.effort_limit:
458
+ out.append(
459
+ Finding(
460
+ severity="violation",
461
+ quantity=path,
462
+ observed=f"{step.effort}",
463
+ expected=_with_enforcement_provenance(
464
+ f"<= {limits.effort}",
465
+ ("effort", limits.effort_enforced_by),
466
+ ),
467
+ action="reduce the commanded effort",
468
+ )
469
+ )
470
+ record(path, "FAIL")
471
+ else:
472
+ record(path, "PASS")
473
+
474
+ driver_device = next(
475
+ (d for d in contract.devices if d.drives_joint == joint.name), None
476
+ )
477
+ actuator_path = f"{base}/joint_target/effort_actuator"
478
+ if driver_device is None:
479
+ out.append(
480
+ Finding(
481
+ severity="not_scored",
482
+ quantity=actuator_path,
483
+ observed="no device drives this joint",
484
+ expected="a device with drives_joint set to this joint",
485
+ action="declare which device drives this joint to score actuator effort",
486
+ )
487
+ )
488
+ record(actuator_path, "UNSCORED")
489
+ else:
490
+ model = contract.device_model(driver_device.model)
491
+ actuator = model.actuator if model is not None else None
492
+ if (
493
+ actuator is None
494
+ or actuator.torque_continuous is None
495
+ or actuator.gear_ratio is None
496
+ ):
497
+ out.append(
498
+ Finding(
499
+ severity="not_scored",
500
+ quantity=actuator_path,
501
+ observed="actuator missing torque_continuous or gear_ratio",
502
+ expected="both declared on the driving device's model",
503
+ action="declare the actuator's continuous torque and gear ratio",
504
+ )
505
+ )
506
+ record(actuator_path, "UNSCORED")
507
+ else:
508
+ continuous = actuator.torque_continuous * actuator.gear_ratio
509
+ peak = (
510
+ actuator.torque_peak * actuator.gear_ratio
511
+ if actuator.torque_peak is not None
512
+ else None
513
+ )
514
+ if abs(step.effort) > continuous:
515
+ if peak is not None and abs(step.effort) <= peak:
516
+ out.append(
517
+ Finding(
518
+ severity="warning",
519
+ quantity=actuator_path,
520
+ observed=f"{step.effort} N*m",
521
+ expected=f"<= {continuous:.2f} N*m continuous geared torque",
522
+ action="sustained effort above continuous rating shortens actuator life",
523
+ )
524
+ )
525
+ record(actuator_path, "PASS")
526
+ else:
527
+ out.append(
528
+ Finding(
529
+ severity="violation",
530
+ quantity=actuator_path,
531
+ observed=f"{step.effort} N*m",
532
+ expected=f"<= {continuous:.2f} N*m continuous geared torque",
533
+ action="reduce the commanded effort or select a stronger actuator",
534
+ )
535
+ )
536
+ record(actuator_path, "FAIL")
537
+ else:
538
+ record(actuator_path, "PASS")
539
+
540
+ if step.position is None and step.velocity is None and step.effort is None:
541
+ path = f"{base}/joint_target"
542
+ out.append(
543
+ Finding(
544
+ severity="not_scored",
545
+ quantity=path,
546
+ observed=f"joint {step.joint!r} selected with no target values",
547
+ expected="at least one of position, velocity, or effort",
548
+ action="add a target value before validating this step",
549
+ )
550
+ )
551
+ record(path, "UNSCORED")
552
+
553
+ elif isinstance(step, RailLoad):
554
+ rail = contract.rail(step.rail)
555
+ if rail is None:
556
+ path = f"{base}/rail_load/rail"
557
+ out.append(
558
+ Finding(
559
+ severity="not_scored",
560
+ quantity=path,
561
+ observed=f"rail {step.rail!r} not found",
562
+ expected="a rail declared in the contract topology",
563
+ action=f"This robot has no rail named {human_name(step.rail)}",
564
+ )
565
+ )
566
+ record(path, "UNSCORED")
567
+ continue
568
+ path = f"{base}/rail_load/draw"
569
+ for checked_rail, total_w, capacity_w, unknown in _rail_capacity_chain(
570
+ contract, step.rail, rail_extra
571
+ ):
572
+ if unknown:
573
+ out.append(_unknown_finding_for(contract, path, unknown))
574
+ record(path, "UNSCORED")
575
+ break
576
+ if total_w > capacity_w:
577
+ out.append(
578
+ Finding(
579
+ severity="violation",
580
+ quantity=path,
581
+ observed=f"{total_w:.2f} W rolled-up draw on {checked_rail.id}",
582
+ expected=_with_enforcement_provenance(
583
+ f"<= {capacity_w:.2f} W capacity",
584
+ ("capacity", checked_rail.capacity_enforced_by),
585
+ ),
586
+ action=f"shed load on rail {checked_rail.id!r} or raise its capacity",
587
+ )
588
+ )
589
+ record(path, "FAIL")
590
+ break
591
+ else:
592
+ record(path, "PASS")
593
+
594
+ elif isinstance(step, SetPowerMode):
595
+ device = contract.device(step.device)
596
+ if device is None or device.power is None:
597
+ path = f"{base}/set_power_mode/device"
598
+ out.append(
599
+ Finding(
600
+ severity="not_scored",
601
+ quantity=path,
602
+ observed=f"device {step.device!r} not found or declares no power",
603
+ expected="a device with a power draw declared",
604
+ action=(
605
+ f"This robot has no device named {human_name(step.device)}"
606
+ if device is None
607
+ else f"{human_name(step.device)} has no power modes declared"
608
+ ),
609
+ )
610
+ )
611
+ record(path, "UNSCORED")
612
+ continue
613
+ mode = next((m for m in device.power.modes if m.name == step.mode), None)
614
+ mode_w = mode.draw.base() if mode is not None else None
615
+ if mode_w is None and step.mode != "nominal":
616
+ path = f"{base}/set_power_mode/mode"
617
+ out.append(
618
+ Finding(
619
+ severity="not_scored",
620
+ quantity=path,
621
+ observed=f"mode {step.mode!r} not declared on {step.device!r}",
622
+ expected="a power mode declared on this device",
623
+ action="check the mode name against the device's declared modes",
624
+ )
625
+ )
626
+ record(path, "UNSCORED")
627
+ continue
628
+ if mode_w is None:
629
+ if device.power.draw is None:
630
+ path = f"{base}/set_power_mode/draw"
631
+ out.append(
632
+ Finding(
633
+ severity="not_scored",
634
+ quantity=path,
635
+ observed=unknown_draw_message(device.id, device.power.rail),
636
+ expected="a declared draw for this device",
637
+ action=(
638
+ f"declare {device.id}'s power draw, or accept this "
639
+ "step as not scored"
640
+ ),
641
+ )
642
+ )
643
+ record(path, "UNSCORED")
644
+ continue
645
+ mode_w = device.power.draw.base()
646
+ if device.power.choices is not None and step.mode not in device.power.choices:
647
+ path = f"{base}/set_power_mode/mode"
648
+ out.append(
649
+ Finding(
650
+ severity="violation",
651
+ quantity=path,
652
+ observed=(
653
+ f"mode {step.mode!r} is declared on {step.device!r} "
654
+ "but not allowed by this contract"
655
+ ),
656
+ expected="one of: " + ", ".join(device.power.choices),
657
+ action=f"pick an allowed power mode for {step.device!r}",
658
+ )
659
+ )
660
+ record(path, "FAIL")
661
+ continue
662
+ rail = contract.rail(device.power.rail)
663
+ if rail is None:
664
+ path = f"{base}/set_power_mode/rail"
665
+ out.append(
666
+ Finding(
667
+ severity="not_scored",
668
+ quantity=path,
669
+ observed=f"rail {device.power.rail!r} not found",
670
+ expected="a rail declared in the contract topology",
671
+ action="fix the device's rail reference",
672
+ )
673
+ )
674
+ record(path, "UNSCORED")
675
+ continue
676
+ pinned_w, unknown = _rail_worst_w_with_device_pinned(
677
+ contract, device.power.rail, device.id, mode_w
678
+ )
679
+ total_w = pinned_w + railload_totals.get(device.power.rail, 0.0)
680
+ capacity_w = rail.capacity.base()
681
+ path = f"{base}/set_power_mode/draw"
682
+ if unknown:
683
+ out.append(_unknown_finding_for(contract, path, unknown))
684
+ record(path, "UNSCORED")
685
+ elif total_w > capacity_w:
686
+ out.append(
687
+ Finding(
688
+ severity="violation",
689
+ quantity=path,
690
+ observed=f"{total_w:.2f} W rolled-up draw on {device.power.rail} at mode {step.mode!r}",
691
+ expected=_with_enforcement_provenance(
692
+ f"<= {capacity_w:.2f} W capacity",
693
+ ("capacity", rail.capacity_enforced_by),
694
+ ),
695
+ action=f"pick a lower power mode for {step.device!r} or shed other load",
696
+ )
697
+ )
698
+ record(path, "FAIL")
699
+ else:
700
+ record(path, "PASS")
701
+
702
+ elif isinstance(step, Publish):
703
+ driver_device = next(
704
+ (
705
+ d
706
+ for d in contract.devices
707
+ if d.driver is not None and d.driver.topic == step.topic
708
+ ),
709
+ None,
710
+ )
711
+ if driver_device is None:
712
+ path = f"{base}/publish/topic"
713
+ out.append(
714
+ Finding(
715
+ severity="not_scored",
716
+ quantity=path,
717
+ observed=f"no driver declares topic {step.topic!r}",
718
+ expected="a device driver with a matching topic",
719
+ action="check the topic name against the contract's drivers",
720
+ )
721
+ )
722
+ record(path, "UNSCORED")
723
+ continue
724
+ driver = driver_device.driver
725
+ assert driver is not None
726
+ if driver.msg_type is not None and driver.msg_type != step.msg_type:
727
+ path = f"{base}/publish/msg_type"
728
+ out.append(
729
+ Finding(
730
+ severity="not_scored",
731
+ quantity=path,
732
+ observed=f"message type {step.msg_type!r}",
733
+ expected=f"declared message type {driver.msg_type!r}",
734
+ action="use the driver's declared message type before validating fields",
735
+ )
736
+ )
737
+ record(path, "ERROR")
738
+ continue
739
+ if not driver.signals:
740
+ path = f"{base}/publish/signals"
741
+ out.append(
742
+ Finding(
743
+ severity="not_scored",
744
+ quantity=path,
745
+ observed=f"driver for topic {step.topic!r} has an empty signal map",
746
+ expected="at least one signal mapped to a message field",
747
+ action="declare driver.signals mappings before scoring this publish step",
748
+ )
749
+ )
750
+ record(path, "UNSCORED")
751
+ continue
752
+ model = contract.device_model(driver_device.model)
753
+ for signal_name, source in driver.signals.items():
754
+ path = f"{base}/publish/{signal_name}"
755
+ value, resolution = _resolve_field(step.msg, source.field)
756
+ if value is None:
757
+ observed = (
758
+ f"mapped field {source.field!r} is missing from the message"
759
+ if resolution == "missing"
760
+ else f"mapped field {source.field!r} is not numeric"
761
+ )
762
+ out.append(
763
+ Finding(
764
+ severity="not_scored",
765
+ quantity=path,
766
+ observed=observed,
767
+ expected="a numeric value at the mapped field path",
768
+ action="populate the mapped field with a numeric value before validating",
769
+ )
770
+ )
771
+ record(path, "UNSCORED")
772
+ continue
773
+ band: Band | None = (
774
+ model.hard_limits.get(signal_name) if model is not None else None
775
+ )
776
+ if band is None:
777
+ out.append(
778
+ Finding(
779
+ severity="not_scored",
780
+ quantity=path,
781
+ observed=f"{value} {source.unit}",
782
+ expected=f"hard_limits[{signal_name!r}] declared on the device model",
783
+ action="declare a hard limit for this signal to score it",
784
+ )
785
+ )
786
+ record(path, "UNSCORED")
787
+ continue
788
+ try:
789
+ in_band = band.contains(Band(lo=value, hi=value, unit=source.unit))
790
+ except UnitError:
791
+ out.append(
792
+ Finding(
793
+ severity="not_scored",
794
+ quantity=path,
795
+ observed=(
796
+ f"signal unit {source.unit!r} vs hard limit unit {band.unit!r}"
797
+ ),
798
+ expected=(
799
+ "the signal and its hard limit declare the same physical dimension"
800
+ ),
801
+ action="fix the contract: the signal's unit and hard_limits[...] "
802
+ "unit must share a dimension",
803
+ )
804
+ )
805
+ record(path, "UNSCORED")
806
+ continue
807
+ if not in_band:
808
+ out.append(
809
+ Finding(
810
+ severity="violation",
811
+ quantity=path,
812
+ observed=f"{value} {source.unit}",
813
+ expected=f"inside the contract's declared hard limit {band}",
814
+ action=(
815
+ "correct the value or the declared range before publishing; "
816
+ "physical truth is indeterminate from this comparison"
817
+ ),
818
+ )
819
+ )
820
+ record(path, "FAIL")
821
+ else:
822
+ record(path, "PASS")
823
+
824
+ converted = units.convert(value, source.unit, band.unit)
825
+ key = (step.topic, signal_name)
826
+ if band.max_rate is not None:
827
+ _score_max_rate(
828
+ out,
829
+ record,
830
+ path=f"{path}/rate",
831
+ band=band,
832
+ previous=previous_published.get(key),
833
+ current=converted,
834
+ rate_hz=driver.nominal_rate_hz,
835
+ )
836
+ previous_published[key] = converted
837
+
838
+ if contract.model is not None:
839
+ provenance = contract.model.provenance
840
+ for finding in out:
841
+ if (
842
+ "/joint_target/" in finding.quantity
843
+ and finding.quantity.rsplit("/", 1)[-1] in {"joint", "position", "velocity", "effort"}
844
+ ):
845
+ finding.source = provenance.source
846
+ finding.witness = provenance.witness
847
+ finding.locked_at = provenance.locked_at
848
+
849
+ return PlanReport(out, outcomes)