# UNDF: UNDF-2026-000000689 # UNDF: (pending) # numpy-0001: stack_arrays — seen=[] list dedup O(A×F²) field-name tracking ## CWE-407 — Algorithmic Complexity: O(A×F²) list-contains in structured-array stacking | Field | Value | |-------|-------| | ID | numpy-0001 | | Severity | MEDIUM | | Ecosystem | numpy | | Package | `numpy` | | File | `numpy/lib/recfunctions.py` | | Lines | 1396–1405 | | Complexity | O(A×F²) on A arrays with F fields each | | Hot path | `stack_arrays()` — stacking structured/record arrays | ## Background `stack_arrays(arrays)` superposes structured arrays field by field, building a merged output array. It tracks which field names have already been seen in order to handle anonymous arrays (arrays without named fields) by generating synthetic names like `f0`, `f1`, … ## Defect ```python # numpy/lib/recfunctions.py lines 1394–1405 output = ma.masked_all((np.sum(nrecords),), newdescr) offset = np.cumsum(np.r_[0, nrecords]) seen = [] # DEFECT: list, not set for (a, n, i, j) in zip(seqarrays, fldnames, offset[:-1], offset[1:]): names = a.dtype.names if names is None: output[f'f{len(seen)}'][i:j] = a else: for name in n: output[name][i:j] = a[name] if name not in seen: # O(F) list scan each time seen.append(name) ``` The outer loop runs A times (once per input array). The inner loop runs F times per array. `if name not in seen` is an O(|seen|) linear scan, so the total work for field-name dedup is O(A×F²). The same `seen` list grows across all arrays, making later checks progressively more expensive. Note that `stack_arrays` also calls a sibling loop at lines 1377–1383 (the `newdescr` / `names` list build) with the same O(F²) pattern — see numpy-0002 for the `join_by` variant. ### When does this matter? Scientific pipelines that stack many structured arrays (e.g. HDF5 record batches, FITS tables, structured CSV readers) can pass hundreds of arrays each with dozens of fields, reaching millions of unnecessary comparisons. ## Complexity table | Arrays (A) | Fields (F) | list ops | set ops | Speedup | |-----------|-----------|---------|---------|---------| | 10 | 50 | ~12,500 | 500 | 25× | | 50 | 100 | ~250,000 | 5,000 | 50× | | 100 | 200 | ~2,000,000 | 20,000 | 100× | | 500 | 500 | ~62,500,000 | 250,000 | 250× | ## Fix Replace `seen = []` with `seen = set()` and use `in`/`.add()`: ```python # Fixed seen = set() # FIX: O(1) membership for (a, n, i, j) in zip(seqarrays, fldnames, offset[:-1], offset[1:]): names = a.dtype.names if names is None: output[f'f{len(seen)}'][i:j] = a else: for name in n: output[name][i:j] = a[name] if name not in seen: # O(1) set probe seen.add(name) ``` `set.add()` and `name not in seen` are both O(1) amortised. The only change is the data structure; the ordering semantics of `len(seen)` for synthetic name generation (`f0`, `f1`, …) are preserved because set cardinality is tracked identically.