Skip to content

test(vetting): run the CellProfiler oracle — five CellProfiler rows proved, one disproved - #425

Open
darkclad wants to merge 5 commits into
PolusAI:mainfrom
darkclad:main-cellprofiler-provenance
Open

test(vetting): run the CellProfiler oracle — five CellProfiler rows proved, one disproved#425
darkclad wants to merge 5 commits into
PolusAI:mainfrom
darkclad:main-cellprofiler-provenance

Conversation

@darkclad

@darkclad darkclad commented Aug 13, 2026

Copy link
Copy Markdown

15 files, all under tests/. No src/nyx changegit rev-parse HEAD:src equals
git rev-parse upstream/main:src.

Rebased onto current main and reworked against it (the branch was 177 commits behind), then two
review rounds. Both rounds found real problems; the second turned up a defect in src/nyx that no
existing test could see.

The defect this PR started from

Six morphology rows read status=vetted, oracle=cellprofiler and carried no evidence for it:
no version, no config, no generator. Nothing in the tree distinguished a CellProfiler number from a
Nyxus one, so the verdict rested on the tracker alone. not_covered.md §C listed it as the one
entry where not even the provenance was established — as against the MATLAB entries, where the
values are the oracle's and only the recipe is missing. That is why it was worth running first.

What the oracle found

tests/vetting/oracles/gen_morphology_cellprofiler.py drives the real
cellprofiler.modules.MeasureObjectIntensity (4.2.8 / cellprofiler-core 4.2.8.1 / centrosome 1.2.3)
on the shape2d_morphology_{mask,intensity} fixture.

Five reproduce and are now vetted for realMASS_DISPLACEMENT and the mean, max, min and
integrated edge intensities:

  OK   MASS_DISPLACEMENT          cp=0.6344760644215962   golden=0.634476074243407
  OK   EDGE_MEAN_INTENSITY        cp=41.833334217468895   golden=41.8333333333333
  OK   EDGE_MAX_INTENSITY         cp=68.00000354647636    golden=68.0
  OK   EDGE_MIN_INTENSITY         cp=12.000000234693289   golden=12.0
  OK   EDGE_INTEGRATED_INTENSITY  cp=753.0000159144402    golden=753.0

The agreement is exact rather than approximate: CellProfiler's edge is
find_boundaries(mode="inner"), so an object pixel is an edge pixel unless all four N/S/E/W
neighbours share its label — 18 of the 26 ROI pixels here, summing to 753 against the ROI's 1048,
the same set Nyxus walks. The 5.2e-8 residual is entirely CellProfiler's float32 image storage
(raw/255 → float32 → ×255), not a disagreement.

The band tightens from 0.1% to 1e-6 relative, set from that measurement rather than inherited
from the shared snapshot helper. Mutation-checked: an 8.8e-6 perturbation of the mean fails.

An earlier revision of this description said the old 0.1% band was loose enough to pass the
estimator gap below. That was wrong, and the correction is worth stating because the wrong
version is what a reader reaches for first. The gap is 2.9% relative — 29 times the band — so
0.1% would have caught CellProfiler's value comfortably. The old assertion passed because the
golden it compared against was the Nyxus number: a test labelled cellprofiler judging Nyxus
against itself, which is precisely the condition §C exists to surface, and a sharper problem than
a loose tolerance. Tightening the band is still right; it fixes something else.

EDGE_STDDEV_INTENSITY does not reproduce and is demoted to regression. Over the identical 18
pixels Nyxus divides the variance by n-1 (Moments4::std()) and CellProfiler by n, so the two
differ by exactly sqrt(n/(n-1)) = 1.0289915 at n=18: 16.769194 against 16.296728. A
definitional gap, not a tolerance one. Its snapshot moves to test_2d_morphology_regression.h so
the _cellprofiler file holds only assertions CellProfiler actually backs. The generator checks the
relationship as an identity — CP against the population std, Nyxus against the sample std, the
ratio against sqrt(n/(n-1)) — so it cannot drift unnoticed in either direction.

The generator is bound to what it claims to check

It originally held its own copy of the fixture and its own copy of the goldens, and opened neither
test_data.h nor the headers — so the registry's claim that it "re-verifies every pin in the header
it feeds" was false, and a fixture edit could move the gtest input while the oracle stayed green.

It now parses both fixtures out of test_data.h and both reference tables out of the headers, and
enforces key equality in both directions. Five mutations of the tree each make it exit 1:

mutation caught as
edit a pin in the CP header FAIL EDGE_MEAN_INTENSITY … rel=1.57e-06
delete a pin UNPINNED EDGE_MIN_INTENSITY: CP vets it but the header pins nothing
add a pin CellProfiler does not back EXTRA EDGE_STDDEV_INTENSITY … this recipe does not vet it
edit the Nyxus stddev pin IDENTITY BROKEN -- investigate
edit the fixture in test_data.h FAIL EDGE_INTEGRATED_INTENSITY … rel=0.0106

This is the binding gen_neighbor_cellprofiler.py already used, whose docstring makes the point
that a local copy "would only ever compare this script against itself".

The config matrix — and a src/nyx defect it exposed

tests/vetting/matrix/morphology.md is new; SPEC §5.1 requires one per family and morphology had
none. It covers the ContourFeature group plus MASS_DISPLACEMENT, which does not inherit a
contour-builder cell — it is BasicMorphologyFeatures::calculate(), reads no contour, and has its
own osized_calculate(), so it gets its own axis.

Every cell is measured, per SPEC §5.2, on one 64×64 single-disk fixture:

feature segmented in-RAM out-of-core whole-slide
PERIMETER 131.88225099390849 112.0 256.0
MASS_DISPLACEMENT 2.7526140113386943 2.7526140113386943 3.3453118163885427
EDGE_MEAN_INTENSITY 257.0 257.0 397.0
EDGE_STDDEV_INTENSITY 98.12659593009853 98.12659593009853 0.0
EDGE_MAX_INTENSITY 397.0 397.0 397.0
EDGE_MIN_INTENSITY 117.0 117.0 397.0
EDGE_INTEGRATED_INTENSITY 28784.0 28784.0 1588.0

Whole-slide is VALID-BUT-PRODUCTION-ONLY: buildWholeSlideContour() does not trace a boundary
at all — it pushes the four AABB corners each carrying aux_max, so min = max = mean = the image
maximum, stddev = 0, integrated = 4 × maximum. A different quantity from the per-object edge
statistics CellProfiler vets, which is why the vetted rows state their scope as the segmented
in-RAM path.

Out-of-core agrees exactly for the five EDGE_* and MASS_DISPLACEMENT — and not for
PERIMETER.
osized_calculate() sets fval_PERIMETER = (StatsInt) K.size(), the contour pixel
count, where calculate() sums Euclidean step lengths: 112.0 against 131.882, ~15%, and the
out-of-core value being an integer is what identifies the definition rather than accumulation as the
cause. CLAUDE.md requires the two paths to produce identical values, so this is a defect, not
a convention.

Why nothing caught it. test_2d_ooc_invariant.py has asserted *ALL_MORPHOLOGY* equality
across the two paths since it was written, and it passes. Its fixture is a full-image rectangle,
and around a rectangle every contour step is an axis-aligned unit step — so the pixel count and the
Euclidean sum are the same number, and the one feature that differs cannot appear. The invariant is
sound; the shape cannot discriminate. The general form is recorded in not_covered.md §G: a
path-equality fixture symmetric in the axis under test proves nothing about that axis
, with a note
to audit the other _ooc_ fixtures on the same grounds.

The defect is characterized, not fixed — the fix is a src/nyx change and PERIMETER is vetted
under a different recipe. tests/python/test_2d_ooc_regression.py pins the divergence and a correct
fix must break it, at which point PERIMETER folds back into the invariant's feature list.

Also in this PR

--check fails when coverage_report.md no longer matches the registry, or is missing. The
report is generated and says so in its own header, but nothing verified it still matched, so "re-run
--write" was remembered rather than enforced — and it has been forgotten: #422 demoted ten GLCM
matlab rows without regenerating, and the report claimed glcm 118/118 against a registry holding
108 until someone happened to regenerate. --check now re-renders and compares, and treats a
missing canonical report as the same failure, since deleting the file would otherwise be the one
edit that passes. Only the --report default must exist; a path named on the command line is
ad-hoc, which the throwaway-registry self-tests rely on. Mutation-checked in both directions.

source is validated against its SPEC §3 set. validate_rows() checked status and oracle
against closed sets but never source, so an invented token read as meaningful — six rows said
generator, the only ones in 900. Same shape of hole as the report one.

A separate config recipe. The CellProfiler comparison needs oracle-side settings
morphology.shape2d_native does not carry (raw/255, background padding), so it takes its own
morphology.cellprofiler_edge_intensity id — the split radial.cellprofiler_8bin already uses —
and names the Nyxus production cell on both axes.

What the rebase absorbed

  • The coverage_report.md drift correction. main regenerated it since, so that is dropped
    rather than replayed. What was not closed is the hole that let it happen, which this PR keeps.
    The report change here is one row and one headline figure: the single demotion above
    (morphology 77 → 76, headline 606 → 605).
  • The registry schema moved under it. 759 → 900 rows, gaining test_name and benchmark;
    check_coverage.py gained benchmark / test_name / config_recipe validators. The six rows
    were rewritten against that schema and now carry the test_name and benchmark SPEC §3 asks for.
  • not_covered.md §C closes the entry the way main now closes them — row removed from the
    "not yet satisfied" table, finding written up in prose — rather than struck through under a
    heading that says otherwise.

Verification

  • Oracle: ALL CHECKS PASSED, exit 0 — 5 verified, 0 failed, 0 unproducible, 0 unpinned,
    identity holds; 5/5 tree mutations caught (table above)
  • gtest: 887 passed / 1 skipped of 888 (Windows, MSVC/Ninja; the skip is
    TEST_2D_GABOR_GPU_RUNS_MECHANICS on a CPU-only build)
  • ASan + UBSan (gcc 13, -fsanitize=address,undefined -fno-sanitize-recover=undefined, Linux):
    gtest 883 passed / 1 skipped of 884, 0 diagnostics, wrapper exit 0 — the gate log's md5
    block matches the tree on every changed file. The four-test delta from Windows is the DICOM
    HU-loader tests, absent from a NOEXTRAS=ON build. Instrumented pytest tests/python/:
    95 passed / 1 skipped / 7 Arrow, again 0 diagnostics, with test_nonimq_wsi_scalability
    deselected — it aborts on a pre-existing UBSan finding, src/nyx/features/glszm.cpp:426: signed integer overflow: 46341 * 46341 cannot be represented in type 'int', reached through the
    2D whole-slide path. Not attributable to this PR: the src tree hash is identical to
    upstream/main, so the instrumented module is built from the same bytes.
  • pytest tests/python/: 96 passed / 1 skipped (7 pre-existing Arrow failures of a
    tiff-only build); vetting self-tests 14/14
  • check_coverage.py --check and check_test_names.py --check clean; --write reproduces the
    committed report byte for byte
  • LF throughout; --shortstat and --ignore-all-space --shortstat agree, so no whitespace churn

Commits

  1. test(vetting): run the CellProfiler oracle -- five edge-intensity rows proved, one disproved
  2. test(vetting): fail --check when coverage_report.md no longer matches the registry
  3. test(vetting): bind the CellProfiler generator to the fixture and pins it claims to check
  4. test(vetting): measure the three morphology contour cells, and find an out-of-core defect

Commits 3 and 4 are the two review rounds; every blocker was verified against the tree before being
acted on, and all of them held.

Left open, deliberately

  • The out-of-core PERIMETER defect. A src/nyx fix, characterized here and recorded in
    not_covered.md §G.
  • Which estimator Nyxus means to report. Moments4::std() is shared across features, so Nyxus
    reports the sample estimator wherever it is used while the tools it is vetted against report the
    population one. A src/nyx decision, recorded in §C and pinned as an identity by the generator so
    the answer cannot drift while it waits.
  • The rest of the morphology matrix. matrix/morphology.md covers the ContourFeature group
    and MASS_DISPLACEMENT — the features this PR makes a claim about. Hull, caliper, moments-fit
    ellipse and fractal have no rows yet, and the file says so rather than reading as complete.

Demian Vladi added 2 commits August 31, 2026 07:03
…s proved, one disproved

The six morphology rows reading oracle=cellprofiler carried no evidence: no version, no
config, no generator, so nothing in the tree distinguished a CellProfiler number from a
Nyxus one and the vetted verdict rested on the tracker alone (not_covered.md section C).
gen_morphology_cellprofiler.py closes that by running the real
cellprofiler.modules.MeasureObjectIntensity (4.2.8) on the shape2d fixture.

Five reproduce and are now vetted for real, with tool, version, module, recipe and
generator recorded: MASS_DISPLACEMENT and the mean, max, min and integrated edge
intensities. The agreement is exact rather than approximate because both tools select the
same edge pixels -- CellProfiler's edge is find_boundaries(mode="inner"), so an object
pixel is an edge pixel unless all four of its N/S/E/W neighbours share its label, which on
this fixture is 18 of the 26 ROI pixels summing to 753 against the ROI's 1048. The residual
is 5.2e-8 relative and is entirely CellProfiler storing the image as float32.

The assertion band tightens from 0.1% to 1e-6 relative to match that measurement. The old
band was inherited from the shared snapshot helper and was loose enough to pass the
estimator gap described below, which is the kind of tolerance that makes a test unable to
fail on the thing it claims. Mutation-checked: a 8.8e-6 perturbation of the mean fails.

EDGE_STDDEV_INTENSITY does not reproduce and is demoted to regression. Over the identical
18 pixels Nyxus divides the variance by n-1 (Moments4::std, a helper shared across
features) and CellProfiler by n, so the two differ by exactly sqrt(n/(n-1)) = 1.0289915 at
n=18: 16.769194 against 16.296728. That is a definitional gap, not a tolerance one. Its
row now reads regression with candidate_oracle=cellprofiler and flag=estimator-divergence,
and its snapshot moves to test_2d_morphology_regression.h so the _cellprofiler file holds
only assertions CellProfiler actually backs. The generator checks the ratio as an identity
rather than printing it, so the relationship cannot drift unnoticed in either direction.

The CellProfiler comparison needs oracle-side settings morphology.shape2d_native does not
carry -- the image fed as raw/255, the fixture padded with background -- so it gets its own
recipe id rather than sharing that one, the same split radial.cellprofiler_8bin already
uses. The six rows also gain the test_name and benchmark SPEC 3 asks for, which they could
not before: the assertion they name now exists as one gtest case per status.

coverage_report.md is regenerated from the registry, which is where the one-feature drop
comes from (morphology 77 -> 76, headline 606 -> 605). It is the single demotion above and
nothing else.

Which estimator Nyxus means to report is a src/nyx question and is left open here.
… the registry

coverage_report.md is generated from oracle_coverage.csv and says so in its own header, but
nothing verified that it still matched, so "re-run --write after editing the registry" was
remembered rather than enforced. It has been forgotten before: PolusAI#422 demoted ten GLCM matlab
rows without regenerating, and for as long as that stood the report in the tree claimed glcm
118/118 vetted against a registry holding 108, with the headline overstating coverage by ten
features. That instance has since been regenerated on main; what has not been closed is the
hole that let it happen, and every family PR since has had to remember on its own.

--check now compares the rendered text to the file on disk. A registry with no report beside
it stays clean, since the self-tests validate ad-hoc registries in tmp_path.

The self-test covers both directions: freshly written passes, registry-moves-on fails, and
absent report passes. Mutation-checked against the real tree -- editing one figure in the
report makes --check exit 1. SPEC 3.1 records the rule and why a stale report is a
correctness problem rather than a tidiness one.
@darkclad
darkclad force-pushed the main-cellprofiler-provenance branch from 37b2442 to 5e0672a Compare August 31, 2026 14:25

# tests/test_data.h: shape2d_morphology_intensity and shape2d_morphology_mask, as rows y=0..7
# of columns x=0..7. A single irregular concave ROI with one interior hole at (x=3, y=3).
INTENSITY = [

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker: This is a second copy of the committed fixture. The generator never reads tests/test_data.h, so a fixture edit can change the gtest input while this oracle stays green. Please parse the named arrays from test_data.h (or move them to one shared machine-readable fixture); as written, this does not actually run CellProfiler on the checked-in fixture it claims to consume.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

OBJECTS = "objs"

# goldens CellProfiler reproduces -- pinned in tests/test_2d_morphology_cellprofiler.h
GOLDENS = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker: These are a second copy of the C++ goldens, and the script never opens test_2d_morphology_cellprofiler.h. The registry statement that this generator “re-verifies every pin in the header it feeds” is therefore false. Please read the reference table from the header and require forward and reverse key equality; apply the same binding to the Nyxus stddev pin so an edited, missing, or extra pin must fail.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

Comment thread tests/test_2d_morphology_cellprofiler.h Outdated
ASSERT_TRUE(morphology_2d_cellprofiler_ref_vals.count(feature_name) > 0) << feature_name;
ASSERT_TRUE(agrees_gt(fvals[static_cast<int>(feature)][0], morphology_2d_cellprofiler_ref_vals.at(feature_name), 1000.0));
// 1e6 -> a 1e-6 relative band, set from the measured 5.2e-8 float32 residual rather than left
// at the 0.1% the shared snapshot helper used; a band that loose would pass the sqrt(n/(n-1))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker: This explanation is mathematically wrong. sqrt(18/17) - 1 = 2.899%, while the old band was 0.1%; the CellProfiler value would have missed by about 29 times that band. The old test passed because its golden was the Nyxus snapshot, not because the tolerance absorbed the estimator gap. Tightening to 1e-6 is justified by the measured residual, but please correct this comment and the same claim in the PR body/registry notes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

- `PERIMETER` is **not** comparable on this fixture (Nyxus chain-code walk 26.935 vs skimage 12.657
on a 26-pixel object with a hole); it is vetted at `morphology.perimeter_circles` instead.

## morphology.cellprofiler_edge_intensity

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker: This recipe describes the CellProfiler side but not the exact Nyxus production cell. The test uses regular in-RAM ContourFeature::calculate() with SINGLEROI=false; the whole-slide and osized_calculate() paths are separate reachable paths. Please narrow this recipe to the cell actually vetted and record the remaining path dispositions in the SPEC-required morphology matrix (tests/vetting/matrix/morphology.md, currently absent).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — the fixture/header binding, tolerance explanation, registry source, and missing-report enforcement are fixed on c12810b. This thread still has one substantive SPEC §5 gap:

  • tests/vetting/config_recipes.md:221-226 names only ContourFeature::calculate(), but the same oracle test also asserts MASS_DISPLACEMENT, which is produced by BasicMorphologyFeatures::calculate() (with its own osized_calculate() path). It does not inherit a contour-builder cell.
  • tests/vetting/matrix/morphology.md:24-25 labels two reachable production cells NOT MEASURED. SPEC §5 permits VALID → oracle, VALID-BUT-PRODUCTION-ONLY → regression, or INVALID → dropped with a reason; it explicitly requires every cell to have a disposition. The file itself says neither reachable path is asserted.

Please give the five contour statistics and MASS_DISPLACEMENT their actual production-path rows, measure/classify the reachable cells, and add the corresponding registry-backed assertions. The current matrix is useful analysis, but “not measured” does not yet satisfy the matrix requirement.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction: I overstated the second half of my follow-up. This PR now scopes its vetted claim to the in-RAM SINGLEROI=false assertion and explicitly records the other paths as unmeasured. Completing those paths is follow-up matrix work; it does not invalidate the five CellProfiler comparisons, and I am not asking for new path tests or registry rows in this PR.

Only two non-blocking wording corrections remain: name BasicMorphologyFeatures::calculate() as the producer of MASS_DISPLACEMENT, and change the title from “five edge-intensity rows” to “five CellProfiler rows” (the proved set is four edge-intensity features plus mass displacement).

Comment thread tests/vetting/oracle_coverage.csv Outdated
2D,WEIGHTED_CENTROID_Y,morphology,vetted,matlab,agreed,morphology.shape2d_native,rel=1e-3,test_2d_morphology_matlab.h;test_2d_morphology_regression.h,,,,audit,"vetted vs GNU Octave 11.3.0 + image 2.20.0 regionprops on the shape2d fixture, recipe morphology.shape2d_native; agreement 0 to ~1e-15 on every row (gen_morphology_matlab.m verifies all 33 pins). MATLAB applies the same +1/12 pixel finite-size second-moment correction Nyxus does, which is why the ellipse triple vets here and not against skimage.",,
2D,WEIGHTED_CENTROID_X,morphology,vetted,matlab,agreed,morphology.shape2d_native,rel=1e-3,test_2d_morphology_matlab.h;test_2d_morphology_regression.h,,,,audit,"vetted vs GNU Octave 11.3.0 + image 2.20.0 regionprops on the shape2d fixture, recipe morphology.shape2d_native; agreement 0 to ~1e-15 on every row (gen_morphology_matlab.m verifies all 33 pins). MATLAB applies the same +1/12 pixel finite-size second-moment correction Nyxus does, which is why the ellipse triple vets here and not against skimage.",,
2D,MASS_DISPLACEMENT,morphology,vetted,cellprofiler,agreed,morphology.shape2d_native,,test_2d_morphology_cellprofiler.h,,,,tracker,,,
2D,MASS_DISPLACEMENT,morphology,vetted,cellprofiler,agreed,morphology.cellprofiler_edge_intensity,rel=1e-6,test_2d_morphology_cellprofiler.h,test_2d_morphology_cellprofiler.h,,,generator,"CellProfiler 4.2.8 MeasureObjectIntensity on the shape2d fixture, recipe morphology.cellprofiler_edge_intensity; the edge set is find_boundaries(mode=inner), 18 of the 26 ROI pixels summing to 753 against the ROI's 1048 -- the same pixels Nyxus walks, which is what makes this an exact comparison. Measured agreement 5.2e-8 relative, the residual being CellProfiler's float32 image storage rather than a disagreement; asserted at 1e-6 relative in test_2d_morphology_cellprofiler.h, which is above that residual and far under the sqrt(n/(n-1)) gap that disqualified EDGE_STDDEV_INTENSITY. Generator tests/vetting/oracles/gen_morphology_cellprofiler.py re-verifies every pin in the header it feeds.",TEST_NYXUS.TEST_2D_MORPHOLOGY_EDGE_INTENSITY_CELLPROFILER,bench_shape8_concave_holed

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker: source=generator is not a SPEC §3 source value; the defined values are in-tree, tracker, and audit. These rows name a runnable gtest assertion, so the source should be in-tree, with the offline generator cited in provenance/notes. This row also says the generator re-verifies the header, which is not true until the generator actually reads it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

"""[] if coverage_report.md matches what the registry renders to, one error if it does not.
A missing report is not an error -- --check runs against ad-hoc registries in the self-tests,
which have no report beside them."""
if not os.path.exists(report_path):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker: This makes deleting the canonical coverage_report.md pass --check, undercutting the new SPEC promise that the committed report is enforced. Please distinguish ad-hoc registry use from the canonical default: a missing canonical report must be an error. Add that deletion as a negative test rather than explicitly blessing it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

@vjaganat90 vjaganat90 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The core result is valuable and likely correct: direct MeasureObjectIntensity output supports four edge-intensity statistics plus MASS_DISPLACEMENT, and demoting EDGE_STDDEV_INTENSITY for the population-vs-sample estimator difference is the honest disposition.

I cannot approve this version because the evidence chain is still self-referential and several SPEC or mathematical claims are incorrect; the inline blockers give the exact fixes.

Apart from those inline issues, please rewrite the PR description against the current rebased diff:

  • 11 changed files, not 9.
  • Coverage moves 606 → 605 and morphology 77 → 76; the current diff contains no ten-feature GLCM drop.
  • The 3ROBUST_MEAN malformed-row note is stale; that row is already fixed.
  • Say five CellProfiler rows rather than five edge-intensity rows: one of the five is MASS_DISPLACEMENT.
  • Correct the tolerance story: the old failure was a Nyxus snapshot presented as a CellProfiler golden. A 0.1% band could not absorb the measured 2.899% estimator gap.

The historical #422 incident is still useful motivation for the report checker, but it should not be described as a change in this diff. Once the generator reads the repository fixture and header, the recipe/matrix and registry source are SPEC-compliant, deletion of the canonical report fails, and the description matches the branch, the central five-vetted/one-regression conclusion will be well supported.

…s it claims to check

Review round on PolusAI#425. Six blockers, each verified against the tree before being acted on;
all six held.

gen_morphology_cellprofiler.py held its own copy of the shape2d fixture and its own copy of
the goldens, and opened neither test_data.h nor the headers. So a fixture edit could move
the gtest input while the oracle stayed green, and the registry's claim that the generator
"re-verifies every pin in the header it feeds" was simply false. It now parses both
fixtures out of test_data.h and both reference tables out of the headers, and enforces key
equality in BOTH directions: a pin that disagrees fails, a pin for a feature this recipe
does not vet is EXTRA, a feature CellProfiler vets with no pin is UNPINNED, and
EDGE_STDDEV_INTENSITY appearing in the cellprofiler header is MISPLACED. The Nyxus stddev
pin is read from the regression header too, so the sqrt(n/(n-1)) identity is checked
against the number the C++ side actually asserts. Five mutations of the tree -- edit a pin,
delete a pin, add an unbacked pin, edit the stddev pin, edit the fixture -- each exit 1.
This is the binding gen_neighbor_cellprofiler.py already used; this generator should have
followed it.

The comment justifying the tightened band was wrong and is corrected. It said the old 0.1%
band was loose enough to pass the estimator gap. The gap is 2.9% relative, 29 times that
band, so 0.1% would have caught CellProfiler's value. What let the old assertion pass is
that its golden was the Nyxus number: a test labelled cellprofiler judging Nyxus against
itself, which is the condition not_covered.md section C exists to surface and a sharper
problem than a loose tolerance. Corrected in the header, both registry notes and section C,
which now states the real reason explicitly because the wrong one is what a reader reaches
for first. Tightening to 1e-6 stands on the measured 5.2e-8 residual alone.

source=generator is not a SPEC 3 value -- the set is in-tree / tracker / audit -- and those
six rows were the only ones in 900 using it. They name a runnable gtest case, so they read
in-tree and the generator is cited in notes. It survived because validate_rows checked
status and oracle against closed sets and never source, so an invented token read as
meaningful; ALLOWED_SOURCES closes that, with a self-test over valid, invented and blank.

A missing coverage_report.md passed --check, which made deleting the file the one edit the
new staleness rule could not see. report_staleness now takes a canonical flag: --report
defaults to None and resolves to DEFAULT_REPORT, so a missing CANONICAL report is an error
while a missing ad-hoc one still claims nothing -- the self-tests validate registries in
tmp_path with no report beside them. Negative test added for the deletion.

The recipe now names the Nyxus production cell it vets, ContourFeature::calculate() at
SINGLEROI=false, rather than describing only the CellProfiler side, and says that the
whole-slide and osized_calculate paths are not covered. matrix/morphology.md records all
three cells, which SPEC 5.1 requires and the family did not have. Writing it surfaced two
untriaged points, recorded there as open questions rather than findings because this
vetting did not run those paths: buildWholeSlideContour() pushes the four AABB corners at
aux_max, so every edge statistic at SINGLEROI=true is degenerate by construction; and
osized_calculate() computes PERIMETER as a contour pixel count where calculate() sums
Euclidean step lengths, with no empty-contour guard where calculate() has one. The two
paths are required to agree, so that is a cell worth measuring.

Still no src/nyx change.
@vjaganat90

Copy link
Copy Markdown
Member

One small description cleanup remains: the title still says “five edge-intensity rows proved,” but the proved set is MASS_DISPLACEMENT plus four edge-intensity rows; EDGE_STDDEV_INTENSITY is the sixth, demoted row. “five CellProfiler rows proved, one disproved” would match the actual result.

@vjaganat90 vjaganat90 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM - Just fix the MASS_DISPLACEMENT function attribution and wording.

…n out-of-core defect

Second review round on PolusAI#425. Both points held: the recipe named only ContourFeature's cell
while the same oracle test also asserts MASS_DISPLACEMENT, and matrix/morphology.md left two
reachable production cells labelled NOT MEASURED, which is not one of the three dispositions
SPEC 5.1 defines.

MASS_DISPLACEMENT does not inherit a contour-builder cell. It is computed by
BasicMorphologyFeatures::calculate() from the geometric and intensity-weighted centroids,
reads no contour, and has its own osized_calculate(). It gets its own axis in the matrix,
landing on the same dispositions by measurement rather than by inheritance.

Both NOT MEASURED cells are now measured, per SPEC 5.2's rule that verdicts are measured
rather than hand-labelled, on one 64x64 single-disk fixture so the three cells are
comparable numbers on one shape:

  feature                    segmented        out-of-core      whole-slide
  PERIMETER                  131.88225099     112.0            256.0
  MASS_DISPLACEMENT          2.75261401       2.75261401       3.34531182
  EDGE_MEAN_INTENSITY        257.0            257.0            397.0
  EDGE_STDDEV_INTENSITY      98.12659593      98.12659593      0.0
  EDGE_MAX / EDGE_MIN        397.0 / 117.0    397.0 / 117.0    397.0 / 397.0
  EDGE_INTEGRATED_INTENSITY  28784.0          28784.0          1588.0

The whole-slide cell is VALID-BUT-PRODUCTION-ONLY and degenerate exactly as the source
predicts: buildWholeSlideContour() pushes the four AABB corners each carrying aux_max, so
min == max == mean == the image maximum, stddev is 0 and integrated is 4*maximum. It is a
different quantity from the per-object edge statistics CellProfiler vets, which is why the
vetted rows state their scope as the segmented in-RAM path.

The out-of-core cell agrees exactly for the five EDGE_* statistics and MASS_DISPLACEMENT --
and does NOT agree for PERIMETER. osized_calculate() sets fval_PERIMETER = K.size(), the
contour pixel count, where calculate() sums Euclidean step lengths: 112.0 against
131.88225099390849, ~15%, and the out-of-core value being an integer is what identifies the
definition rather than accumulation as the cause. CLAUDE.md requires the two paths to produce
identical values, so this is a defect and not a convention. It is characterized here, not
fixed: the fix is a src/nyx change and PERIMETER is vetted under a different recipe.

Why nothing caught it: test_2d_ooc_invariant.py has asserted *ALL_MORPHOLOGY* equality across
the two paths since it was written, and passes. Its fixture is a full-image RECTANGLE, and
around a rectangle every contour step is an axis-aligned unit step, so the pixel count and
the Euclidean sum are the same number. The invariant is sound; that shape cannot discriminate.
A disk separates them. The general form -- a path-equality fixture symmetric in the axis under
test proves nothing about that axis -- is recorded in not_covered.md section G, together with a
note to audit the other _ooc_ fixtures on the same grounds.

Three assertions and seven registry rows back the cells: a discriminating-fixture invariant
for the six features that agree, test_2d_ooc_regression.py pinning the PERIMETER divergence so
a correct fix must break it, and test_2d_morphology_regression.py pinning the whole-slide cell
and asserting it differs from the segmented one so the CellProfiler evidence cannot be read
across. The coverage report is unchanged: these are regression rows for features whose rollup
is already vetted.

Still no src/nyx change.
@vjaganat90

Copy link
Copy Markdown
Member

Thank you for doing the measurement work — my correction crossed this commit while it was in flight. I had over-scoped the matrix requirement, so please move 02af7fb to a follow-up and keep this PR focused on the CellProfiler evidence. Retain only the small BasicMorphologyFeatures::calculate() wording correction from it, plus the pending title correction.

That is also the safest technical split. The new commit expands this PR from 12 to 15 files and introduces a separate out-of-core PERIMETER defect characterization. If retained here, it needs another review round: the new MASS_DISPLACEMENT registry row still says SINGLEROI=true reaches ContourFeature::buildWholeSlideContour; the matrix marks out-of-core EDGE_* as VALID based only on an internal invariant even though SPEC defines VALID as oracle-backed; the new assertion rows have no config recipe/benchmark; and the PR body/title/file count no longer match the branch.

The disk fixture and the PERIMETER finding are worthwhile, just independently reviewable follow-up work. They are not necessary to support the five CellProfiler comparisons in this PR.

@darkclad darkclad changed the title test(vetting): run the CellProfiler oracle — five edge-intensity rows proved, one disproved test(vetting): run the CellProfiler oracle — five CellProfiler rows proved, one disproved Aug 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants