pyannotators-entityfishing 1.6.83__tar.gz → 1.6.87__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.87}/PKG-INFO +1 -1
  2. {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.87}/src/pyannotators_entityfishing/__init__.py +1 -1
  3. {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.87}/src/pyannotators_entityfishing/candidates.py +23 -2
  4. pyannotators_entityfishing-1.6.87/tools/build_rerank_data.py +130 -0
  5. pyannotators_entityfishing-1.6.87/tools/check_rerank_endpoint.py +59 -0
  6. {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.87}/tools/eval_linking.py +67 -12
  7. pyannotators_entityfishing-1.6.87/tools/eval_rerank.py +300 -0
  8. pyannotators_entityfishing-1.6.87/tools/finetune_rerank.py +187 -0
  9. pyannotators_entityfishing-1.6.87/tools/merge_rerank.py +87 -0
  10. pyannotators_entityfishing-1.6.87/tools/recalibrate_linking.py +111 -0
  11. pyannotators_entityfishing-1.6.87/tools/review_linking.html +300 -0
  12. pyannotators_entityfishing-1.6.87/tools/review_linking.py +237 -0
  13. pyannotators_entityfishing-1.6.87/tools/sherpa_plan_project.py +118 -0
  14. {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.87}/.claude/skills/ef-mapping/SKILL.md +0 -0
  15. {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.87}/.gitignore +0 -0
  16. {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.87}/.gitmodules +0 -0
  17. {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.87}/.python-version +0 -0
  18. {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.87}/README.md +0 -0
  19. {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.87}/Taskfile.yml +0 -0
  20. {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.87}/pyproject.toml +0 -0
  21. {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.87}/src/pyannotators_entityfishing/ef_client.py +0 -0
  22. {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.87}/src/pyannotators_entityfishing/entityfishing.py +0 -0
  23. {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.87}/submodules/python-archetype/.claude-plugin/marketplace.json +0 -0
  24. {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.87}/submodules/python-archetype/.claude-plugin/plugin.json +0 -0
  25. {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.87}/submodules/python-archetype/HOWTO.md +0 -0
  26. {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.87}/submodules/python-archetype/README.md +0 -0
  27. {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.87}/submodules/python-archetype/resources/Stages.yml +0 -0
  28. {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.87}/submodules/python-archetype/resources/Taskfile.yml +0 -0
  29. {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.87}/submodules/python-archetype/skills/new-plugin/SKILL.md +0 -0
  30. {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.87}/submodules/python-archetype/skills/new-plugin/references/annotator.md +0 -0
  31. {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.87}/submodules/python-archetype/skills/new-plugin/references/importer.md +0 -0
  32. {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.87}/submodules/python-archetype/skills/new-plugin/references/processor.md +0 -0
  33. {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.87}/submodules/python-archetype/skills/new-plugin/references/types.md +0 -0
  34. {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.87}/submodules/python-archetype/tools/check-marketplace.py +0 -0
  35. {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.87}/submodules/python-archetype/vars/pythonPipeline.groovy +0 -0
  36. {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.87}/tools/el_jev.py +0 -0
  37. {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.87}/tools/eval_mappings.py +0 -0
  38. {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.87}/tools/wdfilter.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pyannotators-entityfishing
3
- Version: 1.6.83
3
+ Version: 1.6.87
4
4
  Summary: Annotator based on entity-fishing
5
5
  Project-URL: Homepage, https://github.com/oterrier/pyannotators_entityfishing/
6
6
  Author-email: Olivier Terrier <olivier.terrier@kairntech.com>
@@ -1,3 +1,3 @@
1
1
  """Annotator based on entity-fishing"""
2
2
 
3
- __version__ = "1.6.83"
3
+ __version__ = "1.6.87"
@@ -29,6 +29,9 @@ WIKIDATA = "wikidata"
29
29
  # Written by pyprocessors_coreference (ADR-0001, §5), when that step ran before this one.
30
30
  ENTITY_GROUP = "entity_group"
31
31
  CANONICAL_FORM = "canonical_form"
32
+ # On an ambiguous partial name ("Antonelli" ending both "Kimi Antonelli" and another full
33
+ # name): the groups it may belong to. Its candidates are then the union of theirs and its own.
34
+ ENTITY_GROUP_CANDIDATES = "entity_group_candidates"
32
35
 
33
36
 
34
37
  class EntityFishingCandidatesParameters(ProcessorParameters):
@@ -122,15 +125,33 @@ def add_candidates(
122
125
  senses = {form: [s for s in ss if not 0 < s.get("prob_c", 0.0) < min_prior] for form, ss in senses.items()}
123
126
  chosen = {key: next((senses[f] for f in fs if senses.get(f)), [])[:top_k] for key, fs in forms.items()}
124
127
  concepts = client.get_kb_concepts_by_page({s["pageid"] for ss in chosen.values() for s in ss}, lang)
128
+ terms_of = {key: candidate_terms(chosen[key], concepts, queries.get(key[0]), wiki_props) for key in groups}
129
+ by_group_id = {key[2]: key for key in groups if key[1] == "group"}
125
130
  for key, members in groups.items():
126
- label = key[0]
127
- terms = candidate_terms(chosen[key], concepts, queries.get(label), wiki_props)
131
+ terms = terms_of[key]
132
+ possible = [by_group_id[g] for g in (members[0].properties or {}).get(ENTITY_GROUP_CANDIDATES) or [] if g in by_group_id]
133
+ if possible:
134
+ # An ambiguous partial name: the decision step chooses among the candidates of
135
+ # every group it may belong to, the full names first, and its own.
136
+ terms = round_robin([terms_of[k] for k in possible] + [terms], top_k)
128
137
  for a in members:
129
138
  # Replace the Wikidata candidates, keep the terms of any other lexicon (AFP...).
130
139
  kept = [t for t in (a.terms or []) if t.lexicon != WIKIDATA]
131
140
  a.terms = kept + [t.model_copy(deep=True) for t in terms]
132
141
 
133
142
 
143
+ def round_robin(lists: list[list[Term]], top_k: int) -> list[Term]:
144
+ """The first of each list, then the second of each... without repeating a QID, up to
145
+ top_k: every possible group keeps its best candidates."""
146
+ out, seen = [], set()
147
+ for rank in range(max((len(ts) for ts in lists), default=0)):
148
+ for ts in lists:
149
+ if rank < len(ts) and ts[rank].identifier not in seen and len(out) < top_k:
150
+ seen.add(ts[rank].identifier)
151
+ out.append(ts[rank])
152
+ return out
153
+
154
+
134
155
  def group_mentions(document: Document, labels: list[str] | None) -> dict[tuple, list[Annotation]]:
135
156
  """The mentions of the same entity: by `entity_group` when the grouping step ran,
136
157
  else by label and normalized surface. Never across labels."""
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env python3
2
+ """Build the fine-tuning set of the reranker from Jev's decisions on AFP dispatches.
3
+
4
+ ADR-0001 §9 Q7, SHERPA-3054: there are no reference links on AFP, so Jev's confident
5
+ decisions stand in for them. Jev becomes a provisional teacher; the student inherits its
6
+ errors, which is why only confident answers are kept and why the evaluation stays on the
7
+ MSNBC and AQUAINT gold links (tools/eval_rerank.py), never on AFP.
8
+
9
+ Inputs, kept in .cache/afp-runs/ (gitignored; regenerating them costs ~14 M Jev tokens):
10
+ fr_pipeline_ef_jev.jsonl plan pipeline_ef_jev on project afp_ner_fr_2 (5,059 dispatches)
11
+ fr_docs.json the texts of those dispatches
12
+ xx_pipeline_ef_jev_noner.jsonl plan pipeline_ef_jev_noner on project afp_ner_xx (641 dispatches, 6 languages)
13
+ the texts of the latter are read from the multilingual test corpus (--xx-corpus).
14
+
15
+ One example per decided group: the context of its first mention (same window and [ ]
16
+ marks as the evaluation), the descriptions of its candidates ("name. definition", as the
17
+ evaluation without facts), and the target: the index of Jev's choice, or -1 for NONE.
18
+
19
+ Kept: answers whose winning probability is at least --min-prob, all NONE and all groups
20
+ with several candidates, and a --single-share sample of the one-candidate groups Jev
21
+ accepted (the easy ones: 26,600 of 41,500). Documents are split into train and
22
+ validation by a hash of their identifier.
23
+
24
+ .venv/bin/python tools/build_rerank_data.py # writes .cache/rerank-train/{train,val}.jsonl
25
+ .venv/bin/python tools/build_rerank_data.py --runs fr_pipeline_ef_jev.jsonl --out .cache/rerank-train-fr
26
+ # without the 641 REM test dispatches (the xx run), so that the model can be measured on them
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import glob
33
+ import hashlib
34
+ import json
35
+ import random
36
+ import sys
37
+ from collections import Counter
38
+ from pathlib import Path
39
+
40
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
41
+ from eval_rerank import mark # noqa: E402
42
+
43
+ ROOT = Path(__file__).resolve().parent.parent
44
+ RUNS = ROOT / ".cache" / "afp-runs"
45
+ OUT = ROOT / ".cache" / "rerank-train"
46
+ XX_CORPUS = "/media/olivier/DATA/corpora/AFP/REM/corpus-test-depeches/flair"
47
+
48
+
49
+ def texts(xx_corpus: str) -> dict[str, tuple[str, str]]:
50
+ """Identifier → (language, text) of every dispatch of the two runs."""
51
+ out = {d["identifier"]: ("fr", d["text"]) for d in json.loads((RUNS / "fr_docs.json").read_text())}
52
+ for f in glob.glob(f"{xx_corpus}/*.json"):
53
+ d = json.loads(Path(f).read_text())
54
+ out[d["identifier"]] = (d["metadata"]["language"], d["text"])
55
+ return out
56
+
57
+
58
+ def examples(run: Path, docs: dict[str, tuple[str, str]], min_prob: float):
59
+ for line in run.open():
60
+ r = json.loads(line)
61
+ j = r["decision"]
62
+ if not j or r["identifier"] not in docs:
63
+ continue
64
+ lang, text = docs[r["identifier"]]
65
+ for k, q in j["questions"].items():
66
+ a = j["answers"][k]
67
+ choice = a["choice"]
68
+ if a["probabilities"].get(choice, 0.0) < min_prob:
69
+ continue
70
+ mention = q["instructions"]["mention"]
71
+ first = next((m for m in r["annotations"] if m["labelName"] == mention["type"] and m["text"] in mention["surfaces"]), None)
72
+ if first is None:
73
+ continue
74
+ qids = [c for c in q["criteria"] if c != "NONE"]
75
+ cands = []
76
+ for qid in qids:
77
+ c = q["criteria"][qid]
78
+ name, definition = (c, "") if isinstance(c, str) else (c["name"], c.get("definition", ""))
79
+ cands.append(f"{name}. {definition}")
80
+ yield {
81
+ "doc": r["identifier"],
82
+ "lang": lang,
83
+ "type": mention["type"],
84
+ "context": mark(text, first["start"], first["end"]),
85
+ "qids": qids,
86
+ "candidates": cands,
87
+ "target": qids.index(choice) if choice != "NONE" else -1,
88
+ "jev_prob": a["probabilities"][choice],
89
+ }
90
+
91
+
92
+ def main() -> int:
93
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
94
+ parser.add_argument("--xx-corpus", default=XX_CORPUS)
95
+ parser.add_argument("--min-prob", type=float, default=0.9)
96
+ parser.add_argument("--single-share", type=float, default=0.2, help="share of the accepted one-candidate groups kept (default 0.2)")
97
+ parser.add_argument("--val-share", type=float, default=0.05)
98
+ parser.add_argument("--seed", type=int, default=3054)
99
+ parser.add_argument("--runs", nargs="+", default=["fr_pipeline_ef_jev.jsonl", "xx_pipeline_ef_jev_noner.jsonl"], help="runs of .cache/afp-runs to draw from, in this order")
100
+ parser.add_argument("--out", type=Path, default=OUT, help="output directory (default .cache/rerank-train)")
101
+ args = parser.parse_args()
102
+ out = args.out
103
+
104
+ docs = texts(args.xx_corpus)
105
+ rng = random.Random(args.seed)
106
+ out.mkdir(parents=True, exist_ok=True)
107
+ files = {split: (out / f"{split}.jsonl").open("w") for split in ("train", "val")}
108
+ stats = Counter()
109
+ for run in args.runs:
110
+ for ex in examples(RUNS / run, docs, args.min_prob):
111
+ kind = "none" if ex["target"] < 0 else ("single" if len(ex["qids"]) == 1 else "multi")
112
+ if kind == "single" and rng.random() >= args.single_share:
113
+ continue
114
+ split = "val" if int(hashlib.md5(ex["doc"].encode()).hexdigest(), 16) % 1000 < args.val_share * 1000 else "train"
115
+ files[split].write(json.dumps(ex, ensure_ascii=False) + "\n")
116
+ stats[split, kind] += 1
117
+ stats[split, "pairs"] += len(ex["qids"])
118
+ stats[split, ex["lang"]] += 1
119
+ for f in files.values():
120
+ f.close()
121
+ for split in ("train", "val"):
122
+ kinds = {k: stats[split, k] for k in ("multi", "single", "none")}
123
+ langs = {k[1]: v for k, v in stats.items() if k[0] == split and k[1] in ("fr", "en", "de", "es", "pt", "ar")}
124
+ print(f"{split}: {sum(kinds.values())} groups {kinds}, {stats[split, 'pairs']} pairs, by language {langs}")
125
+ print(f"written to {out}")
126
+ return 0
127
+
128
+
129
+ if __name__ == "__main__":
130
+ sys.exit(main())
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env python3
2
+ """Check a served reranker against the scores of the evaluation: same model, same numbers?
3
+
4
+ The `rerank` processor of pyprocessors_jev reads log-odds back from a `/rerank` endpoint
5
+ (vLLM serving tools/merge_rerank.py's output, or LiteLLM in front of it). This sends the
6
+ same (context, candidate) pairs as tools/eval_rerank.py --adapter, and compares the
7
+ log-odds with those it cached. vLLM serves in bf16 where the evaluation ran in fp32, so
8
+ small gaps are expected; the decisions must not move.
9
+
10
+ .venv/bin/python tools/check_rerank_endpoint.py --base-url http://<host>:<port>/v1 --docs 5
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import math
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ import requests
21
+
22
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
23
+ import eval_linking as el # noqa: E402
24
+ import eval_rerank as er # noqa: E402
25
+
26
+
27
+ def main() -> int:
28
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
29
+ parser.add_argument("--base-url", required=True)
30
+ parser.add_argument("--model", default="qwen3-reranker-afp-jev")
31
+ parser.add_argument("--label", default="qwen@qwen3-afp-jev", help="the cached scores to compare with")
32
+ parser.add_argument("--docs", type=int, default=5)
33
+ parser.add_argument("--api-key")
34
+ args = parser.parse_args()
35
+ headers = {"Authorization": f"Bearer {args.api_key}"} if args.api_key else {}
36
+ cache = el.cached("rerank.json")
37
+
38
+ gaps, same, surfaces = [], 0, 0
39
+ for doc in el.load_corpus("msnbc")[: args.docs]:
40
+ cands = el.candidates_of(doc, 8, el.EF_DEFAULT, 0.0)
41
+ cached = cache[f"{args.label}/k8/p0.0/{doc.corpus}/{doc.name}"]
42
+ for s, cs in cands.items():
43
+ if not cs:
44
+ continue
45
+ docs = [er.describe(c, facts=False) for c in cs]
46
+ resp = requests.post(args.base_url.rstrip("/") + "/rerank", json={"model": args.model, "query": er.context(doc, s), "documents": docs}, headers=headers, timeout=60)
47
+ resp.raise_for_status()
48
+ by_index = {r["index"]: r["relevance_score"] for r in resp.json()["results"]}
49
+ served = [math.log(max(p, 1e-7) / max(1 - p, 1e-7)) for p in (by_index[i] for i in range(len(docs)))]
50
+ gaps += [abs(a - b) for a, b in zip(served, cached[s], strict=True)]
51
+ same += max(range(len(docs)), key=served.__getitem__) == max(range(len(docs)), key=cached[s].__getitem__)
52
+ surfaces += 1
53
+ gaps.sort()
54
+ print(f"{surfaces} surfaces, {len(gaps)} pairs: log-odds gap median {gaps[len(gaps) // 2]:.3f}, max {gaps[-1]:.3f}; same best candidate {same}/{surfaces}")
55
+ return 0
56
+
57
+
58
+ if __name__ == "__main__":
59
+ sys.exit(main())
@@ -23,6 +23,14 @@ jev term lookup (top K) + one Jev Choice per distinct surface, one cal
23
23
  .venv/bin/python tools/eval_linking.py # both corpora, min_prior 0.01 (Jev calls billed once, then cached)
24
24
  .venv/bin/python tools/eval_linking.py --min-prior 0 # the first evaluation, replayed from the cache
25
25
  .venv/bin/python tools/eval_linking.py --bench 8 # latency and throughput, real calls
26
+ JEV_API_BASE=http://127.0.0.1:8009 .venv/bin/python tools/eval_linking.py --model cygnet --min-prior 0 # a self-hosted Jev-compatible endpoint, no key
27
+ JEV_API_BASE=http://127.0.0.1:8009 .venv/bin/python tools/eval_linking.py --model cygnet --min-prior 0 --fragments 1 # one call per surface, state = its sentences +-1
28
+
29
+ --fragments W replaces the whole text by fragments: one call per distinct surface, whose
30
+ state is the sentences holding its mentions, W sentences on each side. Cygnet re-reads
31
+ the state for every question, so this is where its tokens go; Jev reads it once per call.
32
+ The answers are cached under the model name "<model>+fragW", which
33
+ tools/recalibrate_linking.py --model cygnet+frag1 replays.
26
34
 
27
35
  Jev answers are cached per (model, document) in .cache/linking/jev.json, so a re-run
28
36
  costs nothing; --refresh-jev forces new calls. The key is read from JEV_API_KEY or
@@ -49,7 +57,9 @@ ROOT = Path(__file__).resolve().parent.parent
49
57
  CACHE = ROOT / ".cache" / "linking"
50
58
  RAW = "https://raw.githubusercontent.com/kermitt2/entity-fishing/master/data/corpus/corpus-long"
51
59
  EF_DEFAULT = "https://entityfishing.kairntech.com"
52
- JEV_URL = "https://api.typesafe.ai/v1/systemone"
60
+ # Any endpoint that speaks the Jev protocol: the hosted TypeSafe API by default, or a
61
+ # self-hosted one (Cygnet, Open-Jev) through JEV_API_BASE, as in pyprocessors_jev.
62
+ JEV_BASE = os.getenv("JEV_API_BASE", os.getenv("TYPESAFE_BASE_URL", "https://api.typesafe.ai"))
53
63
  WIKIPEDIA_API = "https://en.wikipedia.org/w/api.php"
54
64
  USER_AGENT = "kairntech-eval-linking/0.1 (olivier.terrier@kairntech.com)"
55
65
  NONE = "NONE"
@@ -255,27 +265,62 @@ def build_questions(cands: dict[str, list[dict]]) -> tuple[list[str], dict]:
255
265
  return surfaces, questions
256
266
 
257
267
 
268
+ def sentence_spans(text: str) -> list[tuple[int, int]]:
269
+ """Sentences as (start, end) offsets: a line break, or [.!?] then a space before a capital or a quote."""
270
+ spans, start = [], 0
271
+ for m in re.finditer(r"\n+|(?<=[.!?])[\"'”’)]*\s+(?=[A-Z\"“‘(])", text):
272
+ if text[start : m.start()].strip():
273
+ spans.append((start, m.start()))
274
+ start = m.end()
275
+ if text[start:].strip():
276
+ spans.append((start, len(text)))
277
+ return spans
278
+
279
+
280
+ def fragment(doc: Doc, surface: str, window: int) -> str:
281
+ """The sentences holding the mentions of `surface`, `window` sentences on each side, gaps marked by [...]."""
282
+ spans = sentence_spans(doc.text)
283
+ hit = {i for m in doc.mentions if m.surface == surface for i, (s, e) in enumerate(spans) if s < m.end and m.start < e}
284
+ keep = sorted({j for i in hit for j in range(i - window, i + window + 1) if 0 <= j < len(spans)})
285
+ parts, prev = [], None
286
+ for j in keep:
287
+ if prev is not None and j != prev + 1:
288
+ parts.append("[...]")
289
+ parts.append(doc.text[spans[j][0] : spans[j][1]].strip())
290
+ prev = j
291
+ return "\n".join(parts)
292
+
293
+
258
294
  def call_jev(text: str, questions: dict, model: str, key: str) -> dict:
259
- resp = session.post(JEV_URL, json={"model": model, "state": {"text": text}, "questions": questions}, headers={"Authorization": f"Bearer {key}"}, timeout=300)
295
+ headers = {"Authorization": f"Bearer {key}"} if key else {}
296
+ resp = session.post(JEV_BASE.rstrip("/") + "/v1/systemone", json={"model": model, "state": {"text": text}, "questions": questions}, headers=headers, timeout=300)
260
297
  resp.raise_for_status()
261
298
  return resp.json()
262
299
 
263
300
 
264
- def jev(doc: Doc, cands: dict[str, list[dict]], model: str, key: str, refresh: bool, k: int, min_prior: float) -> tuple[dict, dict]:
301
+ def jev(doc: Doc, cands: dict[str, list[dict]], model: str, key: str, refresh: bool, k: int, min_prior: float, fragments: int = -1) -> tuple[dict, dict]:
265
302
  cache = cached("jev.json")
266
303
  # The answers depend on the candidates asked: the key carries K and min_prior. The
267
304
  # first evaluation (K=8, no min_prior) keeps its original key, so it replays for free.
268
- ck = f"{model}/{doc.corpus}/{doc.name}" if (k, min_prior) == (8, 0.0) else f"{model}/k{k}/p{min_prior}/{doc.corpus}/{doc.name}"
305
+ tag = f"{model}+frag{fragments}" if fragments >= 0 else model
306
+ ck = f"{tag}/{doc.corpus}/{doc.name}" if (k, min_prior) == (8, 0.0) else f"{tag}/k{k}/p{min_prior}/{doc.corpus}/{doc.name}"
269
307
  if ck in cache and not refresh:
270
308
  return cache[ck]["answers"], cache[ck]["usage"]
271
309
  surfaces, questions = build_questions(cands)
272
310
  if not questions:
273
311
  return {}, {}
274
- result = call_jev(doc.text, questions, model, key)
275
- answers = {surfaces[int(q[1:])]: a for q, a in result["answers"].items()}
276
- cache[ck] = {"answers": answers, "usage": result.get("usage", {})}
312
+ if fragments < 0:
313
+ result = call_jev(doc.text, questions, model, key)
314
+ answers = {surfaces[int(q[1:])]: a for q, a in result["answers"].items()}
315
+ usage = result.get("usage", {})
316
+ else: # one call per surface, on its own fragment
317
+ with ThreadPoolExecutor(8) as pool:
318
+ results = list(pool.map(lambda q: call_jev(fragment(doc, surfaces[int(q[1:])], fragments), {q: questions[q]}, model, key), questions))
319
+ answers = {surfaces[int(q[1:])]: a for r in results for q, a in r["answers"].items()}
320
+ usage = {u: sum(r.get("usage", {}).get(u, 0) for r in results) for u in ("input_tokens", "output_tokens")}
321
+ cache[ck] = {"answers": answers, "usage": usage}
277
322
  save("jev.json", cache)
278
- return answers, result.get("usage", {})
323
+ return answers, usage
279
324
 
280
325
 
281
326
  # --------------------------------------------------------------------------- scoring
@@ -390,6 +435,7 @@ def main() -> int:
390
435
  parser.add_argument("--min-prior", type=float, default=0.01, help="drop senses with 0 < prob_c < this, as the processor (default 0.01; 0 replays the first evaluation)")
391
436
  parser.add_argument("--model", default="jev-latest")
392
437
  parser.add_argument("--refresh-jev", action="store_true")
438
+ parser.add_argument("--fragments", type=int, default=-1, metavar="W", help="one call per surface, state = the sentences of its mentions, W on each side (default -1: the whole text, one call)")
393
439
  parser.add_argument("--no-jev", action="store_true", help="skip the Jev calls (candidates and baselines only; with --bench, time entity-fishing only)")
394
440
  parser.add_argument("--bench", type=int, default=0, metavar="N", help="time real calls (no cache) on N documents, half from each corpus")
395
441
  parser.add_argument("--concurrency", type=int, nargs="+", default=[1, 8], help="Jev concurrencies measured by --bench")
@@ -402,7 +448,7 @@ def main() -> int:
402
448
  k, v = line.split("=", 1)
403
449
  os.environ.setdefault(k.strip(), v.strip().strip("'\""))
404
450
  key = os.getenv("JEV_API_KEY") or os.getenv("TYPESAFE_API_KEY")
405
- if not key and not args.no_jev:
451
+ if not key and not args.no_jev and "JEV_API_BASE" not in os.environ:
406
452
  print("No JEV_API_KEY: use --no-jev", file=sys.stderr)
407
453
  return 1
408
454
 
@@ -419,14 +465,19 @@ def main() -> int:
419
465
  scores: dict[str, Score] = {"disambiguate": Score(), "prior": Score()}
420
466
  for t in THRESHOLDS:
421
467
  scores[f"jev conf>={t}"] = Score()
422
- cand_recall, usage = Counter(), Counter()
468
+ cand_recall, usage, state_chars = Counter(), Counter(), Counter()
423
469
 
424
470
  for n, doc in enumerate(docs, 1):
425
471
  print(f"# {n}/{len(docs)} {doc.corpus}/{doc.name} ({len(doc.mentions)} mentions)", file=sys.stderr)
426
472
  ef_links = disambiguate(doc, args.ef, args.min_selector)
427
473
  cands = candidates_of(doc, args.k, args.ef, args.min_prior)
428
- answers, u = ({}, {}) if args.no_jev else jev(doc, cands, args.model, key, args.refresh_jev, args.k, args.min_prior)
474
+ answers, u = ({}, {}) if args.no_jev else jev(doc, cands, args.model, key, args.refresh_jev, args.k, args.min_prior, args.fragments)
429
475
  usage.update(u)
476
+ n_questions = sum(1 for cs in cands.values() if cs)
477
+ state_chars["text"] += len(doc.text)
478
+ state_chars["text x questions"] += len(doc.text) * n_questions
479
+ if args.fragments >= 0:
480
+ state_chars["fragments"] += sum(len(fragment(doc, s, args.fragments)) for s, cs in cands.items() if cs)
430
481
  for m in doc.mentions:
431
482
  scores["disambiguate"].add(m, ef_links.get((m.start, m.end)))
432
483
  cs = cands.get(m.surface, [])
@@ -437,7 +488,8 @@ def main() -> int:
437
488
  a = answers.get(m.surface)
438
489
  for t in THRESHOLDS:
439
490
  pred = None
440
- if a and a["choice"] != NONE and a["confidence"] >= t:
491
+ # Cygnet returns no confidence: the probability of the choice stands for it (they coincide for Jev).
492
+ if a and a["choice"] != NONE and a.get("confidence", a["probabilities"].get(a["choice"], 0.0)) >= t:
441
493
  pred = a["choice"]
442
494
  scores[f"jev conf>={t}"].add(m, pred)
443
495
  if a and m.qid: # keep_ambiguity: the gold QID among the 2 most probable non-NONE options
@@ -446,6 +498,9 @@ def main() -> int:
446
498
 
447
499
  mentions = sum(len(d.mentions) for d in docs)
448
500
  print(f"\n{len(docs)} documents, {mentions} mentions scored ({unresolved} gold titles with no QID dropped)")
501
+ if args.fragments >= 0:
502
+ reread, frag = state_chars["text x questions"], state_chars["fragments"]
503
+ print(f"state characters: whole text {state_chars['text']}, re-read per question {reread}, fragments (W={args.fragments}) {frag} = {frag / max(reread, 1):.0%} of the re-read text")
449
504
  print(f"candidate recall @K={args.k}, min_prior={args.min_prior}: {cand_recall['in_candidates']}/{cand_recall['gold']} = {cand_recall['in_candidates'] / max(cand_recall['gold'], 1):.1%} (ceiling of the term-lookup chain)\n")
450
505
  for name, s in scores.items():
451
506
  if name.startswith("jev") and args.no_jev: