Skip to content

Codebase audit: fix wrong-result, correctness, cleanup and performance findings - #439

Merged
pastewka merged 23 commits into
masterfrom
claude/codebase-audit-163x4g
Jul 25, 2026
Merged

Codebase audit: fix wrong-result, correctness, cleanup and performance findings#439
pastewka merged 23 commits into
masterfrom
claude/codebase-audit-163x4g

Conversation

@pastewka

@pastewka pastewka commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

This PR works through a full audit of the codebase, tier by tier: high-severity wrong-result bugs, medium-severity correctness and metadata issues, low-severity cleanups, and the performance findings. All fixes include regression tests. What was deliberately left unchanged, and why, is recorded in the sections below.

Fixes #282 — the +q/-q folding in the power spectrum halved the q = 0 entry, which receives no mirrored contribution. fold_fft_half now halves only [1:n//2], i.e. exactly the C_all[1:] /= 2 the issue asks for. The Nyquist entry is structurally excluded, since the folded output stops at n//2.

1. High severity — silent data corruption / wrong results

Geometry and derivatives

  • TransposedUniformTopography now swaps pixel_size, nb_subdomain_grid_pts and subdomain_locations
  • DownsampledUniformTopography reports correct pixel_size and area_per_pt
  • DecoratedUniformTopography.pixel_size derives from the decorator's own geometry instead of the parent's
  • Derivative normalization infers the operator direction from the stencil's nonzero coefficients, so anisotropic pixels scale the right axis

File I/O readers

  • Reshape order fixed for non-square scans in the DI, EZD, MI and PS readers
  • MI reader converts lateral sizes from meters to the requested unit
  • BCR reader interprets voidpixels as a count and masks against format markers
  • Channel selection fixed in the NMM and OIR readers (late-binding closures, index tracking)
  • EZD magic check fixed (bytes vs. str, inverted logic)
  • ZAG container reader re-opens the ZIP per read

C++ extensions

  • _TriangleMoment and _LineScanMoment rewritten in exact polynomial form (complete homogeneous symmetric polynomials), removing sign errors, missing prefactors and division-by-zero crashes
  • Bicubic.__call__ enforces C-contiguity and respects strides; scalar derivative returns match the requested order

Statistics and analysis

  • Periodic 2D bearing-area bounds use explicit axes in np.roll
  • to_uniform() interpolates at the correct x-positions for scans whose origin is not zero
  • _slope_detrend_coeffs uses a length-weighted slope and the matching offset
  • Hann window evaluation in the nonuniform power spectrum is origin-invariant
  • Scale-dependent curvature stencil handles log-spaced and reliability-trimmed ACF grids
  • fourier_synthesis zeroes the DC mode instead of setting it to C(q=1)
  • scale_dependent_statistical_property distance calculation fixed; the container variant no longer shadows its outer loop variable and accepts a scalar distances
  • make_sphere(..., periodic=True) passes the periodic flag through
  • Scan-line alignment direction semantics corrected (transpose for 'x', not 'y')
  • Nonuniform.moment() handles equal adjacent heights analytically
  • interp_derxy was assigned interp_derxx in the bicubic interpolator
  • Fourier interpolation only splits the Nyquist entry along directions that are actually enlarged
  • Linear interpolation uses floor rather than truncation, so negative positions on periodic data interpolate instead of extrapolating

2. Medium severity

  • Undefined (masked) data is normalized by the number of defined points throughout, instead of by the full grid size. This changed the reference values of several IO tests whose fixtures contain undefined data — the old values were dominated by a mean-normalization artifact, and each updated value carries a comment saying so.
  • Analysis-function registration is copy-on-write per class with MRO-merged lookup, so registering on a subclass no longer mutates the base class's table.
  • Correctness under non-default options: reliability cutoffs, resampling methods and window arguments.
  • Metadata and unit handling: unit-less channels no longer fail info validation.

3. Low severity

Reader edge cases (AL3D row stride, WSXM and DZI flat-scan guards, ZON strides and a non-float32 rejection, Text channel bounds, JPK IndexError, FRT explicit byte order), numerical conditioning (the nonuniform polyfit normal equations are solved in scan-centered coordinates, so detrending no longer depends on where the scan sits on the x-axis), a corrected Nyquist frequency for anisotropic pixels, stacklevel on deprecation warnings, and a batch of docstring corrections (two-sided integrals, polyfit2 coefficient order, moment prefactors, derivative trimming semantics).

Two items were left deliberately unchanged: the NMS height-scale divisor (2**16 - 2 vs 2**16 - 1), because no reference implementation or fixture can decide it, and the latent non-square axis pairing in DATX/LEXT, which cannot be proven without a non-square fixture.

4. Architecture

test/test_invariants.py adds property-based invariant tests: transpose/translate/scale invariance of scalar parameters with anisotropic pixels, decorator geometry consistency (pixel_size == physical_sizes / nb_grid_pts for every decorator), bearing-area CDF properties, and origin invariance of nonuniform detrending.

5. Performance, under the library's memory constraints

The performance findings were re-assessed against the memory boundary conditions of the library: a container streams its topographies from disk and must never hold more than one of them in memory, and readers are long-lived metadata handles that must not pin file data. Several of the apparent performance penalties exist precisely because of those constraints, so these changes either reduce memory use or leave it unchanged.

Fixed, and memory use goes down:

  • Bicubic spline coefficients are computed per cell with a single-cell cache instead of being tabulated for every pixel, which cost 128 bytes/pixel before the first evaluation. A 16384² map now constructs and evaluates in 2.1 GB peak RSS; the eager table alone would have needed 34.4 GB.
  • The IBW, MI and MNT readers no longer retain wave data, the whole file, or all decompressed blocks past __init__.
  • The flood-fill stack is allocated once per call instead of 16 MB per patch.
  • Gaussian process regression reuses one Cholesky factorization and computes only the variance diagonal instead of the full predictive covariance.

Fixed, memory-neutral:

  • Container PSD integration precomputes each topography's bandwidth interval in a single streaming pass (two scalars each; heights are never cached) and answers counting queries with searchsorted, turning N² file reads into 2N as the pre-existing TODO proposed.
  • The MNT zlib scan measures candidate streams through a chunked decompressobj over a memoryview and skips past accepted streams, instead of slicing the remaining buffer and fully decompressing it at every candidate byte.
  • The nonuniform height-height autocorrelation moved into a C++ kernel that runs in constant additional memory, rather than being vectorized into an O(N·M) intermediate.
  • The rigid-sphere scan is vectorized over blocks sized so the temporary stays bounded irrespective of scan length and tip radius.

Deliberately unchanged, because the speedup would cost memory: the correlation function's pixel sweep (its FFT alternative needs several full-map complex arrays, ~1 GB at 4096², versus O(1) today) and the bearing-area full-grid sweep per query height (a sorted cache adds another O(N) array and only pays off when many heights are queried).

Related issues not closed by this PR

  • Interpolate bicubic throws segmentation fault on surface topographies of size 32768 x 32768 #318 (bicubic crash on very large maps) is materially improved — the 128 bytes/pixel eager table is gone, so a 16384² map now works in 2.1 GB — but not fixed: n1_ * n2_ is still an int × int product in cpp/bicubic.h, which overflows at and beyond 46341², including the ~65000² case in the issue body. That needs a wider index type throughout and is out of scope here.
  • XYZ reader should not parse file twice #339 (XYZ reader parses the file twice) is intentionally not fixed. Caching the parsed columns on the reader would pin the whole point cloud for the reader's lifetime, which is exactly what the lazy-reader rule forbids; only the per-token allocation cost was reduced. Closing it would require deciding whether the Celery-worker argument in the issue outweighs the memory rule.

Testing

Full suite: 2375 passed, 6 skipped locally. flake8 SurfaceTopography test is clean. Every numerical change was checked against the previous implementation directly, not only through the test suite — the new ACF kernel matches the old Python loop to 6e-17, the chunked rigid-sphere scan is bit-identical across four tip radii on uniform and nonuniform scans, GP mean and variance match to 1e-11 and 1e-15, and the MI reader returns identical heights via path, open stream, repeated calls and a stream handed over at a nonzero offset.

The three tests that require downloading from contact.engineering are excluded locally (sandbox has no network access to that host) and are covered by CI.

https://claude.ai/code/session_01TPEpAGDASUXBVpV5BVDnUZ

claude added 21 commits July 23, 2026 06:06
…hitecture)

Consolidated findings from a systematic audit of the core topography
classes, Uniform/Nonuniform/Generic analysis modules, all IO readers,
Container/Support infrastructure, and the C++ extensions. Findings are
severity-ranked with file:line references; cross-cutting themes and a
suggested triage order are included at the end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPEpAGDASUXBVpV5BVDnUZ
DecoratedUniformTopography now derives pixel_size and area_per_pt from
the physical_sizes and nb_grid_pts of the decorated topography itself
instead of forwarding the parent's values. Decorators that change the
geometry (transpose, downsample) previously reported the parent's pixel
size, silently corrupting every derivative-based quantity (rms_gradient,
scale-dependent slope/curvature, PSD normalizations) for transposed
topographies with anisotropic pixels and for downsampled topographies.

- TransposedUniformTopography additionally swaps nb_subdomain_grid_pts
  and subdomain_locations and no longer crashes in positions() for line
  scans.
- DownsampledUniformTopography now reports physical_sizes consistent
  with the retained samples when the downsampling factor does not divide
  the number of grid points.
- ScaledUniformTopography drops its pixel_size/area_per_pt overrides,
  which would now double-apply the scale factor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPEpAGDASUXBVpV5BVDnUZ
- DI, EZD, PS: The raw data buffer of these formats is stored scan line
  by scan line, i.e. it has C-order shape (ny, nx). The readers reshaped
  to (nx, ny), which interleaves scan lines into garbage for non-square
  maps. All test fixtures for these formats are square, which is why
  this went unnoticed.
- MI: Same reshape fix. In addition, xLength/yLength are stored in
  meters but were reported in the channel unit (typically um), making
  all mixed lateral/height quantities (rms slope, PSD, curvature) wrong
  by ~10^6; the lateral sizes are now converted to the channel unit.
  The scanUp flag is textual TRUE/FALSE and was evaluated for
  truthiness, so scan-down images were also flipped. Text/ASCII data
  now raises UnsupportedFormatFeature instead of NameError. The
  byte-wise chr()/join()/encode() round trip was replaced by direct
  buffer slicing.
- BCR: 'voidpixels' holds the *count* of void pixels, not the marker
  value. Files with voidpixels=0 previously masked every pixel with
  raw value 0; real void pixels (int16 32767 / float FLT_MAX) were
  never masked; a missing key crashed. Masking now follows the
  BCR-STM/BCRF specification.
- EZD: The format magic check compared bytes against str (always
  unequal) and had inverted logic, i.e. no validation at all; it now
  rejects non-EZD files with FileFormatMismatch.
- NMM: Every scan was registered with channel index 0, so
  channels[i].topography() always returned the first scan.
- OIR: The channel reader lambda captured loop variables by reference;
  files with multiple height channels returned the last channel's data
  for every channel. Loop variables are now bound as default arguments.
- POIR: Channels aggregated from multiple OIR sub-files retained their
  per-file index; selecting a channel could return data from a
  different file. Channels are now renumbered on concatenation
  (ChannelInfo gained an index setter for this purpose).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPEpAGDASUXBVpV5BVDnUZ
…line alignment

- Uniform/BearingArea: The periodic 2D element bounds called np.roll
  with a tuple shift and no axis, which flattens the array and rolls by
  the sum of the shifts. The min/max bounds therefore paired wrong
  pixels and no longer bracketed the exact bearing area, silently
  corrupting median(), mad_height() and the robust detrending modes for
  periodic topographies. bearing_area(median()) is now exactly 0.5.
- Generation.fourier_synthesis: The q=0 Fourier mode was populated with
  a random amplitude equal to C(q=1), giving generated surfaces a
  random, unit-dependent mean offset that typically dwarfs the
  requested rms height. The mean is now exactly zero.
- Special.make_sphere: The periodic flag was silently dropped (the
  returned topography was always nonperiodic); kind='paraboloid' with a
  numeric standoff crashed with UnboundLocalError. The redundant
  fill_undefined_data() wrapper for numeric standoffs was removed.
- Uniform/ScanLineAlignment: direction semantics were inverted relative
  to the library's x-first array convention: the default direction='x'
  (fast scan along x) detrended lines running along y. Scan lines along
  x are columns of the height array and are now transposed accordingly.
  Tests were updated to construct artifacts matching the documented
  semantics; direction is now validated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPEpAGDASUXBVpV5BVDnUZ
…tatistics

- Nonuniform/Converters.to_uniform: The uniform interpolation grid always
  starts at zero, but the parent scan's x-coordinates can start anywhere;
  np.interp clamps outside the data range, so scans with an offset origin
  produced garbage heights (and thereby garbage FFT-based PSD, ACF,
  derivatives and reliability cutoffs). The interpolation points are now
  shifted by the parent's origin.
- Nonuniform/Detrending 'slope' mode: The rms-slope-minimizing constant
  slope is the length-weighted mean of the derivative,
  (h[-1] - h[0]) / L, not the plain mean of the per-segment slopes; and
  the offset must remove the mean of the *tilt-corrected* profile, which
  requires the -a1*<x> correction for scans not starting at x = 0.
- Nonuniform/PowerSpectrum: The Hann window was evaluated at absolute x
  instead of x - x.min(), defeating leakage suppression for scans with an
  offset origin.
- Generic/Curvature: The scale-dependent curvature stencil
  8A(lambda) - 2A(2*lambda) was implemented via index arithmetic that is
  only valid on a linearly spaced ACF grid anchored at zero. The default
  arguments deliver a log-spaced grid, and reliability trimming shifts
  the origin, silently pairing wrong distances. A(2*lambda) is now
  obtained by interpolation, which is exact on linear origin-anchored
  grids and correct on any other grid. Verified against the analytic
  curvature of a cosine profile and against artifact-free ground truth
  in the tip-artifact tests (error drops from 48% to 6%); test
  tolerances that were calibrated against the old behavior were updated
  and now additionally compare against the unartefacted topography.
- Generic/ScaleDependentStatistics: When called with scale_factor, the
  reported distance was scale_factor * mean(physical_sizes) instead of
  scale_factor * n * mean(pixel_size) - too large by a factor of
  nb_grid_pts/n - and this wrong distance also fed the reliability
  mask. A list-valued distance no longer crashes the reliability check.
- Uniform/Derivative: interpolation='disable' with a fractional scale
  factor silently truncated the stencil step while normalizing by the
  fractional pixel size; the guard compared against the misspelled
  option 'disabled' and was unreachable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPEpAGDASUXBVpV5BVDnUZ
read_container() opens the container reader as a context manager and
returns the lazy container after __exit__ has closed the underlying
stream. ZAGReader handed ZipExtFile streams bound to that stream to the
ZONReaders it constructs, so every element access on the returned
container read from a closed file. The readers now receive a callable
(ZAGFileOpener) that reopens the ZIP member on each read, following the
pattern established by CEFileOpener.

The only ZAG test was skipped and pointed at a hardcoded local path; it
is replaced by tests that synthesize a minimal ZAG container around the
zon-1.zon fixture, including a regression test for container access
after the reader has been closed. Also use logging.getLogger instead of
instantiating a detached Logger.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPEpAGDASUXBVpV5BVDnUZ
…put handling

- cpp/moments.h: The generic triangle moment had a sign error and a
  missing 2/(h3-h1) prefactor, and the order-1..3 specializations were
  equally wrong: uniform2d_mean of a constant surface at height 5
  returned 0, uniform2d_variance of a terraced surface returned 0, and
  the (unspecialized) order-4 path divided by height differences without
  a degeneracy guard, returning NaN for quantized data. All triangle
  moments are now computed from the barycentric integral formula,
  <h^n> = 2/((n+1)(n+2)) H_n(h1,h2,h3), where H_n is the complete
  homogeneous symmetric polynomial - exact, stable, no sorting and no
  division by height differences. The line-scan moments use the same
  polynomial form for the generic (order >= 4) case, replacing an
  epsilon guard that returned 0 for flat segments (the correct limit for
  a constant profile at height 2 is 16, not 0). Tests that had codified
  the broken values are corrected and extended with analytic references.
- SurfaceTopography/Nonuniform/ScalarParameters.moment: same removable
  0/0 singularity in pure Python; any two equal adjacent heights
  (quantized instrument data) propagated NaN into the result. Replaced
  by the equivalent complete homogeneous polynomial.
- cpp/bicubic.cpp: Array inputs were only 'ensure'd, not forced
  C-contiguous, but the evaluation loop walks the buffer linearly:
  strided views returned silently wrong values and negative strides read
  out of bounds. Inputs are now converted with c_style | forcecast. The
  scalar path also ignored the derivative argument and returned a bare
  float where callers unpack 3- or 6-tuples.
- SurfaceTopography/Uniform/Interpolation.py: the second-order bicubic
  cross-derivative was overwritten by a copy-paste error
  (interp_derxx / dx / dy instead of interp_derxy / dx / dy).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPEpAGDASUXBVpV5BVDnUZ
These files are written to the current working directory by the NetCDF
writer tests and were inadvertently picked up by an earlier commit on
this branch. Remove them and ignore them so future test runs cannot
re-add them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPEpAGDASUXBVpV5BVDnUZ
- Support.fold_fft_half: The q=0 entry has no mirrored partner and must
  not be halved; power_spectrum_from_profile(resampling_method=None)
  previously returned half the true DC power (Parseval violation in the
  first bin). The ACF caller was unaffected (A[0] = 0).
- Uniform/PowerSpectrum: With log collocation, q=0 is stripped before
  resampling, so min_value=q[1] discarded the fundamental mode. The
  resampling range now starts at the smallest positive wavevector.
- Uniform/Autocorrelation: The reliability cutoff of the areal ACF now
  carries the same factor 1/2 as the profile ACF (the cutoff is
  estimated from the curvature but the ACF is the slope, see
  10.1016/j.apsadv.2021.100190), and uses the same two-pixel lower
  bound; the area path previously discarded a factor-two band of
  reliable short-distance data.
- Uniform/Interpolation: interpolate_fourier halved (and mirrored) the
  Nyquist rows/columns even when that direction was not enlarged;
  interpolation onto the same grid was not the identity. The linear
  interpolator truncated positions toward zero instead of flooring,
  extrapolating for negative fractional positions on periodic
  topographies (corrupting scale-dependent derivatives with fractional
  scale factors near the origin).
- Nonuniform/VariableBandwidth: The magnification loop recorded a level
  before checking the post-subdivision point count, so the last datum
  could average segments below nb_grid_pts_cutoff (down to two points,
  which have zero detrended rms height), biasing the smallest
  bandwidth low. The boundary-point search tolerance is now relative
  to the subdivision width instead of an absolute 1e-6, which is
  unit-dependent.
- Models/SelfAffine: generate_roughness resolved longcut_wavelength and
  then ignored it; it now synthesizes from the model PSD (which honors
  shortcut, rolloff and longcut). power_spectrum_isotropic no longer
  evaluates the power law at q=0. The order == Hurst branch of
  variance_derivative uses the same lower integration limit as the
  general branch.
- Generation.self_affine_prefactor: Hurst = 0 with rms_height and
  Hurst = 1 with rms_slope hit removable 0/0 limits and silently
  produced all-NaN topographies; they now raise ValueError.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPEpAGDASUXBVpV5BVDnUZ
- Uniform/Detrending.polyfit_line_scan: np.polyfit on a masked array
  silently fits the raw values stored under the mask; masked points are
  now excluded (like the 2D counterpart already does).
- Uniform/VariableBandwidth: The checkerboard detrend built its normal
  equations with np.bincount, which strips masks; masked points now
  enter both the right-hand side and the matrix with zero weight, and
  retain their mask in the output.
- Uniform/ScalarParameters: Rq/Sq normalized by the total number of grid
  points and computed means over the full grid; sums and means now use
  the number of defined points. (A half-masked constant profile now has
  zero rms height; the reference values of two tests changed - notably,
  the old x3p-1 reference of 9.5e-05 was almost entirely a
  mean-normalization artifact, the true rms of that surface is 2.4e-07.)
- Uniform/GeometryAnalysis.assign_patch_numbers_profile: The periodic
  wrap merge assigned the id of the *second pixel* instead of the first
  patch; a masked patch spanning the wrap boundary came back from
  imputation with one pixel still masked while has_undefined_data
  reported False.
- Uniform/Imputation: The Laplace matrix used unconditional np.roll to
  find neighbors, wrapping across the boundary of nonperiodic
  topographies and coupling edge pixels to patch unknown #0. Stencil
  legs crossing a nonperiodic boundary are now dropped with the
  diagonal reduced accordingly (natural boundary condition).
- IO/FromFile (HGT): SRTM void markers (-32768) are now masked instead
  of entering statistics as -32768 m spikes; physical sizes are no
  longer fabricated from the pixel counts, so the user can (and must)
  provide them.
- IO/SDF: The ASCII variant only replaced the literal 'BAD' token;
  integer invalid markers (-2^15/-2^31) are now mapped to NaN like in
  the binary variant. Also replaces the deprecated np.fromstring.
- IO/X3P: The reader now honors the ISO 5436-2 ValidPointsLink validity
  file (one bit per point, LSB first) and the CZ Offset; the writer
  writes a validity file for undefined points and no longer casts NaN
  to integer for integer data types. Round trips verified for all four
  data types with masked data.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPEpAGDASUXBVpV5BVDnUZ
The tests CI workflow has been failing on master since 2026-06-19:
pytest-flake8 is unmaintained and its pytest_collect_file hookimpl is
incompatible with modern pytest (PluginValidationError at startup, the
plugin breaks pytest merely by being installed). Linting is already
covered by the dedicated flake8 workflow, so the plugin and the
flake8<8 pin are removed from the test extra.

Also replace the dependency on 'tiffile' - the deprecated, misspelled
alias package - with 'tifffile', and update the imports in the JPK, PS
and LEXT readers accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPEpAGDASUXBVpV5BVDnUZ
…aders

Core classes:
- translate() crashed for 1D line scans (rolled a nonexistent second
  axis, even with the default zero offset).
- __eq__ raised (broadcast error / AttributeError) instead of returning
  False for mismatched grids or NotImplemented for foreign types, and
  defining __eq__ without __hash__ made all topographies unhashable.
- is_reentrant crashed for line scans with fewer than two points.

Analysis:
- FourierFilteredUniformTopography's default filter_function had the
  wrong arity for the default isotropic=True (and an operator-precedence
  bug had it been called); error paths raised strings instead of
  exceptions. topography.filter() now works out of the box.
- MirrorStichedTopography.positions() used the parent's subdomain shape
  and returned arrays covering only a quarter of the mirrored domain;
  subdomain properties are now consistent with the mirrored grid.
- scale_dependent_statistical_property crashed with TypeError in the 2D
  single-distance threshold branch (assignment into a tuple).
- scale_dependent_slope_from_area is no longer registered for
  nonuniform line scans, which lack autocorrelation_from_area
  (guaranteed AttributeError).
- suggest_length_unit crashed with OverflowError for all-zero data and
  nonpositive log bounds; the Angstrom ASCII round trip ('A') is now a
  convertible unit; find_length_unit_in_string matches whole tokens
  only ('X Axis' no longer detects Angstrom, 'Height (nm)' now detects
  nm) - this feeds the XYZ reader's unit detection.

Container:
- scale_dependent_statistical_property: the docstring example crashed
  (distance vs distances); scalar distances are now supported; the
  inner loop no longer shadows the outer loop variable.
- read_container forwards **kwargs to the reader constructor (e.g.
  datafile_keys of CEReader) instead of to container(), which accepted
  no kwargs; FileExistsError replaced by FileNotFoundError (also in the
  topography open path); read_published_container now checks HTTP
  status, applies request_args to both requests and uses a default
  timeout.

Readers:
- GWY: files containing object arrays (graphs/spectra) crashed twice
  over (missing kwargs pass-through and a missing comma turning
  nb_items into a tuple); channel indices with more than one digit
  (11+ channels) crashed on a single-digit regex.
- FRT: block 0x00ae contained the invalid struct format 'gint32'
  (copied from Gwyddion C source); files with this block failed to
  open.
- NC: nonuniform line scans reported nb_grid_pts=(None,) (read from
  the absent 'x' dimension instead of 'n'); the MPI guard 'rank > 1'
  let ranks 0 and 1 write the same file concurrently.
- PLUX: multi-layer files advertised N channels but refused to read
  any channel other than 0.
- H5: files containing HDF5 groups crashed (groups have no .shape);
  the reader now walks the full hierarchy and finds nested datasets.
- DATX: converter validation used 'and' where 'or' was needed, letting
  malformed converters through; fixed a copy-paste error message.
- Mitutoyo: spreadsheets without roughness-metric cells crashed with
  IndexError (height unit now falls back to the lateral unit);
  single-point scans crashed on diff; date parsing was locale-dependent
  (now dateutil); the workbook handle is closed.

Reference values for FRT/PLUX/DATX/GWY/OPD/X3P fixtures with undefined
data points changed due to the masked-data normalization fix in the
previous commit; the FRT fixtures are ~30% masked and their old rms
references were dominated by the wrong-mean artifact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPEpAGDASUXBVpV5BVDnUZ
Metadata:
- InfoModel now preserves free-form info keys (extra='allow'); the
  docstrings promise a free-form dictionary for third-party codes, but
  pydantic's default silently discarded unknown keys. Fields are
  properly Optional, so explicitly passing None no longer raises;
  ValueAndUnitModel accepts a missing unit (data without unit
  information).
- The base-class DecoratedTopography.info passed a BaseModel to
  model_copy(update=...), which requires a dict (AttributeError under
  pydantic 2); it now merges parent and decorator info once, and the
  two copy-pasted overrides in the uniform/nonuniform subclasses are
  gone.

Function registration:
- register_function uses copy-on-write per-class registries with an
  MRO-merged lookup: functions registered on a subclass no longer leak
  into the shared base-class dictionary. Functions previously
  registered on concrete classes (interpolate_linear/bicubic,
  mirror_stitch, fourier_derivative, scan_with_rigid_sphere) are
  registered on the interfaces with explicit dimension guards, so they
  remain available on decorated topographies and produce clear errors
  on data of the wrong dimension (scan_with_rigid_sphere now also works
  on decorated line scans and rejects 2D maps instead of producing
  garbage via its periodic branch).
- The deprecated= flag is honored (DeprecationWarning on call); the
  inactive flags on the nonuniform rms_*_from_profile aliases were
  dropped since the same names are primary API for uniform data.
- apply() returns the function's result in both dispatch bases.
- The doi decorator unwinds its bookkeeping in a finally block (an
  exception inside an analysis function previously corrupted the
  class-level state permanently, silently dumping DOIs into the
  caller's set forever after) and marks wrappers with __has_doi__
  (the name check was defeated by functools.wraps and double-wrapped
  every function).

Infrastructure:
- ExtendedJSONEncoder overrides iterencode (json.dump bypassed encode
  and emitted invalid JSON), converts Inf like NaN, and nan_to_none
  handles nomask and multidimensional masked arrays.
- Pipeline.pipeline_function no longer delegates unknown attributes to
  the parent topography, which silently applied chained pipeline
  functions to the parent and discarded the wrapped transformation.
- logging.getLogger instead of detached Logger instances (Regression,
  Averaging, Container SDS, XYZ reader).
- CEFileOpener/ZAGFileOpener close the containing ZipFile together
  with the member stream (file-descriptor leak until GC).
- DeclarativeReaderBase supports channel_id/height_channel_index
  selection and no longer calls scale(None) for formats without
  height-scale metadata.
- make_topography_from_function no longer assigns _heights directly,
  so constructor validation (shape check, float conversion, NaN
  masking) applies.

Readers:
- JPK: per-channel raw_metadata no longer contains the last TIFF
  page's metadata (stale loop variable).
- ZON: the mutable default info dictionary is no longer mutated
  (metadata leaked into all subsequent calls, including via ZAG
  containers), and user-provided info takes precedence.
- MetroPro: metadata fields declared little/native endian in this
  big-endian format are now read big-endian (heights were unaffected,
  but raw_metadata values and the instrument serial were garbage);
  dead header-size constants removed.
- OPD: metadata blocks advance by their directory-declared length
  instead of assuming fixed sizes (silent offset shift), and a missing
  Wavelength block raises CorruptFile instead of NameError.
- VK: physical sizes follow the pixel convention (n * pixel size).

Reference values in the Wyko ASC test updated for the masked-data
normalization fix (the fixture is half undefined).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPEpAGDASUXBVpV5BVDnUZ
- eigen_helper.h: ArrayXl is a fixed-width int64 (long is 32-bit on
  Windows/LLP64, where every binding taking an ArrayXl rejected the
  int64 arrays produced by np.argsort, breaking bearing areas of
  nonuniform line scans in the Windows wheels).
- All input arrays are taken as Eigen::Ref<const ...>: read-only,
  integer-dtype and Fortran-ordered inputs are now converted instead of
  rejected with TypeError (a read-only heights array previously broke
  t.mean() and friends for nonuniform scans).
- patchfinder: periodic wrap-around uses while loops; user stencils
  with offsets larger than the grid dimension previously indexed out
  of bounds (potential heap corruption).
- moments/bearing area: 64-bit triangle counters and Eigen::Index loop
  variables (int overflows at 32768 x 32768 maps); corrected stale
  storage-order comments.
- stack.h: allocation failures throw std::bad_alloc instead of
  printing and dereferencing a null pointer; the stdout printf on
  every stack expansion is gone; copy construction/assignment are
  deleted (raw buffer, rule of three).
- nonuniform_autocorrelation validates that user-supplied distances
  are smaller than the physical size (division by physical_size -
  distance previously produced silent NaNs/garbage).
- module.cpp: long-running pure-Eigen kernels release the GIL
  (autocorrelation, patch analysis, distance maps, bearing areas);
  bearing-area bindings gained py::arg names.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPEpAGDASUXBVpV5BVDnUZ
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPEpAGDASUXBVpV5BVDnUZ
…ns 3 and 4)

Section 3 (low severity):
- IO readers: correct AL3D row-stride read length and absolute-value mask
  tolerance; guard WSXM and DZI against flat scans (zero height range);
  fix ZON multi-entry stride computation and reject non-float32 entries
  with UnsupportedFormatFeature instead of an assert; replace
  defaultdict(None) and off-by-one channel bounds check in Text reader;
  catch IndexError (not KeyError) for out-of-range JPK channel indices;
  pass explicit little-endian byte order in FRT reader.
- Numerics: solve the nonuniform polyfit normal equations in scan-centered
  coordinates (raw monomials are ill-conditioned for scans far from the
  origin) and transform coefficients back to the monomial basis; fix
  operator-direction inference for uniform derivative normalization with
  anisotropic pixels; correct dsinc tolerance handling and remove dead
  code in nonuniform power spectrum; correct Nyquist frequency for
  anisotropic pixels in power_spectrum_from_area; fix geometric-mean
  parenthesization in scanning-probe reliability analysis.
- Misc: cache the communicator alongside the muFFT engine in FFTTricks;
  simplify TranslatedTopography offset setter; raise ValueError instead
  of assert in CompoundTopography; add stacklevel to deprecation
  warnings; numerous docstring corrections (two-sided integrals, polyfit2
  coefficient order, curvature vs slope, moment prefactors, derivative
  trimming semantics, make_grid nb_points semantics).
- C++: document the folded-in 1/2 convention in the nonuniform
  autocorrelation kernel (the old comment showed the product integral of
  a different function); remove unused iostream include; drop the
  never-returned nearest-point index array from distance_map; document
  always-periodic behavior of the patch analysis functions in their
  Python docstrings.

Section 4 (architecture):
- Add test/test_invariants.py with property-based invariant tests:
  transpose/translate/scale invariance of scalar parameters (with
  anisotropic pixels), decorator geometry consistency, bearing-area CDF
  properties, and origin invariance of nonuniform detrending.
- meson.build: use python3 for version discovery; the bare python alias
  does not exist on many systems.

Deliberately unchanged: the NMS height scale divisor (no reference
available) and the latent DATX/LEXT non-square axis pairing (no fixture).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPEpAGDASUXBVpV5BVDnUZ
…wheels

pip's isolated build environment provides python.exe but no python3.exe
on Windows, so 'python3' resolved to an interpreter outside the build
environment that does not have the DiscoverVersion build requirement
installed. All Windows wheel builds failed with "No module named
DiscoverVersion". 'python' is the only interpreter name guaranteed to
exist inside a virtual environment on all platforms; this is now
documented in meson.build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPEpAGDASUXBVpV5BVDnUZ
…t section 2.5)

The performance findings were re-assessed against the memory boundary
conditions of the library: a container streams its topographies from disk
and must never hold more than one of them in memory, and readers are
long-lived metadata handles that must not pin file data. Several of the
apparent performance penalties exist precisely because of those
constraints, so the fixes here either reduce memory use or keep it
unchanged; the two items whose speedup would cost memory are documented
as trade-offs rather than changed.

Fixed, with reduced memory use:
- Bicubic: compute the 16 spline coefficients of a cell on demand with a
  single-cell cache instead of tabulating them for every pixel, which
  cost 128 bytes per pixel (more than 2 GB for a 4096 x 4096 map) before
  the first evaluation.
- IBW, MI: keep only metadata (and, for MI, the byte offset of the data
  block) on the reader and load the data in `topography()`.
- MNT: store block positions instead of all decompressed blocks and the
  raw file copy; decompress on demand.
- patchfinder: allocate the flood-fill stack once per call instead of
  16 MB per patch.
- Gaussian process regression: factorize the observation covariance once
  with `cho_factor` and reuse it, and compute only the variance diagonal
  instead of the full predictive covariance matrix.

Fixed, memory-neutral:
- Container PSD integration: precompute the per-topography bandwidth
  intervals in a single streaming pass (two scalars each, heights are
  never cached) and answer counting queries with `searchsorted`. This
  turns N^2 file reads into 2N, as the pre-existing TODO proposed.
  Topographies whose reliability cutoff exceeds their scan length have an
  empty bandwidth interval and are excluded, so that the interval
  subtraction cannot count them as -1.
- MNT zlib scan: measure candidate streams with a chunked
  `decompressobj` over a memoryview and skip past accepted streams,
  instead of slicing the remaining buffer and fully decompressing it at
  every candidate byte.
- Nonuniform height-height autocorrelation: move the double segment loop
  into a C++ kernel that runs in constant additional memory rather than
  vectorizing it into an O(N*M) intermediate.
- Rigid sphere scan: vectorize over blocks of scan positions sized so the
  temporary stays bounded irrespective of scan length and tip radius.
- XYZ: accumulate columns into `array('d')` buffers during parsing.

Deliberately unchanged: the correlation function's pixel sweep (its FFT
alternative needs several full-map complex arrays) and the bearing area
full-grid sweep per query height (a sorted cache adds another O(N) array
and only pays off when many heights are queried). The XYZ file is still
parsed twice, because caching the columns would pin the point cloud for
the reader's lifetime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPEpAGDASUXBVpV5BVDnUZ
This plot was written while verifying the derivative normalization fix and
should never have been added to the repository.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPEpAGDASUXBVpV5BVDnUZ
@pastewka pastewka changed the title Fix 17 high-severity bugs: silent data corruption and wrong results Codebase audit: fix wrong-result, correctness, cleanup and performance findings Jul 25, 2026
claude added 2 commits July 25, 2026 08:25
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPEpAGDASUXBVpV5BVDnUZ
The audit report was a working document for this branch, not something the
package should ship. The findings it recorded are captured in the commit
history, the change log and the regression tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPEpAGDASUXBVpV5BVDnUZ
@pastewka
pastewka merged commit c7e1e56 into master Jul 25, 2026
74 checks passed
@pastewka
pastewka deleted the claude/codebase-audit-163x4g branch July 25, 2026 08:39
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.

Averaging + q and -q branch of PSD

2 participants