Merge branch 'feat/pairwise-review' into integration

# Conflicts:
#	ProjectStructure.md
#	resources/report.css
This commit is contained in:
Jason Hoku
2026-11-04 00:10:09 -08:00
21 changed files with 2468 additions and 7 deletions
+13 -1
View File
@@ -76,6 +76,8 @@ ComfyUI-Ultimate-Sampler-Grid/
│ ├── logic_standalone.js # Standalone-page mode: own websocket, run-API GENERATE NEW, openStandalone() (loaded last)
│ ├── logic_state.js # State Store (Redux-style: filters, favorites) (104 lines)
│ ├── logic_pipeline.js # Data Processing (Filter -> Sort -> Layout) (510 lines)
│ ├── logic_ratings.js # Pairwise review pure logic: axis normalisers, Bradley-Terry, pairing, scoreboard (no DOM, node-testable)
│ ├── logic_compare.js # Pairwise review DOM glue: compare overlay, vote queue, scoreboard modal, rating getters
│ ├── logic_virtual.js # Virtual DOM / Infinite Scroller Engine (925 lines)
│ ├── logic_ui.js # UI Renderer (DOM creation, Modal handling) (1780 lines)
│ ├── logic_upscale_modal.js # Dashboard upscale modal (inline config, presets, async upscale) (380 lines)
@@ -204,6 +206,8 @@ All routes registered via `PromptServer.instance.routes`.
| POST | `/config_tester/delete_session` | 142 | Delete session folder `{session_name}` |
| GET | `/config_tester/list_sessions` | 174 | List all sessions with metadata (name, count, thumbnail, mtime) |
| POST | `/config_tester/save_changes` | 234 | Delta save — only changed items `{session_name, changed_items}` |
| POST | `/config_tester/save_comparisons` | - | Pairwise-review votes `{session_name, append, remove}`; only touches manifest `comparisons` |
| POST | `/config_tester/clear_comparisons` | - | Delete all votes of a session `{session_name}` |
| POST | `/config_tester/save_manifest` | 308 | Full merge save `{session_name, manifest}` |
| POST | `/config_tester/get_session_html` | 390 | Generate dashboard HTML on-demand `{session_name, node_id}` |
| GET | `/config_tester/dashboard` | - | Dashboard SPA as a top-level page, optional `?session=` (standalone mode, `USCG_STANDALONE`, own websocket via `resources/logic_standalone.js`) |
@@ -279,7 +283,7 @@ All routes registered via `PromptServer.instance.routes`.
* **`html_generator.py`** (57 lines)
* `get_html_template(title, manifest_data, node_id)` — reads all `/resources/` files, inlines into `template.html`
* **JS Load Order:** `logic_state.js` → `logic_utils.js` → `logic_ui.js` → `logic_virtual.js` → `logic_pipeline.js` → `logic_events.js` → `logic_init.js` → `logic_standalone.js`
* **JS Load Order:** `logic_state.js` → `logic_utils.js` → `logic_ratings.js` → `logic_ui.js` → `logic_upscale_modal.js` → `logic_virtual.js` → `logic_pipeline.js` → `logic_compare.js` → `logic_events.js` → `logic_init.js` → `logic_standalone.js` (all files share ONE inlined script: top-level names must be unique across them)
* Replaces placeholders: `__CSS_CONTENT__`, `__JS_CONTENT__`, `__TITLE__`, `__NODE_ID__`
* Zero external HTTP requests — everything inlined
@@ -448,6 +452,12 @@ The Dashboard is a standalone single-page app rendered inside an iframe. All fil
### **Key Data Shapes**
**Manifest `comparisons`** (top level, sibling of `items`/`meta`; written ONLY by `manifest_utils.update_manifest_comparisons`, disk always wins in every other writer via `preserve_disk_comparisons`):
```json
"comparisons": [{"a": 180000000000001, "b": 180000000000002, "winner": "a", "ts": 1762000000000, "mode": "matched"}]
```
`mode` is `matched|seed|one_axis|random`; `blind: false` is present only for votes cast unblinded. Dedupe key is `ts:a:b`.
**Config Array State** (`node.state.config_arrays[n]` in JS, parsed from `lora_config` widget in Python):
```json
{
@@ -871,4 +881,6 @@ __init__.py
| `logic_utils.js` | `loadSession()`, `exportFavorites()`, `scanDirectory()`, `triggerGen()`, `toggleFullscreen()`, `deleteNonFavorites()` |
| `logic_virtual.js` | `renderDOM()`, `updateVisibleItems()`, `calculateVisibleRange()`, `autoFitZoom()`, `goToImage()` |
| `logic_pipeline.js` | `updateDataPipeline()`, `executePipeline()`, `processNewData()`, `incrementalFilter()` |
| `logic_ratings.js` | `computeBradleyTerry()`, `buildPairSource()`, `nextPair()`, `axisDiff()`, `computeScoreboard()` (pure; tests in `tests/js/ratings.test.js`, run `node --test tests/js/*.test.js`) |
| `logic_compare.js` | `openCompareMode()`, `cmpVoteSide()`, `cmpUndo()`, `openScoreboard()`, `getItemRating()`, `compareRatings()`, `onComparisonsReplaced()` |
| `logic_events.js` | Message handler for `progress_update`, `update_data`, `distribution_status` |
+27 -1
View File
@@ -1434,6 +1434,19 @@ ComfyUI/output/benchmarks/model-data/
---
## Pairwise Review (blind A/B compare + scoreboard)
The star is an absolute signal that says little about which *setting* wins. Compare mode records fast two-up votes and rolls them up per setting.
* **Start:** the two-rectangles button in the dashboard header, the `C` key, or the cog panel section **PAIRWISE REVIEW**. Votes go through the current filter and never include rejected items.
* **Pairing modes:** *Matched* (same prompt and seed, different settings), *Same settings, different seed* (noise-floor check), *One axis at a time* (exactly one of model, LoRA, sampler, scheduler, CFG, steps, denoise, resolution, prompt, upscale differs; optional axis focus) and *Random* (same media type). Pairs prefer items with few votes and never mix images with videos. When every unvoted pair is used up the queue repeats voted pairs and shows a badge.
* **Blind by default:** only "A" and "B" are shown, placement is randomised per pair. After a vote a short reveal (off / 1 s / 2 s) outlines the winner and shows only the settings that differ. Any key or tap during the reveal advances without voting. Tap or click an image to vote for it. Panes stack on portrait phones.
* **Ratings:** votes feed a Bradley-Terry model (ghost-player prior, 1500 = average). Sort the grid by **Rating**, or enable the **Rating** field in Label Mode to see `★rating · votes` on voted cards.
* **Scoreboard:** per axis, win % (ties count half) with 95% Wilson intervals and the mean rating of the items with that value. Only votes where exactly one axis differs are attributed (a hidden difference such as the VAE blocks attribution; the footer lists the most common extra differences). Rows with fewer than 3 pairs read "not enough data". Seed is a noise-floor block: if seeds alone "win" as much as settings do, the other numbers are noise. Copy JSON / Copy CSV export the board.
* **Storage:** votes live in the session `manifest.json` as a top-level `comparisons` list `[{a, b, winner, ts, mode, blind?}]` (`a`/`b` are item ids, `a` is the left/top item, `winner` is `a`, `b` or `tie`). Old manifests without the field work unchanged. Votes for items that were deleted stay on disk and are ignored. Every manifest writer (generation saves, the remote VAE worker, the legacy `save_manifest` route) keeps the on-disk `comparisons` untouched. A directory re-scan (`scan_directory`) regenerates item ids, so votes of a scanned session are not carried over.
* **Routes:** `POST /config_tester/save_comparisons` `{session_name, append: [...], remove: ["ts:a:b", ...]}` and `POST /config_tester/clear_comparisons` `{session_name}`. They only touch `comparisons`.
* **Caveat:** win % across mixed opponents is not a controlled effect size; pair choice is biased by the filter and the low-vote preference. Use it to rank candidates, then confirm with a matched one-axis run.
## Keyboard Shortcuts
### Dashboard
@@ -1445,7 +1458,20 @@ ComfyUI/output/benchmarks/model-data/
| `+/-` | Zoom in/out |
| `0` | Reset zoom to 1:1 |
| `F` | Auto-fit first row to viewport width |
| `Escape` | Close active modal/popup (priority: Revision > Filters > Settings > Fullscreen) |
| `Escape` | Close active modal/popup (priority: Revision > Scoreboard > Analytics > Settings > Filters) |
| `C` | Open Compare mode (blind pairwise review, see below) |
### Dashboard: Compare mode (blind pairwise review)
| Shortcut | Action |
|----------|--------|
| `A` / `Left` | A wins |
| `D` / `Right` | B wins |
| `S` / `Down` | Tie |
| `W` / `Up` | Skip (not saved, pair is excluded for this compare session) |
| `Z` / `Backspace` / `Ctrl+Z` | Undo the last vote (multi-level, this compare session) |
| `I` | Unblind: show what differs before voting (the vote is stored with `blind: false`) |
| `B` | Open the scoreboard |
| `Escape` | Close the scoreboard, then compare mode |
### Config Builder
| Shortcut | Action |
+79
View File
@@ -416,6 +416,81 @@ async def save_changes(request):
traceback.print_exc()
return web.Response(status=500, text=str(e))
# =============================================================================
# API: PAIRWISE REVIEW VOTES (manifest "comparisons")
# =============================================================================
def _comparisons_manifest_path(session_name):
"""Resolve a sanitized session name to its manifest path; returns (path, error_response)."""
if session_name:
session_name = re.sub(r'[^\w\-]', '', str(session_name))
if not session_name:
return None, web.Response(status=400, text="Missing session_name")
base_dir = os.path.join(folder_paths.get_output_directory(), "benchmarks", session_name)
if not _is_path_within(base_dir, _get_benchmarks_base()):
return None, web.Response(status=403, text="Forbidden: path outside benchmarks directory")
manifest_path = os.path.join(base_dir, "manifest.json")
if not os.path.exists(manifest_path):
return None, web.Response(status=404, text=f"Session '{session_name}' not found")
return manifest_path, None
@server.PromptServer.instance.routes.post("/config_tester/save_comparisons")
async def save_comparisons(request):
"""
Append / remove blind-compare votes in manifest["comparisons"].
Body: {session_name, append: [{a,b,winner,ts,mode,blind?}], remove: ["ts:a:b", ...]}
Only the comparisons list is touched; items/meta are written back as read.
"""
try:
from .manifest_utils import update_manifest_comparisons
data = await request.json()
append = data.get("append") or []
remove = data.get("remove") or []
if not isinstance(append, list) or not isinstance(remove, list):
return web.Response(status=400, text="append and remove must be lists")
if (not append and not remove) or len(append) > 5000 or len(remove) > 5000:
return web.Response(status=400, text="Nothing to save or too many votes")
manifest_path, err = _comparisons_manifest_path(data.get("session_name"))
if err:
return err
try:
result = await asyncio.to_thread(
update_manifest_comparisons, manifest_path, append,
[str(k) for k in remove])
except FileNotFoundError:
return web.Response(status=404, text="Session manifest not found")
except ValueError: # includes json.JSONDecodeError (manifest mid-write): client retries
return web.Response(status=409, text="Manifest unreadable, retry")
print(f"[ConfigTester] ⚖️ Votes: +{result['added']} -{result['removed']} = {result['total']} in {data.get('session_name')}")
return web.json_response(result)
except Exception as e:
print(f"[ConfigTester] Error saving comparisons: {e}")
return web.Response(status=500, text=str(e))
@server.PromptServer.instance.routes.post("/config_tester/clear_comparisons")
async def clear_comparisons(request):
"""Delete every vote of a session (body: {session_name})."""
try:
from .manifest_utils import update_manifest_comparisons
data = await request.json()
manifest_path, err = _comparisons_manifest_path(data.get("session_name"))
if err:
return err
try:
result = await asyncio.to_thread(update_manifest_comparisons, manifest_path, (), (), True)
except FileNotFoundError:
return web.Response(status=404, text="Session manifest not found")
except ValueError:
return web.Response(status=409, text="Manifest unreadable, retry")
print(f"[ConfigTester] ⚖️ Votes cleared ({result['removed']}) in {data.get('session_name')}")
return web.json_response(result)
except Exception as e:
print(f"[ConfigTester] Error clearing comparisons: {e}")
return web.Response(status=500, text=str(e))
# =============================================================================
# API: SAVE MANIFEST (Legacy - Full Save)
# =============================================================================
@@ -485,6 +560,10 @@ async def save_manifest(request):
if "meta" in server_manifest:
manifest_data["meta"] = server_manifest["meta"]
# Preserve pairwise-review votes (disk wins; the dashboard copy can be stale)
if "comparisons" in server_manifest:
manifest_data["comparisons"] = server_manifest["comparisons"]
# Save merged manifest
os.makedirs(base_dir, exist_ok=True)
with open(manifest_path, "w") as f:
+2
View File
@@ -21,10 +21,12 @@ def get_html_template(title, manifest_data, node_id, standalone=False):
js_files = [
"logic_state.js", # Global variables
"logic_utils.js", # API & Helper functions
"logic_ratings.js", # Pairwise review: pure logic (normalisers, Bradley-Terry, pairing, scoreboard)
"logic_ui.js", # DOM Creation, Modals, JSON Bars
"logic_upscale_modal.js", # Upscale Modal (preset management, async upscale)
"logic_virtual.js", # The Virtual Scroll Engine & Viewport
"logic_pipeline.js", # Sorting & Filtering Logic
"logic_compare.js", # Pairwise review: compare overlay, votes, scoreboard modal
"logic_events.js", # Event Listeners (Message, Keyboard)
"logic_init.js", # Initialization
"logic_standalone.js" # Standalone-page mode (own websocket, run API); must stay LAST
+4
View File
@@ -1084,6 +1084,10 @@ def flush_batch_with_vae(pending_batch, vae, img_dir, existing_data, session_nam
try:
with open(manifest_path, "r") as f:
disk_manifest = json.load(f)
# Pairwise-review votes live on disk (disk wins), never drop them on this save
from .manifest_utils import preserve_disk_comparisons
preserve_disk_comparisons(disk_manifest, existing_data)
# Create a lookup map for items currently in memory
memory_items_map = {
+127
View File
@@ -6,12 +6,136 @@ Handles saving and merging manifest.json with user modifications
import json
import os
import threading
import time
# Thread-safe lock for manifest file writes.
# Required when multiple distributed workers submit results concurrently.
_manifest_lock = threading.Lock()
# ---------------------------------------------------------------------------
# Pairwise review votes (manifest top-level "comparisons" list)
# ---------------------------------------------------------------------------
_WINNERS = ("a", "b", "tie")
def comparison_key(c):
"""Dedupe key of a vote: "ts:a:b" (string)."""
return f"{c.get('ts')}:{c.get('a')}:{c.get('b')}"
def validate_comparison(c):
"""Return a cleaned vote dict, or None when the entry is not a valid vote."""
if not isinstance(c, dict):
return None
a, b = c.get("a"), c.get("b")
if type(a) is not int or type(b) is not int or a == b:
return None
if c.get("winner") not in _WINNERS:
return None
ts = c.get("ts")
if type(ts) not in (int, float) or ts != ts or ts < 0:
return None
mode = c.get("mode")
mode = mode[:32] if isinstance(mode, str) and mode else "matched"
out = {"a": a, "b": b, "winner": c["winner"], "ts": ts, "mode": mode}
if isinstance(c.get("blind"), bool):
out["blind"] = c["blind"]
return out
def merge_comparison_lists(existing, append, remove_keys):
"""
Merge votes: keep existing order, drop entries whose key is in remove_keys,
append valid new entries whose key is not present yet.
Invalid existing entries are kept untouched. Returns (merged, added, removed).
"""
remove = set(remove_keys or ())
merged = []
seen = set()
removed = 0
for c in (existing or []):
if isinstance(c, dict) and validate_comparison(c) is not None:
k = comparison_key(c)
if k in remove:
removed += 1
continue
seen.add(k)
merged.append(c)
added = 0
for c in (append or []):
v = validate_comparison(c)
if v is None:
continue
k = comparison_key(v)
if k in seen or k in remove:
continue
seen.add(k)
merged.append(v)
added += 1
return merged, added, removed
def preserve_disk_comparisons(disk_manifest, target_manifest):
"""
Copy the on-disk votes onto target_manifest (disk wins). Only the compare
routes create or remove votes, so a stale in-memory copy must never
resurrect undone/cleared votes or drop new ones.
"""
try:
if (isinstance(disk_manifest, dict) and "comparisons" in disk_manifest
and isinstance(target_manifest, dict)):
target_manifest["comparisons"] = disk_manifest["comparisons"]
except Exception:
pass
def _atomic_write_json(path, data):
tmp = path + ".tmp"
with open(tmp, "w") as f:
json.dump(data, f, indent=4)
last = None
for _ in range(5):
try:
os.replace(tmp, path)
return
except PermissionError as e: # Windows: a reader has the file open
last = e
time.sleep(0.04)
# Fall back to a plain in-place write (same discipline as save_manifest)
with open(path, "w") as f:
json.dump(data, f, indent=4)
try:
os.remove(tmp)
except OSError:
pass
def update_manifest_comparisons(manifest_path, append=(), remove_keys=(), clear=False):
"""
Locked read-modify-write of ONLY the "comparisons" list.
Raises FileNotFoundError when the manifest is missing and ValueError/JSON
errors when it is unreadable (nothing is written in that case).
"""
with _manifest_lock:
with open(manifest_path, "r") as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError("manifest is not a JSON object")
if clear:
old = data.get("comparisons")
removed = len(old) if isinstance(old, list) else 0
data["comparisons"], added = [], 0
else:
existing = data.get("comparisons")
if not isinstance(existing, list):
existing = []
data["comparisons"], added, removed = merge_comparison_lists(existing, append, remove_keys)
_atomic_write_json(manifest_path, data)
return {"comparisons": data["comparisons"], "added": added,
"removed": removed, "total": len(data["comparisons"])}
def merge_manifest_user_changes(manifest_path, existing_data):
"""
Reload manifest and merge user changes (favorites, rejected, notes) to preserve them.
@@ -24,6 +148,9 @@ def merge_manifest_user_changes(manifest_path, existing_data):
try:
with open(manifest_path, "r") as f:
current_manifest = json.load(f)
# Votes are owned by the compare routes: the disk copy always wins
preserve_disk_comparisons(current_manifest, existing_data)
# Create lookup dict of current items by ID from DISK
current_items_dict = {
+4
View File
@@ -231,6 +231,10 @@ class RemoteVAEDecodeWorker:
try:
with open(self.manifest_path, "r") as f:
disk_manifest = json.load(f)
# Pairwise-review votes live on disk (disk wins), never drop them on this save
from .manifest_utils import preserve_disk_comparisons
preserve_disk_comparisons(disk_manifest, self.existing_data)
# Create a lookup map for items currently in memory
# This allows us to update self.existing_data in-place
+824
View File
@@ -0,0 +1,824 @@
/**
* PAIRWISE REVIEW - DOM + STATE GLUE
* Compare overlay, vote/undo/reveal flow, persistence queue, scoreboard modal,
* settings bindings, and the rating getters used by the grid (sort + label badge).
* Pure logic lives in logic_ratings.js (no DOM there). Everything here is prefixed
* cmp / _cmp because all logic_*.js files share one inlined <script> scope.
*/
var _cmpSettings = { mode: 'matched', axis: '', reveal: 1000, blind: true, restrict: true };
var _cmp = {
open: false,
src: null,
srcKey: '',
state: { votesById: new Map(), votedPairs: new Set(), skipped: new Set(), allowRepeats: false },
current: null, // {a, b (items), repeating, axis}
next: null, // prefetched pair
revealing: false,
revealTimer: null,
unblind: false,
sessionVotes: 0,
undoStack: [], // votes cast in this compare session (objects living in comparisons)
lastTs: 0,
pairsEstimate: 0
};
// Persistence queue
var _cmpPendingAppend = [];
var _cmpPendingRemove = [];
var _cmpInflightKeys = new Set();
var _cmpFlushTimer = null;
var _cmpInFlight = false;
var _cmpRetryMs = 0;
var _cmpSinceFlush = 0;
var _cmpSess = '';
var _cmpLastServer = []; // last list known to be on disk for _cmpSess
var _cmpVersion = 0; // bumped on any change of fullManifest.comparisons
var _cmpRatingCache = { version: -1, n: -1, byId: new Map() };
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function cmpSessionName() {
var el = document.getElementById('session-input');
return (el && el.value) || 'default';
}
function cmpList() {
if (!fullManifest) return [];
if (!Array.isArray(fullManifest.comparisons)) fullManifest.comparisons = [];
return fullManifest.comparisons;
}
function cmpSetStatus(msg, isError) {
var el = document.getElementById('cmp-status');
if (!el) return;
el.textContent = msg || '';
el.style.color = isError ? '#ff5555' : '#666';
}
function cmpBump() {
_cmpVersion++;
}
function cmpItemsMap() {
var m = new Map();
var items = (fullManifest && fullManifest.items) || [];
for (var i = 0; i < items.length; i++) m.set(items[i].id, items[i]);
return m;
}
// ---------------------------------------------------------------------------
// Ratings (used by the Rating sort and the Rating label badge)
// ---------------------------------------------------------------------------
function cmpRatings() {
var n = fullManifest && fullManifest.items ? fullManifest.items.length : 0;
if (_cmpRatingCache.version !== _cmpVersion || _cmpRatingCache.n !== n) {
var ids = new Set();
var items = (fullManifest && fullManifest.items) || [];
for (var i = 0; i < items.length; i++) ids.add(items[i].id);
_cmpRatingCache = { version: _cmpVersion, n: n, byId: computeBradleyTerry(cmpList(), ids) };
}
return _cmpRatingCache.byId;
}
function getItemRating(id) {
return cmpRatings().get(id) || null;
}
/** Sort comparator: rated items first (descending), unrated last, never NaN. */
function compareRatings(a, b) {
var ra = getItemRating(a.id), rb = getItemRating(b.id);
if (!ra && !rb) return 0;
if (!ra) return 1;
if (!rb) return -1;
var d = rb.rating - ra.rating;
return isFinite(d) ? d : 0;
}
// ---------------------------------------------------------------------------
// Settings (cog panel)
// ---------------------------------------------------------------------------
function cmpLoadSettings() {
try {
var raw = localStorage.getItem('ultimate_grid_compare');
if (raw) {
var p = JSON.parse(raw);
if (['matched', 'seed', 'one_axis', 'random'].indexOf(p.mode) >= 0) _cmpSettings.mode = p.mode;
if (typeof p.axis === 'string' && (p.axis === '' || CMP_AXES.indexOf(p.axis) >= 0)) _cmpSettings.axis = p.axis;
if ([0, 1000, 2000].indexOf(+p.reveal) >= 0) _cmpSettings.reveal = +p.reveal;
if (typeof p.blind === 'boolean') _cmpSettings.blind = p.blind;
if (typeof p.restrict === 'boolean') _cmpSettings.restrict = p.restrict;
}
} catch (e) { /* ignore */ }
}
function cmpSaveSettings() {
try { localStorage.setItem('ultimate_grid_compare', JSON.stringify(_cmpSettings)); } catch (e) { /* ignore */ }
}
function cmpSyncSettingsUI() {
var set = function (id, fn) { var el = document.getElementById(id); if (el) fn(el); };
set('cmp-mode', function (el) { el.value = _cmpSettings.mode; });
set('cmp-axis', function (el) { el.value = _cmpSettings.axis; });
set('cmp-reveal', function (el) { el.value = String(_cmpSettings.reveal); });
set('cmp-blind', function (el) { el.checked = _cmpSettings.blind; });
set('cmp-restrict', function (el) { el.checked = _cmpSettings.restrict; });
}
function cmpSettingsChanged() {
var v = function (id) { var el = document.getElementById(id); return el; };
var mode = v('cmp-mode'), axis = v('cmp-axis'), reveal = v('cmp-reveal'), blind = v('cmp-blind'), restrict = v('cmp-restrict');
if (mode) _cmpSettings.mode = mode.value;
if (axis) _cmpSettings.axis = axis.value;
if (reveal) _cmpSettings.reveal = +reveal.value;
if (blind) _cmpSettings.blind = blind.checked;
if (restrict) _cmpSettings.restrict = restrict.checked;
cmpSaveSettings();
if (_cmp.open) {
cmpEnsureSource(true);
cmpShowNext(true);
}
}
function initCompareUI() {
cmpLoadSettings();
cmpSyncSettingsUI();
_cmpSess = cmpSessionName();
window.addEventListener('pagehide', function () { cmpFlushNow(true); });
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'hidden') cmpFlushNow(true);
});
}
// ---------------------------------------------------------------------------
// Persistence
// ---------------------------------------------------------------------------
function cmpScheduleFlush() {
if (_cmpFlushTimer) clearTimeout(_cmpFlushTimer);
_cmpFlushTimer = setTimeout(function () { _cmpFlushTimer = null; cmpFlushNow(false); }, 1500);
}
function cmpApplyPending(list) {
var out = mergeComparisons(list, _cmpPendingAppend);
if (_cmpPendingRemove.length) {
var rm = new Set(_cmpPendingRemove);
out = out.filter(function (c) { return !rm.has(cmpVoteKey(c)); });
}
return out;
}
async function cmpFlushNow(keepalive) {
if (_cmpFlushTimer) { clearTimeout(_cmpFlushTimer); _cmpFlushTimer = null; }
if (_cmpInFlight) return;
if (!_cmpPendingAppend.length && !_cmpPendingRemove.length) return;
var sentA = _cmpPendingAppend.slice();
var sentR = _cmpPendingRemove.slice();
var session = _cmpSess || cmpSessionName();
_cmpInFlight = true;
_cmpInflightKeys = new Set(sentA.map(cmpVoteKey));
try {
var res = await fetch('/config_tester/save_comparisons', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
keepalive: !!keepalive,
body: JSON.stringify({ session_name: session, append: sentA, remove: sentR })
});
if (!res.ok) throw new Error('HTTP ' + res.status);
var data = await res.json();
var sentASet = new Set(sentA);
_cmpPendingAppend = _cmpPendingAppend.filter(function (c) { return !sentASet.has(c); });
var sentRSet = new Set(sentR);
_cmpPendingRemove = _cmpPendingRemove.filter(function (k) { return !sentRSet.has(k); });
_cmpSinceFlush = 0;
_cmpRetryMs = 0;
if (session === cmpSessionName()) {
_cmpLastServer = Array.isArray(data.comparisons) ? data.comparisons : _cmpLastServer;
if (fullManifest) fullManifest.comparisons = cmpApplyPending(_cmpLastServer);
cmpBump();
cmpRefreshAfterSync();
}
cmpSetStatus('Votes saved (' + (data.total !== undefined ? data.total : cmpList().length) + ' total)', false);
} catch (e) {
_cmpRetryMs = Math.min(_cmpRetryMs ? _cmpRetryMs * 2 : 2000, 30000);
cmpSetStatus('Saving votes failed (' + (e && e.message || e) + '), retrying in ' + Math.round(_cmpRetryMs / 1000) + 's', true);
if (_cmpFlushTimer) clearTimeout(_cmpFlushTimer);
_cmpFlushTimer = setTimeout(function () { _cmpFlushTimer = null; cmpFlushNow(false); }, _cmpRetryMs);
} finally {
_cmpInFlight = false;
_cmpInflightKeys = new Set();
// votes cast while the request was running
if ((_cmpPendingAppend.length || _cmpPendingRemove.length) && !_cmpFlushTimer) cmpScheduleFlush();
}
}
/** Called after the manifest was replaced (loadSession / full-manifest push). */
function onComparisonsReplaced() {
var now = cmpSessionName();
if (_cmpSess && _cmpSess !== now) {
// Session switched: send what is still queued to the OLD session (best effort), then reset
cmpFlushNow(true);
_cmpPendingAppend = [];
_cmpPendingRemove = [];
_cmpLastServer = [];
if (_cmp.open) cmpCloseCompare(true);
}
_cmpSess = now;
if (fullManifest) {
if (Array.isArray(fullManifest.comparisons)) _cmpLastServer = fullManifest.comparisons;
else fullManifest.comparisons = _cmpLastServer.slice(); // e.g. a generation push without votes
fullManifest.comparisons = cmpApplyPending(fullManifest.comparisons);
}
cmpBump();
cmpRefreshAfterSync();
}
function cmpRefreshAfterSync() {
if (_cmp.open) {
cmpRebuildVoteState();
cmpUpdateProgress();
}
}
function cmpRecordVote(c) {
cmpList().push(c);
_cmpPendingAppend.push(c);
_cmpSess = cmpSessionName();
cmpBump();
_cmpSinceFlush++;
if (_cmpSinceFlush >= 20) cmpFlushNow(false); else cmpScheduleFlush();
}
function cmpRetractVote(c) {
var key = cmpVoteKey(c);
var list = cmpList();
for (var i = list.length - 1; i >= 0; i--) {
if (list[i] === c || cmpVoteKey(list[i]) === key) { list.splice(i, 1); break; }
}
var idx = _cmpPendingAppend.indexOf(c);
if (idx >= 0 && !_cmpInflightKeys.has(key)) _cmpPendingAppend.splice(idx, 1);
else {
if (idx >= 0) _cmpPendingAppend.splice(idx, 1);
_cmpPendingRemove.push(key); // already sent (or in flight): ask the server to drop it
}
cmpBump();
cmpScheduleFlush();
}
async function clearComparisons() {
var n = cmpList().length;
if (!n) { cmpSetStatus('No votes to clear', false); return; }
if (!window.confirm('Delete all ' + n + ' votes saved in this session? This cannot be undone.')) return;
try {
var res = await fetch('/config_tester/clear_comparisons', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_name: cmpSessionName() })
});
if (!res.ok) throw new Error('HTTP ' + res.status);
_cmpPendingAppend = [];
_cmpPendingRemove = [];
_cmpLastServer = [];
if (fullManifest) fullManifest.comparisons = [];
_cmp.undoStack = [];
_cmp.state.skipped.clear();
cmpBump();
cmpRefreshAfterSync();
cmpAfterRatingsChanged();
cmpSetStatus('Cleared ' + n + ' votes', false);
} catch (e) {
cmpSetStatus('Clearing votes failed: ' + (e && e.message || e), true);
}
}
function cmpAfterRatingsChanged() {
if (typeof refreshLabelOverlays === 'function') refreshLabelOverlays();
if ((currentSort === 'rating' || currentSecondarySort === 'rating') && typeof updateDataPipeline === 'function') {
updateDataPipeline();
}
}
// ---------------------------------------------------------------------------
// Pair source / queue
// ---------------------------------------------------------------------------
function cmpBuildPool() {
var out = [];
var src = processedData || [];
for (var i = 0; i < src.length; i++) {
var d = src[i];
if (d && d.file && !d.rejected) out.push(d); // a reject is a decision already made
}
return out;
}
function cmpRebuildVoteState() {
var ids = new Set();
var items = (fullManifest && fullManifest.items) || [];
for (var i = 0; i < items.length; i++) ids.add(items[i].id);
var votesById = new Map(), votedPairs = new Set();
var list = cmpList();
for (var j = 0; j < list.length; j++) {
var c = list[j];
if (!c || !ids.has(c.a) || !ids.has(c.b) || c.a === c.b) continue;
votedPairs.add(cmpPairKey(c.a, c.b));
votesById.set(c.a, (votesById.get(c.a) || 0) + 1);
votesById.set(c.b, (votesById.get(c.b) || 0) + 1);
}
_cmp.state.votesById = votesById;
_cmp.state.votedPairs = votedPairs;
cmpComputeRemaining();
}
function cmpComputeRemaining() {
if (!_cmp.src) { _cmp.pairsEstimate = 0; return; }
var inPool = new Set();
_cmp.src.buckets.forEach(function (b) { b.items.forEach(function (e) { inPool.add(e.id); }); });
var voted = 0;
_cmp.state.votedPairs.forEach(function (k) {
var p = k.split('|');
if (inPool.has(+p[0]) && inPool.has(+p[1])) voted++;
});
_cmp.pairsEstimate = Math.max(0, _cmp.src.estimate - voted);
}
function cmpEnsureSource(force) {
var key = '';
try { key = getFilterKey(); } catch (e) { key = ''; }
key += '|' + activeData.length + '|' + _cmpSettings.mode + '|' + _cmpSettings.axis;
if (!force && _cmp.src && _cmp.srcKey === key) return;
_cmp.srcKey = key;
_cmp.src = buildPairSource(cmpBuildPool(), _cmpSettings.mode, _cmpSettings.axis, { meta: meta });
_cmp.next = null;
cmpRebuildVoteState();
}
function cmpPickPair() {
var p = _cmp.next;
_cmp.next = null;
if (p) {
var k = cmpPairKey(p.a.id, p.b.id);
var stale = _cmp.state.skipped.has(k) || (!p.repeating && _cmp.state.votedPairs.has(k));
if (!stale) return p;
}
return nextPair(_cmp.src, _cmp.state, Math.random);
}
function cmpPrefetch() {
_cmp.next = nextPair(_cmp.src, _cmp.state, Math.random);
if (_cmp.next) {
[_cmp.next.a, _cmp.next.b].forEach(function (d) {
if (d.media_type !== 'video') { var im = new Image(); im.src = d.file; }
});
}
}
// ---------------------------------------------------------------------------
// Overlay rendering
// ---------------------------------------------------------------------------
function cmpModeLabel() {
var m = _cmpSettings.mode;
if (m === 'matched') return 'Matched pairs';
if (m === 'seed') return 'Seed pairs';
if (m === 'random') return 'Random pairs';
return 'One axis' + (_cmpSettings.axis ? ': ' + _cmpSettings.axis : '');
}
function cmpUpdateProgress() {
var el = document.getElementById('cmp-progress');
if (!el) return;
var total = 0;
var list = cmpList();
for (var i = 0; i < list.length; i++) total++;
var poolN = _cmp.src ? _cmp.src.poolSize : 0;
var rep = document.getElementById('cmp-repeat-badge');
if (rep) rep.style.display = (_cmp.current && _cmp.current.repeating) ? '' : 'none';
el.textContent = 'Session ' + _cmp.sessionVotes + ' · Total ' + total + ' · Pool ' + poolN.toLocaleString() +
' items · ~' + Math.round(_cmp.pairsEstimate).toLocaleString() + ' unvoted pairs';
var ml = document.getElementById('cmp-mode-label');
if (ml) ml.textContent = cmpModeLabel();
var ub = document.getElementById('cmp-unblind-btn');
if (ub) ub.classList.toggle('active', _cmp.unblind);
}
function cmpFillMedia(el, d, asVideo) {
el.textContent = '';
var m;
if (asVideo) {
m = document.createElement('video');
m.src = d.file;
m.autoplay = true; m.muted = true; m.loop = true; m.playsInline = true;
m.setAttribute('muted', ''); m.setAttribute('playsinline', '');
var p = m.play && m.play(); if (p && p.catch) p.catch(function () { });
} else {
m = document.createElement('img');
m.src = d.file;
m.draggable = false;
}
el.appendChild(m);
}
function cmpRenderChips(show) {
var ca = document.getElementById('cmp-chips-a'), cb = document.getElementById('cmp-chips-b');
if (ca) ca.textContent = '';
if (cb) cb.textContent = '';
if (!show || !_cmp.current) return;
var A = _cmp.current.a, B = _cmp.current.b;
var df = axisDiff(A, B, { meta: meta });
var sa = itemSig(A, meta), sb = itemSig(B, meta);
var add = function (host, axis, sig) {
var s = document.createElement('span');
s.className = 'cmp-chip';
s.textContent = axis + ': ' + sig.labels[axis];
s.title = axis + ': ' + sig.axes[axis];
host.appendChild(s);
};
df.axes.forEach(function (ax) { add(ca, ax, sa); add(cb, ax, sb); });
if (!df.axes.length || !df.ctxEqual) {
[ca, cb].forEach(function (h) {
var s = document.createElement('span');
s.className = 'cmp-chip';
s.textContent = df.axes.length ? 'other settings differ' : (df.ctxEqual ? 'identical settings' : 'other settings differ');
h.appendChild(s);
});
}
}
function cmpRenderPair() {
var pair = _cmp.current;
var empty = document.getElementById('cmp-empty');
var panes = document.getElementById('cmp-panes');
var bar = document.getElementById('cmp-bar');
if (!pair) {
if (empty) { empty.style.display = 'flex'; }
if (panes) panes.style.visibility = 'hidden';
if (bar) bar.style.visibility = 'hidden';
cmpUpdateProgress();
return;
}
if (empty) empty.style.display = 'none';
if (panes) panes.style.visibility = '';
if (bar) bar.style.visibility = '';
var video = pair.a.media_type === 'video' && pair.b.media_type === 'video';
cmpFillMedia(document.getElementById('cmp-media-a'), pair.a, video);
cmpFillMedia(document.getElementById('cmp-media-b'), pair.b, video);
['a', 'b'].forEach(function (s) {
var p = document.getElementById('cmp-pane-' + s);
if (p) p.classList.remove('cmp-winner', 'cmp-tie');
});
cmpRenderChips(_cmp.unblind || !_cmpSettings.blind);
cmpUpdateProgress();
}
function cmpEmptyMessage() {
var el = document.getElementById('cmp-empty');
if (!el) return;
var pool = _cmp.src ? _cmp.src.poolSize : 0;
var msg = 'No comparable pairs for this mode with the current filters.';
if (_cmp.src && _cmp.src.buckets.length && _cmp.state.skipped.size) {
msg = 'Every remaining pair was skipped in this compare session.';
} else {
var pairs = cmpBuildPool();
var tries = [['matched', ''], ['seed', ''], ['one_axis', ''], ['random', '']];
var best = null;
tries.forEach(function (t) {
if (t[0] === _cmpSettings.mode && t[1] === _cmpSettings.axis) return;
var s = buildPairSource(pairs, t[0], t[1], { meta: meta });
if (s.estimate > 0 && (!best || s.estimate > best.n)) best = { mode: t[0], n: s.estimate };
});
var names = { matched: 'Matched', seed: 'Seed pairs', one_axis: 'One axis', random: 'Random' };
if (best) msg += ' Try the "' + names[best.mode] + '" pairing mode (about ' + Math.round(best.n).toLocaleString() + ' pairs) in Settings > Pairwise Review.';
else msg += ' The pool has ' + pool + ' item(s); widen the filters or generate more images.';
}
el.textContent = msg;
}
function cmpShowNext(rebuilt) {
if (!_cmp.open) return;
_cmp.revealing = false;
if (_cmp.revealTimer) { clearTimeout(_cmp.revealTimer); _cmp.revealTimer = null; }
_cmp.current = _cmp.src ? cmpPickPair() : null;
if (!_cmp.current) cmpEmptyMessage();
cmpRenderPair();
if (_cmp.current) cmpPrefetch();
}
function cmpShowPair(pair) {
_cmp.revealing = false;
if (_cmp.revealTimer) { clearTimeout(_cmp.revealTimer); _cmp.revealTimer = null; }
_cmp.current = pair;
var empty = document.getElementById('cmp-empty');
if (empty) empty.style.display = 'none';
cmpRenderPair();
_cmp.next = null;
cmpPrefetch();
}
// ---------------------------------------------------------------------------
// Vote / skip / undo
// ---------------------------------------------------------------------------
function cmpAdvance() {
cmpShowNext(false);
}
function cmpVoteSide(side) {
if (!_cmp.open) return;
if (_cmp.revealing) { cmpAdvance(); return; } // tap during reveal: advance, no vote
var pair = _cmp.current;
if (!pair || (side !== 'a' && side !== 'b' && side !== 'tie')) return;
var ts = Math.max(Date.now(), _cmp.lastTs + 1);
_cmp.lastTs = ts;
var c = { a: pair.a.id, b: pair.b.id, winner: side, ts: ts, mode: _cmpSettings.mode };
if (!(_cmpSettings.blind && !_cmp.unblind)) c.blind = false;
cmpRecordVote(c);
_cmp.undoStack.push(c);
_cmp.sessionVotes++;
var pk = cmpPairKey(c.a, c.b);
_cmp.state.votedPairs.add(pk);
_cmp.state.votesById.set(c.a, (_cmp.state.votesById.get(c.a) || 0) + 1);
_cmp.state.votesById.set(c.b, (_cmp.state.votesById.get(c.b) || 0) + 1);
cmpComputeRemaining();
cmpUpdateProgress();
var ms = _cmpSettings.reveal;
if (ms > 0) {
_cmp.revealing = true;
var pa = document.getElementById('cmp-pane-a'), pb = document.getElementById('cmp-pane-b');
if (side === 'tie') { pa.classList.add('cmp-tie'); pb.classList.add('cmp-tie'); }
else (side === 'a' ? pa : pb).classList.add('cmp-winner');
cmpRenderChips(true);
_cmp.revealTimer = setTimeout(cmpAdvance, ms);
} else {
cmpAdvance();
}
}
function cmpSkip() {
if (!_cmp.open) return;
if (_cmp.revealing) { cmpAdvance(); return; }
var pair = _cmp.current;
if (!pair) return;
_cmp.state.skipped.add(cmpPairKey(pair.a.id, pair.b.id)); // memory only, never persisted
cmpAdvance();
}
function cmpUndo() {
if (!_cmp.open || !_cmp.undoStack.length) return;
var c = _cmp.undoStack.pop();
cmpRetractVote(c);
_cmp.sessionVotes = Math.max(0, _cmp.sessionVotes - 1);
cmpRebuildVoteState();
var items = cmpItemsMap();
var A = items.get(c.a), B = items.get(c.b);
if (A && B) cmpShowPair({ a: A, b: B, repeating: false, axis: null });
else cmpShowNext(false);
}
function cmpToggleUnblind() {
_cmp.unblind = !_cmp.unblind;
if (_cmp.current && !_cmp.revealing) cmpRenderChips(_cmp.unblind || !_cmpSettings.blind);
cmpUpdateProgress();
}
// ---------------------------------------------------------------------------
// Open / close + keys
// ---------------------------------------------------------------------------
function cmpKeyHandler(e) {
if (!window._compareOpen) return;
var t = e.target && e.target.tagName;
if (t === 'INPUT' || t === 'TEXTAREA' || t === 'SELECT') return;
var chord = e.ctrlKey || e.metaKey || e.altKey;
var key = e.key;
var isUndoChord = (e.ctrlKey || e.metaKey) && !e.altKey && (key === 'z' || key === 'Z');
if (chord && !isUndoChord) return; // Ctrl+S etc. keep reaching their own handlers
var sbOpen = !!document.getElementById('scoreboard-modal');
var stop = function () { e.preventDefault(); e.stopPropagation(); };
if (key === 'Escape') {
stop();
var sb = document.getElementById('scoreboard-modal');
if (sb) sb.remove(); else closeCompareMode();
return;
}
if (sbOpen) return; // scoreboard on top: only Escape is ours
if (e.repeat) { stop(); return; }
var k = key.length === 1 ? key.toLowerCase() : key;
if (k === 'z' || k === 'Backspace') { stop(); cmpUndo(); return; }
if (_cmp.revealing) {
// any other key during the reveal advances and does not vote
if (key === 'Tab' || key.indexOf('F') === 0 && key.length > 1) return;
stop(); cmpAdvance(); return;
}
switch (k) {
case 'a': case 'ArrowLeft': stop(); cmpVoteSide('a'); return;
case 'd': case 'ArrowRight': stop(); cmpVoteSide('b'); return;
case 's': case 'ArrowDown': stop(); cmpVoteSide('tie'); return;
case 'w': case 'ArrowUp': stop(); cmpSkip(); return;
case 'i': stop(); cmpToggleUnblind(); return;
case 'b': stop(); openScoreboard(); return;
}
// swallow everything else (digits would favourite grid cards, f/r would fullscreen/reload)
if (key.length === 1 || key.indexOf('Arrow') === 0 || key === 'Enter') stop();
}
function openCompareMode() {
if (_cmp.open) return;
if (!fullManifest || !(fullManifest.items || []).length) {
cmpSetStatus('Load a session with images first', true);
return;
}
cmpLoadSettings();
cmpSyncSettingsUI();
// close the settings panel if it is open (the overlay is above it anyway)
var cog = document.getElementById('cog-menu-dropdown');
if (cog && cog.style.display !== 'none' && cog.style.display !== '' && typeof toggleCogMenu === 'function') toggleCogMenu();
_cmpSess = cmpSessionName();
if (!Array.isArray(fullManifest.comparisons)) fullManifest.comparisons = cmpApplyPending(_cmpLastServer.slice());
_cmp.open = true;
_cmp.unblind = false;
_cmp.sessionVotes = 0;
_cmp.undoStack = [];
_cmp.state.skipped = new Set();
_cmp.state.allowRepeats = false;
_cmp.lastTs = 0;
window._compareOpen = true;
window._modalOpen = true;
document.getElementById('compare-overlay').style.display = 'flex';
document.removeEventListener('keydown', window._cmpKeyHandler, true);
window._cmpKeyHandler = cmpKeyHandler;
document.addEventListener('keydown', window._cmpKeyHandler, true);
cmpEnsureSource(true);
cmpShowNext(true);
}
function cmpCloseCompare(silent) {
if (!_cmp.open) return;
_cmp.open = false;
window._compareOpen = false;
var m = document.getElementById('modal');
window._modalOpen = !!(m && m.style.display === 'flex');
if (_cmp.revealTimer) { clearTimeout(_cmp.revealTimer); _cmp.revealTimer = null; }
_cmp.revealing = false;
document.removeEventListener('keydown', window._cmpKeyHandler, true);
window._cmpKeyHandler = null;
var ov = document.getElementById('compare-overlay');
if (ov) ov.style.display = 'none';
['cmp-media-a', 'cmp-media-b'].forEach(function (id) { var el = document.getElementById(id); if (el) el.textContent = ''; });
var sb = document.getElementById('scoreboard-modal');
if (sb && !silent) sb.remove();
cmpFlushNow(false);
if (!silent) cmpAfterRatingsChanged();
}
function closeCompareMode() {
cmpCloseCompare(false);
}
// ---------------------------------------------------------------------------
// Scoreboard modal
// ---------------------------------------------------------------------------
function cmpCopyText(text, btn) {
var done = function () {
if (!btn) return;
var o = btn.textContent;
btn.textContent = 'Copied';
setTimeout(function () { btn.textContent = o; }, 1500);
};
var fallback = function () {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch (e) { /* ignore */ }
document.body.removeChild(ta);
done();
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done, fallback);
} else fallback();
}
function cmpCompareMore(axis) {
var sb = document.getElementById('scoreboard-modal');
if (sb) sb.remove();
if (axis === 'seed') { _cmpSettings.mode = 'seed'; _cmpSettings.axis = ''; }
else { _cmpSettings.mode = 'one_axis'; _cmpSettings.axis = axis; }
cmpSaveSettings();
cmpSyncSettingsUI();
if (_cmp.open) { cmpEnsureSource(true); cmpShowNext(true); }
else openCompareMode();
}
function cmpEl(tag, cls, text) {
var el = document.createElement(tag);
if (cls) el.className = cls;
if (text !== undefined) el.textContent = text;
return el;
}
function cmpPct(x) { return (x * 100).toFixed(0) + '%'; }
function openScoreboard() {
var existing = document.getElementById('scoreboard-modal');
if (existing) existing.remove();
var items = cmpItemsMap();
var restrict = null;
if (_cmpSettings.restrict) {
restrict = new Set();
(processedData || []).forEach(function (d) { restrict.add(d.id); });
}
var ratings = cmpRatings();
var sb = computeScoreboard(cmpList(), items, ratings, { restrictIds: restrict, minN: 3, meta: meta });
var overlay = cmpEl('div', 'analytics-modal-overlay');
overlay.id = 'scoreboard-modal';
var popup = cmpEl('div', 'analytics-modal-popup');
var head = cmpEl('div', 'analytics-modal-header');
head.style.borderBottomColor = '#e0c040';
var title = cmpEl('span', 'analytics-modal-title', 'Pairwise scoreboard');
title.style.color = '#e0c040';
var closeBtn = cmpEl('button', 'close-popup-btn');
closeBtn.innerHTML = '&#10005;';
closeBtn.addEventListener('click', function () { overlay.remove(); });
head.appendChild(title); head.appendChild(closeBtn);
popup.appendChild(head);
var summary = cmpEl('div', 'analytics-summary');
summary.appendChild(cmpEl('span', '', sb.total + ' votes' + (restrict ? ' inside the current filter' : '')));
summary.appendChild(cmpEl('span', '', '|'));
summary.appendChild(cmpEl('span', '', 'win % counts ties as half; best = highest 95% Wilson lower bound with n >= 3'));
summary.title = 'Pairs are chosen with a preference for low-vote items and respect your filters, so win % is not a controlled effect size.';
popup.appendChild(summary);
var body = cmpEl('div', 'analytics-modal-body');
var order = CMP_AXES.concat(['seed']);
order.forEach(function (ax) {
var blk = sb.axes[ax];
var det = cmpEl('details', 'sb-axis');
det.open = blk.rows.length > 0;
var sm = cmpEl('summary', '', ax === 'seed' ? 'seed (noise floor)' : ax);
sm.appendChild(cmpEl('span', 'sb-meta', blk.matchedVotes + ' matched votes'));
det.appendChild(sm);
var anyEnough = blk.rows.some(function (r) { return r.enough; });
if (!anyEnough) {
det.appendChild(cmpEl('div', 'sb-empty', 'not enough data (' + blk.matchedVotes + ' matched votes)'));
}
if (blk.rows.length) {
var table = cmpEl('table', 'analytics-table');
var thead = cmpEl('thead');
var hr = cmpEl('tr');
['value', 'n', 'W-L-T', 'win %', '95% CI', 'mean rating', ''].forEach(function (h) { hr.appendChild(cmpEl('th', 'analytics-th', h)); });
thead.appendChild(hr); table.appendChild(thead);
var tb = cmpEl('tbody');
blk.rows.forEach(function (r) {
var tr = cmpEl('tr', 'analytics-row' + (r.enough ? '' : ' sb-row-dim') + (r.value === blk.best ? ' sb-row-best' : ''));
var tdv = cmpEl('td', 'sb-val', r.label); tdv.title = r.value;
tr.appendChild(tdv);
tr.appendChild(cmpEl('td', 'sb-num', String(r.n)));
tr.appendChild(cmpEl('td', 'sb-num', r.wins + '-' + r.losses + '-' + r.ties));
tr.appendChild(cmpEl('td', 'sb-num', r.enough ? cmpPct(r.winPct) : 'not enough data'));
tr.appendChild(cmpEl('td', 'sb-num', r.enough ? cmpPct(r.wilsonLow) + '–' + cmpPct(r.wilsonHigh) : ''));
tr.appendChild(cmpEl('td', 'sb-num', r.meanRating === null ? '' : String(Math.round(r.meanRating))));
tr.appendChild(cmpEl('td', '', ''));
tb.appendChild(tr);
});
table.appendChild(tb);
det.appendChild(table);
}
var more = cmpEl('div', 'sb-link', ax === 'seed' ? 'compare more seeds' : 'compare more');
more.addEventListener('click', function () { cmpCompareMore(ax); });
det.appendChild(more);
body.appendChild(det);
});
popup.appendChild(body);
var foot = cmpEl('div', 'sb-footer');
var seedVotes = sb.axes.seed.matchedVotes;
var txt = sb.attributable + ' of ' + sb.total + ' votes attributable to exactly one axis';
if (seedVotes) txt += ' (+ ' + seedVotes + ' seed-only votes in the noise-floor block)';
foot.appendChild(cmpEl('div', '', txt));
if (sb.topExtraKeys.length) {
foot.appendChild(cmpEl('div', '', 'Other differences in the remaining votes: ' +
sb.topExtraKeys.map(function (k) { return k.key + ' (' + k.count + ')'; }).join(', ')));
}
popup.appendChild(foot);
var actions = cmpEl('div', 'sb-actions');
var bj = cmpEl('button', '', 'Copy JSON'), bc = cmpEl('button', '', 'Copy CSV'), bx = cmpEl('button', '', 'Close');
bj.addEventListener('click', function () { cmpCopyText(JSON.stringify(sb, null, 2), bj); });
bc.addEventListener('click', function () { cmpCopyText(scoreboardToCSV(sb), bc); });
bx.addEventListener('click', function () { overlay.remove(); });
actions.appendChild(bj); actions.appendChild(bc); actions.appendChild(bx);
popup.appendChild(actions);
overlay.appendChild(popup);
overlay.addEventListener('click', function (e) { if (e.target === overlay) overlay.remove(); });
document.body.appendChild(overlay);
// close the settings panel so the modal is not stacked over it
var cog = document.getElementById('cog-menu-dropdown');
if (cog && cog.style.display !== 'none' && cog.style.display !== '' && typeof toggleCogMenu === 'function') toggleCogMenu();
}
+1
View File
@@ -86,6 +86,7 @@ window.addEventListener('message', (event) => {
fullManifest = payload.manifest;
activeData = fullManifest.items || [];
meta = payload.meta || {};
if (typeof onComparisonsReplaced === 'function') onComparisonsReplaced();
// Recompute label global values with new data
if (typeof computeLabelGlobalValues === 'function' && labelMode && labelMode.enabled) {
+5
View File
@@ -50,6 +50,11 @@ function init() {
loadLabelPreferences();
}
// Pairwise review settings (cog panel) + vote sync
if (typeof initCompareUI === 'function') {
initCompareUI();
}
// Start
refreshIndices();
+2
View File
@@ -310,6 +310,8 @@ function compareItems(a, b, sortKey) {
return (a.sampler || "").toLowerCase().localeCompare((b.sampler || "").toLowerCase());
case 'size':
return (a.width * a.height) - (b.width * b.height);
case 'rating':
return (typeof compareRatings === 'function') ? compareRatings(a, b) : 0;
default: return 0;
}
}
+538
View File
@@ -0,0 +1,538 @@
/**
* PAIRWISE REVIEW - PURE LOGIC (no DOM access)
* Axis normalisation, attribution, Bradley-Terry ratings, pair selection, scoreboard.
* The dashboard inlines every logic_*.js into ONE <script>, so top-level names here are
* prefixed (CMP_ / cmp / rt) or unique. Node tests load this file through the
* module.exports guard at the bottom (tests/js/ratings.test.js).
*
* Vote shape (manifest top-level "comparisons"):
* { a, b, winner: 'a'|'b'|'tie', ts, mode, blind? } a = left/top item id
*/
var CMP_AXES = ['model', 'lora', 'sampler', 'scheduler', 'cfg', 'steps', 'denoise', 'resolution', 'prompt', 'upscale'];
// 'seed' is a diagnostic axis (noise floor): it participates in axisDiff but not in CMP_AXES
var CMP_DIFF_AXES = CMP_AXES.concat(['seed']);
// Item keys that never make two items "different settings" (deny list on purpose: unknown
// keys such as vae / clip_type / use_slg are treated as confounders and block attribution).
var CMP_EXCLUDED_KEYS = new Set([
'id', 'gen_index', 'file', 'filename', 'duration', 'batch_idx', 'favorited', 'rejected',
'note', 'notes', 'positive', 'config_positive', 'negative', 'config_negative', 'seed',
'width', 'height', 'base_width', 'base_height', 'model', 'lora', 'lora_expanded',
'sampler', 'scheduler', 'cfg', 'steps', 'denoise', 'upscaled', 'source_file',
'image_path', 'preview_path', 'video_path', 'hash', 'hires_denoise'
]);
var CMP_EXCLUDED_RE = /^upscale_|^hires_prompt_|_hash$|^worker|^_/;
// ---------------------------------------------------------------------------
// Normalisers
// ---------------------------------------------------------------------------
function fmtNum(v) {
var ok = (typeof v === 'number') || (typeof v === 'string' && v.trim() !== '');
if (ok && isFinite(+v)) return String(+(+v).toFixed(6));
return String(v === undefined || v === null ? '?' : v);
}
function cmpBase(s) {
return String(s).replace(/\\/g, '/').split('/').pop().replace(/\.[^.]+$/, '');
}
function cmpProduct(r) {
var arr = Array.isArray(r) ? r : [r];
var p = 1, any = false;
for (var i = 0; i < arr.length; i++) {
var n = +arr[i];
if (isFinite(n) && n > 0 && arr[i] !== null && arr[i] !== '') { p *= n; any = true; }
}
return any ? p : 0;
}
function normModel(d, m) {
var raw = String(d.model || (m && m.model) || 'Default');
return { key: raw.replace(/\\/g, '/'), label: cmpBase(raw) };
}
function normLora(d) {
var raw = String(d.lora_expanded || d.lora || 'None');
if (raw === 'None' || raw === '') return { key: 'None', label: 'None' };
var parts = raw.split(' + ').map(function (p) {
var c = p.split(':');
var name = c[0].replace(/\\/g, '/');
var ms = fmtNum(c[1] !== undefined && c[1] !== '' ? c[1] : 1);
var cs = fmtNum(c[2] !== undefined && c[2] !== '' ? c[2] : ms);
return { name: name, ms: ms, cs: cs };
});
parts.sort(function (x, y) {
var a = x.name + ':' + x.ms + ':' + x.cs, b = y.name + ':' + y.ms + ':' + y.cs;
return a < b ? -1 : a > b ? 1 : 0;
});
return {
key: parts.map(function (p) { return p.name + ':' + p.ms + ':' + p.cs; }).join(' + '),
label: parts.map(function (p) {
return cmpBase(p.name) + '@' + p.ms + (p.cs !== p.ms ? '/' + p.cs : '');
}).join(' + ')
};
}
function normPrompt(d, m) {
var raw = d.config_positive !== undefined && d.config_positive !== null ? d.config_positive
: (d.positive !== undefined && d.positive !== null ? d.positive
: ((m && m.positive) || ''));
var s = String(raw).replace(/\s+/g, ' ').trim();
return { key: s, label: s.length > 60 ? s.slice(0, 60) + '…' : s };
}
function normResolution(d) {
var k;
if (d.base_width && d.base_height) {
k = d.base_width + 'x' + d.base_height;
} else if (!d.upscaled) {
k = d.width + 'x' + d.height;
} else {
var r = cmpProduct(d.upscale_ratio);
if (r > 0 && d.width > 0 && d.height > 0) {
k = (Math.round(d.width / r / 8) * 8) + 'x' + (Math.round(d.height / r / 8) * 8);
} else {
k = 'AR ' + (d.height > 0 ? (d.width / d.height).toFixed(2) : '?');
}
}
return { key: k, label: k };
}
function normUpscale(d) {
if (!d.upscaled) return { key: 'none', label: 'none' };
var join = function (v) { return Array.isArray(v) ? v.join('>') : v; };
var mode = d.upscale_mode !== undefined && d.upscale_mode !== null ? String(join(d.upscale_mode)) : '';
var models = d.upscale_model !== undefined && d.upscale_model !== null && d.upscale_model !== ''
? (Array.isArray(d.upscale_model) ? d.upscale_model : [d.upscale_model]).map(cmpBase).join('>') : '';
var ratio = d.upscale_ratio !== undefined && d.upscale_ratio !== null
? (Array.isArray(d.upscale_ratio) ? d.upscale_ratio.map(fmtNum).join('>') : fmtNum(d.upscale_ratio)) : '';
var parts = [];
if (mode) parts.push(mode);
if (models) parts.push(models);
if (ratio) parts.push('x' + ratio);
if (d.upscale_denoise !== undefined && d.upscale_denoise !== null) parts.push('d' + fmtNum(d.upscale_denoise));
if (d.hires_prompt_behavior) parts.push('hp:' + d.hires_prompt_behavior);
if (!parts.length) parts.push('upscaled');
var label = [mode, models, ratio ? 'x' + ratio : ''].filter(Boolean).join(' ') || 'upscaled';
return { key: parts.join('|'), label: label };
}
function normalizeAxis(axis, d, m) {
switch (axis) {
case 'model': return normModel(d, m);
case 'lora': return normLora(d);
case 'sampler': { var s = String(d.sampler == null ? '?' : d.sampler); return { key: s, label: s }; }
case 'scheduler': { var c = String(d.scheduler == null ? '?' : d.scheduler); return { key: c, label: c }; }
case 'cfg': { var f = fmtNum(d.cfg); return { key: f, label: f }; }
case 'steps': { var t = fmtNum(d.steps); return { key: t, label: t }; }
case 'denoise': { var n = fmtNum(d.denoise); return { key: n, label: n }; }
case 'resolution': return normResolution(d);
case 'prompt': return normPrompt(d, m);
case 'upscale': return normUpscale(d);
case 'seed': { var e = fmtNum(d.seed); return { key: e, label: e }; }
}
return { key: '?', label: '?' };
}
function cmpStable(v) {
if (v === null || typeof v !== 'object') return JSON.stringify(v === undefined ? null : v);
if (Array.isArray(v)) return '[' + v.map(cmpStable).join(',') + ']';
return '{' + Object.keys(v).sort().map(function (k) { return JSON.stringify(k) + ':' + cmpStable(v[k]); }).join(',') + '}';
}
function cmpCtxEntries(d) {
var out = {};
var ks = Object.keys(d);
for (var i = 0; i < ks.length; i++) {
var k = ks[i];
if (CMP_EXCLUDED_KEYS.has(k) || CMP_EXCLUDED_RE.test(k)) continue;
out[k] = d[k];
}
out.negative = d.config_negative !== undefined && d.config_negative !== null ? d.config_negative : d.negative;
return out;
}
function cmpMediaType(d) { return d.media_type === 'video' ? 'video' : 'image'; }
var _cmpSigCache = new WeakMap();
/** {axes:{axis:key}, labels:{axis:label}, ctx} cached per item object (invalidated on w/h/meta change). */
function itemSig(d, m) {
var hit = _cmpSigCache.get(d);
if (hit && hit.w === d.width && hit.h === d.height && hit.m === m) return hit;
var axes = {}, labels = {};
for (var i = 0; i < CMP_DIFF_AXES.length; i++) {
var ax = CMP_DIFF_AXES[i];
var r = normalizeAxis(ax, d, m);
axes[ax] = r.key; labels[ax] = r.label;
}
var sig = { axes: axes, labels: labels, ctx: cmpStable(cmpCtxEntries(d)), w: d.width, h: d.height, m: m };
_cmpSigCache.set(d, sig);
return sig;
}
/** Which axes differ between two items. ctxKeys only when opts.withCtxKeys (diagnostics). */
function axisDiff(A, B, opts) {
opts = opts || {};
var m = opts.meta;
var sa = itemSig(A, m), sb = itemSig(B, m);
var axes = [];
for (var i = 0; i < CMP_DIFF_AXES.length; i++) {
var ax = CMP_DIFF_AXES[i];
if (sa.axes[ax] !== sb.axes[ax]) axes.push(ax);
}
var ctxEqual = sa.ctx === sb.ctx;
var ctxKeys = [];
if (!ctxEqual && opts.withCtxKeys) {
var ea = cmpCtxEntries(A), eb = cmpCtxEntries(B);
var all = new Set(Object.keys(ea).concat(Object.keys(eb)));
all.forEach(function (k) {
if (cmpStable(ea[k]) !== cmpStable(eb[k])) ctxKeys.push(k);
});
ctxKeys.sort();
}
return { axes: axes, ctxEqual: ctxEqual, ctxKeys: ctxKeys };
}
// ---------------------------------------------------------------------------
// Comparison list helpers
// ---------------------------------------------------------------------------
function cmpVoteKey(c) { return c.ts + ':' + c.a + ':' + c.b; }
function mergeComparisons(existing, incoming) {
var out = [], seen = new Set();
var all = (existing || []).concat(incoming || []);
for (var i = 0; i < all.length; i++) {
var c = all[i];
if (!c) continue;
var k = cmpVoteKey(c);
if (seen.has(k)) continue;
seen.add(k);
out.push(c);
}
return out;
}
function cmpPairKey(a, b) { return a < b ? a + '|' + b : b + '|' + a; }
function cmpValidVote(c, ids) {
return c && c.a !== c.b && (c.winner === 'a' || c.winner === 'b' || c.winner === 'tie') &&
ids.has(c.a) && ids.has(c.b);
}
// ---------------------------------------------------------------------------
// Bradley-Terry (MM, Hunter 2004) with a ghost-player Laplace prior
// ---------------------------------------------------------------------------
function computeBradleyTerry(comparisons, presentIds, opts) {
opts = opts || {};
var prior = opts.prior !== undefined ? opts.prior : 1;
var maxIter = opts.maxIter || 200;
var tol = opts.tol || 1e-6;
var ids = presentIds instanceof Set ? presentIds : new Set(presentIds || []);
var rec = new Map(); // id -> {w,l,t,W,nb:Map}
var get = function (id) {
var r = rec.get(id);
if (!r) { r = { w: 0, l: 0, t: 0, W: prior, nb: new Map() }; rec.set(id, r); }
return r;
};
for (var i = 0; i < (comparisons || []).length; i++) {
var c = comparisons[i];
if (!cmpValidVote(c, ids)) continue;
var ra = get(c.a), rb = get(c.b);
ra.nb.set(c.b, (ra.nb.get(c.b) || 0) + 1);
rb.nb.set(c.a, (rb.nb.get(c.a) || 0) + 1);
if (c.winner === 'a') { ra.w++; rb.l++; ra.W += 1; }
else if (c.winner === 'b') { rb.w++; ra.l++; rb.W += 1; }
else { ra.t++; rb.t++; ra.W += 0.5; rb.W += 0.5; }
}
var out = new Map();
if (!rec.size) return out;
var p = new Map();
rec.forEach(function (_, id) { p.set(id, 1); });
var nG = 2 * prior;
for (var it = 0; it < maxIter; it++) {
var next = new Map(), delta = 0;
rec.forEach(function (r, id) {
var pi = p.get(id);
var denom = nG / (pi + 1);
r.nb.forEach(function (n, j) { denom += n / (pi + p.get(j)); });
var pn = r.W / denom;
next.set(id, pn);
var dl = Math.abs(Math.log(pn) - Math.log(pi));
if (dl > delta) delta = dl;
});
p = next;
if (delta < tol) break;
}
rec.forEach(function (r, id) {
var pi = p.get(id);
out.set(id, { rating: 1500 + 400 * Math.log10(pi), votes: r.w + r.l + r.t, w: r.w, l: r.l, t: r.t, p: pi });
});
return out;
}
// ---------------------------------------------------------------------------
// Pair source + selection
// ---------------------------------------------------------------------------
var CMP_MODES = ['matched', 'seed', 'one_axis', 'random'];
/**
* pool: array of item objects. mode: matched | seed | one_axis | random. axisFocus: axis or ''.
* opts.meta: manifest meta for fallbacks. Returns {mode, axisFocus, buckets, estimate}.
*/
function buildPairSource(pool, mode, axisFocus, opts) {
opts = opts || {};
var m = opts.meta;
var intern = new Map();
var iid = function (s) {
var v = intern.get(s);
if (v === undefined) { v = intern.size; intern.set(s, v); }
return v;
};
var entries = [];
for (var i = 0; i < pool.length; i++) {
var d = pool[i];
if (!d) continue;
var sg = itemSig(d, m);
var vec = new Array(CMP_AXES.length);
for (var a = 0; a < CMP_AXES.length; a++) vec[a] = iid(CMP_AXES[a] + '=' + sg.axes[CMP_AXES[a]]);
entries.push({
d: d, id: d.id, vec: vec, seed: iid('seed=' + sg.axes.seed), ctx: iid('ctx=' + sg.ctx),
mt: cmpMediaType(d)
});
}
var promptIdx = CMP_AXES.indexOf('prompt');
var buckets = [];
var addBuckets = function (keyFn, distinctFn, validFn, axisIdx) {
var map = new Map();
for (var i = 0; i < entries.length; i++) {
var e = entries[i];
var k = keyFn(e);
var arr = map.get(k);
if (!arr) { arr = []; map.set(k, arr); }
arr.push(e);
}
map.forEach(function (items) {
if (items.length < 2) return;
var first = distinctFn(items[0]), ok = false;
for (var j = 1; j < items.length; j++) if (distinctFn(items[j]) !== first) { ok = true; break; }
if (!ok) return;
buckets.push({ items: items, weight: Math.min(items.length, 50), valid: validFn, axis: axisIdx === undefined ? null : CMP_AXES[axisIdx] });
});
};
var configSig = function (e) {
var parts = [];
for (var a = 0; a < e.vec.length; a++) if (a !== promptIdx) parts.push(e.vec[a]);
parts.push(e.ctx);
return parts.join(',');
};
if (mode === 'matched') {
addBuckets(function (e) { return e.vec[promptIdx] + '|' + e.seed + '|' + e.mt; }, configSig,
function (x, y) { return configSig(x) !== configSig(y); });
} else if (mode === 'seed') {
addBuckets(function (e) { return e.vec.join(',') + '|' + e.ctx + '|' + e.mt; },
function (e) { return e.seed; }, function (x, y) { return x.seed !== y.seed; });
} else if (mode === 'one_axis') {
var list = axisFocus && CMP_AXES.indexOf(axisFocus) >= 0 ? [CMP_AXES.indexOf(axisFocus)] : CMP_AXES.map(function (_, i) { return i; });
list.forEach(function (ai) {
addBuckets(function (e) {
var rest = [];
for (var a = 0; a < e.vec.length; a++) if (a !== ai) rest.push(e.vec[a]);
return ai + '|' + e.ctx + '|' + rest.join(',') + '|' + e.seed + '|' + e.mt;
},
function (e) { return e.vec[ai]; }, function (x, y) { return x.vec[ai] !== y.vec[ai]; }, ai);
});
} else {
addBuckets(function (e) { return e.mt; }, function (e) { return e.id; }, function () { return true; });
}
var estimate = 0;
for (var b = 0; b < buckets.length; b++) { var k = buckets[b].items.length; estimate += k * (k - 1) / 2; }
return { mode: mode, axisFocus: axisFocus || '', buckets: buckets, estimate: estimate, poolSize: entries.length };
}
function cmpPickOne(items, rng) { return items[Math.floor(rng() * items.length)]; }
/**
* state: {votesById: Map id->count, votedPairs: Set(pairKey), skipped: Set(pairKey), allowRepeats}
* Returns {a, b (item objects, random left/right), repeating, axis} or null.
*/
function nextPair(src, state, rng) {
rng = rng || Math.random;
if (!src || !src.buckets.length) return null;
var votes = function (id) { return (state.votesById && state.votesById.get(id)) || 0; };
var cum = [], total = 0;
for (var i = 0; i < src.buckets.length; i++) { total += src.buckets[i].weight; cum.push(total); }
var pickBucket = function () {
var r = rng() * total, lo = 0, hi = cum.length - 1;
while (lo < hi) { var mid = (lo + hi) >> 1; if (cum[mid] > r) hi = mid; else lo = mid + 1; }
return src.buckets[lo];
};
var attempt = function (allowRepeats) {
var best = null;
for (var s = 0; s < 64; s++) {
var bk = pickBucket();
var anchor = null;
for (var t = 0; t < 4; t++) {
var c = cmpPickOne(bk.items, rng);
if (!anchor || votes(c.id) < votes(anchor.id)) anchor = c;
}
var partner = null;
for (var u = 0; u < 8; u++) {
var y = cmpPickOne(bk.items, rng);
if (y === anchor || y.id === anchor.id || !bk.valid(anchor, y)) continue;
var pk = cmpPairKey(anchor.id, y.id);
if (state.skipped && state.skipped.has(pk)) continue;
if (!allowRepeats && state.votedPairs && state.votedPairs.has(pk)) continue;
if (!partner || votes(y.id) < votes(partner.id)) partner = y;
}
if (!partner) continue;
var score = votes(anchor.id) + votes(partner.id) + rng() * 0.5;
if (!best || score < best.score) best = { score: score, x: anchor, y: partner, axis: bk.axis };
}
return best;
};
var repeating = false;
var best = attempt(!!state.allowRepeats);
if (!best && !state.allowRepeats) { best = attempt(true); repeating = !!best; }
else if (best && state.allowRepeats) repeating = true;
if (!best) return null;
var swap = rng() < 0.5;
return { a: swap ? best.y.d : best.x.d, b: swap ? best.x.d : best.y.d, repeating: repeating, axis: best.axis };
}
// ---------------------------------------------------------------------------
// Scoreboard
// ---------------------------------------------------------------------------
function wilsonInterval(s, n, z) {
z = z || 1.96;
if (!n) return { low: 0, high: 0 };
var p = s / n, z2 = z * z;
var den = 1 + z2 / n;
var centre = (p + z2 / (2 * n)) / den;
var half = (z * Math.sqrt(p * (1 - p) / n + z2 / (4 * n * n))) / den;
return { low: Math.max(0, centre - half), high: Math.min(1, centre + half) };
}
function cmpItemLookup(itemsById) {
return itemsById instanceof Map ? function (id) { return itemsById.get(id); } : function (id) { return itemsById[id]; };
}
function computeScoreboard(comparisons, itemsById, ratings, opts) {
opts = opts || {};
var minN = opts.minN !== undefined ? opts.minN : 3;
var m = opts.meta;
var restrict = opts.restrictIds || null;
var lookup = cmpItemLookup(itemsById);
var axesOut = {};
var tallies = {};
var order = CMP_AXES.concat(['seed']);
order.forEach(function (ax) { tallies[ax] = new Map(); });
var bump = function (ax, key, label, s, res) {
var t = tallies[ax].get(key);
if (!t) { t = { key: key, label: label, n: 0, s: 0, w: 0, l: 0, t: 0 }; tallies[ax].set(key, t); }
t.n++; t.s += s;
if (res === 'w') t.w++; else if (res === 'l') t.l++; else t.t++;
};
var total = 0, attributable = 0, nonAttr = 0;
var extra = new Map();
var matchedVotes = {};
for (var i = 0; i < (comparisons || []).length; i++) {
var c = comparisons[i];
if (!c || c.a === c.b) continue;
if (c.winner !== 'a' && c.winner !== 'b' && c.winner !== 'tie') continue;
var A = lookup(c.a), B = lookup(c.b);
if (!A || !B) continue;
if (restrict && !(restrict.has(c.a) && restrict.has(c.b))) continue;
total++;
var df = axisDiff(A, B, { meta: m });
var single = df.ctxEqual && (df.axes.length === 1);
if (single) {
var ax = df.axes[0];
var sa = itemSig(A, m), sb = itemSig(B, m);
var va = c.winner === 'a' ? 1 : c.winner === 'tie' ? 0.5 : 0;
bump(ax, sa.axes[ax], sa.labels[ax], va, c.winner === 'a' ? 'w' : c.winner === 'b' ? 'l' : 't');
bump(ax, sb.axes[ax], sb.labels[ax], 1 - va, c.winner === 'b' ? 'w' : c.winner === 'a' ? 'l' : 't');
matchedVotes[ax] = (matchedVotes[ax] || 0) + 1;
if (ax !== 'seed') attributable++;
} else {
nonAttr++;
var dd = axisDiff(A, B, { meta: m, withCtxKeys: true });
var keys = dd.ctxKeys.slice();
if (dd.axes.length > 1) keys = keys.concat(dd.axes);
if (!keys.length) keys.push('(identical settings)');
keys.forEach(function (k) { extra.set(k, (extra.get(k) || 0) + 1); });
}
}
// mean rating per axis value over rated items
var means = {};
order.forEach(function (ax) { means[ax] = new Map(); });
if (ratings && ratings.size) {
var consider = function (d) {
var r = ratings.get(d.id);
if (!r) return;
var sg = itemSig(d, m);
order.forEach(function (ax) {
var k = sg.axes[ax];
var e = means[ax].get(k);
if (!e) { e = { sum: 0, n: 0 }; means[ax].set(k, e); }
e.sum += r.rating; e.n++;
});
};
if (itemsById instanceof Map) itemsById.forEach(function (d, id) { if (!restrict || restrict.has(id)) consider(d); });
else Object.keys(itemsById).forEach(function (id) { var d = itemsById[id]; if (!restrict || restrict.has(d.id)) consider(d); });
}
order.forEach(function (ax) {
var rows = [];
tallies[ax].forEach(function (t) {
var w = wilsonInterval(t.s, t.n);
var mr = means[ax].get(t.key);
rows.push({
value: t.key, label: t.label, n: t.n, wins: t.w, losses: t.l, ties: t.t,
winPct: t.n ? t.s / t.n : 0, wilsonLow: w.low, wilsonHigh: w.high,
meanRating: mr && mr.n ? mr.sum / mr.n : null, enough: t.n >= minN
});
});
rows.sort(function (x, y) { return (y.winPct - x.winPct) || (y.n - x.n); });
var best = null;
rows.forEach(function (r) {
if (!r.enough) return;
if (!best || r.wilsonLow > best.wilsonLow || (r.wilsonLow === best.wilsonLow && r.winPct > best.winPct)) best = r;
});
if (best) best.best = true;
axesOut[ax] = { rows: rows, best: best ? best.value : null, matchedVotes: matchedVotes[ax] || 0 };
});
var topExtraKeys = Array.from(extra.entries()).map(function (e) { return { key: e[0], count: e[1] }; })
.sort(function (x, y) { return y.count - x.count || (x.key < y.key ? -1 : 1); }).slice(0, 5);
return { axes: axesOut, attributable: attributable, total: total, nonAttributable: nonAttr, topExtraKeys: topExtraKeys };
}
function cmpCsvCell(v) {
var s = v === null || v === undefined ? '' : String(v);
return /[",\r\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
}
function scoreboardToCSV(sb) {
var lines = ['axis,value,n,wins,losses,ties,win_pct,wilson_low,wilson_high,mean_rating,best'];
Object.keys(sb.axes).forEach(function (ax) {
sb.axes[ax].rows.forEach(function (r) {
lines.push([
ax, r.value, r.n, r.wins, r.losses, r.ties,
(r.winPct * 100).toFixed(1), (r.wilsonLow * 100).toFixed(1), (r.wilsonHigh * 100).toFixed(1),
r.meanRating === null ? '' : r.meanRating.toFixed(1), r.value === sb.axes[ax].best ? 'true' : 'false'
].map(cmpCsvCell).join(','));
});
});
return lines.join('\n');
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
CMP_AXES: CMP_AXES, CMP_DIFF_AXES: CMP_DIFF_AXES, CMP_MODES: CMP_MODES,
CMP_EXCLUDED_KEYS: CMP_EXCLUDED_KEYS, CMP_EXCLUDED_RE: CMP_EXCLUDED_RE,
fmtNum: fmtNum, normalizeAxis: normalizeAxis, itemSig: itemSig, axisDiff: axisDiff,
mergeComparisons: mergeComparisons, cmpVoteKey: cmpVoteKey, cmpPairKey: cmpPairKey,
computeBradleyTerry: computeBradleyTerry, buildPairSource: buildPairSource, nextPair: nextPair,
wilsonInterval: wilsonInterval, computeScoreboard: computeScoreboard, scoreboardToCSV: scoreboardToCSV
};
}
+4 -2
View File
@@ -54,7 +54,8 @@ const sortOptionsList = [
{ id: 'lora', label: 'LoRA' },
{ id: 'sampler', label: 'Sampler' },
{ id: 'seed', label: 'Seed' },
{ id: 'size', label: 'Size' }
{ id: 'size', label: 'Size' },
{ id: 'rating', label: 'Rating' }
];
// --- SEARCH FILTER STATE ---
@@ -122,7 +123,8 @@ var labelMode = labelMode || {
upscaleHiresSteps: false,
upscaleTiling: false,
hiresPromptBehavior: false,
hiresPromptText: false
hiresPromptText: false,
rating: false // Pairwise-review rating badge
}
};
+16 -2
View File
@@ -295,6 +295,13 @@ function buildLabelOverlay(d) {
}
}
if (labelMode.fields.rating && typeof getItemRating === 'function') {
const rt = getItemRating(d.id);
if (rt && rt.votes > 0) {
tags.push(`<span class="label-tag label-rating" title="Pairwise rating (Bradley-Terry) and number of votes">★${Math.round(rt.rating)} · ${rt.votes}</span>`);
}
}
if (tags.length === 0) return '';
return `<div class="label-overlay">${tags.join('')}</div>`;
}
@@ -320,7 +327,7 @@ function toggleLabelMode(enabled) {
function updateLabelFields() {
const fields = ['model', 'lora', 'prompt', 'sampler', 'scheduler', 'cfg', 'steps', 'seed', 'denoise', 'upscale',
'upscaleMode', 'upscaleModel', 'upscaleRatio', 'upscaleDenoise', 'upscaleResizeMethod', 'upscaleHiresSteps', 'upscaleTiling',
'hiresPromptBehavior', 'hiresPromptText'];
'hiresPromptBehavior', 'hiresPromptText', 'rating'];
fields.forEach(f => {
const cb = document.getElementById(`label-field-${f}`);
if (cb) labelMode.fields[f] = cb.checked;
@@ -388,7 +395,7 @@ function loadLabelPreferences() {
const fields = ['model', 'lora', 'prompt', 'sampler', 'scheduler', 'cfg', 'steps', 'seed', 'denoise', 'upscale',
'upscaleMode', 'upscaleModel', 'upscaleRatio', 'upscaleDenoise', 'upscaleResizeMethod', 'upscaleHiresSteps', 'upscaleTiling',
'hiresPromptBehavior', 'hiresPromptText'];
'hiresPromptBehavior', 'hiresPromptText', 'rating'];
fields.forEach(f => {
const cb = document.getElementById(`label-field-${f}`);
if (cb) cb.checked = labelMode.fields[f] || false;
@@ -524,6 +531,13 @@ document.addEventListener('keydown', (e) => {
return;
}
// Close pairwise-review scoreboard
const scoreboardModal = document.getElementById('scoreboard-modal');
if (scoreboardModal) {
scoreboardModal.remove();
return;
}
// Close analytics results modal
const analyticsModal = document.getElementById('analytics-results-modal');
if (analyticsModal) {
+2
View File
@@ -22,6 +22,7 @@ function toggleFullscreen() {
function setupKeyReloadFullscreen() {
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (window._compareOpen) return; // compare mode swallows f / r
switch (e.key) {
case 'f':
@@ -102,6 +103,7 @@ async function loadSession() {
fullManifest = data;
activeData = fullManifest.items || [];
meta = fullManifest.meta || {};
if (typeof onComparisonsReplaced === 'function') onComparisonsReplaced();
console.log(`[Load] 📦 activeData now has ${activeData.length} items`);
+8
View File
@@ -906,6 +906,14 @@ function setupKeyboardShortcuts() {
updateTransform();
updateVisibleItems();
break;
case 'c':
case 'C':
// Pairwise review (compare mode); plain C only, so Ctrl+C copy is untouched
if (!e.ctrlKey && !e.metaKey && !e.altKey && typeof openCompareMode === 'function') {
e.preventDefault();
openCompareMode();
}
break;
}
// 1-9 (no modifiers): Quick favorite — toggle favorite on Nth visible card
+219 -1
View File
@@ -1745,7 +1745,8 @@ body {
}
}
/* ==========================================================================
/* ===================================================================
TOUCH / PHONE LAYOUT
Scoped to a coarse pointer, or to the standalone page (body.uscg-standalone)
at phone width. Never width alone: node iframes are narrow too and must look
@@ -1821,3 +1822,220 @@ body {
body.uscg-standalone .session-card { width: calc(50% - 6px) !important; }
body.uscg-standalone #session-landing { padding: 16px 12px !important; }
}
=======
/* ============================================================
PAIRWISE REVIEW (compare overlay + scoreboard)
============================================================ */
.cmp-setting-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
font-size: 10px;
color: #999;
}
.cmp-setting-row label { flex: 0 0 auto; user-select: none; }
.cmp-setting-row select {
flex: 1;
min-width: 0;
background: #1a1a1a;
color: #ddd;
border: 1px solid #444;
border-radius: 4px;
padding: 4px;
font-size: 11px;
}
#compare-overlay {
position: fixed;
inset: 0;
z-index: 9000;
background: #050505;
flex-direction: column;
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
}
#cmp-head {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: #0a0a0a;
border-bottom: 1px solid #333;
flex-shrink: 0;
flex-wrap: wrap;
font-size: 11px;
color: #aaa;
}
#cmp-mode-label { font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; }
#cmp-progress { color: #888; }
#cmp-repeat-badge {
background: #7a4a00;
color: #ffd27a;
border-radius: 3px;
padding: 1px 6px;
font-size: 10px;
}
.cmp-spacer { flex: 1; }
.cmp-head-btn {
background: #1a1a1a;
color: #ccc;
border: 1px solid #444;
border-radius: 4px;
padding: 6px 10px;
font-size: 11px;
cursor: pointer;
}
.cmp-head-btn:hover { border-color: var(--accent); color: var(--accent); }
.cmp-head-btn.active { background: #3a2f00; border-color: #e0c040; color: #e0c040; }
#cmp-panes {
flex: 1;
min-height: 0;
display: flex;
gap: 6px;
padding: 6px;
}
.cmp-pane {
flex: 1 1 50%;
min-width: 0;
min-height: 0;
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: #0d0d0d;
border: 3px solid transparent;
border-radius: 6px;
cursor: pointer;
overflow: hidden;
}
.cmp-pane.cmp-winner { border-color: #2ecc71; }
.cmp-pane.cmp-tie { border-color: #888; }
.cmp-media { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
.cmp-media img, .cmp-media video {
max-width: 100%;
max-height: 100%;
object-fit: contain;
pointer-events: none;
}
.cmp-label {
position: absolute;
top: 8px;
left: 10px;
z-index: 2;
font-size: 18px;
font-weight: 800;
color: rgba(255, 255, 255, 0.55);
text-shadow: 0 1px 4px #000;
pointer-events: none;
}
.cmp-chips {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 2;
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 8px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.85));
pointer-events: none;
}
.cmp-chips:empty { display: none; }
.cmp-chip {
background: rgba(0, 0, 0, 0.75);
border: 1px solid #555;
color: #fff;
border-radius: 3px;
padding: 2px 6px;
font-size: 11px;
font-family: monospace;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#cmp-empty {
position: absolute;
left: 0;
right: 0;
top: 48px;
bottom: 64px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 24px;
color: #aaa;
font-size: 14px;
line-height: 1.6;
background: #050505;
z-index: 3;
}
#cmp-bar {
display: flex;
gap: 8px;
justify-content: center;
padding: 8px;
background: #0a0a0a;
border-top: 1px solid #333;
flex-shrink: 0;
flex-wrap: wrap;
}
.cmp-btn {
min-height: 44px;
min-width: 88px;
padding: 8px 16px;
background: #1c1c1c;
color: #eee;
border: 1px solid #555;
border-radius: 6px;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.cmp-btn:hover { border-color: var(--accent); }
.cmp-btn:active { background: #2a2a2a; }
.cmp-btn-undo { background: #14202a; }
@media (max-aspect-ratio: 1/1) and (max-width: 700px) {
#cmp-panes { flex-direction: column; }
.cmp-pane { flex: 1 1 50%; }
.cmp-btn { min-width: 0; flex: 1; padding: 8px 6px; font-size: 12px; }
}
.label-tag.label-rating { background: rgba(110, 90, 0, 0.7); }
/* Scoreboard (reuses the analytics modal classes) */
.sb-axis { border-bottom: 1px solid #222; }
.sb-axis > summary {
cursor: pointer;
padding: 8px 14px;
font-size: 12px;
font-weight: 700;
color: #ddd;
text-transform: uppercase;
letter-spacing: 0.5px;
background: #0d0d0d;
}
.sb-axis > summary .sb-meta { color: #777; font-weight: 400; text-transform: none; margin-left: 8px; font-size: 10px; }
.sb-row-dim td { opacity: 0.45; }
.sb-row-best { background: rgba(0, 209, 178, 0.14); }
.sb-row-best td { color: #fff; }
.sb-num { text-align: right; font-family: 'Consolas', monospace; white-space: nowrap; padding: 5px 10px; }
.sb-val { padding: 5px 10px; color: #ddd; font-family: 'Consolas', monospace; font-size: 10px; word-break: break-all; }
.sb-link { color: var(--accent-sch); cursor: pointer; text-decoration: underline; font-size: 10px; padding: 5px 10px; white-space: nowrap; }
.sb-empty { padding: 10px 14px; color: #777; font-size: 11px; }
.sb-footer { padding: 8px 14px; font-size: 10px; color: #888; background: #0d0d0d; border-top: 1px solid #222; flex-shrink: 0; }
.sb-actions { display: flex; gap: 6px; padding: 8px 14px; flex-shrink: 0; border-top: 1px solid #222; background: #0a0a0a; }
.sb-actions button {
background: #1a1a1a; color: #ccc; border: 1px solid #444; border-radius: 4px; padding: 6px 12px; font-size: 11px; cursor: pointer;
}
.sb-actions button:hover { border-color: var(--accent); color: var(--accent); }
+122
View File
@@ -46,6 +46,13 @@
</div>
</div>
<button class="fs-btn" id="compare-open-btn" onclick="openCompareMode()" title="Compare (C)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="4" width="8" height="16" rx="1.5"></rect>
<rect x="14" y="4" width="8" height="16" rx="1.5"></rect>
</svg>
</button>
<button class="fs-btn" onclick="loadSession()" title="Reload Session (R)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="23 4 23 10 17 10"></polyline>
@@ -217,6 +224,10 @@
<input type="checkbox" id="label-field-hiresPromptText" onchange="updateLabelFields()" style="width: 12px; height: 12px; cursor: pointer;">
<label for="label-field-hiresPromptText" style="font-size: 10px; color: #d06080; cursor: pointer; user-select: none;">HiRes Prompt Text</label>
</div>
<div style="display: flex; align-items: center; gap: 4px;">
<input type="checkbox" id="label-field-rating" onchange="updateLabelFields()" style="width: 12px; height: 12px; cursor: pointer;">
<label for="label-field-rating" style="font-size: 10px; color: #e0c040; cursor: pointer; user-select: none;" title="Pairwise rating and vote count (voted cards only)">Rating</label>
</div>
</div>
<div style="margin-top: 8px; display: flex; align-items: center; gap: 6px;">
<input type="checkbox" id="label-unique-only" checked
@@ -326,6 +337,71 @@
<div id="delete-status" style="margin-top: 4px; font-size: 10px; color: #666; min-height: 16px;"></div>
</div>
<!-- Pairwise review (blind A/B compare + scoreboard) -->
<div class="cog-panel-section">
<div class="cog-panel-section-title">PAIRWISE REVIEW</div>
<div style="font-size: 9px; color: #666; margin-bottom: 8px;">
Blind two-up compare. Votes are saved in the session manifest and rolled up per setting.
</div>
<button class="session-action-btn"
style="background: #e0a020; color: #000; width: 100%; font-size: 11px; padding: 10px; margin-bottom: 6px;"
onclick="openCompareMode()" title="Start comparing pairs (C)">
START COMPARE (C)
</button>
<button class="session-action-btn"
style="background: #3e8ed0; width: 100%; font-size: 11px; padding: 10px; margin-bottom: 8px;"
onclick="openScoreboard()" title="Win rates per setting from all votes">
SCOREBOARD
</button>
<div class="cmp-setting-row">
<label for="cmp-mode">Pairing</label>
<select id="cmp-mode" onchange="cmpSettingsChanged()">
<option value="matched">Matched (same prompt + seed)</option>
<option value="seed">Same settings, different seed</option>
<option value="one_axis">One axis at a time</option>
<option value="random">Random (same media type)</option>
</select>
</div>
<div class="cmp-setting-row">
<label for="cmp-axis">Axis focus</label>
<select id="cmp-axis" onchange="cmpSettingsChanged()">
<option value="">Any axis</option>
<option value="model">Model</option>
<option value="lora">LoRA</option>
<option value="sampler">Sampler</option>
<option value="scheduler">Scheduler</option>
<option value="cfg">CFG</option>
<option value="steps">Steps</option>
<option value="denoise">Denoise</option>
<option value="resolution">Resolution</option>
<option value="prompt">Prompt</option>
<option value="upscale">Upscale</option>
</select>
</div>
<div class="cmp-setting-row">
<label for="cmp-reveal">Reveal after vote</label>
<select id="cmp-reveal" onchange="cmpSettingsChanged()">
<option value="0">Off</option>
<option value="1000" selected>1 s</option>
<option value="2000">2 s</option>
</select>
</div>
<div class="cmp-setting-row">
<input type="checkbox" id="cmp-blind" checked onchange="cmpSettingsChanged()" style="width: 12px; height: 12px; cursor: pointer;">
<label for="cmp-blind" style="cursor: pointer;">Blind (hide settings until you vote)</label>
</div>
<div class="cmp-setting-row">
<input type="checkbox" id="cmp-restrict" checked onchange="cmpSettingsChanged()" style="width: 12px; height: 12px; cursor: pointer;">
<label for="cmp-restrict" style="cursor: pointer;">Scoreboard: only votes inside the current filter</label>
</div>
<button class="session-action-btn"
style="background: #cc2222; width: 100%; font-size: 11px; padding: 8px; margin-top: 8px; border: 2px solid #ff4444;"
onclick="clearComparisons()" title="Delete every vote saved in this session">
CLEAR VOTES
</button>
<div id="cmp-status" style="margin-top: 6px; font-size: 10px; color: #666; min-height: 14px;"></div>
</div>
<!-- Section 5: Manifest Analytics -->
<div class="cog-panel-section">
<div class="cog-panel-section-title">MANIFEST ANALYTICS</div>
@@ -408,6 +484,18 @@
<td style="padding: 4px 8px; color: #0af; font-family: monospace;">R</td>
<td style="padding: 4px 8px; color: #ccc;">Reload session</td>
</tr>
<tr style="border-bottom: 1px solid #333;">
<td style="padding: 4px 8px; color: #0af; font-family: monospace;">C</td>
<td style="padding: 4px 8px; color: #ccc;">Compare mode (blind A/B review)</td>
</tr>
<tr style="border-bottom: 1px solid #333;">
<td style="padding: 4px 8px; color: #0af; font-family: monospace;">A/D, S, W</td>
<td style="padding: 4px 8px; color: #ccc;">In compare: A wins / B wins, tie, skip (arrows work too)</td>
</tr>
<tr style="border-bottom: 1px solid #333;">
<td style="padding: 4px 8px; color: #0af; font-family: monospace;">Z, I, B</td>
<td style="padding: 4px 8px; color: #ccc;">In compare: undo, unblind, scoreboard</td>
</tr>
<tr style="border-bottom: 1px solid #333;">
<td style="padding: 4px 8px; color: #0af; font-family: monospace;">1-9</td>
<td style="padding: 4px 8px; color: #ccc;">Quick favorite Nth visible card</td>
@@ -686,6 +774,40 @@
onclick="selectJSON('json-bar-bad')">[]</div>
</div>
<!-- Pairwise review overlay (see logic_compare.js) -->
<div id="compare-overlay" style="display: none;">
<div id="cmp-head">
<span id="cmp-mode-label"></span>
<span id="cmp-progress"></span>
<span id="cmp-repeat-badge" style="display: none;">repeating voted pairs</span>
<span class="cmp-spacer"></span>
<button class="cmp-head-btn" id="cmp-unblind-btn" onclick="cmpToggleUnblind()" title="Show what differs before you vote (I)">Unblind (I)</button>
<button class="cmp-head-btn" onclick="openScoreboard()" title="Scoreboard (B)">Scoreboard (B)</button>
<button class="cmp-head-btn" onclick="cmpUndo()" title="Undo last vote (Z)">Undo (Z)</button>
<button class="cmp-head-btn cmp-close" onclick="closeCompareMode()" title="Close (Esc)">&#10005;</button>
</div>
<div id="cmp-panes">
<div class="cmp-pane" id="cmp-pane-a" onclick="cmpVoteSide('a')">
<div class="cmp-label">A</div>
<div class="cmp-media" id="cmp-media-a"></div>
<div class="cmp-chips" id="cmp-chips-a"></div>
</div>
<div class="cmp-pane" id="cmp-pane-b" onclick="cmpVoteSide('b')">
<div class="cmp-label">B</div>
<div class="cmp-media" id="cmp-media-b"></div>
<div class="cmp-chips" id="cmp-chips-b"></div>
</div>
</div>
<div id="cmp-empty" style="display: none;"></div>
<div id="cmp-bar">
<button class="cmp-btn" onclick="cmpVoteSide('a')">&#9664; A wins</button>
<button class="cmp-btn" onclick="cmpVoteSide('tie')">Tie</button>
<button class="cmp-btn" onclick="cmpSkip()">Skip</button>
<button class="cmp-btn" onclick="cmpVoteSide('b')">B wins &#9654;</button>
<button class="cmp-btn cmp-btn-undo" onclick="cmpUndo()">&#8630; Undo</button>
</div>
</div>
<div id="modal">
<button class="modal-close-x" onclick="closeM()" title="Close (Esc)">&times;</button>
<!-- Lightbox navigation bar -->
+2
View File
@@ -107,6 +107,8 @@ _GEN_ORCH_DEPS = {
"manifest_utils": {
"load_existing_manifest": lambda *a, **kw: {},
"save_manifest": lambda *a, **kw: None,
"preserve_disk_comparisons": lambda *a, **kw: None,
"update_manifest_comparisons": lambda *a, **kw: {"comparisons": [], "added": 0, "removed": 0, "total": 0},
},
"model_loader": {
"load_checkpoint": lambda *a, **kw: None,
+272
View File
@@ -0,0 +1,272 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const R = require('../../resources/logic_ratings.js');
function rngSeed(seed) {
let a = seed >>> 0;
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
let nextId = 1;
function item(o) {
return Object.assign({
id: nextId++, file: '/view?x', model: 'm.safetensors', lora: 'None', sampler: 'euler', scheduler: 'simple',
cfg: 7, steps: 20, denoise: 1, seed: 1, width: 1024, height: 1024,
config_positive: 'a cat', positive: 'a cat', negative: '', duration: 1, favorited: false
}, o);
}
const idOf = (x) => (typeof x === 'object' ? x.id : x);
const vote = (a, b, winner, ts) => ({ a: idOf(a), b: idOf(b), winner, ts: ts || 1, mode: 'matched' });
// ---- normalizers ----
test('fmtNum', () => {
assert.strictEqual(R.fmtNum(1.0), '1');
assert.strictEqual(R.fmtNum('7.50'), '7.5');
assert.strictEqual(R.fmtNum(undefined), '?');
assert.strictEqual(R.fmtNum('abc'), 'abc');
});
test('lora keys are order-insensitive and legacy-equal', () => {
const k = (l) => R.normalizeAxis('lora', { lora: l }, {}).key;
assert.strictEqual(k('a\\b.safetensors:1.0:1.0 + c:0.5'), k('c:0.5:0.5 + a/b.safetensors:1:1'));
assert.strictEqual(k('x'), k('x:1:1'));
assert.strictEqual(k('None'), k(''));
assert.strictEqual(k(undefined), 'None');
assert.strictEqual(R.normalizeAxis('lora', { lora: 'sub/foo.safetensors:0.8:0.5' }, {}).label, 'foo@0.8/0.5');
});
test('lora prefers lora_expanded', () => {
assert.strictEqual(R.normalizeAxis('lora', { lora: 'a', lora_expanded: 'b:1:1' }, {}).key, 'b:1:1');
});
test('prompt axis uses config_positive (trigger words do not matter)', () => {
const a = item({ positive: 'trigger, a cat', config_positive: 'a cat ' });
const b = item({ positive: 'a cat', config_positive: 'a cat' });
assert.strictEqual(R.normalizeAxis('prompt', a, {}).key, R.normalizeAxis('prompt', b, {}).key);
assert.strictEqual(R.normalizeAxis('prompt', { config_positive: 'x'.repeat(100) }, {}).label.length, 61);
});
test('model falls back to meta and strips path', () => {
assert.strictEqual(R.normalizeAxis('model', {}, { model: 'dir\\foo.safetensors' }).label, 'foo');
assert.strictEqual(R.normalizeAxis('model', {}, {}).key, 'Default');
});
test('resolution of an upscaled item maps back to base size', () => {
const d = { width: 1536, height: 2304, upscaled: true, upscale_ratio: 1.5 };
assert.strictEqual(R.normalizeAxis('resolution', d, {}).key, '1024x1536');
const arr = { width: 2304, height: 2304, upscaled: true, upscale_ratio: [1.5, 1.5] };
assert.strictEqual(R.normalizeAxis('resolution', arr, {}).key, '1024x1024');
assert.strictEqual(R.normalizeAxis('resolution', { width: 100, height: 50, upscaled: true }, {}).key, 'AR 2.00');
assert.strictEqual(R.normalizeAxis('resolution', { width: 1, height: 1, base_width: 512, base_height: 768, upscaled: true }, {}).key, '512x768');
assert.strictEqual(R.normalizeAxis('resolution', { width: 640, height: 480 }, {}).key, '640x480');
});
test('upscale key is stable for array modes', () => {
const d = { upscaled: true, upscale_mode: ['model_only', 'hires'], upscale_model: ['dir/4x.pth', 'x/y.pth'], upscale_ratio: [2, 1.5], upscale_denoise: 0.35 };
const k1 = R.normalizeAxis('upscale', d, {}).key;
assert.strictEqual(k1, R.normalizeAxis('upscale', JSON.parse(JSON.stringify(d)), {}).key);
assert.ok(k1.includes('model_only>hires') && k1.includes('x2>1.5') && k1.includes('d0.35'));
assert.strictEqual(R.normalizeAxis('upscale', {}, {}).key, 'none');
});
// ---- axisDiff ----
test('axisDiff single, double, ctx and excluded keys', () => {
const a = item({});
assert.deepStrictEqual(R.axisDiff(a, item({ sampler: 'dpmpp' })).axes, ['sampler']);
assert.deepStrictEqual(R.axisDiff(a, item({ sampler: 'dpmpp', cfg: 5 })).axes, ['sampler', 'cfg'].sort((x, y) => R.CMP_DIFF_AXES.indexOf(x) - R.CMP_DIFF_AXES.indexOf(y)));
const v = R.axisDiff(a, item({ vae: 'x.vae' }), { withCtxKeys: true });
assert.strictEqual(v.ctxEqual, false);
assert.deepStrictEqual(v.ctxKeys, ['vae']);
const e = R.axisDiff(a, item({ conditioning_pos_hash: 'zz', duration: 99, favorited: true, gen_index: 5, upscale_x: 1, worker_id: 'w' }));
assert.deepStrictEqual(e.axes, []);
assert.strictEqual(e.ctxEqual, true);
assert.deepStrictEqual(R.axisDiff(a, item({ seed: 2 })).axes, ['seed']);
});
// ---- Bradley-Terry ----
test('BT: A beats B 3-1', () => {
const A = item({}), B = item({});
const cs = [vote(A, B, 'a', 1), vote(A, B, 'a', 2), vote(B, A, 'b', 3), vote(A, B, 'b', 4)];
const r = R.computeBradleyTerry(cs, new Set([A.id, B.id]));
assert.ok(r.get(A.id).rating > r.get(B.id).rating);
assert.deepStrictEqual([r.get(A.id).w, r.get(A.id).l, r.get(A.id).t, r.get(A.id).votes], [3, 1, 0, 4]);
assert.deepStrictEqual([r.get(B.id).w, r.get(B.id).l], [1, 3]);
});
test('BT: unbeaten item is finite', () => {
const A = item({}), B = item({});
const cs = [1, 2, 3, 4, 5].map((t) => vote(A, B, 'a', t));
const r = R.computeBradleyTerry(cs, new Set([A.id, B.id]));
assert.ok(Number.isFinite(r.get(A.id).rating) && Number.isFinite(r.get(B.id).rating));
assert.ok(r.get(A.id).rating > 1500 && r.get(B.id).rating < 1500);
});
test('BT: all ties give equal 1500', () => {
const [A, B, C] = [item({}), item({}), item({})];
const cs = [vote(A, B, 'tie', 1), vote(B, C, 'tie', 2), vote(B, C, 'tie', 3)];
const r = R.computeBradleyTerry(cs, new Set([A.id, B.id, C.id]));
for (const id of [A.id, B.id, C.id]) assert.ok(Math.abs(r.get(id).rating - 1500) < 1e-6);
});
test('BT: chain order and order invariance', () => {
const [A, B, C] = [item({}), item({}), item({})];
const cs = [vote(A, B, 'a', 1), vote(B, C, 'a', 2)];
const ids = new Set([A.id, B.id, C.id]);
const r = R.computeBradleyTerry(cs, ids);
assert.ok(r.get(A.id).rating > r.get(B.id).rating && r.get(B.id).rating > r.get(C.id).rating);
const r2 = R.computeBradleyTerry(cs.slice().reverse(), ids);
for (const id of ids) assert.ok(Math.abs(r.get(id).rating - r2.get(id).rating) < 1e-9);
});
test('BT: empty input, dangling ids, convergence', () => {
assert.strictEqual(R.computeBradleyTerry([], new Set([1])).size, 0);
const [A, B] = [item({}), item({})];
const r = R.computeBradleyTerry([vote(A, 99999999, 'a', 1), vote(A, B, 'a', 2), vote(A, A, 'a', 3)], new Set([A.id, B.id]));
assert.strictEqual(r.get(A.id).votes, 1);
const items = Array.from({ length: 30 }, () => item({}));
const rng = rngSeed(7);
const cs = [];
for (let i = 0; i < 600; i++) {
const x = items[Math.floor(rng() * 30)], y = items[Math.floor(rng() * 30)];
if (x === y) continue;
cs.push(vote(x, y, (x.id % 7) > (y.id % 7) ? 'a' : 'b', i + 1));
}
const big = R.computeBradleyTerry(cs, new Set(items.map((d) => d.id)), { maxIter: 200 });
for (const v of big.values()) assert.ok(Number.isFinite(v.rating));
});
// ---- pairing ----
const freshState = () => ({ votesById: new Map(), votedPairs: new Set(), skipped: new Set(), allowRepeats: false });
function drawMany(src, n, rng, state) {
const out = [];
for (let i = 0; i < n; i++) { const p = R.nextPair(src, state || freshState(), rng); if (p) out.push(p); }
return out;
}
function grid() {
const items = [];
for (const p of ['cat', 'dog']) for (const s of [1, 2]) for (const sm of ['euler', 'dpm', 'heun'])
items.push(item({ config_positive: p, positive: p, seed: s, sampler: sm }));
return items;
}
test('matched pairs share prompt+seed and differ in config', () => {
const src = R.buildPairSource(grid(), 'matched', '');
const ps = drawMany(src, 200, rngSeed(1));
assert.ok(ps.length > 0);
for (const p of ps) {
assert.strictEqual(p.a.config_positive, p.b.config_positive);
assert.strictEqual(p.a.seed, p.b.seed);
assert.notStrictEqual(p.a.sampler, p.b.sampler);
}
});
test('one_axis pairs differ in exactly the focus axis', () => {
const items = grid();
items.push(item({ config_positive: 'cat', positive: 'cat', seed: 1, cfg: 9 }));
const src = R.buildPairSource(items, 'one_axis', 'sampler');
const ps = drawMany(src, 100, rngSeed(2));
assert.ok(ps.length > 0);
for (const p of ps) assert.deepStrictEqual(R.axisDiff(p.a, p.b).axes, ['sampler']);
const src2 = R.buildPairSource(items, 'one_axis', '');
for (const p of drawMany(src2, 100, rngSeed(3))) assert.strictEqual(R.axisDiff(p.a, p.b).axes.length, 1);
});
test('seed mode pairs differ only in seed', () => {
const src = R.buildPairSource(grid(), 'seed', '');
const ps = drawMany(src, 100, rngSeed(4));
assert.ok(ps.length);
for (const p of ps) assert.deepStrictEqual(R.axisDiff(p.a, p.b).axes, ['seed']);
});
test('no valid bucket gives null; media types never mix', () => {
const src = R.buildPairSource([item({}), item({ seed: 5 })], 'matched', '');
assert.strictEqual(src.buckets.length, 0);
assert.strictEqual(R.nextPair(src, freshState(), rngSeed(1)), null);
const items = [item({ sampler: 'a' }), item({ sampler: 'b', media_type: 'video' }), item({ sampler: 'c' }), item({ sampler: 'd', media_type: 'video' })];
const rs = R.buildPairSource(items, 'random', '');
const ps = drawMany(rs, 100, rngSeed(5));
assert.ok(ps.length > 0);
for (const p of ps) assert.strictEqual(p.a.media_type === 'video', p.b.media_type === 'video');
});
test('voted pairs are skipped until exhausted, then repeats', () => {
const items = [item({ sampler: 'a' }), item({ sampler: 'b' }), item({ sampler: 'c' })];
const src = R.buildPairSource(items, 'matched', '');
const state = freshState();
const rng = rngSeed(11);
const seen = new Set();
for (let i = 0; i < 3; i++) {
const p = R.nextPair(src, state, rng);
assert.ok(p && !p.repeating);
const k = R.cmpPairKey(p.a.id, p.b.id);
assert.ok(!state.votedPairs.has(k));
state.votedPairs.add(k); seen.add(k);
state.votesById.set(p.a.id, (state.votesById.get(p.a.id) || 0) + 1);
state.votesById.set(p.b.id, (state.votesById.get(p.b.id) || 0) + 1);
}
assert.strictEqual(seen.size, 3);
const p = R.nextPair(src, state, rng);
assert.ok(p && p.repeating === true);
});
test('skipped pairs are excluded even with repeats', () => {
const items = [item({ sampler: 'a' }), item({ sampler: 'b' })];
const src = R.buildPairSource(items, 'matched', '');
const st = freshState();
st.skipped.add(R.cmpPairKey(items[0].id, items[1].id));
st.allowRepeats = true;
assert.strictEqual(R.nextPair(src, st, rngSeed(1)), null);
});
test('low-vote items are preferred', () => {
const items = Array.from({ length: 20 }, (_, i) => item({ sampler: 's' + i }));
const src = R.buildPairSource(items, 'matched', '');
const st = freshState();
items.slice(0, 10).forEach((d) => st.votesById.set(d.id, 50));
const low = new Set(items.slice(10).map((d) => d.id));
let lowCount = 0, tot = 0;
for (const p of drawMany(src, 200, rngSeed(9), st)) {
tot += 2; lowCount += (low.has(p.a.id) ? 1 : 0) + (low.has(p.b.id) ? 1 : 0);
}
assert.ok(lowCount / tot > 0.8, 'low-vote share ' + lowCount / tot);
});
// ---- scoreboard ----
test('scoreboard: 3-sampler dataset, ties half, best row, n<3', () => {
const [E, D, H] = ['euler', 'dpm', 'heun'].map((s) => item({ sampler: s }));
const byId = new Map([E, D, H].map((d) => [d.id, d]));
const cs = [];
let ts = 1;
for (let i = 0; i < 4; i++) cs.push(vote(E, D, 'a', ts++));
cs.push(vote(D, E, 'tie', ts++));
cs.push(vote(H, D, 'a', ts++));
const ids = new Set(byId.keys());
const sb = R.computeScoreboard(cs, byId, R.computeBradleyTerry(cs, ids), {});
const rows = Object.fromEntries(sb.axes.sampler.rows.map((r) => [r.value, r]));
assert.strictEqual(rows.euler.n, 5);
assert.strictEqual(rows.euler.wins, 4); assert.strictEqual(rows.euler.ties, 1);
assert.ok(Math.abs(rows.euler.winPct - 4.5 / 5) < 1e-9);
assert.strictEqual(rows.dpm.n, 6);
assert.ok(Math.abs(rows.dpm.winPct - 0.5 / 6) < 1e-9);
assert.strictEqual(rows.heun.n, 1); assert.strictEqual(rows.heun.enough, false);
assert.strictEqual(sb.axes.sampler.best, 'euler');
assert.strictEqual(sb.attributable, 6); assert.strictEqual(sb.total, 6);
assert.ok(rows.euler.meanRating > rows.dpm.meanRating);
assert.ok(rows.euler.wilsonLow < rows.euler.winPct && rows.euler.wilsonHigh > rows.euler.winPct);
});
test('scoreboard: non-attributable counted, seed is noise floor, restrictIds', () => {
const a = item({}), b = item({ sampler: 'x', cfg: 5 }), c = item({ vae: 'v1' }), d = item({ seed: 9 });
const byId = new Map([a, b, c, d].map((x) => [x.id, x]));
const cs = [vote(a, b, 'a', 1), vote(a, c, 'a', 2), vote(a, c, 'b', 3), vote(a, d, 'tie', 4)];
const sb = R.computeScoreboard(cs, byId, new Map(), {});
assert.strictEqual(sb.total, 4); assert.strictEqual(sb.attributable, 0); assert.strictEqual(sb.nonAttributable, 3);
assert.strictEqual(sb.topExtraKeys[0].key, 'vae'); assert.strictEqual(sb.topExtraKeys[0].count, 2);
assert.ok(sb.topExtraKeys.some((k) => k.key === 'cfg') && sb.topExtraKeys.some((k) => k.key === 'sampler'));
assert.strictEqual(sb.axes.seed.rows.length, 2);
const only = R.computeScoreboard(cs, byId, new Map(), { restrictIds: new Set([a.id, b.id]) });
assert.strictEqual(only.total, 1);
assert.strictEqual(R.computeScoreboard([vote(a, 424242, 'a', 9)], byId, new Map(), {}).total, 0);
});
test('scoreboard CSV quoting + wilson', () => {
const a = item({ config_positive: 'a, "quoted" line' }), b = item({ config_positive: 'plain' });
const byId = new Map([a, b].map((x) => [x.id, x]));
const cs = [vote(a, b, 'a', 1), vote(a, b, 'a', 2), vote(b, a, 'b', 3)];
const csv = R.scoreboardToCSV(R.computeScoreboard(cs, byId, new Map(), {}));
assert.ok(csv.startsWith('axis,value,n,wins,losses,ties,win_pct,wilson_low,wilson_high,mean_rating,best\n'));
assert.ok(csv.includes('"a, ""quoted"" line"'));
const w = R.wilsonInterval(5, 10);
assert.ok(w.low < 0.5 && w.high > 0.5);
assert.deepStrictEqual(R.wilsonInterval(0, 0), { low: 0, high: 0 });
});
test('mergeComparisons dedupes by ts:a:b', () => {
const m = R.mergeComparisons([{ a: 1, b: 2, ts: 5 }], [{ a: 1, b: 2, ts: 5 }, { a: 1, b: 2, ts: 6 }, { a: 2, b: 1, ts: 5 }]);
assert.strictEqual(m.length, 3);
});
+197
View File
@@ -0,0 +1,197 @@
"""Pairwise-review votes stored in manifest["comparisons"].
Loads the real manifest_utils.py (tests/conftest.py registers a stub under the
package name) via importlib, like test_pinned_clip.py does.
"""
import copy
import importlib.util
import json
import os
import threading
import pytest
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_spec = importlib.util.spec_from_file_location("manifest_utils_real", os.path.join(_ROOT, "manifest_utils.py"))
mu = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(mu)
def V(a, b, winner="a", ts=1000, **kw):
d = {"a": a, "b": b, "winner": winner, "ts": ts}
d.update(kw)
return d
def _write(path, data):
with open(path, "w") as f:
json.dump(data, f, indent=4)
def _read(path):
with open(path) as f:
return json.load(f)
def _manifest():
return {"session_name": "s", "meta": {"model": "m.safetensors"},
"items": [{"id": 3, "favorited": True, "note": "n", "file": "/view?x=3"},
{"id": 2, "rejected": True, "file": "/view?x=2"},
{"id": 1, "file": "/view?x=1"}]}
# ---- validate_comparison ----------------------------------------------------
def test_validate_rejects_bad_entries():
assert mu.validate_comparison(V(1, 1)) is None
assert mu.validate_comparison(V(True, 2)) is None
assert mu.validate_comparison(V("1", 2)) is None
assert mu.validate_comparison(V(1.0, 2)) is None
assert mu.validate_comparison(V(1, 2, winner="x")) is None
assert mu.validate_comparison(V(1, 2, ts=-1)) is None
assert mu.validate_comparison(V(1, 2, ts="5")) is None
assert mu.validate_comparison("nope") is None
def test_validate_cleans_fields():
v = mu.validate_comparison(V(1, 2, "tie", mode="x" * 100, blind=False, junk=1))
assert v == {"a": 1, "b": 2, "winner": "tie", "ts": 1000, "mode": "x" * 32, "blind": False}
assert mu.validate_comparison(V(1, 2))["mode"] == "matched"
assert "blind" not in mu.validate_comparison(V(1, 2, blind="yes"))
assert mu.validate_comparison(V(180000000000000, 2))["a"] == 180000000000000
# ---- merge_comparison_lists -------------------------------------------------
def test_merge_append_dedupe_remove():
m, added, removed = mu.merge_comparison_lists([], [V(1, 2), V(1, 2), V(3, 4, ts=5)], [])
assert (len(m), added, removed) == (2, 2, 0)
m2, added, removed = mu.merge_comparison_lists(m, [V(1, 2)], [])
assert (len(m2), added) == (2, 0)
m3, added, removed = mu.merge_comparison_lists(m, [], ["1000:1:2"])
assert (len(m3), removed) == (1, 1) and m3[0]["a"] == 3
m4, added, removed = mu.merge_comparison_lists(m, [], ["nope"])
assert (m4, removed) == (m, 0)
def test_merge_preserves_invalid_existing():
junk = {"weird": True}
m, added, removed = mu.merge_comparison_lists([junk, V(1, 2)], [V(5, 6)], ["1000:1:2"])
assert m[0] == junk and len(m) == 2 and (added, removed) == (1, 1)
# ---- update_manifest_comparisons -------------------------------------------
def test_update_roundtrip_leaves_items_meta_alone(tmp_path):
p = str(tmp_path / "manifest.json")
orig = _manifest()
_write(p, orig)
r = mu.update_manifest_comparisons(p, append=[V(1, 2), V(2, 3, ts=7)])
assert r["added"] == 2 and r["total"] == 2
d = _read(p)
assert d["items"] == orig["items"] and d["meta"] == orig["meta"] and len(d["comparisons"]) == 2
mu.update_manifest_comparisons(p, remove_keys=["1000:1:2"])
d = _read(p)
assert d["items"] == orig["items"] and len(d["comparisons"]) == 1
r = mu.update_manifest_comparisons(p, clear=True)
d = _read(p)
assert d["comparisons"] == [] and d["items"] == orig["items"] and r["removed"] == 1
assert not os.path.exists(p + ".tmp")
def test_update_missing_file_raises(tmp_path):
with pytest.raises(FileNotFoundError):
mu.update_manifest_comparisons(str(tmp_path / "nope.json"), append=[V(1, 2)])
def test_update_corrupt_json_raises_and_keeps_file(tmp_path):
p = str(tmp_path / "manifest.json")
with open(p, "w") as f:
f.write('{"items": [ {"id": 1}, ')
before = open(p).read()
with pytest.raises(ValueError): # JSONDecodeError subclasses ValueError
mu.update_manifest_comparisons(p, append=[V(1, 2)])
assert open(p).read() == before
def test_update_creates_comparisons_when_absent(tmp_path):
p = str(tmp_path / "manifest.json")
_write(p, _manifest())
assert "comparisons" not in _read(p)
mu.update_manifest_comparisons(p, append=[V(1, 2)])
assert len(_read(p)["comparisons"]) == 1
# ---- regression: other writers must not drop / resurrect votes -------------
def test_stale_in_memory_manifest_saved_after_vote_keeps_vote(tmp_path):
p = str(tmp_path / "manifest.json")
_write(p, _manifest())
stale = copy.deepcopy(_manifest()) # the generator's start-of-run copy: no votes
mu.update_manifest_comparisons(p, append=[V(1, 2), V(2, 3, ts=7)])
stale["items"].insert(0, {"id": 4, "file": "/view?x=4"})
mu.save_manifest(p, stale)
d = _read(p)
assert [i["id"] for i in d["items"]] == [4, 3, 2, 1]
assert len(d["comparisons"]) == 2
def test_stale_copy_with_old_votes_does_not_resurrect_after_clear(tmp_path):
p = str(tmp_path / "manifest.json")
m = _manifest()
m["comparisons"] = [V(1, 2)]
_write(p, m)
stale = copy.deepcopy(m)
mu.update_manifest_comparisons(p, clear=True)
mu.save_manifest(p, stale)
assert _read(p)["comparisons"] == []
def test_stale_copy_does_not_resurrect_undone_vote(tmp_path):
p = str(tmp_path / "manifest.json")
m = _manifest()
m["comparisons"] = [V(1, 2), V(2, 3, ts=7)]
_write(p, m)
stale = copy.deepcopy(m)
mu.update_manifest_comparisons(p, remove_keys=["7:2:3"])
mu.save_manifest(p, stale)
assert [c["ts"] for c in _read(p)["comparisons"]] == [1000]
def test_preserve_disk_comparisons_noop_without_field():
t = {"comparisons": [V(1, 2)]}
mu.preserve_disk_comparisons({"items": []}, t)
assert t["comparisons"] == [V(1, 2)]
mu.preserve_disk_comparisons({"comparisons": []}, t)
assert t["comparisons"] == []
# ---- concurrency ------------------------------------------------------------
def test_concurrent_saves_and_votes_lose_nothing(tmp_path):
p = str(tmp_path / "manifest.json")
base = _manifest()
_write(p, base)
state = copy.deepcopy(base) # the generator's long-lived in-memory dict
errors = []
N = 50
def gen():
try:
for i in range(N):
state["items"].insert(0, {"id": 1000 + i, "file": f"/view?x={i}"})
mu.save_manifest(p, state)
except Exception as e: # pragma: no cover
errors.append(e)
def vote():
try:
for i in range(N):
mu.update_manifest_comparisons(p, append=[V(1, 2, ts=i + 1)])
except Exception as e: # pragma: no cover
errors.append(e)
ts = [threading.Thread(target=gen), threading.Thread(target=vote)]
for t in ts:
t.start()
for t in ts:
t.join()
assert not errors
d = _read(p)
assert len(d["items"]) == 3 + N
assert len(d["comparisons"]) == N