From 268fd94ccb7fe7b20d6e4effb058480612307d40 Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Tue, 4 Aug 2026 14:56:00 -0400 Subject: [PATCH 1/2] feat!: derive ccv evidence atrs from outcome strings close #68 * Allow `derive_onco_evidence_attributes` to accept either a criterion enum or valid ccv evidence outcome string and dervie corresponding evidence line attributes --- src/ga4gh/va_spec/ccv_2022/__init__.py | 8 +- .../va_spec/ccv_2022/derived_evidence.py | 137 ++++++++++++------ src/ga4gh/va_spec/ccv_2022/models.py | 11 +- tests/test_ccv_derived_evidence.py | 78 ++++++++++ 4 files changed, 184 insertions(+), 50 deletions(-) diff --git a/src/ga4gh/va_spec/ccv_2022/__init__.py b/src/ga4gh/va_spec/ccv_2022/__init__.py index 7f451dc..ce7327d 100644 --- a/src/ga4gh/va_spec/ccv_2022/__init__.py +++ b/src/ga4gh/va_spec/ccv_2022/__init__.py @@ -1,10 +1,6 @@ """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 .derived_evidence import derive_onco_evidence_attributes from .models import ( METHOD, SYSTEM, @@ -13,8 +9,6 @@ ) __all__ = [ - "CODE_PREFIX_TO_SCORE_MAP", - "CODE_SUFFIX_TO_STRENGTH_MAP", "derive_onco_evidence_attributes", "METHOD", "SYSTEM", diff --git a/src/ga4gh/va_spec/ccv_2022/derived_evidence.py b/src/ga4gh/va_spec/ccv_2022/derived_evidence.py index 9cd0209..7b6339c 100644 --- a/src/ga4gh/va_spec/ccv_2022/derived_evidence.py +++ b/src/ga4gh/va_spec/ccv_2022/derived_evidence.py @@ -1,35 +1,48 @@ -"""Provide derived evidence attributes for an onco evidence code. +"""Derive evidence line attributes from a CCV 2022 evidence outcome code. -Can be used to populate `evidenceOutcome`, `strengthOfEvidenceProvided`, and -`scoreOfEvidenceProvided` fields in `VariantOncogenicityEvidenceLine`. +Can be used to populate `evidenceOutcome`, `directionOfEvidenceProvided`, +`strengthOfEvidenceProvided`, and `scoreOfEvidenceProvided` fields in +`VariantOncogenicityEvidenceLine`. """ +import re from types import MappingProxyType +from typing import NamedTuple from pydantic import BaseModel from ga4gh.core.models import Coding, MappableConcept, code -from ga4gh.va_spec.base.core import Method +from ga4gh.va_spec.base.core import Direction, Method from ga4gh.va_spec.base.enums import StrengthOfEvidenceProvided, System from ga4gh.va_spec.ccv_2022.models import ( - METHOD as CCV_METHOD, + CCV_CODE_PATTERN, + VariantOncogenicityEvidenceLine, ) from ga4gh.va_spec.ccv_2022.models import ( - VariantOncogenicityEvidenceLine, + METHOD as CCV_METHOD, ) class EvidenceAttributes(BaseModel): - """Define derived evidence attributes for an onco evidence code.""" + """Store the evidence line attributes derived from a CCV outcome code.""" evidenceOutcome: MappableConcept - strengthOfEvidenceProvided: MappableConcept - scoreOfEvidenceProvided: int + directionOfEvidenceProvided: Direction + strengthOfEvidenceProvided: MappableConcept | None + scoreOfEvidenceProvided: int | None specifiedBy: Method +class _ParsedEvidenceOutcome(NamedTuple): + """Store the normalized parts of a CCV evidence outcome code.""" + + outcome: str + criterion: VariantOncogenicityEvidenceLine.Criterion + modifier: str + + # IMPORTANT: Don't change the order. Longer suffixes must be evaluated first. -CODE_SUFFIX_TO_STRENGTH_MAP = MappingProxyType( +_CODE_SUFFIX_TO_DEFAULT_STRENGTH_MAP = MappingProxyType( { "VS": StrengthOfEvidenceProvided.VERY_STRONG, "S": StrengthOfEvidenceProvided.STRONG, @@ -39,59 +52,101 @@ class EvidenceAttributes(BaseModel): ) -CODE_PREFIX_TO_SCORE_MAP = MappingProxyType( +_STRENGTH_TO_SCORE_MAGNITUDE_MAP = MappingProxyType( { - "OVS": 8, - "SBVS": -8, - "OS": 4, - "SBS": -4, - "OM": 2, - "SBM": -2, - "OP": 1, - "SBP": -1, + StrengthOfEvidenceProvided.VERY_STRONG: 8, + StrengthOfEvidenceProvided.STRONG: 4, + StrengthOfEvidenceProvided.MODERATE: 2, + StrengthOfEvidenceProvided.SUPPORTING: 1, } ) +def _parse_ccv_evidence_outcome( + evidence: VariantOncogenicityEvidenceLine.Criterion | str, +) -> _ParsedEvidenceOutcome: + """Normalize and validate a CCV evidence outcome code. + + :param evidence: A base criterion or complete CCV evidence outcome code. + :raises ValueError: If the outcome code does not follow the CCV format. + :return: The canonical outcome code and its parsed parts. + """ + provided_outcome = ( + evidence.value + if isinstance(evidence, VariantOncogenicityEvidenceLine.Criterion) + else evidence + ) + evidence_code, separator, outcome_modifier = provided_outcome.partition("_") + outcome_modifier = outcome_modifier.lower() + evidence_outcome = ( + f"{evidence_code}_{outcome_modifier}" if separator else evidence_code + ) + + if re.fullmatch(CCV_CODE_PATTERN, evidence_outcome) is None: + msg = f"Invalid CCV evidence outcome: {provided_outcome}" + raise ValueError(msg) + + criterion = VariantOncogenicityEvidenceLine.Criterion(evidence_code) + return _ParsedEvidenceOutcome(evidence_outcome, criterion, outcome_modifier) + + def derive_onco_evidence_attributes( - evidence: VariantOncogenicityEvidenceLine.Criterion, + evidence: VariantOncogenicityEvidenceLine.Criterion | str, ) -> EvidenceAttributes: - """Derive evidence attributes given a CCV 2022 evidence code. + """Derive evidence line attributes from a CCV 2022 outcome code. - :param evidence: CCV 2022 evidence code - :return: Derived evidence attributes (evidenceOutcome, strengthOfEvidenceProvided, - scoreOfEvidenceProvided, specifiedBy) + :param evidence: A base criterion or complete CCV evidence outcome code. + :raises ValueError: If the outcome code does not follow the CCV format. + :return: The attributes needed to populate a CCV evidence line. """ - 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) + parsed_outcome = _parse_ccv_evidence_outcome(evidence) + evidence_code = parsed_outcome.criterion.value + criterion_prefix = evidence_code.rstrip("1234") + + default_strength = next( + default_strength + for suffix, default_strength in _CODE_SUFFIX_TO_DEFAULT_STRENGTH_MAP.items() + if criterion_prefix.endswith(suffix) ) - code_prefix = next( - prefix - for prefix in CODE_PREFIX_TO_SCORE_MAP - if normalized_evidence_code.startswith(prefix) + direction, score_sign = ( + (Direction.DISPUTES, -1) + if evidence_code.startswith("SB") + else (Direction.SUPPORTS, 1) ) + + if parsed_outcome.modifier == "not_met": + applied_strength = None + direction = Direction.NEUTRAL + else: + applied_strength = ( + StrengthOfEvidenceProvided(parsed_outcome.modifier.replace("_", " ")) + if parsed_outcome.modifier + else default_strength + ) system = System.CCV return EvidenceAttributes( evidenceOutcome=MappableConcept( - primaryCoding=Coding(code=code(evidence_code), system=system) + primaryCoding=Coding(code=code(parsed_outcome.outcome), system=system) ), - strengthOfEvidenceProvided=MappableConcept( - primaryCoding=Coding( - code=code(CODE_SUFFIX_TO_STRENGTH_MAP[code_suffix]), system=system + directionOfEvidenceProvided=direction, + strengthOfEvidenceProvided=( + MappableConcept( + primaryCoding=Coding(code=code(applied_strength), system=system) ) + if applied_strength is not None + else None + ), + scoreOfEvidenceProvided=( + score_sign * _STRENGTH_TO_SCORE_MAGNITUDE_MAP[applied_strength] + if applied_strength is not None + else None ), - scoreOfEvidenceProvided=CODE_PREFIX_TO_SCORE_MAP[code_prefix], specifiedBy=CCV_METHOD.model_copy( deep=True, update={ "methodType": VariantOncogenicityEvidenceLine.METHOD_TYPE_BY_CRITERION[ - evidence + parsed_outcome.criterion ].value }, ), diff --git a/src/ga4gh/va_spec/ccv_2022/models.py b/src/ga4gh/va_spec/ccv_2022/models.py index 262c51b..146791c 100644 --- a/src/ga4gh/va_spec/ccv_2022/models.py +++ b/src/ga4gh/va_spec/ccv_2022/models.py @@ -31,6 +31,14 @@ ) SYSTEM = System.CCV +CCV_CODE_PATTERN = ( + r"^(?:" + r"(?:OVS1|SBVS1)(?:_(?:not_met|strong|moderate|supporting))?" + r"|(?:OS[1-3]|SBS[1-2])(?:_(?:not_met|very_strong|moderate|supporting))?" + r"|OM[1-4](?:_(?:not_met|very_strong|strong|supporting))?" + r"|(?:OP[1-4]|SBP[1-2])(?:_(?:not_met|very_strong|strong|moderate))?" + r")$" +) METHOD = Method( # recommended representation of ClinGen/CGC/VICC 2022 method name=SYSTEM, reportedIn=Document( @@ -226,8 +234,7 @@ def validate_model(self) -> Self: ``directionOfEvidenceProvided`` is neutral """ self._validate_direction_of_evidence_provided() - ccv_code_pattern = r"^((?:OVS1|SBVS1)(?:_(?:not_met|(?:strong|moderate|supporting)))?|(?:OS[1-3]|SBS[1-2])(?:_(?:not_met|(?:very_strong|moderate|supporting)))?|(?:OM[1-4])(?:_(?:not_met|(?:very_strong|strong|supporting)))?|(OP[1-4]|SBP[1-2])(?:_(?:not_met|very_strong|strong|moderate))?)$" - self._validate_evidence_outcome(SYSTEM, ccv_code_pattern, is_required=True) + self._validate_evidence_outcome(SYSTEM, CCV_CODE_PATTERN, is_required=True) self._validate_criterion_specified_by() self._validate_method_type_evidence_outcome( self.specifiedBy.methodType, self.evidenceOutcome.primaryCoding.code.root diff --git a/tests/test_ccv_derived_evidence.py b/tests/test_ccv_derived_evidence.py index d252a35..13d3c8a 100644 --- a/tests/test_ccv_derived_evidence.py +++ b/tests/test_ccv_derived_evidence.py @@ -129,4 +129,82 @@ def test_derive_onco_evidence_attributes( == expected_strength ) assert onco_evidence_attrs.scoreOfEvidenceProvided == expected_score + expected_direction = "disputes" if expected_score < 0 else "supports" + assert onco_evidence_attrs.directionOfEvidenceProvided == expected_direction assert onco_evidence_attrs.specifiedBy.methodType == expected_method_type.value + + +@pytest.mark.parametrize( + ("outcome", "expected_direction", "expected_score", "expected_method_type"), + [ + ( + "OS2_moderate", + "supports", + 2, + VariantOncogenicityEvidenceLine.MethodType.FUNCTIONAL_ASSAY, + ), + ( + "SBS2_moderate", + "disputes", + -2, + VariantOncogenicityEvidenceLine.MethodType.FUNCTIONAL_ASSAY, + ), + ], +) +def test_derive_onco_evidence_attributes_with_adjusted_strength( + outcome, expected_direction, expected_score, expected_method_type +): + """Test that the outcome's adjusted strength determines strength and score.""" + onco_evidence_attrs = derive_onco_evidence_attributes(outcome) + + evidence_outcome = onco_evidence_attrs.evidenceOutcome.primaryCoding.code.root + assert evidence_outcome == outcome + evidence_line = VariantOncogenicityEvidenceLine(**onco_evidence_attrs.model_dump()) + assert evidence_line.evidenceOutcome.primaryCoding.code.root == evidence_outcome + assert onco_evidence_attrs.directionOfEvidenceProvided == expected_direction + assert ( + onco_evidence_attrs.strengthOfEvidenceProvided.primaryCoding.code.root + == "moderate" + ) + assert onco_evidence_attrs.scoreOfEvidenceProvided == expected_score + assert onco_evidence_attrs.specifiedBy.methodType == expected_method_type.value + + +@pytest.mark.parametrize( + ("provided_outcome", "expected_outcome"), + [ + ("OS2_MODERATE", "OS2_moderate"), + ("OS2_Moderate", "OS2_moderate"), + ("OS2_NOT_MET", "OS2_not_met"), + ], +) +def test_derive_onco_evidence_attributes_normalizes_modifier( + provided_outcome, expected_outcome +): + """Test that outcome modifiers are normalized to lowercase.""" + onco_evidence_attrs = derive_onco_evidence_attributes(provided_outcome) + + assert ( + onco_evidence_attrs.evidenceOutcome.primaryCoding.code.root == expected_outcome + ) + + +def test_derive_onco_evidence_attributes_does_not_normalize_criterion(): + """Test that criterion codes must retain their canonical uppercase form.""" + with pytest.raises(ValueError, match="Invalid CCV evidence outcome"): + derive_onco_evidence_attributes("os2_MODERATE") + + +def test_derive_onco_evidence_attributes_from_not_met_outcome(): + """Test that a not-met outcome has no strength or score.""" + onco_evidence_attrs = derive_onco_evidence_attributes("OS2_not_met") + + assert onco_evidence_attrs.evidenceOutcome.primaryCoding.code.root == "OS2_not_met" + assert onco_evidence_attrs.directionOfEvidenceProvided == "neutral" + assert onco_evidence_attrs.strengthOfEvidenceProvided is None + assert onco_evidence_attrs.scoreOfEvidenceProvided is None + assert ( + onco_evidence_attrs.specifiedBy.methodType + == VariantOncogenicityEvidenceLine.MethodType.FUNCTIONAL_ASSAY.value + ) + VariantOncogenicityEvidenceLine(**onco_evidence_attrs.model_dump()) From 02538a996ffb56f90364840d79144ca91ba2cc15 Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Tue, 4 Aug 2026 15:40:48 -0400 Subject: [PATCH 2/2] restore original pattern --- src/ga4gh/va_spec/ccv_2022/models.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ga4gh/va_spec/ccv_2022/models.py b/src/ga4gh/va_spec/ccv_2022/models.py index 146791c..40bb4df 100644 --- a/src/ga4gh/va_spec/ccv_2022/models.py +++ b/src/ga4gh/va_spec/ccv_2022/models.py @@ -32,11 +32,11 @@ SYSTEM = System.CCV CCV_CODE_PATTERN = ( - r"^(?:" - r"(?:OVS1|SBVS1)(?:_(?:not_met|strong|moderate|supporting))?" - r"|(?:OS[1-3]|SBS[1-2])(?:_(?:not_met|very_strong|moderate|supporting))?" - r"|OM[1-4](?:_(?:not_met|very_strong|strong|supporting))?" - r"|(?:OP[1-4]|SBP[1-2])(?:_(?:not_met|very_strong|strong|moderate))?" + r"^(" + r"(?:OVS1|SBVS1)(?:_(?:not_met|(?:strong|moderate|supporting)))?" + r"|(?:OS[1-3]|SBS[1-2])(?:_(?:not_met|(?:very_strong|moderate|supporting)))?" + r"|(?:OM[1-4])(?:_(?:not_met|(?:very_strong|strong|supporting)))?" + r"|(OP[1-4]|SBP[1-2])(?:_(?:not_met|very_strong|strong|moderate))?" r")$" ) METHOD = Method( # recommended representation of ClinGen/CGC/VICC 2022 method