diff --git a/core/record/record.go b/core/record/record.go index 4b7fb0a..32938ad 100644 --- a/core/record/record.go +++ b/core/record/record.go @@ -237,7 +237,8 @@ func normalizedRefs(in []RelationshipRef) []RelationshipRef { ID: strings.TrimSpace(in[i].ID), Extra: cloneRawMessages(in[i].Extra), } - key := ref.Kind + "\x00" + ref.ID + extraKey := rawMapStableKey(ref.Extra) + key := ref.Kind + "\x00" + ref.ID + "\x00" + extraKey if _, ok := seen[key]; ok { continue } @@ -246,6 +247,9 @@ func normalizedRefs(in []RelationshipRef) []RelationshipRef { } sort.SliceStable(refs, func(i, j int) bool { if refs[i].ref.Kind == refs[j].ref.Kind { + if refs[i].ref.ID == refs[j].ref.ID { + return rawMapStableKey(refs[i].ref.Extra) < rawMapStableKey(refs[j].ref.Extra) + } return refs[i].ref.ID < refs[j].ref.ID } return refs[i].ref.Kind < refs[j].ref.Kind @@ -282,7 +286,10 @@ func normalizedEdges(in []RelationshipEdge) []RelationshipEdge { }, Extra: cloneRawMessages(in[i].Extra), } - key := edge.Kind + "\x00" + edge.From.Kind + "\x00" + edge.From.ID + "\x00" + edge.To.Kind + "\x00" + edge.To.ID + fromExtraKey := rawMapStableKey(edge.From.Extra) + toExtraKey := rawMapStableKey(edge.To.Extra) + edgeExtraKey := rawMapStableKey(edge.Extra) + key := edge.Kind + "\x00" + edge.From.Kind + "\x00" + edge.From.ID + "\x00" + fromExtraKey + "\x00" + edge.To.Kind + "\x00" + edge.To.ID + "\x00" + toExtraKey + "\x00" + edgeExtraKey if _, ok := seen[key]; ok { continue } @@ -299,10 +306,19 @@ func normalizedEdges(in []RelationshipEdge) []RelationshipEdge { if edges[i].edge.From.ID != edges[j].edge.From.ID { return edges[i].edge.From.ID < edges[j].edge.From.ID } + if left, right := rawMapStableKey(edges[i].edge.From.Extra), rawMapStableKey(edges[j].edge.From.Extra); left != right { + return left < right + } if edges[i].edge.To.Kind != edges[j].edge.To.Kind { return edges[i].edge.To.Kind < edges[j].edge.To.Kind } - return edges[i].edge.To.ID < edges[j].edge.To.ID + if edges[i].edge.To.ID != edges[j].edge.To.ID { + return edges[i].edge.To.ID < edges[j].edge.To.ID + } + if left, right := rawMapStableKey(edges[i].edge.To.Extra), rawMapStableKey(edges[j].edge.To.Extra); left != right { + return left < right + } + return rawMapStableKey(edges[i].edge.Extra) < rawMapStableKey(edges[j].edge.Extra) }) out := make([]RelationshipEdge, 0, len(edges)) for i := range edges { @@ -453,3 +469,30 @@ func cloneRawMessages(in map[string]json.RawMessage) map[string]json.RawMessage } return out } + +func rawMapStableKey(in map[string]json.RawMessage) string { + if len(in) == 0 { + return "" + } + keys := make([]string, 0, len(in)) + for k := range in { + keys = append(keys, k) + } + sort.Strings(keys) + var b strings.Builder + for i := range keys { + key := keys[i] + val := in[key] + canonical := val + if len(val) > 0 { + if normalized, err := canon.Canonicalize(val, canon.DomainJSON); err == nil { + canonical = normalized + } + } + b.WriteString(key) + b.WriteByte('\x1f') + b.Write(canonical) + b.WriteByte('\x1e') + } + return b.String() +} diff --git a/core/record/record_test.go b/core/record/record_test.go index 9e2fadf..c9ac222 100644 --- a/core/record/record_test.go +++ b/core/record/record_test.go @@ -260,6 +260,53 @@ func TestNormalizedEdgesStableSortAndDedup(t *testing.T) { require.Nil(t, normalizedEdges(nil)) } +func TestNormalizedRefsKeepsDistinctAdditiveMetadata(t *testing.T) { + refs := []RelationshipRef{ + {Kind: "agent", ID: "agent:a", Extra: map[string]json.RawMessage{"tag": json.RawMessage(`"alpha"`)}}, + {Kind: "agent", ID: "agent:a", Extra: map[string]json.RawMessage{"tag": json.RawMessage(`"beta"`)}}, + {Kind: "agent", ID: "agent:a", Extra: map[string]json.RawMessage{"tag": json.RawMessage(`"alpha"`)}}, + } + normalized := normalizedRefs(refs) + require.Len(t, normalized, 2) + require.Equal(t, `"alpha"`, string(normalized[0].Extra["tag"])) + require.Equal(t, `"beta"`, string(normalized[1].Extra["tag"])) +} + +func TestNormalizedEdgesKeepsDistinctAdditiveMetadata(t *testing.T) { + edges := []RelationshipEdge{ + { + Kind: "calls", + From: RelationshipRef{Kind: "agent", ID: "agent:a", Extra: map[string]json.RawMessage{"ctx": json.RawMessage(`"a"`)}}, + To: RelationshipRef{Kind: "tool", ID: "tool:x"}, + Extra: map[string]json.RawMessage{ + "label": json.RawMessage(`"first"`), + }, + }, + { + Kind: "calls", + From: RelationshipRef{Kind: "agent", ID: "agent:a", Extra: map[string]json.RawMessage{"ctx": json.RawMessage(`"b"`)}}, + To: RelationshipRef{Kind: "tool", ID: "tool:x"}, + Extra: map[string]json.RawMessage{ + "label": json.RawMessage(`"second"`), + }, + }, + { + Kind: "calls", + From: RelationshipRef{Kind: "agent", ID: "agent:a", Extra: map[string]json.RawMessage{"ctx": json.RawMessage(`"a"`)}}, + To: RelationshipRef{Kind: "tool", ID: "tool:x"}, + Extra: map[string]json.RawMessage{ + "label": json.RawMessage(`"first"`), + }, + }, + } + normalized := normalizedEdges(edges) + require.Len(t, normalized, 2) + require.Equal(t, `"a"`, string(normalized[0].From.Extra["ctx"])) + require.Equal(t, `"first"`, string(normalized[0].Extra["label"])) + require.Equal(t, `"b"`, string(normalized[1].From.Extra["ctx"])) + require.Equal(t, `"second"`, string(normalized[1].Extra["label"])) +} + func TestComputeHashIncludesRelationshipAndLegacyAlias(t *testing.T) { base := &Record{ RecordID: "prf-test",