textmachine/eval/design11/ws3_seeddelta.py

173 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""WS3 (г) verification, part 2 — emit a seed-delta (§C2-7) from the persisted exp16
tables (A3 candidates + banknote dst), which exp16 itself never emitted (only sidecars),
then DRY-LINT it against a faithful Python replication of the memseed.loadGlossarySeed
fail-louds (the (д) after-build spec runs the real Go loadGlossarySeed for byte parity).
Emission rules (research/20 §C2-7, memseed.go:30-57 seedTerm schema, miner NEVER writes approved):
- non-seed A3 candidate WITH a banknote dst -> status: draft, dst=<majority banknote dst>
- non-seed A3 candidate WITHOUT a dst -> status: auto (no dst, inert in automaton)
- since_ch = first chapter of occurrence (auto, by source); until_ch left 0 (owner only)
$0: deterministic, no LLM, no network. Reuses eval/exp16 miner + banknote artifacts.
Writes seed-delta YAML + lint report to /home/ubuntu/books/gu-zhenren/design11/ (OUT of git).
"""
import json, os, sys, glob, re, collections
import yaml
EVAL = "/home/ubuntu/projects/textmachine/eval"
sys.path.insert(0, f"{EVAL}/exp15")
sys.path.insert(0, f"{EVAL}/exp16")
import exp16_common as X
import arms as A
import mem_select as MS # normalize_source_key, significant_len (byte-verified port of memnorm.go)
A3 = "/home/ubuntu/books/gu-zhenren/exp16/A3_candidates_full.json"
BANK = "/home/ubuntu/books/gu-zhenren/exp16/coldstart/banknote/*.banknote.json"
OUT_YAML = "/home/ubuntu/books/gu-zhenren/design11/ws3_seed_delta.yaml"
OUT_LINT = "/home/ubuntu/books/gu-zhenren/design11/ws3_seeddelta_lint.json"
MIN_KEY_HAN, MIN_KEY_PHON = 2, 3
def min_key_len(nk):
return MIN_KEY_HAN if any('' <= c <= '鿿' or '' <= c <= '﫿' for c in nk) else MIN_KEY_PHON
# ---------- faithful replication of memseed.loadGlossarySeed fail-louds ----------
def lint_seed(terms):
problems = []
seen = {}
for i, t in enumerate(terms):
src = (t.get("src") or "").strip()
sense = (t.get("sense") or "").strip()
dst = (t.get("dst") or "").strip()
if not src:
problems.append(f"term {i}: src is required"); continue
status = t.get("status") or "approved"
if status not in ("auto", "draft", "approved"):
problems.append(f"term {src!r}: status must be auto|draft|approved, got {status!r}"); continue
if status in ("approved", "draft") and not dst:
problems.append(f"term {src!r}: a {status} term must have a non-empty dst (silently inert)"); continue
key = (src, sense, t.get("since_ch", 0), t.get("until_ch", 0))
if key in seen:
problems.append(f"term {src!r}: duplicate (src,sense,window)"); continue
seen[key] = i
# spoiler-window overlap same src different dst (D16.1) + shared-key collision (approved only)
def win_overlap(s1, u1, s2, u2):
s1 = s1 or 1; s2 = s2 or 1; u1 = u1 or (1 << 62); u2 = u2 or (1 << 62)
return max(s1, s2) <= min(u1, u2)
for i in range(len(terms)):
for j in range(i):
a, b = terms[i], terms[j]
if a.get("src") != b.get("src"):
continue
if (a.get("dst") or "") == (b.get("dst") or ""):
continue
if win_overlap(a.get("since_ch", 0), a.get("until_ch", 0), b.get("since_ch", 0), b.get("until_ch", 0)):
problems.append(f"term {a['src']!r}: overlapping spoiler windows with different dst")
return problems
def is_fragment(src, gt_norm, particles="的了在是和就也都不心面前后上下今少多大小与之其"):
if len(src) >= 2 and MS.normalize_source_key(src[:-1]) in gt_norm and src[-1] in particles:
return True
return False
def main():
chunks = X.load_chunks()
gt = X.load_gt()
gt_norm = set()
for e in gt:
for s in e.norm_surfaces:
gt_norm.add(s)
a3 = json.load(open(A3))
# banknote dst: majority per src; strip trailing [type] annotations the model added
bn = collections.defaultdict(collections.Counter)
for f in glob.glob(BANK):
d = json.load(open(f))
# chapter from filename e.g. 4.0.banknote.json -> chapter 4
ch = int(os.path.basename(f).split(".")[0])
for e in d.get("entries", []):
dst = re.sub(r"\[[^\]]*\]", "", e["dst"]).strip()
bn[MS.normalize_source_key(e["src"])][(dst, e.get("type", "term"))] += 1
bn_first_ch = {}
for f in glob.glob(BANK):
ch = int(os.path.basename(f).split(".")[0])
d = json.load(open(f))
for e in d.get("entries", []):
k = MS.normalize_source_key(e["src"])
bn_first_ch[k] = min(bn_first_ch.get(k, 1 << 30), ch)
# candidate chapters helper (first chapter of occurrence)
def first_chapter(src_norm):
for c in chunks:
if src_norm in c.nsource:
return c.chapter
return 0
delta = []
seen_src = set()
n_draft = n_auto = 0
# (1) non-seed candidates WITH a banknote dst -> draft
for cand in a3:
srcn = MS.normalize_source_key(cand["src"])
if srcn in gt_norm or srcn in seen_src:
continue
if is_fragment(cand["src"], gt_norm):
continue
typ = (cand["types"] or ["term"])[0]
if srcn in bn:
(dst, bt) = bn[srcn].most_common(1)[0][0], bn[srcn].most_common(1)[0]
dstv = bn[srcn].most_common(1)[0][0][0]
if not dstv:
continue
delta.append({"src": cand["src"], "dst": dstv, "type": typ, "status": "draft",
"since_ch": bn_first_ch.get(srcn) or first_chapter(srcn),
"note": "mined draft (banknote dst); ⟨проверить⟩ — canon proposal, owner signs"})
seen_src.add(srcn); n_draft += 1
# (2) top non-seed WHICH-only candidates WITHOUT dst -> auto (inert), a bounded sample.
# Apply the miner's operating point (top_k=90 rank window) + single-key ban + fragment filter
# so the owner review-sheet is clean (raw 13618-dump would carry particle/fragment noise).
a3_top = a3[: A.FROZEN["top_k"] * 3] # generous rank window; only pattern-typed or freq-anchored survive filters
for cand in a3_top:
if n_auto >= 40:
break
srcn = MS.normalize_source_key(cand["src"])
if srcn in gt_norm or srcn in seen_src:
continue
if is_fragment(cand["src"], gt_norm):
continue
if MS.significant_len(srcn) < min_key_len(srcn):
continue # single-key ban (A3): a too-short key never fires / is not an entity
if cand["freq"] < X.FREQ_FLOOR and not cand["from_pattern"]:
continue
typ = (cand["types"] or ["term"])[0]
if typ not in ("name", "place", "title", "term", "nickname"):
typ = "term"
delta.append({"src": cand["src"], "dst": "", "type": typ, "status": "auto",
"since_ch": first_chapter(srcn),
"note": "mined WHICH-only auto-candidate (no dst; inert until owner assigns)"})
seen_src.add(srcn); n_auto += 1
seed_doc = {"terms": delta}
os.makedirs(os.path.dirname(OUT_YAML), exist_ok=True)
with open(OUT_YAML, "w") as fh:
yaml.safe_dump(seed_doc, fh, allow_unicode=True, sort_keys=False)
# round-trip: re-parse the emitted YAML and lint it (as loadGlossarySeed would)
reparsed = yaml.safe_load(open(OUT_YAML))["terms"]
problems = lint_seed(reparsed)
report = {
"n_delta_terms": len(delta), "n_draft_with_banknote_dst": n_draft, "n_auto_no_dst": n_auto,
"lint_problems": problems, "lint_pass": len(problems) == 0,
"sample_draft": [t for t in delta if t["status"] == "draft"][:8],
"sample_auto": [t for t in delta if t["status"] == "auto"][:8],
}
json.dump(report, open(OUT_LINT, "w"), ensure_ascii=False, indent=2)
print(json.dumps({k: report[k] for k in ("n_delta_terms", "n_draft_with_banknote_dst", "n_auto_no_dst", "lint_pass", "lint_problems")}, ensure_ascii=False, indent=2))
print("\nsample draft (mined dst):", [(t["src"], t["dst"], t["type"]) for t in report["sample_draft"]])
print("sample auto (which-only):", [(t["src"], t["type"]) for t in report["sample_auto"]])
print(f"\nVERDICT: seed-delta of {len(delta)} terms ({n_draft} draft+dst / {n_auto} auto) "
f"{'PASSES' if report['lint_pass'] else 'FAILS'} memseed fail-loud dry-lint.")
if __name__ == "__main__":
main()