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:
2026-02-19 01:38:35 +01:00
parent f8c9ab33f0
commit dc22017b7c
2 changed files with 620 additions and 0 deletions

View 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