Skip to content

This feature is currently in Preview.

Create a path pattern

A path is a sequence of entities joined by relationships. A path pattern describes which sequences count as a match. A pattern is a sequence of node and edge positions: a node position matches an entity of some concept, and an edge position matches a relationship tuple.

You build a pattern with Model.path(), resulting in a PathPattern, by passing arguments of the following types:

  • Node positions are a Concept, like Account, or a reference to one, like Account.ref().
  • Edge positions are a Relationship like Account.sent_to.
  • Sub-patterns are other PathPattern objects, which lets you repeat or filter a whole pattern.

Neighboring arguments join end to end: the last field of one argument is the first field of the next. So m.path(a, Account.sent_to, b) says that a is the first field of an Account.sent_to edge and b is its last field. Attached relationships may also be repeated within a path pattern definition: m.path(Account.sent_to.repeat(1, 3)) matches traversals of one to three Account.sent_to edges in sequence.

The nodes before the first edge are the pattern’s initial node, and the nodes after the last edge are its final node. Together they are the pattern’s endpoints. Every other node position is an interior node. Only the endpoints can be referenced outside the pattern, for example in an enclosing where(), select(), or define(). Interior nodes can only be constrained by a .where() attached to the pattern itself.

The examples in this guide use a small payments model. Account A pays B and C, both of which pay D, and D pays E:

from relationalai.semantics import Integer, Model, String
m = Model("Payments")
Account = m.Concept("Account", identify_by={"name": String})
Transfer = m.Concept("Transfer", identify_by={"id": Integer})
Account.sent_to = m.Relationship(
f"{Account:src} transferred (via {Transfer:transfer}) to {Account:dst}"
)
# Base facts: A -> B -> D, A -> C -> D, D -> E
m.define(*(Account.new(name=name) for name in ["A", "B", "C", "D", "E"]))
transfers = [(1, "A", "B"), (2, "B", "D"), (3, "A", "C"), (4, "C", "D"), (5, "D", "E")]
for transfer_id, src, dst in transfers:
m.define(Transfer.new(id=transfer_id))
m.define(
Account.sent_to(
Account.lookup(name=src),
Transfer.lookup(id=transfer_id),
Account.lookup(name=dst),
)
)

The pattern examples below belong in your model definition, next to the concepts and relationships they use. A pattern on its own does not compute anything. You turn it into results by choosing what to return and defining the result as a relationship, as described in Use path results. The examples here use .exists() to show how a pattern feeds a definition.

Write a fixed-length path without the path library

Section titled “Write a fixed-length path without the path library”

When you know the number of hops, you do not need a path pattern. Describe the pattern using chains in a where() clause:

first, last = Account.ref(), Account.ref()
Account.two_hops_to = m.Relationship(f"{Account:src} reaches {Account:dst} in two hops")
m.where(
first.sent_to.sent_to == last,
).define(Account.two_hops_to(first, last))
  • first.sent_to.sent_to follows one sent_to relationship from first and a second sent_to relationship from there.
  • Relationships resolve to their final field, so the second sent_to is applied to the destination account from the first sent_to, and the == last equality applies to the destination account of the second sent_to relationship.
  • Account.two_hops_to holds every pair the two relationships connect: A to D, B to E, and C to E. A reaches D by two different paths, but a relationship records the pair once.

This is the right tool for a fixed, small number of hops. Use a path pattern when the number of hops varies, or when you need the paths themselves.

The simplest pattern is a single relationship. m.path(Account.sent_to) matches every sent_to relationship, with any Account as the initial node:

Account.pays = m.Relationship(f"{Account:payer} pays {Account:payee}")
payer, payee = Account.ref(), Account.ref()
m.where(
m.path(payer, Account.sent_to, payee).exists()
).define(Account.pays(payer, payee))
  • m.path(payer, Account.sent_to, payee) names the endpoints: payer is the source of the edge and payee is its destination.
  • .exists() turns the pattern into a condition that holds for each connected (payer, payee) pair.
  • The define() records those pairs in Account.pays.
  • When a relationship has more than two fields, like sent_to with its transfer field, the first field is the edge’s source and the last field is its destination. The fields in between are carried along with the path. See Use relationships with more than two fields.

To use a pattern’s endpoints in a definition, give them names with .ref() and place them before the first edge and after the last edge. An endpoint you do not name is still matched, you just cannot refer to it. The following example names both endpoints and uses them in a definition:

Account.reaches = m.Relationship(f"{Account:src} reaches {Account:dst}")
initial_acct, final_acct = Account.ref(), Account.ref()
m.where(
m.path(initial_acct, Account.sent_to.repeat(1, 4), final_acct).exists()
).define(Account.reaches(initial_acct, final_acct))
  • initial_acct and final_acct are the pattern’s endpoints, so they can appear in the define().
  • Account.sent_to.repeat(1, 4) matches one to four sent_to hops in a row.
  • Account.reaches pairs every account with every account it can reach in at most four payments.
  • Only endpoints unify with the enclosing scope. Referencing an interior node in an enclosing where(), select(), or define() raises PathVariableUnificationError. Constrain interior nodes with a .where() attached to the pattern instead; see Filter with .where().

repeat() matches a variable number of hops. It takes an exact count repeat(3), a range repeat(1, 4), or keyword bounds repeat(min=1, max=4). The upper bound must be finite and at least 1.

Where you put repeat() changes what repeats:

SpellingWhat repeatsWhich nodes are constrained
m.path(Flagged.sent_to.repeat(3))The edge sent_toOnly the first node must be a Flagged account.
m.path(Flagged.sent_to).repeat(3)The pattern Flagged.sent_toEvery node except the last must be a Flagged account.
Flagged = m.Concept("Flagged", extends=[Account])
Account.reaches_from_flagged = m.Relationship(f"{Account:src} reaches {Account:dst}")
Account.reaches_through_flagged = m.Relationship(f"{Account:src} reaches {Account:dst}")
initial_acct, final_acct = Account.ref(), Account.ref()
# Repeats the edge: a flagged account, then any two to three accounts.
m.where(
m.path(initial_acct, Flagged.sent_to.repeat(2, 3), final_acct).exists()
).define(Account.reaches_from_flagged(initial_acct, final_acct))
# Repeats the pattern: every account before the last must be flagged.
m.where(
m.path(initial_acct, m.path(Flagged.sent_to).repeat(2, 3), final_acct).exists()
).define(Account.reaches_through_flagged(initial_acct, final_acct))
  • A pattern can be repeated at most once. A second repeat() on the same pattern raises PathRepeatAppliedTwiceError; write a single repeat() with the bounds you want.
  • A repeat is a boundary for names. A reference used inside a repeated pattern cannot also be used outside it, because it is unclear which repetition it would refer to. To name the endpoints of a repeated pattern, put references before and after it, as the second example does with initial_acct and final_acct.
  • Nested repeats are currently disallowed.

Combine several edges and constrain node types

Section titled “Combine several edges and constrain node types”

A pattern can have several edges in a row, with a node between them. Use a different reference, or a different concept, for each node position, because reusing the same concept or reference in two positions is an error:

Account.works_with = m.Relationship(f"{Account:a} works with {Account:b}")
Account.pays_a_partner_of = m.Relationship(f"{Account:src} pays a partner of {Account:dst}")
initial_acct, partner, final_acct = Account.ref(), Account.ref(), Account.ref()
m.where(
m.path(initial_acct.sent_to, partner.works_with, final_acct).exists()
).define(Account.pays_a_partner_of(initial_acct, final_acct))
  • This path pattern describes starting at the node initial_acct, following a sent_to edge to node partner, then following a works_with edge to node final_acct.
  • Each node position has its own reference, so the three accounts can be different.

To constrain an interior node to a subconcept, start the chain from a reference to that subconcept:

Flagged = m.Concept("Flagged", extends=[Account])
Account.pays_through_flagged = m.Relationship(f"{Account:src} pays through a flagged account to {Account:dst}")
initial_acct, flagged, final_acct = Account.ref(), Flagged.ref(), Account.ref()
m.where(
m.path(initial_acct.sent_to.repeat(1, 3), flagged.sent_to.repeat(1, 3), final_acct).exists()
).define(Account.pays_through_flagged(initial_acct, final_acct))
  • All occurrences of the same concept or reference within one m.path(...) refer to the same node, exactly as elsewhere in PyRel. A chain like Account.sent_to starts a node named Account, so two such chains in one pattern name the same node twice, which raises PathInternalError. Start each chain from its own reference instead.
  • A chain inside a pattern can have only one step. Write m.path(x.sent_to, Account.sent_to) or m.path(x.sent_to.repeat(2)) rather than m.path(x.sent_to.sent_to).

Attach filters to a pattern with .where(). Attached filters can constrain any node in the pattern, including interior nodes, and they prune paths during traversal, which makes the query cheaper. The following example filters both endpoints:

Account.balance = m.Property(f"{Account} has balance {Integer:balance}")
Account.reaches_large = m.Relationship(f"{Account:src} reaches a large account {Account:dst}")
initial_acct, final_acct = Account.ref(), Account.ref()
large = (
m.path(initial_acct, Account.sent_to.repeat(1, 4), final_acct)
.where(initial_acct.name == "A", final_acct.balance >= 1000)
)
m.where(large.exists()).define(Account.reaches_large(initial_acct, final_acct))
  • initial_acct.name == "A" constrains the initial node, so the traversal starts only from A.
  • final_acct.balance >= 1000 constrains the final node.
  • Both filters are local: each one mentions a single node. Local filters are applied while the path is traversed, so they cut down the work rather than filtering a finished result.

A filter can mention:

  • One node: a comparison on its properties, like x.balance >= 1000; membership in a subconcept, like Flagged(x); or a relationship on it, like x.is_vip.
  • The two endpoints of a single edge, like m.path(x.sent_to, y.sent_to, z).where(x.balance < y.balance).
  • The pattern’s two endpoints, like initial_acct.balance < final_acct.balance, even across a repeat. This filter is applied after the paths are found, so it does not prune the traversal.

A filter that spans more than one hop and mentions an interior node, like m.path(x.sent_to.repeat(2), y.sent_to, z).where(x.balance < y.balance), is not supported and raises PathInternalError.

  • A .where() after .repeat() raises PathFilterAfterRepeatError, because the names inside the repeated pattern are not visible outside it. Attach filters before repeat(). m.path(Flagged.sent_to).where(Flagged.balance > 0).repeat(2, 3) applies the filter to every repetition.
  • Filters in an enclosing m.where(...) may only refer to a pattern’s endpoints, and they are applied after the paths are found. Prefer an attached .where() whenever the filter is local to a node or an edge.

Use relationships with more than two fields

Section titled “Use relationships with more than two fields”

A relationship with more than two fields can be an edge. Its first field is the edge’s source and its last field is its destination. The fields in between are carried along with the path, and you can read them back from an enumerated path.

In the payments model, Account.sent_to has three fields: the paying account, the Transfer, and the receiving account. m.path(Account.sent_to.repeat(1, 3)) traverses from account to account, and each matched path remembers which transfer it used at each hop. See Work with PathTraversal results for how to read those fields.

A node position can accept more than one concept, and an edge position more than one relationship, with m.union(...):

Account.refunds = m.Relationship(f"{Account:src} refunds {Account:dst}")
Account.transacts_with = m.Relationship(f"{Account:src} transacts with {Account:dst}")
initial_acct, final_acct = Account.ref(), Account.ref()
m.where(
m.path(initial_acct, m.union(Account.sent_to, Account.refunds), final_acct).exists()
).define(Account.transacts_with(initial_acct, final_acct))
  • m.union(Account.sent_to, Account.refunds) matches either edge.
  • initial_acct and final_acct name the endpoints, so the definition can use them.
  • Node references inside a union cannot be used outside it, not even in an attached .where(). Name the node before or after the union instead.
  • In a union of edges, every branch must be a single hop (no multi-step or repeated chains) on a relationship with at least two fields. If every union branch is a chain starting from the same reference, that reference is the node before the union, so you can filter on it.

Use this table when a pattern raises an error or returns unexpected results:

SymptomLikely causeWhat to do
PathInternalError mentioning a concept or reference used twiceThe same concept or ref() appears at two node positions in one pattern.Use a distinct Account.ref() for each node position.
PathVariableUnificationErrorAn interior node is referenced in an enclosing where(), select(), or define().Move the constraint into a .where() attached to the pattern, or make the node an endpoint.
PathFilterAfterRepeatError.where() was called after .repeat().Call .where() first, then .repeat().
PathRepeatAppliedTwiceError.repeat() was called twice on the same pattern.Write a single .repeat() with the bounds you want.
ValueError from repeat()The bounds are negative, min is greater than max, the maximum is 0, or only one of min and max was given.Give a finite range with a maximum of at least 1, like repeat(1, 4).
A define() from a pattern has no rowsThe pattern never matches, or an attached filter excludes every path.Remove the filters one at a time and check the base facts the pattern reads.
Many more paths than expectedThe data has cycles and paths are walks: entities and relationships may repeat.Lower the upper bound of repeat(), filter the nodes, or count first with .all().count().

The following pattern shapes are not yet supported. Each one raises an error today and has a workaround:

Not yet supportedErrorWorkaround
Account.lookup(...) or Account.new(...) as a nodePathInternalErrorUse a reference and filter it: x = Account.ref(); m.path(x.sent_to).where(x.name == "A").
Multi-step chains, like m.path(x.sent_to.sent_to)PathInternalErrorWrite m.path(x.sent_to.repeat(2)) or m.path(x.sent_to, Account.sent_to).
Unbounded repeats, like repeat(1, math.inf)ValueErrorUse a large finite bound, like repeat(1, 40).
Nested repeats, like m.path(Account.sent_to.repeat(2)).repeat(3)PathAPINotImplementedRepeat side by side, or repeat a sub-pattern inside a pattern that is not repeated.
Chain.repeat() outside m.path(), like select(x.sent_to.repeat(3))PathRepeatedChainUsageErrorExpand the chain by hand, or use a path pattern.
Unions of whole patternsPathInternalErrorDefine each pattern’s result separately and combine the relationships.
reverse() and undirected() edgesPathAPINotImplementedDefine the reverse or bidirectional relationship in the model and use it as the edge.
Simple paths, trails, or acyclic pathsNot availablePaths are walks. Post-filter paths to those that do not revisit nodes or edges.

For a deployed model, apply corrections in the model definition, redeploy, and check that the refresh completes before querying again. See Deploy a model for the deployment workflow.