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.
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/PKG-INFO +2 -2
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/README.md +1 -1
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/pyproject.toml +1 -1
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/__init__.py +1 -1
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/client.py +108 -0
- lucen_cli-0.1.4/src/lucen_cli/cmd_campaigns.py +119 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/cmd_datasets.py +3 -1
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/cmd_model.py +12 -1
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/cmd_quickstart.py +2 -2
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/cmd_robot.py +4 -2
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/cmd_runs.py +215 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/contract_derive.py +50 -3
- lucen_cli-0.1.4/src/lucen_cli/curves_view.py +104 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/embodiment.py +176 -23
- lucen_cli-0.1.4/src/lucen_cli/gates_view.py +252 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/hf_dataset.py +39 -4
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/main.py +4 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/model_card.py +23 -5
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/output.py +3 -2
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/rl/mirror.py +2 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/rl/ppo.py +2 -1
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/rl/tasks.py +56 -11
- lucen_cli-0.1.4/src/lucen_cli/rl/terrain.py +282 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/rl/vecenv.py +72 -10
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/robot/checks.py +29 -6
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/robot/draft.py +5 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/robot/publish.py +9 -1
- lucen_cli-0.1.4/src/lucen_cli/robot/studio.py +266 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/robot/thumbnail.py +146 -31
- lucen_cli-0.1.4/src/lucen_cli/rollout/clip.py +389 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/rollout/conditions.py +36 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/rollout/contract.py +63 -1
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/rollout/engine.py +105 -22
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/rollout/harness.py +2 -2
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/rollout/plant.py +138 -27
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/rollout/worker.py +36 -6
- lucen_cli-0.1.4/src/lucen_cli/safe_text.py +72 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/templates/ppo_mujoco.py +189 -20
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/templates/stub_ppo.py +7 -1
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/LICENSE +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen/__init__.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen/errors.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen/hub.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen/progress.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen/py.typed +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen/results.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/checkpoint_dir.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/checkpoint_publisher.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/cmd_auth.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/cmd_holds.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/cmd_hub.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/cmd_org.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/cmd_repo.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/cmd_rollout.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/cmd_serve.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/cmd_worker.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/config.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/context.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/errors.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/extras.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/gate_scorer.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/group.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/live.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/local.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/offscreen.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/onnx_manifest.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/onnx_writer.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/ort_quiet.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/progress.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/pull.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/push.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/rl/__init__.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/robot/__init__.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/robot/collada.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/robot/confine.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/robot/gains.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/robot/hf.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/robot/hub.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/robot/intake.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/robot/model.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/robot/pose.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/robot/refs.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/robot/urdf.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/robot/worker.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/robot_assets.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/rollout/__init__.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/rollout/assets.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/rollout/obs.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/rollout/posture.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/rollout/video.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/serve/__init__.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/serve/lerobot_backend.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/serve/msgpack_numpy.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/serve/policy.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/serve/server.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/serve/session.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/serve/stub_skill.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/serve/vla.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/serve/worker.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/sim_words.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/state.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/templates/__init__.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/templates/ppo_isaac.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/templates/smolvla_lora.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/templates/stub_skill.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/tfevents.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/training_curve.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/transfer.py +0 -0
- {lucen_cli-0.1.2 → lucen_cli-0.1.4}/src/lucen_cli/vla_manifest.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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.
|
|
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").
|
|
@@ -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
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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,
|