A synthetic-data testbed for evaluating probabilistic record linkage β also called entity resolution: deciding which of many observed identifiers refer to the same underlying real-world entity.
The problem this addresses is that record linkage is hard to evaluate on real
data, because on real data nobody knows the right answer. You can inspect the
output of a matcher and form an opinion about it, but you cannot compute how
often it was right. cellid generates data where the right answer is known by
construction, so matching quality can be measured rather than eyeballed.
The package generates data, calibrates pair evidence through optional IdentityREIM, resolves complete identity partitions, maintains temporal CellIDs, and persists registry history through an in-memory or LadybugDB graph store.
The frozen v1 public surface is listed in the CellID v1 API Contract. The broader behavioral boundary for resolution, lifecycle, persistence, and the future clean-room layer is defined in the Identity Resolution Contract. The first entity-level measurements are frozen in the Identity Resolution Baseline Report. The proposed domain-specific evidence model is defined separately in the IdentityREIM v0 Specification. Its frozen five-seed results are reported in the IdentityREIM v0 Benchmark Report. The graph-policy follow-up is frozen in the Component-Risk Resolver Benchmark Report. Persistent identity lifecycle semantics are frozen in the CellID Registry v0 Specification. Backend-neutral persistence and its embedded graph implementation are defined in the IdentityGraphStore v0 Specification. Out-of-generator transfer and scale results are frozen in the FEBRL3 External Validation Report. Temporal retention and correction behavior is frozen in the Temporal CellID Registry Report.
The Python distribution and import package are named cellid. That name is
stable throughout the 1.x release line. IdentityREIM remains the separate
evidence-calibration extension owned by REIM.
Requires Python 3.10 or newer.
git clone <this repository> cellid
cd cellid
python -m venv env
./env/bin/pip install -e .The core generator, resolvers, CellID registry, in-memory graph store, and
temporal metrics do not import REIM. IdentityREIM and cellid.bridge require
the optional REIM checkout until its distribution is published:
./env/bin/pip install -e /path/to/REIM
./env/bin/pip install -e '.[identity]'LadybugDB persistence is optional:
./env/bin/pip install -e '.[graph]'The external FEBRL3 benchmark uses IdentityREIM and is also optional. Install REIM by path first, then:
./env/bin/pip install -e '.[external]'
./env/bin/python scripts/run_febrl3_benchmark.py
./env/bin/python scripts/run_febrl3_scale_profile.pyThe deterministic temporal correction gate has no optional dependencies:
./env/bin/python scripts/run_temporal_registry_benchmark.pyVerify the installation:
./env/bin/pip install -e '.[dev]'
./env/bin/python -m pytest tests -qfrom cellid import SyntheticIdentityGenerator
data = SyntheticIdentityGenerator(n_persons=500, seed=42).generate()
data.observations # emitted source scores
data.source_opportunities # eligibility and explicit silence
data.ground_truth # evaluation only β never pass this to a matcherSame seed, same output. Always.
Resolved partitions can be assigned stable opaque CellIDs across ordered snapshots:
from cellid import CellIDRegistry
registry = CellIDRegistry()
registry.apply_partition(
{"email:a": "cluster:1", "phone:b": "cluster:1"},
snapshot_id="2026-08-03",
evidence_cutoff="2026-08-03T12:00:00Z",
provenance={"resolver": "component-risk-v0"},
)
registry.latest_snapshot.assignment_map
registry.eventsPartition labels are local inputs and have no effect on the allocated CellIDs. The registry owns creation, maintenance, merge, split, alias, retirement, and deterministic replay. It does not score evidence or resolve the graph.
Persist one complete registry transition atomically through the logical store contract:
from cellid import IdentityGraphCommit, InMemoryIdentityGraphStore
store = InMemoryIdentityGraphStore()
store.append_commit(IdentityGraphCommit.from_registry(registry))Use cellid.ladybug_store.LadybugIdentityGraphStore for the optional embedded
property-graph backend. Both implementations return equal logical snapshots,
events, and transitions for the same commits.
generate() returns a downstream-visible evidence bundle and a structurally
separate ground-truth object.
observations β a DataFrame with exactly these columns, and nothing else:
| column | meaning |
|---|---|
pair_id |
opaque candidate-pair id |
identifier_a, identifier_b |
the pair, lexicographically ordered |
source |
which signal source emitted this |
score |
signal strength in [0, 1] |
observed_at |
when, never before both identifiers existed |
source_opportunities β a DataFrame with one row per candidate pair and
source:
| column | meaning |
|---|---|
pair_id |
opaque candidate-pair id |
identifier_a, identifier_b |
the same canonically ordered pair |
source |
source that had or lacked an opportunity |
status |
observed, no_signal, or not_applicable |
evaluated_at |
time eligibility or silence was established |
observed has exactly one corresponding observation and the two timestamps
match. no_signal means the source was applicable but stayed silent.
not_applicable means its configured identifier-type scope excluded the pair.
Only the first state has a score.
ground_truth β a separate object, not a DataFrame, holding three tables:
identifiersβperson_id, identifier, identifier_type, first_seen. Every identifier appears exactly once, so it is a function identifier β person.pair_labelsβ the true label of every candidate pair, observed or not.source_paramsβ the true accuracy, coverage, and bias of every source.
Plus convenience views: person_to_identifiers, identifier_to_person.
The separation is structural, not conventional. generate() returns a frozen
SyntheticIdentityData, and neither downstream-visible frame carries a column
from which a label could be recovered: pair ids are assigned after a shuffle,
and identifier strings are opaque random tokens. A component that receives the
evidence bundle cannot read owners or true labels, and the test suite asserts
this.
Entities β called persons here β own a varying number of identifiers: emails, phones, devices, cookies. Typically 2 to 10.
Cookies churn. A configurable fraction of persons accumulate several cookie
ids, with first_seen spread across the observation window; stable identifiers
appear near the window's start. This is what makes temporal decay meaningful for
anything consuming the data.
Candidate pairs are every within-person pair plus false_pairs_per_person
cross-person pairs. There is no quadratic expansion, on the assumption that a
real pipeline blocks before it scores.
Sources look at each candidate pair and either stay silent or emit a score. Three are configured by default, with deliberately different characteristics:
| source | accuracy | coverage | on false pairs | bias | character |
|---|---|---|---|---|---|
deterministic |
0.99 | 0.15 | 0.02 | 0.0 | precise, rarely speaks |
behavioural |
0.75 | 0.85 | 0.45 | 0.0 | chatty, noisy, wide variance |
probabilistic |
0.85 | 0.90 | 0.60 | 0.6 | over-merging |
Neither the count nor the parameters are fixed β pass any sequence of
SourceSpec to the generator.
accuracy is operational and directly measurable: the probability that a score
falls on the correct side of SCORE_THRESHOLD (0.5). bias models over-merging
in two ways at once. Accuracy on false pairs degrades to accuracy * (1 - bias),
and surviving scores are pushed toward the top of their side. The result is a
source whose scores on false pairs sit well above every other source's β a
source that looks confident and agreeable while being systematically wrong in
one direction.
Whether that bias is detectable from observations alone is the point of the
whole exercise, so the test suite asserts the property holds in the data.
cellid.bridge adapts generated observations to REIM (Reticular Epistemic
Inference Model), an engine that jointly estimates a latent property per system
and a reliability per observer, from disagreeing observations and without
labels.
The mapping is a reification: each candidate pair becomes a REIM system, each signal source becomes an observer, and each score becomes a value. REIM then estimates how true each pair is, and how reliable each source is, from the pattern of agreement alone. No graph structure enters the engine.
from cellid import SyntheticIdentityGenerator
from cellid.bridge import fit_pairs
data = SyntheticIdentityGenerator(n_persons=500, seed=42).generate()
result = fit_pairs(data.observations) # observations only
result.estimates # pair_id, estimate, uncertainty
result.observer_reliability # per source, as fittedThe bridge sees observations and nothing else. Passing ground truth into it is
not merely discouraged: no function in the module accepts it, and a test asserts
so.
REIM is consumed as a library and never modified from this repository.
cellid.resolver establishes the graph-coherence baseline without using REIM.
It averages source assessments per candidate pair, applies must_link and
cannot_link constraints, and considers qualifying edges in deterministic
score order. A constrained union-find produces one complete partition:
from cellid.resolver import resolve_partition
partition = resolve_partition(
data.observations,
merge_threshold=0.5,
)
partition.assignments # identifier -> deterministic cluster label
partition.accepted_edges # accepted aggregate edges and reasons
partition.rejected_edges # threshold or constraint rejectionsCluster labels identify only this deterministic resolution state. They are not persistent CellIDs. The resolver never imports or accepts ground truth, and an explicit identifier inventory can be supplied to preserve identifiers that no source observed as singleton clusters.
reim.identity owns the experimental pair-calibration layer specified in the
IdentityREIM v0 Specification. cellid.identity_reim
retains compatibility exports for existing callers. The extension learns
separate class-conditional coverage and score-side behavior for each source
and identifier-type domain. Applicable silence is evidence; inapplicability
is neutral.
Fitting accepts an explicit pair_id, is_match calibration table. Those labels
must come from the calibration side of an entity-disjoint evaluation split:
from cellid import fit_identity_reim
model = fit_identity_reim(
calibration_observations,
calibration_opportunities,
calibration_labels,
)
scored = model.score(test_observations, test_opportunities)
scored.pair_frame() # probabilities, entropy, and evidence counts
scored.contribution_frame() # additive source-level provenance
scored.resolver_input() # pair probabilities in resolver input formThe inference API accepts no labels or GroundTruth object. The fitted model,
pair results, and source contributions are immutable records. Beta smoothing,
minimum-support thresholds, model fingerprints, and deterministic domain
backoff are explicit configuration or model state.
resolve_partition_risk_aware() seeds components only from strong pair
probabilities. Weaker evidence can merge two current components only when
multiple distinct candidate edges support the same component-level decision.
Support is recomputed after every merge, with deterministic ordering and full
constraint enforcement.
This resolver consumes IdentityREIM probabilities but does not import or fit the evidence model. Its cluster labels remain run-local and are not persistent CellIDs.
cellid.evaluation is the only module that reads ground truth, and it reads it
for exactly two purposes: drawing gold labels for models that accept
supervision, and scoring. The two are kept structurally apart β
sample_gold_labels() returns both the labels and the pairs it drew, and
evaluate_estimates() takes an exclude argument β so a supervised model is
never graded on the answers it was given.
Metrics are computed on edges. False merges and false splits are reported
separately rather than folded into F1, alongside a threshold sweep and the
threshold-free average_precision and roc_auc.
Entity-level evaluation is separate. Given a complete resolver assignment,
partition_metrics() evaluates every relationship implied by its clusters,
including transitive relationships that were not candidate edges:
from cellid.evaluation import partition_metrics
metrics = partition_metrics(
predicted={"em_a": "cell_1", "ph_b": "cell_1", "ck_c": "cell_2"},
truth={"em_a": "person_1", "ph_b": "person_1", "ck_c": "person_2"},
)It reports pairwise and B-cubed precision/recall/F1, exact-cluster accuracy, cluster purity, over-merge and over-split rates, and the underlying error counts. Predicted cluster labels are opaque and need not equal true entity IDs; the two mappings must cover exactly the same identifiers.
python scripts/run_comparison.py --seeds 1 2 3 4 5 --n-persons 300That runs every model β a plain mean of source scores, reim.SimpleAverage,
reim.TrimmedMean, the single strongest source alone, base REIM, and AuditREIM
in four configurations β on the same data and the same thresholds, and prints
the comparison table, the threshold sweep, and the observer diagnostics with
each model's fitted parameters beside the generator's true ones.
sample_sources() builds a population of any size by drawing accuracy,
coverage, and bias from configurable ranges. The three default sources are kept
as anchors at every size, so the over-merging source and the source the
single-source baseline is defined in terms of are always present. A coverage
regime switches between complementary sources and sources that each observe
nearly every pair, changing overlap while holding everything else fixed.
python scripts/run_sweep.py --levels 3 6 10 15 20 30 --seeds 1 2 3 4 5 --out results/This is expensive β cost grows with the number of observations, which grows
with the number of sources. Each cell is written to --out as it completes.
./env/bin/python -m pytest tests -qThe suite covers reproducibility, ground-truth consistency, empirical accuracy
and coverage against configuration (with sample sizes derived from the
tolerance), the over-merging property, the absence of ground-truth leakage into
observations, and the bridge end to end.
The statistical tests use a large sample and a fixed seed, so their tolerances describe the estimator's standard error rather than a flakiness budget.
AGPL-3.0-or-later. See LICENSE.