feat(analysis): add Formal Concept Analysis for coverage gap detection (S1.7)
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>
This commit is contained in:
313
tests/unit/analysis/test_fca.py
Normal file
313
tests/unit/analysis/test_fca.py
Normal file
@@ -0,0 +1,313 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user