Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,31 @@
# 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
- **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
Expand Down
12 changes: 11 additions & 1 deletion src/laddercodec/csv/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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, "|")


Expand Down
108 changes: 37 additions & 71 deletions src/laddercodec/decode_program.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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)
Expand All @@ -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(
Expand Down
80 changes: 80 additions & 0 deletions tests/csv/test_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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] == "|"
59 changes: 59 additions & 0 deletions tests/csv/test_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
marker,A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z,AA,AB,AC,AD,AE,AF
R,C1,-,T,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,"time_drum(outputs=[C2],presets=[1],unit=Tms,pattern=[[0]],current_step=DS1,accumulator=TD1,completion_flag=C1)"
,,,T,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,.reset()
,C2,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,"copy(1,DS1)"
Loading
Loading