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
5 changes: 5 additions & 0 deletions loopx/capabilities/explore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions loopx/capabilities/explore/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,8 @@ loopx explore worker-branch-plan --goal-id <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 之上**,而不是并排或取代:
Expand Down
10 changes: 8 additions & 2 deletions loopx/capabilities/explore/resource_portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand Down
72 changes: 71 additions & 1 deletion tests/test_explore_resource_portfolio.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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",
)
Loading