test: edge cases, data structures and buffer-pool coverage (refs #87, categories B + D) - #115
Conversation
…efs #87, #114) Category B of the #87 plan. 19 new cases, 180 -> 199 total (198 passing plus one expected failure). Tests only, no src/ changes. Degenerate inputs are where CV code breaks, and the rest of the suite runs on comfortable 16x16 and 96x72 images that say nothing about them. Covers 1x1, single rows and columns, odd-vs-even dimensions, kernels larger than the image, all-black/all-white, borders wider than the image, 1x1 and singular matrices, and minimum kernel sizes. Most of it confirms that invariants established elsewhere still hold at the boundary: gaussian_blur preserves a constant down to 1x1, derivatives stay exactly zero, canny finds nothing, the integral-image corner still equals the total with a zero first row and column, detectors return 0 rather than throwing, and lu_solve reports failure on a singular system while still solving a well-conditioned one. It also found a real bug, filed as #114: box_blur_gray does NOT preserve a uniform image when the image is smaller than the kernel. The rule is exact — output settles precisely at size >= 2*radius+1, verified for radius 1 to 4 — and the error is gross, up to 60% low, not a rounding artefact. A separate float-truncation defect makes radius 3 come out 1 low even on a large image, because 128*49*(1/49) = 127.999999999999986 truncates to 127. Both are bit-identical in original jsfeat, so they are inherited, and gaussian_blur has neither. Exactly the kind of defect parity testing structurally cannot find, since the oracle shares it. The broken case is pinned with `it.fails` rather than by asserting the wrong numbers, because those numbers are not stable: the window reads outside the image, so the result depends on surrounding library state — the identical call returns 85 in isolation and 142 when run after the tests above it. That instability is part of the bug and is recorded in #114. `it.fails` asserts the CORRECT behaviour and passes only because it currently fails, so fixing #114 flips it to a failure and forces a deliberate update. Verified that guard is not a no-op: making the inner assertion trivially true turns the suite red. Also characterizes invert_3x3 on a singular matrix, which silently returns NaN/+-Infinity with no signal to the caller — the same family as #102. Verified: prettier clean, tsc --noEmit clean, license-check clean, npm test 198 passed + 1 expected fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…(refs #87) Category D of the #87 plan — the coverage gaps. 34 new cases, 199 -> 233 total. Tests only, no src/ changes. An audit of what the suite actually touched found more holes than the issue listed. node_utils was entirely untested: data_t and _pool_node_t had zero references anywhere. point_t had none either. pyramid_t, keypoint_t and ransac_params_t were only ever constructed as scaffolding for algorithm tests, never asserted on. And the shared cache — which AGENTS.md makes a standing rule, "balance every get_buffer with a put_buffer" — had nothing beyond api-shape checking that get_buffer is a function. data_t: 8-byte alignment (the f64 view would throw without it), all four views aliasing one ArrayBuffer, view lengths, adopting a handed-in buffer, zeroing. matrix_t beyond parity: the typed-array view matches the type signature, allocation is zero-filled (several existing tests quietly depend on that), and resize reuses the buffer when shrinking but reallocates when the new size will not fit. pyramid_t: levels sized w>>i by h>>i, skip_first_level=false really copies the source into level 0, and a uniform image stays uniform at every level — the cheapest check that each level was written rather than left zeroed. ransac_params_t: defaults, and update_iters respects its cap, stays a non-negative integer, rises with the outlier ratio, and asks for nothing when there are no outliers. The buffer pool gets mechanics (hand out, take back, grow on demand for an oversized request, views stay consistent after a resize) plus the test that matters most: every module returns what it borrows, across 23 operations spanning imgproc, the detectors, orb, optical_flow_lk and the linalg solvers. All are balanced today. Mutation-checked by deleting a put_buffer in box_blur_gray and another in fast_corners.detect — both leaks are caught, and the failure names the offending operation. Two things checked rather than assumed. point_t is NOT on the namespace, but neither is jsfeat's, and nothing in src ever constructs one — four modules import it purely as a type annotation. So the absence is parity, not a gap, and the test now records that instead of asserting a constructor that should not exist. data_type was already covered indirectly through the core wrappers in tests/parity/structs; the new cases pin all sixteen type/channel combinations and tie the reported element size to the buffer matrix_t really allocates. Verified: prettier clean, tsc --noEmit clean, license-check clean, npm test 232 passed + 1 expected fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Pushed category D (coverage gaps) onto this branch as well, since it was still unmerged and both parts are tests-only. Retitled accordingly. 34 further cases, 199 → 233 total. Prompted by the question "is #87 actually complete?" — an audit of what the suite touched found more holes than the issue listed:
The buffer pool is the important one. AGENTS.md makes "balance every Mutation-checked, since a leak detector that cannot detect leaks is worse than none: deleting one — caught, and the message names the offending operation. Also covered: Two things I checked rather than assumed:
With this, #87 categories A, B and D are complete. Only C (third-party ground-truth fixtures) remains. Verified: prettier clean, |
…87) The "every module returns what it borrows" block did not, in fact, cover every module. Checking `grep -rl get_buffer src/` against the operations it exercised turned up three gaps: motion_estimator (ransac and lmeds each borrow three nodes), motion_model (two more inside run(), for both the homography2d and affine2d kernels), and math.get_gaussian_kernel, which borrows directly rather than only via the blur paths already covered. All balanced today. Mutation-checked like the rest: dropping a put_buffer in each of the three is caught, and the failure names the operation. Also documented what that block is actually doing, since exercising imgproc and the detectors inside cache.test.ts reads oddly at first glance. Those calls are the subject matter, not the system under test — nothing about their output is asserted, only that the pool's free count is unchanged. The rule belongs to the pool rather than to any one module, so it lives in one place where the coverage is visible and a newly added module is obviously absent. The comment now names the grep that produced the list, so the next person has a mechanical way to re-check it. Verified: prettier clean, tsc --noEmit clean, license-check clean, npm test 234 passed + 1 expected fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…g it (refs #87, #114) The test was named "is correct once the image is at least as large as the kernel" but checked exactly one square size per radius — four configurations in total. That is a claim about a whole region verified at four points, and it would have passed even if every size above the boundary were broken. Now sweeps cols and rows independently from the kernel size upward, 196 combinations across radii 1 to 4, and collects offenders so a failure names every bad pair rather than aborting at the first. Also corrects the region itself. The reliable zone is BOTH cols >= 2r+1 AND rows >= 2r+1, not min(cols, rows) >= 2r+1 as #114 originally claimed — the two passes run along cols and then rows, and either being shorter than the window breaks it. A 2x50 image at radius 1 comes out correct while 2x4 does not, though both have cols = 2. Below the zone the behaviour is erratic rather than uniformly wrong, some pairs landing on the right value by coincidence, which is why the failing side stays pinned with it.fails. Issue #114 has been corrected accordingly. Mutation-checked, and the check is meaningful rather than decorative: making box_blur_gray wrong for non-square input only (radius += 1 when cols !== rows) is caught with 63 named offenders, while the previous square-only version could not have detected it at all. Worth recording the other half of that experiment: an off-by-one in the second-pass loop bound was NOT caught, by either version. It shifts which loop phase handles a pixel without changing the value for a uniform image, so this whole family of uniform-input invariants is blind to it. Cheap and broad, but not a substitute for the golden fixtures in category C. Verified: prettier clean, tsc --noEmit clean, license-check clean, npm test 234 passed + 1 expected fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…#87) Category C1 of the #87 plan. 18 new cases, 229 -> 247 total. Tests and docs only, no src/ changes. This is the first part of the suite that pins actual output values. Parity compares against a vendored jsfeat that shares any inherited defect, and the invariant tests constrain answers without fixing them — #115 showed an off-by-one in a loop bound surviving both, because it changed which code path produced a pixel without changing the value for a uniform image. Two complementary kinds, in tests/reference/: Reference implementations (imgproc.test.ts). Naive, obvious versions of box_blur, sobel, scharr and the integral image, written from each operation's definition and compared on non-uniform input. All match BIT-EXACTLY, so no tolerances are involved anywhere. Closed-form values (known-values.test.ts). Where a reference implementation cannot help because the same misunderstanding would sit on both sides: the binomial kernels asserted to the bit, jsfeat's integer luma constants checked against real-valued BT.601 rather than against themselves, and inputs CONSTRUCTED from known answers — A = U diag(w) V^T requiring SVD to return the chosen singular values, and a chosen homography requiring homography2d to recover it. Three findings worth recording. Sobel and scharr use ASYMMETRIC border handling: reflect vertically (BORDER_REFLECT_101), replicate horizontally. Assuming replication in both directions makes the interior match exactly (352/352) and nearly every border pixel disagree (35/80) — a failure that reads like a wrong kernel when the kernel is fine. Now pinned by its own test. get_gaussian_kernel size 7 is [2,7,14,18,14,7,2]/64, NOT Pascal's [1,6,15,20,15,6,1]/64, though sizes 3 and 5 are the binomial rows. Flatter peak, heavier tails. Pinned so a rewrite cannot "correct" it and silently change every 7-tap blur. And a flaw in my own first draft. Re-running the #115 off-by-one against it: the ground-truth test STILL missed it. At 23x17 that mutation perturbs only radius 3, by exactly 1 — the slack a ±1 tolerance was allowing for the known truncation defect. Fixed by sweeping five shapes and by MODELLING the defect instead of tolerating it: a reference variant scales by the float reciprocal exactly as the library does, so comparison is exact at every radius, and the truncation is asserted separately as "differs from exact division only at radius 3, and only by 1". Now caught. A tolerance wide enough to absorb a known defect is wide enough to hide an unknown one. Also adds docs/implementation-notes.md, collecting the conventions and quirks established across this work: border conventions, fixed-point and rounding behaviour, the known defects (#102, #110, #111, #114) with the measurements behind them, behaviour that looks wrong but is not, data-structure gotchas, and notes on what each layer of the test suite can and cannot catch. Verified: prettier clean, tsc --noEmit clean, license-check clean, npm test 246 passed + 1 expected fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Category B of the #87 plan (edge/boundary inputs). 19 new cases, 180 → 199 total (198 passing + 1 expected failure). Tests only, no
src/changes.Follows #106 (linalg + matmath), #108 (imgproc), #109 (math + detectors + optical flow) which completed category A.
Why
Degenerate inputs are where CV code breaks, and the rest of the suite runs on comfortable 16×16 and 96×72 images that say nothing about them. This covers 1×1, single rows and columns, odd-vs-even dimensions, kernels larger than the image, all-black/all-white, borders wider than the image, 1×1 and singular matrices, and minimum kernel sizes.
It found a real bug — #114
box_blur_graydoes not preserve a uniform image when the image is smaller than the kernel. A blur is an average, so a constant image must round-trip at any size. It doesn't:[85, 85][51, 128][128, 128]The rule is exact — output settles precisely at
size >= 2*radius + 1, verified for radius 1–4. A separate float-truncation defect makes radius 3 come out 1 low even on a 32×32 image, because128 * 49 * (1/49) = 127.999999999999986truncates to 127.Both are bit-identical in original jsfeat, so they are inherited rather than jsfeatNext regressions, and
gaussian_blurhas neither — it is correct from 1×1 upward. This is precisely the class of defect parity testing structurally cannot find, since the oracle shares it.A follow-up posted on #114: the wrong output is not deterministic. The identical 1×1 radius-1 call returns
85in isolation and142when run after the other cases in the same file, because the window reads outside the image and picks up unrelated state.How the bug is pinned
With
it.fails, not by asserting the wrong numbers — those aren't stable, per the above. The body asserts the correct behaviour and the test passes only because that assertion currently fails, so fixing #114 flips it to a failure and forces a deliberate update.Verified that guard is not a no-op: making the inner assertion trivially true turns the suite red.
What else is covered
Everything below confirms an invariant still holds at the boundary:
gaussian_blurpreserves a constant at 1×1, 1×8, 8×1, 2×2, 3×3, 5×4, 4×5 for kernels 3/5/7cannyfinds nothingequalize_histogrammaps a constant to a single value rather than inventing structurelu_solvereports failure on a singular system and still solves a well-conditioned one (so the 0 is a meaningful signal, not "always fails")svd_inverthandles 1×1; size-1 gaussian kernel is the identity; even-size kernels stay normalized and symmetricPlus one more characterization:
invert_3x3on a singular matrix silently returnsNaN/±Infinitywith no signal to the caller — the same family as #102.Verification
prettier clean ·
tsc --noEmitclean ·license-checkclean ·npm test198 passed + 1 expected fail.Remaining #87 scope
Category A and B are now done. Still open: C third-party ground-truth fixtures, D coverage gaps (
data_typehas no dedicated test). As discussed, C is the natural point to revisit the empirically-characterised bounds in the earlier phases with tighter assertions.🤖 Generated with Claude Code