fix(agent): deterministic hybrid ranking; don't reinforce zero-score filler

test_hebbian_activation_boost failed intermittently. Root causes, all in the
query path:

- normalize_scores mapped a set of identical scores — including the
  single-candidate case — to 0.0, so a lone perfect match contributed nothing
  to the fused score. Identical positive scores now normalise to 1.0 (all
  equally the best match); identical non-positive scores stay 0.0.
- merge_vector_keyword sorted a HashMap's entries by score alone and then
  truncated, so which ties survived varied from run to run; hybrid_search had
  the same problem in its final sort. Both now break ties by index.
- hybrid_search applied the Hebbian boost to every returned record, including
  the zero-score filler that pads the list when fewer than k records match.
  With random tie-breaking a filler record could collect as many boosts as the
  real hit. Only records with a positive fused score are reinforced now.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
osobh
2026-09-19 06:11:38 -07:00
co-authored by Claude Fable 5.1
parent 0744d52639
commit 3ed0489faa
2 changed files with 39 additions and 6 deletions
+27 -5
View File
@@ -91,14 +91,22 @@ pub fn merge_vector_keyword(
}
let mut results: Vec<(usize, f32)> = merged.into_iter().collect();
results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
// Index tie-break: `merged` is a HashMap, so without it the ties that
// survive `truncate` differ from run to run.
results.sort_by(|a, b| {
b.1.partial_cmp(&a.1)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.0.cmp(&b.0))
});
results.truncate(k);
results
}
/// Normalize a set of scores to the [0, 1] range using min-max normalization.
///
/// If all scores are identical, returns 0.0 for each entry.
/// If all scores are identical there is no spread to normalise: each entry
/// gets 1.0 when that score is positive (all equally the best match) and 0.0
/// otherwise (nothing matched).
fn normalize_scores(scores: &[(usize, f32)]) -> Vec<(usize, f32)> {
if scores.is_empty() {
return Vec::new();
@@ -112,7 +120,13 @@ fn normalize_scores(scores: &[(usize, f32)]) -> Vec<(usize, f32)> {
let range = max - min;
if range == 0.0 {
return scores.iter().map(|(idx, _)| (*idx, 0.0)).collect();
// All candidates scored the same (including the single-candidate
// case), so min-max has no spread to work with. They are all equally
// the best match if that score is positive, and all non-matches
// otherwise. This used to return 0.0 unconditionally, which erased a
// lone perfect match from the fused score.
let level = if max > 0.0 { 1.0 } else { 0.0 };
return scores.iter().map(|(idx, _)| (*idx, level)).collect();
}
scores
@@ -324,10 +338,18 @@ mod tests {
#[test]
fn normalize_scores_single() {
// A lone positive score is the best match there is, not a non-match.
let result = normalize_scores(&[(0, 5.0)]);
assert_eq!(result.len(), 1);
// Single score normalizes to 0.0 (range is 0)
assert_eq!(result[0].1, 0.0);
assert_eq!(result[0].1, 1.0);
}
#[test]
fn normalize_scores_all_equal() {
let matched = normalize_scores(&[(0, 0.4), (1, 0.4)]);
assert!(matched.iter().all(|(_, s)| *s == 1.0));
let unmatched = normalize_scores(&[(0, 0.0), (1, 0.0)]);
assert!(unmatched.iter().all(|(_, s)| *s == 0.0));
}
#[test]
+12 -1
View File
@@ -113,13 +113,24 @@ impl HDF5Memory {
}
})
.collect();
// Ties broken by index so results (and therefore which records get
// boosted) don't depend on HashMap iteration order upstream.
results.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.index.cmp(&b.index))
});
let hit_indices: Vec<usize> = results.iter().map(|r| r.index).collect();
// Only reinforce records that actually matched. When fewer than `k`
// records are relevant, the rest of the list is zero-score filler;
// boosting it would teach the store that arbitrary records are
// important just because they were nearby in iteration order.
let hit_indices: Vec<usize> = results
.iter()
.filter(|r| r.score > 0.0)
.map(|r| r.index)
.collect();
self.apply_hebbian_boost(&hit_indices);
self.flush().ok();