Skip to content

MGARD-X: record the decomposition depth cap in the header and restore it on decompression - #263

Closed
gqian-coder wants to merge 2 commits into
CODARcode:masterfrom
gqian-coder:max-level-metadata
Closed

gqian-coder wants to merge 2 commits into
CODARcode:masterfrom
gqian-coder:max-level-metadata

Conversation

@gqian-coder

Copy link
Copy Markdown
Contributor

Problem

Config::max_larget_level caps the number of decomposition levels (Hierarchy::init uses l_target = min(natural depth, cap)), but nothing about it reaches the file. The header's l_target is serialized from a member that is never set (always 0, "written for format compatibility only") and MetadataBase::InitializeConfig never touches config.max_larget_level. A decompressor that only has the file (mgard-x -x, or the API with a default Config) therefore rebuilds the hierarchy at full depth. The per-level quantization step (2 tol / (l_target + 1)) and the number of recomposition passes both depend on the depth, so the output is wrong, with no error.

Measured on a smooth 65^3 float field (range about 3), L-inf ABS tol = 1e-3, compressed with max_larget_level = 1: max error 1.35 under a default Config (0.97e-3 when the caller re-supplies the cap). The hybrid (BlockMGARD) path was not affected: its local/global level counts already travel in the header.

A second, related defect hid the first one in tests: the hierarchy caches (CompressorCache, RefactorCache, ReconstructorCache) are keyed on the shape alone, so a same-process compress + decompress hands the decoder the encoder's hierarchy, and a later compress of a same-shape array silently inherits whatever cap the previous call used.

Changes

  1. mgard.proto: new LevelCap { bool unlimited; uint64 max_level; } sub-message, FunctionDecomposition.level_cap = 5. A sub-message carries presence, which keeps three cases apart: absent (stream predates the field), cap 0 (single level) and unlimited. L_target is left as it was.
  2. Metadata.hpp / Metadata.cpp: max_larget_level_recorded + max_larget_level on MetadataBase; serialized when recorded, deserialized with the presence check (and reset when absent), printed by PrintSummary. InitializeConfig sets config.max_larget_level only when the header recorded it.
  3. CompressionHighLevel.hpp (compress) and MDRHighLevel.hpp (MDRefactor, and load() on the request/reconstruct side): record and restore the cap. Writers always record it, "unlimited" included, so a stale cap in the reader's Config cannot leak in either.
  4. Caches + pipelines: the hierarchy key is (shape, cap); GPUPipelines, CPUPipelines, RefactorPipeline, ReconstructPipeline, ReconstructPipelineQoI pass config.max_larget_level.
  5. Tests: test_LevelCapMetadata (header round trip: cap, cap 0, unlimited overriding a stale cap, absent field leaving the caller's Config alone, reset on re-deserialize) and test_LevelCapRoundTrip (end-to-end through the public API with a reader Config that never saw the writer's parameters; cache test through deterministic compressed sizes).
  6. doc/MGARD-X.md: the max_larget_level bullet claimed a default of 0; it now states the real default and that the cap is persisted.

Compatibility

  • Streams written before this change carry no level_cap. The reader then keeps the caller's Config (unlimited by default), i.e. exactly the previous behaviour: uncapped streams decode correctly, capped ones still need the cap supplied through Config, as nothing in such a stream records it.
  • Streams written with this change remain readable by older readers (protobuf ignores the unknown field); an older reader decodes uncapped streams correctly and needs the cap for capped ones, as before. The header grows by 4 bytes.
  • No file-format or software version bump: the field is presence-based. Bumping is left to the maintainers' judgement.

Results

65^3 float, range about 3, L-inf ABS tol = 1e-3. "default" = reader Config with no cap set (what mgard-x -x uses).

Stream written by cap reader max abs error
unpatched 1.7 none patched, default 3.96e-4 pass
unpatched 1.7 1 patched, default 1.35e+0 fails by design (nothing recorded)
unpatched 1.7 1 patched, cap re-supplied 9.66e-4 pass (old workaround still works)
unpatched 1.7 hybrid 1/1 patched, default 6.54e-4 pass
patched none patched, default 3.96e-4 pass; header: unlimited
patched 0 patched, default 1.00005e-3 pass (float rounding on the bound; header: 0)
patched 1 patched, default, and mgard-x -x 9.66e-4 pass; header: 1
patched 2 patched, default 8.79e-4 pass; header: 2
patched none patched, stale cap 1 in Config 3.96e-4 pass (header wins)
patched none / 1 unpatched 1.7, default 3.96e-4 / 1.35e+0 parses; capped needs the cap, as before

Cache: in one process, compress with cap 1 (228742 B), then with a default Config: 250878 B, equal to the size after release_cache(). Before the change the second call returned 228738 B, the capped hierarchy.

Testing

  • mgard-x-tests-SERIAL and mgard-x-tests-CUDA: all 237 test cases pass (1,148,625 assertions each), including the 9 new ones.
  • Matrix above through the public API with a small standalone harness that compresses in one process and decompresses in another (so the hierarchy cache cannot hide a header defect), plus the mgard-x CLI.
  • MDR-X: the cap is recorded by MDRefactor and restored by load(); the MDR test suites (which run uncapped) pass, but a capped refactor/reconstruct round trip was not exercised beyond that.

🤖 Generated with Claude Code

gqian-coder and others added 2 commits October 14, 2026 03:26
… it on decompression

Config::max_larget_level caps the number of decomposition levels, but
nothing about it reached the file: the header's l_target was serialized
from a member that is never set, and InitializeConfig never touched
config.max_larget_level. A decompressor that only has the file (mgard-x
-x, or the API with a default Config) rebuilt the hierarchy at full
depth, so the per-level quantization step and the number of
recomposition passes no longer matched the writer's and the output was
wrong without any error (max error 1.35 at tol 1e-3 on a range-3 field
compressed with max_larget_level = 1).

Add a LevelCap sub-message to FunctionDecomposition that records either
"unlimited" or the exact cap. Presence keeps three cases apart: absent
(stream predates the field), cap 0 (single level) and unlimited.
Compression and MDR refactoring always record it, unlimited included, so
a stale cap in the reader's Config cannot leak in either; InitializeConfig
and the MDR load() restore it only when the header recorded it, so
streams written before this change behave exactly as before (full depth
with a default Config; a capped legacy stream still needs the cap through
Config, as nothing in it records the cap). Older readers ignore the new
field. PrintSummary reports it; the doc bullet claimed a default of 0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CompressorCache, RefactorCache and ReconstructorCache keyed a cached
hierarchy on its shape alone. The same shape under a different
Config::max_larget_level has a different number of levels, so a
decompress (or a later compress) of a same-shape array silently reused a
hierarchy built for another cap. Within one process this is
self-consistent and only shows in the output size, which is why a
same-process round trip never exposed the missing header field fixed in
the previous commit.

Make the cap part of the key and pass it from the Config at every
lookup, insert and get in the compression and MDR pipelines. The
end-to-end test checks that alternating capped and uncapped compressions
of the same array reproduce the sizes obtained from an empty cache.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.

1 participant