From d5925e57af3b43549d8157d27749863ef74830f6 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Fri, 26 Jun 2026 14:13:39 +0200 Subject: [PATCH] feat(ai-sdk): pin accepted proposer decisions into the GT gate (P3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a human accepts a proposer proposal, an AcceptedPin records a machine-scoped invariant — a pattern MUST fire (coverage/vocab→tag) or must NOT fire (dedup/framing) — that a test re-checks on every run. This makes the library's growth COMPOUND into the gate instead of eroding it: a change that re-introduces a dropped duplicate, un-gates a foreign pattern, or removes a coverage hazard breaks a pin and fails CI. One boolean covers all four proposal types. Seeded testdata/accepted_pins_warewashing.json with the accepted P1 supersessions (HP016/HP018/HP013 must NOT fire; their clean equivalents HP2201/HP144 must fire). TestWarewashing_AcceptedPins re-checks 5/5 against the live engine output; GenerateDedupPin turns an accepted dedup verdict into its pin. Co-Authored-By: Claude Opus 4.7 --- .../internal/iace/proposer_pin.go | 73 +++++++++++++++++++ .../internal/iace/proposer_pin_test.go | 63 ++++++++++++++++ .../testdata/accepted_pins_warewashing.json | 10 +++ 3 files changed, 146 insertions(+) create mode 100644 ai-compliance-sdk/internal/iace/proposer_pin.go create mode 100644 ai-compliance-sdk/internal/iace/proposer_pin_test.go create mode 100644 ai-compliance-sdk/internal/iace/testdata/accepted_pins_warewashing.json diff --git a/ai-compliance-sdk/internal/iace/proposer_pin.go b/ai-compliance-sdk/internal/iace/proposer_pin.go new file mode 100644 index 00000000..e883c6e9 --- /dev/null +++ b/ai-compliance-sdk/internal/iace/proposer_pin.go @@ -0,0 +1,73 @@ +package iace + +// P3: pin accepted proposer decisions into the GT gate. +// +// When a human accepts a proposal from the offline proposer (a dedup +// supersession, a foreign-framing gate, a vocab→tag mapping, a coverage hazard), +// they record an AcceptedPin. A pin is a tiny, machine-scoped invariant — "this +// pattern MUST (or must NOT) fire for this machine" — that a test re-checks on +// every run. This is what makes the library's growth COMPOUND into the gate +// instead of silently eroding it: a future change that re-introduces a dropped +// duplicate, un-gates a foreign pattern, or removes a coverage hazard breaks the +// pin and fails CI. +// +// A single boolean covers all four proposal types: +// - dedup supersession accepted → DropPattern MustFire=false +// - foreign-framing gate accepted → foreign pattern MustFire=false +// - vocab→tag / coverage hazard accepted → the enabled pattern MustFire=true + +// AcceptedPin is one regression invariant for an accepted proposal. +type AcceptedPin struct { + Pattern string `json:"pattern"` + MustFire bool `json:"must_fire"` + Reason string `json:"reason"` + FromProposal string `json:"from_proposal,omitempty"` +} + +// PinSet is the accepted-pin registry for one machine (testdata/accepted_pins_*.json). +type PinSet struct { + Machine string `json:"machine"` + Pins []AcceptedPin `json:"pins"` +} + +// PinResult is the verdict for one pin against an engine run. +type PinResult struct { + Pin AcceptedPin + OK bool + Detail string +} + +// VerifyPins checks every pin against the set of pattern IDs the engine actually +// fired for the machine. A pin holds iff the pattern's presence equals MustFire. +func VerifyPins(pins []AcceptedPin, firedPatternIDs []string) []PinResult { + fired := make(map[string]bool, len(firedPatternIDs)) + for _, id := range firedPatternIDs { + fired[id] = true + } + out := make([]PinResult, 0, len(pins)) + for _, p := range pins { + got := fired[p.Pattern] + ok := got == p.MustFire + detail := "ok" + if !ok { + if p.MustFire { + detail = "expected to fire but did NOT — coverage/mapping regressed" + } else { + detail = "expected to be suppressed but FIRED — gate/supersession regressed" + } + } + out = append(out, PinResult{Pin: p, OK: ok, Detail: detail}) + } + return out +} + +// GenerateDedupPin turns an accepted (verdict=duplicate) dedup candidate into the +// pin that protects the supersession: the dropped pattern must no longer fire. +func GenerateDedupPin(c DedupCandidate) AcceptedPin { + return AcceptedPin{ + Pattern: c.DropPattern, + MustFire: false, + Reason: "accepted duplicate of " + c.KeepPattern + " (" + c.Category + ")", + FromProposal: "dedup " + c.DropPattern + " -> " + c.KeepPattern, + } +} diff --git a/ai-compliance-sdk/internal/iace/proposer_pin_test.go b/ai-compliance-sdk/internal/iace/proposer_pin_test.go new file mode 100644 index 00000000..f2b0bbab --- /dev/null +++ b/ai-compliance-sdk/internal/iace/proposer_pin_test.go @@ -0,0 +1,63 @@ +package iace + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestVerifyPins(t *testing.T) { + pins := []AcceptedPin{ + {Pattern: "HPa", MustFire: true}, + {Pattern: "HPb", MustFire: false}, + } + res := VerifyPins(pins, []string{"HPa", "HPb"}) + if !res[0].OK { + t.Errorf("HPa must_fire=true and it fired -> should be OK") + } + if res[1].OK { + t.Errorf("HPb must_fire=false but it fired -> should be VIOLATED") + } + res2 := VerifyPins(pins, []string{}) + if res2[0].OK || !res2[1].OK { + t.Errorf("expected HPa violated + HPb ok, got %+v", res2) + } +} + +func TestGenerateDedupPin(t *testing.T) { + pin := GenerateDedupPin(DedupCandidate{KeepPattern: "HP144", DropPattern: "HP013", Category: "electrical_hazard"}) + if pin.Pattern != "HP013" || pin.MustFire { + t.Fatalf("want pin {HP013, must_fire=false}, got %+v", pin) + } +} + +// TestWarewashing_AcceptedPins re-checks every accepted P1 supersession against the +// live warewashing engine output. A future change that un-suppresses HP013/016/018 +// or drops HP2201/HP144 breaks a pin here — the gate compounds, not erodes. +func TestWarewashing_AcceptedPins(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("testdata", "accepted_pins_warewashing.json")) + if err != nil { + t.Fatalf("read pins: %v", err) + } + var ps PinSet + if err := json.Unmarshal(raw, &ps); err != nil { + t.Fatalf("parse pins: %v", err) + } + + _, _, kept := warewashingEngineOutput() + firedIDs := make([]string, 0, len(kept)) + for _, pm := range kept { + firedIDs = append(firedIDs, pm.PatternID) + } + + ok := 0 + for _, r := range VerifyPins(ps.Pins, firedIDs) { + if r.OK { + ok++ + continue + } + t.Errorf("PIN VIOLATED: %s (must_fire=%v) — %s [%s]", r.Pin.Pattern, r.Pin.MustFire, r.Detail, r.Pin.Reason) + } + t.Logf("accepted pins for %q: %d/%d hold", ps.Machine, ok, len(ps.Pins)) +} diff --git a/ai-compliance-sdk/internal/iace/testdata/accepted_pins_warewashing.json b/ai-compliance-sdk/internal/iace/testdata/accepted_pins_warewashing.json new file mode 100644 index 00000000..f013918f --- /dev/null +++ b/ai-compliance-sdk/internal/iace/testdata/accepted_pins_warewashing.json @@ -0,0 +1,10 @@ +{ + "machine": "Gewerbliche Untertisch-Geschirrspuelmaschine (vernetzt)", + "pins": [ + {"pattern": "HP016", "must_fire": false, "reason": "generic hot-surface (Formwerkzeuge/Auspuffleitung framing) superseded by HP2201", "from_proposal": "P1 thermal supersession"}, + {"pattern": "HP018", "must_fire": false, "reason": "actuator-burn superseded by HP2201", "from_proposal": "P1 thermal supersession"}, + {"pattern": "HP013", "must_fire": false, "reason": "stored-energy Batterie/USV framing superseded by HP144", "from_proposal": "P1 stored-energy supersession"}, + {"pattern": "HP2201", "must_fire": true, "reason": "warewashing hot-surface (Boiler/Tank/Spuelkammer) must remain — it is the clean equivalent that replaces HP016/HP018", "from_proposal": "P1 thermal supersession"}, + {"pattern": "HP144", "must_fire": true, "reason": "residual-voltage (Frequenzumrichter/Zwischenkreis) must remain — clean equivalent that replaces HP013", "from_proposal": "P1 stored-energy supersession"} + ] +}