Skip to content

test(properties): math, detector and optical-flow invariants (refs #87) - #109

Open
kalwalt wants to merge 5 commits into
devfrom
test/87-invariants-phase3
Open

test(properties): math, detector and optical-flow invariants (refs #87)#109
kalwalt wants to merge 5 commits into
devfrom
test/87-invariants-phase3

Conversation

@kalwalt

@kalwalt kalwalt commented Jul 29, 2026

Copy link
Copy Markdown
Member

Phase 3 of the #87 plan, completing category A (property/invariant tests). 47 new tests, 133 → 180 total. Tests only, no src/ changes.

Refs #87. Follows #106 (linalg + matmath) and #108 (imgproc).

Why these modules matter most

For the detectors the parity suite can only say "the same corners as jsfeat" — and for the detectors still to be ported (haar/bbf, #43/#44) there is no oracle at all. Invariants are the only correctness signal that survives either situation.

What is covered

math — Gaussian kernels are normalized, non-negative, symmetric, unimodal, monotonically decreasing away from the centre, and flatten as sigma grows. The U8 path sums to 256 within the per-tap rounding bound (size/2) and to exactly 256 on the fixed binomial path, where the weights are exact binary fractions. qsort is verified as a sorted permutation — idempotent, correct on already-sorted/reversed/all-equal input, honouring a custom comparator, and touching only the requested sub-range. median is pinned against a sorted copy across odd and even lengths, shown order-independent, and confirmed to return a value actually present in the input.

detectors — a structureless image yields nothing (always paired with a counter-case, so a detector that always returned nothing could not pass); results respect the requested border; detections are equivariant under translation and invariant under a global brightness offset. Both hold bit-exactly, so they are asserted as exact set equality rather than with a tolerance.

optical_flow_lk — zero motion between identical frames (bit-exact), convergence back to zero from a deliberately wrong seed, recovery of small known translations, rejection of every point in a textureless image, and non-mutation of the input coordinates.

To make translation meaningful, the new cornerScene() helper samples one generator at (x - dx, y - dy) — translating the background too, rather than sliding shapes over a static one — and caps intensities at 200 so the brightness offset cannot saturate and void the premise.

Two findings worth recording

1. ORB is exactly brightness-invariant only when the whole patch is inside the image. rectify_patch() warps with fill_value = 128, and that constant does not shift with image brightness, so patches crossing the edge flip a few bits. Measured: border 8 → 2 bits of 8960; border ≥ 16 → exactly 0. Both are asserted, the second as a documented caveat — callers needing exposure-robust descriptors want border >= 16. Paired with a distinctness check (different keypoints sit ≥ 47 bits apart, mean 108.8/256) so the invariance assertions cannot pass vacuously.

2. optical_flow_lk does not recover large displacements at these settings. Small shifts come back to ~0.01px, but a 5px shift of this scene at 2 levels with a 9px window is off by tens of pixels. That is a local gradient method behaving as designed, not a defect — so the accuracy assertions stay inside that regime and the limitation is documented in a comment rather than asserted as a failure.

FAST/yape06 count monotonicity is asserted as the issue asks, but flagged in a comment as a structural trend rather than a theorem: 3×3 non-maximum suppression could in principle let a suppressed corner survive once a stronger neighbour drops out.

Verification

Every behaviour was characterised against the library before being asserted; the ORB border effect was predicted from the fill value and then confirmed. Mutation testing proves the tests bite — all seven injected bugs are caught (1–4 failing tests each), with src/ restored and re-verified after each:

Injected bug Result
fast_corners: drop the threshold clamp caught (1)
math: skew the gaussian normalization caught (1)
math: median returns the low element caught (3)
math: flip the qsort insertion comparison caught (4)
yape06: ignore the border on x caught (1)
optical_flow_lk: bias tracked x by 0.02px caught (2)
orb: describe every keypoint from corner 0 caught (1)

prettier clean · tsc --noEmit clean · npm test 180/180.

Remaining #87 scope

Category A is now complete. Still open: B edge/boundary inputs, C third-party ground-truth fixtures, D coverage gaps (data_type has no dedicated test). As discussed, the golden-fixture work in C is the natural point to revisit these and the earlier phases with tighter assertions.

🤖 Generated with Claude Code

…#87)

Phase 3 of the #87 plan, completing category A. 47 new tests, 133 -> 180
total. Tests only, no src/ changes.

This is where invariant testing pays off most: for the detectors the parity
suite can only say "the same corners as jsfeat", and for future detectors
with no oracle at all (haar/bbf, #43/#44) that option disappears entirely.

math: gaussian kernels are normalized, non-negative, symmetric, unimodal,
monotonically decreasing away from the centre, and flatten as sigma grows;
the U8 path sums to 256 within the per-tap rounding bound (size/2) and to
EXACTLY 256 on the fixed binomial path, where the weights are exact binary
fractions. qsort is verified as a sorted permutation — idempotent, correct on
already-sorted/reversed/all-equal input, honouring a custom comparator, and
touching only the requested sub-range. median is pinned against a sorted
copy across odd and even lengths, shown order-independent, and confirmed to
return a value actually present in the input.

detectors: a structureless image yields nothing, always paired with a
counter-case so a detector that always returned nothing could not pass;
results respect the requested border; detections are EQUIVARIANT under
translation and INVARIANT under a global brightness offset. Both hold
bit-exactly, so they are asserted as exact set equality rather than with a
tolerance. To make translation meaningful the new cornerScene() helper
samples one generator at (x-dx, y-dy), translating the background too rather
than sliding shapes over a static one, and caps intensities at 200 so the
brightness offset cannot saturate and void the premise.

Two findings worth recording:

1. ORB is exactly brightness-invariant only when the whole 32px patch lies
   inside the image. rectify_patch() warps with fill_value = 128, and that
   constant does not shift with image brightness, so patches crossing the
   edge flip a few bits. Measured: border 8 -> 2 bits of 8960; border >= 16
   -> exactly 0. Both are asserted, the second as a documented caveat —
   callers needing exposure-robust descriptors want border >= 16. Paired with
   a distinctness check (different keypoints sit >= 47 bits apart, mean
   108.8/256) so the invariance assertions cannot pass vacuously.

2. optical_flow_lk recovers small displacements to ~0.01px but does NOT
   recover a 5px shift of this scene at 2 levels with a 9px window (errors of
   tens of pixels). That is a local gradient method behaving as designed, not
   a defect, so the accuracy assertions stay inside that regime and the scope
   is documented rather than asserted as a failure. The zero-motion case is
   bit-exact, and a companion test seeds curr_xy 1px off to show the
   iteration truly refines instead of passing the seed through.

FAST/yape06 count monotonicity is asserted as the issue asks, but flagged in
a comment as a structural trend rather than a theorem: 3x3 non-maximum
suppression could in principle let a suppressed corner survive once a
stronger neighbour drops out.

Every behaviour was characterised against the library before being asserted;
the ORB border effect was predicted from the fill value and then confirmed.
Mutation testing proves the tests bite: dropping the fast_corners threshold
clamp, skewing the gaussian normalization, making median return the low
element, flipping the qsort insertion comparison, ignoring yape06's border,
biasing tracked x by 0.02px, and describing every keypoint from corner 0 are
all caught (1-4 failures each); src/ restored and re-verified after each.

Verified: prettier clean, tsc --noEmit clean, npm test 180/180.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kalwalt kalwalt added this to the Parity & Modernization milestone Jul 29, 2026
@kalwalt kalwalt self-assigned this Jul 30, 2026
…87)

Both comments stated a conclusion I had not actually derived. Measured now.

ORB: the required margin is not "half the 32px patch". Only the 256 sampled
pairs are read, and bit_pattern_31's largest coordinate component is 13, so
the furthest sample sits 13*sqrt(2) = 18.39px from the keypoint; rotation
preserves that radius, giving a guaranteed-safe margin of 20 with the
bilinear neighbour. The invariance test now uses border 20 instead of 16,
which was an undersized number that happened to pass on this scene. Sweeping
one keypoint over 2000 angles puts the empirical onset of contamination at
distance <= 16, clean from 17, so 20 carries slack rather than being tight.

optical_flow_lk: the scope note credited pyramid levels alone for the
displacement ceiling; window size matters at least as much. On a 320x240
scene, 2 levels with a 15px window recovers a 5px shift for every point,
and 4 levels with a 9px window recovers 10px for 28 of 29 points — versus
the 96x72 / 2 level / 9px configuration where 5px does not converge at all.
Rewritten to describe the ceiling as a function of both knobs.

Verified: prettier clean, tsc --noEmit clean, npm test 180/180.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The test pins behaviour that is now tracked as a known limitation, so cite
the issue the way tests/divergences.test.ts cites its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds Phase 3 of issue #87’s property/invariant test plan, extending the Vitest suite with oracle-independent checks for core math, detectors/ORB, and Lucas–Kanade optical flow—without changing any src/ runtime code.

Changes:

  • Added invariants for math (get_gaussian_kernel, qsort, median).
  • Added detector/descriptor invariants for fast_corners, yape06, yape, and orb.describe.
  • Added optical-flow invariants for optical_flow_lk.track, plus new shared property-test helpers (scene generator, hamming distance).

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
tests/properties/optical_flow_lk.test.ts New LK optical-flow invariant/property tests.
tests/properties/math.test.ts New oracle-independent invariants for Gaussian kernels, sorting, and medians.
tests/properties/helpers.ts Adds cornerScene, keypointPool, and hammingDistance for property tests.
tests/properties/detectors.test.ts New invariants for detectors and ORB descriptor behavior (translation/brightness properties, borders, determinism).

Comment thread tests/properties/detectors.test.ts
Comment thread tests/properties/optical_flow_lk.test.ts Outdated
Comment thread tests/properties/optical_flow_lk.test.ts Outdated
…d behaviour (refs #87)

All three findings from Copilot's review of #109 verified against the source
and fixed.

yape: src/jsfeatNext.ts line 61 is `jsfeatNext.yape = new yape()`, so yape is
a singleton like every other module since #41. Importing the class to call
`new yape()` was testing a path AGENTS.md tells callers not to take. Now uses
jsfeatNext.yape and the class import is gone.

optical_flow_lk: the "converges back from a wrong starting guess" test could
not demonstrate what its comment claimed. At the coarsest level track() does
`next_x = prev_x`, discarding curr_xy, so the seed never influences anything
and the test could not tell "refines" from "ignores". Confirmed empirically:
seeds of zeros, a copy of prev_xy, +1, +50 and -9999 all produce BIT-IDENTICAL
output.

Replaced it with the invariant that is actually true and actually useful —
the result is independent of the initial curr_xy contents — asserted across
those five seeds and labelled as characterization rather than endorsement. A
bad guess can never corrupt the result, but a good prediction can never help.
Mutation-checked: making the coarsest level honour the seed fails the new test.

The track() helper now seeds curr_xy from prev_xy per the documented contract
instead of zeros. That is a no-op today precisely because the seed is ignored,
but it keeps these tests correct if that ever changes.

Note for the PR: the TSDoc tells callers to "seed it with a prediction or a
copy of prev_xy", which the implementation does not honour. Raising separately
rather than changing behaviour here.

Verified: prettier clean, tsc --noEmit clean, npm test 180/180.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The comments predated #41 and described an API shape that no longer exists.
The file header claimed the detectors are "INSTANCE classes in jsfeatNext",
and the yape case claimed jsfeatNext's yape "is a class to instantiate" —
both true before 0.9.0, neither true since: every algorithm module is now a
singleton instance on the namespace.

The practical consequence is that this was no longer an Axis 2 divergence at
all. Both sides are called identically now, so the note is rewritten to say
that #41 closed it rather than that it exists.

Switched `new yape()` to `jsfeatNext.yape` so the parity test compares the
two public surfaces against each other, which is what a parity test is for,
and removed the now-unused class import. Behaviour-preserving: the parity
assertions still pass unchanged against the vendored oracle.

Verified: prettier clean, tsc --noEmit clean, npm test 180/180.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants