feat(remix): say at click time which remix modes we can verify (#4939)

* feat(remix): say at click time which remix modes we can verify

A prompt-reuse remix feeds no image to the job, so the server resolves no
sourceImageIds and the remix gallery correctly refuses the free submission.
The submit modal already explains that (RemixGallerySubmitModal renders
freeUnavailableReason outside the free/paid block, deliberately). What is
missing is earlier: the three remix options look alike at the moment of
choosing, so the difference is only discoverable after generating.

Mark the two that feed the image itself. On the options that HAVE the
property rather than the one that lacks it — reusing a prompt is a
legitimate remix and the menu should not read as warning someone off it.
It says we can verify, not that the submission will be free: whether free
is on offer is five more rungs in freeSubmissionOffer, and a menu that
promised it would be overruled at the modal.

remixClaimState is split out of remixClaimHolds so the claim's outcome —
holds, carrier, why not, and the score where the prompt is the carrier —
has one derivation. The predicate is now a wrapper over it. A surface that
computed its own similarity would be a second copy of the 0.75 threshold,
and the copy that drifts is the one telling someone their remix still
counts while the submit is about to drop it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(remix): assert the reason, not just the boolean

Review round.

The comment on remixClaimState said the drift notice renders it. There is
no drift notice — it moved to the following PR — so the comment asserted a
consumer that does not exist. Corrected to state the instruction that is
actually load-bearing: this is the only derivation, reuse it rather than
recomputing the threshold.

Every existing test asserted through the holds boolean, so carrier, reason
and score could take any value and stay green. That is the half the next PR
branches on. The pair that matters is drifted against uncarried: both are
holds:false and nothing else separates them, so reporting drifted for a
cleared prompt box would tell someone they had changed their mind at the
moment they cleared it to retype.

Asserted field by field rather than with toMatchObject, which truncates to
"expected { holds: false, ...(3) } to match object { holds: false, ...(3) }"
and never names the value that was wrong. Verified by reverting: swapping
the reason on the cleared-prompt branch now fails with "expected 'drifted'
to be 'uncarried'".

Three comment blocks trimmed to the fact they carry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(remix): cover the three branches a mutation passed through

Second review round found the first one incomplete.

Three branches had no assertion at the state level, so mutating them
printed nothing: the no-remix reason, the branch where the remix seeded no
prompt at all — which is a different branch from the form's prompt being
cleared, and carries nothing rather than the prompt — and the media
branch's reason.

The drifted score assertion was vacuous by fixture rather than by shape.
The two prompts share no token after cleaning, so every term in the
similarity is zero and the score is exactly 0; toBeLessThan(0.75) is then
true of any bounded wrong answer, including a constant. Replaced with an
ordering over three fixtures whose scores were measured rather than
assumed: 0.7601 for two tags changed, 0.2969 for four of eleven left, 0
for disjoint. A constant score now fails with "expected 0.1 to be greater
than 0.1".

Comments cut, not trimmed. "A server-side check is coming" was the same
unfalsifiable forward claim as the one this round already corrected, for a
PR that does not exist. The block comment over the tests restated what the
test names say and what remix-claim.ts states three lines from the branch
it describes, and the note defending the assertion style was the fix round
arguing with a reviewer inside the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(remix): drop the comment defending the assertion, keep the calibration

The note above the ordering assertions described the previous version of
the test rather than this one, and claimed the null fallback guarded a case
that cannot occur at that call site — all three scores come from the branch
that always returns a number. The test name already says what the
assertions say.

The fixture docs keep what a reader cannot recover by eye, that the overlap
is calibrated rather than incidental, and lose the measured decimals. Those
are pinned by no assertion, so a retuned similarity would leave them wrong
with everything still green. The numbers are in the PR body, which is dated
by nature.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(remix): assert the expired branch carries nothing

Third review round, third branch whose mutation printed nothing: the
expired claim asserted its reason, its holds and its score, and not its
carrier. Verified silent by running it — 15 passed with carrier changed to
'prompt' — and now fails with "expected 'prompt' to be null".

Two comments corrected rather than trimmed. The fixture docs said eleven
tags where the seed has nine; eleven is its token count, which is what the
similarity works on, but the sentence says tags. The state doc claimed
expired and uncarried are not caused by the person, which is false for the
branch where they cleared the prompt box themselves — the inline comment
two lines below says exactly that.

The predicate's own doc restated its signature and went.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(remix): assert the whole state on every branch

Three review rounds each found one more field unasserted on one more
branch, a different field each time. Assertions written per branch pin what
their author was thinking about, so a fourth round would have sampled the
same hole rather than closed it.

toEqual over the whole object on every branch, table-driven. A field-level
mutation cannot survive it, because there is no field that no assertion
mentions. Verified against all five survivors the rounds found, plus a
sixth chosen on a branch none of them touched: all six red, the sixth
printing carrier prompt against media.

score is expect.any(Number) where the prompt carries the claim. Its value
is pinned by the ordering beside it, which rules out a constant and a wrong
ranking and does not rule out a monotone-but-wrong score. That is the
ceiling of an ordering property rather than a gap here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Justin Maier
2026-09-18 11:38:30 -06:00
committed by GitHub
parent 5ff986be11
commit e50d122cb2
3 changed files with 159 additions and 13 deletions
+23 -1
View File
@@ -1,6 +1,6 @@
import { Menu, Text, ThemeIcon } from '@mantine/core';
import { useEffect, useRef } from 'react';
import { IconBrush, IconMovie, IconWand } from '@tabler/icons-react';
import { IconBrush, IconMovie, IconRosetteDiscountCheck, IconWand } from '@tabler/icons-react';
import type { RemixKind } from '~/shared/constants/remix.constants';
import { useTrackEvent } from '~/components/TrackView/track.utils';
import {
@@ -22,6 +22,11 @@ type RemixOption = {
icon: typeof IconWand;
/** A colour per option so the three read as distinct things, not a list. */
color: string;
/**
* Set on the options that verify, not on the one that lacks it — so reusing a
* prompt doesn't read as a discouraged choice.
*/
verifiable: boolean;
};
const kindLabels: Record<RemixKind, RemixOption> = {
@@ -30,12 +35,14 @@ const kindLabels: Record<RemixKind, RemixOption> = {
description: 'Change this image with a prompt',
icon: IconWand,
color: 'violet',
verifiable: true,
},
video: {
label: 'Animate',
description: 'Turn this image into a video',
icon: IconMovie,
color: 'blue',
verifiable: true,
},
};
@@ -44,8 +51,22 @@ const reuseOption: RemixOption = {
description: "Start from this image's settings",
icon: IconBrush,
color: 'teal',
verifiable: false,
};
/**
* States what this earns, not a promise the submission will be free —
* `freeSubmissionOffer` decides that later and can override it.
*/
function VerifiedHint() {
return (
<Text size="xs" c="green.6" lh={1.2} className="mt-1 flex items-center gap-1">
<IconRosetteDiscountCheck size={13} stroke={1.9} />
Uses this image, so we can verify the remix
</Text>
);
}
function OptionIcon({ option }: { option: RemixOption }) {
return (
<ThemeIcon size={36} radius="md" variant="light" color={option.color}>
@@ -63,6 +84,7 @@ function OptionLabel({ option }: { option: RemixOption }) {
<Text size="xs" c="dimmed" lh={1.2}>
{option.description}
</Text>
{option.verifiable && <VerifiedHint />}
</div>
);
}
+92 -1
View File
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { REMIX_CLAIM_TTL, useRemixStore } from '~/store/remix.store';
import { remixClaimHolds, resolveRemixOfId } from '~/utils/remix-claim';
import { remixClaimHolds, remixClaimState, resolveRemixOfId } from '~/utils/remix-claim';
const SOURCE_ID = 11217158;
@@ -74,3 +74,94 @@ describe('remixClaimHolds', () => {
expect(resolveRemixOfId({})).toBeUndefined();
});
});
/** Four of the seed's nine tags survive — a real remix that moved on, scoring below the 0.75 cutoff. */
const PARTIAL_PROMPT =
'1girl, 1boy, creampie, bed, forest, waterfall, sunlight, castle, epic fantasy';
/** Two of the seed's nine tags changed — a real edit that still scores at or above the 0.75 cutoff. */
const NEAR_PROMPT =
'netorare, cuckold pov, 1girl, 1boy, creampie, bed, bedroom, day, plain background';
describe('remixClaimState', () => {
const state = (form: Parameters<typeof remixClaimState>[1]) =>
remixClaimState(useRemixStore.getState().data, form);
/**
* The WHOLE object on every branch, not the fields that seemed interesting.
* Three review rounds each found one more field nobody had asserted on one
* more branch — a different field each time — because assertions written
* per-branch pin what their author was thinking about. `toEqual` pins every
* field whether or not anyone thought about it.
*
* `score` is `expect.any(Number)` where the prompt carries the claim: its
* VALUE is pinned by the ordering test below, which is the only thing that
* survives a retuned similarity.
*/
it.each([
{
when: 'there is no remix at all',
arrange: () => undefined,
form: { prompt: SEEDED_PROMPT },
then: { holds: false, carrier: null, reason: 'none', score: null },
},
{
when: 'the claim is older than its TTL',
arrange: () => seedRemix(SEEDED_PROMPT, Date.now() - REMIX_CLAIM_TTL - 1),
form: { prompt: SEEDED_PROMPT },
then: { holds: false, carrier: null, reason: 'expired', score: null },
},
{
when: 'the remix seeded no prompt to compare against',
arrange: () => seedRemix(' '),
form: { prompt: SEEDED_PROMPT },
then: { holds: false, carrier: null, reason: 'uncarried', score: null },
},
{
when: 'the person cleared the prompt box',
arrange: () => seedRemix(),
form: { prompt: ' ' },
then: { holds: false, carrier: 'prompt', reason: 'uncarried', score: null },
},
{
when: 'the prompt was rewritten past the cutoff',
arrange: () => seedRemix(),
form: { prompt: PARTIAL_PROMPT },
then: { holds: false, carrier: 'prompt', reason: 'drifted', score: expect.any(Number) },
},
{
when: 'the prompt changed but still scores above the cutoff',
arrange: () => seedRemix(),
form: { prompt: NEAR_PROMPT },
then: { holds: true, carrier: 'prompt', reason: null, score: expect.any(Number) },
},
{
when: 'the form still holds the source media',
arrange: () => seedRemix(),
form: { prompt: 'pan left', video: { url: 'https://x/1.mp4' } },
then: { holds: true, carrier: 'media', reason: null, score: null },
},
])('$when', ({ arrange, form, then }) => {
arrange();
expect(state(form)).toEqual(then);
});
/**
* What the ordering closes and what it does not: it rules out a score that
* collapses to a constant or ranks the fixtures wrongly. A monotone-but-wrong
* score — raw cosine, any order-preserving scaling — still passes. That is the
* ceiling of an ordering property, not a gap in this instance.
*/
it('ranks a nearer prompt above a further one, and a disjoint one at the floor', () => {
seedRemix();
const near = state({ prompt: NEAR_PROMPT }).score ?? -1;
const partial = state({ prompt: PARTIAL_PROMPT }).score ?? -1;
const disjoint = state({ prompt: UNRELATED_PROMPT }).score ?? -1;
expect(near).toBeGreaterThanOrEqual(0.75);
expect(near).toBeGreaterThan(partial);
expect(partial).toBeGreaterThan(disjoint);
expect(partial).toBeLessThan(0.75);
expect(disjoint).toBe(0);
});
});
+44 -11
View File
@@ -9,6 +9,17 @@ export type RemixClaimFormState = {
video?: unknown;
};
/**
* `score` is set only on the prompt branch — `null` there means not applicable,
* never zero.
*/
export type RemixClaimState = {
holds: boolean;
carrier: 'media' | 'prompt' | null;
reason: 'none' | 'expired' | 'uncarried' | 'drifted' | null;
score: number | null;
};
/**
* Does the form still contain what the remix put there?
*
@@ -22,22 +33,44 @@ export type RemixClaimFormState = {
* prompt with its source, so it broke the link exactly where the derivation was
* most literal (see `track.schema.ts`) — it is reinstated here only on the
* branch where the prompt IS the carrier.
*
* 🔴 The only derivation of this rule — reuse it, don't recompute the threshold
* elsewhere. A second copy that drifts from this one could tell someone their
* remix still counts when the submit drops it.
*/
export function remixClaimState(
data: RemixData | null,
form: RemixClaimFormState
): RemixClaimState {
if (!data) return { holds: false, carrier: null, reason: 'none', score: null };
if (!isRemixDataFresh(data))
return { holds: false, carrier: null, reason: 'expired', score: null };
// Media-consuming workflows carry the derivation in the media; what verifies
// it there is `remix-provenance.store`, keyed by the image's current url.
if (form.images?.length || form.video)
return { holds: true, carrier: 'media', reason: null, score: null };
const seeded = data.originalParams.prompt;
if (typeof seeded !== 'string' || !seeded.trim())
return { holds: false, carrier: null, reason: 'uncarried', score: null };
// An empty prompt is someone mid-edit, not someone who has drifted. Scoring it
// would report a confident 0 at the moment the box is cleared to retype.
if (!form.prompt?.trim())
return { holds: false, carrier: 'prompt', reason: 'uncarried', score: null };
const { similar, adjustedCosine } = promptSimilarity(seeded, form.prompt);
return similar
? { holds: true, carrier: 'prompt', reason: null, score: adjustedCosine }
: { holds: false, carrier: 'prompt', reason: 'drifted', score: adjustedCosine };
}
export function remixClaimHolds(
data: RemixData | null,
form: RemixClaimFormState
): data is RemixData {
if (!isRemixDataFresh(data)) return false;
// Media-consuming workflows carry the derivation in the media; what verifies
// it there is `remix-provenance.store`, keyed by the image's current url.
if (form.images?.length || form.video) return true;
const seeded = data.originalParams.prompt;
if (typeof seeded !== 'string' || !seeded.trim()) return false;
if (!form.prompt?.trim()) return false;
return promptSimilarity(seeded, form.prompt).similar;
return remixClaimState(data, form).holds;
}
/** The `remixOfId` a submission may carry, or undefined when the claim is dead. */