Skip to content

Multi-value QCA

A multi-value condition takes one of several unordered categories — regime type, welfare regime, sector — rather than being present or absent. Forcing such a condition into a binary set either loses information or invents a dichotomy the concept does not have.

from setqca.multivalue import MVQCA

result = MVQCA(consistency=0.8).fit(data, outcome="Y", conditions=["regime", "wealth"])
print(result)
print(result.truth_table.to_frame())
print(result.summary_frame("parsimonious"))

Conditions hold integer category codes from 0; the outcome is a membership in [0, 1]. The workflow deliberately mirrors FSQCA and CSQCA — moving between them is a change of estimator, not a change of method.

Notation

regime{0,2}*wealth{1} reads "regime is 0 or 2, and wealth is 1". A condition allowing every level constrains nothing and is omitted from the expression, so the binary case reduces to familiar QCA notation.

The property space

result.domain  # regime{0,1,2}, wealth{0,1}
result.domain.size  # 6 logically possible configurations

Configurations are indexed in mixed radix, which generalises the binary minterm and reduces to it exactly when every condition has two levels.

Declare levels that have no cases

Levels are inferred from the data, which understates a category that is theoretically possible but happens to be unobserved. That matters: an unobserved level is a remainder, and remainders change the parsimonious solution.

MVQCA(levels={"regime": 4, "wealth": 2}).fit(...)

Declaring fewer levels than the data contain is an error.

Why not Boolean dummies

The obvious shortcut is to encode A{0,1,2} as three binary indicators and reuse the binary minimiser. That transformation does not preserve the semantics.

The binary space contains points such as A_0 = A_1 = 1 — a case that is simultaneously in two mutually exclusive categories, which corresponds to no configuration at all. The minimiser is free to build implicants across those points, producing terms that look valid and describe nothing. Recovering a multi-value expression afterwards requires exactly the mutual-exclusivity constraints the encoding threw away.

So the cube algebra is implemented directly. A cube allows a set of levels per condition, and merging generalises the binary rule:

two cubes that agree on every condition but one merge into a single cube whose set at that condition is the union of the two.

Because the two cubes agree everywhere else, the merged cube covers exactly their union and nothing more — the same property the binary rule relies on. A test asserts precisely that, and another asserts every cube covers only real configurations.

The exact cover is then solved by the same verified solver the binary engine uses, so both inherit one exactness guarantee rather than two implementations. Minimisation is checked against exhaustive enumeration for four different level combinations, and against the binary minimiser for every three-condition problem.

Agreement with R

R QCA supports multi-value and writes literals as regime[2]. The truth table and the parsimonious solution match exactly on the benchmarks in validation/fixtures/r_qca.json.

The conservative solution can differ in representation:

R:      regime[2] + regime[1]*wealth[1]
setqca: regime{2} + regime{1,2}*wealth{1}

Both cover the same configurations and both cost two terms and three literals, so both are minimal. The difference is that R writes single-value literals only, while setqca also forms subset literals — and here R's regime[1]*wealth[1] is a proper subset of regime{1,2}*wealth{1}, so R's term is not a prime implicant. The parity tests therefore compare cost and coverage rather than text, which is the comparison that carries meaning.

setqca.multivalue

Multi-value QCA: categorical conditions with more than two levels.

A multi-value condition takes one of several unordered categories — regime type, welfare regime, sector — rather than being present or absent. Forcing such a condition into a binary set either loses information or invents a dichotomy the concept does not have.

The cube algebra is implemented directly rather than by encoding categories as Boolean indicators; see :mod:setqca.multivalue._cube for why that encoding is unsound. The exact cover is solved by the same verified solver the binary engine uses, so both inherit one exactness guarantee.

Examples:

>>> import pandas as pd
>>> from setqca.multivalue import MVQCA
>>> data = pd.DataFrame(
...     {"regime": [0, 1, 2, 1], "wealth": [0, 1, 1, 0], "Y": [0.1, 0.9, 0.9, 0.2]}
... )
>>> result = MVQCA(consistency=0.8).fit(data, outcome="Y", conditions=["regime", "wealth"])
>>> print(result.summary_frame())

MultiValueCube dataclass

MultiValueCube(pattern: tuple[frozenset[int], ...])

A conjunction allowing a set of levels for each condition.

from_configuration classmethod

from_configuration(
    values: tuple[int, ...],
) -> MultiValueCube

Build the cube covering exactly one configuration.

Source code in src/setqca/multivalue/_cube.py
@classmethod
def from_configuration(cls, values: tuple[int, ...]) -> MultiValueCube:
    """Build the cube covering exactly one configuration."""
    return cls(tuple(frozenset({value}) for value in values))

literals

literals(domain: MultiValueDomain) -> int

Return the number of conditions the cube actually constrains.

Source code in src/setqca/multivalue/_cube.py
def literals(self, domain: MultiValueDomain) -> int:
    """Return the number of conditions the cube actually constrains."""
    return sum(
        1
        for allowed, count in zip(self.pattern, domain.levels, strict=True)
        if len(allowed) < count
    )

is_tautology

is_tautology(domain: MultiValueDomain) -> bool

Return whether the cube constrains nothing.

Source code in src/setqca/multivalue/_cube.py
def is_tautology(self, domain: MultiValueDomain) -> bool:
    """Return whether the cube constrains nothing."""
    return self.literals(domain) == 0

covers_values

covers_values(values: tuple[int, ...]) -> bool

Return whether a configuration falls inside the cube.

Source code in src/setqca/multivalue/_cube.py
def covers_values(self, values: tuple[int, ...]) -> bool:
    """Return whether a configuration falls inside the cube."""
    return all(value in allowed for value, allowed in zip(values, self.pattern, strict=True))

covers

covers(index: int, domain: MultiValueDomain) -> bool

Return whether the configuration at an index falls inside the cube.

Source code in src/setqca/multivalue/_cube.py
def covers(self, index: int, domain: MultiValueDomain) -> bool:
    """Return whether the configuration at an index falls inside the cube."""
    return self.covers_values(domain.values_of(index))

contains

contains(other: MultiValueCube) -> bool

Return whether this cube covers everything other covers.

Source code in src/setqca/multivalue/_cube.py
def contains(self, other: MultiValueCube) -> bool:
    """Return whether this cube covers everything ``other`` covers."""
    return all(theirs <= mine for mine, theirs in zip(self.pattern, other.pattern, strict=True))

merge

merge(other: MultiValueCube) -> MultiValueCube | None

Merge two cubes differing at exactly one condition.

Returns None when they differ at none or several, in which case the union would cover configurations neither cube covers.

Source code in src/setqca/multivalue/_cube.py
def merge(self, other: MultiValueCube) -> MultiValueCube | None:
    """Merge two cubes differing at exactly one condition.

    Returns ``None`` when they differ at none or several, in which case the
    union would cover configurations neither cube covers.
    """
    differing = [
        index
        for index, (mine, theirs) in enumerate(zip(self.pattern, other.pattern, strict=True))
        if mine != theirs
    ]
    if len(differing) != 1:
        return None
    position = differing[0]
    pattern = list(self.pattern)
    pattern[position] = self.pattern[position] | other.pattern[position]
    return MultiValueCube(tuple(pattern))

as_expression

as_expression(domain: MultiValueDomain) -> str

Render in multi-value QCA notation, for example A{0,2}*B{1}.

Conditions allowing every level are omitted, since they constrain nothing. A cube constraining nothing renders as 1.

Source code in src/setqca/multivalue/_cube.py
def as_expression(self, domain: MultiValueDomain) -> str:
    """Render in multi-value QCA notation, for example ``A{0,2}*B{1}``.

    Conditions allowing every level are omitted, since they constrain
    nothing. A cube constraining nothing renders as ``1``.
    """
    parts = [
        f"{name}{{{','.join(str(value) for value in sorted(allowed))}}}"
        for name, allowed, count in zip(
            domain.conditions, self.pattern, domain.levels, strict=True
        )
        if len(allowed) < count
    ]
    return "*".join(parts) if parts else "1"

MultiValueSolution dataclass

MultiValueSolution(cubes: tuple[MultiValueCube, ...])

A minimal cover of multi-value configurations.

literal_count

literal_count(domain: MultiValueDomain) -> int

Return the total number of constrained conditions.

Source code in src/setqca/multivalue/_cube.py
def literal_count(self, domain: MultiValueDomain) -> int:
    """Return the total number of constrained conditions."""
    return sum(cube.literals(domain) for cube in self.cubes)

as_expression

as_expression(domain: MultiValueDomain) -> str

Render the whole cover, for example A{0}*B{1} + A{2}.

Source code in src/setqca/multivalue/_cube.py
def as_expression(self, domain: MultiValueDomain) -> str:
    """Render the whole cover, for example ``A{0}*B{1} + A{2}``."""
    return " + ".join(cube.as_expression(domain) for cube in self.cubes)

covers

covers(index: int, domain: MultiValueDomain) -> bool

Return whether any cube covers a configuration.

Source code in src/setqca/multivalue/_cube.py
def covers(self, index: int, domain: MultiValueDomain) -> bool:
    """Return whether any cube covers a configuration."""
    return any(cube.covers(index, domain) for cube in self.cubes)

MultiValueDomain dataclass

MultiValueDomain(
    conditions: tuple[str, ...], levels: tuple[int, ...]
)

Condition names and how many levels each takes.

Parameters:

Name Type Description Default
conditions tuple of str

Condition names, in the order used for indexing.

required
levels tuple of int

Number of categories per condition. Level values are 0..levels-1.

required

size property

size: int

Return the number of logically possible configurations.

width property

width: int

Return the number of conditions.

from_mapping classmethod

from_mapping(levels: Mapping[str, int]) -> MultiValueDomain

Build a domain from a {condition: levels} mapping.

Source code in src/setqca/multivalue/_domain.py
@classmethod
def from_mapping(cls, levels: Mapping[str, int]) -> MultiValueDomain:
    """Build a domain from a ``{condition: levels}`` mapping."""
    return cls(tuple(levels), tuple(levels.values()))

index_of

index_of(values: Sequence[int]) -> int

Return the mixed-radix index of one configuration.

Raises:

Type Description
ValueError

If the length is wrong or a value is outside its condition's range.

Source code in src/setqca/multivalue/_domain.py
def index_of(self, values: Sequence[int]) -> int:
    """Return the mixed-radix index of one configuration.

    Raises
    ------
    ValueError
        If the length is wrong or a value is outside its condition's range.
    """
    if len(values) != self.width:
        raise ValueError(f"Expected {self.width} values, got {len(values)}.")
    index = 0
    for value, count in zip(values, self.levels, strict=True):
        if not 0 <= value < count:
            raise ValueError(f"Value {value} is outside the range 0..{count - 1}.")
        index = index * count + value
    return index

values_of

values_of(index: int) -> tuple[int, ...]

Return the configuration at a mixed-radix index.

Raises:

Type Description
ValueError

If the index is outside the property space.

Source code in src/setqca/multivalue/_domain.py
def values_of(self, index: int) -> tuple[int, ...]:
    """Return the configuration at a mixed-radix index.

    Raises
    ------
    ValueError
        If the index is outside the property space.
    """
    if not 0 <= index < self.size:
        raise ValueError(f"Index {index} is outside the property space of size {self.size}.")
    values: list[int] = []
    remaining = index
    for count in reversed(self.levels):
        values.append(remaining % count)
        remaining //= count
    return tuple(reversed(values))

configurations

configurations() -> Iterator[tuple[int, ...]]

Yield every configuration, in index order.

Source code in src/setqca/multivalue/_domain.py
def configurations(self) -> Iterator[tuple[int, ...]]:
    """Yield every configuration, in index order."""
    yield from product(*(range(count) for count in self.levels))

MVQCA dataclass

MVQCA(
    consistency: float = 0.8,
    frequency: int = 1,
    max_solutions: int = 256,
    levels: Mapping[str, int] | None = None,
)

Multi-value Qualitative Comparative Analysis estimator.

Parameters:

Name Type Description Default
consistency float

Inclusion cutoff on sufficiency consistency.

0.8
frequency int

Minimum number of cases for a configuration to be observed.

1
max_solutions int

Upper bound on tied minimal covers.

256
levels mapping of str to int

Declared number of categories per condition. Supply this when a level is theoretically possible but happens to have no cases, since it changes the property space and therefore the remainders.

None

fit

fit(
    data: DataFrame,
    *,
    outcome: str,
    conditions: list[str] | tuple[str, ...],
    case_id: str | None = None,
) -> MultiValueResult

Fit mvQCA to categorical conditions and a calibrated outcome.

Source code in src/setqca/multivalue/_model.py
def fit(
    self,
    data: pd.DataFrame,
    *,
    outcome: str,
    conditions: list[str] | tuple[str, ...],
    case_id: str | None = None,
) -> MultiValueResult:
    """Fit mvQCA to categorical conditions and a calibrated outcome."""
    table = build_multivalue_truth_table(
        data,
        outcome=outcome,
        conditions=conditions,
        levels=self.levels,
        inclusion_cutoff=self.consistency,
        frequency_cutoff=self.frequency,
        case_id=case_id,
    )
    conservative = table.minimize(max_solutions=self.max_solutions)
    parsimonious = table.minimize(include_remainders=True, max_solutions=self.max_solutions)

    y = validate_membership(data[outcome].to_numpy(), name=outcome)
    codes = data[list(table.domain.conditions)].to_numpy().astype(np.int64)
    fits: dict[str, SufficiencyFit] = {}
    for solution in (*conservative, *parsimonious):
        expression = solution.as_expression(table.domain)
        if expression in fits:
            continue
        membership = np.asarray(
            [
                1.0 if solution.covers(table.domain.index_of(tuple(row)), table.domain) else 0.0
                for row in codes
            ],
            dtype=np.float64,
        )
        fits[expression] = sufficiency(membership, y)

    return MultiValueResult(
        domain=table.domain,
        outcome=outcome,
        truth_table=table,
        conservative=conservative,
        parsimonious=parsimonious,
        fits=fits,
    )

MultiValueResult dataclass

MultiValueResult(
    domain: MultiValueDomain,
    outcome: str,
    truth_table: MultiValueTruthTable,
    conservative: tuple[MultiValueSolution, ...],
    parsimonious: tuple[MultiValueSolution, ...],
    fits: dict[str, SufficiencyFit] = dict(),
)

A fitted mvQCA analysis.

summary_frame

summary_frame(solution: str = 'conservative') -> DataFrame

Return one row per minimal solution of a family.

Source code in src/setqca/multivalue/_model.py
def summary_frame(self, solution: str = "conservative") -> pd.DataFrame:
    """Return one row per minimal solution of a family."""
    if solution not in ("conservative", "parsimonious"):
        raise ValueError(
            f"Unknown solution kind {solution!r}; expected 'conservative' or 'parsimonious'."
        )
    solutions: tuple[MultiValueSolution, ...] = getattr(self, solution)
    expressions = [item.as_expression(self.domain) for item in solutions]
    return pd.DataFrame(
        {
            "solution": expressions,
            "n_cubes": [len(item.cubes) for item in solutions],
            "n_literals": [item.literal_count(self.domain) for item in solutions],
            "consistency": [
                self.fits[expression].consistency if expression in self.fits else float("nan")
                for expression in expressions
            ],
            "coverage": [
                self.fits[expression].coverage if expression in self.fits else float("nan")
                for expression in expressions
            ],
        }
    )

MultiValueRow dataclass

MultiValueRow(
    index: int,
    configuration: tuple[int, ...],
    frequency: int,
    consistency: float,
    pri: float,
    outcome: MultiValueCode,
    cases: tuple[str, ...],
    exclusion_reason: str | None = None,
)

One configuration of the multi-value property space.

observed property

observed: bool

Return whether the configuration passed the frequency cutoff.

MultiValueTruthTable dataclass

MultiValueTruthTable(
    domain: MultiValueDomain,
    outcome_name: str,
    rows: tuple[MultiValueRow, ...],
    inclusion_cutoff: float,
    frequency_cutoff: int,
)

A complete multi-value truth table.

positive_indices property

positive_indices: set[int]

Return configurations coded sufficient.

remainder_indices property

remainder_indices: set[int]

Return configurations with too few cases to judge.

rows_with

rows_with(
    code: MultiValueCode,
) -> tuple[MultiValueRow, ...]

Return rows carrying one outcome code.

Source code in src/setqca/multivalue/_model.py
def rows_with(self, code: MultiValueCode) -> tuple[MultiValueRow, ...]:
    """Return rows carrying one outcome code."""
    return tuple(row for row in self.rows if row.outcome == code)

to_frame

to_frame() -> DataFrame

Return a tidy representation, one row per configuration.

Source code in src/setqca/multivalue/_model.py
def to_frame(self) -> pd.DataFrame:
    """Return a tidy representation, one row per configuration."""
    records: list[dict[str, Any]] = []
    for row in self.rows:
        record: dict[str, Any] = dict(
            zip(self.domain.conditions, row.configuration, strict=True)
        )
        record.update(
            {
                "index": row.index,
                "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[MultiValueSolution, ...]

Minimise directly from the table.

Raises:

Type Description
ValueError

If no configuration is coded sufficient.

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

    Raises
    ------
    ValueError
        If no configuration is coded sufficient.
    """
    on_set = self.positive_indices
    if not on_set:
        raise ValueError("No configuration is sufficient under the chosen thresholds.")
    return minimize_multivalue(
        on_set,
        domain=self.domain,
        dont_cares=self.remainder_indices if include_remainders else None,
        max_solutions=max_solutions,
    )

minimize_multivalue

minimize_multivalue(
    on_set: set[int],
    *,
    domain: MultiValueDomain,
    dont_cares: set[int] | None = None,
    max_solutions: int = 256,
) -> tuple[MultiValueSolution, ...]

Return every exact minimum cover of a multi-value problem.

Parameters:

Name Type Description Default
on_set set of int

Configuration indices that must be covered.

required
domain MultiValueDomain

The property space.

required
dont_cares set of int

Configurations usable but not required, typically logical remainders.

None
max_solutions int

Upper bound on tied minimum covers.

256

Returns:

Type Description
tuple of MultiValueSolution

Every cover of provably minimal cost.

Source code in src/setqca/multivalue/_cube.py
def minimize_multivalue(
    on_set: set[int],
    *,
    domain: MultiValueDomain,
    dont_cares: set[int] | None = None,
    max_solutions: int = 256,
) -> tuple[MultiValueSolution, ...]:
    """Return every exact minimum cover of a multi-value problem.

    Parameters
    ----------
    on_set : set of int
        Configuration indices that must be covered.
    domain : MultiValueDomain
        The property space.
    dont_cares : set of int, optional
        Configurations usable but not required, typically logical remainders.
    max_solutions : int, default 256
        Upper bound on tied minimum covers.

    Returns
    -------
    tuple of MultiValueSolution
        Every cover of provably minimal cost.
    """
    required = set(on_set)
    if not required:
        return (MultiValueSolution(()),)

    primes = prime_cubes(required, set(dont_cares or ()), domain)
    covered = [
        frozenset(index for index in required if cube.covers(index, domain)) for cube in primes
    ]
    literals = [cube.literals(domain) for cube in primes]
    choices = solve_minimum_cover(covered, literals, required, max_solutions=max_solutions)
    return tuple(MultiValueSolution(tuple(primes[i] for i in indices)) for indices in choices)

prime_cubes

prime_cubes(
    on_set: set[int],
    dont_cares: set[int],
    domain: MultiValueDomain,
) -> tuple[MultiValueCube, ...]

Generate every prime cube for a multi-value problem.

Parameters:

Name Type Description Default
on_set set of int

Configuration indices that must be covered.

required
dont_cares set of int

Configurations usable but not required.

required
domain MultiValueDomain

The property space.

required

Returns:

Type Description
tuple of MultiValueCube

Prime cubes, ordered by literal count then rendered form, and filtered to those covering at least one required configuration.

Raises:

Type Description
ValueError

If the two sets overlap.

Source code in src/setqca/multivalue/_cube.py
def prime_cubes(
    on_set: set[int], dont_cares: set[int], domain: MultiValueDomain
) -> tuple[MultiValueCube, ...]:
    """Generate every prime cube for a multi-value problem.

    Parameters
    ----------
    on_set : set of int
        Configuration indices that must be covered.
    dont_cares : set of int
        Configurations usable but not required.
    domain : MultiValueDomain
        The property space.

    Returns
    -------
    tuple of MultiValueCube
        Prime cubes, ordered by literal count then rendered form, and filtered
        to those covering at least one required configuration.

    Raises
    ------
    ValueError
        If the two sets overlap.
    """
    if on_set & dont_cares:
        raise ValueError("on_set and dont_cares must be disjoint.")
    universe = on_set | dont_cares
    if not universe:
        return ()

    generated = {MultiValueCube.from_configuration(domain.values_of(index)) for index in universe}
    frontier = set(generated)

    while frontier:
        produced: set[MultiValueCube] = set()
        ordered = sorted(frontier, key=lambda cube: tuple(sorted(sorted(s) for s in cube.pattern)))
        for position, left in enumerate(ordered):
            for right in ordered[position + 1 :]:
                merged = left.merge(right)
                if merged is not None and merged not in generated:
                    produced.add(merged)
        generated |= produced
        frontier = produced

    # A cube contained in another is not prime. Equality is excluded so that
    # two identical cubes do not eliminate each other.
    primes = [
        cube
        for cube in generated
        if not any(other != cube and other.contains(cube) for other in generated)
    ]
    useful = [cube for cube in primes if any(cube.covers(index, domain) for index in on_set)]
    return tuple(
        sorted(useful, key=lambda cube: (cube.literals(domain), cube.as_expression(domain)))
    )

build_multivalue_truth_table

build_multivalue_truth_table(
    data: DataFrame,
    *,
    outcome: str,
    conditions: list[str] | tuple[str, ...],
    levels: Mapping[str, int] | None = None,
    inclusion_cutoff: float = 0.8,
    frequency_cutoff: int = 1,
    case_id: str | None = None,
) -> MultiValueTruthTable

Build a complete multi-value truth table.

Parameters:

Name Type Description Default
data DataFrame

Condition columns holding integer category codes from 0, and an outcome column holding memberships in [0, 1].

required
outcome str

Name of the outcome column.

required
conditions list of str or tuple of str

Condition columns.

required
levels mapping of str to int

Number of categories per condition. Inferred from the data when omitted, which can understate a level that no case happens to take.

None
inclusion_cutoff float

Minimum sufficiency consistency for a configuration to count.

0.8
frequency_cutoff int

Minimum number of cases for a configuration to be observed.

1
case_id str

Column holding case labels. Defaults to the frame index.

None

Returns:

Type Description
MultiValueTruthTable

One row per logically possible configuration.

Raises:

Type Description
ValueError

If a condition is not categorical, a level count is too small, or a cutoff is out of range.

Source code in src/setqca/multivalue/_model.py
def build_multivalue_truth_table(
    data: pd.DataFrame,
    *,
    outcome: str,
    conditions: list[str] | tuple[str, ...],
    levels: Mapping[str, int] | None = None,
    inclusion_cutoff: float = 0.8,
    frequency_cutoff: int = 1,
    case_id: str | None = None,
) -> MultiValueTruthTable:
    """Build a complete multi-value truth table.

    Parameters
    ----------
    data : pandas.DataFrame
        Condition columns holding integer category codes from ``0``, and an
        outcome column holding memberships in ``[0, 1]``.
    outcome : str
        Name of the outcome column.
    conditions : list of str or tuple of str
        Condition columns.
    levels : mapping of str to int, optional
        Number of categories per condition. Inferred from the data when
        omitted, which can understate a level that no case happens to take.
    inclusion_cutoff : float, default 0.8
        Minimum sufficiency consistency for a configuration to count.
    frequency_cutoff : int, default 1
        Minimum number of cases for a configuration to be observed.
    case_id : str, optional
        Column holding case labels. Defaults to the frame index.

    Returns
    -------
    MultiValueTruthTable
        One row per logically possible configuration.

    Raises
    ------
    ValueError
        If a condition is not categorical, a level count is too small, or a
        cutoff is out of range.
    """
    if not isinstance(data, pd.DataFrame):
        raise TypeError("data must be a pandas DataFrame.")
    names = validate_columns(data, conditions)
    if not names:
        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].")
    if frequency_cutoff < 1:
        raise ValueError("frequency_cutoff must be at least 1.")

    counts = _levels(data, names)
    if levels is not None:
        missing = [name for name in names if name not in levels]
        if missing:
            raise KeyError(f"Levels missing for conditions: {missing}")
        declared = tuple(int(levels[name]) for name in names)
        for name, observed, stated in zip(names, counts, declared, strict=True):
            if stated < observed:
                raise ValueError(
                    f"Condition {name!r} declares {stated} levels but the data use {observed}."
                )
        counts = declared

    domain = MultiValueDomain(tuple(names), counts)
    y = validate_membership(data[outcome].to_numpy(), name=outcome)
    codes = data[names].to_numpy().astype(np.int64)

    if case_id is None:
        labels = np.asarray([str(index) for index in data.index], dtype=object)
    else:
        validate_columns(data, [case_id])
        labels = data[case_id].astype(str).to_numpy(dtype=object)

    rows: list[MultiValueRow] = []
    for configuration in domain.configurations():
        selector = np.all(codes == np.asarray(configuration), axis=1)
        n = int(selector.sum())
        # Each case belongs to exactly one configuration, so membership is crisp.
        membership = selector.astype(np.float64)
        fit = sufficiency(membership, y)

        reason: str | None = None
        if n < frequency_cutoff:
            code: MultiValueCode = "R"
            reason = f"frequency {n} below the cutoff of {frequency_cutoff}"
        elif fit.consistency >= inclusion_cutoff:
            code = "1"
        else:
            code = "0"
            reason = (
                f"consistency {fit.consistency:.3f} below the inclusion cutoff "
                f"of {inclusion_cutoff}"
            )

        rows.append(
            MultiValueRow(
                index=domain.index_of(configuration),
                configuration=configuration,
                frequency=n,
                consistency=fit.consistency,
                pri=fit.pri,
                outcome=code,
                cases=tuple(str(value) for value in labels[selector]),
                exclusion_reason=reason,
            )
        )

    return MultiValueTruthTable(
        domain=domain,
        outcome_name=outcome,
        rows=tuple(rows),
        inclusion_cutoff=inclusion_cutoff,
        frequency_cutoff=frequency_cutoff,
    )