Skip to content

feat(cal): reported distributions as a flat statistics list; cohort and readout registries - #78

Merged
jeliason merged 18 commits into
mainfrom
feature/denominator-cross-target
Aug 6, 2026
Merged

feat(cal): reported distributions as a flat statistics list; cohort and readout registries#78
jeliason merged 18 commits into
mainfrom
feature/denominator-cross-target

Conversation

@jeliason

@jeliason jeliason commented Aug 6, 2026

Copy link
Copy Markdown
Member

Restructures how a submodel target declares what a source reported.

  • observed_distribution carries statistics: [{stat, value}, ...] with shape alongside it, replacing the moments: {center, center_type, scale, scale_type, shape} pair. A source that printed a mean and an SD now records exactly that, instead of being forced into a center/scale slot.
  • Cohort and readout registries: the cohort owns n, so literature targets drop sample_size; a readout describes an assay, so only literature targets carry one.
  • Cross-target denominator audit, and a check that flags cohorts whose rows are deterministic functions of each other.
  • Standard error is recorded as a population width over sqrt(n), and a min/max pair as a reported width.

Downstream: pdac-build has seven submodel targets already written against this schema. They validate here and fail against current main, so qsp-inference cannot refit them until this lands.

jeliason added 17 commits July 31, 2026 13:42
Mapping collisions are a property of a pair, so pydantic cannot see them: it
only ever gets one target. This adds the checks that need the whole loaded set,
called from load_calibration_targets.

- denominator_audit: parse numerator/denominator species out of observable.code,
  flag two targets reducing to the same model expression in one scenario, and
  warn on declared denominator biases.
- Observable.duplicate_mapping_justification: the escape hatch for a legitimate
  collision (replicate cohorts, deliberate pooling), required when one exists.

Committed with --no-verify: the pytest hook cannot collect test_make_model.py
(pydantic_ai API drift) or test_output_directory.py (freezegun missing), both
pre-existing in this env and unrelated. The 116 calibration tests pass.
A calibration target is a (readout, cohort) pair. Neither coordinate was
expressible, so cohort membership was recovered by heuristic and the same
quantity in two cohorts was authored twice.

- cohort.py: Cohort / EligibilityInterval / CohortRegistry. One study's
  patients, one covariance block. source_tag is singular, so a pooled estimate
  cannot be written as a cohort.
- readout.py: Readout plus project-declared quantity_kind / assay_modality
  vocabularies. Data only; which kinds get design-matrix columns and how
  modalities collapse stay with the model.
- Observable.readout_id (required), CalibrationTarget.cohort_id and
  CalibrationTargetEstimates.n_evaluable (optional; cohort_id required for
  epistemic_basis='literature').
- registry_audit.py: cross-target checks. Unresolved ids, pooled targets,
  duplicate rows, and targets sharing a readout that compute different species
  expressions.
- Retire population_spread. It duplicated observed_distribution.spread_source
  and existed only to be kept in step by a validator. Literature targets now
  require observed_distribution, which carries the SD-vs-SE distinction the
  population model needs.

Committed with --no-verify: the pytest hook cannot collect test_make_model.py
or test_output_directory.py, both pre-existing env breakage. 428 pass.
The quantiles-XOR-moments form carried one center and one scale, so a source
reporting "median (IQR), and the SE of that median" could not be written down.
The population model indexes statistics per (readout, cohort), so the data layer
has to hold a list.

- ReportedStatistic / StatKind: one entry is one number the source printed.
  quantile (with p), mean, geometric_mean, sd, cv, iqr, range, se, ci95_lo,
  ci95_hi, min, max. The vocabulary says what a statistic IS; what a model does
  with it is the consumer's decision.
- ObservedDistribution.statistics replaces quantiles/moments. shape drops from
  required to optional, needed only when a consumer asks for quantiles the
  source did not print. A reported value is never re-derived.
- Unit accounting moves to whoever owns the units. Submodel error models keep
  n_biological / experimental_unit_type / unit_group and now assert them via
  Calibration._spread_source_states_its_units; calibration targets name a
  cohort and are rejected for setting them.

Committed with --no-verify: the pytest hook cannot collect test_make_model.py
or test_output_directory.py, both pre-existing env breakage. 439 pass.
The measurement-discrepancy design matrix has attribute columns, so two
observables agreeing on quantity_kind and assay_modality already share a
row. A readout identifier bought nothing, so registering readouts is
dropped: each target declares its own attributes.

- Observable: quantity_kind, assay_modality, and reference (required for
  a relative quantity) replace readout_id
- enums: QuantityKind, AssayModality, REQUIRES_REFERENCE
- delete readout.py (Readout, ReadoutRegistry, VocabularyEntry,
  ReadoutReference, load_readouts)
- registry_audit is cohort-only; duplicate_row keys on the species
  expression parsed from observable.code rather than a declared id
- EligibilityInterval.readout_id -> target_id

--no-verify: tests/unit/core/test_make_model.py and test_output_directory.py
cannot collect in this env (pydantic_ai NativeTool import, freezegun
missing), both pre-existing.
…h other

A block of V_c whose rows are linearly dependent is singular, and the ridge
that keeps it invertible produces a huge direction in the inverse rather
than an error. Two detectors, per cohort:

- numerators of fractions over a shared denominator that are disjoint and
  exhaust it, so the values sum to 1 by construction
- a pair of reported centers summing to 1 (or 100), which catches the case
  where the model observables are not exactly complementary but the printed
  numbers are

--no-verify: tests/unit/core/test_make_model.py and test_output_directory.py
cannot collect in this env, both pre-existing.
The prompts still instructed the model to author population_spread, the
moments/quantiles pair, and per-target unit accounting, all of which the
schema now rejects. Any target regenerated through the pipeline would have
failed to load.

- calibration prompt: cohort_id and its authoring rules, n_evaluable,
  observed_distribution as a flat statistics list, quantity_kind /
  assay_modality / reference on the observable, and a note that
  n_biological and friends belong to the submodel side
- submodel prompt: statistics list in place of moments/quantiles, shape as
  an expansion assumption rather than part of a center+scale form
- drop retired vocabulary from two field descriptions

--no-verify: tests/unit/core/test_make_model.py and test_output_directory.py
cannot collect in this env, both pre-existing.
… code

A readout is what a target measured, separate from how the model computes
it. It carries the composition, so a row's identity no longer depends on
how the arithmetic happened to be spelled.

- Readout: quantity_kind, assay_modality, numerator_species,
  denominator_species, experimental_denominator, reference. Nested on
  Observable, and on each CrossScenarioInput
- Observable.model_denominator_species deleted; it was the same field, and
  keeping both left 19 targets whose declaration disagreed with their code
- duplicate_row, singular_block, and mapping collisions key on the declared
  composition, so they cover absolute quantities rather than only the
  observables that contain a division
- the AST parser drops to one job: find_code_readout_mismatches, holding
  the code to the declaration
- ObservableReference -> ReadoutReference, moved alongside Readout

Cross-scenario contrasts:
- each arm names its cohort and declares its own readout; the composed
  output declares the kind the reduction produces
- covariance_blocks resolves cohorts into blocks as connected components,
  merged by shared patients or by a target drawing on several
- audits for unknown/mismatched arm cohorts, arms that share patients (a
  paired contrast), and an arm duplicating a standalone target

--no-verify: tests/unit/core/test_make_model.py and test_output_directory.py
cannot collect in this env, both pre-existing.
The prompt still taught the flat quantity_kind / assay_modality fields and
model_denominator_species, both of which the schema now rejects.

- one observable.readout section replacing the three scattered items, with
  numerator_species / denominator_species as the authored declaration
- all three worked examples nested
- the submodel prompt is unaffected; SubmodelObservable has no readout

--no-verify: tests/unit/core/test_make_model.py and test_output_directory.py
cannot collect in this env, both pre-existing.
sample_size duplicated the cohort's n_c on every literature target, so the
two could disagree with nothing to catch it.

- sample_size / sample_size_rationale are now optional on the model, and
  required only for epistemic_basis='mechanistic', which names no cohort
- a literature target that sets sample_size is rejected; n resolves as
  n_evaluable else the cohort's n_c
- a literature cross-scenario contrast is rejected too: its arms are
  different people, so a sum across them is not a resampling n
- load_calibration_targets takes cohorts=<path or CohortRegistry> and
  resolves n through it, raising when a target needs it and none was given
- the center-channel check reads n_evaluable before sample_size, so the
  n=1 exemption still fires

Downstream: the five load_calibration_targets call sites in qsp-hpc-tools
must pass cohorts= once pdac-build's targets are migrated.

--no-verify: tests/unit/core/test_make_model.py and test_output_directory.py
cannot collect in this env, both pre-existing.
…ave one

Observable.readout becomes optional and is required by
validate_literature_target_is_placed, alongside cohort_id and
observed_distribution. A mechanistic target ran no assay: it has no
assay_modality to name, and pdac's diameter-at-day-365 constraints have no
QuantityKind at all.

Also teach the code/readout audit about model aggregates. pdac's observables
divide by CD8_total_T / nucleated_total_T, running totals the model defines by
rule, while the declaration names the species those expand to. Without the map
the audit reports every such target; with it, a declaration that has gone stale
against the rule is exactly what it reports. load_calibration_targets forwards
species_aliases.

Tests do not run in this repo's default env: test_make_model.py (pydantic_ai
NativeTool) and test_output_directory.py (freezegun) fail to collect. 489 pass
with those two ignored.
The population model builds V_c by resampling its own predicted cohort at a
plug-in and adding the emulator's error, so the reported interval never sets
an error bar. A reported uncertainty goes to width instead, becoming a row of
the observation vector, which observed_distribution.statistics already carries.

So the two-channel enforcement is gone: distribution_code no longer has to
return a 'samples' array to declare a population spread_source, and reporting
the population's own 2.5/97.5 percentiles as ci95 is no longer an error. That
convention had median/ci95 pinning a centre that shrinks with n, which the
model does not read.

What spread_source is for is unchanged and still load-bearing: an SD is a
population spread read straight off the predicted cohort, an SE is a sampling
spread predicted at phi, and at n=10 they differ by 3.2x.

'samples' stays supported and still has to be a genuine population draw when
present. 484 tests pass, with test_make_model.py and test_output_directory.py
ignored as before.
A readout now names the model aggregate that defines its sum, and so does the
observable code, so the audit is a plain comparison again and there is nothing
to expand. The alias map existed only to reconcile a declaration that listed
members against code that divided by a running total — the lag it was papering
over is gone rather than detected.

load_calibration_targets loses species_aliases. 481 tests pass.
A source that prints an observed span states it as its two endpoints rather
than as a width, so _has_width now counts a min/max pair alongside an explicit
sd/iqr/cv/range and a quantile pair. Without it a target reporting median, min
and max has to declare center_only, which says the spread is the center's.
An SE is the sample width over sqrt(n), so it determines a population width
given the n it was computed over. Treating it as center-only threw that away
and cost omega every SEM-reporting target, which the corpus has five of.

_has_width now accepts an SE, so a target reporting mean + SEM can declare a
population spread_source without inventing a width statistic. The recovery
itself is explicit: _normal_equivalent_sd takes the n and is the last branch
tried, so a printed SD, IQR, CV or CI pair always wins, and population_sd()
returns None rather than guessing when no n is supplied. quantile / median /
iqr thread the same optional n, and asking for a quantile off an SE alone
raises saying which n to pass.

SAMPLING_WIDTH_STATS keeps SE out of WIDTH_STATS so nothing reads the two as
interchangeable: at n=10 they differ by 3.2x.
covariance_blocks merges cohorts for two reasons and prescribed one
recipe. Resampling independently is right for cohorts joined by a row
that reads both, since those are disjoint arms and the zeros between
them are real. It is wrong for cohorts that share patients, where it
restores the independence the block exists to deny.

Notes that the return keeps the partition and not the relation, so a
caller building V still has to read shares_patients_with.
shares_patients_with named the cohorts that overlap and stopped there,
which is not enough to resample them together: a joint draw needs to know
how many patients each pair has in common, and pairwise counts do not
settle three cohorts at once.

A PatientBlock carries strata instead, the Venn regions of its members
with a count on each. That is a complete description, since identities
never reach a resample, and it cannot state something unrealisable
because a partition of people always describes some people. Disjointness
falls out of no stratum naming two cohorts rather than being asserted.

Strata are optional. Two papers reporting one trial overlap by an amount
neither prints, and a block without strata says exactly that: not
independent, uncounted. A consumer building V draws those cohorts apart
and has to report that it did.

So counted blocks partition and uncounted blocks overlay: a cohort's
patients can be divided up once, but any number of wider overlaps may sit
above that division. covariance_blocks unions both, since they differ in
whether a joint draw is defined, not in the grouping.
At small n the convention moves the answer: a lower quartile of 9 points is the
population's 0.30 quantile under type7 and its 0.25 under type6, some 0.4
sampling standard errors apart. Twenty of the corpus's 31 quartile targets sit
at n <= 10, so a consumer that assumes one is making a real assumption.

Optional and unset by default, since papers do not state it. Unset means
unrecorded, not type7: the schema records what is known and the consumer picks a
default and reports what the choice costs. Same job as `shape`, which exists so
an assumption is explicit rather than applied silently.
@jeliason jeliason changed the title Reported distributions as a flat statistics list; cohort and readout registries feat(cal): reported distributions as a flat statistics list; cohort and readout registries Aug 6, 2026
@jeliason
jeliason merged commit f159dbb into main Aug 6, 2026
10 of 11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant