~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2

grain-serve scores what arrives with the classifiers, on Clef

Merged
Merged chadtmiller.com asked to merge into feature/rust from grain.social/grain:classify/clef

What changes

What arrives is scored, off every request's path. The three live paths that put records (the Jetstream, indexRecords and the web's writes) say which record was new to the index (Index::arrived), into a bounded queue (10,000) that classify::Classify drains, four subjects at a time. A record is scored by the classifiers of its collection: gallery-spam (galleries) and nsfw (photos). Its author is scored by the account classifiers, impersonation, scam and spam, once they have written nothing for a minute, so an import of hundreds of photos asks about the account once. Backfill and the import do not say what they put: nothing that existed before is scored. A subject that fails is tried again with backoff, five times, then dropped with a warning.

Scoring builds the state each classifier shows Clef (the questions, thresholds, states and gates are the old server's, copied), skips it when there is none (a gallery with under 8 characters of text, an account with under 5 galleries for spam) or when its fingerprint (classifier version and state, SHA-256) matches the last one scored, and otherwise asks Clef and keeps the scores in classification (migration V10). A signal at or past its threshold files a report, by system:<classifier>, asking for the label the old server asked for (spam, impersonation, nudity, sexual, other), unless a report on that subject asking for that label is already open. Nothing else acts on a score: a person decides.

Clef (classify::clef) is Workers AI's @cf/cloudflare/clef, posted {state, model, questions, images?} with a bearer token. 408, 429, 5xx and connection failures are tried again (4 tries, from 500 ms, doubling, or after retry-after); anything else is final, and success: false is an error. Every answer type is made a 0..1 score, as before.

Images are the CDN's feed_thumbnail of the photo's blob, fetched by grain-serve, made at most 512 px on the longer side and JPEG (q80), and sent inline, at most four a request.

Child safety. Nothing of an account is shown to a model when any label, self-label or report, negated or resolved since or not, ever said csae about the account or any of its records (Index::may_classify). Taken-down accounts and those gone from their host are not scored either.

Bridged accounts' posts are labeled spam as they arrive (galleries, stories, comments), by grain's own labeler (the service DID from GRAIN_PUBLIC_URL; without one, none), when the handle ends .brid.gy, the profile carries Bridgy Fed's footer, or else the PDS the DID document names is Bridgy Fed's (an account new to the index has no handle there yet, and its profile may come after its first post). A post that ever had a spam label, negated since or not, is left alone, so a moderator who took one off is not overruled. This asks no model and runs without Clef.

Why

The classifier step of the plan. The design keeps the old server's questions and thresholds, which were tuned on clef, and its rule that a classifier only files reports. The settle delay replaces scoring an account on every one of its records.

Differences from the old server:

  • Only arrivals on live paths are scored. The old server scored what any indexing path applied, backfill included, if its record key's time was past a classifiers:since cursor, and ran a catch-up scan over everything new since then at startup, for what a restart dropped from its queue. Here backfill and the import say nothing, and a restart drops what was queued. The stream replays from its cursor, so what it brings after a restart still arrives; accounts on atproto.brid.gy are not backfilled at all, so their posts come by the stream.
  • Bridged accounts: the old server ran bridged as an account classifier that labeled every unlabeled post of the account on each scan, including what backfill wrote. Here each post is labeled as it arrives.
  • Images: there is no 512 px imgproxy preset (IMGPROXY_ONLY_PRESETS is on, and the presets are avatar, feed_thumbnail, feed_fullsize and their _og twins), so the thumbnail is shrunk in-process with image rather than asked of imgproxy at 512.
  • The child-safety gate is new: the old server would send a photo of an account already reported for csae to Clef.
  • Bridged labels are given by the service DID; the old server gave them as its own labeler, the same DID in production.
  • classification is not imported: it is re-derivable by scoring again (at Clef's cost), and the old reports, which are not, are already carried by the import's _reports.

Env vars: CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_AI_TOKEN (both, or no Clef), GRAIN_CLEF_MODEL (default clef), GRAIN_CLEF_URL (default Workers AI's URL for the account and model; tests point it elsewhere). CLOUDFLARE_API_TOKEN is never read. The token is never logged or in an error (Config's Debug leaves it out).

How it was checked

tests/classify.rs runs against a fake Clef that records each request and a CDN serving large PNGs: a photo past its threshold files one report with the right label, reason, reporter and a 512 px JPEG inline; the same state is not asked again, and a changed one is but files no second report; under-threshold scores file nothing; nothing-to-judge, deleted records and no Clef ask nothing; each child-safety case (account label, negated label on another record, report on the account, report on a record, self-label) asks nothing and fetches no image, while another account's or another label's stops nothing; taken-down and deleted accounts are skipped; Clef retries (503 with retry-after, 429), final 400, success: false, giving up, unwrapped results, nobody listening; arrivals end to end, the account asked about once; a failed subject tried again, and dropped after its attempts; bridged labeling by handle and by footer, and by DID document for an account with no handle (one whose document fails once is tried again, one that cannot be read is left alone after its tries, and one that never answers holds up nothing), not for photos or other accounts, and not over a negated label. Unit tests cover thresholds, labels, answer scoring, env parsing and shrinking. Build, clippy, the coverage gate and the rest are left to CI.

Shortcomings

  • Not run against the real Clef from here; the request shape follows the old server's client.
  • A brand-new photo of child sexual abuse, from an account no one has reported or labeled yet, cannot be known before a person sees it, so nsfw could send it. The gate stops everything once anyone flags it. Gating harder would mean not scanning photos at all.
  • The arrival hook (Index::arrived) is the push sender's: it now tells the classifiers (Index::classify_to, a sender of their own) and then the sender. Each has its own queue, so one that falls behind drops only its own arrivals.
  • When admin/moderation lands, report_once and may_classify should move onto its file_report and child_safety, and its migration renumbers after V10 (or this one after it).
  • Queue overflow drops arrivals with a warning, not a retry.
  • The bridged check reads a DID document (kept five minutes) for each gallery, story or comment whose account has no bridged handle or footer, in a task of its own, tried again as scoring is (five times, with backoff); a post whose document cannot be read by then is left unlabeled, with a warning.
  • A child-safety report or label that lands while a request about the same account is already with Clef does not stop that request.

Review

The gate in index/classify.rs (may_classify) and Classify::score in classify/mod.rs are where to start.

🤖 Generated with Claude Code

chadtmiller.com added 1 commit
Approved

This pull scores new records and their authors with the Clef classifiers off the request path. It files reports only, and labels bridged accounts' posts spam without a model.

I read index/classify.rs and classify/mod.rs in full, and the call sites of Index::arrived in the Jetstream, indexRecords and web-write paths. I did not read the whole diff, which was too large to open here. That leaves clef.rs, classifiers.rs, the V9 migration, the env parsing and the tests unreviewed. I did not check whether CI passed.

  • Arrivals: all three paths call arrived only on Applied::Created, so updates and the web write followed by Jetstream don't score twice.
  • Child-safety gate: may_classify is checked at the start of every score, before any image fetch, and covers labels, self-labels and reports on the account and its records. Negated or resolved entries still block, as the description says.
  • Retries: the semaphore permit is released during backoff, so a failing subject doesn't hold up the other three scoring slots.
  • report_once and label_once: each is a single INSERT … WHERE NOT EXISTS, so neither check-then-insert can race.

Two small things, neither blocking:

  • A CSAE report or label that lands while a request is already in flight can't stop that request.
  • score panics through expect("a collection classified") if a covered collection has no table. This is safe for the current collections, but it runs in a spawned task, so the panic would only kill that one task.
chadtmiller.com added 1 commit
Asked for changes

The new commit makes the bridged check fall back to the PDS in the account's DID document. That fixes the new-account case, but the fallback runs in the wrong place.

Blocking: bridged() awaits a network fetch inside the queue's drain loop.

  • In run, self.bridged(&uri, did).await runs once per gallery, story or comment, one record at a time, before the next arrival is read.
  • For every author the handle and profile check doesn't flag, which is nearly all of them, it now calls kept_document. That is a PLC or did:web request unless the DID was read within the last 5 minutes.
  • ctx.http has a 60 s timeout, so a slow or failing PLC directory can hold up all of it, including the scoring dispatch below it, for up to a minute per record. The 10,000-slot queue then fills and arrivals are dropped, which is the overflow path the description says is not retried.
  • Before this commit that call was a local SQLite read, so this is a new cost on the hot loop.
  • Move the labeling into a spawned task, as queue does for scoring. Alternatively, resolve the DID only when the cheap check fails and do it off the loop.

Related: a failed document fetch is never retried. bridged returns the error, the loop logs a warning, and that bridged post is left unlabeled. queue's backoff doesn't cover this path. If you spawn it, run it under the same retry.

The test covers the hosted, unknown and non-bridged cases. It doesn't cover a slow or hanging directory holding up later arrivals, which is the behaviour to pin once this is fixed.

I didn't check CI. All four runs for this commit were still queued.

The two non-blocking notes from round 1 are unchanged.

crates/grain-serve/src/classify/mod.rsline 134Outdated
continue;
};
if BRIDGED_HIDDEN.contains(&collection)
&& let Err(e) = self.bridged(&uri, did).await
review.knotless.dev

This awaits a possible PLC fetch (up to the 60 s client timeout) inside the single drain loop, so one slow lookup delays every later arrival and the scoring dispatch. Spawn it, with retry, instead of awaiting it here.

crates/grain-serve/src/classify/mod.rsline 214Outdated
};
let bridged = match self.index.read(|db| classifiers::bridged(db, did))? {
true => true,
false => {
review.knotless.dev

Most authors are not bridged, so almost every post now reaches kept_document. A fetch error here only logs a warning and the post is never labeled.

chadtmiller.com added 1 commit

ba2916a moves the bridged check off the drain loop: each gallery, story or comment gets its own task, run under the same retry as scoring (tried, shared by both). The test adds a DID whose document never answers, posted first, and a DID whose document fails once: everything after the hanging one is labeled, and the flaky one is labeled on its second try. The non-blocking notes: the in-flight CSAE race is now listed under Shortcomings. The expect stays, because every collection in ALL has a table, and the unit tests pin covers.

Approved

The new commit does what round 2 asked. run now calls bridged, which returns at once and spawns a task. That task runs label_bridged under the same tried backoff that scoring uses. A slow or hanging DID directory no longer holds up the drain loop or the scoring dispatch below it. A failed document fetch is retried, and only warns after the attempts run out. The check order is unchanged: the local handle and profile read first, then the DID document, then label_once, which is a single idempotent insert.

I read classify/mod.rs around the change. I did not re-read the tests or check CI.

Non-blocking: nothing limits how many bridged tasks run at once. In a burst of galleries, stories or comments from accounts not yet cached, each spawns its own DID document fetch. kept_document caches for 5 minutes, but concurrent misses for the same DID could each fetch. The ctx.http timeout bounds each task, so this is not a hang. A small semaphore or per-DID dedup would be tidier if it shows up in practice.

The two notes from round 1 are unchanged.

chadtmiller.com added 1 commit
Approved

Carried forward: the change this pull makes at 4271ae5bb3eea4d07f78f253ef7f890b974dd3a5 is the one reviewed at ba2916a2bc652fa1dd2b2b548e60dd6a16c3580b, so that review stands. What is new comes from its base.

chadtmiller.com added 1 commit
Approved

The new commit only merges feature/rust into the branch. The push sender took V9 and Index::arrived, so this pull's migration is renumbered to V10. arrived now tells the classifiers through their own sender, classify_to, and then the push sender. Each sender has its own queue, so one that falls behind drops only its own arrivals.

I checked that MIGRATIONS lists V1 to V10 in order, and that classify_to and the classifying OnceLock are wired as described. I did not re-read the rest of the merge or look at CI.

The review I carried forward from the last round still stands, as do its non-blocking notes: bridged tasks are not limited in number, and a csae flag that lands while a request is already with Clef does not stop that request.

Approved

The new commit only merges feature/rust into the branch. What it brings in is the separate OpenGraph cards change (og/, fonts, tests/og.rs), which doesn't touch the classifier code. I didn't re-read the merge beyond the log, and I didn't verify that it merges without semantic conflicts. All four CI runs for this commit were still queued, so CI has not confirmed it.

The earlier approval stands, along with its non-blocking notes: bridged tasks are not limited in number, and a csae flag that lands while a request is already with Clef does not stop that request.

Merged as ca043b0
Part of a stack of 2
  1. grain-serve scores what arrives with the classifiers, on Clef Landed as ca043b0
  2. main