feat(iace): integrate ISO 12100 machine risk model with 4-factor assessment
All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 36s
CI/CD / test-python-backend-compliance (push) Successful in 36s
CI/CD / test-python-document-crawler (push) Successful in 22s
CI/CD / test-python-dsms-gateway (push) Successful in 18s
CI/CD / validate-canonical-controls (push) Successful in 12s
CI/CD / Deploy (push) Successful in 2s

Add dual-mode risk engine: legacy S×E×P (avoidance=0) and ISO mode S×F×P×A
(avoidance>=1) with new thresholds (low/medium/high/very_high/not_acceptable).

- 150+ hazard library entries across 28 categories incl. physical hazards
  (mechanical, electrical, thermal, pneumatic/hydraulic, noise/vibration,
  ergonomic, material/environmental)
- 160-entry protective measures library with 3-step hierarchy validation
  (design → protective → information)
- 25 lifecycle phases, 20 affected person roles, 50 evidence types
- 10 verification methods (expanded from 7)
- New API endpoints: lifecycle-phases, roles, evidence-types,
  protective-measures-library, validate-mitigation-hierarchy
- DB migrations 018+019 for extended schema
- Frontend: 4-slider risk assessment, hierarchy warnings, measures library modal
- MkDocs wiki updated with ISO mode docs and legal notice (no norm text)

All content uses original wording — norms referenced as methodology only.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-15 23:13:41 +01:00
parent c8fd9cc780
commit c7651796c9
15 changed files with 3708 additions and 479 deletions

View File

@@ -912,6 +912,242 @@ func TestClamp(t *testing.T) {
}
}
// ============================================================================
// 10. ISO 12100 Mode — S × F × P × A (direct multiplication)
// ============================================================================
func TestCalculateInherentRisk_ISO12100Mode(t *testing.T) {
e := NewRiskEngine()
tests := []struct {
name string
s, f, p, a int
expected float64
}{
// Minimum: 1×1×1×1 = 1
{"min 1×1×1×1", 1, 1, 1, 1, 1},
// Maximum: 5×5×5×5 = 625
{"max 5×5×5×5", 5, 5, 5, 5, 625},
// Typical mid-range: 3×3×3×3 = 81
{"mid 3×3×3×3", 3, 3, 3, 3, 81},
// High severity, low avoidance: 5×3×4×1 = 60
{"high S low A", 5, 3, 4, 1, 60},
// All factors high: 4×4×4×4 = 256
{"high 4×4×4×4", 4, 4, 4, 4, 256},
// Low risk: 2×1×2×1 = 4
{"low risk", 2, 1, 2, 1, 4},
// At not_acceptable boundary: 5×5×3×5 = 375
{"above 300", 5, 5, 3, 5, 375},
// At very_high boundary: 4×3×4×4 = 192
{"very high range", 4, 3, 4, 4, 192},
// At high boundary: 3×3×3×3 = 81
{"high range", 3, 3, 3, 3, 81},
// At medium range: 2×3×3×2 = 36
{"medium range", 2, 3, 3, 2, 36},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := e.CalculateInherentRisk(tt.s, tt.f, tt.p, tt.a)
if !almostEqual(result, tt.expected) {
t.Errorf("CalculateInherentRisk(%d, %d, %d, %d) = %v, want %v",
tt.s, tt.f, tt.p, tt.a, result, tt.expected)
}
})
}
}
func TestDetermineRiskLevelISO(t *testing.T) {
e := NewRiskEngine()
tests := []struct {
name string
risk float64
expected RiskLevel
}{
// not_acceptable: > 300
{"not_acceptable at 301", 301, RiskLevelNotAcceptable},
{"not_acceptable at 625", 625, RiskLevelNotAcceptable},
{"not_acceptable at 400", 400, RiskLevelNotAcceptable},
// very_high: 151-300
{"very_high at 300", 300, RiskLevelVeryHigh},
{"very_high at 151", 151, RiskLevelVeryHigh},
{"very_high at 200", 200, RiskLevelVeryHigh},
// high: 61-150
{"high at 150", 150, RiskLevelHigh},
{"high at 61", 61, RiskLevelHigh},
{"high at 100", 100, RiskLevelHigh},
// medium: 21-60
{"medium at 60", 60, RiskLevelMedium},
{"medium at 21", 21, RiskLevelMedium},
{"medium at 40", 40, RiskLevelMedium},
// low: 1-20
{"low at 20", 20, RiskLevelLow},
{"low at 1", 1, RiskLevelLow},
{"low at 10", 10, RiskLevelLow},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := e.DetermineRiskLevelISO(tt.risk)
if result != tt.expected {
t.Errorf("DetermineRiskLevelISO(%v) = %v, want %v", tt.risk, result, tt.expected)
}
})
}
}
func TestAvoidanceBackwardCompat(t *testing.T) {
e := NewRiskEngine()
// With avoidance=0, formula must remain S×E×P (legacy mode)
tests := []struct {
s, ex, p int
expected float64
}{
{5, 5, 5, 125},
{3, 3, 3, 27},
{1, 1, 1, 1},
{4, 2, 5, 40},
}
for _, tt := range tests {
result := e.CalculateInherentRisk(tt.s, tt.ex, tt.p, 0)
if !almostEqual(result, tt.expected) {
t.Errorf("Legacy mode: CalculateInherentRisk(%d,%d,%d,0) = %v, want %v",
tt.s, tt.ex, tt.p, result, tt.expected)
}
}
}
func TestValidateProtectiveMeasureHierarchy(t *testing.T) {
e := NewRiskEngine()
tests := []struct {
name string
reductionType ReductionType
existing []Mitigation
wantWarnings int
}{
{
name: "design measure — no warning",
reductionType: ReductionTypeDesign,
existing: nil,
wantWarnings: 0,
},
{
name: "information without any — full warning",
reductionType: ReductionTypeInformation,
existing: nil,
wantWarnings: 1,
},
{
name: "information with design — no warning",
reductionType: ReductionTypeInformation,
existing: []Mitigation{
{ReductionType: ReductionTypeDesign, Status: MitigationStatusImplemented},
{ReductionType: ReductionTypeProtective, Status: MitigationStatusPlanned},
},
wantWarnings: 0,
},
{
name: "information with only protective — missing design warning",
reductionType: ReductionTypeInformation,
existing: []Mitigation{
{ReductionType: ReductionTypeProtective, Status: MitigationStatusImplemented},
},
wantWarnings: 1,
},
{
name: "information with rejected measures — full warning",
reductionType: ReductionTypeInformation,
existing: []Mitigation{
{ReductionType: ReductionTypeDesign, Status: MitigationStatusRejected},
{ReductionType: ReductionTypeProtective, Status: MitigationStatusRejected},
},
wantWarnings: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
warnings := e.ValidateProtectiveMeasureHierarchy(tt.reductionType, tt.existing)
if len(warnings) != tt.wantWarnings {
t.Errorf("got %d warnings, want %d: %v", len(warnings), tt.wantWarnings, warnings)
}
})
}
}
func TestComputeRisk_ISOMode(t *testing.T) {
e := NewRiskEngine()
// ISO mode: avoidance >= 1 → uses DetermineRiskLevelISO for inherent risk classification
tests := []struct {
name string
input RiskComputeInput
wantLevel RiskLevel
}{
{
name: "ISO low risk",
input: RiskComputeInput{
Severity: 2, Exposure: 1, Probability: 2, Avoidance: 1,
ControlMaturity: 2, ControlCoverage: 0.5, TestEvidence: 0.5,
},
wantLevel: RiskLevelLow, // 2×1×2×1 = 4 → low
},
{
name: "ISO medium risk",
input: RiskComputeInput{
Severity: 3, Exposure: 2, Probability: 3, Avoidance: 2,
ControlMaturity: 2, ControlCoverage: 0.5, TestEvidence: 0.5,
},
wantLevel: RiskLevelMedium, // 3×2×3×2 = 36 → medium
},
{
name: "ISO high risk",
input: RiskComputeInput{
Severity: 4, Exposure: 3, Probability: 3, Avoidance: 3,
ControlMaturity: 2, ControlCoverage: 0.5, TestEvidence: 0.5,
},
wantLevel: RiskLevelHigh, // 4×3×3×3 = 108 → high
},
{
name: "ISO very high risk",
input: RiskComputeInput{
Severity: 4, Exposure: 4, Probability: 4, Avoidance: 3,
ControlMaturity: 2, ControlCoverage: 0.5, TestEvidence: 0.5,
},
wantLevel: RiskLevelVeryHigh, // 4×4×4×3 = 192 → very_high
},
{
name: "ISO not acceptable",
input: RiskComputeInput{
Severity: 5, Exposure: 5, Probability: 4, Avoidance: 4,
ControlMaturity: 4, ControlCoverage: 1.0, TestEvidence: 1.0,
},
wantLevel: RiskLevelNotAcceptable, // 5×5×4×4 = 400 → not_acceptable
},
}
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 result.RiskLevel != tt.wantLevel {
t.Errorf("RiskLevel = %v, want %v (inherent=%v)",
result.RiskLevel, tt.wantLevel, result.InherentRisk)
}
})
}
}
// ============================================================================
// 11. Edge Cases (continued)
// ============================================================================
func TestClampFloat(t *testing.T) {
tests := []struct {
name string