Skip to content

Relations: the categorical address

A number has an address: entity · attribute · period (see an address). A fact that is not a number — Halvard Quist held the title Vice President, Sales at Kestrel Pumps AG from March 2016 to January 2019 — is a relation row: a named, typed record (held_role) whose slots (company, title, seniority, function, geography) are the dimensions of its address, with a half-open period and a receipt. Rows of one relation can be counted, so a question over them has a denominator exactly as a coordinate does.

The relations a workspace can address are drafted from a connected export's own records (a collection nobody wrote a pack for) or come from a pack applied to it (POST /v1/public/apply_pack). The examples on this page run against a workspace with the expert-network pack applied and a warehouse export connected as rows; roster(client) builds it — the tables and every call it makes are in docs/examples/roster.py. Every name in it is invented.

from docs.examples.roster import ROLES, SCREENINGS
print(len(ROLES), "role rows,", len(SCREENINGS), "screening rows")
for row in ROLES:
    print(row[1], "|", row[3], "|", row[6], "|", row[7], "->", row[8])
6 role rows, 3 screening rows
halvard_quist | Vice President, Sales | Germany | 2016-03 -> 2019-01
halvard_quist | Director, Sales | Austria | 2019-01 -> None
isolde_marbrook | Senior Vice President, Sales | Switzerland | 2018-06 -> 2022-02
odile_fenwright | Director, Marketing | Germany | 2014-01 -> 2019-02
teodor_brask | Chief Operating Officer | United States | 2017-01 -> 2023-01
yusuf_alderane | Vice President, Sales | Austria | 2020-04 -> None

The query

A relation is addressed with relation_query on POST /v1/public/facts (over MCP, limma_facts(..., relation_query={...})):

{"relation": "held_role",
 "where": [["seniority", "gte", "VP"], ["geography", "eq", "DACH"]],
 "active_in": 2019,
 "entity_id": null}

You do not have to ask for mode="enumerate": naming an address IS asking for a closed set, and the default honours it. What you cannot do is name TWO addresses — sending coordinate as well is a 422 — or send a closed-set address to the ranked lane, which is what mode="explore" pins.

from docs.examples.roster import roster
ws = roster(client).workspace_id
for extra in ({"coordinate": {"attribute": "revenue"}}, {"mode": "explore"}):
    r = client.post("/v1/public/facts", json={
        "workspace_id": ws, "question": "roles", **extra,
        "relation_query": {"relation": "held_role"}})
    print(r.status_code, r.json()["detail"]["type"])
422 coordinate_and_relation_query
422 relation_query_requires_enumerate

The answer is the closed set, in a relations block beside the usual enumeration and coverage; facts is empty on this lane.

from docs.examples.roster import roster
ws = roster(client).workspace_id
r = client.post("/v1/public/facts", json={
    "workspace_id": ws, "question": "roles", "mode": "enumerate",
    "relation_query": {"relation": "held_role"}})
body = r.json()
rel = body["relations"]
print("count:", rel["count"], "| delivered:", rel["delivered"], "| bound:", rel["bound"],
      "| truncated:", rel["truncated"], "| facts:", body["facts"])
print("entities:", rel["entities"], "| not_recorded:", rel["not_recorded"])
print("enumeration:", body["enumeration"]["exist"], body["enumeration"]["delivered"],
      "| licensed:", body["coverage"]["completeness_licensed"])
count: 6 | delivered: 6 | bound: True | truncated: False | facts: []
entities: {'matched': 5, 'bound': 5, 'not_recorded': 0} | not_recorded: []
enumeration: 6 6 | licensed: True

field meaning
count current rows matching the query in your visible scope, before the page limit; null when the query is unbound
delivered, offset, truncated this page; truncated is true when rows remain past it
bound / unbound_because whether the address exists here (below)
where_run, expansion the where that ran after the vocabulary, and each resolution printed — see vocabulary
entities {matched, bound, not_recorded} — entities with a matching row, entities some load looked at for this relation, entities it knows that nobody looked at
not_recorded the ids of those last entities
absence the per-entity verdict — see None versus Not recorded
undated under active_in, how many rows carry no period_from
rows the rows of this page

A row

from docs.examples.roster import roster
ws = roster(client).workspace_id
r = client.post("/v1/public/facts", json={
    "workspace_id": ws, "question": "roles", "mode": "enumerate",
    "relation_query": {"relation": "held_role", "entity_id": "halvard_quist",
                       "where": [["company", "eq", "kestrel pumps ag"]]}})
row = r.json()["relations"]["rows"][0]
print(row["entity_id"], row["relation"], row["period_from"], row["period_to"], row["as_of"])
print(row["slots"])
print("provenance_kind:", row["provenance_kind"], "| confidence:", row["confidence"],
      "| stated_by:", row["stated_by"], "| receipt:", row["receipt"])
print("handle starts:", row["handle"].split(":")[0])
title = row["slot_receipts"]["title"]
print({k: title[k] for k in ("value", "db_table", "row_key", "db_column", "byte_verified")})
halvard_quist held_role 2016-03 2019-01 2024-06
{'company': 'Kestrel Pumps AG', 'function': 'Sales', 'geography': 'Germany', 'seniority': 'VP', 'title': 'Vice President, Sales'}
provenance_kind: declared | confidence: 1.0 | stated_by: 1 | receipt: None
handle starts: relation
{'value': 'Vice President, Sales', 'db_table': 'roles', 'row_key': '{"row_id":1}', 'db_column': 'title', 'byte_verified': True}

  • slots holds the values as loaded; the index compares term and entity_ref values whitespace-collapsed and case-folded, which is why kestrel pumps ag found the row.
  • provenance_kind is declared for a row a structured load stated and extracted for one a reader recovered from text — a document read for the pack's relations at ingest, or a criterion's judgment of a profile; confidence is 1.0 on a declared row. Every row of this roster is declared: it was loaded from a database export (provenance).
  • A row is ONE fact; sources lists every document in your scope that states it, and stated_by counts them (2 means two documents state the same row). The flat source_id / receipt / slot_receipts are the first statement's.
  • A row loaded from a table has receipt: null and one structured receipt per slot: db_table / row_key / db_column. It is re-read from that cell when the row is delivered — byte_verified: true means the cell still holds the value; reason: row_changed means it no longer does. A row stated by a document carries a span receipt (source_id, char_start, char_end) that limma_region re-reads.
  • handle (relation:<row_id>) and row_id name the row; row_id is what limma_correct takes.

The ops

eq, ne, in, not_in, gte, lte — each a closed predicate. contains, like, regex and the other pattern ops are refused, because a substring match has no closed set to count over:

from docs.examples.roster import roster
ws = roster(client).workspace_id
r = client.post("/v1/public/facts", json={
    "workspace_id": ws, "question": "roles", "mode": "enumerate",
    "relation_query": {"relation": "held_role", "where": [["title", "contains", "Sales"]]}})
print(r.status_code, r.json()["detail"]["detail"])
422 where[0]: op 'contains' is rejected. A substring, pattern or fuzzy match has no closed set to count over, so a completeness claim cannot be made about its result. Ops are ['eq', 'ne', 'in', 'not_in', 'gte', 'lte'].

Value rules, by slot type:

slot type ops how a value compares
term all six; gte / lte only on an ordered vocabulary axis expanded through the workspace vocabulary first (vocabulary), then case-folded
entity_ref eq, ne, in, not_in whitespace-collapsed and case-folded
number all six numerically; written 1000, 1000.5, -3, 1e3 or 1,000.50 — 1,5 is a 422
date all six a value names the WHOLE of what it names under every op: eq "2021" keeps rows dated anywhere in 2021, lte "2021" reaches 2021-12, a day names only itself
boolean eq, ne, in, not_in (the search verb's brief compile offers eq / ne) true / false

Beside the declared slots, period_from and period_to are addressable in where on YYYY-MM values, so one (entity, company, period) address can be named exactly. A bare year is refused there — under half-open intervals "2019" as a bound could mean January or December:

from docs.examples.roster import roster
ws = roster(client).workspace_id
for bound in ("2019-01", "2019"):
    r = client.post("/v1/public/facts", json={
        "workspace_id": ws, "question": "roles", "mode": "enumerate",
        "relation_query": {"relation": "held_role", "where": [["period_to", "eq", bound]]}})
    body = r.json()
    if r.status_code == 200:
        print(bound, "->", body["relations"]["count"], "row:",
              [(x["entity_id"], x["slots"]["company"]) for x in body["relations"]["rows"]])
    else:
        print(bound, "->", r.status_code, body["detail"]["detail"])
2019-01 -> 1 row: [('halvard_quist', 'Kestrel Pumps AG')]
2019 -> 422 where: period_to='2019' is not a month: use YYYY-MM (or YYYY-MM-DD, truncated); a bare year is ambiguous under half-open intervals and is not accepted

active_in and the half-open interval

A row's period is [period_from, period_to): it starts in the period_from month and has ended BY the period_to month. active_in: Y keeps the rows whose interval overlaps year Y:

period_from <= "Y-12"  and  (period_to is null  or  period_to > "Y-01")

The boundary is where it matters. Halvard's Kestrel role runs 2016-03 → 2019-01: it ended by January 2019, so it is not active in 2019. Odile's runs 2014-01 → 2019-02: it was held in January 2019, so it is.

from docs.examples.roster import roster
ws = roster(client).workspace_id
for year in (2018, 2019):
    r = client.post("/v1/public/facts", json={
        "workspace_id": ws, "question": "roles", "mode": "enumerate",
        "relation_query": {"relation": "held_role", "active_in": year}})
    rel = r.json()["relations"]
    print(year, "count:", rel["count"])
    for x in sorted(rel["rows"], key=lambda x: (x["entity_id"], x["period_from"])):
        print("  ", x["entity_id"], x["period_from"], "->", x["period_to"])
2018 count: 4
   halvard_quist 2016-03 -> 2019-01
   isolde_marbrook 2018-06 -> 2022-02
   odile_fenwright 2014-01 -> 2019-02
   teodor_brask 2017-01 -> 2023-01
2019 count: 4
   halvard_quist 2019-01 -> None
   isolde_marbrook 2018-06 -> 2022-02
   odile_fenwright 2014-01 -> 2019-02
   teodor_brask 2017-01 -> 2023-01

A role with period_to: null is open. An open role needs a witness — the month someone last saw it held, as_of on the row (the export's pull month here) — and it counts as active in every year from its start: Halvard's Lumen Ridge role and Yusuf's role are open.

Rows with no period. A screening makes no tenure claim; its date is a slot (as_of here). Such a row is never active in any year, so active_in cannot see it: it is counted in relations.undated and the licence is withheld (narrowed_by: undated_rows) — a zero beside rows the date test could not see is not there are none:

from docs.examples.roster import roster
ws = roster(client).workspace_id
r = client.post("/v1/public/facts", json={
    "workspace_id": ws, "question": "screenings", "mode": "enumerate",
    "relation_query": {"relation": "screening", "active_in": 2024}})
body = r.json()
print("count:", body["relations"]["count"], "| undated:", body["relations"]["undated"])
print(body["coverage"]["completeness_licensed"], body["coverage"]["narrowed_by"])
count: 0 | undated: 3
False ['entities_not_recorded', 'undated_rows']

To ask a dated question of an undated relation, use a where on its date slot ([["as_of", "gte", "2024"]]), or the search verb's as_of, which places such a row by its one date slot (search).

Bound, and why not

bound: false means nothing was addressed, count is null — never 0 — and unbound_because names why:

from docs.examples.roster import roster
ws = roster(client).workspace_id
for query in ({"relation": "board_seat"},
              {"relation": "engagement"},
              {"relation": "held_role", "where": [["title", "gte", "Director, Sales"]]}):
    r = client.post("/v1/public/facts", json={
        "workspace_id": ws, "question": "q", "mode": "enumerate", "relation_query": query})
    rel = r.json()["relations"]
    print(query["relation"], "-> bound:", rel["bound"], "| count:", rel["count"],
          "|", rel["unbound_because"])
board_seat -> bound: False | count: None | relation_unknown:board_seat
engagement -> bound: False | count: None | relation_not_recorded
held_role -> bound: False | count: None | vocabulary_unbound:title

  • relation_unknown:<name> — no pack applied here declares the relation.
  • relation_not_recorded — the relation is declared, and no load has looked at any entity for it (this workspace connected roles and screenings, never engagements).
  • entity_not_recorded / entity_unknown — with entity_id only: the entity is known here (a row or coverage row of some relation of its kind, in your scope) and nobody looked at this relation for it; or the address space holds nothing about that id at all — never loaded, outside your key's scope, erased, or misspelled. Only the first is listed in not_recorded.
  • vocabulary_unbound:<slot> — a comparison on a term slot whose axis has no vocabulary (title has none in this pack), or whose value the order does not know. See vocabulary.

One entity

entity_id narrows the query to one entity and gives its own verdict: bound is whether any load looked for this relation on this entity. Teodor was covered for screenings (the roles export's screened column says so) and holds none; Yusuf was never looked at:

from docs.examples.roster import roster
ws = roster(client).workspace_id
for person in ("halvard_quist", "teodor_brask", "yusuf_alderane"):
    r = client.post("/v1/public/facts", json={
        "workspace_id": ws, "question": "screenings", "mode": "enumerate",
        "relation_query": {"relation": "screening", "entity_id": person}})
    body = r.json()
    rel = body["relations"]
    verdict = rel["absence"]["entity"]
    print(person, "| bound:", rel["bound"], "| count:", rel["count"],
          "| absence:", verdict["kind"] if verdict else None,
          "| licensed:", body["coverage"]["completeness_licensed"])
halvard_quist | bound: True | count: 1 | absence: None | licensed: True
teodor_brask | bound: True | count: 0 | absence: none | licensed: True
yusuf_alderane | bound: False | count: None | absence: not_recorded | licensed: False

count: 0 with bound: true is a licensed none; count: null with bound: false is not recorded and licenses nothing. None versus Not recorded reads the absence verdict in full.

What a relation count licenses

count == delivered with bound: true and coverage.completeness_licensed: true licenses these are all the rows of this relation matching this query, over the documents your key can read. It does not license:

  • that an entity nobody looked at has no such row — read not_recorded (a workspace-wide query withholds the licence while that list is non-empty, narrowed_by: entities_not_recorded);
  • that the values are right — a structured receipt proves the cell still holds the value, not that the warehouse was right; a wrong row is retired with limma_correct;
  • anything past a vocabulary that could not vouch for the set — every such case is named in coverage.narrowed_by (vocabulary).

To ask several conditions across relations at once — a role AND a screening — use the search verb, which enumerates each relation and intersects server-side.