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.
242 lines
6.6 KiB
Go
242 lines
6.6 KiB
Go
// Command iace-audit runs static and runtime audits on the IACE pattern
|
|
// engine to find gaps without a ground-truth reference.
|
|
//
|
|
// Subcommands:
|
|
//
|
|
// reachability — Method A: which patterns can never fire given the library?
|
|
// consistency — Method B: do components cover their TypicalHazardCategories?
|
|
// vocabulary — Method C: which limits-form words are unknown to the dict?
|
|
// echo — Method D: which limits-form sentences have no hazard echo?
|
|
// hierarchy — Method E: which hazards lack design/protection/information?
|
|
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
|
|
"github.com/breakpilot/ai-compliance-sdk/internal/iace/audit"
|
|
)
|
|
|
|
func main() {
|
|
if len(os.Args) < 2 {
|
|
usage()
|
|
os.Exit(2)
|
|
}
|
|
switch os.Args[1] {
|
|
case "reachability":
|
|
cmdReachability(os.Args[2:])
|
|
case "consistency":
|
|
cmdConsistency(os.Args[2:])
|
|
case "vocabulary":
|
|
cmdVocabulary(os.Args[2:])
|
|
case "echo":
|
|
cmdEcho(os.Args[2:])
|
|
case "hierarchy":
|
|
cmdHierarchy(os.Args[2:])
|
|
default:
|
|
usage()
|
|
os.Exit(2)
|
|
}
|
|
}
|
|
|
|
func usage() {
|
|
fmt.Fprintln(os.Stderr, "Usage: iace-audit <reachability|consistency|vocabulary|echo|hierarchy> [args]")
|
|
}
|
|
|
|
func cmdReachability(_ []string) {
|
|
r := audit.RunReachability()
|
|
printSummary(fmt.Sprintf("Method A — Pattern Reachability"), map[string]int{
|
|
"total": r.TotalPatterns,
|
|
"reachable": r.Reachable,
|
|
"weakly_reachable": r.WeaklyReachable,
|
|
"unreachable": r.Unreachable,
|
|
"universe_tags": len(r.UniverseTags),
|
|
})
|
|
if len(r.UnreachablePatterns) > 0 {
|
|
fmt.Println("\n## Unreachable patterns (top 30 by priority):\n")
|
|
printPatternRows(r.UnreachablePatterns, 30)
|
|
}
|
|
if len(r.WeakPatterns) > 0 {
|
|
fmt.Println("\n## Weakly reachable (top 20 by priority):\n")
|
|
printPatternRows(r.WeakPatterns, 20)
|
|
}
|
|
writeJSON("audit-reports/reachability.json", r)
|
|
}
|
|
|
|
func cmdConsistency(_ []string) {
|
|
r := audit.RunConsistency()
|
|
printSummary("Method B — Component Self-Consistency", map[string]int{
|
|
"total_components": r.TotalComponents,
|
|
"consistent": r.Consistent,
|
|
"incomplete": r.Incomplete,
|
|
})
|
|
if len(r.IncompleteComponents) > 0 {
|
|
fmt.Println("\n## Components missing tags for declared hazard categories:\n")
|
|
for _, c := range r.IncompleteComponents {
|
|
fmt.Printf("- %s (%s)\n", c.ComponentID, c.NameDE)
|
|
for _, miss := range c.MissingForCategories {
|
|
fmt.Printf(" %s: no pattern fires (suggest tags: %s)\n", miss.Category, joinFirst(miss.SuggestedTags, 5))
|
|
}
|
|
}
|
|
}
|
|
writeJSON("audit-reports/consistency.json", r)
|
|
}
|
|
|
|
func cmdVocabulary(args []string) {
|
|
if len(args) < 1 {
|
|
fmt.Fprintln(os.Stderr, "vocabulary: missing path to limits-form JSON")
|
|
os.Exit(2)
|
|
}
|
|
data, err := os.ReadFile(args[0])
|
|
must(err)
|
|
var form map[string]any
|
|
must(json.Unmarshal(data, &form))
|
|
r := audit.RunVocabulary(form)
|
|
printSummary("Method C — Vocabulary Diff", map[string]int{
|
|
"unique_tokens": r.UniqueTokens,
|
|
"unknown_tokens": len(r.UnknownTokens),
|
|
"unknown_with_pattern_hit": len(r.SuggestedDictionaryEntries),
|
|
})
|
|
if len(r.SuggestedDictionaryEntries) > 0 {
|
|
fmt.Println("\n## Suggested dictionary additions (token appears in pattern scenarios but not in dict):\n")
|
|
for _, s := range r.SuggestedDictionaryEntries {
|
|
fmt.Printf("- '%s' → seen in %d patterns. Examples: %s\n", s.Token, len(s.PatternIDs), joinFirst(s.PatternIDs, 5))
|
|
}
|
|
}
|
|
writeJSON("audit-reports/vocabulary.json", r)
|
|
}
|
|
|
|
func cmdEcho(args []string) {
|
|
if len(args) < 2 {
|
|
fmt.Fprintln(os.Stderr, "echo: usage: iace-audit echo <limits-form.json> <hazards.json>")
|
|
os.Exit(2)
|
|
}
|
|
limitsData, err := os.ReadFile(args[0])
|
|
must(err)
|
|
hazardsData, err := os.ReadFile(args[1])
|
|
must(err)
|
|
var form map[string]any
|
|
must(json.Unmarshal(limitsData, &form))
|
|
var hwrap struct {
|
|
Hazards []map[string]any `json:"hazards"`
|
|
}
|
|
must(json.Unmarshal(hazardsData, &hwrap))
|
|
r := audit.RunEcho(form, hwrap.Hazards)
|
|
printSummary("Method D — Limits-Form Echo", map[string]int{
|
|
"total_phrases": r.TotalPhrases,
|
|
"echoed": r.Echoed,
|
|
"orphaned": r.Orphaned,
|
|
})
|
|
if len(r.OrphanedPhrases) > 0 {
|
|
fmt.Println("\n## Orphaned phrases (no hazard echoes them):\n")
|
|
for _, o := range r.OrphanedPhrases {
|
|
fmt.Printf("- [%s] %s\n", o.Field, truncate(o.Phrase, 120))
|
|
}
|
|
}
|
|
writeJSON("audit-reports/echo.json", r)
|
|
}
|
|
|
|
func cmdHierarchy(args []string) {
|
|
if len(args) < 2 {
|
|
fmt.Fprintln(os.Stderr, "hierarchy: usage: iace-audit hierarchy <hazards.json> <mitigations.json>")
|
|
os.Exit(2)
|
|
}
|
|
hData, err := os.ReadFile(args[0])
|
|
must(err)
|
|
mData, err := os.ReadFile(args[1])
|
|
must(err)
|
|
var hwrap struct {
|
|
Hazards []map[string]any `json:"hazards"`
|
|
}
|
|
must(json.Unmarshal(hData, &hwrap))
|
|
var mwrap struct {
|
|
Mitigations []map[string]any `json:"mitigations"`
|
|
}
|
|
must(json.Unmarshal(mData, &mwrap))
|
|
r := audit.RunHierarchy(hwrap.Hazards, mwrap.Mitigations)
|
|
printSummary("Method E — Hierarchy Completeness", map[string]int{
|
|
"total_hazards": r.TotalHazards,
|
|
"complete": r.Complete,
|
|
"missing_design": r.MissingDesign,
|
|
"missing_protection": r.MissingProtection,
|
|
"missing_info": r.MissingInfo,
|
|
})
|
|
if len(r.IncompleteHazards) > 0 {
|
|
fmt.Println("\n## Hazards with incomplete hierarchy:\n")
|
|
for _, h := range r.IncompleteHazards {
|
|
fmt.Printf("- [%s] %s — missing: %s\n", h.Category, truncate(h.Name, 70), joinFirst(h.MissingLevels, 3))
|
|
}
|
|
}
|
|
writeJSON("audit-reports/hierarchy.json", r)
|
|
}
|
|
|
|
func printSummary(title string, kv map[string]int) {
|
|
fmt.Println("=", title, "=")
|
|
for k, v := range kv {
|
|
fmt.Printf(" %-22s %d\n", k, v)
|
|
}
|
|
}
|
|
|
|
func printPatternRows(rows []audit.ReachabilityResult, max int) {
|
|
if max > len(rows) {
|
|
max = len(rows)
|
|
}
|
|
for i := 0; i < max; i++ {
|
|
r := rows[i]
|
|
fmt.Printf("- %s (P%d) %s\n", r.PatternID, r.Priority, truncate(r.Name, 60))
|
|
if len(r.UnreachableTags) > 0 {
|
|
fmt.Printf(" missing tags: %s\n", joinFirst(r.UnreachableTags, 8))
|
|
}
|
|
for _, s := range r.FixSuggestions {
|
|
fmt.Printf(" fix: %s\n", s)
|
|
}
|
|
}
|
|
}
|
|
|
|
func writeJSON(path string, v any) {
|
|
_ = os.MkdirAll("audit-reports", 0o755)
|
|
f, err := os.Create(path)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "warn: could not write report:", err)
|
|
return
|
|
}
|
|
defer f.Close()
|
|
enc := json.NewEncoder(f)
|
|
enc.SetIndent("", " ")
|
|
_ = enc.Encode(v)
|
|
fmt.Println("→ wrote", path)
|
|
}
|
|
|
|
func must(err error) {
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func truncate(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n] + "…"
|
|
}
|
|
|
|
func joinFirst(list []string, n int) string {
|
|
if len(list) <= n {
|
|
return join(list)
|
|
}
|
|
return join(list[:n]) + ", …"
|
|
}
|
|
|
|
func join(list []string) string {
|
|
out := ""
|
|
for i, s := range list {
|
|
if i > 0 {
|
|
out += ", "
|
|
}
|
|
out += s
|
|
}
|
|
return out
|
|
}
|