diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4629c2..6b42630 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,8 +39,8 @@ jobs: - uses: actions/setup-python@v6 with: python-version: "3.12" - # The default splice backend prefers MaxEntScan when it is installed, so - # without this job that path is never exercised in CI. + # Explicit/automatic human splice screening uses MaxEntScan when it is + # installed; exercise that optional backend as well as dependency-free runs. - run: pip install -e ".[dev]" "git+https://github.com/kepbod/maxentpy.git" - name: Confirm MaxEntScan really is in use run: python -c "from codonopt.splice import maxent_available; assert maxent_available()" diff --git a/README.md b/README.md index c79142d..6e27b86 100644 --- a/README.md +++ b/README.md @@ -23,15 +23,17 @@

Give it a protein, or an existing coding sequence, and it returns a synonymous -sequence that balances GC content, repeats, hairpins, homopolymers, forbidden -motifs, cryptic splice sites and host codon usage in a single score. It is a +sequence that resolves mandatory requirements before balancing GC content, +repeats, hairpins, homopolymers, candidate splice signals and host codon usage. +Avoided motifs are mandatory by default; other criteria are soft preferences +unless explicitly required. It is a pure-Python package with no runtime dependencies, a command line, a Python API and a local web interface. ## Highlights -- **One objective, not four passes.** Every concern is a weighted penalty in one score, so fixing one problem cannot silently undo another. -- **Fast.** Scoring is incremental: a codon swap re-scores only what sits near it. An 800-residue protein takes 19 s, against 234 s for the scripts this replaces. +- **Requirements before preferences.** Mandatory violations take precedence over the weighted score. Every result is independently checked and marked valid or unresolved; an unresolved result is not proof that no solution exists. +- **Incremental scoring.** A codon swap re-scores nearby terms and updates repeat/hairpin indexes. Each edit still copies the DNA string; search cost is not strictly independent of gene length. - **The protein cannot change.** The only move is swapping a codon for a synonymous one, so correctness holds by construction, not by checking afterwards. - **Three ways in.** `codonopt` on the command line, a small Python API, and a browser or desktop interface. - **Checked against brute force.** The tests compare the incremental score with a from-scratch recomputation and with simple reference implementations. @@ -54,24 +56,26 @@ codonopt optimize --add-stop --avoid golden_gate,EcoRI --seed 5 --splice-backend The sequence goes to stdout as FASTA (or `--out FILE`), the summary to stderr: ``` ->protein codonopt table=human score=1.17 +>protein codonopt table=human score=1.08 valid=true ATGAAATGGGTGACATTCATCAGCTTACTGCTGCTGTTCTCTTCCGCTTACTCCCGGGGC -GTGTTTAGACGGGACACTCATAAATCTGAGATCGCTCACAGGTTTAAGGACCTGGGCGAG -GAGAACTTCAAGGCCTTGGTTTTAATCGCCTTCGCACAGTATCTGCAGCAGTGCCCTTTC +GTGTTTAGACGGGACACACACAAATCTGAGATCGCTCACAGGTTTAAGGACCTGGGCGAG +GAGAACTTCAAGGCCTTGGTTTTAATCGCCTTTGCACAGTATCTGCAGCAGTGCCCTTTC TGA -Objective 54.44 -> 1.17 (improved by 53.28) +Requirements valid +Objective 53.57 -> 1.08 (improved by 52.49) Property before after ------------------------------------------------ GC fraction 0.481 0.503 -Codon adaptation index 0.774 0.837 +Codon adaptation index 0.774 0.842 Forbidden motif hits 1 0 -Splice donor sites 2 0 +Splice donor sites 1 0 Splice acceptor sites 1 0 ``` `--json FILE` writes the same numbers, the per-term score and the sequence. +Reports also include validity and the effective settings. ## Web interface @@ -84,6 +88,10 @@ table and what to keep out, and press **Optimize**. You get: - a before/after table, a GC plot along the sequence and the score breakdown; - copy and download for the DNA, FASTA and a report; runs can be cancelled and keep the best result so far. +If mandatory requirements remain, the result is labelled **Unresolved candidate**. +Downloading it does not certify it as a valid design. Splice screening is off +by default; select it explicitly when relevant to the expression host. + It is local only: the server binds to loopback, checks Host and Origin, requires a per-run token, sends a strict content security policy and never touches your files. Add `--no-window --no-browser` to just serve it. @@ -92,13 +100,13 @@ files. Add `--no-window --no-browser` to just serve it. | Criterion | Why | Default | | --- | --- | --- | -| Overall GC | Extreme GC complicates synthesis and PCR | target 0.50 | +| Overall GC | Extreme GC complicates synthesis and PCR | target 0.50; length-scaled soft cost | | Local GC | Stretches matter, not just the average | 30 bp windows within 0.30–0.70 | | Direct repeats | Mis-assembly in synthesis, recombination in vivo | no repeated 12-mers | | Hairpins | Stall synthesis and reverse transcription | no 10 bp inverted repeat, 3–60 nt loop | | Homopolymers | Slippage in synthesis and sequencing | at most 6 identical bases | -| Forbidden motifs | Cloning sites, poly(A) signals, anything you name | none | -| Cryptic splice sites | Unwanted splicing of a transgene | MaxEntScan or a consensus screen | +| Forbidden motifs | Cloning sites, poly(A) signals, anything you name | none supplied; mandatory when supplied | +| Candidate splice signals | Screen for potential unwanted splicing | off; opt into MaxEntScan or a consensus screen | | Codon usage | Rare codons slow translation | host codon adaptation index | ## Command line @@ -116,10 +124,23 @@ codonopt motifs # known motifs and g | `--avoid SPEC` | Exclude motifs: a group (`golden_gate`, `common_cloning`, `regulatory`), an enzyme (`EcoRI`), an IUPAC pattern (`GGWCC`) or `NAME=PATTERN`. Both strands are checked. | | `--table NAME` / `--table-file F` | Host codon usage: `human`, `ecoli`, `uniform`, or your own JSON/CSV. | | `--weight NAME=VALUE` | Re-balance a term: `gc_global=200` holds the GC target harder, `usage=0` ignores codon preference. | -| `--splice-backend` | `auto` (MaxEntScan if installed, else a consensus screen), `maxent`, `motif`, `none`. | +| `--splice-backend` | Default `none`. `auto` screens only the bundled human host, using MaxEntScan if installed, otherwise a consensus screen. `maxent` and `motif` are explicit overrides for other hosts; neither is automatically validated for them. | +| `--splice-strand` | Default `forward`, the expressed transcript. Select `reverse` or `both` only when appropriate. Restriction motifs still use both DNA strands. | +| `--require NAME` | Make `gc_window`, `repeat`, `hairpin`, `homopolymer` or `splice` mandatory. Repeat the option for several criteria. Required splice screening needs an explicit backend. | +| `--soft-motifs` | Make motif avoidance a preference instead of a requirement. | +| `--left-flank SEQ` / `--right-flank SEQ` | Score immutable construct context, including features across CDS junctions. Output FASTA remains the editable CDS. | +| `--protect-codon INDEX` | Preserve a zero-based codon of supplied starting DNA, including across restarts. Repeat for several positions. | +| `--exclude-codon CODON` | Forbid a codon explicitly; a zero frequency in a usage table is not an exclusion. | | `--seed N` | Reproducible runs. | | `--restarts N`, `--sweeps N`, `--anneal N` | More search effort. | | `--seconds N` | Wall-clock cap (default 120). | +| `--repair-candidates N` / `--repair-max-codons N` | Bound coordinated repair per restart (defaults 2000 complete candidates and up to 3 codons; maximum 4). | + +The CLI returns **0** when configured requirements are met, **2** when they +remain unresolved, and **1** for invalid input. It still emits the best +candidate and report on exit 2, including in quiet mode. A valid result can +have a higher soft score than its input because validity takes precedence. +Cancellation and time limits are recorded separately from validity. `codonopt optimize --help` lists the rest. MaxEntScan is optional and not on PyPI: `pip install "git+https://github.com/kepbod/maxentpy.git"`. @@ -127,16 +148,43 @@ PyPI: `pip install "git+https://github.com/kepbod/maxentpy.git"`. ## Python API ```python -from codonopt import DesignConfig, OptimizerSettings, get_table, optimize, resolve_motifs +from codonopt import DesignConfig, OptimizerSettings, get_table, optimize, optimize_dna, resolve_motifs config = DesignConfig(table=get_table("human"), motifs=tuple(resolve_motifs(["golden_gate"]))) result = optimize("MKWVTFISLLLLFSSAYS", config, OptimizerSettings(seed=1)) result.sequence, result.score, result.breakdown.terms +result.valid, result.unresolved_constraints, result.stop_reason ``` `measure(sequence, config)` returns what `report` prints; `optimize_dna()` improves an existing sequence. +For an existing construct with fixed context and mandatory local GC: + +```python +config = DesignConfig( + motifs=tuple(resolve_motifs(["golden_gate"])), + required=("motif", "gc_window"), + gc_step=1, # every base-offset window, when the synthesis policy needs it + left_flank="ACGT", right_flank="TGCA", + protected_codons=(0,), +) +result = optimize_dna("ATGGGTCTCAAA", config, OptimizerSettings(seed=5)) +``` + +All context-dependent criteria scan the **complete supplied construct**, +including fixed-only regions. A violation confined to a fixed flank remains +unresolved. Codon usage and overall GC target apply to the CDS. Diagnostic +positions and GC profiles use construct coordinates; `cds_start` records the +offset. Flanks need not have lengths divisible by three. + +JSON reports preserve original input/first seed, winning restart seed, full +effective configuration, actual codon-table data and hash, generated or supplied +seed, search settings, software/backend versions, validity and stop reason. +Custom scorer implementations and uninstalled backend sources additionally +need their source revision recorded externally. A seed and deterministic +proposal budgets replay a run; wall-clock truncation can differ by machine. + ## How it works Each penalty term is tied to a position (a GC window, a homopolymer, a motif @@ -145,12 +193,21 @@ it. Repeats and hairpins are not local, so an incrementally updated k-mer index carries them. A candidate swap costs time proportional to the window sizes, not the sequence length. -The search sweeps every codon greedily until nothing improves, optionally -followed by simulated annealing and several restarts. +The search compares candidates by `(mandatory violation count, soft score)`. +It sweeps codons greedily, uses bounded coordinated repair around unresolved +regions, and optionally follows with simulated annealing and several restarts. +Annealing cannot worsen mandatory violation count. Intermediate edits in a +multi-codon repair are temporary; the complete candidate decides acceptance. `SequenceState.audit()` recomputes a score from scratch; the tests check the incremental path against it and against brute-force implementations in `codonopt.metrics`. +Overall GC cost is `weight * abs(GC bases - target * CDS bases) / 300`. +This retains the old scale at 100 codons while making each GC-base change +carry the same tradeoff at different gene lengths. Local GC, repeats and the +other occurrence-based costs remain sums. The default target is a design +preference, not a universal expression optimum. + ## Development The Python package needs nothing but Python. The interface is written in @@ -191,6 +248,16 @@ benchmark are in [`legacy/README.md`](legacy/README.md). for production work. The consensus splice screen is a filter, not a model. - The search is local. A given `--seed` is reproducible, but another seed may do slightly better; use `--restarts` or `--anneal` when it counts. +- Bounded coordinated repair can still leave feasible requirements unresolved. + The tool does not claim mathematical infeasibility from a search failure. +- A valid result meets the **configured checks**, not an unprovided synthesis + vendor policy or a guarantee of improved expression. CAI and exact inverted + repeats are proxies, not validated predictions of expression or RNA folding. +- The time limit covers search checks. Initial scoring and final independent + validation/reporting must still complete and can add time beyond that budget. +- Version 0.3 changes motif validity, splice defaults/strand policy and GC cost + scaling. Previous seeds can produce different designs. Before/after DNA + reports now always use the original DNA, even when a later restart wins. ## License diff --git a/benchmarks/compare_legacy.py b/benchmarks/compare_legacy.py index 59c30e7..1a73ee9 100644 --- a/benchmarks/compare_legacy.py +++ b/benchmarks/compare_legacy.py @@ -66,6 +66,8 @@ def main() -> int: help="skip the end-to-end comparison, which is the slow part", ) args = parser.parse_args() + # The legacy functions use the module-level generator, not the local RNG. + random.seed(args.seed) splice_legacy = load_legacy(LEGACY_SPLICE) hairpin_legacy = load_legacy(LEGACY_HAIRPIN) diff --git a/pyproject.toml b/pyproject.toml index d4d409a..63a2421 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codonopt" -version = "0.2.0" +version = "0.3.0" description = "Codon-optimize an open reading frame for synthesis success and expression" readme = "README.md" requires-python = ">=3.10" diff --git a/src/codonopt/__init__.py b/src/codonopt/__init__.py index 801a6a4..41e582e 100644 --- a/src/codonopt/__init__.py +++ b/src/codonopt/__init__.py @@ -2,9 +2,8 @@ A coding sequence can be written in many ways without changing the protein it encodes, and some of those ways are much easier to synthesise and express than -others. This package searches that space under one weighted objective covering -GC content, direct repeats, hairpins, homopolymer runs, forbidden motifs, -cryptic splice sites and host codon preference. +others. This package resolves configured mandatory requirements before +improving soft sequence-quality and host-codon-preference objectives. Typical use:: @@ -23,9 +22,10 @@ from __future__ import annotations -__version__ = "0.2.0" +__version__ = "0.3.0" from .config import DesignConfig, OptimizerSettings, Weights +from .constraints import constraint_violations from .index import KmerIndex from .metrics import ( count_hairpin_pairs, @@ -65,6 +65,7 @@ "SequenceState", "Weights", "available_tables", + "constraint_violations", "count_hairpin_pairs", "find_hairpins", "find_splice_sites", diff --git a/src/codonopt/cli.py b/src/codonopt/cli.py index 5519d0b..9152721 100644 --- a/src/codonopt/cli.py +++ b/src/codonopt/cli.py @@ -195,9 +195,9 @@ def _add_constraint_arguments( ) group.add_argument( "--splice-backend", - default="auto", + default="none", choices=("auto", "maxent", "motif", "none"), - help="cryptic splice site scoring (default: auto)", + help="candidate splice signal screening (default: none; auto is human-only)", ) group.add_argument( "--donor-cutoff", @@ -211,6 +211,17 @@ def _add_constraint_arguments( metavar="F", help="highest acceptable acceptor score (default: the backend's)", ) + group.add_argument("--splice-strand", choices=("forward", "reverse", "both"), default="forward") + group.add_argument("--require", action="append", default=[], + choices=("motif", "gc_window", "repeat", "hairpin", "homopolymer", "splice"), + help="make a criterion mandatory; repeat to require several") + group.add_argument("--soft-motifs", action="store_true", help="treat avoided motifs as preferences instead of requirements") + group.add_argument("--left-flank", default="", help="immutable upstream DNA used when scoring the construct") + group.add_argument("--right-flank", default="", help="immutable downstream DNA used when scoring the construct") + group.add_argument("--protect-codon", type=int, action="append", default=[], metavar="INDEX", + help="protect a zero-based codon index in starting DNA; repeat as needed") + group.add_argument("--exclude-codon", action="append", default=[], metavar="CODON", + help="forbid a codon independently of its frequency") if weights: group.add_argument( "--weight", @@ -250,6 +261,8 @@ def _add_search_arguments(parser: argparse.ArgumentParser) -> None: group.add_argument( "--seed-mode", default="weighted", choices=("weighted", "best", "random") ) + group.add_argument("--repair-candidates", type=int, default=2000) + group.add_argument("--repair-max-codons", type=int, default=3, choices=(2, 3, 4)) class _UsageError(Exception): @@ -261,7 +274,7 @@ def _build_config(args: argparse.Namespace) -> DesignConfig: load_table(args.table_file) if args.table_file else get_table(args.table) ) try: - scorer = get_scorer(args.splice_backend) + scorer = get_scorer(args.splice_backend, host=table.name) except RuntimeError as exc: raise _UsageError(str(exc)) from None @@ -286,6 +299,12 @@ def _build_config(args: argparse.Namespace) -> DesignConfig: donor_cutoff=args.donor_cutoff, acceptor_cutoff=args.acceptor_cutoff, weights=_build_weights(getattr(args, "weight", [])), + required=tuple(args.require) + (() if args.soft_motifs else ("motif",)), + splice_strand=args.splice_strand, + left_flank=args.left_flank, + right_flank=args.right_flank, + protected_codons=tuple(args.protect_codon), + excluded_codons=tuple(args.exclude_codon), ) return config @@ -352,7 +371,11 @@ def _run_optimize(args: argparse.Namespace) -> int: protein = validate_protein(raw) if args.add_stop and not protein.endswith("*"): protein += "*" - start = None # the starting sequence no longer matches the protein + if start is not None: + options = [c for c in config.table.codons("*") if c not in config.excluded_codons] + if not options: + raise _UsageError("all stop codons are excluded") + start += max(options, key=config.table.frequency) log = (lambda message: None) if args.quiet else _stderr settings = OptimizerSettings( @@ -361,10 +384,12 @@ def _run_optimize(args: argparse.Namespace) -> int: anneal_steps=args.anneal, start_temperature=args.temp_start, end_temperature=args.temp_end, - max_seconds=None if args.seconds <= 0 else args.seconds, + max_seconds=None if args.seconds == 0 else args.seconds, seed=args.seed, seed_mode=args.seed_mode, progress=log, + repair_candidates=args.repair_candidates, + repair_max_codons=args.repair_max_codons, ) if config.splice_scorer is not None and not args.quiet: log(f"splice backend: {config.splice_scorer.name}") @@ -374,7 +399,7 @@ def _run_optimize(args: argparse.Namespace) -> int: record = Record( name=args.name or name, description=(args.name or name) - + f" codonopt table={config.table.name} score={result.score:.2f}", + + f" codonopt table={config.table.name} score={result.score:.2f} valid={str(result.valid).lower()}", sequence=result.sequence, ) text = format_fasta(record) @@ -385,12 +410,15 @@ def _run_optimize(args: argparse.Namespace) -> int: sys.stdout.write(text) if args.json: Path(args.json).write_text( - json.dumps(report_dict(result, config), indent=2) + "\n" + json.dumps(report_dict(result, config), indent=2, allow_nan=False) + "\n" ) log(f"wrote {args.json}") if not args.quiet: log("") log(format_report(result, config)) + if not result.valid: + _stderr("codonopt: unresolved mandatory constraints; output is a candidate, not a valid design") + return 2 return 0 diff --git a/src/codonopt/config.py b/src/codonopt/config.py index 4bca2e8..eed0fdf 100644 --- a/src/codonopt/config.py +++ b/src/codonopt/config.py @@ -3,8 +3,10 @@ from __future__ import annotations from dataclasses import dataclass, field, replace +import math -from .motifs import Motif +from .motifs import Motif, canonicalize_motifs +from .sequence import CODON_TO_AA, validate_dna from .splice import SpliceScorer from .tables import CodonUsage, HUMAN @@ -16,12 +18,12 @@ class Weights: """Relative importance of each penalty term. All terms are penalties: lower is better, and zero switches a term off. - The defaults were chosen so that a single forbidden motif outweighs any - amount of ordinary GC or codon-usage drift, while repeats and hairpins - trade off against each other roughly one for one. + Mandatory constraints are configured separately and take precedence over + this score. Finite weights alone cannot guarantee a requirement. Attributes: - gc_global: Multiplies ``|GC fraction - target|``. + gc_global: Multiplies absolute CDS GC base-count deviation divided by + 300 (the previous fraction-based scale for a 100-codon CDS). gc_window: Multiplies the summed shortfall/excess of each scored window relative to the acceptable GC range, as a fraction. repeat: Multiplies the number of redundant k-mer occurrences. @@ -47,8 +49,8 @@ class Weights: def __post_init__(self) -> None: for name, value in vars(self).items(): - if value < 0: - raise ValueError(f"weight {name} must not be negative: {value}") + if not math.isfinite(value) or value < 0: + raise ValueError(f"weight {name} must be finite and non-negative: {value}") @dataclass(frozen=True) @@ -90,6 +92,13 @@ class DesignConfig: donor_cutoff: float | None = None acceptor_cutoff: float | None = None weights: Weights = field(default_factory=Weights) + # Names of mandatory criteria. Other criteria remain soft preferences. + required: tuple[str, ...] = ("motif",) + splice_strand: str = "forward" + left_flank: str = "" + right_flank: str = "" + protected_codons: tuple[int, ...] = () + excluded_codons: tuple[str, ...] = () def __post_init__(self) -> None: if not 0.0 <= self.gc_target <= 1.0: @@ -103,10 +112,43 @@ def __post_init__(self) -> None: "with low <= high (use 0.3 rather than 30)" ) for name in ("gc_window", "gc_step", "repeat_k", "hairpin_stem", "max_homopolymer"): - if getattr(self, name) < 1: - raise ValueError(f"{name} must be at least 1") + value = getattr(self, name) + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + raise ValueError(f"{name} must be a positive integer") + for name in ("hairpin_min_loop", "hairpin_max_loop"): + if isinstance(getattr(self, name), bool) or not isinstance(getattr(self, name), int): + raise ValueError(f"{name} must be an integer") if self.hairpin_min_loop < 0 or self.hairpin_max_loop < self.hairpin_min_loop: raise ValueError("require 0 <= hairpin_min_loop <= hairpin_max_loop") + for name in ("donor_cutoff", "acceptor_cutoff"): + value = getattr(self, name) + if value is not None and not math.isfinite(value): + raise ValueError(f"{name} must be finite") + known = {"motif", "gc_window", "repeat", "hairpin", "homopolymer", "splice"} + if set(self.required) - known: + raise ValueError(f"unknown required criteria: {sorted(set(self.required) - known)}") + object.__setattr__(self, "required", tuple(sorted(set(self.required)))) + if "splice" in self.required and self.splice_scorer is None: + raise ValueError("required splice screening needs an explicit splice scorer") + if not isinstance(self.splice_strand, str) or self.splice_strand not in {"forward", "reverse", "both"}: + raise ValueError("splice_strand must be forward, reverse or both") + for name in ("left_flank", "right_flank"): + value = getattr(self, name) + if not isinstance(value, str): + raise ValueError(f"{name} must be a DNA string") + object.__setattr__(self, name, validate_dna(value) if value else "") + if any(isinstance(i, bool) or not isinstance(i, int) or i < 0 for i in self.protected_codons): + raise ValueError("protected_codons must contain non-negative integer indices") + object.__setattr__(self, "protected_codons", tuple(sorted(set(self.protected_codons)))) + excluded = tuple(sorted(set(c.strip().upper().replace("U", "T") for c in self.excluded_codons))) + if any(c not in CODON_TO_AA for c in excluded): + raise ValueError("excluded_codons must contain standard genetic-code codons") + object.__setattr__(self, "excluded_codons", excluded) + object.__setattr__(self, "motifs", canonicalize_motifs(self.motifs)) + + @property + def splice_strands(self) -> tuple[str, ...]: + return ("forward", "reverse") if self.splice_strand == "both" else (self.splice_strand,) @property def effective_donor_cutoff(self) -> float: @@ -152,6 +194,9 @@ class OptimizerSettings: should_stop: Called regularly during the search; once it returns True the search stops cleanly and returns the best sequence found so far, which is how a caller cancels a long run. + repair_candidates: Maximum complete coordinated candidates per restart. + Zero disables repair; unresolved requirements are still reported. + repair_max_codons: Maximum codons per coordinated repair, 2 through 4. """ restarts: int = 1 @@ -164,8 +209,14 @@ class OptimizerSettings: seed_mode: str = "weighted" progress: object | None = None should_stop: object | None = None + repair_candidates: int = 2000 + repair_max_codons: int = 3 def __post_init__(self) -> None: + for name in ("restarts", "max_sweeps", "anneal_steps", "repair_candidates", "repair_max_codons"): + value = getattr(self, name) + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"{name} must be an integer") if self.restarts < 1: raise ValueError("restarts must be at least 1") if self.max_sweeps < 0 or self.anneal_steps < 0: @@ -175,7 +226,11 @@ def __post_init__(self) -> None: f"unknown seed_mode {self.seed_mode!r}; " "expected weighted, best or random" ) - if self.start_temperature <= 0 or self.end_temperature <= 0: - raise ValueError("temperatures must be positive") - if self.max_seconds is not None and self.max_seconds <= 0: - raise ValueError("max_seconds must be positive when set") + if self.repair_candidates < 0 or not 2 <= self.repair_max_codons <= 4: + raise ValueError("repair_candidates must be non-negative and repair_max_codons between 2 and 4") + if any(not math.isfinite(t) or t <= 0 for t in (self.start_temperature, self.end_temperature)): + raise ValueError("temperatures must be finite and positive") + if self.max_seconds is not None and (not math.isfinite(self.max_seconds) or self.max_seconds <= 0): + raise ValueError("max_seconds must be finite and positive when set") + if self.seed is not None and (isinstance(self.seed, bool) or not isinstance(self.seed, int)): + raise ValueError("seed must be an integer") diff --git a/src/codonopt/constraints.py b/src/codonopt/constraints.py new file mode 100644 index 0000000..d14429e --- /dev/null +++ b/src/codonopt/constraints.py @@ -0,0 +1,87 @@ +"""Independent validation of required criteria, in construct coordinates.""" + +from __future__ import annotations + +from itertools import islice + +from .config import DesignConfig +from .metrics import gc_window_report, homopolymer_runs, find_hairpins +from .sequence import reverse_complement, split_codons, validate_dna +from .splice import find_splice_sites + + +def constraint_violations(sequence: str, config: DesignConfig) -> list[dict]: + """Validate a CDS and return unresolved criteria with bounded region examples. + + Counts are complete. Region examples are capped at 64 per criterion; absence + of a violation here means the configured requirement was checked in full. + Fixed flanks are included, so a failure confined to them remains unresolved. + """ + sequence = validate_dna(sequence, in_frame=True) + context = config.left_flank + sequence + config.right_flank + required = set(config.required) + result: list[dict] = [] + + def add(name: str, count: int, regions) -> None: + if count: + examples = list(islice(regions, 64)) + result.append({"criterion": name, "count": count, "regions": examples}) + + if "motif" in required: + regions = [ + {"start": p, "end": p + m.length, "name": m.name} + for m in config.motifs for p in m.find_all(context) + ] + add("motif", len(regions), regions) + if "gc_window" in required: + gc = gc_window_report(context, window=config.gc_window, step=config.gc_step, + target_range=config.gc_range) + add("gc_window", gc.violations, + ({"start": p, "end": min(p + config.gc_window, len(context))} + for p in gc.worst_positions)) + if "homopolymer" in required: + runs = homopolymer_runs(context, config.max_homopolymer + 1) + add("homopolymer", sum(r.length - config.max_homopolymer for r in runs), + ({"start": r.start, "end": r.start + r.length} for r in runs)) + if "repeat" in required: + seen: dict[str, int] = {} + count = 0 + regions = [] + for p in range(len(context) - config.repeat_k + 1): + kmer = context[p:p + config.repeat_k] + if kmer in seen: + count += 1 + if len(regions) < 64: + regions.extend(({"start": seen[kmer], "end": seen[kmer] + config.repeat_k}, + {"start": p, "end": p + config.repeat_k})) + else: + seen[kmer] = p + add("repeat", count, regions) + if "hairpin" in required: + # Count the exact k-mer pairs used by the objective, independently. + k = config.hairpin_stem + count = 0 + for p in range(len(context) - k + 1): + partner = reverse_complement(context[p:p + k]) + for q in range(p + k + config.hairpin_min_loop, + min(p + k + config.hairpin_max_loop + 1, len(context) - k + 1)): + count += context[q:q + k] == partner + examples = find_hairpins(context, stem=k, min_loop=config.hairpin_min_loop, + max_loop=config.hairpin_max_loop, limit=32) + add("hairpin", count, + ({"start": h.stem_start, "end": h.partner_start + h.stem_length} + for h in examples)) + if "splice" in required: + sites = find_splice_sites(context, config.splice_scorer, + donor_cutoff=config.effective_donor_cutoff, + acceptor_cutoff=config.effective_acceptor_cutoff, + strand=config.splice_strand) + add("splice", len(sites), + ({"start": s.position, "end": s.position + (9 if s.site_type == "donor" else 23), + "type": s.site_type, "strand": s.strand} for s in sites)) + if config.excluded_codons: + start = len(config.left_flank) + regions = [{"start": start + 3*i, "end": start + 3*i + 3, "codon": c} + for i, c in enumerate(split_codons(sequence)) if c in config.excluded_codons] + add("excluded_codons", len(regions), regions) + return result diff --git a/src/codonopt/metrics.py b/src/codonopt/metrics.py index e9682cb..f788003 100644 --- a/src/codonopt/metrics.py +++ b/src/codonopt/metrics.py @@ -10,6 +10,7 @@ from __future__ import annotations from dataclasses import dataclass, field +from bisect import bisect_left, bisect_right from .motifs import Motif from .sequence import gc_count, gc_prefix, reverse_complement @@ -322,7 +323,7 @@ def count_hairpin_pairs( continue low = i + stem + min_loop high = i + stem + max_loop - pairs += sum(1 for j in candidates if low <= j <= high) + pairs += bisect_right(candidates, high) - bisect_left(candidates, low) return pairs @@ -343,6 +344,8 @@ def find_hairpins( length for a fixed loop range. """ _check_hairpin_params(stem, min_loop, max_loop) + if limit < 1: + raise ValueError("limit must be positive") index: dict[str, list[int]] = {} for i in range(len(sequence) - stem + 1): index.setdefault(sequence[i : i + stem], []).append(i) @@ -351,7 +354,10 @@ def find_hairpins( hairpins: list[Hairpin] = [] for i in range(len(sequence) - stem + 1): partner = reverse_complement(sequence[i : i + stem]) - for j in index.get(partner, ()): # j is the 3' arm start + candidates = index.get(partner, ()) + first = bisect_left(candidates, i + stem + min_loop) + last = bisect_right(candidates, i + stem + max_loop) + for j in candidates[first:last]: # j is the 3' arm start loop = j - (i + stem) if not (min_loop <= loop <= max_loop): continue @@ -384,9 +390,9 @@ def find_hairpins( stem=sequence[left : left + length], ) ) - if len(hairpins) >= limit: + if len(hairpins) > limit: hairpins.sort(key=lambda h: (-h.stem_length, h.stem_start)) - return hairpins + del hairpins[limit:] hairpins.sort(key=lambda h: (-h.stem_length, h.stem_start)) return hairpins diff --git a/src/codonopt/motifs.py b/src/codonopt/motifs.py index 4930672..024a4eb 100644 --- a/src/codonopt/motifs.py +++ b/src/codonopt/motifs.py @@ -134,6 +134,19 @@ def __repr__(self) -> str: # pragma: no cover - debugging aid return f"Motif({self.name!r}, {self.pattern!r})" +def canonicalize_motifs(motifs) -> tuple[Motif, ...]: + """Deduplicate both-strand patterns and reject ambiguous report names.""" + patterns: dict[str, Motif] = {} + names: dict[str, str] = {} + for motif in motifs: + key = min(motif.pattern, _revcomp_iupac(motif.pattern)) + if motif.name in names and names[motif.name] != key: + raise ValueError(f"motif name {motif.name!r} refers to different patterns") + names[motif.name] = key + patterns.setdefault(key, motif) + return tuple(patterns.values()) + + _IUPAC_COMPLEMENT = str.maketrans( "ACGTRYSWKMBDHVN", "TGCAYRSWMKVHDBN" ) @@ -177,6 +190,8 @@ def resolve_motifs(specs: list[str] | tuple[str, ...] | None) -> list[Motif]: def add(name: str, pattern: str) -> None: motif = Motif(name, pattern) key = min(motif.pattern, _revcomp_iupac(motif.pattern)) + if any(m.name == name and k != key for k, m in resolved.items()): + raise ValueError(f"motif name {name!r} refers to different patterns") resolved.setdefault(key, motif) for raw in specs or (): diff --git a/src/codonopt/optimize.py b/src/codonopt/optimize.py index c484cc7..1599fed 100644 --- a/src/codonopt/optimize.py +++ b/src/codonopt/optimize.py @@ -7,10 +7,11 @@ overall result. This module replaces the four passes with one weighted objective (:mod:`codonopt.state`) and a local search over synonymous codons. -Two phases run per restart: +Three phases run per restart: * greedy sweeps, which visit every codon in random order and keep the best synonymous replacement, repeating until a whole sweep changes nothing; +* bounded coordinated repair around unresolved mandatory requirements; * optional simulated annealing, which accepts occasional uphill moves so the search can leave a local minimum. @@ -23,10 +24,12 @@ import math import random import time -from dataclasses import dataclass, field +from dataclasses import dataclass, field, fields +from itertools import product from typing import Callable from .config import DesignConfig, OptimizerSettings +from .constraints import constraint_violations from .sequence import ( AA_TO_CODONS, translate, @@ -50,9 +53,8 @@ class OptimizationResult: protein: The protein it encodes, which equals the input protein. score: Final objective value. breakdown: Final per-term objective values. - initial_sequence: The starting sequence of the restart that produced - this result, before local search ran on it. With several restarts - this is the winning restart's seed, not the best seed. + initial_sequence: Original DNA input, or the first seed for a protein + design. This baseline is preserved even if a later restart wins. initial_score: Its objective value. initial_breakdown: Its per-term objective values. sweeps: Greedy sweeps run, summed over restarts. @@ -79,6 +81,13 @@ class OptimizationResult: hit_time_limit: bool = False cancelled: bool = False notes: list[str] = field(default_factory=list) + valid: bool = True + unresolved_constraints: list[dict] = field(default_factory=list) + stop_reason: str = "completed" + effective_seed: int | None = None + search_settings: dict = field(default_factory=dict) + winning_seed_sequence: str = "" + repair_evaluated: int = 0 @property def improvement(self) -> float: @@ -91,6 +100,8 @@ def seed_sequence( table: CodonUsage, rng: random.Random, mode: str = "weighted", + *, + excluded_codons: tuple[str, ...] = (), ) -> str: """Build a starting coding sequence for ``protein``. @@ -105,6 +116,23 @@ def seed_sequence( Raises: ValueError: On an unknown ``mode``. """ + if excluded_codons: + sequence = [] + for aa in protein: + options = [c for c in AA_TO_CODONS[aa] if c not in excluded_codons] + if not options: + raise ValueError(f"all codons for {aa} are excluded") + if mode == "weighted": + frequencies = [table.frequency(c) for c in options] + codon = rng.choices(options, weights=frequencies if sum(frequencies) else None, k=1)[0] + elif mode == "best": + codon = max(options, key=table.frequency) + elif mode == "random": + codon = rng.choice(options) + else: + raise ValueError(f"unknown seed mode {mode!r}") + sequence.append(codon) + return "".join(sequence) if mode == "weighted": return "".join(table.sample(aa, rng) for aa in protein) if mode == "best": @@ -145,34 +173,67 @@ def optimize( if translate(start) != protein: raise ValueError("start sequence does not encode the given protein") - rng = random.Random(settings.seed) + if config.protected_codons and start is None: + raise ValueError("protected_codons require a starting DNA sequence") + if any(i >= len(protein) for i in config.protected_codons): + raise ValueError("protected codon index is outside the coding sequence") + for i, aa in enumerate(protein): + if i not in config.protected_codons and not set(AA_TO_CODONS[aa]) - set(config.excluded_codons): + raise ValueError(f"all codons for {aa} at position {i} are excluded") + + effective_seed = settings.seed if settings.seed is not None else random.SystemRandom().getrandbits(31) + rng = random.Random(effective_seed) started = time.perf_counter() deadline = ( started + settings.max_seconds if settings.max_seconds is not None else None ) should_stop: Callable[[], bool] | None = settings.should_stop # type: ignore[assignment] + stop_reason = "completed" def expired() -> bool: """True once the time budget is spent or the caller asked to stop.""" + nonlocal stop_reason + if stop_reason != "completed": + return True if should_stop is not None and should_stop(): + stop_reason = "cancelled" return True - return deadline is not None and time.perf_counter() >= deadline + if deadline is not None and time.perf_counter() >= deadline: + stop_reason = "time_limit" + return True + return False best: SequenceState | None = None - best_initial: tuple[str, float] | None = None + winning_seed = "" + baseline: SequenceState | None = None sweeps = evaluated = accepted = restarts_run = 0 hit_limit = False + repair_evaluated = 0 for restart in range(settings.restarts): if best is not None and expired(): hit_limit = True break - seed = ( - start - if restart == 0 and start is not None - else seed_sequence(protein, config.table, rng, settings.seed_mode) - ) + if restart == 0 and start is not None: + seed = start + elif config.protected_codons: + seed = "".join( + start[3*i:3*i+3] if i in config.protected_codons else + seed_sequence(aa, config.table, rng, settings.seed_mode, + excluded_codons=config.excluded_codons) + for i, aa in enumerate(protein) + ) + else: + seed = seed_sequence(protein, config.table, rng, settings.seed_mode, + excluded_codons=config.excluded_codons) + if config.protected_codons: + codons = [seed[i:i+3] for i in range(0, len(seed), 3)] + for i in config.protected_codons: + codons[i] = start[3*i:3*i+3] + seed = "".join(codons) state = SequenceState(seed, config) + if baseline is None: + baseline = SequenceState(seed, config) initial = (state.sequence, state.score) restarts_run += 1 report( @@ -188,6 +249,20 @@ def expired() -> bool: accepted += local_accepted hit_limit = hit_limit or stopped + if state.violations and not stopped and settings.repair_candidates: + count, kept, stopped = _repair(state, settings, expired) + repair_evaluated += count + evaluated += count + accepted += kept + hit_limit = hit_limit or stopped + report(f"repair: {count} candidates, {state.violations} mandatory violations remain") + if kept and not stopped: + extra_sweeps, extra_evaluated, extra_accepted, stopped = _greedy(state, rng, settings, expired) + sweeps += extra_sweeps + evaluated += extra_evaluated + accepted += extra_accepted + hit_limit = hit_limit or stopped + if settings.anneal_steps and not stopped: annealed, anneal_evaluated, anneal_accepted, stopped = _anneal( state, rng, settings, expired @@ -202,24 +277,31 @@ def expired() -> bool: f"restart {restart + 1}/{settings.restarts}: " f"final score {state.score:.2f}" ) - if best is None or state.score < best.score: + if best is None or _better(state.rank, best.rank): best = state - best_initial = initial + winning_seed = seed - assert best is not None and best_initial is not None # restarts >= 1 + assert best is not None and baseline is not None # restarts >= 1 if translate(best.sequence) != protein: # pragma: no cover - safety net raise AssertionError("optimizer changed the encoded protein") - cancelled = bool(hit_limit and should_stop is not None and should_stop()) - initial_state = SequenceState(best_initial[0], config) + # Validate independently rather than treating the incremental counter as + # proof of feasibility. Never label heuristic failure as impossibility. + unresolved = constraint_violations(best.sequence, config) + if bool(unresolved) != bool(best.violations): + raise AssertionError("incremental constraint count disagrees with independent validation") + cancelled = stop_reason == "cancelled" + search_settings = {f.name: getattr(settings, f.name) for f in fields(settings) + if f.name not in {"progress", "should_stop"}} + search_settings["seed"] = effective_seed return OptimizationResult( sequence=best.sequence, protein=protein, score=best.score, breakdown=best.breakdown(), - initial_sequence=best_initial[0], - initial_score=best_initial[1], - initial_breakdown=initial_state.breakdown(), + initial_sequence=baseline.sequence, + initial_score=baseline.score, + initial_breakdown=baseline.breakdown(), sweeps=sweeps, moves_evaluated=evaluated, moves_accepted=accepted, @@ -227,6 +309,14 @@ def expired() -> bool: elapsed=time.perf_counter() - started, hit_time_limit=hit_limit and not cancelled, cancelled=cancelled, + valid=not unresolved, + unresolved_constraints=unresolved, + stop_reason=stop_reason, + effective_seed=effective_seed, + search_settings=search_settings, + winning_seed_sequence=winning_seed, + repair_evaluated=repair_evaluated, + notes=(["Mandatory constraints remain unresolved; this is not proof of impossibility."] if unresolved else []), ) @@ -249,6 +339,8 @@ def _greedy( expired: Callable[[], bool], ) -> tuple[int, int, int, bool]: """Run greedy sweeps until nothing improves. Returns counters.""" + if expired(): + return 0, 0, 0, True order = list(range(state.codon_count)) sweeps = evaluated = accepted = 0 for _ in range(settings.max_sweeps): @@ -257,20 +349,20 @@ def _greedy( improved = 0 for counter, position in enumerate(order): if not counter % 64 and expired(): - return sweeps, evaluated, accepted, True + return sweeps, evaluated, accepted + improved, True options = state.alternatives(position) - if len(options) == 1: + if options == (state.codon(position),): continue current = state.codon(position) - best_codon, best_score = current, state.score + best_codon, best_rank = current, state.rank for option in options: if option == current: continue state.set_codon(position, option) evaluated += 1 - score = state.score - if score < best_score - _EPSILON: - best_codon, best_score = option, score + rank = state.rank + if _better(rank, best_rank): + best_codon, best_rank = option, rank state.set_codon(position, best_codon) if best_codon != current: improved += 1 @@ -280,6 +372,65 @@ def _greedy( return sweeps, evaluated, accepted, False +def _better(candidate: tuple[int, float], incumbent: tuple[int, float]) -> bool: + return candidate[0] < incumbent[0] or (candidate[0] == incumbent[0] and candidate[1] < incumbent[1] - _EPSILON) + + +def _repair(state: SequenceState, settings: OptimizerSettings, + expired: Callable[[], bool]) -> tuple[int, int, bool]: + """Bounded coordinated repair around unresolved regions. + + Intermediate edits may violate constraints; only the complete candidate + is compared with the incumbent. The search budget is per restart and is + shared across passes and regions. + """ + evaluated = accepted = 0 + while state.violations and evaluated < settings.repair_candidates: + changed = False + failures = constraint_violations(state.sequence, state.config) + for failure in failures: + for region in failure["regions"]: + offset = len(state.config.left_flank) + lo = max(0, (region["start"] - offset) // 3) + hi = min(state.codon_count, (region["end"] - offset + 2) // 3) + if lo >= hi: # wholly fixed context cannot be edited + continue + mutable = [p for p in range(max(0, lo - 1), min(state.codon_count, hi + 1)) + if len(state.alternatives(p)) > 1] + # Long failures are addressed with overlapping local blocks; + # do not enumerate combinations across an entire long run. + for width in range(2, settings.repair_max_codons + 1): + for block_start in range(max(1, len(mutable) - width + 1)): + positions = mutable[block_start:block_start + width] + if len(positions) != width: + continue + original = tuple(state.codon(p) for p in positions) + best_codons, best_rank = original, state.rank + options = [state.alternatives(p) for p in positions] + for candidate in product(*options): + if candidate == original: + continue + if evaluated >= settings.repair_candidates or expired(): + for p, c in zip(positions, best_codons): + state.set_codon(p, c) + return evaluated, accepted + int(best_codons != original), expired() + for p, c in zip(positions, candidate): + state.set_codon(p, c) + evaluated += 1 + if _better(state.rank, best_rank): + best_codons, best_rank = candidate, state.rank + for p, c in zip(positions, best_codons): + state.set_codon(p, c) + if best_codons != original: + accepted += 1 + changed = True + if state.violations == 0: + return evaluated, accepted, False + if not changed: + break + return evaluated, accepted, False + + def _anneal( state: SequenceState, rng: random.Random, @@ -300,15 +451,15 @@ def _anneal( if not positions or steps <= 0: return None, 0, 0, False - best_sequence, best_score = state.sequence, state.score - cooling = (settings.end_temperature / settings.start_temperature) ** ( - 1.0 / max(1, steps - 1) - ) - temperature = settings.start_temperature + best_sequence, best_rank = state.sequence, state.rank evaluated = accepted = 0 stopped = False for step in range(steps): + # Schedule is tied to iterations, including draws of the current codon. + fraction = step / max(1, steps - 1) + temperature = math.exp((1-fraction)*math.log(settings.start_temperature) + + fraction*math.log(settings.end_temperature)) if not step % 256 and expired(): stopped = True break @@ -318,17 +469,19 @@ def _anneal( option = rng.choice(options) if option == current: continue - previous_score = state.score + previous_rank = state.rank state.set_codon(position, option) evaluated += 1 - delta = state.score - previous_score - if delta <= 0 or rng.random() < math.exp(-delta / temperature): + delta = state.score - previous_rank[1] + if state.violations < previous_rank[0] or ( + state.violations == previous_rank[0] and + (delta <= 0 or rng.random() < math.exp(-delta / temperature)) + ): accepted += 1 - if state.score < best_score - _EPSILON: - best_sequence, best_score = state.sequence, state.score + if _better(state.rank, best_rank): + best_sequence, best_rank = state.sequence, state.rank else: state.set_codon(position, current) - temperature *= cooling if state.sequence != best_sequence: state = SequenceState(best_sequence, state.config) diff --git a/src/codonopt/report.py b/src/codonopt/report.py index 68e64ef..073b6d9 100644 --- a/src/codonopt/report.py +++ b/src/codonopt/report.py @@ -2,6 +2,12 @@ from __future__ import annotations +from dataclasses import asdict, fields +import hashlib +import json +import platform +from importlib.metadata import version, PackageNotFoundError + from .config import DesignConfig from .metrics import ( count_hairpin_pairs, @@ -13,7 +19,7 @@ repeat_report, ) from .optimize import OptimizationResult -from .sequence import gc_fraction +from .sequence import gc_fraction, validate_dna from .splice import find_splice_sites __all__ = ["measure", "report_dict", "format_report"] @@ -25,26 +31,30 @@ def measure(sequence: str, config: DesignConfig) -> dict: Returns a JSON-serialisable dictionary; the same keys appear for the starting and the optimized sequence so the two can be compared directly. """ + sequence = validate_dna(sequence, in_frame=True) + context = config.left_flank + sequence + config.right_flank gc = gc_window_report( - sequence, + context, window=config.gc_window, step=config.gc_step, target_range=config.gc_range, ) - repeats = repeat_report(sequence, config.repeat_k) + repeats = repeat_report(context, config.repeat_k) hairpins = find_hairpins( - sequence, + context, stem=config.hairpin_stem, min_loop=config.hairpin_min_loop, max_loop=config.hairpin_max_loop, limit=10, ) - all_runs = homopolymer_runs(sequence, 1) + all_runs = homopolymer_runs(context, 1) runs = [run for run in all_runs if run.length > config.max_homopolymer] - hits = motif_hits(sequence, list(config.motifs)) + hits = motif_hits(context, list(config.motifs)) summary = { "length": len(sequence), + "context_length": len(context), + "cds_start": len(config.left_flank), "gc_fraction": round(gc_fraction(sequence), 4), "gc_windows_scored": gc.windows, "gc_windows_below_range": gc.low, @@ -57,7 +67,7 @@ def measure(sequence: str, config: DesignConfig) -> dict: "distinct_repeated_kmers": repeats.distinct_repeated, "repeat_covered_fraction": round(repeats.covered_fraction, 4), "hairpin_pairs": count_hairpin_pairs( - sequence, + context, stem=config.hairpin_stem, min_loop=config.hairpin_min_loop, max_loop=config.hairpin_max_loop, @@ -65,7 +75,7 @@ def measure(sequence: str, config: DesignConfig) -> dict: "longest_hairpin_stem": hairpins[0].stem_length if hairpins else 0, "homopolymer_runs_over_limit": len(runs), "homopolymer_excess_bases": homopolymer_excess( - sequence, config.max_homopolymer + context, config.max_homopolymer ), "longest_homopolymer": max((run.length for run in all_runs), default=0), "forbidden_motif_hits": sum(len(positions) for positions in hits.values()), @@ -74,12 +84,14 @@ def measure(sequence: str, config: DesignConfig) -> dict: if config.splice_scorer is not None: sites = find_splice_sites( - sequence, + context, config.splice_scorer, donor_cutoff=config.effective_donor_cutoff, acceptor_cutoff=config.effective_acceptor_cutoff, + strand=config.splice_strand, ) summary["splice_backend"] = config.splice_scorer.name + summary["splice_strand"] = config.splice_strand summary["splice_donor_sites"] = sum( 1 for site in sites if site.site_type == "donor" ) @@ -94,8 +106,45 @@ def measure(sequence: str, config: DesignConfig) -> dict: def report_dict(result: OptimizationResult, config: DesignConfig) -> dict: """Return a complete, JSON-serialisable record of a run.""" + from . import __version__ + + configuration = {f.name: getattr(config, f.name) for f in fields(config) + if f.name not in {"table", "motifs", "splice_scorer", "weights"}} + configuration["weights"] = asdict(config.weights) + configuration["motifs"] = [{"name": m.name, "pattern": m.pattern} for m in config.motifs] + table = {"name": config.table.name, "description": config.table.description, + "frequencies": dict(config.table.frequencies), "weights": dict(config.table.weights)} + table["sha256"] = hashlib.sha256(json.dumps(table, sort_keys=True, allow_nan=False).encode()).hexdigest() + configuration["table"] = table + scorer = config.splice_scorer + try: + backend_version = ( + version("maxentpy") if scorer and scorer.name == "maxent" + else __version__ if scorer and type(scorer).__module__ == "codonopt.splice" + else None + ) + except PackageNotFoundError: + backend_version = "uninstalled source; record source revision separately" + configuration["splice_scorer"] = { + "name": scorer.name if scorer else "none", + "class": f"{type(scorer).__module__}.{type(scorer).__qualname__}" if scorer else None, + "version": backend_version, + "donor_cutoff": config.effective_donor_cutoff, + "acceptor_cutoff": config.effective_acceptor_cutoff, + } return { + "schema_version": 1, + "software": {"codonopt": __version__, "python": platform.python_version()}, + "configuration": configuration, + "valid": result.valid, + "unresolved_constraints": result.unresolved_constraints, + "stop_reason": result.stop_reason, + "cancelled": result.cancelled, + "notes": result.notes, + "initial_sequence": result.initial_sequence, + "winning_seed_sequence": result.winning_seed_sequence, "protein_length": len(result.protein), + "protein": result.protein, "codon_usage_table": config.table.name, "score": { "initial": round(result.initial_score, 4), @@ -110,16 +159,21 @@ def report_dict(result: OptimizationResult, config: DesignConfig) -> dict: }, }, "search": { + "settings": result.search_settings, + "seed": result.effective_seed, "restarts_run": result.restarts_run, "greedy_sweeps": result.sweeps, "moves_evaluated": result.moves_evaluated, "moves_accepted": result.moves_accepted, "seconds": round(result.elapsed, 3), "hit_time_limit": result.hit_time_limit, + "cancelled": result.cancelled, + "repair_evaluated": result.repair_evaluated, }, "initial": measure(result.initial_sequence, config), "final": measure(result.sequence, config), "sequence": result.sequence, + "context_sequence": config.left_flank + result.sequence + config.right_flank, } @@ -145,6 +199,7 @@ def format_report(result: OptimizationResult, config: DesignConfig) -> str: after = measure(result.sequence, config) lines = [ + "Requirements " + ("valid" if result.valid else "UNRESOLVED (not a valid design)"), f"Protein length {len(result.protein)} residues", f"Coding sequence {after['length']} bases", f"Codon usage table {config.table.name}", @@ -154,6 +209,7 @@ def format_report(result: OptimizationResult, config: DesignConfig) -> str: f"{result.sweeps} sweep(s), {result.moves_evaluated} moves evaluated, " f"{result.elapsed:.1f}s" + (" [time limit reached]" if result.hit_time_limit else ""), + f"Stop reason {result.stop_reason}", "", f"{'Property':<24}{'before':>12}{'after':>12}", "-" * 48, @@ -170,4 +226,7 @@ def format_report(result: OptimizationResult, config: DesignConfig) -> str: present = after.get("forbidden_motifs_present") if present: lines += ["", "Remaining forbidden motifs: " + ", ".join(present)] + if result.unresolved_constraints: + lines += ["", "Unresolved requirements: " + ", ".join( + f"{v['criterion']} ({v['count']})" for v in result.unresolved_constraints)] return "\n".join(lines) diff --git a/src/codonopt/server.py b/src/codonopt/server.py index 0dbdb8f..5560710 100644 --- a/src/codonopt/server.py +++ b/src/codonopt/server.py @@ -282,19 +282,33 @@ def build_request(payload: object) -> JobSpec: {"v": gc_range[1]}, "v", 0.70, low=0, high=1, label="GC range high" ) - backend = payload.get("splice_backend", "auto") + table = _build_table(payload) + backend = payload.get("splice_backend", "none") if backend not in {"auto", "maxent", "motif", "none"}: raise RequestError("splice backend must be auto, maxent, motif or none") try: - scorer = get_scorer(backend) + scorer = get_scorer(backend, host=table.name) except RuntimeError as exc: raise RequestError(str(exc).splitlines()[0]) from None def whole(key: str, default: int, low: int, high: int) -> int: return _number(payload, key, default, low=low, high=high, integer=True) + def items(key: str, default: list, kind: type) -> tuple: + value = payload.get(key, default) + if not isinstance(value, list) or any(type(x) is not kind for x in value): + raise RequestError(f"{key} must be a list of {kind.__name__} values") + return tuple(value) + + left_flank = payload.get("left_flank", "") + right_flank = payload.get("right_flank", "") + if not isinstance(left_flank, str) or not isinstance(right_flank, str): + raise RequestError("flanks must be DNA strings") + if len(left_flank) + len(sequence) + len(right_flank) > 3 * MAX_RESIDUES: + raise RequestError("the complete construct exceeds the interface length limit") + config = DesignConfig( - table=_build_table(payload), + table=table, gc_target=_number(payload, "gc_target", 0.50, low=0, high=1), gc_range=(gc_low, gc_high), gc_window=whole("gc_window", 30, 6, 300), @@ -311,6 +325,12 @@ def whole(key: str, default: int, low: int, high: int) -> int: payload, "acceptor_cutoff", None, low=-100, high=100 ), weights=_build_weights(payload.get("weights")), + required=items("required", ["motif"], str), + protected_codons=items("protected_codons", [], int), + excluded_codons=items("excluded_codons", [], str), + splice_strand=payload.get("splice_strand", "forward"), + left_flank=left_flank, + right_flank=right_flank, ) effort = payload.get("effort", "standard") @@ -446,13 +466,15 @@ def result_payload(result: OptimizationResult, spec: JobSpec) -> dict: config = spec.config payload = report_dict(result, config) profile_before = gc_profile( - result.initial_sequence, window=config.gc_window, step=config.gc_step + config.left_flank + result.initial_sequence + config.right_flank, + window=config.gc_window, step=config.gc_step ) profile_after = gc_profile( - result.sequence, window=config.gc_window, step=config.gc_step + config.left_flank + result.sequence + config.right_flank, + window=config.gc_window, step=config.gc_step ) lengths = {motif.name: motif.length for motif in config.motifs} - hits = motif_hits(result.sequence, list(config.motifs)) + hits = motif_hits(config.left_flank + result.sequence + config.right_flank, list(config.motifs)) payload.update( name=spec.name, kind=spec.kind, @@ -464,6 +486,7 @@ def result_payload(result: OptimizationResult, spec: JobSpec) -> dict: "step": config.gc_step, "range": list(config.gc_range), "target": config.gc_target, + "coordinates": "construct", "starts": [start for start, _ in profile_after], "initial": [round(value, 4) for _, value in profile_before], "final": [round(value, 4) for _, value in profile_after], @@ -477,7 +500,7 @@ def result_payload(result: OptimizationResult, spec: JobSpec) -> dict: name=spec.name, description=( f"{spec.name} codonopt table={config.table.name} " - f"score={result.score:.2f}" + f"score={result.score:.2f} valid={str(result.valid).lower()}" ), sequence=result.sequence, ) diff --git a/src/codonopt/splice.py b/src/codonopt/splice.py index dd41a78..11d201e 100644 --- a/src/codonopt/splice.py +++ b/src/codonopt/splice.py @@ -169,7 +169,7 @@ def maxent_available() -> bool: return True -def get_scorer(backend: str) -> SpliceScorer | None: +def get_scorer(backend: str, *, host: str | None = None) -> SpliceScorer | None: """Return a scorer for ``backend``. Args: @@ -191,6 +191,10 @@ def get_scorer(backend: str) -> SpliceScorer | None: if key == "motif": return MotifScorer() if key == "auto": + # Automatic screening is only supported for the bundled human host. + # Other hosts can opt into a backend explicitly, with its limitations. + if host is not None and host.strip().lower() != "human": + return None return MaxEntScorer() if maxent_available() else MotifScorer() raise ValueError( f"unknown splice backend {backend!r}; expected maxent, motif, none or auto" diff --git a/src/codonopt/state.py b/src/codonopt/state.py index 7d885b7..13be5ad 100644 --- a/src/codonopt/state.py +++ b/src/codonopt/state.py @@ -7,9 +7,8 @@ the edit need recomputing. Repeats and hairpins, which are not local, are carried by :class:`codonopt.index.KmerIndex`. -The result is that a candidate codon swap costs time proportional to the window -sizes involved rather than to the length of the sequence, which is what makes a -whole-sequence search practical. :func:`SequenceState.audit` recomputes the +Penalty recomputation is local, though each edit still copies the immutable +sequence string. :func:`SequenceState.audit` recomputes the score from scratch and is used by the tests to confirm the incremental path agrees with a full recomputation. """ @@ -21,7 +20,7 @@ from bisect import bisect_left, bisect_right from dataclasses import dataclass -from .config import DesignConfig +from .config import DesignConfig, Weights from .index import KmerIndex from .metrics import window_starts from .motifs import Motif @@ -38,8 +37,6 @@ __all__ = ["SequenceState", "ScoreBreakdown"] -_MIN_WEIGHT = 1e-9 - @dataclass(frozen=True) class ScoreBreakdown: @@ -81,15 +78,28 @@ class SequenceState: "_pattern_motifs", "_motif_lengths", "_splice_terms", + "_coding_start", + "_coding_length", + "_required_weights", + "_required_total", ) def __init__(self, sequence: str, config: DesignConfig) -> None: - self._sequence = validate_dna(sequence, in_frame=True) + sequence = validate_dna(sequence, in_frame=True) + self._coding_start = len(config.left_flank) + self._coding_length = len(sequence) + self._sequence = config.left_flank + sequence + config.right_flank self.config = config - self.protein = translate(self._sequence) + self.protein = translate(sequence) + if any(i >= len(self.protein) for i in config.protected_codons): + raise ValueError("protected codon index is outside the coding sequence") weights = config.weights + self._required_weights = Weights(**{ + name: float(name in config.required) + for name in vars(weights) + }) - self._gc = gc_count(self._sequence) + self._gc = gc_count(sequence) self._window_starts = window_starts( len(self._sequence), config.gc_window, config.gc_step ) @@ -106,13 +116,13 @@ def __init__(self, sequence: str, config: DesignConfig) -> None: (ACCEPTOR_WINDOW, config.splice_scorer.score_acceptor, config.effective_acceptor_cutoff), ) - if config.splice_scorer is not None and weights.splice > _MIN_WEIGHT + if config.splice_scorer is not None else () ) self._repeat_index = ( KmerIndex(self._sequence, config.repeat_k, track_hairpins=False) - if weights.repeat > _MIN_WEIGHT + if weights.repeat > 0 or "repeat" in config.required else None ) self._hairpin_index = ( @@ -123,36 +133,62 @@ def __init__(self, sequence: str, config: DesignConfig) -> None: max_loop=config.hairpin_max_loop, track_hairpins=True, ) - if weights.hairpin > _MIN_WEIGHT + if weights.hairpin > 0 or "hairpin" in config.required else None ) self._local_total = self._local(0, len(self._sequence)) + self._required_total = self._local(0, len(self._sequence), required=True) # -- read-only views --------------------------------------------------- @property def sequence(self) -> str: """The current coding sequence.""" + return self._sequence[self._coding_start : self._coding_start + self._coding_length] + + @property + def context_sequence(self) -> str: + """The CDS together with immutable construct flanks.""" return self._sequence @property def codon_count(self) -> int: """Number of codons in the sequence.""" - return len(self._sequence) // 3 + return self._coding_length // 3 @property def score(self) -> float: """The weighted objective; lower is better.""" return self._local_total + self._global_terms() + @property + def violations(self) -> int: + """Number of mandatory constraint violations, maintained incrementally.""" + total = self._required_total + if "repeat" in self.config.required: + total += self._repeat_index.redundant + if "hairpin" in self.config.required: + total += self._hairpin_index.hairpin_pairs + return int(total) + + @property + def rank(self) -> tuple[int, float]: + """Feasibility takes precedence over the soft objective.""" + return self.violations, self.score + def codon(self, codon_index: int) -> str: """Return the codon at ``codon_index``.""" - start = codon_index * 3 + if not 0 <= codon_index < self.codon_count: + raise IndexError("codon index is outside the coding sequence") + start = self._coding_start + codon_index * 3 return self._sequence[start : start + 3] def alternatives(self, codon_index: int) -> tuple[str, ...]: """Return every synonymous codon for the residue at ``codon_index``.""" - return AA_TO_CODONS[CODON_TO_AA[self.codon(codon_index)]] + current = self.codon(codon_index) + if codon_index in self.config.protected_codons: + return (current,) + return tuple(c for c in AA_TO_CODONS[CODON_TO_AA[current]] if c not in self.config.excluded_codons) # -- mutation ---------------------------------------------------------- @@ -166,11 +202,15 @@ def set_codon(self, codon_index: int, codon: str) -> str: Raises: ValueError: If ``codon`` does not encode the same residue. """ - lo = codon_index * 3 + previous = self.codon(codon_index) + lo = self._coding_start + codon_index * 3 hi = lo + 3 - previous = self._sequence[lo:hi] if codon == previous: return previous + if codon_index in self.config.protected_codons: + raise ValueError(f"codon {codon_index} is protected") + if codon not in CODON_TO_AA: + raise ValueError(f"not a standard genetic-code codon: {codon!r}") if CODON_TO_AA[codon] != CODON_TO_AA[previous]: raise ValueError( f"{codon} encodes {CODON_TO_AA[codon]} but position " @@ -178,6 +218,7 @@ def set_codon(self, codon_index: int, codon: str) -> str: ) before = self._local(lo, hi) + required_before = self._local(lo, hi, required=True) self._sequence = self._sequence[:lo] + codon + self._sequence[hi:] self._gc += gc_count(codon) - gc_count(previous) if self._repeat_index is not None: @@ -185,6 +226,7 @@ def set_codon(self, codon_index: int, codon: str) -> str: if self._hairpin_index is not None: self._hairpin_index.replace_range(self._sequence, lo, hi) self._local_total += self._local(lo, hi) - before + self._required_total += self._local(lo, hi, required=True) - required_before return previous # -- scoring ----------------------------------------------------------- @@ -192,24 +234,23 @@ def set_codon(self, codon_index: int, codon: str) -> str: def _global_terms(self) -> float: weights = self.config.weights total = 0.0 - if weights.gc_global > _MIN_WEIGHT: - fraction = self._gc / len(self._sequence) - total += weights.gc_global * abs(fraction - self.config.gc_target) + if weights.gc_global > 0: + total += weights.gc_global * abs(self._gc - self.config.gc_target * self._coding_length) / 300 if self._repeat_index is not None: total += weights.repeat * self._repeat_index.redundant if self._hairpin_index is not None: total += weights.hairpin * self._hairpin_index.hairpin_pairs return total - def _local(self, lo: int, hi: int) -> float: + def _local(self, lo: int, hi: int, *, required: bool = False) -> float: """Weighted penalty of every term anchored near bases ``[lo, hi)``.""" sequence = self._sequence length = len(sequence) config = self.config - weights = config.weights + weights = self._required_weights if required else config.weights total = 0.0 - if weights.gc_window > _MIN_WEIGHT and self._window_starts: + if weights.gc_window > 0 and self._window_starts: window = config.gc_window low_bound, high_bound = config.gc_range starts = self._window_starts @@ -222,18 +263,18 @@ def _local(self, lo: int, hi: int) -> float: + sequence.count("C", position, end) ) / (end - position) if fraction < low_bound: - total += weights.gc_window * (low_bound - fraction) + total += 1 if required else weights.gc_window * (low_bound - fraction) elif fraction > high_bound: - total += weights.gc_window * (fraction - high_bound) + total += 1 if required else weights.gc_window * (fraction - high_bound) - if weights.homopolymer > _MIN_WEIGHT: + if weights.homopolymer > 0: run = config.max_homopolymer for i in range(max(run, lo), min(length, hi + run)): segment = sequence[i - run : i + 1] if segment == segment[0] * (run + 1): total += weights.homopolymer - if weights.motif > _MIN_WEIGHT: + if weights.motif > 0: for size in self._motif_lengths: literals = self._literals_by_length[size] first = max(0, lo - size + 1) @@ -249,22 +290,26 @@ def _local(self, lo: int, hi: int) -> float: total += weights.motif for window, score_window, cutoff in self._splice_terms: + if weights.splice == 0: + continue first = max(0, lo - window + 1) last = min(length - window + 1, hi) weight = weights.splice for position in range(first, last): forward = sequence[position : position + window] - score = score_window(forward) - if score > cutoff: - total += weight * (score - cutoff) - score = score_window(reverse_complement(forward)) - if score > cutoff: - total += weight * (score - cutoff) - - if weights.usage > _MIN_WEIGHT: + for strand in config.splice_strands: + candidate = reverse_complement(forward) if strand == "reverse" else forward + score = score_window(candidate) + if score > cutoff: + total += 1 if required else weight * (score - cutoff) + + if weights.usage > 0 or (required and config.excluded_codons): cost = self._usage_cost - for index in range(lo // 3, (hi + 2) // 3): - total += weights.usage * cost[sequence[index * 3 : index * 3 + 3]] + first = max(0, (lo - self._coding_start) // 3) + last = min(self.codon_count, (hi - self._coding_start + 2) // 3) + for index in range(first, last): + codon = self.codon(index) + total += int(codon in config.excluded_codons) if required else weights.usage * cost[codon] return total @@ -276,7 +321,7 @@ def audit(self) -> float: Used by the tests to confirm the incremental bookkeeping has not drifted from a full recomputation. """ - return SequenceState(self._sequence, self.config).score + return SequenceState(self.sequence, self.config).score def breakdown(self) -> ScoreBreakdown: """Return the objective split into its individual terms.""" @@ -286,9 +331,9 @@ def breakdown(self) -> ScoreBreakdown: raw: dict[str, float] = {} terms: dict[str, float] = {} - fraction = self._gc / len(sequence) + fraction = self._gc / self._coding_length raw["gc_global"] = fraction - terms["gc_global"] = weights.gc_global * abs(fraction - config.gc_target) + terms["gc_global"] = weights.gc_global * abs(self._gc - config.gc_target * self._coding_length) / 300 window_excess = 0.0 low_bound, high_bound = config.gc_range @@ -339,14 +384,15 @@ def breakdown(self) -> ScoreBreakdown: for window, score_window, cutoff in self._splice_terms: for position in range(len(sequence) - window + 1): forward = sequence[position : position + window] - for candidate in (forward, reverse_complement(forward)): + for strand in config.splice_strands: + candidate = reverse_complement(forward) if strand == "reverse" else forward score = score_window(candidate) if score > cutoff: splice_excess += score - cutoff raw["splice"] = splice_excess terms["splice"] = weights.splice * splice_excess - usage = sum(self._usage_cost[codon] for codon in split_codons(sequence)) + usage = sum(self._usage_cost[codon] for codon in split_codons(self.sequence)) raw["usage"] = usage terms["usage"] = weights.usage * usage diff --git a/src/codonopt/tables.py b/src/codonopt/tables.py index 3a2b717..c9e62c5 100644 --- a/src/codonopt/tables.py +++ b/src/codonopt/tables.py @@ -102,6 +102,18 @@ class CodonUsage: weights: dict[str, float] description: str = "" + def __post_init__(self) -> None: + for name, values in (("frequencies", self.frequencies), ("weights", self.weights)): + if set(values) != set(CODON_TO_AA): + raise ValueError(f"{name} must cover every standard genetic-code codon") + if any(not math.isfinite(v) or not 0 <= v <= 1 for v in values.values()): + raise ValueError(f"{name} must contain finite values between 0 and 1") + for codons in AA_TO_CODONS.values(): + if not math.isclose(sum(self.frequencies[c] for c in codons), 1.0, abs_tol=1e-12): + raise ValueError("codon frequencies must be normalized within each family") + if not math.isclose(max(self.weights[c] for c in codons), 1.0, abs_tol=1e-12): + raise ValueError("each family must have a relative adaptiveness of 1") + @classmethod def from_frequencies( cls, @@ -114,9 +126,11 @@ def from_frequencies( Values are normalised within each amino acid family, so either raw codon counts or already-normalised frequencies may be supplied. Codons - missing from ``frequencies`` default to 0 and are never chosen, unless + missing from ``frequencies`` default to 0 and are never sampled, unless every codon in a family is missing, in which case the family falls back to a uniform distribution. + Zero frequencies are absent observations, not forbidden codons; search + may use them. Use DesignConfig.excluded_codons for actual exclusions. Raises: ValueError: If a key is not a codon of the standard genetic code or @@ -127,8 +141,8 @@ def from_frequencies( codon = codon.strip().upper().replace("U", "T") if codon not in CODON_TO_AA: raise ValueError(f"not a codon of the standard genetic code: {codon!r}") - if value < 0: - raise ValueError(f"negative frequency for codon {codon}: {value}") + if not math.isfinite(value) or value < 0: + raise ValueError(f"negative or non-finite frequency for codon {codon}: {value}") raw[codon] = float(value) normalised: dict[str, float] = {} @@ -136,6 +150,8 @@ def from_frequencies( for aa, codons in AA_TO_CODONS.items(): values = [raw.get(codon, 0.0) for codon in codons] total = sum(values) + if not math.isfinite(total): + raise ValueError(f"frequency total overflows for amino acid {aa}") if total <= 0: values = [1.0] * len(codons) total = float(len(codons)) diff --git a/src/codonopt/web/app.js b/src/codonopt/web/app.js index b87c020..b44d9e0 100644 --- a/src/codonopt/web/app.js +++ b/src/codonopt/web/app.js @@ -1,5 +1,5 @@ -"use strict";(()=>{var P=class extends Error{constructor(n,o){super(n);this.status=o;this.name="ApiError"}},Me="X-Codonopt-Token",I=class{constructor(e){this.token=e}meta(){return this.request("GET","/api/meta")}start(e){return this.request("POST","/api/jobs",e)}poll(e){return this.request("GET",`/api/jobs/${encodeURIComponent(e)}`)}cancel(e){return this.request("POST",`/api/jobs/${encodeURIComponent(e)}/cancel`)}async request(e,n,o){let a;try{a=await fetch(n,{method:e,headers:{[Me]:this.token,...o===void 0?{}:{"Content-Type":"application/json"}},body:o===void 0?void 0:JSON.stringify(o),cache:"no-store"})}catch{throw new P("Cannot reach the codonopt server. Was the window or terminal that started it closed?",0)}let i=null;try{i=await a.json()}catch{}if(!a.ok){let r=i&&typeof i=="object"&&"error"in i?String(i.error):`The server answered ${a.status}.`;throw new P(r,a.status)}if(i===null||typeof i!="object")throw new P(`The server answered ${a.status} but sent no readable data.`,a.status);return i}};var Le=new Set("ACDEFGHIKLMNPQRSTVWY*"),re=new Set("ACGTU");function ce(t,e,n,o=!1){let a=t.trim();if(!a)return{state:"empty",message:"Nothing pasted yet.",kind:null,length:0};let i=a;if(a.startsWith(">")){let b=a.split(/\r?\n/),d=b.filter(w=>w.startsWith(">")).length;if(d>1)return q(`${d} FASTA records found. Paste one sequence at a time.`);i=b.slice(1).join("")}let r=i.replace(/\s+/g,"").toUpperCase();if(!r)return q("The FASTA header has no sequence under it.");let h=r.length>=9&&[...r].every(b=>re.has(b));if((e==="auto"?h?"dna":"protein":e)==="dna"){let b=le([...r].filter(w=>!re.has(w)));if(b.length)return q(`Not DNA: ${b.join(", ")}`);if(r.length%3)return q(`DNA \xB7 ${r.length} bases is not a whole number of codons (${r.length%3} extra).`);let d=r.length/3;return d>n?q(`DNA \xB7 ${d.toLocaleString()} codons; this page takes up to ${n.toLocaleString()}.`):ie("dna",r.length,`DNA \xB7 ${r.length.toLocaleString()} bases, ${d} codons`)}let m=le([...r].filter(b=>!Le.has(b)));if(m.length)return q(`Not a protein letter: ${m.join(", ")}`);let p=r.indexOf("*");if(p!==-1&&p!==r.length-1)return q(`Protein \xB7 stop symbol (*) at position ${p+1}, before the end.`);let L=r.length+(o&&!r.endsWith("*")?1:0);if(L>n)return q(`Protein \xB7 ${L.toLocaleString()} residues${L>r.length?" with the stop codon":""}; this page takes up to ${n.toLocaleString()}.`);let f=r.length===1?"residue":"residues";return ie("protein",r.length,`Protein \xB7 ${r.length.toLocaleString()} ${f}`)}function ie(t,e,n){return{state:"ok",message:n,kind:t,length:e}}function q(t){return{state:"error",message:t,kind:null,length:0}}function le(t){return[...new Set(t)].sort()}function V(t,e){for(let n of e)n==null||n===!1||t.appendChild(typeof n=="object"?n:document.createTextNode(String(n)))}function s(t,e,...n){let o=document.createElement(t);if(e){let{attrs:a,on:i,text:r,...h}=e;for(let[u,m]of Object.entries(h))m===void 0||m===!1||(u==="class"?o.className=String(m):typeof m=="boolean"?o.setAttribute(u,""):o.setAttribute(u,String(m)));for(let[u,m]of Object.entries(a??{}))o.setAttribute(u,m);if(r!==void 0&&(o.textContent=r),i)for(let[u,m]of Object.entries(i))o.addEventListener(u,m)}return V(o,n),o}var _e="http://www.w3.org/2000/svg";function E(t,e,...n){let o=document.createElementNS(_e,t);for(let[a,i]of Object.entries(e??{}))o.setAttribute(a,String(i));return V(o,n),o}function c(t){let e=document.getElementById(t);if(!e)throw new Error(`Missing #${t} in the page`);return e}function k(t,...e){t.replaceChildren(),V(t,e)}var x=(t,e=2)=>Number.isFinite(t)?t.toFixed(e):"\u2013",T=t=>Math.round(t).toLocaleString("en-US");var Se={gc_global:"Overall GC",gc_window:"Local GC",repeat:"Repeats",hairpin:"Hairpins",homopolymer:"Single-base runs",motif:"Forbidden sites",splice:"Splice sites",usage:"Codon usage"},ke={golden_gate:"Golden Gate",common_cloning:"Common cloning sites",regulatory:"Poly(A) and TATA signals"},$e={auto:"Automatic",maxent:"MaxEntScan",motif:"Consensus screen",none:"Off"},M=t=>c(t);function de(t){c("version").textContent=`v${t.version}`;let e=c("table");k(e,...t.tables.map(n=>s("option",{value:n.name,text:n.name})),s("option",{value:"custom",text:"Custom table\u2026"})),k(c("avoid-groups"),...Object.entries(t.motif_groups).filter(([,n])=>n.length).map(([n,o])=>s("label",{class:"chip-check",title:o.join(", ")},s("input",{type:"checkbox",value:n,attrs:{name:"avoid"}}),s("span",{text:ke[n]??n})))),k(c("splice"),...t.splice_backends.map(n=>s("option",{value:n,text:$e[n]??n}))),k(c("weights"),...Object.keys(t.weights).map(n=>s("label",{class:"field"},s("span",{text:Se[n]??n}),s("input",{type:"number",id:`weight-${n}`,attrs:{min:"0",step:"any",inputmode:"decimal","data-weight":n}})))),X(t)}function X(t){let e=t.defaults;c("table").value=t.default_table,c("table-text").value="",Y();for(let n of document.querySelectorAll('#avoid-groups input[type="checkbox"]'))n.checked=!1;c("avoid-extra").value="",M("gc-target").value=String(e.gc_target),M("gc-low").value=String(e.gc_range[0]),M("gc-high").value=String(e.gc_range[1]),M("max-homopolymer").value=String(e.max_homopolymer),c("splice").value="auto",c("effort-standard").checked=!0,M("seconds").value=String(e.seconds),M("seed").value="",M("gc-window").value=String(e.gc_window),M("gc-step").value=String(e.gc_step),M("repeat-k").value=String(e.repeat_k),M("hairpin-stem").value=String(e.hairpin_stem),M("hairpin-min-loop").value=String(e.hairpin_min_loop),M("hairpin-max-loop").value=String(e.hairpin_max_loop);for(let[n,o]of Object.entries(t.weights))M(`weight-${n}`).value=String(o);Q(t),Z(t)}function Y(){let t=c("table").value==="custom";c("custom-table").hidden=!t}function ue(){return c("table").value!=="custom"||c("table-text").value.trim()?null:"Paste or open a codon usage table, or pick a built-in host instead of \u201CCustom table\u2026\u201D."}function B(t){let e=c("table").value;c("table-hint").textContent=e==="custom"?"":t.tables.find(n=>n.name===e)?.description??""}function Q(t){let e=pe(),n=t.effort[e];if(!n)return;let o=[`${n.max_sweeps} greedy passes`];n.restarts>1&&o.push(`${n.restarts} independent restarts`),n.anneal_steps&&o.push(`${n.anneal_steps.toLocaleString()} annealing steps each`),c("effort-hint").textContent=`${o.join(", ")}.`}function Z(t){let e=c("splice").value,n=c("splice-hint");e==="maxent"&&!t.maxent_available?n.textContent="MaxEntScan is not installed, so this will report an error. Pick Automatic or Consensus screen.":e==="auto"?n.textContent=t.maxent_available?"Uses MaxEntScan.":"MaxEntScan is not installed, so a simpler consensus screen is used.":e==="motif"?n.textContent="A fast consensus screen. It flags likely sites rather than modelling splicing.":e==="none"?n.textContent="Splice sites are ignored. Right for sequences that will not be spliced, and faster.":n.textContent=""}function pe(){return document.querySelector('input[name="effort"]:checked')?.value??"standard"}function ee(){return document.querySelector('input[name="kind"]:checked')?.value??"auto"}function S(t){let e=M(t).value.trim();if(e==="")return;let n=Number(e);return Number.isFinite(n)?n:e}function he(){let t=[...document.querySelectorAll("#avoid-groups input:checked")].map(a=>a.value);t.push(...c("avoid-extra").value.split(/[,\n]/).map(a=>a.trim()).filter(Boolean));let e={};for(let a of document.querySelectorAll("#weights input")){let i=a.value.trim();i!==""&&(e[a.dataset.weight??""]=Number.isFinite(Number(i))?Number(i):i)}let n=c("table").value,o=ee();return{sequence:c("sequence").value,kind:o,add_stop:c("add-stop").checked,table:n==="custom"?void 0:n,table_text:n==="custom"?c("table-text").value:void 0,avoid:t,gc_target:S("gc-target"),gc_range:[S("gc-low"),S("gc-high")],max_homopolymer:S("max-homopolymer"),splice_backend:c("splice").value,effort:pe(),seconds:S("seconds"),seed:S("seed"),gc_window:S("gc-window"),gc_step:S("gc-step"),repeat_k:S("repeat-k"),hairpin_stem:S("hairpin-stem"),hairpin_min_loop:S("hairpin-min-loop"),hairpin_max_loop:S("hairpin-max-loop"),weights:e}}var O=720,ne=236,v={left:46,right:14,top:12,bottom:34},G=O-v.left-v.right,D=ne-v.top-v.bottom,te=[50,100,200,250,500,1e3,2e3,2500,5e3,1e4,2e4,5e4];function Ce(t){return te.find(e=>t/e<=8)??te[te.length-1]}function me(t,e){let n=Math.max(e,1),o=d=>v.left+d/n*G,a=d=>v.top+(1-d)*D,i=d=>Math.min(d+t.window/2,n),r=d=>d.map((w,_)=>`${_?"L":"M"}${o(i(t.starts[_])).toFixed(1)} ${a(w).toFixed(1)}`).join(""),h=E("svg",{viewBox:`0 0 ${O} ${ne}`,role:"img",class:"chart","aria-label":`GC content in ${t.window} base windows along the sequence, before and after optimisation, against the allowed range of ${x(t.range[0]*100,0)} to ${x(t.range[1]*100,0)} percent.`});h.append(E("rect",{class:"band",x:v.left,y:a(t.range[1]),width:G,height:Math.max(0,a(t.range[0])-a(t.range[1]))}));for(let d of[0,.25,.5,.75,1])h.append(E("line",{class:"grid",x1:v.left,x2:O-v.right,y1:a(d),y2:a(d)}),E("text",{class:"tick",x:v.left-8,y:a(d)+4,"text-anchor":"end"},`${d*100}%`));let u=Ce(n);for(let d=0;d<=n;d+=u)h.append(E("line",{class:"grid faint",x1:o(d),x2:o(d),y1:v.top,y2:v.top+D}),E("text",{class:"tick",x:o(d),y:ne-12,"text-anchor":"middle"},T(d)));h.append(E("line",{class:"target",x1:v.left,x2:O-v.right,y1:a(t.target),y2:a(t.target)})),t.starts.length>1?h.append(E("path",{class:"line before",d:r(t.initial)}),E("path",{class:"line after",d:r(t.final)})):t.starts.length===1&&h.append(E("circle",{class:"dot before",cx:o(i(t.starts[0])),cy:a(t.initial[0]),r:4}),E("circle",{class:"dot after",cx:o(i(t.starts[0])),cy:a(t.final[0]),r:4}));let m=E("line",{class:"cursor",x1:0,x2:0,y1:v.top,y2:v.top+D});m.setAttribute("visibility","hidden");let p=E("rect",{class:"overlay",x:v.left,y:v.top,width:G,height:D});h.append(m,p);let L="Hover the chart to read a window.",f=s("p",{class:"readout",text:L});p.addEventListener("pointermove",d=>{let w=h.getBoundingClientRect(),A=((d.clientX-w.left)/w.width*O-v.left)/G*n,y=0;for(let N=1;N{m.setAttribute("visibility","hidden"),f.textContent=L});let b=s("ul",{class:"legend"},s("li",null,s("span",{class:"swatch before"}),"Before"),s("li",null,s("span",{class:"swatch after"}),"After"),s("li",null,s("span",{class:"swatch band"}),"Allowed range"),s("li",null,s("span",{class:"swatch target"}),`Target ${x(t.target*100,0)}%`));return s("figure",{class:"figure"},h,b,f)}var fe=20;function ge(t){let e=t.sequence,n=t.initial_sequence,o=e.length/3,a=new Set;for(let f of t.motif_hits)for(let b of f.positions)for(let d=Math.floor(b/3);d<=Math.floor((b+f.length-1)/3);d++)a.add(d);let i=0,r=[],h=String(e.length).length;for(let f=0;fs("label",{class:"check"},s("input",{type:"checkbox",checked:d,on:{change:w=>u.classList.toggle(b,w.target.checked)}}),` ${f}`);!t.motif_hits.length&&!i&&u.classList.remove("show-changes");let p=i===0?"No codon needed to change.":`${T(i)} of ${T(o)} codons changed (${Math.round(i/o*100)}%).`,L=t.motif_hits.length?s("p",{class:"note warn",role:"status"},`Still contains ${t.motif_hits.map(f=>`${f.name} (${f.positions.length})`).join(", ")}; the marked codons overlap a site you asked to keep out.`):null;return s("div",{class:"seq-wrap"},s("div",{class:"seq-tools"},s("p",{class:"hint",text:p}),s("div",{class:"toggles"},m("Highlight changes","show-changes",u.classList.contains("show-changes")),m("Show protein","show-aa",!0))),L,u)}async function be(t){try{return await navigator.clipboard.writeText(t),!0}catch{let e=s("textarea",{attrs:{"aria-hidden":"true",tabindex:"-1"}});e.value=t,document.body.append(e),e.select();let n=!1;try{n=document.execCommand("copy")}catch{n=!1}return e.remove(),n}}function ve(t,e,n){let o=URL.createObjectURL(new Blob([e],{type:n})),a=s("a",{attrs:{href:o,download:t}});document.body.append(a),a.click(),a.remove(),setTimeout(()=>URL.revokeObjectURL(o),1e4)}function F(t,e,n=!1){let o=s("button",{type:"button",class:n?"chip primary-chip":"chip",text:t,on:{click:()=>e(o)}});return o}var se=new WeakMap;function we(t,e){let n=se.get(t);n!==void 0&&window.clearTimeout(n),t.dataset.label===void 0&&(t.dataset.label=t.textContent??""),t.textContent=e,se.set(t,window.setTimeout(()=>{t.textContent=t.dataset.label??"",se.delete(t)},1600))}var He=t=>t.replace(/[^\w.-]+/g,"_")||"sequence";function H(t,e){k(t,s("div",{class:"callout error",role:"alert"},s("strong",{text:"That did not work"}),s("p",{text:e}))),t.focus({preventScroll:!0})}var j=class{constructor(e){this.bar=s("div",{class:"bar-fill"});this.elapsed=s("span",{class:"mono"});this.detail=s("span",{class:"hint"});this.log=s("ol",{class:"log mono",attrs:{"aria-label":"Search log"}});this.trouble=s("p",{class:"note warn",role:"alert"});this.lastLog="";this.trouble.hidden=!0,k(e,s("div",{class:"card progress"},s("h2",{text:"Optimizing"}),s("p",{class:"hint",text:"Trying synonymous codons and keeping the changes that lower the score."}),s("div",{class:"bar",role:"progressbar",attrs:{"aria-label":"Optimization progress"}},this.bar),s("div",{class:"progress-meta"},this.elapsed,this.detail),this.trouble,this.log)),e.focus({preventScroll:!0})}note(e){this.trouble.textContent=e,this.trouble.hidden=!1}update(e){this.elapsed.textContent=`${x(e.elapsed,1)} s of at most ${T(e.max_seconds)} s`;let n=this.bar.parentElement;if(e.restarts_total>1){let a=Math.min(e.restarts_done,e.restarts_total);this.detail.textContent=`Restart ${Math.min(a+1,e.restarts_total)} of ${e.restarts_total}`,this.bar.style.width=`${a/e.restarts_total*100}%`,n?.classList.remove("indeterminate"),n?.setAttribute("aria-valuemin","0"),n?.setAttribute("aria-valuemax",String(e.restarts_total)),n?.setAttribute("aria-valuenow",String(a))}else this.detail.textContent="Stops as soon as nothing improves.",n?.classList.add("indeterminate"),n?.removeAttribute("aria-valuenow");let o=e.messages.join(` -`);o!==this.lastLog&&(this.lastLog=o,k(this.log,...e.messages.map(a=>s("li",{text:a}))))}},J=t=>`${x(t*100,1)}%`;function Ae(t){let e=t.splice_backend!==void 0;return[{label:"GC content",help:"Share of G and C bases over the whole sequence.",get:n=>n.gc_fraction,format:J,better:"target"},{label:"GC windows out of range",help:"Windows whose local GC falls outside the allowed range.",get:n=>n.gc_windows_below_range+n.gc_windows_above_range,better:"lower"},{label:"Codon adaptation index",help:"How closely the codons follow the host's preferences (1 is ideal).",get:n=>n.cai,format:n=>x(n,3),better:"higher"},{label:`Repeated ${t.repeat_k}-mers`,help:"Extra copies of any stretch that appears more than once. Repeats misassemble during synthesis.",get:n=>n.redundant_kmers,better:"lower"},{label:"Hairpin pairs",help:"Pairs of inverted repeats that can fold into stem-loops.",get:n=>n.hairpin_pairs,better:"lower"},{label:"Longest single-base run",help:"The longest stretch of one repeated base.",get:n=>n.longest_homopolymer,better:"lower"},{label:"Forbidden sites",help:"Matches to the enzymes and patterns you asked to keep out.",get:n=>n.forbidden_motif_hits,better:"lower"},{label:"Splice donor sites",help:"Windows that look like a 5\u2032 splice site.",get:n=>n.splice_donor_sites??0,better:"lower",show:()=>e},{label:"Splice acceptor sites",help:"Windows that look like a 3\u2032 splice site.",get:n=>n.splice_acceptor_sites??0,better:"lower",show:()=>e}]}function xe(t){return t.gc_windows_below_range+t.gc_windows_above_range+t.redundant_kmers+t.hairpin_pairs+t.homopolymer_excess_bases+t.forbidden_motif_hits+(t.splice_donor_sites??0)+(t.splice_acceptor_sites??0)}function Re(t){let e=t.gc.target,n=Ae(t.final).filter(o=>!o.show||o.show(t.final)).map(o=>{let a=o.get(t.initial),i=o.get(t.final),r=o.format??T,h;if(o.better==="target"){let p=Math.abs(i-e)-Math.abs(a-e);h=Math.abs(p)<5e-4?"same":p<0?"good":"bad"}else{let p=i-a;h=Math.abs(p)<1e-9?"same":p<0==(o.better==="lower")?"good":"bad"}let u={good:"better",bad:"worse",same:"no change"}[h],m={good:"\u2713",bad:"!",same:"\u2013"}[h];return s("tr",null,s("th",{attrs:{scope:"row",title:o.help}},o.label),s("td",{class:"num",text:r(a)}),s("td",{class:"num strong",text:r(i)}),s("td",{class:`verdict ${h}`},s("span",{attrs:{"aria-hidden":"true"}},`${m} `),u))});return s("table",{class:"compare"},s("thead",null,s("tr",null,s("th",{attrs:{scope:"col"}},"Property"),s("th",{class:"num",attrs:{scope:"col"}},"Before"),s("th",{class:"num",attrs:{scope:"col"}},"After"),s("th",{attrs:{scope:"col"}},"Change"))),s("tbody",null,...n))}var oe={gc_global:["Overall GC","Distance of the whole-sequence GC from the target."],gc_window:["Local GC","How far windows stray outside the allowed range."],repeat:["Repeats","Redundant copies of repeated stretches."],hairpin:["Hairpins","Inverted repeats that can fold back on themselves."],homopolymer:["Single-base runs","Bases beyond the longest run you allow."],motif:["Forbidden sites","Matches to enzymes and patterns you keep out."],splice:["Splice sites","How far splice-site scores exceed their cutoffs."],usage:["Codon usage","Cost of using codons the host dislikes."]};function qe(t){let e=t.score.initial_terms,n=t.score.final_terms,o=Object.keys(oe).filter(r=>(e[r]??0)>1e-9||(n[r]??0)>1e-9);if(!o.length)return s("p",{class:"hint",text:"No penalties at all: every constraint is met and codon usage is ideal."});let a=Math.max(...o.flatMap(r=>[e[r]??0,n[r]??0]),1e-9),i=(r,h)=>{let u=s("span",{class:`fill ${h}`});return u.style.width=`${Math.max(r>0?1.5:0,r/a*100)}%`,s("span",{class:"track"},u)};return s("div",{class:"terms"},...o.map(r=>s("div",{class:"term",title:oe[r][1]},s("span",{class:"term-name",text:oe[r][0]}),s("span",{class:"term-bars"},i(e[r]??0,"before"),i(n[r]??0,"after")),s("span",{class:"term-values mono",text:`${x(e[r]??0,2)} \u2192 ${x(n[r]??0,2)}`}))))}function K(t,e,...n){return s("section",{class:"card"},s("h3",{text:t}),e?s("p",{class:"hint",text:e}):null,...n)}function W(t,e,n,o){return s("div",{class:"stat"},s("span",{class:"stat-label",text:t}),s("span",{class:"stat-value"},s("span",{class:"was",text:e})," \u2192 ",s("strong",{text:n})),s("span",{class:"stat-note",text:o}))}function ye(t,e,n){let o=xe(e.initial),a=xe(e.final),i=e.score.initial>0?`${Math.round((1-e.score.final/e.score.initial)*100)}% lower score`:"score unchanged",r=He(e.name),h=e.cancelled?s("div",{class:"callout warn",role:"status"},"Stopped early. This is the best sequence found so far."):e.search.hit_time_limit?s("div",{class:"callout warn",role:"status"},"The time limit ended the search before it settled. Raise the limit or use a thorough search for more."):null,u=s("div",{class:"toolbar"},F("Copy DNA",m=>{be(e.sequence).then(p=>we(m,p?"Copied":"Copy failed"))},!0),F("Copy FASTA",m=>{be(e.fasta).then(p=>we(m,p?"Copied":"Copy failed"))}),F("Download FASTA",()=>ve(`${r}.codonopt.fna`,e.fasta,"text/plain")),F("Download report",()=>ve(`${r}.codonopt.json`,JSON.stringify(e,null,2)+` -`,"application/json")));k(t,s("div",{class:"result-head"},s("div",null,s("h2",{text:"Optimized sequence"}),s("p",{class:"hint",text:`${e.name} \xB7 ${e.protein.length.toLocaleString()} residues \xB7 ${e.codon_usage_table} codon usage`})),u),h,s("div",{class:"stats"},W("Issues found",T(o),T(a),a===0?"none left":"still to resolve"),W("Score",x(e.score.initial,2),x(e.score.final,2),i),W("GC content",J(e.initial.gc_fraction),J(e.final.gc_fraction),`target ${J(e.gc.target)}`),W("Codon adaptation",x(e.initial.cai,2),x(e.final.cai,2),"1.0 is ideal")),K("DNA sequence","The protein is identical to your input; only synonymous codons changed.",ge(e)),K("Before and after",null,Re(e)),K("GC content along the sequence",`Each point is a ${e.gc.window} base window.`,me(e.gc,e.sequence.length)),K("Where the score comes from","Lower is better. Each term is weighted as in your settings.",qe(e)),s("p",{class:"run-info hint"},`${e.search.restarts_run} ${e.search.restarts_run===1?"restart":"restarts"} \xB7 ${T(e.search.greedy_sweeps)} sweeps \xB7 ${T(e.search.moves_evaluated)} codon swaps tried \xB7 ${x(e.search.seconds,1)} s`)),t.focus({preventScroll:!0})}var Ne=350;async function Pe(){let t=document.querySelector('meta[name="codonopt-token"]')?.content??"",e=new I(t),n=c("output"),o=c("form"),a;try{a=await e.meta()}catch(l){H(n,l instanceof Error?l.message:String(l));return}de(a),B(a),o.removeAttribute("inert"),o.removeAttribute("aria-busy");let i=c("sequence"),r=c("detected"),h=c("run"),u=c("cancel"),m=c("add-stop-wrap"),p=null,L=!1,f=()=>{let l=ce(i.value,ee(),a.limits.max_residues,c("add-stop").checked);r.textContent=l.message,r.className=`detected ${l.state}`,m.hidden=l.kind==="dna",h.disabled=p!==null||L||l.state==="empty"};i.addEventListener("input",f),c("add-stop").addEventListener("change",f);for(let l of document.querySelectorAll('input[name="kind"]'))l.addEventListener("change",f);let b=8e6,d=async(l,g)=>{if(!l)return;if(l.size>b){H(n,`${l.name} is ${Math.round(l.size/1e6)} MB; open a file under 8 MB.`);return}let R;try{R=await l.text()}catch{H(n,`Could not read ${l.name||"that file"}. Open a text file, not a folder.`);return}g.value=R,g.dispatchEvent(new Event("input",{bubbles:!0}))},w=c("file-input");c("btn-open").addEventListener("click",()=>w.click()),w.addEventListener("change",()=>{d(w.files?.[0],i),w.value=""}),i.addEventListener("dragover",l=>l.preventDefault()),i.addEventListener("drop",l=>{l.preventDefault(),d(l.dataTransfer?.files[0],i)}),c("btn-example").addEventListener("click",()=>{i.value=`>${a.example.name} +"use strict";(()=>{var P=class extends Error{constructor(n,o){super(n);this.status=o;this.name="ApiError"}},Te="X-Codonopt-Token",I=class{constructor(e){this.token=e}meta(){return this.request("GET","/api/meta")}start(e){return this.request("POST","/api/jobs",e)}poll(e){return this.request("GET",`/api/jobs/${encodeURIComponent(e)}`)}cancel(e){return this.request("POST",`/api/jobs/${encodeURIComponent(e)}/cancel`)}async request(e,n,o){let a;try{a=await fetch(n,{method:e,headers:{[Te]:this.token,...o===void 0?{}:{"Content-Type":"application/json"}},body:o===void 0?void 0:JSON.stringify(o),cache:"no-store"})}catch{throw new P("Cannot reach the codonopt server. Was the window or terminal that started it closed?",0)}let i=null;try{i=await a.json()}catch{}if(!a.ok){let r=i&&typeof i=="object"&&"error"in i?String(i.error):`The server answered ${a.status}.`;throw new P(r,a.status)}if(i===null||typeof i!="object")throw new P(`The server answered ${a.status} but sent no readable data.`,a.status);return i}};var Le=new Set("ACDEFGHIKLMNPQRSTVWY*"),re=new Set("ACGTU");function ce(t,e,n,o=!1){let a=t.trim();if(!a)return{state:"empty",message:"Nothing pasted yet.",kind:null,length:0};let i=a;if(a.startsWith(">")){let v=a.split(/\r?\n/),d=v.filter(b=>b.startsWith(">")).length;if(d>1)return q(`${d} FASTA records found. Paste one sequence at a time.`);i=v.slice(1).join("")}let r=i.replace(/\s+/g,"").toUpperCase();if(!r)return q("The FASTA header has no sequence under it.");let m=r.length>=9&&[...r].every(v=>re.has(v));if((e==="auto"?m?"dna":"protein":e)==="dna"){let v=le([...r].filter(b=>!re.has(b)));if(v.length)return q(`Not DNA: ${v.join(", ")}`);if(r.length%3)return q(`DNA \xB7 ${r.length} bases is not a whole number of codons (${r.length%3} extra).`);let d=r.length/3;return d>n?q(`DNA \xB7 ${d.toLocaleString()} codons; this page takes up to ${n.toLocaleString()}.`):ie("dna",r.length,`DNA \xB7 ${r.length.toLocaleString()} bases, ${d} codons`)}let h=le([...r].filter(v=>!Le.has(v)));if(h.length)return q(`Not a protein letter: ${h.join(", ")}`);let u=r.indexOf("*");if(u!==-1&&u!==r.length-1)return q(`Protein \xB7 stop symbol (*) at position ${u+1}, before the end.`);let x=r.length+(o&&!r.endsWith("*")?1:0);if(x>n)return q(`Protein \xB7 ${x.toLocaleString()} residues${x>r.length?" with the stop codon":""}; this page takes up to ${n.toLocaleString()}.`);let f=r.length===1?"residue":"residues";return ie("protein",r.length,`Protein \xB7 ${r.length.toLocaleString()} ${f}`)}function ie(t,e,n){return{state:"ok",message:n,kind:t,length:e}}function q(t){return{state:"error",message:t,kind:null,length:0}}function le(t){return[...new Set(t)].sort()}function X(t,e){for(let n of e)n==null||n===!1||t.appendChild(typeof n=="object"?n:document.createTextNode(String(n)))}function s(t,e,...n){let o=document.createElement(t);if(e){let{attrs:a,on:i,text:r,...m}=e;for(let[p,h]of Object.entries(m))h===void 0||h===!1||(p==="class"?o.className=String(h):typeof h=="boolean"?o.setAttribute(p,""):o.setAttribute(p,String(h)));for(let[p,h]of Object.entries(a??{}))o.setAttribute(p,h);if(r!==void 0&&(o.textContent=r),i)for(let[p,h]of Object.entries(i))o.addEventListener(p,h)}return X(o,n),o}var _e="http://www.w3.org/2000/svg";function M(t,e,...n){let o=document.createElementNS(_e,t);for(let[a,i]of Object.entries(e??{}))o.setAttribute(a,String(i));return X(o,n),o}function c(t){let e=document.getElementById(t);if(!e)throw new Error(`Missing #${t} in the page`);return e}function k(t,...e){t.replaceChildren(),X(t,e)}var y=(t,e=2)=>Number.isFinite(t)?t.toFixed(e):"\u2013",T=t=>Math.round(t).toLocaleString("en-US");var Se={gc_global:"Overall GC",gc_window:"Local GC",repeat:"Repeats",hairpin:"Hairpins",homopolymer:"Single-base runs",motif:"Forbidden sites",splice:"Splice sites",usage:"Codon usage"},ke={golden_gate:"Golden Gate",common_cloning:"Common cloning sites",regulatory:"Poly(A) and TATA signals"},$e={auto:"Automatic",maxent:"MaxEntScan",motif:"Consensus screen",none:"Off"},L=t=>c(t);function de(t){c("version").textContent=`v${t.version}`;let e=c("table");k(e,...t.tables.map(n=>s("option",{value:n.name,text:n.name})),s("option",{value:"custom",text:"Custom table\u2026"})),k(c("avoid-groups"),...Object.entries(t.motif_groups).filter(([,n])=>n.length).map(([n,o])=>s("label",{class:"chip-check",title:o.join(", ")},s("input",{type:"checkbox",value:n,attrs:{name:"avoid"}}),s("span",{text:ke[n]??n})))),k(c("splice"),...t.splice_backends.map(n=>s("option",{value:n,text:$e[n]??n}))),k(c("weights"),...Object.keys(t.weights).map(n=>s("label",{class:"field"},s("span",{text:Se[n]??n}),s("input",{type:"number",id:`weight-${n}`,attrs:{min:"0",step:"any",inputmode:"decimal","data-weight":n}})))),Y(t)}function Y(t){let e=t.defaults;c("table").value=t.default_table,c("table-text").value="",Q();for(let n of document.querySelectorAll('#avoid-groups input[type="checkbox"]'))n.checked=!1;c("avoid-extra").value="",L("gc-target").value=String(e.gc_target),L("gc-low").value=String(e.gc_range[0]),L("gc-high").value=String(e.gc_range[1]),L("max-homopolymer").value=String(e.max_homopolymer),c("splice").value="none",c("effort-standard").checked=!0,L("seconds").value=String(e.seconds),L("seed").value="",L("gc-window").value=String(e.gc_window),L("gc-step").value=String(e.gc_step),L("repeat-k").value=String(e.repeat_k),L("hairpin-stem").value=String(e.hairpin_stem),L("hairpin-min-loop").value=String(e.hairpin_min_loop),L("hairpin-max-loop").value=String(e.hairpin_max_loop);for(let[n,o]of Object.entries(t.weights))L(`weight-${n}`).value=String(o);Z(t),G(t)}function Q(){let t=c("table").value==="custom";c("custom-table").hidden=!t}function ue(){return c("table").value!=="custom"||c("table-text").value.trim()?null:"Paste or open a codon usage table, or pick a built-in host instead of \u201CCustom table\u2026\u201D."}function B(t){let e=c("table").value;c("table-hint").textContent=e==="custom"?"":t.tables.find(n=>n.name===e)?.description??""}function Z(t){let e=pe(),n=t.effort[e];if(!n)return;let o=[`${n.max_sweeps} greedy passes`];n.restarts>1&&o.push(`${n.restarts} independent restarts`),n.anneal_steps&&o.push(`${n.anneal_steps.toLocaleString()} annealing steps each`),c("effort-hint").textContent=`${o.join(", ")}.`}function G(t){let e=c("splice").value,n=c("splice-hint");e==="maxent"&&!t.maxent_available?n.textContent="MaxEntScan is not installed, so this will report an error. Pick Automatic or Consensus screen.":e==="auto"?n.textContent=c("table").value!=="human"?"Automatic splice screening is only enabled for the bundled human host. Other hosts need an explicit backend.":t.maxent_available?"Uses MaxEntScan.":"MaxEntScan is not installed, so a simpler consensus screen is used.":e==="motif"?n.textContent="A fast consensus screen. It flags likely sites rather than modelling splicing.":e==="none"?n.textContent="Splice sites are ignored. Right for sequences that will not be spliced, and faster.":n.textContent=""}function pe(){return document.querySelector('input[name="effort"]:checked')?.value??"standard"}function ee(){return document.querySelector('input[name="kind"]:checked')?.value??"auto"}function S(t){let e=L(t).value.trim();if(e==="")return;let n=Number(e);return Number.isFinite(n)?n:e}function me(){let t=[...document.querySelectorAll("#avoid-groups input:checked")].map(a=>a.value);t.push(...c("avoid-extra").value.split(/[,\n]/).map(a=>a.trim()).filter(Boolean));let e={};for(let a of document.querySelectorAll("#weights input")){let i=a.value.trim();i!==""&&(e[a.dataset.weight??""]=Number.isFinite(Number(i))?Number(i):i)}let n=c("table").value,o=ee();return{sequence:c("sequence").value,kind:o,add_stop:c("add-stop").checked,table:n==="custom"?void 0:n,table_text:n==="custom"?c("table-text").value:void 0,avoid:t,gc_target:S("gc-target"),gc_range:[S("gc-low"),S("gc-high")],max_homopolymer:S("max-homopolymer"),splice_backend:c("splice").value,effort:pe(),seconds:S("seconds"),seed:S("seed"),gc_window:S("gc-window"),gc_step:S("gc-step"),repeat_k:S("repeat-k"),hairpin_stem:S("hairpin-stem"),hairpin_min_loop:S("hairpin-min-loop"),hairpin_max_loop:S("hairpin-max-loop"),weights:e}}var O=720,ne=236,w={left:46,right:14,top:12,bottom:34},D=O-w.left-w.right,F=ne-w.top-w.bottom,te=[50,100,200,250,500,1e3,2e3,2500,5e3,1e4,2e4,5e4];function Ce(t){return te.find(e=>t/e<=8)??te[te.length-1]}function he(t,e){let n=Math.max(e,1),o=d=>w.left+d/n*D,a=d=>w.top+(1-d)*F,i=d=>Math.min(d+t.window/2,n),r=d=>d.map((b,_)=>`${_?"L":"M"}${o(i(t.starts[_])).toFixed(1)} ${a(b).toFixed(1)}`).join(""),m=M("svg",{viewBox:`0 0 ${O} ${ne}`,role:"img",class:"chart","aria-label":`GC content in ${t.window} base windows along the sequence, before and after optimisation, against the allowed range of ${y(t.range[0]*100,0)} to ${y(t.range[1]*100,0)} percent.`});m.append(M("rect",{class:"band",x:w.left,y:a(t.range[1]),width:D,height:Math.max(0,a(t.range[0])-a(t.range[1]))}));for(let d of[0,.25,.5,.75,1])m.append(M("line",{class:"grid",x1:w.left,x2:O-w.right,y1:a(d),y2:a(d)}),M("text",{class:"tick",x:w.left-8,y:a(d)+4,"text-anchor":"end"},`${d*100}%`));let p=Ce(n);for(let d=0;d<=n;d+=p)m.append(M("line",{class:"grid faint",x1:o(d),x2:o(d),y1:w.top,y2:w.top+F}),M("text",{class:"tick",x:o(d),y:ne-12,"text-anchor":"middle"},T(d)));m.append(M("line",{class:"target",x1:w.left,x2:O-w.right,y1:a(t.target),y2:a(t.target)})),t.starts.length>1?m.append(M("path",{class:"line before",d:r(t.initial)}),M("path",{class:"line after",d:r(t.final)})):t.starts.length===1&&m.append(M("circle",{class:"dot before",cx:o(i(t.starts[0])),cy:a(t.initial[0]),r:4}),M("circle",{class:"dot after",cx:o(i(t.starts[0])),cy:a(t.final[0]),r:4}));let h=M("line",{class:"cursor",x1:0,x2:0,y1:w.top,y2:w.top+F});h.setAttribute("visibility","hidden");let u=M("rect",{class:"overlay",x:w.left,y:w.top,width:D,height:F});m.append(h,u);let x="Hover the chart to read a window.",f=s("p",{class:"readout",text:x});u.addEventListener("pointermove",d=>{let b=m.getBoundingClientRect(),A=((d.clientX-b.left)/b.width*O-w.left)/D*n,E=0;for(let N=1;N{h.setAttribute("visibility","hidden"),f.textContent=x});let v=s("ul",{class:"legend"},s("li",null,s("span",{class:"swatch before"}),"Before"),s("li",null,s("span",{class:"swatch after"}),"After"),s("li",null,s("span",{class:"swatch band"}),"Allowed range"),s("li",null,s("span",{class:"swatch target"}),`Target ${y(t.target*100,0)}%`));return s("figure",{class:"figure"},m,v,f)}var fe=20;function ge(t){let e=t.sequence,n=t.initial_sequence,o=e.length/3,a=new Set;for(let f of t.motif_hits)for(let v of f.positions){let d=v-t.final.cds_start;for(let b=Math.max(0,Math.floor(d/3));b<=Math.min(o-1,Math.floor((d+f.length-1)/3));b++)a.add(b)}let i=0,r=[],m=String(e.length).length;for(let f=0;fs("label",{class:"check"},s("input",{type:"checkbox",checked:d,on:{change:b=>p.classList.toggle(v,b.target.checked)}}),` ${f}`);!t.motif_hits.length&&!i&&p.classList.remove("show-changes");let u=i===0?"No codon needed to change.":`${T(i)} of ${T(o)} codons changed (${Math.round(i/o*100)}%).`,x=t.motif_hits.length?s("p",{class:"note warn",role:"status"},`Still contains ${t.motif_hits.map(f=>`${f.name} (${f.positions.length})`).join(", ")}; matches are in the scored construct; overlapping CDS codons are marked.`):null;return s("div",{class:"seq-wrap"},s("div",{class:"seq-tools"},s("p",{class:"hint",text:u}),s("div",{class:"toggles"},h("Highlight changes","show-changes",p.classList.contains("show-changes")),h("Show protein","show-aa",!0))),x,p)}async function be(t){try{return await navigator.clipboard.writeText(t),!0}catch{let e=s("textarea",{attrs:{"aria-hidden":"true",tabindex:"-1"}});e.value=t,document.body.append(e),e.select();let n=!1;try{n=document.execCommand("copy")}catch{n=!1}return e.remove(),n}}function ve(t,e,n){let o=URL.createObjectURL(new Blob([e],{type:n})),a=s("a",{attrs:{href:o,download:t}});document.body.append(a),a.click(),a.remove(),setTimeout(()=>URL.revokeObjectURL(o),1e4)}function K(t,e,n=!1){let o=s("button",{type:"button",class:n?"chip primary-chip":"chip",text:t,on:{click:()=>e(o)}});return o}var se=new WeakMap;function we(t,e){let n=se.get(t);n!==void 0&&window.clearTimeout(n),t.dataset.label===void 0&&(t.dataset.label=t.textContent??""),t.textContent=e,se.set(t,window.setTimeout(()=>{t.textContent=t.dataset.label??"",se.delete(t)},1600))}var He=t=>t.replace(/[^\w.-]+/g,"_")||"sequence";function H(t,e){k(t,s("div",{class:"callout error",role:"alert"},s("strong",{text:"That did not work"}),s("p",{text:e}))),t.focus({preventScroll:!0})}var z=class{constructor(e){this.bar=s("div",{class:"bar-fill"});this.elapsed=s("span",{class:"mono"});this.detail=s("span",{class:"hint"});this.log=s("ol",{class:"log mono",attrs:{"aria-label":"Search log"}});this.trouble=s("p",{class:"note warn",role:"alert"});this.lastLog="";this.trouble.hidden=!0,k(e,s("div",{class:"card progress"},s("h2",{text:"Optimizing"}),s("p",{class:"hint",text:"Resolving mandatory requirements, then improving synonymous codon choices."}),s("div",{class:"bar",role:"progressbar",attrs:{"aria-label":"Optimization progress"}},this.bar),s("div",{class:"progress-meta"},this.elapsed,this.detail),this.trouble,this.log)),e.focus({preventScroll:!0})}note(e){this.trouble.textContent=e,this.trouble.hidden=!1}update(e){this.elapsed.textContent=`${y(e.elapsed,1)} s of at most ${T(e.max_seconds)} s`;let n=this.bar.parentElement;if(e.restarts_total>1){let a=Math.min(e.restarts_done,e.restarts_total);this.detail.textContent=`Restart ${Math.min(a+1,e.restarts_total)} of ${e.restarts_total}`,this.bar.style.width=`${a/e.restarts_total*100}%`,n?.classList.remove("indeterminate"),n?.setAttribute("aria-valuemin","0"),n?.setAttribute("aria-valuemax",String(e.restarts_total)),n?.setAttribute("aria-valuenow",String(a))}else this.detail.textContent="Stops as soon as nothing improves.",n?.classList.add("indeterminate"),n?.removeAttribute("aria-valuenow");let o=e.messages.join(` +`);o!==this.lastLog&&(this.lastLog=o,k(this.log,...e.messages.map(a=>s("li",{text:a}))))}},J=t=>`${y(t*100,1)}%`;function Ae(t){let e=t.splice_backend!==void 0;return[{label:"GC content",help:"Share of G and C bases over the whole sequence.",get:n=>n.gc_fraction,format:J,better:"target"},{label:"GC windows out of range",help:"Windows whose local GC falls outside the allowed range.",get:n=>n.gc_windows_below_range+n.gc_windows_above_range,better:"lower"},{label:"Codon adaptation index",help:"How closely the codons follow the host's preferences (1 is ideal).",get:n=>n.cai,format:n=>y(n,3),better:"higher"},{label:`Repeated ${t.repeat_k}-mers`,help:"Extra copies of any stretch that appears more than once. Repeats misassemble during synthesis.",get:n=>n.redundant_kmers,better:"lower"},{label:"Hairpin pairs",help:"Pairs of inverted repeats that can fold into stem-loops.",get:n=>n.hairpin_pairs,better:"lower"},{label:"Longest single-base run",help:"The longest stretch of one repeated base.",get:n=>n.longest_homopolymer,better:"lower"},{label:"Forbidden sites",help:"Matches to the enzymes and patterns you asked to keep out.",get:n=>n.forbidden_motif_hits,better:"lower"},{label:"Splice donor sites",help:"Windows that look like a 5\u2032 splice site.",get:n=>n.splice_donor_sites??0,better:"lower",show:()=>e},{label:"Splice acceptor sites",help:"Windows that look like a 3\u2032 splice site.",get:n=>n.splice_acceptor_sites??0,better:"lower",show:()=>e}]}function xe(t){return t.gc_windows_below_range+t.gc_windows_above_range+t.redundant_kmers+t.hairpin_pairs+t.homopolymer_excess_bases+t.forbidden_motif_hits+(t.splice_donor_sites??0)+(t.splice_acceptor_sites??0)}function Re(t){let e=t.gc.target,n=Ae(t.final).filter(o=>!o.show||o.show(t.final)).map(o=>{let a=o.get(t.initial),i=o.get(t.final),r=o.format??T,m;if(o.better==="target"){let u=Math.abs(i-e)-Math.abs(a-e);m=Math.abs(u)<5e-4?"same":u<0?"good":"bad"}else{let u=i-a;m=Math.abs(u)<1e-9?"same":u<0==(o.better==="lower")?"good":"bad"}let p={good:"better",bad:"worse",same:"no change"}[m],h={good:"\u2713",bad:"!",same:"\u2013"}[m];return s("tr",null,s("th",{attrs:{scope:"row",title:o.help}},o.label),s("td",{class:"num",text:r(a)}),s("td",{class:"num strong",text:r(i)}),s("td",{class:`verdict ${m}`},s("span",{attrs:{"aria-hidden":"true"}},`${h} `),p))});return s("table",{class:"compare"},s("thead",null,s("tr",null,s("th",{attrs:{scope:"col"}},"Property"),s("th",{class:"num",attrs:{scope:"col"}},"Before"),s("th",{class:"num",attrs:{scope:"col"}},"After"),s("th",{attrs:{scope:"col"}},"Change"))),s("tbody",null,...n))}var oe={gc_global:["Overall GC","Distance of the whole-sequence GC from the target."],gc_window:["Local GC","How far windows stray outside the allowed range."],repeat:["Repeats","Redundant copies of repeated stretches."],hairpin:["Hairpins","Inverted repeats that can fold back on themselves."],homopolymer:["Single-base runs","Bases beyond the longest run you allow."],motif:["Forbidden sites","Matches to enzymes and patterns you keep out."],splice:["Splice sites","How far splice-site scores exceed their cutoffs."],usage:["Codon usage","Cost of using codons the host dislikes."]};function qe(t){let e=t.score.initial_terms,n=t.score.final_terms,o=Object.keys(oe).filter(r=>(e[r]??0)>1e-9||(n[r]??0)>1e-9);if(!o.length)return s("p",{class:"hint",text:"No active soft penalties. Mandatory requirements are checked separately."});let a=Math.max(...o.flatMap(r=>[e[r]??0,n[r]??0]),1e-9),i=(r,m)=>{let p=s("span",{class:`fill ${m}`});return p.style.width=`${Math.max(r>0?1.5:0,r/a*100)}%`,s("span",{class:"track"},p)};return s("div",{class:"terms"},...o.map(r=>s("div",{class:"term",title:oe[r][1]},s("span",{class:"term-name",text:oe[r][0]}),s("span",{class:"term-bars"},i(e[r]??0,"before"),i(n[r]??0,"after")),s("span",{class:"term-values mono",text:`${y(e[r]??0,2)} \u2192 ${y(n[r]??0,2)}`}))))}function W(t,e,...n){return s("section",{class:"card"},s("h3",{text:t}),e?s("p",{class:"hint",text:e}):null,...n)}function j(t,e,n,o){return s("div",{class:"stat"},s("span",{class:"stat-label",text:t}),s("span",{class:"stat-value"},s("span",{class:"was",text:e})," \u2192 ",s("strong",{text:n})),s("span",{class:"stat-note",text:o}))}function ye(t,e,n){let o=xe(e.initial),a=xe(e.final),i=e.score.initial>0?`${Math.round((1-e.score.final/e.score.initial)*100)}% lower score`:"score unchanged",r=He(e.name),m=e.cancelled?s("div",{class:"callout warn",role:"status"},"Stopped early. This is the best sequence found so far."):e.search.hit_time_limit?s("div",{class:"callout warn",role:"status"},"The time limit ended the search before it settled. Raise the limit or use a thorough search for more."):null,p=e.valid?null:s("div",{class:"callout warn",role:"alert"},"Mandatory requirements remain unresolved. This candidate is not a valid design: "+e.unresolved_constraints.map(u=>`${u.criterion} (${u.count})`).join(", ")+"."),h=s("div",{class:"toolbar"},K("Copy DNA",u=>{be(e.sequence).then(x=>we(u,x?"Copied":"Copy failed"))},!0),K("Copy FASTA",u=>{be(e.fasta).then(x=>we(u,x?"Copied":"Copy failed"))}),K("Download FASTA",()=>ve(`${r}.codonopt.fna`,e.fasta,"text/plain")),K("Download report",()=>ve(`${r}.codonopt.json`,JSON.stringify(e,null,2)+` +`,"application/json")));k(t,s("div",{class:"result-head"},s("div",null,s("h2",{text:e.valid?"Optimized sequence":"Unresolved candidate"}),s("p",{class:"hint",text:`${e.name} \xB7 ${e.protein.length.toLocaleString()} residues \xB7 ${e.codon_usage_table} codon usage`})),h),m,p,s("div",{class:"stats"},j("Issues found",T(o),T(a),a===0?"none left":"still to resolve"),j("Score",y(e.score.initial,2),y(e.score.final,2),i),j("GC content",J(e.initial.gc_fraction),J(e.final.gc_fraction),`target ${J(e.gc.target)}`),j("Codon adaptation",y(e.initial.cai,2),y(e.final.cai,2),"1.0 is ideal")),W("DNA sequence","The protein is identical to your input; only synonymous codons changed.",ge(e)),W("Before and after",null,Re(e)),W("GC content along the construct",`Each point is a ${e.gc.window} base window, including any fixed flanks.`,he(e.gc,e.context_sequence.length)),W("Where the score comes from","Lower is better. Each term is weighted as in your settings.",qe(e)),s("p",{class:"run-info hint"},`${e.search.restarts_run} ${e.search.restarts_run===1?"restart":"restarts"} \xB7 ${T(e.search.greedy_sweeps)} sweeps \xB7 ${T(e.search.moves_evaluated)} codon swaps tried \xB7 ${y(e.search.seconds,1)} s`)),t.focus({preventScroll:!0})}var Ne=350;async function Pe(){let t=document.querySelector('meta[name="codonopt-token"]')?.content??"",e=new I(t),n=c("output"),o=c("form"),a;try{a=await e.meta()}catch(l){H(n,l instanceof Error?l.message:String(l));return}de(a),B(a),o.removeAttribute("inert"),o.removeAttribute("aria-busy");let i=c("sequence"),r=c("detected"),m=c("run"),p=c("cancel"),h=c("add-stop-wrap"),u=null,x=!1,f=()=>{let l=ce(i.value,ee(),a.limits.max_residues,c("add-stop").checked);r.textContent=l.message,r.className=`detected ${l.state}`,h.hidden=l.kind==="dna",m.disabled=u!==null||x||l.state==="empty"};i.addEventListener("input",f),c("add-stop").addEventListener("change",f);for(let l of document.querySelectorAll('input[name="kind"]'))l.addEventListener("change",f);let v=8e6,d=async(l,g)=>{if(!l)return;if(l.size>v){H(n,`${l.name} is ${Math.round(l.size/1e6)} MB; open a file under 8 MB.`);return}let R;try{R=await l.text()}catch{H(n,`Could not read ${l.name||"that file"}. Open a text file, not a folder.`);return}g.value=R,g.dispatchEvent(new Event("input",{bubbles:!0}))},b=c("file-input");c("btn-open").addEventListener("click",()=>b.click()),b.addEventListener("change",()=>{d(b.files?.[0],i),b.value=""}),i.addEventListener("dragover",l=>l.preventDefault()),i.addEventListener("drop",l=>{l.preventDefault(),d(l.dataTransfer?.files[0],i)}),c("btn-example").addEventListener("click",()=>{i.value=`>${a.example.name} ${a.example.protein} -`,c("kind-auto").checked=!0,f(),i.focus()}),c("btn-clear").addEventListener("click",()=>{i.value="",f(),i.focus()}),c("table").addEventListener("change",()=>{Y(),B(a)});let A=c("table-text"),y=c("table-file");c("btn-open-table").addEventListener("click",()=>y.click()),y.addEventListener("change",()=>{d(y.files?.[0],A),y.value=""}),c("splice").addEventListener("change",()=>Z(a));for(let l of document.querySelectorAll('input[name="effort"]'))l.addEventListener("change",()=>Q(a));c("btn-reset").addEventListener("click",()=>{X(a),B(a)});let $=l=>{u.hidden=!l,u.disabled=!1,u.textContent="Stop",h.hidden=l,f()},C=l=>{c("status").textContent=l},N=l=>{window.clearInterval(l.timer),p===l&&(p=null,$(!1))},Ee=(l,g)=>{let R=p===l;N(l),R&&(g.status==="error"?(C("The optimization failed."),H(n,g.error??"The optimization failed.")):g.result&&(C(g.status==="cancelled"?"Stopped early; showing the best sequence found.":"Optimization finished."),ye(n,g.result,a)))},Te=async l=>{if(p===l)try{let g=await e.poll(l.id);if(p!==l)return;g.status==="running"?l.progress.update(g):Ee(l,g)}catch(g){if(p!==l)return;N(l),H(n,g instanceof Error?g.message:String(g))}};o.addEventListener("submit",async l=>{if(l.preventDefault(),p||L||h.disabled)return;let g=ue();if(g){H(n,g);return}L=!0,h.disabled=!0;let R;try{R=await e.start(he())}catch(U){H(n,U instanceof P?U.message:String(U));return}finally{L=!1,f()}C("Optimizing.");let ae=new j(n);ae.update(R);let z={id:R.id,timer:0,progress:ae};p=z,$(!0),z.timer=window.setInterval(()=>{Te(z)},Ne)}),u.addEventListener("click",async()=>{let l=p;if(l){u.disabled=!0,u.textContent="Stopping\u2026";try{await e.cancel(l.id)}catch(g){if(p!==l)return;u.disabled=!1,u.textContent="Stop",l.progress.note(`${g instanceof Error?g.message:String(g)} The run is still going; try Stop again.`)}}}),f()}Pe().catch(t=>{let e=document.getElementById("output"),n=t instanceof Error?t.message:String(t);e?H(e,`The interface failed to start: ${n}`):document.title="codonopt - failed to start",console.error(t)});})(); +`,c("kind-auto").checked=!0,f(),i.focus()}),c("btn-clear").addEventListener("click",()=>{i.value="",f(),i.focus()}),c("table").addEventListener("change",()=>{Q(),B(a),G(a)});let A=c("table-text"),E=c("table-file");c("btn-open-table").addEventListener("click",()=>E.click()),E.addEventListener("change",()=>{d(E.files?.[0],A),E.value=""}),c("splice").addEventListener("change",()=>G(a));for(let l of document.querySelectorAll('input[name="effort"]'))l.addEventListener("change",()=>Z(a));c("btn-reset").addEventListener("click",()=>{Y(a),B(a)});let $=l=>{p.hidden=!l,p.disabled=!1,p.textContent="Stop",m.hidden=l,f()},C=l=>{c("status").textContent=l},N=l=>{window.clearInterval(l.timer),u===l&&(u=null,$(!1))},Ee=(l,g)=>{let R=u===l;N(l),R&&(g.status==="error"?(C("The optimization failed."),H(n,g.error??"The optimization failed.")):g.result&&(C(g.result.valid?g.status==="cancelled"?"Stopped early; showing the best sequence found.":"Optimization finished.":"Finished with unresolved mandatory requirements."),ye(n,g.result,a)))},Me=async l=>{if(u===l)try{let g=await e.poll(l.id);if(u!==l)return;g.status==="running"?l.progress.update(g):Ee(l,g)}catch(g){if(u!==l)return;N(l),H(n,g instanceof Error?g.message:String(g))}};o.addEventListener("submit",async l=>{if(l.preventDefault(),u||x||m.disabled)return;let g=ue();if(g){H(n,g);return}x=!0,m.disabled=!0;let R;try{R=await e.start(me())}catch(V){H(n,V instanceof P?V.message:String(V));return}finally{x=!1,f()}C("Optimizing.");let ae=new z(n);ae.update(R);let U={id:R.id,timer:0,progress:ae};u=U,$(!0),U.timer=window.setInterval(()=>{Me(U)},Ne)}),p.addEventListener("click",async()=>{let l=u;if(l){p.disabled=!0,p.textContent="Stopping\u2026";try{await e.cancel(l.id)}catch(g){if(u!==l)return;p.disabled=!1,p.textContent="Stop",l.progress.note(`${g instanceof Error?g.message:String(g)} The run is still going; try Stop again.`)}}}),f()}Pe().catch(t=>{let e=document.getElementById("output"),n=t instanceof Error?t.message:String(t);e?H(e,`The interface failed to start: ${n}`):document.title="codonopt - failed to start",console.error(t)});})(); diff --git a/tests/test_constraints.py b/tests/test_constraints.py new file mode 100644 index 0000000..89735fb --- /dev/null +++ b/tests/test_constraints.py @@ -0,0 +1,266 @@ +"""Regression cases from the algorithm review, including independent oracles.""" + +import itertools +import json +import math +import random +import importlib + +import pytest + +from codonopt import (DesignConfig, OptimizerSettings, Weights, Motif, SequenceState, + constraint_violations, optimize, optimize_dna, get_table, + resolve_motifs, report_dict, measure, translate) +from codonopt.cli import main +from codonopt.sequence import reverse_complement +from codonopt.splice import MotifScorer, get_scorer +from codonopt.tables import CodonUsage + + +def only(**terms): + return Weights(**{name: terms.get(name, 0) for name in vars(Weights())}) + + +def test_coordinated_repair_reaches_exhaustive_feasible_optimum(): + config = DesignConfig(table=get_table("uniform"), weights=only(motif=1), + motifs=tuple(resolve_motifs(["GAATTC", "GAGTTC", "GAATTT"]))) + result = optimize_dna("GAATTC", config, OptimizerSettings(seed=1, max_seconds=None)) + candidates = [a+b for a, b in itertools.product(("GAA", "GAG"), ("TTT", "TTC"))] + feasible = [s for s in candidates if not constraint_violations(s, config)] + assert result.valid + assert result.sequence == feasible[0] == "GAGTTT" + assert result.score == min(SequenceState(s, config).score for s in candidates) + assert result.repair_evaluated > 0 + + +def test_validity_outranks_score_and_zero_weight_does_not_disable_a_requirement(): + config = DesignConfig(motifs=tuple(resolve_motifs(["TTC"])), weights=only(usage=1000)) + result = optimize_dna("TTC", config, OptimizerSettings(seed=1)) + assert result.valid and result.sequence == "TTT" + assert result.score > result.initial_score + assert not result.unresolved_constraints + + +def test_unavoidable_constraint_is_unresolved_not_declared_impossible(): + config = DesignConfig(motifs=(Motif("start", "ATG"),)) + result = optimize("MW", config, OptimizerSettings(seed=1)) + assert not result.valid + assert result.unresolved_constraints[0]["criterion"] == "motif" + assert result.unresolved_constraints[0]["count"] == 1 + assert result.stop_reason == "completed" + assert "not proof" in result.notes[0] + + +def test_cli_marks_unresolved_output_even_when_quiet(capsys): + assert main(["optimize", "--protein", "MW", "--avoid", "ATG", "--quiet"]) == 2 + output = capsys.readouterr() + assert "valid=false" in output.out + assert "unresolved" in output.err + + +def test_gc_has_the_same_per_base_tradeoff_across_lengths(): + config = DesignConfig(weights=only(gc_global=50, usage=.1)) + for length in (100, 1000): + result = optimize("C" * length, config, OptimizerSettings(seed=1, max_seconds=None)) + assert measure(result.sequence, config)["gc_fraction"] == .5 + + +def test_flanks_are_scored_and_immutable_with_an_unaligned_cds(): + config = DesignConfig(left_flank="GGT", right_flank="A", + motifs=tuple(resolve_motifs(["BsaI"]))) + result = optimize_dna("CTC", config, OptimizerSettings(seed=1)) + assert result.valid + assert translate(result.sequence) == "L" + assert result.initial_sequence == "CTC" + assert measure(result.initial_sequence, config)["forbidden_motif_hits"] == 1 + assert measure(result.sequence, config)["forbidden_motif_hits"] == 0 + assert report_dict(result, config)["context_sequence"] == "GGT" + result.sequence + "A" + + +def test_fixed_only_violations_are_reported_without_editing_flanks(): + config = DesignConfig(left_flank="GAATTC", motifs=tuple(resolve_motifs(["EcoRI"]))) + result = optimize("M", config, OptimizerSettings(seed=1)) + assert result.sequence == "ATG" and not result.valid + assert result.unresolved_constraints[0]["regions"][0]["start"] == 0 + + +def test_protected_codons_survive_restarts_and_annealing(): + config = DesignConfig(protected_codons=(0, 2)) + result = optimize_dna("TTTCTATTT", config, + OptimizerSettings(seed=0, restarts=3, anneal_steps=100)) + assert result.sequence[:3] == result.sequence[-3:] == "TTT" + assert result.initial_sequence == "TTTCTATTT" + with pytest.raises(ValueError, match="require a starting"): + optimize("FLF", config) + + +def test_original_baseline_is_preserved_when_a_later_restart_wins(): + protein = "M" + "".join(random.Random(41).choices("ACDEFGHIKLMNPQRSTVWY", k=59)) + dna = "".join(get_table("human").most_frequent(a) for a in protein) + config = DesignConfig() + result = optimize_dna(dna, config, OptimizerSettings(seed=0, restarts=3, max_sweeps=3)) + assert result.initial_sequence == dna + assert result.initial_score == pytest.approx(SequenceState(dna, config).score) + assert report_dict(result, config)["initial_sequence"] == dna + assert translate(result.winning_seed_sequence) == protein + + +def test_longest_hairpin_comes_from_the_complete_scan(): + stem = "".join(random.Random(0).choices("ACGT", k=28)) + unit = "ACGTTGCACC" + "TTTTT" + reverse_complement("ACGTTGCACC") + "AAA" + sequence = unit*12 + "AAA" + stem + "AAA" + reverse_complement(stem) + sequence += "A" * (-len(sequence) % 3) + assert measure(sequence, DesignConfig())["longest_hairpin_stem"] >= 28 + + +def test_annealing_schedule_cools_on_noop_draws(monkeypatch): + module = importlib.import_module("codonopt.optimize") + state = SequenceState("AAA", DesignConfig(weights=only(motif=1), required=(), + motifs=(Motif("uphill", "AAG"),))) + class Rng: + draws = 0 + def choice(self, options): + if options == [0]: + return 0 + self.draws += 1 + return "AAA" if self.draws % 2 else "AAG" + def random(self): + return 1 + exponents = [] + original_exp = math.exp + def capture(x): + if x < -1: + exponents.append(x) + return original_exp(x) + monkeypatch.setattr(module.math, "exp", capture) + module._anneal(state, Rng(), OptimizerSettings(anneal_steps=10, start_temperature=2, + end_temperature=.05), lambda: False) + assert exponents[-1] == pytest.approx(-20) + + +def test_feasible_annealing_never_reintroduces_required_motifs(): + config = DesignConfig(motifs=(Motif("bad", "AAA"),), weights=only()) + result = optimize_dna("AAGAAG", config, + OptimizerSettings(seed=8, anneal_steps=500, start_temperature=1e4)) + assert result.valid and not constraint_violations(result.sequence, config) + + +def test_splice_checks_expressed_strand_by_default_and_auto_is_host_aware(): + config = DesignConfig(splice_scorer=MotifScorer(), weights=only(splice=1)) + assert SequenceState("ACTTACCTG", config).score == 0 + assert SequenceState("ACTTACCTG", config.with_(splice_strand="both")).score == 3 + assert get_scorer("auto", host="ecoli") is None + assert get_scorer("auto", host="custom") is None + assert get_scorer("motif", host="ecoli") is not None # explicit override + + +@pytest.mark.parametrize("value", [float("nan"), float("inf"), -float("inf")]) +def test_nonfinite_core_values_are_rejected(value): + for make in (lambda: Weights(usage=value), + lambda: OptimizerSettings(start_temperature=value), + lambda: OptimizerSettings(max_seconds=value), + lambda: DesignConfig(donor_cutoff=value), + lambda: CodonUsage.from_frequencies("bad", {"TTT": value})): + with pytest.raises(ValueError): + make() + + +def test_motifs_are_canonicalized_and_conflicting_names_are_rejected(): + config = DesignConfig(motifs=(Motif("one", "GAATTC"), Motif("two", "GAATTC"))) + state = SequenceState("GAATTC", config) + assert state.breakdown().total == pytest.approx(state.score) + assert len(config.motifs) == 1 + with pytest.raises(ValueError, match="different patterns"): + resolve_motifs(["same=GAATTC", "same=GGATCC"]) + with pytest.raises(ValueError, match="different patterns"): + DesignConfig(motifs=(Motif("same", "GAATTC"), Motif("same", "GGATCC"))) + + +def test_excluded_codons_are_hard_requirements_even_with_one_alternative(): + config = DesignConfig(excluded_codons=("TTT",), weights=only()) + result = optimize_dna("TTT", config, OptimizerSettings(seed=1, restarts=2)) + assert result.sequence == "TTC" and result.valid + with pytest.raises(ValueError, match="all codons"): + optimize("F", config.with_(excluded_codons=("TTT", "TTC"))) + locked = optimize_dna("TTT", config.with_(protected_codons=(0,)), + OptimizerSettings(seed=1, restarts=2)) + assert not locked.valid and locked.sequence == "TTT" + + +def test_saved_record_can_replay_a_generated_seed(): + config = DesignConfig(motifs=tuple(resolve_motifs(["EcoRI"])), left_flank="G") + settings = OptimizerSettings(max_seconds=None, restarts=2, anneal_steps=100) + result = optimize("MELF", config, settings) + payload = report_dict(result, config) + json.dumps(payload, allow_nan=False) + # Reconstruct from JSON bytes, not the original in-memory configuration. + saved = json.loads(json.dumps(payload, allow_nan=False)) + values = saved["configuration"] + table = values.pop("table") + table.pop("sha256") + backend = values.pop("splice_scorer") + values["weights"] = Weights(**values["weights"]) + values["motifs"] = tuple(Motif(m["name"], m["pattern"]) for m in values["motifs"]) + restored = DesignConfig(**values, table=CodonUsage(**table), + splice_scorer=get_scorer(backend["name"])) + replay = optimize("MELF", restored, OptimizerSettings(**saved["search"]["settings"])) + assert replay.sequence == result.sequence + assert payload["configuration"]["table"]["frequencies"] == config.table.frequencies + assert len(payload["configuration"]["table"]["sha256"]) == 64 + assert payload["configuration"]["splice_scorer"]["name"] == "none" + + +def test_incremental_constraints_with_flanks_match_independent_validation(): + config = DesignConfig(left_flank="AA", right_flank="CGTGC", repeat_k=3, + hairpin_stem=2, hairpin_min_loop=0, hairpin_max_loop=10, + max_homopolymer=2, gc_window=5, gc_step=1, + splice_scorer=MotifScorer(), splice_strand="both", + motifs=(Motif("pattern", "AA"),), + required=("motif", "gc_window", "repeat", "hairpin", "homopolymer", "splice"), + weights=only()) + state = SequenceState("AAAACGGCTTTC", config) + rng = random.Random(57) + for _ in range(150): + i = rng.randrange(state.codon_count) + state.set_codon(i, rng.choice(state.alternatives(i))) + counts = sum(v["count"] for v in constraint_violations(state.sequence, config)) + assert state.violations == counts + assert state.score == pytest.approx(state.audit()) + assert state.breakdown().total == pytest.approx(state.score) + assert state.context_sequence[:2] == "AA" + assert state.context_sequence[-5:] == "CGTGC" + + +def test_budget_and_cancellation_keep_a_consistent_result(): + config = DesignConfig(motifs=tuple(resolve_motifs(["GAATTC", "GAGTTC", "GAATTT"])), + weights=only(motif=1)) + limited = optimize_dna("GAATTC", config, OptimizerSettings(seed=1, repair_candidates=1)) + assert limited.repair_evaluated <= 1 + assert limited.valid == (not constraint_violations(limited.sequence, config)) + calls = 0 + def pulse(): + nonlocal calls + calls += 1 + return calls == 1 + cancelled = optimize_dna("GAATTC", config, OptimizerSettings(seed=1, should_stop=pulse)) + assert cancelled.cancelled and cancelled.stop_reason == "cancelled" + no_search = optimize("M", settings=OptimizerSettings(max_sweeps=0, should_stop=lambda: True)) + assert no_search.cancelled and no_search.valid + + +def test_cli_add_stop_keeps_original_dna_as_baseline(tmp_path): + path = tmp_path / "result.json" + assert main(["optimize", "--dna", "TTTCTATTT", "--add-stop", "--sweeps", "0", + "--json", str(path), "--quiet", "--seed", "1"]) == 0 + payload = json.loads(path.read_text()) + assert payload["initial_sequence"].startswith("TTTCTATTT") + + +@pytest.mark.parametrize("criterion", ["gc_window", "repeat", "hairpin", "homopolymer", "splice"]) +def test_required_criteria_work_when_the_soft_weight_is_zero(criterion): + config = DesignConfig(required=(criterion,), weights=only(), gc_window=6, + gc_range=(.4, .6), repeat_k=3, hairpin_stem=2, + hairpin_min_loop=0, hairpin_max_loop=10, + max_homopolymer=2, splice_scorer=MotifScorer()) + state = SequenceState("CAGGTAAGTAAAAAATTT", config) + assert state.violations == sum(v["count"] for v in constraint_violations(state.sequence, config)) diff --git a/tests/test_optimize.py b/tests/test_optimize.py index 3f127ab..e1cbf3b 100644 --- a/tests/test_optimize.py +++ b/tests/test_optimize.py @@ -199,7 +199,7 @@ def test_should_stop_can_flip_part_way_through(plain_config): def stop_after_a_while() -> bool: calls["n"] += 1 - return calls["n"] > 12 + return calls["n"] > 2 full = optimize(protein, plain_config, OptimizerSettings(seed=22, max_sweeps=6)) cut = optimize( diff --git a/tests/test_server.py b/tests/test_server.py index 224c0fc..25682c2 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -216,7 +216,8 @@ def test_a_job_runs_to_completion_and_keeps_the_protein(client): {"sequence": PROTEIN, "avoid": ["golden_gate", "EcoRI"], **FAST} ) assert status == 202 - assert started["status"] == "running" + # The worker may finish before the HTTP start response is serialized. + assert started["status"] in {"running", "done"} done = client.wait(started["id"]) assert done["status"] == "done" @@ -482,6 +483,23 @@ def test_request_errors_are_value_errors(): build_request("not an object") +def test_request_host_policy_and_context_options(): + spec = build_request({"sequence": "TTTCTCTTT", "kind": "dna", "table": "ecoli", + "splice_backend": "auto", "left_flank": "GGT", + "right_flank": "A", "protected_codons": [0], + "excluded_codons": ["TTA"], "required": ["motif", "gc_window"]}) + assert spec.config.splice_scorer is None + assert spec.config.splice_strand == "forward" + assert spec.config.left_flank == "GGT" and spec.config.right_flank == "A" + assert spec.config.protected_codons == (0,) + assert spec.config.required == ("gc_window", "motif") + assert build_request({"sequence": "MKV"}).config.splice_scorer is None + for key, value in (("required", "motif"), ("protected_codons", [True]), + ("left_flank", []), ("splice_strand", "unknown"), ("splice_strand", [])): + with pytest.raises(RequestError): + build_request({"sequence": "MKV", key: value}) + + # ---------------------------------------------------------------- built assets diff --git a/web/src/form.ts b/web/src/form.ts index f64def7..ce6cb96 100644 --- a/web/src/form.ts +++ b/web/src/form.ts @@ -91,7 +91,7 @@ export function reset(meta: Meta): void { num("gc-low").value = String(d.gc_range[0]); num("gc-high").value = String(d.gc_range[1]); num("max-homopolymer").value = String(d.max_homopolymer); - $("splice").value = "auto"; + $("splice").value = "none"; $("effort-standard").checked = true; num("seconds").value = String(d.seconds); num("seed").value = ""; @@ -146,7 +146,9 @@ export function describeSplice(meta: Meta): void { if (backend === "maxent" && !meta.maxent_available) { hint.textContent = "MaxEntScan is not installed, so this will report an error. Pick Automatic or Consensus screen."; } else if (backend === "auto") { - hint.textContent = meta.maxent_available + hint.textContent = $("table").value !== "human" + ? "Automatic splice screening is only enabled for the bundled human host. Other hosts need an explicit backend." + : meta.maxent_available ? "Uses MaxEntScan." : "MaxEntScan is not installed, so a simpler consensus screen is used."; } else if (backend === "motif") { diff --git a/web/src/main.ts b/web/src/main.ts index 0bced6f..21d9441 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -120,6 +120,7 @@ async function main(): Promise { tableSelect.addEventListener("change", () => { syncTable(); describeTable(meta); + describeSplice(meta); }); const tableText = $("table-text"); const tableFile = $("table-file"); @@ -168,7 +169,8 @@ async function main(): Promise { say("The optimization failed."); showError(output, latest.error ?? "The optimization failed."); } else if (latest.result) { - say(latest.status === "cancelled" ? "Stopped early; showing the best sequence found." : "Optimization finished."); + say(!latest.result.valid ? "Finished with unresolved mandatory requirements." : + latest.status === "cancelled" ? "Stopped early; showing the best sequence found." : "Optimization finished."); showResult(output, latest.result, meta); } }; diff --git a/web/src/results.ts b/web/src/results.ts index bda9635..6d7c40c 100644 --- a/web/src/results.ts +++ b/web/src/results.ts @@ -98,7 +98,7 @@ export class Progress { "div", { class: "card progress" }, h("h2", { text: "Optimizing" }), - h("p", { class: "hint", text: "Trying synonymous codons and keeping the changes that lower the score." }), + h("p", { class: "hint", text: "Resolving mandatory requirements, then improving synonymous codon choices." }), h("div", { class: "bar", role: "progressbar", attrs: { "aria-label": "Optimization progress" } }, this.bar), h("div", { class: "progress-meta" }, this.elapsed, this.detail), this.trouble, @@ -292,7 +292,7 @@ function breakdown(result: Result): HTMLElement { const after = result.score.final_terms; const names = Object.keys(TERMS).filter((key) => (before[key] ?? 0) > 1e-9 || (after[key] ?? 0) > 1e-9); if (!names.length) { - return h("p", { class: "hint", text: "No penalties at all: every constraint is met and codon usage is ideal." }); + return h("p", { class: "hint", text: "No active soft penalties. Mandatory requirements are checked separately." }); } const scale = Math.max(...names.flatMap((key) => [before[key] ?? 0, after[key] ?? 0]), 1e-9); const bar = (value: number, className: string): HTMLElement => { @@ -354,6 +354,11 @@ export function showResult(output: HTMLElement, result: Result, _meta: Meta): vo : result.search.hit_time_limit ? h("div", { class: "callout warn", role: "status" }, "The time limit ended the search before it settled. Raise the limit or use a thorough search for more.") : null; + const unresolved = result.valid ? null : h( + "div", { class: "callout warn", role: "alert" }, + "Mandatory requirements remain unresolved. This candidate is not a valid design: " + + result.unresolved_constraints.map((v) => `${v.criterion} (${v.count})`).join(", ") + ".", + ); const toolbar = h( "div", @@ -371,10 +376,11 @@ export function showResult(output: HTMLElement, result: Result, _meta: Meta): vo h( "div", { class: "result-head" }, - h("div", null, h("h2", { text: "Optimized sequence" }), h("p", { class: "hint", text: `${result.name} · ${result.protein.length.toLocaleString()} residues · ${result.codon_usage_table} codon usage` })), + h("div", null, h("h2", { text: result.valid ? "Optimized sequence" : "Unresolved candidate" }), h("p", { class: "hint", text: `${result.name} · ${result.protein.length.toLocaleString()} residues · ${result.codon_usage_table} codon usage` })), toolbar, ), banner, + unresolved, h( "div", { class: "stats" }, @@ -385,7 +391,7 @@ export function showResult(output: HTMLElement, result: Result, _meta: Meta): vo ), card("DNA sequence", "The protein is identical to your input; only synonymous codons changed.", sequenceView(result)), card("Before and after", null, comparison(result)), - card("GC content along the sequence", `Each point is a ${result.gc.window} base window.`, gcChart(result.gc, result.sequence.length)), + card("GC content along the construct", `Each point is a ${result.gc.window} base window, including any fixed flanks.`, gcChart(result.gc, result.context_sequence.length)), card("Where the score comes from", "Lower is better. Each term is weighted as in your settings.", breakdown(result)), h( "p", diff --git a/web/src/sequence.ts b/web/src/sequence.ts index 0d6b51b..83ef1ac 100644 --- a/web/src/sequence.ts +++ b/web/src/sequence.ts @@ -17,7 +17,8 @@ export function sequenceView(result: Result): HTMLElement { const flagged = new Set(); for (const hit of result.motif_hits) { for (const position of hit.positions) { - for (let i = Math.floor(position / 3); i <= Math.floor((position + hit.length - 1) / 3); i++) { + const start = position - result.final.cds_start; + for (let i = Math.max(0, Math.floor(start / 3)); i <= Math.min(codons - 1, Math.floor((start + hit.length - 1) / 3)); i++) { flagged.add(i); } } @@ -75,7 +76,7 @@ export function sequenceView(result: Result): HTMLElement { "p", { class: "note warn", role: "status" }, `Still contains ${result.motif_hits.map((hit) => `${hit.name} (${hit.positions.length})`).join(", ")}; ` + - "the marked codons overlap a site you asked to keep out.", + "matches are in the scored construct; overlapping CDS codons are marked.", ) : null; diff --git a/web/src/types.ts b/web/src/types.ts index b158dd7..248bc4b 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -36,6 +36,8 @@ export interface Meta { } export interface Measures { + context_length: number; + cds_start: number; length: number; gc_fraction: number; gc_windows_below_range: number; @@ -71,6 +73,10 @@ export interface MotifHit { } export interface Result { + context_sequence: string; + valid: boolean; + unresolved_constraints: { criterion: string; count: number }[]; + stop_reason: string; name: string; kind: "protein" | "dna"; protein: string; diff --git a/web/tests/e2e.spec.ts b/web/tests/e2e.spec.ts index 1cc94dc..7c6342b 100644 --- a/web/tests/e2e.spec.ts +++ b/web/tests/e2e.spec.ts @@ -86,6 +86,20 @@ test("keeps the protein unchanged (the sequence is a synonymous recoding)", asyn expect(errors).toEqual([]); }); +test("labels an unavoidable motif as an unresolved candidate", async ({ page }) => { + const errors = await open(page); + await expect(page.locator("#splice")).toHaveValue("none"); + await page.locator("#sequence").fill("MW"); + await page.locator("#avoid-extra").fill("ATG"); + await quick(page); + await page.locator("#run").click(); + await expect(page.locator(".result-head h2")).toHaveText("Unresolved candidate", { timeout: 60_000 }); + await expect(page.locator(".callout.warn[role=alert]")).toContainText("not a valid design"); + await expect(page.locator("#status")).toContainText("unresolved mandatory requirements"); + await expect(page.locator(".seq .line.dna")).toContainText("ATG"); + expect(errors).toEqual([]); +}); + test("explains problems instead of failing silently", async ({ page }) => { const errors = await open(page); await page.locator("#sequence").fill("MKT*AY1"); @@ -104,11 +118,13 @@ test("explains problems instead of failing silently", async ({ page }) => { test("can cancel a long run and hands the controls back", async ({ page }) => { const errors = await open(page); await page.locator("#btn-example").click(); + const example = (await page.locator("#sequence").inputValue()) + .split(/\r?\n/).filter((line) => !line.startsWith(">")).join("").trim(); + await page.locator("#sequence").fill(example.repeat(20)); await page.locator("#seconds").fill("60"); await page.locator('label[for="effort-thorough"]').click(); await page.locator("#run").click(); await expect(page.locator("#cancel")).toBeVisible(); - await page.waitForTimeout(1500); await page.locator("#cancel").click(); // What matters is that Stop ends the run and restores the form, whatever the // search had managed by then.