THE SHOP HAS A STREET. redhill_godverse: Monster Robot Party seated on Musgrave Road (verified — lot 8, type record, godverseShopId 3962749), 37 shops on E's 884 real Red Hill roads. Its coordinate is John-supplied (Google Maps place /g/11g6rlbbc0 -> -27.4553791, 153.0076244) because it is derivable from nothing we hold: OSM doesn't know the shop, the census doesn't either, dealgod's stores/store_entity rows are NULL lat/lng, recordgod has no address. Verified inside E's bbox and on Musgrave Road's own geometry; my own estimate was ~120 m out. RE-EMIT (ledger #5 + #2): atlas keyed by godverseShopId with US `license` throughout (British retired, 0 occurrences) — atlas-QA OK, 120 real records, byte-identical re-bake (the .webp bytes did not move; only the index). newtown_godverse re-emitted with ids (18 keyed). godverse_town.py: psycopg2 -> psql, EXTRA_SHOPS roster, and Fable's R22 Ruling 2 finally implemented. RULING 2 WAS LOAD-BEARING, NOT COSMETIC (measured): census-only Red Hill is unshippable — 9 shops = 306 m spacing warn; +Monster Robot = 10 shops = 261 m warn AND a frontage poster seated INSIDE edge 1037's kerb (-2.95 m); + the donor's texture layer (37) = no warn, poster passes. 261 m is worse than the 255 m that retired ballarat this round: the alpha's headline town would have shipped flagged "scattered, not a high street, or retire the town". 9 donor heroes swapped for census entries (18 in Newtown), counted per the ruling. CONFLICT #1 — C §7.3 vs E's validator. C says lift provenance to TOP LEVEL, US `license`, British retired, "exactly what E's validator reads". Measured: it is not. validate_atlas.py:98 reads NESTED `provenance`; :105 reads `licence` British-first. Lifting-and-retiring as written FAILS the gate; my R23 atlas already passed it untouched. Same species as the bug that held the tag — a doc asserting what code does without running it. Resolved: E's check accepts `licence or license`, so US-nested passes; the four also ride at top level (C's contract + the town-cache convention) off one source of truth. -> E, one line: :198 PRINTS `licence` British-only while the check accepts both, so C's mandated US spelling silently stops printing the licence flag — which :199's own comment calls a human call. Gate passes; evidence line vanished. Fix: .get("licence") or .get("license"). CONFLICT #2 — Ruling 2 vs A's identity gate. NEEDS A RULING; it is why the tree is red (6 of 8). Ruling 2 makes a godverse cache mixed: a keyed GODVERSE layer + an inherited OSM texture layer. A's sweep asserts EVERY shop is keyed -> partial FAILs. Texture shops carry no godverseShopId deliberately: an OSM milkbar has no GODVERSE identity, and minting one from its OSM id pushes a third id space into a field that already carries two — A's validator calls a duplicate mis-stocking, charter risk #3's nightmare with Monster Robot's crate as the prize. And A's own two checks disagree: the validator says "Missing = tier 0 (soft). Duplicate = mis-stocked (hard). They are not the same failure", while the sweep fails partial keying because it "mis-stocks the rest" — conflating exactly those two. Proposed (A's call, Fable's ruling): assert coverage over the godverse layer, not the inherited texture layer. THE 8 RED CHECKS, ITEMISED: newtown_godverse base+gig goldens (2) moved by the re-emit exactly as ledger #2 intends — A re-pins, base 0xcf69b387 gig 0x1e266f60; godverse identity x3 per godverse town (6) = conflict #2. Nothing else. Classic covenant 0x3fa36874 intact. redhill_godverse pins: base 0xb2224939. Verified live: the shop's 120-record pack and the town-wide 350 pack now COEXIST through C's type+base fix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
153 lines
8.2 KiB
Python
153 lines
8.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Lane G — GODVERSE town adapter: thriftgod's REAL shop census -> a procity-town-cache/2.
|
|
|
|
Marries the two halves: roads[] come from an existing OSM cache (Lane E's build_towns.py),
|
|
shops[] come from thriftgod's Postgres (the real-business census DealGod scraped). Result:
|
|
walk the town's actual streets past its actual secondhand shops.
|
|
|
|
python3 pipeline/godverse_town.py newtown_real newtown_godverse # roads-donor key, out key
|
|
python3 pipeline/godverse_town.py redhill_real redhill_godverse # THE shop's real street
|
|
|
|
Reads thriftgod via the `psql` CLI (R24): psycopg2 isn't installed on the m3, and one less dep
|
|
between the census and the cache. Works unchanged wherever thriftgod lives.
|
|
|
|
`godverseShopId` (R24 ledger #2, with Lane A) — the OPAQUE key that resolves
|
|
`stock_godverse/<godverseShopId>/`. **It is not `shop.id` and not "the census id"**: census shops
|
|
carry their thriftgod id (max 2992), while Monster Robot Party isn't in the census at all and
|
|
carries its dealgod store id (3962749). Two disjoint id spaces, one opaque field — G emits it,
|
|
everyone else reads it, nobody derives it. A's `validateTownCache` wants a positive integer,
|
|
unique per cache; **missing is a WARN, not an error** (measured — A's rule is deliberate: absent
|
|
stock identity must never fail a town's boot, per the charter's determinism boundary). So the
|
|
GODVERSE layer is keyed and the inherited texture layer is not — see Ruling 2 below for why that
|
|
is the honest shape rather than a gap.
|
|
"""
|
|
import json, math, os, subprocess, sys
|
|
|
|
DONOR, OUT = sys.argv[1], sys.argv[2]
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
TOWNS = os.path.join(HERE, '..', 'web', 'assets', 'towns')
|
|
|
|
# thriftgod shop_type -> procity SHOP_TYPES (registry.js is the vocabulary of record)
|
|
TYPE_MAP = {'music': 'record', 'charity': 'opshop', 'second_hand': 'opshop', 'antiques': 'opshop',
|
|
'books': 'book', 'toys': 'toy', 'video_games': 'video', 'games': 'toy',
|
|
'pawnbroker': 'pawn', 'market': 'dept'}
|
|
|
|
# REAL shops that exist in neither OSM nor thriftgod's census, added by explicit, sourced ruling.
|
|
# This is not a mapping table (the R12 gigKey law) — it's a hand-verified roster of the shops the
|
|
# epoch is actually about. Every entry states where its identity and its coordinates came from.
|
|
#
|
|
# Monster Robot Party: the v5 alpha's shop — its POS *is* `recordgod`, its own site hosts the
|
|
# covers, and dealgod scrapes it as store 3962749 (which is therefore its godverseShopId). OSM
|
|
# doesn't know it (no name match, no 147 Musgrave Rd address node) and the census doesn't either,
|
|
# so the coordinate cannot be derived from any dataset we hold: John supplied the shop's Google
|
|
# Maps place (place `/g/11g6rlbbc0`), whose point is -27.4553791, 153.0076244 — inside
|
|
# `redhill_real`'s bbox and on Musgrave Road's own geometry. Verified, not estimated.
|
|
EXTRA_SHOPS = {
|
|
'redhill_godverse': [
|
|
{'id': 3962749, 'godverseShopId': 3962749, 'name': 'Monster Robot Party', 'type': 'record',
|
|
'lat': -27.4553791, 'lon': 153.0076244, 'suburb': 'Red Hill',
|
|
'source': 'GODVERSE/dealgod store 3962749 · POS recordgod · coords: Google Maps place '
|
|
'/g/11g6rlbbc0 (147 Musgrave Road, Red Hill QLD), John-supplied R24'},
|
|
],
|
|
}
|
|
|
|
|
|
def psql(db, sql):
|
|
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()]
|
|
|
|
|
|
donor = json.load(open(os.path.join(TOWNS, f'{DONOR}.json')))
|
|
bb = donor['bbox']
|
|
rows = psql(os.environ.get('THRIFTGOD_DB', 'thriftgod'), f"""
|
|
SELECT id, COALESCE(name,''), COALESCE(shop_type,''), lat, lng, COALESCE(suburb,'')
|
|
FROM shop
|
|
WHERE lat BETWEEN {bb['minLat']} AND {bb['maxLat']}
|
|
AND lng BETWEEN {bb['minLon']} AND {bb['maxLon']}
|
|
ORDER BY id""")
|
|
|
|
shops = [{'id': int(r[0]),
|
|
'godverseShopId': int(r[0]), # census shops: the thriftgod id IS the key (not derived
|
|
# from `id` by law — they coincide, see the docstring)
|
|
'name': r[1],
|
|
'type': TYPE_MAP.get(r[2], 'opshop'),
|
|
'lat': float(r[3]), 'lon': float(r[4]),
|
|
'suburb': r[5].split('|')[0]} for r in rows]
|
|
census_n = len(shops)
|
|
|
|
extras = EXTRA_SHOPS.get(OUT, [])
|
|
known = {s['id'] for s in shops}
|
|
for e in sorted(extras, key=lambda s: s['id']):
|
|
if e['id'] in known: # census wins a collision, and we count the swap
|
|
continue
|
|
shops.append({k: e[k] for k in ('id', 'godverseShopId', 'name', 'type', 'lat', 'lon', 'suburb')})
|
|
extra_n = len(shops) - census_n
|
|
|
|
|
|
# ── Fable's Ruling 2 (R22, implemented R24): INHERIT THE DONOR'S TEXTURE LAYER ────────────────
|
|
# "Merge census heroes WITH the donor's texture shops; census heroes always win a collision
|
|
# (dedupe by proximity + name), drop the donor hero, keep yours, count the swap. Result: real
|
|
# census shops on a street that also feels alive."
|
|
#
|
|
# Not cosmetic — MEASURED (R24): the census layer alone is 9 shops across Red Hill's 2.4 km bbox →
|
|
# A's spacing warn at 261 m ("scattered, not a high street"), *worse than the 255 m that retired
|
|
# ballarat this round*, plus a frontage poster seated inside edge 1037's kerb. With the donor's
|
|
# texture layer merged (37 shops) both go away and E's donor's own density is preserved.
|
|
#
|
|
# THE MERGED SHOPS CARRY NO `godverseShopId`, DELIBERATELY. The field is the GODVERSE stock
|
|
# identity; an OSM cafe has none, and minting one from its OSM id would push a third id space into
|
|
# a field that already carries two (thriftgod census ≤2992 · dealgod store ids). OSM ids are the
|
|
# same order of magnitude as dealgod store ids, so a collision is *possible* — and A's validator
|
|
# calls a duplicate godverseShopId mis-stocking (charter risk #3: two shops keyed to one atlas,
|
|
# one of them Monster Robot's crate). Unkeyed is both true and safe: they fall soft to tier 0,
|
|
# which is exactly what a milkbar with no stock should do. A's coverage warn states this honestly.
|
|
def norm(n):
|
|
return ' '.join((n or '').lower().split())
|
|
|
|
|
|
def near(a, b, m=150.0): # metres, flat-earth at suburb scale — plenty at 150 m
|
|
dy = (a['lat'] - b['lat']) * 111_320.0
|
|
dx = (a['lon'] - b['lon']) * 111_320.0 * math.cos(math.radians(a['lat']))
|
|
return math.hypot(dx, dy) <= m
|
|
|
|
|
|
swaps = 0
|
|
for d in sorted(donor.get('shops', []), key=lambda s: s['id']):
|
|
hit = next((s for s in shops if norm(s['name']) == norm(d['name']) and near(s, d)), None)
|
|
if hit: # the donor's own entry for a shop we already hold
|
|
swaps += 1
|
|
continue
|
|
if d['id'] in {s['id'] for s in shops}:
|
|
continue
|
|
shops.append({'id': d['id'], 'name': d['name'], 'type': d['type'],
|
|
'lat': d['lat'], 'lon': d['lon'], 'suburb': d.get('suburb', '')})
|
|
texture_n = len(shops) - census_n - extra_n
|
|
shops.sort(key=lambda s: s['id']) # deterministic order, extras + texture included
|
|
|
|
out = dict(donor)
|
|
out.update({
|
|
'key': OUT,
|
|
'town': donor['town'],
|
|
'source': 'godverse+osm', # shops = thriftgod census, roads = OSM (donor)
|
|
'generator': 'pipeline/godverse_town.py',
|
|
'attribution': donor['attribution'] + ' · shop census: GODVERSE/thriftgod',
|
|
'shops': shops,
|
|
# The donor's shop-side counts (raw/heroes/texture/textureDropped) describe the shop layer we
|
|
# REPLACE, so carrying them would state a census of shops this cache doesn't contain. Keep the
|
|
# donor's road count; recount our own shops.
|
|
'counts': {**{k: v for k, v in donor.get('counts', {}).items() if k == 'roads'},
|
|
'shops': len(shops), 'census': census_n, 'extra': extra_n,
|
|
'texture': texture_n, 'donorSwaps': swaps},
|
|
})
|
|
dst = os.path.join(TOWNS, f'{OUT}.json')
|
|
with open(dst, 'w') as fh:
|
|
json.dump(out, fh, indent=1)
|
|
types = {}
|
|
for s in shops:
|
|
types[s['type']] = types.get(s['type'], 0) + 1
|
|
keyed = sum(1 for s in shops if 'godverseShopId' in s)
|
|
print(f"{OUT}: {len(shops)} shops = {census_n} census + {extra_n} extra + {texture_n} donor-texture "
|
|
f"({swaps} donor heroes swapped for census) · {keyed} keyed w/ godverseShopId")
|
|
print(f" {types} + {len(out.get('roads', []))} OSM roads -> {dst}")
|