Source code for qca.core.literals

"""Literals module."""

from __future__ import annotations

import warnings
from dataclasses import dataclass
from typing import Any

import pandas as pd

from qca._constants import (
    CROSSOVER_EPSILON,
    FULL_MEMBERSHIP,
    MEMBERSHIP_THRESHOLD,
    NO_MEMBERSHIP,
)

# ---------------------------------------------------------------------------
# SetLiteral
# ---------------------------------------------------------------------------


[docs] @dataclass(frozen=True) class SetLiteral: """Literal for a crisp-set or fuzzy-set condition.""" name: str negated: bool = False # ------------------------------------------------------------------ # Display # ------------------------------------------------------------------
[docs] def label(self) -> str: """Return a human-readable label.""" return f"~{self.name}" if self.negated else self.name
def __repr__(self) -> str: return f"SetLiteral({self.label()!r})" # ------------------------------------------------------------------ # Membership calculation # ------------------------------------------------------------------
[docs] def membership(self, df: pd.DataFrame) -> pd.Series: """Return membership scores for this literal.""" if self.name not in df.columns: raise KeyError( f"SetLiteral '{self.name}' is not present in the DataFrame. " f"Available columns: {list(df.columns)}" ) s = pd.to_numeric(df[self.name], errors="raise").astype(float) # Validate the [0.0, 1.0] membership range. if not ((s >= NO_MEMBERSHIP).all() and (s <= FULL_MEMBERSHIP).all()): raise ValueError( f"SetLiteral '{self.name}' values must be within [0, 1]. " f"Observed range: [{s.min():.4f}, {s.max():.4f}]" ) # Ragin (2008, p. 30): cases at 0.5 are analytically ambiguous. at_crossover = (s - MEMBERSHIP_THRESHOLD).abs() < CROSSOVER_EPSILON if at_crossover.any(): n_at = int(at_crossover.sum()) warnings.warn( f"SetLiteral '{self.name}' has {n_at} case(s) at the crossover " "point (0.5). Ragin (2008, p. 30) recommends avoiding exact " "0.5 memberships because of ambiguity. Review the calibration.", UserWarning, stacklevel=2, ) return (FULL_MEMBERSHIP - s) if self.negated else s
# ------------------------------------------------------------------ # Set-operation utilities # ------------------------------------------------------------------
[docs] def negate(self) -> SetLiteral: """Return the negated literal.""" return SetLiteral(self.name, not self.negated)
[docs] def is_negation_of(self, other: object) -> bool: """Return whether another literal is the logical negation of this one.""" if not isinstance(other, SetLiteral): return False return self.name == other.name and self.negated != other.negated
# --------------------------------------------------------------------------- # MultiValueLiteral # ---------------------------------------------------------------------------
[docs] @dataclass(frozen=True) class MultiValueLiteral: """Literal for a multi-value QCA condition.""" name: str value: Any # ------------------------------------------------------------------ # Display # ------------------------------------------------------------------
[docs] def label(self) -> str: """Return a human-readable label.""" if _is_value_collection(self.value): values = _sorted_values(self.value) return f"{self.name}={{{','.join(str(v) for v in values)}}}" return f"{self.name}={self.value}"
def __repr__(self) -> str: return f"MultiValueLiteral({self.name!r}, {self.value!r})" # ------------------------------------------------------------------ # Membership calculation # ------------------------------------------------------------------
[docs] def membership(self, df: pd.DataFrame) -> pd.Series: """Return membership scores for this literal.""" values = _value_set(self.value) fuzzy_membership = _multivalue_membership_from_columns(df, self.name, values) if fuzzy_membership is not None: return fuzzy_membership if self.name not in df.columns: raise KeyError( f"MultiValueLiteral '{self.name}' is not present in the DataFrame. " "No value-specific membership columns were found. " f"Available columns: {list(df.columns)}" ) col = df[self.name] if _is_value_collection(self.value): missing_values = values - frozenset(col.dropna().unique().tolist()) if missing_values: warnings.warn( f"Values {sorted(missing_values, key=str)!r} for " f"MultiValueLiteral '{self.label()}' are not present in " "the DataFrame. Available values: " f"{sorted(col.dropna().unique().tolist(), key=str)}", UserWarning, stacklevel=2, ) return col.isin(values).astype(float) # Check that the category value is present. if self.value not in col.values: warnings.warn( f"Value {self.value!r} for MultiValueLiteral " f"'{self.name}={self.value}' is not present in the DataFrame. " "Available values: " f"{sorted(col.dropna().unique().tolist(), key=str)}\n" "Check for a type mismatch, such as int versus str.", UserWarning, stacklevel=2, ) return (col == self.value).astype(float)
# ------------------------------------------------------------------ # Set-operation utilities # ------------------------------------------------------------------
[docs] def conflicts_with(self, other: object) -> bool: """Conflicts with.""" if not isinstance(other, MultiValueLiteral): return False if self.name != other.name: return False return _value_set(self.value).isdisjoint(_value_set(other.value))
# --------------------------------------------------------------------------- # Factory functions # ---------------------------------------------------------------------------
[docs] def parse_literal(text: str) -> SetLiteral | MultiValueLiteral: """Parse a literal label into a literal object.""" text = text.strip() if not text: raise ValueError("Literal string cannot be empty.") if "=" in text: # MultiValueLiteral: "name=value" name, val_str = text.split("=", 1) value: Any = _parse_value(val_str) return MultiValueLiteral(name.strip(), value) # SetLiteral: "name" or "~name" if text.startswith("~"): return SetLiteral(text[1:].strip(), negated=True) return SetLiteral(text)
def _parse_value(val_str: str) -> Any: """Parse a string token into a Python value.""" val_str = val_str.strip() if val_str.startswith("{") and val_str.endswith("}"): inner = val_str[1:-1].strip() if not inner: raise ValueError("Multi-value set literal cannot be empty.") return frozenset(_parse_value(part) for part in inner.split(",")) try: return int(val_str) except ValueError: pass try: return float(val_str) except ValueError: pass return val_str def _multivalue_membership_from_columns( df: pd.DataFrame, name: str, values: frozenset[Any], ) -> pd.Series | None: available = { value: _find_multivalue_membership_column(df, name, value) for value in values } present = {value: column for value, column in available.items() if column} if not present: return None missing = [value for value, column in available.items() if column is None] if missing: raise KeyError( f"Missing value-specific membership columns for MultiValueLiteral " f"'{name}' values {sorted(missing, key=str)!r}. Available columns: " f"{list(df.columns)}" ) memberships = [ _coerce_membership_column(df[column], f"{name}={value}") for value, column in present.items() ] if len(memberships) == 1: return memberships[0] return pd.concat(memberships, axis=1).max(axis=1) def _find_multivalue_membership_column( df: pd.DataFrame, name: str, value: Any, ) -> str | None: for candidate in _multivalue_membership_column_candidates(name, value): if candidate in df.columns: return candidate return None def _multivalue_membership_column_candidates(name: str, value: Any) -> list[str]: tokens = _value_tokens(value) candidates: list[str] = [] for token in tokens: candidates.extend( [ f"{name}={token}", f"{name}[{token}]", f"{name}__{token}", ] ) return list(dict.fromkeys(candidates)) def _coerce_membership_column(series: pd.Series, label: str) -> pd.Series: values = pd.to_numeric(series, errors="raise").astype(float) if not ((values >= NO_MEMBERSHIP).all() and (values <= FULL_MEMBERSHIP).all()): raise ValueError( f"MultiValueLiteral '{label}' values must be within [0, 1]. " f"Observed range: [{values.min():.4f}, {values.max():.4f}]" ) return values def _is_value_collection(value: Any) -> bool: return isinstance(value, (frozenset, tuple)) and not isinstance(value, str) def _value_set(value: Any) -> frozenset[Any]: if _is_value_collection(value): return frozenset(value) return frozenset([value]) def _sorted_values(values: Any) -> list[Any]: def _sort_key(value: Any) -> tuple: try: return (0, float(value), "") except (TypeError, ValueError): return (1, 0.0, str(value)) return sorted(list(values), key=_sort_key) def _value_tokens(value: Any) -> list[str]: tokens = [str(value)] if isinstance(value, float) and value.is_integer(): tokens.append(str(int(value))) return list(dict.fromkeys(tokens))
[docs] def parse_conjunction(text: str) -> list[SetLiteral | MultiValueLiteral]: """Parse a conjunction label into literal objects.""" if not text.strip(): raise ValueError("Conjunction string cannot be empty.") return [parse_literal(part) for part in text.split("*")]