diff --git a/docs/50_Step记录/STEP-073_QBIN2_V4_Core与通用FieldProfileValidator_v2.md b/docs/50_Step记录/STEP-073_QBIN2_V4_Core与通用FieldProfileValidator_v2.md new file mode 100644 index 000000000..b481d9e19 --- /dev/null +++ b/docs/50_Step记录/STEP-073_QBIN2_V4_Core与通用FieldProfileValidator_v2.md @@ -0,0 +1,40 @@ +# STEP-073 — QBIN2 V4 Core 与通用 Field Profile Validator 审计修正 v2 + +状态:完成 + +日期:2026-09-13 + +## 目标 + +仅修正 STEP-073 v1 审计确认的 QBIN2 V4 Core/通用 Validator 三项问题:DEGREE Unit ID、Plane `(semanticID, groupID)` 唯一性与 ForecastClock HALF tick 舍入漂移。 + +## 完成项 + +- [x] `FieldUnitDegrees` 改为冻结 QBIN V2 registry 的 numeric ID `4`;`NONE=0`、`MPS=1` 未变。Direction `unitID=4` 可接受,`unitID=2` 返回 `InvalidCoreUnit`。 +- [x] 实际 Frame 与 Contract `requiredPlanes[]` 均以 `(semanticID, groupID)` 判重;component 改为 descriptor 属性。semantic/group 已定位但 component 不同返回 `PlaneDescriptorMismatch`。 +- [x] ForecastClock 以整数 half-millisecond remainder 累积,HALF 不再逐 tick 向上取整;seek 清除 remainder,溢出 fail-closed。 +- [x] U/V 继续使用各自 semanticID,并按既有通用 U_EAST/V_NORTH velocity group 规则通过。 +- [x] 新增对应 C++ 自动化覆盖并完成 Linux 定向验证与 V3 回归。 + +## 允许修改 + +仅修改 `include/mbgl/particle/field/`、`src/mbgl/particle/field/`、`test/particle/field_runtime.test.cpp`、本 Step、开发记录及完成报告。 + +## 禁止修改确认 + +未修改 Renderer、shader、`platform/android/`、RN/package/integration、现有 V3 ingress/timeline 生产路径;未实现 QBIN2 Decoder、Binding/Projection、JNI 或 Android/RN 接入。 + +## 验证 + +所有构建/测试均经 `build-output-throttle` 的 `run_limited.py` 执行: + +- `cmake --build build-linux-opengl --target mbgl-test -j3`:PASS。 +- `build-linux-opengl/mbgl-test-runner --gtest_filter='ParticleFieldRuntime.*:ParticleFieldProfileValidator.*'`:PASS。 +- `build-linux-opengl/mbgl-test-runner --gtest_filter='Source.ParticleFieldDataAdapter*:Source.RasterArrayTimeline*:Source.HostField*'`:PASS。 +- `git diff --check`:PASS。 + +未执行 Android Gradle、APK、ADB、真机操作或 Jest;未执行 iOS/Metal 验证。 + +## 阻塞与下一步 + +本审计修正无阻塞。后续工作须另行批准;本 STEP 在此停止,不进入 QBIN2 Decoder 或任一接入阶段。 diff --git a/docs/报告/STEP-073_QBIN2_V4_Core与通用FieldProfileValidator_Codex完成报告_v2.md b/docs/报告/STEP-073_QBIN2_V4_Core与通用FieldProfileValidator_Codex完成报告_v2.md new file mode 100644 index 000000000..f46c41493 --- /dev/null +++ b/docs/报告/STEP-073_QBIN2_V4_Core与通用FieldProfileValidator_Codex完成报告_v2.md @@ -0,0 +1,34 @@ +# STEP-073 QBIN2 V4 Core 与通用 Field Profile Validator 审计修正完成报告 v2 + +## 结论 + +**已完成 / PASS。** 本次仅修正 STEP-073 v1 的三项确定审计问题,并补充 C++ 覆盖;没有实现 QBIN Decoder,未接入 Renderer、JNI、Android 或 RN,现有 V3 `submitField(...)` 与 timeline 生产路径保持不变。 + +## 审计修复结果 + +1. **DEGREE Core Unit ID**:`FieldUnitDegrees` 已由错误的 `2` 改为 QBIN V2 registry 冻结值 `4`。测试同时确认 `NONE=0`、`MPS=1` 保持不变;Direction Angle 的 `unitID=4` 被接受,`unitID=2` 得到 `InvalidCoreUnit`。 +2. **Plane 唯一键**:Frame plane 与 Contract required plane 均按 `(semanticID, groupID)` 唯一。component 不再参与定位键,作为 descriptor 的 valueClass/unitID/component/requiredFlag 比较项;同 semantic/group、不同 component 的 Frame 或 Contract 均结构非法;已定位 plane 的 component 错误返回 `PlaneDescriptorMismatch`。既有 U/V 速度组仍以各自 semanticID 配合 U_EAST/V_NORTH 规则通过。 +3. **ForecastClock HALF 漂移**:时钟在内部保存 half-millisecond remainder,避免每次 HALF tick 独立四舍五入。测试确认连续两次 `advance(1)` 只前进 1ms、多 tick 不发生 2x 漂移、seek 清除旧 remainder、NORMAL/DOUBLE 行为不变、pause 不前进并在 overflow 时 fail-closed。 + +## Linux 验证 + +构建和测试全部经 `build-output-throttle` 节流执行: + +- `cmake --build build-linux-opengl --target mbgl-test -j3`:PASS。 +- `build-linux-opengl/mbgl-test-runner --gtest_filter='ParticleFieldRuntime.*:ParticleFieldProfileValidator.*'`:PASS。 +- `build-linux-opengl/mbgl-test-runner --gtest_filter='Source.ParticleFieldDataAdapter*:Source.RasterArrayTimeline*:Source.HostField*'`:PASS。 +- `git diff --check`:PASS。 + +首次 build 因沙箱禁止 ccache 写入 `/root/.cache` 而未开始编译;经授权以相同节流命令重试后通过,不影响上述结论。 + +## Git 收尾 + +- 任务开始 HEAD:`c3033eb5a4560e5778dcae7f341965caca4f570c`。 +- 本次实现提交:`fix: correct STEP-073 QBIN2 V4 core contract`。 +- Final HEAD 与 push 结果将在本报告随 scoped commit 提交后记录于 Git 历史;仅本任务文件会被暂存。 +- 任务前已有 modified/untracked 文件(包括 STEP-062 文档、历史任务文档、构建日志与 RN 目录)未暂存、未删除、未提交。 + +## 未执行 / 未完成 + +- 未执行 Android Gradle、APK、ADB、真机操作或 Jest;未执行 iOS/Metal 验证。 +- 未完成 QBIN2 Encoded Ingress/Decoder、Binding/Projection、JNI、Renderer 或 RN 接入,均在本任务禁止范围内。 diff --git a/include/mbgl/particle/field/field_profile_contract.hpp b/include/mbgl/particle/field/field_profile_contract.hpp index 7dc628474..6b2b4f2ae 100644 --- a/include/mbgl/particle/field/field_profile_contract.hpp +++ b/include/mbgl/particle/field/field_profile_contract.hpp @@ -30,7 +30,7 @@ enum class FieldTimeRelation : uint8_t { // deliberately remain opaque to the Native runtime. constexpr uint16_t FieldUnitNone = 0; constexpr uint16_t FieldUnitMetersPerSecond = 1; -constexpr uint16_t FieldUnitDegrees = 2; +constexpr uint16_t FieldUnitDegrees = 4; struct FieldPlaneRequirement { uint16_t semanticID = 0; diff --git a/include/mbgl/particle/field/field_runtime.hpp b/include/mbgl/particle/field/field_runtime.hpp index 7095043e9..35305460d 100644 --- a/include/mbgl/particle/field/field_runtime.hpp +++ b/include/mbgl/particle/field/field_runtime.hpp @@ -34,6 +34,8 @@ struct FrameContentIdentity { struct FieldPlaneKey { uint16_t semanticID = 0; uint16_t groupID = 0; + // Component is a plane descriptor property, not part of the QBIN V2 + // plane identity. A Field may contain only one (semanticID, groupID). FieldComponent component = FieldComponent::None; bool operator==(const FieldPlaneKey&) const; @@ -139,6 +141,9 @@ public: private: uint64_t requestedForecastTimeMs_ = 0; + // The supported playback rates are exact multiples of half a millisecond. + // Keep the sub-millisecond half-unit so short HALF ticks do not round up. + uint8_t fractionalHalfMillisecond_ = 0; float playbackSpeed_ = 1.0f; ForecastPlaybackState state_ = ForecastPlaybackState::Paused; }; diff --git a/src/mbgl/particle/field/field_profile_validator.cpp b/src/mbgl/particle/field/field_profile_validator.cpp index 0a315bcfc..598399c1a 100644 --- a/src/mbgl/particle/field/field_profile_validator.cpp +++ b/src/mbgl/particle/field/field_profile_validator.cpp @@ -9,13 +9,13 @@ namespace { using PlaneRef = const DecodedFieldPlane*; bool sameKey(const FieldPlaneRequirement& requirement, const DecodedFieldPlane& plane) { - return requirement.semanticID == plane.key.semanticID && requirement.groupID == plane.key.groupID && - requirement.component == plane.key.component; + return requirement.semanticID == plane.key.semanticID && requirement.groupID == plane.key.groupID; } bool matches(const FieldPlaneRequirement& requirement, const DecodedFieldPlane& plane) { return sameKey(requirement, plane) && requirement.valueClass == plane.valueClass && - requirement.unitID == plane.unitID && requirement.requiredFlag == plane.requiredFlag; + requirement.unitID == plane.unitID && requirement.component == plane.key.component && + requirement.requiredFlag == plane.requiredFlag; } FieldProfileValidationResult result(const FieldProfileValidationCode code, const std::string& detail) { diff --git a/src/mbgl/particle/field/field_runtime.cpp b/src/mbgl/particle/field/field_runtime.cpp index ce8828700..020720bf4 100644 --- a/src/mbgl/particle/field/field_runtime.cpp +++ b/src/mbgl/particle/field/field_runtime.cpp @@ -4,7 +4,6 @@ #include #include #include -#include namespace mbgl::particle { namespace { @@ -24,7 +23,7 @@ bool newerRevision(const FrameContentIdentity& next, const FrameContentIdentity& bool samePlaneSchema(const DecodedFieldPlane& a, const DecodedFieldPlane& b) { return a.key == b.key && a.dtype == b.dtype && a.valueClass == b.valueClass && a.unitID == b.unitID && - a.count == b.count && a.scale == b.scale && a.offset == b.offset; + a.key.component == b.key.component && a.count == b.count && a.scale == b.scale && a.offset == b.offset; } bool validValueClass(const FieldValueClass value) { @@ -45,11 +44,11 @@ bool ResolvedFieldProfileContract::isStructurallyValid() const { if (contractSchemaVersion != 1 || !validProfileID(profileID) || profileVersion == 0 || requiredPlanes.empty() || timeRelation > FieldTimeRelation::ValidEqReference) return false; - std::set> keys; + std::set> keys; for (const auto& plane : requiredPlanes) { if (!plane.requiredFlag || plane.semanticID == 0 || !validValueClass(plane.valueClass) || !validComponent(plane.component)) return false; - const auto key = std::make_tuple(plane.semanticID, plane.groupID, static_cast(plane.component)); + const auto key = std::make_pair(plane.semanticID, plane.groupID); if (!keys.insert(key).second) return false; if (plane.valueClass == FieldValueClass::VelocityComponent && (plane.groupID == 0 || plane.component == FieldComponent::None)) return false; @@ -69,13 +68,12 @@ bool FrameContentIdentity::operator==(const FrameContentIdentity& other) const { } bool FieldPlaneKey::operator==(const FieldPlaneKey& other) const { - return semanticID == other.semanticID && groupID == other.groupID && component == other.component; + return semanticID == other.semanticID && groupID == other.groupID; } bool FieldPlaneKey::operator<(const FieldPlaneKey& other) const { if (semanticID != other.semanticID) return semanticID < other.semanticID; - if (groupID != other.groupID) return groupID < other.groupID; - return static_cast(component) < static_cast(other.component); + return groupID < other.groupID; } bool DecodedFieldPlane::isStructurallyValid(const uint64_t cellCount) const { @@ -192,6 +190,7 @@ std::optional FieldSeries::resolve(const uint64_t requestedFo bool ForecastClock::setRequestedForecastTime(const uint64_t timestampMs) { if (timestampMs == 0) return false; requestedForecastTimeMs_ = timestampMs; + fractionalHalfMillisecond_ = 0; return true; } @@ -203,9 +202,15 @@ bool ForecastClock::setPlaybackSpeed(const float value) { bool ForecastClock::advance(const uint64_t elapsedMs) { if (state_ != ForecastPlaybackState::Playing || requestedForecastTimeMs_ == 0) return false; - const long double delta = static_cast(elapsedMs) * playbackSpeed_; - if (delta > static_cast(std::numeric_limits::max() - requestedForecastTimeMs_)) return false; - requestedForecastTimeMs_ += static_cast(std::llround(delta)); + const uint64_t halfMillisecondsPerElapsedMs = playbackSpeed_ == 0.5f ? 1 : playbackSpeed_ == 1.0f ? 2 : 4; + if (elapsedMs > std::numeric_limits::max() / halfMillisecondsPerElapsedMs) return false; + const uint64_t elapsedHalfMilliseconds = elapsedMs * halfMillisecondsPerElapsedMs; + if (elapsedHalfMilliseconds > std::numeric_limits::max() - fractionalHalfMillisecond_) return false; + const uint64_t accumulatedHalfMilliseconds = elapsedHalfMilliseconds + fractionalHalfMillisecond_; + const uint64_t wholeMilliseconds = accumulatedHalfMilliseconds / 2; + if (wholeMilliseconds > std::numeric_limits::max() - requestedForecastTimeMs_) return false; + requestedForecastTimeMs_ += wholeMilliseconds; + fractionalHalfMillisecond_ = static_cast(accumulatedHalfMilliseconds % 2); return true; } diff --git a/test/particle/field_runtime.test.cpp b/test/particle/field_runtime.test.cpp index 2a6377d9d..46a3a70ee 100644 --- a/test/particle/field_runtime.test.cpp +++ b/test/particle/field_runtime.test.cpp @@ -2,6 +2,7 @@ #include +#include #include #include #include @@ -85,6 +86,9 @@ TEST(ParticleFieldProfileValidator, EnforcesRequiredPlanesAndAdditionalPlanePoli } TEST(ParticleFieldProfileValidator, EnforcesVelocityDirectionAndValidityCoreRules) { + EXPECT_EQ(particle::FieldUnitNone, 0); + EXPECT_EQ(particle::FieldUnitMetersPerSecond, 1); + EXPECT_EQ(particle::FieldUnitDegrees, 4); auto c = contract(); auto f = frame(); const auto bytes = f.planes.front().storage; f.planes.push_back(plane(10, particle::FieldValueClass::VelocityComponent, particle::FieldUnitMetersPerSecond, 3, particle::FieldComponent::UEast, particle::FieldDType::Int16, false, bytes)); @@ -106,8 +110,13 @@ TEST(ParticleFieldProfileValidator, EnforcesVelocityDirectionAndValidityCoreRule f.planes.push_back(plane(12, particle::FieldValueClass::VelocityComponent, particle::FieldUnitMetersPerSecond, 3, particle::FieldComponent::VNorth, particle::FieldDType::Int16, false, bytes)); EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::InvalidVelocityGroup); - f = frame(); f.planes.front().valueClass = particle::FieldValueClass::DirectionAngle; f.planes.front().unitID = 99; - c.requiredPlanes.front().valueClass = particle::FieldValueClass::DirectionAngle; c.requiredPlanes.front().unitID = 99; + f = frame(); f.planes.front().valueClass = particle::FieldValueClass::DirectionAngle; + f.planes.front().unitID = particle::FieldUnitDegrees; + c.requiredPlanes.front().valueClass = particle::FieldValueClass::DirectionAngle; + c.requiredPlanes.front().unitID = particle::FieldUnitDegrees; + EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::Accepted); + f.planes.front().unitID = 2; + c.requiredPlanes.front().unitID = 2; EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::InvalidCoreUnit); c = contract(); f = frame(); f.validityPlane.dtype = particle::FieldDType::UInt8; EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::InvalidCoreUnit); @@ -117,6 +126,25 @@ TEST(ParticleFieldProfileValidator, EnforcesVelocityDirectionAndValidityCoreRule EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::InvalidCoreUnit); } +TEST(ParticleFieldProfileValidator, UsesSemanticAndGroupAsTheOnlyPlaneIdentity) { + auto f = frame(); + auto duplicate = f.planes.front(); + duplicate.key.component = particle::FieldComponent::UEast; + f.planes.push_back(duplicate); + EXPECT_FALSE(f.isStructurallyValid()); + + auto c = contract(); + auto duplicateRequirement = c.requiredPlanes.front(); + duplicateRequirement.component = particle::FieldComponent::UEast; + c.requiredPlanes.push_back(duplicateRequirement); + EXPECT_FALSE(c.isStructurallyValid()); + + c = contract(); + f = frame(); + f.planes.front().key.component = particle::FieldComponent::UEast; + EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::PlaneDescriptorMismatch); +} + TEST(ParticleFieldProfileValidator, AppliesSchemaSharedValidityAndTimeRules) { auto c = contract(); auto f = frame(); c.contractSchemaVersion = 2; EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::UnsupportedContractSchema); @@ -154,3 +182,38 @@ TEST(ParticleFieldRuntime, ForecastClockOwnsRequestedTimeAndPlayback) { EXPECT_EQ(clock.requestedForecastTime(), 1'700'000'001'000ULL); clock.pause(); EXPECT_FALSE(clock.advance(500)); EXPECT_FALSE(clock.setPlaybackSpeed(3.0f)); } + +TEST(ParticleFieldRuntime, ForecastClockPreservesHalfMillisecondRemainder) { + particle::ForecastClock clock; + ASSERT_TRUE(clock.setRequestedForecastTime(1'000)); + ASSERT_TRUE(clock.setPlaybackSpeed(0.5f)); + clock.play(); + EXPECT_TRUE(clock.advance(1)); + EXPECT_EQ(clock.requestedForecastTime(), 1'000); + ASSERT_TRUE(clock.setRequestedForecastTime(2'000)); + EXPECT_TRUE(clock.advance(1)); + EXPECT_EQ(clock.requestedForecastTime(), 2'000); + EXPECT_TRUE(clock.advance(1)); + EXPECT_EQ(clock.requestedForecastTime(), 2'001); + + ASSERT_TRUE(clock.setRequestedForecastTime(1'000)); + EXPECT_TRUE(clock.advance(1)); + EXPECT_EQ(clock.requestedForecastTime(), 1'000); + for (size_t i = 0; i < 8; ++i) EXPECT_TRUE(clock.advance(1)); + EXPECT_EQ(clock.requestedForecastTime(), 1'004); + clock.pause(); + EXPECT_FALSE(clock.advance(10)); + EXPECT_EQ(clock.requestedForecastTime(), 1'004); + + ASSERT_TRUE(clock.setRequestedForecastTime(3'000)); + ASSERT_TRUE(clock.setPlaybackSpeed(1.0f)); + EXPECT_TRUE(clock.advance(3)); + EXPECT_EQ(clock.requestedForecastTime(), 3'003); + ASSERT_TRUE(clock.setPlaybackSpeed(2.0f)); + EXPECT_TRUE(clock.advance(3)); + EXPECT_EQ(clock.requestedForecastTime(), 3'009); + + ASSERT_TRUE(clock.setRequestedForecastTime(std::numeric_limits::max())); + EXPECT_FALSE(clock.advance(1)); + EXPECT_EQ(clock.requestedForecastTime(), std::numeric_limits::max()); +} diff --git a/粒子开发记录.md b/粒子开发记录.md index 8f7a40818..5948e74dc 100644 --- a/粒子开发记录.md +++ b/粒子开发记录.md @@ -691,3 +691,10 @@ Android V3-08 单次综合验收已完成并关闭。Native `13.4.1-particle.2` - 新增 provider-neutral `DecodedFieldFrame` / FieldSeries / ForecastClock primitive 及 Contract Schema v1 的纯数据驱动 Validator;不接入 QBIN decoder、Renderer、V3 生产路径、Android/JNI/RN。 - Validity 采用独立 descriptor 与共享只读 payload;REQUIRED 双向一致、额外 plane、时间关系、共享 validity 能力和 Velocity/Direction/Validity Core 规则均 fail-closed 且结构化返回。 - Linux OpenGL `mbgl-test` 构建、新 Validator/Runtime 定向测试和既有 V3 Field/Timeline 定向回归均 PASS;未执行 Android Gradle/APK/ADB。 + +### 2026-09-13 STEP-073 v2 审计修正 + +- QBIN V2 DEGREE Core Unit ID 由错误 `2` 修正为冻结 registry `4`(NONE=0、MPS=1 保持);Generic Validator 覆盖 Direction `4` 接受、`2` 的 `InvalidCoreUnit` 拒绝。 +- Plane identity 收紧为 `(semanticID, groupID)`;Frame/Contract 同 key 不同 component 均结构非法,已定位但 component 错误返回 `PlaneDescriptorMismatch`,既有 U/V 各 semanticID 的 velocity group 规则不变。 +- ForecastClock 采用 half-millisecond remainder,修复 HALF 连续短 tick 的逐 tick 取整漂移;seek 清残差、pause 与 overflow fail-closed,NORMAL/DOUBLE 保持。 +- Linux OpenGL `mbgl-test`、V4 Validator/Runtime 与 V3 Field/Timeline 定向回归均 PASS;未执行 Android Gradle/APK/ADB/Jest,未进入 QBIN Decoder/JNI/Renderer/RN。