Files
breakpilot-compliance/ai-compliance-sdk/internal/iace/pattern_engine.go
T
Benjamin Admin 2b5376ed54 fix(iace): pattern-specific measures take priority over category fallback
Each hazard now gets measures from its SOURCE PATTERN first
(SuggestedMeasureIDs), then category fallback for remaining slots.

Previously all mechanical hazards got the same generic top-5 measures
(Gefahrstelle eliminieren, Sicherheitsabstaende, Scharfe Kanten...).
Now a KSS-Schlauch hazard gets M420 (Druckfeste Auslegung) first.

SuggestedMeasureIDs added to PatternMatch struct and passed through
from pattern definition to hazard creation to measure assignment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-15 14:17:32 +02:00

479 lines
16 KiB
Go

package iace
import "sort"
// MatchInput defines the inputs for the pattern-matching engine.
type MatchInput struct {
ComponentLibraryIDs []string `json:"component_library_ids"`
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"`
// HumanRoles are the human roles interacting with the machine in this project.
// Used to filter patterns that only apply to specific roles (e.g. programmer, maintenance_tech).
HumanRoles []string `json:"human_roles,omitempty"`
// FailureModes are the active failure mode IDs relevant for this project.
// Used to filter patterns that require specific failure modes.
FailureModes []string `json:"failure_modes,omitempty"`
// MachineTypes are the industry sectors / machine types for this project.
// Patterns with MachineTypes filter only fire if at least one matches.
// Empty = all patterns fire (backwards compatible).
MachineTypes []string `json:"machine_types,omitempty"`
}
// MatchOutput contains the results of pattern matching.
type MatchOutput struct {
MatchedPatterns []PatternMatch `json:"matched_patterns"`
SuggestedHazards []HazardSuggestion `json:"suggested_hazards"`
SuggestedMeasures []MeasureSuggestion `json:"suggested_measures"`
SuggestedEvidence []EvidenceSuggestion `json:"suggested_evidence"`
ResolvedTags []string `json:"resolved_tags"`
}
// MatchReason explains why a specific check passed or was relevant for a pattern match.
type MatchReason struct {
Type string `json:"type"` // "required_component_tag", "required_energy_tag", "lifecycle_match", "no_exclusion"
Tag string `json:"tag"`
Met bool `json:"met"`
}
// PatternMatch records which pattern fired and why.
type PatternMatch struct {
PatternID string `json:"pattern_id"`
PatternName string `json:"pattern_name"`
Priority int `json:"priority"`
MatchedTags []string `json:"matched_tags"`
// Explainability: structured reasons why this pattern fired
MatchReasons []MatchReason `json:"match_reasons,omitempty"`
// Detail fields from the pattern definition
ScenarioDE string `json:"scenario_de,omitempty"`
TriggerDE string `json:"trigger_de,omitempty"`
HarmDE string `json:"harm_de,omitempty"`
AffectedDE string `json:"affected_de,omitempty"`
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"`
OperationalStates []string `json:"operational_states,omitempty"`
StateTransitions []string `json:"state_transitions,omitempty"`
HumanRoles []string `json:"human_roles,omitempty"`
GeneratedHazardType string `json:"generated_hazard_type,omitempty"`
MatchedFailureModes []string `json:"matched_failure_modes,omitempty"`
ApplicableLifecycles []string `json:"applicable_lifecycles,omitempty"`
SuggestedMeasureIDs []string `json:"suggested_measure_ids,omitempty"`
}
// HazardSuggestion is a suggested hazard from pattern matching.
type HazardSuggestion struct {
Category string `json:"category"`
SourcePatterns []string `json:"source_patterns"`
Confidence float64 `json:"confidence"`
}
// MeasureSuggestion is a suggested protective measure from pattern matching.
type MeasureSuggestion struct {
MeasureID string `json:"measure_id"`
SourcePatterns []string `json:"source_patterns"`
}
// EvidenceSuggestion is a suggested evidence type from pattern matching.
type EvidenceSuggestion struct {
EvidenceID string `json:"evidence_id"`
SourcePatterns []string `json:"source_patterns"`
}
// PatternEngine evaluates hazard patterns against resolved tags.
type PatternEngine struct {
resolver *TagResolver
patterns []HazardPattern
}
// NewPatternEngine creates a PatternEngine with all pattern sources and resolver.
// Pattern registration is in pattern_registry.go (collectAllPatterns).
func NewPatternEngine() *PatternEngine {
return &PatternEngine{
resolver: NewTagResolver(),
patterns: collectAllPatterns(),
}
}
// Match executes the pattern-matching algorithm:
// 1. Resolve component + energy IDs → tags
// 2. Merge with custom tags
// 3. Sort patterns by priority (descending)
// 4. For each pattern: check required_tags (AND) and excluded_tags (NOT)
// 5. Collect hazards, measures, evidence from fired patterns (deduplicated)
// 6. Calculate confidence scores
func (e *PatternEngine) Match(input MatchInput) *MatchOutput {
// Step 1+2: resolve all tags
allTags := e.resolver.ResolveTags(input.ComponentLibraryIDs, input.EnergySourceIDs, input.CustomTags)
if len(allTags) == 0 {
return &MatchOutput{ResolvedTags: []string{}}
}
tagSet := toSet(allTags)
// Step 3: sort patterns by priority descending
patterns := make([]HazardPattern, len(e.patterns))
copy(patterns, e.patterns)
sort.Slice(patterns, func(i, j int) bool {
return patterns[i].Priority > patterns[j].Priority
})
// Step 4+5: evaluate each pattern
var matchedPatterns []PatternMatch
hazardCatSources := make(map[string][]string) // category → pattern IDs
measureSources := make(map[string][]string) // measure ID → pattern IDs
evidenceSources := make(map[string][]string) // evidence ID → pattern IDs
for _, p := range patterns {
if !patternMatches(p, tagSet, input) {
continue
}
// Collect the tags that contributed + build explainability reasons
var matchedTags []string
var reasons []MatchReason
for _, t := range p.RequiredComponentTags {
if tagSet[t] {
matchedTags = append(matchedTags, t)
reasons = append(reasons, MatchReason{Type: "required_component_tag", Tag: t, Met: true})
}
}
for _, t := range p.RequiredEnergyTags {
if tagSet[t] {
matchedTags = append(matchedTags, t)
reasons = append(reasons, MatchReason{Type: "required_energy_tag", Tag: t, Met: true})
}
}
for _, t := range p.ExcludedComponentTags {
reasons = append(reasons, MatchReason{Type: "no_exclusion", Tag: t, Met: !tagSet[t]})
}
if len(p.RequiredLifecycles) > 0 {
for _, lc := range p.RequiredLifecycles {
found := false
for _, ilc := range input.LifecyclePhases {
if ilc == lc {
found = true
break
}
}
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]})
}
}
if len(p.HumanRoles) > 0 {
roleSet := toSet(input.HumanRoles)
for _, r := range p.HumanRoles {
reasons = append(reasons, MatchReason{Type: "human_role", Tag: r, Met: roleSet[r]})
}
}
var matchedFMs []string
if len(p.RequiredFailureModes) > 0 {
fmSet := toSet(input.FailureModes)
for _, fm := range p.RequiredFailureModes {
met := fmSet[fm]
reasons = append(reasons, MatchReason{Type: "failure_mode", Tag: fm, Met: met})
if met {
matchedFMs = append(matchedFMs, fm)
}
}
}
matchedPatterns = append(matchedPatterns, PatternMatch{
PatternID: p.ID,
PatternName: p.NameDE,
Priority: p.Priority,
MatchedTags: matchedTags,
MatchReasons: reasons,
ScenarioDE: p.ScenarioDE,
TriggerDE: p.TriggerDE,
HarmDE: p.HarmDE,
AffectedDE: p.AffectedDE,
ZoneDE: p.ZoneDE,
DefaultSeverity: p.DefaultSeverity,
DefaultExposure: p.DefaultExposure,
HazardCats: p.GeneratedHazardCats,
ExpertHintDE: p.ExpertHintDE,
RequiresExpert: p.RequiresExpertCalculation,
OperationalStates: p.OperationalStates,
StateTransitions: p.StateTransitions,
HumanRoles: p.HumanRoles,
GeneratedHazardType: p.GeneratedHazardType,
MatchedFailureModes: matchedFMs,
ApplicableLifecycles: p.ApplicableLifecycles,
SuggestedMeasureIDs: p.SuggestedMeasureIDs,
})
for _, cat := range p.GeneratedHazardCats {
hazardCatSources[cat] = appendUnique(hazardCatSources[cat], p.ID)
}
for _, mid := range p.SuggestedMeasureIDs {
measureSources[mid] = appendUnique(measureSources[mid], p.ID)
}
for _, eid := range p.SuggestedEvidenceIDs {
evidenceSources[eid] = appendUnique(evidenceSources[eid], p.ID)
}
}
// Step 6: build output with confidence scores
totalPatterns := len(patterns)
firedCount := len(matchedPatterns)
var suggestedHazards []HazardSuggestion
for cat, sources := range hazardCatSources {
confidence := float64(len(sources)) / float64(maxInt(firedCount, 1))
if confidence > 1.0 {
confidence = 1.0
}
suggestedHazards = append(suggestedHazards, HazardSuggestion{
Category: cat,
SourcePatterns: sources,
Confidence: confidence,
})
}
// Sort by confidence descending
sort.Slice(suggestedHazards, func(i, j int) bool {
return suggestedHazards[i].Confidence > suggestedHazards[j].Confidence
})
var suggestedMeasures []MeasureSuggestion
for mid, sources := range measureSources {
suggestedMeasures = append(suggestedMeasures, MeasureSuggestion{
MeasureID: mid,
SourcePatterns: sources,
})
}
sort.Slice(suggestedMeasures, func(i, j int) bool {
return suggestedMeasures[i].MeasureID < suggestedMeasures[j].MeasureID
})
var suggestedEvidence []EvidenceSuggestion
for eid, sources := range evidenceSources {
suggestedEvidence = append(suggestedEvidence, EvidenceSuggestion{
EvidenceID: eid,
SourcePatterns: sources,
})
}
sort.Slice(suggestedEvidence, func(i, j int) bool {
return suggestedEvidence[i].EvidenceID < suggestedEvidence[j].EvidenceID
})
_ = totalPatterns // used conceptually for confidence normalization
return &MatchOutput{
MatchedPatterns: matchedPatterns,
SuggestedHazards: suggestedHazards,
SuggestedMeasures: suggestedMeasures,
SuggestedEvidence: suggestedEvidence,
ResolvedTags: allTags,
}
}
// 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 {
// If pattern requires specific machine types, project must match at least one.
// Patterns without MachineTypes fire for ALL projects (backwards compatible).
if len(p.MachineTypes) > 0 && len(input.MachineTypes) > 0 {
found := false
mtSet := toSet(input.MachineTypes)
for _, mt := range p.MachineTypes {
if mtSet[mt] {
found = true
break
}
}
if !found {
return false
}
}
// All required component tags must be present (AND)
for _, t := range p.RequiredComponentTags {
if !tagSet[t] {
return false
}
}
// All required energy tags must be present (AND)
for _, t := range p.RequiredEnergyTags {
if !tagSet[t] {
return false
}
}
// None of the excluded tags may be present (NOT)
for _, t := range p.ExcludedComponentTags {
if tagSet[t] {
return false
}
}
// If pattern requires specific lifecycle phases, at least one must match
if len(p.RequiredLifecycles) > 0 && len(input.LifecyclePhases) > 0 {
found := false
phaseSet := toSet(input.LifecyclePhases)
for _, lp := range p.RequiredLifecycles {
if phaseSet[lp] {
found = true
break
}
}
if !found {
return false
}
}
// 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
}
}
// If pattern requires specific human roles, at least one must match.
if len(p.HumanRoles) > 0 && len(input.HumanRoles) > 0 {
found := false
roleSet := toSet(input.HumanRoles)
for _, r := range p.HumanRoles {
if roleSet[r] {
found = true
break
}
}
if !found {
return false
}
}
// If pattern requires specific failure modes, at least one must match.
if len(p.RequiredFailureModes) > 0 && len(input.FailureModes) > 0 {
found := false
fmSet := toSet(input.FailureModes)
for _, fm := range p.RequiredFailureModes {
if fmSet[fm] {
found = true
break
}
}
if !found {
return false
}
}
return true
}
// appendUnique appends val to slice if not already present.
func appendUnique(slice []string, val string) []string {
for _, s := range slice {
if s == val {
return slice
}
}
return append(slice, val)
}
// maxInt returns the larger of a and b.
func maxInt(a, b int) int {
if a > b {
return a
}
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",
}
}