feat: add V4 field binding projection identities
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled

This commit is contained in:
OpenAI Codex
2026-09-13 13:58:26 +08:00
parent 31783f25a8
commit c465b804ce
8 changed files with 512 additions and 0 deletions

View File

@@ -214,6 +214,7 @@ list(APPEND SRC_FILES
${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/offline_fixture_particle_adapter.hpp
${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/field_data_adapter.cpp
${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/field_runtime.cpp
${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/field_binding.cpp
${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/field_profile_validator.cpp
${PROJECT_SOURCE_DIR}/src/mbgl/particle/ingress/qbin_v2_decoder.cpp
${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/local_particle_field_feed.cpp
@@ -225,6 +226,7 @@ list(APPEND INCLUDE_FILES
${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/field_profile_contract.hpp
${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/field_profile_validator.hpp
${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/field_runtime.hpp
${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/field_binding.hpp
${PROJECT_SOURCE_DIR}/include/mbgl/particle/ingress/qbin_v2_decoder.hpp
${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/local_particle_field_feed.hpp
${PROJECT_SOURCE_DIR}/include/mbgl/actor/actor_ref.hpp

View File

@@ -0,0 +1,36 @@
# STEP-075 — QBIN2 V4 Binding 与 Projection Content Identity v1
状态:完成
日期:2026-09-13
## 目标与基线
- 开始 HEAD:`31783f25a8153f48000f19ac7cc2f2113cf47818`。
- 仅实现 provider-neutral Core Binding/Projection;没有接入 Renderer、GPU、JNI、Android、RN、NavSea 或网络。
## 必做项
- [x] 新增独立 `ColorBinding`、`ParticleBinding`、`ColorProjectionContentIdentity`、`ParticleProjectionContentIdentity` 与 `BindingProjectionResolver`。
- [x] Color 可选择 Scalar/Velocity/Direction numeric plane;unit 只作 equality assertion。
- [x] Particle 支持同 fieldKey/window 的 U/V vector 与 DirectionAngle 两种模式;没有定义速度大小或 Renderer 行为。
- [x] 每次 resolve 以 binding 的 fieldKey 独立调用 `EncodedFieldStore::resolve`,保留 Core-owned requested time 与每 field window。
- [x] SampleState 仅保存 requestedForecastTimeMs/mix;content identity 不纳入 mix、播放状态或视觉参数,且保留只读 frame/storage shared_ptr。
- [x] 结构化 fail-closed 返回 InvalidBinding、FieldSeriesMissing、WindowUnavailable、PlaneMissing、Class/Unit/Component/Group mismatch 与 InterpolationIncompatible。
- [x] 测试 wind `00/01/02` 与 wave `00/03/06` 在 `01:30` 使用各自窗口;MIX `0/.25/.5/.99` 保持 identity/storage;Scalar-only revision 仅改变 Color identity;覆盖 Direction 成功及 unit/selector failure。
## 验证
- Linux OpenGL `mbgl-test-runner` 真实重建:PASS(输出节流)。
- 规定过滤组 XML:50 tests、0 failures、0 errors:Binding 4,Runtime/Profile 11,QBIN Decoder/Ingress 24,V3 Source 11。
- `git diff --check`:PASS。
## 边界与未执行
- FieldSeries 在提交阶段已拒绝不可插值相邻 frame;Resolver 仍保留 `InterpolationIncompatible` 结构化保护,正常 Store 无法形成该窗口。
- 未执行 Android Gradle/APK/ADB/Jest、iOS/Metal 或任何 Host/Renderer 验证;它们不属于本 Step,不能视为 PASS。
- 任务开始前既有 STEP-062 dirty、历史 untracked、build logs 与 RN 输出均未清理、暂存或提交。
## 下一步
STEP-075 关闭;按任务要求停止,不进入 STEP-076。

View File

@@ -0,0 +1,27 @@
# STEP-075 QBIN2 V4 Binding 与 Projection Identity Codex 完成报告 v1
日期:2026-09-13
## 已完成
- 完成纯 Core 的 Color/Particle binding 与 projection resolver。Host 仅表达 binding intent;Core 仍拥有 requested forecast time 和 per-field window。
- Identity 由 binding selector、实际 T0/T1 window frame coordinate、所选 plane 与 shared validity 的 content identity 组成;SampleState 单独承载 requested time/mix。U/V/validity 未变化时,Scalar-only revision 不改变 Particle identity。
- U/V 强制 VELOCITY_COMPONENT、MPS equality、U_EAST/V_NORTH 与同组;Direction 强制 DIRECTION_ANGLE/NONE/unit equality。所有 lookup/assertion 都 fail-closed,绝不 fallback 到其他 fieldKey/plane。
- 增加 4 个独立 Binding tests:不同 cadence、MIX identity/storage、Scalar-only revision isolation、错误矩阵和 DirectionAngle。
## 验证结果
- `cmake --build build-linux-opengl --target mbgl-test-runner -j3`:PASS(真实重建,输出节流)。
- 规定 GTest filter: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 均按任务禁止或范围限制未执行,不能标记 PASS。
- 不可插值相邻帧由既有 FieldSeries submit 拒绝,因此合法 Store 不会产生该 resolve window;Resolver 仍保留 `InterpolationIncompatible` 防御返回。
## Git
- 首个 scoped commit:待本报告随实现创建后回填。
- push:待 scoped commit 后回填。
- 最终 `origin/particle/main` HEAD:待 push 后回填。

View File

@@ -0,0 +1,121 @@
#pragma once
#include <mbgl/particle/field/field_runtime.hpp>
#include <memory>
#include <optional>
#include <string>
namespace mbgl::particle {
// Host-owned binding intent. FieldPlaneKey identity is deliberately only its
// semanticID/groupID pair; component is validated from the selected plane.
struct ColorBinding {
std::string fieldKey;
FieldPlaneKey plane;
FieldValueClass expectedValueClass = FieldValueClass::Scalar;
uint16_t expectedUnitID = FieldUnitNone;
bool operator==(const ColorBinding&) const;
};
enum class ParticleBindingMode : uint8_t { VectorComponents, DirectionAngle };
struct ParticleBinding {
ParticleBindingMode mode = ParticleBindingMode::VectorComponents;
std::string fieldKey;
FieldPlaneKey uPlane;
FieldPlaneKey vPlane;
FieldPlaneKey directionPlane;
uint16_t expectedUnitID = FieldUnitNone;
bool operator==(const ParticleBinding&) const;
};
struct FieldSampleState {
uint64_t requestedForecastTimeMs = 0;
float mix = 0.0f;
};
struct ColorProjectionContentIdentity {
ColorBinding binding;
FrameContentIdentity t0Frame;
FrameContentIdentity t1Frame;
std::string t0Plane;
std::string t1Plane;
std::string t0Validity;
std::string t1Validity;
bool operator==(const ColorProjectionContentIdentity&) const;
};
struct ParticleProjectionContentIdentity {
ParticleBinding binding;
FrameContentIdentity t0Frame;
FrameContentIdentity t1Frame;
std::string t0FirstPlane;
std::string t1FirstPlane;
std::string t0SecondPlane;
std::string t1SecondPlane;
std::string t0Validity;
std::string t1Validity;
bool operator==(const ParticleProjectionContentIdentity&) const;
};
struct ColorProjection {
ColorProjectionContentIdentity contentIdentity;
FieldSampleState sampleState;
std::shared_ptr<const DecodedFieldFrame> t0Frame;
std::shared_ptr<const DecodedFieldFrame> t1Frame;
std::shared_ptr<const std::vector<uint8_t>> t0PlaneStorage;
std::shared_ptr<const std::vector<uint8_t>> t1PlaneStorage;
std::shared_ptr<const std::vector<uint8_t>> t0ValidityStorage;
std::shared_ptr<const std::vector<uint8_t>> t1ValidityStorage;
};
struct ParticleProjection {
ParticleProjectionContentIdentity contentIdentity;
FieldSampleState sampleState;
std::shared_ptr<const DecodedFieldFrame> t0Frame;
std::shared_ptr<const DecodedFieldFrame> t1Frame;
std::shared_ptr<const std::vector<uint8_t>> t0FirstPlaneStorage;
std::shared_ptr<const std::vector<uint8_t>> t1FirstPlaneStorage;
std::shared_ptr<const std::vector<uint8_t>> t0SecondPlaneStorage;
std::shared_ptr<const std::vector<uint8_t>> t1SecondPlaneStorage;
std::shared_ptr<const std::vector<uint8_t>> t0ValidityStorage;
std::shared_ptr<const std::vector<uint8_t>> t1ValidityStorage;
};
enum class BindingProjectionError : uint8_t {
Accepted,
InvalidBinding,
FieldSeriesMissing,
WindowUnavailable,
PlaneMissing,
PlaneClassMismatch,
PlaneUnitMismatch,
PlaneComponentMismatch,
PlaneGroupMismatch,
InterpolationIncompatible,
};
struct ColorProjectionResult {
BindingProjectionError error = BindingProjectionError::InvalidBinding;
std::optional<ColorProjection> projection;
bool accepted() const { return error == BindingProjectionError::Accepted; }
};
struct ParticleProjectionResult {
BindingProjectionError error = BindingProjectionError::InvalidBinding;
std::optional<ParticleProjection> projection;
bool accepted() const { return error == BindingProjectionError::Accepted; }
};
class BindingProjectionResolver final {
public:
static ColorProjectionResult resolve(const EncodedFieldStore&, const ColorBinding&, uint64_t requestedForecastTimeMs);
static ParticleProjectionResult resolve(const EncodedFieldStore&, const ParticleBinding&, uint64_t requestedForecastTimeMs);
};
} // namespace mbgl::particle

View File

@@ -0,0 +1,149 @@
#include <mbgl/particle/field/field_binding.hpp>
#include <cmath>
namespace mbgl::particle {
namespace {
bool validValueClass(FieldValueClass value) { return value <= FieldValueClass::Validity; }
bool validComponent(FieldComponent value) { return value <= FieldComponent::WUp; }
bool validKey(const FieldPlaneKey& key) { return key.semanticID != 0 && validComponent(key.component); }
bool sameSelector(const FieldPlaneKey& a, const FieldPlaneKey& b) {
return a.semanticID == b.semanticID && a.groupID == b.groupID;
}
const DecodedFieldPlane* findPlane(const DecodedFieldFrame& frame, const FieldPlaneKey& selector) {
for (const auto& plane : frame.planes) if (sameSelector(plane.key, selector)) return &plane;
return nullptr;
}
BindingProjectionError validateColorBinding(const ColorBinding& binding) {
if (binding.fieldKey.empty() || !validKey(binding.plane) || !validValueClass(binding.expectedValueClass) ||
binding.expectedValueClass == FieldValueClass::Validity)
return BindingProjectionError::InvalidBinding;
return BindingProjectionError::Accepted;
}
BindingProjectionError validateParticleBinding(const ParticleBinding& binding) {
if (binding.fieldKey.empty()) return BindingProjectionError::InvalidBinding;
if (binding.mode == ParticleBindingMode::VectorComponents) {
if (!validKey(binding.uPlane) || !validKey(binding.vPlane) || sameSelector(binding.uPlane, binding.vPlane))
return BindingProjectionError::InvalidBinding;
} else if (binding.mode == ParticleBindingMode::DirectionAngle) {
if (!validKey(binding.directionPlane)) return BindingProjectionError::InvalidBinding;
} else {
return BindingProjectionError::InvalidBinding;
}
return BindingProjectionError::Accepted;
}
BindingProjectionError validateWindow(const EncodedFieldStore& store, const std::string& fieldKey,
uint64_t requested, std::optional<FieldSeriesWindow>& window) {
if (!store.find(fieldKey)) return BindingProjectionError::FieldSeriesMissing;
window = store.resolve(fieldKey, requested);
return window ? BindingProjectionError::Accepted : BindingProjectionError::WindowUnavailable;
}
BindingProjectionError validateSelected(const DecodedFieldPlane* t0,
const DecodedFieldPlane* t1,
FieldValueClass valueClass,
uint16_t unit,
FieldComponent component) {
if (!t0 || !t1) return BindingProjectionError::PlaneMissing;
if (t0->valueClass != valueClass || t1->valueClass != valueClass) return BindingProjectionError::PlaneClassMismatch;
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;
return BindingProjectionError::Accepted;
}
} // namespace
bool ColorBinding::operator==(const ColorBinding& other) const {
return fieldKey == other.fieldKey && sameSelector(plane, other.plane) &&
expectedValueClass == other.expectedValueClass && expectedUnitID == other.expectedUnitID;
}
bool ParticleBinding::operator==(const ParticleBinding& other) const {
return mode == other.mode && fieldKey == other.fieldKey && sameSelector(uPlane, other.uPlane) &&
sameSelector(vPlane, other.vPlane) && sameSelector(directionPlane, other.directionPlane) &&
expectedUnitID == other.expectedUnitID;
}
bool ColorProjectionContentIdentity::operator==(const ColorProjectionContentIdentity& other) const {
return binding == other.binding && t0Frame == other.t0Frame && t1Frame == other.t1Frame &&
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) &&
t0FirstPlane == other.t0FirstPlane && t1FirstPlane == other.t1FirstPlane &&
t0SecondPlane == other.t0SecondPlane && t1SecondPlane == other.t1SecondPlane &&
t0Validity == other.t0Validity && t1Validity == other.t1Validity;
}
ColorProjectionResult BindingProjectionResolver::resolve(const EncodedFieldStore& store,
const ColorBinding& binding,
const uint64_t requested) {
if (const auto error = validateColorBinding(binding); error != BindingProjectionError::Accepted) return {error, {}};
std::optional<FieldSeriesWindow> window;
if (const auto error = validateWindow(store, binding.fieldKey, requested, window); error != BindingProjectionError::Accepted)
return {error, {}};
const auto* t0 = findPlane(*window->t0, binding.plane);
const auto* t1 = findPlane(*window->t1, binding.plane);
if (const auto error = validateSelected(t0, t1, binding.expectedValueClass, binding.expectedUnitID,
t0 ? t0->key.component : FieldComponent::None);
error != BindingProjectionError::Accepted)
return {error, {}};
ColorProjection projection{{binding, window->t0->frameContentIdentity, window->t1->frameContentIdentity,
t0->contentIdentity, t1->contentIdentity, window->t0->validityContentIdentity,
window->t1->validityContentIdentity},
{requested, window->mix}, window->t0, window->t1, t0->storage, t1->storage,
window->t0->validityBits, window->t1->validityBits};
return {BindingProjectionError::Accepted, std::move(projection)};
}
ParticleProjectionResult BindingProjectionResolver::resolve(const EncodedFieldStore& store,
const ParticleBinding& binding,
const uint64_t requested) {
if (const auto error = validateParticleBinding(binding); error != BindingProjectionError::Accepted) return {error, {}};
std::optional<FieldSeriesWindow> window;
if (const auto error = validateWindow(store, binding.fieldKey, requested, window); error != BindingProjectionError::Accepted)
return {error, {}};
const auto firstSelector = binding.mode == ParticleBindingMode::VectorComponents ? binding.uPlane : binding.directionPlane;
const auto secondSelector = binding.mode == ParticleBindingMode::VectorComponents ? binding.vPlane : FieldPlaneKey{};
const auto* t0First = findPlane(*window->t0, firstSelector);
const auto* t1First = findPlane(*window->t1, firstSelector);
const auto expectedClass = binding.mode == ParticleBindingMode::VectorComponents ? FieldValueClass::VelocityComponent
: FieldValueClass::DirectionAngle;
const auto expectedComponent = binding.mode == ParticleBindingMode::VectorComponents ? FieldComponent::UEast
: FieldComponent::None;
if (const auto error = validateSelected(t0First, t1First, expectedClass, binding.expectedUnitID, expectedComponent);
error != BindingProjectionError::Accepted)
return {error, {}};
const DecodedFieldPlane* t0Second = nullptr;
const DecodedFieldPlane* t1Second = nullptr;
if (binding.mode == ParticleBindingMode::VectorComponents) {
t0Second = findPlane(*window->t0, secondSelector); t1Second = findPlane(*window->t1, secondSelector);
if (const auto error = validateSelected(t0Second, t1Second, FieldValueClass::VelocityComponent,
binding.expectedUnitID, FieldComponent::VNorth);
error != BindingProjectionError::Accepted)
return {error, {}};
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,
t0First->contentIdentity, t1First->contentIdentity,
t0Second ? t0Second->contentIdentity : std::string{},
t1Second ? t1Second->contentIdentity : std::string{},
window->t0->validityContentIdentity, window->t1->validityContentIdentity},
{requested, window->mix}, window->t0, window->t1, t0First->storage, t1First->storage,
t0Second ? t0Second->storage : nullptr, t1Second ? t1Second->storage : nullptr,
window->t0->validityBits, window->t1->validityBits};
return {BindingProjectionError::Accepted, std::move(projection)};
}
} // namespace mbgl::particle

View File

@@ -19,6 +19,7 @@ add_library(
${PROJECT_SOURCE_DIR}/test/math/wrap.test.cpp
${PROJECT_SOURCE_DIR}/test/platform/settings.test.cpp
${PROJECT_SOURCE_DIR}/test/particle/field_runtime.test.cpp
${PROJECT_SOURCE_DIR}/test/particle/field_binding.test.cpp
${PROJECT_SOURCE_DIR}/test/particle/qbin_v2_decoder.test.cpp
${PROJECT_SOURCE_DIR}/test/plugin/plugin.test.cpp
${PROJECT_SOURCE_DIR}/test/renderer/image_manager.test.cpp

View File

@@ -0,0 +1,170 @@
#include <mbgl/particle/field/field_binding.hpp>
#include <gtest/gtest.h>
#include <memory>
#include <string>
#include <vector>
using namespace mbgl;
namespace {
constexpr uint64_t Hour = 3'600'000;
constexpr uint64_t Base = 1'700'000'000'000ULL;
particle::DecodedFieldPlane makePlane(uint16_t semantic, uint16_t group, particle::FieldValueClass valueClass,
particle::FieldComponent component, uint16_t unit, std::string identity,
std::shared_ptr<const std::vector<uint8_t>> bytes) {
return {{semantic, group, component}, particle::FieldDType::UInt8, valueClass, unit, false, 4, 1.0f, 0.0f,
std::move(identity), std::move(bytes)};
}
std::shared_ptr<const particle::DecodedFieldFrame> makeFrame(std::string field, uint64_t time, uint64_t generation,
std::string suffix, bool vector = true,
bool scalar = true) {
const auto bytes = std::make_shared<const std::vector<uint8_t>>(std::vector<uint8_t>{1, 2, 3, 4});
const auto bits = std::make_shared<const std::vector<uint8_t>>(std::vector<uint8_t>{15});
particle::DecodedFieldFrame frame;
frame.frameContentIdentity = {std::move(field), generation, generation, time};
frame.referenceTimeMs = time;
frame.grid = {.layout = 1, .projection = 1, .sampleOrder = 1, .gridRegistration = 1, .width = 2, .height = 2,
.west = 0, .south = 0, .east = 1, .north = 1};
frame.validityContentIdentity = "valid-" + suffix;
frame.validityBits = bits;
frame.validityPlane = makePlane(900, 0, particle::FieldValueClass::Validity, particle::FieldComponent::None,
particle::FieldUnitNone, frame.validityContentIdentity, bits);
frame.validityPlane.dtype = particle::FieldDType::Bitset;
frame.validityPlane.requiredFlag = true;
if (vector) {
frame.planes.push_back(makePlane(1, 7, particle::FieldValueClass::VelocityComponent, particle::FieldComponent::UEast,
particle::FieldUnitMetersPerSecond, "u-" + suffix, bytes));
frame.planes.push_back(makePlane(2, 7, particle::FieldValueClass::VelocityComponent, particle::FieldComponent::VNorth,
particle::FieldUnitMetersPerSecond, "v-" + suffix, bytes));
}
if (scalar) frame.planes.push_back(makePlane(50, 0, particle::FieldValueClass::Scalar, particle::FieldComponent::None,
42, "s-" + suffix, bytes));
return std::make_shared<const particle::DecodedFieldFrame>(std::move(frame));
}
void submit(particle::EncodedFieldStore& store, const std::string& key,
std::shared_ptr<const particle::DecodedFieldFrame> frame) {
ASSERT_EQ(store.submit(key, std::move(frame)), particle::FieldSeriesSubmitResult::Inserted);
}
particle::ColorBinding color(std::string key = "weather.wave") {
return {std::move(key), {50, 0, particle::FieldComponent::None}, particle::FieldValueClass::Scalar, 42};
}
particle::ParticleBinding wind() {
particle::ParticleBinding binding;
binding.fieldKey = "weather.wind"; binding.uPlane = {1, 7, particle::FieldComponent::None};
binding.vPlane = {2, 7, particle::FieldComponent::None}; binding.expectedUnitID = particle::FieldUnitMetersPerSecond;
return binding;
}
} // namespace
TEST(ParticleFieldBinding, ResolvesIndependentFieldKeyCadence) {
particle::EncodedFieldStore store;
for (uint64_t hour : {0ULL, 1ULL, 2ULL}) submit(store, "weather.wind", makeFrame("wind", Base + hour * Hour, 1, "w" + std::to_string(hour)));
for (uint64_t hour : {0ULL, 3ULL, 6ULL}) submit(store, "weather.wave", makeFrame("wave", Base + hour * Hour, 1, "a" + std::to_string(hour)));
const auto c = particle::BindingProjectionResolver::resolve(store, color(), Base + Hour + Hour / 2);
const auto p = particle::BindingProjectionResolver::resolve(store, wind(), Base + Hour + Hour / 2);
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_NE(c.projection->t0Frame, p.projection->t0Frame);
EXPECT_NE(c.projection->t0ValidityStorage, p.projection->t0ValidityStorage);
}
TEST(ParticleFieldBinding, MixChangesSampleStateButNotContentIdentityOrStorage) {
particle::EncodedFieldStore store;
submit(store, "weather.wave", makeFrame("wave", Base, 1, "0"));
submit(store, "weather.wave", makeFrame("wave", Base + 4 * Hour, 1, "4"));
const auto at0 = particle::BindingProjectionResolver::resolve(store, color(), Base);
const auto at1 = particle::BindingProjectionResolver::resolve(store, color(), Base + Hour);
const auto at2 = particle::BindingProjectionResolver::resolve(store, color(), Base + 2 * Hour);
const auto at3 = particle::BindingProjectionResolver::resolve(store, color(), Base + 14'256'000);
ASSERT_TRUE(at0.accepted()); ASSERT_TRUE(at1.accepted()); ASSERT_TRUE(at2.accepted()); ASSERT_TRUE(at3.accepted());
EXPECT_FLOAT_EQ(at0.projection->sampleState.mix, 0.0f); EXPECT_FLOAT_EQ(at1.projection->sampleState.mix, 0.25f);
EXPECT_FLOAT_EQ(at2.projection->sampleState.mix, 0.5f); EXPECT_FLOAT_EQ(at3.projection->sampleState.mix, 0.99f);
EXPECT_EQ(at0.projection->contentIdentity, at1.projection->contentIdentity);
EXPECT_EQ(at0.projection->contentIdentity, at2.projection->contentIdentity);
EXPECT_EQ(at0.projection->contentIdentity, at3.projection->contentIdentity);
EXPECT_EQ(at0.projection->t0PlaneStorage, at3.projection->t0PlaneStorage);
}
TEST(ParticleFieldBinding, ScalarRevisionChangesOnlyColorIdentity) {
particle::EncodedFieldStore store;
submit(store, "weather.wind", makeFrame("wind", Base, 1, "0"));
submit(store, "weather.wind", makeFrame("wind", Base + Hour, 1, "1"));
const auto beforeColor = particle::BindingProjectionResolver::resolve(store, color("weather.wind"), Base + Hour / 2);
const auto beforeParticle = particle::BindingProjectionResolver::resolve(store, wind(), Base + Hour / 2);
ASSERT_TRUE(beforeColor.accepted()); ASSERT_TRUE(beforeParticle.accepted());
auto revision = *beforeParticle.projection->t0Frame;
revision.frameContentIdentity.generation = 2;
revision.frameContentIdentity.dataVersion = 2;
revision.planes.back().contentIdentity = "s-revised";
ASSERT_EQ(store.submit("weather.wind", std::make_shared<const particle::DecodedFieldFrame>(std::move(revision))),
particle::FieldSeriesSubmitResult::ReplacedRevision);
const auto afterColor = particle::BindingProjectionResolver::resolve(store, color("weather.wind"), Base + Hour / 2);
const auto afterParticle = particle::BindingProjectionResolver::resolve(store, wind(), Base + Hour / 2);
ASSERT_TRUE(afterColor.accepted()); ASSERT_TRUE(afterParticle.accepted());
EXPECT_NE(beforeColor.projection->contentIdentity, afterColor.projection->contentIdentity);
EXPECT_EQ(beforeParticle.projection->contentIdentity, afterParticle.projection->contentIdentity);
EXPECT_EQ(beforeParticle.projection->t0FirstPlaneStorage, afterParticle.projection->t0FirstPlaneStorage);
EXPECT_EQ(beforeParticle.projection->t0ValidityStorage, afterParticle.projection->t0ValidityStorage);
}
TEST(ParticleFieldBinding, FailsClosedAndSupportsDirectionAngle) {
particle::EncodedFieldStore store;
submit(store, "weather.wind", makeFrame("wind", Base, 1, "0"));
submit(store, "weather.wind", makeFrame("wind", Base + Hour, 1, "1"));
EXPECT_EQ(particle::BindingProjectionResolver::resolve(store, color(""), Base).error, particle::BindingProjectionError::InvalidBinding);
EXPECT_EQ(particle::BindingProjectionResolver::resolve(store, color("missing"), Base).error, particle::BindingProjectionError::FieldSeriesMissing);
EXPECT_EQ(particle::BindingProjectionResolver::resolve(store, color("weather.wind"), Base + 2 * Hour).error, particle::BindingProjectionError::WindowUnavailable);
auto missing = color("weather.wind"); missing.plane.semanticID = 99;
EXPECT_EQ(particle::BindingProjectionResolver::resolve(store, missing, Base).error, particle::BindingProjectionError::PlaneMissing);
auto classMismatch = color("weather.wind"); classMismatch.plane = {1, 7, particle::FieldComponent::None};
EXPECT_EQ(particle::BindingProjectionResolver::resolve(store, classMismatch, Base).error, particle::BindingProjectionError::PlaneClassMismatch);
auto unitMismatch = wind(); unitMismatch.expectedUnitID = 99;
EXPECT_EQ(particle::BindingProjectionResolver::resolve(store, unitMismatch, Base).error, particle::BindingProjectionError::PlaneUnitMismatch);
auto componentMismatch = wind(); componentMismatch.uPlane.semanticID = 2; componentMismatch.vPlane.semanticID = 1;
EXPECT_EQ(particle::BindingProjectionResolver::resolve(store, componentMismatch, Base).error, particle::BindingProjectionError::PlaneComponentMismatch);
particle::EncodedFieldStore unequalGroups;
auto g0 = *makeFrame("wind", Base, 1, "g0"), g1 = *makeFrame("wind", Base + Hour, 1, "g1");
g0.planes[1].key.groupID = 8; g1.planes[1].key.groupID = 8;
submit(unequalGroups, "weather.wind", std::make_shared<const particle::DecodedFieldFrame>(std::move(g0)));
submit(unequalGroups, "weather.wind", std::make_shared<const particle::DecodedFieldFrame>(std::move(g1)));
auto groupMismatch = wind(); groupMismatch.vPlane.groupID = 8;
EXPECT_EQ(particle::BindingProjectionResolver::resolve(unequalGroups, groupMismatch, Base).error,
particle::BindingProjectionError::PlaneGroupMismatch);
particle::EncodedFieldStore directions;
auto d0 = *makeFrame("direction", Base, 1, "d0", false, false);
auto d1 = *makeFrame("direction", Base + Hour, 1, "d1", false, false);
const auto directionBytes = std::make_shared<const std::vector<uint8_t>>(std::vector<uint8_t>{1, 2, 3, 4});
for (auto* frame : {&d0, &d1}) {
frame->planes.push_back(makePlane(10, 0, particle::FieldValueClass::DirectionAngle, particle::FieldComponent::None,
particle::FieldUnitDegrees, "direction", directionBytes));
frame->planes.back().dtype = particle::FieldDType::UInt16;
frame->planes.back().scale = 360.0f / 65536.0f;
}
submit(directions, "weather.direction", std::make_shared<const particle::DecodedFieldFrame>(std::move(d0)));
submit(directions, "weather.direction", std::make_shared<const particle::DecodedFieldFrame>(std::move(d1)));
particle::ParticleBinding direction;
direction.mode = particle::ParticleBindingMode::DirectionAngle; direction.fieldKey = "weather.direction";
direction.directionPlane = {10, 0, particle::FieldComponent::None}; direction.expectedUnitID = particle::FieldUnitDegrees;
EXPECT_TRUE(particle::BindingProjectionResolver::resolve(directions, direction, Base).accepted());
direction.expectedUnitID = particle::FieldUnitMetersPerSecond;
EXPECT_EQ(particle::BindingProjectionResolver::resolve(directions, direction, Base).error,
particle::BindingProjectionError::PlaneUnitMismatch);
direction.expectedUnitID = particle::FieldUnitDegrees;
direction.directionPlane.semanticID = 99;
EXPECT_EQ(particle::BindingProjectionResolver::resolve(directions, direction, Base).error,
particle::BindingProjectionError::PlaneMissing);
}

View File

@@ -734,3 +734,9 @@ Android V3-08 单次综合验收已完成并关闭。Native `13.4.1-particle.2`
- `AcceptsRawLimitWithCanonicalPaddingExcluded` 改为 1101×1890(2,080,890 cells)的 17-plane canonical fixture:single shared validity BITSET 加 16 个 UINT8 numeric plane。rawSum=33,554,352 bytes,小于 32 MiB limit=33,554,432;payloadLength=33,554,448 bytes,大于 limit 与 rawSum;totalQbinBytes=33,555,104 bytes,小于 64 MiB。
- production `QbinV2Decoder::decode(...)` Accepted;未发现生产缺陷,未改 Decoder 或 Host/网络/JNI/RN/Binding/Projection/Renderer。真实 runner 重建 PASS,Decoder/Ingress 24/24、Runtime/Profile 11/11、Source 回归 11/11 PASS,`git diff --check` PASS;未执行 Android Gradle/APK/ADB/Jest,按范围停止。
### 2026-09-13 STEP-075 QBIN2 V4 Binding 与 Projection Identity
- 新增 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 关闭并停止。