np_app/service/server.py

3916 lines
152 KiB
Python

import json
import hashlib
import math
import mimetypes
import multiprocessing
import os
import queue
import re
import secrets
import threading
import time
import traceback
from decimal import Decimal
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
from http.cookies import SimpleCookie
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import subprocess
import tempfile
from urllib.parse import parse_qs, urlencode, urlparse
from uuid import uuid4
import numpy
import nupack
from nupack import (
Complex,
Complementarity,
Domain,
Diversity,
EnergyMatch,
DesignOptions,
Library,
Match,
Model,
Pattern,
Similarity,
SSM,
SetSpec,
Strand,
TargetComplex,
TargetStrand,
TargetTube,
Tube,
Weights,
Window,
complex_analysis,
complex_design,
tube_analysis,
tube_design,
)
from nupack import config as nupack_config
from account import AccountStore, OIDCAuth
from split_strand_svg import render_split_strands_svg
try:
import redis
except ImportError:
redis = None
ROOT = Path(__file__).resolve().parent
INDEX_PATH = ROOT / "index.html"
HOME_PATH = ROOT / "home.html"
CLOUD_PATH = ROOT / "cloud.html"
ACCOUNT_PATH = ROOT / "account.html"
SETTINGS_PATH = ROOT / "settings.html"
ADMIN_PATH = ROOT / "admin.html"
ADMIN_LOGIN_PATH = ROOT / "admin-login.html"
STATIC_PATHS = {
"/static/app-shell.css": ROOT / "static" / "app-shell.css",
"/static/workspace-refresh.css": ROOT / "static" / "workspace-refresh.css",
"/static/portal.js": ROOT / "static" / "portal.js",
}
FAVICON_PATH = ROOT / "favicon.svg"
GUIDE_PATH = ROOT / "design-guide.html"
HOST = os.environ.get("NP_HOST", "0.0.0.0")
PORT = int(os.environ.get("NP_PORT", "18765"))
RNA_PLOT_CMD = os.environ.get("RNA_PLOT_CMD", "RNAplot")
ENABLE_RNAPLOT = os.environ.get("ENABLE_RNAPLOT", "1") != "0"
STRUCTURE_PLOT_MODE = os.environ.get("NP_STRUCTURE_PLOT_MODE", "auto").strip().lower()
RUN_MODE = os.environ.get("NP_RUN_MODE", "server")
REDIS_URL = os.environ.get("NP_REDIS_URL", "").strip()
JOB_QUEUE_KEY = os.environ.get("NP_JOB_QUEUE_KEY", "np_replica:jobs")
JOB_RUNNING_KEY = os.environ.get("NP_JOB_RUNNING_KEY", "np_replica:jobs:running")
WORKER_RESOURCE_KEY = os.environ.get("NP_WORKER_RESOURCE_KEY", "np_replica:worker:resources")
WORKER_CONCURRENCY = os.environ.get("NP_WORKER_CONCURRENCY", "auto").strip().lower()
PER_JOB_THREAD_LIMIT = max(1, int(os.environ.get("NP_PER_JOB_THREAD_LIMIT", "1")))
NUPACK_CACHE_GB = float(os.environ.get("NP_NUPACK_CACHE_GB", "2.0"))
WORKER_MEMORY_GB = float(os.environ.get("NP_WORKER_MEMORY_GB", "0"))
WORKER_MEMORY_RESERVE_GB = max(0.0, float(os.environ.get("NP_WORKER_MEMORY_RESERVE_GB", "2.0")))
ESTIMATED_JOB_MEMORY_GB = max(0.1, float(os.environ.get("NP_ESTIMATED_JOB_MEMORY_GB", "8.0")))
ACCOUNT_DB_PATH = os.environ.get("NP_ACCOUNT_DB_PATH", "/data/np-replica.sqlite3")
ADMIN_TOKEN = os.environ.get("NP_ADMIN_TOKEN", "").strip()
ADMIN_SESSION_TTL_SECONDS = max(300, int(os.environ.get("NP_ADMIN_SESSION_TTL_SECONDS", "28800")))
ADMIN_COOKIE_NAME = os.environ.get("NP_ADMIN_COOKIE_NAME", "np_admin_session")
ADMIN_COOKIE_SECURE = os.environ.get("NP_ADMIN_COOKIE_SECURE", "1") != "0"
ADMIN_SESSION_PREFIX = os.environ.get("NP_ADMIN_SESSION_PREFIX", "np_replica:admin-session")
UNIT_SCALE = {
"M": 1.0,
"mM": 1e-3,
"uM": 1e-6,
"nM": 1e-9,
"pM": 1e-12,
}
IUPAC_CODES = "ACGTUWSMKRYBDHVN"
IUPAC_CONSTRAINT_TOKEN = re.compile(rf"[{IUPAC_CODES}](?:\d+)?")
FIXED_BASES = set("ACGTU")
DNA_COMPLEMENT_BASE = str.maketrans({"A": "T", "C": "G", "G": "C", "T": "A", "U": "A"})
RNA_COMPLEMENT_BASE = str.maketrans({"A": "U", "C": "G", "G": "C", "T": "A", "U": "A"})
NUPACK_VERSION = str(getattr(nupack, "__version__", "4.1.0.1"))
MATERIAL_ALIASES = {"rna": "rna06", "dna": "dna04.3", "dna04": "dna04.3"}
MIXED_MATERIAL_PREFIXES = {"rna-dna06": "rd", "rna-merna06": "rm"}
MATERIAL_RULES = {
"rna": {"sodium": (0.05, 1.0, 1.0), "magnesium": (0.0, 0.0, 0.0)},
"rna06": {"sodium": (0.05, 1.0, 1.0), "magnesium": (0.0, 0.0, 0.0)},
"rna95": {"sodium": (1.0, 1.0, 1.0), "magnesium": (0.0, 0.0, 0.0)},
"merna06": {"sodium": (0.12, 0.12, 0.12), "magnesium": (0.0, 0.0, 0.0)},
"rna-dna06": {"sodium": (0.12, 1.0, 1.0), "magnesium": (0.0, 0.0, 0.0)},
"rna-merna06": {"sodium": (0.12, 0.12, 0.12), "magnesium": (0.0, 0.0, 0.0)},
"dna": {"sodium": (0.05, 1.1, 1.0), "magnesium": (0.0, 0.2, 0.0)},
"dna04": {"sodium": (0.05, 1.1, 1.0), "magnesium": (0.0, 0.2, 0.0)},
"dna04.1": {"sodium": (0.05, 1.1, 1.0), "magnesium": (0.0, 0.2, 0.0)},
"dna04.2": {"sodium": (0.05, 1.1, 1.0), "magnesium": (0.0, 0.2, 0.0)},
"dna04.3": {"sodium": (0.05, 1.1, 1.0), "magnesium": (0.0, 0.2, 0.0)},
}
VALID_COMPUTE = {"pfunc", "pairs", "mfe", "sample", "subopt", "ensemble_size"}
VALID_UTILITY_OPERATIONS = {
"pfunc", "structure_energy", "structure_probability", "sample", "pairs", "mfe",
"subopt", "ensemble_size", "des", "defect", "seq_distance", "struc_distance",
}
FIXED_TARGET_POLICIES = {"exclude_from_optimization", "include"}
CANCEL_REQUESTED_STATUS = "cancel_requested"
CANCELED_STATUS = "canceled"
TERMINAL_JOB_STATUSES = {"success", "error", CANCELED_STATUS}
JOB_STORE = {}
JOB_LOCK = threading.Lock()
JOB_DEDUP_RESERVATIONS = {}
JOB_TTL_SECONDS = int(os.environ.get("NP_JOB_TTL_SECONDS", "3600"))
JOB_HEARTBEAT_SECONDS = max(1, int(os.environ.get("NP_JOB_HEARTBEAT_SECONDS", "30")))
JOB_MAX_COUNT = int(os.environ.get("NP_JOB_MAX_COUNT", "64"))
JOB_DEDUP_PREFIX = os.environ.get("NP_JOB_DEDUP_PREFIX", "np_replica:job-dedup")
SHARE_STORE = {}
SHARE_LOCK = threading.Lock()
ADMIN_SESSION_STORE = {}
ADMIN_SESSION_LOCK = threading.Lock()
SHARE_MAX_COUNT = int(os.environ.get("NP_SHARE_MAX_COUNT", "100"))
SHARE_MAX_BYTES = int(os.environ.get("NP_SHARE_MAX_BYTES", str(12 * 1024 * 1024)))
SHARE_KEY_PREFIX = os.environ.get("NP_SHARE_KEY_PREFIX", "np_replica:shares")
SHARE_INDEX_KEY = f"{SHARE_KEY_PREFIX}:index"
REDIS_CLIENT = None
ACCOUNT_STORE = AccountStore(ACCOUNT_DB_PATH)
OIDC_AUTH = None
def json_bytes(payload, status=HTTPStatus.OK):
return (
status,
"application/json; charset=utf-8",
json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8"),
)
def log_event(message):
print(f"[np-replica] {time.strftime('%Y-%m-%d %H:%M:%S')} {message}", flush=True)
def detected_worker_memory_gb():
if WORKER_MEMORY_GB > 0:
return WORKER_MEMORY_GB
try:
raw = Path("/sys/fs/cgroup/memory.max").read_text().strip()
if raw != "max":
return int(raw) / (1024 ** 3)
except (OSError, ValueError):
pass
try:
return os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES") / (1024 ** 3)
except (OSError, ValueError):
return 0.0
def detected_worker_cpu_count():
capacities = [max(1, os.cpu_count() or 1)]
try:
capacities.append(max(1, len(os.sched_getaffinity(0))))
except (AttributeError, OSError):
pass
quota_paths = (
(Path("/sys/fs/cgroup/cpu.max"), "v2"),
(Path("/sys/fs/cgroup/cpu/cpu.cfs_quota_us"), "v1"),
)
for path, version in quota_paths:
try:
raw = path.read_text().strip()
if version == "v2":
quota_text, period_text = raw.split()[:2]
if quota_text == "max":
break
quota, period = int(quota_text), int(period_text)
else:
quota = int(raw)
if quota < 0:
break
period = int(Path("/sys/fs/cgroup/cpu/cpu.cfs_period_us").read_text().strip())
if quota > 0 and period > 0:
capacities.append(max(1, quota // period))
break
except (OSError, ValueError):
continue
return min(capacities)
def worker_resource_plan():
cpu_count = detected_worker_cpu_count()
memory_gb = detected_worker_memory_gb()
if WORKER_CONCURRENCY != "auto":
try:
concurrency = max(1, int(WORKER_CONCURRENCY))
except ValueError as exc:
raise ValueError("NP_WORKER_CONCURRENCY must be a positive integer or 'auto'.") from exc
source = "manual"
else:
cpu_capacity = max(1, cpu_count // PER_JOB_THREAD_LIMIT)
usable_memory_gb = max(0.1, memory_gb - WORKER_MEMORY_RESERVE_GB) if memory_gb else 0
memory_capacity = max(1, int(usable_memory_gb // ESTIMATED_JOB_MEMORY_GB)) if usable_memory_gb else cpu_capacity
concurrency = max(1, min(cpu_capacity, memory_capacity))
source = "auto"
return {
"concurrency": concurrency,
"source": source,
"cpu_count": cpu_count,
"per_job_threads": PER_JOB_THREAD_LIMIT,
"memory_gb": round(memory_gb, 3),
"memory_reserve_gb": WORKER_MEMORY_RESERVE_GB,
"estimated_job_memory_gb": ESTIMATED_JOB_MEMORY_GB,
}
def published_worker_resource_plan():
if redis_enabled():
try:
raw = redis_client().get(WORKER_RESOURCE_KEY)
if raw:
return json.loads(raw)
except Exception:
pass
return worker_resource_plan()
def apply_thread_limits():
# Keep native math libraries and NUPACK's own executor from using all CPU cores inside one job.
thread_limit_int = max(1, PER_JOB_THREAD_LIMIT)
thread_limit = str(thread_limit_int)
for key in (
"OMP_NUM_THREADS",
"OPENBLAS_NUM_THREADS",
"MKL_NUM_THREADS",
"NUMEXPR_NUM_THREADS",
"VECLIB_MAXIMUM_THREADS",
"GOTO_NUM_THREADS",
):
os.environ[key] = thread_limit
nupack_config.threads = thread_limit_int
nupack_config.cache = max(0.1, NUPACK_CACHE_GB)
def html_bytes(path):
body = path.read_bytes()
mime, _ = mimetypes.guess_type(str(path))
return HTTPStatus.OK, mime or "text/html; charset=utf-8", body
def file_etag(path):
stat = path.stat()
return f'W/"{stat.st_mtime_ns:x}-{stat.st_size:x}"'
def redis_enabled():
return bool(REDIS_URL and redis is not None)
def redis_client():
global REDIS_CLIENT
if not redis_enabled():
return None
if REDIS_CLIENT is None:
REDIS_CLIENT = redis.Redis.from_url(REDIS_URL, decode_responses=True)
return REDIS_CLIENT
def create_admin_session():
token = secrets.token_urlsafe(32)
if redis_enabled():
redis_client().setex(f"{ADMIN_SESSION_PREFIX}:{token}", ADMIN_SESSION_TTL_SECONDS, "1")
else:
with ADMIN_SESSION_LOCK:
ADMIN_SESSION_STORE[token] = time.time() + ADMIN_SESSION_TTL_SECONDS
return token
def valid_admin_session(token):
if not token:
return False
if redis_enabled():
return bool(redis_client().get(f"{ADMIN_SESSION_PREFIX}:{token}"))
now = time.time()
with ADMIN_SESSION_LOCK:
expired = [key for key, expires_at in ADMIN_SESSION_STORE.items() if expires_at <= now]
for key in expired:
ADMIN_SESSION_STORE.pop(key, None)
return ADMIN_SESSION_STORE.get(token, 0) > now
def delete_admin_session(token):
if not token:
return
if redis_enabled():
redis_client().delete(f"{ADMIN_SESSION_PREFIX}:{token}")
else:
with ADMIN_SESSION_LOCK:
ADMIN_SESSION_STORE.pop(token, None)
def admin_cookie_header(token, *, clear=False):
secure = "; Secure" if ADMIN_COOKIE_SECURE else ""
if clear:
return f"{ADMIN_COOKIE_NAME}=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict{secure}"
return (
f"{ADMIN_COOKIE_NAME}={token}; Path=/; Max-Age={ADMIN_SESSION_TTL_SECONDS}; "
f"HttpOnly; SameSite=Strict{secure}"
)
OIDC_AUTH = OIDCAuth(redis_client)
def queue_size():
if not redis_enabled():
with JOB_LOCK:
return sum(1 for job in JOB_STORE.values() if job.get("status") in {"queued", "running"})
try:
return int(redis_client().llen(JOB_QUEUE_KEY))
except Exception:
return None
def prune_redis_running_jobs():
if not redis_enabled():
return 0
client = redis_client()
removed = 0
try:
for job_id in client.smembers(JOB_RUNNING_KEY):
raw = client.get(job_key(job_id))
if raw is None:
client.srem(JOB_RUNNING_KEY, job_id)
removed += 1
continue
try:
if json.loads(raw).get("status") != "running":
client.srem(JOB_RUNNING_KEY, job_id)
removed += 1
except Exception:
client.srem(JOB_RUNNING_KEY, job_id)
removed += 1
except Exception:
return removed
return removed
def job_stats():
stats = {
"queued": 0,
"running": 0,
"success": 0,
"error": 0,
}
if redis_enabled():
prune_redis_running_jobs()
client = redis_client()
stats["queued"] = int(client.llen(JOB_QUEUE_KEY))
stats["running"] = int(client.scard(JOB_RUNNING_KEY))
return stats
with JOB_LOCK:
prune_jobs()
for job in JOB_STORE.values():
status = job.get("status")
if status in stats:
stats[status] += 1
return stats
def job_key(job_id):
return f"np_replica:job:{job_id}"
def job_dedup_key(fingerprint):
return f"{JOB_DEDUP_PREFIX}:{fingerprint}"
def refresh_redis_job_claim(job_id, fingerprint):
return redis_client().eval(
"""
local current = redis.call('GET', KEYS[1])
if (not current) or current == ARGV[1] then
redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2])
return 1
end
return 0
""",
1,
job_dedup_key(fingerprint),
job_id,
max(60, JOB_TTL_SECONDS),
)
def delete_redis_job_claim(job_id, fingerprint):
return redis_client().eval(
"""
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
""",
1,
job_dedup_key(fingerprint),
job_id,
)
def job_payload_fingerprint(payload, user_id):
encoded = json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
return hashlib.sha256(f"{user_id}\0{encoded}".encode("utf-8")).hexdigest()
def active_job_status(status):
return status in {"queued", "running", CANCEL_REQUESTED_STATUS}
def claim_active_job(payload, owner, job_id):
"""Return an existing active duplicate, or claim this payload for job_id."""
fingerprint = job_payload_fingerprint(payload, owner["user_id"])
if redis_enabled():
client = redis_client()
key = job_dedup_key(fingerprint)
while True:
if client.set(key, job_id, nx=True, ex=max(60, JOB_TTL_SECONDS)):
return job_id, fingerprint, False
existing_id = client.get(key)
if not existing_id:
continue
existing = get_job_data(existing_id)
if existing and existing.get("user_id") == owner["user_id"] and active_job_status(existing.get("status")):
return existing_id, fingerprint, True
# A terminal or expired job left a stale dedup key behind.
delete_redis_job_claim(existing_id, fingerprint)
with JOB_LOCK:
prune_jobs()
for existing in JOB_STORE.values():
if (
existing.get("user_id") == owner["user_id"]
and existing.get("dedup_fingerprint") == fingerprint
and active_job_status(existing.get("status"))
):
return existing["job_id"], fingerprint, True
reserved_id = JOB_DEDUP_RESERVATIONS.get(fingerprint)
if reserved_id:
if reserved_id in JOB_STORE:
return reserved_id, fingerprint, True
JOB_DEDUP_RESERVATIONS.pop(fingerprint, None)
JOB_DEDUP_RESERVATIONS[fingerprint] = job_id
return job_id, fingerprint, False
def release_job_claim(job_id, fingerprint):
if redis_enabled():
delete_redis_job_claim(job_id, fingerprint)
return
with JOB_LOCK:
if JOB_DEDUP_RESERVATIONS.get(fingerprint) == job_id:
JOB_DEDUP_RESERVATIONS.pop(fingerprint, None)
def replace_job_claim(job_id, fingerprint):
if redis_enabled():
redis_client().setex(
job_dedup_key(fingerprint),
max(60, JOB_TTL_SECONDS),
job_id,
)
return
with JOB_LOCK:
JOB_DEDUP_RESERVATIONS[fingerprint] = job_id
def share_key(share_id):
return f"{SHARE_KEY_PREFIX}:item:{share_id}"
def normalize_sequence(sequence, material="rna"):
normalized = re.sub(r"\s+", "", str(sequence or ""))
if str(material or "").strip().lower() not in MIXED_MATERIAL_PREFIXES:
return normalized.upper()
return normalized
def material_rule(material):
return MATERIAL_RULES.get(str(material or "rna").strip().lower())
def validate_material_salt(material, ion, value):
rule = material_rule(material)
if rule is None:
return
lower, upper, _default = rule[ion]
label = "Sodium" if ion == "sodium" else "Magnesium"
if math.isclose(lower, upper, rel_tol=0.0, abs_tol=1e-12):
if not math.isclose(value, lower, rel_tol=0.0, abs_tol=1e-12):
raise ValueError(f"{label} concentration for {material} must be {lower:g} M.")
elif value < lower or value > upper:
raise ValueError(
f"{label} concentration for {material} must be between {lower:g} and {upper:g} M."
)
def sequence_length(model, sequence):
try:
return int(model.alphabet.sequence_length(sequence))
except Exception as exc:
raise ValueError(f"Invalid sequence for material {model}: {sequence}") from exc
def validate_analysis_sequence(sequence, material, model, label="Sequence"):
sequence = normalize_sequence(sequence, material)
material_name = str(material or "rna").strip().lower()
prefixes = MIXED_MATERIAL_PREFIXES.get(material_name)
if prefixes:
if not re.fullmatch(rf"(?:[{prefixes}][ACGTU]+)+", sequence):
raise ValueError(
f"{label} must use explicit lowercase material prefixes "
f"({', '.join(prefixes)}) followed by A, C, G, T, or U."
)
elif not re.fullmatch(r"[ACGTU]+", sequence):
raise ValueError(f"{label} must contain only A, C, G, T, or U for analysis.")
try:
model.alphabet.sequence(sequence)
except Exception as exc:
raise ValueError(f"{label} is invalid for material {material_name}.") from exc
return sequence
def normalize_design_sequence(sequence, material="rna"):
normalized = re.sub(r"\s+", "", str(sequence or ""))
if str(material or "rna").strip().lower() in MIXED_MATERIAL_PREFIXES:
return normalized
return normalized.upper()
def parse_design_constraint_tokens(sequence, material="rna"):
material_name = str(material or "rna").strip().lower()
seq = normalize_design_sequence(sequence, material_name)
if not seq:
raise ValueError("Sequence constraint cannot be empty.")
mixed_prefixes = MIXED_MATERIAL_PREFIXES.get(material_name)
allowed_prefixes = set((mixed_prefixes or "") + ("w" if mixed_prefixes else ""))
index = 0
current_prefix = None
tokens = []
while index < len(seq):
if mixed_prefixes and seq[index] in allowed_prefixes:
current_prefix = seq[index]
index += 1
if index >= len(seq):
raise ValueError("A material prefix must be followed by an IUPAC constraint.")
if mixed_prefixes and current_prefix is None:
expected = ", ".join(sorted(allowed_prefixes))
raise ValueError(f"Mixed-material constraints must start with a lowercase prefix: {expected}.")
match = IUPAC_CONSTRAINT_TOKEN.match(seq, index)
if not match:
raise ValueError(f"Invalid sequence constraint near: {seq[index:]}")
token = match.group(0)
count = int(token[1:] or "1")
if count < 1:
raise ValueError("Sequence constraint repeat counts must be positive.")
tokens.append((current_prefix, token[0], count))
index = match.end()
return seq, tokens
def is_valid_iupac_constraint(sequence, material="rna", model=None):
try:
seq, _tokens = parse_design_constraint_tokens(sequence, material)
if model is not None:
model.alphabet.domain(seq)
except Exception:
return False
return True
def expand_iupac_constraint(sequence, material="rna"):
_seq, tokens = parse_design_constraint_tokens(sequence, material)
output = []
previous_prefix = None
for prefix, base, count in tokens:
if prefix is not None and prefix != previous_prefix:
output.append(prefix)
output.append(base * count)
previous_prefix = prefix
return "".join(output)
def is_mutable_iupac_constraint(sequence, material="rna"):
_seq, tokens = parse_design_constraint_tokens(sequence, material)
return any(prefix == "w" or base not in FIXED_BASES for prefix, base, _count in tokens)
def reverse_complement_fixed(sequence, material="rna", model=None):
material_name = str(material or "rna").strip().lower()
if model is not None:
complement = model.alphabet.to_string(model.alphabet.domain(f"~{sequence}"))
if material_name in {"rna", "rna06", "rna95", "merna06"}:
complement = complement.replace("T", "U")
return complement
table = DNA_COMPLEMENT_BASE if material_name.startswith("dna") else RNA_COMPLEMENT_BASE
return sequence.upper().translate(table)[::-1]
def parse_domain_tokens(text):
tokens = [token.strip() for token in re.split(r"[\s,]+", str(text or "").strip()) if token.strip()]
output = []
for token in tokens:
complement = False
domain_name = token
if token.startswith("~"):
complement = True
domain_name = token[1:]
elif token.endswith("*"):
complement = True
domain_name = token[:-1]
output.append((domain_name, complement))
return output
def get_mapping_value(mapping, key, default=None):
try:
return mapping[key]
except Exception:
return default
def ensure_unit_interval_limits(lower, upper, label):
if not (0 <= lower <= upper <= 1):
raise ValueError(f"{label} limits must satisfy 0 <= lower <= upper <= 1.")
def finite_float(value, label):
try:
parsed = float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{label} must be a number.") from exc
if not math.isfinite(parsed):
raise ValueError(f"{label} must be finite.")
return parsed
def unit_to_molar(value, unit):
if unit not in UNIT_SCALE:
raise ValueError(f"Unsupported concentration unit: {unit}")
return finite_float(value, "Concentration") * UNIT_SCALE[unit]
def parse_model_input(model_input):
material = str(model_input.get("material", "rna") or "rna").strip().lower()
rule = material_rule(material)
sodium_default = rule["sodium"][2] if rule else 1.0
magnesium_default = rule["magnesium"][2] if rule else 0.0
parsed = {
"material": material,
"ensemble": model_input.get("ensemble", "stacking"),
"celsius": finite_float(model_input.get("celsius", 37.0), "Temperature"),
"sodium": finite_float(model_input.get("sodium", sodium_default), "Sodium concentration"),
"magnesium": finite_float(model_input.get("magnesium", magnesium_default), "Magnesium concentration"),
}
if parsed["celsius"] <= -273.15:
raise ValueError("Temperature must be above absolute zero (-273.15 C).")
if parsed["sodium"] < 0 or parsed["magnesium"] < 0:
raise ValueError("Sodium and magnesium concentrations must be non-negative.")
validate_material_salt(material, "sodium", parsed["sodium"])
validate_material_salt(material, "magnesium", parsed["magnesium"])
return parsed
def build_model(model_input):
return Model(**parse_model_input(model_input))
def build_model_summary(model_input):
summary = parse_model_input(model_input)
summary["resolved_material"] = MATERIAL_ALIASES.get(summary["material"], summary["material"])
summary["nupack_version"] = NUPACK_VERSION
return summary
def parse_compute(payload):
compute = payload.get("compute") or ["pfunc", "mfe"]
compute = [item for item in compute if item in VALID_COMPUTE]
if not compute:
raise ValueError("At least one compute option is required.")
return compute
def parse_options(payload):
raw = payload.get("options") or {}
options = {
"num_sample": int(raw.get("num_sample", 20)),
"energy_gap": finite_float(raw.get("energy_gap", 1.0), "energy_gap"),
"sparsity_fraction": finite_float(raw.get("sparsity_fraction", 1.0), "sparsity_fraction"),
"sparsity_threshold": finite_float(raw.get("sparsity_threshold", 0.0), "sparsity_threshold"),
"single_mfe": bool(raw.get("single_mfe", False)),
"indistinguishable_search": bool(raw.get("indistinguishable_search", False)),
"max_subopt_count": int(raw.get("max_subopt_count", 100000)),
"result_limit": int(raw.get("result_limit", 25)),
"pairs_preview_size": int(raw.get("pairs_preview_size", 24)),
}
if options["num_sample"] < 0 or options["num_sample"] > 1000:
raise ValueError("num_sample must be between 0 and 1000.")
if options["energy_gap"] < 0:
raise ValueError("energy_gap must be non-negative.")
if not 0 <= options["sparsity_fraction"] <= 1:
raise ValueError("sparsity_fraction must be between 0 and 1.")
if not 0 <= options["sparsity_threshold"] <= 1:
raise ValueError("sparsity_threshold must be between 0 and 1.")
if options["max_subopt_count"] < 1 or options["max_subopt_count"] > 1000000:
raise ValueError("max_subopt_count must be between 1 and 1000000.")
if options["result_limit"] < 1:
raise ValueError("result_limit must be at least 1.")
if options["pairs_preview_size"] < 8 or options["pairs_preview_size"] > 128:
raise ValueError("pairs_preview_size must be between 8 and 128.")
return options
def parse_design_options(payload):
raw = payload.get("design") or {}
options = {
"trials": int(raw.get("trials", 1)),
"result_limit": int(raw.get("result_limit", 25)),
"off_target_max_size": int(raw.get("off_target_max_size", payload.get("tube", {}).get("max_size", 2))),
"stop_condition": finite_float(raw.get("stop_condition", 0.02), "design stop_condition"),
"seed": int(raw.get("seed", 0)),
"wobble_mutations": bool(raw.get("wobble_mutations", False)),
"max_time_seconds": int(raw.get("max_time_seconds", 0)),
"fixed_target_policy": str(raw.get("fixed_target_policy", "include")).strip().lower(),
}
if options["trials"] < 1 or options["trials"] > 8:
raise ValueError("design trials must be between 1 and 8.")
if options["result_limit"] < 1:
raise ValueError("design result_limit must be at least 1.")
if options["off_target_max_size"] < 1 or options["off_target_max_size"] > 8:
raise ValueError("design off_target_max_size must be between 1 and 8.")
if not 0 < options["stop_condition"] < 1:
raise ValueError("design stop_condition must be between 0 and 1.")
if options["max_time_seconds"] < 0:
raise ValueError("design max_time_seconds must be non-negative.")
if options["fixed_target_policy"] not in FIXED_TARGET_POLICIES:
raise ValueError("design fixed_target_policy must be exclude_from_optimization or include.")
return options
def euler_phi(value):
result = value
factor = 2
remaining = value
while factor * factor <= remaining:
if remaining % factor == 0:
while remaining % factor == 0:
remaining //= factor
result -= result // factor
factor += 1
if remaining > 1:
result -= result // remaining
return result
def cyclic_complex_count(strand_species, max_size):
"""Count strand-order necklaces enumerated by SetSpec up to max_size."""
strand_species = max(0, int(strand_species))
max_size = max(0, int(max_size))
total = 0
for size in range(1, max_size + 1):
rotations = sum(
euler_phi(divisor) * (strand_species ** (size // divisor))
for divisor in range(1, size + 1)
if size % divisor == 0
)
total += rotations // size
return total
def canonical_cyclic_identity(identity):
"""Normalize a strand order using NUPACK's rotation-equivalent complex identity."""
identity = tuple(identity)
if not identity:
return identity
return min(identity[index:] + identity[:index] for index in range(len(identity)))
def build_design_workload_summary(design_domains, target_rows, tube_rows, target_complex_map, design_options):
mutable_domain_nucleotides = sum(
item["length"] if "length" in item else len(expand_iupac_constraint(item["constraint"]))
for item in design_domains
if item.get("mutable")
)
summary = {
"mutable_domain_nucleotides": mutable_domain_nucleotides,
"fixed_target_count": sum(1 for item in target_rows if not item.get("mutable", True)),
"runtime_limit_seconds": design_options["max_time_seconds"] or "unbounded",
}
if not tube_rows:
return summary
total_upper_bound = 0
off_target_upper_bound = 0
largest_tube_upper_bound = 0
for tube in tube_rows:
strand_names = set()
on_target_identities = set()
for entry in tube.get("on_targets", []):
complex_obj = target_complex_map.get(entry["complex"])
if complex_obj is None:
continue
identity = canonical_cyclic_identity(strand.name for strand in complex_obj.strands)
on_target_identities.add(identity)
strand_names.update(identity)
include_identities = set()
exclude_identities = set()
for field, identities in (
("include_complexes", include_identities),
("exclude_complexes", exclude_identities),
):
for line in tube.get(field, []):
names = tuple(token.strip() for token in line.split("+") if token.strip())
if names:
identity = canonical_cyclic_identity(names)
identities.add(identity)
strand_names.update(identity)
max_size = tube["max_size"]
automatic_count = cyclic_complex_count(len(strand_names), max_size)
excluded_automatic = {
identity for identity in exclude_identities
if len(identity) <= max_size and identity not in on_target_identities
}
explicit_outside = {
identity for identity in on_target_identities.union(include_identities)
if len(identity) > max_size
}
tube_upper_bound = automatic_count - len(excluded_automatic) + len(explicit_outside)
largest_tube_upper_bound = max(largest_tube_upper_bound, tube_upper_bound)
total_upper_bound += tube_upper_bound
off_target_upper_bound += max(0, tube_upper_bound - len(on_target_identities))
summary.update(
estimated_complexes_upper_bound=total_upper_bound,
estimated_off_targets_upper_bound=off_target_upper_bound,
largest_tube_complexes_upper_bound=largest_tube_upper_bound,
)
return summary
def build_strands(strand_payload, model, material):
if not strand_payload:
raise ValueError("At least one strand is required.")
strand_map = {}
ordered = []
for row in strand_payload:
name = (row.get("name") or "").strip()
sequence = normalize_sequence(row.get("sequence") or "", material)
if not name:
raise ValueError("Every strand needs a name.")
if not sequence:
raise ValueError(f"Strand {name} is missing a sequence.")
if name in strand_map:
raise ValueError(f"Duplicate strand name: {name}")
sequence = validate_analysis_sequence(sequence, material, model, f"Strand {name}")
concentration = finite_float(row.get("concentration", 0), f"Strand {name} concentration")
if concentration < 0:
raise ValueError(f"Strand {name} concentration must be non-negative.")
strand = Strand(sequence, name=name)
strand_map[name] = strand
ordered.append(
{
"name": name,
"sequence": sequence,
"concentration": concentration,
"unit": row.get("unit", "uM"),
"object": strand,
"length": sequence_length(model, sequence),
}
)
return strand_map, ordered
def build_design_domains(domain_payload, model=None, material="rna"):
domain_map = {}
ordered = []
for row in domain_payload:
name = (row.get("name") or "").strip()
sequence_constraint = normalize_design_sequence(row.get("sequence") or "", material)
if not name:
raise ValueError("Every design domain needs a name.")
if not sequence_constraint:
raise ValueError(f"Design domain {name} is missing a sequence constraint.")
if name in domain_map:
raise ValueError(f"Duplicate domain name: {name}")
if not is_valid_iupac_constraint(sequence_constraint, material, model):
raise ValueError(f"Design domain {name} contains unsupported constraint characters.")
domain = Domain(sequence_constraint, name=name)
domain_map[name] = domain
mutable = is_mutable_iupac_constraint(sequence_constraint, material)
length = int(model.alphabet.domain_length(sequence_constraint)) if model is not None else len(
expand_iupac_constraint(sequence_constraint, material)
)
ordered.append(
{
"name": name,
"constraint": sequence_constraint,
"object": domain,
"mutable": mutable,
"fixed_sequence": None if mutable else expand_iupac_constraint(sequence_constraint, material),
"length": length,
}
)
return domain_map, ordered
def parse_domain_composition(text, domain_map):
tokens = [token.strip() for token in re.split(r"[\s,]+", str(text or "").strip()) if token.strip()]
if not tokens:
raise ValueError("Domain composition cannot be empty.")
domains = []
for token in tokens:
complement = False
domain_name = token
if token.startswith("~"):
complement = True
domain_name = token[1:]
elif token.endswith("*"):
complement = True
domain_name = token[:-1]
if not domain_name:
raise ValueError(f"Invalid domain token: {token}")
if domain_name not in domain_map:
raise ValueError(f"Unknown domain name in strand composition: {domain_name}")
domain = domain_map[domain_name]
domains.append(~domain if complement else domain)
return domains
def build_design_strands(strand_payload, domain_map=None, domain_rows=None, material="rna", model=None):
if not strand_payload:
raise ValueError("At least one design strand is required.")
domain_map = domain_map or {}
domain_info = {item["name"]: item for item in (domain_rows or [])}
use_domain_composition = bool(domain_map)
target_strand_map = {}
ordered = []
for row in strand_payload:
name = (row.get("name") or "").strip()
raw_definition = str(row.get("sequence") or "").strip()
sequence_constraint = normalize_design_sequence(raw_definition, material)
if not name:
raise ValueError("Every design strand needs a name.")
if not raw_definition:
raise ValueError(f"Design strand {name} is missing a strand definition.")
if name in target_strand_map:
raise ValueError(f"Duplicate strand name: {name}")
if use_domain_composition:
try:
strand_domains = parse_domain_composition(raw_definition, domain_map)
except ValueError:
if (" " in raw_definition) or ("," in raw_definition) or ("~" in raw_definition) or ("*" in raw_definition):
raise
if not is_valid_iupac_constraint(sequence_constraint, material, model):
raise
inline_domain = Domain(sequence_constraint, name=name)
strand_domains = [inline_domain]
target_strand = TargetStrand(strand_domains, name=name)
constraint_kind = "sequence_constraint"
constraint_value = sequence_constraint
mutable = is_mutable_iupac_constraint(sequence_constraint, material)
fixed_sequence = None if mutable else expand_iupac_constraint(sequence_constraint, material)
else:
inline_domain = None
target_strand = TargetStrand(strand_domains, name=name)
constraint_kind = "domain_composition"
constraint_value = raw_definition
fixed_parts = []
mutable = False
for domain_name, complement in parse_domain_tokens(raw_definition):
info = domain_info.get(domain_name)
if info is None:
mutable = True
fixed_parts = []
break
if info.get("mutable"):
mutable = True
fixed_parts = []
break
part = info.get("fixed_sequence") or ""
fixed_parts.append(reverse_complement_fixed(part, material, model) if complement else part)
fixed_sequence = None if mutable else "".join(fixed_parts)
else:
if not is_valid_iupac_constraint(sequence_constraint, material, model):
raise ValueError(f"Design strand {name} contains unsupported constraint characters.")
inline_domain = Domain(sequence_constraint, name=name)
strand_domains = [inline_domain]
target_strand = TargetStrand(strand_domains, name=name)
constraint_kind = "sequence_constraint"
constraint_value = sequence_constraint
mutable = is_mutable_iupac_constraint(sequence_constraint, material)
fixed_sequence = None if mutable else expand_iupac_constraint(sequence_constraint, material)
target_strand_map[name] = target_strand
ordered.append(
{
"name": name,
"definition": raw_definition,
"constraint": constraint_value,
"constraint_kind": constraint_kind,
"object": target_strand,
"domains": strand_domains,
"domain": inline_domain,
"mutable": mutable,
"fixed_sequence": fixed_sequence,
}
)
return target_strand_map, ordered
def resolve_scope_reference(expr, domain_map, strand_map, *, allow_global=False):
text = str(expr or "").strip()
if not text:
if allow_global:
return None
raise ValueError("Constraint scope cannot be empty.")
if text in strand_map:
return strand_map[text]
return parse_domain_composition(text, domain_map)
def resolve_domain_scope(expr, domain_map):
text = str(expr or "").strip()
if not text:
raise ValueError("Domain scope cannot be empty.")
return parse_domain_composition(text, domain_map)
def resolve_target_complex_scope(expr, target_complex_map):
text = str(expr or "").strip()
if not text:
raise ValueError("Target complex scope cannot be empty.")
tokens = [token.strip() for token in re.split(r"[\s,]+", text) if token.strip()]
if not tokens:
raise ValueError("Target complex scope cannot be empty.")
missing = [token for token in tokens if token not in target_complex_map]
if missing:
raise ValueError(f"Unknown target complex name(s): {', '.join(missing)}")
return [target_complex_map[token] for token in tokens]
def parse_sequence_list(text, material="rna", model=None):
if isinstance(text, list):
values = text
else:
normalized = str(text or "").replace("|", "\n").replace(";", "\n")
values = normalized.splitlines()
output = [normalize_design_sequence(item, material) for item in values if item and item.strip()]
if not output:
raise ValueError("Constraint sequence list cannot be empty.")
for seq in output:
if not is_valid_iupac_constraint(seq, material, model):
raise ValueError(f"Invalid sequence source: {seq!r}")
return output
def parse_pattern_list(text, material="rna", model=None):
if isinstance(text, list):
values = text
else:
values = re.split(r"[\n,]+", str(text or ""))
output = [normalize_design_sequence(item, material) for item in values if item and item.strip()]
if not output:
raise ValueError("Pattern constraint requires at least one pattern.")
for pattern in output:
if not is_valid_iupac_constraint(pattern, material, model):
raise ValueError(f"Invalid pattern: {pattern!r}")
return output
def parse_catalog_list(text, material="rna", model=None):
raw_lines = [line.strip() for line in str(text or "").splitlines() if line.strip()]
if not raw_lines:
raise ValueError("Library constraint requires at least one catalog row.")
catalog = []
for line in raw_lines:
library = [normalize_design_sequence(item, material) for item in re.split(r"[,|]+", line) if item.strip()]
if not library:
raise ValueError("Library constraint contains an empty catalog row.")
for seq in library:
if not is_valid_iupac_constraint(seq, material, model):
raise ValueError(f"Invalid library sequence: {seq!r}")
catalog.append(library)
return catalog
def build_hard_constraints(payload, domain_map, strand_map, material="rna", model=None):
constraints = []
for index, row in enumerate(payload.get("hard_constraints") or [], start=1):
constraint_type = (row.get("type") or "").strip().lower()
if not constraint_type:
continue
try:
def require_text(field, label):
value = str(row.get(field) or "").strip()
if not value:
raise ValueError(f"{label} is required.")
return value
if constraint_type == "match":
constraints.append(
Match(
resolve_scope_reference(require_text("left", "Match left scope"), domain_map, strand_map),
resolve_scope_reference(require_text("right", "Match right scope"), domain_map, strand_map),
)
)
elif constraint_type == "complementarity":
constraints.append(
Complementarity(
resolve_scope_reference(require_text("left", "Complementarity left scope"), domain_map, strand_map),
resolve_scope_reference(require_text("right", "Complementarity right scope"), domain_map, strand_map),
wobble_mutations=bool(row.get("wobble_mutations", False)),
)
)
elif constraint_type == "diversity":
word = int(row.get("word", 4))
types = int(row.get("types", 2))
if word < 1:
raise ValueError("Diversity word must be >= 1.")
if types < 1 or types > 4:
raise ValueError("Diversity types must be between 1 and 4.")
kwargs = {
"word": word,
"types": types,
}
scope = resolve_scope_reference(row.get("scope"), domain_map, strand_map, allow_global=True)
if scope is not None:
kwargs["scope"] = scope
constraints.append(Diversity(**kwargs))
elif constraint_type == "similarity":
lower = float(row.get("min_fraction", 0.0))
upper = float(row.get("max_fraction", 1.0))
ensure_unit_interval_limits(lower, upper, "Similarity")
reference = normalize_design_sequence(row.get("reference") or "", material)
if not is_valid_iupac_constraint(reference, material, model):
raise ValueError("Similarity reference must be a valid IUPAC constraint.")
constraints.append(
Similarity(
resolve_scope_reference(require_text("scope", "Similarity scope"), domain_map, strand_map),
reference,
limits=[lower, upper],
)
)
elif constraint_type == "window":
constraints.append(
Window(
resolve_scope_reference(require_text("scope", "Window scope"), domain_map, strand_map),
sources=parse_sequence_list(row.get("sources") or "", material, model),
)
)
elif constraint_type == "library":
constraints.append(
Library(
resolve_scope_reference(require_text("scope", "Library scope"), domain_map, strand_map),
catalog=parse_catalog_list(row.get("catalog") or "", material, model),
)
)
elif constraint_type == "pattern":
kwargs = {"patterns": parse_pattern_list(row.get("patterns") or "", material, model)}
scope = resolve_scope_reference(row.get("scope"), domain_map, strand_map, allow_global=True)
if scope is not None:
kwargs["scope"] = scope
constraints.append(Pattern(**kwargs))
else:
raise ValueError(f"Unsupported hard constraint type: {constraint_type}")
except Exception as exc:
raise ValueError(f"Invalid hard constraint #{index}: {exc}") from exc
return constraints
def build_soft_constraints(payload, domain_map, strand_map, target_complex_map, material="rna", model=None):
constraints = []
for index, row in enumerate(payload.get("soft_constraints") or [], start=1):
constraint_type = (row.get("type") or "").strip().lower()
if not constraint_type:
continue
try:
def require_text(field, label):
value = str(row.get(field) or "").strip()
if not value:
raise ValueError(f"{label} is required.")
return value
if constraint_type == "pattern":
weight = float(row.get("weight", 1.0))
if weight < 0:
raise ValueError("Pattern weight must be non-negative.")
kwargs = {
"patterns": parse_pattern_list(row.get("patterns") or "", material, model),
"weight": weight,
}
scope = resolve_scope_reference(row.get("scope"), domain_map, strand_map, allow_global=True)
if scope is not None:
kwargs["scope"] = scope
constraints.append(Pattern(**kwargs))
elif constraint_type == "similarity":
lower = float(row.get("min_fraction", 0.0))
upper = float(row.get("max_fraction", 1.0))
ensure_unit_interval_limits(lower, upper, "Similarity")
reference = normalize_design_sequence(row.get("reference") or "", material)
if not is_valid_iupac_constraint(reference, material, model):
raise ValueError("Similarity reference must be a valid IUPAC constraint.")
weight = float(row.get("weight", 1.0))
if weight < 0:
raise ValueError("Similarity weight must be non-negative.")
constraints.append(
Similarity(
resolve_scope_reference(require_text("scope", "Similarity scope"), domain_map, strand_map),
reference,
limits=[lower, upper],
weight=weight,
)
)
elif constraint_type == "ssm":
word = int(row.get("word", 4))
if word < 1:
raise ValueError("SSM word must be >= 1.")
weight = float(row.get("weight", 1.0))
if weight < 0:
raise ValueError("SSM weight must be non-negative.")
kwargs = {
"word": word,
"weight": weight,
}
scope = resolve_target_complex_scope(row.get("scope"), target_complex_map) if row.get("scope") else None
if scope is not None:
kwargs["scope"] = scope
constraints.append(SSM(**kwargs))
elif constraint_type == "energy_match":
weight = float(row.get("weight", 1.0))
if weight < 0:
raise ValueError("EnergyMatch weight must be non-negative.")
kwargs = {
"domains": resolve_domain_scope(require_text("scope", "EnergyMatch domain scope"), domain_map),
"weight": weight,
}
if row.get("energy_ref") not in {None, ""}:
kwargs["energy_ref"] = float(row.get("energy_ref"))
constraints.append(EnergyMatch(**kwargs))
else:
raise ValueError(f"Unsupported soft constraint type: {constraint_type}")
except Exception as exc:
raise ValueError(f"Invalid soft constraint #{index}: {exc}") from exc
return constraints
def parse_design_complexes(payload, target_strand_map, strand_rows=None):
target_rows = payload.get("design_complexes") or payload.get("design_targets") or []
if not target_rows:
raise ValueError("At least one design target complex is required.")
targets = []
target_complex_map = {}
strand_info = {item["name"]: item for item in (strand_rows or [])}
for idx, row in enumerate(target_rows, start=1):
name = (row.get("name") or f"target_{idx}").strip() or f"target_{idx}"
if name in target_complex_map:
raise ValueError(f"Duplicate design target complex name: {name}")
strand_text = (row.get("strands") or "").strip()
structure = (row.get("structure") or "").strip()
if not strand_text:
raise ValueError(f"Design target complex {name} is missing strand membership.")
if not structure:
raise ValueError(f"Design target complex {name} is missing a target structure.")
tokens = [token.strip() for token in strand_text.split("+") if token.strip()]
if not tokens:
raise ValueError(f"Design target complex {name} has an invalid strand list.")
missing = [token for token in tokens if token not in target_strand_map]
if missing:
raise ValueError(
f"Unknown strand name(s) in design target complex {name}: {', '.join(missing)}"
)
bonus = float(row.get("bonus", 0) or 0)
target_complex = TargetComplex(
[target_strand_map[token] for token in tokens],
structure,
bonus=bonus,
name=name,
)
mutable = any(strand_info.get(token, {}).get("mutable", True) for token in tokens)
target_payload = {
"name": name,
"strands": tokens,
"structure": structure,
"bonus": bonus,
"object": target_complex,
"mutable": mutable,
"optimization_status": "included",
}
targets.append(target_payload)
target_complex_map[name] = target_complex
return targets, target_complex_map
def parse_design_set_members(value, target_complex_map, target_strand_map, label):
members = []
identities = set()
lines = [line.strip() for line in str(value or "").splitlines() if line.strip()]
for index, line in enumerate(lines, start=1):
if line in target_complex_map:
member = target_complex_map[line]
identity = tuple(strand.name for strand in member.strands)
else:
names = [token.strip() for token in line.split("+") if token.strip()]
missing = [name for name in names if name not in target_strand_map]
if not names or missing:
detail = f": {', '.join(missing)}" if missing else ""
raise ValueError(f"Invalid {label} entry #{index}{detail}")
member = [target_strand_map[name] for name in names]
identity = tuple(names)
members.append(member)
identities.add(identity)
return members, identities
def lines_from_set_members(members):
return ["+".join(strand.name for strand in member.strands) if hasattr(member, "strands")
else "+".join(strand.name for strand in member) for member in members]
def parse_design_tubes(payload, target_rows, target_complex_map, target_strand_map, default_max_size):
tube_rows = payload.get("design_tubes") or []
if not tube_rows:
legacy_targets = payload.get("design_targets") or []
if legacy_targets:
on_targets = []
for row in legacy_targets:
if row.get("concentration") in {None, ""}:
continue
on_targets.append(
{
"complex": row.get("name"),
"concentration": row.get("concentration"),
"unit": row.get("unit", "uM"),
}
)
if on_targets:
tube_rows = [{
"name": ((payload.get("tube") or {}).get("name") or "design_tube"),
"max_size": default_max_size,
"on_targets": on_targets,
}]
if not tube_rows:
raise ValueError("Tube design requires at least one target tube.")
tubes = []
ordered_rows = []
tube_name_set = set()
for index, row in enumerate(tube_rows, start=1):
tube_name = (row.get("name") or f"tube_{index}").strip() or f"tube_{index}"
if tube_name in tube_name_set:
raise ValueError(f"Duplicate target tube name: {tube_name}")
tube_name_set.add(tube_name)
max_size = int(row.get("max_size", default_max_size))
if max_size < 1 or max_size > 8:
raise ValueError(f"Target tube {tube_name} max_size must be between 1 and 8.")
on_targets_input = row.get("on_targets") or []
if not on_targets_input:
raise ValueError(f"Target tube {tube_name} must include at least one on-target complex.")
on_targets = {}
serialized_on_targets = []
seen_complex_names = set()
for entry in on_targets_input:
complex_name = (entry.get("complex") or "").strip()
if complex_name not in target_complex_map:
raise ValueError(f"Unknown target complex {complex_name} in tube {tube_name}.")
if complex_name in seen_complex_names:
raise ValueError(f"Duplicate on-target complex {complex_name} in tube {tube_name}.")
seen_complex_names.add(complex_name)
concentration = finite_float(
entry.get("concentration", 0),
f"Target tube {tube_name} concentration",
)
unit = entry.get("unit", "uM")
if concentration <= 0:
raise ValueError(f"Target tube {tube_name} requires positive target concentrations.")
concentration_M = unit_to_molar(concentration, unit)
complex_obj = target_complex_map[complex_name]
on_targets[complex_obj] = concentration_M
serialized_on_targets.append(
{
"complex": complex_name,
"concentration": concentration,
"unit": unit,
"target_concentration_M": concentration_M,
}
)
include, include_ids = parse_design_set_members(
row.get("include_complexes", ""), target_complex_map, target_strand_map,
f"target tube {tube_name} include list",
)
exclude, exclude_ids = parse_design_set_members(
row.get("exclude_complexes", ""), target_complex_map, target_strand_map,
f"target tube {tube_name} exclude list",
)
overlap = include_ids.intersection(exclude_ids)
if overlap:
display = ", ".join("+".join(names) for names in sorted(overlap))
raise ValueError(f"Target tube {tube_name} complexes cannot be both included and excluded: {display}")
tube = TargetTube(
on_targets=on_targets,
off_targets=SetSpec(max_size=max_size, include=tuple(include), exclude=tuple(exclude)),
name=tube_name,
)
ordered_rows.append(
{
"name": tube_name,
"max_size": max_size,
"on_targets": serialized_on_targets,
"include_complexes": lines_from_set_members(include),
"exclude_complexes": lines_from_set_members(exclude),
"object": tube,
}
)
tubes.append(tube)
return ordered_rows, tubes
def payload_with_allowed_design_targets(payload, allowed_target_names):
allowed = set(allowed_target_names)
filtered_tubes = []
for row in payload.get("design_tubes") or []:
on_targets = [entry for entry in (row.get("on_targets") or []) if (entry.get("complex") or "").strip() in allowed]
if on_targets:
tube_row = dict(row)
tube_row["on_targets"] = on_targets
filtered_tubes.append(tube_row)
output = dict(payload)
output["design_tubes"] = filtered_tubes
return output
def validate_design_object_names(design_domains, design_strands, target_rows, tube_rows):
name_map = {}
for kind, rows in (
("domain", design_domains),
("strand", design_strands),
("target complex", target_rows),
("target tube", tube_rows),
):
for row in rows:
name = (row.get("name") or "").strip()
if not name:
continue
prev = name_map.get(name)
if prev and prev != kind:
raise ValueError(
f"Object name {name!r} is reused across {prev} and {kind}. "
"Design object names must be unique in a job."
)
name_map[name] = kind
def build_defect_weights(payload, design_mode, design_domains, design_strands, target_rows, tube_rows):
rows = payload.get("defect_weights") or []
if not rows:
return None
domain_index = {item["name"]: item["object"] for item in design_domains}
strand_index = {item["name"]: item["object"] for item in design_strands}
complex_index = {item["name"]: item["object"] for item in target_rows}
tube_index = {item["name"]: item["object"] for item in tube_rows}
weight_base = [item["object"] for item in tube_rows] if design_mode == "tube" else [item["object"] for item in target_rows]
weights = Weights(weight_base)
for index, row in enumerate(rows, start=1):
kind = (row.get("kind") or "").strip().lower()
value = float(row.get("weight", 1.0))
if value < 0:
raise ValueError(f"Invalid defect weight #{index}: weight must be non-negative")
name = (row.get("name") or "").strip()
try:
if kind == "global":
weights[:] = value
elif kind == "domain":
weights[domain_index[name]] = value
elif kind == "strand":
weights[:, strand_index[name]] = value
elif kind == "complex":
weights[:, :, complex_index[name]] = value
elif kind == "tube":
if design_mode != "tube":
raise ValueError("tube-level defect weights are only valid in tube design mode")
weights[:, :, :, tube_index[name]] = value
else:
raise ValueError(f"Unsupported defect weight kind: {kind}")
except KeyError as exc:
raise ValueError(f"Unknown entity for defect weight #{index}: {name}") from exc
except Exception as exc:
raise ValueError(f"Invalid defect weight #{index}: {exc}") from exc
return weights
def parse_complex_lines(text, strand_map, *, required=True, label="Complex mode", name_prefix="complex"):
complexes = []
raw_lines = [line.strip() for line in str(text or "").splitlines() if line.strip()]
if not raw_lines:
if required:
raise ValueError(f"{label} requires at least one complex definition.")
return complexes
for idx, line in enumerate(raw_lines, start=1):
definition_parts = [part.strip() for part in line.split(";") if part.strip()]
composition = definition_parts[0]
tokens = [token.strip() for token in composition.split("+") if token.strip()]
if not tokens:
raise ValueError(f"Invalid complex definition on line {idx}: {line}")
bonus = 0.0
for option in definition_parts[1:]:
match = re.fullmatch(r"bonus\s*=\s*(.+)", option, flags=re.IGNORECASE)
if not match:
raise ValueError(f"Unsupported complex option on line {idx}: {option}")
bonus = finite_float(match.group(1), f"Complex line {idx} bonus")
missing = [token for token in tokens if token not in strand_map]
if missing:
raise ValueError(
f"Unknown strand name(s) in complex line {idx}: {', '.join(missing)}"
)
strands = [strand_map[token] for token in tokens]
complexes.append(Complex(strands, name=f"{name_prefix}_{idx}", bonus=bonus))
return complexes
def build_tube_set_spec(tube_cfg, strand_map, max_size):
included = parse_complex_lines(
tube_cfg.get("include_complexes", ""),
strand_map,
required=False,
label="Tube include list",
name_prefix="included_complex",
)
excluded = parse_complex_lines(
tube_cfg.get("exclude_complexes", ""),
strand_map,
required=False,
label="Tube exclude list",
name_prefix="excluded_complex",
)
overlap = set(included).intersection(excluded)
if overlap:
names = ", ".join(sorted(stringify_complex(item) for item in overlap))
raise ValueError(f"Tube complexes cannot be both included and excluded: {names}")
return (
SetSpec(max_size=max_size, include=tuple(included), exclude=tuple(excluded)),
included,
excluded,
)
def seconds_since(started_at):
return round(time.perf_counter() - started_at, 6)
def build_performance_summary(timings, workload, notes, pipeline):
measured = {key: round(float(value), 6) for key, value in timings.items()}
dominant_stage = max(measured, key=measured.get) if measured else None
return {
"pipeline": pipeline,
"timings_seconds": measured,
"dominant_stage": dominant_stage,
"workload": workload,
"notes": notes,
}
def stringify_complex(complex_obj):
return " + ".join(strand.name for strand in complex_obj.strands)
def flatten_sequence(complex_obj, alphabet=None, for_plot=False):
sequences = []
for strand in complex_obj.strands:
raw = str(strand)
if alphabet is not None and for_plot:
raw = str(alphabet.sequence(raw)).upper()
sequences.append(raw)
return "".join(sequences)
def sanitize_structure_for_rnaplot(structure):
return str(structure).replace("+", "")
def safe_name(name):
return re.sub(r"[^A-Za-z0-9_.-]+", "_", name).strip("_") or "structure"
def render_rnaplot_svg(complex_obj, structure, suffix, alphabet=None):
if not ENABLE_RNAPLOT:
return None
seq_name = safe_name(f"{stringify_complex(complex_obj)}_{suffix}")
sequence = flatten_sequence(complex_obj, alphabet=alphabet, for_plot=True)
structure_text = sanitize_structure_for_rnaplot(structure)
with tempfile.TemporaryDirectory(prefix="rnaplot-") as tmpdir:
tmp_path = Path(tmpdir)
input_path = tmp_path / f"{seq_name}.seq"
output_path = tmp_path / f"{seq_name}_ss.svg"
input_path.write_text(
f">{seq_name}\n{sequence}\n{structure_text}\n",
encoding="utf-8",
newline="\n",
)
process = subprocess.run(
[RNA_PLOT_CMD, "-f", "svg", input_path.name],
cwd=tmpdir,
capture_output=True,
text=True,
timeout=20,
)
if process.returncode != 0:
raise RuntimeError(process.stderr.strip() or "RNAplot failed.")
if not output_path.exists():
raise RuntimeError("RNAplot finished without producing an SVG file.")
return output_path.read_text(encoding="utf-8")
def render_structure_svg(complex_obj, structure, suffix, alphabet=None):
structure_text = str(structure)
multistrand = len(complex_obj.strands) > 1 and "+" in structure_text
should_try_split = STRUCTURE_PLOT_MODE == "split" or (
STRUCTURE_PLOT_MODE != "rnaplot" and multistrand
)
split_error = None
if should_try_split:
try:
return render_split_strands_svg(
[str(alphabet.sequence(str(strand))).upper() if alphabet is not None else str(strand)
for strand in complex_obj.strands],
structure_text,
title=safe_name(f"{stringify_complex(complex_obj)}_{suffix}"),
)
except Exception as exc:
split_error = exc
if STRUCTURE_PLOT_MODE == "split":
raise RuntimeError(f"Split-strand layout failed: {exc}") from exc
if STRUCTURE_PLOT_MODE == "split":
if split_error is not None:
raise RuntimeError(f"Split-strand layout failed: {split_error}") from split_error
return None
try:
return render_rnaplot_svg(complex_obj, structure, suffix, alphabet=alphabet)
except Exception as exc:
if split_error is not None:
raise RuntimeError(
f"Split-strand layout failed: {split_error}; RNAplot fallback failed: {exc}"
) from exc
raise
def serialize_structures(complex_obj, items, plot_kind=None, alphabet=None):
output = []
for index, item in enumerate(items or []):
row = {
"structure": str(item.structure),
"energy": round(float(item.energy), 6),
"stack_energy": round(float(item.stack_energy), 6),
}
if plot_kind and index == 0:
try:
row["rnaplot_svg"] = render_structure_svg(
complex_obj, item.structure, plot_kind, alphabet=alphabet
)
except Exception as exc:
row["rnaplot_error"] = str(exc)
output.append(row)
return output
def serialize_samples(items):
return [str(item) for item in (items or [])]
def serialize_pairs(pair_matrix, preview_limit=24):
if pair_matrix is None:
return None
dense = numpy.asarray(pair_matrix.to_array(), dtype=float)
preview = dense[:preview_limit, :preview_limit]
return {
"shape": list(dense.shape),
"preview": numpy.round(preview, 6).tolist(),
"preview_size": int(preview_limit),
"preview_truncated": bool(dense.shape[0] > preview_limit),
}
def parse_structure_pairs(structure):
stack = []
pairs = []
compact_index = -1
index_map = []
for char in str(structure):
if char == "+":
continue
compact_index += 1
index_map.append(compact_index)
if char == "(":
stack.append(compact_index)
elif char == ")":
if not stack:
raise ValueError("Unbalanced structure: missing opening bracket.")
left = stack.pop()
pairs.append((left, compact_index))
if stack:
raise ValueError("Unbalanced structure: missing closing bracket.")
pairs.sort()
return pairs, compact_index + 1
def build_structure_probabilities(structure, pair_matrix):
if pair_matrix is None:
return None
dense = numpy.asarray(pair_matrix.to_array(), dtype=float)
mfe_pairs, structure_length = parse_structure_pairs(structure)
if dense.shape[0] != dense.shape[1]:
return None
if structure_length != dense.shape[0]:
return None
pair_partner = {}
for left, right in mfe_pairs:
pair_partner[left] = right
pair_partner[right] = left
residue_probabilities = []
for index in range(structure_length):
partner = pair_partner.get(index)
if partner is None:
probability = float(dense[index, index])
else:
probability = float(dense[index, partner])
residue_probabilities.append(round(probability, 6))
pair_probabilities = [
{
"i": left + 1,
"j": right + 1,
"probability": round(float(dense[left, right]), 6),
}
for left, right in mfe_pairs
]
return {
"length": structure_length,
"residue_probabilities": residue_probabilities,
"pair_probabilities": pair_probabilities,
}
def serialize_complex_result(complex_obj, data, pairs_preview_size=24, alphabet=None):
payload = {
"name": getattr(complex_obj, "name", None),
"display": stringify_complex(complex_obj),
"strand_names": [strand.name for strand in complex_obj.strands],
"sequence": flatten_sequence(complex_obj),
"strand_lengths": [
int(strand.nt(alphabet)) if alphabet is not None else len(str(strand))
for strand in complex_obj.strands
],
"bonus_kcal_mol": float(getattr(complex_obj, "bonus", 0.0)),
}
if data.pfunc is not None:
payload["pfunc"] = format(data.pfunc, "g") if isinstance(data.pfunc, Decimal) else str(data.pfunc)
if data.free_energy is not None:
payload["free_energy_kcal_mol"] = round(float(data.free_energy), 6)
if data.ensemble_size is not None:
payload["ensemble_size"] = int(data.ensemble_size)
if data.mfe_stack is not None:
payload["mfe_stack_kcal_mol"] = round(float(data.mfe_stack), 6)
if data.mfe is not None:
payload["mfe"] = serialize_structures(
complex_obj, data.mfe, plot_kind="mfe", alphabet=alphabet
)
if data.subopt is not None:
payload["subopt"] = serialize_structures(complex_obj, data.subopt, alphabet=alphabet)
if data.sample is not None:
payload["sample"] = serialize_samples(data.sample)
if data.pairs is not None:
payload["pairs"] = serialize_pairs(data.pairs, preview_limit=pairs_preview_size)
if payload.get("mfe"):
payload["mfe"][0]["pair_probability_annotations"] = build_structure_probabilities(
payload["mfe"][0]["structure"], data.pairs
)
return payload
def serialize_target_defect_rows(df, limit=None):
if df is None:
return []
rows = []
for record in df.to_dict("records"):
row = {}
for key, value in record.items():
if key in {"tube", "complex"}:
continue
if isinstance(value, numpy.floating):
row[key] = float(value)
else:
row[key] = value
rows.append(row)
if limit is not None:
rows = rows[:limit]
return rows
def serialize_design_result(
design_result,
design_mode,
model,
model_summary,
design_options,
target_rows,
tube_rows,
ordered_domains,
ordered_strands,
):
analysis_map = getattr(design_result, "to_analysis", {}) or {}
designed_domains = []
designed_domain_map = getattr(design_result, "domains", {}) or {}
for item in ordered_domains:
designed_domain = designed_domain_map.get(item["object"])
domain_sequence = str(designed_domain) if designed_domain is not None else item.get("fixed_sequence")
designed_domains.append(
{
"name": item["name"],
"constraint": item["constraint"],
"sequence": domain_sequence,
"length": int(model.alphabet.domain_length(domain_sequence)) if domain_sequence is not None else None,
"mutable": bool(item.get("mutable", True)),
}
)
designed_strands = []
designed_strand_by_name = {}
for item in ordered_strands:
target_strand = item["object"]
analysis_strand = get_mapping_value(analysis_map, target_strand)
sequence = str(analysis_strand) if analysis_strand is not None else item.get("fixed_sequence")
designed_strands.append(
{
"name": item["name"],
"constraint": item["constraint"],
"constraint_kind": item.get("constraint_kind", "sequence_constraint"),
"definition": item.get("definition", item["constraint"]),
"sequence": sequence,
"length": sequence_length(model, sequence) if sequence is not None else None,
"mutable": bool(item.get("mutable", True)),
}
)
if sequence is not None:
designed_strand_by_name[item["name"]] = sequence
target_complexes = []
for target in target_rows:
target_complex = target["object"]
analysis_complex = get_mapping_value(analysis_map, target_complex)
if analysis_complex is not None:
display = stringify_complex(analysis_complex)
sequence = flatten_sequence(analysis_complex)
else:
display = " + ".join(target["strands"])
sequence = "".join(designed_strand_by_name.get(name, "") for name in target["strands"])
target_complexes.append(
{
"name": target["name"],
"display": display,
"strand_names": list(target["strands"]),
"structure": target["structure"],
"bonus": target.get("bonus", 0),
"sequence": sequence,
"target_concentration_M": target.get("target_concentration_M"),
"optimization_status": target.get("optimization_status", "included"),
"mutable": bool(target.get("mutable", True)),
}
)
concentration_rows = []
concentration_table = getattr(getattr(design_result, "concentrations", None), "table", None)
if design_mode == "tube" and concentration_table is not None:
for record in concentration_table.to_dict("records"):
concentration_rows.append(
{
"tube_name": record["tube_name"],
"complex_name": record["complex_name"],
"concentration_M": float(record["concentration"]),
"target_concentration_M": float(record["target_concentration"]),
"nucleotides": int(record["nucleotides"]),
}
)
concentration_rows.sort(key=lambda row: (row["tube_name"], -row["concentration_M"]))
tube_results = []
if design_mode == "tube":
for tube_row in tube_rows:
rows = [row for row in concentration_rows if row["tube_name"] == tube_row["name"]]
tube_results.append({
"name": tube_row["name"],
"max_size": tube_row["max_size"],
"include_complexes": tube_row.get("include_complexes", []),
"exclude_complexes": tube_row.get("exclude_complexes", []),
"complex_concentrations": rows[: design_options["result_limit"]],
"total_complex_concentrations": len(rows),
})
displayed_concentration_rows = [
concentration
for tube_result in tube_results
for concentration in tube_result["complex_concentrations"]
]
return {
"workflow": "design",
"mode": design_mode,
"model": model_summary,
"compute": ["design"],
"options": design_options,
"strands": designed_strands,
"complexes": target_complexes,
"total_complex_count": len(target_complexes),
"displayed_complex_count": len(target_complexes),
"tube": tube_results[0] if tube_results else None,
"tubes": tube_results,
"design": {
"ensemble_defect": float(design_result.ensemble_defect),
"weighted_ensemble_defect": float(design_result.defects.weighted_ensemble_defect),
"objective": float(design_objective(design_result)),
"domains": designed_domains,
"stats": {
key: float(value) if isinstance(value, (int, float, numpy.floating)) else value
for key, value in design_result.stats.items()
},
"job_options": {
"f_stop": design_options["stop_condition"],
"seed": design_options["seed"],
"wobble_mutations": design_options["wobble_mutations"],
"max_time_seconds": design_options["max_time_seconds"],
"fixed_target_policy": design_options["fixed_target_policy"],
},
"optimization": {
"included_targets": [row["name"] for row in target_rows if row.get("optimization_status") == "included"],
"excluded_fixed_targets": [row["name"] for row in target_rows if row.get("optimization_status") == "fixed_excluded"],
},
"target_tubes": [
{
"name": row["name"],
"max_size": row["max_size"],
"on_targets": row["on_targets"],
"include_complexes": row.get("include_complexes", []),
"exclude_complexes": row.get("exclude_complexes", []),
}
for row in tube_rows
],
"targets": target_complexes,
"defects": {
"tubes": serialize_target_defect_rows(getattr(design_result.defects, "tubes", None)),
"complexes": serialize_target_defect_rows(getattr(design_result.defects, "complexes", None)),
"tube_complexes": serialize_target_defect_rows(getattr(design_result.defects, "tube_complexes", None)),
},
"concentrations": displayed_concentration_rows,
},
}
def design_objective(result):
objectives = getattr(getattr(result, "defects", None), "objectives", None)
if objectives is not None and "weighted" in objectives:
return float(objectives["weighted"].sum())
return float(result.ensemble_defect)
def serialize_utility_structure_energies(items):
return [
{
"structure": str(item.structure),
"energy": float(item.energy),
"stack_energy": float(item.stack_energy),
}
for item in items
]
def run_utility_payload(payload, model, model_summary, progress_callback=None):
utility = payload.get("utility") or {}
operation = str(utility.get("operation", "pfunc")).strip().lower()
if operation not in VALID_UTILITY_OPERATIONS:
raise ValueError(f"Unsupported utility operation: {operation}")
if progress_callback:
progress_callback("validating", "Validating utility inputs", {"operation": operation})
rows = payload.get("strands") or []
material = model_summary["material"]
sequences = [normalize_sequence(row.get("sequence") or "", material) for row in rows]
if operation not in {"seq_distance", "struc_distance"}:
if operation != "des" and (not sequences or any(not sequence for sequence in sequences)):
raise ValueError(f"{operation} requires at least one sequence.")
if operation == "des":
if material in MIXED_MATERIAL_PREFIXES:
raise ValueError("Mixed-material des utility is not enabled in this release.")
if any(not sequence or not re.fullmatch(rf"[{IUPAC_CODES}]+", sequence) for sequence in sequences):
raise ValueError("des received an invalid IUPAC sequence alphabet.")
else:
sequences = [
validate_analysis_sequence(sequence, material, model, f"Utility strand {index + 1}")
for index, sequence in enumerate(sequences)
]
structure = str(utility.get("structure") or "").strip()
options = parse_options(payload)
started = time.perf_counter()
if progress_callback:
progress_callback("computing", f"Running NUPACK utility: {operation}", {
"operation": operation,
"strand_count": len(sequences),
"nucleotides": sum(sequence_length(model, sequence) for sequence in sequences),
})
output = {}
if operation == "pfunc":
partition_function, free_energy = nupack.pfunc(sequences, model)
output = {"partition_function": str(partition_function), "free_energy_kcal_per_mol": float(free_energy)}
elif operation == "structure_energy":
if not structure:
raise ValueError("structure_energy requires a structure.")
output = {"energy_kcal_per_mol": float(nupack.structure_energy(
sequences, structure, model, distinguishable=bool(utility.get("distinguishable", False))
))}
elif operation == "structure_probability":
if not structure:
raise ValueError("structure_probability requires a structure.")
output = {"probability": float(nupack.structure_probability(sequences, structure, model))}
elif operation == "sample":
output = {"structures": [str(item) for item in nupack.sample(sequences, options["num_sample"], model)]}
elif operation == "pairs":
matrix = nupack.pairs(
sequences, model,
sparsity_fraction=options["sparsity_fraction"],
sparsity_threshold=options["sparsity_threshold"],
)
output = {"pairs": serialize_pairs(matrix, preview_limit=options["pairs_preview_size"])}
elif operation == "mfe":
output = {"structures": serialize_utility_structure_energies(nupack.mfe(
sequences,
model,
max_subopt_count=options["max_subopt_count"],
indistinguishable_search=options["indistinguishable_search"],
))}
elif operation == "subopt":
output = {"structures": serialize_utility_structure_energies(
nupack.subopt(
sequences,
options["energy_gap"],
model,
indistinguishable_search=options["indistinguishable_search"],
max_subopt_count=options["max_subopt_count"],
)
)}
elif operation == "ensemble_size":
output = {"ensemble_size": int(nupack.ensemble_size(sequences, model))}
elif operation == "des":
if not structure:
raise ValueError("des requires a target structure.")
output = {"sequences": [str(item) for item in nupack.des(
structure, strands=sequences or None, model=model
)]}
elif operation == "defect":
if not structure:
raise ValueError("defect requires a target structure.")
output = {"defect": float(nupack.defect(structure, sequences, model=model))}
elif operation == "seq_distance":
first = normalize_sequence(utility.get("input_a") or "", material)
second = normalize_sequence(utility.get("input_b") or "", material)
if not first or not second:
raise ValueError("seq_distance requires two sequences.")
first = validate_analysis_sequence(first, material, model, "seq_distance input A")
second = validate_analysis_sequence(second, material, model, "seq_distance input B")
output = {"distance": int(model.alphabet.seq_distance(first, second))}
elif operation == "struc_distance":
first = str(utility.get("input_a") or "").strip()
second = str(utility.get("input_b") or "").strip()
if not first or not second:
raise ValueError("struc_distance requires two structures.")
output = {"distance": int(nupack.struc_distance(first, second))}
compute_seconds = seconds_since(started)
if progress_callback:
progress_callback("serializing", "Serializing utility result", {"operation": operation})
result = {
"workflow": "utilities",
"mode": "utility",
"model": model_summary,
"compute": [operation],
"operation": operation,
"options": options,
"strands": [{"name": row.get("name") or f"strand_{i + 1}", "sequence": sequence}
for i, (row, sequence) in enumerate(zip(rows, sequences))],
"utility": output,
"complexes": [],
"total_complex_count": 0,
"displayed_complex_count": 0,
}
result["performance"] = build_performance_summary(
{"nupack_compute": compute_seconds},
{
"strand_count": len(sequences),
"input_nucleotides": sum(sequence_length(model, item) for item in sequences),
},
[],
f"utility_{operation}",
)
return result
def sort_concentrations(complex_concentrations):
rows = []
for complex_obj, value in complex_concentrations.items():
rows.append(
{
"display": stringify_complex(complex_obj),
"strand_names": [strand.name for strand in complex_obj.strands],
"concentration_M": float(value),
}
)
rows.sort(key=lambda row: row["concentration_M"], reverse=True)
return rows
def run_job_payload(payload, progress_callback=None):
job_started = time.perf_counter()
workflow = payload.get("workflow", "analysis")
mode = payload.get("mode", "tube")
model_input = payload.get("model") or {}
model = build_model(model_input)
model_summary = build_model_summary(model_input)
if workflow == "utilities":
return run_utility_payload(payload, model, model_summary, progress_callback)
if workflow == "design":
if progress_callback:
progress_callback("preparing_design", "Building NUPACK design specification", {
"mode": mode,
"trials": int((payload.get("design") or {}).get("trials", 1)),
})
design_options = parse_design_options(payload)
design_job_option_kwargs = {
"f_stop": design_options["stop_condition"],
"seed": design_options["seed"],
"wobble_mutations": design_options["wobble_mutations"],
}
if design_options["max_time_seconds"] > 0:
design_job_option_kwargs["max_time"] = design_options["max_time_seconds"]
design_job_options = DesignOptions(**design_job_option_kwargs)
design_domain_map, design_domains = build_design_domains(
payload.get("design_domains") or [], model=model, material=model_summary["material"]
)
target_strand_map, design_strands = build_design_strands(
payload.get("strands") or [],
domain_map=design_domain_map,
domain_rows=design_domains,
material=model_summary["material"],
model=model,
)
target_rows, target_complex_map = parse_design_complexes(payload, target_strand_map, strand_rows=design_strands)
optimization_target_rows = target_rows
optimization_target_complex_map = target_complex_map
if design_options["fixed_target_policy"] == "exclude_from_optimization":
optimization_target_rows = [row for row in target_rows if row.get("mutable", True)]
fixed_names = {row["name"] for row in target_rows if not row.get("mutable", True)}
for row in target_rows:
if row["name"] in fixed_names:
row["optimization_status"] = "fixed_excluded"
optimization_target_complex_map = {
row["name"]: row["object"] for row in optimization_target_rows
}
if not optimization_target_rows:
raise ValueError("Design contains no mutable target complexes after fixed-target filtering.")
hard_constraints = build_hard_constraints(
payload, design_domain_map, target_strand_map, model_summary["material"], model
)
soft_constraints = build_soft_constraints(
payload,
design_domain_map,
target_strand_map,
optimization_target_complex_map,
model_summary["material"],
model,
)
tube_rows = []
design_tubes = []
if mode == "tube":
optimization_payload = payload
if design_options["fixed_target_policy"] == "exclude_from_optimization":
optimization_payload = payload_with_allowed_design_targets(
payload,
{row["name"] for row in optimization_target_rows},
)
tube_rows, design_tubes = parse_design_tubes(
optimization_payload,
optimization_target_rows,
optimization_target_complex_map,
target_strand_map,
design_options["off_target_max_size"],
)
validate_design_object_names(design_domains, design_strands, target_rows, tube_rows)
defect_weights = build_defect_weights(
payload,
mode,
design_domains,
design_strands,
target_rows,
tube_rows,
)
design_workload = build_design_workload_summary(
design_domains,
target_rows,
tube_rows,
optimization_target_complex_map,
design_options,
)
if mode == "tube":
tube_name = ((payload.get("tube") or {}).get("name") or "design_tube").strip() or "design_tube"
design_job = tube_design(
tubes=design_tubes,
model=model,
options=design_job_options,
hard_constraints=hard_constraints,
soft_constraints=soft_constraints,
defect_weights=defect_weights,
)
elif mode == "complex":
design_job = complex_design(
complexes=[row["object"] for row in optimization_target_rows],
model=model,
options=design_job_options,
hard_constraints=hard_constraints,
soft_constraints=soft_constraints,
defect_weights=defect_weights,
)
else:
raise ValueError(f"Unsupported design mode: {mode}")
setup_seconds = seconds_since(job_started)
design_started = time.perf_counter()
if progress_callback:
progress_callback("optimizing", "Running NUPACK design optimization", {
"mode": mode,
"trials": design_options["trials"],
"target_complexes": len(optimization_target_rows),
"target_tubes": len(tube_rows),
"f_stop": design_options["stop_condition"],
**design_workload,
})
results = design_job.run(trials=design_options["trials"])
design_seconds = seconds_since(design_started)
best_result = min(results, key=design_objective)
serialization_started = time.perf_counter()
if progress_callback:
progress_callback("serializing", "Serializing best design result", {
"completed_trials": len(results),
"best_objective": design_objective(best_result),
})
serialized_result = serialize_design_result(
best_result,
mode,
model,
model_summary,
design_options,
target_rows,
tube_rows,
design_domains,
design_strands,
)
serialization_seconds = seconds_since(serialization_started)
notes = []
if design_options["trials"] > 1:
notes.append("trials repeats the complete stochastic design search; runtime grows approximately with trial count.")
if design_options["stop_condition"] < 0.02:
notes.append("A strict f_stop can keep the optimizer searching much longer when the target defect is hard to reach.")
if mode == "tube" and design_options["off_target_max_size"] > 2:
notes.append("off_target_max_size expands the off-target ensemble combinatorially.")
serialized_result["performance"] = build_performance_summary(
{
"input_setup": setup_seconds,
"nupack_design": design_seconds,
"serialization": serialization_seconds,
},
{
"domain_count": len(design_domains),
"strand_count": len(design_strands),
"target_complex_count": len(target_rows),
"target_tube_count": len(tube_rows),
"trials": design_options["trials"],
"off_target_max_size": design_options["off_target_max_size"],
"hard_constraint_count": len(payload.get("hard_constraints") or []),
"soft_constraint_count": len(payload.get("soft_constraints") or []),
**design_workload,
},
notes,
"tube_design" if mode == "tube" else "complex_design",
)
return serialized_result
if workflow != "analysis":
raise ValueError(f"Unsupported workflow: {workflow}")
compute = parse_compute(payload)
options = parse_options(payload)
nupack_options = {
key: value
for key, value in options.items()
if key not in {"result_limit", "pairs_preview_size"}
}
strand_map, strands = build_strands(
payload.get("strands") or [], model, model_summary["material"]
)
if mode == "tube":
tube_compute = [item for item in compute if item != "pfunc"]
tube_cfg = payload.get("tube") or {}
max_size = int(tube_cfg.get("max_size", 2))
if max_size < 1 or max_size > 8:
raise ValueError("max_size must be between 1 and 8.")
strand_concentrations = {
item["object"]: unit_to_molar(item["concentration"], item["unit"])
for item in strands
}
set_spec, included_complexes, excluded_complexes = build_tube_set_spec(tube_cfg, strand_map, max_size)
tube = Tube(
strands=strand_concentrations,
complexes=set_spec,
name=(tube_cfg.get("name") or "tube1").strip() or "tube1",
)
setup_seconds = seconds_since(job_started)
nupack_started = time.perf_counter()
if progress_callback:
progress_callback("computing", "Running NUPACK tube analysis", {
"max_size": max_size,
"strand_count": len(strands),
"compute": tube_compute,
})
result = tube_analysis([tube], model=model, compute=tube_compute, options=nupack_options)
nupack_seconds = seconds_since(nupack_started)
tube_result = result[tube]
serialization_started = time.perf_counter()
if progress_callback:
progress_callback("serializing", "Serializing tube analysis result", {})
concentration_rows = sort_concentrations(tube_result.complex_concentrations)
total_complex_count = len(concentration_rows)
result_limit = options["result_limit"]
displayed_concentration_rows = concentration_rows[:result_limit]
concentration_by_display = {
row["display"]: row["concentration_M"] for row in displayed_concentration_rows
}
complexes = []
for complex_obj, data in result.complexes.items():
display_name = stringify_complex(complex_obj)
if display_name not in concentration_by_display:
continue
row = serialize_complex_result(
complex_obj,
data,
pairs_preview_size=options["pairs_preview_size"],
alphabet=model.alphabet,
)
row["concentration_M"] = concentration_by_display[row["display"]]
complexes.append(row)
complexes.sort(key=lambda row: row["concentration_M"], reverse=True)
serialization_seconds = seconds_since(serialization_started)
notes = []
if max_size > 2:
notes.append("max_size expands the combinatorial complex ensemble and is usually the main Tube cost driver.")
if "pairs" in compute:
notes.append("pairs computes a full complex-ensemble pair matrix for every Tube complex; sparsity reduces returned matrix density.")
if "subopt" in compute:
notes.append("subopt result count can grow rapidly as energy_gap increases.")
if "sample" in compute:
notes.append(f"sample requested {options['num_sample']} Boltzmann structures per complex.")
performance = build_performance_summary(
{
"input_setup": setup_seconds,
"nupack_compute": nupack_seconds,
"serialization_and_plots": serialization_seconds,
},
{
"strand_count": len(strands),
"input_nucleotides": sum(item["length"] for item in strands),
"enumerated_complex_count": total_complex_count,
"max_complex_size": max_size,
},
notes,
"tube_analysis",
)
return {
"workflow": "analysis",
"mode": "tube",
"model": model_summary,
"compute": compute,
"options": options,
"strands": [
{
"name": item["name"],
"sequence": item["sequence"],
"concentration": item["concentration"],
"unit": item["unit"],
"concentration_M": unit_to_molar(item["concentration"], item["unit"]),
}
for item in strands
],
"tube": {
"name": tube.name,
"max_size": max_size,
"include_complexes": [stringify_complex(item) for item in included_complexes],
"exclude_complexes": [stringify_complex(item) for item in excluded_complexes],
"fraction_bases_unpaired": (
float(tube_result.fraction_bases_unpaired)
if tube_result.fraction_bases_unpaired is not None else None
),
"ensemble_pair_fractions": serialize_pairs(
tube_result.ensemble_pair_fractions,
preview_limit=options["pairs_preview_size"],
),
"complex_concentrations": displayed_concentration_rows,
"total_complex_concentrations": total_complex_count,
},
"complexes": complexes,
"total_complex_count": total_complex_count,
"displayed_complex_count": len(complexes),
"performance": performance,
}
if mode == "complex":
complexes = parse_complex_lines(payload.get("complexes_text", ""), strand_map)
setup_seconds = seconds_since(job_started)
nupack_started = time.perf_counter()
if progress_callback:
progress_callback("computing", "Running NUPACK complex analysis", {
"complex_count": len(complexes),
"compute": compute,
})
result = complex_analysis(complexes, model=model, compute=compute, options=nupack_options)
nupack_seconds = seconds_since(nupack_started)
serialization_started = time.perf_counter()
if progress_callback:
progress_callback("serializing", "Serializing complex analysis result", {})
rows = [
serialize_complex_result(
complex_obj,
data,
pairs_preview_size=options["pairs_preview_size"],
alphabet=model.alphabet,
)
for complex_obj, data in result.complexes.items()
]
rows.sort(key=lambda row: row["display"])
total_complex_count = len(rows)
serialization_seconds = seconds_since(serialization_started)
notes = []
if "subopt" in compute:
notes.append("subopt result count can grow rapidly as energy_gap increases.")
if "sample" in compute:
notes.append(f"sample requested {options['num_sample']} Boltzmann structures per complex.")
performance = build_performance_summary(
{
"input_setup": setup_seconds,
"nupack_compute": nupack_seconds,
"serialization_and_plots": serialization_seconds,
},
{
"strand_count": len(strands),
"input_nucleotides": sum(item["length"] for item in strands),
"complex_count": total_complex_count,
"largest_complex_nucleotides": max((item.nt(model.alphabet) for item in complexes), default=0),
},
notes,
"complex_analysis",
)
rows = rows[: options["result_limit"]]
return {
"workflow": "analysis",
"mode": "complex",
"model": model_summary,
"compute": compute,
"options": options,
"strands": [
{
"name": item["name"],
"sequence": item["sequence"],
}
for item in strands
],
"complexes": rows,
"total_complex_count": total_complex_count,
"performance": performance,
"displayed_complex_count": len(rows),
}
raise ValueError(f"Unsupported analysis mode: {mode}")
def prune_jobs(now=None):
now = now or time.time()
expired_ids = []
for job_id, job in JOB_STORE.items():
updated_at = job.get("updated_at", job.get("created_at", now))
if now - updated_at > JOB_TTL_SECONDS:
expired_ids.append(job_id)
for job_id in expired_ids:
expired = JOB_STORE.pop(job_id, None)
fingerprint = (expired or {}).get("dedup_fingerprint")
if fingerprint and JOB_DEDUP_RESERVATIONS.get(fingerprint) == job_id:
JOB_DEDUP_RESERVATIONS.pop(fingerprint, None)
if len(JOB_STORE) > JOB_MAX_COUNT:
keep_ids = sorted(
JOB_STORE,
key=lambda job_id: JOB_STORE[job_id].get("created_at", 0),
reverse=True,
)[:JOB_MAX_COUNT]
keep_set = set(keep_ids)
for job_id in list(JOB_STORE):
if job_id not in keep_set:
removed = JOB_STORE.pop(job_id, None)
fingerprint = (removed or {}).get("dedup_fingerprint")
if fingerprint and JOB_DEDUP_RESERVATIONS.get(fingerprint) == job_id:
JOB_DEDUP_RESERVATIONS.pop(fingerprint, None)
def set_job_data(job):
if redis_enabled():
client = redis_client()
key = job_key(job["job_id"])
encoded = json.dumps(job, ensure_ascii=False)
if job.get("status") in TERMINAL_JOB_STATUSES:
client.setex(key, JOB_TTL_SECONDS, encoded)
if job.get("dedup_fingerprint"):
delete_redis_job_claim(job["job_id"], job["dedup_fingerprint"])
else:
# Active jobs may legitimately outlive the result-retention TTL.
client.set(key, encoded)
if job.get("dedup_fingerprint"):
refresh_redis_job_claim(job["job_id"], job["dedup_fingerprint"])
return
with JOB_LOCK:
prune_jobs(job.get("updated_at"))
JOB_STORE[job["job_id"]] = dict(job)
def get_job_data(job_id, include_payload=False):
if redis_enabled():
raw = redis_client().get(job_key(job_id))
if raw is None:
return None
job = json.loads(raw)
job.pop("dedup_fingerprint", None)
if not include_payload:
job.pop("payload", None)
return job
with JOB_LOCK:
prune_jobs()
job = JOB_STORE.get(job_id)
if job is None:
return None
output = dict(job)
output.pop("dedup_fingerprint", None)
if not include_payload:
output.pop("payload", None)
return output
def should_keep_cancel_state(current, updates):
current_status = current.get("status") if current else None
next_status = updates.get("status")
if current_status in {CANCEL_REQUESTED_STATUS, CANCELED_STATUS}:
return next_status not in {CANCEL_REQUESTED_STATUS, CANCELED_STATUS}
return False
def update_job_data(job_id, **updates):
if redis_enabled():
client = redis_client()
key = job_key(job_id)
while True:
pipe = client.pipeline()
try:
pipe.watch(key)
raw = pipe.get(key)
current = json.loads(raw) if raw is not None else {"job_id": job_id, "created_at": time.time()}
if should_keep_cancel_state(current, updates):
pipe.unwatch()
return current
current.update(updates)
current["updated_at"] = time.time()
pipe.multi()
encoded = json.dumps(current, ensure_ascii=False)
if current.get("status") in TERMINAL_JOB_STATUSES:
pipe.setex(key, JOB_TTL_SECONDS, encoded)
else:
pipe.set(key, encoded)
pipe.execute()
if current.get("dedup_fingerprint"):
if current.get("status") in TERMINAL_JOB_STATUSES:
delete_redis_job_claim(job_id, current["dedup_fingerprint"])
else:
refresh_redis_job_claim(job_id, current["dedup_fingerprint"])
return current
except redis.WatchError:
continue
finally:
pipe.reset()
with JOB_LOCK:
current = JOB_STORE.get(job_id)
if current is None:
current = {"job_id": job_id, "created_at": time.time()}
if should_keep_cancel_state(current, updates):
return dict(current)
current.update(updates)
current["updated_at"] = time.time()
prune_jobs(current["updated_at"])
JOB_STORE[job_id] = current
if current.get("status") in TERMINAL_JOB_STATUSES and current.get("dedup_fingerprint"):
if JOB_DEDUP_RESERVATIONS.get(current["dedup_fingerprint"]) == job_id:
JOB_DEDUP_RESERVATIONS.pop(current["dedup_fingerprint"], None)
return dict(current)
def create_job(payload, owner, force_duplicate=False):
requested_job_id = uuid4().hex
job_id, dedup_fingerprint, reused = claim_active_job(payload, owner, requested_job_id)
if reused:
log_event(f"deduplicated job_id={job_id} user_id={owner['user_id']}")
if not force_duplicate:
return job_id, True
job_id = requested_job_id
replace_job_claim(job_id, dedup_fingerprint)
now = time.time()
job = {
"job_id": job_id,
"user_id": owner["user_id"],
"dedup_fingerprint": dedup_fingerprint,
"status": "queued",
"error": None,
"result": None,
"created_at": now,
"updated_at": now,
"payload": payload,
"progress": {
"stage": "queued",
"message": "Waiting for a worker",
"details": {},
"updated_at": now,
},
}
try:
ACCOUNT_STORE.create_job(job_id, owner, payload, status="queued", created_at=now)
set_job_data(job)
except Exception:
release_job_claim(job_id, dedup_fingerprint)
raise
log_event(f"accepted job_id={job_id} user_id={owner['user_id']} mode={payload.get('mode', 'tube')}")
if redis_enabled():
redis_client().lpush(JOB_QUEUE_KEY, job_id)
else:
thread = threading.Thread(target=_run_job, args=(job_id, payload), daemon=True)
thread.start()
return job_id, False
def _job_process_entry(payload, result_queue):
apply_thread_limits()
def report_progress(stage, message, details=None):
result_queue.put({
"kind": "progress",
"progress": {
"stage": stage,
"message": message,
"details": details or {},
"updated_at": time.time(),
},
})
try:
result_queue.put({
"kind": "terminal",
"status": "success",
"result": run_job_payload(payload, progress_callback=report_progress),
})
except BaseException as exc:
result_queue.put(
{
"kind": "terminal",
"status": "error",
"error": {
"message": str(exc),
"traceback": traceback.format_exc(),
},
}
)
def terminate_process(process):
if not process.is_alive():
return
if hasattr(process, "kill"):
process.kill()
else:
process.terminate()
process.join(timeout=2)
if process.is_alive():
process.terminate()
process.join(timeout=2)
def _run_job(job_id, payload):
started_at = time.time()
last_heartbeat_at = started_at
current = get_job_data(job_id)
if current and current.get("status") in {CANCEL_REQUESTED_STATUS, CANCELED_STATUS}:
elapsed = round(time.time() - started_at, 3)
update_job_data(job_id, status=CANCELED_STATUS, payload=None, elapsed_seconds=elapsed)
ACCOUNT_STORE.update_job(job_id, CANCELED_STATUS, error={"message": "Job canceled by user."}, elapsed_seconds=elapsed)
return
log_event(f"running job_id={job_id}")
update_job_data(job_id, status="running")
ACCOUNT_STORE.update_job(job_id, "running")
if redis_enabled():
redis_client().sadd(JOB_RUNNING_KEY, job_id)
ctx = multiprocessing.get_context("spawn")
result_queue = ctx.Queue(maxsize=32)
process = ctx.Process(target=_job_process_entry, args=(payload, result_queue), daemon=True)
message = None
try:
process.start()
while process.is_alive():
current = get_job_data(job_id)
if current and current.get("status") in {CANCEL_REQUESTED_STATUS, CANCELED_STATUS}:
terminate_process(process)
elapsed = round(time.time() - started_at, 3)
update_job_data(
job_id,
status=CANCELED_STATUS,
error={"message": "Job canceled by user."},
result=None,
payload=None,
elapsed_seconds=elapsed,
)
ACCOUNT_STORE.update_job(
job_id, CANCELED_STATUS, error={"message": "Job canceled by user."}, elapsed_seconds=elapsed
)
log_event(f"canceled job_id={job_id} elapsed={elapsed}s")
return
now = time.time()
if now - last_heartbeat_at >= JOB_HEARTBEAT_SECONDS:
update_job_data(job_id, heartbeat_at=now)
last_heartbeat_at = now
try:
incoming = result_queue.get_nowait()
if incoming.get("kind") == "progress":
update_job_data(
job_id,
progress=incoming["progress"],
elapsed_seconds=round(time.time() - started_at, 3),
)
continue
message = incoming
break
except queue.Empty:
time.sleep(0.25)
process.join(timeout=2)
if message is None:
while True:
try:
incoming = result_queue.get_nowait()
if incoming.get("kind") == "progress":
update_job_data(
job_id,
progress=incoming["progress"],
elapsed_seconds=round(time.time() - started_at, 3),
)
else:
message = incoming
except queue.Empty:
break
current = get_job_data(job_id)
if current and current.get("status") in {CANCEL_REQUESTED_STATUS, CANCELED_STATUS}:
elapsed = round(time.time() - started_at, 3)
update_job_data(
job_id,
status=CANCELED_STATUS,
error={"message": "Job canceled by user."},
result=None,
payload=None,
elapsed_seconds=elapsed,
)
ACCOUNT_STORE.update_job(
job_id, CANCELED_STATUS, error={"message": "Job canceled by user."}, elapsed_seconds=elapsed
)
log_event(f"canceled job_id={job_id} elapsed={elapsed}s")
return
elapsed = round(time.time() - started_at, 3)
if message and message.get("status") == "success":
update_job_data(
job_id,
status="success",
result=message["result"],
payload=None,
elapsed_seconds=elapsed,
progress={
"stage": "complete",
"message": "Calculation complete",
"details": {},
"updated_at": time.time(),
},
)
ACCOUNT_STORE.update_job(job_id, "success", result=message["result"], elapsed_seconds=elapsed)
log_event(f"success job_id={job_id} elapsed={elapsed}s")
elif message and message.get("status") == "error":
error = message.get("error") or {"message": "Job failed."}
update_job_data(
job_id,
status="error",
error=error,
payload=None,
elapsed_seconds=elapsed,
progress={
"stage": "error",
"message": error.get("message", "Job failed."),
"details": {},
"updated_at": time.time(),
},
)
ACCOUNT_STORE.update_job(job_id, "error", error=error, elapsed_seconds=elapsed)
log_event(f"error job_id={job_id} elapsed={elapsed}s message={message.get('error', {}).get('message')}")
else:
error = {"message": f"Job process exited with code {process.exitcode}."}
update_job_data(
job_id,
status="error",
error=error,
payload=None,
elapsed_seconds=elapsed,
)
ACCOUNT_STORE.update_job(job_id, "error", error=error, elapsed_seconds=elapsed)
log_event(f"error job_id={job_id} elapsed={elapsed}s exitcode={process.exitcode}")
finally:
terminate_process(process)
result_queue.close()
result_queue.join_thread()
if redis_enabled():
redis_client().srem(JOB_RUNNING_KEY, job_id)
def cancel_job(job_id):
current = get_job_data(job_id, include_payload=True)
if current is None:
return None
status = current.get("status")
if status in TERMINAL_JOB_STATUSES:
output = dict(current)
output.pop("payload", None)
return output
if redis_enabled() and status == "queued":
redis_client().lrem(JOB_QUEUE_KEY, 0, job_id)
if status == "queued":
updated = update_job_data(
job_id,
status=CANCELED_STATUS,
error={"message": "Job canceled by user."},
result=None,
payload=None,
)
ACCOUNT_STORE.update_job(job_id, CANCELED_STATUS, error={"message": "Job canceled by user."})
else:
updated = update_job_data(
job_id,
status=CANCEL_REQUESTED_STATUS,
error={"message": "Cancellation requested."},
)
ACCOUNT_STORE.update_job(job_id, CANCEL_REQUESTED_STATUS, error={"message": "Cancellation requested."})
updated.pop("payload", None)
return updated
def get_job(job_id):
job = get_job_data(job_id, include_payload=False)
if job and job.get("status") in {"queued", "running", CANCEL_REQUESTED_STATUS}:
job["elapsed_seconds"] = round(time.time() - job.get("created_at", time.time()), 3)
return job
def prune_shares(now=None):
if SHARE_MAX_COUNT < 1:
return
if redis_enabled():
client = redis_client()
extra = client.zcard(SHARE_INDEX_KEY) - SHARE_MAX_COUNT
if extra <= 0:
return
stale_ids = client.zrange(SHARE_INDEX_KEY, 0, extra - 1)
if stale_ids:
client.delete(*(share_key(share_id) for share_id in stale_ids))
client.zrem(SHARE_INDEX_KEY, *stale_ids)
return
keep_ids = sorted(
SHARE_STORE,
key=lambda share_id: SHARE_STORE[share_id].get("created_at", 0),
reverse=True,
)[:SHARE_MAX_COUNT]
keep_set = set(keep_ids)
for share_id in list(SHARE_STORE):
if share_id not in keep_set:
SHARE_STORE.pop(share_id, None)
def create_share(record):
payload = record.get("payload")
result = record.get("result")
if not isinstance(payload, dict):
raise ValueError("Share payload must contain an input payload object.")
if not isinstance(result, dict):
raise ValueError("Share payload must contain a result object.")
now = time.time()
share_id = uuid4().hex[:16]
item = {
"id": share_id,
"created_at": now,
"created_at_iso": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now)),
"payload": payload,
"result": result,
"result_summary": record.get("result_summary") or {},
}
if redis_enabled():
client = redis_client()
client.set(share_key(share_id), json.dumps(item, ensure_ascii=False))
client.zadd(SHARE_INDEX_KEY, {share_id: now})
prune_shares(now)
return item
with SHARE_LOCK:
SHARE_STORE[share_id] = item
prune_shares(now)
return dict(item)
def get_share(share_id):
share_id = str(share_id or "").strip()
if not re.fullmatch(r"[0-9a-fA-F]{8,64}", share_id):
return None
if redis_enabled():
raw = redis_client().get(share_key(share_id))
return json.loads(raw) if raw else None
with SHARE_LOCK:
item = SHARE_STORE.get(share_id)
return dict(item) if item else None
def public_share_payload(share_id, *, record_access=True):
metadata = ACCOUNT_STORE.share_metadata(share_id)
if metadata is None:
return get_share(share_id)
live = get_job_data(metadata["job_id"], include_payload=True)
if live is not None:
if record_access:
ACCOUNT_STORE.record_share_access(share_id)
payload = live.get("payload")
if payload is None:
account_job = ACCOUNT_STORE.get_job(metadata["user_id"], metadata["job_id"], include_content=True)
payload = account_job.get("payload") if account_job else None
result = live.get("result")
error = live.get("error")
return {
"id": share_id,
"share_id": share_id,
"job_id": metadata["job_id"],
"created_at": metadata["created_at"],
"status": live.get("status"),
"updated_at": live.get("updated_at"),
"elapsed_seconds": live.get("elapsed_seconds"),
"payload": payload,
"result": result,
"error": error,
"result_summary": build_history_summary(payload, live),
}
return ACCOUNT_STORE.resolve_share(share_id, record_access=record_access)
def build_history_summary(payload, fallback=None):
payload = payload or {}
fallback = fallback or {}
model = payload.get("model") or {}
workflow = payload.get("workflow") or fallback.get("workflow") or "analysis"
mode = payload.get("mode") or fallback.get("mode") or "tube"
strands = payload.get("strands") or []
if workflow == "design":
complexes = payload.get("design_complexes") or payload.get("design_targets") or []
tube_sizes = [int(row.get("max_size", 0) or 0) for row in (payload.get("design_tubes") or [])]
max_size = max(tube_sizes, default=int((payload.get("design") or {}).get("off_target_max_size", 0) or 0))
else:
complexes = [line for line in str(payload.get("complexes_text") or "").splitlines() if line.strip()]
max_size = int((payload.get("tube") or {}).get("max_size", 0) or 0)
return {
"workflow": workflow,
"mode": mode,
"material": model.get("material", fallback.get("material", "rna")),
"celsius": float(model.get("celsius", fallback.get("celsius", 37)) or 37),
"sodium": float(model.get("sodium", fallback.get("sodium", 0)) or 0),
"magnesium": float(model.get("magnesium", fallback.get("magnesium", 0)) or 0),
"max_size": max_size,
"strand_count": len(strands),
"complex_count": len(complexes),
"compute": list(
([((payload.get("utility") or {}).get("operation") or "pfunc")] if workflow == "utilities" else payload.get("compute"))
or fallback.get("compute")
or ([] if workflow != "design" else ["design"])
),
"trials": int((payload.get("design") or {}).get("trials", fallback.get("trials", 0)) or 0),
"stop_condition": float((payload.get("design") or {}).get("stop_condition", fallback.get("stop_condition", 0)) or 0),
"max_time_seconds": int((payload.get("design") or {}).get("max_time_seconds", fallback.get("max_time_seconds", 0)) or 0),
}
def recover_interrupted_jobs():
client = redis_client()
recovered = 0
canceled = 0
for job_id in client.smembers(JOB_RUNNING_KEY):
raw = client.get(job_key(job_id))
if raw is None:
client.srem(JOB_RUNNING_KEY, job_id)
continue
job = json.loads(raw)
status = job.get("status")
if status == "running":
client.lrem(JOB_QUEUE_KEY, 0, job_id)
update_job_data(
job_id,
status="queued",
recovered_at=time.time(),
recovery_count=int(job.get("recovery_count", 0)) + 1,
)
client.lpush(JOB_QUEUE_KEY, job_id)
ACCOUNT_STORE.update_job(job_id, "queued")
recovered += 1
elif status == CANCEL_REQUESTED_STATUS:
update_job_data(
job_id,
status=CANCELED_STATUS,
error={"message": "Job canceled while the worker was restarting."},
result=None,
payload=None,
)
ACCOUNT_STORE.update_job(
job_id,
CANCELED_STATUS,
error={"message": "Job canceled while the worker was restarting."},
)
canceled += 1
client.srem(JOB_RUNNING_KEY, job_id)
if recovered or canceled:
log_event(f"recovered jobs queued={recovered} canceled={canceled}")
def run_worker_loop():
if not redis_enabled():
raise RuntimeError("Worker mode requires NP_REDIS_URL and the redis package.")
client = redis_client()
recover_interrupted_jobs()
resource_plan = worker_resource_plan()
resource_plan["published_at"] = time.time()
worker_count = resource_plan["concurrency"]
client.setex(WORKER_RESOURCE_KEY, 90, json.dumps(resource_plan))
log_event(
f"Starting worker loop on Redis queue {JOB_QUEUE_KEY} "
f"(concurrency={worker_count}, source={resource_plan['source']}, "
f"per_job_threads={PER_JOB_THREAD_LIMIT}, memory_gb={resource_plan['memory_gb']}, "
f"estimated_job_memory_gb={ESTIMATED_JOB_MEMORY_GB})"
)
with ThreadPoolExecutor(max_workers=worker_count, thread_name_prefix="np-job") as executor:
inflight = set()
resource_published_at = time.time()
while True:
if time.time() - resource_published_at >= 30:
resource_plan["published_at"] = time.time()
client.setex(WORKER_RESOURCE_KEY, 90, json.dumps(resource_plan))
resource_published_at = time.time()
finished = {future for future in inflight if future.done()}
if finished:
inflight.difference_update(finished)
for future in finished:
future.result()
if len(inflight) >= worker_count:
done, _ = wait(inflight, return_when=FIRST_COMPLETED, timeout=1)
inflight.difference_update(done)
for future in done:
future.result()
continue
item = client.brpop(JOB_QUEUE_KEY, timeout=2)
if not item:
continue
_, job_id = item
raw = client.get(job_key(job_id))
if raw is None:
continue
job = json.loads(raw)
if job.get("status") in {CANCEL_REQUESTED_STATUS, CANCELED_STATUS}:
continue
payload = job.get("payload")
if payload is None:
continue
inflight.add(executor.submit(_run_job, job_id, payload))
EXAMPLE_PAYLOAD = {
"workflow": "analysis",
"mode": "tube",
"model": {
"material": "rna",
"ensemble": "stacking",
"celsius": 37,
"sodium": 1.0,
"magnesium": 0.0,
},
"compute": ["pfunc", "mfe", "pairs"],
"options": {
"num_sample": 20,
"energy_gap": 1.0,
"sparsity_fraction": 1.0,
"sparsity_threshold": 0.0,
"single_mfe": False,
"result_limit": 25,
"pairs_preview_size": 24,
},
"strands": [
{"name": "A", "sequence": "AGUCUAGGAU", "concentration": 1.0, "unit": "uM"},
{"name": "B", "sequence": "UUAACCCACG", "concentration": 2.0, "unit": "uM"},
],
"tube": {"name": "tube1", "max_size": 2, "include_complexes": "", "exclude_complexes": ""},
"complexes_text": "A\nB\nA+B",
"design": {
"trials": 1,
"result_limit": 25,
"off_target_max_size": 2,
"stop_condition": 0.02,
"seed": 0,
"wobble_mutations": False,
"max_time_seconds": 0,
"fixed_target_policy": "include",
},
"design_domains": [
{"name": "a", "sequence": "N10"},
{"name": "b", "sequence": "N10"},
],
"design_complexes": [
{
"name": "AB_target",
"strands": "A+B",
"structure": "(10+)10",
}
],
"design_tubes": [
{
"name": "Tube 1",
"max_size": 2,
"on_targets": [
{"complex": "AB_target", "concentration": 1.0, "unit": "uM"},
],
}
],
"hard_constraints": [],
"soft_constraints": [],
"defect_weights": [],
}
# Use A=a and B=~a so the full-duplex target "(10+)10" is base-pair consistent.
DESIGN_TUBE_EXAMPLE_PAYLOAD = {
"workflow": "design",
"mode": "tube",
"model": {
"material": "rna",
"ensemble": "stacking",
"celsius": 37,
"sodium": 1.0,
"magnesium": 0.0,
},
"compute": ["pfunc", "mfe", "pairs"],
"options": {
"num_sample": 20,
"energy_gap": 1.0,
"sparsity_fraction": 1.0,
"sparsity_threshold": 0.0,
"single_mfe": False,
"result_limit": 25,
"pairs_preview_size": 24,
},
"strands": [
{"name": "A", "sequence": "a", "concentration": 1.0, "unit": "uM"},
{"name": "B", "sequence": "~a", "concentration": 1.0, "unit": "uM"},
],
"tube": {"name": "Design Tube", "max_size": 2},
"complexes_text": "A+B",
"design": {
"trials": 1,
"result_limit": 25,
"off_target_max_size": 2,
"stop_condition": 0.05,
"seed": 1,
"wobble_mutations": False,
"max_time_seconds": 0,
"fixed_target_policy": "include",
},
"design_domains": [
{"name": "a", "sequence": "N10"},
],
"design_complexes": [
{
"name": "AB_target",
"strands": "A+B",
"structure": "(10+)10",
}
],
"design_tubes": [
{
"name": "Design Tube",
"max_size": 2,
"on_targets": [
{"complex": "AB_target", "concentration": 1.0, "unit": "uM"},
],
}
],
"hard_constraints": [],
"soft_constraints": [],
"defect_weights": [],
}
DESIGN_COMPLEX_EXAMPLE_PAYLOAD = {
**DESIGN_TUBE_EXAMPLE_PAYLOAD,
"mode": "complex",
"tube": {"name": "Design Tube", "max_size": 1},
"design": {
**DESIGN_TUBE_EXAMPLE_PAYLOAD["design"],
"off_target_max_size": 1,
},
"design_tubes": [],
}
UTILITIES_EXAMPLE_PAYLOAD = {
**EXAMPLE_PAYLOAD,
"workflow": "utilities",
"mode": "utility",
"strands": [
{"name": "A", "sequence": "GCGCUUCGCG", "concentration": 0, "unit": "uM"},
],
"utility": {
"operation": "mfe",
"structure": "..........",
"input_a": "",
"input_b": "",
"distinguishable": False,
},
}
def get_example_payload(query):
params = parse_qs(query)
workflow = (params.get("workflow") or ["analysis"])[0]
mode = (params.get("mode") or ["tube"])[0]
if workflow == "utilities":
return UTILITIES_EXAMPLE_PAYLOAD
if workflow == "design":
if mode == "complex":
return DESIGN_COMPLEX_EXAMPLE_PAYLOAD
return DESIGN_TUBE_EXAMPLE_PAYLOAD
return EXAMPLE_PAYLOAD
class AppHandler(BaseHTTPRequestHandler):
server_version = "NPReplica/0.1"
def _session(self):
if not hasattr(self, "_cached_session"):
self._cached_session = OIDC_AUTH.current_session(self.headers)
if self._cached_session:
ACCOUNT_STORE.upsert_user(self._cached_session["user"])
return self._cached_session
def _require_user(self):
session = self._session()
if session is None:
self._respond(*json_bytes({"error": "Authentication required", "login_url": "/auth/login"}, status=HTTPStatus.UNAUTHORIZED))
return None
return session["user"]
def _read_json(self, max_bytes=16 * 1024 * 1024):
length = int(self.headers.get("Content-Length", "0"))
if length < 0 or length > max_bytes:
raise ValueError(f"Request payload is too large. Limit is {max_bytes} bytes.")
return json.loads(self.rfile.read(length).decode("utf-8"))
def _redirect(self, location, *, cookie=None):
headers = {"Location": location}
if cookie:
headers["Set-Cookie"] = cookie
self._respond(HTTPStatus.FOUND, "text/plain; charset=utf-8", b"Redirecting", extra_headers=headers)
def _require_page_user(self, next_path):
if self._session() is not None:
return True
self._redirect(f"/auth/login?{urlencode({'next': next_path})}")
return False
def _admin_session_token(self):
cookie = SimpleCookie()
try:
cookie.load(self.headers.get("Cookie", ""))
except Exception:
return None
morsel = cookie.get(ADMIN_COOKIE_NAME)
return morsel.value if morsel else None
def _require_admin(self):
token = self._admin_session_token()
if valid_admin_session(token):
return token
self._respond(
*json_bytes({"error": "Admin authentication required"}, status=HTTPStatus.UNAUTHORIZED)
)
return None
def _owned_job(self, user, job_id, include_content=True):
account_metadata = ACCOUNT_STORE.get_job(
user["user_id"],
job_id,
include_content=False,
)
if account_metadata is None:
return None
live = get_job(job_id)
if live is not None:
live.pop("user_id", None)
return live
return ACCOUNT_STORE.get_job(user["user_id"], job_id, include_content=include_content)
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path == "/admin":
if valid_admin_session(self._admin_session_token()):
self._redirect("/admin/panel")
else:
self._respond_file(ADMIN_LOGIN_PATH, cache_control="private, no-store")
return
if parsed.path == "/admin/panel":
if not valid_admin_session(self._admin_session_token()):
self._redirect("/admin")
return
self._respond_file(ADMIN_PATH, cache_control="private, no-store")
return
if parsed.path == "/api/admin/jobs":
if self._require_admin() is None:
return
params = {key: values[0] for key, values in parse_qs(parsed.query).items() if values}
self._respond(*json_bytes({
"status": "success",
**ACCOUNT_STORE.list_all_jobs(params),
"live": job_stats(),
"worker": published_worker_resource_plan(),
}))
return
if parsed.path == "/api/admin/settings":
if self._require_admin() is None:
return
retention_seconds = ACCOUNT_STORE.trash_retention_seconds()
self._respond(*json_bytes({
"status": "success",
"trash_retention_seconds": retention_seconds,
"trash_retention_days": retention_seconds / 86400,
}))
return
if parsed.path == "/auth/login":
next_path = (parse_qs(parsed.query).get("next") or ["/"])[0]
try:
self._redirect(OIDC_AUTH.begin_login(next_path))
except Exception as exc:
self._respond(*json_bytes({"error": f"Unable to start login: {exc}"}, status=HTTPStatus.BAD_GATEWAY))
return
if parsed.path == "/auth/callback":
try:
session_id, user, next_path = OIDC_AUTH.complete_login(parse_qs(parsed.query))
ACCOUNT_STORE.upsert_user(user)
self._redirect(next_path, cookie=OIDC_AUTH.cookie_header(session_id))
except Exception as exc:
log_event(f"OIDC callback failed: {exc}")
self._respond(*json_bytes({"error": f"Login failed: {exc}"}, status=HTTPStatus.BAD_REQUEST))
return
if parsed.path == "/auth/logout":
session = self._session()
try:
location = OIDC_AUTH.logout_url(session)
except Exception:
location = "/"
OIDC_AUTH.delete_session(session)
self._redirect(location, cookie=OIDC_AUTH.clear_cookie_header())
return
share_page = re.fullmatch(r"/share/([A-Za-z0-9_-]{8,128})", parsed.path)
if share_page:
self._respond_file(INDEX_PATH, cache_control="public, max-age=60, stale-while-revalidate=86400")
return
legacy_share = (parse_qs(parsed.query).get("share") or [""])[0]
if parsed.path == "/" and re.fullmatch(r"[A-Za-z0-9_-]{8,128}", legacy_share):
self._respond_file(INDEX_PATH, cache_control="public, max-age=60, stale-while-revalidate=86400")
return
if parsed.path == "/":
if not self._require_page_user(self.path):
return
self._respond_file(HOME_PATH, cache_control="private, no-cache")
return
page_paths = {
"/workspace": INDEX_PATH,
"/cloud": CLOUD_PATH,
"/account": ACCOUNT_PATH,
"/settings": SETTINGS_PATH,
}
if parsed.path in page_paths:
if not self._require_page_user(self.path):
return
self._respond_file(page_paths[parsed.path], cache_control="private, no-cache")
return
if parsed.path in STATIC_PATHS:
self._respond_file(STATIC_PATHS[parsed.path], cache_control="public, max-age=3600")
return
if parsed.path in {"/favicon.svg", "/favicon.ico"}:
self._respond_file(FAVICON_PATH, cache_control="public, max-age=86400")
return
if parsed.path == "/design-guide.html":
if not self._require_page_user(self.path):
return
self._respond_file(GUIDE_PATH, cache_control="public, max-age=3600")
return
if parsed.path == "/health":
stats = job_stats()
resource_plan = published_worker_resource_plan()
self._respond(
*json_bytes(
{
"status": "ok",
"job_backend": "redis" if redis_enabled() else "memory",
"run_mode": RUN_MODE,
"queue_depth": queue_size(),
"jobs_running": stats["running"],
"jobs_queued": stats["queued"],
"job_ttl_seconds": JOB_TTL_SECONDS,
"worker_concurrency": resource_plan["concurrency"],
"worker_concurrency_source": resource_plan["source"],
"worker_resource_plan": resource_plan,
"per_job_thread_limit": PER_JOB_THREAD_LIMIT,
"nupack_threads": int(getattr(nupack_config, "threads", PER_JOB_THREAD_LIMIT)),
"nupack_cache_gb": float(getattr(nupack_config, "cache", NUPACK_CACHE_GB)),
}
)
)
return
if parsed.path == "/api/me":
user = self._require_user()
if user is None:
return
self._respond(*json_bytes({"status": "success", "user": user, "usage": ACCOUNT_STORE.usage(user["user_id"])}))
return
if parsed.path == "/api/history":
user = self._require_user()
if user is None:
return
params = {key: values[0] for key, values in parse_qs(parsed.query).items() if values}
self._respond(*json_bytes({"status": "success", **ACCOUNT_STORE.list_jobs(user["user_id"], params)}))
return
if parsed.path.startswith("/api/history/"):
user = self._require_user()
if user is None:
return
job_id = parsed.path.rsplit("/", 1)[-1]
job = ACCOUNT_STORE.get_job(user["user_id"], job_id, include_content=True)
if job is None:
self._respond(*json_bytes({"error": "History record not found"}, status=HTTPStatus.NOT_FOUND))
return
self._respond(*json_bytes({"status": "success", "item": job}))
return
if parsed.path == "/api/account/shares":
user = self._require_user()
if user is None:
return
self._respond(*json_bytes({"status": "success", "items": ACCOUNT_STORE.list_shares(user["user_id"])}))
return
if parsed.path == "/api/example":
user = self._require_user()
if user is None:
return
self._respond(*json_bytes(get_example_payload(parsed.query)))
return
if parsed.path.startswith("/api/jobs/"):
user = self._require_user()
if user is None:
return
job_id = parsed.path.rsplit("/", 1)[-1]
job = self._owned_job(user, job_id)
if job is None:
self._respond(*json_bytes({"error": "Job not found"}, status=HTTPStatus.NOT_FOUND))
return
self._respond(*json_bytes(job))
return
if parsed.path.startswith("/api/shares/"):
share_id = parsed.path.rsplit("/", 1)[-1]
share = public_share_payload(share_id, record_access=(parse_qs(parsed.query).get("poll") or ["0"])[0] != "1")
if share is None:
self._respond(*json_bytes({"error": "Share not found"}, status=HTTPStatus.NOT_FOUND))
return
self._respond(*json_bytes({"status": "success", "share": share}))
return
self._respond(*json_bytes({"error": "Not found"}, status=HTTPStatus.NOT_FOUND))
def do_POST(self):
parsed = urlparse(self.path)
try:
if parsed.path == "/api/admin/login":
body = self._read_json(max_bytes=4096)
password = str(body.get("password") or "")
if not ADMIN_TOKEN:
self._respond(*json_bytes(
{"error": "Admin password is not configured"},
status=HTTPStatus.SERVICE_UNAVAILABLE,
))
return
if not secrets.compare_digest(password, ADMIN_TOKEN):
self._respond(*json_bytes(
{"error": "Admin password is incorrect"},
status=HTTPStatus.UNAUTHORIZED,
))
return
token = create_admin_session()
self._respond(
*json_bytes({"status": "success", "url": "/admin/panel"}),
extra_headers={"Set-Cookie": admin_cookie_header(token)},
)
return
if parsed.path == "/api/admin/logout":
delete_admin_session(self._admin_session_token())
self._respond(
*json_bytes({"status": "success"}),
extra_headers={"Set-Cookie": admin_cookie_header("", clear=True)},
)
return
if parsed.path == "/api/admin/settings":
if self._require_admin() is None:
return
body = self._read_json(max_bytes=4096)
seconds = ACCOUNT_STORE.set_trash_retention_days(body.get("trash_retention_days"))
self._respond(*json_bytes({
"status": "success",
"trash_retention_seconds": seconds,
"trash_retention_days": seconds / 86400,
}))
return
admin_action = re.fullmatch(
r"/api/admin/jobs/([A-Za-z0-9_-]{8,128})/(cancel|trash|restore|delete)",
parsed.path,
)
if admin_action:
if self._require_admin() is None:
return
job_id, action = admin_action.groups()
if action == "cancel":
result = cancel_job(job_id)
found = result is not None
elif action == "trash":
found = ACCOUNT_STORE.admin_trash_job(job_id)
result = {"job_id": job_id, "deleted": found}
elif action == "restore":
found = ACCOUNT_STORE.restore_job(job_id)
result = {"job_id": job_id, "restored": found}
else:
found = ACCOUNT_STORE.permanently_delete_job(job_id)
result = {"job_id": job_id, "permanently_deleted": found}
if not found:
self._respond(*json_bytes({"error": "Job not found"}, status=HTTPStatus.NOT_FOUND))
return
self._respond(*json_bytes({"status": "success", "job": result}))
return
except Exception as exc:
self._respond(*json_bytes({"status": "error", "error": str(exc)}, status=HTTPStatus.BAD_REQUEST))
return
user = self._require_user()
if user is None:
return
try:
if parsed.path.startswith("/api/jobs/") and parsed.path.endswith("/cancel"):
job_id = parsed.path.split("/")[-2]
if ACCOUNT_STORE.owner_id(job_id) != user["user_id"]:
self._respond(*json_bytes({"error": "Job not found"}, status=HTTPStatus.NOT_FOUND))
return
job = cancel_job(job_id)
if job is None:
self._respond(*json_bytes({"error": "Job not found"}, status=HTTPStatus.NOT_FOUND))
return
job.pop("user_id", None)
self._respond(*json_bytes({"status": "success", "job": job}))
return
if parsed.path.startswith("/api/jobs/") and parsed.path.endswith("/shares"):
job_id = parsed.path.split("/")[-2]
body = self._read_json()
expires_in = body.get("expires_in")
if expires_in not in {None, "", 0}:
expires_in = int(expires_in)
if expires_in < 60:
raise ValueError("Share duration must be at least 60 seconds.")
else:
expires_in = None
share = ACCOUNT_STORE.create_share(user["user_id"], job_id, expires_in)
self._respond(*json_bytes({"status": "success", "share": share, "url": f"/share/{share['share_id']}"}, status=HTTPStatus.CREATED))
return
if parsed.path == "/api/jobs":
payload = self._read_json()
force_duplicate = (parse_qs(parsed.query).get("force") or ["0"])[0] == "1"
job_id, deduplicated = create_job(payload, user, force_duplicate=force_duplicate)
if deduplicated:
duplicate = self._owned_job(user, job_id, include_content=False) or {"job_id": job_id}
self._respond(*json_bytes(
{
"status": "duplicate",
"message": "An identical job is already active for this user.",
"duplicate": {
key: duplicate.get(key)
for key in ("job_id", "status", "created_at", "updated_at", "progress")
},
},
status=HTTPStatus.CONFLICT,
))
return
self._respond(*json_bytes(
{"status": "accepted", "job_id": job_id, "forced_duplicate": force_duplicate},
status=HTTPStatus.ACCEPTED,
))
return
if parsed.path == "/api/history/import":
body = self._read_json(max_bytes=64 * 1024 * 1024)
entries = body.get("history") if isinstance(body, dict) else None
if not isinstance(entries, list):
raise ValueError("History import requires a history array.")
imported = ACCOUNT_STORE.import_history(user, entries)
self._respond(*json_bytes({"status": "success", "imported": imported}))
return
if parsed.path == "/api/shares":
body = self._read_json()
job_id = str(body.get("job_id") or "")
if not job_id:
raise ValueError("Cloud shares require a job_id.")
share = ACCOUNT_STORE.create_share(user["user_id"], job_id, body.get("expires_in"))
self._respond(*json_bytes({"status": "success", "share_id": share["share_id"], "url": f"/share/{share['share_id']}"}, status=HTTPStatus.CREATED))
return
if parsed.path == "/api/analyze":
payload = self._read_json()
job_id = uuid4().hex
started = time.time()
ACCOUNT_STORE.create_job(job_id, user, payload, status="running", created_at=started)
try:
result = run_job_payload(payload)
except Exception as exc:
elapsed = round(time.time() - started, 3)
ACCOUNT_STORE.update_job(job_id, "error", error={"message": str(exc), "traceback": traceback.format_exc()}, elapsed_seconds=elapsed)
raise
elapsed = round(time.time() - started, 3)
ACCOUNT_STORE.update_job(job_id, "success", result=result, elapsed_seconds=elapsed)
self._respond(*json_bytes({"status": "success", "job_id": job_id, "result": result}))
return
self._respond(*json_bytes({"error": "Not found"}, status=HTTPStatus.NOT_FOUND))
except Exception as exc:
self._respond(
*json_bytes(
{
"status": "error",
"error": str(exc),
"traceback": traceback.format_exc(),
},
status=HTTPStatus.BAD_REQUEST,
)
)
def do_PATCH(self):
parsed = urlparse(self.path)
user = self._require_user()
if user is None:
return
try:
if parsed.path.startswith("/api/account/shares/"):
share_id = parsed.path.rsplit("/", 1)[-1]
body = self._read_json()
expires_in = body["expires_in"] if "expires_in" in body else "unchanged"
share = ACCOUNT_STORE.update_share(
user["user_id"], share_id, active=body.get("active"), expires_in=expires_in
)
if share is None:
self._respond(*json_bytes({"error": "Share not found"}, status=HTTPStatus.NOT_FOUND))
return
self._respond(*json_bytes({"status": "success", "share": share}))
return
self._respond(*json_bytes({"error": "Not found"}, status=HTTPStatus.NOT_FOUND))
except Exception as exc:
self._respond(*json_bytes({"status": "error", "error": str(exc)}, status=HTTPStatus.BAD_REQUEST))
def do_DELETE(self):
parsed = urlparse(self.path)
user = self._require_user()
if user is None:
return
try:
if parsed.path.startswith("/api/history/"):
job_id = parsed.path.rsplit("/", 1)[-1]
if not ACCOUNT_STORE.delete_job(user["user_id"], job_id):
self._respond(*json_bytes({"error": "History record not found"}, status=HTTPStatus.NOT_FOUND))
return
self._respond(*json_bytes({"status": "success"}))
return
if parsed.path.startswith("/api/account/shares/"):
share_id = parsed.path.rsplit("/", 1)[-1]
share = ACCOUNT_STORE.update_share(user["user_id"], share_id, active=False)
if share is None:
self._respond(*json_bytes({"error": "Share not found"}, status=HTTPStatus.NOT_FOUND))
return
self._respond(*json_bytes({"status": "success", "share": share}))
return
self._respond(*json_bytes({"error": "Not found"}, status=HTTPStatus.NOT_FOUND))
except Exception as exc:
self._respond(*json_bytes({"status": "error", "error": str(exc)}, status=HTTPStatus.BAD_REQUEST))
def log_message(self, format_, *args):
print(f"{self.address_string()} - {format_ % args}")
def _respond_file(self, path, cache_control):
etag = file_etag(path)
if self.headers.get("If-None-Match") == etag:
self.send_response(HTTPStatus.NOT_MODIFIED)
self.send_header("ETag", etag)
self.send_header("Cache-Control", cache_control)
self.end_headers()
return
status, content_type, body = html_bytes(path)
self._respond(status, content_type, body, cache_control=cache_control, extra_headers={"ETag": etag})
def _respond(self, status, content_type, body, cache_control=None, extra_headers=None):
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", cache_control or "no-store, max-age=0")
if cache_control is None:
self.send_header("Pragma", "no-cache")
self.send_header("Expires", "0")
for key, value in (extra_headers or {}).items():
self.send_header(key, value)
self.end_headers()
self.wfile.write(body)
def main():
apply_thread_limits()
ACCOUNT_STORE.initialize()
if RUN_MODE == "worker":
run_worker_loop()
return
server = ThreadingHTTPServer((HOST, PORT), AppHandler)
log_event(
f"Serving NP replica on http://{HOST}:{PORT} "
f"(jobs via {'redis' if redis_enabled() else 'memory'}, "
f"per_job_threads={max(1, PER_JOB_THREAD_LIMIT)})"
)
server.serve_forever()
if __name__ == "__main__":
main()