2026-02-19 01:54:22 +01:00
|
|
|
|
"""
|
|
|
|
|
|
C2 — Coverage completeness.
|
|
|
|
|
|
|
2026-02-20 00:08:46 +01:00
|
|
|
|
**What this measures**
|
|
|
|
|
|
|
|
|
|
|
|
Builds a binary *domain × chapter* cross-table: rows are economic domains
|
|
|
|
|
|
found across all entities, columns are source chapters. A cell is marked
|
|
|
|
|
|
populated when at least one entity has that (domain, chapter) combination.
|
|
|
|
|
|
|
|
|
|
|
|
coverage_ratio = populated_cells / (n_domains × n_chapters)
|
|
|
|
|
|
|
|
|
|
|
|
This is a measure of how *uniformly* economic domains are distributed across
|
|
|
|
|
|
source chapters, not of how richly entities connect to each other (that is
|
|
|
|
|
|
C3 — Structural Coherence) and not of VSM competency-question answerability
|
|
|
|
|
|
(that requires supplying ``extra_attributes`` with VSM system mappings, which
|
|
|
|
|
|
the pipeline does not currently do).
|
|
|
|
|
|
|
|
|
|
|
|
**Interpreting the ratio alone is misleading.** A single ratio cannot
|
|
|
|
|
|
distinguish two structurally different situations:
|
|
|
|
|
|
|
|
|
|
|
|
- *Healthy topic separation* — domains are locally dense within their
|
|
|
|
|
|
book/section, sparse elsewhere. The matrix has clean block structure;
|
|
|
|
|
|
low cross-chapter density per domain is *expected*.
|
|
|
|
|
|
- *Fragmented extraction* — domains appear sporadically in all chapters,
|
|
|
|
|
|
never strongly anchored anywhere. The matrix is uniformly thin everywhere.
|
|
|
|
|
|
|
|
|
|
|
|
Both can produce the same ratio. Use the *per-domain density distribution*
|
|
|
|
|
|
(``domain_densities``, ``density_std``, ``cross_cutting_ratio``) to
|
|
|
|
|
|
distinguish them:
|
|
|
|
|
|
|
|
|
|
|
|
- High ``density_std`` + bimodal distribution → healthy topic separation.
|
|
|
|
|
|
- Low ``density_std`` + uniform distribution → potential fragmentation.
|
|
|
|
|
|
- ``cross_cutting_ratio`` measures what fraction of domains span more than
|
|
|
|
|
|
half the chapters — these are the foundational cross-cutting concepts.
|
|
|
|
|
|
|
|
|
|
|
|
**Threshold note**
|
|
|
|
|
|
|
|
|
|
|
|
A 0.50 threshold is appropriate for a focused single-topic corpus. For a
|
|
|
|
|
|
heterogeneous multi-book corpus (e.g. all five books of The Wealth of
|
|
|
|
|
|
Nations), domains introduced in later books create empty cells for all
|
|
|
|
|
|
earlier chapters, causing the ratio to fall below 0.50 even for structurally
|
|
|
|
|
|
healthy corpora. Consider 0.30–0.40 for large, multi-topic corpora.
|
2026-02-19 01:54:22 +01:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-02-20 00:08:46 +01:00
|
|
|
|
import math
|
|
|
|
|
|
import statistics
|
2026-02-19 01:54:22 +01:00
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
|
|
|
|
|
|
|
|
from markitect.infospace.models import EntityMeta
|
|
|
|
|
|
from markitect.analysis.fca import FormalContext, find_empty_cells, find_gap_concepts
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class CoverageReport:
|
2026-02-20 00:08:46 +01:00
|
|
|
|
"""Results from coverage analysis.
|
|
|
|
|
|
|
|
|
|
|
|
Attributes:
|
|
|
|
|
|
coverage_ratio: Fraction of (domain, chapter) cells that are
|
|
|
|
|
|
populated. See module docstring for interpretation notes.
|
|
|
|
|
|
domain_densities: Per-domain fraction of chapters that contain
|
|
|
|
|
|
at least one entity with that domain. Keys are domain names.
|
|
|
|
|
|
density_std: Standard deviation of ``domain_densities`` values.
|
|
|
|
|
|
High std suggests healthy topic separation; low std suggests
|
|
|
|
|
|
uniform but thin coverage.
|
|
|
|
|
|
cross_cutting_ratio: Fraction of domains that appear in more than
|
|
|
|
|
|
50 % of source chapters. These are the foundational concepts.
|
|
|
|
|
|
empty_cells: List of ``{dimension_a, dimension_b}`` dicts for each
|
|
|
|
|
|
unpopulated (domain, chapter) cell.
|
|
|
|
|
|
gap_concepts: FCA gap concepts — attribute combinations present in
|
|
|
|
|
|
the lattice but with no entity.
|
|
|
|
|
|
domain_counts: Total entity count per domain.
|
|
|
|
|
|
entity_count: Total number of entities analysed.
|
|
|
|
|
|
"""
|
2026-02-19 01:54:22 +01:00
|
|
|
|
|
|
|
|
|
|
coverage_ratio: float = 0.0
|
2026-02-20 00:08:46 +01:00
|
|
|
|
domain_densities: Dict[str, float] = field(default_factory=dict)
|
|
|
|
|
|
density_std: float = 0.0
|
|
|
|
|
|
cross_cutting_ratio: float = 0.0
|
2026-02-19 01:54:22 +01:00
|
|
|
|
empty_cells: List[dict] = field(default_factory=list)
|
|
|
|
|
|
gap_concepts: List[dict] = field(default_factory=list)
|
|
|
|
|
|
domain_counts: Dict[str, int] = field(default_factory=dict)
|
|
|
|
|
|
entity_count: int = 0
|
|
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> dict:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"concern": "C2",
|
|
|
|
|
|
"coverage_ratio": round(self.coverage_ratio, 4),
|
2026-02-20 00:08:46 +01:00
|
|
|
|
"domain_densities": {k: round(v, 4) for k, v in self.domain_densities.items()},
|
|
|
|
|
|
"density_std": round(self.density_std, 4),
|
|
|
|
|
|
"cross_cutting_ratio": round(self.cross_cutting_ratio, 4),
|
2026-02-19 01:54:22 +01:00
|
|
|
|
"empty_cells": self.empty_cells,
|
|
|
|
|
|
"gap_concepts_count": len(self.gap_concepts),
|
|
|
|
|
|
"domain_counts": self.domain_counts,
|
|
|
|
|
|
"entity_count": self.entity_count,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_attributes(entity: EntityMeta) -> set[str]:
|
|
|
|
|
|
"""Extract FCA attributes from an entity."""
|
|
|
|
|
|
attrs: set[str] = set()
|
|
|
|
|
|
if entity.domain:
|
|
|
|
|
|
attrs.add(f"domain:{entity.domain}")
|
|
|
|
|
|
if entity.source_chapter:
|
|
|
|
|
|
attrs.add(f"chapter:{entity.source_chapter}")
|
|
|
|
|
|
return attrs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def check_coverage(
|
|
|
|
|
|
entities: List[EntityMeta],
|
|
|
|
|
|
extra_attributes: Optional[Dict[str, set[str]]] = None,
|
|
|
|
|
|
) -> CoverageReport:
|
|
|
|
|
|
"""Check coverage completeness using FCA gap analysis.
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
entities: Entity metadata list.
|
|
|
|
|
|
extra_attributes: Optional ``{slug: {attr, ...}}`` to merge
|
|
|
|
|
|
with auto-extracted attributes (e.g. VSM mappings).
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
:class:`CoverageReport` with gaps and coverage ratio.
|
|
|
|
|
|
"""
|
|
|
|
|
|
n = len(entities)
|
|
|
|
|
|
if n == 0:
|
|
|
|
|
|
return CoverageReport()
|
|
|
|
|
|
|
|
|
|
|
|
# Build entity → attributes mapping
|
|
|
|
|
|
entity_attrs: Dict[str, set[str]] = {}
|
|
|
|
|
|
for e in entities:
|
|
|
|
|
|
attrs = _extract_attributes(e)
|
|
|
|
|
|
if extra_attributes and e.slug in extra_attributes:
|
|
|
|
|
|
attrs.update(extra_attributes[e.slug])
|
|
|
|
|
|
entity_attrs[e.slug] = attrs
|
|
|
|
|
|
|
|
|
|
|
|
# Domain counts
|
|
|
|
|
|
domain_counts: Dict[str, int] = {}
|
|
|
|
|
|
for e in entities:
|
|
|
|
|
|
d = e.domain or "(unspecified)"
|
|
|
|
|
|
domain_counts[d] = domain_counts.get(d, 0) + 1
|
|
|
|
|
|
|
|
|
|
|
|
# Build FCA context
|
|
|
|
|
|
context = FormalContext.from_dict(entity_attrs)
|
|
|
|
|
|
|
|
|
|
|
|
# Cross-tabulation: domain × chapter
|
|
|
|
|
|
domains = sorted({a for attrs in entity_attrs.values() for a in attrs if a.startswith("domain:")})
|
|
|
|
|
|
chapters = sorted({a for attrs in entity_attrs.values() for a in attrs if a.startswith("chapter:")})
|
|
|
|
|
|
|
|
|
|
|
|
empty = []
|
|
|
|
|
|
if domains and chapters:
|
|
|
|
|
|
raw_empty = find_empty_cells(context, domains, chapters)
|
|
|
|
|
|
empty = [{"dimension_a": a, "dimension_b": b} for a, b in raw_empty]
|
|
|
|
|
|
|
|
|
|
|
|
# FCA gap concepts
|
|
|
|
|
|
gaps = find_gap_concepts(context)
|
|
|
|
|
|
gap_dicts = [
|
|
|
|
|
|
{"intent": sorted(g.intent), "extent_size": g.extent_size}
|
|
|
|
|
|
for g in gaps
|
|
|
|
|
|
if g.intent_size <= 4 # Only report manageable gaps
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
# Coverage ratio: populated cells / total possible cells
|
|
|
|
|
|
total_cells = len(domains) * len(chapters) if domains and chapters else 1
|
|
|
|
|
|
populated = total_cells - len(empty)
|
|
|
|
|
|
ratio = populated / total_cells if total_cells > 0 else 0.0
|
|
|
|
|
|
|
2026-02-20 00:08:46 +01:00
|
|
|
|
# Per-domain density: fraction of chapters that contain this domain
|
|
|
|
|
|
n_chapters = len(chapters)
|
|
|
|
|
|
domain_densities: Dict[str, float] = {}
|
|
|
|
|
|
if n_chapters > 0:
|
|
|
|
|
|
empty_pairs = {(e["dimension_a"], e["dimension_b"]) for e in empty}
|
|
|
|
|
|
for d in domains:
|
|
|
|
|
|
populated_for_domain = sum(
|
|
|
|
|
|
1 for c in chapters if (d, c) not in empty_pairs
|
|
|
|
|
|
)
|
|
|
|
|
|
domain_densities[d.removeprefix("domain:")] = populated_for_domain / n_chapters
|
|
|
|
|
|
|
|
|
|
|
|
density_values = list(domain_densities.values())
|
|
|
|
|
|
density_std = statistics.stdev(density_values) if len(density_values) >= 2 else 0.0
|
|
|
|
|
|
cross_cutting_ratio = (
|
|
|
|
|
|
sum(1 for v in density_values if v > 0.5) / len(density_values)
|
|
|
|
|
|
if density_values else 0.0
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-02-19 01:54:22 +01:00
|
|
|
|
return CoverageReport(
|
|
|
|
|
|
coverage_ratio=ratio,
|
2026-02-20 00:08:46 +01:00
|
|
|
|
domain_densities=domain_densities,
|
|
|
|
|
|
density_std=round(density_std, 6),
|
|
|
|
|
|
cross_cutting_ratio=round(cross_cutting_ratio, 4),
|
2026-02-19 01:54:22 +01:00
|
|
|
|
empty_cells=empty,
|
|
|
|
|
|
gap_concepts=gap_dicts,
|
|
|
|
|
|
domain_counts=domain_counts,
|
|
|
|
|
|
entity_count=n,
|
|
|
|
|
|
)
|