diff --git a/ai-compliance-sdk/internal/iace/gt_risk_benchmark_test.go b/ai-compliance-sdk/internal/iace/gt_risk_benchmark_test.go index e4c7748b..5fa09828 100644 --- a/ai-compliance-sdk/internal/iace/gt_risk_benchmark_test.go +++ b/ai-compliance-sdk/internal/iace/gt_risk_benchmark_test.go @@ -103,6 +103,7 @@ type riskAgg struct { noAvoidDefault int engineRisk []float64 newEngineRisk []float64 + fkRisk []float64 gtRisk []float64 matched int noParam int @@ -243,11 +244,15 @@ func TestGT_RiskBenchmark(t *testing.T) { // NEW = de-biased severity scaled by summed likelihood incl. W + P. oldProxy := float64(maxInt(rp.s, 1) * maxInt(rp.f, 1) * maxInt(rp.a, 1)) newProxy := float64(maxInt(estS, 1) * (maxInt(rp.f, 1) + estW + estP)) + // Fine-Kinney score (our citable backbone) for rank comparison. + fk := SuggestFineKinney(rp.cats, rp.scenario, pr.LifecyclePhases, rp.s) local.engineRisk = append(local.engineRisk, oldProxy) local.newEngineRisk = append(local.newEngineRisk, newProxy) + local.fkRisk = append(local.fkRisk, fk.Score) local.gtRisk = append(local.gtRisk, float64(gtR.R)) overall.engineRisk = append(overall.engineRisk, oldProxy) overall.newEngineRisk = append(overall.newEngineRisk, newProxy) + overall.fkRisk = append(overall.fkRisk, fk.Score) overall.gtRisk = append(overall.gtRisk, float64(gtR.R)) } @@ -260,7 +265,8 @@ func TestGT_RiskBenchmark(t *testing.T) { 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) + fkConc, _ := kendallConcordance(local.fkRisk, local.gtRisk) + t.Logf(" Risk RANK concordance: OLD %.1f%% -> NEW %.1f%% | Fine-Kinney %.1f%% (over %d pairs)", oldConc*100, newConc*100, fkConc*100, pairs) } oldConc, _ := kendallConcordance(overall.engineRisk, overall.gtRisk) @@ -271,5 +277,6 @@ func TestGT_RiskBenchmark(t *testing.T) { 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) + fkConc, _ := kendallConcordance(overall.fkRisk, overall.gtRisk) + t.Logf(" Risk RANK concordance: OLD %.1f%% -> NEW %.1f%% | Fine-Kinney %.1f%% (%d pairs)", oldConc*100, newConc*100, fkConc*100, pairs) } diff --git a/ai-compliance-sdk/internal/iace/risk_fine_kinney.go b/ai-compliance-sdk/internal/iace/risk_fine_kinney.go new file mode 100644 index 00000000..a5083530 --- /dev/null +++ b/ai-compliance-sdk/internal/iace/risk_fine_kinney.go @@ -0,0 +1,140 @@ +package iace + +import ( + "fmt" + "strings" +) + +// Fine-Kinney risk model — BreakPilot's deterministic, citable risk backbone. +// +// Method: W.T. Fine, "Mathematical Evaluations for Controlling Hazards" +// (1971, US Naval Ordnance Laboratory report) and G.F. Kinney & A.D. Wiruth, +// "Practical Risk Analysis for Safety Management" (1976). Risk = P x E x C. +// It is a PUBLISHED, freely-usable method — not a copyrighted DIN/Beuth/ISO +// standard — and is widely used in industry (incl. CE-marking risk analysis). +// +// We derive SUGGESTED P/E/C values deterministically from PUBLIC, permissively +// licensed sources (Eurostat ESAW frequencies, NIOSH/OSHA/MIL-STD-882 injury +// outcomes — see DATA_SOURCES.md), each with a plain-language justification. +// The professional then ADJUSTS them (e.g. from his own licensed DIN/Beuth +// data) — the tool only supplies the formula and computes. We never reproduce +// norm tables. + +// FKParam is a suggested Fine-Kinney parameter value plus why we chose it. +type FKParam struct { + Value float64 `json:"value"` + Justification string `json:"justification"` +} + +// FKAssessment is a full suggested Fine-Kinney assessment for one hazard. +type FKAssessment struct { + Probability FKParam `json:"probability"` // P: 0.1 .. 10 + Exposure FKParam `json:"exposure"` // E: 0.5 .. 10 + Consequence FKParam `json:"consequence"` // C: 1 .. 100 + Score float64 `json:"score"` // R = P * E * C + Band string `json:"band"` // Fine-Kinney risk band label + Action string `json:"action"` // suggested urgency of action +} + +// fkProbabilityByMode maps a contact mode to a Fine-Kinney probability value, +// anchored to the ESAW relative frequency of that injury mechanism. +var fkProbabilityByMode = map[string]float64{ + "impact_stationary": 6, "crushing": 6, "struck_by": 6, "ergonomic": 6, + "cutting": 3, "entanglement": 3, "shearing": 3, "electrical": 3, + "thermal": 3, "fall": 3, + "chemical": 1, "pressure_burst": 1, "radiation": 0.5, +} + +// fkConsequenceFromSeverity maps our pattern-specific, de-biased severity (1-5) +// onto the published Fine-Kinney consequence scale. Using the per-hazard +// severity (not a coarse mode constant) preserves the ranking signal. +// 1=first aid, 3=disability, 7=serious injury, 15=a fatality, 40=multiple. +func fkConsequenceFromSeverity(s int) float64 { + switch { + case s >= 5: + return 40 + case s == 4: + return 15 + case s == 3: + return 7 + case s == 2: + return 3 + default: + return 1 + } +} + +// SuggestFineKinney builds a justified Fine-Kinney assessment from public-data +// anchors. Inputs are the hazard's categories, scenario text and the project's +// lifecycle phases. Values are SUGGESTIONS the professional adjusts. +func SuggestFineKinney(cats []string, scenario string, lifecyclePhases []string, defaultSeverity int) FKAssessment { + mode := DetectContactMode(cats, scenario) + + p := 3.0 + if v, ok := fkProbabilityByMode[mode]; ok { + p = v + } + s := EstimateSeverity(cats, scenario, defaultSeverity) + c := fkConsequenceFromSeverity(s) + e := fkExposure(lifecyclePhases) + + modeLabel := mode + if modeLabel == "" { + modeLabel = "unbestimmt" + } + a := FKAssessment{ + Probability: FKParam{p, "Eintrittswahrscheinlichkeit aus ESAW-Haeufigkeit der Kontaktart '" + modeLabel + "'"}, + Exposure: FKParam{e.value, e.reason}, + Consequence: FKParam{c, fmt.Sprintf("Konsequenz aus Schwere-Einstufung S%d (NIOSH/OSHA/MIL-STD-882-Verletzungsbild)", s)}, + } + a.Score, a.Band, a.Action = ComputeFineKinney(p, e.value, c) + return a +} + +type fkExp struct { + value float64 + reason string +} + +// fkExposure maps the active lifecycle phases to a Fine-Kinney exposure value +// (how often a person is exposed to the task). +func fkExposure(phases []string) fkExp { + has := func(needle string) bool { + for _, p := range phases { + if strings.Contains(p, needle) { + return true + } + } + return false + } + switch { + case has("normal_operation") || has("auto_operation") || has("manual_operation"): + return fkExp{6, "Exposition: Normalbetrieb (taeglich/dauernd)"} + case has("setup") || has("maintenance") || has("cleaning") || has("changeover"): + return fkExp{3, "Exposition: Einricht-/Wartungs-/Reinigungstaetigkeit (gelegentlich)"} + case len(phases) > 0: + return fkExp{1, "Exposition: seltene Lebensphase (wenige Male pro Jahr)"} + default: + return fkExp{3, "Exposition: angenommen gelegentlich (keine Lebensphase angegeben)"} + } +} + +// ComputeFineKinney returns the Fine-Kinney risk score (P*E*C) and the +// published risk band + suggested action urgency. The professional may pass +// his own adjusted P/E/C here (e.g. derived from his licensed DIN/Beuth data) — +// the tool only computes; it stores no norm table. +func ComputeFineKinney(p, e, c float64) (score float64, band, action string) { + score = p * e * c + switch { + case score > 400: + return score, "sehr hoch", "Taetigkeit einstellen / sofortige Massnahmen" + case score > 200: + return score, "hoch", "sofortige Sanierung erforderlich" + case score > 70: + return score, "wesentlich", "Sanierung erforderlich" + case score > 20: + return score, "moeglich", "Aufmerksamkeit, Massnahmen planen" + default: + return score, "gering", "ggf. akzeptabel, beobachten" + } +}