FFI Contract Inventory
Scope. This document enumerates every invariant the Python↔Rust boundary enforces today, keyed to its enforcement point. It is the authoritative test matrix for Phase A1 hardening: each row here is a test to write.
Entry points covered:
Detector.detect/Detector.detect_concurrentBoardEstimator.estimateCharucoRefiner.estimateCameraIntrinsics.__new___create_detector_from_config(implicit constructor behindDetector.__init__)
Each section is organised as {Invariant | Enforcement point | Error type | Notes}.
"Enforcement point" is the function where the check actually runs — not where
the argument is declared. Functions are named rather than line-numbered so the
table does not go stale; crates/locus-py/src/lib.rs is abbreviated lib.rs.
1. Image buffer invariants
All four detection entry points accept a 2-D uint8 NumPy array and route it
through prepare_image_view in crates/locus-py/src/lib.rs.
| Invariant | Enforcement point | Error type |
|---|---|---|
dtype == np.uint8 (single-frame) |
Detector.detect in crates/locus-py/locus/__init__.py |
ValueError (Python-side) |
dtype == np.uint8 (per frame in concurrent path) |
Detector.detect_concurrent in crates/locus-py/locus/__init__.py |
ValueError (Python-side) |
Shape is 2-D (H, W) |
PyReadonlyArray2<'_, u8> signature of BoardEstimator::estimate, CharucoRefiner::estimate, Detector::detect and Detector::detect_concurrent (lib.rs) |
TypeError (PyO3 built-in) |
stride_x == 1 (row-major, C-contiguous along the last axis) |
lib.rs in prepare_image_view |
PyValueError — "Array must be C-contiguous. Call np.ascontiguousarray(image) first." |
strides[0] >= width (rejects reverse-axis views with negative row stride) |
lib.rs in prepare_image_view |
PyValueError — "Array row stride (<value>) must be >= width (<value>); negative strides…" |
stride_y >= width and buffer length fits (height - 1) * stride + width |
ImageView::new (crates/locus-core/src/image.rs) |
PyRuntimeError (wrapped from String) |
has_simd_padding() — ≥3 trailing bytes past the last logical pixel |
lib.rs in prepare_image_view (silently satisfied via internal copy fallback when missing) |
n/a — accepted via copy into padded scratch |
SIMD padding (A1.2 — enforced via internal copy fallback)
ImageView::has_simd_padding() (image.rs) returns whether the buffer
has ≥3 bytes past the last logical pixel — the slack that AVX2 gather
instructions (e.g. sample_bilinear_v8 in decoder.rs) may touch when
loading 32-bit words on 8-bit data. prepare_image_view is responsible for
this gate at every entry path, but it satisfies it transparently rather
than rejecting under-padded inputs:
- If the incoming NumPy buffer already exposes ≥3 trailing bytes per row
(
stride_y >= width + 3, e.g. aparent[:, :W]slice from a wider parent allocation), the function returns a zero-copyFfiImageBuffer::Borrowedvariant — theImageViewborrows directly from the NumPy data. - Otherwise (e.g. a tightly-packed
np.zeros((H, W), dtype=np.uint8)), the function copies the image into an over-allocated scratchVec<u8>of lengthH * W + 3(the trailing 3 bytes are zero-padded guard bytes) and returns anFfiImageBuffer::Paddedvariant. The scratch buffer lives for the lifetime of thedetect()call and is dropped afterwards.
This violates the otherwise-strict "no copies at the FFI" rule in
constraints.md §2, deliberately: PR #287 PR-A initially rejected
under-padded inputs and broke every np.zeros((H, W)) call site in the
public API; the fallback restores compatibility at the cost of one
H × W copy per detect() call on the tightly-packed path.
To take the zero-copy fast path explicitly, allocate a wider parent and view a column prefix:
parent = np.pad(img, ((0, 0), (0, 3))) # or `np.zeros((H, W + 3), ...)`
view = parent[:, : img.shape[1]]
detector.detect(view) # FfiImageBuffer::Borrowed — zero-copy
Note that view is non-C-contiguous (stride_y > width) by design — the
stride_x == 1 gate is what defines "C-contiguous along the last axis" at
this boundary, not NumPy's stricter flags['C_CONTIGUOUS'].
Intrinsics ↔ image-shape coupling (enforced)
When intrinsics is passed to detect() or detect_concurrent(), the
principal point is validated against each image's dimensions:
| Invariant | Enforcement point | Error type |
|---|---|---|
0 <= cx < width |
lib.rs:validate_principal_point (called before detach) |
PyValueError |
0 <= cy < height |
same | PyValueError |
fx, fy plausibility relative to the image size is not gated — it is a
calibration concern, not a correctness concern.
2. CameraIntrinsics construction
Defined by CameraIntrinsics::new in crates/locus-py/src/lib.rs.
| Invariant | Enforcement point | Error type |
|---|---|---|
fx, fy, cx, cy all finite (no NaN/±inf) |
CameraIntrinsics::new |
PyValueError |
fx > 0 and fy > 0 |
CameraIntrinsics::new |
PyValueError |
distortion_model == Pinhole → any dist_coeffs length (including empty) accepted |
CameraIntrinsics::new |
n/a |
distortion_model == BrownConrady → len(dist_coeffs) == 5 |
CameraIntrinsics::new (feature-gated on non_rectified) |
PyValueError |
distortion_model == KannalaBrandt → len(dist_coeffs) == 4 |
CameraIntrinsics::new (feature-gated on non_rectified) |
PyValueError |
BrownConrady / KannalaBrandt available |
Compile-time #[cfg(feature = "non_rectified")] |
AttributeError at import time if feature off |
Principal-point bounds (cx, cy) ∈ image are checked at
detect()/detect_concurrent() — see §1 "Intrinsics ↔ image-shape coupling".
3. DetectorConfig validation
All configuration validation happens in DetectorConfig::validate()
(crates/locus-core/src/config.rs), invoked by
DetectorBuilder::validated_build(). _create_detector_from_config maps every
ConfigError to PyValueError.
| Invariant | Enforcement point | ConfigError variant |
|---|---|---|
threshold_tile_size >= 2 |
DetectorConfig::validate |
TileSizeTooSmall |
1 <= threshold_local_mean_radius <= 127 (threshold::MAX_LOCAL_MEAN_RADIUS: keeps box sums < 2²⁴, exact in the local-mean arithmetic) |
DetectorConfig::validate |
InvalidLocalMeanRadius |
threshold_noise_k finite and > 0 |
DetectorConfig::validate |
InvalidNoiseK |
decoder_max_border_error_rate, when set, lies in [0, 1] (None = the family's max_hamming / bit_count; 1.0 disables the ring check) |
DetectorConfig::validate |
InvalidBorderErrorRate |
decimation >= 1 |
DetectorConfig::validate |
InvalidDecimation |
upscale_factor >= 1 |
DetectorConfig::validate |
InvalidUpscaleFactor |
0.0 <= quad_min_fill_ratio < quad_max_fill_ratio <= 1.0 |
DetectorConfig::validate |
InvalidFillRatio { min, max } |
quad_min_edge_length > 0.0 |
DetectorConfig::validate |
InvalidEdgeLength |
1 <= structure_tensor_radius <= 8 |
DetectorConfig::validate |
InvalidStructureTensorRadius |
0 <= pose_consistency_fpr < 1 |
DetectorConfig::validate |
InvalidPoseConsistencyFpr |
outlier_drop_d2_threshold finite and >= 0 |
DetectorConfig::validate |
InvalidOutlierDropD2Threshold |
quad_extraction_mode == EdLines ⇒ refinement_mode != Erf (also per AdaptivePpb route) |
DetectorConfig::validate |
EdLinesIncompatibleWithErf |
AdaptivePpb low/high extraction modes differ |
DetectorConfig::validate |
AdaptivePolicyDegenerate |
AdaptivePpb threshold in (1.0, 5.0) |
DetectorConfig::validate |
AdaptivePolicyThresholdOutOfRange |
quad_extraction_policy == AdaptivePpb ⇒ refinement_mode == None (each route carries its own refinement) |
DetectorConfig::validate |
AdaptivePolicyStaticRefinement |
A further check runs per call: a Static EdLines
config with distorted intrinsics fails detect() with
EdLinesUnsupportedWithDistortion.
Fields with no runtime validation
The following fields are passed straight through without range checks, even
though the Pydantic DetectorConfig in crates/locus-py/locus/_config.py
declares explicit ranges:
quad_min_area,quad_max_aspect_ratio,quad_min_edge_scoresubpixel_refinement_sigmadecoder_min_contrast,max_hamming_errorquad_max_elongation,quad_min_densityhuber_delta_px,tikhonov_alpha_max,sigma_n_sq
The Pydantic model declares ranges for most of these. Detector(profile=…),
Detector(config=…) and locus.DetectorBuilder.build() all serialize a validated
DetectorConfig. A field assigned on an existing model after construction is not
re-validated (the model has no validate_assignment), so Rust's validate() is the
backstop for that path and for user-authored JSON handed to the Rust API.
Field scope: live, telemetry-only, inert
A config field that is accepted, stored and echoed back by Detector.config()
but never read is a contract violation in its own right — the API promises an
effect it does not deliver. crates/locus-core/tests/contract_config_inertness.rs
is the tripwire: it mutates every DetectorConfig field on a synthetic
frame set and requires the observable output (detections, rejected candidates,
poses, covariances and the binarized / threshold_map telemetry images) to
change. The field list comes from an exhaustive destructuring of the struct, so
a new field fails the build until a case is written for it.
Two categories are exempt, each with a written reason in that file's allowlist. The allowlist is two-way — an allowlisted field that starts changing output also fails the test — so neither category can drift silently.
| Field | Scope |
|---|---|
nthreads |
Live but output-invariant by design: it picks the scoped Rayon pool (LocusEngine::run_scoped). Allowlisted as inert for output; nthreads_selects_the_pipeline_pool separately proves the pool is installed. |
threshold_local_mean_radius, threshold_noise_k |
Live under threshold_mode = LocalMean only (the local-mean window and its noise-calibrated offset); ignored by TileMidExtreme. Not allowlisted: their cases switch the mode on first. |
The tile-contrast floor that used to be the threshold_min_range field is now an internal
constant (TILE_MIN_RANGE = 10 in threshold.rs). It is telemetry-only: the tile-validity
mask it drives is applied only while writing telemetry.binarized, never to the
threshold_map segmentation consumes.
4. Detector constructor (_create_detector_from_config)
Rust entry: _create_detector_from_config in crates/locus-py/src/lib.rs.
Python wrapper: Detector.__init__ in crates/locus-py/locus/__init__.py.
The config crosses the FFI as a JSON string, not a typed struct: Python
passes config.model_dump_json() to _create_detector_from_config(config_json=…),
and Rust parses it with DetectorConfig::from_profile_json.
| Invariant | Enforcement point | Error type |
|---|---|---|
families[i] in {0,…,5} (valid TagFamily discriminant: 16h5, 36h11, 4x4_50, 4x4_100, 6x6_250, ArUcoMip36h12) |
tag_family_from_i32 in lib.rs (also set_families) |
PyValueError |
| Nested config invariants (field ranges, fill-ratio ordering, cross-group compatibility) | locus._config.DetectorConfig model validators |
pydantic.ValidationError |
config_json parses (known keys only, valid enum-variant strings) |
from_profile_json serde deserialize (deny_unknown_fields) |
PyValueError (wrapped from ConfigError::ProfileParse) |
Final config passes Rust DetectorConfig::validate() |
_create_detector_from_config → from_profile_json → validated_build() |
PyValueError (wrapped from ConfigError) |
Profile-level connectivity is an invariant, not advice
The grid profile JSON sets segmentation.connectivity = "Four". Because
detector settings now come from a profile file rather than from kwargs,
mixing the grid profile with 8-connectivity is inexpressible — the user
would have to hand-edit the profile first, and at that point the setting is
theirs to own.
5. detect() vs detect_concurrent() differences
Both share the image invariants above. Behavioural differences:
| Aspect | detect() |
detect_concurrent() |
|---|---|---|
| Frame pool gating | single FrameContext |
max_concurrent_frames pool of FrameContexts |
| Telemetry returned | yes (PipelineTelemetry) |
no (documented on Detector.detect_concurrent) |
| Rejected-corner data returned | yes | no — empty arrays, built from owned detections by build_detection_result_from_owned (lib.rs) |
| GIL released | yes, for the pipeline body | yes, for the entire parallel section |
A1 should document whether callers should rely on the presence of
rejected_corners as a signal for path selection, or if the two paths should
converge.
6. Entry-point reference index
| Python entry | Rust entry | File |
|---|---|---|
Detector.__init__ |
_create_detector_from_config |
lib.rs |
Detector.detect |
Detector::detect |
lib.rs |
Detector.detect_concurrent |
Detector::detect_concurrent |
lib.rs |
BoardEstimator.estimate |
BoardEstimator::estimate |
lib.rs |
CharucoRefiner.estimate |
CharucoRefiner::estimate |
lib.rs |
CameraIntrinsics.__new__ |
CameraIntrinsics::new |
lib.rs |
| (internal) stride/padding check | prepare_image_view |
lib.rs |
| (internal) principal-point check | validate_principal_point |
lib.rs |
| (internal) image-view constructor | ImageView::new |
crates/locus-core/src/image.rs |
| (internal) config validator | DetectorConfig::validate |
crates/locus-core/src/config.rs |
7. Gaps and follow-ups
These feed the Phase A1 test matrix:
- Image: SIMD padding is satisfied transparently at
prepare_image_view(A1.2, shipped 2026-05-30):Borrowedzero-copy when the input already has ≥3 trailing bytes/row, else aPaddedowned scratch (see §1). Public API unchanged from pre-PR #287. - Config: the fields listed in §3 have no Rust range validation. Pydantic
_config.pyis the first-line gate for every Python construction path, so they reach Rust unchecked only through user-authoredfrom_profile_jsonpayloads — lift range checks into the Pydantic model where they are missing. detect()vsdetect_concurrent(): Telemetry + rejected-corner asymmetry is load-bearing for callers; document or converge.