Skip to content

Scaling: Resource: remember which names a tree holds instead of re-reading it - #1222

Open
BioCam wants to merge 9 commits into
mainfrom
v1-resource-name-index
Open

Scaling: Resource: remember which names a tree holds instead of re-reading it#1222
BioCam wants to merge 9 commits into
mainfrom
v1-resource-name-index

Conversation

@BioCam

@BioCam BioCam commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Problem

_check_naming_conflicts enforces tree-wide name uniqueness by recursing over the whole tree, and assign_child_resource calls it on get_root() for every resource assigned. An assignment costs the size of everything already in the tree rather than the size of what is arriving, so building n resources costs n² - measured between n^1.9 and n^2.0 - and every later assignment still walks all of it.

A loaded STARlet deck is around 2 000 resources. One plate move on it, an unassign and a re-assign, already costs 47 ms. Put several instruments in one tree and the cost grows with all of them, not with the plate:

resources instruments before after
2 001 1 47.0 ms 0.0 ms
4 002 2 93.1 ms 0.0 ms
8 004 4 186.7 ms 0.0 ms
16 008 8 379.3 ms 0.0 ms

Changes

  • Resource._name_index holds the names in a tree, kept only by the root, built the first time _names_in_tree asks for it.
  • _subtree_names returns the names at or beneath a resource; assign_child_resource unions the arriving ones in, unassign_child_resource subtracts them.
  • _check_naming_conflicts walks the arriving subtree against that set instead of recursing over both trees. It reads the index off get_root(), where it was already called from, so it still covers the whole tree.

Behaviour: unchanged, and an assignment now costs the size of what is arriving. Carrying the index forward is safe because a name cannot change while a resource is assigned - the setter refuses - and a tree only changes shape in the two methods that maintain it.

Scope: cheaper, not narrower. The check still runs before the branch that detaches an already-attached resource, so a move within one tree is still refused and callers still unassign first. Excluding the arriving resource's own names would fix that, and is a follow-up since it changes behaviour the tests pin.

Tests

TestNameIndex (a duplicate name, one buried in the arriving subtree, unassigning freeing a name, a subtree carrying its names into whatever tree takes it, and the order a move has to happen in). Checked separately against the recursion it replaces over 4 000 generated tree shapes, and the index against a fresh walk after each step of 300 assign and unassign sequences. ruff format, ruff check --select I, ruff check and mypy pylabrobot --check-untyped-defs are clean; the full suite passes (2 450 passed, 2 skipped, 206 subtests).

`_check_naming_conflicts` recursed over the whole tree on every assignment, so an
assignment cost the size of everything already in the tree rather than the size
of what was arriving. The root now keeps the names in its tree, built the first
time something asks and maintained by the two methods that change a tree's shape.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@BioCam
BioCam marked this pull request as ready for review August 27, 2026 15:38
@BioCam
BioCam requested a review from rickwierenga August 27, 2026 15:38
@rickwierenga

Copy link
Copy Markdown
Member

let's remove the stuff from the Deck class in this case

Comment thread pylabrobot/resources/resource.py Outdated
Comment on lines +183 to +187
self.children: List[Resource] = []
# Every name in this tree, kept only by the root and only once anyone asks. A name cannot change
# while a resource is assigned, and a tree changes shape in exactly two places, so an index can
# be carried forward instead of rebuilt: see `_names_in_tree`.
self._name_index: Optional[Set[str]] = None

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

imo _name_index should be called _subtree_names and _subtree_names should be named _get_subtree_names or something

Comment thread pylabrobot/resources/resource.py Outdated
@rickwierenga

Copy link
Copy Markdown
Member

it would be clean to use the will/did assign child resource

  • will: check name as it currently does
  • did: update cache (so no _names_in_tree cache checking needed)

BioCam and others added 7 commits August 28, 2026 12:25
…n `Deck`

Every resource keeps a map of everything at or beneath it, by name, seeded with
itself and kept in step by did-assign and did-unassign handlers it registers on
itself. Those callbacks already propagate to every ancestor, so an assignment
anywhere updates each map above it without walking a tree.

The map holds the resources themselves, so it answers both questions a name is
asked: whether it is taken, and which resource has it. `get_resource` becomes a
lookup rather than a recursive search, and `Deck` no longer needs its own
`_resources` dict, the two handlers that maintained it, or the
`_check_naming_conflicts` override commented "overwrite for speed" - which
checked only the arriving resource's own name and let a clash buried in its
subtree through.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hem on demand

A resource is a root until something takes it, so it can hold the map of its own
tree from the moment it is made, seeded with itself. `assign_child_resource`
hands what arrives to the new root and `unassign_child_resource` hands it back,
which are the only two moments a root changes. Nothing is built on demand, so
`_names_in_tree` and the unbuilt state it existed to guard both go.

Maintained by those two methods directly rather than through the did-assign and
did-unassign callbacks: those are a public notification list, and a subscriber
that raises part-way, or one that deregisters a handler, would leave the map
short of names the tree really holds and let a duplicate in.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
`assign_child_resource` walked what was arriving twice: once to check each name
against the tree, once to record what to add. `_check_naming_conflicts` now
returns what it walked, so the second pass goes. The check still runs before the
tree changes, so a clash leaves it untouched.

Grafting a carrier of five plates onto a facility of 17 823 resources traverses
the 486 arriving resources once and costs 0.12 ms; the facility's size does not
enter it, since each arriving name is one lookup in the root's map.

`Deck` overrides the check, so it hands back the same map until that override is
removed.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Restores `Resource`, `Deck` and their tests to d884b2f, the state under
review. The reverted commits changed how the index is maintained, which is
the open question in review and not settled yet.
@BioCam

BioCam commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

let's remove the stuff from the Deck class in this case

@rickwierenga - Done.
_resources, _register_resource, _deregister_resource, get_resource, has_resource and the _check_naming_conflicts override are all removed, so Deck no longer keeps its own copy of the tree.

get_all_resources is public and has callers, so I kept it and only changed the body:
it was reading the private dict, and now delegates to get_all_children(). Happy to deprecate it separately if you would rather it went too.

One behaviour change I think is worth flagging:
the old override compared only resource.name, so assigning a carrier that already held a resource named like one on the deck was accepted, and put a duplicate name in the tree.
The Resource version checks the whole arriving subtree, so that case now raises.

That behaviour is what prompted me to generate #1228 .
A name is meant to be an identifier: unique across the tree, and fixed for the life of the resource.
Neither was true.
It could be duplicated on assignment, as above, and it could still be reassigned after the resource existed, which left anything already named after it out of step.
#1228 makes name immutable after instantiation; this check closes the other half.

# Conflicts:
#	pylabrobot/resources/resource.py
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants