BUG: Fix DiscreteDP input validation and iteration edge cases#855
Conversation
- Raise ValueError on duplicate state-action pairs, which previously either raised an uninformative IndexError or silently constructed a corrupted instance (coo_matrix.tocsr() sums the duplicated pointer entries) - Handle trailing states with no action: bound the s_indices walk in _generate_a_indptr (previously an unchecked out-of-bounds read under numba) and pass the shape to coo_matrix explicitly, so that the informative feasibility ValueError is raised; check action availability before the R_max check in _check_action_feasibility - Fix operator_iteration to return 0 (not a tuple) when max_iter <= 0, and raise ValueError for max_iter < 1 in the solution methods - Validate beta at the top of __init__; fix docstring typos Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR hardens DiscreteDP’s SA-pair constructor and iteration routines by adding missing input validation and fixing edge cases that could previously lead to silent model corruption or unsafe out-of-bounds access under Numba. It also aligns behavior more closely with the QuantEcon.jl implementation and adds targeted regression tests.
Changes:
- Prevent silent corruption by detecting duplicate
(state, action)pairs during SA-pair index sorting and raising a clearValueError. - Fix trailing “no available actions” cases by bounding
_generate_a_indptr’s index walk and by supplying an explicit COO shape soa_indptris always lengthnum_states + 1. - Make iteration semantics consistent by returning an integer iteration count from
operator_iterationformax_iter <= 0, and raisingValueErrorin all solve methods whenmax_iter < 1(plus doc/test typo fixes).
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
quantecon/markov/ddp.py |
Adds early beta validation, detects duplicate SA pairs on CSR conversion, reorders feasibility checks to avoid uninitialized reads, fixes operator_iteration return type, and validates max_iter in solvers. |
quantecon/markov/utilities.py |
Bounds _generate_a_indptr’s index walk to handle trailing states with no SA pairs safely. |
quantecon/markov/tests/test_ddp.py |
Adds regression tests for trailing no-action states, duplicate SA pairs, and nonpositive max_iter, plus fixes a test name typo. |
On the sorted state-action path, an out-of-range state index (e.g. s_indices=[0, 2] with num_states=2) was silently accepted, with the pair reattributed to a lower state; on the unsorted path it raised only a cryptic scipy shape error. Validate the ranges explicitly before branching, covering both paths with a clear message. Also test max_iter=-1 in addition to max_iter=0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Thanks so much for this, @oyamad! 🙏 Really nice to see these DiscreteDP edge cases rounded up and fixed in one go — every claim was easy to check. Approving.
All four fixes target genuine bugs I reproduced on main, the fixes are correct, and each new test fails on base / passes here (full quantecon/markov suite: 90 passed).
Verified independently:
- Duplicate
(s,a)—nnz < num_sa_pairsis sound and complete: no false positives (tocsr()sums duplicates but keeps explicit zeros, so thearange-pointer0is not dropped), and duplicates always route to the unsorted branch because_has_sorted_sa_indicesrejects equal adjacent pairs — so the sorted branch legitimately needs no check of its own. 👍 - Trailing no-action state — the unbounded
_generate_a_indptrwalk was genuine numba UB (nopython disables bounds-checking; on base I reproduced it yielding a corrupt non-monotonica_indptr). The bound plus the explicitcoo_matrixshape fix both the sorted and unsorted paths, and_generate_a_indptrwas the only kernel inutilities.pywith that unbounded pattern. operator_iteration/max_iter— return type now matches thenum_iter : scalar(int)docstring; the four guards are correctly placed after theNone-default; neither internal caller relied on the old tuple.betamove — behavior-neutral; nothing between the old and new position readsbeta.
Nice touch confirming parity with QuantEcon.jl — the Julia constructor raises ArgumentError("Duplicate s-a pair found") on the same input, so the behaviors line up.
A few optional, non-blocking thoughts — purely take-it-or-leave-it:
beta == 1now emits its warning even when construction later fails structurally, since thewarnmoved above the structural checks. Harmless.- The trailing-state test's sorted sub-case relies on numba UB failing on base (a bit non-deterministic);
pytest.raises(ValueError, match='at least one action')would make it a firmer regression sentinel. A sparse-Qtrailing case and amax_iter=-1value would round out coverage nicely. - Might be worth a changelog line for the
operator_iterationreturn-type change and the newValueErrors, since both are user-visible.
I also filed #856 for a related, out-of-scope gap I noticed while digging: the sorted branch still silently accepts out-of-range state indices, an asymmetry this PR's shape fix happens to surface (not a regression from your change). No action needed here — just opened it for discussion.
Thanks again 🎉
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* FIX: Fix known bugs in DiscreteDP (markov/ddp.jl) - Copy v_init in DPSolveResult so that solve does not overwrite the caller's array - Make MPFI respect a user-supplied v_init; the min-reward initialization is applied only as the default, as in QuantEcon.py - Remove a leftover debug println in the 2-arg s_wise_max! - Fix s_wise_max for DDPsa to preserve the eltype of vals (was hardcoded to Float64) - Seed the dense argmax kernel from the first column instead of -Inf (type-stable; out is always written) - Bound the s_indices walk in _generate_a_indptr! so that trailing states with no action raise the informative ArgumentError instead of a BoundsError - Validate index ranges of s_indices and a_indices (out-of-range state indices were silently reattributed to other states on the sorted path), and pass num_states explicitly to sparse() on the unsorted path; parity with QuantEcon/QuantEcon.py#855 - Replace the type-pirated Base.:*(::AbstractArray{T,3}, ::Vector) method with an internal _mul function - Make DPSolveResult.Tv concrete (Vector{Tval}); warn on beta=1 in the dense constructor as in the sa-form one; minor cleanups in error messages and the PFI convergence check Flips the 8 @test_broken markers from #384 to @test and adds tests for the newly fixed cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * FIX: Update benchmark/ddp.jl for the removal of Base.:* on 3-dim arrays The suite construction used ddp.Q * v, which relied on the type-pirated method removed in this branch; use the internal QuantEcon._mul instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * FIX: Interpolate num_states into the s_indices range error message Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * TEST: Test PFI, not VFI, in the policy_iteration beta=1 testset Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
thanks @oyamad I am happy for you to merge this any time you're ready. |
Closes #856.
This PR fixes several input-validation and edge-case bugs in
DiscreteDP(quantecon/markov/ddp.py), found while reviewing the module against its QuantEcon.jl counterpart.1. Duplicate state-action pairs silently corrupted the model
In the state-action pair formulation with unsorted indices, duplicate (s, a) pairs were not detected:
coo_matrix(...).tocsr()sums duplicate entries, and since the entries here are position pointers intoRandQ, the constructor either raised an uninformativeIndexErroror, when the summed pointer happened to land in bounds, silently built a corrupted instance (len(R) < num_sa_pairs, rewards and transition probabilities reassigned across pairs, stales_indices) that then solved without complaint. AValueError('duplicate state-action pair found')is now raised, consistent with the behavior of QuantEcon.jl. Example that previously constructed (and solved) silently:2. Trailing states with no action caused out-of-bounds reads
With sorted input indices,
_generate_a_indptrwalkeds_indicespast the end whenever the last state(s) had no action available — an unchecked out-of-bounds read under Numba's nopython mode. With unsorted input indices, thecoo_matrixshape was inferred from the largest indices present, soa_indptrcame out shorter thannum_states+1, with the same consequence downstream. The kernel now bounds its walk and the COO shape is passed explicitly, so the intended informativeValueError("for every state at least one action must be available") is raised in both cases. The two checks in_check_action_feasibilityare also reordered (action availability first), since for a state with no actions_wise_maxleaves the corresponding entry ofR_maxuninitialized.3.
operator_iterationreturned a tuple whenmax_iter <= 0, andmax_iter=0crashedpolicy_iterationoperator_iterationreturned(v, 0)instead of the documentednum_iteron its early-return path. The four solution methods now raiseValueErrorformax_iter < 1(previouslypolicy_iterationdied withNameError, andvalue_iterationstored the tuple inres.num_iter).4. Cosmetics
betais now validated at the top of__init__(previously only after the index-sorting work); typo fixes in docstrings ("optinal", "represetned", "correspnoding", "feasbile") and in a test name.Tests are added for each fix; the full
quantecon/markovtest suite passes (90 passed).🤖 Generated with Claude Code (Claude Fable 5)