package iace import ( "fmt" "sort" "strings" ) // Foreign-framing proposer (P2 slice 4, type 2). DEV-TIME, propose-only. // // A pattern can fire for a machine yet describe its hazard with a zone text // framed for a DIFFERENT machine (e.g. a dishwasher hazard whose zone names // "Walzen, Transportbaender" or "Bearbeitungszone"). Such foreign framing leaks // through terms that are NOT yet in domainGateTerms — once a term is a gate term, // the ghost-pattern invariant already fences the pattern out. So we surface the // candidates structurally: zone terms a fired pattern names that the machine's // narrative never mentions (minus generic hazard-location vocabulary). A human // (or the LLM) then decides: add a dom_* gate term, or re-frame the zone text. // // This OVER-surfaces by design — the human/LLM is the precision filter, not the // detector (same contract as the dedup proposer). // genericHazardStop are hazard-LOCATION words that legitimately appear in zones // without being echoed in a narrative — they are not evidence of foreign framing. var genericHazardStop = map[string]bool{ "quetschstelle": true, "einzugstelle": true, "einzugsstelle": true, "scherstelle": true, "schneidstelle": true, "stossstelle": true, "fangstelle": true, "klemmstelle": true, "gefahrbereich": true, "gefahrenbereich": true, "gefahrstelle": true, "gefahrenstelle": true, "arbeitsbereich": true, "wirkbereich": true, "schutzbereich": true, "umgebung": true, "bereich": true, "zugang": true, "oberflaeche": true, "oberflaechen": true, "gehaeuse": true, "bauteil": true, "bauteile": true, "komponente": true, "maschine": true, } // FramingCandidate is a fired pattern whose zone text looks foreign for the machine. type FramingCandidate struct { Pattern string `json:"pattern"` Name string `json:"name"` Category string `json:"category"` Zone string `json:"zone"` OrphanTerms []string `json:"orphan_terms"` OrphanFraction float64 `json:"orphan_fraction"` Verdict string `json:"verdict"` // heuristic lean: foreign | plausible Evidence string `json:"evidence"` } // FindFramingCandidates returns fired patterns whose zone is mostly not echoed in // the narrative, sorted by orphan fraction descending (deterministic). func FindFramingCandidates(fired []PatternMatch, narrative string, minFraction float64) []FramingCandidate { nar := strings.ToLower(narrative) var narStems []string for _, w := range proposerWordSplit.Split(nar, -1) { if len([]rune(w)) >= 5 { narStems = append(narStems, w) } } var out []FramingCandidate for _, pm := range fired { parts := zoneParts(pm.ZoneDE) if len(parts) == 0 { continue } var orphans []string for _, p := range parts { if !partEchoed(p, nar, narStems) { orphans = append(orphans, p) } } frac := float64(len(orphans)) / float64(len(parts)) if len(orphans) == 0 || frac < minFraction { continue } out = append(out, FramingCandidate{ Pattern: pm.PatternID, Name: pm.PatternName, Category: primaryCat(pm), Zone: pm.ZoneDE, OrphanTerms: orphans, OrphanFraction: round2(frac), Verdict: framingHeuristicVerdict(frac), Evidence: fmt.Sprintf("%d/%d zone terms have no narrative echo: %s", len(orphans), len(parts), strings.Join(orphans, ", ")), }) } sort.SliceStable(out, func(i, j int) bool { if out[i].OrphanFraction != out[j].OrphanFraction { return out[i].OrphanFraction > out[j].OrphanFraction } return out[i].Pattern < out[j].Pattern }) return out } func framingHeuristicVerdict(frac float64) string { if frac >= 0.99 { return "foreign" // nothing in the zone is echoed by the narrative } return "plausible" // partial echo — likely generic vocabulary, human to confirm } // zoneParts splits a zone string into significant terms on commas, slashes, // parentheses and semicolons, lowercased, length >= 4. func zoneParts(zone string) []string { fields := strings.FieldsFunc(strings.ToLower(zone), func(r rune) bool { return r == ',' || r == '/' || r == ';' || r == '(' || r == ')' }) var out []string for _, f := range fields { if t := strings.TrimSpace(f); len([]rune(t)) >= 4 { out = append(out, t) } } return out } // partEchoed reports whether a zone part is reflected in the narrative. Matching // is bidirectional to survive German compounding: a zone word echoes if it is a // generic hazard term, if it is a substring of the narrative, OR if any narrative // stem (>= 5 chars) is a substring of the zone word (so narrative "Steuerung" // echoes zone "Steuerungssystem"). func partEchoed(part, narrative string, narStems []string) bool { for _, w := range strings.Fields(part) { if genericHazardStop[w] { return true } if len([]rune(w)) < 4 { continue } if strings.Contains(narrative, w) { return true } for _, ns := range narStems { if strings.Contains(w, ns) { return true } } } return false } // RenderFramingQueue renders foreign-framing candidates as a markdown review queue. func RenderFramingQueue(machine string, candidates []FramingCandidate) string { var b strings.Builder fmt.Fprintf(&b, "# Foreign-framing review queue — %s\n\n", machine) fmt.Fprintf(&b, "%d fired pattern(s) name zone terms the narrative never mentions. Propose-only — a human (or the LLM) decides: add a dom_* gate term, or re-frame the zone.\n\n", len(candidates)) for i, c := range candidates { fmt.Fprintf(&b, "## %d. %s — %s [%s, orphan %.0f%%]\n", i+1, c.Pattern, c.Name, c.Verdict, c.OrphanFraction*100) fmt.Fprintf(&b, "- category: %s\n- zone: %s\n", c.Category, c.Zone) fmt.Fprintf(&b, "- orphan terms (no narrative echo): %s\n", strings.Join(c.OrphanTerms, ", ")) fmt.Fprintf(&b, "- suggested action: %s\n\n", framingAction(c.Verdict)) } return b.String() } func framingAction(verdict string) string { if verdict == "foreign" { return "likely foreign-framed — propose a dom_* gate term for the orphan term(s), or re-frame the zone; human confirms + commits + pins a GT case" } return "partial echo — likely generic vocabulary; human to confirm whether any orphan term is a foreign-machine component" }