Skip to content

fix(adm): bound the integer ADM weights by the contrast-masking cube so Barten mode cannot wrap (ADR-1472) - #1872

Merged
lusoris merged 2 commits into
masterfrom
fix/integer-adm-aim-wrap
Oct 2, 2026
Merged

lusoris merged 2 commits into
masterfrom
fix/integer-adm-aim-wrap

Conversation

@lusoris

@lusoris lusoris commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Integer adm with adm_csf_mode=1 (Barten) wrapped a 32-bit square in its contrast-masking reduction on high-contrast pictures. It failed with a NaN numerator on the 10 px checkerboard and returned a wrong integer_adm2 on the 1 px checkerboard without any message. The weight limits now follow from the arithmetic, so no picture can wrap. Closes T-ADM-AIM-BARTEN-SCALE-TERM-WRAP-2026-10-01 (ADR-1472).

Cause

Per sample the reduction forms the excess v of the weighted wavelet coefficient over its masking threshold, then

v_sq = (int32_t)((v * v + round) >> 30);      /* 29 for the scale-0 h / v bands */
val  = ((int64_t)v_sq * v + round) >> shift_cub;

(upstream's I4_ADM_CM_ACCUM_ROUND, the fork's i4_adm_cm_accum_round() and every SIMD and device twin). The square fits int32 only up to v = 1518500249. ADR-1325 let a scale 1 to 3 weight reach 2^30, which makes the weighted coefficient up to four times the coefficient. The smallest reproducer, from an instrumented build:

checkerboard_1920_1080_10_3_0_0 / _10_0, adm=adm_csf_mode=1, frame 0
scale 3, diagonal band, (8, 13):
  coefficient 460572333, weight 905160448 (exponent 7)
  weighted    1553043199        threshold -27
  excess      1553043226        (excess^2 + 2^29) >> 30 = 2246297208  > INT32_MAX

The cube of that sample is negative, the band's accumulator follows, and powf() of a negative base is NaN. A wrap does not always end in NaN: on the 1 px checkerboard one scale-3 square wraps, the accumulator stays positive, and master returns a wrong score silently. The open row's reproducer (adm_csf_scale=1.2) is the same operation at scale 1, so this is that row's defect.

Fix

adm_csf_fixed_limit(scale, band) in core/src/feature/adm_csf_fixed_point.h bounds each fixed-point weight by the excess budget divided by the largest coefficient the wavelet can produce at that scale. That coefficient follows from the filter taps: a detail band is linear in the centred pixel, which lies in [-1/2, 1/2), so its magnitude is at most half the absolute sum of the composite filter in the band's fixed-point format.

Scale Largest coefficient (bound) Reached by an 8-bit sign-pattern frame Weight limit Was
0, h and v 22929 22840 46603.4 65536
0, d 22929 22840 65536 (storage) 65536
1 1448980000 1443331337 279958309 2^30
2 751508000 748575616 539893111 2^30
3 742509000 739619691 546406567 2^30

Everything else of ADR-1325 stands (one exponent per scale, restored as 3k after the cube). No kernel, SIMD or device file changes: CPU, CUDA, HIP, SYCL and Metal call adm_csf_fixed_scale(). The alternatives (widening the square, a per-frame exponent, saturating) are in the ADR; widening cannot be made complete in 64 bits because under a weight of 2^30 the weighted coefficient itself leaves int32.

Bits

adm=debug=true:<options> at --precision max, master ade338374 against this branch:

Input Options master this PR float_adm
10 px checkerboard adm_csf_mode=1 fails, aim_num=-nan integer_aim 0.999874302 0.999874855
1 px checkerboard, frames 0 / 1 / 2 adm_csf_mode=1 integer_adm2 0.587102 / 0.663832 / 0.563360, no message 0.783548 / 0.834673 / 0.784913 0.783557 / 0.834682 / 0.784922
1 px checkerboard adm_csf_mode=1:adm_csf_scale=1.2 fails integer_adm2 0.782465 0.782475
Netflix pair, 48 frames adm_csf_mode=1 correct largest change integer_adm2 2.1e-7, integer_adm3 4.1e-7, integer_aim 7.7e-7
Netflix pair, 48 frames default, adm_csf_mode=2, adm_csf_mode=3 all 18 outputs bit-identical
  • The recorded adm / float_adm cases of standards batch B1 (21 option sets, 20 fixtures, scalar / AVX2 / AVX-512): 1635 of 1695 identical to master; the 60 that differ are the integer adm_csf_mode=1 set (20 fixtures, three dispatch levels).
  • Watson97 keeps exponent 0 at every viewing geometry (its weights are largest at the minimum geometry, which is the default), and so do both blend modes. The default Barten exponents go from 5, 6, 7 to 7, 7, 8 at scales 1, 2, 3.
  • Adversarial 512x512 frames that drive one coefficient to its bound (the test builds them at 256x256): of the six for scales 1 to 3 master fails on five and returns integer_aim 0.8645 for 1.0037 on the sixth. This PR agrees with float_adm to 8.7e-6 wherever float_adm does not clip.

Test

core/test/test_integer_adm_cm_budget.c (new, suite fast): derives the band bounds from the wavelet taps and holds the header's constants against them (not below, not more than 1 % above); a weight just under its limit keeps the worst square in int32 and one 2 % over does not; adm_csf_fixed_scale() brings a sweep over seven decades and three band ratios under the limits with the smallest exponent; five adversarial frames score in Barten mode and agree with float_adm to 1e-4 (measured 2.4e-6 or better).

With the old limits three of the five tests fail (the other two do not depend on the limits). Ten planted changes (a band bound too low, doubled, halved; the weight shift; the scale-0 budget; a band left out of the normalisation loop; an exponent stepping by two) are each detected.

Golden, twins, cost

  • Netflix golden gate: 271 passed, 12 skipped on x86-64 GCC and on aarch64 GCC under qemu, on this head.

  • x86 --suite=fast: 245 of 245.

  • Twins, every one of the 18 adm=debug=true outputs against --backend cpu, under the default options, adm_csf_mode=1, adm_csf_mode=1:adm_csf_scale=1.2, adm_csf_mode=1:adm_csf_scale=1.4:adm_csf_diag_scale=0.3 and adm_csf_mode=2, on the Netflix pair (48 frames), both checkerboards (3 frames each) and six adversarial frames:

    Twin Device Cases identical Device unit tests
    adm_cuda RTX 4090 21 of 21 8 binaries pass
    HIP gfx1036 21 of 21 9 binaries pass
    SYCL Arc A380 (xe) 21 of 21 3 binaries pass

    Gate cells adm and float_adm, cpu against cuda: 0 on the Netflix pair (48 frames) and on BBB 3840x2160 (200 frames). Metal takes the weights from the same function and is not measured here.

  • Cost: none. BBB 3840x2160, 20 frames, one thread, median of five at a load average of 45: default 29.99 ms per frame before, 29.13 after; adm_csf_mode=1 64.47 before, 65.06 after.

Upstream

Netflix/vmaf cea2b4d83 (2026-10-01), libvmaf/src/feature/integer_adm.c: the option exists (line 179) and the reduction is the same (I4_ADM_CM_ACCUM_ROUND, line 698), but upstream converts the weights with (uint16_t) / (uint32_t) casts and no normalisation, so Barten weights (1.21 at scale 0 to 26.98 at scale 3) wrap in the conversion itself. Measured with upstream's binary, adm=adm_csf_mode=1: integer_adm2 0.002706 against float_adm 0.965404 on the Netflix pair; NaN on the 1 px checkerboard. Upstream's default Watson97 weights are inside the budget derived here. For upstream to follow it needs the fork's shared-exponent normalisation (ADR-1325) with these limits.

Type

  • feat — new feature
  • fix — bug fix
  • perf — performance improvement
  • refactor — no behavior change
  • docs — documentation only
  • test — test-only
  • build / ci — tooling / infra
  • port — cherry-pick from upstream Netflix/vmaf
  • sycl / cuda / simd — backend-specific

Checklist

  • Commits follow Conventional Commits (the commit-msg hook enforces this).
  • make format && make lint is green locally. (clang-format and the commit hooks. clang-tidy 22.1.8: the new test, integer_adm.c and the header measure 0 on the cpu and cuda lanes. The host's clang-tidy became 23.1.1 during this work (system upgrade at 18:05), so the hip, sycl and arm64 lanes could not be measured against their 22.1.8 baselines; under 23.1.1 the new test shows only modernize-redundant-void-arg on f(void), which that version reports for every C test in the tree.)
  • Unit tests pass: python3 scripts/ci/run_meson_test.py -- -C build. (--suite=fast on a CPU build: 245 of 245.)
  • If I touched any SIMD/GPU code path, I ran /cross-backend-diff and the worst ULP is ≤ 2. (No SIMD or GPU file is touched; the twins equal the CPU bit for bit, see above.)
  • If I touched a feature extractor with SIMD/GPU twins, I either updated every twin or listed the gap under "Known follow-ups" below. (Every twin follows through the shared header.)
  • If I added a new .c / .cpp / .cu / .h / .hpp, it has the appropriate license header (see CONTRIBUTING.md). (test_integer_adm_cm_budget.c: EUPL-1.2.)
  • If this is a breaking change, the commit message uses ! or BREAKING CHANGE: and the migration path is documented below. (Not breaking: Barten-mode scores that were right move by at most 7.7e-7.)
  • If this PR adds an ADR, the ADR row lives in docs/adr/_index_fragments/<NNNN-slug>.md. (1472-integer-adm-cm-weight-budget.md.)

Bug-status hygiene (ADR-0165)

  • docs/state.md updated in this PR. T-ADM-AIM-BARTEN-SCALE-TERM-WRAP-2026-10-01 moved to "Recently closed" with the cause, the limits and the measurements.

Netflix golden-data gate (ADR-0024)

  • I did not modify any assertAlmostEqual(...) score in the Netflix golden Python tests.
  • If I believe a golden value must change, I have explained why below AND pinged @lusoris for a CODEOWNERS exception. (None changes.)

Deep-dive deliverables (ADR-0108)

  • Research digest — docs/research/1472-integer-adm-cm-weight-budget.md.
  • Decision matrix — ADR-1472, "Alternatives considered".
  • AGENTS.md invariant note — core/src/feature/AGENTS.d/adm-barten-weights.md (the limit bullet now names adm_csf_fixed_limit() and what must change together) and core/src/feature/cuda/AGENTS.d/adm.md (what a twin must not do).
  • Reproducer / smoke-test command — pasted below under "Reproducer".
  • CHANGELOG fragment — changelog.d/fixed/integer-adm-cm-weight-budget.md.
  • Rebase note — docs/rebase-notes.md, "Integer ADM weight limits follow from the contrast-masking cube".

Reproducer

meson setup build-cpu core -Denable_cuda=false -Denable_sycl=false -Db_lto=false
ninja -C build-cpu
Y=python/test/resource/yuv
build-cpu/tools/vmaf -r $Y/checkerboard_1920_1080_10_3_0_0.yuv -d $Y/checkerboard_1920_1080_10_3_10_0.yuv \
  -w 1920 -h 1080 -p 420 -b 8 --feature adm=adm_csf_mode=1 --feature float_adm=adm_csf_mode=1 \
  --no_prediction --frame_cnt 1 --precision max --json -o /dev/stdout -q
# master: exit status 234, "undefined or non-finite aggregate at frame 0 (... aim_num=-nan)"
# this PR: integer_aim_csf_1 0.999874302, aim_csf_1 0.999874855
build-cpu/test/test_integer_adm_cm_budget      # 5 tests run, 5 passed
make test-netflix-golden                       # 271 passed, 12 skipped

Known follow-ups

  • The tidy baselines' measured_sources do not list the new test; I did not write baselines from this host after its clang-tidy changed version.
  • integer_aim is not clipped at 1 where float_adm clips (1.0037 against 1 on an adversarial frame); test_integer_adm_aim_unclipped holds that behaviour. Not changed here.

…eck on master (#1871)

* fix(ci): fix the fourteen cppcheck findings that fail the Cppcheck check on master

The required Cppcheck job (cppcheck 2.19.0, --check-level=exhaustive) fails
on master. The first complete hosted run since 2026-09-30 (513d2a6, run
37011276599) reported three findings; #1859, landed after it, added eleven.
The merge train does not run cppcheck.

- core/src/dict.cpp, identicalInnerCondition: `if (*dict) return *dict;` in
  a function that returns std::expected reads to cppcheck as one condition
  tested twice. Now `if (*dict != nullptr) return *dict;`.
- core/test/test_video_input_odd_dims.c, returnDanglingLifetime (2): the two
  frame readers were called through a pointer to a function type, which
  cppcheck takes for an initializer list that keeps `&vid`. One function,
  read_frame(), now selects the reader by a bool and calls it directly. The
  cases and assertions are unchanged.
- core/src/feature/adm.c, invalidPointerCast (11): #1859 dropped the
  `(void *)` hop of the band-plane carving for clang-tidy's
  bugprone-casting-through-void, which left a direct `(float *)` cast of a
  `char *` cursor. The cursor now has the sample type and steps
  buf_sz_one / sizeof(float) samples, so no cast is left and both tools are
  quiet. The plane addresses are the same.

Verified on master 38e8ec0 plus this change. The job's own steps in the
dev container (cppcheck 2.19.0-3, Ubuntu 26.04) check 1488 files and exit 0
with an empty report; on 513d2a6 the same steps reproduce the job's XML
report byte for byte. clang-tidy 22.1.8 reports 0 for the three files in
the cpu lane's configuration. CPU fast suite 244 of 244, Netflix golden gate
271 passed and 12 skipped.

Nothing is suppressed.

* docs(rebase): note the typed band-plane cursor of adm.c

An upstream change to init_dwt_band() or to the carving in compute_adm() conflicts with the typed cursor; the note says which form to keep and why both casts fail a required check.
…so Barten mode cannot wrap (ADR-1472) (#1872)

* fix(adm): bound the integer ADM weights by the contrast-masking cube so Barten mode cannot wrap (ADR-1472)

The contrast-masking reduction squares the excess of each weighted
wavelet coefficient over its threshold and narrows the square to int32
(upstream's I4_ADM_CM_ACCUM_ROUND). That holds for an excess up to
1518500249. ADR-1325 let a scale 1 to 3 weight reach 2^30, which makes
the weighted coefficient up to four times the coefficient, so on a
high-contrast picture in Barten mode the square wrapped:

- adm=adm_csf_mode=1 on the 10 px checkerboard failed every frame with
  aim_num=-nan (scale 3, diagonal band: coefficient 460572333, square
  2246297208);
- on the 1 px checkerboard it returned integer_adm2 0.587102 where
  float_adm gives 0.783557, with no message;
- with adm_csf_scale=1.2 (the open row's reproducer) the same operation
  wraps at scale 1.

adm_csf_fixed_limit() now bounds each weight by the excess budget
divided by the largest coefficient the wavelet can produce at the
scale: half the absolute sum of the composite filter, 22929 at scale 0
and 1448980000, 751508000, 742509000 at scales 1 to 3. Limits: 46603.4
(scale 0 h and v), 65536 (scale 0 d), 279958309, 539893111, 546406567
(were 65536 and 2^30). No kernel changes: CPU, CUDA, HIP, SYCL and
Metal take the weights from adm_csf_fixed_scale().

Scores: the three reproducers score and agree with float_adm to 1e-5.
Barten-mode outputs that were right move by at most 7.7e-7 on the
Netflix pair (exponents 5, 6, 7 become 7, 7, 8). Watson97 and the two
blend modes are bit-identical: 1635 of 1695 recorded adm / float_adm
cases identical, the other 60 are the integer adm_csf_mode=1 set.
Netflix golden gate 271 passed, 12 skipped on x86 GCC and aarch64 GCC.

test_integer_adm_cm_budget derives the bounds from the wavelet taps,
holds the limits against them from both sides and scores five
adversarial frames against float_adm. With the old limits three of its
five tests fail; ten planted changes are each detected.

Twins on an RTX 4090, a gfx1036 and an Arc A380: every adm output
equals --backend cpu under five option sets on the Netflix pair, both
checkerboards and six adversarial frames.

Upstream Netflix/vmaf cea2b4d83 has the option and the same reduction
and no weight normalisation: integer_adm2 0.0027 for 0.9654 on the
Netflix pair.

* docs: regenerate the indexes and the citation map after rebasing
@github-actions github-actions Bot added the type:bug Something isn't working label Oct 2, 2026
@lusoris
lusoris force-pushed the fix/integer-adm-aim-wrap branch from 37c3872 to b34732a Compare October 2, 2026 16:52
@lusoris
lusoris merged commit b34732a into master Oct 2, 2026
3 of 78 checks passed
@lusoris
lusoris deleted the fix/integer-adm-aim-wrap branch October 2, 2026 16:53

This branch was successfully deployed

1 active deployment
github-pages — b34732ae Deployed Oct 2, 2026 by lusoris via deploy #4042
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type:bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant