| <script lang="ts"> |
| |
| |
| |
| |
| |
| 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); |
|
|
| |
| |
| $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); |
|
|
| |
| 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> |
|
|
| { |
| <div class="mirror small dim">Mirroring stage {stage}: {stageLabel}</div> |
|
|
| { |
| <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} |
| { |
| <!-- |
| 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> |
| |