Repository: microsoft/MLOS

Issue:
mlos_bench: categorical tunables should accept int values and automatically convert to strings if necessary
No reason to require quoting in the json schemas for these types for configs:

```jsonc
            "max_sectors_kb": {
                "type": "categorical",
                "default": "512",
                "meta": {"name_prefix": "/sys/block/sdc/queue"},
                "values": ["128", "256", "512", "1024", "2048", "4096"]
            }
```


Discussion:

Resolving patch:
diff --git a/mlos_bench/mlos_bench/tunables/tunable.py b/mlos_bench/mlos_bench/tunables/tunable.py
--- a/mlos_bench/mlos_bench/tunables/tunable.py
+++ b/mlos_bench/mlos_bench/tunables/tunable.py
@@ -61,9 +61,14 @@ def __init__(self, name: str, config: TunableDict):
         """
         self._name = name
         self._type = config["type"]  # required
+        if self._type not in self._DTYPE:
+            raise ValueError(f"Invalid parameter type: {self._type}")
         self._description = config.get("description")
         self._default = config["default"]
+        self._default = self.dtype(self._default) if self._default is not None else self._default
         self._values = config.get("values")
+        if self._values:
+            self._values = [str(v) if v else v for v in self._values]
         self._meta: Dict[str, Any] = config.get("meta", {})
         self._range: Optional[Union[Tuple[int, int], Tuple[float, float]]] = None
         config_range = config.get("range")
@@ -72,8 +77,9 @@ def __init__(self, name: str, config: TunableDict):
             config_range = (config_range[0], config_range[1])
             self._range = config_range
         self._special = config.get("special")
-        self._current_value = self._default
+        self._current_value = None
         self._sanity_check()
+        self.value = self._default
 
     def _sanity_check(self) -> None:
         """
@@ -209,7 +215,7 @@ def value(self, value: TunableValue) -> TunableValue:
             if self.is_categorical and value is None:
                 coerced_value = None
             else:
-                coerced_value = self._DTYPE[self._type](value)
+                coerced_value = self.dtype(value)
         except Exception:
             _LOG.error("Impossible conversion: %s %s <- %s %s",
                        self._type, self._name, type(value), value)
diff --git a/mlos_bench/mlos_bench/tunables/tunable_groups.py b/mlos_bench/mlos_bench/tunables/tunable_groups.py
--- a/mlos_bench/mlos_bench/tunables/tunable_groups.py
+++ b/mlos_bench/mlos_bench/tunables/tunable_groups.py
@@ -9,6 +9,7 @@
 
 from typing import Dict, Generator, Iterable, Mapping, Optional, Tuple, Union
 
+from mlos_bench.config.schemas import ConfigSchema
 from mlos_bench.tunables.tunable import Tunable, TunableValue
 from mlos_bench.tunables.covariant_group import CovariantTunableGroup
 
@@ -29,6 +30,7 @@ def __init__(self, config: Optional[dict] = None):
         """
         if config is None:
             config = {}
+        ConfigSchema.TUNABLE_PARAMS.validate(config)
         self._index: Dict[str, CovariantTunableGroup] = {}  # Index (Tunable id -> CovariantTunableGroup)
         self._tunable_groups: Dict[str, CovariantTunableGroup] = {}
         for (name, group_config) in config.items():


Test patch:
diff --git a/mlos_bench/mlos_bench/tests/config/schemas/tunable-params/test-cases/good/full/full-tunable-params-test.jsonc b/mlos_bench/mlos_bench/tests/config/schemas/tunable-params/test-cases/good/full/full-tunable-params-test.jsonc
--- a/mlos_bench/mlos_bench/tests/config/schemas/tunable-params/test-cases/good/full/full-tunable-params-test.jsonc
+++ b/mlos_bench/mlos_bench/tests/config/schemas/tunable-params/test-cases/good/full/full-tunable-params-test.jsonc
@@ -25,7 +25,26 @@
                 "meta": {"quote": true, "list": ["a", "b", "c", 1, 2, 3]},
                 "default": "yes",
                 "values": ["yes", "no"]
+            },
+            "cat-ints": {
+                "description": "Categorical integers",
+                "type": "categorical",
+                "values": [1, 2, 3],
+                "default": 1
+            },
+            "cat-bool": {
+                "description": "Categorical bool",
+                "type": "categorical",
+                "values": [true, false],
+                "default": true
+            },
+            "cat-mixed": {
+                "description": "Categorical mixed (should all be converted to strings internally)",
+                "type": "categorical",
+                "values": [true, 1, "true"],
+                "default": true
             }
+
         }
     },
     "covariant_group_name-2": {
diff --git a/mlos_bench/mlos_bench/tests/conftest.py b/mlos_bench/mlos_bench/tests/conftest.py
--- a/mlos_bench/mlos_bench/tests/conftest.py
+++ b/mlos_bench/mlos_bench/tests/conftest.py
@@ -12,6 +12,8 @@
 import pytest
 
 from mlos_bench.tests import SEED
+
+from mlos_bench.config.schemas import ConfigSchema
 from mlos_bench.environments.mock_env import MockEnv
 from mlos_bench.tunables.covariant_group import CovariantTunableGroup
 from mlos_bench.tunables.tunable_groups import TunableGroups
@@ -74,6 +76,7 @@ def tunable_groups_config() -> Dict[str, Any]:
     """
     conf = json.loads(TUNABLE_GROUPS_JSON)
     assert isinstance(conf, dict)
+    ConfigSchema.TUNABLE_PARAMS.validate(conf)
     return conf
 
 
diff --git a/mlos_bench/mlos_bench/tests/tunables/test_tunable_categoricals.py b/mlos_bench/mlos_bench/tests/tunables/test_tunable_categoricals.py
new file mode 100644
--- /dev/null
+++ b/mlos_bench/mlos_bench/tests/tunables/test_tunable_categoricals.py
@@ -0,0 +1,52 @@
+#
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+#
+"""
+Unit tests for deep copy of tunable objects and groups.
+"""
+
+from mlos_bench.tunables.tunable_groups import TunableGroups
+
+
+def test_tunable_categorical_types() -> None:
+    """
+    Check if we accept tunable categoricals as ints as well as strings and
+    convert both to strings.
+    """
+    tunable_params = {
+        "test-group": {
+            "cost": 1,
+            "params": {
+                "int-cat": {
+                    "type": "categorical",
+                    "values": [1, 2, 3],
+                    "default": 1,
+                },
+                "bool-cat": {
+                    "type": "categorical",
+                    "values": [True, False],
+                    "default": True,
+                },
+                "str-cat": {
+                    "type": "categorical",
+                    "values": ["a", "b", "c"],
+                    "default": "a",
+                },
+            }
+        }
+    }
+    tunable_groups = TunableGroups(tunable_params)
+    tunable_groups.reset()
+
+    int_cat, _ = tunable_groups.get_tunable("int-cat")
+    assert isinstance(int_cat.value, str)
+    assert int_cat.value == "1"
+
+    bool_cat, _ = tunable_groups.get_tunable("bool-cat")
+    assert isinstance(bool_cat.value, str)
+    assert bool_cat.value == "True"
+
+    str_cat, _ = tunable_groups.get_tunable("str-cat")
+    assert isinstance(str_cat.value, str)
+    assert str_cat.value == "a"