goldenmatch v3.7.0
Fixed
-
Zero-config Fellegi-Sunter recall no longer collapses at scale (candidate-pair
projection fix + recall-safe compounding + memory-aware budget). Zero-config
FS recall collapsed 1.0 at ≤2.4M → 0.82 at 4.8M → ~0.02 at 30M (F1 0.030,
the 30M single-box proof) — entirely scale-dependent and invisible below ~5M.
Three fixes, the first being the actual scale-dependent root cause:- Candidate-pair projection (the root cause).
_project_pass_pairs
extrapolated each blocking block's SIZE by the full row ratio
(cnt * n_full / sample_n). That is only right for a SATURATED low-cardinality
key; a NEAR-UNIQUE key keeps producing new values as N grows (blocks stay
~constant size, the COUNT grows), so growing its size invents ~C(ratio, 2)
PHANTOM pairs per sample singleton. At 30M (ratio ~150) a near-unique
(zip, email)compound was projected at ~2.2B pairs and DROPPED by the
pair-gate, collapsing blocking to a singlefirst_namepass (dups have typo'd
first names → recall ~0.02). Fixed by growing block size only by the key's
sample collision headroom (1 - distinct/sample_n): saturated keys grow by the
full ratio (byte-identical), near-unique keys barely grow (singletons stay
singletons → 0 phantom pairs). Small data (n_full == sample_n, the whole
bench-probabilistic panel) is unaffected — no extrapolation runs. - Recall-safe compounding. When the pair-gate DOES bound an over-budget
coarse pass, it now compounds with an exact-agreement identity field
(email / identifier / phone) at full value, before the corruption-prone
name/geo initials. Duplicates share those exactly, so the compound keeps every
true pair together while collapsing the block to near-singletons — recall-safe
AND a stronger reducer (30M person shape:zip + first-initial= recall 0.82;
zip + email= recall 1.0 at 0.9M pairs). The old "most selective" reducer (a
name initial) split true pairs on any typo'd name. - Memory-aware budget.
_fs_total_pair_budgetis now
max(300M floor, available_ram_gb * ~40M)(anchored to the 25M-on-64GB proof
where ~2.1B bounded pairs peaked at ~28 GB), so a big box does less
compounding to begin with and keeps coarse passes pure. Byte-identical below
the trigger (small boxes keep the 300M floor + all #1803 tuning).
Measured F1 0.9005 → 1.0000 at 4.8M (P=R=1.0); the identity reducer holds
recall 1.0 at 4.8M even at a tight 300M budget (3.3M candidate pairs). Not EM:
the EM within-block-pair sample cap is irrelevant to this (100K/200K/400K give
identical F1); blocking recall exactly tracks pipeline recall. The
gate-fs-zeroconfignightly now runs at 10M (was 1M — below where this class is
visible) withset -o pipefailso the F1-floor failure is no longer masked by
| tee.
- Candidate-pair projection (the root cause).
-
Weak-positive blocking-pass pruning now runs on the FS arrow lane.
GOLDENMATCH_BLOCKING_PRUNE_PASSES=1invokedselect_passes(polars-native:
with_row_index/group_by) directly ondf, but the FS routed / arrow
lane passes a pyarrowTable-- so it threwAttributeError, was swallowed
into "keep all passes", and pruning was a silent no-op for every arrow-lane FS
caller._maybe_prune_blocking_passesnow coerces a Table / LazyFrame to
polars first. With the pruner actually running on a representative sample it
cuts a redundant 6-pass zero-config FS scheme (3 first-name + 2 last-name
transform variants + zip) to 3 passes (one name axis per field + zip),
measured 71s -> 32.6s at 1M, F1 unchanged 1.000 (P=R=1.0) on realistic
person data -- recall-safe because it drops only redundant transform variants
of the same field, keeping each blocking axis. Still opt-in; a default-on
flip for the FS path is gated on thebench-probabilisticpanel. -
**Learned blocking no longer clobbers the #1207 strong-identifier union at
=50k rows (#1316).** Auto-config forced
strategy="learned"unconditionally
attotal_rows >= 50_000, discarding the per-identifier blocking union that
null-sparse multi-source strong-id data depends on. Measured on that shape at
50k, learned blocking under-blocks catastrophically -- candidate-pair recall
collapses from 1.0 (union) to 0.0 (the learner trains on a <=5k sample, finds
no pairs above its recall threshold, falls back to one column, and
skip_oversizeddrops every resulting oversized block). The >=50k gate now
keeps a strong-identifier union it detects and only upgrades non-union large
shapes to learned blocking. -
Zero-config Fellegi-Sunter admits shared identity identifiers (email/phone)
at cardinality 1.0. FS auto-config dropped everycard == 1.0exact field as
a "perfect surrogate", but that also discarded identity-bearing identifiers
(email/phone) that duplicates carry verbatim -- FS's single strongest signal.
Because cardinality is measured on a config sample that can under-represent
duplicates, this silently collapsed the EM model to zero matches at scale
(measured: zero-config FS F1 0.0 at 1M on realistic person data; recovers to
1.0 with the identifier admitted).build_probabilistic_matchkeysnow admits
email/phoneatcard >= 1.0(an FS comparison field self-regulates -- a
true PK to neutral, a shared identifier to a large weight) while still
excluding the ambiguous bareidentifiertype (row PKs) for config hygiene.
FS path only; the weighted/exact matchkey path is unchanged. -
Zero-config FS blocking pair-budget now prunes at scale (~6x wall). The FS
blocking pair-budget (_bound_probabilistic_blocking_pairs) is documented to
extrapolate each pass's candidate pairs to the full population, but
auto_configure_probabilistic_dfnever passedn_rows_full-- so on the
auto-config sample the bound measured pairs at sample scale (a 66M-at-1.2M pass
reads as ~1.8M at a 200K sample), stayed under budget, and never pruned, leaving
redundant giant-block soundex passes. Threading the full row count lets the
bound bound the oversized name passes at true scale: measured zero-config FS
wall 410s -> 71s at 1.2M (F1 unchanged at 1.000). The FS routing call site and
the bench/gate helper now passn_rows_full. -
FS
missing="unobserved": a partial-observation pair no longer normalizes to
1.0 (#1854). The min-max score range accumulated only over the OBSERVED
fields, so a pair agreeing on its single observed field hadtotal == pair_max
and rescaled to 1.0 — maximal confidence from minimal evidence. The range now
spans EVERY matchkey field (the sum stays over observed only), so a
one-of-many-observed agreement is correctly uncertain (e.g. 0.75 on a
two-field key). Cross-surface:fs-core::score_fs_pair(the native/unobserved
runtime + fs-wasm), the four Python reference paths incore/probabilistic.py
(vectorized ×2, scalar,score_pair_probabilistic). Identical when every field
is observed (missing="disagree"and fully-populated pairs are byte-unchanged;
auto-config routes null-heavy data todisagree, so the default path is
unaffected). Measured under forcedunobserved: historical_50k
f1_probabilistic recovers to ~0.63 from the collapsed ~0.33; febrl3 −0.002
(within the quality-gate tolerance).
Changed
- Out-of-core FS scorer batches blocks into the native kernel (opt-in path,
parity-exact) +GOLDENMATCH_FS_OOC_DEBUGprogress.score_fs_out_of_core
scored one block perscore_probabilistic_bucket_nativecall; on person data
(tens of thousands of tiny blocks per pass) that made the FFI fan-out the wall
(~60s for a single 200K pass, hours at 25M). It now hands a whole
block-contiguous wave to the kernel in one call per worker-chunk, with the
per-blocksize_listisolating blocks — mirroring the in-memory
_score_one_bucketbatched call, so the emitted pair set + scores are
byte-identical (the existing parity tests against the per-block reference
gate it). The numpy vectorized path likewise batches via
score_probabilistic_vectorized_batch; unsupported scorers keep the per-block
fallback. Measured ~8× on the per-pass scoring at 200K.GOLDENMATCH_FS_OOC_DEBUG=1
prints a per-phase / per-pass timing line (load, block-map, scan+score, block
count) so a long >=25M streaming leg shows live progress instead of a blank
spinner. NOTE: this fixes the per-block fan-out only; the OOC path still runs
one full-dataset scan+score per blocking pass, so low-cardinality passes and
multi-pass depth remain a separate scale lever. - Out-of-core streaming FS refinements (review follow-ups, opt-in path only).
_prep_all_idsreturns arangeinstead of a 25–50M-element Python list when
__row_id__is contiguous (the pipeline-generated common case), avoiding a
multi-GB transient before the pyarrow int64 array on the ≥40M streaming path.
stream_fs_dedupe_outputanddedupe_to_parquet's in-memory fallback now
remove a stalegolden.parquetleft by a prior run into the sameout_dir
when a run produces no golden rows, so the on-disk file set matches the
returnedgolden_path=None.
Added
-
Bounded bucket streaming for the in-RAM FS route
(GOLDENMATCH_FS_BLOCK_SOURCE=frame, default OFF) — cuts the ≥1M
frame-residency peak. The scale branch of the FS (probabilistic) bucket
scorer (score_buckets._score_single_pass, height ≥ n_buckets) used to
partition_bythe keyed frame into alln_bucketseager frames up front — a
~2× transient at partition time whose freed pages jemalloc retains straight
through cluster/golden, the dominant remaining single-node FS peak once the EM
build_blocksfixes landed. With the flag on, the scale branch keeps the
single bucketed frame resident and slices each bucket out on demand
(filter_eqinside the worker), so peak holds the bucketed frame plus at most
max_workersin-flight slices instead of all N partitions. Byte-identical to
the eager path:filter_eqpreserves within-bucket row order ==
partition_by(maintain_order), so each bucket's scorer output is unchanged,
and cross-bucket append order is order-invariant downstream (pairs
canonicalized). Measured (synthetic person 1M, local 4c/15GB, jemalloc-decay
env): whole-pipeline peak 3244 → 2875 MB (−11.4%), byte-identical output
(850,714 clusters both). Default OFF keeps the eager path until a CI ≥1M
peak gate validates the flip; scoped to the FS route (the weighted path is
untouched); the DuckDB (above-RAM) source is the separate
GOLDENMATCH_FS_OUT_OF_COREpath below. Spec:
docs/superpowers/specs/2026-07-20-fs-frame-residency-bucket-streaming-design.md. -
Out-of-core single-box streaming Fellegi-Sunter dedupe — the ≥40M scale
path (GOLDENMATCH_FS_OUT_OF_CORE=1, default OFF). The probabilistic (FS)
route had no out-of-core or distributed path:_fs_use_bucket_routehands
backend=duckdb/rayto a single-node scorer, so the whole prepared frame
stayed resident and the single-box FS wall was ~40M on 64 GB
(CI-measured: 25M @ 40.3 GB / 16 min; 50M projected to ~82 GB OOM), while F1
stayed scale-stable.backends/fs_out_of_core.pyadds three bounded
mechanisms:score_fs_out_of_corestreams block groups one at a time from a
DuckDB-resident (file-spilled) prepared table (scoring peak = one block
group, byte-parity withscore_bucketsabsent oversized blocks);
stream_fs_dedupe_outputwrites unique/dupes via DuckDBCOPY ... TO parquet
with no result frame;run_fs_dedupe_streamingties prep → DuckDB file →
free frame → score → cluster → stream. New public
gm.dedupe_to_parquet(*files, out_dir=...)reaches it (and falls back to the
in-memory pipeline + parquet write when the config is not FS-eligible or the
flag is off, so it always yields the same files). The default path (no
output_dir) is byte-unchanged. Spec:
docs/superpowers/specs/2026-07-20-fs-frame-residency-bucket-streaming-design.md. -
FS EM
build_blocksmemory-peak fixes (both default ON, byte-identical
output). The FS memory peak is EM'sbuild_blocks, notscore_buckets.
GOLDENMATCH_FS_EM_BLOCK_SLIMprojects each EM block-frame to
[__row_id__] + blocking fieldsbefore materialization (width 14→6);
GOLDENMATCH_FS_EM_AGG_BLOCKSbuilds the EM-only blocks as compact int64
row-id arrays via onegroup_by().agg()per pass, never materializing
per-block frames (supersedes the slim lever). Measured whole-pipeline peak on
person 100K: 2126 → 527 MB (−75%); regime-dependent above ~1M where the
EM-sample cap already bounds block count. Ajemallocpage-decay env
(_RJEM_MALLOC_CONF) trims the 1M FS peak a further ~33% at ~zero wall.
Fixed
-
DedupeResult.clustersnow exposes real contents to C-level consumers on
the frames-out path (re-scoped #1961). The lazy cluster handle
(LazyClusterDict, adictsubclass that builds on first Python content
access) left its underlying storage empty until an override fired. C-level
consumers that bypass those overrides — the goldenmatch-pg bridge's pyo3
.extract::<HashMap>()(PyDict_Next),json.dumps(empty-dict fast path
viaPyDict_GET_SIZE) — silently observed zero clusters, so a dedupe that
correctly formed a size-2 cluster serialized as empty (the pgp4_typed
smoke: "expected 2 rows in a size-2 cluster, got 0").DedupeResult.clusters
is now a property that materializes the lazy handle to a plaindicton first
read, so any consumer sees the real contents; a result whose.clustersis
never read still never pays the build (the frames-out perf win is preserved).
The pg extension's columnar SPI read (#1951spi.rs) was correct and is
unchanged. -
Arrow-native auto-config no longer silently degrades blocking on
wide/sparse frames (#1852, mode 2). With
GOLDENMATCH_AUTOCONFIG_ARROW_NATIVE=1(default since 2026-07-14), three
build_blockinghelpers still ran raw-polars idioms on the input frame:
_id_pass_scale_safe_nonnull(the #1207 per-identifier union gate),
_name_path_primary's geo-compound sizing, and_llm_suggest_blocking_keys.
On apa.Tablethe first two AttributeError'd into a bareexceptthat
returnedFalse/continue, so the strong-identifier blocking union and
name+geo compounding silently collapsed to name-only blocking — a recall/
precision divergence between the arrow and polars lanes (the_llm_*path
crashed outright on an arrow+LLM-blocking run). All three now route through
the backend-neutralFrameseam, so arrow and polars select identical
blocking passes. Locked by a wide/sparse config-equality parity test
(test_build_blocking_id_union_arrow_parity). Complements the earlier
_build_compound_blockingfix (mode 1, the crash). -
auto_configure_df(pa.Table)no longer raisesAttributeError: 'height'
in composite blocking search (#1852 tail). When every exact-eligible column
is a perfectly-unique surrogate key, auto-config goes fuzzy-only and
build_blockingfalls into composite-key search.find_composite_blocking_keys
andestimate_avg_block_size(core/blocking_candidates.py) still ran raw
polars idioms (df.height,df.select(...).n_unique()) on the input frame,
which is apa.Tableby default underGOLDENMATCH_AUTOCONFIG_ARROW_NATIVE=1—
crashing on the arrow-native lane. Both are now routed through the backend-
neutralFrameseam (to_frame+joint_n_unique), matching the earlier
build_blockingports. This branch is only reached on an all-unique-identifier
(join-table / order-shaped) frame, which is why the #1852 wide/sparse gate never
exercised it; locked bytest_auto_configure_all_unique_ids_arrow_parity.