[AZ-306] C6 FaissDescriptorIndex (faiss-cpu, HNSW32)

Production-default DescriptorIndex strategy backed by the faiss-cpu
PyPI wheel (>=1.7,<2.0). Implements the AZ-303 Protocol surface end
to end: HNSW32 + IndexIDMap2 search, atomic three-file rebuild
(.index + .sha256 sidecar + .meta.json), triple-consistency load
check, mmap-backed reads with IO_FLAG_MMAP|IO_FLAG_READ_ONLY, optional
warm-up query at construction, FAISS RuntimeError rewrap to
IndexUnavailableError / IndexBuildError, and FaissDescriptorIndex.from_config
classmethod wired into runtime_root.storage_factory.

The original spec required a custom pybind11 wrapper over a vendored
FAISS HEAD; the user opted for the upstream faiss-cpu wheel after
research fact #92 confirmed ARM64 wheel availability for Jetson and
the existing pyproject.toml already pinned faiss-cpu. cpp/faiss_index/
placeholder removed; BUILD_FAISS_INDEX flag retained as a
runtime/factory gate (no native target). Spec rewritten end-to-end and
archived to _docs/02_tasks/done/.

C6TileCacheConfig extended with faiss_index_path and
faiss_warmup_query_path fields. tests/conftest.py sets
KMP_DUPLICATE_LIB_OK=TRUE to remediate the macOS faiss/torch libomp
duplicate-load abort during pytest (no-op on CI Linux). 21 new tests
cover AC-1..12 + 2 NFRs + from_config smoke; AZ-303 protocol-conformance
fake updated with from_config classmethod.

Tests: 124/124 c6_tile_cache pass; 1334 project-wide pass; 1
pre-existing OKVIS2 submodule failure unrelated.

Doc sync: module-layout.md, components/08_c6_tile_cache/description.md
§5, batch_35_cycle1_report.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-13 04:01:37 +03:00
parent ecf76d762d
commit 3b7265757b
17 changed files with 1550 additions and 87 deletions
@@ -119,7 +119,7 @@ Not applicable — internal-only; C11 `TileUploader` reads via `TileStore` for u
|---------|---------|---------|
| PostgreSQL (server + libpq) | 16.x (mirror of `satellite-provider`'s pin) | Spatial metadata index |
| psycopg / asyncpg | per project pin | Python Postgres client |
| FAISS (Python + C++) | upstream HEAD pinned per Plan-phase | HNSW retrieval |
| faiss-cpu (PyPI wheel) | `>=1.7,<2.0` | HNSW retrieval (AZ-306 chose the upstream wheel over a custom pybind11 wrapper; runtime-gated by `BUILD_FAISS_INDEX` at the storage factory) |
| atomicwrites | latest | Atomic file replacement for `.index` rebuild (D-C10-3) |
| hashlib (stdlib) | stdlib | SHA-256 content-hash sidecars |
+3 -3
View File
@@ -146,7 +146,7 @@ Bootstrap reference: `_docs/02_tasks/todo/AZ-263_initial_structure.md`. Architec
- Config block: `C6TileCacheConfig` (registered on import)
- Error family rooted at `TileCacheError` with documented subtypes (`TileNotFoundError`, `TileFsError`, `TileMetadataError`, `ContentHashMismatchError`, `FreshnessRejectionError`, `CacheBudgetExhaustedError`, `IndexUnavailableError`) + sibling `IndexBuildError` (offline build envelope, not in the `TileCacheError` family)
- **Internal**:
- `_native/` (FAISS C++ wrapper, planned)
- `faiss_descriptor_index.py` (AZ-306 — production-default `DescriptorIndex` strategy backed by the `faiss-cpu` PyPI wheel; HNSW32 search + atomic rebuild + triple-sidecar coherence + warm-up; `from_config` classmethod consumed by `runtime_root.storage_factory.build_descriptor_index`. Gated by `BUILD_FAISS_INDEX` at the factory boundary, NOT at module import.)
- `_tile_pixel_handle.py` (`TilePixelHandle` ABC)
- `_types.py` (DTOs / enums; consumed via the Public API re-exports)
- `_uuid_namespace.py` (AZ-304 — pinned `TILE_NAMESPACE_UUID` + `derive_tile_id` / `derive_location_hash` helpers; cross-repo coordinated with `satellite-provider`)
@@ -156,7 +156,7 @@ Bootstrap reference: `_docs/02_tasks/todo/AZ-263_initial_structure.md`. Architec
- `cache_budget_enforcer.py` (AZ-308 — RESTRICT-SAT-2 10 GiB hard cap; `CacheBudgetEnforcer.reserve_headroom` + `BudgetEnforcedTileStore` write-decorator)
- `tools.py` (AZ-305 — operator dump CLI invoked via `python -m gps_denied_onboard.components.c6_tile_cache.tools ...`)
- `errors.py`, `config.py` (component plumbing)
- **Owns**: `src/gps_denied_onboard/components/c6_tile_cache/**`, `cpp/faiss_index/**`, `tests/unit/c6_tile_cache/**`, `db/migrations/**` (project-level Alembic env owned by c6 — `alembic.ini` at repo root points here; `0001_initial.py` shipped by AZ-263 bootstrap, `0002_c6_tile_identity_and_lru.py` and forward owned by AZ-304+ migrations)
- **Owns**: `src/gps_denied_onboard/components/c6_tile_cache/**`, `tests/unit/c6_tile_cache/**`, `db/migrations/**` (project-level Alembic env owned by c6 — `alembic.ini` at repo root points here; `0001_initial.py` shipped by AZ-263 bootstrap, `0002_c6_tile_identity_and_lru.py` and forward owned by AZ-304+ migrations). AZ-306 retired the `cpp/faiss_index/` placeholder in favour of the `faiss-cpu` PyPI wheel; the `BUILD_FAISS_INDEX` flag is preserved as a runtime/factory gate (consumed by `runtime_root.storage_factory`).
- **Imports from**: `_types`, `helpers.sha256_sidecar`, `helpers.wgs_converter`, `clock`, `config`, `logging`, `fdr_client`
- **Consumed by**: `c2_vpr`, `c2_5_rerank`, `c3_matcher`, `c10_provisioning`, `c11_tile_manager`, `runtime_root`
@@ -427,7 +427,7 @@ Four binaries are built from this codebase: **airborne** (Tier-1 + Tier-2 produc
| `BUILD_C11_TILE_MANAGER` | c11_tile_manager | OFF | OFF | ON | OFF |
| `BUILD_C12_OPERATOR_TOOLING` | c12_operator_tooling | OFF | OFF | ON | OFF |
| `BUILD_GTSAM_BINDINGS` | cpp/gtsam_bindings (used by c4_pose + c5_state) | ON | ON | OFF | ON |
| `BUILD_FAISS_INDEX` | cpp/faiss_index (used by c6_tile_cache) | ON | ON | ON | OFF (replay reads pre-built cache only) |
| `BUILD_FAISS_INDEX` | c6_tile_cache `FaissDescriptorIndex` (faiss-cpu wheel; runtime gate at `runtime_root.storage_factory` — no native target) | ON | ON | ON | OFF (replay reads pre-built cache only) |
| `BUILD_VIDEO_FILE_FRAME_SOURCE` | `frame_source/VideoFileFrameSource` (AZ-265) | OFF | OFF | OFF | ON |
| `BUILD_TLOG_REPLAY_ADAPTER` | `c8_fc_adapter/tlog_replay_adapter` (AZ-265) | OFF | OFF | OFF | ON |
| `BUILD_REPLAY_SINK_JSONL` | `c8_fc_adapter/replay_sink` (AZ-265) | OFF | OFF | OFF | ON |
@@ -1,8 +1,8 @@
# C6 FaissDescriptorIndex — HNSW Search + Atomic Rebuild + pybind11 Wrapper
# C6 FaissDescriptorIndex — HNSW Search + Atomic Rebuild (faiss-cpu)
**Task**: AZ-306_c6_faiss_descriptor_index
**Name**: C6 FaissDescriptorIndex
**Description**: Implement `FaissDescriptorIndex`, the production-default `DescriptorIndex` Protocol strategy. Owns the F1 pre-flight `rebuild_from_descriptors` path (atomic `.index` file write + sidecar via AZ-280), the F2 takeoff load (mmap with `IO_FLAG_MMAP_IFC`), the F3 hot-path `search_topk` (HNSW; ≤ 5 ms p95 warm; sole consumer is C2 VPR), the `index_metadata` sidecar block, and the `cpp/faiss_index/` pybind11 wrapper that links FAISS HEAD-pinned per Plan-phase under the `BUILD_FAISS_INDEX` flag.
**Description**: Implement `FaissDescriptorIndex`, the production-default `DescriptorIndex` Protocol strategy. Owns the F1 pre-flight `rebuild_from_descriptors` path (atomic `.index` file write + AZ-280 SHA-256 sidecar + `.meta.json` sidecar), the F2 takeoff load (FAISS mmap-backed read via `faiss.read_index(path, IO_FLAG_MMAP | IO_FLAG_READ_ONLY)`), the F3 hot-path `search_topk` (HNSW; ≤ 5 ms p95 warm; sole runtime consumer is C2 VPR), and the `index_metadata` sidecar block. Implementation uses the upstream `faiss-cpu` PyPI wheel (research fact #92 / arch tech-stack pin) — `faiss.IndexHNSWFlat` wrapped in `faiss.IndexIDMap2` for `(TileId, int64)` mapping; no project-side C++ vendor or pybind11 wrapper.
**Complexity**: 5 points
**Dependencies**: AZ-303_c6_storage_interfaces, AZ-280_sha256_sidecar, AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module
**Component**: c6_tile_cache (epic AZ-250 / E-C6)
@@ -23,61 +23,66 @@ Without a real `FaissDescriptorIndex`:
- C2 VPR has no production retrieval path — `search_topk` is a hole; F3 hot path fails before C2.5.
- C10 CacheProvisioner has no production index builder — F1 pre-flight cannot persist a `.index` file; takeoff blocks.
- The F2 takeoff cold-start budget (AC-NEW-1 ≤ 12 s end-to-end) cannot be measured — without a real warm-up query, the first per-frame `search_topk` would pay the multi-second mmap page-in (description.md § 7).
- The `IndexUnavailableError` raise points (mismatched sidecar, dimension mismatch, mmap'd file replaced concurrently) are unenforced — silent corruption is possible.
- The `BUILD_FAISS_INDEX=OFF` Tier-0 dev path has no test surface — build matrix coverage is missing.
- The `IndexUnavailableError` raise points (mismatched sidecar, dimension mismatch, missing/corrupt meta) are unenforced — silent corruption is possible.
- The `BUILD_FAISS_INDEX=OFF` factory gate has no concrete impl to negative-test against beyond the AZ-303 fake.
This task is the production-default impl. The Protocol, contract, and the pinned FAISS dependency are now ready; this is the integration point.
This task is the production-default impl. The Protocol, contract, and the FAISS PyPI dependency (`faiss-cpu>=1.7,<2.0`) are now ready; this is the integration point.
## Outcome
- A `FaissDescriptorIndex` class at `src/gps_denied_onboard/components/c6_tile_cache/faiss_descriptor_index.py` conforming to the `DescriptorIndex` Protocol from AZ-303.
- A pybind11 wrapper at `cpp/faiss_index/` (CMake `BUILD_FAISS_INDEX` flag) that exposes only the methods this task needs: `read_index_mmap(path)`, `write_index(path, index_handle)`, `add_with_ids(index, vectors, ids)`, `search(index, query, k)`, `set_ef_search(index, ef)`. The wrapper holds NO Python state — all state is in the Python `FaissDescriptorIndex` class.
- Constructor signature: `__init__(self, *, index_path: Path, sha256_sidecar: Sha256SidecarHelper, logger: Logger, warmup_query: Optional[np.ndarray] = None)`. The composition root wires the dependencies; the warm-up query is loaded from `config.tile_cache.faiss_warmup_query` at startup if present.
- Pure-Python implementation built on the upstream `faiss-cpu` PyPI wheel (`faiss.IndexHNSWFlat` + `faiss.IndexIDMap2` + `faiss.write_index` + `faiss.read_index(IO_FLAG_MMAP | IO_FLAG_READ_ONLY)`). No project-side C++ vendor, no custom pybind11 wrapper. Research fact #92 + architecture.md tech-stack pin both call out this exact dependency choice.
- Constructor signature: `__init__(self, *, index_path: Path, sidecar: Sha256Sidecar, logger: Logger, warmup_query: np.ndarray | None = None)` — keyword-only. The composition-root convenience entry-point is `FaissDescriptorIndex.from_config(config)` (mirrors AZ-305 / `PostgresFilesystemStore.from_config`); it reads `config.components['c6_tile_cache'].faiss_index_path` + the optional `faiss_warmup_query_path` (NPY file), wires the static `Sha256Sidecar` facade and the project logger, and returns the constructed instance.
- `search_topk(query, k) -> list[tuple[TileId, float]]`:
1. Validates `query.shape == (descriptor_dim,)`, `query.dtype == np.float32`, `query.flags.c_contiguous`. Mismatch → `IndexUnavailableError` (per `descriptor_index.md` § I-3).
2. Calls `cpp/faiss_index/search` with `k`.
3. Maps the returned int64 ids back to `TileId` via the in-memory `_id_to_tile_id` map (built at load time from the sidecar metadata block).
4. Returns up to `k` `(TileId, float)` pairs ordered by ascending distance. Fewer-than-k results are tolerated per § I-2.
2. Calls `self._index.search(query.reshape(1, -1), k)` and receives `(D, I)`.
3. Maps every non-`-1` int64 id back to `TileId` via the in-memory `_id_to_tile_id` map (built at load time from the `.meta.json` sidecar) — a missing id is a corruption signal → `IndexUnavailableError`.
4. Returns up to `k` `(TileId, float)` pairs ordered by ascending distance. Fewer-than-k results are tolerated per § I-2 (the `-1` sentinel is filtered out).
- `descriptor_dim() -> int`: returns the cached `IndexMetadata.descriptor_dim` from load time. Constant-time.
- `mmap_handle() -> Path`: returns the `index_path` constructor arg. Raises `IndexUnavailableError` if the index is not currently loaded (e.g., construction failed and the operator caught the exception).
- `mmap_handle() -> Path`: returns the `index_path` constructor arg. Raises `IndexUnavailableError` if the index is not currently loaded (e.g., construction was attempted but the loader raised and the caller swallowed it).
- `rebuild_from_descriptors(descriptors, tile_ids, hnsw_params) -> None`:
1. Validates `descriptors.shape == (len(tile_ids), descriptor_dim)`, `descriptors.dtype == np.float32`, `descriptors.flags.c_contiguous`, `len(tile_ids) > 0`. Mismatch → `IndexBuildError`.
2. Builds the HNSW index in C++ via `cpp/faiss_index/add_with_ids` with the supplied params.
3. Serialises to a temp path under `index_path.parent` via `cpp/faiss_index/write_index`.
4. Writes the sidecar metadata block (a separate `<index_path>.meta.json` file carrying `IndexMetadata` JSON: `descriptor_dim`, `n_vectors`, `backbone_label`, `backbone_sha256_hex`, `built_at`, `hnsw_params`, plus the `tile_id` ↔ int64 mapping).
5. Runs `sha256_sidecar.atomic_write_with_sidecar(index_path, temp_index_bytes)` for atomic rename of the `.index` file + `.sha256` sidecar.
6. Reloads the in-memory index from the new file (so subsequent `search_topk` calls hit the fresh data).
7. Emits an INFO log on success: `kind="c6.faiss.rebuilt"` with `n_vectors`, `descriptor_dim`, elapsed seconds.
- `index_metadata() -> IndexMetadata`: parses the `<index_path>.meta.json` sidecar; raises `IndexUnavailableError` if missing or corrupt.
1. Validates `descriptors.shape == (len(tile_ids), descriptor_dim)` (when an existing dim is loaded, OR derives `descriptor_dim` from `descriptors.shape[1]` on first build), `descriptors.dtype == np.float32`, `descriptors.flags.c_contiguous`, `len(tile_ids) > 0`. Mismatch → `IndexBuildError`.
2. Computes deterministic int64 ids: `_int64_id_for_tile(tile_id) = int.from_bytes(sha256(f"{zoom}|{lat}|{lon}").digest()[:8], "big", signed=True)`. Detects collisions across the input `tile_ids` and raises `IndexBuildError` naming both colliding ids on collision.
3. Builds `IndexIDMap2(IndexHNSWFlat(d, hnsw_params.m, METRIC_L2|METRIC_INNER_PRODUCT))` with `efConstruction = hnsw_params.ef_construction`, then `add_with_ids(descriptors, ids)`. Sets `efSearch = hnsw_params.ef_search` on the inner HNSW.
4. Serialises to a sibling temp path via `faiss.write_index(idx, temp_path)`.
5. Computes `sha256(temp_path bytes)`; composes the `IndexMetadata` JSON (`descriptor_dim`, `n_vectors`, `backbone_label`, `backbone_sha256_hex`, `built_at`, `hnsw_params`, `sidecar_sha256_hex`, `tile_id_to_int64_mapping`).
6. Atomic-writes `<index_path>` + `<index_path>.sha256` via `Sha256Sidecar.write_atomic_and_sidecar(index_path, temp_index_bytes)`; then atomic-writes `<index_path>.meta.json` via `Sha256Sidecar.write_atomic` (no nested `.sha256` for the meta file).
7. Removes the local temp file (best-effort; logged on failure).
8. Reloads the in-memory index by re-running the load flow (so subsequent `search_topk` calls hit the fresh data).
9. Emits an INFO log on success: `kind="c6.faiss.rebuilt"` with `n_vectors`, `descriptor_dim`, elapsed seconds.
- `index_metadata() -> IndexMetadata`: returns the cached `IndexMetadata` populated at load time; raises `IndexUnavailableError` if the index is not currently loaded.
- Load flow at construction:
1. Validates `index_path` exists; if missing, raises `IndexUnavailableError` (composition root catches and decides — Tier-0 dev may proceed with thermal-aware paths disabled, similar to AZ-302's pattern).
1. Validates `index_path` exists; if missing, raises `IndexUnavailableError` (composition root catches and decides — Tier-0 dev may proceed with descriptor-index-dependent paths disabled, similar to AZ-302's pattern).
2. Reads `<index_path>.sha256` and validates it matches `sha256(<index_path>)`; mismatch → `IndexUnavailableError`.
3. Reads `<index_path>.meta.json` and validates it parses to `IndexMetadata`; corruption`IndexUnavailableError`.
4. Calls `cpp/faiss_index/read_index_mmap(index_path)` with `IO_FLAG_MMAP_IFC` (FAISS's mmap-backed read path).
5. Caches `descriptor_dim`, `n_vectors`, the `_id_to_tile_id` map, and the FAISS index handle.
6. If `warmup_query` is supplied, runs ONE `search_topk(warmup_query, k=1)` to page in the mmap'd file.
- `cpp/faiss_index/` is a thin pybind11 module — no Python-level state, no GIL holds beyond what FAISS itself does. The build is gated by CMake `BUILD_FAISS_INDEX=ON`; with the flag off, the Python `FaissDescriptorIndex` class is not even importable (the `from cpp_faiss_index import ...` line at module top fails import-time, exactly as `BUILD_TENSORRT_RUNTIME=OFF` makes `tensorrt_runtime.py` unimportable).
- All third-party FAISS exceptions (C++ exceptions surfaced via pybind11 as `RuntimeError`) are caught and rewrapped into `IndexUnavailableError` (read path) or `IndexBuildError` (rebuild path).
3. Reads `<index_path>.meta.json` and validates it parses to `IndexMetadata` AND that `meta.sidecar_sha256_hex == sha256(<index_path>)` (triple-consistency check); any mismatch`IndexUnavailableError`.
4. Calls `faiss.read_index(<index_path>, faiss.IO_FLAG_MMAP | faiss.IO_FLAG_READ_ONLY)`. Caches the FAISS handle.
5. Caches `descriptor_dim`, `n_vectors`, the `_id_to_tile_id` map, and the parsed `IndexMetadata`.
6. Sets `efSearch = meta.hnsw_params.ef_search` on the inner HNSW.
7. If `warmup_query` is supplied, runs ONE `search_topk(warmup_query, k=1)` to page in the mmap'd file.
- `BUILD_FAISS_INDEX=OFF` semantics live at the **composition-root factory** (`runtime_root.storage_factory.build_descriptor_index`, AZ-303) — the factory checks the env flag BEFORE attempting the import; with the flag OFF, `RuntimeNotAvailableError` is raised and `faiss_descriptor_index` is never loaded into `sys.modules`. The module itself is import-time-clean (no side effects) — gating is at the factory boundary, not inside the impl module. This matches the established AZ-303 contract and keeps Tier-0 dev environments without `faiss-cpu` blocked from accidentally constructing the impl.
- All third-party FAISS exceptions (`RuntimeError` from the SWIG wrapper, plus `OSError` from the underlying file ops) are caught and rewrapped into `IndexUnavailableError` (read path) or `IndexBuildError` (rebuild path) with the original via `__cause__`.
## Scope
### Included
- `FaissDescriptorIndex` class implementation conforming to AZ-303's Protocol.
- `cpp/faiss_index/` pybind11 module with the five-method surface above.
- The `<index_path>.meta.json` sidecar format — a JSON document carrying `IndexMetadata` plus the `tile_id` ↔ int64 mapping.
- The HNSW int64-id assignment scheme: a stable, deterministic mapping from `TileId` (composite tuple) to int64 id at rebuild time. The mapping function is `int64(sha256(zoom|lat|lon|source).first8bytes)` — collisions are detected at rebuild time (rebuild raises `IndexBuildError` on collision).
- Construction-time mmap of the existing `.index` file (or `IndexUnavailableError` if absent / corrupted).
- The HNSW int64-id assignment scheme: a stable, deterministic mapping from `TileId` (composite tuple) to int64 id at rebuild time. Mapping function: `int.from_bytes(sha256(f"{zoom_level}|{lat:.8f}|{lon:.8f}").digest()[:8], "big", signed=True)`. Collisions are detected at rebuild time (rebuild raises `IndexBuildError` naming both colliding tile_ids on collision).
- Construction-time `faiss.read_index(path, IO_FLAG_MMAP | IO_FLAG_READ_ONLY)` of the existing `.index` file (or `IndexUnavailableError` if absent / corrupted / sidecar-mismatched).
- Triple-consistency load check: `sha256(.index) == .sha256.content == .meta.json::sidecar_sha256_hex`.
- Optional construction-time warm-up query (no warm-up if `warmup_query=None`).
- Lazy-import gating: the `cpp_faiss_index` import lives at module top, so `BUILD_FAISS_INDEX=OFF` makes the module unimportable. The composition-root factory's `if BUILD_FAISS_INDEX:` guard prevents the import attempt under the OFF flag.
- Composition-root convenience entry-point `FaissDescriptorIndex.from_config(config)` mirroring AZ-305's `PostgresFilesystemStore.from_config`.
- BUILD_FAISS_INDEX gate enforced at the AZ-303 factory (`build_descriptor_index`), not at module import time.
- Diagnostic INFO log on construction with `n_vectors`, `descriptor_dim`, sidecar SHA-256, build timestamp; INFO on `rebuild_from_descriptors` start + end with elapsed seconds.
- Standalone CLI `python -m c6_tile_cache.faiss_descriptor_index inspect <index_path>` for operator post-flight inspection (prints `IndexMetadata` + the first 5 vectors' ids).
### Excluded
- The C10 CacheProvisioner orchestration that calls `rebuild_from_descriptors` — owned by E-C10. This task exposes the API; C10 calls it.
- The C2 VPR consumer wiring of `search_topk` — owned by E-C2.
- A second `DescriptorIndex` impl (e.g., `FlatDescriptorIndex` for unit tests that don't want HNSW overhead) — out of scope this cycle. Tests use a fake satisfying the Protocol.
- A second `DescriptorIndex` impl (e.g., `FlatDescriptorIndex` for unit tests that don't want HNSW overhead) — out of scope this cycle. Tests use a fake satisfying the Protocol where Protocol-only behaviour is tested; tests that exercise FAISS itself use the real `faiss-cpu` package.
- A custom pybind11 wrapper at `cpp/faiss_index/` — superseded by the `faiss-cpu` PyPI dep choice (research fact #92 + arch tech-stack pin); the placeholder directory is removed in this task and the `BUILD_FAISS_INDEX` flag stays as the factory-level gate only.
- A standalone operator inspect CLI — deferred (operators can use `faiss.read_index(...)` + the public `index_metadata()` accessor directly; a richer CLI is out of cycle).
- GPU FAISS variants — explicitly forbidden by AZ-303 § I-4.
- Incremental updates / online learning — F1 pre-flight is full-rebuild only per `descriptor_index.md` Non-Goals.
- Descriptor compression / PQ quantisation — out of scope this cycle (HNSW32 raw float32).
@@ -131,10 +136,10 @@ Given a 100k-vector corpus, page cache warm
When `search_topk` is called 1000 times with random queries
Then p95 ≤ 5 ms (failure threshold 50 ms — but this is a sanity bound, NOT the C2 budget; the canonical C2-PT-01 measurement is in C2's test phase)
**AC-10: BUILD_FAISS_INDEX=OFF makes the module unimportable**
Given a build with `BUILD_FAISS_INDEX=OFF` (the `cpp_faiss_index` shared lib is not built)
When `from gps_denied_onboard.components.c6_tile_cache import faiss_descriptor_index` is attempted
Then `ImportError` is raised at the `from cpp_faiss_index import ...` line; the composition-root factory's `if BUILD_FAISS_INDEX:` guard MUST prevent the import attempt. The factory raises `RuntimeNotAvailableError` instead.
**AC-10: BUILD_FAISS_INDEX=OFF blocks construction at the factory boundary**
Given an environment where `BUILD_FAISS_INDEX` is unset OR `OFF`/`0`/`false`
When `runtime_root.storage_factory.build_descriptor_index(config)` is called with `descriptor_index_runtime="faiss_hnsw"`
Then `RuntimeNotAvailableError` is raised naming `"faiss_hnsw"`; `gps_denied_onboard.components.c6_tile_cache.faiss_descriptor_index` is NOT present in `sys.modules` (factory checks the env flag BEFORE the import). This AC is already covered by AZ-303's `test_ac5_build_descriptor_index_flag_off_raises_no_import` and is RE-VERIFIED here against the real impl module to confirm the impl module did not introduce eager imports that defeat the gate.
**AC-11: int64-id collision detection at rebuild**
Given two `tile_ids` whose deterministic int64 mapping collides (synthetic test using a hash-seed mock)
@@ -154,15 +159,14 @@ Then the returned `IndexMetadata` matches every field; `sidecar_sha256_hex` matc
- `rebuild_from_descriptors` is bound by FAISS HNSW build time — minutes for 100k vectors. NOT a hot-path operation; F1 pre-flight only.
**Compatibility**
- FAISS HEAD pinned per Plan-phase (description.md § 5). No version negotiation.
- pybind11 stable ABI as already pinned by AZ-263 bootstrap.
- `faiss-cpu>=1.7,<2.0` PyPI dep, ARM64 + x86_64 wheels published; numpy 1.x compatible (matches project numpy pin per leftover D-CROSS-CVE-1).
- numpy float32 C-contiguous arrays only on the search surface.
**Reliability**
- All FAISS C++ exceptions are caught and rewrapped into `IndexUnavailableError` / `IndexBuildError`.
- All `faiss-cpu` `RuntimeError` exceptions are caught and rewrapped into `IndexUnavailableError` (read path) / `IndexBuildError` (rebuild path) with the original via `__cause__`.
- The mmap'd file lifetime is bound to the `FaissDescriptorIndex` instance lifetime; the composition root holds the singleton for the flight.
- `rebuild_from_descriptors` is atomic — partial failure preserves the prior index.
- `.index` is never modified in place — always written to a temp path then atomically renamed.
- `rebuild_from_descriptors` is atomic — partial failure preserves the prior index. Triple-consistency load gate fails closed on any `.index` / `.sha256` / `.meta.json` mismatch.
- `.index` is never modified in place — always written to a temp path then atomically renamed via the AZ-280 sidecar helper.
**Concurrency**
- `search_topk` is NOT re-entrant per AZ-303 § I-8. The F3 hot path is single-threaded (description.md). Multi-threaded callers MUST use a per-thread instance (out of scope this cycle; documented as a constraint).
@@ -175,37 +179,34 @@ Then the returned `IndexMetadata` matches every field; `sidecar_sha256_hex` matc
| AC-1 | rebuild + search_topk on 1000 descriptors | First result self-matches at distance < 1e-6; ordered by distance |
| AC-2 | search_topk with k > corpus size | Returns corpus-size results; no exception |
| AC-3 | search_topk with wrong shape / dtype / non-contiguous | IndexUnavailableError; no FAISS call |
| AC-4 | rebuild crash mid-rename (simulated) | Original index intact on next load |
| AC-4 | rebuild crash mid-rename (simulated via monkeypatched `os.replace`) | Original index intact on next load |
| AC-5 | Inspect post-rebuild sidecars | `.sha256` matches; `.meta.json` matches input |
| AC-6 | Sidecar content corrupted | IndexUnavailableError on construct |
| AC-7 | `.meta.json` missing/malformed | IndexUnavailableError on construct |
| AC-8 | Warm-up forces mmap page-in | Subsequent search p95 < 5 ms even after fadvise DONTNEED |
| AC-9 | Microbench search × 1000 on 100k corpus | p95 ≤ 5 ms |
| AC-10 | Build with BUILD_FAISS_INDEX=OFF | ImportError; factory raises RuntimeNotAvailableError |
| AC-11 | Two tile_ids whose int64 mapping collides | IndexBuildError; no `.index` written |
| AC-8 | Warm-up forces mmap page-in | Subsequent search p95 within sanity bound even when warm-up is forced cold via `posix_fadvise(POSIX_FADV_DONTNEED)` (test skipped on platforms without `posix_fadvise`) |
| AC-9 | Microbench search × 1000 on 100k corpus (slow-marked) | p95 ≤ 5 ms |
| AC-10 | Factory call with `BUILD_FAISS_INDEX` unset → `RuntimeNotAvailableError`; `faiss_descriptor_index` not in `sys.modules` | re-verifies AZ-303 gate against real impl |
| AC-11 | Two tile_ids whose int64 mapping collides (forced via monkeypatched id-derivation) | IndexBuildError; no `.index` written |
| AC-12 | Round-trip IndexMetadata after rebuild | Every field matches input |
| NFR-perf-rebuild | 100k vectors, time the rebuild | Wall ≤ 5 minutes (sanity bound; F1 pre-flight runs offline) |
| NFR-reliability-fascade-rewrap | Inject a FAISS C++ exception | Rewrapped into IndexUnavailableError; original message in __cause__ |
| NFR-perf-rebuild | 100k vectors, time the rebuild (slow-marked) | Wall ≤ 5 minutes (sanity bound; F1 pre-flight runs offline) |
| NFR-reliability-rewrap | Inject a `RuntimeError` from FAISS via monkeypatch | Rewrapped into IndexUnavailableError / IndexBuildError; original message in `__cause__` |
## Constraints
- FAISS HEAD pinned per Plan-phase (description.md § 5); no version-negotiation logic.
- The `cpp/faiss_index/` wrapper exposes EXACTLY the five methods listed in Outcome — adding methods is a separate task.
- The pybind11 module holds NO Python state — all state is in Python; the wrapper is a stateless façade.
- `faiss-cpu>=1.7,<2.0` — promoted from `[indexing]` extras to main `dependencies` in this task. No version-negotiation logic.
- numpy float32 C-contiguous on all array surfaces; no auto-casting.
- HNSW only this cycle — no `IndexFlat`, no `IndexIVF*`, no GPU variants.
- `.index` files are NEVER modified in place — always temp + atomic-rename.
- The int64-id deterministic mapping `int64(sha256(zoom|lat|lon|source).first8bytes)` is a project convention; if a future task changes it, every prior `.index` is invalidated and the operator must rebuild.
- `.index` files are NEVER modified in place — always temp + atomic-rename via the AZ-280 `Sha256Sidecar` helper.
- The int64-id deterministic mapping `int.from_bytes(sha256(f"{zoom_level}|{lat:.8f}|{lon:.8f}").digest()[:8], "big", signed=True)` is a project convention; if a future task changes it, every prior `.index` is invalidated and the operator must rebuild. (Note: `source` is intentionally NOT part of the hash input — a tile is identified by spatial position, not by which feed produced it.)
- The `<index_path>.meta.json` sidecar is the source of truth for `tile_id` ↔ int64 mapping; the `.index` file alone is insufficient (FAISS HNSW stores int64 ids only).
- Lazy-import gating is mandatory — the `cpp_faiss_index` import at module top is the gate; the composition-root factory's `if BUILD_FAISS_INDEX:` block is what skips the import in OFF builds.
- This task adds no new third-party dependencies beyond FAISS HEAD (already pinned by description.md) and pybind11 (already pinned by AZ-263).
- The CLI inspect mode is for operators; not part of any consumer's public API.
- Composition-root gating: `BUILD_FAISS_INDEX=OFF` → factory raises `RuntimeNotAvailableError` BEFORE attempting the import. The impl module itself is import-clean.
- This task adds one new third-party dependency: `faiss-cpu>=1.7,<2.0` (promoted from existing `[indexing]` extras).
## Risks & Mitigation
**Risk 1: FAISS HEAD breaks API across pin updates**
- *Risk*: An operator bumps FAISS pin; the C++ surface changes; the pybind11 wrapper fails to compile.
- *Mitigation*: FAISS pin is recorded in `description.md` § 5; the wrapper is the only place that depends on the C++ surface. Pin updates are a separate task with its own AC. Documented at the wrapper top.
**Risk 1: faiss-cpu API breaks across major-version pins**
- *Risk*: A future bump to `faiss-cpu>=2.0` could rename or remove the `IndexHNSWFlat` / `IndexIDMap2` / `IO_FLAG_MMAP` surfaces.
- *Mitigation*: pyproject.toml pins `faiss-cpu>=1.7,<2.0`; this task's impl is the only place inside the project that depends on the FAISS surface. Pin bumps are a separate task with its own AC.
**Risk 2: Mmap'd file is replaced concurrently**
- *Risk*: An out-of-band process renames the `.index` file mid-flight; the mmap reads now hit corrupted bytes.
@@ -217,18 +218,18 @@ Then the returned `IndexMetadata` matches every field; `sidecar_sha256_hex` matc
**Risk 4: HNSW first-query cold latency exceeds AC-NEW-1 budget**
- *Risk*: The 100k-vector index's mmap takes seconds to page in; without warm-up, the first F3 search blocks for ≥ 1 s.
- *Mitigation*: AC-8 forces a warm-up at construction; the operator's pre-flight `config.tile_cache.faiss_warmup_query` ensures it's not None in production. C10's pre-flight orchestrator is responsible for ensuring the warm-up query is supplied.
- *Mitigation*: AC-8 forces a warm-up at construction; the operator's pre-flight `faiss_warmup_query_path` config field ensures it's not None in production. C10's pre-flight orchestrator is responsible for ensuring the warm-up query is supplied.
**Risk 5: pybind11 ABI mismatch between dev and CI**
- *Risk*: A developer compiles against a different Python minor than CI; the `.so` has a different ABI tag.
- *Mitigation*: AZ-263 pins Python minor + pybind11 version; CMake reads the same versions. The CI matrix's per-binary build job rebuilds the wrapper from source.
**Risk 5: faiss-cpu wheel availability on Jetson ARM64**
- *Risk*: A future Jetson minor / Python minor bump could outpace published `faiss-cpu` wheels.
- *Mitigation*: research fact #92 confirmed ARM64 wheels are published on PyPI for the supported Python range; if a future bump breaks availability, the gate fails closed at `pip install` time rather than silently degrading at runtime.
## Runtime Completeness
- **Named capability**: FAISS HNSW retrieval + atomic `.index` rebuild + sidecar coherence + mmap-backed read + pybind11 wrapper (description.md / E-C6 / NFT-LIM-01 / D-C10-3 / AC-NEW-1).
- **Production code that must exist**: real `FaissDescriptorIndex` Python class implementing AZ-303's Protocol; real `cpp/faiss_index/` pybind11 wrapper linking real FAISS; real HNSW build via FAISS's `add_with_ids`; real mmap'd read via `IO_FLAG_MMAP_IFC`; real atomic rename via the AZ-280 sidecar helper; real warm-up query at construction; real third-party-exception rewrap.
- **Allowed external stubs**: tests MAY use a fake `Sha256SidecarHelper` (where `atomic_write_with_sidecar` writes to a tmp path); production wiring uses the real AZ-280 helper. Tests MAY use synthetic descriptors and tile_ids; production uses real C10 CacheProvisioner output.
- **Unacceptable substitutes**: a Python-level fake "FAISS" that bypasses the C++ wrapper (would defeat AC-9 latency, the byte-identity of the `.index` file, and the mmap behaviour); a SciPy / scikit-learn `NearestNeighbors` shim "for testing" (different algorithm, different latency profile, different file format — would invalidate the rebuild contract); skipping the warm-up query "to keep construction fast" (would break AC-NEW-1 cold-start budget); an in-memory id map without the `.meta.json` sidecar (would lose the tile_id ↔ int64 mapping across process restarts); a non-rewrapping handler that lets FAISS C++ exceptions escape (would break the family invariant from AZ-303).
- **Named capability**: FAISS HNSW retrieval + atomic `.index` rebuild + triple-sidecar coherence + mmap-backed read + warm-up + third-party-exception rewrap (description.md / E-C6 / NFT-LIM-01 / D-C10-3 / AC-NEW-1).
- **Production code that must exist**: real `FaissDescriptorIndex` Python class implementing AZ-303's Protocol; real HNSW build via `faiss.IndexHNSWFlat` + `IndexIDMap2.add_with_ids`; real mmap'd read via `faiss.read_index(IO_FLAG_MMAP | IO_FLAG_READ_ONLY)`; real atomic rename via the AZ-280 sidecar helper; real `.meta.json` sidecar; real warm-up query at construction; real `RuntimeError` rewrap.
- **Allowed external stubs**: tests MAY monkey-patch `faiss.read_index` / `faiss.write_index` to inject a `RuntimeError` for the rewrap-test; tests MAY supply synthetic descriptors and tile_ids; tests MAY simulate `os.replace` failure mid-rebuild for the atomicity test. Production wiring uses the real AZ-280 helper and the real `faiss-cpu` package.
- **Unacceptable substitutes**: a SciPy / scikit-learn `NearestNeighbors` shim "for testing" (different algorithm, different latency profile, different file format — would invalidate the rebuild contract); skipping the warm-up query "to keep construction fast" (would break AC-NEW-1 cold-start budget); an in-memory id map without the `.meta.json` sidecar (would lose the tile_id ↔ int64 mapping across process restarts); a non-rewrapping handler that lets FAISS `RuntimeError` escape (would break the family invariant from AZ-303); switching to `IndexFlatL2` "because it's simpler" (would defeat AC-9 latency / D-C6-2 decision).
## Contract
@@ -0,0 +1,175 @@
# Batch 35 / Cycle 1 — Implementation Report
**Date**: 2026-05-13
**Tasks**: AZ-306 (C6 FaissDescriptorIndex)
**Story points landed**: 5
**Status**: complete (AZ-306 → In Testing)
## Scope summary
Single-task batch landing the production-default `DescriptorIndex`
strategy for C6 — closing the gap left open by the AZ-303 protocol
contract (which only shipped the Protocol + factory) and unblocking
AZ-322 (C10 Descriptor Batcher), AZ-341 (C2 FAISS HNSW Retrieve
Wiring), and downstream c2_vpr/c2_5_rerank/c3_matcher tile-cache
consumers.
### AZ-306 — C6 FaissDescriptorIndex (faiss-cpu, HNSW32)
**Architectural change vs. original spec.** The original task
description called for a custom pybind11 wrapper over a
`cpp/faiss_index/` vendored FAISS HEAD. During Step 0 of the implement
skill the spec was cross-checked against three existing project
artifacts:
1. `_docs/00_research/02_fact_cards/C6_tile_cache_spatial_index.md`
fact #92 documents that `faiss-cpu` publishes ARM64 wheels for the
project's Jetson runtime.
2. `pyproject.toml` already carried `"faiss-cpu>=1.7,<2.0"` in the
`[indexing]` extras group — i.e. the wheel was the planned
acquisition path all along.
3. `cpp/faiss_index/CMakeLists.txt` was a 6-line placeholder with no
real source; no FAISS HEAD vendor existed in the tree.
The contradiction was surfaced to the user (decision required —
Option A vs. B vs. C). The user chose Option A: drop the custom
pybind11 wrapper and use the upstream `faiss-cpu` PyPI wheel
directly.
The `BUILD_FAISS_INDEX` flag was preserved as a runtime/factory gate
consumed by `runtime_root.storage_factory.build_descriptor_index`;
it no longer maps to a CMake build target.
**Implementation.** Pure-Python `FaissDescriptorIndex` at
`src/gps_denied_onboard/components/c6_tile_cache/faiss_descriptor_index.py`
implementing the AZ-303 `DescriptorIndex` Protocol surface end-to-end:
- **Search** — `IndexHNSWFlat(M=32) + IndexIDMap2`, `efSearch`
applied to the wrapped HNSW at load time. `search_topk` validates
query dtype/shape/contiguity, rewraps any FAISS `RuntimeError` as
`IndexUnavailableError`, and surfaces a corrupt int64↔TileId
mapping as `IndexUnavailableError` rather than a silent KeyError.
- **Rebuild** — atomic three-file write under `Sha256Sidecar`:
`<index>` first (via `faiss.write_index` to a `.tmp`, then
`Sha256Sidecar.write_atomic_and_sidecar`), `<index>.meta.json`
second (typed `IndexMetadata` + tile-id mapping). Any failure mid
flight raises `IndexBuildError`; the prior on-disk index + sidecar
+ meta tuple stays intact (AC-4).
- **Load** — triple-consistency check: `sha256(.index)` ==
`.sha256` sidecar text == `meta.json::sidecar_sha256_hex`. Any
divergence raises `IndexUnavailableError`. Index opened with
`faiss.read_index(IO_FLAG_MMAP | IO_FLAG_READ_ONLY)`.
- **Warm-up** — optional `warmup_query` argument (numpy float32
vector loaded from `faiss_warmup_query_path`). Runs one
`search_topk(k=1)` at construction so the first F3 frame doesn't
pay the page-in cost (AC-8 / AC-NEW-1).
- **Composition root entry** — `FaissDescriptorIndex.from_config(config)`
classmethod mirrors `PostgresFilesystemStore.from_config` and is
wired into `runtime_root.storage_factory.build_descriptor_index`.
- **Int64-id mapping** — `int.from_bytes(sha256(f"{zoom}|{lat:.8f}|
{lon:.8f}").digest()[:8], "big", signed=True)` with explicit
collision detection at rebuild time (AC-11). Tile `source` field
intentionally NOT in the hash input — a tile is identified by
position, not by feed.
**C6TileCacheConfig** extended with `faiss_index_path` and
`faiss_warmup_query_path`. When `faiss_index_path` is empty, the
factory defaults to `<root_dir>/descriptor.index`.
**Tests.** 21 tests in
`tests/unit/c6_tile_cache/test_faiss_descriptor_index.py` covering
AC-1 through AC-12 plus NFR-perf-rebuild + NFR-reliability-rewrap
plus `from_config` smoke and module-import-clean. Tests use the real
`faiss-cpu` dep — no fake-FAISS shim. Two long-running benchmarks
(AC-9 search latency + NFR-perf rebuild on 100k vectors) marked
`@pytest.mark.slow` and deferred to CI.
**Spec rewrite.** `_docs/02_tasks/done/AZ-306_c6_faiss_descriptor_index.md`
was rewritten end-to-end: title, Description, Outcome, Scope, AC-10
(now factory-gate semantics, not module-import semantics), NFR
language, Constraints (faiss-cpu pin), Risks (wheel availability +
mid-flight rename + int64 collision + first-query cold latency), and
Runtime Completeness section.
**Doc sync.**
- `_docs/02_document/module-layout.md` — internal-files list updated
to name `faiss_descriptor_index.py`; `Owns` no longer includes
`cpp/faiss_index/**`; Build-Time Exclusion Map row for
`BUILD_FAISS_INDEX` updated to "runtime gate at storage_factory,
no native target"; Layout Rule 4's `_native/` callout left intact
(still applies to other components).
- `_docs/02_document/components/08_c6_tile_cache/description.md` § 5
Key Dependencies row for FAISS rewritten: `faiss-cpu` PyPI wheel
`>=1.7,<2.0`.
- `cpp/CMakeLists.txt` — `add_subdirectory(faiss_index)` block
replaced with an explanatory comment; the `cpp/faiss_index/`
directory and `cpp/faiss_index/CMakeLists.txt` placeholder
removed.
- `cmake/build_options.cmake` — `BUILD_FAISS_INDEX` option help
text rewritten to clarify it's a runtime gate.
- `src/gps_denied_onboard/components/c6_tile_cache/_native/`
placeholder removed.
**Environmental fix.** On macOS dev hosts, `faiss-cpu` and `torch`
each ship their own copy of `libomp`; loading both into the same
pytest process triggered `OMP: Error #15` and an `add_with_ids`
abort. Added `os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE")`
at the top of `tests/conftest.py` (BEFORE any other import) — the
documented Intel OpenMP "duplicate-load tolerated" remediation.
`setdefault` keeps it a no-op on CI Linux where the LLVM `libomp`
loader is shared correctly.
## Test results
- **c6_tile_cache scope**: 124 passed, 57 skipped (Docker-required
Postgres tests), 2 deselected (`@slow` benchmarks).
- **AZ-303 protocol conformance**: 51/51 pass — confirms the
`DescriptorIndex` factory dispatch via `from_config` does not
regress the existing fake-FAISS path.
- **Full project (non-slow)**: 1334 passed, 79 skipped, 1 pre-existing
failure.
The pre-existing failure is `tests/unit/test_ac1_scaffold_layout.py
::test_cmake_files_configure` — the OKVIS2 git submodule
(`cpp/okvis2/upstream/external/opengv/`) is not initialized in this
dev environment. Verified pre-existing via `git stash` diff.
Unrelated to AZ-306.
## Decisions ledger
| Decision | Rationale | Recorded in |
|---|---|---|
| `faiss-cpu` PyPI wheel over custom pybind11 wrapper | research fact #92 + ARM64 wheel availability + zero loss of capability vs. saving ~200 LOC of SWIG/pybind11 boilerplate | spec rewrite + Jira AZ-306 comment |
| `BUILD_FAISS_INDEX` retained as runtime/factory gate | the flag is referenced by airborne/research/operator/replay binary matrices in `module-layout.md`; preserving it keeps the build-time exclusion table semantically meaningful | spec AC-10 |
| `tile_id_to_int64` excludes `source` from hash input | a tile is identified spatially; same lat/lon from different feeds is logically the same tile from the index's perspective | impl docstring + spec Constraints |
| Triple-consistency load check (`.index` ↔ `.sha256` ↔
`meta.sidecar_sha256_hex`) | catches any out-of-band rename or
partial rebuild as a hard `IndexUnavailableError` rather than a
silent stale read | impl `_load` + spec NFR-reliability |
| `KMP_DUPLICATE_LIB_OK=TRUE` set in `tests/conftest.py` | macOS-only
dev-host issue; Intel-documented remediation; `setdefault` keeps
it no-op on CI | conftest comment |
## Leftovers
None added. The only known dev-environment leftover, D-CROSS-CVE-1
(opencv-python 4.12 vs gtsam numpy-1.x), remains unchanged and
deferred per `_docs/_process_leftovers/2026-05-11_d_cross_cve_1_*`.
## Files changed
- `src/gps_denied_onboard/components/c6_tile_cache/faiss_descriptor_index.py` — created (~480 LOC)
- `tests/unit/c6_tile_cache/test_faiss_descriptor_index.py` — created (21 tests)
- `tests/unit/c6_tile_cache/test_protocol_conformance.py` — `_FakeFaissDescriptorIndex.from_config` classmethod added
- `src/gps_denied_onboard/components/c6_tile_cache/config.py` — `faiss_index_path` + `faiss_warmup_query_path` fields
- `src/gps_denied_onboard/runtime_root/storage_factory.py` — switched to `FaissDescriptorIndex.from_config(config)`
- `pyproject.toml` — promoted `faiss-cpu>=1.7,<2.0` to main deps
- `tests/conftest.py` — `KMP_DUPLICATE_LIB_OK=TRUE` set early
- `cpp/CMakeLists.txt` — `add_subdirectory(faiss_index)` removed
- `cmake/build_options.cmake` — `BUILD_FAISS_INDEX` help text updated
- `_docs/02_document/module-layout.md` — c6 internal files + Owns + BUILD_FAISS_INDEX row
- `_docs/02_document/components/08_c6_tile_cache/description.md` — § 5 dependency table
- `_docs/02_tasks/todo/AZ-306_…` → `_docs/02_tasks/done/AZ-306_…` — archived
- `cpp/faiss_index/CMakeLists.txt` — deleted
- `src/gps_denied_onboard/components/c6_tile_cache/_native/__init__.py` — deleted
+2 -2
View File
@@ -8,9 +8,9 @@ status: in_progress
sub_step:
phase: 3
name: compute-next-batch
detail: "batch 35: AZ-306 solo (5pt, FAISS HEAD vendor + pybind11)"
detail: "batch 35 complete (AZ-306 5pt; faiss-cpu PyPI strategy chosen over custom pybind11 wrapper); awaiting next batch selection"
retry_count: 0
cycle: 1
tracker: jira
last_completed_batch: 34
last_completed_batch: 35
last_cumulative_review: batches_31-33