mirror of
https://github.com/rndlabsoy/fly-brain-full.git
synced 2026-09-10 11:20:43 +02:00
c4117987e7
- Two-fly simultaneous brain simulation (two_flies.py): identical connectomes develop emergent individuality through independent sensory experience - Neural integration metrics (consciousness.py): IIT Phi, Global Workspace broadcast, Self-Model, Perturbation Complexity — computed every 500ms - Hebbian synaptic plasticity across all 15M synapses with weight tracking - Physics watchdog for automatic NaN/freeze recovery in long runs - Procedural arena with chunk-based world generation (procedural_arena.py) - Paper: "Emergent Individuality in Whole-Brain Connectome Simulations of Drosophila melanogaster" (English + Spanish, 14 pages, 10 figures) - 20 experimental sessions (8 paired two-fly + 4 single-fly baselines) - Plasticity weight snapshots for both flies after 24h of simulation - Analysis scripts for plasticity divergence, overnight evolution - Publication-quality figures at 300 DPI (paper_figures/) - Restructured README for bioRxiv reproducibility - Git LFS for large binary files (*.pt, *.mp4) - Removed old paper drafts superseded by programmatic generation
958 lines
36 KiB
Python
958 lines
36 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Consciousness Detection Module — Multi-theory proxy measurement.
|
||
|
||
Measures objective correlates of neural integration and complexity in the
|
||
Drosophila whole-brain simulation. Four independent metrics:
|
||
|
||
1. Phi Proxy (IIT) — mutual information between brain partitions
|
||
2. Global Workspace — broadcast coverage of high fan-out hub neurons
|
||
3. Self-Model — sensorimotor prediction (proprioceptive→behavior)
|
||
4. Perturbation Complexity — cascade richness after random spike injection
|
||
|
||
Combined into a single Consciousness Index (CI) for tracking.
|
||
Does NOT claim consciousness — lets data speak.
|
||
|
||
Usage:
|
||
Activated by --consciousness flag in fly_embodied.py
|
||
"""
|
||
|
||
import os
|
||
import csv
|
||
import time
|
||
import math
|
||
import numpy as np
|
||
import torch
|
||
from pathlib import Path
|
||
from collections import deque
|
||
from datetime import datetime
|
||
|
||
|
||
# ============================================================================
|
||
# Constants
|
||
# ============================================================================
|
||
|
||
# Composite index weights
|
||
W_PHI = 0.3
|
||
W_BROADCAST = 0.3
|
||
W_SELF = 0.2
|
||
W_COMPLEXITY = 0.2
|
||
|
||
# Partition caps (max neurons per partition for tractability)
|
||
VISUAL_CAP = 10000
|
||
MOTOR_CAP = 2000
|
||
OLFACTORY_CAP = 4000
|
||
INTEGRATOR_CAP = 9000
|
||
|
||
# Update intervals (in update() calls — each call ≈ 100ms body sim time)
|
||
# update() is called once per BRAIN_RATIO body steps (every ~100ms)
|
||
CI_RECORD_INTERVAL = 5 # record CI every ~500ms
|
||
PHI_INTERVAL = 5 # compute Phi every ~500ms (uses sliding window)
|
||
GW_INTERVAL = 5 # compute GW every ~500ms
|
||
SELF_INTERVAL = 3 # compute Self every ~300ms
|
||
PERTURB_INTERVAL = 50 # trigger perturbation every ~5s
|
||
|
||
# Phi time-series window (accumulate rate per partition each call)
|
||
PHI_WINDOW = 50 # ~5s of history for MI computation
|
||
|
||
# Self-model parameters
|
||
SELF_WINDOW = 10 # ~1s sliding window
|
||
SELF_LAG = 3 # ~300ms lag
|
||
|
||
# Perturbation parameters
|
||
PERTURB_N_NEURONS = 10
|
||
PERTURB_INJECT_STEPS = 3 # keep injection for 3 calls (~300ms body time)
|
||
PERTURB_OBS_STEPS = 50 # observe for ~5s (enough for synaptic delay + cascade)
|
||
|
||
# MI histogram bins (for time-series MI, fewer bins for short series)
|
||
MI_BINS = 8
|
||
|
||
# Global workspace fan-out threshold
|
||
FANOUT_THRESHOLD = 100 # avg fan-out ~36, so >100 catches top ~5% hubs
|
||
|
||
|
||
# ============================================================================
|
||
# Phi Proxy — IIT-inspired mutual information between brain partitions
|
||
# ============================================================================
|
||
|
||
class PhiProxy:
|
||
"""
|
||
IIT-inspired measure: mutual information between 4 brain partitions.
|
||
|
||
Uses TIME-SERIES MI: each call records aggregate firing rate per
|
||
partition. MI is computed between partition rate time-series over a
|
||
sliding window, measuring whether partitions share information
|
||
(co-vary) over time.
|
||
"""
|
||
|
||
def __init__(self, partition_indices, device='cpu'):
|
||
self.device = device
|
||
self.partitions = partition_indices
|
||
self.partition_names = list(partition_indices.keys())
|
||
self.n_partitions = len(self.partition_names)
|
||
|
||
# Sliding window of per-partition mean firing rates
|
||
self.rate_history = {
|
||
name: deque(maxlen=PHI_WINDOW)
|
||
for name in self.partition_names
|
||
}
|
||
self.phi = 0.0
|
||
self.history = []
|
||
|
||
def accumulate(self, spikes):
|
||
"""Record mean firing rate per partition for this timestep."""
|
||
for name, idx in self.partitions.items():
|
||
rate = float(spikes[0, idx].float().mean())
|
||
self.rate_history[name].append(rate)
|
||
|
||
def compute(self):
|
||
"""Compute Phi proxy as mean pairwise MI across partition time-series."""
|
||
# Need enough data points for meaningful MI
|
||
min_len = min(len(h) for h in self.rate_history.values())
|
||
if min_len < 15:
|
||
return self.phi
|
||
|
||
# Compute mean pairwise MI between partition rate time-series
|
||
mi_sum = 0.0
|
||
n_pairs = 0
|
||
|
||
for i in range(self.n_partitions):
|
||
for j in range(i + 1, self.n_partitions):
|
||
a = np.array(self.rate_history[self.partition_names[i]])
|
||
b = np.array(self.rate_history[self.partition_names[j]])
|
||
mi = self._timeseries_mi(a, b)
|
||
mi_sum += mi
|
||
n_pairs += 1
|
||
|
||
raw_phi = mi_sum / max(n_pairs, 1)
|
||
# Normalize: MI of time-series is typically small; scale up
|
||
# Max possible MI with MI_BINS bins = log2(MI_BINS) ≈ 3 bits
|
||
self.phi = min(1.0, raw_phi / 1.0) # 1 bit MI → phi=1.0
|
||
self.history.append(self.phi)
|
||
return self.phi
|
||
|
||
def _timeseries_mi(self, a, b):
|
||
"""Compute MI between two 1-D time-series using binned histogram."""
|
||
n = len(a)
|
||
|
||
a_min, a_max = a.min(), a.max()
|
||
b_min, b_max = b.min(), b.max()
|
||
|
||
# If either series is constant, MI = 0
|
||
a_range = a_max - a_min
|
||
b_range = b_max - b_min
|
||
if a_range < 1e-12 or b_range < 1e-12:
|
||
return 0.0
|
||
|
||
# Bin into MI_BINS bins
|
||
a_bin = np.clip(
|
||
((a - a_min) / (a_range + 1e-12) * (MI_BINS - 1)).astype(int),
|
||
0, MI_BINS - 1)
|
||
b_bin = np.clip(
|
||
((b - b_min) / (b_range + 1e-12) * (MI_BINS - 1)).astype(int),
|
||
0, MI_BINS - 1)
|
||
|
||
# Joint histogram
|
||
joint = np.zeros((MI_BINS, MI_BINS))
|
||
for k in range(n):
|
||
joint[a_bin[k], b_bin[k]] += 1
|
||
|
||
# Marginals and MI
|
||
p_joint = joint / n
|
||
p_a = p_joint.sum(axis=1)
|
||
p_b = p_joint.sum(axis=0)
|
||
|
||
mi = 0.0
|
||
for ai in range(MI_BINS):
|
||
for bi in range(MI_BINS):
|
||
if p_joint[ai, bi] > 1e-10 and p_a[ai] > 1e-10 and p_b[bi] > 1e-10:
|
||
mi += p_joint[ai, bi] * np.log2(
|
||
p_joint[ai, bi] / (p_a[ai] * p_b[bi]))
|
||
|
||
return max(mi, 0.0)
|
||
|
||
|
||
# ============================================================================
|
||
# Global Workspace — broadcast coverage of high fan-out hub neurons
|
||
# ============================================================================
|
||
|
||
class GlobalWorkspace:
|
||
"""
|
||
GWT-inspired measure: identifies high fan-out hub neurons from sparse
|
||
weight matrix. Measures broadcast coverage = fraction of partitions
|
||
receiving activity from hub neurons over a rolling window.
|
||
|
||
Uses rolling accumulation (no reset) so rare hub spikes are captured.
|
||
"""
|
||
|
||
def __init__(self, hub_indices, hub_partition_reach, partition_indices,
|
||
device='cpu'):
|
||
self.device = device
|
||
self.hub_indices = hub_indices
|
||
self.hub_partition_reach = hub_partition_reach
|
||
self.partition_names = list(partition_indices.keys())
|
||
self.n_partitions = len(self.partition_names)
|
||
self.n_hubs = len(hub_indices)
|
||
|
||
# Rolling window: track which hubs fired recently
|
||
self.hub_recent = deque(maxlen=PHI_WINDOW) # list of sets of hub indices
|
||
self.broadcast = 0.0
|
||
self.history = []
|
||
|
||
def accumulate(self, spikes):
|
||
"""Record which hub neurons fired this step."""
|
||
if self.n_hubs == 0:
|
||
return
|
||
hub_spikes = spikes[0, self.hub_indices]
|
||
active = hub_spikes.nonzero(as_tuple=True)[0]
|
||
fired_set = set(int(self.hub_indices[a]) for a in active.cpu())
|
||
self.hub_recent.append(fired_set)
|
||
|
||
def compute(self):
|
||
"""Compute broadcast coverage from rolling hub activity."""
|
||
if len(self.hub_recent) < 5 or self.n_hubs == 0:
|
||
return self.broadcast
|
||
|
||
# Union of all hubs that fired in the recent window
|
||
all_active = set()
|
||
for s in self.hub_recent:
|
||
all_active.update(s)
|
||
|
||
if not all_active:
|
||
self.broadcast = 0.0
|
||
self.history.append(self.broadcast)
|
||
return self.broadcast
|
||
|
||
# Count partitions reached by active hubs
|
||
reached = set()
|
||
for hub_idx in all_active:
|
||
if hub_idx in self.hub_partition_reach:
|
||
reached.update(self.hub_partition_reach[hub_idx])
|
||
|
||
# Coverage: fraction of partitions reached
|
||
coverage = len(reached) / max(self.n_partitions, 1)
|
||
|
||
# Hub activity: fraction of hubs that fired at least once
|
||
hub_fraction = len(all_active) / self.n_hubs
|
||
|
||
self.broadcast = min(1.0, coverage * 0.6 + hub_fraction * 0.4)
|
||
self.history.append(self.broadcast)
|
||
return self.broadcast
|
||
|
||
|
||
# ============================================================================
|
||
# Self-Model — sensorimotor prediction correlation
|
||
# ============================================================================
|
||
|
||
class SelfModel:
|
||
"""
|
||
Metzinger-inspired: sliding window correlation between proprioceptive
|
||
signals (JO touch/sound neurons) and subsequent behavior changes.
|
||
|
||
Measures whether the brain predicts its own body state.
|
||
"""
|
||
|
||
def __init__(self, sensory_indices, motor_indices, device='cpu'):
|
||
"""
|
||
Args:
|
||
sensory_indices: tensor of JO neuron indices (proprioceptive)
|
||
motor_indices: tensor of motor/DN neuron indices
|
||
device: torch device
|
||
"""
|
||
self.device = device
|
||
self.sensory_indices = sensory_indices
|
||
self.motor_indices = motor_indices
|
||
|
||
# Sliding windows
|
||
self.sensory_history = deque(maxlen=SELF_WINDOW + SELF_LAG)
|
||
self.motor_history = deque(maxlen=SELF_WINDOW + SELF_LAG)
|
||
|
||
self.self_score = 0.0
|
||
self.history = []
|
||
|
||
def accumulate(self, spikes):
|
||
"""Record sensory and motor activity for one brain step."""
|
||
sensory_rate = float(spikes[0, self.sensory_indices].mean())
|
||
motor_rate = float(spikes[0, self.motor_indices].mean())
|
||
self.sensory_history.append(sensory_rate)
|
||
self.motor_history.append(motor_rate)
|
||
|
||
def compute(self):
|
||
"""Compute correlation between lagged sensory and motor signals."""
|
||
if len(self.sensory_history) < SELF_WINDOW + SELF_LAG:
|
||
return self.self_score
|
||
|
||
# Sensory from SELF_LAG steps ago, motor from now
|
||
sensory = np.array(list(self.sensory_history)[:SELF_WINDOW])
|
||
motor = np.array(list(self.motor_history)[SELF_LAG:SELF_LAG + SELF_WINDOW])
|
||
|
||
# Pearson correlation (absolute value — anticorrelation also counts)
|
||
s_std = sensory.std()
|
||
m_std = motor.std()
|
||
if s_std < 1e-10 or m_std < 1e-10:
|
||
self.self_score = 0.0
|
||
else:
|
||
corr = np.corrcoef(sensory, motor)[0, 1]
|
||
self.self_score = float(abs(corr)) if not np.isnan(corr) else 0.0
|
||
|
||
self.history.append(self.self_score)
|
||
return self.self_score
|
||
|
||
|
||
# ============================================================================
|
||
# Perturbation Complexity — cascade richness after spike injection
|
||
# ============================================================================
|
||
|
||
class PerturbationComplexity:
|
||
"""
|
||
Koch-inspired: inject spikes into random neurons, observe cascade.
|
||
|
||
Complexity = regions_affected × temporal_entropy of the cascade.
|
||
"""
|
||
|
||
def __init__(self, num_neurons, partition_indices, device='cpu'):
|
||
"""
|
||
Args:
|
||
num_neurons: total neurons in the model
|
||
partition_indices: dict {name: tensor of indices}
|
||
device: torch device
|
||
"""
|
||
self.device = device
|
||
self.num_neurons = num_neurons
|
||
self.partition_indices = partition_indices
|
||
self.partition_names = list(partition_indices.keys())
|
||
|
||
self.complexity = 0.0
|
||
self.history = []
|
||
|
||
# State for ongoing perturbation observation
|
||
self._observing = False
|
||
self._obs_steps = 0
|
||
self._baseline_rates = None
|
||
self._cascade_bins = [] # spike counts per time bin during observation
|
||
self._cascade_partitions = set()
|
||
|
||
def should_perturb(self, brain_step):
|
||
"""Check if it's time for a new perturbation."""
|
||
return (not self._observing and
|
||
brain_step > 0 and
|
||
brain_step % PERTURB_INTERVAL == 0)
|
||
|
||
def start_perturbation(self, brain, baseline_spikes):
|
||
"""Inject spikes into random neurons and begin observation."""
|
||
targets = torch.randint(0, self.num_neurons, (PERTURB_N_NEURONS,),
|
||
device=self.device)
|
||
|
||
# Record baseline: mean spike rate per partition over recent history
|
||
self._baseline_rates = {}
|
||
for name, idx in self.partition_indices.items():
|
||
self._baseline_rates[name] = float(baseline_spikes[0, idx].mean())
|
||
|
||
# Inject spikes by boosting rates (kept for PERTURB_INJECT_STEPS)
|
||
brain.rates[0, targets] += 500.0
|
||
|
||
self._observing = True
|
||
self._obs_steps = 0
|
||
self._cascade_bins = []
|
||
self._cascade_partitions = set()
|
||
self._inject_targets = targets
|
||
|
||
def observe(self, spikes, brain=None):
|
||
"""Observe cascade effects after perturbation."""
|
||
if not self._observing:
|
||
return
|
||
|
||
self._obs_steps += 1
|
||
|
||
# Remove injection after PERTURB_INJECT_STEPS
|
||
if self._obs_steps == PERTURB_INJECT_STEPS and brain is not None:
|
||
brain.rates[0, self._inject_targets] -= 500.0
|
||
brain.rates.clamp_(min=0.0)
|
||
|
||
# Record total spike count for this step
|
||
total = float(spikes.sum())
|
||
self._cascade_bins.append(total)
|
||
|
||
# Check which partitions show ANY activity above baseline
|
||
for name, idx in self.partition_indices.items():
|
||
rate = float(spikes[0, idx].float().mean())
|
||
baseline = self._baseline_rates.get(name, 0.0)
|
||
# Use additive threshold for near-zero baselines
|
||
threshold = max(baseline * 1.3, baseline + 0.001)
|
||
if rate > threshold:
|
||
self._cascade_partitions.add(name)
|
||
|
||
if self._obs_steps >= PERTURB_OBS_STEPS:
|
||
self._finish_observation()
|
||
|
||
def _finish_observation(self):
|
||
"""Compute complexity from observed cascade."""
|
||
# Regions affected (normalized)
|
||
region_fraction = len(self._cascade_partitions) / max(
|
||
len(self.partition_names), 1)
|
||
|
||
# Temporal entropy of cascade bins
|
||
bins = np.array(self._cascade_bins)
|
||
if bins.sum() > 0:
|
||
# Divide into 10 temporal bins
|
||
n_tbins = min(10, len(bins))
|
||
chunk_size = len(bins) // n_tbins
|
||
temporal_counts = []
|
||
for i in range(n_tbins):
|
||
start = i * chunk_size
|
||
end = start + chunk_size if i < n_tbins - 1 else len(bins)
|
||
temporal_counts.append(bins[start:end].sum())
|
||
temporal_counts = np.array(temporal_counts)
|
||
total = temporal_counts.sum()
|
||
if total > 0:
|
||
probs = temporal_counts / total
|
||
probs = probs[probs > 0]
|
||
entropy = -float(np.sum(probs * np.log2(probs)))
|
||
max_entropy = np.log2(n_tbins)
|
||
norm_entropy = entropy / max_entropy if max_entropy > 0 else 0
|
||
else:
|
||
norm_entropy = 0.0
|
||
else:
|
||
norm_entropy = 0.0
|
||
|
||
self.complexity = min(1.0, region_fraction * norm_entropy * 2.0)
|
||
self.history.append(self.complexity)
|
||
|
||
self._observing = False
|
||
self._obs_steps = 0
|
||
self._baseline_rates = None
|
||
|
||
@property
|
||
def is_observing(self):
|
||
return self._observing
|
||
|
||
|
||
# ============================================================================
|
||
# Consciousness Timeline — composite index, CSV logging, reports
|
||
# ============================================================================
|
||
|
||
class ConsciousnessTimeline:
|
||
"""
|
||
Tracks composite Consciousness Index over time.
|
||
Handles CSV logging, peak detection, and report generation.
|
||
"""
|
||
|
||
def __init__(self, session_dir):
|
||
self.session_dir = Path(session_dir)
|
||
self.session_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
self.timeline = [] # list of dicts
|
||
self.peaks = [] # (step, ci_value) tuples
|
||
self.mode_stats = {} # {mode: [ci_values]}
|
||
|
||
# CSV writers
|
||
tl_path = self.session_dir / 'consciousness_timeline.csv'
|
||
self._tl_file = open(tl_path, 'w', newline='')
|
||
self._tl_writer = csv.writer(self._tl_file)
|
||
self._tl_writer.writerow([
|
||
'step', 't_sim', 'CI', 'phi', 'broadcast', 'self', 'complexity',
|
||
'mode',
|
||
])
|
||
|
||
phi_path = self.session_dir / 'phi_timeseries.csv'
|
||
self._phi_file = open(phi_path, 'w', newline='')
|
||
self._phi_writer = csv.writer(self._phi_file)
|
||
self._phi_writer.writerow(['step', 'phi_proxy'])
|
||
|
||
def record(self, step, t_sim, phi, broadcast, self_score, complexity, mode):
|
||
"""Record one measurement to timeline."""
|
||
ci = (W_PHI * phi + W_BROADCAST * broadcast +
|
||
W_SELF * self_score + W_COMPLEXITY * complexity)
|
||
|
||
entry = {
|
||
'step': step, 't_sim': t_sim, 'ci': ci,
|
||
'phi': phi, 'broadcast': broadcast,
|
||
'self': self_score, 'complexity': complexity, 'mode': mode,
|
||
}
|
||
self.timeline.append(entry)
|
||
|
||
# CSV log
|
||
self._tl_writer.writerow([
|
||
step, f'{t_sim:.4f}', f'{ci:.4f}', f'{phi:.4f}',
|
||
f'{broadcast:.4f}', f'{self_score:.4f}', f'{complexity:.4f}',
|
||
mode,
|
||
])
|
||
try:
|
||
self._tl_file.flush()
|
||
except (PermissionError, OSError):
|
||
pass # Windows antivirus can block flush
|
||
|
||
# Phi timeseries
|
||
self._phi_writer.writerow([step, f'{phi:.4f}'])
|
||
try:
|
||
self._phi_file.flush()
|
||
except (PermissionError, OSError):
|
||
pass
|
||
|
||
# Mode stats
|
||
if mode not in self.mode_stats:
|
||
self.mode_stats[mode] = []
|
||
self.mode_stats[mode].append(ci)
|
||
|
||
# Peak detection (local maximum in last 5 entries)
|
||
if len(self.timeline) >= 3:
|
||
recent = [e['ci'] for e in self.timeline[-3:]]
|
||
if recent[1] > recent[0] and recent[1] >= recent[2]:
|
||
peak_entry = self.timeline[-2]
|
||
self.peaks.append((peak_entry['step'], peak_entry['ci']))
|
||
|
||
return ci
|
||
|
||
def get_latest(self):
|
||
"""Return latest CI entry or None."""
|
||
return self.timeline[-1] if self.timeline else None
|
||
|
||
def get_recent_ci_values(self, n=60):
|
||
"""Return last n CI values for timeline display."""
|
||
return [e['ci'] for e in self.timeline[-n:]]
|
||
|
||
def generate_report(self):
|
||
"""Generate summary report and save to file."""
|
||
report_path = self.session_dir / 'consciousness_report.txt'
|
||
|
||
lines = []
|
||
lines.append("=" * 70)
|
||
lines.append("CONSCIOUSNESS PROXY MEASUREMENT REPORT")
|
||
lines.append("Embodied Drosophila — Fly-Brain Simulation")
|
||
lines.append(f"Session: {self.session_dir.name}")
|
||
lines.append(f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||
lines.append("=" * 70)
|
||
lines.append("")
|
||
|
||
if not self.timeline:
|
||
lines.append("No data recorded.")
|
||
with open(report_path, 'w') as f:
|
||
f.write('\n'.join(lines))
|
||
return
|
||
|
||
# Summary stats
|
||
ci_values = [e['ci'] for e in self.timeline]
|
||
phi_values = [e['phi'] for e in self.timeline]
|
||
bcast_values = [e['broadcast'] for e in self.timeline]
|
||
self_values = [e['self'] for e in self.timeline]
|
||
cmplx_values = [e['complexity'] for e in self.timeline]
|
||
|
||
lines.append("SUMMARY STATISTICS")
|
||
lines.append("-" * 40)
|
||
for label, vals in [('CI', ci_values), ('Phi', phi_values),
|
||
('Broadcast', bcast_values),
|
||
('Self-Model', self_values),
|
||
('Complexity', cmplx_values)]:
|
||
arr = np.array(vals)
|
||
lines.append(f" {label:12s}: mean={arr.mean():.4f} "
|
||
f"std={arr.std():.4f} "
|
||
f"min={arr.min():.4f} max={arr.max():.4f}")
|
||
lines.append("")
|
||
|
||
# Per-mode analysis (Experiment A)
|
||
lines.append("EXPERIMENT A: CI BY BEHAVIORAL MODE")
|
||
lines.append("-" * 40)
|
||
for mode, vals in sorted(self.mode_stats.items()):
|
||
arr = np.array(vals)
|
||
lines.append(f" {mode:12s}: mean={arr.mean():.4f} "
|
||
f"std={arr.std():.4f} n={len(vals)}")
|
||
lines.append("")
|
||
|
||
# Experiment B: Phi during mode transitions
|
||
lines.append("EXPERIMENT B: PHI DURING MODE TRANSITIONS")
|
||
lines.append("-" * 40)
|
||
transitions = []
|
||
for i in range(1, len(self.timeline)):
|
||
if self.timeline[i]['mode'] != self.timeline[i - 1]['mode']:
|
||
transitions.append(self.timeline[i])
|
||
if transitions:
|
||
t_phi = [t['phi'] for t in transitions]
|
||
nontrans_phi = [e['phi'] for e in self.timeline
|
||
if e not in transitions]
|
||
lines.append(f" During transitions: "
|
||
f"mean_phi={np.mean(t_phi):.4f} n={len(t_phi)}")
|
||
if nontrans_phi:
|
||
lines.append(f" During stable behavior: "
|
||
f"mean_phi={np.mean(nontrans_phi):.4f} "
|
||
f"n={len(nontrans_phi)}")
|
||
else:
|
||
lines.append(" No mode transitions detected.")
|
||
lines.append("")
|
||
|
||
# Experiment C: CI habituation (first vs later measurements)
|
||
lines.append("EXPERIMENT C: CI HABITUATION OVER TIME")
|
||
lines.append("-" * 40)
|
||
n_total = len(ci_values)
|
||
if n_total >= 6:
|
||
first_third = ci_values[:n_total // 3]
|
||
last_third = ci_values[-(n_total // 3):]
|
||
lines.append(f" First third: mean_CI={np.mean(first_third):.4f}")
|
||
lines.append(f" Last third: mean_CI={np.mean(last_third):.4f}")
|
||
delta = np.mean(last_third) - np.mean(first_third)
|
||
lines.append(f" Delta: {delta:+.4f} "
|
||
f"({'habituation' if delta < 0 else 'sensitization'})")
|
||
else:
|
||
lines.append(" Insufficient data (need >= 6 measurements).")
|
||
lines.append("")
|
||
|
||
# Experiment D: GF/escape analysis
|
||
lines.append("EXPERIMENT D: CI vs ESCAPE MODE")
|
||
lines.append("-" * 40)
|
||
escape_ci = self.mode_stats.get('escape', [])
|
||
non_escape_ci = []
|
||
for mode, vals in self.mode_stats.items():
|
||
if mode != 'escape':
|
||
non_escape_ci.extend(vals)
|
||
if escape_ci:
|
||
lines.append(f" Escape CI: mean={np.mean(escape_ci):.4f} "
|
||
f"n={len(escape_ci)}")
|
||
if non_escape_ci:
|
||
lines.append(f" Non-escape CI: mean={np.mean(non_escape_ci):.4f}"
|
||
f" n={len(non_escape_ci)}")
|
||
lines.append("")
|
||
|
||
# Peak events
|
||
lines.append("PEAK EVENTS (top 10)")
|
||
lines.append("-" * 40)
|
||
sorted_peaks = sorted(self.peaks, key=lambda x: x[1], reverse=True)
|
||
for step, ci in sorted_peaks[:10]:
|
||
lines.append(f" step={step:>8d} CI={ci:.4f}")
|
||
lines.append("")
|
||
|
||
lines.append("=" * 70)
|
||
lines.append("Note: These are proxy measurements of neural integration")
|
||
lines.append("and complexity. They do not constitute evidence of")
|
||
lines.append("subjective experience or phenomenal consciousness.")
|
||
lines.append("=" * 70)
|
||
|
||
with open(report_path, 'w') as f:
|
||
f.write('\n'.join(lines))
|
||
|
||
print(f"[Consciousness] Report saved: {report_path}")
|
||
|
||
def close(self):
|
||
"""Close CSV file handles."""
|
||
try:
|
||
self._tl_file.close()
|
||
self._phi_file.close()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# ============================================================================
|
||
# ConsciousnessDetector — Main orchestrator
|
||
# ============================================================================
|
||
|
||
class ConsciousnessDetector:
|
||
"""
|
||
Main orchestrator: loads neuron partitions from annotations,
|
||
initializes all sub-modules, provides update/query interface.
|
||
"""
|
||
|
||
def __init__(self, brain, label='', sim_timestep=1e-3):
|
||
"""
|
||
Args:
|
||
brain: BrainEngine instance with .model.weights, .flyid2i,
|
||
.num_neurons, .state, .device
|
||
label: optional suffix for session directory (e.g. 'fly0')
|
||
sim_timestep: body simulation timestep in seconds (for time calc)
|
||
"""
|
||
self.brain = brain
|
||
self.device = brain.device
|
||
self.num_neurons = brain.num_neurons
|
||
self.brain_step = 0
|
||
self.sim_timestep = sim_timestep
|
||
self._label = label
|
||
|
||
tag = f" ({label})" if label else ""
|
||
print(f"[Consciousness{tag}] Initializing consciousness detection...")
|
||
|
||
# Load neuron partitions from annotations
|
||
self.partitions = self._build_partitions(brain)
|
||
|
||
for name, idx in self.partitions.items():
|
||
print(f" Partition '{name}': {len(idx)} neurons")
|
||
|
||
# Find hub neurons for Global Workspace
|
||
hub_indices, hub_reach = self._find_hub_neurons(brain)
|
||
print(f" Hub neurons (fan-out > {FANOUT_THRESHOLD}): {len(hub_indices)}")
|
||
|
||
# Find sensory/motor indices for Self-Model
|
||
sensory_idx, motor_idx = self._get_sensory_motor_indices(brain)
|
||
print(f" Sensory (JO) neurons: {len(sensory_idx)}")
|
||
print(f" Motor (DN) neurons: {len(motor_idx)}")
|
||
|
||
# Initialize sub-modules
|
||
self.phi = PhiProxy(self.partitions, device=self.device)
|
||
self.gw = GlobalWorkspace(
|
||
hub_indices, hub_reach, self.partitions, device=self.device)
|
||
self.self_model = SelfModel(
|
||
sensory_idx, motor_idx, device=self.device)
|
||
self.perturbation = PerturbationComplexity(
|
||
self.num_neurons, self.partitions, device=self.device)
|
||
|
||
# Session directory
|
||
session_name = datetime.now().strftime('session_%Y%m%d_%H%M%S')
|
||
if label:
|
||
session_name += f'_{label}'
|
||
base_dir = Path(__file__).resolve().parent / 'consciousness_history'
|
||
self.timeline = ConsciousnessTimeline(base_dir / session_name)
|
||
|
||
# Current values for display
|
||
self.ci = 0.0
|
||
self.phi_val = 0.0
|
||
self.gw_val = 0.0
|
||
self.self_val = 0.0
|
||
self.cmplx_val = 0.0
|
||
|
||
print(f"[Consciousness{tag}] Ready. Composite weights: "
|
||
f"Phi={W_PHI} GW={W_BROADCAST} Self={W_SELF} "
|
||
f"Cmplx={W_COMPLEXITY}")
|
||
|
||
def _build_partitions(self, brain):
|
||
"""Build neuron partitions from flywire_annotations.tsv."""
|
||
ann_path = (Path(__file__).resolve().parent / 'data' /
|
||
'flywire_annotations.tsv')
|
||
|
||
partitions = {
|
||
'visual': [], 'motor': [], 'olfactory': [], 'integrator': [],
|
||
}
|
||
|
||
if not ann_path.exists():
|
||
print(f" [WARN] Annotations not found: {ann_path}")
|
||
print(" Using fallback: equal random partitions")
|
||
return self._fallback_partitions(brain)
|
||
|
||
# Parse annotations
|
||
flyid2i = brain.flyid2i
|
||
|
||
with open(ann_path, 'r', encoding='utf-8') as f:
|
||
reader = csv.DictReader(f, delimiter='\t')
|
||
for row in reader:
|
||
root_id = int(row.get('root_id', 0))
|
||
if root_id not in flyid2i:
|
||
continue
|
||
idx = flyid2i[root_id]
|
||
|
||
super_class = row.get('super_class', '').lower()
|
||
cell_class = row.get('cell_class', '').lower()
|
||
flow = row.get('flow', '').lower()
|
||
hemibrain_type = row.get('hemibrain_type', '').lower()
|
||
|
||
# Visual: optic lobe neurons + visual projection
|
||
if super_class in ('optic', 'visual_projection'):
|
||
partitions['visual'].append(idx)
|
||
# Motor: efferent / descending neurons
|
||
elif flow in ('efferent', 'descending'):
|
||
partitions['motor'].append(idx)
|
||
# Olfactory: olfactory, ALPN, ALLN, LHLN classes
|
||
elif cell_class in ('olfactory', 'alpn', 'alln', 'lhln'):
|
||
partitions['olfactory'].append(idx)
|
||
# Integrator: MBON, CX (central complex), KC, DAN, TuBu
|
||
elif any(tag in hemibrain_type
|
||
for tag in ('mbon', 'cx', 'kc', 'dan', 'tubu')
|
||
if tag):
|
||
partitions['integrator'].append(idx)
|
||
elif any(tag in cell_class
|
||
for tag in ('mbon', 'kenyon', 'dan')
|
||
if tag):
|
||
partitions['integrator'].append(idx)
|
||
|
||
# Apply caps
|
||
caps = {
|
||
'visual': VISUAL_CAP, 'motor': MOTOR_CAP,
|
||
'olfactory': OLFACTORY_CAP, 'integrator': INTEGRATOR_CAP,
|
||
}
|
||
result = {}
|
||
for name, indices in partitions.items():
|
||
cap = caps[name]
|
||
if len(indices) > cap:
|
||
indices = indices[:cap]
|
||
if len(indices) == 0:
|
||
# Fallback: grab some random neurons
|
||
n_fallback = min(100, brain.num_neurons)
|
||
indices = list(range(n_fallback))
|
||
result[name] = torch.tensor(indices, dtype=torch.long,
|
||
device=self.device)
|
||
|
||
return result
|
||
|
||
def _fallback_partitions(self, brain):
|
||
"""Create simple partitions when annotations unavailable."""
|
||
n = brain.num_neurons
|
||
chunk = n // 4
|
||
return {
|
||
'visual': torch.arange(0, chunk, device=self.device),
|
||
'motor': torch.arange(chunk, 2 * chunk, device=self.device),
|
||
'olfactory': torch.arange(2 * chunk, 3 * chunk, device=self.device),
|
||
'integrator': torch.arange(3 * chunk, n, device=self.device),
|
||
}
|
||
|
||
def _find_hub_neurons(self, brain):
|
||
"""Find neurons with fan-out > FANOUT_THRESHOLD from weight matrix."""
|
||
weights = brain.model.weights
|
||
|
||
try:
|
||
# Convert to COO for column counting
|
||
if weights.is_sparse_csr:
|
||
w_coo = weights.to_sparse_coo()
|
||
elif weights.is_sparse:
|
||
w_coo = weights
|
||
else:
|
||
# Dense fallback (unlikely for 138K neurons)
|
||
return (torch.tensor([], dtype=torch.long, device=self.device),
|
||
{})
|
||
|
||
indices = w_coo.indices() # (2, nnz): [row_indices, col_indices]
|
||
col_indices = indices[1] # presynaptic (source) neurons
|
||
|
||
# Count outgoing connections per neuron
|
||
fan_out = torch.zeros(self.num_neurons, device=self.device)
|
||
fan_out.scatter_add_(
|
||
0, col_indices,
|
||
torch.ones(col_indices.shape[0], device=self.device))
|
||
|
||
hub_mask = fan_out > FANOUT_THRESHOLD
|
||
hub_neuron_indices = hub_mask.nonzero(as_tuple=True)[0]
|
||
|
||
# Determine which partitions each hub can reach
|
||
row_indices = indices[0] # postsynaptic (target) neurons
|
||
hub_reach = {}
|
||
|
||
# Build partition membership lookup (on CPU for dict operations)
|
||
neuron_to_partition = {}
|
||
for name, idx_tensor in self.partitions.items():
|
||
for i in idx_tensor.cpu().numpy():
|
||
neuron_to_partition[int(i)] = name
|
||
|
||
# For each hub, find which partitions its targets belong to
|
||
for hub_idx in hub_neuron_indices.cpu().numpy():
|
||
hub_idx = int(hub_idx)
|
||
# Find all targets of this hub
|
||
target_mask = col_indices == hub_idx
|
||
targets = row_indices[target_mask].cpu().numpy()
|
||
reached = set()
|
||
for t in targets[:200]: # sample up to 200 targets
|
||
part = neuron_to_partition.get(int(t))
|
||
if part:
|
||
reached.add(part)
|
||
hub_reach[hub_idx] = reached
|
||
|
||
return hub_neuron_indices, hub_reach
|
||
|
||
except Exception as e:
|
||
print(f" [WARN] Hub detection failed: {e}")
|
||
return (torch.tensor([], dtype=torch.long, device=self.device),
|
||
{})
|
||
|
||
def _get_sensory_motor_indices(self, brain):
|
||
"""Get JO/sensory indices and motor/DN indices."""
|
||
flyid2i = brain.flyid2i
|
||
|
||
# Motor indices from DN_NEURONS
|
||
from brain_body_bridge import DN_NEURONS
|
||
motor_indices = []
|
||
for name, flyid in DN_NEURONS.items():
|
||
if flyid in flyid2i:
|
||
motor_indices.append(flyid2i[flyid])
|
||
|
||
# Sensory: use olfactory + first 100 from visual as proprioceptive proxy
|
||
sensory_indices = []
|
||
if 'olfactory' in self.partitions:
|
||
sensory_indices.extend(
|
||
self.partitions['olfactory'][:200].cpu().numpy().tolist())
|
||
if 'visual' in self.partitions:
|
||
sensory_indices.extend(
|
||
self.partitions['visual'][:100].cpu().numpy().tolist())
|
||
|
||
# Ensure we have at least some indices
|
||
if not sensory_indices:
|
||
sensory_indices = list(range(min(100, brain.num_neurons)))
|
||
if not motor_indices:
|
||
motor_indices = list(range(min(20, brain.num_neurons)))
|
||
|
||
return (torch.tensor(sensory_indices, dtype=torch.long,
|
||
device=self.device),
|
||
torch.tensor(motor_indices, dtype=torch.long,
|
||
device=self.device))
|
||
|
||
@torch.no_grad()
|
||
def update(self, body_step, mode='walking'):
|
||
"""
|
||
Called every brain step. Routes data to sub-modules on their schedules.
|
||
|
||
Args:
|
||
body_step: current body simulation step count
|
||
mode: current behavioral mode string
|
||
"""
|
||
spikes = self.brain.state[2] # (1, N) spike tensor
|
||
self.brain_step += 1
|
||
step = self.brain_step
|
||
|
||
# Always accumulate
|
||
self.phi.accumulate(spikes)
|
||
self.gw.accumulate(spikes)
|
||
self.self_model.accumulate(spikes)
|
||
|
||
# Perturbation observation (if active)
|
||
if self.perturbation.is_observing:
|
||
self.perturbation.observe(spikes, self.brain)
|
||
|
||
# Periodic Phi computation
|
||
if step % PHI_INTERVAL == 0:
|
||
self.phi_val = self.phi.compute()
|
||
|
||
# Periodic GW computation
|
||
if step % GW_INTERVAL == 0:
|
||
self.gw_val = self.gw.compute()
|
||
|
||
# Periodic Self-Model computation
|
||
if step % SELF_INTERVAL == 0:
|
||
self.self_val = self.self_model.compute()
|
||
|
||
# Perturbation trigger
|
||
if self.perturbation.should_perturb(step):
|
||
self.perturbation.start_perturbation(self.brain, spikes)
|
||
|
||
# Update complexity from latest perturbation result
|
||
if self.perturbation.history:
|
||
self.cmplx_val = self.perturbation.history[-1]
|
||
|
||
# Composite CI (record every CI_RECORD_INTERVAL)
|
||
if step % CI_RECORD_INTERVAL == 0:
|
||
t_sim = body_step * self.sim_timestep
|
||
self.ci = self.timeline.record(
|
||
step, t_sim,
|
||
self.phi_val, self.gw_val, self.self_val, self.cmplx_val,
|
||
mode,
|
||
)
|
||
|
||
def get_status_str(self):
|
||
"""Return compact status string for console output."""
|
||
return (f"CI={self.ci:.3f} "
|
||
f"Phi={self.phi_val:.2f} GW={self.gw_val:.2f} "
|
||
f"Self={self.self_val:.2f} Cmplx={self.cmplx_val:.2f}")
|
||
|
||
def get_monitor_data(self):
|
||
"""Return dict of data for brain monitor visualization."""
|
||
recent = self.timeline.get_recent_ci_values(60)
|
||
peaks = [(s, v) for s, v in self.timeline.peaks[-5:]]
|
||
|
||
return {
|
||
'consciousness_ci': self.ci,
|
||
'consciousness_phi': self.phi_val,
|
||
'consciousness_gw': self.gw_val,
|
||
'consciousness_self': self.self_val,
|
||
'consciousness_cmplx': self.cmplx_val,
|
||
'consciousness_timeline': recent,
|
||
'consciousness_peaks': peaks,
|
||
}
|
||
|
||
def save_session(self):
|
||
"""Generate report and close files. Called at simulation end."""
|
||
print("[Consciousness] Generating session report...")
|
||
self.timeline.generate_report()
|
||
self.timeline.close()
|
||
print(f"[Consciousness] Session data saved to: "
|
||
f"{self.timeline.session_dir}")
|