text-watermark-microscope / src /components /BaselinePanel.svelte
roomnumber103's picture
Add LLM Text Watermark Microscope
c126239 verified
Raw
History Blame Contribute Delete
9.72 kB
<script lang="ts">
/**
* The baseline is generated once per prompt and then held fixed. It mirrors
* whatever stage the watermark panel is on, so every claim on the right has
* an unwatermarked control on the left.
*/
import {
baseline,
wizards,
activeMethod,
algoParams,
baselineCollapsed,
biasStepIndex,
editorDetect,
requestDetect,
METHOD_META,
} from '../lib/stores/app-state';
import { kirchenbauerSeed, greenUniform, keyFromSeed } from '../lib/utils/hashing';
import { isGreen } from '../lib/watermark/kirchenbauer';
import ProbBars from './shared/ProbBars.svelte';
import type { BarRow } from './shared/ProbBars.svelte';
const method = $derived($activeMethod);
const wizard = $derived($wizards[method]);
const stage = $derived(wizard.stage);
const dist = $derived($baseline.distribution);
const result = $derived($baseline.result);
const stageLabel = $derived(METHOD_META[method].stages[stage - 1]);
const BKEY = 'baseline';
const bundle = $derived($editorDetect[BKEY]?.detections ?? null);
// Score the baseline text with the same secrets whenever we reach a stage
// that compares classifications.
$effect(() => {
const text = result?.text;
if (!text) return;
if (stage >= 3) requestDetect(BKEY, text, false, 0);
});
const kirchDet = $derived(bundle?.kirchenbauer ?? null);
const ksemDet = $derived(bundle?.ksemstamp ?? null);
const sealDet = $derived(bundle?.textseal ?? null);
/** Mirror the exact step the watermark panel is illustrating. */
const steps = $derived(result?.trace.steps ?? []);
const stepIdx = $derived(Math.min($biasStepIndex, Math.max(0, steps.length - 1)));
const mirroredStep = $derived(steps[stepIdx]);
const barRows = $derived.by((): BarRow[] => {
if (mirroredStep) {
return mirroredStep.topCandidates.map((c) => ({
tokenId: c.tokenId,
text: c.tokenText,
before: c.prob,
kind: 'neutral' as const,
}));
}
if (!dist) return [];
return dist.candidates.slice(0, 12).map((c) => ({
tokenId: c.tokenId,
text: c.text,
before: c.prob,
kind: 'neutral' as const,
}));
});
const greenSummary = $derived.by(() => {
if (method !== 'kirchenbauer' || !kirchDet) return null;
const units = kirchDet.perUnit ?? [];
const scored = units.filter((u) => !u.skipped);
const green = scored.filter((u) => u.green).length;
return { green, total: scored.length };
});
</script>
<section class="panel base" class:collapsed={$baselineCollapsed}>
<header>
<div>
<strong>Baseline</strong>
<span class="badge">no watermark</span>
</div>
<button class="toggle" onclick={() => baselineCollapsed.update((v) => !v)}>
{$baselineCollapsed ? 'show' : 'hide'}
</button>
</header>
{#if !$baselineCollapsed}
<div class="mirror small dim">Mirroring stage {stage}: {stageLabel}</div>
{#if $baseline.status === 'idle'}
<p class="dim">Enter a prompt above and generate the baseline to begin.</p>
{:else if $baseline.status === 'running'}
<p class="text-box stream">{$baseline.streamingText || 'generating…'}</p>
{:else if $baseline.status === 'error'}
<p class="err small mono">{$baseline.error}</p>
{:else if result}
{#if stage === 1}
<!--
Nothing to mirror here: there is no secret on this side, so there is
no split, no cluster rule and no R values. Showing a greyed-out
version of the watermark's artefacts would invent a comparison that
does not exist. The text itself is the control.
-->
<p class="dim small">
Written once for this prompt and then left alone — the control the watermarked text on the
right is measured against.
</p>
<p class="text-box">{result.text}</p>
{:else if stage === 2}
{#if method === 'ksemstamp'}
<p class="dim small">
The baseline wrote every sentence in one pass. Nothing was embedded, judged or thrown
away — the cost the watermark on the right is paying does not exist here.
</p>
<ol class="text-box list sentences">
{#each result.text.split(/(?<=[.!?])\s+/).filter(Boolean) as s}
<li><span class="badge">kept</span><span>{s}</span></li>
{/each}
</ol>
{:else}
<p class="dim small">
The unmodified distribution at step {stepIdx} — this is what the watermark on the right
is transforming. Baseline sampling picked
<span class="mono">"{mirroredStep?.chosenTokenText ?? ''}"</span> here.
</p>
<ProbBars
rows={barRows}
chosenTokenId={mirroredStep?.chosenTokenId ?? null}
showAfter={false}
/>
{/if}
{:else if stage === 3 || stage === 4}
{#if method === 'kirchenbauer'}
<p class="dim small">
The same secret applied to unwatermarked text. Green appears only at the rate chance
allows.
</p>
{#if kirchDet}
<div class="text-box">
{#each kirchDet.perUnit ?? [] as u}
<span class={u.skipped ? 'tok-skip' : u.green ? 'tok-green' : 'tok-red'}
>{u.label}</span
>
{/each}
</div>
{#if greenSummary}
<div class="small dim">
{greenSummary.green} of {greenSummary.total} scored tokens green
(<span class="mono"
>{((greenSummary.green / Math.max(1, greenSummary.total)) * 100).toFixed(0)}%</span
>, expected {Math.round($algoParams.kirchenbauer.gamma * 100)}%)
</div>
{/if}
{:else}
<p class="dim small">scoring…</p>
{/if}
{:else if method === 'ksemstamp'}
<p class="dim small">Baseline sentences judged against the same valid regions.</p>
{#if ksemDet}
<ol class="text-box list sentences">
{#each ksemDet.perUnit ?? [] as u}
<li class={u.skipped ? '' : u.green ? 'valid' : 'invalid'}>
<span class="badge {u.skipped ? '' : u.green ? 'ok' : 'bad'}">
{u.skipped ? 'seed' : u.green ? 'valid' : 'invalid'}
</span>
<span>{u.label}</span>
</li>
{/each}
</ol>
{:else}
<p class="dim small">needs at least two sentences…</p>
{/if}
{:else if sealDet}
<p class="dim small">The same key over unwatermarked text: evidence stays flat.</p>
<div class="text-box plain mono heat">
{#each sealDet.perUnit ?? [] as u}
{@const s = u.score ?? 0}
<span
style="background: rgba(29,95,208,{Math.min(0.55, 0.05 + 0.14 * s).toFixed(2)})"
title={`s=${s.toFixed(2)}`}>{u.label}</span
>
{/each}
</div>
{:else}
<p class="dim small">scoring…</p>
{/if}
{#if stage === 4}
<div class="scores">
{#each [['Kirchenbauer', kirchDet], ['k-SemStamp', ksemDet], ['TextSeal', sealDet]] as const as [label, d]}
<div class="score">
<div class="small dim">{label}</div>
{#if d}
<div class="mono stat">{d.statistic.toFixed(2)}</div>
<span class="badge {d.inconclusive ? 'warn' : d.watermarked ? 'ok' : 'bad'}">
{d.inconclusive ? 'inconclusive' : d.watermarked ? 'detected' : 'not detected'}
</span>
{:else}
<div class="dim small">n/a</div>
{/if}
</div>
{/each}
</div>
{/if}
{/if}
{#if stage === 2}
<details class="raw">
<summary class="small dim">baseline text</summary>
<p class="text-box">{result.text}</p>
</details>
{/if}
<div class="meta small dim mono">
{result.tokenIds.length} tok · {(result.timings.totalMs / 1000).toFixed(1)}s ·
{result.timings.tokensPerSec.toFixed(1)} tok/s
</div>
{/if}
{/if}
</section>
<style>
.base {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
}
.toggle {
font-size: 12px;
padding: 2px 8px;
}
.mirror {
border-bottom: 1px solid var(--border);
padding-bottom: 6px;
}
.heat {
font-size: 13px;
line-height: 2;
}
.stream {
opacity: 0.75;
}
.sentences {
font-size: 13.5px;
}
.sentences li {
display: flex;
gap: 8px;
align-items: baseline;
padding: 5px 9px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel-2);
}
.sentences li.valid {
background: var(--green-bg);
border-color: var(--green-border);
}
.sentences li.invalid {
background: var(--red-bg);
border-color: var(--red-border);
}
.scores {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
border-top: 1px solid var(--border);
padding-top: 8px;
}
.score {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 8px;
}
.stat {
font-size: 17px;
font-weight: 650;
}
.meta {
border-top: 1px solid var(--border);
padding-top: 6px;
}
.err {
color: var(--red);
}
</style>