Skip to content

Feature/caf mhc2 rules - #438

Closed
gavehan wants to merge 393 commits into
MathCancer:masterfrom
jeanettejohnson:feature/caf-mhc2-rules
Closed

gavehan wants to merge 393 commits into
MathCancer:masterfrom
jeanettejohnson:feature/caf-mhc2-rules

Conversation

@gavehan

@gavehan gavehan commented Sep 25, 2026

Copy link
Copy Markdown

Add CAF-contact MHC-II rules (off by default) and save every 2 hours

  • cell_rules.csv (both user projects): eight rows. A tumour cell touching a CAF or apCAF gains class II: class1 -> class1_class2 and no class -> class2, in both lineages, at a fixed rate with no reversion. The rate is 0 here, so runs are unchanged. The main repo's runners switch it on with a PCMM rules variation.
  • Save intervals (full data and SVG) go from 60 to 120 min in both templates and the 48 IMC ROI configs. This halves output per run to leave room for replicates.

Checks:

  • Fresh clones of all four simulation types (2-day runs, rate 0) pass every run check. The rules used match the committed rows exactly.
  • With the rate at 2.3e-4/min, all four conversion types occur. 94% of converting cells were touching a CAF or apCAF at the save before, and there were no reversions. The paired baseline runs had no conversions.

🤖 Generated with Claude Code

also make substrates.csv in template project. disable in xml
- about midway between 1.14.0 and 1.14.1
- need to go back and decide how to handle the "old" rules
- right now, just ignoring them (and breaking old projects in the process)
- also some cleaning
- make sure to look at previous commit for notes
- only need a signal reference for AbstractHill signals
- make these fall into new class RelativeSignals
- others fall into AbsoluteSignals
- example showing off many of these new rules
- these allow for memory-ful behaviors where signals lead to either accumulation or attenuation of the behavior over time
- basically, if a behavior is ruled, then they it can either be set, accumulated, or attenuated
- set is the default behavior and what has happened before
- accumulation and attenuation will move away from the base behavior if they process a positive signal
- otherwise they relax to the base
- the top-level signal for these behaviors are still mediators
- but now the mediator sets the rate of accumulation or attenuation
- a positive rate means the behavior is moving away from the base
- a negative rate means the behavior is relaxing to the base
- users will likely want the base behavior to be negative so that in the absence of increasing signals the behavior will relax to the base
drbergman and others added 28 commits August 7, 2026 11:19
…athCancer#60)

get_parameter_value() and set_parameter_value() index species_result_column_index
with operator[], the inserting form. An unknown species name silently resolves to
column 0, so a typo -- or a model whose column map was never populated -- reads, and
writes, a different species than the caller named. A read also mutates the map.

Both accessors now look the species up with find() and, on a miss, report and exit.
Exiting rather than warning is deliberate: there is no value to return and nothing
sensible to write, so continuing would feed a fabricated number into the model and
every step after it. validate_SBML_species() already exits for exactly this condition
at setup; it just cannot cover these calls, because it only validates the mappings
declared in the XML and custom code calls the accessors directly with its own strings.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The block tested phenotype.cell_interactions.pAttackTarget for NULL and
then loaded it again into a local. Those are two separate loads of a
field another thread can clear: remove_all_attackers() nulls an
attacker's pAttackTarget when its target is eaten, fused or lysed, and
that runs from standard_cell_cell_interactions inside the mechanics
parallel-for. If the clear lands between the test and the reload, the
local is NULL and attack_cell() dereferences it -- it only guards
against attacking itself.

Take one snapshot and use it throughout.

Follow-up to MathCancer#59, which introduced the cross-thread write. Mirror of
MathCancer#422's 6c79881.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
ingest_cell, fuse_cell and lyse_cell each flag their victim for removal
and then immediately tear down its attachments, springs and attack
links, from inside the mechanics parallel-for. That mutates other cells'
state from a thread that does not own them.

remove_all_spring_attachments walks state.spring_attachments by index
with no lock, while attach_cell_as_spring and detach_cell_as_spring edit
that same vector under the unnamed critical. detach is swap-and-pop, so a
removal below the walker's cursor moves the tail element into a slot
already passed and it never gets detached -- leaving a neighbour holding
a pointer to a cell about to be freed.

The work was already redundant: flag_for_removal() queues the cell and
the serial cells_ready_to_die -> die() -> delete_cell() pass does the
same steps. Dropping the in-loop copies removes every unlocked walk from
a parallel region.

Mirror of the same commit on MathCancer#422.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…meters (MathCancer#56)

* Give each cell definition its own fixed_duration and death-phase parameters

Fixes MathCancer#199.

Cycle_Model objects are shared by pointer between cell definitions: apoptosis
and necrosis are globals, and every definition registers the same address via
add_death_model( rate, &apoptosis, ... ). Cell_Definition's copy constructor
copies the pointer, not the model.

fixed_duration lived in Phase_Link, inside that shared object, while the XML
parser wrote it per cell definition. So each definition overwrote the flag for
every other one and the last definition parsed decided for all of them. A model
asking for deterministic apoptosis on most of its cell types silently got the
stochastic branch on all of them, with no warning and nothing in the saved
output to reveal it.

Migrate the flag from the graph structure to the param structure, as Paul
suggested on the issue: Cycle_Data gains fixed_durations, indexed exactly like
transition_rates, with fixed_duration() / exit_fixed_duration() accessors
mirroring transition_rate() / exit_rate(). Cycle_Model::advance_model now reads
the per-cell copy. Phase_Link::fixed_duration is removed; the six standard
models declare their defaults through the model's own Cycle_Data instead, and
those defaults still reach cells that do not override them in XML.

That alone is not enough for the death models. There is no per-definition
Cycle_Data for a death model -- Death holds rates, models and parameters, none
of which carry cycle parameters -- so the parser wrote durations to the shared
models[i]->data, and Cycle::sync_to_cycle_model() copies that same shared data
over the cell at start_death(). Relocating the flag would have moved it from
one shared object to another. This is also why rheiland observed on the issue
that the <duration> value is flattened along with the flag.

So Death gains model_data, one Cycle_Data per death model, seeded from the model
at add_death_model(), written by the parser, and applied at start_death()
through a new two-argument Cycle::sync_to_cycle_model( cm, cd ). The
single-argument form delegates to it and keeps its exact previous behaviour.

Verified on a 7-cell-type model where six definitions ask for
<phase_durations fixed_duration="true"> and the last asks for a rate. Before:
all seven report fixed=0. After: six report 1 and the last reports 0, while all
seven still share one Cycle_Model object, so the phase graph stays shared and
Phenotype copies stay cheap. A definition with no apoptosis <phase_durations> at
all still inherits the standard model's default.

Note: this changes results for any model that mixes <phase_durations> and
<phase_transition_rates> across its cell definitions. Those models were not
getting the death timing their XML asked for; they will now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Apply the per-definition death parameters on the runtime death path too

Cell::advance_bundled_phenotype_functions syncs the cycle to the death
model directly when check_for_death fires, rather than going through
start_death. That site was still using the single-argument
sync_to_cycle_model, so cells dying by death_rate -- the common case --
kept getting parameters from the shared Cycle_Model and MathCancer#199 was
unfixed on that path.

Also from review: hoist the transition_rates / fixed_durations resize
out of the inner phase-link loop in Cycle_Data::sync_to_cycle_model, and
take the Cycle_Data by const reference in the new Cycle overload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Report per-definition death parameters in the cell definition summary

display_cell_definitions() read the death phase durations out of
death.models[k]->data, the shared Cycle_Model. The parser no longer
writes that object, so after the rest of this PR the summary printed the
compiled-in standard-model defaults for every definition regardless of
the XML -- and that summary is exactly what a user reads to check
whether MathCancer#199 is fixed.

Take the parameters from death.model_data[k] instead. Phases and phase
links still come from the shared model, which is correct: only the
parameters moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ancer#61)

Every object rule names only its .cpp, so make never rebuilds an object
when a header it includes changes. Editing a header and running make
relinks the stale objects and reports success.

That is merely confusing when the change is behavioural. It is dangerous
when the header changes a type's layout: the objects that did get
rebuilt disagree with the ones that did not about member offsets and
sizeof, the link succeeds because mangled names encode types rather than
layouts, and the result is a binary that reads fields at the wrong
addresses. The crash looks like a bug in the code under test.

-MMD makes the compiler emit a .d file beside each .o listing the
headers that object actually included; -MP adds a dummy target for each
so that deleting or renaming a header does not break the build. The .d
files are read back in at the bottom of each Makefile, which is where
they have to be: each one declares rules for its own .o, so including
them earlier would replace `all` as the default goal.

Applied to all 44 Makefiles, clean removes the generated .d files, and
they are gitignored.

Mirror of MathCancer#426 for my-physicell.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…Cancer#64)

asymmetric_division_function tolerates a small excess when checking that the
probabilities sum to at most 1, because probabilities meant to sum to exactly 1 land a
hair over it in double precision. That tolerance was hard-coded at 1e-12, which is the
right default but not right for every model: how much slack is needed depends on how
many probabilities are summed and how they are produced. A model whose probabilities
come out of rules rather than literals can exceed 1 by far more than 1e-12 -- excesses
around 8e-3 have been observed in practice -- and such a model currently exits with no
way to say that is acceptable.

Adds <asymmetric_division_probability_tolerance> to the <options> block. Absent, the
default 1e-12 applies and nothing is printed, so existing configs are unaffected.
Present, the value is used and echoed at startup. A negative value is rejected at parse
time rather than silently inverting both comparisons.

The name says which quantity is toleranced: it is slack on the probability SUM, not on
division timing or on the division itself.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…cer#65)

dynamic_spring_attachments() runs inside the mechanics parallel-for and walks
pCell->state.spring_attachments with no lock. That vector is not private to this
thread: another thread running the same function for a neighbouring cell calls
attach_cells_as_spring(), which reaches into THIS cell's spring_attachments and
push_back()s. Those writers serialize on the unnamed critical inside
Cell::attach_cell_as_spring() and Cell::detach_cell_as_spring(); the read did
not take it. A concurrent push_back that reallocates leaves the loop indexing a
freed buffer, and the next iteration dereferences whatever it finds.

The result is an intermittent SIGSEGV in dynamic_spring_attachments, inside the
OpenMP outlined region, faulting on a garbage address. It is thread-count
dependent and therefore invisible on single-threaded runs, which is why it
survives casual testing.

Copy the vector under the same critical the writers use, then walk the copy. The
lock is deliberately not held across the loop body: detach_cells_as_spring()
takes the same unnamed critical, and OpenMP criticals are not reentrant.

Measured on a model that reproduces the fault reliably, 60 runs per arm at 4
threads: 10 crashes without the change, 0 with it. Cost is +2.4% wall time
(paired median ratio 1.0237, slower in 9 of 9 paired blocks).

This addresses the crash only. The function still reads .size() outside the lock
in the attachment half, so it is not yet data-race-free.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Adjust asymmetric division probabilities whenever they sum past 1

asymmetric_division_function entered its renormalization block on total > 1.0 + tolerance, so
raising the tolerance did not only relax when an overshoot is an error -- it also decided whether
the probabilities were rewritten at all. That block sets the symmetric division probability, so
gating it on a user-settable threshold let the tolerance change the model rather than its
strictness.

With a tolerance of 0.5 and probabilities of 0.3 for (stem, stem) and 1.0 for
(stem, progenitor_1), the total of 1.3 fell inside the old gate, so nothing was adjusted and the
draw ran against a distribution summing to 1.3. Because select_daughter_types draws from [0,1),
(stem, stem) kept its 0.3 instead of being taken down to 0, and progenitor_1 was starved to 0.7.
Running the asymmetric division sample that way ends with 464 cells, 137 of them stem; adjusting
whenever the total passes 1 ends with 16 cells and a stem population that stays at 1, which is
what those probabilities describe.

Adjust on total > 1.0 and leave the tolerance its one job: deciding whether the resulting
symmetric division probability is negative enough to be an error rather than round-off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Add a weighted mode for asymmetric division

The extended asymmetric division values are probabilities: they must sum to at most 1, and
whatever they leave short of 1 is the chance of dividing symmetrically. That constraint is
awkward for the models this feature is aimed at. The intended use is rule-driven values, and a
Hypothesis Grammar rule cannot see what the other daughter-type pairs currently evaluate to, so
keeping a sum at or below 1 is not something a rule set can enforce. The configurable tolerance
buys slack for round-off, but cannot rescue a model whose values legitimately sum to 2 or 3.

Adds <asymmetric_division_mode> to the <options> block, accepting "probabilities" (the default,
and what an absent element means) or "weights". Under weights the values are relative weights,
normalized by their own sum at each division, so their scale stops mattering and each rule can be
written on its own. A string rather than a bool so a typo errors out instead of quietly reading as
false and running the other model. The mode is model-wide rather than per cell definition: mixing
them would make one rule line mean a probability for one source type and a weight for another,
with nothing in the line to say which.

Weights carry no implicit symmetric-division remainder -- symmetric division needs its own
(type,type) weight -- except that an all-zero total, which has no normalized distribution, is
defined as symmetric division. That needs no special case: leaving the draw scale at 1.0 makes
select_daughter_types fall through to the parent and daughter types unchanged.

The draw itself is one line. select_daughter_types takes a total_weight that scales the random
draw; probability mode passes 1.0, which is exact in double precision, so no existing model's RNG
stream moves. Confirmed by running the asymmetric division sample before and after: 487 .mat files
byte-identical, and 244 SVG and 243 XML identical once wall-clock timestamps are stripped.

Setting <asymmetric_division_probability_tolerance> together with weights is rejected at parse
time. The tolerance bounds how far probabilities may sum past 1, and weights are normalized rather
than bounded, so there is nothing for it to do; erroring beats letting one of the two silently
win. An explicit "probabilities" mode alongside a tolerance stays legal, since that is unambiguous.

The extended_asym_div sample gains a second config and rules file expressing the same model in
weights, plus a README covering both modes and the trap that separates them: probabilities take
the whole overshoot out of the symmetric entry, while weights scale every entry proportionally.
The weights rules saturate at 1.0 where the probabilities rules use 0.5, chosen so the normalized
weights reproduce the probabilities exactly. Verified single-threaded: across all 121 snapshots the
two runs agree on every cell, and the only recorded difference is the asymmetric division values.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ncer#73)

get_row_from_dirichlet_condition_csv took line and substrate_indices by
value, copying a string and a vector for every row of the file. Take both
by const reference, matching the substrate reader.

The "wrong number of density data" warning printed number_of_voxels()
where it means number_of_densities(); Found is a count of substrate
columns, and the matlab loader's equivalent message already uses the
density count.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…quire well-formed rows (MathCancer#66) (MathCancer#69)

* Bounds-check substrate IC csv rows before indexing them

get_row_from_substrate_initial_condition_csv indexed data[0..2] and
data[ci + 3] without consulting data.size(). Both reads are out of
bounds for a malformed row:

  - substrate_csv_to_vector always emits a final field, so a blank line
    parses to a single 0.0 and data[1]/data[2] read past the end.
  - substrate_indices is sized from the header's column count, so a row
    with fewer columns than the header runs data[ci + 3] off the end.

Reproduced with libc++ bounds checking on a 4-voxel microenvironment: a
trailing blank line, a whitespace-only line, a CRLF blank line, a
2-column row, and a row supplying 1 of 3 header substrates all abort
with "vector[] index out of bounds" before this change and are handled
cleanly after it. Note these reads are not new here -- development
crashes on the same five inputs. Its coverage check ran after the read
loop, so it never guarded them.

Blank lines are now skipped, matching load_cells_csv_v1 and
process_csv_v2_line, and a short row is a hard error rather than a
silent partial write. Ordering the guards ahead of the existing warning
also stops data.size() - 3 from underflowing on a short row.

Also correct that warning: it reported number_of_voxels() where it
means number_of_densities(), and the block comment still described the
one-row-per-voxel, no-header format this branch replaced.



* Revive the headerless csv path and require well-formed rows

The headerless branch of load_initial_conditions_from_csv could never
have worked. Two bugs had to be fixed together to see either one:

  - "if (i<3) {continue;}" jumped past the "i++" below it, so i never
    advanced and substrate_indices came out empty.
  - the reopened "std::ifstream file(filename, ...)" shadowed the
    enclosing stream, which had just been closed, so the row loop read a
    closed stream and processed no rows. -Wshadow flags this.

A headerless csv therefore loaded nothing. On development that at least
failed loudly, because the deleted coverage check saw voxel_set.size()
== 0 and exited; without it the run continued silently on the config
file's uniform initial conditions. Reading the first row's column count
and rewinding the stream fixes both, and the row loop now counts lines
so every diagnostic can name the row it rejected.

Rows are now held to being well formed rather than parsed as far as
they go:

  - a field must be empty or a complete finite number. strtod's endptr
    was discarded, so "NA" and "1.5abc" silently became 0 and "inf"
    became a density.
  - a row must have exactly the column count the header (or the first
    row) established. Extra columns were silently dropped.
  - x, y and z must all be present, so ",,,," no longer resolves to the
    origin.
  - a position must lie inside the domain, since nearest_voxel_index
    clamps and would otherwise snap a typo onto an edge voxel.
  - a header may not name the same substrate twice, and must name at
    least one.
  - an empty file is an error rather than a header sniff on nothing.

An omitted entry now travels as NaN instead of 0, which is what lets the
row loop tell "the user left this blank" from "the user asked for zero".
It still resolves to 0, as before; the distinction only makes the checks
above possible.

The header sniff also no longer indexes line.c_str()[2] and [4] without
knowing they exist -- a first line of "x" read past the end. It splits
the row and compares fields instead, which also makes the sniff
whitespace tolerant.

Verified against real BioFVM on a 2x2x1 microenvironment with libc++
bounds checking: 36 cases covering header/headerless, subsets,
reordering, whitespace, CRLF, blank lines, and every rejection above.
All 36 behave as intended with no out-of-bounds reads. Headerless files
load correctly for the first time.



---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…er#74)

template_BM and tutorial both wrote

    if( !load_PhysiCell_config_file(); )

so neither sample compiled at all:

    main.cpp:92:44: error: expected primary-expression before ')' token

g++ also warned about the accidental init-statement in a selection statement
(-Wc++17-extensions), which goes away with the semicolon. cancer_invasion
already has the intended form, and is what these two are matched to.

Introduced in 7eba010 when these samples moved to the argument_parser plus
load_PhysiCell_config_file() API. Not present on upstream development, which
still uses the older XML_status form, so this is fork-only and needs no
upstream PR. These are the only two occurrences in the repository.

This is why "Testing PhysiBoSS Template" and "Testing PhysiBoSS Tutorial"
failed on Ubuntu, Windows and MacOS 14 on every PR, along with the
windows/macos_step0b template_BM build jobs. Both samples now build, link and
run: template_BM completes with the shipped config, and tutorial completes
against config/cell_cycle/PhysiCell_settings.xml (it ships no top-level
config, only the three scenario subdirectories).

Note this does not address the other failure in those jobs,
"Makefile.maboss:193: BooleanNetwork_256n.o Error 127", which is a missing
command in the MaBoSS build environment rather than anything in this tree.
MaBoSS built here without trouble, so that one is CI-specific.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
MathCancer#69 gave the substrate reader a parser that returns the 1-based index of the
first malformed field and yields NaN for an omitted entry. That NaN sentinel
is exactly what the parallel is_missing vector existed to carry, so the
dirichlet reader can use the same parser and both dirichlet_csv_to_vector and
the store_dirichlet_csv_entry helper of the previous revision of this branch
go away.

Rather than copy the header handling a third time, the two loaders now share
it: resolve_substrate_csv_columns reads the optional "x,y,z,<name>,..."
header, applies the headerless "first n densities" convention, and leaves the
stream on the first data row. The two readers differ only in what a parsed row
does to the microenvironment -- the substrate reader resolves an omitted entry
to 0, the dirichlet reader leaves that voxel-substrate pair alone -- and in
the word their diagnostics use.

Sharing it also carries MathCancer#69's fixes onto the dirichlet path, which had the
same two bugs its substrate counterpart did:

  - "if (i<3) {continue;}" jumped past the "i++" below it, so substrate_indices
    came out empty on a headerless file.
  - the reopened "std::ifstream file(filename, ...)" shadowed the enclosing
    stream, which had just been closed, so the row loop read a closed stream.

A headerless dcs.csv therefore set no dirichlet conditions at all and said
nothing about it. Verified on the dirichlet_from_file sample: before this
change a headerless dcs.csv produced a byte-identical final microenvironment
to disabling the file entirely; after it, it matches the headered file.

Blank lines are skipped rather than read as a row of zeroes, a row whose
column count disagrees with the header is a hard error, and every diagnostic
names the line and column it rejected. The out-of-bounds write in
dirichlet_csv_to_vector, which had no bound on ind, disappears with the
function.

The stock sample is unaffected: its dcs.csv leans on empty fields meaning
"leave this pair alone" on nearly every row, and it produces byte-identical
initial and final microenvironments before and after.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…r#75)

* Seed the RNG from OS entropy and widen the seed to 64 bits

`SeedRandom(void)` took the seed straight from
`system_clock::now().time_since_epoch().count()`, truncated into an
`unsigned int`. The clock advances in ~1 microsecond steps (measured with
homebrew g++-15 on macOS: 200k back-to-back `now()` calls returned only
10059 distinct counts), so processes launched together routinely read the
same count and share a seed. Running 8 template sims simultaneously, 2 of
40 batches contained a duplicate `random_seed.txt`.

`SeedRandom(void)` now mixes `std::random_device` with the clock and the
process id. The clock and pid keep the seed varying on toolchains where
`random_device` is a deterministic stub (older MinGW), and the pid
separates processes that read the clock in the same tick.

The seed chain is also widened to 64 bits: `physicell_random_seed`,
`physicell_random_seeds`, `SeedRandom`'s argument, and the config parse
(`std::stoull`). The generator is `std::mt19937_64`, so the old 32-bit
seed both threw away entropy and silently truncated configured seeds at
or above 2^32 (`<random_seed>4294967296</random_seed>` was recorded as
`0`).

`std::seed_seq` consumes 32-bit words, so per-thread seeds derived from a
seed above 2^32 now contribute a second (high) word. Seeds that fit in 32
bits still contribute one word each, exactly as before, so fixed-seed
runs are bit-identical to the previous behavior -- verified for seeds 0
and 42 at 1 and 6 threads against a binary built from the parent commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Derive per-thread RNG seeds with splitmix64 and seed every thread that draws

Follow-up to the 64-bit seed change, dropping the requirement that fixed-seed
runs reproduce previous releases. Three separate defects:

1. Per-thread seeds were 32-bit and thread 0's was the raw base seed. They came
   from `std::seed_seq`, whose input and output are both 32-bit words, so a
   64-bit generator was started from a 32-bit value on every thread but one,
   and `<random_seed>0</random_seed>` started thread 0's mt19937_64 from 0.
   Seeds now come from splitmix64: thread i's seed is
   `mix64(base + (i+1) * 0x9E3779B97F4A7C15)`, the closed form of splitmix64's
   (i+1)-th output. Full 64 bits, a bijection so seeds never collide, ~32 of 64
   bits flipped between adjacent base seeds or adjacent threads, and dependent
   only on (base seed, thread index) -- not on the thread count, as the
   `seed_seq` version was.

2. `NormalRandom()`, `UniformInt()` and `UniformRandom_old_not_thread_safe()`
   read `physicell_PRNG_generator` without the lazy seeding that
   `UniformRandom()` did. A thread whose first random number came from one of
   them drew from a default-constructed `std::mt19937_64` -- seed 5489 on every
   thread. Measured on the parent commit with 6 threads: `NormalRandom` and
   `UniformInt` returned one value shared by 5 of the 6 threads. This reached
   PhysiBoSS (`UniformInt()` seeds each MaBoSS engine, `LogNormalRandom` sets
   `time_to_update`) and PhysiMeSS fibre setup. All four entry points now seed
   first.

3. Re-seeding never reached worker threads. `local_pnrg_setup_done` was set
   once and never cleared, so a second `SeedRandom()` -- the episode sample
   project, or a `random_seed` user parameter -- left every worker thread on
   its old stream. Measured on the parent commit: `SeedRandom(42)` twice in one
   process gave different results the second time on 5 of 6 threads, so
   `random_seed.txt` did not actually pin the run. A generation counter now
   makes each thread reseed on its next draw.

`SeedRandom(void)` absorbs its three entropy sources through `mix64` one at a
time instead of a flat xor, so a small process id cannot cancel against the
clock's low bits.

Fixed-seed runs are reproducible run to run (1 and 6 threads, and after a
re-seed), and no longer match previous releases -- that is the trade this
commit makes deliberately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Drop thread_local from the uniform distributions; fold the seed install together

Two follow-ups from review.

The per-call `seed_thread_rng_if_needed()` is not the cost it looks like:
`UniformRandom()` already did exactly this check inline
(`if( local_pnrg_setup_done == false )`), so the added cost there is a second
integer read. Measured, best of 5 x 200M calls, 1 thread:

                       before this branch   after   this commit
  UniformRandom()             10.10 ns     10.31 ns    6.96 ns
  UniformInt()                 5.85 ns     10.83 ns    6.94 ns
  NormalRandom(0,1)           17.71 ns     19.65 ns   19.43 ns

The real cost was the `thread_local` distribution objects, not the check.
`std::uniform_real_distribution` and `std::uniform_int_distribution` hold only
their parameters and their `reset()` is a no-op, so a plain local is free to
construct and produces an identical stream -- verified on four fixed-seed
configs. It also removes the shared `static` int distribution in
`UniformInt()`, which every thread was mutating through `operator()`.
`std::normal_distribution` really is stateful (it caches the second Box-Muller
variate in `_M_saved`), so `NormalRandom()` keeps building one per call exactly
as it always has.

End to end, heterogeneity-sample at one thread, seed 0, best of 2:
76.86 s before this branch, 76.79 s with it, 74.13 s with this commit.

Second, `install_thread_seeds()` now fills `physicell_random_seeds` and stamps
the new generation in one place, so a new set of seeds and the stamp that makes
threads pick it up cannot drift apart. Kept as a function rather than a struct
because `physicell_random_seed` and `physicell_random_seeds` are `extern` in
PhysiCell_utilities.h; moving them into a type would break user code that
touches them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Put the RNG seed state in a class and derive thread seeds on demand

Replaces the loose seed globals with an RNG_State holding the base seed and the
generation stamp, mutable only through reseed(). Three things this buys:

- The base seed can no longer be set without reseeding. `physicell_random_seed`
  was a writable extern; assigning it compiled, changed nothing (the generators
  were already seeded and nothing told them to look again), and left
  random_seed.txt describing a run that did not happen. The same class of bug
  as the stale worker threads fixed two commits ago, now unrepresentable.
- `physicell_random_seeds` is gone. Thread seeds are a closed-form function of
  (base seed, thread index), so caching them in a vector bought nothing and
  cost a bounds check plus a fallback path on the seeding route, for threads
  beyond the configured count. seed_for_thread() is defined for every index, so
  that case is no longer special.
- The header exports two read-only accessors, get_random_seed() and
  get_thread_random_seed(int), instead of two mutable globals.

Behavior is unchanged: identical output on four fixed-seed configs, and
get_thread_random_seed(3) returns the same value the cached vector held.

No measurable cost. Alternating rounds against the previous commit, best of 5 x
200M calls, one thread, UniformRandom(): 8.49 / 8.89 / 8.59 ns before,
8.84 / 8.79 / 8.47 ns after.

Also corrects the numbers in the previous commit message, which compared runs
made minutes apart under different machine load. Measured back to back, and
stable across rounds, UniformRandom() is 12.3-12.9 ns before this PR and
8.5-8.8 ns after -- a ~32% improvement, the same ratio that message reported
from less careful absolute numbers. UniformInt() goes 7.2 -> 8.2-8.8 ns and
NormalRandom() 21.9-22.5 -> 23.7 ns; both gained the seeding check they should
always have had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Seed every thread when the seed is installed, not lazily on first draw

reseed() now sets the base seed and seeds every thread's generator in one
parallel region, so the link between the recorded seed and the streams threads
draw from is a single visible statement. That removes the generation stamp, the
thread_local copy of it, seed_thread_rng_if_needed(), and its four call sites.

The lazy check existed to cover a thread whose index exceeds <omp_num_threads>.
That was robustness this code never had: the original indexed
`physicell_random_seeds[omp_get_thread_num()]` into a vector sized to the
configured thread count, so such a thread read past the end -- undefined
behavior, which is why it produced 7 of 8 and 14 of 16 distinct streams rather
than failing cleanly. Nothing in PhysiCell, its addons, or the sample projects
uses a num_threads clause or changes the thread count after startup; every
main() calls omp_set_num_threads(PhysiCell_settings.omp_num_threads) once. The
eager version gives 5 of 8 and 6 of 16 in that same test: still wrong, but now
defined rather than an out-of-bounds read.

Everything this PR is actually about is unaffected, because every thread is
seeded before it can draw: NormalRandom() and UniformInt() give six distinct
values across six threads on a first call, and reseeding to the same value
twice reproduces. Output is identical to the previous commit on four
fixed-seed configs.

Best of 5 x 200M calls, one thread, alternating rounds:

                       lazy check     eager
  UniformRandom()        6.85-6.92    5.55 ns
  UniformInt()           6.85-6.89    5.30 ns
  NormalRandom(0,1)     19.13-19.24  17.35-17.51 ns

End to end the difference does not clear run-to-run noise (heterogeneity-sample
at one thread: 73.4-75.4 s with the check, 72.7-74.2 s without), so this is for
the simpler code, with the speed as a bonus. 65 lines of code added over
my-physicell, down from 74.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…dels

- mesenchymal_tumor_class1_class2 no longer returns to epithelial_tumor_class1:
  its base transformation rate to that type goes from 1e-2 to 0 in the HTAN
  config, the IMC template and all 48 IMC ROI configs. Class I+II now returns
  to epithelial only as class I+II, like every other class.
- HTAN config: T-cell base speed 0.649298757545331 -> 0.6665 (CD4_Tcell,
  CD8_Tcell, Treg, CD8_exhausted), the value set with the corrected motility
  fit in eed649e, which updated the IMC config only.
- IMC template and ROI configs: CD4_Tcell, CD8_Tcell and Treg base cycle rate
  0 -> 0.00000351636, the HTAN value; CD8_exhausted stays 0 as in HTAN.

Only these values change; every other byte of the 50 files is untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fix the class I+II leak and align T-cell speed and division across models
save/full_data/interval and save/SVG/interval go from 30 to 60 min in the HTAN
config, the IMC template and all 48 IMC ROI configs. A 7-day run now writes
169 saves instead of 337, halving its output. Nothing else changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Save full data and SVG snapshots every 60 min instead of 30

save/full_data/interval and save/SVG/interval go from 30 to 60 min in the HTAN
config, the IMC template and all 48 IMC ROI configs. A 7-day run now writes
169 saves instead of 337, halving its output. Nothing else changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- user_projects/ root: a project saved without a name (Makefile, main.cpp,
  config/, custom_modules/, VERSION.txt).
- config/ics: make-load copies and older IC generations. PCMM passes every
  run's ICs with -i, and the IMC scripts now read the tracked copy under
  user_projects/antigen_presentation/config/ics.
- HTAN project config: copies of sample-project files, other studies' ICs
  and settings, and a config/Makefile copy.
- IMC project config: rule variants, the old ics/substrate folder, Xenium
  ICs, two JHH387ROI1 duct-filler variants, and config/Makefile and
  VERSION.txt copies.

Nothing in the runs or the prep scripts reads these files: regenerating
the IMC run inputs gives byte-identical output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Replace absolute laptop paths with paths relative to PhysiCell/, the
  convention the 48 IMC ROI configs already use.
- Set omp_num_threads to 1 in both templates and the 48 ROI configs, as in
  the run configs.
- IMC template: name its output folder outputs/template and note that it is
  the stage-3 template (generate_roi_configs.py overwrites the domain,
  output folder, ICs and rules folder per ROI; its volumes are the global
  defaults).
- HTAN template: output folder output, an IC file that exists in the
  project, no reference to the disabled ECM file, and the
  spatial_config_index user parameter from the run config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- HTAN project: main.cpp, Makefile and custom.h now match
  data/inputs/custom_codes, so it builds the extended rules engine with the
  argument parser, as the runs do.
- custom.cpp (both projects): keep initialize_default_cell_definition(),
  which PhysiCell needs, correct its comment, and move the secretion sync
  that sat after the comment onto its own commented-out line.
- custom.cpp and custom.h: comment out setup_tissue_domain(), which read the
  domain bounds into locals and never used them.
- VERSION.txt: 1.14.2-drbergman-2.5.1, as in PhysiCell/VERSION.txt.

A build from these files is byte-identical to one from the run custom code,
and 6-hour test runs give byte-identical outputs before and after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ple projects

Merge the 1.14.2-drbergman-3.2.1 release. Everything outside user_projects/
and .gitignore now matches the release exactly, apart from the sample
projects:

- Engine (core, modules, BioFVM), root Makefile and VERSION.txt from 3.2.1:
  per-thread seeds mixed with the process id, per-cell-type death-phase
  flags, a symmetric attack link, thread-safety fixes and stricter CSV
  parsing.
- addons/ as released, restoring PhysiECM and PhysiPKPD, which the 2.5.1
  merge left out; unit_tests, tests and beta take the release versions.
- sample_projects, sample_projects_intracellular and
  sample_projects_physipkpd removed: the model and PCMM runs do not use
  them.
- user_projects: VERSION.txt set to 1.14.2-drbergman-3.2.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Eight rows in both user projects' cell_rules.csv: a tumour cell touching a
CAF or apCAF gains class II (class1 -> class1_class2, no class -> class2) in
both lineages. Their rate is 0, so runs are unchanged; the main repo's
runners switch them on with a rules variation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Halves the output per run (about 0.67 TB instead of 1.3 TB for a full set of
534 runs), to leave room for replicates. Applied to both user-project
templates and the 48 IMC ROI configs, which setup_imc_spatial_pcmm.py turns
into the IMC run config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@gavehan gavehan closed this Sep 25, 2026
@gavehan
gavehan deleted the feature/caf-mhc2-rules branch September 25, 2026 19:00
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.

4 participants