From 662aec209a66eb87f88b4aea80a7e5cb5d0d162c Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Thu, 25 Jun 2026 09:30:00 +0200 Subject: [PATCH] feat(ai-sdk): foreign-framing proposer (P2 slice 4, type 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces fired patterns whose zone names terms the machine's narrative never mentions — foreign framing that leaks through terms not yet in domainGateTerms (once a term is a gate term, the ghost-pattern invariant already fences it out). - FindFramingCandidates (proposer_framing.go): per fired pattern, zone terms with no narrative echo (minus a generic hazard-location stoplist). Echo matching is bidirectional to survive German compounding (narrative "Steuerung" echoes zone "Steuerungssystem"). Heuristic verdict foreign (fully orphan) / plausible (partial). Over-surfaces by design — human/LLM is the precision filter. - Wired into iace-audit propose -> audit-reports/framing.{md,json}, threshold via IACE_FRAMING_MIN_ORPHAN (default 0.6). Honest finding: genuine wrong-MACHINE framing (Walzen, Transportbaender) no longer fires thanks to the machine-type gate; the residual is mostly cyber/control patterns with generic-industrial zone vocabulary, candidates for re-framing. Proposal types 3-4 (vocab->tag, coverage blind spots) remain for slice 5. Co-Authored-By: Claude Opus 4.7 --- ai-compliance-sdk/cmd/iace-audit/propose.go | 6 + .../internal/iace/proposer_framing.go | 154 ++++++++++++++++++ .../internal/iace/proposer_framing_test.go | 33 ++++ 3 files changed, 193 insertions(+) create mode 100644 ai-compliance-sdk/internal/iace/proposer_framing.go create mode 100644 ai-compliance-sdk/internal/iace/proposer_framing_test.go diff --git a/ai-compliance-sdk/cmd/iace-audit/propose.go b/ai-compliance-sdk/cmd/iace-audit/propose.go index 45667432..dfab7646 100644 --- a/ai-compliance-sdk/cmd/iace-audit/propose.go +++ b/ai-compliance-sdk/cmd/iace-audit/propose.go @@ -84,11 +84,17 @@ func cmdPropose(args []string) { writeText("audit-reports/proposals.md", iace.RenderProposalQueue(in.MachineType, proposals)) writeJSON("audit-reports/proposals.json", proposals) + // Type 2: foreign-framing candidates (zone terms with no narrative echo). + framing := iace.FindFramingCandidates(fired, in.Narrative, envFloat("IACE_FRAMING_MIN_ORPHAN", 0.6)) + writeText("audit-reports/framing.md", iace.RenderFramingQueue(in.MachineType, framing)) + writeJSON("audit-reports/framing.json", framing) + printSummary("Method P — Dedup Proposer ("+judge.Name()+")", map[string]int{ "fired_patterns": len(fired), "candidates": len(candidates), "in_queue": len(proposals), "gt_blocked": blocked, + "framing_flags": len(framing), }) if gt == nil { fmt.Fprintln(os.Stderr, "note: no ground truth provided — GT wall NOT applied (candidates not recall-screened)") diff --git a/ai-compliance-sdk/internal/iace/proposer_framing.go b/ai-compliance-sdk/internal/iace/proposer_framing.go new file mode 100644 index 00000000..687a2f40 --- /dev/null +++ b/ai-compliance-sdk/internal/iace/proposer_framing.go @@ -0,0 +1,154 @@ +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" +} diff --git a/ai-compliance-sdk/internal/iace/proposer_framing_test.go b/ai-compliance-sdk/internal/iace/proposer_framing_test.go new file mode 100644 index 00000000..0acbb29e --- /dev/null +++ b/ai-compliance-sdk/internal/iace/proposer_framing_test.go @@ -0,0 +1,33 @@ +package iace + +import "testing" + +func TestFindFramingCandidates_FlagsForeignZone(t *testing.T) { + narrative := "Gewerbliche Geschirrspuelmaschine mit Boiler und Tank. Die Tuer ist verriegelt." + fired := []PatternMatch{ + mkPM("HPforeign", "mechanical_hazard", "Walzen, Transportbaender, Bearbeitungszone", "Einzug", 80, nil, nil), + mkPM("HPlocal", "thermal_hazard", "Boiler, Tank, Tuer", "Verbrennung", 80, nil, nil), + mkPM("HPgeneric", "mechanical_hazard", "Quetschstelle, Gefahrbereich", "Quetschen", 80, nil, nil), + } + got := FindFramingCandidates(fired, narrative, 0.6) + if len(got) != 1 || got[0].Pattern != "HPforeign" { + t.Fatalf("want only HPforeign flagged, got %+v", got) + } + if got[0].Verdict != "foreign" { + t.Errorf("fully-orphan zone should be 'foreign', got %s", got[0].Verdict) + } +} + +func TestFindFramingCandidates_PartialEchoIsPlausible(t *testing.T) { + narrative := "Maschine mit Boiler und Tank." + fired := []PatternMatch{ + mkPM("HPx", "thermal_hazard", "Boiler, Tank, Auspuffleitung", "x", 80, nil, nil), + } + got := FindFramingCandidates(fired, narrative, 0.3) + if len(got) != 1 { + t.Fatalf("want 1 candidate (1/3 orphan >= 0.3), got %d", len(got)) + } + if got[0].Verdict != "plausible" || len(got[0].OrphanTerms) != 1 || got[0].OrphanTerms[0] != "auspuffleitung" { + t.Errorf("want plausible + orphan [auspuffleitung], got %s %v", got[0].Verdict, got[0].OrphanTerms) + } +}