chore: retire the tag-eval harness — it proved the heads system, job done (operator-approved)
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 33s
CI / integration (push) Successful in 3m24s

The head-vs-centroid eval (#1130) existed to prove the 'frozen embedding +
trained head' spine; the operator accepted the tagging system and dropped the
harness. Removed per rule 22: TagEvalCard + store, /api/tag_eval blueprint,
tag_eval_run ml task, recover-stalled-tag-eval-runs sweep + beat entry,
TagEvalRun model + table (migration 0073), and its tests.

The eval's data loaders + metric helpers were NOT eval-specific — the nightly
heads trainer runs on them — so they moved verbatim to
services/ml/training_data.py (heads.py import updated; behavior unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
2026-07-02 12:41:24 -04:00
parent a7abcc41ca
commit eaea4308fc
17 changed files with 178 additions and 1091 deletions
@@ -132,8 +132,8 @@ const hasMenu = computed(() =>
color: rgb(var(--v-theme-on-surface-variant, var(--v-theme-on-surface)));
font-family: 'JetBrains Mono', monospace;
}
/* Green ✓ / red ✗ verdict pair — same circular language as the eval card
(TagEvalCard .fc-act) so accept/reject read identically across surfaces. */
/* Green ✓ / red ✗ verdict pair — circular buttons so accept/reject read
identically across surfaces. */
.fc-suggestion__acts {
flex: 0 0 auto; display: flex; gap: 4px;
}
@@ -28,7 +28,6 @@
<HeadsCard />
<GpuAgentCard />
<AliasTable />
<TagEvalCard />
</div>
</section>
@@ -55,7 +54,6 @@ import MLThresholdSliders from './MLThresholdSliders.vue'
import HeadsCard from './HeadsCard.vue'
import GpuAgentCard from './GpuAgentCard.vue'
import AliasTable from './AliasTable.vue'
import TagEvalCard from './TagEvalCard.vue'
import BackupCard from './BackupCard.vue'
import { useSystemActivityStore } from '../../stores/systemActivity.js'
@@ -1,303 +0,0 @@
<template>
<MaintenanceTile
icon="mdi-flask-outline"
title="Tagging eval (heads vs centroid)"
blurb="Measure whether a trained head beats the old centroid on your own tags — and whether tagging more sharpens it."
:open="running"
>
<p class="fc-muted text-body-2 mb-3">
Reuses the SigLIP embeddings already stored on your images (no re-embed, no
GPU). For each concept it trains a logistic-regression <strong>head</strong>
on your positives + negatives and compares it to the old single
<strong>centroid</strong>, with cross-validated AP/F1 and a learning curve.
Runs as a background task; the result is saved and reloads here.
</p>
<v-textarea
v-model="conceptsText" label="Concepts (comma-separated)"
rows="2" auto-grow density="compact" hide-details class="mb-3"
:disabled="running"
/>
<div class="d-flex mb-3" style="gap: 12px;">
<v-text-field
v-model.number="autoTopN" label="+ auto-add top-N concepts"
type="number" min="0" max="200" density="compact" hide-details
:disabled="running" style="max-width: 220px;"
/>
<v-text-field
v-model.number="precisionTarget" label="Auto-apply precision target"
type="number" min="0.5" max="0.999" step="0.01" density="compact" hide-details
:disabled="running" style="max-width: 220px;"
/>
</div>
<v-btn
v-if="!running"
color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-play" :loading="busy" @click="onStart"
>Run eval</v-btn>
<div v-if="running" class="mt-3">
<v-progress-linear indeterminate color="accent" />
<div class="text-body-2 mt-2 fc-muted">Running (started {{ startedAgo }})</div>
</div>
<v-alert
v-if="run && run.status === 'error'"
type="error" variant="tonal" density="compact" class="mt-3"
>Eval failed: {{ run.error }}</v-alert>
<div v-if="report" class="mt-4">
<div class="fc-muted text-caption mb-2">
Ran {{ formatTime(report.generated_at) }} ·
{{ report.concepts.length }} concept(s) ·
neg ratio {{ report.params.neg_ratio }}, {{ report.params.cv_folds }}-fold CV
</div>
<div v-for="c in report.concepts" :key="c.name" class="fc-cc">
<div class="fc-cc__head">
<span class="fc-cc__name">{{ c.name }}</span>
<span v-if="c.skipped" class="fc-muted text-caption"> skipped: {{ c.skipped }}</span>
<span v-else class="fc-muted text-caption">
{{ c.n_pos }} pos · {{ c.n_neg }} neg<span v-if="c.n_rejected"> ({{ c.n_rejected }} rejected)</span>
</span>
</div>
<template v-if="!c.skipped">
<table class="fc-metrics">
<thead>
<tr><th></th><th>AP</th><th>F1</th><th>Prec</th><th>Rec</th></tr>
</thead>
<tbody>
<tr>
<td class="fc-metrics__lbl">Head</td>
<td class="fc-num fc-win">{{ c.head.ap }}</td>
<td class="fc-num">{{ c.head.f1 }}</td>
<td class="fc-num">{{ c.head.precision }}</td>
<td class="fc-num">{{ c.head.recall }}</td>
</tr>
<tr>
<td class="fc-metrics__lbl fc-muted">Centroid</td>
<td class="fc-num fc-muted">{{ c.centroid.ap }}</td>
<td class="fc-num fc-muted">{{ c.centroid.f1 }}</td>
<td class="fc-num fc-muted">{{ c.centroid.precision }}</td>
<td class="fc-num fc-muted">{{ c.centroid.recall }}</td>
</tr>
</tbody>
</table>
<div class="text-caption mb-2" :class="apDelta(c) >= 0 ? 'fc-up' : 'fc-down'">
Δ AP {{ apDelta(c) >= 0 ? '+' : '' }}{{ apDelta(c).toFixed(3) }}
(head centroid)
</div>
<div class="text-caption mb-2">
<span class="fc-muted">Auto-apply:</span>
<template v-if="c.head.auto_apply">
<span class="fc-up">ready</span> at P{{ c.head.auto_apply.target }}
catches recall <strong>{{ c.head.auto_apply.recall }}</strong>
(thr {{ c.head.auto_apply.threshold }})
</template>
<span v-else class="fc-down">not reachable at P{{ report.params.precision_target }}</span>
</div>
<div v-if="c.curve && c.curve.length" class="fc-curve">
<span class="fc-muted text-caption">Learning curve (AP @ N positives):</span>
<span v-for="p in c.curve" :key="p.n_pos" class="fc-curve__pt">
{{ p.n_pos }}<strong>{{ p.ap }}</strong>
</span>
</div>
<div v-if="c.examples" class="fc-ex">
<div
v-for="grp in [
{ dir: 'suggest', items: c.examples.head_would_suggest,
label: `Head would suggest — ✓ tag it, ✗ not ${c.name}` },
{ dir: 'doubts', items: c.examples.head_doubts_positive,
label: `Head doubts your tag — ✓ keep, ✗ remove (not ${c.name})` },
]" :key="grp.dir" class="fc-ex__row"
>
<div class="fc-muted text-caption mb-1">{{ grp.label }}</div>
<div class="fc-ex__thumbs">
<div
v-for="it in grp.items" :key="`${grp.dir}${it.id}`"
class="fc-ex__item"
:class="actedLabel(c, grp.dir, it) ? 'fc-ex__item--acted' : ''"
>
<button
type="button" class="fc-ex__thumb"
:title="`#${it.id} — click to enlarge`" @click="modal.open(it.id)"
>
<img :src="it.thumbnail_url" loading="lazy" />
</button>
<div v-if="actedLabel(c, grp.dir, it)" class="fc-ex__badge">
{{ actedLabel(c, grp.dir, it) }}
</div>
<div v-else class="fc-ex__acts">
<button
class="fc-act fc-act--yes" type="button"
:title="`Yes — it is ${c.name}`" @click="act(c, it, grp.dir, 'yes')"
><v-icon size="15">mdi-check</v-icon></button>
<button
class="fc-act fc-act--no" type="button"
:title="`No — not ${c.name}`" @click="act(c, it, grp.dir, 'no')"
><v-icon size="15">mdi-close</v-icon></button>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</MaintenanceTile>
</template>
<script setup>
import { toast } from '../../utils/toast.js'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import MaintenanceTile from '../common/MaintenanceTile.vue'
import { useTagEvalStore } from '../../stores/tagEval.js'
import { useModalStore } from '../../stores/modal.js'
const DEFAULT_CONCEPTS =
'glasses, cat, dog, horse, goblin, cum, lactation, fellatio, xray, stomach bulge'
const store = useTagEvalStore()
const modal = useModalStore()
const run = ref(null)
const conceptsText = ref(DEFAULT_CONCEPTS)
const autoTopN = ref(0)
const precisionTarget = ref(0.97)
const busy = ref(false)
let pollTimer = null
const running = computed(() => run.value?.status === 'running')
const report = computed(() => (run.value?.status === 'ready' ? run.value.report : null))
const startedAgo = computed(() =>
run.value?.started_at ? formatTime(run.value.started_at) : '')
// Rehydrate the persisted run on mount so the report survives navigation — the
// task runs backend-side regardless; we just reconnect to its row.
onMounted(async () => {
try {
const latest = await store.latest()
if (latest) {
run.value = await store.getRun(latest.id)
if (run.value.status === 'running') startPoll(latest.id)
}
} catch { /* non-fatal — card still works for a fresh run */ }
})
onUnmounted(stopPoll)
function startPoll(id) {
stopPoll()
pollTimer = setInterval(async () => {
try {
run.value = await store.getRun(id)
if (run.value.status !== 'running') stopPoll()
} catch (e) {
stopPoll()
toast({ text: `Eval poll failed: ${e.message}`, type: 'error' })
}
}, 5000)
}
function stopPoll() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
}
async function onStart() {
busy.value = true
try {
const concepts = conceptsText.value.split(',').map(s => s.trim()).filter(Boolean)
const res = await store.start({
concepts,
auto_top_n: Number(autoTopN.value) || 0,
precision_target: Number(precisionTarget.value) || 0.97,
})
run.value = await store.getRun(res.run_id)
startPoll(res.run_id)
} catch (e) {
const msg = e.body?.running_id
? 'An eval is already running.'
: e.message
toast({ text: `Could not start eval: ${msg}`, type: 'error' })
} finally {
busy.value = false
}
}
function apDelta(c) { return (c.head?.ap ?? 0) - (c.centroid?.ap ?? 0) }
function formatTime(iso) {
if (!iso) return ''
try { return new Date(iso).toLocaleString() } catch { return iso }
}
// Acting on an example writes the SAME tables the head trains on, so a re-run
// reflects the correction. Keyed per (concept, list, image); the report ids are
// frozen at run time, so we just grey out what's been handled in this view.
const acted = ref({})
const actedKey = (c, dir, it) => `${c.tag_id}:${dir}:${it.id}`
const actedLabel = (c, dir, it) => acted.value[actedKey(c, dir, it)] || ''
async function act(c, it, dir, verdict) {
const key = actedKey(c, dir, it)
let call, label
if (dir === 'suggest' && verdict === 'yes') { call = store.applyTag(it.id, c.tag_id); label = 'tagged' }
else if (dir === 'suggest' && verdict === 'no') { call = store.rejectTag(it.id, c.tag_id); label = 'rejected' }
else if (dir === 'doubts' && verdict === 'no') { call = store.removeTag(it.id, c.tag_id); label = 'removed' }
else { call = store.confirmTag(it.id, c.tag_id); label = 'kept' } // doubt + yes = keep (confirm)
try {
await call
acted.value[key] = label
} catch (e) {
toast({ text: `Action failed: ${e.message}`, type: 'error' })
}
}
</script>
<style scoped>
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-cc {
padding: 12px 0;
border-top: 1px solid rgb(var(--v-theme-surface-light));
}
.fc-cc__head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 6px; }
.fc-cc__name { font-weight: 600; }
.fc-metrics { width: 100%; max-width: 360px; border-collapse: collapse; font-size: 13px; }
.fc-metrics th { text-align: right; font-weight: 600; color: rgb(var(--v-theme-on-surface-variant)); padding: 0 8px; }
.fc-metrics__lbl { text-align: left; }
.fc-num { text-align: right; font-variant-numeric: tabular-nums; padding: 1px 8px; }
.fc-win { color: rgb(var(--v-theme-accent)); font-weight: 600; }
.fc-up { color: rgb(var(--v-theme-success)); }
.fc-down { color: rgb(var(--v-theme-error)); }
.fc-curve { margin-bottom: 8px; }
.fc-curve__pt { margin-left: 10px; font-size: 13px; font-variant-numeric: tabular-nums; }
.fc-ex__row { margin-top: 8px; }
.fc-ex__thumbs { display: flex; flex-wrap: wrap; gap: 6px; }
.fc-ex__item { position: relative; width: 120px; height: 120px; }
.fc-ex__item--acted { opacity: 0.45; }
.fc-ex__thumb {
display: block; width: 100%; height: 100%; border-radius: 6px;
overflow: hidden; background: rgb(var(--v-theme-surface-light));
outline: 1px solid transparent; transition: outline-color 0.12s;
border: none; padding: 0; cursor: pointer;
}
.fc-ex__thumb:hover { outline-color: rgb(var(--v-theme-accent)); }
.fc-ex__thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.fc-ex__acts { position: absolute; top: 4px; right: 4px; display: flex; gap: 4px; }
.fc-act {
width: 26px; height: 26px; border-radius: 50%; border: none; cursor: pointer;
display: flex; align-items: center; justify-content: center; color: #fff;
opacity: 0.9; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); transition: transform 0.1s;
}
.fc-act:hover { opacity: 1; transform: scale(1.1); }
.fc-act--yes { background: rgb(var(--v-theme-success)); }
.fc-act--no { background: rgb(var(--v-theme-error)); }
.fc-ex__badge {
position: absolute; bottom: 4px; left: 4px; right: 4px; text-align: center;
font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em;
background: rgba(0, 0, 0, 0.65); color: #fff; border-radius: 3px; padding: 1px 0;
}
</style>
-57
View File
@@ -1,57 +0,0 @@
import { defineStore } from 'pinia'
import { useApi } from '../composables/useApi.js'
// Tag-eval (#1130): trigger + revisit the head-vs-centroid learning-curve eval.
// The run + full report live server-side (tag_eval_run), so the card rehydrates
// from getRun() on mount — the report survives navigation.
export const useTagEvalStore = defineStore('tagEval', () => {
const api = useApi()
async function start(params) {
return await api.post('/api/tag-eval', { body: { params } })
}
async function getRun(id) {
return await api.get(`/api/tag-eval/${id}`) // includes the full report
}
// The most recent run (light row, no report) — the card calls getRun() with
// its id to pull the persisted report on mount.
async function latest() {
const body = await api.get('/api/tag-eval', { params: { limit: 1 } })
return (body.runs && body.runs[0]) || null
}
// --- Acting on the head's example lists (closes the learn-from-tags loop).
// These write the SAME tables the head trains on: image_tag (positives) and
// tag_suggestion_rejection (negatives, via the dismiss endpoint).
// "Yes, it is this" — apply the tag (new positive).
async function applyTag(imageId, tagId) {
return await api.post(`/api/images/${imageId}/tags`,
{ body: { tag_id: tagId, source: 'manual' } })
}
// "No, it's not" on an UNtagged suggestion — record a rejection (hard negative).
async function rejectTag(imageId, tagId) {
return await api.post(`/api/images/${imageId}/suggestions/dismiss`,
{ body: { tag_id: tagId } })
}
// "Not it" on one of YOUR positives the head doubts — remove the tag AND
// record the rejection (kills the bad positive, leaves a hard negative).
async function removeTag(imageId, tagId) {
await api.delete(`/api/images/${imageId}/tags/${tagId}`)
return await api.post(`/api/images/${imageId}/suggestions/dismiss`,
{ body: { tag_id: tagId } })
}
// "Keep" — affirm a doubted positive is correct. Records a confirmation so it
// stops resurfacing in the doubts list (it stays a positive either way).
async function confirmTag(imageId, tagId) {
return await api.post(`/api/images/${imageId}/tags/${tagId}/confirm`)
}
return { start, getRun, latest, applyTag, rejectTag, removeTag, confirmTag }
})