Skip to content

OMC-21 audit fixes — 56/56 findings actioned (correctness + impl + doc honesty) - #14

Merged
Codeturion merged 17 commits into
dev/omni-collections-v2from
audit-fixes
May 8, 2026
Merged

OMC-21 audit fixes — 56/56 findings actioned (correctness + impl + doc honesty)#14
Codeturion merged 17 commits into
dev/omni-collections-v2from
audit-fixes

Conversation

@Codeturion

Copy link
Copy Markdown
Owner

Summary

Comprehensive complexity audit across all 32 public types, with implementation fixes where the original asymptotic claim was off, plus a doc honesty pass aligning README + XML docs to actual behavior. 15 commits, all green: 1526 net8 / 1497 net6 tests passing, 0 errors.

Backed by a three-stage agent verification process documented in docs/complexity-audit-2026-05.md (gitignored — local research artifact). Initial pass: 3 reading agents covering all 32 types. Verification pass: 9 independent agents (3 per cluster). Final implementation fixes verified by 3 parallel agents per fix, requiring 3/3 unanimous consensus before commit.

Findings actioned

56 of 56 findings from the audit, in priority order:

Tier Findings What
T0 Correctness (1) F-44 GraphDictionary.FindNodesWithinDistance — wrong-result bug on weighted graphs (FIFO Queue + weighted relaxation, not BFS or Dijkstra). Replaced with bounded Dijkstra.
T2 Small impl (8) F-26, F-29, F-30, F-32, F-33, F-42, F-53, F-54 Various asymptotic fixes — see commit log for per-finding bench evidence.
T1 Doc honesty (43) F-1..F-55 README per-type tables, §5 Complexity reference, class XML doc-comments aligned to actual behavior. Single big commit.
T3 API change (3) F-15, F-16/47, F-49 BFS variant; LinkedMultiMap view return; ConcurrentLinkedDictionary snapshot-on-iterate.
T4 Large rewrites (2) F-36, F-24/25/26 KdTree Build via Quickselect (O(N log²N) → O(N log N), bench-confirmed 11.6× at N=100k). Digest skip-list backbone (O(c) → O(log c) Add/Quantile/Cdf, bench-confirmed 43× at high compression).
T2 follow-up (1) Digest bulk-build for RebuildFromSorted — drops Merge end-to-end O((c₁+c₂) log) → O(c₁+c₂), Compress O(c log c) → O(c). 2× bench speedup at high c.

Breaking change

LinkedMultiMap.this[key] and TryGetValues return type changed from IReadOnlyList<TValue> to LinkedMultiMap<K,V>.NodeValueView.

NodeValueView implements IReadOnlyList<TValue>, so most consumer code continues to compile and run:

  • foreach (var v in mm[key]) — works
  • assignment to IReadOnlyList<TValue> — works (interface satisfied)
  • var x = mm[key]; x.Count — works (inferred type changes from interface to concrete; runtime behavior identical)

Real breaks for these patterns:

  • bool TryGetValues(K key, out IReadOnlyList<V> values)out parameter type must change
  • Any code that depended on the result being a fresh TValue[] snapshot immune to subsequent multimap mutations — call .ToArray() for that semantics

The new contract is: O(1) view construction (no per-call array alloc), aliases the live multimap state, mutates LRU order on read. Previous behavior was O(values per key) array alloc per call, also mutating LRU but undocumented.

Verification methodology

Each implementation change in this PR was verified by 3 parallel agents reading the new code independently. Examples:

  • F-36 KdTree Quickselect: 3/3 agents confirmed median-of-three pivot + Lomuto partition, no Array.Sort per recursion level, average O(N log N).
  • F-24/25/26 Digest skip-list: 3/3 agents traced sample input through the implementation; one agent rejected the F-26 Merge claim (interpreted strictly), prompting the bulk-build follow-up.
  • Digest bulk-build follow-up: 3/3 agents confirmed Width invariant Width[i] of X = cumWeight(X.Forward[i]) - cumWeight(X) holds at every node + level pair; algorithm is single-pass left-to-right with O(c) total work.
  • F-49 ConcurrentLinkedDictionary GetEnumerator: 3/3 agents traced caller scenarios (MoveNextMoveNextDispose mid-iteration) and confirmed lock release happens before any yield, writers are unblocked during caller iteration.

All implementation changes also have benchmark before/after numbers in their commit messages where applicable. Tier 1 doc-only changes have no perf impact.

Bench evidence (highlights)

Operation Before After Speedup
KdTree.Fill @ N=100k 431 ms 37 ms 11.6×
KdTree.Fill @ N=10k (memory) 77,254 KB 15,352 KB
Digest.Quantile @ C=1000 / N=1k 1086 ns 25 ns 43×
Digest.Merge @ C=1000 / N=1k 215 us 104 us

(All from smoke-profile bench on i7-13700KF; reproduce with .\bench.ps1 --filter '*<TypeName>Benchmarks*'.)

Test status

1526 passed, 0 failed, 2 skipped  (net8.0)
1497 passed, 0 failed, 2 skipped  (net6.0)

The 2 skipped are pre-existing (DigestStreamingAnalytics compression-comparison test + BoundedDictionary capacity test, both unrelated to this PR).

Targeting

Base = dev/omni-collections-v2, mirroring v2.0.0 / v2.0.1 release pattern. Will land for v2.1.0 — minor bump because LinkedMultiMap interface contract still satisfies IReadOnlyList<TValue> for most consumers (the breaking patterns are narrow).

After this merges:

  1. Bump Directory.Build.props version 2.0.1 → 2.1.0
  2. Merge dev/omni-collections-v2main
  3. git tag v2.1.0 && git push origin v2.1.0
  4. Publish workflow auto-fires → 9 packages + 9 symbol packages on nuget.org

Codeturion added 16 commits May 7, 2026 23:39
…stra

The method previously walked a FIFO Queue<TKey> while adding edge weights to
distances and re-enqueueing on improvement. On weighted graphs FIFO ordering
does not respect distance, so the algorithm could produce wrong shortest-
distance values for some nodes (the first time a node is dequeued may not be
along the actual shortest path; later improvements only update a node's
distance entry but the stale weights have already propagated to its
neighbors). Worst case ~O(V*E) on adversarial weight distributions.

Replaced with a bounded Dijkstra using the same SortedSet<(double, TKey)>-
backed priority queue already used by FindShortestPath. Each node is settled
at most once (HashSet guard); we early-break when the queue's next minimum
exceeds maxDistance.

Time: O((V'+E') log V'), where V'/E' are the vertices/edges within
maxDistance. Space: O(V') for distances + queue + settled.

Tests: 50/50 GraphDictionary tests pass on net8 + net6 with no changes
needed (existing tests use unweighted-equivalent edges where the buggy
algorithm happened to produce correct output).

(also: gitignore docs/complexity-audit-*.md so the planning doc stays local)
…lone allocation

Previously EstimateUnion did Clone()+Merge()+EstimateCardinality() — three
m-byte traversals plus an m-byte heap allocation per call.

Replaced with a single-pass loop over both bucket arrays computing the
per-bucket max-then-2^-b sum directly, then applying the same three-way
cardinality correction logic EstimateCardinality uses. No allocation, two
fewer traversals.

Time stays O(m); allocation drops from O(m) to O(1).

Same correction branches preserved (small-range linear counting, raw
estimate, large-range -2^32 log correction) so the result is identical to
the previous Clone+Merge+Estimate pipeline.

Tests: 35/35 HyperLogLog tests pass on net8 + net6.
… via binary search

Previously linear-scanned _snapshots looking for the timestamp closest to the
query time, so GetObjectsInRadiusAtTime / GetObjectsInRectangleAtTime / any
snapshot-time query was O(snapshots + k) regardless of how snapshots were
indexed.

Added a parallel `_snapshotKeysOrdered : List<DateTime>` maintained alongside
`_snapshots`. Snapshots are appended in chronological order (TakeSnapshot
uses DateTime.UtcNow which is monotonically increasing in normal use). The
list supports O(log N) BinarySearch for FindClosestSnapshot, which now picks
the closer of the two binary-search candidates (immediate predecessor and
successor of the query timestamp).

CleanupOldSnapshots updated to walk the front of _snapshotKeysOrdered (which
is sorted, so all expired keys are contiguous at the start) and pop them in
one RemoveRange call. Same overall O(N) cleanup cost as before but with one
shift instead of N reads.

Time: FindClosestSnapshot drops from O(snapshots) to O(log snapshots).
Per-snapshot insert stays O(1) amortized; per-cleanup stays O(N).
Memory: +sizeof(DateTime) per retained snapshot — typically <= 1000 entries.

Tests: 290/290 Spatial tests pass on net8 + net6.
…ap-and-truncate

Both methods previously walked descending indices calling _items.RemoveAt
per match. Each List<T>.RemoveAt is O(N - i), so for k matches the total
cost was O(N*k). For typical predicates that match a non-trivial fraction
of items, this scales quadratically with N.

Replaced with the standard swap-and-truncate pattern: forward scan with two
pointers (write, read), copy survivors leftward over removed slots, then a
single RemoveRange truncates the tail. O(N) total — single pass through
the items, single shift to truncate.

Behavioral notes:
- Sync RemoveAll: keeps the existing event shape (Reset notification, no
  per-item events). Removed items are no longer materialized into a list
  since the sync path doesn't fire per-item callbacks.
- Async RemoveAllAsync: keeps per-item ItemRemoved events. Items are
  collected in ascending index order during the forward scan, which is
  the natural ordering — the previous code collected in descending order
  and then called .Reverse(); both steps are now unnecessary.
- Async also keeps the periodic Task.Yield(); cancellation still checks
  per iteration.

The dead `lowestIndex` field in RemoveAllAsync (assigned but never read —
it was a leftover from an earlier (Remove, items, index) shape that was
already changed to Reset) is gone.

Tests: 69/69 ObservableList tests pass on net8 + net6.
…ar advance

Previously called GetAtTime per frame inside the while loop. GetAtTime does
a fresh BinarySearchTime, so the cost was O(frames * log N). For a
60-fps replay over a 10s buffer that's 600 binary searches.

Replaced with the standard pattern: one binary search to seed the index,
then advance linearly per frame. Each frame's index is found by stepping
forward until the next-sample timestamp would exceed currentReplayTime.
Total work across all frames is O(log N + N + frames), bounded by
O(log N + frames) for typical replays where frame intervals span multiple
sample timestamps.

Behavior preserved:
- "Round down" semantics: returns the latest sample at-or-before each
  frame timestamp. (GetAtTime did the same via insertion-point-minus-1.)
- "Skip-before-startTime" semantics: frames whose timestamp falls before
  the first recorded sample are skipped. (GetAtTime returned default and
  the caller had `if (snapshot != null)`; we now check explicitly so we
  never need to construct the tuple.)
- startTime > EndTime: yields nothing. Same as before.

Tests: 36/36 TimelineArray tests pass on net8 + net6.
…rateSetBits

F-29: FillArea was nested per-cell loops calling the indexer, O(w·h) per call
even though the bit-packed storage supports word-level fills. Rewrote to
clip the rectangle to grid bounds, then for each row in the rectangle fill
a contiguous bit range via a SetBitRange helper that:

  - first/last word: build a mask covering the relevant bits via
    upperMask & ~lowerMask (avoids the `1UL << 64` overflow corner)
  - middle words: full ulong fill (`= ulong.MaxValue` or `= 0`)

Per-row cost drops from O(width) to O(width/64). Total: O(h · w/64).

F-30: EnumerateSetBits was O(W·H) — visited every cell via the indexer
regardless of bit density. Rewrote to walk the storage word-by-word; for
each non-zero word use BitOperations.TrailingZeroCount to jump directly to
each set bit, clear it via `word &= word - 1`, and continue. Skips entire
64-bit zero-words in one comparison. Total: O(set bits + W·H/64).

The final word is masked to _totalBits to ignore any unused bits past the
grid (in case word-level writes left them set).

Added BitOperations.TrailingZeroCount to Core/Compat/BitOperations.cs (the
netstandard2.1 polyfill — net8 uses the framework version). Cascading-mask
algorithm; matches what System.Numerics.BitOperations does internally on
platforms without TZCNT instruction.

Tests: 58/58 BitGrid2D tests pass on net8 + net6.
…ntroid Add

Previously Merge called Add per centroid in the other digest. Each Add does
a List<T>.Insert at the binary-search position, which shifts O(c) elements
to maintain sorted order. So the worst case for Merge was O(c1*c2) where
c1 grows as the merge proceeds.

Replaced with a two-pointer linear merge: walk both centroid lists in
lockstep (after EnsureSorted on each), build the sorted union in one pass,
swap into _centroids, then Compress to reduce back to compression-bounded
size.

Time: O(c1 + c2) for the merge + O(c) Compress = O(c1 + c2). Compress's
EnsureSorted is a no-op because we build the merged list pre-sorted.

The 'other' digest is not mutated — if it needs sorting we sort a local
copy. Min/max/count are merged in. _needsSort=false because the merged
list is in order by construction.

Triggers Compress when the merged count exceeds _compression (the same
trigger as Add's Compress condition, but on the post-merge size — without
this, two large digests merged together could leave _centroids at
2*_compression which is the upper bound Compress targets anyway).

Tests: 66/66 Digest + DigestStreamingAnalytics tests pass on net8 + net6
(1 pre-existing skip unrelated to this change).
Bring the README and class XML doc-comments in line with what the code
actually does. Two categories:

A) Findings already fixed by code changes earlier on this branch — README
   updated where it still described pre-fix behavior:
     F-26 Digest.Merge       — now O(c1+c2) linear merge (was O(c1*c2))
     F-42 TemporalSpatial-   — FindClosestSnapshot now O(log snapshots)
          HashGrid              via parallel sorted-keys list
     F-44 GraphDictionary    — FindNodesWithinDistance now bounded
                               Dijkstra O((V+E) log V) (was wrong-algo
                               queue with weighted edges)
     (F-29/30/32/33/53/54 — README's original claims now match reality;
      verified, no doc change needed.)

B) Findings that are doc-only (no impl change — README needed updating):
     Per-type tables (~28 entries):
       F-1, F-2  Pooled{Stack,Queue}.{Pop,Dequeue}Span — note alloc
       F-3..F-6 Min/MaxHeap.Insert amortized + storage O(N) (not capacity)
       F-7, F-8 QuadTree / SpatialHashGrid — surface linear-mode caveat
       F-9, F-10 KdTree.Clear / OctTree.Clear — O(1) (orphans tree)
       F-11    KdTree.Insert — periodic rebuild annotation
       F-12    TemporalSpatial.UpdateObject — amortized snapshot cost
       F-13    GetObjectTrajectory — O(s log s) due to Sort
       F-14    KdTree.FindNearestK — O((log N + k) log k) avg
       F-15    GraphDict.FindShortestPath — drop "(unweighted BFS)";
               correct as Dijkstra
       F-16/17 LinkedMultiMap.this[]/TryGetValues — O(values per key),
               *mutates LRU*, allocates per call
       F-18    LinkedMultiMap.RemoveKey — O(1) avg
       F-19    LinkedMultiMap.Clear — O(buckets)
       F-20    PredictiveDictionary.AddOrUpdate — drop "+pattern update"
       F-21    PredictiveDictionary.GetPredictions — fix `p` definition
       F-22    ConcurrentLinkedDict.Clear — O(buckets) per-bucket-locked
       F-24/25 Digest.Add/Quantile/Cdf — O(c) (was O(log c))
       F-27    DigestStreamingAnalytics — inherits Digest's O(c)
       F-31    HexGrid2D.FindPath/GetReachable — fix README signatures
       F-34    ObservableHashSet.SymmetricExceptWith — annotate alloc
       F-35    CountMinSketch — width rounded to next power of 2
       F-37    KdTree.InsertRange — bulk-rebuild path
       F-38    SpatialHashGrid.GetStatistics — O(C log C) (sort)
       F-39/40 GetPotentialCollisions — linear/spatial mode breakdown
       F-41    SpatialHashGrid.Remove for InsertBounds — O(C·M)
       F-43    KdTree.FindNearestK List overload — alloc note
       F-45    GraphDict.GetClusteringCoefficient — O(deg²) (new row)
       F-46    GraphDict.Keys/Values — O(N) snapshot (new row)
       F-49    ConcurrentLinkedDict.GetEnumerator — blocks writers
       F-50    TDigest.CanMerge — subsumed by F-24 fix
       F-55    TimelineArray.ReplayAtFps — fix signature

     Class-level XML doc-comments:
       F-23, F-48 ConcurrentLinkedDictionary — drop "lock-free reads";
                  describe per-bucket-monitor + LRU writer lock honestly
       F-51       TDigest — rewrite to say O(c) Add/Quantile, O(c1+c2)
                  Merge, drop "O(log n)" claim
       F-52       DigestStreamingAnalytics — describe O(c) per-call
                  delegation + periodic O(buffer) cleanup
       F-47       LinkedMultiMap — note NodeValueView indexer is O(i)

     Stale "lock-free" comments in the ConcurrentLinkedDictionary
     benchmark file replaced with "per-bucket monitor" wording.

Skipped per audit-doc instructions: F-28 (HLL caching already works as
documented), F-36 (KdTree.Build not exposed in README — implementation
work is large-rewrite Tier 4 territory).

README: 911 -> 936 lines (+25 net; new annotation rows for findings
that weren't previously listed in any table).

Tests: 1522 net8 / 1493 net6, all green.
… + unweighted

The existing FindShortestPath implementation is weighted Dijkstra over the
edge-weight property. The README's old "(unweighted BFS)" annotation was
wrong about both algorithm and complexity. Tier 1 doc pass already corrected
the claim to "Dijkstra, O((V+E) log V)".

Now adding the actual unweighted variant as a separate method:
FindShortestUnweighted(start, end) — runs BFS over the graph, treats every
edge as unit-weight, returns the path with the fewest hops. Result's
TotalWeight is the hop count (path.Count - 1) rather than weighted distance.

Cost: O(V'+E') where V'/E' are the vertices/edges within reach (BFS halts at
target). Same SortedSet-of-distances overhead is gone — plain Queue<TKey>
suffices because every edge is unit-weight, so FIFO order respects shortest-
path-by-hop-count.

Same metrics-cache key scheme as FindShortestPath, distinct cache prefix so
the two methods don't collide.

Tests: 50 -> 54 GraphDictionary tests on net8 + net6, all green. New tests
cover: prefers-fewest-hops-ignoring-weights, unreachable returns null,
self-path returns single-node path with weight 0, missing-endpoint returns
null.

PublicAPI.Shipped.txt updated.

No bench added — the existing GraphDictionary benchmarks build the graph
once in GlobalSetup, so the metrics-cache serves all iterations after the
first call. Benching cached lookups doesn't demonstrate the Dijkstra-vs-BFS
asymptote. Asymptotic difference is textbook; correctness tests are the
verification.
…lues, drop per-call array alloc

API change. The previous behavior allocated `new TValue[node.ValueCount]`
on every this[key] / TryGetValues / GetEnumerator() call, copying each
value out of the per-key linked list. Hot-path consumers calling
multimap[k] in a loop paid O(values per key) heap allocation per access.

NodeValueView is now public (nested in LinkedMultiMap<TKey, TValue>). Each
get-path allocates only the view object itself (~24 bytes) — the view holds
a reference to the KeyNode and walks the value list lazily. Construction is
O(1); iteration is O(values per key); indexed access via this[i] is O(i).
The view aliases the multimap's internal storage — mutations to the
multimap after the view is returned invalidate the view (caller can call
the standard LINQ ToArray() for a snapshot).

The shared `_cachedView` field is gone. Each call gets a fresh view; no
aliasing-via-shared-state bugs (e.g., calling foreach(map) and
map[k] interleaved no longer reuses the same view object).

API breaks (acceptable per v2.x consumer expectations — bumping the major
on the next release if needed):
  - this[TKey].get : IReadOnlyList<TValue> -> NodeValueView
  - TryGetValues(TKey, out IReadOnlyList<TValue>) ->
    TryGetValues(TKey, out NodeValueView)
  - IEnumerable<KeyValuePair<TKey, IReadOnlyList<TValue>>> ->
    IEnumerable<KeyValuePair<TKey, NodeValueView>>
  - KeyNode and ValueNode bumped from private to internal so the public
    NodeValueView constructor can reference KeyNode (still nested,
    not surfaced beyond the assembly).

NodeValueView implements IReadOnlyList<TValue> so existing code that does
`foreach (var v in multimap[k])` or assigns to `IReadOnlyList<TValue>`
continues to work. Code that depended on the result being a fresh
TValue[] (and immune to subsequent multimap mutations) needs to call
`.ToArray()` explicitly.

PublicAPI.Shipped.txt updated: 3 signature lines retyped to NodeValueView,
4 lines added for NodeValueView itself (Count, indexer, GetEnumerator).

Tests: 1526 net8 / 1497 net6, all green.
…O(N log N)

BuildRecursiveOptimized previously called Array.Sort on the full range at
every recursion level — O(N log N) per level × O(log N) levels = O(N log²N).
Plus, Comparer<T>.Create allocated a fresh closure object per recursive
call.

Replaced with iterative Quickselect (Lomuto partition + median-of-three
pivot). At each level we partition items[start..end] in O(end - start)
average to place the median by `dimension`-coordinate at items[median],
with everything to its left smaller and right at-or-greater. No further
ordering within each side — kd-tree build only needs the partition, not
a full sort. Per-level cost drops to O(N) average; total O(N log N).

Worst case is O(N²) for adversarial input; median-of-three pivot
selection makes that very unlikely on uniform-random data (which kd-trees
are typically built on).

Removed the dead BuildRecursive(List<T>, int) overload — no callers.

Hot-path implications:
  - Build (called from KdTree.Build / InsertRange bulk-rebuild path)
  - RebalanceIfNeeded (every 64 inserts past N=1000 when height blows up)

Bench evidence (KdTree Fill, smoke profile, i7-13700KF, dev branch):

  Before (audit-fixes prior commit):
    N=10k:  Omni_Fill  11,728 us   77,254 KB
    N=100k: Omni_Fill 431,170 us 1,229,724 KB

  After:
    N=10k:  Omni_Fill   4,930 us   15,352 KB   (2.4x faster, 5x less mem)
    N=100k: Omni_Fill  37,109 us  188,105 KB   (11.6x faster, 6.5x less mem)

11.6x speedup at N=100k matches the predicted log N reduction (log₂100k ≈ 17;
we get 11.6 — the rest is constants). Memory drop is the elimination of
per-level Comparer<T>.Create closures plus the per-recursion Sort temp
buffers.

The README's F-11 amortized-rebuild annotation can be tightened later;
for now leave the doc as O(N log²N) (added in Tier 1) since it's still a
valid upper bound for adversarial input.

Tests: 47/47 KdTree tests pass on net8 + net6.
Replaced List<Centroid> backbone with an indexable skip list of Nodes,
each carrying the Centroid plus per-level Forward[] pointers and
Width[] arrays (cumulative-weight prefix sums per level — Pugh's
"indexable skip list" / finger-search trick).

Operations now achieve O(log c):
  - Add: descent records (update[i], ranks[i]) predecessors and prefix-
    weights; Width arrays update in O(log c) along the path. If the new
    centroid merges into an existing one (CanMerge), Width[i] is
    incremented along the parent chain; otherwise we splice a new node
    and split the Width contributions across old vs new at each level.
  - Quantile / Cdf: standard top-down descent, advancing while
    cumulative + Width[i] < target. Drops from O(c) linear scan to
    O(log c) levels × O(1) per level.

Operations preserved:
  - Compress: walks level 0 in order under the same scale-function
    rule, then RebuildFromSorted reconstructs the skip list. O(c log c).
  - Merge: existing two-pointer linear merge over both digests' level-0
    enumerations, then rebuild + Compress. O(c1+c2) (the F-26 fix is
    preserved).
  - Clone, Clear, Min, Max, Count, ClusterCount: wired through.

Removed the vestigial _needsSort field — skip list is always sorted.

Bench evidence (smoke profile, BDN-default, MemoryDiagnoser; param
[100, 1000] added to TDigestBenchmarks):

  Quantile (the headline win):
    N=1k  / C=1000: 1085.69 ns -> 24.97 ns   (43x faster)
    N=10k / C=100:    31.27 ns -> 17.01 ns   (1.8x faster)
    N=100k/ C=1000:   26.85 ns -> 15.84 ns   (1.7x faster)
  At low compression (c <= 200) constant factors dominate: small
  regressions of ~10-14% from the per-level Width updates and the
  skip-list's less cache-friendly access pattern. Acceptable tradeoff
  for the asymptotic guarantee.

  Add (mixed):
    N=10k / C=100: 178.52 ns -> 93.48 ns   (1.9x faster)
    Most other configs: within +/- 25% of baseline.
  The List baseline's Insert is cache-friendly memmove on c <= 2000
  (which is the ceiling at the highest documented compression);
  per-Node skip-list allocation overhead balances the asymptotic
  advantage at this scale. The asymptotic upper bound is now O(log c),
  but constant factors leave the practical Add cost similar.

Public surface unchanged. PublicAPI.Shipped.txt unaffected.
EstimatedMemoryUsage formula bumped from `count*24+64` to `count*96+128`
to honestly reflect per-Node pointer overhead (~24B header + 16B
Centroid + 2 array headers + ~64B forward-pointer + width arrays at
expected ~2 levels avg).

Tests: 66/66 Digest + DigestStreamingAnalytics on net8 + net6 (1
pre-existing CompressionSettings_AffectAccuracyAndMemory skip
unrelated to this change).
…inkedMultiMap, GraphDictionary

Update README per-type tables, the §5 Complexity reference table, and the
class XML doc-comments to match the actual asymptotic behavior after the
implementation fixes earlier on this branch.

KdTree<T> (commit e25e8e6 — Quickselect rebuild):
  - Insert annotation: "periodic O(N log² N) rebuild" → "periodic O(N log N)
    rebuild" (Quickselect drops the log factor from per-level Sort)
  - InsertRange bulk-rebuild path: same O(N log²) → O(N log N) update.

Digest (commit 0d2ae4f — skip-list backbone):
  - Add: O(c) → O(log c)
  - Quantile / Percentile: O(c) → O(log c)
  - Cdf: O(c) → O(log c)
  - Storage row: note ~96 B per-node overhead (skip-list pointers + width
    arrays vs the previous flat List<Centroid>)
  - Class XML doc was already updated by the F-24/25/26 commit; verified
    in place.

DigestStreamingAnalytics<T> (inherits Digest):
  - All public-op rows: O(c) → O(log c)
  - Class XML doc updated to say "skip-list-backed digest" + O(log c).

LinkedMultiMap<TKey, TValue> (commit 9645a56 — view return):
  - this[key] / TryGetValues: O(values per key) + alloc → O(1) view return
  - Added rows for the NodeValueView surface:
      * .Count: O(1)
      * enumeration: O(values per key)
      * indexer [i]: O(i) (singly linked) — flagged with "prefer foreach"
  - §5 reference table row updated to summarize.
  - Class XML doc rewritten: O(1) view returns, live-aliasing semantics,
    LRU mutation on read, "prefer foreach for sequential reads".

GraphDictionary<TKey, TValue> (commit 7d9e15f — BFS variant):
  - Added FindShortestUnweighted row: O(V+E) BFS, ignores weights.
  - §5 reference table row updated to mention both Dijkstra and BFS variants
    side-by-side.

Tests still 1526 net8 / 1497 net6 (no behavior change).
…→ O(c1+c2), Compress O(c log c) → O(c)

The previous RebuildFromSorted re-inserted each centroid via the full Add
path: skip-list descent + per-level Width updates, O(log c) per centroid,
O(c log c) total. That made user-visible Merge O((c1+c2) log(c1+c2)) end-
to-end despite the linear-merge step being O(c1+c2). Three verifier agents
on the previous round flagged this — claim 12e was the only Digest claim
that didn't unanimously confirm.

Replaced with a single-pass left-to-right bulk-build: maintain update[]/
ranks[] as the rightmost-level-i predecessor + cumulative weight per level.
For each new centroid: pick a level via the same RandomLevel() distribution
Add uses, splice the node in at its levels in O(level) by referencing the
stack. Width values fall out as (newRank - ranks[i]) — no descent needed
because the input is already sorted. Sum of levels across all c iterations
is 2c on expectation (Pugh's p=1/2 geometric), so total work is O(c).

Width invariant maintained correctly:
  Width[i] of X = cumWeight(X.Forward[i]) - cumWeight(X)
where cumWeight(X) = sum of weights from start through X inclusive.

Higher-level "invisible" nodes (level < i): the new node doesn't touch
update[i]/ranks[i], so the next level-i arrival's iteration computes
(newRank_M - ranks[i]) which spans the entire intermediate stretch
correctly. No per-centroid "+= weight" updates needed (those were specific
to the per-call Add path, not bulk-build).

Tail nodes (Forward[i]=null, Width[i]=0): handled implicitly by the Node
constructor's zero-init defaults — bulk-build never overwrites a node's
own Forward[i]/Width[i], only the predecessor's. The last linked node at
each level keeps the ctor defaults.

Three independent verifier agents read the new code, traced the same
example (3 centroids, weights {1,2,3}, levels {1,2,1}), and all 3/3
confirmed the implementation is correct and O(c). No correctness bugs
spotted.

Bench evidence (DigestBenchmarks.Omni_Merge, smoke profile, BDN-default,
i7-13700KF, MemoryDiagnoser on; new Merge benchmark added to the file):

  Before (audit-fixes prior commit):
    N=1k  / C=100:   3.05 us       1.40 KB
    N=1k  / C=1000:  214.65 us   313.31 KB    <-- headline (max c)
    N=10k / C=100:   14.96 us     14.05 KB
    N=10k / C=1000:  2.54 us       1.34 KB
    N=100k/ C=100:   3.44 us       2.59 KB
    N=100k/ C=1000:  10.52 us     14.12 KB

  After:
    N=1k  / C=100:   2.18 us       1.49 KB    (1.4x faster)
    N=1k  / C=1000:  104.21 us   313.45 KB    (2.06x faster, headline)
    N=10k / C=100:   7.24 us      14.46 KB    (2.07x faster)
    N=10k / C=1000:  2.91 us       1.46 KB    (noise)
    N=100k/ C=100:   2.83 us       2.55 KB    (1.21x faster)
    N=100k/ C=1000:  5.65 us      14.04 KB    (1.86x faster)

The asymptote class drops from O(c log c) to O(c). Visible 2x speedup at
high c reflects the constant-factor ratio after the log factor is removed
(other linear-time steps in Merge — the two-pointer walk itself,
Compress's level-0 scan — limit the visible improvement).

README Digest row updated:
  Compress:  O(c log c) -> O(c)
  Clone:     O(c log c) -> O(c)
  Merge wording clarified ("then bulk-rebuild" instead of "then compress").

Tests: 66/66 Digest + DigestStreamingAnalytics on net8 + net6.
…-iterate, unblock writers

The previous GetEnumerator held the LRU read lock for the entire iterator
lifetime via `try { ... yield return ... } finally { ExitReadLock(); }`.
Because C# iterator-method finally blocks are deferred until iterator
disposal, every writer (Add/Remove/MoveToFront/EvictLru) was blocked until
the caller finished consuming OR explicitly disposed the enumerator.

Replaced with the snapshot-on-iterate pattern: under the read lock,
allocate KeyValuePair<TKey, TValue>[_count] and walk the LRU chain copying
each entry. Release the lock. Then iterate the local array yielding each
entry. The yield is OUTSIDE the try/finally, so the lock release happens
synchronously on first MoveNext (before any yield to the caller), not at
disposal time.

Defensive trim: if the chain turns out to be shorter than _count
suggested (mid-mutation transient state), Array.Resize trims the unused
tail. In practice _count is read under the read lock and writers can't
mutate it (they hold the write lock for both _count++/-- and chain
edits), so the trim is belt-and-suspenders. Three verifier agents flagged
this as cheap and harmless.

Trade-off: enumeration costs O(N) heap (one KVP per current entry) vs the
previous zero-alloc walk. In exchange writers are no longer blocked for
the duration of caller iteration, which can be arbitrary long-lived for
LINQ-style consumers. Documented in the XML doc + the README per-type row.

Three independent verifier agents (parallel reads, no shared context)
all confirmed CORRECT on every axis:
  - Snapshot taken under read lock                3/3
  - Lock released before any yield                3/3
  - Snapshot consistency (chain stable)           3/3
  - Defensive trim handles _count mismatch        3/3
  - No lock held during yields                    3/3
  - LRU order preserved (MRU-first)               3/3
  - Memory cost is O(N)                           3/3

All three traced the GetEnumerator → MoveNext → MoveNext → Dispose
scenario and confirmed lock state at each step. No correctness bugs.

README + XML doc updated to reflect the new behavior:
  Before: "Enumeration acquires the LRU read lock for the lifetime of
           the iterator and blocks concurrent writers"
  After:  "Enumeration takes a one-time O(N) snapshot of the LRU chain
           under the read lock, then releases the lock before yielding —
           concurrent writers are not blocked during iteration."

Tests: 1526 net8 / 1497 net6, all green.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces extensive performance optimizations and documentation updates across the collection library. Key changes include refactoring the T-Digest implementation to use a width-augmented skip list for O(log c) operations, optimizing ObservableList.RemoveAll to a single-pass O(N) algorithm, and implementing word-level bit operations in BitGrid2D. Additionally, KD-tree construction was improved to O(N log N) using Quickselect, and ConcurrentLinkedDictionary enumeration was updated to prevent writer starvation. Review feedback identifies a critical issue in GraphDictionary where SortedSet usage may cause runtime exceptions for non-comparable keys, warns of potential cache key collisions in graph algorithms, and notes a complexity mismatch in the KdTree.Insert documentation.

var queue = new Queue<TKey>();
queue.Enqueue(source);
var settled = new HashSet<TKey>();
var queue = new SortedSet<(double Distance, TKey Key)> { (0, source) };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The use of SortedSet<(double Distance, TKey Key)> as a priority queue introduces a requirement that TKey must implement IComparable. Since GraphDictionary<TKey, TValue> only constrains TKey to notnull, this will cause a runtime InvalidOperationException if a user provides a non-comparable key type (as ValueTuple comparison relies on Comparer.Default for its elements).

Consider using a custom IComparer<(double Distance, TKey Key)> that handles non-comparable keys or using a priority queue implementation that doesn't require key comparison when distances are unequal.

return null;
if (start.Equals(end))
return new GraphPath<TKey>(new List<TKey> { start }, 0);
var cacheKey = $"shortest_unweighted_{start}_{end}_{_cacheVersion}";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The cache key generation using string interpolation of start and end keys is susceptible to collisions if the keys' ToString() representations contain underscores or if different pairs of keys produce the same concatenated string (e.g., start="12", end="3" vs start="1", end="23").

Additionally, frequent string allocations for cache lookups can impact performance. Since _metricsCache is a ConcurrentDictionary<string, object>, consider using a more robust delimiter or a different caching strategy that avoids string concatenation.

Comment thread README.md
| Operation | Time | Space |
|---|---|---|
| `Insert` | O(log N) avg, O(N) worst (degenerate input) | O(1) |
| `Insert` | O(log N) avg amortized; periodic O(N log N) rebuild every 64 inserts past N=1000 when height exceeds 2·log₂(N)+2 | O(1) |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The complexity claim for KdTree.Insert as O(log N) avg amortized is optimistic given the current implementation. The code (in KDTree.cs, line 57) performs a full tree traversal to estimate height (EstimateTreeHeight) every 64 inserts. Since EstimateTreeHeight is an $O(N)$ operation, the amortized cost of the check alone is $O(N/64) = O(N)$ per insert, making the overall amortized complexity $O(N)$ rather than $O(\log N)$.

To maintain $O(\log N)$ amortized complexity, the height should be tracked incrementally during insertion or the check frequency should be scaled with $N$.

…key collision, KdTree height O(1) tracking

Three issues from gemini-code-assist on PR #14, all real:

1. HIGH — GraphDictionary SortedSet<(double, TKey)> required TKey :
   IComparable<TKey>. The class only constrains TKey to `notnull`, so
   passing a non-IComparable key type triggered InvalidOperationException
   at runtime when the SortedSet's tuple comparison fell through to
   Comparer<TKey>.Default. Affected FindShortestPath (existing) and
   FindNodesWithinDistance (new in F-44).
   Fix: Custom `DistanceComparer : IComparer<(double, long Sequence, TKey)>`
   that orders by Distance, breaks ties by a monotonic per-call sequence
   counter. TKey.CompareTo never invoked.

2. MEDIUM — _metricsCache string keys used '_' as delimiter:
   `$"shortest_path_{start}_{end}_{_cacheVersion}"`. Two different
   (start, end) pairs whose ToString concatenations span the underscore
   can collide. Affects FindShortestPath and FindShortestUnweighted
   (FindStronglyConnectedComponents and GetCacheStatistics use only
   _cacheVersion — safe).
   Fix: replace '_' with ASCII Unit Separator (�) in the two pair-
   based cache keys. The control character is vanishingly unlikely to
   appear in any user TKey.ToString() value.

3. MEDIUM — KdTree.Insert claimed O(log N) amortized but height check
   fired EstimateTreeHeight (O(N) tree walk) every 64 inserts past N=1000,
   making amortized cost O(N/64) = O(N).
   Fix: replace EstimateTreeHeight with incremental _maxDepth tracking.
   InsertRecursive takes `ref int leafDepth` and records the depth at
   which the new node landed; Insert updates _maxDepth in O(1) by
   max-with-leafDepth. Build resets _maxDepth to ⌈log₂(N+1)⌉ (Quickselect
   produces a balanced tree). Clear resets to 0. RebalanceIfNeeded reads
   _maxDepth instead of recomputing — O(1) per check.
   The dead EstimateTreeHeight method is removed.

Tests: 1526 net8 / 1497 net6, all green.
@Codeturion

Copy link
Copy Markdown
Owner Author

Three gemini comments addressed in 16d355b:

  1. HIGH — GraphDictionary SortedSet IComparable safety: replaced SortedSet<(double, TKey)> with SortedSet<(double, long Sequence, TKey)> + custom DistanceComparer that orders by Distance and breaks ties by a per-call monotonic sequence counter. TKey.CompareTo is never invoked, so non-IComparable key types no longer throw at runtime. Applied to both FindShortestPath and FindNodesWithinDistance.

  2. MEDIUM — Cache key collision: replaced '_' delimiter with ASCII Unit Separator (\u001F) in the two pair-keyed cache keys (shortest_path and shortest_unweighted). The control char is vanishingly unlikely to appear in any user TKey.ToString. The other two cache keys (scc and stats) only embed _cacheVersion (an int), so they were already collision-free.

  3. MEDIUM — KdTree.Insert amortized complexity: replaced EstimateTreeHeight (O(N) tree walk every 64 inserts → amortized O(N/64) = O(N)) with incremental _maxDepth tracking. InsertRecursive now takes ref int leafDepth and records the depth at which the new node was placed; Insert updates _maxDepth in O(1). Build resets to ⌈log₂(N+1)⌉ (Quickselect produces a balanced tree). RebalanceIfNeeded reads _maxDepth directly — O(1) per check. The amortized O(log N) Insert claim now actually holds. Dead EstimateTreeHeight method removed.

Tests still 1526 net8 / 1497 net6, all green.

@Codeturion
Codeturion merged commit 1a66a04 into dev/omni-collections-v2 May 8, 2026
6 checks passed
@Codeturion
Codeturion deleted the audit-fixes branch May 8, 2026 21:22
Codeturion added a commit that referenced this pull request May 8, 2026
Minor release. Source changes from 16 commits on the audit-fixes branch
(merged via PR #14): 1 correctness bug, 9 implementation improvements
(some bench-confirmed at 11x-43x speedups), 43 documentation fixes
aligning README + XML docs to actual asymptotic behavior, 1 small API
change.

Implementation highlights:
  - GraphDictionary.FindNodesWithinDistance — bounded Dijkstra
    (was buggy queue+weighted-relaxation, gave wrong distances)
  - GraphDictionary.FindShortestUnweighted — new BFS variant
  - GraphDictionary SortedSet PQ tie-break by sequence (TKey no longer
    needs IComparable; cache key delimiter switched to ASCII Unit
    Separator to remove collision class)
  - KdTree.Build via Quickselect — O(N log²N) -> O(N log N), measured
    11.6x speedup at N=100k. _maxDepth tracked incrementally so
    RebalanceIfNeeded is O(1) per check (was O(N) tree walk every 64
    inserts, breaking the amortized O(log N) Insert claim).
  - Digest skip-list backbone — Add/Quantile/Cdf now genuinely O(log c)
    (was O(c)). Bulk-build in RebuildFromSorted gives Merge O(c1+c2)
    and Compress O(c). Quantile measured 43x faster at high compression.
  - LinkedMultiMap this[key]/TryGetValues return NodeValueView in O(1)
    instead of allocating TValue[]. (Small API change — return type
    changed from IReadOnlyList<TValue> to NodeValueView; the view still
    implements IReadOnlyList<TValue> so most consumers compile clean.)
  - ConcurrentLinkedDictionary.GetEnumerator — snapshot-on-iterate so
    concurrent writers aren't blocked for the iterator's lifetime.
  - ObservableList.RemoveAll/RemoveAllAsync — single-pass swap-and-truncate
    (was O(N*k) descending-index RemoveAt loop).
  - TimelineArray.ReplayAtFps — O(log N + frames) via linear advance.
  - BitGrid2D.FillArea / EnumerateSetBits — word-level fast paths.
  - HyperLogLog.EstimateUnion — no Clone allocation.
  - TemporalSpatialHashGrid.FindClosestSnapshot — O(log snapshots) via
    parallel sorted-keys list.

The README and class XML doc-comments are now aligned to actual code
behavior. Patch releases will continue to honor the PublicApiAnalyzer-
baselined surface; this minor bump is for the LinkedMultiMap return-type
change and the new GraphDictionary.FindShortestUnweighted method.
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