diff --git a/.github/workflows/paper.yml b/.github/workflows/paper.yml new file mode 100644 index 00000000..b6dfc748 --- /dev/null +++ b/.github/workflows/paper.yml @@ -0,0 +1,61 @@ +name: Paper + +# JOSS paper preview: compiles paper/paper.md to PDF whenever paper content changes, so a +# broken front matter, malformed bibtex, or unresolvable citation fails here instead of at +# submission. The paper's structural gates (citation resolution, word budgets, front +# matter) live in tests/test_paper.py and run in ci.yml's python-test job on every PR; +# this workflow deliberately adds only the compiled artifact, path-filtered so untouched +# PRs pay nothing. +# +# Compilation uses the official JOSS paperdraft resources from the archived (frozen +# 2024-04-16) openjournals/whedon repository. The prebuilt openjournals/whedon Docker Hub +# image is gone, so the image is built per run; the Dockerfile needs three patches, +# identical to the Makefile's paper-image target (keep the two in sync): +# 1. fully-qualify the pandoc/latex base image registry, +# 2. pin tlmgr to the frozen TeXLive 2021 historic repo (the live tlnet now serves a +# newer TeXLive, so the stock install commands silently install nothing), +# 3. strip the vestigial biblatex block from the JOSS latex template (frozen polyglossia +# forbids the template's load order; pandoc citeproc renders references anyway). +on: + pull_request: + paths: + - "paper/**" + - ".github/workflows/paper.yml" + push: + branches: + - main + paths: + - "paper/**" + - ".github/workflows/paper.yml" + +permissions: + contents: read + +concurrency: + group: paper-${{ github.ref }} + cancel-in-progress: true + +jobs: + paper: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + - name: Clone and patch JOSS paperdraft sources + run: | + git clone --depth 1 https://github.com/openjournals/whedon.git /tmp/whedon + sed -i 's|^FROM pandoc/latex|FROM docker.io/pandoc/latex|' /tmp/whedon/paperdraft.Dockerfile + sed -i '/^RUN tlmgr update --self/i RUN tlmgr option repository https://ftp.math.utah.edu/pub/tex/historic/systems/texlive/2021/tlnet-final' /tmp/whedon/paperdraft.Dockerfile + perl -0pi -e 's/\\usepackage\[\n backend=biber,\n%[^\n]*\n%[^\n]*\n\]\{biblatex\}\n\\bibliography\{[^}]*\}\n//; s/\\renewcommand\{\\bibfont\}\{[^{}]*\}\n//' /tmp/whedon/resources/joss/latex.template + - name: Build paperdraft image + run: docker build -f /tmp/whedon/paperdraft.Dockerfile -t paperdraft /tmp/whedon + # paperdraft writes paper.pdf next to paper.md (fail-if-warnings: on), so an + # unresolvable citation or latex warning fails the job. + - name: Compile the JOSS paper PDF (test build, fails on warnings) + run: docker run --rm -v "$PWD/paper:/paper" -w /paper paperdraft paper.md + - name: Upload PDF preview + uses: actions/upload-artifact@v4 + with: + name: paper + path: paper/paper.pdf + retention-days: 14 diff --git a/.gitignore b/.gitignore index 3b673b74..eee25c00 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,8 @@ *.opencode/ # mkdocs build output site/ +# JOSS paper build output: paper.md/paper.bib/INSTRUCTIONS.md are tracked, the PDF is generated +paper/*.pdf + .ralph/ +.paperdraft/ diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index 861487c0..711027ee 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -6,7 +6,8 @@ // Scope deliberately includes top-level *.md, docs/, and .github/ templates, and excludes // machine-generated/internal trees: .git, node_modules, .venv, site (mkdocs output), // target (Rust build), __pycache__, .ralph (agent state), plans/ + PLAN.md (agent-generated -// planning artifacts), tests/ (fixture data), .tablassert (runtime artifacts). +// planning artifacts), .paperdraft (local clone of the JOSS paperdraft compiler sources), +// tests/ (fixture data), .tablassert (runtime artifacts). { "config": { "default": true, @@ -35,6 +36,18 @@ }, "customRules": ["./.markdownlint/no-unicode-chars.cjs"], "globs": ["**/*.md"], + // The JOSS paper template puts every required section (Summary, Statement of need, + // ...) at heading level 1 below the YAML front-matter title; multiple H1s are the + // expected format there, not a structure error. + "overrides": [ + { + "filter": ["paper/paper.md"], + "config": { + "MD025": false + }, + "combine": "merge" + } + ], "ignores": [ "**/.git/**", "**/node_modules/**", @@ -45,6 +58,7 @@ ".ralph/**", "plans/**", "PLAN.md", + ".paperdraft/**", "tests/**", ".tablassert/**" ] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 350c4903..f6ae1b5c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -62,6 +62,17 @@ repos: - html-validate@11.16.4 - html-validate-markdown@6.0.0 pass_filenames: false + # JOSS paper integrity gate (front matter, citation resolution, word budgets). + # Scoped to one stdlib-only test file so it stays fast enough for the pre-commit + # stage, unlike the full suite (see the two-stage design comment above); the + # regular CI suite also runs it in python-test. markdownlint-cli2 above already + # lints paper/*.md via its **/*.md globs, so no paper scope is added there. + - id: paper-checks + name: paper-checks + entry: uv run pytest tests/test_paper.py -q -p no:cacheprovider + language: system + files: ^(paper/|tests/test_paper\.py$) + pass_filenames: false - id: pyright name: pyright entry: uv run pyright diff --git a/CITATION.cff b/CITATION.cff index 682ab8a6..d80ae079 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -12,16 +12,16 @@ authors: email: sgoetz@isbscience.org affiliation: Institute for Systems Biology orcid: "https://orcid.org/0009-0007-8373-2352" - - given-names: "Gwênlyn" - family-names: Glusman - email: gglusman@isbscience.org - affiliation: Institute for Systems Biology - orcid: "https://orcid.org/0000-0001-8060-5955" - given-names: Jared C. family-names: Roach email: jroach@isbscience.org affiliation: Institute for Systems Biology orcid: "https://orcid.org/0000-0003-4524-0107" + - given-names: "Gwênlyn" + family-names: Glusman + email: gglusman@isbscience.org + affiliation: Institute for Systems Biology + orcid: "https://orcid.org/0000-0001-8060-5955" references: - type: article title: "MicrobiomeKG: bridging microbiome research and host health through knowledge graphs" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 353105fe..a852239e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,6 +59,10 @@ The `Makefile` is intentionally small and mirrors the underlying commands: | `make fmt-check` | Ruff format check and `cargo fmt --check` | | `make typecheck` | Pyright | | `make check` | Lint, format check, typecheck, Python tests, Rust tests, and clippy with `-D warnings` | +| `make paper-check` | JOSS paper gate: front matter, citation resolution, word budgets | +| `make paper-image` | Build the JOSS paperdraft compiler image (local Docker layer cache) | +| `make paper-pdf` | Compile `paper/paper.pdf` from `paper/paper.md` with Docker | +| `make paper` | JOSS paper gate plus PDF compile | | `make clean` | Local build/test/doc artifacts | ## Project layout diff --git a/Makefile b/Makefile index 937f26f0..d98447d3 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: setup build dev test test-rust lint fmt fmt-check typecheck check clean +.PHONY: setup build dev test test-rust lint fmt fmt-check typecheck check clean paper paper-check paper-pdf paper-image # Install Python dev dependencies, CLI/QC/log extras, and the editable Rust extension. setup: @@ -45,4 +45,40 @@ check: lint fmt-check typecheck test test-rust # Remove local build, test, documentation, and compiled-extension artifacts. clean: - rm -rf build dist site htmlcov .coverage .pytest_cache .ruff_cache rust/target src/tablassert/*.so + rm -rf build dist site htmlcov .coverage .pytest_cache .ruff_cache rust/target src/tablassert/*.so .paperdraft paper/paper.pdf + +# Validate the JOSS paper draft: front matter, citation resolution, word budgets. +paper-check: + uv run pytest tests/test_paper.py -q + +# Clone + patch the JOSS paperdraft sources once; the stamp file keeps the patches +# single-application across repeated make runs. +.paperdraft/.patched: + git clone --depth 1 https://github.com/openjournals/whedon.git .paperdraft/whedon + # Qualify the base image registry so podman-based runtimes (no unqualified-search + # registries) resolve it exactly like Docker does, and pin tlmgr to the frozen + # TeXLive 2021 historic repo: the base image (pandoc/latex:2.11.2 ships TeXLive 2021) + # points tlmgr at the live tlnet, which now serves a newer TeXLive, so the stock + # tlmgr install calls silently install nothing. + sed -i 's|^FROM pandoc/latex|FROM docker.io/pandoc/latex|' .paperdraft/whedon/paperdraft.Dockerfile + sed -i '/^RUN tlmgr update --self/i RUN tlmgr option repository https://ftp.math.utah.edu/pub/tex/historic/systems/texlive/2021/tlnet-final' .paperdraft/whedon/paperdraft.Dockerfile + # The JOSS template loads biblatex before pandoc's polyglossia and the frozen + # TeXLive 2021 polyglossia forbids that order; pandoc citeproc renders the + # bibliography anyway, so the biblatex block is vestigial and gets stripped. + perl -0pi -e 's/\\usepackage\[\n backend=biber,\n%[^\n]*\n%[^\n]*\n\]\{biblatex\}\n\\bibliography\{[^}]*\}\n//; s/\\renewcommand\{\\bibfont\}\{[^{}]*\}\n//' .paperdraft/whedon/resources/joss/latex.template + touch .paperdraft/.patched + +# Build the JOSS paperdraft compiler image from the openjournals/whedon resources. +# whedon is archived (frozen 2024-04-16) but its paperdraft.Dockerfile and LaTeX templates +# are still the official JOSS compilation path; the old openjournals/whedon Docker Hub +# image is gone, so the image is built locally. Docker layer cache makes this a no-op +# after the first build. +paper-image: .paperdraft/.patched + docker build -f .paperdraft/whedon/paperdraft.Dockerfile -t paperdraft .paperdraft/whedon + +# Compile paper/paper.md to paper/paper.pdf with the JOSS paperdraft image. +paper-pdf: paper-image + docker run --rm -v $(PWD)/paper:/paper -w /paper paperdraft paper.md + +# Full JOSS paper gate: validate the draft, then compile the PDF. +paper: paper-check paper-pdf diff --git a/llms.txt b/llms.txt index a397f5a1..8f4267a4 100644 --- a/llms.txt +++ b/llms.txt @@ -38,7 +38,7 @@ Current CLI behavior queries an embedded redb database whose primary file is `fu - [Project Metadata](pyproject.toml): dependencies, tooling config, and CLI entrypoint. Optional extras: `cli`, `rt`, `aria2`, `qc`, and `log`. - [Tests](tests/): pytest coverage and fixtures. - [MkDocs Configuration](mkdocs.yml): docs navigation and published pages. -- [GitHub Workflows](.github/workflows/): `ci.yml` runs lint, format, type, test, and rust checks; `docs.yml` builds and deploys MkDocs; `pipy.yml` checks the project version, builds sdist and wheel artifacts, and publishes to PyPI; `tag-version.yml` tags changed project versions. +- [GitHub Workflows](.github/workflows/): `ci.yml` runs lint, format, type, test, and rust checks; `docs.yml` builds and deploys MkDocs; `pipy.yml` checks the project version, builds sdist and wheel artifacts, and publishes to PyPI; `tag-version.yml` tags changed project versions; `paper.yml` tests the JOSS paper compile and uploads the PDF artifact. - [Changelog](CHANGELOG.md): release history and version notes. ## Implementation Map diff --git a/paper/INSTRUCTIONS.md b/paper/INSTRUCTIONS.md new file mode 100644 index 00000000..0ef143d5 --- /dev/null +++ b/paper/INSTRUCTIONS.md @@ -0,0 +1,88 @@ +# JOSS Paper Instructions + +This directory holds the draft JOSS (Journal of Open Source Software) paper for Tablassert. +`paper.md` is the manuscript, `paper.bib` is its bibliography, and the compiled `paper.pdf` +is generated, never committed. See the [JOSS author guidelines](https://joss.readthedocs.io/en/latest/submitting.html) +before making major changes. + +## What JOSS reviewers check + +JOSS reviews the software as much as the text. Tablassert already satisfies the entry +requirements, so the review focuses on the paper itself: + +- A clear **statement of need**: what problem Tablassert solves and for whom. +- **State of the field**: how the prevailing practice (hand-written per-source Translator + ingests) falls short, what alternatives exist (Koza, KGX, hosted name resolution), and the + build-versus-contribute justification for a new package. +- **Software design** and a **research impact statement**: the design trade-offs and the + realized uses (MicrobiomeKG, DAKP, KRAKEN, pending Translator integration). +- An **AI usage disclosure** naming any generative tools used for the software, + documentation, or manuscript, with versions and how outputs were verified. +- An **archive**: the tagged release gets a Zenodo DOI at acceptance; keep + `CITATION.cff` and this paper's author list in sync. +- The paper's body stays between 750 and 1750 words excluding references; the abstract + under 250. The `paper-check` gate enforces both locally. + +JOSS rules change; re-check the + [paper format page](https://joss.readthedocs.io/en/latest/paper.html) before submission. + +## File map + +- `paper.md`: manuscript in JOSS (pandoc) format. YAML front matter carries title, + authors, ORCIDs, tags, and the `bibliography: paper.bib` key that citeproc loads; + the body uses the sections JOSS expects (Summary, Statement of need, State of the + field, Software design, Research impact statement, AI usage disclosure, + Acknowledgements, References). Author `affiliation:` values are INDICES into the + separate `affiliations:` list (the JOSS convention); putting an institution string + there silently collapses the rendered author block to the last author. +- `paper.bib`: BibTeX entries. Every `[@key]` citation in `paper.md` must resolve to an + entry here, and every entry must be cited; `paper-check` enforces both directions. + Note: pandoc's bibliography parser treats `%` comment lines loosely, so keep `]`, `@`, + and other entry-like punctuation out of comments. +- `figures/`: figure images referenced from `paper.md` (create when adding the figure). + +## Workflow + +1. Draft or edit `paper.md`. Cite with `[@key]`, for example KGX [@kgx] renders as + "(Mungall et al. 2022)". Multi-cite as `[@a; @b]`. +2. Verify the matching `paper.bib` entry: title, authors, year, DOI. Entries seeded with + a `% TODO: verify` comment are unverified; check them against the publisher page or + before submission. +3. Add a figure by dropping the image into `figures/` and referencing it from its own + paragraph with `![Caption text](figures/fig1.png)`; JOSS turns a captioned standalone + image into a numbered figure. There is no figure in the current draft. +4. Run the local gates before committing: + +```bash +make paper-check # front matter, citations, word budgets (also a pre-commit hook) +make paper-pdf # compile paper/paper.pdf with Docker (requires Docker) +make paper # both +``` + +`make paper-pdf` builds the official JOSS paperdraft compiler image from the archived +openjournals/whedon resources (the prebuilt Docker Hub image is gone); the first build +takes a few minutes and later builds hit the Docker layer cache. The existing +markdownlint hooks cover this directory automatically; keep prose ASCII (no unicode +dashes or arrows) as everywhere else in the repository. + +## CI + +`.github/workflows/paper.yml` runs only when files under `paper/` change on a pull +request: it compiles the PDF from the JOSS paperdraft resources and uploads `paper.pdf` +as a workflow artifact so reviewers can preview the compiled paper without Docker. The +pytest gate runs in the regular CI suite on every pull request. + +## Submitting + +When the draft is ready: + +1. Resolve every `TODO(collaborators)` marker in `paper.md` (AI usage disclosure, + Acknowledgements) and verify every remaining `paper.bib` entry against its publisher + page or before submission. +2. Confirm `make paper-check` and `make paper-pdf` both pass. +3. The submitting author creates an account at , then submits + through the JOSS site, pointing at this repository and branch. The editor assigns + two reviewers; JOSS compiles `paper.md` automatically during review (the bot stack + formerly called Whedon, now Buffy, does the compilation). +4. Address review comments here, on this branch, until acceptance; JOSS archives the + accepted version and mints the DOI. diff --git a/paper/paper.bib b/paper/paper.bib new file mode 100644 index 00000000..0f87441d --- /dev/null +++ b/paper/paper.bib @@ -0,0 +1,110 @@ +% JOSS paper bibliography for Tablassert. +% Every published entry below was verified against its primary registry (Crossref, +% arXiv, bioRxiv) or its upstream CITATION.cff / release metadata in October 2026. +% Large author lists use the first verified authors plus "and others"; the full +% lists live in the registry records. The tests/test_paper.py gate refuses +% uncited entries, so delete any entry you drop. + +@article{biolink-model, + author = {Unni, Deepak R. and Moxon, Sierra A. T. and Bada, Michael and Brush, Matthew H. and Bruskiewich, Richard and Caufield, J. Harry and others}, + title = {{Biolink Model}: A universal schema for knowledge graphs in clinical, biomedical, and translational science}, + journal = {Clinical and Translational Science}, + year = {2022}, + volume = {15}, + number = {8}, + pages = {1848--1855}, + doi = {10.1111/cts.13302} +} + +@article{translator, + author = {Fecho, Karamarie and Glusman, Gw{\^e}nlyn and Baranzini, Sergio E. and Bizon, Chris and Brush, Matthew and Byrd, William and others and {The Biomedical Data Translator Consortium}}, + title = {Announcing the {Biomedical Data Translator}: Initial Public Release}, + journal = {Clinical and Translational Science}, + year = {2025}, + volume = {18}, + number = {7}, + pages = {e70284}, + doi = {10.1111/cts.70284} +} + +@article{babel, + author = {Morris, Evan and Vaidya, Gaurav and Owen, Phil and Reilly, Jason and Fecho, Karamarie and Wang, Patrick and Kebede, Yaphet and Carter, E. Kathleen and Bizon, Chris}, + title = {The ``I'' in {FAIR}: Translating from Interoperability in Principle to Interoperation in Practice}, + journal = {arXiv preprint arXiv:2601.10008}, + year = {2026}, + doi = {10.48550/arXiv.2601.10008} +} + +@article{microbiomekg, + author = {Goetz, Skye L. and Glen, Amy K. and Glusman, Gw{\^e}nlyn}, + title = {{MicrobiomeKG}: bridging microbiome research and host health through knowledge graphs}, + journal = {Frontiers in Systems Biology}, + year = {2025}, + volume = {5}, + pages = {1544432}, + doi = {10.3389/fsysb.2025.1544432} +} + +@article{kraken, + author = {Glen, Amy K. and Witherington, Drew and Leslie, Trent and Baumgartner, Andrew and Fernando, Ashen and Vemuri, Bhargav and others}, + title = {{KRAKEN}: A provenance-tracked knowledge graph for multiomic and wellness research}, + journal = {bioRxiv}, + year = {2026}, + note = {Preprint, version 2, October 9, 2026}, + doi = {10.64898/2026.08.18.745544} +} + +@inproceedings{sapbert, + author = {Liu, Fangyu and Shareghi, Ehsan and Meng, Zaiqiao and Basaldella, Marco and Collier, Nigel}, + title = {{Self-Alignment Pretraining} for Biomedical Entity Representations}, + booktitle = {Proceedings of the 2021 Conference of the North American Chapter of the Association for Computational Linguistics: Human Language Technologies}, + year = {2021}, + pages = {4228--4238}, + doi = {10.18653/v1/2021.naacl-main.334} +} + +@misc{kgx, + title = {{KGX}: Knowledge Graph Exchange}, + year = {2026}, + note = {Version 2.7.0, released May 28, 2026}, + howpublished = {\url{https://github.com/biolink/kgx}} +} + +@misc{koza, + author = {Schaper, Kevin and Ships, Glass and Shefchek, Kent and Moxon, Sierra and Mungall, Chris}, + title = {Koza: data transformation framework for {LinkML} data models}, + year = {2026}, + note = {Version 2.6.4, released October 2, 2026}, + howpublished = {\url{https://github.com/monarch-initiative/koza}} +} + +@misc{redb, + author = {Berner, Christopher}, + title = {redb: An embedded key-value database in pure {Rust}}, + year = {2026}, + note = {Version 4.4.0, released October 13, 2026}, + howpublished = {\url{https://github.com/cberner/redb}} +} + +@misc{dakp, + author = {Glusman, Gw{\^e}nlyn and Goetz, Skye Lane}, + title = {{DAKP}: Drug Approvals Knowledge Provider}, + year = {2026}, + note = {Version 1.24.1, released October 14, 2026}, + howpublished = {\url{https://github.com/glusman-team/dakp}} +} + +@misc{drug-approvals, + author = {Goetz, Skye Lane}, + title = {Drug Approvals Knowledge Provider ({DAKP}) dataset, version 1.24.1}, + year = {2026}, + howpublished = {\url{https://huggingface.co/datasets/SkyeAv/drug-approvals-kp}} +} + +@misc{ingests, + author = {Goetz, Skye Lane}, + title = {Add a {Tablassert} passthrough ingest kind for upstream {KGX} sources}, + year = {2026}, + howpublished = {Pull request 543, NCATSTranslator/translator-ingests (under review)}, + url = {https://github.com/NCATSTranslator/translator-ingests/pull/543} +} diff --git a/paper/paper.md b/paper/paper.md new file mode 100644 index 00000000..95e3dfdd --- /dev/null +++ b/paper/paper.md @@ -0,0 +1,124 @@ +--- +title: 'Tablassert: Declarative Construction of NCATS Translator Knowledge Graphs from Biomedical Tables' +tags: + - knowledge graph + - KGX + - biolink + - entity resolution + - bioinformatics +authors: + - name: Skye Lane Goetz + affiliation: 1 + orcid: 0009-0007-8373-2352 + - name: Jared C. Roach + affiliation: 1 + orcid: 0000-0003-4524-0107 + - name: Gwênlyn Glusman + affiliation: 1 + orcid: 0000-0001-8060-5955 +affiliations: + - index: 1 + name: Institute for Systems Biology +date: 20 October 2026 +lang: en-US +bibliography: paper.bib +--- + +# Summary + +Tablassert turns biomedical spreadsheets (Excel, CSV, TSV) into knowledge graphs ready for NCATS +Translator [@translator]. Biomedical research routinely publishes its findings as supplementary +tables: association statistics, assay readouts, curated gene lists. A user declares, in YAML, how +each table's columns map to subject-predicate-object statements; Tablassert resolves free text +("TP53", "lung cancer") to compact uniform resource identifiers (CURIEs), attaches provenance and +statistical annotations, and emits KGX-compliant [@kgx] node and edge files carrying Biolink Model +categories [@biolink-model]. The same recipe can be inspected, reused, and refined, so a graph +starts small and improves iteratively instead of being frozen inside a bespoke ingest script. + +# Statement of need + +Tablassert grew out of a recurring research task: converting one paper's supplementary spreadsheet +into a Translator knowledge graph, then another, then another. Each hand-written ingest repeated +the same structure -- read the table, map columns to Biolink statements, resolve names, attach +provenance -- until near-identical codebases outnumbered the tables they served. Tablassert +replaces that per-source code with a declarative recipe: a few lines of YAML per table describe +what each row asserts, and the package handles formatting, resolution, and validation the same way +every time. + +Tablassert targets knowledge-graph engineers and bioinformaticians who hold tabular biomedical +sources and need rigorously structured output without maintaining a transform pipeline per source. +Beyond journal supplements, the same recipes apply to any relational source -- trial registries, +drug approval records, or a researcher's own tables -- and each build can emit the Resource Ingest +Guide metadata that Translator submissions require. Recipes are validated with Pydantic before a +build starts, so a typo or an impossible combination of options fails immediately with an error +naming the offending field, rather than surfacing as a malformed graph hours later. + +# State of the field + +The prevailing practice for Translator ingests is bespoke Python per data source. Generic KGX +tooling [@kgx] exchanges and validates graphs but provides no column-to-statement mapping. Koza +[@koza], the Monarch Initiative's ingest engine, pairs YAML configuration with semi-declarative +Python transforms and outputs KGX, but leaves entity resolution to separate services or mappings. +Hosted Translator name resolution [@babel] resolves identifiers as a network service, which makes +build-time bulk lookups slow and builds themselves irreproducible without network access. +Extending those tools would not remove the cost Tablassert targets: the repeated ingest code, the +hosted-resolution dependency, and the lack of integrated mapping-quality checks. Tablassert +integrates all three into one recipe-driven pipeline, and contributing resolution or audit stages +back to Koza would not produce a single declarative YAML mapping covering both. + +# Software design + +Tablassert's core trade-off is buy-time work for build-time ease: a large entity-resolution +database is built (or downloaded) once, and every subsequent build resolves millions of terms +locally in batched lookups without service calls. Recipes are compiled into TCode, an ordered +sequence of (callable, arguments) operations, executed through Polars lazy frames so independent +transformations fuse and materialize only at explicit boundaries: batched resolution, quality +control, and output writing. Because operations are first-class values, sections that share a +prefix of instructions reuse its computed snapshot instead of recomputing it. + +The embedded fullmap database is the design's foundation. Built from BABEL [@babel] exports, it +maps normalized synonyms, including the meta-identifiers that unify the same concept across source +vocabularies, onto canonical identifiers with category and taxon metadata. The store itself is a +set of redb [@redb] key-value files -- an embedded, crash-safe Rust engine chosen over +analytical stores because resolution is point-lookup-shaped, not query-shaped -- built in a +parallel, memory-bounded streaming pass, and partitioned into shards that serve concurrent batch +lookups. Restricting resolution to BABEL-covered entities is deliberate: what does not map is +reported, not guessed, and metadata arrives with the identifier. A prebuilt fullmap can be +downloaded from RENCI alongside building one locally. + +Every emitted edge carries structured provenance -- source record URLs, supporting publications, +and the processing context that produced it -- so an assertion can be traced back to the exact row +and evidence that support it. Optional quality control audits each resolved name with a four-stage +cascade: exact or alias match, fuzzy string similarity, Schwartz-Hearst abbreviation expansion, +then SapBERT [@sapbert] embedding similarity, each stage handling only what cheaper stages could +not clear. Suspect mappings are dropped and logged with their scores rather than silently +retained, which in practice catches resolution errors that upstream synonym sources are slow to +fix. Final output is validated against category-specific Biolink schema classes, so a passing +build means the emitted graph, not just its configuration, is well-formed. Tablassert is a Python +package with a PyO3 Rust extension; the resolution engine, database build, and NDJSON writer run +in Rust while recipes and validation remain Python. + +# Research impact statement + +Tablassert and its declarative precursor underlie several production knowledge graphs. The +published MicrobiomeKG [@microbiomekg], a Translator-deployed graph built from supplementary +tables of 40 publications, was produced with an early unnamed precursor; its current maintenance +builds use present-day Tablassert, as do the Microbiome and Multiomics knowledge graph +configurations of the MOKG project. DAKP [@dakp], the Drug Approvals Knowledge Provider, applies +Tablassert to regulatory drug-approval sources and publishes validated, versioned releases to a +public dataset archive [@drug-approvals]; a public linkouts interface exposes its per-edge +evidence, and integration into the Translator ingest infrastructure is under review [@ingests]. +KRAKEN [@kraken], Phenome Health's unified knowledge graph, integrates the Microbiome, Multiomics, +and Drug Approvals graphs as domain-specific sources. A rare-disease multiomics knowledge graph +built with Tablassert is in preparation. + +# AI usage disclosure + +TODO(collaborators): state which generative AI tools (with versions) assisted the software, +tests, documentation, and this manuscript, and how outputs were verified by the authors. + +# Acknowledgements + +TODO(collaborators): funding awards and contributor acknowledgements go here. + +# References diff --git a/tests/test_paper.py b/tests/test_paper.py new file mode 100644 index 00000000..ef003ee1 --- /dev/null +++ b/tests/test_paper.py @@ -0,0 +1,178 @@ +"""JOSS paper gate: structure, citations, and word budgets for paper/paper.md. + +Mirrors tests/test_docs_style.py: plain functions over explicit file paths, no fixtures, +standard-library imports only (the pre-commit hook runs this file alone, so it must stay +import-light). Enforces the structural requirements Whedon compilation and JOSS review +impose so collaborators get fast local feedback: front matter carries title, authors +with ORCIDs, and tags; the body carries every JOSS-required section (JOSS paper-format +guidance, 2026); every [@key] citation resolves to a paper.bib entry and every entry is +cited; the abstract and body stay inside the official JOSS word budgets. +""" + +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +PAPER_MD = REPO_ROOT / "paper" / "paper.md" +PAPER_BIB = REPO_ROOT / "paper" / "paper.bib" + +# Exact non-ASCII characters permitted in the paper, mapped to their source. +ALLOWED_NON_ASCII = {"ê": "Gwênlyn Glusman, author name in CITATION.cff"} + +# JOSS guidance: "The paper should be between 750-1750 words" (body, excluding +# references); the leading unheaded abstract paragraph is capped separately. +BODY_WORD_LIMIT = 1750 +ABSTRACT_WORD_LIMIT = 250 + +REQUIRED_SECTIONS = ( + "Summary", + "Statement of need", + "State of the field", + "Software design", + "Research impact statement", + "AI usage disclosure", + "Acknowledgements", + "References", +) + +_BIB_ENTRY = re.compile(r"^@\w+\s*\{([^,\s]+),", re.MULTILINE) +_CITED_GROUP = re.compile(r"\[@([^\]]*)\]") +_ORCID = re.compile(r"\b\d{4}-\d{4}-\d{4}-\d{3}[\dX]\b") + + +def _split_front_matter(text: str) -> tuple[str, str]: + """Split the leading YAML front matter block (--- ... ---) from the body.""" + if not text.startswith("---\n"): + raise ValueError("paper.md must start with a YAML front matter block") + try: + end = text.index("\n---\n", 3) + except ValueError: + raise ValueError("paper.md front matter is missing its closing --- line") from None + return text[4:end], text[end + 5 :] + + +def _yaml_children(front_matter: str, key: str) -> list[str]: + """Return the indented child lines under a top-level YAML key.""" + lines = front_matter.splitlines() + try: + start = next(i for i, line in enumerate(lines) if line.startswith(f"{key}:")) + except StopIteration: + raise ValueError(f"front matter is missing the {key} key") from None + children: list[str] = [] + for line in lines[start + 1 :]: + if line and not line.startswith((" ", "\t")): + break + children.append(line) + return children + + +def _author_blocks(front_matter: str) -> list[list[str]]: + """Group the authors section into one block per `- name:` entry.""" + blocks: list[list[str]] = [] + for line in _yaml_children(front_matter, "authors"): + if line.lstrip().startswith("- "): + blocks.append([line]) + elif blocks: + blocks[-1].append(line) + return blocks + + +def _strip_fences(body: str) -> str: + """Drop fenced code blocks, which do not count toward prose word budgets.""" + lines: list[str] = [] + fence = False + for line in body.splitlines(): + if line.strip().startswith("```"): + fence = not fence + continue + if not fence: + lines.append(line) + return "\n".join(lines) + + +def _section(body: str, title: str) -> str: + """Return the prose of one top-level section, excluding its heading.""" + match = re.search(rf"^#+\s+{re.escape(title)}\s*$", body, re.MULTILINE) + if match is None: + return "" + rest = body[match.end() :] + nxt = re.search(r"^#+\s", rest, re.MULTILINE) + return rest[: nxt.start()] if nxt else rest + + +def _before_references(body: str) -> str: + """Drop everything from the References heading onward (bibliography is rendered).""" + match = re.search(r"^#+\s*References\s*$", body, re.MULTILINE) + return body[: match.start()] if match else body + + +def _cited_keys(text: str) -> set[str]: + """Collect every citation key inside [@key] / [@!key] / [@a; @b] brackets.""" + keys: set[str] = set() + for group in _CITED_GROUP.findall(text): + for part in group.split(";"): + key = part.strip().lstrip("@").lstrip("!") + if key: + keys.add(key) + return keys + + +def _bib_keys(text: str) -> set[str]: + return set(_BIB_ENTRY.findall(text)) + + +def test_front_matter_complete() -> None: + front_matter, _ = _split_front_matter(PAPER_MD.read_text(encoding="utf-8")) + assert re.search(r"^title:\s*\S", front_matter, re.MULTILINE), "front matter needs a title" + assert re.search(r"^bibliography:\s*paper\.bib\s*$", front_matter, re.MULTILINE), ( + "front matter needs bibliography: paper.bib so pandoc citeproc loads the entries" + ) + assert _yaml_children(front_matter, "tags"), "front matter needs at least one tag" + blocks = _author_blocks(front_matter) + assert blocks, "front matter needs at least one author" + for block in blocks: + assert any("name:" in line for line in block), "every author needs a name" + assert any(_ORCID.search(line) for line in block), f"every author needs an ORCID: {block}" + + +def test_required_sections_present() -> None: + _, body = _split_front_matter(PAPER_MD.read_text(encoding="utf-8")) + headings = {match.group(1).strip() for match in re.finditer(r"^#+\s+(.+?)\s*$", body, re.MULTILINE)} + missing = [section for section in REQUIRED_SECTIONS if section not in headings] + assert not missing, f"paper.md is missing JOSS-required sections: {missing}" + + +def test_citations_resolve() -> None: + text = PAPER_MD.read_text(encoding="utf-8") + bib = _bib_keys(PAPER_BIB.read_text(encoding="utf-8")) + unresolved = _cited_keys(text) - bib + assert not unresolved, f"citations missing from paper.bib: {sorted(unresolved)}" + + +def test_no_uncited_entries() -> None: + text = PAPER_MD.read_text(encoding="utf-8") + bib = _bib_keys(PAPER_BIB.read_text(encoding="utf-8")) + dead = bib - _cited_keys(text) + assert not dead, f"paper.bib entries never cited in paper.md: {sorted(dead)}" + + +def test_abstract_within_budget() -> None: + _, body = _split_front_matter(PAPER_MD.read_text(encoding="utf-8")) + abstract = _section(_strip_fences(body), "Summary") + words = len(abstract.split()) + assert words > 0, "paper.md needs a Summary section with abstract paragraphs" + assert words <= ABSTRACT_WORD_LIMIT, f"abstract is {words} words (max {ABSTRACT_WORD_LIMIT})" + + +def test_body_within_budget() -> None: + _, body = _split_front_matter(PAPER_MD.read_text(encoding="utf-8")) + prose = _before_references(_strip_fences(body)) + words = len(prose.split()) + assert words >= 750, f"body is {words} words (JOSS minimum 750)" + assert words <= BODY_WORD_LIMIT, f"body is {words} words (max {BODY_WORD_LIMIT})" + + +def test_ascii_only() -> None: + text = PAPER_MD.read_text(encoding="utf-8") + offenders = sorted({c for c in text if ord(c) > 127 and c not in ALLOWED_NON_ASCII}) + assert not offenders, f"paper.md: unlisted non-ASCII characters {offenders!r}"