Files
breakpilot-compliance/ai-compliance-sdk/internal/iace/audit/consistency.go
T
Benjamin Admin f534b52817 feat(iace): pattern audit suite + library hygiene wave
Add cmd/iace-audit CLI with 5 deterministic methods that find engine
gaps without ground truth:

- A reachability: 1058 patterns vs achievable tag universe
- B consistency: components vs their declared hazard categories
- C vocabulary: limits-form tokens vs keyword dictionary
- D echo: limits-form sentences vs generated hazards (jaccard)
- E hierarchy: hazards vs ISO 12100 design/protection/info levels

Library fixes triggered by A+B+C findings:

- tag_resolver: synonym map for electrical/pneumatic/hydraulic aliases
- component_library: crush_point + EN03 (gravitational) on C014/C128
  (Hubwerk family) - fixes HP1014/1015/1017/1018 which were silently
  weakly_reachable. noise_source added on 7 components (C006/C011/
  C017/C020/C031/C041/C096). electrical_part on 8 drive components
  (C031/C032/C033/C034/C035/C036/C037/C038/C077/C092). cyber tag
  on 10 sensors (C081-C090) + 3 IT components (C111/C112/C116) +
  KI module C119 (ai_model added). pneumatic_part+hydraulic_part
  on valves C091/C093, hydraulic_part+chemical_risk on pump C097,
  moving_part on motion controller C075
- keyword_dictionary: EN03 added to aufzug/lift/hubwerk/hubgeraet
  (was wrongly EN04-only). New keyword entries for hub-action verbs:
  absenken/senken/anheben/heben + hubhoehe/hubweg/hubgeschwindig

Audit impact:
- A: weakly_reachable 409 -> 358 (-51 patterns now fully reachable)
- B: incomplete components 46 -> 30 (-16, -33%)
- HP1018 (Person unter absenkendem Maschinenteil eingeklemmt):
  weakly_reachable -> reachable

Why: methods A/B/C surfaced that the Kistenhubgeraet test project
generated 0 crush-under-load hazards despite OSHA 1910.212(a)(3) +
EN ISO 12100 6.3.5.5 explicitly requiring them. Three orthogonal
bugs (missing crush_point tag, wrong energy source mapping, missing
action verbs in dictionary) silently disabled the entire lift crush
pattern family.
2026-05-21 10:51:08 +02:00

172 lines
4.5 KiB
Go

package audit
import (
"sort"
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
)
// runConsistencyImpl asks: does this component, with its own tags PLUS the
// tags of its TypicalEnergySources, actually trigger at least one pattern
// in every category listed in its TypicalHazardCategories?
//
// A component declares "this is what I am dangerous for" and the engine
// turns that declaration into hazards through patterns. If no pattern can
// fire from the component's tag set, the declaration is decorative — the
// engine will never produce a hazard in that category for this component,
// even though the library author said it should.
func init() {
runConsistencyImpl = runConsistency
}
func runConsistency() ConsistencyReport {
comps := iace.GetComponentLibrary()
energies := iace.GetEnergySources()
patterns := iace.AllPatterns()
energyByID := map[string]iace.EnergySourceEntry{}
for _, e := range energies {
energyByID[e.ID] = e
}
report := ConsistencyReport{TotalComponents: len(comps)}
for _, c := range comps {
if len(c.TypicalHazardCategories) == 0 {
report.Consistent++
continue
}
effective := buildEffectiveTags(c, energyByID)
covered := categoriesCoveredByPatterns(effective, c.MapsToComponentType, patterns)
var missing []string
for _, cat := range c.TypicalHazardCategories {
if !covered[cat] {
missing = append(missing, cat)
}
}
if len(missing) == 0 {
report.Consistent++
continue
}
result := ComponentResult{
ComponentID: c.ID,
NameDE: c.NameDE,
DeclaredCategories: c.TypicalHazardCategories,
}
for cat := range covered {
result.CoveredCategories = append(result.CoveredCategories, cat)
}
sort.Strings(result.CoveredCategories)
for _, cat := range missing {
result.MissingForCategories = append(result.MissingForCategories, CategoryGap{
Category: cat,
SuggestedTags: suggestTagsForCategory(cat, effective, patterns),
})
}
report.Incomplete++
report.IncompleteComponents = append(report.IncompleteComponents, result)
}
sort.Slice(report.IncompleteComponents, func(i, j int) bool {
return report.IncompleteComponents[i].ComponentID < report.IncompleteComponents[j].ComponentID
})
return report
}
func buildEffectiveTags(c iace.ComponentLibraryEntry, energyByID map[string]iace.EnergySourceEntry) map[string]bool {
set := map[string]bool{}
for _, t := range c.Tags {
set[t] = true
}
for _, eID := range c.TypicalEnergySources {
e, ok := energyByID[eID]
if !ok {
continue
}
for _, t := range e.Tags {
set[t] = true
}
}
return set
}
// categoriesCoveredByPatterns iterates patterns and finds which
// GeneratedHazardCats can fire given the component's effective tags.
// We ignore lifecycle, op-state, and human-role filters — those are
// project-level. The audit asks "can the library produce ANY hazard in
// this category for this component if the project configures everything
// reasonably?"
func categoriesCoveredByPatterns(tags map[string]bool, _ string, patterns []iace.HazardPattern) map[string]bool {
covered := map[string]bool{}
for _, p := range patterns {
if !tagsCover(tags, p.RequiredComponentTags) {
continue
}
if !tagsCover(tags, p.RequiredEnergyTags) {
continue
}
for _, cat := range p.GeneratedHazardCats {
covered[cat] = true
}
}
return covered
}
func tagsCover(have map[string]bool, required []string) bool {
for _, t := range required {
if !have[t] {
return false
}
}
return true
}
// suggestTagsForCategory looks at patterns that DO generate this category
// and identifies the tags that would close the gap. Returns the tags most
// commonly required by patterns in that category, minus what the component
// already has.
func suggestTagsForCategory(cat string, have map[string]bool, patterns []iace.HazardPattern) []string {
counts := map[string]int{}
for _, p := range patterns {
matchCat := false
for _, c := range p.GeneratedHazardCats {
if c == cat {
matchCat = true
break
}
}
if !matchCat {
continue
}
for _, t := range p.RequiredComponentTags {
if !have[t] {
counts[t]++
}
}
for _, t := range p.RequiredEnergyTags {
if !have[t] {
counts[t]++
}
}
}
type kv struct {
tag string
n int
}
var sorted []kv
for t, n := range counts {
sorted = append(sorted, kv{t, n})
}
sort.Slice(sorted, func(i, j int) bool { return sorted[i].n > sorted[j].n })
var out []string
for i, s := range sorted {
if i >= 6 {
break
}
out = append(out, s.tag)
}
return out
}