Phase 70: Performance Optimization¶
Overview¶
Eliminated O(n²) hot paths in battle.py to achieve measurable speedup on large scenarios. Pure optimization phase — no new behavioral features, no new CalibrationSchema fields.
Deliverables¶
70a: Vectorized Nearest-Enemy & Movement Target¶
- Added optional
enemy_pos_arr: np.ndarrayparameter to_nearest_enemy_dist()and_movement_target() - Vectorized path uses numpy
sum/min/argmin/mean/sqrton pre-built position arrays - Scalar fallback preserved for backward compatibility
_execute_movement()updated to accept and passenemy_pos_arrays- Call sites at naval posture (L1272) and standoff check (L2423) pass vectorized arrays
Deviation from plan: Used numpy vectorization instead of STRtree. Simpler approach since _build_enemy_data() already produces per-side numpy arrays — reusing existing infrastructure rather than adding a new spatial index.
70b: Unit ID Index & Formation Sort Hoisting¶
_unit_index: dict[str, Unit]built once per tick inexecute_tick()- Replaces O(n) linear scan for UAV parent lookup (data link range check)
- Replaces redundant per-unit-lookup dict build in fire zone damage section
- Formation sort hoisted before per-unit loop:
_sorted_active+_unit_formation_idxcomputed once per side - Eliminates O(n² log n) from
sorted()inside per-unit movement loop
70c: Signature Cache & Calibration/Engine Hoisting¶
self._signature_cache: dict[str, Any]on BattleManager — caches by unit_type (immutable per scenario)- ~30
cal.get()calls hoisted before engagement loop body (each checks 4 patterns) - ~8
cal.get()calls hoisted before movement loop body - ~20
getattr(ctx, engine, None)calls hoisted before engagement loop - ~8
getattr(ctx, engine, None)calls hoisted before movement loop - Side-prefixed keys (
{side}_force_ratio_modifier) correctly left inline
70d: Performance Benchmarks¶
tests/performance/test_battle_perf.py— 4 tests (2 benchmark + 2 determinism)- 73 Easting: < 30s assertion + determinism verification
- Golan Heights: < 180s assertion + determinism verification (marked
@pytest.mark.slow)
Files Modified¶
| File | Changes |
|---|---|
stochastic_warfare/simulation/battle.py |
253 insertions, 181 deletions |
New Test Files¶
| File | Tests |
|---|---|
tests/unit/test_phase_70a_vectorized.py |
7 |
tests/unit/test_phase_70b_unit_index.py |
6 |
tests/unit/test_phase_70c_caching.py |
7 |
tests/performance/test_battle_perf.py |
4 |
| Total | 24 |
Dropped Items¶
- Weapon category → EngagementType pre-cache (low-frequency path, not worth the complexity)
- Taiwan Strait dedicated benchmark (Golan Heights is the harder test)
- STRtree approach for nearest-enemy (numpy vectorization is simpler and equally effective)
Lessons Learned¶
- Reuse existing arrays:
_build_enemy_data()already produced numpy arrays that were unused by distance functions. Passing them through was far simpler than building a new spatial index. - Hoisting is high line-count, low risk: ~60 mechanical substitutions in the engagement loop, but each is a trivial
cal.get("X", default)→_hoisted_varreplacement. - Side-prefixed keys can't be hoisted above the per-side loop: Keys like
{side}_formation_spacing_mvary by side, so they must stay inside the side loop (but outside the per-unit loop). - Pre-existing test failures mask signal: 15 scenarios fail due to
engine.pyUnboundLocalError (_sim_time_s) from Phase 69 — unrelated to Phase 70 but complicates regression detection.
Postmortem¶
- Scope: On target — all high-impact optimizations delivered, two low-impact items correctly dropped
- Quality: High — 100-point random parity tests, determinism verification, zero TODOs
- Integration: Fully wired — all changes in existing
battle.py, no orphaned code - Deficits: 0 new deficits. Pre-existing: engine.py
_sim_time_sbug (Phase 69) - Action items: Update all lockstep docs (CLAUDE.md, devlog/index.md, development-phases-block8.md, MEMORY.md, README.md)