Compare commits

...

7 Commits

Author SHA1 Message Date
benstull 229af60f1f Merge pull request 'feat(content): add cosmos_miri pool member (Carina mid-infrared pan)' (#25) from feat/cosmos-miri-pool-member into main
feat(content): add cosmos_miri pool member (Carina mid-infrared pan) (#25)
2026-06-26 12:39:34 +00:00
BenStullsBets 32e74ca446 fix(sim): make sim-local use the venv interpreter
`make sim-local` called bare `python`, which isn't on this box's PATH (only
python3 / .venv/bin/python exist) — so it failed instantly and "couldn't connect"
to the server. Resolve PYTHON to the venv interpreter, falling back to
python3 then python.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:28:02 -07:00
BenStullsBets e3a9b7cff6 feat(content): add cosmos_miri pool member (Carina mid-infrared pan)
Operator wanted more Cosmic Cliffs footage and a different-color rendering.
Keeps the NIRCam flythrough as the cosmos primary and adds ESA/Webb weic2205c
("Pan of the Carina Nebula, combined NIRCam + MIRI") as a new cosmos pool
member — the same Carina towers in the muted gray/teal/gold MIRI mid-infrared
palette, distinct from the iconic orange/blue flythrough.

- POOLS["cosmos"] grows 4 -> 5 (cosmos, cosmos_miri, galaxies, hudf, xdf);
  primary unchanged.
- META + LABELS authored (nebula/star/distance, salience-gated); affect inherits
  the cosmos scale vocabulary.
- manifest.json regenerated (19 -> 20 clips).
- Source: 4K master, text-free/human-free, trim 4-26s (clears the fade-from-
  black), 1 s crossfade-loop (~21 s) -> 1080p base + 4K master (gitignored).
- docs/content-candidate-pool.md updated (cosmos pool of 5 + sourcing note).

271 passed, 2 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:07:13 -07:00
BenStullsBets 7a2f37af7e claim human-experience-filter-art session 0019 (placeholder) + sessions.json entry 2026-06-26 04:47:47 -07:00
benstull d0ad9a9ac9 Merge pull request 'feat(sim): emotions are a right-brain output (Right knob alone)' (#24) from feat/affect-right-brain-only into main 2026-06-26 11:29:35 +00:00
BenStullsBets c3aa419c53 feat(sim): emotions are a right-brain output (Right knob alone)
The affect channel previously gated on BOTH knobs (strength = min(left, right)),
so the analytical Left brain co-controlled the emotion words. Per operator
direction, emotions are now driven by the Right (dreamlike) knob ALONE — Left no
longer gates them. This sharpens the split: Left = measurement (detections +
measurements), Right = the dream AND the feeling it evokes.

- player/alteration.py: `_affect_strength(right) = right` (was min(left,right));
  `_affect_intensity` keyed off Right. AffectOverlay + is_identity docs updated
  (is_identity still holds: right>0 ⇒ dream.strength>0).
- simulator/static/app.js: renderAffect comments (gating is Right via `strength`);
  the client logic already keyed min_level/tier off the passed strength + right.
- Affect design spec: revision note + every min(left,right) → right.
- Tests rewritten: affect is Right-driven (full Right + Left 0 surfaces affect;
  Right 0 + Left 4 surfaces none).

271 passed, 2 skipped. Live API verified: left=0/right=4 → strength 4; left=4/
right=0 → strength 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 04:29:21 -07:00
benstull 5bb762ae53 Merge pull request 'fix(sim): content-hash media URLs + fade pool-landing clip swap' (#23) from fix/media-versions-and-pool-landing into main 2026-06-25 20:04:15 +00:00
10 changed files with 243 additions and 59 deletions
+5 -1
View File
@@ -1,7 +1,11 @@
.PHONY: sim sim-local
# Prefer the project venv's interpreter (this box has no bare `python` on PATH),
# fall back to python3, then python.
PYTHON ?= $(firstword $(wildcard .venv/bin/python) python3 python)
sim:
docker compose -f simulator/docker-compose.yml up --build
sim-local:
python -m uvicorn simulator.app:app --reload --port 8000
$(PYTHON) -m uvicorn simulator.app:app --reload --port 8000
+13 -1
View File
@@ -23,11 +23,12 @@ a credit line** (our overlays + dream are derivatives, which these allow);
**CCBYND is NOT usable** (no derivatives); **CCBYNC only if the installation
stays noncommercial**.
## 🌌 cosmos (pool of 4)
## 🌌 cosmos (pool of 5)
| id | clip | source | window (s) | license / credit |
|----|------|--------|-----------|------------------|
| `cosmos` | Cosmic Cliffs — Carina Nebula flythrough | NASA SVS 31348 — https://svs.gsfc.nasa.gov/31348/ (`Clifs-3d-STScI.mp4`, *Exploring the Cosmic Cliffs in 3D*, Webb NIRCam, **4K**) | 1034 | CCBYclass — credit **NASA, ESA, CSA, STScI** |
| `cosmos_miri` | Cosmic Cliffs — Carina Nebula, **mid-infrared** (same towers, muted gray/teal/gold dust palette) | ESA/Webb weic2205c — https://esawebb.org/videos/weic2205c/ (*Pan of the Carina Nebula, combined NIRCam + MIRI*, **4K** `weic2205c.mp4`) | 426 | CCBYclass — credit **NASA, ESA, CSA, STScI** |
> ️ **Cosmos primary swapped to the Webb "Cosmic Cliffs" flythrough (2026-06-25, session 0018).**
> History: the original primary was NASA/JPL's *"Orion: Dust and Death"* (captions
@@ -43,6 +44,17 @@ stays noncommercial**.
| `cosmos_hudf` | Hubble Ultra Deep Field zoom | NASA SVS — https://svs.gsfc.nasa.gov/vis/a030000/a030600/a030687/hudf-b-1920x1080p30.mov | 2044 | CCBYclass — credit **NASA, ESA, STScI** |
| `cosmos_xdf` | eXtreme Deep Field flythrough | NASA SVS — https://svs.gsfc.nasa.gov/vis/a030000/a030600/a030681/hxdf_fly-b-1920x1080p30.mov | 226 | CCBYclass — credit **NASA, ESA, STScI** |
> **`cosmos_miri` added (2026-06-26).** Operator wanted more *Cosmic Cliffs*
> footage and a different-color rendering. The keep-the-flythrough-primary +
> add-the-mid-infrared-pan pair: ESA/Webb's **weic2205c** is the *same* Carina
> towers shot with NIRCam **+ MIRI**, so it renders in a muted gray/teal/gold dust
> palette instead of the iconic orange/blue — a distinct look from the same scene.
> Sourced text-free/human-free from the **4K** master, trim 426s (clears the
> opening fade-from-black, ends before any tail fade) → 1080p base + 4K master,
> 1 s crossfade-loop (~21 s). ESA/Webb's narration-free *"Diving Into the Cosmic
> Cliffs"* pan (`carina_revisit_pan`, NIRCam palette) was the runner-up — left
> out for now since it repeats the existing palette; easy to add if wanted.
> ⚠️ **DISCREPANCY to fix next session.** Operator selected cosmos **#1, #3
> (Orion), #4, #5**. During processing, **#6 Galaxy Traverse was run by mistake
> instead of #3 Orion**. The cosmos primary now lives at
@@ -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)` (04). 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` (04). 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
View File
@@ -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,
@@ -0,0 +1,25 @@
# Session 0019.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-26T04-47 (PST)
> Type: brainstorming
> Posture: careful
> Claude-Session: d6ed1203-92b6-471f-80a4-4c773b645ca1
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
>
> This file reserves session ID 0019 for human-experience-filter-art. The driver replaces this
> body with the full transcript and renames the file to its final
> SESSION-0019.0-TRANSCRIPT-2026-06-26T04-47--<end>.md form at session end.
## Launch prompt
```
Let's start talking about hardware. Let's assume for the first version I'm ok having my computer or laptop do the rendering. We'd still want a controller but it could be connected to the computer via wifi, USB, or bluetooth and would send commands to the software we've already written that would adjust left brain, right brain, etc. We'd prototype this by having a controller simulator run on another device, such as an ipad (just riffing here) but eventually move to a physical remote running on an Arduino or similar
```
## Deferred decisions
_Autonomous-mode low-confidence calls the driver made and would have
liked operator input on. Appended as the session runs; surfaced at
finalize. Empty if none._
+3
View File
@@ -52,5 +52,8 @@
},
"0018": {
"title": ""
},
"0019": {
"title": ""
}
}
+10 -1
View File
@@ -38,7 +38,7 @@ MANIFEST = MEDIA / "manifest.json"
# --- Pools: scale id -> ordered clip ids (primary first) (content-candidate-pool.md) ---
POOLS: dict[str, list[str]] = {
"cosmos": ["cosmos", "cosmos_galaxies", "cosmos_hudf", "cosmos_xdf"],
"cosmos": ["cosmos", "cosmos_miri", "cosmos_galaxies", "cosmos_hudf", "cosmos_xdf"],
"orbit": ["orbit_planetearth", "orbit_crewobs", "orbit_bluemarble"],
"coast": ["coast_birdrock", "coast_surfgrass", "coast_elkbeach", "coast_drakesbeach"],
"reef": ["reef_lionfish", "reef_spawning", "reef_hawkfish", "reef_snapper", "reef_coralspacific"],
@@ -59,6 +59,10 @@ META: dict[str, tuple[str, str, str]] = {
"cosmos": ("Cosmic Cliffs — Carina Nebula flythrough (NASA/ESA/CSA/STScI)", CCBY_WEBB,
"NASA SVS 31348 Clifs-3d-STScI (Exploring the Cosmic Cliffs in 3D, Webb NIRCam); "
"4K source, trim 1034s, crossfade-loop. Text-free — replaces the Orion SVS 30957 flythrough."),
"cosmos_miri": ("Cosmic Cliffs — Carina Nebula, mid-infrared (NASA/ESA/CSA/STScI)", CCBY_WEBB,
"ESA/Webb weic2205c (Pan of the Carina Nebula, combined NIRCam + MIRI); 4K source, "
"trim 426s, crossfade-loop. The MIRI mid-infrared composite — a muted gray/teal/gold "
"dust palette distinct from the NIRCam flythrough, same Cosmic Cliffs towers. Text-free."),
"cosmos_galaxies": ("Flying Through Galaxies (NASA SVS)", PD,
"NASA SVS a014950 14950_Galaxies_FlyThrough_4k; trim 832s, crossfade-loop"),
"cosmos_hudf": ("Hubble Ultra Deep Field zoom (NASA/ESA/STScI)", CCBY_STSCI,
@@ -167,6 +171,11 @@ LABELS: dict[str, list[dict]] = {
static_label("detected.star", 2, ["star", "young star", "protostar", "protostar · a newborn star, <1 Myr old"], [0.54, 0.12, 0.12, 0.14]),
measure("measure.distance", "≈7,500 ly", [0.06, 0.06, 0.2, 0.08], 3),
],
"cosmos_miri": [
static_label("detected.nebula", 4, ["dust", "nebula", "dust ridge", "dust ridge · mid-infrared glow of the Carina cliffs"], [0.10, 0.45, 0.7, 0.45]),
static_label("detected.star", 2, ["star", "young star", "protostar", "protostar · its dust laid bare in mid-infrared"], [0.48, 0.18, 0.14, 0.16]),
measure("measure.distance", "≈7,500 ly", [0.06, 0.06, 0.2, 0.08], 3),
],
"cosmos_galaxies": [
static_label("detected.galaxy", 4, ["galaxy", "spiral galaxy", "barred spiral", "barred spiral · ~10¹¹ stars"], [0.34, 0.30, 0.30, 0.34]),
measure("measure.distance", "~Mly", [0.06, 0.06, 0.18, 0.08], 3),
+116
View File
@@ -115,6 +115,121 @@
}
}
},
{
"id": "cosmos_miri",
"title": "Cosmic Cliffs — Carina Nebula, mid-infrared (NASA/ESA/CSA/STScI)",
"base_file": "cosmos_miri/base.mp4",
"license": "CC-BY-class — credit NASA, ESA, CSA, STScI",
"source": "ESA/Webb weic2205c (Pan of the Carina Nebula, combined NIRCam + MIRI); 4K source, trim 426s, crossfade-loop. The MIRI mid-infrared composite — a muted gray/teal/gold dust palette distinct from the NIRCam flythrough, same Cosmic Cliffs towers. Text-free.",
"right_variants": {},
"annotations": [
{
"key": "detected.nebula",
"salience": 4,
"box": [
0.1,
0.45,
0.7,
0.45
]
},
{
"key": "detected.star",
"salience": 2,
"box": [
0.48,
0.18,
0.14,
0.16
]
},
{
"key": "measure.distance",
"box": [
0.06,
0.06,
0.2,
0.08
],
"min_level": 3
}
],
"affect": [
{
"key": "feel.wonder",
"at": [
0.5,
0.44
],
"min_level": 1
},
{
"key": "feel.vastness",
"at": [
0.2,
0.7
],
"min_level": 2
},
{
"key": "feel.insignificance",
"at": [
0.66,
0.6
],
"min_level": 3
},
{
"key": "feel.longing",
"at": [
0.42,
0.84
],
"min_level": 4
}
],
"strings": {
"en": {
"detected.nebula": [
"dust",
"nebula",
"dust ridge",
"dust ridge · mid-infrared glow of the Carina cliffs"
],
"detected.star": [
"star",
"young star",
"protostar",
"protostar · its dust laid bare in mid-infrared"
],
"measure.distance": "≈7,500 ly",
"feel.wonder": [
"wow",
"wonder",
"awe",
"transcendent awe"
],
"feel.vastness": [
"big",
"vastness",
"immensity",
"a dizzying immensity"
],
"feel.insignificance": [
"small",
"smallness",
"insignificance",
"a humbling insignificance"
],
"feel.longing": [
"want",
"longing",
"yearning",
"an aching yearning"
]
}
}
},
{
"id": "cosmos_galaxies",
"title": "Flying Through Galaxies (NASA SVS)",
@@ -2252,6 +2367,7 @@
"clip_id": "cosmos",
"pool": [
"cosmos",
"cosmos_miri",
"cosmos_galaxies",
"cosmos_hudf",
"cosmos_xdf"
+6 -5
View File
@@ -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)));
}
+23 -21
View File
@@ -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