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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ src/laddercodec/

- **encode.py** — Encoder. Public API is `encode()` (accepts `Rung` or `list[Rung]`). Internal `encode_rung()` orchestrates: validate → compute metadata → build grid → insert comment → pad to page. Constants, RTF helpers, type aliases (`ConditionToken`, `AfToken`), and `_af_segment()` / `_compute_seg_boundaries()` live here.
- **encode_multi.py** — Multi-rung encoder. Internal `encode_rungs()`. Combines N rungs into one buffer with per-rung preambles and data rows. Delegates grid building to `_grid.py`.
- **_grid.py** — Shared grid-building functions used by both encoders. `_validate_rung()` validates dimensions/tokens, `_compute_rung_metadata()` returns a `RungMetadata` dataclass (instruction indices, AF summary, segment boundaries), `_build_rung_grid()` builds the cell grid for one rung.
- **_grid.py** — Shared grid-building functions used by both encoders. `_validate_rung()` validates dimensions/tokens, `_compute_rung_metadata()` returns a `RungMetadata` dataclass (instruction indices, segment boundaries), `_build_rung_grid()` builds the cell grid for one rung.
- **binary_helpers.py** — Shared binary serialization primitives. Encoding: `_utf16le_null()`, `_tagged_field()`, `_variant_tagged_field()`. Decoding: `_read_utf16le()`, `_parse_tagged_fields()`, `_parse_tagged_fields_verbose()` (returns tag IDs + handles variant sentinels). Used by all instruction modules and `devtools/inspect_bin.py`.
- **decode.py** — Decoder. Public API is `decode()` (auto-detects single vs multi-rung). Returns `Rung` or `list[Rung]`. Walks the variable-length cell grid, parses instruction blobs into Contact/Coil/Timer domain objects, decodes RTF comments to markdown. Falls back to `RawInstruction` for unrecognised cell types.
- **decode_program.py** — Program file decoder. Public API is `decode_program()`. Reads `Scr*.tmp` files (Click's internal format, ~17x smaller than clipboard) and returns a `Program` with name, index, and decoded rungs. Same instruction parsing as `decode.py` but different framing.
Expand Down Expand Up @@ -128,7 +128,7 @@ Quick reference: three flag bytes per cell — segment (+0x19), right (+0x1D), d

## Instruction Blobs

Full spec: [`docs/internals/instruction-blobs.md`](docs/internals/instruction-blobs.md) — blob structure, class names, field layouts, AF summary block.
Full spec: [`docs/internals/instruction-blobs.md`](docs/internals/instruction-blobs.md) — blob structure, class names, field layouts.

## Current Encoder State

Expand Down
88 changes: 88 additions & 0 deletions devtools/combine_coverage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Combine random coverage golden CSVs into a single multi-row rung.

Picks N coverage fixtures at random, stacks their data rows into one
rung, encodes to .bin, and writes both to devtools/. The first data
row gets the ``R`` marker; all others get an empty marker. Comment
rows are discarded.

Usage::

uv run devtools/combine_coverage.py # 4 random fixtures
uv run devtools/combine_coverage.py -n 3 # 3 random fixtures
uv run devtools/combine_coverage.py -n 6 # 6 random fixtures
"""

import argparse
import csv
import random
import sys
from io import StringIO
from pathlib import Path

from laddercodec import encode, read_csv
from laddercodec.csv.contract import CSV_HEADER

COVERAGE_DIR = Path("tests/fixtures/coverage/golden")
OUT_CSV = Path("devtools/combined.csv")
OUT_BIN = Path("devtools/combined.bin")


def collect_data_rows(csv_path: Path) -> list[list[str]]:
"""Read a coverage CSV and return data rows (no header, no comments)."""
rows = []
with csv_path.open(newline="") as f:
reader = csv.reader(f)
next(reader) # skip header
for row in reader:
if not row or row[0].strip() == "#":
continue
rows.append(row)
return rows


def main() -> None:
parser = argparse.ArgumentParser(description="Combine coverage CSVs")
parser.add_argument("-n", type=int, default=4, help="number of fixtures (default: 4)")
parser.add_argument("--seed", type=int, default=None, help="random seed")
args = parser.parse_args()

csvs = sorted(COVERAGE_DIR.glob("*.csv"))
if not csvs:
print("No coverage CSVs found", file=sys.stderr)
sys.exit(1)

rng = random.Random(args.seed)
picked = rng.sample(csvs, min(args.n, len(csvs)))

print(f"Combining {len(picked)} fixtures:")
all_rows: list[list[str]] = []
for p in picked:
print(f" {p.stem}")
all_rows.extend(collect_data_rows(p))

if not all_rows:
print("No data rows found", file=sys.stderr)
sys.exit(1)

# First row gets R marker, rest get empty
all_rows[0][0] = "R"
for row in all_rows[1:]:
row[0] = ""

# Write combined CSV
buf = StringIO()
writer = csv.writer(buf)
writer.writerow(CSV_HEADER)
writer.writerows(all_rows)
OUT_CSV.write_text(buf.getvalue())
print(f"\nWrote {OUT_CSV} ({len(all_rows)} data rows)")

# Encode to .bin
rung = read_csv(OUT_CSV)
data = encode(rung)
OUT_BIN.write_bytes(data)
print(f"Wrote {OUT_BIN} ({len(data)} bytes)")


if __name__ == "__main__":
main()
126 changes: 74 additions & 52 deletions devtools/coverage_golden.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,15 @@
"""Manage coverage CSV/BIN fixtures based on verify_progress.log.
"""Manage coverage golden CSV/BIN fixtures.

Behavior:
- Parse tests/fixtures/coverage/golden/verify_progress.log
- Select only fixture stems marked as ``worked``
- Generate ``.bin`` files for those stems from their ``.csv``
- Remove any existing ``.bin`` whose stem is not marked ``worked``
Regenerates all .bin files from .csv sources and prunes the verify log
for changed/deleted fixtures.

Usage:
uv run devtools/coverage_golden.py
"""

from __future__ import annotations

import re
import subprocess
import sys
from pathlib import Path

Expand All @@ -25,20 +22,6 @@
GOLDEN_DIR = ROOT / "tests" / "fixtures" / "coverage" / "golden"
VERIFY_LOG = GOLDEN_DIR / "verify_progress.log"

_WORKED_RE = re.compile(r"^([A-Za-z0-9_]+):\s*worked\s*$")


def _worked_stems_from_log(path: Path) -> list[str]:
if not path.exists():
raise FileNotFoundError(f"Missing verify log: {path}")

stems: list[str] = []
for line in path.read_text(encoding="utf-8").splitlines():
m = _WORKED_RE.match(line.strip())
if m:
stems.append(m.group(1))
return sorted(set(stems))


def _encode_csv(csv_path: Path) -> bytes:
program = parse_csv_file(csv_path)
Expand Down Expand Up @@ -67,51 +50,90 @@ def _encode_csv(csv_path: Path) -> bytes:
return encode_rungs(rung_inputs, comments=comments)


def sync() -> int:
worked_stems = _worked_stems_from_log(VERIFY_LOG)
if not worked_stems:
print(f"No 'worked' fixtures found in {VERIFY_LOG}")
return 1

print(f"Generating coverage bins for {len(worked_stems)} worked fixture(s):")

missing_csv: list[str] = []
generated = 0
for stem in worked_stems:
csv_path = GOLDEN_DIR / f"{stem}.csv"
if not csv_path.exists():
missing_csv.append(stem)
continue
def generate() -> None:
csv_files = sorted(GOLDEN_DIR.glob("*.csv"))
if not csv_files:
print(f"No CSV files found in {GOLDEN_DIR}")
sys.exit(1)

print(f"Generating coverage bins from {len(csv_files)} CSV files:")
for csv_path in csv_files:
encoded = _encode_csv(csv_path)
bin_path = GOLDEN_DIR / f"{stem}.bin"
bin_path = csv_path.with_suffix(".bin")
bin_path.write_bytes(encoded)
generated += 1
print(f" {csv_path.name} -> {bin_path.name} ({len(encoded):,} bytes)")

if missing_csv:
print("\nMissing CSV files for worked fixtures:")
for stem in missing_csv:
print(f" {stem}.csv")
return 1

worked_set = set(worked_stems)
stale_bins = sorted(p for p in GOLDEN_DIR.glob("*.bin") if p.stem not in worked_set)
if stale_bins:
print(f"\nRemoving {len(stale_bins)} non-worked .bin fixture(s):")
for p in stale_bins:
print(f"\nDone. {len(csv_files)} fixtures generated.")


def prune() -> None:
csv_stems = {p.stem for p in GOLDEN_DIR.glob("*.csv")}

# --- Delete orphaned .bin (no matching .csv) ---
orphaned = sorted(p for p in GOLDEN_DIR.glob("*.bin") if p.stem not in csv_stems)
if orphaned:
print(f"Deleting {len(orphaned)} orphaned .bin files:")
for p in orphaned:
print(f" {p.name}")
p.unlink()

print(f"\nDone. Generated {generated} coverage bin fixture(s).")
return 0
# --- Prune verify_progress.log ---
if not VERIFY_LOG.exists():
print("No verify_progress.log found — nothing to prune.")
return

# Find .bin files with uncommitted changes
result = subprocess.run(
["git", "diff", "--name-only", "--", "tests/fixtures/coverage/golden/*.bin"],
capture_output=True,
text=True,
cwd=str(ROOT),
)
changed_stems = {
Path(line.strip()).stem for line in result.stdout.strip().split("\n") if line.strip()
}

lines = VERIFY_LOG.read_text().splitlines(keepends=True)
kept: list[str] = []
removed: list[str] = []

for line in lines:
if line.startswith("#") or not line.strip():
continue
name = line.split(":")[0].strip()
if name in changed_stems or name not in csv_stems:
removed.append(name)
else:
kept.append(line)

with open(VERIFY_LOG, "w", encoding="utf-8") as f:
f.write("# verify_progress.log — tracks Click paste verification status\n")
for line in kept:
f.write(line if line.endswith("\n") else line + "\n")

verified = sum(1 for ln in kept if ln.strip())
total = len(csv_stems)
unverified = total - verified

if removed:
print(f"Pruned {len(removed)} entries from verify_progress.log:")
for name in sorted(removed):
print(f" {name}")

print(f"\nVerification status: {verified}/{total} verified, {unverified} unverified")
if unverified:
verified_names = {ln.split(":")[0].strip() for ln in kept if ln.strip()}
for name in sorted(csv_stems - verified_names):
print(f" UNVERIFIED: {name}")


def main() -> None:
if len(sys.argv) > 1:
print("Usage: uv run devtools/coverage_golden.py")
raise SystemExit(2)
raise SystemExit(sync())
generate()
print()
prune()


if __name__ == "__main__":
Expand Down
25 changes: 17 additions & 8 deletions docs/guides/adding-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,25 +158,34 @@ make test && make lint

## Coverage testing

Coverage golden fixtures live in `tests/fixtures/coverage/golden/`. Each fixture is a hand-written CSV (`<rung_id>.csv`) paired with a Click-captured binary (`<rung_id>.bin`).
Coverage golden fixtures live in `tests/fixtures/coverage/golden/`. Each fixture is a hand-written CSV (`<rung_id>.csv`) paired with a generated binary (`<rung_id>.bin`).

### Adding a fixture

1. Create `tests/fixtures/coverage/golden/<rung_id>.csv` by hand — one rung per file, 33-column canonical format.
2. Capture the golden binary via Click paste round-trip using `clicknick-rung guided`.
3. `make test` — each `.csv` with a matching `.bin` gets a parametrized test comparing encoded CSV against captured bytes.
2. Run `make coverage-golden` to generate the `.bin` from the CSV.
3. Paste-verify in Click using `clicknick-rung guided`.
4. `make test` — each `.csv` with a matching `.bin` gets a parametrized test comparing encoded output to golden binaries.

### Regenerating coverage bins from verified fixtures

When you want to refresh coverage bins from the current encoder, use:
### Regenerating coverage bins

```bash
make coverage-golden
```

This command reads `tests/fixtures/coverage/golden/verify_progress.log`, selects only fixture IDs marked `: worked`, regenerates those `.bin` files, and removes non-worked coverage `.bin` files.
Regenerates all `.bin` files from `.csv` sources and prunes the verify log for changed/deleted fixtures. Same workflow as `make golden` for ladder fixtures.

### Smoke-testing combined fixtures

To quickly test multiple instruction types together in a single rung:

```bash
uv run devtools/combine_coverage.py # 4 random fixtures
uv run devtools/combine_coverage.py -n 6 # 6 random fixtures
uv run devtools/combine_coverage.py --seed 42 # reproducible pick
```

Use this for bulk refresh/sync. For native Click fidelity checks, keep using capture round-trips for the specific fixture.
This picks N random coverage CSVs, stacks their data rows into one multi-row rung, and writes `devtools/combined.csv` + `devtools/combined.bin` for paste-testing.

## Clicknick compatibility

Expand Down
10 changes: 1 addition & 9 deletions docs/guides/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ The cell header is 0x25 (37) bytes. Key fields:

After the header: instruction blob (variable length), then a 16-byte tail.

**Size differences** are the most important signal. If a cell is 32 bytes larger in your output, you're probably emitting an AF summary block that shouldn't be there — or vice versa.
**Size differences** are the most important signal. If a cell has unexpected extra bytes, the blob length formula is probably wrong for that instruction type.

**Flag differences** (segment, wire_right, wire_down) can cause visual corruption but usually don't crash Click.

Expand All @@ -95,14 +95,6 @@ Raw `xxd` / hex diffs of the two binaries are nearly useless because:

## Known pitfalls

### AF summary block

When a single-rung buffer has 2+ AF instructions, the encoder appends a 32-byte summary block to the last AF cell. **This summary must be suppressed when any AF is multi-row** (e.g. a retained timer with a `.reset()` pin row).

The summary is built by `_build_af_summary()` in `encode.py` and gated by `_compute_rung_metadata()` in `_grid.py`. If your rung has a mix of single-row AFs (coils, copies) and multi-row AFs (timers), check that the summary isn't being emitted.

Symptom: the last AF instruction cell is ~32 bytes larger than in the native capture. Click crashes on paste because the extra bytes misalign the rest of the grid.

### Tall instruction visual_rows

The byte at cell offset +0x0A (`visual_rows`) is not always the same as the instruction's `cell_params()["visual_rows"]`. For instructions with pin rows (retained timers, counters, shifts, drums), the native binary may use a different value that accounts for the pin rows.
Expand Down
13 changes: 0 additions & 13 deletions docs/internals/instruction-blobs.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,19 +51,6 @@ stores concrete addresses.
The `encode()` API exposes this via `show_nicknames=True`, which sets the flag
on all math instructions in the buffer.

## AF summary block

In single-rung buffers, when a rung has 2+ AF instruction cells, the **last** AF instruction cell gets an extra block appended between the blob and tail:

1. 12 zero bytes (header padding)
2. `uint32 LE` total instruction count
3. `af_count * 8`-byte entries in a diagonal pattern:
- `entry[af_idx] = left_value` (total_instr_count - instr_index for non-last; instrs_on_row for last)
- `entry[af_idx + af_count] = 1` if row has a condition contact
4. Modified 16-byte tail: `tail[3]=1, tail[12]=1, tail[15]=1`

This block replaces the instruction count that would normally go on an AF data cell (`tail[12] = total_instr_count`) when no AF data cell exists (all rows have AF instructions).

## Blob boundary detection

For unknown instruction types, the blob boundary can be detected using the generic multi-part formula:
Expand Down
Loading