Skip to content

Derive facts with logic

Derived facts are built from a small set of DSL building blocks. Understanding these building blocks and how they work together is key to writing effective logic in PyRel.

There are four main constructs you will use to build logic:

  • Fragments represent a unit of logic that can be built up from conditions, selections, and definitions. They are built by chaining calls to where, select, and define.
  • Variables are placeholders for entities and values that can be reused in multiple expressions.
  • Expressions are a type of variable that represent the result of performing operations like comparisons or relationship traversals.
  • Chains represent paths through your model’s relationships and properties and can be used to traverse your model and extract values.

Methods like Model.where, Model.select, and Model.define return a Fragment. A fragment is a composable, lazy unit of logic. You build it up by chaining more calls to where, select, and define to add more conditions, outputs, and definitions. Each call returns a new fragment.

The three main fragment methods have different semantics:

  • Fragment.where adds filter conditions to the fragment. It does not specify outputs or definitions, only conditions.
  • Fragment.select specifies the values selected by the fragment. It does not add conditions or definitions.
  • Fragment.define specifies the facts to define. It does not add conditions or output values.

Building a fragment describes logic without returning query results. For how to execute queries and retrieve or export their results, see Query a model with PyRel.

For example, the following snippet builds a fragment that selects customer names for customers with pending orders:

from relationalai.semantics import Model, String
m = Model("MyModel")
Customer = m.Concept("Customer")
Order = m.Concept("Order")
class Status(m.Enum):
PENDING = "pending"
SHIPPED = "shipped"
CANCELLED = "cancelled"
RETURNED = "returned"
Customer.orders = m.Relationship(f"{Customer} places {Order}")
Customer.name = m.Property(f"{Customer} is named {String:name}")
Order.status = m.Property(f"{Order} has status {Status}")
# Build a fragment that selects names of customers with pending orders.
q = m.where(Customer.orders.status == Status.PENDING).select(Customer.name)
  • q is a Fragment instance that represents the logic of filtering customers with pending orders and selecting their names.
  • m.where() creates a new fragment with the specified conditions, namely that the customer’s orders have a status of “pending”.
  • .select() adds a selection to the fragment, specifying that we want to retrieve the names of the customers that meet the conditions.

A Variable is a placeholder for an entity or value that can be reused in multiple expressions. You never have to explicitly create a Variable instance, since most DSL objects are variables, and most DSL methods return variables.

For example, the following fragment uses Concept objects as variables to represent entities that are members of that concept:

m.where(Customer.orders.status == Status.PENDING).select(Customer.name)
  • Customer is a variable that represents customer entities.
  • Customer.orders is a variable that represents orders placed by a customer.
  • Customer.orders.status is a variable that represents the status of a customer’s order.

An Expression is a kind of variable that represents the result of performing operations like comparisons or relationship traversals. Expressions can be used in Model.where to filter results, in Model.define to define new facts, and in Model.select to specify output values.

For instance, the following fragment uses expressions to filter customers with pending orders:

m.where(Customer.orders.status == Status.PENDING).select(Customer.name)
  • Customer.orders.status == Status.PENDING is a boolean expression that filters Customer entities based on the status of their orders.
  • Customer.orders.status is an expression that traverses the relationship from Customer to Order and accesses the status property.
  • Customer.orders and Customer.name are also expressions that represent the orders placed by a customer and the name of a customer, respectively.
  • Customer is not an expression; it is a variable that represents the concept of customers.

Write conditional definitions with Model.where and Model.define

Section titled “Write conditional definitions with Model.where and Model.define”

Derived facts are often conditional: for example, “an order needs review if its promised ship date is before a cutoff”. Conditional definitions are a two-step process:

  1. Write a condition with Model.where

    Use Model.where or Fragment.where to express the condition that determines when the derived fact applies. This can include comparisons, relationship traversals, and any other expressions that evaluate to a boolean.

  2. Define the derived fact with Model.define

    Use Model.define or Fragment.define to specify the derived fact you want to define when the condition is met. This can be a new relationship, a new property value, or concept membership.

Model.where and Fragment.where can take any number of arguments that are boolean expressions.

This includes:

  • Comparison expressions

    These are expressions that compare values using operators like <, >, ==, etc:

    Order.promised_ship_date < cutoff
    Order.status != Status.SHIPPED
    100 <= Order.total_amount <= 500

    For a complete reference with examples, see Work with operators. For method-level API details, see the Variable reference documentation.

  • Concept membership checks

    Call a Concept and pass an entity variable to check if that entity is a member of the concept:

    DelayedOrder(Order)
    NeedsReview(Order)
  • Relationship existence checks

    Call a relationship Chain and pass one entity variable for field in the final relationship to check if that relationship path exists:

    Customer.orders(Order)
    Order.shipments.carrier(Carrier)
  • Logical expressions

    You can combine expressions with logical operators, like & for AND, | for OR, and Model.not_ for NOT:

    (Order.promised_ship_date < cutoff) & (Order.status == Status.PENDING)
    (Order.status == Status.CANCELLED) | (Order.status == Status.RETURNED)
    my_model.not_(DelayedOrder(Order))
    • Python’s and, or, and not cannot be used for logical expressions in the PyRel DSL because they cannot be overloaded to build DSL logic. Always use &, |, and Model.not_ instead.

      • The & operator is often unnecessary because Model.where implicitly ANDs multiple arguments. Use it to group conditions with |, or to combine pre-built filter fragments. See Combine where clauses with &.

      • The | operator short-circuits: if the left side matches, the right side is not applied. For true set union semantics, use Model.union instead. Use Model.union(...) when you want to include matches from both branches.

When you want the definition to read like a statement with conditions, such as “an order needs review if its promised ship date is before a cutoff”, put define before where:

from datetime import datetime
from relationalai.semantics import Integer, Model
CUTOFF = datetime(2026, 1, 1)
m = Model("MyModel")
Order = m.Concept("Order", identify_by={"order_id": Integer})
NeedsReview = m.Concept("NeedsReview", extends=[Order])
# Define some order entities.
m.define(
Order.new(order_id=1, promised_ship_date=datetime(2025, 12, 1), status="shipped"),
Order.new(order_id=2, promised_ship_date=datetime(2025, 12, 15), status="pending"),
Order.new(order_id=3, promised_ship_date=datetime(2026, 2, 1), status="pending"),
)
# Define the derived fact with the condition.
m.define(NeedsReview(Order)).where(Order.status == "pending", Order.promised_ship_date < CUTOFF)
  • m.define(NeedsReview(Order)) defines Order entities as NeedsReview members.
  • .where(...) defines the conditions for which Order entities are considered NeedsReview, namely that the order status is “pending” and the promised ship date is before the cutoff.

When you want the condition to read like a filter, such as “orders that are pending and before a cutoff”, put where before define:

from datetime import datetime
from relationalai.semantics import Integer, Model
CUTOFF = datetime(2026, 1, 1)
m = Model("MyModel")
Order = m.Concept("Order", identify_by={"order_id": Integer})
NeedsReview = m.Concept("NeedsReview", extends=[Order])
# Define some order entities.
m.define(
Order.new(order_id=1, promised_ship_date=datetime(2025, 12, 1), status="shipped"),
Order.new(order_id=2, promised_ship_date=datetime(2025, 12, 15), status="pending"),
Order.new(order_id=3, promised_ship_date=datetime(2026, 2, 1), status="pending"),
)
# Filter first, then define the derived fact.
m.where(Order.status == "pending", Order.promised_ship_date < CUTOFF).define(NeedsReview(Order))
  • m.where(...) expresses the condition as a filter.
  • .define(NeedsReview(Order)) defines NeedsReview membership only for the matching Order entities.

When you want to modularize logic, build up your filter in reusable steps. This is especially useful when you have a base condition (like “pending orders”) that you want to reuse across multiple derived facts.

This snippet uses Model.where to build a reusable filter fragment, then refines it with another where before calling define:

from datetime import datetime
from relationalai.semantics import Integer, Model
CUTOFF = datetime(2026, 1, 1)
m = Model("MyModel")
Order = m.Concept("Order", identify_by={"order_id": Integer})
NeedsReview = m.Concept("NeedsReview", extends=[Order])
# Define some order entities.
m.define(
Order.new(order_id=1, promised_ship_date=datetime(2025, 12, 1), status="shipped"),
Order.new(order_id=2, promised_ship_date=datetime(2025, 12, 15), status="pending"),
Order.new(order_id=3, promised_ship_date=datetime(2026, 2, 1), status="pending"),
)
# Build a reusable base filter.
where_pending = m.where(Order.status == "pending")
# Refine it in a second step, then define the derived fact.
where_pending_before_cutoff = where_pending.where(Order.promised_ship_date < CUTOFF)
where_pending_before_cutoff.define(NeedsReview(Order))
  • where_pending = m.where(...) builds a reusable filter fragment that filters for pending orders.
  • where_pending_before_cutoff = where_pending.where(...) refines that fragment by adding another condition to filter pending orders before the cutoff.
  • where_pending_before_cutoff.define(...) defines the derived fact for orders that match both conditions.
  • Each where creates a new fragment, so you can keep the base filter (where_pending) and build multiple derived facts from it by refining it with different conditions.
  • This is more modular and reusable than writing one big where with all conditions at once.

When you already have separate where fragments and you want to combine them into one ANDed filter, combine them with the & operator:

from datetime import datetime
from relationalai.semantics import Integer, Model
CUTOFF = datetime(2026, 1, 1)
m = Model("MyModel")
Order = m.Concept("Order", identify_by={"order_id": Integer})
NeedsReview = m.Concept("NeedsReview", extends=[Order])
# Define some order entities.
m.define(
Order.new(order_id=1, promised_ship_date=datetime(2025, 12, 1), status="shipped"),
Order.new(order_id=2, promised_ship_date=datetime(2025, 12, 15), status="pending"),
Order.new(order_id=3, promised_ship_date=datetime(2026, 2, 1), status="pending"),
)
where_pending = m.where(Order.status == "pending")
where_before_cutoff = m.where(Order.promised_ship_date < CUTOFF)
# Combine the fragments with & to define the derived fact.
where_pending_before_cutoff = where_pending & where_before_cutoff
where_pending_before_cutoff.define(NeedsReview(Order))
  • where_pending and where_before_cutoff are filter-only fragments.
  • & combines those fragments into a single ANDed filter.
  • Use & rather than Python and because Python and cannot be overloaded to build DSL logic.

You can also combine fragments with the | operator to express fallback logic, where you want to take the left branch if it matches, and if not, take the right branch. Each branch must return the same set of values, so select the value you want to carry forward before combining the branches:

shipped_order = m.where(Order.status == "shipped").select(Order)
after_cutoff_order = m.where(Order.promised_ship_date > CUTOFF).select(Order)
shipped_or_after_cutoff = shipped_order | after_cutoff_order
  • The | operator should be used only when you explicitly want fallback behavior (take the left branch if it matches, otherwise the right). If you are defining mutually exclusive categories, multiple conditional definitions (one per category) are often the clearest case-split pattern.
  • For either/or logic, prefer Model.union when you want to combine matches from multiple branches.

Derived concept membership lets you name reusable categories of entities based on conditions over their properties and relationships. For example, you might want to define a DelayedOrder concept for orders that have a promised ship date before their actual ship date:

from relationalai.semantics import DateTime, Integer, Model
from relationalai.semantics.std.datetime import datetime
m = Model("MyModel")
# Model schema
Shipment = m.Concept("Shipment", identify_by={"id": Integer})
Shipment.shipped_at = m.Property(f"{Shipment} shipped at {DateTime}")
Order = m.Concept("Order", identify_by={"id": Integer})
Order.promised_ship_date = m.Property(f"{Order} promised ship date is {DateTime}")
Order.shipments = m.Relationship(f"{Order} has shipment {Shipment}")
DelayedOrder = m.Concept("DelayedOrder", extends=[Order])
# Define some base Order and Shipment entities
m.define(
o := Order.new(id=1, promised_ship_date=datetime(2025, 12, 1)),
s := Shipment.new(id=1, shipped_at=datetime(2025, 12, 15)),
o.shipments(s),
)
# Define derived concept membership for delayed orders
m.where(
Order.shipments.shipped_at > Order.promised_ship_date,
).define(
DelayedOrder(Order)
)
  • DelayedOrder extends Order, so members are still orders.
  • m.where(...) binds Shipment and Order and filters for late shipments.
  • .define(DelayedOrder(Order)) defines membership for the matching orders.
  • This pattern needs base facts for Order.shipments, Shipment.shipped_at, and Order.promised_ship_date, as shown in the example. If those facts are missing, the condition has no matches to classify.

PyRel overloads Python’s arithmetic, comparison, and logical operators so you can build expressions directly from concepts, properties, and chains. This section lists each operator with an example, and calls out the few cases where PyRel behaves differently from plain Python.

Arithmetic operators build numeric expressions that you can nest, reuse, and assign to derived properties with define.

OperatorMeaningExample
+AdditionOrder.subtotal + Order.tax
-SubtractionOrder.total - Order.discount
*MultiplicationItem.price * Item.quantity
/True divisionOrder.total / Order.item_count
//Floor divisionOrder.item_count // 12
**ExponentiationPerson.age ** 2
%ModuloOrder.order_id % 2
- (unary)Negation-Account.balance
# Use an arithmetic expression to define a derived property value.
m.define(Order.tax_total == Order.subtotal * Order.tax_rate)
# Operands can appear in either order, so a literal can go on the left.
m.define(Order.budget_remaining == 100 - Order.total)
  • Each arithmetic operator also supports a reflected form, so you can put a literal on the left-hand side, as in 1 + Person.age, 100 - Order.total, or 2 ** Person.age.
  • Floor division (//) and modulo (%) follow Python’s sign rules. // floors toward negative infinity, so -7 // 2 is -4, and % takes the sign of the divisor, so -7 % 2 is 1.

Comparison operators return boolean expressions that you can use as the where conditions that drive your definitions.

OperatorMeaning
>Greater than
>=Greater than or equal to
<Less than
<=Less than or equal to
==Equal to
!=Not equal to
# A comparison is a boolean expression you can use as a condition.
m.where(Order.total > 100).define(LargeOrder(Order))
# Combine comparisons with &, wrapping each comparison in parentheses.
m.where((Order.total > 100) & (Order.status != Status.SHIPPED)).define(LargeOrder(Order))
  • The parentheses around each comparison are required. Python binds & more tightly than the comparison operators, so without them it reads Order.total > 100 & Order.total < 500 as Order.total > (100 & Order.total) < 500, which is not what you mean.

Use logical operators to combine conditions with AND, provide fallbacks, and negate conditions.

OperatorMeaning
&AND
|First-match fallback
Model.not_Negation
# & combines conditions with AND (Model.where already ANDs its arguments).
m.where((Order.status == Status.PENDING) & (Order.total > 100)).define(PriorityOrder(Order))
# | provides a fallback: use the left value if present, otherwise the right.
m.define(Order.effective_discount == Order.discount_rate | 0.0)
# Model.not_ negates a condition.
m.where(m.not_(DelayedOrder(Order))).define(OnTimeOrder(Order))
  • Python’s and, or, and not cannot be overloaded to build DSL logic. Always use &, |, and Model.not_ instead.
  • The | operator is first-match fallback, not set union: if the left branch matches, the right branch is ignored. To combine matches from both branches, use Model.union instead. See Use defaults with the fallback operator.

To define a boolean property from a condition or the presence of an optional value, use Variable.as_bool:

# Define a boolean property that is True when the person has a nickname.
m.define(Person.has_nickname == Person.nickname.as_bool())

When you want to compute a property value based on other values and conditions, use a conditional definition with Model.define to assign the property value for matches of the condition.

For example, you might want to compute a delay_in_days property for orders that have shipped late:

from relationalai.semantics import DateTime, Integer, Model
from relationalai.semantics.std.datetime import datetime
m = Model("MyModel")
# Model schema
Shipment = m.Concept("Shipment", identify_by={"id": Integer})
Shipment.shipped_at = m.Property(f"{Shipment} shipped at {DateTime}")
Order = m.Concept("Order", identify_by={"id": Integer})
Order.promised_ship_date = m.Property(f"{Order} promised ship date is {DateTime}")
Order.delay_in_days = m.Property(f"{Order} delay in days is {Integer}")
Order.shipments = m.Relationship(f"{Order} has shipment {Shipment}")
# Define some base Order and Shipment entities
m.define(
o := Order.new(id=1, promised_ship_date=datetime(2025, 12, 1)),
s := Shipment.new(id=1, shipped_at=datetime(2025, 12, 15)),
o.shipments(s),
)
# Define derived property values for delayed orders.
m.where(
Order.shipments.shipped_at > Order.promised_ship_date,
).define(
Order.delay_in_days == datetime.diff("day", Order.promised_ship_date, Order.shipments.shipped_at)
)

You can compute derived relationships to capture connections between entities that are not explicitly stated in your base facts, but can be inferred from them. For example, you might want to define a Customer concept and derive a Customer.delayed_orders relationship to link customers to their delayed orders:

from relationalai.semantics import DateTime, Integer, Model
from relationalai.semantics.std.datetime import datetime
m = Model("MyModel")
# Model schema
Shipment = m.Concept("Shipment", identify_by={"id": Integer})
Shipment.shipped_at = m.Property(f"{Shipment} shipped at {DateTime}")
Order = m.Concept("Order", identify_by={"id": Integer})
Order.promised_ship_date = m.Property(f"{Order} promised ship date is {DateTime}")
Customer = m.Concept("Customer", identify_by={"id": Integer})
Customer.orders = m.Relationship(f"{Customer} places {Order}")
Order.shipments = m.Relationship(f"{Order} has shipment {Shipment}")
# Derived relationship
Customer.delayed_orders = m.Relationship(f"{Customer} has delayed order {Order}")
# Define base facts
m.define(
c := Customer.new(id=1),
o := Order.new(id=1, promised_ship_date=datetime(2025, 12, 1)),
s := Shipment.new(id=1, shipped_at=datetime(2025, 12, 15)),
c.orders(o),
o.shipments(s),
)
# Define the derived relationship for delayed orders.
m.where(
Customer.orders.promised_ship_date < Customer.orders.shipments.shipped_at,
).define(
Customer.delayed_orders(Order)
)
  • The schema declares the base facts you need for the derivation: Customer.orders links customers to orders, and Order.shipments.shipped_at gives you the shipped timestamp to compare against Order.promised_ship_date.
  • Customer.delayed_orders is a derived relationship. Its direction is explicit: it links a Customer (left side) to an Order (right side).
  • The m.define(...) block creates one customer (c), one order (o), one shipment (s), and connects them with c.orders(o) and o.shipments(s).
  • m.where(...) compares the promised ship date of a customer’s order with its shipment timestamp.
  • .define(Customer.delayed_orders(Order)) defines the derived relationship facts.

Match multiple entities of the same type with Concept.ref

Section titled “Match multiple entities of the same type with Concept.ref”

When you need to match multiple entities of the same concept in the same query, use Concept.ref to create distinct variable bindings for each match:

from relationalai.semantics import Model, String
m = Model("MyModel")
# Model schema
Order = m.Concept("Order", identify_by={"id": String})
# Define some base facts.
m.define(
Order.new(id="o1"),
Order.new(id="o2"),
Order.new(id="o3"),
)
# Get pairs of order ids.
o1 = Order.ref()
o2 = Order.ref()
pairs = m.where(o1.id < o2.id).select(
o1.id.alias("order_1_id"),
o2.id.alias("order_2_id"),
)
  • Order.ref() creates a new variable binding for the Order concept.
  • Each call to Order.ref() creates a distinct variable, so o1 and o2 can match different orders in the same query.
  • pairs is a fragment that selects pairs of order IDs. The condition o1.id < o2.id excludes self-pairs and reversed duplicates.

Technically, only one of the Order.ref() calls needs to be distinct to match multiple orders. For example, you could have written the query with just one Order.ref() and one Order:

o1 = Order
o2 = Order.ref()
pairs = m.where(o1.id < o2.id).select(
o1.id.alias("order_1_id"),
o2.id.alias("order_2_id"),
)
Section titled “Match multiple related values with Chain.ref”

When you need to match multiple values from the same multi-valued relationship path in one query, use Chain.ref to create distinct path bindings. This is a good fit for pairwise logic over related entities, like “two different shipments for the same order”:

from relationalai.semantics import Integer, Model
m = Model("MyModel")
# Model schema
Order = m.Concept("Order", identify_by={"id": Integer})
Shipment = m.Concept("Shipment", identify_by={"id": Integer})
Order.shipments = m.Relationship(f"{Order} has shipment {Shipment:shipment}")
# Define some base facts.
m.define(
o := Order.new(id=1),
s1 := Shipment.new(id=1),
s2 := Shipment.new(id=2),
s3 := Shipment.new(id=3),
o.shipments(s1),
o.shipments(s2),
o.shipments(s3),
)
# Get pairs of shipments for each order.
s1 = Order.shipments.ref()
s2 = Order.shipments.ref()
pairs = m.where(s1.id < s2.id).select(
Order.id.alias("order_id"),
s1.id.alias("shipment_1_id"),
s2.id.alias("shipment_2_id"),
)
  • Order.shipments is a Chain that represents the path from Order to Shipment through the shipments relationship.
  • Order.shipments.ref() creates a new variable binding for that path, allowing you to match multiple shipments for the same order in the same query.
  • Each call to Order.shipments.ref() creates a distinct path variable, so s1 and s2 can match different shipments for the same order in the same query.
  • The condition s1.id < s2.id ensures that we only get each pair of shipments once, and that s1 and s2 are not the same shipment
  • Chain.ref makes the path matches distinct when the same path can match multiple values. If you need multiple orders (a self-join on Order), you still need Order.ref.