Files
breakpilot-compliance/ai-compliance-sdk/internal/iace/gt_coverage_test.go
T
Benjamin Admin 6a3e96d54c fix(iace): set-based measure-category filter + 235 pattern-author fixes
Two-part nachhaltiger fix replacing the previous "fill to 5 mitigations
no matter what" behavior that the GT-Bremse benchmark proved
unfaithful (e.g. HP1625 "scharfe Kanten" returning M005 "Rotations-
bewegung vermeiden" via category fallback; HP1651 "Wiederanlauf
Roboter" returning M054 "Sichere thermische Auslegung" via
mismatched pattern reference).

PART A — Set-based category filter (handlers package):
- acceptableMeasureCategories: replaces 1:1 patternCatToMeasureCat
  with a curated set per pattern category, so e.g.
  safety_function_failure now accepts software_control measures
  (watchdogs, plausibility checks) and emc_hazard accepts both
  electrical and software_control measures
- isCategoryCompatible: gate every measure id against the accepted
  set before creating a mitigation; mismatches log MEASURE-SKIP
- The old category fallback is REMOVED. A hazard whose pattern has
  no category-compatible measure is now created with zero mitigations
  and logged as COVERAGE-GAP — the operator must consult an expert.
  No more silent invention of generic defaults.

PART B — 235 pattern author-error fixes across 26 files:
- HP040-HP044 (AI): M101/M102/M103 (Auffangwanne/Absauganlage) ->
  M133 Anomalieerkennung + M214 Plausibilitaet + M213 Sensor-Redundanz
  + M044 Zweikanalige Steuerung + others
- HP011-HP015, HP104-HP109, HP1085-HP1095, HP1281-HP1334 (electrical):
  M001-M005/M054/M061 placeholders -> M481/M482 Isolation +
  M511-M522 PE/Schutzleiter/RCD/Hauptschalter
- HP110-HP1331 (material_environmental): M101-M103 -> M384-M395
  Brandschutz/Laserschutz + M533/M408 SDB/PSA
- HP800-HP858, HP1178-HP1264 (software/sensor/hmi):
  M101/M104 -> M105/M106/M107/M214 SPS/Watchdog/Plausibilitaet
- HP026, HP611-HP1690 (ergonomic): M001/M082 -> M353-M360 +
  M530-M532 Hebehilfe/ergonomische Hoehe
- HP201-HP1697 (mechanical): M054/M051 -> M002/M008/M061/M141 +
  M487/M488 Tueroeffnung-Stillsetzung/Wiederanlauf
- Plus EMF/Strahlung/Brand/Lärm/Vibration/Kommunikation/Cyber

Coverage shift (Pattern-Author-Fehler bei aktiviertem Set-Filter):
   start:         237 patterns with zero category-compatible measures
   after Stufe 1A:   5 (AI)
   after Stufe 1B:  20 (mechanical Bestand)
   after Stufe 1C:  35 (electrical Bestand)
   after Stufe 1D:  29 (material_environmental)
   after Stufe 1E:  29 (software/sensor/hmi)
   after Stufe 1F:  20 (ergonomic)
   after Stufe 1G:  80 (thermal/comm/radiation/fire/safety)
   final:           0  (28 extended.go/extended2.go duplicates fixed)

New regression tests:
- TestEveryPattern_HasCategoryCompatibleMeasure: every pattern in
  collectAllPatterns() must reference at least one category-compatible
  measure; gaps must be explicitly listed in AllowlistKnownGaps
  (currently empty). Fails CI for any new pattern that drifts.
- TestAcceptableMeasureCategories: pins the set-mapping for the
  7 most-bug-prone pattern categories.
- TestIsCategoryCompatible_EmptyMeasureCat: protects legacy entries.

A separate task #11 tracks 58 HP-ID duplicates between
extended.go/extended2.go and cobot.go/press.go/operational.go —
patterns are semantically different and TestGetBuiltinHazardPatterns_-
UniqueIDs misses them because it only checks HP001-HP044.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 21:11:02 +02:00

162 lines
6.7 KiB
Go

package iace
import (
"strings"
"testing"
)
// TestGTBremse_PinnedHazardToMeasureMappings is a regression net for the IACE
// benchmark fix. Each pinned (GT-Nr, hazard pattern, measure) triple was
// validated by an expert review on 2026-05 against testdata/ground_truth_bremse.json.
// If any pattern stops referencing the listed measures, this test fails — so the
// underlying GT scenario is no longer answered with the Fachmann-grade mitigation.
//
// Adding new entries here pins the Engine's answer for a specific GT scenario.
// Removing entries means the GT scenario is no longer covered with the same
// concrete measure (e.g. because the library was reorganized) — that needs an
// active decision, not a silent drift.
func TestGTBremse_PinnedHazardToMeasureMappings(t *testing.T) {
cases := []struct {
gtNr string
patternID string
requiredMeasures []string
}{
// GT 2.1/2.2: Elektrischer Schlag durch direktes Beruehren
// Expert demand: konkrete Isolation MOhm + IP2X Einhausung
{"2.1/2.2", "HP1640", []string{"M481", "M482"}},
// GT 2.4: Schutzleiterfehler (>10 mA Ableitstroeme)
// Expert demand: mech. Schutz + 10mm²-Cu + Ueberwachung + durchgehende Verbindung
{"2.4", "HP1641", []string{"M511", "M512", "M514", "M515"}},
// GT 2.5: Indirektes Beruehren — Schutzleiter durchgaengig + SK II / Kleinspannung
{"2.5", "HP1685", []string{"M511", "M512", "M515", "M516"}},
// GT 2.7: RCD an Steckdosenkreisen
{"2.7", "HP1689", []string{"M518"}},
// GT 2.12: Potentialausgleich zwischen Anlagenteilen
{"2.12", "HP1688", []string{"M475", "M477"}},
// GT 1.3: Pneumatik-Komponenten + Schlauchsicherung
{"1.3", "HP1630", []string{"M483", "M484", "M485"}},
// GT 1.5: Pneumatik-Restenergie nach Abschaltung
{"1.5", "HP1717", []string{"M485", "M534"}},
// GT 1.7: Teach-Modus mit Schluesselschalter + 250 mm/s + Zustimmtaster
{"1.7", "HP1605", []string{"M491", "M492", "M493"}},
// GT 1.8: Sicher begrenzter Bewegungsbereich + Zaun-Lastbemessung
{"1.8", "HP1604", []string{"M494", "M501"}},
// GT 1.10/1.18: Reach-over Sicherheitsabstand
{"1.10/1.18", "HP1602", []string{"M495", "M486"}},
// GT 1.11: Foerderband-Geometrie (Abstand + Oeffnungsgroesse)
{"1.11", "HP1621", []string{"M496", "M497", "M498"}},
// GT 1.22: Greifer-Versagen + Werkstueck weggeschleudert
{"1.22", "HP1711", []string{"M501", "M502", "M536"}},
// GT 1.24: Eingeschlossen in Zelle — Innenoeffnung + bewusster Wiederanlauf
{"1.24", "HP1603", []string{"M489", "M488"}},
// GT 1.12/1.24 (HP1651 Wiederanlauf-Variante): Wiederanlauf-Schutz-Measures —
// NOT thermal (M054 was wrongly placed here and surfaced as
// "Sichere thermische Auslegung" for a restart hazard)
{"1.12/1.24", "HP1651", []string{"M488", "M487", "M489", "M490"}},
// GT 1.1 (HP1625 sharp edges): edge-specific only, no rotational/distance fillers
{"1.1", "HP1625", []string{"M003", "M004", "M027"}},
// GT 1.26: Foerderband-Geschwindigkeit < 100 mm/s
{"1.26", "HP1620", []string{"M498", "M499"}},
// GT 1.27: Mechanischer Anschlag am Bandende
{"1.27", "HP1622", []string{"M500"}},
// GT 1.30: Druckluft-Reinigungsduese
{"1.30", "HP1712", []string{"M504", "M505"}},
// GT 1.32: WZM-Beladetuer + zweikanaliger Tuerschalter
{"1.32", "HP1634", []string{}}, // skipped: HP1634 already had M061; verify exists
// GT 1.34/2.10: KSS-Druckschlauch
{"1.34/2.10", "HP1675", []string{"M484", "M483"}},
// GT 1.38/1.39: KSS-Auslauf unten + Druck begrenzt
{"1.38/1.39", "HP1703", []string{"M505", "M506", "M526"}},
// GT 2.9: Wasser/Reinigung Schaltschrank
{"2.9", "HP1716", []string{"M521", "M522", "M539"}},
// GT 7.1: KSS-Hautkontakt
{"7.1", "HP1715", []string{"M408", "M533"}},
// GT 8.1: Manuelle Werkstueck-Handhabung + Hebehilfe >25kg
{"8.1", "HP1713", []string{"M530", "M532"}},
// GT 8.2: Bedienelement-Position ergonomisch
{"8.2", "HP1714", []string{"M531"}},
}
patterns := collectAllPatterns()
measureByID := make(map[string]ProtectiveMeasureEntry)
for _, m := range GetProtectiveMeasureLibrary() {
measureByID[m.ID] = m
}
patternByID := make(map[string]HazardPattern)
for _, p := range patterns {
patternByID[p.ID] = p
}
for _, c := range cases {
t.Run(c.gtNr+"_"+c.patternID, func(t *testing.T) {
p, ok := patternByID[c.patternID]
if !ok {
t.Fatalf("pattern %s missing — GT %s no longer covered", c.patternID, c.gtNr)
}
suggested := make(map[string]bool)
for _, m := range p.SuggestedMeasureIDs {
suggested[m] = true
}
for _, req := range c.requiredMeasures {
if _, exists := measureByID[req]; !exists {
t.Errorf("required measure %s referenced by GT %s does not exist in library", req, c.gtNr)
continue
}
if !suggested[req] {
t.Errorf("pattern %s no longer suggests %s — GT %s expert mitigation lost (current: %v)",
c.patternID, req, c.gtNr, p.SuggestedMeasureIDs)
}
}
})
}
}
// TestGTBremse_ExpertMeasuresAllResolvable pins the static-text expectation
// that every Fachmann measure newly added during the 2026-05 GT coverage work
// (M481-M482, M483-M539) carries the concrete EN/IEC/ISO/DGUV norm reference
// that the expert cited in the GT file. A measure without a concrete norm
// reference is a regression — generic "Sichere X" entries were exactly the
// problem this work was meant to fix.
func TestGTBremse_ExpertMeasuresAllResolvable(t *testing.T) {
expertIDs := []string{
"M481", "M482", "M483", "M484", "M485", "M486", "M487", "M488", "M489", "M490",
"M491", "M492", "M493", "M494", "M495", "M496", "M497", "M498", "M499", "M500",
"M501", "M502", "M503", "M504", "M505", "M506", "M507", "M508", "M509", "M510",
"M511", "M512", "M513", "M514", "M515", "M516", "M517", "M518", "M519", "M520",
"M521", "M522", "M523", "M524", "M525", "M526", "M527", "M528", "M529", "M530",
"M531", "M532", "M533", "M534", "M535", "M536", "M537", "M538", "M539",
}
measureByID := make(map[string]ProtectiveMeasureEntry)
for _, m := range GetProtectiveMeasureLibrary() {
measureByID[m.ID] = m
}
knownPrefixes := []string{"EN ", "IEC ", "ISO ", "DIN ", "TRBS", "TRGS", "ASR ", "DGUV", "OSHA", "VDE", "EN ISO", "DIN EN"}
for _, id := range expertIDs {
m, ok := measureByID[id]
if !ok {
t.Errorf("expert measure %s missing from library", id)
continue
}
if len(m.NormReferences) == 0 {
t.Errorf("measure %s (%q) has no NormReferences — concrete norm anchor missing", id, m.Name)
continue
}
found := false
for _, nr := range m.NormReferences {
for _, p := range knownPrefixes {
if strings.HasPrefix(nr, p) {
found = true
break
}
}
if found {
break
}
}
if !found {
t.Errorf("measure %s (%q) NormReferences %v contain no recognized norm prefix",
id, m.Name, m.NormReferences)
}
}
}