From cb1acd019ed5ccdf187d770ee7b95a3c3bb313c9 Mon Sep 17 00:00:00 2001 From: shangzh0 <97216392+shangzh0@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:29:40 +0000 Subject: [PATCH] fix(explore): reject ambiguous resource portfolio inputs Signed-off-by: shangzh0 <97216392+shangzh0@users.noreply.github.com> --- loopx/capabilities/explore/README.md | 5 ++ loopx/capabilities/explore/README.zh-CN.md | 2 + .../explore/resource_portfolio.py | 10 ++- tests/test_explore_resource_portfolio.py | 72 ++++++++++++++++++- 4 files changed, 86 insertions(+), 3 deletions(-) diff --git a/loopx/capabilities/explore/README.md b/loopx/capabilities/explore/README.md index b90294385f..399fe082a2 100644 --- a/loopx/capabilities/explore/README.md +++ b/loopx/capabilities/explore/README.md @@ -667,6 +667,11 @@ untagged todos retain their previous unconstrained behavior, while a tagged lane must have a matching declared capacity. Usage without a matching capacity fails closed to catch misspelled lane keys. +Lane keys normalize case, spaces, and hyphens (`LONG POOL` and `long-pool` +both name `long_pool`). Repeated normalized keys are rejected in both CLI and +mapping inputs instead of overwriting capacity or usage. Counts must be +non-negative whole numbers; booleans and fractional values are rejected. + This remains analysis-only evidence: `resource_portfolio.score_delta=0`, typed evidence keeps `score_delta=0`, and the planner is read-only. Capacity and usage do not claim todos, acquire leases, launch workers, write state, or grant quota diff --git a/loopx/capabilities/explore/README.zh-CN.md b/loopx/capabilities/explore/README.zh-CN.md index 4d5f105cf2..149487e088 100644 --- a/loopx/capabilities/explore/README.zh-CN.md +++ b/loopx/capabilities/explore/README.zh-CN.md @@ -320,6 +320,8 @@ loopx explore worker-branch-plan --goal-id --worker-width 5 \ 资源输入可选。没有 `--resource-capacity` 时,无 lane 与 legacy todos 保留现有 width/scheduler 行为。资源感知模式下,无 tag todos 保持原来的无约束行为;带 tag lane 必须有匹配的声明容量。使用没有匹配容量的 usage 会 fail closed,以捕捉拼错的 lane key。 +Lane key 会统一大小写、空格与连字符(`LONG POOL` 和 `long-pool` 都表示 `long_pool`)。CLI 与映射输入都会拒绝规范化后的重复 key,避免覆盖容量或占用。计数必须是非负整数;布尔值与非整数小数会被拒绝。 + 这仍是仅分析证据:`resource_portfolio.score_delta=0`、typed evidence 保持 `score_delta=0`、planner 只读。容量与占用不 claim todo、不 acquire lease、不 launch worker、不写状态、不授予 quota 权威。它们只约束预测 portfolio;执行仍进入下面描述的正常 LoopX 生命周期。 该命令只读,需要开启 Goal 有效模式。它设计为**叠加在现有 LoopX harness 之上**,而不是并排或取代: diff --git a/loopx/capabilities/explore/resource_portfolio.py b/loopx/capabilities/explore/resource_portfolio.py index 4bbd03eaad..b346ff5a97 100644 --- a/loopx/capabilities/explore/resource_portfolio.py +++ b/loopx/capabilities/explore/resource_portfolio.py @@ -46,11 +46,17 @@ def normalize_resource_counts( counts: dict[str, int] = {} for raw_key, raw_count in (values or {}).items(): key = normalize_resource_lane_key(raw_key) + if key in counts: + raise ValueError(f"{field_name} repeats resource lane {key!r}") try: count = int(raw_count) - except (TypeError, ValueError) as exc: + except (OverflowError, TypeError, ValueError) as exc: raise ValueError(f"{field_name}[{key!r}] must be a non-negative integer") from exc - if count < 0: + if ( + isinstance(raw_count, bool) + or count < 0 + or (not isinstance(raw_count, str) and raw_count != count) + ): raise ValueError(f"{field_name}[{key!r}] must be a non-negative integer") counts[key] = count return dict(sorted(counts.items())) diff --git a/tests/test_explore_resource_portfolio.py b/tests/test_explore_resource_portfolio.py index 2f27755fe0..1307470bae 100644 --- a/tests/test_explore_resource_portfolio.py +++ b/tests/test_explore_resource_portfolio.py @@ -1,6 +1,13 @@ from __future__ import annotations -from loopx.capabilities.explore.resource_portfolio import parse_resource_counts +from copy import deepcopy + +import pytest + +from loopx.capabilities.explore.resource_portfolio import ( + normalize_resource_counts, + parse_resource_counts, +) from loopx.capabilities.explore.todo_branch_plan import build_explore_todo_branch_plan from loopx.capabilities.explore.worker_branch_plan import build_explore_worker_branch_plan @@ -214,3 +221,66 @@ def test_resource_count_cli_values_are_normalized_and_duplicates_fail() -> None: assert "repeats resource lane" in str(exc) else: raise AssertionError("duplicate resource lane should fail closed") + + +@pytest.mark.parametrize( + "planner", [build_explore_todo_branch_plan, build_explore_worker_branch_plan] +) +@pytest.mark.parametrize("field", ["resource_capacities", "resource_usage"]) +@pytest.mark.parametrize("alias", ["long-pool", "LONG_POOL", "long pool"]) +@pytest.mark.parametrize("reverse", [False, True]) +def test_resource_aliases_cannot_overwrite_capacity_or_usage( + planner, field, alias, reverse +) -> None: + entries = [("long_pool", 2), (alias, 0)] + if reverse: + entries.reverse() + arguments = { + "goal_id": "synthetic-resource-aliases", + "todos": TODOS, + "projection": PROJECTION, + "orchestration": ANALYSIS_ONLY, + "resource_capacities": CAPACITIES, + "resource_usage": USAGE, + field: dict(entries), + } + before = deepcopy(arguments) + + with pytest.raises(ValueError, match=f"{field} repeats resource lane 'long_pool'"): + planner(**arguments) + + assert arguments == before + + +@pytest.mark.parametrize( + "planner", [build_explore_todo_branch_plan, build_explore_worker_branch_plan] +) +@pytest.mark.parametrize("field", ["resource_capacities", "resource_usage"]) +@pytest.mark.parametrize( + "value", [True, False, 0.5, -0.5, -1, float("nan"), float("inf"), "1.5", None] +) +def test_invalid_resource_counts_fail_before_planning(planner, field, value) -> None: + with pytest.raises(ValueError, match=rf"{field}\['long_pool'\].*non-negative integer"): + planner( + goal_id="synthetic-invalid-resource-counts", + todos=TODOS, + orchestration=ANALYSIS_ONLY, + **{ + "resource_capacities": CAPACITIES, + "resource_usage": USAGE, + field: {"long_pool": value}, + }, + ) + + +def test_valid_resource_counts_preserve_normalized_cli_and_api_inputs() -> None: + normalized = normalize_resource_counts( + {"LONG POOL": "2", "short-pool": 3.0, "idle_pool": 0}, + field_name="resource_capacities", + ) + + assert normalized == {"idle_pool": 0, "long_pool": 2, "short_pool": 3} + assert normalized == parse_resource_counts( + ["LONG POOL=2", "short-pool=3", "idle_pool=0"], + flag_name="--resource-capacity", + )