Skip to content

Configurational expressions

QCA notation — A*~B + C, A*B -> Y — is parsed into a typed tree rather than manipulated as text. Expressions are therefore comparable, simplifiable and evaluable, and solutions come back as structured objects instead of strings.

Parsing

from setqca import parse_expression

node = parse_expression("A*~B + C")
Notation Meaning Alternatives
* conjunction, minimum
+ disjunction, maximum
~A negation, 1 - A !A, -A
-> implication (sufficiency claim) =>
( ) grouping

Condition names follow Python identifier rules, so both the uppercase single letters of the literature and longer descriptive names work.

Nothing is evaluated as code

Parsing is structural — a tokenizer and a recursive-descent parser. An expression taken from a configuration file or from user input cannot execute anything. There is no eval anywhere in this package.

Malformed input raises ExpressionSyntaxError, which points at the position:

>>> parse_expression("A * * B")
ExpressionSyntaxError: Expected a condition name, found '*'
  A * * B
      ^

Precedence

Conjunction binds more tightly than disjunction, and negation more tightly still, so A + B*C means A + (B*C). Parentheses override this, and the printer re-inserts them wherever grouping would otherwise be lost:

>>> from setqca.expressions import format_expression
>>> format_expression(parse_expression("(A + B)*C"))
'(A+B)*C'
>>> format_expression(parse_expression("A + B*C"))
'A+B*C'

Parsing and printing round-trip: text → tree → text → tree gives back a semantically identical tree, which is property-tested.

Evaluation

from setqca import evaluate_expression

membership = evaluate_expression("A*~B", data)

Fuzzy operators are the standard ones — minimum, maximum and 1 - x.

An implication has no membership of its own, because it is a relation between two sets rather than a set. Evaluate it as one:

claim = parse_expression("A*B -> Y")
fit = claim.evaluate_relation(data)
print(fit.consistency, fit.coverage, fit.pri)

Asking for the membership of an implication is an error rather than a silent guess.

Simplification

from setqca import simplify_expression
from setqca.expressions import format_expression

format_expression(simplify_expression("A + A*B"))  # 'A'

Applied: associativity, commutativity, idempotence (A*A = A), double negation (~~A = A) and absorption (A + A*B = A).

The complement laws do not hold

In Boolean algebra A*~A is empty and A+~A is the universe. Neither is true for fuzzy sets. With A = 0.5, min(A, 1-A) = 0.5 and max(A, 1-A) = 0.5 — a case can be half in a set and half in its negation at the same time.

setqca therefore never simplifies those away:

format_expression(simplify_expression("A*~A"))  # 'A*~A', not '0'

This is the single most common way a Boolean-minded simplifier corrupts a fuzzy analysis. Every simplification here is verified to leave membership unchanged on real data.

Comparing expressions

Two expressions that differ only by ordering or nesting are equal after canonicalisation:

from setqca.expressions import equivalent

equivalent(parse_expression("A*B + C"), parse_expression("C + B*A"))  # True

Note this is structural equivalence under the laws above, not semantic equivalence over all possible data. Deciding the latter for fuzzy sets is a different and much harder question.

Configurations

A Configuration is one corner of the property space — a state for every condition — and converts to and from a minterm index:

from setqca.expressions import Configuration

config = Configuration.from_minterm(6, ("A", "B", "C"))
str(config)  # 'A*B*~C'
config.minterm  # 6
config.evaluate(data)

Minterm indices are big-endian over the condition order, matching the truth table and the minimiser.

setqca.expressions

Typed configurational expressions: parsing, canonical form and evaluation.

Expressions are parsed structurally into a typed tree. Nothing in the input is ever evaluated as code, so an expression from a configuration file or a user prompt cannot execute anything.

Examples:

>>> import pandas as pd
>>> from setqca.expressions import evaluate_expression, parse_expression
>>> data = pd.DataFrame({"A": [0.9, 0.2], "B": [0.8, 0.7]})
>>> evaluate_expression("A*~B", data).round(2)
array([0.2, 0.2])
>>> str(parse_expression("A*B -> Y"))
'A*B -> Y'

Condition dataclass

Condition(name: str)

Bases: SetExpression

Named calibrated condition drawn from a column of the data.

evaluate

evaluate(data: DataFrame) -> FloatArray

Return the calibrated membership column for this condition.

Source code in src/setqca/sets.py
def evaluate(self, data: pd.DataFrame) -> FloatArray:
    """Return the calibrated membership column for this condition."""
    if self.name not in data.columns:
        raise KeyError(f"Missing condition column: {self.name}")
    return validate_membership(data[self.name].to_numpy(), name=self.name)

Intersection dataclass

Intersection(operands: tuple[SetExpression, ...])

Bases: SetExpression

Fuzzy conjunction using the minimum t-norm.

evaluate

evaluate(data: DataFrame) -> FloatArray

Return the elementwise minimum across all operands.

Source code in src/setqca/sets.py
def evaluate(self, data: pd.DataFrame) -> FloatArray:
    """Return the elementwise minimum across all operands."""
    if not self.operands:
        raise ValueError("Intersection requires at least one operand.")
    arrays = (operand.evaluate(data) for operand in self.operands)
    return reduce(np.minimum, arrays)

Negation dataclass

Negation(operand: SetExpression)

Bases: SetExpression

Fuzzy-set negation using 1 - membership.

evaluate

evaluate(data: DataFrame) -> FloatArray

Return one minus the membership of the negated operand.

Source code in src/setqca/sets.py
def evaluate(self, data: pd.DataFrame) -> FloatArray:
    """Return one minus the membership of the negated operand."""
    return 1.0 - self.operand.evaluate(data)

SetExpression

Bases: ABC

Abstract fuzzy-set expression over calibrated conditions.

Expressions compose with the standard Python operators & (intersection, minimum t-norm), | (union, maximum s-norm) and ~ (negation).

evaluate abstractmethod

evaluate(data: DataFrame) -> FloatArray

Evaluate membership of the expression for every case.

Parameters:

Name Type Description Default
data DataFrame

Frame of calibrated condition memberships.

required

Returns:

Type Description
FloatArray

Membership of each case in the expression.

Source code in src/setqca/sets.py
@abstractmethod
def evaluate(self, data: pd.DataFrame) -> FloatArray:
    """Evaluate membership of the expression for every case.

    Parameters
    ----------
    data : pandas.DataFrame
        Frame of calibrated condition memberships.

    Returns
    -------
    FloatArray
        Membership of each case in the expression.
    """

Union dataclass

Union(operands: tuple[SetExpression, ...])

Bases: SetExpression

Fuzzy disjunction using the maximum s-norm.

evaluate

evaluate(data: DataFrame) -> FloatArray

Return the elementwise maximum across all operands.

Source code in src/setqca/sets.py
def evaluate(self, data: pd.DataFrame) -> FloatArray:
    """Return the elementwise maximum across all operands."""
    if not self.operands:
        raise ValueError("Union requires at least one operand.")
    arrays = (operand.evaluate(data) for operand in self.operands)
    return reduce(np.maximum, arrays)

Configuration dataclass

Configuration(states: tuple[tuple[str, bool], ...])

One corner of the property space: a state for every condition.

Parameters:

Name Type Description Default
states tuple of (str, bool)

Condition name and whether it is present, in minterm order.

required

conditions property

conditions: tuple[str, ...]

Return the condition names in order.

minterm property

minterm: int

Return the big-endian minterm index of this corner.

to_expression

to_expression() -> SetExpression

Return the conjunction of literals describing this corner.

Source code in src/setqca/expressions/_ast.py
def to_expression(self) -> SetExpression:
    """Return the conjunction of literals describing this corner."""
    if not self.states:
        raise ValueError("A configuration requires at least one condition.")
    literals: list[SetExpression] = [
        Condition(name) if present else Negation(Condition(name))
        for name, present in self.states
    ]
    if len(literals) == 1:
        return literals[0]
    return Intersection(tuple(literals))

evaluate

evaluate(data: DataFrame) -> FloatArray

Return membership in this corner for every case.

Source code in src/setqca/expressions/_ast.py
def evaluate(self, data: pd.DataFrame) -> FloatArray:
    """Return membership in this corner for every case."""
    return self.to_expression().evaluate(data)

from_minterm classmethod

from_minterm(
    minterm: int, conditions: tuple[str, ...]
) -> Configuration

Build a configuration from a big-endian minterm index.

Source code in src/setqca/expressions/_ast.py
@classmethod
def from_minterm(cls, minterm: int, conditions: tuple[str, ...]) -> Configuration:
    """Build a configuration from a big-endian minterm index."""
    width = len(conditions)
    if width == 0:
        raise ValueError("At least one condition is required.")
    if not 0 <= minterm < 2**width:
        raise ValueError("minterm is outside the truth-table domain.")
    bits = [(minterm >> shift) & 1 for shift in reversed(range(width))]
    return cls(tuple((name, bool(bit)) for name, bit in zip(conditions, bits, strict=True)))

Implication dataclass

Implication(
    antecedent: SetExpression, consequent: SetExpression
)

A sufficiency claim antecedent -> consequent.

An implication has no membership of its own: it is a relation between two sets, evaluated as a set-theoretic subset relation rather than as a membership vector.

evaluate_relation

evaluate_relation(data: DataFrame) -> SufficiencyFit

Return the parameters of fit for the claim against the data.

Source code in src/setqca/expressions/_ast.py
def evaluate_relation(self, data: pd.DataFrame) -> SufficiencyFit:
    """Return the parameters of fit for the claim against the data."""
    return sufficiency(
        self.antecedent.evaluate(data),
        self.consequent.evaluate(data),
    )

ExpressionSyntaxError

ExpressionSyntaxError(
    message: str, *, expression: str, position: int
)

Bases: ValueError

Raised when an expression cannot be tokenized or parsed.

The message carries the offending position so the caller can point at it.

Source code in src/setqca/expressions/_tokenizer.py
def __init__(self, message: str, *, expression: str, position: int) -> None:
    self.expression = expression
    self.position = position
    caret = " " * position + "^"
    super().__init__(f"{message}\n  {expression}\n  {caret}")

Token dataclass

Token(kind: TokenKind, text: str, position: int)

A lexical token and where it started in the source text.

TokenKind

Bases: Enum

Lexical category of a token.

canonical

canonical(node: SetExpression) -> SetExpression

Return a structurally canonical form of an expression.

Associativity, commutativity, idempotence and double negation are applied, so two expressions that differ only by those laws canonicalise to the same object and therefore compare equal.

The complement laws are deliberately not applied; see the module docstring.

Source code in src/setqca/expressions/_ast.py
def canonical(node: SetExpression) -> SetExpression:
    """Return a structurally canonical form of an expression.

    Associativity, commutativity, idempotence and double negation are applied,
    so two expressions that differ only by those laws canonicalise to the same
    object and therefore compare equal.

    The complement laws are deliberately not applied; see the module docstring.
    """
    if isinstance(node, Condition):
        return node
    if isinstance(node, Negation):
        inner = canonical(node.operand)
        # ~~A = A holds in the fuzzy algebra because 1 - (1 - x) = x.
        if isinstance(inner, Negation):
            return inner.operand
        return Negation(inner)
    if isinstance(node, Intersection | Union):
        operands = [canonical(operand) for operand in _flatten(node)]
        unique: list[SetExpression] = []
        seen: set[str] = set()
        for operand in sorted(operands, key=format_expression):
            key = format_expression(operand)
            if key not in seen:  # idempotence: A*A = A and A+A = A
                seen.add(key)
                unique.append(operand)
        if len(unique) == 1:
            return unique[0]
        return type(node)(tuple(unique))
    raise TypeError(f"Cannot canonicalise node of type {type(node).__name__}.")

equivalent

equivalent(
    left: SetExpression, right: SetExpression
) -> bool

Return whether two expressions are equal after simplification.

Source code in src/setqca/expressions/_ast.py
def equivalent(left: SetExpression, right: SetExpression) -> bool:
    """Return whether two expressions are equal after simplification."""
    return format_expression(simplify(left)) == format_expression(simplify(right))

format_expression

format_expression(node: SetExpression) -> str

Render a node, parenthesising only where grouping would otherwise be lost.

Parameters:

Name Type Description Default
node SetExpression

Expression to render.

required

Returns:

Type Description
str

Standard QCA notation, for example "(A+B)*~C".

Examples:

>>> from setqca import Condition
>>> a, b, c = Condition("A"), Condition("B"), Condition("C")
>>> format_expression((a | b) & c)
'(A+B)*C'
>>> format_expression(a | (b & c))
'A+B*C'
Source code in src/setqca/expressions/_ast.py
def format_expression(node: SetExpression) -> str:
    """Render a node, parenthesising only where grouping would otherwise be lost.

    Parameters
    ----------
    node : SetExpression
        Expression to render.

    Returns
    -------
    str
        Standard QCA notation, for example ``"(A+B)*~C"``.

    Examples
    --------
    >>> from setqca import Condition
    >>> a, b, c = Condition("A"), Condition("B"), Condition("C")
    >>> format_expression((a | b) & c)
    '(A+B)*C'
    >>> format_expression(a | (b & c))
    'A+B*C'
    """
    if isinstance(node, Condition):
        return node.name
    if isinstance(node, Negation):
        inner = node.operand
        text = format_expression(inner)
        # `~` binds tighter than `*` and `+`, so a compound operand needs bracketing.
        return f"~({text})" if precedence(inner) < precedence(node) else f"~{text}"
    if isinstance(node, Intersection | Union):
        separator = "*" if isinstance(node, Intersection) else "+"
        parts: list[str] = []
        for operand in node.operands:
            text = format_expression(operand)
            if precedence(operand) < precedence(node):
                text = f"({text})"
            parts.append(text)
        return separator.join(parts)
    raise TypeError(f"Cannot format node of type {type(node).__name__}.")

precedence

precedence(node: SetExpression) -> int

Return the binding power of a node, higher binding more tightly.

Source code in src/setqca/expressions/_ast.py
def precedence(node: SetExpression) -> int:
    """Return the binding power of a node, higher binding more tightly."""
    return _PRECEDENCE.get(type(node), 4)

simplify

simplify(node: SetExpression) -> SetExpression

Simplify an expression using only laws valid for fuzzy sets.

Applies flattening, commutative ordering, idempotence, double negation and absorption. Never applies the complement laws, which are false for the minimum/maximum operators.

Parameters:

Name Type Description Default
node SetExpression

Expression to simplify.

required

Returns:

Type Description
SetExpression

A semantically identical expression, in canonical order.

Examples:

>>> from setqca import Condition
>>> from setqca.expressions import format_expression, simplify
>>> a, b = Condition("A"), Condition("B")
>>> format_expression(simplify(a | (a & b)))
'A'
Source code in src/setqca/expressions/_ast.py
def simplify(node: SetExpression) -> SetExpression:
    """Simplify an expression using only laws valid for fuzzy sets.

    Applies flattening, commutative ordering, idempotence, double negation and
    absorption. Never applies the complement laws, which are false for the
    minimum/maximum operators.

    Parameters
    ----------
    node : SetExpression
        Expression to simplify.

    Returns
    -------
    SetExpression
        A semantically identical expression, in canonical order.

    Examples
    --------
    >>> from setqca import Condition
    >>> from setqca.expressions import format_expression, simplify
    >>> a, b = Condition("A"), Condition("B")
    >>> format_expression(simplify(a | (a & b)))
    'A'
    """
    current = canonical(node)
    while True:
        reduced = canonical(_absorb(current))
        if format_expression(reduced) == format_expression(current):
            return reduced
        current = reduced

parse_expression

parse_expression(
    expression: str,
) -> SetExpression | Implication

Parse a configurational expression into a typed tree.

Parameters:

Name Type Description Default
expression str

Standard QCA notation. * is conjunction, + disjunction, ~ (or !/-) negation, and -> (or =>) implication. Parentheses group.

required

Returns:

Type Description
SetExpression or Implication

An :class:~setqca.expressions.Implication when the text contains an arrow, otherwise a set expression.

Raises:

Type Description
ExpressionSyntaxError

If the text is not a well-formed expression. The message includes the offending position.

Examples:

>>> from setqca.expressions import parse_expression
>>> str(parse_expression("A*~B + C"))
'A*~B+C'
>>> str(parse_expression("A*B -> Y"))
'A*B -> Y'
Source code in src/setqca/expressions/_parser.py
def parse_expression(expression: str) -> SetExpression | Implication:
    """Parse a configurational expression into a typed tree.

    Parameters
    ----------
    expression : str
        Standard QCA notation. ``*`` is conjunction, ``+`` disjunction, ``~``
        (or ``!``/``-``) negation, and ``->`` (or ``=>``) implication.
        Parentheses group.

    Returns
    -------
    SetExpression or Implication
        An :class:`~setqca.expressions.Implication` when the text contains an
        arrow, otherwise a set expression.

    Raises
    ------
    ExpressionSyntaxError
        If the text is not a well-formed expression. The message includes the
        offending position.

    Examples
    --------
    >>> from setqca.expressions import parse_expression
    >>> str(parse_expression("A*~B + C"))
    'A*~B+C'
    >>> str(parse_expression("A*B -> Y"))
    'A*B -> Y'
    """
    return _Parser(expression, tokenize(expression)).parse()

parse_set_expression

parse_set_expression(expression: str) -> SetExpression

Parse an expression that must not be an implication.

Use this when the caller needs a membership-valued expression and a relation would be a mistake rather than a variant.

Raises:

Type Description
ExpressionSyntaxError

If the text is malformed, or is an implication.

Source code in src/setqca/expressions/_parser.py
def parse_set_expression(expression: str) -> SetExpression:
    """Parse an expression that must not be an implication.

    Use this when the caller needs a membership-valued expression and a
    relation would be a mistake rather than a variant.

    Raises
    ------
    ExpressionSyntaxError
        If the text is malformed, or is an implication.
    """
    node = parse_expression(expression)
    if isinstance(node, Implication):
        raise ExpressionSyntaxError(
            "Expected a set expression but found an implication",
            expression=expression,
            position=expression.find("->") if "->" in expression else 0,
        )
    return node

tokenize

tokenize(expression: str) -> list[Token]

Split an expression into tokens.

Parameters:

Name Type Description Default
expression str

Source text, for example "A*~B + C" or "A*B -> Y".

required

Returns:

Type Description
list of Token

Tokens terminated by a single :attr:TokenKind.END.

Raises:

Type Description
ExpressionSyntaxError

If the text contains a character that cannot begin a token.

Source code in src/setqca/expressions/_tokenizer.py
def tokenize(expression: str) -> list[Token]:
    """Split an expression into tokens.

    Parameters
    ----------
    expression : str
        Source text, for example ``"A*~B + C"`` or ``"A*B -> Y"``.

    Returns
    -------
    list of Token
        Tokens terminated by a single :attr:`TokenKind.END`.

    Raises
    ------
    ExpressionSyntaxError
        If the text contains a character that cannot begin a token.
    """
    tokens: list[Token] = []
    index = 0
    length = len(expression)

    while index < length:
        char = expression[index]

        if char.isspace():
            index += 1
            continue

        if expression.startswith("->", index):
            tokens.append(Token(TokenKind.IMPLIES, "->", index))
            index += 2
            continue

        if expression.startswith("=>", index):
            tokens.append(Token(TokenKind.IMPLIES, "=>", index))
            index += 2
            continue

        match = _IDENTIFIER.match(expression, index)
        if match is not None:
            tokens.append(Token(TokenKind.IDENTIFIER, match.group(), index))
            index = match.end()
            continue

        kind = _SIMPLE.get(char)
        if kind is not None:
            tokens.append(Token(kind, char, index))
            index += 1
            continue

        raise ExpressionSyntaxError(
            f"Unexpected character {char!r}", expression=expression, position=index
        )

    tokens.append(Token(TokenKind.END, "", length))
    return tokens

evaluate_expression

evaluate_expression(
    expression: str | SetExpression, data: DataFrame
) -> FloatArray

Evaluate an expression against calibrated data.

Parameters:

Name Type Description Default
expression str or SetExpression

Expression text, or an already-parsed tree.

required
data DataFrame

Calibrated condition memberships in [0, 1].

required

Returns:

Type Description
FloatArray

Membership of every case in the expression.

Raises:

Type Description
ExpressionSyntaxError

If the text is malformed or is an implication, which has no membership of its own. Use :meth:Implication.evaluate_relation for those.

Source code in src/setqca/expressions/__init__.py
def evaluate_expression(expression: str | SetExpression, data: pd.DataFrame) -> FloatArray:
    """Evaluate an expression against calibrated data.

    Parameters
    ----------
    expression : str or SetExpression
        Expression text, or an already-parsed tree.
    data : pandas.DataFrame
        Calibrated condition memberships in ``[0, 1]``.

    Returns
    -------
    FloatArray
        Membership of every case in the expression.

    Raises
    ------
    ExpressionSyntaxError
        If the text is malformed or is an implication, which has no membership
        of its own. Use :meth:`Implication.evaluate_relation` for those.
    """
    node = parse_set_expression(expression) if isinstance(expression, str) else expression
    return node.evaluate(data)

simplify_expression

simplify_expression(
    expression: str | SetExpression,
) -> SetExpression

Parse if needed, then simplify using only fuzzy-valid laws.

Parameters:

Name Type Description Default
expression str or SetExpression

Expression text, or an already-parsed tree.

required

Returns:

Type Description
SetExpression

A semantically identical expression in canonical order.

Source code in src/setqca/expressions/__init__.py
def simplify_expression(expression: str | SetExpression) -> SetExpression:
    """Parse if needed, then simplify using only fuzzy-valid laws.

    Parameters
    ----------
    expression : str or SetExpression
        Expression text, or an already-parsed tree.

    Returns
    -------
    SetExpression
        A semantically identical expression in canonical order.
    """
    node = parse_set_expression(expression) if isinstance(expression, str) else expression
    return simplify(node)