Skip to content

This feature is currently in Preview.

Use path results

A path pattern describes which paths match. .exists() on the pattern tells you whether any path matches. For anything else, a selector says which of the matching paths to keep, and a method on the selector says what to return about them. You can define the result as a relationship in your model, deploy the model, and query the relationship like any other output.

In the following example, .exists() is used to record which accounts can reach each other through a path of up to four payments.

The model has five accounts. Account A pays B and C, both of which pay D, and D pays E.

  1. Define the model and the path result.

    Consider the following model of accounts and transfers:

    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}"
    )
    10 collapsed lines
    # Base facts: A -> B -> D, A -> C -> D, 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(
    Account.sent_to(
    Account.new(name=src),
    Transfer.new(id=transfer_id),
    Account.new(name=dst),
    )
    )
    Account.reaches = m.Relationship(f"{Account:src} reaches {Account:dst}")
    initial_acct, final_acct = Account.ref(), Account.ref()
    reachable = m.path(initial_acct, Account.sent_to.repeat(1, 4), final_acct)
    m.where(reachable.exists()).define(Account.reaches(initial_acct, final_acct))
    • reachable matches paths of one to four sent_to hops, from initial_acct to final_acct.
    • reachable.exists() is a condition that holds for every (initial_acct, final_acct) pair joined by at least one matching path. It is computed without enumerating the paths.
    • The final rule records those pairs in Account.reaches. After deployment, this relationship is the ACCOUNT_REACHES output with SRC and DST columns.
  2. Query the reachable pairs.

    After you have deployed the model, wait for the refresh to finish, then query the result. Choose a tab. Both tabs read the same deployed output.

    • Path results are computed on the logic reasoner, not in SQL. The refresh creates or resumes the configured reasoner when it needs it, so no reasoner setting is required. See How logic reasoners are created.
    • The first refresh after you add a path pattern can take longer while the reasoner starts. Later refreshes queue a job on it.

    Put the query in a separate script that imports the model:

    find_reachable_accounts.py
    from model import Account, m
    src, dst = Account.ref(), Account.ref()
    m.where(
    Account.reaches(src, dst)
    ).select(
    src.name.alias("src"), dst.name.alias("dst")
    ).inspect()
    Output
    src dst
    0 A B
    1 A C
    2 A D
    3 A E
    4 B D
    5 B E
    6 C D
    7 C E
    8 D E

    A reaches every other account. B and C reach D and E, but not each other. E makes no payments, so it reaches nothing.

A path query has up to three parts: the pattern says which paths match, the selector says which matching paths to keep, and a method on the selector says what to return for the kept paths. If you only need to know whether a path exists, call .exists() directly on the pattern; it needs no selector. Selectors that keep a reduced set of paths per group also take .per(...), which says how to group the paths.

MethodKeepsThen call
.exists()Nothing: a condition that a matching path connects the endpointsUse it in where(), or call .as_bool() on it.
.any_shortest()One shortest path per group.per(initial_node, final_node).paths()
.any()One path per group.per(initial_node, final_node).paths()
.all_shortest()Every shortest path per group.per(initial_node, final_node).paths() or .per(initial_node, final_node).count()
.all()Every matching path.paths() or .count()

The only grouping currently supported is the pattern’s endpoint pair, written .per(initial_node, final_node) with the initial and final node references in that order. Any other grouping raises PathSelectorPerError. .all() keeps every path, so it has no .per(...).

The sections below use the payments model from Use a path result and the same reachable pattern.

.exists() returns a condition: a matching path connects the pattern’s initial node to its final node. It is the cheapest result, because it never lists paths, so prefer it whenever you only need to know whether or which endpoints are connected.

Use the condition as a filter in where(), as the walkthrough above does, or call .as_bool() on it to get a True or False value. Which endpoints you select decides what the condition is checked for. The following queries check it from each initial node to each final node, from each initial node to any final node, from any initial node to each final node, and from any initial node to any final node, respectively:

check_path_connectivity.py
from model import final_acct, initial_acct, m, reachable
# Per pair: the connected (initial_acct, final_acct) pairs.
m.where(reachable.exists()).select(initial_acct.name.alias("initial_acct"), final_acct.name.alias("final_acct")).inspect()
# Per initial node: whether each account reaches any account.
m.select(initial_acct.name.alias("initial_acct"), reachable.exists().as_bool().alias("reaches_any")).inspect()
# Per final node: whether any account reaches each account.
m.select(final_acct.name.alias("final_acct"), reachable.exists().as_bool().alias("reached_by_any")).inspect()
# Overall: whether any matching path exists at all.
m.select(reachable.exists().as_bool().alias("any_path")).inspect()
Output
initial_acct final_acct
0 A B
1 A C
...
8 D E
initial_acct reaches_any
0 A True
1 B True
2 C True
3 D True
4 E False
final_acct reached_by_any
0 A False
1 B True
2 C True
3 D True
4 E True
any_path
0 True
  • The per-pair query lists the nine connected pairs, the same ones Account.reaches holds.
  • E makes no payments, so it reaches no account. A receives none, so no account reaches it.
  • With neither endpoint selected, the condition collapses to one value: at least one matching path exists.

.any_shortest() keeps one shortest path for each connected pair of endpoints. .per(initial_node, final_node) is required and defines the pairs. When a pair has several shortest paths, which one is kept is arbitrary. The following example stores one path per pair:

from relationalai.semantics.std.path import PathTraversal
ShortestPath = m.Property(f"{Account:src} reaches {Account:dst} by {PathTraversal:path}")
p = reachable.any_shortest().per(initial_acct, final_acct).paths()
m.where(p).define(ShortestPath(initial_acct, final_acct, p))
  • p is one PathTraversal value per connected pair: a unique identifier for the kept path.
  • ShortestPath is a Property, because each pair determines exactly one kept path. It stores the pair together with its path, so later rules and queries can read the path’s hops. See Work with PathTraversal results.

.any() has the same shape and keeps one matching path per pair, not necessarily a shortest one. Today it returns the same path as .any_shortest(); a future version may return a possibly longer path, faster.

.all().count() returns the number of matching paths, and .all_shortest().per(initial_node, final_node).count() returns the number of shortest paths per pair. Both are computed without listing the paths, so they stay cheap even when the number of paths is exponential.

The count is an Aggregate. Used bare, it is one global total. Group it where you use it by applying .per(...) to the result, as with any other PyRel aggregate:

Account.num_shortest_paths = m.Property(
f"{Account:src} reaches {Account:dst} by {Integer:n} shortest paths"
)
n = reachable.all_shortest().per(initial_acct, final_acct).count()
m.where(c := n.per(initial_acct, final_acct)).define(Account.num_shortest_paths(initial_acct, final_acct, c))
  • The selector’s .per(initial_acct, final_acct) decides which paths count as shortest: the shortest ones for each pair.
  • The result’s .per(initial_acct, final_acct) decides how the count is grouped: one count per pair, so num_shortest_paths is a Property. n.per(final_acct) would instead sum the counts for each final node.
  • A reaches D by two shortest paths, through B and through C, so the count for pair (A, D) is 2. (A, E) also has a count of 2, through B and D or through C and D. Every other reachable pair has a count of 1.
  • A pair with no matching path gets no count, not 0. If you select the pair together with the count, the count shows as missing. Use | 0 to get 0 instead.
  • Counting needs an unambiguous pattern: a pattern with only one way to match each path. An ambiguous pattern, like m.path(x.sent_to.repeat(0, 1), y.sent_to.repeat(0, 1)), raises PathNonDeterminismError.
  • Every walk within the repeat bounds is counted, including walks around cycles.

.all().paths() returns one PathTraversal per matching path, and .all_shortest().per(initial_node, final_node).paths() returns one per shortest path. The following example stores every path:

AccountPath = m.Relationship(f"{Account:src} reaches {Account:dst} by {PathTraversal:path}")
p = reachable.all().paths()
m.where(p).define(AccountPath(initial_acct, final_acct, p))

A PathTraversal is a value that identifies one path. Read its details through its properties:

PropertyFieldsMeaning
p.lengthIntegerThe number of hops in the path.
p.nodesindex, conceptThe entity at each position. Index 0 is the initial entity and index p.length is the final entity.
p.relationshipsindex, relationshipThe name of the relationship traversed at each hop, like "Account.sent_to".
p.relationship_fieldsindex, field_index, fieldThe entity-typed fields of a relationship with more than two fields, by hop and field position.

The relationship at hop i goes from the entity p.nodes(i) to the entity p.nodes(i+1). p.nodes is typed as any entity, so cast it to a concept to read properties: Account(p.nodes).name.

These properties live in the model’s internal schema, not in the deployed public schema. A relationship that stores PathTraversal values, like ShortestPath above, deploys as an output whose path column holds an opaque identifier. To use the hops after deployment, either read them through PyRel or copy them into a relationship of your own. Both work; pick the one that suits how you query.

Read the hops through PyRel. Query the stored paths from a script that imports the model:

from model import Account, ShortestPath, m
from relationalai.semantics.std.path import PathTraversal
src, dst, p = Account.ref(), Account.ref(), PathTraversal.ref()
m.where(
ShortestPath(src, dst, p)
).select(
src.name.alias("src"),
dst.name.alias("dst"),
p.length,
p.nodes["index"],
Account(p.nodes).name.alias("acct_name"),
).inspect()
Output
src dst length index acct_name
0 A B 1 0 A
1 A B 1 1 B
2 A C 1 0 A
3 A C 1 1 C
4 A D 2 0 A
5 A D 2 1 C
6 A D 2 2 D
...

Copy the hops into your own relationship. Define a relationship with the fields you need, so they become columns of a deployed output:

PathHop = m.Property(f"{PathTraversal:path} at hop {Integer:index} visits {Account:account}")
p = reachable.any_shortest().per(initial_acct, final_acct).paths()
m.where(p).define(PathHop(p, p.nodes["index"], Account(p.nodes)))

After deployment, PATH_HOP has PATH, INDEX, and ACCOUNT columns, and ACCOUNT holds the account’s name.

  • Cast p.nodes to the concept you expect, as in Account(p.nodes), when you copy it. Copying p.nodes["concept"] directly stores the entity’s internal identifier instead of its key.
  • For a relationship with more than two fields, p.relationship_fields holds the fields between the source and the destination. In the payments model, Transfer(p.relationship_fields).id gives the transfer used at each hop, with p.relationship_fields["index"] as the hop index. p.relationship_fields["field_index"] is 1 because the transfer occupies the position 1 field in sent_to.
  • Ask for the least you need. .exists(), .any_shortest(), and .count() all run in time polynomial in the size of your data. .all().paths() and .all_shortest().per(...).paths() can be exponential.
  • Count with .count(), not by counting enumerated paths. count(pattern.all().paths()) is significantly less efficient than pattern.all().count().
  • Attach filters to the pattern. A .where() on the pattern that constrains one node or one edge prunes paths during traversal. The same filter in an enclosing m.where(...) is applied after the paths are found. Naming an endpoint with a subconcept reference, like Flagged.ref(), is the cheapest filter of all.
  • Watch for cycles. Paths are walks, so a cycle in the data multiplies the number of paths. Use .all().count() to check the size before enumerating, or check the structure with the graph library’s is_acyclic().

The following tables list the path library’s public API. Each entry links to its API reference page.

MethodDescription
m.path(...)Creates a PathPattern from nodes, edges, and sub-patterns.
path(...)Standalone form of m.path(...) that infers the active model.
.where(...)Attaches filters to the pattern. Call it before .repeat().
.repeat(...)Repeats the whole pattern a fixed number of times or within a range.
.exists()A boolean condition representing existence of a matching path. Needs no selector.
Chain.repeat(...)Repeats a single edge inside a pattern.
MethodDescription
.all()Keeps every matching path. Returns an AllPaths selector with .paths() and .count() methods.
.any()Keeps one path per group; requires .per(...) to define the groups. Returns an AnyPath selector with .paths() method.
.any_shortest()Keeps one shortest path per group; requires .per(...) to define the groups. Returns an AnyShortestPath selector with .paths() method.
.all_shortest()Keeps every shortest path per group; requires .per(...) to define the groups. Returns an AllShortestPaths selector with .paths() and .count() methods.
NameDescription
PathTraversalOne enumerated path, with length, nodes, relationships, and relationship_fields properties.