Repository: cogent3/cogent3 (python)

Issue:
The upper/lower bounds cannot be successfully imposed on a model app
**Describe the bug**
No response when setting parameters for upper and lower bounds during the initialization of a model app. The docstring indicates "bounds for all rate and length parameters"; it should control the global settings for rate and length.

**To Reproduce**

```
>>> from cogent3 import make_aligned_seqs, get_app

>>> algn = make_aligned_seqs({"a":"ACTG", "b":"TGGC", "c":"GGCA"}, moltype="DNA")
>>> modeller = get_app("model", "GTR", upper = 99, lower=9)
>>> res = modeller(algn)
>>> res.lf.get_param_rules()

[... {'par_name': 'C/T',
  'init': np.float64(0.9929720035793337),
  'lower': 1e-06,
  'upper': 1000000.0},
   ...
   {'par_name': 'length',
  'edge': 'b',
  'init': np.float64(9.999981165175347),
  'lower': 0.0,
  'upper': 10.0}... ]
```

**Expected output**

```
[... {'par_name': 'C/T',
  'init': np.float64(0.9929720035793337),
  'lower': 9,
  'upper': 99},
   ...
   {'par_name': 'length',
  'edge': 'b',
  'init': np.float64(9.999981165175347),
  'lower': 9,
  'upper': 99}... ]
```



Pull request description:
ENH: overwrite the bounds in `lf` to be consistent with the arguments specified in the `model`
Closes #1930 

Patch:
diff --git a/src/cogent3/app/evo.py b/src/cogent3/app/evo.py
index ce5630151..915ea9784 100644
--- a/src/cogent3/app/evo.py
+++ b/src/cogent3/app/evo.py
@@ -29,7 +29,8 @@ from .typing import (
 )
 
 
-def _config_rules(param_rules, lower, upper):
+def _config_rules(param_rules, lower, upper, overwrite=False):
+    """fill the bounds in `param_rules` whenever no bound defined for a parameter"""
     param_rules = deepcopy(param_rules)
     for rule in param_rules:
         if rule.get("par_name", None) in (
@@ -41,8 +42,12 @@ def _config_rules(param_rules, lower, upper):
         ) or rule.get("is_constant"):
             continue
 
-        rule["lower"] = rule.get("lower", lower)  # default lower bound
-        rule["upper"] = rule.get("upper", upper)  # default upper bound
+        rule["lower"] = (
+            lower if overwrite else rule.get("lower", lower)
+        )  # default lower bound
+        rule["upper"] = (
+            upper if overwrite else rule.get("upper", upper)
+        )  # default upper bound
 
     return param_rules
 
@@ -300,6 +305,13 @@ class model:
         lf = self._sm.make_likelihood_function(self._tree, **self._lf_args)
 
         lf.set_alignment(aln)
+
+        # just use the likelihood function instance to give us the rules
+        # which we can then impose the lower/upper bounds
+        rules = lf.get_param_rules()  # innate rules dict with default params
+        rules = _config_rules(rules, self._lower, self._upper, overwrite=True)
+        lf.apply_param_rules(rules)
+
         if self._param_rules:
             lf.apply_param_rules(self._param_rules)
 
@@ -322,12 +334,6 @@ class model:
                 lf.set_time_heterogeneity(
                     edge_sets=self._time_het, lower=self._lower, upper=self._upper
                 )
-        elif not self._param_rules:
-            # just use the likelihood function instance to give us the rules
-            # which we can then impose the lower/upper bounds
-            rules = lf.get_param_rules()
-            rules = _config_rules(rules, self._lower, self._upper)
-            lf.apply_param_rules(rules)
 
         if initialise:
             lf = initialise(lf, identifier)


Test patch:
diff --git a/tests/test_app/test_evo.py b/tests/test_app/test_evo.py
index e502188f6..aab936359 100644
--- a/tests/test_app/test_evo.py
+++ b/tests/test_app/test_evo.py
@@ -493,10 +493,26 @@ class TestHypothesisResult(TestCase):
         aln = make_aligned_seqs(data=_data, moltype="dna")
         opt_args = dict(max_evaluations=10, limit_action="ignore")
         m1 = evo_app.model(
-            "F81", optimise_motif_probs=False, split_codons=True, opt_args=opt_args
+            "F81",
+            optimise_motif_probs=False,
+            split_codons=True,
+            opt_args=opt_args,
+            param_rules=[
+                {"par_name": "length", "upper": 10.0, "lower": 1e-09},
+            ],
+            lower=1e-06,
+            upper=1000000.0,
         )
         m2 = evo_app.model(
-            "GTR", optimise_motif_probs=False, split_codons=True, opt_args=opt_args
+            "GTR",
+            optimise_motif_probs=False,
+            split_codons=True,
+            opt_args=opt_args,
+            param_rules=[
+                {"par_name": "length", "upper": 10.0, "lower": 1e-09},
+            ],
+            lower=1e-06,
+            upper=1000000.0,
         )
         hyp = evo_app.hypothesis(m1, m2)
         r = hyp(aln)
@@ -919,3 +935,46 @@ def test_model_invalid_tree_func():
             "HKY85",
             tree_func="123",
         )
+
+
+def test_model_bounds_allpar():
+    upper = 10.0
+    lower = 0.5
+    app = get_app(
+        "model",
+        "HKY85",
+        optimise_motif_probs=True,
+        show_progress=False,
+        unique_trees=True,
+        lower=lower,
+        upper=upper,
+    )
+
+    aln = make_aligned_seqs(data=dict(s1="ACGT", s2="ACGC", s3="AAGT"))
+    result = app(aln)
+    rules = result.lf.get_param_rules()
+    par_bounds = {
+        (r["lower"], r["upper"])
+        for r in rules
+        if r["par_name"] == ("kappa" or "length")
+    }
+    assert par_bounds == {(lower, upper)}
+
+
+def test_model_bounds_kappa():
+    upper_kappa = 99
+    lower_kappa = 9
+    app = get_app(
+        "model",
+        "HKY85",
+        optimise_motif_probs=True,
+        show_progress=False,
+        unique_trees=True,
+        param_rules=[{"par_name": "kappa", "upper": upper_kappa, "lower": lower_kappa}],
+    )
+
+    aln = make_aligned_seqs(data=dict(s1="ACGT", s2="ACGC", s3="AAGT"))
+    result = app(aln)
+    rules = result.lf.get_param_rules()
+    kappa_bounds = {(r["lower"], r["upper"]) for r in rules if r["par_name"] == "kappa"}
+    assert kappa_bounds == {(lower_kappa, upper_kappa)}