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.
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: aver-core
3
+ Version: 0.1.0
4
+ Summary: Aver domain models, RFC 8785 canonical JSON and hashing (PRD §11). Arrives in M0.
5
+ License-Expression: Apache-2.0
6
+ Requires-Dist: pydantic>=2.9,<3
7
+ Requires-Python: >=3.11
@@ -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]