156 lines
7.7 KiB
Python
156 lines
7.7 KiB
Python
#!/usr/bin/env python3
|
|
"""exp15 — trap curation -> ledger (design §D1). Takes mined candidates + A0 greedy boundaries and:
|
|
- locates each candidate's anchor & dependent spans in S2' (exact .find; drop if not locatable)
|
|
- COMMON DENOMINATOR: keep candidates whose dependency crosses an A0 greedy cut (A0 might fail them);
|
|
record whether it ALSO crosses a scene-shift seam (covariate, not filter)
|
|
- stratify by antecedent distance: <=3 sentences (carryover zone) vs >3 (window/Ф2 zone)
|
|
- dedupe near-identical spans; assign type; report quota status (T-ana/T-ent/T-tense >=20, others >=10)
|
|
- emit the trap ledger with per-trap EXPECTED BEHAVIOR (recorded BEFORE any arm/judge spend)
|
|
|
|
Output is a REVIEW ARTIFACT for the checkpoint (manual curation follows: the polygon signs off each trap).
|
|
Run: python trap_curate.py
|
|
"""
|
|
from __future__ import annotations
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
from chunker import split_source_sentences, greedy_chunks
|
|
|
|
S2 = Path("/home/ubuntu/books/gu-zhenren/exp15/S2prime.txt")
|
|
CANDS = Path("/home/ubuntu/books/gu-zhenren/exp15/trap_candidates.json")
|
|
SEAMS = Path("/home/ubuntu/books/gu-zhenren/exp15/S2prime_seams.json")
|
|
ANCHORS = Path("/home/ubuntu/books/gu-zhenren/exp15/anchors/anchors_analysis.json")
|
|
LABELS = Path("/home/ubuntu/books/gu-zhenren/exp15/S2prime_seam_labels.json")
|
|
OUT = Path("/home/ubuntu/books/gu-zhenren/exp15/trap_ledger.json")
|
|
|
|
QUOTA = {"T-ana": 20, "T-ent": 20, "T-tense": 20, "T-cat": 10, "T-ell": 10, "T-gen": 10, "T-reg": 10}
|
|
|
|
|
|
def sentence_index(text):
|
|
"""Return list of (start_rune, end_rune) for each source sentence, for distance computation."""
|
|
spans, cur = [], 0
|
|
for s in split_source_sentences(text):
|
|
spans.append((cur, cur + len(s)))
|
|
cur += len(s)
|
|
return spans
|
|
|
|
|
|
def sentence_of_offset(spans, off):
|
|
for i, (a, b) in enumerate(spans):
|
|
if a <= off < b:
|
|
return i
|
|
return len(spans) - 1
|
|
|
|
|
|
def main():
|
|
text = S2.read_text(encoding="utf-8")
|
|
spans = sentence_index(text)
|
|
seams = json.load(open(SEAMS))["seams"]
|
|
seam_offs = {s["seam_id"]: s["rune_offset"] for s in seams}
|
|
labels = json.load(open(LABELS))["labels"]
|
|
# A0 (flat, blind-greedy) cut offsets in S2prime.txt SPACE — locate each greedy chunk head in `text`
|
|
# so cuts and trap spans (also located via text.find) share one coordinate system.
|
|
gch = greedy_chunks(text)
|
|
a0_cuts, pos = [], 0
|
|
for i, c in enumerate(gch):
|
|
idx = text.find(c[:25], max(0, pos - 5))
|
|
if idx < 0:
|
|
idx = text.find(c[:25])
|
|
if i > 0 and idx >= 0:
|
|
a0_cuts.append(idx)
|
|
pos = idx + len(c) if idx >= 0 else pos + len(c)
|
|
cands_raw = json.load(open(CANDS))["results"] if CANDS.exists() else []
|
|
|
|
ledger = []
|
|
dropped = {"not_located": 0, "no_a0_cross": 0, "dup": 0}
|
|
seen_keys = set()
|
|
for res in cands_raw:
|
|
for c in res.get("candidates", []):
|
|
a_span = (c.get("anchor_span") or "").strip()
|
|
d_span = (c.get("dependent_span") or "").strip()
|
|
if not a_span or not d_span:
|
|
dropped["not_located"] += 1
|
|
continue
|
|
ai = text.find(a_span[:30])
|
|
di = text.find(d_span[:30])
|
|
if ai < 0 or di < 0:
|
|
dropped["not_located"] += 1
|
|
continue
|
|
a_end = ai + len(a_span)
|
|
# dependency region = [lo, hi); a cut at offset c splits it iff lo < c < hi STRICTLY (a cut
|
|
# exactly at hi lands right AFTER the dependency -> A0 kept it intact -> NOT crossing). BUG1 fix.
|
|
lo, hi = min(ai, di), max(a_end, di + len(d_span))
|
|
crosses_a0 = any(lo < cut < hi for cut in a0_cuts)
|
|
crossed_cuts = [cut for cut in a0_cuts if lo < cut < hi]
|
|
crosses_seam = [sid for sid, off in seam_offs.items() if lo < off < hi]
|
|
# BUG2 fix: dedup on span PROXIMITY (same type + anchor & dependent within 15 runes of a kept
|
|
# trap), not a fixed 20-rune grid (grid mis-buckets across edges -> false keep AND false drop).
|
|
typ = c.get("type")
|
|
if any(t["type"] == typ and abs(t["anchor_offset"] - ai) < 15 and abs(t["dependent_offset"] - di) < 15
|
|
for t in ledger):
|
|
dropped["dup"] += 1
|
|
continue
|
|
# antecedent distance in sentences
|
|
si_a = sentence_of_offset(spans, ai)
|
|
si_d = sentence_of_offset(spans, di)
|
|
dist = abs(si_d - si_a)
|
|
stratum = "near(<=3)" if dist <= 3 else "far(>3)"
|
|
seam_classes = [labels[str(s)]["class"] for s in crosses_seam if str(s) in labels]
|
|
ledger.append({
|
|
"id": f"{c.get('type')}-{len(ledger)+1}",
|
|
"type": c.get("type"),
|
|
"seam_id": c.get("seam_id"),
|
|
"anchor_span": a_span, "dependent_span": d_span,
|
|
"anchor_offset": ai, "dependent_offset": di,
|
|
"antecedent_distance_sentences": dist, "stratum": stratum,
|
|
"crosses_A0_cut": crosses_a0, "crossed_A0_cut_offsets": crossed_cuts,
|
|
"crosses_seam_ids": crosses_seam, "seam_classes": seam_classes,
|
|
"phenomenon": c.get("phenomenon"), "expected_ru": c.get("expected_ru"),
|
|
"failure_if_split": c.get("failure_if_split"),
|
|
# expected behavior (pre-registered before scoring):
|
|
"expected_behavior": {
|
|
"A0_at_risk": crosses_a0, # A0's greedy cut splits the dependency -> may fail
|
|
"logical_helps_if": "cut aligns to scene-shift seam and keeps the dependency intact"
|
|
if any(cl == "scene-shift" for cl in seam_classes) else
|
|
"avoids cutting a continuation seam mid-dependency"
|
|
if any(cl == "continuation" for cl in seam_classes) else
|
|
"boundary alignment orthogonal to this trap (weak Q1 discriminator)",
|
|
},
|
|
})
|
|
|
|
# quota report
|
|
from collections import Counter
|
|
by_type_all = Counter(t["type"] for t in ledger)
|
|
by_type_cross = Counter(t["type"] for t in ledger if t["crosses_A0_cut"])
|
|
quota_status = {}
|
|
for typ, need in QUOTA.items():
|
|
have_cross = by_type_cross.get(typ, 0)
|
|
quota_status[typ] = {"need": need, "have_all": by_type_all.get(typ, 0),
|
|
"have_crossing_A0": have_cross, "meets_quota": have_cross >= need}
|
|
strata = Counter((t["type"], t["stratum"]) for t in ledger if t["crosses_A0_cut"])
|
|
result = {
|
|
"n_candidates_mined": sum(len(r.get("candidates", [])) for r in cands_raw),
|
|
"n_in_ledger": len(ledger), "dropped": dropped,
|
|
"a0_cut_offsets": a0_cuts,
|
|
"quota_status": quota_status,
|
|
"crossing_A0_by_type": dict(by_type_cross),
|
|
"strata_crossing_A0": {f"{k[0]}/{k[1]}": v for k, v in strata.items()},
|
|
"ledger": ledger,
|
|
}
|
|
OUT.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
print(f"candidates mined: {result['n_candidates_mined']} -> ledger: {len(ledger)} dropped: {dropped}")
|
|
print("\nquota status (crossing an A0 cut = common denominator):")
|
|
for typ, q in quota_status.items():
|
|
mark = "OK" if q["meets_quota"] else "SHORT"
|
|
print(f" {typ:8} need>={q['need']:2} have_all={q['have_all']:2} crossing_A0={q['have_crossing_A0']:2} [{mark}]")
|
|
print(f"\nstrata (crossing A0): {result['strata_crossing_A0']}")
|
|
print(f"-> {OUT}")
|
|
print("\nNOTE: any SHORT type -> mine more windows OR mark 'Q undecidable a priori for this type' "
|
|
"BEFORE paid arm/judge spend (design §D1).")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|