#!/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= - 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()