lucen-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. lucen/__init__.py +72 -0
  2. lucen/errors.py +141 -0
  3. lucen/hub.py +889 -0
  4. lucen/progress.py +96 -0
  5. lucen/py.typed +0 -0
  6. lucen/results.py +885 -0
  7. lucen_cli/__init__.py +3 -0
  8. lucen_cli/checkpoint_dir.py +226 -0
  9. lucen_cli/checkpoint_publisher.py +269 -0
  10. lucen_cli/client.py +1730 -0
  11. lucen_cli/cmd_auth.py +256 -0
  12. lucen_cli/cmd_datasets.py +190 -0
  13. lucen_cli/cmd_holds.py +76 -0
  14. lucen_cli/cmd_hub.py +245 -0
  15. lucen_cli/cmd_model.py +175 -0
  16. lucen_cli/cmd_org.py +154 -0
  17. lucen_cli/cmd_quickstart.py +320 -0
  18. lucen_cli/cmd_repo.py +87 -0
  19. lucen_cli/cmd_robot.py +784 -0
  20. lucen_cli/cmd_rollout.py +576 -0
  21. lucen_cli/cmd_runs.py +615 -0
  22. lucen_cli/cmd_serve.py +438 -0
  23. lucen_cli/cmd_worker.py +135 -0
  24. lucen_cli/config.py +268 -0
  25. lucen_cli/context.py +43 -0
  26. lucen_cli/contract_derive.py +491 -0
  27. lucen_cli/embodiment.py +522 -0
  28. lucen_cli/errors.py +210 -0
  29. lucen_cli/gate_scorer.py +168 -0
  30. lucen_cli/group.py +104 -0
  31. lucen_cli/hf_dataset.py +696 -0
  32. lucen_cli/live.py +189 -0
  33. lucen_cli/local.py +160 -0
  34. lucen_cli/main.py +277 -0
  35. lucen_cli/model_card.py +523 -0
  36. lucen_cli/offscreen.py +52 -0
  37. lucen_cli/onnx_manifest.py +121 -0
  38. lucen_cli/onnx_writer.py +313 -0
  39. lucen_cli/ort_quiet.py +75 -0
  40. lucen_cli/output.py +868 -0
  41. lucen_cli/progress.py +133 -0
  42. lucen_cli/pull.py +158 -0
  43. lucen_cli/push.py +384 -0
  44. lucen_cli/rl/__init__.py +8 -0
  45. lucen_cli/rl/ppo.py +316 -0
  46. lucen_cli/rl/tasks.py +298 -0
  47. lucen_cli/rl/vecenv.py +246 -0
  48. lucen_cli/robot/__init__.py +66 -0
  49. lucen_cli/robot/checks.py +1416 -0
  50. lucen_cli/robot/confine.py +176 -0
  51. lucen_cli/robot/draft.py +523 -0
  52. lucen_cli/robot/hf.py +218 -0
  53. lucen_cli/robot/hub.py +227 -0
  54. lucen_cli/robot/intake.py +590 -0
  55. lucen_cli/robot/model.py +334 -0
  56. lucen_cli/robot/pose.py +132 -0
  57. lucen_cli/robot/publish.py +197 -0
  58. lucen_cli/robot/refs.py +212 -0
  59. lucen_cli/robot/thumbnail.py +586 -0
  60. lucen_cli/robot/urdf.py +435 -0
  61. lucen_cli/robot/worker.py +599 -0
  62. lucen_cli/robot_assets.py +127 -0
  63. lucen_cli/rollout/__init__.py +12 -0
  64. lucen_cli/rollout/assets.py +135 -0
  65. lucen_cli/rollout/conditions.py +486 -0
  66. lucen_cli/rollout/contract.py +314 -0
  67. lucen_cli/rollout/engine.py +1808 -0
  68. lucen_cli/rollout/harness.py +201 -0
  69. lucen_cli/rollout/obs.py +262 -0
  70. lucen_cli/rollout/plant.py +456 -0
  71. lucen_cli/rollout/posture.py +96 -0
  72. lucen_cli/rollout/video.py +548 -0
  73. lucen_cli/rollout/worker.py +505 -0
  74. lucen_cli/serve/__init__.py +22 -0
  75. lucen_cli/serve/lerobot_backend.py +337 -0
  76. lucen_cli/serve/msgpack_numpy.py +59 -0
  77. lucen_cli/serve/policy.py +276 -0
  78. lucen_cli/serve/server.py +474 -0
  79. lucen_cli/serve/session.py +142 -0
  80. lucen_cli/serve/stub_skill.py +264 -0
  81. lucen_cli/serve/vla.py +788 -0
  82. lucen_cli/serve/worker.py +578 -0
  83. lucen_cli/sim_words.py +55 -0
  84. lucen_cli/state.py +237 -0
  85. lucen_cli/templates/__init__.py +94 -0
  86. lucen_cli/templates/ppo_isaac.py +914 -0
  87. lucen_cli/templates/ppo_mujoco.py +664 -0
  88. lucen_cli/templates/smolvla_lora.py +986 -0
  89. lucen_cli/templates/stub_ppo.py +597 -0
  90. lucen_cli/templates/stub_skill.py +180 -0
  91. lucen_cli/tfevents.py +323 -0
  92. lucen_cli/training_curve.py +70 -0
  93. lucen_cli/transfer.py +209 -0
  94. lucen_cli/vla_manifest.py +322 -0
  95. lucen_cli/worker.py +1433 -0
  96. lucen_cli-0.1.0.dist-info/METADATA +113 -0
  97. lucen_cli-0.1.0.dist-info/RECORD +100 -0
  98. lucen_cli-0.1.0.dist-info/WHEEL +4 -0
  99. lucen_cli-0.1.0.dist-info/entry_points.txt +3 -0
  100. lucen_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
lucen/results.py ADDED
@@ -0,0 +1,885 @@
1
+ """What a call returns: small frozen records with the fields a script wants.
2
+
3
+ Each one carries ids, URLs and counts as plain attributes, a `to_dict()` that
4
+ is the same object `lucen … --json` prints where the CLI has one, and `raw` —
5
+ the generated SDK model (or the CLI engine's own result) with every field the
6
+ contract defines, for the one case the record does not cover.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Mapping
12
+ from dataclasses import asdict, dataclass, field
13
+ from datetime import datetime
14
+ from typing import Any
15
+
16
+ from lucen_sdk.models import (
17
+ Episode as SdkEpisode,
18
+ )
19
+ from lucen_sdk.models import (
20
+ Me as SdkMe,
21
+ )
22
+ from lucen_sdk.models import (
23
+ Member as SdkMember,
24
+ )
25
+ from lucen_sdk.models import (
26
+ Org as SdkOrg,
27
+ )
28
+ from lucen_sdk.models import (
29
+ OrgMembership as SdkOrgMembership,
30
+ )
31
+ from lucen_sdk.models import (
32
+ Repo as SdkRepo,
33
+ )
34
+ from lucen_sdk.models import (
35
+ Rollout as SdkRollout,
36
+ )
37
+ from lucen_sdk.models import (
38
+ Run as SdkRun,
39
+ )
40
+ from lucen_sdk.models import (
41
+ RunEstimate as SdkRunEstimate,
42
+ )
43
+ from lucen_sdk.models import (
44
+ RunRecommendation as SdkRunRecommendation,
45
+ )
46
+
47
+ from lucen_cli.client import SseEvent, unset_to_none
48
+ from lucen_cli.config import page_url
49
+ from lucen_cli.local import human_bytes
50
+ from lucen_cli.output import repo_dict, rollout_dict, run_dict
51
+ from lucen_cli.pull import PullResult as CliPullResult
52
+ from lucen_cli.push import PushResult as CliPushResult
53
+ from lucen_cli.sim_words import passing, suite_label
54
+
55
+
56
+ def _public(record: Any) -> dict[str, Any]:
57
+ """`asdict` without `raw` (an SDK model is not JSON)."""
58
+ data = asdict(record)
59
+ data.pop("raw", None)
60
+ return data
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Who you are
65
+ # ---------------------------------------------------------------------------
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class Me:
70
+ """`GET /v1/auth/me`: the user and what the credential may do."""
71
+
72
+ username: str
73
+ display_name: str | None
74
+ #: `api_key` or `session`.
75
+ auth_kind: str
76
+ #: The nested scopes the key grants: `read` ⊂ `write` ⊂ `train`.
77
+ scopes: tuple[str, ...]
78
+ key_id: str | None
79
+ #: `lucen_sk_xxxxxxxx…` — never the whole key.
80
+ key_prefix: str
81
+ #: `env`, `file` or `argument`: where the key came from.
82
+ key_source: str
83
+ api_url: str
84
+ raw: SdkMe = field(repr=False, compare=False)
85
+
86
+ @classmethod
87
+ def from_sdk(cls, me: SdkMe, *, key_prefix: str, key_source: str, api_url: str) -> Me:
88
+ return cls(
89
+ username=me.user.username,
90
+ display_name=unset_to_none(me.user.display_name),
91
+ auth_kind=str(me.auth.kind),
92
+ scopes=tuple(str(scope) for scope in me.auth.scopes),
93
+ key_id=unset_to_none(me.auth.key_id),
94
+ key_prefix=key_prefix,
95
+ key_source=key_source,
96
+ api_url=api_url,
97
+ raw=me,
98
+ )
99
+
100
+ def can(self, scope: str) -> bool:
101
+ """Whether the credential grants `scope` (`read`, `write` or `train`)."""
102
+ return scope in self.scopes
103
+
104
+ def to_dict(self) -> dict[str, Any]:
105
+ return _public(self)
106
+
107
+ def __str__(self) -> str:
108
+ return (
109
+ f"{self.username} · key {self.key_prefix} from {self.key_source} · "
110
+ f"scopes {', '.join(self.scopes) or 'none'} · {self.api_url}"
111
+ )
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Repos, push, pull
116
+ # ---------------------------------------------------------------------------
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class Repo:
121
+ """A dataset, model or robot repo as the hub lists it."""
122
+
123
+ id: str
124
+ full_name: str
125
+ owner: str
126
+ slug: str
127
+ #: `dataset`, `model` or `robot`.
128
+ kind: str
129
+ #: `public` or `private`.
130
+ visibility: str
131
+ description: str | None
132
+ tags: tuple[str, ...]
133
+ #: The repo's page on the hub's web app, when the hub names one.
134
+ url: str | None
135
+ created_at: datetime
136
+ updated_at: datetime
137
+ raw: SdkRepo = field(repr=False, compare=False)
138
+
139
+ @classmethod
140
+ def from_sdk(cls, repo: SdkRepo, *, web: str | None) -> Repo:
141
+ return cls(
142
+ id=repo.id,
143
+ full_name=repo.full_name,
144
+ owner=repo.owner.handle,
145
+ slug=repo.slug,
146
+ kind=str(repo.kind),
147
+ visibility=str(repo.visibility),
148
+ description=unset_to_none(repo.description),
149
+ tags=tuple(repo.tags),
150
+ url=page_url(web, f"/{repo.full_name}"),
151
+ created_at=repo.created_at,
152
+ updated_at=repo.updated_at,
153
+ raw=repo,
154
+ )
155
+
156
+ @property
157
+ def public(self) -> bool:
158
+ return self.visibility == "public"
159
+
160
+ def to_dict(self) -> dict[str, Any]:
161
+ """The row `lucen repo ls --json` prints."""
162
+ return repo_dict(self.raw)
163
+
164
+ def __str__(self) -> str:
165
+ return f"{self.full_name} ({self.kind}, {self.visibility})"
166
+
167
+
168
+ @dataclass(frozen=True)
169
+ class PushResult:
170
+ """What :meth:`lucen.Hub.push` did, in numbers a script can assert on."""
171
+
172
+ repo: str
173
+ url: str | None
174
+ #: True when the push created the repo.
175
+ created: bool
176
+ kind: str
177
+ visibility: str
178
+ files_total: int
179
+ #: Files the hub already held at that path with that sha256: untouched.
180
+ files_unchanged: int
181
+ #: Files the hub already held under another path or account: linked, no bytes.
182
+ files_linked: int
183
+ files_uploaded: int
184
+ parts_uploaded: int
185
+ bytes_uploaded: int
186
+ bytes_total: int
187
+ dry_run: bool
188
+ #: With `dry_run=True`: the paths that would have uploaded.
189
+ would_upload: tuple[str, ...]
190
+ raw: CliPushResult = field(repr=False, compare=False)
191
+
192
+ @classmethod
193
+ def from_cli(cls, result: CliPushResult) -> PushResult:
194
+ return cls(
195
+ repo=result.repo,
196
+ url=result.url,
197
+ created=result.created,
198
+ kind=result.kind,
199
+ visibility=result.visibility,
200
+ files_total=result.files_total,
201
+ files_unchanged=result.files_unchanged,
202
+ files_linked=result.files_linked,
203
+ files_uploaded=result.files_uploaded,
204
+ parts_uploaded=result.parts_uploaded,
205
+ bytes_uploaded=result.bytes_uploaded,
206
+ bytes_total=result.bytes_total,
207
+ dry_run=result.dry_run,
208
+ would_upload=tuple(result.would_upload),
209
+ raw=result,
210
+ )
211
+
212
+ def to_dict(self) -> dict[str, Any]:
213
+ """The object `lucen push --json` prints."""
214
+ return self.raw.to_dict()
215
+
216
+ def __str__(self) -> str:
217
+ verb = "Created and pushed" if self.created else "Pushed"
218
+ return (
219
+ f"{verb} {self.repo}: {self.files_total} file(s), {self.files_uploaded} uploaded "
220
+ f"({human_bytes(self.bytes_uploaded)}), {self.files_linked} deduped, "
221
+ f"{self.files_unchanged} unchanged"
222
+ )
223
+
224
+
225
+ @dataclass(frozen=True)
226
+ class PullResult:
227
+ """What :meth:`lucen.Hub.pull` did. Every downloaded file's sha256 was verified."""
228
+
229
+ repo: str
230
+ #: The destination directory, absolute.
231
+ path: str
232
+ files_total: int
233
+ files_downloaded: int
234
+ #: Files already on disk with the right size and sha256.
235
+ files_skipped: int
236
+ bytes_downloaded: int
237
+ bytes_total: int
238
+ raw: CliPullResult = field(repr=False, compare=False)
239
+
240
+ @classmethod
241
+ def from_cli(cls, result: CliPullResult) -> PullResult:
242
+ return cls(
243
+ repo=result.repo,
244
+ path=result.dest,
245
+ files_total=result.files_total,
246
+ files_downloaded=result.files_downloaded,
247
+ files_skipped=result.files_skipped,
248
+ bytes_downloaded=result.bytes_downloaded,
249
+ bytes_total=result.bytes_total,
250
+ raw=result,
251
+ )
252
+
253
+ def to_dict(self) -> dict[str, Any]:
254
+ """The object `lucen pull --json` prints."""
255
+ return self.raw.to_dict()
256
+
257
+ def __str__(self) -> str:
258
+ return (
259
+ f"Pulled {self.repo}: {self.files_total} file(s), {self.files_downloaded} downloaded "
260
+ f"({human_bytes(self.bytes_downloaded)}), {self.files_skipped} already local"
261
+ )
262
+
263
+
264
+ # ---------------------------------------------------------------------------
265
+ # Runs
266
+ # ---------------------------------------------------------------------------
267
+
268
+
269
+ @dataclass(frozen=True)
270
+ class Gate:
271
+ """A run's sim-gate count: `passed` of `total` cells PASS on its latest scorecard."""
272
+
273
+ passed: int
274
+ total: int
275
+
276
+ def __str__(self) -> str:
277
+ return f"{self.passed} / {self.total} PASS"
278
+
279
+
280
+ @dataclass(frozen=True)
281
+ class Run:
282
+ """A training run as the hub reads it back."""
283
+
284
+ id: str
285
+ #: `queued`, `running`, `succeeded`, `failed` or `canceled`.
286
+ status: str
287
+ #: `cloud` (executed), `external` (imported) or `orchestration`.
288
+ kind: str
289
+ template: str
290
+ #: `worker` (a runner you start) or `modal` (the hub's GPU backend).
291
+ executor: str | None
292
+ owner: str
293
+ parent_run_id: str | None
294
+ claimed_by: str | None
295
+ #: `owner/slug` of the model repo the run published, once it has.
296
+ output_model_repo: str | None
297
+ #: The last metrics line the runner reported.
298
+ latest_metrics: dict[str, Any] | None
299
+ #: The sim gate's count on the run's scorecard, when it has one.
300
+ gate: Gate | None
301
+ cost_usd: float | None
302
+ error: str | None
303
+ #: The run's page on the hub's web app, when the hub names one.
304
+ url: str | None
305
+ created_at: datetime
306
+ finished_at: datetime | None
307
+ #: On a create: whether this call made the run (False on an idempotent replay).
308
+ created: bool | None
309
+ raw: SdkRun = field(repr=False, compare=False)
310
+
311
+ @classmethod
312
+ def from_sdk(cls, run: SdkRun, *, web: str | None, created: bool | None = None) -> Run:
313
+ data = run_dict(run)
314
+ gate = unset_to_none(run.gate)
315
+ return cls(
316
+ id=run.id,
317
+ status=data["status"],
318
+ kind=data["kind"],
319
+ template=data["template"],
320
+ executor=data["executor"],
321
+ owner=data["owner"],
322
+ parent_run_id=data["parent_run_id"],
323
+ claimed_by=data["claimed_by"],
324
+ output_model_repo=data["output_model_repo"],
325
+ latest_metrics=data["latest_metrics"],
326
+ gate=Gate(gate.passed, gate.total) if gate is not None else None,
327
+ cost_usd=data["cost_usd"],
328
+ error=data["error"],
329
+ url=page_url(web, f"/runs/{run.id}"),
330
+ created_at=run.created_at,
331
+ finished_at=unset_to_none(run.finished_at),
332
+ created=created,
333
+ raw=run,
334
+ )
335
+
336
+ @property
337
+ def done(self) -> bool:
338
+ """True once the run is `succeeded`, `failed` or `canceled`."""
339
+ return self.status in ("succeeded", "failed", "canceled")
340
+
341
+ def to_dict(self) -> dict[str, Any]:
342
+ """The object `lucen runs show --json` prints."""
343
+ data = run_dict(self.raw)
344
+ data["url"] = self.url
345
+ if self.created is not None:
346
+ data["created"] = self.created
347
+ return data
348
+
349
+ def __str__(self) -> str:
350
+ bits = [f"{self.id} ({self.status}, {self.template}"]
351
+ if self.executor:
352
+ bits[0] += f" on {self.executor}"
353
+ bits[0] += ")"
354
+ if self.output_model_repo:
355
+ bits.append(f"model {self.output_model_repo}")
356
+ if self.gate is not None:
357
+ bits.append(f"gate {self.gate}")
358
+ if self.error:
359
+ bits.append(f"error: {self.error.splitlines()[0]}")
360
+ return " · ".join(bits)
361
+
362
+
363
+ @dataclass(frozen=True)
364
+ class LogEvent:
365
+ """One frame of a run's log stream.
366
+
367
+ `kind` is ``log`` (a line: `line`, with `stream` ``stdout`` for the
368
+ trainer's own output and ``stderr`` for a notice the hub wrote into the
369
+ log — a cancel request, a re-queue), ``metric`` (one `metrics.jsonl` line
370
+ as `metrics`, numbered by `index`), or ``end`` (the stream closed; `status`
371
+ is the run's status at that moment).
372
+ """
373
+
374
+ kind: str
375
+ line: str | None = None
376
+ stream: str | None = None
377
+ ts: str | None = None
378
+ metrics: dict[str, Any] | None = None
379
+ index: int | None = None
380
+ status: str | None = None
381
+ raw: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
382
+
383
+ @classmethod
384
+ def from_sse(cls, event: SseEvent) -> LogEvent:
385
+ data = event.data
386
+ metrics = data.get("metrics")
387
+ index = data.get("index")
388
+ return cls(
389
+ kind=event.event,
390
+ line=data.get("line") if event.event == "log" else None,
391
+ stream=data.get("stream") if event.event == "log" else None,
392
+ ts=data.get("ts"),
393
+ metrics=dict(metrics) if isinstance(metrics, Mapping) else None,
394
+ index=int(index) if isinstance(index, int) else None,
395
+ status=data.get("status") if event.event == "end" else None,
396
+ raw=dict(data),
397
+ )
398
+
399
+ @property
400
+ def is_notice(self) -> bool:
401
+ """True for a line the hub wrote, not the trainer (`stream == "stderr"`)."""
402
+ return self.kind == "log" and self.stream == "stderr"
403
+
404
+ @property
405
+ def text(self) -> str:
406
+ """The line as `lucen train --follow` prints it: ` | …` the runner, ` ! …` the hub."""
407
+ if self.kind == "log":
408
+ return f" {'!' if self.is_notice else '|'} {self.line or ''}"
409
+ if self.kind == "end":
410
+ return f"-- {self.status}"
411
+ return f" = {self.metrics}"
412
+
413
+ def __str__(self) -> str:
414
+ return self.text
415
+
416
+
417
+ @dataclass(frozen=True)
418
+ class RunEstimate:
419
+ """`POST /v1/runs/estimate`: the price of a recipe before anything is created."""
420
+
421
+ executor: str
422
+ tier: str | None
423
+ rate_usd_per_hour: float
424
+ #: C1: the Isaac host's share, on top of the GPU rate (0 elsewhere).
425
+ host_rate_usd_per_hour: float
426
+ max_hours: float
427
+ cost_usd_max: float
428
+ #: False on a `worker` run: your own machine, nothing billed.
429
+ priced: bool
430
+ budget_usd: float | None
431
+ capped_by_budget: bool
432
+ raw: SdkRunEstimate = field(repr=False, compare=False)
433
+
434
+ @classmethod
435
+ def from_sdk(cls, estimate: SdkRunEstimate) -> RunEstimate:
436
+ return cls(
437
+ executor=str(estimate.executor),
438
+ tier=estimate.tier,
439
+ rate_usd_per_hour=estimate.rate_usd_per_hour,
440
+ host_rate_usd_per_hour=float(unset_to_none(estimate.host_rate_usd_per_hour) or 0.0),
441
+ max_hours=estimate.max_hours,
442
+ cost_usd_max=estimate.cost_usd_max,
443
+ priced=estimate.priced,
444
+ budget_usd=unset_to_none(estimate.budget_usd),
445
+ capped_by_budget=bool(unset_to_none(estimate.capped_by_budget) or False),
446
+ raw=estimate,
447
+ )
448
+
449
+ def to_dict(self) -> dict[str, Any]:
450
+ return _public(self)
451
+
452
+ def __str__(self) -> str:
453
+ if not self.priced:
454
+ return f"{self.executor}: not priced (your own machine), up to {self.max_hours:g} h"
455
+ return (
456
+ f"{self.executor} on {self.tier}: ${self.rate_usd_per_hour:.4f}/h x "
457
+ f"{self.max_hours:g} h = at most ${self.cost_usd_max:.4f}"
458
+ )
459
+
460
+
461
+ @dataclass(frozen=True)
462
+ class RunRecommendation:
463
+ """`POST /v1/runs/recommend`: the hub's deterministic hardware planner, with its arithmetic."""
464
+
465
+ template: str
466
+ #: The smallest GPU tier that fits (`l4`, `a10g`, `a100`, `h100`, `l40s`), or None.
467
+ tier: str | None
468
+ vram_estimate_gb: float
469
+ vram_required_gb: float
470
+ fits_on: tuple[str, ...]
471
+ est_hours: float | None
472
+ cost_usd_est: float | None
473
+ cost_usd_max: float
474
+ priced: bool
475
+ #: `modelled` or `measured` (from succeeded runs of the same template).
476
+ basis: str
477
+ #: Whether the hub can execute the template, or only plan it.
478
+ implemented: bool
479
+ #: One line per step of the arithmetic.
480
+ why: tuple[str, ...]
481
+ warnings: tuple[str, ...]
482
+ raw: SdkRunRecommendation = field(repr=False, compare=False)
483
+
484
+ @classmethod
485
+ def from_sdk(cls, rec: SdkRunRecommendation) -> RunRecommendation:
486
+ return cls(
487
+ template=rec.template,
488
+ tier=rec.tier,
489
+ vram_estimate_gb=rec.vram_estimate_gb,
490
+ vram_required_gb=rec.vram_required_gb,
491
+ fits_on=tuple(rec.fits_on),
492
+ est_hours=rec.est_hours,
493
+ cost_usd_est=rec.cost_usd_est,
494
+ cost_usd_max=rec.cost_usd_max,
495
+ priced=rec.priced,
496
+ basis=str(rec.basis),
497
+ implemented=rec.implemented,
498
+ why=tuple(rec.why),
499
+ warnings=tuple(rec.warnings),
500
+ raw=rec,
501
+ )
502
+
503
+ def to_dict(self) -> dict[str, Any]:
504
+ return _public(self)
505
+
506
+ def __str__(self) -> str:
507
+ tier = self.tier or "no tier fits"
508
+ return (
509
+ f"{self.template}: {tier} — {self.vram_required_gb:g} GB required "
510
+ f"({self.vram_estimate_gb:g} GB {self.basis}), fits on {', '.join(self.fits_on) or '—'}"
511
+ )
512
+
513
+
514
+ # ---------------------------------------------------------------------------
515
+ # Rollouts (the sim gate)
516
+ # ---------------------------------------------------------------------------
517
+
518
+
519
+ @dataclass(frozen=True)
520
+ class Cell:
521
+ """One cell of a rollout's battery, with the hub's verdict."""
522
+
523
+ name: str
524
+ #: `PASS` or `FAIL`, or None while the rollout is not finished.
525
+ verdict: str | None
526
+ seeds: int
527
+ #: Seeds that stayed upright for the whole horizon.
528
+ alive: int
529
+ #: Per-axis tracking ratio (`vx`, `vy`, `yaw`), when the cell commands one.
530
+ tracking: dict[str, Any]
531
+ detail: str | None
532
+ #: `velocity`, `joint_target`, `reach`, `recovery`.
533
+ kind: str
534
+ #: Presigned URLs (an hour) for the mp4 and the trajectory JSON.
535
+ video_url: str | None
536
+ traj_url: str | None
537
+
538
+ @property
539
+ def passed(self) -> bool:
540
+ return self.verdict == "PASS"
541
+
542
+ def __str__(self) -> str:
543
+ return f"{self.name}: {self.verdict or '—'} ({self.alive}/{self.seeds} upright)"
544
+
545
+
546
+ @dataclass(frozen=True)
547
+ class Rollout:
548
+ """A rollout — a policy run in MuJoCo on a robot's own model, scored by the hub."""
549
+
550
+ id: str
551
+ #: `queued`, `running`, `succeeded`, `failed` or `canceled`.
552
+ status: str
553
+ #: `owner/slug` of the model repo.
554
+ model: str
555
+ #: `owner/slug` of the robot repo.
556
+ robot: str
557
+ battery: str | None
558
+ seed: int
559
+ horizon_s: float
560
+ #: The training run the scorecard is attached to, when one was named.
561
+ run_id: str | None
562
+ claimed_by: str | None
563
+ cells: tuple[Cell, ...]
564
+ #: Cells that PASS, out of all cells, once scored.
565
+ passed: int | None
566
+ total: int | None
567
+ warnings: tuple[str, ...]
568
+ cost_usd: float | None
569
+ priced: bool
570
+ error: str | None
571
+ url: str | None
572
+ created_at: datetime
573
+ finished_at: datetime | None
574
+ raw: SdkRollout = field(repr=False, compare=False)
575
+
576
+ @classmethod
577
+ def from_sdk(cls, rollout: SdkRollout, *, web: str | None) -> Rollout:
578
+ data = rollout_dict(rollout)
579
+ cells = tuple(
580
+ Cell(
581
+ name=cell["name"],
582
+ verdict=cell["status"],
583
+ seeds=cell["seeds"],
584
+ alive=cell["alive"],
585
+ tracking=dict(cell.get("tracking") or {}),
586
+ detail=cell.get("detail"),
587
+ kind=str(cell.get("kind") or "velocity"),
588
+ video_url=cell.get("video_url"),
589
+ traj_url=cell.get("traj_url"),
590
+ )
591
+ for cell in data["cells"]
592
+ )
593
+ return cls(
594
+ id=rollout.id,
595
+ status=data["status"],
596
+ model=data["model_repo"],
597
+ robot=data["robot_repo"],
598
+ battery=data["battery"],
599
+ seed=data["seed"],
600
+ horizon_s=data["horizon_s"],
601
+ run_id=data["run_id"],
602
+ claimed_by=data["claimed_by"],
603
+ cells=cells,
604
+ passed=data["gates_passed"],
605
+ total=data["gates_total"],
606
+ warnings=tuple(data["warnings"]),
607
+ cost_usd=data["cost_usd"],
608
+ priced=data["priced"],
609
+ error=data["error"],
610
+ url=page_url(web, f"/sim-tests/{rollout.id}"),
611
+ created_at=rollout.created_at,
612
+ finished_at=unset_to_none(rollout.finished_at),
613
+ raw=rollout,
614
+ )
615
+
616
+ @property
617
+ def done(self) -> bool:
618
+ return self.status in ("succeeded", "failed", "canceled")
619
+
620
+ @property
621
+ def scorecard(self) -> dict[str, str | None]:
622
+ """`{cell name: verdict}` — the hub's PASS/FAIL per cell."""
623
+ return {cell.name: cell.verdict for cell in self.cells}
624
+
625
+ def to_dict(self) -> dict[str, Any]:
626
+ """The object `lucen rollout show --json` prints."""
627
+ data = rollout_dict(self.raw)
628
+ data["url"] = self.url
629
+ return data
630
+
631
+ def __str__(self) -> str:
632
+ # Q9: the web's words; `to_dict()` and `scorecard()` keep PASS / FAIL and `battery`.
633
+ head = f"Sim test {self.id} ({self.status}): {self.model} on {self.robot}, "
634
+ head += suite_label(self.battery)
635
+ if self.total is not None:
636
+ head += f". {passing(self.passed, self.total)}"
637
+ return head + "."
638
+
639
+
640
+ # ---------------------------------------------------------------------------
641
+ # Organizations, datasets, robots
642
+ # ---------------------------------------------------------------------------
643
+
644
+
645
+ @dataclass(frozen=True)
646
+ class Org:
647
+ """An organization: a handle a lab shares."""
648
+
649
+ slug: str
650
+ display_name: str | None
651
+ description: str | None
652
+ #: Your role in it (`owner` or `member`), when the call knows it.
653
+ role: str | None
654
+ url: str | None
655
+ raw: SdkOrg = field(repr=False, compare=False)
656
+
657
+ @classmethod
658
+ def from_sdk(cls, org: SdkOrg, *, web: str | None, role: str | None = None) -> Org:
659
+ return cls(
660
+ slug=org.slug,
661
+ display_name=unset_to_none(org.display_name),
662
+ description=unset_to_none(org.description),
663
+ role=role,
664
+ url=page_url(web, f"/{org.slug}"),
665
+ raw=org,
666
+ )
667
+
668
+ @classmethod
669
+ def from_membership(cls, membership: SdkOrgMembership, *, web: str | None) -> Org:
670
+ return cls.from_sdk(membership.org, web=web, role=str(membership.role))
671
+
672
+ def to_dict(self) -> dict[str, Any]:
673
+ return _public(self)
674
+
675
+ def __str__(self) -> str:
676
+ return f"{self.slug}" + (f" ({self.role})" if self.role else "")
677
+
678
+
679
+ @dataclass(frozen=True)
680
+ class Member:
681
+ """A person in an organization, by hub handle."""
682
+
683
+ username: str
684
+ display_name: str | None
685
+ #: `owner` or `member`.
686
+ role: str
687
+ added_at: datetime
688
+ raw: SdkMember = field(repr=False, compare=False)
689
+
690
+ @classmethod
691
+ def from_sdk(cls, member: SdkMember) -> Member:
692
+ return cls(
693
+ username=member.user.username,
694
+ display_name=unset_to_none(member.user.display_name),
695
+ role=str(member.role),
696
+ added_at=member.created_at,
697
+ raw=member,
698
+ )
699
+
700
+ def to_dict(self) -> dict[str, Any]:
701
+ return _public(self)
702
+
703
+ def __str__(self) -> str:
704
+ return f"{self.username} ({self.role})"
705
+
706
+
707
+ @dataclass(frozen=True)
708
+ class Episode:
709
+ """One episode of a LeRobot-format dataset, as the hub indexes it."""
710
+
711
+ index: int
712
+ num_frames: int
713
+ tasks: tuple[str, ...]
714
+ raw: SdkEpisode = field(repr=False, compare=False)
715
+
716
+ @classmethod
717
+ def from_sdk(cls, episode: SdkEpisode) -> Episode:
718
+ return cls(
719
+ index=episode.index,
720
+ num_frames=episode.num_frames,
721
+ tasks=tuple(unset_to_none(episode.tasks) or ()),
722
+ raw=episode,
723
+ )
724
+
725
+ def to_dict(self) -> dict[str, Any]:
726
+ return _public(self)
727
+
728
+ def __str__(self) -> str:
729
+ task = f" — {self.tasks[0]}" if self.tasks else ""
730
+ return f"episode {self.index}: {self.num_frames} frames{task}"
731
+
732
+
733
+ @dataclass(frozen=True)
734
+ class RobotDraft:
735
+ """What `lucen robot init` wrote: the card file and what you still have to declare."""
736
+
737
+ #: Absolute path of the drafted `robot_card.json`.
738
+ card_file: str
739
+ directory: str
740
+ mjcf_path: str
741
+ #: `mjcf` or `urdf` (converted).
742
+ source: str
743
+ joints: tuple[str, ...]
744
+ #: `fixed` or `floating`.
745
+ base: str
746
+ #: What the model file could not state: one entry per field, with a severity.
747
+ needs_you: tuple[dict[str, Any], ...]
748
+ #: The whole result, as `lucen robot init --json` prints it.
749
+ raw: dict[str, Any] = field(repr=False, compare=False)
750
+
751
+ @classmethod
752
+ def from_cli(cls, result: dict[str, Any]) -> RobotDraft:
753
+ model = result.get("model") or {}
754
+ return cls(
755
+ card_file=str(result["card_file"]),
756
+ directory=str(result["directory"]),
757
+ mjcf_path=str(result["mjcf_path"]),
758
+ source=str(result["source"]),
759
+ joints=tuple(str(name) for name in model.get("joints") or ()),
760
+ base=str(model.get("base") or ""),
761
+ needs_you=tuple(dict(item) for item in result.get("needs_you") or ()),
762
+ raw=result,
763
+ )
764
+
765
+ @property
766
+ def blocking(self) -> tuple[dict[str, Any], ...]:
767
+ """The `needs_you` entries that fail validation until declared."""
768
+ return tuple(item for item in self.needs_you if item.get("severity") == "blocking")
769
+
770
+ def to_dict(self) -> dict[str, Any]:
771
+ return dict(self.raw)
772
+
773
+ def __str__(self) -> str:
774
+ return (
775
+ f"{self.card_file}: {len(self.joints)} joints, {self.base} base, "
776
+ f"{len(self.needs_you)} thing(s) to declare ({len(self.blocking)} blocking)"
777
+ )
778
+
779
+
780
+ @dataclass(frozen=True)
781
+ class RobotReport:
782
+ """`lucen robot validate`'s report: every check with its code, status and message."""
783
+
784
+ ok: bool
785
+ #: `pass`, `warn` or `fail`.
786
+ grade: str
787
+ #: `mujoco` (the CLI compiled it) or `hub` (the hub's static checks).
788
+ engine: str
789
+ checks: tuple[dict[str, Any], ...]
790
+ counts: dict[str, int]
791
+ interface_fingerprint: str | None
792
+ #: The whole report, as `lucen robot validate --json` prints it.
793
+ raw: dict[str, Any] = field(repr=False, compare=False)
794
+
795
+ @classmethod
796
+ def from_cli(cls, report: dict[str, Any]) -> RobotReport:
797
+ return cls(
798
+ ok=bool(report.get("ok")),
799
+ grade=str(report.get("grade") or ("pass" if report.get("ok") else "fail")),
800
+ engine=str(report.get("engine") or "mujoco"),
801
+ checks=tuple(dict(check) for check in report.get("checks") or ()),
802
+ counts=dict(report.get("counts") or {}),
803
+ interface_fingerprint=report.get("interface_fingerprint"),
804
+ raw=report,
805
+ )
806
+
807
+ def failed(self) -> tuple[dict[str, Any], ...]:
808
+ return tuple(check for check in self.checks if check.get("status") == "fail")
809
+
810
+ def warnings(self) -> tuple[dict[str, Any], ...]:
811
+ return tuple(check for check in self.checks if check.get("status") == "warn")
812
+
813
+ def to_dict(self) -> dict[str, Any]:
814
+ return dict(self.raw)
815
+
816
+ def __str__(self) -> str:
817
+ counts = self.counts or {}
818
+ return (
819
+ f"{self.grade}: {counts.get('pass', 0)} pass · {counts.get('warn', 0)} warn · "
820
+ f"{counts.get('fail', 0)} fail"
821
+ )
822
+
823
+
824
+ @dataclass(frozen=True)
825
+ class RobotPublishResult:
826
+ """What `lucen robot publish` did: the repo, the push and the card the hub accepted."""
827
+
828
+ repo: str
829
+ url: str | None
830
+ created: bool
831
+ files_total: int
832
+ files_uploaded: int
833
+ bytes_uploaded: int
834
+ interface_fingerprint: str | None
835
+ #: The grade the CLI's own validation gave, and the hub's static one.
836
+ local_grade: str
837
+ hub_grade: str
838
+ raw: dict[str, Any] = field(repr=False, compare=False)
839
+
840
+ @classmethod
841
+ def from_cli(cls, result: dict[str, Any]) -> RobotPublishResult:
842
+ return cls(
843
+ repo=str(result["repo"]),
844
+ url=result.get("url"),
845
+ created=bool(result.get("created")),
846
+ files_total=int(result.get("files_total") or 0),
847
+ files_uploaded=int(result.get("files_uploaded") or 0),
848
+ bytes_uploaded=int(result.get("bytes_uploaded") or 0),
849
+ interface_fingerprint=result.get("interface_fingerprint"),
850
+ local_grade=str(result.get("local_grade") or ""),
851
+ hub_grade=str(result.get("hub_grade") or ""),
852
+ raw=result,
853
+ )
854
+
855
+ def to_dict(self) -> dict[str, Any]:
856
+ return dict(self.raw)
857
+
858
+ def __str__(self) -> str:
859
+ verb = "Created and published" if self.created else "Published"
860
+ return (
861
+ f"{verb} {self.repo}: {self.files_total} file(s), {self.files_uploaded} uploaded "
862
+ f"({human_bytes(self.bytes_uploaded)}); card set (local {self.local_grade}, "
863
+ f"hub {self.hub_grade})"
864
+ )
865
+
866
+
867
+ __all__ = [
868
+ "Cell",
869
+ "Episode",
870
+ "Gate",
871
+ "LogEvent",
872
+ "Me",
873
+ "Member",
874
+ "Org",
875
+ "PullResult",
876
+ "PushResult",
877
+ "Repo",
878
+ "RobotDraft",
879
+ "RobotPublishResult",
880
+ "RobotReport",
881
+ "Rollout",
882
+ "Run",
883
+ "RunEstimate",
884
+ "RunRecommendation",
885
+ ]