Migrate Track 1 extinction layer into renunney

This commit is contained in:
Codex 2026-04-11 06:48:54 -04:00
parent 8e4b4eb216
commit 7ea94aa7fd
6 changed files with 379 additions and 10 deletions

View File

@ -26,6 +26,7 @@ plane and the Track 1 runner/API boundary are now local to `renunney`.
- a local Track 1 threshold/search layer for Nunney-style threshold checks, - a local Track 1 threshold/search layer for Nunney-style threshold checks,
- a local Track 1 simulation kernel, - a local Track 1 simulation kernel,
- a local Track 1 report generator, - a local Track 1 report generator,
- a local Track 1 extinction-model data layer,
- a Makefile for common tasks, - a Makefile for common tasks,
- migration notes for pulling code into this repo in stages. - migration notes for pulling code into this repo in stages.
@ -90,7 +91,8 @@ The current state is split:
- Track 1 threshold/search layer: local to `renunney` - Track 1 threshold/search layer: local to `renunney`
- Track 1 simulation kernel: local to `renunney` - Track 1 simulation kernel: local to `renunney`
- Track 1 report generator: local to `renunney` - Track 1 report generator: local to `renunney`
- Track 1 dataset, fit, and extinction-model helpers: still imported - Track 1 extinction-model data layer: local to `renunney`
- Track 1 dataset and fit helpers: still imported
from the older `cost_of_substitution` directory through the local from the older `cost_of_substitution` directory through the local
compatibility layer compatibility layer

View File

@ -35,12 +35,13 @@ Operational code still lives in:
- `src/renunney/track1_reference.py` - `src/renunney/track1_reference.py`
7. Track 1 report layer has been migrated locally: 7. Track 1 report layer has been migrated locally:
- `src/renunney/track1_report.py` - `src/renunney/track1_report.py`
8. Migrate dataset, fit, and orchestration-adjacent Track 1 modules next: 8. Track 1 extinction-model data layer has been migrated locally:
- `src/renunney/track1_extinction.py`
9. Migrate dataset and fit modules next:
- `python/track1_dataset.py` - `python/track1_dataset.py`
- `python/track1_fit.py` - `python/track1_fit.py`
- `python/track1_extinction.py` 10. Reduce or remove the remaining compatibility-layer imports after those modules are local.
9. Reduce or remove the remaining compatibility-layer imports after those modules are local. 11. Migrate docs and example configs last, after path references are updated.
10. Migrate docs and example configs last, after path references are updated.
## Constraint ## Constraint

View File

@ -48,8 +48,8 @@ make status
The Makefile now drives the local orchestration code in `renunney`, while the The Makefile now drives the local orchestration code in `renunney`, while the
Track 1 runner/API boundary, analysis layer, threshold/search layer, and Track 1 runner/API boundary, analysis layer, threshold/search layer, and
simulation kernel and report generator are also local to `renunney`. The simulation kernel, report generator, and extinction-model data layer are also
remaining Track 1 dataset/fit helpers are still imported from the legacy local to `renunney`. The remaining Track 1 dataset/fit helpers are still
`cost_of_substitution` directory through the compatibility layer in imported from the legacy `cost_of_substitution` directory through the
`src/renunney/legacy.py`. The paper-scale Figure 1 configs used for submission compatibility layer in `src/renunney/legacy.py`. The paper-scale Figure 1
are now local to `renunney/config`. configs used for submission are now local to `renunney/config`.

View File

@ -28,6 +28,13 @@ from .track1_analysis import (
sweep_number_of_loci, sweep_number_of_loci,
) )
from .track1_api import Track1RunConfig, config_from_mapping, load_config, run_config, save_payload from .track1_api import Track1RunConfig, config_from_mapping, load_config, run_config, save_payload
from .track1_extinction import (
ExtinctionGenerationRow,
ExtinctionRunRow,
build_extinction_generation_rows,
build_extinction_run_row,
save_jsonl,
)
from .track1_reference import ( from .track1_reference import (
GenerationSummary, GenerationSummary,
PopulationState, PopulationState,
@ -106,6 +113,8 @@ __all__ = [
"expected_female_productivity", "expected_female_productivity",
"expected_mutations_for_population", "expected_mutations_for_population",
"evaluate_threshold_candidate", "evaluate_threshold_candidate",
"ExtinctionGenerationRow",
"ExtinctionRunRow",
"female_fecundity", "female_fecundity",
"female_fraction", "female_fraction",
"fit_linear_cost_by_loci", "fit_linear_cost_by_loci",
@ -114,6 +123,8 @@ __all__ = [
"generate_report_bundle", "generate_report_bundle",
"initialize_population", "initialize_population",
"is_extinct", "is_extinct",
"build_extinction_generation_rows",
"build_extinction_run_row",
"load_config", "load_config",
"nunney_threshold_accepts", "nunney_threshold_accepts",
"paper_mutation_supply_M", "paper_mutation_supply_M",
@ -128,6 +139,7 @@ __all__ = [
"simulate_run", "simulate_run",
"run_extinction_check", "run_extinction_check",
"save_payload", "save_payload",
"save_jsonl",
"search_threshold_over_candidates", "search_threshold_over_candidates",
"summarize_tracking", "summarize_tracking",
"summarize_generation", "summarize_generation",

View File

@ -0,0 +1,259 @@
"""
track1_extinction.py
Dataset builders and analysis scaffolding for extinction-risk modeling on top
of the Track 1 simulation output.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass
import json
from pathlib import Path
from typing import Iterable
from .track1_analysis import TrackingSummary, summarize_tracking
from .track1_reference import GenerationSummary, Track1Parameters
@dataclass(frozen=True)
class ExtinctionGenerationRow:
"""Per-generation covariate row for discrete-time extinction modeling."""
seed: int
t: int
K: int
N0: int
n: int
u: float
M: float
R: float
T: int
epochs: int
p: float
N: int
female_count: int
male_count: int
female_fraction: float
fecundity: float
mean_fitness: float
mean_expected_female_productivity: float
target_value: float
mean_allele_value: float
mean_genotype_value: float
mean_tracking_gap: float
abs_tracking_gap: float
birth_count: int
surviving_offspring_count: int
survival_fraction: float
replacement_deficit: float
expected_mutation_count: float
realized_mutation_count: int
mutation_shortfall: float
realized_mutation_rate_per_allele: float
ne_approx: float
extinction_occurred: bool
extinction_next_step: bool
@dataclass(frozen=True)
class ExtinctionRunRow:
"""Run-level summary row for finite-horizon extinction modeling."""
seed: int
K: int
N0: int
n: int
u: float
M: float
R: float
T: int
epochs: int
p: float
generations_recorded: int
extinction_occurred: bool
first_extinction_t: int | None
final_extinct: bool
final_N: int
min_N: int
max_N: int
mean_N: float
final_mean_allele_value: float
final_target_value: float
final_tracking_gap: float
mean_abs_tracking_gap: float
max_abs_tracking_gap: float
first_nonzero_allele_t: int | None
last_nonzero_allele_t: int | None
stayed_zero_after_initialization: bool
first_productivity_below_replacement_t: int | None
fraction_generations_below_replacement: float
longest_zero_mutation_streak: int
cumulative_expected_mutations: float
cumulative_realized_mutations: int
cumulative_mutation_shortfall: float
def _survival_fraction(summary: GenerationSummary) -> float:
if summary.birth_count <= 0:
return 0.0
return float(summary.surviving_offspring_count / summary.birth_count)
def _replacement_deficit(summary: GenerationSummary) -> float:
return float(max(0.0, 2.0 - summary.mean_expected_female_productivity))
def _mutation_shortfall(summary: GenerationSummary) -> float:
return float(max(0.0, summary.expected_mutations_current_N - summary.realized_mutation_count))
def build_extinction_generation_rows(
params: Track1Parameters,
summaries: Iterable[GenerationSummary],
seed: int,
) -> list[ExtinctionGenerationRow]:
summary_list = list(summaries)
rows: list[ExtinctionGenerationRow] = []
for index, summary in enumerate(summary_list):
next_extinct = bool(summary_list[index + 1].extinct) if index + 1 < len(summary_list) else bool(summary.extinct)
rows.append(
ExtinctionGenerationRow(
seed=seed,
t=int(summary.t),
K=params.K,
N0=params.N0,
n=params.n,
u=params.u,
M=params.M,
R=params.R,
T=params.T,
epochs=params.epochs,
p=params.p,
N=int(summary.N),
female_count=int(summary.female_count),
male_count=int(summary.male_count),
female_fraction=float(summary.female_fraction),
fecundity=float(summary.fecundity),
mean_fitness=float(summary.mean_fitness),
mean_expected_female_productivity=float(summary.mean_expected_female_productivity),
target_value=float(summary.target_value),
mean_allele_value=float(summary.mean_allele_value),
mean_genotype_value=float(summary.mean_genotype_value),
mean_tracking_gap=float(summary.mean_tracking_gap),
abs_tracking_gap=float(abs(summary.mean_tracking_gap)),
birth_count=int(summary.birth_count),
surviving_offspring_count=int(summary.surviving_offspring_count),
survival_fraction=_survival_fraction(summary),
replacement_deficit=_replacement_deficit(summary),
expected_mutation_count=float(summary.expected_mutations_current_N),
realized_mutation_count=int(summary.realized_mutation_count),
mutation_shortfall=_mutation_shortfall(summary),
realized_mutation_rate_per_allele=float(summary.realized_mutation_rate_per_allele),
ne_approx=float(summary.ne_approx),
extinction_occurred=bool(summary.extinct),
extinction_next_step=next_extinct,
)
)
return rows
def build_extinction_run_row(
params: Track1Parameters,
summaries: Iterable[GenerationSummary],
seed: int,
) -> ExtinctionRunRow:
summary_list = list(summaries)
tracking: TrackingSummary = summarize_tracking(summary_list)
if not summary_list:
return ExtinctionRunRow(
seed=seed,
K=params.K,
N0=params.N0,
n=params.n,
u=params.u,
M=params.M,
R=params.R,
T=params.T,
epochs=params.epochs,
p=params.p,
generations_recorded=0,
extinction_occurred=False,
first_extinction_t=None,
final_extinct=True,
final_N=0,
min_N=0,
max_N=0,
mean_N=0.0,
final_mean_allele_value=0.0,
final_target_value=0.0,
final_tracking_gap=0.0,
mean_abs_tracking_gap=0.0,
max_abs_tracking_gap=0.0,
first_nonzero_allele_t=None,
last_nonzero_allele_t=None,
stayed_zero_after_initialization=True,
first_productivity_below_replacement_t=None,
fraction_generations_below_replacement=0.0,
longest_zero_mutation_streak=0,
cumulative_expected_mutations=0.0,
cumulative_realized_mutations=0,
cumulative_mutation_shortfall=0.0,
)
below_replacement = [s for s in summary_list if s.mean_expected_female_productivity < 2.0]
first_below_replacement_t = None if not below_replacement else int(below_replacement[0].t)
zero_mutation_streak = 0
longest_zero_mutation_streak = 0
for summary in summary_list:
if summary.realized_mutation_count == 0:
zero_mutation_streak += 1
longest_zero_mutation_streak = max(longest_zero_mutation_streak, zero_mutation_streak)
else:
zero_mutation_streak = 0
cumulative_expected = float(sum(s.expected_mutations_current_N for s in summary_list))
cumulative_realized = int(sum(s.realized_mutation_count for s in summary_list))
cumulative_shortfall = float(sum(_mutation_shortfall(s) for s in summary_list))
final = summary_list[-1]
return ExtinctionRunRow(
seed=seed,
K=params.K,
N0=params.N0,
n=params.n,
u=params.u,
M=params.M,
R=params.R,
T=params.T,
epochs=params.epochs,
p=params.p,
generations_recorded=len(summary_list),
extinction_occurred=tracking.extinction_occurred,
first_extinction_t=tracking.first_extinction_t,
final_extinct=bool(final.extinct),
final_N=int(final.N),
min_N=min(int(s.N) for s in summary_list),
max_N=max(int(s.N) for s in summary_list),
mean_N=float(sum(s.N for s in summary_list) / len(summary_list)),
final_mean_allele_value=float(tracking.final_mean_allele_value),
final_target_value=float(tracking.final_target_value),
final_tracking_gap=float(tracking.final_tracking_gap),
mean_abs_tracking_gap=float(tracking.mean_abs_tracking_gap),
max_abs_tracking_gap=float(tracking.max_abs_tracking_gap),
first_nonzero_allele_t=tracking.first_nonzero_allele_t,
last_nonzero_allele_t=tracking.last_nonzero_allele_t,
stayed_zero_after_initialization=bool(tracking.stayed_zero_after_initialization),
first_productivity_below_replacement_t=first_below_replacement_t,
fraction_generations_below_replacement=float(len(below_replacement) / len(summary_list)),
longest_zero_mutation_streak=longest_zero_mutation_streak,
cumulative_expected_mutations=cumulative_expected,
cumulative_realized_mutations=cumulative_realized,
cumulative_mutation_shortfall=cumulative_shortfall,
)
def save_jsonl(rows: Iterable[object], path: str | Path) -> None:
out = Path(path)
out.parent.mkdir(parents=True, exist_ok=True)
with out.open("w", encoding="utf-8") as handle:
for row in rows:
handle.write(json.dumps(asdict(row), sort_keys=True) + "\n")

View File

@ -0,0 +1,95 @@
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SRC_DIR = ROOT / "src"
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
import renunney.track1_extinction as ext
import renunney.track1_reference as ref
def _sample_summaries():
return [
ref.GenerationSummary(
t=0,
N=10,
female_fraction=0.5,
male_count=5,
female_count=5,
fecundity=3.0,
mean_fitness=0.8,
mean_expected_female_productivity=2.4,
target_value=0.0,
mean_allele_value=0.2,
mean_genotype_value=0.2,
mean_tracking_gap=0.2,
paper_M=0.05,
expected_mutations_current_N=0.1,
realized_mutation_count=0,
realized_mutation_rate_per_allele=0.0,
birth_count=6,
surviving_offspring_count=4,
ne_approx=5.0,
extinct=False,
),
ref.GenerationSummary(
t=1,
N=0,
female_fraction=0.0,
male_count=0,
female_count=0,
fecundity=0.0,
mean_fitness=0.0,
mean_expected_female_productivity=0.0,
target_value=0.1,
mean_allele_value=0.0,
mean_genotype_value=0.0,
mean_tracking_gap=-0.1,
paper_M=0.05,
expected_mutations_current_N=0.0,
realized_mutation_count=0,
realized_mutation_rate_per_allele=0.0,
birth_count=0,
surviving_offspring_count=0,
ne_approx=0.0,
extinct=True,
),
]
def test_build_extinction_generation_rows_derives_expected_fields():
params = ref.Track1Parameters(K=5000, N0=20, n=1, u=5e-6, R=10.0, T=20)
rows = ext.build_extinction_generation_rows(params, _sample_summaries(), seed=7)
assert len(rows) == 2
assert rows[0].M == 0.05
assert rows[0].survival_fraction == 4 / 6
assert rows[0].replacement_deficit == 0.0
assert rows[0].mutation_shortfall == 0.1
assert rows[0].extinction_next_step is True
assert rows[1].extinction_occurred is True
def test_build_extinction_run_row_summarizes_extinction_and_mutation_streaks():
params = ref.Track1Parameters(K=5000, N0=20, n=1, u=5e-6, R=10.0, T=20)
row = ext.build_extinction_run_row(params, _sample_summaries(), seed=7)
assert row.extinction_occurred is True
assert row.first_extinction_t == 1
assert row.first_productivity_below_replacement_t == 1
assert row.longest_zero_mutation_streak == 2
assert row.cumulative_expected_mutations == 0.1
assert row.cumulative_realized_mutations == 0
def test_save_jsonl_writes_row_data(tmp_path: Path):
params = ref.Track1Parameters(K=5000, N0=20, n=1, u=5e-6, R=10.0, T=20)
row = ext.build_extinction_run_row(params, _sample_summaries(), seed=7)
path = tmp_path / "runs.jsonl"
ext.save_jsonl([row], path)
lines = path.read_text(encoding="utf-8").splitlines()
assert len(lines) == 1
parsed = json.loads(lines[0])
assert parsed["seed"] == 7
assert parsed["extinction_occurred"] is True