Skip to content

Fix pinned_step for limit-cycle pinning and add driver node search#64

Open
xuan-w wants to merge 2 commits into
CASCI-lab:masterfrom
xuan-w:restore-pinning-control-driver-nodes
Open

Fix pinned_step for limit-cycle pinning and add driver node search#64
xuan-w wants to merge 2 commits into
CASCI-lab:masterfrom
xuan-w:restore-pinning-control-driver-nodes

Conversation

@xuan-w
Copy link
Copy Markdown
Contributor

@xuan-w xuan-w commented Apr 27, 2026

Summary

  • Fix bug in pinned_step() where pinned variables were copied from initial instead of written from pinned_binstate, breaking limit-cycle pinning control (pcstg fragmented into disconnected snapshots, fraction_pinned_configurations returned 0)
  • Add pinning_control_driver_nodes() for minimum-size driver set search with ceil(log2(N_attractors)) lower bound and a cheap necessary-condition pre-filter (_signatures_distinguish_attractors)
  • Rename attsource/attsinksrc_pin/dst_pin in pcstg construction for clarity
  • Add 18 tests covering pinned_step semantics, pcstg WCC invariants, fraction_pinned_configurations, driver node search, and the pre-filter

Test plan

  • 18 new tests in test_pinning_control.py all pass
  • Full test suite (79 tests) passes on Python 3.13

🤖 Generated with Claude Code

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes BooleanNetwork.pinned_step() so pinned variables are written from pinned_binstate (enabling correct limit-cycle pinning in pcstg construction), and adds a new minimum driver-set search API for pinning control.

Changes:

  • Fix pinned_step() semantics to write pinned outputs from pinned_binstate (while still computing unpinned updates from initial).
  • Add _signatures_distinguish_attractors() pre-filter and pinning_control_driver_nodes() brute-force search starting at ceil(log2(N_attractors)).
  • Add a comprehensive new test module covering pinned-step semantics, pcstg connectivity invariants, pcf/mpcf correctness, and driver-node search.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
cana/boolean_network.py Implements signature pre-filter + minimum driver-set search; fixes pinned_step to honor pinned_binstate; renames pcstg loop variables for clarity.
tests/test_pinning_control.py Adds new regression and behavior tests for pinned stepping, pcstg construction, pcf/mpcf, and driver-node search.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/test_pinning_control.py Outdated
Comment thread cana/boolean_network.py Outdated
Comment thread cana/boolean_network.py
Comment thread cana/boolean_network.py Outdated
…t 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>
@xuan-w xuan-w force-pushed the restore-pinning-control-driver-nodes branch from 8d3da1d to b788ffc Compare April 27, 2026 16:15
@xuan-w xuan-w requested a review from Copilot April 27, 2026 23:17
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread cana/boolean_network.py Outdated
Comment thread cana/boolean_network.py Outdated
Comment thread cana/boolean_network.py Outdated
- Return [()] for single-attractor networks and tuple in fallback
  for consistent list-of-tuples return type
- Exclude constant nodes from driver-set search (matching
  attractor_driver_nodes); add comment about modifying models
  if a constant node needs to be controllable
- Bound search loop by len(nodeids) instead of Nnodes to avoid
  no-op iterations when constants are excluded
- Clarify _signatures_distinguish_attractors docstring: collisions
  among flipping cycles are not checked (caught downstream)
- Replace bare asserts in pinned_step with ValueError for clearer
  error messages
- Look up attractors by content in tests instead of hard-coded
  indices to avoid dependence on implementation-defined ordering

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@xuan-w xuan-w force-pushed the restore-pinning-control-driver-nodes branch from 4a42aa9 to 3929506 Compare April 27, 2026 23:32
@xuan-w xuan-w requested a review from Copilot April 27, 2026 23:33
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread cana/boolean_network.py
Comment on lines +1403 to +1410
# Build a quick lookup so the comprehension is O(Nnodes)
# rather than O(Nnodes * |pinned_var|).
pin_map = dict(zip(pinned_var, pinned_binstate))
return "".join(
[
str(node.step("".join(initial[j] for j in self.logic[i]["in"])))
if not (i in pinned_var)
else initial[i]
for i, node in enumerate(self.nodes, start=0)
]
pin_map[i]
if i in pin_map
else str(node.step("".join(initial[j] for j in self.logic[i]["in"])))
for i, node in enumerate(self.nodes, start=0)
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pinned_step builds pin_map from zip(pinned_var, pinned_binstate). If pinned_var contains duplicates, the dict will silently drop earlier entries and ignore some characters of pinned_binstate, producing incorrect output without an error. Consider validating pinned_var has unique indices (e.g., len(set(pinned_var)) == len(pinned_var)) and raising ValueError when duplicates are present.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pinned_var is only produced internally by itertools.combinations (guaranteed unique) or by pinning_controlled_state_transition_graph which also assumes unique indices. Adding validation for an impossible case would be defensive noise.

Comment on lines +328 to +347
def test_pinning_control_driver_nodes_thaliana_strict_criterion():
"""Search must apply the strict criterion (unique attracting SCC
equals target), not the weaker WCC==1. Returns Alex Gates' three
size-6 sets, not the historical size-5 false positive."""
from cana.datasets.bio import THALIANA

bn = THALIANA()
result = bn.pinning_control_driver_nodes()
names = [n.name for n in bn.nodes]
sets = {frozenset(names[i] for i in dvs) for dvs in result}

# Alex Gates 2018 slide-3 result (three alternative size-6 sets,
# all sharing {UFO, WUS, AP3, AG} and extended by one of three pairs):
expected = {
frozenset({"UFO", "WUS", "AP3", "AG", "AP1", "LFY"}),
frozenset({"UFO", "WUS", "AP3", "AG", "TFL1", "EMF1"}),
frozenset({"UFO", "WUS", "AP3", "AG", "TFL1", "LFY"}),
}
assert sets == expected
assert all(len(dvs) == 6 for dvs in result)
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test module adds a full pinning_control_driver_nodes() search on the THALIANA dataset (noted as ~15s). With no existing pytest slow/benchmark markers in the repo, this will run on every CI invocation and can significantly increase suite runtime. Consider guarding it (e.g., pytest.skip unless an env var is set) or restructuring it to a cheaper regression (similar to the size-5-set strict-check test below), so the default unit test suite stays fast.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Thaliana test runs in ~15s and is the sole real-network regression test in this module — it's what caught the WCC-vs-attracting-SCC bug. Adding a slow marker or skip guard for 15s of CI time isn't worth the risk of the regression going untested.

@fxcosta-phd
Copy link
Copy Markdown
Contributor

@xuan-w, what is the priority in merging this to the master branch intead of its own dedicated branch?
If this is something unpublished, i.e. under development, I think it would be best to have it in a separate branch rather than the master.

@xuan-w
Copy link
Copy Markdown
Contributor Author

xuan-w commented May 8, 2026

@xuan-w, what is the priority in merging this to the master branch intead of its own dedicated branch? If this is something unpublished, i.e. under development, I think it would be best to have it in a separate branch rather than the master.

Hi @fxcosta-phd

Sorry for the late reply.

That is a good question. I think the history is this: we published the result in PNAS Gates et.al 2021 paper, in SI sec 6.
we discussed about the real pinning control driver set for Thaliana model, and how the node PI is predicted by FVC to be needed to control the BN, but actually it is not needed. Luis also presented this result in multiple conferences after. That result can be obtained using the function included in this PR.

The function to calculate the "real" (in contrast to FVC or other prediction methods) pinning control driver nodes set was never fully merged to CANA. Alex wrote one version, I wrote one version, and neither got fully merged to CANA when we merged everyone's work.

I lean toward to merge to the main branch, here are my justifications:

  1. This function alone, doesn't make a standalone publication, unless we bundle with some other results.
  2. The pinning control is not defined in this function. FVS paper has already defined it. We implemented the method to search for it. As long as the definition is there, anyone can implement a method to brute force searching for it.
  3. To help others to reproduce the result we published (Thaliana's pinning control driver nodes set)
  4. If others in the lab want to continue working on this and want to modify the function to add more options, having something already in the main branch could alleviate future merging mess.

If you think we should hold on, I can also send this to another branch.
However, this PR shall be split into two PR then. One bug fix PR still need to land to the main branch. The other one, the new function for pinning control driver nodes set, can land to a dedicated branch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants