diff --git a/app/ui/features/settings/taste.js b/app/ui/features/settings/taste.js index 7627956d..17c04754 100644 --- a/app/ui/features/settings/taste.js +++ b/app/ui/features/settings/taste.js @@ -45,6 +45,38 @@ export const taste = (app) => function mount() { c.dropzone.default({ label: "Drop or choose an exported key (.zip)", hint: "from another machine", accept: ".zip,application/zip", multiple: false, onFiles: bring }), ].filter(Boolean)); } - draw(); refresh(); - return { node: page.node, destroy: () => page.node.remove() }; + // Block influence: research recording, read here. Its numbers say whether the four groups point apart; no clip changes. + const research = c.region.stack({ gap: "sm" }); + let influence = null; + const pct = (x) => (x >= 0 ? "+" : "") + x.toFixed(2); + async function refreshResearch() { + try { + const [state, four] = await Promise.all([app.api.blockInfluence("default"), app.api.blockInfluenceGroups("default")]); + influence = { state, four, error: "" }; + } catch (err) { influence = { state: null, four: null, error: err.message }; } + drawResearch(); + } + 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}` })); + if (influence && influence.state) { + const { state, four } = influence; + rows.push(c.toggle.default({ label: "Record block influence", hint: "On: every rated run on a Taste key is measured. 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: "Clips rated", hint: `Key: ${four.key || state.key || "none yet"}`, + control: c.hint.default({ text: four.used ? Object.entries(four.counts || {}).map(([g, n]) => `${g} ${n}`).join(", ") : "none yet" }) })); + if (four.note) rows.push(c.hint.default({ text: four.note })); + for (const [pair, cos] of Object.entries(four.cos || {})) { + const c0 = four.chance[pair]; + rows.push(c.settingsRow.default({ label: pair, hint: `Chance from shuffled labels: mean ${pct(c0.mean)}, 95th ${pct(c0.p95)}`, + control: c.hint.default({ text: pct(cos) }) })); + } + } + research.set(rows); + } + draw(); refresh(); refreshResearch(); + const node = document.createElement("div"); + node.append(page.node, research.node); + return { node, destroy: () => node.remove() }; }; diff --git a/app/ui/features/settings/taste.test.js b/app/ui/features/settings/taste.test.js new file mode 100644 index 00000000..8549382e --- /dev/null +++ b/app/ui/features/settings/taste.test.js @@ -0,0 +1,44 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { setupDom, teardownDom } from "../../composer/tests/_dom.js"; + +let taste; +test.before(async () => { setupDom(); ({ taste } = await import("./taste.js")); }); +test.after(() => teardownDom()); +const tick = () => new Promise((r) => setTimeout(r, 0)); + +test("the panel shows block influence: the switch, the clip counts, and each pair against chance", async () => { + const sets = []; + const app = { api: { + tasteKeys: async () => ({ keys: [] }), + blockInfluence: async () => ({ key: "fox", enabled: true, problem: null }), + blockInfluenceGroups: async () => ({ key: "fox", used: 8, counts: { liked: 2, image: 2, composition: 2, both: 2 }, + cos: { "image vs composition": 0.5 }, chance: { "image vs composition": { mean: 0.02, p95: 0.4 } } }), + setBlockInfluence: async (on) => { sets.push(on); return { enabled: on }; }, + } }; + const page = taste(app)(); + document.body.append(page.node); + await tick(); await tick(); + const text = page.node.textContent; + assert.match(text, /Record block influence/); + assert.match(text, /liked 2, image 2, composition 2, both 2/); + assert.match(text, /image vs composition/); + assert.match(text, /\+0\.50/); + assert.match(text, /95th \+0\.40/); + const toggle = page.node.querySelector('input[type="checkbox"]'); + assert.equal(toggle.checked, true); + toggle.click(); await tick(); await tick(); + assert.deepEqual(sets, [false]); +}); + +test("nothing recorded yet is said, not shown as a comparison", async () => { + const app = { api: { + tasteKeys: async () => ({ keys: [] }), + blockInfluence: async () => ({ key: "none", enabled: false, problem: null }), + blockInfluenceGroups: async () => ({ key: "none", used: 0, counts: {}, cos: {}, chance: {}, note: "nothing recorded yet" }), + } }; + const page = taste(app)(); + document.body.append(page.node); + await tick(); await tick(); + assert.match(page.node.textContent, /nothing recorded yet/); +}); diff --git a/app/ui/shell/api.js b/app/ui/shell/api.js index c6d8c671..06dcc8a3 100644 --- a/app/ui/shell/api.js +++ b/app/ui/shell/api.js @@ -54,6 +54,9 @@ export const api = { shotMemory: () => call("GET", "/api/m/conditioning_shot_camera/memory"), forgetShotMemory: (kind, name) => call("POST", "/api/m/conditioning_shot_camera/forget", { kind, name }), tasteKeys: () => call("GET", "/api/m/taste/keys"), + blockInfluence: (key) => call("GET", `/api/m/block_influence/status?key=${encodeURIComponent(key || "default")}`), + blockInfluenceGroups: (key) => call("GET", `/api/m/block_influence/groups?key=${encodeURIComponent(key || "default")}`), + setBlockInfluence: (on) => call("POST", `/api/m/block_influence/enabled?key=default`, { enabled: !!on }), libraryFiles: () => call("GET", "/api/files"), deleteLibraryFile: (name) => call("DELETE", `/api/files/${encodeURIComponent(name)}`), deleteTasteKey: (name) => call("DELETE", `/api/m/taste/keys/${encodeURIComponent(name)}`), diff --git a/modules/sampling/block_influence/__init__.py b/modules/sampling/block_influence/__init__.py index eab2f529..1897c31b 100644 --- a/modules/sampling/block_influence/__init__.py +++ b/modules/sampling/block_influence/__init__.py @@ -114,6 +114,13 @@ def routes(table, base, web): except ValueError as exc: return web.json_response({"why": str(exc)}, status=400) + @table.get(base + "/groups") + async def _groups(req): + try: + return web.json_response(measure.four_groups(key_of(req))) + except ValueError as exc: + return web.json_response({"why": str(exc)}, status=400) + @table.post(base + "/enabled") async def _enabled(req): body = await req.json() diff --git a/modules/sampling/block_influence/measure.py b/modules/sampling/block_influence/measure.py index 3fe6c9cc..bc43a708 100644 --- a/modules/sampling/block_influence/measure.py +++ b/modules/sampling/block_influence/measure.py @@ -234,6 +234,12 @@ def state(key, fallback=True) -> dict: **prof} +def four_groups(key, fallback=True) -> dict: + """The four-group report for one key, for the panel (same key rules as state()).""" + key = resolve(key) if fallback else key + return {"key": key, "enabled": enabled(), **groups(_kind(key).rows())} + + def clear(key): _kind(key).clear() diff --git a/modules/sampling/block_influence/tests/test_groups.py b/modules/sampling/block_influence/tests/test_groups.py index a67cac46..ebfdc6bb 100644 --- a/modules/sampling/block_influence/tests/test_groups.py +++ b/modules/sampling/block_influence/tests/test_groups.py @@ -44,3 +44,19 @@ def test_too_few_clips_says_so_instead_of_comparing(): def test_nothing_recorded_is_said(): assert measure.groups([])["note"] == "nothing recorded yet" + + +def test_the_panel_report_reads_the_key_and_says_its_counts(monkeypatch): + rows = [_row(1, None, [1, 0]), _row(1, None, [1, 0]), _row(-1, "image", [0, 1]), _row(-1, "image", [0, 1]), + _row(-1, "composition", [0, -1]), _row(-1, "composition", [0, -1])] + + class Kind: + def rows(self): + return rows + + monkeypatch.setattr(measure, "_kind", lambda key: Kind()) + monkeypatch.setattr(measure, "resolve", lambda key: key) + out = measure.four_groups("fox", fallback=False) + 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"] diff --git a/tools/taste_groups.py b/tools/taste_groups.py deleted file mode 100644 index b3d2cf89..00000000 --- a/tools/taste_groups.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Print the four-group comparison for a Taste key's block-influence recordings. - - python tools/taste_groups.py - -Reads the rows the recording wrote (liked, bad image, bad composition, both bad) and prints how -the group directions line up, next to what shuffled labels give by chance. Reads only; changes nothing. -""" - -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - - -def main(key: str) -> None: - from modules.sampling.block_influence import measure - from modules.system.taste import store - - rows = store.load(key, measure.KIND)["rows"] - out = measure.groups(rows) - print(f"key {key!r}: {out['used']} clips rated") - print("counts:", out.get("counts", {})) - if out.get("note"): - print(out["note"]) - for pair, cos in out.get("cos", {}).items(): - c = out["chance"][pair] - print(f"{pair}: {cos:+.3f} (chance: mean {c['mean']:+.3f}, 95th {c['p95']:+.3f})") - - -if __name__ == "__main__": - if len(sys.argv) != 2: - sys.exit(__doc__) - main(sys.argv[1])