package iace import ( "regexp" "strconv" "strings" ) // Distance benchmark dimension: does the engine suggest the same numeric // dimensions (mm gaps, mm/s speeds) as the professional (GT) for a session? // The engine measures are partly GT-derived, so on the seed sessions this // mainly guards DRIFT; its real value is NEW sessions, where the engine has not // been fitted to the assessor. Pure + deterministic (no LLM) — parses prose. // DistanceToken is one numeric dimension parsed from measure text. type DistanceToken struct { Value float64 `json:"value"` Unit string `json:"unit"` // "mm" | "mm/s" Raw string `json:"raw"` } // DistanceComparison reports engine vs GT dimensional coverage for one session. type DistanceComparison struct { GTCount int `json:"gt_count"` MatchedCount int `json:"matched_count"` AgreementPct float64 `json:"agreement_pct"` Matched []DistanceToken `json:"matched"` GTOnly []DistanceToken `json:"gt_only"` // Fachmann-Maße ohne Engine-Entsprechung (Lücken) EngineOnly []DistanceToken `json:"engine_only"` // Engine-Maße ohne GT-Entsprechung } // matches a number (incl. German thousands "1.600" / decimal "2,5") + mm[/s]. var distanceRe = regexp.MustCompile(`(\d{1,3}(?:\.\d{3})+|\d+(?:[,.]\d+)?)\s*mm(/s)?`) var thousandsRe = regexp.MustCompile(`^\d{1,3}(\.\d{3})+$`) func normalizeNumber(s string) float64 { if thousandsRe.MatchString(s) { s = strings.ReplaceAll(s, ".", "") // German thousands separator } else { s = strings.ReplaceAll(s, ",", ".") // German decimal separator } v, _ := strconv.ParseFloat(s, 64) return v } // extractDistanceTokens pulls the distinct (value,unit) dimensions out of prose. func extractDistanceTokens(texts []string) []DistanceToken { seen := map[string]bool{} var out []DistanceToken for _, t := range texts { for _, m := range distanceRe.FindAllStringSubmatch(t, -1) { unit := "mm" if m[2] == "/s" { unit = "mm/s" } val := normalizeNumber(m[1]) if val == 0 { continue } key := unit + ":" + strconv.FormatFloat(val, 'f', 1, 64) if seen[key] { continue } seen[key] = true out = append(out, DistanceToken{Value: val, Unit: unit, Raw: strings.TrimSpace(m[0])}) } } return out } func tokensMatch(a, b DistanceToken) bool { if a.Unit != b.Unit { return false } d := a.Value - b.Value if d < 0 { d = -d } return d < 0.05 } // CompareDistances matches the professional's dimensions (gtTexts) against the // engine's (engineTexts) and reports coverage + the gaps in both directions. func CompareDistances(gtTexts, engineTexts []string) DistanceComparison { gt := extractDistanceTokens(gtTexts) eng := extractDistanceTokens(engineTexts) res := DistanceComparison{ GTCount: len(gt), Matched: []DistanceToken{}, GTOnly: []DistanceToken{}, EngineOnly: []DistanceToken{}, } engMatched := make([]bool, len(eng)) for _, g := range gt { found := false for i, e := range eng { if !engMatched[i] && tokensMatch(g, e) { found, engMatched[i] = true, true break } } if found { res.MatchedCount++ res.Matched = append(res.Matched, g) } else { res.GTOnly = append(res.GTOnly, g) } } for i, e := range eng { if !engMatched[i] { res.EngineOnly = append(res.EngineOnly, e) } } if res.GTCount > 0 { res.AgreementPct = float64(res.MatchedCount) / float64(res.GTCount) * 100 } return res } // CompareSessionDistances is the benchmark-facing helper: it pulls the measure // prose from the GT entries and the engine mitigations and compares them. func CompareSessionDistances(gt *GroundTruth, mitigations []Mitigation) DistanceComparison { var gtTexts []string if gt != nil { for _, e := range gt.Entries { gtTexts = append(gtTexts, e.Measures...) if e.Comment != "" { gtTexts = append(gtTexts, e.Comment) } } } var engTexts []string for _, m := range mitigations { engTexts = append(engTexts, m.Name, m.Description) } return CompareDistances(gtTexts, engTexts) }