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
23 changes: 22 additions & 1 deletion agentdescent/algorithms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -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:
Expand Down
7 changes: 5 additions & 2 deletions agentdescent/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions changelog.d/888-param-choice-aliases.md
Original file line number Diff line number Diff line change
@@ -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).
33 changes: 33 additions & 0 deletions tests/test_algorithms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
26 changes: 26 additions & 0 deletions tests/test_cli_algorithms_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading