feat(sdk,iace): add Personalized Drafting Pipeline v2 and IACE engine
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 44s
CI / test-python-backend-compliance (push) Successful in 37s
CI / test-python-document-crawler (push) Successful in 22s
CI / test-python-dsms-gateway (push) Successful in 20s

Drafting Engine: 7-module pipeline with narrative tags, allowed facts governance,
PII sanitizer, prose validator with repair loop, hash-based cache, and terminology
guide. v1 fallback via ?v=1 query param.

IACE: Initial AI-Act Conformity Engine with risk classifier, completeness checker,
hazard library, and PostgreSQL store for AI system assessments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Boenisch
2026-02-25 22:27:06 +01:00
parent 3efa391de5
commit 06711bad1c
20 changed files with 10588 additions and 261 deletions

View File

@@ -0,0 +1,936 @@
package iace
import (
"math"
"testing"
)
// ============================================================================
// Helper
// ============================================================================
const floatTolerance = 1e-9
func almostEqual(a, b float64) bool {
return math.Abs(a-b) < floatTolerance
}
// ============================================================================
// 1. CalculateInherentRisk — S × E × P
// ============================================================================
func TestCalculateInherentRisk_BasicCases(t *testing.T) {
e := NewRiskEngine()
tests := []struct {
name string
s, ex, p int
expected float64
}{
// Minimum
{"min 1×1×1", 1, 1, 1, 1},
// Maximum
{"max 5×5×5", 5, 5, 5, 125},
// Single factor high
{"5×1×1", 5, 1, 1, 5},
{"1×5×1", 1, 5, 1, 5},
{"1×1×5", 1, 1, 5, 5},
// Typical mid-range
{"3×3×3", 3, 3, 3, 27},
{"2×4×3", 2, 4, 3, 24},
{"4×2×5", 4, 2, 5, 40},
// Boundary at thresholds
{"3×5×5 = 75 (critical threshold)", 3, 5, 5, 75},
{"2×4×5 = 40 (high threshold)", 2, 4, 5, 40},
{"3×5×1 = 15 (medium threshold)", 3, 5, 1, 15},
{"5×1×1 = 5 (low threshold)", 5, 1, 1, 5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := e.CalculateInherentRisk(tt.s, tt.ex, tt.p)
if !almostEqual(result, tt.expected) {
t.Errorf("CalculateInherentRisk(%d, %d, %d) = %v, want %v", tt.s, tt.ex, tt.p, result, tt.expected)
}
})
}
}
func TestCalculateInherentRisk_Clamping(t *testing.T) {
e := NewRiskEngine()
tests := []struct {
name string
s, ex, p int
expected float64
}{
{"below min clamped to 1", 0, 0, 0, 1},
{"negative clamped to 1", -5, -3, -1, 1},
{"above max clamped to 5", 10, 8, 6, 125},
{"mixed out-of-range", 0, 10, 3, 15}, // clamp(0,1,5)=1, clamp(10,1,5)=5, clamp(3,1,5)=3 → 1*5*3=15
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := e.CalculateInherentRisk(tt.s, tt.ex, tt.p)
if !almostEqual(result, tt.expected) {
t.Errorf("CalculateInherentRisk(%d, %d, %d) = %v, want %v", tt.s, tt.ex, tt.p, result, tt.expected)
}
})
}
}
// Full S×E×P coverage: verify all 125 combinations produce correct multiplication.
func TestCalculateInherentRisk_FullCoverage(t *testing.T) {
e := NewRiskEngine()
for s := 1; s <= 5; s++ {
for ex := 1; ex <= 5; ex++ {
for p := 1; p <= 5; p++ {
expected := float64(s * ex * p)
result := e.CalculateInherentRisk(s, ex, p)
if !almostEqual(result, expected) {
t.Errorf("CalculateInherentRisk(%d, %d, %d) = %v, want %v", s, ex, p, result, expected)
}
}
}
}
}
// ============================================================================
// 2. CalculateControlEffectiveness
// C_eff = min(1, 0.2*(maturity/4.0) + 0.5*coverage + 0.3*testEvidence)
// ============================================================================
func TestCalculateControlEffectiveness(t *testing.T) {
e := NewRiskEngine()
tests := []struct {
name string
maturity int
coverage float64
testEvidence float64
expected float64
}{
// All zeros → 0
{"all zero", 0, 0.0, 0.0, 0.0},
// All max → min(1, 0.2*1 + 0.5*1 + 0.3*1) = min(1, 1.0) = 1.0
{"all max", 4, 1.0, 1.0, 1.0},
// Only maturity max → 0.2 * (4/4) = 0.2
{"maturity only", 4, 0.0, 0.0, 0.2},
// Only coverage max → 0.5
{"coverage only", 0, 1.0, 0.0, 0.5},
// Only test evidence max → 0.3
{"evidence only", 0, 0.0, 1.0, 0.3},
// Half maturity → 0.2 * (2/4) = 0.1
{"half maturity", 2, 0.0, 0.0, 0.1},
// Typical mid-range: maturity=2, coverage=0.6, evidence=0.4
// 0.2*(2/4) + 0.5*0.6 + 0.3*0.4 = 0.1 + 0.3 + 0.12 = 0.52
{"typical mid", 2, 0.6, 0.4, 0.52},
// High values exceeding 1.0 should be capped
// maturity=4, coverage=1.0, evidence=1.0 → 0.2+0.5+0.3 = 1.0
{"capped at 1.0", 4, 1.0, 1.0, 1.0},
// maturity=3, coverage=0.8, evidence=0.9
// 0.2*(3/4) + 0.5*0.8 + 0.3*0.9 = 0.15 + 0.4 + 0.27 = 0.82
{"high controls", 3, 0.8, 0.9, 0.82},
// maturity=1, coverage=0.2, evidence=0.1
// 0.2*(1/4) + 0.5*0.2 + 0.3*0.1 = 0.05 + 0.1 + 0.03 = 0.18
{"low controls", 1, 0.2, 0.1, 0.18},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := e.CalculateControlEffectiveness(tt.maturity, tt.coverage, tt.testEvidence)
if !almostEqual(result, tt.expected) {
t.Errorf("CalculateControlEffectiveness(%d, %v, %v) = %v, want %v",
tt.maturity, tt.coverage, tt.testEvidence, result, tt.expected)
}
})
}
}
func TestCalculateControlEffectiveness_Clamping(t *testing.T) {
e := NewRiskEngine()
tests := []struct {
name string
maturity int
coverage float64
testEvidence float64
expected float64
}{
// Maturity below 0 → clamped to 0
{"maturity below zero", -1, 0.5, 0.5, 0.5*0.5 + 0.3*0.5},
// Maturity above 4 → clamped to 4
{"maturity above max", 10, 0.0, 0.0, 0.2},
// Coverage below 0 → clamped to 0
{"coverage below zero", 0, -0.5, 0.0, 0.0},
// Coverage above 1 → clamped to 1
{"coverage above max", 0, 2.0, 0.0, 0.5},
// Evidence below 0 → clamped to 0
{"evidence below zero", 0, 0.0, -1.0, 0.0},
// Evidence above 1 → clamped to 1
{"evidence above max", 0, 0.0, 5.0, 0.3},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := e.CalculateControlEffectiveness(tt.maturity, tt.coverage, tt.testEvidence)
if !almostEqual(result, tt.expected) {
t.Errorf("CalculateControlEffectiveness(%d, %v, %v) = %v, want %v",
tt.maturity, tt.coverage, tt.testEvidence, result, tt.expected)
}
})
}
}
// ============================================================================
// 3. CalculateResidualRisk — R_residual = S × E × P × (1 - C_eff)
// ============================================================================
func TestCalculateResidualRisk(t *testing.T) {
e := NewRiskEngine()
tests := []struct {
name string
s, ex, p int
cEff float64
expected float64
}{
// No controls → residual = inherent
{"no controls", 5, 5, 5, 0.0, 125.0},
// Perfect controls → residual = 0
{"perfect controls", 5, 5, 5, 1.0, 0.0},
// Half effectiveness
{"half controls 3×3×3", 3, 3, 3, 0.5, 13.5},
// Typical scenario: inherent=40, cEff=0.6 → residual=16
{"typical 2×4×5 cEff=0.6", 2, 4, 5, 0.6, 16.0},
// Low risk with some controls
{"low 1×2×3 cEff=0.3", 1, 2, 3, 0.3, 4.2},
// High risk with strong controls
{"high 5×4×4 cEff=0.82", 5, 4, 4, 0.82, 14.4},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := e.CalculateResidualRisk(tt.s, tt.ex, tt.p, tt.cEff)
if !almostEqual(result, tt.expected) {
t.Errorf("CalculateResidualRisk(%d, %d, %d, %v) = %v, want %v",
tt.s, tt.ex, tt.p, tt.cEff, result, tt.expected)
}
})
}
}
// ============================================================================
// 4. DetermineRiskLevel — threshold classification
// ============================================================================
func TestDetermineRiskLevel(t *testing.T) {
e := NewRiskEngine()
tests := []struct {
name string
residual float64
expected RiskLevel
}{
// Critical: >= 75
{"critical at 75", 75.0, RiskLevelCritical},
{"critical at 125", 125.0, RiskLevelCritical},
{"critical at 100", 100.0, RiskLevelCritical},
// High: >= 40
{"high at 40", 40.0, RiskLevelHigh},
{"high at 74.9", 74.9, RiskLevelHigh},
{"high at 50", 50.0, RiskLevelHigh},
// Medium: >= 15
{"medium at 15", 15.0, RiskLevelMedium},
{"medium at 39.9", 39.9, RiskLevelMedium},
{"medium at 27", 27.0, RiskLevelMedium},
// Low: >= 5
{"low at 5", 5.0, RiskLevelLow},
{"low at 14.9", 14.9, RiskLevelLow},
{"low at 10", 10.0, RiskLevelLow},
// Negligible: < 5
{"negligible at 4.9", 4.9, RiskLevelNegligible},
{"negligible at 0", 0.0, RiskLevelNegligible},
{"negligible at 1", 1.0, RiskLevelNegligible},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := e.DetermineRiskLevel(tt.residual)
if result != tt.expected {
t.Errorf("DetermineRiskLevel(%v) = %v, want %v", tt.residual, result, tt.expected)
}
})
}
}
// ============================================================================
// 5. IsAcceptable — ALARP principle
// ============================================================================
func TestIsAcceptable(t *testing.T) {
e := NewRiskEngine()
tests := []struct {
name string
residual float64
allReduction bool
justification bool
wantAcceptable bool
wantReason string
}{
// Below 15 → always acceptable
{"residual 14.9 always ok", 14.9, false, false, true, "Restrisiko unter Schwellwert"},
{"residual 0 always ok", 0.0, false, false, true, "Restrisiko unter Schwellwert"},
{"residual 10 always ok", 10.0, false, false, true, "Restrisiko unter Schwellwert"},
// 15-39.9 with all reduction + justification → ALARP
{"ALARP 20 all+just", 20.0, true, true, true, "ALARP-Prinzip: Restrisiko akzeptabel mit vollstaendiger Risikominderung"},
{"ALARP 39.9 all+just", 39.9, true, true, true, "ALARP-Prinzip: Restrisiko akzeptabel mit vollstaendiger Risikominderung"},
{"ALARP 15 all+just", 15.0, true, true, true, "ALARP-Prinzip: Restrisiko akzeptabel mit vollstaendiger Risikominderung"},
// 15-39.9 without all reduction → NOT acceptable
{"no reduction 20", 20.0, false, true, false, "Restrisiko zu hoch - blockiert CE-Export"},
// 15-39.9 without justification → NOT acceptable
{"no justification 20", 20.0, true, false, false, "Restrisiko zu hoch - blockiert CE-Export"},
// 15-39.9 without either → NOT acceptable
{"neither 30", 30.0, false, false, false, "Restrisiko zu hoch - blockiert CE-Export"},
// >= 40 → NEVER acceptable
{"residual 40 blocked", 40.0, true, true, false, "Restrisiko zu hoch - blockiert CE-Export"},
{"residual 75 blocked", 75.0, true, true, false, "Restrisiko zu hoch - blockiert CE-Export"},
{"residual 125 blocked", 125.0, true, true, false, "Restrisiko zu hoch - blockiert CE-Export"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
acceptable, reason := e.IsAcceptable(tt.residual, tt.allReduction, tt.justification)
if acceptable != tt.wantAcceptable {
t.Errorf("IsAcceptable(%v, %v, %v) acceptable = %v, want %v",
tt.residual, tt.allReduction, tt.justification, acceptable, tt.wantAcceptable)
}
if reason != tt.wantReason {
t.Errorf("IsAcceptable(%v, %v, %v) reason = %q, want %q",
tt.residual, tt.allReduction, tt.justification, reason, tt.wantReason)
}
})
}
}
// ============================================================================
// 6. CalculateCompletenessScore
// ============================================================================
func TestCalculateCompletenessScore(t *testing.T) {
e := NewRiskEngine()
tests := []struct {
name string
passedReq, totalReq, passedRec, totalRec, passedOpt, totalOpt int
expected float64
}{
// All passed
{"all passed", 20, 20, 5, 5, 3, 3, 100.0},
// Nothing passed
{"nothing passed", 0, 20, 0, 5, 0, 3, 0.0},
// Only required fully passed
{"only required", 20, 20, 0, 5, 0, 3, 80.0},
// Only recommended fully passed
{"only recommended", 0, 20, 5, 5, 0, 3, 15.0},
// Only optional fully passed
{"only optional", 0, 20, 0, 5, 3, 3, 5.0},
// Half required, no others
{"half required", 10, 20, 0, 5, 0, 3, 40.0},
// All zero totals → 0 (division by zero safety)
{"all zero totals", 0, 0, 0, 0, 0, 0, 0.0},
// Typical: 18/20 req + 3/5 rec + 1/3 opt
// (18/20)*80 + (3/5)*15 + (1/3)*5 = 72 + 9 + 1.6667 = 82.6667
{"typical", 18, 20, 3, 5, 1, 3, 72.0 + 9.0 + 5.0/3.0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := e.CalculateCompletenessScore(
tt.passedReq, tt.totalReq, tt.passedRec, tt.totalRec, tt.passedOpt, tt.totalOpt)
if !almostEqual(result, tt.expected) {
t.Errorf("CalculateCompletenessScore(%d/%d, %d/%d, %d/%d) = %v, want %v",
tt.passedReq, tt.totalReq, tt.passedRec, tt.totalRec, tt.passedOpt, tt.totalOpt,
result, tt.expected)
}
})
}
}
// ============================================================================
// 7. ComputeRisk — integration test
// ============================================================================
func TestComputeRisk_ValidInput(t *testing.T) {
e := NewRiskEngine()
tests := []struct {
name string
input RiskComputeInput
wantInherent float64
wantCEff float64
wantResidual float64
wantLevel RiskLevel
wantAcceptable bool
}{
{
name: "no controls high risk",
input: RiskComputeInput{
Severity: 5, Exposure: 5, Probability: 5,
ControlMaturity: 0, ControlCoverage: 0, TestEvidence: 0,
},
wantInherent: 125,
wantCEff: 0,
wantResidual: 125,
wantLevel: RiskLevelCritical,
wantAcceptable: false,
},
{
name: "perfect controls zero residual",
input: RiskComputeInput{
Severity: 5, Exposure: 5, Probability: 5,
ControlMaturity: 4, ControlCoverage: 1.0, TestEvidence: 1.0,
},
wantInherent: 125,
wantCEff: 1.0,
wantResidual: 0,
wantLevel: RiskLevelNegligible,
wantAcceptable: true,
},
{
name: "medium risk acceptable",
input: RiskComputeInput{
Severity: 2, Exposure: 2, Probability: 2,
ControlMaturity: 2, ControlCoverage: 0.5, TestEvidence: 0.5,
// C_eff = 0.2*(2/4) + 0.5*0.5 + 0.3*0.5 = 0.1 + 0.25 + 0.15 = 0.5
// inherent = 8, residual = 8 * 0.5 = 4 → negligible → acceptable
},
wantInherent: 8,
wantCEff: 0.5,
wantResidual: 4,
wantLevel: RiskLevelNegligible,
wantAcceptable: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := e.ComputeRisk(tt.input)
if err != nil {
t.Fatalf("ComputeRisk returned error: %v", err)
}
if !almostEqual(result.InherentRisk, tt.wantInherent) {
t.Errorf("InherentRisk = %v, want %v", result.InherentRisk, tt.wantInherent)
}
if !almostEqual(result.ControlEffectiveness, tt.wantCEff) {
t.Errorf("ControlEffectiveness = %v, want %v", result.ControlEffectiveness, tt.wantCEff)
}
if !almostEqual(result.ResidualRisk, tt.wantResidual) {
t.Errorf("ResidualRisk = %v, want %v", result.ResidualRisk, tt.wantResidual)
}
if result.RiskLevel != tt.wantLevel {
t.Errorf("RiskLevel = %v, want %v", result.RiskLevel, tt.wantLevel)
}
if result.IsAcceptable != tt.wantAcceptable {
t.Errorf("IsAcceptable = %v, want %v", result.IsAcceptable, tt.wantAcceptable)
}
})
}
}
func TestComputeRisk_InvalidInput(t *testing.T) {
e := NewRiskEngine()
tests := []struct {
name string
input RiskComputeInput
}{
{"severity zero", RiskComputeInput{Severity: 0, Exposure: 3, Probability: 3}},
{"exposure zero", RiskComputeInput{Severity: 3, Exposure: 0, Probability: 3}},
{"probability zero", RiskComputeInput{Severity: 3, Exposure: 3, Probability: 0}},
{"all zero", RiskComputeInput{Severity: 0, Exposure: 0, Probability: 0}},
{"negative values", RiskComputeInput{Severity: -1, Exposure: -2, Probability: -3}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := e.ComputeRisk(tt.input)
if err == nil {
t.Errorf("ComputeRisk expected error for input %+v, got result %+v", tt.input, result)
}
})
}
}
// ============================================================================
// 8. Golden Test Suite — 10 Referenzmaschinen (Industrial Machine Scenarios)
//
// Each machine scenario tests the full pipeline:
// Inherent Risk → Control Effectiveness → Residual Risk → Risk Level → Acceptability
//
// The control parameters reflect realistic mitigation states for each machine type.
// These serve as regression tests: if any threshold or formula changes,
// these tests will catch the impact immediately.
// ============================================================================
// referenceMachine defines a complete end-to-end test scenario for a real machine type.
type referenceMachine struct {
name string
description string
// Inherent risk factors (pre-mitigation)
severity int // 1-5
exposure int // 1-5
probability int // 1-5
// Control parameters (mitigation state)
controlMaturity int // 0-4
controlCoverage float64 // 0-1
testEvidence float64 // 0-1
hasJustification bool
// Expected outputs
expectedInherentRisk float64
expectedCEff float64
expectedResidualRisk float64
expectedRiskLevel RiskLevel
expectedAcceptable bool
}
// computeExpectedCEff calculates the expected control effectiveness for documentation/verification.
func computeExpectedCEff(maturity int, coverage, testEvidence float64) float64 {
cEff := 0.2*(float64(maturity)/4.0) + 0.5*coverage + 0.3*testEvidence
if cEff > 1.0 {
return 1.0
}
return cEff
}
func getReferenceMachines() []referenceMachine {
return []referenceMachine{
// ---------------------------------------------------------------
// 1. Industrieroboter-Zelle mit Schutzzaun
// 6-Achs-Roboter, Materialhandling, Schutzzaun + Lichtschranke.
// Hauptgefaehrdung: Quetschung/Kollision bei Betreten.
// Hohe Massnahmen: Sicherheits-SPS, zweikanalige Tuerueberwachung.
// ---------------------------------------------------------------
{
name: "Industrieroboter-Zelle",
description: "6-Achs-Roboter mit Schutzzaun, Quetsch-/Kollisionsgefahr",
severity: 5, // Lebensgefaehrlich
exposure: 3, // Regelmaessiger Zugang (Wartung)
probability: 4, // Wahrscheinlich bei offenem Zugang
controlMaturity: 3,
controlCoverage: 0.8,
testEvidence: 0.7,
hasJustification: false,
// Inherent: 5*3*4 = 60
// C_eff: 0.2*(3/4) + 0.5*0.8 + 0.3*0.7 = 0.15 + 0.4 + 0.21 = 0.76
// Residual: 60 * (1-0.76) = 60 * 0.24 = 14.4
// Level: medium (>=5, <15) → actually 14.4 < 15 → low
// Acceptable: 14.4 < 15 → yes
expectedInherentRisk: 60,
expectedCEff: 0.76,
expectedResidualRisk: 60 * (1 - 0.76),
expectedRiskLevel: RiskLevelLow,
expectedAcceptable: true,
},
// ---------------------------------------------------------------
// 2. CNC-Fraesmaschine mit automatischem Werkzeugwechsel
// Werkzeugbruch, Spaeneflug. Vollschutzkabine + Drehzahlueberwachung.
// ---------------------------------------------------------------
{
name: "CNC-Fraesmaschine",
description: "CNC mit Werkzeugwechsel, Werkzeugbruch/Spaeneflug",
severity: 4, // Schwere Verletzung
exposure: 3, // Bediener steht regelmaessig davor
probability: 3, // Moeglich
controlMaturity: 3,
controlCoverage: 0.9,
testEvidence: 0.8,
hasJustification: true,
// Inherent: 4*3*3 = 36
// C_eff: 0.2*(3/4) + 0.5*0.9 + 0.3*0.8 = 0.15 + 0.45 + 0.24 = 0.84
// Residual: 36 * 0.16 = 5.76
// Level: low (>=5, <15)
// Acceptable: 5.76 < 15 → yes
expectedInherentRisk: 36,
expectedCEff: 0.84,
expectedResidualRisk: 36 * (1 - 0.84),
expectedRiskLevel: RiskLevelLow,
expectedAcceptable: true,
},
// ---------------------------------------------------------------
// 3. Verpackungsmaschine mit Schneideeinheit
// Foerderband + Klinge. Schnittverletzung.
// Zweihandbedienung, Sicherheitsrelais, Abdeckung.
// ---------------------------------------------------------------
{
name: "Verpackungsmaschine",
description: "Foerderband + Schneideeinheit, Schnittverletzungsgefahr",
severity: 4, // Schwere Schnittverletzung
exposure: 4, // Dauerbetrieb mit Bediener
probability: 3, // Moeglich
controlMaturity: 2,
controlCoverage: 0.7,
testEvidence: 0.5,
hasJustification: true,
// Inherent: 4*4*3 = 48
// C_eff: 0.2*(2/4) + 0.5*0.7 + 0.3*0.5 = 0.1 + 0.35 + 0.15 = 0.6
// Residual: 48 * 0.4 = 19.2
// Level: medium (>=15, <40)
// Acceptable: 19.2 >= 15, allReduction=false (ComputeRisk default) → NOT acceptable
expectedInherentRisk: 48,
expectedCEff: 0.6,
expectedResidualRisk: 48 * (1 - 0.6),
expectedRiskLevel: RiskLevelMedium,
expectedAcceptable: false, // ComputeRisk sets allReductionStepsApplied=false
},
// ---------------------------------------------------------------
// 4. Automatisierte Pressanlage
// Quetschung im Pressbereich. Hoechste Gefaehrdung.
// Lichtvorhang, Kat-4-Steuerung, mechanische Verriegelung.
// ---------------------------------------------------------------
{
name: "Pressanlage",
description: "Automatische Presse, Quetschgefahr im Pressbereich",
severity: 5, // Toedlich
exposure: 4, // Bediener staendig im Bereich
probability: 4, // Wahrscheinlich ohne Schutz
controlMaturity: 4,
controlCoverage: 0.9,
testEvidence: 0.9,
hasJustification: true,
// Inherent: 5*4*4 = 80
// C_eff: 0.2*(4/4) + 0.5*0.9 + 0.3*0.9 = 0.2 + 0.45 + 0.27 = 0.92
// Residual: 80 * 0.08 = 6.4
// Level: low (>=5, <15)
// Acceptable: 6.4 < 15 → yes
expectedInherentRisk: 80,
expectedCEff: 0.92,
expectedResidualRisk: 80 * (1 - 0.92),
expectedRiskLevel: RiskLevelLow,
expectedAcceptable: true,
},
// ---------------------------------------------------------------
// 5. Lasergravur-Anlage (Klasse 4)
// Augenverletzung durch Laserstrahl.
// Geschlossene Kabine, Interlock.
// ---------------------------------------------------------------
{
name: "Lasergravur-Anlage",
description: "Klasse-4-Laser, Augenverletzungsgefahr",
severity: 5, // Irreversible Augenschaeden
exposure: 2, // Selten direkter Zugang
probability: 2, // Selten bei geschlossener Kabine
controlMaturity: 3,
controlCoverage: 0.95,
testEvidence: 0.8,
hasJustification: false,
// Inherent: 5*2*2 = 20
// C_eff: 0.2*(3/4) + 0.5*0.95 + 0.3*0.8 = 0.15 + 0.475 + 0.24 = 0.865
// Residual: 20 * 0.135 = 2.7
// Level: negligible (<5)
// Acceptable: 2.7 < 15 → yes
expectedInherentRisk: 20,
expectedCEff: 0.865,
expectedResidualRisk: 20 * (1 - 0.865),
expectedRiskLevel: RiskLevelNegligible,
expectedAcceptable: true,
},
// ---------------------------------------------------------------
// 6. Fahrerloses Transportsystem (AGV)
// Kollision mit Personen in Produktionshalle.
// Laserscanner, Not-Aus, Geschwindigkeitsbegrenzung.
// ---------------------------------------------------------------
{
name: "AGV (Fahrerloses Transportsystem)",
description: "Autonomes Fahrzeug, Kollisionsgefahr mit Personen",
severity: 4, // Schwere Verletzung
exposure: 4, // Dauerhaft Personen in der Naehe
probability: 3, // Moeglich in offener Umgebung
controlMaturity: 3,
controlCoverage: 0.7,
testEvidence: 0.6,
hasJustification: true,
// Inherent: 4*4*3 = 48
// C_eff: 0.2*(3/4) + 0.5*0.7 + 0.3*0.6 = 0.15 + 0.35 + 0.18 = 0.68
// Residual: 48 * 0.32 = 15.36
// Level: medium (>=15, <40)
// Acceptable: 15.36 >= 15, allReduction=false → NOT acceptable
expectedInherentRisk: 48,
expectedCEff: 0.68,
expectedResidualRisk: 48 * (1 - 0.68),
expectedRiskLevel: RiskLevelMedium,
expectedAcceptable: false, // ComputeRisk sets allReductionStepsApplied=false
},
// ---------------------------------------------------------------
// 7. Abfuellanlage fuer Chemikalien
// Kontakt mit gefaehrlichem Medium.
// Geschlossene Leitungen, Leckageerkennung.
// ---------------------------------------------------------------
{
name: "Abfuellanlage Chemikalien",
description: "Chemikalien-Abfuellung, Kontaktgefahr",
severity: 4, // Schwere Veraetzung
exposure: 2, // Gelegentlicher Zugang
probability: 2, // Selten bei geschlossenen Leitungen
controlMaturity: 3,
controlCoverage: 0.85,
testEvidence: 0.7,
hasJustification: false,
// Inherent: 4*2*2 = 16
// C_eff: 0.2*(3/4) + 0.5*0.85 + 0.3*0.7 = 0.15 + 0.425 + 0.21 = 0.785
// Residual: 16 * 0.215 = 3.44
// Level: negligible (<5)
// Acceptable: 3.44 < 15 → yes
expectedInherentRisk: 16,
expectedCEff: 0.785,
expectedResidualRisk: 16 * (1 - 0.785),
expectedRiskLevel: RiskLevelNegligible,
expectedAcceptable: true,
},
// ---------------------------------------------------------------
// 8. Industrie-3D-Drucker (Metallpulver)
// Feinstaub-Inhalation, Explosionsgefahr.
// Absauganlage, Explosionsschutz.
// ---------------------------------------------------------------
{
name: "Industrie-3D-Drucker Metallpulver",
description: "Metallpulver-Drucker, Feinstaub und ATEX",
severity: 4, // Schwere Lungenschaeden / Explosion
exposure: 3, // Regelmaessig (Druckjob-Wechsel)
probability: 3, // Moeglich
controlMaturity: 2,
controlCoverage: 0.6,
testEvidence: 0.5,
hasJustification: true,
// Inherent: 4*3*3 = 36
// C_eff: 0.2*(2/4) + 0.5*0.6 + 0.3*0.5 = 0.1 + 0.3 + 0.15 = 0.55
// Residual: 36 * 0.45 = 16.2
// Level: medium (>=15, <40)
// Acceptable: 16.2 >= 15, allReduction=false → NOT acceptable
expectedInherentRisk: 36,
expectedCEff: 0.55,
expectedResidualRisk: 36 * (1 - 0.55),
expectedRiskLevel: RiskLevelMedium,
expectedAcceptable: false,
},
// ---------------------------------------------------------------
// 9. Automatisches Hochregallager
// Absturz von Lasten, Regalbediengeraet.
// Lastsicherung, Sensoren, regelmaessige Wartung.
// ---------------------------------------------------------------
{
name: "Hochregallager",
description: "Automatisches Regallager, Lastabsturzgefahr",
severity: 5, // Toedlich bei Absturz schwerer Lasten
exposure: 2, // Selten (automatisiert, Wartungszugang)
probability: 2, // Selten bei ordnungsgemaessem Betrieb
controlMaturity: 3,
controlCoverage: 0.8,
testEvidence: 0.7,
hasJustification: false,
// Inherent: 5*2*2 = 20
// C_eff: 0.2*(3/4) + 0.5*0.8 + 0.3*0.7 = 0.15 + 0.4 + 0.21 = 0.76
// Residual: 20 * 0.24 = 4.8
// Level: negligible (<5)
// Acceptable: 4.8 < 15 → yes
expectedInherentRisk: 20,
expectedCEff: 0.76,
expectedResidualRisk: 20 * (1 - 0.76),
expectedRiskLevel: RiskLevelNegligible,
expectedAcceptable: true,
},
// ---------------------------------------------------------------
// 10. KI-Bildverarbeitung Qualitaetskontrolle
// Fehlklassifikation → sicherheitsrelevantes Bauteil wird freigegeben.
// Redundante Pruefung, Validierungsdatensatz, KI-Risikobeurteilung.
// AI Act relevant.
// ---------------------------------------------------------------
{
name: "KI-Qualitaetskontrolle",
description: "KI-Vision fuer Bauteilpruefung, Fehlklassifikationsgefahr (AI Act relevant)",
severity: 4, // Fehlerhaftes Sicherheitsbauteil im Feld
exposure: 5, // Kontinuierlich (jedes Bauteil)
probability: 3, // Moeglich (ML nie 100% korrekt)
controlMaturity: 2,
controlCoverage: 0.5,
testEvidence: 0.6,
hasJustification: true,
// Inherent: 4*5*3 = 60
// C_eff: 0.2*(2/4) + 0.5*0.5 + 0.3*0.6 = 0.1 + 0.25 + 0.18 = 0.53
// Residual: 60 * 0.47 = 28.2
// Level: medium (>=15, <40)
// Acceptable: 28.2 >= 15, allReduction=false → NOT acceptable
expectedInherentRisk: 60,
expectedCEff: 0.53,
expectedResidualRisk: 60 * (1 - 0.53),
expectedRiskLevel: RiskLevelMedium,
expectedAcceptable: false,
},
}
}
func TestReferenceMachines_ComputeRisk(t *testing.T) {
engine := NewRiskEngine()
machines := getReferenceMachines()
for _, m := range machines {
t.Run(m.name, func(t *testing.T) {
input := RiskComputeInput{
Severity: m.severity,
Exposure: m.exposure,
Probability: m.probability,
ControlMaturity: m.controlMaturity,
ControlCoverage: m.controlCoverage,
TestEvidence: m.testEvidence,
HasJustification: m.hasJustification,
}
result, err := engine.ComputeRisk(input)
if err != nil {
t.Fatalf("ComputeRisk returned error: %v", err)
}
// Verify inherent risk
if !almostEqual(result.InherentRisk, m.expectedInherentRisk) {
t.Errorf("InherentRisk = %v, want %v", result.InherentRisk, m.expectedInherentRisk)
}
// Verify control effectiveness
if !almostEqual(result.ControlEffectiveness, m.expectedCEff) {
t.Errorf("ControlEffectiveness = %v, want %v", result.ControlEffectiveness, m.expectedCEff)
}
// Verify residual risk
if !almostEqual(result.ResidualRisk, m.expectedResidualRisk) {
t.Errorf("ResidualRisk = %v, want %v (diff: %v)",
result.ResidualRisk, m.expectedResidualRisk,
math.Abs(result.ResidualRisk-m.expectedResidualRisk))
}
// Verify risk level
if result.RiskLevel != m.expectedRiskLevel {
t.Errorf("RiskLevel = %v, want %v (residual=%v)",
result.RiskLevel, m.expectedRiskLevel, result.ResidualRisk)
}
// Verify acceptability
if result.IsAcceptable != m.expectedAcceptable {
t.Errorf("IsAcceptable = %v, want %v (residual=%v, level=%v)",
result.IsAcceptable, m.expectedAcceptable, result.ResidualRisk, result.RiskLevel)
}
})
}
}
// TestReferenceMachines_InherentRiskDistribution verifies that the 10 machines
// cover a meaningful range of inherent risk values (not all clustered).
func TestReferenceMachines_InherentRiskDistribution(t *testing.T) {
machines := getReferenceMachines()
var minRisk, maxRisk float64
minRisk = 999
levelCounts := map[RiskLevel]int{}
for _, m := range machines {
if m.expectedInherentRisk < minRisk {
minRisk = m.expectedInherentRisk
}
if m.expectedInherentRisk > maxRisk {
maxRisk = m.expectedInherentRisk
}
levelCounts[m.expectedRiskLevel]++
}
// Should span a meaningful range
if maxRisk-minRisk < 40 {
t.Errorf("Inherent risk range too narrow: [%v, %v], want spread >= 40", minRisk, maxRisk)
}
// Should cover at least 3 different risk levels
if len(levelCounts) < 3 {
t.Errorf("Only %d risk levels covered, want at least 3: %v", len(levelCounts), levelCounts)
}
}
// TestReferenceMachines_AcceptabilityMix verifies that the test suite has both
// acceptable and unacceptable outcomes.
func TestReferenceMachines_AcceptabilityMix(t *testing.T) {
machines := getReferenceMachines()
acceptableCount := 0
unacceptableCount := 0
for _, m := range machines {
if m.expectedAcceptable {
acceptableCount++
} else {
unacceptableCount++
}
}
if acceptableCount == 0 {
t.Error("No acceptable machines in test suite — need at least one")
}
if unacceptableCount == 0 {
t.Error("No unacceptable machines in test suite — need at least one")
}
t.Logf("Acceptability mix: %d acceptable, %d unacceptable out of %d machines",
acceptableCount, unacceptableCount, len(machines))
}
// ============================================================================
// 9. Edge Cases
// ============================================================================
func TestClamp(t *testing.T) {
tests := []struct {
name string
v, lo, hi int
expected int
}{
{"in range", 3, 1, 5, 3},
{"at low", 1, 1, 5, 1},
{"at high", 5, 1, 5, 5},
{"below low", 0, 1, 5, 1},
{"above high", 10, 1, 5, 5},
{"negative", -100, 0, 4, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := clamp(tt.v, tt.lo, tt.hi)
if result != tt.expected {
t.Errorf("clamp(%d, %d, %d) = %d, want %d", tt.v, tt.lo, tt.hi, result, tt.expected)
}
})
}
}
func TestClampFloat(t *testing.T) {
tests := []struct {
name string
v, lo, hi float64
expected float64
}{
{"in range", 0.5, 0, 1, 0.5},
{"at low", 0, 0, 1, 0},
{"at high", 1.0, 0, 1, 1.0},
{"below low", -0.5, 0, 1, 0},
{"above high", 2.5, 0, 1, 1.0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := clampFloat(tt.v, tt.lo, tt.hi)
if !almostEqual(result, tt.expected) {
t.Errorf("clampFloat(%v, %v, %v) = %v, want %v", tt.v, tt.lo, tt.hi, result, tt.expected)
}
})
}
}