Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ coverage.xml
.vscode
*.egg-info
tmp
temp/
.DS_Store
export.tar
files.txt
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Add label to derivation and circuit_customization/emodel_circuit types

Revision ID: b8ebf9a4e3da
Revises: c8cdf20bbb0d
Create Date: 2026-05-08 09:52:41.175792

"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
from alembic_postgresql_enum import TableReference

from sqlalchemy import Text
import app.db.types

# revision identifiers, used by Alembic.
revision: str = "b8ebf9a4e3da"
down_revision: Union[str, None] = "c8cdf20bbb0d"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("derivation", sa.Column("label", sa.String(), nullable=True))
op.sync_enum_values(
enum_schema="public",
enum_name="derivationtype",
new_values=[
"circuit_customization",
"circuit_extraction",
"circuit_rewiring",
"emodel_circuit",
"unspecified",
],
affected_columns=[
TableReference(
table_schema="public", table_name="derivation", column_name="derivation_type"
)
],
enum_values_to_rename=[],
)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.sync_enum_values(
enum_schema="public",
enum_name="derivationtype",
new_values=["circuit_extraction", "circuit_rewiring", "unspecified"],
affected_columns=[
TableReference(
table_schema="public", table_name="derivation", column_name="derivation_type"
)
],
enum_values_to_rename=[],
)
op.drop_column("derivation", "label")
# ### end Alembic commands ###
1 change: 1 addition & 0 deletions app/db/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -1671,6 +1671,7 @@ class Derivation(Base):
used: Mapped["Entity"] = relationship(foreign_keys=[used_id])
generated: Mapped["Entity"] = relationship(foreign_keys=[generated_id], passive_deletes=True)
derivation_type: Mapped[DerivationType]
label: Mapped[str | None]


class ScientificArtifactPublicationLink(Identifiable):
Expand Down
10 changes: 10 additions & 0 deletions app/db/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,15 +248,25 @@ class DerivationType(StrEnum):
"""Represents the type of derivation relationship between two entities.

Attributes:
circuit_customization: Indicates that a circuit entity was derived from another circuit by
customizing certain components. The optional ``label`` field specifies the type of
customization, such as ``synaptic_modification``, ``emodel_addition``,
``emodel_modification``, ``population_modification``.
circuit_extraction: Indicates that the entity was derived by extracting a set of nodes from
a circuit.
circuit_rewiring: Indicates that the entity was derived by rewiring the connectivity of
a circuit.
emodel_circuit: Indicates that an emodel (used) was assigned to neurons of a circuit
(generated). The optional ``label`` field on the derivation carries the SONATA
``model_template`` entry, by convention ``hoc:<template_name>``
(e.g. ``hoc:cADpyr_L5TPC``, ``hoc:bAC_L23BC``).
unspecified: Indicates a derivation that does not require a specific type.
"""

circuit_customization = auto()
circuit_extraction = auto()
circuit_rewiring = auto()
emodel_circuit = auto()
unspecified = auto()


Expand Down
42 changes: 41 additions & 1 deletion app/schemas/derivation.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
import re
import uuid

from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, model_validator

from app.db.types import DerivationType
from app.schemas.base import BasicEntityRead

# Allowed label values per derivation type.
# - emodel_circuit: SONATA ``model_template`` entry, by convention ``hoc:<template_name>``.
# - circuit_customization: type of customization applied to the source circuit.
_HOC_TEMPLATE_RE = re.compile(r"^hoc:[A-Za-z0-9_]+$")
_CIRCUIT_CUSTOMIZATION_LABELS = frozenset(
{
"synaptic_modification",
"emodel_addition",
"emodel_modification",
"population_modification",
}
)


class DerivationBase(BaseModel):
model_config = ConfigDict(from_attributes=True)
Expand All @@ -14,9 +28,35 @@ class DerivationCreate(DerivationBase):
used_id: uuid.UUID
generated_id: uuid.UUID
derivation_type: DerivationType
label: str | None = None

@model_validator(mode="after")
def label_matches_derivation_type(self):
"""Validate the label against the derivation type when provided."""
if self.label is None:
return self
if self.derivation_type == DerivationType.emodel_circuit and not _HOC_TEMPLATE_RE.fullmatch(
self.label
):
msg = (
"label for derivation_type 'emodel_circuit' must match "
"'hoc:<template_name>' (e.g. 'hoc:cADpyr_L5TPC')"
)
raise ValueError(msg)
if (
self.derivation_type == DerivationType.circuit_customization
and self.label not in _CIRCUIT_CUSTOMIZATION_LABELS
):
msg = (
"label for derivation_type 'circuit_customization' must be one of "
f"{sorted(_CIRCUIT_CUSTOMIZATION_LABELS)}"
)
raise ValueError(msg)
return self


class DerivationRead(DerivationBase):
used: BasicEntityRead
generated: BasicEntityRead
derivation_type: DerivationType
label: str | None = None
65 changes: 65 additions & 0 deletions app/service/derivation.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
"""Generic derivation service."""

import uuid
from http import HTTPStatus

import sqlalchemy as sa
from sqlalchemy import and_
from sqlalchemy.orm import aliased, joinedload, raiseload

from app.db.model import Derivation, DerivationType, Entity
from app.db.types import EntityType
from app.db.utils import ENTITY_TYPE_TO_CLASS, load_db_model_from_pydantic
from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep
from app.dependencies.common import DerivationQueryDep, PaginationQuery
from app.dependencies.db import SessionDep
from app.errors import (
ApiError,
ApiErrorCode,
ensure_authorized_references,
ensure_foreign_keys_integrity,
ensure_result,
Expand All @@ -27,6 +31,66 @@
from app.schemas.types import ListResponse
from app.utils.routers import entity_route_to_type

# Allowed entity types per derivation_type for (used, generated).
# A value of ``None`` means "no type-specific check" (any entity allowed).
# ``unspecified`` is intentionally unconstrained: it is currently used as a
# placeholder for emodel/memodel derivations and will be replaced by dedicated
# derivation types later.
_ALLOWED_ENTITY_TYPES: dict[
DerivationType, tuple[frozenset[EntityType] | None, frozenset[EntityType] | None]
] = {
DerivationType.circuit_extraction: (
frozenset({EntityType.circuit}),
frozenset({EntityType.circuit}),
),
DerivationType.circuit_rewiring: (
frozenset({EntityType.circuit}),
frozenset({EntityType.circuit}),
),
DerivationType.circuit_customization: (
frozenset({EntityType.circuit}),
frozenset({EntityType.circuit}),
),
DerivationType.emodel_circuit: (
frozenset({EntityType.emodel}),
frozenset({EntityType.circuit}),
),
DerivationType.unspecified: (None, None),
}


def _validate_entity_types(
derivation_type: DerivationType,
used_entity: Entity,
generated_entity: Entity,
) -> None:
"""Validate that the used/generated entity types match the derivation_type.

Raises:
ApiError: with ``INVALID_REQUEST`` (HTTP 422) if the types do not match.
"""
allowed_used, allowed_generated = _ALLOWED_ENTITY_TYPES[derivation_type]
if allowed_used is not None and used_entity.type not in allowed_used:
raise ApiError(
message=(
f"derivation_type '{derivation_type.value}' requires used entity type "
f"to be one of {sorted(t.value for t in allowed_used)}, "
f"got '{used_entity.type.value}'"
),
error_code=ApiErrorCode.INVALID_REQUEST,
http_status_code=HTTPStatus.UNPROCESSABLE_ENTITY,
)
if allowed_generated is not None and generated_entity.type not in allowed_generated:
raise ApiError(
message=(
f"derivation_type '{derivation_type.value}' requires generated entity type "
f"to be one of {sorted(t.value for t in allowed_generated)}, "
f"got '{generated_entity.type.value}'"
),
error_code=ApiErrorCode.INVALID_REQUEST,
http_status_code=HTTPStatus.UNPROCESSABLE_ENTITY,
)


def _read_many(
*,
Expand Down Expand Up @@ -158,6 +222,7 @@ def create_one(
json_model.generated_id,
user_context.project_id,
)
_validate_entity_types(json_model.derivation_type, used_entity, generated_entity)
db_model_class = Derivation
db_model_instance = load_db_model_from_pydantic(
json_model=json_model,
Expand Down
4 changes: 2 additions & 2 deletions scripts/export/build_database_archive.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Automatically generated, do not edit!
set -euo pipefail
SCRIPT_VERSION="1"
SCRIPT_DB_VERSION="c8cdf20bbb0d"
SCRIPT_DB_VERSION="b8ebf9a4e3da"
echo "DB dump (version $SCRIPT_VERSION for db version $SCRIPT_DB_VERSION)"


Expand Down Expand Up @@ -271,7 +271,7 @@ install -m 755 /dev/stdin "$WORK_DIR/load.sh" <<'EOF_LOAD_SCRIPT'
# Automatically generated, do not edit!
set -euo pipefail
SCRIPT_VERSION="1"
SCRIPT_DB_VERSION="c8cdf20bbb0d"
SCRIPT_DB_VERSION="b8ebf9a4e3da"
echo "DB load (version $SCRIPT_VERSION for db version $SCRIPT_DB_VERSION)"


Expand Down
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -1289,7 +1289,7 @@ def root_circuit_json_data(brain_atlas_id, subject_id, brain_region_id, license_
"subject_id": str(subject_id),
"build_category": "em_reconstruction",
"authorized_project_id": PROJECT_ID,
"authorized_public": True,
"authorized_public": False,
"created_by_id": str(person_id),
"updated_by_id": str(person_id),
"brain_region_id": str(brain_region_id),
Expand Down
Loading
Loading