rogermt commited on
Commit
6cae82d
·
verified ·
1 Parent(s): 8a77716

Fix fill_color=0 bug + mixed->recolor fallback (980 lines)

Browse files
Files changed (1) hide show
  1. itt_solver/itt_engine.py +980 -0
itt_solver/itt_engine.py CHANGED
@@ -0,0 +1,980 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ITT Physics Engine for ARC-AGI
3
+ ==============================
4
+
5
+ Pure implementation of the Intent Tensor Theory solver, ported from
6
+ Sensei-Intent-Tensor/0.0_ARC_AGI (ITT_PURE_SOLVER.py v4).
7
+
8
+ Phases 1-7 of the ITT integration:
9
+ 1. PhiField dual-field (Φ_q + Φ̃)
10
+ 2. ρ_q boundary charge with physics-derived threshold
11
+ 3. SigmaResidue change typing
12
+ 4. Fan Signature 6-bit classifier
13
+ 5. TransformationRule.learn()
14
+ 6. FieldInvariants (spectral, harmonic, eigenspectrum, Fourier, frames)
15
+ 7. Rule apply methods (tile, self_tile, fill, multi_fill, period, shape, recolor)
16
+
17
+ References:
18
+ - https://github.com/Sensei-Intent-Tensor/0.0_ARC_AGI
19
+ - https://zenodo.org/records/18077258
20
+ """
21
+
22
+ import numpy as np
23
+ from typing import Dict, List, Tuple, Optional, Set, Any
24
+ from dataclasses import dataclass, field
25
+ from collections import deque, Counter
26
+ from math import gcd
27
+ from functools import reduce
28
+
29
+
30
+ # =============================================================================
31
+ # PHASE 1: PhiField — Dual-Field Representation
32
+ # =============================================================================
33
+
34
+ class PhiField:
35
+ """
36
+ Φ — Dual-Field Representation.
37
+
38
+ Φ_q: quantized int (ARC colors 0-9) — semantic truth
39
+ Φ̃: continuous float (2-step discrete diffusion) — operator stability
40
+
41
+ Rule: Read Φ_q for semantics. Compute on Φ̃ for operators.
42
+ """
43
+
44
+ def __init__(self, data):
45
+ arr = np.array(data, dtype=np.float64)
46
+ self._q = np.rint(arr).astype(np.int32)
47
+ self._tilde = self._compute_smooth(self._q)
48
+
49
+ @staticmethod
50
+ def _compute_smooth(q: np.ndarray, iters: int = 2) -> np.ndarray:
51
+ """Compute Φ̃ from Φ_q via discrete diffusion (∇² averaging)."""
52
+ x = q.astype(np.float64)
53
+ h, w = x.shape
54
+ for _ in range(iters):
55
+ new_x = x.copy()
56
+ for i in range(h):
57
+ for j in range(w):
58
+ total = x[i, j]
59
+ count = 1
60
+ if i > 0: total += x[i-1, j]; count += 1
61
+ if i < h-1: total += x[i+1, j]; count += 1
62
+ if j > 0: total += x[i, j-1]; count += 1
63
+ if j < w-1: total += x[i, j+1]; count += 1
64
+ new_x[i, j] = total / count
65
+ x = new_x
66
+ return x
67
+
68
+ @property
69
+ def q(self) -> np.ndarray:
70
+ """Φ_q: Quantized field (int). Use for SEMANTICS."""
71
+ return self._q
72
+
73
+ @property
74
+ def tilde(self) -> np.ndarray:
75
+ """Φ̃: Continuous field (float). Use for OPERATORS."""
76
+ return self._tilde
77
+
78
+ @property
79
+ def shape(self) -> Tuple[int, int]:
80
+ return self._q.shape
81
+
82
+ @property
83
+ def h(self) -> int:
84
+ return self._q.shape[0]
85
+
86
+ @property
87
+ def w(self) -> int:
88
+ return self._q.shape[1]
89
+
90
+ @property
91
+ def colors(self) -> Set[int]:
92
+ """Distinct non-zero collapse states (from Φ_q)."""
93
+ return set(int(x) for x in self._q.flatten() if x != 0)
94
+
95
+ # ---- Layer 1: Operators (on Φ̃) ----
96
+
97
+ def gradient(self) -> Tuple[np.ndarray, np.ndarray]:
98
+ """∇Φ on Φ̃. Returns (gx, gy)."""
99
+ gx = np.zeros_like(self._tilde)
100
+ gy = np.zeros_like(self._tilde)
101
+ gy[:-1, :] = self._tilde[1:, :] - self._tilde[:-1, :]
102
+ gx[:, :-1] = self._tilde[:, 1:] - self._tilde[:, :-1]
103
+ return gx, gy
104
+
105
+ def gradient_magnitude(self) -> np.ndarray:
106
+ """||∇Φ||"""
107
+ gx, gy = self.gradient()
108
+ return np.sqrt(gx**2 + gy**2)
109
+
110
+ def laplacian(self) -> np.ndarray:
111
+ """∇²Φ on Φ̃."""
112
+ x = self._tilde
113
+ lap = np.zeros_like(x)
114
+ h, w = self.shape
115
+ for i in range(h):
116
+ for j in range(w):
117
+ total = 0.0; count = 0
118
+ if i > 0: total += x[i-1, j]; count += 1
119
+ if i < h-1: total += x[i+1, j]; count += 1
120
+ if j > 0: total += x[i, j-1]; count += 1
121
+ if j < w-1: total += x[i, j+1]; count += 1
122
+ lap[i, j] = total - count * x[i, j]
123
+ return lap
124
+
125
+ def boundary_charge(self) -> np.ndarray:
126
+ """ρ_q := |∇(∇²Φ̃)| — gradient of the Laplacian."""
127
+ lap = self.laplacian()
128
+ gx = np.zeros_like(lap)
129
+ gy = np.zeros_like(lap)
130
+ gy[:-1, :] = lap[1:, :] - lap[:-1, :]
131
+ gx[:, :-1] = lap[:, 1:] - lap[:, :-1]
132
+ return np.sqrt(gx**2 + gy**2)
133
+
134
+ def boundary_mask(self) -> np.ndarray:
135
+ """Boolean boundary mask with physics-derived threshold (μ + 1.5σ)."""
136
+ rho = self.boundary_charge()
137
+ nonzero = rho[rho > 0]
138
+ if len(nonzero) == 0:
139
+ return np.zeros_like(rho, dtype=bool)
140
+ mu = np.mean(nonzero)
141
+ sigma = np.std(nonzero)
142
+ return rho > (mu + 1.5 * sigma)
143
+
144
+
145
+ # =============================================================================
146
+ # PHASE 2 & 6: FieldInvariants
147
+ # =============================================================================
148
+
149
+ class FieldInvariants:
150
+ """Derived invariants from the Φ field."""
151
+
152
+ @staticmethod
153
+ def enclosed_mask(phi: PhiField) -> np.ndarray:
154
+ """
155
+ Detect enclosed regions via harmonic solve.
156
+ u = 1 on boundary, solve ∇²u = 0 inside. u > 0.5 → enclosed.
157
+ Falls back to BFS exterior flood if harmonic solve is unstable.
158
+ """
159
+ h, w = phi.shape
160
+ boundary = phi.boundary_mask()
161
+
162
+ # If no boundary detected, try color-based boundary
163
+ if not np.any(boundary):
164
+ boundary = (phi.q != 0)
165
+
166
+ # BFS from grid edges to find exterior
167
+ exterior = np.zeros((h, w), dtype=bool)
168
+ queue = deque()
169
+ for i in range(h):
170
+ for j in range(w):
171
+ if (i == 0 or i == h-1 or j == 0 or j == w-1):
172
+ if not boundary[i, j] and phi.q[i, j] == 0:
173
+ exterior[i, j] = True
174
+ queue.append((i, j))
175
+
176
+ while queue:
177
+ r, c = queue.popleft()
178
+ for dr, dc in [(-1,0),(1,0),(0,-1),(0,1)]:
179
+ nr, nc = r + dr, c + dc
180
+ if 0 <= nr < h and 0 <= nc < w and not exterior[nr, nc] and not boundary[nr, nc]:
181
+ if phi.q[nr, nc] == 0:
182
+ exterior[nr, nc] = True
183
+ queue.append((nr, nc))
184
+
185
+ # Enclosed = zero-valued cells that are NOT exterior and NOT boundary
186
+ enclosed = (phi.q == 0) & ~exterior & ~boundary
187
+ return enclosed
188
+
189
+ @staticmethod
190
+ def get_enclosed_regions(phi: PhiField) -> List[Dict]:
191
+ """Get distinct enclosed regions with their properties."""
192
+ mask = FieldInvariants.enclosed_mask(phi)
193
+ if not np.any(mask):
194
+ return []
195
+
196
+ h, w = phi.shape
197
+ visited = np.zeros((h, w), dtype=bool)
198
+ regions = []
199
+
200
+ for r in range(h):
201
+ for c in range(w):
202
+ if mask[r, c] and not visited[r, c]:
203
+ # BFS to find this region
204
+ region_cells = set()
205
+ queue = deque([(r, c)])
206
+ visited[r, c] = True
207
+ while queue:
208
+ cr, cc = queue.popleft()
209
+ region_cells.add((cr, cc))
210
+ for dr, dc in [(-1,0),(1,0),(0,-1),(0,1)]:
211
+ nr, nc = cr + dr, cc + dc
212
+ if 0 <= nr < h and 0 <= nc < w and mask[nr, nc] and not visited[nr, nc]:
213
+ visited[nr, nc] = True
214
+ queue.append((nr, nc))
215
+
216
+ region_mask = np.zeros((h, w), dtype=bool)
217
+ for rr, rc in region_cells:
218
+ region_mask[rr, rc] = True
219
+
220
+ regions.append({
221
+ 'mask': region_mask,
222
+ 'cells': region_cells,
223
+ 'size': len(region_cells),
224
+ })
225
+ return regions
226
+
227
+ @staticmethod
228
+ def frame_size(phi: PhiField, interior_mask: np.ndarray) -> Tuple[int, int]:
229
+ """Compute the size of the frame surrounding an interior region."""
230
+ rows = np.any(interior_mask, axis=1)
231
+ cols = np.any(interior_mask, axis=0)
232
+ if not rows.any() or not cols.any():
233
+ return (0, 0)
234
+ rmin, rmax = np.where(rows)[0][[0, -1]]
235
+ cmin, cmax = np.where(cols)[0][[0, -1]]
236
+ return (rmax - rmin + 1, cmax - cmin + 1)
237
+
238
+ @staticmethod
239
+ def get_frame_components(phi: PhiField) -> List[Dict]:
240
+ """
241
+ Extract rectangular frame components using ρ_q.
242
+ Each frame has a color, interior mask, and frame size.
243
+ """
244
+ h, w = phi.shape
245
+ bg = _most_common(phi.q)
246
+ frames = []
247
+
248
+ # Find all non-bg colors
249
+ for color in sorted(phi.colors):
250
+ color_mask = (phi.q == color)
251
+ # Find connected components of this color
252
+ visited = np.zeros((h, w), dtype=bool)
253
+ for r in range(h):
254
+ for c in range(w):
255
+ if color_mask[r, c] and not visited[r, c]:
256
+ # BFS this component
257
+ comp = set()
258
+ queue = deque([(r, c)])
259
+ visited[r, c] = True
260
+ while queue:
261
+ cr, cc = queue.popleft()
262
+ comp.add((cr, cc))
263
+ for dr, dc in [(-1,0),(1,0),(0,-1),(0,1)]:
264
+ nr, nc = cr + dr, cc + dc
265
+ if 0 <= nr < h and 0 <= nc < w and color_mask[nr, nc] and not visited[nr, nc]:
266
+ visited[nr, nc] = True
267
+ queue.append((nr, nc))
268
+
269
+ if len(comp) < 4:
270
+ continue
271
+
272
+ # Check if this forms a rectangular frame (has interior)
273
+ rows_c = [rr for rr, _ in comp]
274
+ cols_c = [cc for _, cc in comp]
275
+ rmin, rmax = min(rows_c), max(rows_c)
276
+ cmin, cmax = min(cols_c), max(cols_c)
277
+ bbox_area = (rmax - rmin + 1) * (cmax - cmin + 1)
278
+
279
+ if bbox_area > len(comp) and len(comp) >= 4:
280
+ # Has interior holes — likely a frame
281
+ interior_mask = np.zeros((h, w), dtype=bool)
282
+ comp_set = comp
283
+ for ir in range(rmin + 1, rmax):
284
+ for ic in range(cmin + 1, cmax):
285
+ if (ir, ic) not in comp_set:
286
+ interior_mask[ir, ic] = True
287
+
288
+ if np.any(interior_mask):
289
+ frame_sz = (rmax - rmin + 1, cmax - cmin + 1)
290
+ frames.append({
291
+ 'frame_color': color,
292
+ 'interior_mask': interior_mask,
293
+ 'frame_size': frame_sz,
294
+ 'bbox': (rmin, cmin, rmax, cmax),
295
+ })
296
+ return frames
297
+
298
+ @staticmethod
299
+ def shape_eigenspectrum(phi: PhiField, positions: List[Tuple[int, int]], k: int = 4) -> Optional[Tuple[float, ...]]:
300
+ """
301
+ Laplacian eigenspectrum of a set of positions.
302
+ Translation/rotation invariant shape fingerprint.
303
+ """
304
+ n = len(positions)
305
+ if n < 2:
306
+ return None
307
+
308
+ pos_to_idx = {p: i for i, p in enumerate(positions)}
309
+
310
+ # Build graph Laplacian for 4-connectivity
311
+ L = np.zeros((n, n), dtype=np.float64)
312
+ for i, (r, c) in enumerate(positions):
313
+ degree = 0
314
+ for dr, dc in [(-1,0),(1,0),(0,-1),(0,1)]:
315
+ neighbor = (r + dr, c + dc)
316
+ if neighbor in pos_to_idx:
317
+ j = pos_to_idx[neighbor]
318
+ L[i, j] = -1
319
+ degree += 1
320
+ L[i, i] = degree
321
+
322
+ try:
323
+ eigenvalues = np.linalg.eigvalsh(L)
324
+ # Skip the zero eigenvalue, take next k
325
+ nonzero_eigs = eigenvalues[eigenvalues > 1e-8]
326
+ if len(nonzero_eigs) == 0:
327
+ return (0.0,)
328
+ sig = tuple(round(float(e), 4) for e in sorted(nonzero_eigs)[:k])
329
+ return sig
330
+ except Exception:
331
+ return None
332
+
333
+ @staticmethod
334
+ def detect_period_fourier(phi: PhiField, axis: int = 0) -> int:
335
+ """Detect period along axis using Fourier analysis."""
336
+ data = phi.q.astype(np.float64)
337
+ if axis == 0:
338
+ signal = data.mean(axis=1)
339
+ else:
340
+ signal = data.mean(axis=0)
341
+
342
+ n = len(signal)
343
+ if n < 2:
344
+ return 0
345
+
346
+ fft = np.fft.rfft(signal)
347
+ magnitudes = np.abs(fft)
348
+
349
+ # Skip DC component
350
+ if len(magnitudes) < 2:
351
+ return 0
352
+
353
+ mags = magnitudes[1:]
354
+ if len(mags) == 0 or np.max(mags) < 1e-10:
355
+ return 0
356
+
357
+ # Find significant frequencies
358
+ threshold = np.max(mags) * 0.3
359
+ significant = np.where(mags > threshold)[0] + 1 # +1 because we skipped DC
360
+
361
+ if len(significant) == 0:
362
+ return 0
363
+
364
+ # Period = n / frequency, find GCD of all detected periods
365
+ periods = []
366
+ for freq in significant:
367
+ p = n // freq
368
+ if p > 0 and p < n:
369
+ periods.append(p)
370
+
371
+ if not periods:
372
+ return 0
373
+
374
+ # Verify period by checking if signal actually repeats
375
+ for p in sorted(set(periods)):
376
+ if p > 0 and p < n:
377
+ is_periodic = True
378
+ base = signal[:p]
379
+ for start in range(p, n - p + 1, p):
380
+ chunk = signal[start:start + p]
381
+ if len(chunk) == p and not np.allclose(chunk, base, atol=0.5):
382
+ is_periodic = False
383
+ break
384
+ if is_periodic:
385
+ return p
386
+
387
+ return 0
388
+
389
+
390
+ # =============================================================================
391
+ # PHASE 3: SigmaResidue
392
+ # =============================================================================
393
+
394
+ @dataclass
395
+ class SigmaResidue:
396
+ """σ analysis of a transformation."""
397
+ residue: float
398
+ total_cells: int
399
+ change_type: str # fill, expansion, compression, recolor, erase, identity, mixed
400
+ structural_condition: str # enclosed, size_increase, size_decrease, substitution, etc.
401
+
402
+ @classmethod
403
+ def from_transformation(cls, phi_in: PhiField, phi_out: PhiField) -> 'SigmaResidue':
404
+ h_in, w_in = phi_in.shape
405
+ h_out, w_out = phi_out.shape
406
+ total = h_out * w_out
407
+
408
+ # Size change
409
+ if h_out > h_in or w_out > w_in:
410
+ residue = float(np.sum(np.abs(phi_out.q)))
411
+ return cls(residue, total, "expansion", "size_increase")
412
+
413
+ if h_out < h_in or w_out < w_in:
414
+ residue = float(np.sum(np.abs(phi_in.q)))
415
+ return cls(residue, total, "compression", "size_decrease")
416
+
417
+ # Same shape — analyze cell-by-cell
418
+ diff = (phi_in.q != phi_out.q)
419
+ residue = float(np.sum(np.abs(phi_out.q.astype(float) - phi_in.q.astype(float))))
420
+
421
+ if not np.any(diff):
422
+ return cls(0.0, total, "identity", "none")
423
+
424
+ changed_count = int(np.sum(diff))
425
+ # Where did changes happen?
426
+ in_vals = phi_in.q[diff]
427
+ out_vals = phi_out.q[diff]
428
+
429
+ zero_to_nonzero = np.sum((in_vals == 0) & (out_vals != 0))
430
+ nonzero_to_zero = np.sum((in_vals != 0) & (out_vals == 0))
431
+ color_change = np.sum((in_vals != 0) & (out_vals != 0) & (in_vals != out_vals))
432
+
433
+ if zero_to_nonzero > 0 and nonzero_to_zero == 0 and color_change == 0:
434
+ return cls(residue, total, "fill", "enclosed")
435
+
436
+ if nonzero_to_zero > 0 and zero_to_nonzero == 0:
437
+ return cls(residue, total, "erase", "removal")
438
+
439
+ if color_change > 0 and zero_to_nonzero == 0 and nonzero_to_zero == 0:
440
+ return cls(residue, total, "recolor", "substitution")
441
+
442
+ return cls(residue, total, "mixed", "complex")
443
+
444
+
445
+ # =============================================================================
446
+ # PHASE 4: Fan Signature
447
+ # =============================================================================
448
+
449
+ @dataclass
450
+ class FanSignature:
451
+ """6-bit signature [Δ₁..Δ₆] for task routing."""
452
+ delta_1: bool # ∇Φ (gradient/boundary)
453
+ delta_2: bool # ∇×F (curl/rotation/reflection)
454
+ delta_3: bool # +∇²Φ (expansion/tiling)
455
+ delta_4: bool # -∇²Φ (compression/interior)
456
+ delta_5: bool # ∂Φ/∂t (temporal/period)
457
+ delta_6: bool # Φ₀ (scalar/color)
458
+
459
+ def to_tuple(self) -> Tuple[int, ...]:
460
+ return (int(self.delta_1), int(self.delta_2), int(self.delta_3),
461
+ int(self.delta_4), int(self.delta_5), int(self.delta_6))
462
+
463
+ def __repr__(self):
464
+ fans = []
465
+ if self.delta_1: fans.append("Δ₁(∇Φ)")
466
+ if self.delta_2: fans.append("Δ₂(∇×F)")
467
+ if self.delta_3: fans.append("Δ₃(+∇²Φ)")
468
+ if self.delta_4: fans.append("Δ₄(-∇²Φ)")
469
+ if self.delta_5: fans.append("Δ₅(∂Φ/∂t)")
470
+ if self.delta_6: fans.append("Δ₆(Φ₀)")
471
+ return f"FanSig[{','.join(fans) or 'none'}]"
472
+
473
+
474
+ def compute_fan_signature(train_pairs: List[Dict]) -> FanSignature:
475
+ """Compute fan activation signature for a task from its training pairs."""
476
+ inputs = [np.array(p['input']) for p in train_pairs]
477
+ outputs = [np.array(p['output']) for p in train_pairs]
478
+
479
+ same_shape = all(inp.shape == out.shape for inp, out in zip(inputs, outputs))
480
+ is_expansion = all(
481
+ out.shape[0] >= inp.shape[0] and out.shape[1] >= inp.shape[1] and out.shape != inp.shape
482
+ for inp, out in zip(inputs, outputs)
483
+ )
484
+
485
+ # Δ₂: check symmetries in outputs
486
+ has_symmetry = False
487
+ for inp in inputs:
488
+ if np.array_equal(inp, np.fliplr(inp)) or np.array_equal(inp, np.flipud(inp)):
489
+ has_symmetry = True
490
+ if inp.shape[0] == inp.shape[1] and np.array_equal(inp, np.rot90(inp)):
491
+ has_symmetry = True
492
+ # Also check if output is a transformed input
493
+ for inp, out in zip(inputs, outputs):
494
+ if inp.shape == out.shape:
495
+ if np.array_equal(out, np.fliplr(inp)) or np.array_equal(out, np.flipud(inp)):
496
+ has_symmetry = True
497
+ if np.array_equal(out, np.rot90(inp, 2)):
498
+ has_symmetry = True
499
+
500
+ # Δ₄: check for enclosed regions
501
+ has_enclosed = False
502
+ for inp in inputs:
503
+ phi = PhiField(inp)
504
+ if np.any(FieldInvariants.enclosed_mask(phi)):
505
+ has_enclosed = True
506
+ break
507
+
508
+ # Δ₅: check for period
509
+ has_period = False
510
+ for inp in inputs:
511
+ phi = PhiField(inp)
512
+ if FieldInvariants.detect_period_fourier(phi, 0) > 0:
513
+ has_period = True
514
+ break
515
+ if FieldInvariants.detect_period_fourier(phi, 1) > 0:
516
+ has_period = True
517
+ break
518
+
519
+ # Δ₆: check for color changes
520
+ input_colors = set()
521
+ output_colors = set()
522
+ for inp, out in zip(inputs, outputs):
523
+ input_colors |= set(np.unique(inp))
524
+ output_colors |= set(np.unique(out))
525
+ color_change = bool(output_colors - input_colors) or bool(input_colors - output_colors)
526
+
527
+ return FanSignature(
528
+ delta_1=same_shape and has_enclosed,
529
+ delta_2=has_symmetry,
530
+ delta_3=is_expansion,
531
+ delta_4=has_enclosed or same_shape,
532
+ delta_5=has_period,
533
+ delta_6=color_change,
534
+ )
535
+
536
+
537
+ def classify_pattern(sig: FanSignature) -> str:
538
+ """Map fan signature to pattern class string."""
539
+ s = sig.to_tuple()
540
+
541
+ if s[2]: # Δ₃ expansion
542
+ if s[1]: return "tile_with_transform"
543
+ if s[3] and s[5]: return "fractal_tile"
544
+ if s[4]: return "periodic_extension"
545
+ return "tile_simple"
546
+
547
+ if s[3] and s[5]: # Δ₄ + Δ₆ interior + color
548
+ if s[1]: return "glyph_to_scalar"
549
+ if s[0]: return "fill_enclosed"
550
+ return "fill_enclosed"
551
+
552
+ if s[1] and not any([s[2], s[3], s[4], s[5]]):
553
+ return "geometric_transform"
554
+
555
+ if s[5] and not any([s[0], s[1], s[2], s[3], s[4]]):
556
+ return "color_remap"
557
+
558
+ return "unknown"
559
+
560
+
561
+ # =============================================================================
562
+ # PHASE 5 & 7: TransformationRule
563
+ # =============================================================================
564
+
565
+ @dataclass
566
+ class TransformationRule:
567
+ """Transformation rule learned from σ analysis of training pairs."""
568
+ rule_type: str = "unknown"
569
+ size_ratio: Tuple[float, float] = (1.0, 1.0)
570
+ fill_color: int = 0
571
+ size_to_color: Dict[Tuple[int, int], int] = field(default_factory=dict)
572
+ frame_to_fill: Dict[int, int] = field(default_factory=dict)
573
+ color_map: Dict[int, int] = field(default_factory=dict)
574
+ tile_pattern: List[List[int]] = field(default_factory=list)
575
+ detected_period: int = 0
576
+ indicator_color: int = 0
577
+ target_color: int = 0
578
+ shape_to_color: Dict[Tuple[float, ...], int] = field(default_factory=dict)
579
+
580
+ @classmethod
581
+ def learn(cls, train_pairs: List[Dict]) -> 'TransformationRule':
582
+ rule = cls()
583
+ sigmas = []
584
+
585
+ for pair in train_pairs:
586
+ phi_in = PhiField(pair['input'])
587
+ phi_out = PhiField(pair['output'])
588
+ sigma = SigmaResidue.from_transformation(phi_in, phi_out)
589
+ sigmas.append(sigma)
590
+ rule.size_ratio = (phi_out.h / phi_in.h, phi_out.w / phi_in.w)
591
+ rule._learn_from_pair(phi_in, phi_out, sigma)
592
+
593
+ # Determine rule type
594
+ change_types = [s.change_type for s in sigmas]
595
+ structural = [s.structural_condition for s in sigmas]
596
+
597
+ if all(t == "fill" and s == "enclosed" for t, s in zip(change_types, structural)):
598
+ if len(rule.size_to_color) > 1 and len(set(rule.size_to_color.values())) > 1:
599
+ rule.rule_type = "multi_region_fill"
600
+ else:
601
+ rule.rule_type = "fill_enclosed"
602
+ elif all(t == "fill" for t in change_types):
603
+ rule.rule_type = "fill"
604
+ elif all(t == "recolor" for t in change_types):
605
+ rule.rule_type = "recolor"
606
+ elif all(t == "mixed" for t in change_types):
607
+ # Mixed changes might still be a consistent color remap
608
+ if rule.color_map and len(rule.color_map) >= 1:
609
+ rule.rule_type = "recolor"
610
+ elif all(t == "expansion" for t in change_types):
611
+ if rule._check_tiling(train_pairs):
612
+ rule.rule_type = "tile"
613
+ elif rule._check_self_tile(train_pairs):
614
+ rule.rule_type = "self_tile"
615
+ elif rule.detected_period > 0:
616
+ rule.rule_type = "periodic_extension"
617
+ else:
618
+ rule.rule_type = "expansion"
619
+ elif rule.indicator_color != 0:
620
+ rule.rule_type = "shape_indicator"
621
+
622
+ return rule
623
+
624
+ def _learn_from_pair(self, phi_in: PhiField, phi_out: PhiField, sigma: SigmaResidue):
625
+ # Fill colors for enclosed regions
626
+ if sigma.change_type == "fill" and sigma.structural_condition == "enclosed":
627
+ frames = FieldInvariants.get_frame_components(phi_in)
628
+ for frame in frames:
629
+ interior_mask = frame['interior_mask']
630
+ frame_sz = frame['frame_size']
631
+ fill_vals = phi_out.q[interior_mask]
632
+ if len(fill_vals) > 0:
633
+ unique, counts = np.unique(fill_vals, return_counts=True)
634
+ fill_c = int(unique[np.argmax(counts)])
635
+ if fill_c != 0:
636
+ self.size_to_color[frame_sz] = fill_c
637
+ self.fill_color = fill_c
638
+ frame_c = frame['frame_color']
639
+ if frame_c != 0:
640
+ self.frame_to_fill[frame_c] = fill_c
641
+
642
+ # Fallback: region-based
643
+ regions = FieldInvariants.get_enclosed_regions(phi_in)
644
+ for region in regions:
645
+ mask = region['mask']
646
+ frame_sz = FieldInvariants.frame_size(phi_in, mask)
647
+ fill_vals = phi_out.q[mask]
648
+ if len(fill_vals) > 0:
649
+ unique, counts = np.unique(fill_vals, return_counts=True)
650
+ fill_c = int(unique[np.argmax(counts)])
651
+ if fill_c != 0 and frame_sz not in self.size_to_color:
652
+ self.size_to_color[frame_sz] = fill_c
653
+ self.fill_color = fill_c
654
+
655
+ # Fallback: if fill_color is still 0, learn from diff (new colors in output)
656
+ if self.fill_color == 0:
657
+ diff_mask = (phi_in.q != phi_out.q) & (phi_out.q != 0)
658
+ if np.any(diff_mask):
659
+ fill_vals = phi_out.q[diff_mask]
660
+ unique, counts = np.unique(fill_vals, return_counts=True)
661
+ self.fill_color = int(unique[np.argmax(counts)])
662
+
663
+ # Also learn fill_color from any 0→nonzero changes (covers non-enclosed fills)
664
+ if sigma.change_type == "fill" and self.fill_color == 0:
665
+ diff_mask = (phi_in.q == 0) & (phi_out.q != 0)
666
+ if np.any(diff_mask):
667
+ fill_vals = phi_out.q[diff_mask]
668
+ unique, counts = np.unique(fill_vals, return_counts=True)
669
+ self.fill_color = int(unique[np.argmax(counts)])
670
+
671
+ # Color mapping
672
+ if phi_in.shape == phi_out.shape:
673
+ for c in phi_in.colors:
674
+ mask = phi_in.q == c
675
+ out_vals = phi_out.q[mask]
676
+ unique = np.unique(out_vals)
677
+ if len(unique) == 1 and unique[0] != c:
678
+ self.color_map[int(c)] = int(unique[0])
679
+
680
+ # Period detection
681
+ if phi_in.shape != phi_out.shape and phi_in.w == phi_out.w:
682
+ period = FieldInvariants.detect_period_fourier(phi_in, axis=0)
683
+ if period > 0:
684
+ self.detected_period = period
685
+ in_base = phi_in.q[:period, :]
686
+ out_base = phi_out.q[:period, :]
687
+ for c_in in set(in_base.flatten()) - {0}:
688
+ mask = in_base == c_in
689
+ out_v = out_base[mask]
690
+ if len(out_v) > 0:
691
+ unique = np.unique(out_v)
692
+ if len(unique) == 1 and unique[0] != c_in:
693
+ self.color_map[int(c_in)] = int(unique[0])
694
+
695
+ # Shape indicator
696
+ if len(phi_in.colors) == 2:
697
+ self._learn_shape_indicator(phi_in, phi_out)
698
+
699
+ # Tile pattern
700
+ self._learn_tile_pattern(phi_in, phi_out)
701
+
702
+ def _learn_shape_indicator(self, phi_in: PhiField, phi_out: PhiField):
703
+ if phi_in.shape != phi_out.shape:
704
+ return
705
+ c1, c2 = sorted(phi_in.colors)
706
+ mask1, mask2 = phi_in.q == c1, phi_in.q == c2
707
+ out_at_1 = set(phi_out.q[mask1].flatten()) - {0}
708
+ out_at_2 = set(phi_out.q[mask2].flatten()) - {0}
709
+
710
+ indicator, target, output_color = None, None, None
711
+ if len(out_at_1) == 0 and len(out_at_2) == 1:
712
+ indicator, target, output_color = c1, c2, int(list(out_at_2)[0])
713
+ elif len(out_at_2) == 0 and len(out_at_1) == 1:
714
+ indicator, target, output_color = c2, c1, int(list(out_at_1)[0])
715
+ else:
716
+ return
717
+
718
+ self.indicator_color = indicator
719
+ self.target_color = target
720
+ positions = list(zip(*np.where(phi_in.q == indicator)))
721
+ if positions:
722
+ shape_sig = FieldInvariants.shape_eigenspectrum(phi_in, positions)
723
+ if shape_sig:
724
+ self.shape_to_color[shape_sig] = output_color
725
+
726
+ def _learn_tile_pattern(self, phi_in: PhiField, phi_out: PhiField):
727
+ ih, iw = phi_in.shape
728
+ oh, ow = phi_out.shape
729
+ if oh < ih or ow < iw or oh % ih != 0 or ow % iw != 0:
730
+ return
731
+ tile_h, tile_w = oh // ih, ow // iw
732
+ if tile_h == 1 and tile_w == 1:
733
+ return
734
+
735
+ pattern = []
736
+ for ti in range(tile_h):
737
+ row = []
738
+ for tj in range(tile_w):
739
+ tile = phi_out.q[ti*ih:(ti+1)*ih, tj*iw:(tj+1)*iw]
740
+ if np.array_equal(tile, phi_in.q): row.append(0)
741
+ elif np.array_equal(tile, np.fliplr(phi_in.q)): row.append(1)
742
+ elif np.array_equal(tile, np.flipud(phi_in.q)): row.append(2)
743
+ elif np.array_equal(tile, np.rot90(phi_in.q, 2)): row.append(3)
744
+ else: row.append(-1)
745
+ pattern.append(row)
746
+ self.tile_pattern = pattern
747
+
748
+ def _check_tiling(self, pairs: List[Dict]) -> bool:
749
+ for pair in pairs:
750
+ phi_in, phi_out = PhiField(pair['input']), PhiField(pair['output'])
751
+ ih, iw, oh, ow = phi_in.h, phi_in.w, phi_out.h, phi_out.w
752
+ if oh % ih != 0 or ow % iw != 0:
753
+ return False
754
+ tile_h, tile_w = oh // ih, ow // iw
755
+ if tile_h <= 1 and tile_w <= 1:
756
+ return False
757
+ for ti in range(tile_h):
758
+ for tj in range(tile_w):
759
+ tile = phi_out.q[ti*ih:(ti+1)*ih, tj*iw:(tj+1)*iw]
760
+ if not any(np.array_equal(tile, t) for t in [
761
+ phi_in.q, np.fliplr(phi_in.q), np.flipud(phi_in.q), np.rot90(phi_in.q, 2)
762
+ ]):
763
+ return False
764
+ return True
765
+
766
+ def _check_self_tile(self, pairs: List[Dict]) -> bool:
767
+ for pair in pairs:
768
+ phi_in, phi_out = PhiField(pair['input']), PhiField(pair['output'])
769
+ ih, iw = phi_in.shape
770
+ if phi_out.h != ih * ih or phi_out.w != iw * iw:
771
+ continue
772
+ is_self = True
773
+ for ti in range(ih):
774
+ for tj in range(iw):
775
+ tile = phi_out.q[ti*ih:(ti+1)*ih, tj*iw:(tj+1)*iw]
776
+ if phi_in.q[ti, tj] != 0:
777
+ if not np.array_equal(tile, phi_in.q):
778
+ is_self = False; break
779
+ elif np.any(tile != 0):
780
+ is_self = False; break
781
+ if not is_self:
782
+ break
783
+ if is_self:
784
+ return True
785
+ return False
786
+
787
+ # ---- Apply methods ----
788
+
789
+ def apply(self, phi_in: PhiField) -> np.ndarray:
790
+ """Apply learned rule to input. Returns int grid."""
791
+ if self.rule_type == "tile": return self._apply_tile(phi_in)
792
+ if self.rule_type == "self_tile": return self._apply_self_tile(phi_in)
793
+ if self.rule_type == "fill_enclosed": return self._apply_fill_enclosed(phi_in)
794
+ if self.rule_type == "multi_region_fill": return self._apply_multi_region_fill(phi_in)
795
+ if self.rule_type == "periodic_extension": return self._apply_periodic_extension(phi_in)
796
+ if self.rule_type == "shape_indicator": return self._apply_shape_indicator(phi_in)
797
+ if self.rule_type == "recolor": return self._apply_recolor(phi_in)
798
+ if self.rule_type == "fill": return self._apply_fill_enclosed(phi_in)
799
+ return phi_in.q.copy()
800
+
801
+ def _apply_tile(self, phi_in: PhiField) -> np.ndarray:
802
+ ih, iw = phi_in.shape
803
+ tile_h = int(self.size_ratio[0])
804
+ tile_w = int(self.size_ratio[1])
805
+ result = np.zeros((ih * tile_h, iw * tile_w), dtype=int)
806
+ transforms = [phi_in.q, np.fliplr(phi_in.q), np.flipud(phi_in.q), np.rot90(phi_in.q, 2)]
807
+ for ti in range(tile_h):
808
+ for tj in range(tile_w):
809
+ code = 0
810
+ if self.tile_pattern and ti < len(self.tile_pattern) and tj < len(self.tile_pattern[ti]):
811
+ code = self.tile_pattern[ti][tj]
812
+ tile = transforms[code] if 0 <= code <= 3 else phi_in.q
813
+ result[ti*ih:(ti+1)*ih, tj*iw:(tj+1)*iw] = tile
814
+ return result
815
+
816
+ def _apply_self_tile(self, phi_in: PhiField) -> np.ndarray:
817
+ ih, iw = phi_in.shape
818
+ result = np.zeros((ih * ih, iw * iw), dtype=int)
819
+ for ti in range(ih):
820
+ for tj in range(iw):
821
+ if phi_in.q[ti, tj] != 0:
822
+ result[ti*ih:(ti+1)*ih, tj*iw:(tj+1)*iw] = phi_in.q
823
+ return result
824
+
825
+ def _apply_fill_enclosed(self, phi_in: PhiField) -> np.ndarray:
826
+ result = phi_in.q.copy()
827
+ mask = FieldInvariants.enclosed_mask(phi_in)
828
+ if np.any(mask):
829
+ result[mask] = self.fill_color
830
+ return result
831
+
832
+ def _apply_multi_region_fill(self, phi_in: PhiField) -> np.ndarray:
833
+ result = phi_in.q.copy()
834
+ frames = FieldInvariants.get_frame_components(phi_in)
835
+
836
+ for frame in frames:
837
+ interior_mask = frame['interior_mask']
838
+ frame_sz = frame['frame_size']
839
+
840
+ fill_c = self.size_to_color.get(frame_sz)
841
+
842
+ # Fallback: closest known size
843
+ if fill_c is None and self.size_to_color:
844
+ frame_area = frame_sz[0] * frame_sz[1]
845
+ best_size = min(self.size_to_color.keys(),
846
+ key=lambda s: abs(s[0]*s[1] - frame_area))
847
+ fill_c = self.size_to_color[best_size]
848
+
849
+ # Fallback: frame color
850
+ if fill_c is None:
851
+ fill_c = self.frame_to_fill.get(frame.get('frame_color', 0))
852
+
853
+ # Fallback: default
854
+ if fill_c is None:
855
+ fill_c = self.fill_color
856
+
857
+ if fill_c and fill_c != 0:
858
+ result[interior_mask] = fill_c
859
+
860
+ return result
861
+
862
+ def _apply_periodic_extension(self, phi_in: PhiField) -> np.ndarray:
863
+ if self.detected_period == 0:
864
+ return phi_in.q.copy()
865
+ oh = int(phi_in.h * self.size_ratio[0])
866
+ base = phi_in.q[:self.detected_period, :].copy()
867
+ for old_c, new_c in self.color_map.items():
868
+ base[base == old_c] = new_c
869
+ reps = max(1, oh // self.detected_period)
870
+ return np.tile(base, (reps, 1))[:oh, :]
871
+
872
+ def _apply_shape_indicator(self, phi_in: PhiField) -> np.ndarray:
873
+ result = np.zeros_like(phi_in.q)
874
+ positions = list(zip(*np.where(phi_in.q == self.indicator_color)))
875
+ if positions:
876
+ shape_sig = FieldInvariants.shape_eigenspectrum(phi_in, positions)
877
+ output_color = self.shape_to_color.get(shape_sig, 0)
878
+ if output_color == 0:
879
+ # Fuzzy match: find closest eigenspectrum
880
+ best_dist = float('inf')
881
+ for known_sig, known_color in self.shape_to_color.items():
882
+ if shape_sig is not None and known_sig is not None:
883
+ min_len = min(len(shape_sig), len(known_sig))
884
+ dist = sum((a - b)**2 for a, b in zip(shape_sig[:min_len], known_sig[:min_len]))
885
+ if dist < best_dist:
886
+ best_dist = dist
887
+ output_color = known_color
888
+ result[phi_in.q == self.target_color] = output_color
889
+ return result
890
+
891
+ def _apply_recolor(self, phi_in: PhiField) -> np.ndarray:
892
+ result = phi_in.q.copy()
893
+ for old_c, new_c in self.color_map.items():
894
+ result[phi_in.q == old_c] = new_c
895
+ return result
896
+
897
+
898
+ # =============================================================================
899
+ # PHASE 8: ITT Solver (top-level)
900
+ # =============================================================================
901
+
902
+ class ITTSolver:
903
+ """
904
+ Pure ITT Solver — integrates with the DSL beam search.
905
+
906
+ Usage:
907
+ solver = ITTSolver()
908
+ result = solver.try_solve(task)
909
+ if result is not None:
910
+ # ITT solved it
911
+ else:
912
+ # fall through to DSL beam search
913
+ """
914
+
915
+ def try_solve(self, task: Dict) -> Optional[List[Dict]]:
916
+ """
917
+ Try to solve a full ARC task using ITT physics.
918
+
919
+ Returns list of {input, predicted_output} for test pairs if confident
920
+ (σ=0 on ALL training pairs), else None.
921
+ """
922
+ train_pairs = task.get('train', [])
923
+ test_pairs = task.get('test', [])
924
+
925
+ if not train_pairs:
926
+ return None
927
+
928
+ # Learn rule from training pairs
929
+ rule = TransformationRule.learn(train_pairs)
930
+
931
+ if rule.rule_type == "unknown":
932
+ return None
933
+
934
+ # Validate: σ=0 on ALL training pairs
935
+ for pair in train_pairs:
936
+ phi_in = PhiField(pair['input'])
937
+ predicted = rule.apply(phi_in)
938
+ expected = np.array(pair['output'], dtype=int)
939
+ if predicted.shape != expected.shape or not np.array_equal(predicted, expected):
940
+ return None
941
+
942
+ # Confident — apply to test inputs
943
+ results = []
944
+ for test in test_pairs:
945
+ phi_in = PhiField(test['input'])
946
+ predicted = rule.apply(phi_in)
947
+ results.append(predicted.tolist())
948
+
949
+ return results
950
+
951
+ def try_solve_pair(self, inp, target, train_pairs: List[Dict]) -> Optional[np.ndarray]:
952
+ """
953
+ Try to solve a single pair using ITT physics.
954
+ Returns predicted output if σ=0 on ALL training pairs, else None.
955
+ """
956
+ rule = TransformationRule.learn(train_pairs)
957
+
958
+ if rule.rule_type == "unknown":
959
+ return None
960
+
961
+ # Validate on all training pairs
962
+ for pair in train_pairs:
963
+ phi_in = PhiField(pair['input'])
964
+ predicted = rule.apply(phi_in)
965
+ expected = np.array(pair['output'], dtype=int)
966
+ if predicted.shape != expected.shape or not np.array_equal(predicted, expected):
967
+ return None
968
+
969
+ # Apply to target input
970
+ phi_in = PhiField(inp)
971
+ return rule.apply(phi_in)
972
+
973
+
974
+ # =============================================================================
975
+ # Helpers
976
+ # =============================================================================
977
+
978
+ def _most_common(arr: np.ndarray) -> int:
979
+ counts = Counter(arr.flatten().tolist())
980
+ return counts.most_common(1)[0][0]