Simulations
InterSIM bridge utilities and semi-synthetic trajectory generators for multi-omics simulation studies.
R dependency
The bridge is optional and requires Rscript plus the R InterSIM package:
install.packages(
"InterSIM",
repos = c("https://cran.r-universe.dev", "https://cloud.r-project.org")
)
Check availability before running a simulation:
from motco.simulations import check_intersim_available
availability = check_intersim_available()
if not availability.available:
print(availability.message)
Example
from motco.simulations import InterSIMParams, run_intersim
result = run_intersim(
InterSIMParams(
seed=1203,
n_sample=100,
cluster_sample_prop=(0.3, 0.3, 0.4),
delta_methyl=1.0,
delta_expr=1.0,
delta_protein=1.0,
p_dmp=0.1,
)
)
methylation = result.methylation
expression = result.expression
proteomics = result.proteomics
clusters = result.clusters
Semi-synthetic trajectory generation
The semi-synthetic trajectory generator samples directly from the numpy-native
InterSIM reimplementation (no R at runtime) and returns a MOTCO-ready dataset with:
- aligned methylation, gene expression, and proteomics matrices
- sample metadata containing
sample_id, group, and stage
- truth metadata recording trajectory mode, per-stage/group differential
indicators, per-omic δ, and the generator seed
Group A is a random baseline trajectory; group B is a deterministic transform of
group A's per-stage differential indicators selected by trajectory_mode.
from motco.simulations import (
SemiSyntheticTrajectoryParams,
generate_semisynthetic_trajectory,
)
dataset = generate_semisynthetic_trajectory(
SemiSyntheticTrajectoryParams(
seed=99,
trajectory_mode="magnitude",
n_samples=120,
n_stages=3,
group_effect_size=0.2,
group_ratio=0.5,
),
)
sample_metadata = dataset.metadata
truth = dataset.truth
Supported trajectory modes (all governed by the unified group_effect_size knob,
where 0 is the null for every mode):
| Mode |
Injected group-specific pattern |
none |
Identical to baseline; useful for Type I error scenarios |
translation |
Constant observed-space location offset |
magnitude |
Scales δ (size); see the magnitude_kind table below |
orientation |
One global per-omic feature permutation (rotation) |
shape |
Permutes interior stages only (bend); requires at least three stages |
magnitude_kind selects how magnitude scales group B (the motco simulate --magnitude-kind
flag has the same default):
magnitude_kind |
Construction |
'joint' (default) |
Scales every omic's δ (methylation, expression, proteomics) by the same 1 + e. Size-pure in the joint standardized space, not only within each block. This is the construction the Phase 5 magnitude re-measurement validated. |
'all' |
Scales the methylation δ alone at every stage. Size-pure within the methylation block but rotates the concatenated trajectory. |
'extremes' |
Scales only the endpoint stages' methylation indicators. |
'extremes' and the shape mode's shape_kind='magnitude' remain methylation-only scalings; the
joint default does not extend to them.
Explicit group × stage sizes. group_stage_sizes replaces proportional sizing
(n_samples × stage_sample_prop × group_ratio) with an exact table: one tuple of per-stage
counts per group, rows ordered as group_labels and columns as stages. Every group-stage cell gets
exactly the stated number of samples, and truth metadata records the realized table under
truth["group_stage_sizes"]. Use it when the imbalance varies by stage, as in a real cohort:
SemiSyntheticTrajectoryParams(
seed=1,
n_stages=3,
group_stage_sizes=((11, 10, 28), (9, 10, 12)), # 80 samples, the SEA-AD MTG design
)
The table must have two rows of n_stages integers, each at least 1. Setting it together with a
non-default n_samples, stage_sample_prop or group_ratio is an error that names the
conflicting settings. When the table is absent (the default None), generation, truth metadata,
cell ids and parameter signatures are byte-identical to the generator before the field existed:
hash payloads omit the field while it is None.
Migration. Before default-joint-magnitude-construction the default was 'all'. Code that
relied on the default and wants the old construction must pass magnitude_kind="all" (or
--magnitude-kind all). The committed historical study configs under
examples/trajectory_power_study/ already pin "all", so their cells, signatures and datasets are
unchanged.
Evaluation harness
The evaluation harness runs one SemiSyntheticTrajectoryDataset through MOTCO integration and trajectory testing. It is the per-replicate layer used before larger Type I error or power grids.
from motco.simulations import (
SimulationEvaluationParams,
evaluate_semisynthetic_trajectory,
)
evaluation = evaluate_semisynthetic_trajectory(
dataset,
SimulationEvaluationParams(
integration_method="concat",
permutations=0,
),
)
observed_delta = evaluation.pair_statistics["delta"]
truth = evaluation.truth_metadata
Supported integration methods:
| Method |
Behavior |
concat |
Column-binds methylation, expression, and proteomics matrices after deterministic per-feature standardization by default |
snf |
Builds per-omic affinity matrices, fuses them with SNF, and uses spectral embedding as the trajectory outcome matrix |
Block selection. integration_params["layers"] names the omic blocks the evaluation measures,
for every integration method: e.g. {"layers": ["methylation", "expression"]}. All three blocks are
still generated; only the selected ones enter pooled preprocessing, integration, every
realized-geometry checkpoint (per-block scopes for the selected blocks only, joint scopes over them)
and orientation attribution. The selection is normalized to canonical order (methylation,
expression, proteomics) and must be non-empty, duplicate-free and known; integration metadata
records it as integration_params.layers. When the key is absent, all three blocks are measured and
the evaluation result is byte-identical to the harness before the key existed (no layers metadata
entry is added).
Orientation estimator. orientation_estimator selects how each group's orientation is measured
for the observed angle, every RRPP draw and every realized-geometry checkpoint angle. The default
is "net_displacement" (the unit vector from the first to the last stage mean), adopted after the
net-displacement power comparison; it is part of the
cell id and parameter signature and is recorded in runtime_metadata["orientation_estimator"]. Pass
orientation_estimator="pc1" to opt out to the principal-axis estimator: "pc1" normalizes to
None, which is left out of every hash payload and record, so a PC1 evaluation is byte-identical to
the harness before the selection existed. Every committed study config and historical diagnostic pins
"pc1". Only angle depends on the selection. The stats primitives (estimate_difference, RRPP)
keep "pc1" as their default.
Set permutations=0 for observed statistics only. When permutations > 0, the harness runs RRPP and computes upper-tail empirical p-values with plus-one correction:
p = (1 + count(null >= observed)) / (1 + n_permutations)
The result includes observed delta, angle, and shape matrices, scalar two-group pair statistics, optional p-values, latent matrix metadata, generator truth metadata, runtime metadata, group/stage levels, and the trajectory contrast. Shape pair statistics and p-values are reported as unavailable for datasets with fewer than three stages.
Realized-geometry decomposition
Generated semi-synthetic datasets also expose exact group-stage population means in integration units: methylation M-values and native expression/proteomics units. Evaluation uses one pooled fitted preprocessing artifact for both diagnostics and PLS, then reports:
| Checkpoint |
Scopes |
Interpretation |
population_native |
each omic |
Exact construction and biological-cascade geometry without sampling noise |
population_standardized |
each omic and joint |
Exact means under the replicate's pooled fitted feature scaling |
observed_standardized |
each omic and joint |
Geometry actually presented to integration, including finite sampling |
pls_latent |
joint |
Geometry retained in the fitted production PLS representation |
Every applicable scope records both group path lengths, pairwise delta, angle, and shape, and explicit availability flags. Joint native-space geometry is intentionally omitted because the unstandardized omic blocks use incompatible units. Raw distances across feature and latent checkpoints are not scale-equivalent; use the decomposition to locate changes in behavior rather than subtracting distances across spaces.
See the Phase 2 findings for the revised orientation and shape construction contracts and the matched-seed characterization command.
Grid orchestration
The grid orchestration layer enumerates parameter cells, runs local replicates through the evaluation harness, persists one JSONL row per replicate, resumes completed work, and summarizes rejection rates for Type I error or power studies.
from pathlib import Path
from motco.simulations import (
SemiSyntheticTrajectoryParams,
SimulationEvaluationParams,
SimulationRunConfig,
enumerate_type_i_grid,
run_simulation_grid,
summarize_rejection_rates,
)
grid = enumerate_type_i_grid(
baseline_generator_params=SemiSyntheticTrajectoryParams(seed=2, n_samples=60),
evaluation_params=SimulationEvaluationParams(integration_method="concat", permutations=99),
axes={
"generator.n_samples": [60, 120],
"generator.group_ratio": [0.5, 0.7],
},
n_replicates=3,
base_seed=2026,
)
records = run_simulation_grid(
grid,
config=SimulationRunConfig(output_path=Path("simulation-results.jsonl")),
)
summaries = summarize_rejection_rates(records, alpha=0.05)
Each SimulationCell stores a stable cell_id, phase, SemiSyntheticTrajectoryParams, SimulationEvaluationParams, replicate count, base seed, and metadata such as the varied axis. Axis names use explicit namespaces: generator.<field> or evaluation.<field>.
Initial persistence is JSON Lines. Each row records cell and replicate IDs, deterministic seeds, a parameter signature, status, p-values, pair statistics, realized-geometry diagnostics, truth metadata, runtime metadata, cell metadata, and optional error details. The diagnostic schema version participates in the parameter signature. With resume=True, completed rows with matching parameter signatures are skipped. A matching cell/replicate with a different parameter signature raises unless overwrite=True; legacy rows remain readable but have no realized-geometry payload.
summarize_rejection_rates groups completed replicate rows by cell and statistic, then reports available replicate count, rejection count, rejection rate, Monte Carlo standard error, and unavailable replicate count. Missing statistics, such as shape p-values for two-stage datasets, remain unavailable rather than being counted as non-significant.
API
motco.simulations.InterSIMParams
dataclass
Parameters for the InterSIM R package.
Parameters left as None are omitted from the R call, allowing InterSIM
defaults to apply. The covariance parameters currently support InterSIM's
native "indep" string option or None.
Source code in src/motco/simulations/intersim.py
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71 | @dataclass(frozen=True)
class InterSIMParams:
"""Parameters for the InterSIM R package.
Parameters left as ``None`` are omitted from the R call, allowing InterSIM
defaults to apply. The covariance parameters currently support InterSIM's
native ``"indep"`` string option or ``None``.
"""
seed: int
n_sample: int | None = None
cluster_sample_prop: tuple[float, ...] | None = None
delta_methyl: float | None = None
delta_expr: float | None = None
delta_protein: float | None = None
p_dmp: float | None = None
p_deg: float | None = None
p_dep: float | None = None
sigma_methyl: str | None = None
sigma_expr: str | None = None
sigma_protein: str | None = None
cor_methyl_expr: float | None = None
cor_expr_protein: float | None = None
|
motco.simulations.InterSIMResult
dataclass
Normalized InterSIM simulation output.
Source code in src/motco/simulations/intersim.py
74
75
76
77
78
79
80
81
82
83 | @dataclass(frozen=True)
class InterSIMResult:
"""Normalized InterSIM simulation output."""
methylation: pd.DataFrame
expression: pd.DataFrame
proteomics: pd.DataFrame
sample_ids: pd.Index
clusters: pd.Series
metadata: dict[str, Any] = field(default_factory=dict)
|
motco.simulations.InterSIMAvailability
dataclass
Availability result for the external R InterSIM dependency.
Source code in src/motco/simulations/intersim.py
| @dataclass(frozen=True)
class InterSIMAvailability:
"""Availability result for the external R InterSIM dependency."""
available: bool
message: str
rscript_path: str | None = None
|
motco.simulations.SemiSyntheticTrajectoryParams
dataclass
Parameters for semi-synthetic trajectory generation.
group_ratio is the proportion assigned to the first label in
group_labels within every stage. group_effect_size is the unified
effect knob described in the module docstring (0 is null for all modes).
p_dmp is the per-stage probability that a methylation feature is
differential (InterSIM's p.DMP); expression/protein indicators are
derived from it via the cross-omic maps. baseline_continuity (ρ) is the
baseline stage-program continuity described in the module docstring: 0 draws
each stage's program independently, higher values make a CpG's differential
status persist across adjacent stages with correlation ρ while holding every
stage's marginal at p_dmp. delta_* are the per-omic
mean-shift sizes (InterSIM's delta.*). shape_kind selects the
single-interior-stage perturbation used by shape. magnitude_kind
selects whether magnitude scales every omic's effect size together
(joint, the default), group B's methylation effect alone at all
stages (all, a uniform methylation δ scale), or the methylation effect
only at the extreme stages (extremes, first and last) leaving interior
stages at the baseline effect.
group_stage_sizes is the explicit alternative to n_samples /
stage_sample_prop / group_ratio: one tuple of per-stage counts per
group, rows ordered as group_labels and columns as stages, realized
exactly. It may not be combined with a non-default proportional setting.
None (the default) keeps proportional sizing, byte-identical to the
generator before the table existed.
surgery_censoring is the policy for pool-limited surgeries described in
the module docstring: "error" (default) refuses to realize a partial
surgery, "clamp" realizes the largest surgery the pool allows. Use
"clamp" only to reproduce a historical run or for exploratory work where
a partial surgery is acceptable — under it the requested effect no longer
labels the realized construction.
Source code in src/motco/simulations/semisynthetic.py
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153 | @dataclass(frozen=True)
class SemiSyntheticTrajectoryParams:
"""Parameters for semi-synthetic trajectory generation.
``group_ratio`` is the proportion assigned to the first label in
``group_labels`` within every stage. ``group_effect_size`` is the unified
effect knob described in the module docstring (``0`` is null for all modes).
``p_dmp`` is the per-stage probability that a methylation feature is
differential (InterSIM's ``p.DMP``); expression/protein indicators are
derived from it via the cross-omic maps. ``baseline_continuity`` (ρ) is the
baseline stage-program continuity described in the module docstring: 0 draws
each stage's program independently, higher values make a CpG's differential
status persist across adjacent stages with correlation ρ while holding every
stage's marginal at ``p_dmp``. ``delta_*`` are the per-omic
mean-shift sizes (InterSIM's ``delta.*``). ``shape_kind`` selects the
single-interior-stage perturbation used by ``shape``. ``magnitude_kind``
selects whether ``magnitude`` scales every omic's effect size together
(``joint``, the default), group B's methylation effect alone at *all*
stages (``all``, a uniform methylation δ scale), or the methylation effect
only at the *extreme* stages (``extremes``, first and last) leaving interior
stages at the baseline effect.
``group_stage_sizes`` is the explicit alternative to ``n_samples`` /
``stage_sample_prop`` / ``group_ratio``: one tuple of per-stage counts per
group, rows ordered as ``group_labels`` and columns as stages, realized
exactly. It may not be combined with a non-default proportional setting.
``None`` (the default) keeps proportional sizing, byte-identical to the
generator before the table existed.
``surgery_censoring`` is the policy for pool-limited surgeries described in
the module docstring: ``"error"`` (default) refuses to realize a partial
surgery, ``"clamp"`` realizes the largest surgery the pool allows. Use
``"clamp"`` only to reproduce a historical run or for exploratory work where
a partial surgery is acceptable — under it the requested effect no longer
labels the realized construction.
"""
seed: int
trajectory_mode: TrajectoryMode = "none"
n_samples: int = 600
n_stages: int = 3
group_effect_size: float = 0.0
group_ratio: float = 0.5
group_labels: tuple[str, str] = ("A", "B")
p_dmp: float = 0.2
baseline_continuity: float = 0.0
delta_methyl: float = 2.0
delta_expr: float = 2.0
delta_protein: float = 2.0
shape_kind: ShapeKind = "relocate"
magnitude_kind: MagnitudeKind = "joint"
surgery_censoring: SurgeryCensoring = "error"
stage_sample_prop: tuple[float, ...] | None = None
group_stage_sizes: tuple[tuple[int, ...], tuple[int, ...]] | None = None
|
motco.simulations.SemiSyntheticTrajectoryDataset
dataclass
MOTCO-ready semi-synthetic trajectory dataset.
Source code in src/motco/simulations/semisynthetic.py
171
172
173
174
175
176
177
178
179
180 | @dataclass(frozen=True)
class SemiSyntheticTrajectoryDataset:
"""MOTCO-ready semi-synthetic trajectory dataset."""
methylation: pd.DataFrame
expression: pd.DataFrame
proteomics: pd.DataFrame
metadata: pd.DataFrame
truth: dict[str, Any] = field(default_factory=dict)
population_trajectories: PopulationTrajectories | None = None
|
motco.simulations.SimulationEvaluationParams
dataclass
Parameters for evaluating one semi-synthetic trajectory dataset.
Source code in src/motco/simulations/evaluation.py
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145 | @dataclass(frozen=True)
class SimulationEvaluationParams:
"""Parameters for evaluating one semi-synthetic trajectory dataset."""
integration_method: IntegrationMethod = "concat"
integration_params: Mapping[str, Any] = field(default_factory=dict)
permutations: int = 0
n_jobs: int | None = 1
seed: int | None = None
progress: bool = False
include_null_distributions: bool = False
group_col: str = "group"
stage_col: str = "stage"
attribution: AttributionDiagnosticSettings = field(default_factory=AttributionDiagnosticSettings)
# Orientation estimator for the observed ``angle``, every RRPP draw and every
# realized-geometry checkpoint. The default is net displacement; it is hashed
# into cell ids and signatures and recorded in ``runtime_metadata``. ``None``
# is the hash-omitted spelling of PC1 (``grid._SIGNATURE_OMIT_WHEN_NONE``) and
# an explicit ``"pc1"`` normalizes to it, so PC1 cell ids, signatures and
# records equal those produced before the field existed.
orientation_estimator: str | None = "net_displacement"
def __post_init__(self) -> None:
estimator = self.orientation_estimator
if estimator is None:
return
if estimator not in EVALUATION_ORIENTATION_ESTIMATORS:
raise SimulationEvaluationError(
f"Unknown orientation_estimator {estimator!r}; allowed: "
f"{list(EVALUATION_ORIENTATION_ESTIMATORS)}."
)
if estimator == "pc1":
object.__setattr__(self, "orientation_estimator", None)
@property
def resolved_orientation_estimator(self) -> str:
"""The estimator name the statistics are computed with."""
return self.orientation_estimator or "pc1"
|
resolved_orientation_estimator
property
The estimator name the statistics are computed with.
motco.simulations.SimulationEvaluationResult
dataclass
Result from evaluating one semi-synthetic trajectory dataset.
Source code in src/motco/simulations/evaluation.py
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227 | @dataclass(frozen=True)
class SimulationEvaluationResult:
"""Result from evaluating one semi-synthetic trajectory dataset."""
observed_deltas: np.ndarray
observed_angles: np.ndarray
observed_shapes: np.ndarray
pair_statistics: dict[str, float]
p_values: dict[str, float]
latent_matrix_metadata: dict[str, Any]
truth_metadata: dict[str, Any]
runtime_metadata: dict[str, Any]
evaluation_params: SimulationEvaluationParams
group_levels: list[str]
stage_levels: list[str]
contrast: list[list[int]]
null_distributions: dict[str, list[float]] | None = None
realized_geometry: dict[str, Any] = field(default_factory=dict)
attribution_diagnostics: dict[str, Any] = field(default_factory=dict)
# Compact per-statistic description of the permutation null. Produced on
# every RRPP run — unlike ``null_distributions``, which stays opt-in — so a
# record can locate its own observed statistic against its own critical
# value without retaining the full draw vectors.
null_summary: dict[str, dict[str, float]] = field(default_factory=dict)
# Eigenspectrum of the centered stage-mean configuration in the *evaluated
# latent space*, pooled and per contrast group, plus — whenever permutations
# ran — a summary of the pooled relative eigengap over the permutation draws.
# A recorded covariate: the geometry audit
# (``docs/reports/geometry-audit-2026-09-01.md``) found the relative eigengap
# predicts how wide a replicate's own ``angle`` permutation null will be, and
# therefore whether its orientation is resolvable at all. It qualifies
# reporting; it enters no statistic and no decision rule.
config_spectrum: dict[str, Any] = field(default_factory=dict)
|
motco.simulations.SimulationTrajectoryDesign
dataclass
Trajectory design objects derived from sample metadata.
Source code in src/motco/simulations/evaluation.py
183
184
185
186
187
188
189
190
191
192 | @dataclass(frozen=True)
class SimulationTrajectoryDesign:
"""Trajectory design objects derived from sample metadata."""
model_full: np.ndarray
model_reduced: np.ndarray
ls_means: np.ndarray
contrast: list[list[int]]
group_levels: list[str]
stage_levels: list[str]
|
motco.simulations.LatentIntegrationResult
dataclass
Integrated latent/outcome matrix and metadata.
artifacts carries the ephemeral fitted objects for the PLS path and is
never serialized; only metadata reaches the persisted record.
Source code in src/motco/simulations/evaluation.py
170
171
172
173
174
175
176
177
178
179
180 | @dataclass(frozen=True)
class LatentIntegrationResult:
"""Integrated latent/outcome matrix and metadata.
``artifacts`` carries the ephemeral fitted objects for the PLS path and is
never serialized; only ``metadata`` reaches the persisted record.
"""
matrix: pd.DataFrame
metadata: dict[str, Any]
artifacts: PLSIntegrationArtifacts | None = None
|
motco.simulations.SimulationCell
dataclass
One simulation parameter cell with one or more replicates.
Source code in src/motco/simulations/grid.py
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61 | @dataclass(frozen=True)
class SimulationCell:
"""One simulation parameter cell with one or more replicates."""
cell_id: str
phase: str
generator_params: SemiSyntheticTrajectoryParams
evaluation_params: SimulationEvaluationParams
n_replicates: int = 1
base_seed: int = 0
metadata: Mapping[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
if self.n_replicates < 1:
raise SimulationGridError("n_replicates must be at least 1.")
if not self.cell_id:
raise SimulationGridError("cell_id must be a non-empty string.")
if not self.phase:
raise SimulationGridError("phase must be a non-empty string.")
|
motco.simulations.SimulationGrid
dataclass
Collection of simulation cells.
Source code in src/motco/simulations/grid.py
64
65
66
67
68
69
70
71
72
73
74
75 | @dataclass(frozen=True)
class SimulationGrid:
"""Collection of simulation cells."""
cells: tuple[SimulationCell, ...]
metadata: Mapping[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
ids = [cell.cell_id for cell in self.cells]
duplicates = sorted({cell_id for cell_id in ids if ids.count(cell_id) > 1})
if duplicates:
raise SimulationGridError(f"cell_id values must be unique; duplicates: {duplicates}.")
|
motco.simulations.SimulationReplicateResult
dataclass
One persisted row for a simulation cell replicate.
Source code in src/motco/simulations/grid.py
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121 | @dataclass(frozen=True)
class SimulationReplicateResult:
"""One persisted row for a simulation cell replicate."""
cell_id: str
phase: str
replicate_index: int
replicate_seed: int
generator_seed: int
evaluation_seed: int | None
parameter_signature: str
status: Literal["completed", "failed"]
p_values: dict[str, float | None] = field(default_factory=dict)
pair_statistics: dict[str, float | None] = field(default_factory=dict)
realized_geometry: dict[str, Any] = field(default_factory=dict)
truth_metadata: dict[str, Any] = field(default_factory=dict)
runtime_metadata: dict[str, Any] = field(default_factory=dict)
cell_metadata: dict[str, Any] = field(default_factory=dict)
# Additive Phase 4 fields. They default to empty so records written before
# they existed still load, and so a failed diagnostic is distinguishable
# from one that was never requested.
integration_metadata: dict[str, Any] = field(default_factory=dict)
attribution_status: str = "not_requested"
attribution_diagnostics: dict[str, Any] = field(default_factory=dict)
# Per-statistic description of this replicate's own permutation null. Empty
# for records written before the field existed and for evaluations that ran
# no permutations; ``runtime_metadata["permutations"]`` separates the two.
# Deliberately absent from ``parameter_signature`` — it summarizes draws that
# already happened and changes no generation, integration, or permutation
# behavior, so pre-change shards stay resumable.
null_summary: dict[str, dict[str, float]] = field(default_factory=dict)
# Latent stage-mean configuration spectrum (pooled and per group) and, when
# permutations ran, the pooled eigengap over those draws. Empty for records
# written before the field existed; a *recorded* degenerate spectrum is a
# populated block whose ``relative_eigengap`` is ``None``, which is how the
# two are told apart. Unlike ``null_summary`` this **is** in
# ``parameter_signature``: a resumed pre-change shard would otherwise produce
# a merged set where only some records carry the covariate, and a missing
# covariate is indistinguishable from a degenerate one at analysis time.
config_spectrum: dict[str, Any] = field(default_factory=dict)
diagnostic_error_type: str | None = None
diagnostic_error_message: str | None = None
error_type: str | None = None
error_message: str | None = None
|
motco.simulations.SimulationRunConfig
dataclass
Runtime options for local grid execution.
Source code in src/motco/simulations/grid.py
178
179
180
181
182
183
184
185
186 | @dataclass(frozen=True)
class SimulationRunConfig:
"""Runtime options for local grid execution."""
output_path: Path | None = None
persistence_format: PersistenceFormat = "jsonl"
resume: bool = True
overwrite: bool = False
error_policy: ErrorPolicy = "raise"
|
motco.simulations.SimulationSummaryResult
dataclass
Rejection-rate summary for one cell and statistic.
Source code in src/motco/simulations/grid.py
124
125
126
127
128
129
130
131
132
133
134
135
136
137 | @dataclass(frozen=True)
class SimulationSummaryResult:
"""Rejection-rate summary for one cell and statistic."""
cell_id: str
phase: str
statistic: str
alpha: float
completed_replicates: int
available_replicates: int
rejected_replicates: int
rejection_rate: float | None
monte_carlo_se: float | None
unavailable_replicates: int
|
motco.simulations.check_intersim_available(rscript='Rscript')
Check whether Rscript and the R InterSIM package are available.
Source code in src/motco/simulations/intersim.py
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131 | def check_intersim_available(rscript: str = "Rscript") -> InterSIMAvailability:
"""Check whether Rscript and the R InterSIM package are available."""
rscript_path = which(rscript)
if rscript_path is None:
return InterSIMAvailability(
available=False,
message=f"Rscript dependency is missing or not on PATH: {rscript}",
)
cmd = [
rscript_path,
"-e",
'quit(status = if (requireNamespace("InterSIM", quietly = TRUE)) 0 else 1)',
]
proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
if proc.returncode != 0:
details = _format_process_output(proc)
return InterSIMAvailability(
available=False,
message="R package InterSIM is not installed or cannot be loaded." + details,
rscript_path=rscript_path,
)
return InterSIMAvailability(
available=True,
message="Rscript and R package InterSIM are available.",
rscript_path=rscript_path,
)
|
motco.simulations.run_intersim(params, *, rscript='Rscript', check_dependency=True)
Invoke R InterSIM and return aligned omics matrices plus cluster metadata.
Source code in src/motco/simulations/intersim.py
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163 | def run_intersim(
params: InterSIMParams,
*,
rscript: str = "Rscript",
check_dependency: bool = True,
) -> InterSIMResult:
"""Invoke R InterSIM and return aligned omics matrices plus cluster metadata."""
if check_dependency:
availability = check_intersim_available(rscript)
if not availability.available:
raise InterSIMDependencyError(availability.message)
rscript_cmd = availability.rscript_path or rscript
else:
rscript_cmd = which(rscript) or rscript
with tempfile.TemporaryDirectory(prefix="motco-intersim-") as tmp:
output_dir = Path(tmp)
proc = subprocess.run(
_build_rscript_command(params, output_dir=output_dir, rscript=rscript_cmd),
capture_output=True,
text=True,
check=False,
)
if proc.returncode != 0:
raise InterSIMRuntimeError(
f"InterSIM R process failed with exit code {proc.returncode}."
f"{_format_process_output(proc)}"
)
return _load_result(output_dir, params=params)
|
motco.simulations.generate_semisynthetic_trajectory(params, *, reference=None)
Generate a semi-synthetic trajectory dataset using the numpy generator.
Source code in src/motco/simulations/semisynthetic.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237 | def generate_semisynthetic_trajectory(
params: SemiSyntheticTrajectoryParams,
*,
reference: IntersimReference | None = None,
) -> SemiSyntheticTrajectoryDataset:
"""Generate a semi-synthetic trajectory dataset using the numpy generator."""
_validate_params(params)
ref = reference if reference is not None else load_reference()
rng = np.random.default_rng(params.seed)
if params.group_stage_sizes is not None:
group_a_sizes, group_b_sizes = (list(row) for row in params.group_stage_sizes)
else:
stage_sizes = _stage_sizes(params)
group_a_sizes, group_b_sizes = _group_stage_sizes(stage_sizes, params)
methyl_a = _baseline_methyl(rng, ref, params)
methyl_b, deltas_b, transform_meta = _transform_group_b(rng, ref, params, methyl_a)
deltas_a = (params.delta_methyl, params.delta_expr, params.delta_protein)
indicators_a = _derive_group(methyl_a, ref)
indicators_b = _derive_group(methyl_b, ref)
gen_a = _generate_group(rng, ref, indicators_a, deltas_a, group_a_sizes)
gen_b = _generate_group(rng, ref, indicators_b, deltas_b, group_b_sizes)
population_trajectories = _build_population_trajectories(
ref, params, indicators_a, indicators_b, deltas_a, deltas_b
)
methylation, expression, proteomics, metadata = _assemble(
ref, params, gen_a, gen_b, group_a_sizes, group_b_sizes
)
truth = _build_truth(params, indicators_a, indicators_b, deltas_a, deltas_b, transform_meta)
if params.group_stage_sizes is not None:
truth["group_stage_sizes"] = {
params.group_labels[0]: list(group_a_sizes),
params.group_labels[1]: list(group_b_sizes),
}
return SemiSyntheticTrajectoryDataset(
methylation=methylation,
expression=expression,
proteomics=proteomics,
metadata=metadata,
truth=truth,
population_trajectories=population_trajectories,
)
|
motco.simulations.integrate_semisynthetic_dataset(dataset, params, *, preprocessor=None, observed_blocks=None)
Construct the molecular latent space from aligned omics layers.
snf and pls build genuine latent spaces (the measurement substrate
for trajectory geometry); concat is a standardized-feature-concatenation
baseline, not a constructed latent space. See the module docstring for the
latent-space architecture.
Source code in src/motco/simulations/evaluation.py
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468 | def integrate_semisynthetic_dataset(
dataset: SemiSyntheticTrajectoryDataset,
params: SimulationEvaluationParams,
*,
preprocessor: FittedOmicsPreprocessor | None = None,
observed_blocks: Mapping[OmicsLayer, pd.DataFrame] | None = None,
) -> LatentIntegrationResult:
"""Construct the molecular latent space from aligned omics layers.
``snf`` and ``pls`` build genuine latent spaces (the measurement substrate
for trajectory geometry); ``concat`` is a standardized-feature-concatenation
baseline, not a constructed latent space. See the module docstring for the
latent-space architecture.
"""
_validate_dataset(dataset, params.group_col, params.stage_col)
layers = _measured_layers(params.integration_params)
if preprocessor is None:
preprocessor = fit_omics_preprocessor(dataset, layers)
else:
try:
preprocessor.require_layers(layers)
except ValueError as exc:
raise SimulationEvaluationError(str(exc)) from exc
if observed_blocks is None:
observed_blocks = preprocessor.transform_dataset(dataset)
method = params.integration_method
if method == "concat":
return _concat_integration(dataset, params.integration_params, observed_blocks)
if method == "snf":
return _snf_integration(dataset, params.integration_params)
if method == "pls":
return _pls_integration(
dataset,
params.integration_params,
stage_col=params.stage_col,
observed_blocks=observed_blocks,
preprocessor=preprocessor,
)
if method == "pca":
return _pca_integration(dataset, params.integration_params, observed_blocks)
if method == "pls_within_group":
return _pls_within_group_integration(
dataset,
params.integration_params,
group_col=params.group_col,
stage_col=params.stage_col,
observed_blocks=observed_blocks,
)
raise SimulationEvaluationError(f"Unsupported integration_method: {method!r}.")
|
motco.simulations.build_simulation_trajectory_design(metadata, *, group_col='group', stage_col='stage')
Build full/reduced model matrices, LS means, and two-group contrast.
Source code in src/motco/simulations/evaluation.py
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505 | def build_simulation_trajectory_design(
metadata: pd.DataFrame,
*,
group_col: str = "group",
stage_col: str = "stage",
) -> SimulationTrajectoryDesign:
"""Build full/reduced model matrices, LS means, and two-group contrast."""
_validate_metadata(metadata, group_col, stage_col)
design_frame = metadata[[group_col, stage_col]].reset_index(drop=True).copy()
design_frame[group_col] = design_frame[group_col].astype(str)
design_frame[stage_col] = design_frame[stage_col].astype(str)
group_levels = _sort_levels(pd.unique(design_frame[group_col]).tolist())
stage_levels = _sort_levels(pd.unique(design_frame[stage_col]).tolist())
if len(group_levels) != 2:
raise SimulationEvaluationError(f"Expected exactly two groups, found {len(group_levels)}: {group_levels}.")
if len(stage_levels) < 2:
raise SimulationEvaluationError(
f"Expected at least two stages, found {len(stage_levels)}: {stage_levels}."
)
_validate_group_stage_combinations(design_frame, group_col, stage_col, group_levels, stage_levels)
model_full = get_model_matrix(design_frame, group_col=group_col, level_col=stage_col, full=True)
model_reduced = get_model_matrix(design_frame, group_col=group_col, level_col=stage_col, full=False)
ls_means = build_ls_means(group_levels, stage_levels, full=True)
n_stages = len(stage_levels)
contrast = [[group_index * n_stages + stage_index for stage_index in range(n_stages)] for group_index in range(2)]
return SimulationTrajectoryDesign(
model_full=model_full,
model_reduced=model_reduced,
ls_means=ls_means,
contrast=contrast,
group_levels=group_levels,
stage_levels=stage_levels,
)
|
motco.simulations.evaluate_semisynthetic_trajectory(dataset, params=None, *, latent=None)
Evaluate one semi-synthetic trajectory dataset through MOTCO routines.
latent optionally supplies an integration already built for this
dataset with the same integration_method and integration_params
(from :func:integrate_semisynthetic_dataset), so evaluations that differ
only in measurement settings — the orientation estimator, say — share one
space fit. Omitted (the default), the integration is built here.
Source code in src/motco/simulations/evaluation.py
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371 | def evaluate_semisynthetic_trajectory(
dataset: SemiSyntheticTrajectoryDataset,
params: SimulationEvaluationParams | None = None,
*,
latent: LatentIntegrationResult | None = None,
) -> SimulationEvaluationResult:
"""Evaluate one semi-synthetic trajectory dataset through MOTCO routines.
``latent`` optionally supplies an integration already built for this
dataset with the same ``integration_method`` and ``integration_params``
(from :func:`integrate_semisynthetic_dataset`), so evaluations that differ
only in measurement settings — the orientation estimator, say — share one
space fit. Omitted (the default), the integration is built here.
"""
params = params or SimulationEvaluationParams()
_validate_evaluation_params(params)
start = perf_counter()
preprocessor = fit_omics_preprocessor(dataset, _measured_layers(params.integration_params))
observed_blocks = preprocessor.transform_dataset(dataset)
if latent is None:
latent = integrate_semisynthetic_dataset(
dataset,
params,
preprocessor=preprocessor,
observed_blocks=observed_blocks,
)
elif latent.metadata.get("integration_method") != params.integration_method:
raise SimulationEvaluationError(
f"Supplied latent integration was built with {latent.metadata.get('integration_method')!r}, "
f"not integration_method={params.integration_method!r}."
)
design = build_simulation_trajectory_design(
dataset.metadata,
group_col=params.group_col,
stage_col=params.stage_col,
)
observed_deltas, observed_angles, observed_shapes, observed_spectra = estimate_difference(
Y=latent.matrix,
model_matrix=design.model_full,
LS_means=design.ls_means,
contrast=design.contrast,
return_spectra=True,
orientation_estimator=params.resolved_orientation_estimator,
)
config_spectrum = _config_spectrum_block(observed_spectra, design.group_levels)
shape_available = len(design.stage_levels) >= 3
pair_statistics = _extract_pair_statistics(
observed_deltas,
observed_angles,
observed_shapes,
shape_available=shape_available,
)
p_values: dict[str, float] = {}
null_distributions: dict[str, list[float]] | None = None
null_summary: dict[str, dict[str, float]] = {}
if params.permutations > 0:
dist_delta, dist_angle, dist_shape, dist_eigengap = RRPP(
latent.matrix,
design.model_full,
design.model_reduced,
design.ls_means,
design.contrast,
permutations=params.permutations,
n_jobs=params.n_jobs,
progress=params.progress,
seed=params.seed,
return_eigengaps=True,
orientation_estimator=params.resolved_orientation_estimator,
)
null_values = _extract_null_distributions(
dist_delta,
dist_angle,
dist_shape,
shape_available=shape_available,
)
p_values = {
statistic: _empirical_p_value(null_values[statistic], observed)
for statistic, observed in pair_statistics.items()
if statistic in null_values and np.isfinite(observed)
}
null_summary = _summarize_null_distributions(null_values)
# Only the summary survives: the per-permutation eigengaps are discarded
# here, exactly as the per-permutation statistics are.
config_spectrum["permutation_pooled_eigengap"] = _summarize_permutation_eigengaps(
dist_eigengap
)
if params.include_null_distributions:
null_distributions = null_values
runtime_seconds = perf_counter() - start
runtime_metadata: dict[str, Any] = {
"runtime_seconds": runtime_seconds,
"permutations": params.permutations,
"n_jobs": params.n_jobs,
"seed": params.seed,
"progress": params.progress,
"shape_available": shape_available,
}
if params.orientation_estimator is not None:
runtime_metadata["orientation_estimator"] = params.orientation_estimator
latent_checkpoint = _LATENT_CHECKPOINTS.get(params.integration_method)
realized_geometry = calculate_realized_geometry(
dataset,
preprocessor=preprocessor,
observed_blocks=observed_blocks,
latent_matrix=latent.matrix if latent_checkpoint is not None else None,
group_col=params.group_col,
stage_col=params.stage_col,
latent_checkpoint=latent_checkpoint or "pls_latent",
orientation_estimator=params.resolved_orientation_estimator,
).to_dict()
# The fitted estimator and standardized joint matrix live only inside this
# call: attribution consumes them here, and only the compact JSON-safe
# record survives into the result.
attribution_diagnostics = _attribution_diagnostics(dataset, params, latent)
return SimulationEvaluationResult(
observed_deltas=observed_deltas,
observed_angles=observed_angles,
observed_shapes=observed_shapes,
pair_statistics=pair_statistics,
p_values=p_values,
latent_matrix_metadata=latent.metadata,
# Drop the raw indicator arrays — they are not JSON-serializable for the
# study's JSONL persistence; the per-stage/group counts and transform
# notes (which are) are retained for downstream characterization.
truth_metadata={k: v for k, v in dataset.truth.items() if k != "indicators"},
runtime_metadata=runtime_metadata,
evaluation_params=params,
group_levels=design.group_levels,
stage_levels=design.stage_levels,
contrast=design.contrast,
null_distributions=null_distributions,
realized_geometry=realized_geometry,
attribution_diagnostics=attribution_diagnostics,
null_summary=null_summary,
config_spectrum=config_spectrum,
)
|
motco.simulations.enumerate_type_i_grid(*, baseline_generator_params, evaluation_params=None, axes=None, n_replicates=1, base_seed=0)
Enumerate a null Type I grid with baseline plus one-factor-at-a-time axes.
Source code in src/motco/simulations/grid.py
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270 | def enumerate_type_i_grid(
*,
baseline_generator_params: SemiSyntheticTrajectoryParams,
evaluation_params: SimulationEvaluationParams | None = None,
axes: Mapping[str, Sequence[Any]] | None = None,
n_replicates: int = 1,
base_seed: int = 0,
) -> SimulationGrid:
"""Enumerate a null Type I grid with baseline plus one-factor-at-a-time axes."""
null_generator = replace(baseline_generator_params, trajectory_mode="none", group_effect_size=0.0)
cells = [
make_simulation_cell(
phase="type_i_baseline",
generator_params=null_generator,
evaluation_params=evaluation_params,
n_replicates=n_replicates,
base_seed=base_seed,
metadata={"varied_axis": None, "varied_value": None},
)
]
for axis, values in (axes or {}).items():
baseline_value = _get_axis_value(null_generator, evaluation_params, axis)
for value in values:
if _to_jsonable(value) == _to_jsonable(baseline_value):
continue
generator_params, eval_params = _apply_axis_value(
null_generator,
evaluation_params or SimulationEvaluationParams(),
axis,
value,
)
cells.append(
make_simulation_cell(
phase="type_i_ofat",
generator_params=generator_params,
evaluation_params=eval_params,
n_replicates=n_replicates,
base_seed=base_seed,
metadata={"varied_axis": axis, "varied_value": value},
)
)
return SimulationGrid(cells=tuple(cells), metadata={"grid_type": "type_i"})
|
motco.simulations.enumerate_power_grid(*, baseline_generator_params, evaluation_params=None, trajectory_modes, effect_sizes, axes=None, n_replicates=1, base_seed=0, evaluation_resolver=None, cell_metadata=None)
Enumerate power cells from trajectory modes, effect sizes, and optional axes.
evaluation_resolver may specialize the evaluation parameters per cell
(for example, to enable attribution diagnostics only on selected cells).
Because it runs before cell construction, the resolved settings enter the
cell id and parameter signature rather than being applied after the fact.
cell_metadata is merged into every emitted cell's metadata.
Source code in src/motco/simulations/grid.py
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360 | def enumerate_power_grid(
*,
baseline_generator_params: SemiSyntheticTrajectoryParams,
evaluation_params: SimulationEvaluationParams | None = None,
trajectory_modes: Sequence[str],
effect_sizes: Sequence[float],
axes: Mapping[str, Sequence[Any]] | None = None,
n_replicates: int = 1,
base_seed: int = 0,
evaluation_resolver: CellEvaluationResolver | None = None,
cell_metadata: Mapping[str, Any] | None = None,
) -> SimulationGrid:
"""Enumerate power cells from trajectory modes, effect sizes, and optional axes.
``evaluation_resolver`` may specialize the evaluation parameters per cell
(for example, to enable attribution diagnostics only on selected cells).
Because it runs *before* cell construction, the resolved settings enter the
cell id and parameter signature rather than being applied after the fact.
``cell_metadata`` is merged into every emitted cell's metadata.
"""
if not trajectory_modes:
raise SimulationGridError("trajectory_modes must contain at least one mode.")
if not effect_sizes:
raise SimulationGridError("effect_sizes must contain at least one value.")
cells: list[SimulationCell] = []
eval_params = evaluation_params or SimulationEvaluationParams()
extra_metadata = dict(cell_metadata or {})
def resolve(phase: str, mode: str, effect_size: float) -> SimulationEvaluationParams:
if evaluation_resolver is None:
return eval_params
return evaluation_resolver(phase, mode, float(effect_size))
for mode, effect_size in itertools.product(trajectory_modes, effect_sizes):
generator = replace(
baseline_generator_params,
trajectory_mode=mode, # type: ignore[arg-type]
group_effect_size=float(effect_size),
)
primary_eval = resolve("power_primary", mode, float(effect_size))
cells.append(
make_simulation_cell(
phase="power_primary",
generator_params=generator,
evaluation_params=primary_eval,
n_replicates=n_replicates,
base_seed=base_seed,
metadata={
"trajectory_mode": mode,
"effect_size": float(effect_size),
"varied_axis": None,
**extra_metadata,
},
)
)
for axis, values in (axes or {}).items():
ofat_eval = resolve("power_ofat", mode, float(effect_size))
baseline_value = _get_axis_value(generator, ofat_eval, axis)
for value in values:
if _to_jsonable(value) == _to_jsonable(baseline_value):
continue
generator_params, axis_eval_params = _apply_axis_value(
generator,
ofat_eval,
axis,
value,
)
cells.append(
make_simulation_cell(
phase="power_ofat",
generator_params=generator_params,
evaluation_params=axis_eval_params,
n_replicates=n_replicates,
base_seed=base_seed,
metadata={
"trajectory_mode": mode,
"effect_size": float(effect_size),
"varied_axis": axis,
"varied_value": value,
},
)
)
return SimulationGrid(cells=tuple(cells), metadata={"grid_type": "power"})
|
motco.simulations.run_simulation_replicate(cell, replicate_index, *, evaluator=None, error_policy='raise')
Run one replicate, using a fake evaluator in tests or the default harness in production.
Source code in src/motco/simulations/grid.py
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503 | def run_simulation_replicate(
cell: SimulationCell,
replicate_index: int,
*,
evaluator: Evaluator | None = None,
error_policy: ErrorPolicy = "raise",
) -> SimulationReplicateResult:
"""Run one replicate, using a fake evaluator in tests or the default harness in production."""
_validate_error_policy(error_policy)
replicate_seed = derive_replicate_seed(cell, replicate_index)
generator_params = replace(cell.generator_params, seed=replicate_seed)
evaluation_seed = cell.evaluation_params.seed if cell.evaluation_params.seed is not None else replicate_seed
evaluation_params = replace(cell.evaluation_params, seed=evaluation_seed)
evaluator = evaluator or _default_evaluator
start = perf_counter()
try:
result = evaluator(generator_params, evaluation_params)
except Exception as exc:
if error_policy == "raise":
raise
return SimulationReplicateResult(
cell_id=cell.cell_id,
phase=cell.phase,
replicate_index=replicate_index,
replicate_seed=replicate_seed,
generator_seed=generator_params.seed,
evaluation_seed=evaluation_params.seed,
parameter_signature=parameter_signature(cell),
status="failed",
runtime_metadata={"runtime_seconds": perf_counter() - start},
cell_metadata=dict(cell.metadata),
error_type=type(exc).__name__,
error_message=str(exc),
)
attribution = dict(result.attribution_diagnostics or {})
attribution_status = str(attribution.get("status", "not_requested"))
return SimulationReplicateResult(
cell_id=cell.cell_id,
phase=cell.phase,
replicate_index=replicate_index,
replicate_seed=replicate_seed,
generator_seed=generator_params.seed,
evaluation_seed=evaluation_params.seed,
parameter_signature=parameter_signature(cell),
status="completed",
p_values=_finite_mapping(result.p_values),
pair_statistics=_finite_mapping(result.pair_statistics),
realized_geometry=result.realized_geometry,
truth_metadata=result.truth_metadata,
runtime_metadata=result.runtime_metadata,
cell_metadata=dict(cell.metadata),
integration_metadata=dict(result.latent_matrix_metadata),
attribution_status=attribution_status,
attribution_diagnostics=attribution,
null_summary={
statistic: dict(entry) for statistic, entry in (result.null_summary or {}).items()
},
config_spectrum=dict(result.config_spectrum or {}),
diagnostic_error_type=attribution.get("error_type"),
diagnostic_error_message=attribution.get("reason") if attribution_status == "failed" else None,
)
|
motco.simulations.run_simulation_grid(grid, *, config=None, evaluator=None)
Run a grid locally with optional JSONL persistence and resume support.
Source code in src/motco/simulations/grid.py
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546 | def run_simulation_grid(
grid: SimulationGrid,
*,
config: SimulationRunConfig | None = None,
evaluator: Evaluator | None = None,
) -> list[SimulationReplicateResult]:
"""Run a grid locally with optional JSONL persistence and resume support."""
config = config or SimulationRunConfig()
if config.persistence_format != "jsonl":
raise SimulationGridError("Only JSONL persistence is currently supported.")
completed = (
_completed_index(config.output_path)
if config.output_path and config.resume and not config.overwrite
else {}
)
records: list[SimulationReplicateResult] = []
for cell in grid.cells:
signature = parameter_signature(cell)
for replicate_index in range(cell.n_replicates):
key = (cell.cell_id, replicate_index)
existing_signature = completed.get(key)
if existing_signature is not None:
if existing_signature != signature:
if not config.overwrite:
raise SimulationGridError(
f"Existing result for {cell.cell_id} replicate {replicate_index} has a different "
"parameter signature. Set overwrite=True to rerun."
)
else:
continue
record = run_simulation_replicate(
cell,
replicate_index,
evaluator=evaluator,
error_policy=config.error_policy,
)
records.append(record)
if config.output_path is not None:
append_replicate_results(config.output_path, [record])
return records
|
motco.simulations.read_replicate_results(path)
Read replicate records from a JSONL file.
Source code in src/motco/simulations/grid.py
558
559
560
561
562
563
564
565
566
567
568
569 | def read_replicate_results(path: Path) -> list[SimulationReplicateResult]:
"""Read replicate records from a JSONL file."""
if not path.exists():
return []
records = []
with path.open("r", encoding="utf-8") as handle:
for line in handle:
if not line.strip():
continue
records.append(_replicate_result_from_dict(json.loads(line)))
return records
|
motco.simulations.append_replicate_results(path, records)
Append replicate records to a JSONL file.
Source code in src/motco/simulations/grid.py
549
550
551
552
553
554
555 | def append_replicate_results(path: Path, records: Iterable[SimulationReplicateResult]) -> None:
"""Append replicate records to a JSONL file."""
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as handle:
for record in records:
handle.write(json.dumps(_to_jsonable(record), sort_keys=True, allow_nan=False) + "\n")
|
motco.simulations.summarize_rejection_rates(records, *, alpha=0.05, statistics=('delta', 'angle', 'shape'))
Summarize p-value rejection rates by cell and statistic.
Source code in src/motco/simulations/grid.py
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610 | def summarize_rejection_rates(
records: Sequence[SimulationReplicateResult],
*,
alpha: float = 0.05,
statistics: Sequence[str] = ("delta", "angle", "shape"),
) -> list[SimulationSummaryResult]:
"""Summarize p-value rejection rates by cell and statistic."""
if not (0 < alpha < 1):
raise SimulationGridError("alpha must be between 0 and 1.")
groups: dict[tuple[str, str], list[SimulationReplicateResult]] = {}
for record in records:
if record.status != "completed":
continue
groups.setdefault((record.cell_id, record.phase), []).append(record)
summaries: list[SimulationSummaryResult] = []
for (cell_id, phase), group_records in sorted(groups.items()):
for statistic in statistics:
p_values = [record.p_values.get(statistic) for record in group_records]
available = [float(p) for p in p_values if p is not None and math.isfinite(float(p))]
rejected = sum(p < alpha for p in available)
rate = rejected / len(available) if available else None
se = math.sqrt(rate * (1.0 - rate) / len(available)) if rate is not None else None
summaries.append(
SimulationSummaryResult(
cell_id=cell_id,
phase=phase,
statistic=statistic,
alpha=alpha,
completed_replicates=len(group_records),
available_replicates=len(available),
rejected_replicates=rejected,
rejection_rate=rate,
monte_carlo_se=se,
unavailable_replicates=len(group_records) - len(available),
)
)
return summaries
|
Phase 4 diagnostics and study gate
Phase 4 adds two optional, additive layers on top of the existing harness: a
bounded orientation-attribution diagnostic computed from the same fitted PLS
estimator that produced the trajectory scores, and a predeclared gate that turns
the study's operating characteristics into an explicit proceed / hold /
indeterminate decision for the paper-grade study.
Both are off by default. SimulationEvaluationParams.attribution is a disabled
AttributionDiagnosticSettings, and a study configuration without an
attribution or matched_seeds block behaves exactly as before. Enabling
attribution requires integration_method="pls"; concat and snf produce no
fitted estimator to condition on and are rejected with a descriptive error.
Every gate threshold lives in the study configuration's acceptance.gate block,
never in the summary or report code. See
examples/trajectory_power_study/README.md for the config fields, the matched-
seed and shared-anchor semantics, the gate rules, and the full output inventory.
motco.simulations.AttributionDiagnosticSettings
dataclass
Optional orientation-attribution diagnostic settings for one evaluation.
Attribution is disabled by default: the zero-argument instance carries
enabled=False and no bootstrap work, so evaluations that do not ask for
diagnostics behave exactly as before. When enabled the settings are frozen
into the cell signature, so a diagnostic change cannot silently resume into
an existing shard.
Source code in src/motco/simulations/evaluation.py
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104 | @dataclass(frozen=True)
class AttributionDiagnosticSettings:
"""Optional orientation-attribution diagnostic settings for one evaluation.
Attribution is **disabled by default**: the zero-argument instance carries
``enabled=False`` and no bootstrap work, so evaluations that do not ask for
diagnostics behave exactly as before. When enabled the settings are frozen
into the cell signature, so a diagnostic change cannot silently resume into
an existing shard.
"""
enabled: bool = False
bootstrap_replicates: int = 0
bootstrap_seed: int | None = 0
top_k: int = 20
zero_tolerance: float = 1e-12
|
motco.simulations.attribution_diagnostics.compute_attribution_diagnostics(dataset=None, *, model, features, original_scales, settings, group_col, stage_col, selected_components, feature_order_signature, methylation_units='mvalue', layers=OMIC_LAYERS, metadata=None, groups=None, stages=None, truth=None, original_basis=None)
Run frozen-model attribution and reduce it to a compact JSON-safe record.
See :func:run_attribution_diagnostics for the arguments; this returns only
the compact record.
Source code in src/motco/simulations/attribution_diagnostics.py
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215 | def compute_attribution_diagnostics(
dataset: SemiSyntheticTrajectoryDataset | None = None,
*,
model: Any,
features: pd.DataFrame,
original_scales: np.ndarray | None,
settings: Any,
group_col: str,
stage_col: str,
selected_components: int | None,
feature_order_signature: str | None,
methylation_units: str = "mvalue",
layers: Sequence[OmicsLayer] = OMIC_LAYERS,
metadata: pd.DataFrame | None = None,
groups: Sequence[str] | None = None,
stages: Sequence[str] | None = None,
truth: TruthDrivers | None = None,
original_basis: Mapping[str, str] | None = None,
) -> dict[str, Any]:
"""Run frozen-model attribution and reduce it to a compact JSON-safe record.
See :func:`run_attribution_diagnostics` for the arguments; this returns only
the compact record.
"""
record, _ = run_attribution_diagnostics(
dataset,
model=model,
features=features,
original_scales=original_scales,
settings=settings,
group_col=group_col,
stage_col=stage_col,
selected_components=selected_components,
feature_order_signature=feature_order_signature,
methylation_units=methylation_units,
layers=layers,
metadata=metadata,
groups=groups,
stages=stages,
truth=truth,
original_basis=original_basis,
)
return record
|
motco.simulations.attribution_diagnostics.derive_truth_driver_features(dataset, feature_names, stage_levels, layers=OMIC_LAYERS)
Aligned truth drivers from changed group-stage differential patterns.
A feature is a truth driver for the transition s -> s+1 when the two
groups' population mean changes over that transition differ, i.e. when
delta_A * (v_A[s+1] - v_A[s]) != delta_B * (v_B[s+1] - v_B[s])
for that feature's per-omic effect size delta and per-stage differential
indicator v. Because expression and protein indicators are re-derived
from methylation through the CpG→gene→protein incidence maps, propagated
cascade drivers satisfy this definition on their own layers — a real
downstream driver is therefore never counted as a false positive. The
definition also covers effect-size-only constructions such as
magnitude_kind='all', where the indicators are identical between groups
but the per-omic delta is not.
The global driver set is the union over transitions. layers names the
measured blocks, in canonical order, that make up the joint feature order.
Source code in src/motco/simulations/attribution_diagnostics.py
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169 | def derive_truth_driver_features(
dataset: SemiSyntheticTrajectoryDataset,
feature_names: Sequence[str],
stage_levels: Sequence[str],
layers: Sequence[OmicsLayer] = OMIC_LAYERS,
) -> TruthDrivers:
"""Aligned truth drivers from changed group-stage differential patterns.
A feature is a truth driver for the transition ``s -> s+1`` when the two
groups' *population* mean changes over that transition differ, i.e. when
``delta_A * (v_A[s+1] - v_A[s]) != delta_B * (v_B[s+1] - v_B[s])``
for that feature's per-omic effect size ``delta`` and per-stage differential
indicator ``v``. Because expression and protein indicators are re-derived
from methylation through the CpG→gene→protein incidence maps, propagated
cascade drivers satisfy this definition on their own layers — a real
downstream driver is therefore never counted as a false positive. The
definition also covers effect-size-only constructions such as
``magnitude_kind='all'``, where the indicators are identical between groups
but the per-omic delta is not.
The global driver set is the union over transitions. ``layers`` names the
measured blocks, in canonical order, that make up the joint feature order.
"""
truth = dataset.truth
indicators = truth.get("indicators")
group_labels = truth.get("group_labels")
deltas = truth.get("deltas")
names = tuple(str(name) for name in feature_names)
n_transitions = max(len(stage_levels) - 1, 0)
transition_ids = [
f"{stage_levels[index]}->{stage_levels[index + 1]}" for index in range(n_transitions)
]
if not indicators or not group_labels or not deltas:
empty = np.zeros(len(names), dtype=bool)
return TruthDrivers(
feature_names=names,
global_mask=empty,
per_transition={transition_id: empty.copy() for transition_id in transition_ids},
available=False,
definition="unavailable: dataset truth carries no differential indicators",
)
label_a, label_b = (str(label) for label in group_labels)
per_transition: dict[str, np.ndarray] = {}
blocks: list[list[np.ndarray]] = [[] for _ in transition_ids]
for layer in layers:
layer_index = OMIC_LAYERS.index(layer)
ind_a = np.asarray(indicators[label_a][layer], dtype=float)
ind_b = np.asarray(indicators[label_b][layer], dtype=float)
delta_a = float(deltas[label_a][layer_index])
delta_b = float(deltas[label_b][layer_index])
for index in range(n_transitions):
step_a = delta_a * (ind_a[:, index + 1] - ind_a[:, index])
step_b = delta_b * (ind_b[:, index + 1] - ind_b[:, index])
blocks[index].append(np.abs(step_a - step_b) > _TRUTH_TOLERANCE)
for index, transition_id in enumerate(transition_ids):
mask = np.concatenate(blocks[index]) if blocks[index] else np.zeros(0, dtype=bool)
if mask.shape[0] != len(names):
raise AttributionDiagnosticError(
f"Truth indicators ({mask.shape[0]}) do not align to the joint feature order ({len(names)})."
)
per_transition[transition_id] = mask
global_mask = (
np.logical_or.reduce(list(per_transition.values()))
if per_transition
else np.zeros(len(names), dtype=bool)
)
return TruthDrivers(
feature_names=names,
global_mask=global_mask,
per_transition=per_transition,
available=True,
definition=(
"features whose group-stage differential mean change differs between groups, "
"including CpG->gene->protein propagated effects"
),
)
|
motco.simulations.study.AttributionSelector
dataclass
Which enumerated cells receive orientation-attribution diagnostics.
The selector is resolved into per-cell evaluation parameters during
enumeration, so eligibility and the effective bootstrap settings are part of
the cell's parameter signature rather than something inferred after a run.
Selection is never conditioned on an observed p-value.
Source code in src/motco/simulations/study/config.py
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156 | @dataclass(frozen=True)
class AttributionSelector:
"""Which enumerated cells receive orientation-attribution diagnostics.
The selector is resolved into per-cell evaluation parameters during
enumeration, so eligibility and the effective bootstrap settings are part of
the cell's parameter signature rather than something inferred after a run.
Selection is never conditioned on an observed p-value.
"""
enabled: bool = False
trajectory_modes: tuple[str, ...] = ("orientation",)
phases: tuple[str, ...] = ("power_primary",)
nonzero_effects_only: bool = True
effect_sizes: tuple[float, ...] | None = None
bootstrap_replicates: int = 100
bootstrap_seed: int | None = 0
top_k: int = 20
zero_tolerance: float = 1e-12
def __post_init__(self) -> None:
if not self.enabled:
return
unknown = sorted(set(self.trajectory_modes) - _TRAJECTORY_MODES)
if unknown:
raise StudyConfigError(f"attribution.trajectory_modes has unknown mode(s): {unknown}.")
if not self.trajectory_modes:
raise StudyConfigError("attribution.trajectory_modes must contain at least one mode.")
if not self.phases:
raise StudyConfigError("attribution.phases must contain at least one phase.")
if self.effect_sizes is not None:
if not self.effect_sizes:
raise StudyConfigError("attribution.effect_sizes must be null or a non-empty sequence.")
for value in self.effect_sizes:
if float(value) < 0:
raise StudyConfigError(f"attribution.effect_sizes must be non-negative; got {value}.")
if self.bootstrap_replicates < 0:
raise StudyConfigError("attribution.bootstrap_replicates must be non-negative.")
if self.top_k < 1:
raise StudyConfigError("attribution.top_k must be at least 1.")
if not (self.zero_tolerance > 0):
raise StudyConfigError("attribution.zero_tolerance must be positive.")
def settings(self) -> AttributionDiagnosticSettings:
"""Effective evaluation-level settings implied by this selector."""
return AttributionDiagnosticSettings(
enabled=True,
bootstrap_replicates=int(self.bootstrap_replicates),
bootstrap_seed=None if self.bootstrap_seed is None else int(self.bootstrap_seed),
top_k=int(self.top_k),
zero_tolerance=float(self.zero_tolerance),
)
def selects(self, *, phase: str, trajectory_mode: str | None, effect_size: float | None) -> bool:
"""Whether a cell with this identity is eligible for attribution."""
if not self.enabled:
return False
if phase not in self.phases:
return False
if trajectory_mode is None or trajectory_mode not in self.trajectory_modes:
return False
if effect_size is None:
return False
value = float(effect_size)
if self.nonzero_effects_only and value == 0.0:
return False
if self.effect_sizes is not None:
return any(abs(value - float(candidate)) < 1e-12 for candidate in self.effect_sizes)
return True
|
settings()
Effective evaluation-level settings implied by this selector.
Source code in src/motco/simulations/study/config.py
129
130
131
132
133
134
135
136
137
138 | def settings(self) -> AttributionDiagnosticSettings:
"""Effective evaluation-level settings implied by this selector."""
return AttributionDiagnosticSettings(
enabled=True,
bootstrap_replicates=int(self.bootstrap_replicates),
bootstrap_seed=None if self.bootstrap_seed is None else int(self.bootstrap_seed),
top_k=int(self.top_k),
zero_tolerance=float(self.zero_tolerance),
)
|
selects(*, phase, trajectory_mode, effect_size)
Whether a cell with this identity is eligible for attribution.
Source code in src/motco/simulations/study/config.py
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156 | def selects(self, *, phase: str, trajectory_mode: str | None, effect_size: float | None) -> bool:
"""Whether a cell with this identity is eligible for attribution."""
if not self.enabled:
return False
if phase not in self.phases:
return False
if trajectory_mode is None or trajectory_mode not in self.trajectory_modes:
return False
if effect_size is None:
return False
value = float(effect_size)
if self.nonzero_effects_only and value == 0.0:
return False
if self.effect_sizes is not None:
return any(abs(value - float(candidate)) < 1e-12 for candidate in self.effect_sizes)
return True
|
motco.simulations.study.MatchedSeedPolicy
dataclass
Opt-in matched generator seeds shared across primary cells.
When enabled, every primary power cell draws its generator seed from a
shared (seed family, replicate index) key instead of its own cell id, so
requested-effect comparisons are paired at the generated-reference level.
Negative-control and OFAT cells keep their own seed families so they stay
independent. The policy version enters the parameter signature.
Source code in src/motco/simulations/study/config.py
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181 | @dataclass(frozen=True)
class MatchedSeedPolicy:
"""Opt-in matched generator seeds shared across primary cells.
When enabled, every primary power cell draws its generator seed from a
shared ``(seed family, replicate index)`` key instead of its own cell id, so
requested-effect comparisons are paired at the generated-reference level.
Negative-control and OFAT cells keep their own seed families so they stay
independent. The policy version enters the parameter signature.
"""
enabled: bool = False
version: int = 1
primary_family: str = "primary"
shared_zero_effect_anchor: bool = True
def __post_init__(self) -> None:
if not self.enabled:
return
if self.version < 1:
raise StudyConfigError("matched_seeds.version must be at least 1.")
if not str(self.primary_family).strip():
raise StudyConfigError("matched_seeds.primary_family must be a non-empty string.")
|
motco.simulations.study.Phase4GateConfig
dataclass
Every parameter the Phase 4 gate consumes.
Summary and report code reads all thresholds from here and hard-codes none
of its own, so a gate can be re-specified by editing the committed config.
Source code in src/motco/simulations/study/config.py
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257 | @dataclass(frozen=True)
class Phase4GateConfig:
"""Every parameter the Phase 4 gate consumes.
Summary and report code reads all thresholds from here and hard-codes none
of its own, so a gate can be re-specified by editing the committed config.
"""
enabled: bool = False
alpha: float = 0.05
control_se_tolerance: float = 2.0
monotonicity_se_tolerance: float = 2.0
min_power_at_top: float = 0.80
confirmation_se_threshold: float = 1.0
max_marginal_exceedances: int = 1
control_modes: tuple[str, ...] = ("none", "translation")
rules: tuple[GateRule, ...] = ()
require_complete_records: bool = True
def __post_init__(self) -> None:
if not self.enabled:
return
if not (0 < self.alpha < 1):
raise StudyConfigError("acceptance.gate.alpha must be between 0 and 1.")
for name in ("control_se_tolerance", "monotonicity_se_tolerance", "confirmation_se_threshold"):
value = float(getattr(self, name))
if value < 0:
raise StudyConfigError(f"acceptance.gate.{name} must be non-negative.")
if not (0 <= self.min_power_at_top <= 1):
raise StudyConfigError("acceptance.gate.min_power_at_top must be between 0 and 1.")
if self.max_marginal_exceedances < 0:
raise StudyConfigError("acceptance.gate.max_marginal_exceedances must be non-negative.")
unknown = sorted(set(self.control_modes) - _TRAJECTORY_MODES)
if unknown:
raise StudyConfigError(f"acceptance.gate.control_modes has unknown mode(s): {unknown}.")
if not self.control_modes:
raise StudyConfigError("acceptance.gate.control_modes must contain at least one mode.")
if not self.rules:
raise StudyConfigError("acceptance.gate.rules must declare every mandatory and descriptive pair.")
if not any(rule.role == "mandatory_power" for rule in self.rules):
raise StudyConfigError("acceptance.gate.rules must declare at least one 'mandatory_power' rule.")
seen: set[tuple[str, str]] = set()
for rule in self.rules:
key = (rule.trajectory_mode, rule.statistic)
if key in seen:
raise StudyConfigError(f"acceptance.gate.rules has a duplicate entry for {key}.")
seen.add(key)
def power_floor(self, rule: GateRule) -> float:
"""Minimum power at the top effect for ``rule``."""
return float(self.min_power_at_top if rule.min_power_at_top is None else rule.min_power_at_top)
|
power_floor(rule)
Minimum power at the top effect for rule.
Source code in src/motco/simulations/study/config.py
| def power_floor(self, rule: GateRule) -> float:
"""Minimum power at the top effect for ``rule``."""
return float(self.min_power_at_top if rule.min_power_at_top is None else rule.min_power_at_top)
|
motco.simulations.study.GateRule
dataclass
One predeclared mode/statistic gate role.
Source code in src/motco/simulations/study/config.py
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203 | @dataclass(frozen=True)
class GateRule:
"""One predeclared mode/statistic gate role."""
trajectory_mode: str
statistic: str
role: str
min_power_at_top: float | None = None
def __post_init__(self) -> None:
if self.trajectory_mode not in _TRAJECTORY_MODES:
raise StudyConfigError(f"gate rule trajectory_mode {self.trajectory_mode!r} is unknown.")
if self.statistic not in _STATISTICS:
raise StudyConfigError(f"gate rule statistic {self.statistic!r} is unknown.")
if self.role not in _GATE_ROLES:
raise StudyConfigError(f"gate rule role {self.role!r} is unknown; expected one of {sorted(_GATE_ROLES)}.")
@property
def name(self) -> str:
return f"{self.role}[{self.trajectory_mode},{self.statistic}]"
|
motco.simulations.study.enumerate_study(config)
Build the combined Type I + power grid for a study.
Source code in src/motco/simulations/study/enumerate.py
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87 | def enumerate_study(config: StudyConfig) -> SimulationGrid:
"""Build the combined Type I + power grid for a study."""
resolver = _attribution_resolver(config)
type_i = enumerate_type_i_grid(
baseline_generator_params=config.generator,
evaluation_params=config.evaluation,
axes=config.axes,
n_replicates=config.n_replicates,
base_seed=config.base_seed,
)
cells = list(type_i.cells)
cells.extend(_negative_control_cells(config))
cells.extend(_power_cells(config, resolver))
cells.extend(_design_point_cells(config, resolver))
if config.matched_seeds.enabled:
cells = _assign_seed_families(cells, config)
_require_unique_ids(cells)
_require_surgery_headroom(cells)
grid_metadata: dict[str, Any] = {
"grid_type": "study",
"study_config_metadata": dict(config.metadata),
"matched_seed_policy": {
"enabled": config.matched_seeds.enabled,
"version": config.matched_seeds.version,
"primary_family": config.matched_seeds.primary_family,
"shared_zero_effect_anchor": config.matched_seeds.shared_zero_effect_anchor,
},
}
if config.design_grid.enabled:
grid_metadata["design_grid"] = {
"axes": {axis: list(values) for axis, values in config.design_grid.axes.items()},
"n_points": len(config.design_points()),
"baseline_point": _to_jsonable(config.baseline_design_point()),
}
if config.design_grid.explicit:
grid_metadata["design_grid"]["points"] = _to_jsonable(config.design_points())
return SimulationGrid(cells=tuple(cells), metadata=grid_metadata)
|
motco.simulations.study.summarize_realized_geometry(records)
Long-form geometry by mode, effect, checkpoint, scope, and statistic.
Path lengths are summarized alongside delta / angle / shape.
Unavailable values (a null path with no direction, or shape with fewer
than three stages) are counted rather than coerced to zero, and each row
carries the measurement space its numbers live in so feature-space and
latent-space magnitudes are never silently compared.
Source code in src/motco/simulations/study/phase4.py
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256 | def summarize_realized_geometry(records: Sequence[SimulationReplicateResult]) -> pd.DataFrame:
"""Long-form geometry by mode, effect, checkpoint, scope, and statistic.
Path lengths are summarized alongside ``delta`` / ``angle`` / ``shape``.
Unavailable values (a null path with no direction, or ``shape`` with fewer
than three stages) are counted rather than coerced to zero, and each row
carries the measurement space its numbers live in so feature-space and
latent-space magnitudes are never silently compared.
"""
buckets: dict[tuple, list[float | None]] = {}
identity: dict[tuple, dict[str, Any]] = {}
for record in records:
if record.status != "completed":
continue
geometry = record.realized_geometry or {}
meta = dict(record.cell_metadata)
mode = str(meta.get("trajectory_mode") or geometry.get("requested", {}).get("trajectory_mode") or "none")
effect_size = meta.get("effect_size")
effect_size = float(effect_size) if effect_size is not None else None
for checkpoint, scopes in (geometry.get("checkpoints") or {}).items():
for scope, values in scopes.items():
for statistic, value in _geometry_statistics(values):
key = (mode, effect_size, record.cell_id, checkpoint, scope, statistic)
buckets.setdefault(key, []).append(value)
identity.setdefault(
key,
{
"trajectory_mode": mode,
"effect_size": effect_size,
"cell_id": record.cell_id,
"phase": record.phase,
"checkpoint": checkpoint,
"scope": scope,
"measurement_space": MEASUREMENT_SPACES.get(checkpoint, "unknown"),
"statistic": statistic,
},
)
rows: list[dict[str, Any]] = []
for key, values in buckets.items():
available = [value for value in values if value is not None and math.isfinite(value)]
row = dict(identity[key])
row.update(
{
"n_replicates": len(values),
"n_available": len(available),
"n_unavailable": len(values) - len(available),
"mean": _mean(available),
"sd": _sd(available),
"median": _median(available),
}
)
rows.append(row)
frame = pd.DataFrame(
rows,
columns=[
"trajectory_mode",
"effect_size",
"cell_id",
"phase",
"checkpoint",
"scope",
"measurement_space",
"statistic",
"n_replicates",
"n_available",
"n_unavailable",
"mean",
"sd",
"median",
],
)
if frame.empty:
return frame
frame["checkpoint_order"] = frame["checkpoint"].map(
{name: index for index, name in enumerate(CHECKPOINT_ORDER)}
)
return frame.sort_values(
["trajectory_mode", "effect_size", "checkpoint_order", "scope", "statistic"]
).drop(columns=["checkpoint_order"]).reset_index(drop=True)
|
Angle-pivotality diagnostic
motco.simulations.pivotality.association_table(records, *, statistics=STATISTICS, null_targets=NULL_TARGETS)
Per cell and statistic, how strongly the null tracks the observed value.
A pivotal statistic shows no association: the null mean, spread, and
critical value are properties of the design, not of the replicate's realized
signal. A correlation near +1 with a slope near 1 on q95 is the
non-pivotal case that costs power.
Source code in src/motco/simulations/pivotality.py
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261 | def association_table(
records: Iterable[SimulationReplicateResult],
*,
statistics: Sequence[str] = STATISTICS,
null_targets: Sequence[str] = NULL_TARGETS,
) -> list[AssociationRow]:
"""Per cell and statistic, how strongly the null tracks the observed value.
A pivotal statistic shows no association: the null mean, spread, and
critical value are properties of the design, not of the replicate's realized
signal. A correlation near +1 with a slope near 1 on ``q95`` is the
non-pivotal case that costs power.
"""
rows: list[AssociationRow] = []
for cell, group in group_by_cell(records).items():
for statistic in statistics:
for target in null_targets:
x, y = _paired(group, statistic, target)
r, low, high = _correlation_with_interval(x, y)
rows.append(
AssociationRow(
cell=cell,
statistic=statistic,
null_target=target,
n_replicates=int(x.size),
correlation=r,
correlation_ci_low=low,
correlation_ci_high=high,
slope=_slope(x, y),
mean_observed=float(x.mean()) if x.size else None,
mean_null_target=float(y.mean()) if y.size else None,
)
)
return rows
|
motco.simulations.pivotality.rejection_split_table(records, *, alpha=0.05, statistics=STATISTICS)
Split the observed statistic — and its critical value — by rejection outcome.
inverted flags the Phase 4 pattern: non-rejecting replicates carrying a
larger mean observed statistic than rejecting ones. Reporting the mean
critical value beside it answers the follow-up the spec requires — whether
replicates with a larger observed statistic also carry a proportionally
larger bar.
Source code in src/motco/simulations/pivotality.py
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439 | def rejection_split_table(
records: Iterable[SimulationReplicateResult],
*,
alpha: float = 0.05,
statistics: Sequence[str] = STATISTICS,
) -> list[RejectionSplitRow]:
"""Split the observed statistic — and its critical value — by rejection outcome.
``inverted`` flags the Phase 4 pattern: non-rejecting replicates carrying a
*larger* mean observed statistic than rejecting ones. Reporting the mean
critical value beside it answers the follow-up the spec requires — whether
replicates with a larger observed statistic also carry a proportionally
larger bar.
"""
if not (0 < alpha < 1):
raise PivotalityError("alpha must be between 0 and 1.")
rows: list[RejectionSplitRow] = []
for cell, group in group_by_cell(records).items():
for statistic in statistics:
rejecting: list[tuple[float, float | None]] = []
non_rejecting: list[tuple[float, float | None]] = []
for record in group:
p_value = record.p_values.get(statistic)
observed = _observed(record, statistic)
if p_value is None or observed is None or not math.isfinite(float(p_value)):
continue
critical = _null_feature(record, statistic, "q95")
target = rejecting if float(p_value) < alpha else non_rejecting
target.append((observed, critical))
mean_obs_rej = _mean_first(rejecting)
mean_obs_non = _mean_first(non_rejecting)
rows.append(
RejectionSplitRow(
cell=cell,
statistic=statistic,
alpha=alpha,
n_rejecting=len(rejecting),
n_non_rejecting=len(non_rejecting),
mean_observed_rejecting=mean_obs_rej,
mean_observed_non_rejecting=mean_obs_non,
mean_critical_rejecting=_mean_second(rejecting),
mean_critical_non_rejecting=_mean_second(non_rejecting),
inverted=(
None
if mean_obs_rej is None or mean_obs_non is None
else mean_obs_non > mean_obs_rej
),
)
)
return rows
|
motco.simulations.pivotality.standardized_counterfactual_table(records, *, alpha=0.05, statistics=STATISTICS, null_control_modes=('none', 'translation'))
Rejection rate under a z calibrated on the null-control cells.
Per replicate, z = (observed − null_mean) / null_sd. The threshold is the
1 − alpha quantile of z pooled over the null-control replicates for
that statistic, and every cell — controls included — is then scored against
it. Reporting the controls' own rates is what makes a power gain bought with
an inflated Type I rate visible.
This borrows a reference distribution that does not exist outside a
simulation. It is a diagnostic, never a deployable test; see the module
docstring.
Source code in src/motco/simulations/pivotality.py
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528 | def standardized_counterfactual_table(
records: Iterable[SimulationReplicateResult],
*,
alpha: float = 0.05,
statistics: Sequence[str] = STATISTICS,
null_control_modes: Sequence[str] = ("none", "translation"),
) -> list[StandardizedRow]:
"""Rejection rate under a ``z`` calibrated on the null-control cells.
Per replicate, ``z = (observed − null_mean) / null_sd``. The threshold is the
``1 − alpha`` quantile of ``z`` pooled over the *null-control* replicates for
that statistic, and every cell — controls included — is then scored against
it. Reporting the controls' own rates is what makes a power gain bought with
an inflated Type I rate visible.
This borrows a reference distribution that does not exist outside a
simulation. It is a diagnostic, never a deployable test; see the module
docstring.
"""
if not (0 < alpha < 1):
raise PivotalityError("alpha must be between 0 and 1.")
groups = group_by_cell(records)
control_modes = set(null_control_modes)
thresholds: dict[str, float | None] = {}
for statistic in statistics:
pooled = [
z
for cell, group in groups.items()
if cell.trajectory_mode in control_modes
for record in group
if (z := replicate_z(record, statistic)) is not None
]
thresholds[statistic] = float(np.quantile(pooled, 1.0 - alpha)) if pooled else None
rows: list[StandardizedRow] = []
for cell, group in groups.items():
for statistic in statistics:
zs = [z for record in group if (z := replicate_z(record, statistic)) is not None]
p_values = [
float(p)
for record in group
if (p := record.p_values.get(statistic)) is not None and math.isfinite(float(p))
]
threshold = thresholds[statistic]
rows.append(
StandardizedRow(
cell=cell,
statistic=statistic,
alpha=alpha,
n_replicates=len(zs),
as_specified_rate=(
float(np.mean([p < alpha for p in p_values])) if p_values else None
),
standardized_rate=(
float(np.mean([z >= threshold for z in zs]))
if zs and threshold is not None
else None
),
z_threshold=threshold,
mean_z=float(np.mean(zs)) if zs else None,
is_null_control=cell.trajectory_mode in control_modes,
)
)
return rows
|
motco.simulations.pivotality.replicate_z(record, statistic)
(observed − null_mean) / null_sd for one replicate, or None.
Source code in src/motco/simulations/pivotality.py
451
452
453
454
455
456
457
458
459
460 | def replicate_z(record: SimulationReplicateResult, statistic: str) -> float | None:
"""``(observed − null_mean) / null_sd`` for one replicate, or ``None``."""
observed = _observed(record, statistic)
mean = _null_feature(record, statistic, "mean")
sd = _null_feature(record, statistic, "sd")
if observed is None or mean is None or sd is None or sd <= 0.0:
return None
z = (observed - mean) / sd
return z if math.isfinite(z) else None
|
motco.simulations.pivotality.write_pivotality_tables(records, out_dir, *, alpha=0.05, statistics=STATISTICS)
Write the association, rejection-split, and counterfactual tables as CSV.
Source code in src/motco/simulations/pivotality.py
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590 | def write_pivotality_tables(
records: Sequence[SimulationReplicateResult],
out_dir: Path,
*,
alpha: float = 0.05,
statistics: Sequence[str] = STATISTICS,
) -> dict[str, Path]:
"""Write the association, rejection-split, and counterfactual tables as CSV."""
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
tables: Mapping[str, list[dict[str, object]]] = {
"pivotality_association": rows_to_records(
association_table(records, statistics=statistics)
),
"pivotality_spectrum": rows_to_records(
spectrum_association_table(records, statistics=statistics)
),
"pivotality_rejection_split": rows_to_records(
rejection_split_table(records, alpha=alpha, statistics=statistics)
),
"pivotality_standardized": rows_to_records(
standardized_counterfactual_table(records, alpha=alpha, statistics=statistics)
),
}
paths: dict[str, Path] = {}
for name, table in tables.items():
path = out_dir / f"{name}.csv"
if not table:
path.write_text("", encoding="utf-8")
paths[name] = path
continue
with path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=list(table[0]))
writer.writeheader()
writer.writerows(table)
paths[name] = path
return paths
|
motco.simulations.study.summarize_pls_selection(records)
Selected component counts, effective CV settings, and AUROC per cell.
Source code in src/motco/simulations/study/phase4.py
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343 | def summarize_pls_selection(records: Sequence[SimulationReplicateResult]) -> pd.DataFrame:
"""Selected component counts, effective CV settings, and AUROC per cell."""
buckets: dict[str, dict[str, Any]] = {}
for record in records:
if record.status != "completed":
continue
meta = dict(record.cell_metadata)
entry = buckets.setdefault(
record.cell_id,
{
"cell_id": record.cell_id,
"phase": record.phase,
"trajectory_mode": str(meta.get("trajectory_mode") or "none"),
"effect_size": meta.get("effect_size"),
"completed_replicates": 0,
"missing_integration_metadata": 0,
"selected_lv": [],
"cv_mean_auroc": [],
"cv_settings": set(),
},
)
entry["completed_replicates"] += 1
integration = record.integration_metadata or {}
if not integration or integration.get("integration_method") != "pls":
entry["missing_integration_metadata"] += 1
continue
selected = integration.get("selected_lv")
if selected is None:
selected = (integration.get("integration_params") or {}).get("selected_lv")
if selected is None:
entry["missing_integration_metadata"] += 1
else:
entry["selected_lv"].append(int(selected))
auroc = integration.get("cv_mean_auroc")
if auroc is not None and math.isfinite(float(auroc)):
entry["cv_mean_auroc"].append(float(auroc))
params = integration.get("integration_params") or {}
entry["cv_settings"].add(
tuple(
(key, params.get(key))
for key in ("cv1_splits", "cv2_splits", "n_repeats", "max_components", "random_state")
)
)
rows: list[dict[str, Any]] = []
for entry in buckets.values():
selected = entry.pop("selected_lv")
auroc = entry.pop("cv_mean_auroc")
settings = entry.pop("cv_settings")
row = dict(entry)
row.update(
{
"n_selected_lv": len(selected),
"selected_lv_mean": _mean(selected),
"selected_lv_median": _median(selected),
"selected_lv_mode": _mode(selected),
"selected_lv_min": min(selected) if selected else None,
"selected_lv_max": max(selected) if selected else None,
"cv_mean_auroc_mean": _mean(auroc),
"cv_settings": _format_settings(settings),
"cv_settings_consistent": len(settings) <= 1,
}
)
rows.append(row)
frame = pd.DataFrame(rows)
if frame.empty:
return frame
return frame.sort_values(["trajectory_mode", "effect_size", "cell_id"]).reset_index(drop=True)
|
motco.simulations.study.summarize_attribution(records)
Attribution availability, retention, stability, and truth recovery.
Cross-replicate agreement is computed from the persisted top-k identifiers:
top_k_jaccard is the mean pairwise overlap of the selected sets across
replicates, and sign_agreement is the mean, over features selected in at
least two replicates, of the fraction agreeing with that feature's modal
sign. Both are None when fewer than two replicates carry the transition.
Source code in src/motco/simulations/study/phase4.py
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465 | def summarize_attribution(records: Sequence[SimulationReplicateResult]) -> pd.DataFrame:
"""Attribution availability, retention, stability, and truth recovery.
Cross-replicate agreement is computed from the persisted top-k identifiers:
``top_k_jaccard`` is the mean pairwise overlap of the selected sets across
replicates, and ``sign_agreement`` is the mean, over features selected in at
least two replicates, of the fraction agreeing with that feature's modal
sign. Both are ``None`` when fewer than two replicates carry the transition.
"""
groups: dict[tuple, dict[str, Any]] = {}
for record in records:
if record.status != "completed":
continue
meta = dict(record.cell_metadata)
mode = str(meta.get("trajectory_mode") or "none")
effect_size = meta.get("effect_size")
effect_size = float(effect_size) if effect_size is not None else None
diagnostics = record.attribution_diagnostics or {}
status = record.attribution_status or diagnostics.get("status") or "not_requested"
if status == "not_requested":
continue
per_transition = {
(entry["transition_id"], component)
for entry in diagnostics.get("transitions", [])
for component in _COMPONENTS
}
if not per_transition:
key: tuple[Any, ...] = (mode, effect_size, record.cell_id, None, None)
entry_group = _attribution_group(groups, key, mode, effect_size, record)
entry_group["eligible"] += 1
entry_group["failed"] += 1 if status == "failed" else 0
continue
retention = {entry["transition_id"]: entry.get("retention", {}) for entry in diagnostics.get("transitions", [])}
recovery = {
(entry["transition_id"], entry["component"]): entry
for entry in diagnostics.get("truth_recovery", [])
}
stability = {
(entry["transition_id"], entry["component"]): entry for entry in diagnostics.get("stability", [])
}
selected: dict[tuple[str, str], list[tuple[str, int]]] = {}
for entry in diagnostics.get("top_features", []):
selected.setdefault((entry["transition_id"], entry["component"]), []).append(
(str(entry["feature"]), int(entry.get("sign", 0)))
)
for transition_id, component in sorted(per_transition):
key = (mode, effect_size, record.cell_id, transition_id, component)
entry_group = _attribution_group(groups, key, mode, effect_size, record)
entry_group["eligible"] += 1
entry_group["computed"] += 1
transition_retention = retention.get(transition_id, {})
if component == "observed" and transition_retention.get("available"):
_append(entry_group["retention_cosine"], transition_retention.get("cosine"))
_append(entry_group["retention_norm_ratio"], transition_retention.get("norm_ratio"))
_append(entry_group["residual_fraction"], transition_retention.get("residual_fraction"))
recovery_entry = recovery.get((transition_id, component))
if recovery_entry and recovery_entry.get("available"):
_append(entry_group["precision"], recovery_entry.get("precision"))
_append(entry_group["recall"], recovery_entry.get("recall"))
_append(entry_group["selected_count"], recovery_entry.get("selected_count"))
stability_entry = stability.get((transition_id, component))
if stability_entry:
_append(entry_group["sign_stability"], stability_entry.get("mean_sign_stability"))
_append(
entry_group["top_k_selection_frequency"],
stability_entry.get("mean_top_k_selection_frequency"),
)
features = selected.get((transition_id, component))
if features:
entry_group["selections"].append(features)
rows: list[dict[str, Any]] = []
for entry in groups.values():
selections = entry.pop("selections")
row = {
"trajectory_mode": entry["trajectory_mode"],
"effect_size": entry["effect_size"],
"cell_id": entry["cell_id"],
"transition_id": entry["transition_id"],
"component": entry["component"],
"eligible_replicates": entry["eligible"],
"computed_replicates": entry["computed"],
"failed_replicates": entry["failed"],
"availability_rate": (entry["computed"] / entry["eligible"]) if entry["eligible"] else None,
"retention_cosine_mean": _mean(entry["retention_cosine"]),
"retention_norm_ratio_mean": _mean(entry["retention_norm_ratio"]),
"residual_fraction_mean": _mean(entry["residual_fraction"]),
"precision_mean": _mean(entry["precision"]),
"recall_mean": _mean(entry["recall"]),
"selected_count_mean": _mean(entry["selected_count"]),
"bootstrap_sign_stability_mean": _mean(entry["sign_stability"]),
"bootstrap_top_k_frequency_mean": _mean(entry["top_k_selection_frequency"]),
"top_k_jaccard": _mean_pairwise_jaccard(selections),
"sign_agreement": _sign_agreement(selections),
}
rows.append(row)
frame = pd.DataFrame(rows)
if frame.empty:
return frame
return frame.sort_values(
["trajectory_mode", "effect_size", "cell_id", "transition_id", "component"],
na_position="first",
).reset_index(drop=True)
|
motco.simulations.study.localize_off_diagonal(records, *, scope='joint', materiality_threshold=None, rule='absolute', dust_atol=MATERIALITY_DUST_ATOL)
First checkpoint at which each off-diagonal response becomes material.
Every checkpoint is judged on its own scale-free quantity, so a
standardized-feature distance is never weighed against a PLS-latent one:
delta is divided by that checkpoint's mean group path length, giving a
dimensionless relative size difference;
angle is divided by 180, the estimator reporting orientation in
degrees on [0, 180], giving the fraction of the maximum possible
orientation difference; and
shape is already a Procrustes distance on centroid-scaled configurations.
Two rule values decide when that excess counts as material:
"absolute" (the default, and the rule every already-reported run was
judged under): material when the excess is at least
materiality_threshold, defaulting to
:data:DEFAULT_MATERIALITY_THRESHOLD. One absolute cut cannot serve all
three statistics — shape responses run ~1e-2 while the cut is 5e-2, so
a shape response the RRPP test rejects at high rate still reports
not_material. Kept as the default only so committed reports keep
reproducing.
"null_dispersion": material when the excess is at least
materiality_threshold (defaulting to
:data:DEFAULT_MATERIALITY_DISPERSION_UNITS) times the anchor's dispersion
at the same checkpoint, which puts the three statistics in common units.
Where that dispersion is degenerate — zero or dust, as it is at every
population checkpoint for an analytically null construction — the rule
falls back to "excess exceeds dust_atol" rather than dividing by dust.
materiality_basis records which path decided each row.
Comparing an excess rather than a ratio matters throughout: an exactly-null
construction leaves the anchor's angle at roughly 1e-8, and any ratio against
that is meaningless.
The emitted classification labels where a response first appears —
construction, sampling/preprocessing, or projection. It is descriptive, not
causal, and gates nothing.
Source code in src/motco/simulations/study/phase4.py
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773 | def localize_off_diagonal(
records: Sequence[SimulationReplicateResult],
*,
scope: str = "joint",
materiality_threshold: float | None = None,
rule: str = "absolute",
dust_atol: float = MATERIALITY_DUST_ATOL,
) -> pd.DataFrame:
"""First checkpoint at which each off-diagonal response becomes material.
Every checkpoint is judged on its **own** scale-free quantity, so a
standardized-feature distance is never weighed against a PLS-latent one:
- ``delta`` is divided by that checkpoint's mean group path length, giving a
dimensionless relative size difference;
- ``angle`` is divided by 180, the estimator reporting orientation in
**degrees** on ``[0, 180]``, giving the fraction of the maximum possible
orientation difference; and
- ``shape`` is already a Procrustes distance on centroid-scaled configurations.
Two ``rule`` values decide when that excess counts as material:
- ``"absolute"`` (the default, and the rule every already-reported run was
judged under): material when the excess is at least
``materiality_threshold``, defaulting to
:data:`DEFAULT_MATERIALITY_THRESHOLD`. One absolute cut cannot serve all
three statistics — ``shape`` responses run ~1e-2 while the cut is 5e-2, so
a ``shape`` response the RRPP test rejects at high rate still reports
``not_material``. Kept as the default only so committed reports keep
reproducing.
- ``"null_dispersion"``: material when the excess is at least
``materiality_threshold`` (defaulting to
:data:`DEFAULT_MATERIALITY_DISPERSION_UNITS`) times the anchor's dispersion
at the same checkpoint, which puts the three statistics in common units.
Where that dispersion is degenerate — zero or dust, as it is at every
population checkpoint for an analytically null construction — the rule
falls back to "excess exceeds ``dust_atol``" rather than dividing by dust.
``materiality_basis`` records which path decided each row.
Comparing an excess rather than a ratio matters throughout: an exactly-null
construction leaves the anchor's angle at roughly 1e-8, and any ratio against
that is meaningless.
The emitted classification labels where a response first appears —
construction, sampling/preprocessing, or projection. It is descriptive, not
causal, and gates nothing.
"""
geometry = summarize_realized_geometry(records)
columns = [
"trajectory_mode",
"effect_size",
"statistic",
"first_material_checkpoint",
"classification",
"materiality_threshold",
"normalized_value",
"normalized_null",
"normalized_excess",
"measurement_space",
]
# The legacy rule keeps the legacy schema exactly. Adding columns under it
# would change every already-committed `phase4_localization.csv`, and a
# frozen report is asserted to regenerate byte-identically; the extra
# columns describe the new rule's reasoning, so they travel with it.
if rule != "absolute":
columns = [
*columns[:5],
"materiality_rule",
"materiality_threshold",
"materiality_basis",
"normalized_value",
"normalized_null",
"normalized_excess",
"null_dispersion",
"excess_in_dispersion_units",
"measurement_space",
]
if rule not in MATERIALITY_RULES:
raise Phase4SummaryError(
f"Unknown materiality rule {rule!r}; expected one of {MATERIALITY_RULES}."
)
if materiality_threshold is None:
materiality_threshold = (
DEFAULT_MATERIALITY_THRESHOLD
if rule == "absolute"
else DEFAULT_MATERIALITY_DISPERSION_UNITS
)
if geometry.empty:
return pd.DataFrame(columns=columns)
anchor_cells = {
record.cell_id
for record in records
if record.cell_metadata.get("zero_effect_anchor")
or (record.phase.startswith("type_i_") and not record.cell_metadata.get("trajectory_mode"))
}
scoped = geometry[geometry["scope"] == scope]
normalized = _normalized_geometry(scoped)
null_reference = {
(checkpoint, statistic): value
for (cell_id, checkpoint, statistic), value in normalized.items()
if cell_id in anchor_cells
}
dispersion = _normalized_geometry(scoped, column="sd")
null_dispersion = {
(checkpoint, statistic): value
for (cell_id, checkpoint, statistic), value in dispersion.items()
if cell_id in anchor_cells
}
rows: list[dict[str, Any]] = []
grouped: dict[tuple[str, Any, str], dict[str, float]] = {}
identity: dict[tuple[str, Any, str], dict[str, Any]] = {}
for row in scoped[scoped["statistic"].isin(_STATISTICS)].itertuples(index=False):
if row.cell_id in anchor_cells:
continue
key = (str(row.trajectory_mode), row.effect_size, str(row.statistic))
value = normalized.get((str(row.cell_id), str(row.checkpoint), str(row.statistic)))
if value is not None:
grouped.setdefault(key, {})[str(row.checkpoint)] = value
identity.setdefault(key, {"cell_id": str(row.cell_id)})
for key in sorted(grouped, key=lambda k: (k[0], k[2], k[1] if k[1] is not None else -1.0)):
mode, effect_size, statistic = key
if DIAGONAL_STATISTIC.get(mode) == statistic:
continue
by_checkpoint = grouped[key]
first_checkpoint: str | None = None
observed: float | None = None
null_value: float | None = None
excess: float | None = None
spread: float | None = None
excess_units: float | None = None
basis: str | None = None
for checkpoint in CHECKPOINT_ORDER:
value = by_checkpoint.get(checkpoint)
reference = null_reference.get((checkpoint, statistic))
if value is None:
continue
baseline = 0.0 if reference is None else reference
candidate_excess = value - baseline
candidate_spread = null_dispersion.get((checkpoint, statistic))
candidate_units: float | None = None
if rule == "absolute":
candidate_basis = "absolute_excess"
material = candidate_excess >= materiality_threshold
elif candidate_spread is not None and candidate_spread > dust_atol:
# Usable null dispersion: judge in its units.
candidate_basis = "null_dispersion"
candidate_units = candidate_excess / candidate_spread
material = candidate_units >= materiality_threshold
else:
# Degenerate null (analytically zero at population checkpoints):
# any excess above dust is material. Never divide by dust.
candidate_basis = "degenerate_null_dust_floor"
material = candidate_excess > dust_atol
if material:
first_checkpoint = checkpoint
observed = value
null_value = reference
excess = candidate_excess
spread = candidate_spread
excess_units = candidate_units
basis = candidate_basis
break
localization_row: dict[str, Any] = {
"trajectory_mode": mode,
"effect_size": effect_size,
"statistic": statistic,
"first_material_checkpoint": first_checkpoint,
"classification": (
CHECKPOINT_CLASSIFICATION.get(first_checkpoint, "unclassified")
if first_checkpoint is not None
else "not_material"
),
"materiality_threshold": materiality_threshold,
"normalized_value": observed,
"normalized_null": null_value,
"normalized_excess": excess,
"measurement_space": (
MEASUREMENT_SPACES.get(first_checkpoint) if first_checkpoint is not None else None
),
}
if rule != "absolute":
localization_row["materiality_rule"] = rule
localization_row["materiality_basis"] = basis
localization_row["null_dispersion"] = spread
localization_row["excess_in_dispersion_units"] = excess_units
rows.append(localization_row)
frame = pd.DataFrame(rows, columns=columns)
if frame.empty:
return frame
return frame.sort_values(["trajectory_mode", "statistic", "effect_size"]).reset_index(drop=True)
|
motco.simulations.study.type_i_inflation_bound(alpha, se_tolerance, n_available)
One-sided inflation bound alpha + k * sqrt(alpha (1 - alpha) / n).
Source code in src/motco/simulations/study/phase4.py
| def type_i_inflation_bound(alpha: float, se_tolerance: float, n_available: int) -> float:
"""One-sided inflation bound ``alpha + k * sqrt(alpha (1 - alpha) / n)``."""
if n_available <= 0:
raise Phase4SummaryError("n_available must be positive to compute an inflation bound.")
return float(alpha + se_tolerance * math.sqrt(alpha * (1.0 - alpha) / n_available))
|
motco.simulations.study.evaluate_phase4_gate(gate, summaries, records, *, expected_units=None)
Aggregate the predeclared gates into proceed/hold/indeterminate.
Gate multiplicity is predeclared, not discovered after the run: each control
cell contributes one one-sided test per statistic, and at a true rate of
exactly alpha each exceeds its bound with probability around 0.023. One
marginal exceedance therefore must not decide the phase. When exactly one
control statistic exceeds its bound by less than
confirmation_se_threshold Monte Carlo standard errors and no other
mandatory gate fails, the decision is indeterminate with a named
confirmation re-run. Two or more exceedances, or any exceedance of at least
one standard error, is hold.
proceed is never emitted when mandatory records or diagnostics are
incomplete.
Source code in src/motco/simulations/study/phase4.py
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353 | def evaluate_phase4_gate(
gate: Phase4GateConfig,
summaries: Sequence[SimulationSummaryResult],
records: Sequence[SimulationReplicateResult],
*,
expected_units: int | None = None,
) -> Phase4GateDecision:
"""Aggregate the predeclared gates into ``proceed``/``hold``/``indeterminate``.
Gate multiplicity is predeclared, not discovered after the run: each control
cell contributes one one-sided test per statistic, and at a true rate of
exactly alpha each exceeds its bound with probability around 0.023. One
marginal exceedance therefore must not decide the phase. When exactly one
control statistic exceeds its bound by less than
``confirmation_se_threshold`` Monte Carlo standard errors and no other
mandatory gate fails, the decision is ``indeterminate`` with a named
confirmation re-run. Two or more exceedances, or any exceedance of at least
one standard error, is ``hold``.
``proceed`` is never emitted when mandatory records or diagnostics are
incomplete.
"""
if not gate.enabled:
raise Phase4SummaryError(
"Phase 4 gate evaluation requires acceptance.gate.enabled in the study configuration."
)
operating = build_operating_frame(summaries, records)
observations: list[GateObservation] = list(evaluate_type_i_inflation(gate, summaries, records))
for rule in gate.rules:
if rule.role == "mandatory_power":
observations.append(evaluate_power_rule(gate, rule, operating))
elif rule.role == "mandatory_control":
observations.append(evaluate_control_rule(gate, rule, operating))
else:
observations.append(evaluate_descriptive_rule(rule, operating))
completeness = evaluate_completeness(records, expected_units=expected_units)
if gate.require_complete_records:
observations.append(completeness)
control_failures = [
observation
for observation in observations
if observation.kind in {"type_i_inflation", "control"} and observation.met is False
]
marginal = [
observation
for observation in control_failures
if _is_marginal(observation, gate.confirmation_se_threshold)
]
material = [observation for observation in control_failures if observation not in marginal]
other_failures = [
observation
for observation in observations
if observation.kind in {"power", "completeness"} and observation.met is False
]
missing_evidence = [
observation
for observation in observations
if observation.kind in {"power", "type_i_inflation", "control", "completeness"}
and observation.met is None
]
confirmation_runs: tuple[dict[str, Any], ...] = ()
if material or other_failures:
decision = "hold"
rationale = "Mandatory gate(s) failed: " + "; ".join(
f"{observation.rule} ({observation.detail})" for observation in material + other_failures
)
if len(marginal) > gate.max_marginal_exceedances:
rationale += (
f"; plus {len(marginal)} marginal control exceedance(s) beyond the "
f"{gate.max_marginal_exceedances} allowed"
)
elif len(marginal) > gate.max_marginal_exceedances:
decision = "hold"
rationale = (
f"{len(marginal)} control statistics exceed their inflation bounds, above the "
f"{gate.max_marginal_exceedances} marginal exceedance(s) the confirmation rule allows: "
+ "; ".join(f"{observation.rule} ({observation.detail})" for observation in marginal)
)
elif marginal:
observation = marginal[0]
decision = "indeterminate"
confirmation_runs = (
{
"cell_id": observation.cell_id,
"statistic": observation.statistic,
"trajectory_mode": observation.trajectory_mode,
"effect_size": observation.effect_size,
"reason": "single marginal control exceedance awaiting confirmation",
"action": (
f"re-run cell {observation.cell_id} with independent seeds and re-evaluate "
f"the {observation.statistic} control before any Phase 5 decision"
),
},
)
rationale = (
f"Exactly one control statistic ({observation.rule}) exceeds its bound by less than "
f"{gate.confirmation_se_threshold:g} Monte Carlo SE and no other mandatory gate fails; "
"a confirmation re-run is required before a Phase 5 decision."
)
elif missing_evidence:
decision = "indeterminate"
rationale = "Incomplete evidence: " + "; ".join(
f"{observation.rule} ({observation.detail})" for observation in missing_evidence
)
else:
decision = "proceed"
rationale = "All mandatory gates met with complete eligible diagnostics."
return Phase4GateDecision(
decision=decision,
rationale=rationale,
observations=tuple(observations),
confirmation_runs=confirmation_runs,
)
|
motco.simulations.study.Phase4GateDecision
dataclass
Aggregated Phase 4 gate outcome for the paper-grade study.
Source code in src/motco/simulations/study/phase4.py
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234 | @dataclass(frozen=True)
class Phase4GateDecision:
"""Aggregated Phase 4 gate outcome for the paper-grade study."""
decision: str
rationale: str
observations: tuple[GateObservation, ...]
confirmation_runs: tuple[dict[str, Any], ...] = ()
def to_frame(self) -> pd.DataFrame:
"""Structured rows for CSV/JSON output."""
rows = [
{
"rule": observation.rule,
"kind": observation.kind,
"trajectory_mode": observation.trajectory_mode,
"statistic": observation.statistic,
"cell_id": observation.cell_id,
"effect_size": observation.effect_size,
"met": observation.met,
"detail": observation.detail,
**{f"obs_{key}": value for key, value in observation.observations.items()},
}
for observation in self.observations
]
return pd.DataFrame(rows)
|
to_frame()
Structured rows for CSV/JSON output.
Source code in src/motco/simulations/study/phase4.py
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234 | def to_frame(self) -> pd.DataFrame:
"""Structured rows for CSV/JSON output."""
rows = [
{
"rule": observation.rule,
"kind": observation.kind,
"trajectory_mode": observation.trajectory_mode,
"statistic": observation.statistic,
"cell_id": observation.cell_id,
"effect_size": observation.effect_size,
"met": observation.met,
"detail": observation.detail,
**{f"obs_{key}": value for key, value in observation.observations.items()},
}
for observation in self.observations
]
return pd.DataFrame(rows)
|
motco.simulations.study.build_phase4_frames(gate, summaries, records, *, expected_units=None)
Build every Phase 4 structured output plus the gate decision.
Source code in src/motco/simulations/study/report.py
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114 | def build_phase4_frames(
gate: Phase4GateConfig,
summaries: Sequence[SimulationSummaryResult],
records: Sequence[SimulationReplicateResult],
*,
expected_units: int | None = None,
) -> Phase4ReportFrames:
"""Build every Phase 4 structured output plus the gate decision."""
decision = evaluate_phase4_gate(gate, summaries, records, expected_units=expected_units)
return Phase4ReportFrames(
operating=build_operating_frame(summaries, records),
geometry=summarize_realized_geometry(records),
pls_selection=summarize_pls_selection(records),
attribution=summarize_attribution(records),
localization=localize_off_diagonal(records),
gate=decision.to_frame(),
decision=decision,
)
|
motco.simulations.study.write_phase4_report(frames, out_dir)
Write the Phase 4 tables as CSV and the gate decision as JSON.
Source code in src/motco/simulations/study/report.py
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166 | def write_phase4_report(frames: Phase4ReportFrames, out_dir: Path) -> dict[str, Path]:
"""Write the Phase 4 tables as CSV and the gate decision as JSON."""
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
paths = {
"phase4_operating": out_dir / "phase4_operating.csv",
"phase4_geometry": out_dir / "phase4_geometry.csv",
"phase4_pls_selection": out_dir / "phase4_pls_selection.csv",
"phase4_attribution": out_dir / "phase4_attribution.csv",
"phase4_localization": out_dir / "phase4_localization.csv",
"phase4_gate": out_dir / "phase4_gate.csv",
}
frames.operating.to_csv(paths["phase4_operating"], index=False)
frames.geometry.to_csv(paths["phase4_geometry"], index=False)
frames.pls_selection.to_csv(paths["phase4_pls_selection"], index=False)
frames.attribution.to_csv(paths["phase4_attribution"], index=False)
frames.localization.to_csv(paths["phase4_localization"], index=False)
frames.gate.to_csv(paths["phase4_gate"], index=False)
decision_path = out_dir / "phase4_gate_decision.json"
decision_path.write_text(
json.dumps(
{
"decision": frames.decision.decision,
"rationale": frames.decision.rationale,
"confirmation_runs": list(frames.decision.confirmation_runs),
"observations": [
{
"rule": observation.rule,
"kind": observation.kind,
"trajectory_mode": observation.trajectory_mode,
"statistic": observation.statistic,
"cell_id": observation.cell_id,
"effect_size": observation.effect_size,
"met": observation.met,
"detail": observation.detail,
"observations": observation.observations,
}
for observation in frames.decision.observations
],
},
indent=2,
default=_json_default,
)
+ "\n",
encoding="utf-8",
)
paths["phase4_gate_decision"] = decision_path
return paths
|
Render every Phase 4 figure alongside the existing study outputs.
contract is forwarded to :func:render_attribution_stability; without it
every figure is byte-for-byte the pre-contract output.
Source code in src/motco/simulations/study/report.py
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195 | def render_phase4_figures(
frames: Phase4ReportFrames, out_dir: Path, *, contract: ReportContract | None = None
) -> dict[str, Path]:
"""Render every Phase 4 figure alongside the existing study outputs.
``contract`` is forwarded to :func:`render_attribution_stability`; without it
every figure is byte-for-byte the pre-contract output.
"""
out_dir = Path(out_dir)
return {
"phase4_geometry_plot": render_geometry_checkpoints(
frames.geometry, out_dir / "phase4_geometry.png"
),
"phase4_selected_components_plot": render_selected_components(
frames.pls_selection, out_dir / "phase4_selected_components.png"
),
"phase4_attribution_stability_plot": render_attribution_stability(
frames.attribution, out_dir / "phase4_attribution_stability.png", contract=contract
),
}
|