feat(ml): clamp allowlist min_confidence to the tagger store floor
CI / integration (push) Successful in 3m12s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 27s

Consumer #4 of the store-floor change (#764). An allowlist tag can't
auto-apply more permissively than the ingest floor — predictions below
tagger_store_floor aren't stored, so a lower min_confidence behaves
identically to the floor. update_threshold now clamps to max(value, floor);
the AllowlistTable confidence input min-binds to the live floor and clamps
on edit. Keeps the stored threshold honest about actual apply behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 13:52:20 -04:00
parent 3f92669f12
commit c8b815afe6
3 changed files with 42 additions and 6 deletions
+15 -2
View File
@@ -9,7 +9,7 @@ from sqlalchemy import delete, select
from sqlalchemy.dialects.postgresql import insert from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ...models import Tag, TagAllowlist, TagSuggestionRejection from ...models import MLSettings, Tag, TagAllowlist, TagSuggestionRejection
from ...models.tag import image_tag from ...models.tag import image_tag
from .aliases import AliasService from .aliases import AliasService
@@ -91,12 +91,25 @@ class AllowlistService:
) )
await self.dismiss(image_id, tag_id) await self.dismiss(image_id, tag_id)
async def _store_floor(self) -> float:
return (
await self.session.execute(
select(MLSettings.tagger_store_floor).where(MLSettings.id == 1)
)
).scalar_one()
async def update_threshold( async def update_threshold(
self, tag_id: int, min_confidence: float self, tag_id: int, min_confidence: float
) -> None: ) -> None:
row = await self.session.get(TagAllowlist, tag_id) row = await self.session.get(TagAllowlist, tag_id)
if row is not None: if row is not None:
row.min_confidence = min_confidence # An allowlist tag can't auto-apply more permissively than the
# ingest store floor — predictions below tagger_store_floor aren't
# stored, so a lower min_confidence would behave identically to the
# floor. Clamp so the stored threshold matches actual behavior
# (#764).
floor = await self._store_floor()
row.min_confidence = max(min_confidence, floor)
async def remove(self, tag_id: int) -> None: async def remove(self, tag_id: int) -> None:
await self.session.execute( await self.session.execute(
@@ -10,7 +10,7 @@
<v-text-field <v-text-field
:model-value="item.min_confidence" type="number" :model-value="item.min_confidence" type="number"
density="compact" hide-details style="max-width: 100px;" density="compact" hide-details style="max-width: 100px;"
min="0.05" max="1" step="0.05" :min="floor" max="1" step="0.05"
@update:model-value="(v) => onThreshold(item.tag_id, v)" @update:model-value="(v) => onThreshold(item.tag_id, v)"
/> />
</template> </template>
@@ -25,23 +25,32 @@
</template> </template>
<script setup> <script setup>
import { onMounted } from 'vue' import { computed, onMounted } from 'vue'
import { useAllowlistStore } from '../../stores/allowlist.js' import { useAllowlistStore } from '../../stores/allowlist.js'
import { useMLStore } from '../../stores/ml.js'
const store = useAllowlistStore() const store = useAllowlistStore()
const ml = useMLStore()
// min_confidence can't be set below the tagger store floor — predictions
// below it aren't stored, so a lower threshold would behave identically to
// the floor. The backend clamps too (#764).
const floor = computed(() => ml.settings?.tagger_store_floor ?? 0.70)
const headers = [ const headers = [
{ title: 'Tag', key: 'tag_name', sortable: true }, { title: 'Tag', key: 'tag_name', sortable: true },
{ title: 'Kind', key: 'tag_kind', sortable: true, width: 110 }, { title: 'Kind', key: 'tag_kind', sortable: true, width: 110 },
{ title: 'Min confidence', key: 'min_confidence', sortable: false, width: 140 }, { title: 'Min confidence', key: 'min_confidence', sortable: false, width: 140 },
{ title: '', key: 'actions', sortable: false, width: 60 } { title: '', key: 'actions', sortable: false, width: 60 }
] ]
onMounted(() => store.load()) onMounted(() => {
store.load()
if (!ml.settings) ml.loadSettings()
})
let debounce = null let debounce = null
function onThreshold(tagId, value) { function onThreshold(tagId, value) {
if (debounce) clearTimeout(debounce) if (debounce) clearTimeout(debounce)
debounce = setTimeout(() => { debounce = setTimeout(() => {
const v = parseFloat(value) const v = Math.max(parseFloat(value), floor.value)
if (v > 0 && v <= 1) store.updateThreshold(tagId, v) if (v > 0 && v <= 1) store.updateThreshold(tagId, v)
}, 500) }, 500)
} }
+14
View File
@@ -104,3 +104,17 @@ async def test_update_threshold_and_remove(db):
assert abs(row.min_confidence - 0.80) < 1e-6 assert abs(row.min_confidence - 0.80) < 1e-6
await svc.remove(tag.id) await svc.remove(tag.id)
assert await db.get(TagAllowlist, tag.id) is None assert await db.get(TagAllowlist, tag.id) is None
@pytest.mark.asyncio
async def test_update_threshold_clamped_to_store_floor(db):
# A min_confidence below the store floor (default 0.70) is clamped up —
# predictions below the floor aren't stored, so a lower threshold can't
# apply more permissively than the floor (#764).
tag = await TagService(db).find_or_create("Lowthr", TagKind.general)
svc = AllowlistService(db)
img = await _make_image(db)
await svc.accept(img.id, tag.id)
await svc.update_threshold(tag.id, 0.30)
row = await db.get(TagAllowlist, tag.id)
assert abs(row.min_confidence - 0.70) < 1e-6