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
+115 -10
View File
@@ -8,6 +8,12 @@ type MatchInput struct {
EnergySourceIDs []string `json:"energy_source_ids"`
LifecyclePhases []string `json:"lifecycle_phases"`
CustomTags []string `json:"custom_tags"`
// OperationalStates are the active operational states of the machine.
// Used to filter patterns that only apply in specific states (e.g. teach_mode, maintenance).
OperationalStates []string `json:"operational_states,omitempty"`
// StateTransitions are active state transitions (format: "from→to").
// Used to detect transition-specific hazards like unexpected restart.
StateTransitions []string `json:"state_transitions,omitempty"`
}
// MatchOutput contains the results of pattern matching.
@@ -42,9 +48,11 @@ type PatternMatch struct {
ZoneDE string `json:"zone_de,omitempty"`
DefaultSeverity int `json:"default_severity,omitempty"`
DefaultExposure int `json:"default_exposure,omitempty"`
HazardCats []string `json:"hazard_categories,omitempty"`
ExpertHintDE string `json:"expert_hint_de,omitempty"`
RequiresExpert bool `json:"requires_expert,omitempty"`
HazardCats []string `json:"hazard_categories,omitempty"`
ExpertHintDE string `json:"expert_hint_de,omitempty"`
RequiresExpert bool `json:"requires_expert,omitempty"`
OperationalStates []string `json:"operational_states,omitempty"`
StateTransitions []string `json:"state_transitions,omitempty"`
}
// HazardSuggestion is a suggested hazard from pattern matching.
@@ -141,7 +149,7 @@ func (e *PatternEngine) Match(input MatchInput) *MatchOutput {
evidenceSources := make(map[string][]string) // evidence ID → pattern IDs
for _, p := range patterns {
if !patternMatches(p, tagSet, input.LifecyclePhases) {
if !patternMatches(p, tagSet, input) {
continue
}
@@ -175,6 +183,18 @@ func (e *PatternEngine) Match(input MatchInput) *MatchOutput {
reasons = append(reasons, MatchReason{Type: "lifecycle_match", Tag: lc, Met: found})
}
}
if len(p.OperationalStates) > 0 {
stateSet := toSet(input.OperationalStates)
for _, s := range p.OperationalStates {
reasons = append(reasons, MatchReason{Type: "operational_state", Tag: s, Met: stateSet[s]})
}
}
if len(p.StateTransitions) > 0 {
transSet := toSet(input.StateTransitions)
for _, t := range p.StateTransitions {
reasons = append(reasons, MatchReason{Type: "state_transition", Tag: t, Met: transSet[t]})
}
}
matchedPatterns = append(matchedPatterns, PatternMatch{
PatternID: p.ID,
@@ -190,8 +210,10 @@ func (e *PatternEngine) Match(input MatchInput) *MatchOutput {
DefaultSeverity: p.DefaultSeverity,
DefaultExposure: p.DefaultExposure,
HazardCats: p.GeneratedHazardCats,
ExpertHintDE: p.ExpertHintDE,
RequiresExpert: p.RequiresExpertCalculation,
ExpertHintDE: p.ExpertHintDE,
RequiresExpert: p.RequiresExpertCalculation,
OperationalStates: p.OperationalStates,
StateTransitions: p.StateTransitions,
})
for _, cat := range p.GeneratedHazardCats {
@@ -259,8 +281,9 @@ func (e *PatternEngine) Match(input MatchInput) *MatchOutput {
}
}
// patternMatches checks if a pattern fires given the resolved tag set and lifecycle phases.
func patternMatches(p HazardPattern, tagSet map[string]bool, lifecyclePhases []string) bool {
// patternMatches checks if a pattern fires given the resolved tag set, lifecycle phases,
// operational states, and state transitions.
func patternMatches(p HazardPattern, tagSet map[string]bool, input MatchInput) bool {
// All required component tags must be present (AND)
for _, t := range p.RequiredComponentTags {
if !tagSet[t] {
@@ -283,9 +306,9 @@ func patternMatches(p HazardPattern, tagSet map[string]bool, lifecyclePhases []s
}
// If pattern requires specific lifecycle phases, at least one must match
if len(p.RequiredLifecycles) > 0 && len(lifecyclePhases) > 0 {
if len(p.RequiredLifecycles) > 0 && len(input.LifecyclePhases) > 0 {
found := false
phaseSet := toSet(lifecyclePhases)
phaseSet := toSet(input.LifecyclePhases)
for _, lp := range p.RequiredLifecycles {
if phaseSet[lp] {
found = true
@@ -297,6 +320,37 @@ func patternMatches(p HazardPattern, tagSet map[string]bool, lifecyclePhases []s
}
}
// If pattern requires specific operational states, at least one must match.
// nil/empty OperationalStates on pattern = fires in ALL states (backwards compatible).
if len(p.OperationalStates) > 0 && len(input.OperationalStates) > 0 {
found := false
stateSet := toSet(input.OperationalStates)
for _, s := range p.OperationalStates {
if stateSet[s] {
found = true
break
}
}
if !found {
return false
}
}
// If pattern requires specific state transitions, at least one must match.
if len(p.StateTransitions) > 0 && len(input.StateTransitions) > 0 {
found := false
transSet := toSet(input.StateTransitions)
for _, t := range p.StateTransitions {
if transSet[t] {
found = true
break
}
}
if !found {
return false
}
}
return true
}
@@ -317,3 +371,54 @@ func maxInt(a, b int) int {
}
return b
}
// ── Operational State Graph constants ──────────────────────────────
// Standard operational states for machinery (ISO 12100 + BetrSichV).
const (
StateStartup = "startup"
StateHoming = "homing"
StateAutomaticOperation = "automatic_operation"
StateManualOperation = "manual_operation"
StateTeachMode = "teach_mode"
StateMaintenance = "maintenance"
StateCleaning = "cleaning"
StateEmergencyStop = "emergency_stop"
StateRecoveryMode = "recovery_mode"
)
// AllOperationalStates returns the 9 standard operational states.
func AllOperationalStates() []string {
return []string{
StateStartup, StateHoming, StateAutomaticOperation,
StateManualOperation, StateTeachMode, StateMaintenance,
StateCleaning, StateEmergencyStop, StateRecoveryMode,
}
}
// StandardStateTransitions returns the valid transitions between states.
// Format: "from→to". These represent the directed edges of the state graph.
func StandardStateTransitions() []string {
return []string{
"startup→homing",
"homing→automatic_operation",
"automatic_operation→manual_operation",
"manual_operation→automatic_operation",
"automatic_operation→teach_mode",
"teach_mode→automatic_operation",
"automatic_operation→maintenance",
"manual_operation→maintenance",
"maintenance→automatic_operation",
"maintenance→manual_operation",
"automatic_operation→cleaning",
"cleaning→automatic_operation",
"automatic_operation→emergency_stop",
"manual_operation→emergency_stop",
"teach_mode→emergency_stop",
"maintenance→emergency_stop",
"cleaning→emergency_stop",
"emergency_stop→recovery_mode",
"recovery_mode→homing",
"recovery_mode→manual_operation",
}
}