Skip to content

Commit b788ffc

Browse files
xuan-wclaude
andcommitted
fix: complete pinned_step body, restore driver-set search, with strict pcstg sufficiency check
This PR completes Alex Gates' 2018 pinning-controllability design, fixes a long-standing bug in pinned_step, and restores two utility functions for minimum-driver-set search with a strict pcstg sufficiency criterion. The pinned_step bug fix ----------------------- The pcstg edge-loop scaffolding for limit-cycle attractors has been in place since Alex Gates' 2018 introduction of pinning controllability (98792ed): the iteration over attractor STG edges, the (attsource, attsink) pair, and the call-site form pinned_step(initial, pinned_binstate=attsink, pinned_var=...). What was missing was the body of pinned_step that would honor the pinned_binstate argument — pinned positions in the output were copied from initial instead of being written to pinned_binstate. For fixed-point attractors src and sink share the same pin pattern, so the missing-body path happened to produce correct results. For limit-cycle attractors where the pinned variable flips between cycle states, src and sink differ; the buggy body kept pin=src in the output, fragmenting the pcstg into one disjoint snapshot per cycle position. The cycle's states were split across snapshots, set(att) was never a subset of any single weakly-connected component, and fraction_pinned_configurations returned 0 for every such attractor. A related independent fix was made in 2019 (44a96d3, on python3-friendly) via a different mechanism — splitting pinned_step into two functions and changing the call site. That fix shipped on its branch but didn't make it to upstream when subsequent merges branched off pre-fix history. This commit completes Alex's original design: the body of pinned_step now writes pinned_binstate into the destination's pinned positions while still using the source pin pattern (from initial) to compute the unpinned step. The call site stays exactly as it was written in 2018. Changes ------- * pinned_step: body now reads pin from `initial` (as inputs to node update functions) and writes pin from `pinned_binstate`. Signature unchanged from Alex's original. Docstring rewritten to document all three arguments correctly (the original docstring had a phantom `n` arg from a copy-paste and never mentioned `pinned_binstate`). * pinning_controlled_state_transition_graph: rename loop variables attsource/attsink → src_pin/dst_pin. Same variables, just renamed — the old names suggested *attractor states* when they actually hold the *pin-bit projections* of those states. The rename is purely internal: these are local names inside one method's body, not part of any signature, attribute, or external API. An inline comment in the function explains the meaning and notes the rename. Restored search and Stage-2 design ---------------------------------- pinning_control_driver_nodes — minimum-driver-set search via exhaustive enumeration bracketed by ceil(log2(N_attractors)) lower bound and FVS upper bound. Discrete/Boolean analog of FVS-based open-loop control (Mochizuki & Fiedler 2013, JTB §7). The search uses a two-stage filter: * Stage 1 (necessary) — _signatures_distinguish_attractors. Cheap pre-check that the candidate's per-attractor signatures are distinct. Handles fixed-point, pin-constant cycle, and pin-flipping cycle attractors separately. * Stage 2 (strict sufficient) — attracting_components(pcstg) == [set(att)]. Each pcstg must have exactly one attracting SCC, equal to the target attractor's state set. Pure integer/set comparison — no floating-point exposure even at large pcstg sizes. The historically-tempting weaker criterion is WCC == 1 per pcstg. WCC=1 is necessary (no orphan basins) but not sufficient: it only verifies the pcstg is connected, not that the pcstg's attractor equals the intended target. For deterministic pcstg (fixed-point pinning) each WCC has exactly one attracting SCC, but that SCC may be a different attractor than the target if the pin pattern's projection collides with multiple original attractors and the unpinned dynamics converge to one of the others. Concrete example: Arabidopsis thaliana under pin = {AP3, UFO, AP1, LFY, WUS} — fpc = [1, 0, 1, 1, 1, 0, 1, 1, 1, 0] across the ten attractors, so three target attractors are not the pcstg's attractor under that pinning. Yet all ten pcstg are single-WCC. The size-6 sets Alex Gates' 2018 slide-3 reports for Thaliana pass the strict check; the WCC=1-only check would have admitted the size-5 set as a false positive. Drosophila and Yeast cell-cycle happen to be robust to the difference because their pin signatures distinguish all attractors uniquely. _signatures_distinguish_attractors — module-level helper used by the Stage-1 filter above. Three attractor types handled inline. A third orphaned function from a 2019 effort (full_control_driver_nodes) is deferred to a separate PR. The right name (configuration_perturbation_driver_nodes) needs lab consensus on the broader 4-quadrant control taxonomy (perturbation/pinning × attractor-level/configuration-level). Tests ----- 20 new tests in tests/test_pinning_control.py: - 18 tests on a 3-node BN fixture (A *= B; B *= A; C *= A and B) with all three attractor types simultaneously (length-2 cycle, two fixed points). Exercises: * bug surface (pin A or {A,B} — pin flips in cycle): pcstg single-WCC + mpcf=1.0; verifies the bug is fixed without tripping over fixed-point cases that incidentally worked pre-fix. * pin-constant control case (pin C — pin invariant in cycle): values unchanged from pre-fix; guards against regressing the fixed-point-equivalent case. * minimum driver-set search: returns {A,B} as the unique 2-element pinning control set (ceil(log2(3))=2 lower bound). * pinned_step unit tests: pin-unchanged (pinned_binstate matches initial[pinned_var]), pin-advanced (pinned_binstate differs), multi-pin, length-mismatch error. * _signatures_distinguish_attractors: four cases covering distinct fixed points, colliding fixed points, pin-constant cycle, and pin-flipping cycle. - 2 real-network regression tests on Arabidopsis thaliana (Chaos 2006), the network that motivated the strict Stage-2 criterion: * test_pinning_control_driver_nodes_thaliana_strict_criterion: asserts the search returns exactly Alex's three size-6 sets. * test_thaliana_size5_false_positive_rejected_by_strict_check: direct assertion on the strict criterion against the historical false-positive size-5 set; lighter-weight regression that survives even if the search algorithm changes shape. All 20 new tests pass; full CANA test suite (81 tests total) passes. Sanity check on real CC model: Fanconi anemia (length-2 cycle with CHKREC flipping), pinning CHKREC → pcf=1.0 (pcstg single WCC, correct full-control on the cycle). Lac Operon (fixed-point only) — unchanged. References ---------- - Fiedler, Mochizuki, Kurosawa, Saito (2013). Dynamics and Control at Feedback Vertex Sets I. J. Dyn. Diff. Equat. 25, 563-604. - Mochizuki, Fiedler, Kurosawa, Saito (2013). Dynamics and Control at Feedback Vertex Sets II. J. Theor. Biol. 335, 130-146. - Zañudo, Yang, Albert (2017). Structure-based control of complex networks with nonlinear dynamics. PNAS 114, 7234-7239. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cb2a538 commit b788ffc

2 files changed

Lines changed: 636 additions & 21 deletions

File tree

cana/boolean_network.py

Lines changed: 204 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# All rights reserved.
1414
# MIT license.
1515
from collections import defaultdict
16+
from math import ceil, log2
1617

1718
try:
1819
import cStringIO.StringIO as StringIO # type: ignore
@@ -51,6 +52,64 @@
5152
from cana.utils import entropy, flip_binstate_bit_set, output_transitions
5253

5354

55+
def _signatures_distinguish_attractors(candidate_nodes, bin_attractors):
56+
"""Necessary-condition pre-filter for pinning controllability.
57+
58+
Returns ``True`` iff each attractor in ``bin_attractors`` has a
59+
distinct *signature* on ``candidate_nodes``. The signature is the
60+
tuple of values the candidate nodes take in the attractor:
61+
62+
- **Fixed-point attractor** (length 1): signature is the tuple of
63+
values at the single attractor state.
64+
- **Limit-cycle attractor, pin constant within the cycle**: all
65+
cycle states agree on the candidate nodes; the cycle is treated
66+
as fixed-point-like with that single signature.
67+
- **Limit-cycle attractor, pin flips within the cycle**: each
68+
cycle state contributes its own signature; each must not
69+
collide with another attractor's fixed-point-style signature.
70+
71+
This is a *necessary* condition, not sufficient: two attractors
72+
that pass here may still fail the downstream PCSTG-WCC check
73+
(e.g., two flipping cycles whose per-state signatures happen to
74+
overlap on the candidate nodes — rare in practice). Sufficiency
75+
is verified in :func:`BooleanNetwork.pinning_control_driver_nodes`.
76+
77+
Args:
78+
candidate_nodes (list of int): node indices to check.
79+
bin_attractors (list of list of str): attractors as lists of
80+
binary state strings; ``bin_attractors[i][j][k]`` is the
81+
value of node ``k`` at the ``j``-th state of attractor ``i``.
82+
83+
Returns:
84+
bool: ``True`` iff signatures distinguish all attractors;
85+
``False`` on any signature collision (including the trivial
86+
``len(candidate_nodes) == 0`` case where signatures are all
87+
empty tuples).
88+
"""
89+
if len(candidate_nodes) == 0:
90+
return False
91+
fixed_signatures = set()
92+
flipping_attractors = []
93+
for attr in bin_attractors:
94+
sig = tuple(attr[0][node] for node in candidate_nodes)
95+
is_pin_constant = all(
96+
tuple(state[node] for node in candidate_nodes) == sig
97+
for state in attr[1:]
98+
)
99+
if len(attr) == 1 or is_pin_constant:
100+
if sig in fixed_signatures:
101+
return False
102+
fixed_signatures.add(sig)
103+
else:
104+
flipping_attractors.append(attr)
105+
for attr in flipping_attractors:
106+
for state in attr:
107+
sig = tuple(state[node] for node in candidate_nodes)
108+
if sig in fixed_signatures:
109+
return False
110+
return True
111+
112+
54113
class BooleanNetwork:
55114
""" """
56115

@@ -1050,6 +1109,99 @@ def attractor_driver_nodes(self, min_dvs=1, max_dvs=4, verbose=False):
10501109

10511110
return attractor_controllers_found
10521111

1112+
def pinning_control_driver_nodes(self):
1113+
"""Find minimum-size driver sets that achieve pinning control.
1114+
1115+
A driver set ``D`` achieves *pinning control* if for every
1116+
attractor ``A`` of this Boolean network, pinning the nodes in
1117+
``D`` to ``A``'s projection drives every initial configuration
1118+
to ``A``. Operationally, this requires the pinning-controlled
1119+
STG (``pcstg``) for each attractor to have exactly one
1120+
attracting strongly-connected component, and that SCC must
1121+
equal the target attractor's state set.
1122+
1123+
The search starts at the information-theoretic lower bound
1124+
``ceil(log2(N_attractors))`` (you need that many bits to
1125+
distinguish the attractors at all) and increments until at
1126+
least one valid driver set is found. All minimum-size valid
1127+
sets are returned. A two-stage filter is used:
1128+
1129+
1. *Necessary-condition pre-filter*
1130+
(:func:`_signatures_distinguish_attractors`): cheap
1131+
combinatorial check that the candidate's signatures
1132+
distinguish all attractors. Skips the expensive pcstg build
1133+
for obviously-invalid candidates.
1134+
2. *Sufficiency check*: build pcstg per attractor; require
1135+
``attracting_components(pcstg) == [set(att)]`` (a single
1136+
attracting SCC, equal to the target). The weaker
1137+
``WCC == 1`` check used in earlier revisions only verifies
1138+
that the pcstg is connected — it does not verify that the
1139+
pcstg's unique attractor equals the intended target. For a
1140+
deterministic pcstg (fixed-point pinning) the pcstg always
1141+
has exactly one attracting SCC per WCC, but that SCC may
1142+
be some other state if the pin pattern happens to drive
1143+
the unpinned dynamics to a different attractor. The
1144+
Thaliana network exposed this in 2026; see test
1145+
``test_thaliana_size5_false_positive_rejected_by_strict_check``.
1146+
1147+
This is the discrete/Boolean analog of FVS-based open-loop
1148+
control proved for ODE systems in Mochizuki & Fiedler 2013
1149+
("Dynamics and Control at Feedback Vertex Sets II", JTB §7);
1150+
the Boolean version is supported by stable-motif theory
1151+
(Zañudo & Albert). FVS provides an upper bound on the driver-
1152+
set size; the search here may find smaller sets when the
1153+
discrete dynamics permit, since the FVS theorem requires a
1154+
guarantee for *all* nonlinearities while a specific Boolean
1155+
network may admit a smaller set.
1156+
1157+
Returns:
1158+
list: minimum-size driver sets (as tuples of node indices)
1159+
that achieve pinning control. If no set up to size
1160+
``Nnodes - 1`` works (degenerate networks), returns
1161+
``[list(range(Nnodes))]`` as the trivial fallback.
1162+
1163+
See also:
1164+
:func:`pinning_controlled_state_transition_graph`,
1165+
:func:`fraction_pinned_configurations`,
1166+
:func:`feedback_vertex_set_driver_nodes`,
1167+
:func:`_signatures_distinguish_attractors`.
1168+
"""
1169+
self._check_compute_variables(attractors=True)
1170+
if len(self._attractors) == 1:
1171+
return []
1172+
lower_bound = ceil(log2(len(self._attractors)))
1173+
nodeids = list(range(self.Nnodes))
1174+
bin_attractors = [
1175+
[self.num2bin(state) for state in attr] for attr in self._attractors
1176+
]
1177+
result = []
1178+
for n_pin in range(lower_bound, self.Nnodes):
1179+
if result:
1180+
break
1181+
for pvs in itertools.combinations(nodeids, n_pin):
1182+
if not _signatures_distinguish_attractors(
1183+
list(pvs), bin_attractors
1184+
):
1185+
continue
1186+
controlled = True
1187+
pcstg_dict = self.pinning_controlled_state_transition_graph(
1188+
list(pvs)
1189+
)
1190+
for att, pcstg in pcstg_dict.items():
1191+
# Strict check: pcstg must have exactly one
1192+
# attracting SCC, and that SCC must equal the
1193+
# target attractor's state set. Pure
1194+
# set/integer comparison — no floating point.
1195+
attracting = list(nx.attracting_components(pcstg))
1196+
if len(attracting) != 1 or attracting[0] != set(att):
1197+
controlled = False
1198+
break
1199+
if controlled:
1200+
result.append(pvs)
1201+
if not result:
1202+
return [list(range(self.Nnodes))]
1203+
return result
1204+
10531205
def controlled_state_transition_graph(self, driver_nodes=[]):
10541206
"""Returns the Controlled State-Transition-Graph (CSTG).
10551207
In practice, it copies the original STG, flips driver nodes (variables), and updates the CSTG.
@@ -1124,6 +1276,16 @@ def pinning_controlled_state_transition_graph(self, driver_nodes=[]):
11241276

11251277
pcstg_dict = {}
11261278
for att in self._attractors:
1279+
# For each STG edge ``(s_src, s_dst)`` *inside* the attractor,
1280+
# ``src_pin`` and ``dst_pin`` are the projections of those
1281+
# states onto the pinned variables. For a fixed-point
1282+
# attractor the self-loop gives ``src_pin == dst_pin``; for a
1283+
# length-L cycle the L tuples have ``dst_pin`` rotated one
1284+
# cycle-step ahead of ``src_pin``. (These are the same loop
1285+
# variables previously named ``attsource`` and ``attsink``;
1286+
# renamed because the old names suggested *attractor states*
1287+
# when they actually hold the *pin-bit projections* of those
1288+
# states.)
11271289
dn_attractor_transitions = [
11281290
tuple(
11291291
"".join([self.num2bin(s)[dn] for dn in driver_nodes])
@@ -1136,12 +1298,12 @@ def pinning_controlled_state_transition_graph(self, driver_nodes=[]):
11361298
self.bin2num(
11371299
binstate_pinned_to_binstate(
11381300
statenum_to_binstate(statenum, base=uncontrolled_system_size),
1139-
attsource,
1301+
src_pin,
11401302
pinned_var=driver_nodes,
11411303
)
11421304
)
11431305
for statenum in range(2**uncontrolled_system_size)
1144-
for attsource, attsink in dn_attractor_transitions
1306+
for src_pin, _dst_pin in dn_attractor_transitions
11451307
]
11461308

11471309
pcstg = nx.DiGraph(name="STG: " + self.name)
@@ -1155,19 +1317,27 @@ def pinning_controlled_state_transition_graph(self, driver_nodes=[]):
11551317

11561318
pcstg.add_nodes_from((ps, {"label": ps}) for ps in pcstg_states)
11571319

1158-
for attsource, attsink in dn_attractor_transitions:
1320+
for src_pin, dst_pin in dn_attractor_transitions:
11591321
for statenum in range(2**uncontrolled_system_size):
11601322
initial = binstate_pinned_to_binstate(
11611323
statenum_to_binstate(statenum, base=uncontrolled_system_size),
1162-
attsource,
1324+
src_pin,
11631325
pinned_var=driver_nodes,
11641326
)
1327+
# ``pinned_step`` advances the unpinned variables
1328+
# using ``initial`` (which has ``src_pin`` at the
1329+
# pinned positions) and writes ``dst_pin`` at the
1330+
# pinned positions of the output. For fixed-point
1331+
# attractors ``src_pin == dst_pin`` so the pinned
1332+
# positions are unchanged; for cycles where the pin
1333+
# flips, this is what connects positions of the
1334+
# pcstg around the cycle.
11651335
pcstg.add_edge(
11661336
self.bin2num(initial),
11671337
self.bin2num(
11681338
self.pinned_step(
11691339
initial,
1170-
pinned_binstate=attsink,
1340+
pinned_binstate=dst_pin,
11711341
pinned_var=driver_nodes,
11721342
)
11731343
),
@@ -1178,29 +1348,42 @@ def pinning_controlled_state_transition_graph(self, driver_nodes=[]):
11781348
return pcstg_dict
11791349

11801350
def pinned_step(self, initial, pinned_binstate, pinned_var):
1181-
"""Steps the boolean network 1 step from the given initial input condition when the driver variables are pinned
1182-
to their controlled states.
1351+
"""Advance the network one Boolean step under pinning control.
1352+
1353+
Pinned variables are read as inputs to the node update
1354+
functions from ``initial`` (so the unpinned variables see the
1355+
*source* pin pattern when computing their next values), and
1356+
written as ``pinned_binstate`` in the output (the *destination*
1357+
pin pattern). For fixed-point pinning ``pinned_binstate`` is
1358+
the same pattern at every step; for limit-cycle pinning it
1359+
rotates one cycle position ahead of the source pin.
11831360
11841361
Args:
1185-
initial (string) : the initial state.
1186-
n (int) : the number of steps.
1362+
initial (str) : the source binary state of length ``Nnodes``.
1363+
pinned_binstate (str) : destination values for the pinned
1364+
positions; must satisfy
1365+
``len(pinned_binstate) == len(pinned_var)``.
1366+
pinned_var (list of int) : indices of the pinned variables.
11871367
11881368
Returns:
1189-
(string) : The stepped binary state.
1369+
(str) : the next binary state, with pinned positions equal
1370+
to ``pinned_binstate`` and unpinned positions equal to
1371+
one Boolean step from ``initial`` (using the values in
1372+
``initial`` — including the source pin — as inputs).
1373+
1374+
See also:
1375+
:func:`pinning_controlled_state_transition_graph`.
11901376
"""
1191-
# for every node:
1192-
# node input = breaks down initial by node input
1193-
# asks node to step with the input
1194-
# append output to list
1195-
# joins the results from each node output
11961377
assert len(initial) == self.Nnodes
1378+
assert len(pinned_binstate) == len(pinned_var)
1379+
# Build a quick lookup so the comprehension is O(Nnodes)
1380+
# rather than O(Nnodes * |pinned_var|).
1381+
pin_map = dict(zip(pinned_var, pinned_binstate))
11971382
return "".join(
1198-
[
1199-
str(node.step("".join(initial[j] for j in self.logic[i]["in"])))
1200-
if not (i in pinned_var)
1201-
else initial[i]
1202-
for i, node in enumerate(self.nodes, start=0)
1203-
]
1383+
pin_map[i]
1384+
if i in pin_map
1385+
else str(node.step("".join(initial[j] for j in self.logic[i]["in"])))
1386+
for i, node in enumerate(self.nodes, start=0)
12041387
)
12051388

12061389
def controlled_attractor_graph(self, driver_nodes=[]):

0 commit comments

Comments
 (0)