File size: 5,097 Bytes
4519f8e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
"""The term cap must be even across concept classes, reproducible and nested."""

import term_selection as ts

CONCEPTS = [f"Concept {i}" for i in range(8)]


def _pool(indices: list[int], concepts: list[str] | None = None) -> dict[str, str]:
    """One term per (concept, index). Even indices only, so a later pool can
    interleave odd ones and expose a name-ordered implementation."""
    concepts = concepts or CONCEPTS
    return {
        f"term_{c}_{i:03d}": concept
        for c, concept in enumerate(concepts)
        for i in indices
    }


BASE = _pool(list(range(0, 40, 2)))  # 8 concepts x 20 terms


def test_cap_divides_evenly_across_concepts() -> None:
    selection = ts.select_terms_by_concept(BASE, max_terms=40)
    assert len(selection.terms) == 40
    assert set(selection.per_concept.values()) == {5}


def test_cap_that_does_not_divide_evenly_differs_by_one() -> None:
    selection = ts.select_terms_by_concept(BASE, max_terms=41)
    counts = selection.per_concept
    assert len(selection.terms) == 41
    assert sum(counts.values()) == 41
    assert max(counts.values()) - min(counts.values()) == 1


def test_a_small_concept_redistributes_its_slots() -> None:
    pool = dict(BASE)
    pool.update(_pool([0, 2], concepts=["Concept tiny"]))
    selection = ts.select_terms_by_concept(pool, max_terms=40)
    assert len(selection.terms) == 40
    assert selection.per_concept["Concept tiny"] == 2
    assert sum(selection.per_concept.values()) == 40


def test_shortfall_names_the_concept_that_ran_out() -> None:
    pool = dict(BASE)
    pool.update(_pool([0, 2], concepts=["Concept tiny"]))
    selection = ts.select_terms_by_concept(pool, max_terms=40)
    assert selection.shortfall_concepts() == ["Concept tiny"]


def test_a_cap_larger_than_the_pool_takes_everything_and_names_nobody() -> None:
    selection = ts.select_terms_by_concept(BASE, max_terms=1000)
    assert len(selection.terms) == len(BASE)
    assert selection.shortfall_concepts() == []


def test_shortfall_is_empty_when_cap_exactly_matches_the_pool() -> None:
    """A cap equal to the pool size still took everything -- same as a cap
    larger than the pool, or no cap at all -- so nothing was left out."""
    pool = {f"small_{i:03d}": "Small" for i in range(20)}
    pool.update({f"big_{i:03d}": "Big" for i in range(24)})
    selection = ts.select_terms_by_concept(pool, max_terms=len(pool))
    assert len(selection.terms) == len(pool)
    assert selection.shortfall_concepts() == []


def test_selection_is_nested_across_cap_sizes() -> None:
    """This is what keeps grader verdicts valid when a batch is widened."""
    larger = ts.select_terms_by_concept(BASE, max_terms=80).terms
    for smaller_n in (8, 16, 40, 64):
        smaller = ts.select_terms_by_concept(BASE, max_terms=smaller_n).terms
        assert larger[:smaller_n] == smaller


def test_selection_is_nested_with_a_non_multiple_cap_and_an_undersized_concept() -> (
    None
):
    """Slack redistribution -- what a small concept's leftover slots do -- is
    where a refactor is most likely to break nesting, and that arithmetic is
    only reachable with a cap that doesn't divide the concept count, over a
    pool with an undersized concept."""
    pool = dict(BASE)
    pool.update(_pool([0, 2], concepts=["Concept tiny"]))
    larger = ts.select_terms_by_concept(pool, max_terms=49).terms
    smaller = ts.select_terms_by_concept(pool, max_terms=41).terms
    assert larger[:41] == smaller


def test_result_does_not_depend_on_insertion_order() -> None:
    shuffled = dict(reversed(list(BASE.items())))
    assert ts.select_terms_by_concept(BASE, max_terms=40) == ts.select_terms_by_concept(
        shuffled, max_terms=40
    )


def test_adding_terms_keeps_most_of_the_existing_prefix() -> None:
    """Terms are ranked by hash of the term, not by position or name, so new
    terms displace only their share. A name-ordered implementation would put
    every odd index near the front and move 16 of the 40."""
    before = set(ts.select_terms_by_concept(BASE, max_terms=40).terms)
    grown = dict(BASE)
    grown.update(_pool([1, 3]))
    after = set(ts.select_terms_by_concept(grown, max_terms=40).terms)
    assert len(before & after) >= 32


def test_no_cap_selects_every_term() -> None:
    selection = ts.select_terms_by_concept(BASE)
    assert len(selection.terms) == len(BASE)
    assert selection.requested is None
    assert selection.shortfall_concepts() == []


def test_a_cap_of_zero_selects_nothing() -> None:
    selection = ts.select_terms_by_concept(BASE, max_terms=0)
    assert selection.terms == ()
    assert selection.per_concept == {}


def test_empty_pool() -> None:
    selection = ts.select_terms_by_concept({}, max_terms=10)
    assert selection.terms == ()
    assert selection.available == {}
    assert selection.shortfall_concepts() == []


def test_available_counts_the_whole_pool_not_the_sample() -> None:
    selection = ts.select_terms_by_concept(BASE, max_terms=8)
    assert set(selection.available.values()) == {20}
    assert set(selection.per_concept.values()) == {1}