Skip to content

Commit 28824e1

Browse files
committed
fix: signal descriptor degeneracy -- correct emission_activity and receptor_sensitivity
Two bugs caused all 300 rollouts to land in 1 archive cell. flowlenia.py Add step_with_signal_diagnostics() alongside step(). Returns the same (new_A, new_signal) result plus two diagnostic scalars computed as natural byproducts of the signal physics block: emission_activity: mean(G_pos * effective_rate) over the grid -- actual per-cell emission this step, correctly derived from the growth field (not the mass field). receptor_sensitivity: mean(|receptor_response|) over the grid -- spatially-resolved reception after convolution, not the spatially-averaged approximation. Zero code duplication, zero performance cost for non-diagnostic paths. rollout.py Signal rollout loop calls step_with_signal_diagnostics() instead of step() when capturing descriptor histories. Removes the two broken approximations: Bug 1: emission was computed as mean(mass_field) * emission_rate. mass_field is always positive and nearly constant for viable solitons -- so every creature scored identically on emission_activity. Bug 2: reception was computed as |dot(mean_signal, receptor_profile)| where mean_signal is the spatially-averaged signal field. This is near-zero for almost all of a solo rollout (signal starts tiny, builds slowly) -- so every creature scored ~0 on receptor_sensitivity. descriptors.py Normalizers recalibrated against corrected measurements: emission_activity: 0.05 → 0.001 mean(G_pos * rate) spans ~0.00005-0.003 in practice (G_pos averaged over grid ~0.0-0.1; rate in [0.001, 0.05]). receptor_sensitivity: 0.5 → 0.005 mean|receptor_response| spans ~0.00001-0.003 (convolved signal builds from near-zero; spatial receptor response is small but real). tests/search/test_descriptors.py Test input values updated to stay within the new normalizer ranges so scaling tests remain meaningful rather than clipping to 1.0. 422 passed, 0 errors, 0 warnings
1 parent 99a6a94 commit 28824e1

4 files changed

Lines changed: 108 additions & 27 deletions

File tree

src/biota/search/descriptors.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -697,10 +697,10 @@ def compute_emission_activity(trace: RolloutTrace) -> float:
697697
if trace.signal_emission_history is None or len(trace.signal_emission_history) == 0:
698698
return 0.0
699699
mean_activity = float(trace.signal_emission_history.mean())
700-
# Normalize: emission activity is bounded by emission_rate * G_peak.
701-
# G_peak ~ 1.0, emission_rate max = 0.05, so peak activity ~ 0.05.
702-
# Use 0.05 as normalizer to put typical values in [0, 1].
703-
return float(np.clip(mean_activity / 0.05, 0.0, 1.0))
700+
# Normalizer calibrated to mean(G_pos * rate): G_pos averaged over grid
701+
# is typically 0.0-0.1; rate in [0.001, 0.05]. So mean(G_pos * rate)
702+
# spans ~0.00005-0.003 in practice. 0.001 gives good spread.
703+
return float(np.clip(mean_activity / 0.001, 0.0, 1.0))
704704

705705

706706
def compute_receptor_sensitivity(trace: RolloutTrace) -> float:
@@ -712,12 +712,14 @@ def compute_receptor_sensitivity(trace: RolloutTrace) -> float:
712712
produced strong growth modulation. Low = chemical mismatch or inert.
713713
714714
Returns 0.0 for non-signal rollouts.
715-
Normalized against a typical peak of 0.5.
715+
Normalizer calibrated to mean|receptor_response|: convolved signal builds
716+
from near-zero over 800 steps; typical mean response 0.00001-0.003.
717+
0.005 gives good spread across the [0,1] range.
716718
"""
717719
if trace.signal_reception_history is None or len(trace.signal_reception_history) == 0:
718720
return 0.0
719721
mean_response = float(np.abs(trace.signal_reception_history).mean())
720-
return float(np.clip(mean_response / 0.5, 0.0, 1.0))
722+
return float(np.clip(mean_response / 0.005, 0.0, 1.0))
721723

722724

723725
def compute_signal_retention(trace: RolloutTrace) -> float:

src/biota/search/rollout.py

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -308,26 +308,19 @@ def rollout(
308308
com_x_np[step] = com_x
309309
bbox_np[step] = bbox
310310
gyradius_np[step] = gyr
311-
if (
312-
emission_np is not None
313-
and signal is not None
314-
and sim_params.receptor_profile is not None
315-
):
316-
# Emission activity: mean positive-growth signal emission this step.
317-
g_pos_mean = float(state[:, :, 0].clamp(min=0.0).mean().item())
318-
rate = sim_params.emission_rate if sim_params.emission_rate is not None else 0.0
319-
emission_np[step] = g_pos_mean * rate
320-
# Reception: mean |dot(signal_sum, receptor)| as a scalar.
321-
if reception_np is not None:
322-
sig_mean = signal.mean(dim=(0, 1)) # (C,)
323-
rec_resp = float((sig_mean * sim_params.receptor_profile).sum().abs().item())
324-
reception_np[step] = rec_resp
325311
if step in frame_indices:
326312
thumb_buf.append(_downsample_frame(state, thumbnail_size))
327313
if step == midpoint_step:
328314
midpoint_state_np = state[:, :, 0].detach().cpu().numpy().astype(np.float32)
329315
if step < config.steps:
330-
state, signal = fl.step(state, signal)
316+
if is_signal_rollout and emission_np is not None and reception_np is not None:
317+
# Use diagnostics variant to get accurate G_pos and receptor_response
318+
# scalars from inside step() -- the only place where both are computed.
319+
state, signal, emit_act, recep_sens = fl.step_with_signal_diagnostics(state, signal)
320+
emission_np[step] = emit_act
321+
reception_np[step] = recep_sens
322+
else:
323+
state, signal = fl.step(state, signal)
331324

332325
final_mass = float(state.sum().item())
333326
final_signal_mass = float(signal.sum().item()) if signal is not None else 0.0

src/biota/sim/flowlenia.py

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,93 @@ def step(
258258

259259
return new_A2.unsqueeze(-1), new_signal
260260

261-
def step_batch(self, A: torch.Tensor) -> torch.Tensor:
261+
def step_with_signal_diagnostics(
262+
self, A: torch.Tensor, signal: torch.Tensor | None
263+
) -> tuple[torch.Tensor, torch.Tensor | None, float, float]:
264+
"""Like step(), but also returns per-step signal diagnostic scalars.
265+
266+
Used by the rollout loop to capture emission_activity and
267+
receptor_sensitivity descriptor histories without re-running the sim.
268+
269+
Returns:
270+
(new_A, new_signal, emission_activity, receptor_sensitivity)
271+
emission_activity: mean(G_pos * effective_rate) over the grid --
272+
the actual per-cell emission scalar, averaged.
273+
Proportional to how much signal was emitted this step.
274+
receptor_sensitivity: mean(|receptor_response|) over the grid --
275+
how strongly the creature responded to the convolved
276+
signal field this step.
277+
Both are 0.0 for non-signal creatures or when signal is None.
278+
"""
279+
A2 = A[:, :, 0]
280+
281+
fA = torch.fft.fft2(A2)
282+
U = torch.fft.ifft2(self._fK * fA.unsqueeze(0)).real
283+
m = self.params.m.view(-1, 1, 1)
284+
s = self.params.s.view(-1, 1, 1)
285+
h = self.params.h.view(-1, 1, 1)
286+
G = (torch.exp(-(((U - m) / s) ** 2) / 2.0) * 2.0 - 1.0) * h
287+
U_sum = G.sum(dim=0)
288+
289+
emission_activity_scalar = 0.0
290+
receptor_sensitivity_scalar = 0.0
291+
292+
if signal is not None and self.params.has_signal and self._fK_signal is not None:
293+
assert self.params.emission_vector is not None
294+
assert self.params.receptor_profile is not None
295+
296+
G_pos = U_sum.clamp(min=0.0)
297+
298+
sig_t = signal.permute(2, 0, 1)
299+
fSig = torch.fft.fft2(sig_t)
300+
convolved = torch.fft.ifft2(self._fK_signal * fSig).real
301+
receptor_response = (convolved * self.params.receptor_profile.view(-1, 1, 1)).sum(dim=0)
302+
303+
# receptor_sensitivity: mean absolute reception response over the grid
304+
receptor_sensitivity_scalar = float(receptor_response.abs().mean().item())
305+
306+
alpha_c = self.params.alpha_coupling if self.params.alpha_coupling is not None else 0.0
307+
if alpha_c != 0.0:
308+
growth_multiplier = (1.0 + alpha_c * receptor_response).clamp(min=0.0)
309+
U_sum = U_sum * growth_multiplier
310+
G_pos = U_sum.clamp(min=0.0)
311+
312+
base_rate = self.params.emission_rate if self.params.emission_rate is not None else 0.0
313+
beta_m = self.params.beta_modulation if self.params.beta_modulation is not None else 0.0
314+
if beta_m != 0.0:
315+
received_mean = float(receptor_response.mean().item())
316+
effective_rate = float(
317+
max(0.0, min(0.1, base_rate * (1.0 + beta_m * received_mean)))
318+
)
319+
else:
320+
effective_rate = base_rate
321+
322+
# emission_activity: mean(G_pos * effective_rate) -- actual emission per cell
323+
emission_activity_scalar = float((G_pos * effective_rate).mean().item())
324+
325+
emitted = G_pos * effective_rate
326+
emitted = torch.minimum(emitted, A2.clamp(min=0.0))
327+
emit_per_channel = emitted.unsqueeze(-1) * self.params.emission_vector
328+
signal = signal + emit_per_channel
329+
A2 = A2 - emitted
330+
331+
nabla_U = self._sobel(U_sum)
332+
nabla_A = self._sobel(A2)
333+
alpha = torch.clamp(A2**2, 0.0, 1.0)
334+
F_flow = nabla_U * (1.0 - alpha) - nabla_A * alpha
335+
new_A2 = self._reintegration(A2, F_flow)
336+
337+
if signal is not None and self.params.has_signal:
338+
new_signal = signal * (1.0 - self._decay)
339+
else:
340+
new_signal = signal
341+
342+
return (
343+
new_A2.unsqueeze(-1),
344+
new_signal,
345+
emission_activity_scalar,
346+
receptor_sensitivity_scalar,
347+
)
262348
"""Vectorized step over a batch of states. Signal field not supported
263349
in batch mode (search rollouts don't use it; ecosystem uses single-step).
264350

tests/search/test_descriptors.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -580,8 +580,8 @@ def test_emission_activity_zero_for_empty_history() -> None:
580580

581581

582582
def test_emission_activity_scales_with_emission() -> None:
583-
low = _make_signal_trace(emission_history=np.full(TRACE_LEN, 0.005, dtype=np.float32))
584-
high = _make_signal_trace(emission_history=np.full(TRACE_LEN, 0.04, dtype=np.float32))
583+
low = _make_signal_trace(emission_history=np.full(TRACE_LEN, 0.0002, dtype=np.float32))
584+
high = _make_signal_trace(emission_history=np.full(TRACE_LEN, 0.0008, dtype=np.float32))
585585
assert compute_emission_activity(low) < compute_emission_activity(high)
586586

587587

@@ -613,14 +613,14 @@ def test_receptor_sensitivity_zero_for_empty_history() -> None:
613613

614614

615615
def test_receptor_sensitivity_scales_with_response() -> None:
616-
low = _make_signal_trace(reception_history=np.full(TRACE_LEN, 0.05, dtype=np.float32))
617-
high = _make_signal_trace(reception_history=np.full(TRACE_LEN, 0.4, dtype=np.float32))
616+
low = _make_signal_trace(reception_history=np.full(TRACE_LEN, 0.001, dtype=np.float32))
617+
high = _make_signal_trace(reception_history=np.full(TRACE_LEN, 0.003, dtype=np.float32))
618618
assert compute_receptor_sensitivity(low) < compute_receptor_sensitivity(high)
619619

620620

621621
def test_receptor_sensitivity_in_unit_interval() -> None:
622622
trace = _make_signal_trace(
623-
reception_history=np.random.default_rng(9).random(TRACE_LEN).astype(np.float32) * 0.3
623+
reception_history=np.random.default_rng(9).random(TRACE_LEN).astype(np.float32) * 0.003
624624
)
625625
val = compute_receptor_sensitivity(trace)
626626
assert 0.0 <= val <= 1.0

0 commit comments

Comments
 (0)