Modernize codebase and add a pluggable organ-allocation-strategy framework#37
Open
zspatter wants to merge 61 commits into
Open
Modernize codebase and add a pluggable organ-allocation-strategy framework#37zspatter wants to merge 61 commits into
zspatter wants to merge 61 commits into
Conversation
Adds pyproject.toml (Python 3.12 target, packaging metadata, pytest/mypy/ ruff config) and a GitHub Actions workflow, replacing the defunct Travis CI setup (Travis dropped free OSS builds years ago). Refreshes requirements.txt pins and cleans up .gitignore (was hardcoded to cpython-37 paths, missing .pytest_cache/.ruff_cache/egg-info).
BloodType.__eq__/__ne__ and several test files compared ints (and one bare literal in a source file) using `is`/`is not`, which only "works" today via CPython's small-int caching and emits a SyntaxWarning on modern Python - technically undefined behavior for values outside that cache range.
…llocator - WaitList.get_prioritized_patients no longer relies on heapq's private _heapify_max/_heappop_max (undocumented CPython internals); it now stores (-priority, insertion_order, patient) tuples and drains with the public heapq API. - Patient gains __hash__ (defining __eq__ without it makes instances unhashable, which blocks using Patient in sets/dicts) and a rounds_waited field, incremented via the new WaitList.increment_wait_times(), giving time-aware scoring a real signal to react to. - OrganAllocator is retired: WaitList's return-type change breaks it, and it's being replaced by the allocation-strategy package (added in a later commit). execute/simulator.py and execute/simulation.py still reference the old OrganAllocator import at this point and will be fixed once the replacement lands - they aren't covered by CI (pytest/mypy only cover network_simulator).
ConnectivityChecker, GraphConverter, and OrganList declare parameters typed as e.g. `int = None` without wrapping in Optional. This was allowed under mypy's old implicit-Optional default; mypy now defaults to no_implicit_optional=True (PEP 484), so these need to be explicit.
… units Replaces the scraping-based distance pipeline in import_hospitals.py (distance-cities.com + requests_html/pyppeteer JS-rendering fallback, both now dead weight - requests_html is effectively abandoned, and the target site has since moved) with a pure-Python haversine calculation from the hospital coordinates already present in the checked-in workbook. Node gains latitude/longitude fields; Network's docstring documents the resulting unit convention (edge weight = estimated transit hours). Also fixes a real unit-mismatch bug this surfaced: Organ.get_viability returned hours-times-10 (e.g. 60 for a 6-hour heart) while the new transit-hour weights (and Organ.get_operation_buffer, added here) are in real hours - comparing them directly would have made the viability check almost a no-op. Rescales get_viability to real hours and adds get_operation_buffer: the additional time a match must reserve for the transplant procedure itself, not just transport, sourced from typical operating-room durations per organ (previously a flat, much-too-small 1-hour margin applied to every organ type alike).
Introduces network_simulator.allocation: matching algorithms decide *which* feasible organ/patient pairs to form, scoring functions decide *how good* a match is, and the two axes are independent so a benchmark can attribute a result to the matching algorithm, the scoring model, or both. - feasibility.py: single source of truth for what counts as a valid match (blood/organ type compatibility, operation-buffer-aware viability check), caching one Dijkstra run per distinct organ origin per batch. - matchers/greedy.py: GreedyMatcher, processing organs one at a time and assigning each to its highest-scoring available patient (today's original behavior, now parameterized by scoring function). - matchers/optimal.py: OptimalMatcher, solving one batch as a maximum-weight bipartite matching via networkx (already a dependency, avoids adding scipy) so an early low-value match can't crowd out a better one available to a later organ. maxcardinality=False lets organs/patients with no good match go legitimately unmatched. - scoring.py: PriorityScore (today's raw priority) and CompositeScore (urgency + wait time + geography combined, inspired by real OPTN/UNOS allocation policy). - strategies.py: STRATEGIES registry pairing matchers with scorers (baseline, optimal_priority, optimal_composite, greedy_composite) for benchmarking.
GraphBuilder/PatientGenerator/OrganGenerator (and the compatibility_markers random_* classmethods they call) previously called the bare global random module; GraphBuilder even had a dead `seed` parameter that was accepted but never actually used. All now take an optional random.Random instance, falling back to the global module when omitted, so a caller can drive network topology and every round's arrivals identically across strategies - required for a fair comparison. (compatibility_markers.py's Enum member annotations are also corrected here alongside the classmethod signatures, for mypy's benefit.) execute/benchmark_strategies.py runs each named strategy from STRATEGIES across many seeded multi-round simulations (generate patients -> harvest organs -> allocate -> increment wait times, repeated) and prints a comparison table: organs transplanted, organs wasted, total priority served, and a fairness spread across priority tiers.
Adds a "Select Allocation Strategy" menu item to execute/simulator.py's main loop, persisting the choice across harvests; allocate_organs() now calls the selected Strategy instead of the retired OrganAllocator, printing match/no-match messages itself since matchers are side-effect-free. Also fixes execute/simulation.py's now-dangling OrganAllocator import (a standalone demo script, broken since OrganAllocator was retired a few commits back) to use STRATEGIES['baseline'] instead.
Replaces the dead Coveralls/CodeClimate/LGTM badges with a CI badge, documents the corrected match-feasibility criteria (operation time, not just transport time), and adds an Allocation Strategies section describing the matcher/scorer framework and the four strategies the benchmark harness compares.
Adds tests for every previously-untested branch across the package (__str__ methods on Node/Organ/Patient/BloodType/OrganList/WaitList/ Network, Network's dangling-edge cleanup and adjacency-mirroring on construction, __iter__, regional-weight edges, GraphConverter's convert_to_attribute_nx, SubnetworkGenerator's unsupported-type branch, Patient's ordering-operator NotImplemented paths and hashability, and OptimalMatcher's non-positive-score exclusion from the matching graph). network_simulator is now at 100% line and branch coverage. Also found and fixed 18 more instances of the same is/is-not-for-values bug from an earlier commit that the original mechanical sweep missed: several tests compared `len(...)` call results with `is`/`is not` instead of `==`/`!=` (test_network.py, test_node.py) - these don't trigger a SyntaxWarning like the literal-int case does, since the right-hand side isn't a literal, making them easier to miss.
…ibutions Adds network_simulator/clinical/ with frequencies.py holding the real distributions and a seeded weighted_choice helper: - patient organ need sampled from the US wait-list composition (~85% kidney) instead of uniform 1/6 - blood types sampled from the US population joint (letter, polarity) table (O+ 37% ... AB- 0.6%) instead of uniform - donor organ recovery per-organ (kidney ~0.95 and yields two, heart ~0.30, etc.) instead of a flat 75% per organ type This is the foundation for the clinical-realism work: a benchmark tuned on a uniform demand/supply mix is tuned on a fiction. Existing generator tests updated for the new supply bound (kidney yields two per donor) and a lingering is/is-not int comparison fixed in test_patient_generator.
Patient gains acuity, raw_urgency, body_size, unacceptable_antigens, and cpra; Organ gains hla_antigens and donor_size. All are optional keyword args with harmless defaults, appended after existing params, so the 73 positional construction sites across the codebase are unaffected. The Patient clinical fields are deliberately excluded from __eq__/__ne__/ __hash__: they are mutable per-round state (a patient who has deteriorated is still the same patient), and folding them into equality would break remove_patient and the deepcopy-clone tests. Later phases populate and act on these fields (feasibility gates, urgency scoring, deterioration/mortality).
Adds three clinical modules and wires them into feasibility as organ-specific gates, keeping feasibility.py the single source of truth: - hla.py: donors carry an HLA antigen set, patients carry unacceptable antigens (their antibodies); crossmatch is positive iff the sets intersect, and cPRA is computed exactly from the sensitization. Patient sensitization is tiered so ~70% are unsensitized and ~10% highly sensitized (cPRA>0.9), resembling the real distribution. - size.py: donor/recipient body-size compatibility within an organ-specific tolerance (heart +/-30%, lung +/-25%); unconstrained for other organs. - gates.py: GATES_BY_ORGAN registry - size gate for heart/lung, crossmatch gate for kidney. Gates are pure functions of already-generated fields, so feasibility stays deterministic. Generators now populate donor antigens/size (shared across a donor's organs) and patient unacceptable antigens/cPRA/body size. On generated data these gates bite realistically: a kidney organ matches only a fraction of blood-compatible patients once crossmatch is applied.
Introduces the acuity model so waiting finally has a downside: - urgency.py: each organ has a native urgency scale (liver MELD, lung LAS, heart status tier; kidney is dialysis-sustained and low), generated at realistic initial values, progressed each round (disease advances), and normalized to a common acuity in (0, 1] via documented anchor points. - mortality.py: acuity maps to an annual death rate (geometric across the scale) and then to a per-round death probability via a constant-hazard model over a 1-week round. Calibrated so a constant-severity liver cohort reproduces published MELD 90-day mortality (~4% / ~36% / ~84% for the <=20 / 21-30 / 31-40 bands) and kidney ~6%/yr, verified by tests. - progression.py: simulate_round_progression advances every waiting patient, recomputes acuity, rolls death, removes and returns the dead. Kept out of WaitList (a pure collection) and out of the matchers (death is a third outcome), matching the "callers apply side effects explicitly" convention. PatientGenerator now seeds each patient's initial urgency/acuity. The benchmark loop (next phase) will call simulate_round_progression each round so sickest-first strategies can demonstrably save lives.
Adds AcuityScore (ranks purely by near-term death risk, coherent across organ types thanks to the normalized acuity scale) and extends ScoreWeights/ CompositeScore with an acuity coefficient defaulting to 0.0 - so the existing composite arithmetic and its test are unchanged. Registers two new strategies alongside the original four: - optimal_acuity: "sickest first", the life-saving policy the pre-clinical model couldn't express - composite_acuity: acuity + waiting time + geography, optimally matched The four baselines are kept so the benchmark can measure exactly what acuity-awareness buys.
…-death metrics The benchmark round loop now calls simulate_round_progression after each allocation, so patients left waiting deteriorate and some die. TrialMetrics gains the outcomes that actually distinguish allocation policies: wait-list deaths (total, by organ, and split high- vs low-acuity), median wait to transplant, and a life-years-saved proxy. print_report leads with deaths and high-acuity deaths rather than raw throughput. This is the payoff of the clinical-realism work: with organs scarce, the sickest-first strategies (optimal_acuity / composite_acuity) demonstrably reduce deaths and high-acuity deaths versus the priority baselines while transplanting comparably - a life-saving trade-off the pre-clinical model was structurally unable to see. All trials remain deterministic per seed (one rng drives arrivals, allocation, and mortality in fixed order). Also adds direct tests for the enum random_* helpers (no longer exercised via the generators, which now use the frequency-weighted samplers) to hold the 100% coverage bar.
Expands the README into a complete reference for the current system, not just the delta from this session: - Adds a Getting Started section (install, run the simulator/benchmark, run the test/type/lint suite) and a Project Layout overview. - Documents the full domain model, including the clinical fields added to Patient/Organ (acuity, raw_urgency, body_size, unacceptable_antigens, cpra, hla_antigens, donor_size) and why they're excluded from equality. - Documents all six allocation strategies (adding optimal_acuity and composite_acuity) and the feasibility/scoring architecture behind them. - Adds a Clinical Realism section describing the frequencies / hla / size / gates / urgency / mortality / progression modules, their calibration, and the death-based benchmark metrics. - Expands Scripts to cover every script under execute/ (previously only 3 of 14 were documented), grouped into interactive/benchmark, the real-hospital- network data pipeline, and demo/utility scripts. - Removes the UML diagram (UML.png) and its section: it predates the allocation-strategy and clinical packages and no longer reflects the system's structure; the README's own module documentation now fills that role.
Benchmark comparisons were mean/stdev over 10 seeds with no significance testing, and the only "real" comparator was a synthetic priority-only baseline. This adds: - execute/benchmark_stats.py: paired permutation testing, bootstrap CIs, paired effect size, Holm-Bonferroni correction, and sample-size planning (pure Python, no new dependency), wired into benchmark_strategies.py as a second significance table. - RealWorldScore: organ-native OPTN-calibrated scoring (KAS-style kidney points, MELD, LAS, inverted heart status tiers) instead of one uniform formula. - TieredMatcher + allocation.geography: a hard geographic-constraint matcher modeling the real allocation waterfall (local -> regional -> national), with classifiers for the legacy arbitrary-region system, the current distance-circle system, and no constraint at all - verified against current OPTN sources, since the old 11-Region/58-DSA system was actually eliminated 2018-2021. - Four new strategies (real_world_region/circle/unconstrained, optimal_real_world_circle) isolating the geographic-constraint axis, so the benchmark can directly measure what that constraint costs or buys. 100% coverage maintained on network_simulator; mypy and ruff clean on every changed file.
…cripts ruff/mypy cleanup (43 pre-existing lint errors -> 0): - Auto-fixed unsorted imports and extraneous f-string prefixes. - Wrapped overlong lines. - Organ.py/Patient.py's forward-reference type hints now use TYPE_CHECKING-guarded imports instead of quoted strings + # type: ignore. - compatibility_markers.py's BloodTypeLetter.O gets a documented noqa (real blood-type letter, not a renameable ambiguous variable). Expanded test suite (169 -> 223 tests) across execute/, uncovering and fixing three real bugs along the way: - simulator.py called main_menu() unconditionally at module level, so merely importing it launched an interactive input() loop. Now guarded by if __name__ == '__main__'. - get_coordinates.py hardcoded a Bing Maps API key in source and called .value.lower() on a possibly-None header cell, crashing on any worksheet with fewer than 8 header columns. Key now read from BING_MAPS_API_KEY; the crash is fixed. (The previously-hardcoded key is already in git history and should be rotated independently.) - import_edge_list.py read row[3] for a weighted edge's weight column, but a 3-column row only has valid indices 0-2 - any real weighted edge list would have crashed with IndexError. Fixed to row[2]. import_hospitals.py and get_coordinates.py also had a few functions refactored to take column_indices/neighbor_regions/api_key as explicit parameters instead of relying on implicit script-level globals, which was both a testability blocker and a design smell. Added direct unit tests for the previously-untested pure-function scripts (export_edgelist, hospital_count, make_tsv, import_edge_list, generate_networks, export_hospital_network) and subprocess smoke tests for the top-level demo scripts (simulation.py, network_demo.py, subnetwork_demo.py, networkx_demo.py - the last skips cleanly since matplotlib isn't a declared project dependency). 100% coverage maintained on network_simulator throughout.
…national scenario reports Real hospital network pipeline: - Retire the 2019 xlsx + Bing Maps pipeline (Bing Maps' free tier was retired by Microsoft June 30, 2025) in favor of the current OPTN membership CSV, geocoded via the free Census Bureau batch geocoder with a rate-limited Nominatim fallback for institutional addresses Census can't match. - import_hospitals.py now keeps both Transplant Hospital and OPO (Independent/Hospital Based) rows as distinct network nodes - only transplant hospitals perform transplants, but OPOs are who actually procures organs, and the two are administratively separate even when co-located under the same centerCode. - PatientGenerator/OrganGenerator gained an optional eligible_nodes parameter so patients only generate at transplant-hospital nodes while organs can originate at either role. OptimalMatcher fix (found while stress-testing at national scale): - The previous networkx-based general-graph max_weight_matching both took minutes and crashed the interpreter outright once the wait-list backlog reached tens of thousands of candidates. Rewrote it around scipy.optimize.linear_sum_assignment, solved per organ type (organ types never share candidates, so this is exact, not an approximation) - fixes the crash and cuts realistic-scale runtime to sub-second. execute/scenario_report.py (new): generates shareable Markdown/CSV (PDF opt-in via the new optional reportlab dependency) snapshot reports comparing allocation strategies over 1/5/10-year horizons on the real network, calibrated to OPTN/SRTR 2024 arrival/donor rates. Defaults to a curated 4-tier strategy delta (current policy -> algorithmic-only improvement -> constraint dropped -> theoretical ceiling) at 10% of national volume by default (a single full-scale trial measured 850s and would take hours across the full run) with --scale/--strategies overrides to spend more compute for a fuller comparison. 269 tests passing, 100% coverage on network_simulator maintained.
Matches pyproject.toml's declared floor (requires-python >=3.12).
.envrc uses direnv's built-in `layout pyenv` to create and activate a virtualenv keyed off .python-version, without needing a virtualenv name recorded anywhere - keeps .python-version portable (plain version string, readable by both pyenv-win on Windows and real pyenv on WSL/Linux) while still getting automatic per-directory activation on the WSL side. .direnv/ (the layout-managed venv cache) is gitignored, same as .venv/.
layout pyenv reads .python-version via command substitution, which direnv doesn't automatically track - without watch_file, editing .python-version alone (without touching .envrc itself) wouldn't invalidate direnv's cache, leaving a stale virtualenv active until a manual `direnv reload`.
Human-readable outputs (markdown/PDF) now use thousands separators throughout; CSVs deliberately stay separator-free for machine consumption. The sig column prints an explicit yes/no instead of '*'/'' - an empty cell read as a rendering bug rather than a finding. The real fix: the sign-flip permutation test's p-value floor is 2/2^n (identity + full flip always tie the observed mean), so at the old defaults - 5 seeds for 1-year, 3 for longer - Holm-corrected significance at 0.05 was mathematically unattainable no matter how large the true effect. The first real report showed exactly that: paired Cohen's d of 6.7 and 9.6 with adj. p stuck at 0.724 (the 3-seed floor of 0.75, minus Monte Carlo noise), presented as a negative finding. Changes: - paired_permutation_test enumerates all 2^n sign patterns exactly whenever that costs no more than the Monte Carlo budget, removing resampling noise at the seed counts this project actually runs - new min_achievable_p / seeds_for_significance helpers document and compute the floor (>=7 seeds needed for 3 Holm-corrected comparisons) - the report annotates any significance table whose seed count makes significance unattainable, with the exact floor and the --seeds value that would fix it, instead of silently presenting a guaranteed null - 1-year default seed count raised 5 -> 8 so its table can actually reject; longer horizons keep 3 seeds (runtime) plus the annotation
reports/ is generated output (regenerate via execute/scenario_report.py) - ignored except reports/example/, reserved for one committed sample set: a 1/5/10-year run to be generated deliberately rather than whatever the last ad-hoc invocation produced.
The project moved from pyenv+venv to uv for environment management (the .python-version pin already reflects this); the lockfile makes installs reproducible across machines - uv sync recreates the exact environment.
The project moved from pyenv+direnv to uv: .envrc's layout-pyenv activation targets a tool and interpreter manager that are no longer installed, and uv covers the same need without shell hooks -- uv run resolves the pinned interpreter and venv per invocation, no cd trigger required. The .python-version pin stays: uv reads the same file.
Adds the weekly drift check (unpinned dev deps and runner images move without commits landing here), manual dispatch, superseded-run cancellation, read-only default permissions, and the self-keepalive job that stops GitHub from suspending the schedule after 60 quiet days.
checkout v7, setup-python v6. Clears the Node.js 20 deprecation annotation GitHub now attaches to every job running the old lines.
Runs fixed, seeded workloads over the simulation hot paths and can diff a run against a saved baseline. Records a workload fingerprint (transplants, deaths, final wait-list size) next to timing, so a behavior-preserving optimization can be proven apples-to-apples (identical fingerprint, lower time) rather than eyeballed.
Feasibility ran one Dijkstra per organ origin per round over a network that never changes during a trial. Cache each source's transit map (invalidated on any topology/status mutation). For coordinate-bearing real networks, transit is the direct point-to-point estimate from coordinates - an organ flies straight from donor to recipient, not hop-by-hop - which also avoids summing any per-trip fixed overhead once per hop. Synthetic networks keep the Dijkstra shortest-path fallback.
Feasibility rescanned the entire wait list for every organ. Index patients once per batch by (organ needed, blood type) and precompute each donor type's compatible recipient types, so an organ only ever scans blood-compatible candidates of the right organ type - the dominant cost at scale, since kidney is ~85% of the list. Candidates are restored to wait-list order so greedy tie-breaking is unchanged. Consumes Network.transit_from instead of building a Dijkstra per origin.
The old two-speed model used straight-line distance with zero logistics overhead and a discontinuity at its 400km ground->air switch (a 401km hospital 'arrived' faster than a 399km one), which left mean transit ~2.5h and made viability/geography non-binding - ~99% of hospital pairs cleared every organ's window, so all strategies tied. Replace it with min(ground, air): ground carries road circuity and handling; air carries a multi-hour fixed overhead (both airport ground legs, climb/descent, coordination) plus cruise. The result is monotonic and continuous in distance. Recut Organ.get_operation_buffer to the implant-to-reperfusion portion that actually consumes cold-ischemia time (not full OR time), or short-window thoracic organs would be untransplantable. Measured on the real network: mean transit 2.5h -> 4.8h; heart/lung reach ~50% of the network, kidney still 100% - geography now binds as it does in reality.
With only transplant and death as exits, the wait list could not balance arrivals except by growing (and its death count) unrealistically - ~4-5x past reality before deaths alone caught up. Add the two missing real channels: a non-death removal competing risk (too sick / improved / transferred / declined) and living-donor transplants (kidney/liver, ~7k/yr). Both are strategy- independent, wired into run_trial (off by default so the strategy benchmark stays clean) and enabled on the reality-calibrated scenario-report path. Measured at 10% scale: yearly growth +2,729 -> +291 and decelerating, outflow 61% -> 93% of arrivals.
Organ need for new patients was sampled from the standing wait-list mix (85% kidney), but arrivals are a flow, not a stock: kidney candidates dominate the standing list mainly because they wait longest (Little's law), so sampling arrivals from prevalence over-generates kidney arrivals and inflates the backlog. Add a distinct, less-kidney-dominated additions distribution and random_arrival_ organ; PatientGenerator now uses it. Additions percentages are documented approximations flagged to verify against the exact OPTN annual-additions table.
Document the door-to-door min(ground, air) transit model and the cold-ischemia framing of the operation buffer; the non-death removal and living-donor outflow channels; the additions-vs-prevalence arrival mix; and the new performance benchmark harness.
The model transplanted nearly every recovered organ, so the wait list settled at roughly half its real size once the outflow channels were added. Real organs are frequently declined down the match run and discarded, and a longer cold ischemia lowers graft survival. Add clinical.acceptance: a terminal discard probability (organ-specific base rate, rising with transit time) and a cold-ischemia graft-survival factor on life-years. run_trial gains an opt-in realistic_outcomes flag (off by default so the strategy comparison and tests are unaffected; the scenario report enables it): a declined organ counts as wasted and its patient keeps waiting, and transplanted organs' life-years are scaled by the graft factor - so geography now costs organs and outcomes, not just eligibility. Measured at 10% scale: ~17% of recovered organs discarded and the wait list climbs toward the real ~10,600 rather than undershooting. Adds a conservation-invariant test: every patient ends as exactly one of waiting / transplanted / living-transplanted / died / removed.
Patient.__eq__ compared eight fields while __hash__ used only patient_id - a fragile eq/hash split - and carried six hand-written comparison dunders. But the wait-list heap sorts on (-priority, counter, patient) tuples whose unique counter means Patient is never compared, and no other code orders patients, so the ordering methods were dead. Collapse to identity equality on patient_id (consistent with __hash__ and stable as mutable per-round state evolves) and remove the ordering dunders and the redundant __ne__ (Python derives it).
run_trial had grown a ~60-line round body spanning arrivals, allocation, acceptance/discard, deaths, removals, and living-donor transplants inline. Pull it into _simulate_round backed by _record_allocation and _record_outflows, with a TrialConfig dataclass bundling the per-round knobs. run_trial is now setup plus a loop plus snapshotting; each round reads as three named steps and is unit-testable. Behavior and rng draw order are unchanged (guarded by the reproducibility and conservation tests).
Every Network mutator wrapped its logic in a try/except GraphElementError that it raised and immediately caught to print, threading a feedback flag through every signature and hiding failures from callers. Let the mutators raise; callers decide how to present a failure (import utilities stay silent and fail loud on a real bug; import_edge_list catches to skip a duplicate edge; network_demo echoes the error). Drops the feedback plumbing and ~50 lines of duplicated string-building, and makes the class unit-testable on exceptions rather than captured stdout. Also fixes a copy-paste bug in the mark_edge_* / remove_edge guards, which tested one direction of the edge twice instead of confirming it is mirrored in both adjacency dicts.
The console simulator kept its whole state - network, wait list, organ list, selected strategy - in module globals mutated through global statements, so it was the one part of the system that could not be driven programmatically or tested without resetting shared module state between tests. Move the state onto a SimulatorSession instance and turn the menu functions into methods; each test now drives a fresh session instead of resetting globals.
Integration-level tests that run a multi-year trial with every real channel enabled (deaths, non-death removals, living-donor transplants, organ acceptance/discard) on a small hand-built coordinate network, and assert the aggregate behavior lands in a realistic band: the wait list decelerates toward a steady state rather than growing without bound, most arrivals are absorbed by some real exit, and the organ-discard rate resembles OPTN/SRTR figures. These are the tests that would have caught the original unbounded-growth defect.
The additions-by-organ mix and per-organ discard rates were previously documented approximations flagged to verify. Replace them with the actual 2024 numbers from the OPTN/SRTR 2024 Annual Data Report (Overview and Deceased Organ Donation chapters, srtr.hrsa.gov/adr/2024, retrieved 2026-07-08): new registrations of 50,481 kidney / 15,395 liver / 6,068 heart / 3,822 lung / 1,979 pancreas / 128 intestine, and non-use rates of 29.3% kidney, 25.1% pancreas, 11.5% liver, 11.3% lung, 4.9% intestine, 1.9% heart (overall 20.7%). The prior estimates were close on the mix but off on discard (kidney 20->29%, lung 6->11%, heart 4->2%). Since the observed non-use rates already embed real cold-ischemia effects, shrink the additive ischemia-discard term to a small geographic modifier. Realized decline rate now measures ~20%, matching the 20.7% target; the calibration test asserts that band.
run_benchmark ran every (strategy, seed) trial sequentially, but the trials are independent and fully seeded - an embarrassingly parallel workload that dominated scenario-report wall time. Add a workers parameter (default 1 = sequential, unchanged): with workers > 1, trials run in a ProcessPoolExecutor and are reassembled by (strategy, seed), never by completion order, so a parallel run is bit-for-bit identical to a sequential one (asserted by a new test). scenario_report gains a --workers flag defaulting to all cores. Measured ~6x on 8 workers.
…k cache Two rigor tools plus a robustness fix. validate_realism.py runs the reality-calibrated model on the real network and prints a model-vs-OPTN-2024 table. Against the newly-verified figures the model reproduces deceased transplants to within 6 of 42,048 (1.00x), living transplants 1.04x, and organ non-use 21.6% vs 20.7% (1.04x); wait-list deaths 1.19x and non-death removals 0.34x are the weakest matches (a coupled pair the removal-rate knob tunes). sensitivity_analysis.py perturbs each documented-approximation constant +/-25% one at a time and reports the swing in the headline outcomes, so a conclusion can be reported with the assumptions it leans on. Finding: organ supply (donor recovery) dominates lives saved, transit overhead and mortality calibration are secondary, and results are robust to the removal rate and graft-survival penalty. Fixes a from-import binding trap so dict/arg constants are actually perturbed (mutate in place; read the arg dynamically). build_network now caches the geocoded network next to the CSV, so validate_realism and scenario_report do not re-hit the slow, occasionally-flaky geocoder every run.
Model where OPTN policy is headed: ContinuousDistributionScore combines medical urgency, wait time, geographic proximity, and sensitization into one weighted score with no hard geographic boundary (lung has been fully continuous-distribution since 2023; heart/liver are in progress). Its weights are exposed so frontier_analysis.py can sweep the proximity weight and trace the medical-benefit-vs-geography trade-off. The sweep surfaces a non-obvious result: ignoring geography entirely is *worse* on life-years, because far-flung organs incur more cold-ischemia discard and worse graft survival - a moderate geographic weight is Pareto-optimal (more life-years AND lower transit AND less discard). TrialMetrics now records per-transplant transit so the frontial can report the geographic-cost axis.
Profiling a national-scale trial (not assuming) showed the bottleneck was not geography - kidney reaches every hospital within its 30h window, so distance pruning would not help it. The real costs were an O(n) list.remove in WaitList.remove_patient (P3 had only made *add* O(1)), which drove 25M Patient.__eq__ calls, and the per-candidate all(<genexpr>) gate dispatch. Back the wait list with an insertion-ordered dict so removal is O(1) with order preserved; replace the gate genexpr with a plain loop; and short-circuit the HLA crossmatch for the ~70% of unsensitized candidates before building the set intersection. Re-profiled: list.remove and the 25M __eq__ calls are gone, crossmatch dropped ~27%, and feasibility cumulative fell from 17.1s to 10.7s on the same trial.
Coverage confirms every line runs; these confirm the assertions actually pin behavior. Property-based (Hypothesis): blood-type compatibility is cross-checked against an independently-derived ABO/Rh truth table over all inputs; feasibility is invariant to wait-list order; and the optimal matcher total score is never below the greedy heuristic. Metamorphic: adding organs or patients to a batch never reduces matches; more donor supply never increases mean wait-list deaths; and the acuity scorer is monotonic in acuity. These catch model regressions no fixed-value assertion would. Mutation testing (mutmut, an on-demand extra): a run over BloodType.py killed 22 of 24 mutants; the 2 survivors are equivalent mutants (a defensive polarity="" initialization that is always overwritten), so every behavior-changing mutant was caught. Run e.g.: mutmut run --paths-to-mutate network_simulator/BloodType.py --runner ".venv/bin/python -m pytest -x -q tests/test_blood_type.py tests/test_properties.py"
Immortalize the model and the audit in the repo, so the reasoning outlives any chat or ephemeral artifact. docs/METHODOLOGY.md: every clinical constant with its source, the calibration targets, and the measured model-vs-OPTN-2024 results, sensitivity ranking, and continuous-distribution frontier. docs/DATA_PROVENANCE.md: every external data source with citation and 2026-07-08 retrieval date, including the verified 2024 OPTN/SRTR figures and the stock-vs-flow note. docs/adr/: seven dated decision records for the load-bearing calls (transit model, operation buffer, arrival mix, continuous distribution, patient identity, per-organ LAP matching, outflow channels). docs/FINDINGS.md: the engineering + realism audit as a durable record, including the two measured-and-rejected optimizations. README links them all.
The generic network-simulation / network_simulator name was an artifact of the projects undergrad origins and no longer described an organ-allocation simulator. Rename the distribution to organflow and the importable package network_simulator -> organflow via git mv, so history follows, and update every reference: imports across source/tests/scripts, docstrings and docs, the pyproject / CI / ruff / mypy / coverage targets, the README title and badges, and the Nominatim User-Agent. The lockfile is regenerated for the new distribution name. Note: the serialized shelve networks under execute/export/shelve/ embed the old module path in their pickles and cannot be unpickled after the rename; they are regenerable artifacts (generate_networks.py / import_hospitals.py) to regenerate or remove in a follow-up.
build_network pickle-caches the geocoded network next to the CSV. A cache written under a previous module name (as the organflow rename produced) or a partially-written file raised ModuleNotFoundError/UnpicklingError and aborted the whole run. Catch those and fall through to a fresh rebuild, so a stale cache is self-healing rather than fatal.
Add the deceased-donor pathway (donation after brain death vs. after circulatory death) as a donor-level attribute. DCD is now a large, growing share of US deceased donors (7,284 of 16,989 in 2024, ~43%); DCD organs endure a warm-ischemia interval and are discarded more often and graft somewhat worse. A DonorType enum, a 2024-calibrated 57/43 generation split, and per-pathway discard and graft-survival multipliers in clinical.acceptance capture this. The discard multipliers are mean-preserving over the split, so BASE_DISCARD_PROB stays the population average and the national calibration is unchanged (deceased transplants still 1.00x of 42,048, non-use 21.7% vs 20.7%); the split just redistributes discard toward DCD and reduces DCD graft life-years, so donor-quality now shows up in outcomes.
Add the donor pathway to the methodology constants and data-provenance tables, note the mean-preserving discard multipliers, and record the decision as ADR-0008.
…ription The class-by-class walkthrough (Node, Network, BloodType/Organ/Patient, OrganList/WaitList, Other Classes) no longer earns its space now that the project is a calibrated research simulator rather than a graph exercise - the source and docs/ cover the model. Remove it and promote the sections that were nested under it (Allocation Strategies, Statistical Rigor, Scenario Reports, Clinical Realism, Simulator) to top level. Rewrite the Simulator section to reflect reality: it is a SimulatorSession-based single-round allocation demonstrator and deliberately does not run the multi-round clinical dynamics (deterioration, mortality, removals, discard) that live in the benchmark / scenario-report round loop.
Test against 3.12 (the requires-python floor) and 3.x (latest stable), so CI follows new CPython releases rather than a pinned minor - the scheduled weekly run already exists to surface that drift.
execute/export/ held ~5.7 MB of generated output - pickled shelve networks, GEXF exports, and edge lists - all derivable from generate_networks.py / export_*.py / import_hospitals.py. The shelve pickles also embedded the pre-rename module path and could no longer be unpickled. No test depends on them (the export/generate tests all write to tmp_path). Remove them and gitignore the export contents, keeping the three subdirectories via .gitkeep so the export scripts still have a destination. Consistent with how generated scenario reports are already handled.
Real allocation gives pediatric candidates (< 18) substantial priority - very strong for heart and kidney. Add Patient.is_pediatric, generated per an organ-specific fraction (peds are ~25% of intestine listings, ~2% of kidney), and a priority bonus in the policy-modeling scorers only: a flat +25 to RealWorldScore policy points and a pediatric_weight term in ContinuousDistributionScore. The baseline/priority/acuity scorers are left unchanged so the benchmark can isolate the effect. TrialMetrics records pediatric seen/transplanted/died. Measured directly: the priority raised pediatric transplants ~69% (35->59) from the same organ pool in a scarce scenario (isolating only the pediatric weight). Because it only reorders who among the feasible set is matched - no new inflow/outflow - the national calibration is unchanged (the calibration test still passes). Pediatric fractions are documented approximations flagged to verify against OPTN. ADR-0009.
The docs picked up em-dashes, en-dashes, and Unicode minus signs. Convert them all to the project's plain ASCII hyphen convention (spaced ' - ' for parenthetical breaks). Legitimate math symbols are left as-is.
Replace the binary DBD/DCD discard/graft multipliers (ADR-0008) with a continuous donor-quality index on the KDPI convention (0 = ideal .. 100 = marginal). Each donor draws an index from a pathway-specific Beta - DBD skews ideal (~40), DCD marginal (~60) - so the DBD/DCD split is retained as the distribution selector and DCD-is-more-marginal survives as a special case. - Organ.quality_index, generated per donor (frequencies.random_quality_index) - acceptance: the index drives discard (a mean-preserving multiplier centered on the population mean) and graft survival; the donor_type multipliers are removed - benchmark records mean transplanted vs discarded quality; a metamorphic test asserts marginal organs are preferentially discarded - property test guards monotonicity; a test guards mean-preservation National calibration is unchanged: deceased-donor transplants 1.00x of 42,048, non-use 21.8% vs 20.7% (validate_realism). Docs: ADR-0010 (supersedes 0008), METHODOLOGY / DATA_PROVENANCE / FINDINGS / README updated.
A transplant was terminal; now a recipient enters a graft registry where their graft can later fail and relist them as a re-transplant candidate - coupling future demand to the quality of past allocations. - clinical/retransplant.py: two competing per-round hazards on each living graft. Graft failure (~5.5%/yr) -> relist (organ-specific fraction; kidney 90%, heart 30%) or post-transplant death; and recipient exit (~12%/yr, death with a working graft) so relisting is bounded, not eventual for every graft. A relisted patient is marked is_retransplant, wait clock reset, urgency re-initialized, and cPRA bumped (prior-graft antibodies -> harder to match). - benchmark round loop threads a graft registry; post-transplant deaths are tracked separately from wait-list deaths so they never inflate that calibration. - mean-preserving calibration: the national 70,600 additions already include re-transplants, so first-time arrivals are generated at (1 - share) and the loop supplies the rest (an additive version double-counted, inflating the list). Calibration (validate_realism): deceased transplants 1.00x, wait-list deaths improved to 1.00x (from 1.24x), non-use 1.05x, living 1.04x. The re-transplant share ramps toward the 9% target as the graft pool fills (6.1% at 8yr, ~9% near steady state). New validation row + metamorphic/unit tests. Docs: ADR-0011, METHODOLOGY / DATA_PROVENANCE / FINDINGS / README.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
is/is notvalue-comparison bugs and privateheapqAPI usage, corrects implicit-Optional typing for modern mypy.requests_html/pyppeteer, against a site that's since moved) with direct haversine computation from coordinates already in the repo, and fixes a real unit-mismatch bug where organ viability (stored as hours x 10) was being compared against raw-kilometer edge weights.network_simulator.allocationframework: independent matching-algorithm (greedy vs. optimal bipartite matching) and scoring-function (raw priority vs. composite urgency/wait-time/geography) axes, plus a multi-round benchmark harness (execute/benchmark_strategies.py) and CLI strategy selection, so allocation strategies can be empirically compared rather than just swapped in blind.network_simulator, closing several previously-untested branches and fixing 18 moreis/is notcomparison bugs the first pass missed.Test plan
mypy network_simulator- cleanpytest --cov=network_simulator- 90 tests passing, 100% coverageruff check- clean on all new/touched filesexecute/benchmark_strategies.pyagainst the real 253-hospital network and synthetic networksexecute/simulator.py's interactive CLI, including the new strategy-selection menu