Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 27 additions & 18 deletions lib/neural/graph_of_trust.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down
25 changes: 25 additions & 0 deletions test/graph_of_trust_qualification_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading