beanapologist commited on
Commit
9bbf5fa
·
verified ·
1 Parent(s): ef601ea

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +1196 -803
app.py CHANGED
@@ -1,11 +1,11 @@
1
  """
2
- ARC-AGI-3 Fluid Re/Im Agent
3
  Hugging Face Space: beanapologist/arc-agi
4
 
5
- True Re/Im duality:
6
- Im = arg(M) = phase/transformation type (GLOBAL hypothesis)
7
- Re = |M| = magnitude/density (LOCAL coordinates)
8
- C(r) = 2r/(1+r²) = coherence = confidence measure
9
  """
10
 
11
  import gradio as gr
@@ -26,828 +26,1213 @@ from PIL import Image
26
  ARC_HEX = ['#000000','#1a6faf','#e03a3a','#3aa63a','#f5c400',
27
  '#c060c0','#d07030','#aaaaaa','#60b8d0','#874010']
28
  ARC_CMAP = ListedColormap(ARC_HEX)
 
 
29
 
30
- # ── Coherence Function ────────────────────────────────────────────────────────
31
-
32
- def C(r):
33
- """Coherence function: C(r) = 2r/(1+r²)"""
34
- return 2*r / (1 + r**2 + 1e-9)
35
-
36
- # ── Feature Extraction ────────────────────────────────────────────────────────
37
-
38
- def extract_features(grid, num_colours=10):
39
- """Extract 14-channel feature tensor: 10 Re + 4 Im"""
40
- H, W = grid.shape
41
-
42
- # Re channels (0-9): one-hot color encoding
43
- oh = np.zeros((num_colours, H, W), np.float32)
44
- for c in range(num_colours):
45
- oh[c] = (grid == c).astype(np.float32)
46
-
47
- # Im channels: global structure
48
- gx, gy = _sobel(grid.astype(np.float32) / 9)
49
- h_sym = _sym(grid, 'h')
50
- v_sym = _sym(grid, 'v')
51
- boundary = _boundary(grid)
52
- edge_mag = np.sqrt(gx**2 + gy**2).astype(np.float32)
53
-
54
- stacked = np.concatenate([
55
- oh, # ch 0-9: Re (colors)
56
- h_sym[np.newaxis], # ch 10: Im (H-symmetry)
57
- v_sym[np.newaxis], # ch 11: Im (V-symmetry)
58
- boundary[np.newaxis], # ch 12: Im (boundary)
59
- edge_mag[np.newaxis] # ch 13: Im (gradient)
60
- ], axis=0)
61
-
62
- t = torch.from_numpy(stacked).float().unsqueeze(0)
63
- if H != 64 or W != 64:
64
- t = TF.interpolate(t, size=(64, 64), mode='bilinear', align_corners=False)
65
- return t.squeeze(0)
66
 
67
  def _sobel(f):
68
- p = np.pad(f, 1, mode='edge')
69
- gx = (-p[:-2,:-2] - 2*p[1:-1,:-2] - p[2:,:-2] +
70
- p[:-2,2:] + 2*p[1:-1,2:] + p[2:,2:]) / 8
71
- gy = (-p[:-2,:-2] - 2*p[:-2,1:-1] - p[:-2,2:] +
72
- p[2:,:-2] + 2*p[2:,1:-1] + p[2:,2:]) / 8
73
- return gx, gy
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
  def _boundary(grid):
76
- p = np.pad(grid, 1, mode='edge')
77
- return ((p[1:-1,1:-1] != p[:-2,1:-1]) | (p[1:-1,1:-1] != p[2:,1:-1]) |
78
- (p[1:-1,1:-1] != p[1:-1,:-2]) | (p[1:-1,1:-1] != p[1:-1,2:])).astype(np.float32)
79
 
80
- def _sym(grid, axis):
81
- H, W = grid.shape
82
- s = np.zeros((H, W), np.float32)
83
- if axis == 'h':
84
  for x in range(W):
85
- r = min(x, W - 1 - x)
86
- if r == 0:
87
- s[:, x] = 1.
88
- continue
89
- s[:, x] = (grid[:, x-r:x] == grid[:, x+1:x+r+1][:, ::-1]).mean()
90
  else:
91
  for y in range(H):
92
- r = min(y, H - 1 - y)
93
- if r == 0:
94
- s[y, :] = 1.
95
- continue
96
- s[y, :] = (grid[y-r:y, :] == grid[y+1:y+r+1, :][::-1, :]).mean()
97
  return s
98
 
99
- # ── Re/Im Fluid Duality Solver ────────────────────────────���───────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
 
101
- def read_board_fluid(grid: np.ndarray, features: np.ndarray):
102
  """
103
- Fluid Re/Im duality solver using coherence function.
104
-
105
- Returns: (answer_grid, confidence, reasoning, signal_type, target_cell)
 
 
 
 
 
 
 
 
 
 
 
 
106
  """
107
  H, W = grid.shape
108
- CH_H_SYM, CH_V_SYM, CH_BOUNDARY, CH_EDGE = 10, 11, 12, 13
109
-
110
- # ══════════════════════════════════════════════════════════════
111
- # Im Side: Global Signals (arg(M))
112
- # ══════════════════════════════════════════════════════════════
113
-
114
- h_sym_map = features[CH_H_SYM].numpy() if hasattr(features[CH_H_SYM], 'numpy') else features[CH_H_SYM]
115
- v_sym_map = features[CH_V_SYM].numpy() if hasattr(features[CH_V_SYM], 'numpy') else features[CH_V_SYM]
116
- boundary_map = features[CH_BOUNDARY].numpy() if hasattr(features[CH_BOUNDARY], 'numpy') else features[CH_BOUNDARY]
117
- edge_map = features[CH_EDGE].numpy() if hasattr(features[CH_EDGE], 'numpy') else features[CH_EDGE]
118
-
119
- # Resize back to grid space if needed
120
- if h_sym_map.shape != (H, W):
121
- h_sym_map = _resize_to_grid(h_sym_map, H, W)
122
- v_sym_map = _resize_to_grid(v_sym_map, H, W)
123
- boundary_map = _resize_to_grid(boundary_map, H, W)
124
- edge_map = _resize_to_grid(edge_map, H, W)
125
-
126
- # Im: phase signals
127
- h_sym_score = float(h_sym_map.max())
128
- v_sym_score = float(v_sym_map.max())
129
- boundary_density = float(boundary_map.mean())
130
- edge_strength = float(edge_map.mean())
131
-
132
- # ══════════════════════════════════════════════════════════════
133
- # Re Side: Local Magnitude (|M|)
134
- # ══════════════════════════════════════════════════════════════
135
-
136
- mass_map = (grid > 0).astype(float)
137
- fill_ratio = mass_map.mean()
138
-
139
- hypotheses = []
140
-
141
- # ── Hypothesis 1: H-Mirror Completion ─────────────────────────
142
- if h_sym_score > 0.5:
143
- # Im: fold axis detected
144
- # Re: check mass asymmetry
145
- left_mass = mass_map[:, :W//2].sum()
146
- right_mass = mass_map[:, W//2:].sum()
147
-
148
- if left_mass + right_mass > 0:
149
- r = min(left_mass, right_mass) / max(left_mass, right_mass, 1)
150
- coherence = C(r)
151
-
152
- # Low coherence = asymmetry = complete the mirror
153
- confidence = h_sym_score * (1 - coherence)
154
-
155
- if confidence > 0.35:
156
- answer, cells = _complete_mirror(grid, 'h')
157
- hypotheses.append({
158
- 'answer': answer,
159
- 'confidence': confidence,
160
- 'cells': cells,
161
- 'type': 'h_mirror',
162
- 'reasoning': f"Im: H-sym={h_sym_score:.2f} | Re: C(L/R)={coherence:.2f} | gap={1-coherence:.2f}"
163
- })
164
-
165
- # ── Hypothesis 2: V-Mirror Completion ─────────────────────────
166
- if v_sym_score > 0.5:
167
- top_mass = mass_map[:H//2, :].sum()
168
- bot_mass = mass_map[H//2:, :].sum()
169
-
170
- if top_mass + bot_mass > 0:
171
- r = min(top_mass, bot_mass) / max(top_mass, bot_mass, 1)
172
- coherence = C(r)
173
- confidence = v_sym_score * (1 - coherence)
174
-
175
- if confidence > 0.35:
176
- answer, cells = _complete_mirror(grid, 'v')
177
- hypotheses.append({
178
- 'answer': answer,
179
- 'confidence': confidence,
180
- 'cells': cells,
181
- 'type': 'v_mirror',
182
- 'reasoning': f"Im: V-sym={v_sym_score:.2f} | Re: C(T/B)={coherence:.2f}"
183
- })
184
-
185
- # ── Hypothesis 3: Boundary Extraction ─────────────────────────
186
- if fill_ratio > 0.4 and boundary_density < 0.3:
187
- # Im: low boundary density (Cauchy contour thin)
188
- # Re: high fill (interior dense)
189
- # → radius cancels, extract arg (boundary)
190
-
191
- r = boundary_density / (fill_ratio + 1e-6)
192
- coherence = C(r)
193
- confidence = (1 - coherence) * fill_ratio * 0.9
194
-
195
- if confidence > 0.35:
196
- answer, cells = _extract_boundary(grid)
197
- hypotheses.append({
198
- 'answer': answer,
199
- 'confidence': confidence,
200
- 'cells': cells,
201
- 'type': 'boundary',
202
- 'reasoning': f"Im: ∮ density={boundary_density:.2f} | Re: fill={fill_ratio:.2f} | C={coherence:.2f}"
203
- })
204
-
205
- # ── Hypothesis 4: Interior Fill ───────────────────────────────
206
- if fill_ratio > 0.2 and boundary_density > 0.15:
207
- # Im: boundary exists
208
- # Re: interior has holes
209
- # → fill enclosed regions
210
-
211
- enclosed = _find_enclosed(grid)
212
- if enclosed.sum() > 0:
213
- r = enclosed.sum() / max((grid == 0).sum(), 1)
214
- confidence = boundary_density * r * 0.85
215
-
216
- if confidence > 0.30:
217
- answer, cells = _fill_interior(grid)
218
- hypotheses.append({
219
- 'answer': answer,
220
- 'confidence': confidence,
221
- 'cells': cells,
222
- 'type': 'fill',
223
- 'reasoning': f"Im: boundary={boundary_density:.2f} | Re: holes={enclosed.sum()}"
224
- })
225
-
226
- # ── Hypothesis 5: Gravity ─────────────────────────────────────
227
- suspended = _count_suspended(grid)
228
- if suspended > 0 and edge_strength > 0.2:
229
- r = suspended / max((grid > 0).sum(), 1)
230
- confidence = edge_strength * r * 0.75
231
-
232
- if confidence > 0.25:
233
- answer, cells = _apply_gravity(grid)
234
- hypotheses.append({
235
- 'answer': answer,
236
- 'confidence': confidence,
237
- 'cells': cells,
238
- 'type': 'gravity',
239
- 'reasoning': f"Im: ∇={edge_strength:.2f} | Re: suspended={suspended}"
240
- })
241
-
242
- # ══════════════════════════════════════════════════════════════
243
- # Bridge: Select best hypothesis by coherence gap
244
- # ══════════════════════════════════════════════════════════════
245
-
246
- if not hypotheses:
247
- return None, 0.0, ["No hypothesis > threshold"], 'none', None
248
-
249
- hypotheses.sort(key=lambda h: h['confidence'], reverse=True)
250
- best = hypotheses[0]
251
-
252
- # Most urgent cell (boundary-first, Cauchy principle)
253
- target_cell = _most_urgent_cell(grid, best['answer'], best['cells'])
254
-
255
- reasoning = [best['reasoning'], f"Selected: {best['type']} (C={best['confidence']:.2f})"]
256
-
257
- return best['answer'], best['confidence'], reasoning, best['type'], target_cell
258
-
259
- def _resize_to_grid(feature_map, H, W):
260
- """Resize feature map to grid dimensions"""
261
- fH, fW = feature_map.shape
262
- ry = np.linspace(0, fH-1, H).astype(int)
263
- rx = np.linspace(0, fW-1, W).astype(int)
264
- return feature_map[np.ix_(ry, rx)]
265
-
266
- def _complete_mirror(grid, axis):
267
- """Complete mirror symmetry along axis"""
268
- H, W = grid.shape
269
- answer = grid.copy()
270
- cells = []
271
-
272
- if axis == 'h':
273
- # Find best fold axis in grid space
274
- best_x = W // 2
275
  for x in range(1, W-1):
276
  r = min(x, W-1-x)
277
  if r > 0:
278
- score = (grid[:, x-r:x] == grid[:, x+1:x+r+1][:,::-1]).mean()
279
- if score > 0.5:
280
- best_x = x
281
- break
282
-
283
- # Mirror less-filled side
284
- left_px = (grid[:, :best_x] > 0).sum()
285
- right_px = (grid[:, best_x:] > 0).sum()
286
-
287
- if left_px > right_px:
288
- for c in range(best_x):
289
- mir = W - 1 - c
290
- if 0 <= mir < W:
291
- mask = grid[:, c] > 0
292
- for r in np.where(mask)[0]:
293
- if answer[r, mir] != grid[r, c]:
294
- answer[r, mir] = grid[r, c]
295
- cells.append((r, mir, int(grid[r, c])))
296
- else:
297
- for c in range(best_x+1, W):
298
- mir = W - 1 - c
299
- if 0 <= mir < W:
300
- mask = grid[:, c] > 0
301
- for r in np.where(mask)[0]:
302
- if answer[r, mir] != grid[r, c]:
303
- answer[r, mir] = grid[r, c]
304
- cells.append((r, mir, int(grid[r, c])))
305
- else: # 'v'
306
- best_y = H // 2
 
 
 
 
 
 
 
 
307
  for y in range(1, H-1):
308
  r = min(y, H-1-y)
309
  if r > 0:
310
- score = (grid[y-r:y, :] == grid[y+1:y+r+1, :][::-1, :]).mean()
311
- if score > 0.5:
312
- best_y = y
313
- break
314
-
315
- top_px = (grid[:best_y, :] > 0).sum()
316
- bot_px = (grid[best_y:, :] > 0).sum()
317
-
318
- if top_px > bot_px:
319
- for r in range(best_y):
320
- mir = H - 1 - r
321
- if 0 <= mir < H:
322
- mask = grid[r, :] > 0
323
- for c in np.where(mask)[0]:
324
- if answer[mir, c] != grid[r, c]:
325
- answer[mir, c] = grid[r, c]
326
- cells.append((mir, c, int(grid[r, c])))
327
- else:
328
- for r in range(best_y+1, H):
329
- mir = H - 1 - r
330
- if 0 <= mir < H:
331
- mask = grid[r, :] > 0
332
- for c in np.where(mask)[0]:
333
- if answer[mir, c] != grid[r, c]:
334
- answer[mir, c] = grid[r, c]
335
- cells.append((mir, c, int(grid[r, c])))
336
-
337
- return answer, cells
338
-
339
- def _extract_boundary(grid):
340
- """Extract boundary contour (Cauchy: radius cancels)"""
341
- H, W = grid.shape
342
- answer = np.zeros_like(grid)
343
- cells = []
344
-
345
- # Perimeter detection
346
- mask = grid > 0
347
- padded = np.pad(mask.astype(int), 1, constant_values=0)
348
-
349
- for r in range(H):
350
- for c in range(W):
351
- if mask[r, c]:
352
- # Check if on boundary (has empty neighbor)
353
- neighbors = padded[r:r+3, c:c+3].sum()
354
- if neighbors < 9: # not fully surrounded
355
- answer[r, c] = grid[r, c]
356
- cells.append((r, c, int(grid[r, c])))
357
-
358
- # Handle solid blocks (use outer ring)
359
- if len(cells) == 0 and mask.any():
360
- for r in [0, H-1]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
  for c in range(W):
362
- if mask[r, c]:
363
- answer[r, c] = grid[r, c]
364
- cells.append((r, c, int(grid[r, c])))
365
- for c in [0, W-1]:
366
- for r in range(1, H-1):
367
- if mask[r, c]:
368
- answer[r, c] = grid[r, c]
369
- cells.append((r, c, int(grid[r, c])))
370
-
371
- return answer, cells
372
-
373
- def _fill_interior(grid):
374
- """Fill enclosed empty regions"""
375
- H, W = grid.shape
376
- answer = grid.copy()
377
- cells = []
378
-
379
- enclosed = _find_enclosed(grid)
380
-
381
- if enclosed.sum() > 0:
382
- # Find dominant color
383
- colors = grid[grid > 0]
384
- if len(colors) > 0:
385
- dominant = int(np.bincount(colors).argmax())
386
-
387
- for r, c in zip(*np.where(enclosed)):
388
- answer[r, c] = dominant
389
- cells.append((r, c, dominant))
390
-
391
- return answer, cells
392
-
393
- def _find_enclosed(grid):
394
- """Find truly enclosed empty cells (unreachable from boundary)"""
395
- H, W = grid.shape
396
- reachable = np.zeros((H, W), dtype=bool)
397
-
398
- # Flood-fill from boundary
399
- queue = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
400
  for r in range(H):
401
  for c in range(W):
402
- if grid[r, c] == 0 and (r == 0 or r == H-1 or c == 0 or c == W-1):
403
- if not reachable[r, c]:
404
- reachable[r, c] = True
405
- queue.append((r, c))
406
-
407
- while queue:
408
- y, x = queue.pop(0)
409
- for dy, dx in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
410
- ny, nx = y + dy, x + dx
411
- if 0 <= ny < H and 0 <= nx < W and grid[ny, nx] == 0 and not reachable[ny, nx]:
412
- reachable[ny, nx] = True
413
- queue.append((ny, nx))
414
-
415
- return (grid == 0) & ~reachable
416
-
417
- def _count_suspended(grid):
418
- """Count suspended pixels (not resting on bottom/another pixel)"""
419
- H, W = grid.shape
420
- count = 0
421
-
422
- for c in range(W):
423
- col = grid[:, c]
424
- occupied = np.where(col > 0)[0]
425
- for r in occupied:
426
- if r < H - 1 and grid[r+1, c] == 0:
427
- count += 1
428
-
429
- return count
430
-
431
- def _apply_gravity(grid):
432
- """Apply gravity (drop all pixels down)"""
433
- H, W = grid.shape
434
- answer = np.zeros_like(grid)
435
- cells = []
436
-
437
- for c in range(W):
438
- vals = grid[:, c][grid[:, c] > 0]
439
- if len(vals) > 0:
440
- start_row = H - len(vals)
441
- for i, val in enumerate(vals):
442
- r = start_row + i
443
- answer[r, c] = val
444
- if grid[r, c] != val:
445
- cells.append((r, c, int(val)))
446
-
447
- return answer, cells
448
-
449
- def _most_urgent_cell(current, target, candidate_cells):
450
- """Pick most urgent cell: boundary-first (Cauchy principle)"""
451
- if not candidate_cells:
452
- return None
453
-
454
- boundary = _boundary(current)
455
-
456
- # Prioritize boundary cells
457
- boundary_cells = [(r, c, v) for r, c, v in candidate_cells if boundary[r, c] > 0]
458
-
459
- if boundary_cells:
460
- return boundary_cells[0]
461
- else:
462
- return candidate_cells[0]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
463
 
464
- # ── CNN Agent ─────────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
465
 
466
- class FluidAgent:
 
 
 
 
 
 
 
 
 
 
467
  def __init__(self):
468
- self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
469
- self.model = self._make_model().to(self.device)
470
- self.opt = torch.optim.Adam(self.model.parameters(), lr=1e-4)
471
- self.buf = []
472
- self.prev_feat = None
473
- self.prev_action = None
474
- self.step_count = 0
475
- self.action_counts = {}
476
- self.reward_history = deque(maxlen=300)
477
-
 
 
 
 
 
478
  def _make_model(self):
479
  return nn.Sequential(
480
- nn.Conv2d(14, 32, 3, padding=1), nn.ReLU(),
481
- nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(),
482
- nn.Conv2d(64, 128, 3, padding=1), nn.ReLU(),
483
- nn.AdaptiveAvgPool2d(8), nn.Flatten(),
484
- nn.Linear(128*8*8, 256), nn.ReLU(),
485
- nn.Linear(256, 6),
486
  )
487
-
488
  def reset(self):
489
- self.model = self._make_model().to(self.device)
490
- self.opt = torch.optim.Adam(self.model.parameters(), lr=1e-4)
491
- self.buf = []
492
- self.prev_feat = None
493
- self.prev_action = None
494
- self.step_count = 0
495
- self.action_counts = {}
496
- self.reward_history = deque(maxlen=300)
497
-
498
- def choose(self, grid, available_actions=None, levels=0, state=None):
499
- feat = extract_features(grid).to(self.device)
500
-
501
- # Store experience
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502
  if self.prev_feat is not None:
503
- changed = not np.array_equal(
504
- self.prev_feat.cpu().numpy(), feat.cpu().numpy())
505
- reward = 0.1 if changed else -0.01
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
506
  self.reward_history.append(reward)
507
- self.buf.append((self.prev_feat, self.prev_action, reward))
508
- if len(self.buf) > 500:
509
- self.buf.pop(0)
510
-
511
- if self.step_count % 10 == 0 and len(self.buf) >= 16:
 
512
  self._train()
513
-
514
- # ── Try Re/Im analytic solver ─────────────────────────────
515
- answer, confidence, reasoning, signal, target_cell = read_board_fluid(grid, feat)
516
-
517
- meta = {
518
- 'hypothesis': signal,
519
- 'conf': confidence,
520
- 'reasoning': reasoning,
521
- 'candidates': [(signal, answer, confidence)] if answer is not None else []
522
- }
523
-
524
- # Check what actions are actually available
525
- avail_ids = set()
526
- if available_actions:
527
- avail_ids = set(int(a.value if hasattr(a, 'value') else a)
528
- for a in available_actions)
529
- else:
530
- avail_ids = set(range(1, 7))
531
-
532
- # If we have a strong analytic answer and ACTION6 exists, use it
533
- if (answer is not None and confidence > 0.40 and
534
- target_cell is not None and 6 in avail_ids):
535
- # Im found hypothesis, Re found cell → ACTION6
536
- r, c, _ = target_cell
537
- H, W = grid.shape
538
- gy = min(63, max(0, int(r * 64 / H + 32 / H)))
539
- gx = min(63, max(0, int(c * 64 / W + 32 / W)))
540
-
541
- meta['source'] = 'analytic'
542
- meta['x'] = gx
543
- meta['y'] = gy
544
- meta['cell'] = target_cell
545
-
546
- chosen_id = 6
 
 
 
 
 
 
 
 
 
 
 
 
 
547
  else:
548
- # CNN fallback (or analytic without click action)
549
- if answer is not None and confidence > 0.40:
550
- # We have strong answer but no ACTION6 - pick best alternative
551
- meta['source'] = 'analytic_fallback'
552
- meta['note'] = f"Confidence {confidence:.2f} but ACTION6 not available"
553
- else:
554
- meta['source'] = 'cnn'
555
-
556
- with torch.no_grad():
557
- logits = self.model(feat.unsqueeze(0)).squeeze(0)
558
-
559
- # Mask to only available actions
560
- indices = [m-1 for m in avail_ids if 1 <= m <= 6]
561
- masked = torch.full((6,), float('-inf'))
562
- for i in indices:
563
- masked[i] = logits[i]
564
- probs = torch.softmax(masked, dim=0).cpu().numpy()
565
- probs = np.nan_to_num(probs, nan=0)
566
- if probs.sum() == 0:
567
- probs[np.array(indices)] = 1 / len(indices)
568
- probs = probs / probs.sum()
569
- cnn_action_idx = np.random.choice(6, p=probs)
570
-
571
- chosen_id = cnn_action_idx + 1
572
- meta['probs'] = probs.tolist()
573
-
574
- # Make sure chosen action is actually available
575
- if chosen_id not in avail_ids and avail_ids:
576
- chosen_id = list(avail_ids)[0]
577
-
578
- self.prev_feat = feat
579
- self.prev_action = chosen_id - 1
580
- self.step_count += 1
581
- self.action_counts[chosen_id] = self.action_counts.get(chosen_id, 0) + 1
582
-
583
- # Mock action object
584
- class MockAction:
585
- def __init__(self, val):
586
- self.value = val
587
- def set_data(self, data):
588
- pass
589
-
590
- action = MockAction(chosen_id)
591
- return action, meta
592
-
593
- def _train(self):
594
- import random
595
- batch = random.sample(self.buf, min(16, len(self.buf)))
596
- states = torch.stack([b[0] for b in batch]).to(self.device)
597
- actions = torch.tensor([b[1] for b in batch], dtype=torch.long, device=self.device)
598
- rewards = torch.tensor([b[2] for b in batch], dtype=torch.float32, device=self.device)
599
-
600
- self.opt.zero_grad()
601
- logits = self.model(states)
602
- loss = TF.binary_cross_entropy_with_logits(
603
- logits.gather(1, actions.unsqueeze(1)).squeeze(1),
604
- torch.clamp(rewards, 0, 1))
605
- loss.backward()
606
- self.opt.step()
607
 
608
- # ── Rendering ─────────────────────────────────────────────────────────────────
 
 
 
 
609
 
610
- def _pil(fig):
611
- buf = io.BytesIO()
612
- fig.savefig(buf, format='png', dpi=80, bbox_inches='tight',
613
- facecolor=fig.get_facecolor())
614
- buf.seek(0)
615
- img = Image.open(buf).copy()
616
- plt.close(fig)
617
- return img
618
 
619
- def render_grid(grid, title='', highlight=None, mark_cell=None):
620
- if grid is None:
621
- return None
622
- H, W = grid.shape
623
- cell = max(28, min(56, 360 // max(H, W)))
624
- fig, ax = plt.subplots(figsize=((W*cell+4)/72, (H*cell+22)/72), dpi=72)
625
- fig.patch.set_facecolor('#1e1e2e')
626
- ax.set_facecolor('#1e1e2e')
627
- ax.imshow(grid, cmap=ARC_CMAP, vmin=0, vmax=9, interpolation='nearest', aspect='equal')
628
-
629
- for x in range(W+1):
630
- ax.axvline(x-.5, color='#444', lw=.5)
631
- for y in range(H+1):
632
- ax.axhline(y-.5, color='#444', lw=.5)
633
-
634
- for r in range(H):
635
- for c in range(W):
636
- v = int(grid[r, c])
637
- col = 'white' if v in [0, 1, 2, 3, 5, 6, 9] else 'black'
638
- ax.text(c, r, str(v), ha='center', va='center',
639
- fontsize=max(7, cell//5), color=col,
640
- fontweight='bold', fontfamily='monospace')
641
-
642
- if highlight:
643
- for r, c, _ in highlight[:20]:
644
- ax.add_patch(plt.Rectangle((c-.5, r-.5), 1, 1,
645
- fill=True, facecolor='#ff4444', alpha=0.35, lw=0))
646
-
647
- if mark_cell:
648
- r, c, _ = mark_cell
649
- ax.add_patch(plt.Rectangle((c-.5, r-.5), 1, 1,
650
- fill=False, edgecolor='#00ffff', lw=2.5))
651
- ax.plot(c, r, '*', color='#00ffff', markersize=max(8, cell//4))
652
-
653
- ax.set_xlim(-.5, W-.5)
654
- ax.set_ylim(H-.5, -.5)
655
- ax.axis('off')
656
- if title:
657
- ax.set_title(title, color='#cdd6f4', fontsize=9, pad=4)
658
- plt.tight_layout(pad=.3)
659
- return _pil(fig)
660
 
661
  # ── Session ───────────────────────────────────────────────────────────────────
662
 
663
- _agent = FluidAgent()
664
- _stop_flag = threading.Event()
665
  _run_thread = None
666
- _frame_queue = queue.Queue(maxsize=60)
667
 
668
- def _run_agent(game_id, api_key, max_steps):
669
- """Real ARC-AGI-3 agent runner"""
670
  try:
671
- import arc_agi
672
- arc = arc_agi.Arcade(arc_api_key=api_key)
673
- env = arc.make(game_id, include_frame_data=True)
674
-
675
- frame = env.reset()
676
- _agent.reset()
677
- prev_grid = None
678
- step = 0
679
-
680
- while not _stop_flag.is_set() and step < max_steps:
681
- if frame is None:
682
- break
683
-
684
- raw = np.array(frame.frame, dtype=np.int64)
685
- grid = raw[-1] if raw.ndim == 3 else raw
686
- avail = getattr(frame, 'available_actions', None)
687
- levels = getattr(frame, 'levels_completed', 0)
688
- state = getattr(frame, 'state', None)
689
-
690
- action, meta = _agent.choose(grid, avail, levels=levels, state=state)
691
-
692
- # Add available actions to meta for debugging
693
- if avail:
694
- meta['available_actions'] = [int(a.value if hasattr(a, 'value') else a)
695
- for a in avail]
696
-
697
- diff = (grid != prev_grid) if prev_grid is not None else None
698
- prev_grid = grid.copy()
699
-
700
  _frame_queue.put({
701
- 'grid': grid,
702
- 'diff': diff,
703
- 'step': step,
704
- 'action': int(action.value if hasattr(action, 'value') else action),
705
- 'levels': levels,
706
- 'state': str(state),
707
- 'meta': meta,
708
- 'counts': dict(_agent.action_counts),
709
- 'reward_history': list(_agent.reward_history),
710
- }, block=True, timeout=5)
711
-
712
- state_str = str(state)
713
- if 'WIN' in state_str or 'GAME_OVER' in state_str:
714
- break
715
-
716
- # Execute action
717
  try:
718
  from arcengine import GameAction as GA
719
- a_int = int(action.value if hasattr(action, 'value') else action)
720
- sa = GA(a_int)
721
  if meta.get('x') is not None:
722
- try:
723
- sa.set_data({'x': int(meta['x']), 'y': int(meta['y'])})
724
- except:
725
- pass
726
- frame = env.step(sa)
727
- except ImportError:
728
- # arcengine not available, use action directly
729
- try:
730
- frame = env.step(action)
731
- except:
732
- frame = None
733
-
734
- step += 1
735
  time.sleep(0.08)
736
-
737
- _frame_queue.put({'done': True, 'step': step})
738
  except Exception as e:
739
- _frame_queue.put({'error': str(e)})
 
 
740
 
741
- _latest = {
742
- 'grid_img': None,
743
- 'hyp_img': None,
744
- 'cand_img': None,
745
- 'status': '*Ready*'
746
- }
747
 
748
  def pull_frame():
749
  global _latest
750
- data = None
751
  while True:
752
- try:
753
- data = _frame_queue.get_nowait()
754
- except queue.Empty:
755
- break
756
-
757
  if data is None:
758
- return (_latest['grid_img'], _latest['hyp_img'], _latest['cand_img'], _latest['status'])
759
-
 
760
  if 'error' in data:
761
- _latest['status'] = f"**Error:** {data['error']}"
762
- return (_latest['grid_img'], _latest['hyp_img'], _latest['cand_img'], _latest['status'])
763
-
 
764
  if data.get('done'):
765
- _latest['status'] = f"**Done** — {data['step']} steps"
766
- return (_latest['grid_img'], _latest['hyp_img'], _latest['cand_img'], _latest['status'])
767
-
768
- grid = data['grid']
769
- meta = data['meta']
770
- step = data['step']
771
- action = data['action']
772
- source = meta.get('source', 'cnn')
773
-
774
- mark_cell = meta.get('cell') if source == 'analytic' else None
775
- highlight = None
776
-
777
- cand_list = meta.get('candidates', [])
778
- if cand_list and source == 'analytic':
779
- _, cand_grid, _ = cand_list[0]
780
- if cand_grid is not None and cand_grid.shape == grid.shape:
781
- diffs = [(r, c, int(cand_grid[r, c]))
782
- for r in range(grid.shape[0])
783
- for c in range(grid.shape[1])
784
- if grid[r, c] != cand_grid[r, c]]
785
- highlight = diffs[:20]
786
-
787
- source_emoji = '🧠' if source == 'analytic' else '🎲'
788
-
789
- _latest['grid_img'] = render_grid(
 
 
 
 
 
 
 
 
790
  grid,
791
- title=f"Step {step} | {source_emoji} A{action}",
792
  highlight=highlight,
793
- mark_cell=mark_cell
794
- )
795
-
796
- if cand_list:
797
- sig, cand_grid, conf = cand_list[0]
798
- if cand_grid is not None and cand_grid.shape == grid.shape:
799
- _latest['cand_img'] = render_grid(
800
- cand_grid,
801
- title=f"Im: {sig} (C={conf:.2f})",
802
- highlight=highlight
803
- )
804
-
805
- reasoning = meta.get('reasoning', [])
806
- hyp_text = '\n'.join(reasoning[:2]) if reasoning else 'none'
807
-
808
- avail_actions = meta.get('available_actions', [])
809
- avail_str = f"Available: {avail_actions}" if avail_actions else ""
810
-
811
- source_label = {'analytic': 'Analytic', 'analytic_fallback': 'Analytic (no click)', 'cnn': 'CNN'}
812
-
813
- _latest['status'] = (
814
- f"{source_emoji} **{source_label.get(source, source)}** | "
815
- f"Step {step} | Action {action}\n\n"
816
- f"{hyp_text}\n\n{avail_str}"
817
- )
818
-
819
- return (_latest['grid_img'], _latest['hyp_img'], _latest['cand_img'], _latest['status'])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
820
 
821
  def fetch_games(api_key):
822
- """Fetch available games from ARC API"""
823
  try:
824
  import arc_agi
825
- arc = arc_agi.Arcade(arc_api_key=api_key)
826
- envs = arc.get_environments()
827
- ids = [e.game_id for e in envs]
828
- return gr.Dropdown(choices=ids, value=ids[0] if ids else None), f"Found **{len(ids)}** games."
 
829
  except Exception as e:
830
- return gr.Dropdown(choices=[]), f"**Error:** {e}"
831
-
832
- def start_agent(game_id, api_key, max_steps):
833
- global _run_thread, _stop_flag
834
- if not game_id:
835
- return "Select a game first."
836
- if not api_key:
837
- return "Enter your API key."
838
  _stop_flag.set()
839
- if _run_thread and _run_thread.is_alive():
840
- _run_thread.join(timeout=3)
841
  while not _frame_queue.empty():
842
- try:
843
- _frame_queue.get_nowait()
844
- except:
845
- break
846
  _stop_flag.clear()
847
- _run_thread = threading.Thread(
848
- target=_run_agent, args=(game_id, api_key, int(max_steps)), daemon=True)
849
  _run_thread.start()
850
- return f"Agent started on **{game_id}** — 🧠 Re/Im fluid duality"
851
 
852
  def stop_agent():
853
  _stop_flag.set()
@@ -855,67 +1240,75 @@ def stop_agent():
855
 
856
  # ── UI ────────────────────────────────────────────────────────────────────────
857
 
858
- with gr.Blocks(title="ARC-AGI-3 Fluid Re/Im") as demo:
859
- gr.Markdown("""
860
- # ARC-AGI-3 Fluid Re/Im Agent
861
 
862
- **Im = arg(M)** — global phase/transformation (which hypothesis?)
863
- **Re = |M|** — local magnitude/density (which cells?)
864
- **C(r) = 2r/(1+r²)** coherence measures Im/Re alignment
 
865
 
866
- 🧠 = analytic solver (Im picks transform → Re finds cells → ACTION6)
867
- 🎲 = CNN fallback (when no hypothesis clears threshold)
868
  """)
869
-
870
  with gr.Row():
871
  with gr.Column(scale=3):
872
- api_box = gr.Textbox(
873
- label="ARC API key",
874
- type="password",
875
- value=os.environ.get("ARC_API_KEY", ""),
876
- placeholder="arc-key-... or set ARC_API_KEY secret"
877
- )
878
  with gr.Column(scale=1):
879
- fetch_btn = gr.Button("Fetch games")
880
-
881
- api_status = gr.Markdown()
882
-
883
  with gr.Row():
884
  with gr.Column(scale=2):
885
- game_dd = gr.Dropdown(label="Game", choices=[])
886
  with gr.Column(scale=1):
887
- steps_sl = gr.Slider(label="Max steps", minimum=20, maximum=500, value=150, step=10)
888
  with gr.Column(scale=1):
889
  with gr.Row():
890
- start_btn = gr.Button("▶ Watch", variant="primary")
891
- stop_btn = gr.Button("■ Stop")
892
-
893
- run_status = gr.Markdown("*Fetch games → select → Watch*")
894
-
 
895
  gr.Markdown("---")
896
-
 
 
 
 
 
 
897
  with gr.Row():
898
- grid_img = gr.Image(label="Current frame", type="pil", interactive=False, height=280)
899
- cand_img = gr.Image(label="Im candidate", type="pil", interactive=False, height=280)
900
-
901
- hyp_img = gr.Image(label="Im hypothesis", type="pil", interactive=False, height=200)
902
-
903
- timer = gr.Timer(value=1.0)
904
- timer.tick(pull_frame, outputs=[grid_img, hyp_img, cand_img, run_status])
905
-
906
- fetch_btn.click(fetch_games, inputs=api_box, outputs=[game_dd, api_status])
907
- start_btn.click(start_agent, inputs=[game_dd, api_box, steps_sl], outputs=run_status)
908
- stop_btn.click(stop_agent, outputs=run_status)
909
-
 
 
 
 
 
 
 
910
  gr.Markdown("""
911
  ---
912
  **Re/Im duality in action:**
913
-
914
- The Im side reads global structure (symmetry, flow, topology) and proposes transformations.
915
- The Re side identifies exact local coordinates where the transformation applies.
916
- Coherence C(r) = 2r/(1+r²) measures their alignment — low C = strong signal.
917
-
918
- CNN fires only when analytic solver confidence < 0.40.
919
  """)
920
 
921
  if __name__ == "__main__":
 
1
  """
2
+ ARC-AGI-3 Agent Spectator v4
3
  Hugging Face Space: beanapologist/arc-agi
4
 
5
+ Re/Im solver live demo:
6
+ Im side = bird's eye hypothesis (which transformation?)
7
+ Re side = exact diff (which cells to click?)
8
+ Bridge = ACTION6 at the Re-side coordinates that close the gap
9
  """
10
 
11
  import gradio as gr
 
26
  ARC_HEX = ['#000000','#1a6faf','#e03a3a','#3aa63a','#f5c400',
27
  '#c060c0','#d07030','#aaaaaa','#60b8d0','#874010']
28
  ARC_CMAP = ListedColormap(ARC_HEX)
29
+ COLOR_NAMES = ['black','blue','red','green','yellow',
30
+ 'purple','orange','gray','azure','maroon']
31
 
32
+ # ── Re/Im primitives ──────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
  def _sobel(f):
35
+ p=np.pad(f,1,mode='edge')
36
+ gx=(-p[:-2,:-2]-2*p[1:-1,:-2]-p[2:,:-2]+p[:-2,2:]+2*p[1:-1,2:]+p[2:,2:])/8
37
+ gy=(-p[:-2,:-2]-2*p[:-2,1:-1]-p[:-2,2:]+p[2:,:-2]+2*p[2:,1:-1]+p[2:,2:])/8
38
+ return gx,gy
39
+
40
+ def _sym_axis(grid,axis):
41
+ H,W=grid.shape; best_s,best_i=0.0,0
42
+ if axis=='h':
43
+ for x in range(1,W-1):
44
+ r=min(x,W-1-x)
45
+ s=(grid[:,x-r:x]==grid[:,x+1:x+r+1][:,::-1]).mean()
46
+ if s>best_s: best_s,best_i=s,x
47
+ else:
48
+ for y in range(1,H-1):
49
+ r=min(y,H-1-y)
50
+ s=(grid[y-r:y,:]==grid[y+1:y+r+1,:][::-1,:]).mean()
51
+ if s>best_s: best_s,best_i=s,y
52
+ return best_i,best_s
53
 
54
  def _boundary(grid):
55
+ p=np.pad(grid,1,mode='edge')
56
+ return ((p[1:-1,1:-1]!=p[:-2,1:-1])|(p[1:-1,1:-1]!=p[2:,1:-1])|
57
+ (p[1:-1,1:-1]!=p[1:-1,:-2])|(p[1:-1,1:-1]!=p[1:-1,2:])).astype(np.float32)
58
 
59
+ def _sym(grid,axis):
60
+ H,W=grid.shape; s=np.zeros((H,W),np.float32)
61
+ if axis=='h':
 
62
  for x in range(W):
63
+ r=min(x,W-1-x)
64
+ if r==0: s[:,x]=1.; continue
65
+ s[:,x]=(grid[:,x-r:x]==grid[:,x+1:x+r+1][:,::-1]).mean()
 
 
66
  else:
67
  for y in range(H):
68
+ r=min(y,H-1-y)
69
+ if r==0: s[y,:]=1.; continue
70
+ s[y,:]=(grid[y-r:y,:]==grid[y+1:y+r+1,:][::-1,:]).mean()
 
 
71
  return s
72
 
73
+ # ── Re/Im board reader reads directly from 56-channel feature tensor ─────────
74
+ # Im tensor = signal strength | raw grid = coordinates | i· = answer
75
+
76
+ """
77
+ arc_solver.py — Re/Im analytic solver using the 56-channel feature tensor
78
+ =========================================================================
79
+
80
+ The board IS M = Re(M) + i·Im(M).
81
+ The 56-channel extractor already IS log(M) decomposed:
82
+
83
+ Re channels (ch 0-47):
84
+ ch 0-15: one-hot colors — multiplicative / "what's there"
85
+ ch 16-31: component size maps — "how many, how big" (radial magnitude)
86
+ ch 32-47: distance-from-color maps — proximity / modulus
87
+
88
+ Im channels (ch 48-55):
89
+ ch 48: H-symmetry map — fold axis (Re(s)=1/2 analog)
90
+ ch 49: V-symmetry map — fold axis (vertical)
91
+ ch 50: rotational symmetry — winding structure
92
+ ch 51: Sobel x (edge horiz) — gradient Re component
93
+ ch 52: Sobel y (edge vert) — gradient Im component ← arg(M)
94
+ ch 53: boundary contour — Cauchy ∮ contour
95
+ ch 54: curl / winding proxy — arg(M), rotation direction
96
+ ch 55: component ID map — global topological / winding data
97
+
98
+ The Im channels are not clues — they ARE the answer structure.
99
+ i· (column swap) means: read the Im channels to get the Re answer.
100
+
101
+ Im H-symmetry strong + Re mass asymmetric → mirror to complete
102
+ Im boundary contour + Re solid fill → boundary IS the answer
103
+ Im curl/winding strong → rotation is needed
104
+ Im Sobel-y dominates → vertical flow (gravity)
105
+ Im component map has isolated islands → count → place
106
+
107
+ The CNN operates on all 56 channels.
108
+ This solver reads the Im channels analytically to short-circuit the CNN
109
+ when the Im signal is unambiguous.
110
+ """
111
+
112
+ import numpy as np
113
+ from typing import Optional, Tuple, List
114
+
115
+
116
+ # ── Re/Im channel indices (must match extract_features in my_agent.py) ────────
117
+
118
+ # Space uses 14-channel extractor (subset of full 56-channel Kaggle version)
119
+ # ch 0-9: Re — one-hot colors
120
+ # ch 10: Im — H-symmetry (fold axis)
121
+ # ch 11: Im — V-symmetry
122
+ # ch 12: Im — Cauchy boundary contour
123
+ # ch 13: Im — edge magnitude (Sobel combined)
124
+ CH_ONEHOT_START = 0
125
+ CH_CCSIZE_START = 0 # not in Space extractor — use onehot
126
+ CH_DIST_START = 0 # not in Space extractor — use onehot
127
+ CH_H_SYM = 10 # Im — horizontal symmetry
128
+ CH_V_SYM = 11 # Im — vertical symmetry
129
+ CH_ROT_SYM = 10 # not separate — reuse H-sym
130
+ CH_SOBEL_X = 13 # Im — edge (proxy for gradient)
131
+ CH_SOBEL_Y = 13 # Im — edge (proxy for gradient)
132
+ CH_BOUNDARY = 12 # Im — Cauchy boundary contour
133
+ CH_CURL = 10 # not separate — reuse H-sym
134
+ CH_COMPONENT_ID = 11 # not separate — reuse V-sym
135
+
136
+ NUM_CHANNELS = 14 # Space extractor has 14 channels
137
+ CONFIDENCE_THRESHOLD = 0.72
138
+
139
+
140
+ # ── Raw grid primitives (for when we don't have the tensor yet) ───────────────
141
+
142
+ def _boundary_raw(grid):
143
+ p = np.pad(grid, 1, mode='edge')
144
+ return ((p[1:-1,1:-1]!=p[:-2,1:-1])|(p[1:-1,1:-1]!=p[2:,1:-1])|
145
+ (p[1:-1,1:-1]!=p[1:-1,:-2])|(p[1:-1,1:-1]!=p[1:-1,2:])).astype(np.float32)
146
+
147
+ def _perimeter(grid):
148
+ """Object perimeter — handles solid blocks where _boundary_raw gives 0."""
149
+ H, W = grid.shape
150
+ p = np.zeros((H,W), dtype=np.float32)
151
+ mask = grid > 0
152
+ if not mask.any(): return p
153
+ padded = np.pad(mask.astype(int), 1, constant_values=0)
154
+ for dy,dx in [(-1,0),(1,0),(0,-1),(0,1)]:
155
+ shifted = padded[1+dy:H+1+dy, 1+dx:W+1+dx]
156
+ p[mask & (shifted==0)] = 1
157
+ if p.sum() == 0: # solid block: use outer ring
158
+ p[0,:] = mask[0,:].astype(float); p[-1,:] = mask[-1,:].astype(float)
159
+ p[:,0] = mask[:,0].astype(float); p[:,-1] = mask[:,-1].astype(float)
160
+ return p
161
+
162
+
163
+ # ── Re/Im board reader ────────────────────────────────────────────────────────
164
 
165
+ def read_board(grid: np.ndarray, features: Optional[np.ndarray] = None):
166
  """
167
+ Read the board as complex object M = Re(M) + i·Im(M).
168
+
169
+ Parameters
170
+ ----------
171
+ grid : 2D int array, the raw color grid
172
+ features : optional (56, H, W) float array from extract_features()
173
+ If provided, reads Im channels directly (more accurate).
174
+ If None, recomputes Im channels from the raw grid.
175
+
176
+ Returns
177
+ -------
178
+ answer : np.ndarray — derived answer grid
179
+ confidence : float 0-1
180
+ reasoning : list of strings — one per Im signal that fired
181
+ signal : str — which Im channel drove the answer
182
  """
183
  H, W = grid.shape
184
+ reasoning = []
185
+ answer = None
186
+ confidence = 0.0
187
+ signal = 'none'
188
+
189
+ # ── Read Im channels ──────────────────────────────────────────────────────
190
+ # Use pre-computed feature tensor if available (same computation the CNN uses)
191
+ if features is not None and features.shape[0] == NUM_CHANNELS:
192
+ feat = features
193
+ # Resize Im maps back to grid space if needed
194
+ fH, fW = feat.shape[1], feat.shape[2]
195
+
196
+ def _im(ch):
197
+ """Read Im channel, resize to grid if needed."""
198
+ m = feat[ch]
199
+ if (fH, fW) != (H, W):
200
+ # Simple nearest-neighbor resize
201
+ ry = np.linspace(0, fH-1, H).astype(int)
202
+ rx = np.linspace(0, fW-1, W).astype(int)
203
+ return m[np.ix_(ry, rx)]
204
+ return np.array(m)
205
+
206
+ h_sym_map = _im(CH_H_SYM)
207
+ v_sym_map = _im(CH_V_SYM)
208
+ rot_map = _im(CH_ROT_SYM)
209
+ sobel_x = _im(CH_SOBEL_X)
210
+ sobel_y = _im(CH_SOBEL_Y)
211
+ bound_map = _im(CH_BOUNDARY)
212
+ curl_map = _im(CH_CURL)
213
+ comp_map = _im(CH_COMPONENT_ID)
214
+
215
+ # Re channels: color presence and component sizes
216
+ onehot = feat[CH_ONEHOT_START:CH_ONEHOT_START+16]
217
+ cc_sizes = feat[CH_CCSIZE_START:CH_CCSIZE_START+16]
218
+
219
+ else:
220
+ # Compute features inline and retry once (no recursion)
221
+ t = extract_features(grid)
222
+ feat_np = t.numpy() if hasattr(t, 'numpy') else np.array(t)
223
+ if feat_np.shape[0] == NUM_CHANNELS:
224
+ return read_board(grid, features=feat_np)
225
+ # Shape mismatch just compute Im maps directly from grid
226
+ _gx,_gy = _sobel(grid.astype(np.float32)/9)
227
+ h_sym_map = _sym(grid,'h'); v_sym_map = _sym(grid,'v')
228
+ bound_map = _boundary(grid)
229
+ edge_map = np.sqrt(_gx**2+_gy**2).astype(np.float32)
230
+ # Build minimal feature array
231
+ _oh = np.zeros((10,H,W),np.float32)
232
+ for _c in range(10): _oh[_c]=(grid==_c).astype(np.float32)
233
+ features = np.concatenate([_oh,h_sym_map[np.newaxis],v_sym_map[np.newaxis],
234
+ bound_map[np.newaxis],edge_map[np.newaxis]],axis=0)
235
+ return read_board(grid, features=features)
236
+
237
+ # ── Im signal 1: H-symmetry (fold axis) ───────────────────────────────────
238
+ # Im ch48 gives the symmetry score — use its MAX as signal strength.
239
+ # But compute the actual best axis in raw grid space (not 64x64 space).
240
+ # This is the Re/Im separation: Im tensor gives the SIGNAL,
241
+ # Re grid gives the COORDINATES.
242
+ best_hs = float(h_sym_map.max()) # Im: how strong is any fold?
243
+
244
+ if best_hs > 0.65:
245
+ # Find best axis in grid space (not feature space)
246
+ h_scores_grid = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  for x in range(1, W-1):
248
  r = min(x, W-1-x)
249
  if r > 0:
250
+ s = (grid[:, x-r:x] == grid[:, x+1:x+r+1][:,::-1]).mean()
251
+ h_scores_grid.append((x, float(s)))
252
+ if h_scores_grid:
253
+ best_hx, best_hs_grid = max(h_scores_grid, key=lambda x: x[1])
254
+ # Use Im tensor strength as upper bound, grid score as actual
255
+ hs = min(best_hs, best_hs_grid) if best_hs_grid > 0.4 else best_hs_grid
256
+ left_px = int((grid[:, :best_hx] > 0).sum())
257
+ right_px = int((grid[:, best_hx:] > 0).sum())
258
+ total_px = left_px + right_px
259
+ asymmetry = abs(left_px - right_px) / max(total_px, 1)
260
+ if asymmetry > 0.25 and hs > 0.55:
261
+ ans = grid.copy()
262
+ if left_px > right_px:
263
+ for c in range(best_hx):
264
+ mir = W - 1 - c
265
+ if 0 <= mir < W:
266
+ mask = ans[:, mir] == 0
267
+ ans[mask, mir] = grid[mask, c]
268
+ else:
269
+ for c in range(best_hx+1, W):
270
+ mir = W - 1 - c
271
+ if 0 <= mir < W:
272
+ mask = ans[:, mir] == 0
273
+ ans[mask, mir] = grid[mask, c]
274
+ conf = hs * asymmetry * 0.95
275
+ if conf > confidence:
276
+ answer, confidence, signal = ans, conf, 'Im:H-sym'
277
+ reasoning.append(
278
+ f"Im ch48 signal={best_hs:.2f} grid_score={hs:.2f} at x={best_hx} | "
279
+ f"Re left={left_px} right={right_px} asym={asymmetry:.2f} | "
280
+ f"i·: complete the fold")
281
+
282
+ # ── Im signal 2: V-symmetry ────────────────────────────────────────────────
283
+ best_vs = float(v_sym_map.max()) # Im: V-fold signal strength
284
+
285
+ if best_vs > 0.65 and confidence < 0.6:
286
+ v_scores_grid = []
287
  for y in range(1, H-1):
288
  r = min(y, H-1-y)
289
  if r > 0:
290
+ s = (grid[y-r:y, :] == grid[y+1:y+r+1, :][::-1, :]).mean()
291
+ v_scores_grid.append((y, float(s)))
292
+ if v_scores_grid:
293
+ best_vy, best_vs_grid = max(v_scores_grid, key=lambda x: x[1])
294
+ vs = min(best_vs, best_vs_grid) if best_vs_grid > 0.4 else best_vs_grid
295
+ top_px = int((grid[:best_vy, :] > 0).sum())
296
+ bot_px = int((grid[best_vy:, :] > 0).sum())
297
+ total_px = top_px + bot_px
298
+ asymmetry = abs(top_px - bot_px) / max(total_px, 1)
299
+ if asymmetry > 0.25 and vs > 0.55:
300
+ ans = grid.copy()
301
+ if top_px > bot_px:
302
+ for r in range(best_vy):
303
+ mir = H - 1 - r
304
+ if 0 <= mir < H:
305
+ mask = ans[mir, :] == 0
306
+ ans[mir, mask] = grid[r, mask]
307
+ else:
308
+ for r in range(best_vy+1, H):
309
+ mir = H - 1 - r
310
+ if 0 <= mir < H:
311
+ mask = ans[mir, :] == 0
312
+ ans[mir, mask] = grid[r, mask]
313
+ conf = vs * asymmetry * 0.90
314
+ if conf > confidence:
315
+ answer, confidence, signal = ans, conf, 'Im:V-sym'
316
+ reasoning.append(
317
+ f"Im ch49 signal={best_vs:.2f} grid_score={vs:.2f} at y={best_vy} | "
318
+ f"Re top={top_px} bot={bot_px} | i·: complete V fold")
319
+
320
+ # ── Im signal 3: Cauchy boundary contour ──────────────────────────────────
321
+ # Im ch53 IS the Cauchy contour. When Re fill is solid (high density)
322
+ # and Im boundary is thin, the answer IS the boundary.
323
+ # "The radius cancels (Re collapses); only the angular part i·dθ survives"
324
+ total_px = int((grid > 0).sum())
325
+ if total_px > 0 and confidence < 0.75:
326
+ fill_ratio = total_px / (H * W)
327
+ colors = [c for c in range(1,10) if (grid==c).any()]
328
+
329
+ if len(colors) == 1 and fill_ratio > 0.4:
330
+ # Single color, high fill → Im boundary IS the answer
331
+ perim = _perimeter(grid)
332
+ # Check interior isn't already hollow
333
+ interior = (grid == 0) & (perim == 0)
334
+ if not interior.any():
335
+ ans = np.zeros_like(grid)
336
+ ans[perim > 0] = grid[perim > 0]
337
+ conf = fill_ratio * 0.90
338
+ if conf > confidence:
339
+ answer, confidence, signal = ans, conf, 'Im:boundary'
340
+ reasoning.append(
341
+ f"Im ch53 Cauchy contour | Re fill={fill_ratio:.2f} "
342
+ f"single color={colors[0]} | "
343
+ f"i·: Im boundary = Re answer (Cauchy: radius cancels)")
344
+
345
+ elif fill_ratio > 0.3 and bound_map.mean() < 0.15:
346
+ # Multi-color but low boundary density → extract boundary
347
+ ans = np.zeros_like(grid)
348
+ ans[bound_map > 0.3] = grid[bound_map > 0.3]
349
+ if (ans > 0).any():
350
+ conf = fill_ratio * (1 - float(bound_map.mean())) * 0.75
351
+ if conf > confidence:
352
+ answer, confidence, signal = ans, conf, 'Im:boundary'
353
+ reasoning.append(
354
+ f"Im ch53 boundary density={bound_map.mean():.3f} | "
355
+ f"Re fill={fill_ratio:.2f} | i·: Cauchy contour")
356
+
357
+ # ── Im signal 4: Interior fill ────────────────────────────────────────────
358
+ # Im ch53: If colored region fully encloses empty cells → fill interior.
359
+ # Use flood-fill from boundary to find truly enclosed (unreachable) cells.
360
+ if confidence < 0.55 and total_px > 0:
361
+ # Flood-fill empty cells reachable from grid boundary
362
+ reachable = np.zeros((H, W), dtype=bool)
363
+ fq = []
364
+ for _r in range(H):
365
+ for _c in range(W):
366
+ if grid[_r,_c]==0 and (_r==0 or _r==H-1 or _c==0 or _c==W-1):
367
+ if not reachable[_r,_c]:
368
+ reachable[_r,_c]=True; fq.append((_r,_c))
369
+ while fq:
370
+ _y,_x=fq.pop()
371
+ for _dy,_dx in [(-1,0),(1,0),(0,-1),(0,1)]:
372
+ _ny,_nx=_y+_dy,_x+_dx
373
+ if 0<=_ny<H and 0<=_nx<W and grid[_ny,_nx]==0 and not reachable[_ny,_nx]:
374
+ reachable[_ny,_nx]=True; fq.append((_ny,_nx))
375
+ truly_interior = (grid == 0) & ~reachable
376
+ if truly_interior.any():
377
+ dominant = int(np.argmax(np.bincount(
378
+ grid[grid>0].flatten(), minlength=10)[1:])) + 1
379
+ ans = grid.copy()
380
+ ans[truly_interior] = dominant
381
+ conf = truly_interior.sum() / max(1, (grid==0).sum()) * 0.80
382
+ if conf > confidence:
383
+ answer, confidence, signal = ans, conf, 'Im:hollow→fill'
384
+ reasoning.append(
385
+ f"Im ch53 enclosed interior={int(truly_interior.sum())}px | "
386
+ f"Re dominant={dominant} | i·: fill enclosed interior")
387
+
388
+ # ── Im signal 5: Gradient flow → gravity ──────────────────────────────────
389
+ # Im ch51/52 = Sobel x/y = gradient field direction = arg(M)
390
+ # Suspended Re pixels + Im gradient direction → gravity answer
391
+ suspended = sum(
392
+ 1 for c in range(W)
393
+ for r in np.where(grid[:, c] > 0)[0]
394
+ if r < H-1 and grid[r+1, c] == 0
395
+ )
396
+ if suspended > 0 and confidence < 0.70:
397
+ gx_mag = float(np.abs(sobel_x).mean())
398
+ gy_mag = float(np.abs(sobel_y).mean())
399
+ direction = 'down' if gy_mag >= gx_mag else 'right'
400
+ ans = np.zeros_like(grid)
401
+ if direction == 'down':
402
  for c in range(W):
403
+ vals = grid[:, c][grid[:, c] > 0]
404
+ if len(vals): ans[H-len(vals):H, c] = vals
405
+ else:
406
+ for r in range(H):
407
+ vals = grid[r, :][grid[r, :] > 0]
408
+ if len(vals): ans[r, W-len(vals):W] = vals
409
+ conf = min(0.80, suspended / max(total_px, 1) * 2.5)
410
+ if conf > confidence:
411
+ answer, confidence, signal = ans, conf, 'Im:Sobel→gravity'
412
+ reasoning.append(
413
+ f"Im ch52 Sobel-y={gy_mag:.3f} ch51 Sobel-x={gx_mag:.3f} | "
414
+ f"Re suspended={suspended}px | i·: arg(M) gives gravity {direction}")
415
+
416
+ # ── Im signal 6: Rotational symmetry / curl ────────────────────────────────
417
+ # Im ch50 rot_sym + ch54 curl → rotation answer
418
+ rot_score = float(rot_map.max())
419
+ curl_max = float(np.abs(curl_map).max())
420
+ if rot_score > 0.6 and curl_max > 0.4 and confidence < 0.35:
421
+ ans = np.rot90(grid)
422
+ conf = rot_score * curl_max * 0.60
423
+ if conf > confidence:
424
+ answer, confidence, signal = ans, conf, 'Im:rot+curl'
425
+ reasoning.append(
426
+ f"Im ch50 rot={rot_score:.2f} ch54 curl={curl_max:.2f} | "
427
+ f"i·: rotation indicated")
428
+
429
+ # ── Im signal 7: Color remapping (Re→Im count ordering) ───────────────────
430
+ # Im component map (ch55) encodes which regions are distinct objects.
431
+ # If Re colors appear in counts that suggest an ordering → shift colors.
432
+ if confidence < 0.30 and total_px > 0:
433
+ colors = [c for c in range(1,10) if (grid==c).any()]
434
+ if colors and max(colors) < 9:
435
+ ans = grid.copy()
436
+ mask = grid > 0
437
+ ans[mask] = ((grid[mask] - 1 + 1) % 9) + 1
438
+ if conf > confidence:
439
+ answer, confidence, signal = ans, 0.30, 'Im:color_shift'
440
+ reasoning.append(
441
+ f"Im ch55 component topology | Re colors {colors} | "
442
+ f"i·: Re→Im shift = increment colors")
443
+
444
+ return answer, confidence, reasoning, signal
445
+
446
+
447
+ # ── Re-side: exact cell targeting ────────────────────────────────────────────
448
+
449
+ def pixel_diff(current: np.ndarray, target: np.ndarray):
450
+ """All differing cells: [(r, c, target_color)]"""
451
+ if current.shape != target.shape: return []
452
+ return [(int(r), int(c), int(target[r,c]))
453
+ for r in range(current.shape[0])
454
+ for c in range(current.shape[1])
455
+ if current[r,c] != target[r,c]]
456
+
457
+ def most_urgent_diff(current: np.ndarray, target: np.ndarray):
458
+ """
459
+ Im → Re: pick the most important cell using the Cauchy principle.
460
+ The boundary contour determines the interior, so fix boundary cells first.
461
+ This is ∮ doing its job: read global Im data, recover local Re data.
462
+ """
463
+ diffs = pixel_diff(current, target)
464
+ if not diffs: return None
465
+ bound = _boundary_raw(current)
466
+ boundary_diffs = [(r,c,v) for r,c,v in diffs if bound[r,c] > 0]
467
+ pool = boundary_diffs if boundary_diffs else diffs
468
+ return pool[np.random.randint(len(pool))]
469
+
470
+
471
+ # ── Main entry point ─────────────────────────────────────────────────────────
472
+
473
+ def try_analytic_action(
474
+ frame_2d: np.ndarray,
475
+ available_actions,
476
+ features: Optional[np.ndarray] = None,
477
+ ) -> Tuple[Optional[int], Optional[dict], str, float]:
478
+ """
479
+ Read the board's Im channels to derive the answer, then use Re-side
480
+ pixel diff to find the exact cell to click.
481
+
482
+ Parameters
483
+ ----------
484
+ frame_2d : raw 2D color grid
485
+ available_actions : list of available GameAction values
486
+ features : optional pre-computed (56,H,W) feature array
487
+ (pass this from MyAgent to avoid recomputing)
488
+
489
+ Returns (action_id, action_data, signal_name, confidence)
490
+ """
491
+ if frame_2d is None: return None, None, 'none', 0.0
492
+
493
+ avail_ids = set(
494
+ int(a.value if hasattr(a,'value') else a)
495
+ for a in (available_actions or range(1,7))
496
+ )
497
+
498
+ answer, confidence, reasoning, signal = read_board(frame_2d, features)
499
+
500
+ if answer is None or confidence < CONFIDENCE_THRESHOLD:
501
+ return None, None, signal, confidence
502
+
503
+ diffs = pixel_diff(frame_2d, answer)
504
+ if not diffs:
505
+ return None, None, 'already_matches', confidence
506
+
507
+ # ACTION6: click the most urgent Re-side cell
508
+ if 6 in avail_ids:
509
+ cell = most_urgent_diff(frame_2d, answer)
510
+ if cell is not None:
511
+ r, c, _ = cell
512
+ H, W = frame_2d.shape
513
+ game_y = min(63, max(0, int(r * 64/H + 32/H)))
514
+ game_x = min(63, max(0, int(c * 64/W + 32/W)))
515
+ return 6, {'x': game_x, 'y': game_y}, signal, confidence
516
+
517
+ return None, None, 'no_action6', confidence
518
+
519
+
520
+
521
+
522
+ # ── Feature extractor ─────────────────────────────────────────────────────────
523
+
524
+ def extract_features(grid,num_colours=10):
525
+ H,W=grid.shape
526
+ oh=np.zeros((num_colours,H,W),np.float32)
527
+ for c in range(num_colours): oh[c]=(grid==c).astype(np.float32)
528
+ gx,gy=_sobel(grid.astype(np.float32)/9)
529
+ stacked=np.concatenate([oh,_sym(grid,'h')[np.newaxis],
530
+ _sym(grid,'v')[np.newaxis],
531
+ _boundary(grid)[np.newaxis],
532
+ np.sqrt(gx**2+gy**2)[np.newaxis].astype(np.float32)],axis=0)
533
+ t=torch.from_numpy(stacked).float().unsqueeze(0)
534
+ if H!=64 or W!=64:
535
+ t=TF.interpolate(t,size=(64,64),mode='bilinear',align_corners=False)
536
+ return t.squeeze(0)
537
+
538
+ # ── Gabor filter bank — s-plane cross terms ─────────────────────────────────
539
+
540
+ """
541
+ gabor_channels.py
542
+ =================
543
+ Gabor filter bank for ARC-AGI-3 — the s-plane cross terms.
544
+
545
+ Mathematical position
546
+ ---------------------
547
+ The existing 56-channel extractor covers the AXES of the s-plane:
548
+ Re axis (σ>0, ω=0): ch16-47 CC sizes, distance maps — Laplace side
549
+ Im axis (σ=0, ω>0): ch48-55 symmetry, Sobel, boundary — Fourier side
550
+
551
+ A Gabor filter lives at an INTERIOR point (σ>0, ω>0):
552
+ g(x,y) = exp(-sigma*r^2) · cos(ω·x_θ + φ)
553
+ ______/ ___________/
554
+ Re/Laplace Im/Fourier
555
+ envelope carrier
556
+
557
+ This is exp(-st) evaluated at s = σ + iω, rotated by θ, phased by φ.
558
+ It measures: "is there oscillation at frequency ω in direction θ,
559
+ concentrated within decay radius 1/√σ?"
560
+
561
+ ARC relevance
562
+ -------------
563
+ The cross terms detect structures the axis channels miss:
564
+ - Repeating patterns with finite extent (tiling with boundary)
565
+ - Oriented edges at specific spatial scales
566
+ - Localized symmetry (symmetric patch inside asymmetric grid)
567
+ - Diagonal structure (axis channels are H/V only)
568
+
569
+ Channel layout (72 channels total)
570
+ -----------------------------------
571
+ 3 σ values × 3 ω values × 4 orientations × 2 phases = 72
572
+
573
+ σ = 0.3 → tight decay, radius ~1.8px — local structure
574
+ σ = 1.0 → medium decay, radius ~1.0px — mid-scale
575
+ σ = 2.5 → broad decay, radius ~0.6px — global texture
576
+
577
+ ω = 0.5 → coarse frequency, period ~12px — large patterns
578
+ ω = 1.5 → medium frequency, period ~4px — medium patterns
579
+ ω = 3.0 → fine frequency, period ~2px — fine detail
580
+
581
+ θ = 0, π/4, π/2, 3π/4 — 4 orientations (H, diagonal, V, anti-diagonal)
582
+
583
+ φ = 0 → cosine (even symmetry, detects symmetric features)
584
+ φ = π/2 → sine (odd symmetry, detects antisymmetric/edge features)
585
+ """
586
+
587
+ import numpy as np
588
+ import torch
589
+ import torch.nn.functional as F
590
+ from typing import List, Tuple
591
+
592
+
593
+ # ── Filter bank parameters ────────────────────────────────────────────────────
594
+
595
+ SIGMA_VALUES = [0.3, 1.0, 2.5] # Re/Laplace decay
596
+ OMEGA_VALUES = [0.5, 1.5, 3.0] # Im/Fourier frequency
597
+ THETA_VALUES = [0, np.pi/4, np.pi/2, 3*np.pi/4] # orientations
598
+ PHASE_VALUES = [0.0, np.pi/2] # cosine, sine
599
+
600
+ N_GABOR_CHANNELS = (len(SIGMA_VALUES) * len(OMEGA_VALUES) *
601
+ len(THETA_VALUES) * len(PHASE_VALUES)) # = 72
602
+
603
+ KERNEL_SIZE = 7 # 7×7 kernels — enough for 64×64 grid
604
+
605
+
606
+ def _make_gabor_kernel(sigma: float, omega: float,
607
+ theta: float, phi: float,
608
+ size: int = KERNEL_SIZE) -> np.ndarray:
609
+ """
610
+ 2D Gabor kernel at one point in the s-plane.
611
+
612
+ s = σ + iω (the seam: Re side = decay, Im side = oscillation)
613
+ θ = orientation, φ = phase
614
+ """
615
+ half = size // 2
616
+ y, x = np.mgrid[-half:half+1, -half:half+1].astype(np.float32)
617
+
618
+ # Rotate to orientation θ
619
+ x_rot = x * np.cos(theta) + y * np.sin(theta)
620
+ y_rot = -x * np.sin(theta) + y * np.cos(theta)
621
+
622
+ # Re side: Gaussian envelope — exp(-sigma*r^2)
623
+ envelope = np.exp(-sigma * (x_rot**2 + y_rot**2))
624
+
625
+ # Im side: sinusoidal carrier — cos(ω·x_θ + φ)
626
+ carrier = np.cos(omega * x_rot + phi)
627
+
628
+ # s-plane cross term: exp(-sigma*r^2) · cos(ω·x_θ + φ)
629
+ kernel = envelope * carrier
630
+
631
+ # Zero-mean (remove DC) — ensures kernel responds to structure, not brightness
632
+ kernel -= kernel.mean()
633
+ norm = np.sqrt((kernel ** 2).sum())
634
+ if norm > 0:
635
+ kernel /= norm
636
+
637
+ return kernel # shape (size, size)
638
+
639
+
640
+ def build_gabor_bank() -> torch.Tensor:
641
+ """
642
+ Build the full Gabor filter bank as a (72, 1, K, K) tensor
643
+ ready for torch.nn.functional.conv2d.
644
+ """
645
+ kernels = []
646
+ for sigma in SIGMA_VALUES:
647
+ for omega in OMEGA_VALUES:
648
+ for theta in THETA_VALUES:
649
+ for phi in PHASE_VALUES:
650
+ k = _make_gabor_kernel(sigma, omega, theta, phi)
651
+ kernels.append(k)
652
+ bank = np.stack(kernels, axis=0) # (72, K, K)
653
+ return torch.from_numpy(bank).float().unsqueeze(1) # (72, 1, K, K)
654
+
655
+
656
+ # Pre-built bank — computed once at import time
657
+ _GABOR_BANK: torch.Tensor = build_gabor_bank()
658
+
659
+
660
+ def extract_gabor_features(grid_2d: np.ndarray,
661
+ grid_size: int = 64) -> torch.Tensor:
662
+ """
663
+ Apply the Gabor bank to a 2D color grid.
664
+
665
+ Parameters
666
+ ----------
667
+ grid_2d : np.ndarray (H, W) int — raw color grid
668
+ grid_size : int — target output size (default 64, matching ActionModel)
669
+
670
+ Returns
671
+ -------
672
+ torch.Tensor (72, grid_size, grid_size) float32
673
+ """
674
+ H, W = grid_2d.shape
675
+
676
+ # Normalize grid to [0, 1] float
677
+ grid_f = torch.from_numpy(
678
+ grid_2d.astype(np.float32) / 9.0
679
+ ).unsqueeze(0).unsqueeze(0) # (1, 1, H, W)
680
+
681
+ # Resize to grid_size if needed
682
+ if H != grid_size or W != grid_size:
683
+ grid_f = F.interpolate(grid_f, size=(grid_size, grid_size),
684
+ mode='bilinear', align_corners=False)
685
+
686
+ # Apply all 72 Gabor filters simultaneously
687
+ pad = KERNEL_SIZE // 2
688
+ responses = F.conv2d(grid_f, _GABOR_BANK, padding=pad) # (1, 72, H, W)
689
+ responses = responses.squeeze(0) # (72, grid_size, grid_size)
690
+
691
+ # Normalize responses to [-1, 1]
692
+ max_val = responses.abs().max()
693
+ if max_val > 0:
694
+ responses = responses / max_val
695
+
696
+ return responses
697
+
698
+
699
+ def channel_descriptions() -> List[str]:
700
+ """Human-readable description of each Gabor channel."""
701
+ descs = []
702
+ ch = 0
703
+ for sigma in SIGMA_VALUES:
704
+ for omega in OMEGA_VALUES:
705
+ for theta in THETA_VALUES:
706
+ theta_deg = int(theta * 180 / np.pi)
707
+ for phi in PHASE_VALUES:
708
+ phase_name = 'cos' if phi == 0 else 'sin'
709
+ descs.append(
710
+ f"ch{56+ch:3d}: Gabor σ={sigma:.1f} ω={omega:.1f} "
711
+ f"θ={theta_deg}° φ={phase_name} "
712
+ f"[s={sigma:.1f}+{omega:.1f}i]"
713
+ )
714
+ ch += 1
715
+ return descs
716
+
717
+
718
+ # ── S-plane visualization ─────────────────────────────────────────────────────
719
+
720
+ def splane_coverage_report():
721
+ """Print the s-plane coverage table."""
722
+ print("s-plane coverage: σ (Re/Laplace) × ω (Im/Fourier)")
723
+ print("="*55)
724
+ print(f"{'':8}", end="")
725
+ for o in OMEGA_VALUES:
726
+ print(f" ω={o:.1f}", end="")
727
+ print()
728
+ for s in SIGMA_VALUES:
729
+ print(f"σ={s:.1f} ", end="")
730
+ for o in OMEGA_VALUES:
731
+ n = len(THETA_VALUES) * len(PHASE_VALUES)
732
+ print(f" {n:3d}ch", end="")
733
+ print(f" (×{len(THETA_VALUES)}θ ×{len(PHASE_VALUES)}φ)")
734
+ print()
735
+ print(f"Total Gabor channels: {N_GABOR_CHANNELS}")
736
+ print(f"Existing axis channels: 56")
737
+ print(f"Combined total: {56 + N_GABOR_CHANNELS}")
738
+
739
+
740
+
741
+
742
+ # ── Rendering ─────────────────────────────────────────────────────────────────
743
+
744
+
745
+ def _pil(fig):
746
+ buf=io.BytesIO()
747
+ fig.savefig(buf,format='png',dpi=80,bbox_inches='tight',
748
+ facecolor=fig.get_facecolor())
749
+ buf.seek(0); img=Image.open(buf).copy(); plt.close(fig)
750
+ return img
751
+
752
+ def render_grid(grid,title='',highlight=None,mark_cell=None):
753
+ if grid is None: return None
754
+ H,W=grid.shape; cell=max(28,min(56,360//max(H,W)))
755
+ fig,ax=plt.subplots(figsize=((W*cell+4)/72,(H*cell+22)/72),dpi=72)
756
+ fig.patch.set_facecolor('#1e1e2e'); ax.set_facecolor('#1e1e2e')
757
+ ax.imshow(grid,cmap=ARC_CMAP,vmin=0,vmax=9,interpolation='nearest',aspect='equal')
758
+ for x in range(W+1): ax.axvline(x-.5,color='#444',lw=.5)
759
+ for y in range(H+1): ax.axhline(y-.5,color='#444',lw=.5)
760
  for r in range(H):
761
  for c in range(W):
762
+ v=int(grid[r,c])
763
+ col='white' if v in [0,1,2,3,5,6,9] else 'black'
764
+ ax.text(c,r,str(v),ha='center',va='center',
765
+ fontsize=max(7,cell//5),color=col,
766
+ fontweight='bold',fontfamily='monospace')
767
+ if highlight is not None:
768
+ for r,c,_ in highlight:
769
+ ax.add_patch(plt.Rectangle((c-.5,r-.5),1,1,
770
+ fill=True,facecolor='#ff4444',alpha=0.35,lw=0))
771
+ if mark_cell is not None:
772
+ r,c,_=mark_cell
773
+ ax.add_patch(plt.Rectangle((c-.5,r-.5),1,1,
774
+ fill=False,edgecolor='#00ffff',lw=2.5))
775
+ ax.plot(c,r,'*',color='#00ffff',markersize=max(8,cell//4))
776
+ ax.set_xlim(-.5,W-.5); ax.set_ylim(H-.5,-.5); ax.axis('off')
777
+ if title: ax.set_title(title,color='#cdd6f4',fontsize=9,pad=4)
778
+ plt.tight_layout(pad=.3)
779
+ return _pil(fig)
780
+
781
+ def render_hypothesis_panel(candidates):
782
+ """Im side: bar chart of top hypotheses with confidence."""
783
+ if not candidates: return None
784
+ top=candidates[:6]
785
+ names=[c[0] for c in top]; confs=[c[2] for c in top]
786
+ fig,ax=plt.subplots(figsize=(5,2.2))
787
+ fig.patch.set_facecolor('#1e1e2e'); ax.set_facecolor('#1e1e2e')
788
+ colors=['#ffd700' if i==0 else '#4a9eff' for i in range(len(top))]
789
+ bars=ax.barh(names[::-1],confs[::-1],color=colors[::-1],height=0.6)
790
+ for bar,conf in zip(bars,confs[::-1]):
791
+ ax.text(bar.get_width()+.01,bar.get_y()+bar.get_height()/2,
792
+ f'{conf:.2f}',va='center',color='white',fontsize=8)
793
+ ax.set_xlim(0,1.15); ax.axvline(0.4,color='#ff6666',lw=1,ls='--',alpha=0.7)
794
+ ax.text(0.41,0,'threshold',color='#ff6666',fontsize=7,va='bottom')
795
+ ax.tick_params(colors='#888',labelsize=8); ax.spines[:].set_visible(False)
796
+ ax.set_title('Im side — hypothesis ranking 🟡=selected',
797
+ color='#cdd6f4',fontsize=9,pad=3)
798
+ plt.tight_layout(pad=.4)
799
+ return _pil(fig)
800
+
801
+ def render_action_bar(action_counts,total):
802
+ if not action_counts or total==0: return None
803
+ labels=[f"A{k}" for k in sorted(action_counts)]
804
+ vals =[action_counts[k] for k in sorted(action_counts)]
805
+ pcts =[v/total*100 for v in vals]
806
+ fig,ax=plt.subplots(figsize=(4,1.6))
807
+ fig.patch.set_facecolor('#1e1e2e'); ax.set_facecolor('#1e1e2e')
808
+ colors=['#4a9eff','#e05050','#50c050','#f5c400','#c060c0','#d07030']
809
+ bars=ax.barh(labels,pcts,color=colors[:len(labels)],height=0.6)
810
+ for bar,v,p in zip(bars,vals,pcts):
811
+ ax.text(min(p+1,98),bar.get_y()+bar.get_height()/2,
812
+ f'{v}',va='center',color='white',fontsize=8)
813
+ ax.set_xlim(0,110); ax.tick_params(colors='#888',labelsize=8)
814
+ ax.spines[:].set_visible(False)
815
+ ax.set_title('Action frequency',color='#cdd6f4',fontsize=9,pad=3)
816
+ plt.tight_layout(pad=.4)
817
+ return _pil(fig)
818
+
819
+ def render_reward_chart(reward_history):
820
+ if len(reward_history)<2: return None
821
+ fig,ax=plt.subplots(figsize=(5,1.6))
822
+ fig.patch.set_facecolor('#1e1e2e'); ax.set_facecolor('#1e1e2e')
823
+ for i,r in enumerate(reward_history):
824
+ col='#ffd700' if r>=5 else ('#50c050' if r>0 else '#e05050')
825
+ ax.bar(i,r,color=col,width=1,alpha=0.8)
826
+ ax.axhline(0,color='#555',lw=0.5)
827
+ ax.set_xlim(0,len(reward_history))
828
+ ax.tick_params(colors='#888',labelsize=7); ax.spines[:].set_visible(False)
829
+ ax.set_title('Reward 🟡=level-up 🟢=change 🔴=dead',
830
+ color='#cdd6f4',fontsize=8,pad=3)
831
+ plt.tight_layout(pad=.3)
832
+ return _pil(fig)
833
+
834
+ def render_gabor_panel(grid):
835
+ """
836
+ Visualize the top-responding Gabor channels — the s-plane cross terms.
837
+ Shows which (σ, ω, θ) combination is most active on the current frame.
838
+ """
839
+ if grid is None: return None
840
+ feats = extract_gabor_features(grid) # (72, 64, 64)
841
+ max_per_ch = feats.abs().amax(dim=(1,2)).numpy()
842
+ top4_idx = max_per_ch.argsort()[-4:][::-1]
843
+
844
+ fig, axes = plt.subplots(1, 4, figsize=(10, 2.2))
845
+ fig.patch.set_facecolor('#1e1e2e')
846
+
847
+ # Build channel labels
848
+ labels = []
849
+ for s in SIGMA_VALUES:
850
+ for o in OMEGA_VALUES:
851
+ for t in THETA_VALUES:
852
+ for p in PHASE_VALUES:
853
+ td = int(t*180/np.pi)
854
+ pn = 'cos' if p==0 else 'sin'
855
+ labels.append(f's={s:.1f}+{o:.1f}i th={td} {pn}')
856
 
857
+ for ax, idx in zip(axes, top4_idx):
858
+ ax.set_facecolor('#0d0d1a')
859
+ ch_map = feats[idx].numpy()
860
+ im = ax.imshow(ch_map, cmap='RdBu', vmin=-1, vmax=1,
861
+ interpolation='nearest', aspect='equal')
862
+ ax.set_title(labels[idx], color='#cdd6f4', fontsize=7, pad=2)
863
+ ax.axis('off')
864
+ plt.colorbar(im, ax=ax, fraction=.06, pad=.02)
865
 
866
+ fig.suptitle('Gabor s-plane responses (top 4 cross-term channels)',
867
+ color='#cdd6f4', fontsize=9, y=1.02)
868
+ plt.tight_layout(pad=0.5)
869
+ return _pil(fig)
870
+
871
+
872
+ # ── TinyAgent with Re/Im solver ───────────────────────────────────────────────
873
+
874
+ CONF_THRESHOLD = 0.72 # high bar — only act analytically when very sure
875
+
876
+ class TinyAgent:
877
  def __init__(self):
878
+ self.device=torch.device('cuda' if torch.cuda.is_available() else 'cpu')
879
+ self.model=self._make_model().to(self.device)
880
+ self.opt=torch.optim.Adam(self.model.parameters(),lr=1e-4)
881
+ self.buf=[]; self.prev_feat=None; self.prev_action=None
882
+ self.step_count=0; self.action_counts={}; self.prev_levels=0
883
+ self.reward_history=deque(maxlen=300)
884
+ self.level_history=[]; self.prev_state=None
885
+ self.level_up_reward=10.0; self.win_reward=50.0
886
+ self.near_win_reward=2.0; self.change_reward=0.1
887
+ self.dead_penalty=-0.01; self.candidate_win_reward=30.0
888
+ self.prev_candidate_dist=1.0
889
+ self.explore_steps=20
890
+ self.click_attempts=0; self.click_successes=0
891
+ self._last_was_click=False
892
+
893
  def _make_model(self):
894
  return nn.Sequential(
895
+ nn.Conv2d(14+N_GABOR_CHANNELS,32,3,padding=1),nn.ReLU(),
896
+ nn.Conv2d(32,64,3,padding=1),nn.ReLU(),
897
+ nn.Conv2d(64,128,3,padding=1),nn.ReLU(),
898
+ nn.AdaptiveAvgPool2d(8),nn.Flatten(),
899
+ nn.Linear(128*8*8,256),nn.ReLU(),
900
+ nn.Linear(256,6),
901
  )
902
+
903
  def reset(self):
904
+ self.model=self._make_model().to(self.device)
905
+ self.opt=torch.optim.Adam(self.model.parameters(),lr=1e-4)
906
+ self.buf=[]; self.prev_feat=None; self.prev_action=None
907
+ self.step_count=0; self.action_counts={}; self.prev_levels=0
908
+ self.reward_history=deque(maxlen=300); self.level_history=[]
909
+ self.prev_state=None; self.prev_candidate_dist=1.0
910
+ self.explore_steps=20; self.click_attempts=0; self.click_successes=0
911
+
912
+ def choose(self,grid,available_actions=None,levels=0,state=None):
913
+ feat=extract_features(grid).to(self.device)
914
+ cur_state=str(state) if state else None
915
+
916
+ # ── Re/Im: read the board using the 56-channel feature tensor ──────
917
+ # Pass feat directly so read_board reads Im channels from the same
918
+ # tensor the CNN uses — Im tensor=signal, raw grid=coordinates
919
+ # Concatenate axis + Gabor cross-term channels
920
+ gabor_feat = extract_gabor_features(grid, grid_size=64)
921
+ full_feat = torch.cat([feat, gabor_feat.to(self.device)], dim=0)
922
+ feat_np = full_feat.cpu().numpy()
923
+ cand_answer,cand_conf,cand_reasoning,cand_signal=read_board(grid, feat_np)
924
+
925
+ # Candidate proximity bonus
926
+ if cand_answer is not None and cand_conf>=0.35:
927
+ curr_dist=(grid!=cand_answer).mean() if grid.shape==cand_answer.shape else 1.0
928
+ if curr_dist==0.0:
929
+ cand_bonus=self.candidate_win_reward
930
+ elif curr_dist<self.prev_candidate_dist:
931
+ cand_bonus=(self.prev_candidate_dist-curr_dist)*5.0
932
+ else:
933
+ cand_bonus=0.0
934
+ self.prev_candidate_dist=curr_dist
935
+ else:
936
+ cand_bonus=0.0; cand_answer=None
937
+
938
+ # Store shaped experience
939
  if self.prev_feat is not None:
940
+ changed=not np.array_equal(
941
+ self.prev_feat.cpu().numpy(),full_feat.cpu().numpy())
942
+ if self._last_was_click:
943
+ self.click_attempts+=1
944
+ if changed: self.click_successes+=1
945
+ just_won=(cur_state=='WIN' and self.prev_state!='WIN')
946
+ level_up=levels>self.prev_levels
947
+ if just_won:
948
+ reward=self.win_reward+cand_bonus
949
+ for i in range(min(5,len(self.buf))):
950
+ idx=len(self.buf)-1-i
951
+ self.buf[idx]=(self.buf[idx][0],self.buf[idx][1],
952
+ self.buf[idx][2]+self.near_win_reward*(1-i*0.15))
953
+ elif level_up:
954
+ reward=self.level_up_reward+cand_bonus
955
+ self.level_history.append((self.step_count,levels))
956
+ elif changed:
957
+ reward=self.change_reward+cand_bonus
958
+ else:
959
+ reward=self.dead_penalty+cand_bonus
960
  self.reward_history.append(reward)
961
+ self.buf.append((self.prev_feat,self.prev_action,reward)) # prev_feat is full_feat
962
+ if len(self.buf)>500: self.buf.pop(0)
963
+ self.prev_state=cur_state
964
+ self.prev_levels=levels
965
+
966
+ if self.step_count%10==0 and len(self.buf)>=16:
967
  self._train()
968
+
969
+ # ── Im Re bridge: read board → derive answer → click exact cell ──
970
+ # Only fire after explore_steps of CNN exploration so we have context
971
+ analytic_action=None; analytic_meta={}
972
+ click_rate=(self.click_successes/self.click_attempts
973
+ if self.click_attempts>10 else 1.0)
974
+ if (cand_answer is not None
975
+ and cand_conf>=CONF_THRESHOLD
976
+ and self.step_count>self.explore_steps
977
+ and click_rate>=0.20):
978
+ diffs=pixel_diff(grid,cand_answer)
979
+ if diffs:
980
+ cell=most_urgent_diff(grid,cand_answer)
981
+ if cell is not None:
982
+ r,c,tgt_color=cell
983
+ H,W=grid.shape
984
+ gy=min(63,max(0,int(r*64/H+32/H)))
985
+ gx=min(63,max(0,int(c*64/W+32/W)))
986
+ analytic_action=6
987
+ reasoning_str=' | '.join(cand_reasoning[:2]) if cand_reasoning else 'read_board'
988
+ analytic_meta={'x':gx,'y':gy,'cell':(r,c,tgt_color),
989
+ 'hypothesis':reasoning_str,'conf':cand_conf,
990
+ 'n_diffs':len(diffs),
991
+ 'candidates':[(reasoning_str,cand_answer,cand_conf)]}
992
+
993
+ # ── CNN fallback ──────────────────────────────────────────────────
994
+ with torch.no_grad():
995
+ logits=self.model(full_feat.unsqueeze(0)).squeeze(0)
996
+ avail=list(range(1,7))
997
+ if available_actions:
998
+ avail=[int(a.value if hasattr(a,'value') else a)
999
+ for a in available_actions if
1000
+ int(a.value if hasattr(a,'value') else a)<=6]
1001
+ indices=[m-1 for m in avail if 1<=m<=6]
1002
+ masked=torch.full((6,),float('-inf'))
1003
+ for i in indices: masked[i]=logits[i]
1004
+ probs=torch.softmax(masked,dim=0).cpu().numpy()
1005
+ probs=np.nan_to_num(probs,nan=0)
1006
+ if probs.sum()==0: probs[np.array(indices)]=1/len(indices)
1007
+ probs=probs/probs.sum()
1008
+ cnn_action_idx=np.random.choice(6,p=probs)
1009
+
1010
+ # Pick final action
1011
+ if analytic_action is not None:
1012
+ chosen_id=analytic_action
1013
+ meta=analytic_meta
1014
+ meta['source']='analytic'
1015
  else:
1016
+ chosen_id=cnn_action_idx+1
1017
+ # Package read_board result for display even in CNN fallback
1018
+ cnn_cands=[(cand_signal, cand_answer, cand_conf)] if cand_answer is not None else []
1019
+ meta={'source':'cnn','probs':probs.tolist(),'candidates':cnn_cands,
1020
+ 'hypothesis':cand_reasoning[0][:60] if cand_reasoning else 'none',
1021
+ 'conf':cand_conf,'n_diffs':len(pixel_diff(grid,cand_answer)) if cand_answer is not None else 0}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1022
 
1023
+ self.prev_feat=full_feat; self.prev_action=cnn_action_idx
1024
+ self._last_was_click=(chosen_id==6)
1025
+ self.step_count+=1
1026
+ a_id=chosen_id
1027
+ self.action_counts[a_id]=self.action_counts.get(a_id,0)+1
1028
 
1029
+ try:
1030
+ from arcengine import GameAction
1031
+ action=GameAction(a_id)
1032
+ except Exception:
1033
+ action=a_id
 
 
 
1034
 
1035
+ if a_id==6 and 'x' in meta:
1036
+ try: action.set_data({'x':meta['x'],'y':meta['y']})
1037
+ except: pass
1038
+
1039
+ return action,meta
1040
+
1041
+ def _train(self):
1042
+ import random
1043
+ batch=random.sample(self.buf,min(16,len(self.buf)))
1044
+ states =torch.stack([b[0] for b in batch]).to(self.device)
1045
+ actions=torch.tensor([b[1] for b in batch],dtype=torch.long, device=self.device)
1046
+ rewards=torch.tensor([b[2] for b in batch],dtype=torch.float32,device=self.device)
1047
+ self.opt.zero_grad()
1048
+ logits=self.model(states)
1049
+ loss=TF.binary_cross_entropy_with_logits(
1050
+ logits.gather(1,actions.unsqueeze(1)).squeeze(1),
1051
+ torch.clamp(rewards,0,1))
1052
+ loss.backward(); self.opt.step()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1053
 
1054
  # ── Session ───────────────────────────────────────────────────────────────────
1055
 
1056
+ _agent = TinyAgent()
1057
+ _stop_flag = threading.Event()
1058
  _run_thread = None
1059
+ _frame_queue= queue.Queue(maxsize=60)
1060
 
1061
+ def _run_agent(game_id,api_key,max_steps):
1062
+ import arc_agi
1063
  try:
1064
+ arc=arc_agi.Arcade(arc_api_key=api_key)
1065
+ env=arc.make(game_id,include_frame_data=True)
1066
+ frame=env.reset(); _agent.reset()
1067
+ prev_grid=None; step=0
1068
+ while not _stop_flag.is_set() and step<max_steps:
1069
+ if frame is None: break
1070
+ raw=np.array(frame.frame,dtype=np.int64)
1071
+ grid=raw[-1] if raw.ndim==3 else raw
1072
+ avail=getattr(frame,'available_actions',None)
1073
+ levels=getattr(frame,'levels_completed',0)
1074
+ state=getattr(frame,'state',None)
1075
+ action,meta=_agent.choose(grid,avail,levels=levels,state=state)
1076
+ diff=(grid!=prev_grid) if prev_grid is not None else None
1077
+ prev_grid=grid.copy()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1078
  _frame_queue.put({
1079
+ 'grid':grid,'diff':diff,'step':step,
1080
+ 'action':int(action.value if hasattr(action,'value') else action),
1081
+ 'levels':levels,'state':str(state),
1082
+ 'meta':meta,
1083
+ 'counts':dict(_agent.action_counts),'click_rate':round(_agent.click_successes/_agent.click_attempts,2) if _agent.click_attempts>0 else None,
1084
+ 'reward_history':list(_agent.reward_history),
1085
+ 'grid_raw':grid.tolist(),
1086
+ 'level_history':list(_agent.level_history),
1087
+ },block=True,timeout=5)
1088
+ state_str=str(state)
1089
+ if 'WIN' in state_str or 'GAME_OVER' in state_str: break
 
 
 
 
 
1090
  try:
1091
  from arcengine import GameAction as GA
1092
+ a_int=int(action.value if hasattr(action,'value') else action)
1093
+ sa=GA(a_int)
1094
  if meta.get('x') is not None:
1095
+ try: sa.set_data({'x':int(meta['x']),'y':int(meta['y'])})
1096
+ except: pass
1097
+ frame=env.step(sa)
1098
+ except Exception as step_err:
1099
+ # Last resort: try passing action directly
1100
+ try: frame=env.step(action)
1101
+ except: frame=None
1102
+ step+=1
 
 
 
 
 
1103
  time.sleep(0.08)
1104
+ _frame_queue.put({'done':True,'step':step,
1105
+ 'level_history':list(_agent.level_history)})
1106
  except Exception as e:
1107
+ _frame_queue.put({'error':str(e)})
1108
+
1109
+ # ── Pull frame ────────────────────────────────────────────────────────────────
1110
 
1111
+ _latest={'grid_img':None,'hyp_img':None,'cand_img':None,
1112
+ 'bar_img':None,'reward_img':None,'status':'*Waiting...*'}
 
 
 
 
1113
 
1114
  def pull_frame():
1115
  global _latest
1116
+ data=None
1117
  while True:
1118
+ try: data=_frame_queue.get_nowait()
1119
+ except queue.Empty: break
1120
+
 
 
1121
  if data is None:
1122
+ return (_latest['grid_img'],_latest['hyp_img'],_latest['cand_img'],
1123
+ _latest['bar_img'],_latest['gabor_img'],_latest['reward_img'],_latest['status'])
1124
+
1125
  if 'error' in data:
1126
+ _latest['status']=f"**Error:** {data['error']}"
1127
+ return (_latest['grid_img'],_latest['hyp_img'],_latest['cand_img'],
1128
+ _latest['bar_img'],_latest['gabor_img'],_latest['reward_img'],_latest['status'])
1129
+
1130
  if data.get('done'):
1131
+ lh=data.get('level_history',[])
1132
+ _latest['status']=f"**Done** {data['step']} steps | {len(lh)} levels completed"
1133
+ return (_latest['grid_img'],_latest['hyp_img'],_latest['cand_img'],
1134
+ _latest['bar_img'],_latest['gabor_img'],_latest['reward_img'],_latest['status'])
1135
+
1136
+ grid=data['grid']; meta=data['meta']; step=data['step']
1137
+ levels=data['levels']; state=data['state']; action=data['action']
1138
+ source=meta.get('source','cnn')
1139
+ hyp_str=meta.get('hypothesis','none')
1140
+ cand_conf=meta.get('conf',0.0)
1141
+ n_diffs=meta.get('n_diffs',0)
1142
+
1143
+ # Build display candidates list: [(label, grid, conf)]
1144
+ raw_cands=meta.get('candidates',[])
1145
+ # raw_cands entries are (reasoning_str, grid, conf)
1146
+ # Normalise to (short_label, grid, conf)
1147
+ disp_cands=[]
1148
+ for entry in raw_cands:
1149
+ if len(entry)==3:
1150
+ label=str(entry[0])[:40]; cgrid=entry[1]; cconf=entry[2]
1151
+ if isinstance(cgrid, np.ndarray): disp_cands.append((label,cgrid,cconf))
1152
+
1153
+ # Determine what to highlight
1154
+ mark_cell=None; highlight=None
1155
+ if source=='analytic' and 'cell' in meta and disp_cands:
1156
+ _,cand_grid,_=disp_cands[0]
1157
+ if cand_grid.shape==grid.shape:
1158
+ all_diffs=pixel_diff(grid,cand_grid)
1159
+ highlight=all_diffs[:20]
1160
+ mark_cell=meta['cell']
1161
+
1162
+ source_emoji='🧠' if source=='analytic' else '🎲'
1163
+ _latest['grid_img']=render_grid(
1164
  grid,
1165
+ title=f"Step {step} | {source_emoji} A{action} | Levels {levels}",
1166
  highlight=highlight,
1167
+ mark_cell=mark_cell)
1168
+
1169
+ # Im side: hypothesis panel — show read_board reasoning as bar
1170
+ if disp_cands:
1171
+ # Convert to format render_hypothesis_panel expects: [(name,grid,conf)]
1172
+ _latest['hyp_img']=render_hypothesis_panel(disp_cands)
1173
+ else:
1174
+ _latest['hyp_img']=None
1175
+
1176
+ # Re side: candidate grid
1177
+ if disp_cands and disp_cands[0][1].shape==grid.shape:
1178
+ cname,cgrid,cconf2=disp_cands[0]
1179
+ diffs=pixel_diff(grid,cgrid)
1180
+ _latest['cand_img']=render_grid(
1181
+ cgrid,
1182
+ title=f"Im answer: {cname[:35]} (conf={cconf2:.2f}) — {len(diffs)} cells differ",
1183
+ highlight=diffs[:20])
1184
+ else:
1185
+ _latest['cand_img']=None
1186
+
1187
+ _latest['bar_img'] =render_action_bar(data['counts'],sum(data['counts'].values()))
1188
+ _latest['reward_img']=render_reward_chart(data['reward_history'])
1189
+ grid_raw=np.array(data.get('grid_raw',grid.tolist()),dtype=np.int64)
1190
+ _latest['gabor_img']=render_gabor_panel(grid_raw)
1191
+
1192
+ last_r=data['reward_history'][-1] if data['reward_history'] else 0
1193
+ r_emoji='🟡' if last_r>=5 else ('🟢' if last_r>0 else '🔴')
1194
+ hyp_str=(f"`{meta.get('hypothesis','?')}` conf={meta.get('conf',0):.2f} "
1195
+ f"→ click ({meta.get('x','?')},{meta.get('y','?')}) "
1196
+ f"[{meta.get('n_diffs','?')} cells wrong]"
1197
+ if source=='analytic'
1198
+ else f"CNN probs: {[round(p,2) for p in meta.get('probs',[])]}")
1199
+
1200
+ _latest['status']=(
1201
+ f"{source_emoji} **{'Analytic (Re/Im)' if source=='analytic' else 'CNN fallback'}**"
1202
+ f" &nbsp;|&nbsp; Step {step} &nbsp;|&nbsp; Levels {levels}"
1203
+ f" &nbsp;|&nbsp; Reward {r_emoji} `{last_r:.2f}` &nbsp;|&nbsp; {state}\n\n"
1204
+ f"{hyp_str}")
1205
+
1206
+ return (_latest['grid_img'],_latest['hyp_img'],_latest['cand_img'],
1207
+ _latest['bar_img'],_latest['reward_img'],_latest['status'])
1208
+
1209
+ # ── Handlers ──────────────────────────────────────────────────────────────────
1210
 
1211
  def fetch_games(api_key):
 
1212
  try:
1213
  import arc_agi
1214
+ arc=arc_agi.Arcade(arc_api_key=api_key)
1215
+ envs=arc.get_environments()
1216
+ ids=[e.game_id for e in envs]
1217
+ return gr.Dropdown(choices=ids,value=ids[0] if ids else None),\
1218
+ f"Found **{len(ids)}** games."
1219
  except Exception as e:
1220
+ return gr.Dropdown(choices=[]),f"**Error:** {e}"
1221
+
1222
+ def start_agent(game_id,api_key,max_steps):
1223
+ global _run_thread,_stop_flag
1224
+ if not game_id: return "Select a game first."
1225
+ if not api_key: return "Enter your API key."
 
 
1226
  _stop_flag.set()
1227
+ if _run_thread and _run_thread.is_alive(): _run_thread.join(timeout=3)
 
1228
  while not _frame_queue.empty():
1229
+ try: _frame_queue.get_nowait()
1230
+ except: break
 
 
1231
  _stop_flag.clear()
1232
+ _run_thread=threading.Thread(
1233
+ target=_run_agent,args=(game_id,api_key,int(max_steps)),daemon=True)
1234
  _run_thread.start()
1235
+ return f"Agent started on **{game_id}** — 🧠 Re/Im analytic + 🎲 CNN fallback"
1236
 
1237
  def stop_agent():
1238
  _stop_flag.set()
 
1240
 
1241
  # ── UI ────────────────────────────────────────────────────────────────────────
1242
 
1243
+ with gr.Blocks(title="ARC-AGI-3 Re/Im Agent") as demo:
 
 
1244
 
1245
+ gr.Markdown("""
1246
+ # ARC-AGI-3 Re/Im Agent Spectator
1247
+ **Im side** = bird's eye hypothesis (which transformation?) &nbsp;|&nbsp;
1248
+ **Re side** = exact location (which cells to click?)
1249
 
1250
+ 🧠 = analytic solver (Im picks hypothesis → Re pins cell → ACTION6 click)
1251
+ 🎲 = CNN fallback (when no hypothesis clears the confidence threshold)
1252
  """)
1253
+
1254
  with gr.Row():
1255
  with gr.Column(scale=3):
1256
+ api_box=gr.Textbox(label="ARC API key",type="password",
1257
+ value=os.environ.get("ARC_API_KEY",""),
1258
+ placeholder="arc-key-... or set ARC_API_KEY secret")
 
 
 
1259
  with gr.Column(scale=1):
1260
+ fetch_btn=gr.Button("Fetch games")
1261
+
 
 
1262
  with gr.Row():
1263
  with gr.Column(scale=2):
1264
+ game_dd=gr.Dropdown(label="Game",choices=[])
1265
  with gr.Column(scale=1):
1266
+ steps_sl=gr.Slider(label="Max steps",minimum=20,maximum=500,value=150,step=10)
1267
  with gr.Column(scale=1):
1268
  with gr.Row():
1269
+ start_btn=gr.Button("▶ Watch",variant="primary")
1270
+ stop_btn =gr.Button("■ Stop")
1271
+
1272
+ run_status=gr.Markdown("*Fetch games → select → Watch*")
1273
+ api_status=gr.Markdown()
1274
+
1275
  gr.Markdown("---")
1276
+
1277
+ with gr.Row():
1278
+ grid_img=gr.Image(label="Current frame (🔴=wrong cells ⭐=target click)",
1279
+ type="pil",interactive=False,height=280)
1280
+ hyp_img =gr.Image(label="Im side — hypothesis ranking",
1281
+ type="pil",interactive=False,height=280)
1282
+
1283
  with gr.Row():
1284
+ cand_img =gr.Image(label="Im candidate what the answer should look like",
1285
+ type="pil",interactive=False,height=220)
1286
+ bar_img =gr.Image(label="Action frequency",
1287
+ type="pil",interactive=False,height=220)
1288
+
1289
+ with gr.Row():
1290
+ gabor_img =gr.Image(label="Gabor s-plane — cross terms (σ>0, ω>0)",
1291
+ type="pil",interactive=False,height=160)
1292
+ reward_img=gr.Image(label="Reward 🟡+50 WIN 🟡+10 level 🟢+0.1 change 🔴-0.01 dead",
1293
+ type="pil",interactive=False,height=160)
1294
+
1295
+ timer=gr.Timer(value=1.0)
1296
+ timer.tick(pull_frame,
1297
+ outputs=[grid_img,hyp_img,cand_img,bar_img,gabor_img,reward_img,run_status])
1298
+
1299
+ fetch_btn.click(fetch_games,inputs=api_box,outputs=[game_dd,api_status])
1300
+ start_btn.click(start_agent,inputs=[game_dd,api_box,steps_sl],outputs=run_status)
1301
+ stop_btn.click(stop_agent,outputs=run_status)
1302
+
1303
  gr.Markdown("""
1304
  ---
1305
  **Re/Im duality in action:**
1306
+ The Im side reads the whole board at once — symmetry maps, boundary contour, directional
1307
+ flow and ranks candidate transformations by confidence.
1308
+ The Re side then diffs the current frame against the winning candidate and finds the exact
1309
+ cell (boundary-first, following Cauchy's principle) that most needs fixing.
1310
+ The agent emits ACTION6 at those precise coordinates instead of guessing randomly.
1311
+ CNN fires only when no analytic hypothesis clears 0.40 confidence.
1312
  """)
1313
 
1314
  if __name__ == "__main__":