Repository navigation
Conversation
The 5-tap radius-2 boundary reflection helper used by the integer motion feature was independently duplicated across four CPU implementations (scalar, AVX2, AVX512, NEON), all sharing the same single-bounce formula that reads out-of-bounds indices when width or height is less than 3 (Netflix#1580). Replace the four local copies with one shared, loop-based reflect-101 implementation in integer_motion.h, which all four TUs already include. Named motion_mirror rather than mirror to avoid colliding with the unrelated __device__ mirror() in cuda/motion_score.cu, which also includes this header transitively and implements different boundary semantics that are out of scope here. For size >= 3 with the offsets these callers ever pass (radius 2), the new implementation takes at most one reflection and is bit-identical to the old formula; only sizes below 3 change behavior, from out-of-bounds reads to well-defined reflect-101 indices. Also removes edge_16(), an unused dead-code helper in the same header that shared the old broken formula and had no callers.
Exhaustively checks that motion_mirror matches the old single-bounce formula for every size in [3, 8192] and every offset in the 5-tap radius-2 call contract, proving the consolidation changed no output for normal frame sizes. Also pins down the exact reflect-101 values for size 1 and 2, the cases the fix specifically targets.
The CUDA device-side mirror() in motion_score.cu has the same class of out-of-bounds bug as the CPU implementation for degenerate 1-pixel dimensions, but with a different (reflect-even) formula. Add a minimal guard so sup==1 maps every index to 0, without altering the existing reflect-even behavior for sup>=2. Also extends test_motion_mirror.c with a host-side transcription of both the pre- and post-fix CUDA formulas, proving the fix is a no-op for sup>=2 and degenerates safely for sup==1. This does not compile or link any CUDA code.
…ilter The float convolution path shared by ADM, VIF and float motion assumed width and height are at least filter_width, and misbehaved below that. convolution_internal.h reflected boundary taps with a single bounce, which still lands outside [0, size) once size is smaller than the filter radius. Replace it with convolution_mirror(), a repeated whole-sample symmetric reflection that is safe for any size >= 1 and bit-identical to the previous formula for size >= radius + 1. convolution.c and convolution_avx.c additionally computed negative edge-loop bounds for such sizes, so the trailing edge loops started at a negative index and wrote before the start of dst/tmp. Clamp those bounds. In convolution_avx.c the clamped horizontal trailing start is kept in a separate variable so that the j_end argument of convolution_f32_avx_s_1d_h_scanline stays unraised; raising it would push the scanline's own vector stores past the end of dst. All clamps are no-ops for sizes at or above the filter width, and for the sizes in between they only drop redundant writes of identical values, so the computed output is unchanged.
Exhaustively checks that convolution_mirror matches the single-bounce reflection it replaces for every size in [3, 8192] and every offset in the 5-tap radius-2 call contract, proving the float convolution path produces identical output for normal frame sizes.
The comment claimed the vertical trailing loop's start is i_vec_end's only other use. It is not: i_vec_end is also the end bound of the vector loop. State the actual reason the in-place raise is safe, so the comment still guards against a maintainer collapsing the clamp into a bug. Comment-only change; no behavior change.
The sizes below the 5-tap filter's radius were excluded because the single-bounce mirror() went out of bounds there, making both the scalar and the SIMD score meaningless. Now that the reflection is safe for any size >= 1, those sizes must agree bit-exactly like every other size.
Drive both the integer and the float motion feature extractor end to end at 1x1 through 8x2, for 8, 10, 12 and 16 bpc and for both the scalar and the SIMD dispatch. There is no meaningful reference score at these sizes, so what is asserted is that extraction succeeds, the score is finite and two identical runs agree bit for bit; the memory-safety half of the check comes from running the same binary under AddressSanitizer. The float sub-test skips itself when the library is built with -Denable_float=false, in which case the extractor is not registered.
convolution_f32_c_s() delegates to the AVX kernel and returns as soon as AVX2 is available, so driving the float motion extractor with the CPU flags left at their default only reached convolution_f32_avx_s() on an x86 host. The scalar convolution_y_c_s()/convolution_x_c_s() pair went unexercised there, which is precisely where the boundary reflection had to be corrected. Run the float sizes under both a zeroed and an unrestricted CPU mask, as the integer test already did, and share the mask table between the two.
motion_mirror() and convolution_mirror() special-cased size == 1, where the reflection period 2*(size-1) is zero and the loop would not terminate. Widen the guard to size <= 1 so the functions are total rather than merely correct over their call contract. Both helpers are only ever reached from loops bounded by the same size (for i < h / for j < w in integer_motion.c and the AVX2/AVX-512 motion kernels, and the clamped border loops in convolution.c / convolution_avx.c), so size is always >= 1 in practice and no reachable result changes.
The tiny-size pipeline tests ran the extractor twice per CPU mask and only asserted the two runs agreed, which proves determinism but never compares one dispatch against another. The x86 AVX2/AVX-512 motion kernels therefore had no runtime verification at all; upstream CI runs meson test on an x86_64 runner, so a cross-mask comparison is the one place those kernels get exercised. Capture the cpu_masks[0] (mask 0, forced scalar) score for each (size, bpc) and assert every other mask reproduces it. The integer path is compared exactly, as test_motion_neon.c already does for NEON. The float path is compared with a relative tolerance instead: convolution_avx.c is built with -mfma while convolution.c is not, so the compiler contracts the shared convolution_edge_s() accumulation into fused multiply-adds in the AVX translation unit only and the two results legitimately differ in the last bits. A boundary regression moves the score by O(1) and is still caught.
The border clamps in convolution.c and convolution_avx.c fix an out-of-bounds write, not just an out-of-bounds read: below the filter width the unclamped borders_left/borders_right (and the AVX i_vec_end/j_vec_end) went negative and the trailing edge loop stored outside dst. Only a sanitizer would have caught a regression there, and no sanitizer job exists in CI, so a later "simplification" back to the buggy form would leave CI green. Surround every destination buffer with guard elements holding a sentinel and assert the sentinel survives. The scalar test covers convolution_x_c_s() and convolution_y_c_s() over dimensions 1..4 and runs on every host. The AVX test covers convolution_f32_avx_s(), _sq_s() and _xy_s(), guarding both dst and the caller-supplied tmp scratch, and is compiled only under ARCH_X86 so it is inert elsewhere. Verified against the pre-fix code by removing each clamp in turn: the scalar test reports "convolution_x_c_s wrote before dst" and "convolution_y_c_s wrote before dst" respectively.
…holes Three HIGH findings from an independent adversarial review of this harvest. Each was reproduced before being fixed, and the regression test fails on the pre-fix tree. 1. The Netflix/vmaf#1582 border clamp landed only on the scalar path. convolution_f32_c_s dispatches straight into convolution_f32_avx_s whenever AVX2 is present — every CI runner and the dev workstation — so the clamp this PR added was dead code on x86. The AVX2 and AVX-512 twins derive the same `height - radius` split at three sites each and kept it unclamped. For a plane shorter than the radius that value is negative, so the trailing border loop starts at a negative row and the leading one runs past the end. Both are heap WRITES, not reads. All six sites now share the scalar clamp, which moved into convolution_internal.h as a static inline. 2. motion_filter_size=1 bypassed the minimum-dimension guard entirely. motion_check_min_dim gated its check on `effective_filter_size > 1`, but motion_blur_plane keeps filter_size = 5 for that value and only swaps in the FILTER_5_NO_OP_s coefficients, so the radius is still 2. A 1-row plane therefore reached the convolution in (1) through a documented public option with range 0..9. The guard now mirrors motion_blur_plane exactly: 3 taps only for motion_filter_size == 3, otherwise 5. 3. Odd-height 4:2:0 chroma planes were under-allocated by one row. motion_chroma_heights used `h / 2` while picture.c and the guard both use the ceiling `(h + 1) >> 1`, so motion_copy_and_blur overran ref, tmp and every MOTION_BLUR_RING blur buffer for both U and V. Even heights were unaffected, which is why neither golden fixture caught it. Also removes a stray `} // namespace` inside the _WIN32 block of core/tools/vmaf.cpp that closed a namespace never opened. It broke every Windows build and was invisible on Linux, where the preprocessor drops the block. This PR is a draft and drafts run no CI here, so nothing had compiled it. The file now has exactly one namespace opener and one closer, neither inside any conditional. New test core/test/test_motion_convolution_oob.c drives float_motion through the public vmaf_read_pictures entry point, because no existing test could reach the dispatched SIMD path: test_motion_min_dim only calls init(), and test_convolution_edge_small calls the scalar kernels directly. Verified both ways. The new test fails on the pre-fix tree and passes after. Under -Db_sanitize=address the pre-fix tree reports "heap-buffer-overflow ... WRITE of size 4" in convolution_f32_avx_s reached from vmaf_read_pictures, "0 bytes after 32-byte region" — the single-row buffer. Post-fix: zero sanitizer reports, the guard returns -EINVAL, and the odd-height case scores cleanly. meson test --suite=fast: 111 Ok, 0 Fail. Netflix golden gate: 271 passed, 12 skipped, 0 failed. The twelve MEDIUM and LOW findings from the same review are not addressed here and remain open on the PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ADR-1166) Verify a batch of long-open Netflix/vmaf issues against this tree — the fork diverged far enough (ADR-0700's `libvmaf/` -> `core/` rename, several C-to-C++ conversions, four fork-added GPU backends) that an upstream report is neither automatically applicable nor automatically stale — and fix the subset that still bites. The full triage table, including the ALREADY-FIXED and NOT-APPLICABLE verdicts, is in docs/research/1166-upstream-issue-harvest-2026-09-03.md. Memory safety, all reachable from the public C API today: * reported upstream as Netflix/vmaf#1582 (mirror half also Netflix/vmaf#1581): the reflect-101 fold in convolution_edge_s / _sq_s / _xy_s bounced an out-of-range tap exactly once, which only lands in range for size >= radius + 1; and convolution_x_c_s / convolution_y_c_s derived the trailing border bound as dim - (filter_width - radius), which goes negative for a plane narrower than the filter and starts the trailing loop at a negative index (heap underflow write). Two live paths reached those sizes: `--feature float_vif` on 9..15px frames (the guard admitted >= 9 but the four-scale ladder needs >= 16 — the binding constraint is scale 3), and `--feature float_motion` with motion_add_uv on a 4x4 YUV420P frame (the guard validated luma only while the blur runs at the 2x2 chroma dimensions). The fold is now iterative and bit-identical to the single bounce for every in-contract size; the borders are clamped; float_vif derives its floor from vif_get_min_dim(kernelscale); float_motion validates every plane it convolves. * reported upstream as Netflix/vmaf#1580: the three fork-added Metal motion extractors were written after the Research-0094 sweep and never got the min-dim guard, so a 1- or 2-pixel-tall frame read out of bounds on device. Correctness and contracts: * reported upstream as Netflix/vmaf#1242: vmaf_model_feature_overload() leaked the caller's dictionary on the -ENOMEM path, vmaf_model_collection_feature_overload() swallowed the copy error and dereferenced *model_collection unchecked, and feature.h / model.h documented opposite ownership rules — one of the two readings a latent double free. All three public headers now state the implemented contract identically. Supersedes ADR-0806. * reported upstream as Netflix/vmaf#1551, which retracts Netflix/vmaf#1422: the MSVC __builtin_clz shim used __lzcnt, which emits LZCNT with no runtime gate and silently retires as BSR on any x86-64 without ABM — wrong VIF and ADM log2 shifts, no fault, and invisible to CI because every hosted Windows runner has LZCNT. Now _BitScanReverse, with an architecture guard so an MSVC ARM64 leg compiles. User-visible surfaces: * reported upstream as Netflix/vmaf#743: the CLI wrote UTF-8 braille and a CSI erase to a Windows console it never configured, so the progress line was mojibake under every default code page. The console is switched to UTF-8 + VT for the run and restored on exit, with an ASCII fallback. * reported upstream as Netflix/vmaf#1178: libvmaf.pc omitted the C++ runtime, so `pkg-config --static --libs libvmaf` produced a link line that fails with hundreds of undefined references — the reason ADR-0198's static FFmpeg reproducer had to add -lstdc++ by hand. * reported upstream as Netflix/vmaf#1573: the nvcc fatbin include list used relative paths that stopped resolving at ADR-0700, and three shell-driven tool tests declared no `depends`, so a subset run built nothing and exited 127. Behaviour changes: float_vif now rejects frames below 16px in either dimension, and float_motion with motion_add_uv rejects sub-minimum chroma planes. Both convert previously undefined behaviour into a documented -EINVAL. Regression tests: core/test/test_convolution_edge_small.c (NaN-poisoned guard buffers; fails pre-fix), core/test/test_compat_clz.c, core/test/test_model_feature_overload_ownership.c, core/test/test_spinner.cpp, scripts/ci/check-msvc-clz-shim.sh (fails pre-fix), plus extended cases in test_motion_min_dim.c and test_float_vif_min_dim.c, and a real static link in the libvmaf-build-matrix pkg-config step. Netflix golden scores unchanged: 76.66744 / 35.070245 / 7.985956 (271 passed, 12 skipped). Confirmed but not batched, one docs/state.md row each: Netflix/vmaf#1564, #930, off-by-one found while triaging Netflix/vmaf#1580. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…holes Three HIGH findings from an independent adversarial review of this harvest. Each was reproduced before being fixed, and the regression test fails on the pre-fix tree. 1. The Netflix/vmaf#1582 border clamp landed only on the scalar path. convolution_f32_c_s dispatches straight into convolution_f32_avx_s whenever AVX2 is present — every CI runner and the dev workstation — so the clamp this PR added was dead code on x86. The AVX2 and AVX-512 twins derive the same `height - radius` split at three sites each and kept it unclamped. For a plane shorter than the radius that value is negative, so the trailing border loop starts at a negative row and the leading one runs past the end. Both are heap WRITES, not reads. All six sites now share the scalar clamp, which moved into convolution_internal.h as a static inline. 2. motion_filter_size=1 bypassed the minimum-dimension guard entirely. motion_check_min_dim gated its check on `effective_filter_size > 1`, but motion_blur_plane keeps filter_size = 5 for that value and only swaps in the FILTER_5_NO_OP_s coefficients, so the radius is still 2. A 1-row plane therefore reached the convolution in (1) through a documented public option with range 0..9. The guard now mirrors motion_blur_plane exactly: 3 taps only for motion_filter_size == 3, otherwise 5. 3. Odd-height 4:2:0 chroma planes were under-allocated by one row. motion_chroma_heights used `h / 2` while picture.c and the guard both use the ceiling `(h + 1) >> 1`, so motion_copy_and_blur overran ref, tmp and every MOTION_BLUR_RING blur buffer for both U and V. Even heights were unaffected, which is why neither golden fixture caught it. Also removes a stray `} // namespace` inside the _WIN32 block of core/tools/vmaf.cpp that closed a namespace never opened. It broke every Windows build and was invisible on Linux, where the preprocessor drops the block. This PR is a draft and drafts run no CI here, so nothing had compiled it. The file now has exactly one namespace opener and one closer, neither inside any conditional. New test core/test/test_motion_convolution_oob.c drives float_motion through the public vmaf_read_pictures entry point, because no existing test could reach the dispatched SIMD path: test_motion_min_dim only calls init(), and test_convolution_edge_small calls the scalar kernels directly. Verified both ways. The new test fails on the pre-fix tree and passes after. Under -Db_sanitize=address the pre-fix tree reports "heap-buffer-overflow ... WRITE of size 4" in convolution_f32_avx_s reached from vmaf_read_pictures, "0 bytes after 32-byte region" — the single-row buffer. Post-fix: zero sanitizer reports, the guard returns -EINVAL, and the odd-height case scores cleanly. meson test --suite=fast: 111 Ok, 0 Fail. Netflix golden gate: 271 passed, 12 skipped, 0 failed. The twelve MEDIUM and LOW findings from the same review are not addressed here and remain open on the PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ADR-1166) Verify a batch of long-open Netflix/vmaf issues against this tree — the fork diverged far enough (ADR-0700's `libvmaf/` -> `core/` rename, several C-to-C++ conversions, four fork-added GPU backends) that an upstream report is neither automatically applicable nor automatically stale — and fix the subset that still bites. The full triage table, including the ALREADY-FIXED and NOT-APPLICABLE verdicts, is in docs/research/1166-upstream-issue-harvest-2026-09-03.md. Memory safety, all reachable from the public C API today: * reported upstream as Netflix/vmaf#1582 (mirror half also Netflix/vmaf#1581): the reflect-101 fold in convolution_edge_s / _sq_s / _xy_s bounced an out-of-range tap exactly once, which only lands in range for size >= radius + 1; and convolution_x_c_s / convolution_y_c_s derived the trailing border bound as dim - (filter_width - radius), which goes negative for a plane narrower than the filter and starts the trailing loop at a negative index (heap underflow write). Two live paths reached those sizes: `--feature float_vif` on 9..15px frames (the guard admitted >= 9 but the four-scale ladder needs >= 16 — the binding constraint is scale 3), and `--feature float_motion` with motion_add_uv on a 4x4 YUV420P frame (the guard validated luma only while the blur runs at the 2x2 chroma dimensions). The fold is now iterative and bit-identical to the single bounce for every in-contract size; the borders are clamped; float_vif derives its floor from vif_get_min_dim(kernelscale); float_motion validates every plane it convolves. * reported upstream as Netflix/vmaf#1580: the three fork-added Metal motion extractors were written after the Research-0094 sweep and never got the min-dim guard, so a 1- or 2-pixel-tall frame read out of bounds on device. Correctness and contracts: * reported upstream as Netflix/vmaf#1242: vmaf_model_feature_overload() leaked the caller's dictionary on the -ENOMEM path, vmaf_model_collection_feature_overload() swallowed the copy error and dereferenced *model_collection unchecked, and feature.h / model.h documented opposite ownership rules — one of the two readings a latent double free. All three public headers now state the implemented contract identically. Supersedes ADR-0806. * reported upstream as Netflix/vmaf#1551, which retracts Netflix/vmaf#1422: the MSVC __builtin_clz shim used __lzcnt, which emits LZCNT with no runtime gate and silently retires as BSR on any x86-64 without ABM — wrong VIF and ADM log2 shifts, no fault, and invisible to CI because every hosted Windows runner has LZCNT. Now _BitScanReverse, with an architecture guard so an MSVC ARM64 leg compiles. User-visible surfaces: * reported upstream as Netflix/vmaf#743: the CLI wrote UTF-8 braille and a CSI erase to a Windows console it never configured, so the progress line was mojibake under every default code page. The console is switched to UTF-8 + VT for the run and restored on exit, with an ASCII fallback. * reported upstream as Netflix/vmaf#1178: libvmaf.pc omitted the C++ runtime, so `pkg-config --static --libs libvmaf` produced a link line that fails with hundreds of undefined references — the reason ADR-0198's static FFmpeg reproducer had to add -lstdc++ by hand. * reported upstream as Netflix/vmaf#1573: the nvcc fatbin include list used relative paths that stopped resolving at ADR-0700, and three shell-driven tool tests declared no `depends`, so a subset run built nothing and exited 127. Behaviour changes: float_vif now rejects frames below 16px in either dimension, and float_motion with motion_add_uv rejects sub-minimum chroma planes. Both convert previously undefined behaviour into a documented -EINVAL. Regression tests: core/test/test_convolution_edge_small.c (NaN-poisoned guard buffers; fails pre-fix), core/test/test_compat_clz.c, core/test/test_model_feature_overload_ownership.c, core/test/test_spinner.cpp, scripts/ci/check-msvc-clz-shim.sh (fails pre-fix), plus extended cases in test_motion_min_dim.c and test_float_vif_min_dim.c, and a real static link in the libvmaf-build-matrix pkg-config step. Netflix golden scores unchanged: 76.66744 / 35.070245 / 7.985956 (271 passed, 12 skipped). Confirmed but not batched, one docs/state.md row each: Netflix/vmaf#1564, #930, off-by-one found while triaging Netflix/vmaf#1580. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…holes Three HIGH findings from an independent adversarial review of this harvest. Each was reproduced before being fixed, and the regression test fails on the pre-fix tree. 1. The Netflix/vmaf#1582 border clamp landed only on the scalar path. convolution_f32_c_s dispatches straight into convolution_f32_avx_s whenever AVX2 is present — every CI runner and the dev workstation — so the clamp this PR added was dead code on x86. The AVX2 and AVX-512 twins derive the same `height - radius` split at three sites each and kept it unclamped. For a plane shorter than the radius that value is negative, so the trailing border loop starts at a negative row and the leading one runs past the end. Both are heap WRITES, not reads. All six sites now share the scalar clamp, which moved into convolution_internal.h as a static inline. 2. motion_filter_size=1 bypassed the minimum-dimension guard entirely. motion_check_min_dim gated its check on `effective_filter_size > 1`, but motion_blur_plane keeps filter_size = 5 for that value and only swaps in the FILTER_5_NO_OP_s coefficients, so the radius is still 2. A 1-row plane therefore reached the convolution in (1) through a documented public option with range 0..9. The guard now mirrors motion_blur_plane exactly: 3 taps only for motion_filter_size == 3, otherwise 5. 3. Odd-height 4:2:0 chroma planes were under-allocated by one row. motion_chroma_heights used `h / 2` while picture.c and the guard both use the ceiling `(h + 1) >> 1`, so motion_copy_and_blur overran ref, tmp and every MOTION_BLUR_RING blur buffer for both U and V. Even heights were unaffected, which is why neither golden fixture caught it. Also removes a stray `} // namespace` inside the _WIN32 block of core/tools/vmaf.cpp that closed a namespace never opened. It broke every Windows build and was invisible on Linux, where the preprocessor drops the block. This PR is a draft and drafts run no CI here, so nothing had compiled it. The file now has exactly one namespace opener and one closer, neither inside any conditional. New test core/test/test_motion_convolution_oob.c drives float_motion through the public vmaf_read_pictures entry point, because no existing test could reach the dispatched SIMD path: test_motion_min_dim only calls init(), and test_convolution_edge_small calls the scalar kernels directly. Verified both ways. The new test fails on the pre-fix tree and passes after. Under -Db_sanitize=address the pre-fix tree reports "heap-buffer-overflow ... WRITE of size 4" in convolution_f32_avx_s reached from vmaf_read_pictures, "0 bytes after 32-byte region" — the single-row buffer. Post-fix: zero sanitizer reports, the guard returns -EINVAL, and the odd-height case scores cleanly. meson test --suite=fast: 111 Ok, 0 Fail. Netflix golden gate: 271 passed, 12 skipped, 0 failed. The twelve MEDIUM and LOW findings from the same review are not addressed here and remain open on the PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ADR-1166) Verify a batch of long-open Netflix/vmaf issues against this tree — the fork diverged far enough (ADR-0700's `libvmaf/` -> `core/` rename, several C-to-C++ conversions, four fork-added GPU backends) that an upstream report is neither automatically applicable nor automatically stale — and fix the subset that still bites. The full triage table, including the ALREADY-FIXED and NOT-APPLICABLE verdicts, is in docs/research/1166-upstream-issue-harvest-2026-09-03.md. Memory safety, all reachable from the public C API today: * reported upstream as Netflix/vmaf#1582 (mirror half also Netflix/vmaf#1581): the reflect-101 fold in convolution_edge_s / _sq_s / _xy_s bounced an out-of-range tap exactly once, which only lands in range for size >= radius + 1; and convolution_x_c_s / convolution_y_c_s derived the trailing border bound as dim - (filter_width - radius), which goes negative for a plane narrower than the filter and starts the trailing loop at a negative index (heap underflow write). Two live paths reached those sizes: `--feature float_vif` on 9..15px frames (the guard admitted >= 9 but the four-scale ladder needs >= 16 — the binding constraint is scale 3), and `--feature float_motion` with motion_add_uv on a 4x4 YUV420P frame (the guard validated luma only while the blur runs at the 2x2 chroma dimensions). The fold is now iterative and bit-identical to the single bounce for every in-contract size; the borders are clamped; float_vif derives its floor from vif_get_min_dim(kernelscale); float_motion validates every plane it convolves. * reported upstream as Netflix/vmaf#1580: the three fork-added Metal motion extractors were written after the Research-0094 sweep and never got the min-dim guard, so a 1- or 2-pixel-tall frame read out of bounds on device. Correctness and contracts: * reported upstream as Netflix/vmaf#1242: vmaf_model_feature_overload() leaked the caller's dictionary on the -ENOMEM path, vmaf_model_collection_feature_overload() swallowed the copy error and dereferenced *model_collection unchecked, and feature.h / model.h documented opposite ownership rules — one of the two readings a latent double free. All three public headers now state the implemented contract identically. Supersedes ADR-0806. * reported upstream as Netflix/vmaf#1551, which retracts Netflix/vmaf#1422: the MSVC __builtin_clz shim used __lzcnt, which emits LZCNT with no runtime gate and silently retires as BSR on any x86-64 without ABM — wrong VIF and ADM log2 shifts, no fault, and invisible to CI because every hosted Windows runner has LZCNT. Now _BitScanReverse, with an architecture guard so an MSVC ARM64 leg compiles. User-visible surfaces: * reported upstream as Netflix/vmaf#743: the CLI wrote UTF-8 braille and a CSI erase to a Windows console it never configured, so the progress line was mojibake under every default code page. The console is switched to UTF-8 + VT for the run and restored on exit, with an ASCII fallback. * reported upstream as Netflix/vmaf#1178: libvmaf.pc omitted the C++ runtime, so `pkg-config --static --libs libvmaf` produced a link line that fails with hundreds of undefined references — the reason ADR-0198's static FFmpeg reproducer had to add -lstdc++ by hand. * reported upstream as Netflix/vmaf#1573: the nvcc fatbin include list used relative paths that stopped resolving at ADR-0700, and three shell-driven tool tests declared no `depends`, so a subset run built nothing and exited 127. Behaviour changes: float_vif now rejects frames below 16px in either dimension, and float_motion with motion_add_uv rejects sub-minimum chroma planes. Both convert previously undefined behaviour into a documented -EINVAL. Regression tests: core/test/test_convolution_edge_small.c (NaN-poisoned guard buffers; fails pre-fix), core/test/test_compat_clz.c, core/test/test_model_feature_overload_ownership.c, core/test/test_spinner.cpp, scripts/ci/check-msvc-clz-shim.sh (fails pre-fix), plus extended cases in test_motion_min_dim.c and test_float_vif_min_dim.c, and a real static link in the libvmaf-build-matrix pkg-config step. Netflix golden scores unchanged: 76.66744 / 35.070245 / 7.985956 (271 passed, 12 skipped). Confirmed but not batched, one docs/state.md row each: Netflix/vmaf#1564, #930, off-by-one found while triaging Netflix/vmaf#1580. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…holes Three HIGH findings from an independent adversarial review of this harvest. Each was reproduced before being fixed, and the regression test fails on the pre-fix tree. 1. The Netflix/vmaf#1582 border clamp landed only on the scalar path. convolution_f32_c_s dispatches straight into convolution_f32_avx_s whenever AVX2 is present — every CI runner and the dev workstation — so the clamp this PR added was dead code on x86. The AVX2 and AVX-512 twins derive the same `height - radius` split at three sites each and kept it unclamped. For a plane shorter than the radius that value is negative, so the trailing border loop starts at a negative row and the leading one runs past the end. Both are heap WRITES, not reads. All six sites now share the scalar clamp, which moved into convolution_internal.h as a static inline. 2. motion_filter_size=1 bypassed the minimum-dimension guard entirely. motion_check_min_dim gated its check on `effective_filter_size > 1`, but motion_blur_plane keeps filter_size = 5 for that value and only swaps in the FILTER_5_NO_OP_s coefficients, so the radius is still 2. A 1-row plane therefore reached the convolution in (1) through a documented public option with range 0..9. The guard now mirrors motion_blur_plane exactly: 3 taps only for motion_filter_size == 3, otherwise 5. 3. Odd-height 4:2:0 chroma planes were under-allocated by one row. motion_chroma_heights used `h / 2` while picture.c and the guard both use the ceiling `(h + 1) >> 1`, so motion_copy_and_blur overran ref, tmp and every MOTION_BLUR_RING blur buffer for both U and V. Even heights were unaffected, which is why neither golden fixture caught it. Also removes a stray `} // namespace` inside the _WIN32 block of core/tools/vmaf.cpp that closed a namespace never opened. It broke every Windows build and was invisible on Linux, where the preprocessor drops the block. This PR is a draft and drafts run no CI here, so nothing had compiled it. The file now has exactly one namespace opener and one closer, neither inside any conditional. New test core/test/test_motion_convolution_oob.c drives float_motion through the public vmaf_read_pictures entry point, because no existing test could reach the dispatched SIMD path: test_motion_min_dim only calls init(), and test_convolution_edge_small calls the scalar kernels directly. Verified both ways. The new test fails on the pre-fix tree and passes after. Under -Db_sanitize=address the pre-fix tree reports "heap-buffer-overflow ... WRITE of size 4" in convolution_f32_avx_s reached from vmaf_read_pictures, "0 bytes after 32-byte region" — the single-row buffer. Post-fix: zero sanitizer reports, the guard returns -EINVAL, and the odd-height case scores cleanly. meson test --suite=fast: 111 Ok, 0 Fail. Netflix golden gate: 271 passed, 12 skipped, 0 failed. The twelve MEDIUM and LOW findings from the same review are not addressed here and remain open on the PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ADR-1166) Verify a batch of long-open Netflix/vmaf issues against this tree — the fork diverged far enough (ADR-0700's `libvmaf/` -> `core/` rename, several C-to-C++ conversions, four fork-added GPU backends) that an upstream report is neither automatically applicable nor automatically stale — and fix the subset that still bites. The full triage table, including the ALREADY-FIXED and NOT-APPLICABLE verdicts, is in docs/research/1166-upstream-issue-harvest-2026-09-03.md. Memory safety, all reachable from the public C API today: * reported upstream as Netflix/vmaf#1582 (mirror half also Netflix/vmaf#1581): the reflect-101 fold in convolution_edge_s / _sq_s / _xy_s bounced an out-of-range tap exactly once, which only lands in range for size >= radius + 1; and convolution_x_c_s / convolution_y_c_s derived the trailing border bound as dim - (filter_width - radius), which goes negative for a plane narrower than the filter and starts the trailing loop at a negative index (heap underflow write). Two live paths reached those sizes: `--feature float_vif` on 9..15px frames (the guard admitted >= 9 but the four-scale ladder needs >= 16 — the binding constraint is scale 3), and `--feature float_motion` with motion_add_uv on a 4x4 YUV420P frame (the guard validated luma only while the blur runs at the 2x2 chroma dimensions). The fold is now iterative and bit-identical to the single bounce for every in-contract size; the borders are clamped; float_vif derives its floor from vif_get_min_dim(kernelscale); float_motion validates every plane it convolves. * reported upstream as Netflix/vmaf#1580: the three fork-added Metal motion extractors were written after the Research-0094 sweep and never got the min-dim guard, so a 1- or 2-pixel-tall frame read out of bounds on device. Correctness and contracts: * reported upstream as Netflix/vmaf#1242: vmaf_model_feature_overload() leaked the caller's dictionary on the -ENOMEM path, vmaf_model_collection_feature_overload() swallowed the copy error and dereferenced *model_collection unchecked, and feature.h / model.h documented opposite ownership rules — one of the two readings a latent double free. All three public headers now state the implemented contract identically. Supersedes ADR-0806. * reported upstream as Netflix/vmaf#1551, which retracts Netflix/vmaf#1422: the MSVC __builtin_clz shim used __lzcnt, which emits LZCNT with no runtime gate and silently retires as BSR on any x86-64 without ABM — wrong VIF and ADM log2 shifts, no fault, and invisible to CI because every hosted Windows runner has LZCNT. Now _BitScanReverse, with an architecture guard so an MSVC ARM64 leg compiles. User-visible surfaces: * reported upstream as Netflix/vmaf#743: the CLI wrote UTF-8 braille and a CSI erase to a Windows console it never configured, so the progress line was mojibake under every default code page. The console is switched to UTF-8 + VT for the run and restored on exit, with an ASCII fallback. * reported upstream as Netflix/vmaf#1178: libvmaf.pc omitted the C++ runtime, so `pkg-config --static --libs libvmaf` produced a link line that fails with hundreds of undefined references — the reason ADR-0198's static FFmpeg reproducer had to add -lstdc++ by hand. * reported upstream as Netflix/vmaf#1573: the nvcc fatbin include list used relative paths that stopped resolving at ADR-0700, and three shell-driven tool tests declared no `depends`, so a subset run built nothing and exited 127. Behaviour changes: float_vif now rejects frames below 16px in either dimension, and float_motion with motion_add_uv rejects sub-minimum chroma planes. Both convert previously undefined behaviour into a documented -EINVAL. Regression tests: core/test/test_convolution_edge_small.c (NaN-poisoned guard buffers; fails pre-fix), core/test/test_compat_clz.c, core/test/test_model_feature_overload_ownership.c, core/test/test_spinner.cpp, scripts/ci/check-msvc-clz-shim.sh (fails pre-fix), plus extended cases in test_motion_min_dim.c and test_float_vif_min_dim.c, and a real static link in the libvmaf-build-matrix pkg-config step. Netflix golden scores unchanged: 76.66744 / 35.070245 / 7.985956 (271 passed, 12 skipped). Confirmed but not batched, one docs/state.md row each: Netflix/vmaf#1564, #930, off-by-one found while triaging Netflix/vmaf#1580. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…holes Three HIGH findings from an independent adversarial review of this harvest. Each was reproduced before being fixed, and the regression test fails on the pre-fix tree. 1. The Netflix/vmaf#1582 border clamp landed only on the scalar path. convolution_f32_c_s dispatches straight into convolution_f32_avx_s whenever AVX2 is present — every CI runner and the dev workstation — so the clamp this PR added was dead code on x86. The AVX2 and AVX-512 twins derive the same `height - radius` split at three sites each and kept it unclamped. For a plane shorter than the radius that value is negative, so the trailing border loop starts at a negative row and the leading one runs past the end. Both are heap WRITES, not reads. All six sites now share the scalar clamp, which moved into convolution_internal.h as a static inline. 2. motion_filter_size=1 bypassed the minimum-dimension guard entirely. motion_check_min_dim gated its check on `effective_filter_size > 1`, but motion_blur_plane keeps filter_size = 5 for that value and only swaps in the FILTER_5_NO_OP_s coefficients, so the radius is still 2. A 1-row plane therefore reached the convolution in (1) through a documented public option with range 0..9. The guard now mirrors motion_blur_plane exactly: 3 taps only for motion_filter_size == 3, otherwise 5. 3. Odd-height 4:2:0 chroma planes were under-allocated by one row. motion_chroma_heights used `h / 2` while picture.c and the guard both use the ceiling `(h + 1) >> 1`, so motion_copy_and_blur overran ref, tmp and every MOTION_BLUR_RING blur buffer for both U and V. Even heights were unaffected, which is why neither golden fixture caught it. Also removes a stray `} // namespace` inside the _WIN32 block of core/tools/vmaf.cpp that closed a namespace never opened. It broke every Windows build and was invisible on Linux, where the preprocessor drops the block. This PR is a draft and drafts run no CI here, so nothing had compiled it. The file now has exactly one namespace opener and one closer, neither inside any conditional. New test core/test/test_motion_convolution_oob.c drives float_motion through the public vmaf_read_pictures entry point, because no existing test could reach the dispatched SIMD path: test_motion_min_dim only calls init(), and test_convolution_edge_small calls the scalar kernels directly. Verified both ways. The new test fails on the pre-fix tree and passes after. Under -Db_sanitize=address the pre-fix tree reports "heap-buffer-overflow ... WRITE of size 4" in convolution_f32_avx_s reached from vmaf_read_pictures, "0 bytes after 32-byte region" — the single-row buffer. Post-fix: zero sanitizer reports, the guard returns -EINVAL, and the odd-height case scores cleanly. meson test --suite=fast: 111 Ok, 0 Fail. Netflix golden gate: 271 passed, 12 skipped, 0 failed. The twelve MEDIUM and LOW findings from the same review are not addressed here and remain open on the PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ADR-1166) Verify a batch of long-open Netflix/vmaf issues against this tree — the fork diverged far enough (ADR-0700's `libvmaf/` -> `core/` rename, several C-to-C++ conversions, four fork-added GPU backends) that an upstream report is neither automatically applicable nor automatically stale — and fix the subset that still bites. The full triage table, including the ALREADY-FIXED and NOT-APPLICABLE verdicts, is in docs/research/1166-upstream-issue-harvest-2026-09-03.md. Memory safety, all reachable from the public C API today: * reported upstream as Netflix/vmaf#1582 (mirror half also Netflix/vmaf#1581): the reflect-101 fold in convolution_edge_s / _sq_s / _xy_s bounced an out-of-range tap exactly once, which only lands in range for size >= radius + 1; and convolution_x_c_s / convolution_y_c_s derived the trailing border bound as dim - (filter_width - radius), which goes negative for a plane narrower than the filter and starts the trailing loop at a negative index (heap underflow write). Two live paths reached those sizes: `--feature float_vif` on 9..15px frames (the guard admitted >= 9 but the four-scale ladder needs >= 16 — the binding constraint is scale 3), and `--feature float_motion` with motion_add_uv on a 4x4 YUV420P frame (the guard validated luma only while the blur runs at the 2x2 chroma dimensions). The fold is now iterative and bit-identical to the single bounce for every in-contract size; the borders are clamped; float_vif derives its floor from vif_get_min_dim(kernelscale); float_motion validates every plane it convolves. * reported upstream as Netflix/vmaf#1580: the three fork-added Metal motion extractors were written after the Research-0094 sweep and never got the min-dim guard, so a 1- or 2-pixel-tall frame read out of bounds on device. Correctness and contracts: * reported upstream as Netflix/vmaf#1242: vmaf_model_feature_overload() leaked the caller's dictionary on the -ENOMEM path, vmaf_model_collection_feature_overload() swallowed the copy error and dereferenced *model_collection unchecked, and feature.h / model.h documented opposite ownership rules — one of the two readings a latent double free. All three public headers now state the implemented contract identically. Supersedes ADR-0806. * reported upstream as Netflix/vmaf#1551, which retracts Netflix/vmaf#1422: the MSVC __builtin_clz shim used __lzcnt, which emits LZCNT with no runtime gate and silently retires as BSR on any x86-64 without ABM — wrong VIF and ADM log2 shifts, no fault, and invisible to CI because every hosted Windows runner has LZCNT. Now _BitScanReverse, with an architecture guard so an MSVC ARM64 leg compiles. User-visible surfaces: * reported upstream as Netflix/vmaf#743: the CLI wrote UTF-8 braille and a CSI erase to a Windows console it never configured, so the progress line was mojibake under every default code page. The console is switched to UTF-8 + VT for the run and restored on exit, with an ASCII fallback. * reported upstream as Netflix/vmaf#1178: libvmaf.pc omitted the C++ runtime, so `pkg-config --static --libs libvmaf` produced a link line that fails with hundreds of undefined references — the reason ADR-0198's static FFmpeg reproducer had to add -lstdc++ by hand. * reported upstream as Netflix/vmaf#1573: the nvcc fatbin include list used relative paths that stopped resolving at ADR-0700, and three shell-driven tool tests declared no `depends`, so a subset run built nothing and exited 127. Behaviour changes: float_vif now rejects frames below 16px in either dimension, and float_motion with motion_add_uv rejects sub-minimum chroma planes. Both convert previously undefined behaviour into a documented -EINVAL. Regression tests: core/test/test_convolution_edge_small.c (NaN-poisoned guard buffers; fails pre-fix), core/test/test_compat_clz.c, core/test/test_model_feature_overload_ownership.c, core/test/test_spinner.cpp, scripts/ci/check-msvc-clz-shim.sh (fails pre-fix), plus extended cases in test_motion_min_dim.c and test_float_vif_min_dim.c, and a real static link in the libvmaf-build-matrix pkg-config step. Netflix golden scores unchanged: 76.66744 / 35.070245 / 7.985956 (271 passed, 12 skipped). Confirmed but not batched, one docs/state.md row each: Netflix/vmaf#1564, #930, off-by-one found while triaging Netflix/vmaf#1580. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…holes Three HIGH findings from an independent adversarial review of this harvest. Each was reproduced before being fixed, and the regression test fails on the pre-fix tree. 1. The Netflix/vmaf#1582 border clamp landed only on the scalar path. convolution_f32_c_s dispatches straight into convolution_f32_avx_s whenever AVX2 is present — every CI runner and the dev workstation — so the clamp this PR added was dead code on x86. The AVX2 and AVX-512 twins derive the same `height - radius` split at three sites each and kept it unclamped. For a plane shorter than the radius that value is negative, so the trailing border loop starts at a negative row and the leading one runs past the end. Both are heap WRITES, not reads. All six sites now share the scalar clamp, which moved into convolution_internal.h as a static inline. 2. motion_filter_size=1 bypassed the minimum-dimension guard entirely. motion_check_min_dim gated its check on `effective_filter_size > 1`, but motion_blur_plane keeps filter_size = 5 for that value and only swaps in the FILTER_5_NO_OP_s coefficients, so the radius is still 2. A 1-row plane therefore reached the convolution in (1) through a documented public option with range 0..9. The guard now mirrors motion_blur_plane exactly: 3 taps only for motion_filter_size == 3, otherwise 5. 3. Odd-height 4:2:0 chroma planes were under-allocated by one row. motion_chroma_heights used `h / 2` while picture.c and the guard both use the ceiling `(h + 1) >> 1`, so motion_copy_and_blur overran ref, tmp and every MOTION_BLUR_RING blur buffer for both U and V. Even heights were unaffected, which is why neither golden fixture caught it. Also removes a stray `} // namespace` inside the _WIN32 block of core/tools/vmaf.cpp that closed a namespace never opened. It broke every Windows build and was invisible on Linux, where the preprocessor drops the block. This PR is a draft and drafts run no CI here, so nothing had compiled it. The file now has exactly one namespace opener and one closer, neither inside any conditional. New test core/test/test_motion_convolution_oob.c drives float_motion through the public vmaf_read_pictures entry point, because no existing test could reach the dispatched SIMD path: test_motion_min_dim only calls init(), and test_convolution_edge_small calls the scalar kernels directly. Verified both ways. The new test fails on the pre-fix tree and passes after. Under -Db_sanitize=address the pre-fix tree reports "heap-buffer-overflow ... WRITE of size 4" in convolution_f32_avx_s reached from vmaf_read_pictures, "0 bytes after 32-byte region" — the single-row buffer. Post-fix: zero sanitizer reports, the guard returns -EINVAL, and the odd-height case scores cleanly. meson test --suite=fast: 111 Ok, 0 Fail. Netflix golden gate: 271 passed, 12 skipped, 0 failed. The twelve MEDIUM and LOW findings from the same review are not addressed here and remain open on the PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ADR-1166) Verify a batch of long-open Netflix/vmaf issues against this tree — the fork diverged far enough (ADR-0700's `libvmaf/` -> `core/` rename, several C-to-C++ conversions, four fork-added GPU backends) that an upstream report is neither automatically applicable nor automatically stale — and fix the subset that still bites. The full triage table, including the ALREADY-FIXED and NOT-APPLICABLE verdicts, is in docs/research/1166-upstream-issue-harvest-2026-09-03.md. Memory safety, all reachable from the public C API today: * reported upstream as Netflix/vmaf#1582 (mirror half also Netflix/vmaf#1581): the reflect-101 fold in convolution_edge_s / _sq_s / _xy_s bounced an out-of-range tap exactly once, which only lands in range for size >= radius + 1; and convolution_x_c_s / convolution_y_c_s derived the trailing border bound as dim - (filter_width - radius), which goes negative for a plane narrower than the filter and starts the trailing loop at a negative index (heap underflow write). Two live paths reached those sizes: `--feature float_vif` on 9..15px frames (the guard admitted >= 9 but the four-scale ladder needs >= 16 — the binding constraint is scale 3), and `--feature float_motion` with motion_add_uv on a 4x4 YUV420P frame (the guard validated luma only while the blur runs at the 2x2 chroma dimensions). The fold is now iterative and bit-identical to the single bounce for every in-contract size; the borders are clamped; float_vif derives its floor from vif_get_min_dim(kernelscale); float_motion validates every plane it convolves. * reported upstream as Netflix/vmaf#1580: the three fork-added Metal motion extractors were written after the Research-0094 sweep and never got the min-dim guard, so a 1- or 2-pixel-tall frame read out of bounds on device. Correctness and contracts: * reported upstream as Netflix/vmaf#1242: vmaf_model_feature_overload() leaked the caller's dictionary on the -ENOMEM path, vmaf_model_collection_feature_overload() swallowed the copy error and dereferenced *model_collection unchecked, and feature.h / model.h documented opposite ownership rules — one of the two readings a latent double free. All three public headers now state the implemented contract identically. Supersedes ADR-0806. * reported upstream as Netflix/vmaf#1551, which retracts Netflix/vmaf#1422: the MSVC __builtin_clz shim used __lzcnt, which emits LZCNT with no runtime gate and silently retires as BSR on any x86-64 without ABM — wrong VIF and ADM log2 shifts, no fault, and invisible to CI because every hosted Windows runner has LZCNT. Now _BitScanReverse, with an architecture guard so an MSVC ARM64 leg compiles. User-visible surfaces: * reported upstream as Netflix/vmaf#743: the CLI wrote UTF-8 braille and a CSI erase to a Windows console it never configured, so the progress line was mojibake under every default code page. The console is switched to UTF-8 + VT for the run and restored on exit, with an ASCII fallback. * reported upstream as Netflix/vmaf#1178: libvmaf.pc omitted the C++ runtime, so `pkg-config --static --libs libvmaf` produced a link line that fails with hundreds of undefined references — the reason ADR-0198's static FFmpeg reproducer had to add -lstdc++ by hand. * reported upstream as Netflix/vmaf#1573: the nvcc fatbin include list used relative paths that stopped resolving at ADR-0700, and three shell-driven tool tests declared no `depends`, so a subset run built nothing and exited 127. Behaviour changes: float_vif now rejects frames below 16px in either dimension, and float_motion with motion_add_uv rejects sub-minimum chroma planes. Both convert previously undefined behaviour into a documented -EINVAL. Regression tests: core/test/test_convolution_edge_small.c (NaN-poisoned guard buffers; fails pre-fix), core/test/test_compat_clz.c, core/test/test_model_feature_overload_ownership.c, core/test/test_spinner.cpp, scripts/ci/check-msvc-clz-shim.sh (fails pre-fix), plus extended cases in test_motion_min_dim.c and test_float_vif_min_dim.c, and a real static link in the libvmaf-build-matrix pkg-config step. Netflix golden scores unchanged: 76.66744 / 35.070245 / 7.985956 (271 passed, 12 skipped). Confirmed but not batched, one docs/state.md row each: Netflix/vmaf#1564, #930, off-by-one found while triaging Netflix/vmaf#1580. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…holes Three HIGH findings from an independent adversarial review of this harvest. Each was reproduced before being fixed, and the regression test fails on the pre-fix tree. 1. The Netflix/vmaf#1582 border clamp landed only on the scalar path. convolution_f32_c_s dispatches straight into convolution_f32_avx_s whenever AVX2 is present — every CI runner and the dev workstation — so the clamp this PR added was dead code on x86. The AVX2 and AVX-512 twins derive the same `height - radius` split at three sites each and kept it unclamped. For a plane shorter than the radius that value is negative, so the trailing border loop starts at a negative row and the leading one runs past the end. Both are heap WRITES, not reads. All six sites now share the scalar clamp, which moved into convolution_internal.h as a static inline. 2. motion_filter_size=1 bypassed the minimum-dimension guard entirely. motion_check_min_dim gated its check on `effective_filter_size > 1`, but motion_blur_plane keeps filter_size = 5 for that value and only swaps in the FILTER_5_NO_OP_s coefficients, so the radius is still 2. A 1-row plane therefore reached the convolution in (1) through a documented public option with range 0..9. The guard now mirrors motion_blur_plane exactly: 3 taps only for motion_filter_size == 3, otherwise 5. 3. Odd-height 4:2:0 chroma planes were under-allocated by one row. motion_chroma_heights used `h / 2` while picture.c and the guard both use the ceiling `(h + 1) >> 1`, so motion_copy_and_blur overran ref, tmp and every MOTION_BLUR_RING blur buffer for both U and V. Even heights were unaffected, which is why neither golden fixture caught it. Also removes a stray `} // namespace` inside the _WIN32 block of core/tools/vmaf.cpp that closed a namespace never opened. It broke every Windows build and was invisible on Linux, where the preprocessor drops the block. This PR is a draft and drafts run no CI here, so nothing had compiled it. The file now has exactly one namespace opener and one closer, neither inside any conditional. New test core/test/test_motion_convolution_oob.c drives float_motion through the public vmaf_read_pictures entry point, because no existing test could reach the dispatched SIMD path: test_motion_min_dim only calls init(), and test_convolution_edge_small calls the scalar kernels directly. Verified both ways. The new test fails on the pre-fix tree and passes after. Under -Db_sanitize=address the pre-fix tree reports "heap-buffer-overflow ... WRITE of size 4" in convolution_f32_avx_s reached from vmaf_read_pictures, "0 bytes after 32-byte region" — the single-row buffer. Post-fix: zero sanitizer reports, the guard returns -EINVAL, and the odd-height case scores cleanly. meson test --suite=fast: 111 Ok, 0 Fail. Netflix golden gate: 271 passed, 12 skipped, 0 failed. The twelve MEDIUM and LOW findings from the same review are not addressed here and remain open on the PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ADR-1166) Verify a batch of long-open Netflix/vmaf issues against this tree — the fork diverged far enough (ADR-0700's `libvmaf/` -> `core/` rename, several C-to-C++ conversions, four fork-added GPU backends) that an upstream report is neither automatically applicable nor automatically stale — and fix the subset that still bites. The full triage table, including the ALREADY-FIXED and NOT-APPLICABLE verdicts, is in docs/research/1166-upstream-issue-harvest-2026-09-03.md. Memory safety, all reachable from the public C API today: * reported upstream as Netflix/vmaf#1582 (mirror half also Netflix/vmaf#1581): the reflect-101 fold in convolution_edge_s / _sq_s / _xy_s bounced an out-of-range tap exactly once, which only lands in range for size >= radius + 1; and convolution_x_c_s / convolution_y_c_s derived the trailing border bound as dim - (filter_width - radius), which goes negative for a plane narrower than the filter and starts the trailing loop at a negative index (heap underflow write). Two live paths reached those sizes: `--feature float_vif` on 9..15px frames (the guard admitted >= 9 but the four-scale ladder needs >= 16 — the binding constraint is scale 3), and `--feature float_motion` with motion_add_uv on a 4x4 YUV420P frame (the guard validated luma only while the blur runs at the 2x2 chroma dimensions). The fold is now iterative and bit-identical to the single bounce for every in-contract size; the borders are clamped; float_vif derives its floor from vif_get_min_dim(kernelscale); float_motion validates every plane it convolves. * reported upstream as Netflix/vmaf#1580: the three fork-added Metal motion extractors were written after the Research-0094 sweep and never got the min-dim guard, so a 1- or 2-pixel-tall frame read out of bounds on device. Correctness and contracts: * reported upstream as Netflix/vmaf#1242: vmaf_model_feature_overload() leaked the caller's dictionary on the -ENOMEM path, vmaf_model_collection_feature_overload() swallowed the copy error and dereferenced *model_collection unchecked, and feature.h / model.h documented opposite ownership rules — one of the two readings a latent double free. All three public headers now state the implemented contract identically. Supersedes ADR-0806. * reported upstream as Netflix/vmaf#1551, which retracts Netflix/vmaf#1422: the MSVC __builtin_clz shim used __lzcnt, which emits LZCNT with no runtime gate and silently retires as BSR on any x86-64 without ABM — wrong VIF and ADM log2 shifts, no fault, and invisible to CI because every hosted Windows runner has LZCNT. Now _BitScanReverse, with an architecture guard so an MSVC ARM64 leg compiles. User-visible surfaces: * reported upstream as Netflix/vmaf#743: the CLI wrote UTF-8 braille and a CSI erase to a Windows console it never configured, so the progress line was mojibake under every default code page. The console is switched to UTF-8 + VT for the run and restored on exit, with an ASCII fallback. * reported upstream as Netflix/vmaf#1178: libvmaf.pc omitted the C++ runtime, so `pkg-config --static --libs libvmaf` produced a link line that fails with hundreds of undefined references — the reason ADR-0198's static FFmpeg reproducer had to add -lstdc++ by hand. * reported upstream as Netflix/vmaf#1573: the nvcc fatbin include list used relative paths that stopped resolving at ADR-0700, and three shell-driven tool tests declared no `depends`, so a subset run built nothing and exited 127. Behaviour changes: float_vif now rejects frames below 16px in either dimension, and float_motion with motion_add_uv rejects sub-minimum chroma planes. Both convert previously undefined behaviour into a documented -EINVAL. Regression tests: core/test/test_convolution_edge_small.c (NaN-poisoned guard buffers; fails pre-fix), core/test/test_compat_clz.c, core/test/test_model_feature_overload_ownership.c, core/test/test_spinner.cpp, scripts/ci/check-msvc-clz-shim.sh (fails pre-fix), plus extended cases in test_motion_min_dim.c and test_float_vif_min_dim.c, and a real static link in the libvmaf-build-matrix pkg-config step. Netflix golden scores unchanged: 76.66744 / 35.070245 / 7.985956 (271 passed, 12 skipped). Confirmed but not batched, one docs/state.md row each: Netflix/vmaf#1564, #930, off-by-one found while triaging Netflix/vmaf#1580. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…holes Three HIGH findings from an independent adversarial review of this harvest. Each was reproduced before being fixed, and the regression test fails on the pre-fix tree. 1. The Netflix/vmaf#1582 border clamp landed only on the scalar path. convolution_f32_c_s dispatches straight into convolution_f32_avx_s whenever AVX2 is present — every CI runner and the dev workstation — so the clamp this PR added was dead code on x86. The AVX2 and AVX-512 twins derive the same `height - radius` split at three sites each and kept it unclamped. For a plane shorter than the radius that value is negative, so the trailing border loop starts at a negative row and the leading one runs past the end. Both are heap WRITES, not reads. All six sites now share the scalar clamp, which moved into convolution_internal.h as a static inline. 2. motion_filter_size=1 bypassed the minimum-dimension guard entirely. motion_check_min_dim gated its check on `effective_filter_size > 1`, but motion_blur_plane keeps filter_size = 5 for that value and only swaps in the FILTER_5_NO_OP_s coefficients, so the radius is still 2. A 1-row plane therefore reached the convolution in (1) through a documented public option with range 0..9. The guard now mirrors motion_blur_plane exactly: 3 taps only for motion_filter_size == 3, otherwise 5. 3. Odd-height 4:2:0 chroma planes were under-allocated by one row. motion_chroma_heights used `h / 2` while picture.c and the guard both use the ceiling `(h + 1) >> 1`, so motion_copy_and_blur overran ref, tmp and every MOTION_BLUR_RING blur buffer for both U and V. Even heights were unaffected, which is why neither golden fixture caught it. Also removes a stray `} // namespace` inside the _WIN32 block of core/tools/vmaf.cpp that closed a namespace never opened. It broke every Windows build and was invisible on Linux, where the preprocessor drops the block. This PR is a draft and drafts run no CI here, so nothing had compiled it. The file now has exactly one namespace opener and one closer, neither inside any conditional. New test core/test/test_motion_convolution_oob.c drives float_motion through the public vmaf_read_pictures entry point, because no existing test could reach the dispatched SIMD path: test_motion_min_dim only calls init(), and test_convolution_edge_small calls the scalar kernels directly. Verified both ways. The new test fails on the pre-fix tree and passes after. Under -Db_sanitize=address the pre-fix tree reports "heap-buffer-overflow ... WRITE of size 4" in convolution_f32_avx_s reached from vmaf_read_pictures, "0 bytes after 32-byte region" — the single-row buffer. Post-fix: zero sanitizer reports, the guard returns -EINVAL, and the odd-height case scores cleanly. meson test --suite=fast: 111 Ok, 0 Fail. Netflix golden gate: 271 passed, 12 skipped, 0 failed. The twelve MEDIUM and LOW findings from the same review are not addressed here and remain open on the PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ADR-1166) (#1223) * fix(core): harvest and fix nine stale upstream Netflix/vmaf reports (ADR-1166) Verify a batch of long-open Netflix/vmaf issues against this tree — the fork diverged far enough (ADR-0700's `libvmaf/` -> `core/` rename, several C-to-C++ conversions, four fork-added GPU backends) that an upstream report is neither automatically applicable nor automatically stale — and fix the subset that still bites. The full triage table, including the ALREADY-FIXED and NOT-APPLICABLE verdicts, is in docs/research/1166-upstream-issue-harvest-2026-09-03.md. Memory safety, all reachable from the public C API today: * reported upstream as Netflix/vmaf#1582 (mirror half also Netflix/vmaf#1581): the reflect-101 fold in convolution_edge_s / _sq_s / _xy_s bounced an out-of-range tap exactly once, which only lands in range for size >= radius + 1; and convolution_x_c_s / convolution_y_c_s derived the trailing border bound as dim - (filter_width - radius), which goes negative for a plane narrower than the filter and starts the trailing loop at a negative index (heap underflow write). Two live paths reached those sizes: `--feature float_vif` on 9..15px frames (the guard admitted >= 9 but the four-scale ladder needs >= 16 — the binding constraint is scale 3), and `--feature float_motion` with motion_add_uv on a 4x4 YUV420P frame (the guard validated luma only while the blur runs at the 2x2 chroma dimensions). The fold is now iterative and bit-identical to the single bounce for every in-contract size; the borders are clamped; float_vif derives its floor from vif_get_min_dim(kernelscale); float_motion validates every plane it convolves. * reported upstream as Netflix/vmaf#1580: the three fork-added Metal motion extractors were written after the Research-0094 sweep and never got the min-dim guard, so a 1- or 2-pixel-tall frame read out of bounds on device. Correctness and contracts: * reported upstream as Netflix/vmaf#1242: vmaf_model_feature_overload() leaked the caller's dictionary on the -ENOMEM path, vmaf_model_collection_feature_overload() swallowed the copy error and dereferenced *model_collection unchecked, and feature.h / model.h documented opposite ownership rules — one of the two readings a latent double free. All three public headers now state the implemented contract identically. Supersedes ADR-0806. * reported upstream as Netflix/vmaf#1551, which retracts Netflix/vmaf#1422: the MSVC __builtin_clz shim used __lzcnt, which emits LZCNT with no runtime gate and silently retires as BSR on any x86-64 without ABM — wrong VIF and ADM log2 shifts, no fault, and invisible to CI because every hosted Windows runner has LZCNT. Now _BitScanReverse, with an architecture guard so an MSVC ARM64 leg compiles. User-visible surfaces: * reported upstream as Netflix/vmaf#743: the CLI wrote UTF-8 braille and a CSI erase to a Windows console it never configured, so the progress line was mojibake under every default code page. The console is switched to UTF-8 + VT for the run and restored on exit, with an ASCII fallback. * reported upstream as Netflix/vmaf#1178: libvmaf.pc omitted the C++ runtime, so `pkg-config --static --libs libvmaf` produced a link line that fails with hundreds of undefined references — the reason ADR-0198's static FFmpeg reproducer had to add -lstdc++ by hand. * reported upstream as Netflix/vmaf#1573: the nvcc fatbin include list used relative paths that stopped resolving at ADR-0700, and three shell-driven tool tests declared no `depends`, so a subset run built nothing and exited 127. Behaviour changes: float_vif now rejects frames below 16px in either dimension, and float_motion with motion_add_uv rejects sub-minimum chroma planes. Both convert previously undefined behaviour into a documented -EINVAL. Regression tests: core/test/test_convolution_edge_small.c (NaN-poisoned guard buffers; fails pre-fix), core/test/test_compat_clz.c, core/test/test_model_feature_overload_ownership.c, core/test/test_spinner.cpp, scripts/ci/check-msvc-clz-shim.sh (fails pre-fix), plus extended cases in test_motion_min_dim.c and test_float_vif_min_dim.c, and a real static link in the libvmaf-build-matrix pkg-config step. Netflix golden scores unchanged: 76.66744 / 35.070245 / 7.985956 (271 passed, 12 skipped). Confirmed but not batched, one docs/state.md row each: Netflix/vmaf#1564, #930, off-by-one found while triaging Netflix/vmaf#1580. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(feature): clamp the SIMD convolution borders and close two guard holes Three HIGH findings from an independent adversarial review of this harvest. Each was reproduced before being fixed, and the regression test fails on the pre-fix tree. 1. The Netflix/vmaf#1582 border clamp landed only on the scalar path. convolution_f32_c_s dispatches straight into convolution_f32_avx_s whenever AVX2 is present — every CI runner and the dev workstation — so the clamp this PR added was dead code on x86. The AVX2 and AVX-512 twins derive the same `height - radius` split at three sites each and kept it unclamped. For a plane shorter than the radius that value is negative, so the trailing border loop starts at a negative row and the leading one runs past the end. Both are heap WRITES, not reads. All six sites now share the scalar clamp, which moved into convolution_internal.h as a static inline. 2. motion_filter_size=1 bypassed the minimum-dimension guard entirely. motion_check_min_dim gated its check on `effective_filter_size > 1`, but motion_blur_plane keeps filter_size = 5 for that value and only swaps in the FILTER_5_NO_OP_s coefficients, so the radius is still 2. A 1-row plane therefore reached the convolution in (1) through a documented public option with range 0..9. The guard now mirrors motion_blur_plane exactly: 3 taps only for motion_filter_size == 3, otherwise 5. 3. Odd-height 4:2:0 chroma planes were under-allocated by one row. motion_chroma_heights used `h / 2` while picture.c and the guard both use the ceiling `(h + 1) >> 1`, so motion_copy_and_blur overran ref, tmp and every MOTION_BLUR_RING blur buffer for both U and V. Even heights were unaffected, which is why neither golden fixture caught it. Also removes a stray `} // namespace` inside the _WIN32 block of core/tools/vmaf.cpp that closed a namespace never opened. It broke every Windows build and was invisible on Linux, where the preprocessor drops the block. This PR is a draft and drafts run no CI here, so nothing had compiled it. The file now has exactly one namespace opener and one closer, neither inside any conditional. New test core/test/test_motion_convolution_oob.c drives float_motion through the public vmaf_read_pictures entry point, because no existing test could reach the dispatched SIMD path: test_motion_min_dim only calls init(), and test_convolution_edge_small calls the scalar kernels directly. Verified both ways. The new test fails on the pre-fix tree and passes after. Under -Db_sanitize=address the pre-fix tree reports "heap-buffer-overflow ... WRITE of size 4" in convolution_f32_avx_s reached from vmaf_read_pictures, "0 bytes after 32-byte region" — the single-row buffer. Post-fix: zero sanitizer reports, the guard returns -EINVAL, and the odd-height case scores cleanly. meson test --suite=fast: 111 Ok, 0 Fail. Netflix golden gate: 271 passed, 12 skipped, 0 failed. The twelve MEDIUM and LOW findings from the same review are not addressed here and remain open on the PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(api): state the dictionary ownership contract per function, close two leaks Fourth MEDIUM finding from the adversarial review of this harvest. The Netflix/vmaf#1242 contract was still stated three different ways, and one of them was a double free. <libvmaf/feature.h> and docs/api/index.md both claimed that an unknown feature_name never consumes the dictionary. That is true of vmaf_use_feature, which resolves the name against the global extractor registry and returns -EINVAL before touching it. It is NOT true of vmaf_model_feature_overload, which matches feature_name against the features of one particular model: a name that matches nothing there is not an error, it is a successful no-op returning 0, and the tail vmaf_dictionary_free consumes the dictionary anyway. A caller following the old wording would free it a second time. <libvmaf/libvmaf.h> already described vmaf_use_feature correctly. <libvmaf/model.h> described the overloads correctly but then claimed its rule "matches vmaf_use_feature", which is exactly the case where they differ. All four surfaces now state the asymmetry explicitly and say why it exists rather than papering over it. vmaf_use_feature also leaked the caller's dictionary on two failure paths: a failed vmaf_dictionary_copy returned without releasing the source, and a failed vmaf_feature_extractor_context_create returned without releasing the copy — that function frees only what it allocated itself. Both leaked precisely when the documented contract told the caller not to free, so nothing else could have released them. Two cases added to test_model_feature_overload_ownership.c pin the asymmetry from both sides: the model overload returning 0 and consuming on an unknown name, and vmaf_use_feature returning -EINVAL and handing the dictionary back. The suite is 8 tests and passes clean under -Db_sanitize=address, which is where a regression would surface as a double free rather than a silent contract violation. meson test --suite=fast: 111 Ok, 0 Fail. Netflix golden gate: 271 passed, 12 skipped, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(compat): cover MSVC ARM64 in the __builtin_clz allowlist The MSVC shim's architecture test was `_M_X64 || _M_IX86`, justified in both the header comment and scripts/ci/check-msvc-clz-shim.sh by the claim that `_BitScanReverse` is x86-only. Per the MSVC intrinsics reference that is wrong: `_BitScanReverse` is available on x86, ARM, x64 and ARM64, and only `_BitScanReverse64` is restricted (to x64 and ARM64). `__lzcnt` is the x86-only one, and it is not used here. The header is the sole definition of `__builtin_clz` for integer_adm.c and integer_vif.h, which sit on the generic scalar path and are compiled for every target, so MSVC ARM64 matched no branch and failed to compile outright rather than falling back to anything. The fork runs no MSVC ARM64 CI leg, so the break was latent. The allowlist now enumerates every architecture MSVC targets, selects `_BitScanReverse64` on x64/ARM64 and keeps the two-step 32-bit reconstruction elsewhere. The gate now joins the guard's continuation lines before matching (the guard legitimately spans several lines) and asserts the ARM64 arm specifically, so the allowlist cannot be narrowed again; both that narrowing and an `__lzcnt` reintroduction were negative-tested against it. Header and gate comments corrected to the documented architecture matrix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(metal): fold the motion mirror iteratively and align the VIF floor Round-4 review findings; each checked against the code before acting, and one did not hold up and is recorded as such. Metal motion kernels — the round-3 guard was insufficient and the real defect was in the kernel. integer_motion.metal, float_motion.metal and integer_motion_v2.metal load a TILE_W x TILE_H = 20x20 threadgroup tile at origin `bid * 16 - 2`, so the mirror helper receives indices up to `16*bid + 17`, far outside the 5-tap neighbourhood it appears to serve, and a single bounce only lands in range when `idx <= 2 * (sup - 1)`. Enumerated over the real tile span, the single-bounce form read out of bounds for every dimension in 1..9 AND for exactly 17 — at 17 the last workgroup reaches idx 33 while 2*(17-1) = 32, folding to -1. A 3x3 floor closes neither 4..9 nor 17, so all three kernels now fold iteratively, as the CPU scalar path already does in convolution_internal.h. Verified over dims 1..299 across the full tile span: always in range, always terminating, and bit-identical to the single bounce wherever one bounce sufficed, so no in-contract score moves. The host-side guard comments claimed the 3x3 floor was what kept the kernel in bounds, which was wrong; corrected. integer_motion_v2.metal was also the last backend still using the wrong reflection convention: `2 * sup - idx - 1` repeats the boundary row where reflect-101 skips it. CPU, CUDA (PR #120 / T7-15), SYCL and HIP all carry `- 2`, and the SYCL fix records the old form as a systematic ~2.6e-3 motion drift vs CPU on every frame after the first. Metal now matches (ADR-0214 places=4). The ADM kernels' `- 1` was checked and deliberately left alone — ADM legitimately uses whole-sample reflection, matching adm_tools.c::dwt2_src_indices_filt_s, CUDA's calculate_indices() and the SYCL twin. float_vif — all four GPU backends sat below the CPU floor. The CPU requires vif_get_min_dim() = 16 at the default kernelscale (the binding constraint is scale 3: max(9,10,12,16)). Metal checked only `scale_w[FVIF_SCALES-1] == 0`, i.e. `w >> 3 == 0`, an effective floor of 8; CUDA, HIP and SYCL had no dimension floor at all, halving to scale 3 unchecked. All four now derive the floor from vif_get_min_dim(), the CPU's own source of truth, so the 8..15px range that walks the reflect-101 mirror out of the plane at scale 3 is rejected uniformly. vif_tools.h gained an `extern "C"` guard — without it the C++ (SYCL) and Objective-C++ (Metal) callers demand mangled symbols against the C vif_tools.c. It was previously included only by C translation units. vmaf.cpp — `--help` and `--version` left the Windows console in UTF-8 + VT mode. WindowsConsoleGuard was an automatic local whose comment claimed it restored on every exit path. It did not: cli_parse terminates via usage_exit(), which is [[noreturn]] and calls exit(), and exit() does not destroy objects with automatic storage duration. Objects with static storage duration ARE destroyed by exit() ([basic.start.term]), so the guard is now static and the restore runs on the exit() paths, the `goto cleanup` spine and a normal return alike. POSIX is unaffected (the block is #ifdef _WIN32). check-msvc-clz-shim.sh was evadable by macro indirection: rules (1) and (4) keyed on the call syntax `__lzcnt(`, so `#define LZ __lzcnt` followed by `LZ(x)` reintroduced the instruction while still passing the gate that exists to prevent exactly that. Both rules now match the bare identifier, and rule (4) is scoped to source extensions because core/src/feature/AGENTS.md legitimately discusses __lzcnt in prose. Negative-tested: macro indirection, a narrowed architecture allowlist, and a direct __lzcnt reintroduction all fail the gate. libvmaf-build-matrix.yml — the static-link smoke test linked with bare `cc` while the matrix builds with `ccache gcc-14` / `ccache clang-22`, so it exercised a toolchain the archive was not produced with; now ${CC:-cc}. The accompanying LTO concern does not apply: b_lto is meson-default false here and explicitly false on the SYCL/CUDA legs, so the archive holds plain objects rather than LTO IR. NOT a defect — the Libs.private libc++ detection. The review held that keying on _LIBCPP_VERSION ignores an explicit -stdlib=libc++. Tested against the installed meson: a probe project reading cxx.get_define('FOO') under -Dcpp_args=-DFOO=42 reports 42, so compiler checks do observe the project's cpp_args and the _LIBCPP_VERSION probe therefore sees -stdlib=libc++ exactly as its comment claims. No change. Verified: CPU build + fast suite 111 Ok / 0 Fail; CUDA lane rc=0 with float_vif_cuda.c.o built; SYCL lane rc=0 under icpx with float_vif_sycl.o built and no undefined vif_get_min_dim, confirming the extern "C" linkage resolves. clang-tidy exit=0 on both files CI's changed-files job globs (core/tools/vmaf.cpp, core/src/feature/vif_tools.h); .mm and .metal are not in that glob and cuda/ hip/ sycl/ are excluded by path. The Metal kernels are not buildable on Linux — CI's macOS legs compile them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(test): bring this PR's own files back to zero clang-tidy warnings The whole-tree ratchet exited 2 on this branch — four files were ABOVE their baseline, which ADR-1142 treats as the PR's own regression to fix in code, never to baseline away: core/src/feature/common/convolution_avx.c 0 -> 1 core/src/feature/common/convolution_avx512.c 0 -> 1 core/test/test_model_feature_overload_ownership.c 0 -> 1 core/test/test_motion_convolution_oob.c 0 -> 11 convolution_avx.c / convolution_avx512.c — readability-function-size on convolution_f32_avx{,512}_xy_s: "62 lines including whitespace and comments (threshold 60)". The threshold counts comments, and the clamp this PR added carried a five-line rationale block duplicated at all six call sites while the same explanation already lives on convolution_clamp_borders() in convolution_internal.h. Replaced with a three-line pointer to that definition at every site: the explanation is not lost, it is no longer copied six times, and both functions drop back under the threshold. No code changed. test_model_feature_overload_ownership.c — readability-function-size on run_tests. Each mu_run_test expands to several statements, and the two cases added for the ownership asymmetry took it to eight, crossing StatementThreshold 120. Split into run_guard_tests() and run_consumption_tests(), grouped the way core/test/test_iqa_helpers.c and test_cli_parse.c already group theirs. test_motion_convolution_oob.c — eleven modernize-use-nullptr. This is a C translation unit, and ADR-1138 keeps NULL in C TUs because MSVC's documented /std:clatest C23 feature set has no `nullptr` while the required Windows build compiles it with cl.exe. Wrapped in NOLINTBEGIN/NOLINTEND(modernize-use-nullptr) with the ADR-1138 citation inline, matching the pattern test_model.c and test_output.c already use. Verified: clang-tidy reports 0 warnings on all four files, the CPU fast suite is 112 Ok / 0 Fail, and both new tests pass individually. The three stale-high entries the same run reported (convolution.c 2 -> 0, test_float_vif_min_dim.c 8 -> 0, test_motion_min_dim.c 15 -> 0) are left for CI's next measurement to be committed as the tightened baseline, since the previous measurement was taken with these regressions still present and so is not a usable baseline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(test): make the fold invariant portable and unregister the shell gate on Windows Two platform failures on this branch, neither reproducible on Linux x86-64. macOS arm64 — test_convolution_edge_small::test_large_plane_bit_identical failed with "iterative fold moved an in-contract result". The fold was NOT the cause. It is integer-only, so it cannot vary by platform; the failure was floating-point contraction. The test's reference_convolve() reads the file-scope `kFilter5`, which the compiler can constant-fold and vectorize, while the library kernel receives an opaque `const float *filter`. On any target with FMA in its baseline — every arm64 — clang contracts `accum += filter[k] * src[...]` to an fma in one and not necessarily the other, so the last bit legitimately differs. x86-64 agreed only because FMA is not in its baseline. Two separately-compiled float accumulations are not a portable bit-identity claim. Rather than loosen the invariant, this states it where it actually lives. test_fold_matches_single_bounce_exactly asserts the real claim directly and exhaustively: for every size 2..64 and every in-contract index, convolution_reflect101() must return exactly what a single bounce returns, and out of contract it must still land inside the plane. That is integer-only and platform-independent, and it is a stronger statement than the float comparison ever made. The end-to-end 24x24 cross-check is kept but compared within 8 ULP, with the contraction reasoning recorded on it; 8 ULP is far below anything score-visible while a genuine fold divergence changes which sample is read and moves results by O(1e-2). The now-unused bit-identity helpers are removed. Windows MinGW64 — check_msvc_clz_shim failed, and my first reading of it was wrong: it is unrelated to the rule changes in this PR. meson invokes the script through its shebang interpreter, and on the MinGW64 runner `bash` resolves to Windows' own WSL bash.exe, which has no installed distribution. The leg printed "Windows Subsystem for Linux has no installed distributions" and exited 1 before the script ever ran. The gate is a static source-content check, so it is now registered on non-Windows hosts only — Linux and macOS both run it in the fast suite (macOS passes it today) and the lint lane runs it as well, so no coverage is lost. Also replaced rule (4)'s `grep -vF "$HDR"` self-exclusion with grep's own --exclude on the basename. This is a robustness cleanup, not the Windows fix: comparing grep's walked path against a separately constructed absolute path is fragile, and the basename form has no path dependency. Negative-tested that macro indirection, a narrowed architecture allowlist, and an __lzcnt reintroduction in another file all still fail the gate, and that a clean tree passes. Verified: fast suite 112 Ok / 0 Fail, test_convolution_edge_small passes with the new exhaustive case, clang-tidy 0 warnings on the changed test, and the gate is still registered and passing on this Linux host. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(test): drop strtok_r from the motion OOB test for MSVC portability The Windows MSVC + oneAPI SYCL leg failed to compile this file: test_motion_convolution_oob.c(70,22): error: call to undeclared function 'strtok_r'; ISO C99 and later do not support implicit function declarations test_motion_convolution_oob.c(70,16): error: incompatible integer to pointer conversion initializing 'char *' with an expression of type 'int' strtok_r is POSIX; the MSVC runtime ships strtok_s instead, so the call went undeclared and its int return was then assigned to a char *. Plain strtok is on the fork's banned-function list (docs/principles.md S1.2 rule 30), so neither variant is available here. The option string this test parses is a fixed "k=v:k=v" form under its own control, so it now splits with strchr in a small loop: portable everywhere, no reentrancy question, and no banned call. Behaviour is identical for every input the test uses. Verified: test_motion_convolution_oob passes, the fast suite is green, and clang-tidy reports 0 warnings on the file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Lusoris <lusoris@pm.me> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
I filled in part of the x86 runtime-verification gap on the current PR head, On an AMD Ryzen 9 9950X3D (x86-64, AVX2/AVX-512 available), Linux, GCC 15.2, Meson 1.10.1, with assembly and floating-point features enabled: meson setup build-sanitize libvmaf \
-Denable_cuda=false -Denable_docs=false -Denable_float=true \
--buildtype=debug -Db_sanitize=address,undefined
ninja -C build-sanitize
meson test -C build-sanitize \
test_motion_mirror test_motion_blend test_vif_tools --print-errorlogsAll three targets passed under ASan/UBSan/LSan. This is targeted CPU validation only: it does not validate the CUDA device guard, replace a full model/golden regression run, or establish a performance result. In our fork we also guard the smallest unsupported convolution-plane sizes at initialization, so that policy is separate from this PR's goal of scoring tiny inputs. |
|
Thank you for verifying this. |
Fixes #1580.
Bug
mirror(int idx, int size)reflects row/column indices at frame boundaries for the5-tap (radius-2) motion filter. It's a single-bounce reflect-101 implementation:
For
size < 3this returns indices outside[0, size)— e.g.mirror(-2, 1) = 2,out of range for a 1-row/1-column plane. The identical function was independently
duplicated in 4 places (
integer_motion.c,x86/motion_avx2.c,x86/motion_avx512.c,arm64/motion_neon.c), plus a differently-structured CUDA version, plus the sharedfloat convolution helpers used by
float_motionand (viaconvolution_f32_avx_*)vif_tools.c.The float convolution path additionally has an out-of-bounds write: with radius 2,
convolution_x_c_s'sborders_right = width - 3goes negative forwidth < 3, so thetrailing edge loop starts before
dst[0]. The equivalent bug exists inconvolution_avx.c's vertical pass (i_vec_end = height - radiusgoes negative,writing
tmp[-tmp_stride + j]).This isn't only a 1×1-frame issue.
vif_tools.ccallsconvolution_f32_avx_swithfilter widths up to 17 (radius 8), so on any AVX2 host the same OOB write triggers for
any dimension below 8, at every VIF scale — not just degenerate frames.
Fix
motion_mirror()(new, replacing 4 duplicatedmirror()copies): reflectsrepeatedly instead of once, so any
size >= 1is safe.size == 1/size <= 0degenerate to
0(the unique valid extension of a 1-sample signal — matchesOpenCV's
BORDER_REFLECT_101and scipy'smode='mirror').mirrortomotion_mirrorand consolidated intointeger_motion.h(all 4 CPU files already include it). The rename is required, not stylistic: the CUDA
translation unit also includes this header and already owns a
mirrorsymbol withdifferent (intentionally unrelated, untouched) reflect-even semantics — reusing the
bare name would collide under
nvcc.convolution_mirror()(new, inconvolution_internal.h): same fix, kept separatefrom
motion_mirrorsince this header serves other float features with their ownstyle and no existing dependency on
integer_motion.h.mirror(): addedif (sup == 1) return 0;— one line, itssup >= 2formula is untouched.
convolution.c/convolution_avx.c: clamped the edge-loop bounds so tinywidth/height can't write out of bounds, without changing anything for normal sizes.
Non-regression is structural, not just tested. For the real call contract
(
idxin[-2, size+1], 5-tap/radius-2), a negative overshoot lands in[1,2]and apositive one in
[size-3, size-1]wheneversize >= 3— both already valid, so thenew multi-bounce loop takes exactly one iteration and computes the exact same
expression as the old code. This is proven exhaustively in tests (below), not just
argued in the abstract.
Testing
(
motion_mirror,convolution_mirror, and a host-side transcription of the CUDAformula), comparing every
sizein[3, 8192]against every in-contract offset —mechanically proves zero behavior change for
size >= 3.1×1through8×2) across multiple bit depths,run through the real
motion/float_motionfeature extractors, asserting success,finite scores, and — for each size — that the score obtained via forced-scalar
dispatch matches the score obtained via best-available SIMD dispatch (bit-exact for
the integer path; a documented, generously-tight tolerance for the float path, since
AVX's
-mfmacan contract the shared edge-filter's multiply-add into an FMA andchange the last bit or two versus the non-FMA scalar build — unrelated to this fix).
This is the only runtime coverage the AVX2/AVX512 motion kernels get from this test
suite, since no x86 hardware was available anywhere in developing this fix (see
below).
the real output buffer, asserted intact after the call, for the scalar convolution
path (verified with a negative control — temporarily removing the clamps reproduces
the failure) and,
#if ARCH_X86-gated, the AVX path.toolchains (macOS/Apple Clang arm64, Ubuntu 22.04/GCC 11.4.0 aarch64): built with
-Db_sanitize=address,undefined, confirmed clean on the fix, then temporarilyreverted just the two
mirrorfunction bodies to the old formula and confirmed areal
heap-buffer-overflowabort in both the integer path(
motion_score_pipeline_8) and the float path (convolution_y_c_s) — then restoredand reconfirmed clean.
meson testsuite: 25/25 on both hosts.quality_runner_test.py): 67/67,run twice, on Ubuntu/GCC — no VMAF score on real video content moved.
What's not verified at runtime
No x86 or CUDA hardware was available anywhere in developing this fix.
clang -target x86_64-... -mavx2/-mavx512f -fsyntax-only/-cchecks (proves it compiles and type-checks, notthat it's correct at runtime) plus the exhaustive formula proofs and mechanical
rename review. This PR's own test suite gives these kernels real coverage the moment
CI runs it on an x86 runner.
sup >= 2via ahost-side transcription, but the actual
.cufile has never been compiled. Thisrepo's CI doesn't build the CUDA path, so a maintainer with CUDA access verifying the
guard would be appreciated.