From dffcee55e3d18ee367c334b252ccafd10703bb0c Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Mon, 27 Oct 2025 10:25:13 -0400 Subject: [PATCH 01/19] feat!: update aac 2017 models close #42 --- .gitmodules | 2 +- src/ga4gh/va_spec/aac_2017/__init__.py | 16 +- src/ga4gh/va_spec/aac_2017/models.py | 168 +++++++++++----- src/ga4gh/va_spec/base/__init__.py | 2 + src/ga4gh/va_spec/base/core.py | 244 ++++++++++-------------- src/ga4gh/va_spec/ccv_2022/__init__.py | 4 +- src/ga4gh/va_spec/ccv_2022/models.py | 2 +- submodules/va_spec | 2 +- tests/validation/test_va_spec_models.py | 135 +++++++++++-- 9 files changed, 354 insertions(+), 221 deletions(-) diff --git a/.gitmodules b/.gitmodules index 433d259..64cd7b9 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "submodules/va_spec"] path = submodules/va_spec url = https://github.com/ga4gh/va-spec - branch = 1.0 + branch = issue-293 diff --git a/src/ga4gh/va_spec/aac_2017/__init__.py b/src/ga4gh/va_spec/aac_2017/__init__.py index d26bc8e..60393d5 100644 --- a/src/ga4gh/va_spec/aac_2017/__init__.py +++ b/src/ga4gh/va_spec/aac_2017/__init__.py @@ -1,21 +1,21 @@ """Module to load and init namespace at package level.""" from .models import ( - AMP_ASCO_CAP_LEVELS, + AMP_ASCO_CAP_STRENGTHS, AMP_ASCO_CAP_TIERS, Classification, Strength, - VariantDiagnosticStudyStatement, - VariantPrognosticStudyStatement, - VariantTherapeuticResponseStudyStatement, + VariantDiagnosticStatement, + VariantPrognosticStatement, + VariantTherapeuticResponseStatement, ) __all__ = [ - "AMP_ASCO_CAP_LEVELS", + "AMP_ASCO_CAP_STRENGTHS", "AMP_ASCO_CAP_TIERS", "Classification", "Strength", - "VariantDiagnosticStudyStatement", - "VariantPrognosticStudyStatement", - "VariantTherapeuticResponseStudyStatement", + "VariantDiagnosticStatement", + "VariantPrognosticStatement", + "VariantTherapeuticResponseStatement", ] diff --git a/src/ga4gh/va_spec/aac_2017/models.py b/src/ga4gh/va_spec/aac_2017/models.py index 98028c4..fac1637 100644 --- a/src/ga4gh/va_spec/aac_2017/models.py +++ b/src/ga4gh/va_spec/aac_2017/models.py @@ -4,17 +4,20 @@ sequence variants in cancer. """ +from abc import ABC from enum import Enum +from typing import Self -from pydantic import ( - Field, - field_validator, -) +from pydantic import Field, model_validator +from pydantic.dataclasses import dataclass from ga4gh.core.models import MappableConcept, iriReference from ga4gh.va_spec.base.core import ( + Direction, + EvidenceLine, Method, Statement, + VariantClinicalSignificanceProposition, VariantDiagnosticProposition, VariantPrognosticProposition, VariantTherapeuticResponseProposition, @@ -26,71 +29,140 @@ class Strength(str, Enum): """Define constraints for AMP/ASCO/CAP strength coding""" - LEVEL_A = "Level A" - LEVEL_B = "Level B" - LEVEL_C = "Level C" - LEVEL_D = "Level D" + STRONG = "strong" + POTENTIAL = "potential" -AMP_ASCO_CAP_LEVELS = [v.value for v in Strength.__members__.values()] +AMP_ASCO_CAP_STRENGTHS = [v.value for v in Strength.__members__.values()] class Classification(str, Enum): """Define constraints for AMP/ASCO/CAP classification coding""" - TIER_I = "Tier I" - TIER_II = "Tier II" - TIER_III = "Tier III" - TIER_IV = "Tier IV" + TIER_1 = "tier 1" + TIER_2 = "tier 2" + TIER_3 = "tier 3" + TIER_4 = "tier 4" AMP_ASCO_CAP_TIERS = [v.value for v in Classification.__members__.values()] -class AmpAscoCapValidatorMixin: - """Mixin class for reusable AMP/ASCO/CAP field validators - - Should be used with classes that inherit from Statement - """ - - @field_validator("strength") - @classmethod - def validate_strength(cls, v: MappableConcept | None) -> MappableConcept | None: - """Validate strength - - :param v: Strength - :raises ValueError: If invalid strength values are provided - :return: Validated strength value - """ - return validate_mappable_concept( - v, +class ClassificationName(str, Enum): + """Define constraints for AMP/ASCO/CAP classification name""" + + TIER_1 = "Tier I" + TIER_2 = "Tier II" + TIER_3 = "Tier III" + TIER_4 = "Tier IV" + + +@dataclass +class AmpAscoCapConfig: + """AMP/ASCO/CAP config for expected values""" + + name: ClassificationName + direction: Direction + strength: Strength | None + + +CLASSIFICATION_POLICY_MAP = { + Classification.TIER_1: AmpAscoCapConfig( + name=ClassificationName.TIER_1, + direction=Direction.SUPPORTS, + strength=Strength.STRONG, + ), + Classification.TIER_2: AmpAscoCapConfig( + name=ClassificationName.TIER_2, + direction=Direction.SUPPORTS, + strength=Strength.POTENTIAL, + ), + Classification.TIER_3: AmpAscoCapConfig( + name=ClassificationName.TIER_3, direction=Direction.NEUTRAL, strength=None + ), + Classification.TIER_4: AmpAscoCapConfig( + name=ClassificationName.TIER_4, direction=Direction.DISPUTES, strength=None + ), +} + + +class _AmpAscoCapStatement(Statement, ABC): + """Abstract base class for AAC 2017 statements""" + + @model_validator(mode="after") + def validate_aac_statement(self) -> Self: + """Validate AMP/ASCO/CAP statements""" + + def _validate_classification_config( + classification_code: Classification, + classification_name: str, + direction: str, + strength_code: str | None, + ) -> None: + """Validate that classificati""" + expected_config = CLASSIFICATION_POLICY_MAP[classification_code] + if strength_code != expected_config.strength: + expected_strength = ( + expected_config.strength.value + if expected_config.strength + else expected_config.strength + ) + msg = f"`strength` must be: {expected_strength}" + raise ValueError(msg) + + if classification_name != expected_config.name: + msg = f"`classification.name` must be: {expected_config.name.value}" + raise ValueError(msg) + + if direction != expected_config.direction: + msg = f"`direction` must be: {expected_config.direction.value}" + raise ValueError(msg) + + # Validate strength + validate_mappable_concept( + self.strength, System.AMP_ASCO_CAP, - valid_codes=AMP_ASCO_CAP_LEVELS, mc_is_required=False, ) - @field_validator("classification") - @classmethod - def validate_classification(cls, v: MappableConcept) -> MappableConcept: - """Validate classification - - :param v: Classification - :raises ValueError: If invalid classification values are provided - :return: Validated classification value - """ - return validate_mappable_concept( - v, System.AMP_ASCO_CAP, valid_codes=AMP_ASCO_CAP_TIERS + # Validate classification + validate_mappable_concept( + self.classification, + System.AMP_ASCO_CAP, + valid_codes=AMP_ASCO_CAP_TIERS, + mc_is_required=True, + ) + _validate_classification_config( + Classification(self.classification.primaryCoding.code.root), + self.classification.name, + self.direction, + self.strength.primaryCoding.code.root, ) + # Validate hasEvidenceLines + for evidence_line in self.hasEvidenceLines or []: + if isinstance(evidence_line, EvidenceLine): + target_proposition = evidence_line.targetProposition + if target_proposition and not isinstance( + target_proposition, + VariantDiagnosticProposition + | VariantPrognosticProposition + | VariantTherapeuticResponseProposition, + ): + msg = "`targetProposition` must be one of: `VariantDiagnosticProposition`, `VariantPrognosticProposition`, `VariantTherapeuticResponseProposition`" + raise ValueError(msg) + + return self + -class VariantDiagnosticStudyStatement(Statement, AmpAscoCapValidatorMixin): +class VariantDiagnosticStatement(_AmpAscoCapStatement): """A statement reporting a conclusion from a single study about whether a variant is associated with a disease (a diagnostic inclusion criterion), or absence of a disease (diagnostic exclusion criterion) - based on interpretation of the study's results. """ - proposition: VariantDiagnosticProposition = Field( + proposition: VariantClinicalSignificanceProposition = Field( ..., description="A proposition about a diagnostic association between a variant and condition, for which the study provides evidence. The validity of this proposition, and the level of confidence/evidence supporting it, may be assessed and reported by the Statement.", ) @@ -108,13 +180,13 @@ class VariantDiagnosticStudyStatement(Statement, AmpAscoCapValidatorMixin): ) -class VariantPrognosticStudyStatement(Statement, AmpAscoCapValidatorMixin): +class VariantPrognosticStatement(_AmpAscoCapStatement): """A statement reporting a conclusion from a single study about whether a variant is associated with a disease prognosis - based on interpretation of the study's results. """ - proposition: VariantPrognosticProposition = Field( + proposition: VariantClinicalSignificanceProposition = Field( ..., description="A proposition about a prognostic association between a variant and condition, for which the study provides evidence. The validity of this proposition, and the level of confidence/evidence supporting it, may be assessed and reported by the Statement.", ) @@ -132,13 +204,13 @@ class VariantPrognosticStudyStatement(Statement, AmpAscoCapValidatorMixin): ) -class VariantTherapeuticResponseStudyStatement(Statement, AmpAscoCapValidatorMixin): +class VariantTherapeuticResponseStatement(_AmpAscoCapStatement): """A statement reporting a conclusion from a single study about whether a variant is associated with a therapeutic response (positive or negative) - based on interpretation of the study's results. """ - proposition: VariantTherapeuticResponseProposition = Field( + proposition: VariantClinicalSignificanceProposition = Field( ..., description="A proposition about the therapeutic response associated with a variant, for which the study provides evidence. The validity of this proposition, and the level of confidence/evidence supporting it, may be assessed and reported by the Statement.", ) diff --git a/src/ga4gh/va_spec/base/__init__.py b/src/ga4gh/va_spec/base/__init__.py index 79d710f..85489ce 100644 --- a/src/ga4gh/va_spec/base/__init__.py +++ b/src/ga4gh/va_spec/base/__init__.py @@ -20,6 +20,7 @@ StudyResult, SubjectVariantProposition, TumorVariantFrequencyStudyResult, + VariantClinicalSignificanceProposition, VariantDiagnosticProposition, VariantOncogenicityProposition, VariantPathogenicityProposition, @@ -81,6 +82,7 @@ "Therapeutic", "TherapeuticResponsePredicate", "TherapyGroup", + "VariantClinicalSignificanceProposition", "VariantDiagnosticProposition", "VariantOncogenicityProposition", "VariantPathogenicityProposition", diff --git a/src/ga4gh/va_spec/base/core.py b/src/ga4gh/va_spec/base/core.py index e90fa7d..3062bb7 100644 --- a/src/ga4gh/va_spec/base/core.py +++ b/src/ga4gh/va_spec/base/core.py @@ -2,19 +2,16 @@ from __future__ import annotations -import importlib -import inspect from abc import ABC from datetime import date, datetime from enum import Enum -from typing import Annotated, Literal, TypeVar +from typing import Annotated, Literal from pydantic import ( ConfigDict, Field, RootModel, StringConstraints, - ValidationError, field_validator, ) @@ -35,9 +32,6 @@ from ga4gh.va_spec.base.validators import validate_mappable_concept from ga4gh.vrs.models import Allele, MolecularVariation -StatementType = TypeVar("StatementType") -EvidenceLineType = TypeVar("EvidenceLineType") - class CoreType(str, Enum): """Define VA Spec Base Core Types""" @@ -52,6 +46,22 @@ class CoreType(str, Enum): STUDY_GROUP = "StudyGroup" +class Agent(Entity, BaseModelForbidExtra): + """An autonomous actor (person, organization, or software agent) that bears some + form of responsibility for an activity taking place, for the existence of an entity, + or for another agent's activity. + """ + + type: Literal["Agent"] = Field( + default=CoreType.AGENT.value, description=f"MUST be '{CoreType.AGENT.value}'." + ) + name: str | None = Field(default=None, description="The given name of the Agent.") + agentType: str | None = Field( + default=None, + description="A specific type of agent the Agent object represents. Recommended subtypes include codes for `person`, `organization`, or `software`.", + ) + + class Contribution(Entity, BaseModelForbidExtra): """An action taken by an agent in contributing to the creation, modification, assessment, or deprecation of a particular entity (e.g. a Statement, EvidenceLine, @@ -145,6 +155,57 @@ class InformationEntity(Entity): ) +class StudyGroup(Entity, BaseModelForbidExtra): + """A collection of individuals or specimens from the same taxonomic class, selected + for analysis in a scientific study based on their exhibiting one or more common + characteristics (e.g. species, race, age, gender, disease state, income). May be + referred to as a 'cohort' or 'population' in specific research settings. + """ + + type: Literal["StudyGroup"] = Field( + default=CoreType.STUDY_GROUP.value, + description=f"Must be '{CoreType.STUDY_GROUP.value}'", + ) + memberCount: int | None = Field( + default=None, + description="The total number of individual members in the StudyGroup.", + ) + characteristics: list[MappableConcept] | None = Field( + default=None, + description="A feature or role shared by all members of the StudyGroup, representing a criterion for membership in the group.", + ) + + +class DataSet(Entity, BaseModelForbidExtra): + """A collection of related data items or records that are organized together in a + common format or structure, to enable their computational manipulation as a unit. + """ + + type: Literal["DataSet"] = Field( + default=CoreType.DATA_SET.value, + description=f"MUST be '{CoreType.DATA_SET.value}'.", + ) + datasetType: str | None = Field( + default=None, + description="A specific type of data set the DataSet instance represents (e.g. a 'clinical data set', a 'sequencing data set', a 'gene expression data set', a 'genome annotation data set')", + ) + reportedIn: Document | iriReference | None = Field( + default=None, description="A document in which the the Method is reported." + ) + releaseDate: date | None = Field( + default=None, + description="Indicates the date a version of a DataSet was formally released.", + ) + version: str | None = Field( + default=None, + description="The version of the DataSet, as assigned by its creator.", + ) + license: MappableConcept | None = Field( + default=None, + description="A specific license that dictates legal permissions for how a data set can be used (by whom, where, for what purposes, with what additional requirements, etc.)", + ) + + class _StudyResult(InformationEntity, ABC): """A collection of data items from a single study that pertain to a particular subject or experimental unit in the study, along with optional provenance information @@ -312,6 +373,7 @@ class SubjectVariantProposition(RootModel): | VariantPrognosticProposition | VariantOncogenicityProposition | VariantTherapeuticResponseProposition + | VariantTherapeuticResponseProposition ) = Field(discriminator="type") @@ -359,6 +421,27 @@ class ExperimentalVariantFunctionalImpactProposition( ) +class VariantClinicalSignificanceProposition( + ClinicalVariantProposition, BaseModelForbidExtra +): + """A Proposition describing the clinical significance of a variant with respect to a + condition. + """ + + model_config = ConfigDict(use_enum_values=True) + type: Literal["VariantClinicalSignificanceProposition"] = Field( + default="VariantClinicalSignificanceProposition", + description="MUST be 'VariantClinicalSignificanceProposition'.", + ) + predicate: Literal["hasClinicalSignificanceFor"] = Field( + default="hasClinicalSignificanceFor", + description="The predicate associating the subject variant to clinical significance for the object Condition. MUST be 'hasClinicalSignificanceFor'.", + ) + objectCondition: Condition | iriReference = Field( + ..., description="The disease that is evaluated." + ) + + class VariantDiagnosticProposition(ClinicalVariantProposition, BaseModelForbidExtra): """A Proposition about whether a variant is associated with a disease (a diagnostic inclusion criterion), or absence of a disease (diagnostic exclusion criterion). @@ -464,22 +547,6 @@ class VariantTherapeuticResponseProposition( ) -class Agent(Entity, BaseModelForbidExtra): - """An autonomous actor (person, organization, or software agent) that bears some - form of responsibility for an activity taking place, for the existence of an entity, - or for another agent's activity. - """ - - type: Literal["Agent"] = Field( - default=CoreType.AGENT.value, description=f"MUST be '{CoreType.AGENT.value}'." - ) - name: str | None = Field(default=None, description="The given name of the Agent.") - agentType: str | None = Field( - default=None, - description="A specific type of agent the Agent object represents. Recommended subtypes include codes for `person`, `organization`, or `software`.", - ) - - class Direction(str, Enum): """A term indicating whether the Statement supports, disputes, or remains neutral w.r.t. the validity of the Proposition it evaluates. @@ -490,36 +557,6 @@ class Direction(str, Enum): DISPUTES = "disputes" -class DataSet(Entity, BaseModelForbidExtra): - """A collection of related data items or records that are organized together in a - common format or structure, to enable their computational manipulation as a unit. - """ - - type: Literal["DataSet"] = Field( - default=CoreType.DATA_SET.value, - description=f"MUST be '{CoreType.DATA_SET.value}'.", - ) - datasetType: str | None = Field( - default=None, - description="A specific type of data set the DataSet instance represents (e.g. a 'clinical data set', a 'sequencing data set', a 'gene expression data set', a 'genome annotation data set')", - ) - reportedIn: Document | iriReference | None = Field( - default=None, description="A document in which the the Method is reported." - ) - releaseDate: date | None = Field( - default=None, - description="Indicates the date a version of a DataSet was formally released.", - ) - version: str | None = Field( - default=None, - description="The version of the DataSet, as assigned by its creator.", - ) - license: MappableConcept | None = Field( - default=None, - description="A specific license that dictates legal permissions for how a data set can be used (by whom, where, for what purposes, with what additional requirements, etc.)", - ) - - class EvidenceLine(InformationEntity, BaseModelForbidExtra): """An independent, evidence-based argument that may support or refute the validity of a specific Proposition. The strength and direction of this argument is based on @@ -533,12 +570,21 @@ class EvidenceLine(InformationEntity, BaseModelForbidExtra): default=CoreType.EVIDENCE_LINE.value, description=f"MUST be '{CoreType.EVIDENCE_LINE.value}'.", ) - targetProposition: Proposition | SubjectVariantProposition | None = Field( + targetProposition: ( + ExperimentalVariantFunctionalImpactProposition + | VariantClinicalSignificanceProposition + | VariantDiagnosticProposition + | VariantOncogenicityProposition + | VariantPathogenicityProposition + | VariantPrognosticProposition + | VariantTherapeuticResponseProposition + | None + ) = Field( default=None, description="The possible fact against which evidence items contained in an Evidence Line were collectively evaluated, in determining the overall strength and direction of support they provide. For example, in an ACMG Guideline-based assessment of variant pathogenicity, the support provided by distinct lines of evidence are assessed against a target proposition that the variant is pathogenic for a specific disease.", ) hasEvidenceItems: ( - list[StudyResult | StatementType | EvidenceLineType | iriReference] | None + list[StudyResult | Statement | EvidenceLine | iriReference] | None ) = Field( default=None, description="An individual piece of information that was evaluated as evidence in building the argument represented by an Evidence Line.", @@ -560,73 +606,6 @@ class EvidenceLine(InformationEntity, BaseModelForbidExtra): description="A term summarizing the overall outcome of the evidence assessment represented by the Evidence Line, in terms of the direction and strength of support it provides for or against the target Proposition.", ) - @field_validator("hasEvidenceItems", mode="before") - def validate_has_evidence_items( - cls, # noqa: N805 - v: list | None, - ) -> list | None: - """Ensure hasEvidenceItems is correct type - - This is needed since Pydantic was unable to determine which model to use - - This only handles cases defined in the VA-Spec. - - :param v: hasEvidenceItems value - :raises ValueError: If unable to find valid model for evidence items - :return: Evidence items - """ - if not v: - return v - - evidence_items = [] - - # Avoid circular imports - has_evidence_items_models = [] - for module in [ - "ga4gh.va_spec.aac_2017.models", - "ga4gh.va_spec.acmg_2015.models", - "ga4gh.va_spec.ccv_2022.models", - ]: - imported_module = importlib.import_module(module) - has_evidence_items_models.extend( - [ - obj_ - for _, obj_ in vars(imported_module).items() - if inspect.isclass(obj_) - and issubclass(obj_, Statement) - and obj_.__name__.endswith(("Statement", "EvidenceLine")) - and obj_ not in (Statement, EvidenceLine) - ] - ) - - has_evidence_items_models.extend( - [Statement, StudyResult, EvidenceLine, iriReference] - ) - - for evidence_item in v: - if isinstance(evidence_item, dict): - found_model = False - for evidence_item_model in has_evidence_items_models: - try: - evidence_item = evidence_item_model(**evidence_item) - except ValidationError: - pass - else: - evidence_items.append(evidence_item) - found_model = True - break - if not found_model: - err_msg = "Unable to find valid model for `hasEvidenceItems`" - raise ValueError(err_msg) - elif isinstance(evidence_item, str): - evidence_items.append(iriReference(root=evidence_item)) - elif isinstance(evidence_item, tuple(has_evidence_items_models)): - evidence_items.append(evidence_item) - else: - err_msg = "Unable to find valid model for `hasEvidenceItems`" - raise ValueError(err_msg) - return evidence_items - @staticmethod def _validate_evidence_outcome( values: dict, system: System, code_pattern: str @@ -742,22 +721,5 @@ class Statement(InformationEntity, BaseModelForbidExtra): ) -class StudyGroup(Entity, BaseModelForbidExtra): - """A collection of individuals or specimens from the same taxonomic class, selected - for analysis in a scientific study based on their exhibiting one or more common - characteristics (e.g. species, race, age, gender, disease state, income). May be - referred to as a 'cohort' or 'population' in specific research settings. - """ - - type: Literal["StudyGroup"] = Field( - default=CoreType.STUDY_GROUP.value, - description=f"Must be '{CoreType.STUDY_GROUP.value}'", - ) - memberCount: int | None = Field( - default=None, - description="The total number of individual members in the StudyGroup.", - ) - characteristics: list[MappableConcept] | None = Field( - default=None, - description="A feature or role shared by all members of the StudyGroup, representing a criterion for membership in the group.", - ) +EvidenceLine.model_rebuild() +Statement.model_rebuild() diff --git a/src/ga4gh/va_spec/ccv_2022/__init__.py b/src/ga4gh/va_spec/ccv_2022/__init__.py index 8c6c7a9..a84a82c 100644 --- a/src/ga4gh/va_spec/ccv_2022/__init__.py +++ b/src/ga4gh/va_spec/ccv_2022/__init__.py @@ -2,10 +2,10 @@ from .models import ( VariantOncogenicityEvidenceLine, - VariantOncogenicityStudyStatement, + VariantOncogenicityStatement, ) __all__ = [ "VariantOncogenicityEvidenceLine", - "VariantOncogenicityStudyStatement", + "VariantOncogenicityStatement", ] diff --git a/src/ga4gh/va_spec/ccv_2022/models.py b/src/ga4gh/va_spec/ccv_2022/models.py index 3ba4c88..59f0594 100644 --- a/src/ga4gh/va_spec/ccv_2022/models.py +++ b/src/ga4gh/va_spec/ccv_2022/models.py @@ -98,7 +98,7 @@ def validate_model(cls, values: dict) -> dict: # noqa: N805 return cls._validate_evidence_outcome(values, System.CCV, ccv_code_pattern) -class VariantOncogenicityStudyStatement(Statement): +class VariantOncogenicityStatement(Statement): """A statement reporting a conclusion from a single study about whether a variant is associated with oncogenicity (positive or negative) - based on interpretation of the study's results. diff --git a/submodules/va_spec b/submodules/va_spec index da35aa0..f2379c7 160000 --- a/submodules/va_spec +++ b/submodules/va_spec @@ -1 +1 @@ -Subproject commit da35aa0286aa209b24e8d3a827ddd5a491ab5350 +Subproject commit f2379c75dc1af9eb06cdd60aa001266f3e2872d7 diff --git a/tests/validation/test_va_spec_models.py b/tests/validation/test_va_spec_models.py index 8a302c4..1a111f3 100644 --- a/tests/validation/test_va_spec_models.py +++ b/tests/validation/test_va_spec_models.py @@ -10,7 +10,7 @@ from ga4gh.core.models import Coding, MappableConcept, code, iriReference from ga4gh.va_spec import acmg_2015, base, ccv_2022 -from ga4gh.va_spec.aac_2017.models import VariantTherapeuticResponseStudyStatement +from ga4gh.va_spec.aac_2017.models import VariantTherapeuticResponseStatement from ga4gh.va_spec.acmg_2015.models import ( VariantPathogenicityEvidenceLine, VariantPathogenicityStatement, @@ -20,11 +20,17 @@ CohortAlleleFrequencyStudyResult, ExperimentalVariantFunctionalImpactStudyResult, ) -from ga4gh.va_spec.base.core import EvidenceLine, Method, StudyGroup, StudyResult +from ga4gh.va_spec.base.core import ( + EvidenceLine, + Method, + Statement, + StudyGroup, + StudyResult, +) from ga4gh.va_spec.base.domain_entities import ConditionSet from ga4gh.va_spec.ccv_2022.models import ( VariantOncogenicityEvidenceLine, - VariantOncogenicityStudyStatement, + VariantOncogenicityStatement, ) VA_SPEC_TESTS_DIR = SUBMODULES_DIR / "tests" @@ -234,13 +240,13 @@ def test_evidence_line(caf): "strength": { "primaryCoding": { "system": "AMP/ASCO/CAP (AAC) Guidelines, 2017", - "code": "Level A", + "code": "strong", } }, "classification": { "primaryCoding": { "system": "AMP/ASCO/CAP (AAC) Guidelines, 2017", - "code": "Tier I", + "code": "tier 1", } }, "specifiedBy": { @@ -262,7 +268,7 @@ def test_evidence_line(caf): } el = EvidenceLine(**el_dict) assert isinstance(el.hasEvidenceItems[0], iriReference) - assert isinstance(el.hasEvidenceItems[1], VariantTherapeuticResponseStudyStatement) + assert isinstance(el.hasEvidenceItems[1], Statement) el_dict = { "type": "EvidenceLine", @@ -303,9 +309,7 @@ def test_evidence_line(caf): "hasEvidenceItems": [Agent(name="Joe")], "directionOfEvidenceProvided": "supports", } - with pytest.raises( - ValueError, match="Unable to find valid model for `hasEvidenceItems`" - ): + with pytest.raises(ValueError, match="validation errors for EvidenceLine"): EvidenceLine(**invalid_params) invalid_params = { @@ -313,9 +317,7 @@ def test_evidence_line(caf): "hasEvidenceItems": [{"type": "Statement"}], "directionOfEvidenceProvided": "supports", } - with pytest.raises( - ValueError, match="Unable to find valid model for `hasEvidenceItems`" - ): + with pytest.raises(ValueError, match="validation errors for EvidenceLine"): EvidenceLine(**invalid_params) @@ -489,7 +491,7 @@ def test_variant_pathogenicity_el(): def test_variant_onco_stmt(): - """Ensure VariantOncogenicityStudyStatement model works as expected""" + """Ensure VariantOncogenicityStatement model works as expected""" params = { "direction": "neutral", "proposition": { @@ -512,33 +514,33 @@ def test_variant_onco_stmt(): } }, } - assert VariantOncogenicityStudyStatement(**params) + assert VariantOncogenicityStatement(**params) valid_params = deepcopy(params) valid_params["strength"] = None - assert VariantOncogenicityStudyStatement(**valid_params) + assert VariantOncogenicityStatement(**valid_params) invalid_params = deepcopy(params) invalid_params["strength"]["primaryCoding"]["code"] = "oncogenic" with pytest.raises(ValueError, match="`primaryCoding.code` must be one of"): - VariantOncogenicityStudyStatement(**invalid_params) + VariantOncogenicityStatement(**invalid_params) invalid_params = deepcopy(params) invalid_params["strength"]["primaryCoding"]["system"] = "ACMG Guidelines, 2015" with pytest.raises(ValueError, match="`primaryCoding.system` must be"): - VariantOncogenicityStudyStatement(**invalid_params) + VariantOncogenicityStatement(**invalid_params) invalid_params = deepcopy(params) invalid_params["classification"]["primaryCoding"]["code"] = "pathogenic" with pytest.raises(ValueError, match="`primaryCoding.code` must be one of"): - VariantOncogenicityStudyStatement(**invalid_params) + VariantOncogenicityStatement(**invalid_params) invalid_params = deepcopy(params) invalid_params["classification"]["primaryCoding"]["system"] = ( "ACMG Guidelines, 2015" ) with pytest.raises(ValueError, match="`primaryCoding.system` must be"): - VariantOncogenicityStudyStatement(**invalid_params) + VariantOncogenicityStatement(**invalid_params) def test_variant_onco_el(): @@ -609,6 +611,101 @@ def test_variant_onco_el(): VariantOncogenicityEvidenceLine(**invalid_params) +def test_aac_statement(): + """Test that AMP/ASCO/CAP statement model validators work correctly""" + params = { + "direction": "supports", + "proposition": { + "type": "VariantClinicalSignificanceProposition", + "predicate": "hasClinicalSignificanceFor", + "objectCondition": "conditions.json#/1", + "subjectVariant": "alleles.json#/1", + }, + "strength": { + "primaryCoding": { + "code": "strong", + "system": "AMP/ASCO/CAP (AAC) Guidelines, 2017", + } + }, + "specifiedBy": "documents.json#/1", + "classification": { + "name": "Tier I", + "primaryCoding": { + "code": "tier 1", + "system": "AMP/ASCO/CAP (AAC) Guidelines, 2017", + }, + }, + "hasEvidenceLines": [ + { + "directionOfEvidenceProvided": "supports", + "strengthOfEvidenceProvided": { + "primaryCoding": { + "code": "Level A", + "system": "AMP/ASCO/CAP (AAC) Guidelines, 2017", + } + }, + "hasEvidenceItems": [ + "evidence_lines.json#/1", + { + "type": "Statement", + "direction": "supports", + "proposition": { + "type": "VariantDiagnosticProposition", + "predicate": "isDiagnosticExclusionCriterionFor", + "objectCondition": "conditions.json#/1", + "subjectVariant": "alleles.json#/1", + }, + "strength": { + "primaryCoding": { + "code": "A", + "system": "System", + } + }, + "specifiedBy": "documents.json#/1", + }, + ], + }, + ], + } + assert VariantTherapeuticResponseStatement(**params) + + # Invalid strength + invalid_params = deepcopy(params) + invalid_params["strength"]["primaryCoding"]["code"] = "Strong" + with pytest.raises(ValidationError, match="`strength` must be: strong"): + VariantTherapeuticResponseStatement(**invalid_params) + + invalid_params = deepcopy(params) + invalid_params["strength"]["primaryCoding"]["code"] = "potential" + with pytest.raises(ValidationError, match="`strength` must be: strong"): + VariantTherapeuticResponseStatement(**invalid_params) + + # Invalid classification + invalid_params = deepcopy(params) + invalid_params["classification"]["primaryCoding"]["code"] = "Tier I" + with pytest.raises(ValidationError, match="`primaryCoding.code` must be one of"): + VariantTherapeuticResponseStatement(**invalid_params) + + invalid_params = deepcopy(params) + invalid_params["classification"]["name"] = "tier 1" + with pytest.raises(ValidationError, match="`classification.name` must be: Tier I"): + VariantTherapeuticResponseStatement(**invalid_params) + + # Invalid direction + invalid_params = deepcopy(params) + invalid_params["direction"] = "disputes" + with pytest.raises(ValidationError, match="`direction` must be: supports"): + VariantTherapeuticResponseStatement(**invalid_params) + + # Invalid targetProposition + invalid_params = deepcopy(params) + invalid_params["hasEvidenceLines"][0]["targetProposition"] = invalid_params[ + "proposition" + ] + with pytest.raises(ValidationError, match="`targetProposition` must be one of"): + VariantTherapeuticResponseStatement(**invalid_params) + + def test_examples(test_definitions): """Test VA Spec examples""" va_spec_schema_mapping = { From a8c92dd5bba1bd0d839d7308e72eb5a143d77eb1 Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Mon, 27 Oct 2025 10:28:24 -0400 Subject: [PATCH 02/19] fixes --- src/ga4gh/va_spec/aac_2017/models.py | 50 ++++++++++++++++------------ 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/src/ga4gh/va_spec/aac_2017/models.py b/src/ga4gh/va_spec/aac_2017/models.py index fac1637..78bf63a 100644 --- a/src/ga4gh/va_spec/aac_2017/models.py +++ b/src/ga4gh/va_spec/aac_2017/models.py @@ -6,6 +6,7 @@ from abc import ABC from enum import Enum +from types import MappingProxyType from typing import Self from pydantic import Field, model_validator @@ -66,24 +67,26 @@ class AmpAscoCapConfig: strength: Strength | None -CLASSIFICATION_POLICY_MAP = { - Classification.TIER_1: AmpAscoCapConfig( - name=ClassificationName.TIER_1, - direction=Direction.SUPPORTS, - strength=Strength.STRONG, - ), - Classification.TIER_2: AmpAscoCapConfig( - name=ClassificationName.TIER_2, - direction=Direction.SUPPORTS, - strength=Strength.POTENTIAL, - ), - Classification.TIER_3: AmpAscoCapConfig( - name=ClassificationName.TIER_3, direction=Direction.NEUTRAL, strength=None - ), - Classification.TIER_4: AmpAscoCapConfig( - name=ClassificationName.TIER_4, direction=Direction.DISPUTES, strength=None - ), -} +CLASSIFICATION_POLICY_MAP = MappingProxyType( + { + Classification.TIER_1: AmpAscoCapConfig( + name=ClassificationName.TIER_1, + direction=Direction.SUPPORTS, + strength=Strength.STRONG, + ), + Classification.TIER_2: AmpAscoCapConfig( + name=ClassificationName.TIER_2, + direction=Direction.SUPPORTS, + strength=Strength.POTENTIAL, + ), + Classification.TIER_3: AmpAscoCapConfig( + name=ClassificationName.TIER_3, direction=Direction.NEUTRAL, strength=None + ), + Classification.TIER_4: AmpAscoCapConfig( + name=ClassificationName.TIER_4, direction=Direction.DISPUTES, strength=None + ), + } +) class _AmpAscoCapStatement(Statement, ABC): @@ -97,11 +100,16 @@ def _validate_classification_config( classification_code: Classification, classification_name: str, direction: str, - strength_code: str | None, + strength_code: MappableConcept | None, ) -> None: """Validate that classificati""" expected_config = CLASSIFICATION_POLICY_MAP[classification_code] - if strength_code != expected_config.strength: + actual_strength = ( + strength_code.primaryCoding.code.root + if strength_code + else strength_code + ) + if actual_strength != expected_config.strength: expected_strength = ( expected_config.strength.value if expected_config.strength @@ -136,7 +144,7 @@ def _validate_classification_config( Classification(self.classification.primaryCoding.code.root), self.classification.name, self.direction, - self.strength.primaryCoding.code.root, + self.strength, ) # Validate hasEvidenceLines From d58457d3d2c342eb6764fcdd1e3e8917b0b4eac8 Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Mon, 27 Oct 2025 11:36:27 -0400 Subject: [PATCH 03/19] add typing_extensions --- pyproject.toml | 3 ++- src/ga4gh/va_spec/aac_2017/models.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d53ee72..325d29a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,8 @@ dynamic = ["version"] dependencies = [ "ga4gh.vrs>=2.1.3,<3.0", "ga4gh.cat_vrs~=0.7.1", - "pydantic>=2.0,<3.0" + "pydantic>=2.0,<3.0", + "typing_extensions", ] [project.optional-dependencies] diff --git a/src/ga4gh/va_spec/aac_2017/models.py b/src/ga4gh/va_spec/aac_2017/models.py index 78bf63a..def59ed 100644 --- a/src/ga4gh/va_spec/aac_2017/models.py +++ b/src/ga4gh/va_spec/aac_2017/models.py @@ -7,10 +7,10 @@ from abc import ABC from enum import Enum from types import MappingProxyType -from typing import Self from pydantic import Field, model_validator from pydantic.dataclasses import dataclass +from typing_extensions import Self from ga4gh.core.models import MappableConcept, iriReference from ga4gh.va_spec.base.core import ( From 584390d4a628d37beb26415269376bed805e1343 Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Mon, 27 Oct 2025 12:59:11 -0400 Subject: [PATCH 04/19] fix --- src/ga4gh/va_spec/aac_2017/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ga4gh/va_spec/aac_2017/__init__.py b/src/ga4gh/va_spec/aac_2017/__init__.py index 60393d5..37c9b09 100644 --- a/src/ga4gh/va_spec/aac_2017/__init__.py +++ b/src/ga4gh/va_spec/aac_2017/__init__.py @@ -3,6 +3,7 @@ from .models import ( AMP_ASCO_CAP_STRENGTHS, AMP_ASCO_CAP_TIERS, + CLASSIFICATION_POLICY_MAP, Classification, Strength, VariantDiagnosticStatement, @@ -13,6 +14,7 @@ __all__ = [ "AMP_ASCO_CAP_STRENGTHS", "AMP_ASCO_CAP_TIERS", + "CLASSIFICATION_POLICY_MAP", "Classification", "Strength", "VariantDiagnosticStatement", From 1278ccb423ea7568d202e3cb591f0b9e331d06ae Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Mon, 27 Oct 2025 13:32:06 -0400 Subject: [PATCH 05/19] renaming --- src/ga4gh/va_spec/aac_2017/__init__.py | 16 ++++----- src/ga4gh/va_spec/aac_2017/models.py | 47 ++++++++++++++------------ src/ga4gh/va_spec/acmg_2015/models.py | 4 +-- src/ga4gh/va_spec/base/__init__.py | 8 ++--- src/ga4gh/va_spec/base/enums.py | 9 +++-- src/ga4gh/va_spec/ccv_2022/models.py | 4 +-- 6 files changed, 47 insertions(+), 41 deletions(-) diff --git a/src/ga4gh/va_spec/aac_2017/__init__.py b/src/ga4gh/va_spec/aac_2017/__init__.py index 37c9b09..e227922 100644 --- a/src/ga4gh/va_spec/aac_2017/__init__.py +++ b/src/ga4gh/va_spec/aac_2017/__init__.py @@ -1,22 +1,22 @@ """Module to load and init namespace at package level.""" from .models import ( - AMP_ASCO_CAP_STRENGTHS, - AMP_ASCO_CAP_TIERS, + AMP_ASCO_CAP_CLASSIFICATION_CODES, CLASSIFICATION_POLICY_MAP, - Classification, - Strength, + AmpAscoCapClassificationCode, + AmpAscoCapClassificationName, + AsmpAscoCapStrengthCode, VariantDiagnosticStatement, VariantPrognosticStatement, VariantTherapeuticResponseStatement, ) __all__ = [ - "AMP_ASCO_CAP_STRENGTHS", - "AMP_ASCO_CAP_TIERS", + "AMP_ASCO_CAP_CLASSIFICATION_CODES", "CLASSIFICATION_POLICY_MAP", - "Classification", - "Strength", + "AmpAscoCapClassificationCode", + "AmpAscoCapClassificationName", + "AsmpAscoCapStrengthCode", "VariantDiagnosticStatement", "VariantPrognosticStatement", "VariantTherapeuticResponseStatement", diff --git a/src/ga4gh/va_spec/aac_2017/models.py b/src/ga4gh/va_spec/aac_2017/models.py index def59ed..fa9dd05 100644 --- a/src/ga4gh/va_spec/aac_2017/models.py +++ b/src/ga4gh/va_spec/aac_2017/models.py @@ -27,17 +27,14 @@ from ga4gh.va_spec.base.validators import validate_mappable_concept -class Strength(str, Enum): +class AsmpAscoCapStrengthCode(str, Enum): """Define constraints for AMP/ASCO/CAP strength coding""" STRONG = "strong" POTENTIAL = "potential" -AMP_ASCO_CAP_STRENGTHS = [v.value for v in Strength.__members__.values()] - - -class Classification(str, Enum): +class AmpAscoCapClassificationCode(str, Enum): """Define constraints for AMP/ASCO/CAP classification coding""" TIER_1 = "tier 1" @@ -46,10 +43,12 @@ class Classification(str, Enum): TIER_4 = "tier 4" -AMP_ASCO_CAP_TIERS = [v.value for v in Classification.__members__.values()] +AMP_ASCO_CAP_CLASSIFICATION_CODES = [ + v.value for v in AmpAscoCapClassificationCode.__members__.values() +] -class ClassificationName(str, Enum): +class AmpAscoCapClassificationName(str, Enum): """Define constraints for AMP/ASCO/CAP classification name""" TIER_1 = "Tier I" @@ -62,28 +61,32 @@ class ClassificationName(str, Enum): class AmpAscoCapConfig: """AMP/ASCO/CAP config for expected values""" - name: ClassificationName + name: AmpAscoCapClassificationName direction: Direction - strength: Strength | None + strength: AsmpAscoCapStrengthCode | None CLASSIFICATION_POLICY_MAP = MappingProxyType( { - Classification.TIER_1: AmpAscoCapConfig( - name=ClassificationName.TIER_1, + AmpAscoCapClassificationCode.TIER_1: AmpAscoCapConfig( + name=AmpAscoCapClassificationName.TIER_1, direction=Direction.SUPPORTS, - strength=Strength.STRONG, + strength=AsmpAscoCapStrengthCode.STRONG, ), - Classification.TIER_2: AmpAscoCapConfig( - name=ClassificationName.TIER_2, + AmpAscoCapClassificationCode.TIER_2: AmpAscoCapConfig( + name=AmpAscoCapClassificationName.TIER_2, direction=Direction.SUPPORTS, - strength=Strength.POTENTIAL, + strength=AsmpAscoCapStrengthCode.POTENTIAL, ), - Classification.TIER_3: AmpAscoCapConfig( - name=ClassificationName.TIER_3, direction=Direction.NEUTRAL, strength=None + AmpAscoCapClassificationCode.TIER_3: AmpAscoCapConfig( + name=AmpAscoCapClassificationName.TIER_3, + direction=Direction.NEUTRAL, + strength=None, ), - Classification.TIER_4: AmpAscoCapConfig( - name=ClassificationName.TIER_4, direction=Direction.DISPUTES, strength=None + AmpAscoCapClassificationCode.TIER_4: AmpAscoCapConfig( + name=AmpAscoCapClassificationName.TIER_4, + direction=Direction.DISPUTES, + strength=None, ), } ) @@ -97,7 +100,7 @@ def validate_aac_statement(self) -> Self: """Validate AMP/ASCO/CAP statements""" def _validate_classification_config( - classification_code: Classification, + classification_code: AmpAscoCapClassificationCode, classification_name: str, direction: str, strength_code: MappableConcept | None, @@ -137,11 +140,11 @@ def _validate_classification_config( validate_mappable_concept( self.classification, System.AMP_ASCO_CAP, - valid_codes=AMP_ASCO_CAP_TIERS, + valid_codes=AMP_ASCO_CAP_CLASSIFICATION_CODES, mc_is_required=True, ) _validate_classification_config( - Classification(self.classification.primaryCoding.code.root), + AmpAscoCapClassificationCode(self.classification.primaryCoding.code.root), self.classification.name, self.direction, self.strength, diff --git a/src/ga4gh/va_spec/acmg_2015/models.py b/src/ga4gh/va_spec/acmg_2015/models.py index a3e0afa..362645d 100644 --- a/src/ga4gh/va_spec/acmg_2015/models.py +++ b/src/ga4gh/va_spec/acmg_2015/models.py @@ -16,8 +16,8 @@ ) from ga4gh.va_spec.base.enums import ( CLIN_GEN_CLASSIFICATIONS, + STRENGTH_CODES, STRENGTH_OF_EVIDENCE_PROVIDED_VALUES, - STRENGTHS, System, ) from ga4gh.va_spec.base.validators import ( @@ -157,7 +157,7 @@ def validate_strength(cls, v: MappableConcept | None) -> MappableConcept | None: :return: Validated strength value """ return validate_mappable_concept( - v, System.ACMG, valid_codes=STRENGTHS, mc_is_required=False + v, System.ACMG, valid_codes=STRENGTH_CODES, mc_is_required=False ) @field_validator("classification") diff --git a/src/ga4gh/va_spec/base/__init__.py b/src/ga4gh/va_spec/base/__init__.py index 85489ce..187260d 100644 --- a/src/ga4gh/va_spec/base/__init__.py +++ b/src/ga4gh/va_spec/base/__init__.py @@ -31,14 +31,14 @@ from .enums import ( CCV_CLASSIFICATIONS, CLIN_GEN_CLASSIFICATIONS, + STRENGTH_CODES, STRENGTH_OF_EVIDENCE_PROVIDED_VALUES, - STRENGTHS, CcvClassification, ClinGenClassification, DiagnosticPredicate, MembershipOperator, PrognosticPredicate, - Strength, + StrengthCode, StrengthOfEvidenceProvided, System, TherapeuticResponsePredicate, @@ -70,10 +70,10 @@ "Method", "PrognosticPredicate", "Proposition", - "STRENGTHS", + "STRENGTH_CODES", "STRENGTH_OF_EVIDENCE_PROVIDED_VALUES", "Statement", - "Strength", + "StrengthCode", "StrengthOfEvidenceProvided", "StudyGroup", "StudyResult", diff --git a/src/ga4gh/va_spec/base/enums.py b/src/ga4gh/va_spec/base/enums.py index 7615066..aa1413f 100644 --- a/src/ga4gh/va_spec/base/enums.py +++ b/src/ga4gh/va_spec/base/enums.py @@ -52,14 +52,17 @@ class StrengthOfEvidenceProvided(str, Enum): ] -class Strength(str, Enum): - """Define constrains for strength""" +class StrengthCode(str, Enum): + """Define constrains for strength + + Used in ACMG 2015 and CCV 2022 + """ DEFINITIVE = "definitive" LIKELY = "likely" -STRENGTHS = [v.value for v in Strength.__members__.values()] +STRENGTH_CODES = [v.value for v in StrengthCode.__members__.values()] class ClinGenClassification(str, Enum): diff --git a/src/ga4gh/va_spec/ccv_2022/models.py b/src/ga4gh/va_spec/ccv_2022/models.py index 59f0594..a0c20bd 100644 --- a/src/ga4gh/va_spec/ccv_2022/models.py +++ b/src/ga4gh/va_spec/ccv_2022/models.py @@ -16,8 +16,8 @@ ) from ga4gh.va_spec.base.enums import ( CCV_CLASSIFICATIONS, + STRENGTH_CODES, STRENGTH_OF_EVIDENCE_PROVIDED_VALUES, - STRENGTHS, System, ) from ga4gh.va_spec.base.validators import validate_mappable_concept @@ -130,7 +130,7 @@ def validate_strength(cls, v: MappableConcept | None) -> MappableConcept | None: :return: Validated strength value """ return validate_mappable_concept( - v, System.CCV, valid_codes=STRENGTHS, mc_is_required=False + v, System.CCV, valid_codes=STRENGTH_CODES, mc_is_required=False ) @field_validator("classification") From b873ae2be9c97dce4f3b491799e260795e24049d Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Mon, 27 Oct 2025 13:34:34 -0400 Subject: [PATCH 06/19] more rename --- src/ga4gh/va_spec/aac_2017/__init__.py | 4 ++-- src/ga4gh/va_spec/aac_2017/models.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ga4gh/va_spec/aac_2017/__init__.py b/src/ga4gh/va_spec/aac_2017/__init__.py index e227922..db13ca9 100644 --- a/src/ga4gh/va_spec/aac_2017/__init__.py +++ b/src/ga4gh/va_spec/aac_2017/__init__.py @@ -2,7 +2,7 @@ from .models import ( AMP_ASCO_CAP_CLASSIFICATION_CODES, - CLASSIFICATION_POLICY_MAP, + AMP_ASCO_CAP_CLASSIFICATION_MAP, AmpAscoCapClassificationCode, AmpAscoCapClassificationName, AsmpAscoCapStrengthCode, @@ -13,7 +13,7 @@ __all__ = [ "AMP_ASCO_CAP_CLASSIFICATION_CODES", - "CLASSIFICATION_POLICY_MAP", + "AMP_ASCO_CAP_CLASSIFICATION_MAP", "AmpAscoCapClassificationCode", "AmpAscoCapClassificationName", "AsmpAscoCapStrengthCode", diff --git a/src/ga4gh/va_spec/aac_2017/models.py b/src/ga4gh/va_spec/aac_2017/models.py index fa9dd05..97701eb 100644 --- a/src/ga4gh/va_spec/aac_2017/models.py +++ b/src/ga4gh/va_spec/aac_2017/models.py @@ -66,7 +66,7 @@ class AmpAscoCapConfig: strength: AsmpAscoCapStrengthCode | None -CLASSIFICATION_POLICY_MAP = MappingProxyType( +AMP_ASCO_CAP_CLASSIFICATION_MAP = MappingProxyType( { AmpAscoCapClassificationCode.TIER_1: AmpAscoCapConfig( name=AmpAscoCapClassificationName.TIER_1, @@ -106,7 +106,7 @@ def _validate_classification_config( strength_code: MappableConcept | None, ) -> None: """Validate that classificati""" - expected_config = CLASSIFICATION_POLICY_MAP[classification_code] + expected_config = AMP_ASCO_CAP_CLASSIFICATION_MAP[classification_code] actual_strength = ( strength_code.primaryCoding.code.root if strength_code From e64fbf8a2a1d9c77ed1460e89f10db3e455988a8 Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Wed, 29 Oct 2025 07:41:07 -0400 Subject: [PATCH 07/19] apply system + evidence line strength updates --- src/ga4gh/va_spec/aac_2017/__init__.py | 4 ++++ src/ga4gh/va_spec/aac_2017/models.py | 21 +++++++++++++++++++++ src/ga4gh/va_spec/base/enums.py | 2 +- submodules/va_spec | 2 +- tests/validation/test_va_spec_models.py | 23 +++++++++++++++-------- 5 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/ga4gh/va_spec/aac_2017/__init__.py b/src/ga4gh/va_spec/aac_2017/__init__.py index db13ca9..376df5a 100644 --- a/src/ga4gh/va_spec/aac_2017/__init__.py +++ b/src/ga4gh/va_spec/aac_2017/__init__.py @@ -3,8 +3,10 @@ from .models import ( AMP_ASCO_CAP_CLASSIFICATION_CODES, AMP_ASCO_CAP_CLASSIFICATION_MAP, + AMP_ASCO_CAP_EVIDENCE_LINE_STRENGTHS, AmpAscoCapClassificationCode, AmpAscoCapClassificationName, + AmpAscoCapEvidenceLineStrength, AsmpAscoCapStrengthCode, VariantDiagnosticStatement, VariantPrognosticStatement, @@ -14,8 +16,10 @@ __all__ = [ "AMP_ASCO_CAP_CLASSIFICATION_CODES", "AMP_ASCO_CAP_CLASSIFICATION_MAP", + "AMP_ASCO_CAP_EVIDENCE_LINE_STRENGTHS", "AmpAscoCapClassificationCode", "AmpAscoCapClassificationName", + "AmpAscoCapEvidenceLineStrength", "AsmpAscoCapStrengthCode", "VariantDiagnosticStatement", "VariantPrognosticStatement", diff --git a/src/ga4gh/va_spec/aac_2017/models.py b/src/ga4gh/va_spec/aac_2017/models.py index 97701eb..b18e138 100644 --- a/src/ga4gh/va_spec/aac_2017/models.py +++ b/src/ga4gh/va_spec/aac_2017/models.py @@ -57,6 +57,20 @@ class AmpAscoCapClassificationName(str, Enum): TIER_4 = "Tier IV" +class AmpAscoCapEvidenceLineStrength(str, Enum): + """Define constraints for AMP/ASCO/CAP `EvidenceLine.strengthOfEvidenceProvided`""" + + A = "A" + B = "B" + C = "C" + D = "D" + + +AMP_ASCO_CAP_EVIDENCE_LINE_STRENGTHS = [ + v.value for v in AmpAscoCapEvidenceLineStrength.__members__.values() +] + + @dataclass class AmpAscoCapConfig: """AMP/ASCO/CAP config for expected values""" @@ -163,6 +177,13 @@ def _validate_classification_config( msg = "`targetProposition` must be one of: `VariantDiagnosticProposition`, `VariantPrognosticProposition`, `VariantTherapeuticResponseProposition`" raise ValueError(msg) + validate_mappable_concept( + evidence_line.strengthOfEvidenceProvided, + System.AMP_ASCO_CAP, + valid_codes=AMP_ASCO_CAP_EVIDENCE_LINE_STRENGTHS, + mc_is_required=False, + ) + return self diff --git a/src/ga4gh/va_spec/base/enums.py b/src/ga4gh/va_spec/base/enums.py index aa1413f..5f3f684 100644 --- a/src/ga4gh/va_spec/base/enums.py +++ b/src/ga4gh/va_spec/base/enums.py @@ -95,6 +95,6 @@ class System(str, Enum): """Define constraints for systems""" ACMG = "ACMG Guidelines, 2015" - AMP_ASCO_CAP = "AMP/ASCO/CAP (AAC) Guidelines, 2017" + AMP_ASCO_CAP = "AMP/ASCO/CAP Guidelines, 2017" CLIN_GEN = "ClinGen Low Penetrance and Risk Allele Recommendations, 2024" CCV = "ClinGen/CGC/VICC Guidelines for Oncogenicity, 2022" diff --git a/submodules/va_spec b/submodules/va_spec index f2379c7..27f76a2 160000 --- a/submodules/va_spec +++ b/submodules/va_spec @@ -1 +1 @@ -Subproject commit f2379c75dc1af9eb06cdd60aa001266f3e2872d7 +Subproject commit 27f76a2da27bbfd36e9d617c3996317ebecfa94f diff --git a/tests/validation/test_va_spec_models.py b/tests/validation/test_va_spec_models.py index 1a111f3..2c4461a 100644 --- a/tests/validation/test_va_spec_models.py +++ b/tests/validation/test_va_spec_models.py @@ -239,13 +239,13 @@ def test_evidence_line(caf): }, "strength": { "primaryCoding": { - "system": "AMP/ASCO/CAP (AAC) Guidelines, 2017", + "system": "AMP/ASCO/CAP Guidelines, 2017", "code": "strong", } }, "classification": { "primaryCoding": { - "system": "AMP/ASCO/CAP (AAC) Guidelines, 2017", + "system": "AMP/ASCO/CAP Guidelines, 2017", "code": "tier 1", } }, @@ -352,7 +352,7 @@ def test_variant_pathogenicity_stmt(): invalid_params = deepcopy(params) invalid_params["classification"]["primaryCoding"]["system"] = ( - "AMP/ASCO/CAP (AAC) Guidelines, 2017" + "AMP/ASCO/CAP Guidelines, 2017" ) with pytest.raises(ValueError, match="`primaryCoding.system` must be one of"): VariantPathogenicityStatement(**invalid_params) @@ -459,7 +459,7 @@ def test_variant_pathogenicity_el(): invalid_params = deepcopy(params) invalid_params["strengthOfEvidenceProvided"] = { "primaryCoding": { - "system": "AMP/ASCO/CAP (AAC) Guidelines, 2017", + "system": "AMP/ASCO/CAP Guidelines, 2017", "code": "strong", } } @@ -624,7 +624,7 @@ def test_aac_statement(): "strength": { "primaryCoding": { "code": "strong", - "system": "AMP/ASCO/CAP (AAC) Guidelines, 2017", + "system": "AMP/ASCO/CAP Guidelines, 2017", } }, "specifiedBy": "documents.json#/1", @@ -632,7 +632,7 @@ def test_aac_statement(): "name": "Tier I", "primaryCoding": { "code": "tier 1", - "system": "AMP/ASCO/CAP (AAC) Guidelines, 2017", + "system": "AMP/ASCO/CAP Guidelines, 2017", }, }, "hasEvidenceLines": [ @@ -640,8 +640,8 @@ def test_aac_statement(): "directionOfEvidenceProvided": "supports", "strengthOfEvidenceProvided": { "primaryCoding": { - "code": "Level A", - "system": "AMP/ASCO/CAP (AAC) Guidelines, 2017", + "code": "A", + "system": "AMP/ASCO/CAP Guidelines, 2017", } }, "hasEvidenceItems": [ @@ -669,6 +669,13 @@ def test_aac_statement(): } assert VariantTherapeuticResponseStatement(**params) + # No strengthOfEvidenceProvided + no_evidence_line_strength_params = deepcopy(params) + no_evidence_line_strength_params["hasEvidenceLines"][0].pop( + "strengthOfEvidenceProvided" + ) + assert VariantTherapeuticResponseStatement(**no_evidence_line_strength_params) + # Invalid strength invalid_params = deepcopy(params) invalid_params["strength"]["primaryCoding"]["code"] = "Strong" From 1c56c17be880dd3780f63859d898fb7f7128edb7 Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Fri, 28 Nov 2025 08:19:01 -0500 Subject: [PATCH 08/19] apply VariantClinicalSignificanceStatement + evidence line changes --- .gitmodules | 2 +- src/ga4gh/va_spec/aac_2017/__init__.py | 16 +- src/ga4gh/va_spec/aac_2017/models.py | 216 ++++++++++++------------ src/ga4gh/va_spec/base/core.py | 4 +- submodules/va_spec | 2 +- tests/validation/test_va_spec_models.py | 40 ++--- 6 files changed, 139 insertions(+), 141 deletions(-) diff --git a/.gitmodules b/.gitmodules index 64cd7b9..c464d0b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "submodules/va_spec"] path = submodules/va_spec url = https://github.com/ga4gh/va-spec - branch = issue-293 + branch = fix-aac diff --git a/src/ga4gh/va_spec/aac_2017/__init__.py b/src/ga4gh/va_spec/aac_2017/__init__.py index 376df5a..0f0f8f2 100644 --- a/src/ga4gh/va_spec/aac_2017/__init__.py +++ b/src/ga4gh/va_spec/aac_2017/__init__.py @@ -6,11 +6,13 @@ AMP_ASCO_CAP_EVIDENCE_LINE_STRENGTHS, AmpAscoCapClassificationCode, AmpAscoCapClassificationName, + AmpAscoCapEvidenceLine, AmpAscoCapEvidenceLineStrength, AsmpAscoCapStrengthCode, - VariantDiagnosticStatement, - VariantPrognosticStatement, - VariantTherapeuticResponseStatement, + DiagnosticEvidenceLine, + PrognosticEvidenceLine, + TherapeuticEvidenceLine, + VariantClinicalSignificanceStatement, ) __all__ = [ @@ -19,9 +21,11 @@ "AMP_ASCO_CAP_EVIDENCE_LINE_STRENGTHS", "AmpAscoCapClassificationCode", "AmpAscoCapClassificationName", + "AmpAscoCapEvidenceLine", "AmpAscoCapEvidenceLineStrength", "AsmpAscoCapStrengthCode", - "VariantDiagnosticStatement", - "VariantPrognosticStatement", - "VariantTherapeuticResponseStatement", + "DiagnosticEvidenceLine", + "PrognosticEvidenceLine", + "TherapeuticEvidenceLine", + "VariantClinicalSignificanceStatement", ] diff --git a/src/ga4gh/va_spec/aac_2017/models.py b/src/ga4gh/va_spec/aac_2017/models.py index b18e138..f9cf6a2 100644 --- a/src/ga4gh/va_spec/aac_2017/models.py +++ b/src/ga4gh/va_spec/aac_2017/models.py @@ -4,11 +4,10 @@ sequence variants in cancer. """ -from abc import ABC from enum import Enum from types import MappingProxyType -from pydantic import Field, model_validator +from pydantic import Field, field_validator, model_validator from pydantic.dataclasses import dataclass from typing_extensions import Self @@ -27,6 +26,57 @@ from ga4gh.va_spec.base.validators import validate_mappable_concept +class AmpAscoCapEvidenceLineStrength(str, Enum): + """Define constraints for AMP/ASCO/CAP `EvidenceLine.strengthOfEvidenceProvided`""" + + A = "A" + B = "B" + C = "C" + D = "D" + + +class AmpAscoCapEvidenceLine(EvidenceLine): + """Evidence line for AMP/ASCO/CAP""" + + targetProposition: ( + VariantDiagnosticProposition + | VariantPrognosticProposition + | VariantTherapeuticResponseProposition + ) + + @field_validator("strengthOfEvidenceProvided", mode="after") + @classmethod + def validate_strength_of_evidence_provided( + cls, v: MappableConcept | None + ) -> MappableConcept | None: + """Validate strengthOfEvidenceProvided""" + validate_mappable_concept( + v, + System.AMP_ASCO_CAP, + valid_codes=AMP_ASCO_CAP_EVIDENCE_LINE_STRENGTHS, + mc_is_required=False, + ) + return v + + +class DiagnosticEvidenceLine(AmpAscoCapEvidenceLine): + """Diagnostic evidence line for AMP/ASCO/CAP""" + + targetProposition: VariantDiagnosticProposition + + +class PrognosticEvidenceLine(AmpAscoCapEvidenceLine): + """Prognostic evidence line for AMP/ASCO/CAP""" + + targetProposition: VariantPrognosticProposition + + +class TherapeuticEvidenceLine(AmpAscoCapEvidenceLine): + """Therapeutic evidence line for AMP/ASCO/CAP""" + + targetProposition: VariantTherapeuticResponseProposition + + class AsmpAscoCapStrengthCode(str, Enum): """Define constraints for AMP/ASCO/CAP strength coding""" @@ -37,10 +87,10 @@ class AsmpAscoCapStrengthCode(str, Enum): class AmpAscoCapClassificationCode(str, Enum): """Define constraints for AMP/ASCO/CAP classification coding""" - TIER_1 = "tier 1" - TIER_2 = "tier 2" - TIER_3 = "tier 3" - TIER_4 = "tier 4" + TIER_1 = "tier i" + TIER_2 = "tier ii" + TIER_3 = "tier iii" + TIER_4 = "tier iv" AMP_ASCO_CAP_CLASSIFICATION_CODES = [ @@ -57,15 +107,6 @@ class AmpAscoCapClassificationName(str, Enum): TIER_4 = "Tier IV" -class AmpAscoCapEvidenceLineStrength(str, Enum): - """Define constraints for AMP/ASCO/CAP `EvidenceLine.strengthOfEvidenceProvided`""" - - A = "A" - B = "B" - C = "C" - D = "D" - - AMP_ASCO_CAP_EVIDENCE_LINE_STRENGTHS = [ v.value for v in AmpAscoCapEvidenceLineStrength.__members__.values() ] @@ -106,20 +147,39 @@ class AmpAscoCapConfig: ) -class _AmpAscoCapStatement(Statement, ABC): - """Abstract base class for AAC 2017 statements""" +class VariantClinicalSignificanceStatement(Statement): + """A statement reporting a conclusion from a single study about whether a variant is + associated with a disease (a diagnostic inclusion criterion), or absence of a + disease (diagnostic exclusion criterion) - based on interpretation of the study's + results. + """ + + proposition: VariantClinicalSignificanceProposition = Field( + ..., + description="A proposition about a diagnostic association between a variant and condition, for which the study provides evidence. The validity of this proposition, and the level of confidence/evidence supporting it, may be assessed and reported by the Statement.", + ) + strength: MappableConcept | None = Field( + default=None, + description="The strength of support that the Statement is determined to provide for or against the Diagnostic Proposition for the assessed variant, based on the curation and reporting conventions of the AMP/ASCO/CAP 2017 Guidelines.", + ) + classification: MappableConcept = Field( + ..., + description="A single term or phrase classifying the subject variant based on the outcome of direction and strength assessments of the Statement's Proposition, using terms from the AMP/ASCO/CAP 2017 Guidelines.", + ) + specifiedBy: Method | iriReference @model_validator(mode="after") def validate_aac_statement(self) -> Self: - """Validate AMP/ASCO/CAP statements""" + """Validate VariantClinicalSignificanceStatement""" def _validate_classification_config( classification_code: AmpAscoCapClassificationCode, classification_name: str, direction: str, strength_code: MappableConcept | None, + has_evidence_lines: list, ) -> None: - """Validate that classificati""" + """Validate that classification config is correct""" expected_config = AMP_ASCO_CAP_CLASSIFICATION_MAP[classification_code] actual_strength = ( strength_code.primaryCoding.code.root @@ -143,6 +203,30 @@ def _validate_classification_config( msg = f"`direction` must be: {expected_config.direction.value}" raise ValueError(msg) + if classification_code in { + AmpAscoCapClassificationCode.TIER_1, + AmpAscoCapClassificationCode.TIER_2, + }: + for evidence_line in has_evidence_lines: + found_approved_el_clas = False + for approved_el_cls in [ + DiagnosticEvidenceLine, + PrognosticEvidenceLine, + TherapeuticEvidenceLine, + iriReference, + ]: + try: + approved_el_cls(**evidence_line.model_dump()) + except Exception: # noqa: S110 + pass + else: + found_approved_el_clas = True + break + + if not found_approved_el_clas: + msg = "`hasEvidenceLines` must be one of: `DiagnosticEvidenceLine`, `PrognosticEvidenceLine`, `TherapeuticEvidenceLine`, or `iriReference`" + raise ValueError(msg) + # Validate strength validate_mappable_concept( self.strength, @@ -162,99 +246,7 @@ def _validate_classification_config( self.classification.name, self.direction, self.strength, + self.hasEvidenceLines or [], ) - # Validate hasEvidenceLines - for evidence_line in self.hasEvidenceLines or []: - if isinstance(evidence_line, EvidenceLine): - target_proposition = evidence_line.targetProposition - if target_proposition and not isinstance( - target_proposition, - VariantDiagnosticProposition - | VariantPrognosticProposition - | VariantTherapeuticResponseProposition, - ): - msg = "`targetProposition` must be one of: `VariantDiagnosticProposition`, `VariantPrognosticProposition`, `VariantTherapeuticResponseProposition`" - raise ValueError(msg) - - validate_mappable_concept( - evidence_line.strengthOfEvidenceProvided, - System.AMP_ASCO_CAP, - valid_codes=AMP_ASCO_CAP_EVIDENCE_LINE_STRENGTHS, - mc_is_required=False, - ) - return self - - -class VariantDiagnosticStatement(_AmpAscoCapStatement): - """A statement reporting a conclusion from a single study about whether a variant is - associated with a disease (a diagnostic inclusion criterion), or absence of a - disease (diagnostic exclusion criterion) - based on interpretation of the study's - results. - """ - - proposition: VariantClinicalSignificanceProposition = Field( - ..., - description="A proposition about a diagnostic association between a variant and condition, for which the study provides evidence. The validity of this proposition, and the level of confidence/evidence supporting it, may be assessed and reported by the Statement.", - ) - strength: MappableConcept | None = Field( - default=None, - description="The strength of support that the Statement is determined to provide for or against the Diagnostic Proposition for the assessed variant, based on the curation and reporting conventions of the AMP/ASCO/CAP (AAC) 2017 Guidelines.", - ) - classification: MappableConcept = Field( - ..., - description="A single term or phrase classifying the subject variant based on the outcome of direction and strength assessments of the Statement's Proposition - reported here using terms from the AMP/ASCO/CAP (AAC) 2017 Guidelines.", - ) - specifiedBy: Method | iriReference = Field( - ..., - description="A method that specifies how the diagnostic classification was ultimately assigned to the variant, based on assessment of evidence.", - ) - - -class VariantPrognosticStatement(_AmpAscoCapStatement): - """A statement reporting a conclusion from a single study about whether a variant is - associated with a disease prognosis - based on interpretation of the study's - results. - """ - - proposition: VariantClinicalSignificanceProposition = Field( - ..., - description="A proposition about a prognostic association between a variant and condition, for which the study provides evidence. The validity of this proposition, and the level of confidence/evidence supporting it, may be assessed and reported by the Statement.", - ) - strength: MappableConcept | None = Field( - default=None, - description="The strength of support that the Statement is determined to provide for or against the Prognostic Proposition for the assessed variant, based on the curation and reporting conventions of the AMP/ASCO/CAP (AAC) 2017 Guidelines.", - ) - classification: MappableConcept = Field( - ..., - description="A single term or phrase classifying the subject variant based on the outcome of direction and strength assessments of the Statement's Proposition - reported here using terms from the AMP/ASCO/CAP (AAC) 2017 Guidelines. Note that the enumerated value set here is bound to the `code` field of the Coding object that is nested inside a MappableConcept's primary coding.", - ) - specifiedBy: Method | iriReference = Field( - ..., - description="A method that specifies how the prognostic classification was ultimately assigned to the variant, based on assessment of evidence.", - ) - - -class VariantTherapeuticResponseStatement(_AmpAscoCapStatement): - """A statement reporting a conclusion from a single study about whether a variant is - associated with a therapeutic response (positive or negative) - based on - interpretation of the study's results. - """ - - proposition: VariantClinicalSignificanceProposition = Field( - ..., - description="A proposition about the therapeutic response associated with a variant, for which the study provides evidence. The validity of this proposition, and the level of confidence/evidence supporting it, may be assessed and reported by the Statement.", - ) - strength: MappableConcept | None = Field( - default=None, - description="The strength of support that the Statement is determined to provide for or against the Therapeutic Response Proposition for the assessed variant, based on the curation and reporting conventions of the AMP/ASCO/CAP (AAC) 2017 Guidelines.", - ) - classification: MappableConcept = Field( - ..., - description="A single term or phrase classifying the subject variant based on the outcome of direction and strength assessments of the Statement's Proposition - reported here using terms from the AMP/ASCO/CAP (AAC) 2017 Guidelines.", - ) - specifiedBy: Method | iriReference = Field( - ..., - description="A method that specifies how the therapeutic response classification was ultimately assigned to the variant, based on assessment of evidence.", - ) diff --git a/src/ga4gh/va_spec/base/core.py b/src/ga4gh/va_spec/base/core.py index 3062bb7..3b00ad3 100644 --- a/src/ga4gh/va_spec/base/core.py +++ b/src/ga4gh/va_spec/base/core.py @@ -392,7 +392,7 @@ class ClinicalVariantProposition(_SubjectVariantPropositionBase): ) alleleOriginQualifier: MappableConcept | iriReference | None = Field( default=None, - description="Reports whether the Proposition should be interpreted in the context of a heritable 'germline' variant, an acquired 'somatic' variant in a tumor, post-zygotic 'mosaic' variant. While these are the most commonly reported allele origins, other more nuanced concepts can be captured (e.g. 'maternal' vs 'paternal' allele origin'). In practice, populating this field may be complicated by the fact that some sources report allele origin based on the type of tissue that was sequenced to identify the variant, and others use it more generally to specify a category of variant for which the proposition holds. The stated intent of this attribute is the latter. However, if an implementer is not sure about which is reported in their data, it may be safer to create an Extension to hold this information, where they can explicitly acknowledge this ambiguity.", + description='Reports whether the Proposition should be interpreted in the context of a heritable "germline" variant, an acquired "somatic" variant in a tumor, or a post-zygotic "mosaic" variant. While these are the most commonly reported allele origins, other more nuanced concepts can be captured (e.g. "maternal" vs "paternal" allele origin). In practice, populating this field may be complicated by the fact that some sources report allele origin based on the type of tissue that was sequenced to identify the variant, and others use it more generally to specify a category of variant for which the proposition holds. The stated intent of this attribute is the latter. However, if an implementer is not sure about which is reported in their data, it may be safer to create an Extension to hold this information, where they can explicitly acknowledge this ambiguity.', ) @@ -438,7 +438,7 @@ class VariantClinicalSignificanceProposition( description="The predicate associating the subject variant to clinical significance for the object Condition. MUST be 'hasClinicalSignificanceFor'.", ) objectCondition: Condition | iriReference = Field( - ..., description="The disease that is evaluated." + ..., description="The condition that is evaluated." ) diff --git a/submodules/va_spec b/submodules/va_spec index 27f76a2..e9c8117 160000 --- a/submodules/va_spec +++ b/submodules/va_spec @@ -1 +1 @@ -Subproject commit 27f76a2da27bbfd36e9d617c3996317ebecfa94f +Subproject commit e9c81175a88bb7064756093a7e9b663f1f0aca07 diff --git a/tests/validation/test_va_spec_models.py b/tests/validation/test_va_spec_models.py index 2c4461a..4fa7e85 100644 --- a/tests/validation/test_va_spec_models.py +++ b/tests/validation/test_va_spec_models.py @@ -10,7 +10,7 @@ from ga4gh.core.models import Coding, MappableConcept, code, iriReference from ga4gh.va_spec import acmg_2015, base, ccv_2022 -from ga4gh.va_spec.aac_2017.models import VariantTherapeuticResponseStatement +from ga4gh.va_spec.aac_2017.models import VariantClinicalSignificanceStatement from ga4gh.va_spec.acmg_2015.models import ( VariantPathogenicityEvidenceLine, VariantPathogenicityStatement, @@ -246,7 +246,7 @@ def test_evidence_line(caf): "classification": { "primaryCoding": { "system": "AMP/ASCO/CAP Guidelines, 2017", - "code": "tier 1", + "code": "tier i", } }, "specifiedBy": { @@ -613,6 +613,12 @@ def test_variant_onco_el(): def test_aac_statement(): """Test that AMP/ASCO/CAP statement model validators work correctly""" + prop = { + "type": "VariantDiagnosticProposition", + "predicate": "isDiagnosticExclusionCriterionFor", + "objectCondition": "conditions.json#/1", + "subjectVariant": "alleles.json#/1", + } params = { "direction": "supports", "proposition": { @@ -631,12 +637,13 @@ def test_aac_statement(): "classification": { "name": "Tier I", "primaryCoding": { - "code": "tier 1", + "code": "tier i", "system": "AMP/ASCO/CAP Guidelines, 2017", }, }, "hasEvidenceLines": [ { + "targetProposition": prop, "directionOfEvidenceProvided": "supports", "strengthOfEvidenceProvided": { "primaryCoding": { @@ -649,12 +656,7 @@ def test_aac_statement(): { "type": "Statement", "direction": "supports", - "proposition": { - "type": "VariantDiagnosticProposition", - "predicate": "isDiagnosticExclusionCriterionFor", - "objectCondition": "conditions.json#/1", - "subjectVariant": "alleles.json#/1", - }, + "proposition": prop, "strength": { "primaryCoding": { "code": "A", @@ -667,50 +669,50 @@ def test_aac_statement(): }, ], } - assert VariantTherapeuticResponseStatement(**params) + assert VariantClinicalSignificanceStatement(**params) # No strengthOfEvidenceProvided no_evidence_line_strength_params = deepcopy(params) no_evidence_line_strength_params["hasEvidenceLines"][0].pop( "strengthOfEvidenceProvided" ) - assert VariantTherapeuticResponseStatement(**no_evidence_line_strength_params) + assert VariantClinicalSignificanceStatement(**no_evidence_line_strength_params) # Invalid strength invalid_params = deepcopy(params) invalid_params["strength"]["primaryCoding"]["code"] = "Strong" with pytest.raises(ValidationError, match="`strength` must be: strong"): - VariantTherapeuticResponseStatement(**invalid_params) + VariantClinicalSignificanceStatement(**invalid_params) invalid_params = deepcopy(params) invalid_params["strength"]["primaryCoding"]["code"] = "potential" with pytest.raises(ValidationError, match="`strength` must be: strong"): - VariantTherapeuticResponseStatement(**invalid_params) + VariantClinicalSignificanceStatement(**invalid_params) # Invalid classification invalid_params = deepcopy(params) invalid_params["classification"]["primaryCoding"]["code"] = "Tier I" with pytest.raises(ValidationError, match="`primaryCoding.code` must be one of"): - VariantTherapeuticResponseStatement(**invalid_params) + VariantClinicalSignificanceStatement(**invalid_params) invalid_params = deepcopy(params) - invalid_params["classification"]["name"] = "tier 1" + invalid_params["classification"]["name"] = "tier i" with pytest.raises(ValidationError, match="`classification.name` must be: Tier I"): - VariantTherapeuticResponseStatement(**invalid_params) + VariantClinicalSignificanceStatement(**invalid_params) # Invalid direction invalid_params = deepcopy(params) invalid_params["direction"] = "disputes" with pytest.raises(ValidationError, match="`direction` must be: supports"): - VariantTherapeuticResponseStatement(**invalid_params) + VariantClinicalSignificanceStatement(**invalid_params) # Invalid targetProposition invalid_params = deepcopy(params) invalid_params["hasEvidenceLines"][0]["targetProposition"] = invalid_params[ "proposition" ] - with pytest.raises(ValidationError, match="`targetProposition` must be one of"): - VariantTherapeuticResponseStatement(**invalid_params) + with pytest.raises(ValidationError, match="`hasEvidenceLines` must be one of"): + VariantClinicalSignificanceStatement(**invalid_params) def test_examples(test_definitions): From 87d00242c653f3ff91c80d427e75f493953356bf Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Mon, 16 Feb 2026 10:03:35 -0500 Subject: [PATCH 09/19] fixes --- .gitmodules | 2 +- src/ga4gh/va_spec/base/core.py | 4 ++++ submodules/va_spec | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index c464d0b..b1735c4 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "submodules/va_spec"] path = submodules/va_spec url = https://github.com/ga4gh/va-spec - branch = fix-aac + branch = v1 diff --git a/src/ga4gh/va_spec/base/core.py b/src/ga4gh/va_spec/base/core.py index ba54151..1f9c622 100644 --- a/src/ga4gh/va_spec/base/core.py +++ b/src/ga4gh/va_spec/base/core.py @@ -719,3 +719,7 @@ class Statement(InformationEntity, BaseModelForbidExtra): default=None, description="An evidence-based argument that supports or disputes the validity of the proposition that a Statement assesses or puts forth as true. The strength and direction of this argument (whether it supports or disputes the proposition, and how strongly) is based on an interpretation of one or more pieces of information as evidence (i.e. 'Evidence Items).", ) + + +Statement.model_rebuild() +EvidenceLine.model_rebuild() diff --git a/submodules/va_spec b/submodules/va_spec index e9c8117..7caecd4 160000 --- a/submodules/va_spec +++ b/submodules/va_spec @@ -1 +1 @@ -Subproject commit e9c81175a88bb7064756093a7e9b663f1f0aca07 +Subproject commit 7caecd474864b0e4bcb7764b545c0fa352ee8798 From 8225abefe9451c21afd1de4701fa4035760d54c7 Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Mon, 16 Feb 2026 12:18:04 -0500 Subject: [PATCH 10/19] fix copy/paste --- src/ga4gh/va_spec/base/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ga4gh/va_spec/base/core.py b/src/ga4gh/va_spec/base/core.py index 1f9c622..3a4b402 100644 --- a/src/ga4gh/va_spec/base/core.py +++ b/src/ga4gh/va_spec/base/core.py @@ -373,7 +373,7 @@ class SubjectVariantProposition(RootModel): | VariantPrognosticProposition | VariantOncogenicityProposition | VariantTherapeuticResponseProposition - | VariantTherapeuticResponseProposition + | VariantClinicalSignificanceProposition ) = Field(discriminator="type") From e558904722e93f524503265368325e70173b37af Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Tue, 5 May 2026 11:37:08 -0400 Subject: [PATCH 11/19] wip: update deps + submodule --- .gitmodules | 2 +- pyproject.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitmodules b/.gitmodules index b1735c4..0936f3a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "submodules/va_spec"] path = submodules/va_spec url = https://github.com/ga4gh/va-spec - branch = v1 + branch = update-snapshot diff --git a/pyproject.toml b/pyproject.toml index 18a1e88..3cf2c43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,8 +31,8 @@ keywords = [ requires-python = ">=3.10" dynamic = ["version"] dependencies = [ - "ga4gh.vrs>=2.2.0,<3.0", - "ga4gh.cat_vrs~=0.7.1", + "ga4gh.vrs~=2.4.0-a0", + "ga4gh.cat_vrs~=0.8.0-a0", "pydantic>=2.0,<3.0", "typing_extensions", ] From 93d5f9903a747aa254f07a3d77f5ab96a4e6835d Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Tue, 5 May 2026 11:37:34 -0400 Subject: [PATCH 12/19] update submodule --- submodules/va_spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/va_spec b/submodules/va_spec index 7caecd4..5a4cdd2 160000 --- a/submodules/va_spec +++ b/submodules/va_spec @@ -1 +1 @@ -Subproject commit 7caecd474864b0e4bcb7764b545c0fa352ee8798 +Subproject commit 5a4cdd267b2e5048b203f378931b92cde96058ae From 97e68ffde911099515412fb0e68390550f1474e1 Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Tue, 5 May 2026 20:18:05 -0400 Subject: [PATCH 13/19] fixes/updates/cleanup --- src/ga4gh/va_spec/aac_2017/__init__.py | 4 +- src/ga4gh/va_spec/aac_2017/models.py | 125 +++++++++++++----------- src/ga4gh/va_spec/base/core.py | 8 +- tests/validation/test_va_spec_models.py | 7 +- 4 files changed, 78 insertions(+), 66 deletions(-) diff --git a/src/ga4gh/va_spec/aac_2017/__init__.py b/src/ga4gh/va_spec/aac_2017/__init__.py index 0f0f8f2..1cd469f 100644 --- a/src/ga4gh/va_spec/aac_2017/__init__.py +++ b/src/ga4gh/va_spec/aac_2017/__init__.py @@ -8,7 +8,7 @@ AmpAscoCapClassificationName, AmpAscoCapEvidenceLine, AmpAscoCapEvidenceLineStrength, - AsmpAscoCapStrengthCode, + AmpAscoCapStrengthCode, DiagnosticEvidenceLine, PrognosticEvidenceLine, TherapeuticEvidenceLine, @@ -23,7 +23,7 @@ "AmpAscoCapClassificationName", "AmpAscoCapEvidenceLine", "AmpAscoCapEvidenceLineStrength", - "AsmpAscoCapStrengthCode", + "AmpAscoCapStrengthCode", "DiagnosticEvidenceLine", "PrognosticEvidenceLine", "TherapeuticEvidenceLine", diff --git a/src/ga4gh/va_spec/aac_2017/models.py b/src/ga4gh/va_spec/aac_2017/models.py index f9cf6a2..d5c07a1 100644 --- a/src/ga4gh/va_spec/aac_2017/models.py +++ b/src/ga4gh/va_spec/aac_2017/models.py @@ -7,11 +7,11 @@ from enum import Enum from types import MappingProxyType -from pydantic import Field, field_validator, model_validator +from pydantic import Field, RootModel, field_validator, model_validator from pydantic.dataclasses import dataclass from typing_extensions import Self -from ga4gh.core.models import MappableConcept, iriReference +from ga4gh.core.models import BaseModelForbidExtra, MappableConcept, iriReference from ga4gh.va_spec.base.core import ( Direction, EvidenceLine, @@ -35,12 +35,17 @@ class AmpAscoCapEvidenceLineStrength(str, Enum): D = "D" +AMP_ASCO_CAP_EVIDENCE_LINE_STRENGTHS = [ + v.value for v in AmpAscoCapEvidenceLineStrength.__members__.values() +] + + class AmpAscoCapEvidenceLine(EvidenceLine): """Evidence line for AMP/ASCO/CAP""" targetProposition: ( - VariantDiagnosticProposition - | VariantPrognosticProposition + VariantPrognosticProposition + | VariantDiagnosticProposition | VariantTherapeuticResponseProposition ) @@ -59,25 +64,37 @@ def validate_strength_of_evidence_provided( return v -class DiagnosticEvidenceLine(AmpAscoCapEvidenceLine): - """Diagnostic evidence line for AMP/ASCO/CAP""" +class _PrognosticEvidenceLineObject(AmpAscoCapEvidenceLine): + """Internal prognostic evidence line for AMP/ASCO/CAP""" - targetProposition: VariantDiagnosticProposition + targetProposition: VariantPrognosticProposition -class PrognosticEvidenceLine(AmpAscoCapEvidenceLine): +class PrognosticEvidenceLine(RootModel[_PrognosticEvidenceLineObject | iriReference]): """Prognostic evidence line for AMP/ASCO/CAP""" - targetProposition: VariantPrognosticProposition +class _DiagnosticEvidenceLineObject(AmpAscoCapEvidenceLine): + """Internal diagnostic evidence line for AMP/ASCO/CAP""" -class TherapeuticEvidenceLine(AmpAscoCapEvidenceLine): - """Therapeutic evidence line for AMP/ASCO/CAP""" + targetProposition: VariantDiagnosticProposition + + +class DiagnosticEvidenceLine(RootModel[_DiagnosticEvidenceLineObject | iriReference]): + """Diagnostic evidence line for AMP/ASCO/CAP""" + + +class _TherapeuticEvidenceLineObject(AmpAscoCapEvidenceLine): + """Internal therapeutic evidence line for AMP/ASCO/CAP""" targetProposition: VariantTherapeuticResponseProposition -class AsmpAscoCapStrengthCode(str, Enum): +class TherapeuticEvidenceLine(RootModel[_TherapeuticEvidenceLineObject | iriReference]): + """Therapeutic evidence line for AMP/ASCO/CAP""" + + +class AmpAscoCapStrengthCode(str, Enum): """Define constraints for AMP/ASCO/CAP strength coding""" STRONG = "strong" @@ -107,18 +124,13 @@ class AmpAscoCapClassificationName(str, Enum): TIER_4 = "Tier IV" -AMP_ASCO_CAP_EVIDENCE_LINE_STRENGTHS = [ - v.value for v in AmpAscoCapEvidenceLineStrength.__members__.values() -] - - @dataclass class AmpAscoCapConfig: """AMP/ASCO/CAP config for expected values""" name: AmpAscoCapClassificationName direction: Direction - strength: AsmpAscoCapStrengthCode | None + strength: AmpAscoCapStrengthCode | None AMP_ASCO_CAP_CLASSIFICATION_MAP = MappingProxyType( @@ -126,12 +138,12 @@ class AmpAscoCapConfig: AmpAscoCapClassificationCode.TIER_1: AmpAscoCapConfig( name=AmpAscoCapClassificationName.TIER_1, direction=Direction.SUPPORTS, - strength=AsmpAscoCapStrengthCode.STRONG, + strength=AmpAscoCapStrengthCode.STRONG, ), AmpAscoCapClassificationCode.TIER_2: AmpAscoCapConfig( name=AmpAscoCapClassificationName.TIER_2, direction=Direction.SUPPORTS, - strength=AsmpAscoCapStrengthCode.POTENTIAL, + strength=AmpAscoCapStrengthCode.POTENTIAL, ), AmpAscoCapClassificationCode.TIER_3: AmpAscoCapConfig( name=AmpAscoCapClassificationName.TIER_3, @@ -147,20 +159,16 @@ class AmpAscoCapConfig: ) -class VariantClinicalSignificanceStatement(Statement): - """A statement reporting a conclusion from a single study about whether a variant is - associated with a disease (a diagnostic inclusion criterion), or absence of a - disease (diagnostic exclusion criterion) - based on interpretation of the study's - results. +class VariantClinicalSignificanceStatement(Statement, BaseModelForbidExtra): + """A statement reporting a conclusion from a single study about the clinical + significance of a variant with respect to a condition, based on interpretation of + the study's results. """ - proposition: VariantClinicalSignificanceProposition = Field( - ..., - description="A proposition about a diagnostic association between a variant and condition, for which the study provides evidence. The validity of this proposition, and the level of confidence/evidence supporting it, may be assessed and reported by the Statement.", - ) + proposition: VariantClinicalSignificanceProposition strength: MappableConcept | None = Field( default=None, - description="The strength of support that the Statement is determined to provide for or against the Diagnostic Proposition for the assessed variant, based on the curation and reporting conventions of the AMP/ASCO/CAP 2017 Guidelines.", + description="The strength of support that the Statement is determined to provide for or against the Variant Clinical Significance Proposition for the assessed variant, based on the curation and reporting conventions of the AMP/ASCO/CAP 2017 Guidelines.", ) classification: MappableConcept = Field( ..., @@ -172,35 +180,35 @@ class VariantClinicalSignificanceStatement(Statement): def validate_aac_statement(self) -> Self: """Validate VariantClinicalSignificanceStatement""" - def _validate_classification_config( + def _validate_amp_asco_cap_classification_constraints( classification_code: AmpAscoCapClassificationCode, - classification_name: str, + classification_name: str | None, direction: str, strength_code: MappableConcept | None, has_evidence_lines: list, ) -> None: - """Validate that classification config is correct""" - expected_config = AMP_ASCO_CAP_CLASSIFICATION_MAP[classification_code] + """Validate that a classification code enforces required values for + strength, name, direction, and when applicable allowed evidence line types. + """ + expected = AMP_ASCO_CAP_CLASSIFICATION_MAP[classification_code] actual_strength = ( strength_code.primaryCoding.code.root - if strength_code + if strength_code and strength_code.primaryCoding else strength_code ) - if actual_strength != expected_config.strength: + if actual_strength != expected.strength: expected_strength = ( - expected_config.strength.value - if expected_config.strength - else expected_config.strength + expected.strength.value if expected.strength else expected.strength ) msg = f"`strength` must be: {expected_strength}" raise ValueError(msg) - if classification_name != expected_config.name: - msg = f"`classification.name` must be: {expected_config.name.value}" + if classification_name != expected.name: + msg = f"`classification.name` must be: {expected.name.value}" raise ValueError(msg) - if direction != expected_config.direction: - msg = f"`direction` must be: {expected_config.direction.value}" + if direction != expected.direction: + msg = f"`direction` must be: {expected.direction.value}" raise ValueError(msg) if classification_code in { @@ -208,26 +216,29 @@ def _validate_classification_config( AmpAscoCapClassificationCode.TIER_2, }: for evidence_line in has_evidence_lines: - found_approved_el_clas = False - for approved_el_cls in [ + if hasattr(evidence_line, "root"): + el_input = evidence_line.root + elif hasattr(evidence_line, "model_dump"): + el_input = evidence_line.model_dump() + else: + el_input = evidence_line + + for approved_el_cls in ( DiagnosticEvidenceLine, PrognosticEvidenceLine, TherapeuticEvidenceLine, - iriReference, - ]: + ): try: - approved_el_cls(**evidence_line.model_dump()) - except Exception: # noqa: S110 - pass - else: - found_approved_el_clas = True + approved_el_cls.model_validate(el_input) break - - if not found_approved_el_clas: - msg = "`hasEvidenceLines` must be one of: `DiagnosticEvidenceLine`, `PrognosticEvidenceLine`, `TherapeuticEvidenceLine`, or `iriReference`" + except Exception: # noqa: S112 + continue + else: + msg = "`hasEvidenceLines` must be one of: `DiagnosticEvidenceLine`, `PrognosticEvidenceLine`, or `TherapeuticEvidenceLine`" raise ValueError(msg) - # Validate strength + # Validate strength system. The actual value will be validated in + # `_validate_amp_asco_cap_classification_constraints` validate_mappable_concept( self.strength, System.AMP_ASCO_CAP, @@ -241,7 +252,7 @@ def _validate_classification_config( valid_codes=AMP_ASCO_CAP_CLASSIFICATION_CODES, mc_is_required=True, ) - _validate_classification_config( + _validate_amp_asco_cap_classification_constraints( AmpAscoCapClassificationCode(self.classification.primaryCoding.code.root), self.classification.name, self.direction, diff --git a/src/ga4gh/va_spec/base/core.py b/src/ga4gh/va_spec/base/core.py index 3a4b402..1b5a29b 100644 --- a/src/ga4gh/va_spec/base/core.py +++ b/src/ga4gh/va_spec/base/core.py @@ -1,4 +1,4 @@ -"""VA Spec Base Core Models""" +"""VA Specification Core classes""" from __future__ import annotations @@ -523,8 +523,8 @@ class VariantPrognosticProposition(ClinicalVariantProposition, BaseModelForbidEx class VariantTherapeuticResponseProposition( ClinicalVariantProposition, BaseModelForbidExtra ): - """A Proposition about the role of a variant in modulating the response of a neoplasm to drug - administration or other therapeutic procedures. + """A Proposition about the role of a variant in modulating the response of a + neoplasm to drug administration or other therapeutic procedures. """ model_config = ConfigDict(use_enum_values=True) @@ -535,7 +535,7 @@ class VariantTherapeuticResponseProposition( ) predicate: TherapeuticResponsePredicate = Field( ..., - description="The relationship the Proposition describes between the subject variant and object theapeutic. MUST be one of 'predictsSensitivityTo' or 'predictsResistanceTo'.", + description='The relationship the Proposition describes between the subject variant and object therapeutic. MUST be one of "predictsSensitivityTo" or "predictsResistanceTo".', ) objectTherapeutic: Therapeutic | iriReference = Field( ..., diff --git a/tests/validation/test_va_spec_models.py b/tests/validation/test_va_spec_models.py index 4fa7e85..3247b8a 100644 --- a/tests/validation/test_va_spec_models.py +++ b/tests/validation/test_va_spec_models.py @@ -642,6 +642,7 @@ def test_aac_statement(): }, }, "hasEvidenceLines": [ + "evidence_lines.json#/1", # iri { "targetProposition": prop, "directionOfEvidenceProvided": "supports", @@ -652,7 +653,7 @@ def test_aac_statement(): } }, "hasEvidenceItems": [ - "evidence_lines.json#/1", + "evidence_items.json#/1", { "type": "Statement", "direction": "supports", @@ -673,7 +674,7 @@ def test_aac_statement(): # No strengthOfEvidenceProvided no_evidence_line_strength_params = deepcopy(params) - no_evidence_line_strength_params["hasEvidenceLines"][0].pop( + no_evidence_line_strength_params["hasEvidenceLines"][1].pop( "strengthOfEvidenceProvided" ) assert VariantClinicalSignificanceStatement(**no_evidence_line_strength_params) @@ -708,7 +709,7 @@ def test_aac_statement(): # Invalid targetProposition invalid_params = deepcopy(params) - invalid_params["hasEvidenceLines"][0]["targetProposition"] = invalid_params[ + invalid_params["hasEvidenceLines"][1]["targetProposition"] = invalid_params[ "proposition" ] with pytest.raises(ValidationError, match="`hasEvidenceLines` must be one of"): From 9a98228e54dcd8291b87d2a2e5aa10999f0b2065 Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Tue, 5 May 2026 20:33:42 -0400 Subject: [PATCH 14/19] more fixes --- .gitmodules | 2 +- src/ga4gh/va_spec/base/core.py | 6 +++--- submodules/va_spec | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.gitmodules b/.gitmodules index 0936f3a..74295b3 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "submodules/va_spec"] path = submodules/va_spec url = https://github.com/ga4gh/va-spec - branch = update-snapshot + branch = 1.1.0-snapshot.2026-02 diff --git a/src/ga4gh/va_spec/base/core.py b/src/ga4gh/va_spec/base/core.py index 1b5a29b..541569d 100644 --- a/src/ga4gh/va_spec/base/core.py +++ b/src/ga4gh/va_spec/base/core.py @@ -572,12 +572,12 @@ class EvidenceLine(InformationEntity, BaseModelForbidExtra): ) targetProposition: ( ExperimentalVariantFunctionalImpactProposition - | VariantClinicalSignificanceProposition - | VariantDiagnosticProposition - | VariantOncogenicityProposition | VariantPathogenicityProposition + | VariantDiagnosticProposition | VariantPrognosticProposition + | VariantOncogenicityProposition | VariantTherapeuticResponseProposition + | VariantClinicalSignificanceProposition | None ) = Field( default=None, diff --git a/submodules/va_spec b/submodules/va_spec index 5a4cdd2..b42b9ef 160000 --- a/submodules/va_spec +++ b/submodules/va_spec @@ -1 +1 @@ -Subproject commit 5a4cdd267b2e5048b203f378931b92cde96058ae +Subproject commit b42b9ef2b1e94a6ace58019905a825d0ed62fd57 From a91a447066fc0ff7dff50a4fc050ffff973ed11a Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Tue, 5 May 2026 20:42:26 -0400 Subject: [PATCH 15/19] more cleanup --- src/ga4gh/va_spec/aac_2017/models.py | 63 ++++++++++++++++------------ 1 file changed, 37 insertions(+), 26 deletions(-) diff --git a/src/ga4gh/va_spec/aac_2017/models.py b/src/ga4gh/va_spec/aac_2017/models.py index d5c07a1..b673fa8 100644 --- a/src/ga4gh/va_spec/aac_2017/models.py +++ b/src/ga4gh/va_spec/aac_2017/models.py @@ -177,9 +177,41 @@ class VariantClinicalSignificanceStatement(Statement, BaseModelForbidExtra): specifiedBy: Method | iriReference @model_validator(mode="after") - def validate_aac_statement(self) -> Self: + def validate_statement(self) -> Self: """Validate VariantClinicalSignificanceStatement""" + def _validate_evidence_lines( + classification_code: AmpAscoCapClassificationCode, + has_evidence_lines: list, + ) -> None: + """Validate allowed evidence lines given classification code""" + approved_el_classes = [ + DiagnosticEvidenceLine, + PrognosticEvidenceLine, + TherapeuticEvidenceLine, + ] + if classification_code in { + AmpAscoCapClassificationCode.TIER_1, + AmpAscoCapClassificationCode.TIER_2, + }: + for evidence_line in has_evidence_lines: + if hasattr(evidence_line, "root"): + el_input = evidence_line.root + elif hasattr(evidence_line, "model_dump"): + el_input = evidence_line.model_dump() + else: + el_input = evidence_line + + for approved_el_cls in approved_el_classes: + try: + approved_el_cls.model_validate(el_input) + break + except Exception: # noqa: S112 + continue + else: + msg = "`hasEvidenceLines` must be one of: `DiagnosticEvidenceLine`, `PrognosticEvidenceLine`, or `TherapeuticEvidenceLine`" + raise ValueError(msg) + def _validate_amp_asco_cap_classification_constraints( classification_code: AmpAscoCapClassificationCode, classification_name: str | None, @@ -196,6 +228,7 @@ def _validate_amp_asco_cap_classification_constraints( if strength_code and strength_code.primaryCoding else strength_code ) + if actual_strength != expected.strength: expected_strength = ( expected.strength.value if expected.strength else expected.strength @@ -211,31 +244,7 @@ def _validate_amp_asco_cap_classification_constraints( msg = f"`direction` must be: {expected.direction.value}" raise ValueError(msg) - if classification_code in { - AmpAscoCapClassificationCode.TIER_1, - AmpAscoCapClassificationCode.TIER_2, - }: - for evidence_line in has_evidence_lines: - if hasattr(evidence_line, "root"): - el_input = evidence_line.root - elif hasattr(evidence_line, "model_dump"): - el_input = evidence_line.model_dump() - else: - el_input = evidence_line - - for approved_el_cls in ( - DiagnosticEvidenceLine, - PrognosticEvidenceLine, - TherapeuticEvidenceLine, - ): - try: - approved_el_cls.model_validate(el_input) - break - except Exception: # noqa: S112 - continue - else: - msg = "`hasEvidenceLines` must be one of: `DiagnosticEvidenceLine`, `PrognosticEvidenceLine`, or `TherapeuticEvidenceLine`" - raise ValueError(msg) + _validate_evidence_lines(classification_code, has_evidence_lines) # Validate strength system. The actual value will be validated in # `_validate_amp_asco_cap_classification_constraints` @@ -252,6 +261,8 @@ def _validate_amp_asco_cap_classification_constraints( valid_codes=AMP_ASCO_CAP_CLASSIFICATION_CODES, mc_is_required=True, ) + + # Validate values meet AMP/ASCO/CAP classification constraints _validate_amp_asco_cap_classification_constraints( AmpAscoCapClassificationCode(self.classification.primaryCoding.code.root), self.classification.name, From 5ab6da0c09b6a9f01ddffa378f485a0d0aeeeb44 Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Wed, 6 May 2026 07:56:41 -0400 Subject: [PATCH 16/19] update VASPEC_VERSION to 1.1.0-snapshot.2026-02.1 --- src/ga4gh/va_spec/__init__.py | 2 +- submodules/va_spec | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ga4gh/va_spec/__init__.py b/src/ga4gh/va_spec/__init__.py index 67bf8e6..ca08ca2 100644 --- a/src/ga4gh/va_spec/__init__.py +++ b/src/ga4gh/va_spec/__init__.py @@ -10,4 +10,4 @@ del version, PackageNotFoundError -VASPEC_VERSION = "1.0.1" +VASPEC_VERSION = "1.1.0-snapshot.2026-02.1" diff --git a/submodules/va_spec b/submodules/va_spec index b42b9ef..6890cae 160000 --- a/submodules/va_spec +++ b/submodules/va_spec @@ -1 +1 @@ -Subproject commit b42b9ef2b1e94a6ace58019905a825d0ed62fd57 +Subproject commit 6890cae75fa60bd232cf5b0d8bb608e6b8369852 From 472499ed86b695316c50323a93d2223f921a1ef5 Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Wed, 6 May 2026 13:22:58 -0400 Subject: [PATCH 17/19] feat!: update all models to va-spec 1.1.0-snapshot.2026-02.1 close #49 --- pyproject.toml | 2 +- src/ga4gh/va_spec/acmg_2015/models.py | 13 ++++++--- src/ga4gh/va_spec/base/core.py | 1 + src/ga4gh/va_spec/base/domain_entities.py | 2 +- src/ga4gh/va_spec/ccv_2022/models.py | 33 ++++++++++++++--------- 5 files changed, 33 insertions(+), 18 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3cf2c43..8f36abf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ keywords = [ requires-python = ">=3.10" dynamic = ["version"] dependencies = [ - "ga4gh.vrs~=2.4.0-a0", + "ga4gh.vrs~=2.4.0-a1", "ga4gh.cat_vrs~=0.8.0-a0", "pydantic>=2.0,<3.0", "typing_extensions", diff --git a/src/ga4gh/va_spec/acmg_2015/models.py b/src/ga4gh/va_spec/acmg_2015/models.py index 362645d..466605c 100644 --- a/src/ga4gh/va_spec/acmg_2015/models.py +++ b/src/ga4gh/va_spec/acmg_2015/models.py @@ -9,6 +9,7 @@ from ga4gh.core.models import MappableConcept, iriReference from ga4gh.va_spec.base.core import ( + Direction, EvidenceLine, Method, Statement, @@ -40,7 +41,7 @@ class AcmgClassification(str, Enum): class VariantPathogenicityEvidenceLine(EvidenceLine): """An Evidence Line that describes how a specific type of information was - interpreted as evidence for or againtst a variant's pathogenicity. In the ACMG + interpreted as evidence for or against a variant's pathogenicity. In the ACMG Framework, evidence is assessed by determining if a specific criterion (e.g. 'PM2') with a default strength (e.g. 'moderate') is 'met' or 'not met', and in some cases adjusting the default strength based on the quality and abundance of evidence. @@ -48,15 +49,19 @@ class VariantPathogenicityEvidenceLine(EvidenceLine): targetProposition: VariantPathogenicityProposition | None = Field( default=None, - description="A Variant Pathogenicity Proposition against which specific information was assessed, in determining the strength and direction of support this information provides as evidence.", + description="A Variant Pathogenicity Proposition against which a specific type of evidence was assessed, to determine the strength and direction of support this evidence provides for or against the proposition's validity.", + ) + directionOfEvidenceProvided: Direction = Field( + ..., + description="The direction of support that the Evidence Line is determined to provide toward its target Proposition (supports, disputes, neutral). For ACMG-based assessments, if a pathogenicity criterion is 'met' in the Evidence Line the direction is 'supports', if a benignity criterion is 'met' the direction is 'disputes', and if a criteria is 'not met' the direction is 'none'.", ) strengthOfEvidenceProvided: MappableConcept | None = Field( default=None, - description="The strength of support that an Evidence Line is determined to provide for or against the proposed pathogenicity of the assessed variant. Strength is evaluated relative to the direction indicated by the 'directionOfEvidenceProvided' attribute. The indicated enumeration constrains the nested MappableConcept.primaryCoding > Coding.code attribute when capturing evidence strength. Conditional requirement: if directionOfEvidenceProvided is either 'supports' or 'disputes', then this attribute is required. If it is 'none', then this attribute is not allowed.", + description="The strength of support that an Evidence Line is determined to provide for or against the proposed pathogenicity of the assessed variant. Strength is evaluated relative to the direction indicated by the 'directionOfEvidenceProvided' attribute, and captured using a MappableConcept, whose nested 'code' field is bound to an enumerated set of values. Conditional requirement: if `directionOfEvidenceProvided` is either 'supports' or 'disputes', then this attribute is required. If it is 'none', then this attribute is not allowed.", ) specifiedBy: Method | iriReference = Field( ..., - description="The guidelines that were followed to assess variant information as evidence for or against the assessed variant's pathogenicity.", + description="The guidelines or rubrics followed in interpreting evidence, to determine the strength and direction of support that it provides for or against a variant's pathogenicity. While the ACMG Criteria themselves provide minimal guidance, typically a more detailed, disease- or gene- specific rubric is followed to determine if a given criterion was met, and how strongly (e.g. the ClinGen Hearing Loss Expert Panel guidelines for ACMG interpretations).", ) class Criterion(str, Enum): diff --git a/src/ga4gh/va_spec/base/core.py b/src/ga4gh/va_spec/base/core.py index 541569d..ebf39c6 100644 --- a/src/ga4gh/va_spec/base/core.py +++ b/src/ga4gh/va_spec/base/core.py @@ -429,6 +429,7 @@ class VariantClinicalSignificanceProposition( """ model_config = ConfigDict(use_enum_values=True) + type: Literal["VariantClinicalSignificanceProposition"] = Field( default="VariantClinicalSignificanceProposition", description="MUST be 'VariantClinicalSignificanceProposition'.", diff --git a/src/ga4gh/va_spec/base/domain_entities.py b/src/ga4gh/va_spec/base/domain_entities.py index baaf172..c9bb9a7 100644 --- a/src/ga4gh/va_spec/base/domain_entities.py +++ b/src/ga4gh/va_spec/base/domain_entities.py @@ -44,7 +44,7 @@ class Condition(RootModel): class TherapyGroup(Element, BaseModelForbidExtra): """A group of two or more therapies that are applied in combination to a single patient/subject, or applied individually to a different subset of participants in a - research study + research study. """ model_config = ConfigDict(use_enum_values=True) diff --git a/src/ga4gh/va_spec/ccv_2022/models.py b/src/ga4gh/va_spec/ccv_2022/models.py index a0c20bd..5139934 100644 --- a/src/ga4gh/va_spec/ccv_2022/models.py +++ b/src/ga4gh/va_spec/ccv_2022/models.py @@ -9,6 +9,7 @@ from ga4gh.core.models import MappableConcept, iriReference from ga4gh.va_spec.base.core import ( + Direction, EvidenceLine, Method, Statement, @@ -24,21 +25,31 @@ class VariantOncogenicityEvidenceLine(EvidenceLine): - """An Evidence Line that describes how information about the specific evidence of a - variant was interpreted as evidence for or against the variant's oncogenicity. + """An Evidence Line that describes how evidence for a variant was interpreted to + determine if a specific CCV 2022 criterion code is met, and the strength that + evidence this provides for or against the variant's oncogenicity. An Evidence Line + that describes how a specific type of information was interpreted as evidence for + or against a variant's oncogenicity. In the CCV Framework, evidence is assessed by + determining if a specific criterion (e.g. 'OM2') with a default strength + (e.g. 'moderate') is 'met' or 'not met', and in some cases adjusting the default + strength based on the quality and abundance of evidence. """ targetProposition: VariantOncogenicityProposition | None = Field( default=None, - description="A Variant Oncogenicity Proposition against which evidence information was assessed, in determining the strength and direction of support this information provides as evidence.", + description="A Variant Oncoogenicity Proposition against which a specific type of evidence was assessed, to determine the strength and direction of support this evidence provides for or against the proposition's validity.", + ) + directionOfEvidenceProvided: Direction = Field( + ..., + description="The direction of support that the Evidence Line is determined to provide toward its target Proposition (supports, disputes, neutral). For CCV-based assessments, if a oncogenicity criterion is 'met' in the Evidence Line the direction is 'supports', if a benignity criterion is 'met' the direction is 'disputes', and if a criteria is 'not met' the direction is 'none'.", ) strengthOfEvidenceProvided: MappableConcept | None = Field( default=None, - description="The strength of support that an Evidence Line is determined to provide for or against the proposed pathogenicity of the assessed variant. Strength is evaluated relative to the direction indicated by the 'directionOfEvidenceProvided' attribute. The indicated enumeration constrains the nested MappableConcept.primaryCoding > Coding.code attribute when capturing evidence strength.", + description="The strength of support that an Evidence Line is determined to provide for or against the proposed oncogenicity of the assessed variant. Strength is evaluated relative to the direction indicated by the 'directionOfEvidenceProvided' attribute, and captured using a MappableConcept, whose nested 'code' field is bound to an enumerated set of values. Conditional requirement: if `directionOfEvidenceProvided` is either 'supports' or 'disputes', then this attribute is required. If it is 'none', then this attribute is not allowed.", ) specifiedBy: Method | iriReference = Field( ..., - description="The guidelines that were followed to assess the variant information as evidence for or against the assessed variant's oncogenicity.", + description="The guidelines or rubrics followed in interpreting evidence, to determine the strength and direction of support that it provides for or against a variant's oncogenicity. While the CCV Criteria themselves provide minimal guidance, typically a more detailed, gene- or cancer- specific rubric is followed to determine if a given criterion was met, and how strongly.", ) class Criterion(str, Enum): @@ -99,9 +110,9 @@ def validate_model(cls, values: dict) -> dict: # noqa: N805 class VariantOncogenicityStatement(Statement): - """A statement reporting a conclusion from a single study about whether a - variant is associated with oncogenicity (positive or negative) - based on - interpretation of the study's results. + """A statement reporting a conclusion from a single study about whether a variant is + associated with oncogenicity (positive or negative) - based on interpretation of the + study's results. """ proposition: VariantOncogenicityProposition = Field( @@ -110,11 +121,9 @@ class VariantOncogenicityStatement(Statement): ) strength: MappableConcept | None = Field( default=None, - description="The strength of support that an CCV 2022 Oncogenicity statement is determined to provide for or against the proposed oncogenicity of the assessed variant. Strength is evaluated relative to the direction indicated by the 'direction' attribute. The indicated enumeration constrains the nested MappableConcept.primaryCoding > Coding.code attribute when capturing evidence strength.", - ) - classification: MappableConcept = Field( - ..., + description="The strength of support that an CCV 2022 Oncogenicity statement is determined to provide for or against the proposed oncogenicity of the assessed variant. Strength is evaluated relative to the direction indicated by the 'direction' attribute. The indicated enumeration constrains the nested MappableConcept.primaryCoding > Coding.code attribute when capturing evidence strength. Conditional requirement: if directionOfEvidenceProvided is either 'supports' or 'disputes', then this attribute is required. If it is 'neutral', then this attribute is not allowed.", ) + classification: MappableConcept specifiedBy: Method | iriReference = Field( ..., description="The method that specifies how the oncogenicity classification is ultimately assigned to the variant, based on assessment of evidence.", From 8cbada8348443aae4c81fb3a8411a505d92151c1 Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Wed, 6 May 2026 13:24:53 -0400 Subject: [PATCH 18/19] bump cat vrs --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8f36abf..5335026 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ requires-python = ">=3.10" dynamic = ["version"] dependencies = [ "ga4gh.vrs~=2.4.0-a1", - "ga4gh.cat_vrs~=0.8.0-a0", + "ga4gh.cat_vrs~=0.8.0-a1", "pydantic>=2.0,<3.0", "typing_extensions", ] From 4b5f1bb4702d0f5b048a2c3892cb5ba852b719dc Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Wed, 6 May 2026 14:24:30 -0400 Subject: [PATCH 19/19] feat: add helper function for deriving onco code attrs close #48 * Given onco evidence code, will return associated evidence outcome, strength, and score --- src/ga4gh/va_spec/ccv_2022/__init__.py | 8 ++ .../va_spec/ccv_2022/derived_evidence.py | 86 +++++++++++++ tests/test_ccv_derived_evidence.py | 116 ++++++++++++++++++ 3 files changed, 210 insertions(+) create mode 100644 src/ga4gh/va_spec/ccv_2022/derived_evidence.py create mode 100644 tests/test_ccv_derived_evidence.py diff --git a/src/ga4gh/va_spec/ccv_2022/__init__.py b/src/ga4gh/va_spec/ccv_2022/__init__.py index a84a82c..4f6d372 100644 --- a/src/ga4gh/va_spec/ccv_2022/__init__.py +++ b/src/ga4gh/va_spec/ccv_2022/__init__.py @@ -1,11 +1,19 @@ """Module to load and init namespace at package level.""" +from .derived_evidence import ( + CODE_PREFIX_TO_SCORE_MAP, + CODE_SUFFIX_TO_STRENGTH_MAP, + derive_onco_evidence_attributes, +) from .models import ( VariantOncogenicityEvidenceLine, VariantOncogenicityStatement, ) __all__ = [ + "CODE_PREFIX_TO_SCORE_MAP", + "CODE_SUFFIX_TO_STRENGTH_MAP", + "derive_onco_evidence_attributes", "VariantOncogenicityEvidenceLine", "VariantOncogenicityStatement", ] diff --git a/src/ga4gh/va_spec/ccv_2022/derived_evidence.py b/src/ga4gh/va_spec/ccv_2022/derived_evidence.py new file mode 100644 index 0000000..6812623 --- /dev/null +++ b/src/ga4gh/va_spec/ccv_2022/derived_evidence.py @@ -0,0 +1,86 @@ +"""Provide derived evidence attributes for an onco evidence code. + +Can be used to populate `evidenceOutcome`, `strengthOfEvidenceProvided`, and +`scoreOfEvidenceProvided` fields in `VariantOncogenicityEvidenceLine`. +""" + +from types import MappingProxyType + +from pydantic import BaseModel + +from ga4gh.core.models import Coding, MappableConcept, code +from ga4gh.va_spec.base import ( + StrengthOfEvidenceProvided, +) +from ga4gh.va_spec.base.enums import System +from ga4gh.va_spec.ccv_2022.models import VariantOncogenicityEvidenceLine + + +class EvidenceAttributes(BaseModel): + """Define derived evidence attributes for an onco evidence code.""" + + evidenceOutcome: MappableConcept + strengthOfEvidenceProvided: MappableConcept + scoreOfEvidenceProvided: int + + +# IMPORTANT: Don't change the order. Longer suffixes must be evaluated first. +CODE_SUFFIX_TO_STRENGTH_MAP = MappingProxyType( + { + "VS": StrengthOfEvidenceProvided.VERY_STRONG, + "S": StrengthOfEvidenceProvided.STRONG, + "M": StrengthOfEvidenceProvided.MODERATE, + "P": StrengthOfEvidenceProvided.SUPPORTING, + } +) + + +CODE_PREFIX_TO_SCORE_MAP = MappingProxyType( + { + "OVS": 8, + "SBVS": -8, + "OS": 4, + "SBS": -4, + "OM": 2, + "SBM": -2, + "OP": 1, + "SBP": -1, + } +) + + +def derive_onco_evidence_attributes( + evidence: VariantOncogenicityEvidenceLine.Criterion, +) -> EvidenceAttributes: + """Derive evidence attributes given a CCV 2022 evidence code. + + :param evidence: CCV 2022 evidence code + :return: Derived evidence attributes (evidenceOutcome, strengthOfEvidenceProvided, + scoreOfEvidenceProvided) + """ + evidence_code = evidence.value + normalized_evidence_code = evidence_code.rstrip("1234") + + code_suffix = next( + suffix + for suffix in CODE_SUFFIX_TO_STRENGTH_MAP + if normalized_evidence_code.endswith(suffix) + ) + code_prefix = next( + prefix + for prefix in CODE_PREFIX_TO_SCORE_MAP + if normalized_evidence_code.startswith(prefix) + ) + system = System.CCV + + return EvidenceAttributes( + evidenceOutcome=MappableConcept( + primaryCoding=Coding(code=code(evidence_code), system=system) + ), + strengthOfEvidenceProvided=MappableConcept( + primaryCoding=Coding( + code=code(CODE_SUFFIX_TO_STRENGTH_MAP[code_suffix]), system=system + ) + ), + scoreOfEvidenceProvided=CODE_PREFIX_TO_SCORE_MAP[code_prefix], + ) diff --git a/tests/test_ccv_derived_evidence.py b/tests/test_ccv_derived_evidence.py new file mode 100644 index 0000000..7a78aae --- /dev/null +++ b/tests/test_ccv_derived_evidence.py @@ -0,0 +1,116 @@ +"""Test that CCV 2022 derived evidence is working correctly""" + +import pytest + +from ga4gh.va_spec.ccv_2022 import ( + VariantOncogenicityEvidenceLine, + derive_onco_evidence_attributes, +) + + +@pytest.mark.parametrize( + ("criterion", "expected_strength", "expected_score"), + [ + ( + VariantOncogenicityEvidenceLine.Criterion.OP1, + "supporting", + 1, + ), + ( + VariantOncogenicityEvidenceLine.Criterion.OP2, + "supporting", + 1, + ), + ( + VariantOncogenicityEvidenceLine.Criterion.OP3, + "supporting", + 1, + ), + ( + VariantOncogenicityEvidenceLine.Criterion.OP4, + "supporting", + 1, + ), + ( + VariantOncogenicityEvidenceLine.Criterion.OM1, + "moderate", + 2, + ), + ( + VariantOncogenicityEvidenceLine.Criterion.OM2, + "moderate", + 2, + ), + ( + VariantOncogenicityEvidenceLine.Criterion.OM3, + "moderate", + 2, + ), + ( + VariantOncogenicityEvidenceLine.Criterion.OM4, + "moderate", + 2, + ), + ( + VariantOncogenicityEvidenceLine.Criterion.OS1, + "strong", + 4, + ), + ( + VariantOncogenicityEvidenceLine.Criterion.OS2, + "strong", + 4, + ), + ( + VariantOncogenicityEvidenceLine.Criterion.OS3, + "strong", + 4, + ), + ( + VariantOncogenicityEvidenceLine.Criterion.OVS1, + "very strong", + 8, + ), + ( + VariantOncogenicityEvidenceLine.Criterion.SBP1, + "supporting", + -1, + ), + ( + VariantOncogenicityEvidenceLine.Criterion.SBP2, + "supporting", + -1, + ), + ( + VariantOncogenicityEvidenceLine.Criterion.SBS1, + "strong", + -4, + ), + ( + VariantOncogenicityEvidenceLine.Criterion.SBS2, + "strong", + -4, + ), + ( + VariantOncogenicityEvidenceLine.Criterion.SBVS1, + "very strong", + -8, + ), + ], +) +def test_derive_onco_evidence_attributes( + criterion, + expected_strength, + expected_score, +): + """Test that derive_onco_evidence_attributes works correctly""" + onco_evidence_attrs = derive_onco_evidence_attributes(criterion) + + assert ( + onco_evidence_attrs.evidenceOutcome.primaryCoding.code.root == criterion.value + ) + assert ( + onco_evidence_attrs.strengthOfEvidenceProvided.primaryCoding.code.root + == expected_strength + ) + assert onco_evidence_attrs.scoreOfEvidenceProvided == expected_score