Skip to content

Truth tables

The truth table is the analytical heart of QCA. It enumerates every logically possible configuration of the conditions — all \(2^k\) corners of the property space — and reports what the evidence says about each.

Construction

from setqca import build_truth_table

table = build_truth_table(
    data,
    outcome="Y",
    conditions=["A", "B", "C"],
    inclusion_cutoff=0.8,
    exclusion_cutoff=0.5,
    pri_cutoff=0.6,
    frequency_cutoff=2,
    case_id="country",
)

Corner assignment

Each case is assigned to the corner implied by whether each of its memberships lies above or below the crossover. A case with A=0.9, B=0.2 belongs to corner A=1, B=0.

Corner membership is then computed for every case in every corner using the minimum t-norm, with absent conditions negated as 1 - x. This is what makes consistency a fuzzy quantity rather than a simple count.

Row coding

Rows are coded in this order:

Code Condition Meaning
R n < frequency_cutoff Logical remainder — too little evidence to judge
1 consistency >= inclusion_cutoff and PRI >= pri_cutoff Sufficient for the outcome
C consistency >= exclusion_cutoff Contradictory — between the two cutoffs
0 otherwise Not sufficient

The frequency test comes first: a row with insufficient cases is a remainder no matter how consistent the few cases it has happen to be.

The contradictory band

By default exclusion_cutoff equals inclusion_cutoff, which collapses the C band to nothing — every observed row is either 1 or 0. Setting a lower exclusion cutoff creates an explicit grey zone:

build_truth_table(data, outcome="Y", conditions=[...], inclusion_cutoff=0.8, exclusion_cutoff=0.5)

Rows coded C participate in neither the on-set nor the don't-care set. They are excluded from minimisation, which is deliberately conservative: an ambiguous row should not silently drive a solution.

Inspecting the table

print(table.to_frame())

The tidy frame carries the condition states, the minterm index, case count n, consistency, PRI, the OUT code, and the case labels.

Minterm indices are big-endian over the condition order you supplied, so with conditions ["A", "B", "C"] the configuration A=1, B=1, C=0 is minterm 6.

Set-valued accessors give direct access to each group:

table.positive_minterms  # coded "1"
table.negative_minterms  # coded "0"
table.contradictory_minterms  # coded "C"
table.remainder_minterms  # coded "R"

Nothing is thrown away

Rows excluded by a threshold are kept, with their classification and the reason recorded. A row's outcome code alone conflates situations that call for different responses:

table.positive_rows()  # coded "1"
table.negative_rows()  # coded "0"
table.contradictions()  # coded "C"
table.remainders()  # coded "R"
table.excluded_rows()  # kept out by a *threshold*, not by the evidence
print(table.summary())

excluded_rows() is the interesting one. It returns rows the frequency or PRI cutoff held back — the rows a different analytical choice would have admitted. A row with genuinely low consistency is excluded by the data and is not listed, because no threshold would rescue it.

Every row carries exclusion_reason in words:

frequency 1 below the cutoff of 2
consistency 0.643 below the inclusion cutoff of 0.8
PRI 0.412 below the cutoff of 0.7

Consistency and PRI fail differently

A row can clear the consistency cutoff and still be excluded by the PRI cutoff. Both are named separately, because the responses differ: low consistency means the configuration does not reliably produce the outcome, while low PRI means it is nearly as good at producing the outcome's negation.

A table is a reusable object

A truth table carries everything Boolean minimisation needs, so it can be stored and re-minimised without recalibrating or rebuilding:

text = table.to_json()
restored = TruthTable.from_json(text)

restored.minimize()  # conservative
restored.minimize(include_remainders=True)  # parsimonious

Both agree with the estimator exactly — there are tests asserting so. Only the case-level parameters of fit need the original data, since those describe cases rather than configurations; use FSQCA.fit for those.

Limited diversity

The gap between \(2^k\) logically possible configurations and the handful you actually observe is limited diversity, and it is the central practical problem in QCA. With 6 conditions there are 64 corners; a study of 25 cases can occupy at most 25 of them.

Remainders are exactly what the conservative and parsimonious solutions disagree about:

  • the conservative solution uses no remainders, so it assumes nothing;
  • the parsimonious solution treats every remainder as a don't-care, so it assumes each unobserved configuration behaves however is most convenient.

Neither is more correct in general. Report the number of remainders alongside your solutions — if most of your property space is unobserved, the parsimonious solution rests almost entirely on untested assumptions.

setqca.truth_table

Truth-table construction for crisp-set and fuzzy-set QCA.

TruthCode module-attribute

TruthCode = Literal['1', '0', 'C', 'R']

Outcome code of a truth-table row.

"1" sufficient, "0" not sufficient, "C" contradictory, "R" logical remainder.

TruthTableRow dataclass

TruthTableRow(
    minterm: int,
    configuration: tuple[int, ...],
    frequency: int,
    consistency: float,
    pri: float,
    outcome: TruthCode,
    cases: tuple[str, ...],
    exclusion_reason: str | None = None,
)

A single causal configuration and its empirical fit.

Attributes:

Name Type Description
exclusion_reason str | None

Why the row is not coded sufficient, in words. None for rows coded "1". Recorded because the outcome code alone conflates distinct situations: a row can miss out for lack of cases, for low consistency, or for low PRI, and those call for different responses.

observed property

observed: bool

Return whether the configuration passed the frequency cutoff.

excluded_by_threshold property

excluded_by_threshold: bool

Return whether a threshold, rather than the evidence, kept this row out.

True for rows held back by the frequency or PRI cutoffs. A row with genuinely low consistency is excluded by the data, not by a choice.

TruthTable dataclass

TruthTable(
    conditions: tuple[str, ...],
    outcome_name: str,
    rows: tuple[TruthTableRow, ...],
    inclusion_cutoff: float,
    exclusion_cutoff: float,
    pri_cutoff: float,
    frequency_cutoff: int,
)

Immutable QCA truth table covering every logically possible corner.

positive_minterms property

positive_minterms: set[int]

Return minterms of rows coded sufficient for the outcome.

negative_minterms property

negative_minterms: set[int]

Return minterms of rows coded not sufficient for the outcome.

contradictory_minterms property

contradictory_minterms: set[int]

Return minterms of rows falling between the exclusion and inclusion cutoffs.

remainder_minterms property

remainder_minterms: set[int]

Return minterms of logical remainders, i.e. rows below the frequency cutoff.

rows_with

rows_with(code: TruthCode) -> tuple[TruthTableRow, ...]

Return the rows carrying one outcome code, in minterm order.

Source code in src/setqca/truth_table.py
def rows_with(self, code: TruthCode) -> tuple[TruthTableRow, ...]:
    """Return the rows carrying one outcome code, in minterm order."""
    return tuple(row for row in self.rows if row.outcome == code)

positive_rows

positive_rows() -> tuple[TruthTableRow, ...]

Return rows coded sufficient for the outcome.

Source code in src/setqca/truth_table.py
def positive_rows(self) -> tuple[TruthTableRow, ...]:
    """Return rows coded sufficient for the outcome."""
    return self.rows_with("1")

negative_rows

negative_rows() -> tuple[TruthTableRow, ...]

Return rows coded not sufficient.

Source code in src/setqca/truth_table.py
def negative_rows(self) -> tuple[TruthTableRow, ...]:
    """Return rows coded not sufficient."""
    return self.rows_with("0")

contradictions

contradictions() -> tuple[TruthTableRow, ...]

Return rows falling between the exclusion and inclusion cutoffs.

Source code in src/setqca/truth_table.py
def contradictions(self) -> tuple[TruthTableRow, ...]:
    """Return rows falling between the exclusion and inclusion cutoffs."""
    return self.rows_with("C")

remainders

remainders() -> tuple[TruthTableRow, ...]

Return logical remainders: rows below the frequency cutoff.

Source code in src/setqca/truth_table.py
def remainders(self) -> tuple[TruthTableRow, ...]:
    """Return logical remainders: rows below the frequency cutoff."""
    return self.rows_with("R")

excluded_rows

excluded_rows() -> tuple[TruthTableRow, ...]

Return rows a threshold kept out, rather than the evidence.

These are the rows whose exclusion is a consequence of an analytical choice — the frequency or PRI cutoff — and therefore the rows to revisit when judging how much the result depends on those choices. A row with genuinely low consistency is excluded by the data and is not listed here.

Source code in src/setqca/truth_table.py
def excluded_rows(self) -> tuple[TruthTableRow, ...]:
    """Return rows a *threshold* kept out, rather than the evidence.

    These are the rows whose exclusion is a consequence of an analytical
    choice — the frequency or PRI cutoff — and therefore the rows to
    revisit when judging how much the result depends on those choices. A
    row with genuinely low consistency is excluded by the data and is not
    listed here.
    """
    return tuple(row for row in self.rows if row.excluded_by_threshold)

summary

summary() -> str

Return a short account of how the table came out.

Source code in src/setqca/truth_table.py
def summary(self) -> str:
    """Return a short account of how the table came out."""
    return (
        f"{len(self.rows)} configurations of {len(self.conditions)} conditions "
        f"({self.outcome_name})\n"
        f"  sufficient:    {len(self.positive_rows())}\n"
        f"  not sufficient:{len(self.negative_rows())}\n"
        f"  contradictory: {len(self.contradictions())}\n"
        f"  remainders:    {len(self.remainders())}\n"
        f"  excluded by a threshold: {len(self.excluded_rows())}"
    )

to_frame

to_frame() -> DataFrame

Return a tidy pandas representation of the truth table.

Returns:

Type Description
DataFrame

One row per configuration, with the condition states followed by minterm, n, consistency, PRI, OUT, cases and excluded_because.

Source code in src/setqca/truth_table.py
def to_frame(self) -> pd.DataFrame:
    """Return a tidy pandas representation of the truth table.

    Returns
    -------
    pandas.DataFrame
        One row per configuration, with the condition states followed by
        ``minterm``, ``n``, ``consistency``, ``PRI``, ``OUT``, ``cases``
        and ``excluded_because``.
    """
    records: list[dict[str, object]] = []
    for row in self.rows:
        record: dict[str, object] = dict(zip(self.conditions, row.configuration, strict=True))
        record.update(
            {
                "minterm": row.minterm,
                "n": row.frequency,
                "consistency": row.consistency,
                "PRI": row.pri,
                "OUT": row.outcome,
                "cases": ", ".join(row.cases),
                "excluded_because": row.exclusion_reason or "",
            }
        )
        records.append(record)
    return pd.DataFrame.from_records(records)

minimize

minimize(
    *,
    include_remainders: bool = False,
    max_solutions: int = 256,
) -> tuple[BooleanSolution, ...]

Minimise directly from the table, without the original data.

A stored truth table carries everything Boolean minimisation needs, so a saved table can be re-minimised under different assumptions without recalibrating or rebuilding it.

Parameters:

Name Type Description Default
include_remainders bool

Treat logical remainders as don't-cares, giving the parsimonious solution rather than the conservative one.

False
max_solutions int

Upper bound on tied minimal covers.

256

Returns:

Type Description
tuple of BooleanSolution

Boolean covers only. Case-level parameters of fit need the original data and are produced by :meth:~setqca.FSQCA.fit.

Raises:

Type Description
ValueError

If no row is coded sufficient.

Source code in src/setqca/truth_table.py
def minimize(
    self, *, include_remainders: bool = False, max_solutions: int = 256
) -> tuple[BooleanSolution, ...]:
    """Minimise directly from the table, without the original data.

    A stored truth table carries everything Boolean minimisation needs, so
    a saved table can be re-minimised under different assumptions without
    recalibrating or rebuilding it.

    Parameters
    ----------
    include_remainders : bool, default False
        Treat logical remainders as don't-cares, giving the parsimonious
        solution rather than the conservative one.
    max_solutions : int, default 256
        Upper bound on tied minimal covers.

    Returns
    -------
    tuple of BooleanSolution
        Boolean covers only. Case-level parameters of fit need the original
        data and are produced by :meth:`~setqca.FSQCA.fit`.

    Raises
    ------
    ValueError
        If no row is coded sufficient.
    """
    from .minimize.qmc import minimize as _minimize

    on_set = self.positive_minterms
    if not on_set:
        raise ValueError("No truth-table row is sufficient under the chosen thresholds.")
    return _minimize(
        on_set,
        dont_cares=self.remainder_minterms if include_remainders else None,
        width=len(self.conditions),
        max_solutions=max_solutions,
    )

to_dict

to_dict() -> dict[str, object]

Return a JSON-compatible dictionary describing the whole table.

Source code in src/setqca/truth_table.py
def to_dict(self) -> dict[str, object]:
    """Return a JSON-compatible dictionary describing the whole table."""
    return {
        "conditions": list(self.conditions),
        "outcome": self.outcome_name,
        "inclusion_cutoff": self.inclusion_cutoff,
        "exclusion_cutoff": self.exclusion_cutoff,
        "pri_cutoff": self.pri_cutoff,
        "frequency_cutoff": self.frequency_cutoff,
        "rows": [
            {
                "minterm": row.minterm,
                "configuration": list(row.configuration),
                "n": row.frequency,
                "consistency": row.consistency,
                "pri": row.pri,
                "out": row.outcome,
                "cases": list(row.cases),
                "excluded_because": row.exclusion_reason,
            }
            for row in self.rows
        ],
    }

from_dict classmethod

from_dict(payload: dict[str, Any]) -> TruthTable

Rebuild a table from :meth:to_dict output.

Raises:

Type Description
KeyError

If a required key is missing.

Source code in src/setqca/truth_table.py
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> TruthTable:
    """Rebuild a table from :meth:`to_dict` output.

    Raises
    ------
    KeyError
        If a required key is missing.
    """
    rows = tuple(
        TruthTableRow(
            minterm=int(record["minterm"]),
            configuration=tuple(int(value) for value in record["configuration"]),
            frequency=int(record["n"]),
            consistency=float(record["consistency"]),
            pri=float(record["pri"]),
            outcome=record["out"],
            cases=tuple(str(case) for case in record["cases"]),
            exclusion_reason=record.get("excluded_because"),
        )
        for record in payload["rows"]
    )
    return cls(
        conditions=tuple(payload["conditions"]),
        outcome_name=payload["outcome"],
        rows=rows,
        inclusion_cutoff=float(payload["inclusion_cutoff"]),
        exclusion_cutoff=float(payload["exclusion_cutoff"]),
        pri_cutoff=float(payload["pri_cutoff"]),
        frequency_cutoff=int(payload["frequency_cutoff"]),
    )

to_json

to_json(*, indent: int | None = None) -> str

Serialise the table to JSON.

Source code in src/setqca/truth_table.py
def to_json(self, *, indent: int | None = None) -> str:
    """Serialise the table to JSON."""
    return json.dumps(self.to_dict(), indent=indent)

from_json classmethod

from_json(text: str) -> TruthTable

Rebuild a table from JSON.

Source code in src/setqca/truth_table.py
@classmethod
def from_json(cls, text: str) -> TruthTable:
    """Rebuild a table from JSON."""
    return cls.from_dict(json.loads(text))

build_truth_table

build_truth_table(
    data: DataFrame,
    *,
    outcome: str,
    conditions: list[str] | tuple[str, ...],
    inclusion_cutoff: float = 0.8,
    exclusion_cutoff: float | None = None,
    pri_cutoff: float = 0.0,
    frequency_cutoff: int = 1,
    case_id: str | None = None,
    allow_crossover_cases: bool = False,
) -> TruthTable

Construct a complete binary truth table from calibrated data.

Fuzzy cases are assigned to the crisp truth-table corner implied by scores above/below 0.5. Cases exactly at the crossover are rejected by default because their corner assignment is ambiguous.

Parameters:

Name Type Description Default
data DataFrame

Calibrated condition and outcome memberships in [0, 1].

required
outcome str

Name of the outcome column.

required
conditions list of str or tuple of str

Names of the condition columns, in the order used for minterm coding.

required
inclusion_cutoff float

Minimum sufficiency consistency for a row to be coded "1".

0.8
exclusion_cutoff float

Consistency below which a row is coded "0". Rows between the two cutoffs are coded "C". Defaults to inclusion_cutoff, which disables the contradictory band.

None
pri_cutoff float

Minimum PRI for a row to be coded "1".

0.0
frequency_cutoff int

Minimum number of cases for a row to count as observed.

1
case_id str

Column holding case labels. Defaults to the frame index.

None
allow_crossover_cases bool

Permit membership scores of exactly 0.5.

False

Returns:

Type Description
TruthTable

Complete table with one row per corner of the property space.

Raises:

Type Description
TypeError

If data is not a :class:pandas.DataFrame.

ValueError

If any cutoff is out of range, memberships fall outside [0, 1], or a case sits exactly on the crossover while allow_crossover_cases is False.

Source code in src/setqca/truth_table.py
def build_truth_table(
    data: pd.DataFrame,
    *,
    outcome: str,
    conditions: list[str] | tuple[str, ...],
    inclusion_cutoff: float = 0.8,
    exclusion_cutoff: float | None = None,
    pri_cutoff: float = 0.0,
    frequency_cutoff: int = 1,
    case_id: str | None = None,
    allow_crossover_cases: bool = False,
) -> TruthTable:
    """Construct a complete binary truth table from calibrated data.

    Fuzzy cases are assigned to the crisp truth-table corner implied by scores
    above/below 0.5. Cases exactly at the crossover are rejected by default
    because their corner assignment is ambiguous.

    Parameters
    ----------
    data : pandas.DataFrame
        Calibrated condition and outcome memberships in ``[0, 1]``.
    outcome : str
        Name of the outcome column.
    conditions : list of str or tuple of str
        Names of the condition columns, in the order used for minterm coding.
    inclusion_cutoff : float, default 0.8
        Minimum sufficiency consistency for a row to be coded ``"1"``.
    exclusion_cutoff : float, optional
        Consistency below which a row is coded ``"0"``. Rows between the two
        cutoffs are coded ``"C"``. Defaults to ``inclusion_cutoff``, which
        disables the contradictory band.
    pri_cutoff : float, default 0.0
        Minimum PRI for a row to be coded ``"1"``.
    frequency_cutoff : int, default 1
        Minimum number of cases for a row to count as observed.
    case_id : str, optional
        Column holding case labels. Defaults to the frame index.
    allow_crossover_cases : bool, default False
        Permit membership scores of exactly 0.5.

    Returns
    -------
    TruthTable
        Complete table with one row per corner of the property space.

    Raises
    ------
    TypeError
        If ``data`` is not a :class:`pandas.DataFrame`.
    ValueError
        If any cutoff is out of range, memberships fall outside ``[0, 1]``, or
        a case sits exactly on the crossover while ``allow_crossover_cases``
        is ``False``.
    """
    if not isinstance(data, pd.DataFrame):
        raise TypeError("data must be a pandas DataFrame.")
    conds = validate_columns(data, conditions)
    if not conds:
        raise ValueError("At least one condition is required.")
    validate_columns(data, [outcome])
    if not 0.0 <= inclusion_cutoff <= 1.0:
        raise ValueError("inclusion_cutoff must be in [0, 1].")
    exclusion = inclusion_cutoff if exclusion_cutoff is None else exclusion_cutoff
    if not 0.0 <= exclusion <= inclusion_cutoff:
        raise ValueError("exclusion_cutoff must be in [0, inclusion_cutoff].")
    if not 0.0 <= pri_cutoff <= 1.0:
        raise ValueError("pri_cutoff must be in [0, 1].")
    if frequency_cutoff < 1:
        raise ValueError("frequency_cutoff must be at least 1.")

    y = validate_membership(data[outcome].to_numpy(), name=outcome)
    x = data[conds].to_numpy(dtype=np.float64)
    if not np.isfinite(x).all() or np.any((x < 0.0) | (x > 1.0)):
        raise ValueError("All conditions must be calibrated memberships in [0, 1].")
    if not allow_crossover_cases and np.isclose(x, 0.5, atol=1e-12).any():
        raise ValueError(
            "At least one condition is exactly 0.5. Truth-table corner assignment is ambiguous; "
            "resolve crossover cases or set allow_crossover_cases=True."
        )

    assigned = (x >= 0.5).astype(np.int8)
    if case_id is None:
        case_names = np.asarray([str(idx) for idx in data.index], dtype=object)
    else:
        validate_columns(data, [case_id])
        case_names = data[case_id].astype(str).to_numpy(dtype=object)

    rows: list[TruthTableRow] = []
    for config_raw in product((0, 1), repeat=len(conds)):
        config = tuple(int(v) for v in config_raw)
        selector = np.all(assigned == np.asarray(config), axis=1)
        n = int(selector.sum())
        membership = _configuration_membership(x, config)
        fit = sufficiency(membership, y)
        reason: str | None = None
        if n < frequency_cutoff:
            code: TruthCode = "R"
            reason = f"frequency {n} below the cutoff of {frequency_cutoff}"
        elif fit.consistency >= inclusion_cutoff and fit.pri >= pri_cutoff:
            code = "1"
        else:
            # Consistency and PRI fail for different reasons and warrant
            # different responses, so both are named rather than collapsed
            # into the outcome code.
            failures = []
            if fit.consistency < inclusion_cutoff:
                failures.append(
                    f"consistency {fit.consistency:.3f} below the inclusion "
                    f"cutoff of {inclusion_cutoff}"
                )
            if fit.pri < pri_cutoff:
                failures.append(f"PRI {fit.pri:.3f} below the cutoff of {pri_cutoff}")
            reason = "; ".join(failures)
            code = "C" if fit.consistency >= exclusion else "0"
        rows.append(
            TruthTableRow(
                minterm=_minterm(config),
                configuration=config,
                frequency=n,
                consistency=fit.consistency,
                pri=fit.pri,
                outcome=code,
                cases=tuple(str(v) for v in case_names[selector]),
                exclusion_reason=reason,
            )
        )

    return TruthTable(
        conditions=tuple(conds),
        outcome_name=outcome,
        rows=tuple(rows),
        inclusion_cutoff=inclusion_cutoff,
        exclusion_cutoff=exclusion,
        pri_cutoff=pri_cutoff,
        frequency_cutoff=frequency_cutoff,
    )