diff --git a/docs/superpowers/specs/2026-06-22-affect-channel-hud-design.md b/docs/superpowers/specs/2026-06-22-affect-channel-hud-design.md index 87ce6fe..18157b5 100644 --- a/docs/superpowers/specs/2026-06-22-affect-channel-hud-design.md +++ b/docs/superpowers/specs/2026-06-22-affect-channel-hud-design.md @@ -1,27 +1,36 @@ # Affect channel — emotions in the Left-brain HUD -**Status:** approved (session 0013, 2026-06-22) +**Status:** approved (session 0013, 2026-06-22); **revised session 0018, 2026-06-26** **Surface:** simulator preview (`simulator/`, `player/alteration.py`) **Builds on:** the machine-altered-perception design SPEC; the Left HUD look-and-feel pass (same session). +> **Revision (session 0018) — affect is a RIGHT-brain output.** The original design +> gated emotions on *both* knobs (`strength = min(left, right)`). Per operator +> direction, **emotions are now controlled by the Right (dreamlike) knob alone** — +> the analytical Left knob no longer gates them. This sharpens the left/right split: +> Left = measurement (detections + measurements), Right = the dream **and** the +> feeling it evokes. Every `min(left, right)` below now reads `right`. The data +> shape, rendering, and per-word `min_level` mechanic are unchanged — only the knob +> the strength is keyed off. + ## Idea A third HUD channel beside the cyan *detections* and amber *measurements*: soft, glowing **emotion-words** that surface the feelings the experience may invoke. -They appear only when *both* the analytical (Left) and dreamlike (Right) knobs are -up — the dream leaking into the machine's reading. This is the uncanny edge of the -thesis: the machine trying to quantify not just what is there, but how you are -meant to feel. +They are a **right-brain output: driven by the dreamlike Right knob alone** (the +analytical Left knob does not gate them) — the dream surfacing as the machine's +felt reading. This is the uncanny edge of the thesis: the machine trying to +quantify not just what is there, but how you are meant to feel. ## Behaviour -- **Trigger / intensity.** Affect strength = `min(left, right)` (0–4). Either knob - at 0 → no emotions at all. Opacity scales with that strength × `overlay_gain`. -- **Escalation.** A **stable emotional palette per scene** — higher combined - strength reveals *more* words at higher opacity. Each word carries a `min_level` - (the same mechanic the detection channel uses), but compared against the - *combined* `min(left, right)` strength rather than Left alone. +- **Trigger / intensity.** Affect strength = `right` (0–4). Right at 0 → no + emotions at all, regardless of Left. Opacity scales with that strength × + `overlay_gain`. +- **Escalation.** A **stable emotional palette per scene** — higher Right reveals + *more* words at higher opacity. Each word carries a `min_level` (the same + mechanic the detection channel uses), compared against the `right` strength. - **Rendering.** Floating words at authored scene positions — **no boxes or reticles**, lowercase, semi-transparent with a soft glow, in a distinct **violet** register so they read as affective and clearly *softer* than the hard clinical @@ -33,15 +42,15 @@ Consistent with the existing split (alteration math in Python, label-selection i the client): - `player/alteration.py` gains an `AffectOverlay(strength, intensity)` on the - `RenderPlan`. `strength = min(left, right)`; `intensity = clamp(overlay_gain * - min(left,right) / KNOB_MAX, 0, 1)`. Returned in `render_plan_to_dict` under - `"affect"`. Pure, unit-tested. + `RenderPlan`. `strength = right`; `intensity = clamp(overlay_gain * right / + KNOB_MAX, 0, 1)`. Returned in `render_plan_to_dict` under `"affect"`. Pure, + unit-tested. - The simulator client picks *which* words to show by comparing each affect entry's `min_level` to `plan.affect.strength`, and renders them at `plan.affect.intensity` opacity. -`is_identity` is unaffected: `min(left,right) > 0` implies `left > 0`, which already -makes `overlay.level > 0`. +`is_identity` is unaffected: `affect.strength == right`, and `right > 0` already +makes `dream.strength > 0`. ## Data diff --git a/player/alteration.py b/player/alteration.py index f015073..61e06bc 100644 --- a/player/alteration.py +++ b/player/alteration.py @@ -88,11 +88,12 @@ class AnalyticalOverlay: @dataclass(frozen=True) class AffectOverlay: - """Affect channel (Left x Right): emotion-words surfaced only when BOTH the - analytical (Left) and dreamlike (Right) knobs are up. `strength` is - `min(left, right)` (0..4) — a runtime affect track picks which feeling words - appear by it; `intensity` 0..1 is their opacity. The dream leaking into the - machine's reading (affect-channel design, session 0013).""" + """Affect channel (RIGHT brain): emotion-words surfaced by the Right (dreamlike) + knob alone — the analytical Left knob does NOT gate them. `strength` is the Right + knob (0..4) — a runtime affect track picks which feeling words appear by it; + `intensity` 0..1 is their opacity. Feeling is the right brain's output, surfaced + as the machine's felt reading (affect-channel design, session 0013; Right-only + since session 0018).""" strength: int intensity: float @@ -122,8 +123,9 @@ class RenderPlan: @property def is_identity(self) -> bool: """True when the plan leaves the neutral base un-altered. Affect needs no - clause: `min(left,right) > 0` implies `left > 0`, so `overlay.level > 0` - already makes a plan with active affect non-identity.""" + clause: `affect.strength == right`, and `right > 0` already makes + `dream.strength > 0`, so an active-affect plan is non-identity via the + dream check below.""" return ( self.grade.is_identity and self.overlay.level == 0 @@ -135,13 +137,14 @@ def _overlay_intensity(left: int, cal: Calibration) -> float: return _clamp(cal.overlay_gain * left / KNOB_MAX, 0.0, 1.0) -def _affect_strength(left: int, right: int) -> int: - """Affect surfaces only when BOTH knobs are up — gated by the smaller one.""" - return min(left, right) +def _affect_strength(right: int) -> int: + """Affect is a RIGHT-brain output: the emotion channel is driven by the Right + (dreamlike) knob alone — the analytical Left knob no longer gates it.""" + return right -def _affect_intensity(left: int, right: int, cal: Calibration) -> float: - return _clamp(cal.overlay_gain * _affect_strength(left, right) / KNOB_MAX, 0.0, 1.0) +def _affect_intensity(right: int, cal: Calibration) -> float: + return _clamp(cal.overlay_gain * _affect_strength(right) / KNOB_MAX, 0.0, 1.0) def _dream_intensity(right: int, cal: Calibration) -> float: @@ -163,8 +166,8 @@ def plan_alteration( intensity=_overlay_intensity(coord.left, calibration), ), affect=AffectOverlay( - strength=_affect_strength(coord.left, coord.right), - intensity=_affect_intensity(coord.left, coord.right, calibration), + strength=_affect_strength(coord.right), + intensity=_affect_intensity(coord.right, calibration), ), dream=Dream( strength=coord.right, diff --git a/simulator/static/app.js b/simulator/static/app.js index 739af85..28bd42b 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -456,10 +456,11 @@ function renderOverlay(level, intensity) { lastOverlay = { level, intensity }; } -// Affect channel: soft, glowing emotion-words surfaced only when BOTH knobs are -// up. `strength` = min(left, right); `intensity` is the layer opacity. Words are -// placed at authored scene points (no boxes — feelings are scene-level) and read -// softer than the clinical reticles — the dream leaking into the machine's read. +// Affect channel: soft, glowing emotion-words — a RIGHT-brain output, surfaced by +// the Right (dreamlike) knob alone (`strength` = right; the Left analytical knob no +// longer gates them). `intensity` is the layer opacity. Words are placed at authored +// scene points (no boxes — feelings are scene-level) and read softer than the +// clinical reticles — feeling, not measurement. function renderAffect(strength, intensity, right) { const clip = activeClip(); if (!affectLayer) return; // tolerate a stale page missing the affect layer @@ -472,7 +473,7 @@ function renderAffect(strength, intensity, right) { const [x, y] = f.at.map((n) => n * 100); const t = svg("text", { x, y, "text-anchor": "middle", class: "hud-affect" }, affectLayer); // RIGHT emotion tier: the vocabulary escalates basic -> compound as the Right - // knob rises (appearance still gated by min(left,right) above). + // knob rises (appearance is gated by the Right knob via `strength` above). const raw = strings[f.key]; t.textContent = pickTier(raw !== undefined ? raw : f.key, clamp(right || 0, 1, tierCount(raw))); } diff --git a/tests/test_player_alteration.py b/tests/test_player_alteration.py index 93f13dd..2ff1463 100644 --- a/tests/test_player_alteration.py +++ b/tests/test_player_alteration.py @@ -55,42 +55,44 @@ def test_left_and_right_stack_not_cancel(): assert plan.dream.intensity == 1.0 -def test_affect_needs_both_knobs_up(): - # affect-channel design (session 0013): emotion-words surface only when BOTH - # the Left and Right knobs are up — either at 0 means no affect at all. +def test_affect_is_driven_by_the_right_knob_alone(): + # Right-brain affect (session 0018): emotion-words are controlled by the Right + # knob alone — Right at 0 means no affect regardless of Left, and Left no longer + # gates the channel (full Right with Left at 0 still surfaces affect). assert plan_alteration(_coord(left=4, right=0)).affect.strength == 0 - assert plan_alteration(_coord(left=0, right=4)).affect.strength == 0 assert plan_alteration(_coord(left=4, right=0)).affect.intensity == 0.0 - assert plan_alteration(_coord(left=0, right=4)).affect.intensity == 0.0 + assert plan_alteration(_coord(left=0, right=4)).affect.strength == 4 + assert plan_alteration(_coord(left=0, right=4)).affect.intensity == 1.0 -def test_affect_strength_is_the_smaller_knob(): - # strength = min(left, right) — the smaller knob gates the channel. - assert plan_alteration(_coord(left=2, right=4)).affect.strength == 2 +def test_affect_strength_is_the_right_knob(): + # strength = right — Left is irrelevant to the affect channel. + assert plan_alteration(_coord(left=2, right=4)).affect.strength == 4 assert plan_alteration(_coord(left=4, right=2)).affect.strength == 2 - assert plan_alteration(_coord(left=4, right=4)).affect.strength == 4 - assert plan_alteration(_coord(left=3, right=1)).affect.strength == 1 + assert plan_alteration(_coord(left=0, right=3)).affect.strength == 3 + assert plan_alteration(_coord(left=4, right=1)).affect.strength == 1 -def test_affect_intensity_scales_with_strength_over_full_scale(): - for lo in range(5): - plan = plan_alteration(_coord(left=lo, right=4)) - assert plan.affect.intensity == pytest.approx(lo / 4) - assert plan_alteration(_coord(left=4, right=4)).affect.intensity == 1.0 +def test_affect_intensity_scales_with_right_over_full_scale(): + for r in range(5): + plan = plan_alteration(_coord(left=0, right=r)) + assert plan.affect.intensity == pytest.approx(r / 4) + assert plan_alteration(_coord(left=0, right=4)).affect.intensity == 1.0 def test_affect_intensity_honors_overlay_gain(): - # affect rides the same overlay_gain as the analytical HUD it belongs to. + # affect text rides the overlay-opacity gain, but is keyed off the Right knob. cal = Calibration(overlay_gain=0.5) - assert plan_alteration(_coord(left=4, right=4), cal).affect.intensity == pytest.approx(0.5) + assert plan_alteration(_coord(left=0, right=4), cal).affect.intensity == pytest.approx(0.5) clamp = Calibration(overlay_gain=10.0) - assert plan_alteration(_coord(left=4, right=4), clamp).affect.intensity == 1.0 + assert plan_alteration(_coord(left=0, right=4), clamp).affect.intensity == 1.0 def test_affect_active_implies_non_identity(): - # min(left,right) > 0 implies left > 0, so an active affect plan is never identity. - plan = plan_alteration(_coord(left=2, right=3)) - assert plan.affect.strength == 2 + # affect.strength == right, and right > 0 makes dream.strength > 0, so an active + # affect plan (even with Left at 0) is never identity. + plan = plan_alteration(_coord(left=0, right=3)) + assert plan.affect.strength == 3 assert not plan.is_identity