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