DEV Community

Daniel Pertu
Daniel Pertu

Posted on

One human agreement counts the same as one matching machine read

I wrote earlier about how Munchable accepts crowdsourced label data with no moderation queue. Trust came from consensus: two independent captures of the same panel that agree at 0.85 trigram similarity promote a row from unverified to consensus. That is the whole trust ladder, and it had a gap. The only way up was a second photograph, and the second person to scan a barcode almost never takes one. They already have an ingredient list on screen. Why would they?

So the result screen now asks them a question, and the answer is worth exactly as much as a second matching read.

The rule

/**
 * What a recorded "Looks right" does to a community row. Promote, never
 * demote, and only from `unverified`: one distinct match from someone other
 * than the contributor is a second person who held the pack and read the same
 * words, which is the same standing a second matching machine read earns.
 * A flagged row stays flagged, because a report is a stronger signal than an
 * agreement and the fresh capture it asks for is the way through; a withheld
 * row is never served, so nobody can match it; consensus is already there.
 */
export function nextStatusAfterMatch(current: string, distinctMatches: number): CatalogStatus {
  if (current === 'unverified' && distinctMatches >= 1) return 'consensus';
  return current as CatalogStatus;
}
Enter fullscreen mode Exit fullscreen mode

There is no vote count. One distinct match promotes, because the standard it is matched to, a second machine read, is also one event. Making a human agreement worth less than a photograph would say that a person holding the pack and reading the words is weaker evidence than OCR, which is backwards.

The constraints do the work. The match must come from someone other than the contributor, and a flagged row cannot be promoted by agreement, because a report outranks it. Agreement cannot launder a complaint.

The contributor cannot be asked, and the server cannot tell

The product lookup is condition-blind and cached per barcode for everyone, so the server response cannot say "this is your own capture". The app works it out on the device: when a contribution is accepted the resolved product is marked mine, and that mark is carried across later lookups of the same barcode only while the server is still serving the same text. A different read means someone else has replaced the revision, and the question is theirs to be asked. The server enforces the same rule independently when a match arrives, comparing the revision's contributor id against the caller, so the client flag is a courtesy and not the boundary.

const asksToMatch =
  isCommunity &&
  entry.status === 'unverified' &&
  !entry.mine &&
  !isSample &&
  isApiConfigured &&
  !matchAnswered;
Enter fullscreen mode Exit fullscreen mode

Both answers, together, never a dead end

The card sits directly under the ingredients text, so the eye goes list, pack, answer. The heading is "Does this match the pack in your hand?" and there are two equal buttons: "Looks right", and "Doesn't look right", which records a report and goes straight back to the camera.

That pairing is the product decision. A version of this existed in the very first schema as a lone "yes" bar, and it was removed as a dead end because it had no "no". A question the user can only answer one way is a confirmation screen, and the earlier post covered why those transfer blame rather than add safety. Two answers with two different consequences is a question.

The server side is as calm as the UI:

Nothing here is ever an error the app needs to explain; a match that changes nothing still answers 200 with the row's status, and recorded: false tells the screen to say thanks and put the question away.

A duplicate, a match on your own row, a match on a row that has since been replaced: all 200, all "thanks", all with a discriminator the app can log. After answering, the provenance line under the ingredients changes at once to "Read from the label by the Munchable community, matched by a second person", because the app rewrites its cached entry rather than waiting for a refetch.

The migration that had to be reordered by hand

The feedback table's primary key was (barcode, user_id). A user can now both report and match the same barcode over time, so the key gained the new kind column. The generated migration added the constraint before the column existed:

-- Reordered by hand from drizzle-kit's output: the new primary key names the
-- `kind` column, so the column has to exist before the constraint that uses it.
ALTER TABLE "catalog"."product_feedback" ADD COLUMN "kind" text DEFAULT 'report' NOT NULL;
ALTER TABLE "catalog"."product_feedback" DROP CONSTRAINT "product_feedback_barcode_user_id_pk";
ALTER TABLE "catalog"."product_feedback" ADD CONSTRAINT "product_feedback_barcode_user_id_kind_pk" PRIMARY KEY("barcode","user_id","kind");
Enter fullscreen mode Exit fullscreen mode

Both kinds of feedback are cleared when a fresh capture replaces the revision, because agreement or disagreement with words that are no longer on the row is not information about the new words.

Try it

Sign in and scan a product whose provenance line says it was read from the label by the community and not yet checked. The card appears under the ingredients. If you are the person who captured it, it does not.

Top comments (0)