feat(iace): Sprint 4A — Residual Risk Modeling (Suppression Engine)

RiskReduction Struct + automatische Risk Trajectory:
- RiskReduction{SeverityDelta, ExposureDelta, ProbabilityDelta} auf ProtectiveMeasureEntry
- CalculateRiskTrajectory() in engine.go: berechnet schrittweise Risikoreduktion
  entlang ISO 12100 Hierarchie (design → protection → information)
- Kumulative Deltas pro Stufe, Clamp auf Minimum 1
- RiskTrajectoryStep mit Stage, S/E/P, Score, Level, IsAcceptable

101 Massnahmen mit RiskReduction-Profilen versehen:
- Design/Geometry (M001-M010): S-1, E-1 (Gefahrstelle eliminiert)
- Design/Force (M011-M022): S-2 (Energie/Kraft reduziert)
- Design/Control (M039-M050): P-2 (sichere Steuerung)
- Protection/Guards (M061-M072): E-2 (Zugang verhindert)
- Protection/Electro (M073-M079): E-1, P-1 (Erkennung)
- Protection/Safety (M105-M113): P-2 (sichere SPS)
- Protection/Monitoring (M114-M120): P-1 (Frueerkennung)
- Protection/Cyber (M121-M130): P-1
- Information/Training (M161-M168): P-1
- Information/PPE (M169-M175): S-1

8 neue Tests: NoMeasures, DesignReduce, FullHierarchy, ClampMin1,
  OnlyProtection, WithoutReduction, MandatoryAsProtective, LibraryCount

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-10 09:15:43 +02:00
parent 05d98ea95f
commit 6d2616cad7
5 changed files with 348 additions and 103 deletions
@@ -0,0 +1,161 @@
package iace
import "testing"
func TestRiskTrajectory_NoMeasures(t *testing.T) {
engine := NewRiskEngine()
steps := engine.CalculateRiskTrajectory(4, 4, 3, nil)
if len(steps) != 1 {
t.Fatalf("expected 1 step (inherent only), got %d", len(steps))
}
if steps[0].Stage != "inherent" {
t.Errorf("expected stage 'inherent', got %q", steps[0].Stage)
}
if steps[0].RiskScore != 48 {
t.Errorf("expected risk score 48 (4×4×3), got %.1f", steps[0].RiskScore)
}
}
func TestRiskTrajectory_DesignMeasuresReduce(t *testing.T) {
engine := NewRiskEngine()
measures := []ProtectiveMeasureEntry{
{ID: "M001", ReductionType: "design", RiskReduction: &RiskReduction{SeverityDelta: -1, ExposureDelta: -1}},
{ID: "M012", ReductionType: "design", RiskReduction: &RiskReduction{SeverityDelta: -2}},
}
steps := engine.CalculateRiskTrajectory(4, 4, 3, measures)
if len(steps) != 2 {
t.Fatalf("expected 2 steps (inherent + after_design), got %d", len(steps))
}
// After design: S=4+(-1)+(-2)=1, E=4+(-1)=3, P=3 → 1×3×3 = 9
after := steps[1]
if after.Stage != "after_design" {
t.Errorf("expected stage 'after_design', got %q", after.Stage)
}
if after.Severity != 1 {
t.Errorf("expected severity 1, got %d", after.Severity)
}
if after.Exposure != 3 {
t.Errorf("expected exposure 3, got %d", after.Exposure)
}
if after.RiskScore != 9 {
t.Errorf("expected risk score 9, got %.1f", after.RiskScore)
}
}
func TestRiskTrajectory_FullHierarchy(t *testing.T) {
engine := NewRiskEngine()
measures := []ProtectiveMeasureEntry{
{ID: "M001", ReductionType: "design", RiskReduction: &RiskReduction{ExposureDelta: -1}},
{ID: "M067", ReductionType: "protection", RiskReduction: &RiskReduction{ExposureDelta: -2, ProbabilityDelta: -1}},
{ID: "M161", ReductionType: "information", RiskReduction: &RiskReduction{ProbabilityDelta: -1}},
}
// Start: S=4, E=4, P=3 → 48
steps := engine.CalculateRiskTrajectory(4, 4, 3, measures)
if len(steps) != 4 {
t.Fatalf("expected 4 steps, got %d", len(steps))
}
// Inherent: 4×4×3 = 48
if steps[0].RiskScore != 48 {
t.Errorf("inherent: expected 48, got %.1f", steps[0].RiskScore)
}
// After design: S=4, E=3, P=3 → 36
if steps[1].RiskScore != 36 {
t.Errorf("after_design: expected 36, got %.1f", steps[1].RiskScore)
}
// After protection: S=4, E=1, P=2 → 8
if steps[2].RiskScore != 8 {
t.Errorf("after_protection: expected 8, got %.1f", steps[2].RiskScore)
}
// After information: S=4, E=1, P=1 → 4
if steps[3].RiskScore != 4 {
t.Errorf("after_information: expected 4, got %.1f", steps[3].RiskScore)
}
if !steps[3].IsAcceptable {
t.Error("final risk 4 should be acceptable")
}
}
func TestRiskTrajectory_ClampMinimum1(t *testing.T) {
engine := NewRiskEngine()
// Very aggressive reduction that would push below 1
measures := []ProtectiveMeasureEntry{
{ID: "M001", ReductionType: "design", RiskReduction: &RiskReduction{SeverityDelta: -5, ExposureDelta: -5, ProbabilityDelta: -5}},
}
steps := engine.CalculateRiskTrajectory(3, 3, 3, measures)
after := steps[1]
if after.Severity != 1 || after.Exposure != 1 || after.Probability != 1 {
t.Errorf("expected all clamped to 1, got S=%d E=%d P=%d", after.Severity, after.Exposure, after.Probability)
}
if after.RiskScore != 1 {
t.Errorf("expected minimum risk score 1, got %.1f", after.RiskScore)
}
}
func TestRiskTrajectory_OnlyProtectionMeasures(t *testing.T) {
engine := NewRiskEngine()
// No design measures, only protection
measures := []ProtectiveMeasureEntry{
{ID: "M067", ReductionType: "protection", RiskReduction: &RiskReduction{ExposureDelta: -2}},
}
steps := engine.CalculateRiskTrajectory(4, 4, 3, measures)
// Should have 2 steps: inherent + after_protection (no after_design)
if len(steps) != 2 {
t.Fatalf("expected 2 steps, got %d", len(steps))
}
if steps[1].Stage != "after_protection" {
t.Errorf("expected stage after_protection, got %q", steps[1].Stage)
}
// S=4, E=2, P=3 → 24
if steps[1].RiskScore != 24 {
t.Errorf("expected 24, got %.1f", steps[1].RiskScore)
}
}
func TestRiskTrajectory_MeasuresWithoutRiskReduction(t *testing.T) {
engine := NewRiskEngine()
// Measures without RiskReduction should be skipped (no delta)
measures := []ProtectiveMeasureEntry{
{ID: "M151", ReductionType: "information", Name: "Betriebsanleitung"},
}
steps := engine.CalculateRiskTrajectory(3, 3, 3, measures)
if len(steps) != 1 {
t.Fatalf("expected 1 step (measures without RiskReduction have no effect), got %d", len(steps))
}
}
func TestRiskTrajectory_MandatoryMeasuresAsProtective(t *testing.T) {
engine := NewRiskEngine()
// MN measures have ReductionType "protective" — should be grouped with "protection"
measures := []ProtectiveMeasureEntry{
{ID: "MN001", ReductionType: "protective", RiskReduction: &RiskReduction{ProbabilityDelta: -1}},
}
steps := engine.CalculateRiskTrajectory(4, 4, 3, measures)
if len(steps) != 2 {
t.Fatalf("expected 2 steps, got %d", len(steps))
}
if steps[1].Stage != "after_protection" {
t.Errorf("expected after_protection for 'protective' type, got %q", steps[1].Stage)
}
}
func TestRiskReduction_OnMeasureLibrary(t *testing.T) {
// Verify that the library has measures with RiskReduction profiles
measures := GetProtectiveMeasureLibrary()
withReduction := 0
for _, m := range measures {
if m.RiskReduction != nil {
withReduction++
}
}
if withReduction < 80 {
t.Errorf("expected at least 80 measures with RiskReduction profiles, got %d", withReduction)
}
}