"""
expander/dag.py - Structured Expander DAG ("Helix Bomb")

Unifies existing primitives into ChatGPT's expander DAG concept:
- LITERAL: Raw bytes from ChunkVault
- COPY: Reference from other tensor
- REPEAT: Block iteration
- CONCAT: Sequential assembly
- GENERATE: PRG_SHAKE, CODEBOOK_VQ, SPLINE_CDF, IFS_FRACTAL
- PATCH: Delta application (HXZO sidecars)

Bounded by IT tokens to prevent zip-bomb-style abuse.

This is a THIN WRAPPER over existing primitives, not a rewrite.
"""

from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union
import hashlib
import json
import struct

import numpy as np


class OpType(Enum):
    """Expander DAG operation types."""
    LITERAL = "literal"      # Raw bytes
    COPY = "copy"            # Reference another node
    REPEAT = "repeat"        # Loop N times
    CONCAT = "concat"        # Sequential assembly
    GENERATE = "generate"    # PRG/codebook/fractal expansion
    PATCH = "patch"          # Delta application


class GenerateMode(Enum):
    """GENERATE operation modes."""
    PRG_SHAKE = "prg_shake"        # SHAKE256 expansion (existing)
    CODEBOOK_VQ = "codebook_vq"    # CDNA k-means codebook (existing)
    SPLINE_CDF = "spline_cdf"      # Distribution sampling (new)
    IFS_FRACTAL = "ifs_fractal"    # Fractal expansion (new)


@dataclass
class ExpanderNode:
    """
    A node in the expander DAG.

    Each node represents an operation that produces output bytes.
    Nodes can depend on other nodes (forming a DAG).
    """
    node_id: str
    op: OpType

    # Operation-specific parameters
    params: Dict[str, Any] = field(default_factory=dict)

    # Dependencies (node IDs this depends on)
    depends_on: List[str] = field(default_factory=list)

    # IT token cost estimate
    it_cost: float = 0.0

    # Output size (bytes) - for bounded expansion
    output_size: int = 0

    # SHA256 of output (for verification)
    output_hash: Optional[str] = None

    def to_dict(self) -> Dict[str, Any]:
        return {
            "node_id": self.node_id,
            "op": self.op.value,
            "params": self.params,
            "depends_on": self.depends_on,
            "it_cost": self.it_cost,
            "output_size": self.output_size,
            "output_hash": self.output_hash,
        }


@dataclass
class ExpanderDAG:
    """
    The full expander DAG structure.

    A content-addressed DAG where:
    - Each node is an operation producing bytes
    - Nodes can depend on other nodes
    - Total expansion is bounded by IT tokens
    - Every node output is SHA256-verified
    """
    version: str = "1.0"
    nodes: Dict[str, ExpanderNode] = field(default_factory=dict)
    root_node: Optional[str] = None

    # IT token budget
    it_budget: float = 1000.0
    it_used: float = 0.0

    # Metadata
    created: Optional[str] = None
    description: Optional[str] = None

    def add_node(self, node: ExpanderNode) -> None:
        """Add a node to the DAG."""
        # Check IT budget
        if self.it_used + node.it_cost > self.it_budget:
            raise ValueError(
                f"IT budget exceeded: {self.it_used + node.it_cost:.2f} > {self.it_budget:.2f}"
            )

        # Validate dependencies exist
        for dep in node.depends_on:
            if dep not in self.nodes:
                raise ValueError(f"Dependency not found: {dep}")

        self.nodes[node.node_id] = node
        self.it_used += node.it_cost

    def get_execution_order(self) -> List[str]:
        """Topological sort for execution order."""
        visited = set()
        order = []

        def visit(node_id: str):
            if node_id in visited:
                return
            visited.add(node_id)
            node = self.nodes[node_id]
            for dep in node.depends_on:
                visit(dep)
            order.append(node_id)

        if self.root_node:
            visit(self.root_node)
        else:
            for node_id in self.nodes:
                visit(node_id)

        return order

    def compute_dag_hash(self) -> str:
        """
        Canonical hash of the DAG (content-addressed).

        Uses deterministic JSON serialization (sort_keys=True) so the hash
        only changes when semantic content changes, not when formatting changes.
        """
        # Deterministic serialization
        nodes_sorted = sorted(self.nodes.keys())
        content = json.dumps(
            {
                "version": self.version,
                "nodes": [self.nodes[k].to_dict() for k in nodes_sorted],
                "root_node": self.root_node,
            },
            sort_keys=True,
        )
        return hashlib.sha256(content.encode()).hexdigest()

    def compute_dag_bytes_hash(self, file_path: Path) -> str:
        """
        Bytes hash of serialized DAG file (tamper-evident).

        Unlike compute_dag_hash(), this hashes the raw file bytes, so it
        detects ANY modification including whitespace, encoding changes, etc.

        Args:
            file_path: Path to the serialized DAG file

        Returns:
            SHA256 hex digest of the file bytes
        """
        return hashlib.sha256(file_path.read_bytes()).hexdigest()

    def to_dict(self) -> Dict[str, Any]:
        return {
            "version": self.version,
            "dag_canonical_hash": self.compute_dag_hash(),  # Renamed for clarity
            "nodes": {k: v.to_dict() for k, v in self.nodes.items()},
            "root_node": self.root_node,
            "it_budget": self.it_budget,
            "it_used": self.it_used,
            "created": self.created,
            "description": self.description,
        }

    def save_with_dual_hash(self, file_path: Path, indent: int = 2) -> Dict[str, str]:
        """
        Save the DAG to a file and return both hashes.

        This is a convenience method that:
        1. Serializes the DAG to JSON
        2. Writes to file
        3. Computes and returns both canonical and bytes hashes

        Args:
            file_path: Path to save the DAG JSON
            indent: JSON indentation level

        Returns:
            Dict with 'canonical_hash' and 'bytes_hash' keys
        """
        content = json.dumps(self.to_dict(), indent=indent)
        file_path.write_text(content)
        return {
            "canonical_hash": self.compute_dag_hash(),
            "bytes_hash": self.compute_dag_bytes_hash(file_path),
        }

    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> "ExpanderDAG":
        dag = cls(
            version=data.get("version", "1.0"),
            root_node=data.get("root_node"),
            it_budget=data.get("it_budget", 1000.0),
            it_used=data.get("it_used", 0.0),
            created=data.get("created"),
            description=data.get("description"),
        )
        for node_id, node_data in data.get("nodes", {}).items():
            node = ExpanderNode(
                node_id=node_data["node_id"],
                op=OpType(node_data["op"]),
                params=node_data.get("params", {}),
                depends_on=node_data.get("depends_on", []),
                it_cost=node_data.get("it_cost", 0.0),
                output_size=node_data.get("output_size", 0),
                output_hash=node_data.get("output_hash"),
            )
            dag.nodes[node_id] = node
        return dag


# =============================================================================
# Factory functions to create nodes from existing primitives
# =============================================================================

def literal_node(
    node_id: str,
    chunk_hash: str,
    size: int,
    it_cost: float = 0.01,
) -> ExpanderNode:
    """
    Create a LITERAL node (raw bytes from ChunkVault).

    Maps to: ChunkVault.get_chunk(chunk_hash)
    """
    return ExpanderNode(
        node_id=node_id,
        op=OpType.LITERAL,
        params={"chunk_hash": chunk_hash},
        it_cost=it_cost,
        output_size=size,
        output_hash=chunk_hash,
    )


def copy_node(
    node_id: str,
    source_node: str,
    offset: int = 0,
    length: Optional[int] = None,
    it_cost: float = 0.001,
) -> ExpanderNode:
    """
    Create a COPY node (reference another node's output).

    Maps to: slice of another node's output
    """
    return ExpanderNode(
        node_id=node_id,
        op=OpType.COPY,
        params={"offset": offset, "length": length},
        depends_on=[source_node],
        it_cost=it_cost,
    )


def generate_prg_node(
    node_id: str,
    seed: bytes,
    output_size: int,
    it_cost: float = 0.1,
) -> ExpanderNode:
    """
    Create a GENERATE node with PRG_SHAKE mode.

    Maps to: dna_seed_loader.py SHAKE256 expansion
    WARNING: Output is pseudo-random, NOT original model weights
    """
    return ExpanderNode(
        node_id=node_id,
        op=OpType.GENERATE,
        params={
            "mode": GenerateMode.PRG_SHAKE.value,
            "seed": seed.hex(),
            "output_size": output_size,
        },
        it_cost=it_cost,
        output_size=output_size,
    )


def generate_spline_cdf_node(
    node_id: str,
    seed: bytes,
    output_size: int,
    control_points: List[List[float]],
    dtype: str = "float32",
    it_cost: float = 0.1,
) -> ExpanderNode:
    """
    Create a GENERATE node with SPLINE_CDF mode.

    Samples from a distribution defined by CDF control points.
    Useful for generating values matching a learned distribution.

    Args:
        node_id: Unique node identifier
        seed: Seed bytes for deterministic sampling
        output_size: Number of values to generate
        control_points: List of [x, cdf_y] pairs defining the CDF.
                       x values should be ascending.
                       cdf_y values in [0, 1], monotonically increasing.
        dtype: Output dtype ('float32' or 'float16')
        it_cost: IT token cost

    Example control_points for normal-ish distribution:
        [[-3.0, 0.001], [-2.0, 0.02], [-1.0, 0.16], [0.0, 0.5],
         [1.0, 0.84], [2.0, 0.98], [3.0, 0.999]]
    """
    return ExpanderNode(
        node_id=node_id,
        op=OpType.GENERATE,
        params={
            "mode": GenerateMode.SPLINE_CDF.value,
            "seed": seed.hex(),
            "output_size": output_size,
            "control_points": control_points,
            "dtype": dtype,
        },
        it_cost=it_cost,
        output_size=output_size * (2 if dtype == "float16" else 4),
    )


def generate_ifs_fractal_node(
    node_id: str,
    seed: bytes,
    output_size: int,
    transforms: Optional[List[Dict[str, float]]] = None,
    iterations: Optional[int] = None,
    warmup: int = 100,
    output_dims: int = 1,
    scale: float = 1.0,
    dtype: str = "float32",
    it_cost: float = 0.15,
) -> ExpanderNode:
    """
    Create a GENERATE node with IFS_FRACTAL mode.

    Generates structured values using Iterated Function System (chaos game).
    Useful for creating self-similar patterns from compact transform definitions.

    Args:
        node_id: Unique node identifier
        seed: Seed bytes for deterministic generation
        output_size: Number of values to generate
        transforms: List of affine transform dicts with keys:
                   'a', 'b', 'c', 'd' (2x2 matrix), 'e', 'f' (translation), 'p' (probability)
                   If None, uses Sierpinski triangle transforms.
        iterations: Chaos game iterations (default: output_size * 10)
        warmup: Iterations to discard before collecting
        output_dims: Dimensions per point (1 or 2)
        scale: Scale factor for output values
        dtype: Output dtype ('float32' or 'float16')
        it_cost: IT token cost

    Example transforms (Sierpinski triangle):
        [
            {"a": 0.5, "b": 0, "c": 0, "d": 0.5, "e": 0, "f": 0, "p": 0.33},
            {"a": 0.5, "b": 0, "c": 0, "d": 0.5, "e": 0.5, "f": 0, "p": 0.33},
            {"a": 0.5, "b": 0, "c": 0, "d": 0.5, "e": 0.25, "f": 0.5, "p": 0.34},
        ]
    """
    params = {
        "mode": GenerateMode.IFS_FRACTAL.value,
        "seed": seed.hex(),
        "output_size": output_size,
        "warmup": warmup,
        "output_dims": output_dims,
        "scale": scale,
        "dtype": dtype,
    }

    if transforms:
        params["transforms"] = transforms
    if iterations:
        params["iterations"] = iterations

    return ExpanderNode(
        node_id=node_id,
        op=OpType.GENERATE,
        params=params,
        it_cost=it_cost,
        output_size=output_size * (2 if dtype == "float16" else 4),
    )


def generate_codebook_node(
    node_id: str,
    cdna_shard_path: str,
    block_index: Optional[int] = None,
    start_row: Optional[int] = None,
    end_row: Optional[int] = None,
    dequantize: bool = True,
    depends_on: Optional[List[str]] = None,
    it_cost: float = 0.05,
) -> ExpanderNode:
    """
    Create a GENERATE node with CODEBOOK_VQ mode.

    Maps to: cdna_stream_v2.py block decode

    Args:
        node_id: Unique node identifier
        cdna_shard_path: Path to CDNA shard file
        block_index: Decode this block (0-indexed). Mutually exclusive with start_row/end_row.
        start_row: First row to decode (if not using block_index)
        end_row: Last row to decode (exclusive, if not using block_index)
        dequantize: If True, return float32 weights. If False, return uint8 indices.
        depends_on: Optional dependencies (for ordering)
        it_cost: IT token cost for this operation
    """
    params = {
        "mode": GenerateMode.CODEBOOK_VQ.value,
        "shard_path": cdna_shard_path,
        "dequantize": dequantize,
    }

    if block_index is not None:
        params["block_index"] = block_index
    else:
        params["start_row"] = start_row
        params["end_row"] = end_row

    return ExpanderNode(
        node_id=node_id,
        op=OpType.GENERATE,
        params=params,
        depends_on=depends_on or [],
        it_cost=it_cost,
    )


def patch_node(
    node_id: str,
    base_node: str,
    sidecar_path: str,
    shape: Optional[Tuple[int, int]] = None,
    dtype: str = "float32",
    it_cost: float = 0.02,
) -> ExpanderNode:
    """
    Create a PATCH node (apply HXZO sidecar).

    Maps to: hxzo_sidecar.py apply_sidecar()

    Args:
        node_id: Unique node identifier
        base_node: Node ID of the base tensor to patch
        sidecar_path: Path to HXZO sidecar file
        shape: (rows, cols) of the tensor (for validation)
        dtype: Data type of base tensor ('float32' or 'float16')
        it_cost: IT token cost for this operation
    """
    params = {
        "sidecar_path": sidecar_path,
        "dtype": dtype,
    }
    if shape:
        params["shape"] = list(shape)

    return ExpanderNode(
        node_id=node_id,
        op=OpType.PATCH,
        params=params,
        depends_on=[base_node],
        it_cost=it_cost,
    )


def cdna_tensor_node(
    node_id: str,
    cdna_shard_path: str,
    sidecar_path: Optional[str] = None,
    it_cost: float = 0.1,
) -> List[ExpanderNode]:
    """
    Create nodes for decoding a full CDNA tensor with optional sidecar.

    This is a convenience function that creates:
    1. A CODEBOOK_VQ node for the full tensor
    2. Optionally, a PATCH node to apply sidecar corrections

    Args:
        node_id: Base node ID (will be suffixed with _raw, _patched)
        cdna_shard_path: Path to CDNA shard file
        sidecar_path: Optional path to HXZO sidecar
        it_cost: Total IT cost (split between nodes)

    Returns:
        List of nodes to add to the DAG. The final node ID will be:
        - {node_id} if no sidecar
        - {node_id} if sidecar (the patched version)
    """
    nodes = []

    if sidecar_path:
        # Create raw decode node
        raw_node_id = f"{node_id}_raw"
        raw_node = generate_codebook_node(
            node_id=raw_node_id,
            cdna_shard_path=cdna_shard_path,
            start_row=0,
            end_row=None,  # Will decode all
            it_cost=it_cost * 0.8,
        )
        # Will need to set end_row after reading shape
        raw_node.params["end_row"] = -1  # Sentinel for "all rows"
        nodes.append(raw_node)

        # Create patch node
        patch = patch_node(
            node_id=node_id,
            base_node=raw_node_id,
            sidecar_path=sidecar_path,
            it_cost=it_cost * 0.2,
        )
        nodes.append(patch)
    else:
        # Just the decode node
        decode = generate_codebook_node(
            node_id=node_id,
            cdna_shard_path=cdna_shard_path,
            start_row=0,
            end_row=-1,  # Sentinel for "all rows"
            it_cost=it_cost,
        )
        nodes.append(decode)

    return nodes


def concat_node(
    node_id: str,
    source_nodes: List[str],
    it_cost: float = 0.01,
) -> ExpanderNode:
    """
    Create a CONCAT node (assemble multiple nodes sequentially).

    Maps to: manifest assembly in manifest_regen.py
    """
    return ExpanderNode(
        node_id=node_id,
        op=OpType.CONCAT,
        params={},
        depends_on=source_nodes,
        it_cost=it_cost,
    )


def repeat_node(
    node_id: str,
    template_node: str,
    count: int,
    it_cost_per_iter: float = 0.01,
) -> ExpanderNode:
    """
    Create a REPEAT node (loop unrolling).

    Maps to: block iteration in CDNA streaming
    """
    return ExpanderNode(
        node_id=node_id,
        op=OpType.REPEAT,
        params={"count": count},
        depends_on=[template_node],
        it_cost=it_cost_per_iter * count,
    )


# =============================================================================
# Executor: Actually run the DAG using existing primitives
# =============================================================================

class ExpanderExecutor:
    """
    Execute an ExpanderDAG using existing Helix primitives.

    This is a thin wrapper - it delegates to:
    - ChunkVault for LITERAL
    - CDNA stream for CODEBOOK_VQ
    - HXZO sidecar for PATCH
    - etc.
    """

    def __init__(
        self,
        vault_path: Optional[str] = None,
        cdna_base_path: Optional[str] = None,
        sidecar_base_path: Optional[str] = None,
    ):
        """
        Initialize executor.

        Args:
            vault_path: Base path for ChunkVault (LITERAL nodes)
            cdna_base_path: Base path for CDNA shards (CODEBOOK_VQ nodes)
            sidecar_base_path: Base path for HXZO sidecars (PATCH nodes)
        """
        self.vault_path = vault_path
        self.cdna_base_path = Path(cdna_base_path) if cdna_base_path else None
        self.sidecar_base_path = Path(sidecar_base_path) if sidecar_base_path else None
        self.node_outputs: Dict[str, bytes] = {}
        self._vault = None
        self._cdna_readers: Dict[str, Any] = {}  # Cache CDNAv2Reader instances

    def execute(self, dag: ExpanderDAG) -> Dict[str, bytes]:
        """Execute the DAG and return all node outputs."""
        order = dag.get_execution_order()

        for node_id in order:
            node = dag.nodes[node_id]
            output = self._execute_node(node)
            self.node_outputs[node_id] = output

            # Verify hash if specified
            if node.output_hash:
                actual_hash = hashlib.sha256(output).hexdigest()
                if actual_hash != node.output_hash:
                    raise ValueError(
                        f"Hash mismatch for {node_id}: "
                        f"expected {node.output_hash}, got {actual_hash}"
                    )

        return self.node_outputs

    def _execute_node(self, node: ExpanderNode) -> bytes:
        """Execute a single node."""
        if node.op == OpType.LITERAL:
            return self._exec_literal(node)
        elif node.op == OpType.COPY:
            return self._exec_copy(node)
        elif node.op == OpType.GENERATE:
            return self._exec_generate(node)
        elif node.op == OpType.PATCH:
            return self._exec_patch(node)
        elif node.op == OpType.CONCAT:
            return self._exec_concat(node)
        elif node.op == OpType.REPEAT:
            return self._exec_repeat(node)
        else:
            raise ValueError(f"Unknown op: {node.op}")

    def _exec_literal(self, node: ExpanderNode) -> bytes:
        """Execute LITERAL: fetch from ChunkVault."""
        chunk_hash = node.params["chunk_hash"]

        if self._vault is None and self.vault_path:
            from vaults.chunk_vault_hardened import ChunkVaultHardened
            self._vault = ChunkVaultHardened(self.vault_path)

        if self._vault:
            return self._vault.get_chunk(chunk_hash)
        else:
            raise ValueError("No vault configured for LITERAL node")

    def _exec_copy(self, node: ExpanderNode) -> bytes:
        """Execute COPY: slice from dependency."""
        source = self.node_outputs[node.depends_on[0]]
        offset = node.params.get("offset", 0)
        length = node.params.get("length")

        if length:
            return source[offset:offset + length]
        return source[offset:]

    def _exec_generate(self, node: ExpanderNode) -> bytes:
        """Execute GENERATE: PRG or codebook expansion."""
        mode = node.params.get("mode")

        if mode == GenerateMode.PRG_SHAKE.value:
            # SHAKE256 expansion
            import hashlib
            seed = bytes.fromhex(node.params["seed"])
            size = node.params["output_size"]
            shake = hashlib.shake_256(seed)
            return shake.digest(size)

        elif mode == GenerateMode.CODEBOOK_VQ.value:
            return self._exec_codebook_vq(node)

        elif mode == GenerateMode.SPLINE_CDF.value:
            return self._exec_spline_cdf(node)

        elif mode == GenerateMode.IFS_FRACTAL.value:
            return self._exec_ifs_fractal(node)

        else:
            raise ValueError(f"Unknown generate mode: {mode}")

    def _exec_codebook_vq(self, node: ExpanderNode) -> bytes:
        """
        Execute CODEBOOK_VQ: decode CDNA block using codebook.

        Supports both CDNAv1 (HXZC) and CDNAv2 (HXZ2) formats.

        Params:
            shard_path: Path to CDNA shard (relative to cdna_base_path or absolute)
            block_index: Which block to decode (0-indexed, v2 only)
            start_row: Optional - decode specific row range start
            end_row: Optional - decode specific row range end
            dequantize: Whether to dequantize to float32 (default True)

        Returns:
            Dequantized weights as bytes (float32 by default)
        """
        from helix_cdc.regrow.cdna_stream_v2 import load_cdna_auto

        shard_path = node.params.get("shard_path")
        block_index = node.params.get("block_index")
        start_row = node.params.get("start_row")
        end_row = node.params.get("end_row")
        dequantize = node.params.get("dequantize", True)

        # Resolve path
        if self.cdna_base_path and not Path(shard_path).is_absolute():
            full_path = self.cdna_base_path / shard_path
        else:
            full_path = Path(shard_path)

        # Get or create reader (cached) - auto-detects v1 vs v2
        cache_key = str(full_path)
        if cache_key not in self._cdna_readers:
            self._cdna_readers[cache_key] = load_cdna_auto(full_path)

        reader = self._cdna_readers[cache_key]

        # Get shape info
        rows = reader.rows
        cols = reader.cols
        codebook = reader.codebook

        # Determine row range
        if block_index is not None:
            # Decode specific block (v2 only)
            if not hasattr(reader, 'block_rows'):
                raise ValueError("block_index requires CDNAv2 format")
            start_row = block_index * reader.block_rows
            end_row = min(start_row + reader.block_rows, rows)
        elif start_row is not None and end_row is not None:
            # Use specified range, handle -1 sentinel for "all rows"
            if end_row == -1:
                end_row = rows
        else:
            # Default: decode entire tensor
            start_row = 0
            end_row = rows

        # Load indices
        if hasattr(reader, 'stream_rows'):
            # CDNAv2: use streaming
            indices, _ = reader.stream_rows(start_row, end_row, emit_receipt=False)
        else:
            # CDNAv1: load all and slice
            all_indices = reader.get_all_indices()  # [M, K] uint8
            indices = all_indices[start_row:end_row]

        if dequantize:
            # Dequantize: W[i,j] = codebook[indices[i,j]]
            weights = codebook[indices].astype(np.float32)
            return weights.tobytes()
        else:
            # Return raw uint8 indices
            return indices.tobytes()

    def _exec_spline_cdf(self, node: ExpanderNode) -> bytes:
        """
        Execute SPLINE_CDF: sample from a distribution defined by spline control points.

        This is useful for generating structured random values that follow a learned
        distribution (e.g., weight magnitude distribution of a neural network layer).

        Params:
            seed: Hex string seed for deterministic sampling
            output_size: Number of float32 values to generate
            control_points: List of [x, cdf_y] pairs defining the CDF
                           x values should be in ascending order
                           cdf_y values should be in [0, 1] and monotonically increasing
            dtype: Output dtype ('float32' or 'float16')

        The algorithm:
        1. Generate uniform random values in [0, 1] using SHAKE256
        2. Invert the CDF via linear interpolation to get samples

        Returns:
            Sampled values as bytes (float32 by default)
        """
        seed = bytes.fromhex(node.params.get("seed", "00"))
        output_size = node.params.get("output_size", 256)
        control_points = node.params.get("control_points", [])
        dtype = node.params.get("dtype", "float32")

        if not control_points or len(control_points) < 2:
            # Default: uniform distribution on [-1, 1]
            control_points = [[-1.0, 0.0], [1.0, 1.0]]

        # Extract x (values) and y (CDF probabilities)
        x_vals = np.array([p[0] for p in control_points], dtype=np.float64)
        cdf_vals = np.array([p[1] for p in control_points], dtype=np.float64)

        # Generate uniform samples using SHAKE256
        shake = hashlib.shake_256(seed)
        # Need 4 bytes per float32 for uniform generation
        raw_bytes = shake.digest(output_size * 4)
        # Convert to uint32 and normalize to [0, 1]
        uint_vals = np.frombuffer(raw_bytes, dtype=np.uint32)
        uniform_samples = uint_vals.astype(np.float64) / (2**32 - 1)

        # Invert CDF via linear interpolation
        # For each uniform sample u, find x such that CDF(x) = u
        samples = np.interp(uniform_samples, cdf_vals, x_vals)

        if dtype == "float16":
            return samples.astype(np.float16).tobytes()
        else:
            return samples.astype(np.float32).tobytes()

    def _exec_ifs_fractal(self, node: ExpanderNode) -> bytes:
        """
        Execute IFS_FRACTAL: generate values using Iterated Function System.

        IFS generates structured, self-similar patterns through repeated application
        of affine transformations. This can create compact encodings of structured data.

        Params:
            seed: Hex string seed for deterministic generation
            output_size: Number of float32 values to generate
            transforms: List of transform dicts, each with:
                        - 'a', 'b', 'c', 'd': 2x2 matrix coefficients
                        - 'e', 'f': translation coefficients
                        - 'p': probability weight
            iterations: Number of chaos game iterations (default: output_size * 10)
            warmup: Iterations to discard before collecting (default: 100)
            output_dims: Number of dimensions to output per point (1 or 2)
            scale: Scale factor for output values (default: 1.0)
            dtype: Output dtype ('float32' or 'float16')

        Classic transforms:
        - Sierpinski triangle: 3 transforms with scale 0.5
        - Barnsley fern: 4 transforms with different probabilities
        - Custom learned transforms for weight patterns

        Returns:
            Generated values as bytes
        """
        seed = bytes.fromhex(node.params.get("seed", "00"))
        output_size = node.params.get("output_size", 256)
        transforms = node.params.get("transforms", [])
        iterations = node.params.get("iterations", output_size * 10)
        warmup = node.params.get("warmup", 100)
        output_dims = node.params.get("output_dims", 1)
        scale = node.params.get("scale", 1.0)
        dtype = node.params.get("dtype", "float32")

        if not transforms:
            # Default: simple shrink-and-shift (generates uniform-ish values)
            transforms = [
                {"a": 0.5, "b": 0.0, "c": 0.0, "d": 0.5, "e": 0.0, "f": 0.0, "p": 0.33},
                {"a": 0.5, "b": 0.0, "c": 0.0, "d": 0.5, "e": 0.5, "f": 0.0, "p": 0.33},
                {"a": 0.5, "b": 0.0, "c": 0.0, "d": 0.5, "e": 0.25, "f": 0.5, "p": 0.34},
            ]

        # Normalize probabilities
        total_p = sum(t.get("p", 1.0) for t in transforms)
        probs = np.array([t.get("p", 1.0) / total_p for t in transforms])
        cumulative_probs = np.cumsum(probs)

        # Generate random choices using SHAKE256
        shake = hashlib.shake_256(seed)
        random_bytes = shake.digest((iterations + warmup) * 4)
        random_vals = np.frombuffer(random_bytes, dtype=np.uint32).astype(np.float64) / (2**32 - 1)

        # Run chaos game
        x, y = 0.0, 0.0
        points = []

        for i in range(iterations + warmup):
            # Select transform based on random value
            r = random_vals[i]
            t_idx = np.searchsorted(cumulative_probs, r)
            t_idx = min(t_idx, len(transforms) - 1)
            t = transforms[t_idx]

            # Apply affine transformation: [x', y'] = [a b; c d] @ [x, y] + [e, f]
            a, b = t.get("a", 1.0), t.get("b", 0.0)
            c, d = t.get("c", 0.0), t.get("d", 1.0)
            e, f = t.get("e", 0.0), t.get("f", 0.0)

            x_new = a * x + b * y + e
            y_new = c * x + d * y + f
            x, y = x_new, y_new

            # Collect after warmup
            if i >= warmup:
                if output_dims == 2:
                    points.extend([x * scale, y * scale])
                else:
                    # Interleave or just use x
                    points.append(x * scale)

        # Trim to exact output size
        points = np.array(points[:output_size], dtype=np.float64)

        # Pad if needed
        if len(points) < output_size:
            points = np.pad(points, (0, output_size - len(points)), mode='wrap')

        if dtype == "float16":
            return points.astype(np.float16).tobytes()
        else:
            return points.astype(np.float32).tobytes()

    def _exec_patch(self, node: ExpanderNode) -> bytes:
        """
        Execute PATCH: apply HXZO sidecar corrections to base tensor.

        Params:
            sidecar_path: Path to HXZO sidecar (relative to sidecar_base_path or absolute)
            shape: Tuple of (rows, cols) for the tensor
            dtype: Data type of base tensor ('float32' or 'float16')

        The base dependency should be float32 bytes from CODEBOOK_VQ or similar.
        Sidecar contains (positions, values) to overwrite at specific flat indices.
        """
        from helix_cdc.regrow.hxzo_sidecar import read_outlier_sidecar

        base_bytes = self.node_outputs[node.depends_on[0]]
        sidecar_path = node.params.get("sidecar_path")
        shape = node.params.get("shape")  # (rows, cols)
        dtype = node.params.get("dtype", "float32")

        # Resolve sidecar path
        if self.sidecar_base_path and not Path(sidecar_path).is_absolute():
            full_path = self.sidecar_base_path / sidecar_path
        else:
            full_path = Path(sidecar_path)

        # Convert base bytes to numpy array
        if dtype == "float32":
            base_array = np.frombuffer(base_bytes, dtype=np.float32).copy()
        elif dtype == "float16":
            base_array = np.frombuffer(base_bytes, dtype=np.float16).copy()
        else:
            raise ValueError(f"Unsupported dtype for PATCH: {dtype}")

        # Read sidecar
        positions, values, meta = read_outlier_sidecar(str(full_path))

        # Apply corrections
        # positions are flat indices, values are fp16
        for i in range(len(positions)):
            pos = int(positions[i])
            if 0 <= pos < len(base_array):
                base_array[pos] = float(values[i])

        return base_array.tobytes()

    def _exec_concat(self, node: ExpanderNode) -> bytes:
        """Execute CONCAT: join dependencies."""
        parts = [self.node_outputs[dep] for dep in node.depends_on]
        return b"".join(parts)

    def _exec_repeat(self, node: ExpanderNode) -> bytes:
        """Execute REPEAT: multiply template."""
        template = self.node_outputs[node.depends_on[0]]
        count = node.params["count"]
        return template * count
