← back to Lifestyle Asset Intel
yolo tick #5: METHODOLOGY.md + /methodology route
63e056312fc49ea58c1dcc5e842722d939f95906 · 2026-05-09 23:59:36 -0700 · Steve Abrams
Freeze the formal methodology document the BLUEPRINT calls for. Anchors
every rule the v0 stub and future ML iterations must respect: source
eligibility/weighting, comparable selection, four-tier price
normalization (gross→all-in→net→normalized), the 7-grade ordinal
condition system + facet jsonb, quantile interpretation, the live
liquidity/dts formulas (with units and clamps), the 9-component
confidence breakdown table with v0 sources called out per row, the
authenticity-risk three-band escalation rule, current outlier-filtering
posture (none — by design), region/currency scope, index rebasing
rules, and an explicit "what v0 does NOT model" section so enterprise
buyers don't assume coverage that isn't shipped.
methodology_version field is the version pin every published snapshot
and index point references; v0 stamps everything 'v0-stub'. Versioning
discipline: any rule change moving a published quantile by >1% MUST
bump.
Render path: marked@18 (added) parses the .md at boot into HTML; cached
across requests; reload requires pm2 restart. /methodology added to
nav and to console.js routes. New views/methodology.ejs is a thin
wrapper that injects body_html through partials/head + foot. CSS
rules added for typographic long-form: H2 underlines, pre/code
backgrounds, accent-color blockquote bar, link underline-offset.
29/29 tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
A METHODOLOGY.mdM package-lock.jsonM package.jsonM public/css/app.cssM routes/console.jsA views/methodology.ejsM views/partials/head.ejs
Diff
commit 63e056312fc49ea58c1dcc5e842722d939f95906
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat May 9 23:59:36 2026 -0700
yolo tick #5: METHODOLOGY.md + /methodology route
Freeze the formal methodology document the BLUEPRINT calls for. Anchors
every rule the v0 stub and future ML iterations must respect: source
eligibility/weighting, comparable selection, four-tier price
normalization (gross→all-in→net→normalized), the 7-grade ordinal
condition system + facet jsonb, quantile interpretation, the live
liquidity/dts formulas (with units and clamps), the 9-component
confidence breakdown table with v0 sources called out per row, the
authenticity-risk three-band escalation rule, current outlier-filtering
posture (none — by design), region/currency scope, index rebasing
rules, and an explicit "what v0 does NOT model" section so enterprise
buyers don't assume coverage that isn't shipped.
methodology_version field is the version pin every published snapshot
and index point references; v0 stamps everything 'v0-stub'. Versioning
discipline: any rule change moving a published quantile by >1% MUST
bump.
Render path: marked@18 (added) parses the .md at boot into HTML; cached
across requests; reload requires pm2 restart. /methodology added to
nav and to console.js routes. New views/methodology.ejs is a thin
wrapper that injects body_html through partials/head + foot. CSS
rules added for typographic long-form: H2 underlines, pre/code
backgrounds, accent-color blockquote bar, link underline-offset.
29/29 tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
METHODOLOGY.md | 239 ++++++++++++++++++++++++++++++++++++++++++++++++
package-lock.json | 13 +++
package.json | 1 +
public/css/app.css | 15 +++
routes/console.js | 23 +++++
views/methodology.ejs | 6 ++
views/partials/head.ejs | 1 +
7 files changed, 298 insertions(+)
diff --git a/METHODOLOGY.md b/METHODOLOGY.md
new file mode 100644
index 0000000..003c17d
--- /dev/null
+++ b/METHODOLOGY.md
@@ -0,0 +1,239 @@
+# Methodology — `lifestyle-asset-intel`
+
+This document is the **canonical specification** for how the platform turns
+source observations into a quoted valuation, liquidity score, and expected
+days-to-sell. It is the contract enterprise buyers (lenders, insurers,
+resellers, marketplaces) read to understand what they are paying for, and
+the contract future ML iterations must respect when they replace v0 stubs.
+
+`methodology_version` is stamped on every `valuation_snapshot` and every
+`indices.methodology_version` row so callers can pin a known good version
+and audit changes over time. v0 stamps everything `v0-stub`. Versioning
+follows semver-style discipline: any rule change that moves a published
+quantile by more than 1% MUST bump the version.
+
+## 1. Scope
+
+**In scope (v0):** Hermès Birkin and Kelly handbags in U.S. region. Reference
+configurations include Birkin 25/30/35 and Kelly 25/28/32 across Togo, Epsom,
+Clemence, Swift in neutral and fashion colors with GHW or PHW, both Sellier
+and Retourne constructions.
+
+**Out of scope (v0):** all other handbags, watches, sneakers, jewelry,
+non-U.S. regions, sample-only and pre-loved-for-restoration grades. Watches
+ship in v2.0 (Chrono24 connector + ChronoPulse parity); sneakers and jewelry
+follow.
+
+## 2. Source eligibility
+
+Sources are tiered per BLUEPRINT.md. A source is eligible for valuation iff
+it is `enabled=true` in the `sources` table AND its tier-kind pair appears
+in the table below. Tier 4 dealer/partner feeds are NOT eligible for
+published valuations until they are linked to realized transactions (they
+remain eligible as quote/floor signals).
+
+| Tier | Kind | Class | Realized? | v0 weight |
+|------|---------------|------------------------------------|-----------|-----------|
+| 1 | auction | Sotheby's, Christie's | Yes | 1.000 |
+| 2 | marketplace | FASHIONPHILE, The RealReal, StockX | Yes | 0.700–0.800 |
+| 2 | quote | Rebag (Clair, Consign, Trade) | Bid only | 0.700 |
+| 2 | marketplace | Chrono24 | Yes | 0.650 (watches) |
+| 3 | marketplace | eBay (active listings) | Active | 0.350 |
+| 4 | dealer | Direct dealer feed | Quote only| disabled |
+
+Quote-class sources land in the system as **instant-liquidity bids**, never
+as final settled comps. Active-listing observations are admissible only to
+inform liquidity and ask-curve depth — they do NOT enter the price model.
+
+## 3. Comparable selection
+
+`valueAsset(slug)` retrieves transactions where `canonical_asset_id` matches.
+v0 does NOT widen across similar configurations even when comp depth is low;
+that means a rare config will return a low confidence score rather than a
+borrowed-from-cousin estimate. v0.3 introduces image-similarity widening
+(see § 7).
+
+Selection rules (v0):
+
+1. Same `canonical_asset_id`
+2. `transacted_at` within trailing 24 months
+3. `source_id` is enabled
+4. `kind` ∈ {auction, marketplace} (NOT quote-only)
+5. Maximum 50 comps; ordered by `transacted_at DESC`
+
+## 4. Price normalization
+
+Every observation must derive four price measures before entering the model:
+
+- `gross_transaction_price` — what the buyer paid before any platform-fee
+ adjustments (auction hammer + buyer's premium; marketplace ask-realized
+ price as quoted; quote-system offer).
+- `all_in_buyer_price` — gross plus duties, taxes, shipping. For v0 we
+ leave `all_in_buyer_price = gross_transaction_price` (no cross-border
+ modeling); v0.6 adds the duties/shipping table.
+- `expected_net_seller_proceeds` — gross minus the source's seller fee
+ schedule. v0 uses constant haircuts: Sotheby's hammer-to-net ≈ -18%,
+ FASHIONPHILE/Rebag/TRR ≈ -22%, StockX ≈ -12%, eBay ≈ -14%. v0.4
+ replaces these with per-source published schedules.
+- `normalized_market_value` — the row's contribution to the unweighted
+ comp distribution. Today `normalized_market_value = gross_transaction_price`.
+
+Currency is stored as ISO-4217. v0 only ingests USD. Cross-currency comps
+are admissible only after FX-conversion at `transacted_at`'s daily WM/Refinitiv
+fix; v0.6 delivers the FX table.
+
+## 5. Condition normalization
+
+We store both an **ordinal grade** (0–6) and a **facet-level jsonb**.
+
+| Grade | Label | Translation |
+|-------|----------|--------------------------------------------------------------------|
+| 6 | Giftable | Store-fresh, plastic intact, full set, recent stamp |
+| 5 | Pristine | Like-new, minor handling marks acceptable |
+| 4 | Excellent| Light wear; corners and hardware un-scratched |
+| 3 | Very Good| Visible wear, no structural issues |
+| 2 | Good | Wear + some structural concerns (handle darkening, lining stains) |
+| 1 | Fair | Multiple issues; collector-only |
+| 0 | As Is | Major repair, restoration, or non-original parts |
+
+Source grade tables (FASHIONPHILE Giftable→Flawed; The RealReal
+Pristine→As Is; StockX new-only) map deterministically into the ordinal
+above. Mapping table lives in `lib/condition.js` (v0.4 — currently
+unimplemented; seed grades come from the ingest agent).
+
+Facets stored in `condition_facets` jsonb: `corner_wear`, `hardware_scratching`,
+`handle_darkening`, `odor`, `lining_wear`, `shape_loss`, `repair_history`,
+`store_fresh`, `accessories_complete`, `plastic_intact`, `date_stamp_year`.
+The model uses both: a comp graded "Very Good with clean interior" prices
+differently from "Very Good with odor".
+
+## 6. Quantile interpretation
+
+Every published valuation is a triplet:
+
+- `q10` — the 10th percentile of plausible realizable prices
+- `q50` — the median (the headline number)
+- `q90` — the 90th percentile
+
+Q10/Q90 are **two-sided 80% prediction intervals**, not min/max bounds. They
+represent the model's uncertainty about a sale at `as_of`, conditioned on
+the canonical configuration. They are NOT sample percentiles of the comps
+directly — for low-depth configs, the band widens beyond the comp range.
+
+In v0, q10/q90 are seeded as `q50 * {0.85, 1.27}` heuristics. v0.5 replaces
+with quantile-regression model output.
+
+## 7. Liquidity and expected days-to-sell
+
+`computeLiquidity(stats)` (see `lib/valuation.js`):
+
+```text
+liquidity = 0.4 * count_factor
+ + 0.4 * recency_factor
+ + 0.2 * spread_factor
+
+count_factor = log1p(n_comps) / log1p(20) clamped [0, 1]
+recency_factor = 1 - days_to_newest_comp / 365 clamped [0, 1]
+spread_factor = 1 - (Q90 - Q10) / avg_gross clamped [0, 1]
+```
+
+The 24-month trailing window is hard-coded; configs older than 24 months
+score 0.
+
+`computeExpectedDts(liquidity)`:
+
+```text
+dts = round(60 - liquidity * 50) clamped [7, 120] days
+```
+
+Heuristic, replaced by per-source listing-to-sale gap distribution in v0.6
+once we have ask-curve data.
+
+## 8. Compositional confidence
+
+`computeBreakdown(...)` returns a 9-key object whose values sum to a number
+in `[-0.30, 1.00]`. The published `latest_snapshot.confidence` is that sum
+clamped to `[0, 1]`.
+
+| Component | Range | v0 source |
+|------------------------------------|----------------|--------------------------------------------------|
+| `source_quality_weight` | 0 to +0.30 | Comps' `sources.weight`-weighted average × 0.30 |
+| `comparable_similarity_weight` | 0 to +0.18 | Constant 0.18 (no embeddings until v0.3) |
+| `sample_depth_weight` | 0 to +0.20 | log-saturating in n_comps over 20-comp ceiling |
+| `recency_weight` | 0 to +0.15 | Linear decay over 365 days from newest comp |
+| `image_match_weight` | 0 to +0.09 | Constant 0.09 (no image intake until v0.3) |
+| `authenticity_risk_penalty` | -0.15 to 0 | -0.5 × `auth_risk` (clamped) |
+| `condition_uncertainty_penalty` | -0.10 to 0 | -0.05 missing grade; -0.02 if any grade < 4 |
+| `region_gap_penalty` | -0.10 to 0 | -0.03 if any comp.region != asset.region |
+| `fee_model_uncertainty_penalty` | -0.05 to 0 | Constant -0.01 (per-source fee tables in v0.4) |
+
+If any v0-stub component is replaced (e.g., embeddings land, replacing the
+constant `comparable_similarity_weight`), `methodology_version` MUST bump.
+
+## 9. Authenticity risk
+
+Risk in v0 is read from `valuation_snapshots.auth_risk` (seeded). Production
+v1 splits this into a separate model called by the auth-risk service and
+returns one of:
+
+- `auto_clear` — risk < 0.10, valuation publishes
+- `holdout_badge` — 0.10 ≤ risk < 0.35, valuation publishes with a UI badge
+- `human_review` — risk ≥ 0.35, valuation withheld pending human inspection
+
+The agent orchestration scaffold in BLUEPRINT.md hard-codes the
+`escalate_if_authenticity_risk > 0.35` rule — this is non-negotiable.
+
+## 10. Outlier filtering
+
+v0 does NOT filter outliers. Every eligible comp enters the calculation.
+This is a deliberate posture — we'd rather show a wide band with low
+confidence than silently drop a comp that the model can't explain. v0.5
+introduces a documented MAD-based filter with the version bump.
+
+## 11. Region and currency
+
+v0 admits only `region = 'US'` and `currency = 'USD'`. Cross-region comps
+are stored but excluded from the comp pool. v0.6 adds:
+
+- FX-converted price normalization at `transacted_at` daily fix
+- Region-gap penalty refinement (currently constant -0.03)
+- Per-region fee schedules
+
+## 12. Indices
+
+Each row in `indices` defines a configuration cohort. `indices.definition`
+is a jsonb that filters canonical_assets:
+
+```json
+{ "size": "30", "material": "Togo", "colors": ["Gold","Black","Etoupe","Nata"], "region": "US" }
+```
+
+`index_points.value` is the cohort's sales-weighted average `gross` over
+the prior 365 days, frozen on `as_of`. v0 hand-seeds points; v1.1 stands up
+the daily-recompute publisher. **Rebasing rule:** indices are re-based to
+their starting `value` whenever the `definition` changes — old points stay
+under the old `methodology_version`.
+
+## 13. What v0 does NOT model
+
+This list exists so enterprise buyers don't assume the platform handles
+something it doesn't:
+
+- Active-listing bid-ask spread
+- Per-source listing-to-sale time distribution (uses a heuristic)
+- Per-region duties / shipping
+- FX conversion
+- Bulk-portfolio concentration risk
+- Insurance replacement value (which is GROSS, not NET)
+- Hold-period IRR with ownership cost
+- Date-stamp premium curves (treated as flat in v0; the
+ `recent-stamp-premium` index is the v0 lens)
+
+Each of these has a roadmap entry. Until shipped, the API responses do not
+imply coverage.
+
+## 14. Change log
+
+| Version | Date | Change |
+|----------|------------|---------------------------------------------------------------------|
+| v0-stub | 2026-05-09 | Initial methodology — confidence, liquidity, dts all live-computed. |
diff --git a/package-lock.json b/package-lock.json
index d2d1965..9dbaa13 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,6 +13,7 @@
"ejs": "^3.1.10",
"express": "^4.21.0",
"helmet": "^8.1.0",
+ "marked": "^18.0.3",
"morgan": "^1.10.0",
"pg": "^8.13.1"
},
@@ -605,6 +606,18 @@
"node": ">=10"
}
},
+ "node_modules/marked": {
+ "version": "18.0.3",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.3.tgz",
+ "integrity": "sha512-7VT90JOkDeaRWpfjOReRGPEKn0ecdARBkDGL+tT1wZY0efPPqkUxLUSmzy/C7TIylQYJC9STISEsCHrqb/7VIA==",
+ "license": "MIT",
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
diff --git a/package.json b/package.json
index 509f87c..49a7c29 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"ejs": "^3.1.10",
"express": "^4.21.0",
"helmet": "^8.1.0",
+ "marked": "^18.0.3",
"morgan": "^1.10.0",
"pg": "^8.13.1"
},
diff --git a/public/css/app.css b/public/css/app.css
index 58d5a80..f8ba845 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -172,3 +172,18 @@ tfoot tr.totals th, tfoot tr.totals td {
border-top: 2px solid var(--line); border-bottom: none; padding-top: 0.6rem;
font-weight: 600; color: var(--fg);
}
+
+/* methodology long-form (v0.2) */
+.methodology-page { max-width: 880px; }
+.methodology h1 { font-size: 1.7rem; margin: 0.4rem 0 1rem; }
+.methodology h2 { font-size: 1.2rem; margin: 1.8rem 0 0.5rem; padding-bottom: 0.3rem; border-bottom: 1px solid var(--line); }
+.methodology h3 { font-size: 1.0rem; margin: 1.2rem 0 0.3rem; }
+.methodology p { line-height: 1.55; margin: 0.6rem 0; }
+.methodology ul, .methodology ol { padding-left: 1.4rem; line-height: 1.55; }
+.methodology li { margin: 0.25rem 0; }
+.methodology code { background: var(--card); padding: 0.05rem 0.35rem; border-radius: 4px; border: 1px solid var(--line); font-size: 0.88em; }
+.methodology pre { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 0.9rem; overflow: auto; font-size: 0.86em; line-height: 1.45; }
+.methodology pre code { background: transparent; border: none; padding: 0; }
+.methodology table { font-size: 0.92em; }
+.methodology blockquote { border-left: 3px solid var(--accent); margin: 0.8rem 0; padding: 0.4rem 0.9rem; color: var(--muted); }
+.methodology a { text-decoration: underline; text-underline-offset: 2px; }
diff --git a/routes/console.js b/routes/console.js
index f2a3ff7..0110991 100644
--- a/routes/console.js
+++ b/routes/console.js
@@ -1,4 +1,7 @@
const express = require('express');
+const fs = require('fs');
+const path = require('path');
+const { marked } = require('marked');
const { listAssets, valueAsset, getIndex } = require('../lib/valuation');
const {
isValidEmail,
@@ -9,6 +12,18 @@ const {
const router = express.Router();
+// Cache the rendered methodology HTML at boot — the file is static at
+// runtime; if it changes, pm2 restart is the right way to reload.
+const METHODOLOGY_HTML = (() => {
+ try {
+ const md = fs.readFileSync(path.join(__dirname, '..', 'METHODOLOGY.md'), 'utf8');
+ return marked.parse(md, { gfm: true, breaks: false });
+ } catch (e) {
+ console.error('[console] failed to render METHODOLOGY.md:', e.message);
+ return '<p>Methodology document is unavailable. See <code>METHODOLOGY.md</code> in the project root.</p>';
+ }
+})();
+
router.get('/', async (req, res, next) => {
try {
const assets = await listAssets();
@@ -112,6 +127,14 @@ router.post('/portfolio/add', async (req, res, next) => {
} catch (e) { next(e); }
});
+router.get('/methodology', (req, res) => {
+ res.render('methodology', {
+ title: 'Methodology — lifestyle-asset-intel',
+ body_html: METHODOLOGY_HTML,
+ path: req.path
+ });
+});
+
router.post('/portfolio/remove/:id', async (req, res, next) => {
try {
const ownerOut = (req.body && req.body.owner_email) ? req.body.owner_email.trim() : '';
diff --git a/views/methodology.ejs b/views/methodology.ejs
new file mode 100644
index 0000000..fca33f1
--- /dev/null
+++ b/views/methodology.ejs
@@ -0,0 +1,6 @@
+<%- include('partials/head') %>
+<main class="container methodology-page">
+ <p class="crumbs"><a href="/">← Console</a></p>
+ <article class="methodology"><%- body_html %></article>
+</main>
+<%- include('partials/foot') %>
diff --git a/views/partials/head.ejs b/views/partials/head.ejs
index 8ac4fac..22e40ca 100644
--- a/views/partials/head.ejs
+++ b/views/partials/head.ejs
@@ -22,6 +22,7 @@
<a href="/" class="<%= path === '/' ? 'active' : '' %>">Console</a>
<a href="/indices/birkin-30-togo-neutral" class="<%= path && path.indexOf('/indices') === 0 ? 'active' : '' %>">Indices</a>
<a href="/portfolio" class="<%= path && path.indexOf('/portfolio') === 0 ? 'active' : '' %>">Portfolio</a>
+ <a href="/methodology" class="<%= path === '/methodology' ? 'active' : '' %>">Methodology</a>
<a href="/api/sources" target="_blank" rel="noopener">Sources</a>
<a href="/api/health" target="_blank" rel="noopener">API</a>
</nav>
← 660aa14 yolo tick #4: image_assets + image_embeddings (prep for v0.3
·
back to Lifestyle Asset Intel
·
yolo tick #6: /api/version introspection endpoint 0bd3a76 →