package intelligence import ( "math" "sort" "time" ) func buildSimilarityGraph(assets []loadedAsset, fingerprints map[string]MaterialFingerprint, k int) SimilarityGraph { if k <= 0 { k = 5 } graph := SimilarityGraph{ Version: "3.0", GeneratedAt: time.Now().UTC().Format(time.RFC3339), K: k, Nodes: len(assets), } for _, source := range assets { candidates := make([]SimilarityEdge, 0, len(assets)-1) sourceVec, sourceMethod := comparableVector(source, fingerprints[source.Asset.SKU]) for _, target := range assets { if source.Asset.SKU == target.Asset.SKU { continue } targetVec, targetMethod := comparableVector(target, fingerprints[target.Asset.SKU]) score := cosine(sourceVec, targetVec) method := sourceMethod if sourceMethod != targetMethod { method = "mixed" } candidates = append(candidates, SimilarityEdge{ SourceSKU: source.Asset.SKU, TargetSKU: target.Asset.SKU, Score: round6(score), Method: method, }) } sort.Slice(candidates, func(i, j int) bool { return candidates[i].Score > candidates[j].Score }) if len(candidates) > k { candidates = candidates[:k] } graph.Edges = append(graph.Edges, candidates...) } return graph } func comparableVector(asset loadedAsset, fp MaterialFingerprint) ([]float64, string) { if len(asset.Embeddings) > 0 && len(asset.Embeddings[0].Values) > 0 { vec := make([]float64, len(asset.Embeddings[0].Values)) for i, v := range asset.Embeddings[0].Values { vec[i] = float64(v) } return vec, "embedding_cosine" } return []float64{ fp.Brightness, fp.Contrast, fp.Saturation, fp.Variation, fp.TextureEntropy, fp.TextureFrequency, fp.Orientation / 180, fp.ColorVariance, fp.TextureVariance, }, "fingerprint_cosine" } func cosine(a, b []float64) float64 { n := len(a) if len(b) < n { n = len(b) } if n == 0 { return 0 } var dot, na, nb float64 for i := 0; i < n; i++ { dot += a[i] * b[i] na += a[i] * a[i] nb += b[i] * b[i] } if na == 0 || nb == 0 { return 0 } return dot / (math.Sqrt(na) * math.Sqrt(nb)) }