Bible_Connection

Development notes

This is the technical companion to README.md. Read that first if you just want to run the app — nothing here is required for normal use, since the built database already ships inside the package (bible_connection/data/bible.db.zip).

Project layout

bible_connection/        the app, as one Python package (python3 -m bible_connection)
  app.py                 startup: database setup on first launch, then the window
  core/                  data layer: the two databases and every query the app makes
    schema.sql           SQLite schema of the content database
    user_schema.sql      SQLite schema of the reading state
    db.py                connections, where both files live, migrations
    db_bootstrap.py      extracts the shipped database (and packs a rebuilt one)
    books.py             canonical book list, ordering, verse_id()
    connections.py       get_connections(), search_text(), etc. — used by the app and the API
    ...                  chapters, footnotes, scofield, strongs, reading_state, book_status
  ui/                    PySide6 desktop interface (the primary one)
  pipeline/              rebuilds the database from data/raw (see "Rebuilding from scratch")
    osis_parser.py       parses the OSIS-tagged KJV XML/JSON
    anchor_utils.py      locates a footnote's catchword as a text span
    ingest_*.py          one-shot data loaders
    fetch_scofield.py    downloads Scofield notes from Wikisource
    embeddings.py        computes verse embeddings + semantic edges
    pack.py              zips the rebuilt database into data/bible.db.zip
  api/                   FastAPI web API (secondary), with its one-page frontend
  data/bible.db.zip      the shipped content database
data/raw/                source files (KJV OSIS, cross-references, Strong's, Scofield HTML)
tests/                   smoke tests
packaging/               the Mac app's icon source
Bible Connection.app     the Mac launcher

There are deliberately two SQLite files, opened separately (db.get_connection() / db.get_user_connection()), side by side in the OS’s per-user data directory (db.DATA_DIR — e.g. ~/Library/Application Support/Bible Connection/ on macOS, via the platformdirs package):

Both live outside the app’s own folder: two OS accounts sharing one copy of the app get independent reading histories, moving, redownloading or reinstalling the app doesn’t strand anyone’s progress, an installed package (which can’t be written to) works the same as a clone, and neither file can end up in a git commit. BIBLE_CONNECTION_HOME=/some/folder points both at another folder instead — the tests do this, and it’s how to try a rebuilt database without touching your own copy.

These used to be one file, then one directory; splitting them was a deliberate fix in two stages, not the original design (see “Personal state” below) — a single conn no longer means “the database,” code now needs to be explicit about which of the two it means.

Database shape

user_state.db (user_schema.sql) holds the other three, kept separate for exactly the reason described above:

Personal state used to live in the content database (then in the project folder)

Two rounds of the same underlying problem: personal state kept ending up somewhere it shouldn’t.

Round one: reading_state/app_state/book_status were just three more tables in bible.db. That became a problem the moment this project needed to go on GitHub — bible.db is supposed to be committed (so a fresh clone works without rebuilding), but it was also accumulating someone’s actual reading history, and there was no way to commit one without the other. Fixed by moving those three tables into their own file (get_user_connection() in db.py) rather than “remember to clear those tables before every commit,” which is the kind of manual step that reliably gets forgotten. db._migrate_legacy_user_state() handles the one-time move for a database built before the split — copies any existing rows into the new file, then drops the legacy tables from the content database so it’s clean of personal data going forward; it’s a no-op once that’s done, safe to call on every launch.

Round two: that new file still lived inside the project folder (data/processed/user_state.db), just gitignored instead of tracked — which solved the git problem but not the underlying one: it was still keyed to wherever the project folder happened to sit, so two OS accounts sharing one copy of the app would share (and clobber) each other’s reading progress, and moving or redownloading the folder would strand your history behind. Fixed by moving it again, this time out of the project entirely and into the OS’s per-user data directory via platformdirs.user_data_dir(). db._migrate_user_db_location() handles this one the same way — moves the file on first run after the change, a no-op afterward.

Round three: not a design fix this time, just a consequence of the project’s own rename from “Bible Connections” to “Bible Connection” — user_data_dir()’s app-name argument determines the per-user directory name, so renaming the app moved where user_state.db lives on disk too (~/Library/Application Support/Bible Connections/ → .../Bible Connection/), same underlying problem as round two, just triggered by a name change instead of a location redesign. _migrate_user_db_location() now checks both legacy locations — the original project-folder path and the old plural-named home directory — and moves straight to the current location from whichever it finds; a very old install jumps directly there rather than through the intermediate plural-named stop.

Round four (1.0): bible.db itself moved too, from data/processed/ in the project folder to the same per-user directory, when the app became an installable package. Nothing needed migrating: it’s extracted fresh from the shipped zip, and a leftover data/processed/bible.db in an older clone is simply no longer used (safe to delete).

All three migration checks run unconditionally on every get_user_connection() call; they’re cheap existence checks when there’s nothing left to migrate.

LibraryPage and ReadingPane each take a user_conn alongside the regular content conn now (ReadingPane only for the “resume where you left off” lookup when you switch books from its combo box — everything else it does is content-only).

Verse IDs are book_order * 1_000_000 + chapter * 1000 + verse — monotonic and sortable across the whole Bible, which is what makes “nearest chapter” and range queries cheap without joins.

Rebuilding from scratch

You don’t need to do this — the database is already built, zipped, and committed as bible_connection/data/bible.db.zip (see “Shipping bible.db” below); the app extracts it on first launch. This section is only relevant if you’re changing how the data is parsed or want to regenerate a table after editing an ingest script. It needs a clone (the source files in data/raw/ don’t ship in the package) and the pipeline’s extra libraries:

pip install -e ".[pipeline]"

Each step is idempotent (delete-then-insert) and writes straight into the extracted bible.db (set BIBLE_CONNECTION_HOME to rebuild into a separate folder instead). Run them as modules from the repo root, in this order, since later ones depend on earlier ones:

python3 -m bible_connection.pipeline.ingest_kjv                # base verse text, from data/raw/kjv.csv
python3 -m bible_connection.pipeline.ingest_words_of_christ    # red-letter spans, from kjv_osis.json
python3 -m bible_connection.pipeline.ingest_footnotes          # KJV translators' footnotes, from kjv_osis.json
python3 -m bible_connection.pipeline.ingest_strongs_words      # per-word Strong's tagging, from kjv_osis.json
python3 -m bible_connection.pipeline.ingest_strongs_dictionary # Strong's Hebrew/Greek dictionary entries (base definitions)
python3 -m bible_connection.pipeline.fetch_strongs_lexicon     # downloads STEPBible's richer Greek/Hebrew lexicon (not committed -- see below)
python3 -m bible_connection.pipeline.ingest_strongs_lexicon    # replaces base definitions with the richer lexicon text where available
python3 -m bible_connection.pipeline.ingest_cross_references   # curated cross-references (openbible.info/TSK)
python3 -m bible_connection.pipeline.fetch_scofield            # downloads Scofield HTML from Wikisource (slow, network-bound)
python3 -m bible_connection.pipeline.ingest_scofield           # parses the downloaded HTML into scofield_notes
python3 -m bible_connection.pipeline.embeddings                # encodes every verse + computes semantic edges (slow, CPU-bound)
python3 -m bible_connection.pipeline.pack                      # zips the result into bible_connection/data/bible.db.zip, ready to commit

embeddings.py takes --skip-encode to reuse an existing embeddings table and only recompute edges (useful when tuning --top-k / --threshold), and both flags are also exposed on the CLI.

Strong’s definitions come from two layered sources. ingest_strongs_dictionary.py loads OpenScriptures’ raw Strong’s dictionary first — for Greek in particular, that’s genuinely just Strong’s original one-line 1890 gloss (e.g. G26/ἀγάπη: “love, i.e. affection or benevolence; specially (plural) a love-feast”). fetch_strongs_lexicon.py

STEPBible’s license permits including their data in software freely, but asks two things of anyone who does: (1) note any changes made, and (2) don’t redistribute the raw dataset yourself — point people to github.com/STEPBible/STEPBible-Data instead. On (1): ingest_strongs_lexicon.py strips STEPBible’s own internal <ref=...> hyperlink markup (meaningless outside stepbible.org) and, where one Strong’s number has more than one STEPBible sense-entry (e.g. G1 has separate “Alpha” and “ah!” entries), joins them into one field with <br><br>, since this project’s schema holds one definition per number rather than their disambiguated dStrong scheme — no wording is altered, only reformatted and merged. On (2): tbesg.txt/tbesh.txt are fetched on demand and gitignored, never committed (unlike this project’s other raw sources) — only the derived, reformatted text inside bible.db ships.

Shipping bible.db

bible.db (~192MB) isn’t committed to git directly — it’s zipped down to bible_connection/data/bible.db.zip (~82MB, maximum compression, by pipeline/pack.py) and that’s what’s tracked, and what the package ships; bible_connection/core/db_bootstrap.py extracts it into the per-user data folder the first time the app runs on a machine (~1.5s, measured), and every launch after that just opens the extracted file directly. Two things drove this instead of the more obvious options:

A GitHub Release asset (uploaded separately, downloaded on first launch) was considered too — release-asset bandwidth is also unmetered — but it adds a real network dependency and failure mode (no internet on first launch = broken app) that a git-committed zip just doesn’t have, so it was dropped once the zip turned out to fit under 100MB with room to spare.

Why extraction instead of reading the zip live: SQLite needs true random-access seeks into the file for its B-tree paging — jumping straight to page N wherever a query needs it — and DEFLATE/zip compression only decompresses sequentially from the start of a member. There’s no way to seek into the middle of a compressed stream, so the zip has to become a real file on disk before SQLite can open it at all. extract_bible_db() extracts to a temp name and renames into place atomically, so a crash or a full disk mid-extract can’t leave a truncated bible.db that a later launch would mistake for the real thing.

Updates. Each extraction writes bible.db.source next to the database: the CRC and size the zip already stores for its bible.db member, so comparing costs nothing (no hashing 190MB on every launch). When an update ships a different zip, the two stop matching and the next launch extracts again, with the same “Setting up your Bible library” notice as the first time. A database rebuilt in place by the pipeline keeps its old record — the pipeline never touches it — so it’s never silently replaced, and pack.py records the zip it has just written, so packing doesn’t trigger a pointless re-extract either.

Note this only fixes bandwidth for future clones — the original 192MB LFS blob still exists in this repo’s earlier history (the initial commit and the footnote-anchoring fix that followed it), since removing it fully would mean rewriting history and force-pushing. git clone only pulls LFS objects the checked-out tree actually references though, and the current tree no longer references it at all, so a normal clone today never touches it or its bandwidth cost.

How connections are found

Two independent kinds of edge live in the edges table, both scored 0–1 and both surfaced together in the Connections tab:

Curated edges come straight from openbible.info’s Treasury of Scripture Knowledge cross-reference data — connections people have been drawing between passages for centuries. Weight is their supplied relevance score.

Semantic edges are computed by embeddings.py. Every verse is encoded with sentence-transformers/all-MiniLM-L6-v2 (normalized, so dot product == cosine similarity), and for each verse we brute-force its cosine similarity against all ~31k others in batches (never materializing the full N×N matrix — that’s ~3.7GB). This is small enough that no vector index (FAISS, pgvector, etc.) is worth the complexity.

Two guards keep the results useful:

The Discover tab (connections.get_surprising_connections()) is a read-only view over the same edges table, not a separate computation — it samples semantic edges that have no matching curated edge for the same verse pair (i.e. no traditional cross-reference backs them), excludes verses under _MIN_VERSE_LENGTH characters (kills formulaic short verses like “And the LORD spake unto Moses, saying,” which recur so often they hit a perfect 1.0 cosine score without being an interesting connection), and by default restricts to pairs that cross the Old/New Testament boundary (book_order 39/40, Malachi/Matthew) — prophecy/fulfillment and typology tend to be the most legible kind of surprising connection, and same-testament results skew toward genre duplicates (parallel Kings/Chronicles or Kings/Isaiah narrative blocks retelling the same events in nearly identical wording). “Shuffle” re-samples randomly from the top limit * 15 pool by weight, rather than always returning the same fixed top-N, so repeated shuffles don’t look identical.

A _MIN_DISCOVER_WEIGHT = 0.75 floor also applies. Cosine similarity measures textual/thematic closeness, not “this is a meaningful connection” — measured directly against the cross-testament, no-curated-backing pool: only ~100 pairs clear 0.75, versus ~300 that clear the old unfloored limit * 15 sampling window down to 0.727, and the extra ~200 in that gap noticeably lean toward shared sentence rhythm or vocabulary (e.g. two unrelated verses that both happen to use a “thou art ___” declaration) rather than substantive overlap. If Discover starts feeling thin (not enough variety on repeated shuffles) before it feels noisy, raise limit * 15; if it still surfaces weak matches, raise _MIN_DISCOVER_WEIGHT instead — don’t touch both at once, since they trade off against each other.

Lexical corroboration (_max_shared_word_idf()) is a second, independent filter on top of the weight floor. The first thing tried here was plain content-word overlap (Jaccard / overlap-coefficient on non-stopword words, optionally IDF-weighted) as a combined score with cosine — it was tested directly against known good and bad examples and rejected: a known-weak match (“Thou art the man” / “thou art… King of Israel”, overlap driven by “god”/”israel”/”king”) scored higher on every normalization tried than several genuinely good matches (Numbers 24:19/Luke 1:33, which share only “Jacob”), because those good matches make their connection through a single distinctive word or through meaning rather than literal vocabulary — exactly the kind of match embeddings exist to catch, and exactly what an overlap-ratio filter penalizes. A combined score built from a signal that doesn’t itself separate good from bad would just launder that noise into a single number, not remove it. Optimizing the combination weights against Spearman correlation (or anything else) doesn’t fix this either without labeled ground truth to optimize against — with a weak feature going in, no amount of curve-fitting the mixing weights recovers a separation that wasn’t there to begin with.

What did separate cleanly, checked directly on the same examples: the single highest-IDF word the two verses share, rather than an overlap ratio of all of them. The weak match’s best shared word (“king”) scored 2.65; every hand-checked good match cleared 4.5. Intuition: a real connection usually turns on at least one genuinely uncommon word, whereas coincidental overlap tends to be made of several moderately-common-but-non-stopword words (“god,” “israel,” “king”) that individually mean little. _MIN_LEXICAL_IDF = 4.0 is set between those two clusters. Verses are tokenized with a light KJV-aware suffix stripper (_stem() — not a real stemmer, just folds -eth/ -est/-ing/-ed/plural -s, so “keepeth” and “keep” count as the same word) before IDF is computed once per process and cached (_lexical_index(), ~235ms for all ~31k verses, paid once at the first Discover load). Known gap: irregular stems it can’t fold (“circumcised” doesn’t reduce to “circumcise”) still read as zero-overlap false negatives — acceptable for a coarse corroboration signal, not for anything needing real precision.

Desktop app architecture notes

The launcher (Bible Connection.app)

A real macOS .app bundle, not a script with a document icon — the project used to ship Run Bible Connections.command (a plain shell script Finder knows to open in Terminal), which worked but had no icon, flashed a Terminal window on every launch, and doesn’t mean anything on Windows (.command is a macOS/Finder-specific convention; double-clicking one there just prompts “what program should open this file?”). The bundle is minimal, not a PyInstaller-style frozen build — it’s Contents/Info.plist + Contents/Resources/AppIcon.icns + Contents/MacOS/launch, a shell script that just runs python3 -m bible_connection from the folder the app sits in, after checking that the two packages the reader needs (PySide6 and platformdirs) are importable. Packages still need to be pip-installed once per the README; this only replaces the double-click-to-run step, not the setup step. (Installing with pipx instead gives a bible-connection command and needs no app bundle at all.)

The one non-obvious bug, caught only by testing via open (i.e. the same path a real double-click takes) rather than running the script directly: a GUI-launched macOS app does not get PATH from the user’s shell profile the way a Terminal session does. Plain python3 in that context resolved to Apple’s bare Command Line Tools Python — which has none of the pip-installed packages — instead of the Homebrew (or similar) install a Terminal session finds, even though the packages really were installed correctly. The fix: route both the dependency check and the actual launch through a login invocation of the user’s own shell ("$SHELL" -l -c '...'), which sources the same profile a real Terminal window would and finds the right python3 regardless of whether Python was installed via Homebrew, python.org, or something else. Hardcoding a path like /opt/homebrew/bin/python3 would have “worked” on the machine that built it and silently failed on anyone else’s (wrong path on Intel Macs, wrong entirely for a non-Homebrew install).

The icon (source at packaging/icon.svg, rebuildable via packaging/build_icon.py) was generated, not drawn by hand — the SVG is rendered to every required size via QSvgRenderer (the same technique bible_connection/ui/cards.py’s _svg_icon() already uses), assembled into a .iconset folder, then run through macOS’s built-in iconutil -c icns. Two lessons from actually testing it rather than just building it:

Windows (no launcher of its own since 1.0)

Up to 1.0 the repo shipped a Windows launcher in packaging/windows/: a hand-built multi-resolution .ico, a launch.vbs that started the app hidden via the py launcher, and a Create Desktop Shortcut.vbs, run once, to put an icon on the Desktop (a .vbs file can’t carry an icon of its own; only a shortcut to one can). None of it had ever run on a real Windows machine, and all of it existed only because a folder of Python files has no natural way to be started there. Installing with pipx gives Windows a real bible-connection.exe instead — pip’s standard launcher for a GUI entry point, which starts without a console window — so the scripts and the one-time shortcut step went. python -m bible_connection works from a download too.

Web UI (secondary)

A small FastAPI app (bible_connection/api/) exposes the same connection/search logic over HTTP, with a static single-page frontend (bible_connection/api/index.html). This exists mainly for quick experimentation outside Qt (e.g. checking what an API client would see). Its libraries are an optional extra; run it with:

pip install -e ".[api]"
uvicorn bible_connection.api:app --reload

Endpoints: GET /api/verse?ref=John+3:16, GET /api/search?q=..., GET /api/stats.

GitHub Pages (for extras/ezekiel-temple.html)

Enabled via gh api -X POST repos/.../pages -f "source[branch]=main" -f "source[path]=/", serving the whole repo as static files at https://mrchillstorm.github.io/Bible_Connection/. It exists for exactly one reason: GitHub’s own file viewer doesn’t execute HTML — clicking a plain relative link to a .html file in a rendered README just shows its source code, not a running page. The README’s Ezekiel’s Temple picture links to the Pages URL specifically so it opens as a live page instead. Nothing else in the repo currently depends on Pages being enabled, and nothing else is designed to be accessed through it — bible_connection/api/index.html (the secondary API frontend) would technically be reachable there too, but it needs the FastAPI backend running to do anything, so visiting it via Pages alone just shows a non-functional page, not a broken one. bible.db.zip sits in the served tree as well, but harmlessly — nothing links to it, and Pages serving it as a static download wouldn’t do any harm even if something did.

CLI (no server needed)

Everything the API does is also reachable directly through bible_connection.core.connections’ functions from a python3 REPL — find_verse(), get_connections(), search_text() — useful for one-off queries against the database without starting anything. For a quick look at one verse, python3 -m bible_connection.core.connections "John 3:16" prints its connections.

Tests

python3 -m unittest discover tests

Smoke tests, run offscreen in a throwaway data folder (BIBLE_CONNECTION_HOME), so your own reading history is never touched: the shipped database extracts (and would again for a new zip), a first launch with no reading history works, the queries the app makes answer, the window opens and reads a chapter, and — with the [api] extra installed — the API responds.

Extending it

Scaling up

At ~31,000 verses, brute-force cosine similarity and a single SQLite file are both well within comfortable range — the full embedding matrix is a few hundred MB and every query here is single-user, read-mostly. If this ever needed to serve many concurrent users or a much larger corpus (e.g. multiple translations or extra-biblical texts), the two things worth revisiting first would be a proper vector index (pgvector or FAISS) in place of the batched brute-force scan, and moving from SQLite to Postgres for concurrent write access — neither is warranted at the current scale.