All documentation

Fan-out

What each engine actually searched for behind your prompt, and the eight categories those searches fall into.

The fan-out panel on a prompt's detail page, listing the searches one engine ran before answering, each labelled with a category such as reformulation or comparative, and the pages those searches returned with competitor brands badged

What this capture is

When an engine expands your prompt into its own sub-queries before composing an answer, that expansion is called fan-out; see Engines and measurement for what it means and which engines do it. This page covers the panel underneath it, on a prompt's own page: what DiscoveredBy actually captures about those sub-queries, the eight categories each one can be filed under, and what it means when a run gives us less than the full picture, including when it gives us nothing at all.

Why the category lives in a table of its own

Each query an engine searched for is written once, at collection, as its own row. What that query was about is not a column on it. It is a separate table, fan_out_classifications, joined back to the query it describes (api/models/fan_out_classification.py). The model's own docstring explains why: the query table already has the property that matters, written once and never updated, and keeping the category off it means a bug in classification can never corrupt the record of what the engine actually searched for. A future classifier version writes its own row beside an older one instead of overwriting it. The category is decoration on a list a human reads; nothing in the product computes a score, a metric or a threshold from it.

The eight categories, and the order they are judged in

The model is given the parent prompt and one query at a time, and told to take the FIRST category that applies, checked in this fixed order, because that is what makes the eight mutually exclusive (CATEGORY_DEFINITIONS, api/services/fan_out_classifier.py):

  1. shopping: seeks a purchase artifact: price, cost, deal, coupon, or where to buy.
  2. comparative: sets named things against each other or asks for a ranking, such as vs, alternatives to, or best X.
  3. entity_expansion: introduces a named brand, product or company that is absent from the parent prompt.
  4. personalized: adds a constraint about the asker that is absent from the parent prompt, such as a place, a year or a segment.
  5. reformulation: restates the parent prompt with no new constraint.
  6. implicit: answers a sub-question the parent prompt assumes but does not ask.
  7. related: topically adjacent, neither a restatement nor an implied sub-question.
  8. synthetic: not a plausible human search: scaffolding, an instruction fragment, or tooling.

If nothing on that list fits, or the evidence is too thin to be confident, the model returns a ninth value, unclear, instead of guessing.

The order is not a display convenience, it is the decision itself: because entity_expansion is checked before related, a query that both names a competitor and drifts topically is filed under the more specific read, not the vaguer one. An earlier version of this list carried a ninth candidate category, "search", describing a query's form rather than its relation to the parent prompt; it was dropped because a set mixing two different axes cannot be classified consistently.

The text above is not a paraphrase written for this page. It is CATEGORY_DEFINITIONS itself, the same dictionary the classification prompt is built from and the same text shipped to the product as category_note on each query, so the sentence you would read in the app's tooltip is the sentence the model was actually given, not a second copy that could drift from it.

How much we actually captured: the fidelity states

Not every run gives the same picture of what an engine searched for, and DiscoveredBy does not store a separate status saying which picture this run got. That would be one more field that could fall out of sync with the queries and sources actually saved. Instead it is worked out fresh each time from what is on hand (derive_fan_out_state, api/services/fan_out.py). There are seven possible answers, checked in this order, and three of them mean DiscoveredBy does not know what the engine searched for:

  • not_applicable: this engine's answer never passes through a general-purpose model deciding whether to search. DiscoveredBy calls Perplexity's search endpoint directly, so there is no engine-issued sub-query for it to report. Checked before every other state, specifically so a Perplexity run is never misread as an engine that searched and told us nothing.
  • not_recorded: fan-out capture did not run for this execution at all. There are no queries and no sources to show, and this page has nothing to tell you about what the engine searched for.
  • no_search: capture ran, and found nothing, the engine answered without issuing any sub-queries this time. This is a real claim about the engine's behaviour.
  • searches_not_recorded: at least one retrieved source exists for this run, but zero queries were recorded for it. This is a gap in our own records, not a claim about the engine: we cannot say the engine did not search, only that we did not write down a search for it.
  • queries_only: the queries the engine issued are recorded, but nothing links a retrieved source back to a specific one of them, so they read as a flat list rather than query-to-source pairs.
  • grouped: sources are attributed to a search call, but not cleanly, either a source links to a call we never recorded, or the call it links to ran more than one query, so the honest claim is "one of these searches returned this", not which one.
  • exact: every retrieved source resolves to a single call that ran exactly one query, so a specific source can be shown beside the specific query that produced it.

not_recorded, no_search and searches_not_recorded are the three states where there is genuinely nothing, or next to nothing, to show: no captured queries, and in the first and last of those, possibly no sources either. no_search and searches_not_recorded read almost the same on the page and mean different things on purpose: one is a fact about what the engine did, the other is a fact about what we wrote down, and collapsing them into one sentence would make a claim about the engine's behaviour that our own records do not support.

A query only gets a category once it exists as a captured row, so on not_applicable, not_recorded, no_search and searches_not_recorded there is nothing to classify regardless of plan; the eight categories above only ever apply to a query that was actually captured under queries_only, grouped or exact.

Being captured is necessary but not sufficient. One classification pass judges at most 40 of an execution's queries, in query order (MAX_QUERIES_PER_CALL, api/services/fan_out_classifier.py). A pass skips an execution outright only when every one of its classifiable queries already carries a label at the current classifier version, counted as a comparison of two counts rather than as "does any label exist" (already_classified, same file), and a pass that does run fills its batch from the queries that are still unlabelled at that version (unlabelled_query_ids, same file). So a second pass over the same execution resumes at the tail the first one stopped short of, instead of re-reading the same leading queries.

What triggers that second pass is narrow. Classification is enqueued when a run finishes, and again only if the same prompt, engine and day is run a second time (api/tasks.py#_enqueue_fan_out_classification, #_resume_completed_execution); nothing else in the product schedules a repeat pass over an execution. An execution with more than 40 classifiable queries can therefore still sit with part of its list unlabelled. The difference is that the tail is no longer unreachable: any later pass over that execution picks it up rather than skipping the whole execution.

A new classifier version does start from the front again, because nothing is labelled at that version yet, so it lands on the same leading queries the first version did. When a run has some but not all of its labelable queries labelled, the panel says so directly, "We labelled the first N searches for this run" (labels_capped_at, api/routers/pages.py).

The category is a model's read, and it is versioned

Classifying a query is a model call, not a lookup: the parent prompt and query text both come from an engine's own output, so the classification prompt treats that text as data, never as instructions to follow. A classification below a confidence floor (MIN_DISPLAY_CONFIDENCE, api/services/fan_out.py) still gets stored, but displays as unclear rather than the model's guess.

A query with no chip at all is a third, different case: it has no stored classification, either because it fell past the batch bound above, or because this run's classification simply has not happened yet. Neither is permanent, but neither resolves on a schedule either: both wait on another pass over that execution.

Every stored classification carries the version of the classifier that produced it, CLASSIFIER_VERSION (currently fanout-v1, api/services/fan_out_classifier.py); a newer version writes its own row beside an older one rather than editing it. Classification is enqueued automatically once a run finishes and is best effort: a failure to classify never fails the run itself, since the category is metadata about the measurement, not the measurement. Category labels need a plan that includes them; without one, every captured query and source still shows, just without the category chip. See Pricing for what each plan includes.

From a captured query to a tracked prompt

Because this is a real record of what an engine searched for, any query here can become its own prompt candidate for review, gated the same way category labels are. Tracking it this way does not retroactively classify it; it queues a fresh candidate through the same review flow as any other suggestion. See Suggestions and opportunities for what happens next.

Last verified 2026-08-11

Start monitoring your AI visibility.

See how AI search engines talk about your brand.

Free to start. No credit card required.