just-dna-compiler 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- just_dna_compiler-0.1.0/PKG-INFO +35 -0
- just_dna_compiler-0.1.0/README.md +22 -0
- just_dna_compiler-0.1.0/pyproject.toml +26 -0
- just_dna_compiler-0.1.0/src/just_dna_compiler/__init__.py +9 -0
- just_dna_compiler-0.1.0/src/just_dna_compiler/cache.py +78 -0
- just_dna_compiler-0.1.0/src/just_dna_compiler/compiler.py +676 -0
- just_dna_compiler-0.1.0/src/just_dna_compiler/models.py +33 -0
- just_dna_compiler-0.1.0/src/just_dna_compiler/resolver.py +192 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: just-dna-compiler
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Reference compiler for just-dna annotation modules: spec directory → parquet artifact + manifest
|
|
5
|
+
Requires-Dist: just-dna-format
|
|
6
|
+
Requires-Dist: polars>=1.42.0
|
|
7
|
+
Requires-Dist: duckdb>=1.1.0
|
|
8
|
+
Requires-Dist: pyyaml>=6.0.2
|
|
9
|
+
Requires-Dist: platformdirs>=4.0.0
|
|
10
|
+
Requires-Dist: python-dotenv>=1.0.1
|
|
11
|
+
Requires-Python: >=3.13
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# just-dna-compiler
|
|
15
|
+
|
|
16
|
+
The reference **compiler** for just-dna annotation modules: it turns an authored spec directory
|
|
17
|
+
(`module_spec.yaml` + `variants.csv` + `studies.csv`) into the deployable three-parquet artifact
|
|
18
|
+
(`weights` / `annotations` / `studies`) **plus a `manifest.json`** with integrity digests.
|
|
19
|
+
|
|
20
|
+
It consumes the schema/contract from [`just-dna-format`](../schema) and is the shared transform
|
|
21
|
+
called by both `just-dna-pipelines` (local compile) and `just-dna-marketplace` (server-side
|
|
22
|
+
recompile on publish).
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from just_dna_compiler.compiler import validate_spec, compile_module
|
|
26
|
+
|
|
27
|
+
validate_spec(spec_dir) # -> ValidationResult (genes/categories lists)
|
|
28
|
+
compile_module(spec_dir, out_dir, # -> CompilationResult (+ manifest.json written)
|
|
29
|
+
resolve_with_ensembl=False, # inject an Ensembl DuckDB/parquet dir to resolve
|
|
30
|
+
compiled_by="marketplace-server")
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Dependencies:** `just-dna-format`, `polars`, `duckdb`, `pyyaml` — deliberately no Dagster / LLM
|
|
34
|
+
SDKs. The Ensembl reference is **injected** by the caller (a `.duckdb` file or a parquet dir); this
|
|
35
|
+
package never downloads it.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# just-dna-compiler
|
|
2
|
+
|
|
3
|
+
The reference **compiler** for just-dna annotation modules: it turns an authored spec directory
|
|
4
|
+
(`module_spec.yaml` + `variants.csv` + `studies.csv`) into the deployable three-parquet artifact
|
|
5
|
+
(`weights` / `annotations` / `studies`) **plus a `manifest.json`** with integrity digests.
|
|
6
|
+
|
|
7
|
+
It consumes the schema/contract from [`just-dna-format`](../schema) and is the shared transform
|
|
8
|
+
called by both `just-dna-pipelines` (local compile) and `just-dna-marketplace` (server-side
|
|
9
|
+
recompile on publish).
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from just_dna_compiler.compiler import validate_spec, compile_module
|
|
13
|
+
|
|
14
|
+
validate_spec(spec_dir) # -> ValidationResult (genes/categories lists)
|
|
15
|
+
compile_module(spec_dir, out_dir, # -> CompilationResult (+ manifest.json written)
|
|
16
|
+
resolve_with_ensembl=False, # inject an Ensembl DuckDB/parquet dir to resolve
|
|
17
|
+
compiled_by="marketplace-server")
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
**Dependencies:** `just-dna-format`, `polars`, `duckdb`, `pyyaml` — deliberately no Dagster / LLM
|
|
21
|
+
SDKs. The Ensembl reference is **injected** by the caller (a `.duckdb` file or a parquet dir); this
|
|
22
|
+
package never downloads it.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "just-dna-compiler"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Reference compiler for just-dna annotation modules: spec directory → parquet artifact + manifest"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"just-dna-format",
|
|
9
|
+
"polars>=1.42.0",
|
|
10
|
+
"duckdb>=1.1.0",
|
|
11
|
+
"pyyaml>=6.0.2",
|
|
12
|
+
"platformdirs>=4.0.0",
|
|
13
|
+
"python-dotenv>=1.0.1",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["uv-build"]
|
|
18
|
+
build-backend = "uv_build"
|
|
19
|
+
|
|
20
|
+
[tool.uv.sources]
|
|
21
|
+
just-dna-format = { workspace = true }
|
|
22
|
+
|
|
23
|
+
[dependency-groups]
|
|
24
|
+
dev = [
|
|
25
|
+
"pytest>=9.0.3",
|
|
26
|
+
]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""
|
|
2
|
+
just-dna-compiler — the reference transform from an authored module spec directory to the
|
|
3
|
+
deployable three-parquet artifact plus its `manifest.json`.
|
|
4
|
+
|
|
5
|
+
Consumes the schema/contract from `just-dna-format`; both `just-dna-pipelines` (local compile) and
|
|
6
|
+
`just-dna-marketplace` (server-side recompile on publish) call the same `compile_module`.
|
|
7
|
+
|
|
8
|
+
from just_dna_compiler.compiler import validate_spec, compile_module, reverse_module
|
|
9
|
+
"""
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Ensembl reference cache resolution — mirrors just-dna-lite's on-disk layout so a marketplace or
|
|
3
|
+
standalone compile can **reuse an existing just-dna-lite deployment's cache** (disk economy, no
|
|
4
|
+
re-download), pointed via `.env`.
|
|
5
|
+
|
|
6
|
+
Layout (identical to `just-dna-pipelines`)::
|
|
7
|
+
|
|
8
|
+
<base>/ensembl_variations/data/*.parquet
|
|
9
|
+
<base>/ensembl_variations/ensembl_variations.duckdb # optional prebuilt view
|
|
10
|
+
|
|
11
|
+
where ``<base>`` is ``$JUST_DNA_PIPELINES_CACHE_DIR`` (the same var just-dna-lite uses), or the
|
|
12
|
+
platformdirs user cache for ``"just-dna-pipelines"``. ``$JUST_DNA_ENSEMBL_CACHE`` (a ``.duckdb``
|
|
13
|
+
file or a directory) overrides everything for explicit pointing.
|
|
14
|
+
|
|
15
|
+
This module **never downloads**: if no cache is present, resolution returns ``None`` and the
|
|
16
|
+
resolver skips with a warning. Provisioning the reference is the deployment's job.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
from dotenv import find_dotenv, load_dotenv
|
|
24
|
+
from platformdirs import user_cache_dir
|
|
25
|
+
|
|
26
|
+
APPNAME: str = "just-dna-pipelines"
|
|
27
|
+
ENSEMBL_SUBDIR: str = "ensembl_variations"
|
|
28
|
+
DUCKDB_NAME: str = "ensembl_variations.duckdb"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_env(override: bool = False) -> Optional[str]:
|
|
32
|
+
"""Load the nearest `.env` (walking up from CWD), so cache paths can be set there.
|
|
33
|
+
Returns the loaded path, or None."""
|
|
34
|
+
env_path = find_dotenv(usecwd=True)
|
|
35
|
+
if env_path:
|
|
36
|
+
load_dotenv(env_path, override=override)
|
|
37
|
+
return env_path
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def default_ensembl_cache_dir() -> Path:
|
|
42
|
+
"""The `<base>/ensembl_variations` directory, matching just-dna-lite's convention."""
|
|
43
|
+
base = os.getenv("JUST_DNA_PIPELINES_CACHE_DIR")
|
|
44
|
+
root = Path(base) if base else Path(user_cache_dir(appname=APPNAME))
|
|
45
|
+
return root / ENSEMBL_SUBDIR
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve_ensembl_reference(
|
|
49
|
+
ensembl_cache: Optional[Path] = None, *, load_dotenv_file: bool = True
|
|
50
|
+
) -> Optional[Path]:
|
|
51
|
+
"""Locate a usable Ensembl reference without downloading.
|
|
52
|
+
|
|
53
|
+
Precedence: explicit `ensembl_cache` → ``$JUST_DNA_ENSEMBL_CACHE`` → the just-dna-lite layout
|
|
54
|
+
under ``$JUST_DNA_PIPELINES_CACHE_DIR`` / platformdirs. Prefers a prebuilt
|
|
55
|
+
``ensembl_variations.duckdb``; otherwise the directory of parquet files. Returns the resolved
|
|
56
|
+
path (a ``.duckdb`` file or a directory), or ``None`` if nothing is present.
|
|
57
|
+
"""
|
|
58
|
+
if load_dotenv_file:
|
|
59
|
+
load_env()
|
|
60
|
+
|
|
61
|
+
candidate = ensembl_cache or os.getenv("JUST_DNA_ENSEMBL_CACHE")
|
|
62
|
+
search_dir = Path(candidate) if candidate else default_ensembl_cache_dir()
|
|
63
|
+
|
|
64
|
+
# Explicit pointing at a specific DuckDB file.
|
|
65
|
+
if search_dir.is_file() and search_dir.suffix == ".duckdb":
|
|
66
|
+
return search_dir
|
|
67
|
+
|
|
68
|
+
# Otherwise return the cache directory if it holds a prebuilt db or parquet data; the
|
|
69
|
+
# connection layer decides whether the db is usable and falls back to parquet if not.
|
|
70
|
+
if search_dir.is_dir():
|
|
71
|
+
data_dir = search_dir / "data"
|
|
72
|
+
has_db = (search_dir / DUCKDB_NAME).is_file()
|
|
73
|
+
has_parquet = (data_dir.is_dir() and any(data_dir.glob("*.parquet"))) or any(
|
|
74
|
+
search_dir.glob("*.parquet")
|
|
75
|
+
)
|
|
76
|
+
if has_db or has_parquet:
|
|
77
|
+
return search_dir
|
|
78
|
+
return None
|
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module spec compiler: validates a spec directory and compiles it to the three-parquet artifact
|
|
3
|
+
(weights, annotations, studies) plus a `manifest.json`.
|
|
4
|
+
|
|
5
|
+
Public API:
|
|
6
|
+
validate_spec(spec_dir) -> ValidationResult
|
|
7
|
+
compile_module(spec_dir, output_dir, ...) -> CompilationResult (emits manifest.json)
|
|
8
|
+
reverse_module(parquet_dir, output_dir, ...) -> Path
|
|
9
|
+
|
|
10
|
+
The DSL/manifest schema comes from `just-dna-format`; this package is the transform between them.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import csv
|
|
14
|
+
import shutil
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Optional
|
|
19
|
+
|
|
20
|
+
import polars as pl
|
|
21
|
+
import yaml
|
|
22
|
+
from just_dna_format.integrity import build_artifact, file_entries
|
|
23
|
+
from just_dna_format.manifest import (
|
|
24
|
+
Compilation,
|
|
25
|
+
Display,
|
|
26
|
+
FileEntry,
|
|
27
|
+
Identity,
|
|
28
|
+
ModuleManifest,
|
|
29
|
+
Stats,
|
|
30
|
+
write_manifest,
|
|
31
|
+
)
|
|
32
|
+
from just_dna_format.spec import ModuleSpecConfig, StudyRow, VariantRow
|
|
33
|
+
from pydantic import ValidationError
|
|
34
|
+
|
|
35
|
+
from just_dna_compiler.models import CompilationResult, ValidationResult
|
|
36
|
+
|
|
37
|
+
_INPUT_FILES: tuple[str, ...] = ("module_spec.yaml", "variants.csv", "studies.csv")
|
|
38
|
+
_OUTPUT_FILES: tuple[str, ...] = ("weights.parquet", "annotations.parquet", "studies.parquet")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _compiler_version() -> str:
|
|
42
|
+
try:
|
|
43
|
+
return f"just-dna-compiler {version('just-dna-compiler')}"
|
|
44
|
+
except PackageNotFoundError:
|
|
45
|
+
return "just-dna-compiler unknown"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _now_iso() -> str:
|
|
49
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _collect_logs(
|
|
53
|
+
spec_dir: Path, output_dir: Path, explicit: Optional[list[Path]]
|
|
54
|
+
) -> list[FileEntry]:
|
|
55
|
+
"""Gather optional run/provenance logs into the module dir and hash them.
|
|
56
|
+
|
|
57
|
+
Auto-discovers a top-level aggregate log (`*.log` in `spec_dir`) plus per-role files under a
|
|
58
|
+
`spec_dir/logs/` folder, preserving each file's path relative to the module. An explicit
|
|
59
|
+
`log_files` list overrides discovery. Files are copied into `output_dir` (so they ship with the
|
|
60
|
+
module) and returned as hashed `FileEntry` rows. No logs → empty list (a valid module).
|
|
61
|
+
"""
|
|
62
|
+
pairs: list[tuple[str, Path]] = [] # (relative name in module dir, source file)
|
|
63
|
+
if explicit is not None:
|
|
64
|
+
for path in map(Path, explicit):
|
|
65
|
+
try:
|
|
66
|
+
rel = path.relative_to(spec_dir).as_posix()
|
|
67
|
+
except ValueError:
|
|
68
|
+
rel = path.name
|
|
69
|
+
pairs.append((rel, path))
|
|
70
|
+
else:
|
|
71
|
+
for path in sorted(spec_dir.glob("*.log")):
|
|
72
|
+
pairs.append((path.name, path))
|
|
73
|
+
logs_dir = spec_dir / "logs"
|
|
74
|
+
if logs_dir.is_dir():
|
|
75
|
+
for path in sorted(logs_dir.rglob("*.log")):
|
|
76
|
+
pairs.append((path.relative_to(spec_dir).as_posix(), path))
|
|
77
|
+
|
|
78
|
+
seen: set[str] = set()
|
|
79
|
+
names: list[str] = []
|
|
80
|
+
for rel, src in pairs:
|
|
81
|
+
if rel in seen or not src.is_file():
|
|
82
|
+
continue
|
|
83
|
+
seen.add(rel)
|
|
84
|
+
dest = output_dir / rel
|
|
85
|
+
if dest.resolve() != src.resolve():
|
|
86
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
shutil.copyfile(src, dest)
|
|
88
|
+
names.append(rel)
|
|
89
|
+
return file_entries(output_dir, names)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ── File loading helpers ───────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _load_yaml(path: Path) -> tuple[Optional[ModuleSpecConfig], list[str]]:
|
|
96
|
+
"""Load and validate module_spec.yaml. Returns (config, errors)."""
|
|
97
|
+
if not path.exists():
|
|
98
|
+
return None, [f"module_spec.yaml not found at {path}"]
|
|
99
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
100
|
+
if raw is None:
|
|
101
|
+
return None, ["module_spec.yaml is empty"]
|
|
102
|
+
try:
|
|
103
|
+
return ModuleSpecConfig.model_validate(raw), []
|
|
104
|
+
except ValidationError as exc:
|
|
105
|
+
errors = []
|
|
106
|
+
for err in exc.errors():
|
|
107
|
+
loc = " → ".join(str(x) for x in err["loc"])
|
|
108
|
+
errors.append(f"module_spec.yaml [{loc}]: {err['msg']}")
|
|
109
|
+
return None, errors
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _load_csv_rows(
|
|
113
|
+
path: Path, row_model: type, file_label: str
|
|
114
|
+
) -> tuple[list[Any], list[str], list[str]]:
|
|
115
|
+
"""Load a CSV and validate each row against a Pydantic model. Returns (rows, errors, warnings)."""
|
|
116
|
+
errors: list[str] = []
|
|
117
|
+
rows: list[Any] = []
|
|
118
|
+
if not path.exists():
|
|
119
|
+
return [], [f"{file_label} not found at {path}"], []
|
|
120
|
+
|
|
121
|
+
with open(path, encoding="utf-8", newline="") as f:
|
|
122
|
+
reader = csv.DictReader(f)
|
|
123
|
+
if reader.fieldnames is None:
|
|
124
|
+
return [], [f"{file_label} has no header row"], []
|
|
125
|
+
for line_num, raw_row in enumerate(reader, start=2):
|
|
126
|
+
cleaned = {
|
|
127
|
+
k.strip(): (v.strip() if isinstance(v, str) and v.strip() != "" else None)
|
|
128
|
+
for k, v in raw_row.items()
|
|
129
|
+
if k is not None
|
|
130
|
+
}
|
|
131
|
+
try:
|
|
132
|
+
rows.append(row_model.model_validate(cleaned))
|
|
133
|
+
except ValidationError as exc:
|
|
134
|
+
for err in exc.errors():
|
|
135
|
+
loc = " → ".join(str(x) for x in err["loc"])
|
|
136
|
+
errors.append(f"{file_label} line {line_num} [{loc}]: {err['msg']}")
|
|
137
|
+
return rows, errors, []
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ── Cross-row validation ───────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _cross_validate_variants(variants: list[VariantRow]) -> tuple[list[str], list[str]]:
|
|
144
|
+
"""Validate consistency across variant rows. Returns (errors, warnings)."""
|
|
145
|
+
errors: list[str] = []
|
|
146
|
+
warnings: list[str] = []
|
|
147
|
+
|
|
148
|
+
key_positions: dict[str, tuple[Optional[str], Optional[int]]] = {}
|
|
149
|
+
for row in variants:
|
|
150
|
+
key = row.variant_key
|
|
151
|
+
pos = (row.chrom, row.start)
|
|
152
|
+
if key in key_positions:
|
|
153
|
+
if key_positions[key] != pos:
|
|
154
|
+
errors.append(f"Inconsistent positions for {key}: {key_positions[key]} vs {pos}")
|
|
155
|
+
else:
|
|
156
|
+
key_positions[key] = pos
|
|
157
|
+
|
|
158
|
+
seen_keys: set[tuple[str, str]] = set()
|
|
159
|
+
for row in variants:
|
|
160
|
+
key = (row.variant_key, row.genotype)
|
|
161
|
+
if key in seen_keys:
|
|
162
|
+
errors.append(f"Duplicate (variant, genotype): ({row.variant_key}, {row.genotype})")
|
|
163
|
+
seen_keys.add(key)
|
|
164
|
+
|
|
165
|
+
for row in variants:
|
|
166
|
+
if row.weight is None:
|
|
167
|
+
continue
|
|
168
|
+
if row.state == "risk" and row.weight > 0:
|
|
169
|
+
warnings.append(
|
|
170
|
+
f"{row.variant_key} genotype {row.genotype}: state='risk' but weight={row.weight} > 0"
|
|
171
|
+
)
|
|
172
|
+
if row.state == "protective" and row.weight < 0:
|
|
173
|
+
warnings.append(
|
|
174
|
+
f"{row.variant_key} genotype {row.genotype}: state='protective' but weight={row.weight} < 0"
|
|
175
|
+
)
|
|
176
|
+
return errors, warnings
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _cross_validate_studies(
|
|
180
|
+
studies: list[StudyRow], variant_keys: set[str]
|
|
181
|
+
) -> tuple[list[str], list[str]]:
|
|
182
|
+
"""Validate study rows against variant keys. Returns (errors, warnings)."""
|
|
183
|
+
warnings: list[str] = []
|
|
184
|
+
orphan_keys = {row.variant_key for row in studies} - variant_keys
|
|
185
|
+
if orphan_keys:
|
|
186
|
+
warnings.append(f"Studies reference variants not in variants.csv: {sorted(orphan_keys)}")
|
|
187
|
+
seen: set[tuple[str, str]] = set()
|
|
188
|
+
for row in studies:
|
|
189
|
+
key = (row.variant_key, row.pmid)
|
|
190
|
+
if key in seen:
|
|
191
|
+
warnings.append(f"Duplicate (variant, pmid): ({row.variant_key}, {row.pmid})")
|
|
192
|
+
seen.add(key)
|
|
193
|
+
return [], warnings
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ── Public API ─────────────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def validate_spec(spec_dir: Path) -> ValidationResult:
|
|
200
|
+
"""Validate a module spec directory without producing output.
|
|
201
|
+
|
|
202
|
+
Stats include `genes`/`categories` as lists (filtering None) plus `variant_count`,
|
|
203
|
+
`gene_count`, and `study_count` — the fields the manifest needs.
|
|
204
|
+
"""
|
|
205
|
+
spec_dir = Path(spec_dir)
|
|
206
|
+
all_errors: list[str] = []
|
|
207
|
+
all_warnings: list[str] = []
|
|
208
|
+
|
|
209
|
+
if not spec_dir.is_dir():
|
|
210
|
+
return ValidationResult(valid=False, errors=[f"Spec directory does not exist: {spec_dir}"])
|
|
211
|
+
|
|
212
|
+
config, yaml_errors = _load_yaml(spec_dir / "module_spec.yaml")
|
|
213
|
+
all_errors.extend(yaml_errors)
|
|
214
|
+
|
|
215
|
+
variants, var_errors, var_warnings = _load_csv_rows(
|
|
216
|
+
spec_dir / "variants.csv", VariantRow, "variants.csv"
|
|
217
|
+
)
|
|
218
|
+
all_errors.extend(var_errors)
|
|
219
|
+
all_warnings.extend(var_warnings)
|
|
220
|
+
|
|
221
|
+
studies_path = spec_dir / "studies.csv"
|
|
222
|
+
studies: list[StudyRow] = []
|
|
223
|
+
if studies_path.exists():
|
|
224
|
+
studies, study_errors, study_warnings = _load_csv_rows(
|
|
225
|
+
studies_path, StudyRow, "studies.csv"
|
|
226
|
+
)
|
|
227
|
+
all_errors.extend(study_errors)
|
|
228
|
+
all_warnings.extend(study_warnings)
|
|
229
|
+
if not studies and not study_errors:
|
|
230
|
+
all_errors.append(
|
|
231
|
+
"studies.csv is present but has no study rows. Grounding evidence is mandatory."
|
|
232
|
+
)
|
|
233
|
+
else:
|
|
234
|
+
all_errors.append(
|
|
235
|
+
"studies.csv is missing. Grounding evidence is mandatory; add study rows with PMIDs."
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
if variants:
|
|
239
|
+
cross_errors, cross_warnings = _cross_validate_variants(variants)
|
|
240
|
+
all_errors.extend(cross_errors)
|
|
241
|
+
all_warnings.extend(cross_warnings)
|
|
242
|
+
variant_keys = {v.variant_key for v in variants}
|
|
243
|
+
if studies:
|
|
244
|
+
_, study_warnings = _cross_validate_studies(studies, variant_keys)
|
|
245
|
+
all_warnings.extend(study_warnings)
|
|
246
|
+
|
|
247
|
+
stats: dict[str, Any] = {}
|
|
248
|
+
if variants:
|
|
249
|
+
variant_keys_set = {v.variant_key for v in variants}
|
|
250
|
+
genes = sorted({v.gene for v in variants if v.gene})
|
|
251
|
+
categories = sorted({v.category for v in variants if v.category})
|
|
252
|
+
stats = {
|
|
253
|
+
"variant_count": len(variant_keys_set),
|
|
254
|
+
"unique_rsids": len({v.rsid for v in variants if v.rsid is not None}),
|
|
255
|
+
"gene_count": len(genes),
|
|
256
|
+
"genes": genes,
|
|
257
|
+
"categories": categories,
|
|
258
|
+
"study_count": len(studies),
|
|
259
|
+
}
|
|
260
|
+
if config:
|
|
261
|
+
stats["module_name"] = config.module.name
|
|
262
|
+
|
|
263
|
+
return ValidationResult(
|
|
264
|
+
valid=len(all_errors) == 0, errors=all_errors, warnings=all_warnings, stats=stats
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def compile_module(
|
|
269
|
+
spec_dir: Path,
|
|
270
|
+
output_dir: Path,
|
|
271
|
+
compression: str = "zstd",
|
|
272
|
+
resolve_with_ensembl: bool = True,
|
|
273
|
+
ensembl_cache: Optional[Path] = None,
|
|
274
|
+
compiled_by: Optional[str] = None,
|
|
275
|
+
ensembl_reference: Optional[str] = None,
|
|
276
|
+
log_files: Optional[list[Path]] = None,
|
|
277
|
+
) -> CompilationResult:
|
|
278
|
+
"""Compile a module spec directory into parquet files plus a `manifest.json`.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
spec_dir: Path to the module spec directory.
|
|
282
|
+
output_dir: Directory for output parquet files + manifest.json.
|
|
283
|
+
compression: Parquet compression codec.
|
|
284
|
+
resolve_with_ensembl: Resolve missing rsid/position via an injected Ensembl DuckDB.
|
|
285
|
+
ensembl_cache: Path to a prebuilt Ensembl DuckDB or a parquet cache dir (required to
|
|
286
|
+
resolve; the standalone compiler does not download it — the caller injects it).
|
|
287
|
+
compiled_by: Provenance tag for the manifest (the marketplace passes "marketplace-server";
|
|
288
|
+
a local compile leaves it None, so downloaders treat it as untrusted).
|
|
289
|
+
ensembl_reference: Pinned reference id recorded in the manifest for reproducibility.
|
|
290
|
+
log_files: Explicit run/provenance log files to record. If None, auto-discovers a top-level
|
|
291
|
+
`*.log` plus per-role files under `spec_dir/logs/`. Logs are optional.
|
|
292
|
+
"""
|
|
293
|
+
spec_dir = Path(spec_dir)
|
|
294
|
+
output_dir = Path(output_dir)
|
|
295
|
+
|
|
296
|
+
validation = validate_spec(spec_dir)
|
|
297
|
+
if not validation.valid:
|
|
298
|
+
return CompilationResult(
|
|
299
|
+
success=False, errors=validation.errors, warnings=validation.warnings
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
config, _ = _load_yaml(spec_dir / "module_spec.yaml")
|
|
303
|
+
assert config is not None
|
|
304
|
+
variants, _, _ = _load_csv_rows(spec_dir / "variants.csv", VariantRow, "variants.csv")
|
|
305
|
+
studies: list[StudyRow] = []
|
|
306
|
+
if (spec_dir / "studies.csv").exists():
|
|
307
|
+
studies, _, _ = _load_csv_rows(spec_dir / "studies.csv", StudyRow, "studies.csv")
|
|
308
|
+
|
|
309
|
+
all_warnings = list(validation.warnings)
|
|
310
|
+
if resolve_with_ensembl:
|
|
311
|
+
from just_dna_compiler.resolver import resolve_variants
|
|
312
|
+
|
|
313
|
+
variants, resolve_warnings = resolve_variants(variants, ensembl_cache)
|
|
314
|
+
all_warnings.extend(resolve_warnings)
|
|
315
|
+
|
|
316
|
+
module_name = config.module.name
|
|
317
|
+
weights_df = _build_weights(variants, config)
|
|
318
|
+
annotations_df = _build_annotations(variants, module_name)
|
|
319
|
+
studies_df = _build_studies(studies, module_name) if studies else None
|
|
320
|
+
|
|
321
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
322
|
+
weights_df.write_parquet(output_dir / "weights.parquet", compression=compression)
|
|
323
|
+
annotations_df.write_parquet(output_dir / "annotations.parquet", compression=compression)
|
|
324
|
+
if studies_df is not None:
|
|
325
|
+
studies_df.write_parquet(output_dir / "studies.parquet", compression=compression)
|
|
326
|
+
|
|
327
|
+
logs = _collect_logs(spec_dir, output_dir, log_files)
|
|
328
|
+
manifest = _build_manifest(
|
|
329
|
+
config=config,
|
|
330
|
+
spec_dir=spec_dir,
|
|
331
|
+
output_dir=output_dir,
|
|
332
|
+
validation=validation,
|
|
333
|
+
weights_rows=weights_df.height,
|
|
334
|
+
warnings=all_warnings,
|
|
335
|
+
compiled_by=compiled_by,
|
|
336
|
+
ensembl_reference=ensembl_reference,
|
|
337
|
+
logs=logs,
|
|
338
|
+
)
|
|
339
|
+
write_manifest(manifest, output_dir / "manifest.json")
|
|
340
|
+
|
|
341
|
+
stats: dict[str, Any] = {
|
|
342
|
+
"module_name": module_name,
|
|
343
|
+
"weights_rows": weights_df.height,
|
|
344
|
+
"annotations_rows": annotations_df.height,
|
|
345
|
+
"studies_rows": studies_df.height if studies_df is not None else 0,
|
|
346
|
+
}
|
|
347
|
+
return CompilationResult(
|
|
348
|
+
success=True,
|
|
349
|
+
output_dir=output_dir,
|
|
350
|
+
errors=[],
|
|
351
|
+
warnings=all_warnings,
|
|
352
|
+
stats=stats,
|
|
353
|
+
manifest=manifest,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _build_manifest(
|
|
358
|
+
*,
|
|
359
|
+
config: ModuleSpecConfig,
|
|
360
|
+
spec_dir: Path,
|
|
361
|
+
output_dir: Path,
|
|
362
|
+
validation: ValidationResult,
|
|
363
|
+
weights_rows: int,
|
|
364
|
+
warnings: list[str],
|
|
365
|
+
compiled_by: Optional[str],
|
|
366
|
+
ensembl_reference: Optional[str],
|
|
367
|
+
logs: list[FileEntry],
|
|
368
|
+
) -> ModuleManifest:
|
|
369
|
+
"""Assemble the manifest from the spec, validation stats, and hashed input/output/log files."""
|
|
370
|
+
module = config.module
|
|
371
|
+
vstats = validation.stats
|
|
372
|
+
return ModuleManifest(
|
|
373
|
+
identity=Identity(name=module.name),
|
|
374
|
+
display=Display(
|
|
375
|
+
title=module.title,
|
|
376
|
+
description=module.description,
|
|
377
|
+
report_title=module.report_title,
|
|
378
|
+
icon=module.icon,
|
|
379
|
+
color=module.color,
|
|
380
|
+
),
|
|
381
|
+
genome_build=config.genome_build,
|
|
382
|
+
curator=config.defaults.curator,
|
|
383
|
+
method=config.defaults.method,
|
|
384
|
+
stats=Stats(
|
|
385
|
+
variant_count=vstats.get("variant_count", 0),
|
|
386
|
+
weights_rows=weights_rows,
|
|
387
|
+
study_count=vstats.get("study_count", 0),
|
|
388
|
+
gene_count=vstats.get("gene_count", 0),
|
|
389
|
+
genes=vstats.get("genes", []),
|
|
390
|
+
categories=vstats.get("categories", []),
|
|
391
|
+
),
|
|
392
|
+
compilation=Compilation(
|
|
393
|
+
compile_success=True,
|
|
394
|
+
compiled_by=compiled_by,
|
|
395
|
+
compiler_version=_compiler_version(),
|
|
396
|
+
ensembl_reference=ensembl_reference,
|
|
397
|
+
compiled_at=_now_iso(),
|
|
398
|
+
warnings=warnings,
|
|
399
|
+
),
|
|
400
|
+
inputs=file_entries(spec_dir, list(_INPUT_FILES)),
|
|
401
|
+
artifact=build_artifact(output_dir, list(_OUTPUT_FILES)),
|
|
402
|
+
logs=logs,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# ── Parquet builders ───────────────────────────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _build_weights(variants: list[VariantRow], config: ModuleSpecConfig) -> pl.DataFrame:
|
|
410
|
+
"""Build the weights.parquet DataFrame from validated variant rows."""
|
|
411
|
+
defaults = config.defaults
|
|
412
|
+
module_name = config.module.name
|
|
413
|
+
records: list[dict[str, Any]] = []
|
|
414
|
+
for v in variants:
|
|
415
|
+
priority = v.priority if v.priority is not None else defaults.priority
|
|
416
|
+
records.append(
|
|
417
|
+
{
|
|
418
|
+
"rsid": v.rsid,
|
|
419
|
+
"genotype": v.genotype.split("/"),
|
|
420
|
+
"module": module_name,
|
|
421
|
+
"weight": v.weight,
|
|
422
|
+
"state": v.state,
|
|
423
|
+
"priority": priority,
|
|
424
|
+
"conclusion": v.conclusion,
|
|
425
|
+
"curator": v.curator or defaults.curator,
|
|
426
|
+
"method": v.method or defaults.method,
|
|
427
|
+
"chrom": v.chrom,
|
|
428
|
+
"start": v.start,
|
|
429
|
+
"end": v.start,
|
|
430
|
+
"ref": v.ref,
|
|
431
|
+
"alts": v.alts.split(",") if v.alts else None,
|
|
432
|
+
"clinvar": v.clinvar if v.clinvar is not None else False,
|
|
433
|
+
"pathogenic": v.pathogenic if v.pathogenic is not None else False,
|
|
434
|
+
"benign": v.benign if v.benign is not None else False,
|
|
435
|
+
"likely_pathogenic": False,
|
|
436
|
+
"likely_benign": False,
|
|
437
|
+
}
|
|
438
|
+
)
|
|
439
|
+
schema = {
|
|
440
|
+
"rsid": pl.Utf8,
|
|
441
|
+
"genotype": pl.List(pl.Utf8),
|
|
442
|
+
"module": pl.Utf8,
|
|
443
|
+
"weight": pl.Float64,
|
|
444
|
+
"state": pl.Utf8,
|
|
445
|
+
"priority": pl.Utf8,
|
|
446
|
+
"conclusion": pl.Utf8,
|
|
447
|
+
"curator": pl.Utf8,
|
|
448
|
+
"method": pl.Utf8,
|
|
449
|
+
"chrom": pl.Utf8,
|
|
450
|
+
"start": pl.UInt32,
|
|
451
|
+
"end": pl.UInt32,
|
|
452
|
+
"ref": pl.Utf8,
|
|
453
|
+
"alts": pl.List(pl.Utf8),
|
|
454
|
+
"clinvar": pl.Boolean,
|
|
455
|
+
"pathogenic": pl.Boolean,
|
|
456
|
+
"benign": pl.Boolean,
|
|
457
|
+
"likely_pathogenic": pl.Boolean,
|
|
458
|
+
"likely_benign": pl.Boolean,
|
|
459
|
+
}
|
|
460
|
+
return pl.DataFrame(records, schema=schema)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _build_annotations(variants: list[VariantRow], module_name: str) -> pl.DataFrame:
|
|
464
|
+
"""Build annotations.parquet, deduplicated by variant_key (first occurrence wins)."""
|
|
465
|
+
seen_keys: set[str] = set()
|
|
466
|
+
records: list[dict[str, Optional[str]]] = []
|
|
467
|
+
for v in variants:
|
|
468
|
+
key = v.variant_key
|
|
469
|
+
if key not in seen_keys:
|
|
470
|
+
records.append(
|
|
471
|
+
{
|
|
472
|
+
"rsid": v.rsid,
|
|
473
|
+
"module": module_name,
|
|
474
|
+
"gene": v.gene or "",
|
|
475
|
+
"phenotype": v.phenotype or "",
|
|
476
|
+
"category": v.category or "",
|
|
477
|
+
}
|
|
478
|
+
)
|
|
479
|
+
seen_keys.add(key)
|
|
480
|
+
schema = {
|
|
481
|
+
"rsid": pl.Utf8,
|
|
482
|
+
"module": pl.Utf8,
|
|
483
|
+
"gene": pl.Utf8,
|
|
484
|
+
"phenotype": pl.Utf8,
|
|
485
|
+
"category": pl.Utf8,
|
|
486
|
+
}
|
|
487
|
+
return pl.DataFrame(records, schema=schema)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _build_studies(studies: list[StudyRow], module_name: str) -> pl.DataFrame:
|
|
491
|
+
"""Build the studies.parquet DataFrame from validated study rows."""
|
|
492
|
+
records: list[dict[str, Any]] = []
|
|
493
|
+
for s in studies:
|
|
494
|
+
records.append(
|
|
495
|
+
{
|
|
496
|
+
"rsid": s.rsid,
|
|
497
|
+
"module": module_name,
|
|
498
|
+
"pmid": s.pmid,
|
|
499
|
+
"population": s.population,
|
|
500
|
+
"p_value": s.p_value,
|
|
501
|
+
"conclusion": s.conclusion,
|
|
502
|
+
"study_design": s.study_design,
|
|
503
|
+
}
|
|
504
|
+
)
|
|
505
|
+
schema = {
|
|
506
|
+
"rsid": pl.Utf8,
|
|
507
|
+
"module": pl.Utf8,
|
|
508
|
+
"pmid": pl.Utf8,
|
|
509
|
+
"population": pl.Utf8,
|
|
510
|
+
"p_value": pl.Utf8,
|
|
511
|
+
"conclusion": pl.Utf8,
|
|
512
|
+
"study_design": pl.Utf8,
|
|
513
|
+
}
|
|
514
|
+
return pl.DataFrame(records, schema=schema)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
# ── Reverse engineering ────────────────────────────────────────────────────────
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def reverse_module(
|
|
521
|
+
parquet_dir: Path,
|
|
522
|
+
output_dir: Path,
|
|
523
|
+
module_name: Optional[str] = None,
|
|
524
|
+
title: Optional[str] = None,
|
|
525
|
+
description: Optional[str] = None,
|
|
526
|
+
report_title: Optional[str] = None,
|
|
527
|
+
icon: str = "database",
|
|
528
|
+
color: str = "#6435c9",
|
|
529
|
+
) -> Path:
|
|
530
|
+
"""Reverse-engineer a parquet module back into the spec DSL (yaml + csv). Returns output_dir."""
|
|
531
|
+
parquet_dir = Path(parquet_dir)
|
|
532
|
+
output_dir = Path(output_dir)
|
|
533
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
534
|
+
|
|
535
|
+
weights_df = pl.read_parquet(parquet_dir / "weights.parquet")
|
|
536
|
+
if module_name is None:
|
|
537
|
+
if "module" in weights_df.columns:
|
|
538
|
+
module_name = weights_df["module"].drop_nulls().unique().to_list()[0]
|
|
539
|
+
else:
|
|
540
|
+
module_name = parquet_dir.name
|
|
541
|
+
|
|
542
|
+
default_curator = _most_common(weights_df, "curator") or "unknown"
|
|
543
|
+
default_method = _most_common(weights_df, "method") or "unknown"
|
|
544
|
+
default_priority: Optional[str] = None
|
|
545
|
+
if "priority" in weights_df.columns:
|
|
546
|
+
non_null = weights_df["priority"].drop_nulls()
|
|
547
|
+
if non_null.len() > 0:
|
|
548
|
+
default_priority = non_null.mode().to_list()[0]
|
|
549
|
+
|
|
550
|
+
annotations_df: Optional[pl.DataFrame] = None
|
|
551
|
+
ann_path = parquet_dir / "annotations.parquet"
|
|
552
|
+
if ann_path.exists():
|
|
553
|
+
annotations_df = pl.read_parquet(ann_path)
|
|
554
|
+
|
|
555
|
+
defaults_dict: dict[str, Any] = {"curator": default_curator, "method": default_method}
|
|
556
|
+
if default_priority is not None:
|
|
557
|
+
defaults_dict["priority"] = default_priority
|
|
558
|
+
|
|
559
|
+
spec = {
|
|
560
|
+
"schema_version": "1.0",
|
|
561
|
+
"module": {
|
|
562
|
+
"name": module_name,
|
|
563
|
+
"title": title or module_name.replace("_", " ").title(),
|
|
564
|
+
"description": description or f"Annotation module: {module_name}",
|
|
565
|
+
"report_title": report_title or module_name.replace("_", " ").title(),
|
|
566
|
+
"icon": icon,
|
|
567
|
+
"color": color,
|
|
568
|
+
},
|
|
569
|
+
"defaults": defaults_dict,
|
|
570
|
+
"genome_build": "GRCh38",
|
|
571
|
+
}
|
|
572
|
+
(output_dir / "module_spec.yaml").write_text(
|
|
573
|
+
yaml.dump(spec, default_flow_style=False, sort_keys=False), encoding="utf-8"
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
ann_lookup: dict[str, dict[str, str]] = {}
|
|
577
|
+
if annotations_df is not None:
|
|
578
|
+
for row in annotations_df.iter_rows(named=True):
|
|
579
|
+
ann_lookup[row["rsid"]] = {
|
|
580
|
+
"gene": row.get("gene", ""),
|
|
581
|
+
"phenotype": row.get("phenotype", ""),
|
|
582
|
+
"category": row.get("category", ""),
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
_write_variants_csv(
|
|
586
|
+
weights_df, ann_lookup, default_curator, default_method, default_priority,
|
|
587
|
+
output_dir / "variants.csv",
|
|
588
|
+
)
|
|
589
|
+
studies_path = parquet_dir / "studies.parquet"
|
|
590
|
+
if studies_path.exists():
|
|
591
|
+
_write_studies_csv(pl.read_parquet(studies_path), output_dir / "studies.csv")
|
|
592
|
+
return output_dir
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _most_common(df: pl.DataFrame, col: str) -> Optional[str]:
|
|
596
|
+
"""Return the most common non-null value in a column, or None."""
|
|
597
|
+
if col not in df.columns:
|
|
598
|
+
return None
|
|
599
|
+
non_null = df[col].drop_nulls()
|
|
600
|
+
if non_null.len() == 0:
|
|
601
|
+
return None
|
|
602
|
+
return non_null.mode().to_list()[0]
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _write_variants_csv(
|
|
606
|
+
weights_df: pl.DataFrame,
|
|
607
|
+
ann_lookup: dict[str, dict[str, str]],
|
|
608
|
+
default_curator: str,
|
|
609
|
+
default_method: str,
|
|
610
|
+
default_priority: Optional[str],
|
|
611
|
+
output_path: Path,
|
|
612
|
+
) -> None:
|
|
613
|
+
"""Write variants.csv from weights parquet + annotations lookup."""
|
|
614
|
+
fieldnames = [
|
|
615
|
+
"rsid", "chrom", "start", "ref", "alts", "genotype", "weight", "state", "conclusion",
|
|
616
|
+
"priority", "gene", "phenotype", "category", "clinvar", "pathogenic", "benign",
|
|
617
|
+
"curator", "method",
|
|
618
|
+
]
|
|
619
|
+
with open(output_path, "w", encoding="utf-8", newline="") as f:
|
|
620
|
+
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
|
621
|
+
writer.writeheader()
|
|
622
|
+
for row in weights_df.iter_rows(named=True):
|
|
623
|
+
rsid = row.get("rsid") or ""
|
|
624
|
+
ann = ann_lookup.get(rsid, {})
|
|
625
|
+
genotype_list = row.get("genotype", [])
|
|
626
|
+
alts_list = row.get("alts")
|
|
627
|
+
curator = row.get("curator", "")
|
|
628
|
+
method = row.get("method", "")
|
|
629
|
+
priority = row.get("priority")
|
|
630
|
+
clinvar = row.get("clinvar", False)
|
|
631
|
+
pathogenic = row.get("pathogenic", False)
|
|
632
|
+
benign = row.get("benign", False)
|
|
633
|
+
writer.writerow(
|
|
634
|
+
{
|
|
635
|
+
"rsid": rsid,
|
|
636
|
+
"chrom": row.get("chrom", ""),
|
|
637
|
+
"start": row.get("start", ""),
|
|
638
|
+
"ref": row.get("ref", ""),
|
|
639
|
+
"alts": ",".join(alts_list) if alts_list else "",
|
|
640
|
+
"genotype": "/".join(genotype_list) if genotype_list else "",
|
|
641
|
+
"weight": row.get("weight") if row.get("weight") is not None else "",
|
|
642
|
+
"state": row.get("state", ""),
|
|
643
|
+
"conclusion": row.get("conclusion", ""),
|
|
644
|
+
"priority": priority if priority != default_priority else "",
|
|
645
|
+
"gene": ann.get("gene", ""),
|
|
646
|
+
"phenotype": ann.get("phenotype", ""),
|
|
647
|
+
"category": ann.get("category", ""),
|
|
648
|
+
"clinvar": str(clinvar).lower() if clinvar else "",
|
|
649
|
+
"pathogenic": str(pathogenic).lower() if pathogenic else "",
|
|
650
|
+
"benign": str(benign).lower() if benign else "",
|
|
651
|
+
"curator": curator if curator != default_curator else "",
|
|
652
|
+
"method": method if method != default_method else "",
|
|
653
|
+
}
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _write_studies_csv(studies_df: pl.DataFrame, output_path: Path) -> None:
|
|
658
|
+
"""Write studies.csv from studies parquet."""
|
|
659
|
+
fieldnames = ["rsid", "pmid", "population", "p_value", "conclusion", "study_design"]
|
|
660
|
+
with open(output_path, "w", encoding="utf-8", newline="") as f:
|
|
661
|
+
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
|
662
|
+
writer.writeheader()
|
|
663
|
+
for row in studies_df.iter_rows(named=True):
|
|
664
|
+
pmid = row.get("pmid")
|
|
665
|
+
if pmid is None or str(pmid).strip() == "":
|
|
666
|
+
continue
|
|
667
|
+
writer.writerow(
|
|
668
|
+
{
|
|
669
|
+
"rsid": row["rsid"],
|
|
670
|
+
"pmid": str(pmid).strip(),
|
|
671
|
+
"population": row.get("population") or "",
|
|
672
|
+
"p_value": row.get("p_value") or "",
|
|
673
|
+
"conclusion": row.get("conclusion") or "",
|
|
674
|
+
"study_design": row.get("study_design") or "",
|
|
675
|
+
}
|
|
676
|
+
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Result types returned by the compiler. The compiled *manifest* itself is the
|
|
2
|
+
`just_dna_format.manifest.ModuleManifest` — these wrap validation/compilation outcomes."""
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
from just_dna_format.manifest import ModuleManifest
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ValidationResult(BaseModel):
|
|
12
|
+
"""Result of spec validation."""
|
|
13
|
+
|
|
14
|
+
valid: bool = Field(description="Whether the spec is valid")
|
|
15
|
+
errors: list[str] = Field(default_factory=list, description="Validation errors")
|
|
16
|
+
warnings: list[str] = Field(default_factory=list, description="Non-fatal warnings")
|
|
17
|
+
stats: dict[str, Any] = Field(
|
|
18
|
+
default_factory=dict,
|
|
19
|
+
description="Summary stats: variant_count, gene_count, genes, categories, study_count",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CompilationResult(BaseModel):
|
|
24
|
+
"""Result of spec compilation, including the emitted manifest."""
|
|
25
|
+
|
|
26
|
+
success: bool = Field(description="Whether compilation succeeded")
|
|
27
|
+
output_dir: Optional[Path] = Field(default=None, description="Directory with output parquets")
|
|
28
|
+
errors: list[str] = Field(default_factory=list)
|
|
29
|
+
warnings: list[str] = Field(default_factory=list)
|
|
30
|
+
stats: dict[str, Any] = Field(default_factory=dict)
|
|
31
|
+
manifest: Optional[ModuleManifest] = Field(
|
|
32
|
+
default=None, description="The manifest written next to the parquets (None on failure)"
|
|
33
|
+
)
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bidirectional rsid <-> position resolver using an Ensembl DuckDB (GRCh38).
|
|
3
|
+
|
|
4
|
+
Unlike the original pipelines resolver, this standalone version **injects** the Ensembl
|
|
5
|
+
reference: the caller passes either a prebuilt `.duckdb` file or a directory of Ensembl parquet
|
|
6
|
+
files, and the resolver builds an ephemeral `ensembl_variations` view over it. It never reaches
|
|
7
|
+
into `just-dna-pipelines` and never downloads anything — provisioning the reference is the
|
|
8
|
+
caller's responsibility (the marketplace pins one reference for the whole ecosystem).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
import duckdb
|
|
16
|
+
|
|
17
|
+
from just_dna_format.spec import VariantRow
|
|
18
|
+
|
|
19
|
+
from just_dna_compiler.cache import DUCKDB_NAME, resolve_ensembl_reference
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class EnsemblReferenceError(FileNotFoundError):
|
|
25
|
+
"""Raised when a provided Ensembl reference is neither a usable .duckdb nor a parquet dir."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _has_ensembl_table(con: duckdb.DuckDBPyConnection) -> bool:
|
|
29
|
+
return any(r[0] == "ensembl_variations" for r in con.execute("SHOW TABLES").fetchall())
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _view_over_parquet(reference: Path) -> Optional[duckdb.DuckDBPyConnection]:
|
|
33
|
+
"""Build an in-memory `ensembl_variations` view over the cache's parquet files, or None."""
|
|
34
|
+
parquet_glob: Optional[Path] = None
|
|
35
|
+
if (reference / "data").is_dir() and any((reference / "data").glob("*.parquet")):
|
|
36
|
+
parquet_glob = reference / "data"
|
|
37
|
+
elif reference.is_dir() and any(reference.glob("*.parquet")):
|
|
38
|
+
parquet_glob = reference
|
|
39
|
+
if parquet_glob is None:
|
|
40
|
+
return None
|
|
41
|
+
# DuckDB can't bind a parameter inside CREATE VIEW ... read_parquet(). The pattern is a local
|
|
42
|
+
# path from our own cache resolution, not user input; single-quote-escape it defensively.
|
|
43
|
+
pattern = f"{parquet_glob}/*.parquet".replace("'", "''")
|
|
44
|
+
con = duckdb.connect(":memory:")
|
|
45
|
+
con.execute(f"CREATE VIEW ensembl_variations AS SELECT * FROM read_parquet('{pattern}')")
|
|
46
|
+
return con
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _connect(reference: Path) -> duckdb.DuckDBPyConnection:
|
|
50
|
+
"""Open a DuckDB connection exposing an `ensembl_variations` relation.
|
|
51
|
+
|
|
52
|
+
`reference` (from `resolve_ensembl_reference`) is a `.duckdb` file or a cache directory. A
|
|
53
|
+
prebuilt DuckDB is used only if it actually contains the `ensembl_variations` table (a stale/
|
|
54
|
+
empty db, as just-dna-lite may leave behind, is ignored in favor of the parquet `data/`).
|
|
55
|
+
"""
|
|
56
|
+
reference = Path(reference)
|
|
57
|
+
|
|
58
|
+
if reference.is_file() and reference.suffix == ".duckdb":
|
|
59
|
+
con = duckdb.connect(str(reference), read_only=True)
|
|
60
|
+
if _has_ensembl_table(con):
|
|
61
|
+
return con
|
|
62
|
+
con.close()
|
|
63
|
+
raise EnsemblReferenceError(f"{reference} has no ensembl_variations table")
|
|
64
|
+
|
|
65
|
+
db = reference / DUCKDB_NAME
|
|
66
|
+
if db.is_file():
|
|
67
|
+
con = duckdb.connect(str(db), read_only=True)
|
|
68
|
+
if _has_ensembl_table(con):
|
|
69
|
+
return con
|
|
70
|
+
con.close() # empty/stale prebuilt db — fall back to parquet
|
|
71
|
+
|
|
72
|
+
con = _view_over_parquet(reference)
|
|
73
|
+
if con is None:
|
|
74
|
+
raise EnsemblReferenceError(f"no usable Ensembl .duckdb or parquet files at {reference}")
|
|
75
|
+
return con
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def resolve_variants(
|
|
79
|
+
variants: list[VariantRow],
|
|
80
|
+
ensembl_cache: Optional[Path] = None,
|
|
81
|
+
) -> tuple[list[VariantRow], list[str]]:
|
|
82
|
+
"""Fill in missing rsid or position from the injected Ensembl reference (GRCh38).
|
|
83
|
+
|
|
84
|
+
Variants that already carry both identifiers are returned unchanged. If no reference is
|
|
85
|
+
available, resolution is skipped with a warning rather than raising.
|
|
86
|
+
"""
|
|
87
|
+
need_pos = [v for v in variants if v.rsid is not None and v.chrom is None]
|
|
88
|
+
need_rsid = [v for v in variants if v.rsid is None and v.chrom is not None]
|
|
89
|
+
if not need_pos and not need_rsid:
|
|
90
|
+
return variants, []
|
|
91
|
+
|
|
92
|
+
reference = resolve_ensembl_reference(ensembl_cache)
|
|
93
|
+
if reference is None:
|
|
94
|
+
msg = (
|
|
95
|
+
"Ensembl resolution skipped: no reference cache found "
|
|
96
|
+
"(set JUST_DNA_PIPELINES_CACHE_DIR or JUST_DNA_ENSEMBL_CACHE, or pass ensembl_cache)"
|
|
97
|
+
)
|
|
98
|
+
logger.warning(msg)
|
|
99
|
+
return variants, [msg]
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
con = _connect(reference)
|
|
103
|
+
except EnsemblReferenceError as exc:
|
|
104
|
+
msg = f"Ensembl resolution skipped: {exc}"
|
|
105
|
+
logger.warning(msg)
|
|
106
|
+
return variants, [msg]
|
|
107
|
+
|
|
108
|
+
warnings: list[str] = []
|
|
109
|
+
rsid_to_pos: dict[str, dict] = {}
|
|
110
|
+
if need_pos:
|
|
111
|
+
unique_rsids = list({v.rsid for v in need_pos if v.rsid is not None})
|
|
112
|
+
rsid_to_pos = _lookup_positions_by_rsid(con, unique_rsids, warnings)
|
|
113
|
+
pos_to_rsid: dict[str, str] = {}
|
|
114
|
+
if need_rsid:
|
|
115
|
+
unique_positions = list(
|
|
116
|
+
{(v.chrom, v.start, v.ref) for v in need_rsid if v.chrom is not None and v.start is not None}
|
|
117
|
+
)
|
|
118
|
+
pos_to_rsid = _lookup_rsids_by_position(con, unique_positions, warnings)
|
|
119
|
+
con.close()
|
|
120
|
+
|
|
121
|
+
patched: list[VariantRow] = []
|
|
122
|
+
for v in variants:
|
|
123
|
+
if v.rsid is not None and v.chrom is None and v.rsid in rsid_to_pos:
|
|
124
|
+
patched.append(v.model_copy(update=rsid_to_pos[v.rsid]))
|
|
125
|
+
elif v.rsid is None and v.chrom is not None:
|
|
126
|
+
key = f"{v.chrom}:{v.start}:{v.ref}"
|
|
127
|
+
if key in pos_to_rsid:
|
|
128
|
+
patched.append(v.model_copy(update={"rsid": pos_to_rsid[key]}))
|
|
129
|
+
else:
|
|
130
|
+
warnings.append(f"Position {key}: no rsid found in Ensembl")
|
|
131
|
+
patched.append(v)
|
|
132
|
+
else:
|
|
133
|
+
patched.append(v)
|
|
134
|
+
return patched, warnings
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _lookup_positions_by_rsid(
|
|
138
|
+
con: duckdb.DuckDBPyConnection, rsids: list[str], warnings: list[str]
|
|
139
|
+
) -> dict[str, dict]:
|
|
140
|
+
"""Batch lookup: rsid -> {chrom, start, ref, alts}."""
|
|
141
|
+
if not rsids:
|
|
142
|
+
return {}
|
|
143
|
+
placeholders = ", ".join("?" for _ in rsids)
|
|
144
|
+
rows = con.execute(
|
|
145
|
+
f"""
|
|
146
|
+
SELECT id, chrom, start, ref, string_agg(DISTINCT alt, ',' ORDER BY alt) AS alts
|
|
147
|
+
FROM ensembl_variations
|
|
148
|
+
WHERE id IN ({placeholders})
|
|
149
|
+
GROUP BY id, chrom, start, ref
|
|
150
|
+
""",
|
|
151
|
+
rsids,
|
|
152
|
+
).fetchall()
|
|
153
|
+
result: dict[str, dict] = {}
|
|
154
|
+
for row_id, chrom, start, ref, alts in rows:
|
|
155
|
+
if row_id in result:
|
|
156
|
+
continue
|
|
157
|
+
result[row_id] = {"chrom": str(chrom), "start": int(start), "ref": str(ref), "alts": str(alts)}
|
|
158
|
+
for rsid in rsids:
|
|
159
|
+
if rsid not in result:
|
|
160
|
+
warnings.append(f"{rsid}: not found in Ensembl, position remains unset")
|
|
161
|
+
return result
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _lookup_rsids_by_position(
|
|
165
|
+
con: duckdb.DuckDBPyConnection,
|
|
166
|
+
positions: list[tuple[Optional[str], Optional[int], Optional[str]]],
|
|
167
|
+
warnings: list[str],
|
|
168
|
+
) -> dict[str, str]:
|
|
169
|
+
"""Batch lookup: (chrom, start, ref) -> rsid."""
|
|
170
|
+
if not positions:
|
|
171
|
+
return {}
|
|
172
|
+
conditions = []
|
|
173
|
+
params: list[object] = []
|
|
174
|
+
for chrom, start, ref in positions:
|
|
175
|
+
if ref is not None:
|
|
176
|
+
conditions.append("(chrom = ? AND start = ? AND ref = ?)")
|
|
177
|
+
params.extend([chrom, start, ref])
|
|
178
|
+
else:
|
|
179
|
+
conditions.append("(chrom = ? AND start = ?)")
|
|
180
|
+
params.extend([chrom, start])
|
|
181
|
+
where = " OR ".join(conditions)
|
|
182
|
+
rows = con.execute(
|
|
183
|
+
f"SELECT DISTINCT chrom, start, ref, id FROM ensembl_variations "
|
|
184
|
+
f"WHERE ({where}) AND id LIKE 'rs%'",
|
|
185
|
+
params,
|
|
186
|
+
).fetchall()
|
|
187
|
+
result: dict[str, str] = {}
|
|
188
|
+
for chrom, start, ref, row_id in rows:
|
|
189
|
+
key = f"{chrom}:{start}:{ref}"
|
|
190
|
+
if key not in result:
|
|
191
|
+
result[key] = str(row_id)
|
|
192
|
+
return result
|