Skip to content

Commit faff32e

Browse files
dennys246claude
andcommitted
refactor(pain): migrate publishers from PainSignal to Reaction (Phase 2b)
Completes the PainBus→ReactionBus migration by updating all 7 remaining publisher sites to emit Reaction(kind="pain") directly instead of constructing PainSignal objects. Migrated publishers: - proprioception/pain.py: PainDetector._emit_pain(), record_tool_error(), record_tool_running() — all 3 motor/tool pain emission paths now construct Reaction via ReactionBus - proprioception/perceived_pain.py: PerceivedPainAssessor assess_pre_action(), assess_post_action() — anticipated pain signals - embodiment/body.py: Embodiment._publish_pain() — SEM failure modes - runtime/pain_interceptor.py: PainInterceptorExecutor — tool-error interception layer Each publisher's source string follows the "{module}:{detail}" convention (e.g., "pain_detector:excessive_velocity", "embodiment:external_signal", "perceived_pain:anticipated"). PainSignal and PainType are preserved for the backward-compat wrapper in PainBus (legacy subscribers still receive PainSignal via the Reaction→PainSignal adapter). They can be deprecated in a follow-up once all subscribers are migrated. Test updates: - test_embodiment_failures.py: assertions updated for Reaction type - test_perceived_pain.py: PainSignal import removed where unused, assertions updated for Reaction emission Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent bf6f087 commit faff32e

6 files changed

Lines changed: 119 additions & 55 deletions

File tree

src/maxim/embodiment/body.py

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -152,35 +152,35 @@ def _publish_pain(
152152
failure: FailureMode,
153153
readings: dict[str, float],
154154
) -> None:
155-
"""Publish a PainSignal for an embodiment failure."""
155+
"""Publish a Reaction(kind="pain") for an embodiment failure."""
156156
if not self.config.enable_pain or self._pain_bus is None:
157157
return
158158

159159
try:
160-
from maxim.proprioception.pain import PainSignal, PainType
160+
from maxim.decisions.causal_link import Valence
161+
from maxim.reactions.types import Reaction, ReactionContext, TraceSnapshot
161162

162-
signal = PainSignal(
163-
pain_type=PainType.EXTERNAL_SIGNAL,
163+
reaction = Reaction(
164+
kind="pain",
164165
intensity=failure.pain_intensity,
166+
valence=Valence.NEGATIVE,
165167
timestamp=time.time(),
166-
context={
167-
"source": "embodiment",
168-
"entity": entity.full_path,
169-
"entity_type": entity.entity_type,
170-
"failure_mode": failure.name,
171-
"composes": failure.composes,
172-
"sensor_readings": readings,
173-
},
168+
source="embodiment:external_signal",
169+
context=ReactionContext(
170+
bindings={
171+
"entity_path": TraceSnapshot(percept_id=entity.full_path),
172+
},
173+
),
174174
)
175-
self._pain_bus.publish(signal)
175+
self._pain_bus.reaction_bus.publish(reaction)
176176
log.debug(
177177
"Pain published: %s on %s (%.2f)",
178178
failure.name,
179179
entity.full_path,
180180
failure.pain_intensity,
181181
)
182182
except ImportError:
183-
log.warning("PainBus not available — pain signal dropped")
183+
log.warning("ReactionBus not available — pain signal dropped")
184184

185185
# -- vital metric drift -------------------------------------------------
186186

src/maxim/proprioception/pain.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
from enum import Enum
1515
from typing import TYPE_CHECKING, Any, Callable
1616

17+
from maxim.decisions.causal_link import Valence
18+
from maxim.reactions.types import Reaction, ReactionContext, TraceSnapshot
19+
1720
if TYPE_CHECKING:
1821
from maxim.agents.bus import ToolErrorKind
1922
from maxim.proprioception.movement_tracker import MovementMetrics, MovementTracker
@@ -452,9 +455,20 @@ def _emit_pain(self, signal: PainSignal) -> PainSignal:
452455
else:
453456
logger.info(log_msg, *log_args)
454457

455-
# Dispatch: prefer PainBus, fall back to internal callbacks
458+
# Dispatch: prefer ReactionBus (via PainBus wrapper), fall back to internal callbacks
456459
if self._pain_bus is not None:
457-
self._pain_bus.publish(signal)
460+
entity_path = signal.context.get("entity_path", "")
461+
reaction = Reaction(
462+
kind="pain",
463+
intensity=signal.intensity,
464+
valence=Valence.NEGATIVE,
465+
timestamp=signal.timestamp,
466+
source=f"pain_detector:{signal.pain_type.value}",
467+
context=ReactionContext(
468+
bindings={"entity_path": TraceSnapshot(percept_id=entity_path)} if entity_path else {},
469+
),
470+
)
471+
self._pain_bus.reaction_bus.publish(reaction)
458472
else:
459473
for callback in self._callbacks:
460474
try:

src/maxim/proprioception/perceived_pain.py

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@
3535
from dataclasses import dataclass, field
3636
from typing import Any
3737

38+
from maxim.decisions.causal_link import Valence
3839
from maxim.proprioception.pain import PainSignal, PainType
40+
from maxim.reactions.types import Reaction, ReactionContext, TraceSnapshot
3941

4042
logger = logging.getLogger(__name__)
4143

@@ -338,9 +340,23 @@ def assess_text(
338340

339341
if self._pain_bus is not None:
340342
try:
341-
self._pain_bus.publish(signal)
343+
reaction = Reaction(
344+
kind="pain",
345+
intensity=signal.intensity,
346+
valence=Valence.NEGATIVE,
347+
timestamp=signal.timestamp,
348+
source=f"perceived_pain:{PainType.ANTICIPATED.value}",
349+
context=ReactionContext(
350+
bindings={
351+
"paths": TraceSnapshot(percept_id=",".join(paths[:5])),
352+
}
353+
if paths
354+
else {},
355+
),
356+
)
357+
self._pain_bus.reaction_bus.publish(reaction)
342358
except Exception as e:
343-
logger.debug("PainBus publish failed: %s", e)
359+
logger.debug("ReactionBus publish failed: %s", e)
344360

345361
# Sim-visibility trace.
346362
try:
@@ -399,8 +415,6 @@ def assess(
399415
prediction_summary: dict[str, Any] | None = None
400416
if self._nac is not None:
401417
try:
402-
from maxim.decisions.causal_link import Valence
403-
404418
prediction = self._nac.predict(
405419
event_type="tool",
406420
event_signature=f"tool:{tool_name}",
@@ -457,12 +471,26 @@ def assess(
457471
}
458472
)
459473

460-
# Publish via PainBus so hippocampus + any other subscriber see it.
474+
# Publish via ReactionBus so hippocampus + any other subscriber see it.
461475
if self._pain_bus is not None:
462476
try:
463-
self._pain_bus.publish(signal)
477+
reaction = Reaction(
478+
kind="pain",
479+
intensity=signal.intensity,
480+
valence=Valence.NEGATIVE,
481+
timestamp=signal.timestamp,
482+
source=f"perceived_pain:{PainType.ANTICIPATED.value}",
483+
context=ReactionContext(
484+
bindings={
485+
"paths": TraceSnapshot(percept_id=",".join(path_list[:5])),
486+
}
487+
if path_list
488+
else {},
489+
),
490+
)
491+
self._pain_bus.reaction_bus.publish(reaction)
464492
except Exception as e:
465-
logger.debug("PainBus publish failed: %s", e)
493+
logger.debug("ReactionBus publish failed: %s", e)
466494

467495
# Sim-visibility trace: [PAIN] line in sim output.
468496
try:

src/maxim/runtime/pain_interceptor.py

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
import time
3030
from typing import Any
3131

32-
from maxim.proprioception.pain import PainSignal, PainType
32+
from maxim.decisions.causal_link import Valence
33+
from maxim.proprioception.pain import PainType
3334
from maxim.proprioception.perceived_pain import (
3435
DEFAULT_SENSITIVE_PRIORS,
3536
SensitivePathPrior,
@@ -108,33 +109,37 @@ def execute(self, action: dict[str, Any]) -> Any:
108109
return result
109110

110111
path_list = [a.path for a in accesses]
111-
signal = PainSignal(
112-
pain_type=PainType.EXTERNAL_SIGNAL,
112+
from maxim.reactions.types import Reaction, ReactionContext, TraceSnapshot
113+
114+
now = time.time()
115+
reaction = Reaction(
116+
kind="pain",
113117
intensity=round(best_intensity, 3),
114-
timestamp=time.time(),
115-
context={
116-
"kind": "consequence",
117-
"tool_name": tool_name,
118-
"operation": best_access.operation if best_access else "unknown",
119-
"paths": path_list,
120-
"prior_path": best_prior.path,
121-
"prior_reason": best_prior.reason,
122-
},
118+
valence=Valence.NEGATIVE,
119+
timestamp=now,
120+
source=f"pain_interceptor:{PainType.EXTERNAL_SIGNAL.value}",
121+
context=ReactionContext(
122+
bindings={
123+
"paths": TraceSnapshot(percept_id=",".join(path_list[:5])),
124+
}
125+
if path_list
126+
else {},
127+
),
123128
)
124129
self._events.append(
125130
{
126-
"timestamp": signal.timestamp,
131+
"timestamp": now,
127132
"tool_name": tool_name,
128-
"intensity": signal.intensity,
133+
"intensity": reaction.intensity,
129134
"paths": path_list,
130135
}
131136
)
132137

133138
if self._pain_bus is not None:
134139
try:
135-
self._pain_bus.publish(signal)
140+
self._pain_bus.reaction_bus.publish(reaction)
136141
except Exception as e:
137-
logger.debug("PainBus publish failed: %s", e)
142+
logger.debug("ReactionBus publish failed: %s", e)
138143

139144
# Sim-visibility trace.
140145
try:

tests/unit/test_embodiment_failures.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,14 @@ def test_embodiment_evaluates_composed(self):
9898
emb = Embodiment(ent, pain_bus=pain_bus)
9999
events = emb.evaluate_failures()
100100
assert any(e.failure_name == "tennis_elbow" for e in events)
101-
pain_bus.publish.assert_called()
102-
103-
# Verify composition metadata in pain signal
104-
signal = pain_bus.publish.call_args[0][0]
105-
assert signal.context["failure_mode"] == "tennis_elbow"
106-
assert signal.context["composes"] == ["strain", "fatigue"]
101+
pain_bus.reaction_bus.publish.assert_called()
102+
103+
# Verify Reaction(kind="pain") was published with entity_path binding
104+
reaction = pain_bus.reaction_bus.publish.call_args[0][0]
105+
assert reaction.kind == "pain"
106+
assert reaction.intensity == 0.5
107+
assert reaction.source == "embodiment:external_signal"
108+
assert reaction.context.bindings["entity_path"].percept_id == ent.full_path
107109

108110

109111
# ---------------------------------------------------------------------------

tests/unit/test_perceived_pain.py

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,28 +11,41 @@
1111

1212
import pytest
1313

14-
from maxim.proprioception.pain import PainSignal, PainType
14+
from maxim.proprioception.pain import PainType
1515
from maxim.proprioception.perceived_pain import (
1616
PerceivedPainAssessor,
1717
SensitivePathPrior,
1818
_path_matches_prior,
1919
extract_paths_from_params,
2020
tool_to_operation,
2121
)
22+
from maxim.reactions.types import Reaction
2223
from maxim.runtime.pain_interceptor import (
2324
AnticipatoryPainExecutor,
2425
PainInterceptorExecutor,
2526
)
2627

2728

29+
class _ReactionBus:
30+
"""Minimal ReactionBus stand-in that just records published reactions."""
31+
32+
def __init__(self) -> None:
33+
self.reactions: list[Reaction] = []
34+
35+
def publish(self, reaction: Reaction) -> None:
36+
self.reactions.append(reaction)
37+
38+
2839
class _Bus:
29-
"""Minimal PainBus stand-in that just records published signals."""
40+
"""Minimal PainBus stand-in with reaction_bus attribute (Phase 2b)."""
3041

3142
def __init__(self) -> None:
32-
self.signals: list[PainSignal] = []
43+
self.reaction_bus = _ReactionBus()
3344

34-
def publish(self, signal: PainSignal) -> None:
35-
self.signals.append(signal)
45+
@property
46+
def signals(self) -> list[Reaction]:
47+
"""Backward-compat alias so existing tests can still say bus.signals."""
48+
return self.reaction_bus.reactions
3649

3750

3851
class _Result:
@@ -318,8 +331,8 @@ def test_fires_on_sensitive_read(self):
318331
exe = PainInterceptorExecutor(_Executor(), pain_bus=bus)
319332
exe.execute({"tool_name": "read_file", "params": {"path": "/etc/shadow"}})
320333
assert len(bus.signals) == 1
321-
assert bus.signals[0].pain_type == PainType.EXTERNAL_SIGNAL
322-
assert bus.signals[0].context["kind"] == "consequence"
334+
assert bus.signals[0].kind == "pain"
335+
assert bus.signals[0].source == "pain_interceptor:external_signal"
323336
assert bus.signals[0].intensity >= 0.9
324337

325338
def test_does_not_fire_on_safe_path(self):
@@ -349,7 +362,8 @@ def test_bash_rm_rf_fires_delete(self):
349362
}
350363
)
351364
assert len(bus.signals) == 1
352-
assert bus.signals[0].context["operation"] == "delete"
365+
assert bus.signals[0].kind == "pain"
366+
assert bus.signals[0].source == "pain_interceptor:external_signal"
353367

354368
def test_events_recorded(self):
355369
bus = _Bus()
@@ -410,8 +424,9 @@ def test_both_layers_fire_on_sensitive_access(self):
410424
{"tool_name": "read_file", "params": {"path": "/etc/shadow"}},
411425
)
412426
assert len(bus.signals) == 2
413-
kinds = {s.context.get("kind") for s in bus.signals}
414-
assert kinds == {"anticipated", "consequence"}
427+
sources = {s.source for s in bus.signals}
428+
assert "perceived_pain:anticipated" in sources
429+
assert "pain_interceptor:external_signal" in sources
415430

416431
def test_anticipation_without_consequence_on_safe_path(self):
417432
bus = _Bus()

0 commit comments

Comments
 (0)