feat(iace): Sprint 3A — Operational State Graph + fix(ucca) flaky keyword sort

State Graph:
- 9 Standard-Betriebszustaende (startup, homing, automatic_operation, manual_operation, teach_mode, maintenance, cleaning, emergency_stop, recovery_mode)
- 20 State-Transitions als gerichteter Graph
- OperationalStates + StateTransitions Felder in HazardPattern, MatchInput, PatternMatch
- patternMatches() filtert Patterns nach Betriebszustand (nil = feuert immer)
- Narrative-Parser extrahiert States aus Maschinenbeschreibung (22 Keywords + 4 Transition-Keywords)
- 27 bestehende Patterns mit State-Einschraenkungen annotiert (10 operational, 15 maintenance, 2 cobot)
- MatchReason um operational_state + state_transition Typen erweitert (Explainability)
- 6 neue Tests: NilFiresAlways, MaintenanceFilter, StateTransition, MatchReasons, Count, TransitionValid

UCCA fix:
- Stabiler Tiebreaker (Pattern-ID aufsteigend) bei gleichem Keyword-Score in MatchByKeywords
- Behebt flaky TestControlPatternIndex_MatchByKeywords (1/10 Failure-Rate durch Go map iteration order)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-10 08:05:02 +02:00
parent 33f0a64ff6
commit 77a497d930
10 changed files with 449 additions and 48 deletions
@@ -215,3 +215,219 @@ func TestPatternEngine_SafetyDevice_HighPriority(t *testing.T) {
t.Error("expected safety_function_failure for Not-Halt-Taster")
}
}
// ── Operational State Graph tests ──────────────────────────────────
func TestPatternEngine_OperationalState_NilFiresAlways(t *testing.T) {
// Patterns with nil OperationalStates should fire regardless of input states
engine := NewPatternEngine()
result1 := engine.Match(MatchInput{
ComponentLibraryIDs: []string{"C001"},
EnergySourceIDs: []string{"EN01"},
})
result2 := engine.Match(MatchInput{
ComponentLibraryIDs: []string{"C001"},
EnergySourceIDs: []string{"EN01"},
OperationalStates: []string{"automatic_operation"},
})
if len(result1.MatchedPatterns) == 0 {
t.Fatal("expected patterns to fire without operational states")
}
if len(result2.MatchedPatterns) == 0 {
t.Fatal("expected patterns to fire with automatic_operation state")
}
// nil-state patterns should fire in both cases
if len(result1.MatchedPatterns) != len(result2.MatchedPatterns) {
t.Logf("patterns without states: %d, with automatic_operation: %d", len(result1.MatchedPatterns), len(result2.MatchedPatterns))
// This is OK — some patterns may have OperationalStates set and filter differently
}
}
func TestPatternEngine_OperationalState_MaintenanceFilter(t *testing.T) {
// HP073 (LOTO) has OperationalStates: ["maintenance"]
// It should only fire when maintenance is in the input states
engine := NewPatternEngine()
// Without maintenance state — HP073 should still fire if no states in input
// (because empty input states = no filtering)
resultNoState := engine.Match(MatchInput{
ComponentLibraryIDs: []string{"C001"},
EnergySourceIDs: []string{"EN01"},
LifecyclePhases: []string{"maintenance"},
})
// With maintenance state — HP073 should fire
resultMaint := engine.Match(MatchInput{
ComponentLibraryIDs: []string{"C001"},
EnergySourceIDs: []string{"EN01"},
LifecyclePhases: []string{"maintenance"},
OperationalStates: []string{"maintenance"},
})
// With only automatic_operation — HP073 should NOT fire
resultAuto := engine.Match(MatchInput{
ComponentLibraryIDs: []string{"C001"},
EnergySourceIDs: []string{"EN01"},
LifecyclePhases: []string{"maintenance"},
OperationalStates: []string{"automatic_operation"},
})
// HP073 should be in resultMaint but not in resultAuto
hasHP073Maint := false
for _, p := range resultMaint.MatchedPatterns {
if p.PatternID == "HP073" {
hasHP073Maint = true
break
}
}
hasHP073Auto := false
for _, p := range resultAuto.MatchedPatterns {
if p.PatternID == "HP073" {
hasHP073Auto = true
break
}
}
if !hasHP073Maint {
t.Error("HP073 should fire with maintenance operational state")
}
if hasHP073Auto {
t.Error("HP073 should NOT fire with automatic_operation operational state")
}
_ = resultNoState // HP073 fires here too because empty input states = no filter
}
func TestPatternEngine_StateTransition_UnexpectedRestart(t *testing.T) {
// HP068 has StateTransitions: ["maintenance→automatic_operation", "emergency_stop→recovery_mode"]
engine := NewPatternEngine()
// HP068 requires moving_part + programmable — C001 (Roboterarm) + C071 (SPS)
// With matching transition
resultMatch := engine.Match(MatchInput{
ComponentLibraryIDs: []string{"C001", "C071"},
EnergySourceIDs: []string{"EN01"},
LifecyclePhases: []string{"fault_clearing"},
OperationalStates: []string{"recovery_mode", "emergency_stop"},
StateTransitions: []string{"maintenance→automatic_operation"},
})
// With non-matching transition
resultNoMatch := engine.Match(MatchInput{
ComponentLibraryIDs: []string{"C001", "C071"},
EnergySourceIDs: []string{"EN01"},
LifecyclePhases: []string{"fault_clearing"},
OperationalStates: []string{"recovery_mode", "emergency_stop"},
StateTransitions: []string{"startup→homing"},
})
hasHP068Match := false
for _, p := range resultMatch.MatchedPatterns {
if p.PatternID == "HP068" {
hasHP068Match = true
// Verify explainability reasons include state transition
hasTransReason := false
for _, r := range p.MatchReasons {
if r.Type == "state_transition" {
hasTransReason = true
break
}
}
if !hasTransReason {
t.Error("HP068 match should include state_transition reason")
}
break
}
}
hasHP068NoMatch := false
for _, p := range resultNoMatch.MatchedPatterns {
if p.PatternID == "HP068" {
hasHP068NoMatch = true
break
}
}
if !hasHP068Match {
t.Error("HP068 should fire with matching state transition")
}
if hasHP068NoMatch {
t.Error("HP068 should NOT fire with non-matching state transition")
}
}
func TestPatternEngine_MatchReasons_IncludeOperationalState(t *testing.T) {
// Verify that MatchReasons include operational_state entries
engine := NewPatternEngine()
result := engine.Match(MatchInput{
ComponentLibraryIDs: []string{"C001"},
EnergySourceIDs: []string{"EN01"},
LifecyclePhases: []string{"maintenance"},
OperationalStates: []string{"maintenance"},
})
for _, p := range result.MatchedPatterns {
if p.PatternID == "HP073" {
hasStateReason := false
for _, r := range p.MatchReasons {
if r.Type == "operational_state" && r.Tag == "maintenance" && r.Met {
hasStateReason = true
break
}
}
if !hasStateReason {
t.Error("HP073 MatchReasons should include operational_state:maintenance with Met=true")
}
return
}
}
t.Error("HP073 not found in matched patterns")
}
func TestAllOperationalStates_Count(t *testing.T) {
states := AllOperationalStates()
if len(states) != 9 {
t.Errorf("expected 9 operational states, got %d", len(states))
}
}
func TestStandardStateTransitions_Valid(t *testing.T) {
transitions := StandardStateTransitions()
if len(transitions) < 15 {
t.Errorf("expected at least 15 state transitions, got %d", len(transitions))
}
// Verify all transitions reference valid states
stateSet := make(map[string]bool)
for _, s := range AllOperationalStates() {
stateSet[s] = true
}
for _, tr := range transitions {
// Format: "from→to" (→ is multi-byte UTF-8)
parts := splitTransition(tr)
if len(parts) != 2 {
t.Errorf("invalid transition format: %q", tr)
continue
}
if !stateSet[parts[0]] {
t.Errorf("transition %q references unknown state: %q", tr, parts[0])
}
if !stateSet[parts[1]] {
t.Errorf("transition %q references unknown state: %q", tr, parts[1])
}
}
}
func splitTransition(tr string) []string {
// Split on → (UTF-8: 0xE2 0x86 0x92)
idx := 0
for i := 0; i < len(tr); i++ {
if tr[i] == 0xE2 && i+2 < len(tr) && tr[i+1] == 0x86 && tr[i+2] == 0x92 {
return []string{tr[:i], tr[i+3:]}
}
idx = i
}
_ = idx
return []string{tr}
}