From 85980e00ef9bea469a641b82c28836a8f5245bcf Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Wed, 8 Apr 2026 08:49:42 -0400 Subject: [PATCH 1/5] chore: add v0.1.5 changelog (post release) --- CHANGELOG.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e390d9..979790b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,24 @@ # Changelog +## 0.1.5 + +### Changed +- **Encode**: remove AF summary block — Click accepts multi-AF rungs without + it, eliminating complex gating logic +- **CSV**: consolidate tall-instruction padding into shared hydrate/dehydrate + helpers in converter and writer + +### Added +- **Devtools**: `combine_coverage.py` — combine random coverage fixtures into + a single multi-row rung for paste smoke-testing +- **Devtools**: `coverage_golden.py` now regenerates all bins unconditionally + ## 0.1.4 ### Fixed - **CSV**: preserve multi-row AF round-trips, including pinned/tall AF blocks away from row 0, multiple tall blocks in one rung, and generic tall continuation rows -- **Encode**: suppress AF summary data when a rung contains multi-row AFs, - avoiding grid misalignment and Click paste crashes - **SCR decode**: restore omitted wiring for generic tall AF continuation rows - **CSV writer**: fail loudly when emitted CSV rows lose decoded rung semantics by reparsing writer output and checking for row, condition, or AF From 1c873c28f6642cc155c075a93c6959da8677d0c7 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Wed, 8 Apr 2026 09:03:20 -0400 Subject: [PATCH 2/5] test(csv): add hydrate/dehydrate wire continuation tests and T-junction golden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add unit tests for _hydrate_wire_continuations (T → | propagation in non-A columns) and dehydrate→hydrate round-trip tests through the writer/converter pipeline. Add instr-copy-multirow-2af-T golden fixture for copy instructions with a T-junction wire. Co-Authored-By: Claude Opus 4.6 --- tests/csv/test_converter.py | 80 ++++++++++++++++++ tests/csv/test_writer.py | 59 +++++++++++++ .../golden/instr-copy-multirow-2af-T.bin | Bin 0 -> 16384 bytes .../golden/instr-copy-multirow-2af-T.csv | 4 + .../golden/verify_progress.log | 1 + 5 files changed, 144 insertions(+) create mode 100644 tests/fixtures/ladder_captures/golden/instr-copy-multirow-2af-T.bin create mode 100644 tests/fixtures/ladder_captures/golden/instr-copy-multirow-2af-T.csv diff --git a/tests/csv/test_converter.py b/tests/csv/test_converter.py index c46d77b..74a7ccc 100644 --- a/tests/csv/test_converter.py +++ b/tests/csv/test_converter.py @@ -19,6 +19,7 @@ from laddercodec.csv.contract import CONDITION_COLUMNS, CSV_HEADER from laddercodec.csv.converter import ( ConvertError, + _hydrate_wire_continuations, af_node_to_token, condition_node_to_token, convert_rung, @@ -660,3 +661,82 @@ def test_unsupported_af_with_pin_row_not_strict(self, tmp_path: Path) -> None: assert lr == 2 assert afs[0] == "" assert afs[1] == "" + + +class TestHydrateWireContinuations: + def test_pipe_added_under_T_non_A_column(self) -> None: + """T in column B propagates | to the blank cell below.""" + rows = [ + ["", "T"] + [""] * 29, + [""] * 31, + [""] * 31, + ] + _hydrate_wire_continuations(rows) + assert rows[1][1] == "|" + assert rows[2][1] == "" # last row never written to + + def test_pipe_propagates_multi_row(self) -> None: + """| from hydration continues propagating downward.""" + rows = [ + ["", "", "T"] + [""] * 28, + [""] * 31, + [""] * 31, + [""] * 31, + ] + _hydrate_wire_continuations(rows) + assert rows[1][2] == "|" + assert rows[2][2] == "|" # propagated from row 1's newly-added | + assert rows[3][2] == "" # last row untouched + + def test_does_not_overwrite_existing_content(self) -> None: + """Only blank cells get hydrated.""" + rows = [ + ["", "T"] + [""] * 29, + ["", "-"] + [""] * 29, + [""] * 31, + ] + _hydrate_wire_continuations(rows) + assert rows[1][1] == "-" # preserved, not overwritten + + def test_two_row_rung_is_noop(self) -> None: + """With only 2 rows, hydration does nothing (no safe target row).""" + rows = [ + ["", "T"] + [""] * 29, + [""] * 31, + ] + _hydrate_wire_continuations(rows) + assert rows[1][1] == "" # unchanged — last row, not eligible + + def test_existing_pipe_also_propagates(self) -> None: + """| in the source row also propagates downward, not just T.""" + rows = [ + ["", "|"] + [""] * 29, + [""] * 31, + [""] * 31, + ] + _hydrate_wire_continuations(rows) + assert rows[1][1] == "|" + + def test_timer_autopad_hydrates_T_wire(self, tmp_path: Path) -> None: + """Full converter round-trip: timer with T wire in non-A column.""" + csv_path = tmp_path / "main.csv" + row0 = [""] * len(CONDITION_COLUMNS) + row0[0] = "X001" + row0[1] = "T" + for i in range(2, len(CONDITION_COLUMNS)): + row0[i] = "-" + _write_csv( + csv_path, + [ + ("R", row0, "on_delay(T1,TD1,preset=1000,unit=Tms)"), + ("", _wire_row("X002"), "out(Y001)"), + ], + ) + + rung = parse_csv_file(csv_path).rungs[0] + lr, conds, afs, _ = convert_rung(rung) + # Timer auto-pads to 2 rows, plus the coil row = 3 total + assert lr == 3 + # T in col B (index 1) on row 0 should hydrate | on row 1 + assert conds[0][1] == "T" + assert conds[1][1] == "|" diff --git a/tests/csv/test_writer.py b/tests/csv/test_writer.py index cb39a67..6cec404 100644 --- a/tests/csv/test_writer.py +++ b/tests/csv/test_writer.py @@ -646,6 +646,65 @@ def test_later_tall_block_before_following_af_roundtrips( assert round_tripped.instructions == rung.instructions +class TestDehydrateHydrateWires: + def test_T_wire_pipe_dehydrate_hydrate_roundtrip(self, tmp_path: Path) -> None: + """Padding row with | under T is stripped by writer, restored by converter.""" + search = Search("DS72", "DS81", "DS71", "DS82", "C81", "==") + rung = Rung( + logical_rows=3, + conditions=[ + [Contact(InstructionType.CONTACT_NO, "C10"), "T"] + ["-"] * 29, + ["", "|"] + [""] * 29, + [Contact(InstructionType.CONTACT_NO, "C11")] + ["-"] * 30, + ], + instructions=[search, "", Coil(InstructionType.COIL_OUT, "Y001")], + comment_rtf=None, + comment=None, + ) + + # Writer dehydrates: padding row (only blank + |) is stripped + rows = decoded_rung_to_rows(rung) + assert len(rows) == 2 + assert rows[0][2] == "T" # T preserved in col B + + # Full round-trip: hydration restores | under T + out = tmp_path / "t-wire.csv" + write_csv(out, [rung]) + [rt] = read_csv(out) + + assert rt.logical_rows == 3 + assert rt.conditions[0][1] == "T" + assert rt.conditions[1][1] == "|" # hydrated back + + def test_multi_column_T_wires_roundtrip(self, tmp_path: Path) -> None: + """Multiple T wires in different non-A columns all hydrate correctly.""" + search = Search("DS72", "DS81", "DS71", "DS82", "C81", "==") + conds_row0 = [Contact(InstructionType.CONTACT_NO, "C20")] + ["-"] * 30 + conds_row0[5] = "T" # col F + conds_row0[15] = "T" # col P + rung = Rung( + logical_rows=3, + conditions=[ + conds_row0, + ["", "", "", "", "", "|"] + [""] * 9 + ["|"] + [""] * 15, + [Contact(InstructionType.CONTACT_NO, "C21")] + ["-"] * 30, + ], + instructions=[search, "", Coil(InstructionType.COIL_OUT, "Y002")], + comment_rtf=None, + comment=None, + ) + + out = tmp_path / "multi-t-wire.csv" + write_csv(out, [rung]) + [rt] = read_csv(out) + + assert rt.logical_rows == 3 + assert rt.conditions[0][5] == "T" + assert rt.conditions[0][15] == "T" + assert rt.conditions[1][5] == "|" # hydrated + assert rt.conditions[1][15] == "|" # hydrated + + class TestMultiPinnedRoundTrip: def test_count_up_then_retained_timer_roundtrip(self, tmp_path: Path) -> None: counter = Counter( diff --git a/tests/fixtures/ladder_captures/golden/instr-copy-multirow-2af-T.bin b/tests/fixtures/ladder_captures/golden/instr-copy-multirow-2af-T.bin new file mode 100644 index 0000000000000000000000000000000000000000..ee83ce885cc8154936a6cd4a85540a6a6004e12d GIT binary patch literal 16384 zcmeI2-EQ1e6h_bFr~E@xpcD!;0V%mdX;n(eRi%lL5GWu)?|RV+RkA2F!EUK|MV^9} zK!PjSYaiQbCSjD!x@#OP^DVoce*NY2tM&EuQodN8mYs61+$~*sSlW2K zSH3F`%Fi+1jU`=qRGyUmauDskXg@38##Z~~$EbgZHP_20F@F^6&!_+Q2<*kDx}Imj zPyJ`RUZ(MB-#x|Kn#4AL#2@n`(`0gI@=r|1Rvg)Cym#%@)sq)CizQ}e^c^rN&aogUzYqklD{JPcP0Ox z(c?1&hDRU_k3bk6fiOG*VR!_>@CbzA5eUO05Qaw}439t<9)U1C0%3Rr z!te-$;SmVKBM^p1APkQ{7#@KzJOW{O1j6tLgy9hg!y^!eM<5K3Ko}l@fM1Cq8?rQ- zIkB8+H|``ojXO=HT#eXYDU+q;JJDF(iI;M_+$^8QKiUAbeJk7S5F1$CA!ez{M}#!h z-y1`>e0Y8;bG!(Nug>X%$!O54Q7NwiIUD{WKn zH?&Q?Z_qaN`oC@Jb$r{@>+!a!*VS!PuaDcNUgx$=yUCh-)a$vn zsn=zjIYPYK%^aIr4WqI-j>_f^{iti{Nv-Hht>{gy=ufTaQLX4xt>{&)=vS@iS*_?> zt>_(9mm@SSH?po{)b(T3^<&iaW7PFy)b(T3^<&iaW7PFy)b(T3^<&iaW7PFy)b(T3 z^<(t=I-ejtIMEU72iBjP(L*+_9Zh21OKt{Fk@E$4*#F|$cOEF_GgB^x<7Au1&u~L? z|Ar_34Nv|Xp8PjF z`EPjg-|*zW;mLo)lmCV%{|!(68=m|(Jo&#UUvJQG*C}#5678({pIoqpC;#X18MLv@ z%#@4ac<=N08E$BfJgni#f5VgihA00GPyQR8{5L%LZ+P|Ar_34Nv|X zp8PjF`EPjg-|*zW;mLo)lmCV%{|!(68=m|(Jo#^U^55{}zv0P$!;}ApC;u1a$u1`5 z@CbzA5eUO05Qaw};Flsu^F~6{!!+a{0&q}%i^UsTq$0mr zPwtb}*Q0uxRVlybe0Z+pS- Date: Wed, 8 Apr 2026 12:31:12 -0400 Subject: [PATCH 3/5] fix(csv): stop hydrating vertical wires past horizontal snap points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wire continuation hydration pass blindly propagated `|` downward through every blank cell below a `T` or `|`. When the row below had a horizontal wire path from the left (e.g. a contact at C-1), the vertical wire should terminate — Click auto-snaps the horizontal path to the junction above without an explicit `|`. Skip hydration at (R+1, C) when (R+1, C-1) carries a horizontal path (anything other than blank or `|`). Co-Authored-By: Claude Opus 4.6 --- src/laddercodec/csv/converter.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/laddercodec/csv/converter.py b/src/laddercodec/csv/converter.py index cb4162d..960c87f 100644 --- a/src/laddercodec/csv/converter.py +++ b/src/laddercodec/csv/converter.py @@ -131,7 +131,12 @@ def _hydrate_wire_continuations(condition_rows: list[list[ConditionToken]]) -> N After block finalization and auto-padding, padding rows are all-blank. This pass restores vertical wire continuity: wherever row *R* has a ``T`` (junction-down) or ``|`` (vertical pass-through), row *R+1* - gets a ``|`` if the cell is currently blank. + gets a ``|`` if the cell is currently blank — unless a horizontal wire + from the left already connects to that junction. + + A vertical wire terminates when ``(R+1, C-1)`` carries a horizontal + path (anything other than ``""`` or ``"|"``), because the horizontal + wire auto-snaps to the vertical endpoint above. Iterates top-to-bottom so multi-row vertical runs propagate correctly. Stops one row short of the last row — vertical tokens on the last row @@ -143,6 +148,11 @@ def _hydrate_wire_continuations(condition_rows: list[list[ConditionToken]]) -> N for col_idx in range(len(condition_rows[row_idx])): token = condition_rows[row_idx][col_idx] if token in ("T", "|") and condition_rows[row_idx + 1][col_idx] == "": + # Check if a horizontal wire from the left already connects here. + if col_idx > 0: + left = condition_rows[row_idx + 1][col_idx - 1] + if left not in ("", "|"): + continue # horizontal path snaps to vertical — no | needed condition_rows[row_idx + 1][col_idx] = cast(ConditionToken, "|") From aabd645796a93498429ab2eafbea66c3dd85bb66 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:06:37 -0400 Subject: [PATCH 4/5] fix(scr): replace backward/brute-force topology scanning with single forward pass The backward scan in _find_row_topology_block could match false positive topology blocks created by flag-table tail bytes coincidentally matching the _ROW_TOPOLOGY_PREFIX. This caused spurious 30-row rungs in some SCR files (e.g. Scr12.tmp StackLight). Replace the three scanning functions (_find_row_topology_block, _find_row_topology_blocks_between, _brute_force_topology_block) with a single _find_all_row_topology_blocks that forward-scans once, skipping past each block's full extent (continuation_start). Topology blocks are then assigned to sections by position, eliminating the backward scan entirely. Also removes unused _ROW_TOPOLOGY_FLAG_ENTRY_COUNTS constant. Co-Authored-By: Claude Opus 4.6 --- src/laddercodec/decode_program.py | 108 ++++++++++------------------ tests/ladder/test_decode_program.py | 88 ++++++++++++----------- 2 files changed, 82 insertions(+), 114 deletions(-) diff --git a/src/laddercodec/decode_program.py b/src/laddercodec/decode_program.py index 107c57f..c983601 100644 --- a/src/laddercodec/decode_program.py +++ b/src/laddercodec/decode_program.py @@ -44,7 +44,6 @@ _SCR_MAGIC = b"SC-SCR " _ROW_TOPOLOGY_PREFIX = b"\x03\x00\x00" -_ROW_TOPOLOGY_FLAG_ENTRY_COUNTS = frozenset({31, 32}) _ROW_TOPOLOGY_PRELUDE_LEN_RANGE = range(8, 17) _MAX_SECTION_INSTRUCTIONS = 512 @@ -548,34 +547,8 @@ def _forward_parse_topology_block( return None -def _brute_force_topology_block( - data: bytes, - pos: int, - row_word: int, - marker_scan_limit: int, -) -> _ScrRowTopologyBlock | None: - """Find best topology block by trying every candidate *flags_start*.""" - best_block: _ScrRowTopologyBlock | None = None - best_score: tuple[int, int] | None = None - flags_scan_limit = min(len(data), pos + 0x40) - - for flags_start in range(pos + 8, flags_scan_limit): - block = _try_topology_at_flags_start(data, pos, row_word, flags_start, marker_scan_limit) - if block is not None: - score = (block.row0_flag_count, -flags_start) - if best_score is None or score > best_score: - best_block = block - best_score = score - - return best_block - - def _parse_row_topology_block(data: bytes, pos: int) -> _ScrRowTopologyBlock | None: - """Parse a row-topology block anchored by the ordered row-0 flag table. - - Uses deterministic forward parsing through leading-row wire blocks, - falling back to brute-force scanning if forward parsing fails. - """ + """Parse a row-topology block via deterministic forward parsing.""" if pos < 0 or pos + min(_ROW_TOPOLOGY_PRELUDE_LEN_RANGE) > len(data): return None @@ -587,44 +560,26 @@ def _parse_row_topology_block(data: bytes, pos: int) -> _ScrRowTopologyBlock | N return None marker_scan_limit = min(len(data), pos + 0x800) + return _forward_parse_topology_block(data, pos, row_word, marker_scan_limit) - # Try deterministic forward parse first. - block = _forward_parse_topology_block(data, pos, row_word, marker_scan_limit) - if block is not None: - return block - # Brute-force fallback — should never trigger for well-formed data. - return _brute_force_topology_block(data, pos, row_word, marker_scan_limit) - - -def _find_row_topology_block( - data: bytes, pos: int, max_lookback: int = 4096 -) -> _ScrRowTopologyBlock | None: - """Find the nearest row-topology block before ``pos``.""" - start = max(0, pos - max_lookback) - for i in range(pos - min(_ROW_TOPOLOGY_PRELUDE_LEN_RANGE), start - 1, -1): - block = _parse_row_topology_block(data, i) - if block is not None: - return block - return None - - -def _find_row_topology_blocks_between( +def _find_all_row_topology_blocks( data: bytes, start: int, - end: int, ) -> list[_ScrRowTopologyBlock]: - """Find row-topology blocks whose starts fall within ``[start, end)``.""" + """Forward-scan all topology blocks from ``start`` to end of ``data``. + + Skips past each found block's full extent (``continuation_start``), + preventing false positives from flag-table tail bytes. + """ blocks: list[_ScrRowTopologyBlock] = [] pos = max(0, start) - last_start = -1 + end = len(data) while pos < end: block = _parse_row_topology_block(data, pos) - if block is not None and block.start < end: - if block.start != last_start: - blocks.append(block) - last_start = block.start - pos = block.flags_start + if block is not None: + blocks.append(block) + pos = block.continuation_start continue pos += 1 return blocks @@ -1181,13 +1136,22 @@ def decode_program(data: bytes) -> Program: if not sections: return Program(name=name, prog_idx=prog_idx, rungs=[]) + # Forward-scan all topology blocks once. Skipping past each block's + # full extent (continuation_start) prevents false positives from + # flag-table tail bytes that coincidentally match the prefix. + all_topo_blocks = _find_all_row_topology_blocks(data, data_start) + + # Assign topology blocks to sections by position. For each section, + # blocks whose start falls in [prev_sec_end, sec_off) belong to it: + # earlier ones are empty rungs, the last one is the section's header. + topo_idx = 0 + rungs: list[Rung] = [] prev_sec_end = data_start for sec_off, count, sec_end in sections: section_instrs = _parse_section_instructions(data, sec_off, count) inferred_rows = 1 - has_condition_cells = False for ( row, col, @@ -1200,8 +1164,6 @@ def decode_program(data: bytes) -> Program: variant_string_tags, ) in section_instrs: inferred_rows = max(inferred_rows, row + 1) - if col < _CONDITION_COLUMNS: - has_condition_cells = True if col == _CONDITION_COLUMNS: visual_rows = _infer_af_visual_rows( class_name, @@ -1214,11 +1176,18 @@ def decode_program(data: bytes) -> Program: ) inferred_rows = max(inferred_rows, row + visual_rows) - # Find nearest row-topology block before this section. - topology_block = _find_row_topology_block(data, sec_off) - headerless = topology_block is None or ( - topology_block.start < prev_sec_end and inferred_rows == 1 and not has_condition_cells - ) + # Collect topology blocks in [prev_sec_end, sec_off). + region_blocks: list[_ScrRowTopologyBlock] = [] + while topo_idx < len(all_topo_blocks) and all_topo_blocks[topo_idx].start < sec_off: + if all_topo_blocks[topo_idx].start >= prev_sec_end: + region_blocks.append(all_topo_blocks[topo_idx]) + topo_idx += 1 + + # Last block in the region (if any) is the section's topology header. + topology_block = region_blocks[-1] if region_blocks else None + empty_blocks = region_blocks[:-1] if region_blocks else [] + + headerless = topology_block is None if headerless: # Headerless rung (e.g. next() after for()) — single row, all wired rtf_bytes, comment = _find_rtf_comment(data, prev_sec_end, sec_off) @@ -1237,13 +1206,10 @@ def decode_program(data: bytes) -> Program: assert topology_block is not None comment_start = prev_sec_end - empty_topology_blocks = _find_row_topology_blocks_between( - data, prev_sec_end, topology_block.start - ) - for idx, empty_block in enumerate(empty_topology_blocks): + for idx, empty_block in enumerate(empty_blocks): empty_end = ( - empty_topology_blocks[idx + 1].start - if idx + 1 < len(empty_topology_blocks) + empty_blocks[idx + 1].start + if idx + 1 < len(empty_blocks) else topology_block.start ) empty_rtf_bytes, empty_comment = _find_rtf_comment( diff --git a/tests/ladder/test_decode_program.py b/tests/ladder/test_decode_program.py index 7e93d87..7715f20 100644 --- a/tests/ladder/test_decode_program.py +++ b/tests/ladder/test_decode_program.py @@ -8,7 +8,7 @@ from laddercodec.csv import read_csv from laddercodec.decode import inspect_cells from laddercodec.decode_program import ( - _find_row_topology_block, + _find_all_row_topology_blocks, _find_sections, _implied_modifier_row_offsets, _parse_extra_row_right_wires, @@ -38,6 +38,29 @@ _COL_IDX_BY_NAME = {name: idx for idx, name in _COL_NAMES.items()} +def _topology_blocks_by_section( + scr_data: bytes, +) -> dict[int, object]: + """Map section index to topology block using forward scanning.""" + _name, _prog_idx, data_start = _parse_header(scr_data) + sections = _find_sections(scr_data, start=data_start) + all_blocks = _find_all_row_topology_blocks(scr_data, data_start) + + result: dict[int, object] = {} + topo_idx = 0 + prev_sec_end = data_start + for sec_idx, (sec_off, _count, sec_end) in enumerate(sections): + last_block = None + while topo_idx < len(all_blocks) and all_blocks[topo_idx].start < sec_off: + if all_blocks[topo_idx].start >= prev_sec_end: + last_block = all_blocks[topo_idx] + topo_idx += 1 + if last_block is not None: + result[sec_idx] = last_block + prev_sec_end = sec_end + return result + + def _load_fixture_pair(name: str): scr_data = (_SCR_FIXTURE_DIR / f"{name}.scr").read_bytes() clip_data = (_SCR_FIXTURE_DIR / f"{name}.bin").read_bytes() @@ -516,13 +539,14 @@ def test_parse_extra_row_right_wires_matches_or_topology_clipboard_columns(): _name, _prog_idx, data_start = _parse_header(scr_data) sections = _find_sections(scr_data, start=data_start) + topo_map = _topology_blocks_by_section(scr_data) for rung_idx, clip_rung in enumerate(clip_rungs): if clip_rung.logical_rows < 2: continue sec_off, _count, _sec_end = sections[rung_idx] - block = _find_row_topology_block(scr_data, sec_off) + block = topo_map.get(rung_idx) assert block is not None extra_rows_right_wires, marker_pos = _parse_extra_row_right_wires( @@ -552,6 +576,7 @@ def test_continuation_row_next_seg_matches_successor_segment_flags(): _name, _prog_idx, data_start = _parse_header(scr_data) sections = _find_sections(scr_data, start=data_start) + topo_map = _topology_blocks_by_section(scr_data) saw_wrapped_order = False @@ -560,7 +585,7 @@ def test_continuation_row_next_seg_matches_successor_segment_flags(): continue sec_off, _count, _sec_end = sections[rung_idx] - block = _find_row_topology_block(scr_data, sec_off) + block = topo_map.get(rung_idx) assert block is not None pos = block.continuation_start @@ -701,13 +726,8 @@ def test_decode_program_keeps_comments_for_consecutive_empty_topology_rungs(monk ) monkeypatch.setattr( decode_program_module, - "_find_row_topology_block", - lambda data, pos: main_block, - ) - monkeypatch.setattr( - decode_program_module, - "_find_row_topology_blocks_between", - lambda data, start, end: [empty_block_1, empty_block_2], + "_find_all_row_topology_blocks", + lambda data, start: [empty_block_1, empty_block_2, main_block], ) def fake_find_rtf_comment(data, start, end): @@ -733,10 +753,11 @@ def test_parse_31_entry_row_topology_blocks_in_coverage_fixture(): _name, _prog_idx, data_start = _parse_header(scr_data) sections = _find_sections(scr_data, start=data_start) + topo_map = _topology_blocks_by_section(scr_data) for rung_idx in [58, 70, 71, 72, 75, 76]: sec_off, _count, _sec_end = sections[rung_idx] - block = _find_row_topology_block(scr_data, sec_off) + block = topo_map.get(rung_idx) assert block is not None assert block.row0_flag_count == 31 assert block.prelude.endswith(b"\x1f\x00") @@ -763,6 +784,7 @@ def test_counter_row_topology_blocks_capture_variable_preludes(): _name, _prog_idx, data_start = _parse_header(scr_data) sections = _find_sections(scr_data, start=data_start) + topo_map = _topology_blocks_by_section(scr_data) for rung_idx, expected_prelude, expected_rows in ( (2, bytes.fromhex("0400030000000000012000"), [[]]), @@ -770,7 +792,7 @@ def test_counter_row_topology_blocks_capture_variable_preludes(): ): sec_off, _count, _sec_end = sections[rung_idx] prev_sec_end = sections[rung_idx - 1][2] - block = _find_row_topology_block(scr_data, sec_off) + block = topo_map.get(rung_idx) assert block is not None assert block.start > prev_sec_end assert block.row_word == 4 @@ -792,11 +814,12 @@ def test_counter_count_down_with_row0_data_uses_local_sparse_topology_block(): _name, _prog_idx, data_start = _parse_header(scr_data) sections = _find_sections(scr_data, start=data_start) + topo_map = _topology_blocks_by_section(scr_data) rung_idx = 6 sec_off, _count, _sec_end = sections[rung_idx] prev_sec_end = sections[rung_idx - 1][2] - block = _find_row_topology_block(scr_data, sec_off) + block = topo_map.get(rung_idx) assert block is not None assert block.start == 0xA58 @@ -822,11 +845,12 @@ def test_counter_count_down_with_row0_and_row1_data_uses_two_local_sparse_leadin _name, _prog_idx, data_start = _parse_header(scr_data) sections = _find_sections(scr_data, start=data_start) + topo_map = _topology_blocks_by_section(scr_data) rung_idx = 7 sec_off, _count, _sec_end = sections[rung_idx] prev_sec_end = sections[rung_idx - 1][2] - block = _find_row_topology_block(scr_data, sec_off) + block = topo_map.get(rung_idx) assert block is not None assert block.start == 0xC93 @@ -857,6 +881,7 @@ def test_counter_topology_blocks_can_use_local_wrapped_row0_order_in_coverage_fi _name, _prog_idx, data_start = _parse_header(scr_data) sections = _find_sections(scr_data, start=data_start) + topo_map = _topology_blocks_by_section(scr_data) for rung_idx, expected_start, expected_prelude, expected_rows in ( ( @@ -874,7 +899,7 @@ def test_counter_topology_blocks_can_use_local_wrapped_row0_order_in_coverage_fi ): sec_off, _count, _sec_end = sections[rung_idx] prev_sec_end = sections[rung_idx - 1][2] - block = _find_row_topology_block(scr_data, sec_off) + block = topo_map.get(rung_idx) assert block is not None assert block.start == expected_start @@ -892,41 +917,18 @@ def test_counter_topology_blocks_can_use_local_wrapped_row0_order_in_coverage_fi assert [sorted(cols) for cols in raw_rows] == expected_rows -def test_forward_parser_agrees_with_brute_force_for_all_topology_blocks(): - """The deterministic forward parser must agree with brute-force for every block.""" - _forward_parse = decode_program_module._forward_parse_topology_block - _brute_force = decode_program_module._brute_force_topology_block +def test_forward_scanner_finds_all_topology_blocks(): + """The forward scanner must find a block for every rung with topology.""" _PREFIX = decode_program_module._ROW_TOPOLOGY_PREFIX for scr_path in sorted(_SCR_FIXTURE_DIR.glob("*.scr")): scr_data = scr_path.read_bytes() - _name, _prog_idx, data_start = _parse_header(scr_data) - sections = _find_sections(scr_data, start=data_start) - - for rung_idx, (sec_off, _count, _sec_end) in enumerate(sections): - block = _find_row_topology_block(scr_data, sec_off) - if block is None: - continue + topo_map = _topology_blocks_by_section(scr_data) + for rung_idx, block in topo_map.items(): pos = block.start - row_word = struct.unpack_from("= 1, f"{scr_path.name} rung {rung_idx}: empty flag table" def test_tag_wire_type_covers_all_implicit_tags(): From 88dc787641a6520b98ed934fe8cdfed41b3ad278 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:09:59 -0400 Subject: [PATCH 5/5] chore: update changelog --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 979790b..b9bce23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.1.6 + +### Fixed +- **SCR decode**: replace backward/brute-force topology block scanning with a + single forward pass, fixing false positive matches from flag-table tail bytes + that caused spurious empty rungs with inflated row counts + ## 0.1.5 ### Changed