Skip to content

perf(python/core): check updateComponents incrementally under deferred graph checks - #3326

Open
nan-yu wants to merge 9 commits into
mainfrom
perf/python-core-incremental-component-checks
Open

nan-yu wants to merge 9 commits into
mainfrom
perf/python-core-incremental-component-checks

Conversation

@nan-yu

@nan-yu nan-yu commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Part of #3315 (wires up core/message_processor_scaling.yaml and the shared timing fixture / python-performance CI job; the a2ui_agent scaling suites agent/direct_json/scaling.yaml and agent/express/scaling.yaml remain).

Fixes the Python core gap that the consolidated blueprint (#3283) records in item 4 of "agent use of MessageProcessor" in a2ui_core.blueprint.md. Under a ValidationConfig that allows dangling references, and orphans or a missing root (the config an agent's RendererState uses), MessageProcessor copied every component on the surface and walked the whole graph for each updateComponents. A surface streamed in n small messages therefore took time in proportion to n². It now checks each batch against the components the batch changes, using the approach the Dart processor uses (ComponentGraphBound in dart/a2ui_core/lib/src/core/component_graph_bound.dart, timed by the suites added in #3314).

What changes

  1. ComponentGraphBound records what the last check found about a surface's graph: the references and parents of each component, the position of each component on the surface, and an upper bound on the length of the longest chain below each component. SurfaceComponentsModel builds it after a full check passes under a deferring config, and marks it current when components_update_applied reports that the checked batch has been applied. Adding, removing or mutating a component bumps a change counter, so any change outside a checked updateComponents makes the next check walk the whole graph.
  2. When the bound is current, the next batch's composition constraints (check_composition) are checked only for the components in the batch, the children the batch adds or removes, and the components outside the batch that reference a component in the batch, in surface order so the first error raised matches validate_composition_constraints. Self-references, cycles and depth (admits) are checked by raising the depth bound of the batch and of the components above it. A cycle raises the bound past any limit. When the bound cannot show that the graph passes, the whole graph is walked with analyze_topology as before, so the outcome and the error message do not change.
  3. The references each check reads are the same as on main under every config. A component type that declares no reference fields has its children guessed from its properties (the legacy v0.8 heuristic): a string property, or a list holding one, counts as a reference only when it names a component the surface holds. The whole-surface checks still pass the ids the surface holds, as on main. For its topology edges, the bound reads references as if every id were held (_AnyId), which over-approximates the edges the whole-surface walk reads. It counts a child toward a depth bound only when the surface holds it, and an id that arrives later already has its parents recorded. The bound keeps the references the composition check reads (without known ids, as on main) in a separate map. To pass _AnyId, get_child_references and is_v0_8_heuristic_child_prop_key now accept any Container[str].
  4. Strict and other non-deferring configs run the old code path unchanged. A config of None still skips the checks. The duplicate-id and per-component schema checks were already per batch, and a message is still checked whole before it changes anything.

test_component_graph_bound.py compares the processor with one that checks the whole surface on every batch. It runs random batches (with cycles, self-references, type changes, composition violations, a missing root, a depth limit of 2, a heuristic Leaf type and a Slots type whose only property is an array of component ids) under three deferring configs, plus a v0.8 variant (L239-L273). For every batch, both processors must accept it or raise the same error, and leave the same components. L276-L356 pins the v0.8 heuristic to main's behavior: a Text whose usageHint names another component closes a cycle under the strict config and under the deferred configs, and makes that component reachable under the strict config. It also compares the two processors on those surfaces sent one component per message in every order.

Timing harness

Timing tests are marked performance. The root pyproject.toml deselects them (-m 'not performance' in addopts), so uv run pytest skips them. A new python-performance job in python_ci.yml runs them on their own:

uv run pytest -m performance python/a2ui_core/tests python/a2ui_agent/tests

python/a2ui_core/tests/timing_harness.py provides the timing fixture, and python/a2ui_core/tests/conftest.py loads it by path for the core tests. It is a plain module rather than a conftest, so the agent package's tests can load it the same way when its scaling suites are wired up in a later PR. It writes a PayloadTemplate at a given size and runs measure_growth as conformance/agent/direct_json/scaling.yaml describes: one untimed run of each size, then five timed runs of each in turn, comparing the medians against max_ratio. The core harness now runs core/message_processor_scaling.yaml in test_conformance_timing instead of listing it in UNRUNNABLE_SUITES. test_timing_cases_have_handlers runs in the default run, so a timing case the harness cannot run fails there too. test_children_arriving_one_per_message_scale_linearly is a core-only performance test: a container whose catalog declares allowedChildren names n children, which then arrive one per message, at n=250 and 1000. It is not in the shared YAML because the Dart processor on main fails it (ratio about 11.5).

Numbers

Measured on the same machine, CPython 3.14, medians of 5 runs.

core/message_processor_scaling.yaml (one process_messages call, n and 4n one-component messages):

case before (n=200 / 800, ratio) after (n=200 / 800, ratio)
relaxed 122.3 ms / 1558.4 ms, 12.7 (fails max_ratio 8) 43.8 ms / 120.8 ms, 2.8
none 24.4 ms / 55.8 ms, 2.3 28.1 ms / 59.8 ms, 2.1

n one-component updateComponents on one surface, each in its own process_messages call, after a createSurface and a root Column naming the n children. "deferred" is allow_dangling_references, allow_missing_root and allow_orphan_components:

config before (n=100 / 200 / 400) after (n=100 / 200 / 400)
deferred 42.0 / 119.4 / 429.0 ms 17.2 / 31.4 / 64.8 ms
none 7.5 / 13.7 / 28.0 ms 7.4 / 13.5 / 27.0 ms

A Strict container (allowedChildren) naming n children that then arrive one per message, deferred config: 120.8 ms at n=250, 436.5 ms at n=1000, ratio 3.6.

Verification

  1. uv run pytest: 3046 passed, 46 skipped, 3 deselected, 5 xfailed.
  2. uv run pytest -m performance python/a2ui_core/tests python/a2ui_agent/tests: 3 passed.
  3. uv run pyink --check . and uv run --all-packages mypy . are clean.

The blueprint text in #3283 still lists the Python processor as a gap. Once this lands, that sentence can drop Python.

…d graph checks

Under a ValidationConfig that allows dangling references, and orphans or
a missing root, SurfaceComponentsModel keeps an index of the graph the
last check passed: each component's references and parents, its position
on the surface, and an upper bound on the depth below it. The next batch
is checked against the components it changes, as the Dart processor
does, and the whole graph is walked only when the bound cannot show that
it passes. The composition, root and topology checks and their errors
are unchanged, and other configs check the whole surface as before.

The core conformance harness now runs core/message_processor_scaling.yaml.
Timing tests are marked `performance`, deselected by default and run
with `uv run pytest -m performance` in a job of their own in Python CI.
The `timing` fixture in python/conftest.py is shared with the agent tests.

Signed-off-by: Nan Yu <nanyu@google.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces incremental component graph validation to optimize performance when processing updates. Under a validation configuration that defers graph checks (allowing dangling references, orphans, or a missing root), the MessageProcessor and SurfaceComponentsModel now validate updates against only the changed components using a new GraphIndex helper, rather than walking the entire graph. Additionally, the PR updates type annotations to accept any Container of component IDs, adds a performance test suite for timing-based conformance tests, and includes extensive tests verifying the correctness of the incremental validation. I have no feedback to provide as there are no review comments.

@nan-yu
nan-yu requested a review from polina-c October 19, 2026 23:42
nan-yu added a commit that referenced this pull request Oct 20, 2026
…te where the SDKs differ

- a2ui_core: RendererState's atomic application undoes a rejected message from
  an undo log rather than rolling back to a copy. Requirement 4 now states only
  what a processor must do under a ValidationConfig that defers the graph
  checks, with no SDK status.
- a2ui_core: the agent example shows the agent using RendererState (apply, then
  checkTopology) and, separately, the MessageProcessor calls RendererState makes
  inside, since an agent does not call MessageProcessor itself.
- a2ui_agent: "Where the SDKs differ" assumes the pending Python (#3326,
  #3268-#3271) and TypeScript (#3327, #3278, #3279) pull requests. Both meet the
  three requirements of "Cost of a response"; the remaining entries are Dart's,
  plus Python being the only SDK with a v0.8 state.

Signed-off-by: Nan Yu <nanyu@google.com>
TimingHarness and the `timing` fixture move into a plain module,
python/a2ui_core/tests/timing_harness.py, which conftest.py loads by
path and re-exports. The test suites of other packages can then load the
same module from their own conftest.py instead of needing a conftest
above every test directory.
nan-yu added a commit that referenced this pull request Oct 20, 2026
…t/python-agent-renderer-state

#3326 now keeps the timing harness package-local in
python/a2ui_core/tests/timing_harness.py. Drop python/conftest.py, which
this branch had restored, and add python/a2ui_agent/tests/conftest.py,
which loads that module by path and exposes the `timing` fixture to the
agent's `performance` tests.
@nan-yu
nan-yu added this pull request to stack #3340 October 20, 2026 17:47

@polina-c polina-c left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I checked out the branch and ran the default tests (2715 passed, 15 skipped, 5 xfailed) and -m performance (2 passed). I also timed a case the suite doesn't cover: a Column whose catalog declares allowedChildren, receiving one child per message under the agent config. It stays linear, at 109.8 ms for 250 and 414.2 ms for 1000, a ratio of 3.77. #3327 is quadratic on the same case, and check_composition's holding is the part it lacks.

The PR also makes two changes to which properties count as references. Both apply under every config, strict included, and the CHANGELOG doesn't mention either: it says the checks and their errors are unchanged. The inline comments show the cases.

The description is out of date after the later commits:

  1. _AnyId is gone. The bound and the whole-surface checks now both read references without known ids, so the paragraph about the v0.8 heuristic no longer holds.
  2. The timing fixture is in python/a2ui_core/tests/timing_harness.py, which python/a2ui_core/tests/conftest.py loads. It is no longer in python/conftest.py, and it no longer reaches every test under python/.

This PR and #3327 both rewrite the same sentence of conformance/README.md, and each names the other SDK as the one with no runner. Whichever lands second should drop that example.

🤖 Agent-generated

for ref_id, field_name in comp.get_child_references(
known_component_ids=all_ids
):
for ref_id, field_name in comp.get_child_references():

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Dropping the known ids here, in validate_component_integrity (L75) and in SurfaceComponentsModel.get_child_references changes which v0.8 properties count as references under every config. A type without reference fields falls back to the heuristic. On main, a string property that names a component the surface holds is a reference; now it is not.

With the v0.8 basic catalog, take a Text a whose usageHint is h1, on a surface that also holds a component with id h1, and root a Column naming a:

  1. h1 is a Column naming a, strict config: main raises A2uiRecursionError (cycle). This PR raises A2uiIntegrityError (h1 is not reachable from root).
  2. The same surface under the deferred config: main raises A2uiRecursionError. This PR accepts it.
  3. h1 is a Text, strict config: main accepts it. This PR raises A2uiIntegrityError (h1 is not reachable).

main's reading of usageHint is a false positive, so the new edges may be the better ones. But the CHANGELOG says "The checks and the errors they raise are unchanged", and the differential tests can't catch the difference, because both processors read the new edges.

There are two ways to resolve this:

  1. Keep main's edges: the known ids for the whole-surface checks, and _AnyId for the bound, as in 7968ae3.
  2. Keep the change, and say so in the CHANGELOG with a test for each case above.

With option 2, the CHANGELOG entry about Container[str] can go too, since nothing passes known ids any more.

🤖 Agent-generated

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Went with option 1 and kept main's edges, in 133dbf8.

  • The whole-surface checks pass the known ids again, as on main: validate_component_integrity, analyze_topology and SurfaceComponentsModel.get_child_references. The composition check still reads references without known ids, as on main.
  • ComponentGraphBound uses _AnyId again for its topology edges, so they over-approximate the edges the whole-surface check reads. It keeps a separate map for the composition references. That is the 7968ae3 layout. From 7980442 I kept only the cleanup of empty parent sets (_set_edges). 4403c74 had already reverted that commit's change to ComponentModel.get_child_references.
  • Tests for your three cases, asserting main's behavior (L276-L356): case 1 raises A2uiRecursionError under the strict config. Case 2 raises it under each of the three deferred configs, with the components sent over several messages so the incremental path does the check. Case 3 is accepted under the strict config. A differential test sends the three components one per message, in every order, with h1 as a Column and as a Text, under the deferred configs, and requires the incremental and whole-surface processors to give the same outcome and hold the same components. Cases 1 to 3 fail on bfddfe5. Making the bound read references without _AnyId fails 51 tests in the file.
  • The CHANGELOG's "unchanged" line now holds. I kept the Container[str] entry because _AnyId goes through that API.

Comment on lines +445 to +449
if _is_single_child_json_schema(
items
) or _is_child_list_json_schema(items):
list_refs.add(prop_name)
continue

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This also changes the reference map under every config. A property that is an array with items: {"$ref": "#/$defs/ComponentId"} is now a list of references. On main its spec was empty, so the heuristic counted the property only when one of its items named a component the surface holds.

With a v0.9 catalog and a strict config, a root with slots: ["missing"] was accepted and now raises a dangling-reference error. With one held item and one missing item, both raise, but the field in the message changes from slots.[1] to slots[1].

This looks like a fix, but it is a separate one. It needs a CHANGELOG line and a case in test_reference_map.py.

🤖 Agent-generated

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Took it out in 133dbf8. reference_map.py now matches main, so #3326 changes no reference map under any config.

The incremental check doesn't need it. Such a property now goes through the heuristic again, and the bound reads it with _AnyId (every item counts), which over-approximates what the whole-surface walk reads. To check that, I added a Slots type to the random differential test (L66-L78, L115-L116). It has only slots: {type: array, items: {$ref: "#/$defs/ComponentId"}} and an empty ref spec. The incremental and whole-surface processors agree on it under all three deferred configs.

The fix itself (reading such arrays as lists of references, with slots[1] paths) can go in a separate PR with a CHANGELOG line and a test_reference_map.py case.

nan-yu added a commit that referenced this pull request Oct 20, 2026
…eferred graph checks

A component outside the batch that references a component of the batch
is now checked against only the children of the batch it references, as
the Python core does with its holding index, so a container whose
catalog declares allowedChildren, receiving one child per message, is
no longer checked against all its children each time. Adds an SDK-local
timing case for it under yarn test:timing.

docs(conformance): end the timing runner sentence without naming the
SDK that has no runner, so #3326 and #3327 make the same change there.
Restores the known component ids in validate_component_integrity,
analyze_topology and SurfaceComponentsModel.get_child_references, so the
legacy v0.8 heuristic reads the same references as on main, and restores
_AnyId for ComponentGraphBound, whose topology edges over-approximate
the ones the whole-surface check reads. Composition keeps reading the
references without known ids, as on main, in a map of its own. Keeps the
cleanup of empty parent sets from 7980442.

Drops the reference_map branch that read an array of ComponentId items
as a list of references; it changed the reference map under every
config and is not needed for the incremental check.

Adds tests for the v0.8 usageHint cases from review, a Slots type to the
differential test, and a performance test for a container with
allowedChildren receiving one child per message.

docs(conformance): end the timing runner sentence without naming the
SDK that has no runner, so #3326 and #3327 make the same change there.

This branch has not been deployed

No deployments
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