feat(rci): Regulatory Change Intelligence foundation (delta over the stored map)
RCI/Delta as a read-/reasoning layer ON TOP of the product-first pipeline. Answers "what changes relative to my existing Regulatory Map?" — NOT "what does the new law say in general". No UI, no ingestion (newsletter/mailbox), no RAG, no new regulations/controls, no legal evaluation outside the stored map. - 4 core objects (compliance/rci/schemas.py): ComplianceBaseline (snapshot of profile + map + registry obligations + required/present evidence), RegulatoryChange (simulated/provided INPUT), ObligationDelta (delta_type NEW|CHANGED|REMOVED| ALREADY_COVERED|NEEDS_REVIEW|NOT_APPLICABLE), ChangeImpactSummary. delta_type is a THIRD vocabulary, disjoint from ClaimCoverage (Welt 1) and ComplianceStatus (Welt 2). - create_baseline() snapshots the existing pipeline once; assess_change() computes deltas deterministically against the snapshot (no re-evaluation). - 12 tests = the 5 acceptance questions (affects product? new/changed? already covered by evidence? needs human review? not relevant?) + repeal/uncertain-reg/ missing-evidence/boundary. Existing pipeline tests stay green; mypy clean; LOC ok. - App/reasoning types only — no compliance-meta-model classes (freeze v1.0 untouched). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
"""Regulatory Change Intelligence (RCI) — delta layer over the product-first map.
|
||||
|
||||
Answers "what changes relative to my existing Regulatory Map?" — NOT "what does
|
||||
the new law say in general". Snapshot the pipeline into a ComplianceBaseline, then
|
||||
assess a (simulated/provided) RegulatoryChange into per-obligation deltas + a
|
||||
management ChangeImpactSummary. Read/reasoning only — no UI, no ingestion, no RAG,
|
||||
no new regulations/controls, no legal evaluation outside the stored map.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .baseline import create_baseline
|
||||
from .delta_engine import assess_change
|
||||
from .schemas import (
|
||||
ChangeAssessment,
|
||||
ChangeImpactSummary,
|
||||
ChangeType,
|
||||
ComplianceBaseline,
|
||||
DeltaType,
|
||||
ObligationDelta,
|
||||
RegulatoryChange,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"create_baseline",
|
||||
"assess_change",
|
||||
"ComplianceBaseline",
|
||||
"RegulatoryChange",
|
||||
"ObligationDelta",
|
||||
"ChangeImpactSummary",
|
||||
"ChangeAssessment",
|
||||
"DeltaType",
|
||||
"ChangeType",
|
||||
]
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Snapshot the current product-first pipeline into a ComplianceBaseline.
|
||||
|
||||
This is the ONLY place RCI runs the pipeline — to freeze a point-in-time map +
|
||||
registry-linked obligations + their required evidence. Everything downstream
|
||||
(delta computation) works purely against this snapshot, never re-evaluating.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from compliance.profile.canonical import CanonicalProductRegulatoryProfile
|
||||
from compliance.profile.to_reasoning import to_reasoning_profile
|
||||
from compliance.reasoning.obligation_engine import derive_obligations
|
||||
from compliance.regulatory_map.renderer import render_regulatory_map
|
||||
|
||||
from .schemas import ComplianceBaseline
|
||||
|
||||
|
||||
def create_baseline(
|
||||
profile: CanonicalProductRegulatoryProfile,
|
||||
evidence_refs: Optional[Dict[str, List[str]]] = None,
|
||||
baseline_id: str = "baseline",
|
||||
created_at: Optional[str] = None,
|
||||
) -> ComplianceBaseline:
|
||||
reg_map = render_regulatory_map(profile)
|
||||
obligations = derive_obligations(to_reasoning_profile(profile)).applicable_obligations
|
||||
|
||||
applicable: List[str] = []
|
||||
required: Dict[str, List[str]] = {}
|
||||
for ob in obligations:
|
||||
if ob.registry_anchor: # only registry-linked obligations enter the baseline
|
||||
applicable.append(ob.obligation_id)
|
||||
required[ob.obligation_id] = list(ob.required_evidence)
|
||||
|
||||
return ComplianceBaseline(
|
||||
baseline_id=baseline_id,
|
||||
product_profile_snapshot=profile,
|
||||
regulatory_map_snapshot=reg_map,
|
||||
applicable_obligations=applicable,
|
||||
obligation_evidence_required=required,
|
||||
evidence_refs=dict(evidence_refs or {}),
|
||||
created_at=created_at,
|
||||
)
|
||||
@@ -0,0 +1,114 @@
|
||||
"""RCI delta engine — assess a RegulatoryChange against a ComplianceBaseline.
|
||||
|
||||
Answers "what changes relative to my existing Map?" deterministically, working
|
||||
ONLY against the stored baseline (no re-evaluation of scope, no new legal
|
||||
assessment outside the map). Per-obligation classification -> ObligationDelta;
|
||||
aggregate -> ChangeImpactSummary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Tuple
|
||||
|
||||
from compliance.reasoning.enums import Confidence
|
||||
|
||||
from .schemas import (
|
||||
ChangeAssessment,
|
||||
ChangeImpactSummary,
|
||||
ChangeType,
|
||||
ComplianceBaseline,
|
||||
DeltaType,
|
||||
ObligationDelta,
|
||||
RegulatoryChange,
|
||||
)
|
||||
|
||||
_ACTION = {DeltaType.NEW, DeltaType.CHANGED, DeltaType.NEEDS_REVIEW}
|
||||
|
||||
|
||||
def _classify(
|
||||
in_base: bool, has_ev: bool, change_type: ChangeType, rel_app: bool, rel_unc: bool
|
||||
) -> Tuple[DeltaType, str, Confidence]:
|
||||
if not (rel_app or rel_unc):
|
||||
return DeltaType.NOT_APPLICABLE, "Die Änderung betrifft kein Regelwerk Ihrer Map.", Confidence.HIGH
|
||||
if rel_unc and not rel_app:
|
||||
return (
|
||||
DeltaType.NEEDS_REVIEW,
|
||||
"Betrifft ein für Ihr Produkt noch UNSICHERES Regelwerk — erst Anwendbarkeit klären.",
|
||||
Confidence.LOW,
|
||||
)
|
||||
if change_type == ChangeType.REPEAL:
|
||||
if in_base:
|
||||
return DeltaType.REMOVED, "Regelwerk/Pflicht aufgehoben — entfällt für Ihr Produkt.", Confidence.HIGH
|
||||
return DeltaType.NOT_APPLICABLE, "Aufhebung betrifft keine Ihrer bestehenden Pflichten.", Confidence.HIGH
|
||||
if not in_base:
|
||||
return DeltaType.NEW, "Neue Pflicht durch die Änderung — bisher nicht in Ihrer Map.", Confidence.MEDIUM
|
||||
if change_type == ChangeType.GUIDANCE_UPDATE:
|
||||
if has_ev:
|
||||
return (
|
||||
DeltaType.ALREADY_COVERED,
|
||||
"Bestehende Pflicht mit vorhandenen Nachweisen — Leitlinien-Update vermutlich abgedeckt.",
|
||||
Confidence.MEDIUM,
|
||||
)
|
||||
return DeltaType.NEEDS_REVIEW, "Bestehende Pflicht ohne Nachweis — Leitlinien-Update prüfen.", Confidence.MEDIUM
|
||||
return DeltaType.CHANGED, "Bestehende Pflicht inhaltlich geändert — Umsetzung und Nachweis prüfen.", Confidence.MEDIUM
|
||||
|
||||
|
||||
def assess_change(baseline: ComplianceBaseline, change: RegulatoryChange) -> ChangeAssessment:
|
||||
snap = baseline.regulatory_map_snapshot
|
||||
app_regs = {v.regulation_id for v in snap.applicable_regulations}
|
||||
unc_regs = {v.regulation_id for v in snap.uncertain_regulations}
|
||||
base_obs = set(baseline.applicable_obligations)
|
||||
|
||||
affected = set(change.affected_regulations)
|
||||
rel_app = bool(affected & app_regs)
|
||||
rel_unc = bool(affected & unc_regs)
|
||||
affects_product = rel_app or rel_unc
|
||||
|
||||
deltas: List[ObligationDelta] = []
|
||||
for ob in change.affected_obligations:
|
||||
present = baseline.evidence_refs.get(ob, [])
|
||||
required = baseline.obligation_evidence_required.get(ob, [])
|
||||
dt, reason, conf = _classify(ob in base_obs, bool(present), change.change_type, rel_app, rel_unc)
|
||||
missing = [e for e in required if e not in present] if dt in _ACTION else []
|
||||
deltas.append(
|
||||
ObligationDelta(
|
||||
obligation_id=ob,
|
||||
delta_type=dt,
|
||||
reason=reason,
|
||||
affected_evidence=list(present),
|
||||
missing_evidence=missing,
|
||||
confidence=conf,
|
||||
)
|
||||
)
|
||||
|
||||
return ChangeAssessment(
|
||||
change_id=change.change_id,
|
||||
affects_product=affects_product,
|
||||
deltas=deltas,
|
||||
summary=_summary(deltas, [d.domain for d in snap.unsupported_domains]),
|
||||
)
|
||||
|
||||
|
||||
def _ids(deltas: List[ObligationDelta], *types: DeltaType) -> List[str]:
|
||||
wanted = set(types)
|
||||
return [d.obligation_id for d in deltas if d.delta_type in wanted]
|
||||
|
||||
|
||||
def _summary(deltas: List[ObligationDelta], unsupported: List[str]) -> ChangeImpactSummary:
|
||||
n_new = len(_ids(deltas, DeltaType.NEW))
|
||||
n_changed = len(_ids(deltas, DeltaType.CHANGED))
|
||||
n_removed = len(_ids(deltas, DeltaType.REMOVED))
|
||||
n_covered = len(_ids(deltas, DeltaType.ALREADY_COVERED))
|
||||
n_review = len(_ids(deltas, DeltaType.NEEDS_REVIEW, DeltaType.CHANGED))
|
||||
n_na = len(_ids(deltas, DeltaType.NOT_APPLICABLE))
|
||||
return ChangeImpactSummary(
|
||||
what_changed=(
|
||||
"%d neu, %d geändert, %d entfällt, %d bereits abgedeckt, %d zu prüfen, %d nicht relevant."
|
||||
% (n_new, n_changed, n_removed, n_covered, n_review, n_na)
|
||||
),
|
||||
what_matters_for_this_product=_ids(deltas, *_ACTION),
|
||||
already_covered=_ids(deltas, DeltaType.ALREADY_COVERED),
|
||||
needs_review=_ids(deltas, DeltaType.NEEDS_REVIEW, DeltaType.CHANGED),
|
||||
not_relevant=_ids(deltas, DeltaType.NOT_APPLICABLE),
|
||||
unsupported_domains=unsupported,
|
||||
)
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Regulatory Change Intelligence (RCI) — domain objects.
|
||||
|
||||
RCI is a read-/reasoning layer ON TOP of the product-first pipeline. It answers
|
||||
"what changes relative to my existing Regulatory Map?" — NOT "what does the new
|
||||
law say in general". A RegulatoryChange is simulated/provided INPUT (no ingestion,
|
||||
no newsletter/mailbox, no RAG); the delta is computed against a stored
|
||||
ComplianceBaseline (snapshot of the map).
|
||||
|
||||
`delta_type` is a THIRD vocabulary — distinct from `ClaimCoverage` (Welt 1, what
|
||||
the customer claims) and `ComplianceStatus` (Welt 2, verified evidence). The three
|
||||
must never be conflated. These are application/reasoning types, NOT
|
||||
compliance-meta-model classes (architecture freeze v1.0 untouched).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from compliance.profile.canonical import CanonicalProductRegulatoryProfile
|
||||
from compliance.reasoning.enums import AuthorityLevel, Confidence
|
||||
from compliance.regulatory_map.schemas import RegulatoryMap
|
||||
|
||||
|
||||
class DeltaType(str, Enum):
|
||||
NEW = "new" # obligation now applies that was not in the baseline
|
||||
CHANGED = "changed" # existing obligation substantively modified
|
||||
REMOVED = "removed" # obligation no longer applies (repeal)
|
||||
ALREADY_COVERED = "already_covered" # existing obligation, evidence likely suffices
|
||||
NEEDS_REVIEW = "needs_review" # a human must check
|
||||
NOT_APPLICABLE = "not_applicable" # change does not touch this product's map
|
||||
|
||||
|
||||
class ChangeType(str, Enum):
|
||||
NEW_REGULATION = "new_regulation"
|
||||
AMENDMENT = "amendment"
|
||||
REPEAL = "repeal"
|
||||
GUIDANCE_UPDATE = "guidance_update"
|
||||
|
||||
|
||||
# ── stored snapshot ──────────────────────────────────────────────────────
|
||||
class ComplianceBaseline(BaseModel):
|
||||
baseline_id: str
|
||||
product_profile_snapshot: CanonicalProductRegulatoryProfile
|
||||
regulatory_map_snapshot: RegulatoryMap
|
||||
applicable_obligations: List[str] = Field(default_factory=list) # registry-linked obligation_ids
|
||||
# required evidence per obligation (derived) — to compute missing_evidence
|
||||
obligation_evidence_required: Dict[str, List[str]] = Field(default_factory=dict)
|
||||
# evidence the customer ALREADY has, per obligation (provided)
|
||||
evidence_refs: Dict[str, List[str]] = Field(default_factory=dict)
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
# ── simulated/provided change (INPUT — never ingested) ───────────────────
|
||||
class RegulatoryChange(BaseModel):
|
||||
change_id: str
|
||||
source: str = "simulated"
|
||||
affected_regulations: List[str] = Field(default_factory=list)
|
||||
affected_obligations: List[str] = Field(default_factory=list)
|
||||
change_type: ChangeType
|
||||
effective_date: Optional[str] = None
|
||||
authority_level: AuthorityLevel = AuthorityLevel.LEGAL_TEXT
|
||||
summary: str = ""
|
||||
|
||||
|
||||
# ── per-obligation delta ─────────────────────────────────────────────────
|
||||
class ObligationDelta(BaseModel):
|
||||
obligation_id: str
|
||||
delta_type: DeltaType
|
||||
reason: str
|
||||
affected_evidence: List[str] = Field(default_factory=list) # evidence already present for it
|
||||
missing_evidence: List[str] = Field(default_factory=list) # required but not yet present
|
||||
confidence: Confidence
|
||||
|
||||
|
||||
# ── management-level summary ──────────────────────────────────────────────
|
||||
class ChangeImpactSummary(BaseModel):
|
||||
what_changed: str = ""
|
||||
what_matters_for_this_product: List[str] = Field(default_factory=list) # need action
|
||||
already_covered: List[str] = Field(default_factory=list)
|
||||
needs_review: List[str] = Field(default_factory=list)
|
||||
not_relevant: List[str] = Field(default_factory=list)
|
||||
unsupported_domains: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ChangeAssessment(BaseModel):
|
||||
change_id: str
|
||||
affects_product: bool
|
||||
deltas: List[ObligationDelta] = Field(default_factory=list)
|
||||
summary: ChangeImpactSummary
|
||||
Reference in New Issue
Block a user