[object Object]

← 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

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="/">&larr; 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 →