diff --git a/README.md b/README.md index 1c67511..4ce228e 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,35 @@ source_relevance: **Nuisance parameters** can be marked `nuisance: true` when needed by the forward model but not part of the QSP model (e.g., a proliferation rate that helps constrain an activation rate). They carry their own inline prior, are sampled during MCMC, but are excluded from the output priors. -There's also a **CalibrationTarget** schema for clinical/in vivo observables (biopsies, blood draws) that require full model simulation — these feed into Stage 2. +There's also a **CalibrationTarget** schema for clinical/in vivo observables +(biopsies, blood draws) that require full model simulation — these feed into +Stage 2. A literature target's `observable.readout` records what was measured and +the closed-category facts of its measurement process: + +```yaml +readout: + quantity_kind: fraction + assay_modality: mihc + measurement_context: + acquisition_method: resection + specimen_compartment: primary_tumor + specimen_material: tissue_section + preservation: ffpe + sampling_unit: region_of_interest + sampling_region: non_lymphoid_tumor + detection_basis: marker_defined_cells + analyte_pool: cellular + normalization_mode: cell_fraction_of_all_nucleated_cells + numerator_species: [V_T.CD8] + denominator_species: [V_T.CD8, V_T.Th, V_T.Treg] +``` + +Maple stores this factual vocabulary without choosing a statistical correction; +downstream analyses explicitly select which fields, reference levels, and prior +weights define a discrepancy model. Mechanistic assertions omit `readout` +because no experimental measurement was made. See the +[CalibrationTarget extraction guide](src/maple/prompts/calibration_target_prompt.md) +for the complete enum vocabulary and authoring rules. --- diff --git a/src/maple/core/calibration/__init__.py b/src/maple/core/calibration/__init__.py index f3a5beb..19719aa 100644 --- a/src/maple/core/calibration/__init__.py +++ b/src/maple/core/calibration/__init__.py @@ -27,10 +27,19 @@ # Enums from maple.core.calibration.enums import ( + AcquisitionMethod, + AnalytePool, AssayModality, Compartment, + DetectionBasis, + NormalizationMode, + Preservation, QuantityKind, REQUIRES_REFERENCE, + SamplingRegion, + SamplingUnit, + SpecimenCompartment, + SpecimenMaterial, ExtractionMethod, Indication, IndicationMatch, @@ -70,6 +79,7 @@ ) from maple.core.calibration.readout import ( + MeasurementContext, Readout, ReadoutReference, ) @@ -218,6 +228,15 @@ "ExtractionMethod", "enum_field_description", "AssayModality", + "AcquisitionMethod", + "SpecimenCompartment", + "SpecimenMaterial", + "Preservation", + "SamplingUnit", + "SamplingRegion", + "DetectionBasis", + "AnalytePool", + "NormalizationMode", "QuantityKind", "REQUIRES_REFERENCE", # Reported distribution @@ -252,6 +271,7 @@ "Observable", "Readout", "ReadoutReference", + "MeasurementContext", "ObservableConstant", "SupportType", "Submodel", diff --git a/src/maple/core/calibration/calibration_target_models.py b/src/maple/core/calibration/calibration_target_models.py index 098173d..2c36c6e 100644 --- a/src/maple/core/calibration/calibration_target_models.py +++ b/src/maple/core/calibration/calibration_target_models.py @@ -1291,6 +1291,12 @@ def validate_literature_target_is_placed(self) -> "CalibrationTarget": "measurement-discrepancy design from its attributes, and its species " "composition is the row's identity." ) + elif self.observable.readout.measurement_context is None: + missing.append( + "observable.readout.measurement_context: record the closed-category " + "facts of specimen acquisition, processing, sampling, detection, and " + "normalization. Storing a field does not itself activate a correction." + ) if self.empirical_data.observed_distribution is None: missing.append( "empirical_data.observed_distribution: list the statistics the source " diff --git a/src/maple/core/calibration/cross_scenario_target.py b/src/maple/core/calibration/cross_scenario_target.py index 715f4ab..f64bbae 100644 --- a/src/maple/core/calibration/cross_scenario_target.py +++ b/src/maple/core/calibration/cross_scenario_target.py @@ -346,6 +346,16 @@ def validate_literature_arms_are_placed(self) -> "CrossScenarioCalibrationTarget """ if self.epistemic_basis != "literature": return self + missing_context = [ + i.role for i in self.observable.inputs if i.readout.measurement_context is None + ] + if missing_context: + raise ValueError( + "epistemic_basis='literature' but these roles have no " + f"readout.measurement_context: {missing_context}. Each measured arm must " + "record how its specimen was acquired, processed, sampled, detected, and " + "normalized." + ) unplaced = [i.role for i in self.observable.inputs if not i.cohort_id] if unplaced: raise ValueError( diff --git a/src/maple/core/calibration/enums.py b/src/maple/core/calibration/enums.py index e2834c2..3ee4cb4 100644 --- a/src/maple/core/calibration/enums.py +++ b/src/maple/core/calibration/enums.py @@ -203,9 +203,8 @@ class ExtractionMethod(str, Enum): class QuantityKind(str, Enum): """What kind of quantity an observable is. - Read by inference to build the measurement-discrepancy design matrix. Two - observables of the same kind and modality share a row of it, and so share a - location and scale correction. + This defines the mathematical readout and its observation scale. It is + factual metadata, not a measurement-correction field. """ DENSITY = "density" @@ -235,7 +234,11 @@ class QuantityKind(str, Enum): class AssayModality(str, Enum): - """How an observable was measured.""" + """How an observable was measured. + + A downstream model may explicitly activate this field for shared + corrections; Maple only records the category. + """ MIHC = "mihc" """Multiplex immunohistochemistry with digital image analysis.""" @@ -271,6 +274,116 @@ class AssayModality(str, Enum): """Recorded clinical observation rather than an assay.""" +class AcquisitionMethod(str, Enum): + """How the biological specimen or image was acquired.""" + + RESECTION = "resection" + NEEDLE_BIOPSY = "needle_biopsy" + BLOOD_DRAW = "blood_draw" + IN_SITU_IMAGING = "in_situ_imaging" + UNKNOWN = "unknown" + + +class SpecimenCompartment(str, Enum): + """Anatomical compartment from which the measurement was obtained.""" + + PRIMARY_TUMOR = "primary_tumor" + PERIPHERAL_BLOOD = "peripheral_blood" + UNKNOWN = "unknown" + + +class SpecimenMaterial(str, Enum): + """Physical material presented to the measurement procedure.""" + + TISSUE_SECTION = "tissue_section" + TISSUE_HOMOGENATE = "tissue_homogenate" + DISSOCIATED_CELLS = "dissociated_cells" + WHOLE_BLOOD = "whole_blood" + IN_SITU_TUMOR = "in_situ_tumor" + UNKNOWN = "unknown" + + +class Preservation(str, Enum): + """Specimen-preservation state at measurement.""" + + FFPE = "ffpe" + FRESH = "fresh" + FROZEN = "frozen" + NOT_APPLICABLE = "not_applicable" + UNKNOWN = "unknown" + + +class SamplingUnit(str, Enum): + """Physical unit over which the reported measurement was sampled.""" + + WHOLE_SECTION = "whole_section" + TISSUE_MICROARRAY_CORE = "tissue_microarray_core" + REGION_OF_INTEREST = "region_of_interest" + BULK_TISSUE_SAMPLE = "bulk_tissue_sample" + WHOLE_BLOOD_SAMPLE = "whole_blood_sample" + IMAGED_LESION = "imaged_lesion" + UNKNOWN = "unknown" + + +class SamplingRegion(str, Enum): + """Region within the specimen selected for measurement.""" + + WHOLE_TUMOR = "whole_tumor" + SELECTED_TUMOR_REGION = "selected_tumor_region" + NON_LYMPHOID_TUMOR = "non_lymphoid_tumor" + LYMPHOID_AGGREGATE = "lymphoid_aggregate" + TUMOR_STROMA = "tumor_stroma" + TUMOR_CLUSTER = "tumor_cluster" + NOT_APPLICABLE = "not_applicable" + UNKNOWN = "unknown" + + +class DetectionBasis(str, Enum): + """Operational basis used to identify or quantify the analyte.""" + + MARKER_DEFINED_CELLS = "marker_defined_cells" + TRANSCRIPTOMIC_CLUSTER = "transcriptomic_cluster" + SOLUBLE_ANALYTE = "soluble_analyte" + LABEL_FREE_MATRIX_SIGNAL = "label_free_matrix_signal" + MORPHOLOGIC_SEGMENTATION = "morphologic_segmentation" + ANATOMIC_IMAGING = "anatomic_imaging" + UNKNOWN = "unknown" + + +class AnalytePool(str, Enum): + """Physical analyte pool represented by the reported quantity.""" + + CELLULAR = "cellular" + SOLUBLE_TISSUE_EXTRACT = "soluble_tissue_extract" + STRUCTURAL_MATRIX = "structural_matrix" + TISSUE_MORPHOLOGY = "tissue_morphology" + ANATOMIC_LESION = "anatomic_lesion" + UNKNOWN = "unknown" + + +class NormalizationMode(str, Enum): + """Complete experimental denominator or normalization convention.""" + + CELL_COUNT_PER_TISSUE_AREA = "cell_count_per_tissue_area" + CELL_COUNT_PER_WHOLE_BLOOD_VOLUME = "cell_count_per_whole_blood_volume" + CELL_COUNT_RATIO_WITHIN_SAMPLING_UNIT = "cell_count_ratio_within_sampling_unit" + CELL_FRACTION_OF_ALL_NUCLEATED_CELLS = "cell_fraction_of_all_nucleated_cells" + CELL_FRACTION_OF_CD4_T_CELLS = "cell_fraction_of_cd4_t_cells" + CELL_FRACTION_OF_CD8_T_CELLS = "cell_fraction_of_cd8_t_cells" + CELL_FRACTION_OF_CAFS = "cell_fraction_of_cafs" + CELL_FRACTION_OF_TOTAL_DENDRITIC_CELLS = "cell_fraction_of_total_dendritic_cells" + FIBROBLAST_AREA_FRACTION_OF_CLASSIFIED_FIBROBLAST_AREA = ( + "fibroblast_area_fraction_of_classified_fibroblast_area" + ) + MATRIX_SIGNAL_AREA_FRACTION_OF_IMAGE_FIELD_AREA = ( + "matrix_signal_area_fraction_of_image_field_area" + ) + STROMA_AREA_FRACTION_OF_TOTAL_TISSUE_AREA = "stroma_area_fraction_of_total_tissue_area" + ANALYTE_AMOUNT_PER_TOTAL_PROTEIN_MASS = "analyte_amount_per_total_protein_mass" + ELAPSED_TIME_BETWEEN_SERIAL_IMAGES = "elapsed_time_between_serial_images" + UNKNOWN = "unknown" + + # ============================================================================= # SOURCE RELEVANCE ENUMS # ============================================================================= diff --git a/src/maple/core/calibration/observable.py b/src/maple/core/calibration/observable.py index 14dae3c..bcdb7d2 100644 --- a/src/maple/core/calibration/observable.py +++ b/src/maple/core/calibration/observable.py @@ -389,10 +389,10 @@ class Observable(BaseModel): readout: Optional[Readout] = Field( default=None, - description="What the experiment measured: quantity kind, assay, denominator, and " - "reference. The rest of this model is how to compute it. Required for a literature " - "target; null for a mechanistic one, which asserts a constraint rather than running " - "an assay.", + description="What the experiment measured: quantity kind, assay, structured " + "measurement context, denominator, and reference. The rest of this model is how " + "to compute it. Required for a literature target; null for a mechanistic one, " + "which asserts a constraint rather than running an assay.", ) readout_time: Optional[float] = Field( diff --git a/src/maple/core/calibration/readout.py b/src/maple/core/calibration/readout.py index 5f77ba7..0366d08 100644 --- a/src/maple/core/calibration/readout.py +++ b/src/maple/core/calibration/readout.py @@ -6,7 +6,41 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator -from maple.core.calibration.enums import REQUIRES_REFERENCE, AssayModality, QuantityKind +from maple.core.calibration.enums import ( + REQUIRES_REFERENCE, + AcquisitionMethod, + AnalytePool, + AssayModality, + DetectionBasis, + NormalizationMode, + Preservation, + QuantityKind, + SamplingRegion, + SamplingUnit, + SpecimenCompartment, + SpecimenMaterial, +) + + +class MeasurementContext(BaseModel): + """Factual categorical description of a literature measurement process. + + Maple records the complete context without deciding which fields create a + statistical correction. That activation, its references, and its prior + weights belong to the downstream analysis specification. + """ + + model_config = ConfigDict(extra="forbid") + + acquisition_method: AcquisitionMethod + specimen_compartment: SpecimenCompartment + specimen_material: SpecimenMaterial + preservation: Preservation + sampling_unit: SamplingUnit + sampling_region: SamplingRegion + detection_basis: DetectionBasis + analyte_pool: AnalytePool + normalization_mode: NormalizationMode class ReadoutReference(BaseModel): @@ -39,13 +73,24 @@ def _matches_kind(self) -> "ReadoutReference": class Readout(BaseModel): - """What was measured. Inference builds the measurement-discrepancy design from - these attributes, so two readouts agreeing on them share one correction.""" + """What was measured, separately from how the model computes it. + + The fields are factual. A downstream analysis explicitly selects which, if + any, define shared measurement corrections. + """ model_config = ConfigDict(extra="forbid") quantity_kind: QuantityKind = Field(description="What kind of quantity was reported.") assay_modality: AssayModality = Field(description="How it was measured.") + measurement_context: Optional[MeasurementContext] = Field( + default=None, + description=( + "Closed-category facts about specimen acquisition, processing, sampling, " + "detection, and normalization. Required for literature targets; absent on " + "mechanistic assertions that do not represent a measurement." + ), + ) numerator_species: List[str] = Field( min_length=1, description="Model species composing the numerator." ) diff --git a/src/maple/mcp_server.py b/src/maple/mcp_server.py index 9ffe3fc..8674e68 100644 --- a/src/maple/mcp_server.py +++ b/src/maple/mcp_server.py @@ -12,11 +12,22 @@ from fastmcp import FastMCP from maple.core.calibration.enums import ( + AcquisitionMethod, + AnalytePool, + AssayModality, + DetectionBasis, ExperimentalSystem, IndicationMatch, MeasurementDirectness, + NormalizationMode, PerturbationType, + Preservation, + QuantityKind, + SamplingRegion, + SamplingUnit, SourceQuality, + SpecimenCompartment, + SpecimenMaterial, TemporalResolution, TMECompatibility, ) @@ -126,6 +137,17 @@ def _build_enum_reference() -> str: _enum_table(MeasurementDirectness, "measurement_directness"), _enum_table(TemporalResolution, "temporal_resolution"), _enum_table(ExperimentalSystem, "experimental_system"), + _enum_table(QuantityKind, "quantity_kind"), + _enum_table(AssayModality, "assay_modality"), + _enum_table(AcquisitionMethod, "measurement_context.acquisition_method"), + _enum_table(SpecimenCompartment, "measurement_context.specimen_compartment"), + _enum_table(SpecimenMaterial, "measurement_context.specimen_material"), + _enum_table(Preservation, "measurement_context.preservation"), + _enum_table(SamplingUnit, "measurement_context.sampling_unit"), + _enum_table(SamplingRegion, "measurement_context.sampling_region"), + _enum_table(DetectionBasis, "measurement_context.detection_basis"), + _enum_table(AnalytePool, "measurement_context.analyte_pool"), + _enum_table(NormalizationMode, "measurement_context.normalization_mode"), ] return "\n\n".join(sections) diff --git a/src/maple/prompts/calibration_target_prompt.md b/src/maple/prompts/calibration_target_prompt.md index 4a807dd..97cf057 100644 --- a/src/maple/prompts/calibration_target_prompt.md +++ b/src/maple/prompts/calibration_target_prompt.md @@ -473,12 +473,22 @@ The top-level `observable:` block describes a single experimental observable. (T - `positive_unbounded`: Output must be > 0, no upper bound (fold-changes, ratios) - `real`: Any real value (log-ratios, change scores) -7. **observable.readout** (required for `epistemic_basis: literature`; omit for `mechanistic`) — what the EXPERIMENT measured, as opposed to how the model computes it. The rest of `observable` is the computation; this block is the measurement, and inference reads it to decide which rows share a measurement correction and which rows are the same quantity. A mechanistic target ran no assay, so it has no readout. +7. **observable.readout** (required for `epistemic_basis: literature`; omit for `mechanistic`) — what the EXPERIMENT measured, as opposed to how the model computes it. The rest of `observable` is the computation. This block records factual categories from which a downstream analysis may build a predeclared correction, while its species composition identifies the modeled quantity. A mechanistic target ran no assay, so it has no readout. ```yaml readout: quantity_kind: fraction assay_modality: mihc + measurement_context: + acquisition_method: resection + specimen_compartment: primary_tumor + specimen_material: tissue_section + preservation: ffpe + sampling_unit: region_of_interest + sampling_region: non_lymphoid_tumor + detection_basis: marker_defined_cells + analyte_pool: cellular + normalization_mode: cell_fraction_of_all_nucleated_cells numerator_species: ['V_T.CD8'] denominator_species: ['V_T.CD8', 'V_T.Th', 'V_T.Treg'] experimental_denominator: "CD3+ T cells (all T cell subsets)" @@ -486,7 +496,18 @@ readout: ``` - **`quantity_kind`**: `density` (cells or mass per area/volume of tissue) · `fraction` (a part over a whole containing it, in (0,1)) · `ratio` (one quantity over another not containing it) · `foldchange` (a quantity over its own value at a reference) · `concentration` · `intensity` · `time`. A percentage is a `fraction`, not its own kind; the compartment it is a percentage OF is named by the denominator fields. - - **`assay_modality`**: `mihc` · `ihc` · `mif` · `flow_cytometry` · `scrnaseq` · `multiplex_immunoassay` · `elisa` · `multiphoton_microscopy` · `digital_histopathology` · `imaging` · `clinical`. Describe the measurement, not the cell type: two readouts agreeing on kind and modality share one correction whatever they count. + - **`assay_modality`**: `mihc` · `ihc` · `mif` · `flow_cytometry` · `scrnaseq` · `multiplex_immunoassay` · `elisa` · `multiphoton_microscopy` · `digital_histopathology` · `imaging` · `clinical`. Describe the measurement technology, not the cell type. A downstream model may activate assay as a shared global effect; quantity kind does not partition that effect. + - **`measurement_context`** (required): factual closed-category metadata about how the specimen was acquired, processed, sampled, detected, and normalized. Every field below is required. Recording a field does not itself create a discrepancy effect; downstream analyses predeclare which fields are active. + - `acquisition_method`: `resection` · `needle_biopsy` · `blood_draw` · `in_situ_imaging` · `unknown` + - `specimen_compartment`: `primary_tumor` · `peripheral_blood` · `unknown` + - `specimen_material`: `tissue_section` · `tissue_homogenate` · `dissociated_cells` · `whole_blood` · `in_situ_tumor` · `unknown` + - `preservation`: `ffpe` · `fresh` · `frozen` · `not_applicable` · `unknown` + - `sampling_unit`: `whole_section` · `tissue_microarray_core` · `region_of_interest` · `bulk_tissue_sample` · `whole_blood_sample` · `imaged_lesion` · `unknown` + - `sampling_region`: `whole_tumor` · `selected_tumor_region` · `non_lymphoid_tumor` · `lymphoid_aggregate` · `tumor_stroma` · `tumor_cluster` · `not_applicable` · `unknown` + - `detection_basis`: `marker_defined_cells` · `transcriptomic_cluster` · `soluble_analyte` · `label_free_matrix_signal` · `morphologic_segmentation` · `anatomic_imaging` · `unknown` + - `analyte_pool`: `cellular` · `soluble_tissue_extract` · `structural_matrix` · `tissue_morphology` · `anatomic_lesion` · `unknown` + - `normalization_mode`: `cell_count_per_tissue_area` · `cell_count_per_whole_blood_volume` · `cell_count_ratio_within_sampling_unit` · `cell_fraction_of_all_nucleated_cells` · `cell_fraction_of_cd4_t_cells` · `cell_fraction_of_cd8_t_cells` · `cell_fraction_of_cafs` · `cell_fraction_of_total_dendritic_cells` · `fibroblast_area_fraction_of_classified_fibroblast_area` · `matrix_signal_area_fraction_of_image_field_area` · `stroma_area_fraction_of_total_tissue_area` · `analyte_amount_per_total_protein_mass` · `elapsed_time_between_serial_images` · `unknown` + - Use `unknown` only after reviewing the protocol and finding that it was not reported. Use `not_applicable` only when a field genuinely has no referent. Free text belongs in existing provenance fields and never creates a model category. - **`numerator_species`** (required) and **`denominator_species`** (empty for an absolute quantity) — which model species compose the row. This is the row's identity, so it must not depend on how you spelled the arithmetic in `code`: declare the components even when the code divides by a single pre-aggregated species, and expand that aggregate to the species the model currently sums into it. A cross-target audit holds `code` to this declaration and reports any disagreement. - **`experimental_denominator`** — what the experiment divided by, in the paper's own words. **Conditionally required:** an observable that is a density or per-mass concentration (units like `cell/mm**2`, `pg/mg`, with `support: positive`) is rejected without it. Setting it requires `denominator_species` too. - **`reference`** (required for `quantity_kind: foldchange`, forbidden otherwise) — what the fold change is measured against. Do not rely on a `species[0]` or `series[0]` convention inside the code. @@ -515,6 +536,16 @@ observable: readout: quantity_kind: density assay_modality: ihc + measurement_context: + acquisition_method: resection + specimen_compartment: primary_tumor + specimen_material: tissue_section + preservation: ffpe + sampling_unit: whole_section + sampling_region: whole_tumor + detection_basis: marker_defined_cells + analyte_pool: cellular + normalization_mode: cell_count_per_tissue_area numerator_species: ['V_T.CD8'] denominator_species: ['V_T.C1'] experimental_denominator: "mm^2 of tumor tissue section (cancer cells + stroma)" @@ -540,17 +571,26 @@ observable: code: | def compute_observable(time, species_dict, constants, ureg): treg = species_dict['V_T.Treg'] - total_t = (treg + species_dict['V_T.CD8'] + species_dict['V_T.Th'] - + species_dict['V_T.CD8_exh'] + species_dict['V_T.Th_exh']) - return (treg / total_t).to('dimensionless') + total_cd4 = treg + species_dict['V_T.Th'] + species_dict['V_T.Th_exh'] + return (treg / total_cd4).to('dimensionless') units: dimensionless - species: ['V_T.Treg', 'V_T.CD8', 'V_T.Th', 'V_T.CD8_exh', 'V_T.Th_exh'] + species: ['V_T.Treg', 'V_T.Th', 'V_T.Th_exh'] readout: quantity_kind: fraction assay_modality: mihc + measurement_context: + acquisition_method: resection + specimen_compartment: primary_tumor + specimen_material: tissue_section + preservation: ffpe + sampling_unit: region_of_interest + sampling_region: selected_tumor_region + detection_basis: marker_defined_cells + analyte_pool: cellular + normalization_mode: cell_fraction_of_cd4_t_cells numerator_species: ['V_T.Treg'] - denominator_species: ['V_T.Treg', 'V_T.CD8', 'V_T.Th', 'V_T.CD8_exh', 'V_T.Th_exh'] - experimental_denominator: "CD3+ T cells (all T cell subsets)" + denominator_species: ['V_T.Treg', 'V_T.Th', 'V_T.Th_exh'] + experimental_denominator: "CD4+ T cells" constants: [] support: unit_interval ``` @@ -574,6 +614,16 @@ observable: readout: quantity_kind: concentration assay_modality: multiplex_immunoassay + measurement_context: + acquisition_method: blood_draw + specimen_compartment: peripheral_blood + specimen_material: whole_blood + preservation: not_applicable + sampling_unit: whole_blood_sample + sampling_region: not_applicable + detection_basis: soluble_analyte + analyte_pool: unknown + normalization_mode: unknown numerator_species: ['V_T.TGFb'] constants: - name: active_fraction diff --git a/tests/unit/core/test_calibration_target_validators.py b/tests/unit/core/test_calibration_target_validators.py index 277b870..e2e549c 100644 --- a/tests/unit/core/test_calibration_target_validators.py +++ b/tests/unit/core/test_calibration_target_validators.py @@ -107,6 +107,17 @@ def golden_calibration_target_data(): "readout": { "quantity_kind": "ratio", "assay_modality": "ihc", + "measurement_context": { + "acquisition_method": "resection", + "specimen_compartment": "primary_tumor", + "specimen_material": "tissue_section", + "preservation": "ffpe", + "sampling_unit": "whole_section", + "sampling_region": "whole_tumor", + "detection_basis": "marker_defined_cells", + "analyte_pool": "cellular", + "normalization_mode": "cell_count_ratio_within_sampling_unit", + }, "numerator_species": ["V_T.CD8"], "denominator_species": ["V_T.C1"], }, @@ -280,6 +291,14 @@ def test_literature_target_requires_a_readout( with pytest.raises(ValidationError, match="observable.readout"): CalibrationTarget.model_validate(data, context={"model_structure": model_structure}) + def test_literature_readout_requires_measurement_context( + self, model_structure, golden_calibration_target_data, mock_crossref_success + ): + data = copy.deepcopy(golden_calibration_target_data) + data["observable"]["readout"].pop("measurement_context") + with pytest.raises(ValidationError, match="measurement_context"): + CalibrationTarget.model_validate(data, context={"model_structure": model_structure}) + def test_mechanistic_target_needs_none( self, model_structure, golden_calibration_target_data, mock_crossref_success ): diff --git a/tests/unit/core/test_cohort_registry.py b/tests/unit/core/test_cohort_registry.py index a0b29a8..c72fe83 100644 --- a/tests/unit/core/test_cohort_registry.py +++ b/tests/unit/core/test_cohort_registry.py @@ -257,7 +257,7 @@ def test_experimental_denominator_needs_model_species(self): class TestObservableMeasurementAttributes: - """The attributes inference reads to build the measurement-discrepancy design.""" + """Factual readout attributes, separate from its model composition.""" def test_minimal_observable_validates(self): assert _observable().readout.quantity_kind == QuantityKind.FRACTION @@ -314,8 +314,7 @@ def test_scenario_reference_rejects_a_timepoint(self): reference={"kind": "scenario", "scenario": "baseline", "timepoint": 1.0}, ) - def test_same_kind_and_modality_share_a_design_row(self): - """No identifier needed: the attributes are the row.""" + def test_composition_does_not_change_factual_categories(self): a = _observable() b = _observable(readout={"numerator_species": ["V_T.Treg"]}) assert (a.readout.quantity_kind, a.readout.assay_modality) == ( diff --git a/tests/unit/core/test_measurement_context.py b/tests/unit/core/test_measurement_context.py new file mode 100644 index 0000000..f40c04c --- /dev/null +++ b/tests/unit/core/test_measurement_context.py @@ -0,0 +1,65 @@ +"""Closed measurement-context vocabulary carried by literature readouts.""" + +import pytest +from pydantic import ValidationError + +from maple.core.calibration import MeasurementContext, Readout + + +CONTEXT = { + "acquisition_method": "resection", + "specimen_compartment": "primary_tumor", + "specimen_material": "tissue_section", + "preservation": "ffpe", + "sampling_unit": "region_of_interest", + "sampling_region": "non_lymphoid_tumor", + "detection_basis": "marker_defined_cells", + "analyte_pool": "cellular", + "normalization_mode": "cell_fraction_of_all_nucleated_cells", +} + + +def test_complete_context_parses_as_closed_enums(): + context = MeasurementContext(**CONTEXT) + assert context.specimen_material.value == "tissue_section" + assert context.preservation.value == "ffpe" + + +@pytest.mark.parametrize("field", tuple(CONTEXT)) +def test_every_context_field_is_required(field): + data = dict(CONTEXT) + data.pop(field) + with pytest.raises(ValidationError, match=field): + MeasurementContext(**data) + + +def test_context_rejects_unknown_enum_level(): + with pytest.raises(ValidationError, match="specimen_material"): + MeasurementContext(**{**CONTEXT, "specimen_material": "paraffin_blob"}) + + +def test_context_rejects_free_form_extra_fields(): + with pytest.raises(ValidationError, match="free_text_protocol|Extra inputs"): + MeasurementContext(**CONTEXT, free_text_protocol="whatever the paper said") + + +def test_readout_carries_context_without_assigning_model_roles(): + readout = Readout( + quantity_kind="fraction", + assay_modality="mihc", + measurement_context=CONTEXT, + numerator_species=["V_T.CD8"], + denominator_species=["V_T.nucleated"], + ) + assert readout.measurement_context.normalization_mode.value == ( + "cell_fraction_of_all_nucleated_cells" + ) + + +def test_bare_readout_remains_available_to_mechanistic_compositions(): + readout = Readout( + quantity_kind="density", + assay_modality="mihc", + numerator_species=["V_T.CD8"], + ) + assert readout.measurement_context is None