Repository navigation
feat: Add bounded Scenario result hierarchy projections (#3145) - #3167
Nimit Jain (Nimit3418) wants to merge 19 commits into
Conversation
|
Thank you for the thorough, production-grade review and the concrete edge-case reproductions. I have addressed all 9 requested changes in the latest push:
All linters passed ( |
…nversation clipping (microsoft#3167)
|
Thank you for catching those nuances! All 4 comments from the second review round have been addressed in the latest commit:
All static type checks ( |
|
Note the follow-up responses on a few open threads from above! |
…d reference bounds (microsoft#3167)
|
Thank you for the thorough follow-ups and the heads-up on the open threads! All remaining edge cases have been resolved in the latest push:
All linters ( |
|
A few of the earlier threads remain open (I responded on each) |
Yes sir, I am doing the changes |
…and database shapes (microsoft#3167)
|
Addressed in commit 0e7abda:
|
…ind preview stats to visible nodes (microsoft#3167)
|
All feedback from the latest review round has been resolved in commit
|
…zero-trust conversation stats (microsoft#3167)
| Returns: | ||
| ScenarioHierarchyProjection: Bounded hierarchy projection with diagnostics. | ||
| """ | ||
| deltas_by_id: dict[str, ScenarioAttackResultDelta] = {d.attack_result_id: d for d in snapshot.deltas} | ||
| results_by_id: dict[str, ScenarioProgressResult] = {r.attack_result_id: r for r in snapshot.results} | ||
|
|
There was a problem hiding this comment.
🔴 Must fix: Do not identify roots from an incomplete delta page.
The new max_deltas limit means snapshot.results can omit a parent saved after its children. This code then treats those children as roots because it has not loaded the parent's stored references.
I persisted 500 children followed by their Sequential-style parent, with all 500 ordered child IDs on that parent. A cold max_nodes=1 request returns the first child in group.roots, with parent_attack_result_id=None and child_index=None. After reading progress to warm the cache, the identical hierarchy request returns the actual parent instead. No saved data changes between those requests.
Please obtain the necessary run-scoped root/parent references through a bounded read before choosing roots, or explicitly represent unavailable parent context instead of presenting these children as independent roots. A real persisted case with the parent beyond the delta budget would catch this.
There was a problem hiding this comment.
Resolved in commit 7c489c5.
Added get_scenario_orchestration_parent_deltas_async to MemoryInterface to query run-scoped orchestration parent deltas when progress_snapshot.has_more is True. In build_scenario_hierarchy, these parent deltas are merged before identifying roots, so that parent references saved after their children (such as in sequential compound attacks) are known regardless of cold delta pagination limits.
Furthermore, root selection now strictly excludes results that carry an attempt_index (which designates a child attempt dispatched by an orchestration parent), and the unreached fallback pass preserves the child's attempt-relative position (fallback_child_index = attempt_index - 1) instead of presenting it as an independent root with child_index = None. Added test_cold_hierarchy_projection_preserves_parent_beyond_delta_page_limit matching your exact reproduction (500 children followed by parent) to guarantee parity between cold and warm requests.
There was a problem hiding this comment.
Rechecked 7c489c5. The original example with 500 plain children followed by their parent is fixed.
The new parent read is still only a prefix, though. _execute_get_scenario_orchestration_parent_deltas uses LIKE '%child_attack_result_ids%' and limits the result to _MAX_BIND_VARS, which is 500 on SQLite. Giving those same 500 target-facing children the valid metadata {"child_attack_result_ids": []} fills all 500 parent-query slots with the children. Their actual later parent is not returned. The cold one-node hierarchy again emits child 0 as a root with no parent; warming progress makes it return the actual parent.
I also reproduced the missing outer root with 501 real orchestration parents, so filtering empty arrays alone will not fix the completeness problem.
Please resolve the parent context needed for the visible nodes through a bounded run-scoped read, without treating this limited parent query as a complete reference set. Increasing the row cap would only move the boundary. Leaving this thread open.
Auto-replied by the GitHub Copilot app
There was a problem hiding this comment.
Rechecked 04d713e. The empty-child-array case and the many-parent examples from the previous reply now find the actual outer root.
There is still a gap for cyclic components, which #3145 explicitly includes. I persisted 500 results forming a cycle, followed by an outer parent whose stored child list points to the first cycle member. Every prefix result is already referenced, so current_candidates is empty and the targeted parent query never runs. A cold max_nodes=1 request presents the first cycle member as a root with no parent; warming progress makes the identical request return the actual outer parent.
Could we also check incoming parent context for unreached components before treating them as roots, within the read budget? The fallback should not imply that an omitted parent does not exist. Leaving this thread open for that remaining case.
Auto-replied by the GitHub Copilot app
There was a problem hiding this comment.
Great catch on cyclic components where every node within the cycle is referenced as a child by another node.
We've enhanced the parent candidate resolution in get_scenario_hierarchy_async:
- A reachability traversal now runs from
unreferenced_roots. - If
unreferenced_rootsis empty (a completely closed cyclic loop in the initial page) or if isolated cyclic components exist that aren't reachable from the unreferenced roots, candidate discovery initializes with or expands intounreached_cyclicnodes up to the parent budget. - Added comprehensive unit test
test_500_cyclic_results_with_outer_parent_at_501_parityvalidating that a 500-result cyclic loop with an external parent at index 501 is discovered identically on both warm and cold paths.
There was a problem hiding this comment.
Good catch on both boundary conditions. I have resolved both:
- Depth boundary fallback: I removed the
if not root_results:fallback inbuild_scenario_hierarchythat was strippingunverified_set. In the disconnected-component pass, I computeunverified_descendants(all unverified candidates plus their downstream reachable subtrees) and exclude them from being emitted as roots (parent_id=None). Unverified nodes and their children are never promoted to apparent roots, andhierarchy_truncatedis unconditionally set toTrue. - 2,500-parent budget boundary: I reordered the parent discovery loop in
scenario_run_service.pysolen(parent_deltas) >= parent_budgetis evaluated before checkinglen(new_parents) < page_limit. When clipped by budget, bothlayer_candidatesandnext_layer_candidatesare added tounverified_candidate_ids. I also added an invariant pass marking any node inall_results_mapunverified if it has no parent in the loaded graph and its parents were not completely checked in the DB. - Added regression tests:
test_11_parent_chain_boundary_guards_depth_cutoff_mssql_and_sqlite(verifying no intermediate parent in an 11-parent chain is emitted as a root withmax_nodes=1) andtest_parent_budget_boundary_guards_unverified_parents(verifying none of the 2,500 boundary parents are emitted as roots).
There was a problem hiding this comment.
Rechecked dfb3ce3. The depth fallback is fixed: the 10-, 11-, and 12-parent cases no longer promote unverified nodes. There is still a false-root case caused by the new Python-only exclusion fallback in memory_interface.py:7175-7178,7232-7243.
The SQL row limit is applied before Python drops already-known results. That can make a clipped parent search look complete. Here is a 1,002-result SQLite example:
- Save 500 leaves.
- Save 501 parents that all reference leaf 1 and, between them, cover leaves 1 through 499.
- Save one final parent referencing leaf 0.
The first parent page returns 500 parents. On the next read, SQL exclusions are disabled, so its 501-row limit returns the same 500 known parents plus one new parent. Python discards the known rows and returns just one result. The service treats 1 < 500 as a complete search and never reaches the final parent. The cold one-node hierarchy then emits leaf 0 as a root with no parent, while the warm hierarchy emits an actual parent root. The adjacent 1,000- and 1,001-result controls pass; adding that one extra parent exposes the problem.
Please keep exclusions in the database with a parameter-safe representation, or page the scanned rows and explicitly track incomplete parent coverage. A short list after filtering a limited SQL page is not proof that all parents were found. Leaving this thread open.
Auto-replied by the GitHub Copilot app
There was a problem hiding this comment.
I have addressed this by replacing single-query chunk scanning with keyset-cursor pagination and returning database-level exhaustiveness (has_more) directly to the caller, removing the heuristic that treated a short filtered list as proof of completion.
-
Keyset cursor scanning across chunk pages:
In_execute_get_scenario_parent_deltas_for_child_ids, when SQL exclusions are omitted (or partial), the query within each child chunk now loops using an ascending keyset cursor (AttackResultKeysetCursor(timestamp=..., id=...)) withscan_page_size = min(500, _MAX_BIND_VARS). If SQL returns a full page (len(rows) > scan_page_size) but Python filtering leaveslen(unique_deltas) < limit, the chunk loop continues scanning past known rows until eitherlimitis met or SQL returns< scan_page_size(proving that the database matches for that chunk are truly exhausted). -
Explicit database completeness contract:
Updatedget_scenario_parent_deltas_for_child_ids_asyncto returntuple[list[ScenarioAttackResultDelta], bool]((new_parents, has_more)), matching the bounded pagination contract ofget_scenario_attack_result_deltas_asyncandget_scenario_recount_summaries_async. -
Eliminated short-list termination heuristic:
Inscenario_run_service.py, replacedif len(new_parents) < page_limit: breakwithif not has_more: break. If the database indicateshas_morebut no new parents are found within the bounded scan,layer_clippedis explicitly set toTrue, ensuring unverified candidates are guarded and never falsely emitted as roots. -
1,002-result regression test:
Addedtest_1002_results_500_leaves_501_parents_leaf_0_not_emitted_as_root_parityintest_scenario_hierarchy_parity.pymatching your exact SQLite scenario (500 leaves, 501 parents referencing leaf 1 covering leaves 1..499, and 1 final parent referencing leaf 0). Verifies that the coldmax_nodes=1hierarchy does not emit leaf 0 as a root, correctly attaches leaf 0 under parent 501, and agrees with the warm hierarchy.
There was a problem hiding this comment.
Rechecked 1d14bd2. The keyset scan fixes the previous shared-parent example. There is still a false-root case when the result limit is reached partway through the final child-ID chunk's fetched rows (memory_interface.py:7292-7303). has_more_in_chunk checks for rows beyond the 500-row scan page, but not rows left unprocessed inside that page.
Here is a 1,001-result SQLite example. Save 500 leaves, then these 501 parents in timestamp order:
- 490 parents referencing leaves 0 through 489, one each.
- Two parents referencing leaf 490.
- Nine parents referencing leaves 491 through 499, one each.
SQLite splits the 500 child IDs into chunks of 490 and 10. The first chunk returns 490 parents. The final chunk fetches 11 parents, but the row loop accepts only 10 before reaching the result limit of 500. Both has_more conditions are false, so the last parent is silently omitted and the service marks the search complete. The cold max_nodes=1 hierarchy emits leaf 499 as a root with no parent, while the warm hierarchy emits an actual parent root. The adjacent 500-parent control passes; 502 and 510 parents also reproduce the problem.
Please include unprocessed rows from the current SQL page when deciding has_more. A final SQL page shorter than 500 rows is not proof that the result loop consumed that page. Leaving this thread open.
Auto-replied by the GitHub Copilot app
There was a problem hiding this comment.
I have cleanly merged `upstream/main` and resolved all conflicts across `scenario_progress_read_model.py`, `scenario_run_service.py`, `memory_interface.py`, and `test_azure_sql_memory.py`.
Beyond resolving text conflicts, I ensured full compatibility with recent changes from `main`:
1. **Sequential attack completions (#3118):** Aligned `_build_scenario_history_ranked_units` and group/run metric queries so that `completed` status is preserved and unit outcome statistics match `completed_units` identically.
2. **Post-run live delta polling (#3183):** Maintained keyset cursor polling for updated attempts without short-circuiting on terminal runs, while keeping cold hierarchy requests bounded.
3. **Diagnostic retry parity:** Ensured retry diagnostics capture attempts where `total_retries > 0`.
All 2,890 backend tests and all 72 hierarchy parity tests are passing locally with zero lint or formatting warnings.|
I have addressed all feedback from your latest review with a focus on strict zero-inference, exact cold/warm cache parity, and eliminating arbitrary query limits. Here is a summary of the architectural updates in commit
|
|
The two remaining open review threads are: Both have concrete examples of the remaining issues in the linked follow-ups. |
…, and preserve exact run statistics (microsoft#3167)
…-identity recount (microsoft#3167)
Roman Lutz (romanlutz)
left a comment
There was a problem hiding this comment.
Summary
Reviewed a1b11089. The earlier examples are fixed, but the bounded-read and parent-context guarantees still have blocking gaps.
Findings
🔴 Full-run hydration is back
Location: pyrit/backend/services/scenario_run_service.py:2546-2549
Removing the recount budget restores exact legacy counts by loading every attempt. A cold one-node request over 2,001 saved legacy attempts hydrates 2,501 deltas across six result-page reads. Please preserve exact shared statistics without requiring full-run attempt-detail hydration. Details in the existing thread.
🔴 Root guards fail at their limits
Location: pyrit/backend/services/scenario_progress_read_model.py:909-914; pyrit/backend/services/scenario_run_service.py:2525-2538
An 11-parent chain emits parent 10 as a root even though it is explicitly marked unverified. At the 2,500-parent budget, newly discovered parents also remain eligible as roots without checking their incoming links. Please preserve unknown parent context through the fallback and budget-exhaustion paths rather than presenting these nodes as confirmed roots. Reproductions in the existing thread.
🔴 SQL Server's parameter limit is exceeded
Location: pyrit/memory/memory_interface.py:7062-7067
All exclusion chunks share one statement, but only 200 exclusion parameters are reserved. The actual Azure SQL query compiles to 2,201 parameters for 1,100 exclusions and 1,100 candidates, above SQL Server's 2,100 limit. Later parent paging with the default node limit can produce 2,501 parameters. Please budget all statement parameters, including the complete exclusion list. The inline comment includes the details.
Verdict
REQUEST CHANGES. Both existing threads remain open; the SQL Server finding is included as an inline comment in this review.
…exclusion SQL params (microsoft#3167) - Add ScenarioAttemptSeedSummary dataclass and memory methods to recount legacy runs with lightweight scalar projections instead of hydrating full ScenarioAttackResultDelta objects with outer joins and JSON parsing. - Remove root promotion fallback for unverified candidates and exclude unverified candidates and their descendants from being emitted as roots during depth-10 and 2,500-parent budget cutoffs. - Dynamically budget bind variables in _execute_get_scenario_parent_deltas_for_child_ids to ensure statement parameters strictly stay within _MAX_BIND_VARS, preventing SQL Server 2,100 parameter overflow when large exclusion lists are present. - Add regression tests verifying depth-10 chain truncation, 2,500-parent boundary guards, lightweight recount without delta hydration, and MSSQL parameter compilation limits with 1,100+ and 2,500+ exclusions.
|
I have pushed commit
|
Roman Lutz (romanlutz)
left a comment
There was a problem hiding this comment.
Summary
Reviewed dfb3ce3. The full-detail hydration and SQL Server parameter-limit findings are addressed and their threads are resolved. Two correctness issues remain.
Findings
🔴 Parent discovery still invents roots
Location: pyrit/memory/memory_interface.py:7175-7178,7232-7243
The Python-only exclusion fallback filters known parents after the SQL row limit. A short filtered page is then treated as complete. In a 1,002-result example, this hides a saved parent and makes the cold hierarchy emit its leaf as a root, while the warm hierarchy emits an actual parent root. Please filter before limiting or explicitly track incomplete parent coverage. Reproduction in the existing thread.
🔴 Retry-event-only attempts disappear from diagnostic totals
Location: pyrit/backend/services/scenario_run_service.py:1771-1774
The lightweight recount ignores saved retry events when total_retries=0. A cold hierarchy reports 0 retrying attacks, while SQL and the warm hierarchy report 1. Please preserve the SQL count or carry a retry-presence flag. The inline comment includes the reproduction and adjacent controls.
Verdict
REQUEST CHANGES. The parent-discovery thread remains open, and the diagnostic finding is included as an inline comment in this review.
…erve retry-event diagnostics (microsoft#3167)
|
I have pushed commit 1. Parent Discovery Keyset Pagination & True Completion
2. Retry-Event Diagnostics Preservation
|
|
One issue is still open: parent discovery can report a child as a root. With 500 leaves and 501 parents, the final query chunk stops at the result limit but returns Could you also merge the latest |
PR Update & Upstream Sync
|
Description
This PR implements bounded Scenario result hierarchy projections as requested in #3145. It provides a typed, bounded read-only projection resolving parent/child orchestration relationships, ordered direct child references, and generic conversation projections without requiring full-run result or message hydration.
Key Changes:
pyrit/models/scenario_hierarchy.py):ScenarioHierarchyProjection,ScenarioHierarchyNode,ScenarioOrchestrationParentSummary,ScenarioHierarchyGroup,ScenarioHierarchyDiagnostics, and generic read-onlyScenarioConversationProjection.child_index(0-indexed position), producerattempt_index(1-indexed), and explicit fallback toAttackResultRole.UNKNOWN.pyrit/memory/memory_interface.py):get_scenario_attack_result_deltas_by_ids_asyncexecuting a single batched query matchingid IN (...)andattribution_parent_id == scenario_uuidto ensure cross-run isolation.get_attack_result_conversation_projections_asyncto fetch conversation stats and bounded messages without hydrating unrelated run data.pyrit/backend/services/scenario_progress_read_model.py):get_run_hierarchy_node_asyncfetch only the targeted node and immediate children.active_pathand recursion depth guard (MAX_HIERARCHY_DEPTH = 10).unresolved_child_attack_result_idswithout failing the run.failed_attacks,attack_retries) are capped at 100 while preserving exact total counters.ag.technique_details.producer_countsandOutcomeStatisticsfrom FEAT: Add role-aware Scenario target-attempt accounting (#3043) #3062 and FEAT: Attack analytics SDK #3059 unchanged.pyrit/backend/routes/scenarios.py):GET /api/scenarios/runs/{scenario_result_id}/hierarchy.GET /api/scenarios/runs/{scenario_result_id}/hierarchy/nodes/{attack_result_id}.Closes #3145
Testing
tests/unit/models/test_scenario_hierarchy_models.py.tests/unit/memory/test_azure_sql_memory.py.tests/unit/backend/test_scenario_hierarchy_parity.pycovering ordinary results, nested sequential attacks, cyclic reference guards, retained FIX: preserve child-result links when execution fails in SequentialAttack #3048 failure links, missing child references, diagnostic boundary truncation, bounded single-node reads, and REST routes.make unit-test: all 26,162 tests passed.ruff checkandruff format: all checks passed cleanly.