SPB Git

spb/rareindex

Public

Valuation safety (phase 1): ask-vs-RIV anomaly gate, variant-aware discounts, grade parsing fixes

- scores: assessAsk()/dealScore()/plausibility gates — an ask is compared only with a
  transaction-based RIV of the SAME variant (>=5 sales, confidence >=0.5, match >=0.7);
  asks <10% or >10x RIV are flagged riv_anomaly, never surfaced as deals
- valuation worker: discounts reset each pass, representative variant prefers the default
  (raw/base) variant when it qualifies, changes measured on the representative variant's
  own series, no cross-variant fallback for value_opportunity, numeric(8,6) overflow guard
- taxonomy: parse PSA/DNA n, (8.5), Authentic-only slabs; a grader without a readable grade
  gets its own 'grader|unknown' variant instead of polluting raw
- resolver/writers: refineGrade() re-parses titles for sales, listings, observations, lots
- web: VsRiv primitive (Data/identity anomaly label), opportunity/gainers/losers/listings
  sort + filter directions fixed (negative = below RIV), scanner verdict via assessAsk with FX
- indices: RARE publishes only when its 25-constituent minimum is met
- security: atomic ai_quotas upsert with unique key, scanner choose ownership check,
  anonymous research thread authz
- db: migration 0006 (hot-path indexes, ai_quotas unique); ri regrade repair command

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 11, 2026) parent 1f956d0 Browse files

31 changed files +9,556 −101

modified apps/web/package.json +1 −0
@@ -18,6 +18,7 @@
18 18 "@rareindex/search": "workspace:*",
19 19 "@rareindex/shared": "workspace:*",
20 20 "@rareindex/taxonomy": "workspace:*",
21 + "@rareindex/valuation": "workspace:*",
21 22 "@tanstack/react-query": "^5.90.0",
22 23 "drizzle-orm": "^0.45.0",
23 24 "lucide-react": "^1.0.0",
modified apps/web/src/app/api/scanner/[id]/choose/route.ts +4 −1
@@ -1,6 +1,7 @@
1 1 import { NextResponse } from 'next/server';
2 2 import { z } from 'zod';
3 3 import { chooseCandidate } from '@/lib/ai/scanner';
4 +import { clientIpHash, currentUserId, getAnonId } from '@/lib/ai/request';
4 5
5 6 export const runtime = 'nodejs';
6 7
@@ -8,6 +9,8 @@ export async function POST(req: Request, ctx: { params: Promise<{ id: string }>
8 9 const { id } = await ctx.params;
9 10 const body = z.object({ assetId: z.string().nullable() }).safeParse(await req.json().catch(() => ({})));
10 11 if (!body.success || !/^scan_[a-z0-9]+$/.test(id)) return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
11 − await chooseCandidate(id, body.data.assetId);
12 + const [userId, anonId, ipHash] = await Promise.all([currentUserId(), getAnonId(false), clientIpHash()]);
13 + const ok = await chooseCandidate(id, body.data.assetId, { userId, anonId, ipHash });
14 + if (!ok) return NextResponse.json({ error: 'Not found' }, { status: 404 });
12 15 return NextResponse.json({ ok: true });
13 16 }
modified apps/web/src/app/page.tsx +1 −1
@@ -205,7 +205,7 @@ async function Discovery() {
205 205 <RadarRail items={radar} hideWhenEmpty />
206 206 <LotsRail items={lots} hideWhenEmpty />
207 207 <AssetRail title="Newly Added" subtitle="Latest canonical assets — guide prices labelled, RIV when available" href="/explore?sort=newest" items={newest} metric="guide" />
208 − {opportunities.length ? <AssetRail title="Value Opportunities" subtitle="Listings materially below RIV — analytical data, not advice" href="/listings?sort=discount" items={opportunities} metric="opportunity" /> : null}
208 + {opportunities.length ? <AssetRail title="Value Opportunities" subtitle="Asks ≥ 10 % below a transaction-based RIV of the same variant (≥ 5 sales, medium+ confidence). Analytical data, not advice; implausible asks are held back as data anomalies." href="/listings?sort=discount&disc=10" items={opportunities} metric="opportunity" /> : null}
209 209 {watched.length ? <AssetRail title="Most Watched" subtitle="Assets on the most watchlists" href="/explore?sort=trending" items={watched} metric="watchers" /> : null}
210 210 <MarketInsights rows={markets} />
211 211 </>
modified apps/web/src/components/asset/sales-cards.tsx +2 −5
@@ -1,7 +1,7 @@
1 1 import type { SaleRow, ListingRow } from '@/lib/queries/assets';
2 2 import { fmtDate, fmtMoney, fmtRelative, cn } from '@/lib/format';
3 3 import { humanize } from '@/lib/taxonomy';
4 −import { Badge, Delta, EmptyState } from '@/components/ui/primitives';
4 +import { Badge, Delta, EmptyState, VsRiv } from '@/components/ui/primitives';
5 5 import { GradeBadge, SourceLink, Thumb } from '@/components/market/bits';
6 6
7 7 /** Phone-first card lists for sales and listings (tables stay for md+). */
@@ -48,10 +48,7 @@ export function ListingsCards({ items, className, emptyDescription = 'Live listi
48 48 <div className="flex items-baseline justify-between gap-2">
49 49 <span className="num text-[15px] font-semibold text-fg">{fmtMoney(l.priceUsd)}</span>
50 50 {l.discountToRiv !== null ? (
51 − <span className="inline-flex items-center gap-1 text-[11px]">
52 − <span className="text-subtle">vs RIV</span>
53 − <Delta value={-l.discountToRiv} />
54 − </span>
51 + <VsRiv discount={l.discountToRiv} className="text-[11px]" />
55 52 ) : (
56 53 <span className="text-[11px] text-subtle">{fmtRelative(l.lastSeenAt)}</span>
57 54 )}
modified apps/web/src/components/market/asset-list.tsx +3 −3
@@ -2,7 +2,7 @@ import Link from 'next/link';
2 2 import type { AssetCard as AssetCardData } from '@/lib/queries/assets';
3 3 import { fmtMoney, fmtNum, fmtRelative, confidenceLabel, cn } from '@/lib/format';
4 4 import { catName } from '@/lib/taxonomy';
5 −import { Delta, Table, th, td, tdNum, EmptyState } from '@/components/ui/primitives';
5 +import { Delta, Table, th, td, tdNum, EmptyState, VsRiv } from '@/components/ui/primitives';
6 6 import { Thumb } from './bits';
7 7 import { PriceLine, TileImage } from './asset-tile';
8 8
@@ -81,7 +81,7 @@ function cell(a: AssetCardData, c: AssetColumn) {
81 81 case 'trending':
82 82 return a.trendingScore === null ? <span className="text-subtle">—</span> : a.trendingScore.toFixed(1);
83 83 case 'opportunity':
84 − return a.valueOpportunity === null ? <span className="text-subtle">—</span> : <Delta value={-a.valueOpportunity} />;
84 + return <VsRiv discount={a.valueOpportunity} showLabel={false} />;
85 85 case 'watchers':
86 86 return fmtNum(a.watchers);
87 87 case 'updated':
@@ -142,7 +142,7 @@ export function AssetCard({ a, className, metric = 'change30d' }: { a: AssetCard
142 142 metric === 'latestSale' ? (
143 143 a.latestSaleUsd !== null && a.rivUsd !== null ? <span className="num text-[11px] text-muted">last {fmtMoney(a.latestSaleUsd)}</span> : a.number ? <span className="num text-[11px] text-subtle">#{a.number}</span> : null
144 144 ) : metric === 'opportunity' ? (
145 − a.valueOpportunity !== null ? <span className="text-[11px] text-muted">vs RIV <Delta value={-a.valueOpportunity} className="text-[11px]" /></span> : null
145 + a.valueOpportunity !== null ? <VsRiv discount={a.valueOpportunity} className="text-[11px]" /> : null
146 146 ) : metric === 'trending' ? (
147 147 a.trendingScore !== null ? <span className="num text-[11px] text-muted">trend {a.trendingScore.toFixed(1)}</span> : null
148 148 ) : value !== null ? (
modified apps/web/src/components/market/asset-tile.tsx +2 −4
@@ -3,7 +3,7 @@ import type { ReactNode } from 'react';
3 3 import type { AssetCard as AssetCardData } from '@/lib/queries/assets';
4 4 import { cn, confidenceLabel, fmtMoney, fmtNum, fmtRelative } from '@/lib/format';
5 5 import { catName } from '@/lib/taxonomy';
6 −import { Delta } from '@/components/ui/primitives';
6 +import { Delta, VsRiv } from '@/components/ui/primitives';
7 7
8 8 export type TileMetric = 'change7d' | 'change1d' | 'change30d' | 'trending' | 'watchers' | 'riv' | 'opportunity' | 'new' | 'guide' | 'latestSale';
9 9
@@ -61,9 +61,7 @@ function MetricNode({ a, metric }: { a: AssetCardData; metric: TileMetric }) {
61 61 return <span className="num text-[11px] text-muted">{fmtNum(a.watchers)} watching</span>;
62 62 case 'opportunity':
63 63 return (
64 − <span className="text-[11px] text-muted">
65 − vs RIV <Delta value={a.valueOpportunity === null ? null : -a.valueOpportunity} className="text-[12px]" />
66 − </span>
64 + <VsRiv discount={a.valueOpportunity} className="text-[12px]" />
67 65 );
68 66 case 'new':
69 67 return <span className="text-[11px] text-muted">added {fmtRelative(a.updatedAt)}</span>;
modified apps/web/src/components/market/sales-table.tsx +3 −3
@@ -2,7 +2,7 @@ import type { SaleRow, ListingRow } from '@/lib/queries/assets';
2 2 import type { LotRow } from '@/lib/queries/market-lists';
3 3 import { fmtDate, fmtMoney, fmtRelative, fmtPct, cn } from '@/lib/format';
4 4 import { humanize } from '@/lib/taxonomy';
5 −import { Table, th, td, tdNum, EmptyState, Badge, Delta } from '@/components/ui/primitives';
5 +import { Table, th, td, tdNum, EmptyState, Badge, Delta, VsRiv } from '@/components/ui/primitives';
6 6 import { AssetTitleCell, GradeBadge, PriceCell, SourceLink, Thumb } from './bits';
7 7
8 8 /** Card list for sales on small screens (image, title, grade, price, source). */
@@ -119,7 +119,7 @@ export function ListingsCards({ items, className }: { items: ListingRow[]; class
119 119 </span>
120 120 <span className="num flex shrink-0 flex-col items-end leading-tight">
121 121 <span className="text-[14px] font-semibold text-fg">{fmtMoney(l.priceUsd)}</span>
122 − {l.discountToRiv !== null ? <Delta value={-l.discountToRiv} className="text-[11px]" /> : <span className="text-[10px] text-subtle">{humanize(l.listingType)}</span>}
122 + {l.discountToRiv !== null ? <VsRiv discount={l.discountToRiv} className="text-[11px]" showLabel={false} /> : <span className="text-[10px] text-subtle">{humanize(l.listingType)}</span>}
123 123 </span>
124 124 </a>
125 125 </li>
@@ -186,7 +186,7 @@ export function ListingsTable({ items, showAsset = true, className, emptyDescrip
186 186 <span className="text-subtle" title="No valuation to compare against">—</span>
187 187 ) : (
188 188 <span className="inline-flex flex-col items-end leading-tight">
189 − <Delta value={-l.discountToRiv} />
189 + <VsRiv discount={l.discountToRiv} showLabel={false} />
190 190 {l.assetRivUsd !== undefined && l.assetRivUsd !== null ? <span className="text-[10px] text-subtle">RIV {fmtMoney(l.assetRivUsd)}</span> : null}
191 191 </span>
192 192 )}
modified apps/web/src/components/scanner/scanner-client.tsx +1 −1
@@ -260,7 +260,7 @@ function ScanResults({ r, chosen, onChoose }: { r: ScanResponse; chosen: string
260 260 <CardHeader title="Parsed listing" subtitle={r.listing.sourceId} action={<a href={r.listing.sourceUrl} target="_blank" rel="noreferrer noopener" className="text-muted hover:text-fg">Open source ↗</a>} />
261 261 <div className="grid gap-4 p-4 sm:grid-cols-3">
262 262 <Stat label="Asking price" value={r.listing.price !== null ? fmtMoney(r.listing.price, r.listing.currency ?? 'USD') : '—'} sub={r.listing.rawTitle} />
263 − <Stat label="vs RareIndex Valuation" value={r.listing.discountToRiv !== null ? <span className={r.listing.discountToRiv < 0 ? 'text-gain' : 'text-loss'}>{fmtPct(r.listing.discountToRiv, 1)}</span> : '—'} sub={{ below_fair_value: 'Below fair value range', in_range: 'Within fair value range', above_fair_value: 'Above fair value range', unknown: 'No valuation to compare' }[r.listing.verdict]} />
263 + <Stat label="vs RareIndex Valuation" value={r.listing.verdict === 'anomaly' ? <span className="rounded-sm bg-alert-bg px-1.5 py-0.5 text-[11px] font-medium uppercase tracking-wide text-alert">Data/identity anomaly</span> : r.listing.discountToRiv !== null ? <span className={r.listing.discountToRiv <= -0.1 ? 'text-gain' : r.listing.discountToRiv >= 0.1 ? 'text-loss' : 'text-flat'}>{fmtPct(r.listing.discountToRiv, 1)}</span> : '—'} sub={{ below_fair_value: 'Below fair value range', in_range: 'Within fair value range', above_fair_value: 'Above fair value range', anomaly: 'Implausible against the valuation — review the variant, lot, currency and identity', unknown: 'No valuation to compare' }[r.listing.verdict] ?? 'No valuation to compare'} />
264 264 <Stat label="Grade on listing" value={r.listing.grader ? `${r.listing.grader.toUpperCase()} ${r.listing.grade ?? ''}` : 'Raw / unknown'} />
265 265 </div>
266 266 </Card>
modified apps/web/src/components/ui/primitives.tsx +25 −0
@@ -38,6 +38,31 @@ export function Delta({ value, digits = 2, className, arrow = false }: { value:
38 38 );
39 39 }
40 40
41 +/**
42 + * Ask vs RareIndex Valuation (§83–§84, §196). `discount` = (ask − RIV) / RIV as stored: negative = the ask
43 + * is below the valuation. Anything implausible (ask < 10 % or > 10× RIV) is a data/identity anomaly and is
44 + * labelled as such instead of being shown as a percentage — never as a "deal".
45 + */
46 +export function VsRiv({ discount, className, digits = 1, showLabel = true }: { discount: number | null | undefined; className?: string; digits?: number; showLabel?: boolean }) {
47 + if (discount === null || discount === undefined || !Number.isFinite(discount)) return <span className={cn('text-subtle', className)} title="No comparable valuation for this variant">—</span>;
48 + const ratio = 1 + discount;
49 + if (ratio < 0.1 || ratio > 10) {
50 + return (
51 + <span className={cn('inline-flex items-center gap-1 rounded-sm bg-alert-bg px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-alert', className)} title="The asking price is implausible against this valuation (wrong variant, lot, currency or identity). Not a deal until reviewed.">
52 + Data/identity anomaly
53 + </span>
54 + );
55 + }
56 + const tone = discount <= -0.1 ? 'text-gain' : discount >= 0.1 ? 'text-loss' : 'text-flat';
57 + const word = discount <= -0.1 ? 'below RIV' : discount >= 0.1 ? 'above RIV' : 'near RIV';
58 + return (
59 + <span className={cn('num inline-flex items-baseline gap-1 font-medium', tone, className)} title={`Asking price ${fmtPct(Math.abs(discount), digits, false)} ${word}. Analytical data, not advice.`}>
60 + {fmtPct(discount, digits)}
61 + {showLabel ? <span className="text-[0.85em] font-normal text-subtle">vs RIV</span> : null}
62 + </span>
63 + );
64 +}
65 +
41 66 export function Badge({ children, tone = 'neutral', className, dot = false }: { children: ReactNode; tone?: 'neutral' | 'gain' | 'loss' | 'index' | 'rarity' | 'alert' | 'gold'; className?: string; dot?: boolean }) {
42 67 const tones: Record<string, string> = {
43 68 neutral: 'bg-inset text-muted',
modified apps/web/src/lib/ai/request.ts +8 −8
@@ -2,7 +2,7 @@ import 'server-only';
2 2 import { createHmac, randomUUID } from 'node:crypto';
3 3 import { cookies, headers } from 'next/headers';
4 4 import { getCurrentUser } from '@/lib/auth/session';
5 −import { getDb, aiQuotas, sql } from '@rareindex/database';
5 +import { getDb, sql } from '@rareindex/database';
6 6
7 7 const SECRET = () => process.env.SESSION_SECRET ?? 'dev-only';
8 8
@@ -56,13 +56,13 @@ export async function consumeQuota(feature: keyof typeof QUOTAS, opts: { userId:
56 56 const limit = opts.userId ? QUOTAS[feature]!.user : QUOTAS[feature]!.anonymous;
57 57 const key = opts.userId ? `user:${opts.userId}` : `ip:${opts.ipHash}`;
58 58 const date = new Date().toISOString().slice(0, 10);
59 − const db = getDb();
60 − const [row] = await db.execute(sql`select count from ai_quotas where key = ${key} and feature = ${feature} and date = ${date} limit 1`);
61 − const used = Number((row as { count?: number } | undefined)?.count ?? 0);
62 − if (used >= limit) return { ok: false, used, limit };
63 − if (row) await db.execute(sql`update ai_quotas set count = count + 1 where key = ${key} and feature = ${feature} and date = ${date}`);
64 − else await db.insert(aiQuotas).values({ key, feature, date, count: 1 });
65 − return { ok: true, used: used + 1, limit };
59 + // Atomic increment (unique (key, feature, date)): concurrent requests can no longer create
60 + // duplicate counters and slip past the limit.
61 + const [row] = await getDb().execute(sql`insert into ai_quotas (key, feature, date, count) values (${key}, ${feature}, ${date}, 1)
62 + on conflict (key, feature, date) do update set count = ai_quotas.count + 1 returning count`);
63 + const used = Number((row as { count?: number } | undefined)?.count ?? 1);
64 + if (used > limit) return { ok: false, used: used - 1, limit };
65 + return { ok: true, used, limit };
66 66 }
67 67
68 68 export function aiErrorMessage(err: unknown): { status: number; message: string } {
modified apps/web/src/lib/ai/research-tools.ts +2 −1
@@ -203,11 +203,12 @@ export async function executeTool(name: string, rawInput: unknown): Promise<unkn
203 203 const i = input as z.infer<typeof TOOL_SCHEMAS.screen>;
204 204 const col = sql.raw(`s.${WINDOWS[i.window as Window]}`);
205 205 const sortCol = { change: col, value: sql`s.riv_usd`, liquidity: sql`s.liquidity_score`, sales: sql`s.sales_count`, trending: sql`s.trending_score`, opportunity: sql`s.value_opportunity` }[i.sort as string] ?? col;
206 + const sortDir = i.sort === 'opportunity' ? sql`ASC NULLS LAST` : sql`DESC NULLS LAST`; // opportunity: most below RIV first
206 207 const rows = await run(sql`select ${ASSET_COLS} from assets a join asset_stats s on s.asset_id = a.id where s.riv_sample_size >= ${i.min_sales} ${scope(i.category)}
207 208 ${i.min_price_usd != null ? sql`and s.riv_usd >= ${i.min_price_usd}` : sql``} ${i.max_price_usd != null ? sql`and s.riv_usd <= ${i.max_price_usd}` : sql``}
208 209 ${i.min_change != null ? sql`and ${col} >= ${i.min_change}` : sql``} ${i.max_change != null ? sql`and ${col} <= ${i.max_change}` : sql``}
209 210 ${i.max_sales_1y != null ? sql`and s.sales_1y <= ${i.max_sales_1y}` : sql``} ${i.min_liquidity != null ? sql`and s.liquidity_score >= ${i.min_liquidity}` : sql``}
210 − order by ${sortCol} desc nulls last limit ${i.limit}`);
211 + order by ${sortCol} ${sortDir} limit ${i.limit}`);
211 212 return { filters: i, count: rows.length, results: rows };
212 213 }
213 214 case 'record_sales': {
modified apps/web/src/lib/ai/research.ts +3 −1
@@ -48,7 +48,9 @@ export async function loadSession(sessionId: string, owner: { anonId: string | n
48 48 const [s] = await db.select().from(researchSessions).where(eq(researchSessions.id, sessionId)).limit(1);
49 49 if (!s) return null;
50 50 if (s.userId && s.userId !== owner.userId) return null;
51 − if (!s.userId && s.anonId && s.anonId !== owner.anonId) return null;
51 + // anonymous threads are readable only by the browser that created them; a thread without an
52 + // anon id (cookie could not be set) is not readable by anyone afterwards
53 + if (!s.userId && (!owner.anonId || s.anonId !== owner.anonId)) return null;
52 54 const messages = await db.select().from(researchMessages).where(eq(researchMessages.sessionId, sessionId)).orderBy(researchMessages.createdAt);
53 55 return { session: s, messages };
54 56 }
modified apps/web/src/lib/ai/scanner.ts +21 −5
@@ -5,6 +5,8 @@ import { getGrader, parseGradeFromTitle } from '@rareindex/taxonomy';
5 5 import { identifyCollectible, getRouter, type ImageInput, type Identification } from '@rareindex/ai';
6 6 import { lookupViaApi } from './lookup';
7 7 import { findCandidates, assetContext, type Candidate } from './candidates';
8 +import { assessAsk } from '@rareindex/valuation';
9 +import { toUsdAt } from '@/lib/account/fx';
8 10
9 11 export interface ScanResult {
10 12 sessionId: string;
@@ -33,7 +35,7 @@ export interface ListingSummary {
33 35 identifiers: Record<string, string>;
34 36 /** price vs RIV when both known: negative = below fair value */
35 37 discountToRiv: number | null;
36 − verdict: 'below_fair_value' | 'in_range' | 'above_fair_value' | 'unknown';
38 + verdict: 'below_fair_value' | 'in_range' | 'above_fair_value' | 'anomaly' | 'unknown';
37 39 }
38 40
39 41 interface ScanInput {
@@ -104,9 +106,16 @@ export async function runScan(input: ScanInput): Promise<ScanResult> {
104 106 const best = top && top.score >= 0.25 ? { ...top, context: await assetContext(top.assetId) } : null;
105 107 if (!best) notes.push(candidates.length ? 'Low-confidence match: review the candidates below.' : 'No matching asset in the RareIndex catalog yet. The pipeline adds assets as connectors ingest sources.');
106 108
107 − if (listing && best?.rivUsd && listing.price !== null && listing.currency === 'USD') {
108 − listing.discountToRiv = (listing.price - best.rivUsd) / best.rivUsd;
109 − listing.verdict = listing.discountToRiv <= -0.15 ? 'below_fair_value' : listing.discountToRiv >= 0.2 ? 'above_fair_value' : 'in_range';
109 + if (listing && best?.rivUsd && listing.price !== null && listing.currency) {
110 + // Compare in USD at today's ECB rate; gate exactly like the valuation worker (transaction-based RIV,
111 + // ≥ 5 sales, medium+ confidence) and refuse implausible ratios instead of calling them deals.
112 + const fx = listing.currency === 'USD' ? { usd: listing.price } : await toUsdAt(listing.price, listing.currency, null);
113 + const gradedMismatch = Boolean(listing.grader && listing.grader !== 'raw' && best.context.variants.length && !best.context.variants.some((v) => (v.grader as string | null) === listing.grader && (v.grade as string | null) === listing.grade));
114 + const a = assessAsk({ askUsd: fx?.usd ?? null, rivUsd: best.rivUsd, confidence: best.rivConfidence, sampleSize: best.rivSampleSize, sameVariant: gradedMismatch ? false : undefined });
115 + listing.discountToRiv = a.discount;
116 + listing.verdict = a.verdict === 'deal' ? 'below_fair_value' : a.verdict === 'premium' ? 'above_fair_value' : a.verdict === 'fair' ? 'in_range' : a.verdict === 'anomaly' ? 'anomaly' : 'unknown';
117 + if (a.verdict === 'anomaly') notes.push('The asking price is implausible against the matched valuation (below 10 % or above 10× RIV): most likely a different variant, a lot, a currency or an identity mismatch — review before treating it as a deal.');
118 + else if (a.verdict === 'ungated') notes.push(`The ask was not compared with the valuation (${a.reasons.join(', ')}).`);
110 119 }
111 120
112 121 const durationMs = Date.now() - started;
@@ -134,8 +143,15 @@ export async function runScan(input: ScanInput): Promise<ScanResult> {
134 143 return { sessionId: id, mode: input.mode, guess, guessConfidence: guess?.confidence ?? null, candidates, best, listing, model, usdEst, durationMs, notes };
135 144 }
136 145
137 −export async function chooseCandidate(sessionId: string, assetId: string | null): Promise<void> {
146 +/** Record the user's confirmation of a candidate. Only the session's owner (user, anon cookie or, failing both, the same IP hash) may write it. */
147 +export async function chooseCandidate(sessionId: string, assetId: string | null, owner: { userId: string | null; anonId: string | null; ipHash: string }): Promise<boolean> {
148 + const [s] = await getDb().select({ userId: scannerSessions.userId, anonId: scannerSessions.anonId, ipHash: scannerSessions.ipHash, candidates: scannerSessions.candidates }).from(scannerSessions).where(eq(scannerSessions.id, sessionId)).limit(1);
149 + if (!s) return false;
150 + const owns = s.userId ? s.userId === owner.userId : s.anonId ? s.anonId === owner.anonId : s.ipHash === owner.ipHash;
151 + if (!owns) return false;
152 + if (assetId !== null && !s.candidates.some((c) => c.assetId === assetId)) return false; // only a proposed candidate can be chosen
138 153 await getDb().update(scannerSessions).set({ chosenAssetId: assetId, chosenAt: new Date() }).where(eq(scannerSessions.id, sessionId));
154 + return true;
139 155 }
140 156
141 157 /** Resolve a marketplace URL through the connector framework (lookup → normalize), via the API service. */
modified apps/web/src/lib/queries/assets.ts +8 −4
@@ -135,7 +135,7 @@ const SORTS: Record<ExploreSort, SQL> = {
135 135 trending: sql`s.trending_score DESC NULLS LAST`,
136 136 newest: sql`a.created_at DESC`,
137 137 latest_sale: sql`s.latest_sale_at DESC NULLS LAST`,
138 − opportunity: sql`s.value_opportunity DESC NULLS LAST`,
138 + opportunity: sql`s.value_opportunity ASC NULLS LAST`, // most below RIV first
139 139 name: sql`a.name ASC`,
140 140 number: sql`a.set_name ASC NULLS LAST, (substring(a.number from '^[0-9]+'))::int ASC NULLS LAST, a.number ASC NULLS LAST`,
141 141 };
@@ -193,13 +193,17 @@ export async function rankedAssets(kind: RankedKind, opts: { scope?: string[]; l
193 193 const chg = opts.window === '1d' ? sql`s.change_1d` : opts.window === '30d' ? sql`s.change_30d` : sql`s.change_7d`;
194 194 const spec: Record<typeof kind, { where: SQL; order: SQL }> = {
195 195 trending: { where: sql`s.trending_score IS NOT NULL`, order: sql`s.trending_score DESC` },
196 − gainers: { where: sql`${chg} IS NOT NULL AND ${chg} > 0 AND s.riv_sample_size >= 3`, order: sql`${chg} DESC` },
197 − losers: { where: sql`${chg} IS NOT NULL AND ${chg} < 0 AND s.riv_sample_size >= 3`, order: sql`${chg} ASC` },
196 + // Movers need a valuation that can move: ≥ 5 transactions, medium confidence, a plausible move (≤ ±500 %) and
197 + // at least one sale in the last year (a stale series cannot "move").
198 + gainers: { where: sql`${chg} IS NOT NULL AND ${chg} > 0 AND ${chg} <= 5 AND s.riv_sample_size >= 5 AND s.riv_confidence >= 0.5 AND coalesce(s.sales_1y, 0) >= 3`, order: sql`${chg} DESC` },
199 + losers: { where: sql`${chg} IS NOT NULL AND ${chg} < 0 AND ${chg} >= -0.99 AND s.riv_sample_size >= 5 AND s.riv_confidence >= 0.5 AND coalesce(s.sales_1y, 0) >= 3`, order: sql`${chg} ASC` },
198 200 volume: { where: sql`s.volume_30d_usd IS NOT NULL AND s.volume_30d_usd > 0`, order: sql`s.volume_30d_usd DESC` },
199 201 watched: { where: sql`s.watchers > 0`, order: sql`s.watchers DESC, s.views_30d DESC` },
200 202 newest: { where: sql`true`, order: sql`a.created_at DESC` },
201 203 expensive: { where: sql`s.riv_usd IS NOT NULL`, order: sql`s.riv_usd DESC` },
202 − opportunity: { where: sql`s.value_opportunity IS NOT NULL AND s.value_opportunity > 0`, order: sql`s.value_opportunity DESC` },
204 + // value_opportunity = (best gated ask − RIV) / RIV: negative = below the valuation. A deal is ≤ −10 %; the
205 + // worker never stores implausible ratios, the ≥ −0.9 bound is a second safety net (§84, §196).
206 + opportunity: { where: sql`s.value_opportunity IS NOT NULL AND s.value_opportunity <= -0.1 AND s.value_opportunity >= -0.9 AND s.riv_sample_size >= 5 AND s.riv_confidence >= 0.5`, order: sql`s.value_opportunity ASC` },
203 207 liquid: { where: sql`s.liquidity_score IS NOT NULL`, order: sql`s.liquidity_score DESC` },
204 208 // Fallback rails used while valuations are still being computed (§192: honest, data-backed)
205 209 newest_priced: { where: sql`s.riv_usd IS NOT NULL`, order: sql`s.updated_at DESC` },
modified apps/web/src/lib/queries/market-lists.ts +2 −2
@@ -97,14 +97,14 @@ export async function listListings(f: ListingsFilters): Promise<{ items: Listing
97 97 if (f.grader) where.push(sql`l.grader = ${f.grader}`);
98 98 if (f.minUsd != null) where.push(sql`l.price_usd >= ${f.minUsd}`);
99 99 if (f.maxUsd != null) where.push(sql`l.price_usd <= ${f.maxUsd}`);
100 − if (f.minDiscount != null) where.push(sql`l.discount_to_riv >= ${f.minDiscount}`);
100 + if (f.minDiscount != null) where.push(sql`l.discount_to_riv <= ${-Math.abs(f.minDiscount)}`); // stored negative = below RIV
101 101 if (f.listingType) where.push(sql`l.listing_type = ${f.listingType}`);
102 102 if (f.q && f.q.trim().length >= 2) where.push(sql`(a.title ILIKE ${'%' + f.q.trim() + '%'} OR l.raw_title ILIKE ${'%' + f.q.trim() + '%'})`);
103 103 const orders: Record<NonNullable<ListingsFilters['sort']>, SQL> = {
104 104 newest: sql`l.first_seen_at DESC`,
105 105 price_asc: sql`l.price_usd ASC NULLS LAST`,
106 106 price_desc: sql`l.price_usd DESC NULLS LAST`,
107 − discount: sql`l.discount_to_riv DESC NULLS LAST`,
107 + discount: sql`l.discount_to_riv ASC NULLS LAST`, // most below RIV first
108 108 ending: sql`l.ends_at ASC NULLS LAST`,
109 109 };
110 110 const r = await rows<Record<string, unknown>>(sql`
added packages/database/migrations/0006_skinny_kulan_gath.sql +13 −0
@@ -0,0 +1,13 @@
1 +DROP INDEX "ai_quotas_uq";--> statement-breakpoint
2 +-- collapse duplicate daily counters created by the former non-atomic read-then-write before the key becomes unique
3 +DELETE FROM "ai_quotas" a USING "ai_quotas" b WHERE a.key = b.key AND a.feature = b.feature AND a.date = b.date AND a.ctid < b.ctid;--> statement-breakpoint
4 +CREATE INDEX "asset_stats_change_7d_idx" ON "asset_stats" USING btree ("change_7d");--> statement-breakpoint
5 +CREATE INDEX "asset_stats_change_30d_idx" ON "asset_stats" USING btree ("change_30d");--> statement-breakpoint
6 +CREATE INDEX "asset_stats_change_1y_idx" ON "asset_stats" USING btree ("change_1y");--> statement-breakpoint
7 +CREATE INDEX "asset_stats_opportunity_idx" ON "asset_stats" USING btree ("value_opportunity") WHERE value_opportunity is not null;--> statement-breakpoint
8 +CREATE INDEX "asset_stats_sales_30d_idx" ON "asset_stats" USING btree ("sales_30d");--> statement-breakpoint
9 +CREATE INDEX "variant_stats_asset_idx" ON "variant_stats" USING btree ("asset_id");--> statement-breakpoint
10 +CREATE INDEX "listings_variant_avail_idx" ON "listings" USING btree ("variant_id","availability");--> statement-breakpoint
11 +CREATE INDEX "listings_discount_idx" ON "listings" USING btree ("discount_to_riv") WHERE discount_to_riv is not null and availability = 'available';--> statement-breakpoint
12 +CREATE INDEX "sales_valid_date_idx" ON "sales" USING btree ("sale_date") WHERE status = 'valid';--> statement-breakpoint
13 +CREATE UNIQUE INDEX "ai_quotas_uq" ON "ai_quotas" USING btree ("key","feature","date");
\ No newline at end of file
added packages/database/migrations/meta/0006_snapshot.json +9035 −0
Large diff (9,036 lines) is not loaded by default to keep the page fast.Load diff
modified packages/database/migrations/meta/_journal.json +7 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
modified packages/database/src/schema/ai.ts +2 −2
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
modified packages/database/src/schema/assets.ts +12 −2
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
modified packages/database/src/schema/market.ts +5 −0
Large diff (17 lines) is not loaded by default to keep the page fast.Load diff
modified packages/taxonomy/src/index.ts +18 −6
Large diff (52 lines) is not loaded by default to keep the page fast.Load diff
modified packages/taxonomy/src/taxonomy.test.ts +12 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
modified packages/valuation/src/scores.ts +106 −5
Large diff (120 lines) is not loaded by default to keep the page fast.Load diff
modified packages/valuation/src/valuation.test.ts +38 −1
Large diff (51 lines) is not loaded by default to keep the page fast.Load diff
modified pnpm-lock.yaml +3 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
modified workers/cli.ts +20 −7
Large diff (59 lines) is not loaded by default to keep the page fast.Load diff
added workers/entity-resolution/regrade.ts +107 −0
Large diff (107 lines) is not loaded by default to keep the page fast.Load diff
modified workers/entity-resolution/writers.ts +37 −17
Large diff (138 lines) is not loaded by default to keep the page fast.Load diff
modified workers/indices/run.ts +7 −4
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
modified workers/valuation/run.ts +48 −18
Large diff (136 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme