"""Dataclasses for QCA analysis results."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import pandas as pd
from qca._constants import (
CONSISTENCY_MODERATE,
CONSISTENCY_NECESSITY,
CONSISTENCY_STRONG,
MEMBERSHIP_THRESHOLD,
)
# ---------------------------------------------------------------------------
# SufficiencyResult
# ---------------------------------------------------------------------------
[docs]
@dataclass(frozen=True)
class SufficiencyResult:
"""Result of a sufficiency analysis."""
antecedent: str
consistency: float
raw_coverage: float
unique_coverage: float | None
n_cases_in: int
# ------------------------------------------------------------------
# Evaluation properties
# ------------------------------------------------------------------
@property
def is_sufficient(self) -> bool:
"""Return whether the result meets the sufficiency criteria."""
return self.consistency >= CONSISTENCY_MODERATE and self.n_cases_in > 0
@property
def strength(self) -> str:
"""Return a qualitative strength label."""
if self.n_cases_in == 0:
return "empty"
if self.consistency >= CONSISTENCY_STRONG:
return "strong"
if self.consistency >= CONSISTENCY_MODERATE:
return "moderate"
return "weak"
# ------------------------------------------------------------------
# Serialization
# ------------------------------------------------------------------
[docs]
def to_dict(self) -> dict[str, Any]:
"""Return a dictionary representation."""
return {
"antecedent": self.antecedent,
"consistency": self.consistency,
"raw_coverage": self.raw_coverage,
"unique_coverage": self.unique_coverage,
"n_cases_in": self.n_cases_in,
"is_sufficient": self.is_sufficient,
"strength": self.strength,
}
[docs]
def to_series(self) -> pd.Series:
"""Return a pandas Series representation."""
return pd.Series(self.to_dict())
# ------------------------------------------------------------------
# Display
# ------------------------------------------------------------------
def __str__(self) -> str:
uc_str = (
f"{self.unique_coverage:.3f}" if self.unique_coverage is not None else "N/A"
)
return (
f"SufficiencyResult(\n"
f" antecedent = {self.antecedent}\n"
f" consistency = {self.consistency:.3f} [{self.strength}]\n"
f" raw_coverage = {self.raw_coverage:.3f}\n"
f" unique_cov = {uc_str}\n"
f" n_cases_in = {self.n_cases_in}\n"
f")"
)
# ---------------------------------------------------------------------------
# NecessityResult
# ---------------------------------------------------------------------------
[docs]
@dataclass(frozen=True)
class NecessityResult:
"""Result of a necessity analysis."""
condition: str
consistency: float
coverage: float
n_cases_in: int
# ------------------------------------------------------------------
# Evaluation properties
# ------------------------------------------------------------------
@property
def is_necessary(self) -> bool:
"""Is necessary."""
return self.consistency >= CONSISTENCY_NECESSITY
@property
def is_trivial(self) -> bool:
"""Is trivial."""
return self.coverage >= 0.90
@property
def strength(self) -> str:
"""Return a qualitative strength label."""
if self.consistency >= 0.95:
return "strong"
if self.consistency >= CONSISTENCY_NECESSITY:
return "necessary"
if self.consistency >= CONSISTENCY_MODERATE:
return "weak"
return "insufficient"
# ------------------------------------------------------------------
# Serialization
# ------------------------------------------------------------------
[docs]
def to_dict(self) -> dict[str, Any]:
"""Return a dictionary representation."""
return {
"condition": self.condition,
"consistency": self.consistency,
"coverage": self.coverage,
"n_cases_in": self.n_cases_in,
"is_necessary": self.is_necessary,
"is_trivial": self.is_trivial,
"strength": self.strength,
}
[docs]
def to_series(self) -> pd.Series:
"""Return a pandas Series representation."""
return pd.Series(self.to_dict())
# ------------------------------------------------------------------
# Display
# ------------------------------------------------------------------
def __str__(self) -> str:
trivial_note = (
" Potentially trivial necessary condition\n" if self.is_trivial else ""
)
return (
f"NecessityResult(\n"
f" condition = {self.condition}\n"
f" consistency = {self.consistency:.3f} [{self.strength}]\n"
f" coverage = {self.coverage:.3f}\n"
f" n_cases_in = {self.n_cases_in}\n"
f"{trivial_note}"
f")"
)
# ---------------------------------------------------------------------------
# TruthTableRow
# ---------------------------------------------------------------------------
[docs]
@dataclass(frozen=True)
class TruthTableRow:
"""One row in a QCA truth table."""
config: dict[str, Any]
n_cases: int
outcome_mean: float
outcome_raw_consist: float
case_ids: list[str]
include: bool | None = None
pri_consistency: float | None = None
# ------------------------------------------------------------------
# Validate compatibility with TruthTableRowProtocol.
# ------------------------------------------------------------------
def __post_init__(self) -> None:
"""Validate the dataclass after initialization."""
if not (0.0 <= self.outcome_mean <= 1.0):
raise ValueError(
"outcome_mean must be within [0, 1]. "
f"Received: {self.outcome_mean}"
)
if not (0.0 <= self.outcome_raw_consist <= 1.0):
raise ValueError(
"outcome_raw_consist must be within [0, 1]. "
f"Received: {self.outcome_raw_consist}"
)
if self.pri_consistency is not None and not (
0.0 <= self.pri_consistency <= 1.0
):
raise ValueError(
"pri_consistency must be within [0, 1]. "
f"Received: {self.pri_consistency}"
)
if self.n_cases < 0:
raise ValueError(
f"n_cases must be non-negative. Received: {self.n_cases}"
)
# ------------------------------------------------------------------
# Evaluation properties
# ------------------------------------------------------------------
@property
def is_positive(self) -> bool:
"""Return whether this row is included in the outcome."""
return self.include is True
@property
def is_negative(self) -> bool:
"""Return whether this row is excluded from the outcome."""
return self.include is False
@property
def is_undecided(self) -> bool:
"""Is undecided."""
return self.include is None
@property
def n_cases_in_outcome(self) -> int:
"""N cases in outcome."""
return self.n_cases if self.outcome_mean > MEMBERSHIP_THRESHOLD else 0
# ------------------------------------------------------------------
# Serialization
# ------------------------------------------------------------------
[docs]
def to_dict(self) -> dict[str, Any]:
"""Return a dictionary representation."""
record: dict[str, Any] = {**self.config}
record["n_cases"] = self.n_cases
record["outcome_mean"] = round(self.outcome_mean, 4)
record["outcome_raw_consist"] = round(self.outcome_raw_consist, 4)
record["pri_consistency"] = (
None
if self.pri_consistency is None
else round(self.pri_consistency, 4)
)
record["include"] = self.include
record["cases"] = ", ".join(self.case_ids)
return record
[docs]
def to_series(self) -> pd.Series:
"""Return a pandas Series representation."""
return pd.Series(self.to_dict())
# ------------------------------------------------------------------
# Display
# ------------------------------------------------------------------
def __str__(self) -> str:
config_str = ", ".join(f"{k}={v}" for k, v in self.config.items())
cases_preview = ", ".join(self.case_ids[:5]) + (
" ..." if len(self.case_ids) > 5 else ""
)
include_str = {True: "yes", False: "no", None: "?"}[self.include]
return (
f"TruthTableRow(\n"
f" config = {{{config_str}}}\n"
f" n_cases = {self.n_cases}\n"
f" outcome_mean = {self.outcome_mean:.3f}\n"
f" row_consist = {self.outcome_raw_consist:.3f}\n"
f" pri_consist = "
f"{self.pri_consistency if self.pri_consistency is not None else 'N/A'}\n"
f" include = {include_str}\n"
f" cases = [{cases_preview}]\n"
f")"
)
# ---------------------------------------------------------------------------
# Result aggregation utilities
# ---------------------------------------------------------------------------
def sufficiency_results_to_df(
results: list[SufficiencyResult],
depth: int | None = None,
) -> pd.DataFrame:
"""Convert sufficiency results to a DataFrame."""
if not results:
cols = [
"antecedent",
"consistency",
"raw_coverage",
"unique_coverage",
"n_cases_in",
"is_sufficient",
"strength",
]
if depth is not None:
cols.append("depth")
return pd.DataFrame(columns=cols)
records = [r.to_dict() for r in results]
if depth is not None:
for rec in records:
rec["depth"] = depth
return pd.DataFrame(records)
def necessity_results_to_df(
results: list[NecessityResult],
) -> pd.DataFrame:
"""Convert necessity results to a DataFrame."""
if not results:
return pd.DataFrame(
columns=[
"condition",
"consistency",
"coverage",
"n_cases_in",
"is_necessary",
"is_trivial",
"strength",
]
)
return pd.DataFrame([r.to_dict() for r in results])
def truth_table_to_df(
rows: list[TruthTableRow],
) -> pd.DataFrame:
"""Return the truth table as a DataFrame."""
if not rows:
return pd.DataFrame()
return pd.DataFrame([r.to_dict() for r in rows])