# Batch 36 — Cycle 1 Report

**Date**: 2026-05-13
**Batch**: 36 (single task — direct AZ-306 follow-up)
**Tasks**: AZ-322 (C10 Descriptor Batcher, 3pt)
**Status**: complete; AZ-322 transitioned to "In Testing" pending operator review.

## Scope

AZ-322 implements `DescriptorBatcher` — the C10 phase that walks every C6 tile in the requested
`(bbox, zoom_levels, sector_class)`, embeds it through C2's VPR backbone (via the C7 engine produced
by AZ-321), and rebuilds the AZ-306 FAISS HNSW index in one atomic write.

This unblocks the airborne C2 VPR step's takeoff verify (AC-NEW-1) and makes the C10-PT-01
cold-build budget observable end-to-end.

## Architectural Decisions

### 1. Consumer-side Protocol cuts (AZ-270 / AZ-507 compliance)

The AZ-322 task spec listed direct C6 types (`TileMetadataStore`, `TileStore`, `DescriptorIndex`)
in the `DescriptorBatcher.__init__` signature. That contradicts AZ-270 (no cross-component
imports inside `components/*`) and the AZ-507 cross-component contract surface rule. The
established precedent — AZ-323's `ManifestBuilder` and AZ-324's `ManifestVerifierImpl` — declares
**consumer-side structural Protocol cuts** locally inside the C10 module and lets the composition
root (`runtime_root.c10_factory`) wire C6's concrete strategies in via thin adapters.

This batch follows that precedent. `descriptor_batcher.py` declares four
local-to-C10 Protocols:

- `BackboneEmbedder` (lifted to `interface.py` for re-use by future tasks)
- `TilesByBboxBatchQuery` — narrower than C6's `TileMetadataStore.query_by_bbox`, accepts
  `tuple[int, ...]` of zooms instead of a single zoom
- `TilePixelOpener` — narrower than C6's `TileStore.read_tile_pixels(TileId)`; takes
  `(zoom, lat, lon)` and returns a context manager
- `DescriptorIndexRebuilder` — narrower than C6's
  `DescriptorIndex.rebuild_from_descriptors(descriptors, tile_ids: list[TileId], hnsw_params: HnswParams)`;
  takes `tile_records: list[TileBboxRecord]` plus individual HNSW kwargs

The matching adapters live in `runtime_root/c10_factory.py`:

- `c6_tile_metadata_store_to_tiles_batch_query` — loops over `zoom_levels`, projects `TileMetadata`
  rows down to the four-field `TileBboxRecord`
- `c6_tile_store_to_pixel_opener` — builds `TileId` and returns the C6 `TilePixelHandle` (already
  a context manager)
- `c6_descriptor_index_to_rebuilder` — projects `TileBboxRecord` → `TileId` and folds HNSW kwargs
  into `HnswParams`

### 2. `C7EngineBackboneEmbedder` adapter — `Any`-typed at the c7 boundary

The default `BackboneEmbedder` impl wraps an AZ-297 `InferenceRuntime` + an AZ-321-compiled
`EngineHandle`. Importing those types — even under `TYPE_CHECKING` — fails the AZ-270 AST lint
because the lint walks `ast.ImportFrom` nodes regardless of context. We therefore type the
constructor parameters as `Any` and rely on structural duck-typing
(`inference_runtime.infer(handle, dict) -> dict`). The composition root wires the concrete C7
runtime in.

### 3. JPEG → tensor preprocessing is injected, not owned

`C7EngineBackboneEmbedder` accepts a `tile_decoder: Callable[[Any], np.ndarray]` rather than
hard-wiring OpenCV / Pillow / torchvision. Image preprocessing belongs to E-C2 (AZ-255); when
it ships, the composition root injects a real decoder. Until then the adapter stays free of
imaging-stack dependencies, keeping AZ-322's surface narrow and the test surface tiny.

### 4. Descriptor int64 id formula — reuse AZ-306, do not invent

`DescriptorBatcher` does NOT recompute the int64 id formula. It hands `TileBboxRecord` rows to
the rebuilder; the rebuilder adapter projects to `TileId`; AZ-306's
`FaissDescriptorIndex.rebuild_from_descriptors` uses the canonical
`tile_id_to_int64(TileId)` helper. Test `test_ac6_descriptor_id_mapping_matches_az306_scheme`
confirms by importing `tile_id_to_int64` directly and asserting against the
`int.from_bytes(sha256("zoom|lat|lon").first8, "big", signed=True)` formula.

## Files Changed

### Production code (new)

- `src/gps_denied_onboard/components/c10_provisioning/descriptor_batcher.py` — `DescriptorBatcher`
  class + `BatcherTile`, `TileBboxRecord`, `CorpusFilter`, `ProgressEvent`, `DescriptorBatchReport`,
  `BatcherOutcome`, `C10BatcherConfig` DTOs + `TilesByBboxBatchQuery`, `TilePixelOpener`,
  `DescriptorIndexRebuilder` consumer Protocols.
- `src/gps_denied_onboard/components/c10_provisioning/c7_engine_embedder.py` —
  `C7EngineBackboneEmbedder` adapter wrapping the AZ-297 `InferenceRuntime` surface; `Any`-typed
  to stay below the AZ-270 boundary.

### Production code (modified)

- `src/gps_denied_onboard/components/c10_provisioning/interface.py` — added `BackboneEmbedder`
  Protocol (`embed_batch` + `descriptor_dim`), `runtime_checkable`.
- `src/gps_denied_onboard/components/c10_provisioning/errors.py` — added `DescriptorBatchError`
  exception class extending `C10ProvisioningError`.
- `src/gps_denied_onboard/components/c10_provisioning/__init__.py` — re-exported all new symbols.
- `src/gps_denied_onboard/runtime_root/c10_factory.py` — added `build_descriptor_batcher` plus
  the three C6→C10 adapter functions.

### Tests (new)

- `tests/unit/c10_provisioning/test_descriptor_batcher.py` — 16 tests covering AC-1 through
  AC-10 + NFR-perf-overhead + NFR-reliability-bounded-retry, plus 4 supplemental tests
  (`Protocol` runtime-check for the four consumer cuts, query-args pass-through, handle
  release on embed failure, config validation).

### Documentation

- `_docs/02_document/module-layout.md` — c10 Public API + Internal section updated to list the
  AZ-322 surface; composition root section lists the new factory + adapters.
- `_docs/02_document/components/11_c10_provisioning/description.md` — §5 dependency table picks
  up `numpy`; new "AZ-322 internal phase" subsection summarises the batcher's
  contract / OOM behaviour / progress reporting / id formula.

## Test Results

- 16 / 16 AZ-322 tests pass (`tests/unit/c10_provisioning/test_descriptor_batcher.py`).
- 197 / 197 c10 + c6 + runtime-root targeted runs pass (59 docker-skip).
- Full project suite: **1352 passed, 79 skipped, 1 failed**.
  - 79 skipped: docker / Jetson / CUDA / actionlint env-gated (Tier-0 dev host).
  - 1 failed: `tests/unit/test_ac1_scaffold_layout.py::test_cmake_files_configure` —
    pre-existing OKVIS2 git-submodule failure documented in batch_35 cycle report; unrelated
    to this batch.

## Decisions Ledger

| Decision | Rationale |
|----------|-----------|
| `DescriptorBatcher.__init__` takes consumer-side Protocols, not raw C6 types | AZ-270 lint blocks direct cross-component imports; AZ-323 / AZ-324 set the precedent |
| `C7EngineBackboneEmbedder` parameters are `Any`-typed | AZ-270 AST lint flags `TYPE_CHECKING` imports too; structural duck-typing avoids the boundary |
| `tile_decoder` is injected, not bundled | JPEG preprocessing belongs to E-C2 (AZ-255); keeping it out of AZ-322 narrows scope and dependencies |
| Default `C10BatcherConfig.max_oom_retries=1` | Spec NFR-reliability-bounded-retry; one halve from 64 → 32 is the standard surface, deeper retries mask GPU regressions |
| Reuse AZ-306's `tile_id_to_int64` | Spec AC-6; inventing the formula here would diverge from C6's id scheme |
| Atomic FAISS rebuild guaranteed by AZ-306, not duplicated here | Spec AC-7; the batcher's role is to call `rebuild_from_descriptors` exactly once |

## Notes

- The `C7EngineBackboneEmbedder` is the default `BackboneEmbedder` impl, but production wiring
  to a real C7 engine awaits AZ-326 (T5 orchestrator) and AZ-255 (real C2 backbone preprocessing).
  The adapter is unit-tested via fakes today; integration tests land with AZ-326.
- `C10BatcherConfig` currently has no dedicated config-block hook in
  `C10ProvisioningConfig`; `build_descriptor_batcher` uses defaults. AZ-326 will add the
  config-block plumbing.
- The OKVIS2 cmake submodule failure remains and is independent of every batch-35 / batch-36
  change. It will resolve when the project's submodules are initialised on the dev host.
