Turning subjective description into reliable identification.
A pattern-recognition system that converts how a person describes something into a numeric attribute vector, matches it against a structured catalogue, and explains the result. Built, tested, and shipped by us.
This is not a reference architecture. Every system described below is implemented in a production codebase we own and maintain — ScentID, our own consumer product on Google Play, where the identification problem in this study is the core interaction. Each figure here was read out of that repository at the time of writing rather than estimated: 500 catalogue records, 11 attribute dimensions, 336 automated tests across 23 suites, all passing.
The problem
Most product search assumes the user already knows the name of the thing they are looking for. A large class of real problems does not work that way. The user has an impression, a set of qualities, a description in their own words — and no vocabulary that maps cleanly onto a catalogue.
Bridging that gap is a pattern-recognition problem, not a search problem. The system has to accept imprecise, subjective input, place it in the same space as structured product data, and return a ranked answer it can defend.
We had a domain where this is unusually acute, so we built the system there first. In ScentID, a user describes a fragrance the way people actually describe one — warm, a bit smoky, something citrus at the start — and the product has to name it. There is no barcode, no model number, and no shared vocabulary between what the user says and how the catalogue is written.
What we built
A layered system, each layer usable on its own:
1. Identification engine
Unstructured human description is captured as intensities across eleven attribute dimensions — in this domain, note families such as citrus, woody, spicy, amber, oud — and reduced to a numeric vector. That vector is scored by cosine similarity against every record in a structured catalogue of 500 products, ranked, and returned with a confidence figure banded into strong, moderate, and weak so the interface never presents a 71% match with the same certainty as a 96% one.
It runs entirely on the device, in the React Native client, with no server round-trip. That is a deliberate architectural choice rather than an optimisation: identification is deterministic, works with no connection, returns in a single frame, and costs nothing per query no matter how heavily it is used. For the interaction a user invokes constantly, per-query inference cost is the difference between a feature that scales and one whose unit economics quietly break as it succeeds.
A guard that matters more than the algorithm. Cosine similarity against an all-zero vector scores zero against every record, which leaves the catalogue in its original order and hands back whichever product happens to sort first — presented as a match, with a score attached. The engine returns nothing when the input carries no signal, and the behaviour is pinned by a test that asserts null for an empty profile rather than an arbitrary first row. A confident-looking wrong answer is worse than no answer, and it is the failure mode users never report, because on screen it looks exactly like the system working.
2. Recommendation
The same similarity function is reused to derive a preference profile from what a user has already collected, then score the catalogue against it. Deliberately the same function, imported from the same module: two independent notions of what similar means will eventually disagree, and that divergence is when recommendations start feeling arbitrary to the person receiving them.
Three rules govern output, each with a test behind it. It will not suggest anything until a collection reaches a minimum size, because a profile derived from one item is not a profile. It never recommends something the user already owns — returning someone their own library is the fastest way to make the feature look broken. And it returns an empty list rather than padding with weak matches, because a bad suggestion spends user trust to fill a row.
Every recommendation explains itself in terms of what is familiar and what is new: “Shares your woody, adds amber.” An early version named only shared attributes. It passed its tests and read uselessly — where a collection is dominated by one note family, every suggestion shares it, so every explanation printed the same sentence. We changed the test to assert that two different suggestions produce two different reasons, which is the property that actually mattered and the one the original test never checked.
3. Aggregate profiling
Collections are summarised into plain-language characteristics with evidence thresholds attached. The system will not name a dominant category unless it occurs at least twice and strictly leads the runner-up, and it will not characterise a collection at all below a minimum sample size.
Those thresholds exist because the alternative is flattery. Given four items each belonging to a different category, the leading category is whichever happens to sort first — and an interface that announces it anyway is generating confident noise about the user's own taste, which is precisely the claim they are best equipped to notice is wrong.
4. Model integration behind a server boundary
Where a large language model earns its cost — open-ended analysis rather than matching — it is integrated behind a server function rather than called from the client. Ours is built as a Supabase Edge Function in Deno, calling Claude with a JSON schema for structured output. That boundary does three things at once:
- The API key never ships. Anything inside a client bundle can be extracted from an APK in minutes, and a leaked provider key is an unbounded liability. Only the server holds it.
- Authorisation is verified where it cannot be edited. The client knows what it believes it is entitled to; only the server knows what is true. Entitlement is checked server-side before a paid call is made, not in the interface that renders the button.
- Responses are typed, not parsed. A structured output schema means the interface never has to defend against prose leaking into a field it is about to render, and a malformed response fails at the boundary instead of halfway down a screen.
5. Cost control as an architectural concern
Model calls cost real money per request, which makes spend a design problem rather than an operational afterthought. Our function enforces three independent ceilings before it will call the provider: a per-user call limit, a per-user monthly spend limit, and a global budget backstop.
The middle one is the one that is usually missing, and it is the one that matters. A single shared budget means the first heavy user exhausts it and the capability goes dark for everyone else — a fairness and availability failure wearing the costume of a cost control. Spend is recorded per user in Postgres from the token counts the provider actually returns on each response, not estimated from request size, so the ledger reconciles against the invoice instead of approximating it.
6. Entitlement and billing infrastructure
Access rights resolve to a single authoritative row read by both the client and the server, so the two can never disagree about what a user is owed. Provider integration — RevenueCat over Google Play Billing, in our case — sits behind a one-file boundary, which keeps the choice of payment provider a reversible decision rather than a commitment spread through the codebase.
Webhook delivery is not ordered and providers retry on failure, so a stale expiration event can arrive after a fresh renewal and revoke access the user just paid for. Entitlement writes carry the originating event timestamp and refuse anything older, enforced in the SQL predicate of the update itself rather than in application logic — so two concurrent deliveries cannot both read, both decide they are newer, and both write. Shared secrets are compared in constant time.
How we work
Correctness is measured, not asserted
The codebase carries 336 automated tests across 23 suites, on Jest with React Native Testing Library, weighted toward pure functions where behaviour can be pinned exactly, plus render tests for the parts that break invisibly. Several are static analyses that read the source and fail on architectural violations rather than on wrong output — the class of mistake that survives code review because each individual instance looks reasonable in isolation.
One of them exists because a bulk edit failed silently. We had split one brand colour into a foreground value and a darker filled-surface value, then swept the codebase to move filled buttons onto the darker one. The sweep was line-based; three components wrote the property and the value on separate lines, kept white text on the lighter colour at 3.35:1, and passed every existing test. The replacement test reads the source across line breaks, allows the two indicator cases that legitimately need it with a written reason each, and includes a third assertion that the pattern still matches a known violation — so the check cannot rot into one that silently matches nothing.
Accessibility is enforced, not reviewed
Colour contrast is asserted against WCAG 2.1 AA by automated test, and separately audited against the live rendered interface, because a palette can be entirely correct while a screen still pairs two valid values badly. The design system separates colours used as foreground from colours used as filled surfaces — one value cannot do both jobs against a dark ground, and collapsing them is the root of most contrast failures we find.
Verify the verifier. Our first contrast auditor reported a clean interface for an entire working session. Its filter for skipping emoji used a character range that, read literally, spanned the Latin alphabet — so it skipped every string containing a letter and examined nothing. We found it only because a unit test disagreed with it. Every checking tool we build now ships with a self-test that proves it still detects a known-bad input, because a check that cannot fail is worse than no check: it manufactures confidence, and it does so quietly.
Performance is measured on the constraint that binds
Interface work targets what actually costs the device rather than what is easy to measure: virtualised lists that mount what is visible instead of what exists, an indexed map replacing linear scans over the 500-record catalogue, and memoised derivation so filtering does not re-run the whole pipeline on every keystroke.
Shipping is part of engineering
We carry work through to release rather than handing it over at feature-complete. On this product that meant cloud build pipelines for both Android and iOS, code shrinking and obfuscation through R8, consent management and tracking-transparency compliance, subscription and advertising infrastructure, store listings, and staged release — each of which is a place a technically finished product can fail to reach anyone.
What it is built on
React Native and Expo for the client. Supabase for authentication, Postgres with row-level security, and Edge Functions in Deno for the server boundary. Claude for the model layer, behind a structured-output schema. RevenueCat over Google Play Billing for subscriptions. Jest and React Native Testing Library for the suite. EAS for cloud builds and store submission.
The through-line
The recurring decision in this work is what the system should do when it does not know. It is the difference between a match and a guess dressed as a match, between a recommendation and a filled row, between a profile and flattery, between a passing test and a test that examines nothing.
Systems that answer that question well get trusted. Systems that always produce an answer do not, and users work out which is which considerably faster than teams expect.
See it running
The identification engine, the recommendation layer, and the profiling described here are the working parts of ScentID, available on Google Play and at scentidapp.com. We built it as our own product, which is why this study can quote real thresholds and real failures rather than a sanitised architecture diagram — and why the same team is available to build the equivalent for a catalogue of yours.
The next generation of software will be intelligent.
ScentID Solutions is building toward that future — one platform, one integration, and one intelligent solution at a time.
Work With Us