fasttps 0.2.0__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.
@@ -0,0 +1,287 @@
1
+ # This file is automatically @generated by Cargo.
2
+ # It is not intended for manual editing.
3
+ version = 3
4
+
5
+ [[package]]
6
+ name = "autocfg"
7
+ version = "1.5.0"
8
+ source = "registry+https://github.com/rust-lang/crates.io-index"
9
+ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
10
+
11
+ [[package]]
12
+ name = "cfg-if"
13
+ version = "1.0.4"
14
+ source = "registry+https://github.com/rust-lang/crates.io-index"
15
+ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
16
+
17
+ [[package]]
18
+ name = "fasttps"
19
+ version = "0.2.0"
20
+ dependencies = [
21
+ "fixedbitset",
22
+ "ndarray",
23
+ "numpy",
24
+ "ordered-float",
25
+ "pyo3",
26
+ ]
27
+
28
+ [[package]]
29
+ name = "fixedbitset"
30
+ version = "0.4.2"
31
+ source = "registry+https://github.com/rust-lang/crates.io-index"
32
+ checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
33
+
34
+ [[package]]
35
+ name = "heck"
36
+ version = "0.5.0"
37
+ source = "registry+https://github.com/rust-lang/crates.io-index"
38
+ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
39
+
40
+ [[package]]
41
+ name = "indoc"
42
+ version = "2.0.7"
43
+ source = "registry+https://github.com/rust-lang/crates.io-index"
44
+ checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
45
+ dependencies = [
46
+ "rustversion",
47
+ ]
48
+
49
+ [[package]]
50
+ name = "libc"
51
+ version = "0.2.182"
52
+ source = "registry+https://github.com/rust-lang/crates.io-index"
53
+ checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
54
+
55
+ [[package]]
56
+ name = "matrixmultiply"
57
+ version = "0.3.10"
58
+ source = "registry+https://github.com/rust-lang/crates.io-index"
59
+ checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08"
60
+ dependencies = [
61
+ "autocfg",
62
+ "rawpointer",
63
+ ]
64
+
65
+ [[package]]
66
+ name = "memoffset"
67
+ version = "0.9.1"
68
+ source = "registry+https://github.com/rust-lang/crates.io-index"
69
+ checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
70
+ dependencies = [
71
+ "autocfg",
72
+ ]
73
+
74
+ [[package]]
75
+ name = "ndarray"
76
+ version = "0.16.1"
77
+ source = "registry+https://github.com/rust-lang/crates.io-index"
78
+ checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841"
79
+ dependencies = [
80
+ "matrixmultiply",
81
+ "num-complex",
82
+ "num-integer",
83
+ "num-traits",
84
+ "portable-atomic",
85
+ "portable-atomic-util",
86
+ "rawpointer",
87
+ ]
88
+
89
+ [[package]]
90
+ name = "num-complex"
91
+ version = "0.4.6"
92
+ source = "registry+https://github.com/rust-lang/crates.io-index"
93
+ checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
94
+ dependencies = [
95
+ "num-traits",
96
+ ]
97
+
98
+ [[package]]
99
+ name = "num-integer"
100
+ version = "0.1.46"
101
+ source = "registry+https://github.com/rust-lang/crates.io-index"
102
+ checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
103
+ dependencies = [
104
+ "num-traits",
105
+ ]
106
+
107
+ [[package]]
108
+ name = "num-traits"
109
+ version = "0.2.19"
110
+ source = "registry+https://github.com/rust-lang/crates.io-index"
111
+ checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
112
+ dependencies = [
113
+ "autocfg",
114
+ ]
115
+
116
+ [[package]]
117
+ name = "numpy"
118
+ version = "0.22.1"
119
+ source = "registry+https://github.com/rust-lang/crates.io-index"
120
+ checksum = "edb929bc0da91a4d85ed6c0a84deaa53d411abfb387fc271124f91bf6b89f14e"
121
+ dependencies = [
122
+ "libc",
123
+ "ndarray",
124
+ "num-complex",
125
+ "num-integer",
126
+ "num-traits",
127
+ "pyo3",
128
+ "rustc-hash",
129
+ ]
130
+
131
+ [[package]]
132
+ name = "once_cell"
133
+ version = "1.21.3"
134
+ source = "registry+https://github.com/rust-lang/crates.io-index"
135
+ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
136
+
137
+ [[package]]
138
+ name = "ordered-float"
139
+ version = "3.9.2"
140
+ source = "registry+https://github.com/rust-lang/crates.io-index"
141
+ checksum = "f1e1c390732d15f1d48471625cd92d154e66db2c56645e29a9cd26f4699f72dc"
142
+ dependencies = [
143
+ "num-traits",
144
+ ]
145
+
146
+ [[package]]
147
+ name = "portable-atomic"
148
+ version = "1.13.1"
149
+ source = "registry+https://github.com/rust-lang/crates.io-index"
150
+ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
151
+
152
+ [[package]]
153
+ name = "portable-atomic-util"
154
+ version = "0.2.5"
155
+ source = "registry+https://github.com/rust-lang/crates.io-index"
156
+ checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5"
157
+ dependencies = [
158
+ "portable-atomic",
159
+ ]
160
+
161
+ [[package]]
162
+ name = "proc-macro2"
163
+ version = "1.0.106"
164
+ source = "registry+https://github.com/rust-lang/crates.io-index"
165
+ checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
166
+ dependencies = [
167
+ "unicode-ident",
168
+ ]
169
+
170
+ [[package]]
171
+ name = "pyo3"
172
+ version = "0.22.6"
173
+ source = "registry+https://github.com/rust-lang/crates.io-index"
174
+ checksum = "f402062616ab18202ae8319da13fa4279883a2b8a9d9f83f20dbade813ce1884"
175
+ dependencies = [
176
+ "cfg-if",
177
+ "indoc",
178
+ "libc",
179
+ "memoffset",
180
+ "once_cell",
181
+ "portable-atomic",
182
+ "pyo3-build-config",
183
+ "pyo3-ffi",
184
+ "pyo3-macros",
185
+ "unindent",
186
+ ]
187
+
188
+ [[package]]
189
+ name = "pyo3-build-config"
190
+ version = "0.22.6"
191
+ source = "registry+https://github.com/rust-lang/crates.io-index"
192
+ checksum = "b14b5775b5ff446dd1056212d778012cbe8a0fbffd368029fd9e25b514479c38"
193
+ dependencies = [
194
+ "once_cell",
195
+ "target-lexicon",
196
+ ]
197
+
198
+ [[package]]
199
+ name = "pyo3-ffi"
200
+ version = "0.22.6"
201
+ source = "registry+https://github.com/rust-lang/crates.io-index"
202
+ checksum = "9ab5bcf04a2cdcbb50c7d6105de943f543f9ed92af55818fd17b660390fc8636"
203
+ dependencies = [
204
+ "libc",
205
+ "pyo3-build-config",
206
+ ]
207
+
208
+ [[package]]
209
+ name = "pyo3-macros"
210
+ version = "0.22.6"
211
+ source = "registry+https://github.com/rust-lang/crates.io-index"
212
+ checksum = "0fd24d897903a9e6d80b968368a34e1525aeb719d568dba8b3d4bfa5dc67d453"
213
+ dependencies = [
214
+ "proc-macro2",
215
+ "pyo3-macros-backend",
216
+ "quote",
217
+ "syn",
218
+ ]
219
+
220
+ [[package]]
221
+ name = "pyo3-macros-backend"
222
+ version = "0.22.6"
223
+ source = "registry+https://github.com/rust-lang/crates.io-index"
224
+ checksum = "36c011a03ba1e50152b4b394b479826cad97e7a21eb52df179cd91ac411cbfbe"
225
+ dependencies = [
226
+ "heck",
227
+ "proc-macro2",
228
+ "pyo3-build-config",
229
+ "quote",
230
+ "syn",
231
+ ]
232
+
233
+ [[package]]
234
+ name = "quote"
235
+ version = "1.0.44"
236
+ source = "registry+https://github.com/rust-lang/crates.io-index"
237
+ checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
238
+ dependencies = [
239
+ "proc-macro2",
240
+ ]
241
+
242
+ [[package]]
243
+ name = "rawpointer"
244
+ version = "0.2.1"
245
+ source = "registry+https://github.com/rust-lang/crates.io-index"
246
+ checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
247
+
248
+ [[package]]
249
+ name = "rustc-hash"
250
+ version = "1.1.0"
251
+ source = "registry+https://github.com/rust-lang/crates.io-index"
252
+ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
253
+
254
+ [[package]]
255
+ name = "rustversion"
256
+ version = "1.0.22"
257
+ source = "registry+https://github.com/rust-lang/crates.io-index"
258
+ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
259
+
260
+ [[package]]
261
+ name = "syn"
262
+ version = "2.0.117"
263
+ source = "registry+https://github.com/rust-lang/crates.io-index"
264
+ checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
265
+ dependencies = [
266
+ "proc-macro2",
267
+ "quote",
268
+ "unicode-ident",
269
+ ]
270
+
271
+ [[package]]
272
+ name = "target-lexicon"
273
+ version = "0.12.16"
274
+ source = "registry+https://github.com/rust-lang/crates.io-index"
275
+ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
276
+
277
+ [[package]]
278
+ name = "unicode-ident"
279
+ version = "1.0.24"
280
+ source = "registry+https://github.com/rust-lang/crates.io-index"
281
+ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
282
+
283
+ [[package]]
284
+ name = "unindent"
285
+ version = "0.2.4"
286
+ source = "registry+https://github.com/rust-lang/crates.io-index"
287
+ checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
@@ -0,0 +1,16 @@
1
+ [package]
2
+ name = "fasttps"
3
+ version = "0.2.0"
4
+ edition = "2021"
5
+ readme = "README.md"
6
+
7
+ [lib]
8
+ name = "fasttps"
9
+ crate-type = ["cdylib"]
10
+
11
+ [dependencies]
12
+ pyo3 = { version = "0.22", features = ["extension-module"] }
13
+ numpy = "0.22"
14
+ ndarray = "0.16"
15
+ fixedbitset = "0.4"
16
+ ordered-float = "3"
fasttps-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: fasttps
3
+ Version: 0.2.0
4
+ Summary: Fast TPS internals for the leaguescheduler Python package
5
+ Requires-Python: >=3.13, <3.14
6
+ Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
7
+
8
+ # fasttps
9
+
10
+ Internal Rust extension for [leaguescheduler](https://pypi.org/project/leaguescheduler).
11
+ Not intended for direct use (but be my guest), rather install `leaguescheduler`.
12
+
@@ -0,0 +1,4 @@
1
+ # fasttps
2
+
3
+ Internal Rust extension for [leaguescheduler](https://pypi.org/project/leaguescheduler).
4
+ Not intended for direct use (but be my guest), rather install `leaguescheduler`.
@@ -0,0 +1,13 @@
1
+ [project]
2
+ name = "fasttps"
3
+ version = "0.2.0"
4
+ description = "Fast TPS internals for the leaguescheduler Python package"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13,<3.14"
7
+
8
+ [build-system]
9
+ requires = ["maturin>=1.0,<2.0"]
10
+ build-backend = "maturin"
11
+
12
+ [tool.maturin]
13
+ features = ["pyo3/extension-module"]
@@ -0,0 +1,503 @@
1
+ use std::collections::{HashMap, HashSet};
2
+
3
+ use fixedbitset::FixedBitSet;
4
+ use ndarray::{Array2, ArrayView2};
5
+ use numpy::{PyArray2, PyArrayMethods, PyReadonlyArray2};
6
+ use ordered_float::OrderedFloat;
7
+ use pyo3::prelude::*;
8
+ use pyo3::types::PyDict;
9
+
10
+ // ─── Constants ───────────────────────────────────────────────────────────────
11
+ const DISALLOWED_NBR: f64 = 9_999_999.0; // positive if minimization, negative if maximization
12
+ const DISALLOWED_REPLACE: f64 = 1e15;
13
+ const LARGE_NBR: f64 = 9999.0; // must match Python constants.py
14
+
15
+ // ─── Hungarian algorithm (Kuhn-Munkres) ──────────────────────────────────────
16
+ // Courtesy to https://github.com/neka-nat/fastmunk for main implementation
17
+ fn kuhn_munkres(weights: ArrayView2<f64>, maximize: bool) -> Vec<(usize, usize)> {
18
+ let nx = weights.nrows();
19
+ let ny = weights.ncols();
20
+
21
+ assert!(
22
+ nx <= ny,
23
+ "number of rows must not be larger than number of columns"
24
+ );
25
+ let copied_weights = if !maximize {
26
+ weights.map(|x| -x).view().to_owned()
27
+ } else {
28
+ weights.to_owned()
29
+ };
30
+ let mut xy: Vec<Option<usize>> = vec![None; nx];
31
+ let mut yx: Vec<Option<usize>> = vec![None; ny];
32
+ let mut lx: Vec<OrderedFloat<f64>> = (0..nx)
33
+ .map(|row| {
34
+ (0..ny)
35
+ .map(|col| OrderedFloat(copied_weights[(row, col)]))
36
+ .max()
37
+ .unwrap()
38
+ })
39
+ .collect::<Vec<_>>();
40
+ let mut ly: Vec<f64> = vec![0.0; ny];
41
+ let mut s = FixedBitSet::with_capacity(nx);
42
+ let mut alternating = Vec::with_capacity(ny);
43
+ let mut slack = vec![0.0; ny];
44
+ let mut slackx = Vec::with_capacity(ny);
45
+ for root in 0..nx {
46
+ alternating.clear();
47
+ alternating.resize(ny, None);
48
+ let mut y = {
49
+ s.clear();
50
+ s.insert(root);
51
+ for y in 0..ny {
52
+ slack[y] = lx[root].0 + ly[y] - copied_weights[(root, y)];
53
+ }
54
+ slackx.clear();
55
+ slackx.resize(ny, root);
56
+ Some(loop {
57
+ let mut delta = f64::INFINITY;
58
+ let mut x = 0;
59
+ let mut y = 0;
60
+ for yy in 0..ny {
61
+ if alternating[yy].is_none() && slack[yy] < delta {
62
+ delta = slack[yy];
63
+ x = slackx[yy];
64
+ y = yy;
65
+ }
66
+ }
67
+ if delta > 0.0 {
68
+ for x in s.ones() {
69
+ lx[x] = lx[x] - delta;
70
+ }
71
+ for y in 0..ny {
72
+ if alternating[y].is_some() {
73
+ ly[y] = ly[y] + delta;
74
+ } else {
75
+ slack[y] = slack[y] - delta;
76
+ }
77
+ }
78
+ }
79
+ alternating[y] = Some(x);
80
+ if yx[y].is_none() {
81
+ break y;
82
+ }
83
+ let x = yx[y].unwrap();
84
+ s.insert(x);
85
+ for y in 0..ny {
86
+ if alternating[y].is_none() {
87
+ let alternate_slack = lx[x] + ly[y] - copied_weights[(x, y)];
88
+ if slack[y] > alternate_slack.0 {
89
+ slack[y] = alternate_slack.0;
90
+ slackx[y] = x;
91
+ }
92
+ }
93
+ }
94
+ })
95
+ };
96
+ while y.is_some() {
97
+ let x = alternating[y.unwrap()].unwrap();
98
+ let prec = xy[x];
99
+ yx[y.unwrap()] = Some(x);
100
+ xy[x] = y;
101
+ y = prec;
102
+ }
103
+ }
104
+ xy.into_iter()
105
+ .enumerate()
106
+ .map(|(i, v)| (i, v.unwrap()))
107
+ .collect::<Vec<_>>()
108
+ }
109
+
110
+ // ─── Internal helpers ────────────────────────────────────────────────────────
111
+
112
+ /// Equivalent of Python legacy _get_team_array(): concatenate X[idx,:] and X[:,idx], filter out LARGE_NBR.
113
+ #[inline]
114
+ fn get_team_array(x: &ArrayView2<f64>, idx: usize) -> Vec<f64> {
115
+ let n = x.nrows();
116
+ let mut arr = Vec::with_capacity(2 * n);
117
+ for j in 0..x.ncols() {
118
+ let v = x[(idx, j)];
119
+ if v != LARGE_NBR {
120
+ arr.push(v);
121
+ }
122
+ }
123
+ for i in 0..n {
124
+ let v = x[(i, idx)];
125
+ if v != LARGE_NBR {
126
+ arr.push(v);
127
+ }
128
+ }
129
+ arr
130
+ }
131
+
132
+ /// Minimum positive value in a slice of (value - h) differences.
133
+ /// Returns None if no positive value exists.
134
+ #[inline]
135
+ fn min_positive_delta(games: &[f64], h: f64, forward: bool) -> Option<f64> {
136
+ let mut best = f64::INFINITY;
137
+ for &g in games {
138
+ let d = if forward { g - h } else { h - g };
139
+ if d > 0.0 && d < best {
140
+ best = d;
141
+ }
142
+ }
143
+ if best == f64::INFINITY {
144
+ None
145
+ } else {
146
+ Some(best)
147
+ }
148
+ }
149
+
150
+ /// Build cost matrix for a given team. This is the #1 bottleneck.
151
+ fn create_cost_matrix_inner(
152
+ x: &ArrayView2<f64>,
153
+ team_idx: usize,
154
+ set_home: &[f64],
155
+ opponents: &[usize],
156
+ sets_forbidden: &HashMap<usize, HashSet<i64>>,
157
+ m: f64,
158
+ r_max: f64,
159
+ penalties: &[i64],
160
+ max_penalty_key: usize,
161
+ ) -> Array2<f64> {
162
+ let n_home = set_home.len();
163
+ let n_oppo = opponents.len();
164
+ let mut am_cost = Array2::<f64>::zeros((n_home, n_oppo));
165
+
166
+ // precompute games for the home team
167
+ let games_team = get_team_array(x, team_idx);
168
+
169
+ // C2 - home date availability (home_dates = set_home)
170
+
171
+ // C4 (team part) - precompute which home dates the team already plays on
172
+ // C5 (team part) - precompute whether any game is within r_max of each home date
173
+ let mut team_plays_mask = vec![false; n_home];
174
+ let mut games_in_r_max_team = vec![false; n_home];
175
+
176
+ for (i, &h) in set_home.iter().enumerate() {
177
+ for &g in &games_team {
178
+ // C4 - team already plays game
179
+ if g == h {
180
+ team_plays_mask[i] = true;
181
+ }
182
+ // C5 - distance < r_max means too close
183
+ let d = (g - h).abs() + 1.0;
184
+ if d < r_max {
185
+ games_in_r_max_team[i] = true;
186
+ }
187
+ }
188
+ }
189
+
190
+ for (j, &oppo_idx) in opponents.iter().enumerate() {
191
+ let games_oppo = get_team_array(x, oppo_idx);
192
+
193
+ // get forbidden set for this opponent (empty if not present)
194
+ let forbidden_set = sets_forbidden.get(&oppo_idx);
195
+
196
+ // C6 - reciprocal game value (game j->i)
197
+ let reciprocal_val = x[(oppo_idx, team_idx)];
198
+
199
+ for (i, &h) in set_home.iter().enumerate() {
200
+ let h_i64 = h as i64;
201
+
202
+ // C3 - forbidden game set
203
+ let forbidden = match forbidden_set {
204
+ Some(fs) => fs.contains(&h_i64),
205
+ None => false,
206
+ };
207
+
208
+ // C4 - team already plays game (precomputed)
209
+ let team_plays = team_plays_mask[i];
210
+
211
+ // C4 - opponent already plays game
212
+ let mut oppo_plays = false;
213
+ for &g in &games_oppo {
214
+ if g == h {
215
+ oppo_plays = true;
216
+ break;
217
+ }
218
+ }
219
+
220
+ // C5 - max. 2 games for 'r_max' slots
221
+ let mut games_in_r_max_oppo = false;
222
+ for &g in &games_oppo {
223
+ let d = (g - h).abs() + 1.0;
224
+ if d < r_max {
225
+ games_in_r_max_oppo = true;
226
+ break;
227
+ }
228
+ }
229
+ let r_max_violated = games_in_r_max_team[i] || games_in_r_max_oppo;
230
+
231
+ // C6 - game i-j is within m days of game j-i
232
+ let reciprocal_too_close = (h - reciprocal_val).abs() < m;
233
+
234
+ // set disallowed cost for all disallowed slots
235
+ let disallowed =
236
+ forbidden || team_plays || oppo_plays || reciprocal_too_close || r_max_violated;
237
+
238
+ if disallowed {
239
+ am_cost[(i, j)] = DISALLOWED_NBR;
240
+ } else {
241
+ // process penalty calculations for allowed slots
242
+ let mut total_penalty: i64 = 0;
243
+
244
+ // forward-looking for team
245
+ if let Some(d) = min_positive_delta(&games_team, h, true) {
246
+ let key = d as usize;
247
+ if key <= max_penalty_key {
248
+ total_penalty += penalties[key];
249
+ }
250
+ }
251
+ // backward-looking for team
252
+ if let Some(d) = min_positive_delta(&games_team, h, false) {
253
+ let key = d as usize;
254
+ if key <= max_penalty_key {
255
+ total_penalty += penalties[key];
256
+ }
257
+ }
258
+ // forward-looking for opponent
259
+ if let Some(d) = min_positive_delta(&games_oppo, h, true) {
260
+ let key = d as usize;
261
+ if key <= max_penalty_key {
262
+ total_penalty += penalties[key];
263
+ }
264
+ }
265
+ // backward-looking for opponent
266
+ if let Some(d) = min_positive_delta(&games_oppo, h, false) {
267
+ let key = d as usize;
268
+ if key <= max_penalty_key {
269
+ total_penalty += penalties[key];
270
+ }
271
+ }
272
+
273
+ am_cost[(i, j)] = total_penalty as f64;
274
+ }
275
+ }
276
+ }
277
+
278
+ am_cost
279
+ }
280
+
281
+ /// Full solve: cost matrix → adjacency matrix → Munkres → process indexes → (pick, total_cost).
282
+ fn solve_inner(
283
+ x: &ArrayView2<f64>,
284
+ team_idx: usize,
285
+ set_home: &[f64],
286
+ opponents: &[usize],
287
+ sets_forbidden: &HashMap<usize, HashSet<i64>>,
288
+ m: f64,
289
+ p: f64,
290
+ r_max: f64,
291
+ penalties: &[i64],
292
+ max_penalty_key: usize,
293
+ ) -> (Vec<f64>, f64) {
294
+ let n_home = set_home.len();
295
+ let n_oppo = opponents.len();
296
+ let dim = n_home + n_oppo;
297
+
298
+ // construct cost matrix
299
+ let am_cost = create_cost_matrix_inner(
300
+ x,
301
+ team_idx,
302
+ set_home,
303
+ opponents,
304
+ sets_forbidden,
305
+ m,
306
+ r_max,
307
+ penalties,
308
+ max_penalty_key,
309
+ );
310
+
311
+ // construct full adjacency matrix:
312
+ // [ am_cost | zeros ]
313
+ // [ am_bott | zeros ]
314
+ // where am_bott is n_oppo x n_oppo filled with p
315
+ // and zeros is dim x (dim - n_oppo)
316
+ let mut am = Array2::<f64>::zeros((dim, dim));
317
+
318
+ // fill cost block (top-left: n_home x n_oppo)
319
+ for i in 0..n_home {
320
+ for j in 0..n_oppo {
321
+ let v = am_cost[(i, j)];
322
+ // replace disallowed values with a large number
323
+ am[(i, j)] = if v == DISALLOWED_NBR {
324
+ DISALLOWED_REPLACE
325
+ } else {
326
+ v
327
+ };
328
+ }
329
+ }
330
+
331
+ // fill bottom block (n_oppo x n_oppo) with p
332
+ for i in 0..n_oppo {
333
+ for j in 0..n_oppo {
334
+ am[(n_home + i, j)] = p;
335
+ }
336
+ }
337
+ // right block stays zeros (already initialized)
338
+
339
+ // run Hungarian algorithm (minimize)
340
+ let indexes = kuhn_munkres(am.view(), false);
341
+
342
+ // compute total cost
343
+ let total_cost: f64 = indexes.iter().map(|&(r, c)| am[(r, c)]).sum();
344
+
345
+ // process optimal indexes: sort by opponent, map to home slot or NaN
346
+ let mut indexes_inv: Vec<(usize, f64)> = Vec::with_capacity(n_oppo);
347
+ for &(k, v) in &indexes {
348
+ if v < n_oppo {
349
+ let slot = if k < n_home { set_home[k] } else { f64::NAN };
350
+ indexes_inv.push((opponents[v], slot));
351
+ }
352
+ }
353
+ indexes_inv.sort_by_key(|&(opp, _)| opp);
354
+
355
+ let pick: Vec<f64> = indexes_inv.iter().map(|&(_, slot)| slot).collect();
356
+
357
+ (pick, total_cost)
358
+ }
359
+
360
+ // ─── PyO3 class ──────────────────────────────────────────────────────────────
361
+
362
+ #[pyclass(module = "fasttps")]
363
+ struct FastTPS {
364
+ sets_home: HashMap<usize, Vec<f64>>,
365
+ sets_forbidden: HashMap<usize, HashSet<i64>>,
366
+ m: f64,
367
+ p: f64,
368
+ r_max: f64,
369
+ /// Flat penalty lookup: penalties_vec[d] = penalty for distance d.
370
+ penalties_vec: Vec<i64>,
371
+ max_penalty_key: usize,
372
+ }
373
+
374
+ #[pymethods]
375
+ impl FastTPS {
376
+ #[new]
377
+ #[pyo3(signature = (sets_home, sets_forbidden, m, p, r_max, penalties))]
378
+ fn new(
379
+ sets_home: &Bound<'_, PyDict>,
380
+ sets_forbidden: &Bound<'_, PyDict>,
381
+ m: i64,
382
+ p: i64,
383
+ r_max: i64,
384
+ penalties: &Bound<'_, PyDict>,
385
+ ) -> PyResult<Self> {
386
+ // parse sets_home: dict[int, list/set of float/int]
387
+ let mut sh: HashMap<usize, Vec<f64>> = HashMap::new();
388
+ for (k, v) in sets_home.iter() {
389
+ let key: usize = k.extract()?;
390
+ let vals: Vec<f64> = v.extract()?;
391
+ sh.insert(key, vals);
392
+ }
393
+
394
+ // parse sets_forbidden: dict[int, list/set of float/int] → HashSet<i64>
395
+ let mut sf: HashMap<usize, HashSet<i64>> = HashMap::new();
396
+ for (k, v) in sets_forbidden.iter() {
397
+ let key: usize = k.extract()?;
398
+ let vals: Vec<i64> = v.extract()?;
399
+ sf.insert(key, vals.into_iter().collect());
400
+ }
401
+
402
+ // parse penalties dict to flat Vec
403
+ let mut max_key: usize = 0;
404
+ let mut pen_map: HashMap<usize, i64> = HashMap::new();
405
+ for (k, v) in penalties.iter() {
406
+ let key: usize = k.extract()?;
407
+ let val: i64 = v.extract()?;
408
+ pen_map.insert(key, val);
409
+ if key > max_key {
410
+ max_key = key;
411
+ }
412
+ }
413
+ let mut penalties_vec = vec![0i64; max_key + 1];
414
+ for (k, v) in &pen_map {
415
+ penalties_vec[*k] = *v;
416
+ }
417
+
418
+ Ok(FastTPS {
419
+ sets_home: sh,
420
+ sets_forbidden: sf,
421
+ m: m as f64,
422
+ p: p as f64,
423
+ r_max: r_max.max(2) as f64,
424
+ penalties_vec,
425
+ max_penalty_key: max_key,
426
+ })
427
+ }
428
+
429
+ /// Solve transportation problem for given home team.
430
+ /// Modifies X in-place and returns total_cost.
431
+ fn solve<'py>(
432
+ &self,
433
+ py: Python<'py>,
434
+ x_py: &Bound<'py, PyArray2<f64>>,
435
+ team_idx: usize,
436
+ ) -> PyResult<f64> {
437
+ let x_read = x_py.readonly();
438
+ let x_view = x_read.as_array();
439
+ let n = x_view.nrows();
440
+
441
+ let set_home = self
442
+ .sets_home
443
+ .get(&team_idx)
444
+ .expect("team_idx not in sets_home");
445
+ let opponents: Vec<usize> = (0..n).filter(|&t| t != team_idx).collect();
446
+
447
+ let (pick, total_cost) = solve_inner(
448
+ &x_view,
449
+ team_idx,
450
+ set_home,
451
+ &opponents,
452
+ &self.sets_forbidden,
453
+ self.m,
454
+ self.p,
455
+ self.r_max,
456
+ &self.penalties_vec,
457
+ self.max_penalty_key,
458
+ );
459
+
460
+ // release readonly borrow before writing
461
+ drop(x_read);
462
+
463
+ // assign selection to X in-place: X[team_idx, opponents] = pick
464
+ unsafe {
465
+ let mut x_mut = x_py.as_array_mut();
466
+ for (idx, &opp) in opponents.iter().enumerate() {
467
+ *x_mut.uget_mut([team_idx, opp]) = pick[idx];
468
+ }
469
+ }
470
+
471
+ Ok(total_cost)
472
+ }
473
+
474
+ /// Create cost matrix (exposed for construction phase method 2).
475
+ fn create_cost_matrix<'py>(
476
+ &self,
477
+ py: Python<'py>,
478
+ x_py: PyReadonlyArray2<'py, f64>,
479
+ team_idx: usize,
480
+ set_home: Vec<f64>,
481
+ opponents: Vec<usize>,
482
+ ) -> PyResult<Py<PyArray2<f64>>> {
483
+ let x = x_py.as_array();
484
+ let am = create_cost_matrix_inner(
485
+ &x,
486
+ team_idx,
487
+ &set_home,
488
+ &opponents,
489
+ &self.sets_forbidden,
490
+ self.m,
491
+ self.r_max,
492
+ &self.penalties_vec,
493
+ self.max_penalty_key,
494
+ );
495
+ Ok(PyArray2::from_owned_array_bound(py, am).unbind())
496
+ }
497
+ }
498
+
499
+ #[pymodule]
500
+ fn fasttps(m: &Bound<'_, PyModule>) -> PyResult<()> {
501
+ m.add_class::<FastTPS>()?;
502
+ Ok(())
503
+ }