Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion doc/code/analytics/0_attack_results.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,11 +253,15 @@ is ASCII and native `lower` is equivalent. Unicode, embedded NULs, and non-text
values retain the existing Python conversion and lowercasing.

Objective-target keys are persisted frozen-v1 behavioral evaluation hashes.
Deployment changes need not split behaviorally equivalent targets. The
Deployment changes need not split behaviorally equivalent targets. Targets
without a model name, such as HTTP or Gandalf targets, are keyed by their
content hash instead, so different endpoints or levels stay separate. The
`target_identifier_hash` in a result row is instead its exact content hash for
inspection. Neither key is recomputed from current registry state. Scenario keys
identify saved runs, not potentially repeated scenario names. The SDK adds short
identity suffixes to target/scenario display labels and forwards reader warnings.
Whitespace-only model labels use the target class name and short identity suffix;
SQLite and SQL Server recognize the same Unicode whitespace as Python `str.strip`.

## Freshness and projection limits

Expand Down
13 changes: 13 additions & 0 deletions doc/contributing/11_memory_models.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,19 @@ their names. The frozen v1 evaluation rules match
in place when evaluation rules evolve: add a new versioned column, backfill,
and explicit selection policy instead of silently changing historical groups.

v1 had one pre-release correction before it shipped in a release. Targets
without a model name (HTTP, Gandalf and other non-LLM targets) carry no
behavioral settings, so the original rules merged every such target of a
class into one group. v1 now keys a target with no nonempty
`underlying_model_name` or `model_name` by its content hash, after unwrapping
a wrapper to its first inner target. Targets with a model name keep their
original key. Revision `3f6a9d2c8b1e` recomputes stored keys for databases
that already applied `901e6c7bf9d4`, so existing databases, fresh installs, and
new writes agree. Its downgrade keeps the corrected keys. Objective-target
labels skip blank model names and fall back to the class name, which the SDK
suffixes with a short key. With this correction the v1 definition is frozen
again.

Filters OR values within each predicate (converter `ALL` is the exception) and AND
separate predicates, including repeated dimensions. Missing metadata, recorded
empty converter pipelines, and real empty strings retain different typed keys.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.

"""
Recompute frozen v1 objective-target keys after the model-less target correction.

Revision ``901e6c7bf9d4`` grouped every target without a model name (HTTP, Gandalf and
other non-LLM targets) of one class into a single key. The v1 rules now fall back to the
target content hash for those targets. Databases that already applied ``901e6c7bf9d4``
keep the old keys, because Alembic does not rerun an applied revision, so this revision
recomputes them with the same bounded backfill. Keys for targets with a model name come
out unchanged.

Revision ID: 3f6a9d2c8b1e
Revises: 901e6c7bf9d4
Create Date: 2026-10-13 09:00:00.000000
"""

import importlib
from collections.abc import Sequence

from alembic import op

revision: str = "3f6a9d2c8b1e"
down_revision: str | None = "901e6c7bf9d4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None

_ANALYTICS_REVISION = importlib.import_module("pyrit.memory.alembic.versions.901e6c7bf9d4_index_attack_analytics")


def upgrade() -> None:
"""Recompute every stored v1 objective-target key with the corrected rules."""
_ANALYTICS_REVISION._backfill(connection=op.get_bind())


def downgrade() -> None:
"""
Keep the corrected keys.

The pre-correction keys merged distinct targets and can't be told apart again,
so downgrading leaves the stored values as they are.
"""
37 changes: 37 additions & 0 deletions pyrit/memory/analytics_identity_v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ class ObjectiveTargetAnalyticsIdentityV1:
"""
The objective-target evaluation identity used for both writes and historical backfills.

Targets with a model name group by the behavioral evaluation rules (model, temperature,
top_p). Targets without one (HTTP, Gandalf and other non-LLM targets) group by their
content hash instead, so distinct endpoints or levels stay apart. That fallback is a
pre-release correction to v1, applied by revision ``3f6a9d2c8b1e``.

Keep these rules unchanged when ``Evaluate`` markers evolve. A future rule
change needs a new versioned column and migration so stored chart identities
never acquire a new meaning merely because code was upgraded.
Expand Down Expand Up @@ -43,13 +48,45 @@ def hash(cls, *, identifier: ComponentIdentifier) -> str:
str: The 64-character v1 evaluation hash.
"""
target = TargetIdentifier.from_component_identifier(identifier)
evaluated = cls._evaluated_target(target=target)
if not cls._has_model_name(target=evaluated):
# The behavioral rules only see model settings, so they would merge every
# model-less target of a class into one group.
return evaluated.hash
return compute_eval_hash(
target,
child_eval_rules=cls._CHILD_RULES,
own_rule=cls._OWN_RULE,
root_unwrap_child="targets",
)

@staticmethod
def _evaluated_target(*, target: ComponentIdentifier) -> ComponentIdentifier:
"""
Return the target the rules evaluate: a wrapper's first inner target, or the target itself.

Args:
target (ComponentIdentifier): The recorded objective target.

Returns:
ComponentIdentifier: The unwrapped target.
"""
inner = target.get_child_list("targets")
return inner[0] if inner else target

@classmethod
def _has_model_name(cls, *, target: ComponentIdentifier) -> bool:
"""
Check for a nonempty underlying model name or its ``model_name`` fallback.

Args:
target (ComponentIdentifier): The unwrapped target.

Returns:
bool: True if the target records a model name the rules can group by.
"""
return any(str(target.params.get(name) or "").strip() for name in ("underlying_model_name", "model_name"))

@classmethod
def from_atomic_document(cls, *, document: dict[str, object] | None) -> str | None:
"""
Expand Down
48 changes: 47 additions & 1 deletion pyrit/memory/analytics_sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,15 @@

from typing import TYPE_CHECKING, Any, ClassVar

from sqlalchemy import Boolean, String, UnicodeText
from sqlalchemy import Boolean, String, Unicode, UnicodeText, literal
from sqlalchemy.exc import CompileError
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.sql.elements import BindParameter
from sqlalchemy.sql.functions import FunctionElement
from sqlalchemy.sql.selectable import Join, Lateral

if TYPE_CHECKING:
from sqlalchemy.sql import ColumnElement
from sqlalchemy.sql.compiler import SQLCompiler


Expand Down Expand Up @@ -77,6 +78,51 @@ def _mssql_unicode_lower(element: UnicodeLower, compiler: Any, **kwargs: Any) ->
return f"LOWER({value})"


class UnicodeStrip(FunctionElement[str]):
"""Strip Python's Unicode whitespace set using native database expressions."""

_WHITESPACE: ClassVar[str] = (
"\t\n\v\f\r\x1c\x1d\x1e\x1f \x85\xa0\u1680"
"\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a"
"\u2028\u2029\u202f\u205f\u3000"
)
type = UnicodeText()
inherit_cache = True

def __init__(self, value: ColumnElement[Any]) -> None:
"""
Bind the whitespace set alongside the value to preserve statement caching.

Args:
value (ColumnElement[Any]): Nullable text to strip.
"""
super().__init__(value, literal(self._WHITESPACE, Unicode(len(self._WHITESPACE))))


@compiles(UnicodeStrip, "sqlite")
def _sqlite_unicode_strip(element: UnicodeStrip, compiler: Any, **kwargs: Any) -> str:
"""
Strip the explicit character set instead of SQLite's space-only default.

Returns:
str: A native trim expression preserving NULL.
"""
value, whitespace = _arguments(element, compiler, **kwargs)
return f"trim({value}, {whitespace})"


@compiles(UnicodeStrip, "mssql")
def _mssql_unicode_strip(element: UnicodeStrip, compiler: Any, **kwargs: Any) -> str:
"""
Strip exact whitespace code points independently of database collation.

Returns:
str: A native TRIM expression with a bounded Unicode character argument.
"""
value, whitespace = _arguments(element, compiler, **kwargs)
return f"TRIM({whitespace} FROM {value} COLLATE Latin1_General_100_BIN2)"


class JsonClassNamePresent(FunctionElement[bool]):
"""
Whether an identifier object explicitly has ``class_name``, even when null.
Expand Down
7 changes: 5 additions & 2 deletions pyrit/memory/attack_analytics_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
JsonObjectAggregate,
JsonScalar,
UnicodeLower,
UnicodeStrip,
)
from pyrit.memory.memory_models import (
AtomicAttackIdentifierEntry,
Expand Down Expand Up @@ -759,9 +760,11 @@ def _create_source(self, *, dimension: AttackAnalyticsDimension, embedded: bool
return _Source(value=value, label=value, insensitive=True)
if name is AttackAnalyticsDimensionName.OBJECTIVE_TARGET:
value = self.root.c.objective_target_eval_hash_v1
# Model-less targets record a blank model name; fall through to the class name so
# the SDK's short-hash suffix makes the label readable without endpoint details.
label = func.coalesce(
self.target.c.model_name,
self._target_property(path="params.model_name", embedded=embedded),
func.nullif(UnicodeStrip(self.target.c.model_name), ""),
func.nullif(UnicodeStrip(self._target_property(path="params.model_name", embedded=embedded)), ""),
self.target.c.class_name,
self._target_property(path="class_name", embedded=embedded),
value,
Expand Down
44 changes: 44 additions & 0 deletions tests/unit/analytics/test_attack_result_analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@

import pytest
from pydantic import ValidationError
from sqlalchemy import update

from pyrit.analytics import AttackResultAnalytics, compute_scenario_statistics
from pyrit.common.task_utils import gather_with_cleanup_async
from pyrit.exceptions.analytics_exception import AnalyticsBusyException, AnalyticsDataException
from pyrit.memory import CentralMemory, MemoryInterface, SQLiteMemory
from pyrit.memory.analytics_identity_v1 import ObjectiveTargetAnalyticsIdentityV1
from pyrit.memory.attack_analytics import RawAnalyticsOption
from pyrit.memory.memory_models import TargetIdentifierEntry
from pyrit.models import (
AtomicAttackIdentifier,
AttackAnalyticsDimension,
Expand All @@ -32,6 +34,7 @@
AttackResultRole,
TargetIdentifier,
)
from pyrit.prompt_target import HTTPTarget
from unit.memory.test_attack_analytics import make_result, predicate
from unit.mocks import make_scenario_result

Expand Down Expand Up @@ -518,6 +521,47 @@ async def test_mutated_models_are_revalidated_before_execution(
run.assert_not_awaited()


@pytest.mark.usefixtures("patch_central_database")
@pytest.mark.parametrize("model_name", ["", " ", "\t", "\r\n", "\xa0", "\u2003", " \t\xa0"])
@pytest.mark.parametrize("embedded", [False, True], ids=["normalized", "embedded"])
async def test_blank_target_labels_keep_distinct_sdk_identities_async(
analytics: AttackResultAnalytics, sqlite_instance: SQLiteMemory, model_name: str, embedded: bool
) -> None:
targets = [
HTTPTarget(
http_request=f"POST /chat HTTP/1.1\nHost: {host}.example\n\n{{PROMPT}}",
model_name=model_name,
).get_identifier()
for host in ("bank", "hr")
]
results = [make_result(index=index) for index in (1, 2)]
for result, target in zip(results, targets, strict=True):
result.atomic_attack_identifier = AtomicAttackIdentifier.build(
attack_identifier=AttackIdentifier(class_name="ProbeAttack", class_module="tests", objective_target=target)
)
await sqlite_instance.add_attack_results_to_memory_async(attack_results=results)
if embedded:
async with await sqlite_instance.get_session_async() as session:
await session.execute(update(TargetIdentifierEntry).values(model_name=None, identifier_json=None))
await session.commit()
axis = AttackAnalyticsDimension(name="objective_target")
expected = {target.hash: f"HTTPTarget ({target.hash[-8:]})" for target in targets}
report = await analytics.query_async(query=AttackAnalyticsQuery(group_by=axis))
facets = await analytics.facets_async(query=AttackAnalyticsFacetQuery(dimension=axis, search="HTTPTarget"))
matrix = await analytics.query_async(
query=AttackAnalyticsQuery(group_by=axis, compare_by=AttackAnalyticsDimension(name="operation"))
)
assert {group.key.value: group.label for group in report.groups} == expected
assert {item.key.value: item.label for item in facets.items} == expected
assert {row.key.value: row.label for row in matrix.rows} == expected
for group in report.groups:
page = await analytics.results_async(
query=AttackAnalyticsResultsQuery(filters=AttackAnalyticsFilters(dimensions=group.drilldown_filters))
)
assert len(page.items) == group.statistics.total_results == 1
assert page.items[0].target_identifier_hash == group.key.value


async def test_frozen_target_groups_preserve_content_hashes_and_every_result_id(
analytics: AttackResultAnalytics, sqlite_instance: SQLiteMemory
) -> None:
Expand Down
Loading
Loading