Repository: kiudee/chess-tuning-tools (language: python)

Issue:
Support for nested parameters
Some engines like LCZero utilize nested parameters like this:
```
setoption name TimeManager value legacy(midpoint-move=30.1,steepness=4.69)
```
In this example `legacy` is the time manager selected, which in turn has two parameters `midpoint-move` and `steepness`.
Currently this (rare and non-standard) use-case is not supported by chess-tuning-tools.

A possible solution would be to parse nested parameters like this:
```json
    "parameter_ranges": {
        "TimeManager=legacy(midpoint-move)": "Real(10.0, 80.0)",
        "TimeManager=legacy(steepness)":     "Real(3.0, 10.0)"
    },
```


Discussion / hints:

Fix patch:
diff --git a/HISTORY.rst b/HISTORY.rst
index 0aeff36..b8c9bd0 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -14,6 +14,7 @@ Local tuner
   - An engine loses on time
   - The connection to an engine stalls (usually a crash)
   - Any other error captured by cutechess-cli
+* Add support for specifying nested parameters.
 
 0.8.0 (2021-08-15)
 ------------------
diff --git a/docs/parameters.myst b/docs/parameters.myst
index c5025c2..24530b4 100644
--- a/docs/parameters.myst
+++ b/docs/parameters.myst
@@ -73,6 +73,25 @@ Possible parameter specifications are:
 * `(val1, val2, ...)`: A categorical parameter which can only assume the
   specified values. The values can be of mixed type (e.g. `(42, 'foo', 'bar')`).
 
+Nested parameters can be specified as follows:
+
+```JSON
+{
+    "parameter_ranges": {
+        "TimeManager=legacy(steepness)": "Real(5.0, 10.0)",
+        "TimeManager=legacy(midpoint-move)": "Real(30.0, 80.0)"
+    }
+}
+```
+
+Here ``TimeManager`` is the UCI parameter, ``legacy`` its value,
+``steepness`` and ``midpoint-move`` are (sub-)parameters for ``legacy``.
+For example, the following UCI will be send to the engine:
+
+```
+setoption name TimeManager value legacy(steepness=7.5,midpoint-move=42.0)
+```
+
 The remaining parameters of the optimizer are optional and described below.
 
 ## Model-related parameters
diff --git a/tune/io.py b/tune/io.py
index 4d79d46..faa77a6 100644
--- a/tune/io.py
+++ b/tune/io.py
@@ -9,6 +9,7 @@
 from skopt.space.space import check_dimension
 
 __all__ = [
+    "combine_nested_parameters",
     "InitStrings",
     "uci_tuple",
     "parse_ranges",
@@ -216,9 +217,67 @@ def prepare_engines_json(commands, fixed_params):
     return result_list
 
 
+def combine_nested_parameters(d: dict) -> dict:
+    """Combine all nested parameters in the given dict into one parameter each.
+
+    The correct syntax for specifying nested parameters is
+    ``"Parameter=value(sub-parameter)"``
+    where ``Parameter`` is the name of the UCI parameter, ``value`` is the value the
+    UCI parameter is set to and ``sub-parameter`` is one parameter for ``value``.
+
+    Parameters
+    ----------
+    d : dict
+        Dictionary mapping parameter names to values.
+
+    Returns
+    -------
+    dict
+        Dictionary with nested parameters merged.
+
+    Examples
+    --------
+    >>> d = {
+    ...     "UCIParameter1=composite(sub-parameter1)": 0.0,
+    ...     "UCIParameter1=composite(sub-parameter2)": 1.0,
+    ... }
+    >>> combine_nested_parameters(d)
+    {'UCIParameter1': 'composite(sub-parameter1=0.0,sub-parameter2=1.0)'}
+    """
+    re_pattern = r"(\S+|\S.*\S)=(.+)\((.+)\)"
+    result = dict()
+    nested_params = dict()
+    for k, v in d.items():
+        match = re.search(pattern=re_pattern, string=k)
+        if match is None:
+            result[k] = v
+        else:
+            name, value, sub_param = match.groups()
+            if name not in nested_params:
+                nested_params[name] = (value, dict())
+            else:
+                existing_value = nested_params[name][0]
+                if existing_value != value:
+                    raise ValueError(
+                        f"UCI parameter {name} is set to different values "
+                        f"({existing_value} != {value})."
+                    )
+            nested_params[name][1][sub_param] = v
+
+    for param, (value, sub_values) in nested_params.items():
+        string = f"{value}("
+        for i, (k, v) in enumerate(sub_values.items()):
+            string += f"{k}={v}"
+            if i != len(sub_values) - 1:
+                string += ","
+        string += ")"
+        result[param] = string
+    return result
+
+
 def write_engines_json(engine_json, point_dict):
     engine = engine_json[0]
     initstr = InitStrings(engine["initStrings"])
-    initstr.update(point_dict)
+    initstr.update(combine_nested_parameters(point_dict))
     with open(Path() / "engines.json", "w") as file:
         json.dump(engine_json, file, sort_keys=True, indent=4)


Test patch:
diff --git a/tests/test_io.py b/tests/test_io.py
index 7fd44dc..b48a997 100644
--- a/tests/test_io.py
+++ b/tests/test_io.py
@@ -1,4 +1,6 @@
-from tune.io import load_tuning_config
+import pytest
+
+from tune.io import combine_nested_parameters, load_tuning_config
 
 
 def test_load_tuning_config():
@@ -16,3 +18,37 @@ def test_load_tuning_config():
     assert len(commands) == 2
     assert len(fixed_params) == 2
     assert len(param_ranges) == 1
+
+
+def test_combine_nested_parameters():
+    # A dict without nested parameters should be unchanged:
+    testdict = {
+        "UCIParameter1": 42.0,
+        "UCIParameter2": 0.0,
+    }
+    result = combine_nested_parameters(testdict)
+    assert len(result) == 2
+    assert "UCIParameter1" in result
+    assert "UCIParameter2" in result
+    assert result["UCIParameter1"] == 42.0
+    assert result["UCIParameter2"] == 0.0
+
+    # Test a correct specification of nested parameters:
+    testdict = {
+        "UCIParameter1": 42.0,
+        "UCIParameter2=composite(sub-parameter1)": 0.0,
+        "UCIParameter2=composite(sub-parameter2)": 1.0,
+    }
+    result = combine_nested_parameters(testdict)
+    assert len(result) == 2
+    assert "UCIParameter2" in result
+    assert result["UCIParameter2"] == "composite(sub-parameter1=0.0,sub-parameter2=1.0)"
+
+    # Test an incorrect specification of nested parameters:
+    testdict = {
+        "UCIParameter1": 42.0,
+        "UCIParameter2=composite(sub-parameter1)": 0.0,
+        "UCIParameter2=other(sub-parameter2)": 1.0,
+    }
+    with pytest.raises(ValueError):
+        combine_nested_parameters(testdict)