feat(ai-sdk): foreign-framing proposer (P2 slice 4, type 2)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -84,11 +84,17 @@ func cmdPropose(args []string) {
|
|||||||
writeText("audit-reports/proposals.md", iace.RenderProposalQueue(in.MachineType, proposals))
|
writeText("audit-reports/proposals.md", iace.RenderProposalQueue(in.MachineType, proposals))
|
||||||
writeJSON("audit-reports/proposals.json", 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{
|
printSummary("Method P — Dedup Proposer ("+judge.Name()+")", map[string]int{
|
||||||
"fired_patterns": len(fired),
|
"fired_patterns": len(fired),
|
||||||
"candidates": len(candidates),
|
"candidates": len(candidates),
|
||||||
"in_queue": len(proposals),
|
"in_queue": len(proposals),
|
||||||
"gt_blocked": blocked,
|
"gt_blocked": blocked,
|
||||||
|
"framing_flags": len(framing),
|
||||||
})
|
})
|
||||||
if gt == nil {
|
if gt == nil {
|
||||||
fmt.Fprintln(os.Stderr, "note: no ground truth provided — GT wall NOT applied (candidates not recall-screened)")
|
fmt.Fprintln(os.Stderr, "note: no ground truth provided — GT wall NOT applied (candidates not recall-screened)")
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user