Add censorship and bias pairing towards dense Elo buckets

This commit is contained in:
2026-06-19 11:54:50 +01:00
parent 7a646f16b7
commit 5501802834
11 changed files with 233 additions and 116 deletions
Binary file not shown.
+4 -2
View File
@@ -1,4 +1,4 @@
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import type { NextRequest } from "next/server";
import { db } from "@/db";
import { assets, lensPortraits } from "@/db/schema";
@@ -11,10 +11,12 @@ const UUID_RE =
const ALLOWED_SIZES = new Set(["preview", "thumbnail", "fullsize"]);
async function isAuthorizedAsset(id: string): Promise<boolean> {
// Only serve thumbnails for active assets; lens portraits live outside the
// album so they're authorized independently of asset state.
const [asset] = await db
.select({ id: assets.id })
.from(assets)
.where(eq(assets.id, id))
.where(and(eq(assets.id, id), eq(assets.active, true)))
.limit(1);
if (asset) return true;
+30 -17
View File
@@ -12,24 +12,37 @@ export function HeadToHead({ data }: { data: HeadToHeadEntry[] }) {
Head to Head
</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{data.map((h2h) => (
<Link
key={h2h.opponentId}
href={`/assets/${h2h.opponentId}`}
className="flex flex-col items-center gap-1.5 p-2 rounded-lg hover:bg-default transition-colors"
>
<img
src={`/img/${h2h.opponentId}?size=thumbnail`}
alt=""
className="h-16 rounded object-contain"
style={{ aspectRatio: h2h.opponentAspectRatio }}
/>
<div className="flex items-center gap-1.5 text-sm font-semibold tabular-nums">
<span className="text-success">{h2h.wins}W</span>
<span className="text-danger">{h2h.losses}L</span>
{data.map((h2h) =>
h2h.inactive ? (
<div
key={h2h.opponentId}
className="flex flex-col items-center gap-1.5 p-2 rounded-lg"
>
<div className="h-16 aspect-square rounded bg-default" />
<div className="flex items-center gap-1.5 text-sm font-semibold tabular-nums">
<span className="text-success">{h2h.wins}W</span>
<span className="text-danger">{h2h.losses}L</span>
</div>
</div>
</Link>
))}
) : (
<Link
key={h2h.opponentId}
href={`/assets/${h2h.opponentId}`}
className="flex flex-col items-center gap-1.5 p-2 rounded-lg hover:bg-default transition-colors"
>
<img
src={`/img/${h2h.opponentId}?size=thumbnail`}
alt=""
className="h-16 rounded object-contain"
style={{ aspectRatio: h2h.opponentAspectRatio ?? 1 }}
/>
<div className="flex items-center gap-1.5 text-sm font-semibold tabular-nums">
<span className="text-success">{h2h.wins}W</span>
<span className="text-danger">{h2h.losses}L</span>
</div>
</Link>
),
)}
</div>
</div>
);
+17 -11
View File
@@ -53,17 +53,23 @@ export function AssetMatchHistory({
</span>
</Table.Cell>
<Table.Cell className="flex justify-center">
<Link
href={`/assets/${match.opponentId}`}
className="inline-flex justify-center"
>
<img
src={`/img/${match.opponentId}?size=thumbnail`}
alt=""
className="h-8 rounded object-contain"
style={{ aspectRatio: match.opponentAspectRatio }}
/>
</Link>
{match.opponentId ? (
<Link
href={`/assets/${match.opponentId}`}
className="inline-flex justify-center"
>
<img
src={`/img/${match.opponentId}?size=thumbnail`}
alt=""
className="h-8 rounded object-contain"
style={{
aspectRatio: match.opponentAspectRatio ?? 1,
}}
/>
</Link>
) : (
<div className="h-8 aspect-square rounded bg-default" />
)}
</Table.Cell>
<Table.Cell className="tabular-nums text-xs">
{formatElo(match.assetEloBefore)} {" "}
+30 -22
View File
@@ -16,17 +16,21 @@ function MatchRows({ matches }: { matches: MatchEntry[] }) {
{matches.map((match) => (
<Table.Row key={match.id}>
<Table.Cell className="text-center">
<Link
href={`/assets/${match.leftId}`}
className="inline-flex justify-center"
>
<img
src={`/img/${match.leftId}?size=thumbnail`}
alt=""
className="h-8 rounded object-contain"
style={{ aspectRatio: match.leftAspectRatio }}
/>
</Link>
{match.leftId ? (
<Link
href={`/assets/${match.leftId}`}
className="inline-flex justify-center"
>
<img
src={`/img/${match.leftId}?size=thumbnail`}
alt=""
className="h-8 rounded object-contain"
style={{ aspectRatio: match.leftAspectRatio ?? 1 }}
/>
</Link>
) : (
<div className="inline-block h-8 aspect-square rounded bg-default" />
)}
</Table.Cell>
<Table.Cell
className={`text-center text-xs tabular-nums font-semibold ${match.winningSide === "left" ? "text-success" : "text-danger"}`}
@@ -42,17 +46,21 @@ function MatchRows({ matches }: { matches: MatchEntry[] }) {
{formatElo(match.rightEloAfter)}
</Table.Cell>
<Table.Cell className="text-center">
<Link
href={`/assets/${match.rightId}`}
className="inline-flex justify-center"
>
<img
src={`/img/${match.rightId}?size=thumbnail`}
alt=""
className="h-8 rounded object-contain"
style={{ aspectRatio: match.rightAspectRatio }}
/>
</Link>
{match.rightId ? (
<Link
href={`/assets/${match.rightId}`}
className="inline-flex justify-center"
>
<img
src={`/img/${match.rightId}?size=thumbnail`}
alt=""
className="h-8 rounded object-contain"
style={{ aspectRatio: match.rightAspectRatio ?? 1 }}
/>
</Link>
) : (
<div className="inline-block h-8 aspect-square rounded bg-default" />
)}
</Table.Cell>
<Table.Cell>
<TimeAgo date={match.createdAt} />
+24 -16
View File
@@ -19,22 +19,30 @@ export function UpsetsList({ data }: { data: UpsetEntry[] }) {
className="relative flex items-center justify-between p-3 rounded-lg bg-surface"
>
{/* Thumbnails at the edges */}
<Link href={`/assets/${upset.winnerId}`} className="inline-flex">
<img
src={`/img/${upset.winnerId}?size=thumbnail`}
alt=""
className="h-18 rounded object-contain"
style={{ aspectRatio: upset.winnerAspectRatio }}
/>
</Link>
<Link href={`/assets/${upset.loserId}`} className="inline-flex">
<img
src={`/img/${upset.loserId}?size=thumbnail`}
alt=""
className="h-18 rounded object-contain"
style={{ aspectRatio: upset.loserAspectRatio }}
/>
</Link>
{upset.winnerId ? (
<Link href={`/assets/${upset.winnerId}`} className="inline-flex">
<img
src={`/img/${upset.winnerId}?size=thumbnail`}
alt=""
className="h-18 rounded object-contain"
style={{ aspectRatio: upset.winnerAspectRatio ?? 1 }}
/>
</Link>
) : (
<div className="h-18 aspect-square rounded bg-default" />
)}
{upset.loserId ? (
<Link href={`/assets/${upset.loserId}`} className="inline-flex">
<img
src={`/img/${upset.loserId}?size=thumbnail`}
alt=""
className="h-18 rounded object-contain"
style={{ aspectRatio: upset.loserAspectRatio ?? 1 }}
/>
</Link>
) : (
<div className="h-18 aspect-square rounded bg-default" />
)}
{/* Centered text overlay — "beat" anchored to exact center */}
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
+50 -20
View File
@@ -1,8 +1,13 @@
import { createHash } from "node:crypto";
import { and, desc, eq, or, sql } from "drizzle-orm";
import { db } from "@/db";
import { assets, eloHistory, lenses, matches } from "@/db/schema";
import { calculateElo } from "./elo";
function opaqueToken(id: string): string {
return createHash("sha256").update(id).digest("base64url").slice(0, 16);
}
// ---------- Asset by ID ----------
export async function getAssetById(id: string) {
@@ -14,7 +19,7 @@ export async function getAssetById(id: string) {
})
.from(assets)
.leftJoin(lenses, eq(assets.lensId, lenses.id))
.where(eq(assets.id, id));
.where(and(eq(assets.id, id), eq(assets.active, true)));
if (!row) return null;
return {
...row.asset,
@@ -27,8 +32,8 @@ export async function getAssetById(id: string) {
export interface AssetMatchEntry {
id: number;
opponentId: string;
opponentAspectRatio: number;
opponentId: string | null;
opponentAspectRatio: number | null;
won: boolean;
assetEloBefore: number;
assetEloAfter: number;
@@ -51,12 +56,16 @@ export async function getAssetMatchHistory(
.limit(limit)
.offset(page * limit);
// Fetch opponent aspect ratios
// Fetch opponent aspect ratios and active flag; inactive opponents become censored placeholders.
const opponentIds = rows.map((r) =>
r.winnerId === assetId ? r.loserId : r.winnerId,
);
const opponents = await db
.select({ id: assets.id, aspectRatio: assets.aspectRatio })
.select({
id: assets.id,
aspectRatio: assets.aspectRatio,
active: assets.active,
})
.from(assets)
.where(
sql`${assets.id} IN (${sql.join(
@@ -64,7 +73,7 @@ export async function getAssetMatchHistory(
sql`, `,
)})`,
);
const arMap = new Map(opponents.map((o) => [o.id, o.aspectRatio]));
const oppMap = new Map(opponents.map((o) => [o.id, o]));
return rows.map((r) => {
const won = r.winnerId === assetId;
@@ -72,11 +81,14 @@ export async function getAssetMatchHistory(
r.winnerEloBefore,
r.loserEloBefore,
);
const opponentRawId = won ? r.loserId : r.winnerId;
const opp = oppMap.get(opponentRawId);
const visible = opp?.active === true;
return {
id: r.id,
opponentId: won ? r.loserId : r.winnerId,
opponentAspectRatio: arMap.get(won ? r.loserId : r.winnerId) ?? 1,
opponentId: visible ? opponentRawId : null,
opponentAspectRatio: visible ? (opp?.aspectRatio ?? null) : null,
won,
assetEloBefore: won ? r.winnerEloBefore : r.loserEloBefore,
assetEloAfter: won ? newWinnerElo : newLoserElo,
@@ -150,7 +162,8 @@ export async function getAssetEloHistory(
export interface HeadToHeadEntry {
opponentId: string;
opponentAspectRatio: number;
opponentAspectRatio: number | null;
inactive?: true;
wins: number;
losses: number;
total: number;
@@ -179,12 +192,15 @@ export async function getHeadToHead(
tally.set(opponentId, entry);
}
// Fetch opponent aspect ratios
const opponentIds = [...tally.keys()];
if (opponentIds.length === 0) return [];
const opponents = await db
.select({ id: assets.id, aspectRatio: assets.aspectRatio })
.select({
id: assets.id,
aspectRatio: assets.aspectRatio,
active: assets.active,
})
.from(assets)
.where(
sql`${assets.id} IN (${sql.join(
@@ -192,16 +208,30 @@ export async function getHeadToHead(
sql`, `,
)})`,
);
const arMap = new Map(opponents.map((o) => [o.id, o.aspectRatio]));
const oppMap = new Map(opponents.map((o) => [o.id, o]));
return [...tally.entries()]
.map(([opponentId, { wins, losses }]) => ({
opponentId,
opponentAspectRatio: arMap.get(opponentId) ?? 1,
wins,
losses,
total: wins + losses,
}))
.map(([opponentId, { wins, losses }]) => {
const opp = oppMap.get(opponentId);
const total = wins + losses;
if (opp?.active) {
return {
opponentId,
opponentAspectRatio: opp.aspectRatio,
wins,
losses,
total,
};
}
return {
opponentId: opaqueToken(opponentId),
opponentAspectRatio: null,
inactive: true as const,
wins,
losses,
total,
};
})
.sort((a, b) => b.total - a.total);
}
@@ -225,7 +255,7 @@ export async function getEloNeighbors(
eloProvisional: assets.eloProvisional,
})
.from(assets)
.where(sql`${assets.id} != ${assetId}`)
.where(and(sql`${assets.id} != ${assetId}`, eq(assets.active, true)))
.orderBy(sql`ABS(${assets.eloProvisional} - ${elo})`)
.limit(limit);
+3 -3
View File
@@ -1,4 +1,4 @@
import { asc, desc, eq, inArray, sql } from "drizzle-orm";
import { and, asc, desc, eq, inArray, sql } from "drizzle-orm";
import { db } from "@/db";
import { assets, lenses, lensPortraits } from "@/db/schema";
import { fetchAsset, type ImmichAsset } from "./immich";
@@ -188,7 +188,7 @@ export async function getLensDetail(
})
.from(lenses)
.innerJoin(assets, eq(assets.lensId, lenses.id))
.where(eq(lenses.id, lensId))
.where(and(eq(lenses.id, lensId), eq(assets.active, true)))
.groupBy(lenses.id);
if (!agg) return null;
@@ -230,7 +230,7 @@ export async function getLensAssets(
iso: assets.iso,
})
.from(assets)
.where(eq(assets.lensId, lensId))
.where(and(eq(assets.lensId, lensId), eq(assets.active, true)))
.orderBy(orderFn(sortColumn))
.limit(limit)
.offset(page * limit);
+38 -9
View File
@@ -1,6 +1,10 @@
import { and, eq, gt, inArray, isNull } from "drizzle-orm";
import { db } from "@/db";
import { assets, lenses, pairings } from "@/db/schema";
import { getEloDistribution } from "./stats";
const DENSITY_BUCKET_SIZE = 25;
const DENSITY_EXPONENT = 0.5;
const PAIRING_TTL_MS = 24 * 60 * 60 * 1000; // 1 day
@@ -93,6 +97,18 @@ function proximityWeight(eloA: number, eloB: number): number {
return 1 / (1 + (diff * diff) / 30000);
}
/**
* Spike-smearing term: star-seed clusters are dense piles of well-played assets that the
* uncertainty bias can't dislodge (they're not under-played). Weighting A by local Elo density
* preferentially picks out of those clusters so wins/losses diffuse them outward. Sub-linear
* exponent keeps valley assets sampleable.
*/
function densityWeight(elo: number, counts: Map<number, number>): number {
const bucket = Math.floor(elo / DENSITY_BUCKET_SIZE) * DENSITY_BUCKET_SIZE;
const count = counts.get(bucket) ?? 1;
return count ** DENSITY_EXPONENT;
}
function weightedPick<T>(items: T[], weights: number[]): T {
const total = weights.reduce((sum, w) => sum + w, 0);
let rand = Math.random() * total;
@@ -105,21 +121,26 @@ function weightedPick<T>(items: T[], weights: number[]): T {
/**
* Generate a weighted random pairing. Sequentially samples two assets to
* approximate joint info-gain: A is drawn by inverse match count (favour
* under-played assets), then B by inverse match count × Elo proximity to A.
* approximate joint info-gain: A is drawn by inverse match count × local Elo
* density (favour under-played assets and dissolve seed-induced spikes), then
* B by inverse match count × Elo proximity to A.
*/
export async function generatePairing(
voterIp: string,
): Promise<PairingResult | null> {
const activeAssets = await db
.select()
.from(assets)
.where(eq(assets.active, true));
const [activeAssets, distribution] = await Promise.all([
db.select().from(assets).where(eq(assets.active, true)),
getEloDistribution(DENSITY_BUCKET_SIZE),
]);
if (activeAssets.length < 2) return null;
const aWeights = activeAssets.map((a) =>
uncertaintyWeight(a.matchesProvisional),
const densityCounts = new Map(distribution.map((d) => [d.bucket, d.count]));
const aWeights = activeAssets.map(
(a) =>
uncertaintyWeight(a.matchesProvisional) *
densityWeight(a.eloProvisional, densityCounts),
);
const assetA = weightedPick(activeAssets, aWeights);
@@ -184,7 +205,15 @@ export async function getActivePairing(
.from(assets)
.where(eq(assets.id, existing.assetBId));
if (!assetA || !assetB) return null;
// If either side is gone or has been removed from the album since the pairing
// was generated, expire it so the caller falls through to a fresh pairing.
if (!assetA || !assetB || !assetA.active || !assetB.active) {
await db
.update(pairings)
.set({ expiresAt: now })
.where(eq(pairings.uuid, existing.uuid));
return null;
}
const lensMap = await fetchLensMap([assetA.lensId, assetB.lensId]);
+37 -16
View File
@@ -1,4 +1,4 @@
import { asc, desc, eq, isNotNull, sql } from "drizzle-orm";
import { and, asc, desc, eq, isNotNull, sql } from "drizzle-orm";
import { db } from "@/db";
import { assets, lenses, matches, pairings } from "@/db/schema";
import { calculateElo } from "./elo";
@@ -53,6 +53,7 @@ export async function getLeaderboard(
})
.from(assets)
.leftJoin(lenses, eq(assets.lensId, lenses.id))
.where(eq(assets.active, true))
.orderBy(orderFn(col))
.limit(limit)
.offset(page * limit);
@@ -80,10 +81,10 @@ export async function getLeaderboard(
export interface MatchEntry {
id: number;
leftId: string;
rightId: string;
leftAspectRatio: number;
rightAspectRatio: number;
leftId: string | null;
rightId: string | null;
leftAspectRatio: number | null;
rightAspectRatio: number | null;
leftEloAfter: number;
rightEloAfter: number;
winningSide: "left" | "right";
@@ -99,6 +100,7 @@ export async function getRecentMatches(
.select({
id: assets.id,
aspectRatio: assets.aspectRatio,
active: assets.active,
})
.from(assets)
.as("left");
@@ -106,6 +108,7 @@ export async function getRecentMatches(
.select({
id: assets.id,
aspectRatio: assets.aspectRatio,
active: assets.active,
})
.from(assets)
.as("right");
@@ -118,6 +121,8 @@ export async function getRecentMatches(
rightId: pairings.assetBId,
leftAspectRatio: left.aspectRatio,
rightAspectRatio: right.aspectRatio,
leftActive: left.active,
rightActive: right.active,
winnerEloBefore: matches.winnerEloBefore,
loserEloBefore: matches.loserEloBefore,
createdAt: matches.createdAt,
@@ -138,10 +143,10 @@ export async function getRecentMatches(
const winningSide = r.winnerId === r.leftId ? "left" : "right";
return {
id: r.id,
leftId: r.leftId,
rightId: r.rightId,
leftAspectRatio: r.leftAspectRatio,
rightAspectRatio: r.rightAspectRatio,
leftId: r.leftActive ? r.leftId : null,
rightId: r.rightActive ? r.rightId : null,
leftAspectRatio: r.leftActive ? r.leftAspectRatio : null,
rightAspectRatio: r.rightActive ? r.rightAspectRatio : null,
leftEloAfter: winningSide === "left" ? newWinnerElo : newLoserElo,
rightEloAfter: winningSide === "right" ? newWinnerElo : newLoserElo,
winningSide,
@@ -155,10 +160,10 @@ export async function getRecentMatches(
export interface UpsetEntry {
id: number;
winnerId: string;
loserId: string;
winnerAspectRatio: number;
loserAspectRatio: number;
winnerId: string | null;
loserId: string | null;
winnerAspectRatio: number | null;
loserAspectRatio: number | null;
winnerEloBefore: number;
loserEloBefore: number;
eloDiff: number;
@@ -170,6 +175,7 @@ export async function getBiggestUpsets(limit = 10): Promise<UpsetEntry[]> {
.select({
id: assets.id,
aspectRatio: assets.aspectRatio,
active: assets.active,
})
.from(assets)
.as("winner");
@@ -177,6 +183,7 @@ export async function getBiggestUpsets(limit = 10): Promise<UpsetEntry[]> {
.select({
id: assets.id,
aspectRatio: assets.aspectRatio,
active: assets.active,
})
.from(assets)
.as("loser");
@@ -189,6 +196,8 @@ export async function getBiggestUpsets(limit = 10): Promise<UpsetEntry[]> {
loserId: matches.loserId,
winnerAspectRatio: winner.aspectRatio,
loserAspectRatio: loser.aspectRatio,
winnerActive: winner.active,
loserActive: loser.active,
winnerEloBefore: matches.winnerEloBefore,
loserEloBefore: matches.loserEloBefore,
eloDiff: sql<number>`${matches.loserEloBefore} - ${matches.winnerEloBefore}`,
@@ -201,7 +210,17 @@ export async function getBiggestUpsets(limit = 10): Promise<UpsetEntry[]> {
.orderBy(desc(sql`${matches.loserEloBefore} - ${matches.winnerEloBefore}`))
.limit(limit);
return rows;
return rows.map((r) => ({
id: r.id,
winnerId: r.winnerActive ? r.winnerId : null,
loserId: r.loserActive ? r.loserId : null,
winnerAspectRatio: r.winnerActive ? r.winnerAspectRatio : null,
loserAspectRatio: r.loserActive ? r.loserAspectRatio : null,
winnerEloBefore: r.winnerEloBefore,
loserEloBefore: r.loserEloBefore,
eloDiff: r.eloDiff,
createdAt: r.createdAt,
}));
}
// ---------- Most Controversial ----------
@@ -223,6 +242,7 @@ export async function getMostControversial(
const rows = await db
.select()
.from(assets)
.where(eq(assets.active, true))
.orderBy(
sql`ABS(CASE WHEN ${assets.matchesProvisional} > 0 THEN CAST(${assets.winsProvisional} AS REAL) / ${assets.matchesProvisional} ELSE 0.5 END - 0.5)`,
desc(assets.matchesProvisional),
@@ -256,6 +276,7 @@ export async function getEloDistribution(
count: sql<number>`COUNT(*)`,
})
.from(assets)
.where(eq(assets.active, true))
.groupBy(
sql`CAST(${assets.eloProvisional} / ${bucketSize} AS INTEGER) * ${bucketSize}`,
)
@@ -306,7 +327,7 @@ export async function getLensStats(): Promise<LensStats[]> {
})
.from(lenses)
.innerJoin(assets, eq(assets.lensId, lenses.id))
.where(isNotNull(assets.lensId))
.where(and(isNotNull(assets.lensId), eq(assets.active, true)))
.groupBy(lenses.id)
.orderBy(desc(sql`AVG(${assets.eloProvisional})`));
@@ -320,7 +341,7 @@ export async function getLensStats(): Promise<LensStats[]> {
elo: assets.eloProvisional,
})
.from(assets)
.where(eq(assets.lensId, row.lensId))
.where(and(eq(assets.lensId, row.lensId), eq(assets.active, true)))
.orderBy(desc(assets.eloProvisional))
.limit(1);