Lane F R37 (v8 wave 0, 0.2 + 0.5): the vacuous tram gate rebuilt; classic_regression gets a git-derived network allow-list
0.2 — smoke_tram was ONE assertion (`!!getObjectByName('tram')`), a printed SKIP + early return
on absence, and it PASSED ON THE FENCED PATH because tram.js:110-114 adds a named EMPTY group.
Rebuilt STRICT, four arms + both controls demonstrated every run: RUNS (route/stops/metres, and
the body measurably moves under the SHELL'S own rAF loop) · BEHAVES (an isolated tram on a
throwaway scene, fixed dt: traverses, dwell time accounts to exactly dwells x 3.5 s, max cruising
tick <= SPEED*dt, every discontinuity bounded by 2*LANE with >=1 end reversal, and it rides
3.2 m off Lane A's OWN main-edge centreline) · GENUINELY ABSENT on the fenced katoomba_real
(fenced verdict, 0 stops, 0 group children, 0 m moved) · OFF under ?tram=0 and ?classic=1.
CONTROL A points the run-predicate at the fenced town and CONTROL B points the absence-predicate
at the running tram; both must go red or the gate fails. A missing subject now FAILS.
0.5 — classic_regression made no network assertion at all for 21 rounds, which is why R36's
fetch-surface widening needed a brand-new smoke. It now watches the wire from before the first
byte, with the allow-list DERIVED FROM THE v2.0 TAG at gate time (git ls-tree + the v2.0
manifest), never a deny-list. The four v2-era depot street GLBs and the five record-shop fittings
are BASELINE and admitted by provenance, not by exception. Same-origin is NOT a free pass -- the
R36 breach WAS same-origin, so /models/peds is enumerated by the v2.0 tree (exactly the
covenanted 21 files). Anti-vacuous: a required v2 core must be observed or the clean verdict is
refused. CONTROL, run every time: injecting the R36 breach itself (models/peds/woman_dj_01.glb)
plus a never-existed asset turns the clean verdict into exactly 2 violations.
Measured and carried, not fixed here: booting the actual v2.0 tree and diffing its fetch surface
against today's ?classic=1 shows a POST-v2.0 delta -- 5 audio files (R11 f7dd44f) and
assets/towns/index.json (R20/R21). Allowed on the list so the suite is not permanently yellow
over 26 rounds of shipped behaviour, printed every run so it cannot go invisible. Fable's to
ratify or retire.
Also: tools/qa/budget_walk.py (R37 0.3, the measurement rig -- continuous-motion walk through the
real rAF loop, bearing sweep, dwell, and a stepwalk that reproduces the audit's method) and
`flags_check.py --only` for single-gate development runs, which prints a PARTIAL banner so a
subset can never be read as a suite verdict.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
7bd96e57fc
commit
23968856dc
@ -124,6 +124,114 @@ def plansrc_landed(p):
|
||||
finally:
|
||||
b.close()
|
||||
|
||||
# ── [R37 item 0.5] THE CLASSIC NETWORK ALLOW-LIST ────────────────────────────────────────────────
|
||||
# classic_regression made NO network assertion at all for 21 rounds. That is why R36's fetch-surface
|
||||
# widening (5 trellis DJ bodies joining PED_NAMES.normal) needed a brand-new smoke to catch it: the
|
||||
# covenant gate was watching the plan fingerprint, the flags, the draws and the console, and nothing
|
||||
# was watching the wire.
|
||||
#
|
||||
# THE AUTHORITY IS GIT, NOT AN OPINION. The covenant preserves "the frozen v2 baseline", so the
|
||||
# allow-list is DERIVED from the v2.0 tag's own file tree at gate time:
|
||||
# • same-origin asset ⇒ its path must exist in `git ls-tree -r v2.0 -- web/`;
|
||||
# • depot asset (cross-origin digalot.fyi/3god/a/<file>) ⇒ its basename must appear in v2.0's own
|
||||
# `web/assets/manifest.json`. This is how the four street furniture GLBs (bench · bin ·
|
||||
# bus_shelter · food_cart, `furniture.js` GLB_FILES, byte-identical at 24cef5a and v2.0) and the
|
||||
# five record-shop fittings reached through `useGLB: !NOASSETS` (index.html, byte-identical at
|
||||
# v2.0) are admitted. Those nine fetches are CORRECT — they are in the baseline the covenant
|
||||
# preserves, not a breach — and this gate must never be "fixed" by removing them.
|
||||
# • CODE is allowed as a class (same-origin .js/.mjs + the document). A new module that fetches
|
||||
# nothing is not a fetch-surface change; every ASSET is enumerated.
|
||||
# • blob:/data: are not network — GLTFLoader mints one blob per embedded texture.
|
||||
#
|
||||
# ALLOW-LIST, NEVER DENY-LIST: a deny-list only catches what you already thought of. The R36 breach
|
||||
# was SAME-ORIGIN (`/models/peds/woman_dj_01.glb`), so an allow-list phrased as "the four depot GLBs
|
||||
# plus same-origin" would have been green straight through it. Same-origin is NOT a free pass here;
|
||||
# `/models/peds/` is enumerated by the v2.0 tree, which contains exactly the covenanted 21 files.
|
||||
#
|
||||
# POST-v2.0 ADDITIONS (Tier P) — measured, named, dated, and NOT silently swallowed. Booting the
|
||||
# actual v2.0 tree and diffing its fetch surface against today's ?classic=1 gives a delta of exactly
|
||||
# +3 files, all shipped years of rounds ago and never ruled on:
|
||||
# /assets/audio/* R11 audio engine (f7dd44f, Lane B round-11)
|
||||
# /assets/towns/index.json R20/R21 town selector (078245f, 4b01449)
|
||||
# They are allowed here so the suite is not permanently yellow over shipped behaviour, and PRINTED
|
||||
# every run so the delta can never go invisible. Fable's to ratify or retire — not F's to improvise.
|
||||
V2_TAG = 'v2.0'
|
||||
DEPOT_HOSTS = ('digalot.fyi',)
|
||||
POST_V2_PREFIXES = (
|
||||
('/assets/audio/', 'R11 audio engine (f7dd44f)'),
|
||||
('/assets/towns/index.json', 'R20/R21 town selector (078245f, 4b01449)'),
|
||||
)
|
||||
# The core that must ALL be observed, or the observer is not looking at a live boot (anti-vacuous):
|
||||
# the four v2-era depot street GLBs + the covenanted ped fleet's shared clips.
|
||||
CLASSIC_REQUIRED = ('procity_street_bench_01.glb', 'procity_street_bin_01.glb',
|
||||
'procity_street_bus_shelter_01.glb', 'procity_street_food_cart_01.glb',
|
||||
'/models/peds/walk.glb', '/models/peds/idle.glb',
|
||||
'/models/peds/man_worker_hivis_01.glb', '/assets/manifest.json')
|
||||
_v2_cache = {}
|
||||
|
||||
|
||||
def _v2_baseline():
|
||||
"""{paths, depot_basenames} from the v2.0 TAG. Empty dict ⇒ the tag is unreachable and the gate
|
||||
must FAIL rather than pass with no authority."""
|
||||
if _v2_cache:
|
||||
return _v2_cache
|
||||
ls = subprocess.run(['git', 'ls-tree', '-r', V2_TAG, '--name-only', '--', 'web/'],
|
||||
cwd=str(ROOT), capture_output=True, text=True)
|
||||
mf = subprocess.run(['git', 'show', f'{V2_TAG}:web/assets/manifest.json'],
|
||||
cwd=str(ROOT), capture_output=True, text=True)
|
||||
if ls.returncode != 0 or mf.returncode != 0:
|
||||
return {}
|
||||
paths = {ln.strip()[3:] for ln in ls.stdout.splitlines() if ln.strip().startswith('web/')}
|
||||
depot = {tok.split('/')[-1] for tok in mf.stdout.split('"') if tok.endswith('.glb')}
|
||||
_v2_cache.update({'paths': paths, 'depot': depot})
|
||||
return _v2_cache
|
||||
|
||||
|
||||
def _classify_fetch(url, host, paths, depot):
|
||||
"""→ (verdict, tier). verdict ∈ {'ok','violation','ignored'}."""
|
||||
if url.startswith(('blob:', 'data:', 'about:')):
|
||||
return 'ignored', 'blob/data (not network — GLTFLoader mints one per embedded texture)'
|
||||
if url.startswith(host):
|
||||
path = url[len(host):].split('?')[0].split('#')[0] or '/'
|
||||
if path == '/':
|
||||
path = '/index.html'
|
||||
if path.endswith(('.js', '.mjs', '.html')):
|
||||
return 'ok', 'code (same-origin module/document)'
|
||||
if path == '/favicon.ico':
|
||||
return 'ignored', 'browser noise'
|
||||
for pre, why in POST_V2_PREFIXES:
|
||||
if path.startswith(pre):
|
||||
return 'ok', f'POST-v2.0 addition, allowed + unratified — {why}'
|
||||
if path in paths:
|
||||
return 'ok', f'same-origin asset present in the {V2_TAG} tree'
|
||||
return 'violation', f'same-origin asset NOT in the {V2_TAG} tree'
|
||||
h = url.split('/')[2] if '://' in url else ''
|
||||
if any(d in h for d in DEPOT_HOSTS):
|
||||
f = url.split('/')[-1].split('?')[0]
|
||||
if f in depot:
|
||||
return 'ok', f'depot asset named in the {V2_TAG} manifest'
|
||||
return 'violation', f'depot asset NOT in the {V2_TAG} manifest'
|
||||
return 'violation', f'THIRD-PARTY origin ({h})'
|
||||
|
||||
|
||||
def classic_network_verdict(reqs, host):
|
||||
"""Run every captured request through the allow-list → (violations, tiers, observed)."""
|
||||
b = _v2_baseline()
|
||||
if not b:
|
||||
return None, None, None # the authority is unreachable; the caller FAILS
|
||||
paths, depot = b['paths'], b['depot']
|
||||
violations, tiers, observed = [], {}, []
|
||||
for u in reqs:
|
||||
v, tier = _classify_fetch(u, host, paths, depot)
|
||||
if v == 'ignored':
|
||||
continue
|
||||
observed.append(u)
|
||||
tiers.setdefault(tier, []).append(u)
|
||||
if v == 'violation':
|
||||
violations.append((u, tier))
|
||||
return violations, tiers, observed
|
||||
|
||||
|
||||
# ── the three checks ─────────────────────────────────────────────────────────
|
||||
def classic_regression(p):
|
||||
# [R16 — THE FLIP] The prime-law covenant MOVED from "flags off" to "?classic=1": the classic boot must
|
||||
@ -132,6 +240,8 @@ def classic_regression(p):
|
||||
# flags-off gate, now booted with ?classic=1 (the four flipped flags are off, everything else v2-default).
|
||||
head('CLASSIC REGRESSION (?classic=1 → the frozen v2 covenant)')
|
||||
b, pg, errs = new_page(p)
|
||||
reqs = [] # [R37 0.5] the wire, watched from before the first byte
|
||||
pg.on('request', lambda r: reqs.append(r.url))
|
||||
try:
|
||||
boot(pg, 'classic=1')
|
||||
state = pg.evaluate("""() => {
|
||||
@ -226,6 +336,67 @@ def classic_regression(p):
|
||||
if errs: FAIL(f"{len(errs)} console error(s) under ?classic; first: {errs[0][:140]}")
|
||||
else: OK('0 console errors under ?classic')
|
||||
|
||||
# ── [R37 item 0.5] THE NETWORK ALLOW-LIST ────────────────────────────────────────────────
|
||||
# Everything above this line has been true since R16 and none of it watches the wire. The
|
||||
# capture covers the whole gate path: boot + the fleet + the dig-inert leg's real shop entry
|
||||
# (which is what reaches the depot's interior fittings).
|
||||
pg.wait_for_timeout(1200) # let late/streamed fetches land before the verdict
|
||||
vio, tiers, observed = classic_network_verdict(reqs, HOST)
|
||||
if vio is None:
|
||||
FAIL(f"classic network allow-list has NO AUTHORITY — `git ls-tree {V2_TAG}` / "
|
||||
f"`git show {V2_TAG}:web/assets/manifest.json` failed. The allow-list is derived from "
|
||||
f"the {V2_TAG} tag; without it this arm asserts nothing and must not pass.")
|
||||
else:
|
||||
core_seen = [k for k in CLASSIC_REQUIRED if any(k in u for u in observed)]
|
||||
if len(core_seen) != len(CLASSIC_REQUIRED):
|
||||
missing = [k for k in CLASSIC_REQUIRED if k not in core_seen]
|
||||
FAIL(f"classic network: the REQUIRED v2 core was not observed ({missing}) — the observer "
|
||||
f"is not watching a live boot, so a clean allow-list verdict here would be vacuous")
|
||||
if vio:
|
||||
FAIL(f"CLASSIC FETCH SURFACE WIDENED — {len(vio)} fetch(es) outside the {V2_TAG} baseline: "
|
||||
+ '; '.join(f"{u.split('/')[-1]} [{why}]" for u, why in vio[:6])
|
||||
+ (f" (+{len(vio)-6} more)" if len(vio) > 6 else '')
|
||||
+ ". Allow-list, not deny-list: either the asset belongs in classic and the covenant "
|
||||
"needs a ruling + a re-pin, or it must be gated out of ?classic (R36 precedent).")
|
||||
else:
|
||||
depot_n = len(tiers.get(f'depot asset named in the {V2_TAG} manifest', []))
|
||||
v2_n = len(tiers.get(f'same-origin asset present in the {V2_TAG} tree', []))
|
||||
code_n = len(tiers.get('code (same-origin module/document)', []))
|
||||
OK(f"classic fetch surface CLEAN against the {V2_TAG} tag — {len(observed)} network "
|
||||
f"requests, 0 outside the baseline ({code_n} code modules · {v2_n} same-origin assets "
|
||||
f"present at {V2_TAG} · {depot_n} depot assets named in the {V2_TAG} manifest, incl. the "
|
||||
f"four v2-era street GLBs, which are BASELINE and must never be 'fixed' away)")
|
||||
for tier, urls in sorted(tiers.items()):
|
||||
if tier.startswith('POST-v2.0'):
|
||||
WARN_ONCE = f"classic carries a POST-{V2_TAG} fetch delta: {len(urls)} file(s) — " \
|
||||
f"{sorted({u.split('/')[-1] for u in urls})} — {tier.split('— ')[-1]}. " \
|
||||
f"Allowed here so the suite is not permanently yellow over shipped " \
|
||||
f"behaviour; UNRATIFIED. Fable's to bless or retire."
|
||||
print(f" \033[33m·\033[0m {WARN_ONCE}")
|
||||
|
||||
# ── THE CONTROL, demonstrated in-run: two fabricated fetches must both go RED ──
|
||||
# (a) the R36 breach replayed exactly — a same-origin ped GLB added after v2.0. An
|
||||
# allow-list phrased as "the four depot GLBs + same-origin" would be GREEN on this.
|
||||
# (b) an asset name that has never existed — proves a brand-new file cannot slip through
|
||||
# on a 404 (the request is still on the wire).
|
||||
ctl = list(reqs)
|
||||
pg.evaluate("""async () => { for (const u of ['models/peds/woman_dj_01.glb',
|
||||
'assets/gen/__gate_control_v8_palette.jpg'])
|
||||
{ try { await fetch(u); } catch (e) {} } }""")
|
||||
pg.wait_for_timeout(400)
|
||||
injected = [u for u in reqs if u not in ctl]
|
||||
cvio, _, _ = classic_network_verdict(reqs, HOST)
|
||||
names = sorted({u.split('/')[-1] for u, _ in (cvio or [])})
|
||||
if cvio is not None and len(cvio) == len(vio) + 2 and \
|
||||
'woman_dj_01.glb' in names and '__gate_control_v8_palette.jpg' in names:
|
||||
OK(f"CONTROL: the allow-list DISCRIMINATES — injecting the R36 breach itself "
|
||||
f"(same-origin models/peds/woman_dj_01.glb) plus a never-existed asset turned a clean "
|
||||
f"verdict into exactly 2 violations ({names}); {len(injected)} injected request(s) seen")
|
||||
else:
|
||||
FAIL(f"CONTROL FAILED: the fabricated fetches did not go red — the allow-list does not "
|
||||
f"discriminate (before {len(vio)}, after {len(cvio) if cvio is not None else 'None'}, "
|
||||
f"named {names}, injected {injected})")
|
||||
|
||||
# R7 escape hatch: ?roster=v1 must restore the fixed roster (streamMode:false), render, no errors.
|
||||
b2, pg2, errs2 = new_page(p)
|
||||
try:
|
||||
@ -614,21 +785,293 @@ def smoke_shelfbuy(p):
|
||||
finally:
|
||||
b.close()
|
||||
|
||||
# [R37 item 0.2] the fenced subject. B's R20 per-town fence (tram.js:101-113) refuses the tram on a
|
||||
# real-roads graph whose best main chain fronts < 5 shops. R21 measured katoomba/fremantle/bendigo
|
||||
# fenced at 3/2/1 shops fronted; katoomba is the absence arm's subject. If it ever stops being fenced
|
||||
# this gate FAILS rather than skipping — an absence arm with no subject is the vacuous gate again.
|
||||
TRAM_FENCED_TOWN = 'katoomba_real'
|
||||
TRAM_LANE = 3.2 # tram.js LANE — offset from the road centreline (drive on the left, AU)
|
||||
TRAM_SPEED = 9.0 # tram.js SPEED (m/s)
|
||||
TRAM_DWELL = 3.5 # tram.js DWELL (s at each stop)
|
||||
|
||||
# The behavioural integration, run on an ISOLATED tram (same plan, throwaway scene) so the shell's own
|
||||
# rAF tick cannot contaminate the dt sequence. Returns the whole trajectory's verdicts.
|
||||
_TRAM_DRIVE_JS = r"""
|
||||
async (cfg) => {
|
||||
const P = window.PROCITY;
|
||||
const m = await import('./js/world/tram.js');
|
||||
const scene = new P.THREE.Scene();
|
||||
const t = m.createTram({ scene, plan: P.plan, camera: P.camera, lighting: null });
|
||||
const info = t.routeInfo || {};
|
||||
if (info.fenced || !t.stops) return { fenced: !!info.fenced, stops: t.stops | 0, info };
|
||||
// independent geometry reference: the MAIN-edge segments straight off the plan (NOT tram.js's own
|
||||
// polyline) — so "the tram is 3.2 m off the road centreline" is checked against Lane A's data.
|
||||
const nodes = new Map(P.plan.streets.nodes.map(n => [n.id, n]));
|
||||
const segs = P.plan.streets.edges.filter(e => e.kind === 'main')
|
||||
.map(e => ({ a: nodes.get(e.a), b: nodes.get(e.b) })).filter(s => s.a && s.b);
|
||||
const distToMain = (x, z) => {
|
||||
let best = 1e18;
|
||||
for (const s of segs) {
|
||||
const dx = s.b.x - s.a.x, dz = s.b.z - s.a.z, L2 = dx * dx + dz * dz || 1;
|
||||
let u = ((x - s.a.x) * dx + (z - s.a.z) * dz) / L2; u = Math.max(0, Math.min(1, u));
|
||||
const px = s.a.x + dx * u, pz = s.a.z + dz * u;
|
||||
const d = Math.hypot(x - px, z - pz); if (d < best) best = d;
|
||||
}
|
||||
return best;
|
||||
};
|
||||
const dt = cfg.dt, N = Math.round(cfg.seconds / dt);
|
||||
const CRUISE = cfg.speed * dt * 1.05; // the most the schedule may move it in one tick, +5%
|
||||
let prev = { x: t.group.position.x, z: t.group.position.z };
|
||||
let path = 0, maxStep = 0, stills = 0, dwells = 0, run = 0;
|
||||
const jumps = []; // steps larger than CRUISE: the end-of-line LANE swap
|
||||
const offs = [];
|
||||
const runMin = Math.floor((cfg.dwellS * 0.8) / dt);
|
||||
for (let i = 0; i < N; i++) {
|
||||
t.update(dt);
|
||||
const x = t.group.position.x, z = t.group.position.z;
|
||||
const step = Math.hypot(x - prev.x, z - prev.z);
|
||||
if (step > CRUISE) jumps.push(+step.toFixed(3));
|
||||
else { path += step; if (step > maxStep) maxStep = step; }
|
||||
if (step < 1e-4) { run++; stills++; }
|
||||
else {
|
||||
if (run >= runMin) dwells++;
|
||||
run = 0;
|
||||
offs.push(+distToMain(x, z).toFixed(3));
|
||||
}
|
||||
prev = { x, z };
|
||||
}
|
||||
if (run >= runMin) dwells++;
|
||||
offs.sort((a, b) => a - b);
|
||||
t.dispose();
|
||||
// Three kinds of legitimate discontinuity exist in tram.js and all three are bounded by 2·LANE:
|
||||
// (a) the END REVERSAL — `dir` flips ⇒ the LANE offset mirrors across the road ⇒ exactly 2·LANE;
|
||||
// (b) the STOP SNAP — `s = stopS[nextStop]` when within EPS(2.5 m) of a mark;
|
||||
// (c) the BEND KINK — the lane offset is taken off the SEGMENT direction, so at a polyline
|
||||
// bend of θ the offset point steps 2·LANE·sin(θ/2) sideways.
|
||||
// The gate asserts (a) happened (it reached the end and turned) and that NOTHING exceeds 2·LANE.
|
||||
const big = jumps.filter(j => j >= cfg.lane * 2 - 0.5);
|
||||
return { fenced: false, stops: t.stops | 0, info, path: +path.toFixed(1), maxStep: +maxStep.toFixed(4),
|
||||
dwells, stills, stillS: +(stills * dt).toFixed(2), samples: offs.length,
|
||||
jumps: jumps.length, reversals: big.length,
|
||||
jumpMin: jumps.length ? Math.min(...jumps) : null,
|
||||
jumpMax: jumps.length ? Math.max(...jumps) : null,
|
||||
offMed: offs.length ? offs[offs.length >> 1] : null,
|
||||
offMin: offs.length ? offs[0] : null, offMax: offs.length ? offs[offs.length - 1] : null };
|
||||
}
|
||||
"""
|
||||
|
||||
# Observe the LIVE tram under the shell's own rAF loop (nothing driven by the harness): does the thing
|
||||
# the shell built actually move? `getObjectByName('tram')` cannot tell you — the fenced path adds a
|
||||
# named EMPTY group, which is exactly why the old one-line check passed on towns with no tram.
|
||||
_TRAM_LIVE_JS = r"""
|
||||
async (frames) => {
|
||||
const P = window.PROCITY;
|
||||
const g = P.scene.getObjectByName('tram');
|
||||
const seen = [];
|
||||
for (let i = 0; i < frames; i++) {
|
||||
await new Promise(r => requestAnimationFrame(r));
|
||||
seen.push([+P.tram.group.position.x.toFixed(3), +P.tram.group.position.z.toFixed(3)]);
|
||||
}
|
||||
let moved = 0;
|
||||
for (let i = 1; i < seen.length; i++) moved += Math.hypot(seen[i][0] - seen[i - 1][0], seen[i][1] - seen[i - 1][1]);
|
||||
const uniq = new Set(seen.map(s => s.join(','))).size;
|
||||
return { moved: +moved.toFixed(3), uniq, frames: seen.length,
|
||||
namedGroupPresent: !!g, groupChildren: g ? g.children.length : null,
|
||||
first: seen[0], last: seen[seen.length - 1] };
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _tram_state(pg):
|
||||
"""The tram's own published verdict + the OLD gate's one-liner, side by side."""
|
||||
return pg.evaluate("""() => {
|
||||
const P = window.PROCITY, g = P.scene.getObjectByName('tram');
|
||||
const ri = P.tram ? (P.tram.routeInfo || {}) : null;
|
||||
return { obj: !!P.tram, legacyPresent: !!g, groupChildren: g ? g.children.length : null,
|
||||
fenced: ri ? !!ri.fenced : null, stops: P.tram ? (P.tram.stops | 0) : null,
|
||||
routeEdges: ri ? (ri.routeEdges | 0) : null, routeMetres: ri ? (ri.routeMetres | 0) : null,
|
||||
shopsFronted: ri ? (ri.shopsFronted | 0) : null, reason: ri ? (ri.reason || null) : null,
|
||||
townEdges: (P.plan.streets.edges || []).length, town: P.plan.name };
|
||||
}""")
|
||||
|
||||
|
||||
# The two predicates the gate is built from. They are FUNCTIONS, not inline asserts, precisely so the
|
||||
# falsifiability control can point each one at the other arm's subject and show it go red (house law:
|
||||
# every gate ships with its control demonstrated).
|
||||
def _tram_runs_pred(st, drive):
|
||||
"""True iff this town's tram is a RUNNING tram: route resolved, stops sane, on the road, moving."""
|
||||
why = []
|
||||
if not st['obj']: why.append('PROCITY.tram is null')
|
||||
if st['fenced'] is not False: why.append(f"routeInfo.fenced={st['fenced']}")
|
||||
if not st['stops'] or not (1 <= st['stops'] <= 60): why.append(f"stops={st['stops']} outside [1,60]")
|
||||
if (st['routeEdges'] or 0) < 2: why.append(f"routeEdges={st['routeEdges']} < 2")
|
||||
if (st['routeMetres'] or 0) < 50: why.append(f"routeMetres={st['routeMetres']} < 50")
|
||||
if drive is None or drive.get('fenced'): why.append('behavioural drive refused (fenced/no stops)')
|
||||
else:
|
||||
if drive['path'] < 0.5 * (st['routeMetres'] or 0): why.append(f"travelled {drive['path']}m < half the route")
|
||||
if drive['maxStep'] > TRAM_SPEED * 0.05 * 1.05: why.append(f"max cruising tick {drive['maxStep']}m exceeds SPEED·dt")
|
||||
if drive['dwells'] < 1: why.append('never dwelled at a stop')
|
||||
# dwell ACCOUNTING: total stationary time must equal dwells × DWELL to within a tick. This is
|
||||
# what proves the stops are real marks being consumed rather than the body simply being stuck.
|
||||
if abs(drive['stillS'] - drive['dwells'] * TRAM_DWELL) > 0.5:
|
||||
why.append(f"stationary time {drive['stillS']}s != {drive['dwells']} dwells × {TRAM_DWELL}s")
|
||||
# REVERSAL: the only permitted discontinuity is the end-of-line lane swap (dir flips ⇒ the LANE
|
||||
# offset mirrors ⇒ exactly 2·LANE across the road). Its presence proves it reached the end and
|
||||
# turned; its SIZE proves nothing else is teleporting the tram.
|
||||
if not drive['reversals']:
|
||||
why.append('never reversed at an end (no 2·LANE lane-swap discontinuity in 400 s)')
|
||||
if drive['jumpMax'] is not None and drive['jumpMax'] > 2 * TRAM_LANE + 0.5:
|
||||
why.append(f"discontinuity {drive['jumpMax']}m exceeds 2·LANE {2*TRAM_LANE}m — the tram is being "
|
||||
f"teleported by something other than the documented end-of-line lane swap")
|
||||
if drive['offMed'] is None or not (TRAM_LANE - 0.35 <= drive['offMed'] <= TRAM_LANE + 0.35):
|
||||
why.append(f"median offset from the main centreline {drive['offMed']}m != LANE {TRAM_LANE}m")
|
||||
return (not why), why
|
||||
|
||||
|
||||
def _tram_absent_pred(st, live):
|
||||
"""True iff this town's tram is GENUINELY absent: fenced verdict, no stops, empty group, never moves."""
|
||||
why = []
|
||||
if not st['obj']: why.append('PROCITY.tram is null (expected the fenced stub object)')
|
||||
if st['fenced'] is not True: why.append(f"routeInfo.fenced={st['fenced']} (expected True)")
|
||||
if st['stops'] != 0: why.append(f"stops={st['stops']} (expected 0)")
|
||||
if st['groupChildren'] != 0: why.append(f"the named 'tram' group has {st['groupChildren']} children (expected 0)")
|
||||
if live is not None and live['moved'] > 0.01: why.append(f"the fenced tram MOVED {live['moved']}m")
|
||||
return (not why), why
|
||||
|
||||
|
||||
def smoke_tram(p):
|
||||
"""NEW (warn): ?tram=1 — Lane B tram loop. Auto-skips if not landed."""
|
||||
"""[R37 item 0.2 — REPAIRED] the tram gate, rebuilt from a vacuous one-liner.
|
||||
|
||||
What it was (flags_check.py:617-630, R9→R36): `!!scene.getObjectByName('tram')`, a printed SKIP
|
||||
and an early `return` when absent, warn-level. Three faults, all fatal:
|
||||
· it PASSED ON THE FENCED PATH — `tram.js:110-114` adds a *named empty group* to the scene
|
||||
before returning the no-op stub, so the one assertion is true on every town where the tram
|
||||
deliberately does not exist. The gate was green on precisely the towns it was meant to police.
|
||||
· SKIP-and-return on absence: a missing subject exited 0. Vacuous-gate law, inside our own suite.
|
||||
· it asserted nothing about the tram being a TRAM — no route, no stops, no motion.
|
||||
|
||||
What it is now — four arms and a demonstrated control:
|
||||
1. RUNS (default boot, synthetic): route resolved (edges/metres), stops sane, and — under the
|
||||
SHELL'S OWN rAF loop, nothing driven by the harness — the body measurably moves.
|
||||
2. BEHAVIOUR (isolated tram, same plan, throwaway scene, fixed dt): it traverses the line,
|
||||
reverses at the end, dwells at stops, never exceeds SPEED·dt, and rides exactly LANE=3.2 m
|
||||
off the centreline — measured against Lane A's own main-edge segments, not tram.js's polyline.
|
||||
3. GENUINELY ABSENT (a fenced real-roads town): the fenced verdict is published, stops == 0, the
|
||||
named group is EMPTY, and the thing never moves. The old one-liner's `true` is printed next
|
||||
to it as the standing record of what a vacuous gate looks like.
|
||||
4. OFF (`?tram=0` and `?classic=1`): `PROCITY.tram` is null and no 'tram' object is in the scene.
|
||||
CONTROL, demonstrated every run: the run-predicate is applied to the FENCED town's subject and
|
||||
must go RED, and the absence-predicate is applied to the RUNNING tram and must go RED. Each
|
||||
predicate is proven to discriminate on this tree, in this run, rather than asserted to.
|
||||
|
||||
STRICT (was warn-level). A missing subject FAILS."""
|
||||
head('SMOKE: tram (R37 0.2 — runs where it runs · genuinely absent where fenced · control both ways)')
|
||||
|
||||
# ── arm 1+2: the running tram, on the default boot ──────────────────────────────────────────
|
||||
b, pg, errs = new_page(p)
|
||||
run_st = run_live = run_drive = None
|
||||
try:
|
||||
boot(pg, 'tram=1')
|
||||
present = pg.evaluate("() => !!window.PROCITY.scene.getObjectByName('tram')")
|
||||
if not present:
|
||||
print("· tram=1 not landed (no 'tram' in scene) — skipping (Lane B)")
|
||||
return
|
||||
head('SMOKE: tram (?tram=1; new → warn-level)')
|
||||
OK("tram=1: 'tram' present in scene")
|
||||
if errs: WARN(f"tram: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||||
boot(pg, '')
|
||||
run_st = _tram_state(pg)
|
||||
if not run_st['obj']:
|
||||
FAIL('tram: PROCITY.tram is NULL on the default boot — the subject is absent, so this gate '
|
||||
'FAILS (the pre-R37 gate printed a SKIP here and exited 0)')
|
||||
else:
|
||||
run_live = pg.evaluate(_TRAM_LIVE_JS, 45)
|
||||
run_drive = pg.evaluate(_TRAM_DRIVE_JS,
|
||||
{'dt': 0.05, 'seconds': 400, 'dwellS': TRAM_DWELL,
|
||||
'speed': TRAM_SPEED, 'lane': TRAM_LANE})
|
||||
ok, why = _tram_runs_pred(run_st, run_drive)
|
||||
if ok:
|
||||
OK(f"tram RUNS on '{run_st['town']}': {run_st['stops']} stops over {run_st['routeMetres']} m "
|
||||
f"({run_st['routeEdges']} main edges, {run_st['shopsFronted']} shops fronted)")
|
||||
else:
|
||||
FAIL(f"tram does NOT run on the default boot: {'; '.join(why)} [{run_st}]")
|
||||
# motion under the SHELL's loop — the assertion the old gate never made
|
||||
if run_live['moved'] > 0.5 and run_live['uniq'] > 3:
|
||||
OK(f"tram MOVES under the shell's own loop — {run_live['moved']} m over {run_live['frames']} "
|
||||
f"frames, {run_live['uniq']} distinct positions ({run_live['first']} → {run_live['last']})")
|
||||
else:
|
||||
FAIL(f"tram did not move under the shell's loop: {run_live}")
|
||||
if run_drive and not run_drive.get('fenced'):
|
||||
OK(f"tram BEHAVES: travelled {run_drive['path']} m of a {run_st['routeMetres']} m route in 400 s, "
|
||||
f"{run_drive['dwells']} stop dwell(s) accounting for exactly {run_drive['stillS']} s of door "
|
||||
f"time ({run_drive['dwells']}×{TRAM_DWELL} s), max cruising tick {run_drive['maxStep']} m ≤ "
|
||||
f"SPEED·dt {round(TRAM_SPEED*0.05,3)} m, {run_drive['reversals']} end reversal(s) "
|
||||
f"(2·LANE lane swap) of {run_drive['jumps']} bounded discontinuities "
|
||||
f"({run_drive['jumpMin']}–{run_drive['jumpMax']} m ≤ 2·LANE), offset from A's main "
|
||||
f"centreline med {run_drive['offMed']} m [{run_drive['offMin']}–{run_drive['offMax']}] "
|
||||
f"vs LANE {TRAM_LANE} m")
|
||||
if errs: FAIL(f"tram/default: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||||
else: print(' · 0 console errors (default boot)')
|
||||
finally:
|
||||
b.close()
|
||||
|
||||
# ── arm 3: the fenced town — genuinely absent, and the old one-liner's verdict on record ─────
|
||||
b, pg, errs = new_page(p)
|
||||
fen_st = fen_live = None
|
||||
try:
|
||||
boot(pg, f'plansrc=osm&town={TRAM_FENCED_TOWN}')
|
||||
fen_st = _tram_state(pg)
|
||||
if not fen_st['obj']:
|
||||
FAIL(f"tram/fenced: PROCITY.tram is null on {TRAM_FENCED_TOWN} — the absence arm has no "
|
||||
f"subject to measure; re-pick a fenced town")
|
||||
elif fen_st['fenced'] is not True:
|
||||
FAIL(f"tram/fenced: {TRAM_FENCED_TOWN} is NO LONGER FENCED ({fen_st['shopsFronted']} shops "
|
||||
f"fronted, {fen_st['stops']} stops) — the absence arm lost its subject. Re-pick a fenced "
|
||||
f"town (R21: katoomba 3 / fremantle 2 / bendigo 1 shops fronted) rather than skipping.")
|
||||
else:
|
||||
fen_live = pg.evaluate(_TRAM_LIVE_JS, 30)
|
||||
ok, why = _tram_absent_pred(fen_st, fen_live)
|
||||
if ok:
|
||||
OK(f"tram GENUINELY ABSENT on {TRAM_FENCED_TOWN}: fenced=True, 0 stops, the named group is "
|
||||
f"EMPTY (0 children), moved {fen_live['moved']} m over {fen_live['frames']} frames "
|
||||
f"— reason: {str(fen_st['reason'])[:90]}")
|
||||
else:
|
||||
FAIL(f"tram/fenced: not genuinely absent — {'; '.join(why)} [{fen_st}]")
|
||||
# the standing record of the hole this repair closed
|
||||
if fen_st['legacyPresent']:
|
||||
print(f" · [record] the pre-R37 assertion `!!scene.getObjectByName('tram')` returns TRUE here "
|
||||
f"— a {fen_st['groupChildren']}-child group on a town with no tram. That is the whole bug.")
|
||||
else:
|
||||
WARN("tram/fenced: the fenced path no longer adds a named empty group — the R37 0.2 "
|
||||
"premise moved; re-read tram.js:110-114 before trusting this gate's history")
|
||||
if errs: FAIL(f"tram/fenced: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||||
finally:
|
||||
b.close()
|
||||
|
||||
# ── THE CONTROL, demonstrated: each predicate pointed at the other arm's subject must go RED ──
|
||||
if run_st and fen_st and run_st.get('obj') and fen_st.get('obj'):
|
||||
ok_ctl, why_ctl = _tram_runs_pred(fen_st, None)
|
||||
if not ok_ctl:
|
||||
OK(f"CONTROL A: the RUN predicate applied to the fenced town goes RED as required "
|
||||
f"({why_ctl[0]}) — the run arm cannot pass on a town with no tram")
|
||||
else:
|
||||
FAIL('CONTROL A FAILED: the RUN predicate PASSES on the fenced town — this gate is vacuous again')
|
||||
ok_ctl2, why_ctl2 = _tram_absent_pred(run_st, run_live)
|
||||
if not ok_ctl2:
|
||||
OK(f"CONTROL B: the ABSENCE predicate applied to the running tram goes RED as required "
|
||||
f"({why_ctl2[0]}) — the absence arm cannot pass on a town where the tram runs")
|
||||
else:
|
||||
FAIL('CONTROL B FAILED: the ABSENCE predicate PASSES on the running tram — absence is unfalsifiable')
|
||||
else:
|
||||
FAIL('tram: controls unrun — one of the two subjects was missing (see the failures above)')
|
||||
|
||||
# ── arm 4: the flag is a real off switch (?tram=0 and ?classic=1) ────────────────────────────
|
||||
for q, label in (('tram=0', '?tram=0'), ('classic=1', '?classic=1')):
|
||||
b, pg, errs = new_page(p)
|
||||
try:
|
||||
boot(pg, q)
|
||||
off = pg.evaluate("""() => ({ obj: window.PROCITY.tram,
|
||||
named: !!window.PROCITY.scene.getObjectByName('tram') })""")
|
||||
if off['obj'] is None and not off['named']:
|
||||
OK(f"{label}: no tram object AND no 'tram' in the scene (the flag is a real off switch)")
|
||||
else:
|
||||
FAIL(f"{label}: tram leaked — {off}")
|
||||
if errs: FAIL(f"tram/{label}: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||||
finally:
|
||||
b.close()
|
||||
|
||||
def smoke_audio(p):
|
||||
"""R11 audio house-law (Lane F): silent-and-happy, nothing plays pre-gesture, ?mute=1 silences,
|
||||
?noassets=1 fetches zero audio, and interior beds play + release AudioNodes across enter/exit.
|
||||
@ -2738,9 +3181,29 @@ def smoke_rotation(p):
|
||||
|
||||
|
||||
def main():
|
||||
# [R37] `--only name[,name…]` runs a subset of the smokes by function name (e.g. `--only smoke_tram`).
|
||||
# Development ergonomics only: the suite's contract is still "run them all"; --only prints a banner
|
||||
# so a partial run can never be mistaken for a green suite in a log.
|
||||
only = None
|
||||
for i, arg in enumerate(sys.argv[1:]):
|
||||
if arg == '--only' and i + 2 <= len(sys.argv[1:]):
|
||||
only = set(sys.argv[i + 2].split(','))
|
||||
elif arg.startswith('--only='):
|
||||
only = set(arg.split('=', 1)[1].split(','))
|
||||
if only:
|
||||
print(f"\033[33m● PARTIAL RUN — --only {sorted(only)} (NOT a suite verdict)\033[0m")
|
||||
|
||||
srv = ensure_server()
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
if only:
|
||||
g = globals()
|
||||
for name in sorted(only):
|
||||
fn = g.get(name)
|
||||
if not callable(fn):
|
||||
sys.exit(f"--only: no such check '{name}'")
|
||||
fn(p)
|
||||
raise SystemExit(_verdict(partial=True))
|
||||
flags = list(KNOWN_FLAGS)
|
||||
if plansrc_landed(p):
|
||||
flags.append('plansrc=osm'); print("· plansrc=osm detected as landed — included")
|
||||
@ -2780,15 +3243,19 @@ def main():
|
||||
finally:
|
||||
if srv: srv.terminate()
|
||||
|
||||
head('VERDICT')
|
||||
sys.exit(_verdict())
|
||||
|
||||
|
||||
def _verdict(partial=False):
|
||||
head('VERDICT' + (' (PARTIAL — --only)' if partial else ''))
|
||||
print(json.dumps({'fails': len(fails), 'warns': len(warns)}, indent=0))
|
||||
if fails:
|
||||
print(f"\033[31m● flags_check RED\033[0m — {len(fails)} failure(s), {len(warns)} warning(s).")
|
||||
for f in fails: print(' FAIL:', f)
|
||||
sys.exit(1)
|
||||
print(f"\033[32m● flags_check GREEN\033[0m — {len(warns)} warning(s).")
|
||||
return 1
|
||||
print(f"\033[32m● flags_check {'PARTIAL-GREEN' if partial else 'GREEN'}\033[0m — {len(warns)} warning(s).")
|
||||
for w in warns: print(' warn:', w)
|
||||
sys.exit(0)
|
||||
return 0
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
472
tools/qa/budget_walk.py
Normal file
472
tools/qa/budget_walk.py
Normal file
@ -0,0 +1,472 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PROCITY Lane F — THE WALKED BUDGET (R37 item 0.3: settle the budget of record).
|
||||
|
||||
Two independent headless measurements disagreed and no proposal may spend against a number
|
||||
nobody can reproduce:
|
||||
• the city-patterns audit — "walked worst frame 280 draws of 300", 8 m steps down the main
|
||||
spine, "carrying the R+1 dispose window at up to 31 live chunks";
|
||||
• the animal spike — 191 default / 167 classic, by teleport-walk, could not reach 280.
|
||||
|
||||
This tool measures the same thing three ways on ONE tree, ONE box, ONE run, so the disagreement
|
||||
is a measurement rather than an argument. The three methods:
|
||||
|
||||
walk CONTINUOUS MOTION through the real rAF loop. The player is advanced along the main
|
||||
spine a fixed distance per FRAME and every frame is sampled (`renderer.info` after
|
||||
the composer has run). The shell's own `chunks.update()` drives streaming, so the
|
||||
R+1 dispose residue, the 4 ms/frame build queue and the citizen LOD are all the real
|
||||
ones. `--settle` holds position while `chunks.pending > 0` so every sampled frame is
|
||||
a FULLY-STREAMED frame — which is the state a real 4.6 m/s walker is permanently in
|
||||
(see the drain measurement the tool prints: a boundary crossing drains in a few
|
||||
frames, and a real walker gets ~830 frames per chunk). Without --settle you measure
|
||||
a walker who outruns his own streamer, which UNDER-reports.
|
||||
stepwalk the audit's method: `DBG.teleport()` in 8 m steps (teleport does warmup+render).
|
||||
bookmark the pre-audit method: `DBG.shot(name)` at a named bookmark, clean load.
|
||||
|
||||
Every method reports draws AND tris, the worst frame, and where it stood.
|
||||
|
||||
Run (port-isolated, no-store server, pinned tree):
|
||||
tools/.venv/bin/python tools/qa/budget_walk.py --root <tree>/web --port 8951 \
|
||||
--town synthetic --boot default --method walk
|
||||
|
||||
Headless = SwiftShader. Draw CALLS and TRIANGLES are driver-independent (they are counted by
|
||||
three.js on the JS side, not by the GPU), so these numbers are honest for budget purposes; what
|
||||
headless cannot tell you is frame TIME. Say so wherever the number is quoted.
|
||||
"""
|
||||
import argparse, functools, json, os, pathlib, socket, statistics, sys, threading, time
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError:
|
||||
sys.exit("playwright not installed — tools/.venv/bin/python -m pip install playwright")
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
|
||||
SEED = 20261990
|
||||
|
||||
|
||||
# ── a no-store static server (the house dev server's 304s will serve you a stale module) ──
|
||||
class NoStore(SimpleHTTPRequestHandler):
|
||||
def end_headers(self):
|
||||
self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
|
||||
self.send_header('Pragma', 'no-cache')
|
||||
super().end_headers()
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
def serve(root, port):
|
||||
h = functools.partial(NoStore, directory=str(root))
|
||||
srv = ThreadingHTTPServer(('127.0.0.1', port), h)
|
||||
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||
for _ in range(50):
|
||||
with socket.socket() as s:
|
||||
s.settimeout(0.3)
|
||||
if s.connect_ex(('127.0.0.1', port)) == 0:
|
||||
return srv
|
||||
time.sleep(0.1)
|
||||
sys.exit(f'no-store server would not come up on :{port}')
|
||||
|
||||
|
||||
# ── the in-page spine builder: the same "follow the retail" walk the tram uses (tram.js) ──
|
||||
SPINE_JS = r"""
|
||||
(maxm) => {
|
||||
const P = window.PROCITY, plan = P.plan;
|
||||
const nodes = new Map(plan.streets.nodes.map(n => [n.id, n]));
|
||||
const mains = plan.streets.edges.filter(e => e.kind === 'main');
|
||||
if (!mains.length) return null;
|
||||
const byNode = new Map();
|
||||
for (const e of mains) for (const nid of [e.a, e.b]) {
|
||||
if (!byNode.has(nid)) byNode.set(nid, []); byNode.get(nid).push(e);
|
||||
}
|
||||
const lotById = new Map((plan.lots || []).map(l => [l.id, l]));
|
||||
const shopsOnEdge = new Map();
|
||||
for (const s of (plan.shops || [])) {
|
||||
const l = lotById.get(s.lot);
|
||||
if (l && l.frontEdge != null) shopsOnEdge.set(l.frontEdge, (shopsOnEdge.get(l.frontEdge) || 0) + 1);
|
||||
}
|
||||
const edgeShops = e => shopsOnEdge.get(e.id) || 0;
|
||||
const walk = (start) => {
|
||||
const seq = [start]; const used = new Set(); let cur = start, shops = 0, metres = 0;
|
||||
for (;;) {
|
||||
const cands = (byNode.get(cur) || []).filter(e => !used.has(e.id));
|
||||
if (!cands.length) break;
|
||||
let nx = cands[0];
|
||||
for (const c of cands) if (edgeShops(c) > edgeShops(nx)) nx = c;
|
||||
used.add(nx.id); shops += edgeShops(nx);
|
||||
const A = nodes.get(cur), nid = nx.a === cur ? nx.b : nx.a, B = nodes.get(nid);
|
||||
if (A && B) metres += Math.hypot(B.x - A.x, B.z - A.z);
|
||||
cur = nid; seq.push(cur);
|
||||
}
|
||||
return { seq, shops, metres };
|
||||
};
|
||||
const starts = [];
|
||||
for (const [nid, es] of byNode) if (es.length === 1) starts.push(nid);
|
||||
if (!starts.length) starts.push(mains[0].a);
|
||||
let best = null;
|
||||
for (const s of starts) {
|
||||
const r = walk(s);
|
||||
if (!best || r.shops > best.shops || (r.shops === best.shops && r.metres > best.metres)) best = r;
|
||||
}
|
||||
let poly = best.seq.map(id => { const n = nodes.get(id); return [n.x, n.z]; });
|
||||
// cumulative arc length
|
||||
const cum = [0];
|
||||
for (let i = 1; i < poly.length; i++)
|
||||
cum.push(cum[i - 1] + Math.hypot(poly[i][0] - poly[i - 1][0], poly[i][1] - poly[i - 1][1]));
|
||||
const total = cum[cum.length - 1];
|
||||
// window the walk on the town's RETAIL HEART (DBG.townAnchor) so a 3 km real-roads chain does
|
||||
// not spend the whole run in paddocks. Synthetic anchors at the origin (unchanged behaviour).
|
||||
let out = { poly, cum, total, shopsFronted: best.shops, mainEdges: mains.length,
|
||||
windowed: false, anchor: null };
|
||||
const A = (window.DBG && window.DBG.townAnchor) || null;
|
||||
if (maxm > 0 && total > maxm) {
|
||||
const ax = A ? A.ref.x : 0, az = A ? A.ref.z : 0;
|
||||
let bs = 0, bd = 1e18;
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const dd = (poly[i][0] - ax) ** 2 + (poly[i][1] - az) ** 2;
|
||||
if (dd < bd) { bd = dd; bs = cum[i]; }
|
||||
}
|
||||
const s0 = Math.max(0, Math.min(total - maxm, bs - maxm / 2)), s1 = s0 + maxm;
|
||||
// resample the window
|
||||
const posAt = (s) => {
|
||||
s = Math.max(0, Math.min(total, s));
|
||||
let i = 0; while (i < cum.length - 2 && cum[i + 1] < s) i++;
|
||||
const seg = cum[i + 1] - cum[i] || 1, f = (s - cum[i]) / seg;
|
||||
return [poly[i][0] + (poly[i + 1][0] - poly[i][0]) * f,
|
||||
poly[i][1] + (poly[i + 1][1] - poly[i][1]) * f];
|
||||
};
|
||||
const np = []; const nc = [];
|
||||
for (let s = s0; s <= s1 + 1e-6; s += 4) { np.push(posAt(s)); nc.push(s - s0); }
|
||||
out = { poly: np, cum: nc, total: nc[nc.length - 1], shopsFronted: best.shops,
|
||||
mainEdges: mains.length, windowed: true, anchor: A ? A.ref : null };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
"""
|
||||
|
||||
# ── the walker: real rAF loop, one sample per rendered frame ──
|
||||
WALK_JS = r"""
|
||||
(cfg) => {
|
||||
const P = window.PROCITY;
|
||||
const { poly, cum, total } = cfg.path;
|
||||
const posAt = (s) => {
|
||||
s = Math.max(0, Math.min(total, s));
|
||||
let i = 0; while (i < cum.length - 2 && cum[i + 1] < s) i++;
|
||||
const seg = cum[i + 1] - cum[i] || 1, f = (s - cum[i]) / seg;
|
||||
const x = poly[i][0] + (poly[i + 1][0] - poly[i][0]) * f;
|
||||
const z = poly[i][1] + (poly[i + 1][1] - poly[i][1]) * f;
|
||||
let dx = poly[i + 1][0] - poly[i][0], dz = poly[i + 1][1] - poly[i][1];
|
||||
const L = Math.hypot(dx, dz) || 1; dx /= L; dz /= L;
|
||||
return { x, z, dx, dz };
|
||||
};
|
||||
const W = window.__BW = { samples: [], done: false, frames: 0, holds: 0, laps: 0,
|
||||
drains: [], t0: performance.now() };
|
||||
let s = 0, dir = 1, held = 0, lastChunk = null, sinceCross = 0, dwelt = 0;
|
||||
// face the direction of travel: three's camera looks down -Z, so yaw = atan2(-dx, -dz)
|
||||
const face = (q) => Math.atan2(-q.dx * dir, -q.dz * dir);
|
||||
const q0 = posAt(0); P.player.teleport(q0.x, q0.z, face(q0));
|
||||
function step() {
|
||||
W.frames++;
|
||||
const r = P.renderer.info.render;
|
||||
const pos = P.player.position;
|
||||
const ck = P.chunks ? P.chunks.count : 0, pend = P.chunks ? P.chunks.pending : 0;
|
||||
const key = Math.round(pos.x / 64) + ',' + Math.round(pos.z / 64);
|
||||
if (key !== lastChunk) { if (lastChunk !== null) W.drains.push(sinceCross); lastChunk = key; sinceCross = 0; }
|
||||
sinceCross++;
|
||||
// sample the frame that just rendered (draws/tris are this frame's, position is where it stood)
|
||||
W.samples.push([r.calls, r.triangles, ck, pend, +pos.x.toFixed(1), +pos.z.toFixed(1), +s.toFixed(1), dir]);
|
||||
if (cfg.settle && pend > 0 && held < cfg.maxHold) { held++; W.holds++; return requestAnimationFrame(step); }
|
||||
// hold `holdFrames` further frames at each position. The engine does budgeted per-frame work the
|
||||
// chunk queue does not report: CitizenSim acquires at most a few rigs per frame (sim.js NEAR_MAX 24,
|
||||
// "if it fails this frame, ride as an impostor instead") and staggers mixers round-robin. A walker
|
||||
// who advances a metre per FRAME outruns that budget and measures a chronically half-dressed street.
|
||||
// holdFrames turns "1 m per frame" into "1 m per (1+holdFrames) frames" — i.e. a REAL 4.6 m/s pace
|
||||
// at holdFrames≈5 on this box — while still sampling every frame.
|
||||
if (cfg.holdFrames > 0 && dwelt < cfg.holdFrames) { dwelt++; W.holds++; return requestAnimationFrame(step); }
|
||||
dwelt = 0; held = 0;
|
||||
s += dir * cfg.stepm;
|
||||
if (s >= total) { s = total; dir = -1; W.laps++; }
|
||||
else if (s <= 0 && W.laps > 0) { W.done = true; return; }
|
||||
if (W.laps >= cfg.laps && dir < 0 && s <= 0) { W.done = true; return; }
|
||||
if (W.frames > cfg.maxFrames) { W.done = true; return; }
|
||||
const q = posAt(s);
|
||||
P.player.teleport(q.x, q.z, face(q));
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
requestAnimationFrame(step);
|
||||
return { total, points: poly.length };
|
||||
}
|
||||
"""
|
||||
|
||||
# ── the DWELL diagnostic: stand at one pose and sample N frames. Answers "how much of the frame is
|
||||
# still being assembled after arrival?" — the crowd-fill lag that a fast walker never sees.
|
||||
DWELL_JS = r"""
|
||||
(cfg) => {
|
||||
const P = window.PROCITY;
|
||||
const W = window.__BW = { samples: [], done: false, frames: 0, holds: 0, drains: [],
|
||||
t0: performance.now() };
|
||||
P.player.teleport(cfg.x, cfg.z, cfg.yaw);
|
||||
function step() {
|
||||
W.frames++;
|
||||
const r = P.renderer.info.render, pos = P.player.position;
|
||||
W.samples.push([r.calls, r.triangles, P.chunks.count, P.chunks.pending,
|
||||
+pos.x.toFixed(1), +pos.z.toFixed(1), 0, 1]);
|
||||
if (W.frames >= cfg.frames) { W.done = true; return; }
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
requestAnimationFrame(step);
|
||||
return { dwell: true };
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
# ── the SWEEP: worst frame over POSITION × VIEW DIRECTION. A walk that always faces along the spine
|
||||
# only ever measures one bearing; a player turns their head. At each station the camera is settled,
|
||||
# then rotated through `yaws` bearings, each given `settle` frames before the sample is taken.
|
||||
SWEEP_JS = r"""
|
||||
(cfg) => {
|
||||
const P = window.PROCITY;
|
||||
const { poly, cum, total } = cfg.path;
|
||||
const posAt = (s) => {
|
||||
s = Math.max(0, Math.min(total, s));
|
||||
let i = 0; while (i < cum.length - 2 && cum[i + 1] < s) i++;
|
||||
const seg = cum[i + 1] - cum[i] || 1, f = (s - cum[i]) / seg;
|
||||
return [poly[i][0] + (poly[i + 1][0] - poly[i][0]) * f,
|
||||
poly[i][1] + (poly[i + 1][1] - poly[i][1]) * f];
|
||||
};
|
||||
const stations = [];
|
||||
for (let s = 0; s <= total + 1e-6; s += cfg.stationm) stations.push([s, ...posAt(s)]);
|
||||
const W = window.__BW = { samples: [], done: false, frames: 0, holds: 0, drains: [],
|
||||
t0: performance.now() };
|
||||
let si = 0, yi = 0, wait = cfg.settle;
|
||||
P.player.teleport(stations[0][1], stations[0][2], 0);
|
||||
function step() {
|
||||
W.frames++;
|
||||
const r = P.renderer.info.render, pos = P.player.position;
|
||||
if (wait > 0) { wait--; W.holds++; }
|
||||
else {
|
||||
W.samples.push([r.calls, r.triangles, P.chunks.count, P.chunks.pending,
|
||||
+pos.x.toFixed(1), +pos.z.toFixed(1), stations[si][0],
|
||||
+(yi * 360 / cfg.yaws).toFixed(0)]);
|
||||
yi++;
|
||||
if (yi >= cfg.yaws) { yi = 0; si++; wait = cfg.settle; } else { wait = cfg.yawSettle; }
|
||||
if (si >= stations.length) { W.done = true; return; }
|
||||
}
|
||||
const [, x, z] = stations[si];
|
||||
P.player.teleport(x, z, yi * 2 * Math.PI / cfg.yaws);
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
requestAnimationFrame(step);
|
||||
return { stations: stations.length, total };
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
# ── the audit's method: 8 m DBG.teleport steps (teleport = warmup + one render) ──
|
||||
STEPWALK_JS = r"""
|
||||
(cfg) => {
|
||||
const P = window.PROCITY, D = window.DBG;
|
||||
const { poly, cum, total } = cfg.path;
|
||||
const posAt = (s) => {
|
||||
s = Math.max(0, Math.min(total, s));
|
||||
let i = 0; while (i < cum.length - 2 && cum[i + 1] < s) i++;
|
||||
const seg = cum[i + 1] - cum[i] || 1, f = (s - cum[i]) / seg;
|
||||
const x = poly[i][0] + (poly[i + 1][0] - poly[i][0]) * f;
|
||||
const z = poly[i][1] + (poly[i + 1][1] - poly[i][1]) * f;
|
||||
let dx = poly[i + 1][0] - poly[i][0], dz = poly[i + 1][1] - poly[i][1];
|
||||
const L = Math.hypot(dx, dz) || 1;
|
||||
return { x, z, dx: dx / L, dz: dz / L };
|
||||
};
|
||||
const out = [];
|
||||
for (const dir of [1, -1]) {
|
||||
for (let k = 0; k <= Math.floor(total / cfg.stepm); k++) {
|
||||
const s = dir > 0 ? k * cfg.stepm : total - k * cfg.stepm;
|
||||
const q = posAt(s);
|
||||
const yaw = cfg.fixedYaw === null ? Math.atan2(-q.dx * dir, -q.dz * dir) : cfg.fixedYaw;
|
||||
const i = D.teleport(q.x, q.z, yaw);
|
||||
out.push([i.drawCalls, i.tris, i.chunks, 0, +q.x.toFixed(1), +q.z.toFixed(1), +s.toFixed(1), dir]);
|
||||
}
|
||||
if (cfg.laps < 2) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def boot(pg, host, query, seg):
|
||||
pg.goto(f'{host}/index.html?seed={SEED}&dbg=1' + (('&' + query) if query else ''))
|
||||
pg.wait_for_function('window.DBG && window.DBG.ready === true', timeout=45000)
|
||||
pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
|
||||
try:
|
||||
pg.wait_for_function('() => window.PROCITY && (!window.PROCITY.fleet || window.PROCITY.fleet.ready)',
|
||||
timeout=20000)
|
||||
except Exception:
|
||||
pass
|
||||
if seg is not None:
|
||||
pg.evaluate('(s) => window.DBG.setSegment(s)', seg)
|
||||
pg.wait_for_timeout(600) # let the gig latches / venue queues settle at this segment
|
||||
|
||||
|
||||
def summarise(label, samples, extra=None):
|
||||
if not samples:
|
||||
return {'label': label, 'n': 0}
|
||||
draws = [s[0] for s in samples]
|
||||
tris = [s[1] for s in samples]
|
||||
wi = max(range(len(samples)), key=lambda i: samples[i][0])
|
||||
ti = max(range(len(samples)), key=lambda i: samples[i][1])
|
||||
w, t = samples[wi], samples[ti]
|
||||
out = {
|
||||
'label': label, 'n': len(samples),
|
||||
'draws_worst': w[0], 'draws_worst_at': {'x': w[4], 'z': w[5], 's': w[6], 'dir': w[7],
|
||||
'live_chunks': w[2], 'pending': w[3], 'tris_here': w[1]},
|
||||
'draws_p50': int(statistics.median(draws)), 'draws_p95': sorted(draws)[int(.95 * (len(draws) - 1))],
|
||||
'draws_mean': round(statistics.mean(draws), 1),
|
||||
'tris_worst': t[1], 'tris_worst_at': {'x': t[4], 'z': t[5], 's': t[6], 'dir': t[7],
|
||||
'live_chunks': t[2], 'draws_here': t[0]},
|
||||
'tris_p50': int(statistics.median(tris)), 'tris_p95': sorted(tris)[int(.95 * (len(tris) - 1))],
|
||||
'live_chunks_max': max(s[2] for s in samples),
|
||||
'live_chunks_at_worst_draw': w[2],
|
||||
}
|
||||
if extra:
|
||||
out.update(extra)
|
||||
return out
|
||||
|
||||
|
||||
def run_one(p, host, town, bootflag, method, seg, stepm, laps, maxm, settle, maxframes, keep,
|
||||
holdframes=0, dwell_at=None, dwell_frames=200, yaws=12, extra='',
|
||||
fixed_yaw=None, vw=1280, vh=720):
|
||||
q = []
|
||||
if town != 'synthetic':
|
||||
q.append(f'plansrc=osm&town={town}')
|
||||
if bootflag == 'classic':
|
||||
q.append('classic=1')
|
||||
if extra:
|
||||
q.append(extra)
|
||||
query = '&'.join(x for x in q if x)
|
||||
b = p.chromium.launch()
|
||||
pg = b.new_page(viewport={'width': vw, 'height': vh})
|
||||
errs = []
|
||||
pg.on('console', lambda m: errs.append(m.text) if m.type == 'error' else None)
|
||||
pg.on('pageerror', lambda e: errs.append(str(e)))
|
||||
try:
|
||||
boot(pg, host, query, seg)
|
||||
meta = pg.evaluate("""() => ({ name: window.PROCITY.plan.name,
|
||||
shops: (window.PROCITY.plan.shops||[]).length,
|
||||
edges: (window.PROCITY.plan.streets.edges||[]).length,
|
||||
indexChunks: null })""")
|
||||
path = pg.evaluate(SPINE_JS, maxm)
|
||||
if not path:
|
||||
return {'label': f'{town}/{bootflag}/{method}', 'error': 'no main edges'}
|
||||
if method == 'stepwalk':
|
||||
raw = pg.evaluate(STEPWALK_JS, {'path': path, 'stepm': stepm, 'laps': laps,
|
||||
'fixedYaw': fixed_yaw})
|
||||
extra = {}
|
||||
else:
|
||||
if method == 'dwell':
|
||||
x, z, yaw = dwell_at
|
||||
pg.evaluate(DWELL_JS, {'x': x, 'z': z, 'yaw': yaw, 'frames': dwell_frames})
|
||||
elif method == 'sweep':
|
||||
pg.evaluate(SWEEP_JS, {'path': path, 'stationm': stepm, 'yaws': yaws,
|
||||
'settle': 8, 'yawSettle': 2})
|
||||
else:
|
||||
pg.evaluate(WALK_JS, {'path': path, 'stepm': stepm, 'laps': laps, 'settle': settle,
|
||||
'maxHold': 240, 'maxFrames': maxframes,
|
||||
'holdFrames': holdframes})
|
||||
pg.wait_for_function('() => window.__BW && window.__BW.done === true',
|
||||
timeout=max(120000, maxframes * 120))
|
||||
got = pg.evaluate("""() => { const W = window.__BW; return { samples: W.samples,
|
||||
frames: W.frames, holds: W.holds, drains: W.drains,
|
||||
secs: (performance.now()-W.t0)/1000 }; }""")
|
||||
raw = got['samples']
|
||||
dr = [d for d in got['drains'] if d > 0]
|
||||
extra = {'frames': got['frames'], 'held_frames': got['holds'],
|
||||
'wall_s': round(got['secs'], 1),
|
||||
'fps': round(got['frames'] / max(got['secs'], .001), 1),
|
||||
'chunk_crossings': len(dr),
|
||||
'frames_per_chunk_median': int(statistics.median(dr)) if dr else None}
|
||||
s = summarise(f'{town}/{bootflag}/{method}/seg{seg}', raw, extra)
|
||||
s.update({'town': town, 'boot': bootflag, 'method': method, 'seg': seg, 'stepm': stepm,
|
||||
'extra': extra, 'viewport': f'{vw}x{vh}', 'fixed_yaw': fixed_yaw,
|
||||
'plan': meta, 'path_m': round(path['total'], 1),
|
||||
'path_windowed': path['windowed'], 'shops_fronted': path['shopsFronted'],
|
||||
'console_errors': len(errs)})
|
||||
if keep:
|
||||
s['samples'] = raw
|
||||
return s
|
||||
finally:
|
||||
b.close()
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('--root', default=str(ROOT / 'web'), help='web root to serve (pin a tree here)')
|
||||
ap.add_argument('--port', type=int, default=int(os.environ.get('PROCITY_QA_PORT', '8951')))
|
||||
ap.add_argument('--town', default='synthetic')
|
||||
ap.add_argument('--boot', default='default', choices=['default', 'classic'])
|
||||
ap.add_argument('--method', default='walk', choices=['walk', 'stepwalk', 'dwell', 'sweep'])
|
||||
ap.add_argument('--yaws', type=int, default=12, help='bearings sampled per station (--method sweep)')
|
||||
ap.add_argument('--extra', default='', help='extra query string appended to every boot (e.g. r=3&shadows=1)')
|
||||
ap.add_argument('--fixed-yaw', type=float, default=None,
|
||||
help='stepwalk: hold this yaw at every station instead of facing travel (0 = DBG.teleport default)')
|
||||
ap.add_argument('--viewport', default='1280x720')
|
||||
ap.add_argument('--holdframes', type=int, default=0,
|
||||
help='extra frames held at each position (5 ≈ a real 4.6 m/s walk at --stepm 1)')
|
||||
ap.add_argument('--dwell-at', default=None, help='x,z,yaw for --method dwell')
|
||||
ap.add_argument('--dwell-frames', type=int, default=200)
|
||||
ap.add_argument('--seg', type=int, default=2)
|
||||
ap.add_argument('--stepm', type=float, default=0.5, help='metres advanced per FRAME (walk) or per STEP (stepwalk)')
|
||||
ap.add_argument('--laps', type=int, default=1, help='1 = out and back')
|
||||
ap.add_argument('--maxm', type=float, default=800, help='window the spine to this many metres (0 = whole chain)')
|
||||
ap.add_argument('--no-settle', action='store_true', help='do NOT hold while chunks.pending>0 (walker outruns the streamer)')
|
||||
ap.add_argument('--maxframes', type=int, default=20000)
|
||||
ap.add_argument('--out', default=None)
|
||||
ap.add_argument('--keep-samples', action='store_true')
|
||||
ap.add_argument('--matrix', default=None,
|
||||
help='comma list of town:boot:method:seg cells, e.g. synthetic:default:walk:2,...')
|
||||
a = ap.parse_args()
|
||||
|
||||
srv = serve(pathlib.Path(a.root).resolve(), a.port)
|
||||
host = f'http://127.0.0.1:{a.port}'
|
||||
cells = []
|
||||
if a.matrix:
|
||||
for c in a.matrix.split(','):
|
||||
parts = c.split(':')
|
||||
cells.append((parts[0], parts[1], parts[2], int(parts[3])))
|
||||
else:
|
||||
cells = [(a.town, a.boot, a.method, a.seg)]
|
||||
|
||||
results = []
|
||||
with sync_playwright() as p:
|
||||
for (town, bootflag, method, seg) in cells:
|
||||
t0 = time.time()
|
||||
print(f'▶ {town} / {bootflag} / {method} / seg{seg} …', flush=True)
|
||||
try:
|
||||
dw = tuple(float(v) for v in a.dwell_at.split(',')) if a.dwell_at else None
|
||||
r = run_one(p, host, town, bootflag, method, seg, a.stepm, a.laps, a.maxm,
|
||||
not a.no_settle, a.maxframes, a.keep_samples,
|
||||
holdframes=a.holdframes, dwell_at=dw, dwell_frames=a.dwell_frames,
|
||||
yaws=a.yaws, extra=a.extra, fixed_yaw=a.fixed_yaw,
|
||||
vw=int(a.viewport.split('x')[0]), vh=int(a.viewport.split('x')[1]))
|
||||
except Exception as e:
|
||||
r = {'label': f'{town}/{bootflag}/{method}/seg{seg}', 'error': str(e)[:300]}
|
||||
r['wall_total_s'] = round(time.time() - t0, 1)
|
||||
results.append(r)
|
||||
if 'error' in r:
|
||||
print(f" ✗ {r['error']}", flush=True)
|
||||
else:
|
||||
w = r['draws_worst_at']
|
||||
print(f" worst {r['draws_worst']} draws / {r['tris_worst']:,} tris · "
|
||||
f"p50 {r['draws_p50']}d · n={r['n']} · live chunks max {r['live_chunks_max']} "
|
||||
f"(at worst draw: {w['live_chunks']}) · {r.get('fps','-')}fps "
|
||||
f"[{r['wall_total_s']}s]", flush=True)
|
||||
srv.shutdown()
|
||||
blob = json.dumps(results, indent=1)
|
||||
if a.out:
|
||||
pathlib.Path(a.out).write_text(blob)
|
||||
print(f'\nwrote {a.out}')
|
||||
else:
|
||||
print(blob)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Loading…
Reference in New Issue
Block a user