0.6.0 — 2026-06-14
← Back to the changelog index
Changed
- Docs landing:
docs/index.md is a hand-crafted landing page (Diátaxis-shaped: at-a-glance, install, quick-start, per-section navigator) instead of inlining README.md via pymdownx.snippets. The inlined-README approach broke under mike's per-version routing — README's absolute docs-site URLs 404'd, and cross-version navigation jumped from /0.5/ to wherever the README's hardcoded URLs pointed. The split uses mkdocs-relative links throughout the landing so a reader on /0.5/ stays on /0.5/ when navigating. README cross-references now use the /latest/ mike alias (consistent badge + body channel). mkdocs.yml swaps pymdownx.snippets for admonition (snippets re-used only for the new docs/explanation/performance.md table inclusion); the admonition change incidentally fixes pre-existing !!! warning blocks in coordinates.md, pipeline.md, and concurrent_detection.md that were rendering as plain text.
- User-facing performance docs: new
docs/explanation/performance.md surfaces the Performance Profiles table and the ICRA 2020 comparison previously buried under "Engineering (Internal) → Benchmarking". Tables are snippet-included from README.md (single source of truth) so the numbers never drift between the two surfaces.
- Release pipeline: docs site is now versioned via
mike (.github/workflows/docs.yml, mkdocs.yml). Pushes to main deploy under /locus-tag/main/ and update the latest alias; release tags (v*, non-RC) deploy X.Y and update stable. The extra.version.default = stable block in mkdocs.yml redirects bare /locus-tag/ to the stable channel. Replaces the previous actions/upload-pages-artifact + actions/deploy-pages model that overwrote /latest on every push.
- PyPI publish now uses
pypa/gh-action-pypi-publish@release/v1 with attestations: true (.github/workflows/release.yml::publish-pypi), replacing uv publish --trusted-publishing automatic. PyPI now exposes a PEP 740 sigstore attestation on each artefact; supply-chain-conscious consumers (pip --require-hashes, pip-audit) gate installs on it. uv remains the local-dev publish tool — only the CI publish step changes.
decoder.rs SIMD gather blocks now carry full SAFETY justification including the 3-byte SIMD-gather padding contract (crates/locus-core/src/decoder.rs AVX2 + NEON paths in sample_grid_values_simd).
- Regression tests against the hub dataset cache now panic instead of silently skipping when
LOCUS_HUB_DATASET_DIR is set but the expected subdir is missing (closes the same footgun as PR #252).
- Centralised SO(3) → quaternion conversion through
quat_from_so3 helper, eliminating the latent UnitQuaternion::from_matrix Müller-iteration hang risk on near-singular rotations across board.rs, pose.rs, and the PyO3 board-pose copy.
- Pose/board LM and ChAruco saddle refinement now emit NaN-filled covariance on singular or near-singular Hessian (was: identity/zero). The post-
try_inverse finiteness gate catches the near-singular case that LU's zero-pivot test misses (tiny-but-nonzero pivot inflating the covariance to ~1e15–1e30). Production and bench LM variants are kept in sync; both now bail out after 3 consecutive Cholesky failures to surface the NaN sentinel promptly. ChAruco saddles whose per-corner Σ_c is singular (downstream of the structure-tensor gate) are dropped rather than admitted with an identity info matrix; the new rejection lane is unreachable on the production Tikhonov-regularised kernel but recorded distinctly via the new CharucoTelemetry::rejected_reasons discriminator buffer.
- Python/FFI contract:
BoardEstimator.estimate and CharucoRefiner.estimate now surface the NaN sentinel as board_cov = None so consumers branch on cov is None (matching the typed NDArray | None annotation). The bench-internals dict (_bench_refine_pose_lm_weighted_with_telemetry) preserves the NaN matrix to allow calibration audits to observe singular frames directly — numpy.linalg.inv does NOT raise LinAlgError on a NaN matrix; consumers must gate on np.isfinite(cov).all() (done in tools/bench/pose_cov_audit.py).
- Telemetry:
CharucoTelemetryResult gains a rejected_reasons: NDArray[uint8] buffer (0 = LowStructureTensorDet, 1 = SingularCovariance); always gate rejected_determinants aggregations on this discriminator, since the SingularCovariance lane writes NaN into the determinants buffer.
- Regression harness:
regression_board_hub test now skips NaN-cov frames from mean_board_translation_std_m / mean_board_rotation_std_deg (previously f64::NAN.max(0.0) == 0 silently coerced the sentinel to a zero std contribution) and reports them under the new frames_singular_covariance snapshot field.
Security
- Bumped
pyo3 and numpy from 0.28 → 0.29 to pick up the fixes for RUSTSEC-2026-0176 (OOB read in nth / nth_back on PyList / PyTuple iterators) and RUSTSEC-2026-0177 (missing Sync bound on PyCFunction::new_closure). Both advisories landed against pyo3 ≤ 0.28.3; only fixed releases are ≥ 0.29.0. The 0.28 → 0.29 surface is wire-compatible with the locus-py FFI — no source-code changes needed.
Fixed
- Debug-mode stack overflow in
Detector::with_config (crates/locus-core/src/{batch,detector}.rs): the construction call chain (Detector::with_config → DetectorBuilder::build → FrameContext::new → DetectionBatch::new) used to hold a ~215 KB DetectionBatch value on every stack frame because debug rustc does not perform RVO/NRVO. The original *Box::new(Self { ... }) "workaround" in DetectionBatch::new did not help — the literal is still materialized on the stack, then moved to the heap, then moved back to the stack by the outer dereference. Three or more chained copies overflowed Windows main-thread stacks (1 MB default) and Linux std::thread::spawn stacks (2 MB default); downstream users worked around it by spawning a thread with a bigger stack. Fix: DetectionBatch::new() is replaced by DetectionBatch::new_boxed() -> Box<Self> (breaking — acceptable on v0.5 RC), implemented via Box::<Self>::new_zeroed() + slice::fill for the handful of non-zero default fields (routed_to, and the four bench-internals NaN / u8::MAX fields). FrameContext.batch is now Box<DetectionBatch> so the batch never lives inline at any level of the construction chain. bench_api_get_batch_cloned, build_synthetic_batch, and generate_bench_batch (which previously returned a 215 KB struct by value) now return Box<DetectionBatch>. A const _: () = assert!(...) block next to CandidateState / FunnelStatus pins both default variants to discriminant 0 at compile time, locking the SAFETY contract the unsafe block depends on. bench_api_get_batch_cloned now copies all SoA columns (the previous body silently dropped routed_to, ppb_estimate, and the four bench-internals telemetry columns — a pre-existing latent bug surfaced during this fix). Three new unit tests guard the contract: construction_tests::detector_constructs_in_constrained_stack runs the constructor inside a 256 KB stack; test_new_boxed_defaults_full_coverage and test_new_boxed_bench_internals_defaults assert the post-construction value of every non-zero default column at both endpoints ([0] and [N-1]). constraints.md §1 now codifies the rule "Large SoA / batch structs (≥ 64 KB) must expose only new_boxed() -> Box<Self>" to prevent the next 200 KB SoA struct from re-introducing the same footgun.
prepare_image_view soundness gaps (F1/F2/F3): closed the three latent UB / lifetime issues at the Python FFI image-ingestion boundary (crates/locus-py/src/lib.rs). F1 — AVX2 sample_bilinear_v8's 32-bit gather on 8-bit data can no longer read past the NumPy buffer. The boundary now wraps every input in a new FfiImageBuffer enum: when the incoming buffer already exposes ≥3 trailing bytes per row (has_simd_padding()), the FFI takes a zero-copy Borrowed fast path; otherwise it copies the image into an over-allocated padded scratch (Padded) so the SIMD gather guard bytes always exist. The copy fallback preserves the pre-PR public API for the natural np.zeros((H, W), dtype=np.uint8) idiom at the cost of one H × W-byte allocation + copy per detect() call on the under-padded path. (PR #287's initial PR-A design instead rejected under-padded inputs with PyValueError; the project owner chose the copy fallback over reject after the xfail-comment menu at crates/locus-py/tests/test_ffi_contracts.py:115-122 was reconsidered.) F2 — negative strides[0] (e.g. from reverse-row views like arr[::-1, :]) is rejected before the unsigned cast that previously wrapped to a huge usize and built a multi-gigabyte slice in std::slice::from_raw_parts. F3 — the function signature now ties the returned FfiImageBuffer<'py> lifetime to the input &'py PyReadonlyArray2<'_, u8>, replacing the unconstrained 'a that decoupled the borrow from the NumPy buffer; the call-site SAFETY comment cites the explicit tie. Resolves the Phase A1 follow-up listed at docs/engineering/ffi_contracts.md §1 (SIMD padding) and §7 — a new TestNegativeStrideRejection class covers the reverse-row path on both detect() and detect_concurrent(); TestSimdPaddingGate.test_missing_padding_is_rejected stays xfail(strict=True) as a TODO-style marker for any future variant that re-enables the strict gate.
Added
- Cross-platform wheels on every release tag (
.github/workflows/release.yml::build-wheels-{linux,macos,windows}): Linux x86_64 + aarch64 (both manylinux_2_28 and musllinux_1_2 for Alpine-based containers), macOS x86_64 + aarch64, and Windows x64. Resolves the long-standing footgun where macOS / Windows users had to build from sdist (Rust toolchain required).
- GitHub Release pages now ship release notes extracted from
CHANGELOG.md (scripts/ci/extract_changelog_section.py) and attach the full artefact set (wheels, sdist, .crate). RC tags get --prerelease + a one-line "release candidate" preamble inheriting the parent or Unreleased block. create-github-release fails CI if the resulting CHANGELOG section is empty — forces operators to land notes before tagging.
- Monthly Linux canary build (
schedule: cron: '0 6 1 * *' on release.yml) exercises the Linux wheel matrix on the 1st of each month to catch toolchain drift between releases. macOS (10× billing) and Windows (2× billing) are skipped by design.
docs/engineering/release-runbook.md operator-facing runbook covering topology, OIDC bootstrap (PyPI + crates.io + gh-pages + mike set-default), per-release flow, RC convention, and partial-failure rollback. .agent/skills/release/SKILL.md slimmed to a quick-reference pointer.
CharucoRefiner saddle path: the previous iterative Newton step was empirically inert (and the principled Förstner replacement regresses pose 4.5× on regression_board_hub::board_charuco_v1_golden_forstner). Replaced by an inline structure-tensor determinant gate (compute_structure_tensor_det); the homography-projected saddle position is taken as final. Behaviour preserved. See memory/project_refine_saddle_noop.md.
- Outlier-aware pose LM (
pose.outlier_drop_d2_threshold, default 0.0): after the weighted LM converges, if any corner's reprojection d² = rᵢᵀ Σᵢ⁻¹ rᵢ exceeds the threshold and dominates the second-worst corner by ≥ 2×, the worst corner's info matrix is zeroed and the LM is re-run warm-started. The 3-corner pose is kept only when its aggregate d² over the kept corners is strictly lower than the 4-corner pose's — a self-rejection invariant that catches false-positive drops. Only high_accuracy opts in (threshold 25.0 ≡ 5σ²). Closes the only published SOTA gap on render-tag-hub (rotation p99 0.8613° → 0.5607°, −35 %) while preserving mean recall, mean corner RMSE, and translation p99.
- New
refine_pose_lm_weighted_with_info — info-matrix-direct variant of refine_pose_lm_weighted that skips the 4 Σ_c⁻¹ inversions for callers that already hold pre-inverted info. The existing refine_pose_lm_weighted becomes a thin wrapper that inverts and delegates.
DetectionBatch::outlier_corner_idx: [u8; MAX_CANDIDATES] SoA column (bench-internals-gated) records which corner Phase D masked during outlier-aware refinement; sentinel u8::MAX ⇒ no drop. When idx != u8::MAX the stored pose covariance reflects 6 observations instead of 8. Documented in docs/engineering/detection-batch-contract.md.
decode_batch_soa_generic, decode_batch_soa_with_camera_inner, and refine_poses_soa_with_config now write SoA columns directly from rayon workers via par_iter_mut().zip(...), eliminating a per-frame Vec<TupleN> heap allocation outside the workspace arena. New bench-internals-gated contract_decode_hot_loop_zero_alloc integration test (custom counting GlobalAlloc) locks the byte-delta to exactly zero on warm steady-state calls. decoding_bench::bench_decoding_soa_realistic median latency drops from 21.25 µs → 6.60 µs (−69 %) on AMD EPYC-Milan; pose-bench medians are within ±5 % of baseline (microbench noise floor).
Fixed
decode_batch_soa_generic now recomputes the per-candidate homography whenever either ERF corner refinement or rotation reorder mutates batch.corners[i]. Previously, a canonical-orientation (rot == 0) ERF-refined candidate kept the pre-refinement h_slot, leaving (corners, H) inconsistent and feeding stale projection geometry to CharucoRefiner saddle prediction — the same class of bug documented in memory/project_refine_saddle_noop.md. The contract is now codified in docs/engineering/detection-batch-contract.md §4 Phase C.
contract_decode_hot_loop_zero_alloc test fixture now seeds candidate 0 with the synthetic tag's ground-truth corners so the convergence loop exercises both branches of the hot closure — the FailedDecode bypass (candidates 1..50) AND the state == Valid write-back path (rotation reorder, ERF refined-corners write-back, homography recompute). The previous fixture's quads never overlapped the tag, so the assertion covered only the bypass branch.
contract_decode_hot_loop_zero_alloc warm-up replaced with a convergence loop (max 32 iterations, requires two consecutive (0 bytes, 0 count) deltas). Closes three review-flagged holes: silent pin_rayon_to_one_thread no-op leaving extra workers cold; shrinking realloc crediting 0 bytes while bumping the count; and lazily-initialised structures that need >8 warm-ups to stabilise.
contract_refine_poses_soa_zero_alloc_gwlf_route added: forces CornerRefinementMode::Gwlf and seeds identity per-corner covariances so compute_one's GWLF unpack (Matrix2::new(covs_row[j*4..j*4+3])) is actually exercised by the zero-alloc contract; the default route only covers the ERF branch.
par_iter_mut().zip(...) chains in decoder.rs and pose.rs now guard the disjoint-slice contract with debug_assert_eq!(...len(), n) so a future off-by-one fix on any column fails loudly in debug rather than silently dropping the last candidate's pose / decode cells via rayon Zip's shortest-input truncation.
docs/engineering/detection-batch-contract.md §4: Phase C declared privileges now include homographies[0..N] writes (the post-rotation / post-ERF recompute that has been in the code path since 0.5.0 but was previously elided in the doc); Phase D declared privileges now list the four bench-internals-gated diagnostic columns (outlier_corner_idx, pose_consistency_d2, pose_consistency_d2_max_corner, ippe_branch_d2_ratio) and drop status_mask from the read set (Phase D consumes the upstream partition(v) invariant rather than reading the mask directly).