factorforge-cds 3.3.1__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.1 → factorforge_cds-3.4.0}/PKG-INFO +2 -2
  2. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/README.md +1 -1
  3. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/pyproject.toml +1 -1
  4. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/__init__.py +1 -1
  5. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/analysis/feasibility.py +30 -3
  6. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/cli/main.py +21 -11
  7. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/core/interfaces/optimizer.py +1 -1
  8. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/data/nbenthamiana_golden_set.json +4 -0
  9. factorforge_cds-3.4.0/src/factorforge/design_review.py +437 -0
  10. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/engines/__init__.py +1 -1
  11. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/__init__.py +1 -1
  12. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/optimizer.py +38 -8
  13. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/pipeline.py +38 -15
  14. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/rules/reverse_translator.py +81 -10
  15. factorforge_cds-3.4.0/src/factorforge/review/__init__.py +2 -0
  16. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/schemas/design_package.py +30 -0
  17. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge_cds.egg-info/PKG-INFO +2 -2
  18. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge_cds.egg-info/SOURCES.txt +4 -0
  19. factorforge_cds-3.4.0/tests/test_benchmark_cli_output_guard.py +87 -0
  20. factorforge_cds-3.4.0/tests/test_design_review.py +150 -0
  21. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/LICENSE +0 -0
  22. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/setup.cfg +0 -0
  23. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/__main__.py +0 -0
  24. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/analysis/__init__.py +0 -0
  25. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/analysis/metrics.py +0 -0
  26. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/cli/__init__.py +0 -0
  27. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/cli/legacy_cli.py +0 -0
  28. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/core/interfaces/__init__.py +0 -0
  29. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/core/interfaces/exporter.py +0 -0
  30. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/core/interfaces/validator.py +0 -0
  31. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/data/nbenthamiana_codons.json +0 -0
  32. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/data/ntabacum_codons.json +0 -0
  33. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/data/profiles/nbev11_cds_all_derived_codons.json +0 -0
  34. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/data/profiles/nbev11_cds_all_derived_filtered_stats.json +0 -0
  35. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/data/profiles/nbev11_cds_all_derived_manifest.json +0 -0
  36. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/data/profiles/nbev11_cds_hc_derived_codons.json +0 -0
  37. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/data/profiles/nbev11_cds_hc_derived_filtered_stats.json +0 -0
  38. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/data/profiles/nbev11_cds_hc_derived_manifest.json +0 -0
  39. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/data/profiles/qld183_v103_derived_codons.json +0 -0
  40. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/data/profiles/qld183_v103_derived_manifest.json +0 -0
  41. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/data/profiles/qld183_v103_filtered_stats.json +0 -0
  42. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/data/reference/reference_policy_manifest.json +0 -0
  43. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/data/templates/high_expression.json +0 -0
  44. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/data/templates/standard_expression.json +0 -0
  45. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/data/wolffia_globosa_codons.json +0 -0
  46. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/database.py +0 -0
  47. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/codon_table_builder.py +0 -0
  48. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/construct_builder.py +0 -0
  49. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/exporter.py +0 -0
  50. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/rules/__init__.py +0 -0
  51. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/rules/domesticator.py +0 -0
  52. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/rules/rule_engine.py +0 -0
  53. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/scoring.py +0 -0
  54. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/scoring_ml.py +0 -0
  55. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/utils.py +0 -0
  56. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/engines/profile/validator.py +0 -0
  57. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/engines/registry.py +0 -0
  58. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/io/__init__.py +0 -0
  59. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/io/fasta.py +0 -0
  60. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/io/validation.py +0 -0
  61. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/protein_risk/__init__.py +0 -0
  62. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/protein_risk/annotate.py +0 -0
  63. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/protein_risk/kd_scale.py +0 -0
  64. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/protein_risk/risk_classifier.py +0 -0
  65. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/protein_risk/sp_predict.py +0 -0
  66. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/protein_risk/tm_predict.py +0 -0
  67. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/registry/__init__.py +0 -0
  68. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/registry/registry_loader.py +0 -0
  69. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/schemas/__init__.py +0 -0
  70. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/schemas/design_package.schema.json +0 -0
  71. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/utils/__init__.py +0 -0
  72. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/utils/construct_id.py +0 -0
  73. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/utils/exceptions.py +0 -0
  74. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/utils/restriction_sites.py +0 -0
  75. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/utils/sequence_validator.py +0 -0
  76. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/utils/validation.py +0 -0
  77. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/validation/__init__.py +0 -0
  78. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/validation/cli.py +0 -0
  79. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/validation/package_generator.py +0 -0
  80. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/validation_registry.py +0 -0
  81. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge/validation_report.py +0 -0
  82. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge_cds.egg-info/dependency_links.txt +0 -0
  83. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge_cds.egg-info/entry_points.txt +0 -0
  84. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge_cds.egg-info/requires.txt +0 -0
  85. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/src/factorforge_cds.egg-info/top_level.txt +0 -0
  86. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/tests/test_baselines.py +0 -0
  87. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/tests/test_benchmark_codon_table_metadata.py +0 -0
  88. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/tests/test_benchmark_regression.py +0 -0
  89. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/tests/test_benchmark_scoring.py +0 -0
  90. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/tests/test_benchmark_smoke.py +0 -0
  91. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/tests/test_cai.py +0 -0
  92. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/tests/test_codon_table_manifest.py +0 -0
  93. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/tests/test_database.py +0 -0
  94. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/tests/test_design_package_schema.py +0 -0
  95. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/tests/test_design_package_semantics.py +0 -0
  96. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/tests/test_design_package_serialization.py +0 -0
  97. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/tests/test_docs_consistency.py +0 -0
  98. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/tests/test_fasta_io.py +0 -0
  99. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/tests/test_gc_content.py +0 -0
  100. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/tests/test_host_profile_metadata.py +0 -0
  101. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/tests/test_iupac_validation.py +0 -0
  102. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/tests/test_legacy_cli.py +0 -0
  103. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/tests/test_no_raw_sequence_logging.py +0 -0
  104. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/tests/test_openbio_missing_metric_contract.py +0 -0
  105. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/tests/test_parameter_registry.py +0 -0
  106. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/tests/test_protein_risk.py +0 -0
  107. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/tests/test_registry_production_sync.py +0 -0
  108. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/tests/test_restriction_sites.py +0 -0
  109. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/tests/test_sequence_validator.py +0 -0
  110. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/tests/test_translation_integrity.py +0 -0
  111. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/tests/test_validation_contract_compat.py +0 -0
  112. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/tests/test_validation_registry.py +0 -0
  113. {factorforge_cds-3.3.1 → factorforge_cds-3.4.0}/tests/test_validation_report.py +0 -0
  114. {factorforge_cds-3.3.1 → 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.1
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.1 (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.1 (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.1"
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.1"
7
+ __version__ = "3.4.0"
8
8
  __author__ = "Eijex"
9
9
 
10
10
  # Auto-register engines (safe when running from source tree)
@@ -51,6 +51,7 @@ def _cai_from_log(log_sum: float, codon_count: int) -> float:
51
51
  def _candidate_summary(
52
52
  dna_sequence: str | None,
53
53
  codon_weights: dict[str, float],
54
+ cai_authority: dict[str, Any],
54
55
  ) -> dict[str, Any] | None:
55
56
  if dna_sequence is None:
56
57
  return None
@@ -59,6 +60,7 @@ def _candidate_summary(
59
60
  return {
60
61
  "dna_sequence": dna_sequence,
61
62
  "cai": calculate_cai(dna_sequence, codon_weights),
63
+ "cai_authority": dict(cai_authority),
62
64
  "gc": calculate_gc(dna_sequence),
63
65
  "first_region_gc": calculate_first_region_gc(dna_sequence),
64
66
  "gc_window_min": min(window_values) if window_values else 0.0,
@@ -66,6 +68,20 @@ def _candidate_summary(
66
68
  }
67
69
 
68
70
 
71
+ def _build_cai_authority(codon_reference_id: str | None) -> dict[str, Any]:
72
+ if codon_reference_id is None:
73
+ return {
74
+ "reference_id": None,
75
+ "reference_role": "cai_evaluation",
76
+ "reference_relationship": "unresolved",
77
+ }
78
+ return {
79
+ "reference_id": codon_reference_id,
80
+ "reference_role": "cai_evaluation",
81
+ "reference_relationship": "same_as_generation_reference",
82
+ }
83
+
84
+
69
85
  def _best_gc_under_gc_range(
70
86
  states: dict[int, float],
71
87
  protein_length: int,
@@ -104,6 +120,7 @@ def analyze_feasibility(
104
120
  target_gc_low: float = DEFAULT_GC_LOW,
105
121
  target_gc_high: float = DEFAULT_GC_HIGH,
106
122
  gc_ranges: list[tuple[float, float]] | None = None,
123
+ codon_reference_id: str | None = None,
107
124
  ) -> dict[str, Any]:
108
125
  """Compute exact CAI/GC feasibility over synonymous codon choices.
109
126
 
@@ -160,6 +177,7 @@ def analyze_feasibility(
160
177
 
161
178
  protein_length = len(protein)
162
179
  total_bases = protein_length * 3
180
+ cai_authority = _build_cai_authority(codon_reference_id)
163
181
  best_any_gc = max(states, key=lambda gc_count: states[gc_count])
164
182
  best_any_log_sum = states[best_any_gc]
165
183
  best_any_sequence = _reconstruct_sequence(backrefs, best_any_gc)
@@ -180,7 +198,7 @@ def analyze_feasibility(
180
198
  range_results[key] = {
181
199
  "feasible": True,
182
200
  "max_cai": _cai_from_log(states[gc_count], protein_length),
183
- "best_candidate": _candidate_summary(sequence, codon_weights),
201
+ "best_candidate": _candidate_summary(sequence, codon_weights, cai_authority),
184
202
  }
185
203
 
186
204
  target_cai_possible = (
@@ -192,8 +210,13 @@ def analyze_feasibility(
192
210
  "protein_length": protein_length,
193
211
  "minimum_possible_gc": (min_gc_count / total_bases) * 100.0,
194
212
  "maximum_possible_gc": (max_gc_count / total_bases) * 100.0,
213
+ "cai_authority": dict(cai_authority),
195
214
  "maximum_achievable_cai_without_gc": _cai_from_log(best_any_log_sum, protein_length),
196
- "best_candidate_without_gc": _candidate_summary(best_any_sequence, codon_weights),
215
+ "best_candidate_without_gc": _candidate_summary(
216
+ best_any_sequence,
217
+ codon_weights,
218
+ cai_authority,
219
+ ),
197
220
  "ranges": range_results,
198
221
  "target": {
199
222
  "cai": target_cai,
@@ -206,7 +229,11 @@ def analyze_feasibility(
206
229
  else None
207
230
  ),
208
231
  "best_candidate": (
209
- _candidate_summary(_reconstruct_sequence(backrefs, target_gc_count), codon_weights)
232
+ _candidate_summary(
233
+ _reconstruct_sequence(backrefs, target_gc_count),
234
+ codon_weights,
235
+ cai_authority,
236
+ )
210
237
  if target_gc_count is not None
211
238
  else None
212
239
  ),
@@ -154,6 +154,7 @@ def _build_dp_result(
154
154
  gc_max: float,
155
155
  cai_target: float = DEFAULT_CAI_TARGET,
156
156
  codon_table_path: Path | None = None,
157
+ codon_reference_id: str | None = None,
157
158
  ):
158
159
  """Run the constraint-based DP feasibility engine for a single protein sequence."""
159
160
  if objective != "feasibility_best":
@@ -171,6 +172,7 @@ def _build_dp_result(
171
172
  target_cai=cai_target,
172
173
  target_gc_low=gc_min,
173
174
  target_gc_high=gc_max,
175
+ codon_reference_id=codon_reference_id,
174
176
  )
175
177
  best = result["target"]["best_candidate"]
176
178
  feasible = best is not None
@@ -222,7 +224,10 @@ def _format_profile_fasta(sequence_id: str, profile: str, result) -> str:
222
224
  cai = float(result.metrics.get("cai", 0.0))
223
225
  gc = float(result.metrics.get("gc_percent", result.metrics.get("gc_content", 0.0)))
224
226
  score = float(result.metrics.get("score", 0.0))
225
- header = f">{sequence_id}|profile={profile}|cai={cai:.3f}|gc={gc:.2f}|score={score:.3f}"
227
+ header = (
228
+ f">{sequence_id}|engine=profile|profile={profile}|"
229
+ f"cai={cai:.3f}|gc={gc:.2f}|score={score:.3f}"
230
+ )
226
231
  return f"{header}\n{_wrap_sequence(result.sequence)}\n"
227
232
 
228
233
 
@@ -399,7 +404,10 @@ def optimize(
399
404
  if reference_table_path is not None:
400
405
  from factorforge.engines.profile.optimizer import RuleBasedOptimizer
401
406
 
402
- optimizer = RuleBasedOptimizer(codon_table_path=str(reference_table_path))
407
+ optimizer = RuleBasedOptimizer(
408
+ codon_table_path=str(reference_table_path),
409
+ generation_reference_id=reference_id,
410
+ )
403
411
  else:
404
412
  optimizer = EngineRegistry.get("profile")
405
413
  profile_results = []
@@ -436,7 +444,10 @@ def optimize(
436
444
  if reference_table_path is not None and engine == "profile":
437
445
  from factorforge.engines.profile.optimizer import RuleBasedOptimizer
438
446
 
439
- optimizer = RuleBasedOptimizer(codon_table_path=str(reference_table_path))
447
+ optimizer = RuleBasedOptimizer(
448
+ codon_table_path=str(reference_table_path),
449
+ generation_reference_id=reference_id,
450
+ )
440
451
  else:
441
452
  optimizer = EngineRegistry.get(engine)
442
453
  payload = [{"id": seq_id, "sequence": seq} for seq_id, seq in fasta_records]
@@ -465,14 +476,9 @@ def optimize(
465
476
  combined_fasta = []
466
477
  for idx, result in enumerate(results):
467
478
  seq_id = payload[idx]["id"]
468
- cai = result.metrics.get("cai", 0.0)
469
- gc = result.metrics.get("gc_percent", result.metrics.get("gc_content", 0.0))
470
- score = result.metrics.get("score", 0.0)
471
- header = (
472
- f">{seq_id}|profile={profile}|cai={float(cai):.3f}|"
473
- f"gc={float(gc):.2f}|score={float(score):.3f}"
479
+ combined_fasta.append(
480
+ _format_profile_fasta(seq_id, profile, result).rstrip()
474
481
  )
475
- combined_fasta.append(f"{header}\n{_wrap_sequence(result.sequence)}")
476
482
  out_content = "\n".join(combined_fasta) + "\n"
477
483
 
478
484
  if output:
@@ -497,6 +503,7 @@ def optimize(
497
503
  gc_max=gc_max,
498
504
  cai_target=cai_target,
499
505
  codon_table_path=reference_table_path,
506
+ codon_reference_id=reference_id,
500
507
  )
501
508
  dna_sequence = best["dna_sequence"]
502
509
  cai = float(best["cai"])
@@ -566,7 +573,10 @@ def optimize(
566
573
  if reference_table_path is not None and engine == "profile":
567
574
  from factorforge.engines.profile.optimizer import RuleBasedOptimizer
568
575
 
569
- optimizer = RuleBasedOptimizer(codon_table_path=str(reference_table_path))
576
+ optimizer = RuleBasedOptimizer(
577
+ codon_table_path=str(reference_table_path),
578
+ generation_reference_id=reference_id,
579
+ )
570
580
  else:
571
581
  optimizer = EngineRegistry.get(engine)
572
582
 
@@ -16,7 +16,7 @@ class OptimizationResult:
16
16
  def __init__(
17
17
  self,
18
18
  sequence: str,
19
- metrics: dict[str, float],
19
+ metrics: dict[str, Any],
20
20
  metadata: dict[str, Any] | None = None,
21
21
  ) -> None:
22
22
  self.sequence = sequence
@@ -1,6 +1,10 @@
1
1
  {
2
2
  "organism": "Nicotiana benthamiana",
3
+ "reference_id": "nbenthamiana_golden_set_v1",
4
+ "reference_role": "cai_evaluation",
5
+ "reference_version": "1",
3
6
  "source": "N. benthamiana high-expression CDS frequencies (70%, Kazusa CodonUsage DB / NCBI) blended with RNA-seq expression-weighted codon frequencies (30%, Grosse-Holz et al. 2018 Plant Biotechnology Journal; Prudhomme et al. 2024 Plant Biotechnology Journal / PRIDE PXD042916)",
7
+ "source_basis": "N. benthamiana high-expression CDS frequencies (70%, Kazusa CodonUsage DB / NCBI) blended with RNA-seq expression-weighted codon frequencies (30%, Grosse-Holz et al. 2018 Plant Biotechnology Journal; Prudhomme et al. 2024 Plant Biotechnology Journal / PRIDE PXD042916)",
4
8
  "description": "Codon usage frequencies weighted toward highly expressed N. benthamiana genes (RuBisCO, ribosomal proteins, histones). Used as CAI reference weights per Sharp & Li (1987). All source data is publicly available.",
5
9
  "blend_ratio": 0.7,
6
10
  "optimal_gc_content": 42.5,
@@ -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)