Files
breakpilot-compliance/ai-compliance-sdk/internal/iace/engine_test.go
Benjamin Boenisch 06711bad1c
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
feat(sdk,iace): add Personalized Drafting Pipeline v2 and IACE engine
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>
2026-02-25 22:27:06 +01:00

937 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
})
}
}