Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 37 additions & 5 deletions agentdescent/algorithms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
8 changes: 7 additions & 1 deletion agentdescent/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)}

Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions changelog.d/876-param-did-you-mean.md
Original file line number Diff line number Diff line change
@@ -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).
30 changes: 30 additions & 0 deletions tests/test_algorithms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down
24 changes: 24 additions & 0 deletions tests/test_cli_algorithms_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading