|
10 | 10 | # See the License for the specific language governing permissions and |
11 | 11 | # limitations under the License. |
12 | 12 |
|
| 13 | +import random |
13 | 14 | from time import time |
14 | 15 |
|
15 | 16 | from isovar.read_collector import ReadCollector |
|
25 | 26 | from pyensembl import ensembl_grch38 |
26 | 27 | from varcode import Variant |
27 | 28 |
|
28 | | -from .common import eq_ |
| 29 | +from .common import eq_ |
29 | 30 | from .testing_helpers import load_bam |
30 | 31 |
|
31 | 32 |
|
@@ -330,3 +331,94 @@ def test_assembly_1_sequence(): |
330 | 331 | suffix="GGG", |
331 | 332 | reads={"1"}) |
332 | 333 | eq_(iterative_overlap_assembly([vs]), [vs]) |
| 334 | + |
| 335 | + |
| 336 | +def test_greedy_merge_deterministic_across_input_orders(): |
| 337 | + """ |
| 338 | + Verify that greedy_merge produces the same assembled sequences |
| 339 | + regardless of input order. Regression test for #153, also covers #148. |
| 340 | + """ |
| 341 | + variant_sequences = [ |
| 342 | + VariantSequence(prefix="AAAAAA", alt="CC", suffix="TTTT", reads={"r1"}), |
| 343 | + VariantSequence(prefix="AAAA", alt="CC", suffix="TTTTGG", reads={"r2"}), |
| 344 | + VariantSequence(prefix="GGAAAAAA", alt="CC", suffix="TT", reads={"r3"}), |
| 345 | + VariantSequence(prefix="AAA", alt="CC", suffix="TTTTGGCC", reads={"r4"}), |
| 346 | + VariantSequence(prefix="CCGGAAAAAA", alt="CC", suffix="T", reads={"r5"}), |
| 347 | + ] |
| 348 | + |
| 349 | + reference_result = greedy_merge(variant_sequences, min_overlap_size=1) |
| 350 | + reference_sequences = sorted(r.sequence for r in reference_result) |
| 351 | + reference_all_reads = set().union(*(r.reads for r in reference_result)) |
| 352 | + |
| 353 | + rng = random.Random(42) |
| 354 | + for trial in range(20): |
| 355 | + shuffled = list(variant_sequences) |
| 356 | + rng.shuffle(shuffled) |
| 357 | + result = greedy_merge(shuffled, min_overlap_size=1) |
| 358 | + result_sequences = sorted(r.sequence for r in result) |
| 359 | + result_all_reads = set().union(*(r.reads for r in result)) |
| 360 | + assert result_sequences == reference_sequences, \ |
| 361 | + "Trial %d: different sequences from shuffled input.\nRef: %s\nGot: %s" % ( |
| 362 | + trial, reference_sequences, result_sequences) |
| 363 | + assert result_all_reads == reference_all_reads, \ |
| 364 | + "Trial %d: different reads from shuffled input.\nRef: %s\nGot: %s" % ( |
| 365 | + trial, reference_all_reads, result_all_reads) |
| 366 | + |
| 367 | + |
| 368 | +def test_greedy_merge_prefers_larger_overlap(): |
| 369 | + """ |
| 370 | + When a sequence could merge with two partners, the larger overlap |
| 371 | + should be chosen. This tests the sorting logic in greedy_merge_helper. |
| 372 | + """ |
| 373 | + # A overlaps B by 3 bases, A overlaps C by 6 bases. |
| 374 | + # A should merge with C (larger overlap) not B. |
| 375 | + vs_a = VariantSequence(prefix="AAAAAA", alt="X", suffix="TTTTTT", reads={"a"}) |
| 376 | + vs_b = VariantSequence(prefix="AAA", alt="X", suffix="TTTTTTGGGGGG", reads={"b"}) |
| 377 | + vs_c = VariantSequence(prefix="AAAAAA", alt="X", suffix="TTTTTTCCCCCC", reads={"c"}) |
| 378 | + |
| 379 | + result = greedy_merge([vs_a, vs_b, vs_c], min_overlap_size=1) |
| 380 | + |
| 381 | + # A+C should merge (overlap = 6+6+1=13 bases out of 13+13=26) |
| 382 | + # leaving B unmerged (or merging in a subsequent round if compatible) |
| 383 | + merged_reads = set() |
| 384 | + for r in result: |
| 385 | + merged_reads.update(r.reads) |
| 386 | + assert merged_reads == {"a", "b", "c"}, \ |
| 387 | + "All reads should be accounted for, got %s" % merged_reads |
| 388 | + |
| 389 | + # The longest assembled sequence should contain C's long suffix |
| 390 | + longest = max(result, key=len) |
| 391 | + assert "CCCCCC" in longest.sequence, \ |
| 392 | + "Expected the larger overlap (with C's CCCCCC suffix) to be preferred" |
| 393 | + |
| 394 | + |
| 395 | +def test_iterative_assembly_deterministic_across_input_orders(): |
| 396 | + """ |
| 397 | + End-to-end test: iterative_overlap_assembly should produce |
| 398 | + identical results regardless of input ordering. |
| 399 | + """ |
| 400 | + original_prefix = "ACTGAACCTTGG" |
| 401 | + original_allele = "CC" |
| 402 | + original_suffix = "GGAAGGAAGGAA" |
| 403 | + |
| 404 | + subsequences = [ |
| 405 | + VariantSequence( |
| 406 | + prefix=original_prefix[i:], |
| 407 | + alt=original_allele, |
| 408 | + suffix=original_suffix[:-j] if j > 0 else original_suffix, |
| 409 | + reads={str(i) + "_" + str(j)}) |
| 410 | + for i in range(6) |
| 411 | + for j in range(6) |
| 412 | + ] |
| 413 | + |
| 414 | + reference = iterative_overlap_assembly(subsequences, min_overlap_size=2) |
| 415 | + reference_sequences = sorted(r.sequence for r in reference) |
| 416 | + |
| 417 | + rng = random.Random(123) |
| 418 | + for trial in range(10): |
| 419 | + shuffled = list(subsequences) |
| 420 | + rng.shuffle(shuffled) |
| 421 | + result = iterative_overlap_assembly(shuffled, min_overlap_size=2) |
| 422 | + result_sequences = sorted(r.sequence for r in result) |
| 423 | + assert result_sequences == reference_sequences, \ |
| 424 | + "Trial %d: different assembly from shuffled input" % trial |
0 commit comments