tilesets as finite state machines

This commit is contained in:
David Allemang
2026-09-04 17:02:15 -04:00
parent 3149659af9
commit dd460f3973
4 changed files with 613 additions and 333 deletions

434
finite-state-refine.py Normal file
View File

@@ -0,0 +1,434 @@
from enum import IntEnum
from dataclasses import dataclass
from typing import Tuple, List, Set, Dict, Union
from functools import cmp_to_key
import numba
import tqdm
# ==========================================
# 1. CORE TYPES & ENUMS
# ==========================================
class Edge(IntEnum):
RISING = 0
FALLING_PENDING = 1
FALLING_UTILIZED = 2
class Context(IntEnum):
OTHER = 0
WIRE = 1
DIODE = 2
START_STATE = "START"
@dataclass(frozen=True)
class Entry:
priority: int
delay: int
name: str = '-'
@dataclass(frozen=True)
class Transition:
from_state: Union[Tuple[Edge, Context], str]
to_state: Union[Tuple[Edge, Context], str]
entries: Tuple[Entry, ...]
def total_delay(self) -> int:
return sum(e.delay for e in self.entries)
class TilesetFSM:
def __init__(self, transitions: List[Transition], start_state: str, accept_states: Set[Union[Tuple[Edge, Context], str]]):
self.transitions = transitions
self.start_state = start_state
self.accept_states = accept_states
# Pre-group transitions by their source state for O(1) branching lookup
self.adj_list = {state: [] for state in set(t.from_state for t in transitions)}
for t in transitions:
self.adj_list[t.from_state].append(t)
# ==========================================
# 2. EVENT-DRIVEN SORTING (Numba-Ready)
# ==========================================
def get_timing_events(chain: Tuple[Entry, ...]) -> Tuple[Tuple[int, int], ...]:
"""Converts a chain into a dense tuple of (tick, priority) events."""
events = []
tick = 0
for e in chain:
if e.delay > 0:
events.append((tick, e.priority))
tick += e.delay
return tuple(events)
def cmp_chains(chain_a: Tuple[Entry, ...], chain_b: Tuple[Entry, ...]) -> int:
"""
O(K) lexicographical comparison replicating the behavior of `-inf` padding.
Strictly uses integers, making it a prime candidate for Numba acceleration.
"""
events_a = get_timing_events(chain_a)
events_b = get_timing_events(chain_b)
idx_a, idx_b = 0, 0
while idx_a < len(events_a) and idx_b < len(events_b):
tick_a, pri_a = events_a[idx_a]
tick_b, pri_b = events_b[idx_b]
if tick_a == tick_b:
if pri_a < pri_b: return -1
if pri_a > pri_b: return 1
idx_a += 1
idx_b += 1
elif tick_a < tick_b:
# A has an event while B implies -inf. -inf is smaller, so B is smaller.
return 1
else:
return -1
if idx_a < len(events_a): return 1
if idx_b < len(events_b): return -1
# Tie-breaker on component count (fewer components is preferred/smaller)
if len(chain_a) < len(chain_b): return -1
if len(chain_a) > len(chain_b): return 1
return 0
# ==========================================
# 3. GENERATE THE CARTESIAN STATE MACHINE
# ==========================================
transitions = []
for edge in Edge:
for ctx in Context:
state = (edge, ctx)
# A. Wire
if ctx == Context.DIODE:
transitions.append(Transition(state, (edge, Context.WIRE), (Entry(0, 0, '---'),)))
# B. Torch
if ctx == Context.DIODE:
if edge == Edge.RISING:
transitions.append(Transition(state, (Edge.FALLING_PENDING, Context.OTHER), (Entry(0, 2, 'torch '),)))
elif edge == Edge.FALLING_UTILIZED:
transitions.append(Transition(state, (Edge.RISING, Context.OTHER), (Entry(0, 2, 'torch '),)))
# C. Comparator
cmp_pri = -1 if ctx == Context.DIODE else 0
transitions.append(Transition(state, (edge, Context.DIODE), (Entry(cmp_pri, 2, 'cmp'),)))
# D. Repeaters
for d in (2, 4, 6, 8):
if ctx == Context.DIODE:
transitions.append(Transition(state, (edge, Context.DIODE), (Entry(-3, d, f're{d}'),)))
else:
if edge == Edge.RISING:
if ctx != Context.WIRE:
transitions.append(Transition(state, (edge, Context.DIODE), (Entry(-1, d, f're{d}'),)))
else:
transitions.append(Transition(state, (Edge.FALLING_UTILIZED, Context.DIODE), (Entry(-2, d, f're{d}'),)))
# E. Fluids
if edge == Edge.RISING:
for d in (5, 10, 30):
fluid_macro = (Entry(0, 2, 'obs'), Entry(1, d, f'fluid{d}'), Entry(0, 4, 'disp'))
transitions.append(Transition(state, (Edge.RISING, Context.OTHER), fluid_macro))
# START state boundary
start_transitions = []
for t in transitions:
if t.from_state == (Edge.RISING, Context.OTHER):
if not (len(t.entries) == 1 and t.entries[0].name == '---'):
start_transitions.append(Transition(START_STATE, t.to_state, t.entries))
start_transitions.append(Transition(START_STATE, (Edge.FALLING_PENDING, Context.OTHER), (Entry(0, 2, 'torch '),)))
transitions.extend(start_transitions)
all_states = set(t.from_state for t in transitions) | set(t.to_state for t in transitions)
# Accept states are any normal state tuple (skip START_STATE string)
accept_states = {s for s in all_states if isinstance(s, tuple) and s[0] == Edge.RISING}
model = TilesetFSM(
transitions=transitions,
start_state=START_STATE,
accept_states=accept_states
)
# ==========================================
# 4. FAST DP GENERATOR
# ==========================================
def generate_all_fast(model: TilesetFSM, target_delay: int) -> List[List[Entry]]:
all_states = set(model.adj_list.keys()) | model.accept_states
can_reach = {d: {s: False for s in all_states} for d in range(target_delay + 1)}
for s in model.accept_states:
can_reach[0][s] = True
for d in range(1, target_delay + 1):
for state in all_states:
for t in model.adj_list.get(state, []):
t_delay = t.total_delay()
if t_delay <= d and can_reach[d - t_delay][t.to_state]:
can_reach[d][state] = True
unique_chains = {}
stack = [(model.start_state, target_delay, ())]
with tqdm.tqdm(desc="Traversing DFS") as bar:
while stack:
curr_state, rem_delay, chain = stack.pop()
bar.update(1)
if rem_delay == 0 and curr_state in model.accept_states:
# Use the dense events tuple as an instant hashable uniqueness key
events_key = get_timing_events(chain)
if events_key not in unique_chains:
unique_chains[events_key] = chain
else:
if len(chain) < len(unique_chains[events_key]):
unique_chains[events_key] = chain
continue
for t in model.adj_list.get(curr_state, []):
t_delay = t.total_delay()
if t_delay <= rem_delay and can_reach[rem_delay - t_delay][t.to_state]:
stack.append((t.to_state, rem_delay - t_delay, chain + t.entries))
# Sort strictly using the dense event comparator
sorted_chains = sorted(unique_chains.values(), key=cmp_to_key(cmp_chains))
return [list(c) for c in sorted_chains]
def cmp_prefix(events_prefix: Tuple[Tuple[int, int], ...], events_target: Tuple[Tuple[int, int], ...]) -> int:
"""
Evaluates prefix divergence. Returns 0 if they match exactly so far.
If it returns non-zero, the divergence is permanent for ANY valid completion.
"""
idx_p, idx_t = 0, 0
while idx_p < len(events_prefix) and idx_t < len(events_target):
tick_p, pri_p = events_prefix[idx_p]
tick_t, pri_t = events_target[idx_t]
if tick_p == tick_t:
if pri_p < pri_t: return -1
if pri_p > pri_t: return 1
idx_p += 1
idx_t += 1
elif tick_p < tick_t:
return 1
else:
return -1
return 0
def neighborhood(model: TilesetFSM, target_chain: List[Entry], before: int, after: int) -> Tuple[List[List[Entry]], List[List[Entry]]]:
"""
Finds the exact immediate neighborhood around a target chain without enumerating the language.
Uses DP-guided Branch and Bound to prune the astronomical search space.
"""
target_delay = sum(e.delay for e in target_chain)
events_target = get_timing_events(tuple(target_chain))
# 1. Build the Reachability Table (O(States * Target Delay) - Extremely Fast)
all_states = set(model.adj_list.keys()) | model.accept_states
can_reach = {d: {s: False for s in all_states} for d in range(target_delay + 1)}
for s in model.accept_states: can_reach[0][s] = True
for d in range(1, target_delay + 1):
for state in all_states:
for t in model.adj_list.get(state, []):
t_delay = t.total_delay()
if t_delay <= d and can_reach[d - t_delay][t.to_state]:
can_reach[d][state] = True
# 2. Sort transitions to aggressively target the bounds
def cmp_transitions(t1: Transition, t2: Transition) -> int:
return cmp_chains(t1.entries, t2.entries)
# Ascending sort: Explores lexicographically smaller transitions first
adj_list_asc = {k: sorted(v, key=cmp_to_key(cmp_transitions)) for k, v in model.adj_list.items()}
# Descending sort: Explores lexicographically larger transitions first
adj_list_desc = {k: sorted(v, key=cmp_to_key(cmp_transitions), reverse=True) for k, v in model.adj_list.items()}
# --- SEARCH AFTER ---
found_after = {}
ub_events, ub_chain = None, None
stack = [(model.start_state, target_delay, (), ())]
while stack and after > 0:
curr_state, rem_delay, chain, events_so_far = stack.pop()
if rem_delay == 0 and curr_state in model.accept_states:
if cmp_chains(chain, tuple(target_chain)) > 0:
ev_key = get_timing_events(chain)
# Deduplicate and tie-break on length
if ev_key not in found_after or len(chain) < len(found_after[ev_key]):
found_after[ev_key] = chain
if len(found_after) > after:
sorted_items = sorted(found_after.values(), key=cmp_to_key(cmp_chains))
found_after = {get_timing_events(c): c for c in sorted_items[:after]}
ub_chain = sorted_items[after - 1]
ub_events = get_timing_events(ub_chain)
continue
# Prune branches that are <= target or > our worst accepted bound
div_t = cmp_prefix(events_so_far, events_target)
if div_t < 0: continue
if ub_events and cmp_prefix(events_so_far, ub_events) > 0: continue
# Push in reverse so the smallest transitions are popped/explored first
for t in reversed(adj_list_asc.get(curr_state, [])):
t_delay = t.total_delay()
if t_delay <= rem_delay and can_reach[rem_delay - t_delay][t.to_state]:
new_chain = chain + t.entries
stack.append((t.to_state, rem_delay - t_delay, new_chain, get_timing_events(new_chain)))
# --- SEARCH BEFORE ---
found_before = {}
lb_events, lb_chain = None, None
stack = [(model.start_state, target_delay, (), ())]
while stack and before > 0:
curr_state, rem_delay, chain, events_so_far = stack.pop()
if rem_delay == 0 and curr_state in model.accept_states:
if cmp_chains(chain, tuple(target_chain)) < 0:
ev_key = get_timing_events(chain)
if ev_key not in found_before or len(chain) < len(found_before[ev_key]):
found_before[ev_key] = chain
if len(found_before) > before:
sorted_items = sorted(found_before.values(), key=cmp_to_key(cmp_chains))
found_before = {get_timing_events(c): c for c in sorted_items[-before:]}
lb_chain = sorted_items[-before]
lb_events = get_timing_events(lb_chain)
continue
# Prune branches that are >= target or < our worst accepted bound
div_t = cmp_prefix(events_so_far, events_target)
if div_t > 0: continue
if lb_events and cmp_prefix(events_so_far, lb_events) < 0: continue
# Push in reverse so the largest transitions are popped/explored first
for t in reversed(adj_list_desc.get(curr_state, [])):
t_delay = t.total_delay()
if t_delay <= rem_delay and can_reach[rem_delay - t_delay][t.to_state]:
new_chain = chain + t.entries
stack.append((t.to_state, rem_delay - t_delay, new_chain, get_timing_events(new_chain)))
sorted_before = sorted(found_before.values(), key=cmp_to_key(cmp_chains))
sorted_after = sorted(found_after.values(), key=cmp_to_key(cmp_chains))
return [list(c) for c in sorted_before], [list(c) for c in sorted_after]
def format_csv_timing(chain: List[Entry], target_delay: int) -> str:
"""Helper to reconstruct your original CSV string format without allocating infs."""
events = dict(get_timing_events(tuple(chain)))
out = []
for tick in range(target_delay):
if tick in events:
out.append(f"{events[tick]:+}")
else:
out.append(" ")
return " ".join(out)
# if __name__ == "__main__":
# target_delay = 20
#
# with open('chains.csv', 'w') as f:
# for chain in generate_all_fast(model, target_delay):
# for el in chain:
# f.write(el.name)
# f.write(' ')
# f.write(',')
# f.write(format_csv_timing(chain, target_delay))
# f.write('\n')
def parse_names(model: TilesetFSM, names: List[str]) -> List[Entry]:
"""
Parses a sequence of string names into a validated List[Entry] with inferred priorities.
"""
current_state = model.start_state
i = 0
inferred_chain = []
while i < len(names):
match_found = False
# Look at all valid transitions from our current state
for t in model.adj_list.get(current_state, []):
# Clean up FSM internal names (e.g., 'torch ' -> 'torch')
t_names = [e.name.strip() for e in t.entries]
# Normalize user input for comparison
chunk = names[i : i + len(t_names)]
chunk_norm = []
for n in chunk:
n = n.strip()
if n == 'wire': n = '---'
if n.startswith('rep'): n = n.replace('rep', 're')
chunk_norm.append(n)
# Check if this transition matches the input
if chunk_norm == t_names:
inferred_chain.extend(t.entries)
current_state = t.to_state
i += len(t_names)
match_found = True
break
if not match_found:
raise ValueError(
f"Syntax Error: Cannot place '{names[i]}' while in state {current_state} "
f"at index {i}."
)
if current_state not in model.accept_states:
raise ValueError(
f"Unexpected EOF: Sequence ended in non-accepting state {current_state}. "
"Did you forget to un-invert a torch?"
)
return inferred_chain
# ==========================================
# USAGE EXAMPLE
# ==========================================
if __name__ == "__main__":
# Your target sequence (using friendly names!)
input_str = 'cmp rep2 torch rep2 torch'
names_list = input_str.split()
print(f"Parsing: {names_list}...")
target_chain = parse_names(model, names_list)
print(f"Finding neighborhood...")
before_chains, after_chains = neighborhood(model, target_chain, before=5, after=5)
def print_chain(chain: List[Entry], prefix: str = ""):
# Reconstruct the string without trailing spaces from the names
clean_names = " ".join(e.name.strip() for e in chain)
# Calculate the linearized delay score for visual context
events = get_timing_events(tuple(chain))
print(f"{prefix}{clean_names:<35} | Events: {events}")
print("\n--- BEFORE ---")
for c in before_chains:
print_chain(c)
print("\n--- TARGET ---")
print_chain(target_chain, prefix=">> ")
print("\n--- AFTER ---")
for c in after_chains:
print_chain(c)

View File

@@ -1,19 +1,20 @@
import math
from contextlib import redirect_stdout
from dataclasses import dataclass from dataclasses import dataclass
from itertools import islice from math import isfinite, inf
from typing import Tuple, Optional, List, Set from typing import Tuple, Optional, List, Set
import tqdm
@dataclass(frozen=True) @dataclass(frozen=True)
class Entry: class Entry:
priority: int priority: int
delay: int delay: int
name: str = '-' name: str = '-'
def linear_parts(self) -> Tuple[int, ...]: def linear_parts(self) -> Tuple[int | float, ...]:
if self.delay == 0: if self.delay == 0:
return () return ()
return (self.priority,) + (-9999,) * (self.delay - 1) return (self.priority,) + (-inf,) * (self.delay - 1)
@dataclass(frozen=True) @dataclass(frozen=True)
class Transition: class Transition:
@@ -118,61 +119,83 @@ class TilesetFSM:
if current_state not in self.accept_states: if current_state not in self.accept_states:
raise ValueError( raise ValueError(
f"Unexpected EOF: Sequence ended in non-accepting state '{current_state}'. " f"Unexpected EOF: Sequence ended in non-accepting state '{current_state}'. "
"Did you forget to un-invert a torch?" "Did you forget to un-invert a torch ?"
) )
return inferred_chain return inferred_chain
# ==========================================
# 1. GENERATE THE CARTESIAN STATE MACHINE
# ==========================================
transitions = [] transitions = []
for signal in ["NORMAL", "INVERTED"]: # We expand our signals to track if the unique -2 priority was utilized
for faces in ["OTHER", "DIODE"]: signals = ["NORMAL", "INV_PENDING", "INV_UTILIZED"]
current_state = f"{signal}_{faces}" contexts = ["DIODE", "WIRE", "OTHER"]
# A. Wire (Zero-delay state reset. Breaks diode chains) for signal in signals:
# FIX: Only allow wire if we are actually facing a diode. for context in contexts:
if faces == "DIODE": current_state = f"{signal}_{context}"
# A. Wire
if context == "DIODE":
transitions.append(Transition( transitions.append(Transition(
from_state=current_state, from_state=current_state,
to_state=f"{signal}_OTHER", to_state=f"{signal}_WIRE",
entries=(Entry(0, 0, 'wire'),) entries=(Entry(0, 0, '---'),)
)) ))
# B. Comparator # B. Torch
cmp_pri = -1 if faces == "DIODE" else 0 if context == "DIODE":
if signal == "NORMAL":
# Enter inverted mode as PENDING (haven't used -2 yet)
transitions.append(Transition(
from_state=current_state,
to_state="INV_PENDING_OTHER",
entries=(Entry(0, 2, 'torch '),)
))
elif signal == "INV_UTILIZED":
# Un-invert is ONLY allowed if we successfully utilized the -2 priority
transitions.append(Transition(
from_state=current_state,
to_state="NORMAL_OTHER",
entries=(Entry(0, 2, 'torch '),)
))
# Notice there is no torch transition for INV_PENDING!
# C. Comparator
cmp_pri = -1 if context == "DIODE" else 0
transitions.append(Transition( transitions.append(Transition(
from_state=current_state, from_state=current_state,
to_state=f"{signal}_DIODE", to_state=f"{signal}_DIODE",
entries=(Entry(cmp_pri, 2, 'cmp'),) entries=(Entry(cmp_pri, 2, 'cmp'),)
)) ))
# C. Repeaters # D. Repeaters
for d in (2, 4, 6, 8): for d in (2, 4, 6, 8):
if faces == "DIODE": if context == "DIODE":
# Facing a diode yields -3 and leaves our utilization state unchanged
rep_pri = -3 rep_pri = -3
next_signal = signal
allow_rep = True
else: else:
rep_pri = -1 if signal == "NORMAL" else -2 if signal == "NORMAL":
rep_pri = -1
next_signal = signal
allow_rep = (context != "WIRE") # Block redundant rep after
else:
# We are in an inverted mode and facing OTHER/WIRE.
# This yields the special -2 priority!
rep_pri = -2
next_signal = "INV_UTILIZED" # Mark the -2 as successfully utilized
allow_rep = True
transitions.append(Transition( if allow_rep:
from_state=current_state, transitions.append(Transition(
to_state=f"{signal}_DIODE", from_state=current_state,
entries=(Entry(rep_pri, d, f'rep{d}'),) to_state=f"{next_signal}_DIODE",
)) entries=(Entry(rep_pri, d, f're{d}'),)
))
# D. Torch (Inverts signal, is not a diode) # E. Fluids
next_sig = "INVERTED" if signal == "NORMAL" else "NORMAL"
transitions.append(Transition(
from_state=current_state,
to_state=f"{next_sig}_OTHER",
entries=(Entry(0, 2, 'torch'),)
))
# E. Fluids (Only allowed on rising edges/NORMAL)
if signal == "NORMAL": if signal == "NORMAL":
for d in (5, 10, 30): for d in (5, 10, 30):
fluid_macro = (Entry(0, 2, 'obs'), Entry(1, d, f'fluid{d}'), Entry(0, 4, 'disp')) fluid_macro = (Entry(0, 2, 'obs'), Entry(1, d, f'fluid{d}'), Entry(0, 4, 'disp'))
@@ -182,15 +205,36 @@ for signal in ["NORMAL", "INVERTED"]:
entries=fluid_macro entries=fluid_macro
)) ))
# Initialize assuming the end of the chain faces nothing (OTHER). # ==========================================
# We require the final accepting state to be NORMAL (un-inverted) to be valid. # 2. CREATE THE BOUNDARY 'START' STATE
# ==========================================
start_transitions = []
for t in transitions:
if t.from_state == "NORMAL_OTHER":
# Allow everything EXCEPT the zero-delay at the very start
if not (len(t.entries) == 1 and t.entries[0].name == '---'):
start_transitions.append(Transition(
from_state="START",
to_state=t.to_state,
entries=t.entries
))
start_transitions.append(Transition(
from_state="START",
to_state="INV_PENDING_OTHER",
entries=(Entry(0, 2, 'torch '),)
))
transitions.extend(start_transitions)
all_states = set(t.from_state for t in transitions) | set(t.to_state for t in transitions)
accept_states = {s for s in all_states if s.startswith("NORMAL")}
model = TilesetFSM( model = TilesetFSM(
transitions=transitions, transitions=transitions,
start_state='NORMAL_OTHER', start_state='START',
accept_states={'NORMAL_OTHER', 'NORMAL_DIODE'} accept_states=accept_states
) )
# ========================================== # ==========================================
# 2. USAGE EXAMPLES # 2. USAGE EXAMPLES
# ========================================== # ==========================================
@@ -202,308 +246,81 @@ def display_chain(entries):
def linearize_entries(entries): def linearize_entries(entries):
return tuple(p for e in entries for p in e.linear_parts()) return tuple(p for e in entries for p in e.linear_parts())
# if __name__ == "__main__": def generate_all_fast(model: TilesetFSM, target_delay: int) -> List[List[Entry]]:
# # Example 1: The Zero-Delay Wire breaking the context
# chain_names_1 = ["cmp", "wire", "cmp"]
# entries_1 = model.parse_names(chain_names_1)
#
# print(f"Input: {chain_names_1}")
# print(f"Inferred: {display_chain(entries_1)}")
# # Output: cmp(0) -> wire(0) -> cmp(0)
# print()
#
# # Example 2: Diode-facing-diode context
# chain_names_2 = ["cmp", "cmp"]
# entries_2 = model.parse_names(chain_names_2)
#
# print(f"Input: {chain_names_2}")
# print(f"Inferred: {display_chain(entries_2)}")
# # Output: cmp(0) -> cmp(-1)
# print()
#
# # Example 3: Falling-edge inversion behavior
# chain_names_3 = ["torch", "rep4", "wire", "rep4", "torch"]
# entries_3 = model.parse_names(chain_names_3)
#
# print(f"Input: {chain_names_3}")
# print(f"Inferred: {display_chain(entries_3)}")
# # Output: torch(0) -> rep4(-3) -> wire(0) -> rep4(-2) -> torch(0)
# # (Notice the first rep4 faces the torch (a diode), getting -3,
# # while the second faces the wire (OTHER) during an inverted state, getting -2)
# print()
#
# # Example 4: Compiling a sequence to use as a search target
# target_names = ["cmp", "cmp", "cmp", "cmp"]
# target_entries = model.parse_names(target_names)
# target_lin = linearize_entries(target_entries)
#
# print(f"Finding NEXT chain for target: {target_names}")
# print(f"Inferred: {display_chain(target_entries)}")
# next_chain = model.find_next(target_lin, target_delay=8)
# if next_chain:
# print(f"Next: {display_chain(next_chain)}")
# else:
# print(f'Next: None')
# # Output: Next: rep2(-1) -> rep2(-3) -> rep2(-3) -> rep2(-3)
def enumerate_chains(model: TilesetFSM, target_delay: int):
""" """
Yields every valid tile sequence of the target delay in lexicographical order. Uses Dynamic Programming to generate all valid chains of a given delay in O(N log N) time,
completely eliminating dead-end traversal.
""" """
# Bootstrap with an infinitely low sequence to find the very first chain # 1. DP Table Setup
current_lin = (-999,) * target_delay # can_reach[d][state] = True if we can reach an accept state from 'state' in exactly 'd' ticks
all_states = set(model.adj_list.keys()) | model.accept_states
can_reach = {d: {s: False for s in all_states} for d in range(target_delay + 1)}
while True: # Base cases: Delay 0 is only valid if we are in an accept state
# Find the next valid sequence for s in model.accept_states:
next_chain = model.find_next(current_lin, target_delay) can_reach[0][s] = True
# If no next sequence exists, we've enumerated the entire language # 2. Build the DP table bottom-up
if next_chain is None: for d in range(1, target_delay + 1):
break for state in all_states:
for t in model.adj_list.get(state, []):
t_delay = t.total_delay()
# If this transition fits within our remaining delay...
if t_delay <= d:
# ...and the state it leads to can successfully finish the chain
if can_reach[d - t_delay][t.to_state]:
can_reach[d][state] = True
# 3. Guided DFS
raw_chains = []
yield next_chain def build_chain(current_state: str, remaining_delay: int, current_chain: Tuple[Entry, ...]):
bar.update(1)
if remaining_delay == 0 and current_state in model.accept_states:
raw_chains.append(list(current_chain))
return
# Update our pointer for the next iteration for t in model.adj_list.get(current_state, []):
current_lin = linearize_entries(next_chain) t_delay = t.total_delay()
if t_delay <= remaining_delay:
if can_reach[remaining_delay - t_delay][t.to_state]:
build_chain(t.to_state, remaining_delay - t_delay, current_chain + t.entries)
# ========================================== with tqdm.tqdm() as bar:
# USAGE EXAMPLE build_chain(model.start_state, target_delay, ())
# ==========================================
# if __name__ == "__main__": # 4. Mathematically Guarantee Distinct Sequences
# target_delay = 6
# print(f"Enumerating all valid chains of Delay {target_delay}...\n")
#
# chain_count = 0
# for chain in enumerate_chains(model, target_delay):
# chain_count += 1
#
# # We can extract the names to see the raw component sequence
# names = [e.name for e in chain]
# print(f"{chain_count: 3d}. {names}")
#
# print(f"Total sequences found for delay {target_delay}: {chain_count}")
with tqdm.tqdm() as bar:
unique_chains = {}
for chain in raw_chains:
# Calculate the linearized tuple to use as our uniqueness key
lin_key = tuple(p for e in chain for p in e.linear_parts())
import heapq if lin_key not in unique_chains:
from typing import List unique_chains[lin_key] = chain
bar.update(1)
else:
# TIE-BREAKER: If two chains produce the exact same physical timing,
# keep the one with the fewest components (eliminates redundant wires/torches)
if len(chain) < len(unique_chains[lin_key]):
unique_chains[lin_key] = chain
def generate_render_document(chain_entries: List['Entry']) -> str: # 5. Sort the purely unique chains
""" sorted_chains = sorted(unique_chains.values(),
Generates a setblock-style animation document for a given redstone chain. key=lambda chain: tuple(p for e in chain for p in e.linear_parts()))
"""
# Reverse chain so x=0 is the first component to activate (upstream)
components = chain_entries[::-1]
N = len(components)
# 1. Forward sweep to calculate resting states (t=0) return sorted_chains
resting_power = False
resting_states = []
for c in components:
if c.name == 'torch':
# Torches invert the resting power for downstream components
resting_power = not resting_power
resting_states.append(resting_power)
elif c.name == 'obs':
# Observers block resting power
resting_power = False
resting_states.append(False)
else:
# rep, cmp, and wire pass resting power
resting_states.append(resting_power)
lines = []
actual_visual_state = {}
posmap = {}
x = 0
for i, (c, resting) in enumerate(zip(components, resting_states)):
is_powered = "true" if resting else "false"
if c.name == 'wire':
lines.append(f"p {x} 0 0 white_concrete")
lines.append(f"p {x} -1 0 smooth_stone_slab type=top")
elif c.name == 'torch':
lines.append(f"p {x} 0 0 white_concrete")
lines.append(f"p {x} -1 0 smooth_stone_slab type=top")
x += 1
lines.append(f"p {x} 0 0 redstone_wall_torch facing=east lit={is_powered}")
lines.append(f"p {x} -1 0 smooth_stone_slab type=top")
actual_visual_state[(i, 'lit')] = is_powered
posmap[i] = x
elif c.name.startswith('rep'):
delay_ticks = int(c.name[3:]) // 2
lines.append(f"p {x} 0 0 repeater facing=west powered={is_powered} locked=false delay={delay_ticks}")
lines.append(f"p {x} -1 0 smooth_stone_slab type=top")
actual_visual_state[(i, 'powered')] = is_powered
posmap[i] = x
elif c.name == 'cmp':
lines.append(f"p {x} 0 0 comparator facing=west powered={is_powered} mode=compare")
lines.append(f"p {x} -1 0 smooth_stone_slab type=top")
actual_visual_state[(i, 'powered')] = is_powered
posmap[i] = x
elif c.name == 'obs':
lines.append(f"p {x} 0 0 observer facing=west powered=false")
lines.append(f"p {x} -1 0 smooth_stone_slab type=top")
actual_visual_state[(i, 'powered')] = "false"
posmap[i] = x
x += 1
# 4. Simulate a 2gt pulse passing through the system
# pq holds (time, component_index, input_level)
pq = []
heapq.heappush(pq, (0, 0, True))
heapq.heappush(pq, (2, 0, False))
visual_changes = {} # time -> dict of (index, prop) -> value
while pq:
t, i, level = heapq.heappop(pq)
if i >= N:
continue
c = components[i]
if c.name == 'wire':
# Instantly passes the signal to the next component
heapq.heappush(pq, (t, i+1, level))
elif c.name == 'torch':
out_level = not level
out_t = t + 2
heapq.heappush(pq, (out_t, i+1, out_level))
val_str = "true" if out_level else "false"
if out_t not in visual_changes: visual_changes[out_t] = {}
visual_changes[out_t][(i, 'lit')] = val_str
elif c.name.startswith('rep'):
delay = int(c.name[3:])
out_t = t + delay
heapq.heappush(pq, (out_t, i+1, level))
val_str = "true" if level else "false"
if out_t not in visual_changes: visual_changes[out_t] = {}
visual_changes[out_t][(i, 'powered')] = val_str
elif c.name == 'cmp':
out_t = t + 2
heapq.heappush(pq, (out_t, i+1, level))
val_str = "true" if level else "false"
if out_t not in visual_changes: visual_changes[out_t] = {}
visual_changes[out_t][(i, 'powered')] = val_str
elif c.name == 'obs':
# An observer fires a 2gt pulse whenever its input changes
out_t_on = t + 2
out_t_off = t + 4
heapq.heappush(pq, (out_t_on, i+1, True))
heapq.heappush(pq, (out_t_off, i+1, False))
if out_t_on not in visual_changes: visual_changes[out_t_on] = {}
if out_t_off not in visual_changes: visual_changes[out_t_off] = {}
# Python's heapq pops `False` before `True` if times match.
# This perfectly replicates observer pulse-extension: if an OFF and ON
# hit on the exact same tick, the ON overwrites the OFF.
visual_changes[out_t_on][(i, 'powered')] = "true"
visual_changes[out_t_off][(i, 'powered')] = "false"
# 5. Format the dynamic animation frames
sorted_times = sorted(visual_changes.keys())
for t in sorted_times:
frame_lines = []
changes_at_t = visual_changes[t]
for (i, prop), val in changes_at_t.items():
x = posmap[i]
# Only emit if it actually changes the visual state
if actual_visual_state.get((i, prop)) != val:
actual_visual_state[(i, prop)] = val
frame_lines.append(f"p {x} 0 0 {prop}={val}")
if frame_lines:
lines.append(f"t {t}")
lines.extend(frame_lines)
# 6. Add final padding frame
last_t = sorted_times[-1] if sorted_times else 0
lines.append(f"t {last_t + 4}")
lines.append("p 0 0 0")
return "\n".join(lines)
def makedoc(chain: list[Entry], z=0):
x = 0
for el in chain:
match el.name:
case 'cmp':
yield f'p {x} 0 {z} comparator facing=west powered=false mode=compare'
yield f'p {x} -1 {z} smooth_stone_slab type=top'
case 'rep2':
yield f'p {x} 0 {z} repeater facing=west powered=false locked=false delay=1'
yield f'p {x} -1 {z} smooth_stone_slab type=top'
case 'rep4':
yield f'p {x} 0 {z} repeater facing=west powered=false locked=false delay=2'
yield f'p {x} -1 {z} smooth_stone_slab type=top'
case 'rep6':
yield f'p {x} 0 {z} repeater facing=west powered=false locked=false delay=3'
yield f'p {x} -1 {z} smooth_stone_slab type=top'
case 'rep8':
yield f'p {x} 0 {z} repeater facing=west powered=false locked=false delay=4'
yield f'p {x} -1 {z} smooth_stone_slab type=top'
case 'wire':
yield f'p {x} 0 {z} white_concrete'
yield f'p {x} -1 {z} smooth_stone_slab type=top'
case 'torch':
yield f'p {x} 0 {z} redstone_wall_torch facing=east lit=false'
yield f'p {x} -1 {z} smooth_stone_slab type=top'
x -= 1
yield f'p {x} 0 {z} white_concrete'
yield f'p {x} -1 {z} smooth_stone_slab type=top'
x -= 1
# return ' '.join(el.name for el in chain)
# for el in chain:
# match el.name:
# print(el.name)
if __name__ == "__main__": if __name__ == "__main__":
target_delay = 6 target_delay = 20
with open('/home/local/KHQ/david.allemang/src/wireless/content/tilesets.typ', 'w') as f: with open('chains.csv', 'w') as f:
with redirect_stdout(f): for chain in generate_all_fast(model, target_delay):
print(''' for el in chain:
#import "/lib.typ": diorama, example, note, todo f.write(el.name)
f.write(' ')
== Tilesets <tilesets> f.write(',')
for el in linearize_entries(chain):
#show raw.where(lang: "mc-diorama"): it => diorama( f.write(f'{el:+}' if isfinite(el) else ' ')
zoom: true, f.write(' ')
theta: 180, f.write('\n')
phi: 60,
radius: 30,
height: "120em",
it.text,
)
''')
z = 0
print('```mc-diorama')
for chain in enumerate_chains(model, target_delay):
for line in makedoc(chain, z):
print(line)
z -= 1.25
print('```')

View File

@@ -2,4 +2,6 @@
name = "wireless-formalism" name = "wireless-formalism"
version = "0.1.0" version = "0.1.0"
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [] dependencies = [
"tqdm>=4.70.0",
]

27
uv.lock generated
View File

@@ -2,7 +2,34 @@ version = 1
revision = 3 revision = 3
requires-python = ">=3.14" requires-python = ">=3.14"
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "tqdm"
version = "4.70.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" },
]
[[package]] [[package]]
name = "wireless-formalism" name = "wireless-formalism"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [
{ name = "tqdm" },
]
[package.metadata]
requires-dist = [{ name = "tqdm", specifier = ">=4.70.0" }]