Files
ragflow/internal/common/kg_scoring_test.go
Jack bf6c091c9f feat: add KG scoring utilities (#15666)
KG scoring utilities as pure functions.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 10:10:59 +08:00

264 lines
7.2 KiB
Go

//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package common
import (
"strings"
"testing"
)
// --- AnalyzeNHopPaths ---
func TestAnalyzeNHopPaths_Basic(t *testing.T) {
ents := map[string]*KGEntity{
"A": {
Sim: 0.9,
NhopEnts: []NhopEntity{
{Path: []string{"A", "B", "C"}, Weights: []float64{0.8, 0.5}},
},
},
}
result := AnalyzeNHopPaths(ents)
// A→B: 0.9 / (2+0) = 0.45
// B→C: 0.9 / (2+1) = 0.3
if len(result) != 2 {
t.Fatalf("expected 2 edges, got %d", len(result))
}
if result[Edge{"A", "B"}].Sim != 0.45 {
t.Errorf("expected A→B sim=0.45, got %f", result[Edge{"A", "B"}].Sim)
}
if result[Edge{"B", "C"}].Sim != 0.3 {
t.Errorf("expected B→C sim=0.3, got %f", result[Edge{"B", "C"}].Sim)
}
}
func TestAnalyzeNHopPaths_MultipleContributors(t *testing.T) {
ents := map[string]*KGEntity{
"A": {
Sim: 0.8,
NhopEnts: []NhopEntity{
{Path: []string{"A", "B"}, Weights: []float64{0.7}},
},
},
"X": {
Sim: 0.6,
NhopEnts: []NhopEntity{
{Path: []string{"X", "B"}, Weights: []float64{0.5}},
},
},
}
result := AnalyzeNHopPaths(ents)
// A→B: 0.8 / 2 = 0.4
// X→B: 0.6 / 2 = 0.3
if result[Edge{"A", "B"}].Sim != 0.4 {
t.Errorf("expected A→B sim=0.4, got %f", result[Edge{"A", "B"}].Sim)
}
if result[Edge{"X", "B"}].Sim != 0.3 {
t.Errorf("expected X→B sim=0.3, got %f", result[Edge{"X", "B"}].Sim)
}
}
func TestAnalyzeNHopPaths_Empty(t *testing.T) {
result := AnalyzeNHopPaths(nil)
if len(result) != 0 {
t.Errorf("expected empty, got %d", len(result))
}
}
// --- DoubleHitBoost ---
func TestDoubleHitBoost(t *testing.T) {
ents := map[string]*KGEntity{
"A": {Sim: 0.5},
"B": {Sim: 0.3},
}
types := map[string]struct{}{"A": {}}
DoubleHitBoost(ents, types)
if ents["A"].Sim != 1.0 {
t.Errorf("expected A sim=1.0 after boost, got %f", ents["A"].Sim)
}
if ents["B"].Sim != 0.3 {
t.Errorf("expected B sim unchanged at 0.3, got %f", ents["B"].Sim)
}
}
func TestDoubleHitBoost_Empty(t *testing.T) {
ents := map[string]*KGEntity{"A": {Sim: 0.5}}
DoubleHitBoost(ents, map[string]struct{}{})
if ents["A"].Sim != 0.5 {
t.Errorf("expected unchanged, got %f", ents["A"].Sim)
}
}
// --- FuseRelationScores ---
func TestFuseRelationScores_NhopContribution(t *testing.T) {
rels := map[Edge]*KGRelation{
{"A", "B"}: {Sim: 0.5, PageRank: 0.8},
}
types := map[string]struct{}{}
nhop := map[Edge]EdgeScore{
{"A", "B"}: {Sim: 0.3},
}
FuseRelationScores(rels, types, nhop)
// sim = 0.5 * (0.3 + 1) = 0.65
if rels[Edge{"A", "B"}].Sim != 0.65 {
t.Errorf("expected 0.65, got %f", rels[Edge{"A", "B"}].Sim)
}
}
func TestFuseRelationScores_TypeBoost(t *testing.T) {
rels := map[Edge]*KGRelation{
{"A", "B"}: {Sim: 0.5},
}
types := map[string]struct{}{"A": {}, "B": {}}
nhop := map[Edge]EdgeScore{}
FuseRelationScores(rels, types, nhop)
// Both endpoints in types: s=2, sim = 0.5 * (2+1) = 1.5
if rels[Edge{"A", "B"}].Sim != 1.5 {
t.Errorf("expected 1.5, got %f", rels[Edge{"A", "B"}].Sim)
}
}
func TestFuseRelationScores_NhopNewEdge(t *testing.T) {
rels := map[Edge]*KGRelation{}
types := map[string]struct{}{}
nhop := map[Edge]EdgeScore{
{"A", "B"}: {Sim: 0.4, PageRank: 0.7},
}
FuseRelationScores(rels, types, nhop)
if _, ok := rels[Edge{"A", "B"}]; !ok {
t.Fatal("expected new edge from N-hop")
}
if rels[Edge{"A", "B"}].Sim != 0.4 {
t.Errorf("expected sim=0.4, got %f", rels[Edge{"A", "B"}].Sim)
}
}
// --- SortAndTrim ---
func TestSortAndTrimEntities(t *testing.T) {
ents := map[string]*KGEntity{
"A": {Sim: 0.5, PageRank: 0.9},
"B": {Sim: 0.8, PageRank: 0.3},
"C": {Sim: 0.9, PageRank: 0.1},
}
result := SortAndTrimEntities(ents, 2)
if len(result) != 2 {
t.Fatalf("expected 2, got %d", len(result))
}
// A: 0.45, B: 0.24, C: 0.09 → top 2 should be A, B
if result[0].Entity != "A" {
t.Errorf("expected A first (0.45), got %s (%f)", result[0].Entity, result[0].Score)
}
}
func TestSortAndTrimEntities_DefaultTopN(t *testing.T) {
ents := map[string]*KGEntity{
"A": {Sim: 0.5, PageRank: 0.9},
"B": {Sim: 0.8, PageRank: 0.3},
}
result := SortAndTrimEntities(ents, 0)
if len(result) != 2 {
t.Errorf("expected default topN to include all, got %d", len(result))
}
}
func TestSortAndTrimRelations(t *testing.T) {
rels := map[Edge]*KGRelation{
{"A", "B"}: {Sim: 0.9, PageRank: 0.1},
{"C", "D"}: {Sim: 0.3, PageRank: 0.8},
}
result := SortAndTrimRelations(rels, 1)
if len(result) != 1 {
t.Fatalf("expected 1, got %d", len(result))
}
// A→B: 0.09, C→D: 0.24 → C→D should be first
if result[0].From != "C" {
t.Errorf("expected C first (0.24), got %s (%f)", result[0].From, result[0].Score)
}
}
// --- Format and Build ---
func TestBuildKGContent_Basic(t *testing.T) {
entities := []ScoredEntity{
{Entity: "A", Score: 0.45, Description: `{"description": "Entity A desc"}`},
}
relations := []ScoredRelation{
{From: "A", To: "B", Score: 0.3, Description: `{"description": "rel A-B"}`},
}
result := BuildKGContent(entities, relations, 10000)
if !contains(result, "Entity A desc") {
t.Errorf("expected entity description in output, got: %s", result)
}
if !contains(result, "rel A-B") {
t.Errorf("expected relation description in output, got: %s", result)
}
}
func TestBuildKGContent_TokenBudget(t *testing.T) {
longDesc := strings.Repeat("This is a very long description. ", 50)
entities := []ScoredEntity{
{Entity: "LongEntityName", Score: 1.0, Description: longDesc},
}
relations := []ScoredRelation{
{From: "X", To: "Y", Score: 1.0, Description: "relation desc"},
}
result := BuildKGContent(entities, relations, 50)
// Token budget is very small, should truncate and not include relations
if contains(result, "relation desc") {
t.Log("Note: relations included despite small budget (depending on token count)")
}
}
func TestExtractDescription_JSON(t *testing.T) {
result := extractDescription(`{"description": "Entity A description", "other": "value"}`)
if result != "Entity A description" {
t.Errorf("expected 'Entity A description', got %q", result)
}
}
func TestExtractDescription_Plain(t *testing.T) {
result := extractDescription("plain description")
if result != "plain description" {
t.Errorf("expected 'plain description', got %q", result)
}
}
func TestNumTokensFromString(t *testing.T) {
s := "This is a test string with multiple words"
tokens := NumTokensFromString(s)
if tokens <= 0 {
t.Errorf("expected positive token count, got %d", tokens)
}
}
// contains checks if a string contains a substring.
func contains(s, substr string) bool {
return len(s) >= len(substr) && containsStr(s, substr)
}
func containsStr(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}