Skip to content

graflo.link.candidates

Gather the entities a staged record may be, without a model.

:func:gather_candidates runs after :func:~graflo.link.resolve.resolve_records (and, on a live graph, after its write) and lists, for each staged record, the entities it may be, by three kinds of evidence:

  • alternate: the entities a stated secondary identity matched -- several, or the record would have resolved (ambiguous);
  • exact / key / normalized / alias / compatible: :func:~graflo.hq.lookup.lookup_candidates over the label fields the record states, the values of fields others are derived from, and the caller's lookup text for it;
  • relation: a relation stated between the record and a resolved one -- "the customer" that RAISED a resolved HD-4411 gives the customers linked to HD-4411 by RAISED;
  • context: a neighbour of the record's type, one hop from an entity the same source resolved by key or a subject it is known to concern -- "the unit" of a ticket about one unit.

One candidate per entity carries every lane that found it, best first, and the record's other stated properties graded against the entity's stored values (:mod:graflo.architecture.schema.compare: exact, compatible, differs, conflict, unknown); stored values are never returned.

Candidates never close a record: only keys do. A pick, a declared rule, an agent or a person chooses among them, and :func:support says whether a candidate's features identify it. A candidate whose stored key or secondary-identity values conflict with what the record states is left out: a record that states email is not an entity holding another email. Nor is the entity a record of the same source, related to this one by a stated relation, resolved to: the relation states two entities.

Attributes

Comparable = Callable[[str, str], bool] module-attribute

IDENTIFYING_LANES = frozenset({'key', 'alternate', 'exact', 'normalized'}) module-attribute

LANE_ORDER = {'alternate': 0, 'exact': 1, 'key': 2, 'normalized': 3, 'alias': 4, 'compatible': 5, 'relation': 6, 'metadata': 7, 'context': 8} module-attribute

LookupText = Callable[[PartialRecord], str | None] module-attribute

Classes

Functions:

gather_candidates(schema, resolution, db=None, *, guard=None, spec=None, lookup_text=None, comparable=None, subjects=None, max_candidates=10)

The entities each staged record of resolution may be, best first.

Parameters:

Name Type Description Default
schema Schema

Schema the records were resolved against.

required
resolution Resolution

What :func:~graflo.link.resolve.resolve_records returned. Relation-path anchors are its resolved records, so run this after their write when they are new to the graph.

required
db Any | None

An open connection. Without one, only the batch's resolved records are searched (the alternate lane), and every attribute is unknown.

None
guard FilterExpression | None

A trusted read guard every graph read passes, as in resolve_records: it applies to the reads of a type that declares every field it names, and on a relation path to every vertex and edge of the path. Refused by a backend without read guards.

None
spec LookupSpec | None

Lookup spec for the label lanes (key patterns, alias hops, label field overrides). Its vertex_types and max_seeds are set per record. The normalized and compatible lanes read the schema's derived fields.

None
lookup_text LookupText | None

Words to look each staged record up by besides the label values it states, such as the passage it was read from; typically taken from the caller's own evidence.

None
comparable Comparable | None

Which stated properties may be read and compared, as (vertex type, field) -> bool; one it refuses is never probed, looked up by or checked, keys included. Declared, unreserved fields are the only ones ever considered.

None
subjects Mapping[str, Sequence[KnownSubject]] | None

Per source id, the entities the source is known to concern (from its metadata), anchors of the context lane besides the records it resolved by key.

None
max_candidates int

Most candidates per record, counted as entities after lanes are merged and keys checked; truncated says when more were found.

10

Returns:

Type Description
list[StagedCandidates]

One entry per staged record, in the resolution's order; an entry with

list[StagedCandidates]

no candidates when nothing was found.

Raises:

Type Description
NotImplementedError

A guard on a backend that cannot apply one.

Source code in graflo/link/candidates.py
def gather_candidates(
    schema: Schema,
    resolution: Resolution,
    db: Any | None = None,
    *,
    guard: FilterExpression | None = None,
    spec: LookupSpec | None = None,
    lookup_text: LookupText | None = None,
    comparable: Comparable | None = None,
    subjects: Mapping[str, Sequence[KnownSubject]] | None = None,
    max_candidates: int = 10,
) -> list[StagedCandidates]:
    """The entities each staged record of *resolution* may be, best first.

    Args:
        schema: Schema the records were resolved against.
        resolution: What :func:`~graflo.link.resolve.resolve_records`
            returned. Relation-path anchors are its resolved records, so run
            this after their write when they are new to the graph.
        db: An open connection. Without one, only the batch's resolved records
            are searched (the ``alternate`` lane), and every attribute is
            ``unknown``.
        guard: A trusted read guard every graph read passes, as in
            ``resolve_records``: it applies to the reads of a type that
            declares every field it names, and on a relation path to every
            vertex and edge of the path. Refused by a backend without read
            guards.
        spec: Lookup spec for the label lanes (key patterns, alias hops,
            label field overrides). Its ``vertex_types`` and ``max_seeds`` are
            set per record. The ``normalized`` and ``compatible`` lanes read
            the schema's derived fields.
        lookup_text: Words to look each staged record up by besides the label
            values it states, such as the passage it was read from; typically
            taken from the caller's own ``evidence``.
        comparable: Which stated properties may be read and compared, as
            ``(vertex type, field) -> bool``; one it refuses is never probed,
            looked up by or checked, keys included. Declared, unreserved
            fields are the only ones ever considered.
        subjects: Per source id, the entities the source is known to concern
            (from its metadata), anchors of the ``context`` lane besides the
            records it resolved by key.
        max_candidates: Most candidates per record, counted as entities after
            lanes are merged and keys checked; ``truncated`` says when more
            were found.

    Returns:
        One entry per staged record, in the resolution's order; an entry with
        no candidates when nothing was found.

    Raises:
        NotImplementedError: A *guard* on a backend that cannot apply one.
    """
    if max_candidates < 1:
        raise ValueError("max_candidates must be at least 1")
    if guard is not None and db is not None:
        db.require_read_guard()
    spec = spec or LookupSpec()
    gatherer = _Gatherer(
        schema, resolution, db, guard, spec, lookup_text, comparable, subjects or {}
    )
    limit = max(spec.max_seeds, max_candidates + 1)
    out: list[StagedCandidates] = []
    for staged in resolution.staged.records:
        record = staged.record
        entry = StagedCandidates(
            source_id=staged.source_id,
            record=record,
            reason=staged.reason,
        )
        out.append(entry)
        if not gatherer.contract(record.type).identity_fields:
            continue
        hits = [
            *gatherer.alternate(staged),
            *gatherer.lookup(staged, limit),
            *gatherer.relation(staged),
            *gatherer.context(staged),
        ]
        kept = gatherer.check(staged, gatherer.merge(staged, hits))
        entry.candidates = kept[:max_candidates]
        entry.truncated = len(kept) > max_candidates
    return out

support(candidate)

accept when a feature identifies candidate and nothing conflicts.

Identifying: a hit in an identifying lane (key, alternate, exact, normalized), an exclusive field stated and stored alike, an alias naming only it in the reader's view, or a compatible form no other entity of its type holds there. A compatible form several entities hold, like an alias with several targets, is generic; a context hit never identifies.

Source code in graflo/link/candidates.py
def support(candidate: EntityCandidate) -> Literal["accept", "propose"]:
    """``accept`` when a feature identifies *candidate* and nothing conflicts.

    Identifying: a hit in an identifying lane (``key``, ``alternate``,
    ``exact``, ``normalized``), an exclusive field stated and stored alike, an
    alias naming only it in the reader's view, or a ``compatible`` form no
    other entity of its type holds there. A ``compatible`` form several
    entities hold, like an alias with several targets, is generic; a
    ``context`` hit never identifies.
    """
    features = candidate.support
    identified = (
        features.identifying_lane
        or features.exclusive_agreement
        or features.unique_alias
        or features.unique_compatible
    )
    return "accept" if identified and not features.conflict else "propose"