Evidence-Based Competitor Map (universal method)
A competitive analysis almost always fails the same way: not because companies are missing, but because the file asserts more than it actually knows. A third-party mention becomes a fact, a row already "verified" never gets reopened, a number copied into a document quietly goes stale. This method exists to make those errors visible — and it works the same way for a wearable-device maker, a B2B software company, a retail chain, or an industrial supplier: only the content of the cells changes, never the structure.
The golden rule
A strong label only holds where someone actually looked, and the proof is the link to the page they looked at.
Everything else follows from this.
0. Before you start: what you need to bring
This method knows nothing about your market. Before applying it, decide these five
things — they are the only points where the skill depends on your company, and they
are all collected in one place (the CONFIG block of the script, section 5), so the
rest of the method stays identical from one census to the next:
- Your pillars and sub-criteria — the product or market characteristics you want to compare across every subject (see §1, "The criteria").
- Your decisive criteria — one or two sub-criteria that, on their own, decide whether a competitor genuinely affects you (used by check D3, §4 and §5).
- Your strategic platform or dependency — the technology, supplier, or ecosystem your company is exposed to (a cloud platform, a standard, a distribution channel). Used by check D4, the most important one of all (§4).
- Your priority markets — the countries or segments where you want a higher verification standard than elsewhere (used by §4 battery C and the score in §6).
- Your exclusion rules — the categories of subjects you already know you want to exclude (resellers, adjacent markets, etc.) and the textual signal that identifies them, not a list of names (Decision 3, §2).
With these five answers in hand, the rest of the skill applies mechanically. Section 9 shows a complete, fictional example of how these five answers translate into a real file.
1. File structure
Roles — exclusive, one per row
| Role | Definition |
|---|---|
| Competitor | Sells technology or a service to your same type of end customer |
| Market initiative | Pilot project, consortium, public program, research body. Has no product for sale |
| Potential customer | The subject buys, not sells: an operator, a service provider, an end distributor, a cooperative |
| Technical supplier | Sells a component that can be integrated into your product or a competitor's, or distributes and installs it |
| Out of scope | Adjacent market, or excluded by an explicit rule (see Decision 3) |
| — us — | Your own row, kept in the file for direct comparison |
Two recurring mistakes, in any sector, both of which inflate the counts: the contracted service provider (wins a tender and buys technology from a third party → potential customer, not a competitor) and the distributor/reseller (resells other companies' products without making them → technical supplier, not a competitor).
The evidence scale — six states, not three
| State | Meaning |
|---|---|
Verified present |
Documented on a primary source, with URL and date |
Verified absent |
The product pages were read and the feature/characteristic is not there |
Stated, not verified |
A third-party source says so; no one has opened the subject's own site |
Not verifiable |
The site cannot be read, or the page doesn't say so clearly |
Not confirmed |
A later check disproved a value we had previously asserted |
Not surveyed |
The collection grid never asked the question for this row |
The distinction between proven absence and unverified is the only defense
against self-confirmation: without it, every criterion not yet checked silently drifts
toward "no". Not surveyed matters just as much: it separates "we looked and it wasn't
there" from "we never asked" — two situations that require different work.
This scale applies per cell: every pillar, on every row, receives one of these
states. It is a different — finer-grained — level than the Verification level column
used by the script in §5: that one is a row-level flag, saying whether the
subject's identity and general description have been read from a primary source at
least once. A row can have Verification level = Verified on primary source and, at
the same time, have individual pillars still at Not surveyed, because no one has yet
looked for that specific characteristic. The two scales answer different questions:
"is this subject real, and have we looked at it?" (row) versus "is this specific
characteristic confirmed?" (cell).
The criteria (pillars)
Define them yourself, for your own product — this is part 1 of the checklist in §0. Only one structural rule holds, in any sector: all pillars sit on the same level, no cross-cutting pillar. A cross-cutting pillar (one that applies "depending on" the others) makes the count non-additive: you can no longer say "how many competitors cover N pillars out of M" if M changes row by row. Every sub-criterion must have a written discriminating question, one a verifier can answer yes/no by reading a page — not a judgment call.
Two practical rules, valid in any sector: (a) aim for 6-10 pillars with 2-5 sub-criteria each — fewer, and the census discriminates nothing; many more, and no verifier applies them consistently row after row; (b) a good discriminating question names an observable piece of evidence ("does the site state a public per-unit price?"), not a quality judgment ("is the product affordable?") — if two people reading the same page could answer differently, the question needs rewriting.
Attribution by market served, not by registered office
A country (or market segment) is attributed only with direct proof: an address, a local legal entity, a dedicated national site, a reseller named for that country. Where the site only says "we operate in Europe" or "present in over N countries", the country is not added. The method therefore underestimates, and that is the right direction to err in: a shorter, defensible census beats a larger one inflated by marketing claims.
2. The eight decisions that hold in any sector
- A third-party source cannot prove an absence. If an article or an industry list doesn't mention a characteristic, that doesn't mean the subject lacks it: it only means that source doesn't talk about it.
- A row without a description cannot be classified. No automatic rule can decide a subject's role or criteria from an empty text field: what it actually sells, and to whom, must be described first.
- Exclusion rules apply by evidence, not by name. If you decide to exclude a category of subjects (for example: generic management tools with nothing specific to your sector, or agencies that resell without developing anything), the rule must look in the description for the distinguishing signs of that category and the absence of the characteristics that would exclude it — never a hand-written list of company names, which goes stale the moment a new company appears.
- A reclassification must be logged with its reason, so it can be revisited later instead of looking like a mysterious error.
- Closed lists must be normalized: free text collected from research easily produces dozens of variants for the same category or a single country (e.g., "USA", "United States", "U.S." counted as three different countries).
- Multiple markets or segments are verified one at a time, and the check must be wired into the pipeline that rebuilds the file — it's easy to do it once and forget to reapply it when the file is refreshed.
- Strong labels exist only on rows verified from a primary source. On an
unverified row,
Verified presentmust be demoted toStated, not verified, andVerified absenttoNot verifiable: a strong label cannot survive the loss of the verification that supported it. - A verification label is only valid with its link. A row marked "verified" without the URL of the page that was read carries a label inherited from an earlier version of the file: it has decayed. Because every real verification always writes down the link, the absence of a link is exactly what identifies which inherited labels need to be checked.
Two note columns, never just one
- Evidence notes — only what was read on the source. This is the text the automatic classification rules use as proof.
- Working notes — internal annotations only: demotions, duplicates, domain corrections, editorial decisions.
If the two are mixed, a rule starts reading what you yourself wrote, and the system confirms itself without any real data having changed. Check E4 in §4/§5 exists specifically to catch this.
3. The work cycle: verification in waves
Each wave is a code file (verified<N>.py) with, for every subject:
domain → (name, [one value per criterion], proof URL, note).
Plus an ALIASES map for superseded domains, ROLES for reclassifications, and
DUPLICATES for rows that turn out to be the same subject.
Advantages, independent of sector: verification is rerunnable, every wave is dated, and the pipeline reapplies everything from scratch on every run — there's no hidden state that only one person remembers.
How to instruct a verifier (agent or human)
The prompt template below has held up across dozens of companies in more than one sector. The anti-hallucination rules are not decorative: without them, a verifier (human or agent) produces values that look plausible but are invented.
You are a primary-source verifier. You must OPEN the official websites and read
what is actually written there. You must NOT invent anything.
ANTI-HALLUCINATION RULES (mandatory):
- Use search to find the right pages, then read them. If reading is blocked,
search the domain first: URLs in the search results become readable.
- If a site is unreachable after 3 attempts on different pages, write
"UNREACHABLE" and mark everything N.
- Do not use prior knowledge or inference: if the page doesn't say it, it's N.
- If the domain redirects elsewhere, flag it explicitly.
[list of subjects with domain]
FOR EACH SUBJECT return ONE row:
NAME | actual_domain | <one value per criterion> | URL of the strongest evidence | note
[numbered list of your criteria, with the discriminating question for the
ambiguous ones]
LETTERS: V = present and documented. A = absent, you checked the product
pages and it isn't there. N = not verifiable.
In the note (3-5 sentences): what it actually sells, to whom, whether it
manufactures or resells, which markets it serves WITH PROOF, named partners
or customers, and whether it's a genuine competitor, a technical supplier, a
contracted service provider, or out of scope.
Output: rows only, no preamble.
Be obsessive about the criterion that truly sets you apart. An ambiguous criterion,
left without a threshold, produces contradictory answers from one verifier to the
next. Generic example: if the criterion is "declared native integration with [your
strategic platform]", the discriminating question must be explicit — it only counts
if the site explicitly names that integration; a generic "integrates with the
industry's leading platforms", with no name given, counts as N, not V. Write your
own version of this sentence for your most delicate criterion and put it in the
prompt: without it, a consistent share of the verifier's answers will get the same
point wrong, every time.
When automated tools aren't enough
Some sites respond to any automated fetch with an antibot wall or a 401. They need to be opened with a real browser. Never reclassify a row based on indirect clues: three converging clues can all point the wrong way — the convergence of secondary sources doesn't converge toward the truth, it converges toward what gets said about a company most often outside its own site.
4. The five batteries of checks
| What it looks for | It finds | |
|---|---|---|
| A form | The cell is filled in as the template expects | incomplete rows |
| B sense | The row doesn't contradict itself | inconsistent rows |
| C method | The file as a whole holds together | coverage gaps |
| D notes | The rows we think we already know | rows that are complete and wrong |
| E documents | The deliverables say what the data says | stale numbers |
A, B, and C look for incomplete rows, and an incomplete file is easy to spot even by eye. D and E find things that are complete and wrong, invisible precisely because they look finished — the kind a quick second human review never catches, in any sector.
On a real census of a few hundred rows, over three days battery D typically surfaced competitors never seen before, duplicates, corrected domains, and at least one reversed reclassification — on a file that had already been cleaned up twice. Battery E, the first time it's run against a set of documents already thought "finished", almost always finds anomalies introduced by the editorial work itself, not by the underlying data.
The check that matters most: D4
Looks for anyone naming your strategic platform or dependency (point 3 of the checklist in §0) without having the corresponding criterion confirmed. It's the radar pointed at your own infrastructure: if a competitor depends on the same platform, standard, or channel you also rely on, it's a signal that often goes unsearched because it's "taken for granted" that the competitor list is already complete. In a real case, this check revealed that the most relevant competitor in the sector had already been in the file for months, with its decisive box marked "not confirmed" — and therefore, by construction, outside every work queue, simply because no one had ever reopened that row.
Structural fix: numbers rewrite themselves
Wherever a document contains a figure that can be calculated from the data file,
have the figure rewritten by the calculation every time the document is rebuilt,
leaving the hand-written sentence around it untouched. Example: a regex that replaces
N established companies in the text with the current value computed from the file.
This is the exact point where a copied number goes stale, and there's no reason to
leave its upkeep to an attentive human — sooner or later it gets forgotten, always in
the same spot.
5. The checks script
Save it as checks.py next to the data. It adapts to any census by changing only the
CONFIG block: nothing else in the script contains column names or values specific
to your sector. Every entry in the CONFIG block below is written as a placeholder
to replace, not a real value — they are the same five points from the checklist in
§0, translated into code.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Integrity checks for a verified competitive census.
Works for any sector: the only part to rewrite is the CONFIG block.
python3 checks.py # all batteries
python3 checks.py D E # only some
python3 checks.py --json anomalies.json # output as JSON
"""
import json
import re
import sys
from collections import defaultdict
from urllib.parse import urlparse
# =====================================================================
# CONFIG — the ONLY part to rewrite for your own census.
# Every value between <> is a placeholder: replace it with your real data.
# The rest of the script should not be touched.
# =====================================================================
CONFIG = {
"data_file": "data.json", # list of dicts, one row per subject
"score_file": "score.json", # {market: metrics} — "" to skip check E5
"text_deliverables": ["notes.js", "method.js", "build_score.py"], # your generated documents
# --- your data file's column names: change these to match your own template ---
"col_name": "Company",
"col_website": "Official website",
"col_role": "Role in census",
"col_verification": "Verification level",
"col_proof_url": "Functional proof URL",
"col_markets": "Markets served",
"col_presence_url": "Presence proof URL",
"col_hq": "Registered office",
"col_description": "What it sells",
"col_evidence_notes": "Merge notes", # evidence from sources ONLY
"col_working_notes": "Working notes", # internal annotations ONLY
"col_direct": "Provisional direct",
"pillar_regex": r"^P\d\.\d ", # how you recognize pillar columns by name
# --- status values used in your template: change these if you use different labels ---
"role_competitor": "Competitor",
"role_us": "— us —",
"verified": "Verified on primary source",
"state_disproven": "Not confirmed",
"state_present": "Verified present",
# --- checklist §0 point 2: YOUR decisive criteria ---
# WATCH THE ASYMMETRY: check D3 (§4) rereads ALL the criteria in this list.
# Check D4 (the platform radar, right below) reads ONLY the first element
# of the list, by design. Put your single most decisive criterion first —
# the one that on its own separates a real competitor from an innocuous
# mention — even if you add others afterward for D3.
"decisive_criteria": ["<Decisive Pillar.Sub-criterion 1 of your positioning>",
"<Decisive Pillar.Sub-criterion 2, if you have a second one>"],
# --- checklist §0 point 3: YOUR strategic platform or dependency (D4) ---
# THE MOST IMPORTANT CHECK IN THE SCRIPT.
# Examples of what a "radar" can be: the name of a cloud provider you
# depend on, a technical standard, a distribution channel, a hardware
# ecosystem.
"radar": r"(<keyword-1>|<keyword-2>|<keyword-3>)",
# The columns to search the radar in: use the ones in your data file that
# contain free text (descriptions, notes, references). If a column listed
# here doesn't exist in your template, that's fine: the script simply
# skips it (this isn't a list of required columns, it's a list of "where
# I'd look if it were there").
"radar_columns": ["What it sells", "Merge notes", "Short description",
"Named customers and references", "Declared partners"],
# --- checklist §0 point 4: YOUR priority markets or segments (battery C, §6) ---
"priority_markets": ["<Priority market 1>", "<Priority market 2>",
"<Priority market 3>"],
# --- E1: every figure you write in your documents, and how to recompute it from the data file ---
# Add one entry here for every "citable" number that appears in your deliverables.
# Use \d+ (not a fixed number of digits): this works for a census of 6 rows
# as well as one of 600 — with \d{3}, a very small or very large census
# stops triggering the check without any error flagging it.
"figures": {
"established rows": ([r"(\d+) established companies"],
lambda D, C: len(D)),
"competitive scope": ([r"out of (\d+) in the competitive scope"],
lambda D, C: len([r for r in D if r.get(C["col_role"])
== C["role_competitor"]])),
"verified on primary source": ([r"of (\d+) companies verified on a primary source"],
lambda D, C: len([r for r in D if r.get(C["col_verification"])
== C["verified"]])),
},
"procedural_phrases": [r"Strong labels demoted to ", r"Verification label decayed",
r"DUPLICATE ROW of ", r"Website corrected \("],
# --- third-party domains that don't count as a primary source: add your sector's portals ---
"third_party_domains": (r"(fair|messe|\bexpo|exhibitor|linkedin|facebook|//x\.com|youtube|"
r"crunchbase|wikipedia|zoominfo|europages|kompass|"
r"press\.|prnewswire|businesswire|<your-sector-trade-fair-portal>)"),
"two_level_suffixes": {"co.uk", "org.uk", "ac.uk", "gov.uk", "com.au",
"co.nz", "com.es", "co.at", "or.at", "com.br"},
}
# =====================================================================
C = CONFIG
D = json.load(open(C["data_file"], encoding="utf-8"))
PILLARS = [c for c in D[0] if re.match(C["pillar_regex"], c)]
COMPETITORS = [r for r in D if r.get(C["col_role"]) == C["role_competitor"]]
VERIFIED = [r for r in D if r.get(C["col_verification"]) == C["verified"]]
anomalies, out = [], []
def val(r, key):
return (r.get(C[key]) or "").strip()
def row_name(r):
return val(r, "col_name")
def report(code, title, why, rows):
"""Every anomaly carries the REASON the check exists: a list of codes
that nobody knows how to read never gets looked at twice."""
if not rows:
out.append("")
out.append("[ 0] %s %s — none. ok" % (code, title))
return
for x in rows:
anomalies.append({"code": code, "check": title, "why": why, **x})
out.append("")
out.append("[%4d] %s %s" % (len(rows), code, title))
out.append(" %s" % why)
for x in rows[:8]:
out.append(" %-40s %s" % (x.get("row", "")[:40], x.get("detail", "")[:104]))
if len(rows) > 8:
out.append(" ... and %d more" % (len(rows) - 8))
def print_title(t):
out.append("")
out.append("=" * 78)
out.append(t)
out.append("=" * 78)
def host(u):
try:
h = urlparse(u if "://" in u else "https://" + u).netloc.lower()
except Exception:
return ""
return h[4:] if h.startswith("www.") else h
def root_domain(h):
p = h.split(".")
if len(p) >= 3 and ".".join(p[-2:]) in C["two_level_suffixes"]:
return ".".join(p[-3:])
return ".".join(p[-2:]) if len(p) >= 2 else h
def read_text(path):
"""Resolves \\uXXXX escapes: the comparison must happen on the text that
will end up in the document, not on its source-code encoding."""
t = open(path, encoding="utf-8").read()
return re.sub(r"\\u([0-9a-fA-F]{4})", lambda m: chr(int(m.group(1), 16)), t)
# =====================================================================
# A — FORM
# =====================================================================
def battery_A():
print_title("A FORM — the cell is filled in as the template expects")
report("A1", "competitor with no market served",
"It doesn't enter any per-market analysis: it's invisible to every count.",
[{"row": row_name(r), "detail": "no market"} for r in COMPETITORS
if not val(r, "col_markets")])
report("A2", "market attributed with no proof URL",
"Attribution by market served requires proof: an address, a local legal "
"entity, a national site, or a named reseller.",
[{"row": row_name(r), "detail": val(r, "col_markets")[:80]} for r in COMPETITORS
if val(r, "col_markets") and not val(r, "col_presence_url")])
report("A3", "direct competitor with no official website",
"It can't be verified and can't be tied to any per-domain check: it's a row "
"that can never leave the queue.",
[{"row": row_name(r), "detail": "no website"} for r in COMPETITORS
if r.get(C["col_direct"]) == "Yes" and not val(r, "col_website")])
report("A4", "direct competitor with no description",
"Without a description you can't decide if it's in scope, and no "
"classification rule can work on it.",
[{"row": row_name(r), "detail": "no description"} for r in COMPETITORS
if r.get(C["col_direct"]) == "Yes" and not val(r, "col_description")])
dups = defaultdict(list)
for r in D:
k = re.sub(r"[^a-z0-9]", "", row_name(r).lower())[:14]
if k:
dups[k].append(row_name(r))
report("A5", "two rows with near-identical names",
"Possible duplicate that escaped consolidation: it would count twice in "
"every per-market count.",
[{"row": " | ".join(v), "detail": "very similar names"}
for v in dups.values() if len(v) > 1])
# =====================================================================
# B — SENSE
# =====================================================================
def battery_B():
print_title("B SENSE — the row doesn't contradict itself")
NEGATION = r"(?<!no )(?<!not any )(?<!without )(?<!not )"
open_states = ("", "Not verifiable", "Stated, not verified", "Not surveyed")
found = []
for r in D:
text = " ".join(val(r, k) for k in ("col_description", "col_evidence_notes"))
for c in PILLARS:
word = c.split(" ", 1)[1].split()[0].lower()[:7]
if len(word) < 5:
continue
if re.search(NEGATION + re.escape(word), text, re.I) and (r.get(c) or "") in open_states:
found.append({"row": row_name(r),
"detail": "the description names \"%s\" but %s is open"
% (word, c[:4])})
break
report("B1", "the description names a characteristic the criterion doesn't record",
"This is the most precise verification queue there is: it already says which "
"criterion to check on which site, with no need to search for it.", found)
report("B2", "the HQ market doesn't appear among the markets served",
"Either an attribution is missing, or the HQ is wrong: either way, one market "
"is being counted incorrectly.",
[{"row": row_name(r), "detail": "HQ %s, served: %s"
% (val(r, "col_hq"), val(r, "col_markets")[:60])}
for r in COMPETITORS if val(r, "col_hq") and val(r, "col_markets")
and val(r, "col_hq") not in val(r, "col_markets")])
# =====================================================================
# C — METHOD
# =====================================================================
def battery_C():
print_title("C METHOD — the file as a whole holds together")
out.append("")
out.append(" Verification coverage")
out.append(" total rows ..................... %d" % len(D))
out.append(" verified on primary source ..... %d (%.0f%%)"
% (len(VERIFIED), 100.0 * len(VERIFIED) / max(1, len(D))))
directs = [r for r in COMPETITORS if r.get(C["col_direct"]) == "Yes"]
vd = [r for r in directs if r.get(C["col_verification"]) == C["verified"]]
out.append(" direct competitors ............. %d, of which verified %d (%.0f%%)"
% (len(directs), len(vd), 100.0 * len(vd) / max(1, len(directs))))
out.append("")
out.append(" Criteria fill rate: where the grid doesn't get filled in")
for c in PILLARS:
states = defaultdict(int)
for r in directs:
states[(r.get(c) or "empty")] += 1
nv = states.get("Not verifiable", 0) + states.get("empty", 0)
if directs and nv / len(directs) > 0.6:
out.append(" %-52s %3d%% not derivable from public sources"
% (c[:52], 100 * nv // len(directs)))
out.append("")
out.append(" Coverage by priority market")
for p in C["priority_markets"]:
here = [r for r in COMPETITORS if p in val(r, "col_markets")]
local = [r for r in here if val(r, "col_hq") == p]
vl = [r for r in local if r.get(C["col_verification"]) == C["verified"]]
out.append(" %-16s %3d present | %3d with local HQ | %3d local & verified"
% (p, len(here), len(local), len(vl)))
if local and not vl:
out.append(" WARNING: no competitor headquartered in %s is verified." % p)
# =====================================================================
# D — THE ROWS WE THINK WE ALREADY KNOW (the battery that matters)
# =====================================================================
def battery_D():
print_title("D THE ROWS WE THINK WE ALREADY KNOW")
out.append("A, B, and C look for incomplete rows, and an incomplete file is easy to spot.")
out.append("This one looks for rows that are COMPLETE AND WRONG, invisible because they look finished.")
third_party = re.compile(C["third_party_domains"], re.I)
d1 = []
for r in VERIFIED:
proof, site = val(r, "col_proof_url"), val(r, "col_website")
if not proof:
continue
hp, hs = root_domain(host(proof)), root_domain(host(site))
if third_party.search(proof):
d1.append({"row": row_name(r), "detail": "proof on a third-party domain: " + proof[:70]})
elif hs and hp and hp != hs:
d1.append({"row": row_name(r),
"detail": "the proof is on %s but the official site is %s" % (hp, hs)})
report("D1", "row verified on a page that isn't its own",
"A primary-source verification only counts if the source is the subject's own "
"site. If the proof sits on a trade fair, an article, or a partner's site, the "
"row is documented but not verified. It can also mean the file is still "
"holding a superseded domain.", d1)
report("D2", "verified but with no proof URL",
"A verification no one else can recheck isn't a verification. Since a real "
"verification always writes down the link, a missing link identifies labels "
"INHERITED from an earlier version: these are exactly the ones that escape "
"every demotion.",
[{"row": row_name(r), "detail": "no link to the page that was read"} for r in VERIFIED
if not val(r, "col_proof_url")])
d3 = []
for r in D:
if val(r, "col_role") == C["role_us"]:
continue
disproven = [c for c in C["decisive_criteria"] if r.get(c) == C["state_disproven"]]
if disproven:
d3.append({"row": row_name(r),
"detail": "disproven on %s" % ", ".join(s[:4] for s in disproven)})
report("D3", "disproven on a decisive criterion: to be reviewed, not archived",
"A \"disproven\" state is the RESULT of a verification, so no queue ever "
"reopens it. This is exactly where the competitor you think you've already "
"excluded is hiding.", d3)
radar = re.compile(C["radar"], re.I)
d4 = []
for r in D:
if val(r, "col_role") == C["role_us"]:
continue
text = " ".join((r.get(c) or "") for c in C["radar_columns"] if c in r)
m = radar.search(text)
if m and r.get(C["decisive_criteria"][0]) != C["state_present"]:
d4.append({"row": row_name(r),
"detail": "..." + text[max(0, m.start() - 50):m.end() + 70]
.replace("\n", " ")})
report("D4", "names your own platform without having the criterion confirmed",
"This is the radar pointed at the technology you're exposed to. Anyone who "
"names it without the criterion confirmed is either a competitor you haven't "
"read yet, or an innocuous mention: either way, it needs a look. This is how "
"your biggest competitor goes unnoticed. IF YOU ONLY RUN ONE CHECK, RUN THIS "
"ONE.", d4)
per_host = defaultdict(list)
for r in D:
h = root_domain(host(val(r, "col_website")))
if h:
per_host[h].append(row_name(r))
report("D5", "multiple rows sharing the same domain",
"Two rows on the same site are almost always the same subject counted twice, "
"and every per-market count suffers for it.",
[{"row": " | ".join(v), "detail": "share " + k}
for k, v in per_host.items() if len(v) > 1])
names = {}
for r in D:
base = re.split(r"[(—]", row_name(r))[0].strip()
if len(base) >= 5 and val(r, "col_role") != C["role_us"]:
names[base] = row_name(r)
OWNERSHIP = re.compile(r"(brand of|part of the group|a brand|subsidiary of|acquired|"
r"absorbed by|division of|owned by)", re.I)
d6 = []
for r in D:
text = " ".join((r.get(c) or "") for c in C["radar_columns"] if c in r)
if not OWNERSHIP.search(text):
continue
for base, full in names.items():
if full != row_name(r) and re.search(r"\b%s\b" % re.escape(base), text):
d6.append({"row": row_name(r),
"detail": "names \"%s\", already in the census, with a "
"corporate-ownership term" % base})
break
report("D6", "possible brand or subsidiary of another row",
"Corporate ownership is usually stated in the text. Every duplicate that goes "
"unfound is a market counted twice.", d6)
d7 = []
for r in COMPETITORS:
if r.get(C["col_direct"]) != "Yes" or r.get(C["col_verification"]) == C["verified"]:
continue
markets = [p for p in C["priority_markets"] if p in val(r, "col_markets")]
if markets:
d7.append({"row": row_name(r), "detail": "not verified in " + ", ".join(markets)})
report("D7", "direct competitor in a priority market never checked on-site",
"This is the real work queue, ordered by how much it matters.", d7)
# =====================================================================
# E — THE DELIVERABLES SAY WHAT THE DATA SAYS
# =====================================================================
def battery_E():
print_title("E DOCUMENTS AGAINST DATA")
out.append("The other batteries check the file. This one checks the DOCUMENTS: the numbers")
out.append("written into them by hand, which go stale in silence.")
docs = {}
for p in C["text_deliverables"]:
try:
docs[p.split("/")[-1]] = read_text(p)
except Exception:
pass
e1 = []
out.append("")
for label, (pats, compute) in C["figures"].items():
expected = compute(D, C)
found = False
for docname, text in docs.items():
for pat in pats:
for m in re.finditer(pat, text):
found = True
written = int(m.group(1))
ok = written == expected
out.append(" %-32s expected %4d written %4d %-14s %s"
% (label, expected, written, docname,
"ok" if ok else "<<< MISMATCH"))
if not ok:
e1.append({"row": docname,
"detail": "%s: expected %d, written %d"
% (label, expected, written)})
if not found:
out.append(" %-32s expected %4d no occurrence found in the documents"
% (label, expected))
report("E1", "stale figure inside a document",
"The number in the document no longer matches the file. It needs correcting, "
"and where possible it should be rewritten by the calculation instead of by "
"hand.", e1)
out.append("")
out.append(" E2 UNIQUENESS CLAIMS — \"the only\", \"no one\", \"the sole\"")
out.append(" These are claims by absence, the most fragile kind there is.")
uniq = re.compile(r"[^\"']{0,120}(the only\b|no one\b|the sole\b|none\b|nobody\b)"
r"[^\"']{0,150}", re.I)
seen = set()
for docname, text in docs.items():
for m in uniq.finditer(text):
phrase = " ".join(m.group(0).split())
if len(phrase) > 45 and phrase not in seen:
seen.add(phrase)
out.append(" [%s] %s" % (docname, phrase[:190]))
out.append(" -> each one needs to be re-checked against the count behind it.")
proc = [re.compile(p) for p in C["procedural_phrases"]]
report("E4", "a classification rule may be reading text we wrote ourselves",
"If working annotations end up in the column the rules use as evidence, the "
"system starts confirming itself, and the flaw is invisible because every "
"single step looks correct.",
[{"row": row_name(r), "detail": "procedural note found in the evidence column"}
for r in D if any(p.search(val(r, "col_evidence_notes")) for p in proc)])
if not C["score_file"]:
return
try:
S = json.load(open(C["score_file"], encoding="utf-8"))
except Exception:
return
S = {m["market"]: m for m in S} if isinstance(S, list) else S
e5 = []
for market, m in S.items():
if not m.get("surveyed"):
continue
here = [r for r in COMPETITORS if market in {p.strip() for p in val(r, "col_markets").split(",")}]
if m["surveyed"] != len(here):
e5.append({"row": market, "detail": "the score counts %d surveyed, the file has %d"
% (m["surveyed"], len(here))})
pc, pr = m.get("pressure_confirmed"), m.get("pressure")
if pc is not None and pr is not None and pc > pr + 0.05:
e5.append({"row": market, "detail": "confirmed pressure (%.1f) exceeds detected "
"pressure (%.1f): impossible, since the first is "
"a subset of the second" % (pc, pr)})
for k in ("reliability", "proven_share"):
if m.get(k, 0) > 100:
e5.append({"row": market, "detail": "%s is above 100 percent" % k})
report("E5", "the score doesn't match the census",
"An index calculated on a different base than the file's is a number that "
"means nothing, and nobody notices until it's recalculated.", e5)
# =====================================================================
BATTERIES = {"A": battery_A, "B": battery_B, "C": battery_C,
"D": battery_D, "E": battery_E}
if __name__ == "__main__":
# Not a plain filter on "--": the filename after --json is not a battery
# letter and must be skipped along with the flag, not left in the list.
argv, letters, i = sys.argv[1:], [], 0
while i < len(argv):
if argv[i] == "--json":
i += 2
continue
if not argv[i].startswith("--"):
letters.append(argv[i].upper())
i += 1
for s in (letters or list(BATTERIES)):
if s in BATTERIES:
BATTERIES[s]()
print_title("SUMMARY")
counts = defaultdict(int)
for a in anomalies:
counts[a["code"]] += 1
for k in sorted(counts):
out.append(" %-4s %4d" % (k, counts[k]))
out.append(" %-4s %4d total" % ("", len(anomalies)))
print("\n".join(out))
if "--json" in sys.argv:
dest = sys.argv[sys.argv.index("--json") + 1]
json.dump(anomalies, open(dest, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
print("\nwritten %s" % dest)
6. The market score
Measures detected competitive pressure, not the size of demand: without verified demand data for your sector, don't invent it — a competitive pressure score is defensible even without third-party market data; an invented market-size score never is.
pressure = 25·direct_norm + 20·integrated_norm + 15·(criteria_covered/N)
+ 25
…(truncated)