aver-core 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.
- aver_core-0.1.0/PKG-INFO +7 -0
- aver_core-0.1.0/pyproject.toml +11 -0
- aver_core-0.1.0/pyproject.toml.orig +11 -0
- aver_core-0.1.0/src/aver_core/__init__.py +28 -0
- aver_core-0.1.0/src/aver_core/canonical.py +138 -0
- aver_core-0.1.0/src/aver_core/containers.py +107 -0
- aver_core-0.1.0/src/aver_core/hashing.py +73 -0
- aver_core-0.1.0/src/aver_core/ids.py +19 -0
- aver_core-0.1.0/src/aver_core/models.py +259 -0
- aver_core-0.1.0/src/aver_core/samples.py +86 -0
aver_core-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "aver-core"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Aver domain models, RFC 8785 canonical JSON and hashing (PRD §11). Arrives in M0."
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
license = "Apache-2.0"
|
|
7
|
+
dependencies = ["pydantic>=2.9,<3"]
|
|
8
|
+
|
|
9
|
+
[build-system]
|
|
10
|
+
requires = ["uv_build>=0.8,<0.13"]
|
|
11
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "aver-core"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Aver domain models, RFC 8785 canonical JSON and hashing (PRD §11). Arrives in M0."
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
license = "Apache-2.0"
|
|
7
|
+
dependencies = ["pydantic>=2.9,<3"]
|
|
8
|
+
|
|
9
|
+
[build-system]
|
|
10
|
+
requires = ["uv_build>=0.8,<0.13"]
|
|
11
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Aver core: domain models (PRD §11), RFC 8785 canonical JSON and hashing."""
|
|
2
|
+
|
|
3
|
+
from .canonical import CanonicalizationError, canonical_bytes, canonical_json, parse_json
|
|
4
|
+
from .hashing import (
|
|
5
|
+
GENESIS_HASH,
|
|
6
|
+
audit_entry_hash,
|
|
7
|
+
content_hash,
|
|
8
|
+
run_cache_key,
|
|
9
|
+
sha256_file,
|
|
10
|
+
sha256_hex,
|
|
11
|
+
)
|
|
12
|
+
from .samples import NotAReferenceSampleError, resolve_reference_sample, resolve_somatic_samples
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"GENESIS_HASH",
|
|
16
|
+
"CanonicalizationError",
|
|
17
|
+
"NotAReferenceSampleError",
|
|
18
|
+
"audit_entry_hash",
|
|
19
|
+
"canonical_bytes",
|
|
20
|
+
"canonical_json",
|
|
21
|
+
"content_hash",
|
|
22
|
+
"parse_json",
|
|
23
|
+
"resolve_reference_sample",
|
|
24
|
+
"resolve_somatic_samples",
|
|
25
|
+
"run_cache_key",
|
|
26
|
+
"sha256_file",
|
|
27
|
+
"sha256_hex",
|
|
28
|
+
]
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""RFC 8785 JSON Canonicalization Scheme (JCS).
|
|
2
|
+
|
|
3
|
+
Must agree byte for byte with apps/web/src/lib/hash.ts on the shared vectors in
|
|
4
|
+
tests/golden/jcs (invariant 4). Values JSON cannot represent raise instead of
|
|
5
|
+
being coerced, so a hash never covers silently altered data.
|
|
6
|
+
|
|
7
|
+
Accepted: None, bool, str, int within +/-(2**53 - 1), finite float, list/tuple,
|
|
8
|
+
dict with str keys. Anything else (Decimal, datetime, bytes, sets...) raises:
|
|
9
|
+
convert it explicitly before hashing.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import math
|
|
15
|
+
from collections.abc import Mapping, Sequence
|
|
16
|
+
from decimal import Decimal
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
MAX_SAFE_INTEGER = 2**53 - 1
|
|
20
|
+
|
|
21
|
+
_ESCAPES = {'"': '\\"', "\\": "\\\\", "\b": "\\b", "\f": "\\f", "\n": "\\n", "\r": "\\r", "\t": "\\t"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CanonicalizationError(TypeError):
|
|
25
|
+
"""Raised for values that have no RFC 8785 representation."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def canonical_json(value: Any) -> str:
|
|
29
|
+
"""Serialise `value` as RFC 8785 canonical JSON text."""
|
|
30
|
+
out: list[str] = []
|
|
31
|
+
_write(value, out)
|
|
32
|
+
return "".join(out)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def canonical_bytes(value: Any) -> bytes:
|
|
36
|
+
"""Canonical JSON encoded as UTF-8: the exact bytes that get hashed."""
|
|
37
|
+
return canonical_json(value).encode("utf-8")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_json(text: str | bytes) -> Any:
|
|
41
|
+
"""Parse JSON with ECMAScript number semantics (every number is a double).
|
|
42
|
+
|
|
43
|
+
Integers within the safe range stay `int` (their canonical text is
|
|
44
|
+
identical); larger integer literals become `float`, as JSON.parse would.
|
|
45
|
+
Use this, not json.loads, when re-canonicalising a document from elsewhere.
|
|
46
|
+
"""
|
|
47
|
+
import json
|
|
48
|
+
|
|
49
|
+
def _int(s: str) -> int | float:
|
|
50
|
+
i = int(s)
|
|
51
|
+
return i if abs(i) <= MAX_SAFE_INTEGER else float(s)
|
|
52
|
+
|
|
53
|
+
return json.loads(text, parse_int=_int)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _write(value: Any, out: list[str]) -> None:
|
|
57
|
+
if value is None:
|
|
58
|
+
out.append("null")
|
|
59
|
+
elif value is True:
|
|
60
|
+
out.append("true")
|
|
61
|
+
elif value is False:
|
|
62
|
+
out.append("false")
|
|
63
|
+
elif isinstance(value, str):
|
|
64
|
+
out.append(_string(value))
|
|
65
|
+
elif isinstance(value, int):
|
|
66
|
+
if abs(value) > MAX_SAFE_INTEGER:
|
|
67
|
+
raise CanonicalizationError(f"integer {value} exceeds IEEE 754 safe range")
|
|
68
|
+
out.append(str(value))
|
|
69
|
+
elif isinstance(value, float):
|
|
70
|
+
out.append(_number(value))
|
|
71
|
+
elif isinstance(value, Mapping):
|
|
72
|
+
items = []
|
|
73
|
+
for k, v in value.items():
|
|
74
|
+
if not isinstance(k, str):
|
|
75
|
+
raise CanonicalizationError(f"object key {k!r} is not a string")
|
|
76
|
+
items.append((k, v))
|
|
77
|
+
# RFC 8785 §3.2.3: sort by UTF-16 code units (UTF-16BE bytes sort identically).
|
|
78
|
+
items.sort(key=lambda kv: kv[0].encode("utf-16-be", "surrogatepass"))
|
|
79
|
+
out.append("{")
|
|
80
|
+
for i, (k, v) in enumerate(items):
|
|
81
|
+
if i:
|
|
82
|
+
out.append(",")
|
|
83
|
+
out.append(_string(k))
|
|
84
|
+
out.append(":")
|
|
85
|
+
_write(v, out)
|
|
86
|
+
out.append("}")
|
|
87
|
+
elif isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray)):
|
|
88
|
+
out.append("[")
|
|
89
|
+
for i, v in enumerate(value):
|
|
90
|
+
if i:
|
|
91
|
+
out.append(",")
|
|
92
|
+
_write(v, out)
|
|
93
|
+
out.append("]")
|
|
94
|
+
else:
|
|
95
|
+
hint = " (convert Decimal explicitly)" if isinstance(value, Decimal) else ""
|
|
96
|
+
raise CanonicalizationError(f"{type(value).__name__} is not representable in JSON{hint}")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _string(s: str) -> str:
|
|
100
|
+
"""JSON string as ECMAScript JSON.stringify writes it (well-formed variant)."""
|
|
101
|
+
parts = ['"']
|
|
102
|
+
for ch in s:
|
|
103
|
+
esc = _ESCAPES.get(ch)
|
|
104
|
+
if esc is not None:
|
|
105
|
+
parts.append(esc)
|
|
106
|
+
elif ch < " " or 0xD800 <= ord(ch) <= 0xDFFF:
|
|
107
|
+
parts.append(f"\\u{ord(ch):04x}") # controls and lone surrogates
|
|
108
|
+
else:
|
|
109
|
+
parts.append(ch)
|
|
110
|
+
parts.append('"')
|
|
111
|
+
return "".join(parts)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _number(x: float) -> str:
|
|
115
|
+
"""ECMAScript Number::toString for a finite double (RFC 8785 §3.2.2.3)."""
|
|
116
|
+
if not math.isfinite(x):
|
|
117
|
+
raise CanonicalizationError(f"{x} is not representable in JSON")
|
|
118
|
+
if x == 0:
|
|
119
|
+
return "0" # also -0
|
|
120
|
+
sign = "-" if x < 0 else ""
|
|
121
|
+
# repr() yields the shortest digit string that round-trips, as ECMAScript requires.
|
|
122
|
+
d = Decimal(repr(abs(x)))
|
|
123
|
+
t = d.as_tuple()
|
|
124
|
+
digits = "".join(map(str, t.digits)).rstrip("0") or "0"
|
|
125
|
+
exponent = int(t.exponent) # always an int for a finite value
|
|
126
|
+
k = len(digits)
|
|
127
|
+
n = exponent + len(t.digits) # value = 0.digits * 10**n
|
|
128
|
+
if k <= n <= 21:
|
|
129
|
+
body = digits + "0" * (n - k)
|
|
130
|
+
elif 0 < n <= 21:
|
|
131
|
+
body = digits[:n] + "." + digits[n:]
|
|
132
|
+
elif -6 < n <= 0:
|
|
133
|
+
body = "0." + "0" * (-n) + digits
|
|
134
|
+
else:
|
|
135
|
+
e = n - 1
|
|
136
|
+
exp = f"e{'+' if e >= 0 else '-'}{abs(e)}"
|
|
137
|
+
body = (digits if k == 1 else digits[0] + "." + digits[1:]) + exp
|
|
138
|
+
return sign + body
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Pinned container images and a minimal Docker runner (ADR-005, invariant 5).
|
|
2
|
+
|
|
3
|
+
Every image is referenced by digest. Tags are kept only as a human-readable
|
|
4
|
+
label; the digest is what runs and what is recorded on each result. Changing a
|
|
5
|
+
digest is a reviewed change: a comparator change is itself a drift source
|
|
6
|
+
(PRD §13.5).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import shlex
|
|
13
|
+
import subprocess
|
|
14
|
+
from collections.abc import Mapping, Sequence
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Image:
|
|
21
|
+
name: str
|
|
22
|
+
tag: str
|
|
23
|
+
digest: str # sha256:...
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def ref(self) -> str:
|
|
27
|
+
return f"{self.name}@{self.digest}"
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def label(self) -> str:
|
|
31
|
+
return f"{self.name}:{self.tag}@{self.digest}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Digests resolved 2026-09-24 from the registries (docker pull + RepoDigests).
|
|
35
|
+
IMAGES: dict[str, Image] = {
|
|
36
|
+
"happy": Image(
|
|
37
|
+
"jmcdani20/hap.py", "v0.3.12", "sha256:0812d37e7210011e407914deb3da2094ec8258077b21d3211e694e5ca303b489"
|
|
38
|
+
),
|
|
39
|
+
"samtools": Image(
|
|
40
|
+
"quay.io/biocontainers/samtools",
|
|
41
|
+
"1.24--h9dcdb79_1",
|
|
42
|
+
"sha256:a130447589651ed09252aa95a5e4f4132942cdb54d835d81a04a9a930d656561",
|
|
43
|
+
),
|
|
44
|
+
"bcftools": Image(
|
|
45
|
+
"quay.io/biocontainers/bcftools",
|
|
46
|
+
"1.24--h487d631_1",
|
|
47
|
+
"sha256:08f357e20dcf8ca75c06027f66b181f4fd88019fea7959b69047ad1b12b08a0c",
|
|
48
|
+
),
|
|
49
|
+
# Executes Snakemake pipelines (ADR-012). The tag matches SNAKEMAKE_VERSION in
|
|
50
|
+
# packages/resolver, so the engine that runs is the engine the snapshot records.
|
|
51
|
+
"snakemake": Image(
|
|
52
|
+
"snakemake/snakemake", "v9.27.0", "sha256:488d0de0ad0f471e85df3fe80e4ada9686d4492d14fae4ebc8102f7ba6e4f5f1"
|
|
53
|
+
),
|
|
54
|
+
# SV benchmarking (M8.3): the tool the GIAB SV v0.6 README recommends.
|
|
55
|
+
# Digest resolved 2026-10-13 (docker pull + RepoDigests).
|
|
56
|
+
"truvari": Image(
|
|
57
|
+
"quay.io/biocontainers/truvari",
|
|
58
|
+
"4.2.2--pyhdfd78af_0",
|
|
59
|
+
"sha256:4f99c6d8738549467a8caf0b89df019f10d73fbd30cb9b89661b9cf82a322313",
|
|
60
|
+
),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ContainerError(RuntimeError):
|
|
65
|
+
def __init__(self, cmd: Sequence[str], code: int, tail: str) -> None:
|
|
66
|
+
super().__init__(f"container exited {code}: {shlex.join(cmd)}\n{tail}")
|
|
67
|
+
self.code = code
|
|
68
|
+
self.tail = tail
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def docker_run(
|
|
72
|
+
image: Image,
|
|
73
|
+
args: Sequence[str],
|
|
74
|
+
*,
|
|
75
|
+
mounts: Mapping[str | Path, str],
|
|
76
|
+
workdir: str = "/tmp",
|
|
77
|
+
network: bool = False,
|
|
78
|
+
env: Mapping[str, str] | None = None,
|
|
79
|
+
stdout: Path | None = None,
|
|
80
|
+
) -> str:
|
|
81
|
+
"""Run `args` in `image` (by digest) as the calling user.
|
|
82
|
+
|
|
83
|
+
Returns captured stdout unless `stdout` is given, in which case output is
|
|
84
|
+
written there. No network unless asked: reference data is fetched and
|
|
85
|
+
verified by Aver, never by the tool.
|
|
86
|
+
"""
|
|
87
|
+
cmd = ["docker", "run", "--rm", "-e", "HOME=/tmp", "-w", workdir]
|
|
88
|
+
if hasattr(os, "getuid"):
|
|
89
|
+
cmd += ["--user", f"{os.getuid()}:{os.getgid()}"]
|
|
90
|
+
if not network:
|
|
91
|
+
cmd += ["--network=none"]
|
|
92
|
+
for k, v in (env or {}).items():
|
|
93
|
+
cmd += ["-e", f"{k}={v}"]
|
|
94
|
+
for host, target in mounts.items():
|
|
95
|
+
cmd += ["-v", f"{Path(host).resolve()}:{target}"]
|
|
96
|
+
cmd += [image.ref, *args]
|
|
97
|
+
if stdout is not None:
|
|
98
|
+
with open(stdout, "wb") as f:
|
|
99
|
+
p = subprocess.run(cmd, stdout=f, stderr=subprocess.PIPE, check=False) # noqa: S603
|
|
100
|
+
out = ""
|
|
101
|
+
else:
|
|
102
|
+
p = subprocess.run(cmd, capture_output=True, check=False) # noqa: S603
|
|
103
|
+
out = p.stdout.decode("utf-8", "replace")
|
|
104
|
+
if p.returncode != 0:
|
|
105
|
+
tail = "\n".join(p.stderr.decode("utf-8", "replace").splitlines()[-40:])
|
|
106
|
+
raise ContainerError(cmd, p.returncode, tail)
|
|
107
|
+
return out
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Content hashes used across Aver (PRD §11, §13.2, §15.1; invariant 4).
|
|
2
|
+
|
|
3
|
+
All hashes are lowercase hex SHA-256. Anything structured is hashed over its
|
|
4
|
+
RFC 8785 canonical JSON, never over ad hoc string concatenation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .canonical import canonical_bytes, canonical_json
|
|
14
|
+
|
|
15
|
+
GENESIS_HASH = "0" * 64
|
|
16
|
+
"""prev_hash of the first audit entry (matches apps/web)."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def sha256_hex(data: bytes | str) -> str:
|
|
20
|
+
if isinstance(data, str):
|
|
21
|
+
data = data.encode("utf-8")
|
|
22
|
+
return hashlib.sha256(data).hexdigest()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def sha256_file(path: str | Path, chunk: int = 1 << 20) -> str:
|
|
26
|
+
h = hashlib.sha256()
|
|
27
|
+
with open(path, "rb") as f:
|
|
28
|
+
while block := f.read(chunk):
|
|
29
|
+
h.update(block)
|
|
30
|
+
return h.hexdigest()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def content_hash(value: Any) -> str:
|
|
34
|
+
"""sha256 of the canonical JSON of `value`."""
|
|
35
|
+
return sha256_hex(canonical_bytes(value))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def audit_entry_hash(prev_hash: str, entry_without_hashes: dict[str, Any]) -> str:
|
|
39
|
+
"""F-60 / §11.8: sha256(prev_hash || canonical_json(entry_without_hashes))."""
|
|
40
|
+
if "prev_hash" in entry_without_hashes or "entry_hash" in entry_without_hashes:
|
|
41
|
+
raise ValueError("entry must not contain its own hash fields")
|
|
42
|
+
return sha256_hex(prev_hash + canonical_json(entry_without_hashes))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def run_cache_key(
|
|
46
|
+
*,
|
|
47
|
+
snapshot_hash: str,
|
|
48
|
+
truth_set_id: str,
|
|
49
|
+
target_bed_sha256: str | None,
|
|
50
|
+
comparator_version: str,
|
|
51
|
+
reference_genome_sha256: str,
|
|
52
|
+
region: str | None = None,
|
|
53
|
+
stratifications: tuple[str, str, tuple[str, ...]] | None = None,
|
|
54
|
+
) -> str:
|
|
55
|
+
"""PRD §13.2 run cache key.
|
|
56
|
+
|
|
57
|
+
Same inputs as the PRD formula, hashed as canonical JSON with named fields
|
|
58
|
+
instead of bare concatenation, which is ambiguous at field boundaries.
|
|
59
|
+
`region` (for example 'chr20') is included because a subset run is a
|
|
60
|
+
different measurement. `stratifications` likewise, and only when requested (F-32).
|
|
61
|
+
"""
|
|
62
|
+
fields: dict[str, object] = {
|
|
63
|
+
"snapshot_hash": snapshot_hash,
|
|
64
|
+
"truth_set_id": truth_set_id,
|
|
65
|
+
"target_bed_sha256": target_bed_sha256,
|
|
66
|
+
"comparator_version": comparator_version,
|
|
67
|
+
"reference_genome_sha256": reference_genome_sha256,
|
|
68
|
+
"region": region,
|
|
69
|
+
}
|
|
70
|
+
if stratifications: # (set id, set release, names): absent when unused, so earlier keys stay valid
|
|
71
|
+
set_id, release, names = stratifications
|
|
72
|
+
fields["stratifications"] = {"set": set_id, "release": release, "names": list(names)}
|
|
73
|
+
return content_hash(fields)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""UUIDv7 primary keys (PRD §11: time-ordered locality). RFC 9562 §5.7."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def uuid7(ms: int | None = None) -> uuid.UUID:
|
|
11
|
+
"""48-bit Unix milliseconds, version 7, variant 10, 74 random bits."""
|
|
12
|
+
ts = int(time.time() * 1000) if ms is None else ms
|
|
13
|
+
rand = int.from_bytes(os.urandom(10), "big")
|
|
14
|
+
value = (ts & ((1 << 48) - 1)) << 80
|
|
15
|
+
value |= 0x7 << 76
|
|
16
|
+
value |= ((rand >> 62) & 0xFFF) << 64
|
|
17
|
+
value |= 0b10 << 62
|
|
18
|
+
value |= rand & ((1 << 62) - 1)
|
|
19
|
+
return uuid.UUID(int=value)
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Domain models mirroring PRD §11 (ADR-002 applied to identity).
|
|
2
|
+
|
|
3
|
+
INVARIANT 1: there is no field anywhere for a patient identifier, clinical
|
|
4
|
+
sample name or clinical annotation. `extra="forbid"` means unexpected fields
|
|
5
|
+
are rejected rather than carried along. Do not add such a field.
|
|
6
|
+
|
|
7
|
+
These are transport/domain models shared by the CLI, API and workers. The
|
|
8
|
+
SQLAlchemy tables (M2) map onto them.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from enum import StrEnum
|
|
15
|
+
from typing import Annotated, Any, Literal
|
|
16
|
+
from uuid import UUID
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel, ConfigDict, Field, StringConstraints
|
|
19
|
+
|
|
20
|
+
Sha256 = Annotated[str, StringConstraints(pattern=r"^[0-9a-f]{64}$")]
|
|
21
|
+
OciDigest = Annotated[str, StringConstraints(pattern=r"^sha256:[0-9a-f]{64}$")]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Model(BaseModel):
|
|
25
|
+
model_config = ConfigDict(extra="forbid", frozen=True, use_enum_values=True)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ───────────────────────────── Enums (§11) ─────────────────────────────
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class OrgRole(StrEnum):
|
|
32
|
+
owner = "owner"
|
|
33
|
+
admin = "admin"
|
|
34
|
+
bioinformatician = "bioinformatician"
|
|
35
|
+
approver = "approver"
|
|
36
|
+
viewer = "viewer"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class WorkflowEngine(StrEnum):
|
|
40
|
+
nextflow = "nextflow"
|
|
41
|
+
snakemake = "snakemake"
|
|
42
|
+
wdl = "wdl"
|
|
43
|
+
manual = "manual"
|
|
44
|
+
dragen = "dragen" # vendor configuration snapshotted from the replay JSON (ADR-013)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AssayType(StrEnum):
|
|
48
|
+
wgs = "wgs"
|
|
49
|
+
wes = "wes"
|
|
50
|
+
panel = "panel"
|
|
51
|
+
other = "other"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class VariantDomain(StrEnum):
|
|
55
|
+
germline_small = "germline_small"
|
|
56
|
+
somatic_small = "somatic_small"
|
|
57
|
+
sv = "sv"
|
|
58
|
+
cnv = "cnv"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class DependencyKind(StrEnum):
|
|
62
|
+
container = "container"
|
|
63
|
+
tool = "tool"
|
|
64
|
+
reference_genome = "reference_genome"
|
|
65
|
+
annotation_db = "annotation_db"
|
|
66
|
+
parameter = "parameter"
|
|
67
|
+
conda_env = "conda_env"
|
|
68
|
+
engine = "engine"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class RunStatus(StrEnum):
|
|
72
|
+
queued = "queued"
|
|
73
|
+
preparing = "preparing"
|
|
74
|
+
running = "running"
|
|
75
|
+
comparing = "comparing"
|
|
76
|
+
summarising = "summarising"
|
|
77
|
+
succeeded = "succeeded"
|
|
78
|
+
failed = "failed"
|
|
79
|
+
cancelled = "cancelled"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class RunTrigger(StrEnum):
|
|
83
|
+
manual = "manual"
|
|
84
|
+
commit = "commit"
|
|
85
|
+
digest_change = "digest_change"
|
|
86
|
+
refdata_change = "refdata_change"
|
|
87
|
+
scheduled = "scheduled"
|
|
88
|
+
api = "api"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ExecBackend(StrEnum):
|
|
92
|
+
lab_runner = "lab_runner"
|
|
93
|
+
hosted = "hosted"
|
|
94
|
+
cli = "cli"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class VariantClass(StrEnum):
|
|
98
|
+
SNP = "SNP"
|
|
99
|
+
INDEL = "INDEL"
|
|
100
|
+
ALL = "ALL"
|
|
101
|
+
SV = "SV" # structural variants ≥50bp, benchmarked with truvari (M8.3)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class DriftSeverity(StrEnum):
|
|
105
|
+
informational = "informational"
|
|
106
|
+
documentation = "documentation"
|
|
107
|
+
review_required = "review_required"
|
|
108
|
+
revalidation_required = "revalidation_required"
|
|
109
|
+
critical = "critical"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
SEVERITY_RANK: dict[str, int] = {s.value: i for i, s in enumerate(DriftSeverity)}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class DriftDisposition(StrEnum):
|
|
116
|
+
open = "open"
|
|
117
|
+
acknowledged = "acknowledged"
|
|
118
|
+
revalidated = "revalidated"
|
|
119
|
+
waived = "waived"
|
|
120
|
+
superseded = "superseded"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class EvidenceStatus(StrEnum):
|
|
124
|
+
generating = "generating"
|
|
125
|
+
ready = "ready"
|
|
126
|
+
signed = "signed"
|
|
127
|
+
superseded = "superseded"
|
|
128
|
+
failed = "failed"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ───────────────────────────── Reference data (§11.3, global) ─────────────────────────────
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class ReferenceMaterial(Model):
|
|
135
|
+
code: str # 'HG002'
|
|
136
|
+
common_name: str
|
|
137
|
+
provider: Literal["GIAB", "SEQC2"]
|
|
138
|
+
aliases: tuple[str, ...] = () # accepted VCF sample names, e.g. 'NA24385'
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class TruthSet(Model):
|
|
142
|
+
id: str # stable catalogue id, e.g. 'giab-hg002-v4.2.1-grch38'
|
|
143
|
+
reference_material: str
|
|
144
|
+
release_version: str
|
|
145
|
+
genome_build: Literal["GRCh38", "GRCh37", "CHM13v2.0"]
|
|
146
|
+
status: Literal["published", "draft"]
|
|
147
|
+
vcf_uri: str
|
|
148
|
+
vcf_sha256: Sha256 | None
|
|
149
|
+
confident_bed_uri: str
|
|
150
|
+
confident_bed_sha256: Sha256 | None
|
|
151
|
+
upstream_md5: dict[str, str] = Field(default_factory=dict)
|
|
152
|
+
published: str | None = None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class ReferenceGenome(Model):
|
|
156
|
+
id: str
|
|
157
|
+
build: str
|
|
158
|
+
variant_label: str # 'no_alt_analysis_set'
|
|
159
|
+
fasta_uri: str
|
|
160
|
+
fasta_sha256: Sha256 | None
|
|
161
|
+
upstream_md5: dict[str, str] = Field(default_factory=dict)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ───────────────────────────── Snapshots (§11.2) ─────────────────────────────
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class Dependency(Model):
|
|
168
|
+
kind: DependencyKind
|
|
169
|
+
name: str
|
|
170
|
+
version: str | None = None
|
|
171
|
+
digest: str | None = None
|
|
172
|
+
source_uri: str | None = None
|
|
173
|
+
process_names: tuple[str, ...] = ()
|
|
174
|
+
is_pinned: bool = True
|
|
175
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class WorkflowInfo(Model):
|
|
179
|
+
engine: WorkflowEngine
|
|
180
|
+
engine_version: str | None
|
|
181
|
+
repo_url: str | None
|
|
182
|
+
commit_sha: str | None
|
|
183
|
+
ref_name: str | None
|
|
184
|
+
entrypoint: str | None = None
|
|
185
|
+
profile: str | None = None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class DependencySnapshot(Model):
|
|
189
|
+
"""F-20. The hashed content excludes volatile fields (resolved_at)."""
|
|
190
|
+
|
|
191
|
+
workflow: WorkflowInfo
|
|
192
|
+
dependencies: tuple[Dependency, ...]
|
|
193
|
+
resolved_config: dict[str, Any]
|
|
194
|
+
resolver_version: str
|
|
195
|
+
snapshot_hash: Sha256
|
|
196
|
+
unpinned_count: int
|
|
197
|
+
resolved_at: datetime
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ───────────────────────────── Benchmarking (§11.4) ─────────────────────────────
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class BenchmarkMetric(Model):
|
|
204
|
+
variant_class: VariantClass
|
|
205
|
+
filter: Literal["PASS", "ALL"]
|
|
206
|
+
stratification: str | None = None # None = whole confident region
|
|
207
|
+
truth_total: int
|
|
208
|
+
truth_tp: int
|
|
209
|
+
truth_fn: int
|
|
210
|
+
query_total: int
|
|
211
|
+
query_fp: int
|
|
212
|
+
recall: float | None
|
|
213
|
+
precision: float | None
|
|
214
|
+
f_measure: float | None
|
|
215
|
+
extra: dict[str, str] = Field(default_factory=dict)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class BenchmarkResult(Model):
|
|
219
|
+
"""What a CLI or Runner reports: metrics and metadata only, never reads."""
|
|
220
|
+
|
|
221
|
+
reference_material: str
|
|
222
|
+
truth_set_id: str
|
|
223
|
+
genome_build: str
|
|
224
|
+
region: str | None
|
|
225
|
+
target_bed_sha256: Sha256 | None
|
|
226
|
+
comparator_image: str
|
|
227
|
+
comparator_version: str
|
|
228
|
+
command: str
|
|
229
|
+
metrics: tuple[BenchmarkMetric, ...]
|
|
230
|
+
snapshot_hash: Sha256 | None
|
|
231
|
+
cache_key: Sha256
|
|
232
|
+
completed_at: str # RFC 3339 UTC, second precision; reports take their dates from here, never the clock
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ───────────────────────────── Drift (§11.5, §14) ─────────────────────────────
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class FiredRule(Model):
|
|
239
|
+
rule_id: str
|
|
240
|
+
source: Literal["class_rule", "semver_rule", "digest_rule", "policy", "integrity"]
|
|
241
|
+
severity: DriftSeverity
|
|
242
|
+
reason: str
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class AuditEntry(Model):
|
|
246
|
+
"""§11.8. entry_hash = sha256(prev_hash || canonical_json(entry without hashes))."""
|
|
247
|
+
|
|
248
|
+
seq: int
|
|
249
|
+
organization_id: UUID | None
|
|
250
|
+
actor: str
|
|
251
|
+
action: str
|
|
252
|
+
entity_type: str
|
|
253
|
+
entity_id: str | None
|
|
254
|
+
before: dict[str, Any] | None
|
|
255
|
+
after: dict[str, Any] | None
|
|
256
|
+
request_id: str | None
|
|
257
|
+
occurred_at: str # RFC 3339 UTC, stored as text for byte-stable hashing
|
|
258
|
+
prev_hash: Sha256
|
|
259
|
+
entry_hash: Sha256
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""The no-patient-data guard (invariant 1, PRD §20.2 validation level).
|
|
2
|
+
|
|
3
|
+
Every VCF that Aver compares or transmits must carry exactly one sample, and
|
|
4
|
+
that sample's name must be a registered public reference material (or one of
|
|
5
|
+
its registered aliases). Anything else aborts with a clear error, before any
|
|
6
|
+
metric is computed.
|
|
7
|
+
|
|
8
|
+
Pipelines that compose the read-group sample from several samplesheet columns
|
|
9
|
+
(nf-core/sarek writes SM as "<patient>_<sample>") are accepted only when every
|
|
10
|
+
underscore-separated part is itself a registered name for the same material,
|
|
11
|
+
e.g. "HG002_HG002" or "HG002_NA24385". Nothing else is ever composed.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Iterable
|
|
17
|
+
|
|
18
|
+
from .models import ReferenceMaterial
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NotAReferenceSampleError(ValueError):
|
|
22
|
+
"""The VCF does not belong to a registered public reference material."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _accepted(material: ReferenceMaterial) -> set[str]:
|
|
26
|
+
return {material.code, *material.aliases}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _matches(name: str, accepted: set[str]) -> bool:
|
|
30
|
+
parts = name.split("_")
|
|
31
|
+
return name in accepted or (len(parts) > 1 and all(p in accepted for p in parts))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def resolve_somatic_samples(
|
|
35
|
+
sample_names: Iterable[str], tumor: ReferenceMaterial, normal: ReferenceMaterial
|
|
36
|
+
) -> tuple[str, ...]:
|
|
37
|
+
"""The somatic form of the guard (M7, ADR-014 §4): one or two samples, every name a
|
|
38
|
+
registered name of the declared tumor/normal pair, with the tumor present.
|
|
39
|
+
|
|
40
|
+
Caller-generic names ("TUMOR"/"NORMAL") are rejected: they cannot prove the VCF is
|
|
41
|
+
reference-material-derived, and proving that is this guard's whole job. The error
|
|
42
|
+
never echoes an unrecognised name — it could itself be an identifier.
|
|
43
|
+
"""
|
|
44
|
+
names = list(sample_names)
|
|
45
|
+
if not 1 <= len(names) <= 2:
|
|
46
|
+
raise NotAReferenceSampleError(
|
|
47
|
+
f"expected a one- or two-sample somatic VCF for {tumor.code}/{normal.code}, found {len(names)} samples"
|
|
48
|
+
)
|
|
49
|
+
if len(set(names)) != len(names):
|
|
50
|
+
raise NotAReferenceSampleError("the VCF names the same sample twice")
|
|
51
|
+
t, n = _accepted(tumor), _accepted(normal)
|
|
52
|
+
accepted = sorted(t | n)
|
|
53
|
+
for name in names:
|
|
54
|
+
if not _matches(name, t) and not _matches(name, n):
|
|
55
|
+
raise NotAReferenceSampleError(
|
|
56
|
+
f"a VCF sample is not a registered name of the {tumor.code}/{normal.code} pair "
|
|
57
|
+
f"(accepted names: {', '.join(accepted)}). Caller-generic names such as TUMOR/NORMAL "
|
|
58
|
+
"are not accepted; rename the samples (bcftools reheader) to the registered names. "
|
|
59
|
+
"Aver only processes public reference materials; the comparison was aborted."
|
|
60
|
+
)
|
|
61
|
+
if not any(_matches(name, t) for name in names):
|
|
62
|
+
raise NotAReferenceSampleError(
|
|
63
|
+
f"the VCF carries no sample named for the tumor material {tumor.code}; a somatic "
|
|
64
|
+
"comparison needs the tumor sample present."
|
|
65
|
+
)
|
|
66
|
+
return tuple(names)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def resolve_reference_sample(sample_names: Iterable[str], expected: ReferenceMaterial) -> str:
|
|
70
|
+
"""Return the matched sample name, or raise NotAReferenceSampleError.
|
|
71
|
+
|
|
72
|
+
The error message never echoes an unrecognised sample name, since that name
|
|
73
|
+
could itself be an identifier.
|
|
74
|
+
"""
|
|
75
|
+
names = list(sample_names)
|
|
76
|
+
if len(names) != 1:
|
|
77
|
+
raise NotAReferenceSampleError(f"expected a single-sample VCF for {expected.code}, found {len(names)} samples")
|
|
78
|
+
accepted = {expected.code, *expected.aliases}
|
|
79
|
+
parts = names[0].split("_")
|
|
80
|
+
if names[0] not in accepted and not (len(parts) > 1 and all(p in accepted for p in parts)):
|
|
81
|
+
raise NotAReferenceSampleError(
|
|
82
|
+
f"VCF sample is not the registered reference material {expected.code} "
|
|
83
|
+
f"(accepted names: {', '.join(sorted(accepted))}). Aver only processes public "
|
|
84
|
+
"reference materials; the comparison was aborted."
|
|
85
|
+
)
|
|
86
|
+
return names[0]
|