Lane G R23 (v5.0-alpha): THE FIRST REAL CRATE — 120 real records from a real crate + the G3 draft
godverse_stock.py splits at THE SNAPSHOT BOUNDARY: `snapshot` (runs where the DBs are; freezes every source row + every cover's sha256) -> `pack` (pure: snapshot + bytes -> atlas, no DB, no network). That split is what lets E's validate_atlas gate G's output on m3 without dealgod. Artifact: web/assets/stock_godverse/3962749/ — crate 550 "DEEP HOUSE", 120 real records, 2 atlases (2048^2, 256px cells, E's contract verbatim). Byte-identical re-bake verified; resolves through C's untouched stockAdapter (120 items, real titles/prices, one material per atlas => one draw); missing atlas -> null -> parody, zero console noise. THE BRIEF'S PREMISE DIDN'T HOLD (measured): no Newtown record shop has stock in any database (Egg Records / Papa Disquo / Haze Guitars / Vintage Record / Downtown Music: 0 items each). dealgod.stores is not a census — 8,754 of 9,285 are Discogs seller accounts, zero Newtown. thriftgod.item is a demo seed (2,221 items, 29 shops, all Brisbane/Newcastle charity, zero record shops). mint() cannot produce tier 1: TABLESAMPLE SYSTEM (0.5) + unseeded random.* — random.seed appears nowhere in server.py — and it assigns other sellers' listings to shops that never owned them, voiding the licence law's "the stores' own product shots" green and making write-back meaningless. Not risk #3's fuzzy matching: a population mismatch, nothing to match to. What we shipped instead is truer: monsterrobot (147 Musgrave Rd, Red Hill, Brisbane — John's ruling) is one real shop in three datasets — recordgod (its POS: 28,624 live vinyl, 24,914 in 357 real crates with real labels and slot numbers), monsterrobot.party (its own site: the covers are the SHOP'S OWN product shots), dealgod store 3962749 (the cover cache + the godverse id; clear of the census space, thriftgod max shop id = 2992). 99.3% of its crate stock joins to a cached cover by Discogs release_id. Drops counted: 120 of 122 baked, 2 lacked an own photo. John's R23 rulings recorded: the crate ships as the shop it really is (Red Hill, not Newtown); real titles/artists at tier 1 — an amendment to the no-real-trademarks law build_stock_pack.py enforces, same shape as the licence law (in-house green, flagged before anything public). G3_ECONOMY_DESIGN.md drafts the API surface, async enrichment (the dig never blocks), failure modes, identity, the PII fence (recordgod holds real customer/staff/sales — pipeline reads only inventory/crate/disc_cache), and frames the write-back question for John (§7) against a real trading shop with Square sync and an existing hold_expires_at primitive. No default, per law #4. Asks in G-progress (both outside G's namespace, both gate the crate reaching a room): per-shop packs aren't wired (stockpack.js caches by type alone — confirmed live: a base override is silently ignored, so two stocked shops collide; C owns the wording, G re-emits), and redhill_real doesn't exist (needs E's build_towns.py + one Overpass fetch; G touched neither). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
84e0a1b764
commit
84f3641791
@ -3,6 +3,78 @@
|
||||
*Lane brief: `docs/LANES/LANE_G_GODVERSE.md`. This file follows your progress-file convention —
|
||||
newest round first. Fable-on-ultra writing.*
|
||||
|
||||
## Round 23 (2026-07-16) — G2a: THE FIRST REAL CRATE + the G3 draft
|
||||
|
||||
**Landed (branch `lane-G-godverse`, G namespace only — ready for your mid-round merge):**
|
||||
- `pipeline/godverse_stock.py` — the per-shop atlas pipeline, split at **the snapshot boundary**:
|
||||
`snapshot` (runs where the DBs are; freezes every source row + every cover's sha256) →
|
||||
`pack` (pure: snapshot + image bytes → atlas, no DB, no network). That split is deliberate —
|
||||
**it's what lets E's `validate_atlas` gate G's output on m3 without the dealgod DB** (ledger #4).
|
||||
- `pipeline/_godverse/monsterrobot_550.snapshot.json` — the artifact of record (committed).
|
||||
- `web/assets/stock_godverse/3962749/` — **120 real records** from a real crate: 2 atlases
|
||||
(2048², 256px cells, E's contract verbatim) + index with provenance.
|
||||
- `docs/G3_ECONOMY_DESIGN.md` — the draft (ledger #2), incl. **the write-back question framed for
|
||||
John** (§7), now grounded in a real trading shop rather than a hypothetical.
|
||||
- **Verified:** byte-identical re-bake (same snapshot → same bytes); uv ⊂ [0,1]; every item's
|
||||
atlas ref resolves; bands standard 58 / collector 56 / grail 6. Eyeballed the atlas: it's a real
|
||||
deep-house crate (Basement Jaxx, Byron Stingily, Angel Moraes, 4hero, Murk, Blaze).
|
||||
|
||||
**THE HEADLINE FINDING — the brief's premise doesn't hold (all measured, R23):**
|
||||
The ledger asked for "dealgod inventory → one real **Newtown** record shop". That can't be built:
|
||||
- **No Newtown record shop has stock in any database.** Egg Records / Papa Disquo / Haze Guitars /
|
||||
The Vintage Record / Downtown Music: **0 items each**.
|
||||
- **`dealgod.stores` is not a shop census** — 8,754 of 9,285 are **Discogs seller accounts**.
|
||||
Zero Newtown stores, zero census name matches. It's an item *feed*, not a shop directory.
|
||||
- **`thriftgod.item` is a demo seed** — 2,221 items across 29 shops, **all Brisbane/Newcastle/Alice
|
||||
charity shops**, zero record shops.
|
||||
- **`mint()` cannot produce tier 1**: `TABLESAMPLE SYSTEM (0.5)` + `random.random/uniform/shuffle`,
|
||||
and **`random.seed` appears nowhere in `server.py`**. Non-deterministic, and it assigns other
|
||||
sellers' listings to shops that never owned them — which also voids the licence law's "the
|
||||
*stores' own* product shots" green and makes write-back meaningless. (G3 §2.)
|
||||
- This is **not** risk #3's "fuzzy matching" — it's a population mismatch. Nothing to match *to*.
|
||||
|
||||
**What we found instead — and it's better.** `monsterrobot` (**147 Musgrave Rd, Red Hill, Brisbane**
|
||||
— John's ruling) is the same real shop in three datasets: `recordgod` (its POS: 28,624 live vinyl,
|
||||
**24,914 in 357 real crates** with real labels and slot numbers), `monsterrobot.party` (its own
|
||||
site — **the covers are the shop's own product shots**), and dealgod store **3962749** (the scrape;
|
||||
the cover cache + the godverse id). **99.3%** of its crate stock joins to a cached cover by Discogs
|
||||
`release_id`. Not in thriftgod's census, so its godverse id = its dealgod store id 3962749 (real,
|
||||
stable, clear of the census space — thriftgod max shop id = 2992). Drops counted: 120 of 122 in
|
||||
crate 550 baked; 2 dropped (no own photo).
|
||||
|
||||
**Rulings received from John this round (recorded):**
|
||||
1. **The crate ships as the shop it really is** — Red Hill, Brisbane, not Newtown.
|
||||
2. **Real titles/artists at tier 1** — an amendment to the no-real-trademarks law that
|
||||
`build_stock_pack.py` enforces (it parody-transforms metadata). Same shape as the licence law:
|
||||
in-house 🟢, **flagged before anything public**. Recorded in every atlas's provenance.
|
||||
|
||||
**Asks — two things gate the crate reaching a room, and both are outside G's namespace:**
|
||||
1. **[C + F] Per-shop packs aren't wired at runtime.** `stockpack.js` caches by `type` alone
|
||||
(`_packs`/`_resolved`), and `interior_mode.js:192` does `getStockPack(shop.type)` with `base`
|
||||
fixed to `assets/models/`. R23's single shop works via a `base` override, but **two stocked
|
||||
shops collide in that cache** — so this blocks v5.0-beta, not just ledger #3. Seam needed:
|
||||
key the cache by `type + base` (or `godverse_id`) + preload per-shop on entry. **C owns the
|
||||
wording (LANE_C_PUB); G re-emits to whatever lands.**
|
||||
2. **[E, via Fable] The crate has no street.** `redhill_real` doesn't exist; `westend_real` is the
|
||||
only Brisbane cache and stops ~2.3 km south. Needs a `build_towns.py` TOWNS entry
|
||||
(~`center: (-27.4553, 153.0064), span_km: 2.4`) + one Overpass fetch, then
|
||||
`godverse_town.py redhill_real redhill_godverse`. **That's E's file and an outward-facing
|
||||
fetch — G touched neither.** Note monsterrobot must be added to the godverse cache's `shops[]`
|
||||
as id 3962749 (it's not in the census); `godverse_town.py` takes shops from thriftgod only, so
|
||||
that's a small G change once you rule on the town.
|
||||
|
||||
**Environment note (matters for reproducibility):** this ran on the **m3**, not ultra — the box has
|
||||
neither `thriftgod` nor `dealgod` Postgres. Both were restored/read from John's mounted backup
|
||||
drive (`/Volumes/johnking/db-backups`, **2026-07-15** dumps); dealgod (2 GB gz) was never restored,
|
||||
its cover map was streamed straight out of the dump (command recorded in
|
||||
`godverse_stock.py:load_image_map`). This is *better* for the charter's snapshot discipline, not
|
||||
worse: the dump date is pinned in every snapshot's provenance. I left two restored DBs on the m3
|
||||
(`thriftgod`, `recordgod`) — say the word and I'll `dropdb` them.
|
||||
|
||||
**Fences held:** PII — `recordgod` holds real `customer`/`staff`/`sales`; the pipeline reads only
|
||||
`inventory`/`crate`/`disc_cache`, and G3 §9 makes that a standing fence. Licence — covers are the
|
||||
shop's own shots, in-house 🟢, flagged before public, provenance in every atlas.
|
||||
|
||||
## Round 1 (2026-07-16, ultra) — the marriage's first artifact
|
||||
|
||||
**Landed:**
|
||||
|
||||
247
docs/G3_ECONOMY_DESIGN.md
Normal file
247
docs/G3_ECONOMY_DESIGN.md
Normal file
@ -0,0 +1,247 @@
|
||||
# G3 — THE ECONOMY BACKEND (design doc, DRAFT for ratification)
|
||||
|
||||
*Lane G (ultra), R23. Drafted per the v5 charter ([V5_REAL_SHOP.md](V5_REAL_SHOP.md)) ledger #2.
|
||||
Ratification: both Fables + John, at R24 kickoff. **Nothing server-side ships before that.** This
|
||||
round is paper — plus the tier-1 artifact that made the paper honest (G2a, merged this round).*
|
||||
|
||||
Status of each section: **[MEASURED]** = verified against the real databases this round.
|
||||
**[DESIGN]** = proposed, not built. **[JOHN]** = a decision reserved to John by charter law.
|
||||
|
||||
---
|
||||
|
||||
## 1. What the data actually is [MEASURED]
|
||||
|
||||
The charter assumed one join: thriftgod's census of real shops → dealgod's real items. **That join
|
||||
does not exist.** The three datasets are three different populations:
|
||||
|
||||
| dataset | what it really holds | inventory? |
|
||||
|---|---|---|
|
||||
| `thriftgod.shop` | 2,928 real AU secondhand shops, OSM-anchored (the census) | **no** |
|
||||
| `dealgod.stores` | 9,285 online sellers — **8,754 are Discogs accounts**, not physical shops | 1.77M listings |
|
||||
| `recordgod` | **one real trading record shop's POS** — crates, slots, prices, grading | 28,624 live |
|
||||
|
||||
Measured consequences:
|
||||
|
||||
- **No Newtown record shop has any stock, in any database.** Egg Records, Papa Disquo, Haze
|
||||
Guitars, The Vintage Record, Downtown Music: 0 items each. dealgod has zero Newtown stores and
|
||||
zero census name matches.
|
||||
- `thriftgod.item` (the minted table) holds 2,221 items across **29 shops — all Brisbane/
|
||||
Newcastle/Alice charity shops**. Zero record shops. It is a demo seed, not a stock system.
|
||||
- This is **not** charter risk #3 ("fuzzy matching"). It is a population mismatch. Fuzzy matching
|
||||
cannot fix it, because there is nothing on the other side of the join to match *to*.
|
||||
|
||||
### 1a. The one shop where all three meet [MEASURED]
|
||||
|
||||
`monsterrobot` — **147 Musgrave Road, Red Hill, Brisbane QLD** (John, R23) — is the same real shop
|
||||
in three forms, and that is the whole reason tier 1 can be honest:
|
||||
|
||||
```
|
||||
recordgod (its POS) ──┐
|
||||
monsterrobot.party ├── one real shop: real crates · real slots · real prices · its OWN photos
|
||||
dealgod store 3962749 ─┘
|
||||
```
|
||||
|
||||
- 28,624 live vinyl; **24,914 physically placed in 357 real crates** with real labels
|
||||
("DEEP HOUSE", "$12–$15 TECHNO") and real slot numbers — with gaps where records actually sold.
|
||||
- Covers are the shop's own product shots on the shop's own domain, cached by dealgod
|
||||
(23,392 releases). **99.3%** of its crate stock joins to a cached cover by Discogs `release_id`.
|
||||
- It is **not in thriftgod's OSM census**, so its godverse id is its dealgod store id `3962749`
|
||||
(real, stable, clear of the census space — thriftgod max shop id = 2992).
|
||||
|
||||
**Shipped this round (G2a):** crate 550 "DEEP HOUSE" → 120 real records → `web/assets/
|
||||
stock_godverse/3962749/`. Byte-identical re-bake verified.
|
||||
|
||||
---
|
||||
|
||||
## 2. Why `mint()` cannot be the source of any tier [MEASURED]
|
||||
|
||||
thriftgod's `mint()` is the obvious candidate for stocking shops. It disqualifies itself three ways:
|
||||
|
||||
1. **It is not deterministic.** `FEED_RECORDS` uses `TABLESAMPLE SYSTEM (0.5)`; `mint()` uses
|
||||
`random.random()`, `random.uniform()`, `random.shuffle()`. **`random.seed` appears nowhere in
|
||||
`server.py`.** Re-running mint gives different stock at different prices. That is fatal to tier
|
||||
1 (charter law #2), and it is *why* tier 1 reads the POS instead: a crate is a table of facts.
|
||||
2. **The shop assignment is fictional.** mint samples arbitrary Discogs sellers' listings and
|
||||
assigns them to a census shop that never owned them.
|
||||
3. **Therefore it breaks two laws downstream.** The licence law's green rests on "the *stores'
|
||||
own* product shots" — a Discogs seller's photo shown as Egg Records' stock isn't that. And the
|
||||
write-back question (§7) is **meaningless** for minted stock: you cannot mark real inventory
|
||||
sold for an item the shop never had.
|
||||
|
||||
mint stays what it is: a fun demo seeder for parody tier 0. It is not an inventory system.
|
||||
*(If a future round wants seeded mint for tier 0 variety, seeding it is a small, safe change —
|
||||
`random.Random(shop_id)` + an ordered `ORDER BY id` sample instead of `TABLESAMPLE`.)*
|
||||
|
||||
---
|
||||
|
||||
## 3. The tier ladder, as built [MEASURED for 0–1, DESIGN for 2]
|
||||
|
||||
| tier | source | deterministic | offline |
|
||||
|---|---|---|---|
|
||||
| 0 · parody | seeded canvas / E's parody packs | yes | yes |
|
||||
| 1 · static real | **per-shop atlas baked from the POS snapshot** (G2a, shipped) | **yes — verified** | **yes — files** |
|
||||
| 2 · live | thriftgod server over the POS (this doc) | **no, by design** | **enriches only** |
|
||||
|
||||
**The offline law is structural, not a promise.** `stockpack.js` already returns `null` when an
|
||||
index or atlas is missing, and every caller falls back to parody canvas. Tier 2 rides the same
|
||||
seam: the server is an *enrichment* that upgrades an already-playable tier-1 room. There is no
|
||||
boot path, gate, or tag that may touch the network.
|
||||
|
||||
---
|
||||
|
||||
## 4. API surface [DESIGN]
|
||||
|
||||
Read-only in v5.0; the write verbs are drafted here but **gated on §7**.
|
||||
|
||||
```
|
||||
GET /godverse/v1/shop/<godverse_id>/stock?crate=<id>&since=<iso>
|
||||
-> { shop_id, crate, tier, items:[{sku, slot, release_id, artist, title,
|
||||
price, condition, sleeve_cond, cover, state}],
|
||||
gone:[sku], etag, served_at }
|
||||
GET /godverse/v1/shop/<godverse_id>/crates -> crate list + counts (the rotation menu)
|
||||
POST /godverse/v1/reserve { sku, session } -> { ok, holds_until } [GATED — §7]
|
||||
POST /godverse/v1/buy { sku, session, hold } -> { ok, receipt } [GATED — §7]
|
||||
```
|
||||
|
||||
Design rules:
|
||||
|
||||
- **The atlas is never served by the API.** Covers stay static files (tier 1). The server sends
|
||||
*facts* (what's still there, what it costs), never pixels. A tier-2 shop with a dead server is
|
||||
visually identical to tier 1 — that's the fail-soft.
|
||||
- **`gone[]` is the whole point of tier 2.** The client already holds the tier-1 crate; the server
|
||||
says only what changed. A sold record is a `sku` in `gone[]` → `collapseBuyItem()` (already
|
||||
built, R9) zero-areas the sleeve. Sold-means-gone is a *diff*, not a re-fetch.
|
||||
- **`etag`/`since`** so a re-enter is a 304, not a payload.
|
||||
- Shop id is the **godverse id** (§1a), never a POS internal id.
|
||||
|
||||
---
|
||||
|
||||
## 5. Async enrichment + caching (risk #1) [DESIGN]
|
||||
|
||||
**Law: the dig never blocks on the network.** Not "usually" — never. The mechanism:
|
||||
|
||||
1. Room builds at tier 1 from static files. Playable. Riffling works. **No await.**
|
||||
2. The stock fetch is fired in parallel, never awaited by any build path, with a **hard ~400 ms
|
||||
budget** and `AbortController`.
|
||||
3. If it lands in time: apply `gone[]` + price deltas before the player reaches the crate.
|
||||
4. If it lands late: apply on arrival (a record vanishing mid-riffle is *correct* — that's a real
|
||||
shop). If it never lands: the room stays tier 1, silently. **Zero console noise** — a dead
|
||||
server is an expected state, not an error.
|
||||
5. Cache the response per `(shop, crate)` in memory + `sessionStorage`, keyed by `etag`. One
|
||||
fetch per shop per session, not per crate entry.
|
||||
6. **One flight at a time per shop**; re-entering a shop reuses the cache. No polling loop.
|
||||
|
||||
The v1 machinery makes this cheap: the pack is a shared material + per-item UVs, so applying
|
||||
`gone[]` is a vertex collapse on an existing merged mesh — no rebuild, no re-upload, no stutter.
|
||||
|
||||
---
|
||||
|
||||
## 6. Identity: census ↔ lot ↔ POS (risk #3) [DESIGN, informed by §1]
|
||||
|
||||
The R22 contract said "atlas per shop keyed by godverse shop id". §1 shows the id must come from
|
||||
whichever dataset actually *has* the shop, and mismatches must fail soft, never mis-stock:
|
||||
|
||||
- **A shop is stocked only by explicit, recorded identity** — a `godverse_id` that resolves to a
|
||||
real POS. There is no fuzzy name matching, ever. A shop we cannot identify with certainty gets
|
||||
tier 0. **Mis-stocking is worse than no stock**: it puts one shop's records in another's crate,
|
||||
which is a lie the whole epoch exists to avoid.
|
||||
- **Every unmatched census shop is counted, not guessed** (the drops law). R23's count: 2,928
|
||||
census shops, 1 identified real POS.
|
||||
- **The census is not a stock oracle.** Being in the census means the shop exists, not that we
|
||||
know what's in it. v5.0-beta's "every hero shop stocked" is therefore bounded by *real POS
|
||||
access*, not by census coverage — see §9.
|
||||
|
||||
---
|
||||
|
||||
## 7. THE WRITE-BACK QUESTION [JOHN — charter law #4]
|
||||
|
||||
**Does an in-game purchase ever touch real thriftgod/recordgod inventory?**
|
||||
|
||||
This is now a live question, not a hypothetical: `monsterrobot` is a **real trading shop** whose
|
||||
POS syncs to **Square**, with real customers buying these exact records off the real shelf. Stated
|
||||
per charter law #4 with trade-offs and **no default**.
|
||||
|
||||
Relevant measured facts: the POS already has `in_stock`, `sold_date`, **`hold_expires_at`**, `qty`
|
||||
and `square_item_id`. A reservation primitive therefore already exists — the question is whether
|
||||
the game is allowed to pull it.
|
||||
|
||||
### Option A — Sandbox (read-live, sell-local)
|
||||
Game reads real stock; a purchase never leaves the game. Sold-means-gone is per-session.
|
||||
- **For:** zero real-world risk; no payment, tax, or fulfilment surface; a bug can't cost real
|
||||
stock; ships fastest.
|
||||
- **Against:** the one-of-a-kind thrill is fiction — the real crate never changes, and two players
|
||||
(or the same player, next boot) buy the same record. Tier 2 becomes "live prices" more than
|
||||
"live stock".
|
||||
|
||||
### Option B — Reservation (a real, expiring hold)
|
||||
A game purchase places a real hold on the real SKU via the existing `hold_expires_at`. The record
|
||||
genuinely leaves the real crate for N hours; unclaimed, it returns.
|
||||
- **For:** genuinely one-of-a-kind, and **reversible** — the strongest truth-per-unit-risk; uses a
|
||||
primitive the POS already has.
|
||||
- **Against:** a game action removes real sellable stock from real in-store customers. Needs a
|
||||
hold cap/budget, a staff-visible reason code ("held: GODVERSE"), and a race policy against
|
||||
Square in-store sales. Someone must decide what happens when a held record sells in the shop
|
||||
anyway (the game must lose, gracefully).
|
||||
|
||||
### Option C — Full write-back (game sale = real sale)
|
||||
A purchase marks the real SKU sold and syncs to Square.
|
||||
- **For:** total truth — buy it in the game and it is gone from the real shop, forever.
|
||||
- **Against:** irreversible real-world inventory and revenue consequences; the game becomes a
|
||||
sales channel (real payment, tax, postage, fulfilment, refunds, chargebacks); a bug destroys
|
||||
real stock; a game wallet is not money. This is a business decision, not an engineering one.
|
||||
|
||||
**Lane G asks nothing and recommends nothing here.** Until ratified: **tier 2 is read-live,
|
||||
sell-sandboxed** (the charter's standing state, Option A by default *only* because law #4 says so).
|
||||
|
||||
---
|
||||
|
||||
## 8. Failure modes [DESIGN]
|
||||
|
||||
| failure | behaviour | gate class |
|
||||
|---|---|---|
|
||||
| server unreachable / DNS dead | tier 1, silent, zero console noise | tier-2 smoke |
|
||||
| server dies **mid-session** | room keeps last-good state; no re-fetch storm; no stutter | tier-2 smoke |
|
||||
| server slow (> budget) | request aborted; tier 1 stands; late arrival applies or is dropped | tier-2 smoke |
|
||||
| 5xx / malformed JSON | treated as unreachable — never a partial apply | tier-2 smoke |
|
||||
| atlas missing but server up | **tier 0** — no stock without covers; never text-only sleeves | tier-1 gate |
|
||||
| `gone[]` names an unknown sku | ignored + counted, never an error | tier-2 smoke |
|
||||
| clock skew on holds | server time is authoritative; client never computes expiry | tier-2 smoke |
|
||||
|
||||
**No tier-2 failure may fail a gate or block a tag** (charter law #1). Tier 2 gets its own smoke
|
||||
class: reachability, fail-soft, latency. Kill the server mid-session and the game must not stutter
|
||||
— that is v5.0's release gate, and it is a *client* test, not a server test.
|
||||
|
||||
---
|
||||
|
||||
## 9. Fences (non-negotiable, both already load-bearing)
|
||||
|
||||
- **PII.** `recordgod` holds real `customer`, `customer_session`, `mailing_list`, `staff`,
|
||||
`sales` — real private-individual data. The pipeline and the server read **only**
|
||||
`inventory` / `crate` / `disc_cache`. No exceptions, no joins, no "just for debugging".
|
||||
Charter law #3: no private-individual data, ever.
|
||||
- **Licence.** Covers are the shop's own product shots → in-house 🟢, **flagged before any
|
||||
public/commercial release**. Real titles/artists ship at tier 1 per John's R23 amendment to the
|
||||
no-real-trademarks law, under the same flag. Every atlas carries its provenance.
|
||||
- **Scope reality check for v5.0-beta.** "Every hero shop stocked" needs a real POS per shop. We
|
||||
have exactly one (John's own). Stocking a *second* real shop means a second real shopkeeper's
|
||||
data and consent — a licence and relationship question, not a pipeline question. Beta should be
|
||||
scoped to **monsterrobot's 357 crates** (24,646 real records — the whole shop, crate rotation
|
||||
per risk #2) rather than to more shops, unless John says otherwise.
|
||||
|
||||
---
|
||||
|
||||
## 10. Open items for the reviewers
|
||||
|
||||
1. **[Fable-m3 / C]** Per-shop pack resolution is not wired: `stockpack.js` caches by `type`
|
||||
alone (`_packs`/`_resolved`), and `interior_mode.js:192` resolves `getStockPack(shop.type)`
|
||||
with `base` fixed at `assets/models/`. R23's single shop works via a `base` override; **two
|
||||
stocked shops will collide in that cache.** The seam C/F need: key the cache by
|
||||
`type + base` (or `godverse_id`), and preload per-shop on shop entry. G re-emits to whatever
|
||||
wording C lands (LANE_C_PUB).
|
||||
2. **[Fable-m3 / E]** `redhill_real` doesn't exist — the only Brisbane cache (`westend_real`)
|
||||
stops ~2.3 km south of Red Hill. The crate has no street until E's `build_towns.py` gets a
|
||||
Red Hill entry (~`center: (-27.4553, 153.0064), span_km: 2.4`) and one Overpass fetch. That's
|
||||
E's file and an outward call — **G has not touched either.**
|
||||
3. **[John, R24]** §7, the write-back question.
|
||||
4. **[Both Fables]** §9's beta scoping — depth (one real shop, 357 crates) vs breadth (more
|
||||
shops, which needs other people's data).
|
||||
1487
pipeline/_godverse/monsterrobot_550.snapshot.json
Normal file
1487
pipeline/_godverse/monsterrobot_550.snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
245
pipeline/godverse_stock.py
Normal file
245
pipeline/godverse_stock.py
Normal file
@ -0,0 +1,245 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Lane G — G2a: per-shop REAL stock atlases (tier 1) for `?stock=real`.
|
||||
|
||||
THE FIRST REAL CRATE. One real shop's real crate — real sleeves, real titles, real prices —
|
||||
baked to a static atlas keyed by godverse shop id. No server, no runtime query: tier 1 is files.
|
||||
|
||||
Two stages, split at THE SNAPSHOT BOUNDARY (the charter's determinism rule, and the same
|
||||
discipline build_towns.py applies to Overpass raw):
|
||||
|
||||
snapshot POS DB + image map -> pipeline/_godverse/<shop>_<crate>.snapshot.json
|
||||
pack snapshot + images -> web/assets/stock_godverse/<shop>/stock_record_*.{json,webp}
|
||||
|
||||
The snapshot is the artifact of record: it runs where the databases are, pins every source row
|
||||
AND every cover's sha256, and is committed. The pack stage is pure — same snapshot + same image
|
||||
bytes => byte-identical atlas, on any machine, with no DB. That split is what lets Lane E's
|
||||
validate_atlas gate G's output on m3 without the dealgod DB.
|
||||
|
||||
python3 pipeline/godverse_stock.py snapshot --crate 550 --image-map <tsv>
|
||||
~/Documents/MODELBEAST/venvs/mflux/bin/python pipeline/godverse_stock.py pack \
|
||||
pipeline/_godverse/monsterrobot_550.snapshot.json # needs PIL (house convention)
|
||||
|
||||
THE SHOP (John's ruling, R23): `monsterrobot` — a real, trading record shop at 147 Musgrave Road,
|
||||
Red Hill, Brisbane QLD. It is the same shop in three datasets, which is what makes tier 1 honest:
|
||||
recordgod its POS: real crates, real slot numbers, real prices, real grading
|
||||
monsterrobot.party its own website — the covers are the SHOP'S OWN product shots
|
||||
dealgod store 3962749 the scrape of that site; the cover cache + the godverse shop id
|
||||
It is NOT in thriftgod's OSM census (see G-progress R23), so its godverse id is its dealgod store
|
||||
id 3962749 — real, stable, and clear of the census id space (thriftgod max shop id = 2992).
|
||||
|
||||
DETERMINISM: ordered queries, no RNG, no sampling, no clock in the output. Prices/titles/artists
|
||||
are FACTS copied from the POS, not minted (thriftgod's mint() is TABLESAMPLE + unseeded random —
|
||||
it cannot produce tier 1; see G3_ECONOMY_DESIGN.md §2).
|
||||
|
||||
LICENCE (charter law #3): covers are the shop's own product shots from the shop's own domain,
|
||||
cached by dealgod. In-house 🟢. FLAG BEFORE ANY PUBLIC/COMMERCIAL RELEASE. Real titles/artists
|
||||
ship at tier 1 per John's R23 amendment to the no-real-trademarks law (in-house green, same
|
||||
flag). Provenance rides in every index. PII FENCE: recordgod holds real customer/staff/sales
|
||||
tables — this pipeline reads ONLY inventory/crate/disc_cache and must never read the others.
|
||||
"""
|
||||
import argparse, hashlib, json, os, subprocess, sys
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SNAP_DIR = os.path.join(ROOT, "pipeline", "_godverse")
|
||||
STOCK_DIR = os.path.join(ROOT, "web", "assets", "stock_godverse")
|
||||
IMG_CACHE = os.environ.get("DEALGOD_IMG_CACHE", "/Volumes/johnking/dealgod_backup/images")
|
||||
|
||||
# The shop, per John's R23 ruling. `godverse_id` = dealgod store id (see module docstring).
|
||||
SHOP = {
|
||||
"godverse_id": 3962749, "slug": "monsterrobot", "name": "Monster Robot Party",
|
||||
"address": "147 Musgrave Road", "suburb": "Red Hill", "city": "Brisbane",
|
||||
"state": "QLD", "country": "AU", "type": "record",
|
||||
}
|
||||
OWN_PHOTO_HOST = "monsterrobot.party" # the licence test: the shop's own domain, nothing else
|
||||
|
||||
CELL_W, CELL_H, GRID, ATLAS_PX = 256, 256, 8, 2048 # Lane E's record contract (build_stock_pack.py)
|
||||
PER_ATLAS = GRID * (ATLAS_PX // CELL_H) # 64
|
||||
|
||||
|
||||
def price_band(price):
|
||||
"""Lane E's record bands, verbatim — C's dig UI reads this (grail => `rare`)."""
|
||||
p = price if price is not None else 4
|
||||
return "bargain" if p < 8 else "standard" if p < 25 else "collector" if p < 60 else "grail"
|
||||
|
||||
|
||||
def psql(db, sql):
|
||||
"""Read-only query -> list of field-lists. psql CLI, not psycopg2: the m3 has no psycopg2, and
|
||||
a snapshot stage that shells out has one less dep between the DB and the frozen file."""
|
||||
out = subprocess.run(["psql", "-d", db, "-tAF\t", "-c", sql],
|
||||
capture_output=True, text=True, check=True).stdout
|
||||
return [ln.split("\t") for ln in out.splitlines() if ln.strip()]
|
||||
|
||||
|
||||
def _n(v):
|
||||
return None if v in ("", "\\N") else v
|
||||
|
||||
|
||||
# ── stage 1: snapshot ────────────────────────────────────────────────────────────────────
|
||||
def load_image_map(path):
|
||||
"""release_id -> local_image, restricted to the SHOP'S OWN photos (the licence test).
|
||||
|
||||
On ultra (dealgod present) this is a query. On the m3 dealgod is a 2 GB dump and never
|
||||
restored, so the map is streamed out of it once — the command is recorded in the snapshot's
|
||||
provenance so the extraction is reproducible:
|
||||
|
||||
gzcat dealgod-<date>.sql.gz | awk -F'\\t' '/^COPY public.products \\(/{c=1;next}
|
||||
c && /^\\\\.$/{c=0;next} c && $50!="\\\\N"{print $50"\\t"$5"\\t"$34"\\t"$52"\\t"$56}'
|
||||
(fields: 50=release_id 5=image_url 34=local_image 52=current_price 56=discogs_artist_name)
|
||||
"""
|
||||
m = {}
|
||||
with open(path) as fh:
|
||||
for ln in fh:
|
||||
f = ln.rstrip("\n").split("\t")
|
||||
if len(f) < 3:
|
||||
continue
|
||||
rel, url, local = f[0], f[1], f[2]
|
||||
if OWN_PHOTO_HOST not in url or not local.startswith("/img/"):
|
||||
continue
|
||||
m.setdefault(rel, local) # first wins; ids are sorted upstream => stable
|
||||
return m
|
||||
|
||||
|
||||
def snapshot(args):
|
||||
crate = psql("recordgod", f"SELECT id,name,COALESCE(label_text,'') FROM crate WHERE id={args.crate}")
|
||||
if not crate:
|
||||
sys.exit(f"crate {args.crate} not found in recordgod")
|
||||
cid, cname, clabel = crate[0][0], crate[0][1], crate[0][2]
|
||||
|
||||
# ONLY inventory/crate/disc_cache — the PII fence (see module docstring).
|
||||
rows = psql("recordgod", f"""
|
||||
SELECT i.sku, COALESCE(i.slot_number::text,''), i.release_id,
|
||||
COALESCE(d.artist,''), COALESCE(d.title, COALESCE(i.title,'')),
|
||||
i.price, COALESCE(i.condition,''), COALESCE(i.sleeve_cond,'')
|
||||
FROM inventory i
|
||||
JOIN crate c ON c.id = i.crate_id
|
||||
LEFT JOIN disc_cache d USING (release_id)
|
||||
WHERE i.in_stock AND c.id = {args.crate} AND i.release_id IS NOT NULL
|
||||
ORDER BY i.slot_number NULLS LAST, i.sku""")
|
||||
|
||||
imgs = load_image_map(args.image_map)
|
||||
items, dropped = [], {"no_own_photo": 0, "file_missing": 0, "no_price": 0}
|
||||
for sku, slot, rel, artist, title, price, cond, sleeve in rows:
|
||||
local = imgs.get(rel)
|
||||
if not local:
|
||||
dropped["no_own_photo"] += 1
|
||||
continue
|
||||
path = os.path.join(IMG_CACHE, local[len("/img/"):])
|
||||
if not os.path.isfile(path):
|
||||
dropped["file_missing"] += 1
|
||||
continue
|
||||
if not _n(price):
|
||||
dropped["no_price"] += 1
|
||||
continue
|
||||
with open(path, "rb") as fh:
|
||||
sha = hashlib.sha256(fh.read()).hexdigest()
|
||||
items.append({"sku": sku, "slot": int(slot) if slot else None, "release_id": int(rel),
|
||||
"artist": artist, "title": title, "price": float(price),
|
||||
"condition": cond, "sleeve_cond": sleeve,
|
||||
"cover": local, "cover_sha256": sha})
|
||||
|
||||
if args.max_items:
|
||||
items = items[:args.max_items]
|
||||
|
||||
snap = {
|
||||
"version": 1, "kind": "record", "tier": 1,
|
||||
"shop": SHOP,
|
||||
"crate": {"id": int(cid), "name": cname, "label": clabel},
|
||||
"counts": {"in_crate": len(rows), "baked": len(items), "dropped": dropped},
|
||||
"source": {
|
||||
"pos_db": "recordgod", "pos_dump": args.pos_dump,
|
||||
"image_map_dump": args.image_map_dump, "image_cache": IMG_CACHE,
|
||||
"captured": args.captured,
|
||||
"extraction": "see load_image_map() docstring — the products projection command",
|
||||
},
|
||||
"licence": {
|
||||
"covers": "the shop's own product shots (monsterrobot.party), cached by dealgod",
|
||||
"zone": "in-house-green", "flag": "FLAG BEFORE ANY PUBLIC/COMMERCIAL RELEASE",
|
||||
"metadata": "real titles/artists — John's R23 amendment to the no-real-trademarks law",
|
||||
"pii": "none — inventory/crate/disc_cache only; customer/staff/sales never read",
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
os.makedirs(SNAP_DIR, exist_ok=True)
|
||||
dst = os.path.join(SNAP_DIR, f"{SHOP['slug']}_{cid}.snapshot.json")
|
||||
with open(dst, "w") as fh:
|
||||
json.dump(snap, fh, indent=1, sort_keys=False)
|
||||
fh.write("\n")
|
||||
print(f"snapshot: crate {cid} '{clabel or cname}' — {len(items)} real records "
|
||||
f"(of {len(rows)} in crate; dropped {dropped}) -> {dst}")
|
||||
|
||||
|
||||
# ── stage 2: pack (pure: snapshot + image bytes -> atlas) ─────────────────────────────────
|
||||
def pack(args):
|
||||
from PIL import Image
|
||||
snap = json.load(open(args.snapshot))
|
||||
shop, items = snap["shop"], snap["items"]
|
||||
out_dir = os.path.join(STOCK_DIR, str(shop["godverse_id"]))
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
idx_items, atlases = [], []
|
||||
for ai in range((len(items) + PER_ATLAS - 1) // PER_ATLAS):
|
||||
chunk = items[ai * PER_ATLAS:(ai + 1) * PER_ATLAS]
|
||||
W, H = GRID * CELL_W, (ATLAS_PX // CELL_H) * CELL_H
|
||||
sheet = Image.new("RGB", (W, H), (28, 28, 32))
|
||||
aname = f"stock_record_atlas_{ai:02d}.webp"
|
||||
for j, it in enumerate(chunk):
|
||||
gx, gy = j % GRID, j // GRID
|
||||
path = os.path.join(IMG_CACHE, it["cover"][len("/img/"):])
|
||||
with open(path, "rb") as fh:
|
||||
raw = fh.read()
|
||||
if hashlib.sha256(raw).hexdigest() != it["cover_sha256"]:
|
||||
sys.exit(f"cover drift: {it['sku']} {it['cover']} — snapshot sha mismatch, re-snapshot")
|
||||
import io
|
||||
cov = Image.open(io.BytesIO(raw)).convert("RGB").resize((CELL_W, CELL_H), Image.LANCZOS)
|
||||
sheet.paste(cov, (gx * CELL_W, gy * CELL_H))
|
||||
u0, v0 = gx * CELL_W / W, gy * CELL_H / H
|
||||
idx_items.append({
|
||||
"id": f"rec_{len(idx_items):04d}", "title": it["title"], "artist": it["artist"],
|
||||
"price": int(round(it["price"])), "price_band": price_band(it["price"]),
|
||||
"atlas": aname, "uv": [round(u0, 4), round(v0, 4),
|
||||
round(u0 + CELL_W / W, 4), round(v0 + CELL_H / H, 4)],
|
||||
})
|
||||
sheet.save(os.path.join(out_dir, aname), "WEBP", quality=88, method=6)
|
||||
atlases.append(aname)
|
||||
|
||||
with open(args.snapshot, "rb") as fh:
|
||||
snap_sha = hashlib.sha256(fh.read()).hexdigest()
|
||||
index = {
|
||||
"version": 1, "kind": "record", "tier": 1,
|
||||
"atlas_px": ATLAS_PX, "cell_w": CELL_W, "cell_h": CELL_H,
|
||||
"count": len(idx_items), "atlases": atlases,
|
||||
"shop": {k: shop[k] for k in ("godverse_id", "slug", "name", "suburb", "state")},
|
||||
"crate": snap["crate"],
|
||||
"provenance": {
|
||||
"generator": "pipeline/godverse_stock.py",
|
||||
"source": snap["source"], "licence": snap["licence"],
|
||||
"snapshot": os.path.basename(args.snapshot), "snapshot_sha256": snap_sha,
|
||||
"determinism": "pure bake — same snapshot + same cover bytes => byte-identical atlas; "
|
||||
"covers pinned by sha256, no RNG, no clock",
|
||||
},
|
||||
"items": idx_items,
|
||||
}
|
||||
dst = os.path.join(out_dir, "stock_record_index.json")
|
||||
with open(dst, "w") as fh:
|
||||
json.dump(index, fh, indent=1)
|
||||
fh.write("\n")
|
||||
print(f"pack: {len(idx_items)} real sleeves -> {len(atlases)} atlas(es) in {out_dir}")
|
||||
print(f" shop {shop['godverse_id']} ({shop['slug']}) · crate "
|
||||
f"{snap['crate']['id']} '{snap['crate']['label'] or snap['crate']['name']}'")
|
||||
|
||||
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
s1 = sub.add_parser("snapshot", help="POS + image map -> frozen snapshot (runs where the DBs are)")
|
||||
s1.add_argument("--crate", type=int, required=True)
|
||||
s1.add_argument("--image-map", required=True, help="TSV: release_id, image_url, local_image, ...")
|
||||
s1.add_argument("--max-items", type=int, default=0, help="cap (atlas budget — charter risk #2)")
|
||||
s1.add_argument("--pos-dump", default="", help="recordgod dump filename, for provenance")
|
||||
s1.add_argument("--image-map-dump", default="", help="dealgod dump filename, for provenance")
|
||||
s1.add_argument("--captured", default="", help="snapshot date (YYYY-MM-DD), for provenance")
|
||||
s1.set_defaults(func=snapshot)
|
||||
s2 = sub.add_parser("pack", help="snapshot -> atlas + index (pure; needs PIL, no DB)")
|
||||
s2.add_argument("snapshot")
|
||||
s2.set_defaults(func=pack)
|
||||
args = ap.parse_args()
|
||||
args.func(args)
|
||||
BIN
web/assets/stock_godverse/3962749/stock_record_atlas_00.webp
Normal file
BIN
web/assets/stock_godverse/3962749/stock_record_atlas_00.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 644 KiB |
BIN
web/assets/stock_godverse/3962749/stock_record_atlas_01.webp
Normal file
BIN
web/assets/stock_godverse/3962749/stock_record_atlas_01.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 580 KiB |
1728
web/assets/stock_godverse/3962749/stock_record_index.json
Normal file
1728
web/assets/stock_godverse/3962749/stock_record_index.json
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user