Files
markitect-main/tests/unit/analysis/test_fca.py
tegwick dc22017b7c 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>
2026-02-19 01:38:35 +01:00

314 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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