diff --git a/doc/code/framework.md b/doc/code/framework.md index 621e037a80..c05bfd2a6d 100644 --- a/doc/code/framework.md +++ b/doc/code/framework.md @@ -320,6 +320,7 @@ The below talks about responsibilities of most modules in the PyRIT library - This is where cross-run analysis belongs: e.g. "which attack performed best for this objective?", "how often did a technique succeed?", or "which responses match known content?". - **Does not own**: live, in-attack decisions — any decision made *during* an attack is a scorer's job. Analytics only operates on stored results, after the fact. - Today it includes `ConversationAnalytics` (inspecting conversation history), `analyze_results` / `AttackStats` (aggregating outcomes across techniques), and text-matching strategies (`ExactTextMatching`, `ApproximateTextMatching`). +- `compute_scenario_statistics` calculates scenario success statistics. It owns execution-unit identity (atomic attack, technique configuration, and seed group), latest-attempt selection, counts, denominators, and rounding. SDK callers, the GUI backend's run detail and progress views, and the console, JSON, and HTML reports all present its results (`ScenarioExecutionStatistics`, `ScenarioExecutionUnit`, and `ScenarioProgressCounts` in `pyrit.models`) instead of calculating their own. The one exception is the GUI run-history list, which aggregates the same statistics in SQL (`MemoryInterface._build_scenario_history_aggregate_statement`) so it can page over many runs; `tests/unit/analytics/test_scenario_statistics_parity.py` keeps the two implementations in agreement. - Shared analytics contracts (filters, dimensions, typed values, reports, facets, result pages, and `AttackStats`) live in `pyrit.models.analytics`. They validate data without querying memory or calculating statistics. `AttackResultSelection` defines selection modes without changing existing callers. - Filter-bound cursor and label-normalization helpers live in `pyrit.common.pagination`. The backend pagination module retains compatibility exports, including History's invalid-cursor first-page fallback. diff --git a/pyproject.toml b/pyproject.toml index df88176d1d..56c224911d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -170,6 +170,8 @@ filterwarnings = [ # Keep the in-repo suite on the scorable/expectation contract. Tests that cover the # shim itself opt back in with pytest.warns. "error:Scorer\\.score_async:DeprecationWarning", + # Rates come from pyrit.analytics; tests that cover the deprecated method opt back in with pytest.warns. + "error:ScenarioResult\\.objective_achieved_rate:DeprecationWarning", ] [tool.ty] diff --git a/pyrit/analytics/__init__.py b/pyrit/analytics/__init__.py index bbbd1def2b..d23e841073 100644 --- a/pyrit/analytics/__init__.py +++ b/pyrit/analytics/__init__.py @@ -11,12 +11,14 @@ if TYPE_CHECKING: from pyrit.analytics.conversation_analytics import ConversationAnalytics from pyrit.analytics.result_analysis import AttackStats, analyze_results, get_cached_results_for_technique + from pyrit.analytics.scenario_statistics import compute_scenario_statistics from pyrit.analytics.text_matching import ApproximateTextMatching, ExactTextMatching, TextMatching _LAZY_EXPORTS: dict[str, str | tuple[str, str | None]] = { "analyze_results": "pyrit.analytics.result_analysis", "ApproximateTextMatching": "pyrit.analytics.text_matching", "AttackStats": "pyrit.analytics.result_analysis", + "compute_scenario_statistics": "pyrit.analytics.scenario_statistics", "ConversationAnalytics": "pyrit.analytics.conversation_analytics", "ExactTextMatching": "pyrit.analytics.text_matching", "get_cached_results_for_technique": "pyrit.analytics.result_analysis", diff --git a/pyrit/analytics/scenario_statistics.py b/pyrit/analytics/scenario_statistics.py new file mode 100644 index 0000000000..1abf511411 --- /dev/null +++ b/pyrit/analytics/scenario_statistics.py @@ -0,0 +1,419 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Effective execution-unit statistics for scenario runs. + +This is the single implementation of scenario success statistics. The SDK (``ScenarioResult``), the +GUI backend, and the console, JSON, and HTML reports all derive their numbers from it, so they cannot +drift apart. It owns: + +- execution-unit identity: an atomic group (atomic attack name plus technique configuration) and a + logical seed group, resolved against the saved run plan when one exists; +- attempt selection: each unit counts once, by its latest attempt (timestamp, then attempt ID); +- counts, denominators, and rounding: the success percentage is succeeded units over completed units, + truncated to an integer. + +Historical attempt, error, and retry counts are reported separately from the effective-unit counts. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Protocol + +from pydantic import ValidationError + +from pyrit.common.utils import to_sha256 +from pyrit.models import ( + SCENARIO_RUN_PLAN_METADATA_KEY, + AtomicAttackIdentifier, + AttackOutcome, + AttackResult, + ComponentIdentifier, + ScenarioExecutionStatistics, + ScenarioExecutionUnit, + ScenarioProgressCounts, + ScenarioRunPlan, + ScenarioRunPlanAtomicGroup, + config_hash, +) + +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping, Sequence + + from pyrit.models import ScenarioResult + +logger = logging.getLogger(__name__) + + +class _CountableAttempt(Protocol): + """An attempt that can be counted: both ``ScenarioAttempt`` and ``ScenarioProgressResult`` qualify.""" + + @property + def outcome(self) -> AttackOutcome: ... + + @property + def total_retries(self) -> int: ... + + +@dataclass(frozen=True, slots=True) +class ScenarioPlanLookup: + """Pre-indexed run-plan data used while resolving persisted attempts to execution units.""" + + groups_by_identity: dict[tuple[str, str], ScenarioRunPlanAtomicGroup] + groups_by_name: dict[str, tuple[ScenarioRunPlanAtomicGroup, ...]] + seed_ids_by_group_and_objective: dict[tuple[str, str], tuple[str, ...]] + + @classmethod + def from_plan(cls, *, plan: ScenarioRunPlan | None) -> ScenarioPlanLookup: + """ + Build constant-time lookup tables for one run plan. + + Returns: + ScenarioPlanLookup: Indexed plan data. + """ + if plan is None: + return cls( + groups_by_identity={}, + groups_by_name={}, + seed_ids_by_group_and_objective={}, + ) + + groups_by_identity: dict[tuple[str, str], ScenarioRunPlanAtomicGroup] = {} + grouped_by_name: dict[str, list[ScenarioRunPlanAtomicGroup]] = {} + seeds_by_id = {seed.id: seed for seed in plan.seed_groups} + seed_ids_by_group_and_objective: dict[tuple[str, str], tuple[str, ...]] = {} + for group in plan.atomic_groups: + groups_by_identity[(group.atomic_attack_name, group.technique_eval_hash)] = group + grouped_by_name.setdefault(group.atomic_attack_name, []).append(group) + seed_ids_by_objective: dict[str, list[str]] = {} + for seed_id in group.seed_group_ids: + seed = seeds_by_id[seed_id] + seed_ids_by_objective.setdefault(seed.objective_sha256, []).append(seed_id) + seed_ids_by_group_and_objective.update( + { + (group.id, objective_sha256): tuple(seed_ids) + for objective_sha256, seed_ids in seed_ids_by_objective.items() + } + ) + + return cls( + groups_by_identity=groups_by_identity, + groups_by_name={name: tuple(groups) for name, groups in grouped_by_name.items()}, + seed_ids_by_group_and_objective=seed_ids_by_group_and_objective, + ) + + def resolve_group( + self, + *, + atomic_attack_name: str, + technique_eval_hash: str | None, + ) -> ScenarioRunPlanAtomicGroup | None: + """ + Resolve one planned group from persisted attribution. + + Returns: + ScenarioRunPlanAtomicGroup | None: The uniquely matching group. + """ + if technique_eval_hash is not None: + return self.groups_by_identity.get((atomic_attack_name, technique_eval_hash)) + matching_groups = self.groups_by_name.get(atomic_attack_name, ()) + return matching_groups[0] if len(matching_groups) == 1 else None + + +@dataclass(frozen=True, slots=True) +class ScenarioAttempt: + """One persisted attempt, resolved to the execution unit it belongs to.""" + + unit: ScenarioExecutionUnit + atomic_attack_name: str + outcome: AttackOutcome + timestamp: datetime + attempt_id: str + total_retries: int + + +def load_scenario_run_plan(scenario_result: ScenarioResult) -> ScenarioRunPlan | None: + """ + Load the run plan saved in a scenario result's metadata. + + Returns: + ScenarioRunPlan | None: The saved plan, or None for results persisted without one. + """ + raw_plan = (scenario_result.metadata or {}).get(SCENARIO_RUN_PLAN_METADATA_KEY) + if raw_plan is None: + return None + try: + return ScenarioRunPlan.model_validate(raw_plan) + except (ValidationError, ValueError): + logger.warning( + "Scenario result %s has an invalid saved run plan; counting it as a legacy run.", scenario_result.id + ) + return None + + +def resolve_execution_unit( + *, + atomic_attack_name: str, + technique_eval_hash: str | None, + attributed_seed_group_id: str | None, + atomic_attack_identifier: AtomicAttackIdentifier | None, + objective: str, + objective_sha256: str | None, + plan_lookup: ScenarioPlanLookup, +) -> ScenarioExecutionUnit: + """ + Resolve one persisted attempt to its execution unit. + + The atomic group is the planned group matching the atomic attack name and technique configuration, + or a hash of both for attempts the plan does not describe. The seed group comes from, in order: the + persisted attribution, the atomic identifier's logical seed group, a unique objective match in the + planned group, and finally a hash of the objective (legacy rows). + + Returns: + ScenarioExecutionUnit: The resolved execution unit. + """ + atomic_group_id = config_hash( + {"atomic_attack_name": atomic_attack_name, "technique_eval_hash": technique_eval_hash or ""} + ) + planned_group = plan_lookup.resolve_group( + atomic_attack_name=atomic_attack_name, + technique_eval_hash=technique_eval_hash, + ) + if planned_group is not None: + atomic_group_id = planned_group.id + + seed_group_id = attributed_seed_group_id or "" + if not seed_group_id and atomic_attack_identifier is not None and atomic_attack_identifier.seed_identifiers: + seed_group_id = atomic_attack_identifier.logical_seed_group_id + if not seed_group_id: + matching_seed_ids = plan_lookup.seed_ids_by_group_and_objective.get( + (atomic_group_id, objective_sha256 or to_sha256(objective)), + (), + ) + if len(matching_seed_ids) == 1: + seed_group_id = matching_seed_ids[0] + if not seed_group_id: + seed_group_id = config_hash({"objective": objective}) + return ScenarioExecutionUnit(atomic_group_id=atomic_group_id, seed_group_id=seed_group_id) + + +def resolve_attack_result_attempt( + *, + atomic_attack_name: str, + attack_result: AttackResult, + plan_lookup: ScenarioPlanLookup, +) -> ScenarioAttempt: + """ + Resolve one hydrated attack result to a scenario attempt. + + Returns: + ScenarioAttempt: The attempt and its execution unit. + """ + attribution_data = attack_result.attribution_data if isinstance(attack_result.attribution_data, dict) else {} + eval_hash = attribution_data.get("parent_eval_hash") + attributed_seed_group_id = attribution_data.get("seed_group_id") + atomic_identifier = attack_result.atomic_attack_identifier + typed_identifier = ( + AtomicAttackIdentifier.from_component_identifier(atomic_identifier) + if isinstance(atomic_identifier, ComponentIdentifier) + else None + ) + # Hydrated rows (and test doubles) may carry a non-integer retry count; treat it as zero. + retries = getattr(attack_result, "total_retries", 0) + return ScenarioAttempt( + unit=resolve_execution_unit( + atomic_attack_name=atomic_attack_name, + technique_eval_hash=str(eval_hash) if eval_hash is not None else None, + attributed_seed_group_id=str(attributed_seed_group_id) if attributed_seed_group_id else None, + atomic_attack_identifier=typed_identifier, + objective=str(attack_result.objective), + objective_sha256=None, + plan_lookup=plan_lookup, + ), + atomic_attack_name=atomic_attack_name, + outcome=attack_result.outcome, + timestamp=_timestamp_order_key(attack_result.timestamp), + attempt_id=str(attack_result.attack_result_id), + total_retries=retries if isinstance(retries, int) else 0, + ) + + +def retry_pressure(*, attempts_per_unit: Iterable[int], persisted_retries: Iterable[int]) -> int: + """ + Combine per-attempt retries with re-attempts of the same execution unit. + + Returns: + int: Total retry pressure. + """ + within_attempts = sum(max(0, retries) for retries in persisted_retries) + repeated_units = sum(max(0, count - 1) for count in attempts_per_unit) + return within_attempts + repeated_units + + +def success_percentage(*, succeeded: int, completed: int) -> int | None: + """ + Return the success percentage for effective execution units. + + Returns: + int | None: ``succeeded / completed`` as a truncated integer percentage, or None with no + completed units. + """ + return int((succeeded / completed) * 100) if completed else None + + +def count_execution_units( + *, + units: Iterable[ScenarioExecutionUnit], + attempts_by_unit: Mapping[ScenarioExecutionUnit, Sequence[_CountableAttempt]], + planned: int | None, +) -> ScenarioProgressCounts: + """ + Count effective execution units from chronologically ordered attempts. + + Each item in ``attempts_by_unit`` must expose ``outcome`` and ``total_retries`` and be ordered + oldest first; the last attempt decides the unit's outcome. + + Returns: + ScenarioProgressCounts: Completed and succeeded units plus historical errors and retries. + """ + completed = 0 + succeeded = 0 + errors = 0 + retries = 0 + for unit in units: + attempts = attempts_by_unit.get(unit, ()) + if not attempts: + continue + completed += 1 + succeeded += int(attempts[-1].outcome == AttackOutcome.SUCCESS) + errors += sum(int(attempt.outcome == AttackOutcome.ERROR) for attempt in attempts) + retries += retry_pressure( + attempts_per_unit=[len(attempts)], + persisted_retries=[attempt.total_retries for attempt in attempts], + ) + return ScenarioProgressCounts( + completed=completed, + planned=planned, + succeeded=succeeded, + success_percentage=success_percentage(succeeded=succeeded, completed=completed), + errors=errors, + retries=retries, + ) + + +def combine_execution_counts(counts: Iterable[ScenarioProgressCounts]) -> ScenarioProgressCounts: + """ + Combine counts for disjoint sets of execution units, such as the atomic attacks of one display group. + + Returns: + ScenarioProgressCounts: The summed counts with the success percentage recomputed. ``planned`` is + None unless every input has one. + """ + counts = list(counts) + completed = sum(item.completed for item in counts) + succeeded = sum(item.succeeded for item in counts) + planned = [item.planned for item in counts] + return ScenarioProgressCounts( + completed=completed, + planned=sum(value for value in planned if value is not None) if all(v is not None for v in planned) else None, + succeeded=succeeded, + success_percentage=success_percentage(succeeded=succeeded, completed=completed), + errors=sum(item.errors for item in counts), + retries=sum(item.retries for item in counts), + ) + + +def compute_scenario_statistics( + scenario_result: ScenarioResult, + *, + plan: ScenarioRunPlan | None = None, + use_saved_plan: bool = True, +) -> ScenarioExecutionStatistics: + """ + Calculate effective execution-unit statistics for a scenario result. + + With a run plan, counts cover the planned units, and attempts that match no planned unit are + reported as unattributed. Without one (legacy results), every resolved unit counts and display + groups come from ``display_group_map``. + + Args: + scenario_result (ScenarioResult): The scenario result with its hydrated attack results. + plan (ScenarioRunPlan | None): An explicit run plan. Defaults to None. + use_saved_plan (bool): When ``plan`` is None, whether to use the plan saved in the result's + metadata. Pass False to count the result as a legacy run. Defaults to True. + + Returns: + ScenarioExecutionStatistics: Overall, per atomic attack, and per display group counts. + """ + if plan is None and use_saved_plan: + plan = load_scenario_run_plan(scenario_result) + plan_lookup = ScenarioPlanLookup.from_plan(plan=plan) + + attempts = [ + resolve_attack_result_attempt( + atomic_attack_name=atomic_attack_name, + attack_result=attack_result, + plan_lookup=plan_lookup, + ) + for atomic_attack_name, results in scenario_result.attack_results.items() + for attack_result in results + ] + attempts.sort(key=lambda attempt: (attempt.timestamp, attempt.attempt_id)) + attempts_by_unit: dict[ScenarioExecutionUnit, list[ScenarioAttempt]] = {} + for attempt in attempts: + attempts_by_unit.setdefault(attempt.unit, []).append(attempt) + + units_by_name: dict[str, list[ScenarioExecutionUnit]] = {} + units_by_display_group: dict[str, list[ScenarioExecutionUnit]] = {} + if plan is not None: + for group in plan.atomic_groups: + group_units = [ + ScenarioExecutionUnit(atomic_group_id=group.id, seed_group_id=seed_group_id) + for seed_group_id in group.seed_group_ids + ] + units_by_name.setdefault(group.atomic_attack_name, []).extend(group_units) + units_by_display_group.setdefault(group.display_group, []).extend(group_units) + counted_units = list(dict.fromkeys(unit for units in units_by_name.values() for unit in units)) + planned: int | None = len(counted_units) + else: + for unit, unit_attempts in attempts_by_unit.items(): + name = unit_attempts[0].atomic_attack_name + units_by_name.setdefault(name, []).append(unit) + display_group = scenario_result.display_group_map.get(name, name) + units_by_display_group.setdefault(display_group, []).append(unit) + counted_units = list(attempts_by_unit) + planned = None + + counted = set(counted_units) + unattributed_attempts = sum( + len(unit_attempts) for unit, unit_attempts in attempts_by_unit.items() if unit not in counted + ) + + def _count(units: Sequence[ScenarioExecutionUnit]) -> ScenarioProgressCounts: + return count_execution_units( + units=units, + attempts_by_unit=attempts_by_unit, + planned=len(units) if planned is not None else None, + ) + + return ScenarioExecutionStatistics( + overall=_count(counted_units), + atomic_attacks={name: _count(units) for name, units in units_by_name.items()}, + display_groups={name: _count(units) for name, units in units_by_display_group.items()}, + attempts=len(attempts), + unattributed_attempts=unattributed_attempts, + ) + + +def _timestamp_order_key(timestamp: object) -> datetime: + """ + Normalize potentially malformed timestamps from mutable result objects. + + Returns: + datetime: The timestamp or a stable earliest-time fallback. + """ + return timestamp if isinstance(timestamp, datetime) else datetime.min.replace(tzinfo=UTC) diff --git a/pyrit/backend/services/scenario_progress_read_model.py b/pyrit/backend/services/scenario_progress_read_model.py index 5eb879aa89..ae6d11e8c4 100644 --- a/pyrit/backend/services/scenario_progress_read_model.py +++ b/pyrit/backend/services/scenario_progress_read_model.py @@ -7,16 +7,21 @@ from collections import OrderedDict from collections.abc import Iterable, Sequence from dataclasses import dataclass, field -from datetime import UTC, datetime from threading import Lock from typing import Literal +from pyrit.analytics.scenario_statistics import ( + ScenarioPlanLookup, + compute_scenario_statistics, + count_execution_units, + resolve_attack_result_attempt, + resolve_execution_unit, + retry_pressure, +) from pyrit.common.utils import to_sha256 from pyrit.memory import AttackResultKeysetCursor from pyrit.memory.memory_interface import MemoryInterface from pyrit.models import ( - AtomicAttackIdentifier, - AttackOutcome, AttackResult, AttackTechniqueIdentifier, ComponentIdentifier, @@ -25,6 +30,7 @@ ScenarioAttackTechniqueDetails, ScenarioComponentIdentity, ScenarioDisplayGroupProgress, + ScenarioExecutionUnit, ScenarioObjectiveScorer, ScenarioObjectiveScorerMetrics, ScenarioProgressCounts, @@ -39,13 +45,17 @@ ScenarioTechniqueProgress, ScorerEvaluationIdentifier, ScorerIdentifier, - config_hash, project_behavioral_identity, ) from pyrit.score.scorer_evaluation.scorer_metrics_io import find_objective_metrics_by_eval_hash logger = logging.getLogger(__name__) +# Execution-unit identity and plan lookup live in ``pyrit.analytics`` so the SDK, backend, and reports +# share one implementation. These names remain importable from here for compatibility. +ResultUnitIdentity = ScenarioExecutionUnit +__all__ = ["ResultUnitIdentity", "ScenarioPlanLookup", "ScenarioProgressReadModel", "ScenarioProgressSnapshot"] + # Technique seeds are rendered as content, so the REST payload carries only the # fields the UI displays. All other narrowing is declared by identifier types and # applied by ``project_behavioral_identity``. @@ -53,14 +63,6 @@ _TECHNIQUE_SEED_DISPLAY_PARAMS = ("value", "data_type") -@dataclass(frozen=True, slots=True) -class ResultUnitIdentity: - """Stable identity of one planned scenario execution unit.""" - - atomic_group_id: str - seed_group_id: str - - @dataclass(frozen=True, slots=True) class ScenarioProgressSnapshot: """Immutable boundary returned after refreshing one run's progress state.""" @@ -92,79 +94,6 @@ class _ProgressCacheEntry: summary_state: _ProgressSummaryState | None = None -@dataclass(frozen=True, slots=True) -class ScenarioPlanLookup: - """Pre-indexed run-plan data used while mapping persisted results.""" - - groups_by_identity: dict[tuple[str, str], ScenarioRunPlanAtomicGroup] - groups_by_name: dict[str, tuple[ScenarioRunPlanAtomicGroup, ...]] - seed_ids_by_group_and_objective: dict[tuple[str, str], tuple[str, ...]] - planned_units: frozenset[ResultUnitIdentity] - - @classmethod - def from_plan(cls, *, plan: ScenarioRunPlan | None) -> "ScenarioPlanLookup": - """ - Build constant-time lookup tables for one run plan. - - Returns: - ScenarioPlanLookup: Indexed plan data. - """ - if plan is None: - return cls( - groups_by_identity={}, - groups_by_name={}, - seed_ids_by_group_and_objective={}, - planned_units=frozenset(), - ) - - groups_by_identity: dict[tuple[str, str], ScenarioRunPlanAtomicGroup] = {} - grouped_by_name: dict[str, list[ScenarioRunPlanAtomicGroup]] = {} - seeds_by_id = {seed.id: seed for seed in plan.seed_groups} - seed_ids_by_group_and_objective: dict[tuple[str, str], tuple[str, ...]] = {} - planned_units: set[ResultUnitIdentity] = set() - for group in plan.atomic_groups: - groups_by_identity[(group.atomic_attack_name, group.technique_eval_hash)] = group - grouped_by_name.setdefault(group.atomic_attack_name, []).append(group) - seed_ids_by_objective: dict[str, list[str]] = {} - for seed_id in group.seed_group_ids: - seed = seeds_by_id[seed_id] - seed_ids_by_objective.setdefault(seed.objective_sha256, []).append(seed_id) - seed_ids_by_group_and_objective.update( - { - (group.id, objective_sha256): tuple(seed_ids) - for objective_sha256, seed_ids in seed_ids_by_objective.items() - } - ) - planned_units.update( - ResultUnitIdentity(atomic_group_id=group.id, seed_group_id=seed_group_id) - for seed_group_id in group.seed_group_ids - ) - - return cls( - groups_by_identity=groups_by_identity, - groups_by_name={name: tuple(groups) for name, groups in grouped_by_name.items()}, - seed_ids_by_group_and_objective=seed_ids_by_group_and_objective, - planned_units=frozenset(planned_units), - ) - - def resolve_group( - self, - *, - atomic_attack_name: str, - technique_eval_hash: str | None, - ) -> ScenarioRunPlanAtomicGroup | None: - """ - Resolve one planned group from persisted attribution. - - Returns: - ScenarioRunPlanAtomicGroup | None: The uniquely matching group. - """ - if technique_eval_hash is not None: - return self.groups_by_identity.get((atomic_attack_name, technique_eval_hash)) - matching_groups = self.groups_by_name.get(atomic_attack_name, ()) - return matching_groups[0] if len(matching_groups) == 1 else None - - class ScenarioProgressReadModel: """Hydrate, map, cache, and summarize persisted scenario progress.""" @@ -293,42 +222,14 @@ def resolve_result_unit_identity( Returns: ResultUnitIdentity: The atomic-group and seed-group IDs. """ - atomic_identifier = attack_result.atomic_attack_identifier - typed_identifier = ( - AtomicAttackIdentifier.from_component_identifier(atomic_identifier) - if isinstance(atomic_identifier, ComponentIdentifier) - else None - ) - objective = str(attack_result.objective) - attribution_data = attack_result.attribution_data - attributed_seed_group_id = attribution_data.get("seed_group_id") if isinstance(attribution_data, dict) else None - seed_group_id = str(attributed_seed_group_id) if attributed_seed_group_id else "" - if not seed_group_id and typed_identifier is not None and typed_identifier.seed_identifiers: - seed_group_id = typed_identifier.logical_seed_group_id - - atomic_group_id = atomic_attack_name - eval_hash = attribution_data.get("parent_eval_hash") if isinstance(attribution_data, dict) else None - planned_group = plan_lookup.resolve_group( + return resolve_attack_result_attempt( atomic_attack_name=atomic_attack_name, - technique_eval_hash=str(eval_hash) if eval_hash is not None else None, - ) - if planned_group is not None: - atomic_group_id = planned_group.id - if not seed_group_id: - objective_sha256 = to_sha256(objective) - matching_seed_ids = plan_lookup.seed_ids_by_group_and_objective.get( - (planned_group.id, objective_sha256), - (), - ) - if len(matching_seed_ids) == 1: - seed_group_id = matching_seed_ids[0] - if not seed_group_id: - seed_group_id = config_hash({"objective": objective}) - return ResultUnitIdentity(atomic_group_id=atomic_group_id, seed_group_id=seed_group_id) + attack_result=attack_result, + plan_lookup=plan_lookup, + ).unit - @classmethod + @staticmethod def calculate_progress_counts( - cls, *, scenario_result: ScenarioResult, plan: ScenarioRunPlan | None, @@ -337,46 +238,15 @@ def calculate_progress_counts( """ Calculate planned-unit totals without inflating retries or error attempts. + Delegates to ``pyrit.analytics.scenario_statistics`` so run details match the SDK and reports. + Returns: tuple[int, int, int, int]: Total, completed, success-rate percentage, and successful-unit count. """ - latest_result_by_unit: dict[ResultUnitIdentity, AttackResult] = {} - for atomic_attack_name, results in scenario_result.attack_results.items(): - for attack_result in results: - unit_identity = cls.resolve_result_unit_identity( - atomic_attack_name=atomic_attack_name, - attack_result=attack_result, - plan_lookup=plan_lookup, - ) - previous = latest_result_by_unit.get(unit_identity) - if previous is None or cls._result_order_key(attack_result) > cls._result_order_key(previous): - latest_result_by_unit[unit_identity] = attack_result - - planned_units = plan_lookup.planned_units if plan is not None else frozenset(latest_result_by_unit) - total = len(planned_units) - completed_results = [result for unit, result in latest_result_by_unit.items() if unit in planned_units] - completed = len(completed_results) - succeeded = sum(result.outcome == AttackOutcome.SUCCESS for result in completed_results) - rate = int((succeeded / completed) * 100) if completed else 0 - return total, completed, rate, succeeded - - @staticmethod - def _result_order_key(attack_result: AttackResult) -> tuple[datetime, str]: - """Return a deterministic chronological key for one hydrated result attempt.""" - return ScenarioProgressReadModel._timestamp_order_key(attack_result.timestamp), str( - attack_result.attack_result_id - ) - - @staticmethod - def _timestamp_order_key(timestamp: object) -> datetime: - """ - Normalize potentially malformed timestamps from mutable result objects. - - Returns: - datetime: The timestamp or a stable earliest-time fallback. - """ - return timestamp if isinstance(timestamp, datetime) else datetime.min.replace(tzinfo=UTC) + overall = compute_scenario_statistics(scenario_result, plan=plan, use_saved_plan=False).overall + total = overall.planned if overall.planned is not None else overall.completed + return total, overall.completed, overall.success_percentage or 0, overall.succeeded @staticmethod def total_retry_pressure(*, attempts_per_unit: Iterable[int], persisted_retries: Iterable[int]) -> int: @@ -386,9 +256,7 @@ def total_retry_pressure(*, attempts_per_unit: Iterable[int], persisted_retries: Returns: int: Total retry pressure. """ - within_attempts = sum(max(0, retries) for retries in persisted_retries) - repeated_units = sum(max(0, count - 1) for count in attempts_per_unit) - return within_attempts + repeated_units + return retry_pressure(attempts_per_unit=attempts_per_unit, persisted_retries=persisted_retries) @staticmethod def _build_technique_details_by_group( @@ -443,28 +311,7 @@ def _build_progress_summary( attempts_by_unit.setdefault(identity, []).append(result) def aggregate(*, units: Sequence[ResultUnitIdentity], planned: int | None) -> ScenarioProgressCounts: - completed = 0 - succeeded = 0 - errors = 0 - retries = 0 - for unit in units: - attempts = attempts_by_unit.get(unit, []) - if attempts: - completed += 1 - succeeded += int(attempts[-1].outcome == AttackOutcome.SUCCESS) - errors += sum(int(attempt.outcome == AttackOutcome.ERROR) for attempt in attempts) - retries += ScenarioProgressReadModel.total_retry_pressure( - attempts_per_unit=[len(attempts)], - persisted_retries=[attempt.total_retries for attempt in attempts], - ) - return ScenarioProgressCounts( - completed=completed, - planned=planned, - succeeded=succeeded, - success_percentage=int((succeeded / completed) * 100) if completed else None, - errors=errors, - retries=retries, - ) + return count_execution_units(units=units, attempts_by_unit=attempts_by_unit, planned=planned) group_units: dict[str, list[ResultUnitIdentity]] = { group.id: [ @@ -769,32 +616,18 @@ def _map_progress_delta( """ atomic_attack_name = str(delta.attribution_data.get("parent_collection") or "") eval_hash = delta.attribution_data.get("parent_eval_hash") - atomic_group_id = config_hash( - {"atomic_attack_name": atomic_attack_name, "technique_eval_hash": eval_hash or ""} - ) - planned_group = plan_lookup.resolve_group( + attributed_seed_group_id = delta.attribution_data.get("seed_group_id") + unit = resolve_execution_unit( atomic_attack_name=atomic_attack_name, technique_eval_hash=str(eval_hash) if eval_hash is not None else None, + attributed_seed_group_id=str(attributed_seed_group_id) if attributed_seed_group_id else None, + atomic_attack_identifier=delta.atomic_attack_identifier, + objective=delta.objective, + objective_sha256=delta.objective_sha256, + plan_lookup=plan_lookup, ) - if planned_group is not None: - atomic_group_id = planned_group.id - attributed_seed_group_id = delta.attribution_data.get("seed_group_id") - seed_group_id = str(attributed_seed_group_id) if attributed_seed_group_id else "" - if ( - not seed_group_id - and delta.atomic_attack_identifier is not None - and delta.atomic_attack_identifier.seed_identifiers - ): - seed_group_id = delta.atomic_attack_identifier.logical_seed_group_id - if not seed_group_id and delta.objective_sha256: - matching_seed_ids = plan_lookup.seed_ids_by_group_and_objective.get( - (atomic_group_id, delta.objective_sha256), - (), - ) - if len(matching_seed_ids) == 1: - seed_group_id = matching_seed_ids[0] - if not seed_group_id: - seed_group_id = config_hash({"objective": delta.objective}) + atomic_group_id = unit.atomic_group_id + seed_group_id = unit.seed_group_id return ScenarioProgressResult( attack_result_id=delta.attack_result_id, conversation_id=delta.conversation_id, diff --git a/pyrit/models/__init__.py b/pyrit/models/__init__.py index bc34c01116..bdbbc91e7f 100644 --- a/pyrit/models/__init__.py +++ b/pyrit/models/__init__.py @@ -142,6 +142,8 @@ ScenarioAttackTechniqueDetails, ScenarioComponentIdentity, ScenarioDisplayGroupProgress, + ScenarioExecutionStatistics, + ScenarioExecutionUnit, ScenarioObjectiveScorer, ScenarioObjectiveScorerMetrics, ScenarioProgressCounts, @@ -374,6 +376,8 @@ "ScenarioAttackTechniqueDetails": "pyrit.models.scenario_progress", "ScenarioComponentIdentity": "pyrit.models.scenario_progress", "ScenarioDisplayGroupProgress": "pyrit.models.scenario_progress", + "ScenarioExecutionStatistics": "pyrit.models.scenario_progress", + "ScenarioExecutionUnit": "pyrit.models.scenario_progress", "ScenarioObjectiveScorer": "pyrit.models.scenario_progress", "ScenarioObjectiveScorerMetrics": "pyrit.models.scenario_progress", "ScenarioProgressCounts": "pyrit.models.scenario_progress", diff --git a/pyrit/models/results/scenario_result.py b/pyrit/models/results/scenario_result.py index e13ca5613e..542cf12b05 100644 --- a/pyrit/models/results/scenario_result.py +++ b/pyrit/models/results/scenario_result.py @@ -11,6 +11,8 @@ from pydantic import BaseModel, ConfigDict, Field, computed_field, model_validator +from pyrit.common.deprecation import print_deprecation_message + # Runtime-required by Pydantic field / computed-field annotations. from pyrit.models.identifiers.scenario_identifier import ScenarioIdentifier # noqa: TC001 from pyrit.models.identifiers.scorer_identifier import ScorerIdentifier # noqa: TC001 @@ -229,7 +231,16 @@ def objective_achieved_rate(self, *, atomic_attack_name: str | None = None) -> i Returns: int: Success rate as a percentage (0-100). + .. deprecated:: 1.2.0 + Counts every persisted attempt, including ERROR attempts that were later retried or resumed. + Use ``pyrit.analytics.compute_scenario_statistics(result)``, which counts each execution unit + once and is shared with the GUI backend and the reports. Removed in 1.4.0. """ + print_deprecation_message( + old_item="ScenarioResult.objective_achieved_rate", + new_item="pyrit.analytics.compute_scenario_statistics", + removed_in="1.4.0", + ) if not atomic_attack_name: # Calculate rate across all atomic attacks all_results = [] diff --git a/pyrit/models/scenario_progress.py b/pyrit/models/scenario_progress.py index 8e9ef9bd95..8397fbb285 100644 --- a/pyrit/models/scenario_progress.py +++ b/pyrit/models/scenario_progress.py @@ -6,7 +6,7 @@ from datetime import datetime from typing import Any, Literal -from pydantic import AwareDatetime, BaseModel, Field, model_validator +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator from pyrit.models.catalog.scenario import ScenarioOverloadSummary, ScenarioTargetSummary # noqa: TC001 from pyrit.models.identifiers.atomic_attack_identifier import AtomicAttackIdentifier @@ -167,6 +167,42 @@ class ScenarioProgressCounts(BaseModel): retries: int = Field(..., ge=0) +class ScenarioExecutionUnit(BaseModel): + """ + Identity of one scenario execution unit. + + ``atomic_group_id`` identifies the atomic attack together with its technique configuration, so + two configurations that share an atomic attack name are separate units. ``seed_group_id`` + identifies the logical seed group within it. + """ + + model_config = ConfigDict(frozen=True) + + atomic_group_id: str + seed_group_id: str + + +class ScenarioExecutionStatistics(BaseModel): + """ + Effective execution-unit statistics for one scenario run, calculated by ``pyrit.analytics``. + + Each execution unit counts once, by its latest attempt, so recovered errors do not lower the success + percentage. ``attempts`` and the ``errors`` and ``retries`` of each count keep the historical attempt + history separately from the effective-unit statistics. + """ + + #: Counts across every counted execution unit. + overall: ScenarioProgressCounts + #: Counts keyed by atomic attack name (all technique configurations that share the name). + atomic_attacks: dict[str, ScenarioProgressCounts] = Field(default_factory=dict) + #: Counts keyed by display group label. + display_groups: dict[str, ScenarioProgressCounts] = Field(default_factory=dict) + #: Total persisted attempts, including superseded ones. + attempts: int = Field(default=0, ge=0) + #: Attempts that matched no planned execution unit and are excluded from the counts. + unattributed_attempts: int = Field(default=0, ge=0) + + class ScenarioTechniqueProgress(ScenarioProgressCounts): """Progress for one scenario technique.""" diff --git a/pyrit/output/_derivation.py b/pyrit/output/_derivation.py index 0280a81ff4..a6d903225f 100644 --- a/pyrit/output/_derivation.py +++ b/pyrit/output/_derivation.py @@ -14,7 +14,7 @@ from typing import TYPE_CHECKING, NamedTuple -from pyrit.models import AttackOutcome +from pyrit.analytics.scenario_statistics import combine_execution_counts, compute_scenario_statistics if TYPE_CHECKING: from pyrit.models import AttackResult, ComponentIdentifier, ScenarioResult, Score @@ -53,21 +53,63 @@ def resolve_target_info(target_id: ComponentIdentifier | None) -> TargetInfo: ) -def group_success_rate(attacks: list[AttackResult]) -> int: +class GroupStatistics(NamedTuple): + """Effective-unit statistics for one display group, alongside its raw attempt count.""" + + name: str + units: int + attempts: int + success_rate: int + + +class ScenarioOverview(NamedTuple): + """Overall and per-display-group statistics for a scenario report.""" + + units: int + attempts: int + success_rate: int + groups: list[GroupStatistics] + + +def scenario_overview(result: ScenarioResult) -> ScenarioOverview: """ - Return the percentage of *attacks* whose outcome is SUCCESS (0 when empty). + Summarize a scenario result for the reports. + + The numbers come from ``pyrit.analytics.compute_scenario_statistics``, the calculation shared with the + SDK and the GUI backend. Groups follow ``result.get_display_groups()``: each group folds the per-atomic- + attack counts of the atomic attacks ``display_group_map`` assigns to it, so the rate is always keyed the + same way the printers group their results. ``units`` counts effective execution units (the success-rate + denominator); ``attempts`` counts every persisted attempt, including retries. Args: - attacks (list[AttackResult]): The attacks to score. + result (ScenarioResult): The scenario result to summarize. Returns: - int: The success rate as an integer percent. + ScenarioOverview: The overall and per-group statistics. """ - total = len(attacks) - if not total: - return 0 - successful = sum(1 for attack in attacks if attack.outcome == AttackOutcome.SUCCESS) - return int((successful / total) * 100) + statistics = compute_scenario_statistics(result) + groups: list[GroupStatistics] = [] + for group_name, group_results in result.get_display_groups().items(): + atomic_attack_names = [ + name for name in result.attack_results if result.display_group_map.get(name, name) == group_name + ] + counts = combine_execution_counts( + statistics.atomic_attacks[name] for name in atomic_attack_names if name in statistics.atomic_attacks + ) + groups.append( + GroupStatistics( + name=group_name, + units=counts.completed, + attempts=len(group_results), + success_rate=counts.success_percentage or 0, + ) + ) + return ScenarioOverview( + units=statistics.overall.completed, + attempts=statistics.attempts, + success_rate=statistics.overall.success_percentage or 0, + groups=groups, + ) def attack_score_display(attack: AttackResult, *, none_value: str | None = None) -> str | None: diff --git a/pyrit/output/scenario_result/html.py b/pyrit/output/scenario_result/html.py index bb575db1ee..f43fc132bd 100644 --- a/pyrit/output/scenario_result/html.py +++ b/pyrit/output/scenario_result/html.py @@ -51,6 +51,8 @@
| Group | Results | Success rate | |
|---|---|---|---|
| Group | Results | Attempts | Success rate |
| {{ g.name }} | {{ g.num_results }} | {{ g.success_rate }}% | |
| {{ g.name }} | {{ g.num_results }} | +{{ g.num_attempts if g.num_attempts is defined else g.num_results }} | +{{ g.success_rate }}% |