Skip to content
Documentation menu

Methodology — technical companion

The plain-English version lives at How we measure. This page is the technical companion: formulas, thresholds, and sources, matching the engine code in src/engine/ (which is parity-tested against the Python reference implementation to 4 decimal places).

Sampling protocol

  • ≥ 8 runs per prompt per engine. LLM answers are stochastic; Schulte, Bleeker & Kaufmann (arXiv 2604.07585, §9) put the minimum at 7 runs/prompt/engine for brand-level visibility and at least 8 when source-level coverage matters, with a 24-day rolling window for SE < 0.05 on trend claims. Mapford is a source-level product, so MIN_RUNS_PER_PROMPT_ENGINE = 8 (D-19). The product default is 8; values below 8 require an explicit confirm and permanently mark runs and reports with a low-sample warning; the hard floor is 3.
  • Presence is a rate, not "ever named." Across hundreds of runs nearly every popular source eventually co-occurs with the client once; a boolean silently empties the target list.
  • Co-occurrence is de-duplicated per run. One answer citing a domain three times is one observation.

Wilson intervals (why not Wald)

Every reported proportion carries a Wilson score interval at 95% (z = 1.96):

center = (p + z²/2n) / (1 + z²/n)
half   = z/(1 + z²/n) · √( p(1−p)/n + z²/4n² )

The familiar Wald interval (p ± z·√(p(1−p)/n)) collapses to zero width at p = 0 or 1 and can extend outside [0, 1] — and our proportions frequently sit near those edges (activation near 1, client citation near 0). Wilson stays sane there. Intervals are always rendered as an explicit range — "38.0% (32.0–45.0, n=210)" — never as a symmetric ±, because Wilson intervals are asymmetric.

The decomposition (the honest denominator)

A blended "share of voice" is not comparable across engines with different retrieval behavior. Following Martinez (arXiv 2607.14035), every report decomposes into three conditional rates:

activation × shortlist|activation × brand|shortlist
  • activation — did the engine retrieve/cite anything at all?
  • shortlist | activation — given it retrieved, did it name any tracked vendor in the category?
  • brand | shortlist — given a category shortlist, did it name the client?

Their product is the effective visibility: the end-to-end probability that a single query names the brand. Counters are clamped to nest (brand ≤ shortlist ≤ activated ≤ total) because a model can name a vendor from parametric memory without retrieving anything. Sampling this many times is not paranoia: Martinez measured that even at temperature 0, outputs still vary 9–28% run to run.

Placeability rubric

Two rubrics exist and every report is stamped with the one that classified it (Analysis.rubricVersion, written into reports.snapshot). Period-over-period deltas refuse to compare across rubric versions and say so.

  • v2 (current, D-20, 26 Aug 2026) — page level, evidence based. src/engine/rubric.ts. Every report produced since 26 Aug 2026 uses it.
  • v1 — domain level. src/engine/placeability.ts. A byte-identical port of the Python reference; it stays as the parity reference and still classifies reports written before the change, which keep rendering under the v1 verdict.

Rubric v2: page classes, in precedence order

Each cited URL is normalised to a page key (fragment and tracking parameters stripped, www. dropped) and classified by who controls the page, as a pure function of (url, PageEvidence | undefined, RubricContext). Evidence is what a fetch observed — HTTP status, visible body text, word count, whether advertiser-disclosure copy is present, whether a demo / free-trial call-to-action is present — nothing in it is a judgement. The classes, most disqualifying first:

ClassPublisher sub-typeRuleGradeEst. cost (USD)Est. time (days)
LOCKED_STRUCTURALv1's structural lists (below)LOCKED
OWNa domain the client ownsLOCKED
COMPETITORa configured competitor domain, or a cited domain whose brand token appears in the answer text of more than half the samples citing it (tokens that are prompt words, shorter than 5 characters, structurally locked, or a publication the answers cite by name are excluded). Product pages and "best X" listicles alikeLOCKED
MAJOR_PUBon the major-publication list (forbes.com, techcrunch.com, cnet.com, zapier.com, …)GETTABLE500–3,00090–180
UGCcommunity / user-generated surfaces (reddit.com, youtube.com, quora.com, substack.com, …)PLACEABLE0–1507–45
UNKNOWNno evidence, non-200 status, or fewer than 150 words of bodyUNKNOWN
COMPARISON_3PAFFILIATEa third-party page naming ≥ 3 of the vertical's brands, with advertiser-disclosure copyGETTABLE500–3,00090–180
COMPARISON_3PADJACENT_VENDOR…with a demo / trial call-to-action but not one of the named options — a business selling something else to the same buyersPLACEABLE50–50014–90
COMPARISON_3PPUBLISHER…with neitherPLACEABLE50–50014–90
SINGLE_3Pa third-party page about one or two brands — a review, an integration page, a case studyGETTABLE250–1,50030–120

Mapping B — affiliates are GETTABLE, deliberately. A page carrying an advertiser disclosure lists what pays it per lead; that is a commercial relationship over months, not a placement a newcomer buys for $500. It is an a-priori argument about how affiliate sites work, and it is also, honestly, the choice that made the insurance control fail in the kill-test re-score (product-docs/RUBRIC-DECISION.md §2–3).

UNKNOWN is reported, never counted. Under v1 an unrecognised domain defaulted to PLACEABLE; 93% of the kill-test citations took that branch, so v1 measured absence from a ~100-domain list rather than reachability. Under v2 a page we could not read is a fourth bucket: unknownShare is printed beside the other three (the four sum to 1), the reachability bar and every pill carry it, and it never enters the placeable share or the target list.

The claim is the list. Analysis.concentration records how many distinct pages were cited and what share of citation weight the top 1, 10 and 25 carry, plus how many of the top 25 are PLACEABLE. The v2 verdict string is "{k} of the {n} most-cited pages are reachable; placeable share X%, unknown Y%" — there are no BUILD / STOP thresholds under v2; the >60% bar was retired with D-20.

Structural locks (shared by both rubrics)

ReasonWhat it catchesExamples
unparseable urlURL we can't extract a domain from
google_propertyGoogle's own surfaces — no placement exists to buygoogle.com, business.google.com, unresolved vertexaisearch redirects
own_domainThe client's own domains
competitor_domainDomains owned by tracked competitors (v1 reason string; v2 reports COMPETITOR)
institutionalInstitutional TLDs.gov, .edu, .mil, .gov.uk, .ac.uk, .edu.au
journal_or_medicalJournals / medical authoritynih.gov, mayoclinic.org, nature.com, arxiv.org
retailerRetailer/marketplace-owned — distribution deal, not content placementamazon.com, walmart.com, apple.com
unbuyable_editorialEditorial that does not sell or accept placementnytimes.com, wirecutter.com, consumerreports.org, theverge.com
default_inclusion_directoryDirectories where every competitor is already listed — inclusion is worth ~nothing (G2's own research: review volume explains < 2% of citation variance, R² = 0.009)g2.com, capterra.com, clutch.co, yelp.com, trustpilot.com

Rubric v1 (reference only)

Domain level, three grades, no evidence. Community surfaces PLACEABLE at 0–150 USD / 7–45 days; major publications GETTABLE at 500–3,000 / 90–180; everything else PLACEABLE at 50–500 / 14–90 ("long-tail independent site, no gatekeeper identified"). Verdict thresholds on placeable share: > 60% BUILD · < 35% STOP · in between MARGINAL. Kept byte-identical so the parity goldens against the Python reference stay meaningful; not used for new reports.

Gap targets math

The targets table lists sources where competitors materially out-appear the client:

  • A source qualifies only if it is PLACEABLE (never UNKNOWN), at least one competitor is named in runs citing it, and gap = topCompetitorShare − clientShare ≥ GAP_MARGIN where GAP_MARGIN = 0.15 — if you already roughly match the best competitor there, it's not a target.
  • priority = citationWeight × gap × 100, where citationWeight is the source's share of all citations in the run. Weighting by citation traffic times how far behind you are.
  • Targets are sorted by priority, descending. Priority ordering is not a rank metric — it orders your to-do list, it makes no claim about answer positions.

Period-over-period change (D-17 / D-18)

The measurement period is a calendar month; there is no weekly product. Two reports for the same brand are compared by src/engine/delta.ts only if the comparability gate passes: identical prompt set, engines, competitor set, recorded models (D-16) and rubricVersion (D-20). An incomparable pair renders the reasons and no numbers.

Each difference of two rates carries a Newcombe hybrid-score interval (Newcombe 1998, method 10) built from the two Wilson intervals; wilson() handles one proportion and is never reused for two. A change is detectable only when that interval excludes zero — otherwise the report prints "no detectable change" with the interval and the detectableLiftPp of the smaller sample. Pages are diffed as gained / lost / held by page key, and the target list as entered / left / stayed. Deltas are outside the parity contract (no Python counterpart) and carry their own mutation-tested suite.

What is deliberately not reported

  • No rank or sentiment metrics below n ≈ 100. Evertune's data shows 5-sample rank estimates swing by ±27 percentage points; Kumar/Ranqo found sentiment classifications flip ~6.7× more often than presence. Below n=100 these are noise sold as insight, so they are excluded entirely in v1.
  • No rate without n and a confidence interval. Anywhere.
  • No guarantee language. The report estimates and ranks; it never promises placement or recommendation.

Every report also carries its sampling notes verbatim, including the smallest period-over-period change the sample can actually detect — anything smaller is indistinguishable from noise.

Parity with the reference

The TypeScript engine is a 1:1 port of the audited Python reference in reference/python-engine/. A parity suite (pnpm test:parity) replays Python-exported sample streams through the TS aggregation and asserts every numeric field to 4 decimal places, exact target order and priorities, and byte-identical verdict and warning strings — including three golden fixtures (a niche-SaaS BUILD case, a negative control that must fail, and a hand-written edge case). Two boundaries are excluded by design: string formatting (the report layer owns it) and mock RNG (streams are replayed, not regenerated). The parity suite covers rubric v1 and the shared aggregation; rubric v2 has no Python counterpart and is pinned by its own unit suite over classifyPage instead.