8674b2cd9a
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>
68 lines
2.9 KiB
Go
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)
|
|
}
|
|
}
|