Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

13 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

CellID

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.

Installation

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.py

The deterministic temporal correction gate has no optional dependencies:

./env/bin/python scripts/run_temporal_registry_benchmark.py

Verify the installation:

./env/bin/pip install -e '.[dev]'
./env/bin/python -m pytest tests -q

Usage

from 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 matcher

Same 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.events

Partition 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.

The generated artifacts

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.

The generative model

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.

The REIM bridge

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 fitted

The 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.

The baseline resolver

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 rejections

Cluster 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.

IdentityREIM v0

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 form

The 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.

Component-risk resolver

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.

Evaluation

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 300

That 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.

Varying the number of sources

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.

Tests

./env/bin/python -m pytest tests -q

The 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.

License

AGPL-3.0-or-later. See LICENSE.

About

🧬 Probabilistic identity resolution with stable temporal CellIDs, IdentityREIM calibration, and graph persistence.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages