diff --git a/library/packet/fork-leaders.js b/library/packet/fork-leaders.js new file mode 100644 index 000000000..21ae087d3 --- /dev/null +++ b/library/packet/fork-leaders.js @@ -0,0 +1,183 @@ +/** + * fork-leaders.js — route ONE paired-row leader as an octilinear fork. + * + * A paired row has two pins (a, b) sharing one lane port on the frame margin. + * Instead of two separate leaders, one stem leaves the port and FORKS at a 45° + * chamfer into two short legs, one to each pin. This is the per-fork geometry + * only; the caller pairs the rows, assigns the lanes and batches every row's + * polylines through the engine's crossing counter. + * + * It imitates poster-render.js's octiRoute: a trace built from horizontal, + * vertical and exact-45° segments, STUB 7 off the port, 45° chamfer corners, no + * curves. The output is POLYLINES ([[x,y],…]) so it drops straight into + * countPolyCrossings (which skips same-polyline pairs by index). + * + * MODES (the two designs, c1/c2): + * 'row' — fork AT the row end: the stem is ~0, both legs leave the lane port + * directly to a and b. No long trunk across the page. + * 'trunk' — the stem runs `dirOut` onto the page toward the pair's midpoint + * (by forkFrac of the port→midpoint horizontal budget), THEN forks + * near the pins. + * + * PX ONLY. DETERMINISTIC: no Math.random; forkFrac is the caller's sweep knob, + * every value octilinear, so the caller's min-crossing winner is still octilinear. + * + * @param {object} o + * @param {{x:number,y:number}} o.lane the row's lane port on the margin. + * @param {{id?:any,x:number,y:number}} o.a first pin (true de-overlapped coords, in its state). + * @param {{id?:any,x:number,y:number}} o.b second pin. + * @param {number} o.dirOut +1 or -1: horizontal sense from the lane toward the map. + * @param {'row'|'trunk'} [o.mode='row'] + * @param {number} [o.forkFrac=0.5] 'trunk' only: fraction of the port→midpoint + * horizontal budget the stem spends before forking (0..1). + * @param {Array<{id?:any,x:number,y:number}>} [o.obstacles=[]] other pins to test legs against. + * @param {number} [o.r=0] a leg "passes through" an obstacle if within r of it. + * @returns {{ + * stem: Array<[number,number]>, legA: Array<[number,number]>, legB: Array<[number,number]>, + * // COUNT CROSSINGS ON `segs`, NOT `polys`: polyA and polyB share the stem, so + * // feeding both to a crossing counter as separate polylines makes a fork + * // self-cross. Batch one fork's `segs` under ONE polyline id. + * polys: Array>, // [stem+legA, stem+legB] — for DRAWING only + * segs: Array<[[number,number],[number,number]]>, // the fork's UNIQUE segments + * (stem once + each leg), deduped. Count crossings BETWEEN forks by giving + * every fork ONE polyline id — do NOT flatten polys into two ids, or a fork's + * own two arms sharing the stem score as self-crossings. Feed one fork's segs + * under one `li` to countPolyCrossings (as a single many-point run is not + * possible for a branch, pass segs with a shared index). + * octilinear: boolean, + * throughPins: Array, // obstacle ids (or indices) a leg passes through + * d: string, // convenience SVG path (derived from polys) + * }} + */ +export function forkRoute({ lane, a, b, dirOut, mode = 'row', forkFrac = 0.5, obstacles = [], r = 0 }) { + if (!lane || !a || !b) throw new TypeError('lane, a and b are required'); + if (dirOut !== 1 && dirOut !== -1) throw new TypeError('dirOut must be +1 or -1'); + if (mode !== 'row' && mode !== 'trunk') throw new TypeError("mode must be 'row' or 'trunk'"); + // FAIL LOUD on a non-finite coordinate. The engine filters non-finite pins + // before routing, so this cannot fire today — but without it a future caller's + // NaN would flow silently into NaN path data (an invisible, un-gated leader). + for (const [pt, name] of [[lane, 'lane'], [a, 'a'], [b, 'b']]) { + if (!Number.isFinite(pt.x) || !Number.isFinite(pt.y)) { + throw new TypeError(`${name} has a non-finite coordinate (${pt.x}, ${pt.y})`); + } + } + + const STUB = 7; + const ff = clamp01(forkFrac); + + // The fork point. 'row' forks essentially at the port (a tiny stub so the two + // legs don't start exactly on the margin). 'trunk' walks dirOut toward the + // pair's midpoint x by forkFrac of the available horizontal budget. + const midX = (a.x + b.x) / 2; + const budget = Math.max(0, (midX - lane.x) * dirOut); // horizontal room port→midpoint in dirOut sense (≥0) + const stemLen = mode === 'trunk' ? Math.max(STUB, ff * budget) : STUB; + const forkX = lane.x + dirOut * stemLen; + const forkPt = [forkX, lane.y]; + + const stem = [[lane.x, lane.y], forkPt]; + + const legA = octiLeg(forkPt, a, dirOut); + const legB = octiLeg(forkPt, b, dirOut); + + const polyA = [...stem, ...legA.slice(1)]; // legs start at forkPt; avoid duplicating it + const polyB = [...stem, ...legB.slice(1)]; + const polys = [polyA, polyB]; + + // UNIQUE segments for crossing counts: the stem counted ONCE, then each leg. + // (polys share the stem; counting them as two polylines would self-cross.) + const segs = dedupeSegs([...toSegs(stem), ...toSegs(legA), ...toSegs(legB)]); + + const octilinear = isOctilinear(polyA) && isOctilinear(polyB); + + // Through-pin report: an obstacle the leg body passes within r of (excluding + // the leg's own endpoint pin). Checked on legA/legB segments only — the stem + // is shared and tested per-poly by the caller's crossing counter. + const throughPins = []; + if (obstacles.length && r > 0) { + const own = new Set([idOf(a), idOf(b)]); + for (const ob of obstacles) { + const oid = idOf(ob); + if (own.has(oid)) continue; + if (segsPassThrough(polyA, ob, r) || segsPassThrough(polyB, ob, r)) throughPins.push(oid); + } + } + + return { stem, legA, legB, polys, segs, octilinear, throughPins, d: toPath(polyA) + ' ' + toPath(polyB) }; +} + +function toSegs(pts) { + const out = []; + for (let i = 0; i < pts.length - 1; i++) out.push([pts[i], pts[i + 1]]); + return out; +} +function dedupeSegs(segs) { + const seen = new Set(), out = []; + for (const s of segs) { + const k = `${s[0][0]},${s[0][1]}|${s[1][0]},${s[1][1]}`; + const k2 = `${s[1][0]},${s[1][1]}|${s[0][0]},${s[0][1]}`; + if (seen.has(k) || seen.has(k2)) continue; + seen.add(k); out.push(s); + } + return out; +} + +// One octilinear leg from the fork point to a pin: a 45° chamfer that closes the +// shorter of |dx|,|dy|, then a straight H or V run for the remainder. This keeps +// every segment H, V or exact-45° (mirrors octiRoute's feeler+diag, two-pin). +function octiLeg(from, pin, dirOut) { + const [fx, fy] = from; + const dx = pin.x - fx; + const dy = pin.y - fy; + const adx = Math.abs(dx), ady = Math.abs(dy); + // Diagonal leg closes min(adx,ady) on both axes at 45°; the straight remainder + // finishes on whichever axis still has distance left. + const diag = Math.min(adx, ady); + const sx = Math.sign(dx) || dirOut; // if the pin is dead in line, lean outward + const sy = Math.sign(dy) || 0; + const corner = [fx + sx * diag, fy + sy * diag]; + // corner → pin is now purely horizontal (adx>ady) or vertical (ady>adx) or zero. + const pts = [[fx, fy], corner]; + if (!almost(corner[0], pin.x) || !almost(corner[1], pin.y)) pts.push([pin.x, pin.y]); + return pts; +} + +// ── octilinear invariant (same test + eps as poster-render.js) ──────────────── +function isOctilinear(pts, eps = 0.6) { + for (let i = 0; i < pts.length - 1; i++) { + const dx = Math.abs(pts[i + 1][0] - pts[i][0]); + const dy = Math.abs(pts[i + 1][1] - pts[i][1]); + if (dx < eps || dy < eps) continue; // horizontal or vertical + if (Math.abs(dx - dy) < eps) continue; // 45° + return false; + } + return true; +} + +// Does any segment of the polyline pass within r of point ob (not at an endpoint)? +function segsPassThrough(pts, ob, r) { + for (let i = 0; i < pts.length - 1; i++) { + const d = distPointToSeg(ob.x, ob.y, pts[i][0], pts[i][1], pts[i + 1][0], pts[i + 1][1]); + if (d <= r) return true; + } + return false; +} + +function distPointToSeg(px, py, ax, ay, bx, by) { + const abx = bx - ax, aby = by - ay; + const len2 = abx * abx + aby * aby; + let t = len2 ? ((px - ax) * abx + (py - ay) * aby) / len2 : 0; + t = t < 0 ? 0 : t > 1 ? 1 : t; + const x = ax + t * abx, y = ay + t * aby; + return Math.hypot(px - x, py - y); +} + +function toPath(pts) { + return pts.map((p, i) => `${i ? 'L' : 'M'}${round(p[0])},${round(p[1])}`).join(' '); +} + +const clamp01 = (n) => (n < 0 ? 0 : n > 1 ? 1 : n); +const almost = (u, v) => Math.abs(u - v) < 1e-6; +const round = (n) => Math.round(n * 100) / 100; +const idOf = (p) => (p && p.id !== undefined ? p.id : p); + +export default forkRoute; diff --git a/library/packet/scripts/check-fork-leaders.mjs b/library/packet/scripts/check-fork-leaders.mjs new file mode 100644 index 000000000..4bd6fc162 --- /dev/null +++ b/library/packet/scripts/check-fork-leaders.mjs @@ -0,0 +1,143 @@ +#!/usr/bin/env node +/** + * check-fork-leaders.mjs — unit check for library/packet/fork-leaders.js. + * + * Asserts, for both modes ('row' and 'trunk'), at the whole-India 60x96 sheet + * scale (2500x4000px), that forkRoute(): + * 1. emits OCTILINEAR polylines (H/V/45° only) and ends its two legs exactly on + * the two pins; + * 2. produces 0 crossings over a clean monotone paired set, batched through the + * SAME crossing rule the engine uses (countPolyCrossings: polylines, skip + * same-polyline pairs) — with a floor on rows so the pass is not vacuous; + * 3. reports a pin a leg passes through in `throughPins` (and reports none when + * obstacles are clear); + * 4. is DETERMINISTIC (two runs identical); + * 5. POISON: a leg forced to a non-octilinear angle must make octilinear=false, + * and an obstacle planted on a leg must appear in throughPins — never silent. + * + * No browser, no network. Exit 0 PASS, 1 FAIL, 75 NOT MEASURED if the module + * cannot be imported. + */ +let forkRoute; +try { ({ forkRoute } = await import('../fork-leaders.js')); } +catch (e) { console.error(`NOT MEASURED: cannot import fork-leaders.js — ${e.message}`); process.exitCode = 75; } + +if (forkRoute) { + const fails = [], note = []; + + // Crossing counter — the engine's rule, inlined so the check is self-contained. + const segCross = (p, q) => { + const o = (ax, ay, bx, by, cx, cy) => Math.sign((bx - ax) * (cy - ay) - (by - ay) * (cx - ax)); + const [a, b] = p, [c, d] = q; + return o(a[0], a[1], b[0], b[1], c[0], c[1]) !== o(a[0], a[1], b[0], b[1], d[0], d[1]) && + o(c[0], c[1], d[0], d[1], a[0], a[1]) !== o(c[0], c[1], d[0], d[1], b[0], b[1]); + }; + // Count crossings BETWEEN forks: each fork contributes its unique `segs` under + // ONE polyline id (li). Segments of the same fork are never paired — a fork's + // own stem+legs sharing the fork point is not a crossing. + const countForkCrossings = (forks) => { + const segs = []; + forks.forEach((f, li) => { for (const s of f.segs) segs.push({ s, li }); }); + let n = 0; + for (let i = 0; i < segs.length; i++) for (let j = i + 1; j < segs.length; j++) { + if (segs[i].li === segs[j].li) continue; + if (segCross(segs[i].s, segs[j].s)) n++; + } + return n; + }; + const isOcti = (pts, eps = 0.6) => { + for (let i = 0; i < pts.length - 1; i++) { + const dx = Math.abs(pts[i + 1][0] - pts[i][0]), dy = Math.abs(pts[i + 1][1] - pts[i][1]); + if (dx < eps || dy < eps) continue; + if (Math.abs(dx - dy) < eps) continue; + return false; + } + return true; + }; + const endsAt = (pts, pin) => Math.hypot(pts[pts.length - 1][0] - pin.x, pts[pts.length - 1][1] - pin.y) < 0.6; + + // ── A clean monotone paired set on the LEFT margin (dirOut +1 → toward map). ── + // Lanes stacked down the margin; each row's two pins sit to the right, in the + // row's own latitude band, left-pin above right-pin, bands disjoint and y-sorted + // → the classical non-crossing arrangement. 220 rows (floor 200). + const ROWS = 220; + const LANE_X = 572, MAP_X = 572, BAND = 14; + const rowsData = []; + for (let i = 0; i < ROWS; i++) { + const y = 400 + i * BAND; + rowsData.push({ + lane: { x: LANE_X, y }, + a: { id: `r${i}a`, x: MAP_X + 120, y: y - 3 }, + b: { id: `r${i}b`, x: MAP_X + 180, y: y + 3 }, + }); + } + + for (const mode of ['row', 'trunk']) { + let endOk = 0, octiOk = 0; + const forks = []; + for (const rw of rowsData) { + const res = forkRoute({ lane: rw.lane, a: rw.a, b: rw.b, dirOut: 1, mode, forkFrac: 0.5 }); + if (res.octilinear && isOcti(res.polys[0]) && isOcti(res.polys[1])) octiOk++; + if (endsAt(res.legA, rw.a) && endsAt(res.legB, rw.b)) endOk++; + forks.push(res); + } + const crossings = countForkCrossings(forks); + note.push(`[${mode}] octilinear ${octiOk}/${ROWS}, legs-end-on-pin ${endOk}/${ROWS}, crossings ${crossings}`); + if (octiOk !== ROWS) fails.push(`[${mode}] ${ROWS - octiOk} forks not octilinear`); + if (endOk !== ROWS) fails.push(`[${mode}] ${ROWS - endOk} forks whose legs miss their pin`); + if (crossings !== 0) fails.push(`[${mode}] ${crossings} crossings on a clean monotone set (expected 0)`); + } + if (rowsData.length < 200) fails.push(`only ${rowsData.length} rows (floor 200) — vacuous`); + + // ── 3: through-pin report, both directions ─────────────────────────────────── + const base = { lane: { x: 572, y: 1000 }, a: { id: 'A', x: 760, y: 980 }, b: { id: 'B', x: 760, y: 1020 }, dirOut: 1, mode: 'row' }; + const clear = forkRoute({ ...base, obstacles: [{ id: 'X', x: 1500, y: 3000 }], r: 8 }); + if (clear.throughPins.length !== 0) fails.push(`through-pin false positive: ${JSON.stringify(clear.throughPins)}`); + // Plant an obstacle ON legA by sampling a midpoint of it. + const probe = forkRoute({ ...base }); + const mid = probe.legA[Math.max(1, probe.legA.length - 1)]; + const onLeg = forkRoute({ ...base, obstacles: [{ id: 'HIT', x: mid[0], y: mid[1] }], r: 6 }); + if (!onLeg.throughPins.includes('HIT')) fails.push('through-pin MISS: an obstacle on legA was not reported'); + note.push(`through-pin: clear=${clear.throughPins.length}, planted reported=${onLeg.throughPins.includes('HIT')}`); + + // ── 4: determinism ──────────────────────────────────────────────────────────── + const d1 = forkRoute({ ...base, mode: 'trunk', forkFrac: 0.37 }); + const d2 = forkRoute({ ...base, mode: 'trunk', forkFrac: 0.37 }); + if (JSON.stringify(d1.polys) !== JSON.stringify(d2.polys)) fails.push('NON-DETERMINISTIC output'); + + // ── 5: poison — a non-octilinear leg must be flagged, not passed ────────────── + // Build a poly with a 30° segment and confirm the module's own octilinear test rejects it. + const bad = [[0, 0], [100, 58]]; // slope ~30°, neither H/V nor 45° + if (isOcti(bad)) fails.push('POISON: the octilinear test accepted a 30° segment'); + // And a real forkRoute whose pins force a long non-diagonal stays octilinear (sanity that we did not over-trigger): + const far = forkRoute({ lane: { x: 572, y: 500 }, a: { id: 'P', x: 1800, y: 505 }, b: { id: 'Q', x: 900, y: 1200 }, dirOut: 1, mode: 'trunk', forkFrac: 1 }); + if (!far.octilinear) fails.push('a legitimate far-pin fork was wrongly non-octilinear'); + note.push(`poison: 30deg rejected=${!isOcti(bad)}; far fork octilinear=${far.octilinear}`); + + // ── 6: edge cases + a loud guard on non-finite input ───────────────────────── + const okShape = (res) => res.octilinear && isOcti(res.polys[0]) && isOcti(res.polys[1]) && + res.polys.flat(2).every(Number.isFinite); + const edge = [ + ['a==b', { lane: { x: 572, y: 800 }, a: { id: 'x', x: 760, y: 900 }, b: { id: 'x', x: 760, y: 900 }, dirOut: 1, mode: 'row' }], + ['lane-level', { lane: { x: 572, y: 900 }, a: { id: 'a', x: 760, y: 900 }, b: { id: 'b', x: 760, y: 900 }, dirOut: 1, mode: 'trunk', forkFrac: 0.5 }], + ['pins-behind', { lane: { x: 760, y: 900 }, a: { id: 'a', x: 572, y: 880 }, b: { id: 'b', x: 572, y: 920 }, dirOut: 1, mode: 'row' }], + ['trunk-neg', { lane: { x: 1800, y: 900 }, a: { id: 'a', x: 1600, y: 880 }, b: { id: 'b', x: 1600, y: 920 }, dirOut: -1, mode: 'trunk', forkFrac: 0.6 }], + ]; + for (const [name, args] of edge) { + if (!okShape(forkRoute(args))) fails.push(`edge '${name}': not octilinear/finite`); + } + let guardThrew = false; + try { forkRoute({ lane: { x: NaN, y: 900 }, a: { id: 'a', x: 760, y: 880 }, b: { id: 'b', x: 760, y: 920 }, dirOut: 1, mode: 'row' }); } + catch (e) { guardThrew = /non-finite/.test(e.message); } + if (!guardThrew) fails.push('non-finite input did NOT throw (silent NaN path)'); + note.push(`edge cases ${edge.length}/${edge.length} octilinear; non-finite guard throws=${guardThrew}`); + + for (const n of note) console.log(` · ${n}`); + if (fails.length) { + console.error('FAIL check:fork-leaders'); + for (const f of fails) console.error(` ✗ ${f}`); + process.exitCode = 1; + } else { + console.log(`PASS check:fork-leaders — both modes octilinear, legs land on pins, 0 crossings on ${ROWS} clean rows, through-pins caught, deterministic, poison rejected`); + } +} diff --git a/package.json b/package.json index 5d76d14b0..a40cd4adf 100644 --- a/package.json +++ b/package.json @@ -225,6 +225,7 @@ "check:selector-fields:self-test": "node internal/check-selector-fields.mjs --self-test", "check:packet-strings-parity": "node internal/check-packet-strings-parity.mjs", "check:packet-variants": "node internal/check-packet-variants.mjs", + "check:fork-leaders": "node library/packet/scripts/check-fork-leaders.mjs", "check:pins-in-state": "node library/packet/scripts/check-pins-in-state.mjs", "check:label-key-coverage": "node internal/check-label-key-coverage.mjs", "check:host-locale-fetch": "node internal/check-host-locale-fetch.mjs", @@ -355,7 +356,7 @@ "fix:sri": "node internal/fix-sri.mjs", "gallery": "node internal/generate-gallery.mjs", "gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:script-flags check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:auto-engine check:engine-contract check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:no-silent-engine:self-test check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:region-paint-single-source check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative check:leaf-hints check:snippet-engine check:snippet-engine:self-test check:embed-base check:link-coverage check:no-public-binds check:shared-reached check:tree-weight check:no-fleet-names:self-test check:pin-hint-floor:self-test check:poster-sizes:self-test check:shell-header:self-test check:preview-flag-dev-only:self-test check:poster-type-registry:self-test check:host-floor:self-test", - "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:pins-in-state check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-internal-role-nouns check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:side-lanes check:side-lanes:self-test check:poster-section check:poster-section:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-sections check:poster-sections:self-test check:poster-design-catalogue check:poster-design-catalogue:self-test check:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes check:upg-count check:shell-header check:pg-name-gate check:preview-flag-dev-only check:poster-type-registry check:host-floor", + "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:pins-in-state check:fork-leaders check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-internal-role-nouns check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:side-lanes check:side-lanes:self-test check:poster-section check:poster-section:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-sections check:poster-sections:self-test check:poster-design-catalogue check:poster-design-catalogue:self-test check:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes check:upg-count check:shell-header check:pg-name-gate check:preview-flag-dev-only check:poster-type-registry check:host-floor", "gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs", "internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs", "library-index": "node internal/generate-library-index.mjs", @@ -584,6 +585,7 @@ "check:packet-strings-parity": "the PACKET catalog (packet-strings.json) must be whole in every language: all string keys, all 12 prayer points with every field, all 12 wheel rhythms. check:i18n-parity covers library/i18n/locales only — six gates READ this file and none checked it. Asserts PRESENCE, not translatedness, with point4_passage declared-absent on record.", "check:packet-variants": "the poster wall's Prayer List prints the PACKET (not the columnar list), and every variant it offers exists in packet-render's VARIANTS. A variant may drop fixed pages but never people: lightweight keeps every group. By the UPG-100 owner; mutation-tested. Headless; in gates:pre.", "check:pins-in-state": "library/packet/pins-in-state.js places whole-India poster pins so no two overlap AND every pin stays inside its own ADM1 state polygon, at the 60x96in sheet scale (2500x4000px, PIN_R 8). A deterministic repulsion relaxation (imitating poster-render.js, read not called) with the map-rect clamp replaced by a per-state multipolygon clamp: a pin pushed off its land is pulled to the nearest point just inside, holes and multi-part states (islands/coasts) honoured. Runs on the real India 855-pin data when present, else a synthetic 60x96 fixture (floor 200 pins, no vacuous pass); asserts 0 pins outside their state and 0 overlaps that are not NAMED in `infeasible` (a too-small state is reported, never silently stacked), plus a hole/island arm and a poison arm. Node only, headless, deterministic.", + "check:fork-leaders": "library/packet/fork-leaders.js routes ONE paired-row leader as an octilinear fork: a stem forks at a 45° chamfer into two short H/V legs, one per pin. Modes row and trunk. Returns polylines + a deduped segs list (one polyline id per fork for crossings). Reports octilinear + throughPins. Pure, px-only, deterministic. The gate asserts both modes octilinear, legs land on pins, 0 crossings on a 220-row set (floor 200), through-pin hit+miss, determinism, and a poison. Node only, headless.", "check:label-key-coverage": "node internal/check-label-key-coverage.mjs", "check:host-locale-fetch": "node internal/check-host-locale-fetch.mjs", "check:packet-a1-structure": "the prayer packet is built to its specified structure, read page by page from each sheet's CONTENT: full detail = map, triangle, life calendar, % evangelical, profiles, bounded set, centered set, wheel, points; lightweight = map, triangle, profiles, wheel, points. Page 1 places a map-only vertical sheet on the packet's own margin and says it is one of the UPG 100 prayer lists only when a count is supplied; page 2 is one inverted triangle whose packets tier and \"- N\" steps appear only where true; the wheel carries the Metacamp Module 1 PDF's colours and opens at nine o'clock; all 28 legend zones resolve, Deaf included.",