Skip to content

How Dino Discover Works

A glossary won’t do this section justice. The ideas in Dino Discover hang together — once you understand how a quiz becomes a recommendation, the admin UI and the REST API are both easier to read.

TermWhat it means
QuizA named question flow with a slug, one or more variants, and a publish history. The thing a merchant edits.
QuestionOne step in the flow. Has a prompt, a type, and one or more answers.
AnswerOne choice on a question. Carries an upvote mapping that weights products. Carries a routing pill that says what question comes next.
VariantA version of the flow used for A/B testing. Each variant has its own routing graph and its own upvote weights.
SnapshotAn immutable, hashed serialisation of a published quiz. The storefront only ever reads snapshots.
SessionA single shopper’s run through a quiz. Carries answer history, the variant they were assigned, and the recommendation they were given.
LeadAn email captured during a session, with consent and routing metadata.

A quiz is the unit a merchant edits. A session is the unit a shopper experiences. A snapshot is what connects them safely.

Almost everything Dino Discover does runs through one of four engine interfaces in includes/Engine/:

Decides what question to ask next.

The default — RulesQuestionEngine — reads the answer history, looks up the routing pill on the answer the shopper just chose, and returns the next node decision (continue, end, jump to a specific question). Every decision passes through the filter dino_discover_next_question, so a plugin can override the choice per request.

Swap it via dino_discover_question_engine to use, for example, a learned model that picks the most informative next question based on the current answer history rather than following a static graph.

Turns an answer history into a ranked list of products.

The default — UpvoteRecommendationEngine — sums each answer’s upvote weights, applies eligibility filters (in-stock, purchasable, variant matching, optional taxonomy gates), and returns a ranked RecommendationSet. The final result list passes through dino_discover_recommendation_results, so you can suppress, re-rank, or inject items at the last second.

Swap it via dino_discover_recommendation_engine to substitute an embedding-based or LLM-based ranker; the storefront and REST API don’t change.

Measures whether the question flow can actually reach every product in the catalogue.

The default — CoverageCalculator — walks every possible path through the published snapshot, computes the maximum upvote score each product could achieve, and writes the per-product result to a coverage table. The result powers the Catalogue Coverage admin page: a scatter chart of reachability, plus a “needs attention” list of products that the current flow can’t reach.

Coverage runs nightly on a schedule, on every publish (debounced), and when you click Refresh now.

Computes confidence intervals and sample-size verdicts for A/B variants.

The default — PointEstimateStatistics — returns a point estimate with the CI bounds null. The interface is in place for a v2 backend that returns proper intervals; the v1 default is the placeholder.

The analytics surface (EventAggregator, SankeyBuilder, AttributionLinker) sits alongside these four — see Engine Interfaces for the longer story.

  1. The shopper opens a page that hosts the quiz. Dino Discover injects only a small bootstrap — variant-agnostic, year-cacheable.
  2. The shopper interacts with the bootstrap. The full storefront bundle loads (built against preact/compat to stay small).
  3. The bundle calls GET /wp-json/dino-discover/v1/storefront/quizzes/{slug}/bootstrap. That endpoint validates published_snapshot_id IS NOT NULL and returns the snapshot. If the quiz isn’t published, it returns 404.
  4. The bundle calls POST /storefront/sessions to start a session. The variant assignor weighted-hashes the shopper into one of the active variants and returns a session bearer token.
  5. The bundle renders the first question. Each answer fires a POST /storefront/sessions/{sid}/events to record the interaction — batched, async, off the request path.
  6. After the last question, POST /storefront/sessions/{sid}/recommend runs the recommendation engine and returns the ranked products.
  7. (Optional) The shopper enters an email; POST /storefront/sessions/{sid}/lead stores it and pushes it to the configured integrations asynchronously.

The storefront never sees a draft. The whole pipeline runs against the immutable snapshot the merchant published.

How a merchant edit becomes a published snapshot

Section titled “How a merchant edit becomes a published snapshot”
  1. The Builder makes a PATCH /quizzes/{id} with the edit and the expected_draft_version it last saw. If the draft has moved on, the patch returns 409 — the merchant clicks Refresh and sees the newer state.
  2. Edits accumulate in the draft tables. The Builder shows an “unsaved changes” banner; the storefront sees none of them.
  3. When the merchant clicks Publish, the Builder calls POST /quizzes/{id}/publish with the expected draft version. The publisher validates the draft, serialises it, hashes it, writes one dd_quiz_snapshots row, and atomically flips dd_quizzes.published_snapshot_id to the new row.
  4. The dino_discover_quiz_published action fires.

That’s it. There’s no half-published state, and there’s no second endpoint that ever reads from drafts.

The published-only invariant. The storefront route only reads published snapshots. This is enforced in code by a custom PHPStan rule (PublishedSnapshotOnlyRule) — if a contributor accidentally adds a storefront endpoint that reads drafts, the static analyzer fails the build.

Optimistic locking. Every PATCH and every publish carries an expected_draft_version. If two people are editing the same quiz, the second writer hits 409 and reloads instead of clobbering the first writer’s edits.