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.
Files changed (114) hide show
  1. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/PKG-INFO +2 -2
  2. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/README.md +1 -1
  3. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/pyproject.toml +1 -1
  4. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/__init__.py +1 -1
  5. factorforge_cds-3.4.0/src/factorforge/design_review.py +437 -0
  6. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/__init__.py +1 -1
  7. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/__init__.py +1 -1
  8. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/optimizer.py +25 -3
  9. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/pipeline.py +15 -2
  10. factorforge_cds-3.4.0/src/factorforge/review/__init__.py +2 -0
  11. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/schemas/design_package.py +30 -0
  12. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge_cds.egg-info/PKG-INFO +2 -2
  13. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge_cds.egg-info/SOURCES.txt +3 -0
  14. factorforge_cds-3.4.0/tests/test_design_review.py +150 -0
  15. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/LICENSE +0 -0
  16. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/setup.cfg +0 -0
  17. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/__main__.py +0 -0
  18. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/analysis/__init__.py +0 -0
  19. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/analysis/feasibility.py +0 -0
  20. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/analysis/metrics.py +0 -0
  21. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/cli/__init__.py +0 -0
  22. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/cli/legacy_cli.py +0 -0
  23. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/cli/main.py +0 -0
  24. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/core/interfaces/__init__.py +0 -0
  25. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/core/interfaces/exporter.py +0 -0
  26. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/core/interfaces/optimizer.py +0 -0
  27. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/core/interfaces/validator.py +0 -0
  28. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/nbenthamiana_codons.json +0 -0
  29. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/nbenthamiana_golden_set.json +0 -0
  30. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/ntabacum_codons.json +0 -0
  31. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/profiles/nbev11_cds_all_derived_codons.json +0 -0
  32. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/profiles/nbev11_cds_all_derived_filtered_stats.json +0 -0
  33. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/profiles/nbev11_cds_all_derived_manifest.json +0 -0
  34. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/profiles/nbev11_cds_hc_derived_codons.json +0 -0
  35. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/profiles/nbev11_cds_hc_derived_filtered_stats.json +0 -0
  36. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/profiles/nbev11_cds_hc_derived_manifest.json +0 -0
  37. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/profiles/qld183_v103_derived_codons.json +0 -0
  38. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/profiles/qld183_v103_derived_manifest.json +0 -0
  39. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/profiles/qld183_v103_filtered_stats.json +0 -0
  40. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/reference/reference_policy_manifest.json +0 -0
  41. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/templates/high_expression.json +0 -0
  42. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/templates/standard_expression.json +0 -0
  43. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/data/wolffia_globosa_codons.json +0 -0
  44. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/database.py +0 -0
  45. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/codon_table_builder.py +0 -0
  46. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/construct_builder.py +0 -0
  47. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/exporter.py +0 -0
  48. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/rules/__init__.py +0 -0
  49. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/rules/domesticator.py +0 -0
  50. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/rules/reverse_translator.py +0 -0
  51. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/rules/rule_engine.py +0 -0
  52. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/scoring.py +0 -0
  53. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/scoring_ml.py +0 -0
  54. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/utils.py +0 -0
  55. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/validator.py +0 -0
  56. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/engines/registry.py +0 -0
  57. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/io/__init__.py +0 -0
  58. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/io/fasta.py +0 -0
  59. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/io/validation.py +0 -0
  60. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/protein_risk/__init__.py +0 -0
  61. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/protein_risk/annotate.py +0 -0
  62. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/protein_risk/kd_scale.py +0 -0
  63. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/protein_risk/risk_classifier.py +0 -0
  64. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/protein_risk/sp_predict.py +0 -0
  65. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/protein_risk/tm_predict.py +0 -0
  66. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/registry/__init__.py +0 -0
  67. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/registry/registry_loader.py +0 -0
  68. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/schemas/__init__.py +0 -0
  69. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/schemas/design_package.schema.json +0 -0
  70. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/utils/__init__.py +0 -0
  71. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/utils/construct_id.py +0 -0
  72. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/utils/exceptions.py +0 -0
  73. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/utils/restriction_sites.py +0 -0
  74. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/utils/sequence_validator.py +0 -0
  75. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/utils/validation.py +0 -0
  76. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/validation/__init__.py +0 -0
  77. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/validation/cli.py +0 -0
  78. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/validation/package_generator.py +0 -0
  79. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/validation_registry.py +0 -0
  80. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge/validation_report.py +0 -0
  81. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge_cds.egg-info/dependency_links.txt +0 -0
  82. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge_cds.egg-info/entry_points.txt +0 -0
  83. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge_cds.egg-info/requires.txt +0 -0
  84. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/src/factorforge_cds.egg-info/top_level.txt +0 -0
  85. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_baselines.py +0 -0
  86. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_benchmark_cli_output_guard.py +0 -0
  87. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_benchmark_codon_table_metadata.py +0 -0
  88. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_benchmark_regression.py +0 -0
  89. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_benchmark_scoring.py +0 -0
  90. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_benchmark_smoke.py +0 -0
  91. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_cai.py +0 -0
  92. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_codon_table_manifest.py +0 -0
  93. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_database.py +0 -0
  94. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_design_package_schema.py +0 -0
  95. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_design_package_semantics.py +0 -0
  96. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_design_package_serialization.py +0 -0
  97. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_docs_consistency.py +0 -0
  98. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_fasta_io.py +0 -0
  99. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_gc_content.py +0 -0
  100. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_host_profile_metadata.py +0 -0
  101. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_iupac_validation.py +0 -0
  102. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_legacy_cli.py +0 -0
  103. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_no_raw_sequence_logging.py +0 -0
  104. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_openbio_missing_metric_contract.py +0 -0
  105. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_parameter_registry.py +0 -0
  106. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_protein_risk.py +0 -0
  107. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_registry_production_sync.py +0 -0
  108. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_restriction_sites.py +0 -0
  109. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_sequence_validator.py +0 -0
  110. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_translation_integrity.py +0 -0
  111. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_validation_contract_compat.py +0 -0
  112. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_validation_registry.py +0 -0
  113. {factorforge_cds-3.3.2 → factorforge_cds-3.4.0}/tests/test_validation_report.py +0 -0
  114. {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.2
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.3.2 (2026). Open-source constraint-based CDS design and sequence review.
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.3.2 (2026). Open-source constraint-based CDS design and sequence review.
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.3.2"
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"
@@ -4,7 +4,7 @@ FactorForge - Codon Optimization Platform
4
4
  profile: constraint-aware rule/profile engine
5
5
  """
6
6
 
7
- __version__ = "3.3.2"
7
+ __version__ = "3.4.0"
8
8
  __author__ = "Eijex"
9
9
 
10
10
  # Auto-register engines (safe when running from source tree)
@@ -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)
@@ -13,7 +13,7 @@ def register_builtin_engines() -> None:
13
13
  "profile",
14
14
  RuleBasedOptimizer,
15
15
  metadata={
16
- "version": "3.3.2",
16
+ "version": "3.4.0",
17
17
  "engine_type": "profile_rule_based",
18
18
  "role": "stable_profile_engine",
19
19
  "stable": True,
@@ -5,7 +5,7 @@ Production system (2026)
5
5
  Plant-specific rule-based optimization
6
6
  """
7
7
 
8
- __version__ = "3.3.2"
8
+ __version__ = "3.4.0"
9
9
 
10
10
  from .optimizer import RuleBasedOptimizer
11
11
  from .pipeline import OptimizationPipeline
@@ -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.3.2"
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. Reverse-translate (pick the best candidate)
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
- optimized_dna = processed_seq
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
- optimized_dna = processed
193
- expected_protein = translate_dna(processed).rstrip("*")
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(
@@ -0,0 +1,2 @@
1
+ """Compatibility package for FactorForge design-review helpers."""
2
+
@@ -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.2
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.3.2 (2026). Open-source constraint-based CDS design and sequence review.
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