test(properties): math, detector and optical-flow invariants (refs #87) - #109
Open
kalwalt wants to merge 5 commits into
Open
test(properties): math, detector and optical-flow invariants (refs #87)#109kalwalt wants to merge 5 commits into
kalwalt wants to merge 5 commits into
Conversation
…#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>
…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>
There was a problem hiding this comment.
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, andorb.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). |
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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. TheU8path 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.qsortis 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.medianis 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 withfill_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 wantborder >= 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_lkdoes 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:fast_corners: drop the threshold clampmath: skew the gaussian normalizationmath:medianreturns the low elementmath: flip the qsort insertion comparisonyape06: ignore the border on xoptical_flow_lk: bias tracked x by 0.02pxorb: describe every keypoint from corner 0prettier clean ·
tsc --noEmitclean ·npm test180/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_typehas 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