File size: 10,057 Bytes
1d9bd9b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
#!/usr/bin/env python3
"""Rebuild the citation graph from BODY TEXT — edges_v2.jsonl + case_aliases.json.

The legacy graph (edges.jsonl) parses only the headnote's "Case Law Cited" list:
measured ~3.6 edges/doc vs ~96.6 in-text citation mentions/doc (~4% capture). This
script regexes every chunk of every judgment for SCR / INSC / AIR-SC / SCC citations,
resolves them against the same normalized resolver the runtime uses
(neutral_citation + equivalent_citations), keeps the surrounding sentence as `para`
(the citing court's own description of the precedent — later embedded as the
citation-context retrieval arm), and merges the legacy edges so real treatment labels
survive.

Also emits case_aliases.json: for heavily-cited targets, the short name phrase that
most often precedes their citation in other judgments ("Kesavananda Bharati",
"Maneka Gandhi") -> doc_id, for known-item lookup of famous-name queries.

Run:  python phase1/scripts/build_citation_graph.py [data_dir]   (~5-10 min, CPU)
Out:  <data_dir>/edges_v2.jsonl, <data_dir>/case_aliases.json
Needs: corpus_ledger.jsonl (for sibling clusters; run build_ledger.py first).
"""
import json, os, re, sys, time
from collections import Counter, defaultdict

data_dir = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("THEMIS_DATA", "phase1/data/thor_artifacts")
t0 = time.time()

# ---------- resolvers (identical normalization to tools.py:66-68) ----------
meta = {}
for line in open(os.path.join(data_dir, "escr_meta.jsonl"), encoding="utf-8"):
    m = json.loads(line)
    meta.setdefault(m["doc_id"], m)
resolver = {}
for d, m in meta.items():
    for k in [m.get("neutral_citation")] + (m.get("equivalent_citations") or []):
        if not k: continue
        nk = re.sub(r"\s+", " ", k.replace(".", "")).strip().upper()
        resolver.setdefault(nk, d)
        # SUPP volumes are often printed WITHOUT the volume digit ("[1973] Supp. SCR 1"
        # for "[1973] SUPP. 1 S.C.R. 1") — register the volume-less variant too
        if " SUPP " in nk:
            resolver.setdefault(re.sub(r"(SUPP) \d+ (SCR)", r"\1 \2", nk), d)

cluster_of = {}
lp = os.path.join(data_dir, "corpus_ledger.jsonl")
if os.path.exists(lp):
    for line in open(lp, encoding="utf-8"):
        r = json.loads(line)
        if r.get("cluster_id"): cluster_of[r["doc_id"]] = r["cluster_id"]

# citation patterns; SUPP volumes included. INSC never appears in body text (modern
# administrative id), so the resolvable anchors are SCR (in meta) and, via the learned
# crosswalk below, SCC/AIR (the dominant forms in body text, absent from meta).
SCR_P = re.compile(r"\[\s*\d{4}\s*\]\s*(?:SUPP\.?\s*)?\d*\s*S\.?\s*C\.?\s*R\.?\s*\d+", re.I)
SCC_P = re.compile(r"\(\s*\d{4}\s*\)\s*\d+\s*S\.?C\.?C\.?\s*\d+", re.I)
AIR_P = re.compile(r"A\.?I\.?R\.?\s+\d{4}\s+S\.?C\.?\s+\d+", re.I)
CITE = re.compile(f"({SCR_P.pattern})|({SCC_P.pattern})|({AIR_P.pattern})", re.I)

def norm_key(s):
    return re.sub(r"\s+", " ", s.replace(".", "")).strip().upper()

# crosswalk learned from parallel citations printed side by side in judgments/headnotes
# ("... [1983] 1 SCR 145 : (1980) 2 SCC 684 : AIR 1980 SC 898 ...")
crosswalk = {}

def resolve(s):
    k = norm_key(s)
    return resolver.get(k) or crosswalk.get(k)

# alias: the full "Petitioner v. Respondent" name immediately before the citation;
# alias = the petitioner side (the memorable half: "Kesavananda Bharati", "Maneka Gandhi")
NAME_BEFORE = re.compile(
    r"([A-Z][A-Za-z.'&()-]*(?:\s+[A-Za-z.'&()-]+){0,6}\s+v\.?s?\.?\s+"
    r"[A-Z][A-Za-z.'&()-]*(?:\s+[A-Za-z.'&()-]+){0,5})\s*[,(\[]?\s*$")
_TITLE = re.compile(r"\b(shri|smt|sri|mst|dr|mr|mrs|ms|justice|his|holiness|m/s|the|state|union|of|india)\b\.?", re.I)
_AL_GENERIC = {"state", "union", "india", "government", "collector", "commissioner", "corporation",
               "municipal", "board", "authority", "bank", "company", "ltd", "limited", "co"}

# ---------- stream chunks grouped by doc ----------
edges_best = {}                 # (from,target) -> longest context
mentions = unresolved = 0
alias_votes = defaultdict(Counter)   # target -> Counter(alias phrase)
cur_doc, buf = None, []
pat = re.compile(r'"doc_id":\s*"([^"]+)"')

from collections import Counter as _Counter
crosswalk_votes = defaultdict(_Counter)
_SEP = re.compile(r"^[\s:;,=]*$")

def learn_crosswalk(text):
    """Parallel citations printed adjacently teach SCC/AIR -> doc mappings. Guarded:
    only pure separator chars between the two cites (dense citation LISTS put a case
    name between different cases' cites), majority vote + year sanity applied after."""
    anchors = [(m.start(), m.end(), resolver.get(norm_key(m.group(0)))) for m in SCR_P.finditer(text)]
    anchors = [(s, e, d) for s, e, d in anchors if d]
    for p in (SCC_P, AIR_P):
        for m in p.finditer(text):
            for s, e, d in anchors:
                if 0 <= m.start() - e <= 6 and _SEP.match(text[e:m.start()]):
                    crosswalk_votes[norm_key(m.group(0))][d] += 1; break
                if 0 <= s - m.end() <= 6 and _SEP.match(text[m.end():s]):
                    crosswalk_votes[norm_key(m.group(0))][d] += 1; break

def settle_crosswalk(decision_year):
    for k, votes in crosswalk_votes.items():
        (top, n), total = votes.most_common(1)[0], sum(votes.values())
        if n < 2 or n / total < 0.67: continue
        ym = re.search(r"\d{4}", k)
        dy = decision_year.get(top)
        if ym and dy and abs(int(ym.group(0)) - dy) > 2: continue     # AIR/SCC year must match the case
        crosswalk[k] = top

def petitioner_alias(full_name):
    pet = re.split(r"\s+v\.?s?\.?\s+", full_name, 1, flags=re.I)[0]
    pet = _TITLE.sub(" ", pet)
    words = [w.strip(".,'()&-") for w in pet.split()]
    words = [w for w in words if len(w) >= 3 and w[0].isupper() and w.lower() not in _AL_GENERIC
             and not (len(w) <= 3 and w.isupper())]              # drop initials like "K.S."
    if not 1 <= len(words) <= 3: return None
    a = " ".join(words)
    return a if len(a) >= 6 else None

def flush(doc, text):
    global mentions, unresolved
    if not doc or not text: return
    for m in CITE.finditer(text):
        mentions += 1
        tgt = resolve(m.group(0))
        if not tgt: unresolved += 1; continue
        if tgt == doc: continue
        if cluster_of.get(doc) and cluster_of.get(doc) == cluster_of.get(tgt): continue
        s, e = m.start(), m.end()
        ctx = re.sub(r"\s+", " ", text[max(0, s - 160):min(len(text), e + 60)]).strip()
        key = (doc, tgt)
        if key not in edges_best or len(ctx) > len(edges_best[key]):
            edges_best[key] = ctx
        nm = NAME_BEFORE.search(text[max(0, s - 90):s].strip())
        if nm:
            a = petitioner_alias(re.sub(r"\s+", " ", nm.group(1)))
            if a: alias_votes[tgt][a] += 1

def stream(handler):
    global cur_doc, buf
    cur_doc, buf = None, []
    with open(os.path.join(data_dir, "escr_chunks.jsonl"), encoding="utf-8") as f:
        for line in f:
            d = pat.search(line[:120]).group(1)
            if d != cur_doc:
                handler(cur_doc, " ".join(buf)); cur_doc, buf = d, []
            buf.append(json.loads(line)["text"])
    handler(cur_doc, " ".join(buf))

# pass 1: learn the SCC/AIR crosswalk from parallel citations
stream(lambda d, t: learn_crosswalk(t) if t else None)
_dy = {}
for d, m in meta.items():
    dt = m.get("date") or ""
    if dt[:4].isdigit(): _dy[d] = int(dt[:4])
settle_crosswalk(_dy)
print(f"[graph] crosswalk learned: {len(crosswalk)} SCC/AIR keys "
      f"(from {len(crosswalk_votes)} candidates), {time.time()-t0:.0f}s", flush=True)

# pass 2: extract edges + contexts + aliases with the full resolver
stream(flush)
print(f"[graph] body pass: {mentions} mentions, {len(edges_best)} unique edges, "
      f"{unresolved} unresolved ({unresolved/max(mentions,1):.0%}), {time.time()-t0:.0f}s", flush=True)

# ---------- merge legacy edges (keep their treatment labels) ----------
legacy_kept = 0
legacy = {}
for line in open(os.path.join(data_dir, "edges.jsonl"), encoding="utf-8"):
    e = json.loads(line)
    key = (e["from"], e["target"])
    legacy[key] = e
out = os.path.join(data_dir, "edges_v2.jsonl")
with open(out, "w", encoding="utf-8") as f:
    for key, ctx in edges_best.items():
        le = legacy.pop(key, None)
        treatment = (le or {}).get("treatment") or "cited"
        f.write(json.dumps({"from": key[0], "target": key[1], "treatment": treatment,
                            "method": "body", "para": ctx}, ensure_ascii=False) + "\n")
    for key, e in legacy.items():                       # headnote-only edges body regex missed
        legacy_kept += 1
        f.write(json.dumps({"from": key[0], "target": key[1],
                            "treatment": e.get("treatment") or "cited",
                            "method": "headnote", "para": e.get("para") or ""},
                           ensure_ascii=False) + "\n")
n_edges = len(edges_best) + legacy_kept
print(f"[graph] wrote {n_edges} edges ({len(edges_best)} body + {legacy_kept} headnote-only) "
      f"-> {out} | avg {n_edges/len(meta):.1f}/doc (was 3.6)", flush=True)

# ---------- aliases ----------
indeg = Counter()
for (f_, t_) in edges_best: indeg[t_] += 1
aliases, alias_votes_n = {}, {}
for tgt, votes in alias_votes.items():
    if indeg.get(tgt, 0) < 8: continue
    phrase, n = votes.most_common(1)[0]
    if n >= 4 and len(phrase) >= 6:
        key = phrase.lower()
        if key not in aliases or n > alias_votes_n.get(key, 0):
            aliases[key] = tgt; alias_votes_n[key] = n
with open(os.path.join(data_dir, "case_aliases.json"), "w", encoding="utf-8") as f:
    json.dump(aliases, f, ensure_ascii=False, indent=1)
print(f"[graph] aliases: {len(aliases)} -> case_aliases.json | {time.time()-t0:.0f}s total", flush=True)
for probe in ("kesavananda bharati", "maneka gandhi", "bachan singh"):
    hits = {k: v for k, v in aliases.items() if probe.split()[0] in k}
    if hits: print("   probe:", dict(list(hits.items())[:3]), flush=True)