Skip to content

Fix asymmetric division daughter selection, determinism, and spurious exit(-1) - #58

Merged
drbergman merged 2 commits into
my-physicellfrom
fix-asym-div-selection
Aug 7, 2026
Merged

drbergman merged 2 commits into
my-physicellfrom
fix-asym-div-selection

Conversation

@drbergman

Copy link
Copy Markdown
Owner

Three problems in Asymmetric_Division, plus a unit test suite.

1. select_daughter_types never formed a cumulative distribution

double r = UniformRandom();
for( auto it = probabilities.begin(); it != probabilities.end(); ++it )
{
    if( r <= it->second )   // r vs each INDIVIDUAL probability
    { return it->first; }
}

r was tested against each entry's own probability instead of a running sum, so the first entry whose probability happened to exceed r won. Realised daughter-type frequencies did not match the configured probabilities. PhysiCell_utilities.cpp choose_event() already had the correct idiom.

2. The outcome depended on std::unordered_map iteration order

Even with the arithmetic corrected, the code drew one UniformRandom() and then walked a hash-ordered container. Iteration order there is unspecified and varies with the standard library, with insertion history, and with rehashing — so two runs with the same seed could select different daughter types across platforms or compilers. For a simulator that seeds specifically for reproducibility that is a defect in its own right, and fixing the arithmetic alone does not address it.

asymmetric_division_probabilities is now a std::map with a pair_compare ordering keyed on the upper-triangular (min, max) form of the type pair. That is a function of the cell type indices alone, so iteration order — and hence the daughter types chosen for a given seed — no longer depends on insertion order, rehashing, or the standard library implementation.

This is why modules/PhysiCell_MultiCellDS.cpp appears in the diff: std::map is node-based and has no reserve(), so recreate_sim_state() drops that call. Four lines, and it is a build requirement of the container change rather than unrelated churn.

3. exit(-1) fired on correctly configured models

The sym_div_prob < 0.0 branch hard-killed the simulation from inside a cell division. It turns out this triggers on models whose probabilities are configured correctly, purely from floating-point round-off — a distribution summing to 1.0 in exact arithmetic can land fractionally below zero after the subtraction. At least one user has commented this branch out of their production build to stop it killing multi-hour HPC runs, which means they are now running with no diagnostic at all.

Replaced with a round-off tolerance plus a warn-once-per-cell-type rescale, extracted into a testable enforce_valid_distribution(). A long run survives and is still told.

Worth recording what was checked and found not to be broken: the > 1.0 normalization was suspected of drifting over repeated divisions, since it rewrites the cell's own map rather than normalizing a copy. It does not. It is exactly idempotent, verified across 200,000 configurations and 1,000,000 repeated applications, so it was left alone.

Tests

New unit_tests/asymmetric_division/. The frequency test samples select_daughter_types against a known distribution and asserts the empirical frequencies match — it fails on the old code and passes on the new. Determinism, round-off, and rescale behaviour are covered too.

Both the standard_asymmetric_division and extended_asymmetric_division XML paths route through this code and both still work.

The same change is prepared for feature-extended-asym-div (PR MathCancer#385 upstream) and is not included here.

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings August 7, 2026 00:47

Copilot AI left a comment

Copy link
Copy Markdown

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 correctness and reproducibility issues in PhysiCell’s asymmetric division daughter-type selection, and replaces a hard exit(-1) failure mode with a tolerant, warn-and-continue repair pathway. It also adds a dedicated unit test suite to validate frequency matching, determinism, and round-off / rescale behavior.

Changes:

  • Correct select_daughter_types() to sample from a cumulative distribution (instead of comparing against individual probabilities).
  • Make selection deterministic for a given RNG seed by changing asymmetric_division_probabilities from std::unordered_map to an ordered std::map with a custom comparator.
  • Replace fatal exit(-1) on floating-point/overspec cases with enforce_valid_distribution() (tolerant absorb/rescale behavior) plus warn-once-per-cell-type logging, and add unit tests covering these cases.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
core/PhysiCell_phenotype.h Switch asymmetric-division probability storage to std::map with deterministic ordering; add enforce_valid_distribution() API and helper totals/normalization declarations.
core/PhysiCell_phenotype.cpp Fix daughter selection to use cumulative probability; implement probabilities_total_excluding_self(), normalize_probabilities(), and enforce_valid_distribution().
core/PhysiCell_standard_models.cpp Use the new repair function in asymmetric_division_function() and replace fatal exit with warn-once behavior.
modules/PhysiCell_MultiCellDS.cpp Remove reserve() on asymmetric_division_probabilities since it is now a std::map.
unit_tests/asymmetric_division/test_asymmetric_division.cpp Add a unit test executable validating sampling frequencies, determinism, and repair behavior.
unit_tests/asymmetric_division/Makefile Add build/run targets for the new asymmetric division unit tests.

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

Comment thread core/PhysiCell_standard_models.cpp Outdated
@drbergman
drbergman force-pushed the fix-asym-div-selection branch from eb35f07 to 5626c25 Compare August 7, 2026 12:10
Three problems in Asymmetric_Division.

select_daughter_types() never formed a cumulative distribution. It drew one
UniformRandom() and then compared it against each entry's own probability, so the
first entry whose probability happened to exceed r won. Realised daughter type
frequencies did not match the configured probabilities. It now walks a running sum,
the same idiom as choose_event() in PhysiCell_utilities.cpp.

The outcome depended on std::unordered_map iteration order. Even with the arithmetic
corrected, the code drew one random number and then walked a hash-ordered container,
whose order is unspecified and varies with the standard library, with insertion
history, and with rehashing -- so two runs with the same seed could pick different
daughter types across platforms or compilers. asymmetric_division_probabilities is now
a std::map ordered by the upper-triangular (min,max) form of the type pair, which is a
function of the cell type indices alone. modules/PhysiCell_MultiCellDS.cpp appears in
this diff only because std::map is node-based and has no reserve(), so
recreate_sim_state() drops that call.

exit(-1) fired on correctly configured models. Probabilities meant to sum to exactly 1
routinely sum to a hair over it in double precision -- 0.11 + 0.33 + 0.56 is
1.000000000000000222 -- which tripped the "cannot be normalized" branch on models that
were fine. A 1e-12 tolerance fixes that.

Behaviour on a genuinely over-specified model is deliberately unchanged: it still
reports and exits. Nothing is rescaled. Quietly renormalizing would let a run continue
with daughter frequencies the modeller never asked for, and that is not a decision this
code should make on their behalf. The diagnostic now prints the CELL's current
probabilities rather than the cell definition's configured ones, since a rule or custom
function may have changed them since setup.

The repair of an absorbable excess -- where the asymmetric pairs still sum to at most 1
and the remainder belongs to symmetric division -- is unchanged in behaviour, but is now
computed from the other pairs rather than from the previous self-pair value, which makes
it exactly idempotent. It runs on every division, so that matters.

Verified with a unit test suite covering frequency matching, insertion-order
independence, idempotence over 1,000,000 repeats, the round-off tolerance, and the
over-specified path: 25 checks, all passing. The tests are kept out of this branch so
the diff stays limited to files that compile into a simulation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@drbergman
drbergman force-pushed the fix-asym-div-selection branch from 5626c25 to 17802aa Compare August 7, 2026 12:45
select_daughter_types() never formed a cumulative distribution. It drew one
UniformRandom() and compared it against each entry's own probability, so the first
entry whose probability happened to exceed r won, and realised daughter type
frequencies did not match the configured ones. It now walks a running sum.

The outcome also depended on std::unordered_map iteration order, which is unspecified
and varies with the standard library, insertion history, and rehashing -- so two runs
with the same seed could pick different daughter types on different platforms.
asymmetric_division_probabilities is now a std::map ordered by the (min,max) form of
the type pair, which depends only on the cell type indices.
modules/PhysiCell_MultiCellDS.cpp is in this diff only because std::map has no
reserve(), so recreate_sim_state() drops that call.

exit(-1) fired on correctly configured models. Probabilities meant to sum to exactly 1
routinely sum to a hair over it in double precision -- 0.11 + 0.33 + 0.56 is
1.000000000000000222 -- which tripped the "cannot be normalized" branch on models that
were fine. A 1e-12 tolerance on both the entry test and the negative-probability test
fixes that, and the arithmetic is otherwise left as it was.

Behaviour on a genuinely over-specified model is unchanged: it still reports and exits,
and nothing is rescaled. The diagnostic now prints the CELL's current probabilities
rather than iterating every cell type pair from the definitions, since a rule or custom
function may have changed them since setup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@drbergman
drbergman force-pushed the fix-asym-div-selection branch from 69aa4c4 to 38772af Compare August 7, 2026 13:30
@drbergman
drbergman merged commit 24ded80 into my-physicell Aug 7, 2026
83 of 208 checks passed
@drbergman
drbergman deleted the fix-asym-div-selection branch August 7, 2026 14:28
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.

2 participants