Files
breakpilot-compliance/ai-compliance-sdk/internal/iace/proposer_dedup_test.go
T
Benjamin Admin 8674b2cd9a feat(ai-sdk): offline dedup-candidate proposer + deterministic GT wall (P2 slice 1)
First thin slice of the offline library-improvement proposer. DEV-TIME ONLY,
propose-only — it never mutates the pattern library or the runtime.

- FindDedupCandidates (proposer_dedup.go): structural near-duplicate detection
  over the fired patterns (category + measure/zone/scenario overlap). Bakes in
  the P1 lesson: only same-category pairs compare, and pairs with different
  operational states are never proposed (normal-operation vs maintenance are
  legitimately distinct, e.g. HP011 vs HP077).
- ScreenSupersession (proposer_screen.go): the wall. A proposal is safe only if
  (1) dropping the hazard does not reduce GT recall AND (2) keep/drop do not
  credit DIFFERENT GT entries. Check 2 catches distinct hazards that merely share
  measures (HP2201 hot surface GT 1.3 vs HP2202 hot ware GT 1.4) which recall
  alone would wave through.

On real warewashing output: 3 candidates -> 1 BLOCKED (distinct GT), 2
RECALL-SAFE for human/LLM review (the update + winding/friction near-dupes).
Nothing auto-applied. All 3 GTs unaffected (read-only). The LLM judgement and a
CLI/file queue are slice 2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 10:27:01 +02:00

68 lines
2.9 KiB
Go

package iace
import "testing"
func mkPM(id, cat, zone, scenario string, prio int, measures, opstates []string) PatternMatch {
return PatternMatch{
PatternID: id, PatternName: id, Priority: prio,
HazardCats: []string{cat}, ZoneDE: zone, ScenarioDE: scenario,
SuggestedMeasureIDs: measures, OperationalStates: opstates,
}
}
func TestFindDedupCandidates_FindsOverlappingPair(t *testing.T) {
fired := []PatternMatch{
mkPM("HPa", "update_failure", "Steuerung, SPS", "Software-Update der Steuerung scheitert nach Abbruch", 80,
[]string{"M138", "M146"}, nil),
mkPM("HPb", "update_failure", "Steuerung, Antriebsregler", "Software-Update der Steuerung schlaegt fehl", 75,
[]string{"M138", "M146", "M141"}, nil),
mkPM("HPc", "mechanical_hazard", "Tuer", "Quetschen der Finger an der Tuer", 70,
[]string{"M003"}, nil),
}
got := FindDedupCandidates(fired, 0.4)
if len(got) != 1 {
t.Fatalf("want 1 candidate, got %d: %+v", len(got), got)
}
// Higher-priority pattern survives, lower one is the drop target.
if got[0].KeepPattern != "HPa" || got[0].DropPattern != "HPb" {
t.Errorf("want keep HPa / drop HPb, got keep %s / drop %s", got[0].KeepPattern, got[0].DropPattern)
}
if got[0].DropName != "Software-Update der Steuerung schlaegt fehl" {
t.Errorf("DropName must equal drop pattern ScenarioDE, got %q", got[0].DropName)
}
}
func TestFindDedupCandidates_LifecycleGuard(t *testing.T) {
// Same category, zone and measures — but normal-operation vs maintenance.
// These are legitimate variants (HP011 vs HP077) and must NOT be proposed.
fired := []PatternMatch{
mkPM("HP011", "electrical_hazard", "Schaltschrank, Klemmenkasten", "Person beruehrt spannungsfuehrende Teile", 95,
[]string{"M481", "M482"}, nil),
mkPM("HP077", "electrical_hazard", "Schaltschrank, Klemmenkasten", "Person beruehrt spannungsfuehrende Teile", 80,
[]string{"M481", "M482"}, []string{"maintenance"}),
}
if got := FindDedupCandidates(fired, 0.4); len(got) != 0 {
t.Fatalf("lifecycle guard failed: want 0 candidates, got %d: %+v", len(got), got)
}
}
func TestFindDedupCandidates_DifferentCategoryIgnored(t *testing.T) {
fired := []PatternMatch{
mkPM("HPa", "thermal_hazard", "Boiler", "Heisse Oberflaeche am Boiler", 80, []string{"M071"}, nil),
mkPM("HPb", "mechanical_hazard", "Boiler", "Heisse Oberflaeche am Boiler", 80, []string{"M071"}, nil),
}
if got := FindDedupCandidates(fired, 0.3); len(got) != 0 {
t.Fatalf("cross-category pair must not be proposed, got %d", len(got))
}
}
func TestFindDedupCandidates_BelowThresholdDropped(t *testing.T) {
fired := []PatternMatch{
mkPM("HPa", "mechanical_hazard", "Tuer", "Quetschen an der Tuer", 80, []string{"M003"}, nil),
mkPM("HPb", "mechanical_hazard", "Foerderband", "Einzug am Foerderband", 80, []string{"M540"}, nil),
}
if got := FindDedupCandidates(fired, 0.4); len(got) != 0 {
t.Fatalf("disjoint pair must be below threshold, got %d: %+v", len(got), got)
}
}