Skip to content

Commit c6b3969

Browse files
committed
feat: v3.5.0 commit 2 -- signal descriptors + validation
descriptors.py Descriptor gains signal_only: bool = False field. When True, the descriptor requires a signal-enabled rollout; the loop validates this at startup. RolloutTrace gains three optional signal history fields: signal_emission_history: (T,) mean emission activity per trace step signal_reception_history: (T,) mean |reception response| per trace step signal_retention: float final_mass / initial_mass scalar slice() updated to preserve all three fields. Three new signal descriptor compute functions: compute_emission_activity: mean emission over trace tail, normalized by 0.05 (max emission_rate). Measures dynamic emission, not just the emission_rate parameter. Zero for non-signal traces. compute_receptor_sensitivity: mean |reception response| over trace tail, normalized by 0.5. Measures chemical responsiveness. Zero for non-signal. compute_signal_retention: final_mass / initial_mass clipped to [0,1]. High = conservative emitter, low = aggressive broadcaster. 1.0 for non-signal traces (no mass lost). All three registered in REGISTRY with signal_only=True. Ecological roles: broadcaster (high emit, low retain), listener (low emit, high sensitivity), predator candidate (high emit + high sensitivity + high alpha). rollout.py emission_np and reception_np buffers allocated only for signal rollouts. Per-step: emission activity = G_pos_mean * rate; reception = |dot(signal_mean, receptor_profile)|. Both captured into trace tail. signal_retention_val computed as final_mass / initial_mass. All three passed to RolloutTrace. loop.py After resolve_descriptors(), validate that no signal_only descriptor is active when signal_field=False. Raises ValueError with message pointing to --signal-field if violated. tests test_descriptors.py: 15 new tests for all three signal descriptors covering zero-for-non-signal, zero-for-empty, scaling, clipping, unit interval. test_descriptor_registry.py: registry count updated 15→18; expected names set updated with emission_activity, receptor_sensitivity, signal_retention. test_loop.py: 3 new validation tests covering error-on-signal-only-without- signal-field, no-error-with-signal-field, no-error-for-standard-descriptors. 422 passed, 0 errors, 0 warnings
1 parent ede40ad commit c6b3969

6 files changed

Lines changed: 310 additions & 2 deletions

File tree

src/biota/search/descriptors.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,10 @@ class Descriptor:
112112
short_name: str
113113
direction_label: str
114114
compute: Callable[["RolloutTrace"], float]
115+
signal_only: bool = False
116+
"""When True, this descriptor requires a signal-enabled rollout.
117+
Passing a signal-only descriptor to a non-signal search raises a
118+
ValueError at startup (enforced in loop.py)."""
115119

116120

117121
# === RolloutTrace ===
@@ -155,6 +159,19 @@ class RolloutTrace:
155159
point compactness term in the quality metric to penalise creatures that
156160
peak early and degrade. None when not captured (older code paths and
157161
rollout_batch, which does not support per-element midpoint capture)."""
162+
signal_emission_history: np.ndarray | None = None
163+
"""Mean positive-growth emission activity per step in the trace tail.
164+
Shape (T,) float32. Proportional to how much signal the creature
165+
actually emitted at each step. None for non-signal rollouts."""
166+
signal_reception_history: np.ndarray | None = None
167+
"""Mean absolute reception response |dot(convolved_signal, receptor)|
168+
per step in the trace tail. Shape (T,) float32. Measures how strongly
169+
the creature actually responded to the chemical environment.
170+
None for non-signal rollouts."""
171+
signal_retention: float | None = None
172+
"""final_mass / initial_mass for signal rollouts. Measures how much
173+
mass the creature retained vs bled into the signal field.
174+
None for non-signal rollouts."""
158175

159176
def slice(self, start: int, end: int) -> "RolloutTrace":
160177
"""Return a trace covering steps [start, end) within the original window.
@@ -171,6 +188,17 @@ def slice(self, start: int, end: int) -> "RolloutTrace":
171188
final_state=self.final_state,
172189
grid_size=self.grid_size,
173190
total_steps=self.total_steps,
191+
signal_emission_history=(
192+
self.signal_emission_history[start:end]
193+
if self.signal_emission_history is not None
194+
else None
195+
),
196+
signal_reception_history=(
197+
self.signal_reception_history[start:end]
198+
if self.signal_reception_history is not None
199+
else None
200+
),
201+
signal_retention=self.signal_retention,
174202
)
175203

176204

@@ -653,6 +681,65 @@ def compute_spatial_entropy(trace: "RolloutTrace") -> float:
653681
# === registry ===
654682

655683

684+
# === signal descriptors ===
685+
686+
687+
def compute_emission_activity(trace: RolloutTrace) -> float:
688+
"""Mean emission activity over the trace tail, normalized to [0, 1].
689+
690+
Measures how much signal the creature actually emitted during its rollout,
691+
not just what its emission_rate parameter is. A creature with high
692+
emission_rate but low positive growth activity barely emits.
693+
694+
Returns 0.0 for non-signal rollouts (signal_emission_history is None).
695+
Normalized against a typical peak of 0.02 (2x the max emission_rate).
696+
"""
697+
if trace.signal_emission_history is None or len(trace.signal_emission_history) == 0:
698+
return 0.0
699+
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))
704+
705+
706+
def compute_receptor_sensitivity(trace: RolloutTrace) -> float:
707+
"""Mean absolute reception response over the trace tail, normalized to [0, 1].
708+
709+
Measures how strongly the creature actually responded to the chemical
710+
environment it encountered during its solo rollout. High = the creature's
711+
receptor profile was well-aligned with the signal it encountered and
712+
produced strong growth modulation. Low = chemical mismatch or inert.
713+
714+
Returns 0.0 for non-signal rollouts.
715+
Normalized against a typical peak of 0.5.
716+
"""
717+
if trace.signal_reception_history is None or len(trace.signal_reception_history) == 0:
718+
return 0.0
719+
mean_response = float(np.abs(trace.signal_reception_history).mean())
720+
return float(np.clip(mean_response / 0.5, 0.0, 1.0))
721+
722+
723+
def compute_signal_retention(trace: RolloutTrace) -> float:
724+
"""Mass retained vs bled into the signal field: final_mass / initial_mass.
725+
726+
High retention (near 1.0) = chemically conservative creature; keeps most
727+
of its mass and emits sparingly. Low retention (near 0.0) = aggressive
728+
emitter; broadcasts heavily at the cost of mass.
729+
730+
Together with emission_activity and receptor_sensitivity this creates a
731+
three-axis chemical behavior space:
732+
high emission + low retention = broadcaster
733+
low emission + high sensitivity = listener
734+
high emission + high sensitivity + high alpha = predator candidate
735+
736+
Returns 1.0 for non-signal rollouts (no mass was lost to signal field).
737+
"""
738+
if trace.signal_retention is None:
739+
return 1.0
740+
return float(np.clip(trace.signal_retention, 0.0, 1.0))
741+
742+
656743
REGISTRY: dict[str, Descriptor] = {
657744
"velocity": Descriptor(
658745
name="velocity",
@@ -744,6 +831,28 @@ def compute_spatial_entropy(trace: "RolloutTrace") -> float:
744831
direction_label="more diffuse",
745832
compute=compute_spatial_entropy,
746833
),
834+
# --- signal-only descriptors ---
835+
"emission_activity": Descriptor(
836+
name="emission activity",
837+
short_name="emit.act.",
838+
direction_label="more active emission",
839+
compute=compute_emission_activity,
840+
signal_only=True,
841+
),
842+
"receptor_sensitivity": Descriptor(
843+
name="receptor sensitivity",
844+
short_name="recep.sens.",
845+
direction_label="more sensitive",
846+
compute=compute_receptor_sensitivity,
847+
signal_only=True,
848+
),
849+
"signal_retention": Descriptor(
850+
name="signal retention",
851+
short_name="sig.ret.",
852+
direction_label="more conservative",
853+
compute=compute_signal_retention,
854+
signal_only=True,
855+
),
747856
}
748857

749858
DEFAULT_DESCRIPTORS: tuple[str, str, str] = ("velocity", "gyradius", "spectral_entropy")

src/biota/search/loop.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,16 @@ def search(
208208
# Inject active descriptors into the rollout config so workers compute
209209
# the right three axes.
210210
active = resolve_descriptors(config.descriptor_names)
211+
212+
# Validate: signal-only descriptors require --signal-field.
213+
signal_only_names = [d.name for d in active if d.signal_only]
214+
if signal_only_names and not config.signal_field:
215+
raise ValueError(
216+
f"descriptor(s) {signal_only_names} require a signal-enabled search. "
217+
f"Pass --signal-field (or set signal_field=True in SearchConfig) to "
218+
f"activate the signal field, or choose non-signal descriptors instead."
219+
)
220+
211221
rollout_with_descriptors = dc_replace(config.rollout, active_descriptors=active)
212222
config = dc_replace(config, rollout=rollout_with_descriptors)
213223
state = _LoopState(

src/biota/search/rollout.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,10 @@ def rollout(
293293
com_x_np = np.zeros(history_len, dtype=np.float32)
294294
bbox_np = np.zeros(history_len, dtype=np.float32)
295295
gyradius_np = np.zeros(history_len, dtype=np.float32)
296+
# Signal history buffers -- only allocated for signal rollouts.
297+
is_signal_rollout = signal is not None and sim_params.has_signal
298+
emission_np = np.zeros(history_len, dtype=np.float32) if is_signal_rollout else None
299+
reception_np = np.zeros(history_len, dtype=np.float32) if is_signal_rollout else None
296300
thumb_buf: list[torch.Tensor] = []
297301

298302
# 5. Run the loop, capturing stats and frames as we go
@@ -304,6 +308,20 @@ def rollout(
304308
com_x_np[step] = com_x
305309
bbox_np[step] = bbox
306310
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
307325
if step in frame_indices:
308326
thumb_buf.append(_downsample_frame(state, thumbnail_size))
309327
if step == midpoint_step:
@@ -341,6 +359,11 @@ def rollout(
341359

342360
# 7. Build the trace covering the last TRACE_TAIL_STEPS steps
343361
tail = min(TRACE_TAIL_STEPS, history_len)
362+
# Signal retention scalar for the signal_retention descriptor.
363+
signal_retention_val: float | None = None
364+
if is_signal_rollout and initial_mass > 0:
365+
signal_retention_val = float(np.clip(final_mass / initial_mass, 0.0, 1.0))
366+
344367
trace = RolloutTrace(
345368
com_history=com_history[-tail:].astype(np.float32),
346369
bbox_fraction_history=bbox_np[-tail:].astype(np.float32),
@@ -349,6 +372,9 @@ def rollout(
349372
grid_size=config.sim.grid_h,
350373
total_steps=config.steps,
351374
midpoint_state=midpoint_state_np,
375+
signal_emission_history=emission_np[-tail:] if emission_np is not None else None,
376+
signal_reception_history=reception_np[-tail:] if reception_np is not None else None,
377+
signal_retention=signal_retention_val,
352378
)
353379

354380
# 8. Evaluate quality

tests/search/test_descriptor_registry.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,8 @@ def _write_module(content: str) -> Path:
7373
# === REGISTRY ===
7474

7575

76-
def test_registry_has_fifteen_built_ins() -> None:
77-
assert len(REGISTRY) == 15
76+
def test_registry_has_eighteen_built_ins() -> None:
77+
assert len(REGISTRY) == 18
7878

7979

8080
def test_registry_contains_all_expected_names() -> None:
@@ -94,6 +94,9 @@ def test_registry_contains_all_expected_names() -> None:
9494
"morphological_instability",
9595
"activity",
9696
"spatial_entropy",
97+
"emission_activity",
98+
"receptor_sensitivity",
99+
"signal_retention",
97100
}
98101
assert set(REGISTRY.keys()) == expected
99102

tests/search/test_descriptors.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,12 @@
3737
compute_angular_velocity,
3838
compute_descriptors,
3939
compute_displacement_ratio,
40+
compute_emission_activity,
4041
compute_growth_gradient,
4142
compute_gyradius,
4243
compute_morphological_instability,
44+
compute_receptor_sensitivity,
45+
compute_signal_retention,
4346
compute_spatial_entropy,
4447
compute_spectral_entropy,
4548
compute_velocity,
@@ -533,3 +536,117 @@ def test_spatial_entropy_concentrated_less_than_diffuse() -> None:
533536
def test_spatial_entropy_in_unit_interval() -> None:
534537
state = np.random.default_rng(31).random((GRID, GRID)).astype(np.float32)
535538
assert 0.0 <= compute_spatial_entropy(_make_trace(final_state=state)) <= 1.0
539+
540+
541+
# ===========================================================================
542+
# Signal descriptors
543+
# ===========================================================================
544+
545+
C = 16 # signal channels
546+
547+
548+
def _make_signal_trace(
549+
emission_history: np.ndarray | None = None,
550+
reception_history: np.ndarray | None = None,
551+
retention: float | None = None,
552+
) -> RolloutTrace:
553+
"""Build a RolloutTrace with signal history fields populated."""
554+
state = np.zeros((GRID, GRID), dtype=np.float32)
555+
state[40:56, 40:56] = 1.0
556+
return RolloutTrace(
557+
com_history=np.full((TRACE_LEN, 2), 48.0, dtype=np.float32),
558+
bbox_fraction_history=np.full(TRACE_LEN, 0.04, dtype=np.float32),
559+
gyradius_history=np.zeros(TRACE_LEN, dtype=np.float32),
560+
final_state=state,
561+
grid_size=GRID,
562+
total_steps=STEPS,
563+
signal_emission_history=emission_history,
564+
signal_reception_history=reception_history,
565+
signal_retention=retention,
566+
)
567+
568+
569+
# --- emission_activity ---
570+
571+
572+
def test_emission_activity_zero_for_non_signal() -> None:
573+
trace = _make_trace()
574+
assert compute_emission_activity(trace) == 0.0
575+
576+
577+
def test_emission_activity_zero_for_empty_history() -> None:
578+
trace = _make_signal_trace(emission_history=np.array([], dtype=np.float32))
579+
assert compute_emission_activity(trace) == 0.0
580+
581+
582+
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))
585+
assert compute_emission_activity(low) < compute_emission_activity(high)
586+
587+
588+
def test_emission_activity_clipped_to_unit() -> None:
589+
# History above normalizer -> clips to 1.0
590+
trace = _make_signal_trace(emission_history=np.full(TRACE_LEN, 1.0, dtype=np.float32))
591+
assert compute_emission_activity(trace) == 1.0
592+
593+
594+
def test_emission_activity_in_unit_interval() -> None:
595+
trace = _make_signal_trace(
596+
emission_history=np.random.default_rng(7).random(TRACE_LEN).astype(np.float32) * 0.05
597+
)
598+
val = compute_emission_activity(trace)
599+
assert 0.0 <= val <= 1.0
600+
601+
602+
# --- receptor_sensitivity ---
603+
604+
605+
def test_receptor_sensitivity_zero_for_non_signal() -> None:
606+
trace = _make_trace()
607+
assert compute_receptor_sensitivity(trace) == 0.0
608+
609+
610+
def test_receptor_sensitivity_zero_for_empty_history() -> None:
611+
trace = _make_signal_trace(reception_history=np.array([], dtype=np.float32))
612+
assert compute_receptor_sensitivity(trace) == 0.0
613+
614+
615+
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))
618+
assert compute_receptor_sensitivity(low) < compute_receptor_sensitivity(high)
619+
620+
621+
def test_receptor_sensitivity_in_unit_interval() -> None:
622+
trace = _make_signal_trace(
623+
reception_history=np.random.default_rng(9).random(TRACE_LEN).astype(np.float32) * 0.3
624+
)
625+
val = compute_receptor_sensitivity(trace)
626+
assert 0.0 <= val <= 1.0
627+
628+
629+
# --- signal_retention ---
630+
631+
632+
def test_signal_retention_one_for_non_signal() -> None:
633+
"""Non-signal rollout: no mass was lost, retention = 1.0."""
634+
trace = _make_trace()
635+
assert compute_signal_retention(trace) == 1.0
636+
637+
638+
def test_signal_retention_reflects_mass_loss() -> None:
639+
# 70% retained -> 0.7
640+
trace = _make_signal_trace(retention=0.7)
641+
assert abs(compute_signal_retention(trace) - 0.7) < 1e-6
642+
643+
644+
def test_signal_retention_clipped_to_unit() -> None:
645+
assert compute_signal_retention(_make_signal_trace(retention=1.5)) == 1.0
646+
assert compute_signal_retention(_make_signal_trace(retention=-0.1)) == 0.0
647+
648+
649+
def test_signal_retention_high_beats_low() -> None:
650+
high = _make_signal_trace(retention=0.95)
651+
low = _make_signal_trace(retention=0.40)
652+
assert compute_signal_retention(high) > compute_signal_retention(low)

0 commit comments

Comments
 (0)