diff --git a/ai-compliance-sdk/internal/api/handlers/iace_handler_distances.go b/ai-compliance-sdk/internal/api/handlers/iace_handler_distances.go new file mode 100644 index 00000000..9f0022cc --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/iace_handler_distances.go @@ -0,0 +1,30 @@ +package handlers + +import ( + "net/http" + + "github.com/breakpilot/ai-compliance-sdk/internal/iace" + "github.com/gin-gonic/gin" +) + +// ListMinimumDistances handles GET /minimum-distances. +// Read-only OSHA safety-distance reference (29 CFR 1910, US public domain) +// plus the curated measure→distance link table, so an auditor can see WHERE a +// measure's mm figure comes from. Optional ?measure_id= returns only the +// distances (and links) tied to that protective measure. +func (h *IACEHandler) ListMinimumDistances(c *gin.Context) { + if mid := c.Query("measure_id"); mid != "" { + c.JSON(http.StatusOK, gin.H{ + "measure_id": mid, + "distances": iace.MinimumDistancesForMeasure(mid), + "links": iace.LinksForMeasure(mid), + "note": iace.MinimumDistanceNote, + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "distances": iace.GetOSHAMinimumDistances(), + "links": iace.AllMeasureDistanceLinks(), + "note": iace.MinimumDistanceNote, + }) +} diff --git a/ai-compliance-sdk/internal/app/routes_iace.go b/ai-compliance-sdk/internal/app/routes_iace.go index ffd2ee0a..8e0f0001 100644 --- a/ai-compliance-sdk/internal/app/routes_iace.go +++ b/ai-compliance-sdk/internal/app/routes_iace.go @@ -30,6 +30,7 @@ func registerIACERoutes(v1 *gin.RouterGroup, h *handlers.IACEHandler) { iaceRoutes.GET("/operational-states", h.ListOperationalStates) iaceRoutes.GET("/component-library", h.ListComponentLibrary) iaceRoutes.GET("/energy-sources", h.ListEnergySources) + iaceRoutes.GET("/minimum-distances", h.ListMinimumDistances) iaceRoutes.GET("/tags", h.ListTags) iaceRoutes.GET("/hazard-patterns", h.ListHazardPatterns) diff --git a/ai-compliance-sdk/internal/iace/architecture.go b/ai-compliance-sdk/internal/iace/architecture.go index 279cff0a..0cac3701 100644 --- a/ai-compliance-sdk/internal/iace/architecture.go +++ b/ai-compliance-sdk/internal/iace/architecture.go @@ -146,6 +146,7 @@ func BuildArchitecture() Architecture { {Name: "Domänen-Capability-Gates", Count: distinctDomainGates(), SourceFile: "pattern_domain_gates.go", Description: "dom_*-Tags, die domänenspezifische Muster auf ihre echte Maschine begrenzen (Leak-Schutz)."}, {Name: "Kontaktmodus-Tiers", Count: len(contactModeTable), SourceFile: "risk_estimation.go", Description: "Verletzungsmechanismen mit W/P/S-Tiers (ESAW-verankert, GT-kalibriert)."}, {Name: "Kontaktmodus-Evidenz", Count: len(contactModeEvidence), SourceFile: "risk_data_sources.go", Description: "Belegte öffentliche Statistik-Quoten (ESAW) als Zitat-/Audit-Schicht."}, + {Name: "OSHA-Mindestabstände", Count: len(GetOSHAMinimumDistances()), SourceFile: "minimum_distances.go", Description: "OSHA 29 CFR 1910 Sicherheitsabstände (Public Domain) + Maßnahmen-Verknüpfung; EU-Normen nur referenziert."}, }, DataSources: []ArchDataSource{ {Name: "Eurostat ESAW (Kontaktmodus-Unfallstatistik)", License: "CC BY 4.0", Usage: "Anker für Wahrscheinlichkeits-Tiers (W) + zitierbare Quoten", Status: "verwendet"}, diff --git a/ai-compliance-sdk/internal/iace/minimum_distances.go b/ai-compliance-sdk/internal/iace/minimum_distances.go index fc604833..0b295129 100644 --- a/ai-compliance-sdk/internal/iace/minimum_distances.go +++ b/ai-compliance-sdk/internal/iace/minimum_distances.go @@ -24,10 +24,10 @@ package iace type MinimumDistanceUnit string const ( - UnitInch MinimumDistanceUnit = "inch" - UnitFoot MinimumDistanceUnit = "foot" - UnitMeter MinimumDistanceUnit = "meter" - UnitMM MinimumDistanceUnit = "mm" + UnitInch MinimumDistanceUnit = "inch" + UnitFoot MinimumDistanceUnit = "foot" + UnitMeter MinimumDistanceUnit = "meter" + UnitMM MinimumDistanceUnit = "mm" ) // MinimumDistance is the data contract for a single safety-distance rule. @@ -36,16 +36,16 @@ const ( type MinimumDistance struct { ID string `json:"id"` // MD_OSHA_001 // Source identifier — full CFR citation or norm reference. - SourceCFR string `json:"source_cfr,omitempty"` // "29 CFR §1910.217(c)(1)(i)" - SourceTable string `json:"source_table,omitempty"` // "Table O-10" - License string `json:"license"` // "US Federal Public Domain" - LicenseRule int `json:"license_rule"` // 1 / 2 / 3 (see LICENSE_RULES.md) + SourceCFR string `json:"source_cfr,omitempty"` // "29 CFR §1910.217(c)(1)(i)" + SourceTable string `json:"source_table,omitempty"` // "Table O-10" + License string `json:"license"` // "US Federal Public Domain" + LicenseRule int `json:"license_rule"` // 1 / 2 / 3 (see LICENSE_RULES.md) // Original verbatim value in the source's own unit. - OriginalUnit MinimumDistanceUnit `json:"original_unit"` - OriginalValue float64 `json:"original_value,omitempty"` - OriginalMin float64 `json:"original_min,omitempty"` - OriginalMax float64 `json:"original_max,omitempty"` + OriginalUnit MinimumDistanceUnit `json:"original_unit"` + OriginalValue float64 `json:"original_value,omitempty"` + OriginalMin float64 `json:"original_min,omitempty"` + OriginalMax float64 `json:"original_max,omitempty"` // Exact conversion to mm — no engineering rounding. ExactMM float64 `json:"exact_mm,omitempty"` @@ -55,18 +55,18 @@ type MinimumDistance struct { // Engineering-recommended metric value with safe-side rounding. // For minimum distances: rounded up. For maximum opening widths: // rounded down. - RecommendedMM int `json:"recommended_mm,omitempty"` - RecommendedMinMM int `json:"recommended_min_mm,omitempty"` - RecommendedMaxMM int `json:"recommended_max_mm,omitempty"` - RoundingNote string `json:"rounding_note,omitempty"` + RecommendedMM int `json:"recommended_mm,omitempty"` + RecommendedMinMM int `json:"recommended_min_mm,omitempty"` + RecommendedMaxMM int `json:"recommended_max_mm,omitempty"` + RoundingNote string `json:"rounding_note,omitempty"` // Optional formula constant (e.g. OSHA hand-speed 63 in/s). FormulaInchPerSecond float64 `json:"formula_inch_per_second,omitempty"` FormulaMMPerSecond float64 `json:"formula_mm_per_second,omitempty"` FormulaDescription string `json:"formula_description,omitempty"` - Context string `json:"context"` // "Point of Operation Guarding mechanical presses" - BodyPart string `json:"body_part,omitempty"` // "finger" / "hand" / "head" / "foot" / "body" + Context string `json:"context"` // "Point of Operation Guarding mechanical presses" + BodyPart string `json:"body_part,omitempty"` // "finger" / "hand" / "head" / "foot" / "body" HazardTags []string `json:"hazard_tags,omitempty"` // [crush_point, cutting_part, ...] // EU norm cross-reference — IDENTIFIER ONLY, no values reproduced. @@ -77,8 +77,8 @@ type MinimumDistance struct { // any value or text from it. The DINComparisonNote is a human-curated // qualitative judgement (stricter / equivalent / looser) — not a copy. type EUNormHint struct { - Norm string `json:"norm"` // "EN ISO 13857" - Section string `json:"section,omitempty"` // "Tab. 4, Schutz gegen Hineingreifen" + Norm string `json:"norm"` // "EN ISO 13857" + Section string `json:"section,omitempty"` // "Tab. 4, Schutz gegen Hineingreifen" DINComparisonNote string `json:"din_comparison_note,omitempty"` } @@ -98,12 +98,12 @@ func GetOSHAMinimumDistances() []MinimumDistance { LicenseRule: 1, OriginalUnit: UnitInch, OriginalMin: 0.5, OriginalMax: 1.5, OriginalValue: 0.25, - ExactMinMM: 12.7, ExactMaxMM: 38.1, ExactMM: 6.35, + ExactMinMM: 12.7, ExactMaxMM: 38.1, ExactMM: 6.35, RecommendedMinMM: 15, RecommendedMaxMM: 40, RecommendedMM: 6, - RoundingNote: "Distance auf 5-mm-Raster aufgerundet, opening auf 1-mm-Raster abgerundet (konservativ in beide Richtungen).", - Context: "Point-of-Operation Guarding bei mechanischen Pressen", - BodyPart: "finger", - HazardTags: []string{"crush_point", "cutting_part"}, + RoundingNote: "Distance auf 5-mm-Raster aufgerundet, opening auf 1-mm-Raster abgerundet (konservativ in beide Richtungen).", + Context: "Point-of-Operation Guarding bei mechanischen Pressen", + BodyPart: "finger", + HazardTags: []string{"crush_point", "cutting_part"}, EUNormHints: []EUNormHint{ {Norm: "EN ISO 13857", Section: "Tab. 4 (Hineingreifen)", DINComparisonNote: "Andere Methodik (Reichweitenmodell). Unabhaengig pruefen — Werte koennen abweichen."}, @@ -119,12 +119,12 @@ func GetOSHAMinimumDistances() []MinimumDistance { LicenseRule: 1, OriginalUnit: UnitInch, OriginalMin: 3.5, OriginalMax: 5.5, OriginalValue: 0.625, - ExactMinMM: 88.9, ExactMaxMM: 139.7, ExactMM: 15.875, + ExactMinMM: 88.9, ExactMaxMM: 139.7, ExactMM: 15.875, RecommendedMinMM: 90, RecommendedMaxMM: 140, RecommendedMM: 15, - RoundingNote: "Distance 88.9→90 (+1.1 mm), 139.7→140 (+0.3 mm) aufgerundet; Opening 15.875→15 (-0.875 mm) abgerundet.", - Context: "Point-of-Operation Guarding bei mechanischen Pressen", - BodyPart: "finger", - HazardTags: []string{"crush_point", "cutting_part"}, + RoundingNote: "Distance 88.9→90 (+1.1 mm), 139.7→140 (+0.3 mm) aufgerundet; Opening 15.875→15 (-0.875 mm) abgerundet.", + Context: "Point-of-Operation Guarding bei mechanischen Pressen", + BodyPart: "finger", + HazardTags: []string{"crush_point", "cutting_part"}, EUNormHints: []EUNormHint{ {Norm: "EN ISO 13857", Section: "Tab. 4 (Hineingreifen)", DINComparisonNote: "Andere Methodik (Reichweitenmodell). Compliance-Annotation pflegen."}, @@ -132,18 +132,18 @@ func GetOSHAMinimumDistances() []MinimumDistance { }, // OSHA §1910.212(a)(5) — fan blade guards. Verbatim 1/2 inch. { - ID: "MD_OSHA_212_FAN", - SourceCFR: "29 CFR §1910.212(a)(5)", - License: "US Federal Public Domain (17 U.S.C. §105)", - LicenseRule: 1, - OriginalUnit: UnitInch, + ID: "MD_OSHA_212_FAN", + SourceCFR: "29 CFR §1910.212(a)(5)", + License: "US Federal Public Domain (17 U.S.C. §105)", + LicenseRule: 1, + OriginalUnit: UnitInch, OriginalValue: 0.5, - ExactMM: 12.7, + ExactMM: 12.7, RecommendedMM: 12, - RoundingNote: "Luefterblatt-Schutzgitter: max. Spaltoeffnung 1/2 in = 12.7 mm. Konservativ auf 12 mm abgerundet.", - Context: "Lüfterblätter unter 7 ft (2.13 m) Höhe", - BodyPart: "finger", - HazardTags: []string{"rotating_part", "cutting_part"}, + RoundingNote: "Luefterblatt-Schutzgitter: max. Spaltoeffnung 1/2 in = 12.7 mm. Konservativ auf 12 mm abgerundet.", + Context: "Lüfterblätter unter 7 ft (2.13 m) Höhe", + BodyPart: "finger", + HazardTags: []string{"rotating_part", "cutting_part"}, EUNormHints: []EUNormHint{ {Norm: "EN ISO 13857", Section: "Tab. 4", DINComparisonNote: "DIN-Wert pruefen."}, @@ -151,11 +151,11 @@ func GetOSHAMinimumDistances() []MinimumDistance { }, // OSHA §1910.217 Hand-Speed Constant — formula Ds = 63 in/s × Ts { - ID: "MD_OSHA_217_PSDI", - SourceCFR: "29 CFR §1910.217 (Ds = 63 in/s × Ts)", - License: "US Federal Public Domain (17 U.S.C. §105)", - LicenseRule: 1, - OriginalUnit: UnitInch, + ID: "MD_OSHA_217_PSDI", + SourceCFR: "29 CFR §1910.217 (Ds = 63 in/s × Ts)", + License: "US Federal Public Domain (17 U.S.C. §105)", + LicenseRule: 1, + OriginalUnit: UnitInch, FormulaInchPerSecond: 63.0, FormulaMMPerSecond: 1600.2, FormulaDescription: "Hand-Speed-Konstante 63 in/s ≈ 1600 mm/s. " + @@ -170,3 +170,96 @@ func GetOSHAMinimumDistances() []MinimumDistance { }, } } + +// MinimumDistanceNote is the ready-to-print licensing posture for the +// minimum-distance reference — shown by the API so an auditor sees WHY the +// OSHA values may be reproduced while the EU norms are reference-only. +const MinimumDistanceNote = "OSHA-Werte (29 CFR 1910) sind US-Public-Domain " + + "(17 U.S.C. §105) und werden verbatim wiedergegeben; die mm-Umrechnung ist " + + "mathematisch, die sicherheitsseitige Rundung ist BreakPilot-Empfehlung. " + + "EU-Normen (EN ISO 13857/13854/13855, EN 349) werden nur per Kennung " + + "referenziert — keine Werte reproduziert." + +// GetMinimumDistanceByID returns the OSHA distance entry with the given ID. +func GetMinimumDistanceByID(id string) (MinimumDistance, bool) { + for _, md := range GetOSHAMinimumDistances() { + if md.ID == id { + return md, true + } + } + return MinimumDistance{}, false +} + +// MeasureDistanceLink connects a protective measure to the OSHA distance +// entries that anchor it. Relation makes the nature of the link explicit so +// the join is honest rather than implying every measure's prose IS the OSHA +// value: +// - "value_source" — the OSHA value is the source the measure's own +// mm figure is derived from (it appears in the measure prose). +// - "public_domain_crossref" — the measure is dimensioned by an EU norm; the +// OSHA entry is offered as the public-domain pendant for independent check. +type MeasureDistanceLink struct { + MeasureID string `json:"measure_id"` + DistanceIDs []string `json:"distance_ids"` + Relation string `json:"relation"` + Note string `json:"note,omitempty"` +} + +const ( + LinkValueSource = "value_source" + LinkCrossRef = "public_domain_crossref" +) + +// AllMeasureDistanceLinks returns the curated measure→OSHA-distance links. +// Conservative on purpose: only measures whose CONTEXT genuinely matches an +// OSHA entry are linked. Measures whose "OSHA" citation is loose or carries an +// ISO value (e.g. M340 robot teach speed, M368 air-receiver wall) are NOT +// linked — that would imply a public-domain anchor that does not exist. +func AllMeasureDistanceLinks() []MeasureDistanceLink { + return []MeasureDistanceLink{ + { + MeasureID: "M600", + DistanceIDs: []string{"MD_OSHA_217_PSDI"}, + Relation: LinkValueSource, + Note: "Hand-Speed-Konstante 1.600 mm/s (63 in/s) ist die Obergrenze, aus der die Kriechgeschwindigkeit am Endanschlag abgeleitet ist.", + }, + { + MeasureID: "M254", + DistanceIDs: []string{"MD_OSHA_O10_R1", "MD_OSHA_O10_R4"}, + Relation: LinkCrossRef, + Note: "OSHA Table O-10 (Point-of-Operation an mechanischen Pressen) als Public-Domain-Pendant zur ISO-13855-Methode — Werte eigenstaendig pruefen.", + }, + { + MeasureID: "M065", + DistanceIDs: []string{"MD_OSHA_212_FAN"}, + Relation: LinkCrossRef, + Note: "OSHA §1910.212(a)(5) Luefterschutz (max. 12 mm Spaltoeffnung) als Public-Domain-Pendant zu ISO 13857.", + }, + } +} + +// LinksForMeasure returns the distance links declared for one measure. +func LinksForMeasure(measureID string) []MeasureDistanceLink { + var out []MeasureDistanceLink + for _, l := range AllMeasureDistanceLinks() { + if l.MeasureID == measureID { + out = append(out, l) + } + } + return out +} + +// MinimumDistancesForMeasure resolves the OSHA distance entries linked to a +// protective measure. This is the join that finally lets the OSHA mm values +// "flow into" the measures (read-side), without mutating the measure object. +func MinimumDistancesForMeasure(measureID string) []MinimumDistance { + var out []MinimumDistance + for _, l := range LinksForMeasure(measureID) { + for _, id := range l.DistanceIDs { + if md, ok := GetMinimumDistanceByID(id); ok { + out = append(out, md) + } + } + } + return out +} diff --git a/ai-compliance-sdk/internal/iace/minimum_distances_test.go b/ai-compliance-sdk/internal/iace/minimum_distances_test.go new file mode 100644 index 00000000..4ca3db15 --- /dev/null +++ b/ai-compliance-sdk/internal/iace/minimum_distances_test.go @@ -0,0 +1,135 @@ +package iace + +import ( + "math" + "strconv" + "strings" + "testing" +) + +// These tests codify the May-built-but-never-verified OSHA minimum-distance +// library: that its public-domain values convert correctly, that the +// measure→distance links point at real measures and real distances, and that +// a "value_source" link's OSHA figure actually appears in the measure prose +// (the consistency the audit asked for). + +const inchToMM = 25.4 + +func TestOSHAMinimumDistances_ConversionAndLicense(t *testing.T) { + dists := GetOSHAMinimumDistances() + if len(dists) == 0 { + t.Fatal("OSHA minimum-distance library is empty") + } + seen := map[string]bool{} + for _, d := range dists { + if seen[d.ID] { + t.Errorf("duplicate distance ID %q", d.ID) + } + seen[d.ID] = true + + if d.License == "" || d.SourceCFR == "" || d.Context == "" { + t.Errorf("%s: missing license/source/context: %+v", d.ID, d) + } + + // Inch → mm conversions must be mathematically exact (within rounding). + if d.OriginalUnit == UnitInch { + checkConv := func(label string, orig, exact float64) { + if orig == 0 && exact == 0 { + return + } + if math.Abs(orig*inchToMM-exact) > 0.05 { + t.Errorf("%s: %s conversion off: %.3f in → %.3f mm (expected %.3f)", + d.ID, label, orig, exact, orig*inchToMM) + } + } + checkConv("value", d.OriginalValue, d.ExactMM) + checkConv("min", d.OriginalMin, d.ExactMinMM) + checkConv("max", d.OriginalMax, d.ExactMaxMM) + } + + // Safe-side rounding must stay near the exact value (≤5 mm grid). + if d.ExactMM > 0 && d.RecommendedMM > 0 { + if math.Abs(float64(d.RecommendedMM)-d.ExactMM) > 5 { + t.Errorf("%s: recommended %d mm too far from exact %.2f mm", + d.ID, d.RecommendedMM, d.ExactMM) + } + } + } +} + +func TestMeasureDistanceLinks_Integrity(t *testing.T) { + measures := map[string]ProtectiveMeasureEntry{} + for _, m := range GetProtectiveMeasureLibrary() { + measures[m.ID] = m + } + + links := AllMeasureDistanceLinks() + if len(links) == 0 { + t.Fatal("no measure→distance links declared") + } + for _, l := range links { + if _, ok := measures[l.MeasureID]; !ok { + t.Errorf("link references unknown measure %q", l.MeasureID) + } + if l.Relation != LinkValueSource && l.Relation != LinkCrossRef { + t.Errorf("link %q has invalid relation %q", l.MeasureID, l.Relation) + } + if len(l.DistanceIDs) == 0 { + t.Errorf("link %q has no distance IDs", l.MeasureID) + } + for _, id := range l.DistanceIDs { + if _, ok := GetMinimumDistanceByID(id); !ok { + t.Errorf("link %q references unknown distance %q", l.MeasureID, id) + } + } + // The resolver must return exactly the linked distances. + if got := len(MinimumDistancesForMeasure(l.MeasureID)); got != len(l.DistanceIDs) { + t.Errorf("resolver for %q returned %d, expected %d", l.MeasureID, got, len(l.DistanceIDs)) + } + } +} + +func TestMeasureDistanceLinks_ValueSourceProseConsistency(t *testing.T) { + measures := map[string]ProtectiveMeasureEntry{} + for _, m := range GetProtectiveMeasureLibrary() { + measures[m.ID] = m + } + + for _, l := range AllMeasureDistanceLinks() { + if l.Relation != LinkValueSource { + continue // cross-refs legitimately use an EU value in prose + } + m := measures[l.MeasureID] + text := strings.ToLower(m.Name + " " + m.Description + " " + strings.Join(m.Examples, " ")) + text = strings.ReplaceAll(text, ".", "") // "1.600" → "1600" + + for _, id := range l.DistanceIDs { + md, _ := GetMinimumDistanceByID(id) + if !proseMentionsDistance(text, md) { + t.Errorf("value_source measure %q does not mention any value of linked distance %q "+ + "— prose has drifted from the OSHA source", l.MeasureID, id) + } + } + } +} + +// proseMentionsDistance reports whether the (dot-stripped, lowercased) measure +// text contains a numeric form of the distance's value, formula or recommended mm. +func proseMentionsDistance(text string, md MinimumDistance) bool { + candidates := []int{} + if md.FormulaMMPerSecond > 0 { + candidates = append(candidates, int(math.Round(md.FormulaMMPerSecond))) + } + if md.RecommendedMM > 0 { + candidates = append(candidates, md.RecommendedMM) + } + if md.RecommendedMinMM > 0 { + candidates = append(candidates, md.RecommendedMinMM) + } + for _, n := range candidates { + if strings.Contains(text, strconv.Itoa(n)) { + return true + } + } + return false +}