RevParPro : Compset Discovery

Competitor Recommendations Audit: Lender / Balanced / Owner

Generated 2026-07-15 · Source: revparpro repo (engine scripts/precompute-discovery.mjs, v5.0-2026-05-10) · 49-agent adversarial audit (4 mappers, 5 critics, 40 verification passes), all file:line anchors verified against code
40
raw findings, 0 refuted under adversarial verification (this audit run)
29
unique holes after dedupe (this report)
2
critical: open learning loop, no proof metric
35/100
points that actually change between personas (scoreForPersona :575-606)
The one-paragraph verdict. The v1 scaffolding is genuinely good: continuous distance decay, a faithful implementation of STR's published 50/50/70 compset rules, per-persona caching, and structured feedback capture. But the "secret sauce" claims are ahead of the machinery. The system collects operator feedback and never reads it, dropped the only accuracy metric it ever had (Recall vs the real STR peer set), runs a different, older scoring engine inside deal underwriting than in the Discovery tab, and silently loses its STR ground-truth enrichment every night because the cron box cannot see a Mac-only Google Drive mount. Meanwhile the lender persona is tuned to flatter the subject (down-tier, low-RevPAR comps), which is the opposite of what survives an appraisal. None of these are hard fixes. All of them are the difference between a scoring heuristic and a defensible moat.

Contents

1. How it scores today 2. The open learning loop (critical) 3. No proof it works (critical) 4. Persona design holes 5. Two engines disagree 6. Data-quality holes 7. Silent infrastructure failures 8. Correctness bugs 9. What is already right 10. Prioritized roadmap 11. Decision log (2026-07-15) 12. Scale architecture

1. How it scores today

Every candidate inside a 10-mile / Texas-only universe gets 0-100 points across five components (weights from the engine header, precompute-discovery.mjs:10, enforced at :401-511):

Distance (exp decay)
35 pts · same for all personas
Chain scale
25 pts · persona-directional
Product type
20 pts · same for all personas
Rooms
10 pts · same
"ADR spread" (really RevPAR)
10 pts · persona-directional

Blue = persona-sensitive. Gray = identical across lender / balanced / owner. Only 35 of 100 points move when the persona toggles (scoreForPersona, :575-606).

PersonaChain scale (25 pts)RevPAR spread (10 pts)Intent
LenderFull credit for same or one tier DOWN (:445-449)Rewards candidates 25-50%+ BELOW subject RevPAR (:470-476)Comps that flatter the subject
BalancedFull credit for exact tier match (:440-443)Rewards proximity, |delta| within 25% (:465-468)Daily operating set
OwnerFull credit for same or one tier UP (:451-454)Rewards candidates 25-50%+ ABOVE subject (:478-483)Aspirational set

A post-processor then applies STR-style compliance gates to the top 14 (rules: at least 4 members, at least 3 unaffiliated, no property over 50% of set rooms, no brand over 50%, no parent over 70%; applyComplianceGates :630-814), demoting the lowest-scored offender and pulling from the tail. Results are cached per persona so the toggle is instant.

2. The open learning loop critical

Accepts, rejects, and the 7-reason feedback form never touch the model

The UI collects three high-signal human labels: Add (writes compset_members with source='discovery'), promote-to-active, and a structured rejection form stamped with algorithm_version. The engine reads none of them. precompute-discovery.mjs contains zero references to compset_discovery_feedback; every nightly run rescores from the same static weights. The toast at DiscoveryTab.tsx:682 ("Feedback recorded for the next algorithm pass") is aspirational copy: nothing reads that table except a migration verifier. For the feature you call the secret sauce, the sauce never thickens.

Anchors: src/hooks/useDiscovery.ts:373 · DiscoveryTab.tsx:682 · verified: no consumer of compset_discovery_feedback outside scripts/apply-mig-133.mjs
Fix in 3 cheap stages: (1) at precompute time, read feedback rows and suppress rejected candidates per subject: hard-exclude for reasons like wrong_brand / already_excluded, score decay (roughly -15 pts halving each 90 days) for softer reasons; (2) treat promote-to-active as an acceptance prior; (3) only then consider weight learning. Stage 1 is a day of work and instantly makes the loop real.

Reject is a UX no-op, and staged adds stay invisible

Reject only opens the feedback dialog (DiscoveryTab.tsx:466): no member write, no local hide, no exclusion list, so the candidate reappears at the same rank immediately. The IN COMPSET badge counts only status='active' rows (:215) and neither staging mutation invalidates the overlay query, so a just-Added candidate keeps its Add button; a second click hits the UNIQUE(compset_id, hotel_id) constraint (migration 084:32-35) and surfaces as a confusing "Could not stage candidate" toast. Users learn the algorithm does not listen, which suppresses exactly the labeling you need for the learning loop.

Anchors: DiscoveryTab.tsx:466, 215 · useDiscovery.ts:319-328
Fix: move rejected candidates to a collapsed "Dismissed (n)" section (persist by reading the feedback table for the subject); show STAGED / WATCHING badges for non-active statuses; invalidate the overlay key in both mutations' onSuccess.

3. No proof it works critical

The one accuracy metric (Recall@7/14 vs the real STR peer set) was dropped, and the UI renders its dead columns

v4 computed recall against the hotel's actual STR-file competitive set: the only ground truth the system has. v5 never writes it (cache insert at precompute-discovery.mjs:1309-1324 omits str_peer_count / recall_at_7 / recall_at_14; migration 133 columns sit empty). The UI still reads them: DiscoveryTab.tsx:537-539 passes fields that the hook (useDiscovery.ts:148-164) never hydrates, so the headline proof tiles render blank. These are real TS2339 type errors that ship because the root tsconfig.json is solution-style ("files": []), so CI's tsc --noEmit type-checks zero files and exits green. The recall backtest harness that does exist (scripts/discovery-portfolio-recall.mjs) is hardwired to the retired v4 engine.

Anchors: precompute-discovery.mjs:1309 · DiscoveryTab.tsx:537-539 · 133_compset_discovery_tables.sql:23-25
Fix: restore recall@7/14 as a nightly-written metric and trend it per property: that becomes the scoreboard for every future algorithm change. Also make the CI typecheck actually check (build-mode tsc, drop continue-on-error): it would have caught three of the bugs in this report.

Weights are unvalidated priors; there is no calibration loop at all

The 35/25/20/10/10 split, the adjacency map values, and the persona step thresholds are hand-set with no backtest against anything. Nothing measures whether lender sets survive appraisals, whether balanced sets track subject RevPAR, or whether operators keep the recommendations.

Anchor: precompute-discovery.mjs:39 (ALGORITHM_VERSION, no eval harness for v5)
Fix: once recall is restored and STR peers live in a DB table, grid-search weights against two objectives: agreement with operator-curated active compsets, and realized receipts-RevPAR correlation of chosen sets. Feedback rows become labeled training data.

4. Persona design holes

The lender persona optimizes for the wrong outcome

Lender scoring rewards same-or-one-tier-DOWN comps (:447) and gives max points to candidates with RevPAR 50%+ below the subject (:472). That set produces an inflated RGI that a third-party appraiser or the lender's own STR pull will reject, costing borrower credibility. What lenders actually reward is conservative, defensible similarity: exact scale, same product type, tight radius, stabilized peers. Your own memory canon says lenders see the KeyCard/KeyBook only because credibility with lenders is guarded; this persona quietly works against that.

Anchors: precompute-discovery.mjs:445-449, 470-476
Fix: redefine lender as the STRICTEST persona (exact scale match, hard product-type match, tighter radius, RevPAR-proximity like balanced). If a flattering set is still wanted, name it honestly: a "pitch" persona.

Personas are shallow presets, and the highest-value personas for THM are missing

Only 35/100 points are persona-sensitive, so lender-vs-owner is a modest re-tilt. Meanwhile the personas THM actually needs do not exist: a tax-appeal set for TX assessments (the tax data and useCompsetTaxBenchmarks already exist), an appraiser/HVS-style set, and a franchise/PIP-negotiation set.

Anchor: scoreForPersona precompute-discovery.mjs:575-606
Fix: make weights a config-table parameter with directional flags; render today's three personas as presets; add tax-appeal and appraiser presets cheaply; version the weight vector into the cache row so results stay reproducible.

Distance (the biggest factor, 35 pts) uses one hardcoded suburban decay for every market

The 3-mile half-life is applied to all subjects; urban (1 mi) and rural (5 mi) betas are dead code behind a "TODO v2" (:500-511). In downtown Houston or Dallas a hotel 3 miles away, often a different demand node entirely (airport vs medical center vs CBD), still earns 17.5/35. Since distance is persona-invariant and the largest weight, this single miscalibration dominates urban rankings more than any persona toggle. There is also no demand-generator concept anywhere in the model.

Anchors: precompute-discovery.mjs:500-511; v4's far-distance penalty also dropped
Fix: activate density-based beta now with a cheap proxy (candidate count within 2 miles, or a CBSA urban flag). Add a demand-node tag (airport / CBD / medical / highway / convention) and score node match.

5. Two engines disagree high

Deal underwriting still scores with v4; the Discovery tab scores with v5

deal-compset runs verbatim v4 weights (stepped distance 30, tax-receipt correlation 25, scale 15, rooms 10, type 10, different-parent +10, far-distance penalty) while discovery runs v5 (35/25/20/10/10, exponential decay, no correlation). Its header pledges sync with discovery-v4.mjs, which it honors, but v4 is retired: the pledge points at a corpse. The same hotel can rank top-3 in DealCompsetView's algo cohort and mid-pack in the Discovery tab for the same subject, and deal-compset's rooms-weighted cohort RevPAR drives the deal page's headline numbers. deal_compset_cache carries no algorithm_version stamp, so the divergence is invisible.

Anchors: supabase/functions/deal-compset/index.ts:162-182, 372 vs precompute-discovery.mjs v5
Fix: extract one shared scoring module (or have deal-compset consume compset_discovery_cache for portfolio subjects) and stamp algorithm_version into deal_compset_cache.

The strongest comp signal the system ever had (receipts correlation) was dropped from discovery but kept in deals

v4 scored month-over-month tax-receipt correlation at 25 pts: the one signal that statistically demonstrates two hotels share demand (seasonality, group cycles, compression). v5 dropped it, yet still calls getReceiptsSeries for the subject and discards the result (:1132-1134). It is TX-native, matching the current TX-only footprint, and the fetch code already exists.

Anchors: precompute-discovery.mjs:1132-1134 · correlation math at deal-compset/index.ts:228-240, tiers at :175-182
Fix: reinstate correlation for TX subjects as a 10-15 pt dimension where 12+ overlapping months exist (neutral otherwise), or at minimum as a validator badge on each candidate card.

6. Data-quality holes

Missing data outscores honest bad data, across three separate components

(a) Missing RevPAR on either side returns a neutral 5/10 for every persona (:461): a non-filer or unlinked hotel beats a real candidate whose RevPAR points the "wrong" way (0-2 pts). (b) Unknown chain scale falls back to Independent rank 0 via ?? 0 (:437-438), so a candidate with null scale can collect the full 25 against an Independent or null-scale subject. (c) v4's noise penalties (tiny room counts, no str_id, person-name taxpayers, rental keywords) were all dropped. Net effect: a hotel row with nothing but coordinates and a name can score roughly 65/100 and outrank fully documented comps. The card UI renders a missing-data 5 identically to an earned 5; no confidence signal exists anywhere.

Anchors: precompute-discovery.mjs:461, 437-438; v4 penalties at discovery-v4.mjs
Fix: score missing data strictly below any known-comparable value (2-3 pts with an "unknown" marker), treat null chain scale as unknown (roughly 8 pts, flagged), and emit a per-candidate data_completeness field so cards can show a confidence badge.

Closed and dark hotels are never filtered and score competitively

pullUniverse filters only merged / tax-only / TX / bbox (:922-938). The system elsewhere knows about dead hotels (str_hotel_summary.status = closed / inactive; the dark-hotel detector in migration 238) but discovery never checks. A shuttered property half a mile away ranks top-14, passes compliance, and can be staged into a real compset with one click.

Anchors: precompute-discovery.mjs:922, 1008 · 238_dark_compset_hotel_detector.sql
Fix: join summary status in pullUniverse and hard-exclude closed/inactive, or carry the status into the cache and render a blocking "possibly closed" badge.

RevPAR comparisons mix unaligned time windows with no coverage check

The TTM feeding discovery is "last 12 distinct filed report_periods per str_number" (migration 118 mv_str_ttm_revenue): per-hotel sliding windows. The loader fetches ttm_months_covered but uses it only to break duplicate-row ties (:1018-1020), never to enforce minimum coverage or window overlap. A 3-month "TTM" passes; a candidate whose window ended months before the subject's is compared as if contemporaneous. Receipts-derived RevPAR also embeds F&B at full-service hotels, inflating them against select-service subjects.

Anchors: precompute-discovery.mjs:1004-1020 · 118 mv_str_ttm_revenue · recompute in migrations 186/225
Fix: require ttm_months_covered of at least 11 and window-end within N months of the subject's, else fall to the missing-data path; record window metadata on the candidate for UI staleness flags.

Dual-brand receipts double-count, and taxpayer-based exclusion deletes whole sibling portfolios

Both receipt fetchers include str_number-null rows in full for EACH brand of a dual-brand taxpayer (or(str_number.eq.X, str_number.is.null)): the exact cross-contamination pattern commit cf45466 just fixed at the stamping layer, still live at query level for unstamped rows. Separately, the universe filter drops ANY candidate sharing the subject's taxpayer_number (:1096-1100, also deal-compset:348): a same-owner sister property one mile away is silently unscoreable, when STR's actual concept would just mark it affiliated.

Anchors: deal-compset/index.ts:209, 348 · precompute-discovery.mjs:553, 1096-1100
Fix: include null-stamped rows only for single-location taxpayers (check brand_splits cardinality); exclude siblings only on str_id/hcad identity match, otherwise keep them as candidates flagged "affiliated".

Compliance gates are blind to null-rooms candidates, so a green "all rules pass" can be false

totalActiveRooms sums c.rooms ?? 0 (:638-639) and offender checks require rooms non-null (:659), so unknown-rooms candidates shrink denominators and can never trip concentration rules. If all rooms are null, every gate silently passes. The UI then shows a clean five-rule pass. This compounds with the STR-mount failure below (rooms truth missing on every cron run).

Anchors: precompute-discovery.mjs:638-659, 681, 714
Fix: treat null rooms as non-verifiable: exclude them from gate math AND append a violation string ("N members have unknown rooms; concentration unverified") so the UI cannot show a clean pass.

7. Silent infrastructure failures

The nightly cron amputates STR ground truth: it reads a Mac-only Google Drive mount from ubuntu-latest

DRIVE_ROOT is hardcoded to /Users/hemaniserver/Library/CloudStorage/GoogleDrive-.../ (:81-85) while the daily refresh (.github/workflows/precompute-discovery.yml, cron 30 5 * * *, ubuntu-latest) has no such mount; readdirSync failures are swallowed by bare catches (:129-131, :190-192). Every scheduled run therefore has strFile=null: is_str_peer=false on every candidate (the STR-peer filter pill lies), no STR-truth rooms/year_built overrides, and any recall restoration is impossible from cron. Worse: because the cache is append-only and the UI reads MAX(computed_at), even a correctly enriched manual Mac run is shadowed by the next night's degraded row.

Anchors: precompute-discovery.mjs:81-85, 129-131 · .github/workflows/precompute-discovery.yml:10-21
Fix: stop reading the filesystem. The STR peer sets already live in the database (str_uploads.parsed_data, exactly as deal-compset:414-448 consumes them); point precompute at that, keep Drive as local-dev fallback, and log "STR enrichment: N peers / MISSING" loudly per run.

Cache staleness is invisible: no TTL, no age guard, errors render as "not computed", refresh is a placeholder

If the cron breaks, the UI serves week-old rankings indefinitely: computed_at is shown only as passive relative time with no threshold or warning. Query errors render the same "Discovery has not been computed yet" empty state (isError never checked, DiscoveryTab.tsx:500-512). The Refresh button is a placeholder. And v0-era cache rows silently make the persona toggle a no-op (useDiscovery.ts:127-146 assigns identical ordering to all three personas with only a console.warn).

Anchors: useDiscovery.ts:56, 127-146 · DiscoveryTab.tsx:500-512 · migration 133 ("TTL via cron later", never built)
Fix: age-threshold banner (warn past 48h, error past 7d), a real error state, a workflow_dispatch-backed manual refresh, and a cleanup for old rows.

8. Correctness bugs

BugAnchorSeverityFix
Subject never excluded from its own candidate pool by hotel id; an identifier-poor subject can be its own top comp at distance 0precompute-discovery.mjs:1094-1106highAdd h.id === subject.id guard first; mirror in deal-compset
deal-compset type scoring: null === null gives full 10 pts; subject's product type never inferred, so a true brand match can score 0deal-compset/index.ts:384 (same bug in v4 source)highInfer subject type; require non-null for the equality branch
Rule 5 pools all independents as one "parent" and demotes unrelated independents; rule 2 counts every independent as AFFILIATED with an independent subject (structurally unpassable); rules 1-2 are never repaired by substitution even when compliant tail candidates existprecompute-discovery.mjs:513-517, 713, 746-753highTreat Independent as a sentinel, not a parent; add rule-2 repair to the swap loop
All paginated queries use .range() without .order(): pages past 1,000 rows can drop or duplicate candidates in dense-metro bboxesprecompute-discovery.mjs:922, 825, 1263 · deal-compset:326medAdd .order('id') everywhere
STR cohort metrics silently drop peers outside the 10-mile geo universe from rooms-weighted cohort RevPAR and baselinesdeal-compset/index.ts:533-555medFall back to strOnlyHotels rooms lookup; disclose exclusions
deal-compset subject lookup does not follow merged_into_id: a stale merged stub can drive the whole deal rundeal-compset/index.ts:313-314medPort merge-following from lookupSubject
Ghost compset members (added after precompute, outside universe, or cut by top-N) render as Match Score 0/100 with fabricated 0.00 mi, indistinguishable from a terrible peerDiscoveryTab.tsx:362-405medRender "not scored" with tooltip instead of 0
Recall strip copy describes dimensions that never existed ("age", "STR-peer correlation"); weight tables hand-copied in 3 places; dead v1.2 type fields (why, fit, correlation) still declared as requiredDiscoveryRecallStrip.tsx:54-57 · types/discovery.ts:61medOne exported DIMENSIONS constant; delete dead fields
Candidate RevPAR of exactly 0 treated as real data: perfect 10/10 for lender persona on a dark hotelprecompute-discovery.mjs:461lowGuard candRevpar === 0 to neutral
February hardcoded 28 days: leap-year $/key/day overstated by 29/28 (about 3.6%) in Feb-2024 baselinesdeal-compset/index.ts:123, 263-265lowCompute days from the period string

9. What is already right validated

The 50/50/70 compliance gates match STR's current published Competitive Set Guidelines. One critic claimed the code should use a 40% single-property cap; adversarial verification corrected the critic, not the code: minimum 4 participating properties, minimum 3 unaffiliated, property max 50%, brand max 50%, parent company max 70%, computed excluding the subject's rooms, is a faithful implementation of STR's post-June-2016 rules (applyComplianceGates, :630-814). Also solid: continuous exponential distance decay (better than v4's steps), the union-cache design that makes persona toggling instant with no round-trip, structured feedback capture with algorithm-version stamping (ready to be consumed the moment something reads it), and the product-type adjacency map as a scoring idea.

11. Decision log (Ace, 2026-07-15)

Ten decisions locked in the brick-by-brick review, run against HOURP as the worked example (curated compset: 8 active / 3 evaluate / 2 watch vs the engine's 2026-07-15 cache):

#BrickDecision
1SizeEligibility floor: candidates below ~50% or above ~250% of subject keys cannot enter the active set (pool-visible, flagged "size outlier"). Driven by Comfort Suites (55 keys) ranking lender #8 against Ace's "too small, rate monitoring only" curation.
2Status + positioningBoth: watch/evaluate/rejection statuses and STR change-log drops suppress recommendations with stated re-surface triggers, AND new positioning bricks are added: rate-scan ADR proximity and year_built/renovation vintage. Driven by Residence Inn (#1 balanced despite watch status; Ace's objection: higher ADR, much newer, extended-stay: three signals the engine cannot see).
3Demand nodeTract gate + density beta: different STR tract caps spatial near-disqualifying (~10/35 max); same tract gets the full curve; decay half-life (1/3/5 mi) picked from tract hotel density. Key discovery: str_hotel_summary.tract already holds STR's submarket boundary for every hotel (subject and all curated members = "Houston Medical Ctr/NRG Stadium, TX"; the bad tail = Galleria/Katy Freeway tracts) and the engine never reads it. The DENSITY_BETA urban/suburban/rural design at :501-511 is the dormant machinery for the density half.
4Lender personaRGI 105-125 band: full credit for same-tract comps 0-20% below subject RevPAR, partial at parity, zero below -35% or above +25%. Set-level implied RGI displayed on the persona toggle. Replaces the current max-reward at -50%.
5Owner personaUp-tier same-product as default; PROTOTYPE the two alternatives on real HOURP data before final lock (cross-product at discount vs a separate non-scored "tract ceiling" strip showing Westin / Hilton Plaza / Blossom).
6Extended-stayKeep select-service and ES unified at 20/20 (the Staybridge-active call proves true comps) and let the Q2 positioning bricks separate premium/new ES; PROTOTYPE the ES-haircut and ES-tier-split alternatives for comparison before final lock.
7Set sizeCore + bench: recommend a 6-8 STR-compliant core plus an explicit bench of near-miss monitoring targets. Mirrors how HOURP is actually run.
8Engine unificationOne shared scoring module + correlation reinstated: receipts correlation returns at 10-15 pts for TX subjects with 12+ overlapping months, for both discovery and deal-compset.
9ScoreboardRecall vs both truths: nightly Recall@7 vs the registered STR peer set AND agreement vs curated active members, trended per property; divergence between the two truths is itself a signal.
10SequencingTruth first: scoreboard + STR-source fix + status suppression + the mechanical bug batch ship before any scoring rebuild, so every brick change is measured, not just different.
HOURP worked-example bugs found during the review (beyond the audit above): Holiday Inn Houston South NRG, an ACTIVE curated member, is invisible to all three personas because its DB product_type "full_service" is not in the v1 classifier vocabulary, so it scores 0/20 on type, lands below the top-28 cache cutoff, and renders as a 0/100 ghost. (An earlier draft blamed a merged-duplicate membership pointer; verified wrong, the member row points at the canonical hotel row.) All 32 HOURP candidates carry is_str_peer=false while 9 member rows say is_str_compset=true: the cron Drive-mount hole confirmed live in production data (cache row 2026-07-15).

Decision 11 (Ace, 2026-07-15): demand-generator comps are a first-class concept. HI's inclusion rationale (now recorded in compset_members.inclusion_reason): it is the closest hotel to NRG Stadium and HOURP is second closest, so they compete head-on for NRG event compression nights despite failing every similarity brick. Spec implications: (a) properties get optional demand-generator anchors (NRG Stadium for HOURP) and candidates carry a generator-proximity rank; (b) a "generator comp" tag protects a member from ever being flagged for removal by score and explains the override on the card; (c) similarity score and membership recommendation are therefore distinct outputs, the engine must be able to say "keep, for a reason the score cannot see."

10. Prioritized roadmap

Week 1: stop the silent lies (small, high leverage)

#MoveWhy first
1Point STR-peer enrichment at str_uploads.parsed_data instead of the Mac Drive mount; log enrichment status per runRestores ground truth to every nightly run; prerequisite for recall
2Read compset_discovery_feedback at precompute: suppress/decay rejected candidates per subjectCloses the loop the UI already promises; a day of work
3Restore Recall@7/14 writing + plumb the three fields to the UI stripThe scoreboard for every change after this
4Mechanical fixes batch: subject-id exclusion, .order('id') on paginated queries, closed-hotel exclusion, zero-RevPAR guard, missing-data scores below real data, unknown-chain-scale partial credit, Feb days, reject/staged UX + query invalidation, ghost members as "not scored", error-vs-empty stateEach is under an hour; together they remove most false trust signals
5Make CI typecheck blocking (build-mode tsc)Would have caught 3 shipped bugs in this one feature

Weeks 2-4: coherence

#MovePayoff
6Extract one shared v5 scoring module; deal-compset consumes it (or the discovery cache); stamp algorithm_version in deal_compset_cacheDeal page and Discovery tab stop contradicting each other
7Reinstate TX receipts correlation as a 10-15 pt dimension (or validator badge); enforce TTM window alignment + minimum coverageStrongest "actually shares demand" signal returns
8Redefine lender as the strictest persona; move flattering logic to an explicit "pitch" preset; fix rule 2 / rule 5 independent handling + null-rooms compliance warningsLender sets become appraisal-survivable
9Density-based spatial beta via cheap proxy + demand-node tags; staleness banner + real refreshUrban rankings stop being dominated by one hardcoded constant

The moat (quarter scale)

#MoveWhy it is the sauce
10Compset Defensibility Report: per recommended set, a lender-facing export with trailing-24-month set-vs-subject receipts correlation, five-rule STR compliance table, and deterministic per-candidate "why this comp" narratives (generated from component scores already computed; the why/fit type fields were designed for exactly this and never built)Turns recommendations into a sellable, appraisal-grade artifact no spreadsheet shop can match
11Learning loop v1: acceptance priors + per-market weight tuning, recall trended per property as the public scoreboard; config-table weight vectors with tax-appeal and appraiser presetsThe model provably improves with every operator decision; compounding data advantage
12Portfolio competitor intelligence: nameback-count page (one row per external hotel in 2+ THM compsets), nightly cache-diff alerts on ranking changes, event-driven rescore hooks (dark-hotel detector migration 238, flag changes)The append-only cache history and nameback data already exist, computed nightly and ignored

12. Scale architecture (approved by Ace, 2026-07-15)

The HOURP review does not scale as performed: it took the operator's memory of who is closest to NRG Stadium plus an interview. The system scales because everything decided above decomposes into three layers, and only the thinnest one needs a human.

Layer 1: Deterministic geometry and rules. Computable for every hotel nightly, zero curation.

Generator geography is a data problem, not a tagging problem. A new demand_generators table holds stadiums, airports, convention centers, medical centers, universities, and theme parks per metro: a small per-metro list (assumption: on the order of dozens of majors per metro, to be sized during the POI pull), seeded once from a POI batch pull through the DataForSEO integration the rate scans already use, with an address-verification pass. Once seeded, generator-proximity rank is pure geometry: for every hotel and every generator within N miles, compute the hotel's rank in the distance ordering. "HI is #1 to NRG, HOURP is #2" becomes a computed fact for every hotel-generator pair in Texas, refreshed nightly. The tract gate (Decision 3) already scales the same way: STR assigns every hotel a tract and it is already in str_hotel_summary.tract. The size floor, status suppression, TTM window alignment, and classifier vocabulary fix are likewise mechanical.

Layer 2: Statistical validation. The receipts data discovers generator comps by itself.

Compute each hotel's monthly deviation from its own seasonal norm, then correlate deviations between subject and candidate. Two hotels that both spike in Rodeo months and Texans season share NRG demand, whether or not anyone recorded why. This is the dropped v4 correlation signal (Decision 8) upgraded from raw month-over-month to deviation correlation, which is stronger evidence of a shared generator. Texas Comptroller receipts cover every TX hotel monthly, so this runs portfolio-wide and deal-wide: it is exactly what underwriting a Dallas four-pack needs when there is no operating feel for the micro-market. Where THM owns the subject, event-window rate data from the existing rate calendar (which already badges NRG event dates) sharpens the signal to specific nights: the eventual lender-packet stat is event-night RGI vs the generator comp.

Layer 3: Human curation, captured at the moment of decision instead of by interview.

The HOURP/HI rationale lived only in the operator's head for two-plus years because the UI never asked. Fix: every Add / Reject / Watch action in the Discovery tab requires a structured reason from a small taxonomy (generator comp, size outlier, positioning gap, renovation, new supply, rate-shop only, ...) plus optional free text, written to inclusion_reason / compset_discovery_feedback. The 13 HOURP member rows produced 11 usable labels from one review; a hundred compsets producing labels as a side effect of normal operation is the training set the learning loop (Decision 2, audit finding 1) needs. The tool harvests the reasoning one click at a time.

RolloutCoverage logic
Texas firstReceipts + tracts + rate scans all present; every layer works.
Non-TX secondTract gate and generator geometry still work; the correlation brick goes neutral (no receipts) and rate-scan ADR carries the positioning bricks.
Scoreboard before all of itPer Decision 10 (truth first), nightly Recall@7 vs both truths lands before any layer switches on, so each layer's effect on recall is measured per property, not asserted.
First build package: (1) demand_generators table + nightly geometry job (generator-proximity ranks for all hotels); (2) the receipts deviation-correlation job; (3) structured-reason capture in the Discovery UI. All three land behind the recall scoreboard. Deliberately deferred: generator event calendars. Deviation correlation needs no calendar to find shared demand; calendars only sharpen the narrative for lender packets and rate strategy, so they are an enrichment sprint, not a dependency.