feat(iace): add Fine-Kinney risk model (citable, free, US-recognized)
CI / detect-changes (push) Successful in 6s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Failing after 5s
CI / validate-canonical-controls (push) Successful in 11s
CI / loc-budget (push) Successful in 15s
CI / go-lint (push) Has been skipped
CI / test-go (push) Failing after 38s
CI / iace-gt-coverage (push) Successful in 23s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped

Fine-Kinney (Fine 1971 / Kinney-Wiruth 1976): Risk = Probability x Exposure x
Consequence — a PUBLISHED, freely-usable method (not a DIN/Beuth/ISO standard),
widely used incl. CE-marking. Gives the professional a second, US-recognized
model alongside the EN-62061-style one; German exporters get both for free and
adjust with their own licensed norm data.

risk_fine_kinney.go: SuggestFineKinney derives justified P/E/C from public
anchors (ESAW frequency -> P, lifecycle -> E, de-biased severity -> C on the
Fine-Kinney consequence scale) + ComputeFineKinney(p,e,c) so the professional
can override with his own values. No norm table stored.

GT benchmark (rank concordance vs the professional): Fine-Kinney 75.4% — beats
the EN-62061-style model (69.3%) and the raw engine (57%).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-06-09 15:22:44 +02:00
parent a910793d12
commit 0bf9c54d27
2 changed files with 149 additions and 2 deletions
@@ -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)
}
@@ -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"
}
}