0.5.0 — 2026-05-20
BREAKING CHANGES
Detector configuration has been unified around JSON profiles. Four overlapping config paths have been collapsed into one; migration is documented in docs/migration/config-refactor.md.
- Removed
DetectorPresetenum; passprofile="standard" | "grid" | "high_accuracy"as a string. - Removed flat detector kwargs on
Detector.__init__(threshold_tile_size,quad_min_edge_score,refinement_mode, etc.). Uselocus.DetectorConfig.from_profile(...)and edit the nested model. - Removed
Detector.standard_config(); uselocus.DetectorConfig.from_profile("standard"). - Removed the
**kwargsescape hatch onDetector.__init__. Unknown kwargs now raiseTypeError. - Removed duck-typed integer conversion for typed PyO3 enums at the FFI boundary. Pass the real enum instance, or a string variant name when editing a JSON-shaped dict.
- Removed Grid + 8-connectivity
UserWarningbranch. The grid profile pins 4-connectivity by construction. - Removed
locus._profilemodule.DetectorConfiglives inlocus._configand is re-exported from the package root. - Removed Rust
DetectorConfig::standard_default() / grid_default() / high_accuracy_default(). UseDetectorConfig::from_profile("standard" | "grid" | "high_accuracy")orDetectorConfig::from_profile_json(&str). - Removed Rust
ConfigPresettest enum. The regression harness now takes.with_profile(&str)/.with_profile_json(&str). - Removed Soft-decision decoding entirely. The
DecodeModeenum (Rustlocus_core::config::DecodeModeand Pythonlocus.DecodeMode), thedecode_modefield onDetectorConfig(Rust + Pydantic), thedecode_modekey in shipped JSON profiles, thewith_decode_mode()builder/setter, theEdLines + Softcross-group validation rule, theEdLinesIncompatibleWithSoftDecodeRust error variant, theregression_icra_forward_synthetictest +tests/fixtures/icra_synthetic.jsonfixture, and thesoft_decoding_bench.rsbenchmark are all gone. Hard-decision Hamming decoding is now the only path. - Removed
CornerRefinementMode::Edge(the gradient-peakfit_edge_line-only refiner). Empirically dominated: no shipped profile referenced it, and the variant only surfaced in production via the wasted-work path onGwlfquads (now fixed). Migration: switch toErf(the PSF Gauss-Newton fit; Edge's gradient-peak fallback is still its internal failure path) orNone(the EdLines-native sub-pixel corners). PyO3 enum discriminants shift to dense{None=0, Erf=1, Gwlf=2}— Rust enum order matches. - Removed
PoseEstimationMode(theFast/Accuratetoggle). The pose estimator now selects its cost surface automatically from per-corner covariance availability: image view (or external GWLF covariances) routes to weighted Mahalanobis LM with a 6×6 covariance output; no image and no covariances routes to unweighted Huber LM with no covariance. Fast mode was latency-neutral onhigh_accuracy, ≤ 13 % faster onstandard, and 5–6× worse pose RMSE — a default-value artifact, not an architectural feature. - Migration: drop the
pose_estimation_mode=argument fromlocus.Detector.detect()/detect_concurrent()calls and thetools/cli.py--pose-modeflag. - Removed symbols:
PoseEstimationModeenum (Rust + Python), thepose_estimation_modefield onDetectOptions(Rust + Pydantic) andDetectOptionsBuilder, thepose_mode/modeparameter onDetector::detect,Detector::detect_concurrent,LocusEngine::detect_with_context,LocusEngine::detect_concurrent,estimate_tag_pose,estimate_tag_pose_with_config,refine_poses_soa,refine_poses_soa_with_config, and the bench-internals helpers (bench_estimate_tag_pose,bench_estimate_both_branches,bench_refit_pose_drop_corner). Thebench_pose_fastdivan bench is deleted along with theregression_hub_fast_tag36h11_1080ptest and its*_fast.snapsnapshot.
Added
- Shipped JSON profiles (
crates/locus-core/profiles/*.json) — single source of truth for Rust and Python. Embedded into Rust viainclude_str!and re-exposed to the Python wheel through the_shipped_profile_jsonFFI hook so both readers see byte-identical input. schemas/profile.schema.json— JSON Schema exported from the Pydantic model; CI diffs it against the runtime-generated schema to catch drift.locus._config.DetectorConfig— nested Pydantic model with cross-group invariant checks.DetectorConfig::from_profile_json(&str)on the Rust side for user-supplied profiles.docs/engineering/profiles.md— rationale for the refactor.docs/migration/config-refactor.md— before/after table for every removal.- Pose-consistency gate (
pose.pose_consistency_fpr, default0.0= disabled): final reprojection-consistency check insideestimate_tag_pose_with_configusing Mahalanobis distance with χ²-derived critical values. Aggregate gate at χ²(2; fpr); per-corner gate at χ²(1; fpr). Rejected poses surface asDetection.pose = None. Onlyhigh_accuracyandrender_tag_hubopt in (both at1.0e-3);standard,grid, andgeneralprofiles are byte-identical. Mahalanobis-aware IPPE branch selection swaps to the alternate branch when its aggregated²is at least 2× better AND statistically defensible — gated onfpr > 0so disabled profiles preserve the legacy ideal-corner Euclidean comparison. bench-internalsSoA telemetry columns:pose_consistency_d2,pose_consistency_d2_max_corner,ippe_branch_d2_ratio. NaN sentinel when the gate did not run.crates/locus-core/tests/regression_pose_consistency_roc.rs— synthetic-isotropic ROC sweep with a hardrealized FPR ∈ [1e-4, 1e-2]acceptance gate at the productionfpr = 1e-3. Snapshot lives atregression_pose_consistency_roc__pose_consistency_roc__synthetic_isotropic.snap.docs/engineering/track2_precision_threshold.md— calibration rationale, ROC harness usage, and the empirical-fallback procedure if the χ²(2) tail breaks on a future codebase change.docs/engineering/icra_forward_high_accuracy_diagnostic_20260426.md— re-uses theicra_forward_diagnostictest from the PR-#205 truncation work to attribute thehigh_accuracyrecall collapse on ICRA forward frames0..5to the EdLines extraction stage (not the funnel), tabulates a knob-flip experiment matrix, and lays out the SOTA path forward (adaptive per-candidate extraction).- Adaptive PPB quad-extraction routing (opt-in): new
quad.extraction_policyenum onDetectorConfigwith two variants —Static(default; preserves byte-identity for every shipped profile) andAdaptivePpb { threshold, low_extraction, high_extraction, low_refinement, high_refinement }. Each candidate quad's pixels-per-bit estimate (bbox_short / min_outer_dimacross registered families) is compared againstthreshold(strict<tie-break for snapshot stability) and routed to the low or high extractor + refinement pair. Validators reject degenerate (low == high) policies, thresholds outside(1.0, 5.0), and the per-route EdLines + Erf incompatibility. max_recall_adaptiveshipped profile: opt-in routing ofContourRdp + Erf(PPB < 2.5) versusEdLines + None(PPB ≥ 2.5). Lifts ICRA forward recall from0.4631(high_accuracy) to0.7380while keeping render-tag p99 rotation under the1.897°budget at every shipped resolution. EdLines under distortion is rejected by the upstream guard, so the profile is only well-defined for pinhole / rectified inputs (matchesgeneralandrender_tag_hub).bench-internalsSoA telemetry columns:routed_to(u8;0= low,1= high,255=Static) andppb_estimate(f32;0.0underStatic).docs/engineering/max_recall_adaptive_calibration_20260426.md— records the empirical calibration sweep that landed on(threshold = 2.5, sharpening = on), the negative-result variants, the intrinsic source of the~0.6 pxrender-tag RMSE versushigh_accuracy's~0.2 px, and the deferred Phase B follow-ups (post-decode corner re-refinement, image pyramid, per-route sharpening) that would lift adaptive to a strict improvement.- Phase C.5 — post-decode corner re-refinement (
decoder.post_decode_refinement, defaultfalse): for eachValidcandidate, fits the four outer tag edges with the sharedErfEdgeFitter(PSF step model), intersects adjacent edges to recover four sub-pixel corners, and re-solves the homography. Onlymax_recall_adaptiveopts in; every other shipped profile is byte-identical. Drops the profile's ICRA-forward mean RMSE from0.2871 → 0.2724(−5.1 %) with recall preserved at0.7380, and improves render-tag mean RMSE by 0.3–0.7 % across the full640 / 720 / 1080 / 2160pyramid with p99 rotation flat (within sub-1 % noise). Phase A's GWLF / structure-tensor covariance is intentionally preserved — the line fit's Cramér-Rao bound is too tight for synthetic-PSF imagery and would over-trust the refit in the weighted pose solver. Two new SoA-contract tests (contract_phase_c5_refit_valid_corners_disabled_is_noop,contract_phase_c5_refit_valid_corners_active_writes) lock thecorners+homographieswrite set. docs/engineering/post_decode_refinement_20260426.md— algorithm rationale (why edge-fit-then-intersect over per-corner saddle), failure-mode constants, the covariance-preservation argument, before/after numbers across all 5 datasets, and the byte-revert path for A/B comparison.- EdLines Phase-3 erf-step sub-pixel finder (
quad.edlines_phase3_erf, defaultDisabled): opt-in replacement for the 3-point parabolic vertex inside the EdLines Phase-3 micro-ray refinement. Fits a PSF-blurred step modelI(k) = (A+B)/2 + (B-A)/2 · erf((k-μ)/σ)to the 5 intensity samples per probed pixel via separable nonlinear LS (alternating linear(A,B)solve and one Gauss-Newton step on(μ,σ)). Adapts σ per ray and removes the directional bias that the parabolic clamp introduced when the true edge offset exceeded ±1.5 px. Per-ray fallback to the parabolic vertex on low contrast (Δ < 4intensity units), singular GN, or μ runaway (|μ| > 2) preserves sample density. Under the newAccuratepose-mode default onhigh_accuracy+EdLines, the trade-off vs the parabolic default measured on the hub corpus is: trans_rmse0.0047 → 0.0129 m(single-tag, regression), rot_p990.77 → 0.37 °(single-tag, −52%), rot_p9940.57 → 1.19 °(aprilgrid_golden multi-tag, −97%), multi-tag pose-flips32 → 5(−84%). Latency cost is+3 %at 1 tag/frame and+24 %at ~29 tags/frame, gated bymax_iters = 6per probe. Tail-stability vs accuracy trade-off — no shipped profile flips the default; opt in per-deployment when rot p99 / pose-flip count is the load-bearing metric. JSON / Python accepts booleantrue/falsewith a deprecation warning; canonical form is"Enabled"/"Disabled". - 5 new unit tests on
fit_erf_step_lm(centred edge, sub-pixel offset sweep, low-contrast rejection, robustness to initial guess, variable σ).
Changed
high_accuracyshipped profile tightensdecoder.max_hamming_errorto1(previously inherited the AprilTag36h11 family default of2). On the 1080p render-tag corpus (locus_v1_tag36h11_1920x1080), the family default admitted one decode at hamming = 2 (scene_0026, decoded id 89 for true tag 543) — the entire 1.96 % precision gap vs AprilTag-C on the SOTA detector matrix. Every true-positive across all four shipped render-tag resolutions (640 / 720 / 1080 / 2160) decodes at hamming = 0, so tightening the budget leaves recall at 100 % on the full hub render-tag corpus and at every regression suite (board, distortion, ICRA, robustness — zero snapshot drift). Locushigh_accuracynow ties AprilTag-C and OpenCV on precision (all 100 %) while keeping every translation / latency / rotation-p99 lead documented indocs/engineering/benchmarking/render_tag_sota_20260425.md§1. Same rationale as the prior 16h5 default-tightening (decoder.rs:1562-1566).high_accuracyshipped profile now opts into the AdaptivePpb router with the same routing pair asmax_recall_adaptive(ContourRdp + Erffor PPB < 2.5;EdLines + Nonefor PPB ≥ 2.5). All other knobs are unchanged (enable_sharpening: false,refinement_mode: None,pose_consistency_fpr: 1.0e-3). Render-tag tag36h11 snapshots are byte-identical at640 / 720 / 1080 / 2160because high-PPB tags continue to route through the originalEdLines + Nonepath. ICRA forwardmean_recalllifts0.4631 → 0.6053(+14.2 pt) andmean_rmseimproves0.7535 → 0.5572(−0.20 px) by recovering far-field tags that the EdLines extractor was rejecting — they now route through theContourRdp + Erfbranch instead. Resolves path (1) ofdocs/engineering/icra_forward_high_accuracy_diagnostic_20260426.md.
Fixed
- Quad-extraction pre-filter truncation: dropped the pre-filter
pixel_counttruncation block introduced in5a2f438that cappedcomponent_statsatMAX_CANDIDATESbefore per-component geometric filtering. On dense small-tag scenes the cap discarded tag-sized candidates in favour of large background blobs that the geometric gates would have rejected anyway. Thepixel_count-descending iteration order from5a2f438is preserved verbatim — that ordering is load-bearing for the dedup funnel. Survivor truncation now happens caller-side afterextract_single_quadhas filtered geometrically. Trade-off: distortion suite lifts to its true ceiling — Brown–Conrady recall0.870 → 0.935(+6.5 pp), Kannala–Brandt0.809 → 0.813; ICRA forward variants regress by2-3 pp(e.g.standard0.7517 → 0.7236) because the post-filter survivor set on dense crude-render scenes feeds a different dedup state than the pre-filter set did. Render-tag SOTA snapshots are byte-identical to bless commit4998607. Seedocs/engineering/benchmarking/quad_truncation_fix_20260426.md(superseded — consolidated intodocs/engineering/lessons/in v0.7.1). - ICRA test silent-skip:
tests/common::resolve_dataset_rootpreviously fell through to a stub fixture (tests/fixtures/icra2020/) whenLOCUS_ICRA_DATASET_DIRwas set to a relative path that didn't resolve relative to the test CWD; the macro'sif let Some(provider) = IcraProvider::new(...)then short-circuited without ever asserting on the snapshot. PR #205's pre-merge "16 passed" was a false positive that hid the ICRA delta above. The resolver now treats the env var as authoritative — relative paths resolve against the workspace root (matchingresolve_hub_root), and a non-directory value panics rather than falling back.IcraProvider::newlikewise refuses to silent-skip when the env var is set. - Packaging: relocated the shipped JSON profiles from
crates/locus-py/locus/profiles/tocrates/locus-core/profiles/socargo package -p locus-coreno longer fails with "couldn't readsrc/../../locus-py/locus/profiles/standard.json: No such file or directory". Python now queries the embedded bytes through a new_shipped_profile_jsonFFI hook instead ofimportlib.resources, which means the wheel ships zero profile data files of its own — deleting one is impossible by construction.
Internal
- Telemetry: added a static
#[tracing::instrument(skip_all, name = "pipeline::segmentation")]annotation onsimd_ccl_fusion::label_components_lsl, closing the Segmentation gap left by the 2026-05-03 2160p latency audit (tools/bench/aggregate_telemetry.pypreviously reporteduninstrumented (folded into Quad Extraction)for that stage). Span name follows thepipeline::*convention used by the other five stages. Quad Extraction p95 at 2160p moved by<0.01 msbetween V2's audit and this branch — well inside the ±5 % observability budget. Numbers (50 frames,high_accuracy + Accurate, AMD EPYC-Milan KVM): segmentation p50 = 18.30 ms, p95 = 45.17 ms, p99 = 103.53 ms at 2160p; p50 = 4.72 ms, p95 = 10.00 ms at 1080p. The 2160p ↔ 1080p multiplier is ~3.9–4.5× across percentiles, matching the O(N) expectation for a 4× pixel-count step. Full record:docs/engineering/latency_audit_2160p_segmentation_addendum_2026-05-03.md. - Type checker: migrated from
mypytobasedpyright(PEP 561 stubs unchanged).[tool.mypy]and the five[[tool.mypy.overrides]]blocks inpyproject.tomlare replaced by a single[tool.pyright]block with selective strictness (reportUnnecessaryTypeIgnoreComment,reportUnusedImport). Type-checking is now also enforced in CI via a newtype-check-pythonjob; previously the gate was local-only. The 25# type: ignore[…]comments across the repo are rewritten to# pyright: ignore[…]per the standard mapping. As a side effect, the migration surfaced and fixed several real type bugs:tools/cli.pypreviously calledrr.*even when the--no-rerunflag was passed (latent NameError when rerun-sdk was not installed) —visualizenow requires Rerun and exits early with a clear hint when missing;tools/viz_rerun_hub.pyhad latentNone-subscript bugs in the manifest/intrinsics path;tools/bench/sync_hub.pywas importingEntryNotFoundErrorfrom the deprecatedhuggingface_hub.utilslocation (nowhuggingface_hub.errors);tools/bench/plots/pareto.pywas using the privateplt.Line2Dre-export instead ofmatplotlib.lines.Line2D. - Soft-decode removal: deleted
SoftStrategy,SoftCode, and themultiversion-dispatched LLR distance kernels. With only one strategy left, theDecodingStrategytrait collapsed into a single free functionstrategy::bits_from_intensities;decoder.rs'sdecode_batch_soa_generic,decode_batch_soa_with_camera_inner,decode_candidate_distorted,sample_grid_generic, andsample_grid_soa_precomputedlost their<S: DecodingStrategy>type parameter andS::Codeassociated type, callingdecoder.decode_full(code, …)directly. The deadTagDecoder::for_each_candidate_within_hammingtrait method (its only consumer was Soft) and its five family impls plus the underlyingTagDictionary::for_each_candidate_within_hamminghelper are deleted; the production-pathTagDictionary::decodecontinues to use the MIH index. Also dropped thewith_decode_modetest harness onRegressionHarnessand thetests/common::hubhelper, and an orphanregression_icra2020__icra_forward_synthetic.snapsnapshot. CLI'stools/cli.pycollapses the formerLocus (Hard)/Locus (Soft)side-by-side wrappers into a singleLocusentry. Documentation references inREADME.md,docs/tutorials/guide.md,docs/reference/api.md,docs/explanation/pipeline.md,docs/explanation/algorithms.md, anddocs/engineering/ffi_contracts.mdare updated;docs/engineering/benchmarking/lessons.mdkeeps the historical Soft-vs-Hard analysis as a record-of-decisions. - Pre-release code cleanup: deleted orphan
crates/locus-core/src/homography.rs(never declared inlib.rs; productionHomographylives indecoder.rs), dropped unusedimg_heightparameter fromextract_boundary_segments, removed deadsample_grid_generic/sample_grid_soahelpers, removed unusedDecodingStrategy::distancetrait method, gated test/bench-only helpers behindcfg(any(test, feature = "bench-internals"))(orcfg(feature = "bench-internals")for items with no in-crate test consumers), tightenedunsafeSAFETY comments acrossfilter.rs,threshold.rs,quad.rs, andlocus-pyto cite concrete invariants (rayon disjointness, multiversion runtime checks, pyo3-numpy contracts), removed redundant innercfg(feature = "bench-internals")blocks from tests already gated byrequired-featuresinCargo.toml, and replaced a stream-of-consciousness comment inthreshold.rswith a one-line invariant statement. - Pre-release docs hygiene: untracked the leaked
.claude/settings.local.jsonand added it to.gitignore; stripped an absolute developer path fromdocs/engineering/erf-simd-attempt-postmortem.md; renamed the legacy*_sotaregression test functions and their snapshot files to align with the public preset taxonomy (*_highaccuracyfor the EdLines + GN + None metrology preset,*_standardfor the defaultContourRdp + Hardpreset,*_gridfor the 4-connectivity profile), deleted four stale duplicate hub snapshots attests/snapshots/regression_render_tag__hub_*_sota.snap(the live copies live undertests/common/snapshots/...), reconciledsota_metrology_20260321.mdwith the v0.3.1 shipped JSON profiles (Standard and Grid both shipdecode_mode: Hardsince PR #172 — the historical Soft-decode figures are now flagged as such), updatedrelease_performance_20260322.md's Hub table to use the current preset name (HighAccuracyrather thanSOTA), and removed a danglingregression_hub_tag36h11_*_sota_pure_tagsTODO sentence that had outlived its context.