lucen-cli 0.1.2__tar.gz → 0.1.4__tar.gz

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 (110) hide show
  1. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/PKG-INFO +2 -2
  2. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/README.md +1 -1
  3. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/pyproject.toml +1 -1
  4. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/__init__.py +1 -1
  5. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/client.py +108 -0
  6. lucen_cli-0.1.4/src/lucen_cli/cmd_campaigns.py +119 -0
  7. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/cmd_datasets.py +3 -1
  8. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/cmd_model.py +12 -1
  9. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/cmd_quickstart.py +2 -2
  10. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/cmd_robot.py +4 -2
  11. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/cmd_runs.py +215 -0
  12. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/contract_derive.py +50 -3
  13. lucen_cli-0.1.4/src/lucen_cli/curves_view.py +104 -0
  14. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/embodiment.py +176 -23
  15. lucen_cli-0.1.4/src/lucen_cli/gates_view.py +252 -0
  16. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/hf_dataset.py +39 -4
  17. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/main.py +4 -0
  18. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/model_card.py +23 -5
  19. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/output.py +3 -2
  20. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/rl/mirror.py +2 -0
  21. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/rl/ppo.py +2 -1
  22. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/rl/tasks.py +56 -11
  23. lucen_cli-0.1.4/src/lucen_cli/rl/terrain.py +282 -0
  24. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/rl/vecenv.py +72 -10
  25. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/robot/checks.py +29 -6
  26. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/robot/draft.py +5 -0
  27. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/robot/publish.py +9 -1
  28. lucen_cli-0.1.4/src/lucen_cli/robot/studio.py +266 -0
  29. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/robot/thumbnail.py +146 -31
  30. lucen_cli-0.1.4/src/lucen_cli/rollout/clip.py +389 -0
  31. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/rollout/conditions.py +36 -0
  32. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/rollout/contract.py +63 -1
  33. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/rollout/engine.py +105 -22
  34. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/rollout/harness.py +2 -2
  35. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/rollout/plant.py +138 -27
  36. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/rollout/worker.py +36 -6
  37. lucen_cli-0.1.4/src/lucen_cli/safe_text.py +72 -0
  38. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/templates/ppo_mujoco.py +189 -20
  39. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/templates/stub_ppo.py +7 -1
  40. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/LICENSE +0 -0
  41. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen/__init__.py +0 -0
  42. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen/errors.py +0 -0
  43. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen/hub.py +0 -0
  44. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen/progress.py +0 -0
  45. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen/py.typed +0 -0
  46. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen/results.py +0 -0
  47. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/checkpoint_dir.py +0 -0
  48. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/checkpoint_publisher.py +0 -0
  49. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/cmd_auth.py +0 -0
  50. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/cmd_holds.py +0 -0
  51. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/cmd_hub.py +0 -0
  52. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/cmd_org.py +0 -0
  53. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/cmd_repo.py +0 -0
  54. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/cmd_rollout.py +0 -0
  55. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/cmd_serve.py +0 -0
  56. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/cmd_worker.py +0 -0
  57. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/config.py +0 -0
  58. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/context.py +0 -0
  59. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/errors.py +0 -0
  60. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/extras.py +0 -0
  61. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/gate_scorer.py +0 -0
  62. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/group.py +0 -0
  63. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/live.py +0 -0
  64. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/local.py +0 -0
  65. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/offscreen.py +0 -0
  66. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/onnx_manifest.py +0 -0
  67. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/onnx_writer.py +0 -0
  68. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/ort_quiet.py +0 -0
  69. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/progress.py +0 -0
  70. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/pull.py +0 -0
  71. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/push.py +0 -0
  72. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/rl/__init__.py +0 -0
  73. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/robot/__init__.py +0 -0
  74. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/robot/collada.py +0 -0
  75. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/robot/confine.py +0 -0
  76. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/robot/gains.py +0 -0
  77. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/robot/hf.py +0 -0
  78. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/robot/hub.py +0 -0
  79. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/robot/intake.py +0 -0
  80. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/robot/model.py +0 -0
  81. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/robot/pose.py +0 -0
  82. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/robot/refs.py +0 -0
  83. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/robot/urdf.py +0 -0
  84. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/robot/worker.py +0 -0
  85. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/robot_assets.py +0 -0
  86. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/rollout/__init__.py +0 -0
  87. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/rollout/assets.py +0 -0
  88. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/rollout/obs.py +0 -0
  89. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/rollout/posture.py +0 -0
  90. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/rollout/video.py +0 -0
  91. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/serve/__init__.py +0 -0
  92. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/serve/lerobot_backend.py +0 -0
  93. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/serve/msgpack_numpy.py +0 -0
  94. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/serve/policy.py +0 -0
  95. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/serve/server.py +0 -0
  96. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/serve/session.py +0 -0
  97. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/serve/stub_skill.py +0 -0
  98. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/serve/vla.py +0 -0
  99. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/serve/worker.py +0 -0
  100. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/sim_words.py +0 -0
  101. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/state.py +0 -0
  102. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/templates/__init__.py +0 -0
  103. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/templates/ppo_isaac.py +0 -0
  104. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/templates/smolvla_lora.py +0 -0
  105. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/templates/stub_skill.py +0 -0
  106. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/tfevents.py +0 -0
  107. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/training_curve.py +0 -0
  108. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/transfer.py +0 -0
  109. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/vla_manifest.py +0 -0
  110. {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lucen-cli
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: MouseMouse CLI (`lucen`): push robot data, train and sim-test policies, hand them to a robot through a consent gate
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -129,7 +129,7 @@ AI agent are [`lucen-mcp`](https://pypi.org/project/lucen-mcp/).
129
129
  `lucen-sdk` 0.1.1.
130
130
  - **0.1.1** (2026-10-13): the built-in `ppo_mujoco` trainer's `velocity` preset
131
131
  trains under the sim tests' own conditions (the action delay, the rate limit
132
- and the pushes), so a quick run on Laika stands at about 2° of tilt instead
132
+ and the pushes), so a quick run on Lucen Biped stands at about 2° of tilt instead
133
133
  of a crouch; ONNX Runtime's start-up messages stay out of a run's log; every
134
134
  "install the extra" hint names the PyPI line.
135
135
  - **0.1.0**: the first release on PyPI.
@@ -93,7 +93,7 @@ AI agent are [`lucen-mcp`](https://pypi.org/project/lucen-mcp/).
93
93
  `lucen-sdk` 0.1.1.
94
94
  - **0.1.1** (2026-10-13): the built-in `ppo_mujoco` trainer's `velocity` preset
95
95
  trains under the sim tests' own conditions (the action delay, the rate limit
96
- and the pushes), so a quick run on Laika stands at about 2° of tilt instead
96
+ and the pushes), so a quick run on Lucen Biped stands at about 2° of tilt instead
97
97
  of a crouch; ONNX Runtime's start-up messages stay out of a run's log; every
98
98
  "install the extra" hint names the PyPI line.
99
99
  - **0.1.0**: the first release on PyPI.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lucen-cli"
3
- version = "0.1.2"
3
+ version = "0.1.4"
4
4
  description = "MouseMouse CLI (`lucen`): push robot data, train and sim-test policies, hand them to a robot through a consent gate"
5
5
  # R1: `readme`, `license`, `license-files` and `[project.urls]` are what PyPI
6
6
  # renders; the LICENSE beside this file is Apache-2.0 verbatim (docs/DECISIONS.md "R1").
@@ -1,3 +1,3 @@
1
1
  """MouseMouse CLI."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.1.4"
@@ -36,6 +36,8 @@ from typing import Any, Final
36
36
  import httpx
37
37
  from lucen_sdk import AuthenticatedClient
38
38
  from lucen_sdk.api.auth import get_me
39
+ from lucen_sdk.api.campaigns import get_campaign as get_campaign_api
40
+ from lucen_sdk.api.campaigns import update_campaign as update_campaign_api
39
41
  from lucen_sdk.api.datasets import list_dataset_episodes as list_dataset_episodes_api
40
42
  from lucen_sdk.api.datasets import put_dataset_meta as put_dataset_meta_api
41
43
  from lucen_sdk.api.devices import get_device_signing_key as get_device_signing_key_api
@@ -61,6 +63,7 @@ from lucen_sdk.api.files import (
61
63
  from lucen_sdk.api.files import (
62
64
  presign_upload as presign_upload_api,
63
65
  )
66
+ from lucen_sdk.api.meta import get_capabilities as get_capabilities_api
64
67
  from lucen_sdk.api.meta import healthz as healthz_api
65
68
  from lucen_sdk.api.models import put_model_meta as put_model_meta_api
66
69
  from lucen_sdk.api.orgs import create_org as create_org_api
@@ -123,15 +126,24 @@ from lucen_sdk.api.runs import (
123
126
  from lucen_sdk.api.runs import (
124
127
  claim_run as claim_run_api,
125
128
  )
129
+ from lucen_sdk.api.runs import (
130
+ compare_checkpoints as compare_checkpoints_api,
131
+ )
126
132
  from lucen_sdk.api.runs import (
127
133
  create_run as create_run_api,
128
134
  )
129
135
  from lucen_sdk.api.runs import (
130
136
  estimate_run as estimate_run_api,
131
137
  )
138
+ from lucen_sdk.api.runs import (
139
+ fork_run as fork_run_api,
140
+ )
132
141
  from lucen_sdk.api.runs import (
133
142
  get_run as get_run_api,
134
143
  )
144
+ from lucen_sdk.api.runs import (
145
+ get_run_curves as get_run_curves_api,
146
+ )
135
147
  from lucen_sdk.api.runs import (
136
148
  import_run as import_run_api,
137
149
  )
@@ -155,6 +167,9 @@ from lucen_sdk.api.spend import get_spend_hold as get_spend_hold_api
155
167
  from lucen_sdk.api.spend import list_spend_holds as list_spend_holds_api
156
168
  from lucen_sdk.api.workspace import get_my_overview as get_my_overview_api
157
169
  from lucen_sdk.models import (
170
+ Campaign,
171
+ CampaignUpdate,
172
+ CheckpointComparison,
158
173
  CheckpointGateTable,
159
174
  CheckpointList,
160
175
  CompletedUpload,
@@ -227,8 +242,11 @@ from lucen_sdk.models import (
227
242
  Run,
228
243
  RunClaim,
229
244
  RunCreate,
245
+ RunCurves,
230
246
  RunEstimate,
231
247
  RunExecutor,
248
+ RunFork,
249
+ RunForkResult,
232
250
  RunImport,
233
251
  RunImportArtifacts,
234
252
  RunImportArtifactsScorecardJsonType0,
@@ -639,6 +657,22 @@ class HubClient:
639
657
  response = healthz_api.sync_detailed(client=self._sdk)
640
658
  return self._expect(response, HealthResponse, action="Reaching the hub")
641
659
 
660
+ def studio_stills(self) -> str:
661
+ """S2: `getCapabilities.studio_stills` — `hosted` | `local` | `none`.
662
+
663
+ `none` for a hub that predates the field, or one that cannot be asked:
664
+ the publisher then draws its own still, as before.
665
+ """
666
+ try:
667
+ response = get_capabilities_api.sync_detailed(client=self._sdk)
668
+ except Exception: # a still is never worth failing a publish over
669
+ return "none"
670
+ parsed = response.parsed if int(response.status_code) == 200 else None
671
+ value = getattr(parsed, "studio_stills", None)
672
+ if value is None or isinstance(value, Unset):
673
+ return "none"
674
+ return str(getattr(value, "value", value))
675
+
642
676
  def overview(self) -> MyOverview:
643
677
  """`GET /v1/me/overview` (UI-API): what is yours right now, in counts (`key:read`)."""
644
678
  response = get_my_overview_api.sync_detailed(client=self._sdk)
@@ -1215,6 +1249,21 @@ class HubClient:
1215
1249
  cursor = page.next_cursor
1216
1250
  return out[:limit]
1217
1251
 
1252
+ def get_campaign(self, campaign: str) -> Campaign:
1253
+ response = get_campaign_api.sync_detailed(campaign, client=self._sdk)
1254
+ return self._expect(
1255
+ response, Campaign, action=f"Reading campaign {campaign}", needs_scope="read"
1256
+ )
1257
+
1258
+ def update_campaign(self, campaign: str, body: dict[str, Any]) -> Campaign:
1259
+ """`updateCampaign` (C5's `unattended`): a key may narrow or switch off, never widen."""
1260
+ response = update_campaign_api.sync_detailed(
1261
+ campaign, client=self._sdk, body=CampaignUpdate.from_dict(body)
1262
+ )
1263
+ return self._expect(
1264
+ response, Campaign, action=f"Changing campaign {campaign}", needs_scope="write"
1265
+ )
1266
+
1218
1267
  def cancel_spend_hold(self, hold_id: str) -> SpendHold:
1219
1268
  response = cancel_spend_hold_api.sync_detailed(hold_id, client=self._sdk)
1220
1269
  return self._expect(
@@ -1364,6 +1413,65 @@ class HubClient:
1364
1413
  needs_scope="read",
1365
1414
  )
1366
1415
 
1416
+ def get_run_curves(
1417
+ self,
1418
+ run_id: str,
1419
+ *,
1420
+ keys: str | None = None,
1421
+ max_points: int = 500,
1422
+ parent: bool = False,
1423
+ ) -> RunCurves:
1424
+ """`GET /v1/runs/{id}/curves` (I1a): the run's curves, summaries and flags."""
1425
+ response = get_run_curves_api.sync_detailed(
1426
+ run_id,
1427
+ client=self._sdk,
1428
+ keys=keys if keys else UNSET,
1429
+ max_points=max_points,
1430
+ parent=parent,
1431
+ )
1432
+ return self._expect(
1433
+ response, RunCurves, action=f"Reading the curves of run {run_id}", needs_scope="read"
1434
+ )
1435
+
1436
+ def compare_checkpoints(
1437
+ self, run_id: str, step: int, other_run_id: str, other_step: int
1438
+ ) -> CheckpointComparison:
1439
+ """`GET /v1/runs/{id}/compare` (AG4): two checkpoints side by side."""
1440
+ response = compare_checkpoints_api.sync_detailed(
1441
+ run_id, client=self._sdk, step=step, other_run_id=other_run_id, other_step=other_step
1442
+ )
1443
+ return self._expect(
1444
+ response,
1445
+ CheckpointComparison,
1446
+ action=f"Comparing {run_id}@{step} with {other_run_id}@{other_step}",
1447
+ needs_scope="read",
1448
+ )
1449
+
1450
+ def fork_run(
1451
+ self,
1452
+ run_id: str,
1453
+ body: dict[str, Any],
1454
+ *,
1455
+ dry_run: bool,
1456
+ idempotency_key: str | None = None,
1457
+ ) -> RunForkResult:
1458
+ """`POST /v1/runs/{id}/fork` (AG4): a child with one declared change."""
1459
+ response = fork_run_api.sync_detailed(
1460
+ run_id,
1461
+ client=self._sdk,
1462
+ body=RunFork.from_dict(body),
1463
+ dry_run=dry_run,
1464
+ idempotency_key=idempotency_key if idempotency_key else UNSET,
1465
+ )
1466
+ if int(response.status_code) == 202 and isinstance(response.parsed, SpendHold):
1467
+ raise SpendHeld(response.parsed, web=self.hold_url(response.parsed.id))
1468
+ return self._expect(
1469
+ response,
1470
+ RunForkResult,
1471
+ action=f"Forking run {run_id}" + (" (dry run)" if dry_run else ""),
1472
+ needs_scope="train",
1473
+ )
1474
+
1367
1475
  # -- kind-specific reads/writes the runner needs ------------------------
1368
1476
 
1369
1477
  # -- endpoints (M15): create / estimate / list / get / stop, and the serve protocol
@@ -0,0 +1,119 @@
1
+ """`lucen campaigns` — what a key may do to a campaign's unattended rounds (C5).
2
+
3
+ A person switches a campaign's unattended rounds on, for a stretch of rounds
4
+ and hours, on the campaign's page; continuing a stretch the hub paused is
5
+ theirs too. Both are a signed-in person's decision, so this command does
6
+ neither. It can only **narrow** the stretch (fewer rounds, fewer hours) or
7
+ switch it **off** — what any key with write access may do — and print where it
8
+ stands.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Annotated, Any
14
+
15
+ import typer
16
+
17
+ from lucen_cli.context import AppState
18
+ from lucen_cli.errors import EXIT_REFUSED, CLIError
19
+ from lucen_cli.group import LucenGroup
20
+ from lucen_cli.output import emit_json, stdout
21
+
22
+ app = typer.Typer(
23
+ cls=LucenGroup,
24
+ help="A campaign's unattended rounds: see where the stretch stands, narrow it, or turn it off.",
25
+ no_args_is_help=True,
26
+ )
27
+
28
+ JsonOpt = Annotated[bool, typer.Option("--json", help="Machine-readable output.")]
29
+
30
+ PERSON_ONLY = (
31
+ "Turning unattended rounds on, giving the stretch more rounds or hours, and continuing a "
32
+ "paused stretch are a signed-in person's decisions."
33
+ )
34
+
35
+
36
+ def stretch_line(unattended: dict[str, Any], predictions: dict[str, Any]) -> str:
37
+ """`Unattended: round 3 of 8 · paused (no_pass): …` — one line, as the page says it."""
38
+ hits = predictions.get("hits", 0)
39
+ misses = predictions.get("misses", 0)
40
+ scored = f"predictions {hits} hit, {misses} missed"
41
+ if not unattended.get("enabled"):
42
+ return f"Unattended rounds are off (stretch {unattended.get('max_rounds')} rounds, " + (
43
+ f"{unattended.get('max_hours'):g} hours when a person turns them on); {scored}."
44
+ )
45
+ line = (
46
+ f"Unattended: round {unattended.get('rounds_this_stretch', 0)} of "
47
+ f"{unattended.get('max_rounds')}, at most {unattended.get('max_hours'):g} hours"
48
+ )
49
+ paused = unattended.get("paused")
50
+ if paused:
51
+ return f"{line}; paused ({paused.get('code')}): {paused.get('reason')} {scored}."
52
+ return f"{line}; {scored}."
53
+
54
+
55
+ @app.command("unattended")
56
+ def unattended(
57
+ ctx: typer.Context,
58
+ campaign: Annotated[str, typer.Argument(metavar="CAMPAIGN", help="Its id or slug.")],
59
+ off: Annotated[bool, typer.Option("--off", help="Switch unattended rounds off.")] = False,
60
+ max_rounds: Annotated[
61
+ int | None,
62
+ typer.Option("--max-rounds", min=1, max=100, help="Fewer rounds per stretch."),
63
+ ] = None,
64
+ max_hours: Annotated[
65
+ float | None,
66
+ typer.Option("--max-hours", min=0.01, max=168, help="Fewer hours per stretch."),
67
+ ] = None,
68
+ as_json: JsonOpt = False,
69
+ ) -> None:
70
+ """Show a campaign's unattended stretch, narrow it, or switch it off.
71
+
72
+ With no option it prints where the stretch stands. A key may lower
73
+ `--max-rounds` / `--max-hours` or pass `--off`; turning it on, raising
74
+ either number and continuing a paused stretch happen on the campaign's
75
+ page, signed in.
76
+ """
77
+ state = ctx.ensure_object(AppState)
78
+ with state.client(require_auth=True) as client:
79
+ found = client.get_campaign(campaign).to_dict()
80
+ current = dict(found.get("unattended") or {})
81
+ body: dict[str, Any] = {}
82
+ if off:
83
+ body["enabled"] = False
84
+ if max_rounds is not None:
85
+ if max_rounds > int(current.get("max_rounds") or 0):
86
+ raise CLIError(
87
+ f"`--max-rounds {max_rounds}` is more than the stretch's "
88
+ f"{current.get('max_rounds')}: that widens it.",
89
+ hint=f"{PERSON_ONLY} Do it on the campaign's page.",
90
+ exit_code=EXIT_REFUSED,
91
+ )
92
+ body["max_rounds"] = max_rounds
93
+ if max_hours is not None:
94
+ if max_hours > float(current.get("max_hours") or 0):
95
+ raise CLIError(
96
+ f"`--max-hours {max_hours:g}` is more than the stretch's "
97
+ f"{current.get('max_hours'):g}: that widens it.",
98
+ hint=f"{PERSON_ONLY} Do it on the campaign's page.",
99
+ exit_code=EXIT_REFUSED,
100
+ )
101
+ body["max_hours"] = max_hours
102
+ if body:
103
+ found = client.update_campaign(found["id"], {"unattended": body}).to_dict()
104
+ mode = dict(found.get("unattended") or {})
105
+ if as_json:
106
+ emit_json(
107
+ {
108
+ "campaign": found.get("id"),
109
+ "slug": found.get("slug"),
110
+ "unattended": mode,
111
+ "predictions": found.get("predictions") or {},
112
+ "changed": bool(body),
113
+ }
114
+ )
115
+ return
116
+ stdout.print(f"{found.get('slug')} ({found.get('id')})", markup=False)
117
+ stdout.print(" " + stretch_line(mode, dict(found.get("predictions") or {})), markup=False)
118
+ if mode.get("paused"):
119
+ stdout.print(f" {PERSON_ONLY}", markup=False)
@@ -183,8 +183,10 @@ def render_import(result: ImportResult, *, as_json: bool) -> None:
183
183
  if result.readme_written:
184
184
  notes.append("README.md written with the provenance (the dataset shipped none)")
185
185
  if result.description_set:
186
- notes.append("description set to the provenance")
186
+ notes.append("description set")
187
187
  if notes:
188
188
  stdout.print(" " + "; ".join(notes) + ".", markup=False)
189
+ if result.description_set:
190
+ stdout.print(f" Description: {result.plan.description}", markup=False)
189
191
  page = f"/{result.repo}?tab=episodes"
190
192
  stdout.print((result.url + "?tab=episodes") if result.url else no_link(page), markup=False)
@@ -165,7 +165,7 @@ def stamp(
165
165
  stdout.print(
166
166
  f" contract obs {contract['obs_dim']} = "
167
167
  f"{' + '.join(b['name'] for b in contract['obs_spec'])}; action "
168
- f"{contract['action_dim']} ({contract['action_semantics']}); "
168
+ f"{contract['action_dim']} ({_semantics_line(contract)}); "
169
169
  f"{contract['control_rate_hz']:g} Hz; control_class {manifest['control_class']}"
170
170
  )
171
171
  if unchecked:
@@ -173,3 +173,14 @@ def stamp(
173
173
  f" [yellow]not checked[/yellow] the ONNX's {' and '.join(unchecked)} "
174
174
  "width is symbolic, so it could not be compared with the contract"
175
175
  )
176
+
177
+
178
+ def _semantics_line(contract: dict[str, Any]) -> str:
179
+ """`position_offset`, or `mixed: 8 position_offset, 2 torque` (T2)."""
180
+ layout = contract.get("action_semantics_vec")
181
+ if not isinstance(layout, list):
182
+ return str(contract["action_semantics"])
183
+ counts: dict[str, int] = {}
184
+ for semantics in layout:
185
+ counts[str(semantics)] = counts.get(str(semantics), 0) + 1
186
+ return f"{contract['action_semantics']}: " + ", ".join(f"{n} {s}" for s, n in counts.items())
@@ -195,12 +195,12 @@ def plan_no_file() -> list[Step]:
195
195
  return [
196
196
  Step(
197
197
  "lucen repo ls --kind robot",
198
- "The hub's robots (Laika, Go2, UR5e, SO-101 and more), each with a validated "
198
+ "The hub's robots (Lucen Biped, Go2, UR5e, SO-101 and more), each with a validated "
199
199
  "card and interface; pick one.",
200
200
  ),
201
201
  Step(
202
202
  "lucen runs create --template ppo_mujoco --robot lucen/laika --executor worker",
203
- "Queue a PPO run on Laika, the hub's own biped; it waits for a runner.",
203
+ "Queue a PPO run on Lucen Biped, the hub's own biped; it waits for a runner.",
204
204
  ),
205
205
  Step(
206
206
  "lucen worker run",
@@ -795,7 +795,9 @@ def card_get(
795
795
  card = get_card(client, RepoTarget.parse(repo))
796
796
  if output is not None and not as_json:
797
797
  editable = {
798
- k: v for k, v in card.items() if k not in ("interface_fingerprint", "updated_at")
798
+ k: v
799
+ for k, v in card.items()
800
+ if k not in ("interface_fingerprint", "updated_at", "stills")
799
801
  }
800
802
  output.write_text(
801
803
  json.dumps(editable, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
@@ -831,7 +833,7 @@ def card_set(
831
833
  f"{file} must hold a JSON object.",
832
834
  hint="`lucen robot card get OWNER/SLUG -o card.json` writes one in the right shape.",
833
835
  )
834
- for key in ("needs_you", "interface_fingerprint", "updated_at"):
836
+ for key in ("needs_you", "interface_fingerprint", "updated_at", "stills"):
835
837
  card.pop(key, None)
836
838
  target = RepoTarget.parse(repo)
837
839
  try:
@@ -513,6 +513,221 @@ def list_checkpoints(
513
513
  render_checkpoints(page, as_json=as_json)
514
514
 
515
515
 
516
+ @app.command("curves")
517
+ def run_curves(
518
+ ctx: typer.Context,
519
+ run_id: Annotated[str, typer.Argument(metavar="RUN_ID")],
520
+ keys: Annotated[
521
+ str | None,
522
+ typer.Option(
523
+ "--keys",
524
+ help="Comma-separated series: the hub's names (return, reward/action_rate) or the "
525
+ "logged ones (Train/mean_reward). Default: every known series.",
526
+ ),
527
+ ] = None,
528
+ parent: Annotated[
529
+ bool, typer.Option("--parent/--no-parent", help="Compare with the parent run.")
530
+ ] = True,
531
+ max_points: Annotated[
532
+ int, typer.Option("--max-points", min=2, max=5000, help="Points per series (--json).")
533
+ ] = 500,
534
+ as_json: JsonOpt = False,
535
+ ) -> None:
536
+ """A run's learning curves: last value, slope, plateau, the change against its parent."""
537
+ from lucen_cli.curves_view import render_curves
538
+
539
+ state = ctx.ensure_object(AppState)
540
+ with state.client(require_auth=True) as client:
541
+ curves = client.get_run_curves(run_id, keys=keys, max_points=max_points, parent=parent)
542
+ render_curves(curves, as_json=as_json)
543
+
544
+
545
+ @app.command("gates")
546
+ def run_gates(
547
+ ctx: typer.Context,
548
+ run_id: Annotated[str, typer.Argument(metavar="RUN_ID")],
549
+ step: Annotated[int | None, typer.Option("--step", min=0, help="One checkpoint.")] = None,
550
+ cells: Annotated[
551
+ bool, typer.Option("--cells", help="Each test's result, measured value and seeds upright.")
552
+ ] = False,
553
+ as_json: JsonOpt = False,
554
+ ) -> None:
555
+ """The sim tests a run's checkpoints got while it trained: one line per checkpoint."""
556
+ from lucen_cli.gates_view import render_gates
557
+
558
+ state = ctx.ensure_object(AppState)
559
+ with state.client(require_auth=True) as client:
560
+ table = client.get_run_gates(run_id).to_dict()
561
+ render_gates(table, step=step, cells=cells or step is not None, as_json=as_json)
562
+
563
+
564
+ @app.command("wait")
565
+ def run_wait(
566
+ ctx: typer.Context,
567
+ run_id: Annotated[str, typer.Argument(metavar="RUN_ID")],
568
+ until: Annotated[
569
+ str,
570
+ typer.Option(
571
+ "--for",
572
+ help="`gate`: the next sim test (or the one at --step). `finish`: the run's end.",
573
+ ),
574
+ ] = "gate",
575
+ step: Annotated[
576
+ int | None, typer.Option("--step", min=0, help="Wait for this checkpoint's test.")
577
+ ] = None,
578
+ timeout: Annotated[
579
+ float, typer.Option("--timeout", min=1, help="Seconds before giving up.")
580
+ ] = 600,
581
+ poll: Annotated[
582
+ float, typer.Option("--poll", min=1, max=60, help="Seconds between reads.")
583
+ ] = 5,
584
+ as_json: JsonOpt = False,
585
+ ) -> None:
586
+ """Wait for a run's sim test or its end. Exit 0 when it happened, 2 canceled, 1 otherwise.
587
+
588
+ The sentence says which: the gate landed (with its line), the run ended with no
589
+ test still coming, or the timeout passed.
590
+ """
591
+ from lucen_cli.gates_view import wait, wait_exit_code
592
+
593
+ if until not in ("gate", "finish"):
594
+ raise CLIError(
595
+ f"--for is `gate` or `finish`, not {until!r}.",
596
+ hint="`gate` waits for a sim test; `finish` for the run's end.",
597
+ )
598
+ if step is not None and until != "gate":
599
+ raise CLIError("--step goes with --for gate.", hint="Drop --step, or wait for a gate.")
600
+ state = ctx.ensure_object(AppState)
601
+ with state.client(require_auth=True) as client:
602
+ result = wait(client, run_id, until=until, step=step, timeout_s=timeout, poll_s=poll)
603
+ if as_json:
604
+ emit_json(result)
605
+ else:
606
+ stdout.print(str(result["said"]), markup=False)
607
+ code = wait_exit_code(result)
608
+ if code:
609
+ raise typer.Exit(code)
610
+
611
+
612
+ @app.command("compare")
613
+ def run_compare(
614
+ ctx: typer.Context,
615
+ a: Annotated[str, typer.Argument(metavar="RUN@STEP", help="Checkpoint A.")],
616
+ b: Annotated[str, typer.Argument(metavar="RUN@STEP", help="Checkpoint B (any run).")],
617
+ as_json: JsonOpt = False,
618
+ ) -> None:
619
+ """Two checkpoints side by side: sim tests, reward terms, the config diff, a card scored."""
620
+ from lucen_cli.gates_view import parse_ref, render_compare
621
+
622
+ run_a, step_a = parse_ref(a, "A")
623
+ run_b, step_b = parse_ref(b, "B")
624
+ state = ctx.ensure_object(AppState)
625
+ with state.client(require_auth=True) as client:
626
+ body = client.compare_checkpoints(run_a, step_a, run_b, step_b).to_dict()
627
+ render_compare(body, as_json=as_json)
628
+
629
+
630
+ def _parse_set(value: str) -> tuple[str, Any]:
631
+ path, sep, raw = value.partition("=")
632
+ if not sep or not path.strip():
633
+ raise CLIError(
634
+ f"--set is PATH=VALUE, e.g. task.rewards.action_rate=-0.02; got {value!r}.",
635
+ hint="The path is a resolved-config key, as the run page's Config tab prints it.",
636
+ )
637
+ try:
638
+ parsed: Any = json.loads(raw)
639
+ except ValueError:
640
+ parsed = raw
641
+ return path.strip(), parsed
642
+
643
+
644
+ def _parse_prediction(value: str) -> dict[str, Any]:
645
+ parts = value.split(":")
646
+ if len(parts) not in (2, 3) or parts[1] not in ("up", "same", "down"):
647
+ raise CLIError(
648
+ f"--predict is METRIC:DIRECTION[:VALUE], e.g. gates_passed:up:3; got {value!r}.",
649
+ hint="METRIC is gates_passed or one of the parent's sim tests; DIRECTION up|same|down.",
650
+ )
651
+ out: dict[str, Any] = {"metric": parts[0], "direction": parts[1]}
652
+ if len(parts) == 3:
653
+ try:
654
+ out["value"] = float(parts[2])
655
+ except ValueError as exc:
656
+ raise CLIError(f"--predict's value {parts[2]!r} is not a number.") from exc
657
+ return out
658
+
659
+
660
+ @app.command("fork")
661
+ def run_fork(
662
+ ctx: typer.Context,
663
+ run_id: Annotated[str, typer.Argument(metavar="RUN_ID")],
664
+ set_: Annotated[
665
+ str, typer.Option("--set", help="The ONE change, PATH=VALUE (VALUE is JSON or text).")
666
+ ],
667
+ step: Annotated[
668
+ int | None, typer.Option("--step", min=0, help="The checkpoint to resume from.")
669
+ ] = None,
670
+ scratch: Annotated[
671
+ bool, typer.Option("--from-scratch", help="Keep the contract, not the weights.")
672
+ ] = False,
673
+ predict: Annotated[
674
+ str | None, typer.Option("--predict", help="The card: METRIC:DIRECTION[:VALUE].")
675
+ ] = None,
676
+ hypothesis: Annotated[
677
+ str | None, typer.Option("--hypothesis", help="The card's one sentence.")
678
+ ] = None,
679
+ iterations: Annotated[
680
+ int | None, typer.Option("--iterations", min=1, help="How long the child trains.")
681
+ ] = None,
682
+ executor: Annotated[
683
+ str | None,
684
+ typer.Option(
685
+ "--executor", help="Where the child runs: worker or modal. Default: the parent's."
686
+ ),
687
+ ] = None,
688
+ gpu: Annotated[str | None, typer.Option("--gpu", help="The GPU tier on modal.")] = None,
689
+ dry_run: Annotated[
690
+ bool, typer.Option("--dry-run", help="Compose and check the child; create nothing.")
691
+ ] = False,
692
+ idempotency_key: Annotated[
693
+ str | None, typer.Option("--idempotency-key", help="Retry safely with the same key.")
694
+ ] = None,
695
+ as_json: JsonOpt = False,
696
+ ) -> None:
697
+ """A child of RUN with ONE config change, composed and checked by the hub."""
698
+ from lucen_cli.gates_view import render_fork
699
+
700
+ if (step is None) == (not scratch):
701
+ raise CLIError(
702
+ "Name the checkpoint with --step N, or start over with --from-scratch.",
703
+ hint="`lucen runs gates RUN` lists the tested steps.",
704
+ )
705
+ path, value = _parse_set(set_)
706
+ body: dict[str, Any] = {"checkpoint_step": step, "set": {path: value}}
707
+ if predict is not None:
708
+ body["card"] = {
709
+ "hypothesis": hypothesis or f"{path} -> {value}",
710
+ "prediction": _parse_prediction(predict),
711
+ }
712
+ for key, chosen in (("iterations", iterations), ("executor", executor), ("gpu", gpu)):
713
+ if chosen is not None:
714
+ body[key] = chosen
715
+ state = ctx.ensure_object(AppState)
716
+ client = state.client(require_auth=True)
717
+ try:
718
+ result = client.fork_run(
719
+ run_id, body, dry_run=dry_run, idempotency_key=idempotency_key
720
+ ).to_dict()
721
+ except SpendHeld as held:
722
+ render_hold(held.hold, web=held.web, as_json=as_json, held=True)
723
+ return
724
+ finally:
725
+ client.close()
726
+ render_fork(result, as_json=as_json)
727
+ if dry_run and result.get("refusals"):
728
+ raise typer.Exit(2)
729
+
730
+
516
731
  @app.command("import")
517
732
  def import_run(
518
733
  ctx: typer.Context,