#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Utility functions used throughout BoolForge.
The :mod:`~boolforge.utils` module includes low-level operations for binary and
decimal conversions, truth table manipulations, and combinatorial helper
functions. These utilities are used internally by
:class:`~boolforge.BooleanFunction` and :class:`~boolforge.BooleanNetwork`
classes to enable efficient representation and analysis of Boolean functions
and networks.
Notes
-----
Most functions in this module are intended for internal use and are not part of
the stable public API.
Examples
--------
>>> import boolforge
>>> boolforge.bin2dec([1, 0, 1])
5
>>> boolforge.dec2bin(5, 3)
array([1, 0, 1])
"""
##Imports
import numpy as np
import random as _py_random
from collections.abc import Sequence
from numpy.random import Generator as _NPGen, RandomState as _NPRandomState, SeedSequence, default_rng
__all__ = [
"bin2dec",
"dec2bin",
"hamming_weight_to_ncf_layer_structure",
"get_left_side_of_truth_table",
"left_side_of_truth_tables",
]
def _require_cana():
try:
import cana.boolean_node
return cana.boolean_node
except ModuleNotFoundError as e:
raise ImportError(
"This functionality requires CANA. "
"Install it with `pip install cana`."
) from e
def _coerce_rng(
rng : int | _NPGen | _NPRandomState | _py_random.Random | None = None
) -> _NPGen:
"""
Coerce a variety of RNG-like inputs to a NumPy ``Generator``.
Parameters
----------
rng : int | np.random.Generator | np.random.RandomState | random.Random | None, optional
Random number generator or seed specification.
- ``None``: return ``np.random.default_rng()``.
- ``int``: interpreted as a seed for ``default_rng``.
- ``np.random.Generator``: returned unchanged.
- ``np.random.RandomState``: converted via ``SeedSequence``.
- ``random.Random``: converted via ``SeedSequence``.
Returns
-------
np.random.Generator
A NumPy random number generator.
Raises
------
TypeError
If ``rng`` is not one of the supported types.
Notes
-----
This function provides a unified RNG interface across BoolForge by
normalizing legacy and standard-library RNGs to the modern NumPy
``Generator`` API.
Conversion from ``RandomState`` and ``random.Random`` is performed by
extracting entropy and initializing a ``SeedSequence``. This preserves
reproducibility while avoiding direct reliance on deprecated RNG APIs.
"""
if rng is None:
return default_rng()
if isinstance(rng, _NPGen):
return rng
if isinstance(rng, (int, np.integer)):
return default_rng(int(rng))
if isinstance(rng, _NPRandomState):
# derive robust entropy from the legacy RNG
entropy = rng.randint(0, 2**32, size=4, dtype=np.uint32)
return default_rng(SeedSequence(entropy))
if isinstance(rng, _py_random.Random):
entropy = [_py_random.Random(rng.random()).getrandbits(32) for _ in range(4)]
# simpler: entropy = [rng.getrandbits(32) for _ in range(4)]
return default_rng(SeedSequence(entropy))
raise TypeError(f"Unsupported rng type: {type(rng)!r}")
def _is_number(token: str) -> bool:
"""Return True if token is a pure numeric literal."""
try:
float(token)
return True
except ValueError:
return False
def is_float(element: object) -> bool:
"""
Check whether an object can be coerced to a float.
Parameters
----------
element : object
Object to test for float coercibility.
Returns
-------
bool
True if ``element`` can be converted to ``float`` without raising an
exception, False otherwise.
Notes
-----
This function tests coercibility, not type membership. For example,
numeric strings and integers return True.
Examples
--------
>>> is_float(3)
True
>>> is_float(3.14)
True
>>> is_float("2.7")
True
>>> is_float("abc")
False
>>> is_float(None)
False
"""
try:
float(element)
return True
except (TypeError, ValueError):
return False
[docs]
def bin2dec(binary_vector: list[int]) -> int:
"""
Convert a binary vector to an integer.
Parameters
----------
binary_vector : list of int
Binary digits (0 or 1), ordered from most significant bit to least
significant bit.
Returns
-------
int
Integer represented by the binary vector.
Notes
-----
No validation is performed to ensure that entries of ``binary_vector`` are
binary. Nonzero values are treated as 1 under bitwise conversion.
Examples
--------
>>> bin2dec([1, 0, 1])
5
>>> bin2dec([0, 0, 1, 1])
3
"""
decimal = 0
for bit in binary_vector:
decimal = (decimal << 1) | bit
return int(decimal)
[docs]
def dec2bin(integer_value: int, num_bits: int) -> list[int]:
"""
Convert a nonnegative integer to a binary vector.
Parameters
----------
integer_value : int
Nonnegative integer to convert.
num_bits : int
Length of the binary representation.
Returns
-------
list of int
Binary digits (0 or 1), ordered from most significant bit to least
significant bit.
Notes
-----
- If ``integer_value`` requires more than ``num_bits`` bits, the most
significant bits are truncated.
- No validation is performed for negative inputs.
Examples
--------
>>> dec2bin(5, 3)
[1, 0, 1]
>>> dec2bin(3, 5)
[0, 0, 0, 1, 1]
"""
binary_string = bin(integer_value)[2:].zfill(num_bits)
return [int(bit) for bit in binary_string]
left_side_of_truth_tables = {}
[docs]
def get_left_side_of_truth_table(N: int) -> np.ndarray:
"""
Return the left-hand side of a Boolean truth table.
The left-hand side is the binary representation of all ``2**N`` input
combinations for ``N`` Boolean variables, ordered lexicographically from
``0`` to ``2**N - 1``.
Parameters
----------
N : int
Number of Boolean variables.
Returns
-------
np.ndarray
Array of shape ``(2**N, N)`` with entries in ``{0, 1}``. Columns are
ordered from most significant bit to least significant bit.
Notes
-----
- The result is cached by ``N`` to avoid recomputation.
- Row ``i`` corresponds to the binary expansion of integer ``i``.
- The most significant bit appears in column 0.
Examples
--------
>>> get_left_side_of_truth_table(2)
array([[0, 0],
[0, 1],
[1, 0],
[1, 1]], dtype=uint8)
"""
if N in left_side_of_truth_tables:
left_side_of_truth_table = left_side_of_truth_tables[N]
else:
vals = np.arange(2**N, dtype=np.uint64)[:, None]
masks = (1 << np.arange(N-1, -1, -1, dtype=np.uint64))[None]
left_side_of_truth_table = ((vals & masks) != 0).astype(np.uint8)
left_side_of_truth_tables[N] = left_side_of_truth_table
return left_side_of_truth_table
def find_all_indices(arr: list, el: object) -> list[int]:
"""
Find all indices of a given element in a sequence.
Parameters
----------
arr : list
Sequence to search.
el : object
Element to locate.
Returns
-------
list of int
Indices ``i`` such that ``arr[i] == el``.
Raises
------
ValueError
If ``el`` does not occur in ``arr``.
Examples
--------
>>> find_all_indices([1, 2, 1, 3], 1)
[0, 2]
>>> find_all_indices(['a', 'b', 'a'], 'a')
[0, 2]
"""
res: list[int] = []
for i, a in enumerate(arr):
if a == el:
res.append(i)
if not res:
raise ValueError("Element not found in sequence")
return res
def check_if_empty(my_list: list | np.ndarray) -> bool:
"""
Check whether a list or NumPy array is empty.
Parameters
----------
my_list : list or np.ndarray
Sequence to check.
Returns
-------
bool
True if ``my_list`` is empty, False otherwise.
Notes
-----
For NumPy arrays, emptiness is determined by ``size == 0``.
For Python lists, emptiness is determined by equality to ``[]``.
"""
if isinstance(my_list, np.ndarray):
return my_list.size == 0
return my_list == []
def is_list_or_array_of_ints(
x: list | np.ndarray,
required_length: int | None = None,
) -> bool:
"""
Check whether a list or NumPy array contains only integers.
Parameters
----------
x : list or np.ndarray
Sequence to check.
required_length : int or None, optional
If provided, require that ``x`` has exactly this length.
Returns
-------
bool
True if ``x`` is a list of ``int`` / ``np.integer`` or a NumPy array
with integer dtype, and (if specified) has length ``required_length``.
False otherwise.
Notes
-----
- For Python lists, each element is checked individually.
- For NumPy arrays, the dtype is checked using ``np.issubdtype``.
- One-dimensional arrays are required when ``required_length`` is given.
"""
# Case 1: Python list
if isinstance(x, list):
return (
(required_length is None or len(x) == required_length)
and all(isinstance(el, (int, np.integer)) for el in x)
)
# Case 2: NumPy array
if isinstance(x, np.ndarray):
return (
(required_length is None or x.shape == (required_length,))
and np.issubdtype(x.dtype, np.integer)
)
return False
def is_list_or_array_of_floats(
x: list | np.ndarray,
required_length: int | None = None,
) -> bool:
"""
Check whether a list or NumPy array contains only floating-point numbers.
Parameters
----------
x : list or np.ndarray
Sequence to check.
required_length : int or None, optional
If provided, require that ``x`` has exactly this length.
Returns
-------
bool
True if ``x`` is a list of ``float`` / ``np.floating`` or a NumPy array
with floating-point dtype, and (if specified) has length
``required_length``. False otherwise.
Notes
-----
- For Python lists, each element is checked individually.
- For NumPy arrays, the dtype is checked using ``np.issubdtype``.
- One-dimensional arrays are required when ``required_length`` is given.
"""
# Case 1: Python list
if isinstance(x, list):
return (
(required_length is None or len(x) == required_length)
and all(isinstance(el, (float, np.floating)) for el in x)
)
# Case 2: NumPy array
if isinstance(x, np.ndarray):
return (
(required_length is None or x.shape == (required_length,))
and np.issubdtype(x.dtype, np.floating)
)
return False
def flatten(l: Sequence[Sequence[object]]) -> list[object]:
"""
Flatten a sequence of sequences by one level.
Parameters
----------
l : list or np.ndarray
Sequence whose elements are themselves iterable.
Returns
-------
list
A flat list containing the elements of each sub-sequence in ``l``,
in order.
Notes
-----
This function performs a single-level flattening only. Nested sequences
deeper than one level are not recursively flattened.
Examples
--------
>>> flatten([[1, 2], [3, 4]])
[1, 2, 3, 4]
>>> flatten(np.array([[1, 2], [3, 4]]))
[1, 2, 3, 4]
"""
return [item for sublist in l for item in sublist]
[docs]
def hamming_weight_to_ncf_layer_structure(
n: int,
w: int,
) -> list[int]:
"""
Compute the canalizing layer structure of a nested canalizing function (NCF)
from its Hamming weight.
For nested canalizing functions, there is a bijection between the (odd)
Hamming weight ``w`` and the canalizing layer structure, with ``w`` and
``2**n - w`` corresponding to the same structure.
Parameters
----------
n : int
Number of input variables of the NCF.
w : int
Odd Hamming weight of the NCF.
Returns
-------
list of int
Canalizing layer structure ``[k_1, ..., k_r]``.
Raises
------
TypeError
If ``w`` is not an integer.
ValueError
If ``w`` is outside ``[1, 2**n - 1]`` or if ``w`` is even.
Notes
-----
- All nested canalizing functions have odd Hamming weight.
- The binary expansion of ``w`` (with ``n`` bits) determines the layer
structure.
References
----------
Kadelka, C., Kuipers, J., & Laubenbacher, R. (2017).
The influence of canalization on the robustness of Boolean networks.
Physica D: Nonlinear Phenomena, 353, 39-47.
"""
if not isinstance(w, (int, np.integer)):
raise TypeError("Hamming weight w must be an integer")
if not (1 <= w <= 2**n - 1):
raise ValueError("Hamming weight w must satisfy 1 <= w <= 2**n - 1")
if w % 2 == 0:
raise ValueError("Hamming weight w must be odd for nested canalizing functions")
if w == 1:
return [n]
w_bin = dec2bin(w, n)
current_el = w_bin[0]
layer_structure_NCF = [1]
for el in w_bin[1:-1]:
if el == current_el:
layer_structure_NCF[-1] += 1
else:
layer_structure_NCF.append(1)
current_el = el
layer_structure_NCF[-1] += 1
return layer_structure_NCF