arozantsev commited on
Commit
c8b0a0d
Β·
verified Β·
1 Parent(s): 54673e6

Deploy simready-assets run 20261019-9f72902 (GPU variant) (part 2)

Browse files
pipeline/bake.py CHANGED
@@ -186,6 +186,18 @@ def _image_names(cfg: Config) -> Dict[str, str]:
186
  return names
187
 
188
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  def url_mapping_rules(hf_repos: List[List[str]], asset_counts: Optional[List[List]] = None) -> List[Dict]:
190
  """deepsearch-api's USDSEARCH_URL_MAPPING_RULES for the HF repos a run
191
  indexed (manifest["hf_repos"]: host, repo_type, repo_id, revision): each
@@ -203,20 +215,44 @@ def url_mapping_rules(hf_repos: List[List[str]], asset_counts: Optional[List[Lis
203
  the revision with the most packages (`asset_counts`, manifest
204
  ["hf_repo_asset_counts"]: [host, repo_type, repo_id, revision, n]), the
205
  one most legacy links point into; without counts, the first listed.
 
 
 
 
 
 
 
206
  """
207
  counts = {tuple(row[:4]): row[4] for row in asset_counts or []}
208
  alias_revision: Dict[tuple, str] = {}
 
 
209
  for host, repo_type, repo_id, revision in hf_repos:
 
210
  if not hf_addressing.looks_like_sha(revision):
 
211
  continue
212
- repo = (host, repo_type, repo_id)
213
  best = alias_revision.get(repo)
214
  if best is None or counts.get((*repo, revision), 0) > counts.get((*repo, best), 0):
215
  alias_revision[repo] = revision
 
 
216
  rules = []
217
  for host, repo_type, repo_id, revision in hf_repos:
 
218
  prefix = hf_addressing.REPO_TYPE_PREFIX.get(repo_type, "")
219
  web = f"https://{host}/" + "/".join(p for p in (prefix, repo_id) if p)
 
 
 
 
 
 
 
 
 
 
220
  rule = {
221
  "internal": hf_addressing.build(host=host, repo_type=repo_type, repo_id=repo_id,
222
  revision=revision, is_folder=True),
@@ -264,8 +300,14 @@ def hf_space_build_args(cfg: Config, manifest: Dict) -> Dict[str, str]:
264
  # go stale when the corpus is re-pinned. As a Space Variable it is both
265
  # the build-arg and the runtime env var. Off for an s3:// corpus.
266
  rules = url_mapping_rules(manifest.get("hf_repos") or [], manifest.get("hf_repo_asset_counts")) if storage_backend == "storage_api" else []
 
 
 
 
 
 
267
  args["USDSEARCH_URL_MAPPING_MODE"] = "rules" if rules else "off"
268
- args["USDSEARCH_URL_MAPPING_RULES"] = json.dumps(rules, separators=(",", ":"))
269
  # S3_STORAGE_* is only ever meaningful for an S3-sourced corpus, and
270
  # threading a source bucket name through for an HF-sourced one would be
271
  # actively misleading.
@@ -463,14 +505,20 @@ def public_url_smoke(base_url: str, public_prefixes: List[str], timeout_s: float
463
  is not enough. The stale hand-set rule emitted
464
  `https://.../blob/main/resolve/<sha>/...` (a 404), which passes it, so
465
  each URL must start with one of `public_prefixes`
466
- (url_mapping_rules()'s `public`s). Returns (ok, detail). Polls like
467
- download_smoke(): the seed restores the index after /health is up."""
 
 
 
468
  import time
469
  import httpx
470
 
 
 
 
 
471
  def bad(urls):
472
- return [u for u in urls if not (isinstance(u, str) and u.startswith("https://")
473
- and u.startswith(tuple(public_prefixes)))]
474
 
475
  detail = "no URLs returned"
476
  deadline = time.monotonic() + timeout_s
 
186
  return names
187
 
188
 
189
+ #: A repo pinned at more revisions than this gets ONE rule instead of one per
190
+ #: revision (url_mapping_rules). Above every repo on the submission lists of
191
+ #: 2026-10-15 (most: 19), so their rules are unchanged.
192
+ COLLAPSE_REVISIONS_ABOVE = 32
193
+
194
+ #: USDSEARCH_URL_MAPPING_RULES is one environment string in the Space image.
195
+ #: Linux refuses any single env or argv string over 128 KiB (MAX_ARG_STRLEN),
196
+ #: so past it every process in the container fails to start; stop the bake
197
+ #: well before that.
198
+ URL_MAPPING_RULES_MAX_BYTES = 100_000
199
+
200
+
201
  def url_mapping_rules(hf_repos: List[List[str]], asset_counts: Optional[List[List]] = None) -> List[Dict]:
202
  """deepsearch-api's USDSEARCH_URL_MAPPING_RULES for the HF repos a run
203
  indexed (manifest["hf_repos"]: host, repo_type, repo_id, revision): each
 
215
  the revision with the most packages (`asset_counts`, manifest
216
  ["hf_repo_asset_counts"]: [host, repo_type, repo_id, revision, n]), the
217
  one most legacy links point into; without counts, the first listed.
218
+
219
+ A repo pinned at more than COLLAPSE_REVISIONS_ABOVE revisions (one upload
220
+ commit per package: 657 for PalatialSim on 2026-10-15) gets one rule,
221
+ `hf://.../resolve/` -> `.../blob/`, which carries every revision through
222
+ unchanged, so the rules stop growing with the pins. It has no
223
+ `.../blob/main/` alias: search_utils refuses a public prefix that is a
224
+ prefix of another, and `.../blob/` is one of `.../blob/main/`.
225
  """
226
  counts = {tuple(row[:4]): row[4] for row in asset_counts or []}
227
  alias_revision: Dict[tuple, str] = {}
228
+ pinned: Dict[tuple, int] = {}
229
+ unpinned = set()
230
  for host, repo_type, repo_id, revision in hf_repos:
231
+ repo = (host, repo_type, repo_id)
232
  if not hf_addressing.looks_like_sha(revision):
233
+ unpinned.add(repo)
234
  continue
235
+ pinned[repo] = pinned.get(repo, 0) + 1
236
  best = alias_revision.get(repo)
237
  if best is None or counts.get((*repo, revision), 0) > counts.get((*repo, best), 0):
238
  alias_revision[repo] = revision
239
+ collapsed = {repo for repo, n in pinned.items() if n > COLLAPSE_REVISIONS_ABOVE and repo not in unpinned}
240
+ emitted = set()
241
  rules = []
242
  for host, repo_type, repo_id, revision in hf_repos:
243
+ repo = (host, repo_type, repo_id)
244
  prefix = hf_addressing.REPO_TYPE_PREFIX.get(repo_type, "")
245
  web = f"https://{host}/" + "/".join(p for p in (prefix, repo_id) if p)
246
+ if repo in collapsed:
247
+ if repo in emitted:
248
+ continue
249
+ emitted.add(repo)
250
+ rules.append({
251
+ "internal": hf_addressing.build(host=host, repo_type=repo_type, repo_id=repo_id,
252
+ revision="", is_folder=True) + "resolve/",
253
+ "public": f"{web}/blob/",
254
+ })
255
+ continue
256
  rule = {
257
  "internal": hf_addressing.build(host=host, repo_type=repo_type, repo_id=repo_id,
258
  revision=revision, is_folder=True),
 
300
  # go stale when the corpus is re-pinned. As a Space Variable it is both
301
  # the build-arg and the runtime env var. Off for an s3:// corpus.
302
  rules = url_mapping_rules(manifest.get("hf_repos") or [], manifest.get("hf_repo_asset_counts")) if storage_backend == "storage_api" else []
303
+ rules_json = json.dumps(rules, separators=(",", ":"))
304
+ if len(rules_json.encode()) > URL_MAPPING_RULES_MAX_BYTES:
305
+ raise ValueError(
306
+ f"USDSEARCH_URL_MAPPING_RULES would be {len(rules_json.encode())} bytes ({len(rules)} rules), over "
307
+ f"{URL_MAPPING_RULES_MAX_BYTES}: Linux refuses an env string past 128 KiB, so no process in the Space "
308
+ f"would start. Lower COLLAPSE_REVISIONS_ABOVE or ask the submitter to pin fewer commits.")
309
  args["USDSEARCH_URL_MAPPING_MODE"] = "rules" if rules else "off"
310
+ args["USDSEARCH_URL_MAPPING_RULES"] = rules_json
311
  # S3_STORAGE_* is only ever meaningful for an S3-sourced corpus, and
312
  # threading a source bucket name through for an HF-sourced one would be
313
  # actively misleading.
 
505
  is not enough. The stale hand-set rule emitted
506
  `https://.../blob/main/resolve/<sha>/...` (a 404), which passes it, so
507
  each URL must start with one of `public_prefixes`
508
+ (url_mapping_rules()'s `public`s). Under a collapsed repo's `.../blob/`
509
+ the next segment must be a commit sha, or that stale form would pass.
510
+ Returns (ok, detail). Polls like download_smoke(): the seed restores the
511
+ index after /health is up."""
512
+ import re
513
  import time
514
  import httpx
515
 
516
+ def under(u):
517
+ return any(u.startswith(p) and (not p.endswith("/blob/") or re.match(r"[0-9a-f]{40}/", u[len(p):]))
518
+ for p in public_prefixes)
519
+
520
  def bad(urls):
521
+ return [u for u in urls if not (isinstance(u, str) and u.startswith("https://") and under(u))]
 
522
 
523
  detail = "no URLs returned"
524
  deadline = time.monotonic() + timeout_s
pipeline/categories.py CHANGED
@@ -7,8 +7,10 @@ between `wait` and `export`, every indexed asset gets one more USD property, `ce
7
 
8
  * a partner (OEM) package β€” any dataset owner but NVIDIA's own β€” gets `oem:<owner>`, the HF dataset owner its
9
  simready-central submission list names (`submissions/<owner>/<dataset>`), never vendor-typed metadata;
 
 
 
10
  * any other asset gets its `simready_metadata_qcode` value(s);
11
- * else a pin from config/hierarchy/category_pins.yaml (by package folder);
12
  * else the one Q-code its prims carry as a semantic label (`semantic:wikidata_qcode:params:semanticData`);
13
  * else nothing (it stays in the tree's Uncategorized bucket; the stage lists it).
14
 
@@ -17,6 +19,11 @@ config/hierarchy/vendors.yaml) and a leaf per pin (`augment_hierarchy`), so OEM
17
  OEM > vendor and the NVIDIA categories stay NVIDIA's. Search, query parsing and every other filter keep reading
18
  `simready_metadata_qcode`; only the tree reads `central:category`.
19
 
 
 
 
 
 
20
  python -m pipeline.run_pipeline categories --env-file ... [--dry-run]
21
  python -m pipeline.categories hierarchy --env-file ... --from-run <run> --run-id <run> [--dry-run]
22
  """
@@ -24,7 +31,10 @@ OEM > vendor and the NVIDIA categories stay NVIDIA's. Search, query parsing and
24
  from __future__ import annotations
25
 
26
  import argparse
 
27
  import json
 
 
28
  import sys
29
  from collections import Counter
30
  from pathlib import Path
@@ -44,6 +54,10 @@ VENDORS_FILE = REPO_ROOT / "config" / "hierarchy" / "vendors.yaml"
44
  PINS_FILE = REPO_ROOT / "config" / "hierarchy" / "category_pins.yaml"
45
  REPORT = BUILD_DIR / "categories.json"
46
  HIERARCHY_FILE = "category_hierarchy.json"
 
 
 
 
47
 
48
 
49
  # ── config ───────────────────────────────────────────────────────────────────
@@ -123,36 +137,41 @@ def category_values(url: str, props: Iterable[Dict], vendors: Dict, pins: Dict[s
123
  owner, package = split_url(url)
124
  if owner and owner.lower() not in vendors["own"]:
125
  return [f"{OEM_PREFIX}{owner}"], "oem"
 
 
126
  qcodes = prop_values(props, QCODE_PROPERTY)
127
  if qcodes:
128
  return qcodes, "qcode"
129
- if package and package in pins:
130
- return [pins[package]["key"]], "pin"
131
  semantic = prop_values(props, SEMANTIC_QCODE_PROPERTY)
132
  if len(semantic) == 1:
133
  return semantic, "semantic"
134
  return [], "none"
135
 
136
 
137
- def with_category(props: List[Dict], values: List[str]) -> List[Dict]:
138
- """`props` without any `central:category` entry, plus one entry per value, shaped like the props' own string
139
- entries (name/value/value_type and their `_sayt` twins where the index has them)."""
140
- kept = [p for p in props or [] if not (isinstance(p, dict) and p.get("name") == PROPERTY)]
141
  template = next((p for p in kept if isinstance(p, dict) and p.get("value_type") == "string"), None)
142
  keys = set(template) if template else {"name", "value", "value_type", "name_sayt", "value_sayt"}
143
  out = list(kept)
144
  for v in values:
145
- entry = {"name": PROPERTY, "value": v}
146
  if "value_type" in keys:
147
  entry["value_type"] = "string"
148
  if "name_sayt" in keys:
149
- entry["name_sayt"] = PROPERTY
150
  if "value_sayt" in keys:
151
  entry["value_sayt"] = v
152
  out.append(entry)
153
  return out
154
 
155
 
 
 
 
 
 
156
  def _folder_steps(path: str) -> List[Dict]:
157
  parts = [p for p in path.split("/") if p]
158
  return [{"qcode": FOLDER_PREFIX + "/".join(parts[: i + 1]), "label": parts[i]} for i in range(len(parts))]
@@ -188,6 +207,202 @@ def augment_hierarchy(doc: Dict, values: Iterable[str], vendors: Dict, pins: Dic
188
  return out, unplaced
189
 
190
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  # ── stage: write the property into the live index ────────────────────────────
192
 
193
 
@@ -235,6 +450,7 @@ def run(cfg: Config, dry_run: bool = False) -> int:
235
  unplaced: List[str] = []
236
  updates: List[Tuple[str, List[Dict]]] = []
237
  urls: List[str] = []
 
238
  sample_printed = False
239
  with httpx.Client(timeout=900.0) as http:
240
  for hit in _scroll(http, base, index):
@@ -252,6 +468,7 @@ def run(cfg: Config, dry_run: bool = False) -> int:
252
  vals, why = category_values(url, props or [], vendors, pins)
253
  reasons[why] += 1
254
  values.update(vals)
 
255
  if not vals:
256
  unplaced.append(url)
257
  new_props = with_category(props or [], vals)
@@ -275,7 +492,7 @@ def run(cfg: Config, dry_run: bool = False) -> int:
275
  REPORT.write_text(json.dumps({
276
  "property": PROPERTY, "index": index, "assets": len(urls), "rules": dict(reasons),
277
  "values": dict(sorted(values.items())), "unplaced": unplaced, "problems": problems,
278
- "dry_run": dry_run,
279
  }, indent=1) + "\n")
280
  if problems:
281
  print("ERROR: " + "\nERROR: ".join(problems))
@@ -342,6 +559,7 @@ def publish_hierarchy(cfg: Config, from_run: str, run_id: str, dry_run: bool = F
342
  doc = json.loads(tree_path.read_text())
343
  out, unplaced = augment_hierarchy(doc, report["values"].keys(), load_vendors(), load_pins())
344
  tree_path.write_text(json.dumps(out, indent=1, ensure_ascii=False, sort_keys=True) + "\n")
 
345
  top = sorted({q["path"][0]["label"] for q in out["qcodes"].values() if q.get("path")})
346
  print(f"tree from {from_run}: {len(doc.get('qcodes') or {})} -> {len(out['qcodes'])} entries; top level {top}")
347
  if unplaced:
@@ -351,7 +569,7 @@ def publish_hierarchy(cfg: Config, from_run: str, run_id: str, dry_run: bool = F
351
  return 0
352
  s3.upload_dir(cfg, work, dest)
353
  landed = {rel for rel, _ in s3.list_objects(cfg, dest)}
354
- expected = {rel for rel, _ in objects}
355
  if landed != expected:
356
  print(f"ERROR: {dest} holds {sorted(landed)}, expected {sorted(expected)}")
357
  return 1
 
7
 
8
  * a partner (OEM) package β€” any dataset owner but NVIDIA's own β€” gets `oem:<owner>`, the HF dataset owner its
9
  simready-central submission list names (`submissions/<owner>/<dataset>`), never vendor-typed metadata;
10
+ * a package pinned in config/hierarchy/category_pins.yaml (by package folder) gets its pin, even over its
11
+ Q-code: a Q-code that covers unlike objects (one "machine" for a safety shutoff and a brake tester), or
12
+ a package S3 already holds in another category folder;
13
  * any other asset gets its `simready_metadata_qcode` value(s);
 
14
  * else the one Q-code its prims carry as a semantic label (`semantic:wikidata_qcode:params:semanticData`);
15
  * else nothing (it stays in the tree's Uncategorized bucket; the stage lists it).
16
 
 
19
  OEM > vendor and the NVIDIA categories stay NVIDIA's. Search, query parsing and every other filter keep reading
20
  `simready_metadata_qcode`; only the tree reads `central:category`.
21
 
22
+ Beside the tree goes `install_categories.json` (`install_list`): every package folder of the corpus's HF datasets
23
+ with the category folder the Space's tree files it under, for installers that copy packages into category folders
24
+ (Michael Mallon's S3 copy). A package maps only when all its values sit in the tree under ONE folder path; anything
25
+ else is `needs_review` with a reason, never a guessed folder. The Space serves it as /install_categories.json.
26
+
27
  python -m pipeline.run_pipeline categories --env-file ... [--dry-run]
28
  python -m pipeline.categories hierarchy --env-file ... --from-run <run> --run-id <run> [--dry-run]
29
  """
 
31
  from __future__ import annotations
32
 
33
  import argparse
34
+ import datetime as dt
35
  import json
36
+ import os
37
+ import re
38
  import sys
39
  from collections import Counter
40
  from pathlib import Path
 
54
  PINS_FILE = REPO_ROOT / "config" / "hierarchy" / "category_pins.yaml"
55
  REPORT = BUILD_DIR / "categories.json"
56
  HIERARCHY_FILE = "category_hierarchy.json"
57
+ INSTALL_FILE = "install_categories.json"
58
+ INSTALL_SCHEMA = "1.1.0"
59
+ LAST_COMMITS_ENV = "INSTALL_LIST_LAST_COMMITS" # "off": no HF listing (no last commits, no not-indexed rows)
60
+ QCODE_RE = re.compile(r"^Q[1-9]\d*$")
61
 
62
 
63
  # ── config ───────────────────────────────────────────────────────────────────
 
137
  owner, package = split_url(url)
138
  if owner and owner.lower() not in vendors["own"]:
139
  return [f"{OEM_PREFIX}{owner}"], "oem"
140
+ if package and package in pins:
141
+ return [pins[package]["key"]], "pin"
142
  qcodes = prop_values(props, QCODE_PROPERTY)
143
  if qcodes:
144
  return qcodes, "qcode"
 
 
145
  semantic = prop_values(props, SEMANTIC_QCODE_PROPERTY)
146
  if len(semantic) == 1:
147
  return semantic, "semantic"
148
  return [], "none"
149
 
150
 
151
+ def with_property(props: List[Dict], name: str, values: List[str]) -> List[Dict]:
152
+ """`props` without any `name` entry, plus one entry per value, shaped like the props' own string entries
153
+ (name/value/value_type and their `_sayt` twins where the index has them)."""
154
+ kept = [p for p in props or [] if not (isinstance(p, dict) and p.get("name") == name)]
155
  template = next((p for p in kept if isinstance(p, dict) and p.get("value_type") == "string"), None)
156
  keys = set(template) if template else {"name", "value", "value_type", "name_sayt", "value_sayt"}
157
  out = list(kept)
158
  for v in values:
159
+ entry = {"name": name, "value": v}
160
  if "value_type" in keys:
161
  entry["value_type"] = "string"
162
  if "name_sayt" in keys:
163
+ entry["name_sayt"] = name
164
  if "value_sayt" in keys:
165
  entry["value_sayt"] = v
166
  out.append(entry)
167
  return out
168
 
169
 
170
+ def with_category(props: List[Dict], values: List[str]) -> List[Dict]:
171
+ """`props` with its `central:category` entries replaced by `values`."""
172
+ return with_property(props, PROPERTY, values)
173
+
174
+
175
  def _folder_steps(path: str) -> List[Dict]:
176
  parts = [p for p in path.split("/") if p]
177
  return [{"qcode": FOLDER_PREFIX + "/".join(parts[: i + 1]), "label": parts[i]} for i in range(len(parts))]
 
207
  return out, unplaced
208
 
209
 
210
+ # ── install list: package folder -> category folder ──────────────────────────
211
+
212
+
213
+ def package_address(url: str) -> Optional[Tuple[str, str, str]]:
214
+ """(repo id, revision, top-level package folder) of an hf:// address or its public https blob/resolve URL."""
215
+ if url.startswith("hf://"):
216
+ try:
217
+ addr = hf_addressing.parse(url)
218
+ except hf_addressing.AddressError:
219
+ return None
220
+ folder = addr.path.split("/")[0]
221
+ return (addr.repo_id, addr.revision or "", folder) if folder else None
222
+ parts = url.split("/")
223
+ if len(parts) > 9 and parts[3] == "datasets" and parts[6] in ("blob", "resolve") and parts[8]:
224
+ return f"{parts[4]}/{parts[5]}", parts[7], parts[8]
225
+ return None
226
+
227
+
228
+ def tree_folder(rec: Dict) -> Optional[str]:
229
+ """The folder a tree entry files its assets under: the labels of its `folder:` steps, plus the vendor for an
230
+ OEM entry (OEM > LG Electronics). None when the entry has no folder."""
231
+ steps = rec.get("path") or []
232
+ labels = [str(s.get("label") or "") for s in steps if str(s.get("qcode") or "").startswith(FOLDER_PREFIX)]
233
+ if steps and str(steps[-1].get("qcode") or "").startswith(OEM_PREFIX):
234
+ labels.append(str(steps[-1].get("label") or ""))
235
+ return "/".join(labels) if labels else None
236
+
237
+
238
+ def safe_path(path: str) -> bool:
239
+ """Every segment a plain folder name: not empty, not . or .., no backslash or control character."""
240
+ return all(seg.strip() and seg not in (".", "..") and "\\" not in seg and not any(ord(ch) < 32 for ch in seg)
241
+ for seg in path.split("/"))
242
+
243
+
244
+ def _last_commit(raw) -> Optional[Dict]:
245
+ if not raw:
246
+ return None
247
+ if isinstance(raw, dict):
248
+ oid, title, date = raw.get("id") or raw.get("oid"), raw.get("title"), raw.get("date")
249
+ else:
250
+ oid, title, date = getattr(raw, "oid", None), getattr(raw, "title", None), getattr(raw, "date", None)
251
+ if isinstance(date, dt.datetime):
252
+ date = date.astimezone(dt.timezone.utc).isoformat().replace("+00:00", "Z")
253
+ return {"id": oid, "date": date, "title": title} if oid else None
254
+
255
+
256
+ def package_listings(repos: Iterable[Tuple[str, str]], api=None) -> Tuple[Dict[Tuple[str, str], Dict], str]:
257
+ """The top-level folders of each (repo id, revision) with their last commit at that revision: one
258
+ non-recursive expanded tree listing each (Hugging Face pages expanded listings 50 at a time). Fail-soft: what
259
+ it could list, and a note saying what it could not."""
260
+ from huggingface_hub import RepoFolder
261
+
262
+ if api is None:
263
+ from .enumerate_assets import _hf_api
264
+ api = _hf_api()
265
+ out: Dict[Tuple[str, str], Dict] = {}
266
+ failed: List[str] = []
267
+ for repo, rev in sorted(set(repos)):
268
+ try:
269
+ entries = api.list_repo_tree(repo, recursive=False, revision=rev or None, repo_type="dataset", expand=True)
270
+ out[(repo, rev)] = {e.path: _last_commit(getattr(e, "last_commit", None))
271
+ for e in entries if isinstance(e, RepoFolder) and not e.path.startswith(".")}
272
+ except Exception as exc: # noqa: BLE001 β€” the list ships without last commits rather than not at all
273
+ failed.append(f"{repo}@{rev[:8]}: {type(exc).__name__}: {str(exc)[:160]}")
274
+ return out, ("listed" if not failed else "not listed for " + "; ".join(failed))
275
+
276
+
277
+ def install_list(assets: Iterable[Dict], tree: Dict, *, own: Iterable[str], listings: Optional[Dict] = None,
278
+ run_id: str = "", corpus: str = "", last_commits: str = "", generated_at: str = "") -> Dict:
279
+ """`install_categories.json`: every package folder -> the category folder of the Space's tree.
280
+
281
+ `assets` are the categories stage's per-file rows ({url, values, rule}); `tree` the augmented
282
+ category_hierarchy.json; `listings` the HF top-level folders per (repo, revision) from `package_listings`
283
+ (their last commits, and the folders of our own datasets that no indexed file covers: `not_indexed`).
284
+ The primary repository (our own dataset with the most packages) is keyed by bare folder, like the 2026-10-13
285
+ export; any other dataset as `<owner>/<name>/<folder>`."""
286
+ own = {o.lower() for o in own}
287
+ places = tree.get("qcodes") or {}
288
+ listings = listings or {}
289
+ packages: Dict[Tuple[str, str], Dict] = {}
290
+ for a in assets:
291
+ addr = package_address(a.get("url") or "")
292
+ if not addr:
293
+ continue
294
+ repo, rev, folder = addr
295
+ p = packages.setdefault((repo, folder), {"revisions": set(), "values": set(), "rules": set(), "files": 0})
296
+ p["files"] += 1
297
+ p["revisions"].add(rev)
298
+ p["values"].update(a.get("values") or [])
299
+ p["rules"].add(a.get("rule") or "none")
300
+ for (repo, rev), folders in listings.items():
301
+ if repo.split("/")[0].lower() not in own:
302
+ continue # a partner's dataset holds more than its submission list: only listed packages count
303
+ for folder in folders:
304
+ packages.setdefault((repo, folder), {"revisions": set(), "values": set(), "rules": set(), "files": 0})
305
+ if not packages[(repo, folder)]["files"]:
306
+ packages[(repo, folder)]["revisions"].add(rev)
307
+
308
+ per_repo = Counter(repo for repo, _ in packages)
309
+ ours = [r for r in per_repo if r.split("/")[0].lower() in own]
310
+ primary = max(ours or per_repo, key=lambda r: (per_repo[r], r)) if per_repo else ""
311
+
312
+ rows: Dict[str, Dict] = {}
313
+ for (repo, folder), p in sorted(packages.items()):
314
+ values = sorted(p["values"])
315
+ revs = sorted(r for r in p["revisions"] if r)
316
+ commit = next((listings.get((repo, r), {}).get(folder) for r in revs if listings.get((repo, r), {}).get(folder)), None)
317
+ row = {"asset_path": folder, "repository": repo, "revision": revs[0] if len(revs) == 1 else None,
318
+ "last_commit": commit, "qcodes": [v for v in values if QCODE_RE.match(v)], "category_keys": values,
319
+ "rules": sorted(p["rules"]), "files": p["files"],
320
+ "category_path": None, "install_path": None, "status": "needs_review", "reason": None}
321
+ unplaced = [v for v in values if v not in places]
322
+ paths = {tree_folder(places[v]) for v in values if v in places}
323
+ if not p["files"]:
324
+ row["reason"] = "not_indexed"
325
+ elif len(revs) > 1:
326
+ row["reason"], row["revisions"] = "several_revisions", revs
327
+ elif not values:
328
+ row["reason"] = "missing_qcode"
329
+ elif unplaced:
330
+ row["reason"], row["unplaced"] = "qcode_not_in_tree", unplaced
331
+ elif None in paths:
332
+ row["reason"] = "no_category_folder"
333
+ elif len(paths) > 1:
334
+ row["reason"], row["category_paths"] = "multiple_category_paths", sorted(paths)
335
+ elif not safe_path(next(iter(paths))):
336
+ row["reason"], row["category_paths"] = "unsafe_category_path", sorted(paths)
337
+ else:
338
+ path = next(iter(paths))
339
+ row.update(category_path=path, install_path=f"{path}/{folder}", status="mapped")
340
+ rows[folder if repo == primary else f"{repo}/{folder}"] = row
341
+
342
+ by_dest: Dict[str, List[str]] = {}
343
+ for key, row in rows.items():
344
+ if row["install_path"]:
345
+ by_dest.setdefault(row["install_path"].casefold(), []).append(key)
346
+ for keys in by_dest.values():
347
+ if len(keys) > 1: # two packages would land in one folder (a case-insensitive file system included)
348
+ for key in keys:
349
+ rows[key].update(category_paths=[rows[key]["category_path"]], category_path=None, install_path=None,
350
+ status="needs_review", reason="install_path_collision")
351
+
352
+ mapped = [r for r in rows.values() if r["status"] == "mapped"]
353
+ repos = sorted(per_repo)
354
+ revisions = {repo: dict(sorted(Counter(r["revision"] for r in rows.values()
355
+ if r["repository"] == repo and r["revision"]).items())) for repo in repos}
356
+ coverage = {
357
+ "assets": len(rows), "mapped_assets": len(mapped), "needs_review_assets": len(rows) - len(mapped),
358
+ "reasons": dict(sorted(Counter(r["reason"] for r in rows.values() if r["reason"]).items())),
359
+ "repositories": {repo: {"assets": sum(1 for r in rows.values() if r["repository"] == repo),
360
+ "mapped_assets": sum(1 for r in mapped if r["repository"] == repo)} for repo in repos},
361
+ "assets_with_qcode": sum(1 for r in rows.values() if r["qcodes"]),
362
+ "qcodes": len({q for r in rows.values() for q in r["qcodes"]}),
363
+ "mapped_qcodes": len({q for r in mapped for q in r["qcodes"]}),
364
+ "unplaced_qcodes": sorted({v for r in rows.values() for v in r.get("unplaced") or []}),
365
+ }
366
+ primary_revs = list(revisions.get(primary) or {})
367
+ provenance = {
368
+ "repository": primary, "repository_url": f"https://huggingface.co/datasets/{primary}" if primary else None,
369
+ "revision": primary_revs[0] if len(primary_revs) == 1 else None, "revisions": revisions,
370
+ "corpus": corpus, "run_id": run_id,
371
+ "tree": {"file": HIERARCHY_FILE, "run_id": run_id, "generated_at": tree.get("generated_at"),
372
+ "qcode_property": tree.get("qcode_property"), "source_qcodes": tree.get("source_qcodes"),
373
+ "entries": len(places)},
374
+ "last_commits": last_commits or ("listed" if listings else "off"),
375
+ "method": f"each package's {PROPERTY} values (pipeline/categories.py) joined to the run's Category tree; "
376
+ "a package maps only when every value sits under one folder path; nothing is guessed",
377
+ }
378
+ return {"schema_version": INSTALL_SCHEMA,
379
+ "generated_at": generated_at or dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat(),
380
+ "provenance": provenance, "coverage": coverage, "assets": rows}
381
+
382
+
383
+ def write_install_list(out_dir: Path, tree: Dict, *, run_id: str = "", corpus: str = "") -> Optional[Path]:
384
+ """Write `install_categories.json` into the run's hierarchy dir from the categories report. None (and a
385
+ warning) when the report predates per-asset rows."""
386
+ report = json.loads(REPORT.read_text())
387
+ path = out_dir / INSTALL_FILE
388
+ if "assets" not in report:
389
+ path.unlink(missing_ok=True) # never ship an earlier run's list with this run's tree
390
+ print(f"WARNING: {REPORT} has no per-asset rows (re-run the `categories` stage): no {INSTALL_FILE}")
391
+ return None
392
+ repos = {addr[:2] for addr in (package_address(a.get("url") or "") for a in report["assets"]) if addr}
393
+ if os.environ.get(LAST_COMMITS_ENV, "").strip().lower() == "off":
394
+ listings, note = {}, "off"
395
+ else:
396
+ listings, note = package_listings(repos)
397
+ doc = install_list(report["assets"], tree, own=load_vendors()["own"], listings=listings, run_id=run_id,
398
+ corpus=corpus, last_commits=note)
399
+ path.write_text(json.dumps(doc, indent=1, ensure_ascii=False, sort_keys=True) + "\n")
400
+ cov = doc["coverage"]
401
+ print(f"{INSTALL_FILE}: {cov['mapped_assets']}/{cov['assets']} packages mapped; needs review {cov['reasons']}; "
402
+ f"last commits: {note}")
403
+ return path
404
+
405
+
406
  # ── stage: write the property into the live index ────────────────────────────
407
 
408
 
 
450
  unplaced: List[str] = []
451
  updates: List[Tuple[str, List[Dict]]] = []
452
  urls: List[str] = []
453
+ rows: List[Dict] = [] # per file: the install list's input (install_list)
454
  sample_printed = False
455
  with httpx.Client(timeout=900.0) as http:
456
  for hit in _scroll(http, base, index):
 
468
  vals, why = category_values(url, props or [], vendors, pins)
469
  reasons[why] += 1
470
  values.update(vals)
471
+ rows.append({"url": url, "values": vals, "rule": why})
472
  if not vals:
473
  unplaced.append(url)
474
  new_props = with_category(props or [], vals)
 
492
  REPORT.write_text(json.dumps({
493
  "property": PROPERTY, "index": index, "assets": len(urls), "rules": dict(reasons),
494
  "values": dict(sorted(values.items())), "unplaced": unplaced, "problems": problems,
495
+ "dry_run": dry_run, "assets": rows,
496
  }, indent=1) + "\n")
497
  if problems:
498
  print("ERROR: " + "\nERROR: ".join(problems))
 
559
  doc = json.loads(tree_path.read_text())
560
  out, unplaced = augment_hierarchy(doc, report["values"].keys(), load_vendors(), load_pins())
561
  tree_path.write_text(json.dumps(out, indent=1, ensure_ascii=False, sort_keys=True) + "\n")
562
+ write_install_list(work, out, run_id=run_id, corpus=corpus)
563
  top = sorted({q["path"][0]["label"] for q in out["qcodes"].values() if q.get("path")})
564
  print(f"tree from {from_run}: {len(doc.get('qcodes') or {})} -> {len(out['qcodes'])} entries; top level {top}")
565
  if unplaced:
 
569
  return 0
570
  s3.upload_dir(cfg, work, dest)
571
  landed = {rel for rel, _ in s3.list_objects(cfg, dest)}
572
+ expected = {p.relative_to(work).as_posix() for p in work.rglob("*") if p.is_file()}
573
  if landed != expected:
574
  print(f"ERROR: {dest} holds {sorted(landed)}, expected {sorted(expected)}")
575
  return 1
pipeline/config.py CHANGED
@@ -107,6 +107,7 @@ class Config:
107
  "INFO_ENDPOINT_URL",
108
  "OPENSEARCH_URL",
109
  "SIGLIP2_BACKEND",
 
110
  "SIGLIP2_NVCF_ENDPOINT",
111
  "STORAGE_BACKEND_TYPE",
112
  "SIGLIP2_NVCF_FUNCTION_ID",
@@ -211,6 +212,24 @@ class Config:
211
  def siglip2_nvcf(self) -> bool:
212
  return self.siglip2_backend == "nvcf"
213
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  # ── HF Space GPU variant (local siglip2-triton, no NVCF) ────────────────
215
  # Independent of SIGLIP2_BACKEND above (which is the *indexing stack's*
216
  # compose backend, local-GPU vs. NVCF) β€” this is a second, optional
@@ -451,6 +470,23 @@ class Config:
451
  "../usdsearch/docs/models-and-config.md)."
452
  )
453
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
454
  def compose_env(self) -> Dict[str, str]:
455
  """The environment every `docker compose`/`docker run` subprocess
456
  this pipeline spawns should get β€” `pipeline.env`'s values plus
@@ -482,7 +518,7 @@ class Config:
482
  # so operators only ever set one variable. Verified live: without
483
  # this, hf-storage-api silently ran unauthenticated and every
484
  # submission against a private dataset 401'd as "Asset missing".
485
- hf_token = os.environ.get("HF_TOKEN", "").strip()
486
  if hf_token:
487
  env["HF_ADAPTER_HF_TOKEN"] = hf_token
488
  # No vlm_metadata_enabled-specific merge needed here: `env` already
@@ -496,7 +532,7 @@ class Config:
496
 
497
  def indexing_compose_files(self) -> List[str]:
498
  files = ["-f", str(REPO_ROOT / "compose" / "docker-compose.indexing.yml")]
499
- if self.siglip2_nvcf:
500
  files += ["-f", str(REPO_ROOT / "compose" / "docker-compose.indexing.nvcf-siglip2.yml")]
501
  # Only the indexing stack ever talks to a storage backend directly β€”
502
  # the runtime stack (verify/HF Space) serves an already-baked index and
 
107
  "INFO_ENDPOINT_URL",
108
  "OPENSEARCH_URL",
109
  "SIGLIP2_BACKEND",
110
+ "INDEXING_SIGLIP2_BACKEND",
111
  "SIGLIP2_NVCF_ENDPOINT",
112
  "STORAGE_BACKEND_TYPE",
113
  "SIGLIP2_NVCF_FUNCTION_ID",
 
212
  def siglip2_nvcf(self) -> bool:
213
  return self.siglip2_backend == "nvcf"
214
 
215
+ # SIGLIP2_BACKEND also decides what serves queries (the verify stack, the
216
+ # dev Space's NVCF-only hf_space image), so the indexing stack may override
217
+ # it: e.g. embed the corpus on the local GPU while the dev Space keeps
218
+ # querying NVCF. Same model either way, so the vectors are interchangeable.
219
+
220
+ @property
221
+ def indexing_siglip2_backend(self) -> str:
222
+ backend = self.get("INDEXING_SIGLIP2_BACKEND").strip().lower()
223
+ if not backend:
224
+ return self.siglip2_backend
225
+ if backend not in ("local", "nvcf"):
226
+ raise SystemExit(f"INDEXING_SIGLIP2_BACKEND must be 'local' or 'nvcf', got {backend!r}")
227
+ return backend
228
+
229
+ @property
230
+ def indexing_siglip2_nvcf(self) -> bool:
231
+ return self.indexing_siglip2_backend == "nvcf"
232
+
233
  # ── HF Space GPU variant (local siglip2-triton, no NVCF) ────────────────
234
  # Independent of SIGLIP2_BACKEND above (which is the *indexing stack's*
235
  # compose backend, local-GPU vs. NVCF) β€” this is a second, optional
 
470
  "../usdsearch/docs/models-and-config.md)."
471
  )
472
 
473
+ @staticmethod
474
+ def hf_token() -> str:
475
+ """$HF_TOKEN, else the token `hf auth login` cached β€” the same lookup
476
+ the enumerator's huggingface_hub does on its own. The container only
477
+ sees what compose_env() passes it, so without this fallback it ran
478
+ anonymously whenever $HF_TOKEN wasn't exported, and a public dataset
479
+ still "worked": HF's anonymous per-IP API quota (per 5-minute window)
480
+ throttled a 2,600-package re-index to ~2 asset graphs/min with 429s."""
481
+ token = os.environ.get("HF_TOKEN", "").strip()
482
+ if token:
483
+ return token
484
+ try:
485
+ from huggingface_hub import get_token
486
+ except ImportError:
487
+ return ""
488
+ return (get_token() or "").strip()
489
+
490
  def compose_env(self) -> Dict[str, str]:
491
  """The environment every `docker compose`/`docker run` subprocess
492
  this pipeline spawns should get β€” `pipeline.env`'s values plus
 
518
  # so operators only ever set one variable. Verified live: without
519
  # this, hf-storage-api silently ran unauthenticated and every
520
  # submission against a private dataset 401'd as "Asset missing".
521
+ hf_token = self.hf_token()
522
  if hf_token:
523
  env["HF_ADAPTER_HF_TOKEN"] = hf_token
524
  # No vlm_metadata_enabled-specific merge needed here: `env` already
 
532
 
533
  def indexing_compose_files(self) -> List[str]:
534
  files = ["-f", str(REPO_ROOT / "compose" / "docker-compose.indexing.yml")]
535
+ if self.indexing_siglip2_nvcf:
536
  files += ["-f", str(REPO_ROOT / "compose" / "docker-compose.indexing.nvcf-siglip2.yml")]
537
  # Only the indexing stack ever talks to a storage backend directly β€”
538
  # the runtime stack (verify/HF Space) serves an already-baked index and
pipeline/corpus.py CHANGED
@@ -9,6 +9,8 @@ exactly the kind of thing that silently indexes the wrong 200k assets.
9
 
10
  from __future__ import annotations
11
 
 
 
12
  import hashlib
13
  import json
14
  from dataclasses import dataclass
@@ -46,6 +48,25 @@ class CorpusError(ValueError):
46
  """Raised for a corpus file that cannot be used as written."""
47
 
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  @dataclass(frozen=True)
50
  class S3Prefix:
51
  bucket: str
@@ -201,13 +222,25 @@ class Corpus:
201
 
202
  # ── matching ──────────────────────────────────────────────────────────
203
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  def is_excluded(self, uri: str) -> bool:
205
  if any(s in uri for s in ALWAYS_EXCLUDE_SUBSTRINGS):
206
  return True
207
- return any(uri.startswith(p.uri) for p in self.exclude)
208
 
209
  def is_included(self, uri: str) -> bool:
210
- return any(uri.startswith(p.uri) for p in self.include)
211
 
212
  def has_wanted_extension(self, uri: str) -> bool:
213
  lowered = uri.lower()
@@ -318,13 +351,15 @@ class Corpus:
318
 
319
  # A prefix nested inside another include is redundant, not wrong β€” but
320
  # it usually signals a misunderstanding of the trailing-slash rule.
321
- for a in self.include:
322
- for b in self.include:
323
- if a is not b and a.uri != b.uri and a.uri.startswith(b.uri):
324
- raise CorpusError(
325
- f"include: {a.uri!r} is already covered by {b.uri!r}; "
326
- f"remove the redundant entry"
327
- )
 
 
328
 
329
  # A stray exclude β€” one that sits under no include β€” is nearly always a
330
  # typo, and it silently does nothing.
 
9
 
10
  from __future__ import annotations
11
 
12
+ import bisect
13
+ import functools
14
  import hashlib
15
  import json
16
  from dataclasses import dataclass
 
48
  """Raised for a corpus file that cannot be used as written."""
49
 
50
 
51
+ def _prefix_index(uris: Iterable[str]) -> List[str]:
52
+ """Sorted, with every entry another entry already covers dropped β€” so at
53
+ most one entry is a prefix of any string (see `_has_prefix_in`)."""
54
+ index: List[str] = []
55
+ for uri in sorted(set(uris)):
56
+ if not (index and uri.startswith(index[-1])):
57
+ index.append(uri)
58
+ return index
59
+
60
+
61
+ def _has_prefix_in(index: List[str], uri: str) -> bool:
62
+ """Whether some entry of `index` (from `_prefix_index`) is a prefix of
63
+ `uri`. Only the greatest entry <= `uri` can be: any entry between a prefix
64
+ p and `uri` either starts with p (dropped from the index) or sorts after
65
+ `uri`."""
66
+ i = bisect.bisect_right(index, uri)
67
+ return i > 0 and uri.startswith(index[i - 1])
68
+
69
+
70
  @dataclass(frozen=True)
71
  class S3Prefix:
72
  bucket: str
 
222
 
223
  # ── matching ──────────────────────────────────────────────────────────
224
 
225
+ # `enumerate` tests every file of every listed repo tree (400k on 2026-10-19)
226
+ # against every include (3,569), so a linear startswith scan took ~25 min.
227
+ # Same literal-prefix semantics, one bisect per lookup: see _prefix_index.
228
+
229
+ @functools.cached_property
230
+ def _include_index(self) -> List[str]:
231
+ return _prefix_index(p.uri for p in self.include)
232
+
233
+ @functools.cached_property
234
+ def _exclude_index(self) -> List[str]:
235
+ return _prefix_index(p.uri for p in self.exclude)
236
+
237
  def is_excluded(self, uri: str) -> bool:
238
  if any(s in uri for s in ALWAYS_EXCLUDE_SUBSTRINGS):
239
  return True
240
+ return _has_prefix_in(self._exclude_index, uri)
241
 
242
  def is_included(self, uri: str) -> bool:
243
+ return _has_prefix_in(self._include_index, uri)
244
 
245
  def has_wanted_extension(self, uri: str) -> bool:
246
  lowered = uri.lower()
 
351
 
352
  # A prefix nested inside another include is redundant, not wrong β€” but
353
  # it usually signals a misunderstanding of the trailing-slash rule.
354
+ # Sorted, a prefix's immediate successor starts with it whenever any
355
+ # entry does, so adjacent pairs are enough (duplicates are gone above).
356
+ uris = sorted(seen)
357
+ for b, a in zip(uris, uris[1:]):
358
+ if a.startswith(b):
359
+ raise CorpusError(
360
+ f"include: {a!r} is already covered by {b!r}; "
361
+ f"remove the redundant entry"
362
+ )
363
 
364
  # A stray exclude β€” one that sits under no include β€” is nearly always a
365
  # typo, and it silently does nothing.
pipeline/enumerate_assets.py CHANGED
@@ -131,6 +131,33 @@ def _hf_api():
131
  return HfApi(token=os.environ.get("HF_TOKEN") or None)
132
 
133
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  def enumerate_corpus_hf(corpus: Corpus, cfg: Config) -> Tuple[List[Asset], Dict[hf_catalog.RepoKey, List[str]]]:
135
  """The `storage_api` backend's counterpart to `enumerate_corpus` β€” lists
136
  each corpus HF repo's full file tree, applies the same include/exclude/
@@ -144,6 +171,7 @@ def enumerate_corpus_hf(corpus: Corpus, cfg: Config) -> Tuple[List[Asset], Dict[
144
  seen: Dict[str, Asset] = {}
145
  matched_paths_by_repo: Dict[hf_catalog.RepoKey, List[str]] = {}
146
  all_paths_by_repo: Dict[hf_catalog.RepoKey, Set[str]] = {}
 
147
  listed = 0
148
 
149
  for repo_key in corpus.hf_repos:
@@ -155,6 +183,7 @@ def enumerate_corpus_hf(corpus: Corpus, cfg: Config) -> Tuple[List[Asset], Dict[
155
  if isinstance(entry, RepoFile)
156
  }
157
  all_paths_by_repo[repo_key] = set(file_entries)
 
158
 
159
  matched: List[str] = []
160
  for path, entry in file_entries.items():
@@ -176,6 +205,7 @@ def enumerate_corpus_hf(corpus: Corpus, cfg: Config) -> Tuple[List[Asset], Dict[
176
  print(f" dropped {len(dropped)} 'removed' asset(s) from submission")
177
 
178
  print(f" listed {listed} objects, accepted {len(seen)}")
 
179
  asset_folders = hf_catalog.asset_folders_by_repo(corpus, matched_paths_by_repo, all_paths_by_repo)
180
  return [seen[k] for k in sorted(seen)], asset_folders
181
 
@@ -246,6 +276,7 @@ def run(cfg: Config, dry_run: bool = False) -> int:
246
  BUILD_DIR.mkdir(parents=True, exist_ok=True)
247
 
248
  asset_folders: Dict[hf_catalog.RepoKey, List[str]] = {}
 
249
  if corpus.storage_backend == "storage_api":
250
  print("Enumerating HuggingFace ...")
251
  assets, asset_folders = enumerate_corpus_hf(corpus, cfg)
 
131
  return HfApi(token=os.environ.get("HF_TOKEN") or None)
132
 
133
 
134
+ PACKAGE_SIZES = BUILD_DIR / "package_sizes.json"
135
+
136
+
137
+ def _package_sizes_group(repo_key: hf_catalog.RepoKey, file_entries: Dict) -> Dict:
138
+ """One (repo, revision)'s sums for the Space's /package_sizes.json, from the listing enumerate already made:
139
+ the whole repo's bytes and each top-level folder's β€” every file, not only the matched ones (same definition
140
+ as pipeline/package_download/sizes.py's own listing, which this spares at Space start)."""
141
+ _host, repo_type, repo_id, revision = repo_key
142
+ whole, folders = 0, {}
143
+ for path, entry in file_entries.items():
144
+ size = int(entry.size or 0)
145
+ whole += size
146
+ if "/" in path:
147
+ top = path.split("/", 1)[0]
148
+ folders[top] = folders.get(top, 0) + size
149
+ return {"kind": f"{repo_type}s", "repo_id": repo_id, "revision": revision, "whole": whole,
150
+ "folders": folders, "files": len(file_entries)}
151
+
152
+
153
+ def write_package_sizes(groups: List[Dict]) -> None:
154
+ """`export` ships this with the run (next to hf-storage-api-catalog.yaml); the Space's sizes task loads it
155
+ from the restore and lists HF only for a revision it lacks."""
156
+ doc = {"version": 1, "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "groups": groups}
157
+ PACKAGE_SIZES.write_text(json.dumps(doc, separators=(",", ":"), sort_keys=True))
158
+ print(f" wrote {PACKAGE_SIZES.name}: {len(groups)} (repo, revision) group(s)")
159
+
160
+
161
  def enumerate_corpus_hf(corpus: Corpus, cfg: Config) -> Tuple[List[Asset], Dict[hf_catalog.RepoKey, List[str]]]:
162
  """The `storage_api` backend's counterpart to `enumerate_corpus` β€” lists
163
  each corpus HF repo's full file tree, applies the same include/exclude/
 
171
  seen: Dict[str, Asset] = {}
172
  matched_paths_by_repo: Dict[hf_catalog.RepoKey, List[str]] = {}
173
  all_paths_by_repo: Dict[hf_catalog.RepoKey, Set[str]] = {}
174
+ package_sizes: List[Dict] = []
175
  listed = 0
176
 
177
  for repo_key in corpus.hf_repos:
 
183
  if isinstance(entry, RepoFile)
184
  }
185
  all_paths_by_repo[repo_key] = set(file_entries)
186
+ package_sizes.append(_package_sizes_group(repo_key, file_entries))
187
 
188
  matched: List[str] = []
189
  for path, entry in file_entries.items():
 
205
  print(f" dropped {len(dropped)} 'removed' asset(s) from submission")
206
 
207
  print(f" listed {listed} objects, accepted {len(seen)}")
208
+ write_package_sizes(package_sizes)
209
  asset_folders = hf_catalog.asset_folders_by_repo(corpus, matched_paths_by_repo, all_paths_by_repo)
210
  return [seen[k] for k in sorted(seen)], asset_folders
211
 
 
276
  BUILD_DIR.mkdir(parents=True, exist_ok=True)
277
 
278
  asset_folders: Dict[hf_catalog.RepoKey, List[str]] = {}
279
+ PACKAGE_SIZES.unlink(missing_ok=True) # an earlier run's sums never ride along with this one
280
  if corpus.storage_backend == "storage_api":
281
  print("Enumerating HuggingFace ...")
282
  assets, asset_folders = enumerate_corpus_hf(corpus, cfg)
pipeline/export.py CHANGED
@@ -218,9 +218,14 @@ def export_neo4j(cfg: Config) -> Dict:
218
  print(" stopping asset-graph-service ...")
219
  _run(["docker", "compose", "-p", COMPOSE_PROJECT, *cfg.indexing_compose_files(),
220
  "stop", "--timeout", "300", "asset-graph-service"], env=cfg.compose_env())
221
- print(" stopping neo4j ...")
 
 
 
 
 
222
  _run(["docker", "compose", "-p", COMPOSE_PROJECT, *cfg.indexing_compose_files(),
223
- "stop", "--timeout", "300", "neo4j"], env=cfg.compose_env())
224
 
225
  print(" dumping neo4j ...")
226
  # Runs as root (the image default), NOT as the host uid: the data volume is
@@ -276,6 +281,18 @@ def run(cfg: Config) -> int:
276
 
277
  print("Exporting OpenSearch ...")
278
  manifest["opensearch_artifacts"] = export_opensearch(cfg, manifest)
 
 
 
 
 
 
 
 
 
 
 
 
279
 
280
  print("\nExporting Neo4j ...")
281
  manifest["neo4j_artifact"] = export_neo4j(cfg)
@@ -289,10 +306,14 @@ def run(cfg: Config) -> int:
289
  # docs/hf-space-deployment.md Β§4 item 3 for why this is a
290
  # frozen index-time artifact, not something restored by re-listing the
291
  # HF dataset at Space startup.
292
- for name in ("corpus.json", "index-report.json", "assets.jsonl", "hf-storage-api-catalog.yaml"):
 
 
293
  src = BUILD_DIR / name
294
  if src.exists():
295
  shutil.copy2(src, ARTIFACTS / name)
 
 
296
 
297
  save_manifest(manifest)
298
  shutil.copy2(BUILD_DIR / "manifest.json", ARTIFACTS / "manifest.json")
 
218
  print(" stopping asset-graph-service ...")
219
  _run(["docker", "compose", "-p", COMPOSE_PROJECT, *cfg.indexing_compose_files(),
220
  "stop", "--timeout", "300", "asset-graph-service"], env=cfg.compose_env())
221
+ # 300 s wasn't enough either on 2026-10-16: the full simready-assets graph
222
+ # (~149k nodes, ~12k property indexes) was SIGKILLed mid-checkpoint and the
223
+ # dump refused with "Active logical log detected". Recover by starting
224
+ # neo4j once (it replays the log) and rerunning export.
225
+ stop_timeout = cfg.get_int("NEO4J_STOP_TIMEOUT", 1800)
226
+ print(f" stopping neo4j (up to {stop_timeout}s for its shutdown checkpoint) ...")
227
  _run(["docker", "compose", "-p", COMPOSE_PROJECT, *cfg.indexing_compose_files(),
228
+ "stop", "--timeout", str(stop_timeout), "neo4j"], env=cfg.compose_env())
229
 
230
  print(" dumping neo4j ...")
231
  # Runs as root (the image default), NOT as the host uid: the data volume is
 
281
 
282
  print("Exporting OpenSearch ...")
283
  manifest["opensearch_artifacts"] = export_opensearch(cfg, manifest)
284
+ from .image_overrides import check_dump # every doc its own nonzero vector, >= 98 % distinct
285
+ main_dump = OS_ARTIFACTS / f"{cfg.main_index}.data.json.gz"
286
+ if main_dump.exists() and check_dump(main_dump) != 0:
287
+ print(f"export: {main_dump.name} fails the vector check β€” not exporting a broken index")
288
+ return 1
289
+
290
+ # `wait` recorded image_cache_docs before `image-overrides` ran, and each override adds an image document
291
+ # (the package's own thumbnail document stays): 2597 assets -> 2605 image docs on 2026-10-16. scripts/smoke.sh
292
+ # checks the baked image against this number, so take it from what was actually exported.
293
+ image_cache = manifest["opensearch_artifacts"].get(cfg.image_cache_index)
294
+ if image_cache is not None:
295
+ manifest["image_cache_docs"] = image_cache["doc_count"]
296
 
297
  print("\nExporting Neo4j ...")
298
  manifest["neo4j_artifact"] = export_neo4j(cfg)
 
306
  # docs/hf-space-deployment.md Β§4 item 3 for why this is a
307
  # frozen index-time artifact, not something restored by re-listing the
308
  # HF dataset at Space startup.
309
+ # package_sizes.json (storage_api too): the Space's /package_sizes.json sums, from enumerate's own listing.
310
+ for name in ("corpus.json", "index-report.json", "assets.jsonl", "hf-storage-api-catalog.yaml",
311
+ "package_sizes.json"):
312
  src = BUILD_DIR / name
313
  if src.exists():
314
  shutil.copy2(src, ARTIFACTS / name)
315
+ else:
316
+ (ARTIFACTS / name).unlink(missing_ok=True) # .build/artifacts/ outlives runs; never ship an older one's
317
 
318
  save_manifest(manifest)
319
  shutil.copy2(BUILD_DIR / "manifest.json", ARTIFACTS / "manifest.json")
pipeline/hf_space.py CHANGED
@@ -51,7 +51,7 @@ from typing import Dict, List
51
  from huggingface_hub import HfApi, SpaceHardware
52
  from huggingface_hub.errors import HfHubHTTPError
53
 
54
- from .artifact_store import space_artifact_secrets
55
  from .bake import DOCKER_DIR, hf_space_build_args, hf_space_gpu_build_args, image_tags
56
  from .config import BUILD_DIR, HF_SPACE_GPU_HARDWARE_DEFAULT, REPO_ROOT, Config
57
  from .corpus import Corpus
@@ -225,6 +225,22 @@ def stage_repo(dest: Path, marker: str, readme: str, dockerfile_name: str = "Doc
225
  shutil.copytree(REPO_ROOT / "services" / "explorer" / "dist", dest / "services" / "explorer" / "dist")
226
 
227
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  def run(cfg: Config) -> int:
229
  manifest = load_manifest()
230
  if not manifest.get("hf_space_smoke_passed"):
@@ -238,6 +254,8 @@ def run(cfg: Config) -> int:
238
  if "hf_space" not in image_tags(cfg, manifest):
239
  print("No hf_space image for this run (SIGLIP2_BACKEND != nvcf) β€” nothing to deploy.")
240
  return 1
 
 
241
 
242
  repo_id = cfg.require("HF_SPACE_REPO_ID") # e.g. "nvidia/simready-central"
243
  title = cfg.get("HF_SPACE_TITLE", repo_id.split("/", 1)[-1])
@@ -329,6 +347,8 @@ def run_gpu(cfg: Config) -> int:
329
  if "hf_space_gpu" not in image_tags(cfg, manifest):
330
  print("No hf_space_gpu image for this run (HF_SPACE_BUILD_GPU_VARIANT != true) β€” nothing to deploy.")
331
  return 1
 
 
332
 
333
  repo_id = cfg.require("HF_SPACE_REPO_ID_GPU") # e.g. "nvidia/simready-central-gpu"
334
  title = cfg.get("HF_SPACE_TITLE_GPU", repo_id.split("/", 1)[-1])
 
51
  from huggingface_hub import HfApi, SpaceHardware
52
  from huggingface_hub.errors import HfHubHTTPError
53
 
54
+ from .artifact_store import bucket_base, list_objects, space_artifact_secrets
55
  from .bake import DOCKER_DIR, hf_space_build_args, hf_space_gpu_build_args, image_tags
56
  from .config import BUILD_DIR, HF_SPACE_GPU_HARDWARE_DEFAULT, REPO_ROOT, Config
57
  from .corpus import Corpus
 
225
  shutil.copytree(REPO_ROOT / "services" / "explorer" / "dist", dest / "services" / "explorer" / "dist")
226
 
227
 
228
+ def hierarchy_missing(cfg: Config, manifest: Dict) -> bool:
229
+ """True when the run has no published category tree. The Space serves the run's
230
+ hierarchy/category_hierarchy.json as /wikidata_hierarchy.json (the Explorer's Category tree), so a run without
231
+ one deploys a Space with no category browsing. `all --stop verify` skips the `hierarchy` stage; run it with
232
+ `--run-id <run>` before deploying."""
233
+ prefix = f"{bucket_base(cfg)}/{manifest['corpus_name']}/{manifest['run_id']}/hierarchy"
234
+ if any(rel == "category_hierarchy.json" for rel, _ in list_objects(cfg, prefix)):
235
+ return False
236
+ print(
237
+ f"Run {manifest['run_id']} has no {prefix}/category_hierarchy.json β€” the `hierarchy` stage is required "
238
+ f"before deploying. Run: python -m pipeline.run_pipeline hierarchy --env-file ... "
239
+ f"--run-id {manifest['run_id']} --new-only --api-url <a running stack>"
240
+ )
241
+ return True
242
+
243
+
244
  def run(cfg: Config) -> int:
245
  manifest = load_manifest()
246
  if not manifest.get("hf_space_smoke_passed"):
 
254
  if "hf_space" not in image_tags(cfg, manifest):
255
  print("No hf_space image for this run (SIGLIP2_BACKEND != nvcf) β€” nothing to deploy.")
256
  return 1
257
+ if hierarchy_missing(cfg, manifest):
258
+ return 1
259
 
260
  repo_id = cfg.require("HF_SPACE_REPO_ID") # e.g. "nvidia/simready-central"
261
  title = cfg.get("HF_SPACE_TITLE", repo_id.split("/", 1)[-1])
 
347
  if "hf_space_gpu" not in image_tags(cfg, manifest):
348
  print("No hf_space_gpu image for this run (HF_SPACE_BUILD_GPU_VARIANT != true) β€” nothing to deploy.")
349
  return 1
350
+ if hierarchy_missing(cfg, manifest):
351
+ return 1
352
 
353
  repo_id = cfg.require("HF_SPACE_REPO_ID_GPU") # e.g. "nvidia/simready-central-gpu"
354
  title = cfg.get("HF_SPACE_TITLE_GPU", repo_id.split("/", 1)[-1])
pipeline/hierarchy/run.py CHANGED
@@ -250,8 +250,11 @@ def run(cfg: Config, *, api_url: Optional[str] = None, replace_all: bool = False
250
  categories.load_vendors(), categories.load_pins())
251
  if unplaced:
252
  print(f"WARNING: {len(unplaced)} {categories.PROPERTY} value(s) without a place in the tree: {unplaced}")
 
 
253
  else:
254
  print(f"WARNING: no {categories.REPORT} (the `categories` stage did not run): the tree keeps {qprop}")
 
255
  (out_dir / "category_hierarchy.json").write_text(json.dumps(doc, indent=1, ensure_ascii=False, sort_keys=True) + "\n")
256
  cache_doc = {"corpus": corpus, "generated_at": now, "qcode_property": qprop, "metrics": metrics, "placements": {
257
  q: {"path": p.path, "label": p.label, "confidence": round(p.confidence, 3), "reason": p.reason,
 
250
  categories.load_vendors(), categories.load_pins())
251
  if unplaced:
252
  print(f"WARNING: {len(unplaced)} {categories.PROPERTY} value(s) without a place in the tree: {unplaced}")
253
+ # Package folder -> category folder for installers, from the same tree (served as /install_categories.json).
254
+ categories.write_install_list(out_dir, doc, run_id=target.rsplit("/", 1)[-1] if target else "", corpus=corpus)
255
  else:
256
  print(f"WARNING: no {categories.REPORT} (the `categories` stage did not run): the tree keeps {qprop}")
257
+ (out_dir / categories.INSTALL_FILE).unlink(missing_ok=True)
258
  (out_dir / "category_hierarchy.json").write_text(json.dumps(doc, indent=1, ensure_ascii=False, sort_keys=True) + "\n")
259
  cache_doc = {"corpus": corpus, "generated_at": now, "qcode_property": qprop, "metrics": metrics, "placements": {
260
  q: {"path": p.path, "label": p.label, "confidence": round(p.confidence, 3), "reason": p.reason,
pipeline/hierarchy/store.py CHANGED
@@ -2,6 +2,7 @@
2
 
3
  <ARTIFACT_S3_URI>/<corpus>/<run_id>/hierarchy/
4
  category_hierarchy.json the Explorer's tree (the Space restores it with the run)
 
5
  placements.json decision cache: reused by the next run
6
  folders_added.txt folders the agent created so far (base = repo file + these)
7
  report.md, report.html, misfiled_assets.json for review
 
2
 
3
  <ARTIFACT_S3_URI>/<corpus>/<run_id>/hierarchy/
4
  category_hierarchy.json the Explorer's tree (the Space restores it with the run)
5
+ install_categories.json package folder -> category folder (pipeline/categories.py install_list)
6
  placements.json decision cache: reused by the next run
7
  folders_added.txt folders the agent created so far (base = repo file + these)
8
  report.md, report.html, misfiled_assets.json for review
pipeline/image_overrides.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage `image-overrides` β€” embed, thumbnail and caption chosen assets from OUR image instead of the package's.
2
+
3
+ A partner package's own `.thumbs/256x256/` thumbnail can be a poor picture of it (a flat render on white, a cropped
4
+ part); its SigLIP2 embedding then drifts into unrelated searches. config/image_overrides/overrides.yaml names, per
5
+ asset (its hf:// address in the corpus), an image in that folder β€” SimReady Central's RTX still or spin start frame.
6
+ After `wait`, this stage runs usdsearch's OWN plugins on that image, inside the usdsearch image (same SigLIP2 client,
7
+ same VLM metadata generation and field schema, same OpenSearch writer as the workers), for each listed asset:
8
+
9
+ * thumbnail_to_embedding: the embedding and the stored thumbnail (what /images serves) are replaced;
10
+ * thumbnail_to_vision_metadata: the VLM metadata (caption, tags, ...) is written β€” only when IMAGE_OVERRIDES_VLM is
11
+ on, with USDSEARCH_LLM_BASE_URL / USDSEARCH_LLM_API_KEY / USDSEARCH_VISION_METADATA_MODEL from the environment.
12
+
13
+ Every call has a timeout and retries (IMAGE_OVERRIDES_TIMEOUT_S, IMAGE_OVERRIDES_TRIES); any asset left without its
14
+ embedding, or without its metadata when VLM is on, fails the stage. A wide image is padded to a square first (the
15
+ SigLIP2 preprocessing squashes to 384x384). The record goes to .build/image-overrides.json.
16
+
17
+ python -m pipeline.run_pipeline image-overrides --env-file ... [--dry-run]
18
+
19
+ An entry may also name `vlm_image`: the picture the VLM describes instead of `image` (e.g. an appliance shown open).
20
+ REEMBED_FROM_THUMBNAILS=true additionally recomputes every OTHER asset's embedding from its own `.thumbs` thumbnail
21
+ through the plugin's own loader (batched: REEMBED_BATCH, REEMBED_CONCURRENCY) and then checks that every document
22
+ carries its own vector (none zero, at least 98 % distinct) β€” the repair for vectors a restore left unusable.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ import subprocess
30
+ import sys
31
+ from pathlib import Path
32
+ from typing import Dict, List
33
+
34
+ import yaml
35
+
36
+ from .config import BUILD_DIR, REPO_ROOT, Config
37
+
38
+ OVERRIDES_DIR = REPO_ROOT / "config" / "image_overrides"
39
+ SPEC_FILE = OVERRIDES_DIR / "overrides.yaml"
40
+ REPORT = BUILD_DIR / "image-overrides.json"
41
+ WORKER = Path(__file__).with_name("image_overrides_worker.py")
42
+ COMPOSE_PROJECT = "simready-indexing"
43
+ SERVICE = "omni-writer" # has the OpenSearch writer's settings; the SigLIP2/LLM env is added per run
44
+ IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".webp")
45
+
46
+
47
+ def load_spec(path: Path = SPEC_FILE, includes: List[str] | None = None) -> Dict[str, Dict]:
48
+ """{asset address: {image, source}} β€” every image present in the folder, every asset in the corpus (when given)."""
49
+ if not path.exists():
50
+ return {}
51
+ raw = yaml.safe_load(path.read_text()) or {}
52
+ out: Dict[str, Dict] = {}
53
+ problems: List[str] = []
54
+ include_set = set(includes) if includes is not None else None
55
+ for key, rec in raw.items():
56
+ rec = rec if isinstance(rec, dict) else {"image": rec}
57
+ image = str(rec.get("image") or "")
58
+ if not key.startswith("hf://"):
59
+ problems.append(f"{key}: not an hf:// asset address")
60
+ if not image or "/" in image or not image.lower().endswith(IMAGE_EXTS):
61
+ problems.append(f"{key}: image {image!r} must be a file name in {OVERRIDES_DIR.name}/ ({', '.join(IMAGE_EXTS)})")
62
+ elif not (path.parent / image).is_file():
63
+ problems.append(f"{key}: {image} is missing")
64
+ if include_set is not None and key not in include_set:
65
+ problems.append(f"{key}: not in the corpus")
66
+ vlm_image = str(rec.get("vlm_image") or "")
67
+ if vlm_image and ("/" in vlm_image or not vlm_image.lower().endswith(IMAGE_EXTS) or not (path.parent / vlm_image).is_file()):
68
+ problems.append(f"{key}: vlm_image {vlm_image!r} must be an image file in {OVERRIDES_DIR.name}/")
69
+ out[key] = {"image": image, "source": str(rec.get("source") or "")}
70
+ if vlm_image:
71
+ out[key]["vlm_image"] = vlm_image
72
+ if problems:
73
+ raise SystemExit("image overrides:\n " + "\n ".join(problems))
74
+ return out
75
+
76
+
77
+ def vlm_enabled() -> bool:
78
+ return os.environ.get("IMAGE_OVERRIDES_VLM", "false").strip().lower() in ("1", "true", "yes", "on")
79
+
80
+
81
+ def run(cfg: Config, dry_run: bool = False) -> int:
82
+ corpus_path = BUILD_DIR / "corpus.json"
83
+ includes = json.loads(corpus_path.read_text()).get("include") if corpus_path.exists() else None
84
+ spec = load_spec(includes=includes)
85
+ if not spec:
86
+ print(f"no image overrides ({SPEC_FILE.relative_to(REPO_ROOT)} empty or absent) β€” nothing to do")
87
+ return 0
88
+ vlm = vlm_enabled()
89
+ if vlm and not os.environ.get("USDSEARCH_LLM_API_KEY", "").strip():
90
+ print("IMAGE_OVERRIDES_VLM is on but USDSEARCH_LLM_API_KEY is not set in the environment")
91
+ return 1
92
+ print(f"{len(spec)} image override(s); VLM metadata {'on (' + os.environ.get('USDSEARCH_VISION_METADATA_MODEL', 'default model') + ')' if vlm else 'off'}")
93
+ for key, rec in spec.items():
94
+ print(f" {rec['image']:<44} {key}")
95
+ if dry_run:
96
+ print("dry run: nothing written")
97
+ return 0
98
+
99
+ BUILD_DIR.mkdir(parents=True, exist_ok=True)
100
+ spec_path = BUILD_DIR / "image-overrides-spec.json"
101
+ reembed = []
102
+ if os.environ.get("REEMBED_FROM_THUMBNAILS", "").strip().lower() in ("1", "true", "yes", "on"):
103
+ # Repair: every other asset's embedding is recomputed from its own .thumbs thumbnail (the plugin's own loader).
104
+ reembed = sorted(k for k in (includes or []) if k not in spec)
105
+ print(f"re-embedding {len(reembed)} other asset(s) from their own thumbnails")
106
+ spec_path.write_text(json.dumps({"index": cfg.main_index, "vlm": vlm, "assets": spec, "reembed": reembed}, indent=1) + "\n")
107
+ passthrough = [
108
+ "SIGLIP2_TRITON_SERVER_URL", "SIGLIP2_TRITON_SERVER_SSL", "SIGLIP2_TRITON_SERVER_AUTH_TOKEN",
109
+ "SIGLIP2_TRITON_SERVER_HEADERS", "USDSEARCH_LLM_BASE_URL", "USDSEARCH_LLM_API_KEY",
110
+ "USDSEARCH_VISION_METADATA_MODEL", "IMAGE_OVERRIDES_TIMEOUT_S", "IMAGE_OVERRIDES_TRIES",
111
+ "REEMBED_BATCH", "REEMBED_CONCURRENCY",
112
+ ]
113
+ env = cfg.compose_env()
114
+ cmd = ["docker", "compose", "-p", COMPOSE_PROJECT, *cfg.indexing_compose_files(), "run", "--rm", "--no-deps", "-T",
115
+ "-v", f"{WORKER}:/ops/image_overrides_worker.py:ro",
116
+ "-v", f"{OVERRIDES_DIR}:/overrides:ro",
117
+ "-v", f"{spec_path}:/ops/spec.json:ro",
118
+ "-v", f"{REPO_ROOT / 'config' / 'vlm_metadata_fields.yaml'}:/app/vlm_metadata_fields.yaml:ro",
119
+ "-e", "USDSEARCH_VISION_METADATA_YAML_FILEPATH=/app/vlm_metadata_fields.yaml",
120
+ "-e", f"OPENSEARCH_URL_INTERNAL=http://opensearch:9200"]
121
+ for var in passthrough:
122
+ if env.get(var):
123
+ cmd += ["-e", var] # value from the compose CLI's environment, never on the command line
124
+ cmd += [SERVICE, "python", "/ops/image_overrides_worker.py", "/ops/spec.json"]
125
+ print("$ docker compose ... run --rm " + SERVICE + " python /ops/image_overrides_worker.py")
126
+ proc = subprocess.run(cmd, env=env, capture_output=True, text=True)
127
+ sys.stdout.write(proc.stderr[-20000:] if proc.returncode else "")
128
+ report = None
129
+ for line in proc.stdout.splitlines():
130
+ if line.startswith("IMAGE_OVERRIDES_REPORT "):
131
+ report = json.loads(line[len("IMAGE_OVERRIDES_REPORT "):])
132
+ else:
133
+ print(line)
134
+ if report is None:
135
+ print(f"the worker wrote no report (exit {proc.returncode})")
136
+ return 1
137
+ REPORT.write_text(json.dumps(report, indent=1) + "\n")
138
+ failed = [k for k, r in report["assets"].items() if not r.get("ok")]
139
+ print(f"image overrides: {len(report['assets']) - len(failed)} ok, {len(failed)} failed")
140
+ if failed or proc.returncode:
141
+ for k in failed:
142
+ print(f" FAILED {k}: {report['assets'][k].get('error')}")
143
+ return 1
144
+ return 0
145
+
146
+
147
+ def check_dump(path: Path, min_distinct: float = 0.98) -> int:
148
+ """Gate on an exported OpenSearch dump (elasticdump layout, .data.json[.gz]): every document carries a nonzero
149
+ siglip2 vector and at least `min_distinct` of them are distinct. Run 20261012-9d9f42a's dump failed this: one
150
+ vector on 1,399 of 1,404 documents."""
151
+ import gzip
152
+ import hashlib
153
+
154
+ def first_vector(o):
155
+ if isinstance(o, list) and o and all(isinstance(x, (int, float)) for x in o[:4]) and len(o) >= 64:
156
+ return o
157
+ items = o.values() if isinstance(o, dict) else (o if isinstance(o, list) else [])
158
+ for v in items:
159
+ found = first_vector(v)
160
+ if found is not None:
161
+ return found
162
+ return None
163
+
164
+ opener = gzip.open if str(path).endswith(".gz") else open
165
+ docs = missing = zero = 0
166
+ counts: Dict[str, int] = {}
167
+ with opener(path, "rt") as fh:
168
+ for line in fh:
169
+ if not line.strip():
170
+ continue
171
+ src = json.loads(line).get("_source") or {}
172
+ docs += 1
173
+ v = first_vector(src.get("siglip2-embedding"))
174
+ if v is None:
175
+ missing += 1
176
+ continue
177
+ if sum(x * x for x in v) < 1e-12:
178
+ zero += 1
179
+ h = hashlib.sha1(json.dumps([round(x, 5) for x in v]).encode()).hexdigest()
180
+ counts[h] = counts.get(h, 0) + 1
181
+ distinct = len(counts)
182
+ largest = max(counts.values(), default=0)
183
+ print(f"{path.name}: {docs} docs, {missing} without a vector, {zero} zero, {distinct} distinct, largest shared group {largest}")
184
+ return 0 if docs and not missing and not zero and distinct >= min_distinct * docs else 1
185
+
186
+
187
+ if __name__ == "__main__":
188
+ if len(sys.argv) == 3 and sys.argv[1] == "check-dump":
189
+ sys.exit(check_dump(Path(sys.argv[2])))
190
+ sys.exit("usage: python -m pipeline.image_overrides check-dump <...data.json[.gz]>")
pipeline/image_overrides_worker.py ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Runs INSIDE the usdsearch image (pipeline/image_overrides.py starts it with `docker compose run`): usdsearch's own
2
+ thumbnail_to_embedding and thumbnail_to_vision_metadata plugins on our image for each listed asset, written with the
3
+ plugins' own add_metadata through the same storage client the omni-writer uses. Prints one line
4
+ `IMAGE_OVERRIDES_REPORT {json}` at the end. Stdlib + what the usdsearch image ships (PIL, plugins, storage).
5
+ """
6
+
7
+ import asyncio
8
+ import hashlib
9
+ import json
10
+ import os
11
+ import sys
12
+ import time
13
+ import traceback
14
+ import urllib.request
15
+
16
+ from PIL import Image
17
+
18
+ TIMEOUT_S = float(os.environ.get("IMAGE_OVERRIDES_TIMEOUT_S") or 180)
19
+ TRIES = int(os.environ.get("IMAGE_OVERRIDES_TRIES") or 3)
20
+ OS_URL = os.environ.get("OPENSEARCH_URL_INTERNAL", "http://opensearch:9200").rstrip("/")
21
+ THUMB_PX = 256 # the size every other asset's thumbnail is embedded and stored at (.thumbs/256x256)
22
+ VLM_PX = 512 # the VLM plugin's own vlm_image_max_px default
23
+
24
+
25
+ def square(img: Image.Image) -> Image.Image:
26
+ """Pad a wide or tall image to a square with its own border colour (the SigLIP2 preprocessing squashes to a
27
+ square, which would distort it)."""
28
+ img = img.convert("RGB")
29
+ w, h = img.size
30
+ if w == h:
31
+ return img
32
+ side = max(w, h)
33
+ px = img.load()
34
+ border = [px[x, 0] for x in range(w)] + [px[x, h - 1] for x in range(w)] + [px[0, y] for y in range(h)] + [px[w - 1, y] for y in range(h)]
35
+ fill = tuple(sum(c[i] for c in border) // len(border) for i in range(3))
36
+ out = Image.new("RGB", (side, side), fill)
37
+ out.paste(img, ((side - w) // 2, (side - h) // 2))
38
+ return out
39
+
40
+
41
+ def os_doc(index: str, key: str) -> dict:
42
+ body = json.dumps({"size": 2, "query": {"term": {"base_key": key}}}).encode()
43
+ req = urllib.request.Request(f"{OS_URL}/{index}/_search", data=body, headers={"content-type": "application/json"})
44
+ hits = json.load(urllib.request.urlopen(req, timeout=60))["hits"]["hits"]
45
+ if len(hits) != 1:
46
+ raise RuntimeError(f"{len(hits)} documents for {key}")
47
+ return hits[0]["_source"]
48
+
49
+
50
+ def digests(src: dict) -> dict:
51
+ return {k: hashlib.sha256(json.dumps(v, sort_keys=True, default=str).encode()).hexdigest()[:12] for k, v in src.items()}
52
+
53
+
54
+ def result_content(results: dict, plugin_name: str):
55
+ r = results.get(0)
56
+ if r is None:
57
+ raise RuntimeError(f"{plugin_name}: no result")
58
+ content = (r.get("search_backend_content") or {}).get(plugin_name)
59
+ if not content:
60
+ raise RuntimeError(f"{plugin_name}: empty content ({r.get('asset_status')})")
61
+ if "error" in content:
62
+ raise RuntimeError(f"{plugin_name}: {content['error']}")
63
+ return content
64
+
65
+
66
+ async def with_retries(what: str, fn):
67
+ last = None
68
+ for attempt in range(1, TRIES + 1):
69
+ try:
70
+ return await asyncio.wait_for(fn(), timeout=TIMEOUT_S)
71
+ except Exception as exc: # noqa: BLE001 β€” retried, then reported
72
+ last = f"{type(exc).__name__}: {exc}"
73
+ print(f" {what}: attempt {attempt}/{TRIES} failed: {last}", flush=True)
74
+ await asyncio.sleep(5 * attempt)
75
+ raise RuntimeError(f"{what}: gave up after {TRIES} attempts: {last}")
76
+
77
+
78
+ def thumbnail_url(key: str) -> str:
79
+ """The asset's own .thumbs/256x256 thumbnail at its pinned revision β€” the file the thumbnail_to_embedding worker
80
+ embeds: hf://<host>/datasets/<o>/<n>/resolve/<rev>/<dir>/<file> -> https://<host>/datasets/.../<dir>/.thumbs/256x256/<file>.png"""
81
+ if not key.startswith("hf://"):
82
+ raise ValueError(f"not an hf:// address: {key}")
83
+ folder, name = key[len("hf://"):].rsplit("/", 1)
84
+ return f"https://{folder}/.thumbs/256x256/{name}.png"
85
+
86
+
87
+ def fetch_thumbnail(key: str) -> Image.Image:
88
+ import io
89
+ last = None
90
+ for attempt in range(1, 6):
91
+ try:
92
+ req = urllib.request.Request(thumbnail_url(key), headers={"User-Agent": "simready-central-indexing/reembed"})
93
+ with urllib.request.urlopen(req, timeout=60) as f:
94
+ img = Image.open(io.BytesIO(f.read()))
95
+ img.load()
96
+ return img.convert("RGB")
97
+ except urllib.error.HTTPError as exc:
98
+ last = exc
99
+ if exc.code not in (429, 500, 502, 503, 504):
100
+ raise
101
+ except Exception as exc: # noqa: BLE001
102
+ last = exc
103
+ time.sleep(2 * attempt)
104
+ raise RuntimeError(f"thumbnail {thumbnail_url(key)}: {last}")
105
+
106
+
107
+ def served_thumbnail(index: str, key: str) -> Image.Image:
108
+ """The thumbnail the Space serves for `key` (/images): the image-cache document the doc's siglip2-embedding
109
+ entry points at (its `image` id), decoded."""
110
+ import base64
111
+ import io
112
+ src = os_doc(index, key)
113
+ entries = src.get("siglip2-embedding") or []
114
+ image_id = entries[0].get("image") if entries and isinstance(entries[0], dict) else None
115
+ if not image_id:
116
+ raise RuntimeError("no image id on the document")
117
+ cache_index = index.replace("-ver5.0", "-ver4.0-image-cache")
118
+ doc = json.load(urllib.request.urlopen(f"{OS_URL}/{cache_index}/_doc/{image_id}", timeout=60))["_source"]
119
+ b64 = next(iter(doc.values()))
120
+ img = Image.open(io.BytesIO(base64.b64decode(b64)))
121
+ img.load()
122
+ return img.convert("RGB")
123
+
124
+
125
+ def same_picture(a: Image.Image, b: Image.Image) -> float:
126
+ """Mean absolute difference (0-255) of two pictures at 64x64 greyscale: JPEG re-encoding stays well under 10."""
127
+ pa = list(a.convert("L").resize((64, 64), Image.BILINEAR).getdata())
128
+ pb = list(b.convert("L").resize((64, 64), Image.BILINEAR).getdata())
129
+ return sum(abs(x - y) for x, y in zip(pa, pb)) / len(pa)
130
+
131
+
132
+ def check_sources(index: str, keys: list, report: dict, sample: int = 20, max_diff: float = 12.0) -> bool:
133
+ """Before re-embedding: on a sample, the fetched .thumbs file must be the picture the Space serves."""
134
+ step = max(1, len(keys) // sample)
135
+ rows, bad = [], 0
136
+ for key in keys[::step][:sample]:
137
+ try:
138
+ fetched, served = fetch_thumbnail(key), served_thumbnail(index, key)
139
+ d = round(same_picture(fetched, served), 2)
140
+ rows.append({"key": key, "fetched": list(fetched.size), "served": list(served.size), "diff": d})
141
+ bad += d > max_diff
142
+ except Exception as exc: # noqa: BLE001
143
+ rows.append({"key": key, "error": f"{type(exc).__name__}: {exc}"})
144
+ bad += 1
145
+ report["source_check"] = {"sample": len(rows), "mismatched": bad, "max_diff": max_diff, "rows": rows}
146
+ print(f" source check: {len(rows) - bad} of {len(rows)} fetched thumbnails match the served ones "
147
+ f"(diffs {[r.get('diff', r.get('error')) for r in rows]})", flush=True)
148
+ return bad <= 2
149
+
150
+
151
+ async def reembed(keys, emb, storage, report):
152
+ """thumbnail_to_embedding for each asset from its OWN .thumbs/256x256 thumbnail (fetched from its pinned HF
153
+ revision), batched; the embedding and stored thumbnail replaced through the plugin's add_metadata."""
154
+
155
+ batch_size = int(os.environ.get("REEMBED_BATCH") or 16)
156
+ concurrency = int(os.environ.get("REEMBED_CONCURRENCY") or 4)
157
+ sem = asyncio.Semaphore(concurrency)
158
+ done = {"ok": 0, "failed": []}
159
+
160
+ async def one_batch(chunk):
161
+ async with sem:
162
+ loaded = []
163
+ for key in chunk:
164
+ try:
165
+ img = await asyncio.wait_for(asyncio.to_thread(fetch_thumbnail, key), timeout=TIMEOUT_S)
166
+ loaded.append(emb.load_data(omni_path=key, data=[img]))
167
+ except Exception as exc: # noqa: BLE001
168
+ done["failed"].append({"key": key, "error": f"thumbnail: {type(exc).__name__}: {exc}"})
169
+ if not loaded:
170
+ return
171
+
172
+ async def embed():
173
+ batch, idx, errors = await emb.preprocess(data=loaded)
174
+ results = await emb.process_valid_items(batch_data=batch, indices=idx, sample_ids=list(range(len(loaded))))
175
+ return batch, idx, errors, results
176
+
177
+ try:
178
+ batch, idx, errors, results = await with_retries(f"embedding batch of {len(loaded)}", embed)
179
+ except Exception as exc: # noqa: BLE001
180
+ done["failed"] += [{"key": d["omni_path"], "error": str(exc)} for d in loaded]
181
+ return
182
+ for i, err in errors.items():
183
+ done["failed"].append({"key": loaded[i]["omni_path"], "error": f"preprocess: {err}"})
184
+ for i in idx:
185
+ key = loaded[i]["omni_path"]
186
+ try:
187
+ content = result_content({0: results[i]}, "thumbnail_to_embedding")
188
+ await emb.add_metadata(path=key, content=content, storage_client=storage)
189
+ done["ok"] += 1
190
+ except Exception as exc: # noqa: BLE001
191
+ done["failed"].append({"key": key, "error": f"write: {type(exc).__name__}: {exc}"})
192
+ print(f" re-embedded {done['ok']} so far, {len(done['failed'])} failed", flush=True)
193
+
194
+ chunks = [keys[i:i + batch_size] for i in range(0, len(keys), batch_size)]
195
+ await asyncio.gather(*(one_batch(c) for c in chunks))
196
+ report["reembed"] = {"requested": len(keys), "ok": done["ok"], "failed": done["failed"][:50], "failed_count": len(done["failed"]),
197
+ "batch": batch_size, "concurrency": concurrency}
198
+
199
+
200
+ async def main(spec_path: str) -> int:
201
+ from plugins.thumbnail_to_embedding import ThumbnailToEmbedding
202
+ from plugins.thumbnail_to_vision_metadata import ThumbnailToVisionMetadata, ThumbnailToVisionMetadataConfig
203
+ from search_utils.storage_client.config import StorageConfig
204
+ from storage.src.client import NGSearchStorageClient
205
+
206
+ spec = json.load(open(spec_path))
207
+ index, vlm_on = spec["index"], spec["vlm"]
208
+ storage = await NGSearchStorageClient.get_service(storage_config=StorageConfig(), storage_client_config=None,
209
+ search_backend_config=None)
210
+ emb = ThumbnailToEmbedding()
211
+ vlm = ThumbnailToVisionMetadata(ThumbnailToVisionMetadataConfig(vlm_agenerate_timeout=TIMEOUT_S)) if vlm_on else None
212
+ report = {"index": index, "vlm": vlm_on, "model": os.environ.get("USDSEARCH_VISION_METADATA_MODEL", "") if vlm_on else "",
213
+ "timeout_s": TIMEOUT_S, "tries": TRIES, "assets": {}}
214
+ reembed_keys = spec.get("reembed") or []
215
+ if reembed_keys:
216
+ if not check_sources(index, reembed_keys, report):
217
+ print("IMAGE_OVERRIDES_REPORT " + json.dumps(report), flush=True)
218
+ print(" the fetched thumbnails are not the served ones: not re-embedding", flush=True)
219
+ return 1
220
+ await reembed(reembed_keys, emb, storage, report)
221
+ for key, rec in spec["assets"].items():
222
+ rep = report["assets"].setdefault(key, {"image": rec["image"], "ok": False})
223
+ t0 = time.time()
224
+ try:
225
+ img = Image.open(f"/overrides/{rec['image']}")
226
+ img.load()
227
+ sq = square(img)
228
+ thumb = sq.resize((THUMB_PX, THUMB_PX), Image.BILINEAR)
229
+ rep["source_size"] = list(img.size)
230
+ before = digests(os_doc(index, key))
231
+
232
+ async def embed():
233
+ batch, idx, errors = await emb.preprocess(data=[{"thumbnails": [thumb], "omni_path": key}])
234
+ if errors or idx != [0]:
235
+ raise RuntimeError(f"preprocess: {errors}")
236
+ return result_content(await emb.process_valid_items(batch_data=batch, indices=idx, sample_ids=[0]),
237
+ "thumbnail_to_embedding")
238
+
239
+ content = await with_retries("embedding", embed)
240
+ rep["embedding_dims"] = [len(e) for e in content["embedding"]]
241
+ await emb.add_metadata(path=key, content=content, storage_client=storage)
242
+
243
+ if vlm is not None:
244
+ vsrc = sq
245
+ if rec.get("vlm_image"):
246
+ vimg = Image.open(f"/overrides/{rec['vlm_image']}")
247
+ vimg.load()
248
+ vsrc = square(vimg)
249
+ rep["vlm_image"] = rec["vlm_image"]
250
+ vlm_img = vsrc.resize((VLM_PX, VLM_PX), Image.BILINEAR) if max(vsrc.size) > VLM_PX else vsrc
251
+
252
+ async def describe():
253
+ batch, idx, errors = await vlm.preprocess(data=[{vlm.batch_data_field: [vlm_img], "omni_path": key}])
254
+ if errors or idx != [0]:
255
+ raise RuntimeError(f"preprocess: {errors}")
256
+ return result_content(await vlm.process_valid_items(batch_data=batch, indices=idx, sample_ids=[0]),
257
+ "thumbnail_to_vision_metadata")
258
+
259
+ meta = await with_retries("vision metadata", describe)
260
+ fields = next(iter(meta.values()))
261
+ rep["vision_fields"] = sorted(f["name"] for f in fields)
262
+ caption = next((f for f in fields if f["name"] == "caption"), None)
263
+ rep["caption"] = (caption or {}).get("value_text")
264
+ await vlm.add_metadata(path=key, content=meta, storage_client=storage)
265
+
266
+ urllib.request.urlopen(urllib.request.Request(f"{OS_URL}/{index}/_refresh", method="POST"), timeout=120)
267
+ after = digests(os_doc(index, key))
268
+ # A re-run on the same image writes the same embedding: unchanged fields are not a failure.
269
+ rep["changed_fields"] = sorted(k for k in set(before) | set(after) if before.get(k) != after.get(k))
270
+ rep["ok"] = vlm is None or bool(rep.get("vision_fields"))
271
+ except Exception as exc: # noqa: BLE001 β€” reported per asset
272
+ rep["error"] = f"{type(exc).__name__}: {exc}"
273
+ traceback.print_exc()
274
+ rep["seconds"] = round(time.time() - t0, 1)
275
+ print(f" {'ok ' if rep['ok'] else 'FAIL'} {rec['image']}: changed {rep.get('changed_fields')}; "
276
+ f"caption {rep.get('caption')!r}; {rep['seconds']} s", flush=True)
277
+ ok = all(r["ok"] for r in report["assets"].values())
278
+ if reembed_keys:
279
+ urllib.request.urlopen(urllib.request.Request(f"{OS_URL}/{index}/_refresh", method="POST"), timeout=300)
280
+ # every doc carries its own vector: no zero ones and (almost) no shared ones
281
+ # The vectors themselves are checked on the exported dump (pipeline/image_overrides.py check_dump): a live
282
+ # index with k-NN derived source may not show them in _source.
283
+ # A failed fetch fails that asset, not the run: the dump gate after export decides (>= 98 % distinct vectors).
284
+ if report["reembed"]["failed_count"]:
285
+ print(f" WARNING: {report['reembed']['failed_count']} asset(s) not re-embedded: {report['reembed']['failed'][:5]}", flush=True)
286
+ print("IMAGE_OVERRIDES_REPORT " + json.dumps(report), flush=True)
287
+ return 0 if ok else 1
288
+
289
+
290
+ if __name__ == "__main__":
291
+ sys.exit(asyncio.run(main(sys.argv[1])))
pipeline/library_media.py CHANGED
@@ -166,6 +166,27 @@ Web USD's full level (``.usdhd.tar``) and the media adds' HD files (spin.hd, rtx
166
  ``.hd.tar``'s (``skip``, after the choice, so they are never counted missing), and under GLB off it still asks for room
167
  last, after the USD archives and every add. Revert: delete it, or set the variable, and restart.
168
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  Joint counts (2026-10-16, both GLB modes): every count of an asset's moving parts follows one rule, the joints the
170
  viewer drives (``user_joints``: SimReady Central's libmedia_rules.user_joints since !733, vendored; the Explorer's
171
  userJointCount and isUserJoint): never a mimic, a coupling or a closed loop's solved or closing joint. The archives
@@ -229,6 +250,10 @@ from .config import BUILD_DIR, Config
229
 
230
  SUBDIR = "library-media"
231
  ARCHIVE = re.compile(r"^media-\d{8}T\d{4}Z\.tar$") # media-20261005T1010Z.tar: the name sorts by time
 
 
 
 
232
  # The Explorer's index key for this Space (/config.js in nginx.conf.template): the manifest's URL names it.
233
  INDEX_KEY = "usdsearch-dev"
234
  MEDIA_ROOT = BUILD_DIR / "library_media"
@@ -311,18 +336,68 @@ def run_prefix(cfg: Config, store, corpus: str) -> str:
311
  return json.loads(pointer)["prefix"].rstrip("/")
312
 
313
 
314
- def archive_uri(cfg: Config, store, prefix: str) -> Optional[str]:
 
 
 
 
 
 
 
 
 
315
  """The newest archive of the run at ``prefix``, else of the latest earlier run that has any; None: there is none.
316
- Run ids start with the date (hierarchy/store.py), so they sort by age."""
 
 
317
  base, run = prefix.rsplit("/", 1)
318
  earlier = [r for r in sorted(store.list_subprefixes(cfg, base), reverse=True) if r < run]
 
319
  for r in [run] + earlier:
320
- names = [rel for rel, _ in store.list_objects(cfg, f"{base}/{r}/{SUBDIR}") if ARCHIVE.match(rel)]
 
 
 
 
 
 
321
  if names:
322
- return f"{base}/{r}/{SUBDIR}/{max(names)}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
323
  return None
324
 
325
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
  def valid_manifest(path: Path) -> int | None:
327
  """The number of assets a manifest lists, or None when it is not one the Explorer reads (version 1, assets{})."""
328
  try:
@@ -930,16 +1005,19 @@ def landed(tree: Path, entry_id: str, names: set[str]) -> bool:
930
  return bool(names) and all((tree / entry_id / n).is_file() for n in names)
931
 
932
 
933
- def publish_usd(tree: Path, blocks: dict[str, dict], created: Optional[dict[str, dict]] = None) -> int:
 
934
  """Rewrite the served manifest with exactly ``blocks`` ({Space key: usd block}) as its entries' ``usd`` (every
935
  other block untouched; an entry not in ``blocks`` loses any ``usd``), atomically. The number of entries carrying
936
  one. An unchanged manifest is not written. ``created`` (an add's new entries, {Space key: {id, moving,
937
  robot?}}): each one in ``blocks`` is served as exactly that plus its ``usd`` (added when absent) and the RTX media
938
  an add attached to it (MEDIA_KINDS, kept as served); each one not in ``blocks`` leaves the manifest whole (it has
939
- nothing but its USD and those)."""
 
 
940
  path = tree / "manifest.json"
941
  doc = json.loads(path.read_text())
942
- created = created or {}
943
  assets = doc.get("assets") if isinstance(doc.get("assets"), dict) else {}
944
  changed = n = 0
945
  for key, e in assets.items():
@@ -947,7 +1025,11 @@ def publish_usd(tree: Path, blocks: dict[str, dict], created: Optional[dict[str,
947
  continue
948
  if key in blocks:
949
  changed += e.get("usd") != blocks[key]
 
950
  e["usd"] = blocks[key]
 
 
 
951
  n += 1
952
  elif "usd" in e:
953
  del e["usd"]
@@ -1012,7 +1094,7 @@ def fetch_hd(cfg: Config, store, uri: str, tree: Path, not_written: int = 0,
1012
  MediaAdds.files) is never written, whatever the manifest lists. A manifest that lists no tier-2 model streams the
1013
  ``.hd-noglb.tar`` instead where there is one (``hd_noglb``, ``fetch_hd_noglb``; decided on the manifest, before
1014
  ``skip``)."""
1015
- hd_uri = uri[: -len(".tar")] + HD_SUFFIX
1016
  if cfg.get("LIBRARY_MEDIA_HD", "").strip().lower() in DISABLED:
1017
  print("==> library media HD: off (LIBRARY_MEDIA_HD)")
1018
  return 0
@@ -1024,6 +1106,13 @@ def fetch_hd(cfg: Config, store, uri: str, tree: Path, not_written: int = 0,
1024
  allowed = {i: names for i, names in allowed.items() if names}
1025
  if not allowed:
1026
  return 0
 
 
 
 
 
 
 
1027
  noglb = hd_noglb(cfg, store, uri, manifest)
1028
  del manifest
1029
  if noglb is not None:
@@ -1056,7 +1145,7 @@ def hd_noglb(cfg: Config, store, uri: str, manifest: dict) -> Optional[tuple[str
1056
  LIBRARY_MEDIA_HD_SPLIT is not off; else None, and the ``.hd.tar`` streams as before."""
1057
  if hd_files(manifest) != hd_files(manifest, models=False):
1058
  return None # an entry lists model_hd.glb: only the .hd.tar has it
1059
- noglb = uri[: -len(".tar")] + HD_NOGLB_SUFFIX
1060
  if cfg.get("LIBRARY_MEDIA_HD_SPLIT", "").strip().lower() in DISABLED:
1061
  print(f"==> library media HD: {noglb.rsplit('/', 1)[-1]} off (LIBRARY_MEDIA_HD_SPLIT) β€” the .hd.tar")
1062
  return None
@@ -1067,10 +1156,12 @@ def hd_noglb(cfg: Config, store, uri: str, manifest: dict) -> Optional[tuple[str
1067
  return noglb, size
1068
 
1069
 
1070
- def fetch_hd_noglb(cfg: Config, store, uri: str, size: int, tree: Path, allowed: dict[str, set[str]]) -> int:
 
1071
  """Stream the ``.hd-noglb.tar`` ``uri`` (``size`` listed) into ``tree``, as ``fetch_hd`` streams the ``.hd.tar``:
1072
  only members ``allowed`` lists, only with room for ``size``, a broken stream started again once. Returns the files
1073
- written; logs the listed files still missing (the Explorer stays on tier 1 for those)."""
 
1074
  if not room_for(tree, size, "HD (the archive without model_hd.glb)"):
1075
  return 0
1076
  t0 = time.time()
@@ -1080,7 +1171,8 @@ def fetch_hd_noglb(cfg: Config, store, uri: str, size: int, tree: Path, allowed:
1080
  written, nbytes, skipped = got
1081
  missing = sum(1 for i, names in allowed.items() for n in names if not (tree / i / n).is_file())
1082
  print(f"==> library media HD: {written} files ({nbytes / 1e6:.0f} MB) from {uri} in {time.time() - t0:.0f} s, "
1083
- f"in place of the .hd.tar: the served manifest lists no tier-2 model"
 
1084
  + (f"; {skipped} members not in the manifest skipped" if skipped else "")
1085
  + (f"; {missing} listed HD files not in it (those stay on tier 1)" if missing else ""))
1086
  return written
@@ -1092,11 +1184,12 @@ def viewer_format(cfg: Config) -> str:
1092
  return value if value in VIEWER_FORMATS else "glb"
1093
 
1094
 
1095
- def fetch_usd(cfg: Config, store, uri: str, tree: Path) -> int:
1096
  """Web USD (see the module doc): stream ``uri``'s ``.usd.tar`` sibling into the installed ``tree`` and publish the
1097
  blocks whose tier-1 files landed (without their full level), then its ``.usdhd.tar`` and give back the full level
1098
  of the blocks whose files landed. Returns the number of entries the served manifest then gives a ``usd``. Off
1099
- unless VIEWER_FORMAT is usd or auto; each archive only with room to spare (``room_for``)."""
 
1100
  fmt = viewer_format(cfg)
1101
  if fmt == "glb":
1102
  print("==> library media USD: off (VIEWER_FORMAT glb)")
@@ -1104,7 +1197,7 @@ def fetch_usd(cfg: Config, store, uri: str, tree: Path) -> int:
1104
  if cfg.get("LIBRARY_MEDIA_USD", "").strip().lower() in DISABLED:
1105
  print("==> library media USD: off (LIBRARY_MEDIA_USD)")
1106
  return 0
1107
- base = uri[: -len(".tar")]
1108
  usd_uri = base + USD_SUFFIX
1109
  size = listed_size(cfg, store, usd_uri)
1110
  if size is None:
@@ -1120,7 +1213,7 @@ def fetch_usd(cfg: Config, store, uri: str, tree: Path) -> int:
1120
  return 0
1121
  blocks, left, written, nbytes, skipped = got
1122
  tier1 = {k: usd_without_hd(b) for k, (i, b) in blocks.items() if landed(tree, i, usd_tier1_files(b))}
1123
- shown = publish_usd(tree, tier1)
1124
  print(f"==> library media USD: {shown} of {len(blocks)} blocks served at tier 1 ({written} files, "
1125
  f"{nbytes / 1e6:.0f} MB) from {usd_uri} in {time.time() - t0:.0f} s"
1126
  + (f"; {left} blocks not in this manifest (or malformed) left out" if left else "")
@@ -1135,10 +1228,18 @@ def fetch_usd(cfg: Config, store, uri: str, tree: Path) -> int:
1135
  file=sys.stderr)
1136
  return shown
1137
  if landed_hd:
1138
- publish_usd(tree, {**tier1, **{k: blocks[k][1] for k in landed_hd}})
1139
  return shown
1140
 
1141
 
 
 
 
 
 
 
 
 
1142
  def fetch_usd_hd(cfg: Config, store, uri: str, tree: Path, blocks: dict[str, tuple[str, dict]],
1143
  what: str = "USD HD", media: Optional[dict[str, set[str]]] = None) -> set[str]:
1144
  """Stream the ``.usdhd.tar`` ``uri`` (or an add's ``.usdhd-add-<n>.tar``; ``what`` names it in the log) into
@@ -1183,8 +1284,8 @@ def served_usd(tree: Path) -> dict[str, dict]:
1183
 
1184
  def usd_adds(cfg: Config, store, uri: str) -> list[tuple[int, str, int]]:
1185
  """The supplementary web USD archives of ``uri``'s stamp (media-<stamp>.usd-add-<n>.tar): [(n, uri, size)] by n."""
1186
- folder, name = uri.rsplit("/", 1)
1187
- pattern = re.compile(re.escape(name[: -len(".tar")]) + USD_ADD)
1188
  found = [(int(m.group(1)), f"{folder}/{rel}", size) for rel, size in store.list_objects(cfg, folder)
1189
  if (m := pattern.fullmatch(rel))]
1190
  return sorted(found)
@@ -2405,29 +2506,62 @@ def restore_glb(cfg: Config, store, uri: str, tree: Path, held: GlbHeld, ids: se
2405
  return sum(missed.values())
2406
 
2407
 
2408
- def fetch_usd_only(cfg: Config, store, uri: str, tree: Path, held: Optional[GlbHeld] = None) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2409
  """LIBRARY_MEDIA_GLB=off (``glb_off_why``): the web USD archives and adds first (the GLB stays served meanwhile, but
2410
  for the GLB skip's folders, ``held``), then ``recount_moving``, then ``drop_glb``, then the held folders kept on
2411
  their GLB get it (``restore_glb``), then the GLB HD archive. Each step fails on its own, like ``fetch``'s; a failed
2412
  drop_glb leaves every entry on its GLB (the manifest is written whole or not at all; every held folder restored)
2413
  and its model_hd.glb then written as before; drop_glb removes no file. The HD room check asks for the archive less
2414
  the model_hd.glb drop_glb unlisted: they stream through, never written. The adds may create new entries
2415
- (``NewEntries``): only here. The GLB HD archive never writes a file of the adds' RTX media."""
 
2416
  not_written = 0
2417
  new = NewEntries()
 
2418
  try:
2419
- fetch_usd(cfg, store, uri, tree)
2420
  except Exception as exc: # noqa: BLE001 β€” the GLB tiers are served already; web USD is a bonus
2421
- print(f"==> library media USD: stopped ({type(exc).__name__}: {str(exc)[:300]}) β€” the GLB stays",
2422
- file=sys.stderr)
2423
  try:
2424
  fetch_usd_adds(cfg, store, uri, tree, new)
2425
  except Exception as exc: # noqa: BLE001 β€” as above
2426
  print(f"==> library media USD adds: stopped ({type(exc).__name__}: {str(exc)[:300]})", file=sys.stderr)
 
 
 
 
 
 
 
 
 
 
 
2427
  recount(tree, held, new.aliases)
2428
  restore: set[str] = set()
2429
  try:
2430
- _, not_written, restore = drop_glb(tree, held, new.aliases)
 
 
 
2431
  except Exception as exc: # noqa: BLE001 β€” every entry keeps its GLB
2432
  restore = set(held.names) if held is not None else set()
2433
  print(f"==> library media GLB: stopped ({type(exc).__name__}: {str(exc)[:300]}) β€” every entry keeps its GLB"
@@ -2462,16 +2596,23 @@ def fetch(env: dict[str, str], root: Path = MEDIA_ROOT, store=None, explorer: Op
2462
  cfg = Config(values=dict(env))
2463
  t0 = time.time()
2464
  prefix = run_prefix(cfg, store, corpus)
2465
- uri = archive_uri(cfg, store, prefix)
 
 
 
 
 
2466
  if uri is None:
2467
  print(f"==> library media: none for {prefix} or an earlier run β€” none served")
2468
  return 0
2469
- off_why = glb_off_why(cfg, EXPLORER_ROOT if explorer is None else explorer)
2470
  # the GLB skip: under GLB off in effect, the GLB tier-1 files are not written at install (see the module doc)
2471
- held = GlbHeld() if off_why is None and cfg.get(GLB_SKIP, "").strip().lower() not in DISABLED else None
 
2472
  root.mkdir(parents=True, exist_ok=True)
2473
  tmp = root / f".{INDEX_KEY}.download"
2474
  shutil.rmtree(tmp, ignore_errors=True)
 
2475
  try:
2476
  archive = tmp / "media.tar"
2477
  if not store.download_file(cfg, uri, archive):
@@ -2483,7 +2624,10 @@ def fetch(env: dict[str, str], root: Path = MEDIA_ROOT, store=None, explorer: Op
2483
  print(f"==> library media: {uri} has no usable manifest.json β€” none served")
2484
  return 0
2485
  if '"usd"' in (tmp / "tree" / "manifest.json").read_text():
2486
- # web USD comes only from its own archives (fetch_usd), never from this one's manifest
 
 
 
2487
  publish_usd(tmp / "tree", {})
2488
  if held is not None:
2489
  held.unlist(tmp / "tree")
@@ -2494,11 +2638,12 @@ def fetch(env: dict[str, str], root: Path = MEDIA_ROOT, store=None, explorer: Op
2494
  nheld, held_bytes = held.files() if held is not None else (0, 0)
2495
  print(f"==> library media: {count} assets from {uri} ({size / 1e6:.0f} MB) in {time.time() - t0:.0f} s"
2496
  + (f"; GLB off: {nheld} model.glb / articulation.json ({held_bytes / 1e6:.0f} MB) not written, only an entry "
2497
- f"kept on its GLB gets them" if held is not None else ""))
 
2498
  if off_why:
2499
  print(f"==> library media GLB: off asked, but {off_why} β€” every entry keeps its GLB")
2500
  if off_why is None:
2501
- fetch_usd_only(cfg, store, uri, root / INDEX_KEY, held)
2502
  return count
2503
  try:
2504
  fetch_hd(cfg, store, uri, root / INDEX_KEY)
 
166
  ``.hd.tar``'s (``skip``, after the choice, so they are never counted missing), and under GLB off it still asks for room
167
  last, after the USD archives and every add. Revert: delete it, or set the variable, and restart.
168
 
169
+ USD-only stamp (2026-10-16, under GLB off only): a stamp whose main archive is ``media-<stamp>.noglb.tar`` (``USD_ONLY``)
170
+ in place of ``media-<stamp>.tar`` carries no GLB at all: no entry of its manifest lists ``files.glb``,
171
+ ``files.articulation`` or ``hd``, and none of those files is in it. ``python -m pipeline.library_media_pack
172
+ consolidate`` packs one from a served tree: a start's base and adds as ONE stamp, the blocks the adds replaced and the
173
+ GLB files gone, nothing left for an add to do. Its siblings are the stamp's ``.usd.tar``, ``.usdhd.tar`` and
174
+ ``.hd-noglb.tar`` (its only HD archive: streamed whatever LIBRARY_MEDIA_HD_SPLIT says; there is no ``.hd.tar``). Its
175
+ manifest is the served one, ``usd`` blocks included: they leave it at install as any main archive's do, and each comes
176
+ back from the ``.usd.tar`` in the place that manifest gave it in its entry (``usd_places``), so a start serves that
177
+ manifest byte for byte. Its entries are base entries (an entry with no GLB needs no ``entry`` stub): until its block
178
+ lands, and for good where it does not, an entry is served with its RTX media and no model. The loader takes such a
179
+ stamp only while GLB off is in effect (``glb_off_why``); otherwise ``archive_uri`` skips it, says so, and takes the
180
+ newest stamp with a GLB (VIEWER_FORMAT glb, an Explorer without the USD-only reader: the stamp before it, with its adds,
181
+ served as before). Both kinds count for the newest stamp by their stamp (a GLB archive wins a tie); a USD-only stamp's
182
+ adds are its own, ``media-<stamp>.usd-add-<n>.tar``. ARCHIVE never matches its name, so a loader without this rule
183
+ never opens it: uploading one changes nothing until this loader runs. While it is served, an older stamp's adds in its
184
+ run are not applied (they were folded in, or came too late): one log line names them. An entry whose block does not
185
+ land (no room for the ``.usd.tar``, a broken stream) has no GLB to fall back on and is served with no 3D model: one
186
+ loud line counts and names them. Revert: LIBRARY_MEDIA_USD_ONLY=off (off / none / false / 0) and restart β€” the stamp
187
+ is set aside as under VIEWER_FORMAT glb, GLB off unchanged, and the newest stamp with a GLB comes back with its adds β€”
188
+ or delete its objects and restart.
189
+
190
  Joint counts (2026-10-16, both GLB modes): every count of an asset's moving parts follows one rule, the joints the
191
  viewer drives (``user_joints``: SimReady Central's libmedia_rules.user_joints since !733, vendored; the Explorer's
192
  userJointCount and isUserJoint): never a mimic, a coupling or a closed loop's solved or closing joint. The archives
 
250
 
251
  SUBDIR = "library-media"
252
  ARCHIVE = re.compile(r"^media-\d{8}T\d{4}Z\.tar$") # media-20261005T1010Z.tar: the name sorts by time
253
+ # A USD-only stamp's main archive (see the module doc), taken only while GLB off is in effect; ARCHIVE never matches it.
254
+ USD_ONLY_SUFFIX = ".noglb.tar"
255
+ USD_ONLY = re.compile(r"^media-\d{8}T\d{4}Z\.noglb\.tar$")
256
+ STAMP_LEN = len("media-20261005T1010Z")
257
  # The Explorer's index key for this Space (/config.js in nginx.conf.template): the manifest's URL names it.
258
  INDEX_KEY = "usdsearch-dev"
259
  MEDIA_ROOT = BUILD_DIR / "library_media"
 
336
  return json.loads(pointer)["prefix"].rstrip("/")
337
 
338
 
339
+ USD_ONLY_NOT_ASKED = "LIBRARY_MEDIA_GLB is not off"
340
+ # A USD-only stamp's own switch (off / none / false / 0): set aside like an older Explorer's, with GLB off unchanged β€”
341
+ # the rollback that needs no S3 delete (see the module doc).
342
+ USD_ONLY_SWITCH = "LIBRARY_MEDIA_USD_ONLY"
343
+ USD_ONLY_SWITCHED_OFF = f"{USD_ONLY_SWITCH} is off"
344
+ ADDS_SHOWN = 8 # older stamps' adds a USD-only stamp leaves aside, named in a log line
345
+ OLDER_ADD = re.compile(r"^media-\d{8}T\d{4}Z" + USD_ADD + "$")
346
+
347
+
348
+ def archive_uri(cfg: Config, store, prefix: str, usd_only_why: Optional[str] = USD_ONLY_NOT_ASKED) -> Optional[str]:
349
  """The newest archive of the run at ``prefix``, else of the latest earlier run that has any; None: there is none.
350
+ Run ids start with the date (hierarchy/store.py), so they sort by age. A USD-only stamp's ``.noglb.tar`` (see the
351
+ module doc) counts only when ``usd_only_why`` is None (GLB off in effect), by its stamp, a GLB archive winning a
352
+ tie; else it is skipped, and the newest one newer than what is taken is named in a log line with ``usd_only_why``."""
353
  base, run = prefix.rsplit("/", 1)
354
  earlier = [r for r in sorted(store.list_subprefixes(cfg, base), reverse=True) if r < run]
355
+ skipped: Optional[tuple[str, str]] = None # (run, name) of the newest USD-only stamp skipped
356
  for r in [run] + earlier:
357
+ rels = [rel for rel, _ in store.list_objects(cfg, f"{base}/{r}/{SUBDIR}")]
358
+ names = [rel for rel in rels if ARCHIVE.match(rel)]
359
+ only = [rel for rel in rels if USD_ONLY.match(rel)]
360
+ if usd_only_why is None:
361
+ names += only
362
+ elif only and skipped is None:
363
+ skipped = (r, max(only))
364
  if names:
365
+ pick = max(names, key=lambda n: (n[:STAMP_LEN], ARCHIVE.match(n) is not None))
366
+ if skipped is not None and (skipped[0] > r or skipped[1][:STAMP_LEN] > pick[:STAMP_LEN]):
367
+ print(f"==> library media: {base}/{skipped[0]}/{SUBDIR}/{skipped[1]} skipped β€” "
368
+ f"{usd_only_set_aside(usd_only_why)}; {base}/{r}/{SUBDIR}/{pick} instead")
369
+ if USD_ONLY.match(pick):
370
+ # an older stamp's adds never apply to a newer stamp: said once, so one uploaded too late is seen
371
+ aside = sorted(rel for rel in rels if OLDER_ADD.match(rel) and rel[:STAMP_LEN] < pick[:STAMP_LEN])
372
+ if aside:
373
+ print(f"==> library media: a USD-only stamp ({pick}): {len(aside)} adds of older stamps not "
374
+ f"applied (only this stamp's own adds are): " + ", ".join(aside[:ADDS_SHOWN])
375
+ + (f", +{len(aside) - ADDS_SHOWN} more" if len(aside) > ADDS_SHOWN else ""))
376
+ return f"{base}/{r}/{SUBDIR}/{pick}"
377
+ if skipped is not None:
378
+ print(f"==> library media: {base}/{skipped[0]}/{SUBDIR}/{skipped[1]} skipped β€” "
379
+ f"{usd_only_set_aside(usd_only_why)}; no archive with a GLB")
380
  return None
381
 
382
 
383
+ def usd_only_set_aside(why: str) -> str:
384
+ """Why a USD-only stamp is skipped, for ``archive_uri``'s log line."""
385
+ if why == USD_ONLY_SWITCHED_OFF:
386
+ return f"a USD-only stamp (no GLB) set aside ({why})"
387
+ return f"a USD-only stamp (no GLB) needs LIBRARY_MEDIA_GLB=off in effect ({why})"
388
+
389
+
390
+ def usd_only_stamp(uri: str) -> bool:
391
+ """Whether ``uri`` is a USD-only stamp's main archive (media-<stamp>.noglb.tar)."""
392
+ return USD_ONLY.match(uri.rsplit("/", 1)[-1]) is not None
393
+
394
+
395
+ def stamp_base(uri: str) -> str:
396
+ """A stamp's main archive ``uri`` (media-<stamp>.tar or media-<stamp>.noglb.tar) less its suffix: every sibling's
397
+ name is this plus its own (.hd.tar, .hd-noglb.tar, .usd.tar, .usdhd.tar, .usd-add-<n>.tar, …)."""
398
+ return uri[: -len(USD_ONLY_SUFFIX)] if usd_only_stamp(uri) else uri[: -len(".tar")]
399
+
400
+
401
  def valid_manifest(path: Path) -> int | None:
402
  """The number of assets a manifest lists, or None when it is not one the Explorer reads (version 1, assets{})."""
403
  try:
 
1005
  return bool(names) and all((tree / entry_id / n).is_file() for n in names)
1006
 
1007
 
1008
+ def publish_usd(tree: Path, blocks: dict[str, dict], created: Optional[dict[str, dict]] = None,
1009
+ place: Optional[dict[str, list]] = None) -> int:
1010
  """Rewrite the served manifest with exactly ``blocks`` ({Space key: usd block}) as its entries' ``usd`` (every
1011
  other block untouched; an entry not in ``blocks`` loses any ``usd``), atomically. The number of entries carrying
1012
  one. An unchanged manifest is not written. ``created`` (an add's new entries, {Space key: {id, moving,
1013
  robot?}}): each one in ``blocks`` is served as exactly that plus its ``usd`` (added when absent) and the RTX media
1014
  an add attached to it (MEDIA_KINDS, kept as served); each one not in ``blocks`` leaves the manifest whole (it has
1015
+ nothing but its USD and those). ``place`` (a USD-only stamp's ``usd_places``: {Space key: its entry's keys in the
1016
+ stamp's manifest}): a block added where the entry had none takes the place that list gives it (keys the list does
1017
+ not name after it, in their order); else it goes last."""
1018
  path = tree / "manifest.json"
1019
  doc = json.loads(path.read_text())
1020
+ created, place = created or {}, place or {}
1021
  assets = doc.get("assets") if isinstance(doc.get("assets"), dict) else {}
1022
  changed = n = 0
1023
  for key, e in assets.items():
 
1025
  continue
1026
  if key in blocks:
1027
  changed += e.get("usd") != blocks[key]
1028
+ had = "usd" in e
1029
  e["usd"] = blocks[key]
1030
+ if not had and key in place: # a value replaced while iterating: the dict keeps its size
1031
+ assets[key] = {k: e[k] for k in [*[k for k in place[key] if k in e], *[k for k in e
1032
+ if k not in place[key]]]}
1033
  n += 1
1034
  elif "usd" in e:
1035
  del e["usd"]
 
1094
  MediaAdds.files) is never written, whatever the manifest lists. A manifest that lists no tier-2 model streams the
1095
  ``.hd-noglb.tar`` instead where there is one (``hd_noglb``, ``fetch_hd_noglb``; decided on the manifest, before
1096
  ``skip``)."""
1097
+ hd_uri = stamp_base(uri) + HD_SUFFIX
1098
  if cfg.get("LIBRARY_MEDIA_HD", "").strip().lower() in DISABLED:
1099
  print("==> library media HD: off (LIBRARY_MEDIA_HD)")
1100
  return 0
 
1106
  allowed = {i: names for i, names in allowed.items() if names}
1107
  if not allowed:
1108
  return 0
1109
+ if usd_only_stamp(uri): # its only HD archive (see the module doc)
1110
+ only_uri = stamp_base(uri) + HD_NOGLB_SUFFIX
1111
+ only_size = listed_size(cfg, store, only_uri)
1112
+ if only_size is None:
1113
+ print(f"==> library media HD: none at {only_uri}")
1114
+ return 0
1115
+ return fetch_hd_noglb(cfg, store, only_uri, only_size, tree, allowed, usd_only=True)
1116
  noglb = hd_noglb(cfg, store, uri, manifest)
1117
  del manifest
1118
  if noglb is not None:
 
1145
  LIBRARY_MEDIA_HD_SPLIT is not off; else None, and the ``.hd.tar`` streams as before."""
1146
  if hd_files(manifest) != hd_files(manifest, models=False):
1147
  return None # an entry lists model_hd.glb: only the .hd.tar has it
1148
+ noglb = stamp_base(uri) + HD_NOGLB_SUFFIX
1149
  if cfg.get("LIBRARY_MEDIA_HD_SPLIT", "").strip().lower() in DISABLED:
1150
  print(f"==> library media HD: {noglb.rsplit('/', 1)[-1]} off (LIBRARY_MEDIA_HD_SPLIT) β€” the .hd.tar")
1151
  return None
 
1156
  return noglb, size
1157
 
1158
 
1159
+ def fetch_hd_noglb(cfg: Config, store, uri: str, size: int, tree: Path, allowed: dict[str, set[str]],
1160
+ usd_only: bool = False) -> int:
1161
  """Stream the ``.hd-noglb.tar`` ``uri`` (``size`` listed) into ``tree``, as ``fetch_hd`` streams the ``.hd.tar``:
1162
  only members ``allowed`` lists, only with room for ``size``, a broken stream started again once. Returns the files
1163
+ written; logs the listed files still missing (the Explorer stays on tier 1 for those). ``usd_only``: a USD-only
1164
+ stamp's, its only HD archive (said so in the log)."""
1165
  if not room_for(tree, size, "HD (the archive without model_hd.glb)"):
1166
  return 0
1167
  t0 = time.time()
 
1171
  written, nbytes, skipped = got
1172
  missing = sum(1 for i, names in allowed.items() for n in names if not (tree / i / n).is_file())
1173
  print(f"==> library media HD: {written} files ({nbytes / 1e6:.0f} MB) from {uri} in {time.time() - t0:.0f} s, "
1174
+ + ("the USD-only stamp's HD archive" if usd_only else
1175
+ "in place of the .hd.tar: the served manifest lists no tier-2 model")
1176
  + (f"; {skipped} members not in the manifest skipped" if skipped else "")
1177
  + (f"; {missing} listed HD files not in it (those stay on tier 1)" if missing else ""))
1178
  return written
 
1184
  return value if value in VIEWER_FORMATS else "glb"
1185
 
1186
 
1187
+ def fetch_usd(cfg: Config, store, uri: str, tree: Path, place: Optional[dict[str, list]] = None) -> int:
1188
  """Web USD (see the module doc): stream ``uri``'s ``.usd.tar`` sibling into the installed ``tree`` and publish the
1189
  blocks whose tier-1 files landed (without their full level), then its ``.usdhd.tar`` and give back the full level
1190
  of the blocks whose files landed. Returns the number of entries the served manifest then gives a ``usd``. Off
1191
+ unless VIEWER_FORMAT is usd or auto; each archive only with room to spare (``room_for``). ``place``: a USD-only
1192
+ stamp's ``usd_places`` (``publish_usd``)."""
1193
  fmt = viewer_format(cfg)
1194
  if fmt == "glb":
1195
  print("==> library media USD: off (VIEWER_FORMAT glb)")
 
1197
  if cfg.get("LIBRARY_MEDIA_USD", "").strip().lower() in DISABLED:
1198
  print("==> library media USD: off (LIBRARY_MEDIA_USD)")
1199
  return 0
1200
+ base = stamp_base(uri)
1201
  usd_uri = base + USD_SUFFIX
1202
  size = listed_size(cfg, store, usd_uri)
1203
  if size is None:
 
1213
  return 0
1214
  blocks, left, written, nbytes, skipped = got
1215
  tier1 = {k: usd_without_hd(b) for k, (i, b) in blocks.items() if landed(tree, i, usd_tier1_files(b))}
1216
+ shown = publish_usd(tree, tier1, place=place)
1217
  print(f"==> library media USD: {shown} of {len(blocks)} blocks served at tier 1 ({written} files, "
1218
  f"{nbytes / 1e6:.0f} MB) from {usd_uri} in {time.time() - t0:.0f} s"
1219
  + (f"; {left} blocks not in this manifest (or malformed) left out" if left else "")
 
1228
  file=sys.stderr)
1229
  return shown
1230
  if landed_hd:
1231
+ publish_usd(tree, {**tier1, **{k: blocks[k][1] for k in landed_hd}}, place=place)
1232
  return shown
1233
 
1234
 
1235
+ def usd_places(tree: Path) -> dict[str, list]:
1236
+ """A USD-only stamp's main manifest as unpacked under ``tree``, before its ``usd`` blocks leave it: {Space key:
1237
+ that entry's keys in order} for every entry that carries one (``publish_usd``'s ``place``)."""
1238
+ assets = json.loads((tree / "manifest.json").read_text()).get("assets")
1239
+ return {k: list(e) for k, e in (assets.items() if isinstance(assets, dict) else ())
1240
+ if isinstance(e, dict) and "usd" in e}
1241
+
1242
+
1243
  def fetch_usd_hd(cfg: Config, store, uri: str, tree: Path, blocks: dict[str, tuple[str, dict]],
1244
  what: str = "USD HD", media: Optional[dict[str, set[str]]] = None) -> set[str]:
1245
  """Stream the ``.usdhd.tar`` ``uri`` (or an add's ``.usdhd-add-<n>.tar``; ``what`` names it in the log) into
 
1284
 
1285
  def usd_adds(cfg: Config, store, uri: str) -> list[tuple[int, str, int]]:
1286
  """The supplementary web USD archives of ``uri``'s stamp (media-<stamp>.usd-add-<n>.tar): [(n, uri, size)] by n."""
1287
+ folder, name = stamp_base(uri).rsplit("/", 1)
1288
+ pattern = re.compile(re.escape(name) + USD_ADD)
1289
  found = [(int(m.group(1)), f"{folder}/{rel}", size) for rel, size in store.list_objects(cfg, folder)
1290
  if (m := pattern.fullmatch(rel))]
1291
  return sorted(found)
 
2506
  return sum(missed.values())
2507
 
2508
 
2509
+ def usd_only_without_model(tree: Path, place: dict[str, list]) -> list[str]:
2510
+ """A USD-only stamp's entries (``place``: those its manifest gave a ``usd`` block) the served manifest under
2511
+ ``tree`` lists with no ``usd`` now: served with no 3D model at all. Their ids."""
2512
+ assets = json.loads((tree / "manifest.json").read_text()).get("assets")
2513
+ assets = assets if isinstance(assets, dict) else {}
2514
+ return [str(assets[k].get("id")) for k in place if isinstance(assets.get(k), dict) and "usd" not in assets[k]]
2515
+
2516
+
2517
+ def lists_glb(tree: Path) -> bool:
2518
+ """Whether an entry of the served manifest under ``tree`` lists a GLB (``files.glb`` / ``files.articulation``) or a
2519
+ tier-2 model (``hd``)."""
2520
+ assets = json.loads((tree / "manifest.json").read_text()).get("assets")
2521
+ return any(isinstance(e, dict) and (e.get("hd") is not None or (isinstance(e.get("files"), dict) and any(
2522
+ e["files"].get(r) for r in GLB_ROLES))) for e in (assets.values() if isinstance(assets, dict) else ()))
2523
+
2524
+
2525
+ def fetch_usd_only(cfg: Config, store, uri: str, tree: Path, held: Optional[GlbHeld] = None,
2526
+ place: Optional[dict[str, list]] = None) -> None:
2527
  """LIBRARY_MEDIA_GLB=off (``glb_off_why``): the web USD archives and adds first (the GLB stays served meanwhile, but
2528
  for the GLB skip's folders, ``held``), then ``recount_moving``, then ``drop_glb``, then the held folders kept on
2529
  their GLB get it (``restore_glb``), then the GLB HD archive. Each step fails on its own, like ``fetch``'s; a failed
2530
  drop_glb leaves every entry on its GLB (the manifest is written whole or not at all; every held folder restored)
2531
  and its model_hd.glb then written as before; drop_glb removes no file. The HD room check asks for the archive less
2532
  the model_hd.glb drop_glb unlisted: they stream through, never written. The adds may create new entries
2533
+ (``NewEntries``): only here. The GLB HD archive never writes a file of the adds' RTX media. A USD-only stamp
2534
+ (``place``, its ``usd_places``): its blocks back in their places, and no ``drop_glb`` while no entry lists a GLB."""
2535
  not_written = 0
2536
  new = NewEntries()
2537
+ only = usd_only_stamp(uri)
2538
  try:
2539
+ fetch_usd(cfg, store, uri, tree, place)
2540
  except Exception as exc: # noqa: BLE001 β€” the GLB tiers are served already; web USD is a bonus
2541
+ print(f"==> library media USD: stopped ({type(exc).__name__}: {str(exc)[:300]}) β€” "
2542
+ + ("a USD-only stamp has no GLB to fall back on" if only else "the GLB stays"), file=sys.stderr)
2543
  try:
2544
  fetch_usd_adds(cfg, store, uri, tree, new)
2545
  except Exception as exc: # noqa: BLE001 β€” as above
2546
  print(f"==> library media USD adds: stopped ({type(exc).__name__}: {str(exc)[:300]})", file=sys.stderr)
2547
+ if only and place:
2548
+ try:
2549
+ no_model = usd_only_without_model(tree, place)
2550
+ except Exception as exc: # noqa: BLE001 β€” a log line only
2551
+ no_model = [f"not counted ({type(exc).__name__})"]
2552
+ if no_model:
2553
+ print(f"==> library media USD: a USD-only stamp: {len(no_model)} of {len(place)} entries served with NO "
2554
+ f"3D model (their web USD did not land; no GLB to fall back on): " + ", ".join(no_model[:ADDS_SHOWN])
2555
+ + (f", +{len(no_model) - ADDS_SHOWN} more" if len(no_model) > ADDS_SHOWN else "")
2556
+ + f" β€” revert: {USD_ONLY_SWITCH}=off and restart (the stamp before it, with its adds)",
2557
+ file=sys.stderr)
2558
  recount(tree, held, new.aliases)
2559
  restore: set[str] = set()
2560
  try:
2561
+ if usd_only_stamp(uri) and not lists_glb(tree):
2562
+ print("==> library media GLB: off β€” a USD-only stamp: no entry lists a GLB or a model_hd.glb")
2563
+ else:
2564
+ _, not_written, restore = drop_glb(tree, held, new.aliases)
2565
  except Exception as exc: # noqa: BLE001 β€” every entry keeps its GLB
2566
  restore = set(held.names) if held is not None else set()
2567
  print(f"==> library media GLB: stopped ({type(exc).__name__}: {str(exc)[:300]}) β€” every entry keeps its GLB"
 
2596
  cfg = Config(values=dict(env))
2597
  t0 = time.time()
2598
  prefix = run_prefix(cfg, store, corpus)
2599
+ off_why = glb_off_why(cfg, EXPLORER_ROOT if explorer is None else explorer)
2600
+ # a USD-only stamp only while GLB off is in effect and its own switch is not off (see the module doc)
2601
+ usd_only_why = None if off_why is None else (off_why or USD_ONLY_NOT_ASKED)
2602
+ if usd_only_why is None and cfg.get(USD_ONLY_SWITCH, "").strip().lower() in DISABLED:
2603
+ usd_only_why = USD_ONLY_SWITCHED_OFF
2604
+ uri = archive_uri(cfg, store, prefix, usd_only_why)
2605
  if uri is None:
2606
  print(f"==> library media: none for {prefix} or an earlier run β€” none served")
2607
  return 0
2608
+ only = usd_only_stamp(uri)
2609
  # the GLB skip: under GLB off in effect, the GLB tier-1 files are not written at install (see the module doc)
2610
+ held = (GlbHeld() if off_why is None and not only and cfg.get(GLB_SKIP, "").strip().lower() not in DISABLED
2611
+ else None)
2612
  root.mkdir(parents=True, exist_ok=True)
2613
  tmp = root / f".{INDEX_KEY}.download"
2614
  shutil.rmtree(tmp, ignore_errors=True)
2615
+ place: dict[str, list] = {}
2616
  try:
2617
  archive = tmp / "media.tar"
2618
  if not store.download_file(cfg, uri, archive):
 
2624
  print(f"==> library media: {uri} has no usable manifest.json β€” none served")
2625
  return 0
2626
  if '"usd"' in (tmp / "tree" / "manifest.json").read_text():
2627
+ # web USD comes only from its own archives (fetch_usd), never from this one's manifest; a USD-only
2628
+ # stamp's blocks come back in the places its manifest gives them
2629
+ if only:
2630
+ place = usd_places(tmp / "tree")
2631
  publish_usd(tmp / "tree", {})
2632
  if held is not None:
2633
  held.unlist(tmp / "tree")
 
2638
  nheld, held_bytes = held.files() if held is not None else (0, 0)
2639
  print(f"==> library media: {count} assets from {uri} ({size / 1e6:.0f} MB) in {time.time() - t0:.0f} s"
2640
  + (f"; GLB off: {nheld} model.glb / articulation.json ({held_bytes / 1e6:.0f} MB) not written, only an entry "
2641
+ f"kept on its GLB gets them" if held is not None else "")
2642
+ + (f"; a USD-only stamp: {len(place)} web USD blocks to come from its .usd.tar" if only else ""))
2643
  if off_why:
2644
  print(f"==> library media GLB: off asked, but {off_why} β€” every entry keeps its GLB")
2645
  if off_why is None:
2646
+ fetch_usd_only(cfg, store, uri, root / INDEX_KEY, held, place)
2647
  return count
2648
  try:
2649
  fetch_hd(cfg, store, uri, root / INDEX_KEY)
pipeline/library_media_pack.py ADDED
@@ -0,0 +1,522 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The packing side of pipeline/library_media.py's archives: the ``.hd-noglb.tar``, and a USD-only stamp. Run where
2
+ the media are built or stored (a lab pod, a one-off Action), never on a Space.
3
+
4
+ python -m pipeline.library_media_pack hd-noglb --manifest <manifest.json | media-<stamp>.tar> \\
5
+ [--in <media-<stamp>.hd.tar> | -] [--out <media-<stamp>.hd-noglb.tar> | -] [--summary <file.json>] \\
6
+ [--require-complete]
7
+ python -m pipeline.library_media_pack consolidate --tree <served tree> --stamp <YYYYMMDDTHHMMZ> --out <dir> \\
8
+ [--part-size N] [--jobs N] [--hash-only] [--drop-orphans] [--run <run id>] [--sources <listing.json>]
9
+
10
+ A stamp's ``.hd.tar`` carries the tier-2 models (model_hd.glb) beside the spin HD frames and RTX clip HD files; its
11
+ ``.hd-noglb.tar`` carries those two alone (library_media's module doc, "HD without the models"). This reads the
12
+ ``.hd.tar`` once, as a stream, and writes every member ``library_media.hd_files(manifest, models=False)`` lists (a
13
+ regular file under ``<id>/<path>``: ``library_media.member_listed``, the loader's own rule), header fields (name, size,
14
+ mtime, mode, owner), bytes and order unchanged (``tarfile`` re-encodes the headers as pax, so the archive is not a
15
+ byte-for-byte cut of the source); everything else (model_hd.glb, directories, members the manifest does not list) is
16
+ left out. Streamed by the loader under a manifest that lists no tier-2 model, it writes exactly the files the
17
+ ``.hd.tar`` would.
18
+
19
+ ``--manifest``: the stamp's manifest, a ``manifest.json`` or the ``media-<stamp>.tar`` whose ``manifest.json`` member
20
+ is read. ``--out`` is never written over. The summary (JSON; on stderr unless ``--summary``): members kept and left
21
+ out by kind with their bytes, the source's and the output's size and sha256, and the listed files the source lacks
22
+ (``--require-complete``: exit 2 when there is any). A failure leaves no output file; on stdout it leaves no
23
+ end-of-archive marker (a reader sees a truncated archive), so a caller storing the stream must check its size and
24
+ sha256 against the summary before keeping it.
25
+
26
+ ``consolidate`` (library_media's module doc, "USD-only stamp"): the tree a Space serves under GLB off β€” its
27
+ ``.build/library_media/usdsearch-dev/`` after a start, every entry on its web USD, base and adds applied β€” packed as
28
+ ONE stamp the loader starts from alone: ``media-<stamp>.noglb.tar`` (the served ``manifest.json`` first, ``usd`` blocks
29
+ included, then every file below that no other archive carries), ``.usd.tar`` (``usd-manifest.json`` first: each served
30
+ block under its key and id, then each block's ``usd_tier1_files``), ``.usdhd.tar`` (each served block's
31
+ ``usd_hd_files``: only a block served at its full level names them) and ``.hd-noglb.tar`` (the files
32
+ ``hd_files(manifest, models=False)`` lists that are there: spin HD frames, RTX clip HD files). Every file of the tree
33
+ goes in exactly one archive, so a start of the stamp alone writes the same tree and serves the same manifest byte for
34
+ byte. Refused, before anything is written: a manifest that is not one, an entry that lists a GLB (``files.glb``,
35
+ ``files.articulation``) or a tier-2 model (``hd``: an entry kept on its GLB cannot be USD-only), a block the loader
36
+ would not place (``usd_block_ok``) or whose files are not all there, anything but regular files, a file the loader
37
+ would never write back (a name ``member_path`` cannot read, a dot-file, a ``.part``), a GLB file (``model.glb``,
38
+ ``model_hd.glb``, ``articulation.json`` in an id folder) no served block names. ``--drop-orphans`` leaves out the
39
+ folders of ids no entry carries (never served; listed in the summary either way). ``--sources``: the run's
40
+ ``library-media/`` listing the tree was served from (``{"run", "objects": {name: {size, sha256?}}}``), refused when a
41
+ name already uses the stamp or a stamp in it is not older (the loader serves only the newest), and recorded in
42
+ pack.json with the stamps and the adds folded, so the upload can refuse a run that changed since (an add uploaded after
43
+ the pack would be left aside). pack.json also records the Python that packed it. Deterministic: the same tree and
44
+ stamp give the same bytes β€” members sorted by path after the first, pax headers with mode 0644, owner 0, no user or
45
+ group names, mtime the stamp's own time (a fixed value, and nginx's ETag still changes from one stamp to the next).
46
+ Each archive is written as ``<dir>/<kind>.tar.part-NNN`` parts of at most ``--part-size`` bytes (default 1,900 MiB:
47
+ under GitHub's 2 GiB release asset cap) with ``pack.json`` (names, sizes, sha256 per archive and part, counts) and
48
+ ``SHA256SUMS``; ``--hash-only`` writes no part, only ``pack.json`` with the sizes and sha256s (a determinism check).
49
+ ``--out`` must not exist; a failure removes it.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import argparse
55
+ import calendar
56
+ import hashlib
57
+ import json
58
+ import os
59
+ import platform
60
+ import re
61
+ import shutil
62
+ import stat
63
+ import sys
64
+ import tarfile
65
+ import time
66
+ from concurrent.futures import ProcessPoolExecutor
67
+ from pathlib import Path
68
+ from typing import BinaryIO, Optional
69
+
70
+ from . import library_media as lm
71
+
72
+ BUFSIZE = 1 << 20
73
+ MISSING_SHOWN = 20
74
+ PART_SIZE = 1900 << 20 # 1,992,294,400 B: every part under GitHub's 2 GiB release asset cap
75
+ STAMP = re.compile(r"^\d{8}T\d{4}Z$")
76
+ KINDS = ("main", "usd", "usdhd", "hd")
77
+ SUFFIX = {"main": lm.USD_ONLY_SUFFIX, "usd": lm.USD_SUFFIX, "usdhd": lm.USD_HD_SUFFIX, "hd": lm.HD_NOGLB_SUFFIX}
78
+ PART = {"main": "noglb.tar", "usd": "usd.tar", "usdhd": "usdhd.tar", "hd": "hd-noglb.tar"}
79
+ # A GLB tier's file names in an id folder: a USD-only stamp carries none (one no served block names is refused).
80
+ GLB_FILES = {"model.glb", "model_hd.glb", "articulation.json"}
81
+ # A stamp's main archive, either kind, in a run's listing (``--sources``).
82
+ MAIN_ARCHIVE = re.compile(r"^media-(\d{8}T\d{4}Z)(\.noglb)?\.tar$")
83
+ ADD_OBJECT = re.compile(r"^media-\d{8}T\d{4}Z\.usd(hd)?-add-[1-9][0-9]{0,2}\.tar$")
84
+
85
+
86
+ class Counted:
87
+ """A binary stream that hashes (sha256) and counts every byte read from or written to ``raw``."""
88
+
89
+ def __init__(self, raw):
90
+ self.raw, self.sha, self.size = raw, hashlib.sha256(), 0
91
+
92
+ def read(self, n: int = -1) -> bytes:
93
+ b = self.raw.read(n) if n is not None and n >= 0 else self.raw.read()
94
+ self.sha.update(b)
95
+ self.size += len(b)
96
+ return b
97
+
98
+ def write(self, b) -> int:
99
+ self.raw.write(b)
100
+ self.sha.update(b)
101
+ self.size += len(b)
102
+ return len(b)
103
+
104
+ def flush(self) -> None:
105
+ if hasattr(self.raw, "flush"):
106
+ self.raw.flush()
107
+
108
+ def drain(self) -> None:
109
+ """Read the rest (a tar's end blocks and record padding), so ``size`` and ``sha`` cover the whole source."""
110
+ for _ in iter(lambda: self.read(BUFSIZE), b""):
111
+ pass
112
+
113
+ def facts(self) -> dict:
114
+ return {"bytes": self.size, "sha256": self.sha.hexdigest()}
115
+
116
+
117
+ def split_hd(src: BinaryIO, out: BinaryIO, manifest: dict) -> dict:
118
+ """Write to ``out`` the ``.hd-noglb.tar`` of the ``.hd.tar`` read from ``src`` (once, in order) for ``manifest``.
119
+ Returns the summary (the module doc)."""
120
+ keep = lm.hd_files(manifest, models=False)
121
+ models = lm.hd_files(manifest)
122
+ reader, writer = Counted(src), Counted(out)
123
+ kept = {"spin_hd": [0, 0], "clip_hd": [0, 0]}
124
+ left = {"model_hd": [0, 0], "unlisted": [0, 0], "directory": [0, 0], "other": [0, 0]}
125
+ seen: set[tuple[str, str]] = set()
126
+ duplicates = 0
127
+ with tarfile.open(fileobj=reader, mode="r|", bufsize=BUFSIZE) as tin, \
128
+ tarfile.open(fileobj=writer, mode="w|", bufsize=BUFSIZE) as tout:
129
+ for m in tin:
130
+ where = lm.member_path(m.name)
131
+ if lm.member_listed(m, keep):
132
+ with tin.extractfile(m) as data:
133
+ tout.addfile(m, data)
134
+ kind = "spin_hd" if where[1].startswith("spin_hd_") else "clip_hd"
135
+ kept[kind][0] += 1
136
+ kept[kind][1] += m.size
137
+ duplicates += where in seen
138
+ seen.add(where)
139
+ continue
140
+ if m.isdir():
141
+ kind = "directory"
142
+ elif not m.isfile():
143
+ kind = "other"
144
+ elif where is not None and where[1] in models.get(where[0], ()):
145
+ kind = "model_hd"
146
+ else:
147
+ kind = "unlisted"
148
+ left[kind][0] += 1
149
+ left[kind][1] += m.size if m.isfile() else 0
150
+ reader.drain()
151
+ missing = sorted(f"{i}/{n}" for i, names in keep.items() for n in names if (i, n) not in seen)
152
+ listed = sum(len(names) for names in keep.values())
153
+ return {
154
+ "source": reader.facts(),
155
+ "output": {**writer.facts(), "members": sum(n for n, _ in kept.values())},
156
+ "kept": {k: {"members": n, "bytes": b} for k, (n, b) in kept.items()},
157
+ "left_out": {k: {"members": n, "bytes": b} for k, (n, b) in left.items()},
158
+ "entries": len(keep),
159
+ "entries_with_models": sum(1 for i, names in models.items() if names - keep.get(i, set())),
160
+ "listed": listed,
161
+ "listed_missing": len(missing),
162
+ "missing_sample": missing[:MISSING_SHOWN],
163
+ "duplicates": duplicates,
164
+ }
165
+
166
+
167
+ def read_manifest(path: Path) -> tuple[dict, str]:
168
+ """The manifest at ``path`` (a manifest.json, or a media-<stamp>.tar whose manifest.json member is read as a
169
+ stream) and the sha256 of its bytes. Not one the Explorer reads (version 1, assets{}): SystemExit."""
170
+ raw = None
171
+ if path.suffix == ".tar":
172
+ with open(path, "rb") as fh, tarfile.open(fileobj=fh, mode="r|", bufsize=BUFSIZE) as tf:
173
+ for m in tf:
174
+ if m.isfile() and m.name.removeprefix("./") == "manifest.json":
175
+ with tf.extractfile(m) as data:
176
+ raw = data.read()
177
+ break
178
+ if raw is None:
179
+ raise SystemExit(f"{path}: no manifest.json member")
180
+ else:
181
+ raw = path.read_bytes()
182
+ try:
183
+ doc = json.loads(raw)
184
+ except ValueError as exc:
185
+ raise SystemExit(f"{path}: manifest is not JSON ({exc})") from None
186
+ if not isinstance(doc, dict) or doc.get("version") != 1 or not isinstance(doc.get("assets"), dict):
187
+ raise SystemExit(f"{path}: not a library media manifest (version 1, assets{{}})")
188
+ return doc, hashlib.sha256(raw).hexdigest()
189
+
190
+
191
+ def hd_noglb(a: argparse.Namespace) -> int:
192
+ manifest, manifest_sha = read_manifest(a.manifest)
193
+ out_path = None if a.out == "-" else Path(a.out)
194
+ src = sys.stdin.buffer if a.src == "-" else open(a.src, "rb")
195
+ try:
196
+ out = sys.stdout.buffer if out_path is None else open(out_path, "xb") # never written over
197
+ except BaseException:
198
+ if src is not sys.stdin.buffer:
199
+ src.close()
200
+ raise
201
+ try:
202
+ summary = split_hd(src, out, manifest)
203
+ except BaseException:
204
+ if out_path is not None:
205
+ out.close()
206
+ out_path.unlink(missing_ok=True)
207
+ raise
208
+ finally:
209
+ if src is not sys.stdin.buffer:
210
+ src.close()
211
+ if out_path is not None:
212
+ out.close()
213
+ else:
214
+ out.flush()
215
+ summary = {"manifest_sha256": manifest_sha, "manifest_entries": len(manifest["assets"]), **summary}
216
+ text = json.dumps(summary, indent=1)
217
+ if a.summary:
218
+ a.summary.write_text(text + "\n")
219
+ else:
220
+ print(text, file=sys.stderr)
221
+ if a.require_complete and summary["listed_missing"]:
222
+ print(f"{summary['listed_missing']} listed HD files are not in the source "
223
+ f"(e.g. {', '.join(summary['missing_sample'][:3])})", file=sys.stderr)
224
+ return 2
225
+ return 0
226
+
227
+
228
+ # --- consolidate: a served tree as ONE USD-only stamp (the module doc) ------------------------------------------------
229
+
230
+ class Parts:
231
+ """A write-only stream into ``<folder>/<name>.part-NNN`` files of at most ``limit`` bytes each (``folder`` None:
232
+ nothing written, only hashed), hashing (sha256) the whole stream and each part."""
233
+
234
+ def __init__(self, folder: Optional[Path], name: str, limit: int):
235
+ self.folder, self.name, self.limit = folder, name, limit
236
+ self.sha, self.size = hashlib.sha256(), 0
237
+ self.parts: list[dict] = []
238
+ self.fh = None
239
+
240
+ def write(self, b) -> int:
241
+ mv = memoryview(b).cast("B")
242
+ n = len(mv)
243
+ self.sha.update(mv)
244
+ self.size += n
245
+ while len(mv):
246
+ if not self.parts or self.parts[-1]["size"] >= self.limit:
247
+ self._next()
248
+ part = self.parts[-1]
249
+ take = mv[: self.limit - part["size"]]
250
+ part["sha"].update(take)
251
+ part["size"] += len(take)
252
+ if self.fh is not None:
253
+ self.fh.write(take)
254
+ mv = mv[len(take):]
255
+ return n
256
+
257
+ def _next(self) -> None:
258
+ if self.fh is not None:
259
+ self.fh.close()
260
+ name = f"{self.name}.part-{len(self.parts):03d}"
261
+ self.parts.append({"name": name, "size": 0, "sha": hashlib.sha256()})
262
+ self.fh = None if self.folder is None else open(self.folder / name, "xb") # never over a file
263
+
264
+ def flush(self) -> None:
265
+ if self.fh is not None:
266
+ self.fh.flush()
267
+
268
+ def close(self) -> dict:
269
+ if self.fh is not None:
270
+ self.fh.close()
271
+ self.fh = None
272
+ return {"size": self.size, "sha256": self.sha.hexdigest(), "parts": [p["name"] for p in self.parts],
273
+ "part_sha256": [p["sha"].hexdigest() for p in self.parts],
274
+ "part_sizes": [p["size"] for p in self.parts]}
275
+
276
+
277
+ def member_info(name: str, size: int, mtime: int) -> tarfile.TarInfo:
278
+ """A regular-file header with nothing of the packing host in it: mode 0644, owner 0, no names, ``mtime``."""
279
+ ti = tarfile.TarInfo(name)
280
+ ti.size, ti.mtime, ti.mode, ti.type = size, mtime, 0o644, tarfile.REGTYPE
281
+ ti.uid = ti.gid = 0
282
+ ti.uname = ti.gname = ""
283
+ return ti
284
+
285
+
286
+ def plan_consolidate(tree: Path, drop_orphans: bool = False) -> dict:
287
+ """Which file of the served ``tree`` goes in which archive (the module doc), the overlay, and the facts; every
288
+ refusal at once (SystemExit), before anything is written."""
289
+ try:
290
+ raw = (tree / "manifest.json").read_bytes()
291
+ doc = json.loads(raw)
292
+ except (OSError, ValueError) as exc:
293
+ raise SystemExit(f"{tree}: no readable manifest.json ({exc})") from None
294
+ if not isinstance(doc, dict) or doc.get("version") != 1 or not isinstance(doc.get("assets"), dict):
295
+ raise SystemExit(f"{tree}: not a library media manifest (version 1, assets{{}})")
296
+ problems: list[str] = []
297
+ usd1: dict[str, set[str]] = {}
298
+ usdhd: dict[str, set[str]] = {}
299
+ overlay: dict[str, dict] = {}
300
+ ids: set[str] = set()
301
+ named: dict[str, set[str]] = {} # every string the entries of an id give (their file names)
302
+ full = 0
303
+ for key, e in doc["assets"].items():
304
+ if not isinstance(e, dict) or not isinstance(e.get("id"), str) or not lm.SAFE_ID.match(e["id"]):
305
+ problems.append(f"{str(key)[-120:]}: no safe id")
306
+ continue
307
+ i = e["id"]
308
+ ids.add(i)
309
+ lm._strings(e, named.setdefault(i, set()))
310
+ files = e.get("files") if isinstance(e.get("files"), dict) else {}
311
+ if e.get("hd") is not None or any(files.get(r) for r in lm.GLB_ROLES):
312
+ problems.append(f"{i}: lists a GLB or a model_hd.glb (an entry kept on its GLB is never USD-only)")
313
+ continue
314
+ if "usd" not in e:
315
+ continue
316
+ if not lm.usd_block_ok(e["usd"]):
317
+ problems.append(f"{i}: a usd block the loader would not place (usd_block_ok)")
318
+ continue
319
+ overlay[key] = {"id": i, "usd": e["usd"]}
320
+ usd1.setdefault(i, set()).update(lm.usd_tier1_files(e["usd"]))
321
+ hd_names = lm.usd_hd_files(e["usd"])
322
+ usdhd.setdefault(i, set()).update(hd_names)
323
+ full += bool(hd_names)
324
+ hd = lm.hd_files(doc, models=False)
325
+ groups: dict[str, list[tuple[str, int]]] = {k: [] for k in KINDS}
326
+ found: set[tuple[str, str]] = set()
327
+ orphans: dict[str, list[int]] = {}
328
+ top: list[str] = []
329
+ for dirpath, dirnames, filenames in os.walk(tree):
330
+ dirnames.sort()
331
+ for d in dirnames:
332
+ if os.path.islink(Path(dirpath) / d):
333
+ problems.append(f"{(Path(dirpath) / d).relative_to(tree).as_posix()}: not a regular folder")
334
+ for fn in sorted(filenames):
335
+ p = Path(dirpath) / fn
336
+ rel = p.relative_to(tree).as_posix()
337
+ st = os.lstat(p)
338
+ if not stat.S_ISREG(st.st_mode):
339
+ problems.append(f"{rel}: not a regular file")
340
+ continue
341
+ if any(seg.startswith(".") for seg in rel.split("/")):
342
+ problems.append(f"{rel}: a dot-file (a .part left over?)")
343
+ continue
344
+ if rel == "manifest.json":
345
+ continue
346
+ where = lm.member_path(rel)
347
+ if where is None:
348
+ top.append(rel) # unpack writes it back; nothing names it
349
+ groups["main"].append((rel, st.st_size))
350
+ continue
351
+ i, path = where
352
+ if i not in ids:
353
+ orphans.setdefault(i, [0, 0])
354
+ orphans[i][0] += 1
355
+ orphans[i][1] += st.st_size
356
+ if drop_orphans:
357
+ continue
358
+ if path in GLB_FILES and path not in named.get(i, ()):
359
+ problems.append(f"{rel}: a GLB file no served block names (a USD-only stamp carries none"
360
+ + ("; an orphan folder: --drop-orphans" if i not in ids else
361
+ "; a tree served with LIBRARY_MEDIA_GLB_SKIP=off?") + ")")
362
+ continue
363
+ found.add(where)
364
+ kind = ("usd" if path in usd1.get(i, ()) else "usdhd" if path in usdhd.get(i, ()) else
365
+ "hd" if path in hd.get(i, ()) else "main")
366
+ groups[kind].append((rel, st.st_size))
367
+ for kind, listed in (("usd", usd1), ("usdhd", usdhd)):
368
+ gone = sorted(f"{i}/{n}" for i, names in listed.items() for n in names if (i, n) not in found)
369
+ if gone:
370
+ problems.append(f"{len(gone)} files a served block names for the {kind} archive are not there "
371
+ f"(e.g. {', '.join(gone[:3])})")
372
+ if problems:
373
+ raise SystemExit(f"{tree}: not packed ({len(problems)} problems): " + "; ".join(problems[:MISSING_SHOWN])
374
+ + (f"; +{len(problems) - MISSING_SHOWN} more" if len(problems) > MISSING_SHOWN else ""))
375
+ hd_missing = sorted(f"{i}/{n}" for i, names in hd.items() for n in names if (i, n) not in found)
376
+ for kind in KINDS:
377
+ groups[kind].sort()
378
+ overlay_raw = json.dumps({"version": 1, "profile": lm.USD_PROFILE, "assets": overlay},
379
+ separators=(",", ":")).encode()
380
+ return {
381
+ "manifest": raw, "overlay": overlay_raw, "groups": groups,
382
+ "facts": {
383
+ "entries": len(doc["assets"]), "usd_blocks": len(overlay), "usd_full": full,
384
+ "hd_listed": sum(len(names) for names in hd.values()), "hd_listed_missing": len(hd_missing),
385
+ "hd_missing_sample": hd_missing[:MISSING_SHOWN], "top_level_files": top,
386
+ "orphans": {"ids": len(orphans), "files": sum(n for n, _ in orphans.values()),
387
+ "bytes": sum(b for _, b in orphans.values()), "dropped": drop_orphans,
388
+ "sample": sorted(orphans)[:MISSING_SHOWN]},
389
+ "tree": {"files": 1 + sum(len(g) for g in groups.values()),
390
+ "bytes": len(raw) + sum(n for g in groups.values() for _, n in g)},
391
+ },
392
+ }
393
+
394
+
395
+ def write_archive(tree: Path, kind: str, members: list[tuple[str, int]], first: Optional[tuple[str, bytes]],
396
+ out: Optional[Path], name: str, part_size: int, mtime: int) -> dict:
397
+ """One archive of the stamp: ``first`` (name, bytes) then ``members`` (paths below ``tree``, with the size each
398
+ had when planned: a file that changed since fails it), as ``out``/<part name>.part-NNN (``out`` None: hashed
399
+ only). Its facts for pack.json."""
400
+ sink = Parts(out, PART[kind], part_size)
401
+ with tarfile.open(fileobj=sink, mode="w|", format=tarfile.PAX_FORMAT, bufsize=BUFSIZE) as tf:
402
+ if first is not None:
403
+ tf.addfile(member_info(first[0], len(first[1]), mtime), _Bytes(first[1]))
404
+ for rel, size in members:
405
+ with open(tree / rel, "rb") as fh:
406
+ if os.fstat(fh.fileno()).st_size != size:
407
+ raise SystemExit(f"{tree / rel}: changed while packed ({size} B planned)")
408
+ tf.addfile(member_info(rel, size, mtime), fh)
409
+ facts = sink.close()
410
+ return {"name": name, **facts, "members": len(members) + (first is not None),
411
+ "files_bytes": sum(n for _, n in members) + (len(first[1]) if first else 0)}
412
+
413
+
414
+ class _Bytes:
415
+ """A minimal reader over ``data`` for tarfile.addfile."""
416
+
417
+ def __init__(self, data: bytes):
418
+ self.data, self.pos = data, 0
419
+
420
+ def read(self, n: int = -1) -> bytes:
421
+ end = len(self.data) if n is None or n < 0 else self.pos + n
422
+ b = self.data[self.pos:end]
423
+ self.pos += len(b)
424
+ return b
425
+
426
+
427
+ def read_sources(path: Path, stamp: str) -> dict:
428
+ """``--sources``: the run's ``library-media/`` listing when the served tree was made, ``{"run": <run id>,
429
+ "objects": {<name>: {"size": <bytes>, "sha256": <hex, optional>}}}`` (the folded stamp and adds among them).
430
+ Refused (SystemExit) when a name already uses ``stamp`` or a stamp in it is not older than ``stamp`` (the loader
431
+ serves only the newest). Returned as recorded in pack.json, with the main archives' stamps and the adds folded."""
432
+ try:
433
+ doc = json.loads(Path(path).read_text())
434
+ objects = doc["objects"]
435
+ if not isinstance(objects, dict) or not all(isinstance(n, str) and isinstance(o, dict)
436
+ and isinstance(o.get("size"), int) for n, o in objects.items()):
437
+ raise ValueError("objects: {name: {size, sha256?}}")
438
+ except (OSError, ValueError, KeyError, TypeError) as exc:
439
+ raise SystemExit(f"--sources {path}: unreadable ({exc})") from None
440
+ used = sorted(n for n in objects if n.startswith(f"media-{stamp}."))
441
+ if used:
442
+ raise SystemExit(f"--stamp {stamp} is already used in the run: {', '.join(used[:MISSING_SHOWN])}")
443
+ stamps = sorted({m.group(1) for n in objects if (m := MAIN_ARCHIVE.match(n))})
444
+ if stamps and stamps[-1] >= stamp:
445
+ raise SystemExit(f"--stamp {stamp} is not newer than the run's newest stamp {stamps[-1]}: never served")
446
+ adds = sorted(n for n in objects if ADD_OBJECT.match(n))
447
+ return {"run": doc.get("run"), "stamps": stamps, "adds": adds,
448
+ "objects": {n: objects[n] for n in sorted(objects)}}
449
+
450
+
451
+ def consolidate(a: argparse.Namespace) -> int:
452
+ if not STAMP.match(a.stamp):
453
+ raise SystemExit(f"--stamp {a.stamp!r} is not YYYYMMDDTHHMMZ")
454
+ if a.part_size < tarfile.BLOCKSIZE:
455
+ raise SystemExit(f"--part-size under {tarfile.BLOCKSIZE} B")
456
+ sources = read_sources(a.sources, a.stamp) if a.sources else None
457
+ if sources is None:
458
+ print("library_media_pack: no --sources β€” pack.json records no run listing, so the upload cannot check that "
459
+ "every add was folded", file=sys.stderr)
460
+ plan = plan_consolidate(a.tree, a.drop_orphans)
461
+ mtime = calendar.timegm(time.strptime(a.stamp, "%Y%m%dT%H%MZ"))
462
+ a.out.mkdir(parents=True) # never into an existing folder
463
+ try:
464
+ firsts = {"main": ("manifest.json", plan["manifest"]), "usd": (lm.USD_OVERLAY, plan["overlay"])}
465
+ args = [(a.tree, kind, plan["groups"][kind], firsts.get(kind), None if a.hash_only else a.out,
466
+ f"media-{a.stamp}{SUFFIX[kind]}", a.part_size, mtime) for kind in KINDS]
467
+ if a.jobs <= 1:
468
+ archives = {kind: write_archive(*arg) for kind, arg in zip(KINDS, args)}
469
+ else:
470
+ with ProcessPoolExecutor(min(a.jobs, len(KINDS))) as ex:
471
+ archives = dict(zip(KINDS, [j.result() for j in [ex.submit(write_archive, *arg) for arg in args]]))
472
+ pack = {
473
+ "kind": "usd-only stamp", "stamp": a.stamp, "run": a.run, "profile": lm.USD_PROFILE,
474
+ **archives,
475
+ "manifest_sha256": hashlib.sha256(plan["manifest"]).hexdigest(),
476
+ "overlay_sha256": hashlib.sha256(plan["overlay"]).hexdigest(),
477
+ "mtime": mtime, "part_size": a.part_size, "hash_only": a.hash_only,
478
+ "loader": "pipeline/library_media.py with USD_ONLY (a USD-only stamp, under GLB off only)",
479
+ "python": platform.python_version(), "sources": sources,
480
+ **plan["facts"],
481
+ }
482
+ (a.out / "pack.json").write_text(json.dumps(pack, indent=1) + "\n")
483
+ if not a.hash_only:
484
+ sums = sorted((p, s) for k in KINDS for p, s in zip(archives[k]["parts"], archives[k]["part_sha256"]))
485
+ (a.out / "SHA256SUMS").write_text("".join(f"{s} {p}\n" for p, s in sums))
486
+ except BaseException:
487
+ shutil.rmtree(a.out, ignore_errors=True)
488
+ raise
489
+ print(json.dumps({k: (pack[k] if k not in KINDS else {f: pack[k][f] for f in ("name", "size", "sha256", "members")})
490
+ for k in ("stamp", *KINDS, "entries", "usd_blocks", "usd_full", "hd_listed",
491
+ "hd_listed_missing", "orphans", "tree")}, indent=1))
492
+ return 0
493
+
494
+
495
+ def main(argv=None) -> int:
496
+ ap = argparse.ArgumentParser(prog="python -m pipeline.library_media_pack",
497
+ description="Pack library media archives (see the module doc).")
498
+ sub = ap.add_subparsers(dest="cmd", required=True)
499
+ p = sub.add_parser("hd-noglb", help="a stamp's .hd-noglb.tar from its .hd.tar and manifest")
500
+ p.add_argument("--manifest", type=Path, required=True, help="manifest.json, or the stamp's media-<stamp>.tar")
501
+ p.add_argument("--in", dest="src", default="-", help="the .hd.tar (default: stdin)")
502
+ p.add_argument("--out", default="-", help="the .hd-noglb.tar to write, never over a file (default: stdout)")
503
+ p.add_argument("--summary", type=Path, help="write the summary JSON here (default: stderr)")
504
+ p.add_argument("--require-complete", action="store_true",
505
+ help="exit 2 when a file the manifest lists is not in the source")
506
+ c = sub.add_parser("consolidate", help="a served tree (GLB off) as ONE USD-only stamp")
507
+ c.add_argument("--tree", type=Path, required=True, help="the served tree (.build/library_media/usdsearch-dev)")
508
+ c.add_argument("--stamp", required=True, help="the new stamp, YYYYMMDDTHHMMZ (UTC)")
509
+ c.add_argument("--out", type=Path, required=True, help="a folder that does not exist yet")
510
+ c.add_argument("--run", default=None, help="the index run it is for (recorded in pack.json)")
511
+ c.add_argument("--part-size", type=int, default=PART_SIZE)
512
+ c.add_argument("--jobs", type=int, default=4, help="archives written at once (default 4)")
513
+ c.add_argument("--hash-only", action="store_true", help="no part written: pack.json with sizes and sha256s")
514
+ c.add_argument("--drop-orphans", action="store_true", help="leave out the folders of ids no entry carries")
515
+ c.add_argument("--sources", type=Path, default=None,
516
+ help="the run's library-media listing the tree was served from (JSON; recorded in pack.json)")
517
+ a = ap.parse_args(argv)
518
+ return hd_noglb(a) if a.cmd == "hd-noglb" else consolidate(a)
519
+
520
+
521
+ if __name__ == "__main__":
522
+ sys.exit(main())
pipeline/package_download/app.py CHANGED
@@ -22,7 +22,7 @@ from fastapi import FastAPI, Query, Request
22
  from fastapi.responses import FileResponse, JSONResponse, Response, StreamingResponse
23
  from starlette.background import BackgroundTask
24
 
25
- from . import bundle, telemetry
26
  from .catalog import Catalog
27
  from .settings import Settings
28
  from .sizes import PackageSizes
@@ -336,6 +336,14 @@ def create_app(
336
  if manifest_only:
337
  return JSONResponse(public(bundle.preview(asset_url, package, listed)))
338
 
 
 
 
 
 
 
 
 
339
  if settings.max_bundle_bytes > 0 and total > settings.max_bundle_bytes:
340
  raise _HTTPError(
341
  413, f"Download bundle is too large ({total} bytes; limit is {settings.max_bundle_bytes})."
@@ -360,6 +368,7 @@ def create_app(
360
  read_ahead_bytes=settings.read_ahead_bytes,
361
  connections=connections,
362
  retry=retry,
 
363
  )
364
  except bundle.RootMissing as exc:
365
  raise _HTTPError(404, f"Asset '{to_public_url(asset_url)}' could not be retrieved: {exc}")
 
22
  from fastapi.responses import FileResponse, JSONResponse, Response, StreamingResponse
23
  from starlette.background import BackgroundTask
24
 
25
+ from . import bundle, dedupe, telemetry
26
  from .catalog import Catalog
27
  from .settings import Settings
28
  from .sizes import PackageSizes
 
336
  if manifest_only:
337
  return JSONResponse(public(bundle.preview(asset_url, package, listed)))
338
 
339
+ shared: dict = {}
340
+ if settings.dedupe:
341
+ try:
342
+ shared = await dedupe.plan(package, listed, source.read)
343
+ except Exception as exc: # noqa: BLE001 β€” best effort: every file is fetched on its own
344
+ logger.warning("shared fetches for %s skipped: %s", package.root_address, exc)
345
+ stats["n_shared"] = sum(len(s.copies) for s in shared.values())
346
+
347
  if settings.max_bundle_bytes > 0 and total > settings.max_bundle_bytes:
348
  raise _HTTPError(
349
  413, f"Download bundle is too large ({total} bytes; limit is {settings.max_bundle_bytes})."
 
368
  read_ahead_bytes=settings.read_ahead_bytes,
369
  connections=connections,
370
  retry=retry,
371
+ shared=shared,
372
  )
373
  except bundle.RootMissing as exc:
374
  raise _HTTPError(404, f"Asset '{to_public_url(asset_url)}' could not be retrieved: {exc}")
pipeline/package_download/bundle.py CHANGED
@@ -12,6 +12,7 @@ from __future__ import annotations
12
 
13
  import asyncio
14
  import contextlib
 
15
  import json
16
  import logging
17
  import math
@@ -25,7 +26,7 @@ import zipfile
25
  from collections import deque
26
  from concurrent.futures import ThreadPoolExecutor
27
  from dataclasses import dataclass, field
28
- from typing import AsyncIterator, Awaitable, Callable, Deque, Dict, List, Optional, Tuple, TypeVar
29
 
30
  from .catalog import Package
31
 
@@ -313,6 +314,7 @@ async def build_zip(
313
  read_ahead_bytes: int = 0,
314
  connections: Optional[asyncio.Semaphore] = None,
315
  retry: RetryPolicy = NO_RETRY,
 
316
  ) -> BundleResult:
317
  """Fetch every listed file (bounded concurrency) and write it into a ZIP at ``zip_path``.
318
 
@@ -330,6 +332,10 @@ async def build_zip(
330
  backoff β€” a ``connections`` slot (reads in flight across every bundle). A transient
331
  failure is retried per ``retry`` from byte 0: nothing reaches the archive before a
332
  file is whole. Cancelled, it stops every read and the writer before it returns.
 
 
 
 
333
  """
334
  result = BundleResult()
335
  semaphore = asyncio.Semaphore(max(concurrency, 1))
@@ -339,15 +345,23 @@ async def build_zip(
339
  in_ram = max(min(spool_bytes, read_ahead_bytes) if read_ahead_bytes > 0 else spool_bytes, 1)
340
  mode = compression.strip().lower()
341
  started = time.perf_counter()
 
 
 
342
 
343
  async def download(entry: FileEntry, spool) -> int:
344
  async def attempt() -> int:
345
  spool.seek(0)
346
  spool.truncate() # a retry starts over: nothing of a failed try is kept
 
347
  async with limited(connections):
348
  async with contextlib.aclosing(read(package, entry.path)) as chunks:
349
  async for chunk in chunks:
350
  spool.write(chunk)
 
 
 
 
351
  return spool.tell()
352
 
353
  return await retrying(attempt, retry, package.address(entry.path))
@@ -372,37 +386,63 @@ async def build_zip(
372
  spool.seek(0)
373
  return entry, spool, size, None, cost
374
 
375
- tasks = [asyncio.create_task(fetch(f)) for f in files]
376
  stop = threading.Event() # tells the writer thread to let go of the member it is copying
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
  try:
378
  with ThreadPoolExecutor(max_workers=1, thread_name_prefix="package-zip") as writer:
379
  with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED, allowZip64=True) as zf:
380
- for completed in asyncio.as_completed(tasks):
381
- entry, spool, size, exc, cost = await completed
382
- address = package.address(entry.path)
383
- if spool is None:
384
- if isinstance(exc, SourceError) and exc.kind in ("denied", "busy", "unavailable"):
385
- raise exc # the backend itself is down; a ZIP of holes helps nobody
386
- if entry.path == package.asset_path:
387
- raise RootMissing(str(exc) or "not found")
388
- result.errors[address] = str(exc) or type(exc).__name__
389
- continue
390
- try:
391
- with spool:
392
- result.total_bytes += size
393
- if max_bytes > 0 and result.total_bytes > max_bytes:
394
- raise BundleTooLarge(f"Download bundle exceeded the size limit of {max_bytes} bytes.")
395
- arc = safe_arcname(package, entry.path)
396
- if arc is None:
397
- result.errors[address] = "path outside the package"
398
- continue
399
- info = zipfile.ZipInfo(arc, date_time=time.localtime()[:6])
400
- info.compress_type = _compress_type(arc, mode, stored_extensions)
401
- info.external_attr = 0o644 << 16
402
- await _write_member(writer, stop, zf, info, spool, size)
403
- result.files.append({"url": address, "path": arc, "size": size})
404
- finally:
405
- budget.release(cost)
406
  finally:
407
  for task in tasks:
408
  task.cancel()
 
12
 
13
  import asyncio
14
  import contextlib
15
+ import hashlib
16
  import json
17
  import logging
18
  import math
 
26
  from collections import deque
27
  from concurrent.futures import ThreadPoolExecutor
28
  from dataclasses import dataclass, field
29
+ from typing import Any, AsyncIterator, Awaitable, Callable, Deque, Dict, List, Optional, Tuple, TypeVar
30
 
31
  from .catalog import Package
32
 
 
314
  read_ahead_bytes: int = 0,
315
  connections: Optional[asyncio.Semaphore] = None,
316
  retry: RetryPolicy = NO_RETRY,
317
+ shared: Optional[Dict[str, Any]] = None,
318
  ) -> BundleResult:
319
  """Fetch every listed file (bounded concurrency) and write it into a ZIP at ``zip_path``.
320
 
 
332
  backoff β€” a ``connections`` slot (reads in flight across every bundle). A transient
333
  failure is retried per ``retry`` from byte 0: nothing reaches the archive before a
334
  file is whole. Cancelled, it stops every read and the writer before it returns.
335
+
336
+ ``shared`` (dedupe.plan: path -> Shared(sha256, copies)) fetches a file once for every
337
+ path that holds the same bytes: its copies are written from its spool, after its
338
+ sha256 is checked. A fetch that fails or does not match fetches each copy on its own.
339
  """
340
  result = BundleResult()
341
  semaphore = asyncio.Semaphore(max(concurrency, 1))
 
345
  in_ram = max(min(spool_bytes, read_ahead_bytes) if read_ahead_bytes > 0 else spool_bytes, 1)
346
  mode = compression.strip().lower()
347
  started = time.perf_counter()
348
+ shared = shared or {}
349
+ copied = {c.path for s in shared.values() for c in s.copies}
350
+ digests: Dict[str, str] = {} # sha256 of a shared fetch, checked before its copies are written
351
 
352
  async def download(entry: FileEntry, spool) -> int:
353
  async def attempt() -> int:
354
  spool.seek(0)
355
  spool.truncate() # a retry starts over: nothing of a failed try is kept
356
+ digest = hashlib.sha256() if entry.path in shared else None
357
  async with limited(connections):
358
  async with contextlib.aclosing(read(package, entry.path)) as chunks:
359
  async for chunk in chunks:
360
  spool.write(chunk)
361
+ if digest is not None:
362
+ digest.update(chunk)
363
+ if digest is not None:
364
+ digests[entry.path] = digest.hexdigest()
365
  return spool.tell()
366
 
367
  return await retrying(attempt, retry, package.address(entry.path))
 
386
  spool.seek(0)
387
  return entry, spool, size, None, cost
388
 
389
+ tasks: List[asyncio.Task] = []
390
  stop = threading.Event() # tells the writer thread to let go of the member it is copying
391
+
392
+ async def write(writer, zf, entry: FileEntry, spool, size: int) -> None:
393
+ address = package.address(entry.path)
394
+ result.total_bytes += size
395
+ if max_bytes > 0 and result.total_bytes > max_bytes:
396
+ raise BundleTooLarge(f"Download bundle exceeded the size limit of {max_bytes} bytes.")
397
+ arc = safe_arcname(package, entry.path)
398
+ if arc is None:
399
+ result.errors[address] = "path outside the package"
400
+ return
401
+ info = zipfile.ZipInfo(arc, date_time=time.localtime()[:6])
402
+ info.compress_type = _compress_type(arc, mode, stored_extensions)
403
+ info.external_attr = 0o644 << 16
404
+ await _write_member(writer, stop, zf, info, spool, size)
405
+ result.files.append({"url": address, "path": arc, "size": size})
406
+
407
+ async def run(writer, zf, entries: List[FileEntry]) -> List[FileEntry]:
408
+ """Fetch and write ``entries``; returns the copies whose shared fetch failed or did not match."""
409
+ batch = [asyncio.create_task(fetch(f)) for f in entries]
410
+ tasks.extend(batch)
411
+ again: List[FileEntry] = []
412
+ for completed in asyncio.as_completed(batch):
413
+ entry, spool, size, exc, cost = await completed
414
+ group = shared.get(entry.path)
415
+ if spool is None:
416
+ if isinstance(exc, SourceError) and exc.kind in ("denied", "busy", "unavailable"):
417
+ raise exc # the backend itself is down; a ZIP of holes helps nobody
418
+ if entry.path == package.asset_path:
419
+ raise RootMissing(str(exc) or "not found")
420
+ result.errors[package.address(entry.path)] = str(exc) or type(exc).__name__
421
+ again.extend(group.copies if group else ())
422
+ continue
423
+ try:
424
+ with spool:
425
+ await write(writer, zf, entry, spool, size)
426
+ if group:
427
+ if digests.get(entry.path) != group.sha256:
428
+ logger.warning("%s does not match its BOM sha256: its copies are fetched on their own",
429
+ package.address(entry.path))
430
+ again.extend(group.copies)
431
+ continue
432
+ for copy in group.copies:
433
+ spool.seek(0)
434
+ await write(writer, zf, copy, spool, size)
435
+ finally:
436
+ budget.release(cost)
437
+ return again
438
+
439
  try:
440
  with ThreadPoolExecutor(max_workers=1, thread_name_prefix="package-zip") as writer:
441
  with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED, allowZip64=True) as zf:
442
+ again = await run(writer, zf, [f for f in files if f.path not in copied])
443
+ if again:
444
+ # Each on its own, as without a BOM; a copy is never itself shared.
445
+ await run(writer, zf, again)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
446
  finally:
447
  for task in tasks:
448
  task.cancel()
pipeline/package_download/dedupe.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Byte-identical files of a package, fetched from the source once.
2
+
3
+ SimReady Central product review 2026-10-14 (OMPE-116021, download speed): every package in
4
+ ``nvidia/simready-assets`` ships its textures twice, under ``simready_usd/Textures/`` (what the USD
5
+ references) and as byte-identical copies under ``simready_usd/source_assets/``: about half of a
6
+ package's bytes (``rack_composition_l09``: 238 + 238 of 599 MB; ``robotic_system_console_b01``:
7
+ 981 + 981 of 2,026 MB). The package's BOM (``.metadata/com.nvidia.simready.packaging.bom.json``,
8
+ every file with its sha256 and size) tells which.
9
+
10
+ The download keeps every file: the BOM lists the copies, so a package without them would fail its
11
+ own validation. What changes is how often the bytes are read: the files the BOM gives one sha256 and
12
+ one size, and the listing that same size, are fetched once and written into the archive under every
13
+ path that has them (``bundle.build_zip``, which checks the fetched bytes against the sha256 first and
14
+ fetches the other paths on their own when they differ). No BOM, a malformed one, or a file it does not
15
+ cover: fetched as before.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import logging
22
+ from dataclasses import dataclass, field
23
+ from typing import AsyncIterator, Callable, Dict, List, Tuple
24
+
25
+ from .bundle import FileEntry
26
+ from .catalog import Package
27
+
28
+ logger = logging.getLogger("package_download")
29
+
30
+ BOM_PATH = ".metadata/com.nvidia.simready.packaging.bom.json"
31
+ SOURCE_DIR = "source_assets"
32
+ MAX_BOM = 16 * 1024 * 1024
33
+
34
+ Reader = Callable[[Package, str], AsyncIterator[bytes]]
35
+
36
+
37
+ @dataclass
38
+ class Shared:
39
+ """One fetch for several paths: the BOM's sha256 of the bytes, and the other files that hold them."""
40
+
41
+ sha256: str
42
+ copies: List[FileEntry] = field(default_factory=list)
43
+
44
+
45
+ def bom_items(doc) -> Dict[str, Tuple[str, int]]:
46
+ """Package-relative path -> (sha256, size) from a BOM document; {} when it has none."""
47
+ out: Dict[str, Tuple[str, int]] = {}
48
+ items = doc.get("items") if isinstance(doc, dict) else None
49
+ for item in items if isinstance(items, list) else ():
50
+ if not isinstance(item, dict):
51
+ continue
52
+ path, digest, size = item.get("relative_path"), (item.get("hash") or {}).get("sha256"), item.get("size")
53
+ if isinstance(path, str) and isinstance(digest, str) and path and digest and isinstance(size, int):
54
+ out[path] = (digest.lower(), size)
55
+ return out
56
+
57
+
58
+ def under_source(rel: str) -> bool:
59
+ return SOURCE_DIR in rel.split("/")[:-1]
60
+
61
+
62
+ def groups(package: Package, listed: List[FileEntry], items: Dict[str, Tuple[str, int]]) -> Dict[str, Shared]:
63
+ """Path of the file fetched -> Shared, for every BOM sha256 that two or more listed files hold.
64
+
65
+ A file counts only when the listing's size equals the BOM's. The one fetched is the first outside
66
+ any ``source_assets/`` folder (what the USD references), else the first by path; the root asset is
67
+ always fetched on its own.
68
+ """
69
+ by_hash: Dict[Tuple[str, int], List[FileEntry]] = {}
70
+ for f in listed:
71
+ hit = items.get(package.relative(f.path))
72
+ if hit and hit[1] == f.size and f.path != package.asset_path:
73
+ by_hash.setdefault(hit, []).append(f)
74
+ out: Dict[str, Shared] = {}
75
+ for (digest, _), files in by_hash.items():
76
+ if len(files) < 2:
77
+ continue
78
+ files = sorted(files, key=lambda f: (under_source(package.relative(f.path)), f.path))
79
+ out[files[0].path] = Shared(digest, files[1:])
80
+ return out
81
+
82
+
83
+ async def _read_all(read: Reader, package: Package, path: str, limit: int) -> bytes:
84
+ data = bytearray()
85
+ async for chunk in read(package, path):
86
+ data += chunk
87
+ if len(data) > limit:
88
+ raise ValueError(f"{path} is over {limit} bytes")
89
+ return bytes(data)
90
+
91
+
92
+ async def plan(package: Package, listed: List[FileEntry], read: Reader) -> Dict[str, Shared]:
93
+ """The shared fetches of a package (one read: its BOM); {} without a usable BOM."""
94
+ bom = next((f for f in listed if package.relative(f.path) == BOM_PATH), None)
95
+ if bom is None or bom.size > MAX_BOM:
96
+ return {}
97
+ return groups(package, listed, bom_items(json.loads(await _read_all(read, package, bom.path, MAX_BOM))))
pipeline/package_download/settings.py CHANGED
@@ -67,6 +67,9 @@ class Settings:
67
  read_timeout: float = 300.0
68
  # Read timeout for the forwarded deepsearch-api request (it bundles, then answers).
69
  fallback_timeout: float = 300.0
 
 
 
70
 
71
  @classmethod
72
  def from_env(cls, env: Optional[Mapping[str, str]] = None) -> "Settings":
 
67
  read_timeout: float = 300.0
68
  # Read timeout for the forwarded deepsearch-api request (it bundles, then answers).
69
  fallback_timeout: float = 300.0
70
+ # Fetch the files the package's BOM gives one sha256 once, written under every path that
71
+ # has them (dedupe.py): about half the reads of a simready-assets download. Every file ships.
72
+ dedupe: bool = True
73
 
74
  @classmethod
75
  def from_env(cls, env: Optional[Mapping[str, str]] = None) -> "Settings":
pipeline/package_download/sizes.py CHANGED
@@ -75,6 +75,9 @@ class SizesSettings:
75
  relist_after: float = 6 * 3600.0
76
  # Outside .build/restore/, which the index restore empties at every start.
77
  cache_dir: Path = Path("/app/hf-space-pipeline/.build/package_sizes")
 
 
 
78
 
79
  @classmethod
80
  def from_env(cls, env: Optional[Mapping[str, str]] = None) -> "SizesSettings":
@@ -189,6 +192,7 @@ class PackageSizes:
189
  self._walked = False
190
  self._sums: Dict[Group, Sums] = {}
191
  self._refused: Dict[Group, float] = {} # group -> wall time it may be asked again
 
192
  self._next_hf = 0.0
193
  self._task: Optional[asyncio.Task] = None
194
  self._search: Optional[httpx.AsyncClient] = None
@@ -284,6 +288,9 @@ class PackageSizes:
284
  logger.info("package sizes: index not readable yet (%s); next try in %.0f s", exc, s.retry_interval)
285
  return s.retry_interval
286
  self._keys, self._walked = keys, True
 
 
 
287
  now = self._wall()
288
  self._refused = {g: t for g, t in self._refused.items() if t > now}
289
  counts = Counter(v[0] for v in keys.values() if v)
@@ -445,6 +452,30 @@ class PackageSizes:
445
  logger.info("package sizes: %d group(s) from %s", loaded, folder)
446
  return loaded
447
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
448
  def _write_cache(self, group: Group, sums: Sums) -> None:
449
  folder = self._groups_dir()
450
  try:
 
75
  relist_after: float = 6 * 3600.0
76
  # Outside .build/restore/, which the index restore empties at every start.
77
  cache_dir: Path = Path("/app/hf-space-pipeline/.build/package_sizes")
78
+ # The run's own sums (enumerate's listing, shipped by export): read once the index is readable, i.e. once the
79
+ # seed has downloaded the run. A run without one (before 2026-10-19) is listed from HF as before.
80
+ precomputed: Path = Path("/app/hf-space-pipeline/.build/restore/package_sizes.json")
81
 
82
  @classmethod
83
  def from_env(cls, env: Optional[Mapping[str, str]] = None) -> "SizesSettings":
 
192
  self._walked = False
193
  self._sums: Dict[Group, Sums] = {}
194
  self._refused: Dict[Group, float] = {} # group -> wall time it may be asked again
195
+ self._precomputed_loaded = False
196
  self._next_hf = 0.0
197
  self._task: Optional[asyncio.Task] = None
198
  self._search: Optional[httpx.AsyncClient] = None
 
288
  logger.info("package sizes: index not readable yet (%s); next try in %.0f s", exc, s.retry_interval)
289
  return s.retry_interval
290
  self._keys, self._walked = keys, True
291
+ if not self._precomputed_loaded:
292
+ self._precomputed_loaded = True
293
+ self.load_precomputed()
294
  now = self._wall()
295
  self._refused = {g: t for g, t in self._refused.items() if t > now}
296
  counts = Counter(v[0] for v in keys.values() if v)
 
452
  logger.info("package sizes: %d group(s) from %s", loaded, folder)
453
  return loaded
454
 
455
+ def load_precomputed(self) -> int:
456
+ """The run's own sums (pipeline/enumerate_assets.py's write_package_sizes): every group they cover needs no
457
+ HF listing. Anything unreadable leaves the listing to do it, group by group."""
458
+ path = Path(self.settings.precomputed)
459
+ try:
460
+ groups = json.loads(path.read_text())["groups"]
461
+ except FileNotFoundError:
462
+ return 0
463
+ except (OSError, ValueError, KeyError, TypeError) as exc:
464
+ logger.warning("package sizes: ignoring %s (%s)", path, exc)
465
+ return 0
466
+ loaded, now = 0, self._wall()
467
+ for doc in groups if isinstance(groups, list) else []:
468
+ try:
469
+ group = Group(str(doc["kind"]), str(doc["repo_id"]), str(doc["revision"]))
470
+ self._sums[group] = Sums(
471
+ int(doc["whole"]), {str(k): int(v) for k, v in doc["folders"].items()}, int(doc["files"]), now,
472
+ )
473
+ loaded += 1
474
+ except (ValueError, KeyError, TypeError, AttributeError) as exc:
475
+ logger.warning("package sizes: ignoring a group in %s (%s)", path.name, exc)
476
+ logger.info("package sizes: %d group(s) precomputed by the index run (%s)", loaded, path)
477
+ return loaded
478
+
479
  def _write_cache(self, group: Group, sums: Sums) -> None:
480
  folder = self._groups_dir()
481
  try:
pipeline/profiles.py ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage `profiles` β€” each asset's SimReady validation profile, from its package's validation sidecar.
2
+
3
+ The Space's Profiles filter lists the values of `simready_metadata_validation__profile`. USD Search indexes that
4
+ property only from the root USD's `SimReady_Metadata.validation` block, which NVIDIA's and LG's packages author.
5
+ Packages made with `simready-manage submit` record the profile in `<package>/.metadata/<package>_validation.json`
6
+ instead (validation never edits the USD), so Robotiq's grippers (Robot-Gripper) and Standard Bots' arms (Robot-Body)
7
+ reached the index with no profile at all. Between `categories` and `export`, this stage reads that sidecar at the
8
+ package's pinned revision for every partner (non-NVIDIA) package and every asset still without a profile, and writes
9
+ `simready_metadata_validation__profile` and `..._profile_version` from it:
10
+
11
+ * the sidecar wins where the USD says something else (it is the validation record); the stage lists every such
12
+ disagreement;
13
+ * a package without a sidecar (404) keeps whatever its USD says;
14
+ * NVIDIA packages whose USD carries a profile are not read (their packages have no sidecar).
15
+
16
+ The sidecar is `{"<root USD, package-relative>": {"profile_id", "profile_version", ...}}` as `simready-manage`
17
+ writes it; a top-level `profile` / `profile_version` (the shape simready-central's docs describe) is read too. A read
18
+ that still fails after PROFILES_TRIES attempts fails the stage; nothing is written then. The record goes to
19
+ .build/profiles.json.
20
+
21
+ python -m pipeline.run_pipeline profiles --env-file ... [--dry-run]
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import os
28
+ import time
29
+ from collections import Counter
30
+ from dataclasses import dataclass
31
+ from typing import Dict, Iterable, List, Optional, Tuple
32
+ from urllib.parse import quote
33
+
34
+ from . import hf_addressing
35
+ from .categories import _scroll, _url_of, load_vendors, prop_values, with_property
36
+ from .config import BUILD_DIR, Config
37
+
38
+ PROPERTY = "simready_metadata_validation__profile"
39
+ VERSION_PROPERTY = "simready_metadata_validation__profile_version"
40
+ REPORT = BUILD_DIR / "profiles.json"
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class PackageRef:
45
+ host: str
46
+ repo_id: str # "<owner>/<name>" of a dataset repo
47
+ revision: str
48
+ package: str # the package folder at the repo root
49
+ path: str # the asset's file, relative to the package folder
50
+
51
+ @property
52
+ def owner(self) -> str:
53
+ return self.repo_id.split("/")[0]
54
+
55
+ def sidecar_url(self) -> str:
56
+ rel = f"{self.package}/.metadata/{self.package}_validation.json"
57
+ return f"https://{self.host}/datasets/{self.repo_id}/resolve/{self.revision}/{quote(rel)}"
58
+
59
+
60
+ # ── pure rules ───────────────────────────────────────────────────────────────
61
+
62
+
63
+ def package_ref(url: str) -> Optional[PackageRef]:
64
+ """The dataset package an indexed asset belongs to, from its hf:// address or its public
65
+ https://<host>/datasets/<owner>/<name>/blob|resolve/<rev>/<path> URL. None for anything else."""
66
+ if url.startswith("hf://"):
67
+ try:
68
+ addr = hf_addressing.parse(url)
69
+ except hf_addressing.AddressError:
70
+ return None
71
+ if addr.repo_type != "dataset" or "/" not in addr.path:
72
+ return None
73
+ package, path = addr.path.split("/", 1)
74
+ return PackageRef(addr.host, addr.repo_id, addr.revision or "main", package, path)
75
+ parts = url.split("/")
76
+ # https: '' <host> datasets <owner> <name> blob|resolve <rev> <package> <path...>
77
+ if len(parts) > 9 and parts[0] in ("https:", "http:") and parts[3] == "datasets" and parts[6] in ("blob", "resolve"):
78
+ return PackageRef(parts[2], f"{parts[4]}/{parts[5]}", parts[7], parts[8], "/".join(parts[9:]))
79
+ return None
80
+
81
+
82
+ def needs_sidecar(ref: Optional[PackageRef], props: Iterable[Dict], vendors: Dict) -> bool:
83
+ """Read the sidecar for a partner package, or for any asset whose USD gave no profile."""
84
+ if ref is None:
85
+ return False
86
+ return ref.owner.lower() not in vendors["own"] or not prop_values(props, PROPERTY)
87
+
88
+
89
+ def _profile_of(entry: Dict) -> Optional[Tuple[str, str]]:
90
+ profile = str(entry.get("profile_id") or entry.get("profile") or "").strip()
91
+ if not profile:
92
+ return None
93
+ return profile, str(entry.get("profile_version") or "").strip()
94
+
95
+
96
+ def profile_from_sidecar(doc, path: str) -> Tuple[Optional[Tuple[str, str]], str]:
97
+ """((profile, version), "ok") from a validation sidecar for the asset at package-relative `path`, or
98
+ (None, why): "malformed", "no-profile" or "ambiguous" (several USDs with different profiles, none of them `path`)."""
99
+ if not isinstance(doc, dict):
100
+ return None, "malformed"
101
+ top = _profile_of(doc)
102
+ if top:
103
+ return top, "ok"
104
+ entries = {k: _profile_of(v) for k, v in doc.items() if isinstance(v, dict)}
105
+ entries = {k: v for k, v in entries.items() if v}
106
+ if path in entries:
107
+ return entries[path], "ok"
108
+ found = set(entries.values())
109
+ if len(found) == 1:
110
+ return found.pop(), "ok"
111
+ return None, "ambiguous" if found else "no-profile"
112
+
113
+
114
+ def with_profile(props: List[Dict], profile: str, version: str) -> List[Dict]:
115
+ """`props` carrying exactly this profile (and version, when the sidecar names one). A version the sidecar leaves
116
+ out is kept unless the profile itself changed. The same list back when nothing changes."""
117
+ current = prop_values(props, PROPERTY)
118
+ out = props
119
+ if current != [profile]:
120
+ out = with_property(out, PROPERTY, [profile])
121
+ if not version:
122
+ out = with_property(out, VERSION_PROPERTY, [])
123
+ if version and prop_values(out, VERSION_PROPERTY) != [version]:
124
+ out = with_property(out, VERSION_PROPERTY, [version])
125
+ return out
126
+
127
+
128
+ # ── reading the sidecars ─────────────────────────────────────────────────────
129
+
130
+
131
+ class SidecarError(RuntimeError):
132
+ pass
133
+
134
+
135
+ def fetch_sidecar(http, url: str, tries: int, backoff_s: float) -> Tuple[str, object]:
136
+ """("ok", parsed sidecar), ("missing", None) when the package has none (404), or ("malformed", None) when it
137
+ is not JSON. Retries 429/5xx and transport errors; anything else, or the last failure, raises SidecarError."""
138
+ last = ""
139
+ for attempt in range(tries):
140
+ if attempt:
141
+ time.sleep(backoff_s * (2 ** (attempt - 1)))
142
+ try:
143
+ resp = http.get(url)
144
+ except Exception as e: # noqa: BLE001 β€” httpx transport errors; retried, then reported
145
+ last = f"{type(e).__name__}: {e}"
146
+ continue
147
+ if resp.status_code == 404:
148
+ return "missing", None
149
+ if resp.status_code == 200:
150
+ try:
151
+ return "ok", resp.json()
152
+ except ValueError:
153
+ return "malformed", None
154
+ last = f"HTTP {resp.status_code}"
155
+ if resp.status_code != 429 and resp.status_code < 500:
156
+ break
157
+ raise SidecarError(f"{url}: {last}")
158
+
159
+
160
+ def _headers() -> Dict[str, str]:
161
+ # Public datasets read anonymously; $HF_TOKEN (environment only, as everywhere in this pipeline) when set.
162
+ token = os.environ.get("HF_TOKEN")
163
+ return {"Authorization": f"Bearer {token}"} if token else {}
164
+
165
+
166
+ # ── the stage ────────────────────────────────────────────────────────────────
167
+
168
+
169
+ def run(cfg: Config, dry_run: bool = False) -> int:
170
+ import httpx
171
+
172
+ vendors = load_vendors()
173
+ base, index = cfg.opensearch_url, cfg.main_index
174
+ tries = max(1, int(os.environ.get("PROFILES_TRIES", "3")))
175
+ interval = float(os.environ.get("PROFILES_READ_INTERVAL_S", "0.25"))
176
+ outcomes: Counter = Counter()
177
+ values: Counter = Counter()
178
+ sources: Counter = Counter()
179
+ disagreements: List[Dict] = []
180
+ without: List[str] = []
181
+ errors: List[str] = []
182
+ updates: List[Tuple[str, List[Dict]]] = []
183
+ expected: Dict[str, Tuple[List[str], List[str]]] = {}
184
+ read = 0
185
+ with httpx.Client(timeout=60.0, follow_redirects=True) as index_http, \
186
+ httpx.Client(timeout=60.0, follow_redirects=True, headers=_headers()) as hf_http:
187
+ for hit in _scroll(index_http, base, index):
188
+ src = hit.get("_source") or {}
189
+ props = src.get("usd_properties")
190
+ if props is not None and not isinstance(props, list):
191
+ print(f"ERROR: {hit['_id']}: usd_properties is a {type(props).__name__}, not a list β€” not touching the index")
192
+ return 1
193
+ props = props or []
194
+ url = _url_of(src)
195
+ ref = package_ref(url)
196
+ usd = prop_values(props, PROPERTY)
197
+ new_props = props
198
+ if needs_sidecar(ref, props, vendors):
199
+ if read:
200
+ time.sleep(interval)
201
+ read += 1
202
+ try:
203
+ state, doc = fetch_sidecar(hf_http, ref.sidecar_url(), tries, backoff_s=2.0)
204
+ except SidecarError as e:
205
+ errors.append(str(e))
206
+ continue
207
+ if state == "missing":
208
+ outcomes["no-sidecar"] += 1
209
+ else:
210
+ found, why = profile_from_sidecar(doc, ref.path)
211
+ outcomes[why] += 1
212
+ if found:
213
+ profile, version = found
214
+ if usd and usd != [profile]:
215
+ disagreements.append({"url": url, "usd": usd, "sidecar": profile})
216
+ new_props = with_profile(props, profile, version)
217
+ else:
218
+ outcomes["usd-only"] += 1
219
+ final = prop_values(new_props, PROPERTY)
220
+ values.update(final)
221
+ sources["sidecar" if new_props is not props else ("usd" if final else "none")] += 1
222
+ if not final:
223
+ without.append(url)
224
+ if new_props is not props:
225
+ updates.append((hit["_id"], new_props))
226
+ expected[hit["_id"]] = (final, prop_values(new_props, VERSION_PROPERTY))
227
+
228
+ print(f"{sum(sources.values())} assets in {index}; {read} sidecar(s) read; outcomes: {dict(outcomes)}; "
229
+ f"{len(updates)} doc(s) to update")
230
+ print(f"{PROPERTY}: {json.dumps(dict(values), sort_keys=True)}")
231
+ for d in disagreements:
232
+ print(f"sidecar overrides the USD: {d['url']}: {d['usd']} -> {d['sidecar']}")
233
+ if without:
234
+ print(f"left without {PROPERTY} ({len(without)}): {without[:20]}")
235
+ REPORT.parent.mkdir(parents=True, exist_ok=True)
236
+ REPORT.write_text(json.dumps({
237
+ "property": PROPERTY, "index": index, "assets": sum(sources.values()), "sidecars_read": read,
238
+ "outcomes": dict(outcomes), "values": dict(sorted(values.items())), "disagreements": disagreements,
239
+ "without": without, "errors": errors, "updated": len(updates), "dry_run": dry_run,
240
+ }, indent=1) + "\n")
241
+ if errors:
242
+ print("ERROR: sidecar read failed:\nERROR: " + "\nERROR: ".join(errors[:20]))
243
+ return 1
244
+ if dry_run:
245
+ print("dry run: nothing written")
246
+ return 0
247
+ for i in range(0, len(updates), 200):
248
+ lines = []
249
+ for doc_id, props in updates[i:i + 200]:
250
+ lines.append(json.dumps({"update": {"_index": index, "_id": doc_id}}))
251
+ lines.append(json.dumps({"doc": {"usd_properties": props}}))
252
+ resp = index_http.post(f"{base}/_bulk", content="\n".join(lines) + "\n",
253
+ headers={"content-type": "application/x-ndjson"})
254
+ resp.raise_for_status()
255
+ if resp.json().get("errors"):
256
+ bad = [it for it in resp.json()["items"] if it.get("update", {}).get("error")][:3]
257
+ print(f"ERROR: bulk update failed: {json.dumps(bad)[:800]}")
258
+ return 1
259
+ index_http.post(f"{base}/{index}/_refresh").raise_for_status()
260
+ # Read back: the whole index holds the counted profiles, and every updated doc exactly its own.
261
+ written: Counter = Counter()
262
+ wrong: List[str] = []
263
+ for hit in _scroll(index_http, base, index):
264
+ props = (hit.get("_source") or {}).get("usd_properties") or []
265
+ written.update(prop_values(props, PROPERTY))
266
+ want = expected.get(hit["_id"])
267
+ if want and want != (prop_values(props, PROPERTY), prop_values(props, VERSION_PROPERTY)):
268
+ wrong.append(hit["_id"])
269
+ if written != values or wrong:
270
+ print(f"ERROR: read-back differs: wrote {dict(values)} but the index holds {dict(written)}; docs: {wrong[:10]}")
271
+ return 1
272
+ print(f"wrote {PROPERTY} on {len(updates)} doc(s); the index holds {json.dumps(dict(written), sort_keys=True)}")
273
+ return 0
274
+
275
+
276
+ def main(argv: Optional[List[str]] = None) -> int:
277
+ import argparse
278
+ from pathlib import Path
279
+
280
+ ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
281
+ ap.add_argument("--env-file", type=Path, default=None)
282
+ ap.add_argument("--dry-run", action="store_true")
283
+ a = ap.parse_args(argv)
284
+ return run(Config.load(a.env_file), dry_run=a.dry_run)
285
+
286
+
287
+ if __name__ == "__main__":
288
+ import sys
289
+
290
+ sys.exit(main())
pipeline/restore.py CHANGED
@@ -25,6 +25,7 @@ from __future__ import annotations
25
 
26
  import gzip
27
  import json
 
28
  import shutil
29
  import subprocess
30
  from pathlib import Path
@@ -77,6 +78,21 @@ OPENSEARCH_TIMEOUT = 120.0
77
  BULK_TIMEOUT = 300.0
78
 
79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  def download(cfg: Config, prefix: str, dest: Path = RESTORE_DIR) -> None:
81
  # dest is overridable so two independent restores (e.g. the HF Space's
82
  # OpenSearch and Neo4j restores, run by separate supervisord programs β€”
@@ -159,6 +175,7 @@ def restore_opensearch(cfg: Config, manifest: Dict) -> None:
159
  os_dir / f"{index}.mapping.json.gz",
160
  number_of_shards=cfg.opensearch_number_of_shards,
161
  )
 
162
  response = httpx.put(f"{cfg.opensearch_url}/{index}", json=body, timeout=OPENSEARCH_TIMEOUT)
163
  if response.status_code >= 400:
164
  # raise_for_status()'s own message is just "403 Forbidden" β€”
@@ -236,6 +253,8 @@ def run(cfg: Config, fresh: bool = False) -> int:
236
  manifest = json.loads((RESTORE_DIR / "manifest.json").read_text())
237
 
238
  print("\nRestoring OpenSearch ...")
 
 
239
  restore_opensearch(cfg, manifest)
240
 
241
  print("\nRestoring Neo4j ...")
 
25
 
26
  import gzip
27
  import json
28
+ import os
29
  import shutil
30
  import subprocess
31
  from pathlib import Path
 
78
  BULK_TIMEOUT = 300.0
79
 
80
 
81
+ def keep_derived_source_off(body: dict, mappings_json: str = "") -> None:
82
+ """RESTORE_KNN_DERIVED_SOURCE=off (the restore stage sets it; the Space's seed does not): create a k-NN index with
83
+ `index.knn.derived_source.enabled: false`, as the workers' own indexes are. build_create_body strips that
84
+ setting, so a restored index falls back to derived source ON, and OpenSearch then rebuilds the nested
85
+ `siglip2-embedding` vectors on read with one vector per segment: the next export writes that one vector onto
86
+ every document of the segment (run 20261012-9d9f42a: 1,399 of 1,404). The Space only searches its index (the
87
+ k-NN graph is built from the loaded vectors), so it keeps the default. Set it to `on` to restore the old behaviour."""
88
+ if os.environ.get("RESTORE_KNN_DERIVED_SOURCE", "").strip().lower() not in ("off", "false", "0"):
89
+ return
90
+ index_settings = body.setdefault("settings", {}).setdefault("index", {})
91
+ if str(index_settings.get("knn", "")).lower() == "true" or '"knn_vector"' in mappings_json:
92
+ index_settings["knn.derived_source.enabled"] = "false"
93
+ print(" k-NN derived source off for this index (RESTORE_KNN_DERIVED_SOURCE=off)")
94
+
95
+
96
  def download(cfg: Config, prefix: str, dest: Path = RESTORE_DIR) -> None:
97
  # dest is overridable so two independent restores (e.g. the HF Space's
98
  # OpenSearch and Neo4j restores, run by separate supervisord programs β€”
 
175
  os_dir / f"{index}.mapping.json.gz",
176
  number_of_shards=cfg.opensearch_number_of_shards,
177
  )
178
+ keep_derived_source_off(body, json.dumps(body.get("mappings", {})))
179
  response = httpx.put(f"{cfg.opensearch_url}/{index}", json=body, timeout=OPENSEARCH_TIMEOUT)
180
  if response.status_code >= 400:
181
  # raise_for_status()'s own message is just "403 Forbidden" β€”
 
253
  manifest = json.loads((RESTORE_DIR / "manifest.json").read_text())
254
 
255
  print("\nRestoring OpenSearch ...")
256
+ # This restore feeds an index that `export` dumps again: keep k-NN derived source off (keep_derived_source_off).
257
+ os.environ.setdefault("RESTORE_KNN_DERIVED_SOURCE", "off")
258
  restore_opensearch(cfg, manifest)
259
 
260
  print("\nRestoring Neo4j ...")
pipeline/run_pipeline.py CHANGED
@@ -6,7 +6,8 @@ Stages run in order and each is independently re-runnable:
6
 
7
  enumerate corpus.json -> exact asset list (N) + manifest
8
  index bring the stack up, restore the baseline, clear, submit the
9
- delta, wait
 
10
  export elasticdump the indexes; neo4j-admin dump the graph
11
  upload publish the artifact set to S3
12
  bake build images with the data pre-loaded
@@ -20,7 +21,10 @@ Stages run in order and each is independently re-runnable:
20
  all every stage in order. `hierarchy` runs right after `verify`
21
  with --new-only (only new Q-codes are placed; nothing placed
22
  moves), on the verified stack; a failure there only warns β€”
23
- the index still ships, with the previous run's tree.
 
 
 
24
 
25
  deploy-space deploy the hf_space image to a Hugging Face Space (not
26
  part of `all` β€” run manually after `push`; see
@@ -76,9 +80,14 @@ def stage_up(cfg: Config, args) -> int:
76
  if not (BUILD_DIR / "settings.yaml").exists() or not (BUILD_DIR / "plugin.config.yaml").exists():
77
  print("`.build/settings.yaml`/`.build/plugin.config.yaml` are missing β€” run `enumerate` first.")
78
  return 1
79
- if cfg.siglip2_nvcf:
80
  print(f"siglip2 backend: nvcf ({cfg.get('SIGLIP2_NVCF_ENDPOINT', 'grpc.nvcf.nvidia.com:443')}) "
81
  f"β€” no local GPU needed for embeddings.")
 
 
 
 
 
82
  if cfg.vlm_metadata_enabled:
83
  cfg.require_vlm_metadata_credentials()
84
  model = os.environ.get("USDSEARCH_VISION_METADATA_MODEL", "").strip() or "azure/openai/gpt-5.6-sol (default)"
@@ -133,20 +142,66 @@ def stage_wait(cfg: Config, args) -> int:
133
  return run(cfg)
134
 
135
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  def stage_categories(cfg: Config, args) -> int:
137
  from .categories import run
138
- return run(cfg, dry_run=args.dry_run)
 
 
 
 
 
 
 
 
 
 
 
139
 
140
 
141
  def stage_index(cfg: Config, args) -> int:
142
- """Bring the stack up, restore the baseline, clear, submit the delta, wait, write central:category."""
 
143
  steps = (
144
  ("up", stage_up),
145
  ("restore", stage_restore),
146
  ("clear", stage_clear),
147
  ("submit", stage_submit),
148
  ("wait", stage_wait),
 
149
  ("categories", stage_categories),
 
150
  )
151
  for step, fn in steps:
152
  print(f"\n{'=' * 70}\n== index: {step}\n{'=' * 70}")
@@ -158,6 +213,14 @@ def stage_index(cfg: Config, args) -> int:
158
 
159
 
160
  def stage_export(cfg: Config, args) -> int:
 
 
 
 
 
 
 
 
161
  from .export import run
162
  return run(cfg)
163
 
@@ -247,6 +310,10 @@ def stage_all(cfg: Config, args) -> int:
247
  args.keep_up = keep_up or hierarchy_next # the hierarchy stage reads the verified stack
248
  if name == "hierarchy":
249
  args.new_only = True
 
 
 
 
250
  try:
251
  code = fn(cfg, args)
252
  except (SystemExit, Exception) as e: # noqa: BLE001 β€” only `hierarchy` may fail softly
@@ -305,7 +372,9 @@ STAGES = {
305
  "submit": stage_submit,
306
  "wait": stage_wait,
307
  "status": stage_wait,
 
308
  "categories": stage_categories,
 
309
  "index": stage_index,
310
  "export": stage_export,
311
  "upload": stage_upload,
 
6
 
7
  enumerate corpus.json -> exact asset list (N) + manifest
8
  index bring the stack up, restore the baseline, clear, submit the
9
+ delta, wait, then the image-overrides, categories and
10
+ profiles steps
11
  export elasticdump the indexes; neo4j-admin dump the graph
12
  upload publish the artifact set to S3
13
  bake build images with the data pre-loaded
 
21
  all every stage in order. `hierarchy` runs right after `verify`
22
  with --new-only (only new Q-codes are placed; nothing placed
23
  moves), on the verified stack; a failure there only warns β€”
24
+ the index still ships, with the previous run's tree β€” but the
25
+ stage is required before deploying: deploy-space refuses a run
26
+ with no published hierarchy. After `--stop verify`, run it with
27
+ --run-id <run>.
28
 
29
  deploy-space deploy the hf_space image to a Hugging Face Space (not
30
  part of `all` β€” run manually after `push`; see
 
80
  if not (BUILD_DIR / "settings.yaml").exists() or not (BUILD_DIR / "plugin.config.yaml").exists():
81
  print("`.build/settings.yaml`/`.build/plugin.config.yaml` are missing β€” run `enumerate` first.")
82
  return 1
83
+ if cfg.indexing_siglip2_nvcf:
84
  print(f"siglip2 backend: nvcf ({cfg.get('SIGLIP2_NVCF_ENDPOINT', 'grpc.nvcf.nvidia.com:443')}) "
85
  f"β€” no local GPU needed for embeddings.")
86
+ else:
87
+ print(f"siglip2 backend: local ({cfg.get('SIGLIP2_IMAGE', 'usdsearch-siglip2-triton')}, needs a GPU)")
88
+ if cfg.storage_api_backend and not cfg.hf_token():
89
+ print("WARNING: no Hugging Face token ($HF_TOKEN or `hf auth login`) β€” hf-storage-api runs "
90
+ "anonymously and HF rate-limits it (429s) on anything but a small corpus.")
91
  if cfg.vlm_metadata_enabled:
92
  cfg.require_vlm_metadata_credentials()
93
  model = os.environ.get("USDSEARCH_VISION_METADATA_MODEL", "").strip() or "azure/openai/gpt-5.6-sol (default)"
 
142
  return run(cfg)
143
 
144
 
145
+ # The steps `index` runs after `wait`. Each writes into the index, so `export` refuses to run until all three have
146
+ # succeeded after the latest `wait`: resuming with `wait` and then `all --start export` used to skip them silently
147
+ # (2026-10-16), shipping an index without image overrides, the OEM category tree or partner profiles.
148
+ ENRICHMENT_STEPS = ("image-overrides", "categories", "profiles")
149
+ STAGE_MARKERS = BUILD_DIR / "stages"
150
+
151
+
152
+ def _mark_done(step: str, args) -> None:
153
+ if not getattr(args, "dry_run", False):
154
+ STAGE_MARKERS.mkdir(parents=True, exist_ok=True)
155
+ (STAGE_MARKERS / f"{step}.done").touch()
156
+
157
+
158
+ def enrichment_missing() -> List[str]:
159
+ """The ENRICHMENT_STEPS that haven't succeeded since the latest `wait` (its .build/index-report.json)."""
160
+ report = BUILD_DIR / "index-report.json"
161
+ waited = report.stat().st_mtime if report.exists() else 0.0
162
+ return [
163
+ step for step in ENRICHMENT_STEPS
164
+ if not (STAGE_MARKERS / f"{step}.done").exists()
165
+ or (STAGE_MARKERS / f"{step}.done").stat().st_mtime < waited
166
+ ]
167
+
168
+
169
+ def stage_image_overrides(cfg: Config, args) -> int:
170
+ from .image_overrides import run
171
+ code = run(cfg, dry_run=args.dry_run)
172
+ if code == 0:
173
+ _mark_done("image-overrides", args)
174
+ return code
175
+
176
+
177
  def stage_categories(cfg: Config, args) -> int:
178
  from .categories import run
179
+ code = run(cfg, dry_run=args.dry_run)
180
+ if code == 0:
181
+ _mark_done("categories", args)
182
+ return code
183
+
184
+
185
+ def stage_profiles(cfg: Config, args) -> int:
186
+ from .profiles import run
187
+ code = run(cfg, dry_run=args.dry_run)
188
+ if code == 0:
189
+ _mark_done("profiles", args)
190
+ return code
191
 
192
 
193
  def stage_index(cfg: Config, args) -> int:
194
+ """Bring the stack up, restore the baseline, clear, submit the delta, wait, write central:category and the
195
+ validation profiles the packages' sidecars name."""
196
  steps = (
197
  ("up", stage_up),
198
  ("restore", stage_restore),
199
  ("clear", stage_clear),
200
  ("submit", stage_submit),
201
  ("wait", stage_wait),
202
+ ("image-overrides", stage_image_overrides),
203
  ("categories", stage_categories),
204
+ ("profiles", stage_profiles),
205
  )
206
  for step, fn in steps:
207
  print(f"\n{'=' * 70}\n== index: {step}\n{'=' * 70}")
 
213
 
214
 
215
  def stage_export(cfg: Config, args) -> int:
216
+ missing = enrichment_missing()
217
+ if missing:
218
+ print(
219
+ f"refusing to export: {', '.join(missing)} not run since the last `wait`. They write into the index "
220
+ f"(image overrides, central:category, partner profiles). Run them in order, then export:\n"
221
+ + "".join(f" python -m pipeline.run_pipeline {step} --env-file ...\n" for step in missing)
222
+ )
223
+ return 1
224
  from .export import run
225
  return run(cfg)
226
 
 
310
  args.keep_up = keep_up or hierarchy_next # the hierarchy stage reads the verified stack
311
  if name == "hierarchy":
312
  args.new_only = True
313
+ # This run, not latest.json's: under --no-latest that is still the previous run, whose tree the Spaces
314
+ # serve (2026-10-19: 20261019-9f72902's hierarchy started publishing into 20261015-09c47a6/hierarchy/).
315
+ from .enumerate_assets import load_manifest
316
+ args.run_id = getattr(args, "run_id", None) or load_manifest()["run_id"]
317
  try:
318
  code = fn(cfg, args)
319
  except (SystemExit, Exception) as e: # noqa: BLE001 β€” only `hierarchy` may fail softly
 
372
  "submit": stage_submit,
373
  "wait": stage_wait,
374
  "status": stage_wait,
375
+ "image-overrides": stage_image_overrides,
376
  "categories": stage_categories,
377
+ "profiles": stage_profiles,
378
  "index": stage_index,
379
  "export": stage_export,
380
  "upload": stage_upload,
pipeline/status.py CHANGED
@@ -119,6 +119,12 @@ def classify(doc: Dict, plugins: Tuple[str, ...]) -> Dict[str, Tuple[str, str]]:
119
  async def poll_until_done(cfg: Config, urls: List[str]) -> Tuple[Dict[str, Tally], List[str]]:
120
  """Poll the outstanding set until every (asset, plugin) pair is terminal."""
121
  interval = cfg.get_float("STATUS_POLL_INTERVAL", 15.0)
 
 
 
 
 
 
122
  timeout = cfg.get_float("STATUS_TIMEOUT", 21600.0)
123
  concurrency = cfg.get_int("STATUS_CONCURRENCY", 16)
124
  redis_url = f"redis://localhost:{cfg.get_int('REDIS_HOST_PORT', 6380)}"
@@ -132,6 +138,7 @@ async def poll_until_done(cfg: Config, urls: List[str]) -> Tuple[Dict[str, Tally
132
  redis_client = connect(redis_url)
133
 
134
  limits = httpx.Limits(max_connections=concurrency * 2)
 
135
  async with httpx.AsyncClient(limits=limits, timeout=httpx.Timeout(30.0)) as client:
136
  while outstanding:
137
  if time.monotonic() > deadline:
@@ -139,6 +146,15 @@ async def poll_until_done(cfg: Config, urls: List[str]) -> Tuple[Dict[str, Tally
139
  dump_worker_logs(cfg)
140
  break
141
 
 
 
 
 
 
 
 
 
 
142
  async def check(url: str) -> Tuple[str, Optional[Dict]]:
143
  async with semaphore:
144
  return url, await fetch_status(client, cfg.info_endpoint, url)
 
119
  async def poll_until_done(cfg: Config, urls: List[str]) -> Tuple[Dict[str, Tally], List[str]]:
120
  """Poll the outstanding set until every (asset, plugin) pair is terminal."""
121
  interval = cfg.get_float("STATUS_POLL_INTERVAL", 15.0)
122
+ # Each asset-status request makes info-endpoint list the asset through the
123
+ # storage backend. With storage_api that is an HF tree listing, so polling
124
+ # ~2,600 assets every cycle exhausted HF's API quota (429s) and starved the
125
+ # workers. While jobs are still queued, watch only the Redis queues and do
126
+ # a full per-asset poll at most every STATUS_FULL_POLL_INTERVAL seconds.
127
+ full_poll_interval = cfg.get_float("STATUS_FULL_POLL_INTERVAL", 600.0)
128
  timeout = cfg.get_float("STATUS_TIMEOUT", 21600.0)
129
  concurrency = cfg.get_int("STATUS_CONCURRENCY", 16)
130
  redis_url = f"redis://localhost:{cfg.get_int('REDIS_HOST_PORT', 6380)}"
 
138
  redis_client = connect(redis_url)
139
 
140
  limits = httpx.Limits(max_connections=concurrency * 2)
141
+ last_full_poll: Optional[float] = None
142
  async with httpx.AsyncClient(limits=limits, timeout=httpx.Timeout(30.0)) as client:
143
  while outstanding:
144
  if time.monotonic() > deadline:
 
146
  dump_worker_logs(cfg)
147
  break
148
 
149
+ if last_full_poll is not None and time.monotonic() - last_full_poll < full_poll_interval:
150
+ depths = await queue_depths(redis_client, plugins)
151
+ queued = sum(v for k, v in depths.items() if k != "results")
152
+ if queued or depths["results"]:
153
+ print(f" queued={queued} results={depths['results']}", flush=True)
154
+ await asyncio.sleep(interval)
155
+ continue
156
+ last_full_poll = time.monotonic()
157
+
158
  async def check(url: str) -> Tuple[str, Optional[Dict]]:
159
  async with semaphore:
160
  return url, await fetch_status(client, cfg.info_endpoint, url)
services/explorer/dist/build-info.json CHANGED
@@ -1,7 +1,7 @@
1
  {
2
  "product": "simready-central-slim-explorer",
3
- "version": "slim-ui-8692f52b",
4
- "bundle_id": "main.94a34e97.js",
5
- "built_at": "2026-10-19T09:20:23Z",
6
  "flags": {"public_mode": true, "email_auth": false, "create_ui": false, "hierarchy_editor": false, "create_search": false, "auth_ui": false, "credentials_ui": false, "index_management": false, "inference_backend": "hub", "vlm_asset_refs": false, "validation_image_max_px": 448, "validation_max_retries": 0, "validation_max_concurrent": 32, "validation_tail_concurrent": 8, "validation_eager_count": 50, "validation_batch_stream": true, "library_idle_prefetch": false, "validation_score_scale": "percent", "parse_gate_fields": true, "parse_fast_path": true, "featured_landing": true, "hf_download_commands": true, "dependency_views": true, "model_viewer": false, "library_viewer": true, "library_media": "runtime", "spell_correction": true, "downloads": "runtime", "submit_content": "runtime", "third_party_notices": "runtime"}
7
  }
 
1
  {
2
  "product": "simready-central-slim-explorer",
3
+ "version": "slim-ui-e4522a3f",
4
+ "bundle_id": "main.01872f5b.js",
5
+ "built_at": "2026-10-18T17:30:52Z",
6
  "flags": {"public_mode": true, "email_auth": false, "create_ui": false, "hierarchy_editor": false, "create_search": false, "auth_ui": false, "credentials_ui": false, "index_management": false, "inference_backend": "hub", "vlm_asset_refs": false, "validation_image_max_px": 448, "validation_max_retries": 0, "validation_max_concurrent": 32, "validation_tail_concurrent": 8, "validation_eager_count": 50, "validation_batch_stream": true, "library_idle_prefetch": false, "validation_score_scale": "percent", "parse_gate_fields": true, "parse_fast_path": true, "featured_landing": true, "hf_download_commands": true, "dependency_views": true, "model_viewer": false, "library_viewer": true, "library_media": "runtime", "spell_correction": true, "downloads": "runtime", "submit_content": "runtime", "third_party_notices": "runtime"}
7
  }
services/explorer/dist/index.html CHANGED
@@ -1 +1 @@
1
- <!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/ds.png"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="SimReady Central β€” SimReady asset search"/><meta name="ui-version" content="slim-ui-8692f52b"><script src="/config.js"></script><title>SimReady Central</title><script defer="defer" src="/static/js/main.94a34e97.js"></script><link href="/static/css/main.ed151f6a.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
 
1
+ <!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/ds.png"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="SimReady Central β€” SimReady asset search"/><meta name="ui-version" content="slim-ui-e4522a3f"><script src="/config.js"></script><title>SimReady Central</title><script defer="defer" src="/static/js/main.01872f5b.js"></script><link href="/static/css/main.ed151f6a.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
services/explorer/dist/third-party-notices.txt CHANGED
@@ -1,4 +1,4 @@
1
- Third-party notices β€” SimReady Central Explorer slim-ui-8692f52b
2
 
3
  The software in this web application's browser bundle includes the packages below, each under its own
4
  license. Each entry gives the package, its version, its license and the license / notice text it ships.
 
1
+ Third-party notices β€” SimReady Central Explorer slim-ui-e4522a3f
2
 
3
  The software in this web application's browser bundle includes the packages below, each under its own
4
  license. Each entry gives the package, its version, its license and the license / notice text it ships.