From 3a53df2ad991c0508cec6b21de649eba3e408579 Mon Sep 17 00:00:00 2001 From: Mustafa Date: Sat, 18 Apr 2026 11:41:07 -0700 Subject: [PATCH 1/2] adding random cc --- .../test_random_combinatorial_complexes.py | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 test/generators/test_random_combinatorial_complexes.py diff --git a/test/generators/test_random_combinatorial_complexes.py b/test/generators/test_random_combinatorial_complexes.py new file mode 100644 index 00000000..ae246c17 --- /dev/null +++ b/test/generators/test_random_combinatorial_complexes.py @@ -0,0 +1,240 @@ +"""Test the random combinatorial complex generators.""" + +from itertools import combinations + +import pytest + +from toponetx.generators.random_combinatorial_complexes import ( + random_combinatorial_complex, + uniform_random_combinatorial_complex, +) + + +def _cell_set_by_rank(CC, max_rank=10): + """Return cells grouped by rank as sets of frozensets.""" + cells_by_rank = {} + + for rank in range(max_rank + 1): + try: + cells = CC.skeleton(rank) + except Exception: + continue + + cells = {frozenset(cell) for cell in cells} + if cells: + cells_by_rank[rank] = cells + + return cells_by_rank + + +def _all_cells_with_ranks(CC, max_rank=10): + """Return a dictionary mapping each cell to its rank.""" + result = {} + + for rank, cells in _cell_set_by_rank(CC, max_rank=max_rank).items(): + for cell in cells: + result[cell] = rank + + return result + + +class TestUniformRandomCombinatorialComplex: + """Test the `uniform_random_combinatorial_complex` function.""" + + def test_zero_probability(self): + """Test that zero probability only keeps rank-0 cells.""" + CC = uniform_random_combinatorial_complex( + n=5, + p=0.0, + max_rank=3, + max_cell_size=5, + seed=0, + ) + + cells_by_rank = _cell_set_by_rank(CC) + assert 0 in cells_by_rank + assert len(cells_by_rank[0]) == 5 + assert all(len(cell) == 1 for cell in cells_by_rank[0]) + + for rank in cells_by_rank: + if rank > 0: + assert len(cells_by_rank[rank]) == 0 + + def test_seed_reproducibility(self): + """Test that the same seed gives the same complex.""" + CC1 = uniform_random_combinatorial_complex( + n=6, + p=0.4, + max_rank=3, + max_cell_size=4, + seed=123, + ) + CC2 = uniform_random_combinatorial_complex( + n=6, + p=0.4, + max_rank=3, + max_cell_size=4, + seed=123, + ) + + assert _all_cells_with_ranks(CC1) == _all_cells_with_ranks(CC2) + + def test_invalid_probability(self): + """Test invalid probability values.""" + with pytest.raises(ValueError): + uniform_random_combinatorial_complex(n=5, p=-0.1) + + with pytest.raises(ValueError): + uniform_random_combinatorial_complex(n=5, p=1.1) + + +class TestRandomCombinatorialComplex: + """Test the `random_combinatorial_complex` function.""" + + def test_rank_zero_cells_always_present(self): + """Test that all vertices are present as rank-0 cells.""" + n = 7 + CC = random_combinatorial_complex( + n=n, + probs=[0.2, 0.2, 0.2], + max_rank=3, + max_cell_size=5, + seed=0, + ) + + cells_by_rank = _cell_set_by_rank(CC) + assert 0 in cells_by_rank + assert cells_by_rank[0] == {frozenset([i]) for i in range(n)} + + def test_zero_probabilities(self): + """Test that zero probabilities produce only rank-0 cells.""" + CC = random_combinatorial_complex( + n=6, + probs=[0.0, 0.0, 0.0], + max_rank=3, + max_cell_size=6, + seed=0, + ) + + cells_by_rank = _cell_set_by_rank(CC) + assert cells_by_rank[0] == {frozenset([i]) for i in range(6)} + + for rank in cells_by_rank: + if rank > 0: + assert len(cells_by_rank[rank]) == 0 + + def test_max_rank_one_with_probability_one(self): + """Test that rank-1 with probability one adds all subsets of size >= 2.""" + n = 5 + CC = random_combinatorial_complex( + n=n, + probs=[1.0], + max_rank=1, + max_cell_size=n, + seed=0, + ) + + cells_by_rank = _cell_set_by_rank(CC) + + expected_rank_0 = {frozenset([i]) for i in range(n)} + expected_rank_1 = { + frozenset(cell) + for size in range(2, n + 1) + for cell in combinations(range(n), size) + } + + assert cells_by_rank[0] == expected_rank_0 + assert cells_by_rank[1] == expected_rank_1 + + def test_rank_monotonicity_under_inclusion(self): + """Test the combinatorial complex monotonicity condition. + + For any cells x, y with x proper subset of y, rank(x) <= rank(y). + """ + CC = random_combinatorial_complex( + n=7, + probs=[0.8, 0.5, 0.3], + max_rank=3, + max_cell_size=5, + seed=1, + ) + + cell_rank = _all_cells_with_ranks(CC) + + for x, rx in cell_rank.items(): + for y, ry in cell_rank.items(): + if x < y: + assert rx <= ry + + def test_invalid_probs_sequence_length(self): + """Test that too-short probability sequences raise an error.""" + with pytest.raises(ValueError): + random_combinatorial_complex( + n=5, + probs=[0.5], + max_rank=3, + max_cell_size=5, + seed=0, + ) + + def test_invalid_probability_values(self): + """Test invalid probability values in sequences.""" + with pytest.raises(ValueError): + random_combinatorial_complex( + n=5, + probs=[0.2, -0.1, 0.3], + max_rank=3, + max_cell_size=5, + seed=0, + ) + + with pytest.raises(ValueError): + random_combinatorial_complex( + n=5, + probs=[0.2, 1.2, 0.3], + max_rank=3, + max_cell_size=5, + seed=0, + ) + + def test_invalid_n(self): + """Test invalid number of vertices.""" + with pytest.raises(ValueError): + random_combinatorial_complex( + n=0, + probs=[0.5], + max_rank=1, + max_cell_size=1, + seed=0, + ) + + def test_invalid_max_rank(self): + """Test invalid maximum rank.""" + with pytest.raises(ValueError): + random_combinatorial_complex( + n=5, + probs=[0.5], + max_rank=0, + max_cell_size=5, + seed=0, + ) + + def test_invalid_max_cell_size(self): + """Test invalid maximum cell size.""" + with pytest.raises(ValueError): + random_combinatorial_complex( + n=5, + probs=[0.5], + max_rank=1, + max_cell_size=0, + seed=0, + ) + + with pytest.raises(ValueError): + random_combinatorial_complex( + n=5, + probs=[0.5], + max_rank=1, + max_cell_size=6, + seed=0, + ) From 7ac8b9f234ecf76114387a85132bf8c922331456 Mon Sep 17 00:00:00 2001 From: Mustafa Date: Sat, 18 Apr 2026 11:41:50 -0700 Subject: [PATCH 2/2] adding random cc --- .../random_combinatorial_complexes.py | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 toponetx/generators/random_combinatorial_complexes.py diff --git a/toponetx/generators/random_combinatorial_complexes.py b/toponetx/generators/random_combinatorial_complexes.py new file mode 100644 index 00000000..f662bbfc --- /dev/null +++ b/toponetx/generators/random_combinatorial_complexes.py @@ -0,0 +1,216 @@ +"""Generators for random combinatorial complexes.""" + +from __future__ import annotations + +import random +from collections.abc import Sequence +from itertools import combinations + +import networkx as nx + +from toponetx.classes import CombinatorialComplex + +__all__ = [ + "random_combinatorial_complex", + "uniform_random_combinatorial_complex", +] + + +def _normalize_probabilities( + probs: float | Sequence[float], +) -> list[float]: + """Normalize probability input. + + Parameters + ---------- + probs : float or sequence of float + If a float, the same probability is used for every positive rank. + If a sequence, entry ``probs[r - 1]`` is used for rank ``r``. + + Returns + ------- + list[float] + Probabilities indexed by positive rank minus one. + """ + if isinstance(probs, float): + if not 0.0 <= probs <= 1.0: + raise ValueError("Probability must lie in [0, 1].") + return [probs] + + probs = list(probs) + if len(probs) == 0: + raise ValueError("`probs` must be a float or a non-empty sequence.") + if any(p < 0.0 or p > 1.0 for p in probs): + raise ValueError("All probabilities must lie in [0, 1].") + return probs + + +def _is_compatible( + cell: frozenset[int], + rank: int, + existing_cells: dict[frozenset[int], int], +) -> bool: + """Check whether adding ``cell`` at ``rank`` preserves CC monotonicity. + + Since cells are added in increasing rank order, the only possible violation is: + an already existing lower-rank cell strictly contains the candidate cell. + + Parameters + ---------- + cell : frozenset[int] + Candidate cell. + rank : int + Proposed rank. + existing_cells : dict[frozenset[int], int] + Mapping from cells to their assigned ranks. + + Returns + ------- + bool + True if the cell can be added without violating + ``x subset y => rk(x) <= rk(y)``. + """ + if cell in existing_cells: + return False + + for other_cell, other_rank in existing_cells.items(): + if cell < other_cell and rank > other_rank: + return False + + return True + + +def random_combinatorial_complex( + n: int, + probs: float | Sequence[float], + max_rank: int = 3, + max_cell_size: int | None = None, + seed: int | random.Random | None = None, +) -> CombinatorialComplex: + """Generate a random combinatorial complex. + + The construction is intentionally more general than the simplicial case. + It starts with rank-0 cells, then samples higher-rank cells from subsets + of the vertex set while enforcing the combinatorial-complex monotonicity rule: + if ``x`` is contained in ``y``, then ``rank(x) <= rank(y)``. + + Parameters + ---------- + n : int + Number of vertices. + probs : float or sequence of float + Sampling probabilities for positive ranks. + If a float ``p``, the same probability is used for every rank + ``1, ..., max_rank``. + If a sequence, ``probs[r - 1]`` is used for rank ``r``. + max_rank : int, default=3 + Maximum positive rank to generate. + max_cell_size : int, optional + Maximum size of sampled cells. If ``None``, use ``n``. + seed : int, random.Random, or None, optional + Random seed/state. + + Returns + ------- + CombinatorialComplex + A random combinatorial complex. + + Notes + ----- + This is an exhaustive generator over all subsets up to ``max_cell_size``, + so it is intended for small ``n``. For larger problems, a sparse sampler + would be better. + + Examples + -------- + >>> cc = random_combinatorial_complex( + ... n=6, + ... probs=[0.5, 0.25, 0.1], + ... max_rank=3, + ... max_cell_size=4, + ... seed=0, + ... ) + """ + if n <= 0: + raise ValueError("`n` must be positive.") + if max_rank < 1: + raise ValueError("`max_rank` must be at least 1.") + + prob_list = _normalize_probabilities(probs) + if len(prob_list) == 1: + prob_list = prob_list * max_rank + elif len(prob_list) < max_rank: + raise ValueError( + "If `probs` is a sequence, it must have length at least `max_rank`." + ) + + if max_cell_size is None: + max_cell_size = n + if max_cell_size < 1 or max_cell_size > n: + raise ValueError("`max_cell_size` must satisfy 1 <= max_cell_size <= n.") + + rng = nx.utils.create_py_random_state(seed) + + cc = CombinatorialComplex() + existing_cells: dict[frozenset[int], int] = {} + + # Add rank-0 cells explicitly. + for v in range(n): + cell = frozenset([v]) + cc.add_cell([v], rank=0) + existing_cells[cell] = 0 + + vertices = list(range(n)) + + # Add higher-rank cells in increasing rank order. + for rank in range(1, max_rank + 1): + p = prob_list[rank - 1] + + for size in range(2, max_cell_size + 1): + for cell_tuple in combinations(vertices, size): + cell = frozenset(cell_tuple) + + if not _is_compatible(cell, rank, existing_cells): + continue + + if rng.random() < p: + cc.add_cell(cell_tuple, rank=rank) + existing_cells[cell] = rank + + return cc + + +def uniform_random_combinatorial_complex( + n: int, + p: float, + max_rank: int = 3, + max_cell_size: int | None = None, + seed: int | random.Random | None = None, +) -> CombinatorialComplex: + """Generate a random combinatorial complex with one probability for all ranks. + + Parameters + ---------- + n : int + Number of vertices. + p : float + Sampling probability for every positive rank. + max_rank : int, default=3 + Maximum positive rank to generate. + max_cell_size : int, optional + Maximum sampled cell size. If ``None``, use ``n``. + seed : int, random.Random, or None, optional + Random seed/state. + + Returns + ------- + CombinatorialComplex + A random combinatorial complex. + """ + return random_combinatorial_complex( + n=n, + probs=p, + max_rank=max_rank, + max_cell_size=max_cell_size, + seed=seed, + )