From dacde8b42f12de99ec02f103c666a7db4334dd87 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sun, 8 Nov 2026 20:11:42 +0800 Subject: [PATCH] fix(cli): fold and suggest near-miss parameter choice values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #888 Choice values such as sigmoid-novelty, KEEP_ALL and Linear now resolve to the declared spelling, and a close typo ends with did you mean…? algorithms run --param writes the canonical value into the spec. --- agentdescent/algorithms/__init__.py | 23 ++++++++++++++++- agentdescent/cli.py | 7 ++++-- changelog.d/888-param-choice-aliases.md | 5 ++++ tests/test_algorithms.py | 33 +++++++++++++++++++++++++ tests/test_cli_algorithms_run.py | 26 +++++++++++++++++++ 5 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 changelog.d/888-param-choice-aliases.md diff --git a/agentdescent/algorithms/__init__.py b/agentdescent/algorithms/__init__.py index 70cbc929..64c3b68f 100644 --- a/agentdescent/algorithms/__init__.py +++ b/agentdescent/algorithms/__init__.py @@ -77,6 +77,11 @@ def coerce(self, value: Any, *, where: str) -> Any: Strict about type -- ``"3"`` is not an ``int`` and ``true`` is not a number -- except that an ``int`` is a valid ``float``, which JSON does not distinguish. + + A string that matches exactly one ``choices`` entry once case and + ``-``/``_`` are ignored resolves to it (``sigmoid-novelty`` → + ``sigmoid_novelty``). A remaining unknown that is a close typo of one + choice names that guess (#888). """ t = self.type if t == "bool": @@ -96,7 +101,23 @@ def coerce(self, value: Any, *, where: str) -> Any: if t == "list[str]": value = list(value) if self.choices is not None and value not in self.choices: - raise AlgorithmError(f"{where}: must be one of {list(self.choices)}, got {value!r}") + if isinstance(value, str): + folded = _fold(value) + matches = [c for c in self.choices + if isinstance(c, str) and _fold(c) == folded] + if len(matches) == 1: + value = matches[0] + if value not in self.choices: + msg = (f"{where}: must be one of {list(self.choices)}, " + f"got {value!r}") + if isinstance(value, str): + import difflib + named = [c for c in self.choices if isinstance(c, str)] + close = difflib.get_close_matches( + _fold(value), named, n=1, cutoff=0.75) + if close: + msg += f"; did you mean {close[0]!r}?" + raise AlgorithmError(msg) if self.minimum is not None and isinstance(value, (int, float)) and value < self.minimum: raise AlgorithmError(f"{where}: must be >= {self.minimum}, got {value!r}") if self.maximum is not None and isinstance(value, (int, float)) and value > self.maximum: diff --git a/agentdescent/cli.py b/agentdescent/cli.py index 1e0339e9..abc82a31 100644 --- a/agentdescent/cli.py +++ b/agentdescent/cli.py @@ -308,8 +308,11 @@ def algorithm_override(name: Optional[str], params: Optional[Mapping[str, Any]], alg, kind, target=target, prefix="--algorithm")) if params: params = algorithms._canonical_param_map(alg, params, where="algorithm.params") - algorithms.resolve_params(alg, params) # validates types/unknowns/choices - return name if not params else {"name": name, "params": dict(params)} + resolved = algorithms.resolve_params(alg, params) # types/unknowns/choices + if not params: + return name + # Store user-supplied keys only, with coerced (folded) values (#888). + return {"name": name, "params": {k: resolved[k] for k in params}} def missing_target_message(path: str) -> str: diff --git a/changelog.d/888-param-choice-aliases.md b/changelog.d/888-param-choice-aliases.md new file mode 100644 index 00000000..c1fb0062 --- /dev/null +++ b/changelog.d/888-param-choice-aliases.md @@ -0,0 +1,5 @@ +Fixed +Algorithm parameter values that declare `choices` now fold case and `-`/`_` +(`sigmoid-novelty` → `sigmoid_novelty`, `KEEP_ALL` → `keep_all`) and a +near-miss typo ends with "did you mean 'novelty'?", matching how +parameter names already resolve (#888). diff --git a/tests/test_algorithms.py b/tests/test_algorithms.py index d81257ad..542c2806 100644 --- a/tests/test_algorithms.py +++ b/tests/test_algorithms.py @@ -163,6 +163,39 @@ def test_a_failed_builtin_load_keeps_its_original_error(monkeypatch): def test_choices_are_enforced(): with pytest.raises(AlgorithmError, match=r"sampling: must be one of \[.*'best'"): resolve_params(algorithms.get("archive_selection"), {"sampling": "greedy"}) + with pytest.raises(AlgorithmError) as e: + resolve_params(algorithms.get("archive_selection"), {"sampling": "greedy"}) + assert "did you mean" not in str(e.value) + + +def test_resolve_params_folds_choice_value_aliases(): + """#888: `sigmoid-novelty` / `KEEP_ALL` resolve like parameter-name aliases.""" + arch = resolve_params(algorithms.get("archive_selection"), + {"sampling": "sigmoid-novelty"}) + assert arch["sampling"] == "sigmoid_novelty" + assert resolve_params( + algorithms.get("archive_selection"), + {"sampling": "SIGMOID_NOVELTY"}, + )["sampling"] == "sigmoid_novelty" + assert resolve_params( + algorithms.get("dgm"), {"archive_mode": "keep-all"}, + )["archive_mode"] == "keep_all" + assert resolve_params( + algorithms.get("dgm"), {"archive_mode": "KEEP_ALL"}, + )["archive_mode"] == "keep_all" + assert resolve_params( + algorithms.get("skillopt"), {"mode": "Linear"}, + )["mode"] == "linear" + + +@pytest.mark.parametrize("alg,param,typo,meant", [ + ("archive_selection", "sampling", "novlty", "novelty"), + ("dgm", "archive_mode", "keep_al", "keep_all"), + ("skillopt", "mode", "cosin", "cosine"), +]) +def test_invalid_choice_names_the_near_miss(alg, param, typo, meant): + with pytest.raises(AlgorithmError, match=rf"did you mean '{meant}'\?$"): + resolve_params(algorithms.get(alg), {param: typo}) def test_the_registry_works_from_a_wheel_without_examples(): diff --git a/tests/test_cli_algorithms_run.py b/tests/test_cli_algorithms_run.py index 7a81fc90..7c5d9a43 100644 --- a/tests/test_cli_algorithms_run.py +++ b/tests/test_cli_algorithms_run.py @@ -261,6 +261,32 @@ def test_algorithms_run_param_hyphen_alias(tmp_path, capsys, monkeypatch): assert spec["algorithm"]["params"]["batch_size"] == 2 +def test_algorithms_run_param_choice_hyphen_alias(tmp_path, capsys, monkeypatch): + """#888: `--param sampling=sigmoid-novelty` stores the canonical choice.""" + monkeypatch.chdir(tmp_path) + prompt, cases = _prompt_and_cases(tmp_path) + out = tmp_path / "s.json" + rc = cli.main(["algorithms", "run", "archive_selection", str(prompt), str(cases), + "--param", "sampling=sigmoid-novelty", + "--model", "m", "--base-url", "https://example.test/v1", + "--out", str(out)]) + assert rc in (0, 2) + capsys.readouterr() + spec = json.loads(out.read_text()) + params = spec["algorithm"]["params"] if isinstance(spec["algorithm"], dict) else None + assert params is not None and params["sampling"] == "sigmoid_novelty" + + +def test_algorithms_run_invalid_choice_names_near_miss(tmp_path, capsys, monkeypatch): + monkeypatch.chdir(tmp_path) + prompt, cases = _prompt_and_cases(tmp_path) + assert cli.main(["algorithms", "run", "archive_selection", str(prompt), str(cases), + "--param", "sampling=novlty"]) == 2 + err = capsys.readouterr().err + assert "must be one of" in err and "did you mean 'novelty'?" in err + assert not list(tmp_path.glob("agentdescent.*.spec.json")) + + def test_algorithms_run_unknown_param_names_near_miss(tmp_path, capsys, monkeypatch): monkeypatch.chdir(tmp_path) skill = _skill(tmp_path)