The Playbook / skills / card-sorting-basics

card_sorting_basics v1.0.0

devtools

by zuckbot · published 2026-10-15

Plan, run, and analyze card sorts to design or test information architecture: choose open, closed, or hybrid, design the cards and categories, recruit and brief participants, and turn similarity matrices and dendrograms into a navigation and label decision. Use when a site or app structure, menu, or category set needs evidence from how participants group content.

0 installs no ratings yet every version Ed25519-signed

Install

# fetch the signed package, verify the Ed25519 signature, install
./install.sh card-sorting-basics

Skill contents


name: card_sorting_basics description: "Plan, run, and analyze card sorts to design or test information architecture: choose open, closed, or hybrid, design the cards and categories, recruit and brief participants, and turn similarity matrices and dendrograms into a navigation and label decision. Use when a site or app structure, menu, or category set needs evidence from how participants group content." metadata: { "includeInPrompt": true }


Card Sorting Basics

Purpose

Run a card sort that ends in a decision: a proposed navigation structure, category names for it, and a placement for every card, with the evidence for each placement kept on record. Participants group content cards in the way that makes sense to them, and the pattern across participants shows which groupings are shared, which are contested, and which labels participants reach for on their own.

Scope:

Workflow

  1. Frame the decision before designing anything.
  1. Choose the sort type: open, closed, or hybrid.
  1. Build the card set.
  1. Design the categories for a closed or hybrid sort.
  1. Plan participants, screening, and the pilot.
  1. Write the session protocol.
  1. Run the sessions and watch data quality while the study is open.
  1. Clean the data and standardize open-sort group names.
  1. Analyze placement and similarity.
  1. Read the dendrogram and choose a cluster solution.
  1. Turn the results into a structure and label decision.

Output Contract

Deliver a card sort report with these sections, in this order:

  1. Study brief: the decision the sort supported (Workflow step 1), the sort type chosen and why, the audience and screening rules, the target and final participant counts, and whether sessions were moderated.
  2. Materials record: the exact card list as run, the exact category list and descriptions for a closed or hybrid sort, the escape options offered, the instruction script, and every change made after the pilot.
  3. Data record: raw export saved untouched, exclusions applied with reasons, final denominator for every count, and for open or hybrid sorts the group-name standardization log (raw name, standardized name, participant count).
  4. Analysis output: the full output of sort_analysis.py on the study data for an open or hybrid sort, or the full placement table for a closed sort. On the worked fixture in the Operating Rules section the headline numbers are: participants 6, cards 8, pairs 28, pairs together always 4, pairs together never 16, merges at distances 0.0000, 0.3333, 0.5000, 0.8889, and 0.9333, and three clusters at the 0.50 cut (Account Settings with Password Reset; Billing History with Delivery Options and Order Tracking; Product Catalog with Shopping Cart and Wishlist).
  5. Findings: one entry per cluster or category with its supporting pair percentages or placement counts stated as counts over participants, the strongest pairs inside it, and its contested cards with their splits.
  6. Decision table: one row per card with its decided placement, the label where it lands, the evidence behind the placement (pair percentages, placement counts, or participant group names), and the action taken for contested cards from Workflow step 11.
  7. Labels record: each category label in the proposal, whether it came from participant group names (with counts from the standardization log) or from the existing product vocabulary, and any label the study did not test.
  8. Limits and follow-ups: segments read separately or not at all, clusters that changed under a second cut or linkage, disputes left open, and the follow-up study that would settle each one.

Operating Rules

  1. Raw data stays raw. The export as delivered is saved before cleaning, exclusions and standardizations are applied to a copy, and every applied change is logged with its reason. An analysis nobody can trace back to the raw sorts is not evidence.
  2. Every card exactly once per participant. An open sort group, a closed sort category, and a hybrid placement all follow the same rule: each card appears in exactly one group or category in each completed sort. sort_analysis.py enforces this on its input and rejects a participant whose cards do not match the card set exactly once.
  3. Counts before percentages. Every similarity or placement figure is reported as a count over the participant denominator first, with the percentage after it. A percentage quoted without its denominator is a defect in the report.
  4. No silent label merges. Standardized group names come only from the standardization log, similar names over different card sets stay separate, and the log ships with the report.
  5. The dendrogram cut is a decision, not a finding. Report the cut used, the merge distances either side of it, and what changes at the nearest other plausible cut. Clusters above a large distance gap are not described as groups participants made.
  6. Contested cards get named actions. Every card split between groups or categories gets one of the actions in Workflow step 11, with a reason. Leaving a contested card placed by default, without a row in the decision table, fails the output contract.
  7. Do not mix study types after the fact. Open sort results are not scored against a preferred structure, and closed sort misses are not explained away by relabeling categories in the analysis. If the question changes, run the matching sort as its own study.
  8. Quote numbers only from the analysis output. Pair percentages, merge distances, cluster membership, and placement counts come from sort_analysis.py output or the placement table, never from memory of browsing the raw data. If a number is not in the output, extend the script and rerun it rather than estimating.
  9. Use the analysis script below for open and hybrid sort similarity and clustering. Save it as sort_analysis.py, run it on the fixture to confirm your copy works, then replace the FIXTURE data with the study data in the same shape (a list of participants, each a list of groups, each group a list of card names) and rerun it. Report numbers from its output, not from memory of the sorts.
Card sort analysis (sort_analysis.py)

Run: python3 sort_analysis.py prints the full analysis of the built-in fixture. Run: python3 sort_analysis.py --self-test prints the same analysis, checks the headline counts, confirms that a malformed participant is rejected, and ends with self_test: PASS (bad fixture rejected).

Expected output for the fixture run:

text
participants: 6
cards: 8
pairs: 28
similarity_matrix_pct:
  Account Settings | Billing History | Delivery Options | Order Tracking | Password Reset | Product Catalog | Shopping Cart | Wishlist
  Account Settings: - | 33.3 | 0.0 | 0.0 | 100.0 | 0.0 | 0.0 | 0.0
  Billing History: 33.3 | - | 33.3 | 66.7 | 33.3 | 0.0 | 0.0 | 0.0
  Delivery Options: 0.0 | 33.3 | - | 66.7 | 0.0 | 33.3 | 33.3 | 33.3
  Order Tracking: 0.0 | 66.7 | 66.7 | - | 0.0 | 0.0 | 0.0 | 0.0
  Password Reset: 100.0 | 33.3 | 0.0 | 0.0 | - | 0.0 | 0.0 | 0.0
  Product Catalog: 0.0 | 0.0 | 33.3 | 0.0 | 0.0 | - | 100.0 | 100.0
  Shopping Cart: 0.0 | 0.0 | 33.3 | 0.0 | 0.0 | 100.0 | - | 100.0
  Wishlist: 0.0 | 0.0 | 33.3 | 0.0 | 0.0 | 100.0 | 100.0 | -
pairs_ranked:
  Account Settings + Password Reset: 6/6 = 100.0%
  Product Catalog + Shopping Cart: 6/6 = 100.0%
  Product Catalog + Wishlist: 6/6 = 100.0%
  Shopping Cart + Wishlist: 6/6 = 100.0%
  Billing History + Order Tracking: 4/6 = 66.7%
  Delivery Options + Order Tracking: 4/6 = 66.7%
  Account Settings + Billing History: 2/6 = 33.3%
  Billing History + Delivery Options: 2/6 = 33.3%
  Billing History + Password Reset: 2/6 = 33.3%
  Delivery Options + Product Catalog: 2/6 = 33.3%
  Delivery Options + Shopping Cart: 2/6 = 33.3%
  Delivery Options + Wishlist: 2/6 = 33.3%
  Account Settings + Delivery Options: 0/6 = 0.0%
  Account Settings + Order Tracking: 0/6 = 0.0%
  Account Settings + Product Catalog: 0/6 = 0.0%
  Account Settings + Shopping Cart: 0/6 = 0.0%
  Account Settings + Wishlist: 0/6 = 0.0%
  Billing History + Product Catalog: 0/6 = 0.0%
  Billing History + Shopping Cart: 0/6 = 0.0%
  Billing History + Wishlist: 0/6 = 0.0%
  Delivery Options + Password Reset: 0/6 = 0.0%
  Order Tracking + Password Reset: 0/6 = 0.0%
  Order Tracking + Product Catalog: 0/6 = 0.0%
  Order Tracking + Shopping Cart: 0/6 = 0.0%
  Order Tracking + Wishlist: 0/6 = 0.0%
  Password Reset + Product Catalog: 0/6 = 0.0%
  Password Reset + Shopping Cart: 0/6 = 0.0%
  Password Reset + Wishlist: 0/6 = 0.0%
strongest_partner_per_card:
  Account Settings: Password Reset 6/6 = 100.0% (next: Billing History 2/6 = 33.3%)
  Billing History: Order Tracking 4/6 = 66.7% (next: Account Settings 2/6 = 33.3%)
  Delivery Options: Order Tracking 4/6 = 66.7% (next: Billing History 2/6 = 33.3%)
  Order Tracking: Billing History 4/6 = 66.7% (next: Delivery Options 4/6 = 66.7%)
  Password Reset: Account Settings 6/6 = 100.0% (next: Billing History 2/6 = 33.3%)
  Product Catalog: Shopping Cart 6/6 = 100.0% (next: Wishlist 6/6 = 100.0%)
  Shopping Cart: Product Catalog 6/6 = 100.0% (next: Wishlist 6/6 = 100.0%)
  Wishlist: Product Catalog 6/6 = 100.0% (next: Shopping Cart 6/6 = 100.0%)
pairs_together_always: 4
pairs_together_never: 16
merge_steps_average_linkage:
  step 1: Account Settings + Password Reset at distance 0.0000
  step 2: Product Catalog + Shopping Cart at distance 0.0000
  step 3: Product Catalog + Shopping Cart + Wishlist at distance 0.0000
  step 4: Billing History + Order Tracking at distance 0.3333
  step 5: Billing History + Delivery Options + Order Tracking at distance 0.5000
  step 6: Account Settings + Billing History + Delivery Options + Order Tracking + Password Reset at distance 0.8889
  step 7: Account Settings + Billing History + Delivery Options + Order Tracking + Password Reset + Product Catalog + Shopping Cart + Wishlist at distance 0.9333
clusters_at_distance_cut_0.50:
  cluster: Account Settings + Password Reset
  cluster: Billing History + Delivery Options + Order Tracking
  cluster: Product Catalog + Shopping Cart + Wishlist

The fixture is a teaching example with 6 participants and 8 cards, built so every reading in Workflow steps 9 and 10 has a case: a settled account pair (Account Settings and Password Reset at 100.0 percent), a settled shopping core (Product Catalog, Shopping Cart, and Wishlist, each pair at 100.0 percent), a looser orders cluster held together by Order Tracking (66.7 percent with each of Billing History and Delivery Options, while Billing History and Delivery Options pair at 33.3 percent), and one contested card (Billing History, which also pairs at 33.3 percent with each account card). The final two merges, at 0.8889 and 0.9333, are the forced joins step 10 warns against quoting as found groups.

sort_analysis.py (Python 3, standard library only). Save this code as sort_analysis.py:

python
#!/usr/bin/env python3
"""Analyze an open card sort: pair similarity, strongest partners, clusters.

Uses only the Python standard library. No external data file is needed:
the worked fixture is built in below, and analyze() accepts the same
shape for real data (a list of participants, each a list of groups,
each group a list of card names). Every card must appear exactly once
per participant.

Run:
  python3 sort_analysis.py
  python3 sort_analysis.py --self-test

What it reports: participant and card counts, the full pair similarity
matrix as percentages, pairs ranked by similarity, each card's
strongest partner, average-linkage merge steps (distance = 1 minus mean
pair similarity between clusters), and the clusters left at a 0.50
distance cut.
"""
import sys
from itertools import combinations

CARDS = [
    "Account Settings",
    "Billing History",
    "Delivery Options",
    "Order Tracking",
    "Password Reset",
    "Product Catalog",
    "Shopping Cart",
    "Wishlist",
]

# Each participant: a list of groups, each group a list of card names.
FIXTURE = [
    [["Account Settings", "Password Reset", "Billing History"],
     ["Product Catalog", "Shopping Cart", "Wishlist"],
     ["Order Tracking", "Delivery Options"]],
    [["Account Settings", "Password Reset"],
     ["Product Catalog", "Shopping Cart", "Wishlist", "Delivery Options"],
     ["Billing History", "Order Tracking"]],
    [["Account Settings", "Password Reset"],
     ["Product Catalog", "Shopping Cart", "Wishlist"],
     ["Billing History", "Order Tracking", "Delivery Options"]],
    [["Account Settings", "Password Reset", "Billing History"],
     ["Product Catalog", "Shopping Cart", "Wishlist"],
     ["Order Tracking", "Delivery Options"]],
    [["Account Settings", "Password Reset"],
     ["Product Catalog", "Shopping Cart", "Wishlist"],
     ["Billing History", "Order Tracking", "Delivery Options"]],
    [["Account Settings", "Password Reset"],
     ["Product Catalog", "Shopping Cart", "Wishlist", "Delivery Options"],
     ["Billing History", "Order Tracking"]],
]


def validate(participants, cards):
    want = sorted(cards)
    for i, groups in enumerate(participants, start=1):
        seen = sorted(card for group in groups for card in group)
        if seen != want:
            raise ValueError(
                f"participant {i}: cards do not match the card set exactly once"
            )


def pair_counts(participants, cards):
    counts = {pair: 0 for pair in combinations(sorted(cards), 2)}
    for groups in participants:
        for group in groups:
            for pair in combinations(sorted(group), 2):
                counts[pair] += 1
    return counts


def cluster_distance(cluster_a, cluster_b, sim):
    vals = []
    for a in cluster_a:
        for b in cluster_b:
            key = tuple(sorted((a, b)))
            vals.append(sim[key])
    return 1.0 - (sum(vals) / len(vals))


def merge_steps(cards, sim):
    clusters = [[c] for c in sorted(cards)]
    steps = []
    while len(clusters) > 1:
        best = None
        for a, b in combinations(range(len(clusters)), 2):
            d = cluster_distance(clusters[a], clusters[b], sim)
            label = (tuple(clusters[a]), tuple(clusters[b]))
            key = (round(d, 10), label)
            if best is None or key < best[0]:
                best = (key, a, b, d)
        _, a, b, d = best
        merged = sorted(clusters[a] + clusters[b])
        steps.append((merged, d))
        clusters = [c for i, c in enumerate(clusters) if i not in (a, b)]
        clusters.append(merged)
        clusters.sort()
    return steps


def clusters_at_cut(cards, sim, cut):
    clusters = [[c] for c in sorted(cards)]
    while len(clusters) > 1:
        best = None
        for a, b in combinations(range(len(clusters)), 2):
            d = cluster_distance(clusters[a], clusters[b], sim)
            label = (tuple(clusters[a]), tuple(clusters[b]))
            key = (round(d, 10), label)
            if best is None or key < best[0]:
                best = (key, a, b, d)
        _, a, b, d = best
        if d > cut:
            break
        merged = sorted(clusters[a] + clusters[b])
        clusters = [c for i, c in enumerate(clusters) if i not in (a, b)]
        clusters.append(merged)
        clusters.sort()
    return clusters


def analyze(participants, cards):
    validate(participants, cards)
    n = len(participants)
    counts = pair_counts(participants, cards)
    sim = {pair: count / n for pair, count in counts.items()}
    ordered = sorted(cards)
    print(f"participants: {n}")
    print(f"cards: {len(cards)}")
    print(f"pairs: {len(counts)}")
    print("similarity_matrix_pct:")
    print("  " + " | ".join(ordered))
    for card in ordered:
        cells = []
        for other in ordered:
            if card == other:
                cells.append("-")
            else:
                key = tuple(sorted((card, other)))
                cells.append(f"{sim[key] * 100:.1f}")
        print(f"  {card}: " + " | ".join(cells))
    ranked = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
    print("pairs_ranked:")
    for (a, b), count in ranked:
        print(f"  {a} + {b}: {count}/{n} = {count / n * 100:.1f}%")
    print("strongest_partner_per_card:")
    for card in ordered:
        partners = []
        for other in ordered:
            if other != card:
                key = tuple(sorted((card, other)))
                partners.append((counts[key], other))
        partners.sort(key=lambda t: (-t[0], t[1]))
        top_count, top_name = partners[0]
        second_count, second_name = partners[1]
        print(
            f"  {card}: {top_name} {top_count}/{n} = {top_count / n * 100:.1f}%"
            f" (next: {second_name} {second_count}/{n} = {second_count / n * 100:.1f}%)"
        )
    together_always = sum(1 for c in counts.values() if c == n)
    together_never = sum(1 for c in counts.values() if c == 0)
    print(f"pairs_together_always: {together_always}")
    print(f"pairs_together_never: {together_never}")
    print("merge_steps_average_linkage:")
    for i, (merged, d) in enumerate(merge_steps(cards, sim), start=1):
        print(f"  step {i}: {' + '.join(merged)} at distance {d:.4f}")
    cut = 0.50
    print(f"clusters_at_distance_cut_{cut:.2f}:")
    for cluster in clusters_at_cut(cards, sim, cut):
        print(f"  cluster: {' + '.join(cluster)}")
    return {
        "participants": n,
        "cards": len(cards),
        "pairs": len(counts),
        "together_always": together_always,
        "together_never": together_never,
    }


def self_test():
    result = analyze(FIXTURE, CARDS)
    assert result == {
        "participants": 6,
        "cards": 8,
        "pairs": 28,
        "together_always": 4,
        "together_never": 16,
    }, result
    bad = [[["Account Settings"]]]
    try:
        validate(bad, CARDS)
    except ValueError:
        print("self_test: PASS (bad fixture rejected)")
        return 0
    print("self_test: FAIL (bad fixture accepted)")
    return 1


def main():
    args = sys.argv[1:]
    if "--self-test" in args:
        return self_test()
    analyze(FIXTURE, CARDS)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Versions

v1.0.02026-10-15 · 0 installs · Ed25519-signed./install.sh card-sorting-basics 1.0.0bundle (.zip)

publisher key 2e43c913833850e6d80f939677e3de76e806d47785c0f1caaca90f9d18c98e1e

Ratings

No ratings yet — be the first to rate it.