from __future__ import annotations import hashlib import math from app.schemas.visual_lesson import NucleicAcidSpec from app.services.nucleic_compiler import NucleicCompiler from app.services.nucleic_resolver import DNA_ALPHABET, RNA_ALPHABET from app.services.visual_lesson_store import VisualLessonStore class NucleicSpecValidationError(ValueError): pass class NucleicSpecValidator: def __init__(self, store: VisualLessonStore, compiler: NucleicCompiler) -> None: self.store = store self.compiler = compiler def validate(self, spec: NucleicAcidSpec) -> None: if spec.mode != "structure": if not 1 <= len(spec.sequence) <= 60: raise NucleicSpecValidationError("The displayed sequence must contain 1–60 nucleotides") alphabet = RNA_ALPHABET if spec.molecule == "rna" else DNA_ALPHABET if not set(spec.sequence) <= alphabet or ("T" in spec.sequence and "U" in spec.sequence): raise NucleicSpecValidationError("The displayed sequence is not a valid DNA or RNA IUPAC sequence") feature_ids = {feature.feature_id for feature in spec.features} claim_ids = {claim.claim_id for claim in spec.evidence_claims} if any(feature_id not in feature_ids for feature_id in spec.composition.visible_feature_ids): raise NucleicSpecValidationError("The composition references an unknown visual feature") for feature in spec.features: if not feature.claim_ids or any(claim_id not in claim_ids for claim_id in feature.claim_ids): raise NucleicSpecValidationError(f"Feature {feature.feature_id} is not linked to evidence") if spec.mode == "structure": if not spec.structure or spec.composition.primary_view != "structure": raise NucleicSpecValidationError("A molecular structure visualization requires official coordinates") path = self.store.asset_path(spec.project_id, spec.structure.coordinate_asset_id, spec.structure.coordinate_format) digest = hashlib.sha256(path.read_bytes()).hexdigest() if digest != spec.structure.coordinate_sha256 or digest != spec.structure.coordinate_asset_id: raise NucleicSpecValidationError("The molecular coordinate checksum does not match the saved asset") chain_ids = {chain.chain_id for chain in spec.structure.chains} if any(feature.chain_id and feature.chain_id not in chain_ids for feature in spec.features): raise NucleicSpecValidationError("A molecular feature references an unknown chain") elif spec.structure is not None: raise NucleicSpecValidationError("Generic nucleic geometry cannot claim an official coordinate structure") molecule = spec.nucleobase_molecule if spec.composition.primary_view == "molecule_3d" and molecule is None: raise NucleicSpecValidationError("A 3D molecule view requires verified compound coordinates") if molecule is not None: if spec.focus_base != molecule.symbol: raise NucleicSpecValidationError("The resolved nucleobase does not match the requested base") atom_ids = {atom.atom_id for atom in molecule.atoms} if not atom_ids or len(atom_ids) != len(molecule.atoms): raise NucleicSpecValidationError("The molecular atom catalogue is empty or contains duplicate IDs") if any(not all(math.isfinite(value) for value in (atom.x, atom.y, atom.z)) for atom in molecule.atoms): raise NucleicSpecValidationError("The molecular coordinates contain a non-finite value") if any( bond.atom_a not in atom_ids or bond.atom_b not in atom_ids or bond.atom_a == bond.atom_b or bond.order not in {1, 2, 3} for bond in molecule.bonds ): raise NucleicSpecValidationError("The molecular bond graph references invalid atoms") if "nucleobase-structure" not in claim_ids: raise NucleicSpecValidationError("The 3D molecule is not linked to official compound evidence") elif spec.focus_base and spec.composition.primary_view == "molecule_3d": raise NucleicSpecValidationError("The requested nucleobase has no verified 3D conformer") compiled = self.compiler.compile_spec(spec) if not compiled.assertions_passed: raise NucleicSpecValidationError("The nucleic geometry compiler assertions failed")