Repository navigation
Conversation
…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>
There was a problem hiding this comment.
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.
…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.
…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.
polina-c
left a comment
There was a problem hiding this comment.
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:
_AnyIdis 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.- The
timingfixture is inpython/a2ui_core/tests/timing_harness.py, whichpython/a2ui_core/tests/conftest.pyloads. It is no longer inpython/conftest.py, and it no longer reaches every test underpython/.
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(): |
There was a problem hiding this comment.
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:
h1is aColumnnaminga, strict config:mainraisesA2uiRecursionError(cycle). This PR raisesA2uiIntegrityError(h1is not reachable fromroot).- The same surface under the deferred config:
mainraisesA2uiRecursionError. This PR accepts it. h1is aText, strict config:mainaccepts it. This PR raisesA2uiIntegrityError(h1is 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:
- Keep
main's edges: the known ids for the whole-surface checks, and_AnyIdfor the bound, as in 7968ae3. - 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
There was a problem hiding this comment.
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_topologyandSurfaceComponentsModel.get_child_references. The composition check still reads references without known ids, as onmain. ComponentGraphBounduses_AnyIdagain 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 toComponentModel.get_child_references.- Tests for your three cases, asserting
main's behavior (L276-L356): case 1 raisesA2uiRecursionErrorunder 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, withh1as aColumnand as aText, 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_AnyIdfails 51 tests in the file. - The CHANGELOG's "unchanged" line now holds. I kept the
Container[str]entry because_AnyIdgoes through that API.
| if _is_single_child_json_schema( | ||
| items | ||
| ) or _is_child_list_json_schema(items): | ||
| list_refs.add(prop_name) | ||
| continue |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
…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.
Part of #3315 (wires up
core/message_processor_scaling.yamland the sharedtimingfixture /python-performanceCI job; thea2ui_agentscaling suitesagent/direct_json/scaling.yamlandagent/express/scaling.yamlremain).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 aValidationConfigthat allows dangling references, and orphans or a missing root (the config an agent'sRendererStateuses),MessageProcessorcopied every component on the surface and walked the whole graph for eachupdateComponents. 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 (ComponentGraphBoundindart/a2ui_core/lib/src/core/component_graph_bound.dart, timed by the suites added in #3314).What changes
ComponentGraphBoundrecords 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.SurfaceComponentsModelbuilds it after a full check passes under a deferring config, and marks it current whencomponents_update_appliedreports that the checked batch has been applied. Adding, removing or mutating a component bumps a change counter, so any change outside a checkedupdateComponentsmakes the next check walk the whole graph.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 matchesvalidate_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 withanalyze_topologyas before, so the outcome and the error message do not change.mainunder 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 onmain. 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 onmain) in a separate map. To pass_AnyId,get_child_referencesandis_v0_8_heuristic_child_prop_keynow accept anyContainer[str].Nonestill 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.pycompares 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 heuristicLeaftype and aSlotstype 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 tomain's behavior: aTextwhoseusageHintnames 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 rootpyproject.tomldeselects them (-m 'not performance'inaddopts), souv run pytestskips them. A newpython-performancejob inpython_ci.ymlruns them on their own:python/a2ui_core/tests/timing_harness.pyprovides thetimingfixture, andpython/a2ui_core/tests/conftest.pyloads 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 aPayloadTemplateat a given size and runsmeasure_growthasconformance/agent/direct_json/scaling.yamldescribes: one untimed run of each size, then five timed runs of each in turn, comparing the medians againstmax_ratio. The core harness now runscore/message_processor_scaling.yamlintest_conformance_timinginstead of listing it inUNRUNNABLE_SUITES.test_timing_cases_have_handlersruns in the default run, so a timing case the harness cannot run fails there too.test_children_arriving_one_per_message_scale_linearlyis a core-onlyperformancetest: a container whose catalog declaresallowedChildrennames n children, which then arrive one per message, at n=250 and 1000. It is not in the shared YAML because the Dart processor onmainfails it (ratio about 11.5).Numbers
Measured on the same machine, CPython 3.14, medians of 5 runs.
core/message_processor_scaling.yaml(oneprocess_messagescall, n and 4n one-component messages):relaxedmax_ratio8)nonen one-component
updateComponentson one surface, each in its ownprocess_messagescall, after acreateSurfaceand a rootColumnnaming the n children. "deferred" isallow_dangling_references,allow_missing_rootandallow_orphan_components:A
Strictcontainer (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
uv run pytest: 3046 passed, 46 skipped, 3 deselected, 5 xfailed.uv run pytest -m performance python/a2ui_core/tests python/a2ui_agent/tests: 3 passed.uv run pyink --check .anduv 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.