diff --git a/docs/guide/truth-tables.md b/docs/guide/truth-tables.md index 4b4a6e5..84bb326 100644 --- a/docs/guide/truth-tables.md +++ b/docs/guide/truth-tables.md @@ -80,6 +80,58 @@ 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: + +```python +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: + +```text +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 +``` + +!!! note "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: + +```python +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 diff --git a/src/setqca/truth_table.py b/src/setqca/truth_table.py index 99c5616..a4255d5 100644 --- a/src/setqca/truth_table.py +++ b/src/setqca/truth_table.py @@ -2,9 +2,10 @@ from __future__ import annotations +import json from dataclasses import dataclass from itertools import product -from typing import Literal +from typing import TYPE_CHECKING, Any, Literal import numpy as np import pandas as pd @@ -12,6 +13,9 @@ from ._validation import FloatArray, validate_columns, validate_membership from .metrics import sufficiency +if TYPE_CHECKING: # pragma: no cover - imported for type checking only + from .minimize.qmc import BooleanSolution + TruthCode = Literal["1", "0", "C", "R"] """Outcome code of a truth-table row. @@ -22,7 +26,16 @@ @dataclass(frozen=True, slots=True) class TruthTableRow: - """A single causal configuration and its empirical fit.""" + """A single causal configuration and its empirical fit. + + Attributes + ---------- + exclusion_reason + 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. + """ minterm: int configuration: tuple[int, ...] @@ -31,12 +44,26 @@ class TruthTableRow: pri: float outcome: TruthCode cases: tuple[str, ...] + exclusion_reason: str | None = None @property def observed(self) -> bool: """Return whether the configuration passed the frequency cutoff.""" return self.outcome != "R" + @property + def excluded_by_threshold(self) -> 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. + """ + return ( + self.outcome != "1" + and self.exclusion_reason is not None + and ("frequency" in self.exclusion_reason or "PRI" in self.exclusion_reason) + ) + @dataclass(frozen=True, slots=True) class TruthTable: @@ -70,6 +97,49 @@ def remainder_minterms(self) -> set[int]: """Return minterms of logical remainders, i.e. rows below the frequency cutoff.""" return {row.minterm for row in self.rows if row.outcome == "R"} + 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) + + def positive_rows(self) -> tuple[TruthTableRow, ...]: + """Return rows coded sufficient for the outcome.""" + return self.rows_with("1") + + def negative_rows(self) -> tuple[TruthTableRow, ...]: + """Return rows coded not sufficient.""" + return self.rows_with("0") + + def contradictions(self) -> tuple[TruthTableRow, ...]: + """Return rows falling between the exclusion and inclusion cutoffs.""" + return self.rows_with("C") + + def remainders(self) -> tuple[TruthTableRow, ...]: + """Return logical remainders: rows below the frequency cutoff.""" + return self.rows_with("R") + + 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) + + 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())}" + ) + def to_frame(self) -> pd.DataFrame: """Return a tidy pandas representation of the truth table. @@ -77,7 +147,8 @@ def to_frame(self) -> pd.DataFrame: ------- pandas.DataFrame One row per configuration, with the condition states followed by - ``minterm``, ``n``, ``consistency``, ``PRI``, ``OUT`` and ``cases``. + ``minterm``, ``n``, ``consistency``, ``PRI``, ``OUT``, ``cases`` + and ``excluded_because``. """ records: list[dict[str, object]] = [] for row in self.rows: @@ -90,11 +161,117 @@ def to_frame(self) -> pd.DataFrame: "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) + 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, + ) + + 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 + ], + } + + @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"]), + ) + + def to_json(self, *, indent: int | None = None) -> str: + """Serialise the table to JSON.""" + return json.dumps(self.to_dict(), indent=indent) + + @classmethod + def from_json(cls, text: str) -> TruthTable: + """Rebuild a table from JSON.""" + return cls.from_dict(json.loads(text)) + def _configuration_membership(memberships: FloatArray, config: tuple[int, ...]) -> FloatArray: """Return membership in a corner of the property space. @@ -219,14 +396,26 @@ def build_truth_table( 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" - elif fit.consistency >= exclusion: - code = "C" else: - code = "0" + # 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), @@ -236,6 +425,7 @@ def build_truth_table( pri=fit.pri, outcome=code, cases=tuple(str(v) for v in case_names[selector]), + exclusion_reason=reason, ) ) diff --git a/tests/test_rendering.py b/tests/test_rendering.py index 6b2ffcc..278a724 100644 --- a/tests/test_rendering.py +++ b/tests/test_rendering.py @@ -28,7 +28,17 @@ def test_truth_table_frame_reports_one_row_per_corner(crisp_data: pd.DataFrame) frame = table.to_frame() assert len(frame) == 4, "a complete truth table has 2**k rows" - assert list(frame.columns) == ["A", "B", "minterm", "n", "consistency", "PRI", "OUT", "cases"] + assert list(frame.columns) == [ + "A", + "B", + "minterm", + "n", + "consistency", + "PRI", + "OUT", + "cases", + "excluded_because", + ] assert frame["minterm"].tolist() == [0, 1, 2, 3] assert frame.loc[frame["minterm"] == 3, "cases"].item() == "c1" assert frame.loc[frame["minterm"] == 1, "OUT"].item() == "R" diff --git a/tests/test_truth_table_object.py b/tests/test_truth_table_object.py new file mode 100644 index 0000000..97069ca --- /dev/null +++ b/tests/test_truth_table_object.py @@ -0,0 +1,188 @@ +"""Tests for the truth table as a reusable analytical object.""" + +from __future__ import annotations + +import pandas as pd +import pytest + +from setqca import build_truth_table +from setqca.truth_table import TruthTable + +# Two clearly sufficient corners, one clearly negative, one unobserved. +DATA = pd.DataFrame( + { + "A": [0.9, 0.9, 0.8, 0.1, 0.1], + "B": [0.9, 0.8, 0.9, 0.1, 0.2], + "Y": [0.95, 0.9, 0.85, 0.1, 0.15], + }, + index=["c1", "c2", "c3", "c4", "c5"], +) + + +def _table(**kwargs: object) -> TruthTable: + return build_truth_table(DATA, outcome="Y", conditions=["A", "B"], **kwargs) # type: ignore[arg-type] + + +class TestRowAccessors: + def test_the_four_codes_partition_the_table(self) -> None: + table = _table() + groups = ( + table.positive_rows() + + table.negative_rows() + + table.contradictions() + + table.remainders() + ) + assert len(groups) == len(table.rows) + assert {row.minterm for row in groups} == {row.minterm for row in table.rows} + + def test_accessors_agree_with_the_minterm_properties(self) -> None: + table = _table() + assert {row.minterm for row in table.positive_rows()} == table.positive_minterms + assert {row.minterm for row in table.negative_rows()} == table.negative_minterms + assert {row.minterm for row in table.contradictions()} == table.contradictory_minterms + assert {row.minterm for row in table.remainders()} == table.remainder_minterms + + def test_rows_come_back_in_minterm_order(self) -> None: + table = _table() + minterms = [row.minterm for row in table.rows] + assert minterms == sorted(minterms) + for accessor in (table.positive_rows, table.negative_rows, table.remainders): + values = [row.minterm for row in accessor()] + assert values == sorted(values) + + def test_rows_carry_their_cases(self) -> None: + table = _table(case_id=None) + positive = table.positive_rows() + assert positive + assert any(row.cases for row in positive) + + +class TestExclusionReasons: + def test_a_sufficient_row_has_no_reason(self) -> None: + for row in _table().positive_rows(): + assert row.exclusion_reason is None + + def test_a_remainder_names_the_frequency_cutoff(self) -> None: + table = _table(frequency_cutoff=2) + remainders = table.remainders() + assert remainders + assert all("frequency" in (row.exclusion_reason or "") for row in remainders) + + def test_a_low_consistency_row_names_consistency(self) -> None: + row = next(row for row in _table().negative_rows() if row.frequency > 0) + assert "consistency" in (row.exclusion_reason or "") + + def test_a_row_failing_only_pri_names_pri_not_consistency(self) -> None: + """The outcome code alone cannot distinguish these two exclusions.""" + # This corner is consistent but its PRI is poor, so only the PRI cutoff + # keeps it out. + frame = pd.DataFrame( + {"A": [0.9, 0.9], "B": [0.9, 0.9], "Y": [0.6, 0.55]}, + index=["c1", "c2"], + ) + table = build_truth_table( + frame, outcome="Y", conditions=["A", "B"], inclusion_cutoff=0.5, pri_cutoff=0.9 + ) + row = next(row for row in table.rows if row.minterm == 3) + + assert row.consistency >= 0.5, "consistency passes" + assert row.pri < 0.9, "PRI does not" + assert "PRI" in (row.exclusion_reason or "") + assert "consistency" not in (row.exclusion_reason or "") + + def test_threshold_exclusions_are_separated_from_evidence_exclusions(self) -> None: + table = _table(frequency_cutoff=2) + excluded = table.excluded_rows() + assert excluded + for row in excluded: + assert row.excluded_by_threshold + # A row with genuinely poor consistency is excluded by the data, and is + # therefore not something a different threshold would rescue. + poor = [row for row in table.negative_rows() if row.frequency >= 2] + for row in poor: + assert not row.excluded_by_threshold + + def test_the_reason_appears_in_the_frame(self) -> None: + frame = _table(frequency_cutoff=2).to_frame() + assert "excluded_because" in frame.columns + assert frame["excluded_because"].str.contains("frequency").any() + + +class TestSummary: + def test_the_summary_counts_every_group(self) -> None: + text = _table(frequency_cutoff=2).summary() + assert "configurations of 2 conditions" in text + assert "remainders:" in text + assert "excluded by a threshold:" in text + + +class TestSerialisation: + def test_a_table_round_trips_through_json(self) -> None: + table = _table(frequency_cutoff=2, inclusion_cutoff=0.75, pri_cutoff=0.1) + restored = TruthTable.from_json(table.to_json()) + + assert restored.conditions == table.conditions + assert restored.outcome_name == table.outcome_name + assert restored.inclusion_cutoff == table.inclusion_cutoff + assert restored.exclusion_cutoff == table.exclusion_cutoff + assert restored.pri_cutoff == table.pri_cutoff + assert restored.frequency_cutoff == table.frequency_cutoff + assert restored.rows == table.rows + + def test_the_exclusion_reasons_survive(self) -> None: + table = _table(frequency_cutoff=2) + restored = TruthTable.from_json(table.to_json()) + assert [row.exclusion_reason for row in restored.rows] == [ + row.exclusion_reason for row in table.rows + ] + + def test_the_dictionary_form_is_json_compatible(self) -> None: + import json + + payload = _table().to_dict() + assert json.loads(json.dumps(payload)) == payload + + def test_a_missing_key_is_reported(self) -> None: + with pytest.raises(KeyError): + TruthTable.from_dict({"conditions": ["A"]}) + + +class TestMinimisationFromTheTable: + def test_a_stored_table_minimises_without_the_original_data(self) -> None: + table = _table() + restored = TruthTable.from_json(table.to_json()) + + solutions = restored.minimize() + assert solutions + assert solutions[0].as_expression(restored.conditions) + + def test_it_agrees_with_the_estimator(self) -> None: + from setqca import FSQCA + + table = _table() + result = FSQCA(consistency=0.8).fit(DATA, outcome="Y", conditions=["A", "B"]) + direct = table.minimize() + + assert {solution.implicants for solution in direct} == { + solution.boolean.implicants for solution in result.conservative + } + + def test_remainders_can_be_admitted_for_the_parsimonious_result(self) -> None: + from setqca import FSQCA + + table = _table() + result = FSQCA(consistency=0.8).fit(DATA, outcome="Y", conditions=["A", "B"]) + parsimonious = table.minimize(include_remainders=True) + + assert {solution.implicants for solution in parsimonious} == { + solution.boolean.implicants for solution in result.parsimonious + } + + def test_a_table_with_no_sufficient_row_refuses_to_minimise(self) -> None: + table = _table(inclusion_cutoff=1.0, frequency_cutoff=99) + with pytest.raises(ValueError, match="No truth-table row is sufficient"): + table.minimize() + + def test_max_solutions_is_respected(self) -> None: + table = _table() + assert len(table.minimize(max_solutions=1)) == 1