diff --git a/CHANGELOG.md b/CHANGELOG.md index d642492..403184a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); ver ### Fixed +- `decide` rejects repeated `--option` keys before model setup instead of silently replacing an earlier choice + description. The usage error names the duplicate key without printing either description. - Malformed ticket-router JSONL now names the dataset, physical line and JSON column without printing ticket contents. - Laya browser-state compaction preserves the recovery plan for the next decision. - Desktop recovery resets its detection window on observed progress without resetting its cumulative budget. diff --git a/README.md b/README.md index ce1490d..b465718 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,9 @@ uv run s1a run blackjack --model jev --rethink off --episodes 20 uv run s1a run blackjack --model llm --rethink off --episodes 20 # the chat model in the same agent ``` +Each `--option KEY=DESCRIPTION` must use a unique, case-sensitive key. Repeated keys are usage errors (exit 2), +reported before loading a model or checking its credentials; they never replace an earlier description. + `decide` prints one JSON object with `choice`, a probability per option, `confidence` and `ms`; `run` writes a job folder with the score. Without a key, `--model cua` answers in process after `uv sync --extra cua`. diff --git a/s1a/cli.py b/s1a/cli.py index 134d284..1bb4b4b 100644 --- a/s1a/cli.py +++ b/s1a/cli.py @@ -44,7 +44,11 @@ def parser() -> argparse.ArgumentParser: ) decide.add_argument("--state", required=True, help="a JSON object, or @path to a file holding one") decide.add_argument( - "--option", action="append", required=True, metavar="KEY=DESCRIPTION", help="one candidate; repeat per option" + "--option", + action="append", + required=True, + metavar="KEY=DESCRIPTION", + help="one candidate; repeat per option with a unique key", ) decide.add_argument("--rules", required=True, help="the facts the model applies when it picks") decide.add_argument( @@ -85,6 +89,8 @@ def parse_options(items: list[str]) -> dict[str, str]: key, sep, description = item.partition("=") if not sep or not key: raise ValueError(f"--option needs KEY=DESCRIPTION, got {item!r}") + if key in options: + raise ValueError(f"--option repeats key {key!r}; use a unique key for each choice") options[key] = description return options diff --git a/tests/test_cli.py b/tests/test_cli.py index 74d1ac7..20ea3dd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -241,6 +241,21 @@ def test_malformed_input_is_one_line_on_stderr_and_exit_2_before_any_key_check(s if argv[0] == "decide" and argv[2] != "{}": # the three malformed --state values self.assertTrue(err.startswith("--state"), err) + def test_duplicate_option_keys_fail_before_building_any_model(self) -> None: + for model in cli.DECIDE_MODEL_NAMES: + for description in ("keep", "sensitive replacement description"): + with self.subTest(model=model, description=description), patch.object(cli, "build_model") as build: + code, out, err = _main([*DECIDE, "--model", model, "--option", f"stand={description}"]) + self.assertEqual((code, out), (2, "")) + self.assertEqual(err, "--option repeats key 'stand'; use a unique key for each choice\n") + build.assert_not_called() + + def test_valid_options_preserve_order_case_and_description(self) -> None: + options = cli.parse_options(["b=one=two", "A= leading and trailing ", "a=", "路线=支持"]) + self.assertEqual( + list(options.items()), [("b", "one=two"), ("A", " leading and trailing "), ("a", ""), ("路线", "支持")] + ) + def test_state_from_a_file_and_bad_options(self) -> None: with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "state.json"