//! weighted reciprocal rank fusion — ported from the retired rust backend. //! //! score(doc) = α/(k + rank_semantic) + (1-α)/(k + rank_keyword), k = 60, //! 1-based ranks; a doc missing from a list contributes nothing for that leg. //! ties go to the keyword leg, zero-score docs are dropped, and the top result //! is normalized to 1.0 for display. const std = @import("std"); const Allocator = std.mem.Allocator; pub const RRF_K: f32 = 60.0; pub const Fused = struct { id: []const u8, score: f32, }; const Entry = struct { id: []const u8, score: f32, kw_rank: usize, }; fn better(_: void, a: Entry, b: Entry) bool { if (a.score != b.score) return a.score > b.score; if (a.kw_rank != b.kw_rank) return a.kw_rank < b.kw_rank; return std.mem.lessThan(u8, a.id, b.id); } /// `semantic` and `keyword` are id lists in rank order (best first). ids are /// borrowed, not copied — the caller keeps them alive. pub fn fuseRanks( alloc: Allocator, semantic: []const []const u8, keyword: []const []const u8, alpha_in: f32, ) ![]Fused { const alpha = std.math.clamp(alpha_in, 0.0, 1.0); var by_id: std.StringHashMapUnmanaged(Entry) = .empty; defer by_id.deinit(alloc); for (semantic, 0..) |id, rank| { const gop = try by_id.getOrPut(alloc, id); if (!gop.found_existing) gop.value_ptr.* = .{ .id = id, .score = 0, .kw_rank = std.math.maxInt(usize) }; gop.value_ptr.score += alpha / (RRF_K + @as(f32, @floatFromInt(rank)) + 1.0); } for (keyword, 0..) |id, rank| { const gop = try by_id.getOrPut(alloc, id); if (!gop.found_existing) gop.value_ptr.* = .{ .id = id, .score = 0, .kw_rank = std.math.maxInt(usize) }; gop.value_ptr.score += (1.0 - alpha) / (RRF_K + @as(f32, @floatFromInt(rank)) + 1.0); gop.value_ptr.kw_rank = rank; } var entries: std.ArrayList(Entry) = .empty; defer entries.deinit(alloc); var it = by_id.valueIterator(); while (it.next()) |e| { if (e.score > 0) try entries.append(alloc, e.*); } std.mem.sort(Entry, entries.items, {}, better); const out = try alloc.alloc(Fused, entries.items.len); const max = if (entries.items.len > 0) entries.items[0].score else 1.0; for (entries.items, 0..) |e, i| { out[i] = .{ .id = e.id, .score = e.score / max }; } return out; } // --- tests: the rust regression suite, ported --- const t = std.testing; fn ids(comptime names: []const []const u8) []const []const u8 { return names; } test "keyword-only rank-1 hit wins the tie at default alpha" { const semantic = ids(&.{ "noise-1", "noise-2", "noise-3", "noise-4", "noise-5" }); const keyword = ids(&.{"bufo-lgtm"}); const fused = try fuseRanks(t.allocator, semantic, keyword, 0.5); defer t.allocator.free(fused); try t.expectEqualStrings("bufo-lgtm", fused[0].id); } test "agreement beats a single list" { const fused = try fuseRanks(t.allocator, ids(&.{ "sem-only", "both" }), ids(&.{ "kw-only", "both" }), 0.5); defer t.allocator.free(fused); try t.expectEqualStrings("both", fused[0].id); } test "pure keyword ignores semantic" { const fused = try fuseRanks(t.allocator, ids(&.{ "sem-1", "sem-2" }), ids(&.{ "kw-1", "kw-2" }), 0.0); defer t.allocator.free(fused); try t.expectEqual(@as(usize, 2), fused.len); try t.expectEqualStrings("kw-1", fused[0].id); try t.expectEqualStrings("kw-2", fused[1].id); } test "pure semantic ignores keyword" { const fused = try fuseRanks(t.allocator, ids(&.{ "sem-1", "sem-2" }), ids(&.{"kw-1"}), 1.0); defer t.allocator.free(fused); try t.expectEqual(@as(usize, 2), fused.len); try t.expectEqualStrings("sem-1", fused[0].id); } test "top score normalized to one" { const fused = try fuseRanks(t.allocator, ids(&.{ "a", "b" }), ids(&.{"a"}), 0.5); defer t.allocator.free(fused); try t.expectApproxEqAbs(@as(f32, 1.0), fused[0].score, 1e-6); try t.expect(fused[1].score < 1.0); } test "zero-weight leg contributes no results" { const fused = try fuseRanks(t.allocator, ids(&.{ "sem-1", "sem-2" }), &.{}, 0.0); defer t.allocator.free(fused); try t.expectEqual(@as(usize, 0), fused.len); } test "alpha is clamped" { const fused = try fuseRanks(t.allocator, ids(&.{"s"}), ids(&.{"k"}), 7.0); defer t.allocator.free(fused); try t.expectEqualStrings("s", fused[0].id); try t.expectEqual(@as(usize, 1), fused.len); }