File size: 19,391 Bytes
b92b744
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
"""Run with ``python -m app.ingest --help`` from backend/."""

import argparse
import asyncio
import hashlib
import json
import os
from pathlib import Path

from app.ingest.fetch import atomic_json, fetch_pages
from app.ingest.scope import PROFILES


def read_jsonl(path: Path):
    with path.open() as handle:
        for line_number, line in enumerate(handle, 1):
            if not line.strip():
                continue
            try:
                row = json.loads(line)
                if not isinstance(row, dict):
                    raise TypeError("record is not an object")
                yield row
            except (json.JSONDecodeError, ValueError, TypeError) as exc:
                raise ValueError(f"Invalid JSONL at {path.name}:{line_number}") from exc


def transform_file(source: Path, output: Path, transform, *, resume: bool,
                   stage: str, batch_size: int = 100) -> dict:
    """Transform batches with output truncation and an input-record checkpoint."""
    if source.resolve() == output.resolve():
        raise ValueError("Input and output paths must differ")
    signature = hashlib.sha256(json.dumps([str(source.resolve()), source.stat().st_size,
                                          source.stat().st_mtime_ns, stage]).encode()).hexdigest()
    checkpoint = output.with_suffix(output.suffix + ".checkpoint.json")
    state = {"signature": signature, "input_records": 0, "output_records": 0, "bytes": 0, "done": False}
    if resume and checkpoint.exists():
        state = json.loads(checkpoint.read_text())
        if state.get("signature") != signature:
            raise ValueError("Input or transformation changed since checkpoint")
    elif output.exists():
        raise FileExistsError("Output already exists; use --resume or choose a new path")
    output.parent.mkdir(parents=True, exist_ok=True)
    if not output.exists():
        if state["bytes"]:
            raise ValueError("Checkpoint output is missing")
        output.touch()
    if output.stat().st_size < state["bytes"]:
        raise ValueError("Checkpoint output is truncated")
    if output.stat().st_size != state["bytes"]:
        with output.open("r+b") as handle:
            handle.truncate(state["bytes"])
    atomic_json(checkpoint, state)
    if state["done"]:
        return state
    skipped = state["input_records"]
    batch = []

    def commit(rows):
        result = transform(rows)
        with output.open("ab") as handle:
            for row in result:
                handle.write((json.dumps(row, ensure_ascii=False) + "\n").encode())
            handle.flush()
            os.fsync(handle.fileno())
            state["bytes"] = handle.tell()
        state["input_records"] += len(rows)
        state["output_records"] += len(result)
        atomic_json(checkpoint, state)

    for index, row in enumerate(read_jsonl(source)):
        if index < skipped:
            continue
        batch.append(row)
        if len(batch) >= batch_size:
            commit(batch)
            batch = []
    if batch:
        commit(batch)
    state["done"] = True
    atomic_json(checkpoint, state)
    return state


def parser() -> argparse.ArgumentParser:
    root = argparse.ArgumentParser(description="NullMap resumable ingestion; all intermediate stages use JSONL")
    commands = root.add_subparsers(dest="command", required=True)
    fetch = commands.add_parser("fetch", help="Download ClinicalTrials.gov records")
    fetch.add_argument("source", choices=["ctgov"])
    fetch.add_argument("--output", type=Path, required=True)
    fetch.add_argument("--query", default="")
    fetch.add_argument("--filter", default=None)
    fetch.add_argument("--limit", type=int, default=1000)
    fetch.add_argument("--all", action="store_true", help="Continue until the registry cursor is exhausted")
    fetch.add_argument("--page-size", type=int, default=1000)
    fetch.add_argument("--resume", action="store_true")
    for name in ("normalize", "classify", "embed"):
        command = commands.add_parser(name)
        command.add_argument("--input", type=Path, required=True)
        command.add_argument("--output", type=Path, required=True)
        command.add_argument("--resume", action="store_true")
        command.add_argument("--batch-size", type=int, default=100)
        if name == "classify":
            command.add_argument("--model", type=Path)
    link = commands.add_parser("link", help="Merge normalized JSONL files by PMID and NCT")
    link.add_argument("--input", type=Path, nargs="+", required=True)
    link.add_argument("--output", type=Path, required=True)
    link.add_argument("--resume", action="store_true")
    train = commands.add_parser("train-classifier")
    train.add_argument("--input", type=Path, required=True)
    train.add_argument("--output", type=Path, required=True)
    train.add_argument("--label-field", default="human_label")
    label = commands.add_parser("label", help="Create instrumented structured LLM labels for training")
    label.add_argument("--input", type=Path, required=True)
    label.add_argument("--output", type=Path, required=True)
    label.add_argument("--limit", type=int, default=100)
    label.add_argument("--batch-size", type=int, default=10)
    label.add_argument("--resume", action="store_true")
    index = commands.add_parser("index")
    index.add_argument("--input", type=Path, required=True)
    index.add_argument("--batch-size", type=int, default=200)
    index.add_argument("--resume", action="store_true")
    index.add_argument("--sesoi", type=float, default=0.2)
    index.add_argument("--effect-type", default="SMD")
    pagerank = commands.add_parser(
        "pagerank",
        help="Recompute within-corpus citation PageRank and update authority rank features",
    )
    pagerank.add_argument("--damping", type=float, default=0.85)
    pagerank.add_argument("--iterations", type=int, default=30)
    pagerank.add_argument("--batch-size", type=int, default=500)
    references = commands.add_parser("reference-ids", help="Collect missing review-reference IDs for S3 backfill")
    references.add_argument("--input", type=Path, nargs="+", required=True)
    references.add_argument("--output", type=Path, required=True)
    tei = commands.add_parser("tei", help="Extract null-hypothesis evidence from GROBID TEI XML full text")
    tei.add_argument("--input", type=Path, nargs="+", required=True, help="TEI .xml files or directories")
    tei.add_argument("--output", type=Path, required=True)
    tei.add_argument("--source", default="openalex")
    snapshot = commands.add_parser("snapshot", help="Read the public OpenAlex S3 Parquet snapshot")
    snapshot.add_argument("--input", nargs="+", default=[])
    snapshot.add_argument("--manifest", help="Public S3 manifest URL or a pinned local manifest; defaults to public works")
    snapshot.add_argument("--max-files", type=int, help="Select at most this many manifest files before enforcing the byte budget")
    snapshot.add_argument("--largest-first", action="store_true", help="Order parts by descending size; a partial run then covers more of the corpus")
    snapshot.add_argument("--skip-files", type=int, default=0, help="Skip this many leading manifest files, to continue after an earlier --max-files run in a new output directory")
    snapshot.add_argument("--output", type=Path, default=Path("data/snapshot/works.jsonl"))
    snapshot.add_argument("--max-bytes", type=int, default=5_000_000_000)
    snapshot.add_argument("--limit", type=int, help="Optional explicit row cap; absent means exhaust selected parts")
    snapshot.add_argument("--topic", default="", help="Optional additional topic-name filter")
    snapshot.add_argument("--profile", choices=list(PROFILES), default="hypertension-kidney", help="The -pubmed profile keeps only PubMed-indexed works, which registry linking and full text need")
    snapshot.add_argument("--work-ids", type=Path, help="Backfill these newline-separated W IDs from S3; overrides topic/profile filtering")
    snapshot.add_argument("--min-free-bytes", type=int, default=1_000_000_000)
    snapshot.add_argument("--plan", action="store_true", help="Inspect the manifest without reading Parquet")
    snapshot.add_argument("--resume", action="store_true")
    return root


async def run(args) -> dict:
    from app.config import settings

    if getattr(args, "batch_size", 1) <= 0:
        raise ValueError("batch-size must be positive")
    if args.command == "fetch":
        state = await fetch_pages(args.source, args.output,
                                  query=args.query, filters=args.filter, limit=None if args.all else args.limit,
                                  resume=args.resume, page_size=args.page_size)
        return {"records": state["count"], "source_exhausted": state["done"], "output": str(args.output)}
    if args.command == "label":
        from app.ingest.label import label_file
        return await label_file(args.input, args.output, limit=args.limit,
                                batch_size=args.batch_size, resume=args.resume)
    if args.command == "normalize":
        from app.ingest import flatten_trial, normalize_work

        def transform(rows):
            return [result for row in rows if (result := (
                flatten_trial(row) if "protocolSection" in row else normalize_work(row))) is not None]
    elif args.command == "classify":
        from app.classifier import ResultClassifier
        classifier = ResultClassifier(args.model or settings.classifier_model_path or None)

        def transform(rows):
            return [{**row, **classifier.classify(row)} if row.get("source") == "openalex" else row
                    for row in rows]
    elif args.command == "embed":
        from app.embeddings import get_embedder
        embedder = get_embedder(settings.embedding_model, settings.embedding_device)

        def transform(rows):
            texts = [" ".join(filter(None, [row.get("title", ""), row.get("abstract", ""),
                                            row.get("intervention", ""), row.get("outcome", "")])) for row in rows]
            vectors = embedder.encode(texts)
            return [{**row, "embedding": vector, "embedding_model": settings.embedding_model}
                    for row, vector in zip(rows, vectors, strict=True)]
    elif args.command == "link":
        from app.ingest.linker import link_studies
        signature = hashlib.sha256(json.dumps([
            [str(path.resolve()), path.stat().st_size, path.stat().st_mtime_ns] for path in args.input
        ]).encode()).hexdigest()
        checkpoint = args.output.with_suffix(args.output.suffix + ".checkpoint.json")
        if args.output.exists():
            if args.resume and checkpoint.exists():
                state = json.loads(checkpoint.read_text())
                if state.get("signature") == signature and state.get("done"):
                    return state
            raise FileExistsError("Link output exists with no matching completed checkpoint")
        rows = link_studies([row for path in args.input for row in read_jsonl(path)])
        args.output.parent.mkdir(parents=True, exist_ok=True)
        temporary = args.output.with_suffix(args.output.suffix + ".tmp")
        with temporary.open("w") as handle:
            for row in rows:
                handle.write(json.dumps(row) + "\n")
        temporary.replace(args.output)
        state = {"records": len(rows), "signature": signature, "done": True}
        atomic_json(checkpoint, state)
        return state
    elif args.command == "train-classifier":
        from app.classifier import train_classifier
        return train_classifier(list(read_jsonl(args.input)), args.output, label_field=args.label_field)
    elif args.command == "reference-ids":
        from app.ingest.openalex import normalize_work
        known, referenced = set(), set()
        for path in args.input:
            for row in read_jsonl(path):
                study = normalize_work(row) if "source" not in row else row
                known.add(str(study["id"]).rsplit("/", 1)[-1])
                if study.get("is_review"):
                    referenced.update(str(x).rsplit("/", 1)[-1] for x in study.get("referenced_works", []))
        missing = sorted(referenced - known)
        args.output.parent.mkdir(parents=True, exist_ok=True)
        temporary = args.output.with_suffix(args.output.suffix + ".tmp")
        temporary.write_text("".join(identifier + "\n" for identifier in missing))
        temporary.replace(args.output)
        return {"missing_reference_ids": len(missing), "output": str(args.output)}
    elif args.command == "tei":
        from app.ingest.tei import extract_tei_files
        return extract_tei_files(args.input, args.output, source=args.source)
    elif args.command == "snapshot":
        from app.ingest.snapshot import fingerprint, load_manifest, plan_manifest, scan_snapshot
        if args.input and args.manifest:
            raise ValueError("Choose --input local files or --manifest, not both")
        location = args.manifest or settings.openalex_snapshot_manifest
        if args.input:
            manifest = {"files": [{"url": str(Path(x).resolve()), "size_bytes": Path(x).stat().st_size} for x in args.input]}
        else:
            manifest = await load_manifest(location)
        plan = plan_manifest(manifest, max_files=args.max_files,
                             largest_first=getattr(args, "largest_first", False),
                             skip_files=getattr(args, "skip_files", 0))
        if args.plan:
            return {k: v for k, v in plan.items() if k != "files"}
        pinned = args.output.with_suffix(args.output.suffix + ".manifest.json")
        if pinned.exists() and fingerprint(json.loads(pinned.read_text())) != plan["manifest_sha256"]:
            raise ValueError("The snapshot manifest changed; use a new output directory for this release")
        atomic_json(pinned, manifest)
        files = [part["url"] for part in plan["files"]]
        work_ids = {line.strip() for line in args.work_ids.read_text().splitlines() if line.strip()} if args.work_ids else None
        result = await asyncio.to_thread(
            scan_snapshot, files, args.output, max_bytes=args.max_bytes, limit=args.limit,
            topic=args.topic, profile=args.profile,
            remote_sizes={part["url"]: part["size_bytes"] for part in plan["files"]},
            resume=args.resume, snapshot_id=plan["manifest_sha256"], snapshot_date=plan["snapshot_date"],
            all_parts_selected=plan["all_parts_selected"], work_ids=work_ids,
            min_free_bytes=args.min_free_bytes,
            progress=lambda state: print(json.dumps({"stage": "snapshot", "part": state["part"], "parts": state["selected_parts"], "records": state["records"]}), flush=True),
        )
        if not args.input and "://" in location and result["selected_parts_complete"]:
            if fingerprint(await load_manifest(location)) != plan["manifest_sha256"]:
                result["complete_snapshot_scope"] = False
                result["manifest_changed_during_scan"] = True
                atomic_json(args.output.with_suffix(args.output.suffix + ".checkpoint.json"), result)
                raise RuntimeError("Public snapshot changed during the scan; result is not a complete release")
        return result
    elif args.command == "index":
        from app.repository import ElasticRepository
        from app.statistics import assign_bucket
        repository = ElasticRepository()
        signature = hashlib.sha256(json.dumps([str(args.input.resolve()), args.input.stat().st_size,
                                              args.input.stat().st_mtime_ns, settings.elastic_index,
                                              args.sesoi, args.effect_type]).encode()).hexdigest()
        checkpoint = args.input.with_suffix(args.input.suffix + ".index-checkpoint.json")
        state = {"signature": signature, "records": 0}
        if args.resume and checkpoint.exists():
            state = json.loads(checkpoint.read_text())
            if state.get("signature") != signature:
                raise ValueError("Index checkpoint does not match input or settings")
        skipped = state["records"]
        batch = []
        merged = []
        try:
            await repository.ensure_index()
            for position, row in enumerate(read_jsonl(args.input)):
                if row.get("source") == "merged" and row.get("linked_papers"):
                    merged.append(row)
                if position < skipped:
                    continue
                row = {**row, **assign_bucket(row, sesoi=args.sesoi, effect_type=args.effect_type)}
                batch.append(row)
                if len(batch) >= args.batch_size:
                    await repository.bulk_upsert(batch)
                    state["records"] += len(batch)
                    atomic_json(checkpoint, state)
                    batch = []
            if batch:
                await repository.bulk_upsert(batch)
                state["records"] += len(batch)
                atomic_json(checkpoint, state)
            # A linked JSONL removes duplicate paper rows, but the index can still
            # contain papers from earlier runs. Persist canonical redirects after
            # every batch is durable, then checkpoint this migration separately.
            if state.get("links_version") != 1:
                await repository.persist_links(merged)
                state["links_version"] = 1
                atomic_json(checkpoint, state)
            return state
        finally:
            await repository.close()
    elif args.command == "pagerank":
        from app.ingest.pagerank import update_authority
        from app.repository import ElasticRepository
        repository = ElasticRepository()
        try:
            return await update_authority(
                repository,
                damping=args.damping,
                iterations=args.iterations,
                batch_size=args.batch_size,
            )
        finally:
            await repository.close()
    else:
        raise ValueError("Unsupported command")
    stage = args.command
    if args.command == "normalize":
        from app.ingest.openalex import NORMALIZER_VERSION
        stage += ":" + NORMALIZER_VERSION
    if args.command == "classify":
        from app.classifier import WEAK_CLASSIFIER_VERSION
        model_path = args.model or settings.classifier_model_path
        stage += ":" + (hashlib.sha256(Path(model_path).read_bytes()).hexdigest()
                        if model_path else WEAK_CLASSIFIER_VERSION)
        # Trained models still use the weak lexicon for evidence and rare-class fallbacks.
        if model_path:
            stage += ":" + WEAK_CLASSIFIER_VERSION
    if args.command == "embed":
        stage += ":" + settings.embedding_model
    return transform_file(args.input, args.output, transform, resume=args.resume,
                          stage=stage, batch_size=args.batch_size)


def main():
    arguments = parser().parse_args()
    try:
        print(json.dumps(asyncio.run(run(arguments)), indent=2))
    except (ValueError, RuntimeError, FileExistsError, TypeError) as exc:
        raise SystemExit(str(exc)) from None


if __name__ == "__main__":
    main()