Enforce the duplicate candidate cap during row collection
This commit is contained in:
parent
f1e1a16f9b
commit
73f62ab290
2 changed files with 46 additions and 4 deletions
|
|
@ -282,19 +282,40 @@ def _duplicate_families(
|
||||||
|
|
||||||
order = sorted(normalized)
|
order = sorted(normalized)
|
||||||
centroids = np.array([_unit(normalized[d].mean(axis=0)) for d in order])
|
centroids = np.array([_unit(normalized[d].mean(axis=0)) for d in order])
|
||||||
|
sizes = np.array([normalized[d].shape[0] for d in order], dtype=float)
|
||||||
|
|
||||||
# Stage 1: centroid candidate pairs, block-wise to avoid a full D×D matrix.
|
# Stage 1: centroid candidate pairs, block-wise to avoid a full D×D matrix.
|
||||||
|
# The cap is enforced per row (truncating each row's matches) so a
|
||||||
|
# self-similar corpus can never allocate beyond MAX_CANDIDATE_PAIRS.
|
||||||
candidates: list[tuple[int, int]] = []
|
candidates: list[tuple[int, int]] = []
|
||||||
block = 512
|
block = 512
|
||||||
|
capped = False
|
||||||
for start in range(0, len(order), block):
|
for start in range(0, len(order), block):
|
||||||
|
if capped:
|
||||||
|
break
|
||||||
sims = centroids[start : start + block] @ centroids.T
|
sims = centroids[start : start + block] @ centroids.T
|
||||||
for row in range(sims.shape[0]):
|
for row in range(sims.shape[0]):
|
||||||
gi = start + row
|
gi = start + row
|
||||||
above = np.nonzero(sims[row, gi + 1 :] >= cfg.candidate_threshold)[0]
|
targets = np.arange(gi + 1, len(order))
|
||||||
|
if targets.size == 0:
|
||||||
|
continue
|
||||||
|
# A smaller document can be fully contained in a larger append-only
|
||||||
|
# revision even when the fixed centroid threshold would fail:
|
||||||
|
# with orthogonal chunks, cosine falls to sqrt(small / large).
|
||||||
|
# Scale the candidate gate by that size ratio, then let directed
|
||||||
|
# containment make the actual duplicate decision.
|
||||||
|
ratios = np.minimum(sizes[gi], sizes[targets]) / np.maximum(
|
||||||
|
sizes[gi], sizes[targets]
|
||||||
|
)
|
||||||
|
thresholds = cfg.candidate_threshold * np.sqrt(ratios)
|
||||||
|
above = np.nonzero(sims[row, gi + 1 :] >= thresholds)[0]
|
||||||
|
remaining = MAX_CANDIDATE_PAIRS - len(candidates)
|
||||||
|
if len(above) >= remaining:
|
||||||
|
above = above[:remaining]
|
||||||
|
capped = True
|
||||||
candidates.extend((gi, gi + 1 + int(j)) for j in above)
|
candidates.extend((gi, gi + 1 + int(j)) for j in above)
|
||||||
if len(candidates) >= MAX_CANDIDATE_PAIRS:
|
if capped:
|
||||||
candidates = candidates[:MAX_CANDIDATE_PAIRS]
|
break
|
||||||
break
|
|
||||||
|
|
||||||
# Stage 2: confirm candidates with directed chunk-overlap containment.
|
# Stage 2: confirm candidates with directed chunk-overlap containment.
|
||||||
adjacency: dict[int, set[int]] = {}
|
adjacency: dict[int, set[int]] = {}
|
||||||
|
|
|
||||||
|
|
@ -985,6 +985,17 @@ def test_duplicate_families_append_only_is_asymmetric():
|
||||||
assert a_to_b[3] == pytest.approx(0.5)
|
assert a_to_b[3] == pytest.approx(0.5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_families_append_only_passes_default_centroid_gate():
|
||||||
|
# Fixed 0.85 centroid gating misses this: centroid cosine is sqrt(3 / 6),
|
||||||
|
# but A is fully contained in B and should reach the containment verifier.
|
||||||
|
families = _duplicate_families(
|
||||||
|
_docs({"a": [0, 1, 2], "b": [0, 1, 2, 3, 4, 5]}),
|
||||||
|
DuplicateDetectionConfig(),
|
||||||
|
)
|
||||||
|
assert len(families) == 1
|
||||||
|
assert set(families[0].members) == {"a", "b"}
|
||||||
|
|
||||||
|
|
||||||
def test_duplicate_families_distinct_docs_none():
|
def test_duplicate_families_distinct_docs_none():
|
||||||
families = _duplicate_families(
|
families = _duplicate_families(
|
||||||
_docs({"a": [0, 1, 2], "b": [3, 4, 5]}), _stage2_cfg()
|
_docs({"a": [0, 1, 2], "b": [3, 4, 5]}), _stage2_cfg()
|
||||||
|
|
@ -1014,6 +1025,16 @@ def test_duplicate_families_tiny_docs_ignored():
|
||||||
assert families == []
|
assert families == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_families_caps_candidates_during_collection(monkeypatch):
|
||||||
|
# Three mutually-identical docs would yield 3 candidate pairs, but a cap of 1
|
||||||
|
# must stop collection after the first (a,b), leaving c unconfirmed.
|
||||||
|
monkeypatch.setattr("haiku.rag.doctor.MAX_CANDIDATE_PAIRS", 1)
|
||||||
|
docs = _docs({"a": [0, 1, 2], "b": [0, 1, 2], "c": [0, 1, 2]}, dim=3)
|
||||||
|
families = _duplicate_families(docs, _stage2_cfg())
|
||||||
|
assert len(families) == 1
|
||||||
|
assert set(families[0].members) == {"a", "b"}
|
||||||
|
|
||||||
|
|
||||||
def test_duplicate_families_threshold_is_configurable():
|
def test_duplicate_families_threshold_is_configurable():
|
||||||
# Share 3 of 5 chunks each -> containment 0.6 both ways.
|
# Share 3 of 5 chunks each -> containment 0.6 both ways.
|
||||||
spec = {"a": [0, 1, 2, 3, 4], "b": [0, 1, 2, 5, 6]}
|
spec = {"a": [0, 1, 2, 3, 4], "b": [0, 1, 2, 5, 6]}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue