Skip to content

test: edge cases, data structures and buffer-pool coverage (refs #87, categories B + D) - #115

Merged
kalwalt merged 4 commits into
devfrom
test/87-edge-cases
Aug 1, 2026
Merged

test: edge cases, data structures and buffer-pool coverage (refs #87, categories B + D)#115
kalwalt merged 4 commits into
devfrom
test/87-edge-cases

Conversation

@kalwalt

@kalwalt kalwalt commented Aug 1, 2026

Copy link
Copy Markdown
Member

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_gray does 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:

image radius output
1×1 1 [85, 85] wrong
4×4 2 [51, 128] 60% low
5×5 2 [128, 128] correct

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, because 128 * 49 * (1/49) = 127.999999999999986 truncates to 127.

Both are bit-identical in original jsfeat, so they are inherited rather than jsfeatNext regressions, and gaussian_blur has 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 85 in isolation and 142 when 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_blur preserves a constant at 1×1, 1×8, 8×1, 2×2, 3×3, 5×4, 4×5 for kernels 3/5/7
  • sobel and scharr derivatives stay exactly zero; canny finds nothing
  • equalize_histogram maps a constant to a single value rather than inventing structure
  • integral-image corner equals the total, first row and column zero, at every degenerate shape
  • all-black and all-white survive a blur unchanged, derivatives zero
  • detectors return 0 rather than throwing on tiny images and on borders wider than the image
  • lu_solve reports failure on a singular system and still solves a well-conditioned one (so the 0 is a meaningful signal, not "always fails")
  • svd_invert handles 1×1; size-1 gaussian kernel is the identity; even-size kernels stay normalized and symmetric

Plus one more characterization: invert_3x3 on a singular matrix silently returns NaN/±Infinity with no signal to the caller — the same family as #102.

Verification

prettier clean · tsc --noEmit clean · license-check clean · npm test 198 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_type has 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

…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>
@kalwalt kalwalt added this to the 1.0.0 milestone Aug 1, 2026
…(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>
@kalwalt kalwalt changed the title test(edge-cases): boundary-input tests, finding a box_blur bug (refs #87) test: edge cases, data structures and buffer-pool coverage (refs #87, categories B + D) Aug 1, 2026
@kalwalt

kalwalt commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

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:

Component Before
node_utils/data_t zero references anywhere
node_utils/_pool_node_t zero
point_t zero
pyramid_t, keypoint_t, ransac_params_t constructed as scaffolding, never asserted on
cache (shared pool) api-shape only: typeof get_buffer === "function"

The buffer pool is the important one. AGENTS.md makes "balance every get_buffer with a put_buffer" a standing rule, every algorithm borrows from this one pool, and an imbalance stays invisible until the pool drains and an unrelated module starts reading someone else's scratch memory. There is now a test holding every module to that rule across 23 operations — imgproc, the detectors, orb, optical_flow_lk, the linalg solvers. All balanced today.

Mutation-checked, since a leak detector that cannot detect leaks is worse than none: deleting one put_buffer in box_blur_gray and another in fast_corners.detect produces

AssertionError: expected 'box_blur_gray: 29' to be 'box_blur_gray: 30'
AssertionError: expected 'fast_corners.detect: 28' to be 'fast_corners.detect: 29'

— caught, and the message names the offending operation.

Also covered: data_t's 8-byte alignment (the f64 view would throw without it) and its four views aliasing one buffer; matrix_t's view selection, zero-fill, and the reuse-vs-reallocate rule in resize; pyramid_t level sizing and that a uniform image stays uniform at every level; ransac_params_t.update_iters respecting its cap and rising with the outlier ratio.

Two things I checked rather than assumed:

  1. 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. The test now records that instead of asserting a constructor that shouldn't exist.
  2. data_type was already covered indirectly via 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 actually allocates.

With this, #87 categories A, B and D are complete. Only C (third-party ground-truth fixtures) remains.

Verified: prettier clean, tsc --noEmit clean, license-check clean, npm test 232 passed + 1 expected fail.

@kalwalt kalwalt self-assigned this Aug 1, 2026
@kalwalt kalwalt added enhancement New feature or request tests CI/CD labels Aug 1, 2026
…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>
@kalwalt
kalwalt merged commit 7ce000f into dev Aug 1, 2026
4 checks passed
kalwalt added a commit that referenced this pull request Aug 2, 2026
…#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>
@kalwalt
kalwalt deleted the test/87-edge-cases branch August 3, 2026 22:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CI/CD enhancement New feature or request tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant