Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3aa419c53 | |||
| 5bb762ae53 |
@@ -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
|
||||
|
||||
|
||||
+17
-14
@@ -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,
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user