Skip to content
Open
31 changes: 31 additions & 0 deletions alembic/versions/adb481b7c60b_add_calibration_superseded_column.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""add_calibration_superseded_column

Revision ID: adb481b7c60b
Revises: 398067c53257
Create Date: 2026-06-01 16:45:35.507837

"""
from alembic import op
import sqlalchemy as sa

# revision identifiers, used by Alembic.
revision = 'adb481b7c60b'
down_revision = 'a7f3c2e9b104'
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('score_calibrations', sa.Column('replaces_id', sa.Integer(), nullable=True))
op.create_index(op.f('ix_score_calibrations_replaces_id'), 'score_calibrations', ['replaces_id'], unique=False)
op.create_foreign_key(None, 'score_calibrations', 'score_calibrations', ['replaces_id'], ['id'])
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'score_calibrations', type_='foreignkey')
op.drop_index(op.f('ix_score_calibrations_replaces_id'), table_name='score_calibrations')
op.drop_column('score_calibrations', 'replaces_id')
# ### end Alembic commands ###
224 changes: 213 additions & 11 deletions src/mavedb/lib/score_calibrations.py

Large diffs are not rendered by default.

39 changes: 34 additions & 5 deletions src/mavedb/lib/score_sets.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
)
from mavedb.lib.mave.utils import is_csv_null
from mavedb.lib.permissions import Action, has_permission
from mavedb.lib.score_calibrations import find_superseded_score_calibration_tail
from mavedb.lib.types.authentication import UserData
from mavedb.lib.validation.constants.general import null_values_list
from mavedb.lib.validation.utilities import is_null as validate_is_null
Expand Down Expand Up @@ -57,6 +58,7 @@
from mavedb.models.uniprot_offset import UniprotOffset
from mavedb.models.user import User
from mavedb.models.variant import Variant
from mavedb.view_models import score_set
from mavedb.view_models.search import ScoreSetsSearch, ControlledKeywordFilterOption

if TYPE_CHECKING:
Expand Down Expand Up @@ -321,6 +323,33 @@ def score_set_search_filter_options_from_counter(counter: Counter):
return [{"value": value, "count": count} for value, count in counter.items()]


def enrich_score_set_with_num_score_calibrations(
item_update: ScoreSet, user_data: Optional[UserData]
) -> score_set.ScoreSet:
"""
Validate and update the number of score calibration in score set. The superseded score calibration is excluded.
Data structure: score_set{score_calibration_urns, num_score_calibrations}
"""
filter_superseded_score_calibration_tails = [
find_superseded_score_calibration_tail(score_calibration, Action.READ, user_data) for score_calibration in item_update.score_calibrations
]
filtered_score_calibration_urns = sorted(
{
score_calibration.urn
for score_calibration in filter_superseded_score_calibration_tails
if score_calibration is not None and score_calibration.urn is not None
}
)

updated_score_set = score_set.ScoreSet.model_validate(item_update).copy(
update={
"num_score_calibrations": len(filtered_score_calibration_urns),
"score_calibration_urns": filtered_score_calibration_urns,
}
)
return updated_score_set


def fetch_score_set_search_filter_options(
db: Session, requester: Optional[UserData], owner_or_contributor: Optional[User], search: ScoreSetsSearch
):
Expand Down Expand Up @@ -351,13 +380,13 @@ def fetch_score_set_search_filter_options(
# - Use parallelization (e.g., multiprocessing or concurrent.futures) for large datasets
# - Pre-fetch or denormalize target/publication data in the DB query
# - Profile and refactor nested attribute lookups to minimize Python overhead
for score_set in score_sets:
for ss in score_sets:
# Check read permission for each score set, skip if no permission
if not has_permission(requester, score_set, Action.READ).permitted:
if not has_permission(requester, ss, Action.READ).permitted:
continue

# Target related options
for target in getattr(score_set, "target_genes", []):
for target in getattr(ss, "target_genes", []):
category = getattr(target, "category", None)
if category:
target_category_counter[category] += 1
Expand All @@ -380,7 +409,7 @@ def fetch_score_set_search_filter_options(
target_accession_counter[accession] += 1

# Publication related options
for publication_association in getattr(score_set, "publication_identifier_associations", []):
for publication_association in getattr(ss, "publication_identifier_associations", []):
publication = getattr(publication_association, "publication", None)

authors = getattr(publication, "authors", [])
Expand All @@ -398,7 +427,7 @@ def fetch_score_set_search_filter_options(
publication_journal_counter[journal] += 1

# Controlled keywords related options
for controlled_keyword in getattr(score_set.experiment, "keyword_objs", []):
for controlled_keyword in getattr(ss.experiment, "keyword_objs", []):
keyword = getattr(controlled_keyword, "controlled_keyword", [])
if not keyword:
continue
Expand Down
8 changes: 8 additions & 0 deletions src/mavedb/lib/validation/urn_re.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@
MAVEDB_COLLECTION_URN_PATTERN = r"urn:mavedb:collection-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
MAVEDB_COLLECTION_URN_RE = re.compile(MAVEDB_COLLECTION_URN_PATTERN)

# Temp calibration URN
MAVEDB_TMP_CALIBRATION_URN_PATTERN = r"tmp:mavedb.calibration-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
MAVEDB_TMP_CALIBRATION_URN_RE = re.compile(MAVEDB_TMP_CALIBRATION_URN_PATTERN)

# Calibration URN
MAVEDB_CALIBRATION_URN_PATTERN = r"urn:mavedb:calibration-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
MAVEDB_CALIBRATION_URN_RE = re.compile(MAVEDB_CALIBRATION_URN_PATTERN)

# Any URN
MAVEDB_ANY_URN_PATTERN = "|".join(
[
Expand Down
13 changes: 12 additions & 1 deletion src/mavedb/models/score_calibration.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import annotations

from datetime import date
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Optional

from sqlalchemy import Boolean, Column, Date, Float, ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import JSONB
Expand Down Expand Up @@ -60,6 +60,17 @@ class ScoreCalibration(Base):

calibration_metadata = Column(JSONB(none_as_null=True), nullable=True)

superseded_calibration_id = Column("replaces_id", Integer, ForeignKey("score_calibrations.id"), index=True, nullable=True)
superseded_calibration: Mapped[Optional["ScoreCalibration"]] = relationship(
"ScoreCalibration",
uselist=False,
foreign_keys="ScoreCalibration.superseded_calibration_id",
remote_side=[id],
)
superseding_calibration: Mapped[Optional["ScoreCalibration"]] = relationship(

@bencap bencap Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

superseding_calibration is declared uselist=False, but replaces_id only gets a non-unique index in the migration, so nothing stops two calibrations pointing at the same original. When that happens SQLAlchemy emits Multiple rows returned with uselist=False and picks one of them arbitrarily, which makes the visibility filter in get_score_calibrations_for_score_set nondeterministic: the superseded calibration appears or disappears between requests depending on which superseding row got loaded.

We should use the behavior you implemented for score sets in #706.

"ScoreCalibration", uselist=False, back_populates="superseded_calibration"
)

created_by_id = Column(Integer, ForeignKey("users.id"), index=True, nullable=False)
created_by: Mapped["User"] = relationship("User", foreign_keys="ScoreCalibration.created_by_id")
modified_by_id = Column(Integer, ForeignKey("users.id"), index=True, nullable=False)
Expand Down
58 changes: 52 additions & 6 deletions src/mavedb/routers/score_calibrations.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import logging
from typing import Optional
from typing import Any, Optional

from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from sqlalchemy.orm import Session, selectinload
Expand All @@ -21,9 +21,10 @@
modify_score_calibration,
promote_score_calibration_to_primary,
publish_score_calibration,
search_score_calibrations as _search_score_calibrations,
variant_classification_df_to_dict,
)
from mavedb.lib.score_sets import csv_data_to_df
from mavedb.lib.score_sets import csv_data_to_df, enrich_score_set_with_num_score_calibrations
from mavedb.lib.types.authentication import UserData
from mavedb.lib.validation.constants.general import calibration_class_column_name, calibration_variant_column_name
from mavedb.lib.validation.dataframe.calibration import validate_and_standardize_calibration_classes_dataframe
Expand All @@ -33,6 +34,7 @@
from mavedb.models.score_set import ScoreSet
from mavedb.routers.shared import ACCESS_CONTROL_ERROR_RESPONSES, PUBLIC_ERROR_RESPONSES
from mavedb.view_models import score_calibration
from mavedb.view_models.search import ScoreCalibrationsSearch, ScoreCalibrationsSearchResponse

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -132,13 +134,25 @@ async def get_score_calibrations_for_score_set(
calibrations = (
db.query(ScoreCalibration)
.filter(ScoreCalibration.score_set_id == score_set.id)
.filter(~ScoreCalibration.superseding_calibration.has(ScoreCalibration.private.is_(False)))
.options(selectinload(ScoreCalibration.score_set).selectinload(ScoreSet.contributors))
.all()
)

permitted_calibrations = [
visible_calibrations = [
calibration for calibration in calibrations if has_permission(user_data, calibration, Action.READ).permitted
]

superseded_ids = [sc.superseded_calibration_id for sc in visible_calibrations if
sc.superseded_calibration_id is not None]

permitted_calibrations = [sc for sc in visible_calibrations if sc.id not in superseded_ids]

# Solve Pydantic model validation error
for sc in permitted_calibrations:
sc.superseded_calibration = None
sc.superseding_calibration = None

if not permitted_calibrations:
logger.debug("No score calibrations found for the requested score set", extra=logging_context())
raise HTTPException(status_code=404, detail="No score calibrations found for the requested score set")
Expand Down Expand Up @@ -338,9 +352,12 @@ async def create_score_calibration_route(
detail=[{"loc": [e.custom_loc or "classesFile"], "msg": str(e), "type": "value_error"}],
)

created_calibration = await create_score_calibration_in_score_set(
db, calibration, user_data.user, variant_classes if classes_file else None
)
try:
created_calibration = await create_score_calibration_in_score_set(
db, calibration, user_data, variant_classes if classes_file else None
)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))

db.commit()
db.refresh(created_calibration)
Expand Down Expand Up @@ -598,6 +615,10 @@ async def promote_score_calibration_to_primary_route(
logger.debug("Private score calibrations cannot be promoted to primary", extra=logging_context())
raise HTTPException(status_code=400, detail="Private score calibrations cannot be promoted to primary")

if item.superseding_calibration:
logger.debug("Superseded score calibrations cannot be promoted to primary", extra=logging_context())
raise HTTPException(status_code=400, detail="Superseded score calibrations cannot be promoted to primary")

# We've already checked whether the item matching the calibration URN is primary, so this
# will necessarily be a different calibration, if it exists.
existing_primary_calibration = next((c for c in item.score_set.score_calibrations if c.primary), None)
Expand Down Expand Up @@ -708,6 +729,31 @@ def publish_score_calibration_route(
return item


@router.post(
"/me/search",
status_code=200,
summary="Search my calibrations",
responses={**ACCESS_CONTROL_ERROR_RESPONSES},
response_model=ScoreCalibrationsSearchResponse,
)
def search_my_score_calibrations(
search: ScoreCalibrationsSearch,
db: Session = Depends(deps.get_db),
user_data: UserData = Depends(require_current_user),
) -> Any:
"""
Search calibrations created by the current user.
"""
score_calibrations, num_score_calibrations = _search_score_calibrations(db, user_data.user, search).values()
enriched_score_calibrations = []
for sc in score_calibrations:
enriched_score_calibration = enrich_score_set_with_num_score_calibrations(sc.score_set, user_data)
response_item = score_calibration.ScoreCalibration.model_validate(sc).copy(update={"score_calibration": enriched_score_calibration})
enriched_score_calibrations.append(response_item)

return {"score_calibrations": enriched_score_calibrations, "num_score_calibrations": num_score_calibrations}


@router.get(
"/{urn}/functional-classifications/{classification_id}/variants",
response_model=score_calibration.FunctionalClassificationVariants,
Expand Down
15 changes: 13 additions & 2 deletions src/mavedb/routers/score_sets.py
Original file line number Diff line number Diff line change
Expand Up @@ -604,7 +604,18 @@ async def fetch_score_set_by_urn(
if item.superseding_score_set and not has_permission(user, item.superseding_score_set, Action.READ).permitted:
item.superseding_score_set = None

item.score_calibrations = [sc for sc in item.score_calibrations if has_permission(user, sc, Action.READ).permitted]
visible_calibrations = [sc for sc in item.score_calibrations if has_permission(user, sc, Action.READ).permitted]

superseded_ids = [sc.superseded_calibration_id for sc in visible_calibrations if sc.superseded_calibration_id is not None]

available_calibrations = [sc for sc in visible_calibrations if sc.id not in superseded_ids]

# Solve Pydantic model validation error
for sc in available_calibrations:
sc.superseded_calibration = None

@bencap bencap Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are always null in the response now, for every caller including admins. Same loop in get_score_calibrations_for_score_set, around line 151. They're the two fields #721 adds so consumers can follow the deprecation chain, so that part of the API isn't observable. That is probably why the front end half is editor-only, since there's nothing arriving for a deprecation notice to render from.

Assigning to superseding_calibration also isn't a serialization no-op, and this is an idiom that exists in the code base but I'd like to start avoiding going forward. It's the back_populates reverse side of the many-to-one, so None detaches the other calibration and marks that row's replaces_id to be nulled. Essentially if we called .commit() here, we'd erase the calibrations from the database on accident even though our intention is to just hide them from the API response.

What's the Pydantic error the comment refers to? If it's ShorterScoreCalibration failing to validate against the ORM object I'd rather fix it there than drop the data.

sc.superseding_calibration = None

item.score_calibrations = available_calibrations

return item

Expand Down Expand Up @@ -1703,7 +1714,7 @@ async def create_score_set(
)

created_calibration_item = await create_score_calibration(
db, calibration_create, user_data.user, variant_classes=None
db, calibration_create, user_data, variant_classes=None
)
created_calibration_item.investigator_provided = True # necessarily true on score set creation
score_calibrations.append(created_calibration_item)
Expand Down
4 changes: 3 additions & 1 deletion src/mavedb/scripts/load_calibration_csv.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
from mavedb.lib.acmg import ACMGCriterion, StrengthOfEvidenceProvided
from mavedb.lib.oddspaths import oddspaths_evidence_strength_equivalent
from mavedb.lib.score_calibrations import create_score_calibration_in_score_set
from mavedb.lib.types.authentication import UserData
from mavedb.models import score_calibration
from mavedb.models.enums.functional_classification import FunctionalClassification as FunctionalClassifcationOptions
from mavedb.models.score_set import ScoreSet
Expand Down Expand Up @@ -414,8 +415,9 @@ def main(db: Session, csv_path: str, delimiter: str, overwrite: bool, purge_publ

system_user = db.query(User).filter(User.id == 1).one()
calibration_user = score_set.created_by if calibration_is_investigator_provided else system_user
calibration_user_data = UserData(calibration_user, calibration_user.roles)
new_calibration_object = asyncio.run(
create_score_calibration_in_score_set(db, created_score_calibration, calibration_user)
create_score_calibration_in_score_set(db, created_score_calibration, calibration_user_data)
)
new_calibration_object.primary = primary
new_calibration_object.private = False
Expand Down
4 changes: 3 additions & 1 deletion src/mavedb/scripts/load_excalibr_calibrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
from sqlalchemy.orm import Session

from mavedb.lib.score_calibrations import create_score_calibration_in_score_set
from mavedb.lib.types.authentication import UserData
from mavedb.models.enums.functional_classification import FunctionalClassification as FunctionalClassificationOptions
from mavedb.models.score_calibration import ScoreCalibration
from mavedb.models.score_set import ScoreSet
Expand Down Expand Up @@ -238,8 +239,9 @@ def main(db: Session, csv_path: str, dataset_map: str, overwrite: bool, remove:
method_sources=[EXCALIBR_CALIBRATION_CITATION],
)

system_user_data = UserData(system_user, system_user.roles)
new_calibration_object = asyncio.run(
create_score_calibration_in_score_set(db, score_calibration_create, system_user)
create_score_calibration_in_score_set(db, score_calibration_create, system_user_data)
)
new_calibration_object.primary = False
new_calibration_object.private = False
Expand Down
Loading
Loading