fix: close STEP-075 projection identity audit
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled

This commit is contained in:
OpenAI Codex
2026-09-13 14:10:32 +08:00
parent 3616daec30
commit 90d05f596a
6 changed files with 132 additions and 23 deletions

View File

@@ -0,0 +1,41 @@
# STEP-075 — QBIN2 V4 Binding 与 Projection Identity v2
状态:完成
日期:2026-09-13
## 目标与基线
- 基于 STEP-075 v1(`3616daec30079395847cc6a538fbd963641b7e12`)完成独立审计收口。
- 仅修改 provider-neutral Core Binding/Projection、其离线单元测试与本 Step 文档;没有进入 STEP-076。
## 必做项
- [x] 新增 `ProjectionFrameCoordinate`,只以 `fieldID + validTimeMs` 表达 T0/T1 window coordinate;完整 `FrameContentIdentity` 仍保留在 resolved frame 中作 revision/runtime 诊断。
- [x] Color 和 Particle identity 均统一比较 binding、T0/T1 coordinate、各自 selected plane content identity 及 T0/T1 shared validity content identity。
- [x] 移除 Resolver 公共 `InterpolationIncompatible` 错误码;`FieldSeries::submit` 已拒绝不兼容相邻 frame,`resolve` 只返回已验证 window。
- [x] 通过 Store 的 same-valid-time `ReplacedRevision` 测试 Scalar-only、U/V-only、validity-only revision:分别为 Color-only change、Particle-only change、两者均 change。
- [x] 保持各 binding 独立 fieldKey/cadence、MIX 不进入 identity、in-window storage stable,以及 DirectionAngle success/failure coverage。
## 允许修改
- `include/mbgl/particle/field/field_binding.hpp`
- `src/mbgl/particle/field/field_binding.cpp`
- `test/particle/field_binding.test.cpp`
- 本 Step v2、完成报告 v2 与 `粒子开发记录.md`
## 禁止修改
- 未修改 Decoder、FieldSeries、Profile Validator、Renderer、Host/JNI/RN、Android/iOS 或网络数据路径。
- 未执行 Android Gradle/APK/ADB/Jest、iOS/Metal、Renderer/GPU 或真实 QBIN Golden 数据。
## 验证与当前结果
- Linux OpenGL `cmake --build build-linux-opengl --target mbgl-test-runner -j3` 真实重建 PASS(输出节流)。
- 规定 GTest XML `/tmp/step075-gtest.xml`:50 tests、0 failures、0 errors。Binding 4;Runtime/Profile 11;QBIN Decoder/Ingress 24;V3 Source 11。
- `git diff --check` PASS。
## 阻塞与下一步
- 无实现阻塞。Android/Jest/iOS/Metal 等禁止范围没有验证,不能视为 PASS。
- STEP-075 已关闭;按任务书停止,不进入 STEP-076。

View File

@@ -0,0 +1,25 @@
# STEP-075 QBIN2 V4 Binding 与 Projection Identity Codex 完成报告 v2
日期:2026-09-13
## 已完成
- Projection content identity 改为显式 `ProjectionFrameCoordinate(fieldID, validTimeMs)` 加上 binding 实际消费的 T0/T1 selected plane 和 shared validity content identity。完整 generation/dataVersion 不再污染任一 projection identity。
- 对称 revision 隔离已由 Store 的 `ReplacedRevision` 公共路径验证:Scalar-only 仅改变 Color,U/V-only 仅改变 Particle,validity-only 同时改变 Color 与 Particle。
- `InterpolationIncompatible` 已从 Resolver 公共错误语义移除;FieldSeries submit 已 fail-closed 拒绝不兼容相邻 frame,合法公开 resolve 路径不可形成该状态。
- 独立 fieldKey/cadence、MIX identity/storage stability、DirectionAngle success/failure 与既有 V4/V3 回归均保持。
## 验证结果
- Linux OpenGL `mbgl-test-runner` 真实重建 PASS(输出节流)。
- 规定 GTest XML:50 tests、0 failures、0 errors;Binding 4、Runtime/Profile 11、QBIN Decoder/Ingress 24、V3 Source 11。
- `git diff --check` PASS。
## 未完成或无法验证
- Android Gradle/APK/ADB/Jest、iOS/Metal、Renderer/GPU、Host/JNI/RN、网络和真实 QBIN Golden 数据均按任务禁止或不在范围内,未执行且不能标记 PASS。
- 无已知实现阻塞;本任务不进入 STEP-076。
## Git
- 本报告随本 Step scoped commit 提交;提交与 `origin/particle/main` push 结果在最终 Git 回填提交中记录。

View File

@@ -37,10 +37,20 @@ struct FieldSampleState {
float mix = 0.0f;
};
// The stable coordinate of a FieldSeries interpolation endpoint. Revisions
// are deliberately excluded: projection identities add only the content that
// their binding actually consumes.
struct ProjectionFrameCoordinate {
std::string fieldID;
uint64_t validTimeMs = 0;
bool operator==(const ProjectionFrameCoordinate&) const;
};
struct ColorProjectionContentIdentity {
ColorBinding binding;
FrameContentIdentity t0Frame;
FrameContentIdentity t1Frame;
ProjectionFrameCoordinate t0Coordinate;
ProjectionFrameCoordinate t1Coordinate;
std::string t0Plane;
std::string t1Plane;
std::string t0Validity;
@@ -51,8 +61,8 @@ struct ColorProjectionContentIdentity {
struct ParticleProjectionContentIdentity {
ParticleBinding binding;
FrameContentIdentity t0Frame;
FrameContentIdentity t1Frame;
ProjectionFrameCoordinate t0Coordinate;
ProjectionFrameCoordinate t1Coordinate;
std::string t0FirstPlane;
std::string t1FirstPlane;
std::string t0SecondPlane;
@@ -97,7 +107,6 @@ enum class BindingProjectionError : uint8_t {
PlaneUnitMismatch,
PlaneComponentMismatch,
PlaneGroupMismatch,
InterpolationIncompatible,
};
struct ColorProjectionResult {

View File

@@ -11,6 +11,9 @@ bool validKey(const FieldPlaneKey& key) { return key.semanticID != 0 && validCom
bool sameSelector(const FieldPlaneKey& a, const FieldPlaneKey& b) {
return a.semanticID == b.semanticID && a.groupID == b.groupID;
}
ProjectionFrameCoordinate coordinate(const DecodedFieldFrame& frame) {
return {frame.frameContentIdentity.fieldID, frame.frameContentIdentity.validTimeMs};
}
const DecodedFieldPlane* findPlane(const DecodedFieldFrame& frame, const FieldPlaneKey& selector) {
for (const auto& plane : frame.planes) if (sameSelector(plane.key, selector)) return &plane;
return nullptr;
@@ -50,7 +53,9 @@ BindingProjectionError validateSelected(const DecodedFieldPlane* t0,
if (t0->unitID != unit || t1->unitID != unit) return BindingProjectionError::PlaneUnitMismatch;
if (t0->key.component != component || t1->key.component != component)
return BindingProjectionError::PlaneComponentMismatch;
if (!t0->isInterpolationCompatibleWith(*t1)) return BindingProjectionError::InterpolationIncompatible;
// FieldSeries::submit rejects incompatible neighbours, and resolve only
// returns a validated window. Resolver has no public incompatible-window
// state, so do not expose an unreachable error code here.
return BindingProjectionError::Accepted;
}
@@ -65,21 +70,16 @@ bool ParticleBinding::operator==(const ParticleBinding& other) const {
sameSelector(vPlane, other.vPlane) && sameSelector(directionPlane, other.directionPlane) &&
expectedUnitID == other.expectedUnitID;
}
bool ProjectionFrameCoordinate::operator==(const ProjectionFrameCoordinate& other) const {
return fieldID == other.fieldID && validTimeMs == other.validTimeMs;
}
bool ColorProjectionContentIdentity::operator==(const ColorProjectionContentIdentity& other) const {
return binding == other.binding && t0Frame == other.t0Frame && t1Frame == other.t1Frame &&
return binding == other.binding && t0Coordinate == other.t0Coordinate && t1Coordinate == other.t1Coordinate &&
t0Plane == other.t0Plane && t1Plane == other.t1Plane && t0Validity == other.t0Validity &&
t1Validity == other.t1Validity;
}
bool ParticleProjectionContentIdentity::operator==(const ParticleProjectionContentIdentity& other) const {
// A FieldFrame revision can carry an unrelated plane update. Projection
// identity therefore uses the selected frame time/field as its window
// coordinate; selected plane and validity content identities carry the
// revision-sensitive content part. This preserves a Particle projection
// across a scalar-only revision in the same frame window.
const auto sameWindowFrame = [](const FrameContentIdentity& a, const FrameContentIdentity& b) {
return a.fieldID == b.fieldID && a.validTimeMs == b.validTimeMs;
};
return binding == other.binding && sameWindowFrame(t0Frame, other.t0Frame) && sameWindowFrame(t1Frame, other.t1Frame) &&
return binding == other.binding && t0Coordinate == other.t0Coordinate && t1Coordinate == other.t1Coordinate &&
t0FirstPlane == other.t0FirstPlane && t1FirstPlane == other.t1FirstPlane &&
t0SecondPlane == other.t0SecondPlane && t1SecondPlane == other.t1SecondPlane &&
t0Validity == other.t0Validity && t1Validity == other.t1Validity;
@@ -98,7 +98,7 @@ ColorProjectionResult BindingProjectionResolver::resolve(const EncodedFieldStore
t0 ? t0->key.component : FieldComponent::None);
error != BindingProjectionError::Accepted)
return {error, {}};
ColorProjection projection{{binding, window->t0->frameContentIdentity, window->t1->frameContentIdentity,
ColorProjection projection{{binding, coordinate(*window->t0), coordinate(*window->t1),
t0->contentIdentity, t1->contentIdentity, window->t0->validityContentIdentity,
window->t1->validityContentIdentity},
{requested, window->mix}, window->t0, window->t1, t0->storage, t1->storage,
@@ -135,7 +135,7 @@ ParticleProjectionResult BindingProjectionResolver::resolve(const EncodedFieldSt
if (t0First->key.groupID != t0Second->key.groupID || t1First->key.groupID != t1Second->key.groupID)
return {BindingProjectionError::PlaneGroupMismatch, {}};
}
ParticleProjection projection{{binding, window->t0->frameContentIdentity, window->t1->frameContentIdentity,
ParticleProjection projection{{binding, coordinate(*window->t0), coordinate(*window->t1),
t0First->contentIdentity, t1First->contentIdentity,
t0Second ? t0Second->contentIdentity : std::string{},
t1Second ? t1Second->contentIdentity : std::string{},

View File

@@ -73,10 +73,10 @@ TEST(ParticleFieldBinding, ResolvesIndependentFieldKeyCadence) {
ASSERT_TRUE(c.accepted()); ASSERT_TRUE(p.accepted());
EXPECT_FLOAT_EQ(c.projection->sampleState.mix, 0.5f);
EXPECT_FLOAT_EQ(p.projection->sampleState.mix, 0.5f);
EXPECT_EQ(c.projection->contentIdentity.t0Frame.validTimeMs, Base);
EXPECT_EQ(c.projection->contentIdentity.t1Frame.validTimeMs, Base + 3 * Hour);
EXPECT_EQ(p.projection->contentIdentity.t0Frame.validTimeMs, Base + Hour);
EXPECT_EQ(p.projection->contentIdentity.t1Frame.validTimeMs, Base + 2 * Hour);
EXPECT_EQ(c.projection->contentIdentity.t0Coordinate.validTimeMs, Base);
EXPECT_EQ(c.projection->contentIdentity.t1Coordinate.validTimeMs, Base + 3 * Hour);
EXPECT_EQ(p.projection->contentIdentity.t0Coordinate.validTimeMs, Base + Hour);
EXPECT_EQ(p.projection->contentIdentity.t1Coordinate.validTimeMs, Base + 2 * Hour);
EXPECT_NE(c.projection->t0Frame, p.projection->t0Frame);
EXPECT_NE(c.projection->t0ValidityStorage, p.projection->t0ValidityStorage);
}
@@ -98,7 +98,7 @@ TEST(ParticleFieldBinding, MixChangesSampleStateButNotContentIdentityOrStorage)
EXPECT_EQ(at0.projection->t0PlaneStorage, at3.projection->t0PlaneStorage);
}
TEST(ParticleFieldBinding, ScalarRevisionChangesOnlyColorIdentity) {
TEST(ParticleFieldBinding, ProjectionIdentityIsolatedBySelectedRevision) {
particle::EncodedFieldStore store;
submit(store, "weather.wind", makeFrame("wind", Base, 1, "0"));
submit(store, "weather.wind", makeFrame("wind", Base + Hour, 1, "1"));
@@ -118,6 +118,34 @@ TEST(ParticleFieldBinding, ScalarRevisionChangesOnlyColorIdentity) {
EXPECT_EQ(beforeParticle.projection->contentIdentity, afterParticle.projection->contentIdentity);
EXPECT_EQ(beforeParticle.projection->t0FirstPlaneStorage, afterParticle.projection->t0FirstPlaneStorage);
EXPECT_EQ(beforeParticle.projection->t0ValidityStorage, afterParticle.projection->t0ValidityStorage);
auto vectorRevision = *afterParticle.projection->t0Frame;
vectorRevision.frameContentIdentity.generation = 3;
vectorRevision.frameContentIdentity.dataVersion = 3;
vectorRevision.planes[0].contentIdentity = "u-revised";
vectorRevision.planes[1].contentIdentity = "v-revised";
ASSERT_EQ(store.submit("weather.wind", std::make_shared<const particle::DecodedFieldFrame>(std::move(vectorRevision))),
particle::FieldSeriesSubmitResult::ReplacedRevision);
const auto afterVectorColor = particle::BindingProjectionResolver::resolve(store, color("weather.wind"), Base + Hour / 2);
const auto afterVectorParticle = particle::BindingProjectionResolver::resolve(store, wind(), Base + Hour / 2);
ASSERT_TRUE(afterVectorColor.accepted()); ASSERT_TRUE(afterVectorParticle.accepted());
EXPECT_EQ(afterColor.projection->contentIdentity, afterVectorColor.projection->contentIdentity);
EXPECT_NE(afterParticle.projection->contentIdentity, afterVectorParticle.projection->contentIdentity);
auto validityRevision = *afterVectorParticle.projection->t0Frame;
validityRevision.frameContentIdentity.generation = 4;
validityRevision.frameContentIdentity.dataVersion = 4;
validityRevision.validityContentIdentity = "valid-revised";
validityRevision.validityBits = std::make_shared<const std::vector<uint8_t>>(std::vector<uint8_t>{14});
validityRevision.validityPlane.contentIdentity = validityRevision.validityContentIdentity;
validityRevision.validityPlane.storage = validityRevision.validityBits;
ASSERT_EQ(store.submit("weather.wind", std::make_shared<const particle::DecodedFieldFrame>(std::move(validityRevision))),
particle::FieldSeriesSubmitResult::ReplacedRevision);
const auto afterValidityColor = particle::BindingProjectionResolver::resolve(store, color("weather.wind"), Base + Hour / 2);
const auto afterValidityParticle = particle::BindingProjectionResolver::resolve(store, wind(), Base + Hour / 2);
ASSERT_TRUE(afterValidityColor.accepted()); ASSERT_TRUE(afterValidityParticle.accepted());
EXPECT_NE(afterVectorColor.projection->contentIdentity, afterValidityColor.projection->contentIdentity);
EXPECT_NE(afterVectorParticle.projection->contentIdentity, afterValidityParticle.projection->contentIdentity);
}
TEST(ParticleFieldBinding, FailsClosedAndSupportsDirectionAngle) {

View File

@@ -740,3 +740,9 @@ Android V3-08 单次综合验收已完成并关闭。Native `13.4.1-particle.2`
- 新增 provider-neutral Color/Particle Binding 与 Resolver;每个 binding 按自己的 fieldKey 解析 FieldSeries Window,SampleState 单独保存 requested time/mix,Content Identity 不包含 mix 或视觉/播放状态。
- Vector U/V 与 DirectionAngle 都严格按 selected plane assertion fail-closed;Scalar-only revision 保持 Particle U/V/validity storage 与 identity 不变。
- Linux runner 真实重建、规定 Core filter 50/50 PASS、`git diff --check` PASS;未执行 Android Gradle/APK/ADB/Jest/iOS/Metal,未进入 Renderer/GPU/Host/JNI/RN,STEP-075 关闭并停止。
### 2026-09-13 STEP-075 v2 Binding/Projection Identity 审计收口
- Color 与 Particle identity 统一以 `ProjectionFrameCoordinate(fieldID, validTimeMs)` 定位 T0/T1 window,只纳入各 binding 实际 selected plane 与 shared validity content identity;无关 plane 的 generation/dataVersion revision 不再污染另一 projection。
- Store public-path `ReplacedRevision` 对称覆盖:Scalar-only 仅改变 Color、U/V-only 仅改变 Particle、validity-only 同时改变两者;MIX 仍不进入 identity,in-window storage 仍稳定。
- Resolver 删除不可达 `InterpolationIncompatible`;相邻 frame compatibility 继续由既有 `FieldSeries::submit` fail-closed 保证。真实 Linux runner rebuild、规定过滤 50/50、`git diff --check` 全部 PASS;未执行 Android Gradle/APK/ADB/Jest,按范围关闭并停止,不进入 STEP-076。