This document outlines the coding standards and practices for just-dna-lite.
This repo is a uv workspace with two member projects:
just-dna-pipelines/: pipeline/CLI library (Python package:just-dna-pipelines)webui/: Reflex Web UI (Python package:webui)
Shared, repo-level folders live at the workspace root (e.g. data/, docs/, logs/, notebooks/).
We sometimes (for example purposes) add prepare-annotations to the workspace. This folder is READ-ONLY you are not allowed to make changes in it!
The recommended way to start the application is from the repo root:
uv run start- Starts the Reflex Web UI development server.uv run serve- Starts the single-process production/unified server. This is a valid user-facing command, especially for demos/workshops or when hot reload is not needed.
Ctrl+C must kill the Dagster daemon tree, not just dg. uv run start / serve launch dg dev detached from the launcher (start_new_session on POSIX, CREATE_NEW_PROCESS_GROUP on Windows), so the terminal interrupt lands on the launcher, not Dagster. dagster._daemon then installs capture_interrupts(), which swallows SIGINT/SIGTERM and only exits when dg writes its shutdown pipe. Waiting solely on the dg PID orphans the daemon. Shutdown lives in just_dna_lite.process: snapshot the tree, interrupt the leader (SIGINT / CTRL_BREAK_EVENT), then force-kill leftovers matching this DAGSTER_HOME (SIGKILL on POSIX, TerminateProcess on Windows). Startup also reaps leftover Reflex UI processes for this workspace (uv run --package webui run, .venv/bin/run, react-router dev under webui/.web) so a previous session cannot keep port 3000 while the new one binds 3002 and the browser talks to the wrong backend. A second uv run start is last-writer-wins (no pidfile): the new process reaps the old stack on startup; the dying instance's leftover sweep only kills PIDs that already existed when shutdown began so it cannot SIGKILL the takeover. Do not enable JUST_DNA_START_KILL_PORTS by default: ports 8000/8001 may belong to unrelated tools. Never waitpid(-1) from uv run serve — that process also owns Granian workers and the compute ProcessPoolExecutor. Ctrl+Z is POSIX-only (SIGTSTP); it is treated as shutdown and the foreground group is SIGCONT'd so uv is not left stopped. Windows has no SIGTSTP/zombies/killpg — those branches are skipped. A second Ctrl+C force-kills immediately. tests/test_process_shutdown.py pins the POSIX tree-kill; a simple-child reap test runs on every OS.
If uv run start or another project script unexpectedly resolves to a dependency's script
(for example prs-ui's start instead of Just-DNA-Lite's start), do not rename the
user-facing command or duplicate script entries in workspace members. This can happen after
dependency upgrades when uv keeps stale generated wrappers in .venv/bin. Bump the main
just-dna-lite package version after upgrading dependencies, then run uv sync so uv rebuilds
and reinstalls the root package entry points. The root package's script should own public commands
like start.
- Avoid nested try-catch: try catch often just hide errors, put them only when errors is what we consider unavoidable in the use-case
- Type hints: Mandatory for all Python code.
- Pathlib: Always use for all file paths.
- No relative imports: Always use absolute imports.
- No inline imports: All imports must be at the module top level. Never use
from X import Yinside functions or methods. The only exception is guardedtry/except ImportErrorfor optional dependencies at module level. - Polars: Prefer over Pandas. Use lazyframes (
scan_parquet) and streaming (sink_parquet) for efficiency. - Memory efficient joins: Pre-filter dataframes before joining to avoid materialization.
- Data Pattern: Use
data/input,data/interim,data/output. - Typer CLI: Mandatory for all CLI tools.
- Pydantic 2: Mandatory for data classes.
- Eliot: Used for structured logging and action tracking.
- Pay attention to terminal warnings: Always check terminal output for warnings, especially deprecation ones. AI knowledge of APIs can be outdated; these warnings are critical hints to update code to the current version.
- No placeholders: Never use
/my/custom/path/in code. - No legacy support: Refactor aggressively; do not keep old API functions.
- Dependency Management: Use
uv syncanduv add. NEVER useuv pip install. - Versions: Do not hardcode versions in
__init__.py; useproject.toml. - Avoid all: Avoid
__init__.pywith__all__as it confuses where things are located. - Cross-Project Knowledge: We sometimes add
prepare-annotationsto the workspace. This folder is READ-ONLY. You MUST check@prepare-annotations/AGENTS.mdfor shared Dagster patterns, resource tracking, and best practices. If you find a superior pattern there that is applicable tojust-dna-lite, you should adopt it and update this file. - Self-Correction: If you make an API mistake that leads to a system error (e.g. a crash or a major logic failure due to outdated knowledge), you MUST update this file (
AGENTS.md) with the correct API usage or pattern. This ensures future agents don't repeat the same mistake.
Annotation module sources and display metadata are configured in modules.yaml. The loader checks two locations (first found wins):
- Project root (
./modules.yaml) — preferred, easy for users to find and edit - Package directory (
just-dna-pipelines/src/just_dna_pipelines/modules.yaml) — bundled fallback
This is the single source of truth for:
- Sources to scan for modules (any fsspec-compatible URL: HuggingFace, GitHub, HTTP, S3, etc.)
- Display metadata overrides (title, description, icon, color, report_title) for known modules
- Ensembl reference dataset (
ensembl_source.repo_id) — the HuggingFace dataset used for Ensembl variation annotation
Modules are always auto-discovered from the configured sources. The YAML only provides optional display overrides. Modules not listed in module_metadata get auto-generated defaults (titlecased name, generic icon, default color).
Read/write separation: The repo-root modules.yaml is git-tracked and read-only (defaults). All runtime mutations (register/unregister custom modules) write to a working copy at data/interim/modules.yaml (gitignored). On first write the repo default is copied as seed. The loader checks working copy → repo root → package dir (first found wins).
modules.yaml(project root): Git-tracked defaults — sources, Ensembl reference, quality filters, metadata overridesdata/interim/modules.yaml: Mutable working copy (gitignored) — written by register/unregistermodule_config.py: Pydantic models (Source,ModuleMetadata,EnsemblSource,ModulesConfig), YAML loader, helper functions (get_module_meta(),build_module_metadata_dict(), etc.)annotation/hf_modules.py: Discovery logic — scans sources via fsspec, buildsMODULE_INFOSandDISCOVERED_MODULES
- Upload data to any fsspec-accessible location (HF repo, GitHub, HTTP server, S3, etc.)
- Add the source URL to
modules.yamlundersources: - Optionally add display metadata under
module_metadata: - Modules are auto-discovered on next startup
org/repo(shorthand) orhf://datasets/org/repo→ HuggingFacegithub://org/repo→ GitHub via fsspechttps://...→ HTTP/HTTPS via fsspecs3://...,gcs://...→ cloud storage via fsspec
Each source can be a single module or a collection:
- Auto-detect (default):
weights.parquetat root = single module; subfolders withweights.parquet= collection - Override:
kind: moduleorkind: collectionin the YAML source entry
- Never write to repo-root
modules.yaml— useget_config_path()which returns the working copy atdata/interim/modules.yaml - Never hardcode module lists or metadata in Python files — always use
get_module_meta()orbuild_module_metadata_dict()frommodule_config - Never hardcode HF repo URLs — use
DEFAULT_REPOSorMODULES_CONFIG.sourcesfrommodule_config - Never hardcode Ensembl repo ID —
EnsemblAnnotationsConfig.repo_iddefaults toMODULES_CONFIG.ensembl_source.repo_id HF_DEFAULT_REPOS,HF_REPO_IDinhf_modules.pyare backward-compatible aliases sourced from the YAML
The annotation-module schema (authored DSL spec + manifest.json contract + integrity/identity)
and the reference compiler (spec directory → parquet artifact + manifest) were extracted out of
this repo into two published libraries. Do not re-vendor or fork them here.
just-dna-format(just_dna_format, Pydantic + stdlib only):spec— authored DSL:ModuleSpecConfig,VariantRow,StudyRow,ModuleInfo(+VALID_STATES,VALID_CHROMOSOMES,RSID_PATTERN,ALLELE_PATTERN,SCHEMA_VERSION)manifest—ModuleManifest+Identity/Display/Stats/Compilation/FileEntry/Artifact,read_manifest/write_manifestintegrity—sha256_file,artifact_digest(Merkle root),build_artifact,verify_manifestidentity— name/namespace rules, SemVerVersion,canonical_id, legacyvN → N.0.0
just-dna-compiler(just_dna_compiler, adds polars/duckdb):validate_spec,compile_module(emitsmanifest.jsonwith input/artifact hashes + digest),reverse_module.just-dna-enricher(just_dna_enricher, added in the 0.5 line): the network/reference tier — Ensembl/ClinVar/gnomAD/PGx enrichment, and the Ensemblresolver(EnsemblReferenceError,resolve_variants). Still inject-only: it never downloads a reference.
These libraries are shared by three repos: just-dna-lite (this one), just-dna-marketplace,
and just-dna-agents. Treat them as an external contract; do not assume a symbol is unused just
because grep finds no consumer in this repo — the other repos may use it.
just_dna_pipelines.module_compileris now a thin re-export shim over the libs (models.py→just_dna_format.spec+just_dna_compiler.models;compiler.py→just_dna_compiler.compiler). Prefer importing from the libs directly in new code.- Ensembl provisioning stays local (the only non-shim piece):
module_compiler/resolver.pykeepsensure_resolver_db(HF download + DuckDB build) because the libs are inject-only.register_custom_moduleand the pipelinesresolve_variantswrapper auto-provision the cache and inject it; the barecompile_modulere-export and thepipelines module compileCLI stay inject-only (skip resolution with a warning if no cache is present).
Two import sites in this repo had to move; both are one-liners, but neither is greppable from the old name, so check here first:
RSID_PATTERNleftjust_dna_format.specforjust_dna_format.vocab(0.4.0), where the identifier grammars now live shared across the authored models.ALLELE_PATTERNis still re-exported fromspecfor backwards compatibility —RSID_PATTERNis not.just_dna_compiler.resolveris gone. The DuckDB-backed lookup moved tojust_dna_enricher.resolver(sameresolve_variants(variants, ensembl_cache=...)signature andEnsemblReferenceError). What remains in the compiler isjust_dna_compiler.resolution, which is purely table-injected (resolve_from_table) and takes no DuckDB path at all.
The 0.5 digest window is closed: anything that moves a compiled module's artifact.digest — a
new column, a requiredness or identity change — is a 1.0 in the format repo, so 0.5.x is a stable
target. Note the three packages version independently (enricher can take a patch on its own).
Still outstanding from the 0.4.0 schema change: compiled artifacts gained ~14 columns
(variant_key, effect_size, clin_sig, acmg_sf, …), so freshly compiled modules no longer
match the modules published on HuggingFace under 0.3.x. VariantRow.variant_key is also frozen at
load, so the resolver no longer backfills chrom/start onto a keyed row, and ModuleInfo no
longer accepts version. Migrating means republishing the HF modules and updating the AI module
creator's spec template — the roundtrip/compiler tests fail until that happens.
validate_spec().statskeys:variant_count,unique_rsids,gene_count,genes(sorted list),categories(sorted list),study_count,module_name— renamed from the oldunique_genes/study_rows/unique_variants.VALID_PRIORITIESandPMID_PATTERNare intentionally not injust_dna_format.spec(dead code in the old schema; the live study rule is only "pmid non-empty").
When you find something that belongs in the shared schema/compiler (a bug, a missing field, a
tightening, a parity gap), do not edit or commit the just-dna-format repo — we consume it, we
don't own it. Just leave a note in its docs, which act as that repo's kanban intake; the format-repo
owners pick it up as needed:
/data/sources/just-dna-format/docs/ROADMAP.md— backlog / proposed changes (kanban first column: "sticking a note", handled as needed)./data/sources/just-dna-format/docs/CHANGELOG.md— record cross-repo integration changes made on our (consumer) side, so parallel agents in the other repos aren't surprised.
Writing the note is the whole job on that side — do not follow it up with commits or PRs there.
See docs/IMMUTABLE_MODE.md for full documentation.
Immutable mode disables file uploads and serves only pre-configured public genomes from Zenodo. Controlled by the JUST_DNA_IMMUTABLE_MODE=true env var and immutable_mode: section in modules.yaml.
| File | What it does |
|---|---|
modules.yaml (immutable_mode: section) |
Default samples, disclaimer, allow_zenodo_import flag |
module_config.py (ImmutableModeConfig, DefaultSample) |
Pydantic models, is_immutable_mode(), get_immutable_config() |
annotation/resources.py |
validate_zenodo_record(), resolve_default_samples() |
webui/state.py |
is_immutable_mode var, handle_zenodo_import(), guards on upload/delete |
webui/pages/annotate.py |
Conditional left panel (upload form vs disclaimer, Zenodo import, public genome hint) |
webui/components/layout.py |
"Public Demo" topbar badge, FAQ nav tab (always visible) |
webui/pages/faq.py |
FAQ page at /faq — loads content from docs/FAQ.md |
docs/FAQ.md |
FAQ content (markdown) — user, scientific, legal, technical questions |
| Mode | File Upload | Zenodo Import | Use Case |
|---|---|---|---|
| Normal (default) | Yes | Yes | Local/personal |
Immutable + allow_zenodo_import: true |
No | Yes | Workshop/conference |
Immutable + allow_zenodo_import: false |
No | No | Strict public demo |
- Never hardcode Zenodo URLs in Python — use
get_immutable_config().default_samples - Default samples should include
filename— startup resolvesdata/input/users/public/and the Zenodo cache before any network call; withoutfilename, record URLs require a Zenodo metadata request. is_immutable_mode()checks env var first, then YAMLenabledflagvalidate_zenodo_record()verifies open access, permissive license, and VCF presence before any download- Zenodo metadata is tracked in Dagster —
source: "zenodo",zenodo_url,zenodo_doi,zenodo_license,zenodo_creatoronuser_vcf_sourcematerialization progress_statusstate var provides phase-specific messages during downloads and normalization- In immutable mode,
safe_user_idis always"public"— all users share the same data directory
- Anton Kulaga (CC-Zero):
https://zenodo.org/records/18370498—antonkulaga.vcf(482 MB) - Livia Zaharia (CC-BY-4.0):
https://zenodo.org/records/19487816—SIMHIFQTILQ.hard-filtered.vcf.gz(349 MB)
Quality filters are configured in modules.yaml under quality_filters: and applied during normalization (user_vcf_normalized asset). All downstream assets receive filtered data.
quality_filters:
pass_filters: ["PASS", "."] # FILTER column values to keep (null to disable)
min_depth: 10 # Minimum DP (null/0 to disable)
min_qual: 20 # Minimum QUAL (null/0 to disable)- gVCF support: Reference blocks (
FILTER=RefCall,GT=0/0) are correctly dropped bypass_filterssinceRefCallis not in["PASS", "."]. This is intentional — ref blocks have no alt allele and would never match annotation module weights. - Backward compatible: If
quality_filtersis absent from YAML, no filtering occurs (all fields default toNone).
A non-partitioned quality_filters_config asset materializes the current filter settings from modules.yaml. user_vcf_normalized depends on it.
When modules.yaml changes:
- Re-materialize
quality_filters_config(itsDataVersionis a hash of the filter config) - Dagster marks
user_vcf_normalizedpartitions as stale - Re-materialize stale partitions to apply new filters
modules.yaml:quality_filterssection (single source of truth)module_config.py:QualityFiltersmodel,build_quality_filter_expr()helperannotation/assets.py:quality_filters_configasset, filter application inuser_vcf_normalized
When sex="Female" is set in NormalizeVcfConfig, the normalization asset logs a warning if chrY variants are found (e.g., "WARNING: 1200 chrY variants found in female-labeled sample") but never removes them. This is informational only — QC filters (FILTER, depth, qual) handle the actual cleanup. We deliberately avoid sex-based chromosome filtering to prevent data loss for XXY, XYY, and other karyotype variations.
- Never bypass quality filters — all VCF annotation paths should read from the normalized (and filtered) parquet, not raw VCF
- Column name detection is case-tolerant —
build_quality_filter_expr()searches for(filter, Filter, FILTER),(DP, Dp, dp),(qual, Qual, QUAL)to handle different VCF parser conventions - Cast before comparison — DP and QUAL columns are cast to numeric types before threshold comparison to handle string-typed parquet columns
For any Dagster-related changes, architecture, or troubleshooting, see docs/DAGSTER_GUIDE.md. The guide explains the full pipeline (VCF normalization → HF annotation + optional Ensembl → reports), output paths, jobs, and known quirks (e.g. polars-bio non-fatal Rust panic).
Shared normalization: Both HF module annotation and Ensembl annotation read from user_vcf_normalized (quality-filtered, chr-stripped parquet). Ensembl assets (user_annotated_vcf, user_annotated_vcf_duckdb) depend on user_vcf_normalized — they do NOT re-parse the raw VCF.
Jobs:
annotate_and_report_job: normalize → HF modules → report (default)annotate_all_job: normalize → HF modules + Ensembl DuckDB → report (when Ensembl toggle is on in UI)annotate_ensembl_only_job: normalize → Ensembl DuckDB only (no HF modules, no report)normalize_vcf_job: normalize only (auto-runs on upload)
Always track CPU and RAM consumption for all compute-heavy assets using resource_tracker from just_dna_pipelines.runtime:
from just_dna_pipelines.runtime import resource_tracker
@asset
def my_asset(context: AssetExecutionContext) -> Output[Path]:
with resource_tracker("my_asset", context=context):
# ... compute-heavy code ...
passImportant: Always pass context=context to enable Dagster UI charts. Without it, metrics only go to Eliot logs.
This automatically logs to Dagster UI: duration_sec, cpu_percent, peak_memory_mb, memory_delta_mb.
All jobs must include the resource_summary_hook from just_dna_pipelines.annotation.utils to provide aggregated resource metrics at the run level:
from just_dna_pipelines.annotation.utils import resource_summary_hook
my_job = define_asset_job(
name="my_job",
selection=AssetSelection.assets(...),
hooks={resource_summary_hook}, # Note: must be a set, not a list
)This hook logs a summary at the end of each successful run: Total Duration, Max Peak Memory, and Top memory consumers.
API differences from newer versions (MANDATORY reference):
get_dagster_context()does NOT exist - you must passcontextexplicitly.context.log.info()does NOT accept ametadatakeyword argument - usecontext.add_output_metadata()separately.EventRecordsFilterdoes NOT haverun_idsparameter - useinstance.all_logs(run_id, of_type=...)instead.- For asset materializations, use
EventLogEntry.asset_materialization(returnsOptional[AssetMaterialization]), notDagsterEvent.asset_materialization. hooksparameter indefine_asset_jobmust be aset, not a list:hooks={my_hook}.- Use
defs.resolve_all_asset_specs()instead of deprecateddefs.get_all_asset_specs().
- Auto-configuration: Dagster config is automatically created on first run. See docs/CLEAN_SETUP.md.
- Declarative Assets: We prioritize Software-Defined Assets (SDA) over imperative ops.
- IO Managers: Reference assets (Ensembl, ClinVar, etc.) use
annotation_cache_io_manager→ stored in~/.cache/just-dna-pipelines/. - User assets use
user_asset_io_manager→ stored indata/output/users/{user_name}/. - Ensembl cache layout: Flat chromosome parquets at
~/.cache/just-dna-pipelines/ensembl_variations/data/homo_sapiens-chr*.parquet. Downloaded via fsspec (HfFileSystem). The repo is configured inmodules.yamlunderensembl_source:. DuckDB creates a singleensembl_variationsVIEW over all files. - Lazy materialization: Assets check if cache exists before downloading.
- Start UI:
uv run start(full stack) oruv run dagster(pipelines only).
| Asset Returns | IO Manager | Use Case |
|---|---|---|
pl.LazyFrame |
polars_parquet_io_manager |
Small parquet, schema visibility |
Path |
Custom IO manager | Large data, DuckDB joins, file uploads |
dict |
Default | API responses, upload results |
- dagster-polars: Use
PolarsParquetIOManagerforLazyFrameassets → automatic schema/row count in UI - Path assets: Add
"dagster/column_schema": polars_schema_to_table_schema(path)for schema visibility - Asset checks: Use
@asset_checkfor validation; include viaAssetSelection.checks_for_assets(...) - Streaming: Use
lazy_frame.sink_parquet(), never.collect().write_parquet()on large data - DuckDB: Use for large joins (out-of-core); set
memory_limitandtemp_directory - Concurrency: Use
op_tags={"dagster/concurrency_key": "name"}to limit parallel execution
- Create partition def:
PARTS = DynamicPartitionsDefinition(name="files") - Discovery asset registers partitions:
context.instance.add_dynamic_partitions(PARTS.name, keys) - Partitioned assets use:
partitions_def=PARTS, accesscontext.partition_key - Collector depends on partitioned output via
deps=[partitioned_asset], scans filesystem for results
- Python API only:
defs.resolve_job_def(name)+job.execute_in_process(instance=instance) - Same DAGSTER_HOME for UI and execution:
dg dev -m module.definitions - All assets in
Definitions(assets=[...])for lineage visibility in UI
Never use huggingface_hub.snapshot_download for large datasets:
snapshot_download duplicates data into HuggingFace's own blob store (~/.cache/huggingface/) and then copies/links to local_dir. This wastes disk space and is unreliable. Instead, use fsspec via HfFileSystem for direct file-by-file downloads into our cache:
# WRONG - duplicates data in HF blob store, unreliable local_dir population
from huggingface_hub import snapshot_download
snapshot_download(repo_id="org/repo", local_dir=cache_dir, ...)
# CORRECT - direct download via fsspec, files land exactly where we want
from huggingface_hub import HfFileSystem, get_token
fs = HfFileSystem(token=get_token())
for remote_path in fs.ls("datasets/org/repo/data", detail=False):
if remote_path.endswith(".parquet"):
fs.get(remote_path, str(local_path))This pattern is also future-proof: swapping HfFileSystem for any other fsspec backend (S3, GCS, HTTP) requires minimal changes.
polars-bio scan_vcf API changed (0.23+):
IOOperations.scan_vcf()no longer acceptsthread_num.- Use
concurrent_fetchesinstead. - In
just_dna_pipelines.io.read_vcf_file(), keepthread_numonly as backward-compatible API and map it toconcurrent_fetches.
polars-bio write_vcf with custom INFO fields requires set_source_metadata:
Without pb.set_source_metadata(), extra columns on the DataFrame are silently dropped and the VCF always outputs INFO=.. Register INFO field definitions before calling pb.write_vcf():
import polars_bio as pb
pb.set_source_metadata(df, format="vcf", header={
"info_fields": {
"AF": {"number": "A", "type": "Float", "description": "Allele Frequency"},
"gene": {"number": "1", "type": "String", "description": "Gene symbol"},
}
})
pb.write_vcf(df, str(out_vcf))Each info_fields entry requires number, type, and description. type is one of Integer, Float, String, Flag, Character; number is 1, A, R, G, or .. See https://biodatageeks.org/polars-bio/features/#setting-custom-metadata.
write_vcf also requires all 8 core VCF columns (chrom, start, end, id, ref, alt, qual, filter) with start/end as UInt32. When exporting from parquets that lack some of these, fill defaults: end = start + 1, qual = None, filter = ".".
Timestamps are on RunRecord, not DagsterRun:
# WRONG - DagsterRun has no start_time/end_time
runs = instance.get_runs(limit=10)
for run in runs:
print(run.start_time) # AttributeError!
# CORRECT - Use get_run_records() to access timestamps
records = instance.get_run_records(limit=10)
for record in records:
run = record.dagster_run
# record.start_time and record.end_time are Unix timestamps (floats)
# record.create_timestamp is a datetime object
started = datetime.fromtimestamp(record.start_time) if record.start_time else NonePartition keys via tags, not direct parameter:
# WRONG - create_run_for_job doesn't accept partition_key
run = instance.create_run_for_job(job_def=job, partition_key=pk)
# CORRECT - pass partition via tags
run = instance.create_run_for_job(
job_def=job,
run_config=config,
tags={"dagster/partition": pk},
)Web UI Job Execution Pattern (TRY-DAEMON-WITH-FALLBACK):
For the Reflex Web UI, we use a hybrid approach: try daemon-based execution first, but fall back to execute_in_process if submission fails. Critical: Keep business logic outside exception handlers.
# RECOMMENDED PATTERN - Separate business logic from exception handling
# 1. Create run
job_def = defs.resolve_job_def(job_name)
run = instance.create_run_for_job(
job_def=job_def,
run_config=run_config,
tags={"dagster/partition": partition_key},
)
run_id = run.run_id
# 2. Try daemon submission (register failure, don't process it)
daemon_success, daemon_error = self._try_submit_to_daemon(instance, run_id)
# 3. Handle success/failure outside exception handler
if daemon_success:
# Poll status asynchronously via poll_run_status()
yield rx.toast.info("Job started")
else:
# Fall back to execute_in_process as background task (non-blocking)
self._add_log(f"Daemon failed: {daemon_error}")
yield rx.toast.info("Running in-process - please wait...")
# Launch in thread pool without awaiting (keeps UI responsive)
# CRITICAL: Use run_in_executor, NOT asyncio.create_task or asyncio.to_thread
# Those cause pyo3 panics with Dagster objects
loop = asyncio.get_event_loop()
loop.run_in_executor(
None, # Use default executor
self._execute_inproc_with_state_update,
instance, job_name, run_config, partition_key, run_id, sample_name
)
# Background task will update state when complete
# Helper methods (separate concerns):
def _try_submit_to_daemon(self, instance, run_id) -> tuple[bool, str]:
"""Try daemon submission. Returns (success, error_message)."""
try:
instance.submit_run(run_id, workspace=None)
return (True, "")
except Exception as e:
return (False, str(e))
def _execute_inproc_with_state_update(self, ...) -> None:
"""Execute in-process and update state. Called from thread pool via run_in_executor."""
try:
# Execute synchronously (caller handles threading via run_in_executor)
result = self._execute_job_in_process(...)
# Update UI state with result (self.running = False, etc.)
self.running = False
self.last_run_success = result.success
except Exception as e:
# Update UI state for failure
self.running = False
self.last_run_success = FalseWhy this pattern is better:
- ✅ Business logic outside exception handlers (cleaner separation of concerns)
- ✅ Exception handlers only register failures, don't process them
- ✅ Control flow is linear and easy to follow
- ✅ Each method has single responsibility
- ✅ UI stays responsive - Background task doesn't block event handler
Critical: UI Responsiveness and Python/Rust Thread Safety
NEVER await long-running operations in Reflex event handlers - it blocks the entire UI. Also, be careful with threading when using Dagster (which has Rust/pyo3 internals):
# BAD - Blocks UI until job completes (minutes!)
fallback_result = await self._execute_inproc_with_state_update(...)
if fallback_result["success"]:
yield rx.toast.success("Done")
# BAD - asyncio.to_thread() with Dagster objects causes pyo3 panic:
# "Cannot drop pointer into Python heap without the thread being attached"
result = await asyncio.to_thread(self._execute_job_in_process, ...)
# BAD - asyncio.create_task() on sync function
asyncio.create_task(self._execute_inproc_with_state_update(...)) # Not async!
# GOOD - Use run_in_executor for thread-safe background execution
loop = asyncio.get_event_loop()
loop.run_in_executor(None, self._execute_inproc_with_state_update, ...)
# UI remains responsive, thread-safe, no pyo3 panicsWhy run_in_executor works: It properly manages the Python GIL when moving objects between threads, unlike asyncio.to_thread() which can cause pyo3 (Python/Rust bridge) panics with Dagster objects.
Why submit_run(workspace=None) fails in web UIs:
Daemon-based execution requires ExternalPipelineOrigin which needs workspace context. Web UI state doesn't have easy access to workspace context, so submit_run(run_id, workspace=None) fails with "Expected non-None value: External pipeline origin must be set for submitted runs". The fallback to execute_in_process handles this reliably.
Critical: Per-file running state (not global)
Button enable logic must check if the selected file is running, not if any job is running globally. This allows concurrent jobs on different files:
# BAD - blocks ALL files when ANY file is running
@rx.var
def can_run_annotation(self) -> bool:
return bool(self.selected_file) and len(self.selected_modules) > 0 and not self.running
# GOOD - only blocks the selected file if it's running
@rx.var
def can_run_annotation(self) -> bool:
if not self.selected_file or not self.selected_modules:
return False
# Check if SELECTED file has a running job
for run in self.runs:
if run.get("filename") == self.selected_file:
if run.get("status") in ("RUNNING", "QUEUED", "STARTING"):
return False
return True
# Helper computed var for UI elements
@rx.var
def selected_file_is_running(self) -> bool:
"""Check if the currently selected file has a running job."""
if not self.selected_file:
return False
for run in self.runs:
if run.get("filename") == self.selected_file:
if run.get("status") in ("RUNNING", "QUEUED", "STARTING"):
return True
return FalseUse selected_file_is_running for UI elements (button text, icons, spinners) instead of global self.running flag.
Critical: Orphaned Run Cleanup (execute_in_process survival)
When using execute_in_process in web UIs, runs are abandoned (stuck in STARTED status) on server restart. Implement these safeguards:
- Startup cleanup - Clean up NOT_STARTED runs (daemon submission failures):
def _cleanup_orphaned_runs(self) -> int:
"""Clean up NOT_STARTED runs on startup (daemon submission failures)."""
instance = get_dagster_instance()
not_started_records = instance.get_run_records(
filters=RunsFilter(statuses=[DagsterRunStatus.NOT_STARTED]),
limit=100,
)
cleaned_count = 0
for record in not_started_records:
run = record.dagster_run
instance.report_run_canceled(run, message="Orphaned run from daemon submission failure")
cleaned_count += 1
return cleaned_count
async def on_load(self):
"""Load state and clean up orphaned runs."""
cleaned = self._cleanup_orphaned_runs()
if cleaned > 0:
self._add_log(f"🧹 Cleaned up {cleaned} orphaned run(s) from previous session")
# ... rest of on_load logic- Track active in-process runs - Use class variable to track which runs are executing in-process:
class MyState(rx.State):
# Class variable shared across all instances
_active_inproc_runs: Dict[str, str] = {} # {run_id: partition_key}
def _execute_inproc_with_state_update(self, ...):
actual_run_id = None
try:
result = self._execute_job_in_process(...)
actual_run_id = result.run_id
# Track this run
MyState._active_inproc_runs[actual_run_id] = partition_key
# ... process result
finally:
# Clean up tracker
if actual_run_id and actual_run_id in MyState._active_inproc_runs:
del MyState._active_inproc_runs[actual_run_id]- SIGTERM handler - Mark STARTED runs as CANCELED on shutdown (in app.py):
import signal
import atexit
def cleanup_active_runs():
"""Mark all active in-process runs as CANCELED on shutdown."""
try:
from my_app.state import MyState
from dagster import DagsterInstance
active_runs = MyState._active_inproc_runs.copy()
if not active_runs:
return
instance = DagsterInstance.get()
for run_id in active_runs:
run = instance.get_run_by_id(run_id)
if run:
instance.report_run_canceled(
run,
message="Web server shutdown - in-process execution terminated"
)
except Exception as e:
print(f"Warning: Failed to cleanup active runs: {e}")
# Register cleanup handlers
signal.signal(signal.SIGTERM, lambda sig, frame: (cleanup_active_runs(), sys.exit(0)))
signal.signal(signal.SIGINT, lambda sig, frame: (cleanup_active_runs(), sys.exit(0)))
atexit.register(cleanup_active_runs)- CLI cleanup command - Manual cleanup for orphaned runs:
# Clean up NOT_STARTED runs (daemon failures)
uv run pipelines cleanup-runs
# Clean up STARTED runs (abandoned in-process executions)
uv run pipelines cleanup-runs --status STARTED
# Dry-run to see what would be cleaned
uv run pipelines cleanup-runs --status STARTED --dry-runFor CLI Tools: Direct execute_in_process
CLI tools can use execute_in_process directly (no fallback needed):
# For CLI tools - execute_in_process (no daemon required, runs synchronously)
job_def = defs.resolve_job_def(job_name)
# Ensure partition exists (for dynamic partitions)
existing = instance.get_dynamic_partitions(partition_def.name)
if partition_key not in existing:
instance.add_dynamic_partitions(partition_def.name, [partition_key])
result = job_def.execute_in_process(
run_config=run_config,
instance=instance,
tags={"dagster/partition": partition_key},
)
if result.success:
print("Job completed successfully")
else:
print(f"Job failed: {result.all_events}")Trade-offs of try-daemon-with-fallback pattern:
✅ Benefits:
- UI responsive when daemon works (job runs in daemon, not blocking web server)
- Reliable when daemon fails (falls back to execute_in_process)
- Background threading keeps execute_in_process from blocking UI
❌ Limitations:
- Runs created via execute_in_process fallback cannot be re-executed from Dagster UI (missing
remote_job_origin) - Execute_in_process runs in web server process (mitigated by background threading via
asyncio.to_thread)
Asset job config uses "ops" key, not "assets":
# WRONG - "assets" key causes DagsterInvalidConfigError
run_config = {
"assets": {"user_hf_module_annotations": {"config": {...}}}
}
# CORRECT - use "ops" key for asset job config
run_config = {
"ops": {"user_hf_module_annotations": {"config": {...}}}
}Run logs via all_logs, not EventRecordsFilter:
# WRONG - EventRecordsFilter doesn't have run_ids
records = instance.get_event_records(EventRecordsFilter(run_ids=[run_id]))
# CORRECT - use all_logs(run_id)
events = instance.all_logs(run_id)submit_run() with workspace context - use try/fallback pattern:
# Web UI pattern: Try daemon submission, fall back to execute_in_process
try:
instance.submit_run(run_id, workspace=None)
# Success: daemon will run the job, poll status via poll_run_status()
except Exception as e:
# Daemon rejected run (needs ExternalPipelineOrigin/workspace context)
# Fall back to execute_in_process which runs reliably without workspace context
result = await asyncio.to_thread(
self._execute_job_in_process,
instance, job_name, run_config, partition_key
)
# Update UI state with result immediately (no polling needed)Critical discovery: Wrong parameter workspace_process_context=None caused TypeError → triggered fallback → job ran successfully via execute_in_process. The "correct" workspace=None is worse because it doesn't error immediately - daemon accepts submission but then rejects run with "External pipeline origin must be set", leaving run stuck in NOT_STARTED.
dagster job executeCLI (deprecated)- Hardcoded asset names; use
defs.get_all_asset_specs() - Silent fallbacks when primary data is missing — If normalized parquet does not exist (e.g. user_vcf_normalized), do NOT silently fall back to raw VCF and display it as if it were normalized. Users will not know the data source differs. Either show an explicit error ("Run normalization first") or a very prominent banner ("Using raw VCF — normalize job has not run"). See docs/DAGSTER_GUIDE.md § VCF Normalization.
- Ensembl assets bypassing user_vcf_normalized —
user_annotated_vcfanduser_annotated_vcf_duckdbMUST depend onuser_vcf_normalizedand pass the normalized parquet vianormalized_parquet=parameter. Never read the raw VCF directly in annotation assets. - Config for unselected assets (validation errors)
- Suspended jobs holding DuckDB file locks
- Accessing
run.start_timeon DagsterRun - use RunRecord instead - Using
submit_run(run_id, workspace=None)without fallback in web UIs - daemon rejects run, leaves it stuck in NOT_STARTED; always implement fallback toexecute_in_process - Using global
self.runningflag for button enable logic - blocks ALL files when ANY file is running; use per-file running state instead - Expecting Dagster UI re-execution to work for
execute_in_processruns - not supported, but acceptable trade-off
- Real data + ground truth: Use actual source data, auto-download if needed, and compute expected values at runtime.
- Deterministic coverage: Use fixed seeds or explicit filters; include representative and edge cases.
- Meaningful assertions: Prefer relationships and aggregates over existence-only checks.
- Verbosity: Run
pytest -vvv. - Docs: Put all new markdown files (except README/AGENTS) in
docs/.
- Counts & aggregates: Row counts, sums/min/max/means, distinct counts, and distributions.
- Joins: Pre/post counts, key coverage, cardinality expectations, nulls introduced by outer joins, and a few spot-checks.
- Transformations: Round-trip survival, subset/superset semantics, value mapping, key preservation.
- Data quality: Format/range checks, outliers, malformed entries, duplicates, referential integrity.
- Runtime ground truth: Query source data at test time instead of hardcoding expectations.
- Seeded sampling: Validate random records with a fixed seed, not just known examples.
- Negative & boundary tests: Ensure invalid inputs fail; probe min/max, empty, unicode.
- Derived assertions: Test relationships (e.g., input vs output counts), not magic numbers.
- Allow expected failures: Use
pytest.mark.xfailfor known data quality issues with a clear reason.
- Parameterize over duplicate: If testing the same logic on multiple outputs, use
@pytest.mark.parametrizeinstead of copy-pasting tests. - Set equality over counts: Prefer
assert set_a == set_boverassert len(set_a) == 270- set comparison catches both missing and extra values. - Delete redundant tests: If test A (e.g., set equality) fully covers test B (e.g., count check), keep only test A.
- Domain constants are OK: Hardcoding expected enum values or well-known constants from specs is fine; hardcoding row counts or unique counts derived from data inspection is not.
When claiming a test "would have caught" a bug, demonstrate it:
- Isolate the buggy logic in a test or script
- Run it and show failure against correct expectations
- Then show the fix passes the same test
Never claim "tests would have caught this" without running the buggy code against the test.
- Testing only "happy path" with trivial data
- Hardcoding expected values that drift from source (use derived ground truth)
- Mocking data transformations instead of running real pipelines
- Ignoring edge cases (nulls, empty strings, boundary values, unicode, malformed data)
- Claiming tests "would catch bugs" without demonstrating failure on buggy code
Meaningless Tests to Avoid (common AI-generated anti-patterns):
# BAD: Existence-only checks as the sole validation
assert "name" in df.columns
assert len(df) > 0
# BAD: Hardcoded counts derived from data inspection
assert len(source_ids) == 270 # will break when source changes
# BAD: Redundant with set equality test
assert len(output_cats) == 12 # already covered by subset check
# ACCEPTABLE: Required columns as prerequisites
required_cols = {"id", "name", "value"}
assert required_cols.issubset(df.columns)
# GOOD: Set equality from source data
source_ids = set(source_df["id"].unique().drop_nulls().to_list())
output_ids = set(output_df["id"].unique().drop_nulls().to_list())
assert source_ids == output_ids
# GOOD: Domain knowledge constants (from spec, not data inspection)
assert valid_states == {"active", "inactive", "pending"} # from API specNever fork() a process that has already used Polars, polars-bio, or DuckDB.
Polars' Rayon pool is created on the first Polars operation, not at import. A
forked child inherits the pool's latches but none of its worker threads, so the first
parallel op parks forever. It parks with the GIL released, so Python signal handlers
never run: no traceback, SIGTERM ignored, SIGKILL only. Rayon workers are named
polars-<n> in /proc/self/task/*/comm and are invisible to Python's threading
module, which is why this ships unnoticed. CPython's own
DeprecationWarning: ... fork() may lead to deadlocks is swallowed by the default
ignore::DeprecationWarning filter because the fork happens outside __main__.
Full write-up and reproductions: docs/GRANIAN_POLARS_FORK_DEADLOCK.md.
serve()callsapply_process_model_guards()fromwebui/src/webui/forksafety.pybefore importing reflex. It pinsREFLEX_USE_GRANIAN(Reflex'sshould_use_granian()is afind_specheuristic that otherwise silently selectsgunicorn --preload, which forks after importing the app), forces thespawnstart method, unmutes the fork warning, and installs anos.register_at_forktripwire. Do not remove or reorder.- Any
multiprocessinguse must pass aspawncontext explicitly —mp_context=multiprocessing.get_context("spawn"). Never rely on the platform default. - Spawned children re-import
__main__, so every entry point must be__main__-guarded or the worker dies with thefreeze_support()RuntimeError. uv-generated console scripts already are; bare scripts are not. POLARS_MAX_THREADS=1does not fix this. Measured at 1, 4 and 16 threads, a forked child hangs every time — even one Rayon worker is lost to the fork. It is the intuitive mitigation and it is ineffective, while costing all Polars parallelism. Use spawn.run_in_executor(None, ...)does NOT make native-parallel work safe. It moves the Python frame to another thread; Rayon/Tokio/DuckDB pools are process-global. Use it for blocking I/O only, never as the answer to a native deadlock or to CPU-heavy Polars work.- All Polars / DuckDB / polars-bio / Dagster work goes through
webui.compute—compute.poolfor short queries,compute.jobsfor Dagster runs. The ASGI process marshals arguments and results and nothing else. - Grid pages must be O(page), not O(rows).
lf.sort(...).slice(offset, n)re-sorts the whole frame on every click (Polars only pushes a dynamic predicate down for single-key sorts; multi-key sorts fully materialize). Sort once to a temp parquet, then slice pages off that artifact.
A blocking proc.wait() is not interruptible in any process that imported Polars.
Polars installs its own SIGINT handler through sigaction with SA_RESTART set, so the
kernel restarts the interrupted waitpid instead of returning EINTR. CPython never
reaches the bytecode loop where Python-level handlers run, so KeyboardInterrupt is not
raised until the child exits by itself. Measured: a bare interpreter blocked in
Popen.wait() is interrupted at once; the same code after import polars ignores every
SIGINT. This is why uv run start sat through a dozen Ctrl+C presses with the whole stack
still up — and because every child is started with start_new_session=True, the terminal's
SIGINT does not reach them either. The launcher is the only process that can act on it.
install_launcher_signal_handlers(injust_dna_lite.process) is what makes the wait interruptible again, and not only because it routes first/second signals: CPython'ssignal.signal()registers withsa_flags = 0, which clearsSA_RESTART. Call it before blocking on any child —start_allinline,start_dagsterthrough_run_managed_foreground. A launcher that goes back to a bareproc.wait()without it is silently uninterruptible again, with no symptom other than Ctrl+C doing nothing.- The first signal raises
KeyboardInterruptinto the main flow; the second force-kills the snapshotted tree and exits. SIGTERM and Ctrl+Z enter the same path, sokill <launcher>tears the stack down instead of orphaning it. - Regression tests:
tests/test_launcher_shutdown.pypins the SA_RESTART mechanism, an unclaimed wait sleeping through Ctrl+C, and the claimed wait breaking within a tick.tests/test_process_shutdown.pycovers what shutdown does once it starts.
The webui uses Reflex (Python-based React framework). See docs/DESIGN.md for visual design.
When making significant UI changes, follow this workflow:
- Make changes to UI code (state.py, annotate.py, layout.py, etc.)
- Check terminal for compile errors: Run
uv run startand monitor the terminal output for:ImportError- Missing or renamed importsAttributeError- Wrong API usage (e.g.,App.api_routedoesn't exist)Warning: Invalid icon tag- Wrong icon names (use hyphenated Lucide names)- Traceback errors during "Compiling" phase
- Verify app starts successfully: Look for "App running at: http://localhost:3000"
- Check browser: Navigate to http://localhost:3000 and verify:
- Page loads without blank screen
- Key UI elements are visible (tabs, buttons, panels)
- Interactive elements work (tab switching, file selection, etc.)
- Fix any issues before considering the task complete
Common compile-time errors:
ModuleNotFoundError- Add missing dependency withuv add <package>ImportError: cannot import name 'X'- Function was renamed/removed, update importsAttributeError: 'App' object has no attribute 'Y'- Wrong Reflex API, check docs
Terminal monitoring tip: Reflex hot-reloads on file changes. After editing, wait for "Compiling: 100%" message before checking the browser.
Note on worker warnings: During hot reload, Reflex may show [WARNING] Killing worker-0 after it refused to gracefully stop. This is normal behavior when the worker is busy processing a request during reload. It does not indicate a Dagster issue or data corruption.
0. Use @rx.event(background=True) for heavy computation, NEVER synchronous generators:
Reflex generator event handlers (yield) hold the state lock for their entire execution. yield sends state deltas but does NOT release the lock — other events queue up and fire all at once when the generator finishes, making the UI completely unresponsive. This applies to both direct generators and yield from delegation to mixin generators.
For any operation taking more than ~1 second (PRS computation, file processing, API calls), use @rx.event(background=True) with async with self: for state access:
# BAD — holds state lock for entire loop, UI frozen during computation
def compute_heavy_stuff(self) -> Any:
self.computing = True
yield # sends update but does NOT release lock
for item in self.items:
result = expensive_function(item) # blocks everything
self.progress += 1
yield # UI appears frozen, events queue up
self.computing = False
# GOOD — state lock released between iterations, UI stays responsive
@rx.event(background=True)
async def compute_heavy_stuff(self) -> None:
async with self: # brief lock: read inputs, set computing=True
items = list(self.items)
self.computing = True
for i, item in enumerate(items):
async with self: # brief lock: progress update
self.progress = i
# Heavy work runs WITHOUT state lock — UI responsive
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(None, expensive_function, item)
async with self: # brief lock: store results
self.computing = False
self.results = resultsKey rules:
@rx.backgrounddoes NOT exist in Reflex 0.8.x — always use@rx.event(background=True)- Extract heavy work into pure functions (no
selfaccess) and run viarun_in_executor - Snapshot all needed state vars into locals inside the first
async with self:block - Keep
async with self:blocks as brief as possible (only read/write state)
1. Use fomantic_icon() instead of rx.icon():
Lucide icons (via rx.icon()) often fail to load or trigger terminal warnings in this environment. Use the fomantic_icon() helper from webui.components.layout instead. It maps common Lucide names to Fomantic UI equivalents.
from webui.components.layout import fomantic_icon
# GOOD - consistent and reliable
fomantic_icon("dna", size=24, color="#2185d0")
# BAD - triggers "Invalid icon tag" warnings
fomantic_icon("dna", size=24)2. Icons require STATIC strings:
Even with fomantic_icon(), you cannot pass a dynamic rx.Var as the name. Use rx.match for dynamic selection.
# CRASHES
fomantic_icon(module["icon_name"], size=24)
# WORKS
rx.match(
module["name"],
("heart", fomantic_icon("heart", size=24)),
("star", fomantic_icon("star", size=24)),
fomantic_icon("database", size=24), # default
)3. Icon naming:
fomantic_icon() handles mapping for common names, but generally use Fomantic UI icon names (space-separated) or common hyphenated names which the helper will map.
Verified icons (mapped by helper): circle-check, circle-x, circle-alert, circle-play, cloud-upload, upload, download, file-text, files, dna, heart, heart-pulse, activity, zap, droplets, pill, loader-circle, refresh-cw, external-link, terminal, database, boxes, inbox, history, chart-bar, play.
4. Use rx.cond() for reactive styling:
# GOOD - reactive
class_name=rx.cond(is_active, "ui primary button", "ui button")
# BAD - not reactive, evaluated once at compile time
class_name="ui primary button" if is_active else "ui button"4. rx.foreach with dictionaries:
Values from dicts in rx.foreach are typed as Any. This can cause type errors in components that expect specific types (e.g. rx.checkbox expecting bool). Cast when needed using .to():
# Cast to int for text/formatting
rx.text(item["count"].to(int))
# Cast to bool for control props
rx.checkbox(checked=item["is_checked"].to(bool))5. Use class_name not class:
Reflex uses class_name for CSS classes. Using class will cause a Python SyntaxError as it is a reserved keyword.
# GOOD
rx.box(class_name="ui segment")
# BAD - SyntaxError
rx.box(class="ui segment")- Dynamic icon names - Will crash with "Icon name must be a string"
- Underscore icon names - Use hyphens:
heart-pulsenotheart_pulse - Wrong icon order - It's
circle-checknotcheck-circle - Python conditionals for state - Use
rx.cond()instead - Missing
.to()casts in foreach - Can cause type errors - Comparing a global state Var to a foreach item -
RegistryState.busy_key == card["local_key"]compiles as one shared comparison, so every catalog Get looked clicked. Stamp a bool on the row (card["busy"]) and readitem["busy"].to(bool), the same wayinstalledalready works. Do notdisabled=action_busyon every primary button either — Fomantic disabled+primary looks pressed. - Awaiting long-running tasks in event handlers - Blocks entire UI. Submit to
webui.compute;loop.run_in_executor()is for blocking I/O only - Treating
run_in_executor(None, ...)as making native work safe - It moves the Python frame to another thread, but Rayon/Tokio/DuckDB pools are process-global. It neither prevents a fork deadlock nor bounds memory. See Process Model & Fork Safety - Using
asyncio.to_thread()with Dagster objects - Causes pyo3 panic "Cannot drop pointer into Python heap". Dagster runs belong inwebui.compute.jobs(a spawned child), not in any thread of the ASGI process - Forking after Polars/polars-bio/DuckDB has been used - Silent, unkillable deadlock on the next parallel op. See Process Model & Fork Safety
- Blocking in
proc.wait()without claiming SIGINT first -import polarsinstalls an SA_RESTART SIGINT handler, so the wait is restarted instead of interrupted and Ctrl+C does nothing at all. Callinstall_launcher_signal_handlersbefore the wait. See Process Model & Fork Safety - Business logic in exception handlers - Makes code hard to follow; separate concerns with dedicated methods
- Synchronous generator (
yield) for CPU-heavy loops - Generator event handlers hold the state lock for the entire execution.yieldsends state deltas to the frontend but does NOT release the lock. All queued events (tab clicks, button presses) are blocked until the generator finishes. Use@rx.event(background=True)for anything that takes more than ~1 second. yield EventSpecfrom an upload/select-file handler (Reflex 0.9) - The generator'sEventFutureis marked done when it exhausts. The frontend then re-dispatches those EventSpecs as children of that finished future and Reflex logsCannot add a child to an EventFuture that is already done(normalization still runs). Return a list of EventSpecs instead of yielding them. Fixed upstream in reflex#6801 but not in 0.9.8. Same rule aspoll_run_status.- Underscore-prefixed state vars are backend-only - Reflex does not send
_footo the client. A remount token used askey=on uncontrolled inputs (default_value) must be a public var (form_key, not_form_key), or the Add Sample fields keep the typed values after upload. Also returnrx.clear_selected_files(...)and do not debounce those setters — a late debounce can write the old value back after reset. - Using
@rx.background- Does NOT exist in Reflex 0.8.x. Use@rx.event(background=True)instead.
1. Fomantic UI Grid does NOT work reliably in Reflex:
# UNRELIABLE - columns may stack vertically instead of side-by-side
rx.el.div(
rx.el.div(..., class_name="five wide column"),
rx.el.div(..., class_name="six wide column"),
class_name="ui grid",
)
# GOOD - use CSS flexbox for multi-column layouts
rx.el.div(
rx.el.div(left, style={"flex": "0 0 30%"}),
rx.el.div(center, style={"flex": "0 0 40%"}),
rx.el.div(right, style={"flex": "1 1 30%"}),
style={"display": "flex", "flexDirection": "row"},
)2. Fomantic UI Menu may not render horizontally:
Use flexbox for reliable horizontal menus instead of ui fixed menu.
3. Fomantic UI Checkbox requires specific HTML structure:
# BAD - rx.checkbox() doesn't use Fomantic styling
rx.checkbox(checked=is_checked)
# GOOD - proper Fomantic checkbox structure
rx.el.div(
rx.el.input(type="checkbox", checked=is_checked, read_only=True),
rx.el.label("Label"),
on_click=handler,
class_name=rx.cond(is_checked, "ui checked checkbox", "ui checkbox"),
)4. What DOES work from Fomantic UI in Reflex:
ui segment,ui raised segment- work wellui button,ui primary button- work wellui label,ui mini label,ui green label- work wellui divider- works wellui message- works wellui top attached tabular menu+ui bottom attached segment- works well for tabs (with state-based class toggling)
5. What does NOT work reliably:
ui gridwith column widths - use flexbox insteadui fixed menu- use flexbox insteadui accordion- may need JS initialization- Native
rx.checkbox()styling - use Fomantic structure instead
6. Fomantic UI Tabs (state-based, no jQuery):
# Tab menu - use state-based class toggling
def tab_menu() -> rx.Component:
return rx.el.div(
rx.el.a(
"Tab 1",
class_name=rx.cond(MyState.active_tab == "tab1", "active item", "item"),
on_click=lambda: MyState.switch_tab("tab1"),
),
rx.el.a(
"Tab 2",
class_name=rx.cond(MyState.active_tab == "tab2", "active item", "item"),
on_click=lambda: MyState.switch_tab("tab2"),
),
class_name="ui top attached tabular menu",
)
# Tab content - use rx.match for dynamic content
rx.el.div(
rx.match(
MyState.active_tab,
("tab1", tab1_content()),
("tab2", tab2_content()),
tab1_content(), # default
),
class_name="ui bottom attached segment",
)7. Custom API endpoints with api_transformer:
from fastapi import FastAPI
from fastapi.responses import FileResponse
# Create FastAPI app for custom routes
api = FastAPI()
@api.get("/api/download/{filename}")
async def download_file(filename: str) -> FileResponse:
return FileResponse(path=file_path, filename=filename)
# Pass to Reflex app
app = rx.App(
theme=None,
api_transformer=api, # Mounts custom routes
)The web UI integrates the prs-ui PyPI package for polygenic risk score computation using PGS Catalog data.
just-prs>=0.9.0: Core library — PRS computation, PGS Catalog client, scoring file parsingprs-ui>=0.3.15: Reusable Reflex components —PRSComputeStateMixin,prs_workbench_mode_panel(), score grid, results table
Both are added to webui/pyproject.toml.
PRSState is an independent rx.State subclass (not a substate of UploadState) with its own LazyFrameGridMixin for the PGS Catalog scores DataGrid. This parallels OutputPreviewState.
from prs_ui import PRSComputeStateMixin
class PRSState(PRSComputeStateMixin, LazyFrameGridMixin, rx.State):
genome_build: str = "GRCh38"
cache_dir: str = str(resolve_cache_dir()) # ~/.cache/just-prs/
status_message: str = ""- User selects a VCF file in the left panel
UploadState.select_file()resets Output/PRS/trait grid views first, then remounts the workspace and callsPRSState.reset_for_genome_switch(even if the new parquet is not ready yet), thenPRSState.initialize_prs_for_file(parquet_path, genome_build)when it isPRSStatecreates apl.scan_parquet()LazyFrame from the normalized parquet and callsset_prs_genotypes_lf(lf)(preferred input method — lazy, memory-efficient)- PGS Catalog scores are loaded into the MUI DataGrid for selection
- User selects scores and clicks Compute —
PRSState.compute_selected_prs()runs - Results with quality assessment, percentiles, and effect sizes are displayed
current_reference_genome from file metadata maps directly to PRS genome builds:
"GRCh38","T2T-CHM13v2.0"→"GRCh38"(default)"GRCh37","hg19"→"GRCh37"
| File | What it does |
|---|---|
webui/src/webui/state.py (PRSState) |
PRS computation state, inherits PRSComputeStateMixin + LazyFrameGridMixin |
webui/src/webui/pages/annotate.py |
PRS tab uses the prs-ui workbench layout with a current-sample row instead of a second VCF upload. Compare adds other left-panel samples that share species and reference genome. |
- LazyFrame is the preferred input —
set_prs_genotypes_lf(pl.scan_parquet(path))avoids redundant I/O. The parquet path is also set as string fallback. just-dna-lite normalized parquets keep polars-biostart, notpos. Scoring and ancestry go through_get_genotypes_lf()/_scan_prs_genotypes(), which aliasstart→pos. Never pass a rawscan_parquetintoinfer_sample_ancestry. PRSStateneedsgenome_build,cache_dir,status_message— these are vars on the state itself (not inherited fromUploadState), becausePRSComputeStateMixinreads them viaself.genome_buildetc.- Match the prs-ui workbench, not a second upload. The PRS tab uses
prs_workbench_mode_panelplustrait_selector/prs_scores_selectorinside Radix By Trait / By PRS tabs. Ancestry is shown on the current-sample row; do not add a toolbar population selector or a second VCF upload. Multi-genome scoring uses Add for comparison below the sample rows: picking a leftover left-panel sample adds it immediately (one leftover peer is a single button click; do not add a separate Compare then Add). Labels are sample name and filename (Livia Zaharia (SIMH….vcf.gz)), matching the left-panel display names. Peers share species, reference genome, and a ready normalized parquet. Compute stays onPRSState;PRSTraitStateonly selects traits and syncs PGS IDs. Switching the left-panel file clears the comparison. Mixed comparison rows are not checkpointed to Dagster. Do not passUploadState.vcf_preview_loadingasnormalizing— that locks the By Trait / By PRS grids (including filters) while the Input tab pages millions of VCF rows. Passnormalizing=False; PRS already gates onprs_genotypes_path.PRSTraitState.load_traitsmust passeager_value_options_row_limit=0like prs-ui. - Independent
LazyFrameGridMixin—PRSStategets its own grid vars, completely separate fromUploadState's VCF grid andOutputPreviewState's output grid. - PRS results are per-genome —
select_filemust reset PRS sample state even when the new parquet is still normalizing.prs_results, the Altair/iframe chart (selected_result_*), andprs_results_source_filebelong to one sample. Compute snapshotsprs_compute_token+ the parquet path and must discard writes if the user switched genomes. Never treat a leftover PGS ID as "already computed" for a different file. If the selected scores are already cached for this genome, Compute must still open that trait/score in the chart below (_ensure_result_chart_selected). A remount must not wipeselected_pgs_idsvia an empty MUI selection replay. - Remount the sample workspace, not individual widgets — the right-panel tabs/content wrap with
key=UploadState.selected_file. One sample = one React tree (grids, Vega charts, reports, analysis). Destroying that subtree is cheap; the cost is the parquet page. Do not keep a widget per genome, and do not reuse one MUI/Vega instance across partitions. The left file list and top nav stay mounted. Sort artifacts must include the source path, not just the state class name. - Grid filters/sorts are per-sample —
select_filemust reset Output/PRS/trait grid views before changingselected_file, then remount. MUI keeps a localuseStatefilter model and can replay the previous sample's filters on unmount;SafeGridMixin.reset_grid_view_stateclears everylf_grid_*filter/sort/selection field, bumpslf_grid_view_token(used as the gridkey), and drops one matching remount replay. Quality-filter settings frommodules.yamlare global and should stay the same. - Annotations and reports are per-sample — they live under
data/output/users/{user}/{sample}/.select_filemust clearoutput_files/report_filesimmediately; the background loader may only publish lists whenoutputs_loaded_for_file == selected_file. Tab badges and empty states go through those gated counts so a remount cannot show the previous genome's parquet or HTML.
- Never pass
UploadState.vcf_preview_loadingasnormalizingto the PRS workbench — that freezes By Trait / By PRS (pointer-events none, including the trait filter) for as long as the Input tab is paging the VCF preview. - Never
scan_parqueta just-dna-lite genome intoinfer_sample_ancestry/compute_prs— those parquets keepstart; just-prs looks uppos. Use_get_genotypes_lf()or_scan_prs_genotypes(). - Never make PRSState a substate of UploadState — it needs its own
LazyFrameGridMixininstance; mixing into UploadState would create MRO conflicts. - Never pass UploadState's internal LazyFrame across states — Reflex states are isolated; create a new
pl.scan_parquet()LazyFrame from the shared parquet path instead. - Never keep the previous genome's
prs_resultsor chart spec across a file switch — the chart panel is gated onselected_result_spec != {}, so an uncleared Vega spec keeps showing the old sample. Compute also skips PGS IDs already present inprs_results, which turns a leftover Oksana score into a no-op on Livia.
For UI/frontend changes, see docs/DESIGN.md.
Key principles:
- "Chunky & Tactile" aesthetic with high affordance
- Fomantic UI component classes (segments, buttons, labels work best)
- CSS Flexbox for layouts (not Fomantic grid)
- Oversized icons (min 2rem), large buttons, generous spacing
- Semantic colors:
success(benign),error(pathogenic),info(VUS)
- When writing READMEs or user-facing docs: put images at the top, place caveats after Quick Start, and keep intros concise while avoiding technical jargon (e.g., "VCF", "Polars", "DuckDB"). Move deep implementation details to
docs/. - Write in natural, human prose avoiding AI-typical patterns (em-dashes, filler transitions, marketing voice). Never hallucinate documentation.
- Don't overpromise support levels. GRCh38 WGS/WES VCFs are the primary path; GRCh37/hg19 liftover support is added; consumer microarray support exists experimentally and has much lower coverage than WGS/WES; T2T remains not fully supported unless current code/tests prove otherwise. Balance credibility with honesty: ROGEN results are planned/future work, not finished outcomes. Never claim the tool solves alignment or variant calling — it only handles annotation of an existing VCF or supported genotype/variant input.
- Update related documentation (AGENTS.md, DAGSTER_GUIDE.md) immediately whenever code is refactored.
- For upstream PyPI dependencies (like
prs-ui), try to fix bugs locally or provide copy-paste prompts for upstream fixes rather than patching locally. - Use fsspec-based access patterns instead of symlinks. Cache HuggingFace data in the project's own cache using fsspec/HfFileSystem, never use
snapshot_download. - Avoid
subprocesscomplexity for CLI commands; use uv workspace[project.scripts]instead. Automatically create missing directories in code rather than expecting users tomkdir. - Output file names must reflect semantic content (e.g.,
_ensembl_annotated.parquet), not implementation details. Reports should be timestamped to avoid overwriting previous runs. - When the user gives a minimal working example or pattern, wire it in directly instead of over-exploring alternatives.
- Use global/inclusive framing in docs and UI: avoid EU-only language; users from any country should feel welcome. Reference EHDS as one example among international open health data initiatives.
- When describing the platform in papers/docs, frame it as a bioinformatics tool that joins VCF data against module databases to add annotations. Never imply the VCF already contains annotations or that the tool makes gene-disease inferences.
- For workshop/conference proposals: primary readers are organizers, not participants. Address conference themes implicitly (don't name-drop). Use "instructor" not "facilitator". Avoid manifesto/advocacy tone, words like "neat"/"slippery"/"primer", and never leak AI instructions into document text. Clearly separate "will get" vs "will not get". Use Roman numerals for generation labels (Gen I, Gen II).
- This is a multi-root uv workspace:
just-dna-lite(main) andjust-prs(read-only reference). Never modify files injust-prs.just-prswas developed specifically for Just-DNA-Lite but released as a standalone library. Related repos:just-dna-lite,just-prs,reflex-mui-datagrid,just-biomarkers,dna-seq,prepare-annotations. - The annotation-module schema + compiler live in two shared published libs —
just-dna-format(just_dna_format) andjust-dna-compiler(just_dna_compiler) — consumed byjust-dna-lite,just-dna-marketplace, andjust-dna-agents. We consume them (do not fork/vendor); propose changes only as notes in/data/sources/just-dna-format/docs/{ROADMAP,CHANGELOG}.md, never by committing to that repo. See the "Shared Module Format & Compiler Libraries" section above. - The project runs on Linux, macOS, native Windows, and Apple Silicon Macs. Critical native deps have working wheels; Windows scripts live in
windows/, and the Nix workflow isnix developthenuv syncthenuv run startfor development oruv run servefor the single-process server. - The AI Module Creator uses the Agno agentic framework, which allows configuring OpenAI API-compatible local models (e.g., Ollama or vLLM) for complete privacy.
- Paper 1 now includes ecosystem infrastructure beyond the local app:
just-dna-format(https://github.com/dna-seq/just-dna-format) is the standalone annotation module schema/manifest/integrity contract and reference compiler, andjust-dna-marketplace(https://github.com/dna-seq/just-dna-marketplace) is the catalog/publish/download REST API for annotation modules. Include both in Paper 1 architecture and code-availability discussions. - Images for README live in
images/at the project root. Use<img>tags (not markdown syntax) for images inside HTML<div>blocks. - GRCh38 VCF files remain the primary fully supported path. GRCh37/hg19 liftover support has been added; consumer microarray support is experimental and lower-coverage; T2T should still be treated as not fully supported unless current code/tests show otherwise. just-dna-lite normalized parquets keep polars-bio
start(they do not rename it topos). PRS scoring and ancestry must aliasstart→posvia_get_genotypes_lf()/_scan_prs_genotypes(). PRS runs in Reflex rather than Dagster, and must clear/rebuildprs_results_rows,prs_results_columns, andprs_results_column_groupsafter updatingprs_results. After a Compare / PRS UI change, restartuv run start— hot reload can compile oldannotate.pyagainst newPRSState(compare_picker_openAttributeError). rx.icon()(Lucide) icons often fail in this Reflex setup; usefomantic_icon()fromwebui.components.layoutinstead. Fomantic icon names are space-separated (e.g.,arrow up), not hyphenated Lucide-style.- Backend API port is auto-resolved at startup; never hardcode port 8000. Custom API routes (via
api_transformer) are only served by the Reflex backend; the frontend dev server does NOT proxy arbitrary/api/...paths.webui/deployment_urls.pybuilds the browser-reachable base URL:PUBLIC_BACKEND_URLoverridesAPI_URL(needed when the image setsAPI_URL=http://localhost:8000).webui.runselects a free backend port and persists it inAPI_URL/REFLEX_BACKEND_PORT;backend_api_urlreads those so the browser constructs direct URLs (e.g./api/report/...). A leftoverAPI_URL=http://localhost:8000must not win when Reflex actually bound 8002. Never return""frombackend_api_url— relative URLs 404 on the frontend. - Always load
.envviaload_dotenv()or equivalent before usingos.getenvfor config paths (JUST_DNA_PIPELINES_CACHE_DIR,JUST_DNA_PIPELINES_OUTPUT_DIR, etc.). - Public genomes for demos: Anton Kulaga (Zenodo 18370498, CC-Zero, 482 MB) and Livia Zaharia (Zenodo 19487816, CC-BY-4.0, 349 MB). Both are configured as
default_samplesinmodules.yamlimmutable_mode:section. The app can also import arbitrary Zenodo records with open-access + permissive license + VCF via the "Import from Zenodo" UI. - The core expert-curated HuggingFace modules include
coronary,lipidmetabolism,longevitymap,superhuman, andvo2max. The module ecosystem can also include AI-generated modules, such as "Longevity Variants 2026", which must be labelled as AI-generated research-use drafts requiring review. Do not assume only five modules exist without checking currentmodules.yaml, registered modules, and HuggingFace state. - The first preprint was rejected by bioRxiv ("inference drawn between gene(s) and disease(s)") and medRxiv; published on arXiv instead. To avoid repeat rejection, frame the manuscript as a bioinformatics methods/software paper, not a genomic medicine paper.
ghcr.io/dna-seq/just-dna-lite:latestcontainer image does not exist on GHCR yet;compose.yamlbuilds locally. TheContainerfileneedschmod -R 777 .venvfor Podman rootless compatibility andUV_FROZEN=1to prevent re-syncing. Workshop materials live indocs/workshops/. Pytest must stay in workspace root dev dependencies foruv run pytest, anduvdoes NOT have auv bundlecommand as of April 2026.