Skip to content

Fixes from a full-workspace code review: correctness, error handling, robustness - #171

Merged
zommiommy merged 62 commits into
mainfrom
review-fixes
Jul 10, 2026
Merged

Fixes from a full-workspace code review: correctness, error handling, robustness#171
zommiommy merged 62 commits into
mainfrom
review-fixes

Conversation

@zommiommy

Copy link
Copy Markdown
Collaborator

58 commits of fixes from a systematic review of the workspace (webgraph, webgraph-algo, webgraph-cli), each scoped to one semantic change with a regression test.

Highlights

BVGraph compression & offsets

  • par_comp workers propagate I/O and label-store failures through the ordered merge as contextual errors instead of panicking inside the rayon scope; legal empty interior segments now compress correctly (previously they silently truncated the output while stamping the full node count).
  • check_offsets verifies the final length entry; random-access loads reject offsets files with a wrong entry count (e.g., stale .ef files).
  • to endianness no longer writes offsets shifted by one node.

Labelings & views

  • Zip zips labels strictly (panics on mismatched per-node label counts instead of silently truncating), checks node streams in release builds, and forwards num_arcs_hint (as does UnitLabelGraph); the SortedIterator claim of Right projections is narrowed to sound cases.
  • NoSelfLoopsGraph::num_arcs_hint no longer overstates the filtered count.
  • VecGraph/LabeledVecGraph bulk construction deduplicates arcs (last-wins, matching BTreeGraph).

Algorithms

  • Weighted HyperBall seeds the radius-0 neighborhood function with the total weight; empty graphs and malformed weights produce clean errors.
  • PageRank validates preference-vector stochasticity in production builds; BiRank rejects negative/non-finite query vectors; divergence from the Java LAW default mode is documented.
  • ExactSumSweep radius fixes; LLP gap-cost computed without signed casts.

Overflow & bounds

  • Matrix indexing checks column bounds; partition-boundary computations no longer overflow near usize::MAX; sorter batch size clamps to at least one element per buffer.

CLI

  • from arcs counts --lines-to-skip after comments and fails on zero arcs; to arcs --labels keeps a trailing empty label; perm comp validates permutations; check ef compares the final entry; empty-graph guards in bench and run llp --perm; --max-ref-count validated at parse time.

Tests & docs

  • The roundtrip fuzz target gained corpus replay; changelogs updated across all three crates.

Verification

  • cargo test --workspace: 58 suites green
  • cargo test --workspace --features slow_tests: 58 suites green
  • cargo doc --workspace --no-deps: clean (pre-existing index.html collision warning only)

zommiommy added 29 commits July 10, 2026 18:41
…decoder aliases

Both Decoder associated-type aliases instantiated ConstCodesDecoder
without the OUTDEGREES/REFERENCES/BLOCKS/INTERVALS/RESIDUALS const
parameters, so any Static dispatch with non-default codes validated the
compression flags and then silently decoded with the default
gamma/unary/gamma/gamma/zeta3 codes, producing garbage.
…zes a vertex

In symmetric mode backwards_step_sum_sweep sets the exact (forward)
eccentricity of the start vertex but, unlike forward_step_sum_sweep,
never lowered radius_high, so ExactSumSweep could certify and return a
non-minimal radius (e.g. 3 instead of 2 on the 9-node graph in the new
regression test).
…ition return

run_with_logging returned early when one side of the bipartition was
empty, before copying the preference vector into rank, so a fresh
BiRank with num_sources in {0, n} silently reported all-zero ranks.
An empty .graph file (valid for a 0-node graph) made new_mmap request a zero-length anonymous mapping, which mmap-rs rejects; it now keeps at least one 16-byte block. The vec_len computation is also simplified with size_of.
… helper

split::seq::Iter never advanced the lender to cutpoints[0], so
split_iter_at([10, 20]) on sequential-helper implementations
(BvGraphSeq, ArcListGraph, PermutedGraph, ...) returned nodes 0..10
instead of 10..20, violating the SplitLabeling contract.
Granularity::Nodes(0) and Granularity::Arcs(0) are reachable from the
CLI (--node-granularity 0 / --arc-granularity 0) and flowed into
par_node_apply/par_apply as a zero divisor or zero chunk size,
panicking or spinning; PageRank/BiRank clamped locally but HyperBall
and the generic traits did not.
…ters

ParSortPairs and ParSortIters only bounds-checked the source of each
pair, so out-of-range destinations (an out-of-range map value in
transform::map_*, a non-bijective permutation in permute_*, or a bad
arc target in 'webgraph from arcs') silently produced graphs whose
successors exceed num_nodes.
…past the end

iter_from unwrapped advance_by, so a from greater than num_nodes
panicked instead of yielding an empty lender like the range-based
graph implementations.
Cleanup ran remove_dir_all on the caller-supplied tmp_dir, deleting any
preexisting content of that directory. Each compression now works in a
fresh tempfile subdirectory of the configured base (removing the
stateful owned_tmp_dir caching, which would also have reused a deleted
path on a second compression).
The offsets loop emitted the end offset of node i as the i-th entry,
so the cumulative offsets pointed every node at its successor's start
(with a bogus trailing zero entry): any .ef built from a converted
graph corrupted random access. The stream now starts with the leading
zero and contains exactly num_nodes + 1 entries, matching
'build offsets'.
pagerank and birank dispatched on the endianness read from the
.properties file but then loaded the graphs with the default
(big-endian) loader, so little-endian inputs always failed to load.
…gerank

Both 'threshold' and the flattened NumThreadsArg claimed -t, which
panics clap's debug assertions on any debug-build invocation and is
ambiguous in release builds; -t stays with --num-threads as in the
other commands.
LeftSucc and RightSucc implemented ExactSizeIterator::len but kept the
default (0, None) size_hint, violating the ExactSizeIterator contract
and defeating consumer preallocation.
The constructors only checked the requested width, so
FixedWidth::<i128>::with_bits(5) was accepted even though
deserialization sign-extends through 64 bits, turning -1 into 2^64 - 1.
The documented at-most-64-bit type limit is now enforced.
split_iter_at forwarded union-sized cutpoints to both children, so
splitting a union of graphs with different node counts panicked on the
smaller child ('last cutpoint must be <= num_nodes'); the cutpoints are
now clamped per child, as iter_from already did.
…raphs

FairChunks yields no ranges when the target chunk size is zero (and its
last range can end before trailing zero-outdegree nodes), so with_dcf
could produce cutpoints that do not reach num_nodes or contain a single
element, panicking later in split_iter_at.
CompFlags::from_properties unwrapped the little-endian version parse
and indexed/unwrapped compression-flag tokens, so user-editable
.properties content such as version=abc, compressionflags=OUTDEGREES,
or OUTDEGREES_BOGUS panicked instead of returning the contextual error
the load path promises.
push unwrapped push_label inside the successor iterator, turning
fallible label storage into a panic; BvComp::push already propagated
the same error. The error is now captured while RaggedArray::push
eagerly drains the iterator and returned to the caller.
The unordered path fed a bounded channel from the calling thread while
the scoped workers waited for a free pool thread; when the caller
occupied the only pool thread (num_threads = 1), no worker could drain
the channel and the send blocked forever. A single effective worker is
now run inline.
Only the low-level with_transpose checked that the transpose has the
same number of nodes and arcs as the graph; the high-level constructors
(used by the CLI) accepted any graph, panicking or silently skipping
systolic updates once iterations started.
…icking

'build ef', 'build dcf', and 'check ef' unwrapped the 'nodes'/'arcs'
keys of user-editable .properties files, and 'from arcs --labels'
unwrapped the creation of and writes to the .nodes mapping file;
all now return contextual errors.
All 26 command dispatchers panicked on an unrecognized 'endianness'
property value (user-editable input); they now bail with the same
message, matching the write-side dispatcher.
A 0-node graph produced sentinel and NaN statistics
(minoutdegree=18446744073709551615, avgoutdegree=NaN, percdangling=NaN)
and an empty --scc-sizes file underflowed when indexing the largest
component; both now fail with a clear error.
all_cc_upper_bound unconditionally added three iterations, but the
symmetric branch performs a single BFS plus a linear scan (the directed
branch performs two BFSes plus the scan), so the public iteration
counts of run_symm results were over-reported.
The file was never declared in the module tree (no 'mod comp' in
traits/mod.rs) and ends mid-sentence; it was an abandoned draft that
misleads readers about where compression traits live.
- StoreLabelsConf::label_serializer_name returns "()" (not an empty
  string) for unlabeled graphs, which is the sentinel the compressor
  checks;
- PermutedGraph applies the permutation old-to-new (node x becomes
  perm[x]), not new-to-old as documented;
- BTreeGraph::add_arc/remove_arc panic on missing endpoints, now
  documented;
- par_map_fold2/par_map_fold2_with require A::default() to be a fold
  identity, since idle workers contribute default accumulators;
- the README advertised pre-rename transform functions (transpose,
  permute_split, map, ...) with dead docs.rs links.
The help text claimed labels are stored as Java big-endian 64-bit
integers, but llp/llp-combine write and read eps-serde labels_*.bin
plus labels_*.gap files.
zommiommy added 25 commits July 10, 2026 18:43
…at load time

The random-access decoders seek via unchecked Elias-Fano indexing, so
a stale or truncated .ef (e.g., built for a different graph) caused
out-of-bounds accesses in release builds; both random-access load paths
now verify that the offsets contain num_nodes + 1 entries.
num_arcs_hint forwarded the underlying graph's arc count even though
the type documentation states no exact count is available once
self-loops are filtered; consumers treating the hint as exact (DCF
construction, statistics, the Zip cross-check) saw an inflated value.
…hods

add_arcs/from_arcs sorted the input and fed it to add_arc, which
panicked on duplicate arcs with a misleading 'successor is not
increasing' message; the bulk methods now deduplicate (keeping the
label of the last occurrence, as LabeledBTreeGraph does) and the
add_arc panic message names duplicates explicitly.
…y output

--lines-to-skip counted comment lines, contrary to its documented
behavior, and an input whose lines were all skipped as malformed logged
an error but exited successfully without producing a graph.
'perm comp' indexed through user-provided sequences without checking
that they are permutations, panicking on out-of-range values and
silently emitting non-permutations for duplicates; each input is now
verified. 'check ef' compared only the num_nodes node-start offsets
against the graph, never the final total-length entry.
…mands

- 'to arcs --labels' lost an empty label on the last line because
  str::lines drops the final empty field, failing legitimate label
  files written by 'from arcs --labels';
- 'bench bvgraph' panicked on empty node ranges and divided by zero
  arcs; empty graphs are now rejected up front;
- '--max-ref-count -2' passed parsing and panicked in the CompFlags
  conversion; values below -1 are now rejected by clap.
'run llp --perm' on a zero-node graph failed with 'No labels were
found' because the label stage stores no files for zero nodes; the
permutation is now empty.
The DCF-based work split applies only to the permutation path, so --dcf had no effect without --permutation. clap now requires --permutation whenever --dcf is passed, turning the silent no-op into an argument error instead of a warning that could be lost in the logs.
Indexing checked only the flat Vec bound, so an out-of-range column could silently alias an element of the next row. Index and IndexMut now debug-assert that the column is within bounds.
The six copies of the boundary computation (uniform splits in
SplitLabeling/IntoParLenders/ParGraph and the partitioning in
ParSortPairs/ParSortIters) multiplied the segment index by the
rounded-up step before clamping, which overflows for node counts near
usize::MAX; the multiplications now saturate and split_iter uses an
inclusive range.
The first gap used isize casts whose difference wraps for node ids
above isize::MAX; the distance is now computed with a lossless u128
widening.
The BvGraph-level convenience wrapper was implemented only for the
default const code parameters, although the underlying factory method
is fully generic.
The end-of-sort flush covers every partition of every worker state, so
sparse inputs created and memory-mapped up to threads x partitions
empty batch files.
ParGraph::with_cutpoints and ParSortedLabeledGraph::from_parts stored
whatever they received, so invalid sequences produced wrong metadata
(e.g., par_comp trusting a wrong final boundary as num_nodes) or
panicked only later inside into_par_lenders; the documented invariants
are now checked up front.
Zero-node and zero-arc graphs produced NaN/inf values for avgref,
avgdist, bitsperlink, bitspernode, and compratio (the latter also for
complete graphs, whose theoretical bound is degenerate), which
Java-compatible property consumers may reject; the ratios are now
emitted only when meaningful. The version-0 zetak error message now
explains the Java-compatibility constraint.
PageRank checked stochasticity only under cfg(test) and BiRank checked
only the length, so production callers (including the CLI, which loads
user files) could pass negative or NaN vectors: NaN norm deltas never
satisfy threshold predicates, turning runs into busy loops. PageRank
now always asserts stochasticity and BiRank rejects negative or
non-finite entries; the divergence from the Java LAW default mode
(weakly preferential there, strongly preferential here) is documented.
…ar_comp

Compression workers unwrapped writer creation, pushes, and flushes,
panicking inside the rayon scope on I/O or label-store failures, and an
empty lender sent no message at all. Workers now send an id-carrying
Result (with an explicit empty-segment variant) so the ordered merge
surfaces failures as contextual errors in job order, and legal interior
empty segments compress correctly instead of erroring out.
Only bvcomp_and_read had corpus replay, so the higher-level roundtrip
harness (which exercises BvComp and BvCompZ::par_comp) could bitrot
without CI coverage; a small deterministic seed corpus is now replayed.
A MemoryUsage of BatchSize(0), or a MemorySize smaller than one element,
produced zero-capacity sort buffers whose effective batch size was then
whatever Vec's growth policy picked on the first push. All three batch
size computations (parallel pairs, parallel iters, sequential iters) now
clamp to one element per buffer.
Fixes clippy::useless_conversion; BvComp::push and BvCompZ::push take
any IntoIterator.
Fixes clippy::explicit_counter_loop; degs_iter.get_pos() remains usable
after the loop for the final-entry check.
fs::copy uses copy_file_range, an unsupported syscall under Miri; read
plus write exercises the same stale-offsets rejection.
The default and explicit LoadMmap load paths mprotect anonymous maps,
which Miri does not support; the zero-node roundtrip now runs only its
LoadMem loads under Miri. The new CLI integration tests are excluded
wholesale like the preexisting ones, as the CLI loads graphs and
Elias-Fano structures through mmap.
The CI clippy step runs with -D warnings, and stable clippy flags the if-let/else-return-None in TaskQueue::next (pre-existing on main) as clippy::question_mark. The ? operator is equivalent.
Co-scheduled with the ordered par_map_fold tests in test_utils_coverage, this test's one-thread Rayon pool perturbs Miri's cooperative scheduler enough to trip experimental Tree Borrows in rayon-core's in_place_scope teardown (reproduced at -Zmiri-seed=6 --test-threads=4; the ordered tests pass once it is isolated). Moving it to its own test binary keeps the deadlock regression under both normal CI and Miri without the cross-test interaction. cargo miri test --workspace now passes.
@zommiommy
zommiommy merged commit 0fa971e into main Jul 10, 2026
18 checks passed
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