From 204ece1caf97e067db8f731f0886682df39cc561 Mon Sep 17 00:00:00 2001 From: Jakub Werner Date: Sun, 18 Oct 2026 09:41:20 +0200 Subject: [PATCH 1/2] feat(ner): parser encoder from the checkpoint; pin the v5 UltraBERT parser The torch parser no longer hard-codes ModernBERT-base. resolve_encoder takes the encoder from the checkpoint: a parser repo that ships its encoder's config.json, modeling code and tokenizer loads them from that repo at the parser's pinned revision; a repo without one (v4.5) keeps ModernBERT-base. trust_remote_code is set only for a commit-SHA revision or a local directory, and AutoModel.from_config gets code_revision so the modeling code comes from the same commit as the config rather than the repo's default branch. NER_PARSER_ENCODER / NER_PARSER_ENCODER_REVISION override the encoder and are part of the parser cache key. NER_PARSER_CKPT / NER_PARSER_REVISION now point at scienceverse/bibr-parser-v5-ultrabert@f508ada9 (UltraBERT-150M + CRF). The configuration guide's runtime table and the dry-run size table mirror the pin, and test_model_pins checks both. The export script records the resolved encoder in the ONNX manifest. --- CHANGELOG.md | 13 +++ bibr/config.py | 33 ++++-- bibr/demo/server.py | 2 +- bibr/extract/ref_extractor.py | 18 ++- bibr/extract/ref_field_repair.py | 2 +- bibr/local/cli/dry_run.py | 4 +- bibr/local/cli/parser.py | 2 +- bibr/ner/model.py | 17 ++- bibr/ner/parser.py | 73 +++++++++++- bibr/ner/parser_onnx.py | 2 +- bibr/ner/runtime.py | 8 +- bibr/setup_wizard.py | 2 +- docs/guides/architecture.md | 2 +- docs/guides/configuration.md | 23 ++-- docs/guides/deployment.md | 2 +- scripts/export_onnx_ner_parser.py | 8 +- tests/ner/test_parser_encoder.py | 181 ++++++++++++++++++++++++++++++ tests/ner/test_revision_wiring.py | 12 +- tests/test_model_pins.py | 12 ++ 19 files changed, 374 insertions(+), 42 deletions(-) create mode 100644 tests/ner/test_parser_encoder.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 983f15bf..fe1a3da5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- The default reference parser is v5, `scienceverse/bibr-parser-v5-ultrabert` + (UltraBERT-150M encoder + CRF), trained on JATS reference markup plus the v4.5 + gold rows. Against v4.5 it raises reference exact match from 0.42 to 0.80 on + the refsets sealed test and from 0.56 to 0.73 on RenoBench, and edges it on the + v4.5 gold validation split (entity F1 0.971 vs 0.966). The parser repo carries + UltraBERT's config, modeling code and tokenizer, so the PyTorch runtime loads + them from the same pinned commit (`trust_remote_code` only at a commit SHA or + from a local directory); the ONNX bundle runs no custom code. The encoder now + comes from the checkpoint (ModernBERT-base for a repo without a `config.json`), + and `NER_PARSER_ENCODER` / `NER_PARSER_ENCODER_REVISION` (new) override it. + `NER_PARSER_CKPT=scienceverse/bibr-parser-v4-5-gold` with + `NER_PARSER_REVISION=ff50a83e7f5b73dcf6f8f973a1a6e3847ec429e6` keeps v4.5. + - The resolver's defaults are sized for batch runs: every paper in a bibr process now shares one cap of 8 resolver requests in flight (`BIBR_RESOLVER_MAX_CONCURRENCY`, new; `0` = no cap), one paper's title diff --git a/bibr/config.py b/bibr/config.py index 86e07a22..ac937206 100644 --- a/bibr/config.py +++ b/bibr/config.py @@ -2503,7 +2503,7 @@ def _normalize_serve_log_level(cls, value): # Reference extraction strategy — DECOUPLED segmentation + parsing axes # (resolved in bibr.extract.ref_extractor._resolve_ref_strategies; per-run # overrides ride RunConfig via chew(refs=...) / CLI --refs/--ref-seg). - # The local ModernBERT-CRF parser ("ner") is the default: reference parsing + # The local encoder-CRF parser ("ner") is the default: reference parsing # is the bulk of bibr's LLM tokens, so doing it locally cuts most of the # per-paper cost. To use LLM field parsing instead, set parse=llm (CLI # `--refs llm`). Segmentation defaults to the local geometry GBM ("geom"), @@ -2521,7 +2521,7 @@ def _normalize_serve_log_level(cls, value): ) REF_PARSE_STRATEGY: Literal["llm", "ner", "llm-chunked", "ref-llm", "off"] | None = Field( "ner", - description='Reference-parsing strategy: "ner" (default; local ModernBERT-CRF parser, no ' + description='Reference-parsing strategy: "ner" (default; local encoder-CRF parser, no ' 'per-reference LLM cost), "ref-llm" (dedicated fine-tuned reference model), "llm" (batched LLM), "llm-chunked" (chunk-tolerant ' 'LLM parse over region-aligned chunks), or "off" (disable reference extraction entirely).', ) @@ -2701,13 +2701,15 @@ def _normalize_serve_log_level(cls, value): description="Local path or HF Hub repo id for the NER reference-segmenter checkpoint " "(optionally suffixed :filename.pt).", ) - # Parser default is the v4.5 gold-trained feature-gated checkpoint (0.9656 - # strict micro entity F1 on the gold val split vs 0.0544 for giant-v4, whose - # span conventions are OOD). The parser path (RefParser → - # FeatureGatedEncoderCRF, add_special_tokens=False) requires a v4-format - # checkpoint; the legacy v1 parser is no longer loadable here. + # Parser default is v5: UltraBERT-150M + feature-gated CRF trained on JATS + # reference markup plus the v4.5 gold rows (refsets sealed test ref_exact + # 0.80 vs 0.42 for v4.5, RenoBench 0.73 vs 0.56, v4.5 gold val entity F1 + # 0.971 vs 0.966). The repo carries the encoder's config, modeling code and + # tokenizer, so they load from it at NER_PARSER_REVISION. The parser path + # (RefParser → FeatureGatedEncoderCRF, add_special_tokens=False) requires a + # v4-format checkpoint; the legacy v1 parser is no longer loadable here. NER_PARSER_CKPT: str = Field( - "scienceverse/bibr-parser-v4-5-gold", + "scienceverse/bibr-parser-v5-ultrabert", description="Local path or HF Hub repo id for the NER reference-parser checkpoint " "(optionally suffixed :filename.pt). Requires a v4-format checkpoint.", ) @@ -2721,10 +2723,23 @@ def _normalize_serve_log_level(cls, value): "track the latest revision instead.", ) NER_PARSER_REVISION: str = Field( - "ff50a83e7f5b73dcf6f8f973a1a6e3847ec429e6", + "f508ada9013b74443d7d872ab2de181f5b497ad2", description='Pinned commit revision for the NER parser checkpoint. Set to "main" to track ' "the latest revision instead.", ) + # Unset, the encoder comes from the checkpoint (bibr.ner.parser.resolve_encoder): + # the parser repo itself when it ships a config.json, else ModernBERT-base. + NER_PARSER_ENCODER: str | None = Field( + None, + description="Encoder (HF Hub repo id or local directory) whose config, modeling code and " + "tokenizer the torch NER parser loads. Unset: the checkpoint's own (a parser repo with a " + "config.json, at NER_PARSER_REVISION), else answerdotai/ModernBERT-base.", + ) + NER_PARSER_ENCODER_REVISION: str | None = Field( + None, + description="Revision of NER_PARSER_ENCODER. An encoder with its own modeling code runs it " + "only from a local directory or a commit SHA revision.", + ) NER_DEVICE: str | None = Field( None, description="Device for NER checkpoints: cpu, cuda, or mps (auto-detected if unset)." ) diff --git a/bibr/demo/server.py b/bibr/demo/server.py index d8a87d28..03a1904f 100644 --- a/bibr/demo/server.py +++ b/bibr/demo/server.py @@ -59,7 +59,7 @@ def build_parser() -> argparse.ArgumentParser: default=None, help=( "Reference extraction strategy: 'ner' (default) parses each reference " - "with the local ModernBERT-CRF model (no per-reference LLM cost); 'llm' " + "with the local encoder-CRF model (no per-reference LLM cost); 'llm' " "parses the bibliography with the configured LLM. " "Overrides REF_PARSE_STRATEGY for the demo." ), diff --git a/bibr/extract/ref_extractor.py b/bibr/extract/ref_extractor.py index 8393a518..686fc541 100644 --- a/bibr/extract/ref_extractor.py +++ b/bibr/extract/ref_extractor.py @@ -3,7 +3,7 @@ ``ReferenceExtractor`` turns a located references DataFrame into ``PaperReference`` objects: segmentation (geom GBM → region anchors → LLM anchor-emit → CRF cascade, plus marker-split last resort) followed by parsing -(batched LLM or local ModernBERT-CRF NER, dispatched through +(batched LLM or local encoder-CRF NER, dispatched through ``REF_PARSE_STRATEGIES``). Module-level helpers cover the strategy-independent finalize steps (DOI/author rescue, vol/issue normalization, bib_type inference, bib_id sequencing). @@ -583,7 +583,13 @@ def _get_ner_parser(settings: GlobalSettings | None = None, memory_mode: str | N global _NER_PARSER, _NER_PARSER_KEY settings = settings if settings is not None else snapshot_settings() device = resolve_ner_device(settings, memory_mode) - key = (settings.NER_PARSER_CKPT, device, settings.NER_PARSER_REVISION) + key = ( + settings.NER_PARSER_CKPT, + device, + settings.NER_PARSER_REVISION, + settings.NER_PARSER_ENCODER, + settings.NER_PARSER_ENCODER_REVISION, + ) if _NER_PARSER is None or key != _NER_PARSER_KEY: with _NER_LOCK: if _NER_PARSER is None or key != _NER_PARSER_KEY: @@ -970,8 +976,8 @@ def _backfill_vancouver_numbers(fields: dict[str, Any], segment: str) -> None: Fill-only: a field the parser already populated survives untouched, except when it holds the undecomposed span (see ``_is_lumped_span``). Interim - measure for a ref-parse training-distribution gap — the v4.5-gold parser is - APA-heavy and tags this tail ``O``. + measure for a ref-parse training-distribution gap — the v4.5-gold parser was + APA-heavy and tagged this tail ``O``. """ match = _match_vancouver_tail(fields, segment) if match is None: @@ -1664,7 +1670,7 @@ async def _parse_refs_via_ner( ref_text: str, # noqa: ARG001 — uniform strategy signature ref_strings: list[str], ) -> list[PaperReference]: - # Offload the synchronous ModernBERT-CRF forward pass to a worker thread so + # Offload the synchronous encoder-CRF forward pass to a worker thread so # it does not block the shared serve event loop (BibrPipelineAPI runs # enable_async=True, one loop for many concurrent requests). if ( @@ -2554,7 +2560,7 @@ def _crf_segment_or_recover(self, ref_text: str) -> list[str]: """CRF last-resort segmentation, guarded so a populated references region never silently yields 0. - Synchronous: a ModernBERT-CRF load plus N sliding-window forward passes + Synchronous: an encoder-CRF load plus N sliding-window forward passes holding the process-wide inference lock. Every caller offloads it with ``asyncio.to_thread``, matching ``_segment_geom`` and ``_parse_refs_via_ner`` — ``bibr serve`` runs one async worker, so this diff --git a/bibr/extract/ref_field_repair.py b/bibr/extract/ref_field_repair.py index 185a825f..a474555c 100644 --- a/bibr/extract/ref_field_repair.py +++ b/bibr/extract/ref_field_repair.py @@ -1,6 +1,6 @@ """Deterministic repairs of NER reference fields (post-parse, before finalize). -The NER reference parser (ModernBERT emissions + CRF) segments a reference +The NER reference parser (encoder emissions + CRF) segments a reference correctly far more often than it places the field boundaries correctly. Its recurring boundary errors are systematic enough to repair from the reference text itself, without retraining: diff --git a/bibr/local/cli/dry_run.py b/bibr/local/cli/dry_run.py index 2c61fe60..5fedcd8f 100644 --- a/bibr/local/cli/dry_run.py +++ b/bibr/local/cli/dry_run.py @@ -57,7 +57,9 @@ "scienceverse/bibr-geom-segmenter-v1": "1 MB", # answerdotai/ModernBERT-base (~150M params) encoder + CRF head. "scienceverse/bibr-segmenter-v1": "300 MB", - "scienceverse/bibr-parser-v4-5-gold": "300 MB", + # dmis-lab/UltraBERT-150M encoder + CRF head: best.pt (torch) or onnx/model.onnx + # (ONNX Runtime), 605 MB either way. + "scienceverse/bibr-parser-v5-ultrabert": "605 MB", # GLM-OCR weight repos — same figures as _OCR_BACKEND_DOWNLOAD_NOTES in # bibr/setup_wizard.py (bf16 ~2.7 GB, llama.cpp Q8_0 GGUF quant smaller). # Bare repo id — the ":Q8_0" quant suffix is stripped before lookup (see diff --git a/bibr/local/cli/parser.py b/bibr/local/cli/parser.py index 257911df..b1a7b425 100644 --- a/bibr/local/cli/parser.py +++ b/bibr/local/cli/parser.py @@ -235,7 +235,7 @@ def _add_pipeline_options(parser: argparse.ArgumentParser) -> None: "--refs", choices=["llm", "ner", "llm-chunked", "ref-llm", "off"], help=( - "Reference parser: 'ner' (default) uses the local ModernBERT-CRF model " + "Reference parser: 'ner' (default) uses the local encoder-CRF model " "through ONNX Runtime or the optional PyTorch runtime. " "'llm' parses references with the configured LLM in batches. " "'llm-chunked' parses region-aligned chunks of the reference list. " diff --git a/bibr/ner/model.py b/bibr/ner/model.py index d9c91b73..27444672 100644 --- a/bibr/ner/model.py +++ b/bibr/ner/model.py @@ -43,17 +43,26 @@ def __init__( num_tags: int, dropout: float = 0.0, bio_tags: list[str] | None = None, + revision: str | None = None, + trust_remote_code: bool = False, ) -> None: super().__init__() # Build the encoder from config only (no weight download). The base # pretrained weights would be immediately overwritten by the trained # checkpoint in load_state_dict, so loading them is wasted I/O and emits # a spurious transformers "UNEXPECTED keys" load report for the base - # MLM head we never use. + # MLM head we never use. An encoder with its own modeling code (UltraBERT) + # needs ``trust_remote_code``; ``code_revision`` keeps that code at the + # same pinned ``revision`` as its config (``from_config`` would otherwise + # fetch it from the repo's default branch). from bibr.utils.hf_cache import disable_hf_cache_symlinks_on_windows disable_hf_cache_symlinks_on_windows() - self.encoder = AutoModel.from_config(AutoConfig.from_pretrained(encoder_name)) + config = AutoConfig.from_pretrained( + encoder_name, revision=revision, trust_remote_code=trust_remote_code + ) + remote = {"trust_remote_code": True, "code_revision": revision} if trust_remote_code else {} + self.encoder = AutoModel.from_config(config, **remote) hidden_size: int = self.encoder.config.hidden_size self.dropout = nn.Dropout(dropout) self.linear = nn.Linear(hidden_size, num_tags) @@ -102,12 +111,16 @@ def __init__( dropout: float = 0.0, bio_tags: list[str] | None = None, feature_dim: int = _FEATURE_DIM, + revision: str | None = None, + trust_remote_code: bool = False, ) -> None: super().__init__( encoder_name=encoder_name, num_tags=num_tags, dropout=dropout, bio_tags=bio_tags, + revision=revision, + trust_remote_code=trust_remote_code, ) self.feature_dim = feature_dim self.feature_proj = nn.Linear(feature_dim, self.encoder.config.hidden_size) diff --git a/bibr/ner/parser.py b/bibr/ner/parser.py index 634586a9..d2d4074c 100644 --- a/bibr/ner/parser.py +++ b/bibr/ner/parser.py @@ -1,8 +1,13 @@ -"""Trained ref parser (ModernBERT + feature-gated CRF), v4 checkpoint. +"""Trained ref parser (encoder + feature-gated CRF), v4-format checkpoint. Takes a single reference string and emits BIO field tags, then maps the 39-tag parser output to a flat dict suitable for ``PaperReference.model_validate``. +The encoder comes from the checkpoint (see :func:`resolve_encoder`): a parser +repo that carries its encoder's ``config.json``, modeling code and tokenizer +(v5, UltraBERT-150M) loads them from that repo at the parser's pinned revision; +one that does not (v4.5) uses ModernBERT-base. + The v4 checkpoint (``best.pt``) is a :class:`~bibr.ner.model.FeatureGatedEncoderCRF` trained with ``add_special_tokens=False`` and no tag shift. Its ``feature_proj.weight`` is @@ -17,6 +22,7 @@ from __future__ import annotations import logging +import re from pathlib import Path from bibr.utils.ml_extra import ml_import_error @@ -27,6 +33,7 @@ except ImportError as e: # pragma: no cover raise ml_import_error("NER reference parsing (bibr.ner)") from e +from bibr.utils.ml_runtime import is_hub_repo_id from bibr.utils.text import strip_lone_surrogates from .checkpoint import resolve_checkpoint @@ -38,6 +45,55 @@ logger = logging.getLogger(__name__) DEFAULT_ENCODER = "answerdotai/ModernBERT-base" +ENCODER_CONFIG = "config.json" +_COMMIT_SHA = re.compile(r"[0-9a-f]{40}") + + +def resolve_encoder(ckpt: str | Path, revision: str | None = None) -> tuple[str, str | None]: + """The encoder source (Hub id or local directory) for ``ckpt``, and its revision. + + A checkpoint that ships its encoder's ``config.json`` next to the weights is + its own encoder source: a Hub repo at the parser's ``revision``, or the + checkpoint file's directory. Otherwise the encoder is ModernBERT-base, which + every checkpoint before v5 was trained on. A wrong guess cannot load + silently: the state dict is loaded strictly. + """ + ckpt_str = str(ckpt) + repo_id, _, filename = ckpt_str.partition(":") + if isinstance(ckpt, Path) or not is_hub_repo_id(repo_id, filename=filename or None, warn=False): + local = Path(ckpt_str).expanduser() + folder = local if local.is_dir() else local.parent + if (folder / ENCODER_CONFIG).is_file(): + return str(folder.resolve()), None + return DEFAULT_ENCODER, None + from bibr.utils.hf_cache import hf_download_or_cached + + try: + hf_download_or_cached(repo_id, ENCODER_CONFIG, revision) + except Exception as exc: # noqa: BLE001 — no encoder config: a pre-v5 checkpoint + logger.debug( + "%s@%s has no %s (%s); encoder %s", + repo_id, + revision or "main", + ENCODER_CONFIG, + type(exc).__name__, + DEFAULT_ENCODER, + ) + return DEFAULT_ENCODER, None + return repo_id, revision + + +def trusts_remote_code(encoder: str, revision: str | None) -> bool: + """Whether the encoder's own modeling code may run (``trust_remote_code``). + + Only code that cannot change under bibr: a local directory, or a Hub repo + pinned to a commit SHA. A branch or tag revision (or none) is never + trusted, so an encoder that needs custom code then fails to load instead of + running whatever the repo holds today. + """ + if not is_hub_repo_id(encoder, warn=False): + return True + return revision is not None and _COMMIT_SHA.fullmatch(revision) is not None class RefParser: @@ -47,10 +103,11 @@ class RefParser: def __init__( self, ckpt_path: str | Path, - encoder_name: str = DEFAULT_ENCODER, + encoder_name: str | None = None, device: str | None = None, max_seq_len: int = 256, revision: str | None = None, + encoder_revision: str | None = None, ) -> None: if device is None: from bibr.utils.device import detect_torch_device @@ -61,14 +118,22 @@ def __init__( from bibr.utils.hf_cache import disable_hf_cache_symlinks_on_windows disable_hf_cache_symlinks_on_windows() - self.tokenizer = AutoTokenizer.from_pretrained(encoder_name) + local_path = resolve_checkpoint(ckpt_path, revision=revision) + if encoder_name is None: + encoder_name, encoder_revision = resolve_encoder(ckpt_path, revision) + self.encoder_name = encoder_name + trust = trusts_remote_code(encoder_name, encoder_revision) + self.tokenizer = AutoTokenizer.from_pretrained( + encoder_name, revision=encoder_revision, trust_remote_code=trust + ) self.model = FeatureGatedEncoderCRF( encoder_name=encoder_name, num_tags=len(BIO_TAGS), dropout=0.0, bio_tags=BIO_TAGS, + revision=encoder_revision, + trust_remote_code=trust, ).to(device) - local_path = resolve_checkpoint(ckpt_path, revision=revision) state = torch.load(local_path, map_location=device, weights_only=True) self.model.load_state_dict(state) self.model.eval() diff --git a/bibr/ner/parser_onnx.py b/bibr/ner/parser_onnx.py index 199eb683..fd00232a 100644 --- a/bibr/ner/parser_onnx.py +++ b/bibr/ner/parser_onnx.py @@ -1,4 +1,4 @@ -"""ONNX Runtime reference parser (ModernBERT emissions + numpy CRF decode). +"""ONNX Runtime reference parser (encoder emissions + numpy CRF decode). Behaviourally identical to :class:`bibr.ner.parser.RefParser`: the same tokenizer settings (``add_special_tokens=False``, truncation to diff --git a/bibr/ner/runtime.py b/bibr/ner/runtime.py index c48be705..4e4eeabe 100644 --- a/bibr/ner/runtime.py +++ b/bibr/ner/runtime.py @@ -42,4 +42,10 @@ def load_ref_parser( import bibr.ner.parser as parser_mod logger.info("NER parser runtime: torch (%s@%s)", ckpt_str, revision or "main") - return parser_mod.RefParser(ckpt, device=device, revision=revision) + return parser_mod.RefParser( + ckpt, + encoder_name=getattr(settings, "NER_PARSER_ENCODER", None), + device=device, + revision=revision, + encoder_revision=getattr(settings, "NER_PARSER_ENCODER_REVISION", None), + ) diff --git a/bibr/setup_wizard.py b/bibr/setup_wizard.py index 6954e33b..728f09c4 100644 --- a/bibr/setup_wizard.py +++ b/bibr/setup_wizard.py @@ -1670,7 +1670,7 @@ def _step_external_services(self) -> None: # Reference parsing strategy (mirrors the ``--refs`` chew flag) self.console.print("\n[dim]Reference parsing (bibliography → structured fields):[/dim]") self.console.print( - " [cyan]ner[/cyan] — local ModernBERT-CRF parser (default): cuts most of\n" + " [cyan]ner[/cyan] — local encoder-CRF parser (default): cuts most of\n" " bibr's LLM token use (references are the bulk of it); reference\n" " field precision is lower (requires the ml extra)\n" " [cyan]llm[/cyan] — batched LLM parsing: full reference precision, at the\n" diff --git a/docs/guides/architecture.md b/docs/guides/architecture.md index 9e1b4a61..eae88e66 100644 --- a/docs/guides/architecture.md +++ b/docs/guides/architecture.md @@ -154,7 +154,7 @@ Post-parse pipeline runs after structure parsing: - **Study hierarchy** -- regex markers such as Study 1 and Experiment A establish separate section scopes before classification (`section_tree.py`) - **Metadata extraction** -- selected front-matter rows ground title, authors, abstract, and publication fields. JATS and HTML/ePub can supply preparsed metadata, avoiding the core metadata LLM call - **Paper classification** -- the default MiniLM multitask model predicts paper type and OECD domains from title/abstract; confidence gates and LLM fallback are described in [Classifiers](classifiers.md) -- **Reference extraction** -- segmentation (default `geom`, a local geometry model, cascading through region anchors -> LLM -> CRF when geometry is absent or unconfident, then checked against a segmentation of the section's printed lines that pools the geometry model's line votes with numbering, author/year onsets, hanging indent, layout boxes and DOI links, and replaces a fallback, under-yielding or clearly weaker result) locates each reference; parsing (default `ner`, a local ModernBERT-CRF model, with `llm` for opt-in batched LLM parsing) extracts structured fields +- **Reference extraction** -- segmentation (default `geom`, a local geometry model, cascading through region anchors -> LLM -> CRF when geometry is absent or unconfident, then checked against a segmentation of the section's printed lines that pools the geometry model's line votes with numbering, author/year onsets, hanging indent, layout boxes and DOI links, and replaces a fallback, under-yielding or clearly weaker result) locates each reference; parsing (default `ner`, a local encoder-CRF model, with `llm` for opt-in batched LLM parsing) extracts structured fields - **Citation linking** -- 3-tier hybrid approach: numeric bracket/superscript citations, author-year citations, then LLM fallback (`citation_linker.py`) - **Equation extraction** -- regex + LLM fallback for statistical reporting decomposition (`equation_extractor.py`) - **IMRaD enforcement** -- deduplication of Abstract and References by classification-source trust; repeated Methods/Results/Discussion remain valid diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 002ad23c..69dfc199 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -425,7 +425,7 @@ Reference parsing has its own strategy knob, `--refs` (or `REF_PARSE_STRATEGY`): reference with a local geometry model, cascading through layout-region anchors, LLM segmentation, and CRF when earlier tiers cannot resolve it; a segmentation of the section's printed lines replaces a fallback, - under-yielding or clearly weaker result. A local ModernBERT-CRF model parses + under-yielding or clearly weaker result. A local encoder-CRF model parses the resulting entries. The core install supports geometry and ONNX parsing. When a dedicated fine-tuned endpoint is configured, flagged entries receive bounded model review with NER retention @@ -475,7 +475,7 @@ observable source signals, not calibrated confidence. ## Local model runtime Four of bibr's models run locally: the PP-DocLayoutV3 layout detector, the -section classifier, the paper classifier and the ModernBERT+CRF reference +section classifier, the paper classifier and the encoder+CRF reference parser. Each ships twice — as PyTorch weights, and as an ONNX bundle (`onnx/model.onnx` + `onnx/bibr_onnx.json`, plus `onnx/tokenizer.json` for the text models) in the same Hub repo at the same pinned revision. `ML_RUNTIME` @@ -509,11 +509,20 @@ All four bundles are published, so a core install needs no configuration: | Layout | `scienceverse/bibr-layout-onnx` | `2bcb16a6` | | Section classifier | `scienceverse/bibr-section-classifier` | `cf7c2d85` | | Paper classifier | `scienceverse/bibr-paper-classifier` | `6046171b` | -| Reference parser | `scienceverse/bibr-parser-v4-5-gold` | `ff50a83e` | - -The paper-classifier and reference-parser pins are the commits that added -`onnx/` to the previously audited weights, so their PyTorch weights are -unchanged. The section-classifier pin is the model retrained in 0.7.1; set +| Reference parser | `scienceverse/bibr-parser-v5-ultrabert` | `f508ada9` | + +The paper-classifier pin is the commit that added `onnx/` to the previously +audited weights, so its PyTorch weights are unchanged. The reference-parser pin +is the v5 parser (UltraBERT-150M encoder); its repo also carries the encoder's +`config.json`, modeling code and tokenizer, so the PyTorch runtime loads them +from the same pinned commit with `trust_remote_code` and never runs unpinned +code, while the ONNX bundle needs no custom code at all. Set +`NER_PARSER_CKPT=scienceverse/bibr-parser-v4-5-gold` and +`NER_PARSER_REVISION=ff50a83e7f5b73dcf6f8f973a1a6e3847ec429e6` to keep the +previous ModernBERT-base parser. `NER_PARSER_ENCODER` (and +`NER_PARSER_ENCODER_REVISION`) override the encoder for a checkpoint that does +not ship one; an encoder with its own modeling code runs it only from a local +directory or a commit SHA. The section-classifier pin is the model retrained in 0.7.1; set `ML_SECTION_CLASSIFIER_REVISION=ee1a83db01ce947e3a5eb87dfde7221328f4b207` to keep the previous one. diff --git a/docs/guides/deployment.md b/docs/guides/deployment.md index fdf8bcb2..0d0e3068 100644 --- a/docs/guides/deployment.md +++ b/docs/guides/deployment.md @@ -557,7 +557,7 @@ REF_PARSE_STRATEGY=ner # "ner" (default) | "llm" | "llm-chunked" | "ref-llm" | By default, segmentation runs on a local geometry model (`geom`), which cascades to LLM assistance only when its own confidence is low, and -parsing runs on a local ModernBERT-CRF model (`ner`) — zero per-reference +parsing runs on a local encoder-CRF model (`ner`) — zero per-reference LLM cost in the common case. `REF_PARSE_STRATEGY=llm` trades that cost for LLM-based field extraction by parsing the bibliography with the configured LLM in batches (size controlled by diff --git a/scripts/export_onnx_ner_parser.py b/scripts/export_onnx_ner_parser.py index 46f8cfc9..740a8366 100644 --- a/scripts/export_onnx_ner_parser.py +++ b/scripts/export_onnx_ner_parser.py @@ -1,4 +1,4 @@ -"""Export the ModernBERT+CRF reference parser to ONNX and check parity. +"""Export the encoder+CRF reference parser to ONNX and check parity. Usage:: @@ -85,7 +85,7 @@ def main() -> int: import torch from bibr.config import GlobalSettings - from bibr.ner.parser import DEFAULT_ENCODER, RefParser + from bibr.ner.parser import RefParser from bibr.ner.parser_onnx import OnnxRefParser from bibr.ner.tags import BIO_TAGS @@ -118,7 +118,7 @@ def _eager_from_config(config, *a, **k): if getattr(torch_parser.tokenizer, "vocab_file", None) else None ) - # Save the exact tokenizer the torch parser uses (ModernBERT-base's tokenizer.json). + # Save the exact tokenizer the torch parser uses (its encoder's tokenizer.json). torch_parser.tokenizer.save_pretrained(str(bundle / "_tok")) (bundle / "_tok" / "tokenizer.json").replace(bundle / "tokenizer.json") for leftover in (bundle / "_tok").iterdir(): @@ -157,7 +157,7 @@ def _eager_from_config(config, *a, **k): { "model": "ner_parser", "architecture": "FeatureGatedEncoderCRF", - "encoder": DEFAULT_ENCODER, + "encoder": torch_parser.encoder_name, "opset": args.opset, "exporter": "dynamo" if args.dynamo else "torchscript", "attn_implementation": "eager" if args.eager else "default", diff --git a/tests/ner/test_parser_encoder.py b/tests/ner/test_parser_encoder.py new file mode 100644 index 00000000..ecc892b9 --- /dev/null +++ b/tests/ner/test_parser_encoder.py @@ -0,0 +1,181 @@ +"""Which encoder the torch parser loads, at which revision, and whether its code may run. + +v5 checkpoints ship their encoder (config, modeling code, tokenizer) in the parser +repo and load it from there at the parser's pinned revision; older checkpoints fall +back to ModernBERT-base. Custom encoder code runs only from a commit SHA or a local +directory. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +torch = pytest.importorskip("torch") +from torch import nn + +from bibr.ner import parser as parser_mod +from bibr.ner.parser import DEFAULT_ENCODER, resolve_encoder, trusts_remote_code + +SHA = "f508ada9013b74443d7d872ab2de181f5b497ad2" + + +def _hub_files(monkeypatch, present: set[str]) -> list: + """Stub the Hub: ``present`` holds the ``repo/filename`` pairs that exist.""" + import bibr.utils.hf_cache as hf_cache + + calls = [] + + def fake_download(repo_id, filename, revision=None): + calls.append((repo_id, filename, revision)) + if f"{repo_id}/{filename}" not in present: + raise FileNotFoundError(filename) + return f"/cache/{repo_id}/{filename}" + + monkeypatch.setattr(hf_cache, "hf_download_or_cached", fake_download) + return calls + + +def test_parser_repo_with_encoder_config_is_its_own_encoder_at_the_pin(monkeypatch): + calls = _hub_files(monkeypatch, {"org/parser-v5/config.json"}) + assert resolve_encoder("org/parser-v5", SHA) == ("org/parser-v5", SHA) + assert resolve_encoder("org/parser-v5:best.pt", SHA) == ("org/parser-v5", SHA) + assert calls[0] == ("org/parser-v5", "config.json", SHA) + + +def test_parser_repo_without_encoder_config_falls_back_to_modernbert(monkeypatch): + _hub_files(monkeypatch, set()) + assert resolve_encoder("org/parser-v4-5", "ff50a83e") == (DEFAULT_ENCODER, None) + + +def test_local_checkpoint_uses_a_sibling_encoder_config(tmp_path): + ckpt = tmp_path / "best.pt" + ckpt.write_bytes(b"") + assert resolve_encoder(ckpt) == (DEFAULT_ENCODER, None) + (tmp_path / "config.json").write_text("{}") + assert resolve_encoder(ckpt) == (str(tmp_path.resolve()), None) + assert resolve_encoder(str(ckpt)) == (str(tmp_path.resolve()), None) + + +@pytest.mark.parametrize( + ("encoder", "revision", "trusted"), + [ + ("org/parser-v5", SHA, True), + ("org/parser-v5", "main", False), + ("org/parser-v5", SHA[:8], False), + ("org/parser-v5", None, False), + (DEFAULT_ENCODER, None, False), + ], +) +def test_remote_code_is_trusted_only_at_a_commit_sha(encoder, revision, trusted): + assert trusts_remote_code(encoder, revision) is trusted + + +def test_local_encoder_directory_is_trusted(tmp_path): + assert trusts_remote_code(str(tmp_path), None) is True + + +class _StubModel: + def to(self, _device): + return self + + def load_state_dict(self, _state): + return None + + def eval(self): + return self + + +def _capture_loads(monkeypatch) -> dict: + seen: dict = {} + + def fake_tokenizer(name, **kwargs): + seen["tokenizer"] = (name, kwargs) + return object() + + def fake_model(**kwargs): + seen["model"] = kwargs + return _StubModel() + + monkeypatch.setattr(parser_mod, "resolve_checkpoint", lambda ckpt, revision=None: "/c/best.pt") + monkeypatch.setattr(parser_mod.AutoTokenizer, "from_pretrained", fake_tokenizer) + monkeypatch.setattr(parser_mod, "FeatureGatedEncoderCRF", fake_model) + monkeypatch.setattr(parser_mod.torch, "load", lambda *a, **k: {}) + return seen + + +def test_refparser_loads_a_v5_encoder_from_the_pinned_parser_repo(monkeypatch): + _hub_files(monkeypatch, {"org/parser-v5/config.json"}) + seen = _capture_loads(monkeypatch) + + parser = parser_mod.RefParser("org/parser-v5", device="cpu", revision=SHA) + + assert parser.encoder_name == "org/parser-v5" + assert seen["tokenizer"] == ("org/parser-v5", {"revision": SHA, "trust_remote_code": True}) + assert seen["model"]["encoder_name"] == "org/parser-v5" + assert seen["model"]["revision"] == SHA + assert seen["model"]["trust_remote_code"] is True + + +def test_refparser_keeps_modernbert_without_remote_code_for_v4_5(monkeypatch): + _hub_files(monkeypatch, set()) + seen = _capture_loads(monkeypatch) + + parser_mod.RefParser("org/parser-v4-5", device="cpu", revision="ff50a83e" * 5) + + assert seen["tokenizer"] == (DEFAULT_ENCODER, {"revision": None, "trust_remote_code": False}) + assert seen["model"]["revision"] is None + assert seen["model"]["trust_remote_code"] is False + + +def test_explicit_encoder_setting_reaches_refparser(monkeypatch): + from bibr.ner import runtime + + seen = {} + monkeypatch.setattr( + "bibr.utils.ml_runtime.resolve_runtime", lambda *a, **k: ("torch", None), raising=True + ) + monkeypatch.setattr(parser_mod, "RefParser", lambda ckpt, **k: seen.update(k) or "parser") + settings = SimpleNamespace(NER_PARSER_ENCODER="org/enc", NER_PARSER_ENCODER_REVISION=SHA) + + assert runtime.load_ref_parser("org/parser", revision="r", settings=settings) == "parser" + assert seen["encoder_name"] == "org/enc" + assert seen["encoder_revision"] == SHA + + +class _TinyEncoder(nn.Module): + def __init__(self) -> None: + super().__init__() + self.config = SimpleNamespace(hidden_size=4) + + +@pytest.mark.parametrize("trust", [True, False]) +def test_model_fetches_custom_encoder_code_at_the_config_revision(monkeypatch, trust): + import bibr.ner.model as model_mod + + seen = {} + + def fake_config(name, **kwargs): + seen["config"] = (name, kwargs) + return "cfg" + + def fake_from_config(config, **kwargs): + seen["from_config"] = (config, kwargs) + return _TinyEncoder() + + monkeypatch.setattr(model_mod.AutoConfig, "from_pretrained", fake_config) + monkeypatch.setattr(model_mod.AutoModel, "from_config", fake_from_config) + + model_mod.FeatureGatedEncoderCRF( + "org/parser-v5", + num_tags=3, + bio_tags=["O", "B-X", "I-X"], + revision=SHA, + trust_remote_code=trust, + ) + + assert seen["config"] == ("org/parser-v5", {"revision": SHA, "trust_remote_code": trust}) + # Without code_revision, from_config would fetch the modeling code from `main`. + expected = {"trust_remote_code": True, "code_revision": SHA} if trust else {} + assert seen["from_config"] == ("cfg", expected) diff --git a/tests/ner/test_revision_wiring.py b/tests/ner/test_revision_wiring.py index 023efe55..1cc6cbce 100644 --- a/tests/ner/test_revision_wiring.py +++ b/tests/ner/test_revision_wiring.py @@ -42,6 +42,11 @@ def fake_resolve(ckpt, revision=None): return "/cache/best.pt" monkeypatch.setattr(parser_mod, "resolve_checkpoint", fake_resolve) + monkeypatch.setattr( + parser_mod, + "resolve_encoder", + lambda ckpt, revision=None: (parser_mod.DEFAULT_ENCODER, None), + ) monkeypatch.setattr(parser_mod.AutoTokenizer, "from_pretrained", lambda *a, **k: object()) monkeypatch.setattr(parser_mod, "FeatureGatedEncoderCRF", lambda **k: _StubModel()) monkeypatch.setattr(parser_mod.torch, "load", lambda *a, **k: {}) @@ -79,8 +84,9 @@ def test_extractor_passes_configured_revisions(monkeypatch): calls = {} - def rec_parser(ckpt, device=None, revision=None): + def rec_parser(ckpt, device=None, revision=None, **encoder): calls["parser"] = revision + calls["encoder"] = encoder return object() def rec_seg(ckpt, device=None, revision=None): @@ -96,4 +102,8 @@ def rec_seg(ckpt, device=None, revision=None): ex._get_ner_segmenter() assert calls["parser"] == Settings.NER_PARSER_REVISION + assert calls["encoder"] == { + "encoder_name": Settings.NER_PARSER_ENCODER, + "encoder_revision": Settings.NER_PARSER_ENCODER_REVISION, + } assert calls["seg"] == Settings.NER_SEG_REVISION diff --git a/tests/test_model_pins.py b/tests/test_model_pins.py index 96f348d9..f865c537 100644 --- a/tests/test_model_pins.py +++ b/tests/test_model_pins.py @@ -63,3 +63,15 @@ def test_serve_image_pins_the_torch_runtime(): dockerfile = Path("Dockerfile.serve").read_text() assert "ENV ML_RUNTIME=torch" in dockerfile assert dockerfile.count("ignore_patterns=['onnx/*']") == 2 + + +def test_docs_and_dry_run_mirror_the_parser_pin(): + """The runtime table in the configuration guide and the dry-run download sizes + name the default reference parser; a re-pin must update them too.""" + from bibr.config import Settings + from bibr.local.cli.dry_run import _APPROX_MODEL_SIZES + + docs = Path("docs/guides/configuration.md").read_text() + row = f"| Reference parser | `{Settings.NER_PARSER_CKPT}` | `{Settings.NER_PARSER_REVISION[:8]}` |" + assert row in docs + assert Settings.NER_PARSER_CKPT in _APPROX_MODEL_SIZES From 93c0ebea15cc9004df658c11e572a9940f28270f Mon Sep 17 00:00:00 2001 From: Jakub Werner Date: Sun, 18 Oct 2026 10:47:30 +0200 Subject: [PATCH 2/2] feat(ner): pin the v5.1 UltraBERT parser trained on permissive-licence data Re-pin NER_PARSER_CKPT to scienceverse/bibr-parser-v5-1-ultrabert at 97b06cc7: the same recipe retrained on CC BY / CC0 JATS papers, LinkedBooks and the v4.5 gold rows only. Refsets sealed test ref_exact 0.75 vs 0.42 for v4.5, RenoBench 0.73 vs 0.56, v4.5 gold val entity F1 0.975 vs 0.966. Mirror the pin in the docs table, the dry-run size list and the changelog, and note that a parser repo with its own encoder code does not load at NER_PARSER_REVISION=main under PyTorch. --- CHANGELOG.md | 24 +++++++++++++----------- bibr/config.py | 15 ++++++++------- bibr/local/cli/dry_run.py | 2 +- docs/guides/configuration.md | 7 ++++--- tests/ner/test_parser_encoder.py | 2 +- 5 files changed, 27 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe1a3da5..466178d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,17 +18,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed -- The default reference parser is v5, `scienceverse/bibr-parser-v5-ultrabert` - (UltraBERT-150M encoder + CRF), trained on JATS reference markup plus the v4.5 - gold rows. Against v4.5 it raises reference exact match from 0.42 to 0.80 on - the refsets sealed test and from 0.56 to 0.73 on RenoBench, and edges it on the - v4.5 gold validation split (entity F1 0.971 vs 0.966). The parser repo carries - UltraBERT's config, modeling code and tokenizer, so the PyTorch runtime loads - them from the same pinned commit (`trust_remote_code` only at a commit SHA or - from a local directory); the ONNX bundle runs no custom code. The encoder now - comes from the checkpoint (ModernBERT-base for a repo without a `config.json`), - and `NER_PARSER_ENCODER` / `NER_PARSER_ENCODER_REVISION` (new) override it. - `NER_PARSER_CKPT=scienceverse/bibr-parser-v4-5-gold` with +- The default reference parser is v5.1, + `scienceverse/bibr-parser-v5-1-ultrabert` (UltraBERT-150M encoder + CRF), + trained on the reference markup of CC BY / CC0 JATS papers, LinkedBooks and + the v4.5 gold rows. Against v4.5 it raises reference exact match from 0.42 to + 0.75 on the refsets sealed test and from 0.56 to 0.73 on RenoBench, and edges + it on the v4.5 gold validation split (entity F1 0.975 vs 0.966). The parser + repo carries UltraBERT's config, modeling code and tokenizer, so the PyTorch + runtime loads them from the same pinned commit (`trust_remote_code` only at a + commit SHA or from a local directory, so `NER_PARSER_REVISION=main` does not + load it under PyTorch); the ONNX bundle runs no custom code. The encoder now + comes from the checkpoint (ModernBERT-base for a repo without a + `config.json`), and `NER_PARSER_ENCODER` / `NER_PARSER_ENCODER_REVISION` (new) + override it. `NER_PARSER_CKPT=scienceverse/bibr-parser-v4-5-gold` with `NER_PARSER_REVISION=ff50a83e7f5b73dcf6f8f973a1a6e3847ec429e6` keeps v4.5. - The resolver's defaults are sized for batch runs: every paper in a bibr diff --git a/bibr/config.py b/bibr/config.py index ac937206..85ede0c1 100644 --- a/bibr/config.py +++ b/bibr/config.py @@ -2701,15 +2701,15 @@ def _normalize_serve_log_level(cls, value): description="Local path or HF Hub repo id for the NER reference-segmenter checkpoint " "(optionally suffixed :filename.pt).", ) - # Parser default is v5: UltraBERT-150M + feature-gated CRF trained on JATS - # reference markup plus the v4.5 gold rows (refsets sealed test ref_exact - # 0.80 vs 0.42 for v4.5, RenoBench 0.73 vs 0.56, v4.5 gold val entity F1 - # 0.971 vs 0.966). The repo carries the encoder's config, modeling code and + # Parser default is v5.1: UltraBERT-150M + feature-gated CRF trained on the + # reference markup of CC BY / CC0 JATS papers, LinkedBooks and the v4.5 gold + # rows (refsets sealed test ref_exact 0.75 vs 0.42 for v4.5, RenoBench 0.73 + # vs 0.56, v4.5 gold val entity F1 0.975 vs 0.966). The repo carries the encoder's config, modeling code and # tokenizer, so they load from it at NER_PARSER_REVISION. The parser path # (RefParser → FeatureGatedEncoderCRF, add_special_tokens=False) requires a # v4-format checkpoint; the legacy v1 parser is no longer loadable here. NER_PARSER_CKPT: str = Field( - "scienceverse/bibr-parser-v5-ultrabert", + "scienceverse/bibr-parser-v5-1-ultrabert", description="Local path or HF Hub repo id for the NER reference-parser checkpoint " "(optionally suffixed :filename.pt). Requires a v4-format checkpoint.", ) @@ -2723,9 +2723,10 @@ def _normalize_serve_log_level(cls, value): "track the latest revision instead.", ) NER_PARSER_REVISION: str = Field( - "f508ada9013b74443d7d872ab2de181f5b497ad2", + "97b06cc7dd6511bdd53f8855c93a29020231d215", description='Pinned commit revision for the NER parser checkpoint. Set to "main" to track ' - "the latest revision instead.", + "the latest revision instead; a parser repo that ships its own encoder code (v5) loads " + "only from a commit SHA or a local directory.", ) # Unset, the encoder comes from the checkpoint (bibr.ner.parser.resolve_encoder): # the parser repo itself when it ships a config.json, else ModernBERT-base. diff --git a/bibr/local/cli/dry_run.py b/bibr/local/cli/dry_run.py index 5fedcd8f..842c3711 100644 --- a/bibr/local/cli/dry_run.py +++ b/bibr/local/cli/dry_run.py @@ -59,7 +59,7 @@ "scienceverse/bibr-segmenter-v1": "300 MB", # dmis-lab/UltraBERT-150M encoder + CRF head: best.pt (torch) or onnx/model.onnx # (ONNX Runtime), 605 MB either way. - "scienceverse/bibr-parser-v5-ultrabert": "605 MB", + "scienceverse/bibr-parser-v5-1-ultrabert": "605 MB", # GLM-OCR weight repos — same figures as _OCR_BACKEND_DOWNLOAD_NOTES in # bibr/setup_wizard.py (bf16 ~2.7 GB, llama.cpp Q8_0 GGUF quant smaller). # Bare repo id — the ":Q8_0" quant suffix is stripped before lookup (see diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 69dfc199..8f61f94c 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -509,14 +509,15 @@ All four bundles are published, so a core install needs no configuration: | Layout | `scienceverse/bibr-layout-onnx` | `2bcb16a6` | | Section classifier | `scienceverse/bibr-section-classifier` | `cf7c2d85` | | Paper classifier | `scienceverse/bibr-paper-classifier` | `6046171b` | -| Reference parser | `scienceverse/bibr-parser-v5-ultrabert` | `f508ada9` | +| Reference parser | `scienceverse/bibr-parser-v5-1-ultrabert` | `97b06cc7` | The paper-classifier pin is the commit that added `onnx/` to the previously audited weights, so its PyTorch weights are unchanged. The reference-parser pin -is the v5 parser (UltraBERT-150M encoder); its repo also carries the encoder's +is the v5.1 parser (UltraBERT-150M encoder); its repo also carries the encoder's `config.json`, modeling code and tokenizer, so the PyTorch runtime loads them from the same pinned commit with `trust_remote_code` and never runs unpinned -code, while the ONNX bundle needs no custom code at all. Set +code, while the ONNX bundle needs no custom code at all. For that reason the +PyTorch runtime cannot load it at `NER_PARSER_REVISION=main`; pin a commit SHA. Set `NER_PARSER_CKPT=scienceverse/bibr-parser-v4-5-gold` and `NER_PARSER_REVISION=ff50a83e7f5b73dcf6f8f973a1a6e3847ec429e6` to keep the previous ModernBERT-base parser. `NER_PARSER_ENCODER` (and diff --git a/tests/ner/test_parser_encoder.py b/tests/ner/test_parser_encoder.py index ecc892b9..fc350c38 100644 --- a/tests/ner/test_parser_encoder.py +++ b/tests/ner/test_parser_encoder.py @@ -18,7 +18,7 @@ from bibr.ner import parser as parser_mod from bibr.ner.parser import DEFAULT_ENCODER, resolve_encoder, trusts_remote_code -SHA = "f508ada9013b74443d7d872ab2de181f5b497ad2" +SHA = "97b06cc7dd6511bdd53f8855c93a29020231d215" def _hub_files(monkeypatch, present: set[str]) -> list: