3027 lines
117 KiB
Python
3027 lines
117 KiB
Python
"""Strict data models shared by the versioned PlantAI workflow layers.
|
|||
|
|
|
||
|
|
This module intentionally contains no workflow transitions, persistence, file
|
||
|
|
access, or route handling. It only defines immutable, fail-closed data shapes.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import hashlib
|
||
|
|
import json
|
||
|
|
import re
|
||
|
|
from collections.abc import Mapping
|
||
|
|
from datetime import datetime
|
||
|
|
from enum import Enum
|
||
|
|
from pathlib import PurePosixPath
|
||
|
|
from types import MappingProxyType
|
||
|
|
from typing import Annotated, Any, Literal
|
||
|
|
from uuid import UUID
|
||
|
|
|
||
|
|
from pydantic import (
|
||
|
|
BaseModel,
|
||
|
|
ConfigDict,
|
||
|
|
Field,
|
||
|
|
JsonValue,
|
||
|
|
StrictBool,
|
||
|
|
StrictInt,
|
||
|
|
StringConstraints,
|
||
|
|
field_validator,
|
||
|
|
field_serializer,
|
||
|
|
model_validator,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||
|
|
_IDENTITY_RE = r"^[A-Za-z0-9][A-Za-z0-9._:-]*$"
|
||
|
|
|
||
|
|
SpeciesCode = Annotated[
|
||
|
|
str,
|
||
|
|
StringConstraints(strip_whitespace=True, min_length=1, pattern=r"^[A-Za-z][A-Za-z0-9._-]*$"),
|
||
|
|
]
|
||
|
|
GeneFamily = Annotated[
|
||
|
|
str,
|
||
|
|
StringConstraints(strip_whitespace=True, min_length=1, pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]*$"),
|
||
|
|
]
|
||
|
|
WorkflowIdentity = Annotated[
|
||
|
|
str,
|
||
|
|
StringConstraints(strip_whitespace=True, min_length=1, pattern=_IDENTITY_RE),
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
def _canonical_json(value: Any) -> str:
|
||
|
|
return json.dumps(
|
||
|
|
value,
|
||
|
|
ensure_ascii=False,
|
||
|
|
sort_keys=True,
|
||
|
|
separators=(",", ":"),
|
||
|
|
allow_nan=False,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _digest(value: Any) -> str:
|
||
|
|
return hashlib.sha256(_canonical_json(value).encode("utf-8")).hexdigest()
|
||
|
|
|
||
|
|
|
||
|
|
def _require_nonblank(value: str, field_name: str) -> None:
|
||
|
|
if not value.strip():
|
||
|
|
raise ValueError(f"{field_name} must be nonblank")
|
||
|
|
|
||
|
|
|
||
|
|
def _require_sha256(value: str, field_name: str) -> None:
|
||
|
|
if not _SHA256_RE.fullmatch(value):
|
||
|
|
raise ValueError(f"{field_name} must be a lowercase SHA-256 digest")
|
||
|
|
|
||
|
|
|
||
|
|
def _validate_projection_identity(
|
||
|
|
scope_id: str,
|
||
|
|
analysis_route: str,
|
||
|
|
member_set_id: str,
|
||
|
|
*,
|
||
|
|
member_route: str | None = None,
|
||
|
|
) -> bool:
|
||
|
|
triplet = (scope_id, analysis_route, member_set_id)
|
||
|
|
if any(triplet) and not all(triplet):
|
||
|
|
raise ValueError("a complete family projection triplet is required")
|
||
|
|
if not all(triplet):
|
||
|
|
return False
|
||
|
|
expected_route = member_route or analysis_route
|
||
|
|
prefix = f"{scope_id}:{expected_route}:"
|
||
|
|
if not member_set_id.startswith(prefix) or not member_set_id.removeprefix(prefix):
|
||
|
|
raise ValueError(f"member_set_id prefix must be {prefix} with a nonempty suffix")
|
||
|
|
return True
|
||
|
|
|
||
|
|
|
||
|
|
def _require_aware(value: datetime, field_name: str) -> None:
|
||
|
|
if value.tzinfo is None or value.utcoffset() is None:
|
||
|
|
raise ValueError(f"{field_name} must include a timezone")
|
||
|
|
|
||
|
|
|
||
|
|
def _freeze_json(value: JsonValue) -> Any:
|
||
|
|
if isinstance(value, dict):
|
||
|
|
return MappingProxyType({key: _freeze_json(item) for key, item in value.items()})
|
||
|
|
if isinstance(value, list):
|
||
|
|
return tuple(_freeze_json(item) for item in value)
|
||
|
|
return value
|
||
|
|
|
||
|
|
|
||
|
|
def _thaw_json(value: Any) -> JsonValue:
|
||
|
|
if isinstance(value, Mapping):
|
||
|
|
return {str(key): _thaw_json(item) for key, item in value.items()}
|
||
|
|
if isinstance(value, tuple):
|
||
|
|
return [_thaw_json(item) for item in value]
|
||
|
|
return value
|
||
|
|
|
||
|
|
|
||
|
|
def _require_job_relative_path(value: str, field_name: str) -> None:
|
||
|
|
path = PurePosixPath(value)
|
||
|
|
if (
|
||
|
|
not value
|
||
|
|
or "\\" in value
|
||
|
|
or path.is_absolute()
|
||
|
|
or any(part in {"", ".", ".."} for part in value.split("/"))
|
||
|
|
or path.as_posix() != value
|
||
|
|
):
|
||
|
|
raise ValueError(f"{field_name} must be a normalized job-relative path")
|
||
|
|
|
||
|
|
|
||
|
|
def _require_absolute_canonical_path(value: str, field_name: str) -> PurePosixPath:
|
||
|
|
path = PurePosixPath(value)
|
||
|
|
if (
|
||
|
|
not path.is_absolute()
|
||
|
|
or any(part in {"", ".", ".."} for part in value.split("/")[1:])
|
||
|
|
or path.as_posix() != value
|
||
|
|
):
|
||
|
|
raise ValueError(f"{field_name} must use absolute canonical text")
|
||
|
|
return path
|
||
|
|
|
||
|
|
|
||
|
|
class _WorkflowModel(BaseModel):
|
||
|
|
model_config = ConfigDict(
|
||
|
|
extra="forbid",
|
||
|
|
frozen=True,
|
||
|
|
str_strip_whitespace=True,
|
||
|
|
)
|
||
|
|
|
||
|
|
def model_copy(
|
||
|
|
self,
|
||
|
|
*,
|
||
|
|
update: Mapping[str, Any] | None = None,
|
||
|
|
deep: bool = False,
|
||
|
|
) -> "_WorkflowModel":
|
||
|
|
if not update:
|
||
|
|
return super().model_copy(deep=deep)
|
||
|
|
data = self.model_dump(mode="python", round_trip=True)
|
||
|
|
data.update(dict(update))
|
||
|
|
return type(self).model_validate(data)
|
||
|
|
|
||
|
|
|
||
|
|
class RNARoute(str, Enum):
|
||
|
|
WHOLE_GENOME = "whole_genome"
|
||
|
|
FAMILY_MEMBER = "family_member"
|
||
|
|
|
||
|
|
|
||
|
|
class RNAForegroundMode(str, Enum):
|
||
|
|
GLOBAL = "global"
|
||
|
|
FAMILY = "family"
|
||
|
|
|
||
|
|
|
||
|
|
class RNAFactKind(str, Enum):
|
||
|
|
EXPERIMENT_INPUT = "rna_experiment_input"
|
||
|
|
ALIGNMENT_STRANDNESS = "rna_alignment_strandness"
|
||
|
|
DE_DESIGN = "rna_de_design"
|
||
|
|
GENOME_RESOURCE = "rna_genome_resource"
|
||
|
|
|
||
|
|
|
||
|
|
class RNALibraryStrandness(str, Enum):
|
||
|
|
NON_STRANDED = "non_stranded"
|
||
|
|
FORWARD = "forward"
|
||
|
|
REVERSE = "reverse"
|
||
|
|
|
||
|
|
|
||
|
|
class RNAStrandnessPolicy(str, Enum):
|
||
|
|
ALL_SAMPLES_EXPLICIT = "all_samples_explicit"
|
||
|
|
PROJECT_DEFAULT = "project_default"
|
||
|
|
MIXED_EXPLICIT_PROJECT_DEFAULT = "mixed_explicit_project_default"
|
||
|
|
|
||
|
|
|
||
|
|
class RNAExperimentDesign(str, Enum):
|
||
|
|
TWO_GROUP = "two_group"
|
||
|
|
MULTI_GROUP = "multi_group"
|
||
|
|
TIME_SERIES = "time_series"
|
||
|
|
|
||
|
|
|
||
|
|
class RNADEComparisonMode(str, Enum):
|
||
|
|
CONFIGURED = "configured"
|
||
|
|
GROUP_ROLE_CONTROL = "group_role_control"
|
||
|
|
|
||
|
|
|
||
|
|
class RNAReplicateViolationPolicy(str, Enum):
|
||
|
|
WARN = "warn"
|
||
|
|
ERROR = "error"
|
||
|
|
|
||
|
|
|
||
|
|
class RNADependencyKind(str, Enum):
|
||
|
|
QC_INPUT = "rna_qc_input"
|
||
|
|
ALIGNMENT_INPUT = "rna_alignment_input"
|
||
|
|
QUANTIFICATION_INPUT = "rna_quantification_input"
|
||
|
|
SAMPLE_QC_INPUT = "rna_sample_qc_input"
|
||
|
|
DE_INPUT = "rna_de_input"
|
||
|
|
FAMILY_PROJECTION = "family_projection"
|
||
|
|
|
||
|
|
|
||
|
|
_RNA_STAGE_DEPENDENCY_KIND = {
|
||
|
|
"rnaseq_qc": RNADependencyKind.QC_INPUT,
|
||
|
|
"rnaseq_align": RNADependencyKind.ALIGNMENT_INPUT,
|
||
|
|
"rnaseq_quant": RNADependencyKind.QUANTIFICATION_INPUT,
|
||
|
|
"rnaseq_sample_qc": RNADependencyKind.SAMPLE_QC_INPUT,
|
||
|
|
"rnaseq_de": RNADependencyKind.DE_INPUT,
|
||
|
|
"rnaseq_family_projection": RNADependencyKind.FAMILY_PROJECTION,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def _rna_fact_evidence_id(
|
||
|
|
*,
|
||
|
|
job_id: str,
|
||
|
|
species: str,
|
||
|
|
rnaseq_scope_id: str,
|
||
|
|
fact_kind: RNAFactKind,
|
||
|
|
schema_version: str,
|
||
|
|
digest: str,
|
||
|
|
) -> str:
|
||
|
|
identity = _digest(
|
||
|
|
{
|
||
|
|
"job_id": job_id,
|
||
|
|
"species": species,
|
||
|
|
"rnaseq_scope_id": rnaseq_scope_id,
|
||
|
|
"fact_kind": fact_kind.value,
|
||
|
|
"schema_version": schema_version,
|
||
|
|
"digest": digest,
|
||
|
|
}
|
||
|
|
)
|
||
|
|
return f"rna_fact:{identity}"
|
||
|
|
|
||
|
|
|
||
|
|
class RNAReadIdentity(_WorkflowModel):
|
||
|
|
read_identity_version: Literal["rna_read_identity_v1"] = "rna_read_identity_v1"
|
||
|
|
sample_id: WorkflowIdentity
|
||
|
|
read_end: Literal["r1", "r2"]
|
||
|
|
path: str
|
||
|
|
cache_key: str
|
||
|
|
content_sha256: str
|
||
|
|
|
||
|
|
@model_validator(mode="after")
|
||
|
|
def validate_read_identity(self) -> "RNAReadIdentity":
|
||
|
|
_require_job_relative_path(self.path, "path")
|
||
|
|
_require_sha256(self.cache_key, "cache_key")
|
||
|
|
_require_sha256(self.content_sha256, "content_sha256")
|
||
|
|
return self
|
||
|
|
|
||
|
|
|
||
|
|
class RNAArtifactIdentity(_WorkflowModel):
|
||
|
|
artifact_identity_version: Literal["rna_artifact_identity_v1"] = (
|
||
|
|
"rna_artifact_identity_v1"
|
||
|
|
)
|
||
|
|
path: str
|
||
|
|
cache_key: str
|
||
|
|
content_sha256: str
|
||
|
|
|
||
|
|
@model_validator(mode="after")
|
||
|
|
def validate_artifact_identity(self) -> "RNAArtifactIdentity":
|
||
|
|
_require_job_relative_path(self.path, "path")
|
||
|
|
_require_sha256(self.cache_key, "cache_key")
|
||
|
|
_require_sha256(self.content_sha256, "content_sha256")
|
||
|
|
return self
|
||
|
|
|
||
|
|
|
||
|
|
class RNAExperimentInputSample(_WorkflowModel):
|
||
|
|
sample_id: WorkflowIdentity
|
||
|
|
run: WorkflowIdentity
|
||
|
|
r1: RNAReadIdentity
|
||
|
|
r2: RNAReadIdentity
|
||
|
|
condition: str = Field(min_length=1, max_length=256)
|
||
|
|
time: str = Field(max_length=256)
|
||
|
|
replicate: str = Field(min_length=1, max_length=128)
|
||
|
|
|
||
|
|
@model_validator(mode="after")
|
||
|
|
def validate_read_binding(self) -> "RNAExperimentInputSample":
|
||
|
|
for expected_end, identity in (("r1", self.r1), ("r2", self.r2)):
|
||
|
|
if identity.sample_id != self.sample_id:
|
||
|
|
raise ValueError("read identity sample must match experiment sample")
|
||
|
|
if identity.read_end != expected_end:
|
||
|
|
raise ValueError("read identity mate must match r1/r2 position")
|
||
|
|
if self.r1.path == self.r2.path:
|
||
|
|
raise ValueError("paired reads must use distinct paths")
|
||
|
|
return self
|
||
|
|
|
||
|
|
|
||
|
|
class RNAExperimentInputFact(_WorkflowModel):
|
||
|
|
fact_kind: Literal[RNAFactKind.EXPERIMENT_INPUT] = (
|
||
|
|
RNAFactKind.EXPERIMENT_INPUT
|
||
|
|
)
|
||
|
|
schema_version: Literal["rna_experiment_input_v1"] = (
|
||
|
|
"rna_experiment_input_v1"
|
||
|
|
)
|
||
|
|
job_id: str
|
||
|
|
species: SpeciesCode
|
||
|
|
samples: tuple[RNAExperimentInputSample, ...] = Field(min_length=1)
|
||
|
|
rnaseq_scope_id: WorkflowIdentity
|
||
|
|
source_attestation_sha256: str
|
||
|
|
digest: str
|
||
|
|
evidence_id: str
|
||
|
|
|
||
|
|
@field_validator("samples")
|
||
|
|
@classmethod
|
||
|
|
def sort_samples(
|
||
|
|
cls, value: tuple[RNAExperimentInputSample, ...]
|
||
|
|
) -> tuple[RNAExperimentInputSample, ...]:
|
||
|
|
return tuple(sorted(value, key=lambda item: item.sample_id))
|
||
|
|
|
||
|
|
def canonical_payload(self) -> dict[str, Any]:
|
||
|
|
return {
|
||
|
|
"fact_kind": self.fact_kind.value,
|
||
|
|
"schema_version": self.schema_version,
|
||
|
|
"species": self.species,
|
||
|
|
"samples": [item.model_dump(mode="json") for item in self.samples],
|
||
|
|
}
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def build(
|
||
|
|
cls,
|
||
|
|
*,
|
||
|
|
job_id: str,
|
||
|
|
species: str,
|
||
|
|
samples: tuple[RNAExperimentInputSample, ...],
|
||
|
|
source_attestation_sha256: str,
|
||
|
|
) -> "RNAExperimentInputFact":
|
||
|
|
ordered = tuple(sorted(samples, key=lambda item: item.sample_id))
|
||
|
|
canonical = {
|
||
|
|
"fact_kind": RNAFactKind.EXPERIMENT_INPUT.value,
|
||
|
|
"schema_version": "rna_experiment_input_v1",
|
||
|
|
"species": species,
|
||
|
|
"samples": [item.model_dump(mode="json") for item in ordered],
|
||
|
|
}
|
||
|
|
digest = _digest(canonical)
|
||
|
|
rnaseq_scope_id = f"{species}.rnaseq.{digest}"
|
||
|
|
return cls(
|
||
|
|
job_id=job_id,
|
||
|
|
species=species,
|
||
|
|
samples=ordered,
|
||
|
|
rnaseq_scope_id=rnaseq_scope_id,
|
||
|
|
source_attestation_sha256=source_attestation_sha256,
|
||
|
|
digest=digest,
|
||
|
|
evidence_id=_rna_fact_evidence_id(
|
||
|
|
job_id=job_id,
|
||
|
|
species=species,
|
||
|
|
rnaseq_scope_id=rnaseq_scope_id,
|
||
|
|
fact_kind=RNAFactKind.EXPERIMENT_INPUT,
|
||
|
|
schema_version="rna_experiment_input_v1",
|
||
|
|
digest=digest,
|
||
|
|
),
|
||
|
|
)
|
||
|
|
|
||
|
|
@model_validator(mode="after")
|
||
|
|
def validate_fact(self) -> "RNAExperimentInputFact":
|
||
|
|
_require_nonblank(self.job_id, "job_id")
|
||
|
|
_require_sha256(
|
||
|
|
self.source_attestation_sha256, "source_attestation_sha256"
|
||
|
|
)
|
||
|
|
_require_sha256(self.digest, "digest")
|
||
|
|
sample_ids = [item.sample_id for item in self.samples]
|
||
|
|
if len(sample_ids) != len(set(sample_ids)):
|
||
|
|
raise ValueError("experiment input sample IDs must be unique")
|
||
|
|
expected_digest = _digest(self.canonical_payload())
|
||
|
|
if self.digest != expected_digest:
|
||
|
|
raise ValueError("digest does not match canonical experiment input")
|
||
|
|
expected_scope = f"{self.species}.rnaseq.{expected_digest}"
|
||
|
|
if self.rnaseq_scope_id != expected_scope:
|
||
|
|
raise ValueError("rnaseq_scope_id must derive from complete experiment input")
|
||
|
|
expected_evidence_id = _rna_fact_evidence_id(
|
||
|
|
job_id=self.job_id,
|
||
|
|
species=self.species,
|
||
|
|
rnaseq_scope_id=self.rnaseq_scope_id,
|
||
|
|
fact_kind=RNAFactKind.EXPERIMENT_INPUT,
|
||
|
|
schema_version=self.schema_version,
|
||
|
|
digest=self.digest,
|
||
|
|
)
|
||
|
|
if self.evidence_id != expected_evidence_id:
|
||
|
|
raise ValueError("evidence_id does not match experiment input fact")
|
||
|
|
return self
|
||
|
|
|
||
|
|
|
||
|
|
class RNAStrandnessSample(_WorkflowModel):
|
||
|
|
sample_id: WorkflowIdentity
|
||
|
|
strandness: RNALibraryStrandness
|
||
|
|
|
||
|
|
|
||
|
|
class RNAAlignmentStrandnessFact(_WorkflowModel):
|
||
|
|
fact_kind: Literal[RNAFactKind.ALIGNMENT_STRANDNESS] = (
|
||
|
|
RNAFactKind.ALIGNMENT_STRANDNESS
|
||
|
|
)
|
||
|
|
schema_version: Literal["rna_alignment_strandness_v1"] = (
|
||
|
|
"rna_alignment_strandness_v1"
|
||
|
|
)
|
||
|
|
job_id: str
|
||
|
|
species: SpeciesCode
|
||
|
|
rnaseq_scope_id: WorkflowIdentity
|
||
|
|
samples: tuple[RNAStrandnessSample, ...] = Field(min_length=1)
|
||
|
|
policy: RNAStrandnessPolicy
|
||
|
|
mixed: StrictBool
|
||
|
|
source_attestation_sha256: str
|
||
|
|
digest: str
|
||
|
|
evidence_id: str
|
||
|
|
|
||
|
|
@field_validator("samples")
|
||
|
|
@classmethod
|
||
|
|
def sort_samples(
|
||
|
|
cls, value: tuple[RNAStrandnessSample, ...]
|
||
|
|
) -> tuple[RNAStrandnessSample, ...]:
|
||
|
|
return tuple(sorted(value, key=lambda item: item.sample_id))
|
||
|
|
|
||
|
|
def canonical_payload(self) -> dict[str, Any]:
|
||
|
|
return {
|
||
|
|
"fact_kind": self.fact_kind.value,
|
||
|
|
"schema_version": self.schema_version,
|
||
|
|
"species": self.species,
|
||
|
|
"rnaseq_scope_id": self.rnaseq_scope_id,
|
||
|
|
"samples": [item.model_dump(mode="json") for item in self.samples],
|
||
|
|
"policy": self.policy.value,
|
||
|
|
"mixed": self.mixed,
|
||
|
|
}
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def build(cls, **values: Any) -> "RNAAlignmentStrandnessFact":
|
||
|
|
payload = dict(values)
|
||
|
|
ordered = tuple(
|
||
|
|
sorted(payload.pop("samples"), key=lambda item: item.sample_id)
|
||
|
|
)
|
||
|
|
canonical = {
|
||
|
|
"fact_kind": RNAFactKind.ALIGNMENT_STRANDNESS.value,
|
||
|
|
"schema_version": "rna_alignment_strandness_v1",
|
||
|
|
"species": payload["species"],
|
||
|
|
"rnaseq_scope_id": payload["rnaseq_scope_id"],
|
||
|
|
"samples": [item.model_dump(mode="json") for item in ordered],
|
||
|
|
"policy": RNAStrandnessPolicy(payload["policy"]).value,
|
||
|
|
"mixed": payload["mixed"],
|
||
|
|
}
|
||
|
|
digest = _digest(canonical)
|
||
|
|
return cls(
|
||
|
|
**payload,
|
||
|
|
samples=ordered,
|
||
|
|
digest=digest,
|
||
|
|
evidence_id=_rna_fact_evidence_id(
|
||
|
|
job_id=payload["job_id"],
|
||
|
|
species=payload["species"],
|
||
|
|
rnaseq_scope_id=payload["rnaseq_scope_id"],
|
||
|
|
fact_kind=RNAFactKind.ALIGNMENT_STRANDNESS,
|
||
|
|
schema_version="rna_alignment_strandness_v1",
|
||
|
|
digest=digest,
|
||
|
|
),
|
||
|
|
)
|
||
|
|
|
||
|
|
@model_validator(mode="after")
|
||
|
|
def validate_fact(self) -> "RNAAlignmentStrandnessFact":
|
||
|
|
_require_nonblank(self.job_id, "job_id")
|
||
|
|
_require_sha256(
|
||
|
|
self.source_attestation_sha256, "source_attestation_sha256"
|
||
|
|
)
|
||
|
|
_require_sha256(self.digest, "digest")
|
||
|
|
sample_ids = [item.sample_id for item in self.samples]
|
||
|
|
if len(sample_ids) != len(set(sample_ids)):
|
||
|
|
raise ValueError("strandness sample IDs must be unique")
|
||
|
|
if self.mixed != (len({item.strandness for item in self.samples}) > 1):
|
||
|
|
raise ValueError("mixed must reflect normalized strandness values")
|
||
|
|
expected_digest = _digest(self.canonical_payload())
|
||
|
|
if self.digest != expected_digest:
|
||
|
|
raise ValueError("digest does not match canonical strandness")
|
||
|
|
expected_id = _rna_fact_evidence_id(
|
||
|
|
job_id=self.job_id,
|
||
|
|
species=self.species,
|
||
|
|
rnaseq_scope_id=self.rnaseq_scope_id,
|
||
|
|
fact_kind=RNAFactKind.ALIGNMENT_STRANDNESS,
|
||
|
|
schema_version=self.schema_version,
|
||
|
|
digest=self.digest,
|
||
|
|
)
|
||
|
|
if self.evidence_id != expected_id:
|
||
|
|
raise ValueError("evidence_id does not match strandness fact")
|
||
|
|
return self
|
||
|
|
|
||
|
|
|
||
|
|
class RNADEGroupAssignment(_WorkflowModel):
|
||
|
|
sample_id: WorkflowIdentity
|
||
|
|
group: str = Field(min_length=1, max_length=256)
|
||
|
|
|
||
|
|
|
||
|
|
class RNADEContrast(_WorkflowModel):
|
||
|
|
treatment: str = Field(min_length=1, max_length=256)
|
||
|
|
reference: str = Field(min_length=1, max_length=256)
|
||
|
|
|
||
|
|
@model_validator(mode="after")
|
||
|
|
def validate_contrast(self) -> "RNADEContrast":
|
||
|
|
if self.treatment == self.reference:
|
||
|
|
raise ValueError("DE contrast endpoints must differ")
|
||
|
|
return self
|
||
|
|
|
||
|
|
|
||
|
|
class RNAReplicatePolicy(_WorkflowModel):
|
||
|
|
min_reps_per_group: StrictInt = Field(ge=1)
|
||
|
|
allow_no_replicate: StrictBool
|
||
|
|
on_violation: RNAReplicateViolationPolicy
|
||
|
|
|
||
|
|
|
||
|
|
class RNADEGroupCount(_WorkflowModel):
|
||
|
|
group: str = Field(min_length=1, max_length=256)
|
||
|
|
count: StrictInt = Field(ge=1)
|
||
|
|
|
||
|
|
|
||
|
|
class RNADEGroupTime(_WorkflowModel):
|
||
|
|
group: str = Field(min_length=1, max_length=256)
|
||
|
|
time: str = Field(max_length=256)
|
||
|
|
|
||
|
|
|
||
|
|
class RNADEReplicateViolation(_WorkflowModel):
|
||
|
|
group: str = Field(min_length=1, max_length=256)
|
||
|
|
count: StrictInt = Field(ge=1)
|
||
|
|
|
||
|
|
|
||
|
|
class RNADEDesignFact(_WorkflowModel):
|
||
|
|
fact_kind: Literal[RNAFactKind.DE_DESIGN] = RNAFactKind.DE_DESIGN
|
||
|
|
schema_version: Literal["rna_de_design_v1"] = "rna_de_design_v1"
|
||
|
|
job_id: str
|
||
|
|
species: SpeciesCode
|
||
|
|
rnaseq_scope_id: WorkflowIdentity
|
||
|
|
strategy: Literal["auto"]
|
||
|
|
design: RNAExperimentDesign
|
||
|
|
comparison_mode: RNADEComparisonMode
|
||
|
|
effective_control_group: str = Field(min_length=1, max_length=256)
|
||
|
|
active_samples: tuple[WorkflowIdentity, ...] = Field(min_length=1)
|
||
|
|
ignored_samples: tuple[WorkflowIdentity, ...]
|
||
|
|
groups: tuple[RNADEGroupAssignment, ...] = Field(min_length=1)
|
||
|
|
contrasts: tuple[RNADEContrast, ...] = Field(min_length=1)
|
||
|
|
replicate_counts: tuple[RNADEGroupCount, ...] = Field(min_length=1)
|
||
|
|
replicate_policy: RNAReplicatePolicy
|
||
|
|
replicate_violations: tuple[RNADEReplicateViolation, ...]
|
||
|
|
group_times: tuple[RNADEGroupTime, ...]
|
||
|
|
time_levels: tuple[str, ...]
|
||
|
|
run_lrt: StrictBool
|
||
|
|
run_spline_lrt: StrictBool
|
||
|
|
spline_df: StrictInt = Field(ge=1)
|
||
|
|
source_attestation_sha256: str
|
||
|
|
digest: str
|
||
|
|
evidence_id: str
|
||
|
|
|
||
|
|
@field_validator("groups")
|
||
|
|
@classmethod
|
||
|
|
def sort_groups(
|
||
|
|
cls, value: tuple[RNADEGroupAssignment, ...]
|
||
|
|
) -> tuple[RNADEGroupAssignment, ...]:
|
||
|
|
return tuple(sorted(value, key=lambda item: item.sample_id))
|
||
|
|
|
||
|
|
@field_validator("contrasts")
|
||
|
|
@classmethod
|
||
|
|
def sort_contrasts(
|
||
|
|
cls, value: tuple[RNADEContrast, ...]
|
||
|
|
) -> tuple[RNADEContrast, ...]:
|
||
|
|
return tuple(
|
||
|
|
sorted(value, key=lambda item: (item.treatment, item.reference))
|
||
|
|
)
|
||
|
|
|
||
|
|
def canonical_payload(self) -> dict[str, Any]:
|
||
|
|
return {
|
||
|
|
"fact_kind": self.fact_kind.value,
|
||
|
|
"schema_version": self.schema_version,
|
||
|
|
"species": self.species,
|
||
|
|
"rnaseq_scope_id": self.rnaseq_scope_id,
|
||
|
|
"strategy": self.strategy,
|
||
|
|
"design": self.design.value,
|
||
|
|
"comparison_mode": self.comparison_mode.value,
|
||
|
|
"effective_control_group": self.effective_control_group,
|
||
|
|
"active_samples": list(self.active_samples),
|
||
|
|
"ignored_samples": list(self.ignored_samples),
|
||
|
|
"groups": [item.model_dump(mode="json") for item in self.groups],
|
||
|
|
"contrasts": [
|
||
|
|
item.model_dump(mode="json") for item in self.contrasts
|
||
|
|
],
|
||
|
|
"replicate_counts": [
|
||
|
|
item.model_dump(mode="json") for item in self.replicate_counts
|
||
|
|
],
|
||
|
|
"replicate_policy": self.replicate_policy.model_dump(mode="json"),
|
||
|
|
"replicate_violations": [
|
||
|
|
item.model_dump(mode="json") for item in self.replicate_violations
|
||
|
|
],
|
||
|
|
"group_times": [
|
||
|
|
item.model_dump(mode="json") for item in self.group_times
|
||
|
|
],
|
||
|
|
"time_levels": list(self.time_levels),
|
||
|
|
"run_lrt": self.run_lrt,
|
||
|
|
"run_spline_lrt": self.run_spline_lrt,
|
||
|
|
"spline_df": self.spline_df,
|
||
|
|
}
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def build(cls, **values: Any) -> "RNADEDesignFact":
|
||
|
|
payload = dict(values)
|
||
|
|
groups = tuple(
|
||
|
|
sorted(payload.pop("groups"), key=lambda item: item.sample_id)
|
||
|
|
)
|
||
|
|
contrasts = tuple(
|
||
|
|
sorted(
|
||
|
|
payload.pop("contrasts"),
|
||
|
|
key=lambda item: (item.treatment, item.reference),
|
||
|
|
)
|
||
|
|
)
|
||
|
|
active_samples = tuple(sorted(payload.pop("active_samples")))
|
||
|
|
ignored_samples = tuple(sorted(payload.pop("ignored_samples")))
|
||
|
|
replicate_counts = tuple(
|
||
|
|
sorted(payload.pop("replicate_counts"), key=lambda item: item.group)
|
||
|
|
)
|
||
|
|
replicate_violations = tuple(
|
||
|
|
sorted(payload.pop("replicate_violations"), key=lambda item: item.group)
|
||
|
|
)
|
||
|
|
group_times = tuple(
|
||
|
|
sorted(payload.pop("group_times"), key=lambda item: item.group)
|
||
|
|
)
|
||
|
|
time_levels = tuple(payload.pop("time_levels"))
|
||
|
|
canonical = {
|
||
|
|
"fact_kind": RNAFactKind.DE_DESIGN.value,
|
||
|
|
"schema_version": "rna_de_design_v1",
|
||
|
|
"species": payload["species"],
|
||
|
|
"rnaseq_scope_id": payload["rnaseq_scope_id"],
|
||
|
|
"strategy": payload["strategy"],
|
||
|
|
"design": RNAExperimentDesign(payload["design"]).value,
|
||
|
|
"comparison_mode": RNADEComparisonMode(
|
||
|
|
payload["comparison_mode"]
|
||
|
|
).value,
|
||
|
|
"effective_control_group": payload["effective_control_group"],
|
||
|
|
"active_samples": list(active_samples),
|
||
|
|
"ignored_samples": list(ignored_samples),
|
||
|
|
"groups": [item.model_dump(mode="json") for item in groups],
|
||
|
|
"contrasts": [
|
||
|
|
item.model_dump(mode="json") for item in contrasts
|
||
|
|
],
|
||
|
|
"replicate_counts": [
|
||
|
|
item.model_dump(mode="json") for item in replicate_counts
|
||
|
|
],
|
||
|
|
"replicate_policy": payload["replicate_policy"].model_dump(
|
||
|
|
mode="json"
|
||
|
|
),
|
||
|
|
"replicate_violations": [
|
||
|
|
item.model_dump(mode="json") for item in replicate_violations
|
||
|
|
],
|
||
|
|
"group_times": [
|
||
|
|
item.model_dump(mode="json") for item in group_times
|
||
|
|
],
|
||
|
|
"time_levels": list(time_levels),
|
||
|
|
"run_lrt": payload["run_lrt"],
|
||
|
|
"run_spline_lrt": payload["run_spline_lrt"],
|
||
|
|
"spline_df": payload["spline_df"],
|
||
|
|
}
|
||
|
|
digest = _digest(canonical)
|
||
|
|
return cls(
|
||
|
|
**payload,
|
||
|
|
active_samples=active_samples,
|
||
|
|
ignored_samples=ignored_samples,
|
||
|
|
groups=groups,
|
||
|
|
contrasts=contrasts,
|
||
|
|
replicate_counts=replicate_counts,
|
||
|
|
replicate_violations=replicate_violations,
|
||
|
|
group_times=group_times,
|
||
|
|
time_levels=time_levels,
|
||
|
|
digest=digest,
|
||
|
|
evidence_id=_rna_fact_evidence_id(
|
||
|
|
job_id=payload["job_id"],
|
||
|
|
species=payload["species"],
|
||
|
|
rnaseq_scope_id=payload["rnaseq_scope_id"],
|
||
|
|
fact_kind=RNAFactKind.DE_DESIGN,
|
||
|
|
schema_version="rna_de_design_v1",
|
||
|
|
digest=digest,
|
||
|
|
),
|
||
|
|
)
|
||
|
|
|
||
|
|
@model_validator(mode="after")
|
||
|
|
def validate_fact(self) -> "RNADEDesignFact":
|
||
|
|
_require_nonblank(self.job_id, "job_id")
|
||
|
|
_require_sha256(
|
||
|
|
self.source_attestation_sha256, "source_attestation_sha256"
|
||
|
|
)
|
||
|
|
_require_sha256(self.digest, "digest")
|
||
|
|
sample_ids = [item.sample_id for item in self.groups]
|
||
|
|
if len(sample_ids) != len(set(sample_ids)):
|
||
|
|
raise ValueError("DE group sample IDs must be unique")
|
||
|
|
contrast_ids = [
|
||
|
|
(item.treatment, item.reference) for item in self.contrasts
|
||
|
|
]
|
||
|
|
if len(contrast_ids) != len(set(contrast_ids)):
|
||
|
|
raise ValueError("DE contrasts must be unique")
|
||
|
|
known_groups = {item.group for item in self.groups}
|
||
|
|
if set(sample_ids) != set(self.active_samples):
|
||
|
|
raise ValueError("active_samples must exactly match DE group samples")
|
||
|
|
if set(self.active_samples) & set(self.ignored_samples):
|
||
|
|
raise ValueError("active and ignored DE samples must be disjoint")
|
||
|
|
if len(self.active_samples) != len(set(self.active_samples)):
|
||
|
|
raise ValueError("active_samples must be unique")
|
||
|
|
if len(self.ignored_samples) != len(set(self.ignored_samples)):
|
||
|
|
raise ValueError("ignored_samples must be unique")
|
||
|
|
count_groups = [item.group for item in self.replicate_counts]
|
||
|
|
if set(count_groups) != known_groups or len(count_groups) != len(set(count_groups)):
|
||
|
|
raise ValueError("replicate_counts must exactly cover DE groups")
|
||
|
|
if sum(item.count for item in self.replicate_counts) != len(self.active_samples):
|
||
|
|
raise ValueError("replicate_counts must total active samples")
|
||
|
|
violation_groups = [item.group for item in self.replicate_violations]
|
||
|
|
if not set(violation_groups).issubset(known_groups):
|
||
|
|
raise ValueError("replicate violations must reference DE groups")
|
||
|
|
if len(violation_groups) != len(set(violation_groups)):
|
||
|
|
raise ValueError("replicate violations must be unique")
|
||
|
|
if self.run_lrt and self.design != RNAExperimentDesign.TIME_SERIES:
|
||
|
|
raise ValueError("run_lrt is only valid for time_series design")
|
||
|
|
group_time_names = [item.group for item in self.group_times]
|
||
|
|
if len(group_time_names) != len(set(group_time_names)):
|
||
|
|
raise ValueError("group_times must be unique")
|
||
|
|
if not set(group_time_names).issubset(known_groups):
|
||
|
|
raise ValueError("group_times must reference DE groups")
|
||
|
|
if set(self.time_levels) != set(group_time_names):
|
||
|
|
raise ValueError("time_levels must exactly match group_times")
|
||
|
|
if self.effective_control_group not in known_groups:
|
||
|
|
raise ValueError("effective control group must be a declared group")
|
||
|
|
for contrast in self.contrasts:
|
||
|
|
if (
|
||
|
|
contrast.treatment not in known_groups
|
||
|
|
or contrast.reference not in known_groups
|
||
|
|
):
|
||
|
|
raise ValueError("DE contrast must reference declared groups")
|
||
|
|
expected_digest = _digest(self.canonical_payload())
|
||
|
|
if self.digest != expected_digest:
|
||
|
|
raise ValueError("digest does not match canonical DE design")
|
||
|
|
expected_id = _rna_fact_evidence_id(
|
||
|
|
job_id=self.job_id,
|
||
|
|
species=self.species,
|
||
|
|
rnaseq_scope_id=self.rnaseq_scope_id,
|
||
|
|
fact_kind=RNAFactKind.DE_DESIGN,
|
||
|
|
schema_version=self.schema_version,
|
||
|
|
digest=self.digest,
|
||
|
|
)
|
||
|
|
if self.evidence_id != expected_id:
|
||
|
|
raise ValueError("evidence_id does not match DE design fact")
|
||
|
|
return self
|
||
|
|
|
||
|
|
|
||
|
|
class RNAGenomeResourceFact(_WorkflowModel):
|
||
|
|
fact_kind: Literal[RNAFactKind.GENOME_RESOURCE] = RNAFactKind.GENOME_RESOURCE
|
||
|
|
schema_version: Literal["rna_genome_resource_v1"] = "rna_genome_resource_v1"
|
||
|
|
job_id: str
|
||
|
|
species: SpeciesCode
|
||
|
|
rnaseq_scope_id: Literal[""] = ""
|
||
|
|
reference_id: WorkflowIdentity
|
||
|
|
promoted_config_sha256: str
|
||
|
|
genome: RNAArtifactIdentity
|
||
|
|
gff: RNAArtifactIdentity
|
||
|
|
genome_prepare_manifest: RNAArtifactIdentity
|
||
|
|
source_attestation_sha256: str
|
||
|
|
digest: str
|
||
|
|
evidence_id: str
|
||
|
|
|
||
|
|
def canonical_payload(self) -> dict[str, Any]:
|
||
|
|
return {
|
||
|
|
"fact_kind": self.fact_kind.value,
|
||
|
|
"schema_version": self.schema_version,
|
||
|
|
"species": self.species,
|
||
|
|
"reference_id": self.reference_id,
|
||
|
|
"promoted_config_sha256": self.promoted_config_sha256,
|
||
|
|
"genome": self.genome.model_dump(mode="json"),
|
||
|
|
"gff": self.gff.model_dump(mode="json"),
|
||
|
|
"genome_prepare_manifest": self.genome_prepare_manifest.model_dump(
|
||
|
|
mode="json"
|
||
|
|
),
|
||
|
|
}
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def build(cls, **values: Any) -> "RNAGenomeResourceFact":
|
||
|
|
payload = dict(values)
|
||
|
|
canonical = {
|
||
|
|
"fact_kind": RNAFactKind.GENOME_RESOURCE.value,
|
||
|
|
"schema_version": "rna_genome_resource_v1",
|
||
|
|
"species": payload["species"],
|
||
|
|
"reference_id": payload["reference_id"],
|
||
|
|
"promoted_config_sha256": payload["promoted_config_sha256"],
|
||
|
|
"genome": payload["genome"].model_dump(mode="json"),
|
||
|
|
"gff": payload["gff"].model_dump(mode="json"),
|
||
|
|
"genome_prepare_manifest": payload[
|
||
|
|
"genome_prepare_manifest"
|
||
|
|
].model_dump(mode="json"),
|
||
|
|
}
|
||
|
|
digest = _digest(canonical)
|
||
|
|
return cls(
|
||
|
|
**payload,
|
||
|
|
digest=digest,
|
||
|
|
evidence_id=_rna_fact_evidence_id(
|
||
|
|
job_id=payload["job_id"],
|
||
|
|
species=payload["species"],
|
||
|
|
rnaseq_scope_id="",
|
||
|
|
fact_kind=RNAFactKind.GENOME_RESOURCE,
|
||
|
|
schema_version="rna_genome_resource_v1",
|
||
|
|
digest=digest,
|
||
|
|
),
|
||
|
|
)
|
||
|
|
|
||
|
|
@model_validator(mode="after")
|
||
|
|
def validate_fact(self) -> "RNAGenomeResourceFact":
|
||
|
|
_require_nonblank(self.job_id, "job_id")
|
||
|
|
_require_sha256(
|
||
|
|
self.promoted_config_sha256, "promoted_config_sha256"
|
||
|
|
)
|
||
|
|
_require_sha256(
|
||
|
|
self.source_attestation_sha256, "source_attestation_sha256"
|
||
|
|
)
|
||
|
|
_require_sha256(self.digest, "digest")
|
||
|
|
expected_digest = _digest(self.canonical_payload())
|
||
|
|
if self.digest != expected_digest:
|
||
|
|
raise ValueError("digest does not match canonical genome resources")
|
||
|
|
expected_id = _rna_fact_evidence_id(
|
||
|
|
job_id=self.job_id,
|
||
|
|
species=self.species,
|
||
|
|
rnaseq_scope_id="",
|
||
|
|
fact_kind=RNAFactKind.GENOME_RESOURCE,
|
||
|
|
schema_version=self.schema_version,
|
||
|
|
digest=self.digest,
|
||
|
|
)
|
||
|
|
if self.evidence_id != expected_id:
|
||
|
|
raise ValueError("evidence_id does not match genome resource fact")
|
||
|
|
return self
|
||
|
|
|
||
|
|
|
||
|
|
class RNASamplesFactSourceClosure(_WorkflowModel):
|
||
|
|
closure_kind: Literal["samples_confirmation"]
|
||
|
|
source_evidence_id: str
|
||
|
|
source_manifest_path: str
|
||
|
|
source_manifest_sha256: str
|
||
|
|
confirmed_artifact_path: str
|
||
|
|
confirmed_artifact_sha256: str
|
||
|
|
digest: str
|
||
|
|
|
||
|
|
def canonical_payload(self) -> dict[str, Any]:
|
||
|
|
return self.model_dump(mode="json", exclude={"digest"})
|
||
|
|
|
||
|
|
@model_validator(mode="after")
|
||
|
|
def validate_closure(self) -> "RNASamplesFactSourceClosure":
|
||
|
|
_require_job_relative_path(self.source_manifest_path, "source_manifest_path")
|
||
|
|
_require_sha256(self.source_manifest_sha256, "source_manifest_sha256")
|
||
|
|
_require_job_relative_path(
|
||
|
|
self.confirmed_artifact_path,
|
||
|
|
"confirmed_artifact_path",
|
||
|
|
)
|
||
|
|
_require_sha256(
|
||
|
|
self.confirmed_artifact_sha256,
|
||
|
|
"confirmed_artifact_sha256",
|
||
|
|
)
|
||
|
|
expected_id = (
|
||
|
|
f"formal_confirmation:{self.source_manifest_sha256}:"
|
||
|
|
f"{self.source_manifest_path}"
|
||
|
|
)
|
||
|
|
if self.source_evidence_id != expected_id:
|
||
|
|
raise ValueError("samples source evidence identity does not match closure")
|
||
|
|
if self.digest != _digest(self.canonical_payload()):
|
||
|
|
raise ValueError("samples source closure digest does not match")
|
||
|
|
return self
|
||
|
|
|
||
|
|
|
||
|
|
class RNAGenomeFactSourceClosure(_WorkflowModel):
|
||
|
|
closure_kind: Literal["genome_resource"]
|
||
|
|
config_evidence_id: str
|
||
|
|
config_manifest_path: str
|
||
|
|
config_manifest_sha256: str
|
||
|
|
confirmed_config_artifact_path: str
|
||
|
|
confirmed_config_sha256: str
|
||
|
|
genome_prepare_evidence_id: str
|
||
|
|
genome_prepare_manifest_path: str
|
||
|
|
genome_prepare_manifest_sha256: str
|
||
|
|
source_config_artifact_sha256: str
|
||
|
|
digest: str
|
||
|
|
|
||
|
|
def canonical_payload(self) -> dict[str, Any]:
|
||
|
|
return self.model_dump(mode="json", exclude={"digest"})
|
||
|
|
|
||
|
|
@model_validator(mode="after")
|
||
|
|
def validate_closure(self) -> "RNAGenomeFactSourceClosure":
|
||
|
|
for field_name in (
|
||
|
|
"config_manifest_path",
|
||
|
|
"confirmed_config_artifact_path",
|
||
|
|
"genome_prepare_manifest_path",
|
||
|
|
):
|
||
|
|
_require_job_relative_path(getattr(self, field_name), field_name)
|
||
|
|
for field_name in (
|
||
|
|
"config_manifest_sha256",
|
||
|
|
"confirmed_config_sha256",
|
||
|
|
"genome_prepare_manifest_sha256",
|
||
|
|
"source_config_artifact_sha256",
|
||
|
|
):
|
||
|
|
_require_sha256(getattr(self, field_name), field_name)
|
||
|
|
expected_config_id = (
|
||
|
|
f"formal_confirmation:{self.config_manifest_sha256}:"
|
||
|
|
f"{self.config_manifest_path}"
|
||
|
|
)
|
||
|
|
expected_prepare_id = (
|
||
|
|
f"strict_v2:{self.genome_prepare_manifest_sha256}:"
|
||
|
|
f"{self.genome_prepare_manifest_path}"
|
||
|
|
)
|
||
|
|
if self.config_evidence_id != expected_config_id:
|
||
|
|
raise ValueError("config source evidence identity does not match closure")
|
||
|
|
if self.genome_prepare_evidence_id != expected_prepare_id:
|
||
|
|
raise ValueError("genome-prepare evidence identity does not match closure")
|
||
|
|
if self.digest != _digest(self.canonical_payload()):
|
||
|
|
raise ValueError("genome source closure digest does not match")
|
||
|
|
return self
|
||
|
|
|
||
|
|
|
||
|
|
RNAFactProjectionSourceClosure = Annotated[
|
||
|
|
RNASamplesFactSourceClosure | RNAGenomeFactSourceClosure,
|
||
|
|
Field(discriminator="closure_kind"),
|
||
|
|
]
|
||
|
|
RNAProjectedFact = Annotated[
|
||
|
|
RNAExperimentInputFact
|
||
|
|
| RNAAlignmentStrandnessFact
|
||
|
|
| RNADEDesignFact
|
||
|
|
| RNAGenomeResourceFact,
|
||
|
|
Field(discriminator="fact_kind"),
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
class RNAFactProjectionDocument(_WorkflowModel):
|
||
|
|
projection_record_version: Literal["rna_fact_projection_record_v1"]
|
||
|
|
record_type: Literal["rna_fact_projection"]
|
||
|
|
job_id: str
|
||
|
|
species: SpeciesCode
|
||
|
|
rnaseq_scope_id: str
|
||
|
|
fact_kind: RNAFactKind
|
||
|
|
source_closure: RNAFactProjectionSourceClosure
|
||
|
|
fact: RNAProjectedFact
|
||
|
|
|
||
|
|
@model_validator(mode="after")
|
||
|
|
def validate_document(self) -> "RNAFactProjectionDocument":
|
||
|
|
fact = self.fact
|
||
|
|
if (
|
||
|
|
fact.job_id != self.job_id
|
||
|
|
or fact.species != self.species
|
||
|
|
or fact.rnaseq_scope_id != self.rnaseq_scope_id
|
||
|
|
or fact.fact_kind != self.fact_kind
|
||
|
|
):
|
||
|
|
raise ValueError("projection document identity must match its fact")
|
||
|
|
if fact.source_attestation_sha256 != self.source_closure.digest:
|
||
|
|
raise ValueError("fact source attestation must match source closure digest")
|
||
|
|
sample_kinds = {
|
||
|
|
RNAFactKind.EXPERIMENT_INPUT,
|
||
|
|
RNAFactKind.ALIGNMENT_STRANDNESS,
|
||
|
|
RNAFactKind.DE_DESIGN,
|
||
|
|
}
|
||
|
|
if self.fact_kind in sample_kinds:
|
||
|
|
if not isinstance(self.source_closure, RNASamplesFactSourceClosure):
|
||
|
|
raise ValueError("sample-derived RNA facts require samples closure")
|
||
|
|
elif not isinstance(self.source_closure, RNAGenomeFactSourceClosure):
|
||
|
|
raise ValueError("genome resource fact requires genome closure")
|
||
|
|
return self
|
||
|
|
|
||
|
|
|
||
|
|
class EvidenceKind(str, Enum):
|
||
|
|
CONFIG_DRAFT = "config_draft"
|
||
|
|
SAMPLES_DRAFT = "samples_draft"
|
||
|
|
CONFIG_CONFIRMATION = "config_confirmation"
|
||
|
|
SAMPLES_CONFIRMATION = "samples_confirmation"
|
||
|
|
STAGE_MANIFEST = "stage_manifest"
|
||
|
|
SUBFAMILY_CONFIRMATION = "subfamily_confirmation"
|
||
|
|
WHOLE_GENOME_RNA = "whole_genome_rna"
|
||
|
|
RNA_EXPERIMENT_INPUT = "rna_experiment_input"
|
||
|
|
RNA_ALIGNMENT_STRANDNESS = "rna_alignment_strandness"
|
||
|
|
RNA_DE_DESIGN = "rna_de_design"
|
||
|
|
RNA_GENOME_RESOURCE = "rna_genome_resource"
|
||
|
|
|
||
|
|
|
||
|
|
class RNAStableFactRef(_WorkflowModel):
|
||
|
|
fact_kind: RNAFactKind
|
||
|
|
schema_version: str
|
||
|
|
job_id: str
|
||
|
|
species: SpeciesCode
|
||
|
|
rnaseq_scope_id: str
|
||
|
|
digest: str
|
||
|
|
evidence_id: str
|
||
|
|
|
||
|
|
@model_validator(mode="after")
|
||
|
|
def validate_stable_fact(self) -> "RNAStableFactRef":
|
||
|
|
_require_nonblank(self.job_id, "job_id")
|
||
|
|
_require_sha256(self.digest, "digest")
|
||
|
|
expected_schemas = {
|
||
|
|
RNAFactKind.EXPERIMENT_INPUT: "rna_experiment_input_v1",
|
||
|
|
RNAFactKind.ALIGNMENT_STRANDNESS: "rna_alignment_strandness_v1",
|
||
|
|
RNAFactKind.DE_DESIGN: "rna_de_design_v1",
|
||
|
|
RNAFactKind.GENOME_RESOURCE: "rna_genome_resource_v1",
|
||
|
|
}
|
||
|
|
if self.schema_version != expected_schemas[self.fact_kind]:
|
||
|
|
raise ValueError("schema_version must match fact_kind")
|
||
|
|
if self.fact_kind == RNAFactKind.GENOME_RESOURCE:
|
||
|
|
if self.rnaseq_scope_id:
|
||
|
|
raise ValueError("genome resource fact cannot carry rnaseq_scope_id")
|
||
|
|
elif not self.rnaseq_scope_id:
|
||
|
|
raise ValueError("RNA experiment fact requires rnaseq_scope_id")
|
||
|
|
elif not re.fullmatch(_IDENTITY_RE, self.rnaseq_scope_id):
|
||
|
|
raise ValueError("rnaseq_scope_id must be a workflow identity")
|
||
|
|
expected_id = _rna_fact_evidence_id(
|
||
|
|
job_id=self.job_id,
|
||
|
|
species=self.species,
|
||
|
|
rnaseq_scope_id=self.rnaseq_scope_id,
|
||
|
|
fact_kind=self.fact_kind,
|
||
|
|
schema_version=self.schema_version,
|
||
|
|
digest=self.digest,
|
||
|
|
)
|
||
|
|
if self.evidence_id != expected_id:
|
||
|
|
raise ValueError("evidence_id does not match stable RNA fact")
|
||
|
|
return self
|
||
|
|
|
||
|
|
|
||
|
|
class RNAFactProjectionState(_WorkflowModel):
|
||
|
|
projection_state_version: Literal["rna_fact_projection_state_v1"] = (
|
||
|
|
"rna_fact_projection_state_v1"
|
||
|
|
)
|
||
|
|
record_path: str
|
||
|
|
record_sha256: str
|
||
|
|
document: RNAFactProjectionDocument
|
||
|
|
|
||
|
|
@property
|
||
|
|
def fact(self) -> RNAProjectedFact:
|
||
|
|
return self.document.fact
|
||
|
|
|
||
|
|
@property
|
||
|
|
def stable_ref(self) -> RNAStableFactRef:
|
||
|
|
fact = self.fact
|
||
|
|
return RNAStableFactRef(
|
||
|
|
fact_kind=fact.fact_kind,
|
||
|
|
schema_version=fact.schema_version,
|
||
|
|
job_id=fact.job_id,
|
||
|
|
species=fact.species,
|
||
|
|
rnaseq_scope_id=fact.rnaseq_scope_id,
|
||
|
|
digest=fact.digest,
|
||
|
|
evidence_id=fact.evidence_id,
|
||
|
|
)
|
||
|
|
|
||
|
|
@model_validator(mode="after")
|
||
|
|
def validate_projection_state(self) -> "RNAFactProjectionState":
|
||
|
|
_require_job_relative_path(self.record_path, "record_path")
|
||
|
|
_require_sha256(self.record_sha256, "record_sha256")
|
||
|
|
RNAFactProjectionDocument.model_validate(
|
||
|
|
self.document.model_dump(mode="python", round_trip=True)
|
||
|
|
)
|
||
|
|
expected_paths = {
|
||
|
|
"rna_experiment_input_v1": (
|
||
|
|
"final/workflow/rna_facts/current/"
|
||
|
|
"rna_experiment_input_v1.json"
|
||
|
|
),
|
||
|
|
"rna_alignment_strandness_v1": (
|
||
|
|
"final/workflow/rna_facts/current/"
|
||
|
|
"rna_alignment_strandness_v1.json"
|
||
|
|
),
|
||
|
|
"rna_de_design_v1": (
|
||
|
|
"final/workflow/rna_facts/current/rna_de_design_v1.json"
|
||
|
|
),
|
||
|
|
"rna_genome_resource_v1": (
|
||
|
|
"final/workflow/rna_facts/current/"
|
||
|
|
"rna_genome_resource_v1.json"
|
||
|
|
),
|
||
|
|
}
|
||
|
|
expected_path = expected_paths[self.fact.schema_version]
|
||
|
|
if self.record_path != expected_path:
|
||
|
|
raise ValueError("projection record path must match fact schema")
|
||
|
|
return self
|
||
|
|
|
||
|
|
|
||
|
|
class ManifestStatus(str, Enum):
|
||
|
|
SUCCESS = "success"
|
||
|
|
PARTIAL_SUCCESS = "partial_success"
|
||
|
|
FAILED = "failed"
|
||
|
|
|
||
|
|
|
||
|
|
class EvidenceRef(_WorkflowModel):
|
||
|
|
evidence_ref_version: Literal["evidence_ref_v2"] = "evidence_ref_v2"
|
||
|
|
evidence_id: str
|
||
|
|
evidence_kind: EvidenceKind
|
||
|
|
job_id: str
|
||
|
|
authority: Literal["strict_v2", "formal_confirmation", "legacy_display"]
|
||
|
|
manifest_identity_version: Literal["v2", "formal_v1", "legacy"]
|
||
|
|
stage: WorkflowIdentity
|
||
|
|
manifest_status: ManifestStatus
|
||
|
|
manifest_path: str
|
||
|
|
manifest_sha256: str
|
||
|
|
confirmed_artifact_path: str = ""
|
||
|
|
confirmed_artifact_sha256: str = ""
|
||
|
|
confirmation_schema_version: str = ""
|
||
|
|
source_draft_path: str = ""
|
||
|
|
source_draft_sha256: str = ""
|
||
|
|
draft_artifact_path: str = ""
|
||
|
|
draft_artifact_sha256: str = ""
|
||
|
|
source_config_artifact_sha256: str = ""
|
||
|
|
species: str = ""
|
||
|
|
rnaseq_scope_id: str = ""
|
||
|
|
scope_id: str = ""
|
||
|
|
analysis_route: str = ""
|
||
|
|
member_set_id: str = ""
|
||
|
|
source_confirmation_digest: str = ""
|
||
|
|
source_dependency_kind: RNADependencyKind | Literal[""] = ""
|
||
|
|
source_dependency_fingerprint_digest: str = ""
|
||
|
|
|
||
|
|
@model_validator(mode="after")
|
||
|
|
def validate_evidence_identity(self) -> "EvidenceRef":
|
||
|
|
for field_name in (
|
||
|
|
"evidence_id",
|
||
|
|
"job_id",
|
||
|
|
):
|
||
|
|
_require_nonblank(getattr(self, field_name), field_name)
|
||
|
|
_require_sha256(self.manifest_sha256, "manifest_sha256")
|
||
|
|
_require_job_relative_path(self.manifest_path, "manifest_path")
|
||
|
|
expected_versions = {
|
||
|
|
"strict_v2": "v2",
|
||
|
|
"formal_confirmation": "formal_v1",
|
||
|
|
"legacy_display": "legacy",
|
||
|
|
}
|
||
|
|
if self.manifest_identity_version != expected_versions[self.authority]:
|
||
|
|
raise ValueError("evidence authority and identity version must match")
|
||
|
|
source_dependency = (
|
||
|
|
self.source_dependency_kind,
|
||
|
|
self.source_dependency_fingerprint_digest,
|
||
|
|
)
|
||
|
|
if any(source_dependency) and not all(source_dependency):
|
||
|
|
raise ValueError(
|
||
|
|
"source dependency kind and fingerprint digest must be provided together"
|
||
|
|
)
|
||
|
|
if all(source_dependency):
|
||
|
|
expected_dependency_kind = _RNA_STAGE_DEPENDENCY_KIND.get(self.stage)
|
||
|
|
expected_evidence_kind = (
|
||
|
|
EvidenceKind.STAGE_MANIFEST
|
||
|
|
if self.stage == "rnaseq_family_projection"
|
||
|
|
else EvidenceKind.WHOLE_GENOME_RNA
|
||
|
|
)
|
||
|
|
if (
|
||
|
|
self.authority != "strict_v2"
|
||
|
|
or self.manifest_identity_version != "v2"
|
||
|
|
or self.evidence_kind != expected_evidence_kind
|
||
|
|
or expected_dependency_kind is None
|
||
|
|
or self.source_dependency_kind != expected_dependency_kind
|
||
|
|
):
|
||
|
|
raise ValueError(
|
||
|
|
"source dependency kind must match the strict RNA output stage"
|
||
|
|
)
|
||
|
|
_require_sha256(
|
||
|
|
self.source_dependency_fingerprint_digest,
|
||
|
|
"source_dependency_fingerprint_digest",
|
||
|
|
)
|
||
|
|
for value in (
|
||
|
|
self.rnaseq_scope_id,
|
||
|
|
self.scope_id,
|
||
|
|
self.analysis_route,
|
||
|
|
self.member_set_id,
|
||
|
|
):
|
||
|
|
if value and not re.fullmatch(_IDENTITY_RE, value):
|
||
|
|
raise ValueError("evidence identity fields must be safe tokens")
|
||
|
|
|
||
|
|
formal_fields = (
|
||
|
|
self.confirmed_artifact_path,
|
||
|
|
self.confirmed_artifact_sha256,
|
||
|
|
self.confirmation_schema_version,
|
||
|
|
self.source_draft_path,
|
||
|
|
self.source_draft_sha256,
|
||
|
|
)
|
||
|
|
draft_fields = (
|
||
|
|
self.draft_artifact_path,
|
||
|
|
self.draft_artifact_sha256,
|
||
|
|
)
|
||
|
|
if self.authority == "formal_confirmation":
|
||
|
|
if any((*draft_fields, self.source_config_artifact_sha256)):
|
||
|
|
raise ValueError(
|
||
|
|
"formal confirmation cannot carry draft artifact or "
|
||
|
|
"source config fields"
|
||
|
|
)
|
||
|
|
elif any(formal_fields):
|
||
|
|
raise ValueError(
|
||
|
|
"formal confirmation fields are only valid for formal evidence"
|
||
|
|
)
|
||
|
|
if (
|
||
|
|
self.evidence_kind
|
||
|
|
not in {EvidenceKind.CONFIG_DRAFT, EvidenceKind.SAMPLES_DRAFT}
|
||
|
|
and any(draft_fields)
|
||
|
|
):
|
||
|
|
raise ValueError(
|
||
|
|
"draft artifact fields are only valid for draft evidence"
|
||
|
|
)
|
||
|
|
if self.source_config_artifact_sha256 and not (
|
||
|
|
self.authority == "strict_v2"
|
||
|
|
and self.manifest_identity_version == "v2"
|
||
|
|
and self.evidence_kind == EvidenceKind.STAGE_MANIFEST
|
||
|
|
and self.stage == "genome_prepare"
|
||
|
|
):
|
||
|
|
raise ValueError(
|
||
|
|
"source config identity is only valid for genome_prepare"
|
||
|
|
)
|
||
|
|
|
||
|
|
scientific_identity = (
|
||
|
|
self.species,
|
||
|
|
self.rnaseq_scope_id,
|
||
|
|
self.scope_id,
|
||
|
|
self.analysis_route,
|
||
|
|
self.member_set_id,
|
||
|
|
self.source_confirmation_digest,
|
||
|
|
)
|
||
|
|
if self.authority == "formal_confirmation":
|
||
|
|
if self.evidence_kind not in {
|
||
|
|
EvidenceKind.CONFIG_CONFIRMATION,
|
||
|
|
EvidenceKind.SAMPLES_CONFIRMATION,
|
||
|
|
}:
|
||
|
|
raise ValueError("formal_confirmation is only valid for config/sample evidence")
|
||
|
|
if any(scientific_identity):
|
||
|
|
raise ValueError("config/sample confirmation evidence cannot carry scientific identity")
|
||
|
|
expected_stage = {
|
||
|
|
EvidenceKind.CONFIG_CONFIRMATION: "config_autofill",
|
||
|
|
EvidenceKind.SAMPLES_CONFIRMATION: "samples_autofill",
|
||
|
|
}[self.evidence_kind]
|
||
|
|
if self.stage != expected_stage:
|
||
|
|
raise ValueError("formal confirmation kind and stage must match")
|
||
|
|
if not self.source_draft_path or not self.source_draft_sha256:
|
||
|
|
raise ValueError(
|
||
|
|
"formal confirmation requires source draft path and SHA256"
|
||
|
|
)
|
||
|
|
if (
|
||
|
|
self.manifest_status != ManifestStatus.SUCCESS
|
||
|
|
or not self.confirmed_artifact_path
|
||
|
|
or not self.confirmed_artifact_sha256
|
||
|
|
or not self.confirmation_schema_version
|
||
|
|
):
|
||
|
|
raise ValueError("formal confirmation fields require successful closure")
|
||
|
|
try:
|
||
|
|
_require_job_relative_path(
|
||
|
|
self.source_draft_path,
|
||
|
|
"source_draft_path",
|
||
|
|
)
|
||
|
|
_require_sha256(
|
||
|
|
self.source_draft_sha256,
|
||
|
|
"source_draft_sha256",
|
||
|
|
)
|
||
|
|
except ValueError as exc:
|
||
|
|
raise ValueError(f"invalid source draft evidence: {exc}") from exc
|
||
|
|
_require_job_relative_path(
|
||
|
|
self.confirmed_artifact_path,
|
||
|
|
"confirmed_artifact_path",
|
||
|
|
)
|
||
|
|
_require_sha256(
|
||
|
|
self.confirmed_artifact_sha256,
|
||
|
|
"confirmed_artifact_sha256",
|
||
|
|
)
|
||
|
|
_require_nonblank(
|
||
|
|
self.confirmation_schema_version,
|
||
|
|
"confirmation_schema_version",
|
||
|
|
)
|
||
|
|
return self
|
||
|
|
if self.evidence_kind in {
|
||
|
|
EvidenceKind.CONFIG_DRAFT,
|
||
|
|
EvidenceKind.SAMPLES_DRAFT,
|
||
|
|
}:
|
||
|
|
expected_stage = {
|
||
|
|
EvidenceKind.CONFIG_DRAFT: "config_autofill",
|
||
|
|
EvidenceKind.SAMPLES_DRAFT: "samples_autofill",
|
||
|
|
}[self.evidence_kind]
|
||
|
|
base_valid = (
|
||
|
|
self.authority == "strict_v2"
|
||
|
|
and self.manifest_identity_version == "v2"
|
||
|
|
and self.manifest_status == ManifestStatus.SUCCESS
|
||
|
|
and self.stage == expected_stage
|
||
|
|
and all(draft_fields)
|
||
|
|
and not self.source_config_artifact_sha256
|
||
|
|
)
|
||
|
|
if self.evidence_kind == EvidenceKind.CONFIG_DRAFT:
|
||
|
|
identity_valid = not any(scientific_identity)
|
||
|
|
else:
|
||
|
|
identity_valid = (
|
||
|
|
bool(self.species)
|
||
|
|
and bool(self.rnaseq_scope_id)
|
||
|
|
and self.analysis_route == RNARoute.WHOLE_GENOME.value
|
||
|
|
and not any(
|
||
|
|
(
|
||
|
|
self.scope_id,
|
||
|
|
self.member_set_id,
|
||
|
|
self.source_confirmation_digest,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
)
|
||
|
|
if not base_valid or not identity_valid:
|
||
|
|
raise ValueError(
|
||
|
|
"draft evidence requires an exact successful strict-v2 artifact"
|
||
|
|
)
|
||
|
|
_require_job_relative_path(
|
||
|
|
self.draft_artifact_path,
|
||
|
|
"draft_artifact_path",
|
||
|
|
)
|
||
|
|
_require_sha256(
|
||
|
|
self.draft_artifact_sha256,
|
||
|
|
"draft_artifact_sha256",
|
||
|
|
)
|
||
|
|
if not re.fullmatch(r"^[A-Za-z][A-Za-z0-9._-]*$", self.species or "A"):
|
||
|
|
raise ValueError("draft evidence species must be a species code")
|
||
|
|
return self
|
||
|
|
if self.evidence_kind in {
|
||
|
|
EvidenceKind.CONFIG_CONFIRMATION,
|
||
|
|
EvidenceKind.SAMPLES_CONFIRMATION,
|
||
|
|
}:
|
||
|
|
raise ValueError("config/sample confirmation evidence requires formal_confirmation")
|
||
|
|
if self.evidence_kind in {
|
||
|
|
EvidenceKind.RNA_EXPERIMENT_INPUT,
|
||
|
|
EvidenceKind.RNA_ALIGNMENT_STRANDNESS,
|
||
|
|
EvidenceKind.RNA_DE_DESIGN,
|
||
|
|
EvidenceKind.RNA_GENOME_RESOURCE,
|
||
|
|
}:
|
||
|
|
raise ValueError("RNA fact selectors require RNAStableFactRef")
|
||
|
|
if self.authority == "legacy_display":
|
||
|
|
if self.source_confirmation_digest:
|
||
|
|
raise ValueError("legacy display evidence cannot carry confirmation authority")
|
||
|
|
return self
|
||
|
|
|
||
|
|
if self.evidence_kind == EvidenceKind.SUBFAMILY_CONFIRMATION:
|
||
|
|
if (
|
||
|
|
self.stage != "subfamily_confirm"
|
||
|
|
or self.species
|
||
|
|
or self.rnaseq_scope_id
|
||
|
|
or not self.scope_id
|
||
|
|
or self.analysis_route != "confirmed_subfamily"
|
||
|
|
or not self.member_set_id
|
||
|
|
):
|
||
|
|
raise ValueError("family strict_v2 evidence requires scope/route/member identity")
|
||
|
|
member_prefix = f"{self.scope_id}:confirmed_subfamily:"
|
||
|
|
if not self.member_set_id.startswith(member_prefix) or not self.member_set_id[
|
||
|
|
len(member_prefix) :
|
||
|
|
]:
|
||
|
|
raise ValueError("family member identity must be scope-bound")
|
||
|
|
_require_sha256(
|
||
|
|
self.source_confirmation_digest,
|
||
|
|
"source_confirmation_digest",
|
||
|
|
)
|
||
|
|
elif self.evidence_kind == EvidenceKind.WHOLE_GENOME_RNA:
|
||
|
|
if (
|
||
|
|
not self.stage.startswith("rnaseq_")
|
||
|
|
or not self.rnaseq_scope_id
|
||
|
|
or self.analysis_route != RNARoute.WHOLE_GENOME.value
|
||
|
|
or any(
|
||
|
|
(
|
||
|
|
self.species,
|
||
|
|
self.scope_id,
|
||
|
|
self.member_set_id,
|
||
|
|
self.source_confirmation_digest,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
):
|
||
|
|
raise ValueError(
|
||
|
|
"whole-genome RNA strict_v2 evidence requires RNA scope and whole route only"
|
||
|
|
)
|
||
|
|
elif self.evidence_kind == EvidenceKind.STAGE_MANIFEST:
|
||
|
|
if self.stage == "rnaseq_family_projection":
|
||
|
|
member_prefix = f"{self.scope_id}:confirmed_subfamily:"
|
||
|
|
if (
|
||
|
|
self.species
|
||
|
|
or not self.rnaseq_scope_id
|
||
|
|
or not self.scope_id
|
||
|
|
or self.analysis_route != RNARoute.FAMILY_MEMBER.value
|
||
|
|
or not self.member_set_id.startswith(member_prefix)
|
||
|
|
or self.member_set_id[len(member_prefix) :] != "all_members"
|
||
|
|
or not self.source_confirmation_digest
|
||
|
|
or self.source_dependency_kind
|
||
|
|
!= RNADependencyKind.FAMILY_PROJECTION
|
||
|
|
):
|
||
|
|
raise ValueError(
|
||
|
|
"family projection evidence requires exact RNA/family/source identity"
|
||
|
|
)
|
||
|
|
_require_sha256(
|
||
|
|
self.source_confirmation_digest,
|
||
|
|
"source_confirmation_digest",
|
||
|
|
)
|
||
|
|
elif not self.analysis_route or self.source_confirmation_digest:
|
||
|
|
raise ValueError("strict_v2 stage evidence requires a route without confirmation authority")
|
||
|
|
if self.stage == "genome_prepare":
|
||
|
|
if not self.source_config_artifact_sha256:
|
||
|
|
raise ValueError(
|
||
|
|
"genome_prepare evidence requires source config identity"
|
||
|
|
)
|
||
|
|
_require_sha256(
|
||
|
|
self.source_config_artifact_sha256,
|
||
|
|
"source_config_artifact_sha256",
|
||
|
|
)
|
||
|
|
if self.species and not re.fullmatch(
|
||
|
|
r"^[A-Za-z][A-Za-z0-9._-]*$",
|
||
|
|
self.species,
|
||
|
|
):
|
||
|
|
raise ValueError("stage evidence species must be a species code")
|
||
|
|
elif self.species:
|
||
|
|
raise ValueError("species binding is not valid for this evidence kind")
|
||
|
|
return self
|
||
|
|
|
||
|
|
|
||
|
|
class DependencyFingerprint(_WorkflowModel):
|
||
|
|
dependency_fingerprint_version: Literal["dependency_fingerprint_v1"] = (
|
||
|
|
"dependency_fingerprint_v1"
|
||
|
|
)
|
||
|
|
job_id: str
|
||
|
|
dependency_kind: str
|
||
|
|
evidence: tuple[EvidenceRef, ...] = ()
|
||
|
|
rna_facts: tuple[RNAStableFactRef, ...] = ()
|
||
|
|
species: str = ""
|
||
|
|
rnaseq_scope_id: str = ""
|
||
|
|
family_scope_id: str = ""
|
||
|
|
scope_id: str = ""
|
||
|
|
analysis_route: str = ""
|
||
|
|
member_set_id: str = ""
|
||
|
|
digest: str
|
||
|
|
|
||
|
|
@field_validator("evidence")
|
||
|
|
@classmethod
|
||
|
|
def sort_evidence(cls, value: tuple[EvidenceRef, ...]) -> tuple[EvidenceRef, ...]:
|
||
|
|
return tuple(sorted(value, key=lambda item: item.evidence_id))
|
||
|
|
|
||
|
|
@field_validator("rna_facts")
|
||
|
|
@classmethod
|
||
|
|
def sort_rna_facts(
|
||
|
|
cls, value: tuple[RNAStableFactRef, ...]
|
||
|
|
) -> tuple[RNAStableFactRef, ...]:
|
||
|
|
return tuple(
|
||
|
|
sorted(
|
||
|
|
value,
|
||
|
|
key=lambda item: (item.fact_kind.value, item.evidence_id),
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
def canonical_input(self) -> dict[str, Any]:
|
||
|
|
return {
|
||
|
|
"dependency_fingerprint_version": self.dependency_fingerprint_version,
|
||
|
|
"job_id": self.job_id,
|
||
|
|
"dependency_kind": self.dependency_kind,
|
||
|
|
"evidence": [item.model_dump(mode="json") for item in self.evidence],
|
||
|
|
"rna_facts": [
|
||
|
|
item.model_dump(mode="json") for item in self.rna_facts
|
||
|
|
],
|
||
|
|
"species": self.species,
|
||
|
|
"rnaseq_scope_id": self.rnaseq_scope_id,
|
||
|
|
"family_scope_id": self.family_scope_id,
|
||
|
|
"scope_id": self.scope_id,
|
||
|
|
"analysis_route": self.analysis_route,
|
||
|
|
"member_set_id": self.member_set_id,
|
||
|
|
}
|
||
|
|
|
||
|
|
@model_validator(mode="after")
|
||
|
|
def validate_fingerprint(self) -> "DependencyFingerprint":
|
||
|
|
_require_nonblank(self.job_id, "job_id")
|
||
|
|
_require_nonblank(self.dependency_kind, "dependency_kind")
|
||
|
|
_require_sha256(self.digest, "digest")
|
||
|
|
if not self.evidence and not self.rna_facts:
|
||
|
|
raise ValueError("dependency fingerprint requires evidence or RNA facts")
|
||
|
|
for item in self.rna_facts:
|
||
|
|
RNAStableFactRef.model_validate(
|
||
|
|
item.model_dump(mode="python", round_trip=True)
|
||
|
|
)
|
||
|
|
evidence_ids = [item.evidence_id for item in self.evidence]
|
||
|
|
if len(evidence_ids) != len(set(evidence_ids)):
|
||
|
|
raise ValueError("dependency evidence IDs must be unique")
|
||
|
|
if any(item.job_id != self.job_id for item in self.evidence):
|
||
|
|
raise ValueError("dependency evidence must belong to the same job")
|
||
|
|
if any(item.authority == "legacy_display" for item in self.evidence):
|
||
|
|
raise ValueError("dependency fingerprints cannot use legacy evidence")
|
||
|
|
if any(item.manifest_status != ManifestStatus.SUCCESS for item in self.evidence):
|
||
|
|
raise ValueError("dependency fingerprints require successful evidence")
|
||
|
|
fact_ids = [item.evidence_id for item in self.rna_facts]
|
||
|
|
fact_kinds = [item.fact_kind for item in self.rna_facts]
|
||
|
|
if len(fact_ids) != len(set(fact_ids)):
|
||
|
|
raise ValueError("dependency RNA fact evidence IDs must be unique")
|
||
|
|
if len(fact_kinds) != len(set(fact_kinds)):
|
||
|
|
raise ValueError("dependency RNA fact kinds must be unique")
|
||
|
|
for item in self.rna_facts:
|
||
|
|
if item.job_id != self.job_id:
|
||
|
|
raise ValueError("RNA fact job_id must match fingerprint job_id")
|
||
|
|
if item.species != self.species:
|
||
|
|
raise ValueError("RNA fact species must match fingerprint species")
|
||
|
|
if (
|
||
|
|
item.fact_kind != RNAFactKind.GENOME_RESOURCE
|
||
|
|
and item.rnaseq_scope_id != self.rnaseq_scope_id
|
||
|
|
):
|
||
|
|
raise ValueError(
|
||
|
|
"RNA fact rnaseq_scope_id must match fingerprint rnaseq_scope_id"
|
||
|
|
)
|
||
|
|
if self.rna_facts and (
|
||
|
|
not self.species
|
||
|
|
or not self.rnaseq_scope_id
|
||
|
|
or self.analysis_route != RNARoute.WHOLE_GENOME.value
|
||
|
|
or any((self.family_scope_id, self.scope_id, self.member_set_id))
|
||
|
|
):
|
||
|
|
raise ValueError(
|
||
|
|
"RNA facts require exact whole-genome species and RNA scope identity"
|
||
|
|
)
|
||
|
|
identity_values = (
|
||
|
|
self.species,
|
||
|
|
self.rnaseq_scope_id,
|
||
|
|
self.family_scope_id,
|
||
|
|
self.scope_id,
|
||
|
|
self.analysis_route,
|
||
|
|
self.member_set_id,
|
||
|
|
)
|
||
|
|
for value in identity_values:
|
||
|
|
if value and not re.fullmatch(_IDENTITY_RE, value):
|
||
|
|
raise ValueError("dependency identity fields must be safe tokens")
|
||
|
|
whole_matches = []
|
||
|
|
shared_de_matches = []
|
||
|
|
family_matches = []
|
||
|
|
for item in self.evidence:
|
||
|
|
if item.authority == "formal_confirmation":
|
||
|
|
continue
|
||
|
|
if item.authority != "strict_v2" or item.manifest_identity_version != "v2":
|
||
|
|
raise ValueError("scientific dependency evidence must be strict_v2")
|
||
|
|
if item.evidence_kind == EvidenceKind.WHOLE_GENOME_RNA:
|
||
|
|
if self.analysis_route == RNARoute.FAMILY_MEMBER.value:
|
||
|
|
matches = (
|
||
|
|
item.stage == "rnaseq_de"
|
||
|
|
and item.analysis_route == RNARoute.WHOLE_GENOME.value
|
||
|
|
and self.rnaseq_scope_id == item.rnaseq_scope_id
|
||
|
|
and not any(
|
||
|
|
(
|
||
|
|
item.scope_id,
|
||
|
|
item.member_set_id,
|
||
|
|
item.source_confirmation_digest,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
)
|
||
|
|
shared_de_matches.append(matches)
|
||
|
|
else:
|
||
|
|
matches = (
|
||
|
|
self.analysis_route == RNARoute.WHOLE_GENOME.value
|
||
|
|
and self.rnaseq_scope_id == item.rnaseq_scope_id
|
||
|
|
and not any(
|
||
|
|
(
|
||
|
|
self.family_scope_id,
|
||
|
|
self.scope_id,
|
||
|
|
self.member_set_id,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
)
|
||
|
|
whole_matches.append(matches)
|
||
|
|
if not matches:
|
||
|
|
raise ValueError("evidence identity must match fingerprint identity")
|
||
|
|
elif item.evidence_kind == EvidenceKind.SUBFAMILY_CONFIRMATION:
|
||
|
|
matches = (
|
||
|
|
self.analysis_route
|
||
|
|
in {"confirmed_subfamily", RNARoute.FAMILY_MEMBER.value}
|
||
|
|
and self.scope_id == item.scope_id
|
||
|
|
and self.member_set_id == item.member_set_id
|
||
|
|
and (not self.family_scope_id or self.family_scope_id == item.scope_id)
|
||
|
|
)
|
||
|
|
family_matches.append(matches)
|
||
|
|
if not matches:
|
||
|
|
raise ValueError("evidence identity must match fingerprint identity")
|
||
|
|
elif item.evidence_kind == EvidenceKind.STAGE_MANIFEST:
|
||
|
|
bootstrap_preparation = (
|
||
|
|
self.dependency_kind == "whole_genome_rna_bootstrap"
|
||
|
|
and self.analysis_route == RNARoute.WHOLE_GENOME.value
|
||
|
|
and item.stage == "genome_prepare"
|
||
|
|
and item.analysis_route == "data_preparation"
|
||
|
|
and item.species == self.species
|
||
|
|
and not any(
|
||
|
|
(
|
||
|
|
item.rnaseq_scope_id,
|
||
|
|
item.scope_id,
|
||
|
|
item.member_set_id,
|
||
|
|
item.source_confirmation_digest,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
)
|
||
|
|
pairs = (
|
||
|
|
(item.species, self.species),
|
||
|
|
(item.rnaseq_scope_id, self.rnaseq_scope_id),
|
||
|
|
(item.scope_id, self.scope_id),
|
||
|
|
(item.analysis_route, self.analysis_route),
|
||
|
|
(item.member_set_id, self.member_set_id),
|
||
|
|
)
|
||
|
|
if not bootstrap_preparation and any(
|
||
|
|
evidence_value != fingerprint_value
|
||
|
|
for evidence_value, fingerprint_value in pairs
|
||
|
|
):
|
||
|
|
raise ValueError("evidence identity must match fingerprint identity")
|
||
|
|
draft_kinds = {
|
||
|
|
EvidenceKind.CONFIG_DRAFT,
|
||
|
|
EvidenceKind.SAMPLES_DRAFT,
|
||
|
|
}
|
||
|
|
is_bootstrap = self.dependency_kind == "whole_genome_rna_bootstrap"
|
||
|
|
if is_bootstrap:
|
||
|
|
by_kind = {
|
||
|
|
kind: [item for item in self.evidence if item.evidence_kind == kind]
|
||
|
|
for kind in (
|
||
|
|
EvidenceKind.CONFIG_DRAFT,
|
||
|
|
EvidenceKind.CONFIG_CONFIRMATION,
|
||
|
|
EvidenceKind.SAMPLES_DRAFT,
|
||
|
|
EvidenceKind.SAMPLES_CONFIRMATION,
|
||
|
|
EvidenceKind.STAGE_MANIFEST,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
config_draft = by_kind[EvidenceKind.CONFIG_DRAFT]
|
||
|
|
config_confirmed = by_kind[EvidenceKind.CONFIG_CONFIRMATION]
|
||
|
|
samples_draft = by_kind[EvidenceKind.SAMPLES_DRAFT]
|
||
|
|
samples_confirmed = by_kind[EvidenceKind.SAMPLES_CONFIRMATION]
|
||
|
|
prepared = [
|
||
|
|
item
|
||
|
|
for item in by_kind[EvidenceKind.STAGE_MANIFEST]
|
||
|
|
if item.stage == "genome_prepare"
|
||
|
|
and item.analysis_route == "data_preparation"
|
||
|
|
and item.species == self.species
|
||
|
|
]
|
||
|
|
exact_shape = (
|
||
|
|
len(self.evidence) == 5
|
||
|
|
and all(
|
||
|
|
len(items) == 1
|
||
|
|
for items in (
|
||
|
|
config_draft,
|
||
|
|
config_confirmed,
|
||
|
|
samples_draft,
|
||
|
|
samples_confirmed,
|
||
|
|
prepared,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
)
|
||
|
|
if exact_shape:
|
||
|
|
config_draft_item = config_draft[0]
|
||
|
|
config_confirmed_item = config_confirmed[0]
|
||
|
|
samples_draft_item = samples_draft[0]
|
||
|
|
samples_confirmed_item = samples_confirmed[0]
|
||
|
|
prepared_item = prepared[0]
|
||
|
|
exact_shape = (
|
||
|
|
config_draft_item.draft_artifact_path
|
||
|
|
== config_confirmed_item.source_draft_path
|
||
|
|
and config_draft_item.draft_artifact_sha256
|
||
|
|
== config_confirmed_item.source_draft_sha256
|
||
|
|
and samples_draft_item.draft_artifact_path
|
||
|
|
== samples_confirmed_item.source_draft_path
|
||
|
|
and samples_draft_item.draft_artifact_sha256
|
||
|
|
== samples_confirmed_item.source_draft_sha256
|
||
|
|
and samples_draft_item.species == self.species
|
||
|
|
and samples_draft_item.rnaseq_scope_id == self.rnaseq_scope_id
|
||
|
|
and samples_draft_item.analysis_route
|
||
|
|
== RNARoute.WHOLE_GENOME.value
|
||
|
|
and prepared_item.source_config_artifact_sha256
|
||
|
|
== config_confirmed_item.confirmed_artifact_sha256
|
||
|
|
)
|
||
|
|
if (
|
||
|
|
not exact_shape
|
||
|
|
or self.analysis_route != RNARoute.WHOLE_GENOME.value
|
||
|
|
or not self.species
|
||
|
|
or not self.rnaseq_scope_id
|
||
|
|
or any(
|
||
|
|
(
|
||
|
|
self.family_scope_id,
|
||
|
|
self.scope_id,
|
||
|
|
self.member_set_id,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
or whole_matches
|
||
|
|
):
|
||
|
|
raise ValueError(
|
||
|
|
"whole-genome bootstrap requires the exact five input authorities"
|
||
|
|
)
|
||
|
|
elif self.dependency_kind == "config_draft_confirmation":
|
||
|
|
if (
|
||
|
|
len(self.evidence) != 1
|
||
|
|
or self.evidence[0].evidence_kind != EvidenceKind.CONFIG_DRAFT
|
||
|
|
or any(identity_values)
|
||
|
|
):
|
||
|
|
raise ValueError(
|
||
|
|
"config confirmation requires exactly one job-wide config draft"
|
||
|
|
)
|
||
|
|
elif self.dependency_kind == "samples_draft_confirmation":
|
||
|
|
if (
|
||
|
|
len(self.evidence) != 1
|
||
|
|
or self.evidence[0].evidence_kind != EvidenceKind.SAMPLES_DRAFT
|
||
|
|
or self.species != self.evidence[0].species
|
||
|
|
or self.rnaseq_scope_id != self.evidence[0].rnaseq_scope_id
|
||
|
|
or self.analysis_route != RNARoute.WHOLE_GENOME.value
|
||
|
|
):
|
||
|
|
raise ValueError(
|
||
|
|
"samples confirmation requires exactly one experiment draft"
|
||
|
|
)
|
||
|
|
elif any(item.evidence_kind in draft_kinds for item in self.evidence):
|
||
|
|
raise ValueError("draft evidence is not valid for this dependency kind")
|
||
|
|
elif (
|
||
|
|
self.species
|
||
|
|
and self.analysis_route != RNARoute.WHOLE_GENOME.value
|
||
|
|
and not self.rna_facts
|
||
|
|
):
|
||
|
|
raise ValueError(
|
||
|
|
"fingerprint species requires whole-genome, draft, or bootstrap identity"
|
||
|
|
)
|
||
|
|
if (
|
||
|
|
self.analysis_route == RNARoute.WHOLE_GENOME.value
|
||
|
|
and not is_bootstrap
|
||
|
|
and self.dependency_kind != "samples_draft_confirmation"
|
||
|
|
and not any(whole_matches)
|
||
|
|
and not self.rna_facts
|
||
|
|
):
|
||
|
|
raise ValueError("whole-genome dependency requires matching whole-genome RNA evidence")
|
||
|
|
if self.analysis_route == RNARoute.FAMILY_MEMBER.value:
|
||
|
|
if self.dependency_kind != "family_projection":
|
||
|
|
raise ValueError(
|
||
|
|
"family_member dependency kind must be family_projection"
|
||
|
|
)
|
||
|
|
if not self.rnaseq_scope_id or not all(
|
||
|
|
(self.family_scope_id, self.scope_id, self.member_set_id)
|
||
|
|
):
|
||
|
|
raise ValueError("family dependency requires complete top-level identity")
|
||
|
|
if sum(shared_de_matches) != 1 or sum(family_matches) != 1:
|
||
|
|
raise ValueError(
|
||
|
|
"family dependency requires exactly one shared whole-genome DE "
|
||
|
|
"and one matching subfamily confirmation"
|
||
|
|
)
|
||
|
|
if self.analysis_route == "confirmed_subfamily" and not any(family_matches):
|
||
|
|
raise ValueError("confirmed family dependency requires matching subfamily confirmation")
|
||
|
|
if self.digest != _digest(self.canonical_input()):
|
||
|
|
raise ValueError("digest does not match canonical dependency input")
|
||
|
|
return self
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def build(
|
||
|
|
cls,
|
||
|
|
*,
|
||
|
|
job_id: str,
|
||
|
|
dependency_kind: str,
|
||
|
|
evidence: tuple[EvidenceRef, ...],
|
||
|
|
rna_facts: tuple[RNAStableFactRef | Mapping[str, Any], ...] = (),
|
||
|
|
species: str = "",
|
||
|
|
rnaseq_scope_id: str = "",
|
||
|
|
family_scope_id: str = "",
|
||
|
|
scope_id: str = "",
|
||
|
|
analysis_route: str = "",
|
||
|
|
member_set_id: str = "",
|
||
|
|
) -> "DependencyFingerprint":
|
||
|
|
ordered = tuple(sorted(evidence, key=lambda item: item.evidence_id))
|
||
|
|
normalized_facts = tuple(
|
||
|
|
RNAStableFactRef.model_validate(
|
||
|
|
item.model_dump(mode="python", round_trip=True)
|
||
|
|
if isinstance(item, RNAStableFactRef)
|
||
|
|
else item
|
||
|
|
)
|
||
|
|
for item in rna_facts
|
||
|
|
)
|
||
|
|
ordered_facts = tuple(
|
||
|
|
sorted(
|
||
|
|
normalized_facts,
|
||
|
|
key=lambda item: (item.fact_kind.value, item.evidence_id),
|
||
|
|
)
|
||
|
|
)
|
||
|
|
canonical = {
|
||
|
|
"dependency_fingerprint_version": "dependency_fingerprint_v1",
|
||
|
|
"job_id": job_id,
|
||
|
|
"dependency_kind": dependency_kind,
|
||
|
|
"evidence": [item.model_dump(mode="json") for item in ordered],
|
||
|
|
"rna_facts": [
|
||
|
|
item.model_dump(mode="json") for item in ordered_facts
|
||
|
|
],
|
||
|
|
"species": species,
|
||
|
|
"rnaseq_scope_id": rnaseq_scope_id,
|
||
|
|
"family_scope_id": family_scope_id,
|
||
|
|
"scope_id": scope_id,
|
||
|
|
"analysis_route": analysis_route,
|
||
|
|
"member_set_id": member_set_id,
|
||
|
|
}
|
||
|
|
return cls(
|
||
|
|
job_id=job_id,
|
||
|
|
dependency_kind=dependency_kind,
|
||
|
|
evidence=ordered,
|
||
|
|
rna_facts=ordered_facts,
|
||
|
|
species=species,
|
||
|
|
rnaseq_scope_id=rnaseq_scope_id,
|
||
|
|
family_scope_id=family_scope_id,
|
||
|
|
scope_id=scope_id,
|
||
|
|
analysis_route=analysis_route,
|
||
|
|
member_set_id=member_set_id,
|
||
|
|
digest=_digest(canonical),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class SourceConfirmationV1(_WorkflowModel):
|
||
|
|
source_confirmation_version: Literal["source_confirmation_v1"] = (
|
||
|
|
"source_confirmation_v1"
|
||
|
|
)
|
||
|
|
job_id: str
|
||
|
|
species: str
|
||
|
|
family: str
|
||
|
|
scope_id: str
|
||
|
|
member_set_id: str
|
||
|
|
manifest_path: str
|
||
|
|
manifest_sha256: str
|
||
|
|
confirmed_subfamily_path: str
|
||
|
|
confirmed_subfamily_raw_sha256: str
|
||
|
|
confirmed_subfamily_row_count: int = Field(gt=0)
|
||
|
|
label_canonicalization_version: str
|
||
|
|
canonical_labels_path: str
|
||
|
|
canonical_labels_sha256: str
|
||
|
|
gene_label_digest: str
|
||
|
|
member_id_path: str
|
||
|
|
member_id_sha256: str
|
||
|
|
member_pep_path: str
|
||
|
|
member_pep_sha256: str
|
||
|
|
member_count: int = Field(gt=0)
|
||
|
|
confirmed_at: datetime
|
||
|
|
source_confirmation_digest: str
|
||
|
|
|
||
|
|
@property
|
||
|
|
def analysis_route(self) -> Literal["confirmed_subfamily"]:
|
||
|
|
return "confirmed_subfamily"
|
||
|
|
|
||
|
|
def canonical_input(self) -> dict[str, Any]:
|
||
|
|
return self.model_dump(
|
||
|
|
mode="json",
|
||
|
|
exclude={"source_confirmation_digest"},
|
||
|
|
)
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def build(cls, **values: Any) -> "SourceConfirmationV1":
|
||
|
|
payload = dict(values)
|
||
|
|
payload.pop("source_confirmation_digest", None)
|
||
|
|
version = payload.get(
|
||
|
|
"source_confirmation_version",
|
||
|
|
"source_confirmation_v1",
|
||
|
|
)
|
||
|
|
payload["source_confirmation_version"] = version
|
||
|
|
confirmed_at = payload.get("confirmed_at")
|
||
|
|
if isinstance(confirmed_at, str):
|
||
|
|
confirmed_at = datetime.fromisoformat(confirmed_at.replace("Z", "+00:00"))
|
||
|
|
payload["confirmed_at"] = confirmed_at
|
||
|
|
canonical = dict(payload)
|
||
|
|
if isinstance(confirmed_at, datetime):
|
||
|
|
canonical["confirmed_at"] = confirmed_at.isoformat().replace("+00:00", "Z")
|
||
|
|
return cls(
|
||
|
|
**payload,
|
||
|
|
source_confirmation_digest=_digest(canonical),
|
||
|
|
)
|
||
|
|
|
||
|
|
@model_validator(mode="after")
|
||
|
|
def validate_confirmation(self) -> "SourceConfirmationV1":
|
||
|
|
for field_name in (
|
||
|
|
"job_id",
|
||
|
|
"species",
|
||
|
|
"family",
|
||
|
|
"scope_id",
|
||
|
|
"member_set_id",
|
||
|
|
"manifest_path",
|
||
|
|
"confirmed_subfamily_path",
|
||
|
|
"label_canonicalization_version",
|
||
|
|
"canonical_labels_path",
|
||
|
|
"member_id_path",
|
||
|
|
"member_pep_path",
|
||
|
|
):
|
||
|
|
_require_nonblank(getattr(self, field_name), field_name)
|
||
|
|
for field_name in (
|
||
|
|
"manifest_sha256",
|
||
|
|
"confirmed_subfamily_raw_sha256",
|
||
|
|
"canonical_labels_sha256",
|
||
|
|
"gene_label_digest",
|
||
|
|
"member_id_sha256",
|
||
|
|
"member_pep_sha256",
|
||
|
|
):
|
||
|
|
_require_sha256(getattr(self, field_name), field_name)
|
||
|
|
expected_member_prefix = f"{self.scope_id}:confirmed_subfamily:"
|
||
|
|
suffix = (
|
||
|
|
self.member_set_id[len(expected_member_prefix) :]
|
||
|
|
if self.member_set_id.startswith(expected_member_prefix)
|
||
|
|
else ""
|
||
|
|
)
|
||
|
|
if suffix != "all_members":
|
||
|
|
raise ValueError(
|
||
|
|
"member_set_id must bind confirmed_subfamily to the valid nonempty "
|
||
|
|
"suffix all_members"
|
||
|
|
)
|
||
|
|
for field_name in (
|
||
|
|
"manifest_path",
|
||
|
|
"confirmed_subfamily_path",
|
||
|
|
"canonical_labels_path",
|
||
|
|
"member_id_path",
|
||
|
|
"member_pep_path",
|
||
|
|
):
|
||
|
|
_require_job_relative_path(getattr(self, field_name), field_name)
|
||
|
|
_require_aware(self.confirmed_at, "confirmed_at")
|
||
|
|
_require_sha256(
|
||
|
|
self.source_confirmation_digest,
|
||
|
|
"source_confirmation_digest",
|
||
|
|
)
|
||
|
|
if self.source_confirmation_digest != _digest(self.canonical_input()):
|
||
|
|
raise ValueError(
|
||
|
|
"source_confirmation_digest does not match canonical confirmation input"
|
||
|
|
)
|
||
|
|
return self
|
||
|
|
|
||
|
|
|
||
|
|
class FamilyConfirmationBinding(_WorkflowModel):
|
||
|
|
family_binding_version: Literal["family_confirmation_binding_v1"] = (
|
||
|
|
"family_confirmation_binding_v1"
|
||
|
|
)
|
||
|
|
source_confirmation: SourceConfirmationV1
|
||
|
|
source_confirmation_digest: str
|
||
|
|
rnaseq_scope_id: WorkflowIdentity
|
||
|
|
family_scope_id: WorkflowIdentity
|
||
|
|
family_projection_fingerprint: DependencyFingerprint
|
||
|
|
|
||
|
|
@model_validator(mode="after")
|
||
|
|
def validate_binding(self) -> "FamilyConfirmationBinding":
|
||
|
|
if (
|
||
|
|
self.source_confirmation.job_id
|
||
|
|
!= self.family_projection_fingerprint.job_id
|
||
|
|
):
|
||
|
|
raise ValueError("family binding and fingerprint must use the same job")
|
||
|
|
if self.source_confirmation_digest != self.source_confirmation.source_confirmation_digest:
|
||
|
|
raise ValueError("family binding must use the exact source confirmation digest")
|
||
|
|
_require_sha256(
|
||
|
|
self.source_confirmation_digest,
|
||
|
|
"source_confirmation_digest",
|
||
|
|
)
|
||
|
|
if self.family_scope_id != self.source_confirmation.scope_id:
|
||
|
|
raise ValueError("family_scope_id must match source confirmation scope")
|
||
|
|
fingerprint = self.family_projection_fingerprint
|
||
|
|
if (
|
||
|
|
fingerprint.dependency_kind != "family_projection"
|
||
|
|
or fingerprint.rnaseq_scope_id != self.rnaseq_scope_id
|
||
|
|
or fingerprint.family_scope_id != self.family_scope_id
|
||
|
|
or fingerprint.scope_id != self.source_confirmation.scope_id
|
||
|
|
or fingerprint.analysis_route != "family_member"
|
||
|
|
or fingerprint.member_set_id != self.source_confirmation.member_set_id
|
||
|
|
):
|
||
|
|
raise ValueError("family binding identity must match projection fingerprint")
|
||
|
|
source = self.source_confirmation
|
||
|
|
if not any(
|
||
|
|
evidence.evidence_kind == EvidenceKind.SUBFAMILY_CONFIRMATION
|
||
|
|
and evidence.manifest_path == source.manifest_path
|
||
|
|
and evidence.manifest_sha256 == source.manifest_sha256
|
||
|
|
and evidence.scope_id == source.scope_id
|
||
|
|
and evidence.analysis_route == source.analysis_route
|
||
|
|
and evidence.member_set_id == source.member_set_id
|
||
|
|
and evidence.source_confirmation_digest == self.source_confirmation_digest
|
||
|
|
for evidence in self.family_projection_fingerprint.evidence
|
||
|
|
):
|
||
|
|
raise ValueError(
|
||
|
|
"family projection fingerprint must include the exact source confirmation digest"
|
||
|
|
)
|
||
|
|
return self
|
||
|
|
|
||
|
|
|
||
|
|
class ConfirmationEvidenceStatus(str, Enum):
|
||
|
|
MISSING = "missing"
|
||
|
|
DRAFT = "draft"
|
||
|
|
CONFIRMED = "confirmed"
|
||
|
|
INVALID = "invalid"
|
||
|
|
|
||
|
|
|
||
|
|
def _validate_confirmation_evidence_state(
|
||
|
|
*,
|
||
|
|
job_id: str,
|
||
|
|
status: ConfirmationEvidenceStatus,
|
||
|
|
schema_version: str,
|
||
|
|
confirmed_artifact_path: str,
|
||
|
|
confirmed_artifact_sha256: str,
|
||
|
|
evidence: EvidenceRef | None,
|
||
|
|
expected_kind: EvidenceKind,
|
||
|
|
) -> None:
|
||
|
|
_require_nonblank(job_id, "job_id")
|
||
|
|
if status != ConfirmationEvidenceStatus.CONFIRMED:
|
||
|
|
if (
|
||
|
|
schema_version
|
||
|
|
or confirmed_artifact_path
|
||
|
|
or confirmed_artifact_sha256
|
||
|
|
or evidence is not None
|
||
|
|
):
|
||
|
|
raise ValueError("unconfirmed state cannot carry evidence")
|
||
|
|
return
|
||
|
|
_require_nonblank(schema_version, "schema_version")
|
||
|
|
_require_job_relative_path(confirmed_artifact_path, "confirmed_artifact_path")
|
||
|
|
_require_sha256(confirmed_artifact_sha256, "confirmed_artifact_sha256")
|
||
|
|
if evidence is not None:
|
||
|
|
evidence = EvidenceRef.model_validate(
|
||
|
|
evidence.model_dump(mode="python", round_trip=True)
|
||
|
|
)
|
||
|
|
if evidence is None or evidence.evidence_kind != expected_kind:
|
||
|
|
raise ValueError(f"confirmation state requires {expected_kind.value} evidence")
|
||
|
|
if evidence.job_id != job_id:
|
||
|
|
raise ValueError("confirmation evidence must belong to the same job")
|
||
|
|
if (
|
||
|
|
evidence.authority != "formal_confirmation"
|
||
|
|
or evidence.manifest_identity_version != "formal_v1"
|
||
|
|
):
|
||
|
|
raise ValueError("confirmed state requires formal confirmation evidence")
|
||
|
|
if confirmed_artifact_path != evidence.confirmed_artifact_path:
|
||
|
|
raise ValueError("confirmed artifact path must match evidence")
|
||
|
|
if confirmed_artifact_sha256 != evidence.confirmed_artifact_sha256:
|
||
|
|
raise ValueError("confirmed artifact hash must match evidence")
|
||
|
|
if schema_version != evidence.confirmation_schema_version:
|
||
|
|
raise ValueError("confirmation schema version must match evidence")
|
||
|
|
|
||
|
|
|
||
|
|
class ConfigEvidenceState(_WorkflowModel):
|
||
|
|
config_evidence_version: Literal["config_evidence_state_v1"] = (
|
||
|
|
"config_evidence_state_v1"
|
||
|
|
)
|
||
|
|
job_id: str
|
||
|
|
status: ConfirmationEvidenceStatus
|
||
|
|
schema_version: str = ""
|
||
|
|
confirmed_artifact_path: str = ""
|
||
|
|
confirmed_artifact_sha256: str = ""
|
||
|
|
evidence: EvidenceRef | None = None
|
||
|
|
|
||
|
|
@model_validator(mode="after")
|
||
|
|
def validate_config_evidence(self) -> "ConfigEvidenceState":
|
||
|
|
_validate_confirmation_evidence_state(
|
||
|
|
job_id=self.job_id,
|
||
|
|
status=self.status,
|
||
|
|
schema_version=self.schema_version,
|
||
|
|
confirmed_artifact_path=self.confirmed_artifact_path,
|
||
|
|
confirmed_artifact_sha256=self.confirmed_artifact_sha256,
|
||
|
|
evidence=self.evidence,
|
||
|
|
expected_kind=EvidenceKind.CONFIG_CONFIRMATION,
|
||
|
|
)
|
||
|
|
return self
|
||
|
|
|
||
|
|
|
||
|
|
class SamplesEvidenceState(_WorkflowModel):
|
||
|
|
samples_evidence_version: Literal["samples_evidence_state_v1"] = (
|
||
|
|
"samples_evidence_state_v1"
|
||
|
|
)
|
||
|
|
job_id: str
|
||
|
|
status: ConfirmationEvidenceStatus
|
||
|
|
schema_version: str = ""
|
||
|
|
confirmed_artifact_path: str = ""
|
||
|
|
confirmed_artifact_sha256: str = ""
|
||
|
|
evidence: EvidenceRef | None = None
|
||
|
|
|
||
|
|
@model_validator(mode="after")
|
||
|
|
def validate_samples_evidence(self) -> "SamplesEvidenceState":
|
||
|
|
_validate_confirmation_evidence_state(
|
||
|
|
job_id=self.job_id,
|
||
|
|
status=self.status,
|
||
|
|
schema_version=self.schema_version,
|
||
|
|
confirmed_artifact_path=self.confirmed_artifact_path,
|
||
|
|
confirmed_artifact_sha256=self.confirmed_artifact_sha256,
|
||
|
|
evidence=self.evidence,
|
||
|
|
expected_kind=EvidenceKind.SAMPLES_CONFIRMATION,
|
||
|
|
)
|
||
|
|
return self
|
||
|
|
|
||
|
|
|
||
|
|
class StageEvidenceStatus(str, Enum):
|
||
|
|
MISSING = "missing"
|
||
|
|
RUNNING = "running"
|
||
|
|
SUCCESS = "success"
|
||
|
|
PARTIAL_SUCCESS = "partial_success"
|
||
|
|
FAILED = "failed"
|
||
|
|
|
||
|
|
|
||
|
|
class StageEvidenceState(_WorkflowModel):
|
||
|
|
stage_evidence_version: Literal["stage_evidence_state_v1"] = (
|
||
|
|
"stage_evidence_state_v1"
|
||
|
|
)
|
||
|
|
job_id: str
|
||
|
|
stage: WorkflowIdentity
|
||
|
|
status: StageEvidenceStatus
|
||
|
|
evidence: EvidenceRef | None = None
|
||
|
|
|
||
|
|
@model_validator(mode="after")
|
||
|
|
def validate_stage_evidence(self) -> "StageEvidenceState":
|
||
|
|
_require_nonblank(self.job_id, "job_id")
|
||
|
|
if self.evidence is not None:
|
||
|
|
evidence = EvidenceRef.model_validate(
|
||
|
|
self.evidence.model_dump(mode="python", round_trip=True)
|
||
|
|
)
|
||
|
|
if evidence.job_id != self.job_id:
|
||
|
|
raise ValueError("stage evidence must belong to the same job")
|
||
|
|
if self.stage != evidence.stage:
|
||
|
|
raise ValueError("stage must match evidence stage")
|
||
|
|
if evidence.evidence_kind in {
|
||
|
|
EvidenceKind.CONFIG_CONFIRMATION,
|
||
|
|
EvidenceKind.SAMPLES_CONFIRMATION,
|
||
|
|
}:
|
||
|
|
raise ValueError("stage evidence cannot use config/sample confirmation")
|
||
|
|
if evidence.evidence_kind == EvidenceKind.WHOLE_GENOME_RNA and not self.stage.startswith("rnaseq_"):
|
||
|
|
raise ValueError("whole-genome RNA evidence requires an RNA stage")
|
||
|
|
if evidence.evidence_kind == EvidenceKind.SUBFAMILY_CONFIRMATION and self.stage != "subfamily_confirm":
|
||
|
|
raise ValueError("subfamily evidence requires the subfamily_confirm stage")
|
||
|
|
if self.status.value != evidence.manifest_status.value:
|
||
|
|
raise ValueError("stage status must match evidence manifest status")
|
||
|
|
elif self.status not in {StageEvidenceStatus.MISSING, StageEvidenceStatus.RUNNING}:
|
||
|
|
raise ValueError("terminal stage evidence requires a manifest")
|
||
|
|
return self
|
||
|
|
|
||
|
|
|
||
|
|
class TaskEvidenceStatus(str, Enum):
|
||
|
|
PENDING = "pending"
|
||
|
|
QUEUED = "queued"
|
||
|
|
WAITING_RESOURCE = "waiting_resource"
|
||
|
|
RUNNING = "running"
|
||
|
|
WAITING_CONFIRMATION = "waiting_confirmation"
|
||
|
|
SUCCESS = "success"
|
||
|
|
COMPLETED = "completed"
|
||
|
|
FAILED = "failed"
|
||
|
|
FAILURE = "failure"
|
||
|
|
CANCELLED = "cancelled"
|
||
|
|
REVOKED = "revoked"
|
||
|
|
QUEUE_UNAVAILABLE = "queue_unavailable"
|
||
|
|
STALLED = "stalled"
|
||
|
|
STALLED_WARNING = "stalled_warning"
|
||
|
|
|
||
|
|
|
||
|
|
class TaskEvidenceSource(str, Enum):
|
||
|
|
RUNTIME = "runtime"
|
||
|
|
RESOURCE_LEDGER = "resource_ledger"
|
||
|
|
|
||
|
|
|
||
|
|
class WorkClaimKind(str, Enum):
|
||
|
|
JOB_WIDE = "job_wide"
|
||
|
|
STAGE = "stage"
|
||
|
|
RNA_EXPERIMENT = "rna_experiment"
|
||
|
|
FAMILY_PROJECTION = "family_projection"
|
||
|
|
|
||
|
|
|
||
|
|
class WorkClaim(_WorkflowModel):
|
||
|
|
work_claim_version: Literal["work_claim_v1"] = "work_claim_v1"
|
||
|
|
kind: WorkClaimKind
|
||
|
|
job_id: str
|
||
|
|
target_stage: WorkflowIdentity
|
||
|
|
rnaseq_scope_id: str = ""
|
||
|
|
family_scope_id: str = ""
|
||
|
|
scope_id: str = ""
|
||
|
|
analysis_route: str = ""
|
||
|
|
member_set_id: str = ""
|
||
|
|
source_confirmation_digest: str = ""
|
||
|
|
resource_key: str
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def build(
|
||
|
|
cls,
|
||
|
|
*,
|
||
|
|
kind: WorkClaimKind,
|
||
|
|
job_id: str,
|
||
|
|
target_stage: str,
|
||
|
|
rnaseq_scope_id: str = "",
|
||
|
|
family_scope_id: str = "",
|
||
|
|
scope_id: str = "",
|
||
|
|
analysis_route: str = "",
|
||
|
|
member_set_id: str = "",
|
||
|
|
source_confirmation_digest: str = "",
|
||
|
|
) -> "WorkClaim":
|
||
|
|
values = {
|
||
|
|
"kind": kind,
|
||
|
|
"job_id": job_id,
|
||
|
|
"target_stage": target_stage,
|
||
|
|
"rnaseq_scope_id": rnaseq_scope_id,
|
||
|
|
"family_scope_id": family_scope_id,
|
||
|
|
"scope_id": scope_id,
|
||
|
|
"analysis_route": analysis_route,
|
||
|
|
"member_set_id": member_set_id,
|
||
|
|
"source_confirmation_digest": source_confirmation_digest,
|
||
|
|
}
|
||
|
|
return cls(**values, resource_key=cls._resource_key_for(values))
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def _resource_key_for(values: Mapping[str, Any]) -> str:
|
||
|
|
kind = WorkClaimKind(values["kind"])
|
||
|
|
identity: dict[str, str] = {
|
||
|
|
"work_claim_version": "work_claim_v1",
|
||
|
|
"kind": kind.value,
|
||
|
|
"job_id": str(values["job_id"]),
|
||
|
|
}
|
||
|
|
if kind == WorkClaimKind.STAGE:
|
||
|
|
identity["target_stage"] = str(values["target_stage"])
|
||
|
|
if values.get("scope_id"):
|
||
|
|
identity.update(
|
||
|
|
scope_id=str(values["scope_id"]),
|
||
|
|
analysis_route=str(values["analysis_route"]),
|
||
|
|
member_set_id=str(values["member_set_id"]),
|
||
|
|
)
|
||
|
|
elif kind == WorkClaimKind.RNA_EXPERIMENT:
|
||
|
|
identity["rnaseq_scope_id"] = str(values["rnaseq_scope_id"])
|
||
|
|
elif kind == WorkClaimKind.FAMILY_PROJECTION:
|
||
|
|
identity.update(
|
||
|
|
rnaseq_scope_id=str(values["rnaseq_scope_id"]),
|
||
|
|
family_scope_id=str(values["family_scope_id"]),
|
||
|
|
scope_id=str(values["scope_id"]),
|
||
|
|
analysis_route=str(values["analysis_route"]),
|
||
|
|
member_set_id=str(values["member_set_id"]),
|
||
|
|
source_confirmation_digest=str(values["source_confirmation_digest"]),
|
||
|
|
)
|
||
|
|
return _digest(identity)
|
||
|
|
|
||
|
|
@model_validator(mode="after")
|
||
|
|
def validate_claim(self) -> "WorkClaim":
|
||
|
|
_require_nonblank(self.job_id, "job_id")
|
||
|
|
optional_identities = {
|
||
|
|
"rnaseq_scope_id": self.rnaseq_scope_id,
|
||
|
|
"family_scope_id": self.family_scope_id,
|
||
|
|
"scope_id": self.scope_id,
|
||
|
|
"analysis_route": self.analysis_route,
|
||
|
|
"member_set_id": self.member_set_id,
|
||
|
|
}
|
||
|
|
for field_name, value in optional_identities.items():
|
||
|
|
if value and not re.fullmatch(_IDENTITY_RE, value):
|
||
|
|
raise ValueError(f"{field_name} must be a workflow identity")
|
||
|
|
|
||
|
|
if self.kind == WorkClaimKind.JOB_WIDE:
|
||
|
|
if any(optional_identities.values()) or self.source_confirmation_digest:
|
||
|
|
raise ValueError("scope fields are not valid for job_wide")
|
||
|
|
elif self.kind == WorkClaimKind.STAGE:
|
||
|
|
if self.rnaseq_scope_id or self.family_scope_id or self.source_confirmation_digest:
|
||
|
|
raise ValueError("RNA/family scope is not valid for stage")
|
||
|
|
_validate_projection_identity(
|
||
|
|
self.scope_id,
|
||
|
|
self.analysis_route,
|
||
|
|
self.member_set_id,
|
||
|
|
)
|
||
|
|
elif self.kind == WorkClaimKind.RNA_EXPERIMENT:
|
||
|
|
if not self.rnaseq_scope_id:
|
||
|
|
raise ValueError("rnaseq_scope_id is required for RNA experiment")
|
||
|
|
if (
|
||
|
|
self.family_scope_id
|
||
|
|
or self.scope_id
|
||
|
|
or self.analysis_route
|
||
|
|
or self.member_set_id
|
||
|
|
or self.source_confirmation_digest
|
||
|
|
):
|
||
|
|
raise ValueError("family scope is not valid for RNA experiment")
|
||
|
|
elif self.kind == WorkClaimKind.FAMILY_PROJECTION:
|
||
|
|
if not self.rnaseq_scope_id or not self.family_scope_id:
|
||
|
|
raise ValueError("family projection requires all typed identity fields")
|
||
|
|
if self.family_scope_id != self.scope_id:
|
||
|
|
raise ValueError("family_scope_id must equal scope_id")
|
||
|
|
if self.analysis_route != "family_member":
|
||
|
|
raise ValueError("family projection analysis_route must be family_member")
|
||
|
|
_validate_projection_identity(
|
||
|
|
self.scope_id,
|
||
|
|
self.analysis_route,
|
||
|
|
self.member_set_id,
|
||
|
|
member_route="confirmed_subfamily",
|
||
|
|
)
|
||
|
|
expected_member_set_id = (
|
||
|
|
f"{self.scope_id}:confirmed_subfamily:all_members"
|
||
|
|
)
|
||
|
|
if self.member_set_id != expected_member_set_id:
|
||
|
|
raise ValueError(
|
||
|
|
f"member_set_id must be exactly {expected_member_set_id}"
|
||
|
|
)
|
||
|
|
_require_sha256(
|
||
|
|
self.source_confirmation_digest,
|
||
|
|
"source_confirmation_digest",
|
||
|
|
)
|
||
|
|
|
||
|
|
expected = self._resource_key_for(self.model_dump(mode="python"))
|
||
|
|
_require_sha256(self.resource_key, "resource_key")
|
||
|
|
if self.resource_key != expected:
|
||
|
|
raise ValueError("resource_key must match the canonical work identity")
|
||
|
|
return self
|
||
|
|
|
||
|
|
|
||
|
|
class TaskClaimAuthority(str, Enum):
|
||
|
|
TYPED = "typed"
|
||
|
|
LEGACY_UNSCOPED = "legacy_unscoped"
|
||
|
|
|
||
|
|
|
||
|
|
class TaskEvidenceState(_WorkflowModel):
|
||
|
|
task_evidence_version: Literal["task_evidence_state_v1"] = (
|
||
|
|
"task_evidence_state_v1"
|
||
|
|
)
|
||
|
|
job_id: str
|
||
|
|
task_id: WorkflowIdentity
|
||
|
|
stage: WorkflowIdentity
|
||
|
|
status: TaskEvidenceStatus
|
||
|
|
source: TaskEvidenceSource
|
||
|
|
source_revision: StrictInt = Field(ge=0)
|
||
|
|
claim_authority: TaskClaimAuthority = TaskClaimAuthority.LEGACY_UNSCOPED
|
||
|
|
claim: WorkClaim | None = None
|
||
|
|
|
||
|
|
@model_validator(mode="after")
|
||
|
|
def validate_task_evidence(self) -> "TaskEvidenceState":
|
||
|
|
_require_nonblank(self.job_id, "job_id")
|
||
|
|
resource_statuses = {
|
||
|
|
TaskEvidenceStatus.QUEUED,
|
||
|
|
TaskEvidenceStatus.WAITING_RESOURCE,
|
||
|
|
TaskEvidenceStatus.RUNNING,
|
||
|
|
TaskEvidenceStatus.SUCCESS,
|
||
|
|
TaskEvidenceStatus.COMPLETED,
|
||
|
|
TaskEvidenceStatus.FAILED,
|
||
|
|
TaskEvidenceStatus.FAILURE,
|
||
|
|
TaskEvidenceStatus.CANCELLED,
|
||
|
|
TaskEvidenceStatus.REVOKED,
|
||
|
|
}
|
||
|
|
runtime_statuses = {
|
||
|
|
TaskEvidenceStatus.PENDING,
|
||
|
|
TaskEvidenceStatus.QUEUED,
|
||
|
|
TaskEvidenceStatus.WAITING_RESOURCE,
|
||
|
|
TaskEvidenceStatus.WAITING_CONFIRMATION,
|
||
|
|
TaskEvidenceStatus.RUNNING,
|
||
|
|
TaskEvidenceStatus.SUCCESS,
|
||
|
|
TaskEvidenceStatus.COMPLETED,
|
||
|
|
TaskEvidenceStatus.FAILED,
|
||
|
|
TaskEvidenceStatus.CANCELLED,
|
||
|
|
TaskEvidenceStatus.QUEUE_UNAVAILABLE,
|
||
|
|
TaskEvidenceStatus.STALLED,
|
||
|
|
TaskEvidenceStatus.STALLED_WARNING,
|
||
|
|
}
|
||
|
|
allowed = (
|
||
|
|
resource_statuses
|
||
|
|
if self.source == TaskEvidenceSource.RESOURCE_LEDGER
|
||
|
|
else runtime_statuses
|
||
|
|
)
|
||
|
|
if self.status not in allowed:
|
||
|
|
raise ValueError(f"{self.source.value} status is not valid for that source")
|
||
|
|
if self.claim_authority == TaskClaimAuthority.TYPED:
|
||
|
|
if self.claim is None:
|
||
|
|
raise ValueError("typed task evidence requires a claim")
|
||
|
|
if self.claim.job_id != self.job_id:
|
||
|
|
raise ValueError("claim job must match task evidence job")
|
||
|
|
if self.claim.target_stage != self.stage:
|
||
|
|
raise ValueError("claim stage must match task evidence stage")
|
||
|
|
elif self.claim is not None:
|
||
|
|
raise ValueError("legacy_unscoped task evidence cannot retain a claim")
|
||
|
|
return self
|
||
|
|
|
||
|
|
|
||
|
|
class LargeInputEvidence(_WorkflowModel):
|
||
|
|
large_input_evidence_version: Literal["large_input_evidence_v2"] = (
|
||
|
|
"large_input_evidence_v2"
|
||
|
|
)
|
||
|
|
job_id: str
|
||
|
|
path: str
|
||
|
|
size: int = Field(gt=0)
|
||
|
|
mtime_ns: int = Field(gt=0)
|
||
|
|
device: int = Field(ge=0)
|
||
|
|
inode: int = Field(gt=0)
|
||
|
|
ctime_ns: int = Field(gt=0)
|
||
|
|
recorded_sha256: str
|
||
|
|
cache_key: str
|
||
|
|
source: Literal["manifest_metadata", "reproducibility_cache", "metadata_snapshot"]
|
||
|
|
|
||
|
|
def canonical_input(self) -> dict[str, Any]:
|
||
|
|
return {
|
||
|
|
"path": self.path,
|
||
|
|
"size": self.size,
|
||
|
|
"mtime_ns": self.mtime_ns,
|
||
|
|
"device": self.device,
|
||
|
|
"inode": self.inode,
|
||
|
|
"ctime_ns": self.ctime_ns,
|
||
|
|
"source": self.source,
|
||
|
|
"recorded_sha256": self.recorded_sha256,
|
||
|
|
}
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def build(cls, **values: Any) -> "LargeInputEvidence":
|
||
|
|
payload = dict(values)
|
||
|
|
payload.pop("cache_key", None)
|
||
|
|
required = (
|
||
|
|
"path",
|
||
|
|
"size",
|
||
|
|
"mtime_ns",
|
||
|
|
"device",
|
||
|
|
"inode",
|
||
|
|
"ctime_ns",
|
||
|
|
"source",
|
||
|
|
"recorded_sha256",
|
||
|
|
)
|
||
|
|
if any(key not in payload for key in required):
|
||
|
|
# Delegate missing-field reporting to Pydantic so callers always
|
||
|
|
# receive the model's public ValidationError contract.
|
||
|
|
return cls(**payload)
|
||
|
|
canonical = {
|
||
|
|
key: payload[key]
|
||
|
|
for key in required
|
||
|
|
}
|
||
|
|
return cls(**payload, cache_key=_digest(canonical))
|
||
|
|
|
||
|
|
@model_validator(mode="after")
|
||
|
|
def validate_large_input(self) -> "LargeInputEvidence":
|
||
|
|
_require_nonblank(self.job_id, "job_id")
|
||
|
|
_require_job_relative_path(self.path, "path")
|
||
|
|
if len(self.path) > 4096:
|
||
|
|
raise ValueError("path is too long")
|
||
|
|
_require_sha256(self.recorded_sha256, "recorded_sha256")
|
||
|
|
_require_sha256(self.cache_key, "cache_key")
|
||
|
|
if self.cache_key != _digest(self.canonical_input()):
|
||
|
|
raise ValueError("cache_key does not match canonical large-input identity")
|
||
|
|
return self
|
||
|
|
|
||
|
|
|
||
|
|
class SnapshotIncompleteReason(str, Enum):
|
||
|
|
PATH_BUDGET_EXHAUSTED = "path_budget_exhausted"
|
||
|
|
METADATA_BUDGET_EXHAUSTED = "metadata_budget_exhausted"
|
||
|
|
WALL_TIME_BUDGET_EXHAUSTED = "wall_time_budget_exhausted"
|
||
|
|
SCAN_ERROR = "scan_error"
|
||
|
|
|
||
|
|
|
||
|
|
class SnapshotCompleteness(_WorkflowModel):
|
||
|
|
snapshot_completeness_version: Literal["snapshot_completeness_v1"] = (
|
||
|
|
"snapshot_completeness_v1"
|
||
|
|
)
|
||
|
|
complete: StrictBool
|
||
|
|
incomplete_reasons: tuple[SnapshotIncompleteReason, ...] = ()
|
||
|
|
scanned_paths: int = Field(ge=0)
|
||
|
|
scanned_metadata_bytes: int = Field(ge=0)
|
||
|
|
elapsed_ms: int = Field(ge=0)
|
||
|
|
max_paths: int = Field(gt=0)
|
||
|
|
max_metadata_bytes: int = Field(gt=0)
|
||
|
|
max_wall_time_ms: int = Field(gt=0)
|
||
|
|
|
||
|
|
@model_validator(mode="after")
|
||
|
|
def validate_completeness(self) -> "SnapshotCompleteness":
|
||
|
|
if self.complete and self.incomplete_reasons:
|
||
|
|
raise ValueError("complete scan cannot have incomplete_reasons")
|
||
|
|
if not self.complete and not self.incomplete_reasons:
|
||
|
|
raise ValueError("incomplete scan requires incomplete_reasons")
|
||
|
|
if len(self.incomplete_reasons) != len(set(self.incomplete_reasons)):
|
||
|
|
raise ValueError("incomplete_reasons must be unique")
|
||
|
|
if self.scanned_metadata_bytes < self.scanned_paths:
|
||
|
|
raise ValueError("scanned metadata bytes cannot be less than scanned paths")
|
||
|
|
if (
|
||
|
|
self.scanned_paths > self.max_paths
|
||
|
|
or self.scanned_metadata_bytes > self.max_metadata_bytes
|
||
|
|
or self.elapsed_ms > self.max_wall_time_ms
|
||
|
|
):
|
||
|
|
raise ValueError("scan counters cannot exceed declared budgets")
|
||
|
|
reason_set = set(self.incomplete_reasons)
|
||
|
|
if (
|
||
|
|
SnapshotIncompleteReason.PATH_BUDGET_EXHAUSTED in reason_set
|
||
|
|
and self.scanned_paths != self.max_paths
|
||
|
|
):
|
||
|
|
raise ValueError("path budget reason requires reaching max_paths")
|
||
|
|
if (
|
||
|
|
SnapshotIncompleteReason.METADATA_BUDGET_EXHAUSTED in reason_set
|
||
|
|
and self.scanned_metadata_bytes != self.max_metadata_bytes
|
||
|
|
):
|
||
|
|
raise ValueError("metadata budget reason requires reaching max_metadata_bytes")
|
||
|
|
if (
|
||
|
|
SnapshotIncompleteReason.WALL_TIME_BUDGET_EXHAUSTED in reason_set
|
||
|
|
and self.elapsed_ms != self.max_wall_time_ms
|
||
|
|
):
|
||
|
|
raise ValueError("wall time budget reason requires reaching max_wall_time_ms")
|
||
|
|
return self
|
||
|
|
|
||
|
|
|
||
|
|
class WorkflowStateSnapshot(_WorkflowModel):
|
||
|
|
workflow_state_version: Literal["workflow_state_snapshot_v2"] = (
|
||
|
|
"workflow_state_snapshot_v2"
|
||
|
|
)
|
||
|
|
active_job_id: str
|
||
|
|
revision: int = Field(ge=0)
|
||
|
|
completeness: SnapshotCompleteness
|
||
|
|
evidence: tuple[EvidenceRef, ...] = ()
|
||
|
|
config_evidence: ConfigEvidenceState | None = None
|
||
|
|
samples_evidence: SamplesEvidenceState | None = None
|
||
|
|
stage_evidence: tuple[StageEvidenceState, ...] = ()
|
||
|
|
task_evidence: tuple[TaskEvidenceState, ...] = ()
|
||
|
|
large_inputs: tuple[LargeInputEvidence, ...] = ()
|
||
|
|
rna_fact_projections: tuple[RNAFactProjectionState, ...] = ()
|
||
|
|
rna_route: RNARoute | None = None
|
||
|
|
foreground_mode: RNAForegroundMode | None = None
|
||
|
|
species: SpeciesCode | None = None
|
||
|
|
family: GeneFamily | None = None
|
||
|
|
rnaseq_scope_id: WorkflowIdentity | None = None
|
||
|
|
family_scope_id: WorkflowIdentity | None = None
|
||
|
|
scope_id: WorkflowIdentity | None = None
|
||
|
|
analysis_route: WorkflowIdentity | None = None
|
||
|
|
member_set_id: WorkflowIdentity | None = None
|
||
|
|
family_binding: FamilyConfirmationBinding | None = None
|
||
|
|
|
||
|
|
@property
|
||
|
|
def complete(self) -> bool:
|
||
|
|
return self.completeness.complete
|
||
|
|
|
||
|
|
@property
|
||
|
|
def incomplete_reasons(self) -> tuple[SnapshotIncompleteReason, ...]:
|
||
|
|
return self.completeness.incomplete_reasons
|
||
|
|
|
||
|
|
@model_validator(mode="after")
|
||
|
|
def validate_snapshot(self) -> "WorkflowStateSnapshot":
|
||
|
|
_require_nonblank(self.active_job_id, "active_job_id")
|
||
|
|
for item in self.evidence:
|
||
|
|
EvidenceRef.model_validate(
|
||
|
|
item.model_dump(mode="python", round_trip=True)
|
||
|
|
)
|
||
|
|
evidence_ids = [item.evidence_id for item in self.evidence]
|
||
|
|
if len(evidence_ids) != len(set(evidence_ids)):
|
||
|
|
raise ValueError("snapshot evidence IDs must be unique")
|
||
|
|
if any(item.job_id != self.active_job_id for item in self.evidence):
|
||
|
|
raise ValueError("snapshot evidence must belong to the active job")
|
||
|
|
state_jobs = [
|
||
|
|
state.job_id
|
||
|
|
for state in (
|
||
|
|
self.config_evidence,
|
||
|
|
self.samples_evidence,
|
||
|
|
*self.stage_evidence,
|
||
|
|
*self.task_evidence,
|
||
|
|
*self.large_inputs,
|
||
|
|
)
|
||
|
|
if state is not None
|
||
|
|
]
|
||
|
|
if any(job_id != self.active_job_id for job_id in state_jobs):
|
||
|
|
raise ValueError("snapshot state evidence must belong to the active job")
|
||
|
|
task_ids = [item.task_id for item in self.task_evidence]
|
||
|
|
if len(task_ids) != len(set(task_ids)):
|
||
|
|
raise ValueError("snapshot task IDs must be unique")
|
||
|
|
large_paths = [item.path for item in self.large_inputs]
|
||
|
|
if len(large_paths) != len(set(large_paths)):
|
||
|
|
raise ValueError("snapshot large-input paths must be unique")
|
||
|
|
cache_keys = [item.cache_key for item in self.large_inputs]
|
||
|
|
if len(cache_keys) != len(set(cache_keys)):
|
||
|
|
raise ValueError("snapshot large-input cache keys must be unique")
|
||
|
|
projection_kinds = []
|
||
|
|
projection_paths = []
|
||
|
|
for item in self.rna_fact_projections:
|
||
|
|
validated = RNAFactProjectionState.model_validate(
|
||
|
|
item.model_dump(mode="python", round_trip=True)
|
||
|
|
)
|
||
|
|
if validated.fact.job_id != self.active_job_id:
|
||
|
|
raise ValueError("RNA fact projection must belong to active job")
|
||
|
|
projection_kinds.append(validated.fact.fact_kind)
|
||
|
|
projection_paths.append(validated.record_path)
|
||
|
|
if len(projection_kinds) != len(set(projection_kinds)):
|
||
|
|
raise ValueError("snapshot RNA fact kinds must be unique")
|
||
|
|
if len(projection_paths) != len(set(projection_paths)):
|
||
|
|
raise ValueError("snapshot RNA fact paths must be unique")
|
||
|
|
stage_keys = []
|
||
|
|
for item in self.stage_evidence:
|
||
|
|
evidence = item.evidence
|
||
|
|
stage_keys.append(
|
||
|
|
(
|
||
|
|
item.stage,
|
||
|
|
evidence.rnaseq_scope_id if evidence else "",
|
||
|
|
evidence.scope_id if evidence else "",
|
||
|
|
evidence.analysis_route if evidence else "",
|
||
|
|
evidence.member_set_id if evidence else "",
|
||
|
|
)
|
||
|
|
)
|
||
|
|
if len(stage_keys) != len(set(stage_keys)):
|
||
|
|
raise ValueError("snapshot stage identity must be unique")
|
||
|
|
carrier_lower_bound = (
|
||
|
|
len(self.evidence)
|
||
|
|
+ int(self.config_evidence is not None)
|
||
|
|
+ int(self.samples_evidence is not None)
|
||
|
|
+ len(self.stage_evidence)
|
||
|
|
+ len(self.task_evidence)
|
||
|
|
+ len(self.large_inputs)
|
||
|
|
+ len(self.rna_fact_projections)
|
||
|
|
)
|
||
|
|
if self.completeness.scanned_paths < carrier_lower_bound:
|
||
|
|
raise ValueError("scanned_paths cannot be lower than the carrier count")
|
||
|
|
if self.family_binding is not None and (
|
||
|
|
self.family_binding.source_confirmation.job_id != self.active_job_id
|
||
|
|
):
|
||
|
|
raise ValueError("family binding must belong to the active job")
|
||
|
|
if self.rna_route is None:
|
||
|
|
if any(
|
||
|
|
value is not None
|
||
|
|
for value in (
|
||
|
|
self.foreground_mode,
|
||
|
|
self.species,
|
||
|
|
self.family,
|
||
|
|
self.rnaseq_scope_id,
|
||
|
|
self.family_scope_id,
|
||
|
|
self.scope_id,
|
||
|
|
self.analysis_route,
|
||
|
|
self.member_set_id,
|
||
|
|
self.family_binding,
|
||
|
|
)
|
||
|
|
):
|
||
|
|
raise ValueError("RNA identity requires an explicit rna_route")
|
||
|
|
elif self.rna_route == RNARoute.WHOLE_GENOME:
|
||
|
|
if (
|
||
|
|
self.foreground_mode != RNAForegroundMode.GLOBAL
|
||
|
|
or self.species is None
|
||
|
|
or self.rnaseq_scope_id is None
|
||
|
|
or self.analysis_route != RNARoute.WHOLE_GENOME.value
|
||
|
|
or any(
|
||
|
|
value is not None
|
||
|
|
for value in (
|
||
|
|
self.family,
|
||
|
|
self.family_scope_id,
|
||
|
|
self.scope_id,
|
||
|
|
self.member_set_id,
|
||
|
|
self.family_binding,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
):
|
||
|
|
raise ValueError("whole_genome snapshot identity is inconsistent")
|
||
|
|
else:
|
||
|
|
required = (
|
||
|
|
self.species,
|
||
|
|
self.family,
|
||
|
|
self.rnaseq_scope_id,
|
||
|
|
self.family_scope_id,
|
||
|
|
self.scope_id,
|
||
|
|
self.member_set_id,
|
||
|
|
self.family_binding,
|
||
|
|
)
|
||
|
|
if (
|
||
|
|
self.foreground_mode != RNAForegroundMode.FAMILY
|
||
|
|
or self.analysis_route != RNARoute.FAMILY_MEMBER.value
|
||
|
|
or any(value is None for value in required)
|
||
|
|
):
|
||
|
|
raise ValueError("family_member snapshot requires complete family identity")
|
||
|
|
binding = self.family_binding
|
||
|
|
assert binding is not None
|
||
|
|
source = binding.source_confirmation
|
||
|
|
if (
|
||
|
|
binding.rnaseq_scope_id != self.rnaseq_scope_id
|
||
|
|
or binding.family_scope_id != self.family_scope_id
|
||
|
|
or source.species != self.species
|
||
|
|
or source.family != self.family
|
||
|
|
or source.scope_id != self.scope_id
|
||
|
|
or source.member_set_id != self.member_set_id
|
||
|
|
):
|
||
|
|
raise ValueError("family_member snapshot identity does not match binding")
|
||
|
|
return self
|
||
|
|
|
||
|
|
|
||
|
|
class GoalLedger(_WorkflowModel):
|
||
|
|
goal_ledger_version: Literal["goal_ledger_v1"] = "goal_ledger_v1"
|
||
|
|
conversation_id: str
|
||
|
|
revision: int = Field(ge=0)
|
||
|
|
active_goal: str | None = None
|
||
|
|
queued_goals: tuple[str, ...] = ()
|
||
|
|
approved_decisions: tuple[str, ...] = ()
|
||
|
|
rejected_routes: tuple[RNARoute, ...] = ()
|
||
|
|
unresolved_blockers: tuple[str, ...] = ()
|
||
|
|
user_terms: dict[str, JsonValue] = Field(default_factory=dict)
|
||
|
|
corrections: tuple[str, ...] = ()
|
||
|
|
evidence_ids: tuple[str, ...] = ()
|
||
|
|
|
||
|
|
@field_validator("user_terms")
|
||
|
|
@classmethod
|
||
|
|
def freeze_user_terms(cls, value: Mapping[str, JsonValue]) -> Mapping[str, JsonValue]:
|
||
|
|
return _freeze_json(dict(value))
|
||
|
|
|
||
|
|
@field_serializer("user_terms")
|
||
|
|
def serialize_user_terms(self, value: Mapping[str, JsonValue]) -> dict[str, JsonValue]:
|
||
|
|
thawed = _thaw_json(value)
|
||
|
|
assert isinstance(thawed, dict)
|
||
|
|
return thawed
|
||
|
|
|
||
|
|
@model_validator(mode="after")
|
||
|
|
def validate_ledger(self) -> "GoalLedger":
|
||
|
|
_require_nonblank(self.conversation_id, "conversation_id")
|
||
|
|
for field_name in (
|
||
|
|
"queued_goals",
|
||
|
|
"approved_decisions",
|
||
|
|
"unresolved_blockers",
|
||
|
|
"corrections",
|
||
|
|
"evidence_ids",
|
||
|
|
):
|
||
|
|
values = getattr(self, field_name)
|
||
|
|
if any(not value.strip() for value in values):
|
||
|
|
raise ValueError(f"{field_name} cannot contain blank values")
|
||
|
|
if len(values) != len(set(values)):
|
||
|
|
raise ValueError(f"{field_name} must contain unique values")
|
||
|
|
if self.active_goal is not None:
|
||
|
|
_require_nonblank(self.active_goal, "active_goal")
|
||
|
|
if self.active_goal in self.queued_goals:
|
||
|
|
raise ValueError("active_goal cannot also be queued")
|
||
|
|
if len(self.rejected_routes) != len(set(self.rejected_routes)):
|
||
|
|
raise ValueError("rejected_routes must contain unique values")
|
||
|
|
if any(not key.strip() for key in self.user_terms):
|
||
|
|
raise ValueError("user_terms cannot contain blank keys")
|
||
|
|
return self
|
||
|
|
|
||
|
|
def model_copy(
|
||
|
|
self,
|
||
|
|
*,
|
||
|
|
update: Mapping[str, Any] | None = None,
|
||
|
|
deep: bool = False,
|
||
|
|
) -> "GoalLedger":
|
||
|
|
candidate = super().model_copy(update=update, deep=deep)
|
||
|
|
assert isinstance(candidate, GoalLedger)
|
||
|
|
if candidate.revision < self.revision:
|
||
|
|
raise ValueError("goal ledger revision cannot decrease")
|
||
|
|
persisted_fields = (
|
||
|
|
"active_goal",
|
||
|
|
"queued_goals",
|
||
|
|
"approved_decisions",
|
||
|
|
"rejected_routes",
|
||
|
|
"unresolved_blockers",
|
||
|
|
"user_terms",
|
||
|
|
"corrections",
|
||
|
|
"evidence_ids",
|
||
|
|
)
|
||
|
|
content_changed = any(
|
||
|
|
getattr(candidate, field_name) != getattr(self, field_name)
|
||
|
|
for field_name in persisted_fields
|
||
|
|
)
|
||
|
|
if content_changed and candidate.revision <= self.revision:
|
||
|
|
raise ValueError(
|
||
|
|
"persisted goal ledger changes require a larger revision"
|
||
|
|
)
|
||
|
|
return candidate
|
||
|
|
|
||
|
|
|
||
|
|
class ActionEnvelope(_WorkflowModel):
|
||
|
|
action_envelope_version: Literal["action_envelope_v1"] = "action_envelope_v1"
|
||
|
|
action_type: str
|
||
|
|
job_id: str
|
||
|
|
payload: dict[str, JsonValue]
|
||
|
|
payload_sha256: str
|
||
|
|
dependency_fingerprint: DependencyFingerprint
|
||
|
|
rna_route: RNARoute | None = None
|
||
|
|
foreground_mode: RNAForegroundMode | None = None
|
||
|
|
species: SpeciesCode | None = None
|
||
|
|
family: GeneFamily | None = None
|
||
|
|
rnaseq_scope_id: WorkflowIdentity | None = None
|
||
|
|
family_scope_id: WorkflowIdentity | None = None
|
||
|
|
scope_id: WorkflowIdentity | None = None
|
||
|
|
analysis_route: WorkflowIdentity | None = None
|
||
|
|
member_set_id: WorkflowIdentity | None = None
|
||
|
|
family_binding: FamilyConfirmationBinding | None = None
|
||
|
|
|
||
|
|
@field_validator("payload")
|
||
|
|
@classmethod
|
||
|
|
def freeze_payload(cls, value: Mapping[str, JsonValue]) -> Mapping[str, JsonValue]:
|
||
|
|
return _freeze_json(dict(value))
|
||
|
|
|
||
|
|
@field_serializer("payload")
|
||
|
|
def serialize_payload(self, value: Mapping[str, JsonValue]) -> dict[str, JsonValue]:
|
||
|
|
thawed = _thaw_json(value)
|
||
|
|
assert isinstance(thawed, dict)
|
||
|
|
return thawed
|
||
|
|
|
||
|
|
@model_validator(mode="after")
|
||
|
|
def validate_action(self) -> "ActionEnvelope":
|
||
|
|
_require_nonblank(self.action_type, "action_type")
|
||
|
|
_require_nonblank(self.job_id, "job_id")
|
||
|
|
_require_sha256(self.payload_sha256, "payload_sha256")
|
||
|
|
payload = _thaw_json(self.payload)
|
||
|
|
if self.payload_sha256 != _digest(payload):
|
||
|
|
raise ValueError("payload_sha256 does not match canonical payload")
|
||
|
|
authority_keys = {
|
||
|
|
"job_id",
|
||
|
|
"species",
|
||
|
|
"family",
|
||
|
|
"rnaseq_scope_id",
|
||
|
|
"family_scope_id",
|
||
|
|
"scope_id",
|
||
|
|
"analysis_route",
|
||
|
|
"member_set_id",
|
||
|
|
"rna_route",
|
||
|
|
"foreground_mode",
|
||
|
|
"source_confirmation",
|
||
|
|
"source_confirmation_digest",
|
||
|
|
}
|
||
|
|
if authority_keys.intersection(self.payload):
|
||
|
|
raise ValueError("payload cannot contain workflow authority fields")
|
||
|
|
if self.dependency_fingerprint.job_id != self.job_id:
|
||
|
|
raise ValueError("action and dependencies must use the same job")
|
||
|
|
if (self.rna_route is None) != (self.foreground_mode is None):
|
||
|
|
raise ValueError("RNA route and foreground mode must be supplied together")
|
||
|
|
if self.rna_route is None and any(
|
||
|
|
value is not None
|
||
|
|
for value in (
|
||
|
|
self.species,
|
||
|
|
self.family,
|
||
|
|
self.rnaseq_scope_id,
|
||
|
|
self.family_scope_id,
|
||
|
|
self.scope_id,
|
||
|
|
self.analysis_route,
|
||
|
|
self.member_set_id,
|
||
|
|
self.family_binding,
|
||
|
|
)
|
||
|
|
):
|
||
|
|
raise ValueError("RNA identity requires an explicit rna_route")
|
||
|
|
if self.rna_route == RNARoute.WHOLE_GENOME:
|
||
|
|
if (
|
||
|
|
self.foreground_mode != RNAForegroundMode.GLOBAL
|
||
|
|
or self.species is None
|
||
|
|
or self.rnaseq_scope_id is None
|
||
|
|
or self.analysis_route != RNARoute.WHOLE_GENOME.value
|
||
|
|
or any(
|
||
|
|
value is not None
|
||
|
|
for value in (
|
||
|
|
self.family,
|
||
|
|
self.family_scope_id,
|
||
|
|
self.scope_id,
|
||
|
|
self.member_set_id,
|
||
|
|
self.family_binding,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
):
|
||
|
|
raise ValueError(
|
||
|
|
"whole_genome requires global foreground and whole-genome identity"
|
||
|
|
)
|
||
|
|
fingerprint = self.dependency_fingerprint
|
||
|
|
if (
|
||
|
|
fingerprint.rnaseq_scope_id != self.rnaseq_scope_id
|
||
|
|
or fingerprint.analysis_route != self.analysis_route
|
||
|
|
or fingerprint.species != self.species
|
||
|
|
or any(
|
||
|
|
(
|
||
|
|
fingerprint.family_scope_id,
|
||
|
|
fingerprint.scope_id,
|
||
|
|
fingerprint.member_set_id,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
):
|
||
|
|
if fingerprint.species != self.species:
|
||
|
|
raise ValueError(
|
||
|
|
"whole_genome dependency species does not match action"
|
||
|
|
)
|
||
|
|
raise ValueError("whole_genome dependency identity does not match action")
|
||
|
|
elif self.rna_route == RNARoute.FAMILY_MEMBER:
|
||
|
|
required = (
|
||
|
|
self.species,
|
||
|
|
self.family,
|
||
|
|
self.rnaseq_scope_id,
|
||
|
|
self.family_scope_id,
|
||
|
|
self.scope_id,
|
||
|
|
self.member_set_id,
|
||
|
|
self.family_binding,
|
||
|
|
)
|
||
|
|
if (
|
||
|
|
self.foreground_mode != RNAForegroundMode.FAMILY
|
||
|
|
or self.analysis_route != RNARoute.FAMILY_MEMBER.value
|
||
|
|
or any(value is None for value in required)
|
||
|
|
):
|
||
|
|
raise ValueError(
|
||
|
|
"family_member requires complete family foreground identity"
|
||
|
|
)
|
||
|
|
binding = self.family_binding
|
||
|
|
assert binding is not None
|
||
|
|
source = binding.source_confirmation
|
||
|
|
fingerprint = self.dependency_fingerprint
|
||
|
|
if binding.rnaseq_scope_id != self.rnaseq_scope_id:
|
||
|
|
raise ValueError("rnaseq_scope_id does not match family binding")
|
||
|
|
if binding.family_scope_id != self.family_scope_id:
|
||
|
|
raise ValueError("family_scope_id does not match family binding")
|
||
|
|
if (
|
||
|
|
source.species != self.species
|
||
|
|
or source.family != self.family
|
||
|
|
or source.scope_id != self.scope_id
|
||
|
|
or source.member_set_id != self.member_set_id
|
||
|
|
or fingerprint.digest != binding.family_projection_fingerprint.digest
|
||
|
|
or fingerprint.rnaseq_scope_id != self.rnaseq_scope_id
|
||
|
|
or fingerprint.family_scope_id != self.family_scope_id
|
||
|
|
or fingerprint.scope_id != self.scope_id
|
||
|
|
or fingerprint.analysis_route != self.analysis_route
|
||
|
|
or fingerprint.member_set_id != self.member_set_id
|
||
|
|
):
|
||
|
|
raise ValueError("family_member identity does not match exact binding")
|
||
|
|
elif self.family_binding is not None:
|
||
|
|
raise ValueError("family binding requires an explicit family_member route")
|
||
|
|
if self.family_binding is not None and (
|
||
|
|
self.family_binding.source_confirmation.job_id != self.job_id
|
||
|
|
):
|
||
|
|
raise ValueError("action and family binding must use the same job")
|
||
|
|
return self
|
||
|
|
|
||
|
|
|
||
|
|
class PendingAction(_WorkflowModel):
|
||
|
|
pending_action_version: Literal["pending_action_v1"] = "pending_action_v1"
|
||
|
|
action_id: UUID
|
||
|
|
principal_id: str
|
||
|
|
session_id: str
|
||
|
|
conversation_id: str
|
||
|
|
envelope: ActionEnvelope
|
||
|
|
token_sha256: str
|
||
|
|
issued_at: datetime
|
||
|
|
expires_at: datetime
|
||
|
|
|
||
|
|
@property
|
||
|
|
def job_id(self) -> str:
|
||
|
|
return self.envelope.job_id
|
||
|
|
|
||
|
|
@model_validator(mode="after")
|
||
|
|
def validate_pending_action(self) -> "PendingAction":
|
||
|
|
for field_name in ("principal_id", "session_id", "conversation_id"):
|
||
|
|
_require_nonblank(getattr(self, field_name), field_name)
|
||
|
|
_require_sha256(self.token_sha256, "token_sha256")
|
||
|
|
_require_aware(self.issued_at, "issued_at")
|
||
|
|
_require_aware(self.expires_at, "expires_at")
|
||
|
|
if self.expires_at <= self.issued_at:
|
||
|
|
raise ValueError("expires_at must be after issued_at")
|
||
|
|
return self
|
||
|
|
|
||
|
|
|
||
|
|
class ImportGrant(_WorkflowModel):
|
||
|
|
"""Bind identical normalized path text without resolving the filesystem.
|
||
|
|
|
||
|
|
The service must normalize before constructing the grant. Kernel code must
|
||
|
|
resolve safely and recheck the stored device, inode, size, modification
|
||
|
|
time, and digest metadata. Original user text belongs in later audit data.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import_grant_version: Literal["import_grant_v1"] = "import_grant_v1"
|
||
|
|
grant_id: UUID
|
||
|
|
principal_id: str
|
||
|
|
session_id: str
|
||
|
|
conversation_id: str
|
||
|
|
job_id: str
|
||
|
|
authorized_root: str
|
||
|
|
requested_path: Annotated[str, StringConstraints(strip_whitespace=False)]
|
||
|
|
resolved_path: str
|
||
|
|
source_device: int = Field(ge=0)
|
||
|
|
source_inode: int = Field(gt=0)
|
||
|
|
source_size: int = Field(ge=0)
|
||
|
|
source_mtime_ns: int = Field(ge=0)
|
||
|
|
source_sha256: str
|
||
|
|
allowed_suffixes: tuple[str, ...]
|
||
|
|
file_count: int = Field(gt=0)
|
||
|
|
total_size: int = Field(ge=0)
|
||
|
|
largest_file_size: int = Field(ge=0)
|
||
|
|
max_file_count: int = Field(gt=0)
|
||
|
|
max_total_size: int = Field(gt=0)
|
||
|
|
max_file_size: int = Field(gt=0)
|
||
|
|
issued_at: datetime
|
||
|
|
expires_at: datetime
|
||
|
|
token_sha256: str
|
||
|
|
|
||
|
|
@model_validator(mode="after")
|
||
|
|
def validate_grant(self) -> "ImportGrant":
|
||
|
|
for field_name in (
|
||
|
|
"principal_id",
|
||
|
|
"session_id",
|
||
|
|
"conversation_id",
|
||
|
|
"job_id",
|
||
|
|
"authorized_root",
|
||
|
|
"requested_path",
|
||
|
|
"resolved_path",
|
||
|
|
):
|
||
|
|
_require_nonblank(getattr(self, field_name), field_name)
|
||
|
|
authorized_root = _require_absolute_canonical_path(
|
||
|
|
self.authorized_root,
|
||
|
|
"authorized_root",
|
||
|
|
)
|
||
|
|
requested_path = _require_absolute_canonical_path(
|
||
|
|
self.requested_path,
|
||
|
|
"requested_path",
|
||
|
|
)
|
||
|
|
resolved_path = _require_absolute_canonical_path(
|
||
|
|
self.resolved_path,
|
||
|
|
"resolved_path",
|
||
|
|
)
|
||
|
|
try:
|
||
|
|
relative = resolved_path.relative_to(authorized_root)
|
||
|
|
except ValueError as exc:
|
||
|
|
raise ValueError("resolved_path must be within authorized_root") from exc
|
||
|
|
if not relative.parts:
|
||
|
|
raise ValueError("resolved_path must identify an item below authorized_root")
|
||
|
|
if requested_path != resolved_path:
|
||
|
|
raise ValueError(
|
||
|
|
"requested_path and resolved_path must be identical normalized text"
|
||
|
|
)
|
||
|
|
_require_sha256(self.source_sha256, "source_sha256")
|
||
|
|
_require_sha256(self.token_sha256, "token_sha256")
|
||
|
|
if not self.allowed_suffixes or any(
|
||
|
|
not suffix.startswith(".") or suffix != suffix.lower()
|
||
|
|
for suffix in self.allowed_suffixes
|
||
|
|
):
|
||
|
|
raise ValueError("allowed_suffixes must contain lowercase dot suffixes")
|
||
|
|
if len(self.allowed_suffixes) != len(set(self.allowed_suffixes)):
|
||
|
|
raise ValueError("allowed_suffixes must be unique")
|
||
|
|
if self.file_count > self.max_file_count:
|
||
|
|
raise ValueError("file count exceeds grant limit")
|
||
|
|
if self.total_size > self.max_total_size:
|
||
|
|
raise ValueError("total size exceeds grant limit")
|
||
|
|
if self.largest_file_size > self.max_file_size:
|
||
|
|
raise ValueError("largest file exceeds grant limit")
|
||
|
|
if self.largest_file_size > self.total_size:
|
||
|
|
raise ValueError("largest file cannot exceed total size")
|
||
|
|
_require_aware(self.issued_at, "issued_at")
|
||
|
|
_require_aware(self.expires_at, "expires_at")
|
||
|
|
if self.expires_at <= self.issued_at:
|
||
|
|
raise ValueError("expires_at must be after issued_at")
|
||
|
|
return self
|
||
|
|
|
||
|
|
|
||
|
|
__all__ = [
|
||
|
|
"ActionEnvelope",
|
||
|
|
"ConfigEvidenceState",
|
||
|
|
"ConfirmationEvidenceStatus",
|
||
|
|
"DependencyFingerprint",
|
||
|
|
"EvidenceKind",
|
||
|
|
"EvidenceRef",
|
||
|
|
"FamilyConfirmationBinding",
|
||
|
|
"GeneFamily",
|
||
|
|
"GoalLedger",
|
||
|
|
"ImportGrant",
|
||
|
|
"LargeInputEvidence",
|
||
|
|
"ManifestStatus",
|
||
|
|
"PendingAction",
|
||
|
|
"RNAForegroundMode",
|
||
|
|
"RNAAlignmentStrandnessFact",
|
||
|
|
"RNAArtifactIdentity",
|
||
|
|
"RNADEComparisonMode",
|
||
|
|
"RNADEContrast",
|
||
|
|
"RNADEDesignFact",
|
||
|
|
"RNADEGroupAssignment",
|
||
|
|
"RNADEGroupCount",
|
||
|
|
"RNADEGroupTime",
|
||
|
|
"RNADEReplicateViolation",
|
||
|
|
"RNAExperimentDesign",
|
||
|
|
"RNAExperimentInputFact",
|
||
|
|
"RNAExperimentInputSample",
|
||
|
|
"RNAFactKind",
|
||
|
|
"RNAFactProjectionDocument",
|
||
|
|
"RNAFactProjectionState",
|
||
|
|
"RNAGenomeResourceFact",
|
||
|
|
"RNAGenomeFactSourceClosure",
|
||
|
|
"RNALibraryStrandness",
|
||
|
|
"RNAReadIdentity",
|
||
|
|
"RNADependencyKind",
|
||
|
|
"RNAReplicatePolicy",
|
||
|
|
"RNAReplicateViolationPolicy",
|
||
|
|
"RNARoute",
|
||
|
|
"RNAStableFactRef",
|
||
|
|
"RNAStrandnessPolicy",
|
||
|
|
"RNAStrandnessSample",
|
||
|
|
"RNASamplesFactSourceClosure",
|
||
|
|
"SamplesEvidenceState",
|
||
|
|
"SnapshotCompleteness",
|
||
|
|
"SnapshotIncompleteReason",
|
||
|
|
"SpeciesCode",
|
||
|
|
"SourceConfirmationV1",
|
||
|
|
"StageEvidenceState",
|
||
|
|
"StageEvidenceStatus",
|
||
|
|
"TaskEvidenceSource",
|
||
|
|
"TaskEvidenceState",
|
||
|
|
"TaskEvidenceStatus",
|
||
|
|
"TaskClaimAuthority",
|
||
|
|
"WorkClaim",
|
||
|
|
"WorkClaimKind",
|
||
|
|
"WorkflowIdentity",
|
||
|
|
"WorkflowStateSnapshot",
|
||
|
|
]
|