riezqidr commited on
Commit
3a87be3
·
1 Parent(s): 5cb2420

test(models): add failing reproducers for the screening-run tables

Browse files

Thirty-three metadata assertions covering screening_runs, candidate_scores,
requirement_verdicts, and evidence_spans against the schema in
ARCHITECTURE.md section 6.6.

RED validated: pytest tests/unit/test_screening_models.py -> 33 failed,
AttributeError: module 'app.models' has no attribute 'ScreeningRun'.
The failure is the missing implementation, not a broken fixture.

Files changed (1) hide show
  1. tests/unit/test_screening_models.py +420 -0
tests/unit/test_screening_models.py ADDED
@@ -0,0 +1,420 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the Phase 4 screening-run ORM models.
2
+
3
+ These four tables are the audit trail of a hiring decision, so their column
4
+ shapes are part of the contract rather than an implementation detail. The
5
+ schema is specified in ARCHITECTURE.md section 6.6, and three properties in it
6
+ are load-bearing:
7
+
8
+ * ``unique(run_id, candidate_id)`` — a candidate is scored once per run. Without
9
+ it, a retried task silently produces two scores and the ranking becomes
10
+ ambiguous.
11
+ * ``unique(score_id, requirement_id)`` — one verdict per requirement per score.
12
+ The aggregator already rejects duplicate verdicts; this is the same invariant
13
+ enforced one layer down, where a retry actually happens.
14
+ * ``retrieved_chunk_ids`` and ``verbatim_verified`` — the exposure record and
15
+ the anti-hallucination gate. "Why did the model say that?" is unanswerable
16
+ without the first, and a citation nobody checked is worse than no citation.
17
+
18
+ Money and score columns are ``numeric`` rather than float for the same reason
19
+ ``Requirement.weight`` is: a stored score that does not reproduce exactly on
20
+ replay is not defensible in a hiring decision.
21
+
22
+ Every case reads table metadata or constructs objects in memory — no database.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import uuid
28
+ from decimal import Decimal
29
+
30
+ from sqlalchemy import Boolean, Integer, Numeric, UniqueConstraint
31
+ from sqlalchemy.dialects.postgresql import ARRAY, JSONB
32
+
33
+ import app.models as models
34
+
35
+
36
+ def _unique_column_sets(table: object) -> set[tuple[str, ...]]:
37
+ """Return the column-name tuples covered by each unique constraint."""
38
+ return {
39
+ tuple(sorted(column.name for column in constraint.columns))
40
+ for constraint in table.constraints # type: ignore[attr-defined]
41
+ if isinstance(constraint, UniqueConstraint)
42
+ }
43
+
44
+
45
+ # --- screening_runs ---------------------------------------------------------
46
+
47
+
48
+ def test_screening_run_table_is_named_and_tenant_scoped() -> None:
49
+ """The table must exist and carry `tenant_id` as a non-null filter column."""
50
+ table = models.ScreeningRun.__table__
51
+
52
+ assert table.name == "screening_runs"
53
+ assert table.columns["tenant_id"].nullable is False
54
+ assert table.columns["tenant_id"].index is True
55
+
56
+
57
+ def test_screening_run_starts_queued() -> None:
58
+ """A run is accepted before it executes, so `queued` is the only safe default.
59
+
60
+ The admission endpoint answers 202 the moment the row exists. Defaulting to
61
+ `running` would make a crashed-before-start run indistinguishable from one
62
+ that is genuinely mid-flight.
63
+ """
64
+ column = models.ScreeningRun.__table__.columns["status"]
65
+
66
+ assert column.default is not None
67
+ assert column.default.arg == "queued"
68
+ assert column.nullable is False
69
+
70
+
71
+ def test_screening_run_references_the_job_it_screens_for() -> None:
72
+ """A run without its job cannot be resolved back to what was screened."""
73
+ fk = next(iter(models.ScreeningRun.__table__.columns["job_id"].foreign_keys))
74
+
75
+ assert fk.column.table.name == "jobs"
76
+ assert fk.ondelete == "CASCADE"
77
+
78
+
79
+ def test_screening_run_pins_the_rubric_version_it_was_scored_against() -> None:
80
+ """Scores are only interpretable against the criteria that produced them.
81
+
82
+ The FK does not cascade: deleting a rubric version that scores reference
83
+ would erase the basis of a decision already communicated to a candidate.
84
+ """
85
+ column = models.ScreeningRun.__table__.columns["rubric_version_id"]
86
+ fk = next(iter(column.foreign_keys))
87
+
88
+ assert fk.column.table.name == "rubric_versions"
89
+ assert fk.ondelete == "RESTRICT"
90
+ assert column.nullable is False
91
+
92
+
93
+ def test_screening_run_records_the_funnel_stage_counts_as_jsonb() -> None:
94
+ """The funnel's call-reduction claim is unverifiable without per-stage counts."""
95
+ column = models.ScreeningRun.__table__.columns["funnel_stage_counts"]
96
+
97
+ assert isinstance(column.type, JSONB)
98
+
99
+
100
+ def test_screening_run_cost_is_an_exact_decimal() -> None:
101
+ """Spend is money; `numeric(10,4)` per section 6.6, never a float."""
102
+ column = models.ScreeningRun.__table__.columns["cost_usd"]
103
+
104
+ assert isinstance(column.type, Numeric)
105
+ assert column.type.precision == 10
106
+ assert column.type.scale == 4
107
+
108
+
109
+ def test_screening_run_token_counters_are_bigint_and_start_at_zero() -> None:
110
+ """A run that has spent nothing must read zero, not NULL.
111
+
112
+ NULL would force every consumer of the ledger to special-case "not yet
113
+ started" before summing, and one that forgets produces a NULL total.
114
+ """
115
+ table = models.ScreeningRun.__table__
116
+
117
+ for name in (
118
+ "total_input_tokens",
119
+ "total_output_tokens",
120
+ "cache_read_tokens",
121
+ "cache_write_tokens",
122
+ ):
123
+ column = table.columns[name]
124
+ assert column.nullable is False, name
125
+ assert column.default is not None, name
126
+ assert column.default.arg == 0, name
127
+
128
+
129
+ def test_screening_run_completion_columns_are_null_until_it_finishes() -> None:
130
+ """A queued run has not started and has not completed."""
131
+ table = models.ScreeningRun.__table__
132
+
133
+ assert table.columns["started_at"].nullable is True
134
+ assert table.columns["completed_at"].nullable is True
135
+
136
+
137
+ # --- candidate_scores ------------------------------------------------------
138
+
139
+
140
+ def test_candidate_score_table_is_named_and_tenant_scoped() -> None:
141
+ """The table must exist and carry `tenant_id` as a non-null filter column."""
142
+ table = models.CandidateScore.__table__
143
+
144
+ assert table.name == "candidate_scores"
145
+ assert table.columns["tenant_id"].nullable is False
146
+ assert table.columns["tenant_id"].index is True
147
+
148
+
149
+ def test_candidate_score_is_unique_per_run_and_candidate() -> None:
150
+ """A candidate is scored once per run.
151
+
152
+ A retried run task that inserted a second row would leave two different
153
+ ranks for one person, and the shortlist would depend on read order.
154
+ """
155
+ assert ("candidate_id", "run_id") in _unique_column_sets(
156
+ models.CandidateScore.__table__
157
+ )
158
+
159
+
160
+ def test_candidate_score_cascades_from_its_run() -> None:
161
+ """A score has no meaning apart from the run that produced it."""
162
+ fk = next(iter(models.CandidateScore.__table__.columns["run_id"].foreign_keys))
163
+
164
+ assert fk.column.table.name == "screening_runs"
165
+ assert fk.ondelete == "CASCADE"
166
+
167
+
168
+ def test_candidate_score_overall_is_numeric_five_two() -> None:
169
+ """`numeric(5,2)` per section 6.6 — a replayed run must reproduce the digits."""
170
+ column = models.CandidateScore.__table__.columns["overall_score"]
171
+
172
+ assert isinstance(column.type, Numeric)
173
+ assert column.type.precision == 5
174
+ assert column.type.scale == 2
175
+
176
+
177
+ def test_candidate_score_raw_weighted_keeps_four_decimals() -> None:
178
+ """The pre-cap weighted sum is what makes a capped score explainable."""
179
+ column = models.CandidateScore.__table__.columns["raw_weighted"]
180
+
181
+ assert isinstance(column.type, Numeric)
182
+ assert column.type.precision == 5
183
+ assert column.type.scale == 4
184
+
185
+
186
+ def test_candidate_score_cap_applied_is_null_when_no_cap_fired() -> None:
187
+ """`cap_applied` distinguishes "capped to 40" from "genuinely scored 40".
188
+
189
+ `aggregate_score()` already reports the cap separately from the must-have
190
+ failure; storing NULL rather than 0 preserves that distinction on disk.
191
+ """
192
+ column = models.CandidateScore.__table__.columns["cap_applied"]
193
+
194
+ assert column.nullable is True
195
+ assert isinstance(column.type, Integer)
196
+
197
+
198
+ def test_candidate_score_records_the_formula_that_produced_it() -> None:
199
+ """Provenance for replay: the same inputs must be re-derivable."""
200
+ column = models.CandidateScore.__table__.columns["aggregation_formula_version"]
201
+
202
+ assert column.nullable is False
203
+
204
+
205
+ def test_candidate_score_candidate_id_is_a_plain_uuid() -> None:
206
+ """There is no `candidates` table in this repository yet.
207
+
208
+ ARCHITECTURE.md section 6.6 declares `candidate_id` as a foreign key, but
209
+ the `candidates` table it points at is unbuilt — the same situation as
210
+ `Requirement.skill_id` and the ESCO taxonomy. Declaring the FK here would
211
+ make the migration unrunnable, so the column is a plain uuid until the
212
+ table exists.
213
+ """
214
+ column = models.CandidateScore.__table__.columns["candidate_id"]
215
+
216
+ assert column.nullable is False
217
+ assert not column.foreign_keys
218
+
219
+
220
+ # --- requirement_verdicts --------------------------------------------------
221
+
222
+
223
+ def test_requirement_verdict_table_is_named_and_tenant_scoped() -> None:
224
+ """The table must exist and carry `tenant_id` as a non-null filter column."""
225
+ table = models.RequirementVerdict.__table__
226
+
227
+ assert table.name == "requirement_verdicts"
228
+ assert table.columns["tenant_id"].nullable is False
229
+ assert table.columns["tenant_id"].index is True
230
+
231
+
232
+ def test_requirement_verdict_is_unique_per_score_and_requirement() -> None:
233
+ """One verdict per requirement per score.
234
+
235
+ `aggregate_score()` raises on duplicate verdicts; this is the same
236
+ invariant at the storage layer, which is where a retry actually races.
237
+ """
238
+ assert ("requirement_id", "score_id") in _unique_column_sets(
239
+ models.RequirementVerdict.__table__
240
+ )
241
+
242
+
243
+ def test_requirement_verdict_cascades_from_its_score() -> None:
244
+ """A verdict is a component of one score and cannot outlive it."""
245
+ fk = next(iter(models.RequirementVerdict.__table__.columns["score_id"].foreign_keys))
246
+
247
+ assert fk.column.table.name == "candidate_scores"
248
+ assert fk.ondelete == "CASCADE"
249
+
250
+
251
+ def test_requirement_verdict_pins_the_requirement_it_judged() -> None:
252
+ """A verdict detached from its requirement text explains nothing."""
253
+ fk = next(
254
+ iter(models.RequirementVerdict.__table__.columns["requirement_id"].foreign_keys)
255
+ )
256
+
257
+ assert fk.column.table.name == "requirements"
258
+ assert fk.ondelete == "RESTRICT"
259
+
260
+
261
+ def test_requirement_verdict_stores_the_chunks_the_judge_actually_saw() -> None:
262
+ """`retrieved_chunk_ids` is the exposure record.
263
+
264
+ Section 6.6: it is precisely the context the judge saw. Without it, "why
265
+ did the model say that?" cannot be answered after the fact.
266
+ """
267
+ column = models.RequirementVerdict.__table__.columns["retrieved_chunk_ids"]
268
+
269
+ assert isinstance(column.type, ARRAY)
270
+
271
+
272
+ def test_requirement_verdict_weight_and_contribution_are_exact_decimals() -> None:
273
+ """The verdict's arithmetic must reproduce the score exactly."""
274
+ table = models.RequirementVerdict.__table__
275
+
276
+ weight = table.columns["weight_at_scoring"]
277
+ assert isinstance(weight.type, Numeric)
278
+ assert (weight.type.precision, weight.type.scale) == (5, 4)
279
+
280
+ contribution = table.columns["contribution"]
281
+ assert isinstance(contribution.type, Numeric)
282
+ assert (contribution.type.precision, contribution.type.scale) == (6, 4)
283
+
284
+
285
+ def test_requirement_verdict_cache_key_holds_a_sha256_digest() -> None:
286
+ """`result_cache_key` is the 64-hex key `verdict_cache_key()` derives."""
287
+ column = models.RequirementVerdict.__table__.columns["result_cache_key"]
288
+
289
+ assert column.type.length == 64
290
+
291
+
292
+ def test_requirement_verdict_records_whether_the_cache_was_hit() -> None:
293
+ """The "re-run costs ~0 tokens" claim is measured from this column."""
294
+ column = models.RequirementVerdict.__table__.columns["cache_hit"]
295
+
296
+ assert isinstance(column.type, Boolean)
297
+ assert column.nullable is False
298
+
299
+
300
+ def test_requirement_verdict_override_columns_are_null_until_a_human_acts() -> None:
301
+ """A human override is recorded beside the model's verdict, never over it.
302
+
303
+ The system never rejects a candidate; a recruiter can overrule any verdict.
304
+ Overwriting `verdict` in place would destroy the evidence that the model and
305
+ the human disagreed, which is exactly what an audit needs to see.
306
+ """
307
+ table = models.RequirementVerdict.__table__
308
+
309
+ for name in ("overridden_by", "override_verdict", "override_reason", "overridden_at"):
310
+ assert table.columns[name].nullable is True, name
311
+
312
+
313
+ # --- evidence_spans --------------------------------------------------------
314
+
315
+
316
+ def test_evidence_span_table_is_named_and_tenant_scoped() -> None:
317
+ """The table must exist and carry `tenant_id` as a non-null filter column."""
318
+ table = models.EvidenceSpanRecord.__table__
319
+
320
+ assert table.name == "evidence_spans"
321
+ assert table.columns["tenant_id"].nullable is False
322
+ assert table.columns["tenant_id"].index is True
323
+
324
+
325
+ def test_evidence_span_verdict_id_is_nullable() -> None:
326
+ """A span can be retrieved and verified before any verdict cites it.
327
+
328
+ Section 6.6 declares `verdict_id fk null`. Requiring it would force the
329
+ pipeline to invent a verdict before it has judged anything.
330
+ """
331
+ assert models.EvidenceSpanRecord.__table__.columns["verdict_id"].nullable is True
332
+
333
+
334
+ def test_evidence_span_cascades_from_its_verdict() -> None:
335
+ """Deleting a verdict must not leave orphan citations behind."""
336
+ fk = next(iter(models.EvidenceSpanRecord.__table__.columns["verdict_id"].foreign_keys))
337
+
338
+ assert fk.column.table.name == "requirement_verdicts"
339
+ assert fk.ondelete == "CASCADE"
340
+
341
+
342
+ def test_evidence_span_anchors_to_the_parse_that_produced_the_offsets() -> None:
343
+ """Offsets are only valid against the resume version they were taken from.
344
+
345
+ A re-parse under a newer parser is a new `resume_versions` row precisely so
346
+ that older offsets stay attributable. The FK restricts deletion for the same
347
+ reason: the span would silently point into a different text.
348
+ """
349
+ column = models.EvidenceSpanRecord.__table__.columns["resume_version_id"]
350
+ fk = next(iter(column.foreign_keys))
351
+
352
+ assert fk.column.table.name == "resume_versions"
353
+ assert fk.ondelete == "RESTRICT"
354
+ assert column.nullable is False
355
+
356
+
357
+ def test_evidence_span_carries_exact_character_offsets() -> None:
358
+ """`text[start_char:end_char]` is the check `verify_evidence_span()` runs.
359
+
360
+ Both offsets are required: a span with only a start is not checkable, and an
361
+ unverifiable citation is the failure mode this column set exists to prevent.
362
+ """
363
+ table = models.EvidenceSpanRecord.__table__
364
+
365
+ assert table.columns["start_char"].nullable is False
366
+ assert table.columns["end_char"].nullable is False
367
+ assert isinstance(table.columns["start_char"].type, Integer)
368
+ assert isinstance(table.columns["end_char"].type, Integer)
369
+
370
+
371
+ def test_evidence_span_verbatim_flag_defaults_to_unverified() -> None:
372
+ """An unchecked citation must never read as verified.
373
+
374
+ Defaulting to True would mean a span inserted before the check ran claims a
375
+ guarantee nobody established — the anti-hallucination gate held open.
376
+ """
377
+ column = models.EvidenceSpanRecord.__table__.columns["verbatim_verified"]
378
+
379
+ assert isinstance(column.type, Boolean)
380
+ assert column.nullable is False
381
+ assert column.default is not None
382
+ assert column.default.arg is False
383
+
384
+
385
+ # --- construction ----------------------------------------------------------
386
+
387
+
388
+ def test_a_screening_run_accepts_the_fields_the_admission_endpoint_supplies() -> None:
389
+ """The row the endpoint inserts round-trips onto the instance."""
390
+ run = models.ScreeningRun(
391
+ id=uuid.uuid4(),
392
+ tenant_id=uuid.uuid4(),
393
+ job_id=uuid.uuid4(),
394
+ rubric_version_id=uuid.uuid4(),
395
+ triggered_by=uuid.uuid4(),
396
+ mode="interactive",
397
+ candidate_count=3,
398
+ )
399
+
400
+ assert run.mode == "interactive"
401
+ assert run.candidate_count == 3
402
+
403
+
404
+ def test_a_candidate_score_accepts_exact_decimals() -> None:
405
+ """Score and pre-cap weighted sum round-trip as exact Decimals."""
406
+ score = models.CandidateScore(
407
+ id=uuid.uuid4(),
408
+ tenant_id=uuid.uuid4(),
409
+ run_id=uuid.uuid4(),
410
+ candidate_id=uuid.uuid4(),
411
+ overall_score=Decimal("40.00"),
412
+ raw_weighted=Decimal("0.8125"),
413
+ cap_applied=40,
414
+ rank=1,
415
+ aggregation_formula_version="v1",
416
+ )
417
+
418
+ assert score.overall_score == Decimal("40.00")
419
+ assert score.raw_weighted == Decimal("0.8125")
420
+ assert score.cap_applied == 40