From 7bf9b51007b8c5cb7d4dc6c7f7a09bd6ca66bab2 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:35:32 +0300 Subject: [PATCH] fix: block influence flatness reads raw push; the switch stays visible and works when the status fails or a file is damaged; a block some clip never measured is left out of the groups; the rated-runs label says what it counts Co-Authored-By: Claude Haiku 5.5 --- app/ui/features/settings/taste.js | 8 +++++--- app/ui/features/settings/taste.test.js | 17 +++++++++++++++++ modules/sampling/block_influence/__init__.py | 2 ++ modules/sampling/block_influence/measure.py | 12 +++++++++--- .../tests/test_block_influence.py | 9 +++++++++ .../block_influence/tests/test_groups.py | 9 +++++++++ 6 files changed, 51 insertions(+), 6 deletions(-) diff --git a/app/ui/features/settings/taste.js b/app/ui/features/settings/taste.js index bd5a457e..b27fb77c 100644 --- a/app/ui/features/settings/taste.js +++ b/app/ui/features/settings/taste.js @@ -74,12 +74,14 @@ export const taste = (app) => function mount() { function drawResearch() { const rows = [c.label.section({ text: "Block influence (research)" })]; if (influence && influence.error) rows.push(c.banner.warn({ text: `Could not read the recording: ${influence.error}` })); + // The switch is always here, even when the report could not be read: a failed read must not take away the way to turn recording off. + rows.push(c.toggle.default({ label: "Record block influence", hint: "On: each run on a Taste key is measured, and kept once you rate it. Off: nothing is recorded.", + checked: influence && influence.state ? !!influence.state.enabled : true, + onChange: async (on) => { try { await app.api.setBlockInfluence(on); } catch (err) { tell(err.message); } refreshResearch(); } })); if (influence && influence.state) { const { state, four } = influence; - rows.push(c.toggle.default({ label: "Record block influence", hint: "On: each run on a Taste key is measured, and kept once you rate it. Off: nothing is recorded.", - checked: !!state.enabled, onChange: async (on) => { try { await app.api.setBlockInfluence(on); } catch (err) { tell(err.message); } refreshResearch(); } })); if (state.problem) rows.push(c.banner.warn({ text: state.problem })); - rows.push(c.settingsRow.default({ label: "Runs measured", hint: `Runs on this key that have a rating: ${state.runs}, of which ${state.skipped} had no usable picture rows`, + rows.push(c.settingsRow.default({ label: "Rated runs kept", hint: `Runs on this key that have a rating: ${state.runs}, of which ${state.skipped} had no usable picture rows`, control: c.hint.default({ text: `${state.used} kept` }) })); if (state.used) rows.push(c.settingsRow.default({ label: "Flatness", hint: "How evenly the blocks move the picture. Near 0 means every block moves it equally, so there is nothing to aim at.", control: c.hint.default({ text: state.flatness == null ? "n/a" : state.flatness.toFixed(3) }) })); diff --git a/app/ui/features/settings/taste.test.js b/app/ui/features/settings/taste.test.js index 75a04d67..74867a95 100644 --- a/app/ui/features/settings/taste.test.js +++ b/app/ui/features/settings/taste.test.js @@ -78,3 +78,20 @@ test("the block influence readout shows flatness, and Clear asks before it clear assert.match(page.node.textContent, /Download/); assert.match(page.node.textContent, /Clear/); }); + +test("a failed status request still shows the switch, so recording can be turned off", async () => { + const sets = []; + const app = { api: { + tasteKeys: async () => ({ keys: [] }), + blockInfluence: async () => { throw new Error("HTTP 500"); }, + blockInfluenceGroups: async () => ({ key: "fox", used: 0, counts: {}, cos: {}, chance: {}, note: "nothing recorded yet" }), + setBlockInfluence: async (on) => { sets.push(on); return { enabled: on }; }, + } }; + const page = taste(app)(); + document.body.append(page.node); + await tick(); await tick(); + const toggle = page.node.querySelector('input[type="checkbox"]'); + assert.ok(toggle, "the switch is still there"); + toggle.click(); await tick(); await tick(); + assert.deepEqual(sets, [false], "the unknown state shows on, so one click turns it off"); +}); diff --git a/modules/sampling/block_influence/__init__.py b/modules/sampling/block_influence/__init__.py index 82fbc46c..d0f7e2ce 100644 --- a/modules/sampling/block_influence/__init__.py +++ b/modules/sampling/block_influence/__init__.py @@ -129,6 +129,8 @@ def routes(table, base, web): state = measure.state(key_of(req, body)) # refuse BEFORE flipping the switch except ValueError as exc: return web.json_response({"why": str(exc)}, status=400) + except Exception: # a damaged file must not keep the switch from going off + state = {} measure.set_enabled(body.get("enabled") is True) return web.json_response({**state, "enabled": measure.enabled()}) diff --git a/modules/sampling/block_influence/measure.py b/modules/sampling/block_influence/measure.py index bc43a708..f0a0c8f9 100644 --- a/modules/sampling/block_influence/measure.py +++ b/modules/sampling/block_influence/measure.py @@ -106,7 +106,7 @@ def profile(rows) -> dict: `overall` is where the model does its work regardless of ratings; `share` is each block's RAW push as a fraction of all blocks' (rank by this: the ratio shrinks with depth by construction, since the stream grows); `difference` is - liked minus disliked; `flatness` is the spread of `overall` as a fraction of + liked minus disliked; `flatness` is the spread of RAW push as a fraction of its mean -- near 0 means every block moves the stream equally and there is nothing to aim at. `novelty` ~1: a block amplifies its predecessor, ~0 it adds something new, negative it partly undoes it. @@ -135,7 +135,9 @@ def profile(rows) -> dict: difference = None if int(liked.sum()) >= MIN_PER_GROUP and int(disliked.sum()) >= MIN_PER_GROUP: difference = table(mean("ratio", liked) - mean("ratio", disliked)) - vals = overall[torch.isfinite(overall)] + # Flatness is read on RAW push: the ratio falls with depth by construction (the stream grows), so + # a flat model would read as uneven on it. + vals = raw[torch.isfinite(raw)] flat = float(vals.std(unbiased=False) / vals.mean()) if len(vals) and abs(float(vals.mean())) > 1e-12 else None nov = novelty[torch.isfinite(novelty)] return {"used": len(rows), "overall": table(overall), "difference": difference, "share": share, @@ -170,7 +172,11 @@ def groups(rows, shuffles=200, seed=0) -> dict: n = len(rows[-1]["rows"]["ratio"]) rows = [r for r in rows if len(r["rows"]["ratio"]) == n] stack = torch.stack([r["rows"]["ratio"].float() for r in rows]) - stack = torch.where(torch.isfinite(stack), stack, torch.nanmean(stack, dim=0).nan_to_num(0.0)) + # A block some clip never measured is left out of every group, not filled in with a guess. + stack = stack[:, torch.isfinite(stack).all(dim=0)] + if stack.shape[1] == 0: + return {"used": len(rows), "counts": {g: sum(1 for r in rows if _group(r) == g) for g in GROUPS}, + "cos": {}, "note": "no block was measured on every clip"} labels = [_group(r) for r in rows] counts = {g: labels.count(g) for g in GROUPS} diff --git a/modules/sampling/block_influence/tests/test_block_influence.py b/modules/sampling/block_influence/tests/test_block_influence.py index 777dcae5..1fac5953 100644 --- a/modules/sampling/block_influence/tests/test_block_influence.py +++ b/modules/sampling/block_influence/tests/test_block_influence.py @@ -349,3 +349,12 @@ def test_the_panel_says_a_taste_key_is_needed_even_when_toggled_on_after_install assert "Taste key" in measure.state("default")["problem"] _load(tiny_h3) # a key is set: the banner goes assert measure.state("default")["problem"] is None + + +def test_flatness_reads_raw_push_so_a_growing_stream_with_equal_pushes_is_flat(): + from modules.sampling.block_influence.measure import profile + # every block pushes 1.0 raw, but the stream grows with depth: the ratio falls, the raw push does not + ratio = [1.0 / (1.0 + i * 0.5) for i in range(10)] + rows = [{"reward": w, "rows": {"ratio": torch.tensor(ratio), "raw": torch.ones(10), + "novelty": torch.full((10,), 0.5)}} for w in (1.0, 1.0, -1.0, -1.0)] + assert profile(rows)["flatness"] == pytest.approx(0.0, abs=1e-6) diff --git a/modules/sampling/block_influence/tests/test_groups.py b/modules/sampling/block_influence/tests/test_groups.py index ebfdc6bb..ca108e6e 100644 --- a/modules/sampling/block_influence/tests/test_groups.py +++ b/modules/sampling/block_influence/tests/test_groups.py @@ -60,3 +60,12 @@ def test_the_panel_report_reads_the_key_and_says_its_counts(monkeypatch): assert out["key"] == "fox" and out["used"] == 6 assert out["counts"]["image"] == 2 and out["counts"]["composition"] == 2 assert "composition vs image" in out["cos"] + + +def test_a_block_some_clip_never_measured_is_left_out_not_guessed(): + nan = float("nan") + rows = [_row(1, None, [1, 0, nan]), _row(1, None, [1, 0, nan]), _row(-1, "image", [0, 1, nan]), + _row(-1, "image", [0, 1, nan]), _row(-1, "composition", [0, -1, 0.5]), _row(-1, "composition", [0, -1, 0.5])] + out = measure.groups(rows, shuffles=10) + assert out["cos"], "the blocks everyone measured still compare" + assert out["counts"]["composition"] == 2