feat(ccip): tunable match threshold, default 0.85 (#114)
Live data showed the v1 flat 0.75 cosine over-fired — ~64% of matched images got
3-10 character guesses dominated by the most-referenced characters (a 27-ref
character clears a low bar on many images). A sweep showed 0.85 collapses the
noise (noisy multi-matches 47→3) while keeping the confident single-character
matches.
- ml_settings.ccip_match_threshold (migration 0063, default 0.85); match_image
reads it (override still accepted). DEFAULT_SIM_THRESHOLD fallback 0.75→0.85.
- Exposed in GET/PATCH /api/ml/settings (validated 0.5–0.999).
- Slider in the GPU agent card ("Character-match strictness") — tune live, no
redeploy, same observe-and-tune loop as auto-apply.
Test: a ~0.9-cosine figure matches at 0.85, dropped at 0.95.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
This commit is contained in:
@@ -60,6 +60,22 @@
|
||||
Enqueues every image that doesn't have a CCIP embedding yet. Nothing
|
||||
processes until the agent is running.
|
||||
</p>
|
||||
|
||||
<!-- Match strictness -->
|
||||
<div class="fc-section-h mt-5 mb-1">Character-match strictness</div>
|
||||
<div v-if="ml.settings" class="d-flex align-center" style="gap:12px">
|
||||
<v-slider
|
||||
v-model="threshold" :min="0.70" :max="0.95" :step="0.01"
|
||||
color="accent" hide-details density="compact" class="flex-grow-1"
|
||||
:loading="savingThreshold" @end="onSaveThreshold"
|
||||
/>
|
||||
<span class="fc-q__n" style="font-size:16px">{{ threshold.toFixed(2) }}</span>
|
||||
</div>
|
||||
<p class="fc-muted text-caption mt-1 mb-0">
|
||||
How close a figure must be (CCIP cosine) to suggest a character. Higher =
|
||||
stricter — fewer but more confident matches. 0.85 recommended; below ~0.80
|
||||
a heavily-tagged character starts matching everything.
|
||||
</p>
|
||||
</MaintenanceTile>
|
||||
</template>
|
||||
|
||||
@@ -69,14 +85,18 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||
import { useGpuStore } from '../../stores/gpu.js'
|
||||
import { useMLStore } from '../../stores/ml.js'
|
||||
import { copyText } from '../../utils/clipboard.js'
|
||||
|
||||
const store = useGpuStore()
|
||||
const ml = useMLStore()
|
||||
const loading = ref(true)
|
||||
const tokenValue = ref(null)
|
||||
const masked = ref(true)
|
||||
const rotating = ref(false)
|
||||
const backfilling = ref(false)
|
||||
const threshold = ref(0.85)
|
||||
const savingThreshold = ref(false)
|
||||
const queue = ref({ pending: 0, leased: 0, done: 0, error: 0 })
|
||||
let pollTimer = null
|
||||
|
||||
@@ -94,9 +114,27 @@ onMounted(async () => {
|
||||
}
|
||||
await refreshQueue()
|
||||
pollTimer = setInterval(() => { if (!document.hidden) refreshQueue() }, 5000)
|
||||
try {
|
||||
await ml.loadSettings()
|
||||
if (ml.settings?.ccip_match_threshold != null) {
|
||||
threshold.value = ml.settings.ccip_match_threshold
|
||||
}
|
||||
} catch { /* non-fatal */ }
|
||||
})
|
||||
onUnmounted(() => { if (pollTimer) clearInterval(pollTimer) })
|
||||
|
||||
async function onSaveThreshold() {
|
||||
savingThreshold.value = true
|
||||
try {
|
||||
await ml.patchSettings({ ccip_match_threshold: threshold.value })
|
||||
toast({ text: `Match strictness set to ${threshold.value.toFixed(2)}`, type: 'success' })
|
||||
} catch (e) {
|
||||
toast({ text: `Could not save: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
savingThreshold.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshQueue() {
|
||||
try { queue.value = await store.status() } catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user