-
Type:
Bug
-
Resolution: Fixed
-
Priority:
Normal
-
Affects Version/s: 3.0.2
-
Component/s: User Interface
-
None
Summary
ArticleInsensitiveAdapter (custom column "article-insensitive" sorting) produces a non-deterministic sort order. For values that reduce to the same article-stripped tail (e.g. "Artist" vs "An Artist", "The Beatles" vs "Beatles"), the resulting order depends on the input list order and on the ICU version Qt is linked against. This is the underlying cause of the intermittent failure of test_article_insensitive_adapter observed when building against Qt 6.12 (ICU 76+/78).
Environment
- Reproduced on:
- Qt 6.11.0 / ICU 74.2 (Unicode 15.1, CLDR 44.1)
- Qt 6.10.2 / ICU 76.1 (Fedora 42)
- Qt 6.12.0 / ICU 78.3 (Arch)
- Collator backend: qt (ACTIVE_COLLATOR == 'qt', i.e. non-Windows / QCollator.sortKey path).
- Not Windows-specific: the strxfrm / string backends return plain comparable values and are unaffected.
Root cause
The adapter builds a composite (tuple) sort key and relies on it to tie-break:
def sort_key(self, obj: Item) -> Comparable: v = (self._base.evaluate(obj) or "").strip() lv = v.casefold() for art in self._articles: prefix = f"{art} " if lv.startswith(prefix): return (_sort_key(lv[len(prefix):]), _sort_key(prefix)) return (_sort_key(lv), _sort_key(""))
With the Qt backend, _sort_key() returns a QCollatorSortKey. That type provides compare() and a < operator, but does not implement a meaningful == (it falls back to identity). Python tuple comparison uses == on each element to decide whether to advance to the next element. Two sort keys built from equal strings therefore compare as "not equal", so the second (tie-break) element is never consulted and the final order is left to sort stability — i.e. it depends on the input order.
A short demonstration (any platform, qt collator):
from picard.i18n.collate import sort_key a = sort_key("thing") b = sort_key("thing") a < b # False b < a # False a == b # False <-- should be True; this is the bug
Note that QCollatorSortKey.compare() itself is correct and stable across ICU versions:
QCollator.sortKey("").compare(sortKey("an ")):
ICU 74.2 -> -41
ICU 76.1 -> -42
ICU 78.3 -> -42
So the comparison primitive is fine; the defect is using a type without working == as a non-final tuple element. The differing key bytes across ICU versions merely change which input permutations misbehave, which is why the test looked like a "Qt 6.11 vs 6.12 order change".
Steps to reproduce
Sort all permutations of ["The Beatles", "Beatles", "An Artist", "Artist"] through ArticleInsensitiveAdapter with the qt collator and collect the distinct outputs.
PICARD_COLLATOR=qt pytest test/ui/test_custom_columns_sorting_adapters.py
Observed (original code)
Four distinct outputs depending on input order, on both ICU 74 and ICU 78:
DISTINCT_OUTPUTS 4 An Artist | Artist | Beatles | The Beatles An Artist | Artist | The Beatles | Beatles Artist | An Artist | Beatles | The Beatles Artist | An Artist | The Beatles | Beatles
A second, independent symptom: values that are identical for sorting can be split across an unrelated value. Sorting ["a thing", "thing", "A thing"] yields "a thing" and "A thing" on opposite sides of "thing", even though both reduce to the same tail.
Expected
A single, deterministic order, independent of input order and ICU/Qt version:
DISTINCT_OUTPUTS 1 Artist | An Artist | Beatles | The Beatles
Impact
- Column sorting with the article-insensitive option is unstable; rows reorder unpredictably and identical-for-sorting values do not group together.
- The regression is intermittent, so a naive test fix that hard-codes an expected order per Qt version (branching on QT_VERSION) only masks the symptom for one input permutation and remains fragile.
Proposed fix
- In picard/i18n/collate.py, wrap the Qt sort key in a small comparable type that implements {}eq _and }}{{lt_ via QCollatorSortKey.compare() (using functools.total_ordering). This makes collation keys safe to use as non-final elements of any composite sort key and fixes the whole class of bug for every consumer, not just this adapter.
- In picard/ui/itemviews/custom_columns/sorting_adapters.py, replace the collation-of-a-prefix tie-break with a deterministic, locale-independent one: (tail, has_article, article), where has_article is 0 for the bare form and 1 for an articled form, and article is the normalized leading article. This groups the bare form before the articled form and avoids collating the empty string entirely.
Notes
- The real trigger is the linked ICU version (Qt links system ICU on Linux), not the Qt version per se; gating behaviour on QT_VERSION is therefore an unreliable proxy.
- After the fix, test_article_insensitive_adapter needs a single expected order with no QT_VERSION branch, and a regression test should assert permutation stability and that same-tail values group contiguously.