diff --git a/.gitignore b/.gitignore index 6f711279..43cd6286 100644 --- a/.gitignore +++ b/.gitignore @@ -138,3 +138,6 @@ uv.lock # mixed-case variants from slipping through on case-insensitive machines. /[Pp][Rr][Oo][Mm][Pp][Tt]_[Ss][Ee][Ss][Ss][Ii][Oo][Nn]_[Tt][Rr][Aa][Nn][Ss][Cc][Rr][Ii][Pp][Tt]_[Aa][Nn][Aa][Ll][Yy][Ss][Ii][Ss].md /[Ss][Ee][Ss][Ss][Ii][Oo][Nn]_[Tt][Rr][Aa][Nn][Ss][Cc][Rr][Ii][Pp][Tt]_[Qq][Uu][Ee][Rr][Yy]_[Pp][Rr][Oo][Mm][Pp][Tt].md + +# Command Code per-session scratchpad (agent working files, never package content). +/$COMMANDCODE_SCRATCHPAD/ diff --git a/engraphis/classic_assets/index.html b/engraphis/classic_assets/index.html index 74d9bf23..fcca95be 100644 --- a/engraphis/classic_assets/index.html +++ b/engraphis/classic_assets/index.html @@ -6,7 +6,7 @@ Engraphis - + @@ -349,6 +349,6 @@ graph view. dashboard.js fetches both on demand from graphRender(); see loadForceGraph() and loadGraphEngine(). scripts/externalize_dashboard_assets.py enforces both halves: they stay out of this file, and the lazy references still have to resolve. --> - + diff --git a/engraphis/dashboard_assets/engraphis-graph.js b/engraphis/dashboard_assets/engraphis-graph.js index ed83da91..df5f3690 100644 --- a/engraphis/dashboard_assets/engraphis-graph.js +++ b/engraphis/dashboard_assets/engraphis-graph.js @@ -7501,6 +7501,7 @@ saved phase byte-for-byte after the render's safety projections. */ let galaxyPhaseRestorePending = false; let preserveGalaxyPhaseOnResume = false; + let galaxyContactCorrectionDeferred = false; let adj = Object.create(null), liveAdj = Object.create(null), hilite = null, hoverSet = null, maxDeg = 1; let legacySizeBy = 'degree'; // The classic renderer treats label density as a hard ranked cap, not merely a looser @@ -8040,6 +8041,7 @@ setSimulationBudget(false, true); } + function applyForces() { /* Extremely large complete snapshots use the deterministic fallback, but a normal full graph remains a live layout. The previous `renderMode === 'full'` guard removed @@ -8130,15 +8132,16 @@ large) is the neutral settling behaviour, so the slider's effect is a *multiplier* on that baseline, not a replacement. Above 1 the layout settles harder, below 1 it stays more elastic. */ + /* Space friction (the dashboard's "damping" slider) maps onto d3's velocityDecay. + The slider's 0..15 visible range must reach the full d3 decay range so the lower + quarter is not inert. At the default (slider=1) the size-aware baseline (0.38 small + / 0.45 large) is the neutral settling behaviour, so the slider's effect is a + *multiplier* on that baseline, not a replacement. Above 1 the layout settles + harder, below 1 it stays more elastic, down to the d3 floor 0.05 at 0. */ if (fg.d3VelocityDecay) { const dampingRaw = Number(state.settings.damping); const damping = Number.isFinite(dampingRaw) ? clamp(dampingRaw, 0, 15) : 1; const baseline = large ? 0.45 : 0.38; - /* Linearly interpolate between the d3 velocityDecay floor (0.05) at damping=0, - the size-aware baseline at damping=1, and the d3 velocityDecay ceiling (0.85) - at damping=15. The full 0..15 visible range is now meaningful, and the default - (damping=1) keeps the size-aware settling behaviour the rest of the engine - already assumes. */ const floor = 0.05; const ceiling = 0.85; const target = damping <= 1 @@ -9268,6 +9271,7 @@ behaviour synchronous while browsers coalesce a burst of range-input events. */ if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') { physicsReheatPending = false; + galaxyContactCorrectionDeferred = false; render(false, true); return; } @@ -9282,7 +9286,13 @@ physicsFrame = 0; if (destroyed || suspended || !physicsReheatPending) return; physicsReheatPending = false; - if (phaseLock) preserveGalaxyPhaseOnResume = true; + if (phaseLock) { + preserveGalaxyPhaseOnResume = true; + /* Apply the painted-edge projection once, after the browser has coalesced the + complete input burst. Intermediate projections would make the final layout depend + on how many range-input events happened before this frame. */ + galaxyContactCorrectionDeferred = false; + } render(false, true); }); } @@ -9494,7 +9504,7 @@ envelopeRadius: galaxyLastFarFieldConfinement.envelopeRadius, softRadius: galaxyLastFarFieldConfinement.softRadius, samples: 0, acceleratedSystems: 0, acceleratedCoreNodes: 0, - acceleratedFixedFollowers: 0, maximumAcceleration: 0, + acceleratedFixedSource: 0, acceleratedFixedFollowers: 0, maximumAcceleration: 0, }; const postOuterHorizon = applyGalaxyBlackHoleExclusion( data.nodes, { padding: GALAXY_BLACK_HOLE_EXCLUSION_PADDING } @@ -9516,6 +9526,60 @@ galaxyLastBlackHoleExclusion = combineGalaxyBlackHoleExclusions( [prePaintHorizon, postOuterHorizon, postStarHorizon] ); + } else if (reused && galaxyMode && skipGalaxyReseed) { + /* The slider burst just rescaled carriers and their satellites by a known ratio. + The phase-preserving contact pass is deferred until the coalesced frame below. */ + const skippedAnchor = galaxyGlobalAnchor(data.nodes); + if (galaxyContactCorrectionDeferred) { + /* A range-input burst can issue several synchronous settings updates before the + scheduled browser frame. Preserve the complete multiplicative response first, then + project once in that final frame. This keeps the painted-edge invariant while + making a single jump and an equivalent fine sweep converge to the same state. */ + galaxyLastSystemAnchorExclusion = combineGalaxySystemAnchorExclusions([{ + padding: GALAXY_SYSTEM_ANCHOR_EXCLUSION_PADDING, + systems: 0, contacts: 0, correctedDistance: 0, maximumShift: 0, + inwardVelocityRemoved: 0, tangentialVelocityRemoved: 0, + minimumClearance: null, iterations: 0, + }]); + galaxyLastBlackHoleExclusion = combineGalaxyBlackHoleExclusions([{ + anchorId: skippedAnchor ? skippedAnchor.id : null, + contacts: 0, systems: 0, coreNodes: 0, fixedSystemNodes: 0, + repelledNodes: 0, correctedDistance: 0, maximumShift: 0, + inwardVelocityRemoved: 0, tangentialVelocityRemoved: 0, + minimumClearance: null, + }]); + } else { + /* The final scheduled frame still enforces both painted contact boundaries, including + while the Galaxy clock is frozen or orbit-paused. */ + const prePaintHorizon = applyGalaxyBlackHoleExclusion( + data.nodes, { padding: GALAXY_BLACK_HOLE_EXCLUSION_PADDING } + ); + const preStarExclusion = applyGalaxySystemAnchorExclusion(data.nodes, { + padding: GALAXY_SYSTEM_ANCHOR_EXCLUSION_PADDING, + fixAnchors: true, + }); + const postStarExclusion = applyGalaxySystemAnchorExclusion(data.nodes, { + padding: GALAXY_SYSTEM_ANCHOR_EXCLUSION_PADDING, + fixAnchors: true, + }); + const postPaintHorizon = applyGalaxyBlackHoleExclusion( + data.nodes, { padding: GALAXY_BLACK_HOLE_EXCLUSION_PADDING } + ); + galaxyLastSystemAnchorExclusion = combineGalaxySystemAnchorExclusions( + [preStarExclusion, postStarExclusion] + ); + galaxyLastBlackHoleExclusion = combineGalaxyBlackHoleExclusions( + [prePaintHorizon, postPaintHorizon] + ); + } + galaxyLastFarFieldConfinement = galaxyLastFarFieldConfinement || { + anchorId: skippedAnchor ? skippedAnchor.id : null, envelopeRadius: 0, softRadius: 0, + acceleratedSystems: 0, boundedSystems: 0, boundedCoreNodes: 0, + boundedFixedSource: 0, boundedFixedFollowers: 0, boundedDeformedSystems: 0, + boundedOversizedNodes: 0, correctedDistance: 0, maximumShift: 0, + outwardVelocityRemoved: 0, tangentialVelocityRemoved: 0, + annulus: { innerCorrectedNodes: 0, outerCorrectedNodes: 0, infeasibleNodes: 0 }, + }; } applyForces(); fg.autoPauseRedraw(!needsContinuousFrames()); @@ -9526,19 +9590,6 @@ /* D3 is only the renderer in Galaxy mode. Its alpha, velocity decay and countdown are intentionally untouched; the fixed-step clock owns all three physical concerns. */ if (!galaxyMode && fg.d3AlphaDecay) fg.d3AlphaDecay(staticFullLayout ? 1 : alphaDecay()); - if (!galaxyMode && fg.d3VelocityDecay) { - /* applyForces() above already installed the user-facing damping slider value. The - size-aware baseline (0.38 small / 0.45 large) is only the *default* when the user - has not touched the slider, so this fallback must not clobber a value the user has - already set. The proxy in the test harness (and the real force-graph) returns the - same function for any property access, so we cannot ask "was the setter called?" — - instead we honour the slider's value whenever it is finite, and only fall back to - the size-aware baseline when the dashboard never supplied a damping value. */ - const dampingSetting = Number(state.settings.damping); - if (!Number.isFinite(dampingSetting)) { - fg.d3VelocityDecay(large ? 0.45 : 0.38); - } - } if (fg.linkCurvature) { fg.linkCurvature(dense ? 0 : ((PRESETS[state.settings.mode] || PRESETS.compact).curve || 0)); } @@ -10187,6 +10238,7 @@ || next.blackHoleMass !== undefined || next.damping !== undefined || next.springStiffness !== undefined)) { preserveGalaxyPhaseOnResume = true; + galaxyContactCorrectionDeferred = true; } if (gravityChanged && previousMode === 'galaxy' && state.settings.mode === 'galaxy') { /* Gravity changes need an immediate, legible density response: a range control whose @@ -10271,6 +10323,13 @@ preserveGalaxyPhaseOnResume = true; } render(false, false); + /* Re-arm the phase-preserve flag for the synchronous physics reheat that follows. That + reheat shares the same render path and would otherwise run contact corrections on the + post-scaling layout, undoing the slider's burst response and breaking path + independence across burst intermediates. */ + if (gravityChanged && previousMode === 'galaxy' && state.settings.mode === 'galaxy') { + preserveGalaxyPhaseOnResume = true; + } if (layoutChanged) schedulePhysicsUpdate(); }; api.setPreset = name => { diff --git a/engraphis/static/index.html b/engraphis/static/index.html index 7343ede4..486db6ef 100644 --- a/engraphis/static/index.html +++ b/engraphis/static/index.html @@ -349,6 +349,6 @@ graph view. dashboard.js fetches both on demand from graphRender(); see loadForceGraph() and loadGraphEngine(). scripts/externalize_dashboard_assets.py enforces both halves: they stay out of this file, and the lazy references still have to resolve. --> - + diff --git a/tests/e2e/graph-engine.spec.js b/tests/e2e/graph-engine.spec.js index 1b7db89f..93adf5d8 100644 --- a/tests/e2e/graph-engine.spec.js +++ b/tests/e2e/graph-engine.spec.js @@ -2783,6 +2783,48 @@ test('served primary dashboard keeps local stellar orbits independent at Galaxy- expect(session.pageErrors).toEqual([]); }); +test('served non-Galaxy spacetime controls keep their full normalized range', async ({ page }) => { + const session = await openDashboard(page); + await page.goto('/'); + await page.locator('.nav-item[data-view="relations"]').click(); + await expect(page.locator('#graph-canvas canvas').first()).toBeAttached({ timeout: 20_000 }); + await page.locator('[data-graph-preset-choice="compact"]').click(); + await page.waitForFunction(() => window.__engraphisGraph + && window.__engraphisGraph.state().settings.mode === 'compact' + && window.__fg); + + const report = await page.evaluate(() => { + const set = (id, value) => { + const control = document.getElementById(id); + control.value = String(value); + control.dispatchEvent(new Event('input', { bubbles: true })); + }; + const mass = [20, 40, 160, 460, 500].map(value => { + set('graph-black-hole-mass', value); + return { value, multiplier: window.__engraphisGraph.state().settings.blackHoleMass }; + }); + const damping = [1, 15].map(value => { + set('graph-space-damping', value); + return { value, decay: window.__fg.d3VelocityDecay() }; + }); + return { mass, damping }; + }); + + expect(report.mass).toEqual([ + { value: 20, multiplier: 0.125 }, + { value: 40, multiplier: 0.25 }, + { value: 160, multiplier: 1 }, + { value: 460, multiplier: 4 }, + { value: 500, multiplier: 4.4 }, + ]); + /* The merged engine interpolates damping onto d3's velocityDecay with a size-aware + baseline: damping=0 -> 0.05 floor, damping=1 -> the neutral settling baseline + (0.38 small / 0.45 large), damping=15 -> 0.85 ceiling. This served graph is small. */ + expect(report.damping[0].decay).toBeCloseTo(0.38, 12); + expect(report.damping[1].decay).toBeCloseTo(0.85, 12); + expect(session.pageErrors).toEqual([]); +}); + test('Galaxy motion is 50 percent faster while core perturbation stays bound', async ({ page }) => { await openDashboard(page, { query: '?graph-engine=next' }); await openGraphView(page); diff --git a/tests/test_galaxy_gravity_floor.py b/tests/test_galaxy_gravity_floor.py index 4a110a3b..c1322f1d 100644 --- a/tests/test_galaxy_gravity_floor.py +++ b/tests/test_galaxy_gravity_floor.py @@ -185,7 +185,9 @@ def test_gravity_slider_every_integer_changes_carrier_radius_strictly() -> None: # Coarse contract: the loose endpoint (s=0) must be substantially looser than the # full tight endpoint (s=400, the user's "tight"). The slider's full range is # 0..400 (HTML min/max); 96 is the preset baseline, NOT the tight end. - # Contract: loose radius ≥ 1.5x tight radius (i.e. at least 33% contraction). + # The final painted-edge contact projection can cap the tight endpoint when this + # fixture's black-hole horizon intersects a contracted carrier. Keep a substantial + # 20% contraction contract after that invariant is enforced. report_full = _run( """ const scene = """ + json.dumps(SCENE) + """; @@ -206,15 +208,10 @@ def test_gravity_slider_every_integer_changes_carrier_radius_strictly() -> None: ) loose = report_full["loose"] tight = report_full["tight"] - # 1.25x calibration: the merged renderer (PR spacetime feature + main floor removal) - # produces ~1.30x loose/tight contraction across the full 0..400 travel. The floor - # removal contract is that every tick is distinct AND the full travel produces a - # substantial visible contraction; 1.25x leaves regression headroom below the - # measured 1.30x while still catching any plateau collapse (the old bug was 1.0x). assert loose >= tight * 1.25, ( f"Slider 0 (loose) vs 400 (tight): radii {loose:.2f} vs {tight:.2f}; " f"the slider should contract carriers by at least 20% across its full " - f"range (loose >= 1.25x tight), but the ratio is only " + f"range (loose >= 1.25x tight) after contact projection, but the ratio is only " f"{loose / tight if tight > 0 else float('inf'):.2f}x. The combined " f"response-mapping + floor-removal fix should let the slider's full " f"0..400 travel produce a substantial visible contraction." @@ -287,4 +284,4 @@ def test_gravity_slider_global_field_unfloored_zero_loose_endpoint() -> None: f"tight endpoint should produce a substantially stronger central force " f"than the loose endpoint; the renderer's prior floor plateau collapsed " f"both ends to roughly the same force." - ) \ No newline at end of file + ) diff --git a/tests/test_graph_engine_asset.py b/tests/test_graph_engine_asset.py index 08ad5ef7..f39ccfb1 100644 --- a/tests/test_graph_engine_asset.py +++ b/tests/test_graph_engine_asset.py @@ -10826,6 +10826,119 @@ def test_spacetime_sliders_reach_d3_forces_in_non_galaxy_mode() -> None: +def test_black_hole_mass_reaches_centering_forces_in_every_non_galaxy_layout() -> None: + """Black-hole mass must modulate the D3 centering strength in every non-Galaxy layout. + + The normalized engine value arrives in ``applyForces()`` as ``massMultiplier`` + (clamped to the adapter's full 0.125..4.4 interval). Communities and radial consumed it, + but compact/original (the default overview branch) and constellation ignored it, so + dragging the Black hole mass slider changed nothing visible in those modes (PR #185, + thread 3902779917). This pins the multiplier on the x/y centering forces with the d3 + stubs the neighboring tests lack. + """ + report = _run_engine( + """ + const bodyForce = () => ({ strength(value) { this.value = value; return this; } }); + globalThis.d3 = { + forceManyBody: bodyForce, + forceLink: () => ({ + id(value) { this.idValue = value; return this; }, + distance(value) { this.value = value; return this; }, + strength(fn) { this.strengthValue = fn; return this; }, + }), + forceX: target => ({ target, strength(value) { this.value = value; return this; } }), + forceY: target => ({ target, strength(value) { this.value = value; return this; } }), + forceCollide: () => ({ iterations(value) { this.value = value; return this; } }), + forceRadial: radius => ({ radius, strength(value) { this.value = value; return this; } }), + }; + const api = G.create(el, {}); + const axes = () => { + const f = store.d3Forces || {}; + return [f.x && f.x.value, f.y && f.y.value]; + }; + const sampled = {}; + for (const mode of ['compact', 'constellation']) { + api.setPreset(mode); + api.setData(chain(6)); + api.setSettings({ gravity: 98, blackHoleMass: 1 }); + sampled[mode] = { + weak: axes(), + blackHoleMassWeak: api.state().settings.blackHoleMass, + }; + api.setSettings({ blackHoleMass: 400 }); + sampled[mode].strong = axes(); + sampled[mode].blackHoleMassStrong = api.state().settings.blackHoleMass; + } + emit(sampled); + """ + ) + for mode in ('compact', 'constellation'): + entry = report[mode] + assert entry['blackHoleMassWeak'] == pytest.approx(1.0) + assert entry['blackHoleMassStrong'] == pytest.approx(16.0) + weak_x, weak_y = entry['weak'] + strong_x, strong_y = entry['strong'] + assert weak_x is not None and weak_y is not None, f"{mode}: x/y forces missing" + base = 0.98 if mode == 'compact' else 0.18 + # The centering strength must carry the mass multiplier: mass 1 -> 1.0x, + # mass 400 normalizes to engine setting 16; applyForces clamps the + # multiplier at the adapter's 4.4 ceiling, so the response saturates there. + assert weak_x == pytest.approx(base) + assert weak_y == pytest.approx(base) + assert strong_x == pytest.approx(base * 4.4) + assert strong_y == pytest.approx(base * 4.4) + + + +def test_slider_burst_reasserts_contact_invariant_when_galaxy_is_frozen() -> None: + """A phase-preserving slider render must still repair painted contact penetrations.""" + report = _run_engine( + """ + const api = G.create(el, { reducedMotion: () => false }); + api.setPreset('galaxy'); + api.setData({ + nodes: [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + system_anchor_id: 'black-hole', gravity_mass: 64, visual_radius: 8, + x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'star', anchor_role: 'community', community_id: 'outer', + system_anchor_id: 'star', gravity_mass: 8, visual_radius: 5, + x: 120, y: 0, vx: 0, vy: 0 }, + { id: 'planet', community_id: 'outer', system_anchor_id: 'star', + orbit_tier: 1, gravity_mass: 1, visual_radius: 3, + x: 150, y: 0, vx: 0, vy: 0 }, + ], + edges: [{ source: 'star', target: 'planet', layer: 'entity' }], + }); + api.freeze(true); + const nodes = store.graphData.nodes; + const anchor = nodes.find(node => node.id === 'black-hole'); + const star = nodes.find(node => node.id === 'star'); + const planet = nodes.find(node => node.id === 'planet'); + star.x = 0; star.y = 0; star.vx = 0; star.vy = 0; + planet.x = 1; planet.y = 0; planet.vx = 0; planet.vy = 0; + /* Size is a layout key and therefore takes the phase-preserving slider path while the + clock is frozen. The final contact projection must still run synchronously. */ + api.setSettings({ size: 4 }); + const diagnostics = api.physicsDiagnostics(); + emit({ contacts: diagnostics.blackHoleExclusion.contacts, + starClearance: Math.hypot(star.x - anchor.x, star.y - anchor.y) + - anchor.radius - star.radius - diagnostics.blackHoleExclusionPadding, + planetClearance: Math.hypot(planet.x - star.x, planet.y - star.y) + - star.radius - planet.radius - diagnostics.systemAnchorExclusion.padding, + frozen: diagnostics.frozen, + finite: nodes.every(node => + [node.x, node.y, node.vx, node.vy].every(Number.isFinite)) }); + """ + ) + assert report["frozen"] is True + assert report["contacts"] > 0 + assert report["starClearance"] >= -1e-9 + assert report["planetClearance"] >= -1e-9 + assert report["finite"] is True + + + @requires_node def test_full_graph_within_the_force_budget_keeps_centre_gravity_live() -> None: """Full mode must not turn a normal large workspace into a pinned, inert ring. diff --git a/tests/test_slider_response.py b/tests/test_slider_response.py new file mode 100644 index 00000000..69926f84 --- /dev/null +++ b/tests/test_slider_response.py @@ -0,0 +1,111 @@ +"""Pin the production slider response curve: strictly monotone, no dead zones. + +Extracts ``graphSliderResponseValue`` from the shipped ``ledger.js`` (the same +function the browser runs), drives it across the full 0..400 HTML range, and +asserts every raw value maps to a unique, strictly increasing effective value. +Any dead zone (multiple raw values collapsing to one effective value) or +saturation plateau fails the test — this is the exact regression the floor +removal fixed. +""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +LEDGER = ROOT / "engraphis" / "dashboard_assets" / "ledger.js" + +NODE = shutil.which("node") +requires_node = pytest.mark.skipif(NODE is None, reason="node is not installed") + +PRELUDE = """ +const emit = value => console.log(JSON.stringify(value)); +""" + + +def _extract_function(source: str, name: str) -> str: + """Pull a top-level ``function (...) {...}`` body via brace matching.""" + start = source.index(f"function {name}(") + depth = 0 + i = start + while i < len(source): + c = source[i] + if c == '{': + depth += 1 + elif c == '}': + depth -= 1 + if depth == 0: + break + i += 1 + return source[start:i + 1] + + +@requires_node +def test_slider_response_is_strictly_monotone_without_dead_zones() -> None: + """Every raw slider value 0..400 must map to a unique effective value. + + The production mapping is extracted from the shipped ledger.js, not + re-implemented, so any regression (dead zone, floor, saturation) is + caught against the real code the browser runs. + """ + src = LEDGER.read_text(encoding="utf-8") + response_fn = _extract_function(src, "graphSliderResponseValue") + in_range_fn = _extract_function(src, "graphValueInRange") + + gain_match = src.rfind("GRAPH_SLIDER_RESPONSE_PEAK_GAIN") + if gain_match == -1: + peak_gain = 1 + else: + # Evaluate the constant the same way ledger.js defines it. + m = None + for chunk in src.split(';'): + if 'GRAPH_SLIDER_RESPONSE_PEAK_GAIN' in chunk and '=' in chunk: + m = chunk.split('=', 1)[1].strip() + break + peak_gain = float(m) if m and m.replace('.', '', 1).isdigit() else 1 + + script = ( + "const GRAPH_SLIDER_RESPONSE_PEAK_GAIN = " + + json.dumps(peak_gain) + ";\n" + + "const byId = id => ({ value: '96', min: '0', max: '400' });\n" + + in_range_fn + "\n" + + response_fn + "\n" + + """ +const samples = []; +for (let s = 0; s <= 400; s += 1) { + const raw = String(s); + const inRange = graphValueInRange('graph-gravity', raw, 96); + const eff = graphSliderResponseValue('graph-gravity', inRange, 96); + samples.push({ s, inRange, eff }); +} +emit({ samples }); +""" + ) + + result = subprocess.run( + [NODE, "-e", PRELUDE + script, str(LEDGER)], + cwd=ROOT, capture_output=True, text=True, check=False, + ) + assert result.returncode == 0, result.stderr + report = json.loads(result.stdout.strip().splitlines()[-1]) + samples = report["samples"] + + # 1. Full range reaches the engine verbatim (clamped, no floor). + assert len(samples) == 401 + assert samples[0]["eff"] == 0, f"slider 0 must map to 0, got {samples[0]}" + assert samples[-1]["eff"] == 400, ( + f"slider 400 must map to 400, got {samples[-1]}") + + # 2. Strictly monotone: no dead zone anywhere in 0..400. + for i in range(1, len(samples)): + prev, cur = samples[i - 1], samples[i] + assert cur["eff"] > prev["eff"], ( + f"dead zone / non-monotone at raw {prev['s']}->{cur['s']}: " + f"effective {prev['eff']} -> {cur['eff']}") + + # 3. No saturation plateau at the top end (the old 2x gain clipped at 200). + unique_eff = {s["eff"] for s in samples} + assert len(unique_eff) == 401, ( + f"expected 401 unique effective values, got {len(unique_eff)}")