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.
159 lines
4.6 KiB
Go
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
|
|
}
|