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.
@@ -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