Skip to content

Understanding Conditions

Want more background information on Vulcan's Conditions?

Read the Concepts

Conditions represent the evaluation criteria of a Rule. They determine whether a Rule's Action should be applied to the Working Memory. During inferencing, Vulcan uses the Facts referenced within a Condition to filter the applicable rules to only those affected by additions or changes in the Working Memory.

A simple Condition definition:

Python
1
2
3
4
5
6
7
8
from vulcan_core import Fact, condition


class Inventory(Fact):
    apples: int


cond = condition(lambda: Inventory.apples > 0)
API Reference: Fact | condition

Condition Constraints

Vulcan's syntax is designed to be simple, yet flexible enough to express complex reasoning tasks. By leveraging Python's typing and metaprogramming features, Vulcan encourages a declarative style of programming that enhances the developer experience when used with modern IDE static analysis and autocompletion. As a result, boilerplate code and the possibility of errors are minimized.

To ensure a consistent and error-resistant interface for Vulcan rules, Vulcan imposes some requirements on how Conditions may be defined and used.

No Lambda Parameters

Correct

Python
cond = condition(lambda: Inventory.apples > 0)

Lambda Conditions are not allowed to define parameters. When a Condition is defined, Vulcan will automatically generate a set of parameters for the lambda based on the referenced Facts. This allows a more compact syntax for developers, but may be unintuitive for those used to defining lambda parameters in other contexts.

If a Condition receives a lambda with defined parameters, runtime errors will occur:

Incorrect: Lambda Parameters

Python
cond = condition(lambda x: Inventory.apples > 0)
Error
CallableSignatureError: Lambda expressions must not have parameters

Conditions Must Return a Boolean

Correct

Python
1
2
3
4
5
6
7
8
# Lambda Form:
cond = condition(lambda: True)


# Function Form:
@condition
def condition_func(inv: Inventory) -> bool:
    return inv.apples > 0

To be useful in a Rule, Conditions must return a Python boolean type. If a Condition does not return a boolean, static and runtime errors will occur:

Incorrect: Non-Boolean Return

Python
1
2
3
4
5
6
7
8
# Lambda Form:
cond = condition(lambda: "true")  # (1)!


# Function Form:
@condition
def condition_func(inv: Inventory):
    return inv.apples > 0
  1. Modern IDEs with type checking will eagerly display an error on this line.
Error
CallableSignatureError: Return type hint is required and must be <class 'bool'>

Use a Python Type Checker

Vulcan is unable to inspect the return type of a lambda function at runtime. Therefore, it is highly recommended to ensure your project is configured with Python linting and type checking to catch errors early.

Working With Conditions

Vulcan's Conditions are designed to promote simplicity and readability, while offering developers features to express complex logic. As a result, there are a number of ways to define and use Conditions in Rules, including lambdas, functions, and via composition with other conditions.

Lambdas as Conditions

The most simple form of a Condition are Python lambdas. For simple logic, they are compact, easy to read, and can be declared inline with Rule definitions.

Below is an example of a lambda Condition referencing multiple Facts:

Python
1
2
3
4
5
6
7
8
9
class Inventory(Fact):
    apples: int


class CurrentDate(Fact):
    is_weekend: bool


order_more_apples = condition(lambda: Inventory.apples <= 5 and not CurrentDate.is_weekend)

Conditions are Composable

To improve Rule readability, consider whether your conditions should be composed of smaller conditions rather than a single complex condition.

Functions as Conditions

For more complicated expressions, functions may be decorated with @condition and used within Rules. The function must declare typed arguments that match Facts in the working memory and the function must return a boolean value.

Python
1
2
3
4
5
6
7
class Inventory(Fact):
    apples: int


@condition
def check_inventory(inv: Inventory) -> bool:
    return inv.apples > 0

Lambda Conditions are Preferred

Vulcan's design philosophy promotes a declarative programming style. Function Conditions should be considered for only the most difficult scenarios. In most cases, composition of smaller Lambda Conditions is possible and the preferred approach.

Condition Composition

Conditions support the &, |, and ~ operators to express logical: "and", "or", and "not". Parenthesis are also allowed to control the left-to-right order of operations. This allows more readable and maintainable Conditions by decomposing complex logic into smaller, reusable components.

The following demonstrate their usage:

Python
class Inventory(Fact):
    apples: int


class CurrentDate(Fact):
    day_of_week: int


is_weekend = condition(lambda: CurrentDate.day_of_week in (0, 6))
low_apple_stock = condition(lambda: Inventory.apples < 5)

# Don't reorder apples on the weekend:
time_to_reorder_apples = low_apple_stock & ~is_weekend

# Test the condition:
result = time_to_reorder_apples(Inventory(apples=4), CurrentDate(day_of_week=0))
print(result)
Text Output
False

Beware Python boolean and, or, and not

The Python boolean and, or, and not operations can not be used to compose Conditions. This is because these operators work at a different level and are unaware of special handling needed to compose Conditions - instead of returning a new Condition object, they will return a boolean value. Attempting to use them will result in static and runtime errors:

Python
1
2
3
4
time_to_reorder_apples = low_apple_stock and not is_weekend

result = time_to_reorder_apples(Inventory(apples=4), CurrentDate(day_of_week=0)) # (1)!
print(result)
  1. Modern IDEs with linting will eagerly display an error on this line.
Error
TypeError: 'bool' object is not callable

Testing Conditions

Developers typically will not need to invoke Conditions directly when used with Rules. However, for complex conditions, it may be useful to test the Condition with unit tests.

Conditions can be evaluated standalone by providing arguments that match the order of the Facts referenced in the Condition:

Python
# Defined in my_models.py:
class Inventory(Fact):
    apples: int


class CurrentDate(Fact):
    is_weekend: bool


# Defined in my_conditions.py:
order_more_apples = condition(lambda: Inventory.apples <= 5 and not CurrentDate.is_weekend)


# Defined in test_conditions.py:
def test_order_more_apples():
    inv = Inventory(apples=3)
    current_date = CurrentDate(is_weekend=False)
    assert order_more_apples(inv, current_date) is True


def test_dont_order_more_apples():
    inv = Inventory(apples=8)
    current_date = CurrentDate(is_weekend=False)
    assert order_more_apples(inv, current_date) is False