feat: add confidence merging and link upsert

merge_confidence/2 combines scores using 1-(1-a)(1-b).
upsert_link/1 creates or updates links keyed on source device +
interface + remote MAC. Merges confidence on update, appends
evidence records.
This commit is contained in:
Graham McIntire 2026-02-12 12:58:06 -06:00
parent 00c366ac9a
commit 9327fd3f13
No known key found for this signature in database
2 changed files with 186 additions and 0 deletions

View file

@ -12,6 +12,8 @@ defmodule Towerops.Topology do
alias Towerops.Snmp.Interface
alias Towerops.Snmp.MacAddress
alias Towerops.Snmp.Neighbor
alias Towerops.Topology.DeviceLink
alias Towerops.Topology.DeviceLinkEvidence
@doc """
Builds lookup maps for matching evidence to managed devices in an organization.
@ -219,4 +221,57 @@ defmodule Towerops.Topology do
end)
|> Enum.reject(fn ev -> ev.matched_device_id == device_id end)
end
@doc "Merge two independent confidence scores: 1 - (1-a)(1-b)"
def merge_confidence(a, b), do: 1.0 - (1.0 - a) * (1.0 - b)
@doc """
Create or update a device link. Keyed on source_device_id + source_interface_id +
discovered_remote_mac. If existing, merges confidence and appends evidence.
Always updates last_confirmed_at.
"""
def upsert_link(attrs) do
evidence_attrs = Map.get(attrs, :evidence, [])
link_attrs = Map.delete(attrs, :evidence)
existing =
Repo.one(
from(l in DeviceLink,
where:
l.source_device_id == ^link_attrs.source_device_id and
l.source_interface_id == ^link_attrs[:source_interface_id] and
l.discovered_remote_mac == ^link_attrs[:discovered_remote_mac]
)
)
result =
case existing do
nil ->
%DeviceLink{}
|> DeviceLink.changeset(link_attrs)
|> Repo.insert()
link ->
merged_confidence = merge_confidence(link.confidence, link_attrs.confidence)
link
|> DeviceLink.changeset(%{
confidence: merged_confidence,
last_confirmed_at: link_attrs.last_confirmed_at,
target_device_id: link_attrs[:target_device_id] || link.target_device_id,
metadata: Map.merge(link.metadata || %{}, link_attrs[:metadata] || %{})
})
|> Repo.update()
end
with {:ok, link} <- result do
Enum.each(evidence_attrs, fn ev ->
%DeviceLinkEvidence{}
|> DeviceLinkEvidence.changeset(Map.put(ev, :device_link_id, link.id))
|> Repo.insert!()
end)
{:ok, Repo.reload!(link)}
end
end
end

View file

@ -283,6 +283,137 @@ defmodule Towerops.TopologyTest do
end
end
describe "merge_confidence/2" do
test "combines two independent confidence scores" do
assert_in_delta Topology.merge_confidence(0.95, 0.7), 0.985, 0.001
end
test "combining with 0 returns the other score" do
assert Topology.merge_confidence(0.8, 0.0) == 0.8
end
test "combining two high scores approaches 1.0" do
assert_in_delta Topology.merge_confidence(0.95, 0.95), 0.9975, 0.001
end
end
describe "upsert_link/1" do
test "creates a new link from evidence", %{router: router, router_iface: iface} do
now = DateTime.utc_now()
{:ok, link} =
Topology.upsert_link(%{
source_device_id: router.id,
target_device_id: nil,
source_interface_id: iface.id,
link_type: "lldp",
confidence: 0.95,
discovered_remote_mac: "dd:ee:ff:00:11:22",
discovered_remote_ip: "10.0.0.5",
discovered_remote_name: "remote-switch",
last_confirmed_at: now,
evidence: [
%{
evidence_type: "lldp_neighbor",
evidence_data: %{"remote_chassis_id" => "dd:ee:ff:00:11:22"},
observed_at: now
}
]
})
assert link.source_device_id == router.id
assert link.confidence == 0.95
assert link.link_type == "lldp"
link_with_evidence = Repo.preload(link, :evidence)
assert length(link_with_evidence.evidence) == 1
end
test "updates existing link and merges confidence", %{router: router, router_iface: iface} do
now = DateTime.utc_now()
# Create initial LLDP link
{:ok, _link1} =
Topology.upsert_link(%{
source_device_id: router.id,
source_interface_id: iface.id,
link_type: "lldp",
confidence: 0.95,
discovered_remote_mac: "dd:ee:ff:00:11:22",
last_confirmed_at: now,
evidence: [
%{
evidence_type: "lldp_neighbor",
evidence_data: %{},
observed_at: now
}
]
})
# Upsert again with MAC evidence — should merge confidence
{:ok, updated} =
Topology.upsert_link(%{
source_device_id: router.id,
source_interface_id: iface.id,
link_type: "mac_match",
confidence: 0.7,
discovered_remote_mac: "dd:ee:ff:00:11:22",
last_confirmed_at: now,
evidence: [
%{
evidence_type: "mac_on_interface",
evidence_data: %{},
observed_at: now
}
]
})
# Confidence merged: 1 - (1-0.95)(1-0.7) = 0.985
assert_in_delta updated.confidence, 0.985, 0.001
# Evidence accumulated
updated_with_evidence = Repo.preload(updated, :evidence)
assert length(updated_with_evidence.evidence) == 2
end
test "populates target_device_id when matched", %{
router: router,
switch: switch,
router_iface: iface
} do
now = DateTime.utc_now()
{:ok, link} =
Topology.upsert_link(%{
source_device_id: router.id,
target_device_id: switch.id,
source_interface_id: iface.id,
link_type: "lldp",
confidence: 0.95,
discovered_remote_mac: "bb:cc:dd:00:11:22",
last_confirmed_at: now,
evidence: []
})
assert link.target_device_id == switch.id
end
test "creates link without evidence list", %{router: router, router_iface: iface} do
now = DateTime.utc_now()
{:ok, link} =
Topology.upsert_link(%{
source_device_id: router.id,
source_interface_id: iface.id,
link_type: "lldp",
confidence: 0.95,
discovered_remote_mac: "dd:ee:ff:00:11:22",
last_confirmed_at: now
})
assert link.id
end
end
describe "collect_arp_evidence/2" do
test "returns evidence for ARP entries matching known devices", %{
organization: org,