fix: correct STEP-073 QBIN2 V4 core contract
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
This commit is contained in:
@@ -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 或任一接入阶段。
|
||||
@@ -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 接入,均在本任务禁止范围内。
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <set>
|
||||
#include <tuple>
|
||||
|
||||
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<std::tuple<uint16_t, uint16_t, uint8_t>> keys;
|
||||
std::set<std::pair<uint16_t, uint16_t>> 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<uint8_t>(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<uint8_t>(component) < static_cast<uint8_t>(other.component);
|
||||
return groupID < other.groupID;
|
||||
}
|
||||
|
||||
bool DecodedFieldPlane::isStructurallyValid(const uint64_t cellCount) const {
|
||||
@@ -192,6 +190,7 @@ std::optional<FieldSeriesWindow> 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<long double>(elapsedMs) * playbackSpeed_;
|
||||
if (delta > static_cast<long double>(std::numeric_limits<uint64_t>::max() - requestedForecastTimeMs_)) return false;
|
||||
requestedForecastTimeMs_ += static_cast<uint64_t>(std::llround(delta));
|
||||
const uint64_t halfMillisecondsPerElapsedMs = playbackSpeed_ == 0.5f ? 1 : playbackSpeed_ == 1.0f ? 2 : 4;
|
||||
if (elapsedMs > std::numeric_limits<uint64_t>::max() / halfMillisecondsPerElapsedMs) return false;
|
||||
const uint64_t elapsedHalfMilliseconds = elapsedMs * halfMillisecondsPerElapsedMs;
|
||||
if (elapsedHalfMilliseconds > std::numeric_limits<uint64_t>::max() - fractionalHalfMillisecond_) return false;
|
||||
const uint64_t accumulatedHalfMilliseconds = elapsedHalfMilliseconds + fractionalHalfMillisecond_;
|
||||
const uint64_t wholeMilliseconds = accumulatedHalfMilliseconds / 2;
|
||||
if (wholeMilliseconds > std::numeric_limits<uint64_t>::max() - requestedForecastTimeMs_) return false;
|
||||
requestedForecastTimeMs_ += wholeMilliseconds;
|
||||
fractionalHalfMillisecond_ = static_cast<uint8_t>(accumulatedHalfMilliseconds % 2);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <limits>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -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<uint64_t>::max()));
|
||||
EXPECT_FALSE(clock.advance(1));
|
||||
EXPECT_EQ(clock.requestedForecastTime(), std::numeric_limits<uint64_t>::max());
|
||||
}
|
||||
|
||||
@@ -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。
|
||||
|
||||
Reference in New Issue
Block a user