factorforge-cds 3.2.6__tar.gz → 3.2.7__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 (100) hide show
  1. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/PKG-INFO +2 -2
  2. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/README.md +1 -1
  3. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/pyproject.toml +1 -1
  4. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/__init__.py +1 -1
  5. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/analysis/feasibility.py +14 -6
  6. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/analysis/metrics.py +4 -1
  7. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/cli/main.py +2 -2
  8. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/engines/__init__.py +1 -1
  9. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/engines/profile/__init__.py +1 -1
  10. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/engines/profile/optimizer.py +7 -3
  11. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/engines/profile/pipeline.py +3 -1
  12. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/engines/profile/rules/reverse_translator.py +27 -12
  13. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/engines/profile/rules/rule_engine.py +6 -4
  14. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/engines/profile/scoring.py +108 -19
  15. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/engines/profile/utils.py +21 -2
  16. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge_cds.egg-info/PKG-INFO +2 -2
  17. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/tests/test_benchmark_codon_table_metadata.py +32 -1
  18. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/tests/test_registry_production_sync.py +56 -0
  19. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/tests/test_worked_example.py +28 -1
  20. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/LICENSE +0 -0
  21. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/setup.cfg +0 -0
  22. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/__main__.py +0 -0
  23. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/analysis/__init__.py +0 -0
  24. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/cli/__init__.py +0 -0
  25. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/cli/legacy_cli.py +0 -0
  26. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/core/interfaces/__init__.py +0 -0
  27. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/core/interfaces/exporter.py +0 -0
  28. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/core/interfaces/optimizer.py +0 -0
  29. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/core/interfaces/validator.py +0 -0
  30. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/data/nbenthamiana_codons.json +0 -0
  31. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/data/nbenthamiana_golden_set.json +0 -0
  32. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/data/ntabacum_codons.json +0 -0
  33. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/data/templates/high_expression.json +0 -0
  34. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/data/templates/standard_expression.json +0 -0
  35. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/data/wolffia_globosa_codons.json +0 -0
  36. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/database.py +0 -0
  37. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/engines/profile/codon_table_builder.py +0 -0
  38. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/engines/profile/construct_builder.py +0 -0
  39. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/engines/profile/exporter.py +0 -0
  40. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/engines/profile/rules/__init__.py +0 -0
  41. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/engines/profile/rules/domesticator.py +0 -0
  42. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/engines/profile/scoring_ml.py +0 -0
  43. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/engines/profile/validator.py +0 -0
  44. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/engines/registry.py +0 -0
  45. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/io/__init__.py +0 -0
  46. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/io/fasta.py +0 -0
  47. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/io/validation.py +0 -0
  48. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/protein_risk/__init__.py +0 -0
  49. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/protein_risk/annotate.py +0 -0
  50. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/protein_risk/kd_scale.py +0 -0
  51. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/protein_risk/risk_classifier.py +0 -0
  52. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/protein_risk/sp_predict.py +0 -0
  53. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/protein_risk/tm_predict.py +0 -0
  54. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/registry/__init__.py +0 -0
  55. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/registry/registry_loader.py +0 -0
  56. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/schemas/__init__.py +0 -0
  57. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/schemas/design_package.py +0 -0
  58. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/schemas/design_package.schema.json +0 -0
  59. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/utils/__init__.py +0 -0
  60. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/utils/construct_id.py +0 -0
  61. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/utils/exceptions.py +0 -0
  62. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/utils/restriction_sites.py +0 -0
  63. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/utils/sequence_validator.py +0 -0
  64. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/utils/validation.py +0 -0
  65. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/validation/__init__.py +0 -0
  66. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/validation/cli.py +0 -0
  67. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/validation/package_generator.py +0 -0
  68. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/validation_registry.py +0 -0
  69. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge/validation_report.py +0 -0
  70. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge_cds.egg-info/SOURCES.txt +0 -0
  71. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge_cds.egg-info/dependency_links.txt +0 -0
  72. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge_cds.egg-info/entry_points.txt +0 -0
  73. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge_cds.egg-info/requires.txt +0 -0
  74. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/src/factorforge_cds.egg-info/top_level.txt +0 -0
  75. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/tests/test_baselines.py +0 -0
  76. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/tests/test_benchmark_regression.py +0 -0
  77. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/tests/test_benchmark_scoring.py +0 -0
  78. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/tests/test_benchmark_smoke.py +0 -0
  79. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/tests/test_cai.py +0 -0
  80. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/tests/test_codon_table_manifest.py +0 -0
  81. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/tests/test_database.py +0 -0
  82. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/tests/test_design_package_schema.py +0 -0
  83. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/tests/test_design_package_semantics.py +0 -0
  84. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/tests/test_design_package_serialization.py +0 -0
  85. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/tests/test_docs_consistency.py +0 -0
  86. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/tests/test_fasta_io.py +0 -0
  87. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/tests/test_gc_content.py +0 -0
  88. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/tests/test_host_profile_metadata.py +0 -0
  89. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/tests/test_iupac_validation.py +0 -0
  90. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/tests/test_legacy_cli.py +0 -0
  91. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/tests/test_no_raw_sequence_logging.py +0 -0
  92. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/tests/test_openbio_missing_metric_contract.py +0 -0
  93. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/tests/test_parameter_registry.py +0 -0
  94. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/tests/test_protein_risk.py +0 -0
  95. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/tests/test_restriction_sites.py +0 -0
  96. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/tests/test_sequence_validator.py +0 -0
  97. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/tests/test_translation_integrity.py +0 -0
  98. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/tests/test_validation_contract_compat.py +0 -0
  99. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/tests/test_validation_registry.py +0 -0
  100. {factorforge_cds-3.2.6 → factorforge_cds-3.2.7}/tests/test_validation_report.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: factorforge-cds
3
- Version: 3.2.6
3
+ Version: 3.2.7
4
4
  Summary: FactorForge - open-source CDS design and pre-synthesis sequence review engine 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.2.6 (2026). Open-source constraint-based CDS design engine.
96
+ FactorForge v3.2.7 (2026). Open-source constraint-based CDS design engine.
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.2.6 (2026). Open-source constraint-based CDS design engine.
63
+ FactorForge v3.2.7 (2026). Open-source constraint-based CDS design engine.
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.2.6"
7
+ version = "3.2.7"
8
8
  description = "FactorForge - open-source CDS design and pre-synthesis sequence review engine 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.2.6"
7
+ __version__ = "3.2.7"
8
8
  __author__ = "Eijex"
9
9
 
10
10
  # Auto-register engines (safe when running from source tree)
@@ -14,14 +14,18 @@ from factorforge.analysis.metrics import (
14
14
  )
15
15
 
16
16
 
17
- # Defaults calibrated to nbenthamiana profile engine output distribution
18
- # (internal benchmark, n=49): avg CAI=0.76, avg GC=60.1% (range 55-71%).
19
- # DEFAULT_CAI_TARGET=0.82 aligns with industry practice (>0.8) and is achievable.
17
+ # DEFAULT_CAI_TARGET=0.82 aligns with industry practice (>0.8) and is achievable
18
+ # (internal benchmark, n=49, avg CAI=0.76).
19
+ # DEFAULT_GC_LOW/HIGH = native genome-composition anchor for N. benthamiana CDS
20
+ # (_analysis/025 STEP 2: 004 endogenous n=10 measured range 40-47%, cross-checked
21
+ # against nbev11_cds_hc/all and qld183_v103 derived-asset GC ~42.8-43.1% and
22
+ # external ground truth ~44%). NOT an empirically validated expression optimum —
23
+ # this is a composition anchor, not a target to maximize toward.
20
24
  # Exported as named constants so tests/test_registry_production_sync.py can
21
25
  # strictly compare them against the registry (single source of truth).
22
26
  DEFAULT_CAI_TARGET: float = 0.82
23
- DEFAULT_GC_LOW: float = 55.0
24
- DEFAULT_GC_HIGH: float = 65.0
27
+ DEFAULT_GC_LOW: float = 40.0
28
+ DEFAULT_GC_HIGH: float = 47.0
25
29
 
26
30
 
27
31
  AA_TO_CODONS: dict[str, list[str]] = {}
@@ -116,7 +120,11 @@ def analyze_feasibility(
116
120
  if not protein:
117
121
  raise ValueError("protein_sequence must not be empty")
118
122
 
119
- ranges = gc_ranges or [(55.0, 65.0), (50.0, 65.0), (40.0, 65.0)]
123
+ # Default exploration ranges: genome-grounded native anchor first, then
124
+ # progressively wider windows. (55.0, 65.0) is retained ONLY as an explicit
125
+ # non-native/high-GC option (matches legacy engine-output-calibrated band) —
126
+ # callers must opt in explicitly to it, it is not the production default.
127
+ ranges = gc_ranges or [(40.0, 47.0), (35.0, 50.0), (55.0, 65.0)]
120
128
  normalized_ranges = [
121
129
  (_normalize_gc_bound(low), _normalize_gc_bound(high)) for low, high in ranges
122
130
  ]
@@ -98,7 +98,10 @@ class CodonUsageTable:
98
98
 
99
99
 
100
100
  def _default_codon_table_path() -> Path:
101
- return get_data_path() / "nbenthamiana_codons.json"
101
+ # Job 168 / v3.3.0 (_analysis/025): production default switched from the
102
+ # legacy Kazusa/SGN-derived table to the NbeV1.1 LAB-strain high-confidence
103
+ # derived table. See data/reference/active_codon_reference.json.
104
+ return get_data_path() / "profiles" / "nbev11_cds_hc_derived_codons.json"
102
105
 
103
106
 
104
107
  def load_codon_usage_table(path: Path | None = None) -> CodonUsageTable:
@@ -164,8 +164,8 @@ def list_engines():
164
164
  type=click.Choice(["feasibility_best"], case_sensitive=False),
165
165
  help="DP objective",
166
166
  )
167
- @click.option("--gc-min", type=float, default=55.0, help="Minimum target GC percentage")
168
- @click.option("--gc-max", type=float, default=65.0, help="Maximum target GC percentage")
167
+ @click.option("--gc-min", type=float, default=40.0, help="Minimum target GC percentage")
168
+ @click.option("--gc-max", type=float, default=47.0, help="Maximum target GC percentage")
169
169
  @click.option("--template", "construct_template", help="Construct template name")
170
170
  @click.option("--output", "-o", help="Output file")
171
171
  @click.option("--format", "output_format", default="fasta", help="Output format (fasta, genbank)")
@@ -13,7 +13,7 @@ def register_builtin_engines() -> None:
13
13
  "profile",
14
14
  RuleBasedOptimizer,
15
15
  metadata={
16
- "version": "3.2.6",
16
+ "version": "3.2.7",
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.2.6"
8
+ __version__ = "3.2.7"
9
9
 
10
10
  from .optimizer import RuleBasedOptimizer
11
11
  from .pipeline import OptimizationPipeline
@@ -20,7 +20,7 @@ class RuleBasedOptimizer(OptimizerEngine):
20
20
  """Profile-based rule optimization engine."""
21
21
 
22
22
  name = "Profile-based"
23
- version = "3.2.6"
23
+ version = "3.2.7"
24
24
 
25
25
  def __init__(self, codon_table_path: str | None = None) -> None:
26
26
  """
@@ -122,12 +122,16 @@ class RuleBasedOptimizer(OptimizerEngine):
122
122
  cai = translator.calculate_cai(optimized_dna)
123
123
  gc = translator.calculate_gc_content(optimized_dna)
124
124
  score = calculate_composite_score(
125
- cai=cai, gc=gc, sequence=optimized_dna, profile=profile_value
125
+ cai=cai, gc=gc, sequence=optimized_dna, profile=profile_value, host=host
126
126
  )
127
127
  candidates = [{"sequence": optimized_dna, "cai": cai, "gc": gc, "score": score}]
128
128
  else:
129
+ translate_kwargs = {
130
+ k: v for k, v in kwargs.items()
131
+ if k not in ("scan_mode", "scan_include", "scan_exclude")
132
+ }
129
133
  candidates = translator.generate_candidates(
130
- processed_seq, profile=opt_profile, n=1, seed=seed
134
+ processed_seq, profile=opt_profile, n=1, seed=seed, **translate_kwargs
131
135
  )
132
136
  if not candidates:
133
137
  raise ValueError("No candidates generated for input sequence.")
@@ -192,7 +192,8 @@ class OptimizationPipeline:
192
192
  cai = translator.calculate_cai(optimized_dna)
193
193
  gc = translator.calculate_gc_content(optimized_dna)
194
194
  score = calculate_composite_score(
195
- cai=cai, gc=gc, sequence=optimized_dna, profile=effective_profile
195
+ cai=cai, gc=gc, sequence=optimized_dna, profile=effective_profile,
196
+ host=effective_host,
196
197
  )
197
198
  candidate_metrics = {"cai": cai, "gc": gc, "score": score}
198
199
  else:
@@ -244,6 +245,7 @@ class OptimizationPipeline:
244
245
  gc=candidate_metrics["gc"],
245
246
  sequence=optimized_dna,
246
247
  profile=effective_profile,
248
+ host=effective_host,
247
249
  )
248
250
  logger.info(
249
251
  f"Dinucleotide reduction [{dinu_fix['mode']}]: "
@@ -15,12 +15,16 @@ from enum import Enum
15
15
  from pathlib import Path
16
16
  from typing import Any, cast
17
17
 
18
- from factorforge.engines.profile.scoring import GC_OPT_MID, calculate_composite_score
18
+ from factorforge.engines.profile.scoring import (
19
+ calculate_composite_score,
20
+ resolve_host_gc_range,
21
+ )
19
22
  from factorforge.engines.profile.utils import (
20
23
  build_aa_to_codons_map,
21
24
  calculate_gc,
22
25
  get_data_path,
23
26
  load_golden_set,
27
+ resolve_host_codon_table_path,
24
28
  )
25
29
  from factorforge.utils.exceptions import EmptyCandidateError
26
30
 
@@ -66,7 +70,7 @@ class ReverseTranslator:
66
70
  if codon_table_path is None:
67
71
  # Use centralized data path management
68
72
  data_dir = get_data_path()
69
- codon_table_path = data_dir / f"{host}_codons.json"
73
+ codon_table_path = resolve_host_codon_table_path(host, data_dir)
70
74
 
71
75
  self.codon_table: dict[str, Any] = self._load_codon_table(codon_table_path)
72
76
  self.aa_to_codons: dict[str, list[tuple[str, float]]] = self._build_aa_to_codons_map()
@@ -370,10 +374,15 @@ class ReverseTranslator:
370
374
  Balanced profile: CAI first, GC balanced
371
375
 
372
376
  - Preferred codon ratio: 70%
373
- - Target GC: 55-65% (internal benchmark: avg output 60.1%)
377
+ - Target GC: host composition band (nbenthamiana: 40-47%, native
378
+ genome-composition anchor; see _analysis/025 STEP 2 and
379
+ engines/profile/scoring.py GC_OPT_MIN/MAX. Not an empirically
380
+ validated expression optimum. Other hosts: resolve_host_gc_range()
381
+ default, unchanged from pre-v3.3.0 behavior.)
374
382
  """
375
- target_gc_min = kwargs.get("target_gc_min", 55)
376
- target_gc_max = kwargs.get("target_gc_max", 65)
383
+ _host_gc_min, _host_gc_max = resolve_host_gc_range(self.host)
384
+ target_gc_min = kwargs.get("target_gc_min", _host_gc_min)
385
+ target_gc_max = kwargs.get("target_gc_max", _host_gc_max)
377
386
  preferred_ratio = kwargs.get("preferred_ratio", 0.7)
378
387
  max_attempts = kwargs.get("max_gc_attempts", 10)
379
388
  if max_attempts < 1:
@@ -443,19 +452,19 @@ class ReverseTranslator:
443
452
  """GC-Target profile: drive global GC toward a configurable target.
444
453
 
445
454
  Targets the caller-supplied ``target_gc`` if provided, otherwise the
446
- host-profile GC midpoint (GC_OPT_MID = 60% for N. benthamiana). To target
447
- a lower GC (e.g. for specific vector requirements), pass target_gc explicitly.
455
+ active host's composition midpoint (resolve_host_gc_range(self.host);
456
+ 43.5% for nbenthamiana since v3.3.0 / _analysis/025, 60.0% for other
457
+ hosts pending their own host-specific analysis). To target a different
458
+ GC (e.g. for specific vector requirements), pass target_gc explicitly.
448
459
 
449
460
  - GC constraint first
450
461
  - CAI may be sacrificed
451
462
  - Balance local window GC (50 bp)
452
-
453
- TODO: GC_OPT_MID is currently a single N. benthamiana-calibrated constant.
454
- When per-host GC profiles are added, source the default from the active host.
455
463
  """
456
464
  target_gc = kwargs.get("target_gc")
457
465
  if target_gc is None:
458
- target_gc = GC_OPT_MID
466
+ _host_gc_min, _host_gc_max = resolve_host_gc_range(self.host)
467
+ target_gc = (_host_gc_min + _host_gc_max) / 2
459
468
 
460
469
  dna_seq: list[str] = []
461
470
 
@@ -507,9 +516,14 @@ class ReverseTranslator:
507
516
  raise ValueError("max_attempts must be >= 1")
508
517
  last_seq = ""
509
518
 
519
+ balanced_kwargs = {
520
+ k: v for k, v in kwargs.items() if k in ("target_gc_min", "target_gc_max", "max_gc_attempts")
521
+ }
522
+ balanced_kwargs["preferred_ratio"] = 0.6
523
+
510
524
  for attempt in range(max_attempts):
511
525
  # Start with Balanced strategy
512
- dna_seq = self._balanced_translate(protein_seq, preferred_ratio=0.6)
526
+ dna_seq = self._balanced_translate(protein_seq, **balanced_kwargs)
513
527
  last_seq = dna_seq
514
528
 
515
529
  # Check restriction sites (forward + reverse complement)
@@ -710,6 +724,7 @@ class ReverseTranslator:
710
724
  gc=gc,
711
725
  sequence=dna_seq,
712
726
  profile=profile.value,
727
+ host=self.host,
713
728
  **kwargs,
714
729
  )
715
730
  return {"sequence": dna_seq, "cai": cai, "gc": gc, "score": score}
@@ -358,10 +358,12 @@ class RuleEngine:
358
358
 
359
359
  This is a LOCAL synthesis/extreme-window guard (default 25-75% over a
360
360
  50 bp window), NOT the global GC target. Global GC is governed separately
361
- by the scoring band (GC_OPT_MIN/MAX, ~55-65%) and the API/DP gc_min/gc_max
362
- constraints. The wide 25-75% band intentionally flags only synthesis-hostile
363
- local windows; narrowing it toward the global optimum would raise false
364
- positives against the engine's own output distribution (internal benchmark: 55-71%).
361
+ by the scoring band (GC_OPT_MIN/MAX, native genome-composition anchor
362
+ ~40-47%, see _analysis/025 STEP 2) and the API/DP gc_min/gc_max
363
+ constraints. The wide 25-75% local band is an independent synthesis/
364
+ hairpin-risk guard (registry `gc_extreme_local_window`), not derived from
365
+ or coupled to the global GC target — narrowing it is a separate decision
366
+ from the global band retarget and is out of scope here.
365
367
 
366
368
  Args:
367
369
  seq: DNA sequence
@@ -12,16 +12,43 @@ from typing import Any
12
12
  logger = logging.getLogger(__name__)
13
13
 
14
14
  # GC band for N. benthamiana codon-optimized sequences.
15
- # Benchmark (internal, n=49): balanced profile output average GC% = 60.1%
16
- # (range 55-71%). The genome-wide average (~42%) reflects all genes, not the
17
- # high-expression codon table which exhibits 3rd-position GC bias.
18
- # These constants define the acceptable band — sequences within [GC_OPT_MIN, GC_OPT_MAX]
19
- # receive full GC score; outside the band the score decays linearly.
20
- GC_OPT_MIN = 55.0
21
- GC_OPT_MAX = 65.0
22
- GC_OPT_MID = 60.0 # kept for gc_target point-scoring and viral_delivery centering
15
+ # Native genome-composition anchor (_analysis/025 STEP 2: 004 endogenous CDS
16
+ # n=10 measured range 40-47%; cross-checked against nbev11_cds_hc/all and
17
+ # qld183_v103 derived-asset GC ~42.8-43.1% and external ground truth ~44%).
18
+ # NOT an empirically validated expression optimum — this is a composition
19
+ # anchor, not a target to maximize toward. Sequences within [GC_OPT_MIN,
20
+ # GC_OPT_MAX] receive full GC score; outside the band the score decays linearly.
21
+ GC_OPT_MIN = 40.0
22
+ GC_OPT_MAX = 47.0
23
+ GC_OPT_MID = 43.5 # kept for gc_target point-scoring and viral_delivery centering
23
24
  GC_DECAY_WIDTH = 20.0 # percentage points outside band before score reaches 0.0
24
25
 
26
+ # Job 168 / v3.3.0 (_analysis/025) scoped the genome-composition re-derivation
27
+ # to N. benthamiana only. Other hosts (e.g. ntabacum/BY-2) keep the pre-v3.3.0
28
+ # global default (55-65%, internal benchmark n=49 avg GC=60.1%) until they get
29
+ # their own host-specific genome-composition analysis — they must NOT silently
30
+ # inherit GC_OPT_MIN/MAX, which is an N.-benthamiana-specific anchor.
31
+ GC_RANGE_DEFAULT: tuple[float, float] = (55.0, 65.0)
32
+ GC_RANGES_BY_HOST: dict[str, tuple[float, float]] = {
33
+ "nbenthamiana": (GC_OPT_MIN, GC_OPT_MAX),
34
+ }
35
+
36
+
37
+ def resolve_host_gc_range(host: str | None) -> tuple[float, float]:
38
+ """Resolve the (gc_min, gc_max) composition band for a host.
39
+
40
+ nbenthamiana uses the _analysis/025 native genome-composition anchor.
41
+ Any other host (including unknown ones) keeps the pre-v3.3.0 global
42
+ default band until it gets its own host-specific analysis.
43
+ """
44
+ return GC_RANGES_BY_HOST.get(host or "nbenthamiana", GC_RANGE_DEFAULT)
45
+
46
+
47
+ def resolve_host_gc_mid(host: str | None) -> float:
48
+ """Resolve the GC band midpoint for a host (see resolve_host_gc_range)."""
49
+ gc_min, gc_max = resolve_host_gc_range(host)
50
+ return (gc_min + gc_max) / 2
51
+
25
52
  # ViennaRNA availability cache
26
53
  _vienna_available: bool | None = None
27
54
 
@@ -45,7 +72,12 @@ class ScoringConfig:
45
72
  use_mfe: bool = True
46
73
 
47
74
  def __post_init__(self) -> None:
48
- """Normalize weights to sum to 1.0."""
75
+ """Validate inputs, then normalize weights to sum to 1.0."""
76
+ for field_name in ("w_cai", "w_gc", "w_mfe", "w_dinuc", "w_syncodonlm"):
77
+ if getattr(self, field_name) < 0:
78
+ raise ValueError(f"{field_name} must be >= 0, got {getattr(self, field_name)}")
79
+ if self.gc_min > self.gc_max:
80
+ raise ValueError(f"gc_min ({self.gc_min}) must be <= gc_max ({self.gc_max})")
49
81
  self._normalize()
50
82
 
51
83
  def _normalize(self) -> None:
@@ -110,6 +142,20 @@ def _check_vienna_available() -> bool:
110
142
  return _vienna_available
111
143
 
112
144
 
145
+ # 170-fix: ViennaRNA's RNA.fold() uses Zuker's MFE algorithm, O(n^3) time /
146
+ # O(n^2) memory — there was previously no length guard anywhere in the
147
+ # calculate_mfe() call chain, so a single request at/under the existing
148
+ # public API length limits (5000aa/15000bp) could pin a CPU core for minutes
149
+ # (algorithmic-complexity DoS, CWE-407; confirmed via faulthandler stack
150
+ # traces + an isolated RNA.fold() timing curve: 1000nt ~2.2s, 2000nt ~9.8s,
151
+ # 3000nt ~24.6s). calculate_mfe() is called twice per optimize() call
152
+ # (once for scoring, once independently for compute_mfe_evidence()
153
+ # provenance), so the real per-request cost is ~2x this curve. 1000nt
154
+ # (~333aa) keeps worst-case cost to roughly 4-5s even with that doubling,
155
+ # while still covering most realistic single-protein CDS design requests.
156
+ MFE_MAX_SEQUENCE_LENGTH = 1000
157
+
158
+
113
159
  def calculate_mfe(sequence: str) -> float | None:
114
160
  """
115
161
  Calculate minimum free energy (MFE) using ViennaRNA.
@@ -118,11 +164,22 @@ def calculate_mfe(sequence: str) -> float | None:
118
164
  sequence: DNA or RNA sequence.
119
165
 
120
166
  Returns:
121
- MFE in kcal/mol, or None if ViennaRNA is not available.
167
+ MFE in kcal/mol, or None if ViennaRNA is not available or the
168
+ sequence exceeds MFE_MAX_SEQUENCE_LENGTH.
122
169
  """
123
170
  if not _check_vienna_available():
124
171
  return None
125
172
 
173
+ if len(sequence) > MFE_MAX_SEQUENCE_LENGTH:
174
+ logger.warning(
175
+ "Sequence length (%d nt) exceeds MFE_MAX_SEQUENCE_LENGTH (%d nt); "
176
+ "skipping global MFE calculation to avoid an unbounded ViennaRNA "
177
+ "RNA.fold() runtime (O(n^3)). MFE scoring falls back to a neutral, "
178
+ "zero-weighted contribution for this candidate (170-fix).",
179
+ len(sequence), MFE_MAX_SEQUENCE_LENGTH,
180
+ )
181
+ return None
182
+
126
183
  try:
127
184
  import RNA
128
185
 
@@ -139,24 +196,34 @@ def normalize_mfe(mfe: float, seq_length: int) -> float:
139
196
  """
140
197
  Normalize MFE to 0-1 range where 1 = no structure (favorable).
141
198
 
142
- Uses empirical scaling: MFE per nucleotide typically ranges from
143
- -0.5 to 0.0 kcal/mol/nt for mRNA coding sequences.
199
+ Clamp range calibrated empirically in analysis 011
200
+ (eijex-workspace/_analysis/2026-06-26/011-mfe-clamp-calibration):
201
+ measured MFE/nt across 135 FactorForge outputs (N. benthamiana +
202
+ BY-2 hosts, 5 profiles) ranged -0.4064 to -0.1338 (combined 5th/95th
203
+ percentile -0.3839/-0.1760). The range below widens that empirical
204
+ percentile by a margin on both sides to avoid saturating ~10% of
205
+ future sequences at the score floor/ceiling.
144
206
 
145
207
  Args:
146
208
  mfe: Minimum free energy in kcal/mol.
147
209
  seq_length: Sequence length in nucleotides.
148
210
 
149
211
  Returns:
150
- Normalized MFE score (0-1, higher = less structured = better for translation).
212
+ Normalized MFE score (0-1) under this computational normalization,
213
+ where a higher value represents a less negative whole-CDS MFE/nt.
214
+ This is a Tier-0 computational heuristic only (see
215
+ eijex-validationHub/docs/CLAIM_EVIDENCE_BENCHMARK_MODEL.md §4) — no
216
+ biological interpretation (translation efficiency, mRNA stability,
217
+ or expression outcome) is implied or validated.
151
218
  """
152
219
  if seq_length == 0:
153
220
  return 0.5
154
221
 
155
222
  mfe_per_nt = mfe / seq_length
156
- # Clamp to expected range [-0.5, 0.0]
157
- clamped = max(-0.5, min(0.0, mfe_per_nt))
158
- # Map to [0, 1] where 0.0 kcal/mol/nt → 1.0 and -0.5 → 0.0
159
- return 1.0 + (clamped / 0.5)
223
+ # Clamp to empirically calibrated range [-0.40, -0.15] (analysis 011)
224
+ clamped = max(-0.40, min(-0.15, mfe_per_nt))
225
+ # Map to [0, 1] where -0.15 kcal/mol/nt → 1.0 and -0.40 → 0.0
226
+ return (clamped + 0.40) / 0.25
160
227
 
161
228
 
162
229
  def gc_band_score(
@@ -210,6 +277,7 @@ def calculate_composite_score(
210
277
  sequence: str | None = None,
211
278
  config: ScoringConfig | None = None,
212
279
  profile: str | None = None,
280
+ host: str | None = None,
213
281
  **kwargs: Any,
214
282
  ) -> float:
215
283
  """Calculate multidimensional composite score.
@@ -238,7 +306,12 @@ def calculate_composite_score(
238
306
  profile_name = (profile or "balanced").lower()
239
307
  config = PROFILE_SCORING_CONFIGS.get(profile_name)
240
308
  if config is None:
241
- config = PROFILE_SCORING_CONFIGS["balanced"]
309
+ supported = ", ".join(sorted(PROFILE_SCORING_CONFIGS))
310
+ raise ValueError(
311
+ f"Unknown profile: {profile_name}. Supported profiles: {supported}"
312
+ )
313
+ if "target_gc" in kwargs and profile_name != "gc_target":
314
+ raise ValueError("target_gc is only valid for the gc_target profile")
242
315
 
243
316
  # Component 1: CAI (already 0-1)
244
317
  cai_score = max(0.0, min(1.0, cai))
@@ -250,7 +323,15 @@ def calculate_composite_score(
250
323
  tgt = float(kwargs["target_gc"])
251
324
  gc_score = gc_band_score(gc, tgt - 5.0, tgt + 5.0, config.gc_decay_width)
252
325
  else:
253
- gc_score = gc_band_score(gc, config.gc_min, config.gc_max, config.gc_decay_width)
326
+ gc_min, gc_max = config.gc_min, config.gc_max
327
+ # Profiles that haven't customized the band (still on the
328
+ # GC_OPT_MIN/MAX default) follow the active host's composition band.
329
+ # Profiles with an explicit band override (e.g. viral_delivery) keep
330
+ # it regardless of host — that override is profile-specific, not a
331
+ # host default.
332
+ if (gc_min, gc_max) == (GC_OPT_MIN, GC_OPT_MAX):
333
+ gc_min, gc_max = resolve_host_gc_range(host)
334
+ gc_score = gc_band_score(gc, gc_min, gc_max, config.gc_decay_width)
254
335
 
255
336
  # Component 3: MFE (optional)
256
337
  mfe_score = 0.5 # neutral default
@@ -336,6 +417,14 @@ def compute_mfe_evidence(
336
417
  reason = "MFE was not computed because no sequence was provided."
337
418
  elif not _check_vienna_available():
338
419
  reason = "MFE was not computed because ViennaRNA is unavailable in this environment."
420
+ elif len(sequence) > MFE_MAX_SEQUENCE_LENGTH:
421
+ # 170-fix: distinguish a deliberate length-based skip from an actual
422
+ # fold failure — the generic "computation failed" message below would
423
+ # otherwise mislead a caller into thinking something is broken.
424
+ reason = (
425
+ f"MFE was skipped because the sequence ({len(sequence)} nt) exceeds "
426
+ f"the {MFE_MAX_SEQUENCE_LENGTH} nt limit for global MFE folding."
427
+ )
339
428
  else:
340
429
  mfe_value = calculate_mfe(sequence)
341
430
  if mfe_value is None:
@@ -115,6 +115,23 @@ def calculate_dinucleotide_ratio(sequence: str, dinucleotide: str = "CG") -> flo
115
115
  return observed / expected
116
116
 
117
117
 
118
+ # Job 168 / v3.3.0 (_analysis/025): host -> production-default codon table file
119
+ # overrides. nbenthamiana moved from the legacy {host}_codons.json convention to
120
+ # the NbeV1.1 LAB-strain derived table. See data/reference/active_codon_reference.json.
121
+ # The legacy file itself (nbenthamiana_codons.json) is left untouched on disk for
122
+ # historical benchmark replay (benchmarks/run_benchmark.py --codon-table-path).
123
+ _HOST_CODON_TABLE_OVERRIDES: dict[str, str] = {
124
+ "nbenthamiana": "profiles/nbev11_cds_hc_derived_codons.json",
125
+ }
126
+
127
+
128
+ def resolve_host_codon_table_path(host: str, codon_tables_dir: Path) -> Path:
129
+ """Resolve the production-default codon table file path for a host."""
130
+ override = _HOST_CODON_TABLE_OVERRIDES.get(host)
131
+ filename = override or f"{host}_codons.json"
132
+ return codon_tables_dir / filename
133
+
134
+
118
135
  def load_codon_table(organism: str, codon_tables_dir: Path) -> dict[str, Any]:
119
136
  """Load codon usage table for organism.
120
137
 
@@ -128,8 +145,10 @@ def load_codon_table(organism: str, codon_tables_dir: Path) -> dict[str, Any]:
128
145
  Raises:
129
146
  FileNotFoundError: If codon table file not found.
130
147
  """
131
- filename = organism if organism.endswith(".json") else f"{organism}_codons.json"
132
- codon_table_path = codon_tables_dir / filename
148
+ if organism.endswith(".json"):
149
+ codon_table_path = codon_tables_dir / organism
150
+ else:
151
+ codon_table_path = resolve_host_codon_table_path(organism, codon_tables_dir)
133
152
 
134
153
  with open(codon_table_path, "r", encoding="utf-8") as handle:
135
154
  return cast(dict[str, Any], json.load(handle))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: factorforge-cds
3
- Version: 3.2.6
3
+ Version: 3.2.7
4
4
  Summary: FactorForge - open-source CDS design and pre-synthesis sequence review engine 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.2.6 (2026). Open-source constraint-based CDS design engine.
96
+ FactorForge v3.2.7 (2026). Open-source constraint-based CDS design engine.
97
97
  Eijex. https://github.com/eijex/factorforge-cds
98
98
  ```
99
99
 
@@ -110,7 +110,38 @@ def test_smoke_summary_contains_codon_table_fields(tmp_path):
110
110
  ]:
111
111
  assert field in data, f"Missing codon table field in smoke summary JSON: {field}"
112
112
 
113
- assert data["codon_table_id"] == "nbenthamiana_legacy_kazusa_sgn_v101"
113
+ # No --codon-table-path override ⇒ codon_table_id must reflect whichever
114
+ # asset is actually the current production default (Job 168 / v3.3.0,
115
+ # _analysis/025 — see data/reference/active_codon_reference.json), not a
116
+ # hardcoded legacy literal.
117
+ active_ref = json.loads(
118
+ (ROOT / "data" / "reference" / "active_codon_reference.json").read_text(encoding="utf-8")
119
+ )
120
+ assert data["codon_table_id"] == active_ref["active_codon_table_id"]
121
+
122
+
123
+ def test_smoke_summary_contains_vienna_rna_active(tmp_path):
124
+ """Every future run must record whether ViennaRNA (and thus MFE-weighted
125
+ candidate selection) was active, per analysis 011's provenance-gap finding."""
126
+ from benchmarks.run_benchmark import run
127
+
128
+ out_csv = tmp_path / "results.csv"
129
+ out_md = tmp_path / "summary.md"
130
+ run(
131
+ dataset="synthetic",
132
+ mode="smoke",
133
+ out_csv=out_csv,
134
+ out_md=out_md,
135
+ proteins_fasta=ROOT / "tests" / "fixtures" / "small_proteins.fasta",
136
+ native_fasta=ROOT / "tests" / "fixtures" / "small_native_cds.fasta",
137
+ )
138
+ summary_json = tmp_path / "benchmark_summary.json"
139
+ data = json.loads(summary_json.read_text(encoding="utf-8"))
140
+ assert isinstance(data.get("vienna_rna_active"), bool), (
141
+ "vienna_rna_active must be a bool recording whether ViennaRNA was "
142
+ "available (and therefore could affect MFE-weighted candidate "
143
+ "selection) during this benchmark run"
144
+ )
114
145
 
115
146
 
116
147
  # --- raw FASTA / genome files must not be committed ---
@@ -74,3 +74,59 @@ def test_aa_identity_policy_sync():
74
74
  # registry says identity must be 1.0 — passing case confirms
75
75
  ok = validate_cds_output("MKT", "ATGAAAACC")
76
76
  assert ok["passed"] and ok["aa_identity"] == registry_val
77
+
78
+
79
+ # ── codon_reference source-of-truth sync (Job 168 / v3.3.0, _analysis/025) ────
80
+
81
+ def test_codon_reference_active_sync_with_active_reference_file():
82
+ """registry's codon_reference.active block must match
83
+ data/reference/active_codon_reference.json (the file run_benchmark.py
84
+ reads at runtime) — both describe "what is the current production
85
+ default", and must not drift apart."""
86
+ import json
87
+ from pathlib import Path
88
+
89
+ root = Path(__file__).resolve().parents[1]
90
+ registry_active = _resolve("codon_reference.active")
91
+ active_ref = json.loads(
92
+ (root / "data" / "reference" / "active_codon_reference.json").read_text(encoding="utf-8")
93
+ )
94
+ assert registry_active["id"] == active_ref["active_codon_table_id"]
95
+ assert registry_active["asset_type"] == active_ref["active_asset_type"]
96
+ assert registry_active["codon_reference_contract_version"] == active_ref[
97
+ "codon_reference_contract_version"
98
+ ]
99
+ # active_codon_reference.json doesn't carry sha256 directly — cross-check
100
+ # via the file it points at instead.
101
+ import hashlib
102
+ table_path = root / active_ref["file"]
103
+ assert registry_active["sha256"] == hashlib.sha256(table_path.read_bytes()).hexdigest()
104
+
105
+
106
+ def test_codon_reference_active_sync_with_v2_manifest():
107
+ """registry's codon_reference.active block must match the schema-conformant
108
+ v2 manifest file's facts (asset_type, sha256, source_status)."""
109
+ import json
110
+ from pathlib import Path
111
+
112
+ registry_active = _resolve("codon_reference.active")
113
+ manifest = json.loads(
114
+ (Path(__file__).resolve().parents[1] / "data" / "reference"
115
+ / "codon_table_manifest_nbev11_hc_v2.json").read_text(encoding="utf-8")
116
+ )
117
+ assert registry_active["id"] == manifest["codon_table_id"]
118
+ assert registry_active["sha256"] == manifest["sha256"]
119
+ assert registry_active["asset_type"] == manifest["asset_type"]
120
+ assert registry_active["source_status"] == manifest["source_status"]
121
+
122
+
123
+ def test_codon_reference_active_table_sha256_matches_production_default():
124
+ """The sha256 recorded for the active codon_reference must match the
125
+ actual file the production engine resolves to by default."""
126
+ from factorforge.engines.profile.utils import get_data_path, resolve_host_codon_table_path
127
+ import hashlib
128
+
129
+ registry_active = _resolve("codon_reference.active")
130
+ resolved_path = resolve_host_codon_table_path("nbenthamiana", get_data_path())
131
+ actual_sha256 = hashlib.sha256(resolved_path.read_bytes()).hexdigest()
132
+ assert registry_active["sha256"] == actual_sha256
@@ -61,7 +61,14 @@ def test_sfgfp_sequence_no_x_residues():
61
61
  # ---------------------------------------------------------------------------
62
62
 
63
63
  def test_run_example_deterministic():
64
- """run_example.py (no --freeze) must exit 0 when frozen outputs exist."""
64
+ """run_example.py (no --freeze) must exit 0 when frozen outputs exist.
65
+
66
+ Job 168 / v3.3.0 (_analysis/025): this script is explicitly pinned to the
67
+ legacy v1 codon reference (GC 55-65%) regardless of the engine's current
68
+ production default, so this reproducibility check stays valid forever.
69
+ See run_example_v2_smoke.py / test_run_example_v2_smoke_succeeds for the
70
+ current-default (v2) path.
71
+ """
65
72
  result = subprocess.run(
66
73
  [sys.executable, str(EXAMPLE_DIR / "run_example.py")],
67
74
  capture_output=True,
@@ -74,6 +81,26 @@ def test_run_example_deterministic():
74
81
  assert "OK" in result.stdout, "expected 'OK' in output"
75
82
 
76
83
 
84
+ def test_run_example_v2_smoke_succeeds():
85
+ """run_example_v2_smoke.py (current production default) must exit 0.
86
+
87
+ No frozen-output comparison — this only checks that the v2 path runs
88
+ end-to-end and reports correct provenance (Job 168 / v3.3.0, _analysis/025).
89
+ """
90
+ result = subprocess.run(
91
+ [sys.executable, str(EXAMPLE_DIR / "run_example_v2_smoke.py")],
92
+ capture_output=True,
93
+ text=True,
94
+ cwd=str(ROOT),
95
+ )
96
+ assert result.returncode == 0, (
97
+ f"run_example_v2_smoke.py exited {result.returncode}\n"
98
+ f"stdout:{result.stdout}\nstderr:{result.stderr}"
99
+ )
100
+ assert "OK" in result.stdout
101
+ assert "nbenthamiana_nbev11_hc_v2" in result.stdout
102
+
103
+
77
104
  # ---------------------------------------------------------------------------
78
105
  # TC3 — design_package.json claim boundary
79
106
  # ---------------------------------------------------------------------------
File without changes