From ea776292e0a4b2da5be06cf8a2ead31087eada4c Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sun, 8 Nov 2026 08:11:06 +0800 Subject: [PATCH] fix(algorithms): fold and suggest near-miss parameter names Parameter keys now resolve like algorithm names: case and -/_ aliases (batch-size, NOISE_BAND) map to the declared name, and a close typo ends with "did you mean 'batch_size'?". Closes #876. --- agentdescent/algorithms/__init__.py | 42 +++++++++++++++++++++++---- agentdescent/cli.py | 8 ++++- changelog.d/876-param-did-you-mean.md | 4 +++ tests/test_algorithms.py | 30 +++++++++++++++++++ tests/test_cli_algorithms_run.py | 24 +++++++++++++++ 5 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 changelog.d/876-param-did-you-mean.md diff --git a/agentdescent/algorithms/__init__.py b/agentdescent/algorithms/__init__.py index 13053bb1..70cbc929 100644 --- a/agentdescent/algorithms/__init__.py +++ b/agentdescent/algorithms/__init__.py @@ -393,16 +393,48 @@ def kind_mismatch_message(algorithm: Algorithm, kind: str, *, return msg +def _canonical_param_map(algorithm: Algorithm, params: Mapping[str, Any], *, + where: str) -> Dict[str, Any]: + """Rewrite keys to declared names. Alias collisions are errors (#876).""" + known = {p.name: p for p in algorithm.params} + canonical: Dict[str, Any] = {} + seen_as: Dict[str, str] = {} + for key, value in params.items(): + name = key if (not isinstance(key, str) or key in known) else _resolve_alias(key, known) + if name in canonical and seen_as.get(name) != key: + raise AlgorithmError( + f"{where}: {key!r} and {seen_as[name]!r} both refer to " + f"{algorithm.name}'s {name!r} parameter") + canonical[name] = value + if isinstance(key, str): + seen_as[name] = key + return canonical + + def resolve_params(algorithm: Algorithm, params: Optional[Mapping[str, Any]], *, where: str = "algorithm.params") -> Dict[str, Any]: - """Defaults, overridden by ``params``; unknown names and wrong types are errors.""" - params = dict(params or {}) + """Defaults, overridden by ``params``; unknown names and wrong types are errors. + + A name that matches exactly one declared parameter once case and + ``-``/``_`` are ignored resolves to it (``batch-size``, ``BATCH_SIZE``), + matching algorithm-name aliasing (#876). A remaining unknown that is a + close typo of one parameter names that guess (``bacth_size``). + """ + params = _canonical_param_map(algorithm, dict(params or {}), where=where) known = {p.name: p for p in algorithm.params} unknown = sorted(set(params) - set(known)) if unknown: - raise AlgorithmError( - f"{where}: {algorithm.name!r} has no parameter(s) {unknown}; " - f"parameters: {sorted(known) or 'none'}") + msg = (f"{where}: {algorithm.name!r} has no parameter(s) {unknown}; " + f"parameters: {sorted(known) or 'none'}") + # A near-miss typo (`bacth_size`, `maxlr`) names the parameter it most + # likely meant, so the fix is one word rather than a list scan (#876). + if len(unknown) == 1 and isinstance(unknown[0], str): + import difflib + close = difflib.get_close_matches( + _fold(unknown[0]), sorted(known), n=1, cutoff=0.75) + if close: + msg += f"; did you mean {close[0]!r}?" + raise AlgorithmError(msg) out = {p.name: p.default for p in algorithm.params} for key, value in params.items(): out[key] = known[key].coerce(value, where=f"{where}.{key}") diff --git a/agentdescent/cli.py b/agentdescent/cli.py index 109814d0..0e487bd0 100644 --- a/agentdescent/cli.py +++ b/agentdescent/cli.py @@ -305,6 +305,8 @@ def algorithm_override(name: Optional[str], params: Optional[Mapping[str, Any]], if kind not in alg.kinds: raise algorithms.AlgorithmError(algorithms.kind_mismatch_message( 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)} @@ -429,7 +431,11 @@ def starter_spec(path: str, *, kind: Optional[str] = None, data: Optional[str] = if algorithm: from . import algorithms as _algs try: - if _algs.get(algorithm, where="init").name == "evoskill": + alg = _algs.get(algorithm, where="init") + params = _algs._canonical_param_map(alg, params, where="algorithm.params") + if alg.name == "evoskill": + # Honour a hyphen/case alias (`batch-size`) so setdefault + # does not collide with it in resolve_params (#876). params.setdefault("batch_size", 1) except _algs.AlgorithmError: pass diff --git a/changelog.d/876-param-did-you-mean.md b/changelog.d/876-param-did-you-mean.md new file mode 100644 index 00000000..e3583744 --- /dev/null +++ b/changelog.d/876-param-did-you-mean.md @@ -0,0 +1,4 @@ +Fixed +Unknown algorithm parameter names now fold case and `-`/`_` (`batch-size` → +`batch_size`) and a near-miss typo ends with "did you mean 'batch_size'?", +matching how algorithm names already resolve (#876). diff --git a/tests/test_algorithms.py b/tests/test_algorithms.py index c7001608..d2951957 100644 --- a/tests/test_algorithms.py +++ b/tests/test_algorithms.py @@ -106,6 +106,36 @@ def test_resolve_params_fills_defaults_and_widens_int_to_float(): assert out["b_max"] == 3 and out["T"] == RRsiConfig().T and out["protected"] == [] +def test_resolve_params_folds_hyphen_and_case_aliases(): + """#876: `batch-size` / `NOISE_BAND` resolve like algorithm-name aliases.""" + evoskill = resolve_params(algorithms.get("evoskill"), {"batch-size": 2}) + assert evoskill["batch_size"] == 2 + rrsi = resolve_params(algorithms.get("rrsi"), {"NOISE_BAND": 0.25}) + assert rrsi["noise_band"] == 0.25 + + +def test_resolve_params_refuses_duplicate_after_folding(): + with pytest.raises(AlgorithmError, match=r"batch-size.*batch_size.*both refer"): + resolve_params(algorithms.get("evoskill"), {"batch_size": 1, "batch-size": 2}) + + +@pytest.mark.parametrize("typo, meant", [ + ("bacth_size", "batch_size"), + ("maxlr", "max_lr"), + ("noize_band", "noise_band"), +]) +def test_unknown_parameter_names_the_near_miss(typo, meant): + alg = {"bacth_size": "evoskill", "maxlr": "skillopt", "noize_band": "rrsi"}[typo] + with pytest.raises(AlgorithmError, match=rf"did you mean '{meant}'\?$"): + resolve_params(algorithms.get(alg), {typo: 1}) + + +def test_unknown_parameter_without_near_miss_has_no_guess(): + with pytest.raises(AlgorithmError) as e: + resolve_params(algorithms.get("rrsi"), {"nope": 1}) + assert "did you mean" not in str(e.value) + + def test_a_zero_temperature_is_refused_by_the_schema_not_deep_in_build(): with pytest.raises(AlgorithmError, match=r"temperature: must be >="): resolve_params(algorithms.get("archive_selection"), {"temperature": 0}) diff --git a/tests/test_cli_algorithms_run.py b/tests/test_cli_algorithms_run.py index ef2903d3..7a81fc90 100644 --- a/tests/test_cli_algorithms_run.py +++ b/tests/test_cli_algorithms_run.py @@ -247,6 +247,30 @@ def test_algorithms_run_agent_passthrough(tmp_path, capsys, monkeypatch): assert spec["algorithm"] == {"name": "evoskill", "params": {"batch_size": 1}} +def test_algorithms_run_param_hyphen_alias(tmp_path, capsys, monkeypatch): + """#876: `--param batch-size=2` is the same as `batch_size=2`.""" + monkeypatch.chdir(tmp_path) + skill = _skill(tmp_path) + _, cases = _prompt_and_cases(tmp_path) + out = tmp_path / "s.json" + rc = cli.main(["algorithms", "run", "evoskill", str(skill), str(cases), + "--param", "batch-size=2", "--out", str(out)]) + assert rc in (0, 2) + capsys.readouterr() + spec = json.loads(out.read_text()) + assert spec["algorithm"]["params"]["batch_size"] == 2 + + +def test_algorithms_run_unknown_param_names_near_miss(tmp_path, capsys, monkeypatch): + monkeypatch.chdir(tmp_path) + skill = _skill(tmp_path) + assert cli.main(["algorithms", "run", "evoskill", str(skill), + "--param", "bacth_size=1"]) == 2 + err = capsys.readouterr().err + assert "no parameter" in err and "did you mean 'batch_size'?" in err + assert not list(tmp_path.glob("agentdescent.*.spec.json")) + + def test_algorithms_run_agent_mismatch_refused(tmp_path, capsys, monkeypatch): monkeypatch.chdir(tmp_path) skill = _skill(tmp_path)