java-topology/defects/symfony/bench/bench-symfony-0001.py
russell@unturf.com 24052ec668
2 follow-up flagships: symfony-0001 + pyright-0001 (Wave 17 backlog cleared)
Both were flagged in Wave 17 survey as borderline real defects deferred
for follow-up because the fix needed careful design beyond a single-line
set hoist. Both shipped now with full bench + ticket + intel.

UNDF-1310 symfony-0001 (HIGH) - PropertyAccessor::writeCollection.
  Doctrine entity collection diff: in_array($item, $collection, true)
  per item in $previousValue, then in_array($item, $previousValue, true)
  per item in $collection. O(P*C). Fix: dual lookup
  (SplObjectStorage for objects + serialize-keyed array for scalars,
  in_array fallback for resources). Bench: 5.2x at P=C=100,
  88x at P=C=2000.

UNDF-1311 pyright-0001 (HIGH) - CallHierarchyProvider outgoing/incoming
  call dedup. _outgoingCalls.find / _incomingCalls.find with composite
  key (uri, range) walks the list per call expression. O(C^2). Fix:
  parallel Map<string, entry> keyed by composite serialized form
  (uri|start.line|start.char|end.line|end.char). Bench: 2.7x at C=100,
  22x at C=2000.

Total session flagships: 11 (was 9) — 7 CWE-407 + 3 MOAD-0003 + 1 MOAD-0004.
Wave 17 borderline backlog now empty.
2026-04-26 12:36:07 -04:00

93 lines
3.2 KiB
Python

"""
Benchmark for UNDF-2026-000001310 / symfony-0001
PropertyAccessor::writeCollection — O(P*C) -> O(P+C) via dual lookup
(SplObjectStorage for objects + serialize-keyed array for scalars).
The Symfony PropertyAccessor walks `previousValue` and `collection` doing
in_array($item, $other, true) per pass. For P previous items + C new items
the cost is O(P*C) per write. For a Doctrine entity with a OneToMany
association of N items being updated to N different items, the cost is
O(N^2).
This Python bench models the cost in equivalent terms (set operations
mirror SplObjectStorage / serialize lookup). The PHP fix is in the .patch
file; this bench validates the complexity-class change is real.
"""
import random
import time
class Item:
__slots__ = ("id",)
def __init__(self, id_):
self.id = id_
def bench_defective(previous, collection):
# Symfony's current shape: in_array($item, $other, true) per pass.
# In Python: list membership via `in`.
removed = 0
for item in previous:
if item not in collection:
removed += 1
added = 0
for item in collection:
if item not in previous:
added += 1
return removed, added
def bench_fixed(previous, collection):
# Build object lookup (id() set) once, then O(1) membership.
# SplObjectStorage / WeakSet equivalent via id().
prev_set = {id(x) for x in previous}
coll_set = {id(x) for x in collection}
removed = sum(1 for item in previous if id(item) not in coll_set)
added = sum(1 for item in collection if id(item) not in prev_set)
return removed, added
def best_of(fn, *args, trials=3):
best = float("inf")
for _ in range(trials):
t0 = time.perf_counter()
fn(*args)
t = time.perf_counter() - t0
if t < best:
best = t
return best
def main():
random.seed(42)
out = []
out.append("=== symfony-0001: PropertyAccessor::writeCollection O(P*C) -> O(P+C) ===")
out.append("")
out.append(f"{'scale':>22} {'defective':>12} {'fixed':>10} {'speedup':>10}")
out.append("-" * 60)
for n_prev, n_coll in [
(100, 100), # small entity collection
(300, 300), # mid (e.g. user with many tags)
(500, 500), # large
(1000, 1000), # very large
(2000, 2000), # extreme (denormalized media library)
]:
# Pool of objects; ~50% overlap between previous and new (typical
# update pattern: most stays, some added, some removed).
pool = [Item(i) for i in range(n_prev + n_coll)]
previous = random.sample(pool, n_prev)
collection = random.sample(pool, n_coll)
d = best_of(bench_defective, previous, collection)
f = best_of(bench_fixed, previous, collection)
speedup = d / f if f > 0 else float("inf")
out.append(
f" P={n_prev:>5} C={n_coll:>5} {d * 1000:>9.2f}ms {f * 1000:>7.2f}ms {speedup:>7.1f}x"
)
out.append("")
out.append("Conclusion: dual-lookup hoist (SplObjectStorage + serialize-keyed array).")
out.append("Symfony Doctrine entities with deep OneToMany hit O(N^2) without this fix.")
print("\n".join(out))
if __name__ == "__main__":
main()