Pure-Python FCA implementation: FormalContext (entity × attribute binary relation with extent/intent/closure), ConceptLattice via NextClosure algorithm, find_gap_concepts() for structural coverage gaps, and find_empty_cells() for cross-tabulation analysis. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
314 lines
11 KiB
Python
314 lines
11 KiB
Python
"""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
|