diff --git a/ai-compliance-sdk/internal/api/handlers/iace_handler_init.go b/ai-compliance-sdk/internal/api/handlers/iace_handler_init.go index c84442b5..485859d3 100644 --- a/ai-compliance-sdk/internal/api/handlers/iace_handler_init.go +++ b/ai-compliance-sdk/internal/api/handlers/iace_handler_init.go @@ -219,26 +219,20 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) { // scenario itself. Only the aggregated norm-references // block is appended below for an at-a-glance audit trail. desc := mp.ScenarioDE - // Phase 17: PLr per EN ISO 13849-1 Anhang A. The graph - // inputs come from the pattern's DefaultSeverity/Exposure - // (mapped to S1/S2 and F1/F2 at threshold 3) plus - // DefaultAvoidability (P1/P2). If avoidability is unset - // we default to P1 — the conservative direction is - // downward (lower PLr), the operator can raise it - // manually after expert review. - avoid := 1 - if mp.DefaultAvoidability == 2 { - avoid = 2 - } + // BreakPilot's OWN risk model (NOT a norm reproduction): + // severity + frequency from the pattern defaults; probability + // (W) and avoidance (P) from public accident-statistics anchors + // (see iace/risk_estimation.go + DATA_SOURCES.md). No EN ISO + // 13849-1 risk-graph table or parameter binning is reproduced. if mp.DefaultSeverity > 0 && mp.DefaultExposure > 0 { - sBin := iace.SeverityToS(mp.DefaultSeverity) - fBin := iace.ExposureToF(mp.DefaultExposure) - plr := iace.ComputePLr(sBin, fBin, avoid) - desc += fmt.Sprintf("\n\nRisikograph EN ISO 13849-1 (Anhang A): S%d · F%d · P%d → PLr %s", - sBin, fBin, avoid, plr) + w := iace.EstimateProbabilityW(mp.HazardCats, mp.ScenarioDE) + p := iace.EstimateAvoidabilityP(mp.HazardCats, mp.ScenarioDE) + _, level := iace.EstimateRiskLevel(mp.DefaultSeverity, mp.DefaultExposure, w, p) + desc += fmt.Sprintf("\n\nRisikoeinschaetzung (BreakPilot-Modell): S%d · F%d · W%d · P%d → Risiko: %s", + mp.DefaultSeverity, mp.DefaultExposure, w, p, level) } if mp.ISO12100Section != "" { - desc += "\n\nKlassifikation: EN ISO 12100 Anhang B, Abschnitt " + mp.ISO12100Section + desc += "\n\nKlassifikation: EN ISO 12100 Abschnitt " + mp.ISO12100Section } hz, cerr := h.store.CreateHazard(ctx, iace.CreateHazardRequest{ diff --git a/ai-compliance-sdk/internal/iace/DATA_SOURCES.md b/ai-compliance-sdk/internal/iace/DATA_SOURCES.md new file mode 100644 index 00000000..0b77c0d9 --- /dev/null +++ b/ai-compliance-sdk/internal/iace/DATA_SOURCES.md @@ -0,0 +1,54 @@ +# Risk-estimation data sources & licenses + +Provenance for the probability (W) / avoidance (P) tiers in `risk_estimation.go` +(`contactModeTable`). We do **not** vendor any raw dataset — only the small +aggregate facts used as anchors plus our own calibrated tiers live in code. + +## What we use and how + +The tiers are derived in two steps: + +1. **Anchor** — the *relative ordering* of injury contact modes from public, + permissively-licensed occupational-accident statistics (which mechanisms are + more vs. less frequent). +2. **Calibrate** — adjust the tier *values* to our own ground-truth corpus + (the professional's W/P per mode). Well-sampled modes are set to the GT mean; + sparse modes use conservative defaults (no overfitting to a 2-GT sample). + +The numbers in code are therefore **ours**, not a copy of any dataset, and they +do **not** reproduce any standard's risk-graph table, decision tree or matrix. + +## Primary source — Eurostat ESAW + +- **Dataset:** European Statistics on Accidents at Work (ESAW), contact mode of injury. +- **License:** **CC BY 4.0** — commercial and non-commercial reuse permitted, + source acknowledgement required. +- **Attribution string:** `Source: Eurostat (ESAW), CC BY 4.0` — surface this in + any generated risk-assessment export that shows engine risk numbers. +- **URL:** https://ec.europa.eu/eurostat/statistics-explained/index.php/Accidents_at_work_-_statistics_on_causes_and_circumstances +- **Aggregate facts used (anchor only):** contact-mode shares of accidents at + work, e.g. impact with stationary object ~24%, struck by moving object ~13% + (non-fatal) / ~24% (fatal), trapped/crushed ~14% (fatal), contact with sharp + agent ~15%. Retrieved 2026-06. + +## Acceptable supplements + +- **US BLS / OSHA** (Bureau of Labor Statistics, occupational injuries) — **U.S. + Government work, public domain**; free for any use. +- **UK HSE** (RIDDOR / kinds-of-accident) — **Open Government Licence v3**; + commercial reuse with attribution. + +## Explicitly excluded + +- **DGUV statistics** — terms grant only editorial use and forbid modification + / re-licensing; **unsuitable for a commercial product**. Not used. +- **DIN / Beuth / ISO / IEC standards** (e.g. risk-graph tables, parameter + decision trees, SIL/PL matrices) — copyrighted; **not reproduced or + re-implemented**. Our model uses only the universal, non-protectable risk + *dimensions* (severity, frequency, probability, avoidance). + +## Maintenance + +When a tier in `contactModeTable` changes, record the source figure and the GT +calibration basis here. Add this file to the repository SBOM / license register +alongside software dependencies. diff --git a/ai-compliance-sdk/internal/iace/gt_risk_benchmark_test.go b/ai-compliance-sdk/internal/iace/gt_risk_benchmark_test.go new file mode 100644 index 00000000..bfbe33cb --- /dev/null +++ b/ai-compliance-sdk/internal/iace/gt_risk_benchmark_test.go @@ -0,0 +1,265 @@ +package iace + +import ( + "math" + "testing" +) + +// ============================================================================ +// Risk benchmark: engine risk parameters vs. the professional's (Fachmann) GT. +// +// The risk numbers have never been validated. This test measures — for the +// first time — how far the engine's per-pattern risk defaults are from the +// professional's EN-62061-style assessment in the ground truth, for every +// matched hazard across both GTs. +// +// COPYRIGHT NOTE: this test only COMPARES numbers (our defaults vs the GT's +// values) and computes agreement statistics. It does NOT reproduce any DIN/ +// Beuth/ISO risk-graph table, parameter decision tree, or normative formula. +// The GT values are the professional's assessment of a specific machine, not +// the standard's text. Any future estimator must likewise derive parameters +// from OUR own model + PUBLIC accident data (ESAW/DGUV), never from a +// transcribed norm table. +// +// Parameter mapping (engine default -> GT column, EN-62061 naming): +// +// DefaultSeverity <-> GT.S (Se, severity) +// DefaultExposure <-> GT.F (Fr, frequency / duration of exposure) +// DefaultAvoidability <-> GT.P (Av, possibility of avoidance) +// (none) <-> GT.W (Pr, probability of occurrence) <-- the gap +// +// Run with: +// +// go test -v -vet=off -run TestGT_RiskBenchmark ./internal/iace/ +// ============================================================================ + +type riskParams struct { + s, f, a int // severity, frequency/exposure, avoidability (engine defaults) + cats []string + scenario string +} + +type axisStats struct { + n int + absErrSum float64 + exact int + within1 int +} + +func (a *axisStats) add(engine, gt int) { + a.n++ + d := math.Abs(float64(engine - gt)) + a.absErrSum += d + if d == 0 { + a.exact++ + } + if d <= 1 { + a.within1++ + } +} + +func (a axisStats) mae() float64 { + if a.n == 0 { + return 0 + } + return a.absErrSum / float64(a.n) +} +func (a axisStats) pct(x int) float64 { + if a.n == 0 { + return 0 + } + return 100 * float64(x) / float64(a.n) +} + +// kendallConcordance returns the fraction of comparable hazard pairs that the +// engine orders the same way the professional does (rank agreement, scale- +// invariant). 1.0 = identical ordering, 0.5 = random, 0.0 = inverted. +func kendallConcordance(engine, gt []float64) (float64, int) { + concordant, discordant := 0, 0 + for i := 0; i < len(engine); i++ { + for j := i + 1; j < len(engine); j++ { + de := engine[i] - engine[j] + dg := gt[i] - gt[j] + if de == 0 || dg == 0 { + continue // tie on one side: not comparable + } + if (de > 0) == (dg > 0) { + concordant++ + } else { + discordant++ + } + } + } + total := concordant + discordant + if total == 0 { + return 0, 0 + } + return float64(concordant) / float64(total), total +} + +type riskAgg struct { + sev, freq, avoid axisStats + wEst, pEst axisStats + noAvoidDefault int + engineRisk []float64 + newEngineRisk []float64 + gtRisk []float64 + matched int + noParam int +} + +// TestGT_RiskCalibrationData logs, per contact mode, the professional's mean +// W and P vs our current estimate — the input for calibrating contactModeTable. +func TestGT_RiskCalibrationData(t *testing.T) { + type acc struct { + n int + sumGTW, sumGTP int + estW, estP int + } + byMode := map[string]*acc{} + + for _, c := range gtBenchmarkCases { + gtData, narrative, _ := readGTNarrative(t, c.path) + if c.narrativeOverride != "" { + narrative = c.narrativeOverride + } + pr := ParseNarrative(narrative, c.machineType) + out := NewPatternEngine().Match(parseResultToMatchInput(pr, c.machineType)) + byName := map[string]riskParams{} + for _, pm := range out.MatchedPatterns { + key := normalizeDE(pm.ScenarioDE) + if key == "" { + key = normalizeDE(pm.PatternName) + } + byName[key] = riskParams{cats: pm.HazardCats, scenario: pm.ScenarioDE} + } + hazards, mitigations := patternsToHazardsAndMitigations(out) + res := CompareBenchmark(>Data, hazards, mitigations) + for _, mp := range res.MatchedPairs { + rp, ok := byName[normalizeDE(mp.EngineHazard.Name)] + if !ok { + continue + } + mode := DetectContactMode(rp.cats, rp.scenario) + if mode == "" { + mode = "(none)" + } + a := byMode[mode] + if a == nil { + a = &acc{estW: EstimateProbabilityW(rp.cats, rp.scenario), estP: EstimateAvoidabilityP(rp.cats, rp.scenario)} + byMode[mode] = a + } + a.n++ + a.sumGTW += mp.GTEntry.RiskIn.W + a.sumGTP += mp.GTEntry.RiskIn.P + } + } + + t.Logf("=== Per-contact-mode calibration data (GT mean vs our tier) ===") + t.Logf(" %-18s %4s | %7s %7s | %7s %7s", "mode", "n", "estW", "gtW̄", "estP", "gtP̄") + for mode, a := range byMode { + t.Logf(" %-18s %4d | %7d %7.1f | %7d %7.1f", + mode, a.n, a.estW, float64(a.sumGTW)/float64(a.n), a.estP, float64(a.sumGTP)/float64(a.n)) + } +} + +func TestGT_RiskBenchmark(t *testing.T) { + overall := riskAgg{} + + for _, c := range gtBenchmarkCases { + gtData, narrative, _ := readGTNarrative(t, c.path) + if c.narrativeOverride != "" { + narrative = c.narrativeOverride + } + pr := ParseNarrative(narrative, c.machineType) + out := NewPatternEngine().Match(parseResultToMatchInput(pr, c.machineType)) + + // Index engine risk params by the hazard name the matcher will see + // (patternsToHazardsAndMitigations sets Hazard.Name = ScenarioDE, else PatternName). + byName := map[string]riskParams{} + for _, pm := range out.MatchedPatterns { + key := normalizeDE(pm.ScenarioDE) + if key == "" { + key = normalizeDE(pm.PatternName) + } + byName[key] = riskParams{s: pm.DefaultSeverity, f: pm.DefaultExposure, a: pm.DefaultAvoidability, cats: pm.HazardCats, scenario: pm.ScenarioDE} + } + + hazards, mitigations := patternsToHazardsAndMitigations(out) + res := CompareBenchmark(>Data, hazards, mitigations) + + local := riskAgg{} + for _, mp := range res.MatchedPairs { + rp, ok := byName[normalizeDE(mp.EngineHazard.Name)] + if !ok { + local.noParam++ + overall.noParam++ + continue + } + gtR := mp.GTEntry.RiskIn + local.matched++ + overall.matched++ + if rp.s > 0 && gtR.S > 0 { + local.sev.add(rp.s, gtR.S) + overall.sev.add(rp.s, gtR.S) + } + if rp.f > 0 && gtR.F > 0 { + local.freq.add(rp.f, gtR.F) + overall.freq.add(rp.f, gtR.F) + } + if rp.a > 0 && gtR.P > 0 { + local.avoid.add(rp.a, gtR.P) + overall.avoid.add(rp.a, gtR.P) + } + if rp.a == 0 { + local.noAvoidDefault++ + overall.noAvoidDefault++ + } + + // NEW: data-anchored estimates for the two missing axes. + estW := EstimateProbabilityW(rp.cats, rp.scenario) + estP := EstimateAvoidabilityP(rp.cats, rp.scenario) + if gtR.W > 0 { + local.wEst.add(estW, gtR.W) + overall.wEst.add(estW, gtR.W) + } + if gtR.P > 0 { + local.pEst.add(estP, gtR.P) + overall.pEst.add(estP, gtR.P) + } + + // Two risk proxies for RANK comparison (our own aggregates, NOT a + // norm formula): OLD = today's engine (severity x exposure, with + // avoidability mostly unset); NEW = severity scaled by summed + // likelihood factors incl. the estimated W and P. + sev := maxInt(rp.s, 1) + oldProxy := float64(sev * maxInt(rp.f, 1) * maxInt(rp.a, 1)) + newProxy := float64(sev * (maxInt(rp.f, 1) + estW + estP)) + local.engineRisk = append(local.engineRisk, oldProxy) + local.newEngineRisk = append(local.newEngineRisk, newProxy) + local.gtRisk = append(local.gtRisk, float64(gtR.R)) + overall.engineRisk = append(overall.engineRisk, oldProxy) + overall.newEngineRisk = append(overall.newEngineRisk, newProxy) + overall.gtRisk = append(overall.gtRisk, float64(gtR.R)) + } + + oldConc, _ := kendallConcordance(local.engineRisk, local.gtRisk) + newConc, pairs := kendallConcordance(local.newEngineRisk, local.gtRisk) + t.Logf("=== %s — Risk benchmark ===", c.name) + t.Logf(" Matched hazards w/ engine params: %d (%d pairs had no pattern param)", local.matched, local.noParam) + t.Logf(" Severity S: MAE %.2f | within±1 %.0f%% | exact %.0f%% (n=%d)", local.sev.mae(), local.sev.pct(local.sev.within1), local.sev.pct(local.sev.exact), local.sev.n) + t.Logf(" Frequency F: MAE %.2f | within±1 %.0f%% | exact %.0f%% (n=%d)", local.freq.mae(), local.freq.pct(local.freq.within1), local.freq.pct(local.freq.exact), local.freq.n) + t.Logf(" Probability W (NEW estimate): MAE %.2f | within±1 %.0f%% | exact %.0f%% (n=%d)", local.wEst.mae(), local.wEst.pct(local.wEst.within1), local.wEst.pct(local.wEst.exact), local.wEst.n) + t.Logf(" Avoidance P (NEW estimate): MAE %.2f | within±1 %.0f%% | exact %.0f%% (n=%d)", local.pEst.mae(), local.pEst.pct(local.pEst.within1), local.pEst.pct(local.pEst.exact), local.pEst.n) + t.Logf(" Risk RANK concordance: OLD %.1f%% -> NEW %.1f%% (over %d comparable pairs)", oldConc*100, newConc*100, pairs) + } + + oldConc, _ := kendallConcordance(overall.engineRisk, overall.gtRisk) + newConc, pairs := kendallConcordance(overall.newEngineRisk, overall.gtRisk) + t.Logf("\n=== Cross-GT aggregate ===") + t.Logf(" Severity S: MAE %.2f | within±1 %.0f%% | exact %.0f%% (n=%d)", overall.sev.mae(), overall.sev.pct(overall.sev.within1), overall.sev.pct(overall.sev.exact), overall.sev.n) + t.Logf(" Frequency F: MAE %.2f | within±1 %.0f%% | exact %.0f%% (n=%d)", overall.freq.mae(), overall.freq.pct(overall.freq.within1), overall.freq.pct(overall.freq.exact), overall.freq.n) + t.Logf(" Probability W (NEW): MAE %.2f | within±1 %.0f%% | exact %.0f%% (n=%d)", overall.wEst.mae(), overall.wEst.pct(overall.wEst.within1), overall.wEst.pct(overall.wEst.exact), overall.wEst.n) + t.Logf(" Avoidance P (NEW): MAE %.2f | within±1 %.0f%% | exact %.0f%% (n=%d)", overall.pEst.mae(), overall.pEst.pct(overall.pEst.within1), overall.pEst.pct(overall.pEst.exact), overall.pEst.n) + t.Logf(" Risk RANK concordance: OLD %.1f%% -> NEW %.1f%% (%d pairs)", oldConc*100, newConc*100, pairs) +} diff --git a/ai-compliance-sdk/internal/iace/hazard_pattern_types.go b/ai-compliance-sdk/internal/iace/hazard_pattern_types.go index 0432bd0b..624862ad 100644 --- a/ai-compliance-sdk/internal/iace/hazard_pattern_types.go +++ b/ai-compliance-sdk/internal/iace/hazard_pattern_types.go @@ -76,12 +76,9 @@ type HazardPattern struct { // keep the library urheberrechtlich neutral (DIN/Beuth license). // The frontend renders it as "EN ISO 12100 Abschnitt 6.3.5.5". ISO12100Section string `json:"iso_12100_section,omitempty"` - // DefaultAvoidability is the P parameter of the EN ISO 13849-1 - // risk graph (Annex A): 1 = avoidable under certain conditions, 2 = - // hardly avoidable. Combined with DefaultSeverity (S1/S2 derived - // at threshold 3) and DefaultExposure (F1/F2 at threshold 3) it - // feeds into the PLr (required Performance Level) computation, - // see ComputePLr. + // DefaultAvoidability is our avoidance parameter: 1 = avoidable under + // certain conditions, 2 = hardly avoidable. Feeds BreakPilot's own risk + // model (risk_estimation.go) — NOT a reproduced norm risk graph. DefaultAvoidability int `json:"default_avoidability,omitempty"` // 1 or 2 // SecondaryHarms describes consequential damage chains beyond the // classical IACE Hazard→Harm step: end-customer safety, product @@ -91,45 +88,6 @@ type HazardPattern struct { SecondaryHarms []SecondaryHarm `json:"secondary_harms,omitempty"` } -// ComputePLr returns the required Performance Level (PLr) per EN ISO -// 13849-1 Anhang A (Risikograph). Inputs are the three parameters of -// the graph in their 1/2 form: -// - s (Schwere): 1 = leicht/reversibel, 2 = schwer/irreversibel inkl. Tod -// - f (Haeufigkeit/Dauer): 1 = selten/kurz, 2 = haeufig/dauernd -// - p (Moeglichkeit Vermeidung): 1 = unter Bedingungen moeglich, 2 = kaum -// Return value is one of "a", "b", "c", "d", "e" (PLa..PLe). -// -// The mapping follows the canonical 8-leaf binary tree of the standard: -// S1 F1 P1 -> a -// S1 F1 P2 -> b -// S1 F2 P1 -> b -// S1 F2 P2 -> c -// S2 F1 P1 -> c -// S2 F1 P2 -> d -// S2 F2 P1 -> d -// S2 F2 P2 -> e -func ComputePLr(s, f, p int) string { - idx := 0 - if s == 2 { idx += 4 } - if f == 2 { idx += 2 } - if p == 2 { idx += 1 } - return []string{"a", "b", "b", "c", "c", "d", "d", "e"}[idx] -} - -// SeverityToS maps a 1-5 DefaultSeverity to the binary S parameter of -// EN ISO 13849-1: 1-2 -> S1 (leicht/reversibel), 3-5 -> S2 (schwer/Tod). -func SeverityToS(severity int) int { - if severity >= 3 { return 2 } - return 1 -} - -// ExposureToF maps a 1-5 DefaultExposure to the binary F parameter of -// EN ISO 13849-1: 1-2 -> F1 (selten/kurz), 3-5 -> F2 (haeufig/dauernd). -func ExposureToF(exposure int) int { - if exposure >= 3 { return 2 } - return 1 -} - // Standard human roles for machinery interaction (ISO 12100 + BetrSichV). const ( RoleOperator = "operator" diff --git a/ai-compliance-sdk/internal/iace/risk_estimation.go b/ai-compliance-sdk/internal/iace/risk_estimation.go new file mode 100644 index 00000000..a9ead673 --- /dev/null +++ b/ai-compliance-sdk/internal/iace/risk_estimation.go @@ -0,0 +1,160 @@ +package iace + +import "strings" + +// Risk parameter estimation — probability of occurrence (W) and possibility of +// avoidance (P) — for auto-generated hazards. +// +// COPYRIGHT / IP NOTE: This is BreakPilot's OWN heuristic model. It does NOT +// reproduce, transcribe or re-implement any DIN/Beuth/ISO/IEC risk-graph table, +// parameter decision tree, threshold or matrix. It derives values on OUR OWN +// 1-5 scale from (a) PUBLIC, permissively-licensed occupational-accident +// statistics organised by contact mode — primarily Eurostat ESAW (CC BY 4.0, +// commercial reuse permitted with source attribution); US BLS/OSHA (public +// domain) and UK HSE (Open Government Licence) are acceptable supplements — +// and (b) observable machine facts the engine already extracts (hazard +// category, scenario kinematics). The scale and weights are ours and are +// calibrated against our own ground-truth corpus, not copied from a standard. +// NOTE: DGUV statistics are NOT used — their terms permit only editorial use +// and forbid modification, so they are unsuitable for a commercial product. +// Provenance, exact figures used and attribution: see DATA_SOURCES.md. +// +// The universal risk DIMENSIONS (severity, frequency, probability, avoidance) +// are general engineering concepts, not protectable expression. + +// contactMode is a coarse injury-mechanism class. ESAW/DGUV publish accident +// frequencies by such modes; we use that public ordering to anchor a relative +// probability tier, and the injury kinematics to anchor an avoidance tier. +type contactMode struct { + name string + // baseW: relative probability-of-occurrence tier (1-5). Anchored to the + // ESAW contact-mode frequency ranking (impact/struck-by/crush/cut are the + // most frequent; pressure-burst/radiation are rare). OUR calibrated scale. + baseW int + // baseP: avoidance-difficulty tier (1-5; higher = harder to avoid). + // Anchored to injury kinematics (sudden, no-warning events are hard to + // avoid; gradual exposure is easy). OUR reasoning, no norm table. + baseP int +} + +// contactModeTable — our tiers. Initially anchored to the public ESAW +// contact-mode frequency ranking, then CALIBRATED against our own ground-truth +// corpus (the professional's W/P distribution per mode). The well-sampled modes +// (crushing n=40, electrical n=20, struck_by n=14) are set to the GT means; +// sparsely-sampled modes (n<=4) use conservative defaults to avoid overfitting +// to noise from a 2-GT sample. This is the single place to tune; never +// hard-code per-machine values into patterns. See DATA_SOURCES.md for the +// public-data provenance and license. +var contactModeTable = map[string]contactMode{ + "impact_stationary": {"impact_stationary", 3, 1}, // seen coming -> easy to avoid + "struck_by": {"struck_by", 2, 3}, // GT-calibrated (n=14) + "crushing": {"crushing", 2, 3}, // GT-calibrated (n=40) + "cutting": {"cutting", 2, 3}, + "entanglement": {"entanglement", 3, 3}, + "shearing": {"shearing", 2, 3}, + "fall": {"fall", 3, 4}, // higher avoidance difficulty in GT + "electrical": {"electrical", 2, 3}, // GT-calibrated (n=20) + "thermal": {"thermal", 2, 2}, + "ergonomic": {"ergonomic", 2, 3}, + "chemical": {"chemical", 2, 3}, + "pressure_burst": {"pressure_burst", 2, 3}, + "radiation": {"radiation", 2, 3}, +} + +// contactModeKeywords maps umlaut-normalised scenario keywords to a contact +// mode. Order-independent; the first matching mode in detection order wins. +var contactModeKeywords = []struct { + mode string + keywords []string +}{ + {"crushing", []string{"quetsch", "einklemm", "eingeklemmt", "klemm", "zerquetsch"}}, + {"entanglement", []string{"einzug", "eingezogen", "erfasst", "aufwickel", "umwickel", "wickelt"}}, + {"shearing", []string{"scher"}}, + {"cutting", []string{"schneid", "schnitt", "scharfe kante", "abtrenn", "amputation", "stich"}}, + {"electrical", []string{"stromschlag", "spannungsfuehr", "koerperdurchstroem", "beruehrungsspannung", "lichtbogen", "elektrisch"}}, + {"thermal", []string{"verbrenn", "verbruehung", "heisse", "thermisch", "heisser"}}, + {"pressure_burst", []string{"bersten", "hochdruck", "ueberdruck", "druckbehaelter", "injektion"}}, + {"fall", []string{"sturz", "stuerz", "absturz", "ausrutsch", "stolper", "abstuerz"}}, + {"struck_by", []string{"weggeschleudert", "geschleudert", "geschoss", "herabfallen", "herabstuerz", "getroffen", "wegfliegen", "fallende last", "schlag"}}, + {"impact_stationary", []string{"anstossen", "anprall", "stossen gegen", "stoss gegen"}}, + {"ergonomic", []string{"belastung", "ergonom", "zwangshaltung", "manuelles heben", "ueberlastung"}}, + {"chemical", []string{"exposition", "gefahrstoff", "daempfe", "kontamination", "reizung", "aerosol", "vergiftung"}}, +} + +// categoryDefaultMode is the fallback contact mode per hazard category when the +// scenario text carries no specific kinematic keyword. +var categoryDefaultMode = map[string]string{ + "mechanical_hazard": "crushing", + "electrical_hazard": "electrical", + "thermal_hazard": "thermal", + "chemical_hazard": "chemical", + "material_environmental": "chemical", + "ergonomic": "ergonomic", + "noise_vibration": "ergonomic", + "radiation_hazard": "radiation", + "fire_explosion": "thermal", + "pneumatic_hydraulic": "pressure_burst", +} + +// DetectContactMode classifies a hazard's injury mechanism from its scenario +// text first, then its category. Returns the contact-mode key, or "" if none. +func DetectContactMode(cats []string, scenario string) string { + text := normalizeDE(scenario) + for _, e := range contactModeKeywords { + for _, kw := range e.keywords { + if strings.Contains(text, kw) { + return e.mode + } + } + } + for _, c := range cats { + if m, ok := categoryDefaultMode[c]; ok { + return m + } + } + return "" +} + +// EstimateProbabilityW returns the probability-of-occurrence tier (1-5) for a +// hazard, anchored to the public accident-frequency ranking of its contact +// mode. Returns 3 (neutral) when the mode is unknown. +func EstimateProbabilityW(cats []string, scenario string) int { + if m, ok := contactModeTable[DetectContactMode(cats, scenario)]; ok { + return m.baseW + } + return 3 +} + +// EstimateAvoidabilityP returns the avoidance-difficulty tier (1-5; higher = +// harder to avoid) from the contact mode's kinematics. Returns 3 when unknown. +func EstimateAvoidabilityP(cats []string, scenario string) int { + if m, ok := contactModeTable[DetectContactMode(cats, scenario)]; ok { + return m.baseP + } + return 3 +} + +// EstimateRiskLevel combines the four parameters into BreakPilot's OWN risk +// index and band. The index is a generic severity-weighted sum of the +// likelihood factors — index = S * (F + W + P) — i.e. basic arithmetic on the +// universal risk dimensions. It is NOT a reproduction of any standard's +// risk graph, parameter table or SIL/PL matrix. The bands are ours, tuned to +// our ground-truth corpus. Returns (index 3..75, German level label). +func EstimateRiskLevel(s, f, w, p int) (int, string) { + if s < 1 { + s = 1 + } + idx := s * (f + w + p) + switch { + case idx >= 45: + return idx, "kritisch" + case idx >= 30: + return idx, "hoch" + case idx >= 18: + return idx, "mittel" + case idx >= 9: + return idx, "gering" + default: + return idx, "vernachlaessigbar" + } +} diff --git a/ai-compliance-sdk/internal/iace/risk_graph_test.go b/ai-compliance-sdk/internal/iace/risk_graph_test.go deleted file mode 100644 index 77418b1a..00000000 --- a/ai-compliance-sdk/internal/iace/risk_graph_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package iace - -import "testing" - -// TestComputePLr_Canonical8 pins the 8 leaves of the EN ISO 13849-1 -// Annex A risk graph: S1/S2 x F1/F2 x P1/P2 -> a..e. -func TestComputePLr_Canonical8(t *testing.T) { - cases := []struct { - s, f, p int - want string - }{ - {1, 1, 1, "a"}, - {1, 1, 2, "b"}, - {1, 2, 1, "b"}, - {1, 2, 2, "c"}, - {2, 1, 1, "c"}, - {2, 1, 2, "d"}, - {2, 2, 1, "d"}, - {2, 2, 2, "e"}, // worst case: severe + frequent + hardly avoidable - } - for _, c := range cases { - got := ComputePLr(c.s, c.f, c.p) - if got != c.want { - t.Errorf("ComputePLr(S%d F%d P%d) = %q, want %q", c.s, c.f, c.p, got, c.want) - } - } -} - -// TestSeverityExposureMapping ensures the 1-5 internal fields map to the -// correct binary S/F parameter at the documented threshold (3). -func TestSeverityExposureMapping(t *testing.T) { - for sev, wantS := range map[int]int{1: 1, 2: 1, 3: 2, 4: 2, 5: 2} { - if got := SeverityToS(sev); got != wantS { - t.Errorf("SeverityToS(%d) = %d, want %d", sev, got, wantS) - } - } - for exp, wantF := range map[int]int{1: 1, 2: 1, 3: 2, 4: 2, 5: 2} { - if got := ExposureToF(exp); got != wantF { - t.Errorf("ExposureToF(%d) = %d, want %d", exp, got, wantF) - } - } -}