Files
breakpilot-compliance/ai-compliance-sdk/internal/iace/audit/hierarchy.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

159 lines
4.6 KiB
Go

package audit
import (
"sort"
"strings"
)
// runHierarchyImpl checks the ISO 12100 / EN 12100 risk-reduction
// hierarchy on the generated mitigation set: every safety-relevant
// hazard should have at least one "inherently safe design" measure
// (design) and additionally either a guarding/protective device
// (protection) or an information-for-use measure (information).
//
// Cyber-, ergonomic-, and software-only hazards have looser
// expectations — design alone or information alone may legitimately
// suffice. The audit reports which level is missing, not whether the
// remaining measures are individually correct. That is a different
// check (E2 — semantic quality), out of scope here.
func init() {
runHierarchyImpl = runHierarchy
}
// hazardExpectsProtection lists hazard categories where a pure
// design+information combination is usually not enough — the engine
// should produce at least one explicit protective measure (guard,
// interlock, sensor, presence detector, …).
var hazardExpectsProtection = map[string]bool{
"mechanical_hazard": true,
"electrical_hazard": true,
"thermal_hazard": true,
"pneumatic_hydraulic": true,
"radiation_hazard": true,
"laser_hazard": true,
"fire_explosion_hazard": true,
"chemical_hazard": true,
}
func runHierarchy(hazards, mitigations []map[string]any) HierarchyReport {
report := HierarchyReport{TotalHazards: len(hazards)}
// Index mitigations by hazard_id
byHazard := map[string][]map[string]any{}
for _, m := range mitigations {
hid, _ := m["hazard_id"].(string)
if hid == "" {
continue
}
byHazard[hid] = append(byHazard[hid], m)
}
for _, h := range hazards {
hid, _ := h["id"].(string)
category, _ := h["category"].(string)
name, _ := h["name"].(string)
levels := levelsForHazard(byHazard[hid])
missing := expectedMissing(category, levels)
if len(missing) == 0 {
report.Complete++
continue
}
for _, m := range missing {
switch m {
case "design":
report.MissingDesign++
case "protection":
report.MissingProtection++
case "information":
report.MissingInfo++
}
}
report.IncompleteHazards = append(report.IncompleteHazards, HazardHierarchyResult{
HazardID: hid,
Name: name,
Category: category,
Levels: levels,
MissingLevels: missing,
})
}
// Sort: protection-missing first (most consequential), then by category
sort.Slice(report.IncompleteHazards, func(i, j int) bool {
a := report.IncompleteHazards[i]
b := report.IncompleteHazards[j]
ap := contains(a.MissingLevels, "protection")
bp := contains(b.MissingLevels, "protection")
if ap != bp {
return ap
}
return a.Category < b.Category
})
return report
}
// levelsForHazard returns the distinct reduction-type levels present
// for a hazard's mitigation set. Possible values: design, protection,
// information.
func levelsForHazard(mits []map[string]any) []string {
seen := map[string]bool{}
for _, m := range mits {
rt, _ := m["reduction_type"].(string)
switch strings.ToLower(rt) {
case "design":
seen["design"] = true
case "protection", "protective":
seen["protection"] = true
case "information":
seen["information"] = true
}
}
var out []string
for k := range seen {
out = append(out, k)
}
sort.Strings(out)
return out
}
// expectedMissing returns the levels that the hierarchy demands but
// the mitigation set does not provide.
//
// Rule:
// - Every hazard with mitigations should have a design measure.
// - Categories in hazardExpectsProtection additionally need a
// protection measure.
// - All hazards should have an information measure unless they
// already have both design + protection (the information layer
// can then be considered subsumed for the audit's purpose; the
// real engine usually still adds it).
func expectedMissing(category string, present []string) []string {
have := toBoolSet(present)
var missing []string
if !have["design"] {
missing = append(missing, "design")
}
if hazardExpectsProtection[category] && !have["protection"] {
missing = append(missing, "protection")
}
// Information is only flagged if both design and protection are
// also absent — otherwise too noisy. We still surface the case
// where information is the SOLE present level: that means the
// hazard is mitigated only by warning labels, which is rarely
// adequate.
if !have["information"] && !have["design"] && !have["protection"] {
missing = append(missing, "information")
}
return missing
}
func contains(list []string, target string) bool {
for _, x := range list {
if x == target {
return true
}
}
return false
}