From 4bcf967a9a06fb172361b32a24e3026fe2e46c24 Mon Sep 17 00:00:00 2001 From: Daniel Ecer Date: Wed, 1 Jul 2026 16:45:32 +0100 Subject: [PATCH 1/9] Add grobid_custom_hybrid profile with SciELO-preprints reference segmenter Introduces a new sequence-model profile that overlays the grobid 0.9.0 CRF/wapiti baseline with our own retrained DELFT models, starting with a CustomBidLSTM_CRF reference segmenter trained on SciELO preprints. Intended to grow as more custom models are retrained and swapped in. --- .../resources/default_config/config.yml | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/sciencebeam_parser/resources/default_config/config.yml b/sciencebeam_parser/resources/default_config/config.yml index 45a205fb..5b185d8b 100644 --- a/sciencebeam_parser/resources/default_config/config.yml +++ b/sciencebeam_parser/resources/default_config/config.yml @@ -179,6 +179,38 @@ sequence_model_profiles: citation: path: 'https://github.com/kermitt2/grobid/raw/0.9.0/grobid-home/models/citation' engine: 'wapiti' + grobid_custom_hybrid: + segmentation: + path: 'https://github.com/kermitt2/grobid/raw/0.9.0/grobid-home/models/segmentation' + engine: 'wapiti' + use_first_token_of_block: true + header: + path: 'https://github.com/kermitt2/grobid/raw/0.9.0/grobid-home/models/header' + engine: 'wapiti' + persist_indentation_reference_across_blocks: true + name_header: + path: 'https://github.com/kermitt2/grobid/raw/0.9.0/grobid-home/models/name/header' + engine: 'wapiti' + name_citation: + path: 'https://github.com/kermitt2/grobid/raw/0.9.0/grobid-home/models/name/citation' + engine: 'wapiti' + affiliation_address: + path: 'https://github.com/kermitt2/grobid/raw/0.9.0/grobid-home/models/affiliation-address' + engine: 'wapiti' + fulltext: + path: 'https://github.com/kermitt2/grobid/raw/0.9.0/grobid-home/models/fulltext' + engine: 'wapiti' + figure: + path: 'https://github.com/kermitt2/grobid/raw/0.9.0/grobid-home/models/figure' + engine: 'wapiti' + table: + path: 'https://github.com/kermitt2/grobid/raw/0.9.0/grobid-home/models/table' + engine: 'wapiti' + reference_segmenter: + path: 'https://github.com/eLifePathways/sciencebeam-models/releases/download/dev/2026-07-01-delft-grobid-reference-segmenter-CustomBidLSTM_CRF-grobid_090-scielo_preprints-v1.tar.gz' + citation: + path: 'https://github.com/kermitt2/grobid/raw/0.9.0/grobid-home/models/citation' + engine: 'wapiti' profiles: biorxiv_elife: @@ -188,6 +220,11 @@ profiles: processors: fulltext: noise_filter_enabled: false + grobid_custom_hybrid: + sequence_models: grobid_custom_hybrid + processors: + fulltext: + noise_filter_enabled: false profile_aliases: grobid_crf: grobid_crf_0_9_0 From 3c1f56d816cf1c422fba83efd69d2183bfa255f0 Mon Sep 17 00:00:00 2001 From: Daniel Ecer Date: Wed, 1 Jul 2026 18:05:26 +0100 Subject: [PATCH 2/9] Add grobid_custom_hybrid profile with SciELO-preprints reference segmenter Introduces a new sequence-model profile that overlays the grobid 0.9.0 CRF/wapiti baseline with our own retrained DELFT models, starting with a CustomBidLSTM_CRF reference segmenter trained on SciELO preprints. Intended to grow as more custom models are retrained and swapped in. Also wires up the profile:grobid_custom_hybrid PR label in the benchmark workflow so it selects this profile. --- .github/workflows/benchmark.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 22f3ba70..82a52532 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -57,6 +57,7 @@ jobs: ${{ contains(github.event.pull_request.labels.*.name, 'profile:grobid_crf') && 'grobid_crf' || contains(github.event.pull_request.labels.*.name, 'profile:biorxiv_elife') && 'biorxiv_elife' || + contains(github.event.pull_request.labels.*.name, 'profile:grobid_custom_hybrid') && 'grobid_custom_hybrid' || inputs.profile || 'grobid_crf' }} From e63b775c5336b98711756081c90ab72d272c90fc Mon Sep 17 00:00:00 2001 From: Daniel Ecer Date: Wed, 1 Jul 2026 18:14:22 +0100 Subject: [PATCH 3/9] Automatically resolve benchmark profile from profile: PR labels Replaces the hardcoded per-profile contains() checks with a step that extracts any profile: label directly, validated against a safe character set, so new profiles no longer require a benchmark.yml edit. --- .github/workflows/benchmark.yml | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 82a52532..16a16e3a 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -53,19 +53,24 @@ jobs: github.ref == 'refs/heads/main' && 'medium' || 'smoke' }} - BENCHMARK_PROFILE: >- - ${{ - contains(github.event.pull_request.labels.*.name, 'profile:grobid_crf') && 'grobid_crf' || - contains(github.event.pull_request.labels.*.name, 'profile:biorxiv_elife') && 'biorxiv_elife' || - contains(github.event.pull_request.labels.*.name, 'profile:grobid_custom_hybrid') && 'grobid_custom_hybrid' || - inputs.profile || - 'grobid_crf' - }} BENCHMARK_RUN: benchmarks/runs/${{ github.sha }}/validation steps: - uses: actions/checkout@v5 + - name: Resolve benchmark profile + env: + PR_LABELS_JSON: ${{ toJson(github.event.pull_request.labels.*.name) }} + DISPATCH_PROFILE: ${{ inputs.profile }} + run: | + LABEL_PROFILE=$(echo "$PR_LABELS_JSON" | jq -r '.[] | select(startswith("profile:")) | sub("^profile:"; "")' | head -n1) + PROFILE="${LABEL_PROFILE:-${DISPATCH_PROFILE:-grobid_crf}}" + if ! [[ "$PROFILE" =~ ^[A-Za-z0-9_]+$ ]]; then + echo "::error::Invalid profile '$PROFILE' (from label or input) - only letters, digits and underscores are allowed" + exit 1 + fi + echo "BENCHMARK_PROFILE=$PROFILE" >> "$GITHUB_ENV" + - name: Set image tag id: image_tag run: | From d4fe2e7dde0e3a8ab9f31b8157cdf1a0ad079471 Mon Sep 17 00:00:00 2001 From: Daniel Ecer Date: Thu, 2 Jul 2026 08:22:56 +0100 Subject: [PATCH 4/9] Temporarily use private GCS bucket for reference-segmenter model Authenticate CI to GCP via Workload Identity Federation so the grobid_custom_hybrid profile can pull the reference-segmenter model from gs://sciencebeam-v2-models while the trained model's release is not yet public. --- .github/workflows/benchmark.yml | 10 ++++++++++ sciencebeam_parser/resources/default_config/config.yml | 4 +++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 16a16e3a..6b2c7059 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -40,6 +40,7 @@ jobs: contents: read packages: read pull-requests: write + id-token: write # required to mint the WIF token for GCS access env: BENCHMARK_SPLIT: validation @@ -137,6 +138,13 @@ jobs: --predictions-repo sciencebeam-eval-predictions \ --baseline-only + - name: Authenticate to GCP (temporary access to private model bucket) + id: gcp_auth + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: projects/476793733249/locations/global/workloadIdentityPools/sciencebeam-parser-github/providers/github-provider + service_account: sciencebeam-parser-ci@sciencebeam-v2.iam.gserviceaccount.com + - name: Start sciencebeam-parser run: | PROFILE_ENV=() @@ -145,6 +153,8 @@ jobs: fi docker run -d --name sciencebeam-parser -p 8080:8070 \ -e SCIENCEBEAM_PARSER__PRELOAD_ON_STARTUP=true \ + -v "${{ steps.gcp_auth.outputs.credentials_file_path }}:/creds/gcp-key.json:ro" \ + -e GOOGLE_APPLICATION_CREDENTIALS=/creds/gcp-key.json \ "${PROFILE_ENV[@]}" ${{ steps.image_tag.outputs.value }} - name: Wait for parser diff --git a/sciencebeam_parser/resources/default_config/config.yml b/sciencebeam_parser/resources/default_config/config.yml index 5b185d8b..a84b8de5 100644 --- a/sciencebeam_parser/resources/default_config/config.yml +++ b/sciencebeam_parser/resources/default_config/config.yml @@ -207,7 +207,9 @@ sequence_model_profiles: path: 'https://github.com/kermitt2/grobid/raw/0.9.0/grobid-home/models/table' engine: 'wapiti' reference_segmenter: - path: 'https://github.com/eLifePathways/sciencebeam-models/releases/download/dev/2026-07-01-delft-grobid-reference-segmenter-CustomBidLSTM_CRF-grobid_090-scielo_preprints-v1.tar.gz' + # TODO: switch back once the release is public + # path: 'https://github.com/eLifePathways/sciencebeam-models/releases/download/dev/2026-07-01-delft-grobid-reference-segmenter-CustomBidLSTM_CRF-grobid_090-scielo_preprints-v1.tar.gz' + path: 'gs://sciencebeam-v2-models/delft/reference-segmenter/models/2026-07-01_CustomBidLSTM_CRF_grobid_090_scielo_preprints_ore/reference-segmenter/' citation: path: 'https://github.com/kermitt2/grobid/raw/0.9.0/grobid-home/models/citation' engine: 'wapiti' From 77926d45fb3dfb85a038c1f16f20eba1a4a34886 Mon Sep 17 00:00:00 2001 From: Daniel Ecer Date: Thu, 2 Jul 2026 09:03:04 +0100 Subject: [PATCH 5/9] Moved Authenticate to GCP up to fail earlier --- .github/workflows/benchmark.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 6b2c7059..243bcd30 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -59,6 +59,13 @@ jobs: steps: - uses: actions/checkout@v5 + - name: Authenticate to GCP (temporary access to private model bucket) + id: gcp_auth + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: projects/476793733249/locations/global/workloadIdentityPools/sciencebeam-parser-github/providers/github-provider + service_account: sciencebeam-parser-ci@sciencebeam-v2.iam.gserviceaccount.com + - name: Resolve benchmark profile env: PR_LABELS_JSON: ${{ toJson(github.event.pull_request.labels.*.name) }} @@ -138,13 +145,6 @@ jobs: --predictions-repo sciencebeam-eval-predictions \ --baseline-only - - name: Authenticate to GCP (temporary access to private model bucket) - id: gcp_auth - uses: google-github-actions/auth@v2 - with: - workload_identity_provider: projects/476793733249/locations/global/workloadIdentityPools/sciencebeam-parser-github/providers/github-provider - service_account: sciencebeam-parser-ci@sciencebeam-v2.iam.gserviceaccount.com - - name: Start sciencebeam-parser run: | PROFILE_ENV=() From 0f22a7261c0f45792058ef8c5eb6b9098dbf4a1d Mon Sep 17 00:00:00 2001 From: Daniel Ecer Date: Thu, 2 Jul 2026 10:29:16 +0100 Subject: [PATCH 6/9] Switch GCS model bucket access to a service account key TensorFlow's built-in gs:// filesystem support only recognizes plain service-account JSON keys, not the Workload Identity Federation external_account credential files google-github-actions/auth produces - those reference a credential source that can't be resolved from inside the sciencebeam-parser container, causing anonymous (unauthenticated) requests. Decode a service account key from the GCS_MODEL_READER_SA_KEY_B64 secret instead and mount it into the container. --- .github/workflows/benchmark.yml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 243bcd30..f1c4d056 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -40,7 +40,6 @@ jobs: contents: read packages: read pull-requests: write - id-token: write # required to mint the WIF token for GCS access env: BENCHMARK_SPLIT: validation @@ -59,12 +58,15 @@ jobs: steps: - uses: actions/checkout@v5 - - name: Authenticate to GCP (temporary access to private model bucket) + - name: Write GCS model reader key (temporary access to private model bucket) id: gcp_auth - uses: google-github-actions/auth@v2 - with: - workload_identity_provider: projects/476793733249/locations/global/workloadIdentityPools/sciencebeam-parser-github/providers/github-provider - service_account: sciencebeam-parser-ci@sciencebeam-v2.iam.gserviceaccount.com + env: + GCS_MODEL_READER_SA_KEY_B64: ${{ secrets.GCS_MODEL_READER_SA_KEY_B64 }} + run: | + KEY_PATH="${RUNNER_TEMP}/gcs-model-reader-key.json" + printf '%s' "$GCS_MODEL_READER_SA_KEY_B64" | base64 -d > "$KEY_PATH" + chmod 600 "$KEY_PATH" + echo "key_path=$KEY_PATH" >> "$GITHUB_OUTPUT" - name: Resolve benchmark profile env: @@ -153,7 +155,7 @@ jobs: fi docker run -d --name sciencebeam-parser -p 8080:8070 \ -e SCIENCEBEAM_PARSER__PRELOAD_ON_STARTUP=true \ - -v "${{ steps.gcp_auth.outputs.credentials_file_path }}:/creds/gcp-key.json:ro" \ + -v "${{ steps.gcp_auth.outputs.key_path }}:/creds/gcp-key.json:ro" \ -e GOOGLE_APPLICATION_CREDENTIALS=/creds/gcp-key.json \ "${PROFILE_ENV[@]}" ${{ steps.image_tag.outputs.value }} From e76a885ccd733bac45019748b01a4abbd9af1edd Mon Sep 17 00:00:00 2001 From: Daniel Ecer Date: Thu, 2 Jul 2026 11:22:58 +0100 Subject: [PATCH 7/9] Use custom citation model for grobid_custom_hybrid profile Points the citation model at the new CustomBidLSTM_CRF model in the private sciencebeam-v2-models GCS bucket, replacing the grobid 0.9.0 wapiti model, while its release is not yet public. --- sciencebeam_parser/resources/default_config/config.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/sciencebeam_parser/resources/default_config/config.yml b/sciencebeam_parser/resources/default_config/config.yml index a84b8de5..da974319 100644 --- a/sciencebeam_parser/resources/default_config/config.yml +++ b/sciencebeam_parser/resources/default_config/config.yml @@ -211,8 +211,10 @@ sequence_model_profiles: # path: 'https://github.com/eLifePathways/sciencebeam-models/releases/download/dev/2026-07-01-delft-grobid-reference-segmenter-CustomBidLSTM_CRF-grobid_090-scielo_preprints-v1.tar.gz' path: 'gs://sciencebeam-v2-models/delft/reference-segmenter/models/2026-07-01_CustomBidLSTM_CRF_grobid_090_scielo_preprints_ore/reference-segmenter/' citation: - path: 'https://github.com/kermitt2/grobid/raw/0.9.0/grobid-home/models/citation' - engine: 'wapiti' + # TODO: switch back once a public release is available + # path: 'https://github.com/kermitt2/grobid/raw/0.9.0/grobid-home/models/citation' + # engine: 'wapiti' + path: 'gs://sciencebeam-v2-models/delft/citation/models/2026-07-01_CustomBidLSTM_CRF_grobid_090/citation/' profiles: biorxiv_elife: From 0798184ef4b8a8c4925bb752e9e5ce32673e6c59 Mon Sep 17 00:00:00 2001 From: Daniel Ecer Date: Fri, 3 Jul 2026 12:23:52 +0100 Subject: [PATCH 8/9] Use trained wapiti model for reference segmenter --- sciencebeam_parser/resources/default_config/config.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/sciencebeam_parser/resources/default_config/config.yml b/sciencebeam_parser/resources/default_config/config.yml index da974319..cacb3b09 100644 --- a/sciencebeam_parser/resources/default_config/config.yml +++ b/sciencebeam_parser/resources/default_config/config.yml @@ -209,12 +209,14 @@ sequence_model_profiles: reference_segmenter: # TODO: switch back once the release is public # path: 'https://github.com/eLifePathways/sciencebeam-models/releases/download/dev/2026-07-01-delft-grobid-reference-segmenter-CustomBidLSTM_CRF-grobid_090-scielo_preprints-v1.tar.gz' - path: 'gs://sciencebeam-v2-models/delft/reference-segmenter/models/2026-07-01_CustomBidLSTM_CRF_grobid_090_scielo_preprints_ore/reference-segmenter/' + # path: 'gs://sciencebeam-v2-models/delft/reference-segmenter/models/2026-07-01_CustomBidLSTM_CRF_grobid_090_scielo_preprints_ore/reference-segmenter/' + path: 'gs://sciencebeam-v2-models/wapiti/reference-segmenter/models/2026-07-01_wapiti_grobid_090_scielo_preprints_ore/reference-segmenter' + engine: 'wapiti' citation: # TODO: switch back once a public release is available - # path: 'https://github.com/kermitt2/grobid/raw/0.9.0/grobid-home/models/citation' - # engine: 'wapiti' - path: 'gs://sciencebeam-v2-models/delft/citation/models/2026-07-01_CustomBidLSTM_CRF_grobid_090/citation/' + path: 'https://github.com/kermitt2/grobid/raw/0.9.0/grobid-home/models/citation' + engine: 'wapiti' + # path: 'gs://sciencebeam-v2-models/delft/citation/models/2026-07-01_CustomBidLSTM_CRF_grobid_090/citation/' profiles: biorxiv_elife: From 2ee72f4fb19dc211e5005136f8a38b0a9f4673ca Mon Sep 17 00:00:00 2001 From: Daniel Ecer Date: Fri, 3 Jul 2026 12:47:16 +0100 Subject: [PATCH 9/9] Add explicit extends for sequence_model_profiles Custom hybrid profiles are expected to grow beyond this one, so inherit from a base profile via a code-resolved `extends` key rather than a YAML anchor - it deep-merges instead of replacing whole model entries, gives clear errors for unknown/circular references, and keeps working if profiles ever get split across files. --- sciencebeam_parser/config/config.py | 27 +++++++- .../resources/default_config/config.yml | 32 +-------- tests/config/config_test.py | 69 ++++++++++++++++++- 3 files changed, 94 insertions(+), 34 deletions(-) diff --git a/sciencebeam_parser/config/config.py b/sciencebeam_parser/config/config.py index 05a860dc..4b0bae36 100644 --- a/sciencebeam_parser/config/config.py +++ b/sciencebeam_parser/config/config.py @@ -2,7 +2,7 @@ import os import copy from pathlib import Path -from typing import Any, Optional, Union +from typing import Any, FrozenSet, Optional, Union import yaml @@ -27,6 +27,29 @@ def _deep_merge(base: dict, overlay: dict) -> dict: return result +def _resolve_sequence_model_profile( + seq_profiles: dict, + name: str, + _seen: FrozenSet[str] = frozenset() +) -> dict: + if name not in seq_profiles: + raise ValueError( + f'Unknown sequence_model_profile {name!r}. Available: {sorted(seq_profiles)}' + ) + if name in _seen: + raise ValueError( + f'Circular extends detected for sequence_model_profile {name!r} ' + f'(chain: {" -> ".join([*_seen, name])})' + ) + profile = seq_profiles[name] + base_name = profile.get('extends') + overlay = {key: value for key, value in profile.items() if key != 'extends'} + if not base_name: + return overlay + base = _resolve_sequence_model_profile(seq_profiles, base_name, _seen | {name}) + return _deep_merge(base, overlay) + + class AppConfig: def __init__(self, props: dict): self.props = props @@ -87,7 +110,7 @@ def resolve_profile(self, profile_name: Optional[str] = None) -> 'AppConfig': f'Profile {resolved!r} references unknown sequence_model_profile ' f'{seq_name!r}. Available: {sorted(seq_profiles)}' ) - overlay['models'] = seq_profiles[seq_name] + overlay['models'] = _resolve_sequence_model_profile(seq_profiles, seq_name) for key, value in profile.items(): if key != 'sequence_models': diff --git a/sciencebeam_parser/resources/default_config/config.yml b/sciencebeam_parser/resources/default_config/config.yml index cacb3b09..f73a3b52 100644 --- a/sciencebeam_parser/resources/default_config/config.yml +++ b/sciencebeam_parser/resources/default_config/config.yml @@ -180,43 +180,13 @@ sequence_model_profiles: path: 'https://github.com/kermitt2/grobid/raw/0.9.0/grobid-home/models/citation' engine: 'wapiti' grobid_custom_hybrid: - segmentation: - path: 'https://github.com/kermitt2/grobid/raw/0.9.0/grobid-home/models/segmentation' - engine: 'wapiti' - use_first_token_of_block: true - header: - path: 'https://github.com/kermitt2/grobid/raw/0.9.0/grobid-home/models/header' - engine: 'wapiti' - persist_indentation_reference_across_blocks: true - name_header: - path: 'https://github.com/kermitt2/grobid/raw/0.9.0/grobid-home/models/name/header' - engine: 'wapiti' - name_citation: - path: 'https://github.com/kermitt2/grobid/raw/0.9.0/grobid-home/models/name/citation' - engine: 'wapiti' - affiliation_address: - path: 'https://github.com/kermitt2/grobid/raw/0.9.0/grobid-home/models/affiliation-address' - engine: 'wapiti' - fulltext: - path: 'https://github.com/kermitt2/grobid/raw/0.9.0/grobid-home/models/fulltext' - engine: 'wapiti' - figure: - path: 'https://github.com/kermitt2/grobid/raw/0.9.0/grobid-home/models/figure' - engine: 'wapiti' - table: - path: 'https://github.com/kermitt2/grobid/raw/0.9.0/grobid-home/models/table' - engine: 'wapiti' + extends: grobid_crf_0_9_0 reference_segmenter: # TODO: switch back once the release is public # path: 'https://github.com/eLifePathways/sciencebeam-models/releases/download/dev/2026-07-01-delft-grobid-reference-segmenter-CustomBidLSTM_CRF-grobid_090-scielo_preprints-v1.tar.gz' # path: 'gs://sciencebeam-v2-models/delft/reference-segmenter/models/2026-07-01_CustomBidLSTM_CRF_grobid_090_scielo_preprints_ore/reference-segmenter/' path: 'gs://sciencebeam-v2-models/wapiti/reference-segmenter/models/2026-07-01_wapiti_grobid_090_scielo_preprints_ore/reference-segmenter' engine: 'wapiti' - citation: - # TODO: switch back once a public release is available - path: 'https://github.com/kermitt2/grobid/raw/0.9.0/grobid-home/models/citation' - engine: 'wapiti' - # path: 'gs://sciencebeam-v2-models/delft/citation/models/2026-07-01_CustomBidLSTM_CRF_grobid_090/citation/' profiles: biorxiv_elife: diff --git a/tests/config/config_test.py b/tests/config/config_test.py index 22faa2d8..3ebc1bf3 100644 --- a/tests/config/config_test.py +++ b/tests/config/config_test.py @@ -5,7 +5,11 @@ import pytest import yaml -from sciencebeam_parser.config.config import AppConfig, _deep_merge +from sciencebeam_parser.config.config import ( + AppConfig, + _deep_merge, + _resolve_sequence_model_profile +) MINIMAL_PROFILE_CONFIG = { @@ -18,10 +22,15 @@ 'segmentation': {'path': 'path_b/segmentation', 'engine': 'wapiti'}, 'header': {'path': 'path_b/header', 'engine': 'wapiti'}, }, + 'profile_b_extended': { + 'extends': 'profile_b', + 'header': {'path': 'path_b_extended/header', 'engine': 'wapiti'}, + }, }, 'profiles': { 'profile_a': {'sequence_models': 'profile_a'}, 'profile_b': {'sequence_models': 'profile_b'}, + 'profile_b_extended': {'sequence_models': 'profile_b_extended'}, 'profile_with_extra': { 'sequence_models': 'profile_a', 'processors': {'fulltext': {'use_cv_model': True}}, @@ -67,6 +76,59 @@ def test_does_not_mutate_base(self): assert base['a']['b'] == 1 +class TestResolveSequenceModelProfile: + def test_returns_profile_without_extends_unchanged(self): + seq_profiles = { + 'base': {'segmentation': {'path': 'base/segmentation'}}, + } + result = _resolve_sequence_model_profile(seq_profiles, 'base') + assert result == {'segmentation': {'path': 'base/segmentation'}} + + def test_merges_extended_profile(self): + seq_profiles = { + 'base': { + 'segmentation': {'path': 'base/segmentation', 'engine': 'wapiti'}, + 'header': {'path': 'base/header', 'engine': 'wapiti'}, + }, + 'child': { + 'extends': 'base', + 'header': {'path': 'child/header'}, + }, + } + result = _resolve_sequence_model_profile(seq_profiles, 'child') + assert result['segmentation'] == {'path': 'base/segmentation', 'engine': 'wapiti'} + assert result['header'] == {'path': 'child/header', 'engine': 'wapiti'} + assert 'extends' not in result + + def test_supports_chained_extends(self): + seq_profiles = { + 'grandparent': {'segmentation': {'path': 'gp/segmentation'}}, + 'parent': {'extends': 'grandparent', 'header': {'path': 'p/header'}}, + 'child': {'extends': 'parent', 'table': {'path': 'c/table'}}, + } + result = _resolve_sequence_model_profile(seq_profiles, 'child') + assert result['segmentation'] == {'path': 'gp/segmentation'} + assert result['header'] == {'path': 'p/header'} + assert result['table'] == {'path': 'c/table'} + + def test_raises_on_unknown_profile(self): + with pytest.raises(ValueError, match='Unknown sequence_model_profile'): + _resolve_sequence_model_profile({}, 'missing') + + def test_raises_on_unknown_extends_target(self): + seq_profiles = {'child': {'extends': 'missing'}} + with pytest.raises(ValueError, match='Unknown sequence_model_profile'): + _resolve_sequence_model_profile(seq_profiles, 'child') + + def test_raises_on_circular_extends(self): + seq_profiles = { + 'a': {'extends': 'b'}, + 'b': {'extends': 'a'}, + } + with pytest.raises(ValueError, match='Circular extends'): + _resolve_sequence_model_profile(seq_profiles, 'a') + + class TestAppConfigResolveProfile: def _make_config(self, extra: Optional[dict] = None) -> AppConfig: props = dict(MINIMAL_PROFILE_CONFIG) @@ -80,6 +142,11 @@ def test_applies_sequence_model_profile(self): assert config['models']['segmentation']['engine'] == 'wapiti' assert config['models']['header']['path'] == 'path_b/header' + def test_applies_extended_sequence_model_profile(self): + config = self._make_config().resolve_profile('profile_b_extended') + assert config['models']['segmentation']['path'] == 'path_b/segmentation' + assert config['models']['header']['path'] == 'path_b_extended/header' + def test_inherits_base_model_keys_not_in_profile(self): config = self._make_config().resolve_profile('profile_a') assert config['models']['segmentation']['path'] == 'path_a/segmentation'