Two braids per segment + 3-segment open chains — sub-packs now live on separate lanes for up to 60 cells: - path.ts: braid gate braids.length < 2 (an open fork is necessarily last); continuations may STAY OPEN (0.35) when the cap affords the full middle segment + a minimal merge — the alt lane rides an ENTIRE segment boundary-to-boundary (L2 = SEGMENT_TARGET, no merge, open state re-emitted with accumulated cells). OPEN_BRAID_CAP 34 → 60: fork segment + one full open middle + merge segment is the longest legal chain, still comfortably co-live under KEEP_SEGMENTS. - world.ts: altPath concatenates EVERY lane's loop (was last-wins — a two-braid segment lost its first lane's channel proxies). - proxies.ts: chains and merge windows pair IN PATH ORDER (find-first broke the moment a cont chain and a fresh fork shared a segment — the closed braid lost both junction composites; caught by the contract suite on braid-scan-0). - Client lane carry is transitive through braidContOf (0→1→2), so a rider's fork-drawn lane survives BOTH boundaries. - Tests: chain-walking V3 contract (transitive resolution, ≤3 segments, cap law, port continuity at every hop, stay-open middles emerge); altPath containment law (concatenated lanes); pinned 3-segment chain grav-scan-15 with both-lane drive soak through the whole network. 208 green, check clean, endless goldens re-pinned.
- commit
- 39a9911f5fa1c5a6d58fb7425b8d2d76c10d420f
- parent
- 291336ca2fd71bcafcaa71b5e65417321aa5dc8c
- author
- Dami <dami.etoile@gmail.com>
| M | progress.md | 17 | +-- |
| M | src/ | 94 | +++++++++++++++----- |
| M | src/ | 25 | +++-- |
| M | src/ | 23 | +++-- |
| M | tests/ | 142 | +++++++++++++++--------------- |
| M | tests/ | 12 | +- |
| M | tests/ | 24 | +++++ |
@@ -69,16 +69,13 @@ cross composite, quantitative metrics CI).
6969 until something can hit decor (island-top pieces are off the channel).7070 - **Bumper density tuning**: after watching feedback (pins currently7171 only on wide island decks).72-- **Braids V2/V3 residual**: lane mods + wanderer (V2) and OPEN FORKS73- (V3, user directive: forks may leave a segment unresolved and resolve74- in the successor, capped at OPEN_BRAID_CAP total cells) shipped.75- Still deferred ON THE RECORD: vertical lane divergence (re-litigates76- the junction-dip law — its own milestone), braids over islands, >177- braid/segment (realize is generalized over plan.braids — one-line78- planner change + pin cycle), multi-segment open chains (the cap79- currently forces resolution at the very next boundary; raising it80- needs open-state re-emission in the continuation). Super-bounce zone81- assessed and parked (fights shock's reason to exist).72+- **Braid residual (V2/V3/M23)**: lane mods, wanderers, open forks,73+ TWO braids per segment and 3-SEGMENT CHAINS (stay-open middles riding74+ whole segments, OPEN_BRAID_CAP 60) all shipped. Still deferred ON THE75+ RECORD: vertical lane divergence (re-litigates the junction-dip law),76+ braids over islands, chains past 3 segments (needs a per-chain77+ segment budget beyond the cell cap). Super-bounce zone assessed and78+ parked (fights shock's reason to exist).8279 - **Theme biomes (M18a residual)**: winter/spooky/tropical/autumn rows8380 shipped on Holiday/Graveyard/Pirate/Survival kits. Open: biome-weight8481 and scale-range tuning after watching real streams, and the SPACE@@ -238,8 +238,13 @@ export interface OpenBraidState {
238238 }239239 240240 /** Hard cap on a fork's total main-lane span (fork → merge), cells.241- * The continuation must merge before this — bounded irresolution. */242-export const OPEN_BRAID_CAP = 34;241+ * "Up to a point": a continuation may STAY OPEN once (M23 braid242+ * networks — the alt lane rides a whole middle segment), so the longest243+ * legal chain is fork segment + one full open segment + merge segment. */244+export const OPEN_BRAID_CAP = 60;245+/** A continuation may re-emit the open state at this chance when the246+ * cap still affords the full middle segment plus a minimal merge. */247+const STAY_OPEN_CHANCE = 0.35;243248 244249 /** M21 — climate → hazard appetite (clause-weight multipliers by the245250 * zone's BASE mood). Legibility rule: each mood leans at most 2-3@@ -1136,9 +1141,10 @@ class SegmentBuilder {
11361141 * own channel bands; they are a SUB-skeleton on the Braid record, not11371142 * part of plan.cells.11381143 */1139- private hadBraid = false;11401144 private tryBraid(rng: Rng): boolean {1141- if (this.hadBraid) return false;1145+ // M23 braid networks: up to TWO braids per segment (an open fork is1146+ // necessarily the last — it runs flush to the boundary).1147+ if (this.braids.length >= 2 || this.openBraidOut) return false;11421148 // Flat approach runway: a fork at the crest of a climb receives its11431149 // marbles at near-zero speed and they park on the junction lip11441150 // (grav-scan-21's alt rider stalled against the deck edge at the top@@ -1346,7 +1352,6 @@ class SegmentBuilder {
13461352 ...(wanderer ? { wanderer: true } : {}),13471353 };13481354 }1349- this.hadBraid = true;13501355 return true;13511356 }13521357 @@ -1362,6 +1367,26 @@ class SegmentBuilder {
13621367 const w = ob.side;13631368 const [wx, wz] = DIR_STEP[w];13641369 const remaining = OPEN_BRAID_CAP - ob.cells;1370+ // M23: STAY OPEN — the alt lane rides this whole segment and the1371+ // NEXT segment merges. Only when the cap affords the full middle1372+ // segment plus a minimal merge leg downstream.1373+ if (1374+ remaining >= SEGMENT_TARGET + 6 &&1375+ rng.child("stay-open").chance(STAY_OPEN_CHANCE) &&1376+ this.planContinuation(1377+ rng.child("open-fit"),1378+ SEGMENT_TARGET,1379+ d,1380+ y0,1381+ w,1382+ [dx, dz],1383+ [wx, wz],1384+ ob,1385+ true,1386+ )1387+ ) {1388+ return true;1389+ }13651390 const minL = Math.max(6, ob.offset + 4);13661391 const maxL = Math.max(minL, Math.min(13, remaining));13671392 // Longest first — a failed probe steps the merge closer (bounded@@ -1387,19 +1412,24 @@ class SegmentBuilder {
13871412 [dx, dz]: readonly [number, number],13881413 [wx, wz]: readonly [number, number],13891414 ob: OpenBraidState,1415+ /** M23: leave the chain open at THIS boundary too (no merge). */1416+ stayOpen = false,13901417 ): boolean {13911418 const steps = this.straightSteps(L2);13921419 const probed = this.probe(steps);13931420 if (!probed) return false;13941421 const first = probed.cells[0];13951422 1396- // Offset profile home: o[0] is the carried boundary offset; wander1397- // with the return budget so o[L2−2] lands at 1 for the merge mouth.1423+ // Offset profile: o[0] is the carried boundary offset. A merging1424+ // continuation wanders home under the return budget (o[L2−2] = 11425+ // for the merge mouth); a stay-open one keeps wandering to the far1426+ // boundary.13981427 const pRng = rng.child("profile");13991428 const o = new Array<number>(L2).fill(1);14001429 o[0] = ob.offset;1401- for (let j = 1; j <= L2 - 3; j++) {1402- const maxHere = Math.min(3, 1 + (L2 - 2 - j));1430+ const wanderEnd = stayOpen ? L2 - 1 : L2 - 3;1431+ for (let j = 1; j <= wanderEnd; j++) {1432+ const maxHere = stayOpen ? 3 : Math.min(3, 1 + (L2 - 2 - j));14031433 const choices: number[] = [];14041434 for (const step of [-1, 0, 1]) {14051435 const v = o[j - 1] + step;@@ -1410,9 +1440,11 @@ class SegmentBuilder {
14101440 ? pRng.weighted(choices.map((c) => [c, c * c] as const))14111441 : pRng.pick(choices);14121442 }1413- o[L2 - 2] = 1;1414- // |Δ| ≤ 1 must hold into the forced return cell too.1415- if (L2 >= 3 && Math.abs(o[L2 - 3] - 1) > 1) return false;1443+ if (!stayOpen) {1444+ o[L2 - 2] = 1;1445+ // |Δ| ≤ 1 must hold into the forced return cell too.1446+ if (L2 >= 3 && Math.abs(o[L2 - 3] - 1) > 1) return false;1447+ }14161448 14171449 const cellAt = (j: number, off: number): readonly [number, number] => [14181450 first.x + dx * j + wx * off,@@ -1432,8 +1464,9 @@ class SegmentBuilder {
14321464 };14331465 // The boundary cell continues FORWARD at the carried offset — no14341466 // fork branch cell (the fork lives segments back).1435- for (let j = 0; j <= L2 - 2; j++) {1436- const next = j === L2 - 2 ? 1 : o[j + 1];1467+ const lastJ = stayOpen ? L2 - 1 : L2 - 2;1468+ for (let j = 0; j <= lastJ; j++) {1469+ const next = j === lastJ ? (stayOpen ? o[j] : 1) : o[j + 1];14371470 if (next === o[j]) {14381471 emit(j, o[j], d, d);14391472 } else if (next > o[j]) {@@ -1445,7 +1478,7 @@ class SegmentBuilder {
14451478 }14461479 }14471480 // Branch cell beside the merge: turn back into the branch mouth.1448- emit(L2 - 1, 1, d, opposite(w));1481+ if (!stayOpen) emit(L2 - 1, 1, d, opposite(w));14491482 14501483 for (const c of altCells) {14511484 if (!this.occ.isFree(c.x, c.z, y0, y0 + CHANNEL_CLEARANCE)) {@@ -1463,25 +1496,36 @@ class SegmentBuilder {
14631496 for (const c of altCells) {14641497 this.occ.claim(c.x, c.z, y0, y0 + CHANNEL_CLEARANCE, this.ref);14651498 }1466- this.reservations.push(1467- {1468- start: start + L2 - 1,1469- len: 1,1470- pieceIds: ["straight"],1471- reversed: false,1472- },1473- );1474- this.mods.push({ start: start + L2 - 1, len: 1, kind: "braid" });1499+ if (!stayOpen) {1500+ this.reservations.push(1501+ {1502+ start: start + L2 - 1,1503+ len: 1,1504+ pieceIds: ["straight"],1505+ reversed: false,1506+ },1507+ );1508+ this.mods.push({ start: start + L2 - 1, len: 1, kind: "braid" });1509+ }14751510 this.braids.push({14761511 start,14771512 len: L2,14781513 side: w,14791514 altCells,14801515 cont: true,1516+ ...(stayOpen ? { open: true } : {}),14811517 ...(ob.laneMods ? { laneMods: ob.laneMods } : {}),14821518 ...(ob.wanderer ? { wanderer: true } : {}),14831519 });1484- this.hadBraid = true;1520+ if (stayOpen) {1521+ this.openBraidOut = {1522+ side: w,1523+ offset: o[L2 - 1],1524+ cells: ob.cells + L2,1525+ ...(ob.laneMods ? { laneMods: ob.laneMods } : {}),1526+ ...(ob.wanderer ? { wanderer: true } : {}),1527+ };1528+ }14851529 return true;14861530 }14871531 @@ -663,18 +663,25 @@ export function channelProxies(
663663 // meeting these mouths at the branch ports. The branch side comes from664664 // sampling the fork's alt chain just past each junction (the lane sits665665 // a full track cell out — the sign is unambiguous).666- const forkSeg = path.segments.find((s) => s.alt?.braid);667- if (forkSeg?.alt) {668- const total = forkSeg.alt.length;669- const near = pointAt(forkSeg.alt.curve, Math.min(2.5, total / 2));670- const far = pointAt(forkSeg.alt.curve, Math.max(total - 2.5, total / 2));666+ // M23: a segment may carry TWO chains (a continuation resolving plus a667+ // fresh fork). Chains and merge windows pair IN PATH ORDER — each668+ // chain that merges here consumes the next braidMerge window.669+ const chainSegs = path.segments.filter((s) => s.alt?.braid);670+ const mergeSegs = path.segments.filter((s) => s.braidMerge);671+ let mi = 0;672+ for (const cs of chainSegs) {673+ const total = cs.alt!.length;674+ const near = pointAt(cs.alt!.curve, Math.min(2.5, total / 2));675+ const far = pointAt(cs.alt!.curve, Math.max(total - 2.5, total / 2));671676 // V3: a CONTINUATION chain enters at the segment boundary — its672677 // entry host is a plain straight, not a junction; cutting a fork673678 // mouth into it would open a hole in an ordinary channel wall.674- if (!forkSeg.alt.contBraid) emitBraidJunction(forkSeg, near, out);675- // Likewise an OPEN chain exits at the boundary — no merge here.676- const mergeSeg = path.segments.find((s) => s.braidMerge);677- if (mergeSeg) emitBraidJunction(mergeSeg, far, out);679+ if (!cs.alt!.contBraid) emitBraidJunction(cs, near, out);680+ // Likewise an OPEN chain exits at the boundary — no merge there.681+ if (!cs.alt!.openBraid) {682+ const ms = mergeSegs[mi++];683+ if (ms) emitBraidJunction(ms, far, out);684+ }678685 }679686 return out;680687 }@@ -603,7 +603,7 @@ export class EndlessWorld {
603603 // lane and skips the main windows underneath it (resuming at the604604 // window that starts at the merge exit port).605605 const braidPieces: { placement: Placement; host: number }[] = [];606- let braidAltLoop: LoopPath | null = null;606+ const braidAltLoops: LoopPath[] = [];607607 for (const [bi, braid] of plan.braids.entries()) {608608 const altWindows = altWindowsOf[bi];609609 if (!altWindows) break; // realized with the plan — always present@@ -700,7 +700,7 @@ export class EndlessWorld {
700700 host: forkIdx,701701 });702702 }703- braidAltLoop = altAssembled.loop;703+ braidAltLoops.push(altAssembled.loop);704704 705705 // The alt chain (V3 cases): a CLOSED braid runs fork entry port →706706 // junction pull-off → alt lane → merge pull-in → merge exit port.@@ -1003,14 +1003,19 @@ export class EndlessWorld {
10031003 // Braid alt LANE, world-scaled — the client (and drive harness)10041004 // append plain channel proxies over it (placementIndex −1: its10051005 // meshes ride in `placements` already, chapter-stamped).1006- const altPath: LoopPath | undefined = braidAltLoop1006+ // M23: EVERY braid lane's loop rides altPath (two braids per segment1007+ // concatenate — channel ribbons per window, spans disjoint).1008+ const altPath: LoopPath | undefined = braidAltLoops.length > 010071009 ? {1008- segments: braidAltLoop.segments.map((s) => ({1009- placementIndex: -1,1010- curve: s.curve.map(scaleSeg),1011- length: s.length * TRACK_SCALE,1012- })),1013- totalLength: braidAltLoop.totalLength * TRACK_SCALE,1010+ segments: braidAltLoops.flatMap((lp) =>1011+ lp.segments.map((s) => ({1012+ placementIndex: -1,1013+ curve: s.curve.map(scaleSeg),1014+ length: s.length * TRACK_SCALE,1015+ }))1016+ ),1017+ totalLength: braidAltLoops.reduce((a, lp) => a + lp.totalLength, 0) *1018+ TRACK_SCALE,10141019 }10151020 : undefined;10161021 @@ -81,8 +81,8 @@ Deno.test("braid: every braid obeys the fork/merge/lane contract", () => {
8181 for (let i = 0; i < SEGS; i++) {8282 const seg = w.get(i);8383 assert(84- seg.plan.braids.length <= 1,85- `${seed} seg ${i}: ${seg.plan.braids.length} braids (one per segment)`,84+ seg.plan.braids.length <= 2,85+ `${seed} seg ${i}: ${seg.plan.braids.length} braids (max two — M23)`,8686 );8787 for (const braid of seg.plan.braids) {8888 braids++;@@ -275,9 +275,9 @@ Deno.test("braid: every braid obeys the fork/merge/lane contract", () => {
275275 );276276 }277277 assert(seg.altPath !== undefined, `${seed} seg ${i}: altPath missing`);278- const ap0 = segPoint(seg.altPath.segments[0].curve[0], 0);279- const apLast = seg.altPath.segments[seg.altPath.segments.length - 1];280- const apEnd = segPoint(apLast.curve[apLast.curve.length - 1], 1);278+ // M23: altPath concatenates EVERY lane's loop (two braids per279+ // segment) — assert containment of THIS braid's ports among the280+ // altPath endpoints, not global span.281281 const forkPort = [282282 (fork.x + wx / 2) * TRACK_SCALE,283283 floorW,@@ -288,13 +288,17 @@ Deno.test("braid: every braid obeys the fork/merge/lane contract", () => {
288288 floorW,289289 (merge.z + wz / 2) * TRACK_SCALE,290290 ];291- for (let k = 0; k < 3; k++) {292- assert(293- Math.abs(ap0[k] - forkPort[k]) < 1e-6 &&294- Math.abs(apEnd[k] - mergePort[k]) < 1e-6,295- `${seed} seg ${i}: altPath does not span branch port → branch port`,296- );291+ const endpoints: number[][] = [];292+ for (const s of seg.altPath.segments) {293+ endpoints.push([...segPoint(s.curve[0], 0)]);294+ endpoints.push([...segPoint(s.curve[s.curve.length - 1], 1)]);297295 }296+ const hasPoint = (p: number[]): boolean =>297+ endpoints.some((e) => e.every((v, k) => Math.abs(v - p[k]) < 1e-6));298+ assert(299+ hasPoint(forkPort) && hasPoint(mergePort),300+ `${seed} seg ${i}: altPath misses this braid's branch ports`,301+ );298302 299303 // The alt rail rides the whole alt chain and SKIPS the main300304 // windows underneath the braid: no alt-rail station may sit on@@ -569,9 +573,10 @@ Deno.test("braid lanes: rail stations resolve the tag OVER the fork's centering"
569573 // V3 (user directive) — forks that resolve in LATER generation.570574 // ---------------------------------------------------------------------------571575 572-Deno.test("open forks: resolve next segment, bounded, port-continuous", () => {576+Deno.test("open forks: chains resolve within the cap, port-continuous", () => {573577 let opens = 0;574- let conts = 0;578+ let chains = 0;579+ let middles = 0;575580 for (const fam of ["grav", "jump", "wide", "helix"]) {576581 for (let n = 0; n < 30; n++) {577582 const seed = `${fam}-scan-${n}`;@@ -588,70 +593,63 @@ Deno.test("open forks: resolve next segment, bounded, port-continuous", () => {
588593 } catch {589594 break;590595 }591- const openBraid = seg.plan.braids.find((b) => b.open);592- if (!openBraid) continue;596+ const fork = seg.plan.braids.find((b) => b.open && !b.cont);597+ if (!fork) continue;593598 opens++;594- // The open segment: fork mutated, NO merge here, alt chain flagged595- // open and ending at the segment boundary.596- const lsF = seg.path.segments.find((ls) => ls.alt?.braid);597- assert(lsF?.alt?.openBraid, `${seed} seg ${i}: open chain unflagged`);598- assert(599- !seg.path.segments.some((ls) => ls.braidMerge),600- `${seed} seg ${i}: open braid must not merge in its own segment`,601- );602- // The successor resolves it: cont braid, merge mutation, lane603- // carried, total span under the cap.604- const nextSeg = w.get(i + 1);605- const cont = nextSeg.plan.braids.find((b) => b.cont);606- assert(cont, `${seed} seg ${i}: open fork never resolved`);607- conts++;608- assertEquals(609- nextSeg.braidContOf,610- i,611- `${seed} seg ${i + 1}: braidContOf must name the fork segment`,612- );613- assertEquals(cont!.side, openBraid.side, "side must carry over");614- assertEquals(615- cont!.laneMods,616- openBraid.laneMods,617- "lane mods must carry over",618- );619- assert(620- openBraid.len + cont!.len <= OPEN_BRAID_CAP,621- `${seed}: fork span ${openBraid.len}+${cont!.len} over the cap`,622- );623- assert(624- nextSeg.path.segments.some((ls) => ls.braidMerge),625- `${seed} seg ${i + 1}: continuation lost its merge junction`,626- );627- // Port continuity: the open chain's end is the cont chain's start628- // (world coords — the alt lane crosses the boundary seamlessly).629- const lsC = nextSeg.path.segments.find((ls) => ls.alt?.braid);630- assert(lsC?.alt?.contBraid, `${seed} seg ${i + 1}: cont unflagged`);631- const openEnd = segPoint(632- lsF!.alt!.curve[lsF!.alt!.curve.length - 1],633- 1,634- );635- const contStart = segPoint(lsC!.alt!.curve[0], 0);636- assert(637- Math.hypot(638- openEnd[0] - contStart[0],639- openEnd[1] - contStart[1],640- openEnd[2] - contStart[2],641- ) < 1e-6,642- `${seed}: alt lane tears at the ${i}/${i + 1} boundary ` +643- `(${openEnd} vs ${contStart})`,644- );645- // The continuation merges home: its chain ends at the merge exit646- // port on the MAIN lane (already asserted via braidMerge above);647- // and no cont exists without an open predecessor.599+ // Walk the chain: stay-open middles (cont+open) ride whole600+ // segments; the chain must END with a merging continuation601+ // within OPEN_BRAID_CAP total cells, every hop port-exact.602+ let total = fork.len;603+ let prevChain = seg.path.segments.find((ls) => ls.alt?.braid)!;604+ assert(prevChain?.alt?.openBraid, `${seed}@${i}: open chain unflagged`);605+ let j = i;606+ let done = false;607+ while (!done) {608+ j++;609+ assert(j - i <= 3, `${seed}@${i}: chain overruns 3 segments`);610+ const nextSeg = w.get(j);611+ const cont = nextSeg.plan.braids.find((b) => b.cont);612+ assert(cont, `${seed}@${i}: chain broken at seg ${j}`);613+ assertEquals(nextSeg.braidContOf, j - 1, "contOf must chain");614+ assertEquals(cont!.side, fork.side, "side must carry");615+ total += cont!.len;616+ const chainLs = nextSeg.path.segments.find((ls) => ls.alt?.braid)!;617+ assert(chainLs?.alt?.contBraid, `${seed}@${j}: cont unflagged`);618+ // Port continuity across the boundary.619+ const prevEnd = segPoint(620+ prevChain.alt!.curve[prevChain.alt!.curve.length - 1],621+ 1,622+ );623+ const nextStart = segPoint(chainLs.alt!.curve[0], 0);624+ assert(625+ Math.hypot(626+ prevEnd[0] - nextStart[0],627+ prevEnd[1] - nextStart[1],628+ prevEnd[2] - nextStart[2],629+ ) < 1e-6,630+ `${seed}: alt lane tears at the ${j - 1}/${j} boundary`,631+ );632+ prevChain = chainLs;633+ if (cont!.open) {634+ middles++;635+ assert(chainLs.alt!.openBraid, "stay-open middle unflagged");636+ } else {637+ assert(638+ nextSeg.path.segments.some((ls) => ls.braidMerge),639+ `${seed}@${j}: chain end lost its merge junction`,640+ );641+ done = true;642+ }643+ }644+ chains++;648645 assert(649- seg.plan.braids.some((b) => b.open),650- "cont without an open predecessor",646+ total <= OPEN_BRAID_CAP,647+ `${seed}@${i}: chain span ${total} over the cap`,651648 );652649 }653650 }654651 }655652 assert(opens > 3, `only ${opens} open forks across the corpus`);656- assertEquals(opens, conts, "every open fork must resolve exactly once");653+ assertEquals(opens, chains, "every open fork must resolve exactly once");654+ assert(middles > 0, "no stay-open middle segments across the corpus");657655 });@@ -35,12 +35,12 @@
3535 "03ab51d0fb014e9f",3636 "3dbde6539329e204",3737 "1521c59fb7168cbc",38- "b7d1590408562704",39- "9efbe9f40adfabf5",40- "0a14fadf3358f6d6",41- "a4b39685e1900827",42- "0e07fda1fe7507b2",43- "1163498944eda36d"38+ "91b62225d706d7e6",39+ "5d595762b11c5e81",40+ "f39ac629725bd694",41+ "1975acb821636014",42+ "82bab798791c0ab7",43+ "f28405844f326ff0"4444 ],4545 "marbles": [4646 "905f4cc52c9a1638",@@ -365,6 +365,30 @@ Deno.test("scenario open fork (grav-scan-32): both lanes cross the boundary clea
365365 }366366 });367367 368+Deno.test("scenario braid chain (grav-scan-15): 3-segment fork rides home on both lanes", async () => {369+ module_ = await factory();370+ // grav-scan-15: open fork @0, STAY-OPEN middle spanning all of @1,371+ // merge continuation @2 (M23 braid networks). The alt lane crosses TWO372+ // segment boundaries — the longest legal chain. Both lanes must drive373+ // clean through the whole network.374+ const w = new EndlessWorld("grav-scan-15", catalog);375+ assert(376+ w.get(0).plan.braids.some((b) => b.open && !b.cont) &&377+ w.get(1).plan.braids.some((b) => b.open && b.cont) &&378+ w.get(2).plan.braids.some((b) => b.cont && !b.open),379+ "grav-scan-15 lost its 3-segment chain — re-scan",380+ );381+ const goal = w.get(3).startS + 6;382+ for (const useAlt of [false, true]) {383+ const r = driveWith("grav-scan-15", 6, 360, goal, 0, useAlt);384+ assert(385+ !r.fell && r.reachedS >= goal,386+ `chain lane ${useAlt ? "alt" : "main"} failed: fell=${r.fell} ` +387+ `reached=${r.reachedS.toFixed(1)}/${goal.toFixed(1)}`,388+ );389+ }390+});391+368392 Deno.test("scenario jump-gap: cold entry launches, flies, and is caught (both kinds)", async () => {369393 module_ = await factory();370394 // One pinned case per gap kind: helix-scan-6 early segments plan a