ffspaces 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,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: ffspaces
3
+ Version: 0.2.0
4
+ Summary: A computational laboratory for finite field vector spaces, Hamming balls, and sumset structural analysis.
5
+ Author: Nathan Hoehndorf
6
+ Project-URL: Homepage, https://github.com/nathanhoehndorf/finite-field-spaces
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: numpy>=1.24.0
14
+ Provides-Extra: test
15
+ Requires-Dist: pytest>=7.0; extra == "test"
16
+
17
+ # Finite-Field Spaces (`ffspaces`)
18
+
19
+ `ffspaces` is a lightweight Python toolkit for working with vectors over finite fields $\mathbb{F}_p^n$, with a focus on sumset analysis, linear-subspace structure, and Hamming-ball geometry.
20
+
21
+ The package is designed for small-to-medium computational experiments in additive combinatorics, coding theory, and related discrete geometry settings. It provides both direct implementations and faster specialized routines for common operations over finite fields.
22
+
23
+ ## Current functionality
24
+
25
+ - **Finite-field vector spaces:** generate the full vector space $\mathbb{F}_p^n$ or construct random invertible basis changes.
26
+ - **Finite-field rank:** compute matrix rank over $\mathbb{F}_p$ using modular Gaussian elimination.
27
+ - **Sumsets:** compute $S+S$ for a set of vectors over $\mathbb{F}_p^n$, including a direct reference method and a faster FWHT-based implementation.
28
+ - **Subspace detection:** find the largest linear subspace contained in a sumset, with both greedy and exhaustive search modes.
29
+ - **Hamming-ball geometry:** generate standard Hamming balls, compute Hamming weights, and build balls under arbitrary linear transforms.
30
+ - **Cover constructions:** build unions of Hamming balls and compute complements relative to a universe.
31
+
32
+ ## Features
33
+
34
+ - **NumPy-based operations:** vectorized generation and manipulation of finite-field vectors.
35
+ - **Field-aware linear algebra:** rank and invertibility checks performed over $\mathbb{F}_p$, not over the reals.
36
+ - **Flexible geometry tools:** support for binary and higher-characteristic experiments.
37
+ - **Reusable experiment helpers:** scripts and utilities in the `experiments/` directory for one-off analysis.
38
+
39
+ ## Installation
40
+
41
+ Clone the repository and install it in editable mode:
42
+
43
+ ```bash
44
+ git clone https://github.com/nathanhoehndorf/finite-field-spaces
45
+ cd finite-field-spaces
46
+ pip install -e .
47
+ ```
48
+
49
+ The package requires Python 3.10+ and NumPy.
50
+
51
+ ## Mathematical background
52
+
53
+ This package works in the vector space $\mathbb{F}_p^n$, where each coordinate is taken modulo a prime $p$. In particular, $\mathbb{F}_2^n$ is the set of binary vectors of length $n$, with addition done coordinatewise modulo $2$.
54
+
55
+ - **What is $\mathbb{F}_p^n$?** A finite-dimensional vector space over the finite field with $p$ elements. For $p=2$, this is the space of binary vectors.
56
+ - **What is $S+S$?** For a set $S \subseteq \mathbb{F}_p^n$, the sumset $S+S$ is the set of all pairwise sums $x+y$ with $x,y \in S$, taken modulo $p$.
57
+ - **What is a Hamming ball?** A Hamming ball is the set of vectors within a given distance from a center, where distance is the number of coordinates in which two vectors differ (known as Hamming weight, or the Hamming metric).
58
+ - **What is the covering problem?** A covering problem asks whether a collection of balls (or other subsets) can be chosen so that their union contains all points in a given universe, or perhaps all points of a particular structure.
59
+ - **Why does this matter?** These objects are central in additive combinatorics, coding theory, and discrete geometry, and they provide a natural setting for studying how small sets expand under addition or how geometric configurations cover a space.
60
+
61
+
62
+ ## Quick example
63
+
64
+ ```python
65
+ import numpy as np
66
+ from ffspaces import generate_space, compute_sumset, find_maximum_subspace_dimension
67
+
68
+ space = generate_space(3, p=2)
69
+ subset = space[[0, 1, 2, 4]]
70
+ sumset = compute_sumset(subset, p=2)
71
+ dimension = find_maximum_subspace_dimension(sumset, p=2)
72
+ print(dimension)
73
+ ```
74
+
75
+ ## Experiments
76
+
77
+ One-off analysis and experiment scripts live in the `experiments/` directory so they stay separate from the installable package code in `src/`.
@@ -0,0 +1,61 @@
1
+ # Finite-Field Spaces (`ffspaces`)
2
+
3
+ `ffspaces` is a lightweight Python toolkit for working with vectors over finite fields $\mathbb{F}_p^n$, with a focus on sumset analysis, linear-subspace structure, and Hamming-ball geometry.
4
+
5
+ The package is designed for small-to-medium computational experiments in additive combinatorics, coding theory, and related discrete geometry settings. It provides both direct implementations and faster specialized routines for common operations over finite fields.
6
+
7
+ ## Current functionality
8
+
9
+ - **Finite-field vector spaces:** generate the full vector space $\mathbb{F}_p^n$ or construct random invertible basis changes.
10
+ - **Finite-field rank:** compute matrix rank over $\mathbb{F}_p$ using modular Gaussian elimination.
11
+ - **Sumsets:** compute $S+S$ for a set of vectors over $\mathbb{F}_p^n$, including a direct reference method and a faster FWHT-based implementation.
12
+ - **Subspace detection:** find the largest linear subspace contained in a sumset, with both greedy and exhaustive search modes.
13
+ - **Hamming-ball geometry:** generate standard Hamming balls, compute Hamming weights, and build balls under arbitrary linear transforms.
14
+ - **Cover constructions:** build unions of Hamming balls and compute complements relative to a universe.
15
+
16
+ ## Features
17
+
18
+ - **NumPy-based operations:** vectorized generation and manipulation of finite-field vectors.
19
+ - **Field-aware linear algebra:** rank and invertibility checks performed over $\mathbb{F}_p$, not over the reals.
20
+ - **Flexible geometry tools:** support for binary and higher-characteristic experiments.
21
+ - **Reusable experiment helpers:** scripts and utilities in the `experiments/` directory for one-off analysis.
22
+
23
+ ## Installation
24
+
25
+ Clone the repository and install it in editable mode:
26
+
27
+ ```bash
28
+ git clone https://github.com/nathanhoehndorf/finite-field-spaces
29
+ cd finite-field-spaces
30
+ pip install -e .
31
+ ```
32
+
33
+ The package requires Python 3.10+ and NumPy.
34
+
35
+ ## Mathematical background
36
+
37
+ This package works in the vector space $\mathbb{F}_p^n$, where each coordinate is taken modulo a prime $p$. In particular, $\mathbb{F}_2^n$ is the set of binary vectors of length $n$, with addition done coordinatewise modulo $2$.
38
+
39
+ - **What is $\mathbb{F}_p^n$?** A finite-dimensional vector space over the finite field with $p$ elements. For $p=2$, this is the space of binary vectors.
40
+ - **What is $S+S$?** For a set $S \subseteq \mathbb{F}_p^n$, the sumset $S+S$ is the set of all pairwise sums $x+y$ with $x,y \in S$, taken modulo $p$.
41
+ - **What is a Hamming ball?** A Hamming ball is the set of vectors within a given distance from a center, where distance is the number of coordinates in which two vectors differ (known as Hamming weight, or the Hamming metric).
42
+ - **What is the covering problem?** A covering problem asks whether a collection of balls (or other subsets) can be chosen so that their union contains all points in a given universe, or perhaps all points of a particular structure.
43
+ - **Why does this matter?** These objects are central in additive combinatorics, coding theory, and discrete geometry, and they provide a natural setting for studying how small sets expand under addition or how geometric configurations cover a space.
44
+
45
+
46
+ ## Quick example
47
+
48
+ ```python
49
+ import numpy as np
50
+ from ffspaces import generate_space, compute_sumset, find_maximum_subspace_dimension
51
+
52
+ space = generate_space(3, p=2)
53
+ subset = space[[0, 1, 2, 4]]
54
+ sumset = compute_sumset(subset, p=2)
55
+ dimension = find_maximum_subspace_dimension(sumset, p=2)
56
+ print(dimension)
57
+ ```
58
+
59
+ ## Experiments
60
+
61
+ One-off analysis and experiment scripts live in the `experiments/` directory so they stay separate from the installable package code in `src/`.
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ffspaces"
7
+ version = "0.2.0"
8
+ authors = [{name="Nathan Hoehndorf"}]
9
+ description = "A computational laboratory for finite field vector spaces, Hamming balls, and sumset structural analysis."
10
+ readme = "README.md"
11
+ requires-python = ">=3.10"
12
+ classifiers = [
13
+ "Programming Language :: Python :: 3",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Operating System :: OS Independent",
16
+ "Topic :: Scientific/Engineering :: Mathematics"]
17
+ dependencies = ["numpy>=1.24.0"]
18
+ optional-dependencies = { test = ["pytest>=7.0"] }
19
+ [project.urls]
20
+ "Homepage" = "https://github.com/nathanhoehndorf/finite-field-spaces"
21
+
22
+ [tool.setuptools]
23
+ license-files = []
24
+
25
+ [tool.setuptools.package-dir]
26
+ "" = "src"
27
+
28
+ [tool.setuptools.packages.find]
29
+ where = ["src"]
30
+ include = ["ffspaces*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,27 @@
1
+ from .core import generate_space, generate_random_basis, is_invertible
2
+ from .fwht_operators import vectors_to_ints, ints_to_vectors, compute_sumset_fwht
3
+ from .operators import (
4
+ _compute_sumset_original,
5
+ compute_sumset,
6
+ compare_sumset_methods,
7
+ find_maximum_subspace_dimension,
8
+ )
9
+ from .covers import generate_covering, complement
10
+ from .geometries import compute_hamming_weight, generate_hamming_ball, generate_standard_ball
11
+
12
+ __all__ = [
13
+ "generate_space",
14
+ "generate_random_basis",
15
+ "is_invertible",
16
+ "vectors_to_ints",
17
+ "ints_to_vectors",
18
+ "compute_sumset_fwht",
19
+ "_compute_sumset_original",
20
+ "compute_sumset",
21
+ "compare_sumset_methods",
22
+ "find_maximum_subspace_dimension",
23
+ "generate_covering",
24
+ "complement",
25
+ "compute_hamming_weight",
26
+ "generate_hamming_ball",
27
+ ]
@@ -0,0 +1,75 @@
1
+ import itertools
2
+
3
+ import numpy as np
4
+
5
+
6
+ def generate_space(n: int, p: int = 2) -> np.ndarray:
7
+ """
8
+ Generates all p^n vectors in the vector space F_p^n.
9
+ Returns a 2D numpy array of shape (p^n, n).
10
+ """
11
+ elements = list(itertools.product(range(p), repeat=n))
12
+ return np.array(elements, dtype=np.int8)
13
+
14
+ def rank_mod_p(matrix, p: int = 2) -> int:
15
+ """
16
+ Computes the rank of `matrix` over the finite field F_p using Guassian elimination
17
+ with modular arithmetic. Works for any prime p; matrix need not be square
18
+ """
19
+ if p<=1:
20
+ raise ValueError("p must be a prime greater than 1")
21
+
22
+ A = np.array(matrix, dtype=np.int64, copy=True) % p
23
+ if A.ndim != 2:
24
+ raise ValueError("matrix must be 2-dimensional")
25
+
26
+ m, n = A.shape
27
+ rank = 0
28
+ for col in range(n):
29
+ pivot_row = None
30
+ for row in range(rank, m):
31
+ if A[row, col] % p != 0:
32
+ pivot_row = row
33
+ break
34
+ if pivot_row is None:
35
+ continue
36
+
37
+ if pivot_row != rank:
38
+ A[[rank, pivot_row], :] = A[[pivot_row, rank], :]
39
+
40
+ pivot_inverse = pow(int(A[rank, col]), -1, p)
41
+ for row in range(m):
42
+ if row == rank:
43
+ continue
44
+ factor = (A[row, col] * pivot_inverse) % p
45
+ if factor != 0:
46
+ A[row, :] = (A[row, :] - factor * A[rank, :]) % p
47
+
48
+ rank += 1
49
+ if rank == min(m, n):
50
+ break
51
+
52
+ return rank
53
+
54
+ def is_invertible(matrix: np.ndarray, p: int = 2) -> bool:
55
+ """Checks if an n x n matrix is invertible over the finite field F_p."""
56
+ matrix = np.asarray(matrix)
57
+ if matrix.ndim != 2 or matrix.shape[0] != matrix.shape[1]:
58
+ return False
59
+ return rank_mod_p(matrix, p) == matrix.shape[0]
60
+
61
+
62
+ def generate_random_basis(n: int, p: int = 2, rng=None) -> np.ndarray:
63
+ """Generates a random invertible n x n matrix over F_p to represent a basis change."""
64
+ if rng is None:
65
+ rng = np.random.default_rng()
66
+
67
+ while True:
68
+ matrix = rng.integers(0, p, size=(n, n)).astype(np.int8)
69
+ if is_invertible(matrix, p):
70
+ return matrix
71
+
72
+
73
+
74
+
75
+
@@ -0,0 +1,120 @@
1
+ from typing import Optional, Sequence
2
+
3
+ import numpy as np
4
+
5
+ from .fwht_operators import vectors_to_ints
6
+ from .geometries import generate_hamming_ball
7
+
8
+
9
+ def _generate_weight_ball(n: int, radius: int) -> np.ndarray:
10
+ """
11
+ Generate all binary vectors in F_2^n with Hamming weight <= radius.
12
+ """
13
+ import itertools
14
+
15
+ vectors = []
16
+ for weight in range(radius + 1):
17
+ for combo in itertools.combinations(range(n), weight):
18
+ vec = np.zeros(n, dtype=np.int8)
19
+ vec[list(combo)] = 1
20
+ vectors.append(vec)
21
+ if len(vectors) == 0:
22
+ return np.empty((0, n), dtype=np.int8)
23
+ return np.array(vectors, dtype=np.int8)
24
+
25
+
26
+ def generate_covering(
27
+ centers: Sequence[np.ndarray],
28
+ radii,
29
+ bases: Optional[Sequence[np.ndarray]] = None,
30
+ p: int = 2,
31
+ universe: Optional[np.ndarray] = None,
32
+ ) -> np.ndarray:
33
+ """
34
+ Produce the union of Hamming balls defined by `centers`, `radii` and `bases`.
35
+
36
+ - `centers`: sequence of center vectors (each length n)
37
+ - `radii`: either a single int or sequence matching centers
38
+ - `bases`: either None, a single linear transform, or sequence of linear transforms
39
+ - `universe`: optional full universe array; when provided `generate_hamming_ball`
40
+ will be used (supports arbitrary p). When omitted and p==2 a combinatorial
41
+ weight-based generation is used for speed.
42
+
43
+ Returns a numpy array of unique covered vectors (dtype=int8) with shape (M,n).
44
+ """
45
+ if isinstance(radii, int):
46
+ radii = [radii] * len(centers)
47
+ if bases is None:
48
+ bases = [None] * len(centers)
49
+ if not (len(centers) == len(radii) == len(bases)):
50
+ raise ValueError("centers, radii and bases must have the same length")
51
+
52
+ centers = [np.array(c, dtype=np.int8) for c in centers]
53
+ n = centers[0].size if len(centers) > 0 else 0
54
+
55
+ covered_ints = None
56
+ covered_rows = []
57
+
58
+ if universe is None and p == 2:
59
+ weight_balls = {}
60
+ for c, r, B in zip(centers, radii, bases):
61
+ if r not in weight_balls:
62
+ weight_balls[r] = _generate_weight_ball(n, r)
63
+ W_r = weight_balls[r]
64
+ if B is None:
65
+ A = W_r.copy()
66
+ else:
67
+ A = (W_r @ B.T) % 2
68
+ ball = (A ^ c).astype(np.int8)
69
+ ints = vectors_to_ints(ball)
70
+ if covered_ints is None:
71
+ covered_ints = ints
72
+ else:
73
+ covered_ints = np.concatenate([covered_ints, ints])
74
+
75
+ if covered_ints is None:
76
+ return np.empty((0, n), dtype=np.int8)
77
+
78
+ unique_ints = np.unique(covered_ints)
79
+ from .fwht_operators import ints_to_vectors
80
+
81
+ return ints_to_vectors(unique_ints, n)
82
+
83
+ for c, r, B in zip(centers, radii, bases):
84
+ ball = generate_hamming_ball(universe, c, r, B, p)
85
+ covered_rows.append(ball.astype(np.int8))
86
+
87
+ if len(covered_rows) == 0:
88
+ return np.empty((0, n), dtype=np.int8)
89
+
90
+ all_covered = np.vstack(covered_rows)
91
+ if p == 2:
92
+ ints = vectors_to_ints(all_covered)
93
+ unique_ints = np.unique(ints)
94
+ from .fwht_operators import ints_to_vectors
95
+
96
+ return ints_to_vectors(unique_ints, all_covered.shape[1])
97
+ else:
98
+ return np.unique(all_covered, axis=0)
99
+
100
+
101
+ def complement(universe: np.ndarray, covered: np.ndarray) -> np.ndarray:
102
+ """
103
+ Return rows of `universe` that are not in `covered`.
104
+ Both inputs are arrays of vectors (rows). Result preserves order from `universe`.
105
+ """
106
+ if len(universe) == 0:
107
+ return universe.copy()
108
+ if len(covered) == 0:
109
+ return universe.copy()
110
+
111
+ n = universe.shape[1]
112
+ try:
113
+ covered_ints = vectors_to_ints(covered)
114
+ universe_ints = vectors_to_ints(universe)
115
+ covered_set = set(covered_ints.tolist())
116
+ mask = [i not in covered_set for i in universe_ints.tolist()]
117
+ return universe[np.array(mask, dtype=bool)]
118
+ except Exception:
119
+ covered_set = {tuple(row) for row in covered}
120
+ return np.array([row for row in universe if tuple(row) not in covered_set], dtype=np.int8)
@@ -0,0 +1,97 @@
1
+ import numpy as np
2
+
3
+
4
+ def vectors_to_ints(vectors: np.ndarray, p: int = 2) -> np.ndarray:
5
+ """
6
+ Converts a matrix of Z_p^n vectors (shape M x n) to a 1d array of integers.
7
+ """
8
+ if len(vectors) == 0:
9
+ return np.array([], dtype=np.int64)
10
+
11
+ if p <= 1:
12
+ raise ValueError("p must be at least 2")
13
+
14
+ n = vectors.shape[1]
15
+ if np.any(vectors < 0) or np.any(vectors >= p):
16
+ raise ValueError("All vector entries must lie in {0, ..., p-1}")
17
+
18
+ powers = p ** np.arange(n - 1, -1, -1, dtype=np.int64)
19
+ return np.dot(vectors.astype(np.int64), powers).astype(np.int64)
20
+
21
+
22
+ def ints_to_vectors(ints: np.ndarray, n: int, p: int = 2) -> np.ndarray:
23
+ """
24
+ Converts a 1d array of integers back to a matrix of Z_p^n vectors.
25
+ """
26
+ if p <= 1:
27
+ raise ValueError("p must be at least 2")
28
+
29
+ if len(ints) == 0:
30
+ return np.empty((0, n), dtype=np.int8)
31
+
32
+ ints_expanded = ints.astype(np.int64)[:, np.newaxis]
33
+ digits = np.empty((len(ints), n), dtype=np.int8)
34
+ remaining = ints_expanded.copy()
35
+
36
+ for axis in range(n - 1, -1, -1):
37
+ digits[:, axis] = (remaining[:, 0] % p).astype(np.int8)
38
+ remaining = remaining // p
39
+
40
+ return digits
41
+
42
+
43
+ def fwht(a: np.ndarray) -> np.ndarray:
44
+ """
45
+ In-place Fast Walsh-Hadamard Transform of 1d array `a`.
46
+ Array size must be exactly 2^n. Fully vectorized in NumPy.
47
+ """
48
+ n_elements = len(a)
49
+ n_bits = int(np.log2(n_elements))
50
+
51
+ if 1 << n_bits != n_elements:
52
+ raise ValueError("Array length must be a power of 2")
53
+
54
+ res = a.astype(np.float64)
55
+
56
+ for d in range(n_bits):
57
+ half_step = 1 << d
58
+ shape = (-1, 2, half_step)
59
+ res_reshaped = res.reshape(shape)
60
+
61
+ a0 = res_reshaped[:, 0, :].copy()
62
+ a1 = res_reshaped[:, 1, :].copy()
63
+
64
+ res_reshaped[:, 0, :] = a0 + a1
65
+ res_reshaped[:, 1, :] = a0 - a1
66
+
67
+ return res
68
+
69
+
70
+ def compute_sumset_fwht(set_elements: np.ndarray, p: int = 2) -> np.ndarray:
71
+ """
72
+ Computes S+S over Z_p^n using the Fourier transform on the finite abelian group.
73
+ Time Complexity: O(p^n * n * log p) for a dense transform over the universe size p^n.
74
+ Space Complexity: O(p^n)
75
+ """
76
+ if len(set_elements) == 0:
77
+ return np.empty((0, set_elements.shape[1]), dtype=np.int8)
78
+
79
+ if p <= 1:
80
+ raise ValueError("p must be at least 2")
81
+
82
+ n = set_elements.shape[1]
83
+ universe_size = p ** n
84
+
85
+ ints = vectors_to_ints(set_elements, p=p)
86
+
87
+ indicator = np.zeros((p,) * n, dtype=np.complex128)
88
+ indicator.reshape(-1)[ints] = 1.0
89
+
90
+ transformed = np.fft.fftn(indicator, axes=tuple(range(n)))
91
+ transformed_squared = transformed * transformed
92
+
93
+ convolution = np.fft.ifftn(transformed_squared, axes=tuple(range(n))).real
94
+ sumset_ints = np.flatnonzero(np.rint(convolution).astype(np.int64) > 0)
95
+ sumset_vectors = ints_to_vectors(sumset_ints, n, p=p)
96
+
97
+ return sumset_vectors
@@ -0,0 +1,46 @@
1
+ import itertools
2
+
3
+ import numpy as np
4
+
5
+
6
+ def generate_standard_ball(n: int, r: int) -> np.ndarray:
7
+ """
8
+ Generates all vectors in F_2^n with Hamming weight <= r.
9
+ """
10
+ vectors = []
11
+ for weight in range(r + 1):
12
+ for combo in itertools.combinations(range(n), weight):
13
+ vec = np.zeros(n, dtype=np.int8)
14
+ vec[list(combo)] = 1
15
+ vectors.append(vec)
16
+ return np.array(vectors, dtype=np.int8)
17
+
18
+
19
+ def compute_hamming_weight(vectors: np.ndarray) -> np.ndarray:
20
+ """
21
+ Computes the Hamming weight for an array of vectors.
22
+ Input shape: (M, n) -> Output shape (M,)
23
+ """
24
+ return np.count_nonzero(vectors, axis=1)
25
+
26
+
27
+ def generate_hamming_ball(
28
+ universe: np.ndarray,
29
+ center: np.ndarray,
30
+ radius: int,
31
+ linear_transform: np.ndarray,
32
+ p: int = 2,
33
+ ) -> np.ndarray:
34
+ """
35
+ Generates a Hamming ball centered at `center` with a given `radius`
36
+ under the basis defined by `linear_transform` over F_p^n.
37
+
38
+ B_{L}(v, r)= { x in F_p^n : weight( L(x-v) mod p ) <= r }
39
+ """
40
+ shifted = (universe - center) % p
41
+ transformed = (shifted @ linear_transform.T) % p
42
+
43
+ weights = compute_hamming_weight(transformed)
44
+ mask = weights <= radius
45
+
46
+ return universe[mask]
@@ -0,0 +1,146 @@
1
+ from itertools import combinations, product
2
+ from typing import Optional, Tuple
3
+
4
+ import numpy as np
5
+
6
+ from .core import rank_mod_p
7
+ from .fwht_operators import compute_sumset_fwht
8
+
9
+
10
+ def _compute_sumset_original(set_elements: np.ndarray, p: int = 2) -> np.ndarray:
11
+ """
12
+ Computes the unique elements of the sumset S + S over F_p
13
+ using the direct broadcast-based approach.
14
+ """
15
+ if len(set_elements) == 0:
16
+ return np.empty((0, set_elements.shape[1]), dtype=np.int8)
17
+
18
+ broadcasted_sum = (set_elements[:, np.newaxis, :] + set_elements[np.newaxis, :, :]) % p
19
+ reshaped_sums = broadcasted_sum.reshape(-1, set_elements.shape[1])
20
+
21
+ return np.unique(reshaped_sums, axis=0)
22
+
23
+
24
+ def compute_sumset(set_elements: np.ndarray, p: int = 2) -> np.ndarray:
25
+ """
26
+ Computes the unique elements of the sumset S + S over Z_p^n.
27
+ This uses the generalized Fourier-transform fast path for all p >= 2.
28
+ """
29
+ if p <= 1:
30
+ raise ValueError("p must be at least 2")
31
+
32
+ return compute_sumset_fwht(set_elements, p=p)
33
+
34
+
35
+ def compare_sumset_methods(n: int = 6, subset_size: int = 10, seed: Optional[int] = None) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
36
+ """
37
+ Generates a random subset of F_2^n and compares the FWHT-based sumset against
38
+ the original direct implementation.
39
+ """
40
+ if n < 1:
41
+ raise ValueError("n must be at least 1")
42
+ if subset_size < 1:
43
+ raise ValueError("subset_size must be at least 1")
44
+
45
+ universe = np.array(list(product([0, 1], repeat=n)), dtype=np.int8)
46
+ rng = np.random.default_rng(seed)
47
+ subset_indices = rng.choice(len(universe), size=subset_size, replace=False)
48
+ subset = universe[subset_indices]
49
+
50
+ fwht_sumset = compute_sumset(subset, p=2)
51
+ original_sumset = _compute_sumset_original(subset, p=2)
52
+
53
+ return subset, fwht_sumset, original_sumset
54
+
55
+
56
+ def _find_maximum_subspace_dimension_greedy(sumset: np.ndarray, p: int = 2) -> int:
57
+ """Greedily build a maximal set of independent generators whose span is contained in sumset."""
58
+ n = sumset.shape[1]
59
+ sumset_set = {tuple(row) for row in sumset}
60
+ independent_generators = []
61
+
62
+ for candidate in sumset:
63
+ if np.all(candidate == 0):
64
+ continue
65
+
66
+ valid_generator = True
67
+ current_span = [np.zeros(n, dtype=np.int8)]
68
+
69
+ for gen in independent_generators:
70
+ current_span += [(gen * k + s) % p for k in range(1, p) for s in current_span]
71
+
72
+ for vec in current_span:
73
+ for k in range(1, p):
74
+ test_vec = (vec + k * candidate) % p
75
+ if tuple(test_vec) not in sumset_set:
76
+ valid_generator = False
77
+ break
78
+ if not valid_generator:
79
+ break
80
+
81
+ if valid_generator:
82
+ test_matrix = np.array(independent_generators + [candidate])
83
+ if rank_mod_p(test_matrix, p) == len(independent_generators) + 1:
84
+ independent_generators.append(candidate)
85
+
86
+ return len(independent_generators)
87
+
88
+
89
+ def find_maximum_subspace_dimension(
90
+ sumset: np.ndarray,
91
+ p: int = 2,
92
+ exhaustive: bool = False,
93
+ max_combinations: Optional[int] = 10_000,
94
+ ) -> int:
95
+ """
96
+ Finds the dimension of the largest linear subspace completely contained within S+S.
97
+ By default uses a greedy iterative check of linearly independent generators. If
98
+ `exhaustive=True` it will search combinations of generators (starting from the
99
+ largest possible dimension) to find the maximum dimension whose span is fully
100
+ contained in `sumset`. A `max_combinations` limit can be supplied to avoid
101
+ combinatorial blowups in large experiments; if reached, the greedy result is returned.
102
+ """
103
+ n = sumset.shape[1]
104
+ sumset_set = {tuple(row) for row in sumset}
105
+
106
+ if tuple(np.zeros(n, dtype=np.int8)) not in sumset_set:
107
+ return -1
108
+
109
+ max_possible_d = 0
110
+ size = len(sumset)
111
+ if size > 0:
112
+ while p ** (max_possible_d + 1) <= size and max_possible_d < n:
113
+ max_possible_d += 1
114
+
115
+ greedy_dimension = _find_maximum_subspace_dimension_greedy(sumset, p)
116
+
117
+ if exhaustive:
118
+ checked_combinations = 0
119
+ for d in range(max_possible_d, 0, -1):
120
+ nonzero_rows = [row for row in sumset if not np.all(row == 0)]
121
+ for combo in combinations(nonzero_rows, d):
122
+ if max_combinations is not None and checked_combinations >= max_combinations:
123
+ return greedy_dimension
124
+
125
+ checked_combinations += 1
126
+ mat = np.vstack(combo)
127
+ if rank_mod_p(mat, p) != d:
128
+ continue
129
+
130
+ all_in_sumset = True
131
+ for coeffs in product(range(p), repeat=d):
132
+ if all(c == 0 for c in coeffs):
133
+ vec = tuple(np.zeros(n, dtype=np.int8))
134
+ else:
135
+ vec_arr = sum((coeffs[i] * mat[i]) for i in range(d)) % p
136
+ vec = tuple(vec_arr.astype(np.int8))
137
+ if vec not in sumset_set:
138
+ all_in_sumset = False
139
+ break
140
+
141
+ if all_in_sumset:
142
+ return d
143
+
144
+ return 0
145
+
146
+ return greedy_dimension
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: ffspaces
3
+ Version: 0.2.0
4
+ Summary: A computational laboratory for finite field vector spaces, Hamming balls, and sumset structural analysis.
5
+ Author: Nathan Hoehndorf
6
+ Project-URL: Homepage, https://github.com/nathanhoehndorf/finite-field-spaces
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: numpy>=1.24.0
14
+ Provides-Extra: test
15
+ Requires-Dist: pytest>=7.0; extra == "test"
16
+
17
+ # Finite-Field Spaces (`ffspaces`)
18
+
19
+ `ffspaces` is a lightweight Python toolkit for working with vectors over finite fields $\mathbb{F}_p^n$, with a focus on sumset analysis, linear-subspace structure, and Hamming-ball geometry.
20
+
21
+ The package is designed for small-to-medium computational experiments in additive combinatorics, coding theory, and related discrete geometry settings. It provides both direct implementations and faster specialized routines for common operations over finite fields.
22
+
23
+ ## Current functionality
24
+
25
+ - **Finite-field vector spaces:** generate the full vector space $\mathbb{F}_p^n$ or construct random invertible basis changes.
26
+ - **Finite-field rank:** compute matrix rank over $\mathbb{F}_p$ using modular Gaussian elimination.
27
+ - **Sumsets:** compute $S+S$ for a set of vectors over $\mathbb{F}_p^n$, including a direct reference method and a faster FWHT-based implementation.
28
+ - **Subspace detection:** find the largest linear subspace contained in a sumset, with both greedy and exhaustive search modes.
29
+ - **Hamming-ball geometry:** generate standard Hamming balls, compute Hamming weights, and build balls under arbitrary linear transforms.
30
+ - **Cover constructions:** build unions of Hamming balls and compute complements relative to a universe.
31
+
32
+ ## Features
33
+
34
+ - **NumPy-based operations:** vectorized generation and manipulation of finite-field vectors.
35
+ - **Field-aware linear algebra:** rank and invertibility checks performed over $\mathbb{F}_p$, not over the reals.
36
+ - **Flexible geometry tools:** support for binary and higher-characteristic experiments.
37
+ - **Reusable experiment helpers:** scripts and utilities in the `experiments/` directory for one-off analysis.
38
+
39
+ ## Installation
40
+
41
+ Clone the repository and install it in editable mode:
42
+
43
+ ```bash
44
+ git clone https://github.com/nathanhoehndorf/finite-field-spaces
45
+ cd finite-field-spaces
46
+ pip install -e .
47
+ ```
48
+
49
+ The package requires Python 3.10+ and NumPy.
50
+
51
+ ## Mathematical background
52
+
53
+ This package works in the vector space $\mathbb{F}_p^n$, where each coordinate is taken modulo a prime $p$. In particular, $\mathbb{F}_2^n$ is the set of binary vectors of length $n$, with addition done coordinatewise modulo $2$.
54
+
55
+ - **What is $\mathbb{F}_p^n$?** A finite-dimensional vector space over the finite field with $p$ elements. For $p=2$, this is the space of binary vectors.
56
+ - **What is $S+S$?** For a set $S \subseteq \mathbb{F}_p^n$, the sumset $S+S$ is the set of all pairwise sums $x+y$ with $x,y \in S$, taken modulo $p$.
57
+ - **What is a Hamming ball?** A Hamming ball is the set of vectors within a given distance from a center, where distance is the number of coordinates in which two vectors differ (known as Hamming weight, or the Hamming metric).
58
+ - **What is the covering problem?** A covering problem asks whether a collection of balls (or other subsets) can be chosen so that their union contains all points in a given universe, or perhaps all points of a particular structure.
59
+ - **Why does this matter?** These objects are central in additive combinatorics, coding theory, and discrete geometry, and they provide a natural setting for studying how small sets expand under addition or how geometric configurations cover a space.
60
+
61
+
62
+ ## Quick example
63
+
64
+ ```python
65
+ import numpy as np
66
+ from ffspaces import generate_space, compute_sumset, find_maximum_subspace_dimension
67
+
68
+ space = generate_space(3, p=2)
69
+ subset = space[[0, 1, 2, 4]]
70
+ sumset = compute_sumset(subset, p=2)
71
+ dimension = find_maximum_subspace_dimension(sumset, p=2)
72
+ print(dimension)
73
+ ```
74
+
75
+ ## Experiments
76
+
77
+ One-off analysis and experiment scripts live in the `experiments/` directory so they stay separate from the installable package code in `src/`.
@@ -0,0 +1,16 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/ffspaces/__init__.py
4
+ src/ffspaces/core.py
5
+ src/ffspaces/covers.py
6
+ src/ffspaces/fwht_operators.py
7
+ src/ffspaces/geometries.py
8
+ src/ffspaces/operators.py
9
+ src/ffspaces.egg-info/PKG-INFO
10
+ src/ffspaces.egg-info/SOURCES.txt
11
+ src/ffspaces.egg-info/dependency_links.txt
12
+ src/ffspaces.egg-info/requires.txt
13
+ src/ffspaces.egg-info/top_level.txt
14
+ tests/test_core_and_operators.py
15
+ tests/test_fwht_sumset_equivalence.py
16
+ tests/test_public_api.py
@@ -0,0 +1,4 @@
1
+ numpy>=1.24.0
2
+
3
+ [test]
4
+ pytest>=7.0
@@ -0,0 +1 @@
1
+ ffspaces
@@ -0,0 +1,142 @@
1
+ import numpy as np
2
+ import pytest
3
+
4
+ from ffspaces.core import generate_space, generate_random_basis, is_invertible, rank_mod_p
5
+ from ffspaces.operators import (
6
+ _compute_sumset_original,
7
+ compute_sumset,
8
+ find_maximum_subspace_dimension,
9
+ )
10
+ from ffspaces.geometries import generate_standard_ball
11
+
12
+
13
+ @pytest.mark.parametrize(
14
+ ("n", "p", "expected"),
15
+ [
16
+ (0, 2, 1),
17
+ (1, 2, 2),
18
+ (2, 2, 4),
19
+ (3, 3, 27),
20
+ ],
21
+ )
22
+ def test_generate_space_edge_cases(n, p, expected):
23
+ space = generate_space(n, p=p)
24
+ assert space.shape == (expected, n)
25
+ assert np.array_equal(space[0], np.zeros(n, dtype=np.int8))
26
+
27
+
28
+ def test_generate_space_full_space_contains_all_vectors():
29
+ space = generate_space(3, p=2)
30
+ assert set(map(tuple, space)) == {
31
+ (0, 0, 0),
32
+ (0, 0, 1),
33
+ (0, 1, 0),
34
+ (0, 1, 1),
35
+ (1, 0, 0),
36
+ (1, 0, 1),
37
+ (1, 1, 0),
38
+ (1, 1, 1),
39
+ }
40
+
41
+
42
+ def test_is_invertible_edge_cases():
43
+ assert is_invertible(np.array([[1]], dtype=np.int8), p=2)
44
+ assert not is_invertible(np.array([[0]], dtype=np.int8), p=2)
45
+ assert not is_invertible(np.array([[1, 0], [0, 0]], dtype=np.int8), p=2)
46
+ assert is_invertible(np.array([[1, 1], [1, 0]], dtype=np.int8), p=2)
47
+
48
+
49
+ def test_compute_sumset_handles_empty_and_singleton_sets():
50
+ empty = np.empty((0, 3), dtype=np.int8)
51
+ singleton = np.array([[1, 0, 1]], dtype=np.int8)
52
+
53
+ assert compute_sumset(empty, p=2).shape == (0, 3)
54
+ assert _compute_sumset_original(empty, p=2).shape == (0, 3)
55
+
56
+ expected_singleton = np.array([[0, 0, 0]], dtype=np.int8)
57
+ computed_singleton = compute_sumset(singleton, p=2)
58
+ assert computed_singleton.shape == (1, 3)
59
+ assert np.array_equal(computed_singleton, expected_singleton)
60
+
61
+
62
+ def test_compute_sumset_full_space_matches_original():
63
+ full_space = generate_space(3, p=2)
64
+ computed = compute_sumset(full_space, p=2)
65
+ original = _compute_sumset_original(full_space, p=2)
66
+ assert set(map(tuple, computed)) == set(map(tuple, original))
67
+ assert set(map(tuple, computed)) == set(map(tuple, full_space))
68
+
69
+
70
+ @pytest.mark.parametrize(
71
+ ("sumset", "p", "expected"),
72
+ [
73
+ (np.array([[0, 0]], dtype=np.int8), 2, 0),
74
+ (np.array([[0, 0], [1, 0]], dtype=np.int8), 2, 1),
75
+ (np.array([[0, 0], [1, 0], [0, 1], [1, 1]], dtype=np.int8), 2, 2),
76
+ ],
77
+ )
78
+ def test_find_maximum_subspace_dimension_edge_cases(sumset, p, expected):
79
+ assert find_maximum_subspace_dimension(sumset, p=p) == expected
80
+
81
+
82
+ def test_find_maximum_subspace_dimension_exhaustive_matches_greedy():
83
+ sumset = np.array(
84
+ [
85
+ [0, 0, 0],
86
+ [1, 0, 0],
87
+ [0, 1, 0],
88
+ [1, 1, 0],
89
+ [0, 0, 1],
90
+ [1, 0, 1],
91
+ [0, 1, 1],
92
+ [1, 1, 1],
93
+ ],
94
+ dtype=np.int8,
95
+ )
96
+ assert find_maximum_subspace_dimension(sumset, p=2) == 3
97
+ assert find_maximum_subspace_dimension(sumset, p=2, exhaustive=True) == 3
98
+
99
+
100
+ def test_find_maximum_subspace_dimension_uses_field_rank_over_f2():
101
+ sumset = np.array(
102
+ [
103
+ [0, 0, 0, 0],
104
+ [1, 1, 0, 0],
105
+ [1, 0, 1, 0],
106
+ [0, 1, 1, 0],
107
+ ],
108
+ dtype=np.int8,
109
+ )
110
+ assert find_maximum_subspace_dimension(sumset, p=2) == 2
111
+ assert find_maximum_subspace_dimension(sumset, p=2, exhaustive=True) == 2
112
+
113
+
114
+ def test_find_maximum_subspace_dimension_exhaustive_respects_combinatorial_limit():
115
+ sumset = generate_space(6, p=2)
116
+ greedy = find_maximum_subspace_dimension(sumset, p=2)
117
+ assert find_maximum_subspace_dimension(sumset, p=2, exhaustive=True, max_combinations=10) == greedy
118
+
119
+
120
+ def test_generate_standard_ball_regression_matches_expected_vectors():
121
+ ball = generate_standard_ball(3, 1)
122
+ expected = np.array(
123
+ [
124
+ [0, 0, 0],
125
+ [1, 0, 0],
126
+ [0, 1, 0],
127
+ [0, 0, 1],
128
+ ],
129
+ dtype=np.int8,
130
+ )
131
+ assert ball.shape == (4, 3)
132
+ assert np.array_equal(ball, expected)
133
+
134
+
135
+ def test_generate_standard_ball_radius_zero_is_singleton_zero_vector():
136
+ ball = generate_standard_ball(4, 0)
137
+ assert ball.shape == (1, 4)
138
+ assert np.array_equal(ball, np.zeros((1, 4), dtype=np.int8))
139
+
140
+ def test_rank_mod_p_catches_dependency_that_looks_indepenent_over_reals():
141
+ a, b, c = [1,1,0,0], [1,0,1,0], [0,1,1,0]
142
+ assert rank_mod_p(np.array([a,b,c]), p=2) == 2
@@ -0,0 +1,50 @@
1
+ import unittest
2
+ import numpy as np
3
+
4
+ from ffspaces.core import generate_random_basis, is_invertible
5
+ from ffspaces.fwht_operators import compute_sumset_fwht
6
+ from ffspaces.operators import _compute_sumset_original, compare_sumset_methods
7
+
8
+
9
+ class SumsetFwhtComparisonTest(unittest.TestCase):
10
+ def test_random_subset_matches_fwht_and_original(self):
11
+ subset, fwht_sumset, original_sumset = compare_sumset_methods(n=6, subset_size=10, seed=7)
12
+
13
+ self.assertEqual(subset.shape[1], 6)
14
+ self.assertEqual(subset.shape[0], 10)
15
+ self.assertEqual({tuple(row) for row in fwht_sumset}, {tuple(row) for row in original_sumset})
16
+
17
+ def test_generate_random_basis_accepts_rng(self):
18
+ rng = np.random.default_rng(17)
19
+ basis = generate_random_basis(4, p=2, rng=rng)
20
+ basis_again = generate_random_basis(4, p=2, rng=np.random.default_rng(17))
21
+
22
+ self.assertEqual(basis.shape, (4, 4))
23
+ self.assertTrue(is_invertible(basis, p=2))
24
+ self.assertTrue(np.array_equal(basis, basis_again))
25
+
26
+ def test_general_fourier_sumset_matches_original_for_p3(self):
27
+ subset = np.array([
28
+ [0, 0],
29
+ [1, 0],
30
+ [0, 1],
31
+ [1, 1],
32
+ [2, 0],
33
+ [0, 2],
34
+ ], dtype=np.int8)
35
+
36
+ fwht_sumset = compute_sumset_fwht(subset, p=3)
37
+ original_sumset = _compute_sumset_original(subset, p=3)
38
+
39
+ self.assertEqual({tuple(row) for row in fwht_sumset}, {tuple(row) for row in original_sumset})
40
+
41
+ def test_is_invertible_uses_rank_over_f2(self):
42
+ invertible = np.array([[1, 1], [1, 0]], dtype=np.int8)
43
+ singular = np.array([[1, 1], [1, 1]], dtype=np.int8)
44
+
45
+ self.assertTrue(is_invertible(invertible, p=2))
46
+ self.assertFalse(is_invertible(singular, p=2))
47
+
48
+
49
+ if __name__ == "__main__":
50
+ unittest.main()
@@ -0,0 +1,6 @@
1
+ import ffspaces
2
+
3
+
4
+ def test_public_api_exposes_sumset_helpers_but_not_fwht():
5
+ assert hasattr(ffspaces, "compute_sumset_fwht")
6
+ assert not hasattr(ffspaces, "fwht")