Skip to content

[FIX] Keep execution-distinct seeds when deduplicating - #3773

Open
Chen Yufeiyang (feiiiiii5) wants to merge 7 commits into
microsoft:mainfrom
feiiiiii5:fix/seed-dedup-identity
Open

Chen Yufeiyang (feiiiiii5) wants to merge 7 commits into
microsoft:mainfrom
feiiiiii5:fix/seed-dedup-identity

Conversation

@feiiiiii5

@feiiiiii5 Chen Yufeiyang (feiiiiii5) commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #3738.

Description

Seed deduplication could silently drop execution-distinct standalone seeds and singleton groups: for example, a prompt and an objective with the same text, different response schemas or roles, or a stored singleton objective re-added with edited conditions.

Compare standalone seeds and singleton groups by their execution identity, and compare retained multi-seed groups by complete membership. A changed stored singleton is copied into a new row and group, preserving the original. Exact duplicates and metadata-only edits still deduplicate.

Tests and Documentation

The new SQLite regression fails on the previous PR head (f9c25ff): editing a stored objective from the Paris condition to Lyon leaves only one group. It passes after restricting behavioral group retention to multi-seed groups, and verifies both persisted conditions and independent row/group IDs.

The deduplication, seed-condition and seed-prompt test files pass together: 195 passed. Ruff lint/format and targeted ty checks pass. Updated 8_seed_database.py and its paired notebook with identical deduplication rules; the 11 paired cells match, and notebook outputs and metadata are preserved.

Validation scope
python -m pytest \
  tests/unit/memory/memory_interface/test_interface_seed_dedup_identity.py \
  tests/unit/memory/test_seed_conditions.py \
  tests/unit/memory/memory_interface/test_interface_seed_prompts.py -q

Tests use the repository's SQLite fixture. The full repository suite, Azure SQL and notebook execution were not run in this update.

Standalone seeds and singleton groups were deduplicated by the behavioral key alone, so seeds that differed only by seed type, data type, or response_json_schema were silently skipped even though they execute differently. Standalone identity now includes those fields, and singleton/already-stored groups use the full member identity that multi-seed groups already use. Exact duplicates stay skipped; multi-seed group behavior is unchanged.

Fixes microsoft#3738
@hannahwestra25 hannahwestra25 self-assigned this Nov 2, 2026
Comment thread pyrit/memory/memory_interface.py Resolved
return json.dumps(
{
"seed_type": entry.seed_type,
"data_type": entry.data_type,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Role isn't in this key, so standalone user and system prompts with the same text still collapse to one row and the system prompt is lost. That's the same execution-distinct case #3738 is about. Normalize None to "user" to match SeedGroup:

Suggested change
"data_type": entry.data_type,
"data_type": entry.data_type,
"role": entry.role or "user",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: _seed_standalone_key now includes "role": entry.role or "user", matching SeedGroup's None->user normalization, so a standalone user and system seed with the same text stay distinct. Covered by the new schema/role test.

Comment thread pyrit/memory/memory_interface.py Resolved
Comment thread pyrit/memory/memory_interface.py Outdated
Comment on lines +5048 to +5051
stored_member_pairs = {
(dataset, self._seed_group_member_key(seed)) for dataset, seed in existing_seeds if dataset
}
stored_member_keys = {self._seed_group_member_key(seed) for dataset, seed in existing_seeds if not dataset}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each stored seed now builds SeedEntry + JSON for the member key 2–3 times, plus a standalone key even with no standalone input. Reloading 3,000 stored seeds was ~1.6–2x slower than main. If member identity stays, compute once:

Suggested change
stored_member_pairs = {
(dataset, self._seed_group_member_key(seed)) for dataset, seed in existing_seeds if dataset
}
stored_member_keys = {self._seed_group_member_key(seed) for dataset, seed in existing_seeds if not dataset}
member_keys = [(dataset, self._seed_group_member_key(seed)) for dataset, seed in existing_seeds]
stored_member_pairs = {(dataset, key) for dataset, key in member_keys if dataset}
stored_member_keys = {key for dataset, key in member_keys if not dataset}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed: each stored seed's member key is computed once and passed to _get_new_seed_group_ids; the standalone-key sets are built in one pass and skipped entirely when no incoming seed needs them. On a 300-seed reload benchmark: main 27.0ms, old PR head 39.4ms, this change 36.6ms (the remaining cost is the execution-identity key itself).

Address review feedback: non-retained seeds (standalone and singleton) share one standalone execution key including role; the dual member/standalone key sets and the incoming-order dependency are gone; stored keys are computed once and only when needed. Add schema/role and metadata-only dedup regressions. tests/unit/memory: 1927 passed (one pre-existing SQLite index-plan failure unrelated to this change); tests/unit/datasets: 5280 passed; ruff clean.
Copilot AI balanced review requested due to automatic review settings November 3, 2026 01:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Singleton prompt copies with a changed group and response schema can reuse an existing seed primary key, causing an IntegrityError and rolling back the batch.

1 open issue
What changed in this PR

Addresses #3738 by refining PyRIT’s seed deduplication so execution-distinct seeds are preserved while equivalent seeds remain deduplicated.

Changes:

  • Includes seed type, data type, role, response schema, and behavioral inputs in standalone and singleton execution identity.
  • Reuses stored member identities for complete-group comparisons and documents the deduplication rules.
  • Adds seven regression tests covering distinct identities, exact duplicates, and metadata-only changes across grouped and standalone seeds.
File Description
tests/​unit/​memory/​memory_interface/​test_interface_seed_dedup_identity.py Adds regression coverage for execution identity and duplicate handling.
pyrit/​memory/​memory_interface.py Refines deduplication keys, reuses group-member identities, and updates the method docstring.

Review effort: Balanced

Comment thread pyrit/memory/memory_interface.py Resolved
A stored seed whose execution identity changes (e.g. response schema) is inserted as a new row, but it kept its old primary key, so the insert raised IntegrityError and rolled back the batch. Look up stored ids for every incoming seed (not only retained groups) and regenerate the row id for a non-retained copy before insertion, updating the caller-visible id only after the insert succeeds. Add order-symmetry and singleton-copy regressions.
Comment thread pyrit/memory/memory_interface.py Resolved
A stored grouped seed that is copied back after an execution edit keeps its original prompt_group_id, so the copy silently joins the stored seed group and a schema or role edit corrupts that group. Generate a new group id for such a copy before insert, publish it back to the caller-visible prompt after insert succeeds, and add role/response-schema regressions that assert the original group stays intact.
Comment on lines +5121 to +5122
if prompt.prompt_group_id is not None and stored_groups[prompt.id] == prompt.prompt_group_id:
entry.prompt_group_id = uuid.uuid4()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Objectives never reach this copy path: a stored single-seed objective re-added with edited conditions is still silently dropped. _get_new_behavior_group_ids retains its one-seed group, so the 5095 skip fires. Fix: retain only multi-seed groups (lines 5287-5290, outside the diff):

        group_sizes = Counter(seed.prompt_group_id for seed in seeds)
        group_ids: set[uuid.UUID] = set()
        for seed in seeds:
            if (
                not isinstance(seed, SeedObjective)
                or seed.prompt_group_id is None
                or group_sizes[seed.prompt_group_id] < 2
                or not seed.value_sha256
            ):
                continue

Regression test (fails on head):

from pyrit.models import AnswerMatches, Seed, SeedGroup, SeedObjective, SeedPrompt


async def test_readding_objective_with_edited_conditions_keeps_both(sqlite_instance: MemoryInterface) -> None:
    """An objective re-added with edited conditions is execution-distinct, so both must stay."""

    def objective(answer: str) -> SeedObjective:
        return SeedObjective(
            value="capital?",
            dataset_name=DATASET,
            conditions=(AnswerMatches(correct_answer=answer, correct_answer_label="A"),),
        )

    await _add(sqlite_instance, *SeedGroup(seeds=[objective("Paris")]).seeds)
    stored = (await sqlite_instance.get_seeds_async(dataset_name=DATASET))[0]

    stored.conditions = objective("Lyon").conditions
    await _add(sqlite_instance, *SeedGroup(seeds=[stored]).seeds)

    groups = await sqlite_instance.get_seed_groups_async(dataset_name=DATASET)
    assert sorted(len(group.seeds) for group in groups) == [1, 1]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for catching the retained-singleton path. Fixed in 13d0e08: behavioral retention now applies only to multi-seed groups. The SQLite regression fails on the previous head and now verifies both singleton groups, new row/group IDs, and the unchanged Paris condition alongside the Lyon copy. The three related seed test files pass together (195 tests); Ruff and targeted ty checks pass.

Comment on lines +5016 to +5018
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The public docs still state the old rule. doc/code/memory/8_seed_database.py:27-30 and the paired .ipynb markdown cell are outside this diff, so this is plain text. Update both identically. Rule 2 assumes the conditions fix.

doc/code/memory/8_seed_database.py:

# 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 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

doc/code/memory/8_seed_database.ipynb:

    "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 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",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated both 8_seed_database.py and the paired notebook in 13d0e08 with the same execution-identity and copy semantics. The 11 paired cells match; only the existing Markdown cell changed, and notebook outputs and metadata are preserved.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Seed deduplication silently drops distinct seed types and singleton response schemas

3 participants