themis / phase1 /scripts /statute_crosswalk.py
vg15o2's picture
Moonley backend (HF Space build)
1d9bd9b
Raw
History Blame Contribute Delete
6.29 kB
"""Deterministic old/new Indian criminal-code section correspondences.
The source dataset records verified direct correspondences from the new codes to
the repealed codes. This module builds the reverse index as well, without
turning a one-to-many correspondence into a claim of legal equivalence.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
ACT_NAMES = {
"IPC": "Indian Penal Code, 1860",
"BNS": "Bharatiya Nyaya Sanhita, 2023",
"CRPC": "Code of Criminal Procedure, 1973",
"BNSS": "Bharatiya Nagarik Suraksha Sanhita, 2023",
"IEA": "Indian Evidence Act, 1872",
"BSA": "Bharatiya Sakshya Adhiniyam, 2023",
}
ACT_ALIASES = {
"IPC": "IPC",
"INDIAN PENAL CODE": "IPC",
"BNS": "BNS",
"BHARATIYA NYAYA SANHITA": "BNS",
"CRPC": "CRPC",
"CRIMINAL PROCEDURE CODE": "CRPC",
"CODE OF CRIMINAL PROCEDURE": "CRPC",
"BNSS": "BNSS",
"BHARATIYA NAGARIK SURAKSHA SANHITA": "BNSS",
"IEA": "IEA",
"INDIAN EVIDENCE ACT": "IEA",
"BSA": "BSA",
"BHARATIYA SAKSHYA ADHINIYAM": "BSA",
}
PAIRS = {
"IPC": "BNS",
"BNS": "IPC",
"CRPC": "BNSS",
"BNSS": "CRPC",
"IEA": "BSA",
"BSA": "IEA",
}
def normalise_act(value: object) -> str:
text = str(value or "").upper()
text = re.sub(r"\bI\.?\s*P\.?\s*C\.?", "IPC", text)
text = re.sub(r"\bCR\.?\s*P\.?\s*C\.?", "CRPC", text)
text = re.sub(r"\b(?:18|19|20)\d{2}\b", " ", text)
text = re.sub(r"[^A-Z0-9]+", " ", text)
return ACT_ALIASES.get(re.sub(r"\s+", " ", text).strip(), "")
def normalise_section(value: object) -> str:
text = re.sub(
r"^\s*(?:sections?|secs?\.?|ss?\.?)\s*[-:]*\s*",
"",
str(value or ""),
flags=re.IGNORECASE,
)
text = re.sub(r"\s+", "", text).upper().strip(".,;:")
return text if re.fullmatch(r"\d+[A-Z]*", text) else ""
class StatuteCrosswalk:
"""Load and query a verified crosswalk in either direction."""
def __init__(self, payload: dict[str, Any], *, source_path: Path | None = None):
mappings = payload.get("mappings")
if not isinstance(mappings, list):
raise ValueError("Crosswalk must contain a mappings list.")
self.schema_version = payload.get("schema_version")
self.description = str(payload.get("description") or "")
self.source = payload.get("source") if isinstance(payload.get("source"), dict) else {}
self.source_path = source_path
self._index: dict[tuple[str, str], list[dict[str, str]]] = {}
for item in mappings:
if not isinstance(item, dict):
continue
source = item.get("from") if isinstance(item.get("from"), dict) else {}
from_act = normalise_act(source.get("act"))
from_section = normalise_section(source.get("section"))
if not from_act or not from_section:
continue
for target in item.get("to") or []:
if not isinstance(target, dict):
continue
to_act = normalise_act(target.get("act"))
to_section = normalise_section(target.get("section"))
if not to_act or not to_section or PAIRS.get(from_act) != to_act:
continue
self._append(from_act, from_section, to_act, to_section)
self._append(to_act, to_section, from_act, from_section)
@classmethod
def from_file(cls, path: str | Path) -> "StatuteCrosswalk":
resolved = Path(path).expanduser().resolve()
return cls(json.loads(resolved.read_text(encoding="utf-8")), source_path=resolved)
def _append(self, from_act: str, from_section: str, to_act: str, to_section: str) -> None:
values = self._index.setdefault((from_act, from_section), [])
record = {"act": to_act, "section": to_section}
if record not in values:
values.append(record)
@property
def mapping_count(self) -> int:
return len(self._index)
def lookup(self, act: object, section: object) -> dict[str, Any]:
code = normalise_act(act)
number = normalise_section(section)
corresponding = [
{
**item,
"act_name": ACT_NAMES[item["act"]],
"label": f'{item["act"]} section {item["section"]}',
}
for item in self._index.get((code, number), [])
]
source_label = f"{code} {number}".strip()
target_label = ", ".join(f'{item["act"]} {item["section"]}' for item in corresponding)
table_key = "_".join(sorted((code, PAIRS.get(code, ""))))
source_tables = self.source.get("tables") if isinstance(self.source.get("tables"), dict) else {}
source_url = next(
(
value
for key, value in source_tables.items()
if set(key.split("_")) == {code, PAIRS.get(code, "")}
),
None,
)
return {
"found": bool(corresponding),
"from": source_label,
"to": target_label or None,
"query": {
"act": code,
"act_name": ACT_NAMES.get(code),
"section": number,
"label": f"{code} section {number}" if code and number else source_label,
},
"corresponding": corresponding,
"one_to_many": len(corresponding) > 1,
"direction": f"{code.lower()}_to_{PAIRS.get(code, '').lower()}" if code else None,
"source": {
"publisher": self.source.get("publisher"),
"url": source_url,
"table": table_key,
},
"notice": (
"These are direct statutory correspondences from the published crosswalk. "
"They are not a finding that the provisions are legally identical; compare the text and case law."
),
}
def default_crosswalk_path() -> Path:
return Path(__file__).resolve().parents[1] / "section_crosswalk.json"
def load_default_crosswalk(path: str | Path | None = None) -> StatuteCrosswalk:
return StatuteCrosswalk.from_file(path or default_crosswalk_path())