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
scoreForPersona :575-606)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):
| Persona | Chain scale (25 pts) | RevPAR spread (10 pts) | Intent |
|---|---|---|---|
| Lender | Full credit for same or one tier DOWN (:445-449) | Rewards candidates 25-50%+ BELOW subject RevPAR (:470-476) | Comps that flatter the subject |
| Balanced | Full credit for exact tier match (:440-443) | Rewards proximity, |delta| within 25% (:465-468) | Daily operating set |
| Owner | Full 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.
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.
src/hooks/useDiscovery.ts:373 · DiscoveryTab.tsx:682 · verified: no consumer of compset_discovery_feedback outside scripts/apply-mig-133.mjsReject 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.
DiscoveryTab.tsx:466, 215 · useDiscovery.ts:319-328v4 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.
precompute-discovery.mjs:1309 · DiscoveryTab.tsx:537-539 · 133_compset_discovery_tables.sql:23-25The 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.
precompute-discovery.mjs:39 (ALGORITHM_VERSION, no eval harness for v5)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.
precompute-discovery.mjs:445-449, 470-476Only 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.
scoreForPersona precompute-discovery.mjs:575-606The 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.
precompute-discovery.mjs:500-511; v4's far-distance penalty also droppeddeal-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.
supabase/functions/deal-compset/index.ts:162-182, 372 vs precompute-discovery.mjs v5compset_discovery_cache for portfolio subjects) and stamp algorithm_version into deal_compset_cache.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.
precompute-discovery.mjs:1132-1134 · correlation math at deal-compset/index.ts:228-240, tiers at :175-182(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.
precompute-discovery.mjs:461, 437-438; v4 penalties at discovery-v4.mjspullUniverse 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.
precompute-discovery.mjs:922, 1008 · 238_dark_compset_hotel_detector.sqlThe 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.
precompute-discovery.mjs:1004-1020 · 118 mv_str_ttm_revenue · recompute in migrations 186/225Both 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.
deal-compset/index.ts:209, 348 · precompute-discovery.mjs:553, 1096-1100totalActiveRooms 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).
precompute-discovery.mjs:638-659, 681, 714DRIVE_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.
precompute-discovery.mjs:81-85, 129-131 · .github/workflows/precompute-discovery.yml:10-21str_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.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).
useDiscovery.ts:56, 127-146 · DiscoveryTab.tsx:500-512 · migration 133 ("TTL via cron later", never built)| Bug | Anchor | Severity | Fix |
|---|---|---|---|
| Subject never excluded from its own candidate pool by hotel id; an identifier-poor subject can be its own top comp at distance 0 | precompute-discovery.mjs:1094-1106 | high | Add 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 0 | deal-compset/index.ts:384 (same bug in v4 source) | high | Infer 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 exist | precompute-discovery.mjs:513-517, 713, 746-753 | high | Treat 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 bboxes | precompute-discovery.mjs:922, 825, 1263 · deal-compset:326 | med | Add .order('id') everywhere |
| STR cohort metrics silently drop peers outside the 10-mile geo universe from rooms-weighted cohort RevPAR and baselines | deal-compset/index.ts:533-555 | med | Fall 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 run | deal-compset/index.ts:313-314 | med | Port 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 peer | DiscoveryTab.tsx:362-405 | med | Render "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 required | DiscoveryRecallStrip.tsx:54-57 · types/discovery.ts:61 | med | One exported DIMENSIONS constant; delete dead fields |
| Candidate RevPAR of exactly 0 treated as real data: perfect 10/10 for lender persona on a dark hotel | precompute-discovery.mjs:461 | low | Guard candRevpar === 0 to neutral |
| February hardcoded 28 days: leap-year $/key/day overstated by 29/28 (about 3.6%) in Feb-2024 baselines | deal-compset/index.ts:123, 263-265 | low | Compute days from the period string |
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.
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):
| # | Brick | Decision |
|---|---|---|
| 1 | Size | Eligibility 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. |
| 2 | Status + positioning | Both: 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). |
| 3 | Demand node | Tract 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. |
| 4 | Lender persona | RGI 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%. |
| 5 | Owner persona | Up-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). |
| 6 | Extended-stay | Keep 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. |
| 7 | Set size | Core + bench: recommend a 6-8 STR-compliant core plus an explicit bench of near-miss monitoring targets. Mirrors how HOURP is actually run. |
| 8 | Engine unification | One 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. |
| 9 | Scoreboard | Recall 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. |
| 10 | Sequencing | Truth 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. |
| # | Move | Why first |
|---|---|---|
| 1 | Point STR-peer enrichment at str_uploads.parsed_data instead of the Mac Drive mount; log enrichment status per run | Restores ground truth to every nightly run; prerequisite for recall |
| 2 | Read compset_discovery_feedback at precompute: suppress/decay rejected candidates per subject | Closes the loop the UI already promises; a day of work |
| 3 | Restore Recall@7/14 writing + plumb the three fields to the UI strip | The scoreboard for every change after this |
| 4 | Mechanical 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 state | Each is under an hour; together they remove most false trust signals |
| 5 | Make CI typecheck blocking (build-mode tsc) | Would have caught 3 shipped bugs in this one feature |
| # | Move | Payoff |
|---|---|---|
| 6 | Extract one shared v5 scoring module; deal-compset consumes it (or the discovery cache); stamp algorithm_version in deal_compset_cache | Deal page and Discovery tab stop contradicting each other |
| 7 | Reinstate TX receipts correlation as a 10-15 pt dimension (or validator badge); enforce TTM window alignment + minimum coverage | Strongest "actually shares demand" signal returns |
| 8 | Redefine lender as the strictest persona; move flattering logic to an explicit "pitch" preset; fix rule 2 / rule 5 independent handling + null-rooms compliance warnings | Lender sets become appraisal-survivable |
| 9 | Density-based spatial beta via cheap proxy + demand-node tags; staleness banner + real refresh | Urban rankings stop being dominated by one hardcoded constant |
| # | Move | Why it is the sauce |
|---|---|---|
| 10 | Compset 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 |
| 11 | Learning 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 presets | The model provably improves with every operator decision; compounding data advantage |
| 12 | Portfolio 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 |
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.
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.
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.
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.
| Rollout | Coverage logic |
|---|---|
| Texas first | Receipts + tracts + rate scans all present; every layer works. |
| Non-TX second | Tract 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 it | Per 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. |
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.