Skip to content
6 changes: 3 additions & 3 deletions doc/code/memory/8_seed_database.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@
"source": [
"## Adding Seeds to the Database\n",
"\n",
"PyRIT uses content hashing to avoid re-importing stored seeds. Standalone seeds follow these rules:\n",
"PyRIT uses content hashing to avoid re-importing stored seeds. Standalone seeds and single-seed groups follow these rules:\n",
"\n",
"1. **Same dataset, duplicate content and conditions**: Seed is skipped\n",
"2. **Same dataset, modified content**: Seed is accepted (different hash indicates changes)\n",
"1. **Same dataset, duplicate content and execution identity** (seed type, data type, role, response schema, and behavioral inputs such as conditions): Seed is skipped, even if descriptive metadata such as harm categories differs\n",
"2. **Same dataset, modified content or execution identity**: Seed is accepted as a new row; the stored original is unchanged\n",
"3. **Different dataset, duplicate content**: Seed is accepted (allows the same content across datasets)\n",
"\n",
"Multi-seed groups are compared as complete groups, including roles, sequence, metadata, and conditions. Distinct groups retain all their members, even when they share a system prompt or objective with another group. Re-importing an equivalent complete group does not add duplicates, even if its IDs have changed.\n",
Expand Down
6 changes: 3 additions & 3 deletions doc/code/memory/8_seed_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@
# %% [markdown]
# ## Adding Seeds to the Database
#
# PyRIT uses content hashing to avoid re-importing stored seeds. Standalone seeds follow these rules:
# PyRIT uses content hashing to avoid re-importing stored seeds. Standalone seeds and single-seed groups follow these rules:
#
# 1. **Same dataset, duplicate content and conditions**: Seed is skipped
# 2. **Same dataset, modified content**: Seed is accepted (different hash indicates changes)
# 1. **Same dataset, duplicate content and execution identity** (seed type, data type, role, response schema, and behavioral inputs such as conditions): Seed is skipped, even if descriptive metadata such as harm categories differs
# 2. **Same dataset, modified content or execution identity**: Seed is accepted as a new row; the stored original is unchanged
# 3. **Different dataset, duplicate content**: Seed is accepted (allows the same content across datasets)
#
# Multi-seed groups are compared as complete groups, including roles, sequence, metadata, and conditions. Distinct groups retain all their members, even when they share a system prompt or objective with another group. Re-importing an equivalent complete group does not add duplicates, even if its IDs have changed.
Expand Down
186 changes: 89 additions & 97 deletions pyrit/memory/memory_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,6 @@
SeedDatasetSummary,
SeedGroup,
SeedIdentifier,
SeedObjective,
SeedOrigin,
SeedPrompt,
SeedRecord,
Expand Down Expand Up @@ -213,10 +212,6 @@ def __init__(self, *, operation_id: uuid.UUID, usage: OperationUsage) -> None:
self.usage = usage


#: Canonical key of a seed without conditions or named adversarial prompts.
_NO_SEED_BEHAVIOR_KEY = json.dumps({}, separators=(",", ":"))


Model = TypeVar("Model")
IdentifierModel = TypeVar("IdentifierModel", bound=ComponentIdentifier)
OperationArgs = ParamSpec("OperationArgs")
Expand Down Expand Up @@ -5013,13 +5008,18 @@ async def add_seeds_to_memory_async(self, *, seeds: Sequence[Seed[Any]], added_b
"""
Insert a list of seeds into the memory storage.

Standalone seeds with the same value hash, dataset, and behavioral inputs already
present in storage are skipped. Multi-seed groups are compared as complete groups,
including roles, sequence, metadata, and behavioral inputs, so shared content cannot strip
members from a different conversation. Duplicates *within* ``seeds`` are all inserted,
because the check looks at what storage held when the call started.
Seeds already present in storage are skipped by execution identity: value hash, dataset,
seed type, data type, role (``None`` counts as ``user``), response schema, and behavioral
inputs. Non-execution metadata such as harm categories or descriptions is not part of that
Comment thread
hannahwestra25 marked this conversation as resolved.
Resolved
identity, so a metadata-only edit does not create a duplicate and sending a seed standalone
or inside a singleton group deduplicates the same way. Retained multi-seed groups keep their
complete membership; they are compared as complete groups by full member identity, including
roles, sequence, metadata, and behavioral inputs, so shared content cannot strip members from
a different conversation. Duplicates *within* ``seeds`` are all inserted, because the check
looks at what storage held when the call started.
When a stored seed is copied to a new group, its caller-visible ID is updated after
successful insertion.
successful insertion; a copy that would otherwise reuse the stored group ID is moved to a
new group so editing it cannot alter the original group.

Args:
seeds (Sequence[Seed]): A list of seeds to insert.
Expand All @@ -5033,25 +5033,44 @@ async def add_seeds_to_memory_async(self, *, seeds: Sequence[Seed[Any]], added_b

def _insert_prepared_seeds(self, *, seeds: Sequence[Seed[Any]]) -> None:
"""Deduplicate and store seeds after their media is prepared."""
existing_pairs, existing_hashes, existing_seeds = self._get_existing_seed_keys(seeds=seeds)
retained_group_ids = self._get_new_behavior_group_ids(
seeds=seeds, existing_pairs=existing_pairs, existing_hashes=existing_hashes
)
retained_group_ids.update(self._get_new_seed_group_ids(seeds=seeds, existing_seeds=existing_seeds))
existing_seeds = self._get_existing_seed_keys(seeds=seeds)
# Compute each stored seed's full member identity once; retained groups and the
# complete-group check both use it.
existing_group_members: list[tuple[uuid.UUID, str | None, str, uuid.UUID | None]] = []
for dataset, seed in existing_seeds:
if seed.prompt_group_id is not None:
existing_group_members.append(
(seed.prompt_group_id, dataset, self._seed_group_member_key(seed), seed.id)
)
retained_group_ids = self._get_new_seed_group_ids(seeds=seeds, existing_group_members=existing_group_members)
stored_group_members = {
(seed.prompt_group_id, dataset, self._seed_group_member_key(seed))
for dataset, seed in existing_seeds
if seed.prompt_group_id is not None
(group_id, dataset, member_key)
for group_id, dataset, member_key, _ in existing_group_members
if group_id in retained_group_ids
}
retained_seed_ids = [seed.id for seed in seeds if seed.prompt_group_id in retained_group_ids]
# Everything not retained as a new group is deduplicated by one execution key, computed
# once per stored seed. Seeds that are not retained include singleton groups and groups
# whose complete membership is already stored, so a metadata-only edit and the same seed
# arriving grouped or standalone deduplicate identically.
stored_standalone_pairs: set[tuple[str | None, str | None, str]] = set()
stored_standalone_hashes: set[tuple[str | None, str]] = set()
if any(prompt.value_sha256 and prompt.prompt_group_id not in retained_group_ids for prompt in seeds):
for dataset, seed in existing_seeds:
standalone_key = self._seed_standalone_key(seed)
if dataset:
stored_standalone_pairs.add((seed.value_sha256, dataset, standalone_key))
else:
stored_standalone_hashes.add((seed.value_sha256, standalone_key))
# Any incoming seed may be a copy of a stored row, not only retained group members, so look
# up every seed ID before inserting. A copy keeps its caller-visible ID, but it must not
# reuse the stored primary key when its execution identity changed.
seed_ids = [seed.id for seed in seeds if seed.id is not None]
stored_groups = (
{
entry.id: entry.prompt_group_id
for entry in self._execute_batched_query(
SeedEntry, batch_column=SeedEntry.id, batch_values=retained_seed_ids
)
for entry in self._execute_batched_query(SeedEntry, batch_column=SeedEntry.id, batch_values=seed_ids)
}
if retained_seed_ids
if seed_ids
else {}
)

Expand All @@ -5060,7 +5079,6 @@ def _insert_prepared_seeds(self, *, seeds: Sequence[Seed[Any]]) -> None:
for prompt in seeds:
if not prompt.value_sha256:
continue
behavior_key = self._seed_behavior_key(prompt)
# A seed without a dataset name matches the hash in any dataset, mirroring the
# filter that is applied when dataset_name is not supplied.
if prompt.prompt_group_id in retained_group_ids:
Expand All @@ -5078,16 +5096,28 @@ def _insert_prepared_seeds(self, *, seeds: Sequence[Seed[Any]]) -> None:
copied_seed_ids.append((prompt, entry.id))
entries.append(entry)
continue
standalone_key = self._seed_standalone_key(prompt)
if prompt.dataset_name:
if (prompt.value_sha256, prompt.dataset_name, behavior_key) in existing_pairs:
if (prompt.value_sha256, prompt.dataset_name, standalone_key) in stored_standalone_pairs:
Comment thread
hannahwestra25 marked this conversation as resolved.
Resolved
continue
elif (prompt.value_sha256, behavior_key) in existing_hashes:
elif (prompt.value_sha256, standalone_key) in stored_standalone_hashes:
continue
entries.append(SeedEntry(entry=prompt))

entry = SeedEntry(entry=prompt)
if prompt.id in stored_groups:
entry.id = uuid.uuid4()
# A caller that wraps a stored seed in a new group keeps the stored group ID.
# Move the independent copy to its own group so editing it cannot alter the
# original group's membership.
if prompt.prompt_group_id is not None and stored_groups[prompt.id] == prompt.prompt_group_id:
entry.prompt_group_id = uuid.uuid4()
Comment thread
hannahwestra25 marked this conversation as resolved.
Resolved
copied_seed_ids.append((prompt, entry.id))
entries.append(entry)

group_ids_by_entry = {entry.id: entry.prompt_group_id for entry in entries}
self._insert_entries(entries=entries)
for prompt, new_id in copied_seed_ids:
Comment thread
hannahwestra25 marked this conversation as resolved.
Resolved
prompt.id = new_id
prompt.prompt_group_id = group_ids_by_entry[new_id]

@staticmethod
def _seed_group_member_key(seed: Seed[Any]) -> str:
Expand Down Expand Up @@ -5123,11 +5153,32 @@ def _seed_group_member_key(seed: Seed[Any]) -> str:
separators=(",", ":"),
)

@staticmethod
def _seed_standalone_key(seed: Seed[Any]) -> str:
"""
Identify a standalone seed by its execution fields and behavioral inputs.

Returns:
str: Canonical standalone identity.
"""
entry = SeedEntry(entry=seed)
return json.dumps(
{
"seed_type": entry.seed_type,
"data_type": entry.data_type,
Comment thread
hannahwestra25 marked this conversation as resolved.
Resolved
"role": entry.role or "user",
"response_json_schema": getattr(seed, "response_json_schema", None),
"behavioral_parameters": SeedIdentifier.get_behavioral_parameters(seed),
},
sort_keys=True,
separators=(",", ":"),
)

def _get_new_seed_group_ids(
self,
*,
seeds: Sequence[Seed[Any]],
existing_seeds: Sequence[tuple[str | None, Seed[Any]]],
existing_group_members: Sequence[tuple[uuid.UUID, str | None, str, uuid.UUID | None]],
) -> set[uuid.UUID]:
"""
Retain multi-seed groups unless an equivalent complete group is already stored.
Expand All @@ -5145,10 +5196,8 @@ def _get_new_seed_group_ids(
return set()

matches: dict[tuple[str | None, str], dict[uuid.UUID, set[uuid.UUID | None]]] = {}
for dataset, seed in existing_seeds:
if seed.prompt_group_id is not None:
key = (dataset, self._seed_group_member_key(seed))
matches.setdefault(key, {}).setdefault(seed.prompt_group_id, set()).add(seed.id)
for group_id, dataset, member_key, seed_id in existing_group_members:
matches.setdefault((dataset, member_key), {}).setdefault(group_id, set()).add(seed_id)

candidate_groups: dict[uuid.UUID, set[uuid.UUID]] = {}
for group_id, members in members_by_group.items():
Expand Down Expand Up @@ -5193,55 +5242,9 @@ def _get_new_seed_group_ids(
new_group_ids.add(group_id)
return new_group_ids

@staticmethod
def _seed_behavior_key(seed: Seed[Any]) -> str:
"""
Serialize the behavioral inputs used by seed identifiers.

Returns:
str: Canonical JSON, preserving condition and template-parameter order.
"""
return json.dumps(SeedIdentifier.get_behavioral_parameters(seed), sort_keys=True, separators=(",", ":"))

def _get_new_behavior_group_ids(
self,
*,
seeds: Sequence[Seed[Any]],
existing_pairs: set[tuple[str, str, str]],
existing_hashes: set[tuple[str, str]],
) -> set[uuid.UUID]:
"""
Identify groups with new objective conditions or adversarial inputs.

Retain their companion seeds, including when an objective removes previously stored
behavioral inputs. Other multi-seed groups are compared by full membership separately.

Returns:
set[uuid.UUID]: Group IDs whose complete input membership must be retained.
"""
behaviors_by_value: dict[tuple[str, str | None], set[str]] = {}
for value_hash, dataset, behavior in existing_pairs:
behaviors_by_value.setdefault((value_hash, dataset), set()).add(behavior)
for value_hash, behavior in existing_hashes:
behaviors_by_value.setdefault((value_hash, None), set()).add(behavior)

group_ids: set[uuid.UUID] = set()
for seed in seeds:
if not isinstance(seed, SeedObjective) or seed.prompt_group_id is None or not seed.value_sha256:
continue
stored_behaviors = behaviors_by_value.get((seed.value_sha256, seed.dataset_name or None), set())
behavior_key = self._seed_behavior_key(seed)
if behavior_key in stored_behaviors:
continue
if behavior_key != _NO_SEED_BEHAVIOR_KEY or stored_behaviors - {_NO_SEED_BEHAVIOR_KEY}:
group_ids.add(seed.prompt_group_id)
return group_ids

def _get_existing_seed_keys(
self, *, seeds: Sequence[Seed[Any]]
) -> tuple[set[tuple[str, str, str]], set[tuple[str, str]], list[tuple[str | None, Seed[Any]]]]:
def _get_existing_seed_keys(self, *, seeds: Sequence[Seed[Any]]) -> list[tuple[str | None, Seed[Any]]]:
"""
Look up which value hashes and behavioral inputs are already stored.
Look up stored seeds matching incoming value hashes and dataset filters.

Queries in chunks rather than once per seed, which otherwise dominates the cost of
loading a large dataset. ``get_seeds`` issues the statement directly instead of going
Expand All @@ -5258,10 +5261,9 @@ def _get_existing_seed_keys(
seeds (Sequence[Seed]): The seeds whose hashes should be looked up.

Returns:
tuple: The stored
(value_sha256, dataset_name, behavior) keys, using the requested
dataset name, (value_sha256, behavior) keys irrespective of dataset,
and matched seeds paired with their requested dataset filter.
list[tuple[str | None, Seed[Any]]]: Matched seeds paired with their requested
dataset filter. The requested name is retained because the database may
match a different stored spelling under its collation.
"""
hashes_by_dataset: dict[str | None, set[str]] = {}
for prompt in seeds:
Expand All @@ -5271,8 +5273,6 @@ def _get_existing_seed_keys(
# cannot disagree.
hashes_by_dataset.setdefault(prompt.dataset_name or None, set()).add(prompt.value_sha256)

existing_pairs: set[tuple[str, str, str]] = set()
existing_hashes: set[tuple[str, str]] = set()
existing_seeds: list[tuple[str | None, Seed[Any]]] = []
for dataset_name, dataset_hashes in hashes_by_dataset.items():
hashes = sorted(dataset_hashes)
Expand All @@ -5287,16 +5287,8 @@ def _get_existing_seed_keys(
if not existing.value_sha256:
continue
existing_seeds.append((dataset_name, existing))
behavior_key = self._seed_behavior_key(existing)
if dataset_name:
# The database decided the name matched, so record the name that was
# asked for; the stored spelling can differ under a case-insensitive
# collation.
existing_pairs.add((existing.value_sha256, dataset_name, behavior_key))
else:
existing_hashes.add((existing.value_sha256, behavior_key))

return existing_pairs, existing_hashes, existing_seeds
return existing_seeds

async def add_seed_datasets_to_memory_async(self, *, datasets: Sequence[SeedDataset], added_by: str) -> None:
"""
Expand Down
Loading
Loading