Design decisions

Instruments are data, not code

Before 2.0 each questionnaire carried its own scoring function. bfi2() held a five-branch reverse-keying chain, a per-participant Python loop, and a hardcoded len(answer) == 60; vasf() was a bare subtraction with no subscales at all. Adding a third instrument meant writing a third scorer.

Now an instrument is a Questionnaire value and all arithmetic lives in personality_questionnaire.scoring. The before/after on the reverse-keying alone:

# before -- one instrument, one scale, one participant at a time
def _R(value):
    if value == 1:
        return 5
    elif value == 2:
        return 4
    elif value == 4:
        return 2
    elif value == 5:
        return 1
    else:
        return value


# after -- any instrument, any range, whole cohort
def reverse(values, minimum, maximum):
    return (minimum + maximum) - values

Reverse-keying belongs to the subscale

The obvious implementation builds a per-item boolean mask and reflects those columns once. That is wrong, and the VAS-F proves it: items 6–10 are forward-keyed in Energy and reverse-keyed in Fatigue (composite). A per-item mask has to pick one, and whichever it picks corrupts the other scale.

Instead the reflection is folded into the weight matrix. A forward item contributes value / k; a reverse-keyed one contributes (min + max - value) / k, which splits into a weight of -1 / k and a constant gathered into an offset vector. Scoring is then answers @ weights + offsets — one matrix multiplication for every subscale of every level, with per-column keying that a mask cannot express.

Hierarchy is recorded, not computed

The BFI-2’s domains could be composed from their facets. They are not: each domain lists its own twelve item numbers, and Subscale.parent exists only to draw the tree in documentation and the UI. Composition would add a code path and change no number.

Polarity is data

Every subscale carries a higher_is string. This is a direct response to the bug fixed in 2.0, where three facets were named for one construct and scored as its opposite: with direction recorded next to the items, a reader never has to infer it from a name, and a reviewer can see a contradiction without running anything.

Scores are computed from raw responses

keyed reports responses as a reader of a single scale would see them, but scoring never consumes it — only the per-subscale weights are authoritative. Keeping the presentation view and the arithmetic separate is what makes the per-column keying above safe.

Diagnostics and output are different streams

Participant prompts and scores are the program’s output and go to stdout. Everything else — which database is in use, what was written, deprecation notices — is a diagnostic and goes to logging on stderr, so pq export | jq works.

The logging handler is rebuilt on each CLI invocation rather than left to logging.basicConfig, which binds sys.stderr once and ignores later calls. Without that, a redirected stream silently receives nothing — which is how the first version of the CLI tests passed while asserting on empty strings.

Reliability is computed per subscale, not from ScoreResult.keyed

Cronbach’s alpha needs items pointing the same direction, so it looks like it should reuse keyed — which already reflects every reverse-keyed item. It cannot: keyed applies one global mask, built from every subscale’s reverse set, and the same reasoning that rules it out for scoring (see “Reverse-keying belongs to the subscale” above) applies here too. An item that is forward-keyed in one subscale and reverse-keyed in another would be flipped for both, corrupting whichever one didn’t ask for it.

personality_questionnaire.analysis.subscale_reliability instead re-derives keying per subscale, exactly as scoring.score does internally, before computing alpha and item-total correlations on that subscale’s own items alone. The cost is repeating a few lines of masking logic rather than reusing a shared array; the alternative is a diagnostic that is silently wrong for any instrument where one item serves two scales with opposite polarity.

The demo is a separate application, not a flag

demo/ is its own top-level package rather than a --demo flag on the Lab application. A flag means the record-writing code path ships to a public host and is disabled by a boolean — one bad conditional away from persisting a stranger’s responses on a machine nobody administers. A separate package means there is no personality_questionnaire.db import anywhere in demo/ to misconfigure: tests/demo_app/test_isolation.py walks every file in the package with ast and fails if one ever imports the database layer, NiceGUI, or SQLAlchemy, so the boundary is enforced by CI rather than by discipline.

The two applications share only registry and scoring — pure, stateless modules with no notion of a database connection to accidentally reach for.

A second, hand-ported demo exists because Gradio Lite is broken

demo-react/ reimplements the same instrument-fill-evaluate-plot flow as demo/ in TypeScript, deployed to a free Hugging Face static Space. It exists alongside demo/, not instead of it, because of a hosting constraint rather than a technical one: Hugging Face’s free personal-account tier does not allow a Docker or native-Gradio Space at all (both require a paid PRO plan), except for up to two Gradio Spaces running on ZeroGPU hardware – a GPU-hosting mechanism this app has no use for, since every instrument scores and plots on CPU.

The natural fix looked like Gradio Lite (@gradio/lite): it runs the actual demo/app.py client-side via Pyodide, so it would need no second implementation at all, and it deploys as exactly the free static Space this needed. Tried directly against the official example from Hugging Face’s own gradio-lite blog post (not just this app): it fails to load. gradio’s own dependency chain pins huggingface-hub<1.0,>=0.33.5, and Pyodide’s resolver cannot satisfy that constraint against what is currently on PyPI. A Gradio maintainer confirmed on the tracking issue (gradio-app/gradio#12262) that Lite is no longer maintained, and offered an unofficial patched build on a personal S3 bucket as a one-off favor – which gets past that crash but then hits a second, unrelated one: huggingface-hub’s filelock dependency calls os.link(..., follow_symlinks=False), a call Pyodide’s WASM filesystem shim does not support. Two independent breakages in an explicitly unmaintained library is not something to build a portfolio piece’s public demo on.

demo-react/src/scoring.ts is therefore a deliberate, hand-written port of scoring.py’s arithmetic, not a client-side Python runtime – see personality_questionnaire/scoring.py’s own module docstring for the canonical algorithm. It is tested against the same fixtures scoring.py’s own tests use (tests/fixtures.py::PUBLISHED_OCEAN, PANAS’s sum-vs-mean invariants, VAS-F’s normalization bounds), not values invented for the port, so a transcription mistake is caught the same way a mistake in the Python would be. The instrument data (items, labels, subscale structure) does not suffer the same duplication risk: scripts/export_instruments.py generates demo-react/src/instruments.json from the live registry.py, and tests/demo_react/test_instruments_export.py fails CI if the checked-in file drifts from a fresh export.