-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathtest_release_validation.py
More file actions
1791 lines (1605 loc) · 74 KB
/
Copy pathtest_release_validation.py
File metadata and controls
1791 lines (1605 loc) · 74 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Release-tier validation test.
Runs realistic full-stack simulations against bundled species
presets and runs two complementary equivalence checks per record:
1. `result.validate_records(refdata)` — the user-facing AIRR
postcondition validator. Asks "does each projected AIRR
record agree with an independent re-derivation from the
final Outcome?" Empty failure list = the projected output
is internally consistent.
2. `outcome.check_live_call_cache_parity(refdata)` — the
engine-integrity parity harness. Asks "does the cached
SegmentLiveCall on the final Simulation equal a from-
scratch recompute over the same sim + refdata?" Empty
mismatches = the runtime cache that *feeds* projection is
itself consistent.
Both gates ship green on every release. They're deliberately kept
separate: the validator is the downstream contract for users; the
parity harness is the engine-side guard. A bug in the live-call
refresh path can land in either gate first (or both); having them
side by side localises the failure quickly.
History: the C4 allele-call oracle mirrors the walker's
NP-extension scoring (`live_call/scoring.rs`); two boundary bugs
in the live-call refresh path were closed (`segment_region_overlaps_dirty`
strict-`<` + `on_indel_inserted` `<=`) after this harness surfaced
them. All four locus configurations validate at 100% across the
seed counts run here.
"""
from __future__ import annotations
import GenAIRR as ga
def test_full_stack_vdj_validates_all_airr_records():
"""Productive human IGH full-stack pipeline (the canonical
realistic config). Two gates run side by side:
AIRR validator — every projected record must agree with the
engine's truth oracle (no filtering).
Cache parity — every cached SegmentLiveCall must equal a
from-scratch recompute over the same sim + refdata.
Validator covers projected output; parity covers the cached
state feeding it. If a future refresh-path bug regresses, one
or both gates surface the divergence — start with whichever
fails first."""
exp = (
ga.Experiment.on("human_igh")
.recombine()
.productive_only()
.mutate(rate=0.03)
.pcr_amplify(rate=1e-4)
.polymerase_indels(count=2)
.primer_trim_5prime(length=(0, 3))
.primer_trim_3prime(length=(0, 3))
)
refdata = exp.refdata
result = exp.run_records(n=100, seed=4242)
# Gate 1: projected AIRR records.
report = result.validate_records(refdata)
assert report, (
f"validation failed on {len(report.failures)}/{report.count} "
f"records — summary={report.summary()}; "
f"first failure={report.failures[0] if report.failures else None}"
)
# Gate 2: cached live calls vs from-scratch recompute. Iterate
# outcomes (they're attached to the result for non-clonal runs)
# and assert per-segment parity on every record.
assert result.outcomes is not None, "result must carry outcomes for parity check"
for i, outcome in enumerate(result.outcomes):
for p in outcome.check_live_call_cache_parity(refdata):
assert p["tie_set_matches"], (
f"record {i} segment {p['segment']}: cached live call "
f"diverges from fresh recompute — "
f"cached={p['cached_tie_set']} fresh={p['fresh_tie_set']}"
)
if p["hypothesis_bounds_match"] is not None:
assert p["hypothesis_bounds_match"], (
f"record {i} segment {p['segment']}: hypothesis bounds "
f"diverge cached={p['cached_hypothesis']} "
f"fresh={p['fresh_hypothesis']}"
)
def test_full_stack_vdj_non_productive_validates():
"""Same VDJ pipeline minus productive_only — ~30% of records
have junction stops or OOF junctions, but every AIRR record
must still agree with the validator's re-derivation."""
exp = (
ga.Experiment.on("human_igh")
.recombine()
.mutate(rate=0.03)
.pcr_amplify(rate=1e-4)
.polymerase_indels(count=2)
)
refdata = exp.refdata
result = exp.run_records(n=100, seed=4242)
report = result.validate_records(refdata)
assert report, (
f"non-productive VDJ validation failed: {report.summary()}; "
f"first failure={report.failures[0] if report.failures else None}"
)
def test_full_stack_validates_igk_with_no_filtering():
"""Productive IGK full stack — SAME shape as IGH but on a VJ
chain. Previously this fixture surfaced a ~5% J-segment
tie-set divergence under end-loss + productive_only;
the boundary fix in segment_region_overlaps_dirty closed it.
This test is the regression guard: if the fix gets reverted or
a similar refresh-skip bug appears at the 3' end-loss boundary,
this fails first."""
exp = (
ga.Experiment.on("human_igk")
.recombine()
.productive_only()
.mutate(rate=0.03)
.pcr_amplify(rate=1e-4)
.polymerase_indels(count=2)
.primer_trim_5prime(length=(0, 3))
.primer_trim_3prime(length=(0, 3))
)
refdata = exp.refdata
result = exp.run_records(n=100, seed=4242)
report = result.validate_records(refdata)
assert report, (
f"IGK full-stack validation failed: {report.summary()}; "
f"first failure={report.failures[0] if report.failures else None}"
)
def test_full_stack_validates_lambda_with_no_filtering():
"""IGL (lambda) productive full stack — the second VJ locus."""
exp = (
ga.Experiment.on("human_igl")
.recombine()
.productive_only()
.mutate(rate=0.03)
.pcr_amplify(rate=1e-4)
)
refdata = exp.refdata
result = exp.run_records(n=100, seed=4242)
report = result.validate_records(refdata)
assert report, (
f"IGL validation failed: {report.summary()}; "
f"first failure={report.failures[0] if report.failures else None}"
)
def test_end_loss_three_prime_does_not_strand_stale_live_call():
"""Regression test for the segment_region_overlaps_dirty
boundary bug: a 3' primer-trim of length≥1 with productive_only
on a productive IGK simulation must produce a J live-call that
matches the from-scratch recompute oracle on every seed.
The bug: an IndelDeleted event at exactly pool_len-1 (the
deleted byte) leaves a dirty window at [pool_len-1, pool_len).
Post-deletion J.region.end = pool_len-1. Strict-`<` overlap
check skipped the segment, leaving the stale pre-deletion live
call committed. Fixed by making the overlap upper bound
inclusive: `w.start <= region_end`.
"""
exp = (
ga.Experiment.on("human_igk")
.recombine()
.productive_only()
.primer_trim_3prime(length=(1, 3)) # always at least 1 byte trimmed
)
refdata = exp.refdata
result = exp.run_records(n=200, seed=4242)
report = result.validate_records(refdata)
assert report, (
f"3'-end-loss boundary regression: {report.summary()}; "
f"first failure={report.failures[0] if report.failures else None}"
)
# ──────────────────────────────────────────────────────────────────
# Two-layer integrity model — the canonical example of running
# both gates together on the same outcomes.
# ──────────────────────────────────────────────────────────────────
def test_validator_and_parity_are_independent_layers():
"""Demonstrates the two-layer model end to end:
Layer 1 (downstream contract): validate_records inspects the
*projected* AIRR record. This is what users see when they
consume engine output.
Layer 2 (engine-side guard): check_live_call_cache_parity
inspects the *cached* live-call state on the final
Simulation. This is the state projection reads from.
Both gates run on every outcome in this test. If a future
refresh-path bug regresses, the failing layer tells you where
to look — projection (rare; usually downstream of a cache
bug) or refresh-path (more common, caught at the source by
parity). Both green means projection AND cache are consistent.
The two layers are independent by design: a projection bug
could pass parity but fail validation; a cache bug usually
fails parity first and *may* leak into the validator. Keeping
them separate lets the failure point at the root cause."""
exp = (
ga.Experiment.on("human_igh")
.recombine()
.productive_only()
.mutate(rate=0.03)
.polymerase_indels(count=2)
.primer_trim_3prime(length=(1, 3))
)
refdata = exp.refdata
result = exp.run_records(n=50, seed=4242)
report = result.validate_records(refdata)
assert report, f"projection layer (validator) failed: {report.summary()}"
assert result.outcomes is not None
for i, outcome in enumerate(result.outcomes):
for p in outcome.check_live_call_cache_parity(refdata):
assert p["tie_set_matches"], (
f"engine layer (cache parity) failed at record {i} "
f"segment {p['segment']}: cached={p['cached_tie_set']} "
f"fresh={p['fresh_tie_set']}"
)
# ──────────────────────────────────────────────────────────────────
# D inversion — release-tier inclusion. Pin that the new mechanism
# survives both the AIRR validator and the cache-parity oracle under
# realistic pipeline loads, on a heavy-chain catalogue with every
# corruption stage active.
#
# Locks down the Slice A → E arc against regressions: any bug that
# corrupts the orientation flag, lets `d_inverted` desync from the
# IR, or breaks live-call refresh under inverted D would surface
# here before reaching downstream consumers.
#
# **D-tie oracle under inversion — RESOLVED.** Earlier slices of
# the D-inversion arc carried a documented limitation: the walker
# scored assembled D bytes against the forward-orientation index,
# so the post-Slice-E inverted-D records surfaced spurious
# `AlleleCallTieSetMismatch{segment: D}` issues. The release
# tests below previously routed through a `_strip_inverted_d_tie_set_issues`
# helper that filtered those.
#
# The D-inversion live-call / allele-call cleanup slice replaced
# the boundary primitive with the orientation-aware
# `matches_observed_with_orientation` (and routed the walker, the
# extension walks, the `from_existing_region` rebuild, and the
# validator oracle through it). The filter is no longer needed —
# the validator now agrees with the walker on inverted-D tie sets
# directly. The previous helper has been removed; the release
# tests below assert validator cleanliness without exceptions.
# ──────────────────────────────────────────────────────────────────
def test_productive_igh_full_stack_with_d_inversion_validates_all_records():
"""Productive IGH + every corruption stage + ``invert_d(prob=1.0)``.
With prob=1, every record's D is committed in reverse-complement
orientation, so every record exercises the inverted-D code path
through assembly, walker scoring, AIRR projection, and the
validator's `d_inverted` consistency check. Both two-layer
gates must run clean.
"""
exp = (
ga.Experiment.on("human_igh")
.recombine()
.invert_d(prob=1.0)
.productive_only()
.mutate(rate=0.03)
.pcr_amplify(rate=1e-4)
.polymerase_indels(count=2)
.primer_trim_5prime(length=(0, 3))
.primer_trim_3prime(length=(0, 3))
)
refdata = exp.refdata
result = exp.run_records(n=50, seed=4242)
# Gate 1: AIRR validator. Every postcondition runs clean now
# that the orientation-aware walker + validator oracle
# converge on the inverted-D tie set. No exceptions stripped.
report = result.validate_records(refdata)
assert report, (
f"inverted-D productive IGH validation failed: "
f"summary={report.summary()}; first failure="
f"{report.failures[0] if report.failures else None}"
)
# Gate 2: live-call cache parity on every outcome.
assert result.outcomes is not None
for i, outcome in enumerate(result.outcomes):
for p in outcome.check_live_call_cache_parity(refdata):
assert p["tie_set_matches"], (
f"inverted-D record {i} segment {p['segment']}: "
f"cache parity failed under inversion — "
f"cached={p['cached_tie_set']} fresh={p['fresh_tie_set']}"
)
# Every record's d_inverted must be True (prob=1 commits RC on
# every seed). Acts as a smoke check that the AIRR field
# plumbing fires for the realistic stack.
assert all(rec["d_inverted"] is True for rec in result.records), (
"prob=1.0 must commit ReverseComplement on every record"
)
def test_non_productive_igh_full_stack_with_d_inversion_validates_all_records():
"""Same as the productive variant minus the constraint bundle.
Without `productive_only`, junctions can carry in-frame stops or
out-of-frame lengths. The inverted-D bytes propagate into the
junction-scan path and the validator must still agree with the
engine's re-derivation on every record — pins that inversion
doesn't break the validator's structural / counter checks even
when the record is non-productive."""
exp = (
ga.Experiment.on("human_igh")
.recombine()
.invert_d(prob=1.0)
.mutate(rate=0.03)
.pcr_amplify(rate=1e-4)
.polymerase_indels(count=2)
)
refdata = exp.refdata
result = exp.run_records(n=50, seed=4242)
report = result.validate_records(refdata)
assert report, (
f"inverted-D non-productive IGH validation failed: "
f"summary={report.summary()}; first failure="
f"{report.failures[0] if report.failures else None}"
)
assert all(rec["d_inverted"] is True for rec in result.records)
def test_invert_d_trace_replay_round_trip_passes_validator():
"""Replay determinism with inversion in the chain.
For each fresh outcome: build a TraceFile, rerun it via
`rerun_from_trace_file`, build the AIRR record from the replayed
outcome, and assert it (a) carries the same `d_inverted` value
and (b) passes the validator.
Uses ``prob=0.5`` so the seed sweep exercises both the True and
False branches of the inversion decision; the replay must
reproduce whichever branch fired in the fresh run."""
from GenAIRR._airr_record import outcome_to_airr_record
exp = (
ga.Experiment.on("human_igh")
.recombine()
.invert_d(prob=0.5)
.productive_only()
.mutate(rate=0.01)
)
refdata = exp.refdata
compiled = exp.compile()
seen_true = False
seen_false = False
for seed in range(8):
fresh = compiled.simulator.run(seed=seed)
tf = compiled.simulator.trace_file_from(fresh, seed=seed)
replayed = compiled.simulator.rerun_from_trace_file(tf)
fresh_rec = outcome_to_airr_record(fresh, refdata, sequence_id=f"fresh-{seed}")
replayed_rec = outcome_to_airr_record(
replayed, refdata, sequence_id=f"replay-{seed}"
)
# Orientation must round-trip.
assert fresh_rec["d_inverted"] == replayed_rec["d_inverted"], (
f"seed {seed}: d_inverted desynced through replay "
f"({fresh_rec['d_inverted']} vs {replayed_rec['d_inverted']})"
)
if fresh_rec["d_inverted"]:
seen_true = True
else:
seen_false = True
# The full AIRR record's sequence bytes must round-trip too.
assert fresh_rec["sequence"] == replayed_rec["sequence"], (
f"seed {seed}: assembled sequence diverged under replay"
)
# And the replayed record must pass the validator unchanged.
replayed_issues = replayed.validate_record(refdata, sequence_id=f"replay-{seed}")
kinds = {issue["kind"] for issue in replayed_issues}
assert "DInvertedMismatch" not in kinds, (
f"seed {seed}: replayed record tripped DInvertedMismatch"
)
# Both branches must fire across the 8 seeds at prob=0.5 — if
# not, the test isn't actually exercising the True/False split.
assert seen_true, "prob=0.5 over 8 seeds: no True branch ever fired"
assert seen_false, "prob=0.5 over 8 seeds: no False branch ever fired"
# ──────────────────────────────────────────────────────────────────
# Receptor revision (audit-first biology mechanism #2). Same shape
# as the D-inversion release tests above: validator + cache parity
# under a realistic productive IGH stack, plus a replay round-trip
# that pins both AIRR provenance fields. The distribution invariant
# (`prob=0.25`, ±5σ) lives in `test_distribution_invariants.py`
# alongside the equivalent `invert_d` Bernoulli draw.
# ──────────────────────────────────────────────────────────────────
def test_productive_igh_full_stack_with_receptor_revision_validates_all_records():
"""Productive IGH + every corruption stage + ``receptor_revision(prob=1.0)``.
Every record's V segment is rewritten by the receptor-revision
pass, so every record exercises the post-recombine V-replacement
code path through `SegmentReplaced` event emission, the
AllStructural-equivalent live-call refresh, AIRR projection of
the new `receptor_revision_applied` / `original_v_call` fields,
and the validator's two corresponding consistency checks. Both
two-layer gates must run clean.
"""
exp = (
ga.Experiment.on("human_igh")
.recombine()
.receptor_revision(prob=1.0)
.productive_only()
.mutate(rate=0.03)
.polymerase_indels(count=2)
)
refdata = exp.refdata
result = exp.run_records(n=50, seed=4242)
# Gate 1: AIRR validator. Every postcondition, including the
# new `ReceptorRevisionAppliedMismatch` and `OriginalVCallMismatch`
# checks from Slice E.
report = result.validate_records(refdata)
assert report, (
f"revised-V productive IGH validation failed: "
f"summary={report.summary()}; first failure={report.failures[0] if report.failures else None}"
)
# Gate 2: live-call cache parity. After a `SegmentReplaced(V)`
# event the refresh hook runs an AllStructural-equivalent
# re-walk; the cached `SegmentLiveCall` must agree with a fresh
# from-scratch recompute on every outcome.
assert result.outcomes is not None
for i, outcome in enumerate(result.outcomes):
for p in outcome.check_live_call_cache_parity(refdata):
assert p["tie_set_matches"], (
f"revised-V record {i} segment {p['segment']}: "
f"cache parity failed after receptor revision — "
f"cached={p['cached_tie_set']} fresh={p['fresh_tie_set']}"
)
# Every record's receptor_revision_applied must be True (prob=1
# commits revision on every seed). Smoke check that the AIRR
# field plumbing fires for the realistic stack.
assert all(
rec["receptor_revision_applied"] is True for rec in result.records
), "prob=1.0 must commit a revision on every record"
# And every record carries a non-empty original_v_call when
# applied — the trace-sourced pre-revision V name.
assert all(rec["original_v_call"] for rec in result.records), (
"applied=True records must carry a non-empty original_v_call"
)
def test_receptor_revision_trace_replay_round_trip_passes_validator():
"""Replay determinism with receptor revision in the chain.
For each fresh outcome: build a TraceFile, rerun it via
`rerun_from_trace_file`, build the AIRR record from the replayed
outcome, and assert it (a) carries the same
`receptor_revision_applied` + `original_v_call` values and (b)
passes the validator with no `ReceptorRevisionAppliedMismatch`
or `OriginalVCallMismatch`.
Uses ``prob=0.5`` over multiple seeds so the sweep exercises
both branches of the revision decision; the replay must
reproduce whichever branch fired in the fresh run.
"""
from GenAIRR._airr_record import outcome_to_airr_record
exp = (
ga.Experiment.on("human_igh")
.recombine()
.receptor_revision(prob=0.5)
.productive_only()
.mutate(rate=0.01)
)
refdata = exp.refdata
compiled = exp.compile()
seen_true = False
seen_false = False
for seed in range(8):
fresh = compiled.simulator.run(seed=seed)
tf = compiled.simulator.trace_file_from(fresh, seed=seed)
replayed = compiled.simulator.rerun_from_trace_file(tf)
fresh_rec = outcome_to_airr_record(
fresh, refdata, sequence_id=f"fresh-{seed}"
)
replayed_rec = outcome_to_airr_record(
replayed, refdata, sequence_id=f"replay-{seed}"
)
# Both provenance fields must round-trip.
assert (
fresh_rec["receptor_revision_applied"]
== replayed_rec["receptor_revision_applied"]
), (
f"seed {seed}: receptor_revision_applied desynced through replay "
f"({fresh_rec['receptor_revision_applied']} vs "
f"{replayed_rec['receptor_revision_applied']})"
)
assert (
fresh_rec["original_v_call"] == replayed_rec["original_v_call"]
), (
f"seed {seed}: original_v_call desynced through replay "
f"({fresh_rec['original_v_call']!r} vs "
f"{replayed_rec['original_v_call']!r})"
)
if fresh_rec["receptor_revision_applied"]:
seen_true = True
else:
seen_false = True
# Full AIRR record's sequence bytes must round-trip too.
assert fresh_rec["sequence"] == replayed_rec["sequence"], (
f"seed {seed}: assembled sequence diverged under replay"
)
# And the replayed record must pass the two new validator
# checks with no mismatches.
replayed_issues = replayed.validate_record(
refdata, sequence_id=f"replay-{seed}"
)
kinds = {issue["kind"] for issue in replayed_issues}
assert "ReceptorRevisionAppliedMismatch" not in kinds, (
f"seed {seed}: replayed record tripped "
f"ReceptorRevisionAppliedMismatch"
)
assert "OriginalVCallMismatch" not in kinds, (
f"seed {seed}: replayed record tripped OriginalVCallMismatch"
)
# Both branches must fire across the 8 seeds at prob=0.5; if
# they don't, the test isn't exercising both halves of the
# revision decision and a regression in one branch would slip
# through unnoticed.
assert seen_true, "prob=0.5 over 8 seeds: no True branch ever fired"
assert seen_false, "prob=0.5 over 8 seeds: no False branch ever fired"
# ──────────────────────────────────────────────────────────────────
# Paired-end / read layout (audit-first biology mechanism #3 — by
# arc count; biologically it's a sequencing-stage projection, not
# a biology mechanism). Same shape as the receptor-revision tests:
# validator + cache parity under a realistic productive IGH stack
# composed with every other audit mechanism, plus a replay round-
# trip that pins all eight paired-end fields. The distribution
# invariant (`insert_size=(low, high)`, ±5σ) lives in
# `test_distribution_invariants.py` alongside the equivalent
# Bernoulli draws.
# ──────────────────────────────────────────────────────────────────
def test_productive_igh_full_stack_with_paired_end_validates_all_records():
"""Productive IGH composed with **every** audit mechanism —
receptor revision + D inversion + mutation + end-loss (both
sides) + random strand orientation + paired-end.
This is the high-value composition check: paired-end must
remain a projection layer after every IR-mutating + every
observation-stage mechanism. Both two-layer gates run clean,
and every record carries the eight populated paired-end fields.
The D-tie oracle is still known-incomplete under inversion
(see `_strip_inverted_d_tie_set_issues`); the remaining issue
set still includes every paired-end check
(`PairedEndFieldWithoutLayout`, `ReadWindowOutOfBounds`,
`ReadSequenceMismatch`, `ReadInsertSizeMismatch`,
`ReadLayoutMismatch`).
"""
exp = (
ga.Experiment.on("human_igh")
.recombine()
.receptor_revision(prob=0.5)
.invert_d(prob=0.5)
.productive_only()
.mutate(rate=0.03)
.end_loss_5prime(length=[(2, 1.0)])
.end_loss_3prime(length=[(2, 1.0)])
.random_strand_orientation(prob=0.5)
.paired_end(r1_length=80, insert_size=200)
)
refdata = exp.refdata
result = exp.run_records(n=50, seed=4242)
# Gate 1: AIRR validator. No exceptions stripped — the
# orientation-aware walker + validator oracle converge on the
# inverted-D tie set directly.
report = result.validate_records(refdata)
assert report, (
f"paired-end full-stack validation failed: "
f"summary={report.summary()}; first failure="
f"{report.failures[0] if report.failures else None}"
)
# Gate 2: live-call cache parity on every outcome. Paired-end
# is projection-only and must not invalidate the live-call
# layer — pin that cache parity stays clean even under the
# full stack of preceding mechanisms.
assert result.outcomes is not None
for i, outcome in enumerate(result.outcomes):
for p in outcome.check_live_call_cache_parity(refdata):
assert p["tie_set_matches"], (
f"paired-end record {i} segment {p['segment']}: "
f"cache parity failed — cached={p['cached_tie_set']} "
f"fresh={p['fresh_tie_set']}"
)
# Every record carries the eight paired-end fields populated.
# Smoke check that the AIRR field plumbing fires for the
# realistic stack.
assert all(
rec["read_layout"] == "paired_end" for rec in result.records
), "paired-end must populate every record under the full stack"
assert all(
len(rec["r1_sequence"]) == 80 for rec in result.records
), "r1_sequence length must match the requested r1_length"
assert all(
rec["insert_size"] == 200 for rec in result.records
), "insert_size must match the requested value"
def test_paired_end_trace_replay_round_trip_passes_validator():
"""Replay determinism with paired-end in the chain over
multiple seeds with **variable** insert sizes (a
`(low, high)` uniform-int distribution).
For each fresh outcome: build a TraceFile, rerun it via
``rerun_from_trace_file``, build the AIRR record from the
replayed outcome, and assert all eight paired-end fields +
the full ``sequence`` round-trip bit-for-bit. The variable
insert size pins that the per-seed sampled values flow
through the trace into the AIRR projection identically on
replay.
"""
from GenAIRR._airr_record import outcome_to_airr_record
exp = (
ga.Experiment.on("human_igh")
.recombine()
.productive_only()
.mutate(rate=0.01)
.paired_end(r1_length=80, insert_size=(150, 280))
)
refdata = exp.refdata
compiled = exp.compile()
seen_insert_sizes = set()
for seed in range(8):
fresh = compiled.simulator.run(seed=seed)
tf = compiled.simulator.trace_file_from(fresh, seed=seed)
replayed = compiled.simulator.rerun_from_trace_file(tf)
fresh_rec = outcome_to_airr_record(
fresh, refdata, sequence_id=f"fresh-{seed}"
)
replayed_rec = outcome_to_airr_record(
replayed, refdata, sequence_id=f"replay-{seed}"
)
for field in (
"read_layout",
"r1_sequence",
"r2_sequence",
"r1_start",
"r1_end",
"r2_start",
"r2_end",
"insert_size",
"sequence",
):
assert fresh_rec[field] == replayed_rec[field], (
f"seed {seed}: paired-end field {field!r} desynced under "
f"replay ({fresh_rec[field]!r} vs {replayed_rec[field]!r})"
)
seen_insert_sizes.add(fresh_rec["insert_size"])
# And the replayed record must pass the validator unchanged
# — none of the five paired-end issue variants surface.
replayed_issues = replayed.validate_record(
refdata, sequence_id=f"replay-{seed}"
)
kinds = {issue["kind"] for issue in replayed_issues}
for forbidden in (
"PairedEndFieldWithoutLayout",
"ReadWindowOutOfBounds",
"ReadSequenceMismatch",
"ReadInsertSizeMismatch",
"ReadLayoutMismatch",
):
assert forbidden not in kinds, (
f"seed {seed}: replayed record tripped {forbidden}"
)
# Sanity: across 8 seeds we must observe at least two distinct
# insert sizes — otherwise the test isn't exercising the
# variable-insert path and a regression that froze the
# distribution would slip through.
assert len(seen_insert_sizes) >= 2, (
f"variable-insert test only saw one distinct insert size "
f"across 8 seeds: {seen_insert_sizes}"
)
# ──────────────────────────────────────────────────────────────────
# Targeted SHM (per-segment rate scalars) — release-tier consolidation
#
# Closes the per-segment SHM rates slice the same way D inversion /
# receptor revision / paired-end closed: a productive IGH full-stack
# run with realistic kwargs + replay round-trip + the zero-rate
# exclusion invariant. See ``docs/shm_segment_rate_design.md`` (audit)
# and ``tests/test_segment_rates_implementation.py`` (slice spec
# tests).
# ──────────────────────────────────────────────────────────────────
def test_productive_igh_full_stack_with_segment_rates_validates_all_records():
"""Productive IGH + every corruption stage + ``invert_d`` +
``receptor_revision`` + ``paired_end`` with a realistic
non-default ``segment_rates`` vector.
Exercises the entire targeted-SHM code path under contracts
that are most likely to interact with segment-rate filtering
(productive_only + heavy SHM rate). Asserts both the AIRR
validator and the live-call cache parity layer run clean —
same two-gate posture the d_inversion / receptor_revision /
paired_end stacks already use."""
exp = (
ga.Experiment.on("human_igh")
.recombine()
.invert_d(prob=0.3)
.receptor_revision(prob=0.3)
.productive_only()
.mutate(
model="s5f",
rate=0.03,
segment_rates={"V": 1.0, "D": 0.2, "J": 0.5, "NP": 0.0},
)
.pcr_amplify(rate=1e-4)
.polymerase_indels(count=2)
.primer_trim_5prime(length=(0, 3))
.primer_trim_3prime(length=(0, 3))
.paired_end(r1_length=150, insert_size=300)
)
refdata = exp.refdata
result = exp.run_records(n=50, seed=4242)
# Gate 1: AIRR validator. Every postcondition runs clean.
report = result.validate_records(refdata)
assert report, (
f"targeted-SHM productive IGH validation failed: "
f"summary={report.summary()}; first failure="
f"{report.failures[0] if report.failures else None}"
)
# Gate 2: live-call cache parity on every outcome.
assert result.outcomes is not None
for i, outcome in enumerate(result.outcomes):
for p in outcome.check_live_call_cache_parity(refdata):
assert p["tie_set_matches"], (
f"targeted-SHM record {i} segment {p['segment']}: "
f"cache parity failed — cached={p['cached_tie_set']} "
f"fresh={p['fresh_tie_set']}"
)
# Paired-end fields populated on every record (smoke check that
# the full stack ran through to the last pass). The downstream
# corruption passes (polymerase indels, end-loss) can flip
# ``productive`` to False on some records even when
# ``productive_only()`` constrained the sampling — that's the
# documented behaviour shared by the d_inversion and
# receptor_revision release tests. The two-gate validator is
# the real correctness check.
for rec in result.records:
assert rec["r1_sequence"], "paired-end r1_sequence empty"
assert rec["r2_sequence"], "paired-end r2_sequence empty"
def test_segment_rates_trace_replay_round_trip_passes_validator():
"""Replay determinism with non-default ``segment_rates`` in the
chain.
For each fresh outcome: build a TraceFile, rerun via
``rerun_from_trace_file``, project to AIRR and assert the
replayed record (a) reproduces the assembled sequence and
``n_mutations`` exactly, (b) reproduces the live calls and
junction fields, and (c) passes the per-record validator.
Uses a mixed-rate vector (V/D/J non-zero, NP zero) so the
replay path's zero-rate validation actually has work to do —
a recorded site that fell in V must still validate under the
same rate vector at replay time.
"""
from GenAIRR._airr_record import outcome_to_airr_record
exp = (
ga.Experiment.on("human_igh")
.recombine()
.productive_only()
.mutate(
model="s5f",
rate=0.03,
segment_rates={"V": 1.0, "D": 0.5, "J": 0.5, "NP": 0.0},
)
)
refdata = exp.refdata
compiled = exp.compile()
seen_any_mutations = False
for seed in range(8):
fresh = compiled.simulator.run(seed=seed)
tf = compiled.simulator.trace_file_from(fresh, seed=seed)
replayed = compiled.simulator.rerun_from_trace_file(tf)
fresh_rec = outcome_to_airr_record(
fresh, refdata, sequence_id=f"fresh-{seed}"
)
replayed_rec = outcome_to_airr_record(
replayed, refdata, sequence_id=f"replay-{seed}"
)
# Sequence + n_mutations round-trip.
assert fresh_rec["sequence"] == replayed_rec["sequence"], (
f"seed {seed}: assembled sequence diverged under replay"
)
assert fresh_rec["n_mutations"] == replayed_rec["n_mutations"], (
f"seed {seed}: n_mutations desynced "
f"({fresh_rec['n_mutations']} vs {replayed_rec['n_mutations']})"
)
# Live calls + junction also round-trip — the segment-rate
# replay path doesn't disturb projection.
for field in ("v_call", "d_call", "j_call", "junction", "junction_aa"):
assert fresh_rec[field] == replayed_rec[field], (
f"seed {seed} field {field!r}: replay diverged "
f"({fresh_rec[field]!r} vs {replayed_rec[field]!r})"
)
if fresh_rec["n_mutations"] > 0:
seen_any_mutations = True
# Replayed record passes the per-record validator.
replayed_issues = replayed.validate_record(
refdata, sequence_id=f"replay-{seed}"
)
assert not replayed_issues, (
f"seed {seed}: replayed record carries unexpected issues "
f"{[i.get('kind') for i in replayed_issues]}"
)
# The sweep must actually exercise SHM (otherwise the replay
# path's segment-rate validation never runs).
assert seen_any_mutations, (
"no SHM mutations observed across 8 seeds — replay sweep "
"isn't exercising the segment-rate validation path."
)
def test_segment_rates_zero_rate_exclusion_invariant_full_stack():
"""**Zero-rate exclusion invariant** — the load-bearing
distribution check for the segment-rates slice. Across N
records of a productive-IGH full stack with
``segment_rates={"D": 0, "J": 0, "NP": 0}``, every mutated
site must lie within the V region. Any single mutated base
outside V is a support-leak bug; the test fails immediately
on the first offender so the diagnostic points at the
specific record + segment that leaked.
Uses the AIRR record's V/D/J/J coordinate fields to assign
each mutated position to a segment. Comparing the
no-mutation baseline (same seed, same recombination) to the
mutated batch lets us identify which positions changed; the
segment classification flags zero-rate leaks."""
n = 50
seed = 8181
base_exp = (
ga.Experiment.on("human_igh").recombine().productive_only()
)
targeted_exp = (
ga.Experiment.on("human_igh")
.recombine()
.productive_only()
.mutate(
model="s5f",
count=25,
segment_rates={"V": 1.0, "D": 0.0, "J": 0.0, "NP": 0.0},
)
)
base = base_exp.run_records(n=n, seed=seed)
targeted = targeted_exp.run_records(n=n, seed=seed)
saw_any_mutation = False
for i, (base_rec, mut_rec) in enumerate(zip(base, targeted)):
b_seq = base_rec["sequence"].upper()
m_seq = mut_rec["sequence"].upper()
if b_seq == m_seq:
# Same seed gave zero realised mutations on this record
# (rare but possible with constraint filtering); nothing
# to classify.
continue
# Per-record segment boundaries.
v_end = mut_rec["v_sequence_end"]
d_start = mut_rec["d_sequence_start"]
d_end = mut_rec["d_sequence_end"]
j_start = mut_rec["j_sequence_start"]
# Sequences must be the same length — segment_rates doesn't
# change pool length under SHM (substitutions only).
assert len(b_seq) == len(m_seq), (
f"record {i}: sequence length changed under SHM "
f"(base={len(b_seq)}, mutated={len(m_seq)}); SHM should "
"only substitute, not insert / delete."
)
for pos, (bb, mb) in enumerate(zip(b_seq, m_seq)):
if bb == mb:
continue
saw_any_mutation = True
if pos < v_end:
segment_label = "V"
elif pos < d_start:
segment_label = "NP1"
elif pos < d_end:
segment_label = "D"
elif pos < j_start:
segment_label = "NP2"
else:
segment_label = "J"
assert segment_label == "V", (
f"record {i} pos {pos} (segment {segment_label}): "
"mutation landed outside V despite "
"segment_rates={V:1, D:0, J:0, NP:0}. "
"Zero-rate exclusion broke."
)
# The invariant only has bite when the sweep actually exercises
# SHM on at least one record.
assert saw_any_mutation, (
"targeted SHM sweep produced zero mutations across 50 records; "
"test isn't exercising the path."
)
# ──────────────────────────────────────────────────────────────────
# Mutation provenance counters — release-tier consolidation
#
# Closes the per-segment SHM counter slice with the same closure
# standard as targeted SHM: full-stack IGH + non-default
# segment_rates + sum-invariant cross-check + corruption isolation.
# See ``docs/mutation_provenance_audit.md`` for the architecture
# contract and ``tests/test_per_segment_mutation_counters.py`` for
# the slice spec tests.
# ──────────────────────────────────────────────────────────────────
def test_per_segment_mutation_counters_full_stack_validates_and_partitions():
"""Productive IGH full stack with non-default ``segment_rates``
+ heavy corruption. Three load-bearing assertions:
1. ``validate_records(refdata)`` runs clean — the validator's
re-derived per-segment counts agree with the engine's by
construction.
2. **Sum invariant** ``n_v + n_d + n_j + n_np == n_mutations``
holds for every record. The audit's headline claim.
3. **Corruption isolation**: ``n_pcr_errors`` /
``n_quality_errors`` are populated (heavy corruption ran)
while the four per-segment SHM counters reflect biological
SHM only — the pass-name filter excludes PCR / quality
``BaseChanged`` events from the per-segment buckets.
"""
exp = (
ga.Experiment.on("human_igh")
.recombine()
.invert_d(prob=0.3)
.receptor_revision(prob=0.3)
.productive_only()
.mutate(
model="s5f",
count=20,
segment_rates={"V": 1.0, "D": 0.4, "J": 0.6, "NP": 0.2},
)
.pcr_amplify(count=10)
.sequencing_errors(count=8)
.polymerase_indels(count=2)