Repository navigation
FIX Share scenario success statistics across SDK, backend and reports - #2820
Utkarsh Bahuguna (u7k4rs6) wants to merge 30 commits into
Conversation
Execution-unit identity, latest-attempt selection, counts, denominators and rounding now live in pyrit.analytics.scenario_statistics, with the shared result types in pyrit.models. The backend read model, the pretty/JSON/HTML reports and SDK callers all use it. ScenarioResult.objective_achieved_rate is deprecated because pyrit.models cannot import pyrit.analytics. Adds parity tests that check SDK, run detail, run history, live progress and the JSON report agree on the same saved history, and fixes a test that leaked the SPA mount onto the shared app.
- Reports fold per-atomic-attack counts with display_group_map, the same keys they group results by, via combine_execution_counts. - Reports show effective units next to raw attempts (num_results vs num_attempts, total_results vs total_attempts). - compute_scenario_statistics takes use_saved_plan instead of a sentinel; drop the unused ScenarioPlanLookup.planned_units. - Parity test compares group key sets and unit counts directly. - Framework doc calls out the SQL history aggregate as a second implementation kept in parity by the test.
…enario-rate-latest-attempt
…nchmark results - The history aggregate's fallback unit identity (no saved plan, or attempts outside the plan-resolution set) now includes the technique eval hash, like pyrit.analytics.scenario_statistics. - AdversarialBenchmark adds its cache-served atomic attacks to the returned result's in-memory run plan, so their merged results count in the reports. Scenario._build_run_plan_from builds plan entries for any atomic attacks. - Parity case for two technique configurations sharing a name without a plan, and a report regression test for a partially cached benchmark.
|
Heads up on an overlap with #2551 (hannahwestra25). That PR reworks benchmark caching: it drops cached objectives from each atomic attack before the plan is built, then copies the cached results into the new run. Those copies aren't in the saved plan, so the plan-based counting here (and the backend progress view on main today) would treat them as unattributed and leave them out of the stats. The cached-plan hook I added in |
…ched results, rename report counts to units
…s by identifier seeds in SQL too
…/u7k4rs6/PyRIT into fix/scenario-rate-latest-attempt
…est-attempt # Conflicts: # pyrit/analytics/__init__.py # pyrit/backend/services/scenario_progress_read_model.py # pyrit/memory/memory_interface.py # pyrit/scenario/scenarios/benchmark/adversarial.py
…est-attempt # Conflicts: # pyproject.toml
…est-attempt # Conflicts: # tests/unit/output/test_derivation.py
Re-executed the nine documentation notebooks whose stored outputs still showed the pre-change report labels so they match the new scenario statistics format: - Total Attack Results -> Total Units + Total Attempts - Number of Results -> Units + Attempts Re-execution was required rather than an inline text edit because units and attempts genuinely diverge when retries occur, so the new Units values are not derivable from the previously stored attempt counts. Only .ipynb outputs change; the paired .py sources are untouched and remain in sync. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…est-attempt # Conflicts: # doc/code/scenarios/1_common_scenario_parameters.ipynb # doc/code/scenarios/3_adaptive_scenarios.ipynb # doc/scanner/1_pyrit_scan.ipynb # doc/scanner/foundry.ipynb # pyrit/backend/services/scenario_progress_read_model.py
The notebooks regenerated for the Units/Attempts report format captured stderr from the regeneration environment rather than PyRIT behavior: - TqdmWarning: IProgress not found (ipywidgets missing locally) - UserWarning: PyRIT contains local edits, including an absolute C:\Python314 path - An unauthenticated HF Hub rate-limit warning - A transient OpenAI 500 retry log that embedded partial adversarial model output None of these appear in the notebooks on main. Genuine PyRIT output such as the _EXCLUDED_TECHNIQUES catalog warning and the plaintext .env advisory is unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
| fallback_seed_id = func.coalesce( | ||
| attributed_seed_group_id, | ||
| literal("seeds:", Unicode).concat(type_coerce(identifier_seed_key, Unicode)), |
There was a problem hiding this comment.
🔴 Must Fix: Give identifier-derived and attributed seeds the same SQL identity
These two branches produce different keys for the same logical seed group: seed_group_id is its canonical hash, while the identifier fallback is "seeds:" + joined_seed_hashes. The SDK resolves both to AtomicAttackIdentifier.logical_seed_group_id.
I reproduced this in SQLite with no saved plan and two attempts for the same attack, configuration, objective and prompt context:
# Older ERROR: atomic identifier contains the seeds, no seed_group_id.
# Later SUCCESS: same identifier, seed_group_id = seed_group.logical_id.The SDK, run detail and live progress report 1 completed unit, 100%, 1 retry. The history list reports 2 completed units, 50%, 0 retries. This is one recovered execution, not two separate units.
Can we normalize both SQL branches to the same logical seed identity in both backends, and add a mixed identifier-only / attributed history to the parity suite? The current legacy-seed fixture gives every row identifier-only attribution, so it cannot catch this mismatch.
There was a problem hiding this comment.
Fixed in 11b3fd2. Both backends now key an attempt by the identifier's seed key whenever it has one, and rows that only have the attributed seed_group_id borrow that key from a row in the same run that carries both, since those two spellings name the same logical seed group. SQL can't hash the seeds itself, so that pairing is the bridge. Added identifier_and_attributed_rows_of_one_seed_group to the parity histories: an identifier-only ERROR, an attributed-only ERROR and a row with both. Everything reports 1 unit, 100%, 2 retries; before the fix the history list said 50%. One case it can't bridge: a seed group whose only rows are identifier-only and attributed-only, with none carrying both. That needs an old run resumed by new code where every new attempt failed before enrichment, so I left it.
There was a problem hiding this comment.
This is partially fixed, but the remaining no-bridge case still needs to be addressed before resolving this thread.
I reproduced it again on e394bff with no saved plan and two attempts for the same logical seed group:
# Earlier ERROR: identifier seeds only, no seed_group_id.
# Later SUCCESS: seed_group_id only, no identifier seeds.
# No third row carrying both.The SDK and run detail report 1 objective execution, 100%, 1 retry. History reports 2 objective executions, 50%, 0 retries.
The new test's third row supplies the bridge, so it does not cover this case. Please add this two-row history to the parity suite and make the counts agree without requiring another row carrying both identity forms. The history is already sufficient for the SDK to identify one recovered execution.
Auto-replied by the GitHub Copilot app
There was a problem hiding this comment.
Fixed in 8f0382c. SQL can't derive the logical seed group from stored seeds, so instead of approximating it, the history aggregate now flags any run with an attempt that has identifier seeds but no explicit attribution, and list_runs recounts those runs with compute_scenario_statistics, the same path run detail uses. Runs without such rows stay on the SQL aggregate. Your two-row history is identifier_only_then_attributed_only in the parity suite (1 execution, 100%, 1 retry everywhere), plus a direct test for the flag. Only legacy runs from before seed attribution take the slower path.
| func.coalesce( | ||
| raw_attempts.c.identifier_seed_key, | ||
| bridged.c.identifier_seed_key, | ||
| raw_attempts.c.attributed_seed_group_id, |
There was a problem hiding this comment.
🔴 Must Fix: Keep explicit seed attribution authoritative
This now prefers the identifier's seeds over seed_group_id, but resolve_execution_unit() does the opposite. Those values are not always interchangeable: the benchmark cache copier deliberately assigns the current seed group while retaining the original result's identifier.
I persisted two rows without a saved plan, with the same attack/configuration/objective and the same stored seed identifier, but different explicitly attributed logical seed groups:
# FAILURE: identifier seeds = old context, seed_group_id = current group A
# SUCCESS: identifier seeds = old context, seed_group_id = current group BThe SDK and run detail correctly report 2 objective executions, 50%, no retries. History merges them into 1 execution, 100%, 1 retry. The reverse case also fails: one attributed seed group with two different stored identifiers is split into two SQL executions instead of one.
Can we keep explicit attribution authoritative and normalize identifier-only rows onto that canonical identity, rather than replacing attributed identities with identifier keys? Please cover both cases in the parity suite. Simply putting attribution first again would reintroduce the original mixed-attribution bug, so this needs a consistent normalization rule.
There was a problem hiding this comment.
Fixed in 8f0382c. The SQL now keys attempts by explicit seed attribution first, then the objective hash, same order as resolve_execution_unit, and the identifier bridge is gone. Both of your cases are in the parity suite: explicit_attribution_wins_over_identifier_seeds (same identifier, different attribution, 2 executions at 50%) and one_attributed_seed_group_with_two_identifiers (same attribution, different identifiers, 1 execution at 100%, 1 retry). Both fail on e394bff.
…-only runs with the SDK
Fixes #2819.
Reworked per the discussion on #2819. The scenario success calculation now lives in one place,
pyrit.analytics.scenario_statistics, and everything else just presents its results.What moved there: the plan lookup, execution-unit identity (atomic attack name plus technique configuration, plus seed group), latest-attempt selection, counts, denominators and rounding. The shared result types are in
pyrit.models:ScenarioExecutionUnitandScenarioExecutionStatistics, which reuses the existingScenarioProgressCounts. Historical attempts, errors and retries are reported separately from the effective-unit counts.Who uses it now:
ScenarioPlanLookup,ResultUnitIdentityandtotal_retry_pressuredelegate to it and stay importable from the old module.scenario_overview. Group rates fold the per-atomic-attack counts usingdisplay_group_map, the same keys the reports group by. Reports now show objective executions, one objective run with one attack configuration (num_objective_executions,total_objective_executions), next to raw attempts (num_attempts,total_attempts).group_success_rateis gone.pyrit.analytics.compute_scenario_statistics(result).One API change:
ScenarioResult.objective_achieved_rateis deprecated (removed in 1.4.0).test_import_boundarydoesn't allowpyrit.modelsto importpyrit.analytics, even lazily, so the method can't call the shared code without adding a new tracked violation. It keeps its old behaviour until removal, and nothing in PyRIT calls it anymore. I added afilterwarningsentry like the one forScorer.score_asyncso the suite can't drift back onto it. If you'd rather have a tracked lazy violation instead, that's an easy switch.Breaking change for
pyrit scan --report json(worth a release note):stats.total_resultsis nowstats.total_objective_executionsplusstats.total_attempts, and each group'snum_resultsis nownum_objective_executionsplusnum_attempts. The denominator really changed, so keeping the old names with a new meaning would be worse. Anything reading the old keys gets a KeyError after upgrading. If the JSON report counts as a supported contract, I can also emittotal_results/num_resultswith their old meaning (attempt counts) until 1.4.0, same asobjective_achieved_rate.This also unifies the two identity resolvers the backend had. One fell back to the bare atomic attack name and the other to a hash of the name plus eval hash. Both now use the latter, so technique configurations that share a name are separate units everywhere.
Parity:
tests/unit/analytics/test_scenario_statistics_parity.pysaves a history in SQLite, then checks that the SDK, run detail, run history list (the SQL aggregate), live progress and the JSON report give the same overall numbers. For histories with a saved plan it checks the per-group numbers too. It covers retry/resume, unrecovered errors, legacy rows with no plan or seed attribution, an old error row matched to its unit by the saved plan, two technique configs sharing a name, legacy seed groups that share an objective (told apart by the atomic identifier's seeds in both the SDK and SQL), display groups, and an empty history. A strict xfail pins one known gap that predates this PR: the history list rejects a plan with two seed groups sharing an objective and falls back to legacy totals, while run detail keeps using the plan. For reference, running the same histories through main, the SDK/report disagree with the backend on 5 of 7 (e.g. 50 vs 100 after retry/resume, 33 vs 50 with unrecovered errors).A few existing tests built several rows with the same objective in one atomic attack and expected each row to count separately. Under the shared policy those are one unit, as they already were in the backend, so I gave them distinct objectives.
Unrelated, but it made CI flaky here:
test_frontend_exists_mounts_staticmounted the SPA on the sharedappand never removed it. That turnedGET /runs/{id}/resumeinto a 404 for any test that ran after it in the same worker (reproducible on main by running the two tests in that order). It now restores the routes.Cached benchmark results: since #2551 the benchmark drops cached objectives before building the plan and copies the cached results into the new run. The plan now still lists those objectives, with one seed group per cached result, and each copy is attributed to that seed group when it's saved, so cached results count as planned units.
Tests:
tests/unit/{analytics,backend,output,scenario,memory,models}: 6190 passed, 1 xfailed.