109 lines
2.7 KiB
Go
109 lines
2.7 KiB
Go
|
|
package intelligence
|
||
|
|
|
||
|
|
import (
|
||
|
|
"path/filepath"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
func buildRegressionReport(assets []loadedAsset, baselineDir string) RegressionReport {
|
||
|
|
report := RegressionReport{
|
||
|
|
Version: "3.0",
|
||
|
|
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||
|
|
Status: "skipped",
|
||
|
|
BaselineDir: baselineDir,
|
||
|
|
Summary: map[string]interface{}{},
|
||
|
|
}
|
||
|
|
if baselineDir == "" {
|
||
|
|
report.Summary["reason"] = "baseline-dir not provided"
|
||
|
|
return report
|
||
|
|
}
|
||
|
|
baseline, err := loadAssets(baselineDir)
|
||
|
|
if err != nil {
|
||
|
|
report.Summary["reason"] = err.Error()
|
||
|
|
return report
|
||
|
|
}
|
||
|
|
bySKU := map[string]loadedAsset{}
|
||
|
|
for _, item := range baseline {
|
||
|
|
bySKU[item.Asset.SKU] = item
|
||
|
|
}
|
||
|
|
var drifted int
|
||
|
|
for _, current := range assets {
|
||
|
|
old, ok := bySKU[current.Asset.SKU]
|
||
|
|
if !ok {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
item := compareAssets(old, current)
|
||
|
|
if item.Status != "stable" {
|
||
|
|
drifted++
|
||
|
|
}
|
||
|
|
report.Items = append(report.Items, item)
|
||
|
|
report.Compared++
|
||
|
|
}
|
||
|
|
report.Status = "complete"
|
||
|
|
report.Summary["drifted"] = drifted
|
||
|
|
report.Summary["stable"] = report.Compared - drifted
|
||
|
|
return report
|
||
|
|
}
|
||
|
|
|
||
|
|
func compareAssets(old, current loadedAsset) RegressionItem {
|
||
|
|
brightnessDelta := abs(current.Asset.Visual.Brightness - old.Asset.Visual.Brightness)
|
||
|
|
histDistance := histogramDistance(old.Histogram.Values, current.Histogram.Values)
|
||
|
|
embeddingDrift := 1 - cosineFromEmbeddingRecords(old.Embeddings, current.Embeddings)
|
||
|
|
semanticChanged := old.Asset.Semantic.Description != current.Asset.Semantic.Description ||
|
||
|
|
old.Asset.Semantic.MaterialType != current.Asset.Semantic.MaterialType ||
|
||
|
|
old.Asset.Semantic.SurfaceFinish != current.Asset.Semantic.SurfaceFinish
|
||
|
|
status := "stable"
|
||
|
|
if brightnessDelta > 0.04 || histDistance > 0.10 || embeddingDrift > 0.08 || semanticChanged {
|
||
|
|
status = "drift"
|
||
|
|
}
|
||
|
|
return RegressionItem{
|
||
|
|
SKU: current.Asset.SKU, BrightnessDelta: round6(brightnessDelta),
|
||
|
|
HistogramDistance: round6(histDistance), EmbeddingDrift: round6(embeddingDrift),
|
||
|
|
SemanticChanged: semanticChanged, Status: status,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func histogramDistance(a, b []float64) float64 {
|
||
|
|
n := len(a)
|
||
|
|
if len(b) < n {
|
||
|
|
n = len(b)
|
||
|
|
}
|
||
|
|
if n == 0 {
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
var sum float64
|
||
|
|
for i := 0; i < n; i++ {
|
||
|
|
sum += abs(a[i] - b[i])
|
||
|
|
}
|
||
|
|
return sum / 2
|
||
|
|
}
|
||
|
|
|
||
|
|
func cosineFromEmbeddingRecords(a, b []EmbeddingRecord) float64 {
|
||
|
|
if len(a) == 0 || len(b) == 0 {
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
av := make([]float64, len(a[0].Values))
|
||
|
|
bv := make([]float64, len(b[0].Values))
|
||
|
|
for i, v := range a[0].Values {
|
||
|
|
av[i] = float64(v)
|
||
|
|
}
|
||
|
|
for i, v := range b[0].Values {
|
||
|
|
bv[i] = float64(v)
|
||
|
|
}
|
||
|
|
return cosine(av, bv)
|
||
|
|
}
|
||
|
|
|
||
|
|
func abs(v float64) float64 {
|
||
|
|
if v < 0 {
|
||
|
|
return -v
|
||
|
|
}
|
||
|
|
return v
|
||
|
|
}
|
||
|
|
|
||
|
|
func regressionBaselineName(path string) string {
|
||
|
|
if path == "" {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
return filepath.Base(path)
|
||
|
|
}
|