diff --git a/lib/neural/graph_of_trust.ex b/lib/neural/graph_of_trust.ex index 3909fc7d..569b4271 100644 --- a/lib/neural/graph_of_trust.ex +++ b/lib/neural/graph_of_trust.ex @@ -215,9 +215,15 @@ defmodule Hypatia.Neural.GraphOfTrust do initial_score = 1.0 / max(n, 1) scores = Map.new(supported_nodes, fn id -> {id, initial_score} end) - # Build adjacency: source -> [{target, weight}] + # Build adjacency: source -> [{target, weight}]. build_edges/1 emits one + # edge per verified success, so the same evidence recorded twice arrives + # as a parallel edge. Rank over the set of edges, as the cross-repo + # (Jaccard) edges already do, so that trust depends on what was observed + # and not on how many times it was logged. adjacency = - Enum.reduce(edges, %{}, fn {src, tgt, weight}, acc -> + edges + |> Enum.uniq() + |> Enum.reduce(%{}, fn {src, tgt, weight}, acc -> Map.update(acc, src, [{tgt, weight}], fn existing -> [{tgt, weight} | existing] end) end) @@ -250,22 +256,25 @@ defmodule Hypatia.Neural.GraphOfTrust do Map.new(scores, fn {node_id, _old_score} -> incoming = Enum.reduce(adjacency, 0.0, fn {src, targets}, acc -> - case Enum.find(targets, fn {tgt, _w} -> tgt == node_id end) do - {_tgt, weight} when is_number(weight) and weight > 0 -> - src_score = Map.get(scores, src, 0.0) - total = Map.get(out_weight_sum, src, 0.0) - - if total > 0 do - acc + src_score * weight / total - else - acc - end - - _ -> - # Zero or negative weights carry no trust — skip them. Negative - # edges would break the non-negative-matrix premise PageRank - # needs; they are filtered at source in `build_edges/1`. - acc + # Credit every edge src -> node_id, as `total` counts every edge + # out of src; otherwise the column sums fall below 1 and trust + # leaks. (Taking only the first edge lost most of a source's + # trust while parallel edges reached this point.) Zero or + # negative weights carry no trust: negative edges would break + # the non-negative-matrix premise PageRank needs, and they are + # filtered at source in `build_edges/1`. + weight = + Enum.reduce(targets, 0.0, fn + {^node_id, w}, sum when is_number(w) and w > 0 -> sum + w + _, sum -> sum + end) + + total = Map.get(out_weight_sum, src, 0.0) + + if weight > 0 and total > 0 do + acc + Map.get(scores, src, 0.0) * weight / total + else + acc end end) diff --git a/test/graph_of_trust_qualification_test.exs b/test/graph_of_trust_qualification_test.exs index 2fc7511d..e0818b19 100644 --- a/test/graph_of_trust_qualification_test.exs +++ b/test/graph_of_trust_qualification_test.exs @@ -201,4 +201,29 @@ defmodule Hypatia.Neural.GraphOfTrustQualificationTest do assert Enum.all?(extra_recipes, fn recipe -> expanded.trust_scores[recipe["id"]] == 0.5 end) assert GraphOfTrust.trust_score(expanded, "unknown-entity") == 0.5 end + + test "recording the same evidence k times leaves every trust score unchanged" do + # build_edges/1 emits one edge per verified success, so the same evidence + # recorded again arrives as parallel edges. The ranking used to divide a + # source's trust by the weight of all its out-edges but credit only the + # first edge to each target, so trust leaked, and the leak grew with k: + # tripling this log moved recipe-a-only-fixture from 0.38 to 0.59. Trust + # is now ranked over the set of edges, as the Jaccard cross-repo edges + # already are, so re-recording evidence changes nothing. + rows = [ + verified("repo-a-fixture", "recipe-a-only-fixture"), + verified("repo-b-fixture", "recipe-a-only-fixture"), + verified("repo-b-fixture", "recipe-b-only-fixture"), + verified("repo-c-fixture", "recipe-b-only-fixture") + ] + + once = graph(rows).trust_scores + thrice = graph(rows ++ rows ++ rows).trust_scores + + assert Map.keys(once) == Map.keys(thrice) + + for {id, score} <- once do + assert_in_delta thrice[id], score, 1.0e-9, "#{id}: once=#{score} thrice=#{thrice[id]}" + end + end end