feat: add receive message reconciliation helper
This commit is contained in:
104
internal/isphere/message_reconciliation.go
Normal file
104
internal/isphere/message_reconciliation.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package isphere
|
||||
|
||||
import "math"
|
||||
|
||||
const reconciliationMediumTimestampWindowMillis int64 = 3000
|
||||
|
||||
type MessageReconciliationCandidate struct {
|
||||
Source string
|
||||
MessageID string
|
||||
ConversationID string
|
||||
SenderID string
|
||||
TimestampUnixMilli int64
|
||||
BodyPresent bool
|
||||
AttachmentPresent bool
|
||||
}
|
||||
|
||||
type MessageReconciliationSummary struct {
|
||||
LeftCount int
|
||||
RightCount int
|
||||
StrongMatches int
|
||||
MediumMatches int
|
||||
UnmatchedLeft int
|
||||
UnmatchedRight int
|
||||
AutoMergeRecommended bool
|
||||
}
|
||||
|
||||
func ReconcileMessageSources(left, right []MessageReconciliationCandidate) MessageReconciliationSummary {
|
||||
matchedLeft := make([]bool, len(left))
|
||||
matchedRight := make([]bool, len(right))
|
||||
summary := MessageReconciliationSummary{LeftCount: len(left), RightCount: len(right)}
|
||||
|
||||
for leftIndex, leftCandidate := range left {
|
||||
if leftCandidate.MessageID == "" {
|
||||
continue
|
||||
}
|
||||
for rightIndex, rightCandidate := range right {
|
||||
if matchedRight[rightIndex] || rightCandidate.MessageID == "" {
|
||||
continue
|
||||
}
|
||||
if leftCandidate.MessageID == rightCandidate.MessageID {
|
||||
matchedLeft[leftIndex] = true
|
||||
matchedRight[rightIndex] = true
|
||||
summary.StrongMatches++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for leftIndex, leftCandidate := range left {
|
||||
if matchedLeft[leftIndex] {
|
||||
continue
|
||||
}
|
||||
for rightIndex, rightCandidate := range right {
|
||||
if matchedRight[rightIndex] {
|
||||
continue
|
||||
}
|
||||
if isMediumMessageMatch(leftCandidate, rightCandidate) {
|
||||
matchedLeft[leftIndex] = true
|
||||
matchedRight[rightIndex] = true
|
||||
summary.MediumMatches++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
summary.UnmatchedLeft = countFalse(matchedLeft)
|
||||
summary.UnmatchedRight = countFalse(matchedRight)
|
||||
summary.AutoMergeRecommended = shouldRecommendAutoMerge(summary)
|
||||
return summary
|
||||
}
|
||||
|
||||
func isMediumMessageMatch(left, right MessageReconciliationCandidate) bool {
|
||||
if left.ConversationID == "" || right.ConversationID == "" || left.ConversationID != right.ConversationID {
|
||||
return false
|
||||
}
|
||||
if left.SenderID == "" || right.SenderID == "" || left.SenderID != right.SenderID {
|
||||
return false
|
||||
}
|
||||
if left.TimestampUnixMilli <= 0 || right.TimestampUnixMilli <= 0 {
|
||||
return false
|
||||
}
|
||||
delta := int64(math.Abs(float64(left.TimestampUnixMilli - right.TimestampUnixMilli)))
|
||||
return delta <= reconciliationMediumTimestampWindowMillis
|
||||
}
|
||||
|
||||
func countFalse(values []bool) int {
|
||||
count := 0
|
||||
for _, value := range values {
|
||||
if !value {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func shouldRecommendAutoMerge(summary MessageReconciliationSummary) bool {
|
||||
if summary.LeftCount == 0 || summary.RightCount == 0 {
|
||||
return false
|
||||
}
|
||||
if summary.UnmatchedLeft != 0 || summary.UnmatchedRight != 0 || summary.MediumMatches != 0 {
|
||||
return false
|
||||
}
|
||||
return summary.StrongMatches == summary.LeftCount && summary.StrongMatches == summary.RightCount
|
||||
}
|
||||
57
internal/isphere/message_reconciliation_test.go
Normal file
57
internal/isphere/message_reconciliation_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package isphere
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestReconcileMessageSourcesStrongAndMediumMatches(t *testing.T) {
|
||||
left := []MessageReconciliationCandidate{
|
||||
{Source: "packetlog", MessageID: "same-1", ConversationID: "conv-a", SenderID: "alice", TimestampUnixMilli: 1000, BodyPresent: true},
|
||||
{Source: "packetlog", MessageID: "left-2", ConversationID: "conv-b", SenderID: "bob", TimestampUnixMilli: 100000, BodyPresent: true},
|
||||
{Source: "packetlog", MessageID: "left-3", ConversationID: "conv-c", SenderID: "carol", TimestampUnixMilli: 200000, AttachmentPresent: true},
|
||||
}
|
||||
right := []MessageReconciliationCandidate{
|
||||
{Source: "msglib", MessageID: "same-1", ConversationID: "conv-a", SenderID: "alice", TimestampUnixMilli: 900, BodyPresent: true},
|
||||
{Source: "msglib", MessageID: "right-2", ConversationID: "conv-b", SenderID: "bob", TimestampUnixMilli: 102500, BodyPresent: true},
|
||||
{Source: "msglib", MessageID: "right-4", ConversationID: "conv-d", SenderID: "dave", TimestampUnixMilli: 300000, AttachmentPresent: true},
|
||||
}
|
||||
|
||||
summary := ReconcileMessageSources(left, right)
|
||||
|
||||
if summary.LeftCount != 3 || summary.RightCount != 3 {
|
||||
t.Fatalf("counts = %d/%d", summary.LeftCount, summary.RightCount)
|
||||
}
|
||||
if summary.StrongMatches != 1 {
|
||||
t.Fatalf("strong matches = %d, want 1", summary.StrongMatches)
|
||||
}
|
||||
if summary.MediumMatches != 1 {
|
||||
t.Fatalf("medium matches = %d, want 1", summary.MediumMatches)
|
||||
}
|
||||
if summary.UnmatchedLeft != 1 || summary.UnmatchedRight != 1 {
|
||||
t.Fatalf("unmatched = %d/%d, want 1/1", summary.UnmatchedLeft, summary.UnmatchedRight)
|
||||
}
|
||||
if summary.AutoMergeRecommended {
|
||||
t.Fatalf("auto merge should remain false with weak coverage: %+v", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileMessageSourcesAutoMergeRecommendedAtHighStrongRatio(t *testing.T) {
|
||||
left := []MessageReconciliationCandidate{
|
||||
{Source: "packetlog", MessageID: "m1", ConversationID: "c1", SenderID: "s1", TimestampUnixMilli: 1},
|
||||
{Source: "packetlog", MessageID: "m2", ConversationID: "c2", SenderID: "s2", TimestampUnixMilli: 2},
|
||||
{Source: "packetlog", MessageID: "m3", ConversationID: "c3", SenderID: "s3", TimestampUnixMilli: 3},
|
||||
{Source: "packetlog", MessageID: "m4", ConversationID: "c4", SenderID: "s4", TimestampUnixMilli: 4},
|
||||
}
|
||||
right := []MessageReconciliationCandidate{
|
||||
{Source: "msglib", MessageID: "m1", ConversationID: "c1", SenderID: "s1", TimestampUnixMilli: 1},
|
||||
{Source: "msglib", MessageID: "m2", ConversationID: "c2", SenderID: "s2", TimestampUnixMilli: 2},
|
||||
{Source: "msglib", MessageID: "m3", ConversationID: "c3", SenderID: "s3", TimestampUnixMilli: 3},
|
||||
{Source: "msglib", MessageID: "m4", ConversationID: "c4", SenderID: "s4", TimestampUnixMilli: 4},
|
||||
}
|
||||
|
||||
summary := ReconcileMessageSources(left, right)
|
||||
if summary.StrongMatches != 4 || summary.MediumMatches != 0 || summary.UnmatchedLeft != 0 || summary.UnmatchedRight != 0 {
|
||||
t.Fatalf("unexpected exact summary: %+v", summary)
|
||||
}
|
||||
if !summary.AutoMergeRecommended {
|
||||
t.Fatalf("auto merge should be recommended for complete strong match: %+v", summary)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user