Skip to content

Commit d6a9618

Browse files
Travis Wrightsmantedil
andauthored
feat(grzctl,grz-db): add should-qc as db subcommand (#473)
Returns "true" if the submission of the given ID should be run through the detailed QC pipeline or "false" if not. Resolves #471 feat(grz-pydantic-models): add quarter date calculation functions --------- Co-authored-by: Till Hartmann <till.hartmann@tu-dortmund.de>
1 parent 165a37c commit d6a9618

7 files changed

Lines changed: 409 additions & 22 deletions

File tree

packages/grz-db/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ dependencies = [
1818
"alembic[tz] >=1.16.1",
1919
"cryptography >=45.0.3",
2020
"sqlmodel >=0.0.27",
21-
"grz-pydantic-models >=2.4,<3",
21+
"grz-pydantic-models >=2.5,<3",
2222
"psycopg[binary] ~=3.2"
2323
]
2424

packages/grz-db/src/grz_db/models/submission.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1+
import calendar
12
import datetime
3+
import logging
4+
import math
5+
import random
26
from collections.abc import Generator, Sequence
37
from contextlib import contextmanager
48
from operator import attrgetter
@@ -9,6 +13,7 @@
913
from alembic.config import Config as AlembicConfig
1014
from alembic.runtime.migration import MigrationContext
1115
from alembic.script import ScriptDirectory as AlembicScriptDirectory
16+
from grz_pydantic_models.dates import date_to_quarter_year, quarter_date_bounds
1217
from grz_pydantic_models.submission.metadata import (
1318
CoverageType,
1419
DiseaseType,
@@ -40,6 +45,8 @@
4045
from .author import Author
4146
from .base import BaseSignablePayload, VerifiableLog
4247

48+
logger = logging.getLogger(__name__)
49+
4350

4451
class OutdatedDatabaseSchemaError(Exception):
4552
pass
@@ -737,3 +744,110 @@ def list_change_requests(self) -> Sequence[Submission]:
737744
)
738745
change_requests = session.exec(statement).all()
739746
return change_requests
747+
748+
def should_qc(self, submission_id: str, target_percentage: float, salt: str | None) -> bool:
749+
"""
750+
Determines whether or not a submission should go through detailed QC or not.
751+
"""
752+
target_proportion = target_percentage / 100.0
753+
submission = self.get_submission(submission_id)
754+
755+
if submission is None:
756+
raise SubmissionNotFoundError(submission_id)
757+
submission_date = submission.submission_date
758+
if submission_date is None:
759+
raise ValueError("Submission has no submission date set.")
760+
submission_type = submission.submission_type
761+
if submission_type is None:
762+
raise ValueError("Submission has no type set.")
763+
if submission_type != SubmissionType.initial:
764+
# only initial submissions matter for detailed QC selection
765+
return False
766+
767+
submission_month = submission_date.month
768+
submission_quarter, submission_year = date_to_quarter_year(submission_date)
769+
submission_quarter_start, submission_quarter_end = quarter_date_bounds(
770+
quarter=submission_quarter, year=submission_year
771+
)
772+
_, days_in_submission_month = calendar.monthrange(submission_year, submission_month)
773+
774+
# used instead of a lambda below to type check properly (get_latest_state() can return None)
775+
def latest_state_is_qcing(submission: Submission):
776+
latest_state = submission.get_latest_state()
777+
return latest_state.state == SubmissionStateEnum.QCING if latest_state is not None else False
778+
779+
# yes if none QCed/QCing from submitter yet for the submission month
780+
with self._get_session() as session:
781+
submitter_submissions_month = session.exec(
782+
select(Submission)
783+
.options(selectinload(Submission.states)) # type: ignore[arg-type]
784+
.where(Submission.submission_type == SubmissionType.initial)
785+
.where(Submission.basic_qc_passed) # type: ignore[arg-type]
786+
.where(
787+
Submission.submission_date.between( # type: ignore[union-attr]
788+
datetime.date(year=submission_year, month=submission_month, day=1),
789+
datetime.date(year=submission_year, month=submission_month, day=days_in_submission_month),
790+
)
791+
)
792+
.where(Submission.submitter_id == submission.submitter_id)
793+
).all()
794+
submitter_submissions_month_total_qced = sum(
795+
map(lambda s: s.detailed_qc_passed is not None, submitter_submissions_month)
796+
)
797+
logger.debug(
798+
f"Total QCed submissions for submitter in submission's month: {submitter_submissions_month_total_qced}"
799+
)
800+
submitter_submissions_month_total_qcing = sum(map(latest_state_is_qcing, submitter_submissions_month))
801+
logger.debug(
802+
f"Total QCing submissions for submitter in submission's month: {submitter_submissions_month_total_qcing}"
803+
)
804+
if not (submitter_submissions_month_total_qced + submitter_submissions_month_total_qcing):
805+
return True
806+
807+
# yes if we are under target percentage for submitter for the submission's quarter
808+
with self._get_session() as session:
809+
submitter_submissions_quarter = session.exec(
810+
select(Submission)
811+
.options(selectinload(Submission.states)) # type: ignore[arg-type]
812+
.where(Submission.submission_type == SubmissionType.initial)
813+
.where(Submission.basic_qc_passed) # type: ignore[arg-type]
814+
.where(Submission.submission_date.between(submission_quarter_start, submission_quarter_end)) # type: ignore[union-attr]
815+
.where(Submission.submitter_id == submission.submitter_id)
816+
.order_by(Submission.submission_date) # type: ignore[arg-type]
817+
).all()
818+
submitter_submissions_quarter_total_qced = sum(
819+
map(lambda s: s.detailed_qc_passed is not None, submitter_submissions_quarter)
820+
)
821+
logger.debug(
822+
f"Total QCed submissions for submitter in submission's quarter: {submitter_submissions_quarter_total_qced}"
823+
)
824+
submitter_submissions_quarter_total_qcing = sum(map(latest_state_is_qcing, submitter_submissions_quarter))
825+
logger.debug(
826+
f"Total QCing submissions for submitter in submission's quarter: {submitter_submissions_quarter_total_qcing}"
827+
)
828+
qc_ratio = (submitter_submissions_quarter_total_qced + submitter_submissions_quarter_total_qcing) / len(
829+
submitter_submissions_quarter
830+
)
831+
logger.debug(
832+
f"Total submissions for submitter in submission's quarter: {len(submitter_submissions_quarter)}"
833+
)
834+
logger.debug(f"Ratio of submissions QCing/QCed for submitter in submission's quarter: {qc_ratio:.2%}")
835+
if qc_ratio <= target_proportion:
836+
return True
837+
838+
# randomly, but reproducibly, select submissions for a given submitter, quarter, block, and salt
839+
logger.debug("Randomly choosing whether to QC or not.")
840+
block_size = math.floor(1 / target_proportion)
841+
block_index = len(submitter_submissions_quarter) // block_size
842+
seed = f"{submission.submitter_id}-{submission_year}-{submission_quarter}-{block_index}-{salt}"
843+
rng = random.Random(seed) # noqa: S311
844+
845+
target_index_in_block = rng.randint(0, block_size - 1)
846+
try:
847+
current_index_in_block = [submission.id for submission in submitter_submissions_quarter].index(
848+
submission_id
849+
)
850+
except ValueError:
851+
# if the submission ID isn't in the quarter list, it hasn't met the requirements to be detailed QCed (e.g. passed basic QC)
852+
return False
853+
return current_index_in_block == target_index_in_block
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import datetime
2+
import math
3+
import random
4+
from pathlib import Path
5+
6+
import pytest
7+
from cryptography.hazmat.primitives import serialization
8+
from cryptography.hazmat.primitives.asymmetric import ed25519
9+
from grz_db.models.author import Author
10+
from grz_db.models.submission import SubmissionDb, SubmissionStateEnum, SubmissionStateLog, SubmissionType
11+
from sqlmodel import Session
12+
13+
SUBMITTER_ID = "123456789"
14+
DEFAULT_HISTORY = ["uploaded", "downloading", "downloaded", "decrypting", "decrypted", "validating", "validated"]
15+
16+
17+
@pytest.fixture(scope="function")
18+
def test_author() -> Author:
19+
"""Creates a test author with a valid temporary private key."""
20+
key = ed25519.Ed25519PrivateKey.generate()
21+
private_key_bytes = key.private_bytes(
22+
encoding=serialization.Encoding.PEM,
23+
format=serialization.PrivateFormat.OpenSSH,
24+
encryption_algorithm=serialization.NoEncryption(),
25+
)
26+
return Author(
27+
name="alice",
28+
private_key_bytes=private_key_bytes,
29+
private_key_passphrase="",
30+
)
31+
32+
33+
@pytest.fixture(scope="function")
34+
def db(tmp_path: Path, test_author: Author) -> SubmissionDb:
35+
"""Create a clean database initialized with schema for each test function."""
36+
db_path = tmp_path / "submissions.sqlite"
37+
db_url = f"sqlite:///{db_path.resolve()}"
38+
39+
submission_db = SubmissionDb(db_url=db_url, author=test_author, debug=False)
40+
submission_db.initialize_schema()
41+
42+
return submission_db
43+
44+
45+
def _update_submission_state(
46+
db: SubmissionDb, submission_id: str, state: SubmissionStateEnum, timestamp: datetime.datetime
47+
):
48+
"""
49+
Helper to manually insert a submission state log, to be able to control timestamps.
50+
"""
51+
with Session(db.engine) as session:
52+
log = SubmissionStateLog(
53+
submission_id=submission_id,
54+
state=state,
55+
timestamp=timestamp,
56+
author_name="alice",
57+
signature="dummy",
58+
)
59+
session.add(log)
60+
session.commit()
61+
62+
63+
def _add_submission_with_history(
64+
db: SubmissionDb,
65+
submission_id: str,
66+
submitter_id: str,
67+
submission_date: datetime.date,
68+
states: list[str],
69+
base_timestamp: datetime.datetime,
70+
is_qced: bool = False,
71+
):
72+
"""
73+
Helper to manually insert a submission and its state history.
74+
"""
75+
db.add_submission(submission_id)
76+
77+
db.modify_submission(submission_id, "submission_date", str(submission_date.isoformat()))
78+
db.modify_submission(submission_id, "submission_type", SubmissionType.initial)
79+
db.modify_submission(submission_id, "submitter_id", submitter_id)
80+
db.modify_submission(submission_id, "basic_qc_passed", "true")
81+
82+
if is_qced:
83+
db.modify_submission(submission_id, "detailed_qc_passed", "true")
84+
85+
current_timestamp = base_timestamp
86+
for state_str in states:
87+
state_enum = SubmissionStateEnum(state_str.capitalize())
88+
_update_submission_state(db, submission_id, state_enum, current_timestamp)
89+
current_timestamp += datetime.timedelta(seconds=1)
90+
91+
92+
class TestQcStrategy:
93+
"""Tests the QC selection strategy method db.should_qc."""
94+
95+
def test_first_of_month_always_runs(self, db: SubmissionDb):
96+
"""
97+
Test that the first (validated, initial) submission of a month is always QCed.
98+
"""
99+
test_date = datetime.date(2025, 12, 1)
100+
base_timestamp = datetime.datetime.combine(test_date, datetime.time(10, 0), tzinfo=datetime.UTC)
101+
submission_id = f"{SUBMITTER_ID}_{test_date}_00000000"
102+
103+
_add_submission_with_history(
104+
db,
105+
submission_id,
106+
SUBMITTER_ID,
107+
test_date,
108+
DEFAULT_HISTORY,
109+
base_timestamp=base_timestamp,
110+
is_qced=False,
111+
)
112+
113+
should_run = db.should_qc(
114+
submission_id=submission_id,
115+
target_percentage=2.0,
116+
salt="any_salt",
117+
)
118+
assert should_run is True, "The first submission of the month must be selected for QC."
119+
120+
def test_quarterly_ratio_catchup(self, db: SubmissionDb):
121+
"""
122+
Tests that the quarterly target is met.
123+
Target: 20%.
124+
"""
125+
target_percentage = 20.0
126+
salt = "ratio-test"
127+
base_date = datetime.date(2025, 12, 1)
128+
start_time = datetime.datetime.combine(base_date, datetime.time(9, 0), tzinfo=datetime.UTC)
129+
130+
submission_id = f"{SUBMITTER_ID}_{base_date}_00000000"
131+
_add_submission_with_history(
132+
db,
133+
submission_id,
134+
SUBMITTER_ID,
135+
base_date,
136+
[*DEFAULT_HISTORY, "qcing", "qced"],
137+
base_timestamp=start_time,
138+
is_qced=True,
139+
)
140+
141+
for i in range(1, 10):
142+
submission_id = f"{SUBMITTER_ID}_{base_date}_{i:0>8}"
143+
submission_timestamp = start_time + datetime.timedelta(minutes=i * 10)
144+
145+
_add_submission_with_history(
146+
db,
147+
submission_id,
148+
SUBMITTER_ID,
149+
base_date,
150+
DEFAULT_HISTORY,
151+
base_timestamp=submission_timestamp,
152+
is_qced=False,
153+
)
154+
155+
should_run = db.should_qc(submission_id, target_percentage, salt)
156+
157+
# Ratio hits 20% exactly at indices 4 (1/5) and 9 (2/10)
158+
if i in (4, 9):
159+
assert should_run is True, f"Index {i} should have been selected for QC (Ratio catchup)"
160+
161+
_update_submission_state(
162+
db, submission_id, SubmissionStateEnum.QCING, submission_timestamp + datetime.timedelta(minutes=1)
163+
)
164+
_update_submission_state(
165+
db, submission_id, SubmissionStateEnum.QCED, submission_timestamp + datetime.timedelta(minutes=2)
166+
)
167+
168+
db.modify_submission(submission_id, "detailed_qc_passed", "true")
169+
else:
170+
assert should_run is False, f"Index {i} should NOT have been selected for QC"
171+
172+
@pytest.mark.parametrize("target_percentage", [1.0, 2.0, 4.0, 5.0, 10.0, 20.0, 100.0])
173+
def test_random_selection(self, db: SubmissionDb, target_percentage: float):
174+
"""
175+
Test the random selection logic.
176+
We simulate the logic (Month -> Ratio -> Random) to verify expectation.
177+
"""
178+
salt = "test-salt"
179+
base_date = datetime.date(2025, 7, 1)
180+
start_time = datetime.datetime.combine(base_date, datetime.time(8, 0), tzinfo=datetime.UTC)
181+
182+
block_size = math.floor(1 / (target_percentage / 100.0))
183+
limit = block_size
184+
185+
qced_count = 0
186+
total_count = 0
187+
188+
for i in range(limit):
189+
submission_id = f"{SUBMITTER_ID}_{base_date}_{i:0>8}"
190+
submission_timestamp = start_time + datetime.timedelta(minutes=i * 10)
191+
total_count += 1
192+
193+
is_first_of_month_trigger = qced_count == 0
194+
195+
current_ratio = qced_count / total_count
196+
is_ratio_trigger = current_ratio <= (target_percentage / 100.0)
197+
198+
block_index = total_count // block_size
199+
seed = f"{SUBMITTER_ID}-{base_date.year}-3-{block_index}-{salt}"
200+
rng = random.Random(seed)
201+
target_index_in_block = rng.randint(0, block_size - 1)
202+
203+
is_random_trigger = i == target_index_in_block
204+
205+
expect_qc = False
206+
if is_first_of_month_trigger:
207+
expect_qc = True
208+
elif is_ratio_trigger:
209+
expect_qc = True
210+
elif is_random_trigger:
211+
expect_qc = True
212+
213+
_add_submission_with_history(
214+
db,
215+
submission_id,
216+
SUBMITTER_ID,
217+
base_date,
218+
DEFAULT_HISTORY,
219+
base_timestamp=submission_timestamp,
220+
is_qced=False,
221+
)
222+
223+
should_run = db.should_qc(submission_id=submission_id, target_percentage=target_percentage, salt=salt)
224+
225+
assert should_run is expect_qc, (
226+
f"Index {i}: Expect={expect_qc}, Got={should_run}. "
227+
f"(Ratio: {current_ratio:.3f} vs {target_percentage / 100.0}, "
228+
f"Stats: QCed={qced_count}, Total={total_count})"
229+
)
230+
231+
if should_run:
232+
_update_submission_state(
233+
db, submission_id, SubmissionStateEnum.QCING, submission_timestamp + datetime.timedelta(minutes=1)
234+
)
235+
_update_submission_state(
236+
db, submission_id, SubmissionStateEnum.QCED, submission_timestamp + datetime.timedelta(minutes=2)
237+
)
238+
db.modify_submission(submission_id, "detailed_qc_passed", "true")
239+
qced_count += 1

0 commit comments

Comments
 (0)