"""Tests for markitect.analysis.fca.""" import pytest from markitect.analysis.fca import ( FormalContext, FormalConcept, ConceptLattice, find_gap_concepts, find_empty_cells, ) # ── Test data ──────────────────────────────────────────────────────── def _animal_context(): """Classic FCA example: animals × properties. Context: | animal | legs | wings | feathers | fur | |-----------|------|-------|----------|-----| | dog | x | | | x | | cat | x | | | x | | eagle | x | x | x | | | sparrow | x | x | x | | | penguin | x | | x | | """ return FormalContext( objects=["dog", "cat", "eagle", "sparrow", "penguin"], attributes=["legs", "wings", "feathers", "fur"], incidence={ "dog": {"legs", "fur"}, "cat": {"legs", "fur"}, "eagle": {"legs", "wings", "feathers"}, "sparrow": {"legs", "wings", "feathers"}, "penguin": {"legs", "feathers"}, }, ) def _infospace_context(): """Simplified infospace-style context: entities × {domain, vsm_system}. Entities with domain and VSM classification, including a gap: no entity has both domain:Exchange and vsm:S3. """ return FormalContext.from_dict({ "division-of-labour": {"domain:Production", "vsm:S1"}, "pin-factory": {"domain:Production", "vsm:S1"}, "market-extent": {"domain:Exchange", "vsm:S4"}, "wage-determination": {"domain:Distribution", "vsm:S3"}, "rent-theory": {"domain:Distribution", "vsm:S5"}, "capital-accumulation": {"domain:Production", "vsm:S3"}, }) def _empty_context(): """Context with no objects.""" return FormalContext([], ["a", "b"], {}) def _single_entity(): """Context with one object.""" return FormalContext(["only"], ["x", "y"], {"only": {"x", "y"}}) # ── FormalContext ──────────────────────────────────────────────────── class TestFormalContext: def test_objects_sorted(self): ctx = _animal_context() assert ctx.objects == sorted(ctx.objects) def test_attributes_sorted(self): ctx = _animal_context() assert ctx.attributes == sorted(ctx.attributes) def test_object_count(self): assert _animal_context().object_count == 5 def test_attribute_count(self): assert _animal_context().attribute_count == 4 def test_extent_single_attr(self): ctx = _animal_context() assert ctx.extent(["fur"]) == frozenset({"dog", "cat"}) def test_extent_multiple_attrs(self): ctx = _animal_context() assert ctx.extent(["wings", "feathers"]) == frozenset({"eagle", "sparrow"}) def test_extent_empty_returns_all(self): ctx = _animal_context() assert ctx.extent([]) == frozenset(ctx.objects) def test_extent_no_match(self): ctx = _animal_context() assert ctx.extent(["fur", "feathers"]) == frozenset() def test_intent_single_obj(self): ctx = _animal_context() assert ctx.intent(["penguin"]) == frozenset({"legs", "feathers"}) def test_intent_multiple_objs(self): ctx = _animal_context() # dog and cat share: legs, fur assert ctx.intent(["dog", "cat"]) == frozenset({"legs", "fur"}) def test_intent_empty_returns_all(self): ctx = _animal_context() assert ctx.intent([]) == frozenset(ctx.attributes) def test_closure_is_idempotent(self): ctx = _animal_context() c1 = ctx.closure({"fur"}) c2 = ctx.closure(c1) assert c1 == c2 def test_closure_expands(self): ctx = _animal_context() # fur → {dog, cat} → {legs, fur} (both have legs too) assert ctx.closure({"fur"}) == frozenset({"legs", "fur"}) def test_has_attribute(self): ctx = _animal_context() assert ctx.has_attribute("dog", "legs") is True assert ctx.has_attribute("dog", "wings") is False def test_density(self): ctx = _animal_context() # 5 objects × 4 attributes = 20 cells # dog:2, cat:2, eagle:3, sparrow:3, penguin:2 = 12 filled assert ctx.density() == pytest.approx(12 / 20) def test_density_empty(self): assert FormalContext([], [], {}).density() == 0.0 def test_from_dict(self): ctx = FormalContext.from_dict({ "a": {"x", "y"}, "b": {"y", "z"}, }) assert ctx.object_count == 2 assert ctx.attribute_count == 3 def test_unknown_attributes_ignored(self): ctx = FormalContext( ["a"], ["x"], {"a": {"x", "unknown"}} ) assert ctx.intent(["a"]) == frozenset({"x"}) # ── ConceptLattice ────────────────────────────────────────────────── class TestConceptLattice: def test_animal_concept_count(self): ctx = _animal_context() lattice = ConceptLattice.from_context(ctx) # Known: the animal context produces exactly 7 formal concepts # Top: ({all}, {legs}), Bottom: ({}, {all 4}), # plus intermediate concepts assert lattice.size >= 5 def test_top_has_all_objects(self): ctx = _animal_context() lattice = ConceptLattice.from_context(ctx) top = lattice.top assert top is not None assert top.extent == frozenset(ctx.objects) def test_top_intent_is_common_attributes(self): ctx = _animal_context() lattice = ConceptLattice.from_context(ctx) top = lattice.top # All animals have "legs" assert "legs" in top.intent def test_bottom_has_all_attributes(self): ctx = _animal_context() lattice = ConceptLattice.from_context(ctx) bottom = lattice.bottom assert bottom is not None assert bottom.intent == frozenset(ctx.attributes) def test_bottom_extent_empty_when_no_universal_object(self): ctx = _animal_context() lattice = ConceptLattice.from_context(ctx) bottom = lattice.bottom # No animal has all 4 attributes assert bottom.extent_size == 0 def test_all_concepts_are_closed(self): ctx = _animal_context() lattice = ConceptLattice.from_context(ctx) for concept in lattice.concepts: # intent should be closed: closure(intent) == intent assert ctx.closure(concept.intent) == concept.intent # extent' should equal intent assert ctx.intent(concept.extent) == concept.intent # intent' should equal extent assert ctx.extent(concept.intent) == concept.extent def test_empty_context(self): ctx = _empty_context() lattice = ConceptLattice.from_context(ctx) # Empty context → gap concepts for all attribute combinations assert lattice.size >= 1 def test_single_entity(self): ctx = _single_entity() lattice = ConceptLattice.from_context(ctx) # At least 1 concept containing the single entity has_entity = any( "only" in c.extent for c in lattice.concepts ) assert has_entity def test_no_attributes_produces_one_concept(self): ctx = FormalContext(["a", "b"], [], {}) lattice = ConceptLattice.from_context(ctx) assert lattice.size == 1 assert lattice.concepts[0].extent == frozenset({"a", "b"}) def test_depth(self): ctx = _animal_context() lattice = ConceptLattice.from_context(ctx) d = lattice.depth() # At least 2 levels (top → bottom) assert d >= 2 def test_depth_empty(self): lattice = ConceptLattice(concepts=[]) assert lattice.depth() == 0 # ── Gap concepts ──────────────────────────────────────────────────── class TestGapConcepts: def test_animal_has_gap(self): ctx = _animal_context() gaps = find_gap_concepts(ctx) # {fur, feathers} has no animal → gap concept fur_feathers_gap = any( {"fur", "feathers"} <= c.intent for c in gaps ) assert fur_feathers_gap def test_gap_extents_are_empty(self): ctx = _animal_context() gaps = find_gap_concepts(ctx) for gap in gaps: assert gap.extent_size == 0 def test_no_gaps_when_all_combinations_covered(self): # Every attribute combination has at least one object ctx = FormalContext.from_dict({ "obj1": {"a", "b"}, "obj2": {"a"}, "obj3": {"b"}, }) lattice = ConceptLattice.from_context(ctx) gaps = find_gap_concepts(ctx, lattice) assert len(gaps) == 0 def test_sorted_by_intent_size(self): ctx = _animal_context() gaps = find_gap_concepts(ctx) sizes = [g.intent_size for g in gaps] assert sizes == sorted(sizes) def test_infospace_gap(self): ctx = _infospace_context() gaps = find_gap_concepts(ctx) # domain:Exchange + vsm:S1 has no entity → should appear as gap gap_intents = [g.intent for g in gaps] exchange_s1_covered = any( {"domain:Exchange", "vsm:S1"} <= intent for intent in gap_intents ) assert exchange_s1_covered # ── Empty cells (cross-tab) ───────────────────────────────────────── class TestFindEmptyCells: def test_finds_empty_cells(self): ctx = _infospace_context() domains = ["domain:Production", "domain:Distribution", "domain:Exchange"] vsm_systems = ["vsm:S1", "vsm:S3", "vsm:S4", "vsm:S5"] empty = find_empty_cells(ctx, domains, vsm_systems) # domain:Exchange + vsm:S1 should be empty assert ("domain:Exchange", "vsm:S1") in empty # domain:Production + vsm:S1 should NOT be empty (division-of-labour) assert ("domain:Production", "vsm:S1") not in empty def test_all_filled_returns_empty_list(self): ctx = FormalContext.from_dict({ "a": {"x", "y"}, "b": {"x", "z"}, "c": {"y", "z"}, "d": {"x", "y", "z"}, }) empty = find_empty_cells(ctx, ["x", "y"], ["z"]) assert empty == [] def test_empty_context_all_cells_empty(self): ctx = FormalContext([], ["a", "b", "c"], {}) empty = find_empty_cells(ctx, ["a"], ["b", "c"]) assert len(empty) == 2