@@ -776,3 +776,198 @@ It does **not** resolve the equivalent gap for:
776776| Test suite result after the change | 235 passed, 0 failed (` pytest tests/ ` ) |
777777| HEAD at start of this section | ` 81c76bb ` (this document's own §7 commit) |
778778| This section | Drafted 2026-07-18, same session as the diagnostic run |
779+
780+ ## 9. RLLA mechanism — why is realized BLER 0.13×–0.17× of target, specifically?
781+
782+ Continues §8 (same commit lineage: ` 81c76bb ` , ` 5e40240 ` , ` c012148 ` ). §8 established
783+ * that* RLLA's BLER is far under target; this section identifies * why* , from
784+ ` EpsilonGreedyRLLA ` 's code (` projects/p6_rl_link_adapt/rl_core.py ` , lines
785+ 178–264, read in full this session) plus a throwaway empirical trace. No
786+ committed file was modified to reach this conclusion (confirmed: `git diff
787+ --stat projects/p6_rl_link_adapt/rl_core.py projects/p6_rl_link_adapt/runner.py`
788+ returns empty at the time of writing).
789+
790+ ### 9.1 Part 1 — code reading
791+
792+ ** 1. Action space / MCS range — ruled out as a cause.** `_ MCS_INDICES =
793+ sorted(NRConstants.MCS_TABLE.keys())` ( ` rl_core.py` line 61) — confirmed via
794+ ` nr_simulator/constants.py ` line 111 (` N_MCS = len(MCS_TABLE) # 29 (0..28) ` )
795+ this is the full 0–28 range. ` EpsilonGreedyRLLA.select_mcs ` draws
796+ exploration arms from ` _MCS_INDICES ` directly (line 232: `self._ rng.choice
797+ (_ MCS_INDICES)`) and scans the same set for the greedy choice (line 240:
798+ ` for mcs in _MCS_INDICES ` ) — identical to the shared ` _MCS_INDICES ` global
799+ that ` ThompsonSamplingLA ` /` _best_mcs_for_sinr ` (LTSLA) also uses (` rl_core.py `
800+ line 71). ` BanditLAController.select_mcs ` 's `np.clip(mcs, 0,
801+ NRConstants.N_MCS - 1)` (line 451) is a no-op defensive clip, not a range
802+ restriction. ** No off-by-one or truncated range** — RLLA can structurally
803+ reach every MCS OLLA/LTSLA can.
804+
805+ ** 2. Initial value estimates — optimistic, but exactly at the feasibility
806+ cutoff.** Line 219: `self._ p_success: Dict[ int, float] = {m: 0.9 for m in
807+ _ MCS_INDICES}` — every arm (MCS 0 through 28 alike) starts at the * same*
808+ optimistic estimate, 0.9, with the comment (line 218) confirming intent:
809+ "Optimistic init: assume each arm succeeds, encourages exploration." The
810+ critical detail: ` target_bler ` defaults to ` NRConstants.TARGET_BLER ` = 0.10
811+ (` constants.py ` line 128), so the feasibility test in ` select_mcs ` (line
812+ 242: ` if p >= (1.0 - self.target_bler) ` ) requires ` p >= 0.90 ` — ** exactly
813+ equal to the optimistic initial value** , not comfortably above it.
814+
815+ ** 3. Exploration schedule.** ` epsilon=0.10 ` , ` epsilon_decay=0.9995 ` ,
816+ ` epsilon_min=0.01 ` (lines 205–207), decayed once per ` update() ` call (line
817+ 259: ` self.epsilon = max(self.epsilon_min, self.epsilon * self.epsilon_decay) ` ).
818+ Solving ` 0.10 × 0.9995^N = 0.01 ` gives ` N ≈ 4603 ` updates — at roughly one
819+ scheduled update per TTI per UE, epsilon reaches its floor well inside the
820+ first ~ 15% of a 30000-TTI run, then stays at the 1% floor (not zero) for
821+ the remainder. Exploration never fully stops, but becomes rare early on.
822+
823+ ** 4. Reward/update rule — plain unweighted running average, no explicit
824+ asymmetric penalty.** ` update() ` (lines 253–259): `target = 1.0 if is_ack
825+ else 0.0; self._ p_success[ mcs] += self.lr * (target - self._ p_success[ mcs] )`
826+ (` lr=0.05 ` , line 208). This is a plain exponential running average of the
827+ raw ACK/NACK bit — ** not** throughput-weighted, and there is no explicit
828+ NACK-penalty coefficient anywhere in this update. Any apparent asymmetry
829+ between "cost of a NACK" and "benefit of a higher MCS" is not written into
830+ the reward function directly — it would have to emerge from the interaction
831+ of items 2+3 with the hard threshold in ` select_mcs ` , not from a deliberate
832+ reward-shaping choice. This distinguishes the finding from "RLLA's authors
833+ chose a conservative reward on purpose" — nothing in the code sets out to
834+ do that.
835+
836+ ** Most likely explanation from code alone, before the empirical check:**
837+ items 2+3 together create a specific, quantifiable failure mode. Starting
838+ at ` p=0.9 ` and drawing one NACK gives ` p = 0.9 + 0.05×(0 − 0.9) = 0.855 `
839+ — ** below the 0.90 cutoff after exactly one failure** , regardless of an
840+ arm's true long-run success rate. Once below 0.90, the greedy branch (lines
841+ 236–247) can never re-select that arm (it only iterates ` p >= 0.90 `
842+ arms) — the * only* path back is an epsilon-triggered uniform draw over all
843+ 29 arms (line 232, not weighted toward near-threshold arms), each such
844+ event nudging ` p ` by only ` lr × (target − p) ` , i.e. slow, and needing
845+ several consecutive lucky successes to climb back above 0.90 from 0.855.
846+ This reads as a ** calibration issue** in how three otherwise-reasonable
847+ choices interact (optimistic init exactly at the threshold, a hard
848+ pass/fail cutoff, slow+rare recovery) rather than a coding bug (no
849+ incorrect logic) or a deliberate reward-shaping choice (no throughput/
850+ penalty weighting exists to shape). Confirming this requires seeing actual
851+ selection behavior over time — code reading alone can't distinguish "gets
852+ stuck at an early unlucky estimate and never recovers" from "explores fine
853+ but something else makes low MCS genuinely optimal" — hence Part 2.
854+
855+ ### 9.2 Part 2 — empirical trace
856+
857+ ** Method.** A throwaway script (not committed; scratchpad only) instantiated
858+ ` BanditLAController(method="epsilon_greedy", ...) ` and drove it through the
859+ same ` _run_rl ` -style loop (` CellLayout ` → ` ProportionalFairScheduler ` →
860+ ` ctrl.select_mcs ` /` ctrl.update ` ), recording every returned ` mcs ` in call
861+ order (aggregated across all UEs — an initial attempt to track a single
862+ UE's trajectory found that UE 0 was scheduled rarely under this seed/PF
863+ combination, so tracking switched to all scheduled events instead, which
864+ also better matches "does the bandit population as a whole ever try/
865+ abandon higher MCS"). Bucketed into 1000-event windows. Two configs, one
866+ seed (42): 3 km/h/CDL-A (favorable, §7/§8) and 30 km/h/CDL-A (unfavorable).
867+ No ` rl_core.py ` /` runner.py ` code was touched — only the existing public
868+ ` select_mcs ` /` update ` API was called from the external script.
869+
870+ ** Result — both configs, essentially identical pattern:**
871+
872+ | Config | Mean MCS, first 1000 events | Mean MCS, last 1000 events | Realized BLER (this single-seed run) |
873+ | ---| ---| ---| ---|
874+ | 3 km/h, CDL-A | 10.74 | 5.63 | 0.0178 |
875+ | 30 km/h, CDL-A | 9.89 | 5.73 | 0.0194 |
876+
877+ (Single-seed values here differ slightly from §8's 5-seed means — 0.0143
878+ at 3 km/h-A, 0.0159 at 30 km/h-A — as expected for one seed vs. a 5-seed
879+ average; same order of magnitude, same conclusion.)
880+
881+ - Both configs start with a mean selected MCS around 10–11 (consistent with
882+ the optimistic-init greedy branch initially favoring high-spec-eff,
883+ as-yet-untried arms) and decline over the run to a ** steady state around
884+ 5.6–5.9 — nearly identical between 3 km/h and 30 km/h** , not a
885+ monotonic straight-line drop: the 3 km/h trace shows a partial rebound
886+ around events 6000–10000 (mean rising back to ~ 8.5) before settling down
887+ again by the end. This is consistent with a stochastic
888+ equilibrium-seeking process (arms near the 0.90 cutoff randomly crossing
889+ above and below it), not a one-shot collapse.
890+ - ** MCS 28 (the ceiling) is drawn in nearly every 1000-event bucket, all
891+ the way to the final bucket of the run, in both configs** — the bandit
892+ never permanently loses access to the top of the range; the epsilon
893+ floor (1%, uniform over 29 arms, ≈0.03% per event) keeps sampling it
894+ throughout, exactly as item 3 predicts. This is why "highest MCS ever
895+ tried" and "max in final 1000 events" are both 28 in every trace — a
896+ low-rate noise artifact of ` min ` /` max ` , not evidence the ceiling is
897+ reachable * and stable* : the ** mean** is the signal that matters, and it
898+ settles low regardless.
899+ - ** The key discriminating result: 3 km/h and 30 km/h converge to nearly
900+ the same final mean MCS (5.63 vs. 5.73)** , despite very different fading
901+ dynamics between the two configs. If the mechanism were Doppler/speed-
902+ tracking-related (the way LTSLA's §8 finding is), the two configs would
903+ be expected to diverge, not converge to nearly the same value. They
904+ don't — this is direct evidence against a speed-dependent explanation
905+ and points at something intrinsic to the bandit's own estimation
906+ dynamics, common to both configs.
907+
908+ ### 9.3 Part 3 — conclusion
909+
910+ ** The evidence (code + empirical trace) converges on a single, specific
911+ mechanism, not an unresolved choice between candidates:**
912+
913+ ` EpsilonGreedyRLLA ` 's optimistic initial value (0.9) is set ** exactly
914+ equal to** its own feasibility threshold (` 1 − target_bler ` = 0.90, since
915+ ` target_bler ` defaults to the same 10% used everywhere else in this
916+ project). Because of this exact equality, a single NACK on any untried arm
917+ — including an arm whose * true* long-run success rate is close to the
918+ target's own 90% — immediately drops that arm's estimate to 0.855, below
919+ the cutoff, disqualifying it from ever being greedily re-selected until a
920+ rare (≈0.03%-per-event, uniform-over-29-arms) exploration event re-tries
921+ it, which then needs * sustained* luck (several consecutive successes
922+ against the slow ` lr=0.05 ` running average) to climb back above 0.90.
923+ Arms whose true success rate sits * near* 90% are the least likely to
924+ survive this process, because they fail roughly 1 time in 10 by
925+ definition — almost exactly as often as it takes to trigger the
926+ disqualification — while arms with a true success rate * safely above* 90%
927+ (i.e., meaningfully more conservative than the 10% BLER target) are the
928+ only ones stable enough to survive an initial unlucky run and remain the
929+ long-run greedy choice. This produces a systematic downward bias toward
930+ BLER well under target, ** independent of channel speed** — confirmed
931+ empirically by the near-identical steady-state mean MCS at 3 km/h and
932+ 30 km/h (§9.2), which specifically rules out a Doppler/speed-tracking
933+ explanation (the mechanism behind LTSLA's §8 finding) as the cause here.
934+
935+ This is a ** design/calibration issue** , not a coding bug (no incorrect
936+ logic, no off-by-one, confirmed in §9.1 item 1) and not a deliberate
937+ reward-shaping choice (the update rule is an unweighted ACK/NACK running
938+ average with no asymmetric penalty term, confirmed in §9.1 item 4) —
939+ specifically, the calibration issue is the coincidence of the optimistic
940+ initial value and the feasibility threshold being set to the * same*
941+ number, combined with a hard binary cutoff and a slow, rare recovery path.
942+
943+ ** What this does not establish** : this conclusion rests on code analysis
944+ plus a single-seed, two-config empirical trace (per this task's own scope
945+ — not a fresh 5-seed/6-config campaign). The qualitative mechanism (the
946+ arithmetic of ` p0=0.9 ` , ` threshold=0.90 ` , ` lr=0.05 ` interacting with a hard
947+ cutoff) does not depend on seed or config, so there is no specific reason
948+ to expect other seeds/configs to tell a different story — but this was not
949+ verified beyond the two configs and one seed traced here. No fix was
950+ attempted or evaluated (out of scope per the task's own STOP condition);
951+ this section identifies the mechanism, it does not remediate it.
952+
953+ ### 9.4 Recommendation for a follow-up task (not done here)
954+
955+ ` README.md ` and ` docs/index.html ` (commits ` c012148 ` and the prior,
956+ still-uncommitted ` docs/index.html ` edit) currently describe this as
957+ "likely a design/calibration issue in ` EpsilonGreedyRLLA ` 's MCS selection"
958+ — deliberately unspecific at the time. Given §9.3's conclusion is now
959+ specific and code-cited, a follow-up documentation task could tighten that
960+ wording to name the actual mechanism (optimistic-init-equals-threshold
961+ plus hard-cutoff-with-slow-recovery). Not done as part of this task, which
962+ was scoped to identification only, and per the task's own instruction not
963+ to edit ` README.md ` /` docs/index.html ` here.
964+
965+ ### 9.5 Provenance for this section
966+
967+ | Artifact | Location |
968+ | ---| ---|
969+ | Code read this session | ` projects/p6_rl_link_adapt/rl_core.py ` lines 61, 71, 178–264, 451; ` nr_simulator/constants.py ` lines 111, 128 |
970+ | Empirical trace script | Throwaway, scratchpad-only, not committed — calls only ` BanditLAController ` 's existing public API |
971+ | Confirmation no algorithm file was left modified | ` git diff --stat projects/p6_rl_link_adapt/rl_core.py projects/p6_rl_link_adapt/runner.py ` → empty, this session |
972+ | HEAD at start of this section | ` 81c76bb ` / ` 5e40240 ` / ` c012148 ` (no new commits from this section's own work — documentation-only append) |
973+ | This section | Drafted 2026-07-18, same session as the code review and trace |
0 commit comments