{
  "id": "#1108",
  "pr": 1108,
  "title": "perf: SpecifierSet use tuple instead of frozenset for `_specs`",
  "merged": "2026-03-07",
  "after_ultra_release": false,
  "known": [
    {
      "id": "dedup-equality",
      "match": {
        "probe": "P3",
        "detail": "eq"
      },
      "fixed_by": 1109,
      "status": "verified"
    },
    {
      "id": "dedup-str-len",
      "match": {
        "probe": "P1"
      },
      "fixed_by": 1109,
      "status": "verified"
    },
    {
      "id": "old-pickles-break-comparison",
      "match": {
        "probe": "P4"
      },
      "fixed_by": "26.2",
      "status": "verified"
    }
  ],
  "provider": "tokenfactory",
  "targets": [
    {
      "target": "packaging.specifiers:SpecifierSet",
      "image": "052dfee2-22cb-4d9b-b081-e27b77a69db0",
      "backend": "contree",
      "run_id": "adj-1108-ec38bb",
      "samples": 6,
      "usd": 0.0697,
      "verdicts": [
        {
          "signature": "P1|len|differs|plain",
          "label": "likely-bug",
          "votes": {
            "likely-bug": 3
          },
          "rationale": "SpecifierSet now deduplicates on raw strings (dict.fromkeys) instead of on canonical Specifier equality, so two specifiers that are equal after canonicalization (e.g. '>=1.0' and '>=1.0.0') are both kept, changing len().",
          "evidence": [
            "self._specs: tuple[Specifier, ...] = tuple(map(Specifier, dict.fromkeys(split_specifiers)))",
            "Specifier('==1.2.3') == Specifier('== 1.2.3.0') True",
            "self._specs = frozenset(map(Specifier, split_specifiers))",
            "-            self._specs = frozenset(map(Specifier, split_specifiers))",
            "+            self._specs: tuple[Specifier, ...] = tuple(map(Specifier, dict.fromkeys(split_specifiers)))",
            "def __len__(self) -> int: ... return len(self._specs)"
          ],
          "rejected": [],
          "reason": "",
          "known": [
            "dedup-str-len"
          ],
          "facts": "Output `len` differs. Input [\">=1.0,>=1.0.0\"]: base gives 1, head gives 2."
        },
        {
          "signature": "P1|len|differs|whitespace",
          "label": "likely-bug",
          "votes": {
            "likely-bug": 3
          },
          "rationale": "Same root cause as C1 – whitespace differences do not affect string‑level deduplication, so both specifiers remain.",
          "evidence": [
            "self._specs: tuple[Specifier, ...] = tuple(map(Specifier, dict.fromkeys(split_specifiers)))",
            "Specifier('==1.2.3') == Specifier('== 1.2.3.0') True",
            "self._specs = frozenset(map(Specifier, split_specifiers))",
            "-            self._specs = frozenset(map(Specifier, split_specifiers))",
            "+            self._specs: tuple[Specifier, ...] = tuple(map(Specifier, dict.fromkeys(split_specifiers)))",
            "def __len__(self) -> int: ... return len(self._specs)"
          ],
          "rejected": [],
          "reason": "",
          "known": [
            "dedup-str-len"
          ],
          "facts": "Output `len` differs. Input [\">= 1.0,>=1.0.0\"]: base gives 1, head gives 2."
        },
        {
          "signature": "P1|len|differs|non-ascii+whitespace",
          "label": "likely-bug",
          "votes": {
            "likely-bug": 3
          },
          "rationale": "Leading/trailing spaces are stripped before dict.fromkeys, but the two specifiers are still distinct strings, so both survive.",
          "evidence": [
            "split_specifiers = [s.strip() for s in specifiers.split(',') if s.strip()]",
            "Specifier('==1.2.3') == Specifier('== 1.2.3.0') True",
            "self._specs = frozenset(map(Specifier, split_specifiers))",
            "self._specs: tuple[Specifier, ...] = tuple(map(Specifier, dict.fromkeys(split_specifiers)))",
            "-            self._specs = frozenset(map(Specifier, split_specifiers))",
            "+            self._specs: tuple[Specifier, ...] = tuple(map(Specifier, dict.fromkeys(split_specifiers)))",
            "def __len__(self) -> int: ... return len(self._specs)"
          ],
          "rejected": [],
          "reason": "",
          "known": [
            "dedup-str-len"
          ],
          "facts": "Output `len` differs. Input [\" >=1.0,>=1.0.0 \"]: base gives 1, head gives 2."
        },
        {
          "signature": "P1|str|differs|plain",
          "label": "likely-bug",
          "votes": {
            "likely-bug": 3
          },
          "rationale": "__str__ joins the sorted tuple of specs; with two retained specifiers the string now contains both.",
          "evidence": [
            "return ','.join(str(s) for s in self._sort_specs())",
            "self._specs: tuple[Specifier, ...] = tuple(map(Specifier, dict.fromkeys(split_specifiers)))",
            "-            self._specs = frozenset(map(Specifier, split_specifiers))",
            "+            self._specs: tuple[Specifier, ...] = tuple(map(Specifier, dict.fromkeys(split_specifiers)))",
            "def __str__(self) -> str: ... return ','.join(str(s) for s in self._sort_specs())"
          ],
          "rejected": [],
          "reason": "",
          "known": [
            "dedup-str-len"
          ],
          "facts": "Output `str` differs. Input [\">=1.0,>=1.0.0\"]: base gives \">=1.0\", head gives \">=1.0,>=1.0.0\"."
        },
        {
          "signature": "P1|str|differs|whitespace",
          "label": "likely-bug",
          "votes": {
            "likely-bug": 3
          },
          "rationale": "Same as C4 – whitespace does not change the fact that two distinct strings survive deduplication.",
          "evidence": [
            "return ','.join(str(s) for s in self._sort_specs())",
            "self._specs: tuple[Specifier, ...] = tuple(map(Specifier, dict.fromkeys(split_specifiers)))",
            "-            self._specs = frozenset(map(Specifier, split_specifiers))",
            "+            self._specs: tuple[Specifier, ...] = tuple(map(Specifier, dict.fromkeys(split_specifiers)))",
            "def __str__(self) -> str: ... return ','.join(str(s) for s in self._sort_specs())"
          ],
          "rejected": [],
          "reason": "",
          "known": [
            "dedup-str-len"
          ],
          "facts": "Output `str` differs. Input [\">= 1.0,>=1.0.0\"]: base gives \">=1.0\", head gives \">=1.0,>=1.0.0\"."
        },
        {
          "signature": "P1|str|differs|non-ascii+whitespace",
          "label": "likely-bug",
          "votes": {
            "likely-bug": 3
          },
          "rationale": "Same as C4/C5 – extra spaces are stripped before dict.fromkeys, leaving two entries.",
          "evidence": [
            "return ','.join(str(s) for s in self._sort_specs())",
            "self._specs: tuple[Specifier, ...] = tuple(map(Specifier, dict.fromkeys(split_specifiers)))",
            "-            self._specs = frozenset(map(Specifier, split_specifiers))",
            "+            self._specs: tuple[Specifier, ...] = tuple(map(Specifier, dict.fromkeys(split_specifiers)))",
            "def __str__(self) -> str: ... return ','.join(str(s) for s in self._sort_specs())"
          ],
          "rejected": [],
          "reason": "",
          "known": [
            "dedup-str-len"
          ],
          "facts": "Output `str` differs. Input [\" >=1.0,>=1.0.0 \"]: base gives \">=1.0\", head gives \">=1.0,>=1.0.0\"."
        },
        {
          "signature": "P2|outcome|exc:builtins.AssertionError|ok|long-digits",
          "label": "needs-human",
          "votes": {
            "needs-human": 3
          },
          "rationale": "The change from frozenset to tuple does not modify the comparison logic for a single '>=' specifier, yet the observed AssertionError disappears. No documentation or test change explains this, so the cause is unclear.",
          "evidence": [],
          "rejected": [],
          "reason": "model abstained",
          "known": [],
          "facts": "Outcome differs. Input [\">=1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000... (4317 chars): base raises builtins.AssertionError, head returns normally."
        },
        {
          "signature": "P3|eq|True|False",
          "label": "likely-bug",
          "votes": {
            "likely-bug": 3
          },
          "rationale": "Equality of SpecifierSet now depends on the tuple length after string‑level deduplication; the two sets have different lengths (2 vs 1) and therefore compare False, whereas the old frozenset canonicalized them to a single element and compared True.",
          "evidence": [
            "if len(self._specs) != len(other._specs): return False",
            "self._specs: tuple[Specifier, ...] = tuple(map(Specifier, dict.fromkeys(split_specifiers)))",
            "return self._sort_specs() == other._sort_specs()",
            "-            self._specs = frozenset(map(Specifier, split_specifiers))",
            "+            self._specs: tuple[Specifier, ...] = tuple(map(Specifier, dict.fromkeys(split_specifiers)))"
          ],
          "rejected": [
            "def __eq__(self, other): ... if len(self._specs) != len(other._specs): return False; return self._sort_specs() == other._sort_specs()"
          ],
          "reason": "",
          "known": [
            "dedup-equality"
          ],
          "facts": "Relation `a == b` differs for a = target(*[\">=1.0,>=1.0.0\"]), b = target(*[\">=1.0.0,>=1.0.0\"]): base True, head False."
        },
        {
          "signature": "P3|hash_eq|True|False",
          "label": "likely-bug",
          "votes": {
            "likely-bug": 3
          },
          "rationale": "Specifier equality is based on a canonical form that strips trailing zeros (e.g., '>=1.0' == '>=1.0.0'). The old SpecifierSet stored specifiers in a frozenset, which deduplicated equal specifiers, while the new implementation deduplicates only on the raw strings (dict.fromkeys(split_specifiers)), so two equal specifiers remain as two items. Consequently the hash of a set containing '>=1.0,>=1.0.0' differs from one containing '>=1.0.0,>=1.0' under the new code.",
          "evidence": [
            "return self._canonical_spec == other._canonical_spec",
            "canonicalize_version(spec_version, strip_trailing_zero=(operator != '~='))",
            "self._specs: tuple[Specifier, ...] = tuple(map(Specifier, dict.fromkeys(split_specifiers)))",
            "-    def __hash__(self) -> int:",
            "-        return hash(self._specs)",
            "def __hash__(self) -> int: return hash(self._sort_specs())",
            "def _sort_specs(self) -> tuple[Specifier, ...]: ... self._specs = tuple(sorted(self._specs, key=str))"
          ],
          "rejected": [
            "+    def __hash__(self) -> int: +        return hash(self._sort_specs())"
          ],
          "reason": "",
          "known": [],
          "facts": "Relation `hash(a) == hash(b)` differs for a = target(*[\">=1.0,>=1.0.0\"]), b = target(*[\">=1.0.0,>=1.0.0\"]): base True, head False."
        },
        {
          "signature": "P4|base->head eq-fresh|True|False",
          "label": "likely-bug",
          "votes": {
            "likely-bug": 3
          },
          "rationale": "The change adds a new slot '_sorted' and makes __hash__/__eq__ depend on it via _sort_specs. Objects pickled by the old version lack this slot; when unpickled by the new version the attribute is missing, causing an AttributeError during comparison.",
          "evidence": [
            "__slots__ = ( '_prereleases', '_sorted', '_specs')",
            "def __hash__(self) -> int: return hash(self._sort_specs())",
            "-    __slots__ = (",
            "-        '_prereleases',",
            "self._specs: tuple[Specifier, ...] = tuple(map(Specifier, dict.fromkeys(split_specifiers)))",
            "def __eq__(self, other: object) -> bool: ... return self._sort_specs() == other._sort_specs()",
            "def _sort_specs(self) -> tuple[Specifier, ...]: ... self._specs = tuple(sorted(self._specs, key=str))"
          ],
          "rejected": [
            "def _sort_specs(self) -> tuple[Specifier, ...]: if not self._sorted: self._specs = tuple(sorted(self._specs, key=str)); self._sorted = True; return self._specs",
            "-        '_specs' ) +    __slots__ = ( '_prereleases', '_sorted', '_specs' )"
          ],
          "reason": "",
          "known": [
            "old-pickles-break-comparison"
          ],
          "facts": "An object built by base from [\">=1.0,>=1.0.0\"], pickled, then unpickled by head loads, but no longer equals a freshly built head object (== gives False)."
        },
        {
          "signature": "P4|base->head eq-fresh|True|<exc:builtins.AttributeError>",
          "label": "likely-bug",
          "votes": {
            "likely-bug": 3
          },
          "rationale": "Same root cause as C2: the new '_sorted' slot is absent on objects pickled by the old implementation (including an empty SpecifierSet), leading to AttributeError when the unpickled object participates in equality checks.",
          "evidence": [
            "__slots__ = ( '_prereleases', '_sorted', '_specs')",
            "def __eq__(self, other: object) -> bool: ... return self._sort_specs() == other._sort_specs()",
            "+        self._sorted = len(self._specs) <= 1",
            "    def _sort_specs(self) -> tuple[Specifier, ...]:",
            "        if not self._sorted:",
            "__slots__ = (..., '_sorted', '_specs')",
            "self._sorted = len(self._specs) <= 1"
          ],
          "rejected": [
            "def _sort_specs(self) -> tuple[Specifier, ...]: if not self._sorted: self._specs = tuple(sorted(self._specs, key=str)); self._sorted = True; return self._specs",
            "def _sort_specs(self) -> tuple[Specifier, ...]: if not self._sorted: ..."
          ],
          "reason": "",
          "known": [
            "old-pickles-break-comparison"
          ],
          "facts": "An object built by base from [\"\"], pickled, then unpickled by head loads, but comparing it with a freshly built head object fails: <exc:builtins.AttributeError>."
        }
      ]
    }
  ]
}