cndetector 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.
- cndetector-0.2.0/LICENSE +21 -0
- cndetector-0.2.0/PKG-INFO +241 -0
- cndetector-0.2.0/README.md +204 -0
- cndetector-0.2.0/buildtools/__init__.py +0 -0
- cndetector-0.2.0/buildtools/build_extensions.py +79 -0
- cndetector-0.2.0/buildtools/build_wrapper.py +23 -0
- cndetector-0.2.0/buildtools/get_version.py +70 -0
- cndetector-0.2.0/cndetector/Model.py +645 -0
- cndetector-0.2.0/cndetector/ProgressPrinter.py +103 -0
- cndetector-0.2.0/cndetector/Result.py +21 -0
- cndetector-0.2.0/cndetector/__init__.py +57 -0
- cndetector-0.2.0/cndetector/_cndetector.pyi +160 -0
- cndetector-0.2.0/cndetector/constants.py +17 -0
- cndetector-0.2.0/cndetector/cpp/Graph/CNP_Graph.cpp +948 -0
- cndetector-0.2.0/cndetector/cpp/Graph/CNP_Graph.h +270 -0
- cndetector-0.2.0/cndetector/cpp/Graph/DCNP_Graph.cpp +490 -0
- cndetector-0.2.0/cndetector/cpp/Graph/DCNP_Graph.h +278 -0
- cndetector-0.2.0/cndetector/cpp/Graph/Types.h +26 -0
- cndetector-0.2.0/cndetector/cpp/ParallelFor.h +152 -0
- cndetector-0.2.0/cndetector/cpp/Population.cpp +589 -0
- cndetector-0.2.0/cndetector/cpp/Population.h +158 -0
- cndetector-0.2.0/cndetector/cpp/ProblemData.cpp +185 -0
- cndetector-0.2.0/cndetector/cpp/ProblemData.h +38 -0
- cndetector-0.2.0/cndetector/cpp/RandomNumberGenerator.h +129 -0
- cndetector-0.2.0/cndetector/cpp/crossover/reduceSolveCombine.cpp +144 -0
- cndetector-0.2.0/cndetector/cpp/crossover/reduceSolveCombine.h +56 -0
- cndetector-0.2.0/cndetector/cpp/pybind.cpp +268 -0
- cndetector-0.2.0/cndetector/cpp/search/DCNPSearch.cpp +95 -0
- cndetector-0.2.0/cndetector/cpp/search/DCNPSearch.h +11 -0
- cndetector-0.2.0/cndetector/cpp/search/L2NSSearch.cpp +98 -0
- cndetector-0.2.0/cndetector/cpp/search/L2NSSearch.h +44 -0
- cndetector-0.2.0/cndetector/cpp/search/LocalSearch.h +24 -0
- cndetector-0.2.0/cndetector/params.py +88 -0
- cndetector-0.2.0/cndetector/py.typed +0 -0
- cndetector-0.2.0/cndetector/read.py +40 -0
- cndetector-0.2.0/cndetector/stop/MaxIterations.py +20 -0
- cndetector-0.2.0/cndetector/stop/MaxRuntime.py +19 -0
- cndetector-0.2.0/cndetector/stop/NoImprovement.py +23 -0
- cndetector-0.2.0/cndetector/stop/StoppingCriterion.py +11 -0
- cndetector-0.2.0/cndetector/stop/__init__.py +13 -0
- cndetector-0.2.0/meson.build +96 -0
- cndetector-0.2.0/pyproject.toml +130 -0
- cndetector-0.2.0/tests/test_basic.py +351 -0
cndetector-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Xuebo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cndetector
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: A high-performance Python solver for Critical Node Problems using Population-based Dual Memetic Search
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: graph,optimization,critical-node-problem,memetic-search,metaheuristic
|
|
8
|
+
Author: xuebo100
|
|
9
|
+
Author-email: x3219658574@126.com
|
|
10
|
+
Maintainer: xuebo100
|
|
11
|
+
Maintainer-email: x3219658574@126.com
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Development Status :: 3 - Alpha
|
|
16
|
+
Classifier: Topic :: Software Development
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Programming Language :: C++
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: mypy (>=1.0.0) ; extra == "dev"
|
|
28
|
+
Requires-Dist: pre-commit (>=3.0.0) ; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest (>=7.0.0) ; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-cov (>=4.0.0) ; extra == "dev"
|
|
31
|
+
Requires-Dist: ruff (>=0.1.0) ; extra == "dev"
|
|
32
|
+
Project-URL: Homepage, https://github.com/xuebo100/PCMS
|
|
33
|
+
Project-URL: Issues, https://github.com/xuebo100/PCMS/issues
|
|
34
|
+
Project-URL: Repository, https://github.com/xuebo100/PCMS
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
<div align="center">
|
|
38
|
+
|
|
39
|
+
# CNDetector
|
|
40
|
+
|
|
41
|
+
**A high-performance solver for Critical Node Problems, with a C++ core and a clean Python API.**
|
|
42
|
+
|
|
43
|
+
[](https://pypi.org/project/cndetector/)
|
|
44
|
+
[](https://pypi.org/project/cndetector/)
|
|
45
|
+
[](https://github.com/xuebo100/CNDetector/actions/workflows/ci.yml)
|
|
46
|
+
[](LICENSE)
|
|
47
|
+
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
CNDetector finds the set of vertices whose removal most fragments a graph, using
|
|
51
|
+
**IRMS** (*Iterative Ruin and Memetic Search*) — a population-based memetic
|
|
52
|
+
metaheuristic implemented in C++ and exposed through pybind11.
|
|
53
|
+
|
|
54
|
+
Given a graph and a budget `k`, the **Critical Node Problem (CNP)** asks which
|
|
55
|
+
`k` vertices to remove to minimize the residual pairwise connectivity — the sum
|
|
56
|
+
of `|C|·(|C|−1)/2` over every remaining connected component `C`. The
|
|
57
|
+
**distance-based variant (DCNP)** instead minimizes the number of node pairs
|
|
58
|
+
that stay within `D` hops of each other.
|
|
59
|
+
|
|
60
|
+
## Installation
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install cndetector
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
<details>
|
|
67
|
+
<summary>Build from source</summary>
|
|
68
|
+
|
|
69
|
+
CNDetector builds from source with Meson + Ninja + pybind11:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install -e . --no-build-isolation
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Requirements: Python ≥ 3.9 and a C++20-capable compiler (clang ≥ 17, gcc ≥ 11,
|
|
76
|
+
or MSVC 2022). `meson`, `ninja` and `pybind11` are pulled in automatically.
|
|
77
|
+
|
|
78
|
+
</details>
|
|
79
|
+
|
|
80
|
+
## Quick start — CNP
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from cndetector import Model, MaxIterations
|
|
84
|
+
|
|
85
|
+
model = Model()
|
|
86
|
+
for u, v in [
|
|
87
|
+
(0, 1), (1, 2), (2, 3), (3, 4), (4, 5),
|
|
88
|
+
(5, 6), (6, 7), (7, 8), (8, 9), (9, 0),
|
|
89
|
+
(0, 5), (1, 6), (2, 7), (3, 8), (4, 9),
|
|
90
|
+
]:
|
|
91
|
+
model.add_edge(u, v)
|
|
92
|
+
|
|
93
|
+
result = model.solve(
|
|
94
|
+
budget=3, # remove 3 nodes
|
|
95
|
+
stopping_criterion=MaxIterations(50),
|
|
96
|
+
seed=42,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
print(f"Best objective: {result.best_obj_value}")
|
|
100
|
+
print(f"Removed nodes: {sorted(result.best_solution)}")
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Load a graph from a file instead of building it by hand:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
import cndetector
|
|
107
|
+
|
|
108
|
+
model = cndetector.Model.from_data(cndetector.read("path/to/graph.adj"))
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`cndetector.read()` handles adjacency-list files and auto-detects DIMACS edge-list
|
|
112
|
+
files (a `p edge n m` line followed by `e u v` lines).
|
|
113
|
+
|
|
114
|
+
## Distance-based CNP (DCNP)
|
|
115
|
+
|
|
116
|
+
Select DCNP with `problem="DCNP"` and a `distance`:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
import cndetector
|
|
120
|
+
from cndetector import MaxRuntime
|
|
121
|
+
|
|
122
|
+
model = cndetector.Model.from_data(cndetector.read("Instances/DCNP/R1/karate.txt"))
|
|
123
|
+
|
|
124
|
+
result = model.solve(
|
|
125
|
+
problem="DCNP",
|
|
126
|
+
budget=3,
|
|
127
|
+
distance=2, # count pairs within 2 hops
|
|
128
|
+
stopping_criterion=MaxRuntime(10),
|
|
129
|
+
seed=1,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
print(f"Best D-hop pairs: {result.best_obj_value}")
|
|
133
|
+
print(f"Removed nodes: {sorted(result.best_solution)}")
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
DCNP runs the same dual-population search as CNP. Its objective rebuilds a
|
|
137
|
+
K-hop tree on every step, so a single local search is far more expensive, and
|
|
138
|
+
CNDetector therefore applies a lighter parameter set automatically — see
|
|
139
|
+
[Tuning DCNP](#tuning-dcnp).
|
|
140
|
+
|
|
141
|
+
## API reference
|
|
142
|
+
|
|
143
|
+
### `Model.solve(...)`
|
|
144
|
+
|
|
145
|
+
| Argument | Default | Description |
|
|
146
|
+
|----------|---------|-------------|
|
|
147
|
+
| `budget` | — | Number of nodes to remove (`1 ≤ budget < \|V\|`). Required. |
|
|
148
|
+
| `stopping_criterion` | — | Callable stopping the solver when it returns `True`. Required. |
|
|
149
|
+
| `problem` | `"CNP"` | `"CNP"` or `"DCNP"`. |
|
|
150
|
+
| `distance` | `None` | DCNP distance threshold `D` (required for DCNP, `D ≥ 1`). |
|
|
151
|
+
| `seed` | `0` | RNG seed (`0` is valid). |
|
|
152
|
+
| `params` | `SolverParams()` | Tunable solver parameters (see below). |
|
|
153
|
+
| `display` | `True` | Log progress to the package logger. |
|
|
154
|
+
| `collect_stats` | `True` | Record a per-iteration trace in `Result.stats`. |
|
|
155
|
+
|
|
156
|
+
### Stopping criteria
|
|
157
|
+
|
|
158
|
+
| Criterion | Stops when |
|
|
159
|
+
|-----------|------------|
|
|
160
|
+
| `MaxIterations(n)` | `n` solver iterations have run |
|
|
161
|
+
| `MaxRuntime(s)` | `s` seconds of wall-clock time have elapsed |
|
|
162
|
+
| `NoImprovement(n)` | the best objective hasn't improved for `n` iterations |
|
|
163
|
+
|
|
164
|
+
Any `Callable[[float], bool]` works, so you can supply your own.
|
|
165
|
+
|
|
166
|
+
### `SolverParams`
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
from cndetector import SolverParams
|
|
170
|
+
|
|
171
|
+
params = SolverParams(
|
|
172
|
+
population_size=10, # theta: size of each population
|
|
173
|
+
thread_count=2, # kappa: threads, and offspring per generation
|
|
174
|
+
transfer_interval=20, # beta: generations between population exchanges
|
|
175
|
+
partial_ratio=0.95, # 1 - alpha, the relaxation coefficient
|
|
176
|
+
stagnation_threshold=500, # delta: idle generations before reconstruction
|
|
177
|
+
search="L2NS", # local-search strategy
|
|
178
|
+
)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
The defaults are the tuned values used in the paper: `theta = 10`,
|
|
182
|
+
`kappa = 2`, `beta = 20`, `alpha = 0.05`, `xi = 1000`, `delta = 500`. The same
|
|
183
|
+
set applies to both CNP and DCNP.
|
|
184
|
+
|
|
185
|
+
`search` accepts `"L2NS"` (the default), `"L2NS-ADAPT"`, or `"L2NS<N>"` for a
|
|
186
|
+
fixed destroy size (e.g. `"L2NS5"`). The `l2ns_*` fields override the
|
|
187
|
+
local-search budget: `l2ns_random_idle_product` is the allowable idle iteration
|
|
188
|
+
count `xi`, and each L2NS run draws a destroy size `lambda` from `[1, 50]` and
|
|
189
|
+
stops after `min(500, xi / lambda)` idle iterations.
|
|
190
|
+
|
|
191
|
+
### Parallelism
|
|
192
|
+
|
|
193
|
+
`thread_count` (`kappa`) sets the number of worker threads, and Algorithm 2
|
|
194
|
+
generates one offspring per thread. Parallelism affects wall-clock time only:
|
|
195
|
+
for a fixed iteration budget the solver returns bit-identical solutions for any
|
|
196
|
+
thread count. `set_max_threads` / `get_max_threads` expose the cap directly.
|
|
197
|
+
|
|
198
|
+
### `Result`
|
|
199
|
+
|
|
200
|
+
`Model.solve` returns a `Result` with `best_solution`, `best_obj_value`,
|
|
201
|
+
`num_iterations`, `runtime`, `best_found_at_time`, an optional per-iteration
|
|
202
|
+
`stats` list, and the final `feasible_population`.
|
|
203
|
+
|
|
204
|
+
## Tuning DCNP
|
|
205
|
+
|
|
206
|
+
Evaluating the DCNP objective rebuilds the b-hop trees on every step, which
|
|
207
|
+
makes one local search one to two orders of magnitude more expensive than for
|
|
208
|
+
CNP. DCNP therefore uses a lighter parameter set, applied automatically **only
|
|
209
|
+
when you leave these knobs at the library defaults**:
|
|
210
|
+
|
|
211
|
+
| Knob | CNP default | DCNP default |
|
|
212
|
+
|------|-------------|--------------|
|
|
213
|
+
| `population_size` (theta) | 10 | **4** |
|
|
214
|
+
| `transfer_interval` (beta) | 20 | **5** |
|
|
215
|
+
| `l2ns_random_idle_product` (xi) | 1000 | **100** |
|
|
216
|
+
| `l2ns_random_min_idle_steps` | 1 | **20** |
|
|
217
|
+
| `l2ns_random_max_idle_steps` | 500 | **80** |
|
|
218
|
+
| `l2ns_random_batch_max` | 50 | **15** |
|
|
219
|
+
| `l2ns_theta` | 0.3 | **0.3** |
|
|
220
|
+
|
|
221
|
+
Passing an explicit value (or any `l2ns_*` field) overrides it. These values
|
|
222
|
+
were tuned on the 100–500 node instances USAir97, Circuit and Ecoli.
|
|
223
|
+
|
|
224
|
+
## Development
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
pip install -e ".[dev]" --no-build-isolation # dev dependencies
|
|
228
|
+
pytest tests/ # run the tests
|
|
229
|
+
python buildtools/build_extensions.py --build_type release # recompile C++
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
> **macOS note.** On Apple Silicon, after recompiling and copying the native
|
|
233
|
+
> extension (`_cndetector.*.so`), `import` may be killed by AMFI with SIGKILL (exit
|
|
234
|
+
> code 137) because the copied Mach-O's signature is invalidated. Re-sign it
|
|
235
|
+
> ad-hoc to fix it:
|
|
236
|
+
> `codesign -f -s - cndetector/_cndetector.cpython-*-darwin.so`.
|
|
237
|
+
|
|
238
|
+
## License
|
|
239
|
+
|
|
240
|
+
Released under the MIT License — see [`LICENSE`](LICENSE).
|
|
241
|
+
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# CNDetector
|
|
4
|
+
|
|
5
|
+
**A high-performance solver for Critical Node Problems, with a C++ core and a clean Python API.**
|
|
6
|
+
|
|
7
|
+
[](https://pypi.org/project/cndetector/)
|
|
8
|
+
[](https://pypi.org/project/cndetector/)
|
|
9
|
+
[](https://github.com/xuebo100/CNDetector/actions/workflows/ci.yml)
|
|
10
|
+
[](LICENSE)
|
|
11
|
+
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
CNDetector finds the set of vertices whose removal most fragments a graph, using
|
|
15
|
+
**IRMS** (*Iterative Ruin and Memetic Search*) — a population-based memetic
|
|
16
|
+
metaheuristic implemented in C++ and exposed through pybind11.
|
|
17
|
+
|
|
18
|
+
Given a graph and a budget `k`, the **Critical Node Problem (CNP)** asks which
|
|
19
|
+
`k` vertices to remove to minimize the residual pairwise connectivity — the sum
|
|
20
|
+
of `|C|·(|C|−1)/2` over every remaining connected component `C`. The
|
|
21
|
+
**distance-based variant (DCNP)** instead minimizes the number of node pairs
|
|
22
|
+
that stay within `D` hops of each other.
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install cndetector
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
<details>
|
|
31
|
+
<summary>Build from source</summary>
|
|
32
|
+
|
|
33
|
+
CNDetector builds from source with Meson + Ninja + pybind11:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install -e . --no-build-isolation
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Requirements: Python ≥ 3.9 and a C++20-capable compiler (clang ≥ 17, gcc ≥ 11,
|
|
40
|
+
or MSVC 2022). `meson`, `ninja` and `pybind11` are pulled in automatically.
|
|
41
|
+
|
|
42
|
+
</details>
|
|
43
|
+
|
|
44
|
+
## Quick start — CNP
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from cndetector import Model, MaxIterations
|
|
48
|
+
|
|
49
|
+
model = Model()
|
|
50
|
+
for u, v in [
|
|
51
|
+
(0, 1), (1, 2), (2, 3), (3, 4), (4, 5),
|
|
52
|
+
(5, 6), (6, 7), (7, 8), (8, 9), (9, 0),
|
|
53
|
+
(0, 5), (1, 6), (2, 7), (3, 8), (4, 9),
|
|
54
|
+
]:
|
|
55
|
+
model.add_edge(u, v)
|
|
56
|
+
|
|
57
|
+
result = model.solve(
|
|
58
|
+
budget=3, # remove 3 nodes
|
|
59
|
+
stopping_criterion=MaxIterations(50),
|
|
60
|
+
seed=42,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
print(f"Best objective: {result.best_obj_value}")
|
|
64
|
+
print(f"Removed nodes: {sorted(result.best_solution)}")
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Load a graph from a file instead of building it by hand:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
import cndetector
|
|
71
|
+
|
|
72
|
+
model = cndetector.Model.from_data(cndetector.read("path/to/graph.adj"))
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`cndetector.read()` handles adjacency-list files and auto-detects DIMACS edge-list
|
|
76
|
+
files (a `p edge n m` line followed by `e u v` lines).
|
|
77
|
+
|
|
78
|
+
## Distance-based CNP (DCNP)
|
|
79
|
+
|
|
80
|
+
Select DCNP with `problem="DCNP"` and a `distance`:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
import cndetector
|
|
84
|
+
from cndetector import MaxRuntime
|
|
85
|
+
|
|
86
|
+
model = cndetector.Model.from_data(cndetector.read("Instances/DCNP/R1/karate.txt"))
|
|
87
|
+
|
|
88
|
+
result = model.solve(
|
|
89
|
+
problem="DCNP",
|
|
90
|
+
budget=3,
|
|
91
|
+
distance=2, # count pairs within 2 hops
|
|
92
|
+
stopping_criterion=MaxRuntime(10),
|
|
93
|
+
seed=1,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
print(f"Best D-hop pairs: {result.best_obj_value}")
|
|
97
|
+
print(f"Removed nodes: {sorted(result.best_solution)}")
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
DCNP runs the same dual-population search as CNP. Its objective rebuilds a
|
|
101
|
+
K-hop tree on every step, so a single local search is far more expensive, and
|
|
102
|
+
CNDetector therefore applies a lighter parameter set automatically — see
|
|
103
|
+
[Tuning DCNP](#tuning-dcnp).
|
|
104
|
+
|
|
105
|
+
## API reference
|
|
106
|
+
|
|
107
|
+
### `Model.solve(...)`
|
|
108
|
+
|
|
109
|
+
| Argument | Default | Description |
|
|
110
|
+
|----------|---------|-------------|
|
|
111
|
+
| `budget` | — | Number of nodes to remove (`1 ≤ budget < \|V\|`). Required. |
|
|
112
|
+
| `stopping_criterion` | — | Callable stopping the solver when it returns `True`. Required. |
|
|
113
|
+
| `problem` | `"CNP"` | `"CNP"` or `"DCNP"`. |
|
|
114
|
+
| `distance` | `None` | DCNP distance threshold `D` (required for DCNP, `D ≥ 1`). |
|
|
115
|
+
| `seed` | `0` | RNG seed (`0` is valid). |
|
|
116
|
+
| `params` | `SolverParams()` | Tunable solver parameters (see below). |
|
|
117
|
+
| `display` | `True` | Log progress to the package logger. |
|
|
118
|
+
| `collect_stats` | `True` | Record a per-iteration trace in `Result.stats`. |
|
|
119
|
+
|
|
120
|
+
### Stopping criteria
|
|
121
|
+
|
|
122
|
+
| Criterion | Stops when |
|
|
123
|
+
|-----------|------------|
|
|
124
|
+
| `MaxIterations(n)` | `n` solver iterations have run |
|
|
125
|
+
| `MaxRuntime(s)` | `s` seconds of wall-clock time have elapsed |
|
|
126
|
+
| `NoImprovement(n)` | the best objective hasn't improved for `n` iterations |
|
|
127
|
+
|
|
128
|
+
Any `Callable[[float], bool]` works, so you can supply your own.
|
|
129
|
+
|
|
130
|
+
### `SolverParams`
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from cndetector import SolverParams
|
|
134
|
+
|
|
135
|
+
params = SolverParams(
|
|
136
|
+
population_size=10, # theta: size of each population
|
|
137
|
+
thread_count=2, # kappa: threads, and offspring per generation
|
|
138
|
+
transfer_interval=20, # beta: generations between population exchanges
|
|
139
|
+
partial_ratio=0.95, # 1 - alpha, the relaxation coefficient
|
|
140
|
+
stagnation_threshold=500, # delta: idle generations before reconstruction
|
|
141
|
+
search="L2NS", # local-search strategy
|
|
142
|
+
)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The defaults are the tuned values used in the paper: `theta = 10`,
|
|
146
|
+
`kappa = 2`, `beta = 20`, `alpha = 0.05`, `xi = 1000`, `delta = 500`. The same
|
|
147
|
+
set applies to both CNP and DCNP.
|
|
148
|
+
|
|
149
|
+
`search` accepts `"L2NS"` (the default), `"L2NS-ADAPT"`, or `"L2NS<N>"` for a
|
|
150
|
+
fixed destroy size (e.g. `"L2NS5"`). The `l2ns_*` fields override the
|
|
151
|
+
local-search budget: `l2ns_random_idle_product` is the allowable idle iteration
|
|
152
|
+
count `xi`, and each L2NS run draws a destroy size `lambda` from `[1, 50]` and
|
|
153
|
+
stops after `min(500, xi / lambda)` idle iterations.
|
|
154
|
+
|
|
155
|
+
### Parallelism
|
|
156
|
+
|
|
157
|
+
`thread_count` (`kappa`) sets the number of worker threads, and Algorithm 2
|
|
158
|
+
generates one offspring per thread. Parallelism affects wall-clock time only:
|
|
159
|
+
for a fixed iteration budget the solver returns bit-identical solutions for any
|
|
160
|
+
thread count. `set_max_threads` / `get_max_threads` expose the cap directly.
|
|
161
|
+
|
|
162
|
+
### `Result`
|
|
163
|
+
|
|
164
|
+
`Model.solve` returns a `Result` with `best_solution`, `best_obj_value`,
|
|
165
|
+
`num_iterations`, `runtime`, `best_found_at_time`, an optional per-iteration
|
|
166
|
+
`stats` list, and the final `feasible_population`.
|
|
167
|
+
|
|
168
|
+
## Tuning DCNP
|
|
169
|
+
|
|
170
|
+
Evaluating the DCNP objective rebuilds the b-hop trees on every step, which
|
|
171
|
+
makes one local search one to two orders of magnitude more expensive than for
|
|
172
|
+
CNP. DCNP therefore uses a lighter parameter set, applied automatically **only
|
|
173
|
+
when you leave these knobs at the library defaults**:
|
|
174
|
+
|
|
175
|
+
| Knob | CNP default | DCNP default |
|
|
176
|
+
|------|-------------|--------------|
|
|
177
|
+
| `population_size` (theta) | 10 | **4** |
|
|
178
|
+
| `transfer_interval` (beta) | 20 | **5** |
|
|
179
|
+
| `l2ns_random_idle_product` (xi) | 1000 | **100** |
|
|
180
|
+
| `l2ns_random_min_idle_steps` | 1 | **20** |
|
|
181
|
+
| `l2ns_random_max_idle_steps` | 500 | **80** |
|
|
182
|
+
| `l2ns_random_batch_max` | 50 | **15** |
|
|
183
|
+
| `l2ns_theta` | 0.3 | **0.3** |
|
|
184
|
+
|
|
185
|
+
Passing an explicit value (or any `l2ns_*` field) overrides it. These values
|
|
186
|
+
were tuned on the 100–500 node instances USAir97, Circuit and Ecoli.
|
|
187
|
+
|
|
188
|
+
## Development
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
pip install -e ".[dev]" --no-build-isolation # dev dependencies
|
|
192
|
+
pytest tests/ # run the tests
|
|
193
|
+
python buildtools/build_extensions.py --build_type release # recompile C++
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
> **macOS note.** On Apple Silicon, after recompiling and copying the native
|
|
197
|
+
> extension (`_cndetector.*.so`), `import` may be killed by AMFI with SIGKILL (exit
|
|
198
|
+
> code 137) because the copied Mach-O's signature is invalidated. Re-sign it
|
|
199
|
+
> ad-hoc to fix it:
|
|
200
|
+
> `codesign -f -s - cndetector/_cndetector.cpython-*-darwin.so`.
|
|
201
|
+
|
|
202
|
+
## License
|
|
203
|
+
|
|
204
|
+
Released under the MIT License — see [`LICENSE`](LICENSE).
|
|
File without changes
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Builds the native extensions for CNDetector."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import pathlib
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
from subprocess import check_call
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_args():
|
|
11
|
+
parser = argparse.ArgumentParser(prog="build_extensions")
|
|
12
|
+
parser.add_argument("--build_dir", default="build")
|
|
13
|
+
parser.add_argument("--build_type", default="release",
|
|
14
|
+
choices=["debug", "debugoptimized", "release"])
|
|
15
|
+
parser.add_argument("--clean", action="store_true")
|
|
16
|
+
parser.add_argument("--verbose", action="store_true")
|
|
17
|
+
parser.add_argument("--additional", nargs=argparse.REMAINDER, default=[])
|
|
18
|
+
return parser.parse_args()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def clean(build_dir: pathlib.Path, install_dir: pathlib.Path):
|
|
22
|
+
if build_dir.exists():
|
|
23
|
+
shutil.rmtree(build_dir)
|
|
24
|
+
for extension in install_dir.rglob("*.so"):
|
|
25
|
+
extension.unlink()
|
|
26
|
+
for extension in install_dir.rglob("*.pyd"):
|
|
27
|
+
extension.unlink()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def configure(build_dir: pathlib.Path, build_type: str, *additional: list[str]):
|
|
31
|
+
cwd = pathlib.Path.cwd()
|
|
32
|
+
is_windows = sys.platform.startswith("win")
|
|
33
|
+
args = [
|
|
34
|
+
build_dir,
|
|
35
|
+
"--buildtype", build_type,
|
|
36
|
+
f"-Dpython.platlibdir={cwd.absolute()}",
|
|
37
|
+
f"-Dstrip={'true' if build_type == 'release' else 'false'}",
|
|
38
|
+
f"-Db_coverage={'true' if build_type != 'release' else 'false'}",
|
|
39
|
+
*(("-Db_vscrt=mt",) if is_windows else ()),
|
|
40
|
+
*additional,
|
|
41
|
+
]
|
|
42
|
+
cmd = "configure" if build_dir.exists() else "setup"
|
|
43
|
+
check_call(["meson", cmd, *args])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def compile(build_dir: pathlib.Path, verbose: bool):
|
|
47
|
+
args = ["-C", build_dir] + (["--verbose"] if verbose else [])
|
|
48
|
+
check_call(["meson", "compile", *args])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def install(build_dir: pathlib.Path):
|
|
52
|
+
check_call(["meson", "install", "-C", build_dir])
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build(
|
|
56
|
+
build_dir: pathlib.Path,
|
|
57
|
+
build_type: str,
|
|
58
|
+
verbose: bool,
|
|
59
|
+
*additional: list[str],
|
|
60
|
+
):
|
|
61
|
+
configure(build_dir, build_type, *additional)
|
|
62
|
+
compile(build_dir, verbose)
|
|
63
|
+
install(build_dir)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def main():
|
|
67
|
+
args = parse_args()
|
|
68
|
+
cwd = pathlib.Path.cwd()
|
|
69
|
+
build_dir = cwd / args.build_dir
|
|
70
|
+
|
|
71
|
+
if args.clean:
|
|
72
|
+
install_dir = cwd / "cndetector"
|
|
73
|
+
clean(build_dir, install_dir)
|
|
74
|
+
|
|
75
|
+
build(build_dir, args.build_type, args.verbose, *args.additional)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
main()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Wrapper around the build_extensions script for poetry build backend.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import pathlib
|
|
6
|
+
import tempfile
|
|
7
|
+
|
|
8
|
+
from build_extensions import build, clean
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main():
|
|
12
|
+
cwd = pathlib.Path.cwd()
|
|
13
|
+
|
|
14
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
15
|
+
build_dir = pathlib.Path(tmpdir)
|
|
16
|
+
install_dir = cwd / "cndetector"
|
|
17
|
+
|
|
18
|
+
clean(build_dir, install_dir)
|
|
19
|
+
build(build_dir, build_type="release", verbose=False)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if __name__ == "__main__":
|
|
23
|
+
main()
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Extract the project version from pyproject.toml.
|
|
2
|
+
|
|
3
|
+
This is the single source of truth for the version: ``[project].version`` in
|
|
4
|
+
pyproject.toml. meson.build calls this script so the native extension and the
|
|
5
|
+
Python metadata never drift apart.
|
|
6
|
+
|
|
7
|
+
meson runs this with whatever ``python3`` it finds first (often the system
|
|
8
|
+
interpreter, e.g. 3.9), so it must stay compatible with older Pythons: the
|
|
9
|
+
``from __future__`` import keeps the ``X | None`` annotations from being
|
|
10
|
+
evaluated at definition time on 3.9.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
import tomllib # Python 3.11+
|
|
21
|
+
except ModuleNotFoundError: # pragma: no cover - older build interpreters
|
|
22
|
+
try:
|
|
23
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
24
|
+
except ModuleNotFoundError:
|
|
25
|
+
tomllib = None # type: ignore[assignment]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _find_pyproject() -> Path | None:
|
|
29
|
+
script_dir = Path(__file__).resolve().parent
|
|
30
|
+
candidates = [script_dir.parent / "pyproject.toml", Path.cwd() / "pyproject.toml"]
|
|
31
|
+
for candidate in candidates:
|
|
32
|
+
if candidate.exists():
|
|
33
|
+
return candidate
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _version_from_toml(text: str) -> str | None:
|
|
38
|
+
if tomllib is not None:
|
|
39
|
+
try:
|
|
40
|
+
data = tomllib.loads(text)
|
|
41
|
+
except Exception:
|
|
42
|
+
data = {}
|
|
43
|
+
version = data.get("project", {}).get("version")
|
|
44
|
+
if isinstance(version, str):
|
|
45
|
+
return version
|
|
46
|
+
# Fallback: match version only inside the [project] table, so unrelated
|
|
47
|
+
# version = "..." lines (e.g. dependency pins) are never picked up.
|
|
48
|
+
project_section = re.search(
|
|
49
|
+
r"^\[project\]\s*$(.*?)(?=^\[|\Z)", text, re.MULTILINE | re.DOTALL
|
|
50
|
+
)
|
|
51
|
+
scope = project_section.group(1) if project_section else text
|
|
52
|
+
match = re.search(r'^\s*version\s*=\s*"([^"]+)"', scope, re.MULTILINE)
|
|
53
|
+
return match.group(1) if match else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main() -> None:
|
|
57
|
+
pyproject = _find_pyproject()
|
|
58
|
+
if pyproject is None:
|
|
59
|
+
print("0.0.0")
|
|
60
|
+
return
|
|
61
|
+
version = _version_from_toml(pyproject.read_text(encoding="utf-8"))
|
|
62
|
+
print(version if version else "0.0.0")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
if __name__ == "__main__":
|
|
66
|
+
try:
|
|
67
|
+
main()
|
|
68
|
+
except Exception as exc: # pragma: no cover
|
|
69
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
70
|
+
sys.exit(1)
|