468 lines
18 KiB
Python
468 lines
18 KiB
Python
"""Быстрые проверки Lab046: фрагментация большого объекта и fail-closed сборка.
|
||
|
||
Тесты не трогают оборудование, не создают файлов и не запускают RF. Полный
|
||
тракт PHY (кадр Lab045 -> канал -> приём -> сборка) проверяется на маленьких
|
||
объектах, чтобы оставаться быстрым.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import inspect
|
||
import struct
|
||
|
||
import numpy as np
|
||
|
||
from experiments import lab046_bpsk_object_transport as lab046
|
||
from protocol import image_fragments as fragments
|
||
|
||
|
||
# ------------------------------------------------------------ Детерминированный объект
|
||
|
||
|
||
def test_deterministic_object_is_reproducible_and_hashable() -> None:
|
||
first = lab046.make_deterministic_object(4096, seed=46)
|
||
second = lab046.make_deterministic_object(4096, seed=46)
|
||
assert first == second
|
||
assert len(first) == 4096
|
||
other = lab046.make_deterministic_object(4096, seed=47)
|
||
assert other != first
|
||
assert len(lab046.object_sha256(first)) == 64
|
||
|
||
|
||
# ------------------------------------------------------------ Фрагмент туда-обратно
|
||
|
||
|
||
def test_fragment_round_trip_exact_without_channel() -> None:
|
||
obj = lab046.make_deterministic_object(3000, seed=1)
|
||
pieces = fragments.split_image_bytes(obj, lab046.DEFAULT_OBJECT_ID, lab046.FRAGMENT_DATA_SIZE)
|
||
assert fragments.reassemble_image(pieces) == obj
|
||
|
||
|
||
def test_last_short_fragment_recovered_by_header_length() -> None:
|
||
# 3000 не кратно 244, значит последний фрагмент короче.
|
||
obj = lab046.make_deterministic_object(3000, seed=2)
|
||
pieces = fragments.split_image_bytes(obj, lab046.DEFAULT_OBJECT_ID, lab046.FRAGMENT_DATA_SIZE)
|
||
last = pieces[-1]
|
||
assert len(last.data) < lab046.FRAGMENT_DATA_SIZE
|
||
padded = lab046._pad_payload_to_packet(fragments.encode_image_fragment(last))
|
||
assert len(padded) == lab046.PACKET_PAYLOAD_SIZE
|
||
recovered = lab046.recover_fragment_from_payload(padded)
|
||
assert recovered is not None
|
||
assert recovered.data == last.data
|
||
|
||
|
||
def _make_payload(object_id: int, index: int, count: int, original_size: int, data: bytes) -> bytes:
|
||
header = struct.pack(
|
||
fragments.FRAGMENT_HEADER_FORMAT,
|
||
object_id,
|
||
index,
|
||
count,
|
||
original_size,
|
||
)
|
||
return lab046._pad_payload_to_packet(header + data)
|
||
|
||
|
||
# ------------------------------------------------------------ recover: fail-closed
|
||
|
||
|
||
def test_recover_rejects_truncated_metadata() -> None:
|
||
# Короче заголовка -> декод не проходит, объект не собирается.
|
||
assert lab046.recover_fragment_from_payload(b"\x00\x03") is None
|
||
|
||
|
||
def test_recover_rejects_impossible_index() -> None:
|
||
payload = _make_payload(lab046.DEFAULT_OBJECT_ID, index=5, count=3, original_size=600, data=b"\x01" * 200)
|
||
assert lab046.recover_fragment_from_payload(payload) is None
|
||
|
||
|
||
def test_recover_rejects_inconsistent_count_and_size() -> None:
|
||
# count=1, но original_size больше одного фрагмента -> несогласовано.
|
||
payload = _make_payload(lab046.DEFAULT_OBJECT_ID, index=0, count=1, original_size=10_000, data=b"\x01" * 244)
|
||
assert lab046.recover_fragment_from_payload(payload) is None
|
||
|
||
|
||
def test_recover_rejects_absurd_original_size_without_huge_alloc() -> None:
|
||
payload = _make_payload(lab046.DEFAULT_OBJECT_ID, index=0, count=2, original_size=2**31, data=b"\x01" * 244)
|
||
assert lab046.recover_fragment_from_payload(payload) is None
|
||
|
||
|
||
def test_expected_length_returns_none_on_bad_fields() -> None:
|
||
assert lab046.expected_fragment_data_length(0, 0, 100) is None
|
||
assert lab046.expected_fragment_data_length(-1, 3, 100) is None
|
||
assert lab046.expected_fragment_data_length(0, 1, 0) is None
|
||
|
||
|
||
# ------------------------------------------------------------ Сборщик: fail-closed
|
||
|
||
|
||
def test_assembler_rejects_foreign_object_id() -> None:
|
||
obj = lab046.make_deterministic_object(600, seed=3)
|
||
pieces = fragments.split_image_bytes(obj, lab046.DEFAULT_OBJECT_ID, lab046.FRAGMENT_DATA_SIZE)
|
||
assembler = lab046.ObjectAssembler()
|
||
assert assembler.offer(pieces[0]) == "accepted"
|
||
foreign = fragments.ImageFragment(
|
||
image_id=0xDEADBEEF,
|
||
fragment_index=pieces[1].fragment_index,
|
||
fragment_count=pieces[1].fragment_count,
|
||
original_size=pieces[1].original_size,
|
||
data=pieces[1].data,
|
||
)
|
||
assert assembler.offer(foreign) == "rejected"
|
||
assert not assembler.object_complete()
|
||
|
||
|
||
def test_assembler_identical_duplicate_ignored_conflicting_rejected() -> None:
|
||
obj = lab046.make_deterministic_object(600, seed=4)
|
||
pieces = fragments.split_image_bytes(obj, lab046.DEFAULT_OBJECT_ID, lab046.FRAGMENT_DATA_SIZE)
|
||
assembler = lab046.ObjectAssembler()
|
||
assert assembler.offer(pieces[0]) == "accepted"
|
||
assert assembler.offer(pieces[0]) == "duplicate"
|
||
conflicting = fragments.ImageFragment(
|
||
image_id=pieces[0].image_id,
|
||
fragment_index=pieces[0].fragment_index,
|
||
fragment_count=pieces[0].fragment_count,
|
||
original_size=pieces[0].original_size,
|
||
data=bytes(len(pieces[0].data)),
|
||
)
|
||
assert assembler.offer(conflicting) == "conflicting"
|
||
|
||
|
||
def test_assembler_none_is_rejected_not_raised() -> None:
|
||
assembler = lab046.ObjectAssembler()
|
||
assert assembler.offer(None) == "rejected"
|
||
assert assembler.recovered_object() is None
|
||
|
||
|
||
def test_assembler_incomplete_returns_no_object() -> None:
|
||
obj = lab046.make_deterministic_object(600, seed=5)
|
||
pieces = fragments.split_image_bytes(obj, lab046.DEFAULT_OBJECT_ID, lab046.FRAGMENT_DATA_SIZE)
|
||
assembler = lab046.ObjectAssembler()
|
||
assembler.offer(pieces[0]) # намеренно не все
|
||
assert not assembler.object_complete()
|
||
assert assembler.recovered_object() is None
|
||
|
||
|
||
# ------------------------------------------------------------ Полный тракт PHY: сценарии
|
||
|
||
|
||
def test_scenario_a_ideal_channel_exact_object() -> None:
|
||
obj = lab046.make_deterministic_object(2048, seed=46)
|
||
result = lab046.run_object_transport(obj, channel=lab046.ideal_channel)
|
||
assert result.object_complete
|
||
assert result.sha_match
|
||
assert result.object_byte_errors == 0
|
||
assert result.crc_fail_count == 0
|
||
assert result.recovered_object_sha256 == result.object_sha256
|
||
|
||
|
||
def test_scenario_b_distributed_bit_errors_corrected() -> None:
|
||
# Честный гауссов шум: до FEC ошибки есть, после FEC ноль, объект точен.
|
||
obj = lab046.make_deterministic_object(2048, seed=46)
|
||
channel = lab046.make_noisy_channel(noise_sigma=0.40, base_seed=46000)
|
||
result = lab046.run_object_transport(obj, channel=channel)
|
||
assert result.pre_fec_coded_bit_errors > 0
|
||
assert result.post_fec_protocol_bit_errors == 0
|
||
assert result.crc_fail_count == 0
|
||
assert result.object_complete
|
||
assert result.sha_match
|
||
assert result.object_byte_errors == 0
|
||
|
||
|
||
def test_scenario_c_out_of_order_delivery_still_exact() -> None:
|
||
obj = lab046.make_deterministic_object(2048, seed=46)
|
||
frames = lab046.build_object_frames(obj)
|
||
order = list(reversed(range(len(frames))))
|
||
result = lab046.run_object_transport(obj, channel=lab046.ideal_channel, delivery_order=order)
|
||
assert result.object_complete
|
||
assert result.sha_match
|
||
|
||
|
||
def test_scenario_d_duplicate_fragments_still_exact() -> None:
|
||
obj = lab046.make_deterministic_object(2048, seed=46)
|
||
result = lab046.run_object_transport(
|
||
obj,
|
||
channel=lab046.ideal_channel,
|
||
duplicate_indexes=(0, 2, 2),
|
||
)
|
||
assert result.duplicates >= 1
|
||
assert result.object_complete
|
||
assert result.sha_match
|
||
|
||
|
||
def test_scenario_e_missing_fragment_means_not_complete() -> None:
|
||
obj = lab046.make_deterministic_object(2048, seed=46)
|
||
result = lab046.run_object_transport(
|
||
obj,
|
||
channel=lab046.ideal_channel,
|
||
drop_indexes=(1,),
|
||
)
|
||
assert not result.object_complete
|
||
assert 1 in result.missing_indexes
|
||
assert result.recovered_object_sha256 is None
|
||
assert not result.sha_match
|
||
|
||
|
||
def test_scenario_f_corrupted_packet_rejected_no_fake_object() -> None:
|
||
obj = lab046.make_deterministic_object(2048, seed=46)
|
||
result = lab046.run_object_transport(
|
||
obj,
|
||
channel=lab046.ideal_channel,
|
||
corrupt_indexes=(0,),
|
||
)
|
||
assert result.crc_fail_count >= 1
|
||
assert not result.object_complete
|
||
assert result.recovered_object_sha256 is None
|
||
|
||
|
||
# ------------------------------------------------------------ Метрики кадра
|
||
|
||
|
||
def test_object_metrics_match_lab045_frame() -> None:
|
||
obj = lab046.make_deterministic_object(16384, seed=46)
|
||
frames = lab046.build_object_frames(obj)
|
||
assert len(frames) == 68 # ceil(16384 / 244)
|
||
assert all(f.plan.frame_symbol_count == 5724 for f in frames)
|
||
assert all(len(f.plan.packet) == 268 for f in frames)
|
||
|
||
|
||
# ------------------------------------------------------------ Нет оракула
|
||
|
||
|
||
def test_receiver_ignores_original_object_on_corruption() -> None:
|
||
# Если бы приёмник подглядывал в оригинал, повреждённый кадр «восстановился»
|
||
# бы. Проверяем, что этого не происходит: объект остаётся неполным.
|
||
obj = lab046.make_deterministic_object(2048, seed=46)
|
||
result = lab046.run_object_transport(
|
||
obj,
|
||
channel=lab046.ideal_channel,
|
||
corrupt_indexes=tuple(range(len(lab046.build_object_frames(obj)))),
|
||
)
|
||
assert result.crc_pass_count == 0
|
||
assert not result.object_complete
|
||
assert result.recovered_object_sha256 is None
|
||
|
||
|
||
def test_functional_self_check_runs() -> None:
|
||
lab046.run_functional_tests()
|
||
|
||
|
||
# ------------------------------------------------------------ Lab046A: continuous stream
|
||
|
||
|
||
def _small_continuous_stream(
|
||
*,
|
||
leading: int = 0,
|
||
gaps: dict[int, int] | None = None,
|
||
trailing: int = 0,
|
||
) -> tuple[bytes, lab046.ContinuousSoftStreamPlan]:
|
||
obj = lab046.make_deterministic_object(300, seed=460)
|
||
plan = lab046.build_continuous_soft_stream(
|
||
lab046.build_object_frames(obj),
|
||
leading_background_symbols=leading,
|
||
inter_frame_background=gaps,
|
||
trailing_background_symbols=trailing,
|
||
seed=461,
|
||
)
|
||
return obj, plan
|
||
|
||
|
||
def test_two_sequential_frames_found_without_known_offsets() -> None:
|
||
obj, stream_plan = _small_continuous_stream()
|
||
received = lab046.scan_continuous_soft_stream(stream_plan.soft_symbols)
|
||
assert received.valid_frame_count == 2
|
||
assert received.recovered_object == obj
|
||
# Сравнение с TX offsets разрешено только здесь, после независимого receive.
|
||
assert received.candidate_starts == stream_plan.frame_starts
|
||
|
||
|
||
def test_many_sequential_frames_are_found() -> None:
|
||
obj = lab046.make_deterministic_object(244 * 10, seed=462)
|
||
result = lab046.run_continuous_object_transport(obj)
|
||
assert result.receive.valid_frame_count == 10
|
||
assert result.receive.preamble_candidates == 10
|
||
assert result.sha_match
|
||
|
||
|
||
def test_first_frame_need_not_start_at_zero() -> None:
|
||
obj, stream_plan = _small_continuous_stream(leading=37)
|
||
received = lab046.scan_continuous_soft_stream(stream_plan.soft_symbols)
|
||
assert received.candidate_starts[0] == 37
|
||
assert received.recovered_object == obj
|
||
|
||
|
||
def test_trailing_noise_is_ignored() -> None:
|
||
obj, stream_plan = _small_continuous_stream(trailing=43)
|
||
received = lab046.scan_continuous_soft_stream(stream_plan.soft_symbols)
|
||
assert received.valid_frame_count == 2
|
||
assert received.preamble_candidates == 2
|
||
assert received.recovered_object == obj
|
||
|
||
|
||
def test_inter_frame_background_does_not_hide_next_frame() -> None:
|
||
obj, stream_plan = _small_continuous_stream(gaps={0: 19})
|
||
received = lab046.scan_continuous_soft_stream(stream_plan.soft_symbols)
|
||
assert received.candidate_starts[1] - received.candidate_starts[0] == 5724 + 19
|
||
assert received.recovered_object == obj
|
||
|
||
|
||
def test_same_frame_is_not_decoded_twice() -> None:
|
||
_, stream_plan = _small_continuous_stream()
|
||
received = lab046.scan_continuous_soft_stream(stream_plan.soft_symbols)
|
||
assert len(received.candidate_starts) == len(set(received.candidate_starts))
|
||
assert received.preamble_candidates == received.valid_frame_count == 2
|
||
|
||
|
||
def test_malformed_candidate_rejected_and_later_good_frame_found() -> None:
|
||
obj = lab046.make_deterministic_object(300, seed=463)
|
||
result = lab046.run_continuous_object_transport(
|
||
obj,
|
||
prepend_malformed_candidate=True,
|
||
)
|
||
assert result.receive.rejected_candidates > 0
|
||
assert result.receive.valid_frame_count == 2
|
||
assert result.receive.candidate_starts[0] == 0
|
||
assert result.sha_match
|
||
|
||
|
||
def test_stronger_false_peak_does_not_delete_overlapping_later_frame() -> None:
|
||
obj = lab046.make_deterministic_object(100, seed=999)
|
||
frame = lab046.build_object_frames(obj)[0]
|
||
preamble = lab046._bits_to_float32_soft(lab046.lab045.lab044.preamble_bits())
|
||
true_frame = lab046._bits_to_float32_soft(frame.plan.frame_bits)
|
||
true_start = 508 # четыре периода PRBS7 после ложного старта
|
||
stream = np.zeros(true_start + true_frame.size, dtype=np.float32)
|
||
stream[:preamble.size] = preamble
|
||
stream[true_start:true_start + true_frame.size] = true_frame
|
||
stream[preamble.size:true_start + preamble.size] *= np.float32(0.7)
|
||
|
||
scores = lab046._normalized_soft_preamble_correlation(stream)
|
||
assert scores[0] > scores[true_start]
|
||
received = lab046.scan_continuous_soft_stream(stream)
|
||
assert received.rejected_candidates > 0
|
||
assert true_start in received.candidate_starts
|
||
assert received.valid_frame_count == 1
|
||
assert received.recovered_object == obj
|
||
|
||
|
||
def test_validated_header_drives_candidate_body_length() -> None:
|
||
obj, stream_plan = _small_continuous_stream()
|
||
packet_bytes = lab046.lab045.MIN_PACKET_BYTES
|
||
coded_symbols = lab046.lab045.conv.encoded_bit_count(packet_bytes * 8)
|
||
prefix_bits = np.concatenate((
|
||
lab046.lab045.lab044.preamble_bits(),
|
||
lab046.lab045.encode_phy_header(packet_bytes),
|
||
np.zeros(coded_symbols, dtype=np.uint8),
|
||
))
|
||
stream = np.concatenate((
|
||
lab046._bits_to_float32_soft(prefix_bits),
|
||
stream_plan.soft_symbols,
|
||
))
|
||
received = lab046.scan_continuous_soft_stream(stream)
|
||
assert received.frame_receptions[0].packet_bytes == packet_bytes
|
||
assert received.frame_receptions[0].frame_end == len(prefix_bits)
|
||
assert received.rejected_candidates >= 1
|
||
assert received.recovered_object == obj
|
||
|
||
|
||
def test_cursor_is_strictly_monotonic() -> None:
|
||
_, stream_plan = _small_continuous_stream(leading=7, gaps={0: 13}, trailing=5)
|
||
received = lab046.scan_continuous_soft_stream(stream_plan.soft_symbols)
|
||
assert all(
|
||
later > earlier
|
||
for earlier, later in zip(received.cursor_history, received.cursor_history[1:])
|
||
)
|
||
|
||
|
||
def test_max_search_iterations_is_fail_closed() -> None:
|
||
_, stream_plan = _small_continuous_stream()
|
||
received = lab046.scan_continuous_soft_stream(
|
||
stream_plan.soft_symbols,
|
||
max_search_iterations=1,
|
||
)
|
||
assert received.max_search_iterations_reached
|
||
assert received.termination_reason == "MAX_SEARCH_ITERATIONS_REACHED"
|
||
assert received.valid_frame_count == 1
|
||
assert not received.object_complete
|
||
|
||
|
||
def test_max_frames_is_fail_closed() -> None:
|
||
_, stream_plan = _small_continuous_stream()
|
||
received = lab046.scan_continuous_soft_stream(
|
||
stream_plan.soft_symbols,
|
||
max_frames=1,
|
||
)
|
||
assert received.max_frames_reached
|
||
assert received.termination_reason == "MAX_FRAMES_REACHED"
|
||
assert received.valid_frame_count == 1
|
||
assert not received.object_complete
|
||
|
||
|
||
def test_continuous_ideal_16_kib_object_is_exact() -> None:
|
||
obj = lab046.make_deterministic_object()
|
||
result = lab046.run_continuous_object_transport(obj)
|
||
assert result.receive.preamble_candidates == 68
|
||
assert result.receive.valid_phy_headers == 68
|
||
assert result.receive.fec_pass_count == 68
|
||
assert result.receive.crc_pass_count == 68
|
||
assert result.receive.valid_fragments == 68
|
||
assert result.receive.object_complete
|
||
assert result.sha_match
|
||
assert result.object_byte_errors == 0
|
||
assert result.stream_plan.soft_symbols.dtype == np.float32
|
||
assert result.stream_plan.soft_symbols.nbytes < 2_000_000
|
||
|
||
|
||
def test_continuous_background_16_kib_object_is_exact() -> None:
|
||
obj = lab046.make_deterministic_object()
|
||
result = lab046.run_continuous_object_transport(
|
||
obj,
|
||
leading_background_symbols=37,
|
||
inter_frame_background={7: 11, 23: 19, 51: 7},
|
||
trailing_background_symbols=43,
|
||
seed=464,
|
||
)
|
||
assert result.stream_plan.leading_background_symbols == 37
|
||
assert result.stream_plan.inter_frame_background_symbols == 37
|
||
assert result.stream_plan.trailing_background_symbols == 43
|
||
assert result.receive.valid_frame_count == 68
|
||
assert result.receive.object_complete
|
||
assert result.sha_match
|
||
|
||
|
||
def test_continuous_noisy_16_kib_object_is_exact_after_fec() -> None:
|
||
obj = lab046.make_deterministic_object()
|
||
result = lab046.run_continuous_object_transport(
|
||
obj,
|
||
noise_sigma=0.40,
|
||
seed=46001,
|
||
)
|
||
assert result.receive.valid_frame_count == 68
|
||
assert result.pre_fec_coded_bit_errors > 0
|
||
assert result.post_fec_protocol_bit_errors == 0
|
||
assert result.receive.crc_pass_count == 68
|
||
assert result.receive.crc_fail_count == 0
|
||
assert result.receive.object_complete
|
||
assert result.sha_match
|
||
|
||
|
||
def test_continuous_receiver_has_no_tx_oracle_parameters(monkeypatch) -> None:
|
||
obj, stream_plan = _small_continuous_stream(leading=5)
|
||
parameters = set(inspect.signature(lab046.scan_continuous_soft_stream).parameters)
|
||
forbidden = {
|
||
"frame_plans",
|
||
"frame_starts",
|
||
"frame_count",
|
||
"fragment_indexes",
|
||
"packets",
|
||
"coded_bits",
|
||
"original_object",
|
||
}
|
||
assert parameters.isdisjoint(forbidden)
|
||
|
||
def forbidden_tx_lookup(*args, **kwargs):
|
||
raise AssertionError("scanner обратился к TX-side plan")
|
||
|
||
monkeypatch.setattr(lab046, "build_object_frames", forbidden_tx_lookup)
|
||
received = lab046.scan_continuous_soft_stream(stream_plan.soft_symbols)
|
||
assert received.recovered_object == obj
|