mdpsolver 0.10.1__tar.gz → 0.10.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 Anders Reenberg Andersen and Jesper Fink Andersen
3
+ Copyright (c) 2026 Anders Reenberg Andersen and Jesper Fink Andersen
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: mdpsolver
3
- Version: 0.10.1
4
- Summary: A fast solver for Markov Decision Processes
3
+ Version: 0.10.3
4
+ Summary: A fast Python solver for Markov Decision Processes
5
5
  Author-Email: Anders Reenberg Andersen <andersra@live.dk>, Jesper Fink Andersen <jesperfinkandersen@gmail.com>
6
6
  Classifier: Development Status :: 4 - Beta
7
7
  Classifier: Programming Language :: Python :: 3
@@ -31,7 +31,7 @@ MDPSolver is a Python package for large Markov Decision Processes (MDPs) with in
31
31
 
32
32
  # Installation
33
33
 
34
- Prebuilt wheels are available for **Linux**, **Windows**, and **macOS (14+)** across Python 3.10–3.14. Install directly from PyPI with:
34
+ Install directly from PyPI with:
35
35
 
36
36
  ```
37
37
  pip install mdpsolver
@@ -13,7 +13,7 @@ MDPSolver is a Python package for large Markov Decision Processes (MDPs) with in
13
13
 
14
14
  # Installation
15
15
 
16
- Prebuilt wheels are available for **Linux**, **Windows**, and **macOS (14+)** across Python 3.10–3.14. Install directly from PyPI with:
16
+ Install directly from PyPI with:
17
17
 
18
18
  ```
19
19
  pip install mdpsolver
@@ -15,12 +15,12 @@ CMAKE_BUILD_TYPE = "Release"
15
15
 
16
16
  [project]
17
17
  name = "mdpsolver"
18
- version = "0.10.1"
18
+ version = "0.10.3"
19
19
  authors = [
20
20
  { name="Anders Reenberg Andersen", email="andersra@live.dk" },
21
21
  { name="Jesper Fink Andersen", email="jesperfinkandersen@gmail.com" },
22
22
  ]
23
- description = "A fast solver for Markov Decision Processes"
23
+ description = "A fast Python solver for Markov Decision Processes"
24
24
  readme = "README.md"
25
25
  requires-python = ">=3.10"
26
26
  classifiers = [
@@ -24,6 +24,9 @@ class model:
24
24
  def initialize(self):
25
25
  """Create and initialize the solver module object."""
26
26
  self.mdl = solvermodule.Model()
27
+ # A model object contains only one model
28
+ self._modelDefined = False
29
+ self._solved = False
27
30
 
28
31
  def solve(
29
32
  self,
@@ -45,35 +48,63 @@ class model:
45
48
 
46
49
  Args:
47
50
  algorithm (str): Algorithm to use.
48
- tolerance (float): Convergence threshold for the algorithm.
49
- update (str): The value-update method.
51
+ tolerance (float): Convergence threshold for the algorithm (0 < tolerance < 1).
52
+ update (str): The value-update method ('standard', 'gs' or 'sor'; only 'standard' with the average reward criterion).
50
53
  criterion (str): The optimality criterion.
51
54
  parIterLim (int): The partial evaluation limit employed in the modified policy iteration algorithm.
52
55
  SORrelaxation (float): Relaxation parameter for the Successive Over-Relaxation method.
53
- initPolicy (list): A 1D-list defining the initial policy. This option can be used for "warm starting" the optimization procedure.
54
- initValueVector (list): A 1D-list defining the initial value vector. This option can be used for "warm starting" the optimization procedure.
56
+ initPolicy (list): A 1D-list defining the initial policy, one valid action index per state. This option can be used for "warm starting" the optimization procedure.
57
+ initValueVector (list): A 1D-list defining the initial value vector, one value per state. This option can be used for "warm starting" the optimization procedure.
55
58
  verbose (bool): If True, prints solver progress to console.
56
59
  postProcessing (bool): If True, performs post-processing after solving.
57
60
  makeFinalCheck (bool): If True, makes a final check of the value vector. This process checks if the resulting values are reasonable.
58
61
  parallel (bool): If True, enables parallel computation for faster solving. This option is only available for the custom MDP model with standard updates.
59
62
 
63
+ Solving the same model object again starts from the policy and the
64
+ value vector of the previous run, unless initPolicy or initValueVector
65
+ replace them. Create a new model object to start over.
66
+
60
67
  Returns:
61
68
  None
62
69
  """
63
70
 
64
71
  if not (algorithm == "mpi" or algorithm == "pi" or algorithm == "vi"):
65
72
  sys.exit("Error: Algorithm type not recognized. Select either: 'mpi', 'pi', or 'vi'.")
66
- if tolerance <= 0.0:
67
- sys.exit("Error: The tolerance needs to be a positive number.")
73
+ if tolerance <= 0.0 or tolerance >= 1.0:
74
+ sys.exit("Error: The tolerance needs to be between 0.0 and 1.0 (i.e. 0 < tolerance < 1).")
68
75
  if not (update == "standard" or update == "gs" or update == "sor"):
69
76
  sys.exit("Error: Update method not recognized. Select either: 'standard', 'gs', or 'sor'.")
70
77
  if not (criterion == "discounted" or criterion == "average"):
71
78
  sys.exit("Error: Optimality criterion not recognized. Select either: 'discounted' or 'average'.")
79
+ if criterion == "average" and (update == "gs" or update == "sor"):
80
+ sys.exit("Error: Gauss-Seidel and SOR updates are not available with the average reward criterion. Select update='standard'.")
72
81
  if parIterLim <= 0 and algorithm == "mpi":
73
82
  sys.exit("Error: The partial evaluation limit needs to be a positive number.")
74
83
  if (SORrelaxation <= 0.0 or SORrelaxation >= 2.0) and update == "sor":
75
84
  sys.exit("Error: The relaxation parameter needs to be between 0.0 and 2.0 (i.e. 0 < SORrelaxation < 2).")
76
-
85
+ if len(initPolicy) > 0 or len(initValueVector) > 0:
86
+ numberOfStates = self.mdl.getNumberOfStates()
87
+ if numberOfStates >= 0 and len(initPolicy) > 0 and len(initPolicy) != numberOfStates:
88
+ sys.exit("Error: The initial policy needs one action index per state (the model has %d states, initPolicy has %d elements)." % (numberOfStates, len(initPolicy)))
89
+ if numberOfStates >= 0 and len(initValueVector) > 0 and len(initValueVector) != numberOfStates:
90
+ sys.exit("Error: The initial value vector needs one value per state (the model has %d states, initValueVector has %d elements)." % (numberOfStates, len(initValueVector)))
91
+ if numberOfStates >= 0 and len(initPolicy) > 0:
92
+ for stateIndex, action in enumerate(initPolicy):
93
+ numberOfActions = self.mdl.getNumberOfActions(stateIndex=stateIndex)
94
+ if not 0 <= action < numberOfActions:
95
+ sys.exit("Error: The initial policy assigns action %s to state %d, which has %d actions (indices 0 to %d)." % (action, stateIndex, numberOfActions, numberOfActions - 1))
96
+
97
+ # The solver keeps the policy and the value vector of the previous run,
98
+ # unless initPolicy or initValueVector replace them.
99
+
100
+ if self._solved:
101
+ # Value iteration never uses the policy it starts from
102
+ reusesPolicy = len(initPolicy) == 0 and algorithm != "vi"
103
+ reusesValueVector = len(initValueVector) == 0
104
+ if reusesPolicy or reusesValueVector:
105
+ print("Note: This model has been solved before. Create a new model object to run solver with initial values.", flush=True)
106
+ self._solved = True
107
+
77
108
  self.mdl.solve(
78
109
  algorithm=algorithm,
79
110
  tolerance=tolerance,
@@ -98,6 +129,17 @@ class model:
98
129
  """
99
130
  return self.mdl.getRuntime()
100
131
 
132
+ def getIterations(self) -> int:
133
+ """
134
+ Get the number of iterations of the last solver execution.
135
+ An iteration is one policy improvement step, i.e. one update that
136
+ chooses the best action in every state.
137
+
138
+ Returns:
139
+ int: Number of iterations.
140
+ """
141
+ return self.mdl.getIterations()
142
+
101
143
  def printPolicy(self) -> None:
102
144
  """Print the entire policy to the terminal."""
103
145
  self.mdl.printPolicy()
@@ -191,13 +233,21 @@ class model:
191
233
  tranMatColumns (list): Sparse transition probabilities (option 2, part 2). A 3D-list containing the columns of the non-zero transition probabilities.
192
234
  tranMatFromFile (str): Load the transition probabilities from a comma-separated (,) file.
193
235
 
236
+ A model object holds one model, so mdp() can be called only once per
237
+ model object. Create a new model object to define another model.
238
+
194
239
  Returns:
195
240
  None
196
241
  """
197
242
 
243
+ if self._modelDefined:
244
+ sys.exit("Error: A model has already been defined for this model object, and mdp() can only be called once per model object. Create a new model object (e.g. mdl = mdpsolver.model()) to define another model.")
198
245
  if discount <= 0.0 or discount >= 1.0:
199
246
  sys.exit("Error: The discount needs to be in the interval between 0.0 and 1.0 (i.e. 0 < discount < 1).")
200
247
 
248
+ # Set before loading, so that a load that fails halfway cannot be
249
+ # followed by another into the same object
250
+ self._modelDefined = True
201
251
  self.mdl.mdp(
202
252
  discount=discount,
203
253
  rewards=rewards,
@@ -0,0 +1,300 @@
1
+ '''
2
+ PROBLEMS WITH KNOWN SOLUTIONS, AND EXACT SOLUTIONS, FOR THE TESTS IN THIS FOLDER.
3
+
4
+ A problem is given as rewards[s][a] and transitions[s][a] = {next state:
5
+ probability}, from which the tests build the arguments of mdp().
6
+
7
+ The hub-and-spoke problem. In the hub (state 0) one either rests, for a reward
8
+ REST per period, or starts one of three ventures, which succeeds with
9
+ probability 1 - FAIL. Venture k (state k) pays R_k per period and ends with
10
+ probability q_k per period, back in the hub; two of the ventures can also be
11
+ quit, for nothing, back to the hub. All rewards are non-negative. With discount
12
+ factor lambda, the hub is worth m/(1 - lambda), where m is the best of REST and
13
+ lambda(1 - FAIL)R_k/(1 + lambda(q_k - FAIL)), the per-period worth of resting or
14
+ of venture k; a venture is continued if R_k >= lambda(1 - q_k)m, and is then
15
+ worth (R_k + lambda q_k v_hub)/(1 - lambda(1 - q_k)), and is quit otherwise,
16
+ worth lambda v_hub. The constants are chosen so that the best choice in the hub
17
+ moves from resting, to the short venture, to the long one as lambda grows, and
18
+ the poor venture goes from continued to quit. Under the average-reward criterion
19
+ the gain is the best of REST and (1 - FAIL)R_k/(1 + q_k - FAIL) (a renewal
20
+ argument), and with the bias of the hub set to 0, a continued venture has bias
21
+ (R_k - g)/q_k and a quit one -g.
22
+
23
+ The secretary problem (Ferguson, 1989). NCANDIDATES candidates are seen one at
24
+ a time in random order, and each can be selected, ending the search, or passed
25
+ over for good; the aim is to select the best of all. The optimal rule passes
26
+ over the first CUTOFF - 1 candidates and then selects the first one that is
27
+ better than all before it, which wins with probability about 1/e (0.398690 for
28
+ ten candidates). The states are a candidate's position i and whether it is the
29
+ best so far, plus an absorbing state once a candidate is selected or none is
30
+ left. The classical problem is undiscounted and MDPSolver discounts every step,
31
+ but every candidate takes exactly one step, so a reward of (i/n)/lambda^(i - 1)
32
+ for selecting candidate i undoes the discount exactly: the start state is worth
33
+ the classical probability of winning at every lambda, and candidate i's states
34
+ are worth their classical values divided by lambda^(i - 1).
35
+
36
+ The exact solutions of any problem: exactdiscounted() and exactaverage() run
37
+ policy iteration with every policy evaluated exactly, by solving its linear
38
+ equations, so they share no arithmetic with MDPSolver's iterative evaluation.
39
+ checkoptimality() checks a solution against the optimality equation, with a
40
+ margin for the optimal actions, and unichainaperiodic() the condition under
41
+ which value iteration converges under the average-reward criterion (Puterman,
42
+ 1994, Section 8.5).
43
+
44
+ References:
45
+ Ferguson, T. S. (1989). Who solved the secretary problem? Statistical
46
+ Science 4(3), 282-289. https://doi.org/10.1214/ss/1177012493
47
+ Puterman, M. L. (1994). Markov Decision Processes: Discrete Stochastic
48
+ Dynamic Programming. Wiley. https://doi.org/10.1002/9780470316887
49
+ '''
50
+
51
+ import itertools
52
+ import sys
53
+
54
+ import numpy as np
55
+
56
+
57
+ # ------------------------------------------------------------------
58
+ # THE HUB-AND-SPOKE PROBLEM
59
+ # ------------------------------------------------------------------
60
+
61
+ # The reward for resting in the hub, the probability that starting a venture
62
+ # fails, and the ventures as (reward per period, probability of ending per
63
+ # period, whether it can be quit): a short one, a long one and a poor one.
64
+ REST = 2.0
65
+ FAIL = 0.1
66
+ VENTURES = ((6.0, 0.6, False),
67
+ (4.2, 0.05, True),
68
+ (1.5, 0.3, True))
69
+
70
+
71
+ def hubproblem():
72
+ '''The rewards and transitions of the hub-and-spoke problem.'''
73
+ nventures = len(VENTURES)
74
+ rewards = [[REST] + [0.0] * nventures]
75
+ transitions = [[{0: 1.0}] + [{0: FAIL, k: 1.0 - FAIL} for k in range(1, nventures + 1)]]
76
+ for k, (reward, end, quittable) in enumerate(VENTURES, start=1):
77
+ rewards.append([reward] + ([0.0] if quittable else []))
78
+ transitions.append([{0: end, k: 1.0 - end}] + ([{0: 1.0}] if quittable else []))
79
+ return rewards, transitions
80
+
81
+
82
+ def hubdiscounted(discount):
83
+ '''The optimal policy and values of the hub-and-spoke problem under discounting.'''
84
+ worths = [REST] + [discount * (1.0 - FAIL) * reward / (1.0 + discount * (end - FAIL))
85
+ for reward, end, _ in VENTURES]
86
+ best = max(worths)
87
+ hub = best / (1.0 - discount)
88
+ policy, values = [int(np.argmax(worths))], [hub]
89
+ for reward, end, quittable in VENTURES:
90
+ if quittable and reward < discount * (1.0 - end) * best:
91
+ policy.append(1)
92
+ values.append(discount * hub)
93
+ else:
94
+ policy.append(0)
95
+ values.append((reward + discount * end * hub) / (1.0 - discount * (1.0 - end)))
96
+ return policy, np.array(values)
97
+
98
+
99
+ def hubaverage():
100
+ '''The gain-optimal policy, the gain and the bias (0 in the hub) of the hub-and-spoke problem.'''
101
+ gains = [REST] + [(1.0 - FAIL) * reward / (1.0 + end - FAIL) for reward, end, _ in VENTURES]
102
+ gain = max(gains)
103
+ policy, bias = [int(np.argmax(gains))], [0.0]
104
+ for reward, end, quittable in VENTURES:
105
+ if quittable and reward < gain * (1.0 - end):
106
+ policy.append(1)
107
+ bias.append(-gain)
108
+ else:
109
+ policy.append(0)
110
+ bias.append((reward - gain) / end)
111
+ return policy, gain, np.array(bias)
112
+
113
+
114
+ # ------------------------------------------------------------------
115
+ # THE SECRETARY PROBLEM
116
+ # ------------------------------------------------------------------
117
+
118
+ NCANDIDATES = 10
119
+ WINPROBABILITY = 0.398690 # for ten candidates (Ferguson, 1989), to six decimals
120
+
121
+
122
+ def secretaryproblem(discount, compensated=True):
123
+ '''
124
+ The rewards and transitions of the secretary problem. State 2(i - 1) is
125
+ candidate i being the best so far, state 2(i - 1) + 1 it not being so, and
126
+ state 2n the end. A best-so-far candidate can be passed over (action 0) or
127
+ selected (action 1); any other is passed over. Compensated, the reward for
128
+ selecting candidate i undoes the discount of the i - 1 steps to it;
129
+ otherwise it is i/n whatever the discount.
130
+ '''
131
+ n = NCANDIDATES
132
+ end = 2 * n
133
+ rewards, transitions = [], []
134
+ for i in range(1, n + 1):
135
+ onward = {end: 1.0} if i == n else {2 * i: 1.0 / (i + 1), 2 * i + 1: i / (i + 1)}
136
+ rewards.append([0.0, (i / n) / (discount ** (i - 1) if compensated else 1.0)])
137
+ transitions.append([onward, {end: 1.0}])
138
+ rewards.append([0.0])
139
+ transitions.append([dict(onward)])
140
+ rewards.append([0.0])
141
+ transitions.append([{end: 1.0}])
142
+ return rewards, transitions
143
+
144
+
145
+ def secretarysolution(discount):
146
+ '''
147
+ The optimal policy and values of the compensated secretary problem, from
148
+ the classical solution: W(m) = (m/n) sum_{k=m}^{n-1} 1/k is the probability
149
+ of winning by selecting the next best-so-far candidate after the m-th, and
150
+ the cutoff is the first candidate from which selecting a best-so-far one is
151
+ at least as good as going on. Returns the cutoff too.
152
+ '''
153
+ n = NCANDIDATES
154
+
155
+ def W(m):
156
+ return (m / n) * sum(1.0 / k for k in range(m, n))
157
+
158
+ cutoff = min(r for r in range(1, n + 1) if sum(1.0 / k for k in range(r, n)) <= 1.0)
159
+ policy, values = [], []
160
+ for i in range(1, n + 1):
161
+ onward = W(max(i, cutoff - 1))
162
+ scale = discount ** (i - 1)
163
+ policy += [1 if i >= cutoff else 0, 0]
164
+ values += [max(i / n, onward) / scale, onward / scale]
165
+ policy.append(0)
166
+ values.append(0.0)
167
+ return cutoff, policy, np.array(values)
168
+
169
+
170
+ # ------------------------------------------------------------------
171
+ # ANY PROBLEM
172
+ # ------------------------------------------------------------------
173
+
174
+ def densetranmat(transitions):
175
+ '''The transition probabilities as a 3D list with zeros, as tranMatWithZeros.'''
176
+ n = len(transitions)
177
+ return [[[probs.get(j, 0.0) for j in range(n)] for probs in state] for state in transitions]
178
+
179
+
180
+ def elementwisetranmat(transitions):
181
+ '''The non-zero transition probabilities as rows [s, a, j, probability], as tranMatElementwise.'''
182
+ return [[s, a, j, p] for s, state in enumerate(transitions)
183
+ for a, probs in enumerate(state) for j, p in probs.items()]
184
+
185
+
186
+ def matrices(rewards, transitions):
187
+ '''Per state, the rewards of its actions and the rows of their transition probabilities.'''
188
+ n = len(rewards)
189
+ R = [np.array(state, dtype=float) for state in rewards]
190
+ P = []
191
+ for state in transitions:
192
+ rows = np.zeros((len(state), n))
193
+ for a, probs in enumerate(state):
194
+ for j, p in probs.items():
195
+ rows[a, j] = p
196
+ P.append(rows)
197
+ return R, P
198
+
199
+
200
+ def improve(R, P, policy, values, discount):
201
+ '''The greedy policy for the values, keeping an action that ties with the best.'''
202
+ improved = []
203
+ for s in range(len(R)):
204
+ q = R[s] + discount * (P[s] @ values)
205
+ best = int(np.argmax(q))
206
+ tie = q[policy[s]] >= q[best] - 1e-12 * max(1.0, abs(q[best]))
207
+ improved.append(policy[s] if tie else best)
208
+ return improved
209
+
210
+
211
+ def exactdiscounted(rewards, transitions, discount):
212
+ '''The optimal policy and values under discounting, by policy iteration with exact evaluation.'''
213
+ R, P = matrices(rewards, transitions)
214
+ n = len(R)
215
+ policy = [int(np.argmax(r)) for r in R]
216
+ while True:
217
+ Ppolicy = np.array([P[s][policy[s]] for s in range(n)])
218
+ rpolicy = np.array([R[s][policy[s]] for s in range(n)])
219
+ values = np.linalg.solve(np.eye(n) - discount * Ppolicy, rpolicy)
220
+ improved = improve(R, P, policy, values, discount)
221
+ if improved == policy:
222
+ return policy, values
223
+ policy = improved
224
+
225
+
226
+ def exactaverage(rewards, transitions):
227
+ '''
228
+ The gain-optimal policy, the gain and the bias (0 in state 0) of a unichain
229
+ problem, by policy iteration with exact evaluation: h + g = r + P h, h(0) = 0.
230
+ '''
231
+ R, P = matrices(rewards, transitions)
232
+ n = len(R)
233
+ policy = [int(np.argmax(r)) for r in R]
234
+ while True:
235
+ Ppolicy = np.array([P[s][policy[s]] for s in range(n)])
236
+ rpolicy = np.array([R[s][policy[s]] for s in range(n)])
237
+ A = np.zeros((n + 1, n + 1))
238
+ A[:n, :n] = np.eye(n) - Ppolicy
239
+ A[:n, n] = 1.0
240
+ A[n, 0] = 1.0
241
+ solution = np.linalg.solve(A, np.r_[rpolicy, 0.0])
242
+ bias, gain = solution[:n], solution[n]
243
+ improved = improve(R, P, policy, bias, 1.0)
244
+ if improved == policy:
245
+ return policy, gain, bias
246
+ policy = improved
247
+
248
+
249
+ def qvalues(rewards, transitions, values, discount):
250
+ '''r(s, a) + discount * sum_j p(j | s, a) v(j) for every state and action.'''
251
+ R, P = matrices(rewards, transitions)
252
+ return [list(R[s] + discount * (P[s] @ np.asarray(values))) for s in range(len(R))]
253
+
254
+
255
+ def checkoptimality(name, rewards, transitions, policy, values, discount, mingap, gain=0.0):
256
+ '''
257
+ Ends the test unless the values solve the optimality equation
258
+ v + g = max_a (r + discount P v), with the policy as the maximizer and every
259
+ maximum ahead of the runner-up by mingap.
260
+ '''
261
+ q = qvalues(rewards, transitions, values, discount)
262
+ scale = max(1.0, float(np.max(np.abs(values))))
263
+ for s in range(len(rewards)):
264
+ if abs(values[s] + gain - max(q[s])) > 1e-11 * scale:
265
+ sys.exit("Validation test failed at initialization: the known solution of the %s does not solve the optimality equation in state %d." % (name, s))
266
+ if int(np.argmax(q[s])) != policy[s]:
267
+ sys.exit("Validation test failed at initialization: the known policy of the %s does not maximize in state %d." % (name, s))
268
+ if len(q[s]) > 1 and sorted(q[s])[-1] - sorted(q[s])[-2] < mingap:
269
+ sys.exit("Validation test failed at initialization: in the %s the best action of state %d wins by only %.3e, so the comparison of policies would hinge on rounding." % (name, s, sorted(q[s])[-1] - sorted(q[s])[-2]))
270
+
271
+
272
+ def smallestgap(rewards, transitions, values, discount):
273
+ '''The smallest margin by which the best action of a state beats its runner-up.'''
274
+ q = qvalues(rewards, transitions, values, discount)
275
+ gaps = [sorted(qs)[-1] - sorted(qs)[-2] for qs in q if len(qs) > 1]
276
+ return min(gaps) if gaps else np.inf
277
+
278
+
279
+ def unichainaperiodic(rewards, transitions):
280
+ '''The first policy that is not unichain and aperiodic, or None. Enumerates every policy.'''
281
+ n = len(rewards)
282
+ for policy in itertools.product(*[range(len(r)) for r in rewards]):
283
+ step = np.zeros((n, n), dtype=np.int64)
284
+ for s in range(n):
285
+ for j, p in transitions[s][policy[s]].items():
286
+ step[s, j] = p > 0.0
287
+ reach = np.eye(n, dtype=np.int64)
288
+ for _ in range(n):
289
+ reach = np.minimum(reach + reach @ step, 1)
290
+ recurrent = [s for s in range(n) if all(reach[j, s] for j in range(n) if reach[s, j])]
291
+ classes = {frozenset(j for j in recurrent if reach[s, j] and reach[j, s]) for s in recurrent}
292
+ if len(classes) != 1:
293
+ return policy
294
+ members = sorted(next(iter(classes)))
295
+ power = sub = step[np.ix_(members, members)]
296
+ for _ in range(len(members) ** 2): # primitive: some power is positive
297
+ power = np.minimum(power @ sub, 1)
298
+ if not np.all(power > 0):
299
+ return policy
300
+ return None
@@ -0,0 +1,148 @@
1
+ '''
2
+ LOADS MDPSOLVER FOR THE TESTS IN THIS FOLDER.
3
+
4
+ Every test imports it before anything else from mdpsolver:
5
+
6
+ from loadmdpsolver import mdpsolver, finalline, quietly
7
+
8
+ The tests run the Python code of mdpsolver in Python/src, so that they can be
9
+ run from a clone without installing anything. The compiled solvermodule does
10
+ not have to be copied there; it is loaded from the first of these places that
11
+ has one:
12
+ 1. Python/src/mdpsolver, if it has been copied there;
13
+ 2. CPP_Source_Code/build, or CPP_Source_Code/build/Release on Windows,
14
+ where the CMake build of the wiki's Contribution guidelines puts it,
15
+ unless it is older than one of the C++ sources (which is when CMake
16
+ would rebuild it);
17
+ 3. the installed mdpsolver package (pip install mdpsolver, or
18
+ pip install ./Python), whose C++ may be older than the working tree's.
19
+ Only modules this Python can load are considered, so a build for another Python
20
+ version or platform in a shared folder is passed over. The module must have
21
+ every method that the Python code calls on it, or it is older than that code.
22
+ When the module is not the one in Python/src, the final line of a test says
23
+ where it came from. Copied away from the repository, as for a test of a built
24
+ wheel, the tests find no Python/src and test the installed package as a whole.
25
+
26
+ Also here: finalline(), the line a test prints when it has passed, and
27
+ quietly(), which runs a function with everything the process prints captured,
28
+ the output of the compiled module included.
29
+ '''
30
+
31
+ import ctypes
32
+ import glob
33
+ import importlib.machinery
34
+ import importlib.metadata
35
+ import importlib.util
36
+ import os
37
+ import re
38
+ import sys
39
+ import tempfile
40
+
41
+ TESTDIR = os.path.dirname(os.path.abspath(__file__))
42
+ SRCDIR = os.path.normpath(os.path.join(TESTDIR, "..", "src"))
43
+ CPPDIR = os.path.normpath(os.path.join(TESTDIR, "..", "..", "CPP_Source_Code"))
44
+ BUILDDIRS = (os.path.join(CPPDIR, "build"), os.path.join(CPPDIR, "build", "Release"))
45
+
46
+
47
+ def compiledmodules(directory):
48
+ '''The files in a directory that this Python can load as solvermodule.'''
49
+ paths = [os.path.join(directory, "solvermodule" + suffix)
50
+ for suffix in importlib.machinery.EXTENSION_SUFFIXES]
51
+ return [path for path in paths if os.path.isfile(path)]
52
+
53
+
54
+ SOLVERMODULENOTE = None # said in the final line when the module is not in Python/src
55
+
56
+ if not os.path.isdir(os.path.join(SRCDIR, "mdpsolver")):
57
+ SOLVERMODULENOTE = "Python/src was not found next to the test, so the installed mdpsolver was tested as a whole."
58
+
59
+ elif not compiledmodules(os.path.join(SRCDIR, "mdpsolver")):
60
+ sources = glob.glob(os.path.join(CPPDIR, "*.cpp")) + glob.glob(os.path.join(CPPDIR, "*.h"))
61
+ newestsource = max(map(os.path.getmtime, sources), default=0.0)
62
+ builds = [path for directory in BUILDDIRS for path in compiledmodules(directory)]
63
+ currentbuilds = [path for path in builds if os.path.getmtime(path) >= newestsource]
64
+
65
+ if currentbuilds:
66
+ spec = importlib.util.spec_from_file_location("mdpsolver.solvermodule", currentbuilds[0])
67
+ solvermodule = importlib.util.module_from_spec(spec)
68
+ spec.loader.exec_module(solvermodule)
69
+ SOLVERMODULENOTE = "The compiled solvermodule was loaded from %s." % currentbuilds[0]
70
+
71
+ else:
72
+ # The installed package is imported as a whole, before Python/src is on
73
+ # the path, so that whatever it does to find the libraries its module
74
+ # links to is done. Everything but its compiled module is then forgotten.
75
+ try:
76
+ import mdpsolver
77
+ except ImportError:
78
+ sys.exit("Validation test failed at initialization: there is no compiled solvermodule for this Python in Python/src/mdpsolver or in %s%s, and mdpsolver is not installed. Build it as described in the wiki's Contribution guidelines." % (BUILDDIRS[0], " (only one older than the C++ sources)" if builds else ""))
79
+ solvermodule = mdpsolver.solvermodule
80
+ for name in [name for name in sys.modules if name.split(".")[0] == "mdpsolver"]:
81
+ del sys.modules[name]
82
+
83
+ try:
84
+ version = importlib.metadata.version("mdpsolver")
85
+ except importlib.metadata.PackageNotFoundError:
86
+ version = "(version unknown)"
87
+ SOLVERMODULENOTE = "The compiled solvermodule was that of the installed mdpsolver %s, %s, whose C++ may be older than that of the working tree." % (version, solvermodule.__file__)
88
+ if builds:
89
+ SOLVERMODULENOTE += " The one in %s is older than the C++ sources and was passed over." % os.path.dirname(builds[0])
90
+
91
+ # the Python code in Python/src imports the module as mdpsolver.solvermodule
92
+ sys.modules["mdpsolver.solvermodule"] = solvermodule
93
+
94
+ sys.path.insert(0, SRCDIR)
95
+
96
+ import mdpsolver
97
+
98
+ if os.path.isdir(os.path.join(SRCDIR, "mdpsolver")) and not os.path.samefile(
99
+ os.path.dirname(os.path.abspath(mdpsolver.__file__)), os.path.join(SRCDIR, "mdpsolver")):
100
+ sys.exit("Validation test failed at initialization: mdpsolver was loaded from %s rather than from Python/src." % os.path.dirname(mdpsolver.__file__))
101
+
102
+ # the compiled module must have every method that the Python code calls on it
103
+ with open(sys.modules["mdpsolver.model"].__file__) as file:
104
+ called = sorted(set(re.findall(r"self\.mdl\.(\w+)", file.read())))
105
+ missing = [name for name in called if not hasattr(sys.modules["mdpsolver.solvermodule"].Model, name)]
106
+ if missing:
107
+ sys.exit("Validation test failed at initialization: the compiled solvermodule has no %s, which the Python code of mdpsolver calls, so it is older than that code. Rebuild it. (%s)" % (", ".join(missing), SOLVERMODULENOTE or "It was loaded from Python/src/mdpsolver."))
108
+
109
+
110
+ def finalline(testfile):
111
+ '''The line a test prints when it has passed: its file name, and where the compiled module came from, if not from Python/src.'''
112
+ name = os.path.splitext(os.path.basename(testfile))[0]
113
+ if SOLVERMODULENOTE is None:
114
+ return "%s: All tests passed." % name
115
+ return "%s: All tests passed. (%s)" % (name, SOLVERMODULENOTE)
116
+
117
+
118
+ try:
119
+ LIBC = ctypes.CDLL(None) # the C library, to flush the output of the compiled module
120
+ except (OSError, TypeError):
121
+ LIBC = None
122
+
123
+
124
+ def flushall():
125
+ sys.stdout.flush()
126
+ sys.stderr.flush()
127
+ if LIBC is not None:
128
+ LIBC.fflush(None)
129
+
130
+
131
+ def quietly(function):
132
+ '''Runs a function with everything the process prints captured; returns its result and the output.'''
133
+ flushall()
134
+ with tempfile.TemporaryFile() as capture:
135
+ saved = (os.dup(1), os.dup(2))
136
+ os.dup2(capture.fileno(), 1)
137
+ os.dup2(capture.fileno(), 2)
138
+ try:
139
+ result = function()
140
+ finally:
141
+ flushall()
142
+ os.dup2(saved[0], 1)
143
+ os.dup2(saved[1], 2)
144
+ os.close(saved[0])
145
+ os.close(saved[1])
146
+ capture.seek(0)
147
+ output = capture.read().decode(errors="replace")
148
+ return result, output