pysimplicial 0.2.0__py3-none-any.whl

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,27 @@
1
+ """Finite simplicial complexes, contiguity and simplicial category."""
2
+
3
+ from ._errors import CudaUnavailableError, SearchLimitExceeded
4
+ from .category import (
5
+ CategoryResult,
6
+ GSCatResult,
7
+ contiguity_distance,
8
+ cuda_status,
9
+ gscat,
10
+ is_categorical,
11
+ scat,
12
+ verify_gscat_cover,
13
+ )
14
+ from .collapse import core, dominators, is_strongly_collapsible, strong_core
15
+ from .complex import SimplicialComplex
16
+ from .contiguity import contiguity_chain, contiguity_class, is_contiguous, same_contiguity_class
17
+ from .maps import SimplicialMap
18
+ from .operations import categorical_product, connected_components, disjoint_union, graph_join, join, link, relabel, wedge
19
+
20
+ __all__ = [
21
+ "CategoryResult", "CudaUnavailableError", "GSCatResult", "SearchLimitExceeded",
22
+ "SimplicialComplex", "SimplicialMap", "categorical_product", "connected_components",
23
+ "contiguity_chain", "contiguity_class", "contiguity_distance", "core", "cuda_status",
24
+ "disjoint_union", "dominators", "graph_join", "gscat", "is_categorical",
25
+ "is_contiguous", "is_strongly_collapsible", "join", "link", "relabel",
26
+ "same_contiguity_class", "scat", "strong_core", "verify_gscat_cover", "wedge",
27
+ ]
@@ -0,0 +1,13 @@
1
+ class SearchLimitExceeded(RuntimeError):
2
+ """A search budget expired before minimality or reachability was decided."""
3
+
4
+ def __init__(self, message, *, gpu_used=False, cover_lower_bound=None, cover_masks=()):
5
+ super().__init__(message)
6
+ self.gpu_used = gpu_used
7
+ self.cover_lower_bound = cover_lower_bound
8
+ self.cover_masks = cover_masks
9
+ self.cover_witnesses = ()
10
+
11
+
12
+ class CudaUnavailableError(RuntimeError):
13
+ """The explicitly requested CUDA backend is unavailable."""
File without changes
@@ -0,0 +1,507 @@
1
+ """Randomized CUDA search for a small geometric cover."""
2
+
3
+ from time import monotonic
4
+
5
+ import numpy as np
6
+
7
+ from .._errors import CudaUnavailableError
8
+ from ..collapse import _vertex_key
9
+ from ..complex import SimplicialComplex
10
+
11
+ try:
12
+ from numba import cuda, int16, int32, uint8, uint64
13
+ except Exception as error: # An optional accelerator can fail to load.
14
+ cuda = None
15
+ _NUMBA_IMPORT_ERROR = error
16
+ else:
17
+ _NUMBA_IMPORT_ERROR = None
18
+
19
+
20
+ MAX_VERTICES = 64
21
+ MAX_FACETS = 512
22
+
23
+ _NEIGHBOR_NONE = 0
24
+ _NEIGHBOR_PREFER = 1
25
+ _NEIGHBOR_STRICT = 2
26
+ _NEIGHBOR_CODES = {
27
+ "none": _NEIGHBOR_NONE,
28
+ "prefer": _NEIGHBOR_PREFER,
29
+ "strict": _NEIGHBOR_STRICT,
30
+ }
31
+
32
+
33
+ def cuda_status() -> dict[str, bool | str | int]:
34
+ """Return dependency/device status without launching a kernel."""
35
+
36
+ if cuda is None:
37
+ return {
38
+ "numba_imported": False,
39
+ "cuda_available": False,
40
+ "reason": repr(_NUMBA_IMPORT_ERROR),
41
+ "max_vertices": MAX_VERTICES,
42
+ "max_facets": MAX_FACETS,
43
+ }
44
+ try:
45
+ available = bool(cuda.is_available())
46
+ except Exception as error: # Driver initialization failures vary by host.
47
+ return {
48
+ "numba_imported": True,
49
+ "cuda_available": False,
50
+ "reason": repr(error),
51
+ "max_vertices": MAX_VERTICES,
52
+ "max_facets": MAX_FACETS,
53
+ }
54
+ return {
55
+ "numba_imported": True,
56
+ "cuda_available": available,
57
+ "reason": "ok" if available else "CUDA driver/device is unavailable",
58
+ "max_vertices": MAX_VERTICES,
59
+ "max_facets": MAX_FACETS,
60
+ }
61
+
62
+
63
+ if cuda is not None:
64
+
65
+ @cuda.jit(device=True, inline=True)
66
+ def _popcount64(value):
67
+ count = 0
68
+ while value != 0:
69
+ value &= value - uint64(1)
70
+ count += 1
71
+ return count
72
+
73
+
74
+ @cuda.jit(device=True, inline=True)
75
+ def _xorshift64(state):
76
+ state ^= state << 13
77
+ state ^= state >> 7
78
+ state ^= state << 17
79
+ return state
80
+
81
+
82
+ @cuda.jit(device=True, inline=True)
83
+ def _are_neighbors(first, second):
84
+ first_size = _popcount64(first)
85
+ second_size = _popcount64(second)
86
+ required = first_size
87
+ if second_size < required:
88
+ required = second_size
89
+ required -= 1
90
+ if required < 1:
91
+ return False
92
+ return _popcount64(first & second) >= required
93
+
94
+
95
+ @cuda.jit(device=True)
96
+ def _normalize_maximal_faces(work_faces, live, facet_count):
97
+ """Mark exactly one copy of every inclusion-maximal nonempty face."""
98
+
99
+ for first in range(facet_count):
100
+ live[first] = uint8(0)
101
+ first_face = work_faces[first]
102
+ if first_face == 0:
103
+ continue
104
+ keep = True
105
+ for second in range(facet_count):
106
+ if first == second:
107
+ continue
108
+ second_face = work_faces[second]
109
+ if second_face == 0:
110
+ continue
111
+ if (first_face & second_face) == first_face:
112
+ if first_face != second_face or second < first:
113
+ keep = False
114
+ break
115
+ if keep:
116
+ live[first] = uint8(1)
117
+ # Non-maximal generators must be discarded permanently. Allowing a
118
+ # discarded face to reappear after a later vertex deletion would no
119
+ # longer represent the elementary strong collapse of the current
120
+ # complex.
121
+ for first in range(facet_count):
122
+ if live[first] == 0:
123
+ work_faces[first] = uint64(0)
124
+
125
+
126
+ @cuda.jit(device=True)
127
+ def _selection_is_strongly_collapsible(
128
+ facet_masks,
129
+ assignment,
130
+ facet_count,
131
+ vertex_count,
132
+ first_part,
133
+ second_part,
134
+ added_facet,
135
+ work_faces,
136
+ live,
137
+ ):
138
+ """Exact dominated-vertex test for one facet-generated subcomplex."""
139
+
140
+ for facet_index in range(facet_count):
141
+ selected = assignment[facet_index] == first_part
142
+ if second_part >= 0 and assignment[facet_index] == second_part:
143
+ selected = True
144
+ if facet_index == added_facet:
145
+ selected = True
146
+ if selected:
147
+ work_faces[facet_index] = facet_masks[facet_index]
148
+ else:
149
+ work_faces[facet_index] = uint64(0)
150
+
151
+ _normalize_maximal_faces(work_faces, live, facet_count)
152
+
153
+ # At most vertex_count-1 elementary strong collapses are possible.
154
+ for _ in range(vertex_count + 1):
155
+ active_vertices = uint64(0)
156
+ for facet_index in range(facet_count):
157
+ if live[facet_index] != 0:
158
+ active_vertices |= work_faces[facet_index]
159
+
160
+ if active_vertices == 0:
161
+ return False
162
+ if _popcount64(active_vertices) == 1:
163
+ return True
164
+
165
+ dominated_vertex = -1
166
+ for vertex in range(vertex_count):
167
+ vertex_bit = uint64(1) << vertex
168
+ if (active_vertices & vertex_bit) == 0:
169
+ continue
170
+ for apex in range(vertex_count):
171
+ if apex == vertex:
172
+ continue
173
+ apex_bit = uint64(1) << apex
174
+ if (active_vertices & apex_bit) == 0:
175
+ continue
176
+ dominated = True
177
+ for facet_index in range(facet_count):
178
+ if live[facet_index] == 0:
179
+ continue
180
+ face = work_faces[facet_index]
181
+ if (face & vertex_bit) != 0 and (face & apex_bit) == 0:
182
+ dominated = False
183
+ break
184
+ if dominated:
185
+ dominated_vertex = vertex
186
+ break
187
+ if dominated_vertex >= 0:
188
+ break
189
+
190
+ if dominated_vertex < 0:
191
+ return False
192
+
193
+ deleted_bit = uint64(1) << dominated_vertex
194
+ for facet_index in range(facet_count):
195
+ if live[facet_index] != 0:
196
+ work_faces[facet_index] &= ~deleted_bit
197
+ _normalize_maximal_faces(work_faces, live, facet_count)
198
+
199
+ return False
200
+
201
+
202
+ @cuda.jit(device=True)
203
+ def _parts_share_a_vertex(
204
+ facet_masks,
205
+ assignment,
206
+ facet_count,
207
+ first_part,
208
+ second_part,
209
+ ):
210
+ first_vertices = uint64(0)
211
+ second_vertices = uint64(0)
212
+ for facet_index in range(facet_count):
213
+ part = assignment[facet_index]
214
+ if part == first_part:
215
+ first_vertices |= facet_masks[facet_index]
216
+ elif part == second_part:
217
+ second_vertices |= facet_masks[facet_index]
218
+ return (first_vertices & second_vertices) != 0
219
+
220
+
221
+ @cuda.jit
222
+ def _greedy_restarts_kernel(
223
+ facet_masks,
224
+ facet_count,
225
+ vertex_count,
226
+ restart_offset,
227
+ restart_count,
228
+ base_seed,
229
+ neighbor_mode,
230
+ do_merge,
231
+ output_counts,
232
+ output_assignment,
233
+ ):
234
+ local_restart = cuda.grid(1)
235
+ if local_restart >= restart_count:
236
+ return
237
+
238
+ restart = restart_offset + local_restart
239
+ state = base_seed ^ (
240
+ uint64(restart + 1) * uint64(0x9E3779B97F4A7C15)
241
+ )
242
+ if state == 0:
243
+ state = uint64(0xD1B54A32D192ED03)
244
+
245
+ order = cuda.local.array(MAX_FACETS, dtype=int16)
246
+ assignment = cuda.local.array(MAX_FACETS, dtype=int16)
247
+ part_order = cuda.local.array(MAX_FACETS, dtype=int16)
248
+ part_sizes = cuda.local.array(MAX_FACETS, dtype=int16)
249
+ neighbor_scores = cuda.local.array(MAX_FACETS, dtype=int16)
250
+ used = cuda.local.array(MAX_FACETS, dtype=uint8)
251
+ frontier = cuda.local.array(MAX_FACETS, dtype=uint8)
252
+ work_faces = cuda.local.array(MAX_FACETS, dtype=uint64)
253
+ live = cuda.local.array(MAX_FACETS, dtype=uint8)
254
+
255
+ for index in range(facet_count):
256
+ order[index] = int16(index)
257
+ assignment[index] = int16(-1)
258
+ part_sizes[index] = int16(0)
259
+ used[index] = uint8(0)
260
+ frontier[index] = uint8(0)
261
+
262
+ if restart == 1:
263
+ for index in range(facet_count):
264
+ order[index] = int16(facet_count - 1 - index)
265
+ elif restart >= 2 and neighbor_mode != _NEIGHBOR_NONE and restart % 3 == 2:
266
+ # Random walk over the codimension-one facet adjacency frontier.
267
+ state = _xorshift64(state)
268
+ current = int32(state % uint64(facet_count))
269
+ for position in range(facet_count):
270
+ if position > 0:
271
+ candidate_count = 0
272
+ for candidate in range(facet_count):
273
+ if used[candidate] == 0 and frontier[candidate] != 0:
274
+ candidate_count += 1
275
+ if candidate_count == 0:
276
+ for candidate in range(facet_count):
277
+ if used[candidate] == 0:
278
+ candidate_count += 1
279
+ state = _xorshift64(state)
280
+ selected_rank = int32(state % uint64(candidate_count))
281
+ rank = 0
282
+ for candidate in range(facet_count):
283
+ eligible = used[candidate] == 0
284
+ if candidate_count < facet_count - position:
285
+ eligible = eligible and frontier[candidate] != 0
286
+ if eligible:
287
+ if rank == selected_rank:
288
+ current = candidate
289
+ break
290
+ rank += 1
291
+
292
+ order[position] = int16(current)
293
+ used[current] = uint8(1)
294
+ frontier[current] = uint8(0)
295
+ current_mask = facet_masks[current]
296
+ for candidate in range(facet_count):
297
+ if used[candidate] == 0 and _are_neighbors(
298
+ current_mask,
299
+ facet_masks[candidate],
300
+ ):
301
+ frontier[candidate] = uint8(1)
302
+ elif restart >= 2:
303
+ # Fisher-Yates shuffle.
304
+ for index in range(facet_count - 1, 0, -1):
305
+ state = _xorshift64(state)
306
+ other = int32(state % uint64(index + 1))
307
+ temporary = order[index]
308
+ order[index] = order[other]
309
+ order[other] = temporary
310
+
311
+ part_count = 0
312
+ for position in range(facet_count):
313
+ facet_index = int32(order[position])
314
+
315
+ for part in range(part_count):
316
+ neighbor_scores[part] = int16(0)
317
+ part_order[part] = int16(part)
318
+
319
+ if neighbor_mode != _NEIGHBOR_NONE:
320
+ facet_mask = facet_masks[facet_index]
321
+ for previous_position in range(position):
322
+ previous_facet = int32(order[previous_position])
323
+ if _are_neighbors(facet_mask, facet_masks[previous_facet]):
324
+ previous_part = int32(assignment[previous_facet])
325
+ neighbor_scores[previous_part] += int16(1)
326
+
327
+ # Shuffle before a stable insertion sort to randomize equal scores.
328
+ for index in range(part_count - 1, 0, -1):
329
+ state = _xorshift64(state)
330
+ other = int32(state % uint64(index + 1))
331
+ temporary = part_order[index]
332
+ part_order[index] = part_order[other]
333
+ part_order[other] = temporary
334
+
335
+ for index in range(1, part_count):
336
+ key = int32(part_order[index])
337
+ cursor = index - 1
338
+ while cursor >= 0:
339
+ previous = int32(part_order[cursor])
340
+ key_score = int32(neighbor_scores[key])
341
+ previous_score = int32(neighbor_scores[previous])
342
+ key_size = int32(part_sizes[key])
343
+ previous_size = int32(part_sizes[previous])
344
+ if neighbor_mode == _NEIGHBOR_NONE:
345
+ key_score = 0
346
+ previous_score = 0
347
+ if key_score < previous_score:
348
+ break
349
+ if key_score == previous_score and key_size <= previous_size:
350
+ break
351
+ part_order[cursor + 1] = part_order[cursor]
352
+ cursor -= 1
353
+ part_order[cursor + 1] = int16(key)
354
+
355
+ placed = False
356
+ for target_position in range(part_count):
357
+ target_part = int32(part_order[target_position])
358
+ if (
359
+ neighbor_mode == _NEIGHBOR_STRICT
360
+ and neighbor_scores[target_part] == 0
361
+ ):
362
+ continue
363
+ if _selection_is_strongly_collapsible(
364
+ facet_masks,
365
+ assignment,
366
+ facet_count,
367
+ vertex_count,
368
+ target_part,
369
+ -1,
370
+ facet_index,
371
+ work_faces,
372
+ live,
373
+ ):
374
+ assignment[facet_index] = int16(target_part)
375
+ part_sizes[target_part] += int16(1)
376
+ placed = True
377
+ break
378
+
379
+ if not placed:
380
+ assignment[facet_index] = int16(part_count)
381
+ part_sizes[part_count] = int16(1)
382
+ part_count += 1
383
+
384
+ if do_merge != 0:
385
+ merged = True
386
+ while merged and part_count > 1:
387
+ merged = False
388
+ state = _xorshift64(state)
389
+ first_start = int32(state % uint64(part_count))
390
+ for first_offset in range(part_count):
391
+ first = (first_start + first_offset) % part_count
392
+ if merged:
393
+ break
394
+ for second_offset in range(1, part_count):
395
+ second = (first + second_offset) % part_count
396
+ if not _parts_share_a_vertex(
397
+ facet_masks,
398
+ assignment,
399
+ facet_count,
400
+ first,
401
+ second,
402
+ ):
403
+ continue
404
+ if not _selection_is_strongly_collapsible(
405
+ facet_masks,
406
+ assignment,
407
+ facet_count,
408
+ vertex_count,
409
+ first,
410
+ second,
411
+ -1,
412
+ work_faces,
413
+ live,
414
+ ):
415
+ continue
416
+
417
+ last = part_count - 1
418
+ for facet_index in range(facet_count):
419
+ if assignment[facet_index] == second:
420
+ assignment[facet_index] = int16(first)
421
+ if second != last:
422
+ for facet_index in range(facet_count):
423
+ if assignment[facet_index] == last:
424
+ assignment[facet_index] = int16(second)
425
+ part_count -= 1
426
+ merged = True
427
+ break
428
+
429
+ output_counts[local_restart] = int16(part_count)
430
+ for facet_index in range(facet_count):
431
+ output_assignment[local_restart, facet_index] = assignment[facet_index]
432
+
433
+
434
+ def _require_cuda(device_id: int) -> None:
435
+ if cuda is None:
436
+ raise CudaUnavailableError(
437
+ "Numba CUDA is not installed. For CUDA 12 run: "
438
+ "python -m pip install numpy 'numba-cuda[cu12]'"
439
+ ) from _NUMBA_IMPORT_ERROR
440
+ try:
441
+ if not cuda.is_available():
442
+ raise CudaUnavailableError(
443
+ "Numba cannot access an NVIDIA CUDA device. Check the NVIDIA "
444
+ "driver and run pysimplicial.cuda_status()."
445
+ )
446
+ cuda.select_device(device_id)
447
+ except CudaUnavailableError:
448
+ raise
449
+ except Exception as error:
450
+ raise CudaUnavailableError(
451
+ f"Failed to initialize CUDA device {device_id}: {error}"
452
+ ) from error
453
+
454
+
455
+
456
+ def randomized_cover(
457
+ complex_, *, restarts, seed, lower_bound, batch_size, progress,
458
+ neighbor_mode="prefer", merge=True,
459
+ ):
460
+ """Return the best facet partition and the number of executed restarts."""
461
+ labels = sorted(complex_.vertices, key=_vertex_key)
462
+ indices = {vertex: index for index, vertex in enumerate(labels)}
463
+ encoded = sorted(
464
+ (sum(1 << indices[v] for v in facet), facet)
465
+ for facet in complex_.facets
466
+ )
467
+ masks, facets = zip(*encoded)
468
+ facet_count = len(facets)
469
+ device_facets = cuda.to_device(np.asarray(masks, dtype=np.uint64))
470
+ neighbor_code = _NEIGHBOR_CODES[neighbor_mode]
471
+ threads = 32
472
+ best_count = facet_count
473
+ best_assignment = np.arange(facet_count, dtype=np.int16)
474
+ completed = 0
475
+ started = monotonic()
476
+ while completed < restarts and best_count - 1 > lower_bound:
477
+ count = min(batch_size, restarts - completed)
478
+ device_counts = cuda.device_array(count, dtype=np.int16)
479
+ device_assignment = cuda.device_array((count, facet_count), dtype=np.int16)
480
+ blocks = (count + threads - 1) // threads
481
+ _greedy_restarts_kernel[blocks, threads](
482
+ device_facets, facet_count, len(labels), completed, count,
483
+ np.uint64(seed), neighbor_code, int(merge),
484
+ device_counts, device_assignment,
485
+ )
486
+ counts = device_counts.copy_to_host()
487
+ best = int(np.argmin(counts))
488
+ local_count = int(counts[best])
489
+ if local_count < best_count:
490
+ best_count = local_count
491
+ best_assignment = device_assignment[best].copy_to_host()
492
+ completed += count
493
+ if progress:
494
+ progress({
495
+ "phase": "cuda_restarts",
496
+ "restarts": completed,
497
+ "upper_bound": best_count - 1,
498
+ "seconds": monotonic() - started,
499
+ })
500
+ groups = [[] for _ in range(best_count)]
501
+ for facet, part in zip(facets, best_assignment):
502
+ if not 0 <= part < best_count:
503
+ raise RuntimeError("CUDA returned an invalid cover assignment.")
504
+ groups[int(part)].append(facet)
505
+ if any(not group for group in groups):
506
+ raise RuntimeError("CUDA returned an empty cover member.")
507
+ return tuple(SimplicialComplex(group) for group in groups), completed