lucen-cli 0.1.2__tar.gz → 0.1.3__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 (107) hide show
  1. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/PKG-INFO +2 -2
  2. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/README.md +1 -1
  3. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/pyproject.toml +1 -1
  4. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/__init__.py +1 -1
  5. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/client.py +91 -0
  6. lucen_cli-0.1.3/src/lucen_cli/cmd_campaigns.py +119 -0
  7. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/cmd_datasets.py +3 -1
  8. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/cmd_quickstart.py +2 -2
  9. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/cmd_runs.py +215 -0
  10. lucen_cli-0.1.3/src/lucen_cli/curves_view.py +104 -0
  11. lucen_cli-0.1.3/src/lucen_cli/gates_view.py +252 -0
  12. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/hf_dataset.py +39 -4
  13. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/main.py +4 -0
  14. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/output.py +3 -2
  15. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/rl/tasks.py +22 -7
  16. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/rl/vecenv.py +29 -1
  17. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/robot/draft.py +5 -0
  18. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/robot/thumbnail.py +96 -29
  19. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/rollout/harness.py +2 -2
  20. lucen_cli-0.1.3/src/lucen_cli/safe_text.py +72 -0
  21. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/templates/ppo_mujoco.py +6 -0
  22. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/templates/stub_ppo.py +7 -1
  23. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/LICENSE +0 -0
  24. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen/__init__.py +0 -0
  25. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen/errors.py +0 -0
  26. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen/hub.py +0 -0
  27. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen/progress.py +0 -0
  28. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen/py.typed +0 -0
  29. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen/results.py +0 -0
  30. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/checkpoint_dir.py +0 -0
  31. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/checkpoint_publisher.py +0 -0
  32. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/cmd_auth.py +0 -0
  33. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/cmd_holds.py +0 -0
  34. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/cmd_hub.py +0 -0
  35. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/cmd_model.py +0 -0
  36. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/cmd_org.py +0 -0
  37. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/cmd_repo.py +0 -0
  38. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/cmd_robot.py +0 -0
  39. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/cmd_rollout.py +0 -0
  40. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/cmd_serve.py +0 -0
  41. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/cmd_worker.py +0 -0
  42. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/config.py +0 -0
  43. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/context.py +0 -0
  44. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/contract_derive.py +0 -0
  45. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/embodiment.py +0 -0
  46. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/errors.py +0 -0
  47. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/extras.py +0 -0
  48. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/gate_scorer.py +0 -0
  49. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/group.py +0 -0
  50. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/live.py +0 -0
  51. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/local.py +0 -0
  52. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/model_card.py +0 -0
  53. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/offscreen.py +0 -0
  54. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/onnx_manifest.py +0 -0
  55. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/onnx_writer.py +0 -0
  56. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/ort_quiet.py +0 -0
  57. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/progress.py +0 -0
  58. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/pull.py +0 -0
  59. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/push.py +0 -0
  60. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/rl/__init__.py +0 -0
  61. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/rl/mirror.py +0 -0
  62. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/rl/ppo.py +0 -0
  63. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/robot/__init__.py +0 -0
  64. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/robot/checks.py +0 -0
  65. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/robot/collada.py +0 -0
  66. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/robot/confine.py +0 -0
  67. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/robot/gains.py +0 -0
  68. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/robot/hf.py +0 -0
  69. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/robot/hub.py +0 -0
  70. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/robot/intake.py +0 -0
  71. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/robot/model.py +0 -0
  72. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/robot/pose.py +0 -0
  73. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/robot/publish.py +0 -0
  74. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/robot/refs.py +0 -0
  75. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/robot/urdf.py +0 -0
  76. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/robot/worker.py +0 -0
  77. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/robot_assets.py +0 -0
  78. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/rollout/__init__.py +0 -0
  79. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/rollout/assets.py +0 -0
  80. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/rollout/conditions.py +0 -0
  81. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/rollout/contract.py +0 -0
  82. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/rollout/engine.py +0 -0
  83. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/rollout/obs.py +0 -0
  84. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/rollout/plant.py +0 -0
  85. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/rollout/posture.py +0 -0
  86. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/rollout/video.py +0 -0
  87. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/rollout/worker.py +0 -0
  88. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/serve/__init__.py +0 -0
  89. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/serve/lerobot_backend.py +0 -0
  90. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/serve/msgpack_numpy.py +0 -0
  91. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/serve/policy.py +0 -0
  92. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/serve/server.py +0 -0
  93. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/serve/session.py +0 -0
  94. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/serve/stub_skill.py +0 -0
  95. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/serve/vla.py +0 -0
  96. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/serve/worker.py +0 -0
  97. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/sim_words.py +0 -0
  98. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/state.py +0 -0
  99. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/templates/__init__.py +0 -0
  100. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/templates/ppo_isaac.py +0 -0
  101. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/templates/smolvla_lora.py +0 -0
  102. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/templates/stub_skill.py +0 -0
  103. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/tfevents.py +0 -0
  104. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/training_curve.py +0 -0
  105. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/transfer.py +0 -0
  106. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/src/lucen_cli/vla_manifest.py +0 -0
  107. {lucen_cli-0.1.2 → lucen_cli-0.1.3}/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.3
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.3"
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.3"
@@ -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
@@ -123,15 +125,24 @@ from lucen_sdk.api.runs import (
123
125
  from lucen_sdk.api.runs import (
124
126
  claim_run as claim_run_api,
125
127
  )
128
+ from lucen_sdk.api.runs import (
129
+ compare_checkpoints as compare_checkpoints_api,
130
+ )
126
131
  from lucen_sdk.api.runs import (
127
132
  create_run as create_run_api,
128
133
  )
129
134
  from lucen_sdk.api.runs import (
130
135
  estimate_run as estimate_run_api,
131
136
  )
137
+ from lucen_sdk.api.runs import (
138
+ fork_run as fork_run_api,
139
+ )
132
140
  from lucen_sdk.api.runs import (
133
141
  get_run as get_run_api,
134
142
  )
143
+ from lucen_sdk.api.runs import (
144
+ get_run_curves as get_run_curves_api,
145
+ )
135
146
  from lucen_sdk.api.runs import (
136
147
  import_run as import_run_api,
137
148
  )
@@ -155,6 +166,9 @@ from lucen_sdk.api.spend import get_spend_hold as get_spend_hold_api
155
166
  from lucen_sdk.api.spend import list_spend_holds as list_spend_holds_api
156
167
  from lucen_sdk.api.workspace import get_my_overview as get_my_overview_api
157
168
  from lucen_sdk.models import (
169
+ Campaign,
170
+ CampaignUpdate,
171
+ CheckpointComparison,
158
172
  CheckpointGateTable,
159
173
  CheckpointList,
160
174
  CompletedUpload,
@@ -227,8 +241,11 @@ from lucen_sdk.models import (
227
241
  Run,
228
242
  RunClaim,
229
243
  RunCreate,
244
+ RunCurves,
230
245
  RunEstimate,
231
246
  RunExecutor,
247
+ RunFork,
248
+ RunForkResult,
232
249
  RunImport,
233
250
  RunImportArtifacts,
234
251
  RunImportArtifactsScorecardJsonType0,
@@ -1215,6 +1232,21 @@ class HubClient:
1215
1232
  cursor = page.next_cursor
1216
1233
  return out[:limit]
1217
1234
 
1235
+ def get_campaign(self, campaign: str) -> Campaign:
1236
+ response = get_campaign_api.sync_detailed(campaign, client=self._sdk)
1237
+ return self._expect(
1238
+ response, Campaign, action=f"Reading campaign {campaign}", needs_scope="read"
1239
+ )
1240
+
1241
+ def update_campaign(self, campaign: str, body: dict[str, Any]) -> Campaign:
1242
+ """`updateCampaign` (C5's `unattended`): a key may narrow or switch off, never widen."""
1243
+ response = update_campaign_api.sync_detailed(
1244
+ campaign, client=self._sdk, body=CampaignUpdate.from_dict(body)
1245
+ )
1246
+ return self._expect(
1247
+ response, Campaign, action=f"Changing campaign {campaign}", needs_scope="write"
1248
+ )
1249
+
1218
1250
  def cancel_spend_hold(self, hold_id: str) -> SpendHold:
1219
1251
  response = cancel_spend_hold_api.sync_detailed(hold_id, client=self._sdk)
1220
1252
  return self._expect(
@@ -1364,6 +1396,65 @@ class HubClient:
1364
1396
  needs_scope="read",
1365
1397
  )
1366
1398
 
1399
+ def get_run_curves(
1400
+ self,
1401
+ run_id: str,
1402
+ *,
1403
+ keys: str | None = None,
1404
+ max_points: int = 500,
1405
+ parent: bool = False,
1406
+ ) -> RunCurves:
1407
+ """`GET /v1/runs/{id}/curves` (I1a): the run's curves, summaries and flags."""
1408
+ response = get_run_curves_api.sync_detailed(
1409
+ run_id,
1410
+ client=self._sdk,
1411
+ keys=keys if keys else UNSET,
1412
+ max_points=max_points,
1413
+ parent=parent,
1414
+ )
1415
+ return self._expect(
1416
+ response, RunCurves, action=f"Reading the curves of run {run_id}", needs_scope="read"
1417
+ )
1418
+
1419
+ def compare_checkpoints(
1420
+ self, run_id: str, step: int, other_run_id: str, other_step: int
1421
+ ) -> CheckpointComparison:
1422
+ """`GET /v1/runs/{id}/compare` (AG4): two checkpoints side by side."""
1423
+ response = compare_checkpoints_api.sync_detailed(
1424
+ run_id, client=self._sdk, step=step, other_run_id=other_run_id, other_step=other_step
1425
+ )
1426
+ return self._expect(
1427
+ response,
1428
+ CheckpointComparison,
1429
+ action=f"Comparing {run_id}@{step} with {other_run_id}@{other_step}",
1430
+ needs_scope="read",
1431
+ )
1432
+
1433
+ def fork_run(
1434
+ self,
1435
+ run_id: str,
1436
+ body: dict[str, Any],
1437
+ *,
1438
+ dry_run: bool,
1439
+ idempotency_key: str | None = None,
1440
+ ) -> RunForkResult:
1441
+ """`POST /v1/runs/{id}/fork` (AG4): a child with one declared change."""
1442
+ response = fork_run_api.sync_detailed(
1443
+ run_id,
1444
+ client=self._sdk,
1445
+ body=RunFork.from_dict(body),
1446
+ dry_run=dry_run,
1447
+ idempotency_key=idempotency_key if idempotency_key else UNSET,
1448
+ )
1449
+ if int(response.status_code) == 202 and isinstance(response.parsed, SpendHold):
1450
+ raise SpendHeld(response.parsed, web=self.hold_url(response.parsed.id))
1451
+ return self._expect(
1452
+ response,
1453
+ RunForkResult,
1454
+ action=f"Forking run {run_id}" + (" (dry run)" if dry_run else ""),
1455
+ needs_scope="train",
1456
+ )
1457
+
1367
1458
  # -- kind-specific reads/writes the runner needs ------------------------
1368
1459
 
1369
1460
  # -- 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)
@@ -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",
@@ -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,
@@ -0,0 +1,104 @@
1
+ """`lucen runs curves` — a run's curves as a table of summaries (I1a).
2
+
3
+ The hub computes everything (`GET /v1/runs/{id}/curves`); this prints it. A
4
+ terminal shows each series' last value, its slope over the last 10% of
5
+ iterations, whether that is a plateau and the change against the parent, then
6
+ the sim tests by step and the flags. The points themselves are for a chart:
7
+ `--json` carries them.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+ from lucen_sdk.models import RunCurves
15
+ from rich.table import Table
16
+
17
+ from lucen_cli.output import emit_json, stdout
18
+ from lucen_cli.safe_text import cell, inert
19
+
20
+
21
+ def _num(value: Any) -> str:
22
+ if value is None:
23
+ return "-"
24
+ number = float(value)
25
+ return f"{number:.4g}"
26
+
27
+
28
+ def _signed(value: Any) -> str:
29
+ return "-" if value is None else f"{float(value):+.3g}"
30
+
31
+
32
+ def render_curves(curves: RunCurves, *, as_json: bool) -> None:
33
+ data = curves.to_dict()
34
+ if as_json:
35
+ emit_json(data)
36
+ return
37
+ span = data.get("iterations") or {}
38
+ head = f"{inert(data['run_id'])} · {data['lines']} lines · {inert(data['vocabulary'])} names"
39
+ if span:
40
+ head += f" · iterations {_num(span.get('first'))}-{_num(span.get('last'))}"
41
+ stdout.print(head, markup=False)
42
+ parent = data.get("parent")
43
+ if parent:
44
+ stdout.print(
45
+ f"parent {inert(parent['run_id'])} ({inert(parent['source'])}), compared at iteration "
46
+ f"{_num(parent.get('compare_at'))}",
47
+ markup=False,
48
+ )
49
+ elif data.get("parent_reason"):
50
+ stdout.print(inert(data["parent_reason"]), markup=False)
51
+ series = data.get("series") or []
52
+ if not series:
53
+ stdout.print(inert(data.get("reason") or "No curves."), markup=False)
54
+ return
55
+ table = Table(box=None, pad_edge=False)
56
+ for column, justify in (
57
+ ("series", "left"),
58
+ ("last", "right"),
59
+ ("slope/it", "right"),
60
+ ("plateau", "left"),
61
+ ("weight", "right"),
62
+ ("parent", "right"),
63
+ ("delta", "right"),
64
+ ):
65
+ table.add_column(column, justify=justify) # type: ignore[arg-type]
66
+ for s in series:
67
+ summary = s.get("summary") or {}
68
+ weight = s.get("weight")
69
+ if weight is not None and s.get("weight_changed"):
70
+ shown = f"{weight:g} (was {s.get('parent_weight'):g})"
71
+ else:
72
+ shown = "-" if weight is None else f"{weight:g}"
73
+ plateau = summary.get("plateau")
74
+ # Text cells: a series name is the logger's, and `[steps]` is not markup.
75
+ table.add_row(
76
+ cell(s["key"] + (f" [{s['unit']}]" if s.get("unit") else "")),
77
+ cell(_num(summary.get("last"))),
78
+ cell(_signed(summary.get("slope"))),
79
+ cell("-" if plateau is None else ("yes" if plateau else "no")),
80
+ cell(shown),
81
+ cell(_num(summary.get("parent_at"))),
82
+ cell(_signed(summary.get("delta_vs_parent"))),
83
+ )
84
+ stdout.print(table)
85
+ if data.get("reward_terms_changed"):
86
+ stdout.print(
87
+ "Reward terms that moved most against the parent: "
88
+ + ", ".join(inert(term) for term in data["reward_terms_changed"]),
89
+ markup=False,
90
+ )
91
+ gates = [g for g in data.get("gates_by_step") or [] if g.get("status") == "succeeded"]
92
+ if gates:
93
+ stdout.print(
94
+ "Sim tests by step: "
95
+ + " · ".join(
96
+ f"{g['step']} {g.get('gates_passed')}/{g.get('gates_total')}" for g in gates
97
+ ),
98
+ markup=False,
99
+ )
100
+ for flag in data.get("flags") or []:
101
+ stdout.print(f"! {inert(flag['text'])}", markup=False)
102
+ if data.get("missing_keys"):
103
+ missing = ", ".join(inert(key) for key in data["missing_keys"])
104
+ stdout.print(f"Not in this run's log: {missing}", markup=False)