openral-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.
@@ -0,0 +1,334 @@
1
+ """Per-family scaffold defaults and HF Hub config introspection.
2
+
3
+ This module powers the two ``ral skill new`` UX upgrades:
4
+
5
+ - ``--family <act|smolvla|pi05|xvla|diffusion>`` — drives manifest
6
+ defaults so a freshly-scaffolded ACT skill doesn't ship a
7
+ pi0.5-shaped baseline that the user then has to rewrite by hand.
8
+ - ``--from-hf <owner/repo>`` — fetches the checkpoint's ``config.json``
9
+ (and ``policy_preprocessor.json`` when present) from the Hub, infers
10
+ the policy family, chunk size, image-feature names, and proprio /
11
+ action dims, and folds those into the scaffold patch. Bypasses the
12
+ family menu when present.
13
+
14
+ Both paths produce a `RSkillPatch` dict that
15
+ `openral_cli._rskill_scaffolder.scaffold_rskill` applies on top
16
+ of the on-disk template, so the rest of the scaffolder stays oblivious
17
+ to family-specific knobs.
18
+
19
+ No mocks, no network unless ``--from-hf`` is requested.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Any, Literal, TypedDict
25
+
26
+ RSkillFamily = Literal["act", "smolvla", "pi05", "xvla", "diffusion"]
27
+
28
+ #: Min ``shape`` length for HxW extraction from an ``input_features``
29
+ #: entry. Anything shorter triggers the template-default fallback.
30
+ _CHW_DIMS = 2
31
+
32
+ #: The five families OpenRAL ships sim policy adapters for. Mirrors
33
+ #: the keys of :data:`openral_sim.registry.POLICIES` minus the mock
34
+ #: entries (``zero`` / ``random``) which are scene-side aids, not
35
+ #: packageable skills.
36
+ RSKILL_FAMILIES: tuple[RSkillFamily, ...] = ("act", "smolvla", "pi05", "xvla", "diffusion")
37
+
38
+
39
+ class RSkillPatch(TypedDict, total=False):
40
+ """Subset of manifest fields the scaffolder will overlay on the template.
41
+
42
+ Empty / missing keys mean "leave the template value untouched". The
43
+ scaffolder applies the patch *after* the rename / license rewrite so
44
+ keys that come from CLI flags (name, license, embodiment_tags,
45
+ weights_uri) stay authoritative.
46
+ """
47
+
48
+ model_family: str
49
+ chunk_size: int
50
+ quantization: dict[str, Any]
51
+ latency_budget: dict[str, float]
52
+ min_vram_gb: dict[str, float] | None
53
+ n_action_steps: int | None
54
+ image_preprocessing: dict[str, Any] | None
55
+ state_contract: dict[str, Any] | None
56
+ # Per-checkpoint action contract. Required for any rSkill
57
+ # that wants to write through the dataset bridge — the bridge reads
58
+ # ``action_contract.dim`` to bind the LeRobot v3 ``action`` feature
59
+ # shape (cf. ``state_contract`` for ``observation.state``).
60
+ action_contract: dict[str, Any] | None
61
+ sensors_required: list[dict[str, Any]]
62
+ weights_uri: str
63
+ source_repo: str
64
+ description: str
65
+
66
+
67
+ def family_defaults(family: RSkillFamily) -> RSkillPatch:
68
+ """Return the manifest patch for a given family.
69
+
70
+ Numbers match the published reference manifests under ``rskills/``
71
+ (``act-aloha``, ``smolvla-libero``, ``pi05-libero-int8``, ``xvla-libero``,
72
+ ``diffusion-pusht``). Keeping them in one place avoids drift between
73
+ the scaffolder and the in-tree reference skills.
74
+ """
75
+ if family == "act":
76
+ # ACT (Zhao et al., 2023) — light ResNet-18 backbone, chunk=100,
77
+ # plain chunked replay. fp32 only (the published checkpoints
78
+ # were not trained with bf16 norm stats).
79
+ return {
80
+ "model_family": "act",
81
+ "chunk_size": 100,
82
+ "quantization": {"dtype": "fp32", "backend": "pytorch"},
83
+ "latency_budget": {
84
+ "per_chunk_ms": 100.0,
85
+ "warmup_ms": 5000.0,
86
+ "load_ms": 15000.0,
87
+ },
88
+ "min_vram_gb": None,
89
+ "n_action_steps": None,
90
+ "image_preprocessing": None,
91
+ "state_contract": None,
92
+ }
93
+ if family == "smolvla":
94
+ # SmolVLA paper config — chunk=16, bf16 on a desktop GPU.
95
+ return {
96
+ "model_family": "smolvla",
97
+ "chunk_size": 16,
98
+ "quantization": {"dtype": "bf16", "backend": "pytorch"},
99
+ "latency_budget": {
100
+ "per_chunk_ms": 150.0,
101
+ "warmup_ms": 8000.0,
102
+ "load_ms": 30000.0,
103
+ },
104
+ "min_vram_gb": None,
105
+ "n_action_steps": None,
106
+ }
107
+ if family == "pi05":
108
+ # Physical Intelligence π0.5 — 3B PaliGemma backbone, bf16,
109
+ # chunk=50 with half-chunk replan (n_action_steps=25). Keeps the
110
+ # min_vram_gb block so `openral doctor` can warn on 8 GB GPUs.
111
+ return {
112
+ "model_family": "pi05",
113
+ "chunk_size": 50,
114
+ "quantization": {"dtype": "bf16", "backend": "pytorch"},
115
+ "latency_budget": {
116
+ "per_chunk_ms": 200.0,
117
+ "warmup_ms": 15000.0,
118
+ "load_ms": 60000.0,
119
+ },
120
+ "min_vram_gb": {"fp32": 14.0, "bf16": 7.0},
121
+ "n_action_steps": 25,
122
+ }
123
+ if family == "xvla":
124
+ # xVLA (Florence-2 backbone). Same LIBERO 8-D state contract,
125
+ # but action space is 20-D internally (collapsed to 7-D by the
126
+ # adapter's postprocessor).
127
+ return {
128
+ "model_family": "xvla",
129
+ "chunk_size": 30,
130
+ "quantization": {"dtype": "bf16", "backend": "pytorch"},
131
+ "latency_budget": {
132
+ "per_chunk_ms": 200.0,
133
+ "warmup_ms": 10000.0,
134
+ "load_ms": 30000.0,
135
+ },
136
+ "min_vram_gb": {"fp32": 10.0, "bf16": 5.0},
137
+ "n_action_steps": None,
138
+ }
139
+ if family == "diffusion":
140
+ # Diffusion Policy — single camera, chunk=16, fp32 CPU-friendly.
141
+ return {
142
+ "model_family": "diffusion",
143
+ "chunk_size": 16,
144
+ "quantization": {"dtype": "fp32", "backend": "pytorch"},
145
+ "latency_budget": {
146
+ "per_chunk_ms": 100.0,
147
+ "warmup_ms": 3000.0,
148
+ "load_ms": 10000.0,
149
+ },
150
+ "min_vram_gb": None,
151
+ "n_action_steps": 8,
152
+ "image_preprocessing": None,
153
+ "state_contract": None,
154
+ }
155
+ raise ValueError(f"unknown skill family: {family!r}")
156
+
157
+
158
+ _CONFIG_TYPE_TO_FAMILY: dict[str, RSkillFamily] = {
159
+ "act": "act",
160
+ "smolvla": "smolvla",
161
+ "pi05": "pi05",
162
+ "xvla": "xvla",
163
+ "diffusion": "diffusion",
164
+ "diffusion_policy": "diffusion",
165
+ }
166
+
167
+
168
+ def introspect_hf(
169
+ repo_id: str, *, default_family: RSkillFamily | None = None
170
+ ) -> tuple[RSkillFamily, RSkillPatch]:
171
+ """Probe a HF Hub repo for ``config.json`` and derive a scaffold patch.
172
+
173
+ Builds on `family_defaults` — the introspected fields
174
+ (chunk_size, sensors, state_contract, weights_uri) override the
175
+ family baseline so the scaffold lands as close to the actual
176
+ checkpoint contract as possible.
177
+
178
+ Args:
179
+ repo_id: Hub identifier, e.g. ``"Deepkar/libero-test-act"`` or
180
+ ``"hf://Deepkar/libero-test-act"`` (the ``hf://`` prefix is
181
+ tolerated for symmetry with ``weights_uri``).
182
+ default_family: Family to fall back to when ``config.json``
183
+ ``type`` field is missing or unrecognized. ``None`` raises
184
+ on an unknown type.
185
+
186
+ Returns:
187
+ ``(family, patch)`` — the inferred family and a manifest patch
188
+ ready for the scaffolder. ``patch["weights_uri"]`` and
189
+ ``patch["source_repo"]`` are pinned to ``hf://<repo_id>``.
190
+
191
+ Raises:
192
+ ValueError: ``config.json`` cannot be fetched, doesn't parse,
193
+ or has an unknown ``type`` and no ``default_family`` was
194
+ given.
195
+ """
196
+ repo_id = repo_id.removeprefix("hf://")
197
+ config = _fetch_hf_json(repo_id, "config.json")
198
+ if not isinstance(config, dict):
199
+ raise ValueError(f"{repo_id}/config.json did not parse as a JSON object")
200
+
201
+ raw_type = str(config.get("type", "")).lower()
202
+ family = _CONFIG_TYPE_TO_FAMILY.get(raw_type, default_family)
203
+ if family is None:
204
+ valid = ", ".join(sorted(_CONFIG_TYPE_TO_FAMILY))
205
+ raise ValueError(
206
+ f"{repo_id}/config.json declares type={raw_type!r}; recognized: {valid}. "
207
+ "Pass --family explicitly to override, or use a checkpoint with one "
208
+ "of the supported policy types."
209
+ )
210
+
211
+ patch: RSkillPatch = dict(family_defaults(family)) # type: ignore[assignment]
212
+ if "chunk_size" in config:
213
+ patch["chunk_size"] = int(config["chunk_size"])
214
+
215
+ sensors = _sensors_from_input_features(config.get("input_features"))
216
+ if sensors:
217
+ patch["sensors_required"] = sensors
218
+
219
+ state_dim = _state_dim_from_input_features(config.get("input_features"))
220
+ if state_dim is not None:
221
+ patch["state_contract"] = {"dim": state_dim}
222
+
223
+ aliases = _aliases_from_input_features(config.get("input_features"))
224
+ if aliases:
225
+ ip = dict(patch.get("image_preprocessing") or {})
226
+ ip.setdefault("flip_180", False)
227
+ ip["aliases"] = aliases
228
+ patch["image_preprocessing"] = ip
229
+
230
+ patch["weights_uri"] = f"hf://{repo_id}"
231
+ patch["source_repo"] = f"hf://{repo_id}"
232
+ return family, patch
233
+
234
+
235
+ def _fetch_hf_json(repo_id: str, filename: str) -> Any: # noqa: ANN401 # reason: returns parsed JSON of arbitrary shape
236
+ """Download a single JSON file from a HF Hub repo, return parsed body.
237
+
238
+ Uses ``huggingface_hub.hf_hub_download`` (same path the loader uses
239
+ for ``rskill.yaml``) so cache + auth behave identically. Raises on
240
+ network / parse error so the caller can surface a clear message.
241
+ """
242
+ import json # noqa: PLC0415 # reason: keep top-level imports minimal
243
+
244
+ # reason: optional dep, only needed for --from-hf
245
+ try:
246
+ from huggingface_hub import hf_hub_download # noqa: PLC0415
247
+ except ImportError as exc:
248
+ raise ValueError(
249
+ "huggingface_hub is required for --from-hf; install with `uv sync`."
250
+ ) from exc
251
+
252
+ try:
253
+ local = hf_hub_download(repo_id=repo_id, filename=filename)
254
+ except Exception as exc:
255
+ raise ValueError(f"could not fetch {repo_id}/{filename}: {exc}") from exc
256
+
257
+ with open(local) as f:
258
+ return json.load(f)
259
+
260
+
261
+ def _sensors_from_input_features(input_features: Any) -> list[dict[str, Any]]: # noqa: ANN401 # reason: parsed-JSON shape varies per checkpoint
262
+ """Build ``sensors_required`` from ``config.input_features``.
263
+
264
+ Only emits entries for keys that start with ``observation.images.``;
265
+ state / proprio features stay out (they're declared via
266
+ ``state_contract`` instead). Each image feature becomes a
267
+ ``camera<N>`` sensor on the scene side, with the checkpoint's
268
+ ``image*`` / ``wrist_image`` key landing in
269
+ ``image_preprocessing.aliases`` separately.
270
+ """
271
+ if not isinstance(input_features, dict):
272
+ return []
273
+ sensors: list[dict[str, Any]] = []
274
+ counter = 0
275
+ for key, spec in input_features.items():
276
+ if not key.startswith("observation.images."):
277
+ continue
278
+ if not isinstance(spec, dict):
279
+ continue
280
+ shape = spec.get("shape") or [3, 224, 224]
281
+ # ``input_features`` shapes are CHW; the last two dims are HxW.
282
+ # Length < ``_CHW_DIMS`` means we couldn't parse the shape, so we
283
+ # fall back to the template's 224x224 minimum.
284
+ h = int(shape[-2]) if len(shape) >= _CHW_DIMS else 224
285
+ w = int(shape[-1]) if len(shape) >= _CHW_DIMS else 224
286
+ counter += 1
287
+ sensors.append(
288
+ {
289
+ "modality": "rgb",
290
+ "vla_feature_key": f"observation.images.camera{counter}",
291
+ "min_width": w,
292
+ "min_height": h,
293
+ }
294
+ )
295
+ return sensors
296
+
297
+
298
+ def _state_dim_from_input_features(input_features: Any) -> int | None: # noqa: ANN401 # reason: parsed-JSON shape varies per checkpoint
299
+ """Pull the proprio state dim from ``observation.state.shape``."""
300
+ if not isinstance(input_features, dict):
301
+ return None
302
+ state = input_features.get("observation.state")
303
+ if not isinstance(state, dict):
304
+ return None
305
+ shape = state.get("shape")
306
+ if not isinstance(shape, (list, tuple)) or not shape:
307
+ return None
308
+ return int(shape[0])
309
+
310
+
311
+ def _aliases_from_input_features(input_features: Any) -> dict[str, str]: # noqa: ANN401 # reason: parsed-JSON shape varies per checkpoint
312
+ """Map ``camera<N>`` source keys → checkpoint image-feature names.
313
+
314
+ Walks ``observation.images.<feature_name>`` entries in declaration
315
+ order and pairs them with the ``camera<N>`` slots emitted by
316
+ `_sensors_from_input_features`. The result is suitable for
317
+ ``image_preprocessing.aliases`` — the in-tree adapters rename the
318
+ scene-side ``camera<N>`` keys into the checkpoint's input-feature
319
+ names at step time.
320
+
321
+ Returns an empty dict when the checkpoint's image keys already
322
+ match ``camera<N>`` (no rename needed).
323
+ """
324
+ if not isinstance(input_features, dict):
325
+ return {}
326
+ image_keys = [k for k in input_features if k.startswith("observation.images.")]
327
+ aliases: dict[str, str] = {}
328
+ for i, key in enumerate(image_keys, start=1):
329
+ feature_name = key.removeprefix("observation.images.")
330
+ cam = f"camera{i}"
331
+ if feature_name == cam:
332
+ continue
333
+ aliases[cam] = feature_name
334
+ return aliases
@@ -0,0 +1,217 @@
1
+ """Build an HF model-card README for an rSkill from its manifest.
2
+
3
+ The ``rskill.yaml`` manifest is the single source of truth (CLAUDE.md §1.3). The
4
+ HF model-card **front-matter** (license, tags, ``pipeline_tag``, ``library_name``,
5
+ the ``base_model`` tree, ``datasets``) is DERIVED from it here so every published
6
+ repo — **private or public** — carries a consistent, discoverable model card
7
+ without hand-maintaining YAML front-matter across ~30 READMEs. The human-written
8
+ prose **body** is kept verbatim; only the front-matter is (re)generated.
9
+
10
+ This is the "best of both" reconciliation: the OpenRAL-specific tags + accurate
11
+ license posture that the in-tree READMEs already carried, unioned with the HF
12
+ model-card fields (``library_name``, ``pipeline_tag``, ``inference``,
13
+ ``base_model`` / ``base_model_relation``, ``datasets``) that only some hand-edited
14
+ HF cards had — now emitted uniformly from the manifest.
15
+
16
+ Example:
17
+ >>> from openral_core.schemas import RSkillManifest
18
+ >>> m = RSkillManifest.from_yaml("rskills/act-aloha/rskill.yaml")
19
+ >>> card = build_rskill_readme(m, "# body\\n\\n## License\\nMIT.\\n")
20
+ >>> card.startswith("---\\n")
21
+ True
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ import yaml
31
+ from openral_core.schemas import RSkillLicensePosture, RSkillManifest
32
+
33
+ __all__ = ["build_rskill_frontmatter", "build_rskill_readme", "render_frontmatter"]
34
+
35
+ # lerobot ``PolicyProcessorPipeline`` families — these load through lerobot, so the
36
+ # HF ``library_name`` widget should point there. Other families (gr00t / rldx run
37
+ # out-of-process; molmoact2 is a transformers custom-code model) don't.
38
+ _LEROBOT_FAMILIES = frozenset({"smolvla", "act", "pi05", "diffusion", "xvla"})
39
+
40
+ # License posture → (HF license identifier, optional license_name). HF only knows
41
+ # a fixed SPDX-ish set; anything non-standard maps to ``other`` + a descriptive
42
+ # ``license_name`` (mirrors the accurate posture the in-tree READMEs carried).
43
+ _LICENSE_MAP: dict[RSkillLicensePosture, tuple[str, str | None]] = {
44
+ RSkillLicensePosture.APACHE_2_0: ("apache-2.0", None),
45
+ RSkillLicensePosture.MIT: ("mit", None),
46
+ RSkillLicensePosture.BSD: ("bsd-3-clause", None),
47
+ RSkillLicensePosture.PERMISSIVE_RESEARCH: ("other", "permissive-research"),
48
+ RSkillLicensePosture.NVIDIA_NON_COMMERCIAL: ("other", "nvidia-non-commercial"),
49
+ RSkillLicensePosture.NVIDIA_OPEN_MODEL: ("other", "nvidia-open-model-license"),
50
+ RSkillLicensePosture.RLWRLD_NON_COMMERCIAL: ("other", "rlwrld-non-commercial"),
51
+ RSkillLicensePosture.PROPRIETARY: ("other", "proprietary"),
52
+ RSkillLicensePosture.UNKNOWN: ("other", None),
53
+ }
54
+
55
+ _NON_QUANTIZED_DTYPES = frozenset({"fp32", "bf16", "fp16", "float32", "float16"})
56
+
57
+
58
+ def _hf_repo(uri: str | None) -> str | None:
59
+ """``hf://org/repo@sha`` → ``org/repo``; ``None``/non-hf → ``None``."""
60
+ if not uri or "://" not in uri:
61
+ return None
62
+ scheme, rest = uri.split("://", 1)
63
+ if scheme != "hf":
64
+ return None
65
+ return rest.split("@", 1)[0] or None
66
+
67
+
68
+ _FRONTMATTER_RE = re.compile(r"^---\n(?P<fm>.*?)\n---\n+", re.DOTALL)
69
+
70
+
71
+ def _strip_frontmatter(body: str) -> str:
72
+ r"""Drop a leading ``---\\n…\\n---\\n`` YAML front-matter block if present."""
73
+ m = _FRONTMATTER_RE.match(body)
74
+ return body[m.end() :] if m else body.lstrip("\n")
75
+
76
+
77
+ def _parse_frontmatter(body: str) -> dict[str, Any]:
78
+ """Parse a leading YAML front-matter block into a dict (``{}`` if none/invalid)."""
79
+ m = _FRONTMATTER_RE.match(body)
80
+ if not m:
81
+ return {}
82
+ try:
83
+ loaded = yaml.safe_load(m.group("fm"))
84
+ return loaded if isinstance(loaded, dict) else {}
85
+ except yaml.YAMLError:
86
+ return {}
87
+
88
+
89
+ def _dedup(seq: list[str]) -> list[str]:
90
+ seen: set[str] = set()
91
+ out: list[str] = []
92
+ for x in seq:
93
+ if x and x not in seen:
94
+ seen.add(x)
95
+ out.append(x)
96
+ return out
97
+
98
+
99
+ def build_rskill_frontmatter(manifest: RSkillManifest) -> dict[str, Any]:
100
+ """Derive the HF model-card front-matter dict from an rSkill manifest.
101
+
102
+ Deterministic and pure (no network, no HF read), so the publisher builds the
103
+ same card whether the repo is private or public.
104
+ """
105
+ kind = str(getattr(manifest.kind, "value", manifest.kind) or "")
106
+ family = manifest.model_family
107
+ family_s = str(getattr(family, "value", family)) if family else None
108
+ is_detector = kind == "detector"
109
+ is_lerobot = family_s in _LEROBOT_FAMILIES
110
+
111
+ fm: dict[str, Any] = {"language": ["en"]}
112
+
113
+ lic_id, lic_name = _LICENSE_MAP.get(manifest.license, ("other", None))
114
+ fm["license"] = lic_id
115
+ if lic_name:
116
+ fm["license_name"] = lic_name
117
+
118
+ if is_lerobot:
119
+ fm["library_name"] = "lerobot"
120
+ fm["pipeline_tag"] = "object-detection" if is_detector else "robotics"
121
+
122
+ tags = ["OpenRAL", "rskill"]
123
+ if family_s:
124
+ tags.append(family_s)
125
+ if is_lerobot:
126
+ tags.append("lerobot")
127
+ if kind == "vla":
128
+ tags.append("vision-language-action")
129
+ elif kind in {"ros_action", "ros_service"}:
130
+ tags += ["ros2", "moveit"] if "moveit" in manifest.name else ["ros2"]
131
+ elif is_detector:
132
+ tags += ["detector", "object-detection"]
133
+ q = manifest.quantization
134
+ if q is not None:
135
+ dt = str(getattr(q.dtype, "value", q.dtype))
136
+ if dt not in _NON_QUANTIZED_DTYPES:
137
+ tags.append("nf4" if dt == "int4" else dt)
138
+ tags.append("4-bit" if dt == "int4" else "8-bit" if dt == "int8" else "quantized")
139
+ tags += list(manifest.embodiment_tags)
140
+ fm["tags"] = _dedup(tags)
141
+
142
+ base = _hf_repo(getattr(manifest, "source_repo", None))
143
+ own = _hf_repo(manifest.weights_uri)
144
+ if base and base != own:
145
+ fm["base_model"] = [base]
146
+ # relation reflects what the packaging did to the upstream: quantized when
147
+ # a real quantization block is present, else a plain finetune/wrapper.
148
+ dtype = str(getattr(q.dtype, "value", q.dtype)) if q is not None else ""
149
+ is_quantized = q is not None and dtype not in _NON_QUANTIZED_DTYPES
150
+ fm["base_model_relation"] = "quantized" if is_quantized else "finetune"
151
+
152
+ ds = _hf_repo(getattr(manifest, "dataset_uri", None))
153
+ if ds:
154
+ fm["datasets"] = [ds]
155
+
156
+ # rSkills are packaging manifests, not HF-inference-servable checkpoints.
157
+ fm["inference"] = False
158
+ return fm
159
+
160
+
161
+ def render_frontmatter(fm: dict[str, Any]) -> str:
162
+ """Render a front-matter dict as a ``---``-fenced YAML block (stable order)."""
163
+ body = yaml.safe_dump(fm, sort_keys=False, default_flow_style=False, allow_unicode=True)
164
+ return f"---\n{body}---\n"
165
+
166
+
167
+ def build_rskill_readme(manifest: RSkillManifest, body: str) -> str:
168
+ """Return the full README = merged front-matter + the prose ``body``.
169
+
170
+ "Best of both": the manifest-derived model-card fields are authoritative, but
171
+ hand-curated extras already on ``body``'s front-matter are preserved — its
172
+ ``tags`` are unioned onto the derived tags, and any scalar/list field the
173
+ derive did not emit (e.g. a curated ``datasets`` or ``license_name``) is kept.
174
+ Derived fields win on conflict so the card stays consistent with the manifest.
175
+ """
176
+ derived = build_rskill_frontmatter(manifest)
177
+ existing = _parse_frontmatter(body)
178
+ if existing:
179
+ extra_tags = [t for t in existing.get("tags", []) or [] if isinstance(t, str)]
180
+ derived["tags"] = _dedup([*derived.get("tags", []), *extra_tags])
181
+ for k, v in existing.items():
182
+ if k != "tags" and k not in derived and v not in (None, "", []):
183
+ derived[k] = v
184
+ return f"{render_frontmatter(derived)}\n{_strip_frontmatter(body)}"
185
+
186
+
187
+ def _demo() -> None:
188
+ """Runnable self-check: front-matter derives the expected best-of-both fields."""
189
+ root = Path(__file__).resolve()
190
+ while root != root.parent and not (root / "rskills").is_dir():
191
+ root = root.parent
192
+ m = RSkillManifest.from_yaml(str(root / "rskills" / "pi05-libero-nf4" / "rskill.yaml"))
193
+ fm = build_rskill_frontmatter(m)
194
+ assert fm["library_name"] == "lerobot", fm
195
+ assert fm["pipeline_tag"] == "robotics", fm
196
+ assert fm["license"] == "other" and fm["license_name"] == "permissive-research", fm
197
+ assert fm["base_model"] == ["lerobot/pi05_libero_finetuned_v044"], fm
198
+ assert fm["base_model_relation"] == "quantized", fm
199
+ assert "4-bit" in fm["tags"] and "OpenRAL" in fm["tags"], fm
200
+ assert fm["inference"] is False
201
+ # merge: hand-curated extra tags + local-only scalars survive; derived wins
202
+ card = build_rskill_readme(
203
+ m, "---\ntags: [OpenRAL, hand-tag]\ncustom_field: keep\n---\n\n# body\n\n## License\nx\n"
204
+ )
205
+ merged = _parse_frontmatter(card)
206
+ assert "hand-tag" in merged["tags"] and merged.get("custom_field") == "keep", merged
207
+ assert merged["library_name"] == "lerobot", "derived field lost in merge"
208
+ n_fences = card.count("---\n")
209
+ assert n_fences >= 2 and "\n# body" in card, n_fences # noqa: PLR2004 # reason: yaml front-matter has exactly two fences
210
+ # detector uses the object-detection pipeline
211
+ d = RSkillManifest.from_yaml(str(root / "rskills" / "rtdetr-coco-r18" / "rskill.yaml"))
212
+ assert build_rskill_frontmatter(d)["pipeline_tag"] == "object-detection"
213
+ print("ok: _rskill_readme self-check passed")
214
+
215
+
216
+ if __name__ == "__main__":
217
+ _demo()