feat(regulatory-map): customer-readable read-model over the scope (step 4)
The Map Renderer explains the engine's state, it does not extend it. Pure composition of resolve_product_scope (scope verdict) + derive_obligations (registry-linked obligations + overlaps) into one RegulatoryMap. - product_summary, trigger_facts, applicable/uncertain/excluded regulations, unsupported_domains, overlaps (shared_obligations), shared_evidence, and a customer-readable executive_summary. - No own legal decisions: applicable/uncertain mirror the scope verdict exactly. - Obligations shown ONLY when registry-linkable (registry_anchor) — MaschinenVO/ EMV obligations are proposed, so they render empty + a note, never as linked. Overlaps/shared_evidence likewise filtered to registry-linked members. - Uncertain regulations link to the navigator question that would resolve them (RED -> has_radio_module, DataAct -> generates_usage_data). - Environmental appears only as unsupported_domain; executive_summary has NO percentage (counts + "no further regulations identified" instead). - POST /reasoning/regulatory-map (thin handler). Response types are presentation- level, not meta-model classes (freeze v1.0 untouched). - 9 tests; 56 green (existing reasoning MVP stays green), mypy clean, LOC ok. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
"""Tests for the Regulatory Map renderer (step 4).
|
||||
|
||||
Acceptance: the renderer makes no own legal decisions (it composes the scope +
|
||||
registry-linked obligations); CRA/MaschVO/EMV are separate; RED/DataAct/NIS2 are
|
||||
uncertain; environmental is unsupported (not applicable); obligations appear only
|
||||
when registry-linkable; the executive summary has no percentage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from compliance.product_scope import resolve_product_scope
|
||||
from compliance.profile.canonical import (
|
||||
CanonicalLifecyclePhase,
|
||||
CanonicalProductRegulatoryProfile,
|
||||
CanonicalProductType,
|
||||
EconomicOperatorRole,
|
||||
EnvironmentalImpact,
|
||||
)
|
||||
from compliance.regulatory_map import render_regulatory_map
|
||||
|
||||
_PROPOSED_IDS = {
|
||||
"machine_risk_assessment", "machine_safety_control_systems", "machine_protection_against_corruption",
|
||||
"machine_instructions_for_use", "machine_ce_conformity", "data_act_data_access_by_design",
|
||||
"data_act_user_data_access", "cra_secure_by_design", "cra_risk_assessment",
|
||||
"cra_technical_documentation", "cra_ce_conformity_assessment", "cra_instructions_for_use",
|
||||
}
|
||||
|
||||
|
||||
def ready_profile(**ov) -> CanonicalProductRegulatoryProfile:
|
||||
base = dict(
|
||||
name="Industriespülmaschine",
|
||||
product_type=CanonicalProductType.MACHINERY,
|
||||
markets=["EU", "DE"],
|
||||
economic_operator_role=EconomicOperatorRole.MANUFACTURER,
|
||||
lifecycle_phase=CanonicalLifecyclePhase.PLACING_ON_MARKET,
|
||||
is_machine=True,
|
||||
is_component=False,
|
||||
has_software_updates=True,
|
||||
has_embedded_software=True,
|
||||
has_remote_access=True,
|
||||
technologies=["cloud", "ota_updates"],
|
||||
)
|
||||
base.update(ov)
|
||||
return CanonicalProductRegulatoryProfile(**base)
|
||||
|
||||
|
||||
# 1. renderer makes no own decisions — it mirrors the scope verdict exactly.
|
||||
def test_no_own_legal_decisions():
|
||||
p = ready_profile()
|
||||
m = render_regulatory_map(p)
|
||||
scope = resolve_product_scope(p).regulatory_scope
|
||||
assert {v.regulation_id for v in m.applicable_regulations} == {
|
||||
r.regulation_id for r in scope.applicable_regulations
|
||||
}
|
||||
assert {v.regulation_id for v in m.uncertain_regulations} == {
|
||||
r.regulation_id for r in scope.uncertain_regulations
|
||||
}
|
||||
|
||||
|
||||
# 2/3/5. CRA/MaschVO/EMV separate applicable; RED/DataAct/NIS2 uncertain.
|
||||
def test_regulation_separation():
|
||||
m = render_regulatory_map(ready_profile())
|
||||
applicable = {v.regulation_id for v in m.applicable_regulations}
|
||||
uncertain = {v.regulation_id for v in m.uncertain_regulations}
|
||||
assert {"CRA", "MaschinenVO", "EMV"} <= applicable
|
||||
assert {"RED", "DataAct", "NIS2"} <= uncertain
|
||||
|
||||
|
||||
# 4. environmental triggers surface as unsupported_domain, never applicable.
|
||||
def test_environmental_unsupported_not_applicable():
|
||||
p = ready_profile(environmental=EnvironmentalImpact(discharges_to_wastewater=True, uses_cleaning_chemicals=True))
|
||||
m = render_regulatory_map(p)
|
||||
domains = {d.domain for d in m.unsupported_domains}
|
||||
assert "environment_water" in domains and "chemicals" in domains
|
||||
assert all(v.regulation_id in {"CRA", "MaschinenVO", "RED", "DataAct", "EMV", "NIS2"} for v in m.applicable_regulations)
|
||||
|
||||
|
||||
# 6. obligations are shown only when a registry id is linkable.
|
||||
def test_obligations_only_registry_linkable():
|
||||
m = render_regulatory_map(ready_profile())
|
||||
shown = {o.obligation_id for v in m.applicable_regulations for o in v.obligations}
|
||||
assert shown # CRA registry obligations are shown
|
||||
assert "sbom_creation" in shown
|
||||
assert not (shown & _PROPOSED_IDS) # no proposed (non-registry) obligation leaks in
|
||||
# MaschinenVO is applicable but its obligations are proposed -> empty + note
|
||||
machvo = next(v for v in m.applicable_regulations if v.regulation_id == "MaschinenVO")
|
||||
assert machvo.obligations == []
|
||||
assert machvo.obligations_note
|
||||
|
||||
|
||||
# 7. executive summary contains no percentage.
|
||||
def test_executive_summary_no_percent():
|
||||
m = render_regulatory_map(ready_profile())
|
||||
assert "%" not in m.executive_summary
|
||||
assert "prozent" not in m.executive_summary.lower()
|
||||
|
||||
|
||||
# 8. output is customer-readable and structured.
|
||||
def test_customer_readable():
|
||||
m = render_regulatory_map(ready_profile())
|
||||
assert m.product_summary
|
||||
assert "wahrscheinlich" in m.executive_summary
|
||||
assert "Unsicher" in m.executive_summary
|
||||
assert m.trigger_facts
|
||||
|
||||
|
||||
# needs-facts profile -> map says scope not yet resolved.
|
||||
def test_needs_facts_map():
|
||||
m = render_regulatory_map(CanonicalProductRegulatoryProfile(name="X"))
|
||||
assert m.scope_resolved is False
|
||||
assert "Mindestfakten" in m.executive_summary
|
||||
assert m.applicable_regulations == []
|
||||
|
||||
|
||||
# uncertain RED links to the radio navigator question.
|
||||
def test_uncertain_links_to_navigator_question():
|
||||
m = render_regulatory_map(ready_profile())
|
||||
red = next(v for v in m.uncertain_regulations if v.regulation_id == "RED")
|
||||
assert "has_radio_module" in red.question_refs
|
||||
|
||||
|
||||
# endpoint smoke.
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
from compliance.api.reasoning_routes import router
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_endpoint_regulatory_map(client):
|
||||
r = client.post(
|
||||
"/reasoning/regulatory-map",
|
||||
json={
|
||||
"product_profile": {
|
||||
"name": "M",
|
||||
"product_type": "machinery",
|
||||
"markets": ["EU"],
|
||||
"economic_operator_role": "manufacturer",
|
||||
"lifecycle_phase": "placing_on_market",
|
||||
"is_machine": True,
|
||||
"is_component": False,
|
||||
"has_software_updates": True,
|
||||
"has_embedded_software": True,
|
||||
"has_remote_access": True,
|
||||
"technologies": ["cloud"],
|
||||
}
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["scope_resolved"] is True
|
||||
assert {v["regulation_id"] for v in body["applicable_regulations"]} >= {"CRA", "MaschinenVO"}
|
||||
assert "%" not in body["executive_summary"]
|
||||
Reference in New Issue
Block a user