# -*- coding: utf-8 -*- """Second analysis pass over study500/answers.json: the market-facing cuts. study500_findings.py answers "what did the engines do". This answers "what does that mean for an agent deciding whether to pay for AI visibility", which is a different set of questions and the one the published research has to survive. It imports the extraction and canonicalisation from study500_findings.py rather than reimplementing them, so both files always agree on what counts as a named firm. Every number printed here is computed, never typed. py -3.10 study500_market.py # writes study500/MARKET.txt Sections: M1 concentration how unequal the mention distribution is (Gini, top-N shares) M2 the denominator what share of Houston's licensed agents ever get named M3 who the winners are what the top-mentioned firms have in common, from the data alone M4 brand vs person do national brands behave differently from individual agents M5 the citation layer how much of the citation graph points at portals and referral networks M6 contested areas where the competition is thin and where it is not M7 intent doors which query intents are the cheapest way in M8 stability how much of a firm's visibility rests on a single engine M9 zero-answer risk queries where the engines name nobody 2026-09-23: the per-firm index, the tier groups, the own-domain scorer and the referral base rate were lifted out of main() into module-level functions so build_study500.py can IMPORT them for the published page data instead of reimplementing them. Nothing about the arithmetic changed; MARKET.txt regenerated byte-identical after the move. """ from __future__ import annotations import io import json import os import re import sys from collections import Counter, defaultdict sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import study500_findings as F # noqa: E402 (extraction rules live there, on purpose) HERE = os.path.dirname(os.path.abspath(__file__)) STORE = os.path.join(HERE, "study500", "answers.json") OUT = os.path.join(HERE, "study500", "MARKET%s.txt" % ("-consumer" if "--consumer" in sys.argv else "")) # Agent referral networks, kept separate from the portals (zillow/realtor/har) because they # are a different kind of intermediary: a portal listing is a by-product of being licensed, # a referral network is a matching business an agent opts into. # # The label is descriptive only. It groups domains that match agents with clients; it makes # no claim about any company's fees or ranking rules. REFERRAL_NETWORKS = { "fastexpert.com", "homelight.com", "effectiveagents.com", "agentpronto.com", "idealagent.com", "clever.com", "upnest.com", "referralexchange.com", "experience.com", "ratemyagent.com", } PORTALS = {"zillow.com", "har.com", "realtor.com", "homes.com", "redfin.com", "trulia.com", "movoto.com", "point2homes.com"} # National brands and franchises. A brand is a different product from an agent: the agent # cannot buy the brand's visibility, so mixing them understates how hard it is for a person. BRAND_PAT = re.compile( r"\b(compass|coldwell banker|sotheby|keller williams|re ?/?max|century 21|" r"berkshire hathaway|douglas elliman|better homes and gardens|christie s|" r"engel|corcoran|ebby halliday|john daugherty|greenwood king|martha turner|" r"perry homes|david weekley|toll brothers|lennar|dr horton|highland homes|" r"village builders|meritage|pulte|newmark|jpar|era |exp realty|opendoor)\b", re.I) def gini(counts): """Inequality of the mention distribution. 0 = every firm named equally, 1 = one firm takes everything. Reported because 'top 20 take 10.5%' reads like a flat market and it is not; the long tail is 4,000 firms with a single mention each.""" xs = sorted(counts) n = len(xs) if n == 0 or sum(xs) == 0: return 0.0 cum = 0 for i, x in enumerate(xs, 1): cum += i * x return (2 * cum) / (n * sum(xs)) - (n + 1) / n def load_rows(consumer: bool = False) -> tuple[list[dict], list[dict]]: """The raw answer rows, with F._AREAS primed and the geo filter applied. Returns (rows, clean). Every script that extracts names must go through this or reproduce both steps, because both were missed once and each cost a wrong number. """ store = json.load(io.open(STORE, encoding="utf-8")) rows = list(store["answers"].values()) if consumer: # Optional internal view: three surfaces, see CONSUMER in study500_findings. rows = [r for r in rows if r.get("engine") in F.CONSUMER] # F.named() rejects Houston area names as firm names by checking F._AREAS, and it is # main() that fills that set (study500_findings.py line 279). Importing the module does # not run main(), so without this line every area name leaks in as a "firm" and the two # scripts report different totals. This cost 52 phantom firms before it was caught. F._AREAS.update(str(r["area"]).lower() for r in rows if r.get("area")) # Same geo filter study500_findings.py uses (its line 288). Without this the two files # disagree by the 60 geo-flagged rows, and a published study cannot have two firm counts. clean = [r for r in rows if not r.get("geo_mismatch")] return rows, clean class FirmIndex: """The per-firm index every section reads. One object so the fields travel together.""" def __init__(self, clean: list[dict]) -> None: self.mentions = Counter() self.firm_areas = defaultdict(set) self.firm_engines = defaultdict(set) self.firm_intents = defaultdict(set) self.firm_rows = defaultdict(list) self.per_engine_firms = defaultdict(set) self.area_firms = defaultdict(set) self.zero_answers = [] # The raw names an answer produced, in order, per (engine, query_id). Kept so the # published CSV can list exactly what was counted and reproduce the mention total. self.row_names = {} # Counted EXACTLY as study500_findings.py does it (its lines 332-342): every occurrence # in the raw extraction, no de-duplication inside a single answer, no falsy filter. The # two scripts must not print two different firm counts for the same dataset. for r in clean: raw = F.named(r.get("answer_text") or "") self.row_names[(r.get("engine"), r.get("query_id"))] = raw if not raw: self.zero_answers.append(r) eng, area = r.get("engine"), r.get("area") tier = r.get("tier") or "generic" for n in raw: c = F.canon(n) self.mentions[c] += 1 if area: self.firm_areas[c].add(area) self.firm_engines[c].add(eng) self.firm_intents[c].add(tier) self.firm_rows[c].append(r) self.per_engine_firms[eng].add(c) if area: self.area_firms[area].add(c) @property def ranked(self) -> list[tuple[str, int]]: return self.mentions.most_common() def tier_groups(ranked: list[tuple[str, int]]) -> tuple[tuple[str, list[str]], ...]: """The four tiers M10 and M12 compare. Overlapping on purpose: 'top 50' sits inside 'named 2+ times'. They are exposure bands, not a partition.""" return (("top 50", [n for n, _ in ranked[:50]]), ("rank 51-500", [n for n, _ in ranked[50:500]]), ("named 2+ times", [n for n, c in ranked if c >= 2]), ("named exactly once", [n for n, c in ranked if c == 1])) # The lever question. When an engine names a firm, is it also citing that firm's OWN # site, or only citing portals and directories that happen to list them? The answer # decides what an agent should actually pay for. GENERIC = {"real", "estate", "group", "team", "realty", "homes", "properties", "company", "agent", "agents", "realtors", "realtor", "the", "and", "house", "home", "sells", "sold", "luxury", "international"} def own_domain_hit(firm: str, urls: list) -> bool: toks = [t for t in re.split(r"[^a-z0-9]+", firm.lower()) if t and t not in GENERIC] if not toks: return False joined = "".join(toks) for u in urls: d = str(u).lower().split("/")[0] if d.startswith("www."): d = d[4:] stem = d.rsplit(".", 1)[0].replace(".", "").replace("-", "") if len(joined) >= 6 and joined in stem: return True if len(toks) > 1 and len(toks[-1]) >= 5 and toks[-1] in stem: return True return False def own_domain_rates(grp: list[str], firm_rows: dict) -> tuple[float, float]: """(per-answer %, ever %) for a tier. Exposure control. "Did this firm's domain EVER get cited" rewards firms with more answers purely for having more chances, which would manufacture the gradient this section is testing for. So score each firm as the SHARE OF ITS OWN ANSWERS that cite its domain, then average those per-firm rates within the tier. """ rates, ever = [], 0 for n in grp: rows_n = firm_rows[n] h = sum(1 for r in rows_n if own_domain_hit(n, r.get("cited_urls") or [])) rates.append(h / max(1, len(rows_n))) if h: ever += 1 return sum(rates) / max(1, len(rates)) * 100, ever / max(1, len(grp)) * 100 def share_of_answers_citing(grp: list[str], domains: set, firm_rows: dict) -> float: rates = [] for n in grp: rows_n = firm_rows[n] h = sum(1 for r in rows_n if any(str(u).lower().strip() in domains for u in (r.get("cited_urls") or []))) rates.append(h / max(1, len(rows_n))) return sum(rates) / max(1, len(rates)) * 100 def referral_base_rate(clean: list[dict]) -> float: """BASE RATE, and it is the whole story of M12. Answers that cite a referral domain name far MORE firms than answers that do not, so a firm lands in one by chance far more often than 50/50. Without this line the tier gradient below reads as evidence that directory presence drives visibility. It is not. Measured 2026-09-23: pay-fed answers name 7.71 firms each, others 4.82, giving a 42.4% name-weighted base rate, and every tier below sits within a few points of it. This was reported to Abdul as a real finding before the control was run. Do not remove it.""" pay_named = sum(len(F.named(r.get("answer_text") or "")) for r in clean if any(str(u).lower().strip() in REFERRAL_NETWORKS for u in (r.get("cited_urls") or []))) all_named = sum(len(F.named(r.get("answer_text") or "")) for r in clean) return pay_named / max(1, all_named) * 100 def cite_counter(rows: list[dict]) -> Counter: """Citations as M5 counts them: every cited domain, once per listing, lowercased.""" cite = Counter() for r in rows: for u in (r.get("cited_urls") or []): cite[str(u).lower().strip()] += 1 return cite def intent_doors(clean: list[dict], firm_engines: dict) -> list[tuple]: """M7 rows: (tier, answers, distinct firms, firms per answer, solo-surface %).""" by_tier = defaultdict(list) for r in clean: by_tier[r.get("tier") or "generic"].append(r) out = [] for tier, rs in sorted(by_tier.items(), key=lambda x: -len(x[1])): fs = set() per = [] for r in rs: nm = {F.canon(n) for n in F.named(r.get("answer_text") or "")} per.append(len(nm)) fs |= nm solo = sum(1 for n in fs if len(firm_engines[n]) == 1) / max(1, len(fs)) * 100 out.append((tier, len(rs), len(fs), sum(per) / max(1, len(per)), solo)) return out def main() -> None: rows, clean = load_rows(consumer="--consumer" in sys.argv) # Rebuild the same per-firm index study500_findings.py uses. ix = FirmIndex(clean) mentions = ix.mentions firm_areas = ix.firm_areas firm_engines = ix.firm_engines firm_intents = ix.firm_intents firm_rows = ix.firm_rows per_engine_firms = ix.per_engine_firms area_firms = ix.area_firms zero_answers = ix.zero_answers L = [] def w(s=""): L.append(s) w("=" * 74) w("500-QUERY HOUSTON STUDY - MARKET ANALYSIS (second pass)") w("generated by study500_market.py, never hand-edited") w("=" * 74) w() # ---------------------------------------------------------------- M1 counts = list(mentions.values()) tot = sum(counts) ranked = mentions.most_common() w("M1. CONCENTRATION - how unequal is AI visibility?") w(" distinct firms named %d" % len(mentions)) w(" total mentions %d" % tot) w(" Gini coefficient %.3f" % gini(counts)) for pct in (1, 5, 10, 25): k = max(1, int(len(ranked) * pct / 100)) share = sum(c for _, c in ranked[:k]) / tot * 100 w(" top %2d%% of firms (%4d) take %5.1f%% of mentions" % (pct, k, share)) once = sum(1 for c in counts if c == 1) twice = sum(1 for c in counts if c <= 2) w(" named exactly once %d (%.1f%%)" % (once, once / len(mentions) * 100)) w(" named twice or fewer %d (%.1f%%)" % (twice, twice / len(mentions) * 100)) w(" median mentions per firm %d" % sorted(counts)[len(counts) // 2]) w() # ---------------------------------------------------------------- M2 # Denominators are sourced separately; this prints the ratio for each candidate so the # write-up can pick one and show its working rather than asserting a single number. w("M2. THE DENOMINATOR - what share of Houston's agents ever get named?") w(" firms the engines named at all %d" % len(mentions)) for label, denom in (("HAR membership ~50,000", 50000), ("HAR membership ~47,000", 47000), ("if 30,000 active agents", 30000)): w(" vs %-28s %5.1f%% named at least once" % (label, len(mentions) / denom * 100)) w(" NOTE: denominator must be sourced before publication. The point survives any of") w(" them: the overwhelming majority of licensed agents are never named once.") w() # ---------------------------------------------------------------- M3 w("M3. WHAT THE MOST-NAMED FIRMS HAVE IN COMMON (from the data alone)") w(" %-38s %5s %6s %7s %7s" % ("firm", "ment", "areas", "engines", "intents")) for n, c in ranked[:25]: w(" %-38s %5d %6d %7d %7d" % (n[:38], c, len(firm_areas[n]), len(firm_engines[n]), len(firm_intents[n]))) top50 = [n for n, _ in ranked[:50]] rest = [n for n, _ in ranked[50:]] def avg(seq, fn): return sum(fn(x) for x in seq) / max(1, len(seq)) w() w(" top 50 firms: mean areas %.1f | mean engines %.2f | mean intents %.2f" % (avg(top50, lambda n: len(firm_areas[n])), avg(top50, lambda n: len(firm_engines[n])), avg(top50, lambda n: len(firm_intents[n])))) w(" everyone else: mean areas %.1f | mean engines %.2f | mean intents %.2f" % (avg(rest, lambda n: len(firm_areas[n])), avg(rest, lambda n: len(firm_engines[n])), avg(rest, lambda n: len(firm_intents[n])))) w() # ---------------------------------------------------------------- M4 brands = [n for n in mentions if BRAND_PAT.search(n)] people = [n for n in mentions if not BRAND_PAT.search(n)] w("M4. NATIONAL BRAND vs INDIVIDUAL - are they playing the same game?") for label, grp in (("brand/franchise", brands), ("individual/local", people)): if not grp: continue m = sum(mentions[n] for n in grp) w(" %-18s firms %5d | mentions %6d (%4.1f%%) | mean areas %5.2f | mean engines %.2f" % (label, len(grp), m, m / tot * 100, avg(grp, lambda n: len(firm_areas[n])), avg(grp, lambda n: len(firm_engines[n])))) multi_b = sum(1 for n in brands if len(firm_areas[n]) >= 5) multi_p = sum(1 for n in people if len(firm_areas[n]) >= 5) w(" reaching 5+ areas: brands %d of %d (%.1f%%) | individuals %d of %d (%.1f%%)" % (multi_b, len(brands), multi_b / max(1, len(brands)) * 100, multi_p, len(people), multi_p / max(1, len(people)) * 100)) w() # ---------------------------------------------------------------- M5 cite = cite_counter(clean) ctot = sum(cite.values()) pay = sum(c for d, c in cite.items() if d in REFERRAL_NETWORKS) port = sum(c for d, c in cite.items() if d in PORTALS) w("M5. THE CITATION LAYER - portals, referral networks and everything else") w(" citations total %d" % ctot) w(" portals (zillow/har/realtor..) %d (%.1f%%)" % (port, port / ctot * 100)) w(" agent referral networks %d (%.1f%%)" % (pay, pay / ctot * 100)) w(" everything else %d (%.1f%%)" % (ctot - port - pay, (ctot - port - pay) / ctot * 100)) w(" referral-network domains seen:") for d, c in sorted(((d, c) for d, c in cite.items() if d in REFERRAL_NETWORKS), key=lambda x: -x[1]): w(" %5d %s (%.1f%%)" % (c, d, c / ctot * 100)) w(" per-engine share going to portals and agent referral networks:") for eng in sorted({r["engine"] for r in clean}): ec = cite_counter([r for r in clean if r["engine"] == eng]) et = sum(ec.values()) if et: ep = sum(c for d, c in ec.items() if d in REFERRAL_NETWORKS) epo = sum(c for d, c in ec.items() if d in PORTALS) w(" %-12s citations %5d | portal %5.1f%% | referral %5.1f%%" % (eng, et, epo / et * 100, ep / et * 100)) w() # ---------------------------------------------------------------- M6 w("M6. AREA COMPETITION - where is the door still open?") rows_by_area = Counter(r["area"] for r in clean) scored = [] for a, firms in area_firms.items(): scored.append((len(firms), rows_by_area[a], a)) scored.sort() w(" thinnest fields (fewest distinct firms ever named):") for n, q, a in scored[:10]: w(" %4d firms across %3d answers %s" % (n, q, a)) w(" most crowded:") for n, q, a in scored[-10:][::-1]: w(" %4d firms across %3d answers %s" % (n, q, a)) w() # ---------------------------------------------------------------- M7 w("M7. INTENT DOORS - which question is the cheapest to win?") w(" %-12s %8s %8s %10s %12s" % ("intent", "answers", "firms", "firms/ans", "solo-engine%")) for tier, n_ans, n_firms, per_ans, solo in intent_doors(clean, firm_engines): w(" %-12s %8d %8d %10.1f %11.1f%%" % (tier, n_ans, n_firms, per_ans, solo)) w() # ---------------------------------------------------------------- M8 w("M8. HOW FRAGILE IS A FIRM'S VISIBILITY?") for k in (1, 2, 3, 4): n = sum(1 for f in mentions if len(firm_engines[f]) == k) w(" named by exactly %d surface(s) %5d (%.1f%%)" % (k, n, n / len(mentions) * 100)) single_area_single_engine = sum( 1 for f in mentions if len(firm_engines[f]) == 1 and len(firm_areas[f]) == 1) w(" ONE area AND ONE surface %5d (%.1f%%) <- one engine update from zero" % (single_area_single_engine, single_area_single_engine / len(mentions) * 100)) w(" distinct firms per surface:") for eng, fs in sorted(per_engine_firms.items()): w(" %-12s %5d" % (eng, len(fs))) w() # ---------------------------------------------------------------- M9 w("M9. ANSWERS THAT NAME NOBODY") w(" zero-name answers %d (%.1f%% of geo-clean)" % (len(zero_answers), len(zero_answers) / len(clean) * 100)) w(" by surface:") for eng, c in Counter(r["engine"] for r in zero_answers).most_common(): tot_e = sum(1 for r in clean if r["engine"] == eng) w(" %-12s %4d of %4d (%.1f%%)" % (eng, c, tot_e, c / tot_e * 100)) w(" by intent:") for t, c in Counter(r.get("tier") or "generic" for r in zero_answers).most_common(): tot_t = sum(1 for r in clean if (r.get("tier") or "generic") == t) w(" %-12s %4d of %4d (%.1f%%)" % (t, c, tot_t, c / tot_t * 100)) w() # ---------------------------------------------------------------- M10 w("M10. IS THE ENGINE READING THE FIRM'S OWN SITE?") w(" share of firms whose OWN domain appears in the citations of an answer naming them") w(" %-22s %7s %11s %11s" % ("tier", "firms", "per-answer", "ever")) for label, grp in tier_groups(ranked): if not grp: continue per_answer, ever = own_domain_rates(grp, firm_rows) w(" %-22s %7d %10.1f%% %10.1f%%" % (label, len(grp), per_answer, ever)) w() w("M11. THE SUPPORT STRUCTURE - what is cited alongside a winner vs the tail") for label, grp in (("top 50 firms", [n for n, _ in ranked[:50]]), ("single-mention firms", [n for n, c in ranked if c == 1])): cc = Counter() for n in grp: for r in firm_rows[n]: for u in (r.get("cited_urls") or []): cc[str(u).lower().strip()] += 1 t = sum(cc.values()) or 1 w(" %s (%d citations)" % (label, t)) for d, n in cc.most_common(8): tag = "referral" if d in REFERRAL_NETWORKS else ("portal" if d in PORTALS else "") w(" %5.1f%% %-28s %s" % (n / t * 100, d, tag)) w() # ---------------------------------------------------------------- M12 # M10 found that owning a cited domain does NOT separate winners from the tail once # exposure is controlled. M11 suggested the separation is in the DIRECTORY layer # instead. This tests that properly, on the same per-answer basis as M10, so the two # are comparable and neither is a pool-size artefact. base_pay = referral_base_rate(clean) w("M12. PER-ANSWER, WHAT SURROUNDS A WINNER vs THE TAIL?") w(" same exposure control as M10: per-firm rate first, then averaged in the tier") w(" >>> BASE RATE for referral networks: %.1f%%. Answers citing one name more firms, so a" % base_pay) w(" >>> firm lands in one by chance at that rate. Read the column as DEVIATION, and") w(" >>> note every tier is within a few points. This is NOT evidence of a lever.") w(" %-22s %7s %12s %12s %12s" % ("tier", "firms", "referral net", "portal", "own domain")) for label, grp in tier_groups(ranked): own_pct, _ = own_domain_rates(grp, firm_rows) pay_r = share_of_answers_citing(grp, REFERRAL_NETWORKS, firm_rows) w(" %-22s %7d %11.1f%% %11.1f%% %11.1f%% (vs base %+.1f pts)" % (label, len(grp), pay_r, share_of_answers_citing(grp, PORTALS, firm_rows), own_pct, pay_r - base_pay)) w() w("=" * 74) text = "\n".join(L) io.open(OUT, "w", encoding="utf-8", newline="\n").write(text) print(text) if __name__ == "__main__": main()