---
phase: 04-animation-flow-control
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - tests/test_animation/test_packets.py
autonomous: true
requirements:
  - ANIM-02
  - ANIM-04

must_haves:
  truths:
    - "A failing (RED) branch-matrix suite pins the row-aligned large-tile chunking contract for the 13×26 Ceiling: 7 Set64 packets (52×6 + 26 colours) + 1 CopyFrameBuffer, y offsets 0,4,…,24, row-aligned hsbk_start values"
    - "A failing (RED) suite pins the probe-attachment seam: FLAGS_OFFSET=22 / ACK_REQUIRED_FLAG=0x02 constants and probe_template_index (0 everywhere, last CopyFrameBuffer in large-tile mode per D4-04)"
    - "Every pre-existing test in test_packets.py still passes — the RED suite extends, never modifies, the 16×8 coverage"
  artifacts:
    - path: "tests/test_animation/test_packets.py"
      provides: "TestMatrixPacketGeneratorRowAlignedChunking + TestProbeTemplateIndex + TestHeaderFlagConstants RED classes"
  key_links:
    - from: "tests/test_animation/test_packets.py"
      to: "src/lifx/animation/packets.py"
      via: "asserts the row-aligned template geometry and probe seam that plan 04-03 implements"
      pattern: "MatrixPacketGenerator(tile_count=1, tile_width=13, tile_height=26)"
---

<objective>
Write the Wave-0 RED branch-matrix suite for the packets.py half of Phase 4: the
row-aligned large-tile chunking fix (the latent bug that would paint garbled frames on
the ANIM-04 target device, Ceiling 13×26 / product 201) and the ack-probe attachment
seam (`probe_template_index` + header flag constants, D4-03/D4-04). These tests define
GREEN for plan 04-03 exactly, mirroring the Phase 3 RED→GREEN cadence (03-01 → 03-02).

Purpose: ANIM-04 depends on correct 13×26 chunking — today's generator slices colours at
raw 64-pixel boundaries while stamping row-aligned rect offsets, which only agree when
tile width divides 64. The probe seam is where D4-01's ack flag gets baked (D4-04:
final CopyFrameBuffer in large-tile mode, first packet everywhere else).
Output: RED test classes in tests/test_animation/test_packets.py; existing tests green.
</objective>

<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>

<context>
@.planning/STATE.md
@.planning/phases/04-animation-flow-control/04-CONTEXT.md
@.planning/phases/04-animation-flow-control/04-RESEARCH.md
@src/lifx/animation/packets.py
@tests/test_animation/test_packets.py
</context>

<tasks>

<task type="auto">
  <name>Task 1: RED tests — row-aligned large-tile chunking for non-64-divisible widths</name>
  <files>tests/test_animation/test_packets.py</files>
  <read_first>
    - src/lifx/animation/packets.py — `MatrixPacketGenerator.__init__` (lines 197-226: current `_rows_per_packet`/`_packets_per_tile` math) and `_create_large_tile_templates` (lines 298-384: the raw-64 `color_start = pkt_idx * 64` slicing vs row-based `y_offset` stamping)
    - src/lifx/devices/matrix.py lines 836-859 — the hardware-proven row-aligned batching strategy these tests pin (`rows_per_batch = 64 // width`, colours sliced to the same rows the rect covers)
    - tests/test_animation/test_packets.py — `TestMatrixPacketGeneratorLargeTile` (lines 178-345): existing 16×8 assertions and idioms (payload offset reads, struct.unpack_from) to extend, not modify
    - .planning/phases/04-animation-flow-control/04-RESEARCH.md — Pitfall 1 (the bug arithmetic) and the "Row-aligned large-tile chunking" code example (target math)
  </read_first>
  <action>
    Add a new class `TestMatrixPacketGeneratorRowAlignedChunking` to
    tests/test_animation/test_packets.py (extend the file; do not modify any existing
    test). All tests use `MatrixPacketGenerator(tile_count=1, tile_width=13,
    tile_height=26)` (the ANIM-04 device shape, product 201) unless stated. Pin the
    row-aligned contract from 04-RESEARCH.md:

    1. `test_packets_per_tile_13x26`: `packets_per_tile == 7` (rows_per_packet = 64//13
       = 4; ceil(26/4) = 7). RED today (current pixel-based math gives 6).
    2. `test_template_count_13x26`: `len(create_templates(...)) == 8` (7 Set64 + 1
       CopyFrameBuffer). RED today (7).
    3. `test_color_counts_13x26`: Set64 templates' `color_count` == [52, 52, 52, 52,
       52, 52, 26]. RED today ([64]×5 + [18]).
    4. `test_y_offsets_13x26`: each Set64 payload's rect y byte (payload offset 4, i.e.
       data[HEADER_SIZE + 4]) == 0, 4, 8, 12, 16, 20, 24. RED today (only 6 packets).
    5. `test_hsbk_start_row_aligned_13x26`: Set64 templates' `hsbk_start` == 0, 52,
       104, 156, 208, 260, 312 (y_offset × width). RED today (multiples of 64).
    6. `test_final_partial_row_batch_13x26`: last Set64 covers exactly rows 24-25 —
       y_offset 24, `color_count` 26. RED today.
    7. `test_rect_geometry_13x26`: every Set64 rect has fb_index byte == 1 (payload
       offset 2) and width byte == 13 (payload offset 5); passes today — keep as an
       invariance guard.
    8. `test_copy_fb_last_13x26`: final template is the CopyFrameBuffer — pkt_type 716
       at header offset 32 (`struct.unpack_from("<H", data, 32)`), `color_count == 0`,
       payload width/height bytes (offsets 8/9) == 13/26; passes today — invariance.
    9. `test_update_colors_lands_row_aligned_13x26`: build a 338-colour input, all
       black except a marker HSBK at linear index 52 (row 4, col 0). After
       `update_colors`, the marker must be the FIRST colour slot of the SECOND Set64
       template (bytes at `templates[1].color_offset`), and template 0 must contain
       only the first 52 colours. RED today (raw-64 slicing puts index 52 inside
       template 0).
    10. `test_divisible_width_unchanged_16x8`: `MatrixPacketGenerator(1, 16, 8)` —
        `packets_per_tile == 2`, Set64 color_counts [64, 64], hsbk_start [0, 64],
        y offsets [0, 4]. Passes today AND after the fix (64 divisible by 16) — the
        explicit no-regression guard for the existing Ceiling 16×8 shape.
    11. `test_multi_tile_large_13x26`: `MatrixPacketGenerator(2, 13, 26)` — 16
        templates (8 per tile); the second tile's Set64 `hsbk_start` values are
        338 + [0, 52, …, 312]; the two CopyFrameBuffer templates sit at indices 7 and
        15. RED today.

    Follow the existing file's idioms (struct.unpack_from on template.data, HEADER_SIZE
    from lifx.animation.packets, full type annotations for pyright strict, docstrings
    stating the expected geometry). Each test's docstring names the row-aligned rule it
    pins (device fills Set64 rects row-major from (x, y), so packet colours must start
    at y_offset × width — per D4-04's framebuffer-path scope).
  </action>
  <verify>
    <automated>uv run pytest tests/test_animation/test_packets.py -q -k "RowAlignedChunking"; test $? -eq 1</automated>
  </verify>
  <acceptance_criteria>
    - `uv run pytest tests/test_animation/test_packets.py -q -k "RowAlignedChunking"` exits 1 (test failures, NOT collection errors — exit 2 is a defect in the suite itself)
    - Tests 1-6, 9 and 11 fail on assertions matching the current raw-64 behaviour; tests 7, 8 and 10 pass (invariance guards)
    - `uv run pytest tests/test_animation/test_packets.py -q -k "not RowAlignedChunking and not ProbeTemplateIndex and not HeaderFlagConstants"` passes — zero existing tests modified
    - `uv run ruff check tests/test_animation/test_packets.py` and `uv run pyright tests/test_animation/test_packets.py` are clean
  </acceptance_criteria>
  <done>A RED suite pins the exact row-aligned 13×26 template geometry (7 Set64 + CopyFB, 52×6+26 colours, row-aligned starts) with 16×8 invariance guarded, defining GREEN for plan 04-03 Task 1.</done>
</task>

<task type="auto">
  <name>Task 2: RED tests — header flag constants and probe_template_index seam</name>
  <files>tests/test_animation/test_packets.py</files>
  <read_first>
    - src/lifx/animation/packets.py — `_build_header` (lines 77-115: `struct.pack_into("<8s6sBB", header, 8, ...)` puts flags at absolute offset 8+8+6=22, sequence at 23) and module constants (lines 42-52)
    - .planning/phases/04-animation-flow-control/04-RESEARCH.md — Pattern 2 (probe attaches to the final CopyFrameBuffer in large-tile mode, D4-04 decision + rationale) and the `probe_template_index` code example
    - .planning/phases/04-animation-flow-control/04-CONTEXT.md — D4-03 (FLAGS_OFFSET=22 bit 1) and D4-04 (probe-attachment question)
  </read_first>
  <action>
    Add two classes to tests/test_animation/test_packets.py. IMPORTANT import
    discipline: import the module (`from lifx.animation import packets`) at the top of
    the file (it already is, or add it) and access new names via attribute access
    inside tests — never `from lifx.animation.packets import FLAGS_OFFSET` at module
    top, which would break collection of the whole file's existing tests while RED.

    `TestHeaderFlagConstants`:
    1. `test_flags_offset_constant`: `packets.FLAGS_OFFSET == 22` (via getattr with an
       assert, so absence is a test failure not a collection error). RED (AttributeError).
    2. `test_ack_required_flag_constant`: `packets.ACK_REQUIRED_FLAG == 0x02`. RED.
    3. `test_templates_flags_byte_zero_at_creation`: for a standard 8×8
       MatrixPacketGenerator, a 13×26 large-tile generator, MultiZonePacketGenerator(82)
       and LightPacketGenerator(), every created template has `data[22] == 0` — the
       generators never set the ack flag themselves; the Animator bakes it once at init
       (D4-03). Passes today — invariance guard.

    `TestProbeTemplateIndex` (all RED today — the property does not exist):
    4. `test_default_zero_light`: `LightPacketGenerator().probe_template_index == 0`.
    5. `test_default_zero_multizone`: `MultiZonePacketGenerator(zone_count=82).probe_template_index == 0` — flow control is uniform across families (research Q5): probe on the first packet, the exact arm spike 003 measured.
    6. `test_standard_matrix_zero`: `MatrixPacketGenerator(1, 8, 8).probe_template_index == 0`.
    7. `test_large_tile_13x26_is_final_copyfb`: `MatrixPacketGenerator(1, 13, 26).probe_template_index == 7` — with row-aligned chunking (7 Set64 + CopyFB) the last index is the CopyFrameBuffer. Docstring records this as the D4-04 decision: probe on the frame-commit packet, Glowup-style, hardware-validated in the ANIM-04 UAT (plan 04-07), with a one-line fallback to index 0 if hardware disagrees.
    8. `test_large_tile_16x8_is_final_copyfb`: `MatrixPacketGenerator(1, 16, 8).probe_template_index == 2`.
    9. `test_multi_tile_large_is_last_copyfb`: `MatrixPacketGenerator(2, 13, 26).probe_template_index == 15` (tile_count × (packets_per_tile + 1) − 1).
    10. `test_probe_index_points_at_copyfb_template`: for the 13×26 generator, the
        template at `probe_template_index` has pkt_type 716 and `color_count == 0`;
        for the standard 8×8 generator, the template at index 0 has pkt_type 715.

    Note: tests 7, 9 and 10 also depend on the Task 1 row-aligned packet count, so they
    stay RED until BOTH 04-03 tasks land — state this in their docstrings.
  </action>
  <verify>
    <automated>uv run pytest tests/test_animation/test_packets.py -q -k "ProbeTemplateIndex or HeaderFlagConstants"; test $? -eq 1</automated>
  </verify>
  <acceptance_criteria>
    - `uv run pytest tests/test_animation/test_packets.py -q -k "ProbeTemplateIndex or HeaderFlagConstants"` exits 1 with failures (AttributeError-based test failures, not collection errors)
    - `test_templates_flags_byte_zero_at_creation` passes (invariance)
    - `uv run pytest tests/test_animation/test_packets.py -q -k "not RowAlignedChunking and not ProbeTemplateIndex and not HeaderFlagConstants"` still passes
    - `uv run ruff check tests/test_animation/test_packets.py` and `uv run pyright tests/test_animation/test_packets.py` are clean
  </acceptance_criteria>
  <done>A RED suite pins FLAGS_OFFSET/ACK_REQUIRED_FLAG values and the full probe_template_index matrix (light/multizone/standard-matrix → 0; large-tile → final CopyFrameBuffer), recording the D4-04 attachment decision for hardware validation.</done>
</task>

</tasks>

## Artifacts this plan produces

| Artifact | Kind | Location |
|----------|------|----------|
| `TestMatrixPacketGeneratorRowAlignedChunking` — RED geometry matrix for 13×26 (+16×8 invariance) | Test suite | `tests/test_animation/test_packets.py` |
| `TestProbeTemplateIndex` + `TestHeaderFlagConstants` — RED probe-seam matrix | Test suite | `tests/test_animation/test_packets.py` |

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| None new | Test-only plan; no runtime surface changes |

## STRIDE Threat Register

| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-04-01 | Tampering | test suite pinning wire geometry | low | mitigate | Tests pin exact byte offsets/rect geometry so a future regression that would corrupt on-device framebuffers (garbled output on 13×26) fails CI |

No packages are installed by this plan (zero-dependency project) — no supply-chain gate applies.
</threat_model>

<verification>
- New RED classes fail with assertion/attribute failures (pytest exit 1), never collection errors (exit 2)
- All pre-existing test_packets.py tests pass unmodified
- ruff + pyright clean on the test file
</verification>

<success_criteria>
- The row-aligned chunking contract (ANIM-04 correctness prerequisite) and the probe-attachment seam (ANIM-02/D4-04) are fully pinned by failing tests that plan 04-03 must turn green without editing any test
</success_criteria>

<output>
Create `.planning/phases/04-animation-flow-control/04-01-SUMMARY.md` when done, recording:
the RED test count per class, which tests are invariance guards (passing), and the commit hash.
</output>
