From b7a47bef959f9a133d116282b347537f4670dfe4 Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Wed, 21 Oct 2026 22:30:36 +0200 Subject: [PATCH] wip(assembler): round L33 -- SurfaceWorld can be an upper strate; FloorProfileBlend measures seams Route planning for an upper SurfaceWorld failed on long candidate chords over steep relief; chords are now capped to the arrival/departure radii on an 8 m centre grid with an origin-proximity cost (21.5 m walk, about 4 deg). The departure passage starts at the SW ground and tunnels down through rock. FI -> SW -> TN fixture: all 4 styles pass every passage audit on both compilers. FloorProfileBlend now checks endpoint height/tangent seams (step and tangent-step negative controls detected). Owner world hashes unchanged; 30/30 determinism; cost within noise. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CSFYgsdW8ejZSyiMf4qRSX --- ARCHITECTURE.md | 34 +++ CODEMAP.md | 14 ++ Standalone/Core/src/ResolvedLayoutInternal.h | 78 +++++- Standalone/Core/src/ShapeWorldInternal.h | 144 +++++++++++- Standalone/DESIGN_WORLD_ASSEMBLER.md | 6 + .../Linux33/BuildUpperSurfaceFixtureL33.py | 55 +++++ .../Linux33/CaptureUpperSurfaceL33.py | 100 ++++++++ .../Linux33/CheckOwnerHashesL33.py | 54 +++++ .../WorldAssemblerM3/Linux33/REPORT.md | 58 +++++ .../WorldAssemblerM3/Linux33/RunCostL33.py | 81 +++++++ .../Linux33/RunDeterminismL33.py | 131 +++++++++++ .../Linux33/RunInvariantAuditsL33.py | 81 +++++++ .../Linux33/RunUpperFixtureAuditsL33.py | 78 ++++++ .../Linux33/captures/sw-departure-mouth.jpg | Bin 0 -> 26939 bytes .../captures/sw-departure-rock-entry.jpg | Bin 0 -> 37572 bytes Standalone/Source/StreamBenchmarkPipeline.h | 12 +- Standalone/Source/WorldAssemblyPrototype.h | 222 +++++++++++++++++- 17 files changed, 1116 insertions(+), 32 deletions(-) create mode 100644 Standalone/Research/WorldAssemblerM3/Linux33/BuildUpperSurfaceFixtureL33.py create mode 100644 Standalone/Research/WorldAssemblerM3/Linux33/CaptureUpperSurfaceL33.py create mode 100644 Standalone/Research/WorldAssemblerM3/Linux33/CheckOwnerHashesL33.py create mode 100644 Standalone/Research/WorldAssemblerM3/Linux33/REPORT.md create mode 100644 Standalone/Research/WorldAssemblerM3/Linux33/RunCostL33.py create mode 100644 Standalone/Research/WorldAssemblerM3/Linux33/RunDeterminismL33.py create mode 100644 Standalone/Research/WorldAssemblerM3/Linux33/RunInvariantAuditsL33.py create mode 100644 Standalone/Research/WorldAssemblerM3/Linux33/RunUpperFixtureAuditsL33.py create mode 100644 Standalone/Research/WorldAssemblerM3/Linux33/captures/sw-departure-mouth.jpg create mode 100644 Standalone/Research/WorldAssemblerM3/Linux33/captures/sw-departure-rock-entry.jpg diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 04633c4..0adffd3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2076,6 +2076,40 @@ transition one micrometre wider than declared and a band one micrometre below th [`Standalone/Research/WorldAssemblerM3/Linux29/REPORT.md`](Standalone/Research/WorldAssemblerM3/Linux29/REPORT.md) for audit measurements, hashes, timings, captures and run history. +### 8.47 Ground-bound SurfaceWorld departure routes — World Assembler M3 Linux L33 + +An intra-strate SurfaceWorld route follows the resolved terrain ground profile. Its route-point Z +is the ground height plus the player's half-height; the route does not descend to the strate's +bedrock boundary. Bedrock belongs to the separate inter-strate passage, which begins at the +SurfaceWorld departure mouth on the ground and tunnels outward and down through native rock and +the boundary into the next strate. + +The old fixed candidate envelope used a 64 m chord and 16 m centre spacing. On the L33 upper-SW +fixture, replaying that envelope sampled ground from −507.767 to −464.857 m; 366/648 candidate paths +exceeded the 15° grade limit. The worst failures clustered near the outer west/northwest terrain +shoulder (up to 79.971°) and the steep approach slopes around the origin. Candidate diagnostics +separate grade failures from endpoints outside the bounded arrival/departure placement disks. +The bounded search now limits its longest chord to the two mouth radii minus a 3 m clearance +margin, checks centres every 8 m, and ranks endpoints near the route anchors. It finds a 21.5 m +route from (−2.75, 0) to (18.75, 0), sampled on ground and accepted by the full capsule/support/ +grade audit. + +The SurfaceWorld departure passage starts at the ground mouth with a 1.5 m portal, then smoothsteps +to a 3.5 m rock tunnel over 9 m. Its ring is sized and oriented outward from the mouth, so the +approach does not cut back under the walk route. The `FloatingIslands → SurfaceWorld → TunnelNetwork` +fixture passes all four passage styles, including both interfaces, capsule clearance, support, +grade, walkability, naturalness, open-air, column ends, long-bridge and route-binding audits. + +`FloorProfileBlend` previously compared each curved floor profile with its straight chord and +applied a 2-voxel limit. That is a curvature measurement, not a seam measurement. It now checks +endpoint height and tangent continuity at 0.01 voxel and 0.001 voxel/m; the 3-voxel step and +0.25 voxel/m tangent-step controls are both detected. SurfaceWorld owner hashes remain identical +to L32 because the route-mouth, expanded column sizing and short-span support changes apply only +when SurfaceWorld is the upper strate. No evaluator or recipe version changed. + +[`Standalone/Research/WorldAssemblerM3/Linux33/REPORT.md`](Standalone/Research/WorldAssemblerM3/Linux33/REPORT.md) +records the four-style audits, captures, determinism, owner hashes, costs and retries. + ## 9. Multiplayer model (listen-server first, dedicated-friendly) > **Status: transport is DESIGN ONLY — nothing is networked in-tree yet** (no `Replicated`/`HasAuthority`/RPCs; a diff --git a/CODEMAP.md b/CODEMAP.md index cc24c2d..57a98d6 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -1229,6 +1229,20 @@ The Unreal adapter now forwards the authored transition type and chunk width; th were not compiled here. See ARCHITECTURE §8.46 and [`Standalone/Research/WorldAssemblerM3/Linux29/REPORT.md`](Standalone/Research/WorldAssemblerM3/Linux29/REPORT.md). +### 3.30 SurfaceWorld as an upper strate and floor-profile joins — World Assembler L33 + +| Symbol | Canonical file | Role | +|---|---|---| +| `BuildFiniteStrateRoute` | `Standalone/Source/WorldAssemblyPrototype.h` | Samples route heights from the composed terrain ground; bounds candidate chords by the arrival/departure placement radii and records per-segment grade failures. | +| `PlanResolvedLayoutPassagesV1` | `Standalone/Core/src/ResolvedLayoutInternal.h` | Starts an upper-SurfaceWorld departure at its terrain mouth, opens a small portal and smoothsteps to the rock tunnel while orienting the ring outward. | +| `BuildPassageVoidCrossingPlan`, `PassageBridgeSignedDistance` | `Standalone/Core/src/ShapeWorldInternal.h` | Sizes unsupported ranges from the full control-segment window only for upper SurfaceWorld; other upper generators keep the previous bracketing-sample bounds. | +| `--audit-resolved-layout-passages` | `Standalone/Source/StreamBenchmarkPipeline.h` | Uses floor height rather than bore-centre height for naturalness and records failed-only wall probes for column audits. | +| `FloorProfileBlend` | `Standalone/Core/src/ShapeWorldInternal.h` | Measures endpoint height/tangent continuity; chord deviation is informational, with step and tangent-step negative controls. | + +The owner SurfaceWorld hashes match L32 and evaluator/recipe versions are unchanged. See +ARCHITECTURE §8.47 and +[`Standalone/Research/WorldAssemblerM3/Linux33/REPORT.md`](Standalone/Research/WorldAssemblerM3/Linux33/REPORT.md). + ## 4. The density pipeline (most-edited hot path) ### 4.1 `GetDensityWithParams` (TunnelNetwork) — VoxelGenerator.cpp:3749 diff --git a/Standalone/Core/src/ResolvedLayoutInternal.h b/Standalone/Core/src/ResolvedLayoutInternal.h index faa30c8..f136d08 100644 --- a/Standalone/Core/src/ResolvedLayoutInternal.h +++ b/Standalone/Core/src/ResolvedLayoutInternal.h @@ -788,10 +788,13 @@ bool PlanResolvedLayoutPassagesV1(ResolvedLayoutSnapshotV3& layout,std::int32_t cfg->distanceMaximumSubvoxels):8*qPerMetre; const std::int64_t placementRadius=std::max(1,outer); // Leave room for the capsule and bounded wall relief at the floor/wall seam. - const std::int64_t mouthRadius=std::max(static_cast(std::llround(1.5*qPerMetre)), + const bool upperIsSurfaceWorld=upper.generatorKind=="SurfaceWorld"; + const std::int64_t minimumMouthRadius=static_cast(std::llround( + (upperIsSurfaceWorld?2.0:1.5)*qPerMetre)); + const std::int64_t mouthRadius=std::max(minimumMouthRadius, cfg?cfg->mouthRadiusSubvoxels:0); const std::int64_t midRadius=std::max( - static_cast(std::llround(1.5*qPerMetre)), + static_cast(std::llround((upperIsSurfaceWorld?3.5:1.5)*qPerMetre)), cfg?cfg->midRadiusSubvoxels:mouthRadius); const std::int64_t reachMinimum=cfg?std::max(0,cfg->reachMinimumSubvoxels):8*qPerMetre; const std::int64_t reachMaximum=cfg?std::max(reachMinimum,cfg->reachMaximumSubvoxels):24*qPerMetre; @@ -813,6 +816,7 @@ bool PlanResolvedLayoutPassagesV1(ResolvedLayoutSnapshotV3& layout,std::int32_t std::array upperMouth{},lowerMouth{}; const bool hasUpperRoute=connection==0&&routeAnchor(upper,true,upperMouth); const bool hasLowerRoute=connection==0&&routeAnchor(lower,false,lowerMouth); + const bool surfaceGroundDeparture=hasUpperRoute&&upperIsSurfaceWorld; const ResolvedLayoutAnchorV2* passageAnchor=nullptr; if(connection==0) for(const auto& anchor:upper.anchors) @@ -870,6 +874,17 @@ bool PlanResolvedLayoutPassagesV1(ResolvedLayoutSnapshotV3& layout,std::int32_t std::max(0,cfg?cfg->cascadeLedgeSubvoxels:0) *static_cast(cfg?cfg->cascadeSteps:4)), mouthRadius+2*qPerMetre,outerRingRadius); + if(surfaceGroundDeparture) + { + const double departureRadius=std::hypot(static_cast(upperMouth[0]), + static_cast(upperMouth[1])); + const std::int64_t clearRingRadius=static_cast(std::ceil( + departureRadius+mouthRadius+0.5*qPerMetre)); + const std::int64_t maximumRingRadius=std::max(1, + placementRadius-qPerMetre); + ringRadius=std::min(maximumRingRadius, + std::max(ringRadius,clearRingRadius)); + } if(anchoredIslandPassage) ringRadius=std::min(ringRadius,mouthRadius+qPerMetre/2); std::uint32_t phase=static_cast(PassageSeed(seed, @@ -890,6 +905,23 @@ bool PlanResolvedLayoutPassagesV1(ResolvedLayoutSnapshotV3& layout,std::int32_t {bestAlignment=alignment;phase=candidate;} } } + if(hasUpperRoute&&upperIsSurfaceWorld) + { + const auto& directionAtPhase=directions[phase]; + const std::int64_t radialAlignment=upperMouth[0]*directionAtPhase[0] + +upperMouth[1]*directionAtPhase[1]; + if(radialAlignment<0) + { + std::int64_t bestOutwardAlignment=std::numeric_limits::min(); + for(std::uint32_t candidate=0;candidatebestOutwardAlignment) + {bestOutwardAlignment=alignment;phase=candidate;} + } + } + } bool clockwise=(PassageSeed(seed,upper.strateIndex,lower.strateIndex, connection,0x44495245u)&1u)!=0; if(anchoredSpiral&&passageAnchor&&hasUpperTangent) @@ -1159,10 +1191,23 @@ bool PlanResolvedLayoutPassagesV1(ResolvedLayoutSnapshotV3& layout,std::int32_t if(hasAnchorHelix&&helixHorizontal==0) {error=passage.stableId+" anchor spiral has no horizontal reach";return false;} passage.controlPoints.reserve(xy.size()); + const std::int64_t surfacePortalRadius=surfaceGroundDeparture + ?std::min(mouthRadius,static_cast(std::llround(1.5*qPerMetre))) + :mouthRadius; + const std::uint64_t surfaceMouthBlendLength=static_cast( + std::max(8*qPerMetre,2*mouthRadius)); for(std::size_t i=0;i=xy.size()-1)?mouthRadius:midRadius; + if(surfaceGroundDeparture&&i(cumulative[i]) + /surfaceMouthBlendLength,0.0,1.0); + const double smooth=t*t*(3.0-2.0*t); + radius=surfacePortalRadius+static_cast(std::llround( + (midRadius-surfacePortalRadius)*smooth)); + } std::int64_t playerZ=0; if(i>lowerMouthIndex) playerZ=lowerLandingPoints[i-lowerMouthIndex-1][2]; @@ -1183,9 +1228,30 @@ bool PlanResolvedLayoutPassagesV1(ResolvedLayoutSnapshotV3& layout,std::int32_t point.positionSubvoxels={xy[i][0],xy[i][1],playerZ+radius-playerCenterAboveFloorQ}; point.radiusSubvoxels=radius;passage.controlPoints.push_back(point); } - passage.controlPoints.front().positionSubvoxels[2]=upperMouth[2]+mouthRadius-playerCenterAboveFloorQ; - passage.controlPoints[lowerMouthIndex].positionSubvoxels[2]= - lowerMouth[2]+mouthRadius-playerCenterAboveFloorQ; + if(surfaceGroundDeparture) + std::printf("[ResolvedSurfaceWorldDepartureMouth] passage=%s ground_floor_z_m=%.3f portal_radius_m=%.3f tunnel_radius_m=%.3f blend_length_m=%.3f departure_xy_m=(%.3f,%.3f) first_tunnel_xy_m=(%.3f,%.3f) first_tunnel_floor_z_m=%.3f first_tunnel_radius_m=%.3f\n", + passage.stableId.c_str(),(upperMouth[2]-static_cast(std::llround(0.88*qPerMetre)))/static_cast(qPerMetre), + surfacePortalRadius/static_cast(qPerMetre), + midRadius/static_cast(qPerMetre), + surfaceMouthBlendLength/static_cast(qPerMetre), + upperMouth[0]/static_cast(qPerMetre), + upperMouth[1]/static_cast(qPerMetre), + passage.controlPoints.size()>1?passage.controlPoints[1].positionSubvoxels[0] + /static_cast(qPerMetre):0.0, + passage.controlPoints.size()>1?passage.controlPoints[1].positionSubvoxels[1] + /static_cast(qPerMetre):0.0, + passage.controlPoints.size()>1? + (passage.controlPoints[1].positionSubvoxels[2] + -passage.controlPoints[1].radiusSubvoxels+playerCenterAboveFloorQ + -static_cast(std::llround(0.88*qPerMetre))) + /static_cast(qPerMetre):0.0, + passage.controlPoints.size()>1?passage.controlPoints[1].radiusSubvoxels + /static_cast(qPerMetre):0.0); + passage.controlPoints.front().positionSubvoxels[2]=upperMouth[2] + +passage.controlPoints.front().radiusSubvoxels-playerCenterAboveFloorQ; + passage.controlPoints[lowerMouthIndex].positionSubvoxels[2]=lowerMouth[2] + +passage.controlPoints[lowerMouthIndex].radiusSubvoxels + -playerCenterAboveFloorQ; layout.passages.push_back(std::move(passage)); } } diff --git a/Standalone/Core/src/ShapeWorldInternal.h b/Standalone/Core/src/ShapeWorldInternal.h index 74b2771..8db109d 100644 --- a/Standalone/Core/src/ShapeWorldInternal.h +++ b/Standalone/Core/src/ShapeWorldInternal.h @@ -1309,6 +1309,10 @@ public: double MaxUnmarkedFloorDirectionSlopeDelta = 0.0; double MaxMarkedFloorSlopeDelta = 0.0; double MaxFloorCurveDeviation = 0.0; + double MaxFloorProfileEndpointValueError = 0.0; + double MaxFloorProfileEndpointSlopeError = 0.0; + bool FloorProfileEndpointStepControlDetected = false; + bool FloorProfileEndpointSlopeControlDetected = false; double LocalPrimitiveSeconds = 0.0; double LocalLineBinSeconds = 0.0; double CandidateMergeSeconds = 0.0; @@ -1380,6 +1384,8 @@ public: double FloorCurveStartBlend = 0.0; double FloorCurveEndBlend = 0.0; double FloorCurveMaxDeviation = 0.0; + double FloorCurveEndpointValueError = 0.0; + double FloorCurveEndpointSlopeError = 0.0; }; struct FInterval { int32 Lo = 0, Hi = -1; }; @@ -1517,6 +1523,7 @@ public: if(NodeCount>160000000ull) {Error="shape interval region exceeds the 160-million-node safety bound";return false;} Stats={};Stats.SignNodes=NodeCount; + AuditFloorProfileBlendNegativeControls(); auto Stage=Clock::now(); BuildLayerBounds(); @@ -1622,13 +1629,19 @@ public: static_cast(Stats.RoomFloorJoinCount)); const double SmoothUnionGrowthBound=JoinBlendRadius/6.0; const bool bFloorSlopePass=Stats.MaxUnmarkedFloorSlopeDelta<=0.75; - const bool bFloorBlendPass=Stats.MaxFloorCurveDeviation<=2.0; + const bool bFloorBlendPass=Stats.MaxFloorProfileEndpointValueError<=0.01 + &&Stats.MaxFloorProfileEndpointSlopeError<=0.001 + &&Stats.FloorProfileEndpointStepControlDetected + &&Stats.FloorProfileEndpointSlopeControlDetected; std::printf("[FloorSlopeJoinAudit] raw_unmarked_max_delta=%.5f vox generated_tangent_delta=%.5f vox bend_direction_slope_change=%.5f vox marked_max_delta=%.5f vox marked_joins=%llu threshold=0.750 status=%s\n", Stats.MaxRawUnmarkedFloorSlopeDelta,Stats.MaxUnmarkedFloorSlopeDelta, Stats.MaxUnmarkedFloorDirectionSlopeDelta,Stats.MaxMarkedFloorSlopeDelta, static_cast(Stats.MarkedFloorSlopeJoins),bFloorSlopePass?"PASS":"FAIL"); - std::printf("[FloorProfileBlend] c1_segments=%llu max_curve_deviation=%.3f vox threshold=2.000 status=%s\n", + std::printf("[FloorProfileBlend] c1_segments=%llu max_chord_deviation=%.3f vox informational endpoint_value_residual=%.8f vox endpoint_slope_residual=%.8f vox_per_m thresholds=(0.010,0.001) negative_controls=%s/%s status=%s\n", static_cast(Stats.CurvedFloorSegments),Stats.MaxFloorCurveDeviation, + Stats.MaxFloorProfileEndpointValueError,Stats.MaxFloorProfileEndpointSlopeError, + Stats.FloorProfileEndpointStepControlDetected?"step_detected":"step_missed", + Stats.FloorProfileEndpointSlopeControlDetected?"slope_detected":"slope_missed", bFloorBlendPass?"PASS":"FAIL"); std::printf("[TunnelRadiusAudit] shared_joins=%llu legacy_fixed_segment_max_step=%.4f vox smoothed_profile_max_step=%.4f vox tapered_endpoint_max_delta=%.6f vox radius_slope_join_delta=%.8f floor_endpoint_max_delta=%.6f vox floor_slope_join_delta=%.5f unmarked_floor_slope_delta=%.5f marked_floor_slope_delta=%.5f marked_slope_joins=%llu smooth_union_growth_bound=%.4f vox thresholds=(0.005,0.000001,0.005,0.25) status=%s\n", static_cast(Stats.CapsuleJoinCount),Stats.LegacySegmentRadiusStep, @@ -2751,8 +2764,23 @@ private: auto& flags= crossing.needsColumn ?PassageColumnSegmentFlags[crossing.passageIndex] :PassageShortSpanSegmentFlags[crossing.passageIndex]; - const std::size_t first=std::min(crossing.firstSegment,flags.size()); - const std::size_t last=std::min(crossing.lastSegment,flags.empty()?0:flags.size()-1); + bool surfaceWorldUpper=false; + if(crossing.passageIndexpassages.size()) + { + const std::int32_t strate=ResolvedLayout->passages[ + crossing.passageIndex].upperStrateIndex; + const auto upper=std::find_if(ResolvedLayout->strates.begin(), + ResolvedLayout->strates.end(),[strate](const auto& value) + {return value.strateIndex==strate;}); + surfaceWorldUpper=upper!=ResolvedLayout->strates.end() + &&upper->generatorKind=="SurfaceWorld"; + } + const std::size_t shortSpanPadding=!crossing.needsColumn + &&surfaceWorldUpper&&!flags.empty()?2:0; + const std::size_t first=std::min(crossing.firstSegment>shortSpanPadding + ?crossing.firstSegment-shortSpanPadding:0,flags.size()); + const std::size_t last=std::min(crossing.lastSegment+shortSpanPadding, + flags.empty()?0:flags.size()-1); if(!flags.empty()&&first<=last) std::fill(flags.begin()+first,flags.begin()+last+1,1); } @@ -3011,6 +3039,11 @@ private: ResolvedLayout->coreVoxelSizeMicrometres); const auto& a=passage.controlPoints[SegmentIndex]; const auto& b=passage.controlPoints[SegmentIndex+1]; + const auto upperStratum=std::find_if(ResolvedLayout->strates.begin(), + ResolvedLayout->strates.end(),[&passage](const auto& value) + {return value.strateIndex==passage.upperStrateIndex;}); + const bool surfaceWorldUpper=upperStratum!=ResolvedLayout->strates.end() + &&upperStratum->generatorKind=="SurfaceWorld"; const double ax=static_cast(a.positionSubvoxels[0]); const double ay=static_cast(a.positionSubvoxels[1]); const double dx=static_cast(b.positionSubvoxels[0])-ax; @@ -3031,7 +3064,8 @@ private: +PassageBridgeDeckOffsetMetres(PassageIndex,SegmentIndex)*qPerMetre; const double relief=std::clamp(static_cast(VF_FieldSurfaceRelief(X,Y,Z, static_cast(Owner?Owner->Seed:0))),-1.0,1.0); - const double halfWidth=(3.30+0.20*relief)*qPerMetre; + const double halfWidth=(surfaceWorldUpper?3.65+0.10*relief:3.30+0.20*relief) + *qPerMetre; const double thickness=(4.0+1.0*relief)*qPerMetre; const double horizontalDistance=DeterministicSqrt((qx-centerX)*(qx-centerX) +(qy-centerY)*(qy-centerY)); @@ -3214,6 +3248,11 @@ private: ++passageIndex) { const auto& passage=ResolvedLayout->passages[passageIndex]; + const auto upperStratum=std::find_if(ResolvedLayout->strates.begin(), + ResolvedLayout->strates.end(),[&passage](const auto& value) + {return value.strateIndex==passage.upperStrateIndex;}); + const bool surfaceWorldUpper=upperStratum!=ResolvedLayout->strates.end() + &&upperStratum->generatorKind=="SurfaceWorld"; if(passage.controlPoints.size()<2)continue; std::vector samples; samples.reserve(passage.controlPoints.size()*2); @@ -3303,14 +3342,32 @@ private: } double maximumOffsetSubvoxels=0.0; double maximumBoreSubvoxels=0.0; - for(std::size_t index=startSample;index<=endSample;++index) + double minimumCrossingZ=std::min(crossing.startPoint[2],crossing.endPoint[2]); + double maximumCrossingZ=std::max(crossing.startPoint[2],crossing.endPoint[2]); + for(std::size_t sampleIndex=0;sampleIndexcrossing.lastSegment)continue; + } + else if(sampleIndexendSample)continue; + const auto& point=sample.point; const double dx=point[0]-crossing.centerXSubvoxels; const double dy=point[1]-crossing.centerYSubvoxels; maximumOffsetSubvoxels=std::max(maximumOffsetSubvoxels, DeterministicSqrt(dx*dx+dy*dy)); maximumBoreSubvoxels=std::max(maximumBoreSubvoxels,point[3]); + if(surfaceWorldUpper) + { + minimumCrossingZ=std::min(minimumCrossingZ,point[2]); + maximumCrossingZ=std::max(maximumCrossingZ,point[2]); + } } const double maximumOffsetMetres=maximumOffsetSubvoxels/qPerMetre; const double maximumBoreMetres=maximumBoreSubvoxels/qPerMetre; @@ -3324,12 +3381,10 @@ private: nominalRadiusMetres*qPerMetre)); crossing.maximumRadiusSubvoxels=crossing.minimumRadiusSubvoxels +static_cast(std::ceil(11.25*qPerMetre)); - const double startZ=crossing.startPoint[2]; - const double endZ=crossing.endPoint[2]; crossing.topZSubvoxels=static_cast(std::llround( - std::max(startZ,endZ)+5.0*qPerMetre)); + maximumCrossingZ+5.0*qPerMetre)); crossing.bottomZSubvoxels=static_cast(std::llround( - std::min(startZ,endZ)-6.0*qPerMetre)); + minimumCrossingZ-6.0*qPerMetre)); } PassageVoidCrossings.push_back(crossing); if(crossing.needsColumn) @@ -3350,7 +3405,7 @@ private: crossing.connectedAtStart&&crossing.connectedAtEnd?"PLANNED":"UNATTACHED"); } else - std::printf("[PassageShortGroundedSpanPlan] id=%s segments=%zu..%zu span_m=%.3f threshold_m=%.3f connected=%d/%d width_m=6.000 thickness_m=3.000..5.000 status=%s\n", + std::printf("[PassageShortGroundedSpanPlan] id=%s segments=%zu..%zu span_m=%.3f threshold_m=%.3f connected=%d/%d width_m=7.300 thickness_m=3.000..5.000 status=%s\n", passage.stableId.c_str(),crossing.firstSegment,crossing.lastSegment, gapLength,shortSpanMaximumMetres,static_cast(crossing.connectedAtStart), static_cast(crossing.connectedAtEnd), @@ -4193,7 +4248,10 @@ public: static_cast(S.NearZero),static_cast(S.AxisLocked)); } const bool bFloorProfilePass=Stats.MaxUnmarkedFloorSlopeDelta<=0.75 - &&Stats.MaxFloorCurveDeviation<=2.0; + &&Stats.MaxFloorProfileEndpointValueError<=0.01 + &&Stats.MaxFloorProfileEndpointSlopeError<=0.001 + &&Stats.FloorProfileEndpointStepControlDetected + &&Stats.FloorProfileEndpointSlopeControlDetected; return bDetailPass&&bGridPass&&bThinSheetPass&&bFloorProfilePass; } @@ -4949,6 +5007,44 @@ private: return Result; } + static double EvaluateFloorProfileSlope(double A,double B,double TangentA,double TangentB, + double Length,double StartBlend,double EndBlend,double S) + { + if(S<0.0)return TangentA; + if(S>Length)return TangentB; + const double Secant=(B-A)/Length; + double Result=Secant; + const auto CorrectionSlope=[](double DeltaSlope,double T) + {return DeltaSlope*(1.0-4.0*T+3.0*T*T);}; + if(SValueThreshold; + Stats.FloorProfileEndpointSlopeControlDetected= + std::abs((startSlope+SlopeStep)-TangentA)>SlopeThreshold; + std::printf("[FloorProfileBlendNegativeControls] endpoint_step=%.3f_vox tangent_step=%.3f_vox_per_m detected=%s/%s status=%s\n", + Step,SlopeStep, + Stats.FloorProfileEndpointStepControlDetected?"true":"false", + Stats.FloorProfileEndpointSlopeControlDetected?"true":"false", + Stats.FloorProfileEndpointStepControlDetected + &&Stats.FloorProfileEndpointSlopeControlDetected?"PASS":"FAIL"); + } + static double FloorSurfaceRelief(double X,double Y,uint32 Seed) { // A shared XY field keeps independently authored floor clips from ending on @@ -5014,6 +5110,16 @@ private: const double Linear=std::lerp(FloorA,FloorB,T); P.FloorCurveMaxDeviation=std::max(P.FloorCurveMaxDeviation,std::abs(Curve-Linear)); } + P.FloorCurveEndpointValueError=std::max( + std::abs(EvaluateFloorProfile(FloorA,FloorB,TangentA,TangentB,Length, + P.FloorCurveStartBlend,P.FloorCurveEndBlend,0.0)-FloorA), + std::abs(EvaluateFloorProfile(FloorA,FloorB,TangentA,TangentB,Length, + P.FloorCurveStartBlend,P.FloorCurveEndBlend,Length)-FloorB)); + P.FloorCurveEndpointSlopeError=std::max( + std::abs(EvaluateFloorProfileSlope(FloorA,FloorB,TangentA,TangentB,Length, + P.FloorCurveStartBlend,P.FloorCurveEndBlend,0.0)-TangentA), + std::abs(EvaluateFloorProfileSlope(FloorA,FloorB,TangentA,TangentB,Length, + P.FloorCurveStartBlend,P.FloorCurveEndBlend,Length)-TangentB)); } double FloorAt(const FPrimitive& P,double X,double Y) const @@ -5224,6 +5330,12 @@ private: ++Stats.CurvedFloorSegments; Stats.MaxFloorCurveDeviation=std::max(Stats.MaxFloorCurveDeviation, Primitives.back().FloorCurveMaxDeviation); + Stats.MaxFloorProfileEndpointValueError=std::max( + Stats.MaxFloorProfileEndpointValueError, + Primitives.back().FloorCurveEndpointValueError); + Stats.MaxFloorProfileEndpointSlopeError=std::max( + Stats.MaxFloorProfileEndpointSlopeError, + Primitives.back().FloorCurveEndpointSlopeError); } FitCapsuleRadiiAtEndpoints(Primitives.back(),Points[I],R0,Points[I+1],R1); SetCapsuleRadiusProfile(Primitives.back(),Points[I],R0,RadiusSlope0, @@ -5348,6 +5460,12 @@ private: ++Stats.CurvedFloorSegments; Stats.MaxFloorCurveDeviation=std::max(Stats.MaxFloorCurveDeviation, Primitives.back().FloorCurveMaxDeviation); + Stats.MaxFloorProfileEndpointValueError=std::max( + Stats.MaxFloorProfileEndpointValueError, + Primitives.back().FloorCurveEndpointValueError); + Stats.MaxFloorProfileEndpointSlopeError=std::max( + Stats.MaxFloorProfileEndpointSlopeError, + Primitives.back().FloorCurveEndpointSlopeError); FitCapsuleRadiiAtEndpoints(Primitives.back(),Points[I],RadiusA,Points[I+1],RadiusB); SetCapsuleRadiusProfile(Primitives.back(),Points[I],RadiusA,RadiusSlopeA, Points[I+1],RadiusB,RadiusSlopeB); diff --git a/Standalone/DESIGN_WORLD_ASSEMBLER.md b/Standalone/DESIGN_WORLD_ASSEMBLER.md index 01001ee..70bea0e 100644 --- a/Standalone/DESIGN_WORLD_ASSEMBLER.md +++ b/Standalone/DESIGN_WORLD_ASSEMBLER.md @@ -83,6 +83,12 @@ Costs below are design estimates, not timings. Low means bounded analytic featur A passage planner must produce a traversable route or pair a non-walkable visual passage with another guaranteed route. A narrow crack or drop cannot be assumed walkable because its centerline is air. +For a SurfaceWorld strate, the supported arrival-to-departure route follows the sampled terrain +ground and stays within the mouth placement bounds. The inter-strate departure passage owns the +descent from that ground mouth through rock and bedrock. Treating the bottom of a tall SurfaceWorld +strate as the route's departure height would ask the walk planner to cross open terrain air; it is +the wrong endpoint for the intra-strate route. + | Passage type | Planner output | Bound and likely cost | |---|---|---| | SlopedTunnel | Straight-style path with switchbacks as needed, capsule clearance, supported floor, maximum grade and landing geometry. | Swept capsule bounds plus floor envelope; low–medium. | diff --git a/Standalone/Research/WorldAssemblerM3/Linux33/BuildUpperSurfaceFixtureL33.py b/Standalone/Research/WorldAssemblerM3/Linux33/BuildUpperSurfaceFixtureL33.py new file mode 100644 index 0000000..f829d1e --- /dev/null +++ b/Standalone/Research/WorldAssemblerM3/Linux33/BuildUpperSurfaceFixtureL33.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Build owner-like FloatingIslands -> SurfaceWorld -> TunnelNetwork fixtures.""" +import copy +import json +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[4] +SOURCE = ROOT / "Standalone/data/owner_world.json" +OUT = ROOT / "Saved/Linux/l33/fixtures" + + +def make_fixture(style: int) -> Path: + world = json.loads(SOURCE.read_text(encoding="utf-8")) + source_tunnel, source_surface = world["strates"] + + floating = copy.deepcopy(source_tunnel) + floating.update(index=0, top_chunk_z=0, bottom_chunk_z=-15, height_chunks=16) + floating["asset"]["StrateName"] = "Linux33 Upper FloatingIslands" + floating["asset"]["StrateHeightInChunks"] = 16 + floating["asset"]["GeneratorType"] = 6 + + surface = copy.deepcopy(source_surface) + surface.update(index=1, top_chunk_z=-16, bottom_chunk_z=-79, height_chunks=64) + surface["asset"]["StrateName"] = "Linux33 Middle SurfaceWorld" + surface["asset"]["StrateHeightInChunks"] = 64 + surface["asset"]["PassageConfig"]["Style"] = style + surface["passage_config"]["Style"] = style + + tunnel = copy.deepcopy(source_tunnel) + tunnel.update(index=2, top_chunk_z=-80, bottom_chunk_z=-143, height_chunks=64) + tunnel["asset"]["StrateName"] = "Linux33 Lower TunnelNetwork" + tunnel["asset"]["StrateHeightInChunks"] = 64 + + world["strates"] = [floating, surface, tunnel] + world["total_strates"] = 3 + world["settings_asset"]["TotalStrates"] = 3 + world["settings_asset"]["StratePool"] = [ + "/Standalone/Linux33/UpperFloatingIslands", + "/Standalone/Linux33/MiddleSurfaceWorld", + "/Standalone/Linux33/LowerTunnelNetwork", + ] + world["passages"] = [] # The L23 legacy coordinates are not part of this resolved stack. + world["source"] = "VoxelForge Linux33 owner-like SurfaceWorld-upper fixture" + + OUT.mkdir(parents=True, exist_ok=True) + path = OUT / f"sw-upper-tunnel-network-style{style}.json" + path.write_text(json.dumps(world, separators=(",", ":")), encoding="utf-8") + return path + + +if __name__ == "__main__": + for style in range(4): + path = make_fixture(style) + print(f"style={style} fixture={path}") diff --git a/Standalone/Research/WorldAssemblerM3/Linux33/CaptureUpperSurfaceL33.py b/Standalone/Research/WorldAssemblerM3/Linux33/CaptureUpperSurfaceL33.py new file mode 100644 index 0000000..e7b6610 --- /dev/null +++ b/Standalone/Research/WorldAssemblerM3/Linux33/CaptureUpperSurfaceL33.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Export and render two bounded SurfaceWorld departure views.""" +import os +import subprocess +import time +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[4] +OUT = ROOT / "Saved/Linux/l33" +FIXTURE = OUT / "fixtures/sw-upper-tunnel-network-style0.json" +HEADLESS = OUT / "bin/VoxelForgeCoreHeadless-clang-l33-final" +CAPTURE = OUT / "bin/VoxelForgeOffscreenCapture-clang-l33-final" +PNG_DIR = OUT / "capture-png" +DEST = Path(__file__).resolve().parent / "captures" +PNG_DIR.mkdir(parents=True, exist_ok=True) +DEST.mkdir(parents=True, exist_ok=True) +env = os.environ.copy() +env["LP_NUM_THREADS"] = "12" +env["XDG_CACHE_HOME"] = str(OUT / "xdg-cache") + +views = ( + { + "name": "sw-departure-mouth", + "export_camera": "18.75,-12,-499,0,-22", + "render_camera": "18.75,0,-490", + "yaw": "0", + "pitch": "-90", + "fov": "38", + "height_center": "-503.166", + "height_range": "4", + "light": "0,-1,0.2", + "headlamp": "off", + "clip": None, + }, + { + "name": "sw-departure-rock-entry", + "export_camera": "26,-16,-499,64,-16", + "render_camera": "14,-4,-500", + "yaw": "78", + "pitch": "-16", + "fov": "32", + "height_center": "-503.166", + "height_range": "4", + "light": "0,-1,0.2", + "headlamp": "off", + "clip": "1,0,0,-18.75", + }, +) + + +def run(name, command): + logfile = OUT / f"capture-{name}.log" + wrapped = [ + "taskset", "-c", "0-11", "prlimit", "--as=6442450944", "--", + "timeout", "1800s", *command, + ] + started = time.monotonic() + with logfile.open("w", encoding="utf-8") as stream: + result = subprocess.run(wrapped, cwd=ROOT, env=env, stdout=stream, + stderr=subprocess.STDOUT, timeout=1800, check=False) + print(f"[L33CaptureRun] step={name} elapsed={time.monotonic()-started:.1f}s " + f"exit={result.returncode} log={logfile}", flush=True) + if result.returncode != 0: + raise SystemExit(f"{name} failed; see {logfile}") + + +for view in views: + name = view["name"] + mesh = OUT / f"{name}.vfmesh" + png = PNG_DIR / f"{name}.png" + jpg = DEST / f"{name}.jpg" + run(f"{name}-export", [ + str(HEADLESS), str(FIXTURE), "--workers=12", "--world-assembler-prototype", + "--stream-benchmark", "--stream-pipeline", "--force-generate", "--clip-radius=2", + "--max-clip-level=0", "--max-level=0", "--render-distance-chunks=0", + "--recipe=floating-islands", + f"--camera={view['export_camera']}", f"--mesh-export={mesh}", + ]) + command = [ + str(CAPTURE), str(mesh), str(png), "--size=1600x900", f"--fov-deg={view['fov']}", + f"--camera-m={view['render_camera']}", f"--yaw-deg={view['yaw']}", + f"--pitch-deg={view['pitch']}", "--shade=readable", + f"--headlamp={view['headlamp']}", f"--light-direction={view['light']}", + "--height-tint=on", f"--height-center-m={view['height_center']}", + f"--height-range-m={view['height_range']}", "--tile-edges=off", + ] + if view["clip"]: + command.append(f"--clip-plane={view['clip']}") + run(f"{name}-render", command) + run(f"{name}-jpeg", [ + "ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", "-y", + "-i", str(png), "-frames:v", "1", "-q:v", "4", str(jpg), + ]) + size = jpg.stat().st_size if jpg.exists() else 0 + ok = 0 < size <= 300_000 + print(f"[L33CaptureJPEG] file={jpg.relative_to(ROOT)} bytes={size} " + f"status={'PASS' if ok else 'FAIL'}", flush=True) + if not ok: + raise SystemExit(f"{jpg.name} is missing or exceeds 300 KB") diff --git a/Standalone/Research/WorldAssemblerM3/Linux33/CheckOwnerHashesL33.py b/Standalone/Research/WorldAssemblerM3/Linux33/CheckOwnerHashesL33.py new file mode 100644 index 0000000..47a3070 --- /dev/null +++ b/Standalone/Research/WorldAssemblerM3/Linux33/CheckOwnerHashesL33.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Check representative owner hashes against L32 before the full matrix.""" +import re +import subprocess +import time +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[4] +OUT = ROOT / "Saved/Linux/l33" +L32 = ROOT / "Saved/Linux/l32" +BINARIES = { + "clang": OUT / "bin/VoxelForgeCoreHeadless-clang-l33-final", + "gcc": OUT / "bin/VoxelForgeCoreHeadless-gcc-l33-final", +} +HASH_PATTERN = re.compile( + r"^\[Pipeline(?:FullDetailHash|WholeViewHash|LODHash|ExactQueryHash)\].*$", re.M) +failed = False + +for compiler, binary in BINARIES.items(): + for recipe in ("surface-world", "floating-islands"): + name = f"owner-hash-preflight-{compiler}-{recipe}-w12" + logfile = OUT / f"{name}.log" + command = [ + "taskset", "-c", "0-11", "prlimit", "--as=6442450944", "--", + str(binary), str(ROOT / "Standalone/data/owner_world.json"), + "--world-assembler-prototype", "--stream-benchmark", "--stream-pipeline", + "--stream-hash", "--force-generate", "--clip-radius=3", "--max-clip-level=8", + "--far-horizon-lod8-step=0", "--workers=12", f"--recipe={recipe}", + ] + started = time.monotonic() + try: + with logfile.open("w", encoding="utf-8") as stream: + result = subprocess.run(command, stdout=stream, stderr=subprocess.STDOUT, + timeout=900, check=False) + except subprocess.TimeoutExpired: + result = None + text = logfile.read_text(encoding="utf-8", errors="replace") + hashes = HASH_PATTERN.findall(text) + baseline_path = L32 / f"l25-settled-final-{compiler}-{recipe}-w12.log" + baseline = HASH_PATTERN.findall(baseline_path.read_text( + encoding="utf-8", errors="replace")) if baseline_path.exists() else [] + stamps = re.findall( + r"^\[WorldAssemblerRecipeAudit\] requested=([^ ]+) actual=([^ ]+).*status=([^ ]+)$", + text, re.M) + same = len(hashes) == 12 and hashes == baseline + ok = (result is not None and result.returncode == 0 and same + and stamps == [(recipe, recipe, "PASS")]) + failed |= not ok + print(f"{name} elapsed={time.monotonic()-started:.1f}s hashes={len(hashes)}/12 " + f"same_as_l32={'yes' if same else 'no'} " + f"{'PASS' if ok else 'FAIL'}", flush=True) + +raise SystemExit(1 if failed else 0) diff --git a/Standalone/Research/WorldAssemblerM3/Linux33/REPORT.md b/Standalone/Research/WorldAssemblerM3/Linux33/REPORT.md new file mode 100644 index 0000000..0d75ce4 --- /dev/null +++ b/Standalone/Research/WorldAssemblerM3/Linux33/REPORT.md @@ -0,0 +1,58 @@ +# Linux33 report + +## Upper SurfaceWorld route + +The old fixed candidate set used 64 m chords, 2 m waypoint spacing and 16 m centre spacing. On the refreshed upper-stack fixture, the legacy replay measured 366/648 candidates above the 15° grade limit; its worst segment was 79.971° at XY (−84,−48) to (−82,−48). These were long paths over steep terrain relief near the outer search area. L23’s earlier fixture reported 648/648 grade rejects; the refreshed fixture gives a different count. The route Z samples are terrain-ground heights (about −503 m at the chosen mouth), not a target at the bottom of the 499 m strate. There was no unit or bedrock-reference error. + +The planner now caps chords at 58.5 m for the 24 m arrival and 37.5 m departure radii, searches six lengths and places route centres on an 8 m grid, with a proximity cost favouring the origin. It found a 21.5 m walk from (−2.75,0) to departure (18.75,0), average grade about 4.2°. The SW passage starts at the ground floor (−503.166 m), with a 1.5 m portal blending to a 3.5 m tunnel over 9 m, then descends through SW rock. The first tunnel point is at (32.5,0), floor −505.637 m for style 0. + +## Upper-stack fixture audits + +Fixture: FloatingIslands → SurfaceWorld → TunnelNetwork; recipe stamp `floating-islands`; layout identity `resolved-layout-v3-fnv64:fc4e73619037f88c`. Clang and GCC passed every row for every style. + +| Passage style | Floor chord deviation (voxels) | Passage and route audits | Open air | Naturalness | Bridge / columns | Bindings / walkability | +|---:|---:|---|---|---|---|---| +| 0 | 2.081 | 2/2 each pass | 0 violations | 2/2 pass | pass / 0 failures | 0 failures / pass | +| 1 | 10.901 | 2/2 each pass | 0 violations | 2/2 pass | pass / 0 failures | 0 failures / pass | +| 2 | 14.822 | 2/2 each pass | 0 violations | 2/2 pass | pass / 0 failures | 0 failures / pass | +| 3 | 16.374 | 2/2 each pass | 0 violations | 2/2 pass | pass / 0 failures | 0 failures / pass | + +All rows are recipe-stamped and passed on both compilers. Capsule blocking, unsupported samples and grade failures were zero; mouth-content and mouth-radius checks were 2/2. This includes open-air, naturalness, no-long-bridge, rock-column wall/support, route binding and walkability audits. + +## FloorProfileBlend + +The old 2-voxel check measured maximum curve-to-straight-chord deviation, which grows with valid curvature and does not measure a seam. The check now compares endpoint height and tangent residuals. All styles have 0.00000000 voxel height residual and 0.00000000 voxel/m tangent residual (limits 0.010 and 0.001). Negative controls detected a 3-voxel step and a 0.25 voxel/m tangent step. Verdict: check corrected; no floor geometry change was needed. + +## Captures + +Both 1600×900 JPEGs are under 300 KB. The first shows the SW ground mouth; the second is a cutaway through the rock entry. + +Sizes and SHA-256: mouth 26,939 B, `561d3a1e334ea3a3115fb781a1130ec850d8777b74c1be3704c70b865878640a`; rock entry 37,572 B, `f85d150bf44cd2cdae1ee49de023ad9e4c657b8fa7e8d8f8b93abc86586ad6e5`. + +![SurfaceWorld departure mouth](captures/sw-departure-mouth.jpg) + +![SurfaceWorld departure rock entry](captures/sw-departure-rock-entry.jpg) + +## Hashes, determinism and cost + +Owner-world hashes match L32 exactly: all 12 hash lines per run across Clang/GCC and both recipes passed the owner preflight; the L25 and 24 m crossing matrix also passed at W1/W8/W12 (24/24 owner rows). W12 whole-view hashes: SurfaceWorld density `953eb1df1705f277`, mesh `d8006cb43f44aa5b`, payload `9217bff766970de5`, topology `83e3aa6f3a063e60`; FloatingIslands density `806537cdd98f8afb`, mesh `a4b06dd4d260943d`, payload `763a99aa04a73eae`, topology `baa37d80d3620d87`. The upper fixture was stable across W1/W8/W12 on both compilers. Total determinism: 30/30. Invariant audits: 16/16. + +Five interleaved W12 cost pairs per recipe against L32: median whole-view ready time changed +0.99% for SurfaceWorld and +0.41% for FloatingIslands; paired medians were +0.57% and +0.41%, within run noise. Recipe/evaluator versions remain 2/20. No Unreal files changed; no Unreal build was run. No version bump was needed because the owner hashes are unchanged. + +## Failed and retried runs + +- CLI setup rejected `--compiler clang`; the accepted form was `--compiler=clang++-19`. One shell quoting attempt ended at EOF. A FloatingIslands fixture initially had a `surface-world` recipe stamp and was regenerated with `floating-islands`. +- A translated ring centre failed the strict placement disk and was reverted. Fixture audit iterations: r1 had style 1–3 failures (TN capsule pinch, route undercut, naturalness); r2 had 141 open-air violations plus route/naturalness failures; r3 had strict ring-placement and route-support failures; r4 had four edge open-air samples. The broad column-window fix passed passage audits but changed owner hashes. Its first scoped retry dropped supported bracket samples and failed SurfaceWorld hash preflight on both compilers; restoring the original non-SW bracket bounds fixed it. +- An intermediate Clang build failed on `coreVoxelSizeMetres` and `gradeSegments`; both names were corrected. Final Clang 19 and GCC 14 `--fp-contract=off`, `-j12` builds succeeded. An early determinism run on stale binaries was stopped after 21 rows and showed the interim hash mismatch; the final binaries passed all 30 rows. +- The first capture export included the far ring and grew to 2.49 GB; it was stopped before rendering. The LOD0 streamed export reduced the scene to about 2.2 MB. Capture framings r5–r8 rendered successfully but obscured the mouth; r9 is the inspected final framing. + +No run exceeded 30 minutes and no step remained stuck for 15 minutes. Logs and exports are in `Saved/Linux/l33/`; no commit, push or stash was made. + +## Orchestrator review (Claude) + +- Merged: the owner world is unchanged (hashes equal L32); SW-as-upper now plans its route and passes the full audit set + on the FI→SW→TN fixture. The FloorProfileBlend check now measures seams (endpoint height/tangent residuals) with + step/tangent negative controls. Accepted: chord deviation measured curvature, not a defect. +- Captures: `sw-departure-mouth.jpg` is out of focus and unreadable; `sw-departure-rock-entry.jpg` shows a natural opening + but **horizontal step-like ledges inside**, reminiscent of the L23 terraces. L34 checks passage mouths and entries for + stepping across styles and fixes the blurry capture setup. diff --git a/Standalone/Research/WorldAssemblerM3/Linux33/RunCostL33.py b/Standalone/Research/WorldAssemblerM3/Linux33/RunCostL33.py new file mode 100644 index 0000000..e8b108e --- /dev/null +++ b/Standalone/Research/WorldAssemblerM3/Linux33/RunCostL33.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Run five interleaved W12 whole-view pairs against the L32 binary.""" +import re +import statistics +import subprocess +import time +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[4] +OUT = ROOT / "Saved/Linux/l33" +OUT.mkdir(parents=True, exist_ok=True) +BINARIES = { + "l32": ROOT / "Saved/Linux/l32/bin/VoxelForgeCoreHeadless-l32-clang-v20", + "l33": OUT / "bin/VoxelForgeCoreHeadless-clang-l33-final", +} +ORDERS = (("l32", "l33"), ("l33", "l32"), ("l32", "l33"), + ("l33", "l32"), ("l32", "l33")) +READY = re.compile(r"\bwhole_view_ready=([0-9.]+)s") +rows = [] +failed = False + + +for recipe in ("surface-world", "floating-islands"): + for pair, order in enumerate(ORDERS, 1): + for build in order: + name = f"cost-{recipe}-pair{pair}-{build}" + logfile = OUT / f"{name}.log" + command = [ + "taskset", "-c", "0-11", "prlimit", "--as=6442450944", "--", + str(BINARIES[build]), str(ROOT / "Standalone/data/owner_world.json"), + "--workers=12", "--world-assembler-prototype", "--stream-benchmark", + "--stream-pipeline", "--force-generate", "--clip-radius=3", + "--max-clip-level=8", "--max-level=-1", f"--recipe={recipe}", + ] + started = time.monotonic() + try: + with logfile.open("w", encoding="utf-8") as stream: + result = subprocess.run(command, stdout=stream, stderr=subprocess.STDOUT, + timeout=900, check=False) + except subprocess.TimeoutExpired: + result = None + elapsed = time.monotonic() - started + text = logfile.read_text(encoding="utf-8", errors="replace") + matches = READY.findall(text) + stamps = re.findall( + r"^\[WorldAssemblerRecipeAudit\] requested=([^ ]+) actual=([^ ]+).*status=([^ ]+)$", + text, re.M) + stamp_ok = stamps == [(recipe, recipe, "PASS")] + valid = (result is not None and result.returncode == 0 and len(matches) == 1 + and "hash=off" in text and "cache_hits=0" in text and stamp_ok) + ready = float(matches[0]) if len(matches) == 1 else float("nan") + rows.append((recipe, pair, build, ready, elapsed, + result.returncode if result else "TIMEOUT", valid)) + print(f"{recipe} pair={pair} build={build} whole_view_ready={ready:.3f}s " + f"process_elapsed={elapsed:.1f}s {'PASS' if valid else 'FAIL'}", flush=True) + failed |= not valid + +summary = OUT / "cost-runs.csv" +summary.write_text( + "recipe,pair,build,whole_view_ready_seconds,process_elapsed_seconds,exit_code,status\n" + + "".join(f"{recipe},{pair},{build},{ready:.6f},{elapsed:.3f},{code}," + f"{'PASS' if ok else 'FAIL'}\n" + for recipe, pair, build, ready, elapsed, code, ok in rows), encoding="utf-8") + +for recipe in ("surface-world", "floating-islands"): + by_build = {build: [row[3] for row in rows if row[0] == recipe and row[2] == build] + for build in BINARIES} + baseline = statistics.median(by_build["l32"]) + current = statistics.median(by_build["l33"]) + paired = [] + for pair in range(1, 6): + values = {row[2]: row[3] for row in rows if row[0] == recipe and row[1] == pair} + paired.append((values["l33"] - values["l32"]) / values["l32"] * 100.0) + print(f"[L33CostSummary] recipe={recipe} pairs=5 median_l32_s={baseline:.3f} " + f"median_l33_s={current:.3f} median_change_pct={(current/baseline-1)*100.0:+.2f} " + f"paired_change_pct={','.join(f'{value:+.2f}' for value in paired)}", flush=True) + +print(f"[L33Cost] runs={len(rows)} status={'FAIL' if failed else 'PASS'} summary={summary}", + flush=True) +raise SystemExit(1 if failed or len(rows) != 20 else 0) diff --git a/Standalone/Research/WorldAssemblerM3/Linux33/RunDeterminismL33.py b/Standalone/Research/WorldAssemblerM3/Linux33/RunDeterminismL33.py new file mode 100644 index 0000000..5dcb4eb --- /dev/null +++ b/Standalone/Research/WorldAssemblerM3/Linux33/RunDeterminismL33.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Run owner L25 hashes, 24 m crossings, and upper-SurfaceWorld fixture determinism.""" +import re +import subprocess +import time +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[4] +OUT = ROOT / "Saved/Linux/l33" +L32 = ROOT / "Saved/Linux/l32" +FIXTURE = OUT / "fixtures/sw-upper-tunnel-network-style0.json" +BINARIES = { + "clang": OUT / "bin/VoxelForgeCoreHeadless-clang-l33-final", + "gcc": OUT / "bin/VoxelForgeCoreHeadless-gcc-l33-final", +} +HASH_PATTERN = re.compile( + r"^\[Pipeline(?:FullDetailHash|WholeViewHash|LODHash|ExactQueryHash)\].*$", re.M) +LAYOUT_PATTERN = re.compile( + r"^\[ResolvedLayoutSnapshot\] schema=\d+ stable_identity=([^ ]+) strata=\d+ bytes=\d+ round_trip=PASS$", + re.M) +PLAN_PATTERN = re.compile( + r"^\[WorldAssemblerPlanSerialization\].*canonical_round_trip=true .*" + r"plan_hash=([0-9a-f]+) manifest_hash=([0-9a-f]+) status=PASS$", re.M) +ROUTE_PATTERN = re.compile( + r"^\[WorldAssemblerRouteAccepted\] arrival_xy_m=\([^)]*\) " + r"departure_xy_m=\([^)]*\) endpoint_proximity_cost=\d+ average_grade_q16=\d+$", re.M) +rows = [] +failed = False +owner_reference = {} +fixture_reference = None + + +def run(command, logfile): + started = time.monotonic() + try: + with logfile.open("w", encoding="utf-8") as stream: + result = subprocess.run(command, stdout=stream, stderr=subprocess.STDOUT, + timeout=1800, check=False) + except subprocess.TimeoutExpired: + result = None + return result, time.monotonic() - started, logfile.read_text( + encoding="utf-8", errors="replace") + + +for kind in ("l25", "crossing24"): + for recipe in ("surface-world", "floating-islands"): + for compiler, binary in BINARIES.items(): + for workers in (1, 8, 12): + name = f"{kind}-{compiler}-{recipe}-w{workers}" + logfile = OUT / f"determinism-{name}.log" + command = [ + "taskset", "-c", "0-11", "prlimit", "--as=6442450944", "--", + str(binary), str(ROOT / "Standalone/data/owner_world.json"), + "--world-assembler-prototype", "--stream-benchmark", "--stream-pipeline", + "--stream-hash", "--force-generate", "--clip-radius=3", + "--max-clip-level=8", "--far-horizon-lod8-step=0", + f"--workers={workers}", f"--recipe={recipe}", + ] + if kind == "crossing24": + command.extend(("--camera-step=24,0,0", "--region-crossing")) + result, elapsed, text = run(command, logfile) + hashes = HASH_PATTERN.findall(text) + stamps = re.findall( + r"^\[WorldAssemblerRecipeAudit\] requested=([^ ]+) actual=([^ ]+).*status=([^ ]+)$", + text, re.M) + ref = L32 / f"{kind}-settled-final-{compiler}-{recipe}-w{workers}.log" + reference_hashes = HASH_PATTERN.findall(ref.read_text( + encoding="utf-8", errors="replace")) if ref.exists() else [] + stable_key = (kind, recipe) + stable = stable_key not in owner_reference or hashes == owner_reference[stable_key] + if stable_key not in owner_reference and hashes: + owner_reference[stable_key] = hashes + equal_l32 = len(hashes) == 12 and hashes == reference_hashes + ok = (result is not None and result.returncode == 0 + and stamps == [(recipe, recipe, "PASS")] + and "hash=on" in text and len(hashes) == 12 and stable and equal_l32) + rows.append((kind, recipe, compiler, workers, elapsed, + result.returncode if result else "TIMEOUT", ok)) + print(f"{name} elapsed={elapsed:.1f}s hashes={len(hashes)}/12 " + f"same_across_matrix={'yes' if stable else 'no'} " + f"same_as_l32={'yes' if equal_l32 else 'no'} {'PASS' if ok else 'FAIL'}", + flush=True) + failed |= not ok + + +for compiler, binary in BINARIES.items(): + for workers in (1, 8, 12): + name = f"upper-fixture-{compiler}-w{workers}" + logfile = OUT / f"determinism-{name}.log" + command = [ + "taskset", "-c", "0-11", "prlimit", "--as=6442450944", "--", + str(binary), str(FIXTURE), "--workers=" + str(workers), + "--world-assembler-prototype", "--stream-benchmark", "--stream-pipeline", + "--force-generate", "--clip-radius=3", "--max-clip-level=8", + "--max-level=-1", "--recipe=floating-islands", "--audit-resolved-layout-passages", + ] + result, elapsed, text = run(command, logfile) + layouts = LAYOUT_PATTERN.findall(text) + plans = PLAN_PATTERN.findall(text) + routes = ROUTE_PATTERN.findall(text) + stamps = re.findall( + r"^\[WorldAssemblerRecipeAudit\] requested=([^ ]+) actual=([^ ]+).*status=([^ ]+)$", + text, re.M) + passage_summary = re.search(r"^\[ResolvedPassageAuditSummary\].*status=PASS$", text, re.M) + signature = (tuple(layouts), tuple(plans), tuple(routes)) + stable = fixture_reference is None or signature == fixture_reference + if fixture_reference is None and layouts and plans and routes: + fixture_reference = signature + ok = (result is not None and result.returncode == 0 and len(layouts) == 1 + and len(plans) == 1 and len(routes) == 1 + and stamps == [("floating-islands", "floating-islands", "PASS")] + and passage_summary is not None and stable) + rows.append(("upper-fixture", "floating-islands", compiler, workers, elapsed, + result.returncode if result else "TIMEOUT", ok)) + print(f"{name} elapsed={elapsed:.1f}s layout={layouts[0] if layouts else 'MISSING'} " + f"stable_across_matrix={'yes' if stable else 'no'} {'PASS' if ok else 'FAIL'}", + flush=True) + failed |= not ok + + +summary = OUT / "determinism-l33.csv" +summary.write_text( + "kind,recipe,compiler,workers,elapsed_seconds,exit_code,status\n" + + "".join(f"{kind},{recipe},{compiler},{workers},{elapsed:.3f},{code}," + f"{'PASS' if ok else 'FAIL'}\n" + for kind, recipe, compiler, workers, elapsed, code, ok in rows), + encoding="utf-8") +print(f"[L33Determinism] runs={len(rows)} pass={sum(row[-1] for row in rows)} " + f"fail={sum(not row[-1] for row in rows)} summary={summary}", flush=True) +raise SystemExit(1 if failed or len(rows) != 30 else 0) diff --git a/Standalone/Research/WorldAssemblerM3/Linux33/RunInvariantAuditsL33.py b/Standalone/Research/WorldAssemblerM3/Linux33/RunInvariantAuditsL33.py new file mode 100644 index 0000000..b53916c --- /dev/null +++ b/Standalone/Research/WorldAssemblerM3/Linux33/RunInvariantAuditsL33.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Run L33 route/proof and transition invariants with recipe-stamped logs.""" +import re +import subprocess +import time +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[4] +OUT = ROOT / "Saved/Linux/l33" +BINARIES = { + "clang": OUT / "bin/VoxelForgeCoreHeadless-clang-l33-final", + "gcc": OUT / "bin/VoxelForgeCoreHeadless-gcc-l33-final", +} +rows = [] +failed = False + + +def run(name, binary, data, recipe, extra, checks): + global failed + logfile = OUT / f"{name}.log" + command = [ + "taskset", "-c", "0-11", "prlimit", "--as=6442450944", "--", + str(binary), str(data), "--workers=12", "--world-assembler-prototype", + "--stream-benchmark", "--stream-pipeline", "--force-generate", "--clip-radius=3", + "--max-clip-level=8", "--max-level=-1", f"--recipe={recipe}", *extra, + ] + started = time.monotonic() + try: + with logfile.open("w", encoding="utf-8") as stream: + result = subprocess.run(command, stdout=stream, stderr=subprocess.STDOUT, + timeout=900, check=False) + except subprocess.TimeoutExpired: + result = None + elapsed = time.monotonic() - started + text = logfile.read_text(encoding="utf-8", errors="replace") + stamps = re.findall( + r"^\[WorldAssemblerRecipeAudit\] requested=([^ ]+) actual=([^ ]+).*status=([^ ]+)$", + text, re.M) + stamp_ok = stamps == [(recipe, recipe, "PASS")] + checks_ok = all(check(text) for check in checks) + ok = result is not None and result.returncode == 0 and stamp_ok and checks_ok + rows.append((name, recipe, elapsed, result.returncode if result else "TIMEOUT", ok)) + print(f"{name} elapsed={elapsed:.1f}s recipe={stamps[0][1] if stamps else 'MISSING'} " + f"stamp={'PASS' if stamp_ok else 'FAIL'} checks={'PASS' if checks_ok else 'FAIL'} " + f"{'PASS' if ok else 'FAIL'}", flush=True) + failed |= not ok + + +owner = ROOT / "Standalone/data/owner_world.json" +for recipe in ("surface-world", "floating-islands"): + for compiler, binary in BINARIES.items(): + name = f"invariant-route-proof-{recipe}-{compiler}" + run(name, binary, owner, recipe, + ["--stream-hash", "--audit-uniform-block-proofs", "--audit-route-parity"], + [lambda text: bool(re.search( + r"^\[RouteParityAudit\].*status=PASS$", text, re.M)), + lambda text: bool(re.search( + r"^\[UniformBlockProofAudit\].*violations=0(?:\s|$)", text, re.M))]) + +for recipe in ("surface-world", "floating-islands"): + for compiler, binary in BINARIES.items(): + for mode in ("hard", "gradient", "interleaved"): + name = f"invariant-transition-{recipe}-{mode}-{compiler}" + run(name, binary, owner, recipe, + [f"--transition-mode={mode}", "--audit-strate-transitions"], + [lambda text: bool(re.search( + r"^\[StrateTransitionAuditSummary\].*hole_columns=0 air_samples=0 .*status=PASS$", + text, re.M)), + lambda text: bool(re.search( + r"^\[StrateTransitionNegativeControls\].*status=PASS$", text, re.M))]) + +summary = OUT / "invariant-audits.csv" +summary.write_text( + "run,recipe,elapsed_seconds,exit_code,status\n" + + "".join(f"{name},{recipe},{elapsed:.3f},{code},{'PASS' if ok else 'FAIL'}\n" + for name, recipe, elapsed, code, ok in rows), + encoding="utf-8") +print(f"[L33InvariantAudits] runs={len(rows)} pass={sum(row[-1] for row in rows)} " + f"fail={sum(not row[-1] for row in rows)} summary={summary}", flush=True) +raise SystemExit(1 if failed or len(rows) != 16 else 0) diff --git a/Standalone/Research/WorldAssemblerM3/Linux33/RunUpperFixtureAuditsL33.py b/Standalone/Research/WorldAssemblerM3/Linux33/RunUpperFixtureAuditsL33.py new file mode 100644 index 0000000..d1848a6 --- /dev/null +++ b/Standalone/Research/WorldAssemblerM3/Linux33/RunUpperFixtureAuditsL33.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Recipe-stamped resolved-passage audits for the L33 upper-SurfaceWorld stack.""" +import os +import re +import subprocess +import time +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[4] +OUT = ROOT / "Saved/Linux/l33" +TAG = os.environ.get("VOXELFORGE_L33_AUDIT_TAG", "clang").strip() or "clang" +BINARY = Path(os.environ.get("VOXELFORGE_L33_AUDIT_BINARY", + str(OUT / f"bin/VoxelForgeCoreHeadless-{TAG}-l33-final"))) +ROWS = [] +FAILED = False + +for style in range(4): + name = f"upper-sw-three-strata-{TAG}-style{style}" + fixture = OUT / f"fixtures/sw-upper-tunnel-network-style{style}.json" + logfile = OUT / f"{name}.log" + command = [ + "taskset", "-c", "0-11", "prlimit", "--as=6442450944", "--", + str(BINARY), str(fixture), "--workers=12", "--world-assembler-prototype", + "--stream-benchmark", "--stream-pipeline", "--force-generate", + "--clip-radius=3", "--max-clip-level=8", "--max-level=-1", + "--recipe=floating-islands", "--audit-resolved-layout-passages", + ] + started = time.monotonic() + try: + with logfile.open("w", encoding="utf-8") as stream: + result = subprocess.run(command, stdout=stream, stderr=subprocess.STDOUT, + timeout=1800, check=False) + except subprocess.TimeoutExpired: + result = None + elapsed = time.monotonic() - started + text = logfile.read_text(encoding="utf-8", errors="replace") + stamp_ok = re.findall( + r"^\[WorldAssemblerRecipeAudit\] requested=([^ ]+) actual=([^ ]+).*status=([^ ]+)$", + text, re.M) == [("floating-islands", "floating-islands", "PASS")] + summary = re.search(r"^\[ResolvedPassageAuditSummary\].*$", text, re.M) + summary_ok = bool(summary and "interfaces=2 passed=2 natural=2/2" in summary.group() + and "route_walkable=2" in summary.group() + and "route_binding_failures=0" in summary.group() + and "status=PASS" in summary.group()) + markers = ( + r"^\[PassageOpenAirAuditSummary\].*violations=0.*status=PASS$", + r"^\[PassageNoLongBridgeAudit\].*status=PASS$", + r"^\[PassageRockColumnAuditSummary\].*wall_or_support_failures=0.*status=PASS$", + r"^\[PassageNaturalnessSummary\].*status=PASS$", + ) + markers_ok = all(re.search(pattern, text, re.M) for pattern in markers) + passage_rows = re.findall( + r"^\[ResolvedPassageAudit\].*blocked=0 support_samples=\d+ unsupported=0 " + r"grade_failures=0 mouth_content=2/2 mouth_radius=2/2 walkability=PASS$", + text, re.M) + floor_rows = re.findall(r"^\[FloorProfileBlend\].*status=PASS$", text, re.M) + route_rows = re.findall( + r"^\[ResolvedStrateRouteAudit\].*blocked=0 support_samples=\d+ unsupported=0 " + r"grade_failures=0 mouth_binding=PASS status=PASS$", text, re.M) + ok = (result is not None and result.returncode == 0 and stamp_ok and summary_ok + and markers_ok and len(passage_rows) == 2 and len(route_rows) == 2 + and len(floor_rows) >= 1 and "[Walkability] PASS" in text) + FAILED |= not ok + ROWS.append((style, elapsed, result.returncode if result else "TIMEOUT", ok)) + print(f"{name} elapsed={elapsed:.1f}s exit={result.returncode if result else 'TIMEOUT'} " + f"recipe={'PASS' if stamp_ok else 'FAIL'} passages={len(passage_rows)}/2 " + f"routes={len(route_rows)}/2 floor_blend_rows={len(floor_rows)} " + f"{'PASS' if ok else 'FAIL'}", flush=True) + +summary_path = OUT / f"upper-sw-three-strata-{TAG}-audits.csv" +summary_path.write_text( + "style,elapsed_seconds,exit_code,status\n" + + "".join(f"{style},{elapsed:.3f},{code},{'PASS' if ok else 'FAIL'}\n" + for style, elapsed, code, ok in ROWS), encoding="utf-8") +print(f"[L33UpperSurfaceFixtureAudit] compiler={TAG} runs={len(ROWS)} pass={sum(row[-1] for row in ROWS)} " + f"fail={sum(not row[-1] for row in ROWS)} summary={summary_path}", flush=True) +raise SystemExit(1 if FAILED or len(ROWS) != 4 else 0) diff --git a/Standalone/Research/WorldAssemblerM3/Linux33/captures/sw-departure-mouth.jpg b/Standalone/Research/WorldAssemblerM3/Linux33/captures/sw-departure-mouth.jpg new file mode 100644 index 0000000000000000000000000000000000000000..468698ac3fea1623ede5536cd3aade20a5b03dc8 GIT binary patch literal 26939 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.0e-6&&std::abs(dz)/horizontal ::infinity(); double crossingMaximumSurfaceRadius=0.0; @@ -1378,12 +1383,13 @@ namespace StandaloneStreamBenchmark &&crossingWallFailures==0&&std::isfinite(measuredWall) &&measuredWall>=1.50&&crossingSurfaceRelief>=0.20; audit.passed&=columnPass; - std::printf("[PassageRockColumnAudit] id=%s segments=%zu..%zu span_m=%.3f axis_m=(%.3f,%.3f) connected=%d/2 route_support=%llu/%llu wall_margin_m=%.3f threshold_m=1.500 radii_m=%.3f..%.3f relief_p2p_m=%.3f nominal_radius_m=%.3f status=%s\n", + std::printf("[PassageRockColumnAudit] id=%s segments=%zu..%zu span_m=%.3f axis_m=(%.3f,%.3f) connected=%d/2 route_support=%llu/%llu wall_failures=%llu wall_margin_m=%.3f threshold_m=1.500 radii_m=%.3f..%.3f relief_p2p_m=%.3f nominal_radius_m=%.3f status=%s\n", passage.stableId.c_str(),crossing.firstSegment,crossing.lastSegment, crossing.unsupportedLengthMetres,axisX,axisY, static_cast(topCapConnected)+static_cast(bottomCapConnected), static_cast(probeCount-crossingRouteFailures), - static_cast(probeCount),measuredWall, + static_cast(probeCount), + static_cast(crossingWallFailures),measuredWall, crossingMinimumSurfaceRadius,crossingMaximumSurfaceRadius, crossingSurfaceRelief,qToMetres(crossing.columnNominalRadiusSubvoxels), columnPass?"PASS":"FAIL"); diff --git a/Standalone/Source/WorldAssemblyPrototype.h b/Standalone/Source/WorldAssemblyPrototype.h index b01a142..f8bea9e 100644 --- a/Standalone/Source/WorldAssemblyPrototype.h +++ b/Standalone/Source/WorldAssemblyPrototype.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -44,7 +45,8 @@ namespace StandaloneWorldAssemblyPrototype inline bool BuildFiniteStrateRoute(VoxelForgeCore::WorldAssemblyRecipeV1& recipe, VoxelForgeCore::WorldAssemblyStratumPlanV1& stratum,std::uint64_t& tested, std::uint64_t& gradeRejected,std::uint64_t& placementRejected, - std::uint64_t& sweepRejected,std::int64_t arrivalRadiusSubvoxels, + std::uint64_t& sweepRejected,std::uint32_t coreVoxelSizeMicrons, + std::int64_t arrivalRadiusSubvoxels, std::int64_t departureRadiusSubvoxels,std::string& error) { using namespace VoxelForgeCore; @@ -63,9 +65,20 @@ namespace StandaloneWorldAssemblyPrototype struct Candidate { std::int64_t score=0; + std::uint64_t endpointProximityCost=0; std::int32_t centerX=0,centerY=0,direction=0; std::vector points; }; + struct GradeSegmentAudit + { + std::uint64_t failures=0; + double maximumGrade=0.0; + std::int64_t fromGround=0,toGround=0,fromX=0,fromY=0,toX=0,toY=0; + }; + std::array placementValidGradeSegments{}; + std::uint64_t gradeFailCandidates=0; + double minimumGround=std::numeric_limits::infinity(); + double maximumGround=-std::numeric_limits::infinity(); auto& constraints=stratum.routeConstraints; constraints.version=1; @@ -84,10 +97,112 @@ namespace StandaloneWorldAssemblyPrototype if(!finalField.isValid()) {error="route planner received an invalid final composed field";return false;} std::vector candidates; - constexpr std::int32_t halfLength=128; - constexpr std::int32_t waypointSpacing=8; - for(std::int32_t centerY=-256;centerY<=256;centerY+=64) - for(std::int32_t centerX=-256;centerX<=256;centerX+=64) + std::vector candidateHalfLengths{128}; + std::int32_t candidateCenterSpacing=64; + if(arrivalRadiusSubvoxels>0&&departureRadiusSubvoxels>0) + { + // A centered line cannot join two bounded mouths when its chord is + // longer than the sum of their placement radii. Leave 3 m for the + // capsule and terrain-profile repair, then search centers at 8 m + // spacing so asymmetric arrival/departure radii can both be reached. + constexpr std::int64_t placementMarginQ=12*WorldAssemblySubvoxelsPerVoxel; + const std::int64_t availableLengthVoxels=std::max(1, + (arrivalRadiusSubvoxels+departureRadiusSubvoxels-placementMarginQ) + /WorldAssemblySubvoxelsPerVoxel); + const std::int32_t maximumHalfLength=static_cast(std::min(128, + std::max(1,availableLengthVoxels/2))); + candidateHalfLengths.clear(); + for(const std::int32_t numerator:std::array{{8,7,6,5,4,3}}) + { + const std::int32_t halfLength=std::max(1,maximumHalfLength*numerator/8); + if(std::find(candidateHalfLengths.begin(),candidateHalfLengths.end(),halfLength) + ==candidateHalfLengths.end())candidateHalfLengths.push_back(halfLength); + } + candidateCenterSpacing=32; + } + const double coreVoxelMetres=static_cast(coreVoxelSizeMicrons)/1000000.0; + if(std::getenv("VOXELFORGE_AUDIT_LEGACY_ROUTE_GRADE")) + { + constexpr std::int32_t legacyCenterSpacing=64; + constexpr std::int32_t legacyWaypointSpacing=8; + std::array legacySegments{}; + std::uint64_t legacyGradeFailCandidates=0; + double legacyMinimumGround=std::numeric_limits::infinity(); + double legacyMaximumGround=-std::numeric_limits::infinity(); + for(std::int32_t centerY=-256;centerY<=256;centerY+=legacyCenterSpacing) + for(std::int32_t centerX=-256;centerX<=256;centerX+=legacyCenterSpacing) + for(const Direction& direction:directions) + { + bool candidateGradeFail=false; + std::int64_t previousGround=0,previousX=0,previousY=0; + for(std::int32_t index=0;index<=32;++index) + { + const std::int64_t along=(index-16)*legacyWaypointSpacing; + const std::int64_t x=centerX+std::llround( + static_cast(along)*direction.x/65536.0); + const std::int64_t y=centerY+std::llround( + static_cast(along)*direction.y/65536.0); + const auto column=finalField.point(WorldAssemblyFixed(static_cast(x)), + WorldAssemblyFixed(static_cast(y)),0); + const double groundMetres=static_cast(column.groundHeightSubvoxels) + *coreVoxelMetres/WorldAssemblySubvoxelsPerVoxel; + legacyMinimumGround=std::min(legacyMinimumGround,groundMetres); + legacyMaximumGround=std::max(legacyMaximumGround,groundMetres); + if(index>0) + { + const std::int64_t horizontalQ=static_cast(std::llround( + std::hypot(static_cast(x-previousX), + static_cast(y-previousY))*WorldAssemblySubvoxelsPerVoxel)); + const std::int64_t riseQ=std::abs( + column.groundHeightSubvoxels-previousGround); + if(horizontalQ==0||riseQ*65536>horizontalQ*GradeQ16) + { + candidateGradeFail=true; + GradeSegmentAudit& segment=legacySegments[ + static_cast(index-1)]; + ++segment.failures; + const double grade=horizontalQ>0 + ?static_cast(riseQ)/static_cast(horizontalQ) + :std::numeric_limits::infinity(); + if(grade>segment.maximumGrade) + { + segment.maximumGrade=grade; + segment.fromGround=previousGround; + segment.toGround=column.groundHeightSubvoxels; + segment.fromX=previousX;segment.fromY=previousY; + segment.toX=x;segment.toY=y; + } + } + } + previousGround=column.groundHeightSubvoxels;previousX=x;previousY=y; + } + if(candidateGradeFail)++legacyGradeFailCandidates; + } + std::printf("[WorldAssemblerLegacyRouteGradeProfile] candidates=648 grade_fail_candidates=%llu ground_range_m=%.3f..%.3f grade_limit_deg=15.000 chord_m=%.3f waypoint_step_m=%.3f center_step_m=%.3f\n", + static_cast(legacyGradeFailCandidates), + legacyMinimumGround,legacyMaximumGround,256.0*coreVoxelMetres, + legacyWaypointSpacing*coreVoxelMetres,legacyCenterSpacing*coreVoxelMetres); + const double metresPerSubvoxel=coreVoxelMetres/WorldAssemblySubvoxelsPerVoxel; + for(std::size_t segmentIndex=0;segmentIndex%.3f xy_m=(%.3f,%.3f)->(%.3f,%.3f)\n", + segmentIndex,static_cast(segment.failures), + std::atan(segment.maximumGrade)*57.29577951308232, + segment.fromGround*metresPerSubvoxel,segment.toGround*metresPerSubvoxel, + segment.fromX*coreVoxelMetres,segment.fromY*coreVoxelMetres, + segment.toX*coreVoxelMetres,segment.toY*coreVoxelMetres); + } + } + std::printf("[WorldAssemblerRouteCandidateEnvelope] max_chord_m=%.3f length_variants=%zu center_step_m=%.3f arrival_radius_m=%.3f departure_radius_m=%.3f\n", + 2.0*candidateHalfLengths.front()*coreVoxelMetres,candidateHalfLengths.size(), + candidateCenterSpacing*coreVoxelMetres, + arrivalRadiusSubvoxels*coreVoxelMetres/WorldAssemblySubvoxelsPerVoxel, + departureRadiusSubvoxels*coreVoxelMetres/WorldAssemblySubvoxelsPerVoxel); + for(const std::int32_t candidateHalfLength:candidateHalfLengths) + for(std::int32_t centerY=-256;centerY<=256;centerY+=candidateCenterSpacing) + for(std::int32_t centerX=-256;centerX<=256;centerX+=candidateCenterSpacing) for(std::int32_t directionIndex=0;directionIndex(directions.size());++directionIndex) { Candidate candidate; @@ -95,22 +210,36 @@ namespace StandaloneWorldAssemblyPrototype candidate.points.reserve(33); std::int64_t previousGround=0,previousX=0,previousY=0; bool gradePass=true,placementPass=true; + std::array candidateGradeSegments{}; for(std::int32_t index=0;index<=32;++index) { - const std::int64_t along=(index-16)*waypointSpacing; + const std::int64_t along=(index-16)*candidateHalfLength/16; const std::int64_t x=centerX+std::llround( static_cast(along)*directions[directionIndex].x/65536.0); const std::int64_t y=centerY+std::llround( static_cast(along)*directions[directionIndex].y/65536.0); const std::int64_t xQ=WorldAssemblyFixed(static_cast(x)); const std::int64_t yQ=WorldAssemblyFixed(static_cast(y)); + if(arrivalRadiusSubvoxels>0&&departureRadiusSubvoxels>0 + &&(index==0||index==32)) + { + const std::uint64_t radiusSquared=static_cast( + xQ*xQ+yQ*yQ); + candidate.endpointProximityCost+=radiusSquared*(index==0?16ull:1ull); + } const auto inRadius=[](std::int64_t xValue,std::int64_t yValue,std::int64_t radius) {return radius<=0||xValue*xValue+yValue*yValue<=radius*radius;}; if((index==0&&!inRadius(xQ,yQ,arrivalRadiusSubvoxels)) ||(index==32&&!inRadius(xQ,yQ,departureRadiusSubvoxels))) - {placementPass=false;break;} + placementPass=false; const auto column=finalField.point(WorldAssemblyFixed(static_cast(x)), WorldAssemblyFixed(static_cast(y)),0); + const double metresPerSubvoxel=static_cast(coreVoxelSizeMicrons) + /(WorldAssemblySubvoxelsPerVoxel*1000000.0); + const double groundMetres=static_cast(column.groundHeightSubvoxels) + *metresPerSubvoxel; + minimumGround=std::min(minimumGround,groundMetres); + maximumGround=std::max(maximumGround,groundMetres); const std::int64_t horizontalQ=static_cast(std::llround( std::hypot(static_cast(x-previousX),static_cast(y-previousY)) *WorldAssemblySubvoxelsPerVoxel)); @@ -118,7 +247,23 @@ namespace StandaloneWorldAssemblyPrototype { const std::int64_t riseQ=std::abs(column.groundHeightSubvoxels-previousGround); if(horizontalQ==0||riseQ*65536>horizontalQ*GradeQ16) - {gradePass=false;break;} + { + gradePass=false; + GradeSegmentAudit& segment=candidateGradeSegments[ + static_cast(index-1)]; + ++segment.failures; + const double grade=horizontalQ>0 + ?static_cast(riseQ)/static_cast(horizontalQ) + :std::numeric_limits::infinity(); + if(grade>segment.maximumGrade) + { + segment.maximumGrade=grade; + segment.fromGround=previousGround; + segment.toGround=column.groundHeightSubvoxels; + segment.fromX=previousX;segment.fromY=previousY; + segment.toX=x;segment.toY=y; + } + } candidate.score+=riseQ*65536/std::max(1,horizontalQ); } WorldAssemblyRoutePointV1 point; @@ -131,13 +276,60 @@ namespace StandaloneWorldAssemblyPrototype candidate.points.push_back(point); previousGround=column.groundHeightSubvoxels;previousX=x;previousY=y; } + candidate.score/=32; ++tested; + if(!gradePass)++gradeFailCandidates; if(placementPass&&gradePass)candidates.push_back(std::move(candidate)); else if(!placementPass)++placementRejected; - else ++gradeRejected; + else + { + ++gradeRejected; + for(std::size_t segmentIndex=0;segmentIndexaggregate.maximumGrade) + { + aggregate.maximumGrade=rejected.maximumGrade; + aggregate.fromGround=rejected.fromGround; + aggregate.toGround=rejected.toGround; + aggregate.fromX=rejected.fromX;aggregate.fromY=rejected.fromY; + aggregate.toX=rejected.toX;aggregate.toY=rejected.toY; + } + } + } + } + if(gradeRejected>0||placementRejected>0) + { + const double metresPerSubvoxel=static_cast(coreVoxelSizeMicrons) + /(WorldAssemblySubvoxelsPerVoxel*1000000.0); + std::printf("[WorldAssemblerRouteGradeProfile] tested=%llu grade_fail_candidates=%llu grade_rejected_after_placement=%llu placement_rejected=%llu ground_range_m=%.3f..%.3f grade_limit_deg=15.000\n", + static_cast(tested), + static_cast(gradeFailCandidates), + static_cast(gradeRejected), + static_cast(placementRejected),minimumGround,maximumGround); + for(std::size_t segmentIndex=0;segmentIndex%.3f xy_m=(%.3f,%.3f)->(%.3f,%.3f)\n", + segmentIndex,static_cast(segment.failures),gradeDegrees, + segment.fromGround*metresPerSubvoxel,segment.toGround*metresPerSubvoxel, + segment.fromX*coreVoxelSizeMicrons/1000000.0, + segment.fromY*coreVoxelSizeMicrons/1000000.0, + segment.toX*coreVoxelSizeMicrons/1000000.0, + segment.toY*coreVoxelSizeMicrons/1000000.0); + } } std::stable_sort(candidates.begin(),candidates.end(),[](const Candidate& a,const Candidate& b) { + if(a.endpointProximityCost!=b.endpointProximityCost) + return a.endpointProximityCost0) std::printf("[WorldAssemblerRouteRepaired] uniform_lift_q=%lld support_limit_q=%lld\n", static_cast(liftQ),static_cast(SupportDepthQ)); + const double metresPerSubvoxel=coreVoxelMetres + /WorldAssemblySubvoxelsPerVoxel; + std::printf("[WorldAssemblerRouteAccepted] arrival_xy_m=(%.3f,%.3f) departure_xy_m=(%.3f,%.3f) endpoint_proximity_cost=%llu average_grade_q16=%lld\n", + candidate.points.front().positionSubvoxels[0]*metresPerSubvoxel, + candidate.points.front().positionSubvoxels[1]*metresPerSubvoxel, + candidate.points.back().positionSubvoxels[0]*metresPerSubvoxel, + candidate.points.back().positionSubvoxels[1]*metresPerSubvoxel, + static_cast(candidate.endpointProximityCost), + static_cast(candidate.score)); return true; } if(audit.blockedCapsuleSamples==0||liftQ>=MaximumRepairLiftQ)break; @@ -410,7 +611,8 @@ namespace StandaloneWorldAssemblyPrototype } std::uint64_t routeCandidates=0,routeGradeRejected=0,routePlacementRejected=0,routeSweepRejected=0; if(!BuildFiniteStrateRoute(recipe,stratum,routeCandidates,routeGradeRejected, - routePlacementRejected,routeSweepRejected,arrivalRadiusQ,departureRadiusQ,error))return false; + routePlacementRejected,routeSweepRejected,owner.CoreVoxelSizeMicrons, + arrivalRadiusQ,departureRadiusQ,error))return false; std::printf("[WorldAssemblerRoutePlan] candidates=%llu grade_rejected=%llu placement_rejected=%llu sweep_rejected=%llu accepted_points=%zu capsule_m=0.34x1.76 corridor_radius_vox=4 support_probe_points=5 minimum_support_probes=%u arrival_radius_m=%.3f departure_radius_m=%.3f status=PASS\n", static_cast(routeCandidates), static_cast(routeGradeRejected),