arozantsev commited on
Commit
6f8eac4
Β·
verified Β·
1 Parent(s): 780d845

Deploy simready-assets run 20260928-ed4eb73 (GPU variant)

Browse files
This view is limited to 50 files because it contains too many changes. Β  See raw diff
Files changed (50) hide show
  1. .gitattributes +4 -0
  2. Dockerfile +297 -0
  3. README.md +17 -8
  4. docker/hf-space/logging.yml +80 -0
  5. docker/hf-space/nginx.conf.template +259 -0
  6. docker/hf-space/search_fields.yaml +599 -0
  7. docker/hf-space/siglip2-model/PACKAGE-LICENSES/siglip2-giant-opt-patch16-384.txt +202 -0
  8. docker/hf-space/siglip2-model/SOURCE.txt +4 -0
  9. docker/hf-space/siglip2-model/model_repo/ensemble_image_model/1/_.txt +0 -0
  10. docker/hf-space/siglip2-model/model_repo/ensemble_image_model/config.pbtxt +48 -0
  11. docker/hf-space/siglip2-model/model_repo/ensemble_text_model/1/_.txt +0 -0
  12. docker/hf-space/siglip2-model/model_repo/ensemble_text_model/config.pbtxt +48 -0
  13. docker/hf-space/siglip2-model/model_repo/image_preprocessing/1/model.py +101 -0
  14. docker/hf-space/siglip2-model/model_repo/image_preprocessing/1/preprocessor_config/preprocessor_config.json +24 -0
  15. docker/hf-space/siglip2-model/model_repo/image_preprocessing/config.pbtxt +31 -0
  16. docker/hf-space/siglip2-model/model_repo/siglip2_text_encoder_onnx/config.pbtxt +67 -0
  17. docker/hf-space/siglip2-model/model_repo/siglip2_vision_encoder_onnx/config.pbtxt +65 -0
  18. docker/hf-space/siglip2-model/model_repo/tokenizer/1/model.py +80 -0
  19. docker/hf-space/siglip2-model/model_repo/tokenizer/1/tokenizer/tokenizer.json +3 -0
  20. docker/hf-space/siglip2-model/model_repo/tokenizer/config.pbtxt +35 -0
  21. docker/hf-space/siglip2-model/pyproject.toml +14 -0
  22. docker/hf-space/siglip2-model/uv.lock +597 -0
  23. docker/hf-space/supervisord-gpu.conf +113 -0
  24. docker/hf-space/supervisord.conf +130 -0
  25. docker/hf-space/wheels/SOURCE.txt +3 -0
  26. docker/hf-space/wheels/asset_graph_client-0.1.0-py3-none-any.whl +0 -0
  27. docker/hf-space/wheels/asset_graph_service-0.1.0-py3-none-any.whl +0 -0
  28. docker/hf-space/wheels/base_logging-0.1.0-py3-none-any.whl +0 -0
  29. docker/hf-space/wheels/constraints.txt +160 -0
  30. docker/hf-space/wheels/deepsearch_api-0.1.0-py3-none-any.whl +3 -0
  31. docker/hf-space/wheels/llm_client-0.1.0-py3-none-any.whl +0 -0
  32. docker/hf-space/wheels/search_utils-0.1.0-py3-none-any.whl +3 -0
  33. docker/hf-space/wheels/siglip2_triton_client-0.6.1-py3-none-any.whl +3 -0
  34. docker/hf-space/wheels/storage_api-0.1.0-py3-none-any.whl +0 -0
  35. pipeline/__init__.py +1 -0
  36. pipeline/artifact_store.py +139 -0
  37. pipeline/bake.py +631 -0
  38. pipeline/clear.py +101 -0
  39. pipeline/config.py +512 -0
  40. pipeline/corpus.py +370 -0
  41. pipeline/enumerate_assets.py +331 -0
  42. pipeline/export.py +296 -0
  43. pipeline/hf_addressing.py +133 -0
  44. pipeline/hf_catalog.py +109 -0
  45. pipeline/hf_space.py +314 -0
  46. pipeline/hf_space_seed.py +245 -0
  47. pipeline/queues.py +59 -0
  48. pipeline/restore.py +266 -0
  49. pipeline/run_pipeline.py +311 -0
  50. pipeline/status.py +381 -0
.gitattributes CHANGED
@@ -33,3 +33,7 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ docker/hf-space/siglip2-model/model_repo/tokenizer/1/tokenizer/tokenizer.json filter=lfs diff=lfs merge=lfs -text
37
+ docker/hf-space/wheels/deepsearch_api-0.1.0-py3-none-any.whl filter=lfs diff=lfs merge=lfs -text
38
+ docker/hf-space/wheels/search_utils-0.1.0-py3-none-any.whl filter=lfs diff=lfs merge=lfs -text
39
+ docker/hf-space/wheels/siglip2_triton_client-0.6.1-py3-none-any.whl filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # deploy: 20260928-ed4eb73 (simready-assets)
2
+ # GPU variant of the single-container MVP image for Hugging Face Spaces
3
+ # (Docker SDK): everything docker/Dockerfile.hf-space has (OpenSearch + Neo4j
4
+ # + Redis + deepsearch-api + asset-graph-service + nginx under supervisord),
5
+ # plus a *local* siglip2-triton process instead of routing SigLIP2 embeddings
6
+ # to NVCF β€” see docs/plans/siglip2-embedded-hf-space.md for the investigation
7
+ # and decision behind this file. Meant to be deployed to an HF Space running
8
+ # on a GPU hardware tier (T4 medium β€” see pipeline/hf_space.py's
9
+ # request_space_hardware call for the gpu variant); docker/Dockerfile.hf-space
10
+ # remains the free/cpu-basic, NVCF-backed variant β€” this is an additional
11
+ # deploy target, not a replacement (`SIGLIP2_BACKEND=nvcf` is unaffected).
12
+ #
13
+ # Builds the Triton half of this image the same way
14
+ # ../usdsearch/docker/Dockerfile.siglip2-triton does β€” FROM the bare, public
15
+ # nvcr.io/nvidia/tritonserver image, model_repo/ COPYed in from
16
+ # docker/hf-space/siglip2-model/ (vendored into *this* repo by
17
+ # scripts/extract_siglip2_model.sh, since HF's remote builder can reach
18
+ # neither ../usdsearch nor any private registry) β€” rather than FROM the
19
+ # prepackaged nvcr.io/nvidia/usdsearch/siglip2-triton image an earlier
20
+ # revision of this file used. Verified live why that prepackaged image was
21
+ # dropped: its published 1.4.0 tag bakes **FP32** weights (vision 4.4 GB +
22
+ # text 2.7 GB, inspected inside a pulled copy) β€” at this model's baked
23
+ # `instance_group: 2`, that's ~14 GB of resident GPU weight memory alone,
24
+ # and ~18 GB observed live in `nvidia-smi`, blowing past a T4's 16 GB VRAM.
25
+ # ../usdsearch's *current* services/siglip2-triton/model_repo checkout is
26
+ # already FP16-exported (vision 2.2 GB + text 1.4 GB β€” half the size, ~7 GB
27
+ # at instance_group: 2, comfortably under 16 GB) but was never republished
28
+ # under that NGC tag β€” vendoring this checkout's own model_repo directly
29
+ # sidesteps waiting on a new publish. See
30
+ # scripts/extract_siglip2_model.sh's own header for the full mechanism and
31
+ # docs/plans/siglip2-embedded-hf-space.md's progress log for when this was
32
+ # last refreshed.
33
+ #
34
+ # nvcr.io/nvidia/tritonserver itself (the base, not the prepackaged image)
35
+ # is on the same *public* NGC catalog path already verified live this
36
+ # project's other public pulls (opensearchproject/opensearch, neo4j) use β€”
37
+ # no credentials needed, so Hugging Face's own remote builder can pull it.
38
+ #
39
+ # VERIFIED LIVE on a real GPU (RTX 4090, local β€” not a T4, but same
40
+ # execution path): full build, `docker run --gpus all`, all 6 Triton models
41
+ # READY, OpenSearch/Neo4j restore from S3, deepsearch-api + asset-graph-service
42
+ # healthy through nginx. GPU memory settled at ~10-11 GB (tritonserver
43
+ # ~10.3 GB + a smaller deepsearch-api CUDA-context allocation) β€” comfortably
44
+ # under a T4's 16 GB, confirming the FP32->FP16 switch actually fixed the
45
+ # budget problem, not just on paper. A real `/v3/deepsearch/search` call
46
+ # came back with genuine `search_type: text_to_vector` /
47
+ # `field: siglip2-embedding` hits (query "a red chair" correctly ranked
48
+ # office-chair assets top) β€” the local siglip2-triton round trip is real,
49
+ # not just "the process started". LLM query parsing
50
+ # (`/v3/deepsearch/llm_parse/query`) and VLM validation
51
+ # (`/v3/deepsearch/vlm_validate/search_result`) were also exercised live
52
+ # against the already-deployed NVCF Qwen endpoint
53
+ # (config/pipeline.env.srp-staging-controlled's LLM_ENDPOINT_BASE_URL) with
54
+ # $NVCF_GENAI_RUN_KEY as USDSEARCH_LLM_API_KEY β€” both returned real model
55
+ # output (a structured filter parse; a logprob-based
56
+ # `P(Yes)=... from first-token log-probs.` verdict), confirming this
57
+ # variant's NVCF-backed LLM/VLM path works the same as the NVCF/cpu-basic
58
+ # variant's. See docs/plans/siglip2-embedded-hf-space.md's progress log for
59
+ # the full session notes, including the earlier FP32 attempt this
60
+ # superseded.
61
+
62
+ ARG OPENSEARCH_VERSION=3.8.0
63
+ ARG NEO4J_VERSION=5.23.0-community
64
+ ARG TRITONSERVER_BASE_IMAGE=nvcr.io/nvidia/tritonserver:26.05-py3
65
+
66
+ FROM opensearchproject/opensearch:${OPENSEARCH_VERSION} AS opensearch-src
67
+
68
+ FROM neo4j:${NEO4J_VERSION} AS neo4j-src
69
+
70
+ FROM ${TRITONSERVER_BASE_IMAGE}
71
+
72
+ WORKDIR /workspace
73
+ RUN mkdir -p /models
74
+
75
+ # Vendored model repo (see header comment) β€” ensembles, the Python-backend
76
+ # tokenizer/preprocessing models, and the two ONNX encoders' *config.pbtxt*
77
+ # only. The encoders' actual weight files (~3.6 GB combined β€” the ONNX
78
+ # externa-data blobs under each encoder's own 1/ subdirectory) are
79
+ # deliberately NOT baked here: a real `deploy-space-gpu` attempt 403'd with
80
+ # "Repository storage limit reached (Max: 1 GB)" β€” HF Space git repos have a
81
+ # default 1 GB storage quota, well under what baking these in needs. Instead
82
+ # they're restored from the artifact S3 bucket at container startup
83
+ # (pipeline/hf_space_seed.py --component=siglip2-model, run by
84
+ # supervisord-gpu.conf's [program:siglip2-triton] before tritonserver
85
+ # itself execs) β€” the same "runtime restore, not baked" pattern this image
86
+ # already uses for the OpenSearch index and Neo4j graph, just for a
87
+ # corpus-independent static artifact instead of per-run data. Publish
88
+ # weights to that S3 path with scripts/publish_siglip2_model_to_s3.sh
89
+ # whenever scripts/extract_siglip2_model.sh refreshes the vendored copy.
90
+ COPY docker/hf-space/siglip2-model/model_repo/ensemble_image_model /models/ensemble_image_model
91
+ COPY docker/hf-space/siglip2-model/model_repo/ensemble_text_model /models/ensemble_text_model
92
+ COPY docker/hf-space/siglip2-model/model_repo/image_preprocessing /models/image_preprocessing
93
+ COPY docker/hf-space/siglip2-model/model_repo/siglip2_vision_encoder_onnx/config.pbtxt /models/siglip2_vision_encoder_onnx/config.pbtxt
94
+ COPY docker/hf-space/siglip2-model/model_repo/siglip2_text_encoder_onnx/config.pbtxt /models/siglip2_text_encoder_onnx/config.pbtxt
95
+ RUN mkdir -p /models/siglip2_vision_encoder_onnx/1 /models/siglip2_text_encoder_onnx/1
96
+ COPY docker/hf-space/siglip2-model/model_repo/tokenizer /models/tokenizer
97
+
98
+ # Python deps for the tokenizer + image_preprocessing Python-backend models β€”
99
+ # resolved from pyproject.toml + uv.lock (the source of truth) at build time,
100
+ # same as ../usdsearch/docker/Dockerfile.siglip2-triton itself does, so this
101
+ # doesn't need a separately-maintained requirements.txt that can drift.
102
+ COPY docker/hf-space/siglip2-model/pyproject.toml docker/hf-space/siglip2-model/uv.lock /workspace/
103
+ RUN pip install --no-cache-dir uv \
104
+ && uv export --frozen --no-hashes --directory /workspace -o /workspace/requirements.txt \
105
+ && pip install --no-cache-dir -r /workspace/requirements.txt \
106
+ && rm /workspace/requirements.txt
107
+
108
+ COPY docker/hf-space/siglip2-model/PACKAGE-LICENSES /PACKAGE-LICENSES
109
+
110
+ # Same two size/vulnerability trims ../usdsearch/docker/Dockerfile.siglip2-triton
111
+ # itself applies to this exact base image β€” carried over verbatim rather than
112
+ # dropped, since they're cheap and free up real space (~1 GB+ for Nsight
113
+ # Systems alone) on top of the FP16 savings above.
114
+ RUN rm -rf /usr/local/cuda-13.2/NsightSystems-cli-2026.2.1
115
+
116
+ USER root
117
+
118
+ # Non-root runtime uid/gid 1000, same convention docker/Dockerfile.hf-space
119
+ # uses for every data-writing process in that image (matches
120
+ # opensearchproject/opensearch's own "opensearch" user, 1000:1000). This
121
+ # base image is Ubuntu 24.04 ("noble") β€” not verified live whether uid 1000
122
+ # is already taken by a user the NGC tritonserver image itself creates, so
123
+ # this reuses it (rather than erroring) if it is: --chown=1000:1000 below
124
+ # only cares that the numeric id resolves consistently, not which username
125
+ # owns it.
126
+ RUN if id -u 1000 >/dev/null 2>&1; then \
127
+ echo "uid 1000 already exists as $(getent passwd 1000 | cut -d: -f1) β€” reusing it"; \
128
+ else \
129
+ groupadd -g 1000 nvs && useradd -u 1000 -g 1000 -m -d /home/nvs -s /usr/sbin/nologin nvs; \
130
+ fi
131
+
132
+ # AWS CLI v2 via its official self-contained installer, not apt/pip β€” same
133
+ # reasoning as docker/Dockerfile.hf-space. python3.12-venv is requested
134
+ # explicitly (Ubuntu splits the venv module out of the base python3.12
135
+ # package on some images) since this base is noble rather than bookworm and
136
+ # hasn't been checked for what's preinstalled.
137
+ RUN apt-get update && apt-get install -y --no-install-recommends \
138
+ redis-server supervisor nginx curl unzip python3.12-venv openssl \
139
+ && rm -rf /var/lib/apt/lists/* \
140
+ && rm -f /etc/nginx/sites-enabled/default \
141
+ && curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscliv2.zip \
142
+ && unzip -q /tmp/awscliv2.zip -d /tmp \
143
+ && /tmp/aws/install \
144
+ && rm -rf /tmp/awscliv2.zip /tmp/aws
145
+
146
+ # The app venv: same /app/.venv path docker/Dockerfile.hf-space and
147
+ # docker/hf-space/supervisord*.conf both hardcode. Installed from the same
148
+ # repacked usdsearch wheels + constraints.txt as the NVCF variant β€” see that
149
+ # Dockerfile's header comment for how wheels/ is produced and refreshed.
150
+ # Upstream's own docker/Dockerfile.usdsearch compiles these wheels in a
151
+ # python:3.12-bookworm builder stage but runs them in a
152
+ # nvcr.io/nvidia/base/ubuntu:noble-20260217 *runtime* stage β€” the same OS
153
+ # family as this Triton image's own base β€” so installing them here follows
154
+ # upstream's own production direction (older-glibc build -> newer-glibc
155
+ # run), not a new cross-distro risk. Not yet verified live in this repo,
156
+ # though (see header comment).
157
+ COPY docker/hf-space/wheels/ /tmp/usdsearch-wheels/
158
+ RUN python3.12 -m venv /app/.venv \
159
+ && /app/.venv/bin/pip install --no-cache-dir \
160
+ --find-links /tmp/usdsearch-wheels \
161
+ --constraint /tmp/usdsearch-wheels/constraints.txt \
162
+ /tmp/usdsearch-wheels/*.whl \
163
+ && rm -rf /tmp/usdsearch-wheels \
164
+ && chown -R 1000:1000 /app/.venv
165
+
166
+ COPY --from=opensearch-src --chown=1000:1000 /usr/share/opensearch /usr/share/opensearch
167
+
168
+ RUN /usr/share/opensearch/bin/opensearch-plugin remove opensearch-performance-analyzer
169
+
170
+ # See docker/Dockerfile.hf-space's own (much longer) comment on this same
171
+ # block for the full k-NN/faiss library-path story β€” copied verbatim here
172
+ # since it's the identical opensearchproject/opensearch source image and the
173
+ # identical cross-image-copy cause.
174
+ ENV OPENSEARCH_HOME=/usr/share/opensearch \
175
+ OPENSEARCH_PATH_CONF=/usr/share/opensearch/config \
176
+ PATH="/usr/share/opensearch/bin:/app/.venv/bin:${PATH}" \
177
+ LD_LIBRARY_PATH=/usr/share/opensearch/plugins/opensearch-knn/lib \
178
+ discovery.type=single-node \
179
+ DISABLE_SECURITY_PLUGIN=true \
180
+ DISABLE_INSTALL_DEMO_CONFIG=true \
181
+ bootstrap.memory_lock=false \
182
+ OPENSEARCH_JAVA_OPTS="-Xms1g -Xmx1g -Djava.library.path=/usr/share/opensearch/plugins/opensearch-knn/lib"
183
+
184
+ COPY --from=neo4j-src /opt/java/openjdk /opt/java/openjdk
185
+ COPY --from=neo4j-src /var/lib/neo4j /var/lib/neo4j
186
+
187
+ RUN mkdir -p /data /logs && chmod 777 /data /logs
188
+
189
+ RUN cp /var/lib/neo4j/labs/apoc-*-core.jar /var/lib/neo4j/plugins/
190
+
191
+ RUN { \
192
+ echo "dbms.security.auth_enabled=false"; \
193
+ echo "server.memory.heap.initial_size=1G"; \
194
+ echo "server.memory.heap.max_size=1G"; \
195
+ echo "server.memory.pagecache.size=512M"; \
196
+ } >> /var/lib/neo4j/conf/neo4j.conf
197
+
198
+ ENV NEO4J_HOME=/var/lib/neo4j \
199
+ PATH="/var/lib/neo4j/bin:/opt/java/openjdk/bin:${PATH}"
200
+
201
+ COPY docker/hf-space/logging.yml /logging.yml
202
+ COPY docker/hf-space/search_fields.yaml /app/search_fields.yaml
203
+
204
+ # The GPU-variant supervisord config β€” same six programs as
205
+ # docker/hf-space/supervisord.conf plus [program:siglip2-triton], and
206
+ # deepsearch-api's own command additionally waits on Triton's readiness
207
+ # before execing uvicorn. See that file's header for the port-collision
208
+ # reasoning (Triton's default HTTP port 8000 clashes with deepsearch-api's
209
+ # own uvicorn; remapped to 8003 here).
210
+ COPY docker/hf-space/supervisord-gpu.conf /etc/supervisor/conf.d/hf-space.conf
211
+
212
+ COPY docker/hf-space/nginx.conf.template /etc/nginx/hf-space.conf.template
213
+
214
+ COPY services/explorer/dist /usr/share/nginx/explorer
215
+
216
+ COPY --chown=1000:1000 pipeline /app/hf-space-pipeline/pipeline
217
+
218
+ ARG ARTIFACT_S3_URI
219
+ ARG ARTIFACT_S3_REGION=us-east-2
220
+ ARG CORPUS_NAME
221
+ ENV ARTIFACT_S3_URI=${ARTIFACT_S3_URI} \
222
+ ARTIFACT_S3_REGION=${ARTIFACT_S3_REGION} \
223
+ CORPUS_NAME=${CORPUS_NAME}
224
+
225
+ # ─── deepsearch-api config baked for this corpus/run ────────────────────────
226
+ # Same as docker/Dockerfile.hf-space's equivalent block, minus every
227
+ # NVCF-specific ARG/env (SIGLIP2_NVCF_ENDPOINT/FUNCTION_ID and their
228
+ # downstream SIGLIP2_TRITON_SERVER_* values) β€” this variant never talks to
229
+ # NVCF at all. SIGLIP2_TRITON_SERVER_URL instead points at the local
230
+ # siglip2-triton process's gRPC port (8001, loopback-only β€” nginx remains
231
+ # the sole externally-proxied port, same as every other backend process in
232
+ # this image), with no SSL and no auth headers, since nothing external ever
233
+ # reaches it.
234
+ ARG ES_NAME
235
+ ARG S3_STORAGE_BUCKET_NAME
236
+ ARG S3_STORAGE_REGION_NAME=us-west-2
237
+ ARG STORAGE_BACKEND_TYPE=s3
238
+ # URL mapping (hf:// -> https://huggingface.co/.../blob/<rev>/), derived per
239
+ # run from the corpus's own HF repos by pipeline/bake.py's
240
+ # hf_url_mapping_rules() and passed in by bake (--build-arg) and
241
+ # deploy-space (Space variables). Defaults to off: an s3:// corpus needs no
242
+ # rewrite, and a storage_api build without these args would otherwise map
243
+ # to some other dataset's prefix.
244
+ ARG USDSEARCH_URL_MAPPING_MODE=off
245
+ ARG USDSEARCH_URL_MAPPING_RULES=[]
246
+ ENV STORAGE_BACKEND_TYPE=${STORAGE_BACKEND_TYPE} \
247
+ S3_STORAGE_BUCKET_NAME=${S3_STORAGE_BUCKET_NAME} \
248
+ S3_STORAGE_REGION_NAME=${S3_STORAGE_REGION_NAME} \
249
+ opensearch_host=http://localhost:9200 \
250
+ opensearch_index_name=${ES_NAME}-ver5.0 \
251
+ opensearch_image_cache_index_name=${ES_NAME}-ver4.0-image-cache \
252
+ opensearch_use_ssl=false \
253
+ REDIS_URL=redis://localhost:6379 \
254
+ DEEPSEARCH_BACKEND_ASSET_GRAPH_SERVICE_URL=http://127.0.0.1:8010 \
255
+ ASSET_GRAPH_SERVICE_ENDPOINT=http://127.0.0.1:8010 \
256
+ ENABLE_ACCESS_VERIFICATION=False \
257
+ STORAGE_REQUIRE_AUTH=False \
258
+ VERIFY_ASSET_EXISTENCE=False \
259
+ verify_access=false \
260
+ ASSERT_ADMIN_USER=false \
261
+ USDSEARCH_VISION_VALIDATION_ENABLED=false \
262
+ USDSEARCH_LLM_PARSING_ENABLED=false \
263
+ USDSEARCH_LLM_PARSING_FIELDS_FILEPATH=/app/search_fields.yaml \
264
+ OTEL_SDK_DISABLED=true \
265
+ BACKEND_CONFIG_STR="backend_name: es-sample" \
266
+ DEFAULT_CUTOFF_THRESHOLD=0 \
267
+ HYBRID_SEARCH_SCORING_CONFIG_VECTOR_FIELDS="{\"siglip2-embedding.embedding\":{\"enabled\":true,\"weight\":1,\"field_name\":\"siglip2-embedding.embedding\",\"dimension\":1536}}" \
268
+ USDSEARCH_URL_MAPPING_MODE=${USDSEARCH_URL_MAPPING_MODE} \
269
+ USDSEARCH_URL_MAPPING_RULES="${USDSEARCH_URL_MAPPING_RULES}" \
270
+ SIGLIP2_TRITON_SERVER_URL=localhost:8001 \
271
+ TRITON_SERVER_URL=localhost:8001 \
272
+ SIGLIP2_TRITON_SERVER_SSL=false \
273
+ PORT=7860
274
+
275
+ ARG RUN_ID
276
+ ARG CORPUS_SHA256
277
+ ARG USDSEARCH_GIT_SHA
278
+ LABEL org.opencontainers.image.title="usdsearch-hf-space-simready-central-gpu" \
279
+ org.opencontainers.image.description="Single-container MVP (OpenSearch+Neo4j+Redis+deepsearch-api, SigLIP2 via local Triton on GPU) for Hugging Face Spaces" \
280
+ com.nvidia.usdsearch.run_id="${RUN_ID}" \
281
+ com.nvidia.usdsearch.corpus_sha256="${CORPUS_SHA256}" \
282
+ com.nvidia.usdsearch.usdsearch_git_sha="${USDSEARCH_GIT_SHA}"
283
+
284
+ # Longer start-period than docker/Dockerfile.hf-space's 10m: this variant
285
+ # additionally waits on Triton loading ~3.6 GB of ONNX weights onto the GPU
286
+ # before deepsearch-api's own uvicorn execs (see supervisord-gpu.conf).
287
+ # Verified live against a small (1488-asset) dev corpus: Triton model load
288
+ # took ~10s and the whole stack was healthy within ~20s total β€” 15m is
289
+ # deliberately still a generous upper bound, not a tight one, since a larger
290
+ # production corpus's OpenSearch bulk load / Neo4j offline load (the same
291
+ # restore path docker/Dockerfile.hf-space already budgets 10m for) can take
292
+ # much longer than this dev corpus did.
293
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=15m --retries=3 \
294
+ CMD curl -sf "http://localhost:${PORT}/health" || exit 1
295
+
296
+ EXPOSE 7860
297
+ ENTRYPOINT ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisor/conf.d/hf-space.conf"]
README.md CHANGED
@@ -1,12 +1,21 @@
1
  ---
2
- title: Simready Central
3
- emoji: 🏒
4
- colorFrom: red
5
- colorTo: indigo
6
  sdk: docker
7
- pinned: false
8
- license: apache-2.0
9
- short_description: SimReady Central
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: SimReady Central USD Search
3
+ emoji: πŸ”Ž
4
+ colorFrom: green
5
+ colorTo: blue
6
  sdk: docker
7
+ app_port: 7860
8
+ hardware: t4-medium
 
9
  ---
10
 
11
+ # SimReady Central USD Search
12
+
13
+ SimReady Central USD Search β€” single-container demo (OpenSearch + Neo4j +
14
+ Redis + deepsearch-api + asset-graph-service, SigLIP2 embeddings via a local
15
+ siglip2-triton process β€” see docker/Dockerfile.hf-space-gpu). Runs on a GPU
16
+ hardware tier (t4-medium), unlike the free-tier
17
+ docker/Dockerfile.hf-space variant, which routes SigLIP2 to NVCF instead.
18
+ The index itself is pulled from the artifact S3 bucket at container
19
+ startup, not baked in. Deployed by
20
+ github.com/NVIDIA-dev/simready-central-indexing-internal's
21
+ `python -m pipeline.run_pipeline deploy-space-gpu`.
docker/hf-space/logging.yml ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copied verbatim from ../usdsearch's own logging.yml (root of that repo,
2
+ # COPY'd to /logging.yml by its docker/Dockerfile.usdsearch) β€” the wheels in
3
+ # docker/hf-space/wheels/ carry the app code but not this repo-level config
4
+ # file, and packages/base-logging's get_logging_config() hardcodes
5
+ # /logging.yml as its default path ($LOGGING_CONFIG overrides it), so it has
6
+ # to be shipped here too or every service falls back to Python's bare
7
+ # default logging config (harmless β€” base_logging.setup_logging() catches
8
+ # the FileNotFoundError and just warns β€” but loses uvicorn's access-log
9
+ # formatting and the per-logger level tuning below, e.g. quieting
10
+ # opensearch/aiohttp.access to WARNING). Re-copy from ../usdsearch's
11
+ # logging.yml if it changes upstream; nothing here is HF-Space-specific.
12
+ #
13
+ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
14
+ # SPDX-License-Identifier: Apache-2.0
15
+ #
16
+ # Licensed under the Apache License, Version 2.0 (the "License");
17
+ # you may not use this file except in compliance with the License.
18
+ # You may obtain a copy of the License at
19
+ #
20
+ # http://www.apache.org/licenses/LICENSE-2.0
21
+ #
22
+ # Unless required by applicable law or agreed to in writing, software
23
+ # distributed under the License is distributed on an "AS IS" BASIS,
24
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
25
+ # See the License for the specific language governing permissions and
26
+ # limitations under the License.
27
+
28
+ version: 1
29
+ disable_existing_loggers: False
30
+ formatters:
31
+ default:
32
+ "()": uvicorn.logging.DefaultFormatter
33
+ format: '%(levelprefix)s %(asctime)s %(name)s - %(message)s'
34
+ access:
35
+ "()": uvicorn.logging.AccessFormatter
36
+ format: '%(levelprefix)s %(asctime)s %(name)s - %(client_addr)s - "%(request_line)s" %(status_code)s'
37
+ handlers:
38
+ default:
39
+ formatter: default
40
+ class: logging.StreamHandler
41
+ stream: ext://sys.stderr
42
+ access:
43
+ formatter: access
44
+ class: logging.StreamHandler
45
+ stream: ext://sys.stdout
46
+ loggers:
47
+ root:
48
+ level: INFO
49
+ handlers:
50
+ - default
51
+ idl.service:
52
+ level: WARNING
53
+ opensearch:
54
+ level: WARNING
55
+ aiohttp.access:
56
+ level: WARNING
57
+ uvicorn.error:
58
+ level: INFO
59
+ handlers:
60
+ - default
61
+ propagate: no
62
+ uvicorn.access:
63
+ level: INFO
64
+ handlers:
65
+ - access
66
+ propagate: no
67
+ search_backend_observability:
68
+ level: INFO
69
+ handlers:
70
+ - default
71
+ propagate: no
72
+ websockets.client:
73
+ level: INFO
74
+ opentelemetry.sdk.trace:
75
+ level: WARNING
76
+ deepsearch_api.request_duration_logger:
77
+ level: INFO
78
+ handlers:
79
+ - default
80
+ propagate: no
docker/hf-space/nginx.conf.template ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Friendly external paths remapped to deepsearch-api's/asset-graph-service's
2
+ # actual routes β€” same mapping as
3
+ # usdsearch/helm/usdsearch/templates/api_gateway_config_map.yaml.
4
+ # deepsearch-api listens on 127.0.0.1:8000, asset-graph-service on
5
+ # 127.0.0.1:8010 (see docker/hf-space/supervisord.conf) β€” nginx is the sole
6
+ # process bound to the externally proxied port, so it can front every route
7
+ # with these rewrites plus CORS.
8
+ #
9
+ # The Explorer UI is served at `/` (root), not proxied to deepsearch-api at
10
+ # all β€” served directly from the static bundle baked into this image at
11
+ # build time (see the Dockerfile). Real difference from the multi-service
12
+ # deployment, which proxies /ui and /static/ to a *separate*
13
+ # deepsearch-explorer container: that bundle is just static files with
14
+ # same-origin relative API calls (REACT_APP_API_URL baked empty) and its
15
+ # own assets already use root-absolute paths (/static/js/..., /ds.png), so
16
+ # serving it directly at `/` β€” where it already expects to live β€” needs no
17
+ # extra process and no /ui prefix-stripping trick. The tradeoff: unlike the
18
+ # old single catch-all proxy, deepsearch-api's own root-level routes
19
+ # (/health, /docs, /redoc, /openapi.json, /metrics) now need to be listed
20
+ # explicitly below, since the final `location /` falls through to the SPA
21
+ # instead. Also sidesteps a real problem seen live: a Space deployed with
22
+ # `/` returning a 301 (redirecting to `/ui`, the previous approach) broke β€”
23
+ # root cause not fully confirmed, but consistent with Hugging Face's own
24
+ # readiness check wanting a real 200 at `/`, not a redirect.
25
+ #
26
+ # __PORT__ is substituted with $PORT at container start (supervisord.conf),
27
+ # matching the Spaces convention of an operator-configurable app_port.
28
+
29
+ # >>> simready-central slim explorer (http) >>>
30
+ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
31
+ # SPDX-License-Identifier: Apache-2.0
32
+ # Included from nginx.conf's http{} (conf.d/*.conf) β€” an http-level directive is legal here.
33
+ # ONE level under /tmp on purpose: nginx mkdir()s only the last path component (no mkdir -p), and
34
+ # nothing in this image pre-creates a parent β€” /tmp/nginx-cache/slim-stats fails `nginx -t`. The
35
+ # `levels=` subdirectories are different: nginx creates those itself on the first write.
36
+ proxy_cache_path /tmp/slim-stats-cache levels=1 keys_zone=slim_stats_cache:2m max_size=256m inactive=1h use_temp_path=off;
37
+ # Thumbnails (/images): ~1,500 JPEGs of 27-52 KB (~75 MB) fit well inside 1g. Lives in the container's
38
+ # /tmp, so every deploy, restart or sleep starts it cold.
39
+ proxy_cache_path /tmp/slim-images-cache levels=1:2 keys_zone=slim_images_cache:10m max_size=1g inactive=7d use_temp_path=off;
40
+ # <<< simready-central slim explorer (http) <<<
41
+ server {
42
+ listen __PORT__;
43
+
44
+ proxy_http_version 1.1;
45
+ proxy_set_header Connection "";
46
+ proxy_connect_timeout 5s;
47
+ proxy_send_timeout 60s;
48
+ proxy_read_timeout 60s;
49
+ proxy_set_header Host $host;
50
+ proxy_set_header X-Real-IP $remote_addr;
51
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
52
+ proxy_set_header X-Forwarded-Proto $scheme;
53
+
54
+ add_header Access-Control-Allow-Origin "*" always;
55
+ add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
56
+ add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
57
+
58
+ location /search {
59
+ if ($request_method = OPTIONS) { add_header Content-Length 0 always; add_header Content-Type "text/plain" always; return 204; }
60
+ rewrite ^/search(.*)$ /v2/deepsearch/search$1 break;
61
+ proxy_pass http://127.0.0.1:8000;
62
+ }
63
+
64
+ location /search_hybrid {
65
+ if ($request_method = OPTIONS) { add_header Content-Length 0 always; add_header Content-Type "text/plain" always; return 204; }
66
+ rewrite ^/search_hybrid(.*)$ /v3/deepsearch/search$1 break;
67
+ proxy_pass http://127.0.0.1:8000;
68
+ }
69
+
70
+ location /vlm_validate/search_result {
71
+ if ($request_method = OPTIONS) { add_header Content-Length 0 always; add_header Content-Type "text/plain" always; return 204; }
72
+ rewrite ^/vlm_validate/search_result(.*)$ /v3/deepsearch/vlm_validate/search_result$1 break;
73
+ proxy_pass http://127.0.0.1:8000;
74
+ }
75
+
76
+ location /llm_parse/ {
77
+ if ($request_method = OPTIONS) { add_header Content-Length 0 always; add_header Content-Type "text/plain" always; return 204; }
78
+ rewrite ^/llm_parse/(.*)$ /v3/deepsearch/llm_parse/$1 break;
79
+ proxy_pass http://127.0.0.1:8000;
80
+ }
81
+
82
+ location /download {
83
+ if ($request_method = OPTIONS) { add_header Content-Length 0 always; add_header Content-Type "text/plain" always; return 204; }
84
+ rewrite ^/download(.*)$ /v3/download$1 break;
85
+ proxy_pass http://127.0.0.1:8000;
86
+ # Bundling an asset + all its dependencies can take longer than a single thumbnail.
87
+ proxy_read_timeout 300s;
88
+ }
89
+
90
+ location /images {
91
+ if ($request_method = OPTIONS) { add_header Content-Length 0 always; add_header Content-Type "text/plain" always; return 204; }
92
+ rewrite ^/images(.*)$ /v3/images$1 break;
93
+ proxy_pass http://127.0.0.1:8000;
94
+ }
95
+
96
+ location /static/ {
97
+ alias /usr/share/nginx/explorer/static/;
98
+ }
99
+
100
+ # asset-graph-service routes (query-time graph reads only β€” see
101
+ # docker/Dockerfile.hf-space's header for why there's no graph-builder/
102
+ # asset_graph_generation worker in this image). Same rewrites as the
103
+ # real gateway config's asset_graph_service locations, just proxying to
104
+ # our own internal port instead of a separate service/Space.
105
+ location /asset_graph/usd/scene_summary/ {
106
+ if ($request_method = OPTIONS) { add_header Content-Length 0 always; add_header Content-Type "text/plain" always; return 204; }
107
+ rewrite ^/asset_graph/usd/scene_summary/(.*)$ /asset_graph/usd/scene_summary/$1 break;
108
+ proxy_pass http://127.0.0.1:8010;
109
+ }
110
+
111
+ location /asset_graph/usd/scene_summary {
112
+ if ($request_method = OPTIONS) { add_header Content-Length 0 always; add_header Content-Type "text/plain" always; return 204; }
113
+ rewrite ^/asset_graph/usd/scene_summary(.*)$ /asset_graph/usd/scene_summary/$1 break;
114
+ proxy_pass http://127.0.0.1:8010;
115
+ }
116
+
117
+ location /asset_graph/usd/prims {
118
+ if ($request_method = OPTIONS) { add_header Content-Length 0 always; add_header Content-Type "text/plain" always; return 204; }
119
+ rewrite ^/asset_graph/usd/prims(.*)$ /asset_graph/usd/prims$1 break;
120
+ proxy_pass http://127.0.0.1:8010;
121
+ }
122
+
123
+ location /dependency_graph {
124
+ if ($request_method = OPTIONS) { add_header Content-Length 0 always; add_header Content-Type "text/plain" always; return 204; }
125
+ rewrite ^/dependency_graph(.*)$ /dependency_graph$1 break;
126
+ proxy_pass http://127.0.0.1:8010;
127
+ }
128
+
129
+ # deepsearch-api's own root-level routes β€” explicit now that `location /`
130
+ # below serves the Explorer SPA instead of proxying everything
131
+ # unmatched. /docs and /redoc are prefix matches, not exact: Swagger
132
+ # UI/ReDoc can request sub-resources under their own path.
133
+ location = /health { proxy_pass http://127.0.0.1:8000; }
134
+ location = /openapi.json { proxy_pass http://127.0.0.1:8000; }
135
+ location = /metrics { proxy_pass http://127.0.0.1:8000; }
136
+ location /docs { proxy_pass http://127.0.0.1:8000; }
137
+ location /redoc { proxy_pass http://127.0.0.1:8000; }
138
+
139
+ # The Explorer SPA (see header comment) at root: try_files serves any of
140
+ # its own literal files (index.html, ds.png, favicon.ico, manifest.json,
141
+ # ...) directly, and falls back to index.html for anything else
142
+ # (client-side routing) β€” anything actually meant for the API above was
143
+ # already matched by a more specific location before nginx gets here.
144
+ # >>> simready-central slim explorer >>>
145
+ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
146
+ # SPDX-License-Identifier: Apache-2.0
147
+ # services/explorer/dist is SimReady Central's search-only Explorer (repo simready-central,
148
+ # branch slim, slim/devspace/). The locations here are additive β€” exact matches, longer
149
+ # prefixes or a regex β€” and none of his locations is redefined. This block carries: /config.js,
150
+ # a no-cache /index.html, a 10-min
151
+ # proxy cache on /search/stats/usd_properties, a shared proxy cache on /images,
152
+ # /wikidata_hierarchy.json and JSON 404s for the routes this build does not have. What DOES
153
+ # reach beyond it: client_max_body_size 32m is server-wide (it applies to ALL his locations
154
+ # too); `= /images` takes the requests his `/images` prefix served before (same rewrite +
155
+ # proxy_pass, plus the cache); and an
156
+ # add_header inside one of our locations replaces his server-level headers for THOSE responses
157
+ # β€” /images and the stats cache re-declare his three CORS lines, /config.js and /index.html
158
+ # (same-origin UI files) do not. Applied by slim/devspace/deploy.sh.
159
+ client_max_body_size 32m; # query-image searches POST a base64 JPEG (nginx default 1 MB)
160
+ gzip_types text/css application/javascript application/json image/svg+xml; # stock gzip covers text/html only
161
+
162
+ # Runtime index registry (blocking <script src="/config.js">). The CORPUS_NAME marker is
163
+ # rendered by the same sed that renders the PORT marker (supervisord.conf). download:false β€”
164
+ # /download/asset needs the Storage API this image does not run. seedDefaultScope:false β€”
165
+ # boot browses the corpus.
166
+ # railFilters ["profiles","license","category"] + railDefaults ["profiles","license"]: rail = Profiles + License on,
167
+ # the Wikidata Category tree an OPT-IN row in Preferences β€Ί Select Filters (owner, 2026-09-21); the parser catalog
168
+ # docker/hf-space/search_fields.yaml stays curated to profile + license. propertyAliases: this API stores the
169
+ # Wikidata code as semantic:wikidata_qcode:params:semanticData (wikidata_qcode= matches nothing here), so the
170
+ # Explorer translates its canonical name at the wire and renames the stats key back on arrival.
171
+ # propertyFilterCombine "and": the API ORs every filter_by_properties_include_any token globally, so with License
172
+ # pre-checked a Category pick would change nothing β€” a property with ONE checked value rides filter_by_properties
173
+ # (AND) instead; a multi-valued one (the tree's qcodes) stays the OR bucket. Measured: license 1488, one qcode 124,
174
+ # both in include_any 1488, license AND qcode 124.
175
+ location = /config.js {
176
+ default_type application/javascript;
177
+ add_header Cache-Control "no-cache, must-revalidate" always;
178
+ return 200 '(function(){var c="__CORPUS_NAME__";if(!c||c.indexOf("__")===0){c="usdsearch-dev";}window.__SERVER_MAPPING__={"usdsearch-dev":{"name":c+" (Hugging Face)","apiUrl":"","embedding_config":{"field_name":"siglip2-embedding.embedding","dimension":1536},"features":{"download":false,"s3UrlRemap":false,"seedDefaultScope":false,"railFilters":["profiles","license","category"],"railDefaults":["profiles","license"],"propertyAliases":{"wikidata_qcode":"semantic:wikidata_qcode:params:semanticData"},"propertyFilterCombine":"and","defaultFilters":{"simready_metadata_asset_license":["CC-BY-4.0"]}}}};window.__DEFAULT_SERVER__="usdsearch-dev";})();';
179
+ }
180
+ # index.html must always revalidate (covers the try_files fallback too): a swap or rollback
181
+ # must not leave a browser holding an index.html that names deleted chunks.
182
+ location = /index.html {
183
+ root /usr/share/nginx/explorer;
184
+ add_header Cache-Control "no-cache" always;
185
+ }
186
+
187
+ # Property inventory (rail facets + parser grounding): multi-MB aggregation β€” cached 10 min.
188
+ location = /search/stats/usd_properties {
189
+ if ($request_method = OPTIONS) { add_header Content-Length 0 always; add_header Content-Type "text/plain" always; return 204; }
190
+ rewrite ^/search(.*)$ /v2/deepsearch/search$1 break;
191
+ proxy_pass http://127.0.0.1:8000;
192
+ proxy_ignore_headers Cache-Control Expires;
193
+ proxy_cache slim_stats_cache;
194
+ proxy_cache_key "$request_uri";
195
+ proxy_cache_valid 200 10m;
196
+ proxy_cache_lock on;
197
+ proxy_cache_use_stale error timeout http_500 http_502 http_503 http_504;
198
+ proxy_read_timeout 120s;
199
+ add_header X-Cache-Status $upstream_cache_status always;
200
+ # add_header above drops his server-level headers β€” re-declared as in his template
201
+ add_header Access-Control-Allow-Origin "*" always;
202
+ add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
203
+ add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
204
+ }
205
+ # Thumbnails: his `location /images` rewrite + proxy_pass (this exact match wins over his prefix)
206
+ # plus a SHARED cache β€” every cold /images costs the API ~150 CPU-ms on its one event loop.
207
+ # Keyed by the full URI (image_key, or asset_url + img_offset); the API's `Cache-Control: private`
208
+ # is ignored on purpose. Valid ONLY while the image runs with STORAGE_REQUIRE_AUTH=False (deploy.sh
209
+ # refuses a hub Dockerfile without it): with per-user storage auth, add
210
+ # `proxy_no_cache $arg_asset_url; proxy_cache_bypass $arg_asset_url;` so the access-checked
211
+ # asset_url path is never shared (image_key is content-addressed and unchecked). add_header here
212
+ # drops his server-level headers, so his three CORS lines are re-declared; proxy_hide_header
213
+ # keeps a chained nginx (check.sh --replica) to its own X-Cache-Status.
214
+ location = /images {
215
+ if ($request_method = OPTIONS) { add_header Content-Length 0 always; add_header Content-Type "text/plain" always; return 204; }
216
+ rewrite ^/images(.*)$ /v3/images$1 break;
217
+ proxy_pass http://127.0.0.1:8000;
218
+ proxy_cache slim_images_cache;
219
+ proxy_cache_key "$request_uri";
220
+ proxy_ignore_headers Cache-Control Expires;
221
+ proxy_hide_header X-Cache-Status;
222
+ proxy_cache_valid 200 7d;
223
+ proxy_cache_lock on;
224
+ proxy_cache_lock_timeout 15s;
225
+ proxy_cache_use_stale error timeout updating;
226
+ add_header X-Cache-Status $upstream_cache_status always;
227
+ add_header Access-Control-Allow-Origin "*" always;
228
+ add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
229
+ add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
230
+ }
231
+ # Our 404s are JSON like the API's own (a bare `return 404;` would be nginx's text/html error
232
+ # page): reachable only from the locations below, so nothing of his changes.
233
+ location @slim_not_found {
234
+ default_type application/json;
235
+ return 404 '{"detail":"Not Found"}';
236
+ }
237
+ # Per-corpus Wikidata hierarchy β€” the Category tree's ancestry: the bundle ships the corpus's file at its root
238
+ # (slim/scripts/build-explorer-public.sh, since 2026-09-21); absent β†’ a real 404 and the Explorer falls back.
239
+ location = /wikidata_hierarchy.json {
240
+ root /usr/share/nginx/explorer;
241
+ try_files $uri @slim_not_found;
242
+ }
243
+ # The streaming validate shim does not exist here: a real 404 pins per-image validation through
244
+ # /vlm_validate/search_result (longer prefix, still his); a 502 or index.html would disable it.
245
+ location /vlm_validate/ {
246
+ default_type application/json;
247
+ return 404 '{"detail":"Not Found"}';
248
+ }
249
+ # Central's per-user surface is absent in this build: hard 404s, never the SPA's index.html.
250
+ location ~ ^/(auth|packs|prefs|chats|created_assets|concept_images|meshy_quota|info|process|folder_hierarchy\.json|api/create-chat|api/cad2simready)(/|$) {
251
+ default_type application/json;
252
+ return 404 '{"detail":"Not Found"}';
253
+ }
254
+ # <<< simready-central slim explorer <<<
255
+ location / {
256
+ root /usr/share/nginx/explorer;
257
+ try_files $uri $uri/ /index.html;
258
+ }
259
+ }
docker/hf-space/search_fields.yaml ADDED
@@ -0,0 +1,599 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ #
4
+ # Trimmed to the two fields the SimReady Central slim Explorer's rail actually
5
+ # exposes (features.railFilters in docker/hf-space/nginx.conf.template's
6
+ # /config.js: Profiles + License) β€” see main@8c555a9dc920 ("Serve SimReady
7
+ # Central's slim Explorer at / β€” Profiles + License filtering..."), which
8
+ # replaced the full auto-derived catalog below with exactly these two for the
9
+ # same reason: deepsearch_api.llm_parse.fields loads this file for BOTH the
10
+ # LLM query parser and the catalog-driven rail facets, so a wider catalog here
11
+ # widens the parser AND the rail. `label:` on each field is preserved from
12
+ # that commit for the same reason (Explorer rail display name).
13
+ #
14
+ # `simready_metadata_mass`/`simready_metadata_rigid_body_count` added
15
+ # parser-only (no `label:`) β€” neither is in nginx.conf.template's
16
+ # railFilters allow-list, so they're invisible to the rail regardless of
17
+ # being registered here; only the LLM query parser picks them up. Verified
18
+ # live via /search/stats/usd_properties: mass is numeric (kg), continuous
19
+ # (1407 distinct values across 1484 of 1488 assets, range 0.001-217652.74,
20
+ # median ~1.77); rigid_body_count is numeric (integer), 37 distinct values
21
+ # across 1484 of 1488 assets, range 1-106, most common value 1 (904
22
+ # assets).
23
+ #
24
+ # The rest of the auto-generated catalog (40 fields total; regenerate via
25
+ # whatever produced this file's original derivation from the corpus's USD
26
+ # properties) is kept below, commented out, in case LLM-query-parsing over
27
+ # the full property set is wanted again later β€” see the header this replaces
28
+ # for how to regenerate it.
29
+ fields:
30
+ - name: simready_metadata_validation_profile
31
+ type: string
32
+ operators:
33
+ - eq
34
+ - contains
35
+ description: 'Auto-generated from USD property ''simready_metadata_validation__profile'' (1488 assets).
36
+ Common values: ''Robotics-Prop'' (1484), ''Prop-Robotics-Physx'' (4).'
37
+ examples:
38
+ - '"simready_metadata_validation_profile is Robotics-Prop" -> simready_metadata_validation_profile eq
39
+ "Robotics-Prop"'
40
+ label: Profiles
41
+ map:
42
+ kind: property_match
43
+ property: simready_metadata_validation__profile
44
+ - name: simready_metadata_asset_license
45
+ type: string
46
+ operators:
47
+ - eq
48
+ - contains
49
+ description: 'Auto-generated from USD property ''simready_metadata_asset_license'' (1488 assets). Common
50
+ values: ''CC-BY-4.0''.'
51
+ examples:
52
+ - '"simready_metadata_asset_license is CC-BY-4.0" -> simready_metadata_asset_license eq "CC-BY-4.0"'
53
+ label: License
54
+ map:
55
+ kind: property_match
56
+ property: simready_metadata_asset_license
57
+ - name: simready_metadata_mass
58
+ type: number
59
+ operators:
60
+ - gt
61
+ - gte
62
+ - lt
63
+ - lte
64
+ - eq
65
+ description: 'Auto-generated from USD property ''simready_metadata_mass'' (1484 of 1488 assets). Mass
66
+ in kilograms. Range 0.001-217652.74, median ~1.77.'
67
+ examples:
68
+ - '"simready_metadata_mass over 10" -> simready_metadata_mass gt 10'
69
+ map:
70
+ kind: property_numeric
71
+ property: simready_metadata_mass
72
+ - name: simready_metadata_rigid_body_count
73
+ type: number
74
+ operators:
75
+ - gt
76
+ - gte
77
+ - lt
78
+ - lte
79
+ - eq
80
+ description: 'Auto-generated from USD property ''simready_metadata_rigid_body_count'' (1484 of 1488
81
+ assets). Number of rigid bodies in the physics simulation. Integer, range 1-106, most common value 1
82
+ (904 assets).'
83
+ examples:
84
+ - '"simready_metadata_rigid_body_count over 5" -> simready_metadata_rigid_body_count gt 5'
85
+ map:
86
+ kind: property_numeric
87
+ property: simready_metadata_rigid_body_count
88
+
89
+ # --- auto-derived fields below, commented out (see header) ---
90
+ # - name: polygon_count_436
91
+ # type: number
92
+ # operators:
93
+ # - gt
94
+ # - gte
95
+ # - lt
96
+ # - lte
97
+ # - eq
98
+ # description: Auto-generated from USD property '__polygon_count' (1492 assets).
99
+ # examples:
100
+ # - '"polygon_count_436 over 10" -> polygon_count_436 gt 10'
101
+ # map:
102
+ # kind: property_numeric
103
+ # property: __polygon_count
104
+ # - name: simready_metadata_variants_physics_mu_jo_co_activate_option
105
+ # type: string
106
+ # operators:
107
+ # - eq
108
+ # - contains
109
+ # description: 'Auto-generated from USD property ''simready_metadata_Variants__Physics__MuJoCo__activateOption''
110
+ # (1492 assets). Common values: ''Enabled''.'
111
+ # examples:
112
+ # - '"simready_metadata_variants_physics_mu_jo_co_activate_option is Enabled" -> simready_metadata_variants_physics_mu_jo_co_activate_option
113
+ # eq "Enabled"'
114
+ # map:
115
+ # kind: property_match
116
+ # property: simready_metadata_Variants__Physics__MuJoCo__activateOption
117
+ # - name: simready_metadata_variants_physics_mu_jo_co_prim
118
+ # type: string
119
+ # operators:
120
+ # - eq
121
+ # - contains
122
+ # description: 'Auto-generated from USD property ''simready_metadata_Variants__Physics__MuJoCo__prim''
123
+ # (1492 assets). Common values: ''/RootNode''.'
124
+ # examples:
125
+ # - '"simready_metadata_variants_physics_mu_jo_co_prim is /RootNode" -> simready_metadata_variants_physics_mu_jo_co_prim
126
+ # eq "/RootNode"'
127
+ # map:
128
+ # kind: property_match
129
+ # property: simready_metadata_Variants__Physics__MuJoCo__prim
130
+ # - name: simready_metadata_variants_physics_mu_jo_co_variant_set_name
131
+ # type: string
132
+ # operators:
133
+ # - eq
134
+ # - contains
135
+ # description: 'Auto-generated from USD property ''simready_metadata_Variants__Physics__MuJoCo__variantSetName''
136
+ # (1492 assets). Common values: ''MuJoCo''.'
137
+ # examples:
138
+ # - '"simready_metadata_variants_physics_mu_jo_co_variant_set_name is MuJoCo" -> simready_metadata_variants_physics_mu_jo_co_variant_set_name
139
+ # eq "MuJoCo"'
140
+ # map:
141
+ # kind: property_match
142
+ # property: simready_metadata_Variants__Physics__MuJoCo__variantSetName
143
+ # - name: simready_metadata_variants_physics_newton_activate_option
144
+ # type: string
145
+ # operators:
146
+ # - eq
147
+ # - contains
148
+ # description: 'Auto-generated from USD property ''simready_metadata_Variants__Physics__Newton__activateOption''
149
+ # (1492 assets). Common values: ''Enabled''.'
150
+ # examples:
151
+ # - '"simready_metadata_variants_physics_newton_activate_option is Enabled" -> simready_metadata_variants_physics_newton_activate_option
152
+ # eq "Enabled"'
153
+ # map:
154
+ # kind: property_match
155
+ # property: simready_metadata_Variants__Physics__Newton__activateOption
156
+ # - name: simready_metadata_variants_physics_newton_prim
157
+ # type: string
158
+ # operators:
159
+ # - eq
160
+ # - contains
161
+ # description: 'Auto-generated from USD property ''simready_metadata_Variants__Physics__Newton__prim''
162
+ # (1492 assets). Common values: ''/RootNode''.'
163
+ # examples:
164
+ # - '"simready_metadata_variants_physics_newton_prim is /RootNode" -> simready_metadata_variants_physics_newton_prim
165
+ # eq "/RootNode"'
166
+ # map:
167
+ # kind: property_match
168
+ # property: simready_metadata_Variants__Physics__Newton__prim
169
+ # - name: simready_metadata_variants_physics_newton_variant_set_name
170
+ # type: string
171
+ # operators:
172
+ # - eq
173
+ # - contains
174
+ # description: 'Auto-generated from USD property ''simready_metadata_Variants__Physics__Newton__variantSetName''
175
+ # (1492 assets). Common values: ''Newton''.'
176
+ # examples:
177
+ # - '"simready_metadata_variants_physics_newton_variant_set_name is Newton" -> simready_metadata_variants_physics_newton_variant_set_name
178
+ # eq "Newton"'
179
+ # map:
180
+ # kind: property_match
181
+ # property: simready_metadata_Variants__Physics__Newton__variantSetName
182
+ # - name: simready_metadata_variants_physics_phys_x_activate_option
183
+ # type: string
184
+ # operators:
185
+ # - eq
186
+ # - contains
187
+ # description: 'Auto-generated from USD property ''simready_metadata_Variants__Physics__PhysX__activateOption''
188
+ # (1492 assets). Common values: ''Enabled''.'
189
+ # examples:
190
+ # - '"simready_metadata_variants_physics_phys_x_activate_option is Enabled" -> simready_metadata_variants_physics_phys_x_activate_option
191
+ # eq "Enabled"'
192
+ # map:
193
+ # kind: property_match
194
+ # property: simready_metadata_Variants__Physics__PhysX__activateOption
195
+ # - name: simready_metadata_variants_physics_phys_x_prim
196
+ # type: string
197
+ # operators:
198
+ # - eq
199
+ # - contains
200
+ # description: 'Auto-generated from USD property ''simready_metadata_Variants__Physics__PhysX__prim''
201
+ # (1492 assets). Common values: ''/RootNode''.'
202
+ # examples:
203
+ # - '"simready_metadata_variants_physics_phys_x_prim is /RootNode" -> simready_metadata_variants_physics_phys_x_prim
204
+ # eq "/RootNode"'
205
+ # map:
206
+ # kind: property_match
207
+ # property: simready_metadata_Variants__Physics__PhysX__prim
208
+ # - name: simready_metadata_variants_physics_phys_x_variant_set_name
209
+ # type: string
210
+ # operators:
211
+ # - eq
212
+ # - contains
213
+ # description: 'Auto-generated from USD property ''simready_metadata_Variants__Physics__PhysX__variantSetName''
214
+ # (1492 assets). Common values: ''PhysX''.'
215
+ # examples:
216
+ # - '"simready_metadata_variants_physics_phys_x_variant_set_name is PhysX" -> simready_metadata_variants_physics_phys_x_variant_set_name
217
+ # eq "PhysX"'
218
+ # map:
219
+ # kind: property_match
220
+ # property: simready_metadata_Variants__Physics__PhysX__variantSetName
221
+ # - name: simready_metadata_asset_type
222
+ # type: string
223
+ # operators:
224
+ # - eq
225
+ # - contains
226
+ # description: 'Auto-generated from USD property ''simready_metadata_asset_type'' (1492 assets). Common
227
+ # values: ''prop''.'
228
+ # examples:
229
+ # - '"simready_metadata_asset_type is prop" -> simready_metadata_asset_type eq "prop"'
230
+ # map:
231
+ # kind: property_match
232
+ # property: simready_metadata_asset_type
233
+ # - name: simready_metadata_validation_profile_version
234
+ # type: string
235
+ # operators:
236
+ # - eq
237
+ # - contains
238
+ # description: 'Auto-generated from USD property ''simready_metadata_validation__profile_version'' (1492
239
+ # assets). Common values: ''3.1.0'', ''1.0.0'', ''3.2.0''.'
240
+ # examples:
241
+ # - '"simready_metadata_validation_profile_version is 3.1.0" -> simready_metadata_validation_profile_version
242
+ # eq "3.1.0"'
243
+ # map:
244
+ # kind: property_match
245
+ # property: simready_metadata_validation__profile_version
246
+ # - name: simready_metadata_author
247
+ # type: string
248
+ # operators:
249
+ # - eq
250
+ # - contains
251
+ # description: 'Auto-generated from USD property ''simready_metadata_author'' (1488 assets). Common values:
252
+ # ''NVIDIA''.'
253
+ # examples:
254
+ # - '"simready_metadata_author is NVIDIA" -> simready_metadata_author eq "NVIDIA"'
255
+ # map:
256
+ # kind: property_match
257
+ # property: simready_metadata_author
258
+ # - name: simready_metadata_category
259
+ # type: string
260
+ # operators:
261
+ # - eq
262
+ # - contains
263
+ # description: 'Auto-generated from USD property ''simready_metadata_category'' (1488 assets). Common
264
+ # values: ''prop'', ''candle'', ''book'', ''glasses''.'
265
+ # examples:
266
+ # - '"simready_metadata_category is prop" -> simready_metadata_category eq "prop"'
267
+ # map:
268
+ # kind: property_match
269
+ # property: simready_metadata_category
270
+ # - name: simready_metadata_mass
271
+ # type: number
272
+ # operators:
273
+ # - gt
274
+ # - gte
275
+ # - lt
276
+ # - lte
277
+ # - eq
278
+ # description: Auto-generated from USD property 'simready_metadata_mass' (1488 assets).
279
+ # examples:
280
+ # - '"simready_metadata_mass over 10" -> simready_metadata_mass gt 10'
281
+ # map:
282
+ # kind: property_numeric
283
+ # property: simready_metadata_mass
284
+ # - name: simready_metadata_rigid_body_count
285
+ # type: number
286
+ # operators:
287
+ # - gt
288
+ # - gte
289
+ # - lt
290
+ # - lte
291
+ # - eq
292
+ # description: Auto-generated from USD property 'simready_metadata_rigid_body_count' (1488 assets).
293
+ # examples:
294
+ # - '"simready_metadata_rigid_body_count over 10" -> simready_metadata_rigid_body_count gt 10'
295
+ # map:
296
+ # kind: property_numeric
297
+ # property: simready_metadata_rigid_body_count
298
+ # - name: semantic_wikidata_class_params_semantic_type
299
+ # type: string
300
+ # operators:
301
+ # - eq
302
+ # - contains
303
+ # description: 'Auto-generated from USD property ''semantic:wikidata_class:params:semanticType'' (1264
304
+ # assets). Common values: ''wikidata_class''.'
305
+ # examples:
306
+ # - '"semantic_wikidata_class_params_semantic_type is wikidata_class" -> semantic_wikidata_class_params_semantic_type
307
+ # eq "wikidata_class"'
308
+ # map:
309
+ # kind: property_match
310
+ # property: semantic:wikidata_class:params:semanticType
311
+ # - name: semantic_wikidata_qcode_params_semantic_type
312
+ # type: string
313
+ # operators:
314
+ # - eq
315
+ # - contains
316
+ # description: 'Auto-generated from USD property ''semantic:wikidata_qcode:params:semanticType'' (1264
317
+ # assets). Common values: ''wikidata_qcode''.'
318
+ # examples:
319
+ # - '"semantic_wikidata_qcode_params_semantic_type is wikidata_qcode" -> semantic_wikidata_qcode_params_semantic_type
320
+ # eq "wikidata_qcode"'
321
+ # map:
322
+ # kind: property_match
323
+ # property: semantic:wikidata_qcode:params:semanticType
324
+ # - name: simready_metadata_validation_validated_features_2026_08_18_fet_000_mujoco_dependencies
325
+ # type: string
326
+ # operators:
327
+ # - eq
328
+ # - contains
329
+ # description: 'Auto-generated from USD property ''simready_metadata_validation__validated_features__2026-08-18__FET_000_MUJOCO__dependencies''
330
+ # (1000 assets). Common values: "[{''FET_000_STANDARD'': {''version'': ''0.1.0''}}]".'
331
+ # examples:
332
+ # - '"simready_metadata_validation_validated_features_2026_08_18_fet_000_mujoco_dependencies is [{''FET_000_STANDARD'':
333
+ # {''version'': ''0.1.0''}}]" -> simready_metadata_validation_validated_features_2026_08_18_fet_000_mujoco_dependencies
334
+ # eq "[{''FET_000_STANDARD'': {''version'': ''0.1.0''}}]"'
335
+ # map:
336
+ # kind: property_match
337
+ # property: simready_metadata_validation__validated_features__2026-08-18__FET_000_MUJOCO__dependencies
338
+ # - name: simready_metadata_validation_validated_features_2026_08_18_fet_000_mujoco_optional_requirements
339
+ # type: string
340
+ # operators:
341
+ # - eq
342
+ # - contains
343
+ # description: 'Auto-generated from USD property ''simready_metadata_validation__validated_features__2026-08-18__FET_000_MUJOCO__optional
344
+ # requirements'' (1000 assets). Common values: "[''com.nvidia.simready.RV.010'', ''com.nvidia.simready.RV.008'']",
345
+ # "[''com.nvidia.simready.RV.008'', ''com.nvidia.simready.RV.010'']".'
346
+ # examples:
347
+ # - '"simready_metadata_validation_validated_features_2026_08_18_fet_000_mujoco_optional_requirements
348
+ # is [''com.nvidia.simready.RV.010'', ''com.nvidia.simready.RV.008'']" -> simready_metadata_validation_validated_features_2026_08_18_fet_000_mujoco_optional_requirements
349
+ # eq "[''com.nvidia.simready.RV.010'', ''com.nvidia.simready.RV.008'']"'
350
+ # map:
351
+ # kind: property_match
352
+ # property: simready_metadata_validation__validated_features__2026-08-18__FET_000_MUJOCO__optional requirements
353
+ # - name: simready_metadata_validation_validated_features_2026_08_18_fet_000_mujoco_passed
354
+ # type: boolean
355
+ # operators:
356
+ # - eq
357
+ # - exists
358
+ # description: Auto-generated from USD property 'simready_metadata_validation__validated_features__2026-08-18__FET_000_MUJOCO__passed'
359
+ # (1000 assets).
360
+ # examples:
361
+ # - '"with simready_metadata_validation_validated_features_2026_08_18_fet_000_mujoco_passed" -> simready_metadata_validation_validated_features_2026_08_18_fet_000_mujoco_passed
362
+ # eq true'
363
+ # map:
364
+ # kind: property_exists
365
+ # property: simready_metadata_validation__validated_features__2026-08-18__FET_000_MUJOCO__passed
366
+ # - name: simready_metadata_validation_validated_features_2026_08_18_fet_000_mujoco_version
367
+ # type: string
368
+ # operators:
369
+ # - eq
370
+ # - contains
371
+ # description: 'Auto-generated from USD property ''simready_metadata_validation__validated_features__2026-08-18__FET_000_MUJOCO__version''
372
+ # (1000 assets). Common values: ''0.1.0''.'
373
+ # examples:
374
+ # - '"simready_metadata_validation_validated_features_2026_08_18_fet_000_mujoco_version is 0.1.0" -> simready_metadata_validation_validated_features_2026_08_18_fet_000_mujoco_version
375
+ # eq "0.1.0"'
376
+ # map:
377
+ # kind: property_match
378
+ # property: simready_metadata_validation__validated_features__2026-08-18__FET_000_MUJOCO__version
379
+ # - name: simready_metadata_validation_validated_features_2026_08_18_fet_000_newton_dependencies
380
+ # type: string
381
+ # operators:
382
+ # - eq
383
+ # - contains
384
+ # description: 'Auto-generated from USD property ''simready_metadata_validation__validated_features__2026-08-18__FET_000_NEWTON__dependencies''
385
+ # (1000 assets). Common values: "[{''FET_000_STANDARD'': {''version'': ''0.1.0''}}]".'
386
+ # examples:
387
+ # - '"simready_metadata_validation_validated_features_2026_08_18_fet_000_newton_dependencies is [{''FET_000_STANDARD'':
388
+ # {''version'': ''0.1.0''}}]" -> simready_metadata_validation_validated_features_2026_08_18_fet_000_newton_dependencies
389
+ # eq "[{''FET_000_STANDARD'': {''version'': ''0.1.0''}}]"'
390
+ # map:
391
+ # kind: property_match
392
+ # property: simready_metadata_validation__validated_features__2026-08-18__FET_000_NEWTON__dependencies
393
+ # - name: simready_metadata_validation_validated_features_2026_08_18_fet_000_newton_optional_requirements
394
+ # type: string
395
+ # operators:
396
+ # - eq
397
+ # - contains
398
+ # description: 'Auto-generated from USD property ''simready_metadata_validation__validated_features__2026-08-18__FET_000_NEWTON__optional
399
+ # requirements'' (1000 assets). Common values: "[''com.nvidia.simready.RV.005'', ''com.nvidia.simready.RV.010'']",
400
+ # "[''com.nvidia.simready.RV.010'', ''com.nvidia.simready.RV.005'']".'
401
+ # examples:
402
+ # - '"simready_metadata_validation_validated_features_2026_08_18_fet_000_newton_optional_requirements
403
+ # is [''com.nvidia.simready.RV.005'', ''com.nvidia.simready.RV.010'']" -> simready_metadata_validation_validated_features_2026_08_18_fet_000_newton_optional_requirements
404
+ # eq "[''com.nvidia.simready.RV.005'', ''com.nvidia.simready.RV.010'']"'
405
+ # map:
406
+ # kind: property_match
407
+ # property: simready_metadata_validation__validated_features__2026-08-18__FET_000_NEWTON__optional requirements
408
+ # - name: simready_metadata_validation_validated_features_2026_08_18_fet_000_newton_passed
409
+ # type: boolean
410
+ # operators:
411
+ # - eq
412
+ # - exists
413
+ # description: Auto-generated from USD property 'simready_metadata_validation__validated_features__2026-08-18__FET_000_NEWTON__passed'
414
+ # (1000 assets).
415
+ # examples:
416
+ # - '"with simready_metadata_validation_validated_features_2026_08_18_fet_000_newton_passed" -> simready_metadata_validation_validated_features_2026_08_18_fet_000_newton_passed
417
+ # eq true'
418
+ # map:
419
+ # kind: property_exists
420
+ # property: simready_metadata_validation__validated_features__2026-08-18__FET_000_NEWTON__passed
421
+ # - name: simready_metadata_validation_validated_features_2026_08_18_fet_000_newton_version
422
+ # type: string
423
+ # operators:
424
+ # - eq
425
+ # - contains
426
+ # description: 'Auto-generated from USD property ''simready_metadata_validation__validated_features__2026-08-18__FET_000_NEWTON__version''
427
+ # (1000 assets). Common values: ''0.1.0''.'
428
+ # examples:
429
+ # - '"simready_metadata_validation_validated_features_2026_08_18_fet_000_newton_version is 0.1.0" -> simready_metadata_validation_validated_features_2026_08_18_fet_000_newton_version
430
+ # eq "0.1.0"'
431
+ # map:
432
+ # kind: property_match
433
+ # property: simready_metadata_validation__validated_features__2026-08-18__FET_000_NEWTON__version
434
+ # - name: simready_metadata_validation_validated_features_2026_08_18_fet_000_physx_dependencies
435
+ # type: string
436
+ # operators:
437
+ # - eq
438
+ # - contains
439
+ # description: 'Auto-generated from USD property ''simready_metadata_validation__validated_features__2026-08-18__FET_000_PHYSX__dependencies''
440
+ # (1000 assets). Common values: "[{''FET_000_STANDARD'': {''version'': ''0.1.0''}}]".'
441
+ # examples:
442
+ # - '"simready_metadata_validation_validated_features_2026_08_18_fet_000_physx_dependencies is [{''FET_000_STANDARD'':
443
+ # {''version'': ''0.1.0''}}]" -> simready_metadata_validation_validated_features_2026_08_18_fet_000_physx_dependencies
444
+ # eq "[{''FET_000_STANDARD'': {''version'': ''0.1.0''}}]"'
445
+ # map:
446
+ # kind: property_match
447
+ # property: simready_metadata_validation__validated_features__2026-08-18__FET_000_PHYSX__dependencies
448
+ # - name: simready_metadata_validation_validated_features_2026_08_18_fet_000_physx_optional_requirements
449
+ # type: string
450
+ # operators:
451
+ # - eq
452
+ # - contains
453
+ # description: 'Auto-generated from USD property ''simready_metadata_validation__validated_features__2026-08-18__FET_000_PHYSX__optional
454
+ # requirements'' (1000 assets). Common values: "[''com.nvidia.simready.RV.001'', ''com.nvidia.simready.RV.002'',
455
+ # ''com.nvidia.simready.RV.010'']", "[''com.nvidia.simready.RV.010'', ''com.nvidia.simready.RV.001'',
456
+ # ''com.nvidia.simready.RV.002'']", "[''com.nvidia.simready.RV.002'', ''com.nvidia.simready.RV.001'',
457
+ # ''com.nvidia.simready.RV.010'']", "[''com.nvidia.simready.RV.001'', ''com.nvidia.simready.RV.010'',
458
+ # ''com.nvidia.simready.RV.002'']", "[''com.nvidia.simready.RV.002'', ''com.nvidia.simready.RV.010'',
459
+ # ''com.nvidia.simready.RV.001'']", "[''com.nvidia.simready.RV.010'', ''com.nvidia.simready.RV.002'',
460
+ # ''com.nvidia.simready.RV.001'']".'
461
+ # examples:
462
+ # - '"simready_metadata_validation_validated_features_2026_08_18_fet_000_physx_optional_requirements is
463
+ # [''com.nvidia.simready.RV.001'', ''com.nvidia.simready.RV.002'', ''com.nvidia.simready.RV.010'']"
464
+ # -> simready_metadata_validation_validated_features_2026_08_18_fet_000_physx_optional_requirements
465
+ # eq "[''com.nvidia.simready.RV.001'', ''com.nvidia.simready.RV.002'', ''com.nvidia.simready.RV.010'']"'
466
+ # map:
467
+ # kind: property_match
468
+ # property: simready_metadata_validation__validated_features__2026-08-18__FET_000_PHYSX__optional requirements
469
+ # - name: simready_metadata_validation_validated_features_2026_08_18_fet_000_physx_passed
470
+ # type: boolean
471
+ # operators:
472
+ # - eq
473
+ # - exists
474
+ # description: Auto-generated from USD property 'simready_metadata_validation__validated_features__2026-08-18__FET_000_PHYSX__passed'
475
+ # (1000 assets).
476
+ # examples:
477
+ # - '"with simready_metadata_validation_validated_features_2026_08_18_fet_000_physx_passed" -> simready_metadata_validation_validated_features_2026_08_18_fet_000_physx_passed
478
+ # eq true'
479
+ # map:
480
+ # kind: property_exists
481
+ # property: simready_metadata_validation__validated_features__2026-08-18__FET_000_PHYSX__passed
482
+ # - name: simready_metadata_validation_validated_features_2026_08_18_fet_000_physx_version
483
+ # type: string
484
+ # operators:
485
+ # - eq
486
+ # - contains
487
+ # description: 'Auto-generated from USD property ''simready_metadata_validation__validated_features__2026-08-18__FET_000_PHYSX__version''
488
+ # (1000 assets). Common values: ''0.1.0''.'
489
+ # examples:
490
+ # - '"simready_metadata_validation_validated_features_2026_08_18_fet_000_physx_version is 0.1.0" -> simready_metadata_validation_validated_features_2026_08_18_fet_000_physx_version
491
+ # eq "0.1.0"'
492
+ # map:
493
+ # kind: property_match
494
+ # property: simready_metadata_validation__validated_features__2026-08-18__FET_000_PHYSX__version
495
+ # - name: simready_metadata_validation_validated_features_2026_08_18_fet_000_standard_dependencies
496
+ # type: string
497
+ # operators:
498
+ # - eq
499
+ # - contains
500
+ # description: 'Auto-generated from USD property ''simready_metadata_validation__validated_features__2026-08-18__FET_000_STANDARD__dependencies''
501
+ # (1000 assets). Common values: ''[]''.'
502
+ # examples:
503
+ # - '"simready_metadata_validation_validated_features_2026_08_18_fet_000_standard_dependencies is []"
504
+ # -> simready_metadata_validation_validated_features_2026_08_18_fet_000_standard_dependencies eq "[]"'
505
+ # map:
506
+ # kind: property_match
507
+ # property: simready_metadata_validation__validated_features__2026-08-18__FET_000_STANDARD__dependencies
508
+ # - name: simready_metadata_validation_validated_features_2026_08_18_fet_000_standard_passed
509
+ # type: boolean
510
+ # operators:
511
+ # - eq
512
+ # - exists
513
+ # description: Auto-generated from USD property 'simready_metadata_validation__validated_features__2026-08-18__FET_000_STANDARD__passed'
514
+ # (1000 assets).
515
+ # examples:
516
+ # - '"with simready_metadata_validation_validated_features_2026_08_18_fet_000_standard_passed" -> simready_metadata_validation_validated_features_2026_08_18_fet_000_standard_passed
517
+ # eq true'
518
+ # map:
519
+ # kind: property_exists
520
+ # property: simready_metadata_validation__validated_features__2026-08-18__FET_000_STANDARD__passed
521
+ # - name: simready_metadata_validation_validated_features_2026_08_18_fet_000_standard_version
522
+ # type: string
523
+ # operators:
524
+ # - eq
525
+ # - contains
526
+ # description: 'Auto-generated from USD property ''simready_metadata_validation__validated_features__2026-08-18__FET_000_STANDARD__version''
527
+ # (1000 assets). Common values: ''0.1.0''.'
528
+ # examples:
529
+ # - '"simready_metadata_validation_validated_features_2026_08_18_fet_000_standard_version is 0.1.0" ->
530
+ # simready_metadata_validation_validated_features_2026_08_18_fet_000_standard_version eq "0.1.0"'
531
+ # map:
532
+ # kind: property_match
533
+ # property: simready_metadata_validation__validated_features__2026-08-18__FET_000_STANDARD__version
534
+ # - name: simready_metadata_validation_validated_features_2026_08_18_fet_001_standard_dependencies
535
+ # type: string
536
+ # operators:
537
+ # - eq
538
+ # - contains
539
+ # description: 'Auto-generated from USD property ''simready_metadata_validation__validated_features__2026-08-18__FET_001_STANDARD__dependencies''
540
+ # (1000 assets). Common values: ''[]''.'
541
+ # examples:
542
+ # - '"simready_metadata_validation_validated_features_2026_08_18_fet_001_standard_dependencies is []"
543
+ # -> simready_metadata_validation_validated_features_2026_08_18_fet_001_standard_dependencies eq "[]"'
544
+ # map:
545
+ # kind: property_match
546
+ # property: simready_metadata_validation__validated_features__2026-08-18__FET_001_STANDARD__dependencies
547
+ # - name: simready_metadata_validation_validated_features_2026_08_18_fet_001_standard_passed
548
+ # type: boolean
549
+ # operators:
550
+ # - eq
551
+ # - exists
552
+ # description: Auto-generated from USD property 'simready_metadata_validation__validated_features__2026-08-18__FET_001_STANDARD__passed'
553
+ # (1000 assets).
554
+ # examples:
555
+ # - '"with simready_metadata_validation_validated_features_2026_08_18_fet_001_standard_passed" -> simready_metadata_validation_validated_features_2026_08_18_fet_001_standard_passed
556
+ # eq true'
557
+ # map:
558
+ # kind: property_exists
559
+ # property: simready_metadata_validation__validated_features__2026-08-18__FET_001_STANDARD__passed
560
+ # - name: simready_metadata_validation_validated_features_2026_08_18_fet_001_standard_version
561
+ # type: string
562
+ # operators:
563
+ # - eq
564
+ # - contains
565
+ # description: 'Auto-generated from USD property ''simready_metadata_validation__validated_features__2026-08-18__FET_001_STANDARD__version''
566
+ # (1000 assets). Common values: ''1.0.1''.'
567
+ # examples:
568
+ # - '"simready_metadata_validation_validated_features_2026_08_18_fet_001_standard_version is 1.0.1" ->
569
+ # simready_metadata_validation_validated_features_2026_08_18_fet_001_standard_version eq "1.0.1"'
570
+ # map:
571
+ # kind: property_match
572
+ # property: simready_metadata_validation__validated_features__2026-08-18__FET_001_STANDARD__version
573
+ # - name: simready_metadata_validation_validated_features_2026_08_18_fet_003_mujoco_dependencies
574
+ # type: string
575
+ # operators:
576
+ # - eq
577
+ # - contains
578
+ # description: 'Auto-generated from USD property ''simready_metadata_validation__validated_features__2026-08-18__FET_003_MUJOCO__dependencies''
579
+ # (1000 assets). Common values: ''[]''.'
580
+ # examples:
581
+ # - '"simready_metadata_validation_validated_features_2026_08_18_fet_003_mujoco_dependencies is []" ->
582
+ # simready_metadata_validation_validated_features_2026_08_18_fet_003_mujoco_dependencies eq "[]"'
583
+ # map:
584
+ # kind: property_match
585
+ # property: simready_metadata_validation__validated_features__2026-08-18__FET_003_MUJOCO__dependencies
586
+ # - name: simready_metadata_validation_validated_features_2026_08_18_fet_003_mujoco_passed
587
+ # type: boolean
588
+ # operators:
589
+ # - eq
590
+ # - exists
591
+ # description: Auto-generated from USD property 'simready_metadata_validation__validated_features__2026-08-18__FET_003_MUJOCO__passed'
592
+ # (1000 assets).
593
+ # examples:
594
+ # - '"with simready_metadata_validation_validated_features_2026_08_18_fet_003_mujoco_passed" -> simready_metadata_validation_validated_features_2026_08_18_fet_003_mujoco_passed
595
+ # eq true'
596
+ # map:
597
+ # kind: property_exists
598
+ # property: simready_metadata_validation__validated_features__2026-08-18__FET_003_MUJOCO__passed
599
+ #
docker/hf-space/siglip2-model/PACKAGE-LICENSES/siglip2-giant-opt-patch16-384.txt ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
docker/hf-space/siglip2-model/SOURCE.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # Generated by scripts/extract_siglip2_model.sh β€” do not hand-edit.
2
+ source_repo=../usdsearch
3
+ source_commit=555bee64e6f4d12995ab49313d8a1487cae97c84
4
+ extracted_at=2026-09-23T08:29:31Z
docker/hf-space/siglip2-model/model_repo/ensemble_image_model/1/_.txt ADDED
File without changes
docker/hf-space/siglip2-model/model_repo/ensemble_image_model/config.pbtxt ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: "ensemble_image_model"
2
+ platform: "ensemble"
3
+ max_batch_size: 32
4
+
5
+ input [
6
+ {
7
+ name: "raw_image"
8
+ data_type: TYPE_UINT8
9
+ dims: [-1, -1, 3]
10
+ }
11
+ ]
12
+
13
+ output [
14
+ {
15
+ name: "image_embeds"
16
+ data_type: TYPE_FP32
17
+ dims: [1536]
18
+ }
19
+ ]
20
+
21
+ ensemble_scheduling {
22
+ step [
23
+ {
24
+ model_name: "image_preprocessing"
25
+ model_version: -1
26
+ input_map {
27
+ key: "raw_image"
28
+ value: "raw_image"
29
+ }
30
+ output_map {
31
+ key: "pixel_values"
32
+ value: "pixel_values"
33
+ }
34
+ },
35
+ {
36
+ model_name: "siglip2_vision_encoder_onnx"
37
+ model_version: -1
38
+ input_map {
39
+ key: "pixel_values"
40
+ value: "pixel_values"
41
+ }
42
+ output_map {
43
+ key: "image_embeds"
44
+ value: "image_embeds"
45
+ }
46
+ }
47
+ ]
48
+ }
docker/hf-space/siglip2-model/model_repo/ensemble_text_model/1/_.txt ADDED
File without changes
docker/hf-space/siglip2-model/model_repo/ensemble_text_model/config.pbtxt ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: "ensemble_text_model"
2
+ platform: "ensemble"
3
+ max_batch_size: 32
4
+
5
+ input [
6
+ {
7
+ name: "text"
8
+ data_type: TYPE_STRING
9
+ dims: [1]
10
+ }
11
+ ]
12
+
13
+ output [
14
+ {
15
+ name: "embeddings"
16
+ data_type: TYPE_FP32
17
+ dims: [1536]
18
+ }
19
+ ]
20
+
21
+ ensemble_scheduling {
22
+ step [
23
+ {
24
+ model_name: "tokenizer"
25
+ model_version: -1
26
+ input_map {
27
+ key: "text"
28
+ value: "text"
29
+ }
30
+ output_map {
31
+ key: "input_ids"
32
+ value: "input_ids"
33
+ }
34
+ },
35
+ {
36
+ model_name: "siglip2_text_encoder_onnx"
37
+ model_version: -1
38
+ input_map {
39
+ key: "input_ids"
40
+ value: "input_ids"
41
+ }
42
+ output_map {
43
+ key: "embeddings"
44
+ value: "embeddings"
45
+ }
46
+ }
47
+ ]
48
+ }
docker/hf-space/siglip2-model/model_repo/image_preprocessing/1/model.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ # standard modules
17
+ import json
18
+ import os
19
+ from pathlib import Path
20
+
21
+ # third party modules
22
+ import numpy as np
23
+ import triton_python_backend_utils as pb_utils
24
+ from PIL import Image
25
+
26
+ # PIL resampling enum values
27
+ _RESAMPLE_MAP = {
28
+ 0: Image.NEAREST,
29
+ 1: Image.LANCZOS,
30
+ 2: Image.BILINEAR,
31
+ 3: Image.BICUBIC,
32
+ }
33
+
34
+
35
+ class TritonPythonModel:
36
+ """Image preprocessor using PIL resize + vectorized numpy rescale/normalize.
37
+
38
+ Uses direct PIL + numpy operations to avoid torch dependency in the
39
+ Triton server container (CPU-only torch conflicts with ONNX Runtime CUDA).
40
+ """
41
+
42
+ def initialize(self, args: dict) -> None:
43
+ model_config = json.loads(args["model_config"])
44
+
45
+ current_dir = Path(__file__).parent
46
+ preprocessor_config_path = os.path.join(current_dir, "preprocessor_config", "preprocessor_config.json")
47
+ with open(preprocessor_config_path, "r") as f:
48
+ config = json.load(f)
49
+
50
+ self.height = config["size"]["height"]
51
+ self.width = config["size"]["width"]
52
+ self.rescale_factor = np.float32(config.get("rescale_factor", 1.0 / 255.0))
53
+ self.image_mean = np.array(config.get("image_mean", [0.5, 0.5, 0.5]), dtype=np.float32).reshape(3, 1, 1)
54
+ self.image_std = np.array(config.get("image_std", [0.5, 0.5, 0.5]), dtype=np.float32).reshape(3, 1, 1)
55
+ self.do_resize = config.get("do_resize", True)
56
+ self.do_rescale = config.get("do_rescale", True)
57
+ self.do_normalize = config.get("do_normalize", True)
58
+ self.resample = _RESAMPLE_MAP.get(config.get("resample", 2), Image.BILINEAR)
59
+
60
+ data_type = model_config["output"][0]["data_type"]
61
+ self.output_dtype = pb_utils.triton_string_to_numpy(data_type)
62
+
63
+ def _process_single(self, img_hwc: np.ndarray) -> np.ndarray:
64
+ """Process a single HWC uint8 image to CHW float32."""
65
+ if self.do_resize:
66
+ pil_img = Image.fromarray(img_hwc)
67
+ pil_img = pil_img.resize((self.width, self.height), self.resample)
68
+ img_hwc = np.asarray(pil_img)
69
+
70
+ # HWC -> CHW
71
+ img = img_hwc.transpose(2, 0, 1).astype(np.float32)
72
+
73
+ if self.do_rescale:
74
+ img *= self.rescale_factor
75
+
76
+ if self.do_normalize:
77
+ img = (img - self.image_mean) / self.image_std
78
+
79
+ return img
80
+
81
+ def execute(self, requests: list) -> list:
82
+ responses = []
83
+ for request in requests:
84
+ image_tensor = pb_utils.get_input_tensor_by_name(request, "raw_image")
85
+ images = image_tensor.as_numpy()
86
+
87
+ batch_size = images.shape[0]
88
+ pixel_values = np.empty((batch_size, 3, self.height, self.width), dtype=self.output_dtype)
89
+
90
+ for i in range(batch_size):
91
+ pixel_values[i] = self._process_single(images[i])
92
+
93
+ inference_response = pb_utils.InferenceResponse(
94
+ output_tensors=[pb_utils.Tensor("pixel_values", pixel_values)]
95
+ )
96
+ responses.append(inference_response)
97
+
98
+ return responses
99
+
100
+ def finalize(self) -> None:
101
+ print("Cleaning up image preprocessor...")
docker/hf-space/siglip2-model/model_repo/image_preprocessing/1/preprocessor_config/preprocessor_config.json ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "do_convert_rgb": null,
3
+ "do_normalize": true,
4
+ "do_rescale": true,
5
+ "do_resize": true,
6
+ "image_mean": [
7
+ 0.5,
8
+ 0.5,
9
+ 0.5
10
+ ],
11
+ "image_processor_type": "SiglipImageProcessor",
12
+ "image_std": [
13
+ 0.5,
14
+ 0.5,
15
+ 0.5
16
+ ],
17
+ "processor_class": "SiglipProcessor",
18
+ "resample": 2,
19
+ "rescale_factor": 0.00392156862745098,
20
+ "size": {
21
+ "height": 384,
22
+ "width": 384
23
+ }
24
+ }
docker/hf-space/siglip2-model/model_repo/image_preprocessing/config.pbtxt ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: "image_preprocessing"
2
+ backend: "python"
3
+ max_batch_size: 32
4
+
5
+ input [
6
+ {
7
+ name: "raw_image"
8
+ data_type: TYPE_UINT8
9
+ dims: [-1, -1, 3]
10
+ }
11
+ ]
12
+
13
+ output [
14
+ {
15
+ name: "pixel_values"
16
+ data_type: TYPE_FP32
17
+ dims: [3, 384, 384]
18
+ }
19
+ ]
20
+
21
+ dynamic_batching {
22
+ preferred_batch_size: [4, 8, 16]
23
+ max_queue_delay_microseconds: 50000
24
+ }
25
+
26
+ instance_group [
27
+ {
28
+ count: 4
29
+ kind: KIND_CPU
30
+ }
31
+ ]
docker/hf-space/siglip2-model/model_repo/siglip2_text_encoder_onnx/config.pbtxt ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: "siglip2_text_encoder_onnx"
2
+ backend: "onnxruntime"
3
+ max_batch_size: 32
4
+
5
+ input [
6
+ {
7
+ name: "input_ids"
8
+ data_type: TYPE_INT64
9
+ dims: [64]
10
+ }
11
+ ]
12
+
13
+ output [
14
+ {
15
+ name: "embeddings"
16
+ data_type: TYPE_FP32
17
+ dims: [1536]
18
+ }
19
+ ]
20
+
21
+ dynamic_batching {
22
+ preferred_batch_size: [8, 16, 32]
23
+ max_queue_delay_microseconds: 50000
24
+ }
25
+
26
+ instance_group [
27
+ {
28
+ count: 2
29
+ kind: KIND_AUTO
30
+ }
31
+ ]
32
+
33
+ parameters {
34
+ key: "execution_accelerators"
35
+ value: { string_value: "cuda:0" }
36
+ }
37
+ parameters {
38
+ key: "enable_mem_pattern"
39
+ value: { string_value: "1" }
40
+ }
41
+ parameters {
42
+ key: "enable_mem_reuse"
43
+ value: { string_value: "1" }
44
+ }
45
+
46
+ optimization {
47
+ graph {
48
+ level: 0
49
+ }
50
+ }
51
+
52
+ model_warmup [
53
+ {
54
+ name: "warmup_batch_1"
55
+ batch_size: 1
56
+ inputs [
57
+ {
58
+ key: "input_ids"
59
+ value: {
60
+ data_type: TYPE_INT64
61
+ dims: [64]
62
+ zero_data: true
63
+ }
64
+ }
65
+ ]
66
+ }
67
+ ]
docker/hf-space/siglip2-model/model_repo/siglip2_vision_encoder_onnx/config.pbtxt ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: "siglip2_vision_encoder_onnx"
2
+ backend: "onnxruntime"
3
+ max_batch_size: 32
4
+
5
+ input [
6
+ {
7
+ name: "pixel_values"
8
+ data_type: TYPE_FP32
9
+ dims: [3, 384, 384]
10
+ }
11
+ ]
12
+
13
+ output [
14
+ {
15
+ name: "image_embeds"
16
+ data_type: TYPE_FP32
17
+ dims: [1536]
18
+ }
19
+ ]
20
+
21
+ dynamic_batching {
22
+ preferred_batch_size: [4, 8, 16]
23
+ max_queue_delay_microseconds: 50000
24
+ }
25
+
26
+ instance_group [
27
+ {
28
+ count: 2
29
+ kind: KIND_AUTO
30
+ }
31
+ ]
32
+
33
+ parameters {
34
+ key: "execution_accelerators"
35
+ value: { string_value: "cuda:0" }
36
+ }
37
+ parameters {
38
+ key: "enable_mem_pattern"
39
+ value: { string_value: "1" }
40
+ }
41
+ parameters {
42
+ key: "enable_mem_reuse"
43
+ value: { string_value: "1" }
44
+ }
45
+
46
+ optimization {
47
+ graph {
48
+ level: 0
49
+ }
50
+ }
51
+
52
+ model_warmup [
53
+ {
54
+ name: "warmup_batch_1"
55
+ batch_size: 1
56
+ inputs {
57
+ key: "pixel_values"
58
+ value: {
59
+ data_type: TYPE_FP32
60
+ dims: [3, 384, 384]
61
+ zero_data: true
62
+ }
63
+ }
64
+ }
65
+ ]
docker/hf-space/siglip2-model/model_repo/tokenizer/1/model.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ # standard modules
17
+ import json
18
+ from pathlib import Path
19
+
20
+ # third party modules
21
+ import numpy as np
22
+ import triton_python_backend_utils as pb_utils
23
+ from tokenizers import Tokenizer
24
+
25
+
26
+ class TritonPythonModel:
27
+ """Triton Python backend model for SigLIP2 tokenizer."""
28
+
29
+ def initialize(self, args):
30
+ model_config = json.loads(args["model_config"])
31
+
32
+ current_dir = Path(__file__).parent
33
+ tokenizer_file = current_dir / "tokenizer" / "tokenizer.json"
34
+
35
+ # Load tokenizer directly from tokenizer.json using tokenizers library
36
+ self.tokenizer = Tokenizer.from_file(str(tokenizer_file))
37
+
38
+ # Enable padding and truncation
39
+ self.tokenizer.enable_padding(length=64, pad_id=0, pad_token="<pad>")
40
+ self.tokenizer.enable_truncation(max_length=64)
41
+
42
+ # Resolve output dtypes from model config
43
+ self.output_dtypes = {}
44
+ for output_config in model_config["output"]:
45
+ name = output_config["name"]
46
+ data_type = output_config["data_type"]
47
+ self.output_dtypes[name] = pb_utils.triton_string_to_numpy(data_type)
48
+
49
+ def execute(self, requests: list) -> list:
50
+ responses = []
51
+ for request in requests:
52
+ # Get input text
53
+ text_tensor = pb_utils.get_input_tensor_by_name(request, "text")
54
+ text_data = text_tensor.as_numpy()
55
+
56
+ # Decode bytes to strings if necessary
57
+ if text_data.dtype == np.object_:
58
+ texts = [t.decode("utf-8") if isinstance(t, bytes) else t for t in text_data.flatten()]
59
+ else:
60
+ texts = text_data.flatten().tolist()
61
+
62
+ # Tokenize using tokenizers library
63
+ encoded = self.tokenizer.encode_batch(texts)
64
+
65
+ # Extract input IDs from encodings
66
+ input_ids_dtype = self.output_dtypes.get("input_ids", np.int64)
67
+ input_ids = np.array([enc.ids for enc in encoded], dtype=input_ids_dtype)
68
+
69
+ # Create output tensors
70
+ input_ids_tensor = pb_utils.Tensor("input_ids", input_ids)
71
+
72
+ # Create response
73
+ inference_response = pb_utils.InferenceResponse(output_tensors=[input_ids_tensor])
74
+ responses.append(inference_response)
75
+
76
+ return responses
77
+
78
+ def finalize(self):
79
+ """Clean up resources."""
80
+ print("Cleaning up tokenizer...")
docker/hf-space/siglip2-model/model_repo/tokenizer/1/tokenizer/tokenizer.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fc1870feba58b2d0cbcc75bfcb5124ee5c1202c5a64fdde45d473d228f1f35ae
3
+ size 34363171
docker/hf-space/siglip2-model/model_repo/tokenizer/config.pbtxt ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: "tokenizer"
2
+ backend: "python"
3
+ max_batch_size: 32
4
+
5
+ input [
6
+ {
7
+ name: "text"
8
+ data_type: TYPE_STRING
9
+ dims: [1]
10
+ }
11
+ ]
12
+
13
+ output [
14
+ {
15
+ name: "input_ids"
16
+ data_type: TYPE_INT64
17
+ dims: [64]
18
+ }
19
+ ]
20
+
21
+ dynamic_batching {
22
+ preferred_batch_size: [8, 16, 32]
23
+ max_queue_delay_microseconds: 50000
24
+ }
25
+
26
+ instance_group [
27
+ {
28
+ count: 4
29
+ kind: KIND_CPU
30
+ }
31
+ ]
32
+
33
+ response_cache {
34
+ enable: true
35
+ }
docker/hf-space/siglip2-model/pyproject.toml ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "siglip2-triton-server"
3
+ version = "0.1.0"
4
+ description = "Server dependencies for SigLIP2 Triton"
5
+ requires-python = ">=3.11,<3.14"
6
+ dependencies = [
7
+ "tokenizers>=0.22.2",
8
+ "transformers>=4.57.1",
9
+ "pillow>=12.3.0",
10
+ "numpy",
11
+ "wheel>=0.46.3",
12
+ "urllib3>=2.7.0",
13
+ "starlette>=1.3.1",
14
+ ]
docker/hf-space/siglip2-model/uv.lock ADDED
@@ -0,0 +1,597 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.11, <3.14"
4
+
5
+ [[package]]
6
+ name = "annotated-doc"
7
+ version = "0.0.4"
8
+ source = { registry = "https://pypi.org/simple" }
9
+ sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
10
+ wheels = [
11
+ { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
12
+ ]
13
+
14
+ [[package]]
15
+ name = "anyio"
16
+ version = "4.13.0"
17
+ source = { registry = "https://pypi.org/simple" }
18
+ dependencies = [
19
+ { name = "idna" },
20
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
21
+ ]
22
+ sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
23
+ wheels = [
24
+ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
25
+ ]
26
+
27
+ [[package]]
28
+ name = "certifi"
29
+ version = "2026.4.22"
30
+ source = { registry = "https://pypi.org/simple" }
31
+ sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" }
32
+ wheels = [
33
+ { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
34
+ ]
35
+
36
+ [[package]]
37
+ name = "click"
38
+ version = "8.3.3"
39
+ source = { registry = "https://pypi.org/simple" }
40
+ dependencies = [
41
+ { name = "colorama", marker = "sys_platform == 'win32'" },
42
+ ]
43
+ sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" }
44
+ wheels = [
45
+ { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" },
46
+ ]
47
+
48
+ [[package]]
49
+ name = "colorama"
50
+ version = "0.4.6"
51
+ source = { registry = "https://pypi.org/simple" }
52
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
53
+ wheels = [
54
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
55
+ ]
56
+
57
+ [[package]]
58
+ name = "filelock"
59
+ version = "3.29.0"
60
+ source = { registry = "https://pypi.org/simple" }
61
+ sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" }
62
+ wheels = [
63
+ { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" },
64
+ ]
65
+
66
+ [[package]]
67
+ name = "fsspec"
68
+ version = "2026.4.0"
69
+ source = { registry = "https://pypi.org/simple" }
70
+ sdist = { url = "https://files.pythonhosted.org/packages/d5/8d/1c51c094345df128ca4a990d633fe1a0ff28726c9e6b3c41ba65087bba1d/fsspec-2026.4.0.tar.gz", hash = "sha256:301d8ac70ae90ef3ad05dcf94d6c3754a097f9b5fe4667d2787aa359ec7df7e4", size = 312760, upload-time = "2026-04-29T20:42:38.635Z" }
71
+ wheels = [
72
+ { url = "https://files.pythonhosted.org/packages/d5/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-py3-none-any.whl", hash = "sha256:11ef7bb35dab8a394fde6e608221d5cf3e8499401c249bebaeaad760a1a8dec2", size = 203402, upload-time = "2026-04-29T20:42:36.842Z" },
73
+ ]
74
+
75
+ [[package]]
76
+ name = "h11"
77
+ version = "0.16.0"
78
+ source = { registry = "https://pypi.org/simple" }
79
+ sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
80
+ wheels = [
81
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
82
+ ]
83
+
84
+ [[package]]
85
+ name = "hf-xet"
86
+ version = "1.5.0"
87
+ source = { registry = "https://pypi.org/simple" }
88
+ sdist = { url = "https://files.pythonhosted.org/packages/74/d8/5c06fc76461418326a7decf8367480c35be11a41fd938633929c60a9ec6b/hf_xet-1.5.0.tar.gz", hash = "sha256:e0fb0a34d9f406eed88233e829a67ec016bec5af19e480eac65a233ea289a948", size = 837196, upload-time = "2026-05-06T06:18:15.583Z" }
89
+ wheels = [
90
+ { url = "https://files.pythonhosted.org/packages/68/9b/6912c99070915a4f28119e3c5b52a9abd1eec0ad5cb293b8c967a0c6f5a2/hf_xet-1.5.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:7d70fe2ce97b9db73b9c9b9c81fe3693640aec83416a966c446afea54acfae3c", size = 4023383, upload-time = "2026-05-06T06:17:53.947Z" },
91
+ { url = "https://files.pythonhosted.org/packages/0f/6d/9563cfde59b5d8128a9c7ec972a087f4c782e4f7bac5a85234edfd5d5e49/hf_xet-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:73a0dae8c71de3b0633a45c73f4a4a5ed09e94b43441d82981a781d4f12baa42", size = 3792751, upload-time = "2026-05-06T06:17:51.791Z" },
92
+ { url = "https://files.pythonhosted.org/packages/07/a5/ed5a0cf35b49a0571af5a8f53416dad1877a718c021c9937c3a53cb45781/hf_xet-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a60290ec57e9b71767fba7c3645ddafdd0759974b540441510c629c6db6db24a", size = 4456058, upload-time = "2026-05-06T06:17:40.735Z" },
93
+ { url = "https://files.pythonhosted.org/packages/60/fb/3ae8bf2a7a37a4197d0195d7247fd25b3952e15cb8a599e285dfaa6f52b3/hf_xet-1.5.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:e5de0f6deada0dada870bb376a11bcd1f08abf3a968a6d118f33e72d1b1eb480", size = 4250783, upload-time = "2026-05-06T06:17:38.412Z" },
94
+ { url = "https://files.pythonhosted.org/packages/a2/9b/8bae40d4d91525085137196e84eb0ed49cf65b5e96e5c3ecdadd8bd0fac2/hf_xet-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c799d49f1a5544a0ef7591c0ee75e0d6b93d6f56dc7a4979f59f7518d2872216", size = 4445594, upload-time = "2026-05-06T06:18:04.219Z" },
95
+ { url = "https://files.pythonhosted.org/packages/13/59/c74efbbd4e8728172b2cc72a2bc014d2947a4b7bdced932fbd3f5da1a4e5/hf_xet-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2baea1b0b989e5c152fe81425f7745ddc8901280ba3d97c98d8cdece7b706c60", size = 4663995, upload-time = "2026-05-06T06:18:06.1Z" },
96
+ { url = "https://files.pythonhosted.org/packages/73/32/8e1e0410af64cda9b139d1dcebdc993a8ff9c8c7c0e2696ae356d75ccc0d/hf_xet-1.5.0-cp313-cp313t-win_amd64.whl", hash = "sha256:526345b3ed45f374f6317349df489167606736c876241ba984105afe7fd4839d", size = 3966608, upload-time = "2026-05-06T06:18:19.74Z" },
97
+ { url = "https://files.pythonhosted.org/packages/fc/34/a8febc8f4edbea8b3e21b02ebc8b628679b84ba7e45cde624a7736b51500/hf_xet-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:786d28e2eb8315d5035544b9d137b4a842d600c434bb91bf7d0d953cce906ad4", size = 3796946, upload-time = "2026-05-06T06:18:17.568Z" },
98
+ { url = "https://files.pythonhosted.org/packages/3d/fb/69ff198a82cae7eb1a69fb84d93b3a3e4816564d76817fe541ddc96874eb/hf_xet-1.5.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:dad0dc84e941b8ba3c860659fe1fdc35c049d47cce293f003287757e971a8f56", size = 4030814, upload-time = "2026-05-06T06:17:57.933Z" },
99
+ { url = "https://files.pythonhosted.org/packages/9b/ff/edcc2b40162bef3ff78e14ab637e5f3b89243d6aee72f5949d3bb6a5af83/hf_xet-1.5.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:fd6e5a9b0fdac4ed03ed45ef79254a655b1aaab514a02202617fbf643f5fdf7a", size = 3798444, upload-time = "2026-05-06T06:17:55.79Z" },
100
+ { url = "https://files.pythonhosted.org/packages/49/4d/103f76b04310e5e57656696cc184690d20c466af0bca3ca88f8c8ea5d4f3/hf_xet-1.5.0-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3531b1823a0e6d77d80f9ed15ca0e00f0d115094f8ac033d5cae88f4564cc949", size = 4465986, upload-time = "2026-05-06T06:17:44.886Z" },
101
+ { url = "https://files.pythonhosted.org/packages/c4/a2/546f47f464737b3edbab6f8ddb57f2599b93d2cbb66f06abb475ccb48651/hf_xet-1.5.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9a0ee58cd18d5ea799f7ed11290bbccbe56bdd8b1d97ca74b9cc49a3945d7a3b", size = 4259865, upload-time = "2026-05-06T06:17:42.639Z" },
102
+ { url = "https://files.pythonhosted.org/packages/95/7f/1be593c1f28613be2e196473481cd81bfc5910795e30a34e8f744f6cac4f/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e60df5a42e9bed8628b6416af2cba4cba57ae9f02de226a06b020d98e1aab18", size = 4459835, upload-time = "2026-05-06T06:18:08.026Z" },
103
+ { url = "https://files.pythonhosted.org/packages/aa/b2/703569fc881f3284487e68cda7b42179978480da3c438042a6bbbb4a671c/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4b35549ce62601b84da4ff9b24d970032ace3d4430f52d91bcbb26c901d6c690", size = 4672414, upload-time = "2026-05-06T06:18:09.864Z" },
104
+ { url = "https://files.pythonhosted.org/packages/af/37/1b6def445c567286b50aa3b33828158e135b1be44938dde59f11382a500c/hf_xet-1.5.0-cp37-abi3-win_amd64.whl", hash = "sha256:2806c7c17b4d23f8d88f7c4814f838c3b6150773fe339c20af23e1cfaf2797e4", size = 3977238, upload-time = "2026-05-06T06:18:23.621Z" },
105
+ { url = "https://files.pythonhosted.org/packages/62/94/3b66b148778ee100dcfd69c2ca22b57b41b44d3063ceec934f209e9184ce/hf_xet-1.5.0-cp37-abi3-win_arm64.whl", hash = "sha256:b6c9df403040248c76d808d3e047d64db2d923bae593eb244c41e425cf6cd7be", size = 3806916, upload-time = "2026-05-06T06:18:21.7Z" },
106
+ ]
107
+
108
+ [[package]]
109
+ name = "httpcore"
110
+ version = "1.0.9"
111
+ source = { registry = "https://pypi.org/simple" }
112
+ dependencies = [
113
+ { name = "certifi" },
114
+ { name = "h11" },
115
+ ]
116
+ sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
117
+ wheels = [
118
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
119
+ ]
120
+
121
+ [[package]]
122
+ name = "httpx"
123
+ version = "0.28.1"
124
+ source = { registry = "https://pypi.org/simple" }
125
+ dependencies = [
126
+ { name = "anyio" },
127
+ { name = "certifi" },
128
+ { name = "httpcore" },
129
+ { name = "idna" },
130
+ ]
131
+ sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
132
+ wheels = [
133
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
134
+ ]
135
+
136
+ [[package]]
137
+ name = "huggingface-hub"
138
+ version = "1.14.0"
139
+ source = { registry = "https://pypi.org/simple" }
140
+ dependencies = [
141
+ { name = "filelock" },
142
+ { name = "fsspec" },
143
+ { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" },
144
+ { name = "httpx" },
145
+ { name = "packaging" },
146
+ { name = "pyyaml" },
147
+ { name = "tqdm" },
148
+ { name = "typer" },
149
+ { name = "typing-extensions" },
150
+ ]
151
+ sdist = { url = "https://files.pythonhosted.org/packages/39/40/43109e943fd718b0ccd0cd61eb4f1c347df22bf81f5874c6f22adf44bcff/huggingface_hub-1.14.0.tar.gz", hash = "sha256:d6d2c9cd6be1d02ae9ec6672d5587d10a427f377db688e82528f426a041622c2", size = 782365, upload-time = "2026-05-06T14:14:34.278Z" }
152
+ wheels = [
153
+ { url = "https://files.pythonhosted.org/packages/89/a5/33b49ba7bea7c41bb37f74ec0f8beea0831e052330196633fe2c77516ea6/huggingface_hub-1.14.0-py3-none-any.whl", hash = "sha256:efe075535c62e130b30e836b138e13785f6f043d1f0539e0a39aa411a99e90b8", size = 661479, upload-time = "2026-05-06T14:14:32.029Z" },
154
+ ]
155
+
156
+ [[package]]
157
+ name = "idna"
158
+ version = "3.18"
159
+ source = { registry = "https://pypi.org/simple" }
160
+ sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
161
+ wheels = [
162
+ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
163
+ ]
164
+
165
+ [[package]]
166
+ name = "markdown-it-py"
167
+ version = "4.2.0"
168
+ source = { registry = "https://pypi.org/simple" }
169
+ dependencies = [
170
+ { name = "mdurl" },
171
+ ]
172
+ sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
173
+ wheels = [
174
+ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
175
+ ]
176
+
177
+ [[package]]
178
+ name = "mdurl"
179
+ version = "0.1.2"
180
+ source = { registry = "https://pypi.org/simple" }
181
+ sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
182
+ wheels = [
183
+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
184
+ ]
185
+
186
+ [[package]]
187
+ name = "numpy"
188
+ version = "2.4.6"
189
+ source = { registry = "https://pypi.org/simple" }
190
+ sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" }
191
+ wheels = [
192
+ { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" },
193
+ { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" },
194
+ { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" },
195
+ { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" },
196
+ { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" },
197
+ { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" },
198
+ { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" },
199
+ { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" },
200
+ { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" },
201
+ { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" },
202
+ { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" },
203
+ { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" },
204
+ { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" },
205
+ { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" },
206
+ { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" },
207
+ { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" },
208
+ { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" },
209
+ { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" },
210
+ { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" },
211
+ { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" },
212
+ { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" },
213
+ { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" },
214
+ { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" },
215
+ { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" },
216
+ { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" },
217
+ { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" },
218
+ { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" },
219
+ { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" },
220
+ { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" },
221
+ { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" },
222
+ { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" },
223
+ { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" },
224
+ { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" },
225
+ { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" },
226
+ { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" },
227
+ { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" },
228
+ { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" },
229
+ { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" },
230
+ { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" },
231
+ { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" },
232
+ { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" },
233
+ { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" },
234
+ { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" },
235
+ { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" },
236
+ { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" },
237
+ { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" },
238
+ { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" },
239
+ { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" },
240
+ { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" },
241
+ { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" },
242
+ ]
243
+
244
+ [[package]]
245
+ name = "packaging"
246
+ version = "26.2"
247
+ source = { registry = "https://pypi.org/simple" }
248
+ sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
249
+ wheels = [
250
+ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
251
+ ]
252
+
253
+ [[package]]
254
+ name = "pillow"
255
+ version = "12.3.0"
256
+ source = { registry = "https://pypi.org/simple" }
257
+ sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
258
+ wheels = [
259
+ { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" },
260
+ { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" },
261
+ { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" },
262
+ { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" },
263
+ { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" },
264
+ { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" },
265
+ { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" },
266
+ { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" },
267
+ { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" },
268
+ { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" },
269
+ { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" },
270
+ { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" },
271
+ { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" },
272
+ { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" },
273
+ { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" },
274
+ { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" },
275
+ { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" },
276
+ { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" },
277
+ { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" },
278
+ { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" },
279
+ { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" },
280
+ { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" },
281
+ { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" },
282
+ { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" },
283
+ { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" },
284
+ { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" },
285
+ { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" },
286
+ { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" },
287
+ { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" },
288
+ { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" },
289
+ { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" },
290
+ { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" },
291
+ { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" },
292
+ { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" },
293
+ { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" },
294
+ ]
295
+
296
+ [[package]]
297
+ name = "pygments"
298
+ version = "2.20.0"
299
+ source = { registry = "https://pypi.org/simple" }
300
+ sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
301
+ wheels = [
302
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
303
+ ]
304
+
305
+ [[package]]
306
+ name = "pyyaml"
307
+ version = "6.0.3"
308
+ source = { registry = "https://pypi.org/simple" }
309
+ sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
310
+ wheels = [
311
+ { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
312
+ { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
313
+ { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
314
+ { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
315
+ { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
316
+ { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
317
+ { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
318
+ { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
319
+ { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
320
+ { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
321
+ { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
322
+ { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
323
+ { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
324
+ { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
325
+ { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
326
+ { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
327
+ { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
328
+ { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
329
+ { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
330
+ { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
331
+ { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
332
+ { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
333
+ { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
334
+ { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
335
+ { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
336
+ { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
337
+ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
338
+ { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
339
+ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
340
+ ]
341
+
342
+ [[package]]
343
+ name = "regex"
344
+ version = "2026.5.9"
345
+ source = { registry = "https://pypi.org/simple" }
346
+ sdist = { url = "https://files.pythonhosted.org/packages/dc/0e/49aee608ad09480e7fd276898c99ec6192985fa331abe4eb3a986094490b/regex-2026.5.9.tar.gz", hash = "sha256:a8234aa23ec39894bfe4a3f1b85616a7032481964a13ac6fc9f10de4f6fca270", size = 416074, upload-time = "2026-05-09T23:15:19.37Z" }
347
+ wheels = [
348
+ { url = "https://files.pythonhosted.org/packages/c2/dc/c1f2df4027e82fc54b5a473e4b250f5139faca49a0fbe29a48668d228f34/regex-2026.5.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ccf5249114cc3e772ecdd88a98a86eca0fd74c61ce32a94743758c083fc05d48", size = 489445, upload-time = "2026-05-09T23:12:06.111Z" },
349
+ { url = "https://files.pythonhosted.org/packages/03/d2/59f01110660081cce9c0bc30ebd0b5ee250dacf658e3248ed92f01e0e8ee/regex-2026.5.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46f1326ca6e65b0879d23ca302c0f2415aad42ff0309b9c818e7949fe19a41d8", size = 291271, upload-time = "2026-05-09T23:12:07.731Z" },
350
+ { url = "https://files.pythonhosted.org/packages/58/b6/14b2c84ff90ddb370c81d27503f4a0fcf071496416f4855f6cc8c5d81c35/regex-2026.5.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ef31cbfe458e21c6122ba8150ff060e0c7789ed0d26eb423f25472584920b555", size = 289212, upload-time = "2026-05-09T23:12:09.266Z" },
351
+ { url = "https://files.pythonhosted.org/packages/03/d0/4db86529117320de0c84afd90e70bb47434625875e34fcef9d8c127c5b16/regex-2026.5.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:992604d02e6d9c6d786c24a706a71ecffe1020fc1ef264044474cd81fa2c3919", size = 792310, upload-time = "2026-05-09T23:12:11.416Z" },
352
+ { url = "https://files.pythonhosted.org/packages/07/78/fe4800cd322f862ecffd2d553409b20d80650e5ed71b9d178f853d020b82/regex-2026.5.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9411dd64ca95477225734a93dfc8583b51916b8d5942f99d6cac21e09965451", size = 861721, upload-time = "2026-05-09T23:12:13.681Z" },
353
+ { url = "https://files.pythonhosted.org/packages/b5/d0/b3618a895dd8feb897c61bb2954edd265e1767d82a01d53065d5871127a3/regex-2026.5.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4a3ff360dfb836fecdb93a4598f9d6e2ac81e3e397125145c6221bf58cf4c", size = 906460, upload-time = "2026-05-09T23:12:15.443Z" },
354
+ { url = "https://files.pythonhosted.org/packages/33/6f/1481597e859ef19508b345eec4afd1416ed6e6b459c75a64026ef193aecf/regex-2026.5.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a661a7d270a61f7cf460caee8b9fa2d5ef9e5c681234bcb9e0fe14f488e7dfc", size = 799843, upload-time = "2026-05-09T23:12:16.892Z" },
355
+ { url = "https://files.pythonhosted.org/packages/73/59/955734c803f59108deccba3597ae440c76b62a652733c0006e6243758420/regex-2026.5.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f079e50a0d3cc3cd5091fa9ff45869a2e6b2cd35895731edafb0327901a8d86d", size = 773610, upload-time = "2026-05-09T23:12:19.127Z" },
356
+ { url = "https://files.pythonhosted.org/packages/68/8f/70c04a236d651c81881dac42ef8538bddda6121434509d0a22d9e601503b/regex-2026.5.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4ebe8f0b5ec5a5024dc4a4c59f444c4e9afc5f2abdbb8962065b75d27fb971f9", size = 781645, upload-time = "2026-05-09T23:12:20.806Z" },
357
+ { url = "https://files.pythonhosted.org/packages/1d/96/05c7434d88185e5d27fe54aeb74df86bd77cd79f52f0b4eae54faa8fea70/regex-2026.5.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:97cf3bc1b7d7d2306772ec07366c80d9df00ff79e79cea32898883a646d2fae2", size = 854473, upload-time = "2026-05-09T23:12:22.465Z" },
358
+ { url = "https://files.pythonhosted.org/packages/4e/c1/6e3d8202d981f3117004bf341ee74893ba4ba8a9fbaf4b94615846550a08/regex-2026.5.9-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0f9eede6a5cbdc02d4978090186390936e1776a7d1359b21e41014c609880bcf", size = 763311, upload-time = "2026-05-09T23:12:24.351Z" },
359
+ { url = "https://files.pythonhosted.org/packages/93/c7/e7737f1526b3fb32bd4c337fd6c71c3ebb5c8296fc34d11197e0955d2e35/regex-2026.5.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:01f0f5f55f4b64dacec85dc116d3c05fd23ad3ff037bbc73a2085775953c2611", size = 844593, upload-time = "2026-05-09T23:12:26.341Z" },
360
+ { url = "https://files.pythonhosted.org/packages/a5/27/0daffb1a535bb39f422c3d200f4ab023c71110ad66a32b366bee708baba0/regex-2026.5.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1268eddd8486dc561d08eee1156e40aa3a8fe10f4bdec8fa653b455fcbffd12c", size = 789167, upload-time = "2026-05-09T23:12:27.975Z" },
361
+ { url = "https://files.pythonhosted.org/packages/ce/fc/294fe4fac4f2ed67207b17471815870c1c45b3a489e08e0ac96daea16ef6/regex-2026.5.9-cp311-cp311-win32.whl", hash = "sha256:8676474c07469d6f33dd1085ca2cd45f65785f32518f2b20e36d9953ca07f994", size = 266249, upload-time = "2026-05-09T23:12:30.141Z" },
362
+ { url = "https://files.pythonhosted.org/packages/d0/b0/8dce459f6245bcf8f6e9f23ac9569f1a0f15c131cc0745e82b43226204cf/regex-2026.5.9-cp311-cp311-win_amd64.whl", hash = "sha256:246de9d60aa3f8538b519834dd95cbf276ea263d6a7bd5a3666dc3fa0230505b", size = 278423, upload-time = "2026-05-09T23:12:31.676Z" },
363
+ { url = "https://files.pythonhosted.org/packages/db/8d/f9aeff6ad63a3ef720386f2907e6d34a35a510a6e498ebad28b0fb3f6ab6/regex-2026.5.9-cp311-cp311-win_arm64.whl", hash = "sha256:d726ca3f0d76969bf1e8e477d160d3d666bbf999f6860bd314889e5345782046", size = 270420, upload-time = "2026-05-09T23:12:33.194Z" },
364
+ { url = "https://files.pythonhosted.org/packages/50/9b/6550044bc44e17c84d312c031c2ec42fbdb6a4ec4e29093be3a172d08772/regex-2026.5.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:57eeeb05db7979413dec5438f2db21d7ecbba787cde7a711df1a6f6df672aa06", size = 490451, upload-time = "2026-05-09T23:12:34.72Z" },
365
+ { url = "https://files.pythonhosted.org/packages/1e/95/fc7ba4303b5a0f92446a12ee6778ef2c6c799233f5060042a31bf390cfe9/regex-2026.5.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:398c521292f4c7fb807001dcd54694d3a1fcafc179a36ad9cc56f98df85930b6", size = 292112, upload-time = "2026-05-09T23:12:36.285Z" },
366
+ { url = "https://files.pythonhosted.org/packages/54/4b/ee27938d1b2c443e89a9a10e00d2d19aa5ee300cd3d61140644e93bb083e/regex-2026.5.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f7a7c26137296beba7784de6eba69c6a93a63ccebc385e4962fe67e267a91225", size = 289599, upload-time = "2026-05-09T23:12:38.089Z" },
367
+ { url = "https://files.pythonhosted.org/packages/d8/dd/ba103dc19614e25f3880800ca67ce093d6e21b325d72b8383c7bf906e9fa/regex-2026.5.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6441cc660d76107934a09c22167200839a0e89604a6297f78a974e66e931d2c0", size = 796732, upload-time = "2026-05-09T23:12:40.062Z" },
368
+ { url = "https://files.pythonhosted.org/packages/cf/e7/f035b4fd858b050b0080bf302968dc0f59ba34e391872d54936758e6844e/regex-2026.5.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:91328f1c23d47595ca3ef0a7557fa129c5a23404b775c770697d2f35b33e0107", size = 865440, upload-time = "2026-05-09T23:12:42.059Z" },
369
+ { url = "https://files.pythonhosted.org/packages/0a/51/8cd301ecc899aea28124357f729f4272f44de7806fc7ca02490bfbe253e8/regex-2026.5.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:93a7860539414dddaefba2b40f8771765ae17949d4c7182b876ce429e11a8309", size = 912329, upload-time = "2026-05-09T23:12:44.373Z" },
370
+ { url = "https://files.pythonhosted.org/packages/cc/1e/3fbe2fa1e8cebd62f3bb7d3321cff1640aca2e240b51d9bd624aad949260/regex-2026.5.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd2810d22146b6d838acc5ec15602cb6b47920aa4e33015df3868eedfd20bab8", size = 801239, upload-time = "2026-05-09T23:12:46.268Z" },
371
+ { url = "https://files.pythonhosted.org/packages/17/2f/6f6008682bf2cf98040a0d3153a8e557b6ab728d7713d045cee4ce544ab8/regex-2026.5.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daff2bdbaf1d23e52fdff7c0b7bc2048b68f978df6a4d107ac981f94caef2e66", size = 777054, upload-time = "2026-05-09T23:12:48.051Z" },
372
+ { url = "https://files.pythonhosted.org/packages/19/2b/eee0d20a6842ba04df4b8847a920b57ef56853f14ef85405473e586b605a/regex-2026.5.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4eeb011098fcb77af513dcef521a3dbecbf8849b1e38940759d293b7a93f5026", size = 785098, upload-time = "2026-05-09T23:12:49.851Z" },
373
+ { url = "https://files.pythonhosted.org/packages/4a/98/6fc1e6410feefb92159edaed5041992bfe390e8d26c721865434acbca558/regex-2026.5.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ea9c8ecfa1b73c73b626534d6626e5340d429630943672b8480724f44e84b962", size = 860095, upload-time = "2026-05-09T23:12:51.666Z" },
374
+ { url = "https://files.pythonhosted.org/packages/18/a3/bd855e0f2cb1a978ecf6fa6bb69632dd9c3f6ea3b81cde62fde14c9daec7/regex-2026.5.9-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cd2846168eb9ee3c513902bc8225409cb1caab31d04728b145171fa1625d9621", size = 765762, upload-time = "2026-05-09T23:12:53.413Z" },
375
+ { url = "https://files.pythonhosted.org/packages/dc/66/0ae8c092e60b14c79d24f8e0b7f0aea5bfbffdcab00b5483d13404d3c3a5/regex-2026.5.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39617fb0cde9c0e6306dc70e3bfc096f3da793219879f7ae7aa341a69fbdcf6d", size = 852100, upload-time = "2026-05-09T23:12:55.256Z" },
376
+ { url = "https://files.pythonhosted.org/packages/21/de/8dfde60fc1b21c946a893ba273403b72617edb261370cb1087099a83f088/regex-2026.5.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd03c4f0e33280d15cae17159b899245d6b7c53d21def19b263b39655061f5ce", size = 789479, upload-time = "2026-05-09T23:12:57.573Z" },
377
+ { url = "https://files.pythonhosted.org/packages/c3/1c/bdcc98f9a4af4fdd166c74941174619ccff4726d3ce32faa8e9a2ecd38dd/regex-2026.5.9-cp312-cp312-win32.whl", hash = "sha256:164eba9b755ea6f244b0d881196fbc1fac09714e9782c9e2732b813142033c8e", size = 266699, upload-time = "2026-05-09T23:12:59.14Z" },
378
+ { url = "https://files.pythonhosted.org/packages/78/87/240d36864f9e48ace85f72e79ced97ceb7f27ce87739a947dcb834b4e6bc/regex-2026.5.9-cp312-cp312-win_amd64.whl", hash = "sha256:86f40a5d6444db30a125c9c9177e6b25dad981cbc37451fd838f145e6edac92e", size = 277783, upload-time = "2026-05-09T23:13:00.789Z" },
379
+ { url = "https://files.pythonhosted.org/packages/4f/b5/7b30f312b0669dff5beebe5b0989dc2d1a312b1a44fab852199c387a5b96/regex-2026.5.9-cp312-cp312-win_arm64.whl", hash = "sha256:96f5f58b54a063d7ea9dca08e1cf57bfe10499c4d579ee672da284f57f5f0070", size = 270513, upload-time = "2026-05-09T23:13:02.426Z" },
380
+ { url = "https://files.pythonhosted.org/packages/aa/da/797e91ecec6f84135da778ddce78c20e0af5d2a15c26f87a81bc3eadb6db/regex-2026.5.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d626b84406444b165fc0ba981604edea39f0588ff1f92baa23fe50799ea9afdb", size = 490303, upload-time = "2026-05-09T23:13:04.382Z" },
381
+ { url = "https://files.pythonhosted.org/packages/44/da/bf30abaaa737b58f4a4b8c4a03659e02fd92092c822e0197ed9e0daab917/regex-2026.5.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d7bdc0ab8f3dd7e1b4f9ab88634e13374669db86bb3c72e8292f07ae313f539f", size = 292019, upload-time = "2026-05-09T23:13:06.022Z" },
382
+ { url = "https://files.pythonhosted.org/packages/2d/e7/d0eaf5713828417b9e5648cf81fa9bacd4961f6ab98c380c2034f8716e35/regex-2026.5.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a8820737949116ffff55fe18f9fc644530063ba6ebfcb8314239416e78f1347c", size = 289468, upload-time = "2026-05-09T23:13:08.214Z" },
383
+ { url = "https://files.pythonhosted.org/packages/d3/9b/b3fdd62b003baa1a9b593cd8c8699c9651c2e80cc21a5c715707983c42d7/regex-2026.5.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0fbdbac82cb3e4450d0ccde7d7a35607f4cb2dd9fba4b8b69bfaf8c9fa6aed", size = 796749, upload-time = "2026-05-09T23:13:10.573Z" },
384
+ { url = "https://files.pythonhosted.org/packages/d4/30/66ab84588765f5b4b271a9ca09ef7ce2b87caa95176ec3d2ad65d7bc4902/regex-2026.5.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57e8915c7986aa33d25e4d3629cef711cd2863f2961b10409f0c04cb8b7d9020", size = 865445, upload-time = "2026-05-09T23:13:12.523Z" },
385
+ { url = "https://files.pythonhosted.org/packages/1a/89/f05169e8588aac365f35ffc7f3bc3184f095ef4cfded7cfaa3c7fd5dbd89/regex-2026.5.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508f56a89ba9cb26e4168cbc37dbd60a28d82430a9e18ad1d25fe0883c314ca2", size = 912322, upload-time = "2026-05-09T23:13:14.281Z" },
386
+ { url = "https://files.pythonhosted.org/packages/30/e1/c93444052cf41581f3c884ab3fb5823daf0992f11cd4388d4275ca610558/regex-2026.5.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d189041f15691cfa2b6c4290448ec221244d225b3f5fe9e7771b34ffcdf6e2", size = 801269, upload-time = "2026-05-09T23:13:16.569Z" },
387
+ { url = "https://files.pythonhosted.org/packages/50/fe/0cf96b882f540e62e8b9956599798203d599c44cf4c77917ca27400ff69b/regex-2026.5.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e82db382b44d0111b22601c509c89f64434816c9e0eef9d1989cda8cc6ff1c04", size = 777085, upload-time = "2026-05-09T23:13:18.675Z" },
388
+ { url = "https://files.pythonhosted.org/packages/23/5c/d78d4924e7fc875557b9e9b768423925fdfaac5549d06da7810019a9bd26/regex-2026.5.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2acfb48634f64996b57f90f39afa692ff362162722581921fe92239a59960f3c", size = 785153, upload-time = "2026-05-09T23:13:20.525Z" },
389
+ { url = "https://files.pythonhosted.org/packages/bf/e0/5214774090e7b4524dcea3e3c4aa74141d43043f8beb49c1599db1c8b53a/regex-2026.5.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d29eebfc9525db68cad3c97eedd7f754fa265aa5cd0cf4f863b2421e1b48fc9f", size = 860164, upload-time = "2026-05-09T23:13:22.263Z" },
390
+ { url = "https://files.pythonhosted.org/packages/6e/e1/4a57a83350319b1271f0d7a249b8672513ed928b237a741631270de6caea/regex-2026.5.9-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:debb893095e944091c16e641a6e33c1b0f4cb61ab945ec5afbf53ce7068834d8", size = 765731, upload-time = "2026-05-09T23:13:24.277Z" },
391
+ { url = "https://files.pythonhosted.org/packages/12/f4/499e74a20c156fc75836ee04a72a38d1a063978f600937f9760467beb1b0/regex-2026.5.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d659eee77986549c9ea45b861c7567e44d6287c3dc9a4565478853f7b9fe2ff6", size = 852062, upload-time = "2026-05-09T23:13:26.125Z" },
392
+ { url = "https://files.pythonhosted.org/packages/5b/92/7eebc0d0a01e78629695f342ba17e0deaff8fb45e79cc0d7b98287da6e3e/regex-2026.5.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2efa205e6d98b24d1f3ab395c11aa15cdf10935bca283d0285e0499c284fba21", size = 789577, upload-time = "2026-05-09T23:13:27.814Z" },
393
+ { url = "https://files.pythonhosted.org/packages/05/a4/018e71f7d2ad48c1ebe6d3ae0026f9b7cb4802fd15c7cc02fdf724355102/regex-2026.5.9-cp313-cp313-win32.whl", hash = "sha256:f3844f134e834076677dd369976e9f5068679fcb8e50102fdf6b7ac96a3ec127", size = 266691, upload-time = "2026-05-09T23:13:29.549Z" },
394
+ { url = "https://files.pythonhosted.org/packages/e6/1d/861a93719fb9ee7dbfc3761b3797b7a3e112a5d42c6129459d2d741be9b5/regex-2026.5.9-cp313-cp313-win_amd64.whl", hash = "sha256:3527bb4942d2c14552155406cdedd906567456821848aed1cb4933a391bf5eca", size = 277747, upload-time = "2026-05-09T23:13:31.859Z" },
395
+ { url = "https://files.pythonhosted.org/packages/d9/c6/0a2436ae4da1ba76e51cb98943c6838a9a721faa40ebe2dce07694ae34e3/regex-2026.5.9-cp313-cp313-win_arm64.whl", hash = "sha256:56a33f191f17d8c417f99945ebdc1e691d3af9605d86ec68c7e54a57e3e17af6", size = 270500, upload-time = "2026-05-09T23:13:33.525Z" },
396
+ { url = "https://files.pythonhosted.org/packages/e8/e9/d21346f7b60ed58789371358ed66b09d00f832e1bd7c06e55d9da5679882/regex-2026.5.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:01f28d868834624c934b8d2e0aa1c8341337e37831f4a012f18a5afcba4cbaf3", size = 494172, upload-time = "2026-05-09T23:13:35.935Z" },
397
+ { url = "https://files.pythonhosted.org/packages/c4/43/fd1177a2032037c681baecdb3422ee4e1424aec4e4f470ef47793d325274/regex-2026.5.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:48036f6374aaa79eb3b754ec29c61d1c6b1606749d705a13f8854fa2539671f6", size = 293952, upload-time = "2026-05-09T23:13:38.307Z" },
398
+ { url = "https://files.pythonhosted.org/packages/f2/7d/9fbf919768368d3f8a4f6c692cf2aa61e482b2b81ec6a298ace4cbf02480/regex-2026.5.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b96350aa424e79d4fd6b567b344dcbe2b2d6bfc48dfe7717587e1fa6d43da6ff", size = 292314, upload-time = "2026-05-09T23:13:40.353Z" },
399
+ { url = "https://files.pythonhosted.org/packages/e2/6c/e41bfeecb589716843e7c4df09ba46ff2a42961457afece19059d85caeef/regex-2026.5.9-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f3af7a4903c5c04a11a196a5aa75cdd7dd3f8508132f9fb3259d9f5908e3b88", size = 811681, upload-time = "2026-05-09T23:13:42.543Z" },
400
+ { url = "https://files.pythonhosted.org/packages/87/83/a5c1c525fba0aa656e88ad0face0b1829788ef4c2fb6b26df58aa1151b84/regex-2026.5.9-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e87577720152d2caae19fe2baaf1f8d5ca12091e9e229f03915c37d1e4b9178", size = 871135, upload-time = "2026-05-09T23:13:44.326Z" },
401
+ { url = "https://files.pythonhosted.org/packages/18/d4/80882e799e440dd878b0979cbebf8fa4d54624a332c83037c7a701649e3f/regex-2026.5.9-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c8b9b9d294cfea3cd19c718ade7cc93492b2c4991abd9a68d0b3477ae6d8e100", size = 917265, upload-time = "2026-05-09T23:13:47.295Z" },
402
+ { url = "https://files.pythonhosted.org/packages/ae/ff/8db60211e2286e396aad7dc7725356c502bff0901ea05bd6cdc2e1a042b9/regex-2026.5.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:728d8bfd28a8845c8b6bc5dc7ce010453d206396786c0765c2740cb65f37791e", size = 816311, upload-time = "2026-05-09T23:13:49.885Z" },
403
+ { url = "https://files.pythonhosted.org/packages/4c/47/742ef579c61730f8d268e5cf1f9ce0e37e2ea041ad0f5644724f2378e463/regex-2026.5.9-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e30b874d341fac767d7df5a0870540541c2c054b80cfaac116e8d367a8a7ff2", size = 785498, upload-time = "2026-05-09T23:13:52.25Z" },
404
+ { url = "https://files.pythonhosted.org/packages/7f/ab/cb0999802dcb0fb95b1ab005e8d4163d8afdd67efc2cb6b6630ac13f8cb1/regex-2026.5.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fd190e88a895a8901325fad284a3f74ea52b1da8525b76cc811fa9b1edf0ce2b", size = 801348, upload-time = "2026-05-09T23:13:54.127Z" },
405
+ { url = "https://files.pythonhosted.org/packages/7d/62/8ca59a24c55bc34d166eefaf3717bd77772f329fdbf984d86581e0a3571c/regex-2026.5.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:8e76e8161ad00694cfce6767d5dea860c6391ac5b83e5c3a39661e696f11fc7e", size = 866493, upload-time = "2026-05-09T23:13:56.067Z" },
406
+ { url = "https://files.pythonhosted.org/packages/8d/3d/30f2ae62cef3278bb5bb821f467277a55fb73f01032cf85997e15e8289a8/regex-2026.5.9-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ddda5340e6c01a293027dd46232fa79eaff1b48058ce7a98f572b6445b088041", size = 772811, upload-time = "2026-05-09T23:13:57.867Z" },
407
+ { url = "https://files.pythonhosted.org/packages/d8/ae/7d2089bcd78ad0c0161bc684339df50032acb438a7bd3305e7ddb1193cec/regex-2026.5.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:205109e96b3cf5adf8f4cd62bedde9487feb282b9497a3535451e5a24cd706a0", size = 856584, upload-time = "2026-05-09T23:13:59.679Z" },
408
+ { url = "https://files.pythonhosted.org/packages/a9/29/92ff47f75990131ea4f24ba17819e5a9d141e10819807e09addd73409af6/regex-2026.5.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbe4579b9f08036aa7d101d1835437a20783574ac66327e6b29b4018a138081", size = 803453, upload-time = "2026-05-09T23:14:01.978Z" },
409
+ { url = "https://files.pythonhosted.org/packages/04/99/eff29f1037dcab36702c9ee5d6858cf1ce2336ea8ea2987f64245b99ea5e/regex-2026.5.9-cp313-cp313t-win32.whl", hash = "sha256:ed2c9e8068b614c574d8d30e543d617cf5379b0535d46f97ef00e904745a08b5", size = 269951, upload-time = "2026-05-09T23:14:03.661Z" },
410
+ { url = "https://files.pythonhosted.org/packages/0e/9d/8870b8981d27b22cda77bb26a5ac7ebfa9c7d9e0dea195a834a82380e748/regex-2026.5.9-cp313-cp313t-win_amd64.whl", hash = "sha256:b46b0f094dc1d3b90356c85a0bd2c9bafc4a6a190b9d6f8ddd5a033b6e088ed4", size = 281240, upload-time = "2026-05-09T23:14:05.56Z" },
411
+ { url = "https://files.pythonhosted.org/packages/72/b1/3379415e8f135c13ac551353397cc4fe97b4978f3cac73c5fcbcded548b8/regex-2026.5.9-cp313-cp313t-win_arm64.whl", hash = "sha256:872acc074bd29ffc9913ecdfedf6ea77502312ca44a4aa0d3779089c6069d8de", size = 272383, upload-time = "2026-05-09T23:14:07.843Z" },
412
+ ]
413
+
414
+ [[package]]
415
+ name = "rich"
416
+ version = "15.0.0"
417
+ source = { registry = "https://pypi.org/simple" }
418
+ dependencies = [
419
+ { name = "markdown-it-py" },
420
+ { name = "pygments" },
421
+ ]
422
+ sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
423
+ wheels = [
424
+ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
425
+ ]
426
+
427
+ [[package]]
428
+ name = "safetensors"
429
+ version = "0.7.0"
430
+ source = { registry = "https://pypi.org/simple" }
431
+ sdist = { url = "https://files.pythonhosted.org/packages/29/9c/6e74567782559a63bd040a236edca26fd71bc7ba88de2ef35d75df3bca5e/safetensors-0.7.0.tar.gz", hash = "sha256:07663963b67e8bd9f0b8ad15bb9163606cd27cc5a1b96235a50d8369803b96b0", size = 200878, upload-time = "2025-11-19T15:18:43.199Z" }
432
+ wheels = [
433
+ { url = "https://files.pythonhosted.org/packages/fa/47/aef6c06649039accf914afef490268e1067ed82be62bcfa5b7e886ad15e8/safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517", size = 467781, upload-time = "2025-11-19T15:18:35.84Z" },
434
+ { url = "https://files.pythonhosted.org/packages/e8/00/374c0c068e30cd31f1e1b46b4b5738168ec79e7689ca82ee93ddfea05109/safetensors-0.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57", size = 447058, upload-time = "2025-11-19T15:18:34.416Z" },
435
+ { url = "https://files.pythonhosted.org/packages/f1/06/578ffed52c2296f93d7fd2d844cabfa92be51a587c38c8afbb8ae449ca89/safetensors-0.7.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07d91d0c92a31200f25351f4acb2bc6aff7f48094e13ebb1d0fb995b54b6542", size = 491748, upload-time = "2025-11-19T15:18:09.79Z" },
436
+ { url = "https://files.pythonhosted.org/packages/ae/33/1debbbb70e4791dde185edb9413d1fe01619255abb64b300157d7f15dddd/safetensors-0.7.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8469155f4cb518bafb4acf4865e8bb9d6804110d2d9bdcaa78564b9fd841e104", size = 503881, upload-time = "2025-11-19T15:18:16.145Z" },
437
+ { url = "https://files.pythonhosted.org/packages/8e/1c/40c2ca924d60792c3be509833df711b553c60effbd91da6f5284a83f7122/safetensors-0.7.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54bef08bf00a2bff599982f6b08e8770e09cc012d7bba00783fc7ea38f1fb37d", size = 623463, upload-time = "2025-11-19T15:18:21.11Z" },
438
+ { url = "https://files.pythonhosted.org/packages/9b/3a/13784a9364bd43b0d61eef4bea2845039bc2030458b16594a1bd787ae26e/safetensors-0.7.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42cb091236206bb2016d245c377ed383aa7f78691748f3bb6ee1bfa51ae2ce6a", size = 532855, upload-time = "2025-11-19T15:18:25.719Z" },
439
+ { url = "https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48", size = 507152, upload-time = "2025-11-19T15:18:33.023Z" },
440
+ { url = "https://files.pythonhosted.org/packages/3c/a8/4b45e4e059270d17af60359713ffd83f97900d45a6afa73aaa0d737d48b6/safetensors-0.7.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1d060c70284127fa805085d8f10fbd0962792aed71879d00864acda69dbab981", size = 541856, upload-time = "2025-11-19T15:18:31.075Z" },
441
+ { url = "https://files.pythonhosted.org/packages/06/87/d26d8407c44175d8ae164a95b5a62707fcc445f3c0c56108e37d98070a3d/safetensors-0.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cdab83a366799fa730f90a4ebb563e494f28e9e92c4819e556152ad55e43591b", size = 674060, upload-time = "2025-11-19T15:18:37.211Z" },
442
+ { url = "https://files.pythonhosted.org/packages/11/f5/57644a2ff08dc6325816ba7217e5095f17269dada2554b658442c66aed51/safetensors-0.7.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85", size = 771715, upload-time = "2025-11-19T15:18:38.689Z" },
443
+ { url = "https://files.pythonhosted.org/packages/86/31/17883e13a814bd278ae6e266b13282a01049b0c81341da7fd0e3e71a80a3/safetensors-0.7.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0", size = 714377, upload-time = "2025-11-19T15:18:40.162Z" },
444
+ { url = "https://files.pythonhosted.org/packages/4a/d8/0c8a7dc9b41dcac53c4cbf9df2b9c83e0e0097203de8b37a712b345c0be5/safetensors-0.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4", size = 677368, upload-time = "2025-11-19T15:18:41.627Z" },
445
+ { url = "https://files.pythonhosted.org/packages/05/e5/cb4b713c8a93469e3c5be7c3f8d77d307e65fe89673e731f5c2bfd0a9237/safetensors-0.7.0-cp38-abi3-win32.whl", hash = "sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba", size = 326423, upload-time = "2025-11-19T15:18:45.74Z" },
446
+ { url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" },
447
+ ]
448
+
449
+ [[package]]
450
+ name = "shellingham"
451
+ version = "1.5.4"
452
+ source = { registry = "https://pypi.org/simple" }
453
+ sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
454
+ wheels = [
455
+ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
456
+ ]
457
+
458
+ [[package]]
459
+ name = "siglip2-triton-server"
460
+ version = "0.1.0"
461
+ source = { virtual = "." }
462
+ dependencies = [
463
+ { name = "numpy" },
464
+ { name = "pillow" },
465
+ { name = "starlette" },
466
+ { name = "tokenizers" },
467
+ { name = "transformers" },
468
+ { name = "urllib3" },
469
+ { name = "wheel" },
470
+ ]
471
+
472
+ [package.metadata]
473
+ requires-dist = [
474
+ { name = "numpy" },
475
+ { name = "pillow", specifier = ">=12.3.0" },
476
+ { name = "starlette", specifier = ">=1.3.1" },
477
+ { name = "tokenizers", specifier = ">=0.22.2" },
478
+ { name = "transformers", specifier = ">=4.57.1" },
479
+ { name = "urllib3", specifier = ">=2.7.0" },
480
+ { name = "wheel", specifier = ">=0.46.3" },
481
+ ]
482
+
483
+ [[package]]
484
+ name = "starlette"
485
+ version = "1.3.1"
486
+ source = { registry = "https://pypi.org/simple" }
487
+ dependencies = [
488
+ { name = "anyio" },
489
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
490
+ ]
491
+ sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
492
+ wheels = [
493
+ { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
494
+ ]
495
+
496
+ [[package]]
497
+ name = "tokenizers"
498
+ version = "0.22.2"
499
+ source = { registry = "https://pypi.org/simple" }
500
+ dependencies = [
501
+ { name = "huggingface-hub" },
502
+ ]
503
+ sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" }
504
+ wheels = [
505
+ { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" },
506
+ { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" },
507
+ { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" },
508
+ { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" },
509
+ { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" },
510
+ { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" },
511
+ { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" },
512
+ { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" },
513
+ { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" },
514
+ { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" },
515
+ { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" },
516
+ { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" },
517
+ { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" },
518
+ { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" },
519
+ { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" },
520
+ ]
521
+
522
+ [[package]]
523
+ name = "tqdm"
524
+ version = "4.67.3"
525
+ source = { registry = "https://pypi.org/simple" }
526
+ dependencies = [
527
+ { name = "colorama", marker = "sys_platform == 'win32'" },
528
+ ]
529
+ sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" }
530
+ wheels = [
531
+ { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
532
+ ]
533
+
534
+ [[package]]
535
+ name = "transformers"
536
+ version = "5.8.1"
537
+ source = { registry = "https://pypi.org/simple" }
538
+ dependencies = [
539
+ { name = "huggingface-hub" },
540
+ { name = "numpy" },
541
+ { name = "packaging" },
542
+ { name = "pyyaml" },
543
+ { name = "regex" },
544
+ { name = "safetensors" },
545
+ { name = "tokenizers" },
546
+ { name = "tqdm" },
547
+ { name = "typer" },
548
+ ]
549
+ sdist = { url = "https://files.pythonhosted.org/packages/e7/e6/4134ea2fbea322cddc7ffc94a0d8ee47fe32ce8e876b320cd37d88edfc4d/transformers-5.8.1.tar.gz", hash = "sha256:4dd5b6de4105725104d84fd6abd74b305f4debfc251b38c648ee5dd087cf543b", size = 8532019, upload-time = "2026-05-13T03:21:57.234Z" }
550
+ wheels = [
551
+ { url = "https://files.pythonhosted.org/packages/fc/b1/8be7e7ef0b5200491312201918b6125ef9c9df9dd0f0240ccef9ac824e6b/transformers-5.8.1-py3-none-any.whl", hash = "sha256:5340fb95962162cdfdae5cc91d7f8fedd92ed75216c1154c5e1f590fcf56dd0e", size = 10632882, upload-time = "2026-05-13T03:21:52.876Z" },
552
+ ]
553
+
554
+ [[package]]
555
+ name = "typer"
556
+ version = "0.25.1"
557
+ source = { registry = "https://pypi.org/simple" }
558
+ dependencies = [
559
+ { name = "annotated-doc" },
560
+ { name = "click" },
561
+ { name = "rich" },
562
+ { name = "shellingham" },
563
+ ]
564
+ sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" }
565
+ wheels = [
566
+ { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" },
567
+ ]
568
+
569
+ [[package]]
570
+ name = "typing-extensions"
571
+ version = "4.15.0"
572
+ source = { registry = "https://pypi.org/simple" }
573
+ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
574
+ wheels = [
575
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
576
+ ]
577
+
578
+ [[package]]
579
+ name = "urllib3"
580
+ version = "2.7.0"
581
+ source = { registry = "https://pypi.org/simple" }
582
+ sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
583
+ wheels = [
584
+ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
585
+ ]
586
+
587
+ [[package]]
588
+ name = "wheel"
589
+ version = "0.47.0"
590
+ source = { registry = "https://pypi.org/simple" }
591
+ dependencies = [
592
+ { name = "packaging" },
593
+ ]
594
+ sdist = { url = "https://files.pythonhosted.org/packages/39/62/75f18a0f03b4219c456652c7780e4d749b929eb605c098ce3a5b6b6bc081/wheel-0.47.0.tar.gz", hash = "sha256:cc72bd1009ba0cf63922e28f94d9d83b920aa2bb28f798a31d0691b02fa3c9b3", size = 63854, upload-time = "2026-04-22T15:51:27.727Z" }
595
+ wheels = [
596
+ { url = "https://files.pythonhosted.org/packages/87/1b/9e33c09813d65e248f7f773119148a612516a4bea93e9c6f545f78455b7c/wheel-0.47.0-py3-none-any.whl", hash = "sha256:212281cab4dff978f6cedd499cd893e1f620791ca6ff7107cf270781e587eced", size = 32218, upload-time = "2026-04-22T15:51:26.296Z" },
597
+ ]
docker/hf-space/supervisord-gpu.conf ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ; GPU variant of supervisord.conf (docker/Dockerfile.hf-space-gpu): the same
2
+ ; six programs as the NVCF/cpu-basic variant, plus [program:siglip2-triton]
3
+ ; (restores its ONNX weights from S3, then execs the local siglip2-triton
4
+ ; server β€” see its own comment), and deepsearch-api's own command
5
+ ; additionally waits on Triton's readiness before execing uvicorn β€” same
6
+ ; "poll a real endpoint the process only starts once ready" idiom already
7
+ ; used below for neo4j/opensearch. See
8
+ ; docs/plans/siglip2-embedded-hf-space.md for the decision behind this file.
9
+ ; Verified live end to end (2026-09-23), including through a real bake.py
10
+ ; run and its own --gpus all smoke test.
11
+
12
+ [supervisord]
13
+ nodaemon=true
14
+ logfile=/dev/stdout
15
+ logfile_maxbytes=0
16
+ pidfile=/tmp/supervisord.pid
17
+
18
+ [program:opensearch]
19
+ command=/usr/share/opensearch/opensearch-docker-entrypoint.sh opensearch
20
+ directory=/usr/share/opensearch
21
+ user=1000
22
+ autorestart=true
23
+ startsecs=5
24
+ stdout_logfile=/dev/stdout
25
+ stdout_logfile_maxbytes=0
26
+ stderr_logfile=/dev/stderr
27
+ stderr_logfile_maxbytes=0
28
+
29
+ [program:redis]
30
+ command=/usr/bin/redis-server --port 6379 --databases 32 --save "" --stop-writes-on-bgsave-error no --dir /tmp
31
+ user=1000
32
+ autorestart=true
33
+ startsecs=3
34
+ stdout_logfile=/dev/stdout
35
+ stdout_logfile_maxbytes=0
36
+ stderr_logfile=/dev/stderr
37
+ stderr_logfile_maxbytes=0
38
+
39
+ [program:siglip2-triton]
40
+ ; This program's command *is* pipeline/hf_space_seed.py --component=siglip2-model,
41
+ ; not tritonserver itself β€” same "restore then exec into the real process"
42
+ ; shape as [program:neo4j] below, for the same reason: the ~3.6 GB SigLIP2
43
+ ; ONNX encoder weights aren't baked into the image (HF Space git repos cap
44
+ ; storage at 1 GB by default β€” verified live, a real deploy blew through it)
45
+ ; so they're synced from the artifact S3 bucket here before tritonserver
46
+ ; execs β€” see that script's own docstring. The exec'd tritonserver command
47
+ ; (SIGLIP2_TRITON_CMD in hf_space_seed.py) --http-ports off Triton's own
48
+ ; default (8000) because deepsearch-api's own uvicorn already binds
49
+ ; 127.0.0.1:8000 in this same container β€” only the gRPC port (8001,
50
+ ; Triton's default, unchanged) is actually used by the siglip2-triton-client
51
+ ; wheel deepsearch-api talks through; the HTTP port is kept (not disabled)
52
+ ; only so this program's own readiness can be polled the same curl-based
53
+ ; way every other program here is. Nothing here is bound beyond localhost
54
+ ; in effect anyway β€” nginx is the sole externally-proxied process in this
55
+ ; image, same as every other backend.
56
+ command=/bin/sh -c "cd /app/hf-space-pipeline && exec /app/.venv/bin/python -m pipeline.hf_space_seed --component=siglip2-model"
57
+ directory=/app/hf-space-pipeline
58
+ user=root
59
+ autorestart=true
60
+ startsecs=10
61
+ stdout_logfile=/dev/stdout
62
+ stdout_logfile_maxbytes=0
63
+ stderr_logfile=/dev/stderr
64
+ stderr_logfile_maxbytes=0
65
+
66
+ [program:neo4j]
67
+ command=/bin/sh -c "cd /app/hf-space-pipeline && exec /app/.venv/bin/python -m pipeline.hf_space_seed --component=neo4j"
68
+ directory=/app/hf-space-pipeline
69
+ user=1000
70
+ autorestart=true
71
+ startsecs=10
72
+ stdout_logfile=/dev/stdout
73
+ stdout_logfile_maxbytes=0
74
+ stderr_logfile=/dev/stderr
75
+ stderr_logfile_maxbytes=0
76
+
77
+ [program:asset-graph-service]
78
+ command=/bin/sh -c "until curl -sf http://localhost:7474 >/dev/null 2>&1; do sleep 2; done; exec /app/.venv/bin/python -m uvicorn asset_graph_service.api:app --host 127.0.0.1 --port 8010"
79
+ environment=DB_URI="neo4j://localhost:7687",DB_USERNAME="neo4j",DB_PASSWORD="none",ACCEPT_EULA="Y",OTEL_SDK_DISABLED="true"
80
+ directory=/app
81
+ user=1000
82
+ autorestart=true
83
+ startsecs=10
84
+ stdout_logfile=/dev/stdout
85
+ stdout_logfile_maxbytes=0
86
+ stderr_logfile=/dev/stderr
87
+ stderr_logfile_maxbytes=0
88
+
89
+ [program:deepsearch-api]
90
+ ; Waits on siglip2-triton's own readiness (via its remapped HTTP port,
91
+ ; 8003 β€” see [program:siglip2-triton] above) in addition to running
92
+ ; pipeline/hf_space_seed.py, same "don't exec uvicorn until every backend
93
+ ; this process depends on is actually ready" rule the cpu-basic/NVCF
94
+ ; variant already follows for OpenSearch.
95
+ command=/bin/sh -c "until curl -sf http://localhost:8003/v2/health/ready >/dev/null 2>&1; do sleep 2; done; cd /app/hf-space-pipeline && /app/.venv/bin/python -m pipeline.hf_space_seed && cd /app && exec /app/.venv/bin/python -m uvicorn deepsearch_api.main:app --host 127.0.0.1 --port 8000"
96
+ directory=/app
97
+ user=1000
98
+ autorestart=true
99
+ startsecs=10
100
+ stdout_logfile=/dev/stdout
101
+ stdout_logfile_maxbytes=0
102
+ stderr_logfile=/dev/stderr
103
+ stderr_logfile_maxbytes=0
104
+
105
+ [program:nginx]
106
+ command=/bin/sh -c "until curl -sf http://localhost:8000/health >/dev/null 2>&1 && curl -sf http://localhost:8010/docs >/dev/null 2>&1; do sleep 2; done; sed -e 's/__PORT__/%(ENV_PORT)s/' -e 's|__CORPUS_NAME__|%(ENV_CORPUS_NAME)s|g' /etc/nginx/hf-space.conf.template > /etc/nginx/conf.d/hf-space.conf && exec nginx -g 'daemon off;'"
107
+ user=root
108
+ autorestart=true
109
+ startsecs=3
110
+ stdout_logfile=/dev/stdout
111
+ stdout_logfile_maxbytes=0
112
+ stderr_logfile=/dev/stderr
113
+ stderr_logfile_maxbytes=0
docker/hf-space/supervisord.conf ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ; Runs the MVP single-container stack (OpenSearch + Neo4j + Redis +
2
+ ; deepsearch-api + asset-graph-service) for a Hugging Face Docker Space,
3
+ ; which proxies exactly one port and has no docker-compose support β€” see
4
+ ; docker/Dockerfile.hf-space.
5
+ ;
6
+ ; No local siglip2-triton β€” SigLIP2 embeddings always go to NVCF in this
7
+ ; image. No graph-builder/asset_graph_generation worker either: both are
8
+ ; indexing-time-only (building the graph from scratch), not needed to serve
9
+ ; an already-built one β€” deepsearch-api's asset-graph calls are wired up as
10
+ ; a dependency-injected per-request client (see routers_v2/dependencies.py),
11
+ ; never a hard startup dependency, so /v2/deepsearch/search and
12
+ ; /v3/deepsearch/search work regardless of asset-graph-service's own
13
+ ; readiness.
14
+
15
+ [supervisord]
16
+ nodaemon=true
17
+ logfile=/dev/stdout
18
+ logfile_maxbytes=0
19
+ pidfile=/tmp/supervisord.pid
20
+
21
+ [program:opensearch]
22
+ command=/usr/share/opensearch/opensearch-docker-entrypoint.sh opensearch
23
+ directory=/usr/share/opensearch
24
+ user=1000
25
+ autorestart=true
26
+ startsecs=5
27
+ stdout_logfile=/dev/stdout
28
+ stdout_logfile_maxbytes=0
29
+ stderr_logfile=/dev/stderr
30
+ stderr_logfile_maxbytes=0
31
+
32
+ [program:redis]
33
+ command=/usr/bin/redis-server --port 6379 --databases 32 --save "" --stop-writes-on-bgsave-error no --dir /tmp
34
+ user=1000
35
+ autorestart=true
36
+ startsecs=3
37
+ stdout_logfile=/dev/stdout
38
+ stdout_logfile_maxbytes=0
39
+ stderr_logfile=/dev/stderr
40
+ stderr_logfile_maxbytes=0
41
+
42
+ [program:neo4j]
43
+ ; This program's command *is* pipeline/hf_space_seed.py --component=neo4j,
44
+ ; not neo4j itself: neo4j-admin's offline load has to finish before the
45
+ ; real neo4j process starts (unlike OpenSearch's live bulk-load), so the
46
+ ; seed step execs into the actual `neo4j console` as its last step once
47
+ ; restore is done β€” see that module's docstring. A restore failure (e.g.
48
+ ; bad AWS creds, no published run) correctly counts as this program failing
49
+ ; to start, same as every other startup error here.
50
+ command=/bin/sh -c "cd /app/hf-space-pipeline && exec /app/.venv/bin/python -m pipeline.hf_space_seed --component=neo4j"
51
+ directory=/app/hf-space-pipeline
52
+ user=1000
53
+ autorestart=true
54
+ startsecs=10
55
+ stdout_logfile=/dev/stdout
56
+ stdout_logfile_maxbytes=0
57
+ stderr_logfile=/dev/stderr
58
+ stderr_logfile_maxbytes=0
59
+
60
+ [program:asset-graph-service]
61
+ ; Waits for neo4j's own HTTP port rather than depending on it through
62
+ ; supervisord (which has no real dependency ordering) β€” same shape as
63
+ ; deepsearch-api waiting on hf_space_seed.py's own OpenSearch poll, just
64
+ ; inline here since it's a one-line curl loop rather than needing a Python
65
+ ; script. Bound to localhost only, same as deepsearch-api β€” nginx is the
66
+ ; sole externally-proxied process.
67
+ command=/bin/sh -c "until curl -sf http://localhost:7474 >/dev/null 2>&1; do sleep 2; done; exec /app/.venv/bin/python -m uvicorn asset_graph_service.api:app --host 127.0.0.1 --port 8010"
68
+ environment=DB_URI="neo4j://localhost:7687",DB_USERNAME="neo4j",DB_PASSWORD="none",ACCEPT_EULA="Y",OTEL_SDK_DISABLED="true"
69
+ directory=/app
70
+ user=1000
71
+ autorestart=true
72
+ startsecs=10
73
+ stdout_logfile=/dev/stdout
74
+ stdout_logfile_maxbytes=0
75
+ stderr_logfile=/dev/stderr
76
+ stderr_logfile_maxbytes=0
77
+
78
+ [program:deepsearch-api]
79
+ ; Bound to localhost only β€” nginx (below) is the sole process bound to the
80
+ ; externally proxied port, so it can front every route with the friendly-URL
81
+ ; rewrites and CORS headers in nginx.conf.template.
82
+ ;
83
+ ; Runs pipeline/hf_space_seed.py first and only execs uvicorn once it
84
+ ; succeeds: that script waits for opensearch (started in parallel by
85
+ ; supervisord, below) and bulk-loads this corpus's indexed run from S3
86
+ ; before deepsearch-api has anything to search β€” see that module's
87
+ ; docstring for why this happens at container startup, not `docker build`
88
+ ; time. A seeding failure (e.g. no published run yet, or bad AWS creds)
89
+ ; correctly counts as this program failing to start, same as any other
90
+ ; startup error here.
91
+ command=/bin/sh -c "cd /app/hf-space-pipeline && /app/.venv/bin/python -m pipeline.hf_space_seed && cd /app && exec /app/.venv/bin/python -m uvicorn deepsearch_api.main:app --host 127.0.0.1 --port 8000"
92
+ directory=/app
93
+ user=1000
94
+ autorestart=true
95
+ startsecs=10
96
+ stdout_logfile=/dev/stdout
97
+ stdout_logfile_maxbytes=0
98
+ stderr_logfile=/dev/stderr
99
+ stderr_logfile_maxbytes=0
100
+
101
+ [program:nginx]
102
+ ; nginx is the *only* process bound to the externally-proxied port, and
103
+ ; Hugging Face's own Space "ready" detection is just "does something answer
104
+ ; on app_port" β€” so starting nginx immediately (as this used to) flipped the
105
+ ; Space to Running as soon as the container booted, while deepsearch-api and
106
+ ; asset-graph-service were still behind it restoring their data (a
107
+ ; multi-hundred-MB S3 bulk load / offline neo4j-admin load, routinely taking
108
+ ; minutes β€” see bake.py's own verify_baked comment on the same point): the
109
+ ; Explorer UI loaded instantly but every search/graph call 502'd or came
110
+ ; back empty until restore finished. Waiting for both backends' own readiness
111
+ ; first β€” same "poll a real endpoint the process only starts once restore
112
+ ; finished" idiom asset-graph-service already uses above for neo4j, not a
113
+ ; new pattern β€” means the external port stays closed, and HF keeps the Space
114
+ ; in "building"/"starting" rather than Running, until the stack actually
115
+ ; works. /health / /docs are plain unauthenticated GETs, so no extra config
116
+ ; needed to poll them from inside the container.
117
+ ;
118
+ ; nginx reads $PORT if HF sets it (Spaces convention), else the baked
119
+ ; default matches the Space's declared app_port (7860) β€” same convention
120
+ ; deepsearch-api used to follow directly before nginx sat in front of it.
121
+ ; %(ENV_PORT)s is substituted by supervisord itself, so no extra templating
122
+ ; tool (envsubst etc.) is needed in the image.
123
+ command=/bin/sh -c "until curl -sf http://localhost:8000/health >/dev/null 2>&1 && curl -sf http://localhost:8010/docs >/dev/null 2>&1; do sleep 2; done; sed -e 's/__PORT__/%(ENV_PORT)s/' -e 's|__CORPUS_NAME__|%(ENV_CORPUS_NAME)s|g' /etc/nginx/hf-space.conf.template > /etc/nginx/conf.d/hf-space.conf && exec nginx -g 'daemon off;'"
124
+ user=root
125
+ autorestart=true
126
+ startsecs=3
127
+ stdout_logfile=/dev/stdout
128
+ stdout_logfile_maxbytes=0
129
+ stderr_logfile=/dev/stderr
130
+ stderr_logfile_maxbytes=0
docker/hf-space/wheels/SOURCE.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # Generated by scripts/extract_usdsearch_wheels.sh β€” do not hand-edit.
2
+ source_image=nvcr.io/m3sujtetvf5w/usdsearch/usdsearch:1.5.0-a1b3d0fa
3
+ extracted_at=2026-09-24T14:15:22Z
docker/hf-space/wheels/asset_graph_client-0.1.0-py3-none-any.whl ADDED
Binary file (89.1 kB). View file
 
docker/hf-space/wheels/asset_graph_service-0.1.0-py3-none-any.whl ADDED
Binary file (43.6 kB). View file
 
docker/hf-space/wheels/base_logging-0.1.0-py3-none-any.whl ADDED
Binary file (3.04 kB). View file
 
docker/hf-space/wheels/constraints.txt ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ aioboto3==15.5.0
2
+ aiobotocore==2.25.1
3
+ aiofiles==25.1.0
4
+ aiohappyeyeballs==2.6.1
5
+ aiohttp==3.14.3
6
+ aiohttp-retry==2.9.1
7
+ aioitertools==0.13.0
8
+ aiosignal==1.4.0
9
+ annotated-doc==0.0.4
10
+ annotated-types==0.7.0
11
+ anyio==4.13.0
12
+ asgi-correlation-id==4.3.4
13
+ asgiref==3.11.1
14
+ asset-graph-builder==0.1.0
15
+ async-lru==2.3.0
16
+ attrs==26.1.0
17
+ Authlib==1.7.2
18
+ backoff==2.2.1
19
+ boto3==1.40.61
20
+ botocore==1.40.61
21
+ certifi==2026.4.22
22
+ cffi==2.0.0
23
+ charset-normalizer==3.4.7
24
+ click==8.3.2
25
+ cryptography==50.0.0
26
+ deepsearch-crawler==0.1.0
27
+ deepsearch-monitor==0.1.0
28
+ deepsearch-utils==0.1.0
29
+ Deprecated==1.3.1
30
+ distro==1.9.0
31
+ elastic-transport==9.2.1
32
+ elasticsearch==9.3.0
33
+ envyaml==1.10.211231
34
+ Events==0.5
35
+ fastapi==0.136.0
36
+ fastapi-swagger==0.4.48
37
+ filelock==3.29.0
38
+ fire==0.7.1
39
+ frozenlist==1.8.0
40
+ fsspec==2026.3.0
41
+ googleapis-common-protos==1.74.0
42
+ greenlet==3.4.0
43
+ grpcio==1.80.0
44
+ h11==0.16.0
45
+ hf-xet==1.4.3
46
+ hiredis==3.3.1
47
+ httpcore==1.0.9
48
+ httptools==0.7.1
49
+ httpx==0.28.1
50
+ huggingface_hub==1.11.0
51
+ humanize==4.15.0
52
+ idna==3.18
53
+ importlib_metadata==8.7.1
54
+ Jinja2==3.1.6
55
+ jiter==0.14.0
56
+ jmespath==1.1.0
57
+ joserfc==1.7.2
58
+ jsonpatch==1.33
59
+ jsonpointer==3.1.1
60
+ kubernetes_asyncio==35.0.1
61
+ langchain==1.3.15
62
+ langchain-classic==1.0.7
63
+ langchain-core==1.5.6
64
+ langchain-openai==1.1.16
65
+ langchain-protocol==0.0.18
66
+ langchain-text-splitters==1.1.2
67
+ langgraph==1.2.11
68
+ langgraph-checkpoint==4.1.1
69
+ langgraph-prebuilt==1.1.0
70
+ langgraph-sdk==0.4.2
71
+ langsmith==0.8.18
72
+ markdown-it-py==4.0.0
73
+ MarkupSafe==3.0.3
74
+ mdurl==0.1.2
75
+ multidict==6.7.1
76
+ neo4j==6.1.0
77
+ ngsearch-backend==0.1.0
78
+ ngsearch-cache==0.1.0
79
+ ngsearch-crawlers==0.1.0
80
+ ngsearch-info-endpoint==0.1.0
81
+ ngsearch-plugins==0.1.0
82
+ ngsearch-storage==0.1.0
83
+ numpy==2.4.6
84
+ openai==2.32.0
85
+ opensearch-protobufs==0.19.0
86
+ opensearch-py==3.1.0
87
+ opentelemetry-api==1.41.0
88
+ opentelemetry-exporter-otlp==1.41.0
89
+ opentelemetry-exporter-otlp-proto-common==1.41.0
90
+ opentelemetry-exporter-otlp-proto-grpc==1.41.0
91
+ opentelemetry-exporter-otlp-proto-http==1.41.0
92
+ opentelemetry-instrumentation==0.62b0
93
+ opentelemetry-instrumentation-aiohttp-client==0.62b0
94
+ opentelemetry-instrumentation-asgi==0.62b0
95
+ opentelemetry-instrumentation-fastapi==0.62b0
96
+ opentelemetry-instrumentation-grpc==0.62b0
97
+ opentelemetry-instrumentation-httpx==0.62b0
98
+ opentelemetry-proto==1.41.0
99
+ opentelemetry-sdk==1.41.0
100
+ opentelemetry-semantic-conventions==0.62b0
101
+ opentelemetry-util-http==0.62b0
102
+ orjson==3.11.8
103
+ ormsgpack==1.12.2
104
+ packaging==26.1
105
+ pillow==12.3.0
106
+ prometheus_client==0.25.0
107
+ prometheus-fastapi-instrumentator==8.0.0
108
+ propcache==0.4.1
109
+ protobuf==6.33.6
110
+ psutil==7.2.2
111
+ pycparser==3.0
112
+ pydantic==2.13.3
113
+ pydantic_core==2.46.3
114
+ pydantic-settings==2.14.2
115
+ Pygments==2.20.0
116
+ pyinstrument==5.1.2
117
+ pyparsing==3.3.2
118
+ python-dateutil==2.9.0.post0
119
+ python-dotenv==1.2.2
120
+ python-json-logger==4.1.0
121
+ python-multipart==0.0.32
122
+ python-rapidjson==1.23
123
+ pytinyexr==0.9.1
124
+ pytz==2026.1.post1
125
+ PyYAML==6.0.3
126
+ redis==7.4.0
127
+ regex==2026.4.4
128
+ requests==2.33.1
129
+ requests-toolbelt==1.0.0
130
+ rich==15.0.0
131
+ s3transfer==0.14.0
132
+ safetensors==0.7.0
133
+ sentry-sdk==2.58.0
134
+ setuptools==82.0.1
135
+ shellingham==1.5.4
136
+ six==1.17.0
137
+ sniffio==1.3.1
138
+ SQLAlchemy==2.0.49
139
+ starlette==1.3.1
140
+ tenacity==9.1.4
141
+ termcolor==3.3.0
142
+ tiktoken==0.12.0
143
+ tokenizers==0.22.2
144
+ tqdm==4.67.3
145
+ transformers==5.5.4
146
+ tritonclient==2.41.0
147
+ typer==0.24.1
148
+ typing_extensions==4.15.0
149
+ typing-inspection==0.4.2
150
+ urllib3==2.7.0
151
+ usd-core==25.11
152
+ usdsearch==0.1.0
153
+ uuid_utils==0.14.1
154
+ uvicorn==0.45.0
155
+ websockets==15.0.1
156
+ wrapt==1.17.3
157
+ xxhash==3.6.0
158
+ yarl==1.23.0
159
+ zipp==3.23.1
160
+ zstandard==0.25.0
docker/hf-space/wheels/deepsearch_api-0.1.0-py3-none-any.whl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:16406fa180ff1ecac8b48bbface39243eeb471bfcee62af8f5b3cccb7891985e
3
+ size 212789
docker/hf-space/wheels/llm_client-0.1.0-py3-none-any.whl ADDED
Binary file (39.4 kB). View file
 
docker/hf-space/wheels/search_utils-0.1.0-py3-none-any.whl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f81e65e56543e8393c4bae7aecab58b4c66c5cb8034e450ca88993efde4aaca9
3
+ size 286603
docker/hf-space/wheels/siglip2_triton_client-0.6.1-py3-none-any.whl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5f8405481eb33cd4bc22401bf7a9391d5ba32342caa3206ce990854d7ca15df0
3
+ size 5482420
docker/hf-space/wheels/storage_api-0.1.0-py3-none-any.whl ADDED
Binary file (77.1 kB). View file
 
pipeline/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """USD Search index re-generation pipeline."""
pipeline/artifact_store.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared plumbing for talking to the dedicated artifact S3 bucket.
2
+
3
+ Used by both `upload` (stage 3, write) and `restore` (read, to continue
4
+ indexing on top of a previous run's data). Deliberately separate credentials
5
+ from `S3_STORAGE_*` (the source corpus bucket), so a mistake in one credential
6
+ set can't touch the other bucket β€” see the plan, Β§7.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import functools
12
+ import os
13
+ import re
14
+ import subprocess
15
+ import time
16
+ from typing import Dict, List
17
+
18
+ from .config import Config
19
+
20
+
21
+ @functools.lru_cache(maxsize=1)
22
+ def _aws_cli_major_version() -> int:
23
+ """1 or 2 β€” memoized, one `aws --version` subprocess call per process.
24
+
25
+ Defaults to 1 (the more defensive branch in aws_cmd(), below) if
26
+ detection fails for any reason, rather than assuming the newer,
27
+ less-defensive CLI is present."""
28
+ try:
29
+ result = subprocess.run(["aws", "--version"], capture_output=True, text=True, timeout=10)
30
+ match = re.search(r"aws-cli/(\d+)\.", result.stdout + result.stderr)
31
+ if match:
32
+ return int(match.group(1))
33
+ except Exception: # noqa: BLE001 - detection failure just falls through to the default below
34
+ pass
35
+ return 1
36
+
37
+ ARTIFACT_AWS_ACCESS_KEY_ID_VAR = "SIMREADYCENTRAL_INDEX_STORE_AWS_ACCESS_KEY_ID"
38
+ ARTIFACT_AWS_SECRET_ACCESS_KEY_VAR = "SIMREADYCENTRAL_INDEX_STORE_AWS_SECRET_ACCESS_KEY"
39
+
40
+
41
+ def artifact_env() -> Dict[str, str]:
42
+ key = os.environ.get(ARTIFACT_AWS_ACCESS_KEY_ID_VAR, "").strip()
43
+ secret = os.environ.get(ARTIFACT_AWS_SECRET_ACCESS_KEY_VAR, "").strip()
44
+ if not key or not secret:
45
+ raise SystemExit(
46
+ f"{ARTIFACT_AWS_ACCESS_KEY_ID_VAR} / {ARTIFACT_AWS_SECRET_ACCESS_KEY_VAR} "
47
+ "must be set in the environment to reach the artifact bucket."
48
+ )
49
+ env = dict(os.environ)
50
+ env["AWS_ACCESS_KEY_ID"] = key
51
+ env["AWS_SECRET_ACCESS_KEY"] = secret
52
+ env.pop("AWS_PROFILE", None)
53
+ return env
54
+
55
+
56
+ def aws_cmd(cfg: Config, read_timeout: int = 300) -> List[str]:
57
+ cmd = ["aws", "s3"]
58
+ # aws-cli v1's `s3` subcommands mis-sign SigV4 requests against a bucket
59
+ # outside the CLI's configured default region (`SignatureDoesNotMatch`,
60
+ # even though `aws s3api` and plain `--region` alone are unaffected) β€”
61
+ # pin the endpoint explicitly to the artifact bucket's actual region.
62
+ # v1-only: verified live (2026-09-23) that aws-cli v2 (what both HF Space
63
+ # Dockerfiles bake, via the official installer β€” see
64
+ # docker/Dockerfile.hf-space-gpu) signs correctly with just `--region`,
65
+ # no `--endpoint-url` override needed at all. Kept conditional rather
66
+ # than dropped outright: this function is also called by this pipeline's
67
+ # own local `enumerate`/`upload`/`restore` stages, run on whatever `aws`
68
+ # an operator's own machine happens to have on PATH β€” and at least one
69
+ # (this session's own dev machine) is still v1.
70
+ region = cfg.get("ARTIFACT_S3_REGION", "us-east-2").strip()
71
+ if _aws_cli_major_version() >= 2:
72
+ cmd += ["--region", region]
73
+ else:
74
+ cmd += ["--endpoint-url", f"https://s3.{region}.amazonaws.com"]
75
+ # Bounded, not left to whatever the network happens to do: a CI run
76
+ # hung for 45 minutes (the whole job's ceiling) with no error and no
77
+ # log, right around where restore's `aws s3 cp`/`sync` calls run β€” those
78
+ # had no timeout anywhere, unlike the httpx calls elsewhere in this
79
+ # pipeline, which all do. Not confirmed as the cause (the actual hang
80
+ # point is unrecoverable β€” see the plan, Β§11), but a real, previously
81
+ # unprotected gap either way: any of these calls could hang forever on a
82
+ # stalled connection with zero backstop. `subprocess.run`'s own
83
+ # `timeout=` (used at every call site below) is the hard kill if even
84
+ # these don't fire.
85
+ #
86
+ # connect_timeout/read_timeout were originally 10s/60s β€” read_timeout is
87
+ # per-socket-read (resets on every chunk received, not a total-transfer
88
+ # deadline), so 60s looked plenty for this pipeline's normal restore
89
+ # payloads (MBs). Verified live it wasn't: a real deploy to a T4 HF
90
+ # Space hit `aws s3 sync ... exit status 255` (no further detail in the
91
+ # logs β€” --only-show-errors swallows it) on *both* the ~3.6 GB
92
+ # siglip2-model sync (raised to 300s there first) *and*, on a later
93
+ # deploy, the ordinary ~30 MB neo4j restore that had worked moments
94
+ # earlier on the same Space β€” i.e. this is a real, apparently
95
+ # intermittent characteristic of that Space's network path to S3, not
96
+ # specific to large-payload transfers, so the safer fix is raising the
97
+ # default here for every caller rather than special-casing one.
98
+ cmd += ["--cli-connect-timeout", "30", "--cli-read-timeout", str(read_timeout)]
99
+ return cmd
100
+
101
+
102
+ def run_aws_with_retry(
103
+ cmd: List[str], *, env: Dict[str, str], timeout: int, max_attempts: int = 4, base_delay: float = 5.0,
104
+ ) -> subprocess.CompletedProcess:
105
+ """Run an `aws s3 ...` command, retrying on failure with exponential
106
+ backoff (5s/10s/20s between attempts by default).
107
+
108
+ Raising aws_cmd()'s own CLI timeouts (see that function's comment) alone
109
+ didn't fix the intermittent `aws s3 sync ... exit status 255` a real T4
110
+ HF Space deploy hit repeatedly, on both a small (~30 MB neo4j) and large
111
+ (~3.6 GB siglip2-model) payload, with no more detail than a bare
112
+ "aws: [ERROR]:" even with output captured β€” consistent with something
113
+ transient in that Space's network path to S3 (e.g. an egress connection
114
+ limit under concurrent load β€” OpenSearch, Neo4j, and siglip2-model all
115
+ restore in parallel at container startup) rather than a single
116
+ reproducible cause worth chasing further from here. A short backoff-retry
117
+ loop is the standard mitigation for exactly this class of transient
118
+ failure and costs nothing when the first attempt just succeeds (the
119
+ common case, verified live outside this specific Space).
120
+ """
121
+ result = None
122
+ for attempt in range(1, max_attempts + 1):
123
+ result = subprocess.run(cmd, capture_output=True, text=True, env=env, timeout=timeout)
124
+ if result.returncode == 0:
125
+ return result
126
+ print(f"==> aws command failed (attempt {attempt}/{max_attempts}, exit {result.returncode})")
127
+ if result.stdout.strip():
128
+ print(f" stdout: {result.stdout.strip()}")
129
+ if result.stderr.strip():
130
+ print(f" stderr: {result.stderr.strip()}")
131
+ if attempt < max_attempts:
132
+ delay = base_delay * (2 ** (attempt - 1))
133
+ print(f"==> retrying in {delay:.0f}s")
134
+ time.sleep(delay)
135
+ raise SystemExit(f"aws command failed after {max_attempts} attempts: {' '.join(str(c) for c in cmd[:8])} ...")
136
+
137
+
138
+ def bucket_base(cfg: Config) -> str:
139
+ return cfg.require("ARTIFACT_S3_URI").rstrip("/")
pipeline/bake.py ADDED
@@ -0,0 +1,631 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage 4 β€” build (and optionally push) images with the data pre-loaded.
2
+
3
+ Staging happens here, on the host, so the Dockerfiles stay simple:
4
+
5
+ * settings + mappings are merged into one `<index>.create.json` per index,
6
+ with `index.knn.derived_source` stripped β€” the same transform upstream's
7
+ loader.sh applies, carried over verbatim because that setting is rejected
8
+ on restore.
9
+ * the elasticdump data file is converted to `_bulk` chunks (to_bulk.py),
10
+ since the OpenSearch image has no Node to run elasticdump with.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import gzip
16
+ import json
17
+ import shutil
18
+ import subprocess
19
+ from pathlib import Path
20
+ from typing import Dict, List
21
+
22
+ from . import hf_addressing
23
+ from .artifact_store import artifact_env
24
+ from .config import (
25
+ BUILD_DIR,
26
+ REPO_ROOT,
27
+ Config,
28
+ SIGLIP2_NVCF_ENDPOINT_DEFAULT,
29
+ SIGLIP2_NVCF_FUNCTION_ID_DEFAULT,
30
+ TRITONSERVER_BASE_IMAGE_DEFAULT,
31
+ )
32
+ from .enumerate_assets import load_manifest, save_manifest
33
+ from .export import NEO4J_ARTIFACTS, OS_ARTIFACTS
34
+ from .to_bulk import convert
35
+
36
+ BAKE_DIR = BUILD_DIR / "bake"
37
+ DOCKER_DIR = REPO_ROOT / "docker"
38
+
39
+
40
+ def _run(cmd: List[str], timeout: int = 900, **kwargs) -> subprocess.CompletedProcess:
41
+ print(" $ " + " ".join(cmd[:8]) + (" ..." if len(cmd) > 8 else ""))
42
+ return subprocess.run(cmd, check=True, timeout=timeout, **kwargs)
43
+
44
+
45
+ def _read_json_gz(path: Path) -> Dict:
46
+ """Load a gzipped elasticdump artifact, undoing double JSON encoding.
47
+
48
+ elasticdump is inconsistent about this: `--type=settings` writes the body as
49
+ a JSON *string* containing JSON, while `--type=mapping` writes a plain
50
+ object. Upstream loader.sh normalises both with a `try fromjson catch .`
51
+ step; this is the same defence, applied to whichever form we get.
52
+ """
53
+ with gzip.open(path, "rt") as fh:
54
+ doc = json.load(fh)
55
+ for _ in range(3):
56
+ if not isinstance(doc, str):
57
+ break
58
+ try:
59
+ doc = json.loads(doc)
60
+ except json.JSONDecodeError:
61
+ break
62
+ if not isinstance(doc, dict):
63
+ raise SystemExit(f"{path.name}: expected a JSON object, got {type(doc).__name__}")
64
+ return doc
65
+
66
+
67
+ def _unwrap(doc: Dict, key: str) -> Dict:
68
+ """elasticdump writes {"<index>": {"settings": ...}}; loader.sh also accepts
69
+ the bare {"settings": ...} form. Handle both, like loader.sh does."""
70
+ if key in doc:
71
+ return doc[key]
72
+ if len(doc) == 1:
73
+ inner = next(iter(doc.values()))
74
+ if isinstance(inner, dict) and key in inner:
75
+ return inner[key]
76
+ raise SystemExit(f"could not find {key!r} in dump: keys={list(doc)[:5]}")
77
+
78
+
79
+ def build_create_body(settings_path: Path, mapping_path: Path) -> Dict:
80
+ settings = _unwrap(_read_json_gz(settings_path), "settings")
81
+ mappings = _unwrap(_read_json_gz(mapping_path), "mappings")
82
+
83
+ index_settings = settings.get("index")
84
+ if isinstance(index_settings, dict):
85
+ # Present in the dump, rejected on create. loader.sh strips it too.
86
+ # The dump spells it as a literal dotted key mapping to an object:
87
+ # "knn.derived_source": {"enabled": "false"}
88
+ # but tolerate the nested spelling as well.
89
+ index_settings.pop("knn.derived_source", None)
90
+ index_settings.pop("knn.derived_source.enabled", None)
91
+ if isinstance(index_settings.get("knn"), dict):
92
+ index_settings["knn"].pop("derived_source", None)
93
+ # Cluster-assigned values that cannot be supplied on create.
94
+ for key in ("uuid", "creation_date", "creation_date_string", "provided_name",
95
+ "version", "resize", "routing", "history"):
96
+ index_settings.pop(key, None)
97
+ return {"settings": settings, "mappings": mappings}
98
+
99
+
100
+ def stage_opensearch(cfg: Config, manifest: Dict) -> Path:
101
+ seed_dir = BAKE_DIR / "opensearch" / "seed"
102
+ if seed_dir.exists():
103
+ shutil.rmtree(seed_dir)
104
+ seed_dir.mkdir(parents=True)
105
+
106
+ artifacts = manifest.get("opensearch_artifacts") or {}
107
+ if not artifacts:
108
+ raise SystemExit("manifest has no opensearch_artifacts β€” run `export` first.")
109
+
110
+ ordered: List[str] = []
111
+ for index in sorted(artifacts):
112
+ print(f" staging {index}")
113
+ body = build_create_body(
114
+ OS_ARTIFACTS / f"{index}.settings.json.gz",
115
+ OS_ARTIFACTS / f"{index}.mapping.json.gz",
116
+ )
117
+ (seed_dir / f"{index}.create.json").write_text(json.dumps(body))
118
+ chunks = convert(
119
+ OS_ARTIFACTS / f"{index}.data.json.gz",
120
+ seed_dir,
121
+ index,
122
+ chunk_docs=cfg.get_int("BULK_CHUNK_DOCS", 500),
123
+ )
124
+ print(f" {len(chunks)} bulk chunk(s)")
125
+ ordered.append(index)
126
+
127
+ (seed_dir / "indexes.txt").write_text("\n".join(ordered) + "\n")
128
+
129
+ context = BAKE_DIR / "opensearch"
130
+ for name in ("Dockerfile.opensearch-baked", "opensearch-seed.sh", "check_bulk.py"):
131
+ shutil.copy2(DOCKER_DIR / name, context / name)
132
+ return context
133
+
134
+
135
+ def stage_neo4j(manifest: Dict) -> Path:
136
+ context = BAKE_DIR / "neo4j"
137
+ dumps = context / "dumps"
138
+ if dumps.exists():
139
+ shutil.rmtree(dumps)
140
+ dumps.mkdir(parents=True)
141
+
142
+ artifact = manifest.get("neo4j_artifact")
143
+ if not artifact:
144
+ raise SystemExit("manifest has no neo4j_artifact β€” run `export` first.")
145
+
146
+ src = NEO4J_ARTIFACTS / artifact["file"]
147
+ dest = dumps / "neo4j.dump"
148
+ with gzip.open(src, "rb") as fh, dest.open("wb") as out:
149
+ shutil.copyfileobj(fh, out)
150
+ print(f" staged {dest.name} ({dest.stat().st_size:,} bytes)")
151
+
152
+ for name in ("Dockerfile.neo4j-baked", "neo4j-seeded-entrypoint.sh"):
153
+ shutil.copy2(DOCKER_DIR / name, context / name)
154
+ return context
155
+
156
+
157
+ def _image_names(cfg: Config) -> Dict[str, str]:
158
+ registry = cfg.get("REGISTRY", "nvcr.io/m3sujtetvf5w/usdsearch").rstrip("/")
159
+ names = {
160
+ "opensearch": f"{registry}/{cfg.get('OPENSEARCH_IMAGE_NAME', 'opensearch-simready-central')}",
161
+ "neo4j": f"{registry}/{cfg.get('NEO4J_IMAGE_NAME', 'neo4j-simready-central')}",
162
+ }
163
+ # The combined single-container MVP image (docker/Dockerfile.hf-space)
164
+ # always routes siglip2 to NVCF β€” it has no local Triton β€” so it's only
165
+ # buildable when the run itself is already NVCF-backed. cfg is the same
166
+ # object bake/verify/push all load from pipeline.env, so this stays
167
+ # consistent across those stages without threading a separate flag.
168
+ if cfg.siglip2_nvcf:
169
+ names["hf_space"] = f"{registry}/{cfg.get('HF_SPACE_IMAGE_NAME', 'hf-space-simready-central')}"
170
+ # The GPU variant (docker/Dockerfile.hf-space-gpu, local siglip2-triton
171
+ # instead of NVCF) is a separate, optional deploy target β€” independent
172
+ # of SIGLIP2_BACKEND/siglip2_nvcf above, since it serves the same
173
+ # already-indexed corpus regardless of how the indexing stack itself
174
+ # computed embeddings. Off by default (HF_SPACE_BUILD_GPU_VARIANT):
175
+ # existing pipelines shouldn't pay for an extra ~15 GB build/push they
176
+ # didn't ask for.
177
+ if cfg.hf_space_gpu_variant_enabled:
178
+ names["hf_space_gpu"] = f"{registry}/{cfg.get('HF_SPACE_GPU_IMAGE_NAME', 'hf-space-simready-central-gpu')}"
179
+ return names
180
+
181
+
182
+ def hf_url_mapping_rules(manifest: Dict) -> List[Dict[str, str]]:
183
+ """USDSEARCH_URL_MAPPING_RULES for a storage_api corpus: one rule per HF
184
+ repo the manifest records, rewriting the indexed hf:// prefix to that
185
+ repo's browsable file-viewer URL (.../blob/<revision>/). The internal
186
+ prefix comes from hf_addressing.build(), so it matches the indexed
187
+ addresses exactly, pinned-sha `/resolve/<sha>/` segment included.
188
+ Empty for an S3 corpus. Per-repo prefixes never nest, so the library's
189
+ _validate_rules() ambiguity check can't trip on a multi-repo corpus.
190
+ """
191
+ rules = []
192
+ for host, repo_type, repo_id, revision in manifest.get("hf_repos") or []:
193
+ type_prefix = hf_addressing.REPO_TYPE_PREFIX.get(repo_type, "")
194
+ public_repo = "/".join(p for p in (type_prefix, repo_id) if p)
195
+ rules.append({
196
+ "internal": hf_addressing.build(
197
+ host=host, repo_type=repo_type, repo_id=repo_id, revision=revision, is_folder=True
198
+ ),
199
+ "public": f"https://{host}/{public_repo}/blob/{revision}/",
200
+ })
201
+ return rules
202
+
203
+
204
+ def hf_space_build_args(cfg: Config, manifest: Dict) -> Dict[str, str]:
205
+ """The `docker/Dockerfile.hf-space` ARGs this run needs, shared between
206
+ the local/CI `docker build` (bake.run, as --build-arg) and pipeline/
207
+ hf_space.py's deploy stage (as HF Space "Variables" β€” Hugging Face
208
+ passes any Space Variable whose name matches a Dockerfile ARG through as
209
+ a build-arg on its own build, same mechanism, different trigger). One
210
+ dict so the two build-arg lists can't silently drift apart.
211
+ """
212
+ storage_backend = manifest.get("storage_backend", "s3")
213
+ args = {
214
+ "OPENSEARCH_VERSION": cfg.get("OPENSEARCH_VERSION", "3.8.0"),
215
+ "ARTIFACT_S3_URI": cfg.require("ARTIFACT_S3_URI"),
216
+ "ARTIFACT_S3_REGION": cfg.get("ARTIFACT_S3_REGION", "us-east-2"),
217
+ "CORPUS_NAME": manifest["corpus_name"],
218
+ "ES_NAME": manifest["es_name"],
219
+ # Search never makes a live storage-backend call at serve time (the
220
+ # Dockerfile bakes verification off β€” see its own comment), but the
221
+ # backend *type* still has to match what the corpus was indexed
222
+ # under: it gates a client-side URL/result code path unrelated to
223
+ # verification, and getting it wrong silently returns zero search
224
+ # results rather than erroring. Verified live.
225
+ "STORAGE_BACKEND_TYPE": storage_backend,
226
+ "SIGLIP2_NVCF_ENDPOINT": cfg.get("SIGLIP2_NVCF_ENDPOINT", SIGLIP2_NVCF_ENDPOINT_DEFAULT),
227
+ "SIGLIP2_NVCF_FUNCTION_ID": cfg.get("SIGLIP2_NVCF_FUNCTION_ID", SIGLIP2_NVCF_FUNCTION_ID_DEFAULT),
228
+ }
229
+ # S3_STORAGE_* is only ever meaningful for an S3-sourced corpus, and
230
+ # threading a source bucket name through for an HF-sourced one would be
231
+ # actively misleading.
232
+ if storage_backend != "storage_api":
233
+ args["S3_STORAGE_BUCKET_NAME"] = cfg.get("S3_STORAGE_BUCKET_NAME", "")
234
+ args["S3_STORAGE_REGION_NAME"] = cfg.get("S3_STORAGE_REGION_NAME", "us-west-2")
235
+ # Derived from this run's own corpus rather than hardcoded in the
236
+ # Dockerfile, so a Space follows its corpus when the dataset moves.
237
+ rules = hf_url_mapping_rules(manifest)
238
+ args["USDSEARCH_URL_MAPPING_MODE"] = "rules" if rules else "off"
239
+ args["USDSEARCH_URL_MAPPING_RULES"] = json.dumps(rules)
240
+ return args
241
+
242
+
243
+ def hf_space_gpu_build_args(cfg: Config, manifest: Dict) -> Dict[str, str]:
244
+ """The `docker/Dockerfile.hf-space-gpu` ARGs this run needs β€” same shape
245
+ as hf_space_build_args() above, minus every NVCF-only key (that
246
+ Dockerfile has no SIGLIP2_NVCF_* ARGs at all: SigLIP2 comes from the
247
+ local siglip2-triton process it bakes in, not NVCF), plus the bare
248
+ Triton base image, overridable in case a newer NGC tritonserver tag
249
+ needs picking up without a Dockerfile edit. The SigLIP2 model weights
250
+ themselves are not a build-arg β€” they're vendored source
251
+ (docker/hf-space/siglip2-model/, see scripts/extract_siglip2_model.sh),
252
+ committed like every other file this Dockerfile COPYs.
253
+ """
254
+ args = hf_space_build_args(cfg, manifest)
255
+ args.pop("SIGLIP2_NVCF_ENDPOINT", None)
256
+ args.pop("SIGLIP2_NVCF_FUNCTION_ID", None)
257
+ args["TRITONSERVER_BASE_IMAGE"] = cfg.get("TRITONSERVER_BASE_IMAGE", TRITONSERVER_BASE_IMAGE_DEFAULT)
258
+ return args
259
+
260
+
261
+ def image_tags(cfg: Config, manifest: Dict) -> Dict[str, str]:
262
+ # <corpus name>-<run_id> β€” in CI, run_id is itself
263
+ # <branch slug>-<workflow run id>-<short commit sha> (see
264
+ # nvcf-pipeline.yml's "Compute version" step), so the full tag traces
265
+ # a published image back to the exact corpus, branch, CI run and commit
266
+ # that built it. Locally, run_id falls back to enumerate_assets.py's
267
+ # `YYYYMMDD-<corpus sha prefix>`.
268
+ version = f"{manifest['corpus_name']}-{manifest['run_id']}"
269
+ return {name: f"{image}:{version}" for name, image in _image_names(cfg).items()}
270
+
271
+
272
+ def latest_tags(cfg: Config, manifest: Dict) -> Dict[str, str]:
273
+ """`<corpus name>-latest` β€” updated only when `push` actually publishes."""
274
+ tag = f"{manifest['corpus_name']}-latest"
275
+ return {name: f"{image}:{tag}" for name, image in _image_names(cfg).items()}
276
+
277
+
278
+ def run(cfg: Config, push: bool = False) -> int:
279
+ manifest = load_manifest()
280
+ tags = image_tags(cfg, manifest)
281
+ common = [
282
+ "--build-arg", f"RUN_ID={manifest['run_id']}",
283
+ "--build-arg", f"CORPUS_SHA256={manifest['corpus_sha256']}",
284
+ "--build-arg", f"USDSEARCH_GIT_SHA={manifest.get('usdsearch_git_sha', 'unknown')}",
285
+ ]
286
+
287
+ print("Staging OpenSearch seed ...")
288
+ os_context = stage_opensearch(cfg, manifest)
289
+ print(f"\nBuilding {tags['opensearch']}")
290
+ _run([
291
+ "docker", "build",
292
+ "-f", str(os_context / "Dockerfile.opensearch-baked"),
293
+ "-t", tags["opensearch"],
294
+ "--build-arg", f"OPENSEARCH_VERSION={cfg.get('OPENSEARCH_VERSION', '3.8.0')}",
295
+ "--build-arg", f"MAIN_INDEX={cfg.main_index}",
296
+ "--build-arg", f"DOC_COUNT={manifest.get('base_docs_indexed', 0)}",
297
+ *common, str(os_context),
298
+ ])
299
+
300
+ if "hf_space" in tags:
301
+ print(f"\nBuilding {tags['hf_space']} (single-container MVP: opensearch+redis+deepsearch-api, "
302
+ f"for Hugging Face Spaces)")
303
+ # Fails fast here, before the Neo4j build, if no NVCF token is in the
304
+ # environment β€” same fallback chain as the indexing/runtime nvcf
305
+ # overlays (config.py's siglip2_nvcf_env), so a missing token is
306
+ # reported the same way everywhere rather than surfacing later as an
307
+ # opaque docker build failure. No AWS-credential check here: this
308
+ # image no longer bakes the index in at build time (see the
309
+ # Dockerfile's header) β€” the artifact-bucket keys are only needed at
310
+ # `verify_baked`'s smoke-test `docker run`, and at the real Space's
311
+ # own runtime.
312
+ cfg.siglip2_nvcf_env()
313
+ # Build context is REPO_ROOT here (not DOCKER_DIR, unlike the
314
+ # opensearch/neo4j builds above): this Dockerfile COPYs pipeline/ in
315
+ # directly (its runtime seed step needs it) rather than reusing
316
+ # another already-built image's tag, since HF's own builder must be
317
+ # able to build it standalone with only public base images.
318
+ build_arg_flags = [
319
+ flag
320
+ for key, value in hf_space_build_args(cfg, manifest).items()
321
+ for flag in ("--build-arg", f"{key}={value}")
322
+ ]
323
+ _run([
324
+ "docker", "build",
325
+ "-f", str(DOCKER_DIR / "Dockerfile.hf-space"),
326
+ "-t", tags["hf_space"],
327
+ *build_arg_flags,
328
+ *common, str(REPO_ROOT),
329
+ ])
330
+ else:
331
+ print("\nSIGLIP2_BACKEND=local β€” skipping the HF Space image (it is NVCF-only by "
332
+ "design; set SIGLIP2_BACKEND=nvcf to build it β€” see docker/Dockerfile.hf-space)")
333
+
334
+ if "hf_space_gpu" in tags:
335
+ print(f"\nBuilding {tags['hf_space_gpu']} (single-container MVP + local siglip2-triton, "
336
+ f"for a GPU-tier Hugging Face Space β€” see docker/Dockerfile.hf-space-gpu)")
337
+ # No NVCF token needed here (this variant never calls NVCF) β€” only
338
+ # the same build-arg dict shape as the NVCF image, minus its
339
+ # NVCF-only keys. Build context is REPO_ROOT, same reasoning as the
340
+ # NVCF hf_space build above.
341
+ build_arg_flags = [
342
+ flag
343
+ for key, value in hf_space_gpu_build_args(cfg, manifest).items()
344
+ for flag in ("--build-arg", f"{key}={value}")
345
+ ]
346
+ _run([
347
+ "docker", "build",
348
+ "-f", str(DOCKER_DIR / "Dockerfile.hf-space-gpu"),
349
+ "-t", tags["hf_space_gpu"],
350
+ *build_arg_flags,
351
+ *common, str(REPO_ROOT),
352
+ ])
353
+
354
+ print("\nStaging Neo4j dump ...")
355
+ neo_context = stage_neo4j(manifest)
356
+ print(f"\nBuilding {tags['neo4j']}")
357
+ _run([
358
+ "docker", "build",
359
+ "-f", str(neo_context / "Dockerfile.neo4j-baked"),
360
+ "-t", tags["neo4j"],
361
+ "--build-arg", f"NEO4J_VERSION={cfg.get('NEO4J_VERSION', '5.23.0-community')}",
362
+ "--build-arg", f"NODE_COUNT={manifest.get('neo4j_node_count', 0)}",
363
+ "--build-arg", f"RELATIONSHIP_COUNT={manifest.get('neo4j_relationship_count', 0)}",
364
+ *common, str(neo_context),
365
+ ])
366
+
367
+ manifest["images"] = tags
368
+ (BUILD_DIR / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
369
+
370
+ print("\nVerifying the baked data from a fresh container ...")
371
+ if verify_baked(cfg, tags, manifest) != 0:
372
+ return 1
373
+
374
+ if push:
375
+ # Only reached after `verify` has passed; run_pipeline never pushes
376
+ # before the smoke test.
377
+ for name, tag in tags.items():
378
+ print(f"\nPushing {tag}")
379
+ _run(["docker", "push", tag])
380
+
381
+ print("\nbuilt:")
382
+ for name, tag in tags.items():
383
+ print(f" {name}: {tag}")
384
+ if not push:
385
+ print("\nNot pushed. `run_pipeline.py verify` runs the smoke test; push with")
386
+ print("`run_pipeline.py push` once it passes.")
387
+ return 0
388
+
389
+
390
+ def verify_baked(cfg: Config, tags: Dict[str, str], manifest: Dict) -> int:
391
+ """Check the data from a fresh `docker run`, not from the build log.
392
+
393
+ This is what catches the VOLUME trap: a Dockerfile that writes into a
394
+ declared VOLUME builds green and ships an empty database, and only starting
395
+ the built image reveals it.
396
+ """
397
+ ok = True
398
+
399
+ print(" opensearch ...")
400
+ result = subprocess.run(
401
+ ["docker", "run", "--rm", "-d", "-p", "19200:9200", tags["opensearch"]],
402
+ capture_output=True, text=True, check=True, timeout=60,
403
+ )
404
+ container = result.stdout.strip()
405
+ try:
406
+ import time
407
+ import httpx
408
+ expected = manifest.get("base_docs_indexed", 0)
409
+ count = -1
410
+ shards_ready = False
411
+ for _ in range(90):
412
+ try:
413
+ response = httpx.get(f"http://localhost:19200/{cfg.main_index}/_count", timeout=5.0)
414
+ if response.status_code < 400:
415
+ body = response.json()
416
+ shards = body.get("_shards", {})
417
+ shards_ready = shards.get("successful") == shards.get("total")
418
+ count = int(body["count"])
419
+ # A fresh container recovers its shards from the baked
420
+ # segments over several seconds. OpenSearch answers
421
+ # `_count` as soon as any shard is open, and
422
+ # `allow_partial_search_results` defaults to true, so a
423
+ # response with `shards_ready=False` can silently
424
+ # undercount rather than error β€” keep polling instead of
425
+ # trusting the first HTTP 200.
426
+ if shards_ready and count == expected:
427
+ break
428
+ except httpx.HTTPError:
429
+ pass
430
+ time.sleep(2)
431
+ print(f" {cfg.main_index}: {count} docs (expected {expected}, shards ready: {shards_ready})")
432
+ if count != expected or count == 0:
433
+ print(" FAIL: baked OpenSearch image does not hold the expected documents")
434
+ ok = False
435
+ finally:
436
+ subprocess.run(["docker", "rm", "-f", container], capture_output=True, check=False, timeout=30)
437
+
438
+ print(" neo4j ...")
439
+ result = subprocess.run(
440
+ ["docker", "run", "--rm", "-d", "-p", "17687:7687", tags["neo4j"]],
441
+ capture_output=True, text=True, check=True, timeout=60,
442
+ )
443
+ container = result.stdout.strip()
444
+ try:
445
+ import time
446
+ from neo4j import GraphDatabase
447
+ expected = manifest.get("neo4j_node_count", 0)
448
+ nodes = -1
449
+ # A bounded wall-clock deadline, not a bare iteration count: the
450
+ # driver is given no timeouts below by default, so a single stuck
451
+ # connection attempt (e.g. the server accepting TCP but stalling
452
+ # mid-handshake) can block far longer than the intended ~3 minutes
453
+ # of polling β€” 90 iterations at an unbounded per-attempt cost was
454
+ # exactly the shape of a run that hung here for 44 minutes and
455
+ # blew straight through this step's own 10-minute CI timeout
456
+ # without ever failing cleanly.
457
+ deadline = time.monotonic() + 180
458
+ while time.monotonic() < deadline:
459
+ try:
460
+ driver = GraphDatabase.driver(
461
+ "bolt://localhost:17687", auth=None,
462
+ connection_timeout=5.0, connection_acquisition_timeout=5.0,
463
+ )
464
+ with driver.session() as session:
465
+ nodes = session.run("MATCH (n) RETURN count(n) AS c").single()["c"]
466
+ driver.close()
467
+ break
468
+ except Exception: # noqa: BLE001 - the server is simply not up yet
469
+ time.sleep(2)
470
+ print(f" nodes: {nodes} (expected {expected})")
471
+ if expected > 0 and nodes != expected:
472
+ print(" FAIL: baked Neo4j image does not hold the expected graph")
473
+ ok = False
474
+ finally:
475
+ subprocess.run(["docker", "rm", "-f", container], capture_output=True, check=False, timeout=30)
476
+
477
+ if "hf_space" in tags:
478
+ print(" hf-space (combined MVP image) ...")
479
+ # The AWS keys here exercise the same runtime restore
480
+ # (pipeline/hf_space_seed.py, via supervisord) a real Space does β€”
481
+ # this smoke test is what actually proves that path works, not just
482
+ # that the image builds. A longer deadline than the other images'
483
+ # health checks: startup now includes a real S3 download + bulk
484
+ # load, not just a process coming up.
485
+ token = cfg.siglip2_nvcf_env()["SIGLIP2_TRITON_SERVER_AUTH_TOKEN"]
486
+ aws = artifact_env()
487
+ result = subprocess.run(
488
+ ["docker", "run", "--rm", "-d", "-p", "17860:7860",
489
+ "-e", f"SIGLIP2_TRITON_SERVER_AUTH_TOKEN={token}",
490
+ "-e", f"SIMREADYCENTRAL_INDEX_STORE_AWS_ACCESS_KEY_ID={aws['AWS_ACCESS_KEY_ID']}",
491
+ "-e", f"SIMREADYCENTRAL_INDEX_STORE_AWS_SECRET_ACCESS_KEY={aws['AWS_SECRET_ACCESS_KEY']}",
492
+ tags["hf_space"]],
493
+ capture_output=True, text=True, check=True, timeout=60,
494
+ )
495
+ container = result.stdout.strip()
496
+ try:
497
+ import time
498
+ import httpx
499
+ healthy = False
500
+ deadline = time.monotonic() + 300
501
+ while time.monotonic() < deadline:
502
+ try:
503
+ if httpx.get("http://localhost:17860/health", timeout=5.0).status_code == 200:
504
+ healthy = True
505
+ break
506
+ except httpx.HTTPError:
507
+ pass
508
+ time.sleep(2)
509
+ print(f" /health responded: {healthy}")
510
+ if not healthy:
511
+ print(" FAIL: hf-space image never became healthy")
512
+ ok = False
513
+
514
+ # /health only proves deepsearch-api/OpenSearch came up β€” it says
515
+ # nothing about neo4j/asset-graph-service, which have their own
516
+ # offline-load startup path (pipeline/hf_space_seed.py
517
+ # --component=neo4j) that can fail silently as far as /health is
518
+ # concerned, and which routinely takes longer than deepsearch-api's
519
+ # own startup (a multi-hundred-MB neo4j-admin load, run offline,
520
+ # before that program even execs into `neo4j console`) β€” so this
521
+ # has to poll, not fire once right after /health passes. Exercise
522
+ # a real graph read through nginx, the same way a client actually
523
+ # would: search for a real asset url, then ask
524
+ # asset-graph-service for its prims.
525
+ graph_ok = False
526
+ if healthy:
527
+ graph_deadline = time.monotonic() + 180
528
+ while time.monotonic() < graph_deadline and not graph_ok:
529
+ try:
530
+ search_resp = httpx.get(
531
+ "http://localhost:17860/search", params={"query": "box", "limit": 1}, timeout=10.0,
532
+ )
533
+ results = search_resp.json()
534
+ asset_url = results[0]["url"] if results else None
535
+ if asset_url:
536
+ prims_resp = httpx.get(
537
+ "http://localhost:17860/asset_graph/usd/prims",
538
+ params={"asset_url": asset_url}, timeout=10.0,
539
+ )
540
+ prims = prims_resp.json()
541
+ graph_ok = prims_resp.status_code == 200 and isinstance(prims, list) and len(prims) > 0
542
+ except (httpx.HTTPError, ValueError):
543
+ pass
544
+ if not graph_ok:
545
+ time.sleep(3)
546
+ print(f" asset-graph-service responded with real prims: {graph_ok}")
547
+ if not graph_ok:
548
+ print(" FAIL: hf-space image's neo4j/asset-graph-service restore did not produce queryable data")
549
+ ok = False
550
+
551
+ # Recorded independently of `ok`/ the other images' checks β€”
552
+ # deploy-space (pipeline/hf_space.py) gates on this specifically,
553
+ # not on the separate full-stack `verify` stage's smoke_passed,
554
+ # since that stage also exercises asset-graph-service and other
555
+ # components the hf_space image doesn't even include.
556
+ manifest["hf_space_smoke_passed"] = healthy and graph_ok
557
+ save_manifest(manifest)
558
+ finally:
559
+ subprocess.run(["docker", "rm", "-f", container], capture_output=True, check=False, timeout=30)
560
+
561
+ if "hf_space_gpu" in tags:
562
+ print(" hf-space-gpu (local siglip2-triton variant) ...")
563
+ # Unlike every other smoke test in this function, this one needs an
564
+ # actual GPU on the machine running `bake` β€” `--gpus all` fails fast
565
+ # and loudly if there isn't one, rather than hanging, which is the
566
+ # right failure mode here: this is why this variant is opt-in
567
+ # (HF_SPACE_BUILD_GPU_VARIANT) and why the existing nightly CI
568
+ # workflow (no-GPU-runner, per docs/plans/index-regeneration-
569
+ # pipeline.md Β§12) doesn't enable it. No NVCF token needed. AWS keys
570
+ # still are β€” the index restore path is identical to the NVCF
571
+ # variant's.
572
+ aws = artifact_env()
573
+ result = subprocess.run(
574
+ ["docker", "run", "--rm", "-d", "-p", "17861:7860", "--gpus", "all",
575
+ "-e", f"SIMREADYCENTRAL_INDEX_STORE_AWS_ACCESS_KEY_ID={aws['AWS_ACCESS_KEY_ID']}",
576
+ "-e", f"SIMREADYCENTRAL_INDEX_STORE_AWS_SECRET_ACCESS_KEY={aws['AWS_SECRET_ACCESS_KEY']}",
577
+ tags["hf_space_gpu"]],
578
+ capture_output=True, text=True, check=True, timeout=60,
579
+ )
580
+ container = result.stdout.strip()
581
+ try:
582
+ import time
583
+ import httpx
584
+ healthy = False
585
+ # Longer deadline than the NVCF variant's: this one also has to
586
+ # load ~3.6 GB of ONNX weights onto the GPU before deepsearch-api
587
+ # execs uvicorn (see supervisord-gpu.conf) β€” not measured live
588
+ # yet (see docker/Dockerfile.hf-space-gpu's header), budgeted
589
+ # generously rather than guessed tight.
590
+ deadline = time.monotonic() + 600
591
+ while time.monotonic() < deadline:
592
+ try:
593
+ if httpx.get("http://localhost:17861/health", timeout=5.0).status_code == 200:
594
+ healthy = True
595
+ break
596
+ except httpx.HTTPError:
597
+ pass
598
+ time.sleep(2)
599
+ print(f" /health responded: {healthy}")
600
+ if not healthy:
601
+ print(" FAIL: hf-space-gpu image never became healthy")
602
+ ok = False
603
+
604
+ embedding_ok = False
605
+ if healthy:
606
+ try:
607
+ search_resp = httpx.get(
608
+ "http://localhost:17861/search", params={"query": "box", "limit": 1}, timeout=15.0,
609
+ )
610
+ results = search_resp.json()
611
+ # A non-empty, non-erroring hybrid-search response through
612
+ # nginx is proof the local siglip2-triton text-embedding
613
+ # round trip actually worked, not just that the process
614
+ # answered /health β€” same "exercise a real call, not just
615
+ # readiness" reasoning as the NVCF variant's asset-graph
616
+ # check above.
617
+ embedding_ok = search_resp.status_code == 200 and isinstance(results, list) and len(results) > 0
618
+ except (httpx.HTTPError, ValueError):
619
+ pass
620
+ print(f" local siglip2-triton search round trip: {embedding_ok}")
621
+ if not embedding_ok:
622
+ print(" FAIL: hf-space-gpu image's local siglip2-triton embedding path did not "
623
+ "return real results")
624
+ ok = False
625
+
626
+ manifest["hf_space_gpu_smoke_passed"] = healthy and embedding_ok
627
+ save_manifest(manifest)
628
+ finally:
629
+ subprocess.run(["docker", "rm", "-f", container], capture_output=True, check=False, timeout=30)
630
+
631
+ return 0 if ok else 1
pipeline/clear.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage 1b (pre-submit) β€” clear stale data before delta submission.
2
+
3
+ Two OpenSearch/Neo4j write paths have different idempotency on resubmission
4
+ (verified against upstream source, not assumed):
5
+
6
+ * OpenSearch base documents are safe: the doc `_id` is a deterministic hash
7
+ of the URL (`OpenSearchCache.update_meta`,
8
+ packages/search-utils/.../elasticsearch.py:90-91), so `/process/asset`
9
+ overwrites the same document. No clearing needed for a resubmission.
10
+ * Neo4j asset *nodes* are safe (`MERGE (n:Asset {url: ...})`,
11
+ asset_graph_service/db/neo4j.py:244-253) but *edges* are not β€” they are
12
+ written with `apoc.create.relationship`, which always inserts
13
+ (neo4j.py:262-274). Reprocessing an asset that already has edges in the
14
+ graph duplicates every one of them.
15
+
16
+ `DETACH DELETE` on the asset node removes the node *and every edge touching
17
+ it* in one statement, which fixes the edge case and doubles as exactly what a
18
+ `removed` asset needs (gone from both stores, never resubmitted). So one
19
+ `clear()` step covers both:
20
+
21
+ * a "repeating" asset β€” present in the restored baseline AND in this run's
22
+ `assets.jsonl` β€” gets Neo4j-cleared, then resubmitted by `submit`. The
23
+ OpenSearch doc does not need clearing; the resubmission overwrites it.
24
+ * a "removed" asset (corpus.json's `removed` list) β€” gets Neo4j-cleared
25
+ *and* its OpenSearch doc deleted, and is never resubmitted (enumerate
26
+ already drops it from assets.jsonl).
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from typing import Dict, List, Set
32
+
33
+ import httpx
34
+
35
+ from .config import BUILD_DIR, Config
36
+ from .corpus import Corpus
37
+ from .enumerate_assets import load_assets
38
+ from .restore import load_baseline_urls
39
+
40
+
41
+ def compute_targets(corpus: Corpus) -> Dict[str, Set[str]]:
42
+ current = {a.url for a in load_assets()}
43
+ baseline = load_baseline_urls()
44
+ removed = corpus.removed_set
45
+
46
+ repeating = current & baseline
47
+ return {
48
+ "neo4j_clear": repeating | removed,
49
+ "opensearch_delete": set(removed),
50
+ }
51
+
52
+
53
+ def clear_neo4j(cfg: Config, urls: List[str]) -> int:
54
+ if not urls:
55
+ return 0
56
+ from neo4j import GraphDatabase
57
+
58
+ driver = GraphDatabase.driver(cfg.neo4j_bolt_url, auth=None)
59
+ try:
60
+ with driver.session() as session:
61
+ result = session.run(
62
+ "UNWIND $urls AS u MATCH (n:Asset {url: u}) DETACH DELETE n RETURN count(n) AS c",
63
+ urls=urls,
64
+ )
65
+ return int(result.single()["c"])
66
+ finally:
67
+ driver.close()
68
+
69
+
70
+ def clear_opensearch(cfg: Config, urls: List[str]) -> int:
71
+ if not urls:
72
+ return 0
73
+ response = httpx.post(
74
+ f"{cfg.opensearch_url}/{cfg.main_index}/_delete_by_query?refresh=true",
75
+ json={"query": {"terms": {"base_key": urls}}},
76
+ timeout=60.0,
77
+ )
78
+ response.raise_for_status()
79
+ return int(response.json().get("deleted", 0))
80
+
81
+
82
+ def run(cfg: Config) -> int:
83
+ corpus = Corpus.load(BUILD_DIR / "corpus.json")
84
+ targets = compute_targets(corpus)
85
+
86
+ neo4j_urls = sorted(targets["neo4j_clear"])
87
+ opensearch_urls = sorted(targets["opensearch_delete"])
88
+
89
+ if not neo4j_urls and not opensearch_urls:
90
+ print("nothing to clear β€” no repeating or removed assets against the baseline")
91
+ return 0
92
+
93
+ print(f"clearing {len(neo4j_urls)} Neo4j node(s) (repeating + removed) ...")
94
+ cleared = clear_neo4j(cfg, neo4j_urls)
95
+ print(f" detached and deleted {cleared} node(s)")
96
+
97
+ print(f"deleting {len(opensearch_urls)} OpenSearch doc(s) (removed only) ...")
98
+ deleted = clear_opensearch(cfg, opensearch_urls)
99
+ print(f" deleted {deleted} doc(s)")
100
+
101
+ return 0
pipeline/config.py ADDED
@@ -0,0 +1,512 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pipeline configuration, loaded from config/pipeline.env.
2
+
3
+ Deliberately reads only this file (plus the process environment as an
4
+ override), never CI_* variables: stage 1 runs locally today and on a CI runner
5
+ later, and that later change should be a wrapper exporting the same names
6
+ rather than a second configuration path.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Dict, List, Optional
16
+
17
+ # The default NVCF function backing the siglip2-triton embedding model.
18
+ # Fixed, not looked up by name: NVCF function-name resolution needs an extra
19
+ # API call the smoke tooling in ../usdsearch (ci/siglip2-triton/smoke_test_nvcf.py)
20
+ # only falls back to when no id is given. We always have the id, so nothing in
21
+ # this repo ever needs to resolve a name.
22
+ SIGLIP2_NVCF_FUNCTION_ID_DEFAULT = "af53131c-7416-40b4-b652-4a8b4c82a6e6"
23
+ SIGLIP2_NVCF_ENDPOINT_DEFAULT = "grpc.nvcf.nvidia.com:443"
24
+
25
+ # Same fallback chain and order as smoke_test_nvcf.py:106-107 in ../usdsearch
26
+ # (`NVCF_RUN_KEY or NGC_GENAI_API_KEY or NVCF_GENAI_RUN_KEY`), so whichever of
27
+ # the three an operator already has exported just works, matching upstream's
28
+ # own tooling rather than picking one name and hoping it matches.
29
+ SIGLIP2_NVCF_AUTH_TOKEN_VARS_DEFAULT = ("NVCF_RUN_KEY", "NGC_GENAI_API_KEY", "NVCF_GENAI_RUN_KEY")
30
+
31
+ # The HF Space GPU variant's Triton base image (bare, public NGC catalog
32
+ # tritonserver β€” verified live via the nvcr.io v2 registry API with an
33
+ # anonymous guest-client token, no docker login needed; model_repo/ is
34
+ # vendored in separately by scripts/extract_siglip2_model.sh, not baked into
35
+ # this base β€” see that script's header for why the prepackaged
36
+ # nvcr.io/nvidia/usdsearch/siglip2-triton image was dropped: its published
37
+ # tag bakes FP32 weights, too large for a T4's 16 GB VRAM) and the HF Space
38
+ # hardware tier it's meant to run on. See docker/Dockerfile.hf-space-gpu and
39
+ # docs/plans/siglip2-embedded-hf-space.md.
40
+ TRITONSERVER_BASE_IMAGE_DEFAULT = "nvcr.io/nvidia/tritonserver:26.05-py3"
41
+ HF_SPACE_GPU_HARDWARE_DEFAULT = "t4-medium"
42
+
43
+ # The two source-content backends the indexing stack (stage 1) can point at.
44
+ # "s3" reads S3_STORAGE_* directly; "storage_api" runs `hf-storage-api`
45
+ # (https://gitlab-master.nvidia.com/omniverse/gen-ai/hf-storage-api-service)
46
+ # locally, backed by a HuggingFace dataset β€” see docs/plans/
47
+ # index-regeneration-pipeline.md's decision log. Matches the corpus JSON's own
48
+ # `storage_backend` field (pipeline/corpus.py); enumerate_assets.py checks the
49
+ # two agree, since a corpus written for one backend indexed against the other
50
+ # would silently list zero assets rather than error clearly.
51
+ STORAGE_BACKENDS = ("s3", "storage_api")
52
+ DEFAULT_STORAGE_BACKEND = "s3"
53
+
54
+ REPO_ROOT = Path(__file__).resolve().parent.parent
55
+ BUILD_DIR = REPO_ROOT / ".build"
56
+ DEFAULT_ENV_FILE = REPO_ROOT / "config" / "pipeline.env"
57
+
58
+
59
+ def parse_env_file(path: Path) -> Dict[str, str]:
60
+ """Parse a KEY=VALUE env file. Blank lines and # comments are ignored.
61
+
62
+ Values are taken literally: no shell expansion, no quote stripping beyond a
63
+ single matched pair, because these values end up in URLs and S3 keys where a
64
+ stray transformation is hard to notice and expensive to debug.
65
+ """
66
+ out: Dict[str, str] = {}
67
+ if not path.exists():
68
+ return out
69
+ for raw in path.read_text().splitlines():
70
+ line = raw.strip()
71
+ if not line or line.startswith("#"):
72
+ continue
73
+ if "=" not in line:
74
+ continue
75
+ key, _, value = line.partition("=")
76
+ key = key.strip()
77
+ value = value.strip()
78
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
79
+ value = value[1:-1]
80
+ out[key] = value
81
+ return out
82
+
83
+
84
+ @dataclass
85
+ class Config:
86
+ values: Dict[str, str] = field(default_factory=dict)
87
+ env_file: Path = DEFAULT_ENV_FILE
88
+
89
+ @classmethod
90
+ def load(cls, env_file: Optional[Path] = None) -> "Config":
91
+ path = Path(env_file) if env_file else DEFAULT_ENV_FILE
92
+ values = parse_env_file(path)
93
+ # Process environment wins, so a one-off override needs no file edit.
94
+ for key in list(values) + [
95
+ "ARTIFACT_S3_URI",
96
+ "RUN_ID",
97
+ "RESTORE_RUN_ID",
98
+ "ES_NAME",
99
+ "CORPUS_FILE",
100
+ "INFO_ENDPOINT_URL",
101
+ "OPENSEARCH_URL",
102
+ "SIGLIP2_BACKEND",
103
+ "SIGLIP2_NVCF_ENDPOINT",
104
+ "STORAGE_BACKEND_TYPE",
105
+ "SIGLIP2_NVCF_FUNCTION_ID",
106
+ "SIGLIP2_NVCF_FUNCTION_VERSION_ID",
107
+ "HF_SPACE_REPO_ID",
108
+ "HF_SPACE_TITLE",
109
+ "HF_SPACE_REPO_ID_GPU",
110
+ "HF_SPACE_TITLE_GPU",
111
+ "HF_SPACE_BUILD_GPU_VARIANT",
112
+ "HF_SPACE_GPU_HARDWARE",
113
+ "TRITONSERVER_BASE_IMAGE",
114
+ "LLM_ENDPOINT_BASE_URL",
115
+ "LLM_PARSING_MODEL",
116
+ "VISION_VALIDATION_MODEL",
117
+ "VLM_METADATA_ENABLED",
118
+ ]:
119
+ if os.environ.get(key):
120
+ values[key] = os.environ[key]
121
+ return cls(values=values, env_file=path)
122
+
123
+ # ── typed accessors ───────────────────────────────────────────────────
124
+
125
+ def get(self, key: str, default: str = "") -> str:
126
+ return self.values.get(key, default) or default
127
+
128
+ def require(self, key: str) -> str:
129
+ value = self.values.get(key, "").strip()
130
+ if not value:
131
+ raise SystemExit(
132
+ f"{key} is not set. Add it to {self.env_file} "
133
+ f"(see config/pipeline.env.example)."
134
+ )
135
+ return value
136
+
137
+ def get_int(self, key: str, default: int) -> int:
138
+ raw = self.values.get(key, "").strip()
139
+ return int(raw) if raw else default
140
+
141
+ def get_float(self, key: str, default: float) -> float:
142
+ raw = self.values.get(key, "").strip()
143
+ return float(raw) if raw else default
144
+
145
+ # ── derived values ────────────────────────────────────────────────────
146
+
147
+ @property
148
+ def es_name(self) -> str:
149
+ return self.get("ES_NAME", "simready-central")
150
+
151
+ @property
152
+ def main_index(self) -> str:
153
+ """`{ES_NAME}-ver5.0` β€” OSCacheDict.get_index_name()."""
154
+ return f"{self.es_name}-ver5.0"
155
+
156
+ @property
157
+ def image_cache_index(self) -> str:
158
+ """`{ES_NAME}-ver4.0-image-cache` β€” storage_index_name + field + "-cache"."""
159
+ return f"{self.es_name}-ver4.0-image-cache"
160
+
161
+ @property
162
+ def corpus_file(self) -> Path:
163
+ return REPO_ROOT / self.get("CORPUS_FILE", "config/corpus.json")
164
+
165
+ @property
166
+ def info_endpoint(self) -> str:
167
+ return self.get("INFO_ENDPOINT_URL", "http://localhost:8001").rstrip("/")
168
+
169
+ @property
170
+ def opensearch_url(self) -> str:
171
+ return self.get("OPENSEARCH_URL", "http://localhost:9200").rstrip("/")
172
+
173
+ @property
174
+ def neo4j_bolt_url(self) -> str:
175
+ return self.get("NEO4J_BOLT_URL", "bolt://localhost:7687")
176
+
177
+ # ── siglip2 backend (local Triton vs. NVCF) ─────────────────────────────
178
+
179
+ @property
180
+ def siglip2_backend(self) -> str:
181
+ backend = self.get("SIGLIP2_BACKEND", "local").strip().lower()
182
+ if backend not in ("local", "nvcf"):
183
+ raise SystemExit(f"SIGLIP2_BACKEND must be 'local' or 'nvcf', got {backend!r}")
184
+ return backend
185
+
186
+ @property
187
+ def siglip2_nvcf(self) -> bool:
188
+ return self.siglip2_backend == "nvcf"
189
+
190
+ # ── HF Space GPU variant (local siglip2-triton, no NVCF) ────────────────
191
+ # Independent of SIGLIP2_BACKEND above (which is the *indexing stack's*
192
+ # compose backend, local-GPU vs. NVCF) β€” this is a second, optional
193
+ # `bake`/`deploy-space-gpu` target that serves the same already-indexed
194
+ # corpus through docker/Dockerfile.hf-space-gpu instead of
195
+ # docker/Dockerfile.hf-space, off by default so existing pipelines that
196
+ # don't want the extra ~15 GB build/push are unaffected. See
197
+ # docs/plans/siglip2-embedded-hf-space.md.
198
+
199
+ @property
200
+ def hf_space_gpu_variant_enabled(self) -> bool:
201
+ return self.get("HF_SPACE_BUILD_GPU_VARIANT", "false").strip().lower() in ("1", "true", "yes")
202
+
203
+ # ── storage backend (S3 vs. hf-storage-api / storage_api) ──────────────
204
+
205
+ @property
206
+ def storage_backend(self) -> str:
207
+ backend = self.get("STORAGE_BACKEND_TYPE", DEFAULT_STORAGE_BACKEND).strip().lower()
208
+ if backend not in STORAGE_BACKENDS:
209
+ raise SystemExit(f"STORAGE_BACKEND_TYPE must be one of {STORAGE_BACKENDS}, got {backend!r}")
210
+ return backend
211
+
212
+ @property
213
+ def storage_api_backend(self) -> bool:
214
+ return self.storage_backend == "storage_api"
215
+
216
+ def siglip2_nvcf_env(self) -> Dict[str, str]:
217
+ """Env vars the gRPC Triton client (packages/siglip2-triton-client)
218
+ reads to reach an NVCF-hosted function instead of a local container β€”
219
+ same `SIGLIP2_TRITON_SERVER_*`-prefixed pydantic-settings fields
220
+ either way, just pointed at NVCF with auth headers instead of the
221
+ local siglip2-triton service.
222
+
223
+ `SIGLIP2_NVCF_AUTH_TOKEN_VAR`, if set, names one specific *environment
224
+ variable* to read the token from. Left unset (the default), the same
225
+ three-way fallback chain as upstream's smoke tooling applies β€” the
226
+ token itself is never read from `pipeline.env` or committed, same
227
+ pattern as the S3 artifact credentials.
228
+ """
229
+ function_id = self.get("SIGLIP2_NVCF_FUNCTION_ID", SIGLIP2_NVCF_FUNCTION_ID_DEFAULT)
230
+ version_id = self.get("SIGLIP2_NVCF_FUNCTION_VERSION_ID").strip()
231
+ headers = {"function-id": function_id}
232
+ if version_id:
233
+ headers["function-version-id"] = version_id
234
+
235
+ explicit_var = self.get("SIGLIP2_NVCF_AUTH_TOKEN_VAR").strip()
236
+ token_vars = (explicit_var,) if explicit_var else SIGLIP2_NVCF_AUTH_TOKEN_VARS_DEFAULT
237
+ token = ""
238
+ for var in token_vars:
239
+ token = os.environ.get(var, "").strip()
240
+ if token:
241
+ break
242
+ if not token:
243
+ raise SystemExit(
244
+ f"SIGLIP2_BACKEND=nvcf but none of {', '.join('$' + v for v in token_vars)} "
245
+ f"is set in the environment. One must hold the NVCF run key "
246
+ f"(never committed to pipeline.env)."
247
+ )
248
+
249
+ return {
250
+ "SIGLIP2_TRITON_SERVER_URL": self.get("SIGLIP2_NVCF_ENDPOINT", SIGLIP2_NVCF_ENDPOINT_DEFAULT),
251
+ "SIGLIP2_TRITON_SERVER_SSL": "true",
252
+ "SIGLIP2_TRITON_SERVER_AUTH_TOKEN": token,
253
+ "SIGLIP2_TRITON_SERVER_HEADERS": json.dumps(headers),
254
+ # deepsearch-api reads this one unprefixed (main.py:161); the
255
+ # worker reads the SIGLIP2_TRITON_SERVER_URL above instead.
256
+ "TRITON_SERVER_URL": self.get("SIGLIP2_NVCF_ENDPOINT", SIGLIP2_NVCF_ENDPOINT_DEFAULT),
257
+ }
258
+
259
+ # ── LLM parsing / VLM verification endpoint (HF Space) ──────────────────
260
+
261
+ @property
262
+ def llm_endpoint_base_url(self) -> str:
263
+ """Empty (the default) means the HF Space keeps shipping with both
264
+ roles disabled, exactly as it does today
265
+ (`USDSEARCH_LLM_PARSING_ENABLED`/`USDSEARCH_VISION_VALIDATION_ENABLED`
266
+ baked `false` in docker/Dockerfile.hf-space) β€” this is the one knob
267
+ that turns them on. Point it at any OpenAI-compatible
268
+ `/v1/chat/completions` server that serves both a `LLM_PARSING_MODEL`
269
+ alias and a `VISION_VALIDATION_MODEL` alias β€” e.g. either of this
270
+ repo's own deploy scripts' endpoints
271
+ (scripts/deploy_hf_endpoint.py / scripts/deploy_nvcf_endpoint.py)."""
272
+ return self.get("LLM_ENDPOINT_BASE_URL").strip()
273
+
274
+ @property
275
+ def llm_parsing_model(self) -> str:
276
+ return self.get("LLM_PARSING_MODEL", "parser")
277
+
278
+ @property
279
+ def vision_validation_model(self) -> str:
280
+ return self.get("VISION_VALIDATION_MODEL", "reranker")
281
+
282
+ def llm_endpoint_api_key(self) -> str:
283
+ """Read from the environment only β€” never `pipeline.env`, same
284
+ policy as every other credential this pipeline touches (NVCF token,
285
+ S3 keys, HF Space deploy token). Provider-agnostic by design β€” HF
286
+ Inference Endpoints, NVCF, or anything else OpenAI-compatible β€” so
287
+ there's no single "the" NVCF/HF var name to default to; instead of
288
+ forcing every provider through one hardcoded `$LLM_ENDPOINT_API_KEY`
289
+ name, `LLM_ENDPOINT_API_KEY_VAR` (in `pipeline.env`, not sensitive
290
+ itself β€” it's just a variable *name*) names which env var actually
291
+ holds the bearer token for whichever endpoint `LLM_ENDPOINT_BASE_URL`
292
+ currently points at, so switching providers (e.g. NVCF's
293
+ `$NVCF_GENAI_RUN_KEY` vs. an HF Inference Endpoint's
294
+ `$HF_INFERENCE_ENDPOINT_TOKEN` β€” see docs/development.md) is a
295
+ `pipeline.env` edit, not an extra `export` before every command.
296
+ `$LLM_ENDPOINT_API_KEY` itself, if set, still wins outright β€” same
297
+ name/precedence as before this existed, so nothing already relying
298
+ on it breaks."""
299
+ explicit_var = self.get("LLM_ENDPOINT_API_KEY_VAR").strip()
300
+ token = os.environ.get("LLM_ENDPOINT_API_KEY", "").strip()
301
+ if not token and explicit_var:
302
+ token = os.environ.get(explicit_var, "").strip()
303
+ if not token:
304
+ hint = f" (or ${explicit_var}, per LLM_ENDPOINT_API_KEY_VAR)" if explicit_var else ""
305
+ raise SystemExit(
306
+ f"LLM_ENDPOINT_BASE_URL is set but no bearer token was found β€” set "
307
+ f"$LLM_ENDPOINT_API_KEY{hint} in the environment (never pipeline.env) to the "
308
+ f"key for that endpoint."
309
+ )
310
+ return token
311
+
312
+ def llm_endpoint_variables(self) -> Dict[str, str]:
313
+ """Non-sensitive `USDSEARCH_LLM_*`/`USDSEARCH_VISION_VALIDATION_*`
314
+ env vars for the HF Space β€” plain runtime Space "Variables", not
315
+ Dockerfile build ARGs: docker/Dockerfile.hf-space already bakes
316
+ `USDSEARCH_LLM_PARSING_ENABLED`/`USDSEARCH_VISION_VALIDATION_ENABLED`
317
+ as `ENV ... false` defaults, and a runtime-injected env var of the
318
+ same name overrides an image's baked `ENV` value (standard Docker
319
+ behavior) β€” so applying these to an already-built, already-running
320
+ Space needs no rebuild, just a restart. See llm_endpoint_api_key()
321
+ for the one secret half of this (USDSEARCH_LLM_API_KEY).
322
+
323
+ `enable_thinking: false` is forced into both EXTRA_BODY values
324
+ unconditionally: it's required for this pipeline's own Qwen3.6
325
+ deploy scripts and a harmless no-op for a hypothetical non-Qwen
326
+ endpoint (OpenAI-compatible servers ignore unknown request-body
327
+ keys β€” see llm_client.client.LLMClient's own docstring in
328
+ ../usdsearch). `USDSEARCH_VISION_VALIDATION_METHOD=logprob` matches
329
+ this pipeline's own deploy scripts' `reranker` role (a single-token
330
+ logprob judge, not the default `generative` structured-JSON verdict)
331
+ β€” pin explicitly rather than relying on the client's own default,
332
+ since a `generative`-only endpoint would otherwise get a request
333
+ shape it never expected. Verified live against the deployed NVCF
334
+ `reranker` endpoint: it returns real per-token `top_logprobs`, and a
335
+ full `/vlm_validate/search_result` round trip through this image
336
+ comes back with `reasoning: "P(Yes)=... from first-token
337
+ log-probs."`, not a generated sentence, confirming the logprob path
338
+ actually runs rather than just the flag being set.
339
+
340
+ The `..._LOGPROB_*` knobs are pinned so client default changes can't
341
+ drift this deployment. `max_tokens=1`: Qwen emits the verdict at
342
+ position 0. `top_logprobs=20`: the judge scores P(Yes)/(P(Yes)+P(No))
343
+ over the returned tokens, so 1 makes every verdict 0 or 100 and
344
+ abstains (504) when the top token is neither; it costs nothing extra
345
+ (the engine computes the full distribution) and must stay <= 20 (the
346
+ client cap; the engine allows 25). `MAX_TRIES=2`: the client calls
347
+ with max_retries=0, so one transient engine error would otherwise be a
348
+ 503, which stops the Explorer validating the whole search.
349
+ """
350
+ if not self.llm_endpoint_base_url:
351
+ # Nothing to override β€” defer entirely to the Dockerfile's own
352
+ # baked `false` defaults rather than pushing a redundant (and,
353
+ # for USDSEARCH_LLM_BASE_URL, actively wrong β€” an explicit empty
354
+ # string is not the same as "unset" to pydantic-settings)
355
+ # variable update.
356
+ return {}
357
+ extra_body = json.dumps({"chat_template_kwargs": {"enable_thinking": False}})
358
+ return {
359
+ "USDSEARCH_LLM_PARSING_ENABLED": "true",
360
+ "USDSEARCH_VISION_VALIDATION_ENABLED": "true",
361
+ "USDSEARCH_LLM_BASE_URL": self.llm_endpoint_base_url,
362
+ "USDSEARCH_LLM_PARSING_MODEL": self.llm_parsing_model,
363
+ "USDSEARCH_VISION_VALIDATION_MODEL": self.vision_validation_model,
364
+ "USDSEARCH_LLM_PARSING_EXTRA_BODY": extra_body,
365
+ "USDSEARCH_VISION_VALIDATION_EXTRA_BODY": extra_body,
366
+ "USDSEARCH_VISION_VALIDATION_METHOD": "logprob",
367
+ "USDSEARCH_VISION_VALIDATION_LOGPROB_COVER_MIN": "0.2",
368
+ "USDSEARCH_VISION_VALIDATION_LOGPROB_ABSTAIN_AS_NO": "false",
369
+ "USDSEARCH_VISION_VALIDATION_LOGPROB_TOP_LOGPROBS": "20",
370
+ "USDSEARCH_VISION_VALIDATION_LOGPROB_MAX_TOKENS": "1",
371
+ "USDSEARCH_VISION_VALIDATION_MAX_TRIES": "2",
372
+ }
373
+
374
+ # ── VLM metadata extraction from thumbnails (indexing-time plugin) ──────
375
+ #
376
+ # The `thumbnail_to_vision_metadata` plugin (inactive by default, see
377
+ # config/plugin.config.yaml) calls out to an LLM/VLM at indexing time to
378
+ # caption/tag each asset's thumbnail. Off unless VLM_METADATA_ENABLED=true
379
+ # β€” same "additive, opt-in" posture as SIGLIP2_BACKEND=nvcf and
380
+ # STORAGE_BACKEND_TYPE=storage_api.
381
+ #
382
+ # Deliberately its OWN connection, separate from LLM_ENDPOINT_BASE_URL
383
+ # (the HF Space's real-time LLM-parsing/vision-validation connection,
384
+ # llm_endpoint_variables() above): this role runs batch/background at
385
+ # indexing time in its own container (monitor-worker-thumbnail-to-
386
+ # vision-metadata), never in the HF Space process, so there is no reason
387
+ # to share a connection sized/rate-limited for interactive query traffic.
388
+ #
389
+ # No pipeline-specific wrapper env vars here (unlike LLM_ENDPOINT_* above)
390
+ # β€” docker-compose.indexing.vlm-metadata.yml's worker reads the real
391
+ # container var names directly (USDSEARCH_LLM_API_KEY/_BASE_URL,
392
+ # USDSEARCH_VISION_METADATA_MODEL β€” packages/llm-client/llm_client/
393
+ # client.py's LLMConnectionConfig / metadata_generation.py's
394
+ # MetadataGenerationConfig in ../usdsearch), null-pass-through from
395
+ # whatever's already exported in the shell (compose_env()'s `dict(os.environ)`
396
+ # base already carries them to the `docker compose` subprocess β€” no
397
+ # translation needed). Left unset, USDSEARCH_LLM_BASE_URL/
398
+ # USDSEARCH_VISION_METADATA_MODEL fall back to llm_client's own shipped
399
+ # defaults (DEFAULT_BASE_URL = "https://inference-api.nvidia.com" / NVIDIA
400
+ # Inference Hub, MetadataGenerationConfig.model = "azure/openai/gpt-5.6-sol"
401
+ # β€” see ../usdsearch's docs/models-and-config.md, "Metadata gen" row) β€”
402
+ # exactly the endpoint+model this deployment wants. Only
403
+ # $USDSEARCH_LLM_API_KEY is required; require_vlm_metadata_credentials()
404
+ # below fails fast (before `up`) if it's missing.
405
+
406
+ @property
407
+ def vlm_metadata_enabled(self) -> bool:
408
+ return self.get("VLM_METADATA_ENABLED", "false").strip().lower() in ("1", "true", "yes")
409
+
410
+ def require_vlm_metadata_credentials(self) -> None:
411
+ if not os.environ.get("USDSEARCH_LLM_API_KEY", "").strip():
412
+ raise SystemExit(
413
+ "VLM_METADATA_ENABLED=true but $USDSEARCH_LLM_API_KEY is not set in the "
414
+ "environment (never pipeline.env) β€” the bearer key for the VLM metadata "
415
+ "endpoint (NVIDIA Inference Hub by default; see "
416
+ "../usdsearch/docs/models-and-config.md)."
417
+ )
418
+
419
+ def compose_env(self) -> Dict[str, str]:
420
+ """The environment every `docker compose`/`docker run` subprocess
421
+ this pipeline spawns should get β€” `pipeline.env`'s values plus
422
+ (when `SIGLIP2_BACKEND=nvcf`) the computed NVCF endpoint/auth vars
423
+ the compose overlays interpolate.
424
+
425
+ A single source of truth for this, used everywhere a compose command
426
+ touches the indexing project (`run_pipeline.py`'s `up`/`down`,
427
+ `verify.py`'s runtime bring-up, and `export.py`/`restore.py`'s own
428
+ `stop`/`start neo4j asset-graph-service` calls) β€” those last two used
429
+ to build their `docker compose` commands with no `env=` at all,
430
+ silently inheriting the bare process environment instead. Harmless
431
+ so far only because neither `neo4j` nor `asset-graph-service`
432
+ actually reads the vars that went missing (`ES_NAME`,
433
+ `S3_STORAGE_BUCKET_NAME`, `SIGLIP2_TRITON_SERVER_*`) β€” but real
434
+ enough to show up as a page of "variable is not set" warnings in the
435
+ CI log, which is what surfaced it.
436
+ """
437
+ env = dict(os.environ)
438
+ env.update({k: v for k, v in self.values.items() if v})
439
+ if self.siglip2_nvcf:
440
+ env.update(self.siglip2_nvcf_env())
441
+ if self.storage_api_backend:
442
+ # The hf-storage-api container's YAML-null env passthrough
443
+ # (docker-compose.indexing.storage-api.yml) forwards a host var
444
+ # only under its own literal name, HF_ADAPTER_HF_TOKEN β€” but
445
+ # every other HF credential in this pipeline (the enumerator,
446
+ # pipeline/hf_space.py) reads the standard $HF_TOKEN. Mapped here
447
+ # so operators only ever set one variable. Verified live: without
448
+ # this, hf-storage-api silently ran unauthenticated and every
449
+ # submission against a private dataset 401'd as "Asset missing".
450
+ hf_token = os.environ.get("HF_TOKEN", "").strip()
451
+ if hf_token:
452
+ env["HF_ADAPTER_HF_TOKEN"] = hf_token
453
+ # No vlm_metadata_enabled-specific merge needed here: `env` already
454
+ # started as `dict(os.environ)` above, so USDSEARCH_LLM_API_KEY/
455
+ # USDSEARCH_LLM_BASE_URL/USDSEARCH_VISION_METADATA_MODEL β€” whatever's
456
+ # already exported in the shell β€” reach docker-compose.indexing.vlm-metadata.yml's
457
+ # null-pass-through vars unchanged. See the "VLM metadata extraction"
458
+ # section above for why this role deliberately has no pipeline-specific
459
+ # wrapper vars the way LLM_ENDPOINT_* does.
460
+ return env
461
+
462
+ def indexing_compose_files(self) -> List[str]:
463
+ files = ["-f", str(REPO_ROOT / "compose" / "docker-compose.indexing.yml")]
464
+ if self.siglip2_nvcf:
465
+ files += ["-f", str(REPO_ROOT / "compose" / "docker-compose.indexing.nvcf-siglip2.yml")]
466
+ # Only the indexing stack ever talks to a storage backend directly β€”
467
+ # the runtime stack (verify/HF Space) serves an already-baked index and
468
+ # needs neither S3 nor a running hf-storage-api. See runtime_compose_files().
469
+ if self.storage_api_backend:
470
+ files += ["-f", str(REPO_ROOT / "compose" / "docker-compose.indexing.storage-api.yml")]
471
+ # Independent of storage backend (the new worker reads thumbnails
472
+ # through whichever backend is already configured), applied last by
473
+ # convention alongside the others above.
474
+ if self.vlm_metadata_enabled:
475
+ files += ["-f", str(REPO_ROOT / "compose" / "docker-compose.indexing.vlm-metadata.yml")]
476
+ return files
477
+
478
+ def runtime_compose_files(self) -> List[str]:
479
+ files = ["-f", str(REPO_ROOT / "compose" / "docker-compose.runtime.yml")]
480
+ if self.siglip2_nvcf:
481
+ files += ["-f", str(REPO_ROOT / "compose" / "docker-compose.runtime.nvcf-siglip2.yml")]
482
+ return files
483
+
484
+
485
+ BASE_PLUGINS = ("thumbnail_to_embedding", "asset_graph_generation")
486
+ """The two plugins this pipeline always runs."""
487
+
488
+ # Every plugin key config/plugin.config.yaml lists β€” used to generate
489
+ # .build/plugin.config.yaml (see enumerate_assets.write_plugin_config_yaml).
490
+ # Keep in sync with that file's own keys.
491
+ ALL_PLUGIN_NAMES = (
492
+ "thumbnail_to_embedding",
493
+ "asset_graph_generation",
494
+ "thumbnail_generation",
495
+ "rendering_to_embedding",
496
+ "image_to_embedding",
497
+ "image_to_vision_metadata",
498
+ "rendering_to_vision_metadata",
499
+ "thumbnail_to_vision_metadata",
500
+ )
501
+
502
+
503
+ def active_plugins(cfg: "Config") -> tuple:
504
+ """The plugins this run actually enqueues β€” BASE_PLUGINS plus, opt-in,
505
+ `thumbnail_to_vision_metadata` (VLM_METADATA_ENABLED=true). Superseded
506
+ the old module-level `ACTIVE_PLUGINS` constant so this can vary per run
507
+ rather than being fixed at import time; every former caller of that
508
+ constant now calls this with its own `cfg` instead."""
509
+ plugins = list(BASE_PLUGINS)
510
+ if cfg.vlm_metadata_enabled:
511
+ plugins.append("thumbnail_to_vision_metadata")
512
+ return tuple(plugins)
pipeline/corpus.py ADDED
@@ -0,0 +1,370 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Corpus definition: parsing, validation, and include/exclude matching.
2
+
3
+ The corpus JSON is the sole authority over what gets indexed. With the crawler
4
+ removed, nothing else filters and nothing else can widen the set, so the
5
+ matching rules here are deliberately dumb: literal `s3://bucket/key` prefixes
6
+ compared with `str.startswith`. No globs, no regex. Glob-vs-regex ambiguity is
7
+ exactly the kind of thing that silently indexes the wrong 200k assets.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Iterable, List, Sequence, Tuple, Union
17
+
18
+ from . import hf_addressing
19
+
20
+ DEFAULT_EXTENSIONS = (".usd", ".usda", ".usdc", ".usdz")
21
+
22
+ STORAGE_BACKENDS = ("s3", "storage_api")
23
+ DEFAULT_STORAGE_BACKEND = "s3"
24
+
25
+ # `hf-storage-api`'s catalog unit is an asset *folder*, identified by a marker
26
+ # file at its root (its `layout.marker`/`layout.manifest`, default
27
+ # "com.nvidia.simready.packaging.json" β€” the SimReady packaging convention).
28
+ # Once a folder is catalogued, every file beneath it is individually
29
+ # addressable (its own `resolve()` walks the whole subtree, not just the
30
+ # marker) β€” so this pipeline's existing file-level include/exclude/extension
31
+ # matching still selects exactly which files get indexed; `asset_marker` only
32
+ # tells the enumerator (and the catalog it generates) which ancestor folder of
33
+ # each matched file is "the asset" for hf-storage-api's access-boundary.
34
+ DEFAULT_ASSET_MARKER = "com.nvidia.simready.packaging.json"
35
+
36
+ # The revision an hf:// corpus entry with no /resolve/<sha>/ segment is taken
37
+ # to track β€” matches hf-storage-api's own RepoConfig.revision default.
38
+ DEFAULT_HF_REVISION = "main"
39
+
40
+ # Applied on top of the corpus, mirroring the upstream monitor's OV_EXCLUDE_PATHS
41
+ # default. Thumbnails are inputs to thumbnail_to_embedding, never assets.
42
+ ALWAYS_EXCLUDE_SUBSTRINGS = ("/.thumbs/", ".__omni_channel__")
43
+
44
+
45
+ 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
52
+ key: str
53
+
54
+ @property
55
+ def uri(self) -> str:
56
+ return f"s3://{self.bucket}/{self.key}"
57
+
58
+ @staticmethod
59
+ def parse(raw: str, whence: str) -> "S3Prefix":
60
+ if not isinstance(raw, str) or not raw.startswith("s3://"):
61
+ raise CorpusError(f"{whence}: {raw!r} is not an s3:// URL")
62
+ remainder = raw[len("s3://"):]
63
+ if "/" not in remainder:
64
+ # "s3://bucket" with no key would mean the whole bucket. Require an
65
+ # explicit trailing slash so that intent is visible in the file.
66
+ raise CorpusError(
67
+ f"{whence}: {raw!r} has no key. Write 's3://{remainder}/' if you "
68
+ f"really mean the entire bucket."
69
+ )
70
+ bucket, _, key = remainder.partition("/")
71
+ if not bucket:
72
+ raise CorpusError(f"{whence}: {raw!r} has an empty bucket name")
73
+ return S3Prefix(bucket=bucket, key=key)
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class HfPrefix:
78
+ """An ``hf://`` prefix naming a HuggingFace repo (+ pinned revision) and an
79
+ optional path prefix within it β€” the ``storage_api`` backend's equivalent
80
+ of :class:`S3Prefix`'s bucket/key. See `pipeline/hf_addressing.py` for the
81
+ address grammar.
82
+ """
83
+
84
+ host: str
85
+ repo_type: str
86
+ repo_id: str
87
+ revision: str
88
+ key: str # trailing "/" preserved for a folder, exactly like S3Prefix.key
89
+
90
+ @property
91
+ def repo_key(self) -> Tuple[str, str, str, str]:
92
+ """Groups prefixes that share one repo+revision β€” the `storage_api`
93
+ equivalent of `S3Prefix.bucket`, used by `hf_repos`/
94
+ `include_prefixes_for_repo`."""
95
+ return (self.host, self.repo_type, self.repo_id, self.revision)
96
+
97
+ @property
98
+ def uri(self) -> str:
99
+ is_folder = self.key == "" or self.key.endswith("/")
100
+ return hf_addressing.build(
101
+ host=self.host, repo_type=self.repo_type, repo_id=self.repo_id,
102
+ revision=self.revision, path=self.key, is_folder=is_folder,
103
+ )
104
+
105
+ @staticmethod
106
+ def parse(raw: str, whence: str) -> "HfPrefix":
107
+ if not isinstance(raw, str) or not raw.startswith("hf://"):
108
+ raise CorpusError(f"{whence}: {raw!r} is not an hf:// URL")
109
+ try:
110
+ addr = hf_addressing.parse(raw)
111
+ except hf_addressing.AddressError as exc:
112
+ raise CorpusError(f"{whence}: {raw!r} is not a valid hf:// address ({exc})") from exc
113
+ # A /resolve/<rev>/ segment is only ever valid as a pinned commit sha:
114
+ # hf-storage-api's own address minting omits it entirely for a moving
115
+ # ref (`AssetStore._emit_revision`), and its resolve path checks a
116
+ # present segment against the repo's *actual* resolved sha
117
+ # (`_serves_revision`) β€” so a literal `resolve/main/` never matches
118
+ # and the address 404s. Verified live. No segment at all means "the
119
+ # repo's configured revision", which this pipeline always takes to be
120
+ # `main` (moving) β€” see DEFAULT_HF_REVISION.
121
+ if addr.revision and not hf_addressing.looks_like_sha(addr.revision):
122
+ raise CorpusError(
123
+ f"{whence}: {raw!r} pins /resolve/{addr.revision}/, which is not a full "
124
+ f"commit sha. hf-storage-api never resolves a moving ref embedded this "
125
+ f"way (it always 404s) β€” either pin an actual sha, or drop the "
126
+ f"/resolve/{addr.revision}/ segment entirely to track '{DEFAULT_HF_REVISION}'."
127
+ )
128
+ revision = addr.revision or DEFAULT_HF_REVISION
129
+ key = addr.path
130
+ if addr.is_folder and key and not key.endswith("/"):
131
+ key += "/"
132
+ return HfPrefix(
133
+ host=addr.host, repo_type=addr.repo_type, repo_id=addr.repo_id,
134
+ revision=revision, key=key,
135
+ )
136
+
137
+
138
+ Prefix = Union[S3Prefix, HfPrefix]
139
+
140
+ _PREFIX_PARSERS = {"s3": S3Prefix.parse, "storage_api": HfPrefix.parse}
141
+
142
+
143
+ @dataclass
144
+ class Corpus:
145
+ name: str
146
+ storage_backend: str
147
+ include: List[Prefix]
148
+ exclude: List[Prefix]
149
+ removed: List[str]
150
+ extensions: Tuple[str, ...]
151
+ sha256: str
152
+ source_path: Path
153
+ asset_marker: str = DEFAULT_ASSET_MARKER
154
+ # Both passed straight through to the generated hf-storage-api catalog's
155
+ # `layout:` block (pipeline/hf_catalog.py) β€” "" means omit the key and
156
+ # let hf-storage-api's own default apply. Needed for a dataset whose
157
+ # thumbnails/root-USD don't sit at hf-storage-api's default SimReady
158
+ # locations: verified live against nvidia/srp-staging-controlled, whose
159
+ # actual layout is `catalog_kind: wrapp` (root USD resolved from the
160
+ # `.wrapp` package's own top-level file, not the `{data_dir}/{asset_name}.usd`
161
+ # naming guess) with thumbnails at `.thumbs/` directly under the asset
162
+ # folder, not `{data_dir}/.thumbs`. Without these, hf-storage-api's
163
+ # GetMetadata silently computes a nonsense root_usd/thumbnail path and
164
+ # thumbnail_to_embedding "succeeds" with zero embeddings β€” no error,
165
+ # just 0% coverage.
166
+ catalog_kind: str = ""
167
+ thumbs_dir: str = ""
168
+ # `content_fallback` is hf-storage-api's own naming-convention template
169
+ # for the root USD when no `root_usd_index`/`.wrapp` sidecar is present
170
+ # (default `{data_dir}/{asset_name}.usd`) β€” overridable here for a
171
+ # dataset whose filenames don't match that guess. Verified live against
172
+ # nvidia/srp-staging-controlled's 2026-09 layout restructure: every one
173
+ # of 1488 assets' root USD is `{folder}/simready_usd/sm_{folder}_01.usd`,
174
+ # not `{folder}/simready_usd/{folder}.usd` β€” and that restructure also
175
+ # dropped the `.wrapp` sidecar `catalog_kind: wrapp` depends on entirely
176
+ # (folder() itself 404s without one, before content_fallback even runs),
177
+ # so this corpus now needs `catalog_kind: manifest` (the default) with
178
+ # this override, not `wrapp` + thumbs_dir as before.
179
+ content_fallback: str = ""
180
+
181
+ @property
182
+ def buckets(self) -> List[str]:
183
+ """S3 only β€” see `hf_repos` for the `storage_api` equivalent."""
184
+ return sorted({p.bucket for p in self.include})
185
+
186
+ @property
187
+ def hf_repos(self) -> List[Tuple[str, str, str, str]]:
188
+ """`storage_api` only β€” see `buckets` for the S3 equivalent. Each
189
+ tuple is `(host, repo_type, repo_id, revision)`, i.e. `HfPrefix.repo_key`."""
190
+ return sorted({p.repo_key for p in self.include})
191
+
192
+ @property
193
+ def removed_set(self) -> frozenset:
194
+ return frozenset(self.removed)
195
+
196
+ def include_prefixes_for(self, bucket: str) -> List[str]:
197
+ return [p.key for p in self.include if p.bucket == bucket]
198
+
199
+ def include_prefixes_for_repo(self, repo_key: Tuple[str, str, str, str]) -> List[str]:
200
+ return [p.key for p in self.include if p.repo_key == repo_key]
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()
214
+ return any(lowered.endswith(ext) for ext in self.extensions)
215
+
216
+ def accepts(self, uri: str) -> bool:
217
+ """Exclude wins over include, matching upstream `exclude_items` order."""
218
+ if self.is_excluded(uri):
219
+ return False
220
+ if not self.is_included(uri):
221
+ return False
222
+ return self.has_wanted_extension(uri)
223
+
224
+ # ── loading ─────────────────────────────────────────────────────���─────
225
+
226
+ @classmethod
227
+ def load(cls, path: Path) -> "Corpus":
228
+ if not path.exists():
229
+ raise CorpusError(
230
+ f"corpus file not found: {path}. Copy config/corpus.example.json "
231
+ f"and edit it."
232
+ )
233
+ raw_bytes = path.read_bytes()
234
+ try:
235
+ doc = json.loads(raw_bytes)
236
+ except json.JSONDecodeError as exc:
237
+ raise CorpusError(f"{path}: invalid JSON β€” {exc}") from exc
238
+ return cls.from_dict(doc, sha256=hashlib.sha256(raw_bytes).hexdigest(), source_path=path)
239
+
240
+ @classmethod
241
+ def from_dict(cls, doc: dict, sha256: str = "", source_path: Path = Path(".")) -> "Corpus":
242
+ if not isinstance(doc, dict):
243
+ raise CorpusError("corpus must be a JSON object")
244
+
245
+ version = doc.get("version", 1)
246
+ if version != 1:
247
+ raise CorpusError(f"unsupported corpus version {version!r}; expected 1")
248
+
249
+ name = doc.get("name") or ""
250
+ if not name:
251
+ raise CorpusError("corpus 'name' is required")
252
+
253
+ storage_backend = doc.get("storage_backend") or DEFAULT_STORAGE_BACKEND
254
+ if storage_backend not in STORAGE_BACKENDS:
255
+ raise CorpusError(
256
+ f"storage_backend: {storage_backend!r} is not one of {STORAGE_BACKENDS}"
257
+ )
258
+ parse_prefix = _PREFIX_PARSERS[storage_backend]
259
+
260
+ raw_include = doc.get("include") or []
261
+ if not raw_include:
262
+ # An empty include is almost always an accident, and reading it as
263
+ # "everything" would index an entire bucket by omission.
264
+ raise CorpusError(
265
+ "corpus 'include' is empty. Refusing to interpret that as "
266
+ "'index everything' β€” list the prefixes explicitly."
267
+ )
268
+ include = [parse_prefix(r, "include") for r in raw_include]
269
+ exclude = [parse_prefix(r, "exclude") for r in (doc.get("exclude") or [])]
270
+ # Exact asset URLs (not prefixes) known to have been indexed before and
271
+ # since deleted from the source bucket. Matched by exact equality
272
+ # against a baseline run's asset list, never by prefix β€” see `clear`
273
+ # (restore/delta indexing). Validated the same way as include/exclude
274
+ # so a malformed URL is caught here, not at Neo4j/OpenSearch call time.
275
+ removed = [parse_prefix(r, "removed").uri for r in (doc.get("removed") or [])]
276
+
277
+ extensions = tuple(
278
+ e.lower() if e.startswith(".") else f".{e.lower()}"
279
+ for e in (doc.get("extensions") or DEFAULT_EXTENSIONS)
280
+ )
281
+ asset_marker = doc.get("asset_marker") or DEFAULT_ASSET_MARKER
282
+ if storage_backend != "storage_api" and "asset_marker" in doc:
283
+ raise CorpusError("asset_marker is only meaningful for storage_backend: storage_api")
284
+ catalog_kind = doc.get("catalog_kind") or ""
285
+ if storage_backend != "storage_api" and "catalog_kind" in doc:
286
+ raise CorpusError("catalog_kind is only meaningful for storage_backend: storage_api")
287
+ thumbs_dir = doc.get("thumbs_dir") or ""
288
+ if storage_backend != "storage_api" and "thumbs_dir" in doc:
289
+ raise CorpusError("thumbs_dir is only meaningful for storage_backend: storage_api")
290
+ content_fallback = doc.get("content_fallback") or ""
291
+ if storage_backend != "storage_api" and "content_fallback" in doc:
292
+ raise CorpusError("content_fallback is only meaningful for storage_backend: storage_api")
293
+
294
+ corpus = cls(
295
+ name=name,
296
+ storage_backend=storage_backend,
297
+ include=include,
298
+ exclude=exclude,
299
+ removed=removed,
300
+ extensions=extensions,
301
+ sha256=sha256,
302
+ source_path=source_path,
303
+ asset_marker=asset_marker,
304
+ catalog_kind=catalog_kind,
305
+ thumbs_dir=thumbs_dir,
306
+ content_fallback=content_fallback,
307
+ )
308
+ corpus.validate()
309
+ return corpus
310
+
311
+ def validate(self) -> None:
312
+ """Structural checks that catch the common typos."""
313
+ seen = set()
314
+ for prefix in self.include:
315
+ if prefix.uri in seen:
316
+ raise CorpusError(f"include: duplicate entry {prefix.uri!r}")
317
+ seen.add(prefix.uri)
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.
331
+ for prefix in self.exclude:
332
+ if not any(prefix.uri.startswith(inc.uri) for inc in self.include):
333
+ raise CorpusError(
334
+ f"exclude: {prefix.uri!r} is not under any include prefix, so "
335
+ f"it would have no effect. Fix the path or drop the entry."
336
+ )
337
+
338
+ removed_seen = set()
339
+ for uri in self.removed:
340
+ if uri in removed_seen:
341
+ raise CorpusError(f"removed: duplicate entry {uri!r}")
342
+ removed_seen.add(uri)
343
+
344
+ @property
345
+ def run_id_suffix(self) -> str:
346
+ return self.sha256[:7]
347
+
348
+
349
+ def summarize(corpus: Corpus) -> str:
350
+ if corpus.storage_backend == "storage_api":
351
+ sources_label = "hf repos"
352
+ sources = ", ".join(f"{repo_id}@{revision}" for _host, _rtype, repo_id, revision in corpus.hf_repos)
353
+ else:
354
+ sources_label = "buckets"
355
+ sources = ", ".join(corpus.buckets)
356
+ lines = [
357
+ f"corpus: {corpus.name}",
358
+ f"storage_backend: {corpus.storage_backend}",
359
+ f"sha256: {corpus.sha256}",
360
+ f"{sources_label}:{' ' * (12 - len(sources_label))} {sources}",
361
+ f"include: {len(corpus.include)} prefixes",
362
+ f"exclude: {len(corpus.exclude)} prefixes",
363
+ f"removed: {len(corpus.removed)} assets",
364
+ f"extensions: {', '.join(corpus.extensions)}",
365
+ ]
366
+ return "\n".join(lines)
367
+
368
+
369
+ def iter_uris(objects: Iterable[Tuple[str, str]]) -> Sequence[str]:
370
+ return [f"s3://{bucket}/{key}" for bucket, key in objects]
pipeline/enumerate_assets.py ADDED
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage 0 β€” expand the corpus into an exact asset list.
2
+
3
+ With the crawler removed, nothing lists the bucket at index time, so this stage
4
+ owns enumeration. Its output is the pipeline's ground truth: `N = len(assets)`
5
+ is the expected document count, known before any indexing starts, and every
6
+ later stage asserts against it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import subprocess
14
+ from dataclasses import asdict, dataclass
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+ from typing import Dict, Iterator, List, Optional, Set, Tuple
18
+
19
+ import boto3
20
+ import yaml
21
+ from botocore import UNSIGNED
22
+ from botocore.client import Config as BotoConfig
23
+
24
+ from . import hf_addressing, hf_catalog
25
+ from .config import ALL_PLUGIN_NAMES, BUILD_DIR, REPO_ROOT, Config, active_plugins
26
+ from .corpus import Corpus, summarize
27
+
28
+ SETTINGS_TEMPLATE = """\
29
+ # Generated by `run_pipeline.py enumerate` β€” do not edit by hand.
30
+ # Mounted into info-endpoint and omni-writer as /app/settings.yaml.
31
+ backends:
32
+ default_backend:
33
+ search_backend_config:
34
+ es_host: opensearch
35
+ es_port: 9200
36
+ ES_NAME: {es_name}
37
+ backend_type: os_index
38
+ DS_EMBEDDING_DIM: {embedding_dim}
39
+ """
40
+
41
+
42
+ @dataclass
43
+ class Asset:
44
+ url: str
45
+ size: int
46
+ etag: str
47
+
48
+
49
+ def _s3_client(cfg: Config):
50
+ """An S3 client that signs only when credentials were supplied.
51
+
52
+ Mirrors the upstream storage client's behaviour: a public bucket must be
53
+ read anonymously, and passing empty-string credentials would force signed
54
+ requests and fail with NoCredentialsError.
55
+ """
56
+ key = cfg.get("S3_STORAGE_AWS_ACCESS_KEY_ID").strip()
57
+ secret = cfg.get("S3_STORAGE_AWS_SECRET_ACCESS_KEY").strip()
58
+ endpoint = cfg.get("S3_STORAGE_AWS_ENDPOINT_URL").strip() or None
59
+ region = cfg.get("S3_STORAGE_REGION_NAME", "us-west-2")
60
+
61
+ if key and secret:
62
+ return boto3.client(
63
+ "s3",
64
+ aws_access_key_id=key,
65
+ aws_secret_access_key=secret,
66
+ endpoint_url=endpoint,
67
+ region_name=region,
68
+ )
69
+ return boto3.client(
70
+ "s3",
71
+ endpoint_url=endpoint,
72
+ region_name=region,
73
+ config=BotoConfig(signature_version=UNSIGNED),
74
+ )
75
+
76
+
77
+ def list_prefix(client, bucket: str, prefix: str) -> Iterator[Dict]:
78
+ paginator = client.get_paginator("list_objects_v2")
79
+ for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
80
+ for obj in page.get("Contents", []):
81
+ yield obj
82
+
83
+
84
+ def enumerate_corpus(corpus: Corpus, cfg: Config) -> List[Asset]:
85
+ client = _s3_client(cfg)
86
+ seen: Dict[str, Asset] = {}
87
+ listed = 0
88
+
89
+ for bucket in corpus.buckets:
90
+ for prefix in corpus.include_prefixes_for(bucket):
91
+ print(f" listing s3://{bucket}/{prefix} ...", flush=True)
92
+ for obj in list_prefix(client, bucket, prefix):
93
+ listed += 1
94
+ url = f"s3://{bucket}/{obj['Key']}"
95
+ if not corpus.accepts(url):
96
+ continue
97
+ # Sibling include prefixes cannot overlap (validate() rejects
98
+ # nesting), but dedupe anyway so a future rule change cannot
99
+ # produce duplicate submissions.
100
+ seen.setdefault(
101
+ url,
102
+ Asset(url=url, size=int(obj.get("Size", 0)), etag=str(obj.get("ETag", "")).strip('"')),
103
+ )
104
+
105
+ # `removed` always wins, even over a matching `include` prefix: it names
106
+ # an asset the operator knows is gone, and re-submitting it here would
107
+ # fight the `clear` step (restore/delta indexing) that is about to drop it
108
+ # from the index.
109
+ removed = corpus.removed_set
110
+ if removed:
111
+ dropped = [url for url in seen if url in removed]
112
+ for url in dropped:
113
+ del seen[url]
114
+ if dropped:
115
+ print(f" dropped {len(dropped)} 'removed' asset(s) from submission")
116
+
117
+ print(f" listed {listed} objects, accepted {len(seen)}")
118
+ return [seen[k] for k in sorted(seen)]
119
+
120
+
121
+ def _hf_api():
122
+ """A `huggingface_hub.HfApi`, authenticated from `$HF_TOKEN` when set.
123
+
124
+ Read from the environment only, never `pipeline.env` β€” same policy as
125
+ every other credential this pipeline touches (S3 keys, the NVCF token).
126
+ `None` is fine for a public dataset; `list_repo_tree` then calls
127
+ anonymously.
128
+ """
129
+ from huggingface_hub import HfApi
130
+
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/
137
+ extension matching `Corpus.accepts` already uses for S3, and additionally
138
+ derives the `hf-storage-api` catalog's asset-folder set from the matched
139
+ files (see `pipeline/hf_catalog.py`) β€” one repo-tree read serves both.
140
+ """
141
+ from huggingface_hub import RepoFile
142
+
143
+ api = _hf_api()
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:
150
+ host, repo_type, repo_id, revision = repo_key
151
+ print(f" listing hf://{host}/{repo_type}s/{repo_id}/resolve/{revision}/ ...", flush=True)
152
+ file_entries = {
153
+ entry.path: entry
154
+ for entry in api.list_repo_tree(repo_id, recursive=True, revision=revision, repo_type=repo_type)
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():
161
+ listed += 1
162
+ url = hf_addressing.build(host=host, repo_type=repo_type, repo_id=repo_id, revision=revision, path=path)
163
+ if not corpus.accepts(url):
164
+ continue
165
+ matched.append(path)
166
+ seen.setdefault(url, Asset(url=url, size=int(entry.size or 0), etag=entry.blob_id or ""))
167
+ matched_paths_by_repo[repo_key] = matched
168
+
169
+ # See enumerate_corpus()'s own comment: `removed` always wins.
170
+ removed = corpus.removed_set
171
+ if removed:
172
+ dropped = [url for url in seen if url in removed]
173
+ for url in dropped:
174
+ del seen[url]
175
+ if dropped:
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
+
182
+
183
+ def _usdsearch_git_sha(cfg: Config) -> str:
184
+ repo = (REPO_ROOT / cfg.get("USDSEARCH_REPO", "../usdsearch")).resolve()
185
+ try:
186
+ out = subprocess.run(
187
+ ["git", "-C", str(repo), "rev-parse", "--short", "HEAD"],
188
+ capture_output=True, text=True, timeout=10,
189
+ )
190
+ return out.stdout.strip() if out.returncode == 0 else "unknown"
191
+ except (OSError, subprocess.SubprocessError):
192
+ return "unknown"
193
+
194
+
195
+ def build_run_id(cfg: Config, corpus: Corpus) -> str:
196
+ explicit = cfg.get("RUN_ID").strip()
197
+ if explicit:
198
+ return explicit
199
+ return f"{datetime.now(timezone.utc):%Y%m%d}-{corpus.run_id_suffix}"
200
+
201
+
202
+ def write_settings_yaml(cfg: Config, path: Path) -> None:
203
+ path.write_text(
204
+ SETTINGS_TEMPLATE.format(
205
+ es_name=cfg.es_name,
206
+ embedding_dim=cfg.get_int("DS_EMBEDDING_DIM", 1536),
207
+ )
208
+ )
209
+
210
+
211
+ PLUGIN_CONFIG_SOURCE = REPO_ROOT / "config" / "plugin.config.yaml"
212
+
213
+
214
+ def write_plugin_config_yaml(cfg: Config, path: Path) -> None:
215
+ """Generate `.build/plugin.config.yaml`, the file actually mounted into
216
+ info-endpoint (docker-compose.indexing.yml) β€” a copy of
217
+ config/plugin.config.yaml with `thumbnail_to_vision_metadata.active`
218
+ overridden to match `VLM_METADATA_ENABLED`, the one plugin this pipeline
219
+ toggles per run rather than pinning statically. Every other key is left
220
+ exactly as authored in the source file (same "explicit list, no silent
221
+ all-active fallback" policy that file's own header describes).
222
+ """
223
+ plugin_config: Dict[str, Dict] = yaml.safe_load(PLUGIN_CONFIG_SOURCE.read_text()) or {}
224
+ enabled = set(active_plugins(cfg))
225
+ for name in ALL_PLUGIN_NAMES:
226
+ plugin_config.setdefault(name, {})["active"] = name in enabled
227
+ path.write_text(
228
+ "# Generated by `run_pipeline.py enumerate` from config/plugin.config.yaml "
229
+ "β€” do not edit by hand.\n" + yaml.safe_dump(plugin_config, sort_keys=False)
230
+ )
231
+
232
+
233
+ def run(cfg: Config, dry_run: bool = False) -> int:
234
+ corpus = Corpus.load(cfg.corpus_file)
235
+ print(summarize(corpus))
236
+ print()
237
+
238
+ if corpus.storage_backend != cfg.storage_backend:
239
+ print(
240
+ f"\nERROR: corpus.json declares storage_backend={corpus.storage_backend!r} but "
241
+ f"STORAGE_BACKEND_TYPE={cfg.storage_backend!r} (pipeline.env / environment). "
242
+ f"They must agree β€” indexing against the wrong backend silently matches zero assets."
243
+ )
244
+ return 1
245
+
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)
252
+ else:
253
+ print("Enumerating S3 ...")
254
+ assets = enumerate_corpus(corpus, cfg)
255
+ if not assets:
256
+ print(
257
+ "\nERROR: the corpus matched zero objects. Check the include prefixes "
258
+ "and the extension list against what is actually in the bucket/repo."
259
+ )
260
+ return 1
261
+
262
+ run_id = build_run_id(cfg, corpus)
263
+
264
+ assets_path = BUILD_DIR / "assets.jsonl"
265
+ if not dry_run:
266
+ with assets_path.open("w") as fh:
267
+ for asset in assets:
268
+ fh.write(json.dumps(asdict(asset)) + "\n")
269
+
270
+ manifest = {
271
+ "run_id": run_id,
272
+ "corpus_name": corpus.name,
273
+ "corpus_sha256": corpus.sha256,
274
+ "storage_backend": corpus.storage_backend,
275
+ "created_utc": datetime.now(timezone.utc).isoformat(),
276
+ "usdsearch_git_sha": _usdsearch_git_sha(cfg),
277
+ "es_name": cfg.es_name,
278
+ "main_index": cfg.main_index,
279
+ "image_cache_index": cfg.image_cache_index,
280
+ "buckets": corpus.buckets if corpus.storage_backend == "s3" else [],
281
+ "hf_repos": [list(k) for k in corpus.hf_repos] if corpus.storage_backend == "storage_api" else [],
282
+ "active_plugins": list(active_plugins(cfg)),
283
+ "asset_count": len(assets),
284
+ "removed": list(corpus.removed),
285
+ }
286
+ if not dry_run:
287
+ if corpus.storage_backend == "storage_api":
288
+ catalog = hf_catalog.build_catalog(corpus, asset_folders)
289
+ (BUILD_DIR / "hf-storage-api-catalog.yaml").write_text(hf_catalog.dump_catalog(catalog))
290
+ (BUILD_DIR / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
291
+ write_settings_yaml(cfg, BUILD_DIR / "settings.yaml")
292
+ write_plugin_config_yaml(cfg, BUILD_DIR / "plugin.config.yaml")
293
+ # The exact input, copied for reproducibility and uploaded in stage 3.
294
+ (BUILD_DIR / "corpus.json").write_text(cfg.corpus_file.read_text())
295
+
296
+ print()
297
+ print(f"run_id: {run_id}")
298
+ print(f"assets: {len(assets)}")
299
+ print(f"main index: {cfg.main_index}")
300
+ print(f"image index: {cfg.image_cache_index}")
301
+ if dry_run:
302
+ print("\n(dry run β€” nothing written)")
303
+ else:
304
+ print(f"\nwrote {assets_path.relative_to(REPO_ROOT)} and manifest.json")
305
+ return 0
306
+
307
+
308
+ def load_assets(path: Optional[Path] = None) -> List[Asset]:
309
+ path = path or (BUILD_DIR / "assets.jsonl")
310
+ if not path.exists():
311
+ raise SystemExit(
312
+ f"{path} not found β€” run `python -m pipeline.run_pipeline enumerate` first."
313
+ )
314
+ out = []
315
+ for line in path.read_text().splitlines():
316
+ if line.strip():
317
+ out.append(Asset(**json.loads(line)))
318
+ return out
319
+
320
+
321
+ def load_manifest() -> Dict:
322
+ path = BUILD_DIR / "manifest.json"
323
+ if not path.exists():
324
+ raise SystemExit(
325
+ f"{path} not found β€” run `python -m pipeline.run_pipeline enumerate` first."
326
+ )
327
+ return json.loads(path.read_text())
328
+
329
+
330
+ def save_manifest(manifest: Dict) -> None:
331
+ (BUILD_DIR / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
pipeline/export.py ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage 2 β€” export the OpenSearch indexes and the Neo4j database.
2
+
3
+ The OpenSearch export deliberately produces the exact file layout that
4
+ upstream's `services/deepsearch_api/loader.sh` consumes:
5
+
6
+ <index>.settings.json <index>.mapping.json <index>.data.json
7
+
8
+ so the artifacts are restorable by the existing Helm `data_loader_job` and
9
+ container-test compose, not only by our own bake step. That compatibility is the
10
+ acceptance test for this stage (see the plan, Β§9 step 5).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import gzip
16
+ import hashlib
17
+ import json
18
+ import shutil
19
+ import subprocess
20
+ from pathlib import Path
21
+ from typing import Dict, List
22
+
23
+ import httpx
24
+
25
+ from .config import BUILD_DIR, REPO_ROOT, Config
26
+ from .enumerate_assets import load_manifest, save_manifest
27
+
28
+ ARTIFACTS = BUILD_DIR / "artifacts"
29
+ OS_ARTIFACTS = ARTIFACTS / "opensearch"
30
+ NEO4J_ARTIFACTS = ARTIFACTS / "neo4j"
31
+
32
+ COMPOSE_PROJECT = "simready-indexing"
33
+ ELASTICDUMP_IMAGE = "elasticdump/elasticsearch-dump:latest"
34
+
35
+
36
+ def _run(cmd: List[str], **kwargs) -> subprocess.CompletedProcess:
37
+ print(" $ " + " ".join(cmd[:6]) + (" ..." if len(cmd) > 6 else ""))
38
+ return subprocess.run(cmd, check=True, **kwargs)
39
+
40
+
41
+ def _uid_gid() -> str:
42
+ import os
43
+ return f"{os.getuid()}:{os.getgid()}"
44
+
45
+
46
+ def discover_indexes(cfg: Config) -> List[str]:
47
+ """Indexes belonging to this ES_NAME, read from the cluster.
48
+
49
+ Discovery rather than a hardcoded pair: the pointcloud cache exists in some
50
+ corpora, and a rename that missed one place would otherwise export an empty
51
+ index without complaint.
52
+ """
53
+ response = httpx.get(f"{cfg.opensearch_url}/_cat/indices?format=json", timeout=30.0)
54
+ response.raise_for_status()
55
+ names = sorted(
56
+ row["index"] for row in response.json()
57
+ if row.get("index", "").startswith(f"{cfg.es_name}-")
58
+ )
59
+ if cfg.main_index not in names:
60
+ raise SystemExit(
61
+ f"main index {cfg.main_index} not found in the cluster. Found: {names or 'none'}. "
62
+ f"Check ES_NAME against what the writers actually created."
63
+ )
64
+ return names
65
+
66
+
67
+ def _network_name() -> str:
68
+ return f"{COMPOSE_PROJECT}_default"
69
+
70
+
71
+ def elasticdump(index: str, dump_type: str, out_path: Path) -> None:
72
+ _run([
73
+ "docker", "run", "--rm",
74
+ "--network", _network_name(),
75
+ "--user", _uid_gid(),
76
+ "-v", f"{OS_ARTIFACTS}:/out",
77
+ ELASTICDUMP_IMAGE,
78
+ f"--input=http://opensearch:9200/{index}",
79
+ f"--output=/out/{out_path.name}",
80
+ f"--type={dump_type}",
81
+ "--limit=1000",
82
+ "--overwrite",
83
+ ])
84
+
85
+
86
+ def sha256_of(path: Path) -> str:
87
+ digest = hashlib.sha256()
88
+ with path.open("rb") as fh:
89
+ for block in iter(lambda: fh.read(1024 * 1024), b""):
90
+ digest.update(block)
91
+ return digest.hexdigest()
92
+
93
+
94
+ def gzip_file(path: Path) -> Path:
95
+ dest = path.with_suffix(path.suffix + ".gz")
96
+ with path.open("rb") as src, gzip.open(dest, "wb") as out:
97
+ shutil.copyfileobj(src, out)
98
+ return dest
99
+
100
+
101
+ def export_opensearch(cfg: Config, manifest: Dict) -> Dict:
102
+ OS_ARTIFACTS.mkdir(parents=True, exist_ok=True)
103
+ indexes = discover_indexes(cfg)
104
+ print(f" indexes: {', '.join(indexes)}")
105
+
106
+ expected = manifest.get("asset_count", 0)
107
+ main_count = int(
108
+ httpx.get(f"{cfg.opensearch_url}/{cfg.main_index}/_count", timeout=30.0).json()["count"]
109
+ )
110
+ if expected and main_count != expected:
111
+ raise SystemExit(
112
+ f"pre-flight: {cfg.main_index} holds {main_count} docs but the run expects "
113
+ f"{expected}. Refusing to export a partial index β€” re-run stage 1."
114
+ )
115
+
116
+ # Flush so everything written is on disk and countable.
117
+ httpx.post(f"{cfg.opensearch_url}/_flush", timeout=120.0)
118
+
119
+ exported: Dict[str, Dict] = {}
120
+ for index in indexes:
121
+ print(f" exporting {index} ...")
122
+ files = {}
123
+ for dump_type, suffix in (
124
+ ("settings", "settings.json"),
125
+ ("mapping", "mapping.json"),
126
+ ("data", "data.json"),
127
+ ):
128
+ plain = OS_ARTIFACTS / f"{index}.{suffix}"
129
+ elasticdump(index, dump_type, plain)
130
+ if not plain.exists() or plain.stat().st_size == 0:
131
+ raise SystemExit(f"elasticdump produced no {dump_type} for {index}")
132
+ compressed = gzip_file(plain)
133
+ files[dump_type] = {
134
+ "file": compressed.name,
135
+ "bytes": compressed.stat().st_size,
136
+ "sha256": sha256_of(compressed),
137
+ }
138
+ count = int(
139
+ httpx.get(f"{cfg.opensearch_url}/{index}/_count", timeout=30.0).json()["count"]
140
+ )
141
+ data_lines = sum(
142
+ 1 for _ in (OS_ARTIFACTS / f"{index}.data.json").open() if _.strip()
143
+ )
144
+ if data_lines != count:
145
+ raise SystemExit(
146
+ f"{index}: dumped {data_lines} rows but the index reports {count} docs"
147
+ )
148
+ exported[index] = {"doc_count": count, "files": files}
149
+ print(f" {count} docs")
150
+
151
+ return exported
152
+
153
+
154
+ def verify_dump(cfg: Config, dump: Path) -> None:
155
+ """Load the dump into a throwaway data dir to prove it is loadable.
156
+
157
+ Same principle as `bake.py::verify_baked` β€” check the artifact actually
158
+ works from a fresh process, not just that a file got written. A dump can
159
+ fail this while still existing on disk at a plausible size β€” see the
160
+ stop-ordering comment in `export_neo4j` β€” and `bake` finding out is
161
+ hours and several stages too late.
162
+
163
+ Runs as the host uid, unlike the real dump/load steps: those touch the
164
+ named `neo4j-data` volume, already owned by the in-container uid 7474,
165
+ so they must run as root. This scratch dir is fresh and ours, so running
166
+ as root here would just leave root-owned files behind that the host
167
+ user can't clean up afterwards.
168
+ """
169
+ import tempfile
170
+
171
+ with tempfile.TemporaryDirectory() as scratch:
172
+ result = subprocess.run(
173
+ [
174
+ "docker", "run", "--rm",
175
+ "--user", _uid_gid(),
176
+ "-v", f"{dump.parent}:/dumps:ro",
177
+ "-v", f"{scratch}:/data",
178
+ f"neo4j:{cfg.get('NEO4J_VERSION', '5.23.0-community')}",
179
+ "neo4j-admin", "database", "load", "neo4j",
180
+ "--from-path=/dumps", "--overwrite-destination=true",
181
+ ],
182
+ capture_output=True, text=True, check=False,
183
+ )
184
+ if result.returncode != 0:
185
+ raise SystemExit(
186
+ f"neo4j dump failed to verify-load β€” likely corrupted by AGS "
187
+ f"and neo4j being stopped concurrently (see the stop-ordering "
188
+ f"comment in export_neo4j):\n{result.stdout}\n{result.stderr}"
189
+ )
190
+
191
+
192
+ def export_neo4j(cfg: Config) -> Dict:
193
+ NEO4J_ARTIFACTS.mkdir(parents=True, exist_ok=True)
194
+
195
+ # Community edition cannot stop the default database from Cypher (no
196
+ # manual `db.checkpoint()` either β€” that procedure doesn't exist outside
197
+ # Enterprise), so the dump has to be taken offline via a plain container
198
+ # stop, relying on Neo4j's own graceful-shutdown checkpoint.
199
+ #
200
+ # AGS *must* be given its own `stop` call, fully awaited, before neo4j's
201
+ # `stop` call starts β€” not one `stop asset-graph-service neo4j` command
202
+ # naming both. `docker compose stop` sends SIGTERM to every named service
203
+ # concurrently regardless of argument order, so a single combined call
204
+ # can (and, reproduced directly, does) stop AGS and neo4j at the same
205
+ # moment β€” if AGS is mid-write when neo4j's shutdown checkpoint starts,
206
+ # the resulting dump is corrupted (truncated trailing zstd frame,
207
+ # `Not a valid Neo4j archive`) while still landing on disk at a
208
+ # plausible size, silently, caught only by a `neo4j-admin database load`
209
+ # deep in a later `bake`. Two sequential calls make the ordering real
210
+ # instead of advisory; `verify_dump` below is the safety net regardless.
211
+ # `docker compose stop`'s default grace period (~10s) is nowhere near
212
+ # enough for Neo4j's own graceful-shutdown checkpoint on a real corpus β€”
213
+ # verified live: a ~69k-node/81k-relationship graph took ~103s to stop
214
+ # cleanly. Short of that, compose SIGKILLs it, which produces exactly the
215
+ # "plausible size, silently corrupted" dump the comment above warns
216
+ # about, just from a different cause (timeout, not concurrent-stop
217
+ # ordering) β€” caught by `verify_dump` below either way, but avoidable.
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
227
+ # owned by the in-container neo4j user (uid 7474), and neo4j-admin refuses
228
+ # with "You do not have permission to dump the database" under --user.
229
+ # The dump is chowned to the host uid afterwards so the artifact is readable
230
+ # and removable outside the container.
231
+ #
232
+ # rm -f the previous dump ourselves, rather than passing
233
+ # --overwrite-destination=true: that flag has a confirmed upstream bug
234
+ # (neo4j/neo4j#13604) where overwriting a pre-existing *larger* archive
235
+ # doesn't fully truncate it, leaving old bytes at the tail of the new one
236
+ # β€” `Not a valid Neo4j archive` / `ZstdIOException: Unknown frame
237
+ # descriptor` on load, reproduced here on every retry (each one dumping
238
+ # over the previous attempt's own leftover file). The Neo4j maintainers'
239
+ # own workaround (same issue, 2025-10-27) is to delete the destination
240
+ # first and never pass --overwrite-destination β€” a genuinely fresh
241
+ # destination file can't hit this bug.
242
+ _run([
243
+ "docker", "run", "--rm",
244
+ "--entrypoint", "bash",
245
+ "-v", f"{COMPOSE_PROJECT}_neo4j-data:/data",
246
+ "-v", f"{NEO4J_ARTIFACTS}:/dumps",
247
+ f"neo4j:{cfg.get('NEO4J_VERSION', '5.23.0-community')}",
248
+ "-c",
249
+ "set -e; rm -f /dumps/neo4j.dump; neo4j-admin database dump neo4j --to-path=/dumps; "
250
+ f"chown -R {_uid_gid()} /dumps",
251
+ ])
252
+
253
+ dump = NEO4J_ARTIFACTS / "neo4j.dump"
254
+ if not dump.exists():
255
+ raise SystemExit(f"neo4j-admin produced no dump at {dump}")
256
+
257
+ print(" verifying the dump loads cleanly ...")
258
+ verify_dump(cfg, dump)
259
+
260
+ compressed = gzip_file(dump)
261
+
262
+ print(" restarting neo4j and asset-graph-service ...")
263
+ _run(["docker", "compose", "-p", COMPOSE_PROJECT, *cfg.indexing_compose_files(),
264
+ "start", "neo4j", "asset-graph-service"], env=cfg.compose_env())
265
+
266
+ return {
267
+ "file": compressed.name,
268
+ "bytes": compressed.stat().st_size,
269
+ "sha256": sha256_of(compressed),
270
+ }
271
+
272
+
273
+ def run(cfg: Config) -> int:
274
+ manifest = load_manifest()
275
+ ARTIFACTS.mkdir(parents=True, exist_ok=True)
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)
282
+
283
+ # Copy the inputs alongside the data so an artifact set is self-describing.
284
+ for name in ("corpus.json", "index-report.json", "assets.jsonl"):
285
+ src = BUILD_DIR / name
286
+ if src.exists():
287
+ shutil.copy2(src, ARTIFACTS / name)
288
+
289
+ save_manifest(manifest)
290
+ shutil.copy2(BUILD_DIR / "manifest.json", ARTIFACTS / "manifest.json")
291
+
292
+ print(f"\nartifacts in {ARTIFACTS.relative_to(REPO_ROOT)}:")
293
+ for path in sorted(ARTIFACTS.rglob("*")):
294
+ if path.is_file():
295
+ print(f" {path.relative_to(ARTIFACTS)} ({path.stat().st_size:,} bytes)")
296
+ return 0
pipeline/hf_addressing.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Parse/build ``hf://`` asset addresses in the same namespace `hf-storage-api`
2
+ (the gRPC Omniverse Storage API server backing the ``storage_api`` backend,
3
+ https://gitlab-master.nvidia.com/omniverse/gen-ai/hf-storage-api-service) hands
4
+ out and resolves.
5
+
6
+ This is a small, faithful port of that service's own
7
+ ``src/hf_storage_api/addressing.py`` (``build``/``parse``) β€” not an import,
8
+ since this repo has no dependency on that service's package. Keeping the
9
+ grammar here in sync with upstream matters: this pipeline's enumerator (stage
10
+ 0) must mint the *exact* addresses the running `hf-storage-api` instance will
11
+ resolve at indexing time, or `/process/asset` submissions 404 against a
12
+ storage backend that considers them out of catalog.
13
+
14
+ Addresses look like ``hf://<host>/[datasets/]<owner>/<name>[/resolve/<rev>]/<path>``
15
+ β€” folders end in ``/``, assets never do. `hf://` (not `https://`) is used
16
+ throughout this repo's corpus files and compose wiring specifically because
17
+ Kit's `omni.client` has a built-in HTTPS provider that would otherwise
18
+ ambiguously claim a plain `https://huggingface.co/...` address ahead of the
19
+ registered Storage API one β€” see the cluster's `hf-storage-api.yaml`
20
+ (`config.uriScheme: hf`).
21
+
22
+ **The `/resolve/<rev>/` segment must be a pinned commit sha, or absent
23
+ entirely β€” never a moving ref like `main`.** `hf-storage-api`'s own address
24
+ minting (`AssetStore._emit_revision`) only ever emits that segment for a
25
+ repo whose configured revision looks like a sha; for a moving ref it emits
26
+ none, specifically so an asset's address survives a re-crawl. Its resolve
27
+ path (`AssetStore.stat`/`_serves_revision`) enforces the same rule on the
28
+ way in: an address carrying a literal `resolve/main/` is checked against the
29
+ repo's *actual* resolved sha and β€” since `"main" != "<sha>"` β€” never
30
+ matches, so `Stat` always answers `NOT_FOUND`. Verified live against a real
31
+ deployment: every submission against a `resolve/main/`-shaped address
32
+ 404'd, and the exact same paths resolved cleanly with that segment removed.
33
+ `looks_like_sha()`/`build()`/`parse()` below enforce this at the address
34
+ grammar level rather than leaving it to each call site to remember.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from dataclasses import dataclass
40
+ from urllib.parse import unquote, urlsplit
41
+
42
+ REPO_TYPE_PREFIX = {"dataset": "datasets", "space": "spaces", "model": ""}
43
+
44
+ SCHEME = "hf"
45
+
46
+
47
+ def looks_like_sha(revision: str) -> bool:
48
+ """Mirrors `hf-storage-api`'s own `store._looks_like_sha` exactly β€” a
49
+ full 40-hex-char git commit sha, case-insensitive."""
50
+ return len(revision) == 40 and all(c in "0123456789abcdef" for c in revision.lower())
51
+
52
+
53
+ class AddressError(ValueError):
54
+ """The address is not a well-formed hf:// asset address."""
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class Address:
59
+ host: str
60
+ repo_type: str
61
+ repo_id: str # "<owner>/<name>"
62
+ path: str # repo-relative, no leading slash; "" for the repo root
63
+ is_folder: bool
64
+ revision: str | None = None
65
+
66
+
67
+ def parse(address: str) -> Address:
68
+ """Parse ``hf://host/[datasets/]<owner>/<name>[/resolve/<rev>][/<path>]``."""
69
+ if not address:
70
+ raise AddressError("empty address")
71
+
72
+ split = urlsplit(address)
73
+ if split.scheme != SCHEME:
74
+ raise AddressError(f"expected scheme {SCHEME!r}, got {split.scheme!r} in {address!r}")
75
+ if not split.netloc:
76
+ raise AddressError(f"missing host in {address!r}")
77
+
78
+ is_folder = split.path.endswith("/")
79
+ segments = [unquote(s) for s in split.path.split("/") if s]
80
+
81
+ repo_type = "model"
82
+ for rtype, prefix in REPO_TYPE_PREFIX.items():
83
+ if prefix and segments[:1] == [prefix]:
84
+ repo_type, segments = rtype, segments[1:]
85
+ break
86
+
87
+ if len(segments) < 2:
88
+ raise AddressError(f"address does not name a repo (<owner>/<name>): {address!r}")
89
+
90
+ repo_id = f"{segments[0]}/{segments[1]}"
91
+ rest = segments[2:]
92
+
93
+ revision: str | None = None
94
+ if len(rest) >= 2 and rest[0] == "resolve":
95
+ revision, rest = rest[1], rest[2:]
96
+
97
+ return Address(
98
+ host=split.netloc,
99
+ repo_type=repo_type,
100
+ repo_id=repo_id,
101
+ path="/".join(rest),
102
+ is_folder=is_folder,
103
+ revision=revision,
104
+ )
105
+
106
+
107
+ def build(
108
+ *,
109
+ host: str,
110
+ repo_type: str,
111
+ repo_id: str,
112
+ revision: str,
113
+ path: str = "",
114
+ is_folder: bool = False,
115
+ ) -> str:
116
+ """Emit a canonical ``hf://`` address. Folders end in ``/``; assets never do.
117
+
118
+ ``revision`` is the *repo's configured* revision (e.g. a corpus's
119
+ `HfPrefix.revision` or `hf-storage-api`'s `RepoConfig.revision`) β€” the
120
+ `/resolve/<rev>/` segment is emitted only when it looks like a pinned
121
+ commit sha (`looks_like_sha`), mirroring `AssetStore._emit_revision`
122
+ exactly. A moving ref (e.g. ``"main"``) is silently omitted from the
123
+ address rather than embedded literally β€” embedding it produces an
124
+ address `hf-storage-api` itself can never resolve (see the module
125
+ docstring).
126
+ """
127
+ prefix = REPO_TYPE_PREFIX.get(repo_type, "")
128
+ resolve_segment = f"resolve/{revision}" if looks_like_sha(revision) else ""
129
+ parts = [p for p in (prefix, repo_id, resolve_segment, path.strip("/")) if p]
130
+ out = f"{SCHEME}://{host}/" + "/".join(parts)
131
+ if is_folder and not out.endswith("/"):
132
+ out += "/"
133
+ return out
pipeline/hf_catalog.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Derive the `hf-storage-api` catalog (stage 0 output) from this run's matched
2
+ HF asset files.
3
+
4
+ `hf-storage-api`'s catalog names asset *folders*, not individual files β€” a
5
+ folder is "the asset" once it (or an ancestor) carries `corpus.asset_marker`,
6
+ and every file beneath a catalogued folder is then individually addressable
7
+ (see `pipeline/corpus.py`'s `DEFAULT_ASSET_MARKER` docstring). This module
8
+ turns the flat list of matched file paths `enumerate_assets.py` already
9
+ computed (via the same include/exclude/extension rules used for the S3
10
+ backend) into the folder set that catalog needs, by walking each matched
11
+ file's ancestors for the nearest one containing the marker β€” using the same
12
+ full repo-tree listing already fetched for matching, so this costs no extra
13
+ HuggingFace calls.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import yaml
19
+
20
+ from pathlib import PurePosixPath
21
+ from typing import Dict, Iterable, List, Set, Tuple
22
+
23
+ from .corpus import Corpus
24
+
25
+ # (host, repo_type, repo_id, revision) -> set of every path present in that
26
+ # repo tree, used to test marker-file membership without extra HF calls.
27
+ RepoKey = Tuple[str, str, str, str]
28
+
29
+
30
+ def _marker_path(folder: str, marker: str) -> str:
31
+ return f"{folder}/{marker}" if folder else marker
32
+
33
+
34
+ def _ancestors(folder: str) -> Iterable[str]:
35
+ """`folder` itself, then each ancestor up to (and including) the repo root `""`."""
36
+ current = folder
37
+ while True:
38
+ yield current
39
+ if not current:
40
+ return
41
+ current = PurePosixPath(current).parent.as_posix()
42
+ if current == ".":
43
+ current = ""
44
+
45
+
46
+ def find_asset_folder(path: str, marker: str, all_paths: Set[str]) -> str:
47
+ """The nearest ancestor of `path`'s directory that contains `marker`,
48
+ walking upward to the repo root. Falls back to `path`'s immediate parent
49
+ directory if no ancestor carries the marker β€” a corpus can include files
50
+ from a dataset with no marker file at all, and the folder is still a
51
+ well-defined catalog entry (just not marker-discoverable on its own).
52
+ """
53
+ parent = PurePosixPath(path).parent.as_posix()
54
+ parent = "" if parent == "." else parent
55
+ for ancestor in _ancestors(parent):
56
+ if _marker_path(ancestor, marker) in all_paths:
57
+ return ancestor
58
+ return parent
59
+
60
+
61
+ def asset_folders_by_repo(
62
+ corpus: Corpus,
63
+ matched_paths_by_repo: Dict[RepoKey, List[str]],
64
+ all_paths_by_repo: Dict[RepoKey, Set[str]],
65
+ ) -> Dict[RepoKey, List[str]]:
66
+ """One sorted, deduplicated folder list per repo, derived from this run's
67
+ matched files."""
68
+ out: Dict[RepoKey, List[str]] = {}
69
+ for repo_key, matched_paths in matched_paths_by_repo.items():
70
+ all_paths = all_paths_by_repo.get(repo_key, set())
71
+ folders = {
72
+ find_asset_folder(path, corpus.asset_marker, all_paths) for path in matched_paths
73
+ }
74
+ out[repo_key] = sorted(folders)
75
+ return out
76
+
77
+
78
+ def build_catalog(corpus: Corpus, asset_folders: Dict[RepoKey, List[str]]) -> dict:
79
+ """Render the `hf-storage-api` YAML catalog dict β€” see
80
+ `catalog.example.yaml` in
81
+ https://gitlab-master.nvidia.com/omniverse/gen-ai/hf-storage-api-service.
82
+ """
83
+ repos = []
84
+ for host, repo_type, repo_id, revision in corpus.hf_repos:
85
+ folders = asset_folders.get((host, repo_type, repo_id, revision), [])
86
+ repos.append({
87
+ "repo_id": repo_id,
88
+ "repo_type": repo_type,
89
+ "revision": revision,
90
+ "assets": folders,
91
+ })
92
+ layout = {"marker": corpus.asset_marker, "manifest": corpus.asset_marker}
93
+ if corpus.catalog_kind:
94
+ layout["catalog_kind"] = corpus.catalog_kind
95
+ if corpus.thumbs_dir:
96
+ layout["thumbs_dir"] = corpus.thumbs_dir
97
+ if corpus.content_fallback:
98
+ layout["content_fallback"] = corpus.content_fallback
99
+ return {
100
+ # All corpus entries share one host (validated by HfPrefix parsing);
101
+ # hf-storage-api's `endpoint` is catalog-wide.
102
+ "endpoint": f"https://{corpus.hf_repos[0][0]}" if corpus.hf_repos else "https://huggingface.co",
103
+ "repos": repos,
104
+ "layout": layout,
105
+ }
106
+
107
+
108
+ def dump_catalog(catalog: dict) -> str:
109
+ return yaml.safe_dump(catalog, sort_keys=False)
pipeline/hf_space.py ADDED
@@ -0,0 +1,314 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage 6 β€” deploy the HF Space source (not a prebuilt image) to a Hugging
2
+ Face Space.
3
+
4
+ Wired into CI as a manual-dispatch-only step (never the nightly schedule β€”
5
+ see nvcf-pipeline.yml); run locally with
6
+ `python -m pipeline.run_pipeline deploy-space --env-file ...` once `bake`
7
+ has verified the hf_space Dockerfile builds for this corpus (see
8
+ docker/Dockerfile.hf-space's header for why this Space builds from source
9
+ with only public base images, rather than pulling a prebuilt image of ours:
10
+ Hugging Face's own infra builds a Space's Dockerfile with no credentials
11
+ available for a private registry pull). Gates on `bake`'s own
12
+ hf_space_smoke_passed (set by bake.py's verify_baked), not the separate
13
+ `verify` stage's smoke_passed β€” `verify` brings up the *entire* runtime
14
+ stack, including asset-graph-service and other components the hf_space
15
+ image doesn't even include, so requiring it to fully pass would block a
16
+ deploy over failures this image can't possibly be affected by.
17
+
18
+ Requires a Hugging Face token with write access to the target Space's org,
19
+ resolved the usual huggingface_hub way (`hf auth login`, or `HF_TOKEN` in
20
+ the environment) β€” never read from pipeline.env, same policy as every other
21
+ credential this pipeline touches.
22
+
23
+ The index itself is *not* redeployed by this script β€” pipeline/hf_space_seed.py
24
+ restores it from S3 at container startup (see that module's docstring), so a
25
+ Space already picks up a newly `push`ed run on its own next restart, with no
26
+ redeploy at all. What this script's `# deploy: <run_id> (<corpus>)` marker
27
+ line (prepended to the Dockerfile) is for is forcing that restart when you
28
+ want it now rather than waiting: Hugging Face only rebuilds/restarts a Space
29
+ on a new commit, never by noticing that S3 data changed β€” without the
30
+ marker, re-running this stage when the pipeline code hasn't otherwise
31
+ changed would produce byte-identical file content and silently commit
32
+ nothing. The previous container keeps serving traffic until the new build
33
+ is ready (or forever, if the new build fails) β€” see docs/hub/spaces-overview's
34
+ "restarting" note.
35
+
36
+ RESTORE_RUN_ID (pin the Space to one fixed run instead of always-latest) is
37
+ deliberately left unmanaged here β€” set it directly via the Space's Settings
38
+ -> Variables in the HF UI if needed, so a redeploy never silently clobbers
39
+ an operator's manual pin.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import shutil
45
+ from pathlib import Path
46
+ from typing import Dict
47
+
48
+ from huggingface_hub import HfApi, SpaceHardware
49
+
50
+ from .artifact_store import artifact_env
51
+ from .bake import DOCKER_DIR, hf_space_build_args, hf_space_gpu_build_args, image_tags
52
+ from .config import BUILD_DIR, HF_SPACE_GPU_HARDWARE_DEFAULT, REPO_ROOT, Config
53
+ from .enumerate_assets import load_manifest
54
+
55
+ STAGE_DIR = BUILD_DIR / "hf_space_deploy"
56
+ STAGE_DIR_GPU = BUILD_DIR / "hf_space_deploy_gpu"
57
+
58
+ README_TEMPLATE = """---
59
+ title: {title}
60
+ emoji: \U0001f50e
61
+ colorFrom: green
62
+ colorTo: blue
63
+ sdk: docker
64
+ app_port: 7860
65
+ ---
66
+
67
+ # {title}
68
+
69
+ SimReady Central USD Search β€” single-container demo (OpenSearch + Redis +
70
+ deepsearch-api, SigLIP2 embeddings via NVCF). Built by Hugging Face from
71
+ this repo's docker/Dockerfile.hf-space; the index itself is pulled from the
72
+ artifact S3 bucket at container startup, not baked in. Deployed by
73
+ github.com/NVIDIA-dev/simready-central-indexing-internal's
74
+ `python -m pipeline.run_pipeline deploy-space`.
75
+ """
76
+
77
+ # GPU variant β€” see docker/Dockerfile.hf-space-gpu and
78
+ # docs/plans/siglip2-embedded-hf-space.md. `hardware:` in the front matter
79
+ # only sets the *default* for a brand-new Space; run() below also calls
80
+ # request_space_hardware explicitly so an existing Space actually gets
81
+ # switched to it on redeploy, since HF only reads this front-matter field
82
+ # at Space-creation time.
83
+ README_TEMPLATE_GPU = """---
84
+ title: {title}
85
+ emoji: \U0001f50e
86
+ colorFrom: green
87
+ colorTo: blue
88
+ sdk: docker
89
+ app_port: 7860
90
+ hardware: {hardware}
91
+ ---
92
+
93
+ # {title}
94
+
95
+ SimReady Central USD Search β€” single-container demo (OpenSearch + Neo4j +
96
+ Redis + deepsearch-api + asset-graph-service, SigLIP2 embeddings via a local
97
+ siglip2-triton process β€” see docker/Dockerfile.hf-space-gpu). Runs on a GPU
98
+ hardware tier ({hardware}), unlike the free-tier
99
+ docker/Dockerfile.hf-space variant, which routes SigLIP2 to NVCF instead.
100
+ The index itself is pulled from the artifact S3 bucket at container
101
+ startup, not baked in. Deployed by
102
+ github.com/NVIDIA-dev/simready-central-indexing-internal's
103
+ `python -m pipeline.run_pipeline deploy-space-gpu`.
104
+ """
105
+
106
+
107
+ def stage_repo(dest: Path, manifest: Dict, readme: str, dockerfile_name: str = "Dockerfile.hf-space") -> None:
108
+ if dest.exists():
109
+ shutil.rmtree(dest)
110
+ dest.mkdir(parents=True)
111
+
112
+ marker = f"# deploy: {manifest['run_id']} ({manifest['corpus_name']})\n"
113
+ dockerfile = (DOCKER_DIR / dockerfile_name).read_text()
114
+ (dest / "Dockerfile").write_text(marker + dockerfile)
115
+ (dest / "README.md").write_text(readme)
116
+
117
+ # docker/hf-space/siglip2-model/ (vendored via scripts/extract_siglip2_model.sh)
118
+ # is only referenced by Dockerfile.hf-space-gpu's own COPY instructions β€”
119
+ # excluded entirely for the NVCF/cpu-basic variant. Even for the GPU
120
+ # variant, that Dockerfile only COPYs the two ONNX encoders'
121
+ # config.pbtxt (tiny) β€” NOT their ~3.6 GB weight files under each
122
+ # encoder's own 1/ subdirectory, which are restored from S3 at container
123
+ # startup instead (pipeline/hf_space_seed.py --component=siglip2-model β€”
124
+ # see that Dockerfile's own comment for why: HF Space git repos cap
125
+ # storage at 1 GB by default, and a real deploy-space-gpu attempt 403'd
126
+ # with "Repository storage limit reached" until *both* of these
127
+ # exclusions existed β€” the first fix here only handled the NVCF variant,
128
+ # verified live to still be insufficient for the GPU one on its own).
129
+ def _ignore_siglip2_weights(dir_path: str, names: List[str]) -> List[str]:
130
+ if Path(dir_path).name in ("siglip2_vision_encoder_onnx", "siglip2_text_encoder_onnx") and "1" in names:
131
+ return ["1"]
132
+ return []
133
+
134
+ if dockerfile_name == "Dockerfile.hf-space-gpu":
135
+ hf_space_ignore = _ignore_siglip2_weights
136
+ else:
137
+ hf_space_ignore = shutil.ignore_patterns("siglip2-model")
138
+ shutil.copytree(DOCKER_DIR / "hf-space", dest / "docker" / "hf-space", ignore=hf_space_ignore)
139
+ shutil.copytree(
140
+ REPO_ROOT / "pipeline", dest / "pipeline",
141
+ ignore=shutil.ignore_patterns("__pycache__", "*.pyc"),
142
+ )
143
+ # The Dockerfile COPYs this in directly now (see its own comment) β€”
144
+ # must ship alongside it or HF's build fails with a missing path.
145
+ # A dist built by simready-central's slim/scripts/build-explorer-public.sh
146
+ # carries services/explorer/dist/build-info.json ({product, version,
147
+ # bundle_id, built_at, flags}) β€” the bundle's provenance; the upstream
148
+ # USD Search Explorer build has no such file.
149
+ shutil.copytree(REPO_ROOT / "services" / "explorer" / "dist", dest / "services" / "explorer" / "dist")
150
+
151
+
152
+ def run(cfg: Config) -> int:
153
+ manifest = load_manifest()
154
+ if not manifest.get("hf_space_smoke_passed"):
155
+ print(
156
+ "The hf_space image's own smoke test has not passed for this "
157
+ "run. Run `bake` (its verify_baked step sets "
158
+ "hf_space_smoke_passed) before deploying β€” an unverified build "
159
+ "shouldn't go live."
160
+ )
161
+ return 1
162
+ if "hf_space" not in image_tags(cfg, manifest):
163
+ print("No hf_space image for this run (SIGLIP2_BACKEND != nvcf) β€” nothing to deploy.")
164
+ return 1
165
+
166
+ repo_id = cfg.require("HF_SPACE_REPO_ID") # e.g. "nvidia/simready-central"
167
+ title = cfg.get("HF_SPACE_TITLE", repo_id.split("/", 1)[-1])
168
+ siglip2_token = cfg.siglip2_nvcf_env()["SIGLIP2_TRITON_SERVER_AUTH_TOKEN"]
169
+ aws = artifact_env() # raises if the artifact-bucket keys aren't set
170
+
171
+ print(f"staging Space source at {STAGE_DIR} ...")
172
+ stage_repo(STAGE_DIR, manifest, README_TEMPLATE.format(title=title))
173
+
174
+ api = HfApi()
175
+ print(f"creating/reusing Space {repo_id} ...")
176
+ api.create_repo(repo_id=repo_id, repo_type="space", space_sdk="docker", exist_ok=True)
177
+
178
+ print("setting build-arg variables ...")
179
+ # Hugging Face passes any Space "Variable" whose name matches a
180
+ # Dockerfile ARG through as a --build-arg on its own build (see
181
+ # docs/hub/spaces-sdks-docker) β€” this is the *only* way these values
182
+ # reach HF's build, since nothing here invokes `docker build` directly
183
+ # against the committed Dockerfile. hf_space_build_args() is the same
184
+ # dict bake.py's own local/CI build passes via --build-arg, so the two
185
+ # can't silently drift apart.
186
+ for key, value in hf_space_build_args(cfg, manifest).items():
187
+ api.add_space_variable(repo_id, key, value)
188
+
189
+ print("setting runtime secrets ...")
190
+ # Names must match what pipeline/artifact_store.py's artifact_env() and
191
+ # config.py's siglip2_nvcf_env() read from the environment β€” Hugging
192
+ # Face injects Space secrets as plain runtime env vars, so no separate
193
+ # "buildtime" naming/mounting is needed (see hf_space_seed.py's
194
+ # docstring for why this pipeline restores at runtime rather than build
195
+ # time in the first place).
196
+ api.add_space_secret(repo_id, "SIMREADYCENTRAL_INDEX_STORE_AWS_ACCESS_KEY_ID", aws["AWS_ACCESS_KEY_ID"])
197
+ api.add_space_secret(repo_id, "SIMREADYCENTRAL_INDEX_STORE_AWS_SECRET_ACCESS_KEY", aws["AWS_SECRET_ACCESS_KEY"])
198
+ api.add_space_secret(repo_id, "SIGLIP2_TRITON_SERVER_AUTH_TOKEN", siglip2_token)
199
+
200
+ # LLM parsing / VLM verification: off by default (see
201
+ # docker/Dockerfile.hf-space's baked USDSEARCH_LLM_PARSING_ENABLED/
202
+ # USDSEARCH_VISION_VALIDATION_ENABLED=false) β€” only touched when
203
+ # LLM_ENDPOINT_BASE_URL is actually set, so a deploy with it unset
204
+ # doesn't require LLM_ENDPOINT_API_KEY either and behaves exactly as
205
+ # before this feature existed.
206
+ llm_vars = cfg.llm_endpoint_variables()
207
+ if llm_vars:
208
+ print(f"enabling LLM parsing / VLM verification -> {cfg.llm_endpoint_base_url}")
209
+ for key, value in llm_vars.items():
210
+ api.add_space_variable(repo_id, key, value)
211
+ api.add_space_secret(repo_id, "USDSEARCH_LLM_API_KEY", cfg.llm_endpoint_api_key())
212
+
213
+ print("committing Space source ...")
214
+ api.upload_folder(
215
+ repo_id=repo_id,
216
+ repo_type="space",
217
+ folder_path=str(STAGE_DIR),
218
+ commit_message=f"Deploy {manifest['corpus_name']} run {manifest['run_id']}",
219
+ # The Explorer bundle is content-hashed (static/js/main.<hash>.js):
220
+ # without this, every deploy after a bundle change leaves the previous
221
+ # hashed chunks behind as orphans on the Hub, and a stale index.html
222
+ # from another bundle can survive next to the new one.
223
+ delete_patterns=["services/explorer/dist/**"],
224
+ )
225
+
226
+ print(f"\ndeployed: https://huggingface.co/spaces/{repo_id}")
227
+ print(
228
+ "Hugging Face is now rebuilding on this commit β€” the previous "
229
+ "container (if any) keeps serving until the new one is ready."
230
+ )
231
+ return 0
232
+
233
+
234
+ def run_gpu(cfg: Config) -> int:
235
+ """Deploy the GPU variant (docker/Dockerfile.hf-space-gpu, local
236
+ siglip2-triton instead of NVCF) to its own, separate Space β€”
237
+ HF_SPACE_REPO_ID_GPU, not HF_SPACE_REPO_ID. Same shape as run() above;
238
+ see that function's own comments for anything not called out again
239
+ here. Gated on hf_space_gpu_smoke_passed (set by bake.py's
240
+ verify_baked, which needs a GPU host to run at all β€” see that
241
+ function's own comment) rather than hf_space_smoke_passed, since the
242
+ two images are built and verified independently.
243
+ """
244
+ manifest = load_manifest()
245
+ if not manifest.get("hf_space_gpu_smoke_passed"):
246
+ print(
247
+ "The hf_space_gpu image's own smoke test has not passed for "
248
+ "this run. Run `bake` with HF_SPACE_BUILD_GPU_VARIANT=true on a "
249
+ "GPU-equipped host (its verify_baked step sets "
250
+ "hf_space_gpu_smoke_passed) before deploying."
251
+ )
252
+ return 1
253
+ if "hf_space_gpu" not in image_tags(cfg, manifest):
254
+ print("No hf_space_gpu image for this run (HF_SPACE_BUILD_GPU_VARIANT != true) β€” nothing to deploy.")
255
+ return 1
256
+
257
+ repo_id = cfg.require("HF_SPACE_REPO_ID_GPU") # e.g. "nvidia/simready-central-gpu"
258
+ title = cfg.get("HF_SPACE_TITLE_GPU", repo_id.split("/", 1)[-1])
259
+ hardware = cfg.get("HF_SPACE_GPU_HARDWARE", HF_SPACE_GPU_HARDWARE_DEFAULT)
260
+ aws = artifact_env() # raises if the artifact-bucket keys aren't set β€” no NVCF token needed here
261
+
262
+ print(f"staging Space source at {STAGE_DIR_GPU} ...")
263
+ stage_repo(
264
+ STAGE_DIR_GPU, manifest,
265
+ README_TEMPLATE_GPU.format(title=title, hardware=hardware),
266
+ dockerfile_name="Dockerfile.hf-space-gpu",
267
+ )
268
+
269
+ api = HfApi()
270
+ print(f"creating/reusing Space {repo_id} ...")
271
+ api.create_repo(repo_id=repo_id, repo_type="space", space_sdk="docker", exist_ok=True)
272
+
273
+ print(f"requesting {hardware} hardware ...")
274
+ # Front-matter `hardware:` (in README_TEMPLATE_GPU) only takes effect
275
+ # when HF creates a brand-new Space; an existing Space keeps whatever
276
+ # tier it's already on unless explicitly changed here β€” this call is
277
+ # what actually makes a redeploy switch it, e.g. after bumping
278
+ # HF_SPACE_GPU_HARDWARE.
279
+ api.request_space_hardware(repo_id, hardware=SpaceHardware(hardware))
280
+
281
+ print("setting build-arg variables ...")
282
+ for key, value in hf_space_gpu_build_args(cfg, manifest).items():
283
+ api.add_space_variable(repo_id, key, value)
284
+
285
+ print("setting runtime secrets ...")
286
+ # No SIGLIP2_TRITON_SERVER_AUTH_TOKEN here β€” this variant's local
287
+ # siglip2-triton process has no auth in front of it (loopback-only,
288
+ # same as OpenSearch/Neo4j/Redis in this image).
289
+ api.add_space_secret(repo_id, "SIMREADYCENTRAL_INDEX_STORE_AWS_ACCESS_KEY_ID", aws["AWS_ACCESS_KEY_ID"])
290
+ api.add_space_secret(repo_id, "SIMREADYCENTRAL_INDEX_STORE_AWS_SECRET_ACCESS_KEY", aws["AWS_SECRET_ACCESS_KEY"])
291
+
292
+ llm_vars = cfg.llm_endpoint_variables()
293
+ if llm_vars:
294
+ print(f"enabling LLM parsing / VLM verification -> {cfg.llm_endpoint_base_url}")
295
+ for key, value in llm_vars.items():
296
+ api.add_space_variable(repo_id, key, value)
297
+ api.add_space_secret(repo_id, "USDSEARCH_LLM_API_KEY", cfg.llm_endpoint_api_key())
298
+
299
+ print("committing Space source ...")
300
+ api.upload_folder(
301
+ repo_id=repo_id,
302
+ repo_type="space",
303
+ folder_path=str(STAGE_DIR_GPU),
304
+ commit_message=f"Deploy {manifest['corpus_name']} run {manifest['run_id']} (GPU variant)",
305
+ delete_patterns=["services/explorer/dist/**"],
306
+ )
307
+
308
+ print(f"\ndeployed: https://huggingface.co/spaces/{repo_id}")
309
+ print(
310
+ "Hugging Face is now rebuilding on this commit β€” the previous "
311
+ "container (if any) keeps serving until the new one is ready. "
312
+ f"Billing runs at the {hardware} rate while this Space is not asleep."
313
+ )
314
+ return 0
pipeline/hf_space_seed.py ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Runtime OpenSearch/Neo4j/siglip2-model seeding for the HF Space image.
2
+
3
+ Run at container startup by supervisord, one call per data store β€” see
4
+ docker/hf-space/supervisord.conf (NVCF/cpu-basic variant) and
5
+ docker/hf-space/supervisord-gpu.conf (GPU variant): `--component=opensearch`
6
+ gates deepsearch-api's own startup on finishing (it waits for OpenSearch,
7
+ then restores into the already-running cluster over HTTP); `--component=neo4j`
8
+ *is* the neo4j supervisord program's command, since neo4j-admin's load
9
+ requires the database to be offline β€” restore has to happen and finish
10
+ *before* the real `neo4j` process starts, not after, unlike OpenSearch's
11
+ live-bulk-load. `--component=siglip2-model` (GPU variant only) *is* the
12
+ siglip2-triton supervisord program's command, same "restore then exec into
13
+ the real process" shape as neo4j's β€” see main_siglip2_model()'s own
14
+ docstring for why these particular weights are restored at runtime instead
15
+ of baked in, unlike everything else this variant's Dockerfile does bake.
16
+ Not at `docker build` time: HF Space secrets are only ever
17
+ plain runtime env vars for a Docker Space (there is no way to give a `FROM`
18
+ pull credentials at Hugging Face's own build time β€” see
19
+ docker/Dockerfile.hf-space's header β€” and using a runtime secret sidesteps
20
+ that entirely, since restoring becomes something the already-running,
21
+ already-public container does for itself, with the artifact-bucket AWS
22
+ keys arriving the same way the NVCF token does). Reuses `restore.py`'s
23
+ tested S3-download + PUT-index + `_bulk` + `_refresh` sequence for
24
+ OpenSearch directly, just without the indexing pipeline's own local
25
+ manifest.json/docker-compose state, since none of that exists inside a
26
+ Space container β€” only the plain env vars baked in by pipeline/bake.py
27
+ (ARTIFACT_S3_URI, ARTIFACT_S3_REGION, CORPUS_NAME) plus one live one:
28
+
29
+ RESTORE_RUN_ID β€” optional runtime *variable* (not secret; not baked in).
30
+ Empty (the default) means "whatever this corpus's `latest.json` pointer
31
+ currently names" β€” re-resolved independently by each component on every
32
+ container (re)start, so a Space picks up newly `push`ed runs on its next
33
+ restart with no redeploy needed. Set to an explicit run id (matching
34
+ pipeline/restore.py's own RESTORE_RUN_ID) to pin the Space to one fixed run
35
+ regardless of what gets published later β€” set via the Space's Settings ->
36
+ Variables (a plain, non-secret runtime var, editable without triggering a
37
+ rebuild). Both components resolve it the same way, so they always agree on
38
+ which run to restore β€” except for a narrow, accepted race: if a new run is
39
+ published to S3 in the brief window between the two components' own
40
+ independent (unpinned) `latest.json` reads, they could disagree by one run.
41
+ Low-probability and self-corrects on the Space's next restart; not worth
42
+ the added complexity of coordinating the two processes over this.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import argparse
48
+ import gzip
49
+ import json
50
+ import os
51
+ import shutil
52
+ import subprocess
53
+ import sys
54
+ import time
55
+ from pathlib import Path
56
+
57
+ import httpx
58
+
59
+ from .artifact_store import artifact_env, aws_cmd, run_aws_with_retry
60
+ from .config import BUILD_DIR, Config
61
+ from .restore import RESTORE_DIR, download, restore_opensearch
62
+
63
+ OS_URL = "http://localhost:9200"
64
+ NEO4J_HOME = Path("/var/lib/neo4j")
65
+ NEO4J_DATA_DIR = Path("/data")
66
+ # Separate from OpenSearch's own RESTORE_DIR (pipeline/restore.py's default)
67
+ # so the two components' downloads, run by separate supervisord programs,
68
+ # never race on the same directory β€” see download()'s own docstring note.
69
+ NEO4J_RESTORE_DIR = BUILD_DIR / "restore-neo4j"
70
+
71
+ # GPU variant only (docker/Dockerfile.hf-space-gpu) β€” a third component,
72
+ # corpus-*independent* unlike the two above (the same weights serve every
73
+ # corpus this variant might ever be built for): restores the SigLIP2 ONNX
74
+ # encoders' weight files, which the Dockerfile deliberately does NOT bake
75
+ # in (see its own comment β€” HF Space git repos cap storage at 1 GB by
76
+ # default, well under the ~3.6 GB these weights need). Published once via
77
+ # scripts/publish_siglip2_model_to_s3.sh, whenever
78
+ # scripts/extract_siglip2_model.sh refreshes the vendored copy; restored
79
+ # here into the config.pbtxt-only skeleton the Dockerfile does bake.
80
+ MODELS_DIR = Path("/models")
81
+ SIGLIP2_MODEL_S3_PREFIX_DEFAULT = "siglip2-model/fp16-v1"
82
+ SIGLIP2_TRITON_CMD = [
83
+ "tritonserver", "--model-repository=/models", "--http-port=8003",
84
+ "--log-verbose=0", "--strict-model-config=true",
85
+ "--backend-config=onnxruntime,default-max-batch-size=32",
86
+ "--response-cache-byte-size=536870912", "--exit-on-error=false",
87
+ ]
88
+
89
+
90
+ def resolve_prefix(cfg: Config, base: str, corpus_name: str) -> str:
91
+ pinned = cfg.get("RESTORE_RUN_ID").strip()
92
+ if pinned:
93
+ print(f"==> RESTORE_RUN_ID={pinned} β€” using a pinned run, not latest.json")
94
+ return f"{base}/{corpus_name}/{pinned}"
95
+
96
+ pointer_uri = f"{base}/{corpus_name}/latest.json"
97
+ # Outer timeout raised alongside aws_cmd()'s own --cli-read-timeout
98
+ # default (see that function's comment) β€” even this tiny a file hit the
99
+ # same intermittent connectivity issue on a real T4 Space deploy.
100
+ result = subprocess.run(
101
+ aws_cmd(cfg) + ["cp", pointer_uri, "-"],
102
+ capture_output=True, text=True, env=artifact_env(), timeout=120,
103
+ )
104
+ if result.returncode != 0:
105
+ raise SystemExit(
106
+ f"no published run found at {pointer_uri} β€” run `upload` for "
107
+ f"this corpus at least once before starting this Space."
108
+ )
109
+ return json.loads(result.stdout)["prefix"]
110
+
111
+
112
+ def wait_for_cluster(timeout: float = 180.0) -> None:
113
+ deadline = time.monotonic() + timeout
114
+ while time.monotonic() < deadline:
115
+ try:
116
+ if httpx.get(f"{OS_URL}/_cluster/health", timeout=5.0).status_code < 400:
117
+ return
118
+ except httpx.HTTPError:
119
+ pass
120
+ time.sleep(2)
121
+ raise SystemExit("OpenSearch did not become ready before the seeding deadline")
122
+
123
+
124
+ def main_opensearch(cfg: Config, base: str, corpus_name: str) -> int:
125
+ print("==> waiting for OpenSearch")
126
+ wait_for_cluster()
127
+
128
+ prefix = resolve_prefix(cfg, base, corpus_name)
129
+ print(f"==> restoring from {prefix}")
130
+ download(cfg, prefix)
131
+ manifest = json.loads((RESTORE_DIR / "manifest.json").read_text())
132
+ restore_opensearch(cfg, manifest)
133
+
134
+ print("==> document counts")
135
+ for index in sorted(manifest.get("opensearch_artifacts") or {}):
136
+ count = httpx.get(f"{OS_URL}/{index}/_count", timeout=10.0).json()["count"]
137
+ print(f" {index}: {count}")
138
+ if count == 0:
139
+ raise SystemExit(f"index {index} is empty after seeding")
140
+
141
+ print("==> seed complete")
142
+ return 0
143
+
144
+
145
+ def main_neo4j(cfg: Config, base: str, corpus_name: str) -> int:
146
+ # Idempotency guard, same reasoning as docker/neo4j-seeded-entrypoint.sh:
147
+ # a supervisord restart of this program after a crash (not a fresh
148
+ # container) should just start neo4j, not re-download/re-load.
149
+ if (NEO4J_DATA_DIR / "databases" / "neo4j").exists():
150
+ print("==> /data already populated, skipping restore")
151
+ else:
152
+ prefix = resolve_prefix(cfg, base, corpus_name)
153
+ print(f"==> restoring from {prefix}")
154
+ download(cfg, prefix, dest=NEO4J_RESTORE_DIR)
155
+ manifest = json.loads((NEO4J_RESTORE_DIR / "manifest.json").read_text())
156
+ artifact = manifest.get("neo4j_artifact")
157
+ if not artifact:
158
+ raise SystemExit("manifest has no neo4j_artifact for this run")
159
+
160
+ load_dir = NEO4J_RESTORE_DIR / "neo4j" / "load"
161
+ load_dir.mkdir(parents=True, exist_ok=True)
162
+ src = NEO4J_RESTORE_DIR / "neo4j" / artifact["file"]
163
+ dest = load_dir / "neo4j.dump"
164
+ with gzip.open(src, "rb") as fh, dest.open("wb") as out:
165
+ shutil.copyfileobj(fh, out)
166
+
167
+ print("==> loading neo4j (offline β€” must finish before the server starts)")
168
+ subprocess.run(
169
+ [str(NEO4J_HOME / "bin" / "neo4j-admin"), "database", "load", "neo4j",
170
+ f"--from-path={load_dir}", "--overwrite-destination=true"],
171
+ check=True, timeout=600,
172
+ )
173
+ shutil.rmtree(NEO4J_RESTORE_DIR, ignore_errors=True)
174
+ print("==> neo4j load complete")
175
+
176
+ print("==> starting neo4j")
177
+ os.execvp(str(NEO4J_HOME / "bin" / "neo4j"), [str(NEO4J_HOME / "bin" / "neo4j"), "console"])
178
+
179
+
180
+ def main_siglip2_model(cfg: Config) -> int:
181
+ """Restore the SigLIP2 ONNX encoders' weight files, then exec tritonserver.
182
+
183
+ Corpus-independent (unlike main_opensearch/main_neo4j) β€” these weights
184
+ are the same regardless of which corpus this Space serves, published
185
+ once at a fixed S3 prefix (scripts/publish_siglip2_model_to_s3.sh), not
186
+ part of any indexing run's own artifact set. Restored at runtime rather
187
+ than baked into docker/Dockerfile.hf-space-gpu because HF Space git
188
+ repos cap storage at 1 GB by default β€” verified live 2026-09-23, a real
189
+ deploy-space-gpu attempt 403'd ("Repository storage limit reached") at
190
+ ~3.6 GB of baked weights.
191
+ """
192
+ # Idempotency guard, same reasoning as main_neo4j's: a supervisord
193
+ # restart of this program after a crash (not a fresh container) should
194
+ # just start tritonserver, not re-download ~3.6 GB.
195
+ marker = MODELS_DIR / ".siglip2_weights_restored"
196
+ if marker.exists():
197
+ print("==> siglip2 weights already restored, skipping")
198
+ else:
199
+ prefix_name = cfg.get("SIGLIP2_MODEL_S3_PREFIX", SIGLIP2_MODEL_S3_PREFIX_DEFAULT).strip()
200
+ # Deliberately not resolve_prefix()/ARTIFACT_S3_URI's <corpus>/<run>
201
+ # shape β€” these weights are corpus-independent, published once at a
202
+ # fixed prefix (see scripts/publish_siglip2_model_to_s3.sh), not
203
+ # part of any indexing run's own artifact set.
204
+ base = cfg.require("ARTIFACT_S3_URI").rstrip("/")
205
+ prefix = f"{base}/{prefix_name}"
206
+ print(f"==> restoring siglip2 model weights from {prefix}")
207
+ # run_aws_with_retry, not a bare subprocess.run: a real deploy hit
208
+ # an intermittent `exit 255` here (and, on a later deploy, on the
209
+ # much smaller neo4j sync too) that longer timeouts alone didn't
210
+ # fully fix β€” see that function's own docstring.
211
+ run_aws_with_retry(
212
+ aws_cmd(cfg) + ["sync", prefix + "/", str(MODELS_DIR), "--only-show-errors"],
213
+ env=artifact_env(), timeout=1800,
214
+ )
215
+ for name in ("siglip2_vision_encoder_onnx", "siglip2_text_encoder_onnx"):
216
+ weight_file = MODELS_DIR / name / "1" / "model.onnx"
217
+ if not weight_file.exists():
218
+ raise SystemExit(f"{weight_file} missing after sync from {prefix} β€” publish incomplete?")
219
+ marker.touch()
220
+ print("==> siglip2 weights restored")
221
+
222
+ print("==> starting siglip2-triton")
223
+ os.execvp(SIGLIP2_TRITON_CMD[0], SIGLIP2_TRITON_CMD)
224
+
225
+
226
+ def main() -> int:
227
+ parser = argparse.ArgumentParser()
228
+ parser.add_argument("--component", choices=["opensearch", "neo4j", "siglip2-model"], default="opensearch")
229
+ args = parser.parse_args()
230
+
231
+ cfg = Config(values=dict(os.environ))
232
+
233
+ if args.component == "siglip2-model":
234
+ return main_siglip2_model(cfg)
235
+
236
+ base = cfg.require("ARTIFACT_S3_URI").rstrip("/")
237
+ corpus_name = cfg.require("CORPUS_NAME")
238
+
239
+ if args.component == "neo4j":
240
+ return main_neo4j(cfg, base, corpus_name)
241
+ return main_opensearch(cfg, base, corpus_name)
242
+
243
+
244
+ if __name__ == "__main__":
245
+ sys.exit(main())
pipeline/queues.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Redis plugin-queue depth, for submission backpressure and drain checks.
2
+
3
+ Key names are not guessed. From `packages/cache/cache/src/client/redis.py:109`
4
+ and `client/config.py:22`, the streams are:
5
+
6
+ deepsearch_{plugin_name}_jobs per-plugin job stream
7
+ deepsearch_results plugin results, drained by omni-writer
8
+
9
+ with consumer group `deepsearch-worker-pool`. "Unprocessed" for a stream is the
10
+ consumer group's backlog (`lag`) plus its in-flight entries (`pending`), which
11
+ is what `_total_unprocessed` reports internally.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Dict, Iterable
17
+
18
+ import redis.asyncio as aioredis
19
+
20
+ STREAM_PREFIX = "deepsearch"
21
+
22
+
23
+ def plugin_stream(plugin_name: str) -> str:
24
+ return f"{STREAM_PREFIX}_{plugin_name}_jobs"
25
+
26
+
27
+ RESULTS_STREAM = f"{STREAM_PREFIX}_results"
28
+
29
+
30
+ async def stream_backlog(client: "aioredis.Redis", stream: str) -> int:
31
+ """Unprocessed entries in `stream`, across all consumer groups.
32
+
33
+ Before any consumer group exists the whole stream is unprocessed, so fall
34
+ back to XLEN rather than reporting a misleading zero.
35
+ """
36
+ try:
37
+ groups = await client.xinfo_groups(stream)
38
+ except aioredis.ResponseError:
39
+ return 0 # stream does not exist yet
40
+ if not groups:
41
+ try:
42
+ return int(await client.xlen(stream))
43
+ except aioredis.ResponseError:
44
+ return 0
45
+ total = 0
46
+ for group in groups:
47
+ lag = group.get("lag")
48
+ total += int(lag or 0) + int(group.get("pending") or 0)
49
+ return total
50
+
51
+
52
+ async def queue_depths(client: "aioredis.Redis", plugins: Iterable[str]) -> Dict[str, int]:
53
+ depths = {p: await stream_backlog(client, plugin_stream(p)) for p in plugins}
54
+ depths["results"] = await stream_backlog(client, RESULTS_STREAM)
55
+ return depths
56
+
57
+
58
+ def connect(url: str) -> "aioredis.Redis":
59
+ return aioredis.from_url(url, decode_responses=True)
pipeline/restore.py ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage 1a (pre-submit) β€” restore a previous run's index into the live stack.
2
+
3
+ Paired with `clear`, this turns full-corpus indexing into delta indexing:
4
+ resume from the last successful run's complete OpenSearch + Neo4j state, then
5
+ `submit` only touches the assets this run's corpus.json actually names β€”
6
+ `new`, `changed` (a URL that already exists in the baseline, so it must be
7
+ cleared first β€” see `clear.py`), or `removed` (cleared and never resubmitted).
8
+
9
+ Skipped cleanly on the very first run for a corpus: no `latest.json` yet and
10
+ no explicit `RESTORE_RUN_ID` means there is nothing to restore, so the stack
11
+ just starts empty, exactly as before this feature existed.
12
+
13
+ The OpenSearch load mirrors `docker/opensearch-seed.sh` (same PUT-index +
14
+ `_bulk` + `_refresh` sequence, same settings/mappings merge from `bake.py`),
15
+ run directly against the live cluster instead of a build-time container. The
16
+ Neo4j load mirrors `export.py::export_neo4j`'s stop/dump/restart dance,
17
+ reversed: stop, `neo4j-admin database load --overwrite-destination=true`,
18
+ restart.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import gzip
24
+ import json
25
+ import shutil
26
+ import subprocess
27
+ from pathlib import Path
28
+ from typing import Dict, List, Optional
29
+
30
+ import httpx
31
+
32
+ from .artifact_store import artifact_env, aws_cmd, bucket_base, run_aws_with_retry
33
+ from .bake import build_create_body
34
+ from .config import BUILD_DIR, Config
35
+ from .enumerate_assets import load_manifest
36
+ from .export import COMPOSE_PROJECT
37
+ from .to_bulk import iter_bulk_lines
38
+
39
+ RESTORE_DIR = BUILD_DIR / "restore"
40
+ BASELINE_ASSETS = RESTORE_DIR / "assets.jsonl"
41
+
42
+
43
+ def _run(cmd: List[str], **kwargs) -> subprocess.CompletedProcess:
44
+ print(" $ " + " ".join(str(c) for c in cmd[:8]) + (" ..." if len(cmd) > 8 else ""))
45
+ return subprocess.run(cmd, check=True, **kwargs)
46
+
47
+
48
+ def resolve_baseline_prefix(cfg: Config) -> Optional[str]:
49
+ """The S3 prefix to restore from, or None if there is no previous run."""
50
+ corpus_name = load_manifest()["corpus_name"]
51
+ base = bucket_base(cfg)
52
+
53
+ explicit = cfg.get("RESTORE_RUN_ID").strip()
54
+ if explicit:
55
+ return f"{base}/{corpus_name}/{explicit}"
56
+
57
+ pointer_uri = f"{base}/{corpus_name}/latest.json"
58
+ try:
59
+ result = subprocess.run(
60
+ aws_cmd(cfg) + ["cp", pointer_uri, "-"],
61
+ capture_output=True, text=True, env=artifact_env(), timeout=60,
62
+ )
63
+ except subprocess.TimeoutExpired:
64
+ raise SystemExit(
65
+ f"aws s3 cp {pointer_uri} timed out after 60s β€” see artifact_store.py's "
66
+ f"aws_cmd() docstring for why this call is bounded at all."
67
+ )
68
+ if result.returncode != 0:
69
+ return None
70
+ pointer = json.loads(result.stdout)
71
+ return pointer["prefix"]
72
+
73
+
74
+ def download(cfg: Config, prefix: str, dest: Path = RESTORE_DIR) -> None:
75
+ # dest is overridable so two independent restores (e.g. the HF Space's
76
+ # OpenSearch and Neo4j restores, run by separate supervisord programs β€”
77
+ # see pipeline/hf_space_seed.py) can download in parallel without
78
+ # racing on the same directory: one's `rmtree` could wipe out files the
79
+ # other is still reading.
80
+ if dest.exists():
81
+ shutil.rmtree(dest)
82
+ dest.mkdir(parents=True)
83
+ # Outer wall-clock timeout raised alongside aws_cmd()'s own
84
+ # --cli-read-timeout default (see that function's comment) β€” a per-read
85
+ # timeout larger than this outer one would just get preempted by it.
86
+ # run_aws_with_retry (not the bare _run() every other caller in this
87
+ # file uses) β€” see its own docstring for why this specific call needed
88
+ # backoff-retry, not just a longer timeout.
89
+ print(" $ " + " ".join(str(c) for c in (aws_cmd(cfg) + ["sync", prefix + "/", str(dest), "--only-show-errors"])[:8]) + " ...")
90
+ run_aws_with_retry(
91
+ aws_cmd(cfg) + ["sync", prefix + "/", str(dest), "--only-show-errors"],
92
+ env=artifact_env(), timeout=360,
93
+ )
94
+
95
+
96
+ def bulk_load(opensearch_url: str, index: str, data_path: Path, chunk_docs: int) -> int:
97
+ lines = iter_bulk_lines(data_path, index)
98
+ batch: List[str] = []
99
+ docs_in_batch = 0
100
+ loaded = 0
101
+
102
+ def flush() -> None:
103
+ nonlocal batch, docs_in_batch, loaded
104
+ if not batch:
105
+ return
106
+ response = httpx.post(
107
+ f"{opensearch_url}/_bulk",
108
+ content=("\n".join(batch) + "\n").encode(),
109
+ headers={"Content-Type": "application/x-ndjson"},
110
+ timeout=120.0,
111
+ )
112
+ response.raise_for_status()
113
+ result = response.json()
114
+ if result.get("errors"):
115
+ failed = [
116
+ item for action in result.get("items", [])
117
+ for item in action.values() if item.get("error")
118
+ ]
119
+ raise SystemExit(
120
+ f"{index}: bulk restore rejected {len(failed)} document(s): {failed[:3]}"
121
+ )
122
+ loaded += docs_in_batch
123
+ batch = []
124
+ docs_in_batch = 0
125
+
126
+ for action_line, source_line in zip(*[lines] * 2):
127
+ batch.append(action_line)
128
+ batch.append(source_line)
129
+ docs_in_batch += 1
130
+ if docs_in_batch >= chunk_docs:
131
+ flush()
132
+ flush()
133
+ return loaded
134
+
135
+
136
+ def restore_opensearch(cfg: Config, manifest: Dict) -> None:
137
+ artifacts = manifest.get("opensearch_artifacts") or {}
138
+ if not artifacts:
139
+ print(" no opensearch_artifacts in the baseline manifest, nothing to load")
140
+ return
141
+
142
+ os_dir = RESTORE_DIR / "opensearch"
143
+ chunk_docs = cfg.get_int("BULK_CHUNK_DOCS", 500)
144
+ for index in sorted(artifacts):
145
+ print(f" loading {index} ...")
146
+ # info-endpoint/omni-writer create the index empty at their own
147
+ # startup, with whatever minimal/dynamic mapping their app code uses
148
+ # β€” not the full settings+mappings a previous run resolved. Loading
149
+ # into that leaves fields like `usd_dimensions.*` dynamically typed,
150
+ # which OpenSearch resolves per-document during the bulk load itself
151
+ # and can reject a later doc with "cannot be changed from type X to
152
+ # Y" if two documents' JSON numbers imply different types. An index
153
+ # that already holds documents is left alone (never drop real data);
154
+ # an empty one is replaced with the authoritative baseline mapping.
155
+ count_response = httpx.get(f"{cfg.opensearch_url}/{index}/_count", timeout=10.0)
156
+ existing_docs = count_response.json().get("count", 0) if count_response.status_code < 400 else 0
157
+ if existing_docs == 0:
158
+ if count_response.status_code < 400:
159
+ httpx.delete(f"{cfg.opensearch_url}/{index}", timeout=30.0)
160
+ body = build_create_body(
161
+ os_dir / f"{index}.settings.json.gz",
162
+ os_dir / f"{index}.mapping.json.gz",
163
+ )
164
+ response = httpx.put(f"{cfg.opensearch_url}/{index}", json=body, timeout=30.0)
165
+ if response.status_code >= 400:
166
+ # raise_for_status()'s own message is just "403 Forbidden" β€”
167
+ # the actual reason (e.g. a disk-watermark read-only block)
168
+ # is in the body, and only there.
169
+ raise SystemExit(
170
+ f"PUT {cfg.opensearch_url}/{index} failed: "
171
+ f"{response.status_code} {response.text}"
172
+ )
173
+ else:
174
+ print(f" {index} already holds {existing_docs} doc(s) β€” leaving its mapping as-is")
175
+
176
+ loaded = bulk_load(cfg.opensearch_url, index, os_dir / f"{index}.data.json.gz", chunk_docs)
177
+ print(f" loaded {loaded} docs")
178
+
179
+ # Same reasoning as opensearch-seed.sh: the index is created with a long
180
+ # refresh_interval, so `_count` reads 0 until an explicit refresh happens.
181
+ httpx.post(f"{cfg.opensearch_url}/_refresh", timeout=60.0)
182
+
183
+
184
+ def restore_neo4j(cfg: Config, manifest: Dict) -> None:
185
+ artifact = manifest.get("neo4j_artifact")
186
+ if not artifact:
187
+ print(" no neo4j_artifact in the baseline manifest, nothing to load")
188
+ return
189
+
190
+ load_dir = RESTORE_DIR / "neo4j" / "load"
191
+ if load_dir.exists():
192
+ shutil.rmtree(load_dir)
193
+ load_dir.mkdir(parents=True)
194
+ src = RESTORE_DIR / "neo4j" / artifact["file"]
195
+ dest = load_dir / "neo4j.dump"
196
+ with gzip.open(src, "rb") as fh, dest.open("wb") as out:
197
+ shutil.copyfileobj(fh, out)
198
+
199
+ print(" stopping asset-graph-service and neo4j ...")
200
+ _run(["docker", "compose", "-p", COMPOSE_PROJECT, *cfg.indexing_compose_files(),
201
+ "stop", "asset-graph-service", "neo4j"], env=cfg.compose_env())
202
+
203
+ print(" loading neo4j ...")
204
+ # Runs as root (the image default, same as export's dump), then chowns
205
+ # back to uid 7474 β€” the volume is owned by the in-container neo4j user,
206
+ # same fact export.py's dump step relies on in the other direction.
207
+ _run([
208
+ "docker", "run", "--rm",
209
+ "--entrypoint", "bash",
210
+ "-v", f"{COMPOSE_PROJECT}_neo4j-data:/data",
211
+ "-v", f"{load_dir}:/dumps",
212
+ f"neo4j:{cfg.get('NEO4J_VERSION', '5.23.0-community')}",
213
+ "-c",
214
+ "set -e; neo4j-admin database load neo4j --from-path=/dumps "
215
+ "--overwrite-destination=true; chown -R 7474:7474 /data",
216
+ ])
217
+
218
+ print(" restarting neo4j and asset-graph-service ...")
219
+ _run(["docker", "compose", "-p", COMPOSE_PROJECT, *cfg.indexing_compose_files(),
220
+ "start", "neo4j", "asset-graph-service"], env=cfg.compose_env())
221
+
222
+
223
+ def run(cfg: Config) -> int:
224
+ prefix = resolve_baseline_prefix(cfg)
225
+ if prefix is None:
226
+ print("no previous run found for this corpus β€” starting from an empty index")
227
+ if RESTORE_DIR.exists():
228
+ shutil.rmtree(RESTORE_DIR)
229
+ RESTORE_DIR.mkdir(parents=True)
230
+ BASELINE_ASSETS.write_text("")
231
+ return 0
232
+
233
+ print(f"restoring baseline from {prefix}")
234
+ download(cfg, prefix)
235
+ manifest = json.loads((RESTORE_DIR / "manifest.json").read_text())
236
+
237
+ print("\nRestoring OpenSearch ...")
238
+ restore_opensearch(cfg, manifest)
239
+
240
+ print("\nRestoring Neo4j ...")
241
+ restore_neo4j(cfg, manifest)
242
+
243
+ print(f"\nrestored run {manifest['run_id']} ({manifest.get('asset_count', '?')} baseline assets)")
244
+ return 0
245
+
246
+
247
+ def load_baseline_urls() -> set:
248
+ """URLs already indexed as of the restored baseline. Empty on a first run."""
249
+ if not BASELINE_ASSETS.exists():
250
+ return set()
251
+ return {
252
+ json.loads(line)["url"]
253
+ for line in BASELINE_ASSETS.read_text().splitlines()
254
+ if line.strip()
255
+ }
256
+
257
+
258
+ def expected_total(removed: set, current_urls: set) -> int:
259
+ """Doc/node count the whole index should hold once this run finishes.
260
+
261
+ baseline, minus whatever this run explicitly removed, unioned with
262
+ whatever this run submits (new assets add, repeating assets overwrite in
263
+ place so they don't double-count).
264
+ """
265
+ baseline = load_baseline_urls()
266
+ return len((baseline - removed) | current_urls)
pipeline/run_pipeline.py ADDED
@@ -0,0 +1,311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """USD Search index re-generation pipeline β€” single entry point.
2
+
3
+ python -m pipeline.run_pipeline <stage> [options]
4
+
5
+ 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
13
+ verify runtime-only stack from the baked images + smoke test
14
+ push push the baked images (only after verify passes)
15
+ all every stage in order
16
+
17
+ deploy-space deploy the hf_space image to a Hugging Face Space (not
18
+ part of `all` β€” run manually after `push`; see
19
+ pipeline/hf_space.py)
20
+ deploy-space-gpu same, for the GPU variant (local siglip2-triton
21
+ instead of NVCF, HF_SPACE_BUILD_GPU_VARIANT=true β€”
22
+ see docker/Dockerfile.hf-space-gpu and
23
+ docs/plans/siglip2-embedded-hf-space.md)
24
+
25
+ Helper stages: `up`, `down`, `restore`, `clear`, `submit`, `wait`, `status`.
26
+
27
+ `restore`/`clear` turn this into delta indexing: `restore` loads the previous
28
+ run's complete OpenSearch + Neo4j state (from `latest.json`, or an explicit
29
+ `RESTORE_RUN_ID`) into the freshly-up stack; `clear` then removes anything
30
+ this run's corpus.json marks `removed`, plus anything that's about to be
31
+ resubmitted (a URL present in both the baseline and this run's assets β€”
32
+ Neo4j's dependency edges aren't idempotent on reprocessing, so the old ones
33
+ must go first). `submit` then only touches this run's assets.jsonl β€” the
34
+ delta the corpus.json actually names. First run for a corpus (no previous
35
+ `latest.json`) skips `restore` cleanly and behaves exactly as before this
36
+ feature existed.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import argparse
42
+ import os
43
+ import subprocess
44
+ import sys
45
+ from pathlib import Path
46
+ from typing import List
47
+
48
+ from .config import BUILD_DIR, Config
49
+
50
+ INDEXING_PROJECT = "simready-indexing"
51
+
52
+
53
+ def _compose(cfg: Config, args: List[str]) -> int:
54
+ cmd = ["docker", "compose", "-p", INDEXING_PROJECT, *cfg.indexing_compose_files(), *args]
55
+ print("$ " + " ".join(cmd[:8]) + (" ..." if len(cmd) > 8 else ""))
56
+ return subprocess.run(cmd, env=cfg.compose_env(), check=False).returncode
57
+
58
+
59
+ # ── stages ───────────────────────────────────────────────────────────────────
60
+
61
+
62
+ def stage_enumerate(cfg: Config, args) -> int:
63
+ from .enumerate_assets import run
64
+ return run(cfg, dry_run=args.dry_run)
65
+
66
+
67
+ def stage_up(cfg: Config, args) -> int:
68
+ if not (BUILD_DIR / "settings.yaml").exists() or not (BUILD_DIR / "plugin.config.yaml").exists():
69
+ print("`.build/settings.yaml`/`.build/plugin.config.yaml` are missing β€” run `enumerate` first.")
70
+ return 1
71
+ if cfg.siglip2_nvcf:
72
+ print(f"siglip2 backend: nvcf ({cfg.get('SIGLIP2_NVCF_ENDPOINT', 'grpc.nvcf.nvidia.com:443')}) "
73
+ f"β€” no local GPU needed for embeddings.")
74
+ if cfg.vlm_metadata_enabled:
75
+ cfg.require_vlm_metadata_credentials()
76
+ model = os.environ.get("USDSEARCH_VISION_METADATA_MODEL", "").strip() or "azure/openai/gpt-5.6-sol (default)"
77
+ base_url = os.environ.get("USDSEARCH_LLM_BASE_URL", "").strip() or "https://inference-api.nvidia.com (default)"
78
+ print(f"VLM metadata extraction: enabled (model={model!r} @ {base_url})")
79
+ # Independent replica counts, not one shared knob: thumbnail_to_embedding
80
+ # and asset_graph_generation don't actually bottleneck together in
81
+ # practice β€” the Kit-based USD parse graph-builder does for
82
+ # asset_graph_generation is far slower per-asset than a siglip2 call, so
83
+ # a real corpus typically wants these scaled very differently. All three
84
+ # services dropped container_name (and, for the two workers, host ports)
85
+ # in compose/docker-compose.indexing.yml specifically to allow this.
86
+ scale_args: List[str] = []
87
+ scale_targets = [
88
+ ("monitor-worker-thumbnail-to-embedding", "THUMBNAIL_WORKER_REPLICAS"),
89
+ ("monitor-worker-asset-graph-generation", "AGS_WORKER_REPLICAS"),
90
+ ("graph-builder", "GRAPH_BUILDER_REPLICAS"),
91
+ ]
92
+ if cfg.vlm_metadata_enabled:
93
+ scale_targets.append(
94
+ ("monitor-worker-thumbnail-to-vision-metadata", "VLM_METADATA_WORKER_REPLICAS")
95
+ )
96
+ for service, key in scale_targets:
97
+ replicas = cfg.get_int(key, 1)
98
+ if replicas > 1:
99
+ print(f"scaling {service} to {replicas} replicas ({key})")
100
+ scale_args += ["--scale", f"{service}={replicas}"]
101
+ return _compose(cfg, ["up", "-d", "--wait", *scale_args])
102
+
103
+
104
+ def stage_down(cfg: Config, args) -> int:
105
+ return _compose(cfg, ["down"] + (["-v"] if args.volumes else []))
106
+
107
+
108
+ def stage_restore(cfg: Config, args) -> int:
109
+ from .restore import run
110
+ return run(cfg)
111
+
112
+
113
+ def stage_clear(cfg: Config, args) -> int:
114
+ from .clear import run
115
+ return run(cfg)
116
+
117
+
118
+ def stage_submit(cfg: Config, args) -> int:
119
+ from .submit import run
120
+ return run(cfg, resume=not args.no_resume)
121
+
122
+
123
+ def stage_wait(cfg: Config, args) -> int:
124
+ from .status import run
125
+ return run(cfg)
126
+
127
+
128
+ def stage_index(cfg: Config, args) -> int:
129
+ """Bring the stack up, restore the baseline, clear, submit the delta, wait."""
130
+ steps = (
131
+ ("up", stage_up),
132
+ ("restore", stage_restore),
133
+ ("clear", stage_clear),
134
+ ("submit", stage_submit),
135
+ ("wait", stage_wait),
136
+ )
137
+ for step, fn in steps:
138
+ print(f"\n{'=' * 70}\n== index: {step}\n{'=' * 70}")
139
+ code = fn(cfg, args)
140
+ if code != 0:
141
+ print(f"\nindex stage failed at `{step}` (exit {code})")
142
+ return code
143
+ return 0
144
+
145
+
146
+ def stage_export(cfg: Config, args) -> int:
147
+ from .export import run
148
+ return run(cfg)
149
+
150
+
151
+ def stage_upload(cfg: Config, args) -> int:
152
+ from .upload import run
153
+ return run(cfg)
154
+
155
+
156
+ def stage_bake(cfg: Config, args) -> int:
157
+ from .bake import run
158
+ return run(cfg, push=False)
159
+
160
+
161
+ def stage_verify(cfg: Config, args) -> int:
162
+ from .verify import run
163
+ return run(cfg, keep_up=args.keep_up)
164
+
165
+
166
+ def stage_deploy_space(cfg: Config, args) -> int:
167
+ from .hf_space import run
168
+ return run(cfg)
169
+
170
+
171
+ def stage_deploy_space_gpu(cfg: Config, args) -> int:
172
+ from .hf_space import run_gpu
173
+ return run_gpu(cfg)
174
+
175
+
176
+ def stage_push(cfg: Config, args) -> int:
177
+ from .bake import image_tags, latest_tags
178
+ from .enumerate_assets import load_manifest
179
+ manifest = load_manifest()
180
+ if not manifest.get("smoke_passed") and not args.force:
181
+ print(
182
+ "The smoke test has not passed for this run. Run `verify` first, or "
183
+ "pass --force if you are pushing deliberately without it."
184
+ )
185
+ return 1
186
+ latest = latest_tags(cfg, manifest)
187
+ for name, tag in image_tags(cfg, manifest).items():
188
+ print(f"pushing {tag}")
189
+ result = subprocess.run(["docker", "push", tag], check=False)
190
+ if result.returncode != 0:
191
+ return result.returncode
192
+
193
+ # `-latest` only ever means "the most recently *published* run" β€”
194
+ # a `docker tag` here, not built alongside the versioned tag in
195
+ # bake.py, so a plain `bake` (never pushed) never touches it.
196
+ latest_tag = latest[name]
197
+ print(f"tagging {latest_tag}")
198
+ subprocess.run(["docker", "tag", tag, latest_tag], check=True)
199
+ print(f"pushing {latest_tag}")
200
+ result = subprocess.run(["docker", "push", latest_tag], check=False)
201
+ if result.returncode != 0:
202
+ return result.returncode
203
+ return 0
204
+
205
+
206
+ def stage_all(cfg: Config, args) -> int:
207
+ order = [
208
+ ("enumerate", stage_enumerate),
209
+ ("index", stage_index),
210
+ ("export", stage_export),
211
+ ("upload", stage_upload),
212
+ ("bake", stage_bake),
213
+ ("verify", stage_verify),
214
+ ("push", stage_push),
215
+ ]
216
+ names = [n for n, _ in order]
217
+ start = names.index(args.start) if args.start else 0
218
+ stop = names.index(args.stop) + 1 if args.stop else len(order)
219
+
220
+ for i, (name, fn) in enumerate(order[start:stop], start=start):
221
+ print(f"\n{'#' * 70}\n# stage: {name}\n{'#' * 70}")
222
+ code = fn(cfg, args)
223
+ if name == "upload" and code == 2:
224
+ # ARTIFACT_S3_URI is the one unresolved input; skipping it must not
225
+ # block baking and verifying locally.
226
+ print("skipping upload (ARTIFACT_S3_URI unset) and continuing")
227
+ continue
228
+ if code != 0:
229
+ print(f"\npipeline stopped: stage `{name}` failed (exit {code})")
230
+ return code
231
+ if name == "export" and i + 1 < stop:
232
+ # `export` already captured everything the indexing stack has to
233
+ # offer β€” neither `upload` nor `bake` touch it again, and
234
+ # `verify` next brings up the *runtime* stack, which binds the
235
+ # same host ports (9200, 7474/7687, ...) anyway. Tear it down
236
+ # here, not after `bake`: leaving opensearch/neo4j/usdsearch/
237
+ # graph-builder running for the full duration of `bake`'s two
238
+ # `docker build`s serves no purpose and competes for the same
239
+ # constrained disk/memory a CI runner has β€” a run was observed
240
+ # to hang inside `bake` for 44+ minutes, unresponsive even to
241
+ # the job's own force-cancel, consistent with the runner
242
+ # thrashing under that contention rather than any single
243
+ # command actually being stuck. Skipped if `--stop export` or
244
+ # `--stop upload` means nothing after this needs the stack down,
245
+ # so an operator who stopped here on purpose can still inspect it.
246
+ print("\ntearing down the indexing stack (nothing after `export` needs it) ...")
247
+ _compose(cfg, ["down", "--volumes"])
248
+ print("\npipeline complete")
249
+ return 0
250
+
251
+
252
+ STAGES = {
253
+ "enumerate": stage_enumerate,
254
+ "up": stage_up,
255
+ "down": stage_down,
256
+ "restore": stage_restore,
257
+ "clear": stage_clear,
258
+ "submit": stage_submit,
259
+ "wait": stage_wait,
260
+ "status": stage_wait,
261
+ "index": stage_index,
262
+ "export": stage_export,
263
+ "upload": stage_upload,
264
+ "bake": stage_bake,
265
+ "verify": stage_verify,
266
+ "push": stage_push,
267
+ "deploy-space": stage_deploy_space,
268
+ "deploy-space-gpu": stage_deploy_space_gpu,
269
+ "all": stage_all,
270
+ }
271
+
272
+
273
+ def main(argv: List[str] | None = None) -> int:
274
+ parser = argparse.ArgumentParser(
275
+ prog="run_pipeline",
276
+ description=__doc__,
277
+ formatter_class=argparse.RawDescriptionHelpFormatter,
278
+ )
279
+ parser.add_argument("stage", choices=sorted(STAGES))
280
+ parser.add_argument("--env-file", type=Path, default=None,
281
+ help="defaults to config/pipeline.env")
282
+ parser.add_argument("--dry-run", action="store_true",
283
+ help="enumerate: list and filter without writing")
284
+ parser.add_argument("--no-resume", action="store_true",
285
+ help="submit: ignore the checkpoint and resubmit everything")
286
+ parser.add_argument("--volumes", action="store_true",
287
+ help="down: also remove volumes (destroys the index)")
288
+ parser.add_argument("--keep-up", action="store_true",
289
+ help="verify: leave the runtime stack running afterwards")
290
+ parser.add_argument("--force", action="store_true",
291
+ help="push: push without a passing smoke test")
292
+ parser.add_argument("--start", choices=list(STAGES), default=None,
293
+ help="all: resume from this stage")
294
+ parser.add_argument("--stop", choices=list(STAGES), default=None,
295
+ help="all: stop after this stage")
296
+ args = parser.parse_args(argv)
297
+
298
+ cfg = Config.load(args.env_file)
299
+ if not cfg.values:
300
+ print(
301
+ f"No configuration found at {cfg.env_file}.\n"
302
+ f"Copy config/pipeline.env.example to config/pipeline.env and edit it.",
303
+ file=sys.stderr,
304
+ )
305
+ return 1
306
+
307
+ return STAGES[args.stage](cfg, args)
308
+
309
+
310
+ if __name__ == "__main__":
311
+ sys.exit(main())
pipeline/status.py ADDED
@@ -0,0 +1,381 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage 1b β€” wait for indexing to complete, then report on it.
2
+
3
+ Two phases, because the endpoint's two halves have different timing
4
+ (see submit.py):
5
+
6
+ Phase A (deterministic) the base metadata write is synchronous, so once
7
+ submission succeeds for N assets the main index must
8
+ hold exactly N documents. No polling.
9
+
10
+ Phase B (polled) plugin work is asynchronous. Poll
11
+ /info/indexing/asset/status per asset, which reports
12
+ per-plugin state *and the failure reason*.
13
+
14
+ `PluginStatusType` (services/info-endpoint/.../api/models.py:26) has five
15
+ states, not the three its endpoint docstring lists β€” `failed_indexing` and
16
+ `out_of_sync_and_failed_indexing` are terminal failures we can read directly
17
+ rather than inferring from history strings.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import json
24
+ import subprocess
25
+ import time
26
+ from dataclasses import dataclass, field
27
+ from typing import Dict, List, Optional, Set, Tuple
28
+
29
+ import httpx
30
+
31
+ from .config import BUILD_DIR, Config, active_plugins
32
+ from .enumerate_assets import load_assets, load_manifest
33
+ from .export import COMPOSE_PROJECT
34
+ from .queues import connect, queue_depths
35
+
36
+
37
+ def dump_worker_logs(cfg: Config) -> None:
38
+ """Surface why assets never went terminal, when the poll loop gives up.
39
+
40
+ The actual failure (if any) happened inside these containers β€” the
41
+ worker calling out to NVCF/Triton, or info-endpoint dispatching the
42
+ job β€” not in this script, so a bare "TIMEOUT" here explains nothing on
43
+ its own. Best-effort: never let a logging step mask the real failure.
44
+ """
45
+ print("\n worker/info-endpoint logs (last 100 lines each), for diagnosis:")
46
+ services = ["monitor-worker-thumbnail-to-embedding", "monitor-worker-asset-graph-generation", "info-endpoint"]
47
+ if cfg.vlm_metadata_enabled:
48
+ services.insert(2, "monitor-worker-thumbnail-to-vision-metadata")
49
+ for service in services:
50
+ print(f"\n --- {service} ---")
51
+ try:
52
+ subprocess.run(
53
+ ["docker", "compose", "-p", COMPOSE_PROJECT, *cfg.indexing_compose_files(),
54
+ "logs", "--tail", "100", service],
55
+ check=False, timeout=30, env=cfg.compose_env(),
56
+ )
57
+ except subprocess.SubprocessError as exc:
58
+ print(f" (could not fetch logs for {service}: {exc})")
59
+
60
+ DONE = {"in_sync"}
61
+ TERMINAL_FAILURE = {"failed_indexing", "out_of_sync_and_failed_indexing"}
62
+ PENDING = {"out_of_sync", "not_found"}
63
+
64
+
65
+ @dataclass
66
+ class PluginOutcome:
67
+ url: str
68
+ plugin: str
69
+ status: str
70
+ exception: str = ""
71
+
72
+
73
+ @dataclass
74
+ class Tally:
75
+ ok: int = 0
76
+ failed: int = 0
77
+ pending: int = 0
78
+ failures: List[PluginOutcome] = field(default_factory=list)
79
+
80
+
81
+ async def fetch_status(
82
+ client: httpx.AsyncClient, base_url: str, url: str
83
+ ) -> Optional[Dict]:
84
+ try:
85
+ response = await client.get(
86
+ f"{base_url}/info/indexing/asset/status", params={"url": url}
87
+ )
88
+ except (httpx.TransportError, httpx.TimeoutException):
89
+ return None
90
+ if response.status_code >= 400:
91
+ return None
92
+ return response.json()
93
+
94
+
95
+ def classify(doc: Dict, plugins: Tuple[str, ...]) -> Dict[str, Tuple[str, str]]:
96
+ """-> {plugin: (verdict, detail)} where verdict is ok|failed|pending."""
97
+ out: Dict[str, Tuple[str, str]] = {}
98
+ for plugin, info in (doc.get("plugins_statuses") or {}).items():
99
+ if plugin not in plugins:
100
+ continue
101
+ state = info.get("indexing_status", "not_found")
102
+ detail = ""
103
+ history = info.get("plugin_status_history") or []
104
+ if history:
105
+ last = history[-1]
106
+ detail = str(last.get("exception") or last.get("status") or "")
107
+ if state in DONE:
108
+ out[plugin] = ("ok", detail)
109
+ elif state in TERMINAL_FAILURE:
110
+ out[plugin] = ("failed", detail or state)
111
+ else:
112
+ out[plugin] = ("pending", state)
113
+ # A plugin absent from the response has never been seen for this asset.
114
+ for plugin in plugins:
115
+ out.setdefault(plugin, ("pending", "absent"))
116
+ return out
117
+
118
+
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)}"
125
+ plugins = active_plugins(cfg)
126
+
127
+ tallies: Dict[str, Tally] = {p: Tally() for p in plugins}
128
+ settled: Dict[Tuple[str, str], Tuple[str, str]] = {}
129
+ outstanding: Set[str] = set(urls)
130
+ deadline = time.monotonic() + timeout
131
+ semaphore = asyncio.Semaphore(concurrency)
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:
138
+ print(f"\nTIMEOUT after {timeout:.0f}s with {len(outstanding)} assets outstanding.")
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)
145
+
146
+ batch = await asyncio.gather(*(check(u) for u in sorted(outstanding)))
147
+
148
+ for url, doc in batch:
149
+ if doc is None:
150
+ continue
151
+ verdicts = classify(doc, plugins)
152
+ if all(v[0] != "pending" for v in verdicts.values()):
153
+ outstanding.discard(url)
154
+ for plugin, (verdict, detail) in verdicts.items():
155
+ settled[(url, plugin)] = (verdict, detail)
156
+
157
+ depths = await queue_depths(redis_client, plugins)
158
+ queued = sum(v for k, v in depths.items() if k != "results")
159
+ print(
160
+ f" outstanding={len(outstanding)} "
161
+ f"queued={queued} results={depths['results']}",
162
+ flush=True,
163
+ )
164
+
165
+ if not outstanding:
166
+ break
167
+
168
+ # Everything drained but assets are still not terminal: the jobs
169
+ # were never picked up. The usual cause is a worker whose
170
+ # JOB_ITEM_TYPE omits "priority", which discards every job this
171
+ # pipeline creates.
172
+ if queued == 0 and depths["results"] == 0:
173
+ await asyncio.sleep(interval)
174
+ depths = await queue_depths(redis_client, plugins)
175
+ if sum(depths.values()) == 0:
176
+ print(
177
+ f"\nQueues are empty but {len(outstanding)} assets never reached a "
178
+ f"terminal state.\nCheck that both workers set "
179
+ f"DEEPSEARCH_MONITOR_WORKER_CONFIG_JOB_ITEM_TYPE to "
180
+ f'\'["priority","normal","none"]\' β€” the default drops '
181
+ f"priority jobs silently."
182
+ )
183
+ dump_worker_logs(cfg)
184
+ break
185
+ await asyncio.sleep(interval)
186
+
187
+ await redis_client.aclose()
188
+
189
+ for (url, plugin), (verdict, detail) in settled.items():
190
+ tally = tallies[plugin]
191
+ if verdict == "ok":
192
+ tally.ok += 1
193
+ else:
194
+ tally.failed += 1
195
+ tally.failures.append(PluginOutcome(url, plugin, verdict, detail))
196
+ for url in outstanding:
197
+ for plugin in plugins:
198
+ tallies[plugin].pending += 1
199
+
200
+ return tallies, sorted(outstanding)
201
+
202
+
203
+ # ── OpenSearch / Neo4j measurements ──────────────────────────────────────────
204
+
205
+
206
+ def refresh_index(cfg: Config, index: str) -> None:
207
+ """Force docs into a searchable state before counting.
208
+
209
+ Indexes are created with a long `refresh_interval` (Β§2.8 batch tuning), so
210
+ `_count` can lag a synchronous write by up to that interval and report a
211
+ false submission gap β€” the same class of bug Β§8.2 already found at bake
212
+ time ("`_refresh`, not just `_flush`").
213
+ """
214
+ try:
215
+ httpx.post(f"{cfg.opensearch_url}/{index}/_refresh", timeout=30.0)
216
+ except httpx.HTTPError:
217
+ pass # os_count will report a possibly-stale count; not fatal here
218
+
219
+
220
+ def os_count(cfg: Config, index: str) -> int:
221
+ try:
222
+ response = httpx.get(f"{cfg.opensearch_url}/{index}/_count", timeout=30.0)
223
+ if response.status_code >= 400:
224
+ return 0
225
+ return int(response.json().get("count", 0))
226
+ except httpx.HTTPError:
227
+ return 0
228
+
229
+
230
+ def embedding_coverage(cfg: Config) -> Tuple[int, float]:
231
+ """Documents carrying a siglip2 embedding, as a fraction of the index.
232
+
233
+ Every corpus asset is assumed to already have a thumbnail (see the plan,
234
+ Β§5.4), so this should be 1.0. It is measured rather than trusted because a
235
+ missing thumbnail produces a searchable-by-text but vector-less document β€”
236
+ a silent recall hole.
237
+ """
238
+ total = os_count(cfg, cfg.main_index)
239
+ if total == 0:
240
+ return 0, 0.0
241
+ query = {"query": {"nested": {
242
+ "path": "siglip2-embedding",
243
+ "query": {"exists": {"field": "siglip2-embedding.embedding"}},
244
+ }}}
245
+ try:
246
+ response = httpx.post(
247
+ f"{cfg.opensearch_url}/{cfg.main_index}/_count",
248
+ json=query, timeout=60.0,
249
+ )
250
+ if response.status_code >= 400:
251
+ return 0, 0.0
252
+ with_embedding = int(response.json().get("count", 0))
253
+ except httpx.HTTPError:
254
+ return 0, 0.0
255
+ return with_embedding, with_embedding / total
256
+
257
+
258
+ def neo4j_counts(cfg: Config) -> Tuple[int, int]:
259
+ try:
260
+ from neo4j import GraphDatabase
261
+ except ImportError:
262
+ return -1, -1
263
+ try:
264
+ # Neo4j runs with auth disabled in this stack, so no credentials.
265
+ driver = GraphDatabase.driver(cfg.neo4j_bolt_url, auth=None)
266
+ with driver.session() as session:
267
+ nodes = session.run("MATCH (n) RETURN count(n) AS c").single()["c"]
268
+ rels = session.run("MATCH ()-[r]->() RETURN count(r) AS c").single()["c"]
269
+ driver.close()
270
+ return int(nodes), int(rels)
271
+ except Exception as exc: # noqa: BLE001 - report, never abort the stage
272
+ print(f" warning: could not read Neo4j counts ({type(exc).__name__}: {exc})")
273
+ return -1, -1
274
+
275
+
276
+ def run(cfg: Config) -> int:
277
+ from .restore import expected_total as compute_expected_total
278
+
279
+ manifest = load_manifest()
280
+ assets = load_assets()
281
+ expected = len(assets) # submitted this run β€” scopes Phase B / plugin ratios
282
+ urls = [a.url for a in assets]
283
+
284
+ # Whole-index target: baseline (restored, minus this run's `removed`) plus
285
+ # whatever this run submits. Equals `expected` when there is no baseline
286
+ # (first run for a corpus, or `restore` never ran).
287
+ removed = set(manifest.get("removed") or [])
288
+ total_expected = compute_expected_total(removed, {a.url for a in assets})
289
+
290
+ print(f"expected assets this run: {expected}")
291
+ print(f"expected index total: {total_expected}\n")
292
+
293
+ print("Phase A β€” base documents (synchronous write, no polling needed)")
294
+ refresh_index(cfg, cfg.main_index)
295
+ base_docs = os_count(cfg, cfg.main_index)
296
+ print(f" {cfg.main_index}: {base_docs} docs (expected {total_expected})")
297
+
298
+ print("\nPhase B β€” plugin completion")
299
+ tallies, outstanding = asyncio.run(poll_until_done(cfg, urls))
300
+
301
+ print("\nMeasuring index ...")
302
+ refresh_index(cfg, cfg.main_index)
303
+ refresh_index(cfg, cfg.image_cache_index)
304
+ image_docs = os_count(cfg, cfg.image_cache_index)
305
+ embedded, coverage = embedding_coverage(cfg)
306
+ nodes, rels = neo4j_counts(cfg)
307
+ base_docs = os_count(cfg, cfg.main_index)
308
+
309
+ report = {
310
+ "run_id": manifest["run_id"],
311
+ "asset_count_expected": expected,
312
+ "index_total_expected": total_expected,
313
+ "base_docs_indexed": base_docs,
314
+ "image_cache_docs": image_docs,
315
+ "embedding_coverage": round(coverage, 4),
316
+ "docs_with_embedding": embedded,
317
+ "neo4j_node_count": nodes,
318
+ "neo4j_relationship_count": rels,
319
+ "per_plugin": {
320
+ p: {"ok": t.ok, "failed": t.failed, "pending": t.pending}
321
+ for p, t in tallies.items()
322
+ },
323
+ "failures": [
324
+ {"url": f.url, "plugin": f.plugin, "status": f.status, "exception": f.exception}
325
+ for t in tallies.values() for f in t.failures
326
+ ],
327
+ "never_terminal": outstanding,
328
+ }
329
+ (BUILD_DIR / "index-report.json").write_text(json.dumps(report, indent=2) + "\n")
330
+
331
+ # Carry the counts forward; the smoke test and export's pre-flight check
332
+ # both assert against these, and they need to reflect the whole merged
333
+ # index (baseline + delta), not just what this run submitted.
334
+ manifest.update({
335
+ "asset_count": total_expected,
336
+ "base_docs_indexed": base_docs,
337
+ "image_cache_docs": image_docs,
338
+ "neo4j_node_count": nodes,
339
+ "neo4j_relationship_count": rels,
340
+ })
341
+ from .enumerate_assets import save_manifest
342
+ save_manifest(manifest)
343
+
344
+ # ── acceptance ────────────────────────────────────────────────────────
345
+ print("\n── Report ──")
346
+ print(f" base docs: {base_docs} / {total_expected}")
347
+ print(f" image cache docs: {image_docs}")
348
+ print(f" embedding coverage: {coverage:.2%} ({embedded}/{base_docs})")
349
+ print(f" neo4j nodes/rels: {nodes} / {rels}")
350
+ for plugin, tally in tallies.items():
351
+ print(f" {plugin}: ok={tally.ok} failed={tally.failed} pending={tally.pending}")
352
+
353
+ failed = False
354
+ if base_docs != total_expected:
355
+ print(
356
+ f"\nFAIL: base document count {base_docs} != expected {total_expected} "
357
+ f"(baseline + this run's delta, minus removed). The metadata write is "
358
+ f"synchronous, so this is a submission gap, not a timing one β€” see "
359
+ f".build/submit-failures.json."
360
+ )
361
+ failed = True
362
+
363
+ plugin_min = cfg.get_float("PLUGIN_OK_MIN", 0.95)
364
+ for plugin, tally in tallies.items():
365
+ ratio = tally.ok / expected if expected else 0.0
366
+ if ratio < plugin_min:
367
+ print(f"\nFAIL: {plugin} completed {ratio:.2%} of assets, below PLUGIN_OK_MIN {plugin_min:.2%}.")
368
+ failed = True
369
+
370
+ coverage_min = cfg.get_float("EMBEDDING_COVERAGE_MIN", 0.99)
371
+ if coverage < coverage_min:
372
+ print(
373
+ f"\nFAIL: embedding coverage {coverage:.2%} is below EMBEDDING_COVERAGE_MIN "
374
+ f"{coverage_min:.2%}.\nThe corpus is assumed to be fully thumbnailed; a "
375
+ f"systematic gap here means it is not, and the fix is to enable the "
376
+ f"thumbnail_generation plugin."
377
+ )
378
+ failed = True
379
+
380
+ print(f"\nwrote .build/index-report.json")
381
+ return 1 if failed else 0