Foresight.Entities.Resolution (Foresight v0.1.0)

Copy Markdown View Source

Entity resolution scoring (C2 slice B1) — decide whether an incoming entity mention resolves to an existing bank entity or creates a new one.

Faithful port of Hindsight's engine/entity_resolver.py _resolve_from_candidates three-signal scorer. For each candidate the score in [0.0, 1.0] is:

name_similarity  × 0.5   (how alike the surface strings are)
  • cooccurrence × 0.3 (share of the mention's nearby entities that this
                          candidate has historically co-occurred with)
  • temporal × 0.2 (recency: 1.0 when last seen today, linearly to 0
                          at a 7-day window, 0 beyond)

The best-scoring candidate is merged into iff its score is strictly greater than the threshold (default 0.6, matching Hindsight); otherwise a new entity is created. Because name-similarity alone caps at 0.5, a merge always requires at least one corroborating signal (co-occurrence or recency) — the same property that keeps Hindsight from collapsing two unrelated entities that merely share a substring.

Name-similarity is computed by Foresight.Text.SequenceMatcher.ratio/2, a faithful port of Hindsight's difflib.SequenceMatcher(None, a, b).ratio() (entity_resolver.py:664) on lowercased inputs — so the 0.6 threshold is a byte-faithful constant, not a calibratable knob. Concretely, "google inc" vs "Google" scores 0.75 → 0.375, which even with same-day recency (0.575) stays below threshold and does NOT merge, exactly as Hindsight decides. Slice B2 adds a semantic (embedding) term for cases lexical similarity alone cannot disambiguate (e.g. "NYC" ↔ "New York City").

Pure and side-effect-free: candidate loading, co-occurrence assembly, and the upsert live in the storage layer, which calls resolve/3 per mention.

Summary

Types

A resolution candidate: an existing bank entity with the signals the scorer needs.

Functions

Whether an existing entity named canonical is even eligible to be scored as a candidate for the mention name.

Resolve name against candidates, returning {:merge, id} or :create.

Score one candidate for the mention name (with its nearby set and event_date). Exposed for testing and diagnostics.

Types

candidate()

@type candidate() :: %{
  :id => term(),
  :name => String.t(),
  optional(:last_seen) => DateTime.t() | nil,
  optional(:cooccurring) => MapSet.t(String.t()) | [String.t()],
  optional(:semantic_similarity) => number() | nil
}

A resolution candidate: an existing bank entity with the signals the scorer needs.

  • :id — opaque identifier returned on a merge
  • :name — the candidate's canonical name
  • :last_seen — when the entity was last mentioned (nil → no temporal signal)
  • :cooccurring — lowercased names this entity has historically co-occurred with
  • :semantic_similarity — B2 exceed: cosine between the mention's name embedding and this candidate's (0.0/absent → lexical-only, i.e. parity)

Functions

candidate_match?(name, canonical)

@spec candidate_match?(String.t(), String.t()) :: boolean()

Whether an existing entity named canonical is even eligible to be scored as a candidate for the mention name.

Faithful port of Hindsight's full-strategy filter (entity_resolver.py:_resolve_entities_batch_full): case-insensitive, a match iff the names are equal, or one is a substring of the other. The 3-signal scorer only ever sees entities that pass this filter, so a near-miss with no substring relation (e.g. "goggle"/"google") is never a merge candidate. Empty names are guarded out — they are dropped upstream (fact_entities), and Python's "" in s == true would otherwise make an empty mention match every entity.

resolve(name, candidates, opts \\ [])

@spec resolve(String.t(), [candidate()], keyword()) :: {:merge, term()} | :create

Resolve name against candidates, returning {:merge, id} or :create.

Opts:

  • :nearby — other entity names mentioned alongside name (for co-occurrence); the mention's own name is filtered out. Default [].
  • :event_date — when this mention occurred, for the temporal signal. Default nil.
  • :threshold — minimum winning score to merge (strict >). Default 0.6.
  • :labels — label taxonomy values (enum groups); :label_prefixes — label key prefixes (text groups). When name is a label entity, the fuzzy scorer is skipped and only an EXACT case-insensitive name match merges — faithful to Hindsight's label guard (entity_resolver.py, is_label_entity), so distinct label values that look alike are never merged. (Map-group label recursion is not ported — Foresight has no map-label config.)

score(name, candidate, nearby, event_date)

@spec score(String.t(), candidate(), MapSet.t(String.t()), DateTime.t() | nil) ::
  float()

Score one candidate for the mention name (with its nearby set and event_date). Exposed for testing and diagnostics.