factorforge-cds 3.3.2__tar.gz → 3.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/PKG-INFO +2 -2
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/README.md +1 -1
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/pyproject.toml +1 -1
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/__init__.py +1 -1
- factorforge_cds-3.4.0/src/factorforge/design_review.py +437 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/__init__.py +1 -1
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/__init__.py +1 -1
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/optimizer.py +25 -3
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/pipeline.py +15 -2
- factorforge_cds-3.4.0/src/factorforge/review/__init__.py +2 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/schemas/design_package.py +30 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge_cds.egg-info/PKG-INFO +2 -2
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge_cds.egg-info/SOURCES.txt +3 -0
- factorforge_cds-3.4.0/tests/test_design_review.py +150 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/LICENSE +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/setup.cfg +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/__main__.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/analysis/__init__.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/analysis/feasibility.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/analysis/metrics.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/cli/__init__.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/cli/legacy_cli.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/cli/main.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/core/interfaces/__init__.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/core/interfaces/exporter.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/core/interfaces/optimizer.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/core/interfaces/validator.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/nbenthamiana_codons.json +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/nbenthamiana_golden_set.json +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/ntabacum_codons.json +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/profiles/nbev11_cds_all_derived_codons.json +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/profiles/nbev11_cds_all_derived_filtered_stats.json +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/profiles/nbev11_cds_all_derived_manifest.json +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/profiles/nbev11_cds_hc_derived_codons.json +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/profiles/nbev11_cds_hc_derived_filtered_stats.json +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/profiles/nbev11_cds_hc_derived_manifest.json +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/profiles/qld183_v103_derived_codons.json +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/profiles/qld183_v103_derived_manifest.json +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/profiles/qld183_v103_filtered_stats.json +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/reference/reference_policy_manifest.json +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/templates/high_expression.json +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/templates/standard_expression.json +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/wolffia_globosa_codons.json +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/database.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/codon_table_builder.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/construct_builder.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/exporter.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/rules/__init__.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/rules/domesticator.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/rules/reverse_translator.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/rules/rule_engine.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/scoring.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/scoring_ml.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/utils.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/validator.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/registry.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/io/__init__.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/io/fasta.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/io/validation.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/protein_risk/__init__.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/protein_risk/annotate.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/protein_risk/kd_scale.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/protein_risk/risk_classifier.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/protein_risk/sp_predict.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/protein_risk/tm_predict.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/registry/__init__.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/registry/registry_loader.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/schemas/__init__.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/schemas/design_package.schema.json +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/utils/__init__.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/utils/construct_id.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/utils/exceptions.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/utils/restriction_sites.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/utils/sequence_validator.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/utils/validation.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/validation/__init__.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/validation/cli.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/validation/package_generator.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/validation_registry.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/validation_report.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge_cds.egg-info/dependency_links.txt +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge_cds.egg-info/entry_points.txt +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge_cds.egg-info/requires.txt +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge_cds.egg-info/top_level.txt +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_baselines.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_benchmark_cli_output_guard.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_benchmark_codon_table_metadata.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_benchmark_regression.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_benchmark_scoring.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_benchmark_smoke.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_cai.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_codon_table_manifest.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_database.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_design_package_schema.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_design_package_semantics.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_design_package_serialization.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_docs_consistency.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_fasta_io.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_gc_content.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_host_profile_metadata.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_iupac_validation.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_legacy_cli.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_no_raw_sequence_logging.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_openbio_missing_metric_contract.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_parameter_registry.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_protein_risk.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_registry_production_sync.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_restriction_sites.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_sequence_validator.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_translation_integrity.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_validation_contract_compat.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_validation_registry.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_validation_report.py +0 -0
- {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_worked_example.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: factorforge-cds
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.4.0
|
|
4
4
|
Summary: FactorForge - open-source CDS design and pre-synthesis sequence review by Eijex.
|
|
5
5
|
Author-email: Eijex <eijex.lab@gmail.com>
|
|
6
6
|
License-Expression: AGPL-3.0-only
|
|
@@ -93,7 +93,7 @@ FactorForge outputs are **in-silico only** and have not been experimentally vali
|
|
|
93
93
|
## Citing
|
|
94
94
|
|
|
95
95
|
```
|
|
96
|
-
FactorForge v3.
|
|
96
|
+
FactorForge v3.4.0 (2026). Open-source constraint-based CDS design and sequence review.
|
|
97
97
|
Eijex. https://github.com/eijex/factorforge-cds
|
|
98
98
|
```
|
|
99
99
|
|
|
@@ -60,7 +60,7 @@ FactorForge outputs are **in-silico only** and have not been experimentally vali
|
|
|
60
60
|
## Citing
|
|
61
61
|
|
|
62
62
|
```
|
|
63
|
-
FactorForge v3.
|
|
63
|
+
FactorForge v3.4.0 (2026). Open-source constraint-based CDS design and sequence review.
|
|
64
64
|
Eijex. https://github.com/eijex/factorforge-cds
|
|
65
65
|
```
|
|
66
66
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "factorforge-cds"
|
|
7
|
-
version = "3.
|
|
7
|
+
version = "3.4.0"
|
|
8
8
|
description = "FactorForge - open-source CDS design and pre-synthesis sequence review by Eijex."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "AGPL-3.0-only"
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
"""Deterministic CDS design-review parsing, criteria, and decision helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
import hashlib
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
|
|
11
|
+
from factorforge.analysis.metrics import (
|
|
12
|
+
STOP_CODONS,
|
|
13
|
+
calculate_cai,
|
|
14
|
+
calculate_gc,
|
|
15
|
+
calculate_gc_windows,
|
|
16
|
+
detect_forbidden_motifs,
|
|
17
|
+
translate_dna,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
ConstraintMode = Literal["required", "preferred", "ignored"]
|
|
21
|
+
AutomatedDecision = Literal["PASS", "CONDITIONAL_PASS", "FAIL"]
|
|
22
|
+
|
|
23
|
+
MULTI_FASTA_ERROR = "Multiple FASTA records detected. Upload one sequence at a time."
|
|
24
|
+
VALID_PROTEIN = frozenset("ACDEFGHIKLMNPQRSTVWY*")
|
|
25
|
+
VALID_DNA = frozenset("ACGT")
|
|
26
|
+
TYPE_IIS_SITES = {
|
|
27
|
+
"BsaI": ("GGTCTC", "GAGACC"),
|
|
28
|
+
"BsmBI/Esp3I": ("CGTCTC", "GAGACG"),
|
|
29
|
+
"SapI": ("GCTCTTC", "GAAGAGC"),
|
|
30
|
+
}
|
|
31
|
+
VALID_MODES = frozenset({"required", "preferred", "ignored"})
|
|
32
|
+
VALID_DISPOSITIONS = frozenset(
|
|
33
|
+
{"accept", "accept_with_exception", "return_for_redesign", "reject"}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _compact_sequence(value: str) -> str:
|
|
38
|
+
return re.sub(r"\s+", "", value).upper()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def parse_sequence_input(raw: str) -> dict[str, Any]:
|
|
42
|
+
"""Parse one plain sequence or one FASTA record and classify it explicitly."""
|
|
43
|
+
if not isinstance(raw, str) or not raw.strip():
|
|
44
|
+
raise ValueError("Sequence is required.")
|
|
45
|
+
|
|
46
|
+
lines = raw.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
|
47
|
+
nonempty = [line.strip() for line in lines if line.strip()]
|
|
48
|
+
headers = [index for index, line in enumerate(nonempty) if line.startswith(">")]
|
|
49
|
+
if len(headers) > 1:
|
|
50
|
+
raise ValueError(MULTI_FASTA_ERROR)
|
|
51
|
+
if headers and headers[0] != 0:
|
|
52
|
+
raise ValueError("FASTA header must be the first non-empty line.")
|
|
53
|
+
|
|
54
|
+
header = nonempty[0][1:].strip() if headers else None
|
|
55
|
+
sequence_lines = nonempty[1:] if headers else nonempty
|
|
56
|
+
sequence = _compact_sequence("".join(sequence_lines))
|
|
57
|
+
if not sequence:
|
|
58
|
+
raise ValueError("Sequence is required.")
|
|
59
|
+
|
|
60
|
+
characters = set(sequence)
|
|
61
|
+
if characters <= VALID_DNA:
|
|
62
|
+
return _build_cds_context(sequence, header)
|
|
63
|
+
|
|
64
|
+
invalid = characters - VALID_PROTEIN
|
|
65
|
+
if invalid:
|
|
66
|
+
raise ValueError(f"Invalid sequence characters: {', '.join(sorted(invalid))}.")
|
|
67
|
+
if "*" in sequence[:-1]:
|
|
68
|
+
raise ValueError("Protein input contains an internal stop marker.")
|
|
69
|
+
|
|
70
|
+
protein = sequence.rstrip("*")
|
|
71
|
+
if not protein:
|
|
72
|
+
raise ValueError("Protein sequence must contain at least one amino acid.")
|
|
73
|
+
return {
|
|
74
|
+
"input_type": "protein",
|
|
75
|
+
"normalized_sequence": sequence,
|
|
76
|
+
"optimization_sequence": protein,
|
|
77
|
+
"fasta_header": header,
|
|
78
|
+
"generation_allowed": True,
|
|
79
|
+
"message": (
|
|
80
|
+
"Protein sequence detected. FactorForge will reverse translate it into "
|
|
81
|
+
"a host-adapted coding sequence."
|
|
82
|
+
),
|
|
83
|
+
"summary": {
|
|
84
|
+
"protein_length_aa": len(protein),
|
|
85
|
+
"terminal_stop_marker": sequence.endswith("*"),
|
|
86
|
+
},
|
|
87
|
+
"errors": [],
|
|
88
|
+
"warnings": [],
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _build_cds_context(sequence: str, header: str | None) -> dict[str, Any]:
|
|
93
|
+
errors: list[str] = []
|
|
94
|
+
warnings: list[str] = []
|
|
95
|
+
frame_valid = len(sequence) % 3 == 0
|
|
96
|
+
if not frame_valid:
|
|
97
|
+
errors.append("CDS length must be divisible by 3.")
|
|
98
|
+
|
|
99
|
+
codons = [sequence[index : index + 3] for index in range(0, len(sequence), 3)]
|
|
100
|
+
complete_codons = [codon for codon in codons if len(codon) == 3]
|
|
101
|
+
start_present = bool(complete_codons) and complete_codons[0] == "ATG"
|
|
102
|
+
terminal_stop_present = bool(complete_codons) and complete_codons[-1] in STOP_CODONS
|
|
103
|
+
internal_stops = [
|
|
104
|
+
index for index, codon in enumerate(complete_codons[:-1]) if codon in STOP_CODONS
|
|
105
|
+
]
|
|
106
|
+
if internal_stops:
|
|
107
|
+
errors.append("CDS contains internal stop codon(s).")
|
|
108
|
+
if not start_present:
|
|
109
|
+
warnings.append("Start codon is absent.")
|
|
110
|
+
if not terminal_stop_present:
|
|
111
|
+
warnings.append("Terminal stop codon is absent.")
|
|
112
|
+
|
|
113
|
+
translated = translate_dna(sequence) if frame_valid else ""
|
|
114
|
+
protein = translated[:-1] if terminal_stop_present else translated
|
|
115
|
+
return {
|
|
116
|
+
"input_type": "cds",
|
|
117
|
+
"normalized_sequence": sequence,
|
|
118
|
+
"optimization_sequence": protein,
|
|
119
|
+
"fasta_header": header,
|
|
120
|
+
"generation_allowed": not errors,
|
|
121
|
+
"message": (
|
|
122
|
+
"DNA coding sequence detected. FactorForge will preserve the translated "
|
|
123
|
+
"protein while redesigning synonymous codons for the selected host and constraints."
|
|
124
|
+
),
|
|
125
|
+
"summary": {
|
|
126
|
+
"cds_length_bp": len(sequence),
|
|
127
|
+
"codon_count": len(complete_codons),
|
|
128
|
+
"protein_length_aa": len(protein),
|
|
129
|
+
"gc_percent": round(calculate_gc(sequence), 1),
|
|
130
|
+
"frame_valid": frame_valid,
|
|
131
|
+
"start_codon_present": start_present,
|
|
132
|
+
"terminal_stop_present": terminal_stop_present,
|
|
133
|
+
"terminal_stop_codon": complete_codons[-1] if terminal_stop_present else None,
|
|
134
|
+
"internal_stop_count": len(internal_stops),
|
|
135
|
+
},
|
|
136
|
+
"errors": errors,
|
|
137
|
+
"warnings": warnings,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def restore_cds_stop_policy(candidate: str, input_context: dict[str, Any]) -> str:
|
|
142
|
+
"""Restore the original CDS terminal-stop policy to a generated candidate."""
|
|
143
|
+
sequence = _compact_sequence(candidate)
|
|
144
|
+
if input_context["input_type"] != "cds":
|
|
145
|
+
return sequence
|
|
146
|
+
stop = input_context["summary"]["terminal_stop_codon"]
|
|
147
|
+
translated = translate_dna(sequence)
|
|
148
|
+
if translated.endswith("*"):
|
|
149
|
+
sequence = sequence[:-3]
|
|
150
|
+
return sequence + (stop or "")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def assert_pathway_invariants(
|
|
154
|
+
original_cds: str,
|
|
155
|
+
optimized_cds: str,
|
|
156
|
+
input_context: dict[str, Any],
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Raise visibly when CDS length or translated-protein invariants regress."""
|
|
159
|
+
if input_context["input_type"] != "cds":
|
|
160
|
+
return
|
|
161
|
+
if len(original_cds) != len(optimized_cds):
|
|
162
|
+
raise ValueError(
|
|
163
|
+
"CDS invariant failed: optimized nucleotide length differs from the original."
|
|
164
|
+
)
|
|
165
|
+
original_protein = translate_dna(original_cds).rstrip("*")
|
|
166
|
+
optimized_protein = translate_dna(optimized_cds).rstrip("*")
|
|
167
|
+
if original_protein != optimized_protein:
|
|
168
|
+
raise ValueError("CDS invariant failed: translated protein was not preserved.")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def default_acceptance_criteria(
|
|
172
|
+
gc_min: float = 40.0,
|
|
173
|
+
gc_max: float = 55.0,
|
|
174
|
+
) -> dict[str, dict[str, Any]]:
|
|
175
|
+
return {
|
|
176
|
+
"cai": {"mode": "preferred", "minimum": 0.8},
|
|
177
|
+
"overall_gc": {"mode": "preferred", "minimum": gc_min, "maximum": gc_max},
|
|
178
|
+
"local_gc": {
|
|
179
|
+
"mode": "preferred",
|
|
180
|
+
"minimum": 30.0,
|
|
181
|
+
"maximum": 70.0,
|
|
182
|
+
"window_size": 60,
|
|
183
|
+
},
|
|
184
|
+
"type_iis": {
|
|
185
|
+
"mode": "required",
|
|
186
|
+
"enzymes": ["BsaI", "BsmBI/Esp3I", "SapI"],
|
|
187
|
+
"custom_sites": [],
|
|
188
|
+
},
|
|
189
|
+
"repeats": {"mode": "preferred", "minimum_length": 18, "maximum_count": 0},
|
|
190
|
+
"homopolymers": {"mode": "preferred", "maximum_length": 8},
|
|
191
|
+
"forbidden_motifs": {
|
|
192
|
+
"mode": "preferred",
|
|
193
|
+
"motifs": ["AATAAA", "GTAAGT", "ATTTA"],
|
|
194
|
+
},
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def normalize_acceptance_criteria(
|
|
199
|
+
value: dict[str, Any] | None,
|
|
200
|
+
*,
|
|
201
|
+
gc_min: float,
|
|
202
|
+
gc_max: float,
|
|
203
|
+
) -> dict[str, dict[str, Any]]:
|
|
204
|
+
criteria = default_acceptance_criteria(gc_min, gc_max)
|
|
205
|
+
if value is None:
|
|
206
|
+
return criteria
|
|
207
|
+
if not isinstance(value, dict):
|
|
208
|
+
raise ValueError("acceptance_criteria must be an object.")
|
|
209
|
+
unknown = set(value) - set(criteria)
|
|
210
|
+
if unknown:
|
|
211
|
+
raise ValueError(f"Unknown acceptance criteria: {', '.join(sorted(unknown))}.")
|
|
212
|
+
for name, override in value.items():
|
|
213
|
+
if not isinstance(override, dict):
|
|
214
|
+
raise ValueError(f"acceptance_criteria.{name} must be an object.")
|
|
215
|
+
criteria[name].update(deepcopy(override))
|
|
216
|
+
mode = criteria[name].get("mode")
|
|
217
|
+
if mode not in VALID_MODES:
|
|
218
|
+
raise ValueError(
|
|
219
|
+
f"acceptance_criteria.{name}.mode must be required, preferred, or ignored."
|
|
220
|
+
)
|
|
221
|
+
_validate_criteria_ranges(criteria)
|
|
222
|
+
return criteria
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _validate_criteria_ranges(criteria: dict[str, dict[str, Any]]) -> None:
|
|
226
|
+
for name in ("overall_gc", "local_gc"):
|
|
227
|
+
minimum = float(criteria[name]["minimum"])
|
|
228
|
+
maximum = float(criteria[name]["maximum"])
|
|
229
|
+
if not 0 <= minimum <= maximum <= 100:
|
|
230
|
+
raise ValueError(f"acceptance_criteria.{name} range must be within 0-100.")
|
|
231
|
+
cai_minimum = float(criteria["cai"]["minimum"])
|
|
232
|
+
if not 0 <= cai_minimum <= 1:
|
|
233
|
+
raise ValueError("acceptance_criteria.cai.minimum must be within 0-1.")
|
|
234
|
+
if int(criteria["local_gc"]["window_size"]) <= 0:
|
|
235
|
+
raise ValueError("acceptance_criteria.local_gc.window_size must be positive.")
|
|
236
|
+
if int(criteria["homopolymers"]["maximum_length"]) < 2:
|
|
237
|
+
raise ValueError("acceptance_criteria.homopolymers.maximum_length must be at least 2.")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _count_direct_repeats(sequence: str, minimum_length: int) -> int:
|
|
241
|
+
if len(sequence) < minimum_length * 2:
|
|
242
|
+
return 0
|
|
243
|
+
seen: set[str] = set()
|
|
244
|
+
repeated: set[str] = set()
|
|
245
|
+
for index in range(0, len(sequence) - minimum_length + 1):
|
|
246
|
+
motif = sequence[index : index + minimum_length]
|
|
247
|
+
if motif in seen:
|
|
248
|
+
repeated.add(motif)
|
|
249
|
+
seen.add(motif)
|
|
250
|
+
return len(repeated)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _longest_homopolymer(sequence: str) -> int:
|
|
254
|
+
return max((len(match.group(0)) for match in re.finditer(r"([ACGT])\1*", sequence)), default=0)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _type_iis_findings(
|
|
258
|
+
sequence: str,
|
|
259
|
+
criterion: dict[str, Any],
|
|
260
|
+
) -> list[dict[str, Any]]:
|
|
261
|
+
findings: list[dict[str, Any]] = []
|
|
262
|
+
enabled = set(criterion.get("enzymes", []))
|
|
263
|
+
for enzyme, patterns in TYPE_IIS_SITES.items():
|
|
264
|
+
if enzyme not in enabled:
|
|
265
|
+
continue
|
|
266
|
+
for pattern in patterns:
|
|
267
|
+
start = sequence.find(pattern)
|
|
268
|
+
while start >= 0:
|
|
269
|
+
findings.append({"enzyme": enzyme, "site": pattern, "start": start})
|
|
270
|
+
start = sequence.find(pattern, start + 1)
|
|
271
|
+
for site in criterion.get("custom_sites", []):
|
|
272
|
+
if isinstance(site, dict):
|
|
273
|
+
name = str(site.get("name") or "Custom")
|
|
274
|
+
pattern = _compact_sequence(str(site.get("sequence") or ""))
|
|
275
|
+
else:
|
|
276
|
+
name = "Custom"
|
|
277
|
+
pattern = _compact_sequence(str(site))
|
|
278
|
+
if not pattern or set(pattern) - VALID_DNA:
|
|
279
|
+
continue
|
|
280
|
+
start = sequence.find(pattern)
|
|
281
|
+
while start >= 0:
|
|
282
|
+
findings.append({"enzyme": name, "site": pattern, "start": start})
|
|
283
|
+
start = sequence.find(pattern, start + 1)
|
|
284
|
+
return findings
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def evaluate_candidate(
|
|
288
|
+
sequence: str,
|
|
289
|
+
*,
|
|
290
|
+
cai: float,
|
|
291
|
+
criteria: dict[str, dict[str, Any]],
|
|
292
|
+
) -> dict[str, Any]:
|
|
293
|
+
"""Evaluate one CDS against a normalized criteria snapshot."""
|
|
294
|
+
seq = _compact_sequence(sequence)
|
|
295
|
+
overall_gc = calculate_gc(seq)
|
|
296
|
+
local_rule = criteria["local_gc"]
|
|
297
|
+
windows = calculate_gc_windows(
|
|
298
|
+
seq,
|
|
299
|
+
window_size=int(local_rule["window_size"]),
|
|
300
|
+
step=max(1, int(local_rule["window_size"]) // 2),
|
|
301
|
+
)
|
|
302
|
+
local_values = [float(window["gc"]) for window in windows] or [overall_gc]
|
|
303
|
+
type_iis = _type_iis_findings(seq, criteria["type_iis"])
|
|
304
|
+
repeat_count = _count_direct_repeats(seq, int(criteria["repeats"]["minimum_length"]))
|
|
305
|
+
longest_homopolymer = _longest_homopolymer(seq)
|
|
306
|
+
motifs = detect_forbidden_motifs(seq, list(criteria["forbidden_motifs"].get("motifs", [])))
|
|
307
|
+
|
|
308
|
+
observations = {
|
|
309
|
+
"cai": (float(cai), float(criteria["cai"]["minimum"]), float(cai) >= float(criteria["cai"]["minimum"])),
|
|
310
|
+
"overall_gc": (
|
|
311
|
+
round(overall_gc, 1),
|
|
312
|
+
f"{float(criteria['overall_gc']['minimum']):g}-{float(criteria['overall_gc']['maximum']):g}%",
|
|
313
|
+
float(criteria["overall_gc"]["minimum"]) <= overall_gc <= float(criteria["overall_gc"]["maximum"]),
|
|
314
|
+
),
|
|
315
|
+
"local_gc": (
|
|
316
|
+
f"{min(local_values):.1f}-{max(local_values):.1f}%",
|
|
317
|
+
f"{float(local_rule['minimum']):g}-{float(local_rule['maximum']):g}%",
|
|
318
|
+
min(local_values) >= float(local_rule["minimum"]) and max(local_values) <= float(local_rule["maximum"]),
|
|
319
|
+
),
|
|
320
|
+
"type_iis": (len(type_iis), 0, not type_iis),
|
|
321
|
+
"repeats": (
|
|
322
|
+
repeat_count,
|
|
323
|
+
int(criteria["repeats"]["maximum_count"]),
|
|
324
|
+
repeat_count <= int(criteria["repeats"]["maximum_count"]),
|
|
325
|
+
),
|
|
326
|
+
"homopolymers": (
|
|
327
|
+
longest_homopolymer,
|
|
328
|
+
int(criteria["homopolymers"]["maximum_length"]),
|
|
329
|
+
longest_homopolymer <= int(criteria["homopolymers"]["maximum_length"]),
|
|
330
|
+
),
|
|
331
|
+
"forbidden_motifs": (len(motifs), 0, not motifs),
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
rows: list[dict[str, Any]] = []
|
|
335
|
+
required_failures = 0
|
|
336
|
+
preferred_warnings = 0
|
|
337
|
+
for name, (observed, threshold, passed) in observations.items():
|
|
338
|
+
mode: ConstraintMode = criteria[name]["mode"]
|
|
339
|
+
if mode == "ignored":
|
|
340
|
+
result = "IGNORED"
|
|
341
|
+
elif passed:
|
|
342
|
+
result = "PASS"
|
|
343
|
+
elif mode == "required":
|
|
344
|
+
result = "FAIL"
|
|
345
|
+
required_failures += 1
|
|
346
|
+
else:
|
|
347
|
+
result = "WARN"
|
|
348
|
+
preferred_warnings += 1
|
|
349
|
+
rows.append(
|
|
350
|
+
{
|
|
351
|
+
"criterion": name,
|
|
352
|
+
"mode": mode,
|
|
353
|
+
"observed": observed,
|
|
354
|
+
"threshold": threshold,
|
|
355
|
+
"result": result,
|
|
356
|
+
}
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
if required_failures:
|
|
360
|
+
decision: AutomatedDecision = "FAIL"
|
|
361
|
+
elif preferred_warnings:
|
|
362
|
+
decision = "CONDITIONAL_PASS"
|
|
363
|
+
else:
|
|
364
|
+
decision = "PASS"
|
|
365
|
+
failed_names = [
|
|
366
|
+
row["criterion"] for row in rows if row["result"] in {"FAIL", "WARN"}
|
|
367
|
+
]
|
|
368
|
+
explanation = (
|
|
369
|
+
f"{', '.join(failed_names)} require review."
|
|
370
|
+
if failed_names
|
|
371
|
+
else "All active acceptance criteria passed."
|
|
372
|
+
)
|
|
373
|
+
return {
|
|
374
|
+
"automated_decision": decision,
|
|
375
|
+
"required_failure_count": required_failures,
|
|
376
|
+
"preferred_warning_count": preferred_warnings,
|
|
377
|
+
"explanation": explanation,
|
|
378
|
+
"criteria": rows,
|
|
379
|
+
"details": {
|
|
380
|
+
"type_iis_sites": type_iis,
|
|
381
|
+
"forbidden_motifs": motifs,
|
|
382
|
+
"local_gc_min": round(min(local_values), 1),
|
|
383
|
+
"local_gc_max": round(max(local_values), 1),
|
|
384
|
+
"repeat_count": repeat_count,
|
|
385
|
+
"longest_homopolymer": longest_homopolymer,
|
|
386
|
+
},
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def apply_reviewer_disposition(
|
|
391
|
+
automated_decision: AutomatedDecision,
|
|
392
|
+
value: dict[str, Any] | None,
|
|
393
|
+
) -> dict[str, Any] | None:
|
|
394
|
+
if value is None:
|
|
395
|
+
return None
|
|
396
|
+
if not isinstance(value, dict):
|
|
397
|
+
raise ValueError("reviewer_disposition must be an object.")
|
|
398
|
+
disposition = str(value.get("disposition") or "").strip().lower()
|
|
399
|
+
if disposition not in VALID_DISPOSITIONS:
|
|
400
|
+
raise ValueError(
|
|
401
|
+
"reviewer_disposition.disposition must be accept, accept_with_exception, "
|
|
402
|
+
"return_for_redesign, or reject."
|
|
403
|
+
)
|
|
404
|
+
reason = str(value.get("reason") or "").strip()
|
|
405
|
+
accepting_failure = automated_decision == "FAIL" and disposition in {
|
|
406
|
+
"accept",
|
|
407
|
+
"accept_with_exception",
|
|
408
|
+
}
|
|
409
|
+
if accepting_failure and not reason:
|
|
410
|
+
raise ValueError("A written reason is required to accept an automated FAIL.")
|
|
411
|
+
final_state = {
|
|
412
|
+
"accept": "ACCEPTED",
|
|
413
|
+
"accept_with_exception": "ACCEPTED_WITH_EXCEPTION",
|
|
414
|
+
"return_for_redesign": "RETURNED_FOR_REDESIGN",
|
|
415
|
+
"reject": "REJECTED",
|
|
416
|
+
}[disposition]
|
|
417
|
+
if accepting_failure:
|
|
418
|
+
final_state = "MANUALLY_ACCEPTED"
|
|
419
|
+
return {
|
|
420
|
+
"disposition": disposition,
|
|
421
|
+
"reason": reason or None,
|
|
422
|
+
"timestamp": value.get("timestamp")
|
|
423
|
+
or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
424
|
+
"final_state": final_state,
|
|
425
|
+
"automated_decision": automated_decision,
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def build_result_identifier(input_sequence: str, criteria: dict[str, Any]) -> str:
|
|
430
|
+
payload = f"{input_sequence}|{repr(criteria)}".encode("utf-8")
|
|
431
|
+
return "ff-" + hashlib.sha256(payload).hexdigest()[:16]
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def original_cai(sequence: str, codon_weights: dict[str, float]) -> float | None:
|
|
435
|
+
if not sequence or len(sequence) % 3:
|
|
436
|
+
return None
|
|
437
|
+
return calculate_cai(sequence, codon_weights)
|
{factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/optimizer.py
RENAMED
|
@@ -6,6 +6,7 @@ import logging
|
|
|
6
6
|
from typing import Any
|
|
7
7
|
|
|
8
8
|
from factorforge.core.interfaces import OptimizationResult, OptimizerEngine
|
|
9
|
+
from factorforge.analysis.metrics import translate_dna
|
|
9
10
|
|
|
10
11
|
from .exporter import SequenceExporter
|
|
11
12
|
from .rules.reverse_translator import OptimizationProfile, ReverseTranslator
|
|
@@ -20,7 +21,7 @@ class RuleBasedOptimizer(OptimizerEngine):
|
|
|
20
21
|
"""Profile-based rule optimization engine."""
|
|
21
22
|
|
|
22
23
|
name = "Profile-based"
|
|
23
|
-
version = "3.
|
|
24
|
+
version = "3.4.0"
|
|
24
25
|
|
|
25
26
|
def __init__(
|
|
26
27
|
self,
|
|
@@ -124,9 +125,30 @@ class RuleBasedOptimizer(OptimizerEngine):
|
|
|
124
125
|
translator = ReverseTranslator(host=host)
|
|
125
126
|
rule_engine = RuleEngine(host=host)
|
|
126
127
|
|
|
127
|
-
# 3.
|
|
128
|
+
# 3. Optimize DNA/CDS through the translated protein, then restore the
|
|
129
|
+
# original terminal-stop policy. DNA must never be treated as a
|
|
130
|
+
# protein string (300 bp must not become 300 aa / 900 bp).
|
|
128
131
|
if seq_type == "dna":
|
|
129
|
-
|
|
132
|
+
if len(processed_seq) % 3:
|
|
133
|
+
raise ValueError("DNA/CDS input length must be divisible by 3.")
|
|
134
|
+
translated = translate_dna(processed_seq)
|
|
135
|
+
if "*" in translated[:-1]:
|
|
136
|
+
raise ValueError("DNA/CDS input contains an internal stop codon.")
|
|
137
|
+
terminal_stop = processed_seq[-3:] if translated.endswith("*") else ""
|
|
138
|
+
protein = translated.rstrip("*")
|
|
139
|
+
generated = translator.generate_candidates(
|
|
140
|
+
protein, profile=opt_profile, n=1, seed=seed, **{
|
|
141
|
+
k: v for k, v in kwargs.items()
|
|
142
|
+
if k not in ("scan_mode", "scan_include", "scan_exclude")
|
|
143
|
+
}
|
|
144
|
+
)
|
|
145
|
+
if not generated:
|
|
146
|
+
raise ValueError("No candidates generated for DNA/CDS input.")
|
|
147
|
+
optimized_dna = generated[0]["sequence"] + terminal_stop
|
|
148
|
+
if len(optimized_dna) != len(processed_seq):
|
|
149
|
+
raise ValueError("CDS invariant failed: optimized nucleotide length differs.")
|
|
150
|
+
if translate_dna(optimized_dna).rstrip("*") != protein:
|
|
151
|
+
raise ValueError("CDS invariant failed: translated protein was not preserved.")
|
|
130
152
|
cai = translator.calculate_cai(optimized_dna)
|
|
131
153
|
gc = translator.calculate_gc_content(optimized_dna)
|
|
132
154
|
score = calculate_composite_score(
|
|
@@ -189,8 +189,21 @@ class OptimizationPipeline:
|
|
|
189
189
|
|
|
190
190
|
candidate_metrics: dict[str, Any]
|
|
191
191
|
if seq_type == "dna":
|
|
192
|
-
|
|
193
|
-
|
|
192
|
+
if len(processed) % 3:
|
|
193
|
+
raise ValueError("DNA/CDS input length must be divisible by 3.")
|
|
194
|
+
translated = translate_dna(processed)
|
|
195
|
+
if "*" in translated[:-1]:
|
|
196
|
+
raise ValueError("DNA/CDS input contains an internal stop codon.")
|
|
197
|
+
expected_protein = translated.rstrip("*")
|
|
198
|
+
terminal_stop = processed[-3:] if translated.endswith("*") else ""
|
|
199
|
+
generated = translator.generate_candidates(expected_protein, profile=opt_profile, n=1)
|
|
200
|
+
if not generated:
|
|
201
|
+
raise ValueError("No candidates generated for DNA/CDS input.")
|
|
202
|
+
optimized_dna = generated[0]["sequence"] + terminal_stop
|
|
203
|
+
if len(optimized_dna) != len(processed):
|
|
204
|
+
raise ValueError("CDS invariant failed: optimized nucleotide length differs.")
|
|
205
|
+
if translate_dna(optimized_dna).rstrip("*") != expected_protein:
|
|
206
|
+
raise ValueError("CDS invariant failed: translated protein was not preserved.")
|
|
194
207
|
cai = translator.calculate_cai(optimized_dna)
|
|
195
208
|
gc = translator.calculate_gc_content(optimized_dna)
|
|
196
209
|
score = calculate_composite_score(
|
|
@@ -90,6 +90,30 @@ class WetLabFeedback(BaseModel):
|
|
|
90
90
|
submissions: list[Any] = Field(default_factory=list)
|
|
91
91
|
|
|
92
92
|
|
|
93
|
+
class ReviewerDisposition(BaseModel):
|
|
94
|
+
model_config = ConfigDict(extra="allow")
|
|
95
|
+
|
|
96
|
+
disposition: Literal[
|
|
97
|
+
"accept", "accept_with_exception", "return_for_redesign", "reject"
|
|
98
|
+
]
|
|
99
|
+
reason: Optional[str] = None
|
|
100
|
+
timestamp: str
|
|
101
|
+
final_state: str
|
|
102
|
+
automated_decision: Literal["PASS", "CONDITIONAL_PASS", "FAIL"]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class AcceptanceCriteriaSnapshot(BaseModel):
|
|
106
|
+
model_config = ConfigDict(extra="allow")
|
|
107
|
+
|
|
108
|
+
cai: dict[str, Any]
|
|
109
|
+
overall_gc: dict[str, Any]
|
|
110
|
+
local_gc: dict[str, Any]
|
|
111
|
+
type_iis: dict[str, Any]
|
|
112
|
+
repeats: dict[str, Any]
|
|
113
|
+
homopolymers: dict[str, Any]
|
|
114
|
+
forbidden_motifs: dict[str, Any]
|
|
115
|
+
|
|
116
|
+
|
|
93
117
|
class ProteinRisk(BaseModel):
|
|
94
118
|
model_config = ConfigDict(extra="forbid")
|
|
95
119
|
|
|
@@ -114,3 +138,9 @@ class DesignPackage(BaseModel):
|
|
|
114
138
|
provenance: Provenance
|
|
115
139
|
wet_lab_feedback: WetLabFeedback = Field(default_factory=WetLabFeedback)
|
|
116
140
|
protein_risk: Optional[ProteinRisk] = None
|
|
141
|
+
result_identifier: Optional[str] = None
|
|
142
|
+
input_type: Optional[Literal["cds", "protein"]] = None
|
|
143
|
+
input_summary: Optional[dict[str, Any]] = None
|
|
144
|
+
acceptance_criteria_snapshot: Optional[AcceptanceCriteriaSnapshot] = None
|
|
145
|
+
automated_decision: Optional[Literal["PASS", "CONDITIONAL_PASS", "FAIL"]] = None
|
|
146
|
+
reviewer_disposition: Optional[ReviewerDisposition] = None
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: factorforge-cds
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.4.0
|
|
4
4
|
Summary: FactorForge - open-source CDS design and pre-synthesis sequence review by Eijex.
|
|
5
5
|
Author-email: Eijex <eijex.lab@gmail.com>
|
|
6
6
|
License-Expression: AGPL-3.0-only
|
|
@@ -93,7 +93,7 @@ FactorForge outputs are **in-silico only** and have not been experimentally vali
|
|
|
93
93
|
## Citing
|
|
94
94
|
|
|
95
95
|
```
|
|
96
|
-
FactorForge v3.
|
|
96
|
+
FactorForge v3.4.0 (2026). Open-source constraint-based CDS design and sequence review.
|
|
97
97
|
Eijex. https://github.com/eijex/factorforge-cds
|
|
98
98
|
```
|
|
99
99
|
|
|
@@ -4,6 +4,7 @@ pyproject.toml
|
|
|
4
4
|
src/factorforge/__init__.py
|
|
5
5
|
src/factorforge/__main__.py
|
|
6
6
|
src/factorforge/database.py
|
|
7
|
+
src/factorforge/design_review.py
|
|
7
8
|
src/factorforge/validation_registry.py
|
|
8
9
|
src/factorforge/validation_report.py
|
|
9
10
|
src/factorforge/analysis/__init__.py
|
|
@@ -59,6 +60,7 @@ src/factorforge/protein_risk/sp_predict.py
|
|
|
59
60
|
src/factorforge/protein_risk/tm_predict.py
|
|
60
61
|
src/factorforge/registry/__init__.py
|
|
61
62
|
src/factorforge/registry/registry_loader.py
|
|
63
|
+
src/factorforge/review/__init__.py
|
|
62
64
|
src/factorforge/schemas/__init__.py
|
|
63
65
|
src/factorforge/schemas/design_package.py
|
|
64
66
|
src/factorforge/schemas/design_package.schema.json
|
|
@@ -89,6 +91,7 @@ tests/test_database.py
|
|
|
89
91
|
tests/test_design_package_schema.py
|
|
90
92
|
tests/test_design_package_semantics.py
|
|
91
93
|
tests/test_design_package_serialization.py
|
|
94
|
+
tests/test_design_review.py
|
|
92
95
|
tests/test_docs_consistency.py
|
|
93
96
|
tests/test_fasta_io.py
|
|
94
97
|
tests/test_gc_content.py
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from factorforge.analysis.metrics import translate_dna
|
|
6
|
+
from factorforge.design_review import (
|
|
7
|
+
apply_reviewer_disposition,
|
|
8
|
+
assert_pathway_invariants,
|
|
9
|
+
default_acceptance_criteria,
|
|
10
|
+
evaluate_candidate,
|
|
11
|
+
normalize_acceptance_criteria,
|
|
12
|
+
parse_sequence_input,
|
|
13
|
+
restore_cds_stop_policy,
|
|
14
|
+
original_cai,
|
|
15
|
+
)
|
|
16
|
+
from factorforge.analysis.metrics import load_codon_usage_table
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
BALANCED_DNA = "ATG" + ("GCT" * 97) + "GAA" + "TAA"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_plain_and_single_fasta_dna_are_normalized() -> None:
|
|
23
|
+
plain = parse_sequence_input(BALANCED_DNA.lower())
|
|
24
|
+
fasta = parse_sequence_input(f">fixture\n{BALANCED_DNA[:90]}\n{BALANCED_DNA[90:]}\n")
|
|
25
|
+
|
|
26
|
+
assert plain["normalized_sequence"] == BALANCED_DNA
|
|
27
|
+
assert fasta["normalized_sequence"] == BALANCED_DNA
|
|
28
|
+
assert fasta["fasta_header"] == "fixture"
|
|
29
|
+
assert fasta["input_type"] == "cds"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_multifasta_is_rejected_without_concatenation() -> None:
|
|
33
|
+
with pytest.raises(ValueError, match="Multiple FASTA records detected"):
|
|
34
|
+
parse_sequence_input(">one\nATGGCTTAA\n>two\nATGGAATAA")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_invalid_characters_and_frame_are_reported() -> None:
|
|
38
|
+
with pytest.raises(ValueError, match="Invalid sequence characters: B"):
|
|
39
|
+
parse_sequence_input("ATGBCC")
|
|
40
|
+
|
|
41
|
+
parsed = parse_sequence_input("ATGG")
|
|
42
|
+
assert parsed["input_type"] == "cds"
|
|
43
|
+
assert parsed["generation_allowed"] is False
|
|
44
|
+
assert parsed["summary"]["frame_valid"] is False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_protein_is_classified_separately() -> None:
|
|
48
|
+
parsed = parse_sequence_input("MSKGEELFTGVV")
|
|
49
|
+
assert parsed["input_type"] == "protein"
|
|
50
|
+
assert parsed["summary"]["protein_length_aa"] == 12
|
|
51
|
+
assert "reverse translate" in parsed["message"]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_300bp_cds_context_and_stop_policy_preserve_units() -> None:
|
|
55
|
+
parsed = parse_sequence_input(BALANCED_DNA)
|
|
56
|
+
candidate = restore_cds_stop_policy("ATG" + ("GCC" * 97) + "GAG", parsed)
|
|
57
|
+
|
|
58
|
+
assert len(BALANCED_DNA) == 300
|
|
59
|
+
assert parsed["summary"]["cds_length_bp"] == 300
|
|
60
|
+
assert parsed["summary"]["codon_count"] == 100
|
|
61
|
+
assert parsed["summary"]["protein_length_aa"] == 99
|
|
62
|
+
assert len(candidate) == 300
|
|
63
|
+
assert translate_dna(candidate).rstrip("*") == translate_dna(BALANCED_DNA).rstrip("*")
|
|
64
|
+
assert_pathway_invariants(BALANCED_DNA, candidate, parsed)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_internal_stop_disables_generation() -> None:
|
|
68
|
+
parsed = parse_sequence_input("ATGGCTTAAGCTGAA")
|
|
69
|
+
assert parsed["generation_allowed"] is False
|
|
70
|
+
assert parsed["summary"]["internal_stop_count"] == 1
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_required_preferred_and_ignored_decisions() -> None:
|
|
74
|
+
sequence = "ATG" + ("GCT" * 30) + "GGTCTC" + ("GCT" * 10) + "TAA"
|
|
75
|
+
criteria = default_acceptance_criteria(0, 100)
|
|
76
|
+
required = evaluate_candidate(sequence, cai=0.9, criteria=criteria)
|
|
77
|
+
assert required["automated_decision"] == "FAIL"
|
|
78
|
+
|
|
79
|
+
criteria["type_iis"]["mode"] = "preferred"
|
|
80
|
+
preferred = evaluate_candidate(sequence, cai=0.9, criteria=criteria)
|
|
81
|
+
assert preferred["automated_decision"] == "CONDITIONAL_PASS"
|
|
82
|
+
|
|
83
|
+
criteria["type_iis"]["mode"] = "ignored"
|
|
84
|
+
criteria["local_gc"]["mode"] = "ignored"
|
|
85
|
+
criteria["repeats"]["mode"] = "ignored"
|
|
86
|
+
criteria["homopolymers"]["mode"] = "ignored"
|
|
87
|
+
criteria["forbidden_motifs"]["mode"] = "ignored"
|
|
88
|
+
ignored = evaluate_candidate(sequence, cai=0.9, criteria=criteria)
|
|
89
|
+
assert ignored["automated_decision"] == "PASS"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_preferred_repeat_issue_is_conditional() -> None:
|
|
93
|
+
repeat = "ATGGCTGAACTGTTTGGT" * 2
|
|
94
|
+
criteria = default_acceptance_criteria(0, 100)
|
|
95
|
+
criteria["type_iis"]["mode"] = "ignored"
|
|
96
|
+
criteria["local_gc"]["mode"] = "ignored"
|
|
97
|
+
criteria["homopolymers"]["mode"] = "ignored"
|
|
98
|
+
criteria["forbidden_motifs"]["mode"] = "ignored"
|
|
99
|
+
result = evaluate_candidate(repeat, cai=0.9, criteria=criteria)
|
|
100
|
+
assert result["automated_decision"] == "CONDITIONAL_PASS"
|
|
101
|
+
assert result["preferred_warning_count"] >= 1
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_manual_acceptance_of_fail_requires_reason_and_retains_fail() -> None:
|
|
105
|
+
with pytest.raises(ValueError, match="written reason"):
|
|
106
|
+
apply_reviewer_disposition("FAIL", {"disposition": "accept_with_exception"})
|
|
107
|
+
|
|
108
|
+
result = apply_reviewer_disposition(
|
|
109
|
+
"FAIL",
|
|
110
|
+
{"disposition": "accept_with_exception", "reason": "Documented synthesis exception"},
|
|
111
|
+
)
|
|
112
|
+
assert result["final_state"] == "MANUALLY_ACCEPTED"
|
|
113
|
+
assert result["automated_decision"] == "FAIL"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_criteria_validation_rejects_bad_mode() -> None:
|
|
117
|
+
with pytest.raises(ValueError, match="must be required"):
|
|
118
|
+
normalize_acceptance_criteria(
|
|
119
|
+
{"cai": {"mode": "blocking"}},
|
|
120
|
+
gc_min=40,
|
|
121
|
+
gc_max=55,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_required_type_iis_sites_and_forbidden_fixture_are_visible() -> None:
|
|
126
|
+
fixture = "ATG" + "GGTCTC" + "CGTCTC" + "GCTCTTC" + "AATAAA" + ("A" * 10) + "TAA"
|
|
127
|
+
criteria = default_acceptance_criteria(0, 100)
|
|
128
|
+
result = evaluate_candidate(fixture, cai=0.9, criteria=criteria)
|
|
129
|
+
assert result["automated_decision"] == "FAIL"
|
|
130
|
+
assert len(result["details"]["type_iis_sites"]) >= 3
|
|
131
|
+
assert result["details"]["forbidden_motifs"]
|
|
132
|
+
assert result["details"]["longest_homopolymer"] >= 10
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_original_cai_is_calculated_from_the_active_reference() -> None:
|
|
136
|
+
table = load_codon_usage_table()
|
|
137
|
+
high = "ATG" + "GCC" * 4 + "TAA"
|
|
138
|
+
low = "ATG" + "GCT" * 4 + "TAA"
|
|
139
|
+
high_cai = original_cai(high, table.codon_weights)
|
|
140
|
+
low_cai = original_cai(low, table.codon_weights)
|
|
141
|
+
assert high_cai is not None and low_cai is not None
|
|
142
|
+
assert high_cai != low_cai
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_all_criteria_pass_returns_pass() -> None:
|
|
146
|
+
criteria = default_acceptance_criteria(0, 100)
|
|
147
|
+
for criterion in criteria.values():
|
|
148
|
+
criterion["mode"] = "ignored"
|
|
149
|
+
result = evaluate_candidate("ATGGCTGAA", cai=0.9, criteria=criteria)
|
|
150
|
+
assert result["automated_decision"] == "PASS"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/core/interfaces/optimizer.py
RENAMED
|
File without changes
|
{factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/core/interfaces/validator.py
RENAMED
|
File without changes
|
{factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/nbenthamiana_codons.json
RENAMED
|
File without changes
|
{factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/nbenthamiana_golden_set.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/templates/high_expression.json
RENAMED
|
File without changes
|
|
File without changes
|
{factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/wolffia_globosa_codons.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/construct_builder.py
RENAMED
|
File without changes
|
|
File without changes
|
{factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/rules/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/rules/rule_engine.py
RENAMED
|
File without changes
|
|
File without changes
|
{factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/scoring_ml.py
RENAMED
|
File without changes
|
|
File without changes
|
{factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/validator.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/protein_risk/risk_classifier.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/schemas/design_package.schema.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/validation/package_generator.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge_cds.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge_cds.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_benchmark_codon_table_metadata.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_openbio_missing_metric_contract.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|