Source code for lalandre_core.utils.regulatory_level
"""
Regulatory level inference from act metadata.
EU financial regulation follows a 3-level hierarchy:
1 (L1) — Framework legislation (Regulations, Directives) adopted by Parliament/Council
2 (L2) — Delegated/implementing acts (RTS, ITS) adopted by the Commission
3 (L3) — Supervisory guidance (Guidelines, Q&A, Recommendations) by ESA (EBA/ESMA/EIOPA)
"""
import re
from typing import Optional
_EURLEX_RE = re.compile(r"^3\d{4}([A-Z])\d{4}$")
_L2_TITLE_PATTERNS = re.compile(
r"commission\s+(?:delegated|implementing)"
r"|regulatory\s+technical\s+standard"
r"|implementing\s+technical\s+standard"
r"|\bRTS\b|\bITS\b",
re.IGNORECASE,
)
_ESA_PREFIX_RE = re.compile(r"^E(?:BA|SMA|IOPA)-", re.IGNORECASE)
_ESA_L2_RE = re.compile(r"\bITS\b|\bRTS\b|technical\s+standard", re.IGNORECASE)
_ESA_L3_RE = re.compile(
r"\bGL\b|guideline|recommendation|Q\s*&\s*A|\bopinion\b",
re.IGNORECASE,
)
LEVEL_LABELS = {1: "L1", 2: "L2", 3: "L3"}
[docs]
def infer_regulatory_level(
celex: str,
act_type: str,
title: Optional[str] = None,
form_number: Optional[str] = None,
) -> Optional[int]:
"""Infer regulatory level from act metadata.
Returns ``1`` (L1), ``2`` (L2), ``3`` (L3), or ``None`` (outside scope).
"""
if not celex:
return None
title = title or ""
form_number = form_number or ""
searchable = f"{title} {form_number}"
# ── EUR-Lex acts (3YYYYXNNNN) ────────────────────────────────────
m = _EURLEX_RE.match(celex)
if m:
type_letter = m.group(1)
if type_letter in ("R", "L"): # Regulation / Directive
# L2 override: Commission delegated/implementing acts
if _L2_TITLE_PATTERNS.search(searchable):
return 2
return 1
if type_letter == "D": # Decision — typically Commission implementing
return 2
if type_letter == "H": # Recommendation
return 3
# Other EUR-Lex type letters (rare): conservative
return None
# ── ESA documents (EBA-*, ESMA-*, EIOPA-*) ──────────────────────
if _ESA_PREFIX_RE.match(celex):
esa_searchable = f"{celex} {searchable}"
if _ESA_L2_RE.search(esa_searchable):
return 2
if _ESA_L3_RE.search(esa_searchable):
return 3
# Default ESA → L3 (majority of ESA output is guidance)
return 3
# ── AMF, Legifrance, others — outside Lamfalussy scope ──────────
return None
[docs]
def level_to_label(level: Optional[int]) -> Optional[str]:
"""Convert numeric level to display label: 1→'L1', 2→'L2', 3→'L3'."""
return LEVEL_LABELS.get(level) if level is not None else None