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 "@rareindex/search": "workspace:*", 18 "@rareindex/search": "workspace:*",
19 "@rareindex/shared": "workspace:*", 19 "@rareindex/shared": "workspace:*",
20 "@rareindex/taxonomy": "workspace:*", 20 "@rareindex/taxonomy": "workspace:*",
21+ "@rareindex/valuation": "workspace:*",
21 "@tanstack/react-query": "^5.90.0", 22 "@tanstack/react-query": "^5.90.0",
22 "drizzle-orm": "^0.45.0", 23 "drizzle-orm": "^0.45.0",
23 "lucide-react": "^1.0.0", 24 "lucide-react": "^1.0.0",
modified apps/web/src/app/api/scanner/[id]/choose/route.ts +4 −1
@@ -1,6 +1,7 @@
1 import { NextResponse } from 'next/server'; 1 import { NextResponse } from 'next/server';
2 import { z } from 'zod'; 2 import { z } from 'zod';
3 import { chooseCandidate } from '@/lib/ai/scanner'; 3 import { chooseCandidate } from '@/lib/ai/scanner';
4+import { clientIpHash, currentUserId, getAnonId } from '@/lib/ai/request';
4 5
5 export const runtime = 'nodejs'; 6 export const runtime = 'nodejs';
6 7
@@ -8,6 +9,8 @@ export async function POST(req: Request, ctx: { params: Promise<{ id: string }>
8 const { id } = await ctx.params; 9 const { id } = await ctx.params;
9 const body = z.object({ assetId: z.string().nullable() }).safeParse(await req.json().catch(() => ({}))); 10 const body = z.object({ assetId: z.string().nullable() }).safeParse(await req.json().catch(() => ({})));
10 if (!body.success || !/^scan_[a-z0-9]+$/.test(id)) return NextResponse.json({ error: 'Invalid request' }, { status: 400 }); 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 return NextResponse.json({ ok: true }); 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 <RadarRail items={radar} hideWhenEmpty /> 205 <RadarRail items={radar} hideWhenEmpty />
206 <LotsRail items={lots} hideWhenEmpty /> 206 <LotsRail items={lots} hideWhenEmpty />
207 <AssetRail title="Newly Added" subtitle="Latest canonical assets — guide prices labelled, RIV when available" href="/explore?sort=newest" items={newest} metric="guide" /> 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 {watched.length ? <AssetRail title="Most Watched" subtitle="Assets on the most watchlists" href="/explore?sort=trending" items={watched} metric="watchers" /> : null} 209 {watched.length ? <AssetRail title="Most Watched" subtitle="Assets on the most watchlists" href="/explore?sort=trending" items={watched} metric="watchers" /> : null}
210 <MarketInsights rows={markets} /> 210 <MarketInsights rows={markets} />
211 </> 211 </>
modified apps/web/src/components/asset/sales-cards.tsx +2 −5
@@ -1,7 +1,7 @@
1 import type { SaleRow, ListingRow } from '@/lib/queries/assets'; 1 import type { SaleRow, ListingRow } from '@/lib/queries/assets';
2 import { fmtDate, fmtMoney, fmtRelative, cn } from '@/lib/format'; 2 import { fmtDate, fmtMoney, fmtRelative, cn } from '@/lib/format';
3 import { humanize } from '@/lib/taxonomy'; 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 import { GradeBadge, SourceLink, Thumb } from '@/components/market/bits'; 5 import { GradeBadge, SourceLink, Thumb } from '@/components/market/bits';
6 6
7 /** Phone-first card lists for sales and listings (tables stay for md+). */ 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 <div className="flex items-baseline justify-between gap-2"> 48 <div className="flex items-baseline justify-between gap-2">
49 <span className="num text-[15px] font-semibold text-fg">{fmtMoney(l.priceUsd)}</span> 49 <span className="num text-[15px] font-semibold text-fg">{fmtMoney(l.priceUsd)}</span>
50 {l.discountToRiv !== null ? ( 50 {l.discountToRiv !== null ? (
51− <span className="inline-flex items-center gap-1 text-[11px]"> 51+ <VsRiv discount={l.discountToRiv} className="text-[11px]" />
52− <span className="text-subtle">vs RIV</span>
53− <Delta value={-l.discountToRiv} />
54− </span>
55 ) : ( 52 ) : (
56 <span className="text-[11px] text-subtle">{fmtRelative(l.lastSeenAt)}</span> 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 import type { AssetCard as AssetCardData } from '@/lib/queries/assets'; 2 import type { AssetCard as AssetCardData } from '@/lib/queries/assets';
3 import { fmtMoney, fmtNum, fmtRelative, confidenceLabel, cn } from '@/lib/format'; 3 import { fmtMoney, fmtNum, fmtRelative, confidenceLabel, cn } from '@/lib/format';
4 import { catName } from '@/lib/taxonomy'; 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 import { Thumb } from './bits'; 6 import { Thumb } from './bits';
7 import { PriceLine, TileImage } from './asset-tile'; 7 import { PriceLine, TileImage } from './asset-tile';
8 8
@@ -81,7 +81,7 @@ function cell(a: AssetCardData, c: AssetColumn) {
81 case 'trending': 81 case 'trending':
82 return a.trendingScore === null ? <span className="text-subtle">—</span> : a.trendingScore.toFixed(1); 82 return a.trendingScore === null ? <span className="text-subtle">—</span> : a.trendingScore.toFixed(1);
83 case 'opportunity': 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 case 'watchers': 85 case 'watchers':
86 return fmtNum(a.watchers); 86 return fmtNum(a.watchers);
87 case 'updated': 87 case 'updated':
@@ -142,7 +142,7 @@ export function AssetCard({ a, className, metric = 'change30d' }: { a: AssetCard
142 metric === 'latestSale' ? ( 142 metric === 'latestSale' ? (
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 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 ) : metric === 'opportunity' ? ( 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 ) : metric === 'trending' ? ( 146 ) : metric === 'trending' ? (
147 a.trendingScore !== null ? <span className="num text-[11px] text-muted">trend {a.trendingScore.toFixed(1)}</span> : null 147 a.trendingScore !== null ? <span className="num text-[11px] text-muted">trend {a.trendingScore.toFixed(1)}</span> : null
148 ) : value !== null ? ( 148 ) : value !== null ? (
modified apps/web/src/components/market/asset-tile.tsx +2 −4
@@ -3,7 +3,7 @@ import type { ReactNode } from 'react';
3 import type { AssetCard as AssetCardData } from '@/lib/queries/assets'; 3 import type { AssetCard as AssetCardData } from '@/lib/queries/assets';
4 import { cn, confidenceLabel, fmtMoney, fmtNum, fmtRelative } from '@/lib/format'; 4 import { cn, confidenceLabel, fmtMoney, fmtNum, fmtRelative } from '@/lib/format';
5 import { catName } from '@/lib/taxonomy'; 5 import { catName } from '@/lib/taxonomy';
6−import { Delta } from '@/components/ui/primitives'; 6+import { Delta, VsRiv } from '@/components/ui/primitives';
7 7
8 export type TileMetric = 'change7d' | 'change1d' | 'change30d' | 'trending' | 'watchers' | 'riv' | 'opportunity' | 'new' | 'guide' | 'latestSale'; 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 return <span className="num text-[11px] text-muted">{fmtNum(a.watchers)} watching</span>; 61 return <span className="num text-[11px] text-muted">{fmtNum(a.watchers)} watching</span>;
62 case 'opportunity': 62 case 'opportunity':
63 return ( 63 return (
64− <span className="text-[11px] text-muted"> 64+ <VsRiv discount={a.valueOpportunity} className="text-[12px]" />
65− vs RIV <Delta value={a.valueOpportunity === null ? null : -a.valueOpportunity} className="text-[12px]" />
66− </span>
67 ); 65 );
68 case 'new': 66 case 'new':
69 return <span className="text-[11px] text-muted">added {fmtRelative(a.updatedAt)}</span>; 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 import type { LotRow } from '@/lib/queries/market-lists'; 2 import type { LotRow } from '@/lib/queries/market-lists';
3 import { fmtDate, fmtMoney, fmtRelative, fmtPct, cn } from '@/lib/format'; 3 import { fmtDate, fmtMoney, fmtRelative, fmtPct, cn } from '@/lib/format';
4 import { humanize } from '@/lib/taxonomy'; 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 import { AssetTitleCell, GradeBadge, PriceCell, SourceLink, Thumb } from './bits'; 6 import { AssetTitleCell, GradeBadge, PriceCell, SourceLink, Thumb } from './bits';
7 7
8 /** Card list for sales on small screens (image, title, grade, price, source). */ 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 </span> 119 </span>
120 <span className="num flex shrink-0 flex-col items-end leading-tight"> 120 <span className="num flex shrink-0 flex-col items-end leading-tight">
121 <span className="text-[14px] font-semibold text-fg">{fmtMoney(l.priceUsd)}</span> 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 </span> 123 </span>
124 </a> 124 </a>
125 </li> 125 </li>
@@ -186,7 +186,7 @@ export function ListingsTable({ items, showAsset = true, className, emptyDescrip
186 <span className="text-subtle" title="No valuation to compare against">—</span> 186 <span className="text-subtle" title="No valuation to compare against">—</span>
187 ) : ( 187 ) : (
188 <span className="inline-flex flex-col items-end leading-tight"> 188 <span className="inline-flex flex-col items-end leading-tight">
189− <Delta value={-l.discountToRiv} /> 189+ <VsRiv discount={l.discountToRiv} showLabel={false} />
190 {l.assetRivUsd !== undefined && l.assetRivUsd !== null ? <span className="text-[10px] text-subtle">RIV {fmtMoney(l.assetRivUsd)}</span> : null} 190 {l.assetRivUsd !== undefined && l.assetRivUsd !== null ? <span className="text-[10px] text-subtle">RIV {fmtMoney(l.assetRivUsd)}</span> : null}
191 </span> 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 <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>} /> 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 <div className="grid gap-4 p-4 sm:grid-cols-3"> 261 <div className="grid gap-4 p-4 sm:grid-cols-3">
262 <Stat label="Asking price" value={r.listing.price !== null ? fmtMoney(r.listing.price, r.listing.currency ?? 'USD') : '—'} sub={r.listing.rawTitle} /> 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 <Stat label="Grade on listing" value={r.listing.grader ? `${r.listing.grader.toUpperCase()} ${r.listing.grade ?? ''}` : 'Raw / unknown'} /> 264 <Stat label="Grade on listing" value={r.listing.grader ? `${r.listing.grader.toUpperCase()} ${r.listing.grade ?? ''}` : 'Raw / unknown'} />
265 </div> 265 </div>
266 </Card> 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 export function Badge({ children, tone = 'neutral', className, dot = false }: { children: ReactNode; tone?: 'neutral' | 'gain' | 'loss' | 'index' | 'rarity' | 'alert' | 'gold'; className?: string; dot?: boolean }) { 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 const tones: Record<string, string> = { 67 const tones: Record<string, string> = {
43 neutral: 'bg-inset text-muted', 68 neutral: 'bg-inset text-muted',
modified apps/web/src/lib/ai/request.ts +8 −8
@@ -2,7 +2,7 @@ import 'server-only';
2 import { createHmac, randomUUID } from 'node:crypto'; 2 import { createHmac, randomUUID } from 'node:crypto';
3 import { cookies, headers } from 'next/headers'; 3 import { cookies, headers } from 'next/headers';
4 import { getCurrentUser } from '@/lib/auth/session'; 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 const SECRET = () => process.env.SESSION_SECRET ?? 'dev-only'; 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 const limit = opts.userId ? QUOTAS[feature]!.user : QUOTAS[feature]!.anonymous; 56 const limit = opts.userId ? QUOTAS[feature]!.user : QUOTAS[feature]!.anonymous;
57 const key = opts.userId ? `user:${opts.userId}` : `ip:${opts.ipHash}`; 57 const key = opts.userId ? `user:${opts.userId}` : `ip:${opts.ipHash}`;
58 const date = new Date().toISOString().slice(0, 10); 58 const date = new Date().toISOString().slice(0, 10);
59− const db = getDb(); 59+ // Atomic increment (unique (key, feature, date)): concurrent requests can no longer create
60− const [row] = await db.execute(sql`select count from ai_quotas where key = ${key} and feature = ${feature} and date = ${date} limit 1`); 60+ // duplicate counters and slip past the limit.
61− const used = Number((row as { count?: number } | undefined)?.count ?? 0); 61+ const [row] = await getDb().execute(sql`insert into ai_quotas (key, feature, date, count) values (${key}, ${feature}, ${date}, 1)
62− if (used >= limit) return { ok: false, used, limit }; 62+ on conflict (key, feature, date) do update set count = ai_quotas.count + 1 returning count`);
63− if (row) await db.execute(sql`update ai_quotas set count = count + 1 where key = ${key} and feature = ${feature} and date = ${date}`); 63+ const used = Number((row as { count?: number } | undefined)?.count ?? 1);
64− else await db.insert(aiQuotas).values({ key, feature, date, count: 1 }); 64+ if (used > limit) return { ok: false, used: used - 1, limit };
65− return { ok: true, used: used + 1, limit }; 65+ return { ok: true, used, limit };
66 } 66 }
67 67
68 export function aiErrorMessage(err: unknown): { status: number; message: string } { 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 const i = input as z.infer<typeof TOOL_SCHEMAS.screen>; 203 const i = input as z.infer<typeof TOOL_SCHEMAS.screen>;
204 const col = sql.raw(`s.${WINDOWS[i.window as Window]}`); 204 const col = sql.raw(`s.${WINDOWS[i.window as Window]}`);
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; 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 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 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 ${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 ${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 ${i.min_change != null ? sql`and ${col} >= ${i.min_change}` : sql``} ${i.max_change != null ? sql`and ${col} <= ${i.max_change}` : sql``} 209 ${i.min_change != null ? sql`and ${col} >= ${i.min_change}` : sql``} ${i.max_change != null ? sql`and ${col} <= ${i.max_change}` : sql``}
209 ${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 ${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 return { filters: i, count: rows.length, results: rows }; 212 return { filters: i, count: rows.length, results: rows };
212 } 213 }
213 case 'record_sales': { 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 const [s] = await db.select().from(researchSessions).where(eq(researchSessions.id, sessionId)).limit(1); 48 const [s] = await db.select().from(researchSessions).where(eq(researchSessions.id, sessionId)).limit(1);
49 if (!s) return null; 49 if (!s) return null;
50 if (s.userId && s.userId !== owner.userId) return null; 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 const messages = await db.select().from(researchMessages).where(eq(researchMessages.sessionId, sessionId)).orderBy(researchMessages.createdAt); 54 const messages = await db.select().from(researchMessages).where(eq(researchMessages.sessionId, sessionId)).orderBy(researchMessages.createdAt);
53 return { session: s, messages }; 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 import { identifyCollectible, getRouter, type ImageInput, type Identification } from '@rareindex/ai'; 5 import { identifyCollectible, getRouter, type ImageInput, type Identification } from '@rareindex/ai';
6 import { lookupViaApi } from './lookup'; 6 import { lookupViaApi } from './lookup';
7 import { findCandidates, assetContext, type Candidate } from './candidates'; 7 import { findCandidates, assetContext, type Candidate } from './candidates';
8+import { assessAsk } from '@rareindex/valuation';
9+import { toUsdAt } from '@/lib/account/fx';
8 10
9 export interface ScanResult { 11 export interface ScanResult {
10 sessionId: string; 12 sessionId: string;
@@ -33,7 +35,7 @@ export interface ListingSummary {
33 identifiers: Record<string, string>; 35 identifiers: Record<string, string>;
34 /** price vs RIV when both known: negative = below fair value */ 36 /** price vs RIV when both known: negative = below fair value */
35 discountToRiv: number | null; 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 interface ScanInput { 41 interface ScanInput {
@@ -104,9 +106,16 @@ export async function runScan(input: ScanInput): Promise<ScanResult> {
104 const best = top && top.score >= 0.25 ? { ...top, context: await assetContext(top.assetId) } : null; 106 const best = top && top.score >= 0.25 ? { ...top, context: await assetContext(top.assetId) } : null;
105 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.'); 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') { 109+ if (listing && best?.rivUsd && listing.price !== null && listing.currency) {
108− listing.discountToRiv = (listing.price - best.rivUsd) / best.rivUsd; 110+ // Compare in USD at today's ECB rate; gate exactly like the valuation worker (transaction-based RIV,
109− listing.verdict = listing.discountToRiv <= -0.15 ? 'below_fair_value' : listing.discountToRiv >= 0.2 ? 'above_fair_value' : 'in_range'; 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 const durationMs = Date.now() - started; 121 const durationMs = Date.now() - started;
@@ -134,8 +143,15 @@ export async function runScan(input: ScanInput): Promise<ScanResult> {
134 return { sessionId: id, mode: input.mode, guess, guessConfidence: guess?.confidence ?? null, candidates, best, listing, model, usdEst, durationMs, notes }; 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 await getDb().update(scannerSessions).set({ chosenAssetId: assetId, chosenAt: new Date() }).where(eq(scannerSessions.id, sessionId)); 153 await getDb().update(scannerSessions).set({ chosenAssetId: assetId, chosenAt: new Date() }).where(eq(scannerSessions.id, sessionId));
154+ return true;
139 } 155 }
140 156
141 /** Resolve a marketplace URL through the connector framework (lookup → normalize), via the API service. */ 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 trending: sql`s.trending_score DESC NULLS LAST`, 135 trending: sql`s.trending_score DESC NULLS LAST`,
136 newest: sql`a.created_at DESC`, 136 newest: sql`a.created_at DESC`,
137 latest_sale: sql`s.latest_sale_at DESC NULLS LAST`, 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 name: sql`a.name ASC`, 139 name: sql`a.name ASC`,
140 number: sql`a.set_name ASC NULLS LAST, (substring(a.number from '^[0-9]+'))::int ASC NULLS LAST, a.number ASC NULLS LAST`, 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 const chg = opts.window === '1d' ? sql`s.change_1d` : opts.window === '30d' ? sql`s.change_30d` : sql`s.change_7d`; 193 const chg = opts.window === '1d' ? sql`s.change_1d` : opts.window === '30d' ? sql`s.change_30d` : sql`s.change_7d`;
194 const spec: Record<typeof kind, { where: SQL; order: SQL }> = { 194 const spec: Record<typeof kind, { where: SQL; order: SQL }> = {
195 trending: { where: sql`s.trending_score IS NOT NULL`, order: sql`s.trending_score DESC` }, 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` }, 196+ // Movers need a valuation that can move: ≥ 5 transactions, medium confidence, a plausible move (≤ ±500 %) and
197− losers: { where: sql`${chg} IS NOT NULL AND ${chg} < 0 AND s.riv_sample_size >= 3`, order: sql`${chg} ASC` }, 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 volume: { where: sql`s.volume_30d_usd IS NOT NULL AND s.volume_30d_usd > 0`, order: sql`s.volume_30d_usd DESC` }, 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 watched: { where: sql`s.watchers > 0`, order: sql`s.watchers DESC, s.views_30d DESC` }, 201 watched: { where: sql`s.watchers > 0`, order: sql`s.watchers DESC, s.views_30d DESC` },
200 newest: { where: sql`true`, order: sql`a.created_at DESC` }, 202 newest: { where: sql`true`, order: sql`a.created_at DESC` },
201 expensive: { where: sql`s.riv_usd IS NOT NULL`, order: sql`s.riv_usd DESC` }, 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 liquid: { where: sql`s.liquidity_score IS NOT NULL`, order: sql`s.liquidity_score DESC` }, 207 liquid: { where: sql`s.liquidity_score IS NOT NULL`, order: sql`s.liquidity_score DESC` },
204 // Fallback rails used while valuations are still being computed (§192: honest, data-backed) 208 // Fallback rails used while valuations are still being computed (§192: honest, data-backed)
205 newest_priced: { where: sql`s.riv_usd IS NOT NULL`, order: sql`s.updated_at DESC` }, 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 if (f.grader) where.push(sql`l.grader = ${f.grader}`); 97 if (f.grader) where.push(sql`l.grader = ${f.grader}`);
98 if (f.minUsd != null) where.push(sql`l.price_usd >= ${f.minUsd}`); 98 if (f.minUsd != null) where.push(sql`l.price_usd >= ${f.minUsd}`);
99 if (f.maxUsd != null) where.push(sql`l.price_usd <= ${f.maxUsd}`); 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 if (f.listingType) where.push(sql`l.listing_type = ${f.listingType}`); 101 if (f.listingType) where.push(sql`l.listing_type = ${f.listingType}`);
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() + '%'})`); 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 const orders: Record<NonNullable<ListingsFilters['sort']>, SQL> = { 103 const orders: Record<NonNullable<ListingsFilters['sort']>, SQL> = {
104 newest: sql`l.first_seen_at DESC`, 104 newest: sql`l.first_seen_at DESC`,
105 price_asc: sql`l.price_usd ASC NULLS LAST`, 105 price_asc: sql`l.price_usd ASC NULLS LAST`,
106 price_desc: sql`l.price_usd DESC NULLS LAST`, 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 ending: sql`l.ends_at ASC NULLS LAST`, 108 ending: sql`l.ends_at ASC NULLS LAST`,
109 }; 109 };
110 const r = await rows<Record<string, unknown>>(sql` 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