Phase 11: Core Fidelity Fixes¶
Summary¶
Phase 11 resolves 15 MAJOR and high-impact MODERATE deficits logged during MVP (Phases 0-10) through surgical modifications to ~20 existing source files. No new modules, no architectural changes, no new dependencies. All changes are backward-compatible with default parameters preserving MVP behavior.
Test count: 109 tests (9 + 42 + 35 + 23) across 4 test files. Total: 3,818 tests passing (up from 3,782).
What Was Built¶
11d: AI Fidelity (2 changes, 9 tests)¶
- Echelon hardcode fix (
decisions.py): Changed_decide_brigade_div()from hardcodedechelon_level=9to accept and pass through actual echelon parameter. Brigade now correctly reports echelon=8. - Tactical OODA acceleration (
ooda.py,battle.py): Addedtactical_acceleration: float = 0.5toOODAConfigandtactical_multparameter tocompute_phase_duration()/start_phase(). Battle manager now advances OODA phases after completions and applies tactical multiplier. Designed as a stacking multiplier for Phase 19 doctrinal school modifiers.
11a: Combat Fidelity (5 changes, 36 tests)¶
- Fire rate limiting (
ammunition.py,engagement.py,battle.py): Added_last_fire_time_s,_cooldown_s(fromrate_of_fire_rpm) toWeaponInstance.can_fire_timed()/record_fire()gate engagement execution. Cooldown gate inexecute_engagement()returnsaborted_reason="cooldown". - Per-side target_size_modifier (
battle.py): Changed from singletarget_size_modifierto per-side lookup viatarget_size_modifier_{target_side}with fallback to uniform value. - Environment coupling (
air_combat.py,air_defense.py,naval_surface.py,indirect_fire.py): Addedweather_modifier/visibility_kmto air combat,weather_modifierto air defense,sea_stateto naval surface,wind_speed_mps/wind_direction_degto indirect fire. Severe weather aborts sorties, sea state degrades salvo exchange, crosswind inflates CEP. - Mach-dependent drag (
ballistics.py): Added_speed_of_sound(),_mach_drag_multiplier()with piecewise regimes (subsonic=1.0, transonic rising to 2.0, supersonic falling).enable_mach_dragconfig flag preserves MVP behavior when False. - Armor type + obliquity (
damage.py): AddedArmorTypeenum (RHA/COMPOSITE/REACTIVE/SPACED),_ARMOR_EFFECTIVENESSlookup table, ricochet at >75 degrees.armor_typeparameter defaults to "RHA" for backward compatibility.
11b: Detection Fidelity (4 changes, 35 tests)¶
- Sensor FOV filtering (
sensors.py,detection.py): Addedboresight_offset_degtoSensorDefinition. FOV check incheck_detection()computes relative bearing from observer heading + boresight offset, filters targets outside FOV half-angle. - Dwell/integration gain (
detection.py): Added_scan_countsdict tracking (sensor_id, target_id) scan count. SNR boosted by5*log10(n_scans), capped atmax_integration_gain_db(default 6.0 dB = 4 scans).reset_scan_counts()method. State persistence viaget_state()/set_state(). - Geometric sonar bearing (
sonar.py): Replacedbearing = rng.uniform(0, 360)placeholder withatan2(dx, dy)geometric bearing + SNR-dependent Gaussian noise. Bothpassive_detection()andactive_detection()accept optionalobserver_pos/target_posparameters. Falls back to random bearing when positions not provided. - Mahalanobis gating (
estimation.py): Added Mahalanobis distance gate before Kalman update. Computesd2 = y.T @ S_inv @ yand rejects ifd2 > gating_threshold_chi2(default 9.21 = 99% for 2 DOF).update()now returnsbool(True=accepted, False=gated).enable_gatingconfig flag.
11c: Movement & Logistics Fidelity (4 changes, 23 tests)¶
- Fuel gating (
movement/engine.py): Addedfuel_available: float = infparameter tomove_unit(). Zero fuel prevents movement; partial fuel clamps distance tofuel_available / fuel_rate. Infantry (max_speed <= 5) unaffected (fuel_rate=0). - Stochastic engineering times (
logistics/engineering.py): Addedduration_sigma: float = 0.0toEngineeringConfig. When sigma > 0,assess_task()multiplies base duration byrng.lognormal(0, sigma). Default 0.0 preserves MVP deterministic behavior. - Wave attack modeling (
simulation/battle.py): Addedwave_assignments: dict[str, int]andbattle_elapsed_s: floattoBattleContext. Wave 0 moves immediately, wave N waits N * wave_interval_s (from calibration, default 300s), wave -1 (reserve) never moves. State persisted. - Stochastic reinforcement arrivals (
simulation/scenario.py,simulation/campaign.py): Addedarrival_sigma: float = 0.0toReinforcementConfig. Addedactual_arrival_time_stoReinforcementEntry, computed viarng.lognormal(0, sigma)at setup.check_reinforcements()uses actual time. State persisted.
Design Decisions¶
- Backward compatibility: All new parameters have defaults matching MVP behavior.
enable_*config flags for Mach drag and Mahalanobis gating. - DI pattern: Environmental data passed as parameters (fuel_available, observer_heading_deg, weather_modifier), not imported.
- PRNG discipline: All stochastic additions use
self._rng(injected Generator). - State protocol: All new stateful fields included in
get_state()/set_state(). - No new modules or dependencies: All changes modify existing files.
Issues & Fixes¶
- Existing estimation tests: Two tests (
test_position_moves_toward_measurement,test_multiple_updates_converge) used measurements far from predicted state that Mahalanobis gating correctly rejects. Fixed by increasingpos_varto ensure measurements are within the gate. - Integration test fire rate:
test_multiple_engagements_consume_ammofired 5 shots at t=0 — blocked by cooldown after first shot. Fixed by spacing shots 60s apart viacurrent_time_s. - Temperature/Mach interaction:
test_cold_reduces_muzzle_velocityinverted due to Mach-dependent drag interacting with temperature-dependent speed of sound. Fixed by disabling Mach drag in that test to isolate the MV-temperature relationship being tested.
Known Limitations / Post-MVP Refinements¶
- Fuel gating not wired to stockpile in battle.py: The movement engine accepts
fuel_availablebut battle.py does not yet queryctx.stockpile_managerfor Class III. This wiring is deferred until Phase 12b logistics depth. - Wave assignments are manual: No AI auto-assignment of units to waves. Phase 19 doctrinal AI may generate wave plans.
- Integration gain caps at 4 scans: Real radar integration may benefit from more scans. Current cap is conservative.
- Armor type YAML data: Existing unit definitions don't specify armor_type. Defaults to "RHA" everywhere.
Lessons Learned¶
- Mach-dependent drag affects temperature tests: Adding Mach drag changes the relationship between temperature and range because speed of sound is temperature-dependent. Tests isolating MV-temperature effects need to disable Mach drag.
- Mahalanobis gating threshold of 9.21 (99% for 2 DOF) is tight enough to reject measurements at 3-6 sigma: This catches scenarios where measurement noise is very high relative to position uncertainty, which is physically correct.
- Default parameter values matter for backward compatibility: Setting
duration_sigma=0.2broke existing deterministic tests. Changed to0.0with explicit opt-in.
Retrospective Cleanup¶
Post-implementation retrospective identified 7 gaps (2 Medium, 5 Low). All resolved:
Test Gaps Fixed¶
- Per-side
target_size_modifiertests (Medium, +4 tests): AddedTestPerSideTargetSizeModifierclass testing per-side lookup, fallback to uniform, both sides different, and default 1.0. - Naval surface sea state assertion (Medium): Replaced weak
isinstance(r_rough.hits, int)withassert r_rough.offensive_power < r_calm.offensive_powerand defensive_power comparison. - SPACED armor type penetration tests (Low, +2 tests): Added tests verifying SPACED vs KE (0.9 effectiveness, weaker than RHA) and SPACED vs HEAT (1.3 effectiveness, stronger than RHA).
- Conftest fixture migration (Low): Migrated
test_phase_11bandtest_phase_11cfrom local_rng()helpers to sharedmake_rng()from conftest, per project conventions.
Source Code Quality Fixes¶
- Public
tactical_accelerationproperty (Low): Added@propertytoOODALoopEngine. Updatedbattle.pyto usectx.ooda_engine.tactical_accelerationinstead of accessing private_config. - Explicit
air_temp_cparameter (Low): Replaced transientself._current_air_temp_cinstance variable inballistics.pywith explicitair_temp_cparameter on_drag_acceleration(). Temperature now flows through thederivs()closure like other condition parameters. reset_scan_counts()wired to battle resolution (Low): Added call toDetectionEngine.reset_scan_counts()inengine.pyafterresolve_battle()to prevent integration gain scan counts from bleeding across battles.