Skip to content

SpatialBuilder Heterogeneous Config Sharing Mechanism

Problem

SpatialBuilder._build_heterogeneous() calls _build_template_for_group() for each config-equivalent group. This function fully replays the builder pipeline (setup → … → build()), calling build_population_config() each time to create a brand new PopulationConfig.

If only a few parameters differ between groups, all large arrays (genotype_to_gametes_map, gametes_to_zygote_map, viability_fitness, fecundity_fitness, etc.) are still duplicated, causing memory waste.

2601 demes, each with a unique carrying_capacity
→ 2601 full PopulationConfig instances
→ Large arrays copied 2601 times

Solution: _replace Fast Path

PopulationConfig is a NamedTuple, and its _replace() method creates a new instance while sharing references to all fields that are not replaced. Leveraging this property, the first group is built in full, and subsequent groups only replace the differing fields:

Group 0: Full builder pipeline → base_config (all arrays)
Group 1: base_config._replace(carrying_capacity=2000)       → shares all large arrays
Group 2: base_config._replace(carrying_capacity=3000)       → shares all large arrays
...
Group N: base_config._replace(initial_individual_count=arr) → rebuilds only initial_individual_count

Parameter Discovery Mechanism

Instead of maintaining a hardcoded allowlist, parameters eligible for _replace are automatically discovered through a layered strategy:

1. Array Fields (Explicit)

Builder parameters that require dict → numpy array conversion, defined in _ARRAY_KWARGS:

Builder kwarg Config Field Conversion Method
individual_count initial_individual_count PopulationConfigBuilder.resolve_*_initial_individual_count()
sperm_storage initial_sperm_storage PopulationConfigBuilder.resolve_age_structured_initial_sperm_storage()

2. Multi-Field Mapping (Explicit)

One builder kwarg mapping to multiple config fields (requiring special scaling), defined in _KWARG_MULTI_FIELD:

Builder kwarg Config Fields
carrying_capacity base_carrying_capacity (raw) + carrying_capacity (× population_scale)
age_1_carrying_capacity Same as above
old_juvenile_carrying_capacity Same as above

3. Renames (Explicit)

Builder kwarg names that differ from config field names, defined in _KWARG_RENAMES:

Builder kwarg Config Field
eggs_per_female expected_eggs_per_female
expected_num_adult_females base_expected_num_adult_females

4. Dynamic Discovery (Implicit)

For kwargs not in the three categories above, hasattr(base_config, kwarg_name) is used to check if it is a valid config field. For example, low_density_growth_rate, juvenile_growth_mode, sex_ratio, sperm_displacement_rate, etc., since the builder kwarg name matches the config field name, no mapping configuration is needed for automatic support.

Adding new batch-able scalar parameters typically does not require modifying the mapping tables — as long as the builder kwarg name matches the config field name.

Parameters not in any of the above categories (presets, fitness, survival rate arrays, etc.) fall back to full builder replay.

Deliberately Unsupportable Heterogeneous Parameters

stochastic and use_continuous_sampling are simulation-mode-level parameters that should not vary between demes. The setup() method does not pass through _detect_and_delegate, so these parameters cannot be provided via batch_setting.

Equilibrium Recalculation

Changes to carrying_capacity, eggs_per_female, and sex_ratio affect expected_competition_strength and expected_survival_rate. These parameters are flagged in _EQUILIBRIUM_SENSITIVE_KWARGS, and after _replace completes, compute_equilibrium_metrics() is automatically called to recalculate.

Array Field Conversion

The values for individual_count and sperm_storage are user-provided dicts (e.g., {"female": {"WT|WT": 100}}), which must be converted to numpy arrays before _replace. Conversion is done via static methods on PopulationConfigBuilder:

  • Age-structured: resolve_age_structured_initial_individual_count(species, distribution, n_ages, new_adult_age)
  • Discrete generation: resolve_discrete_initial_individual_count(species, distribution)

The result is multiplied by population_scale to match builder behavior.

State Overwrite After Cloning

_clone_deme() copies state data from the template. When individual_count or sperm_storage differs between groups, the _replace path additionally overwrites the corresponding state arrays after cloning:

group_template = _clone_deme(base_template, config=variant_config, name=...)
# _clone copies state from base_template; overwrite with config values
state = group_template._require_state()
if "individual_count" in sig_map:
    state.individual_count[:] = variant_config.initial_individual_count
if "sperm_storage" in sig_map:
    ss = getattr(state, 'sperm_storage', None)
    if ss is not None:
        ss[:] = variant_config.initial_sperm_storage

_build_heterogeneous Flow

_build_heterogeneous()
  ├─ 1. Expand all batch_settings into per-deme value lists
  ├─ 2. Compute config signature (hash) for each deme
  ├─ 3. Group by signature
  └─ 4. For each group:
       ├─ First group → _build_template_for_group()
       │                 Full builder pipeline, produces base_config + base_template
       ├─ Subsequent groups + _can_use_replace(sig_map, base_config)
       │   │  All kwargs pass through explicit mapping or dynamic hasattr discovery
       │   │
       │   ├─ _build_variant_config(sig_map, base_config)
       │   │   │
       │   │   ├─ Array fields → PopulationConfigBuilder.resolve_* → _replace
       │   │   ├─ Multi-field → _replace(base=raw, scaled=raw*pop_scale)
       │   │   ├─ Renames → _replace(renamed_field=val)
       │   │   ├─ Dynamic discovery → hasattr → _replace
       │   │   └─ Equilibrium-sensitive → compute_equilibrium_metrics()
       │   │
       │   └─ _clone_deme(base_template, variant_config)
       │        └─ Overwrite state corresponding to array-valued batch settings
       └─ Subsequent groups + non-replaceable
           └─ _build_template_for_group()  (full replay, behavior unchanged)

Memory Impact

With 2601 demes and only carrying_capacity differing:

Item Before Optimization After Optimization
genotype_to_gametes_map 2601 copies 1 copy (shared)
gametes_to_zygote_map 2601 copies 1 copy (shared)
viability_fitness 2601 copies 1 copy (shared)
fecundity_fitness 2601 copies 1 copy (shared)
carrying_capacity (scalar) 2601 copies 2601 copies (~60KB)
initial_individual_count 2601 copies 1 copy (all demes homogeneous)

With 2601 demes and only initial_individual_count differing:

Item Before Optimization After Optimization
genotype_to_gametes_map 2601 copies 1 copy (shared)
gametes_to_zygote_map 2601 copies 1 copy (shared)
All fitness arrays 2601 copies 1 copy (shared)
initial_individual_count 2601 copies 2601 copies (must differ)

File Location

All changes are concentrated in src/natal/spatial_builder.py:

Symbol Role
_ARRAY_KWARGS Set of parameters requiring dict→array conversion
_KWARG_MULTI_FIELD Multi-field mapping (carrying_capacity variants)
_KWARG_RENAMES Builder kwarg → config field renames
_EQUILIBRIUM_SENSITIVE_KWARGS Set of parameters requiring equilibrium recalculation
SpatialBuilder._build_heterogeneous() Main build logic
SpatialBuilder._can_use_replace(sig_map, base_config) Determines whether _replace can be used
SpatialBuilder._build_variant_config() Creates variant config