From 289520d9b849941ec7605683aa087481bd6dc4b3 Mon Sep 17 00:00:00 2001 From: Vladimir Yakunin Date: Thu, 10 Sep 2026 22:16:50 +0000 Subject: [PATCH 1/2] Let a plan name one task twice, and number its nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A customer plan may want one catalogue task under two scenes. `EvalPlan` refused it: `_each_task_appears_once` held the task list to unique ids, which is a rule about our report rather than about what the platform can run. The rule goes. In its place `_number_the_nodes` stamps each node with its index, which is what identifies a node when `task_id` cannot. `TaskNode.position` is excluded from the wire: a caller states the order by writing the list, and the index overwrites anything sent. The coordinator keys its tables on that position already, so the two agree on what a node is. Client 0.7.0 -> 0.8.0, with the root pin and the lock. Ticket: none — client-side plan model; the coordinator half is its own PR. --- client/platform_client/eval_plan.py | 16 +++++++++-- .../platform_client/tests/test_eval_plan.py | 28 +++++++++++++++++-- client/pyproject.toml | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 5 files changed, 42 insertions(+), 8 deletions(-) diff --git a/client/platform_client/eval_plan.py b/client/platform_client/eval_plan.py index fb0bf0b0a..6780328bc 100644 --- a/client/platform_client/eval_plan.py +++ b/client/platform_client/eval_plan.py @@ -164,6 +164,11 @@ class TaskNode(Cascade): task_id: TaskRef endpoints: list[Endpoint] | None = Field(default=None, min_length=1) + # Where this node sits in the plan's task list, stamped by `EvalPlan._number_the_nodes`. It + # identifies the node, so a plan naming one task twice keys its two nodes apart; `task_id` alone + # cannot. Excluded from the wire: a caller states the order by writing the list, and a value it + # sent would be overwritten by the index anyway. + position: int = Field(default=0, ge=0, exclude=True) @model_validator(mode='before') @classmethod @@ -223,8 +228,15 @@ def _states_a_count(self) -> Self: return self @model_validator(mode='after') - def _each_task_appears_once(self) -> Self: - _require_unique_names([task.task_id for task in self.tasks], 'the plan') + def _number_the_nodes(self) -> Self: + """Stamp each node with its place in the list, which is what identifies it. + + A plan may name one catalogue task twice, each node with its own scene, and the position is + what keeps the two apart everywhere they are filed. Nothing refuses the repeat: a caller + asking for two scenes of one task is asking for something the platform runs. + """ + for position, task in enumerate(self.tasks): + task.position = position return self @model_validator(mode='after') diff --git a/client/platform_client/tests/test_eval_plan.py b/client/platform_client/tests/test_eval_plan.py index d0b06488b..5264f532a 100644 --- a/client/platform_client/tests/test_eval_plan.py +++ b/client/platform_client/tests/test_eval_plan.py @@ -240,15 +240,37 @@ def test_an_endpoint_says_whether_it_names_a_locator(): assert Endpoint(name='baseline', url='wss://x/ws').names_a_locator is True -def test_a_plan_names_each_task_and_each_endpoint_once(): - with pytest.raises(ValidationError, match='more than once'): - a_plan(tasks=[SPOONS, SPOONS]) +def test_a_plan_names_each_endpoint_once(): with pytest.raises(ValidationError, match='more than once'): a_plan(endpoints=[BASELINE, BASELINE]) with pytest.raises(ValidationError, match='more than once'): TaskNode.model_validate({'task_id': SPOONS, 'endpoints': [{'name': 'e'}, {'name': 'e'}]}) +def test_a_plan_may_name_one_task_twice(): + """Two nodes of one task, each with its own scene, is a plan the platform runs. The position + keeps them apart; the id cannot.""" + plan = a_plan(tasks=[SPOONS, SPOONS]) + + assert [task.task_id for task in plan.tasks] == [SPOONS, SPOONS] + assert [task.position for task in plan.tasks] == [0, 1] + + +def test_every_node_is_numbered_by_its_place_in_the_list(): + plan = a_plan(tasks=[SPOONS, {'task_id': SPOONS, 'episodes_per_endpoint': 2}, SPOONS]) + + assert [task.position for task in plan.tasks] == [0, 1, 2] + + +def test_a_position_a_caller_states_is_the_list_order_regardless(): + """The index identifies the node, so a sent value cannot disagree with where it sits — and the + field never goes out on the wire for a caller to have sent one in the first place.""" + plan = a_plan(tasks=[{'task_id': SPOONS, 'position': 7}, SPOONS]) + + assert [task.position for task in plan.tasks] == [0, 1] + assert 'position' not in plan.model_dump()['tasks'][0] + + def test_a_task_endpoint_naming_no_locator_names_one_the_plan_defines(): with pytest.raises(ValidationError, match='name no endpoint the plan defines'): a_plan(tasks=[{'task_id': SPOONS, 'endpoints': ['elsewhere']}]) diff --git a/client/pyproject.toml b/client/pyproject.toml index c932eed8c..d02705e6c 100644 --- a/client/pyproject.toml +++ b/client/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "positronic-platform-client" -version = "0.7.0" +version = "0.8.0" description = "Typed wire contract and HTTP client for the Positronic evaluation platform API" readme = "README.md" requires-python = ">=3.11" diff --git a/pyproject.toml b/pyproject.toml index 281f7640e..8c6ae6156 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ dependencies = [ # (.github/workflows/release.yaml). Pinned exactly, because that package guarantees no # backwards compatibility: a floating requirement would let a fresh install of THIS release # resolve a contract it was never built against. - "positronic-platform-client==0.7.0", + "positronic-platform-client==0.8.0", "psutil", # `positronic-server` enumerates local interfaces to name a wildcard bind's addresses "pyarrow", "pydantic", diff --git a/uv.lock b/uv.lock index 8e162a212..9c55bdc72 100644 --- a/uv.lock +++ b/uv.lock @@ -5095,7 +5095,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/16/45/029c72cffe797d826 [[package]] name = "positronic-platform-client" -version = "0.7.0" +version = "0.8.0" source = { editable = "client" } dependencies = [ { name = "httpx" }, From fd310f93ed260944243046dcc1e980af8d01bd3d Mon Sep 17 00:00:00 2001 From: Vladimir Yakunin Date: Thu, 10 Sep 2026 22:19:06 +0000 Subject: [PATCH 2/2] Tighten the position comment and docstring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cut the four-line field comment to the one fact a reader acts on, and drop the two clefts and the rationale the PR body already carries. Ticket: none — a prose pass on the commit before it. --- client/platform_client/eval_plan.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/client/platform_client/eval_plan.py b/client/platform_client/eval_plan.py index 6780328bc..0e1ce7a0b 100644 --- a/client/platform_client/eval_plan.py +++ b/client/platform_client/eval_plan.py @@ -164,10 +164,7 @@ class TaskNode(Cascade): task_id: TaskRef endpoints: list[Endpoint] | None = Field(default=None, min_length=1) - # Where this node sits in the plan's task list, stamped by `EvalPlan._number_the_nodes`. It - # identifies the node, so a plan naming one task twice keys its two nodes apart; `task_id` alone - # cannot. Excluded from the wire: a caller states the order by writing the list, and a value it - # sent would be overwritten by the index anyway. + # This node's index in the plan's task list, stamped by `EvalPlan._number_the_nodes`. position: int = Field(default=0, ge=0, exclude=True) @model_validator(mode='before') @@ -229,11 +226,11 @@ def _states_a_count(self) -> Self: @model_validator(mode='after') def _number_the_nodes(self) -> Self: - """Stamp each node with its place in the list, which is what identifies it. + """Stamp each node with its place in the list, which identifies it. - A plan may name one catalogue task twice, each node with its own scene, and the position is - what keeps the two apart everywhere they are filed. Nothing refuses the repeat: a caller - asking for two scenes of one task is asking for something the platform runs. + A plan may name one catalogue task twice, each node with its own scene, so `task_id` names + no single node and the position does. The stamp overwrites a value a caller sent: the index + is the only answer consistent with where the node sits. """ for position, task in enumerate(self.tasks): task.position = position