From 659b37cc21ac20b80c36ac9eef34ee00acc686fa Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Wed, 24 Jun 2026 13:07:22 +0200 Subject: [PATCH] =?UTF-8?q?feat(ai-sdk):=20source=5Frole=20control-pool=20?= =?UTF-8?q?=E2=80=94=20controls=20are=20not=20only=20technical=5Fstandard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live gate test showed control-intent (#36/#37) was inert for the EU cyber corpus: "Welche Controls passen zu Security Updates?" recalls ENISA good-practices (relevant measures, but source_class=supervisory_guidance) + binding regs, never NIST — so lifting technical_standard above binding did nothing. Per the finalized control-corpus model (User 2026-06-24): add source_role (functional role) ORTHOGONAL to source_class (legal authority). source_class still decides rank; source_role decides CONTROL-POOL membership. classifyRole derives 7 roles from markers (no re-tagging): obligation / operational_requirement / procedural_requirement / control_standard / implementation_guidance / interpretation / definition. Control-intent now boosts the control-pool (operational/procedural requirement, control standard, implementation guidance) over the abstract obligation, soft- ordered op_req > procedural > standard > guidance (controlPoolGain + role bonus) — replacing "lift technical_standard above binding". So CRA Annex I (operational_requirement) wins over NIST (control_standard) for "which measures", and ENISA (implementation_guidance) enters the pool while staying guidance. Recall of not-retrieved standards (NIST) for generic control queries = next step (searchControls). Tested: classifyRole table, role-preference, op_req-Top-1. Co-Authored-By: Claude Opus 4.7 --- .../internal/ucca/authority_rerank.go | 11 ++- .../internal/ucca/control_role.go | 94 +++++++++++++++++++ .../internal/ucca/control_role_test.go | 50 ++++++++++ .../internal/ucca/legal_rag_intent_test.go | 45 ++++----- 4 files changed, 166 insertions(+), 34 deletions(-) create mode 100644 ai-compliance-sdk/internal/ucca/control_role.go create mode 100644 ai-compliance-sdk/internal/ucca/control_role_test.go diff --git a/ai-compliance-sdk/internal/ucca/authority_rerank.go b/ai-compliance-sdk/internal/ucca/authority_rerank.go index e5cbf463..611b0111 100644 --- a/ai-compliance-sdk/internal/ucca/authority_rerank.go +++ b/ai-compliance-sdk/internal/ucca/authority_rerank.go @@ -120,21 +120,22 @@ func rerankByAuthority(query string, results []LegalSearchResult) []LegalSearchR qForeign := queryIsForeign(query) wantsGuidance := queryWantsGuidance(query) wantsControls := queryWantsControls(query) - bestBindingSem := bestBindingSemantic(results, wantsGuidance || wantsControls) + bestBindingSem := bestBindingSemantic(results, wantsGuidance) out := make([]LegalSearchResult, len(results)) copy(out, results) for i := range out { out[i].Score = authorityScore(query, out[i], qDomain, qForeign) } - // Explicit interpretation intent → a competitive guideline may outrank binding; - // explicit implementation intent → a competitive technical_standard may. Both lift - // ABOVE the best binding FINAL, so a pure norm question (neither intent) is untouched. + // Explicit interpretation intent → a competitive guideline may outrank binding (lift + // above the best binding FINAL). Explicit implementation intent → boost the CONTROL-POOL + // (operational/procedural requirement, control standard, implementation guidance) over + // the abstract obligation, soft-ordered by role. Norm questions (neither) stay untouched. if wantsGuidance { liftAboveBinding(out, results, bestBindingSem, "supervisory_guidance") } if wantsControls { - liftAboveBinding(out, results, bestBindingSem, "technical_standard") + applyControlRoles(out) } sort.SliceStable(out, func(a, b int) bool { return out[a].Score > out[b].Score diff --git a/ai-compliance-sdk/internal/ucca/control_role.go b/ai-compliance-sdk/internal/ucca/control_role.go new file mode 100644 index 00000000..fd2d7ece --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/control_role.go @@ -0,0 +1,94 @@ +package ucca + +import "strings" + +// source_role is the FUNCTIONAL role of a chunk — WHAT must be done (obligation), +// HOW to implement it (operational/procedural requirement, control standard, +// implementation guidance), or how to READ the norm (interpretation/definition). +// It is ORTHOGONAL to source_class (legal authority): source_class decides RANK, +// source_role decides CONTROL-POOL membership for implementation questions. +// Derived deterministically from markers, so the untagged corpus needs no re-tag. +const ( + roleObligation = "obligation" // the abstract duty (the WHAT) + roleOperationalReq = "operational_requirement" // concrete binding requirement (CRA Annex I) + roleProceduralReq = "procedural_requirement" // a process: notification/registration/DPIA/incident report + roleControlStandard = "control_standard" // best-practice control catalog (NIST/OWASP/ISO/CIS) + roleImplGuidance = "implementation_guidance" // advisory how-to (ENISA good practices, BSI) + roleInterpretation = "interpretation" // interprets the norm's MEANING (EDPB guideline) + roleDefinition = "definition" // definitions / scope / recitals +) + +var ( + proceduralMarkers = []string{ + "Meldung", "Meldepflicht", "Notification", "Notifizierung", "Registrierung", + "Registration", "Konformitätserklärung", "Declaration of Conformity", "Incident", + "Berichterstattung", "Reporting", "Folgenabschätzung", "DSFA", "DPIA", "Anzeigepflicht", + } + annexMarkers = []string{"Anhang", "Annex", "Appendix", "Anlage"} + operationalMarkers = []string{"Anforderung", "Requirement", "essential", "wesentliche"} + implMarkers = []string{ + "Good Practice", "Best Practice", "Standards Mapping", "Umsetzung", "Implementation", + "Handreichung", "Maßnahmenkatalog", "ICS", "SCADA", "Technical Guideline", "TIG", + } + definitionMarkers = []string{"Begriffsbestimmung", "Definition"} +) + +// classifyRole derives the functional source_role from chunk metadata + the authority +// class. technical_standard is always a control_standard; guidance splits into +// implementation_guidance (how-to) vs interpretation (meaning); binding splits into +// procedural / operational requirement / definition / plain obligation. +func classifyRole(r LegalSearchResult) string { + cls := classifyAuthority(r).sourceClass + hay := strings.ToLower(r.ArticleLabel + " " + r.RegulationShort + " " + r.RegulationName + " " + r.Article) + switch { + case r.IsRecital: + return roleDefinition + case cls == "technical_standard": + return roleControlStandard + case cls == "supervisory_guidance": + if containsAnyLower(hay, implMarkers) { + return roleImplGuidance + } + return roleInterpretation + case cls == "binding_law": + switch { + case containsAnyLower(hay, definitionMarkers): + return roleDefinition + case containsAnyLower(hay, proceduralMarkers): + return roleProceduralReq + case containsAnyLower(hay, annexMarkers) || containsAnyLower(hay, operationalMarkers): + return roleOperationalReq + default: + return roleObligation + } + default: + return roleObligation + } +} + +// controlRoleBonus is the soft intra-pool preference (User 2026-06-24): +// operational_requirement > procedural_requirement > control_standard > implementation_guidance. +var controlRoleBonus = map[string]float64{ + roleOperationalReq: 0.100, + roleProceduralReq: 0.075, + roleControlStandard: 0.050, + roleImplGuidance: 0.000, +} + +// controlPoolGain lifts EVERY control-pool role over the non-control roles (obligation/ +// interpretation/definition) on an implementation question, so the binding abstract +// obligation does not dominate by authority alone. The obligation is not removed — it +// stays visible as "Rechtsgrundlage" context below the recommended measures. +const controlPoolGain = 0.15 + +// applyControlRoles boosts the control-pool (the four implementation roles) for an +// EXPLICIT implementation question, soft-ordered op_req > procedural > standard > guidance. +// Replaces the earlier "lift technical_standard above binding" — controls are not only +// technical_standard, and the binding operational_requirement (e.g. CRA Annex I) should win. +func applyControlRoles(out []LegalSearchResult) { + for i := range out { + if bonus, ok := controlRoleBonus[classifyRole(out[i])]; ok { + out[i].Score += controlPoolGain + bonus + } + } +} diff --git a/ai-compliance-sdk/internal/ucca/control_role_test.go b/ai-compliance-sdk/internal/ucca/control_role_test.go new file mode 100644 index 00000000..b4872769 --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/control_role_test.go @@ -0,0 +1,50 @@ +package ucca + +import "testing" + +func TestClassifyRole(t *testing.T) { + tests := []struct { + name string + r LegalSearchResult + want string + }{ + {"NIST -> control_standard", LegalSearchResult{RegulationShort: "NIST SP 800-82r3", ArticleLabel: "AU-8"}, roleControlStandard}, + {"OWASP -> control_standard", LegalSearchResult{RegulationShort: "OWASP ASVS"}, roleControlStandard}, + {"CRA Anhang -> operational_requirement", LegalSearchResult{RegulationShort: "CRA", ArticleLabel: "CRA Anhang I", Category: "regulation"}, roleOperationalReq}, + {"CRA Meldepflicht -> procedural_requirement", LegalSearchResult{RegulationShort: "CRA", ArticleLabel: "Art. 14 CRA Meldepflicht", Category: "regulation"}, roleProceduralReq}, + {"ENISA Good Practices -> implementation_guidance", LegalSearchResult{RegulationShort: "ENISA Supply Chain Good Practices"}, roleImplGuidance}, + {"EDPB Leitlinie -> interpretation", LegalSearchResult{RegulationShort: "EDPB DPO", ArticleLabel: "WP243 Leitlinien Datenschutzbeauftragte"}, roleInterpretation}, + {"DORA article -> obligation", LegalSearchResult{RegulationShort: "DORA", ArticleLabel: "Art. 5 DORA", Category: "regulation"}, roleObligation}, + {"DSGVO Begriffsbestimmungen -> definition", LegalSearchResult{RegulationShort: "DSGVO", ArticleLabel: "Art. 4 DSGVO Begriffsbestimmungen", Category: "regulation"}, roleDefinition}, + {"recital -> definition", LegalSearchResult{RegulationShort: "CRA", IsRecital: true}, roleDefinition}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := classifyRole(tt.r); got != tt.want { + t.Errorf("classifyRole() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestApplyControlRoles_PoolPreference(t *testing.T) { + // op_req > procedural > control_standard > impl_guidance; non-control roles get no boost. + roles := []struct { + r LegalSearchResult + wantGain float64 + }{ + {LegalSearchResult{ArticleLabel: "CRA Anhang I", Category: "regulation"}, controlPoolGain + 0.100}, + {LegalSearchResult{ArticleLabel: "Art. 14 CRA Meldepflicht", Category: "regulation"}, controlPoolGain + 0.075}, + {LegalSearchResult{RegulationShort: "NIST SP 800-53"}, controlPoolGain + 0.050}, + {LegalSearchResult{RegulationShort: "ENISA Good Practices"}, controlPoolGain + 0.000}, + {LegalSearchResult{ArticleLabel: "Art. 5 DORA", Category: "regulation"}, 0.0}, // obligation: no boost + } + for _, rc := range roles { + out := []LegalSearchResult{rc.r} + out[0].Score = 1.0 + applyControlRoles(out) + if got := out[0].Score - 1.0; got < rc.wantGain-1e-9 || got > rc.wantGain+1e-9 { + t.Errorf("role %q: gain %.3f, want %.3f", classifyRole(rc.r), got, rc.wantGain) + } + } +} diff --git a/ai-compliance-sdk/internal/ucca/legal_rag_intent_test.go b/ai-compliance-sdk/internal/ucca/legal_rag_intent_test.go index 7c36c3d8..4005304a 100644 --- a/ai-compliance-sdk/internal/ucca/legal_rag_intent_test.go +++ b/ai-compliance-sdk/internal/ucca/legal_rag_intent_test.go @@ -96,20 +96,21 @@ func TestQueryWantsControls(t *testing.T) { } } -func TestRerank_ControlQuestion_StandardMayWin(t *testing.T) { - // Explicit implementation intent + standard semantically competitive → standard wins. +func TestRerank_ControlQuestion_OperationalReqTop(t *testing.T) { + // User priority for implementation questions: operational_requirement (binding concrete, + // CRA Anhang I) > control_standard (NIST). Both are in the control-pool; op_req wins. results := []LegalSearchResult{ - intentRes("NIST SP 800-82", "technical_standard", 0.62, 80), - intentRes("CRA", "binding_law", 0.58, 100), + {RegulationShort: "NIST SP 800-82r3", ArticleLabel: "AU-8", SourceClass: "technical_standard", AuthorityWeight: 80, Jurisdiction: "EU", Score: 0.60}, + {RegulationShort: "CRA", ArticleLabel: "CRA Anhang I", Category: "regulation", Score: 0.58}, } - out := rerankByAuthority("Welche Controls passen zu Security Updates?", results) - if out[0].SourceClass != "technical_standard" { - t.Errorf("control question: technical_standard should win Top-1, got %s", out[0].SourceClass) + out := rerankByAuthority("Welche Controls und Massnahmen passen zu Security Updates?", results) + if out[0].RegulationShort != "CRA" { + t.Errorf("operational_requirement (CRA Anhang I) should be Top-1 over control_standard, got %q", out[0].RegulationShort) } } func TestRerank_NormQuestion_BindingOverStandard(t *testing.T) { - // "Anforderungen" → no control intent → binding stays Top-1 over the standard. + // "Anforderungen" → no control intent → binding obligation stays Top-1 over the standard. results := []LegalSearchResult{ intentRes("NIST SP 800-82", "technical_standard", 0.62, 80), intentRes("CRA", "binding_law", 0.58, 100), @@ -120,29 +121,15 @@ func TestRerank_NormQuestion_BindingOverStandard(t *testing.T) { } } -func TestRerank_OffTopicStandard_BlockedByGuard(t *testing.T) { - // Control intent present, but the standard is semantically far below binding → - // the margin guard keeps binding Top-1 (no off-topic standard override). +func TestRerank_ControlQuestion_PoolBeatsBareObligation(t *testing.T) { + // A control-pool source (NIST control_standard) outranks an abstract obligation with no + // domain/topic advantage, because the implementation intent boosts the control-pool. results := []LegalSearchResult{ - intentRes("NIST SP 800-82", "technical_standard", 0.40, 80), - intentRes("CRA", "binding_law", 0.58, 100), + {RegulationShort: "NIST SP 800-82r3", ArticleLabel: "AU-8", SourceClass: "technical_standard", AuthorityWeight: 80, Jurisdiction: "EU", Score: 0.55}, + {RegulationShort: "XYZ", ArticleLabel: "Art. 5 XYZ", Category: "regulation", Score: 0.58}, } - out := rerankByAuthority("Welche Controls passen zu Security Updates?", results) - if out[0].SourceClass != "binding_law" { - t.Errorf("off-topic standard must not win even with control intent, got %s", out[0].SourceClass) - } -} - -func TestRerank_ControlQuestion_UntaggedNISTLifted(t *testing.T) { - // The existing NIST corpus is UNtagged (no source_class). It must still be classified - // technical_standard via markers and lifted on a control question — the whole reason - // the lift path classifies instead of trusting the raw payload field. - results := []LegalSearchResult{ - {RegulationShort: "NIST SP 800-82r3", ArticleLabel: "AU-8", Score: 0.62}, - {RegulationShort: "CRA", ArticleLabel: "Art. 13 CRA", Category: "regulation", Score: 0.58}, - } - out := rerankByAuthority("Welche Controls passen zu Security Updates?", results) + out := rerankByAuthority("Welche Controls und Massnahmen passen zu Security Updates?", results) if out[0].RegulationShort != "NIST SP 800-82r3" { - t.Errorf("untagged NIST should be lifted Top-1 on a control question, got %q", out[0].RegulationShort) + t.Errorf("control_standard should beat a bare abstract obligation on a control question, got %q", out[0].RegulationShort) } }