Skip to content

fix(memory): keep the best duplicate score in recall dedup, not the first - #6748

Open
LHMQ878 wants to merge 2 commits into
crewAIInc:mainfrom
LHMQ878:fix/recall-dedup-keeps-best-score
Open

fix(memory): keep the best duplicate score in recall dedup, not the first#6748
LHMQ878 wants to merge 2 commits into
crewAIInc:mainfrom
LHMQ878:fix/recall-dedup-keeps-best-score

Conversation

@LHMQ878

@LHMQ878 LHMQ878 commented Jul 31, 2026

Copy link
Copy Markdown

Closes #6747

Problem

RecallFlow.synthesize_results deduplicated by record.id keeping the first occurrence, but _do_search appends findings inside for future in as_completed(futures) — i.e. in thread-completion order. So when the same record arrived from more than one (sub-query, scope) task, thread timing decided which score survived.

Duplicates are structural rather than incidental:

  • lancedb_storage.search filters with query.where(f"scope LIKE '{prefix}%'"), so a record stored at /a/b is returned for scope /a and /a/b — and filter_and_chunk routinely selects both.
  • analyze_query_step yields up to 3 sub-query embeddings and _do_search runs the full embeddings × scopes cross product, so the same record arrives carrying different semantic scores.

compute_composite_score is monotonic in semantic_score, so keeping the first-arriving duplicate can keep a strictly worse score — under-ranking the record, or dropping it out of matches[: self.state.limit]. This reaches users through UnifiedMemory (flow.state.final_resultstouch_recordsMemoryQueryCompletedEvent).

A second, smaller dependence: sort(key=..., reverse=True) is stable, so equal-scoring records kept their arrival order too.

Fix

Keep the best score per record.id instead of the first, and break sort ties on record.id:

best: dict[str, MemoryMatch] = {}
...
    previous = best.get(record.id)
    if previous is None or composite > previous.score:
        best[record.id] = MemoryMatch(record=record, score=composite, match_reasons=reasons)
matches: list[MemoryMatch] = list(best.values())
matches.sort(key=lambda m: (-m.score, m.record.id))

Best-wins is both order-independent and the score ranking should have been using. Behaviour that already worked is untouched: dedup still collapses duplicates to one entry, ranking is still by descending composite, limit is still applied last, and the defensive shape checks (non-dict finding, non-list results, short tuple, non-MemoryRecord) are all preserved.

state.confidence is deliberately unchanged — max((f["top_score"] for f in findings), default=0.0) is already order-independent.

Tests

New lib/crewai/tests/memory/test_recall_flow_dedup.py, 6 tests. Each drives synthesize_results on a hand-built chunk_findings list rather than through a thread pool, so the assertions pin the dedup rule itself instead of racing the scheduler.

Test Pins
test_duplicate_record_keeps_the_best_score_not_the_first_seen the regression: [high, low] and [low, high] both resolve to the higher score
test_equal_scores_rank_by_record_id_rather_than_arrival ties order by record.id in both a forward and a reversed findings list
test_record_reachable_from_several_scopes_appears_once best-wins is not keep-both — 4 scopes still collapse to one match
test_distinct_records_are_all_kept_and_ranked_by_score descending-composite ranking did not regress
test_limit_is_applied_after_deduplication a duplicate does not consume one of the caller's limit slots
test_malformed_findings_are_skipped_without_raising the defensive shapes the original loop tolerated are still tolerated

The composite carries a recency term read from the wall clock at scoring time, so the score assertions compare with pytest.approx(..., abs=1e-6) against separately-scored baselines — a tolerance three orders of magnitude below the 0.9/0.1 gap being asserted — rather than against hard-coded floats.

Control run — with git checkout origin/main -- lib/crewai/src/crewai/memory/recall_flow.py and the new test file in place:

2 failed, 4 passed
FAILED test_recall_flow_dedup.py::test_duplicate_record_keeps_the_best_score_not_the_first_seen
FAILED test_recall_flow_dedup.py::test_equal_scores_rank_by_record_id_rather_than_arrival

So both new assertions fail on unfixed source, and the four guarding existing behaviour pass on it — they are not tautologies.

Suite comparisonlib/crewai/tests/memory/ before and after, comparing failing test names:

before: 5 errors (all test_memory_root_scope.py, pre-existing on main)
after : 134 passed, 19 skipped, 5 errors  <- same 5, no new failures

Checks

  • ruff check on both files: All checks passed!
  • ruff format --check on the source file: already formatted (lib/crewai/tests/ is ruff-excluded)

…irst

`synthesize_results` deduplicated by `record.id` keeping the first
occurrence, but `_do_search` collects findings in `as_completed` order, so
thread timing decided which of a duplicate's scores survived.

Duplicates are structural, not incidental: `lancedb_storage.search` matches
`scope LIKE 'prefix%'`, so a record at `/a/b` is returned for scope `/a` and
for `/a/b`, and each of the up-to-3 sub-query embeddings scores it
differently. `compute_composite_score` is monotonic in the semantic score, so
keeping the first-arriving one can keep a strictly worse score, under-rank the
record, or drop it out of `matches[: limit]`.

Keep the best score per record id instead, and break sort ties on
`record.id` so equal-scoring records do not order by arrival either.
`state.confidence` already used `max()` over all findings and is unchanged.

Closes crewAIInc#6747
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0f241b45-156b-4fb6-a91a-5c1deb147c3b

📥 Commits

Reviewing files that changed from the base of the PR and between 8f1bb7a and dac5e8c.

📒 Files selected for processing (1)
  • lib/crewai/tests/memory/test_recall_flow_dedup.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • lib/crewai/tests/memory/test_recall_flow_dedup.py

📝 Walkthrough

Walkthrough

RecallFlow.synthesize_results now keeps the highest composite score for duplicate records, orders ties by record ID, validates record types before scoring, and skips malformed findings. Regression tests cover deduplication, ordering, limits, ranking, and malformed inputs.

Changes

Memory Recall Deduplication

Layer / File(s) Summary
Best-score recall synthesis
lib/crewai/src/crewai/memory/recall_flow.py
Synthesis tracks the best MemoryMatch per record, validates MemoryRecord instances before scoring, and sorts by descending score with record-ID tie-breaking.
Deduplication regression coverage
lib/crewai/tests/memory/test_recall_flow_dedup.py
Deterministic fixtures and tests verify best-score retention, cross-scope deduplication, stable ordering, distinct-record ranking, post-deduplication limits, and malformed-input handling.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary change: retaining the best duplicate score during memory recall deduplication.
Description check ✅ Passed The description directly explains the recall deduplication bug, the fix, and the regression tests.
Linked Issues check ✅ Passed The changes satisfy issue #6747 by keeping the best score per record ID and applying deterministic ID-based tie-breaking.
Out of Scope Changes check ✅ Passed The source change and regression tests remain within the linked issue scope and preserve the specified existing behavior.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/crewai/src/crewai/memory/recall_flow.py`:
- Around line 367-380: Freeze a single scoring timestamp in the synthesis used
by test_equal_scores_rank_by_record_id_rather_than_arrival so
compute_composite_score applies identical recency decay to both records,
allowing the record.id tiebreaker assertion to remain deterministic. Update
lib/crewai/src/crewai/memory/recall_flow.py lines 367-380 only as needed to
support reusing that timestamp, and apply the corresponding test setup in
lib/crewai/tests/memory/test_recall_flow_dedup.py lines 88-104; preserve the
existing ordering assertion.
- Around line 367-369: Update the score-processing path around
compute_composite_score in lib/crewai/src/crewai/memory/recall_flow.py:367-369
to catch TypeError and ValueError during score conversion, skip non-finite
converted scores, and continue recall without aborting. Add invalid-score
entries before the valid record in
lib/crewai/tests/memory/test_recall_flow_dedup.py:138-149 and assert the valid
match remains available.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: dfbe0a4e-4b18-46cb-ba9d-b02f74883e2e

📥 Commits

Reviewing files that changed from the base of the PR and between ebe0082 and 8f1bb7a.

📒 Files selected for processing (2)
  • lib/crewai/src/crewai/memory/recall_flow.py
  • lib/crewai/tests/memory/test_recall_flow_dedup.py

Comment on lines +367 to +369
composite, reasons = compute_composite_score(
record, float(score), self._config
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Skip invalid numeric scores without aborting recall.

A valid MemoryRecord paired with None or "invalid" reaches float(score) and raises, so one malformed entry prevents synthesis instead of being skipped.

  • lib/crewai/src/crewai/memory/recall_flow.py#L367-L369: catch TypeError/ValueError around score conversion and skip non-finite values.
  • lib/crewai/tests/memory/test_recall_flow_dedup.py#L138-L149: include invalid-score entries before the valid record and assert the valid match remains available.
Proposed fix
+from math import isfinite
+
-                composite, reasons = compute_composite_score(
-                    record, float(score), self._config
-                )
+                try:
+                    semantic_score = float(score)
+                except (TypeError, ValueError):
+                    continue
+                if not isfinite(semantic_score):
+                    continue
+                composite, reasons = compute_composite_score(
+                    record, semantic_score, self._config
+                )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
composite, reasons = compute_composite_score(
record, float(score), self._config
)
try:
semantic_score = float(score)
except (TypeError, ValueError):
continue
if not isfinite(semantic_score):
continue
composite, reasons = compute_composite_score(
record, semantic_score, self._config
)
📍 Affects 2 files
  • lib/crewai/src/crewai/memory/recall_flow.py#L367-L369 (this comment)
  • lib/crewai/tests/memory/test_recall_flow_dedup.py#L138-L149
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/crewai/src/crewai/memory/recall_flow.py` around lines 367 - 369, Update
the score-processing path around compute_composite_score in
lib/crewai/src/crewai/memory/recall_flow.py:367-369 to catch TypeError and
ValueError during score conversion, skip non-finite converted scores, and
continue recall without aborting. Add invalid-score entries before the valid
record in lib/crewai/tests/memory/test_recall_flow_dedup.py:138-149 and assert
the valid match remains available.

Comment on lines +367 to +380
composite, reasons = compute_composite_score(
record, float(score), self._config
)
previous = best.get(record.id)
if previous is None or composite > previous.score:
best[record.id] = MemoryMatch(
record=record,
score=composite,
match_reasons=reasons,
)
matches.sort(key=lambda m: m.score, reverse=True)
matches: list[MemoryMatch] = list(best.values())
# ``record.id`` breaks ties, so equal-scoring records do not order by
# which task happened to finish first either.
matches.sort(key=lambda m: (-m.score, m.record.id))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 12 'def compute_composite_score\b|datetime\.(now|utcnow)\(|time\.time\(' \
  lib/crewai/src/crewai

rg -n -C 6 'test_equal_scores_rank_by_record_id|monkeypatch|freeze_time|freezegun' \
  lib/crewai/tests/memory/test_recall_flow_dedup.py

Repository: crewAIInc/crewAI

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- recall_flow key regions ---\n'
sed -n '320,390p' lib/crewai/src/crewai/memory/recall_flow.py
printf '\n--- dedup test region ---\n'
sed -n '1,130p' lib/crewai/tests/memory/test_recall_flow_dedup.py

printf '\n--- locate MemoryConfig / compute_composite_score usages ---\n'
rg -n 'MemoryConfig|compute_composite_score|recency_half_life_days|recency_weight|relevance_weight' lib/crewai/src/crewai lib/crewai/tests | head -200

Repository: crewAIInc/crewAI

Length of output: 245


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- recall_flow key regions ---'
sed -n '320,390p' lib/crewai/src/crewai/memory/recall_flow.py

printf '%s\n' '
--- dedup test region ---'
sed -n '1,130p' lib/crewai/tests/memory/test_recall_flow_dedup.py

printf '%s\n' '
--- locate MemoryConfig / compute_composite_score usages ---'
rg -n 'MemoryConfig|compute_composite_score|recency_half_life_days|recency_weight|relevance_weight' lib/crewai/src/crewai lib/crewai/tests | head -200

Repository: crewAIInc/crewAI

Length of output: 12823


🏁 Script executed:

#!/bin/bash
set -euo pipefail
printf '%s\n' '--- recall_flow key regions ---'
sed -n '320,390p' lib/crewai/src/crewai/memory/recall_flow.py
printf '%s\n' '--- dedup test region ---'
sed -n '1,130p' lib/crewai/tests/memory/test_recall_flow_dedup.py
printf '%s\n' '--- locate MemoryConfig/composite usages ---'
rg -n 'MemoryConfig|compute_composite_score|recency_half_life_days|recency_weight' lib/crewai/src/crewai lib/crewai/tests | head -200

Repository: crewAIInc/crewAI

Length of output: 12805


Freeze the recency timestamp in test_equal_scores_rank_by_record_id_rather_than_arrival.

compute_composite_score calls datetime.utcnow() for the recency decay, so otherwise identical semantic scores can drift apart depending on processing order before the record.id tiebreaker is reached. Freeze one scoring timestamp for the synthesis, or replace this assertion with a monotonicity check that tolerates small timing drift.

📍 Affects 2 files
  • lib/crewai/src/crewai/memory/recall_flow.py#L367-L380 (this comment)
  • lib/crewai/tests/memory/test_recall_flow_dedup.py#L88-L104
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/crewai/src/crewai/memory/recall_flow.py` around lines 367 - 380, Freeze a
single scoring timestamp in the synthesis used by
test_equal_scores_rank_by_record_id_rather_than_arrival so
compute_composite_score applies identical recency decay to both records,
allowing the record.id tiebreaker assertion to remain deterministic. Update
lib/crewai/src/crewai/memory/recall_flow.py lines 367-380 only as needed to
support reusing that timestamp, and apply the corresponding test setup in
lib/crewai/tests/memory/test_recall_flow_dedup.py lines 88-104; preserve the
existing ordering assertion.

…flaky

The tie assertion added by this PR raced the very thing it asserts against.
`compute_composite_score` reads `datetime.utcnow()` per call for the recency
decay, so two records created at the same instant do not score identically:
whichever is scored first is read a few hundred nanoseconds younger, gets a
larger decay, and wins a comparison the `record.id` tiebreaker is supposed to
decide. Measured on the two-record shape the test uses, 6 of 2000 pairs
differed, every one of them favouring the record scored first.

That direction is what makes it flaky rather than simply wrong. The test asserts
`["aaa", "bbb"]` for both the forward and the reversed finding order, so the
forward case passes for the wrong reason -- `aaa` is scored first and wins on
drift, not on id -- while the reversed case is the one that breaks. Running the
synthesize loop 20000 times: forward 0 failures, reversed 200 (1.0%).

Fixed at the test layer, since the product behaviour is correct: sub-nanosecond
recency drift is meaningless at a 30-day half-life and the sort already has a
deterministic tiebreaker. A `_frozen_clock` fixture substitutes a `datetime`
subclass whose `utcnow` returns a fixed instant one day after `_FIXED_CREATED_AT`,
so the decay term is still exercised rather than zeroed.

Under the frozen clock the difference between the two composites is exactly
`0.0` over 50000 pairs, and the frozen composite matches the value computed by
hand for a one-day age (`0.643147990530`), which is what shows the patch is in
effect rather than silently a no-op. 40 consecutive runs of the file pass.

`_record`'s docstring also claimed that pinning `created_at` and `importance`
made the composite a function of the semantic score alone. It does not -- the
wall clock is the third input -- so that is corrected too.
@LHMQ878

LHMQ878 commented Jul 31, 2026

Copy link
Copy Markdown
Author

Two findings, and they land differently. The flakiness one is right and was mine — fixed in dac5e8c0. The invalid-score one describes a real hazard but the patch is at the wrong site and nothing in tree can reach it; declining with the measurements below.


1. Recency-drift flakiness in the tie test — confirmed, fixed

Right, and worse than "can drift apart": the drift has a direction, which is what makes it a flake rather than simply noise. compute_composite_score reads datetime.utcnow() per call, so of two records created at the same instant the one scored first is read a few hundred nanoseconds younger, gets a larger decay, and wins.

Measured on the exact two-record shape the test builds:

composite diff over 2000 pairs:  6 nonzero, max 8.1e-11
                                 6 positive (first-scored wins), 0 negative

So the test passes for the wrong reason in the forward case — aaa is scored first and wins on drift, not on record.id — and the reversed assertion is the one that breaks. Running the synthesize loop 20000 times:

forward order wrong:   0 / 20000  (0.000%)
reverse order wrong: 200 / 20000  (1.000%)   <-- the flake

Fixed at the test layer rather than in the product: sub-nanosecond drift is meaningless at a 30-day half-life, and the sort already carries a deterministic tiebreaker — the test was just failing to hold the clock still long enough to reach it. A _frozen_clock fixture substitutes a datetime subclass whose utcnow returns a fixed instant one day after _FIXED_CREATED_AT, so the decay term is still exercised instead of zeroed.

Two checks that the fixture is actually in effect, not silently a no-op:

unfrozen composite: 0.352308425586
frozen   composite: 0.643147990530
expected (1-day age, half_life 30, computed by hand): 0.643147990530   match: True
distinct diffs over 50000 tied pairs under frozen clock: {0.0}

Exactly 0.0, not "small" — the flake is gone by construction. 40 consecutive runs of the file: 0/40 failed.

I also corrected _record's docstring, which claimed pinning created_at and importance made the composite a function of the semantic score alone. It does not — the wall clock is the third input — and that wrong claim is why the flake got written in the first place.

2. Invalid numeric scores — declining, with the reason

The hazard is real but the suggested guard does not prevent the abort, because the abort happens earlier than the patched line. _do_search computes top_score at recall_flow.py:134 (and :163 on the threaded path) by passing the score straight through, with no float():

top_composite, _ = compute_composite_score(results[0][0], results[0][1], self._config)

Measured against that call:

_do_search top_score with score=None      -> TypeError: unsupported operand type(s) for *: 'float' and 'NoneType'
_do_search top_score with score='invalid' -> TypeError: can't multiply sequence by non-int of type 'float'

Neither is caught — the surrounding try/except wraps _search_one / future.result(), and compute_composite_score sits after it. So a malformed score aborts recall before synthesize_results is ever entered, and a guard at :367 would run only on entries that already got past :134.

And nothing in tree can produce one. The MemoryStorage.search ABC types the return as list[tuple[MemoryRecord, float]], and both implementations coerce at the source:

  • lancedb_storage.py:404score = 1.0 / (1.0 + float(distance)) if distance is not None else 1.0
  • qdrant_edge_storage.py:352(sp.payload or {}, float(sp.score), is_local)

So None / "invalid" reaching this loop means a third-party backend already violated the typed contract. Hardening for that is worth doing, but it belongs at the boundary that trusts the contract (_do_search, or a coercion in search's caller) and it is out of scope for a dedup fix — this PR does not touch float(score), which is pre-existing at HEAD~1:361-362.

Happy to open it separately as a _do_search-level guard if you'd like it — that version would actually stop the abort, whereas the one suggested here would not.

isfinite is a fair separate point: nan and inf do propagate (composite=nan / composite=inf respectively). A nan score also breaks the sort silently rather than loudly, since every comparison with it is False. Same conclusion though — the place to reject it is where the score enters, not where dedup reads it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Memory recall dedup keeps whichever duplicate a thread finished first, not the best score

1 participant