@@ -347,11 +347,14 @@ struct GFit {
347347// Section 19 collapsed-region state: totals frozen at collapse, membership,
348348// and the splitmix64 state left after the jitter draws (the expansion spread
349349// continues drawing from it). Jitter offsets live on the world (indexed by
350- // body id).
350+ // body id). Section 20 multipole totals (mx, my and the second central
351+ // moments) freeze alongside; mp marks the cycle as section 20.
351352struct GCollapsed {
352353 bool active = false ;
354+ bool mp = false ;
353355 u64 n = 0 ;
354356 f64 mass = 0 , com_x = 0 , com_y = 0 , pxt = 0 , pyt = 0 , vcx = 0 , vcy = 0 , energy = 0 ;
357+ f64 mx = 0 , my = 0 , qxx = 0 , qxy = 0 , qyy = 0 ;
355358 u64 jitter_state = 0 ;
356359 std::vector<std::size_t > members;
357360};
@@ -454,6 +457,7 @@ struct GravityWorld {
454457 u64 seed = 0 ;
455458 u64 tick = 0 ;
456459 f64 px = 0 , py = 0 ;
460+ bool mp_enabled = true ;
457461 std::vector<GBody> bodies;
458462 std::vector<GFit> coarse;
459463 std::vector<u8 > body_region;
@@ -670,6 +674,19 @@ struct GravityWorld {
670674 c.vcx = c.pxt / c.mass ;
671675 c.vcy = c.pyt / c.mass ;
672676 }
677+ // Section 20 multipole totals: the exact dipole accumulators and the
678+ // second central moments about com (id order, left-to-right sums).
679+ c.mx = sum_mx;
680+ c.my = sum_my;
681+ for (std::size_t i : c.members ) {
682+ const GBody &b = bodies[i];
683+ const f64 dx = b.x - c.com_x ;
684+ const f64 dy = b.y - c.com_y ;
685+ c.qxx += b.mass * dx * dx;
686+ c.qxy += b.mass * dx * dy;
687+ c.qyy += b.mass * dy * dy;
688+ }
689+ c.mp = mp_enabled;
673690 SplitMix64 jitter (seed ^ (static_cast <u64 >(region) * 0x9E3779B97F4A7C15ULL ));
674691 for (std::size_t i : c.members ) {
675692 const u64 ux = jitter.draw ();
@@ -688,20 +705,91 @@ struct GravityWorld {
688705 return c;
689706 }
690707
691- // Section 19 expansion: positions from the frozen jitter, velocities
692- // v_com + spread with the last body absorbing the exact momentum
693- // residual (bit-identical per the spec).
708+ // Section 19/20 expansion: positions per mode (section 20 synthesizes
709+ // under dipole-exact + quadrupole-transform constraints; section 19 keeps
710+ // com + raw jitter), velocities v_com + spread with the last body
711+ // absorbing the exact momentum residual (bit-identical per the spec).
694712 void expand (int region) {
695713 GCollapsed &c = collapsed[region];
714+ if (c.mp ) {
715+ std::vector<std::pair<f64 , f64 >> base;
716+ base.reserve (c.members .size ());
717+ for (std::size_t i : c.members ) {
718+ base.emplace_back (body_jx[i], body_jy[i]);
719+ }
720+ if (c.members .size () >= 3 ) {
721+ f64 swx = 0.0 ;
722+ f64 swy = 0.0 ;
723+ for (std::size_t a = 0 ; a < c.members .size (); ++a) {
724+ const f64 m = bodies[c.members [a]].mass ;
725+ swx += m * base[a].first ;
726+ swy += m * base[a].second ;
727+ }
728+ const f64 wx = swx / c.mass ;
729+ const f64 wy = swy / c.mass ;
730+ std::vector<std::pair<f64 , f64 >> dhat (c.members .size ());
731+ f64 jxx = 0.0 ;
732+ f64 jxy = 0.0 ;
733+ f64 jyy = 0.0 ;
734+ for (std::size_t a = 0 ; a < c.members .size (); ++a) {
735+ const f64 m = bodies[c.members [a]].mass ;
736+ const f64 dx = base[a].first - wx;
737+ const f64 dy = base[a].second - wy;
738+ jxx += m * dx * dx;
739+ jxy += m * dx * dy;
740+ jyy += m * dy * dy;
741+ dhat[a] = {dx, dy};
742+ }
743+ if (jxx > 0.0 && c.qxx > 0.0 ) {
744+ const f64 lj00 = std::sqrt (jxx);
745+ const f64 lj10 = jxy / lj00;
746+ const f64 jjd = jyy - lj10 * lj10;
747+ if (jjd > 0.0 ) {
748+ const f64 lq00 = std::sqrt (c.qxx );
749+ const f64 lq10 = c.qxy / lq00;
750+ const f64 qqd = c.qyy - lq10 * lq10;
751+ if (qqd > 0.0 ) {
752+ const f64 lj11 = std::sqrt (jjd);
753+ const f64 lq11 = std::sqrt (qqd);
754+ const f64 u00 = 1.0 / lj00;
755+ const f64 u11 = 1.0 / lj11;
756+ const f64 u10 = -(lj10 / (lj00 * lj11));
757+ const f64 a00 = lq00 * u00;
758+ const f64 a11 = lq11 * u11;
759+ const f64 a10 = lq10 * u00 + lq11 * u10;
760+ for (std::size_t a = 0 ; a < c.members .size (); ++a) {
761+ base[a] = {a00 * dhat[a].first , a10 * dhat[a].first + a11 * dhat[a].second };
762+ }
763+ }
764+ }
765+ }
766+ }
767+ f64 sum_mx = 0.0 ;
768+ f64 sum_my = 0.0 ;
769+ for (std::size_t a = 0 ; a + 1 < c.members .size (); ++a) {
770+ GBody &b = bodies[c.members [a]];
771+ b.x = c.com_x + base[a].first ;
772+ b.y = c.com_y + base[a].second ;
773+ sum_mx += b.mass * b.x ;
774+ sum_my += b.mass * b.y ;
775+ }
776+ if (!c.members .empty ()) {
777+ const std::size_t last = c.members .back ();
778+ bodies[last].x = (c.mx - sum_mx) / bodies[last].mass ;
779+ bodies[last].y = (c.my - sum_my) / bodies[last].mass ;
780+ }
781+ }
696782 SplitMix64 jitter (c.jitter_state );
697783 f64 sum_mv_x = 0.0 ;
698784 f64 sum_mv_y = 0.0 ;
699785 f64 last_mass = 0.0 ;
700786 for (std::size_t a = 0 ; a < c.members .size (); ++a) {
701787 const std::size_t i = c.members [a];
702788 GBody &b = bodies[i];
703- b.x = c.com_x + body_jx[i];
704- b.y = c.com_y + body_jy[i];
789+ if (!c.mp ) {
790+ b.x = c.com_x + body_jx[i];
791+ b.y = c.com_y + body_jy[i];
792+ }
705793 if (a + 1 < c.members .size ()) {
706794 const u64 ux = jitter.draw ();
707795 const u64 uy = jitter.draw ();
@@ -1023,7 +1111,20 @@ static int run_gravity(const std::vector<u8> &data, u64 seed, u32 body_count) {
10231111 int region;
10241112 u8 level;
10251113 };
1114+ struct PendingCollapse {
1115+ u64 t;
1116+ int region;
1117+ u64 n;
1118+ f64 mass, com_x, com_y, pxt, pyt, energy;
1119+ };
1120+ struct PendingMultipole {
1121+ u64 t;
1122+ int region;
1123+ f64 mx, my, qxx, qxy, qyy;
1124+ };
10261125 std::vector<Pending> pending;
1126+ std::vector<PendingCollapse> pending_collapsed;
1127+ std::vector<PendingMultipole> pending_multipole;
10271128 u64 ticks_seen = 0 ;
10281129 u64 totals_seen = 0 ;
10291130 u64 states_seen = 0 ;
@@ -1043,6 +1144,12 @@ static int run_gravity(const std::vector<u8> &data, u64 seed, u32 body_count) {
10431144 std::fprintf (stderr, " error: truncated TickHeader at offset %zu\n " , rec_start);
10441145 return 2 ;
10451146 }
1147+ // Collapse mode is selected per cycle by the presence of the
1148+ // section 20 RegionMultipole record (section 19 streams carry
1149+ // none). RegionLevel records queue and apply here, at the tick
1150+ // boundary, in stream order — level 2 collapses validate against
1151+ // the world's frozen totals bit-for-bit.
1152+ world.mp_enabled = !pending_multipole.empty ();
10461153 for (const Pending &p : pending) {
10471154 if (p.level == 0 ) {
10481155 // Demote on a collapsed region is a no-op (section 19).
@@ -1051,9 +1158,46 @@ static int run_gravity(const std::vector<u8> &data, u64 seed, u32 body_count) {
10511158 }
10521159 } else if (p.level == 1 ) {
10531160 world.promote (p.region , world.tick + 1 );
1161+ } else {
1162+ if (world.rstate [p.region ] != 2 ) {
1163+ world.collapse (p.region , world.tick + 1 );
1164+ }
10541165 }
10551166 }
10561167 pending.clear ();
1168+ for (const PendingCollapse &pc : pending_collapsed) {
1169+ ++collapses_seen;
1170+ const GCollapsed &c = world.collapsed [pc.region ];
1171+ if (!c.active || pc.t != world.tick + 1 || pc.n != c.n || !f64_bits_eq (pc.mass , c.mass ) ||
1172+ !f64_bits_eq (pc.com_x , c.com_x ) || !f64_bits_eq (pc.com_y , c.com_y ) ||
1173+ !f64_bits_eq (pc.pxt , c.pxt ) || !f64_bits_eq (pc.pyt , c.pyt ) ||
1174+ !f64_bits_eq (pc.energy , c.energy )) {
1175+ std::fprintf (stderr,
1176+ " MISMATCH: RegionCollapsed tick=%" PRIu64 " region=%d"
1177+ " stream=(n=%" PRIu64 " mass=%.17g com=(%.17g,%.17g) p=(%.17g,%.17g)"
1178+ " E=%.17g) computed=(n=%" PRIu64 " mass=%.17g com=(%.17g,%.17g)"
1179+ " p=(%.17g,%.17g) E=%.17g)\n " ,
1180+ pc.t , pc.region , pc.n , pc.mass , pc.com_x , pc.com_y , pc.pxt , pc.pyt ,
1181+ pc.energy , c.n , c.mass , c.com_x , c.com_y , c.pxt , c.pyt , c.energy );
1182+ return 1 ;
1183+ }
1184+ }
1185+ pending_collapsed.clear ();
1186+ for (const PendingMultipole &pm : pending_multipole) {
1187+ const GCollapsed &c = world.collapsed [pm.region ];
1188+ if (!c.active || !c.mp || pm.t != world.tick + 1 || !f64_bits_eq (pm.mx , c.mx ) ||
1189+ !f64_bits_eq (pm.my , c.my ) || !f64_bits_eq (pm.qxx , c.qxx ) ||
1190+ !f64_bits_eq (pm.qxy , c.qxy ) || !f64_bits_eq (pm.qyy , c.qyy )) {
1191+ std::fprintf (stderr,
1192+ " MISMATCH: RegionMultipole tick=%" PRIu64 " region=%d"
1193+ " stream=(m=(%.17g,%.17g) q=(%.17g,%.17g,%.17g))"
1194+ " computed=(m=(%.17g,%.17g) q=(%.17g,%.17g,%.17g))\n " ,
1195+ pm.t , pm.region , pm.mx , pm.my , pm.qxx , pm.qxy , pm.qyy , c.mx , c.my ,
1196+ c.qxx , c.qxy , c.qyy );
1197+ return 1 ;
1198+ }
1199+ }
1200+ pending_multipole.clear ();
10571201 world.step ();
10581202 reference.step ();
10591203 if (t != world.tick ) {
@@ -1116,11 +1260,9 @@ static int run_gravity(const std::vector<u8> &data, u64 seed, u32 body_count) {
11161260 rx, ry, lv, rec_start);
11171261 return 2 ;
11181262 }
1119- // Level 2 (collapse) is applied when its RegionCollapsed record
1120- // arrives; level 0/1 queue for the next tick boundary.
1121- if (lv != 2 ) {
1122- pending.push_back ({static_cast <int >(ry) * 2 + static_cast <int >(rx), lv});
1123- }
1263+ // All levels (including 2/collapse) queue for the next tick
1264+ // boundary, where the collapse's RegionMultipole presence is known.
1265+ pending.push_back ({static_cast <int >(ry) * 2 + static_cast <int >(rx), lv});
11241266 } break ;
11251267 case 5 : {
11261268 u64 t = 0 ;
@@ -1222,8 +1364,8 @@ static int run_gravity(const std::vector<u8> &data, u64 seed, u32 body_count) {
12221364 }
12231365 } break ;
12241366 case 8 : {
1225- // Section 19 RegionCollapsed: apply the collapse at this boundary
1226- // and validate the record's frozen totals bit-for-bit .
1367+ // Section 19 RegionCollapsed: parsed here, applied and validated
1368+ // bit-for-bit at the tick boundary (see case 1) .
12271369 u64 t = 0 ;
12281370 u32 rx = 0 ;
12291371 u32 ry = 0 ;
@@ -1244,22 +1386,33 @@ static int run_gravity(const std::vector<u8> &data, u64 seed, u32 body_count) {
12441386 rx, ry, rec_start);
12451387 return 2 ;
12461388 }
1247- ++collapses_seen;
1248- const int region = static_cast <int >(ry) * 2 + static_cast <int >(rx);
1249- const GCollapsed c = world.collapse (region, world.tick + 1 );
1250- if (t != world.tick + 1 || n != c.n || !f64_bits_eq (mass, c.mass ) ||
1251- !f64_bits_eq (com_x, c.com_x ) || !f64_bits_eq (com_y, c.com_y ) ||
1252- !f64_bits_eq (pxt, c.pxt ) || !f64_bits_eq (pyt, c.pyt ) ||
1253- !f64_bits_eq (energy, c.energy )) {
1389+ pending_collapsed.push_back (
1390+ {t, static_cast <int >(ry) * 2 + static_cast <int >(rx), n, mass, com_x, com_y, pxt, pyt,
1391+ energy});
1392+ } break ;
1393+ case 9 : {
1394+ // Section 20 RegionMultipole: parsed here, validated bit-for-bit at
1395+ // the tick boundary.
1396+ u64 t = 0 ;
1397+ u32 rx = 0 ;
1398+ u32 ry = 0 ;
1399+ f64 mx = 0 , my = 0 , qxx = 0 , qxy = 0 , qyy = 0 ;
1400+ if (!take_u64 (data, off, t) || !take_u32 (data, off, rx) || !take_u32 (data, off, ry) ||
1401+ !take_f64 (data, off, mx) || !take_f64 (data, off, my) ||
1402+ !take_f64 (data, off, qxx) || !take_f64 (data, off, qxy) ||
1403+ !take_f64 (data, off, qyy)) {
1404+ std::fprintf (stderr, " error: truncated RegionMultipole at offset %zu\n " , rec_start);
1405+ return 2 ;
1406+ }
1407+ if (rx > 1 || ry > 1 ) {
12541408 std::fprintf (stderr,
1255- " MISMATCH: RegionCollapsed tick=%" PRIu64 " region=(%" PRIu32 " ,%" PRIu32
1256- " ) stream=(n=%" PRIu64 " mass=%.17g com=(%.17g,%.17g) p=(%.17g,%.17g)"
1257- " E=%.17g) computed=(n=%" PRIu64 " mass=%.17g com=(%.17g,%.17g)"
1258- " p=(%.17g,%.17g) E=%.17g)\n " ,
1259- t, rx, ry, n, mass, com_x, com_y, pxt, pyt, energy, c.n , c.mass , c.com_x ,
1260- c.com_y , c.pxt , c.pyt , c.energy );
1261- return 1 ;
1409+ " error: bad RegionMultipole region (%" PRIu32 " ,%" PRIu32
1410+ " ) at offset %zu\n " ,
1411+ rx, ry, rec_start);
1412+ return 2 ;
12621413 }
1414+ pending_multipole.push_back (
1415+ {t, static_cast <int >(ry) * 2 + static_cast <int >(rx), mx, my, qxx, qxy, qyy});
12631416 } break ;
12641417 default :
12651418 std::fprintf (stderr, " error: unknown record tag %u at offset %zu\n " , tag, rec_start);
0 commit comments