Compset Discovery Audit: Compsets > My Compset

Generated 2026-08-14 · RevParPro app.revparpro.com · engine, frontend, and live data audited by three parallel review agents plus direct verification · repo ~/revparpro, production = origin/main @ 1c6955f5

The plumbing is healthy, the judgment layer has real defects. Every property gets a fresh discovery result daily, the data payloads are intact, and today's new Texas stay-mix engine ran and is live in the UI. But the audit found 1 critical infrastructure hole (the app compiles to production with zero type checking), 9 major defects, and a tail of minor ones. The two worst product defects share one theme: hotels with missing data outscore hotels with honest unfavorable data, which is the exact opposite of the engine's own written rule, and errors and unscored hotels are displayed as if they were real zeros.

1 critical 9 major 14 minor 10 areas verified clean

Critical

C1The app ships to production with zero type checking; the safety net is a no-opinfra

The build command runs tsc --noEmit, but the root tsconfig.json is a solution-style file with "files": [], so that command checks zero files and always passes. The real check, tsc -p tsconfig.app.json --noEmit, currently reports 235 errors on the working branch, including 2 in DiscoveryTab.tsx and 14 in useCompsets.ts. The same no-op config is on deployed main, so CI has been green while type errors ship. A comment inside DiscoveryTab.tsx documents two production crashes (ReferenceErrors) that this exact hole already let through.

tsconfig.json ("files": [], same on origin/main); package.json:8; tsc -p tsconfig.app.json --noEmit run 2026-08-14 = 235 errors (branch str-collection-pipeline); prior incidents referenced in DiscoveryTab.tsx:226-232 (commits 478f6b9, 7d2c901)
Fix: point the build at tsc --build (or -p tsconfig.app.json), then burn down the 235 errors. Until then every "biome passed, tsc passed" claim in CI is theater.

Major: scoring engine

E1Missing data outscores honest unfavorable data, on two scoring bricksengine

The scoring library's own header states the invariant: missing data must never outscore honest unfavorable data on the same brick. It is violated twice. Under the lender persona, a candidate with an unknown chain scale scores 5 points while an honest one-tier-off comp scores 4. On the new stay-mix brick, a candidate with no stay data scores 10 points while an honest large-mismatch operator (0.28 non-tax-share gap) scores 8. Both were confirmed by executing the deployed scoring functions directly. Effect: hotels with data holes rank above honestly mismatched hotels and can flip who makes the recommended core. Roughly 4.6% of the candidate pool (193 of 4,232 STR summary rows) rides the missing-data default today.

supabase/functions/_shared/compset_scoring.mjs:435 vs 443-444 (chain), :66,71,360 (stay-mix); invariant at :26-27; executed via node import 2026-08-14 (5 vs 4 pts, 10 vs 8 pts); pool counts from str_hotel_summary REST queries 2026-08-14 (4,039 of 4,232 pass the stay filter)
Fix: cap every unknown-data fallback at or below the worst honest score on that brick (lender chain unknown ≤ 4, stay-mix insufficient ≤ 8). Same treatment for the older vintage variant (unknown 3 > honest 0).
E2A live compset member was silently dropped from scoring and rendered as a zeroenginedata

On the live HOUZN page, current member Staybridge Suites Houston Willowbrook shows score 0/100, no rank, 0.00 mi, and the new stay strip counts it as "1 unknown". The engine never scored it: its STR summary row is marked status: inactive, which excludes it from the candidate universe, even though it filed 12 consecutive months of receipts through 2026-06 totaling $2,108,158 TTM. The wrong status is almost certainly an ownership-transition artifact (its merged hotel rows carry two different taxpayer numbers). Three separate lies land on screen from one root cause: a fake zero score, a fake 0.00 mi distance, and a footer count ("7 currently in compset") that contradicts the members table ("8 peers").

live page app.revparpro.com/app/compsets/b8d85b28-f16b-453c-a3e1-29bcaedb504d?tab=discovery, loaded 2026-08-14; str_hotel_summary str_number 54112 (status inactive, ttm_months_covered 12, ttm_end_period 2026-06, ttm_total_receipts 2,108,157.69); hotels rows for str_id 54112 (taxpayer 32069485665 on merged row vs 32089948155 on canonical); HOUZN compset_discovery_cache latest row contains no Staybridge Willowbrook candidate
Fix: (1) always score current members regardless of universe filters, or mark them "not scored" with the reason; (2) derive summary status from any recently filing taxpayer, not just the original; (3) never render sentinel zeros as measurements.
E3The nightly batch and the deal pipeline now score the same hotel differentlyengine

Today's stay-mix commit changed the batch pipeline to classify product type from brand_name || canonical_name, but the deal-compset edge function (which imports the same "single source of truth" library and carries a "keep in sync" comment) still passes brand_name only. A hotel with a null brand but a brand-bearing name (e.g. "Homewood Suites ...") scores the full 15-point product brick in the nightly batch and 0 in the deal path.

scripts/precompute-discovery.mjs:1004,1015 vs supabase/functions/deal-compset/index.ts:200-205,372-377; introduced in the stay-mix commit (93d32d2c on main, 5a0bc467 on branch, both 2026-08-14)
Fix: move the brand-or-name fallback into the shared library so both callers get it for free.
E4Lender band-seek additions bypass STR compliance, and the compliance badge is computed before they are addedengine

The lender persona can append up to 2 members to push RGI into the 105-125 band. Those additions are checked for tract, size, correlation, value, and tier, but not for the STR concentration rules (single property ≤ 50%, brand ≤ 50%, parent ≤ 70%). Worse, the cached compliance_by_persona is snapshotted before band-seek runs, so the UI can show a green "all five rules pass" badge on a lender set that violates rule 3.

scripts/precompute-discovery.mjs:1306-1319 (compliance capture) vs :1397-1404 (band-seek append); guard list in compset_scoring.mjs:1190-1206
Fix: re-run the compliance gates on the post-band-seek core and cache that result.
E5The drift alert overstates drift because 16 of 18 compsets contain the subject hotel as its own memberdata

The STR upload reconcile writes the subject hotel into its own compset as an active member. The dashboard drift metric counts all active members, but the engine excludes the subject from its universe, so the subject can never be a "top-7 pick" and every self-including compset takes an automatic drift hit. Recomputed both ways from live data: HOURP reads 13% drift but is truly 0%; CFDBR and CLLUD read 40% vs a true 25%; CLLSP 33% vs 20%; HOUZN 33% vs 25%. Four properties wear a drift badge one severity band too alarming.

src/hooks/useDiscovery.ts:254-304 (useCompsetDrift); subject exclusion at scripts/precompute-discovery.mjs:994; self-membership count and drift recompute from live compset_members + compset_discovery_cache (audit scripts in session scratchpad, run 2026-08-14); self-row acknowledged in useCompsets.ts:1463-1469
Fix: exclude the subject row in useCompsetDrift and in the list page member counts (one filter each), then decide whether reconcile should write it at all.

Major: frontend all four verified present on deployed main

F1Query failures masquerade as "no data yet"frontend

The Discovery tab has loading and empty branches but no error branch. Any RLS, network, or PostgREST failure renders "Discovery has not been computed for X yet ... appears within a day", a false factual claim. The compset list page does the same: a metrics query error silently renders every RGI/MPI/ARI as a dash, indistinguishable from genuinely missing data.

origin/main DiscoveryTab.tsx (empty-state copy at line 697, no isError branch); Compsets.tsx:240-244 equivalent on branch, metrics error path verified by frontend audit agent
Fix: add an isError branch to both surfaces that says a load failed and offers retry.
F2Add / Watchlist buttons crash on any previously dropped member, and dropped members can never be re-addedfrontend

The Add and Watchlist mutations do bare INSERTs into compset_members, which has a unique constraint on (compset_id, hotel_id). Hotels dropped by an earlier STR reconcile still have a row (status "watch", reason "dropped from STR upload"), and they render as suggested cards with live Add buttons. One click surfaces a raw Postgres duplicate-key error in the toast. Freshly staged adds have the same trap: the overlay only fetches active and watch rows, so a hotel just staged with status "add" keeps offering "+ Add", and the second click hits the same crash.

origin/main src/hooks/useDiscovery.ts:329 (.insert); unique constraint in migration 084:25-36; overlay filter DiscoveryTab.tsx:246,253; live probe found reconcile-stamped watch rows (frontend audit agent, 2026-08-14)
Fix: upsert with onConflict: (compset_id, hotel_id) updating status, and include staged statuses in the overlay so buttons reflect reality.
F3Three different numbers on the page are all labeled "RGI"frontend

On the live HOUZN view, the persona pills read "RGI 108", which is the recommended set's implied RGI, with no qualifier on screen. The swap line says "your index vs the set 112 → 108 (tax-receipt basis)", the current set. The list page says RGI 112.3, from the Jun 2026 STAR report. Same word, three bases, only one of them labeled. An owner glancing at the pills reads their RGI as 4 points lower than it is.

live page 2026-08-14 (pills 119/108/108, swap 112 → 108, list 112.3 from "Jun 2026 STAR Report · uploaded Jul 19"); origin/main DiscoveryTab.tsx:728 (impliedRgi = cache.implied_rgi_by_persona), PersonaToggle.tsx:63-68 (no on-screen qualifier)
Fix: label the pills ("recommended set RGI") and put the basis (STAR vs tax-receipt) next to every index on the page.
F4Add / Watchlist write to "newest compset for this property", not the compset being viewedfrontend

The Discovery tab receives the viewed compset's id but ignores it; mutations resolve the target as the most recent compset for the property code, while the membership badges read the viewed compset. Today no subject has two compsets so it works by coincidence; the day one does (e.g. a deal compset alongside the operating one), adds land in the wrong set with no error.

src/hooks/useDiscovery.ts:29-49,323; DiscoveryTab.tsx:118-134 and unused compsetId at :889-892; live probe: 0 subjects currently have multiple compsets (frontend audit agent, 2026-08-14)
Fix: thread the viewed compsetId into the mutations. Two-line change now, silent data corruption later.

Minor findings 14

DefectDetail and source
Verdict band ignores the persona toggleRecall ("6 of 8 STR peers in the top 7") is computed once on the balanced ranking; under Owner or Lender the band still claims it against a different visible list. Also latent: with more than 10 STR peers the 0.7 recall@7 threshold is mathematically unreachable, and the hits number is reconstructed as round(fraction × peers) with no guard against pre-migration-253 integer rows. DiscoveryTab.tsx:491-501; precompute-discovery.mjs:1282; migration 253
Exactly-100 RGI renders as good but is not counted as goodThe color threshold is ≥ 100 on the raw value while "Above Fair Share" counts > 100, and formatting rounds: 99.95 displays "100.0" in amber. origin/main Compsets.tsx:25-27,451
List-page Scan Now inserts an empty job and swallows errorsLegacy call path creates a rate_scan_jobs row with hotel_ids [] and total_targets 0, and the rejection ("another scan running") is discarded, so the button gives no feedback. Compsets.tsx:164; useCompsets.ts:631-657
Detail-page scans pay for non-active membersScan dispatch builds hotel ids from ALL member statuses (watch/add/evaluate) while displaying actives only: paid DataForSEO scans for watchlisted hotels. Doc comment says nights capped at 30, code caps at 90. CompsetDetail.tsx:97,143; RateCalendarTab.tsx:301-305; useCompsets.ts:627,632
Stay-mix market profiles include dead hotelsThe tract profile pool filters only coverage and span, not status or recency: 47 closed and 24 inactive hotels plus 78 rows whose TTM ended before 2025-08 feed the market bands (the 71 dead-status rows alone are 1.8% of the 4,039 eligible rows; a concentrated tract could tilt further). precompute-discovery.mjs:838-898 (loadTexasStayContext); str_hotel_summary REST counts 2026-08-14
Shared-filing stay data keeps full weightMedium-confidence (shared taxpayer filing) stay shares score identically to high-confidence ones, flag only; and shared-filing detection misses dual-brand pairs where one tower lacks a complete TTM row (suspected, not traced live). compset_scoring.mjs:369-373; precompute-discovery.mjs:858-869; is_dual_brand exists (mig 098) and is not consulted
Role gating is default-openchief_engineer, comptroller, bookkeeper, analyst, and staff fall through to the default tab set and see MORE than a GM sees (Discovery, Parity, Alerts). Policy question rather than bug, but worth an explicit decision. CompsetDetail.tsx:47-64; roles in nav-config.ts:20-32
Generated DB types are stalesrc/types/database.ts says compset_members.status_reason and hotel_tax_summary.tract do not exist; live probes confirm both exist. Last regenerated at commit ffa393c0. Several of the 235 type errors trace here. frontend audit agent live probes 2026-08-14; database.ts:402-432
Discovery cache grows without boundInsert-only table, 1,728 rows at +15/day, large JSON payloads, and the UI only ever reads the newest row per property. No pruning exists. compset_discovery_cache full scan 2026-08-14; write path precompute-discovery.mjs:1771
Test residue live in prod"RLS Test Compset Alpha" (null subject, 1 member) is an active compset counted by any unfiltered query. compsets scan 2026-08-14 (data audit agent)
HOUZV is blind on STR recallstr_peer_count is null in its latest cache row: no STR upload exists for it, so the recall scoreboard and STR-peer filter are empty for that property. compset_discovery_cache HOUZV latest row; str_uploads (data audit agent 2026-08-14)
Dead code in the discovery surfaceDiscoveryCandidatesTable, CompsetDiffView, PerformancePanel, useStrSummary, and the "Dropped YYYY-MM" pill logic are imported or computed by nothing. discovery-v4.mjs is dead legacy that writes no tables (verified: zero insert/update calls, no scheduler references). import graph + scheduler sweep (both audit agents, 2026-08-14)
INNRLY folder override map is dead config, flipped differently on each branchLive property codes are DALDL, DFWZY, DFWXF and the Drive folders carry the same names, so no override ever fires; the branch and main carry mutually inverted key/value pairs of codes that do not exist. The two audit agents disagreed on direction; live hotels query plus a Drive folder listing settled it as harmless on both. hotels REST query + ls of "STAR Reports for Innrly Config" 2026-08-14; precompute-discovery.mjs:195 branch vs main
Process: today's live results were computed from uncommitted codeThe 15 live v6.3 rows were written 10:15-10:16 CDT; the engine commit landed 10:17:47 CDT. Content matches what was committed, but for two minutes production data had no provable source revision. First cron-produced v6.3 rows arrive with the 2026-08-15 05:30 UTC run. compset_discovery_cache computed_at vs git log 93d32d2c

What checked out clean

How discovery works (30-second map)

  1. Nightly at 05:30 UTC, GitHub Actions runs scripts/precompute-discovery.mjs from main for all 15 properties.
  2. For each property it pulls every Texas hotel within a 10-mile box, enriches each with tax-receipt history, trailing rate scans, tract, and (new today) a stay profile derived from hotel-tax exemptions (long stays are tax exempt, so the non-taxable share of receipts reveals extended-stay behavior).
  3. Each candidate is scored 0-100 under three personas (spatial 25, chain 15, product+stay 15, correlation 15, rooms 10, rate position 10, vintage 10), a recommended core is assembled under STR compliance rules, and one row per property is written to compset_discovery_cache.
  4. The My Compset tab reads the newest row: verdict band, stay profile strip, fingerprint, current members ranked on the same scale, suggested adds, and the persona toggle.
engine audit agent map, verified against .github/workflows/precompute-discovery.yml and the live HOUZN page footer ("Computed today · algorithm v6.3-2026-08-14 · 130 hotels considered")

Recommended fix order