f534b52817
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.
172 lines
4.5 KiB
Go
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
|
|
}
|