From 0a190adff3a24af59647af4977f1c1e7e107ec28 Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Sun, 13 Sep 2026 09:42:41 +0800 Subject: [PATCH] feat: add QBIN2 V4 generic field profile validation --- CMakeLists.txt | 5 + ...IN2_V4_Core与通用FieldProfileValidator_v1.md | 39 +++ .../particle/field/field_profile_contract.hpp | 56 +++++ .../field/field_profile_validator.hpp | 38 +++ include/mbgl/particle/field/field_runtime.hpp | 162 ++++++++++++ .../field/field_profile_validator.cpp | 108 ++++++++ src/mbgl/particle/field/field_runtime.cpp | 237 ++++++++++++++++++ test/CMakeLists.txt | 1 + test/particle/field_runtime.test.cpp | 154 ++++++++++++ 粒子开发记录.md | 6 + 10 files changed, 806 insertions(+) create mode 100644 docs/50_Step记录/STEP-073_QBIN2_V4_Core与通用FieldProfileValidator_v1.md create mode 100644 include/mbgl/particle/field/field_profile_contract.hpp create mode 100644 include/mbgl/particle/field/field_profile_validator.hpp create mode 100644 include/mbgl/particle/field/field_runtime.hpp create mode 100644 src/mbgl/particle/field/field_profile_validator.cpp create mode 100644 src/mbgl/particle/field/field_runtime.cpp create mode 100644 test/particle/field_runtime.test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index bb6e8f2d5..f1b3b0b65 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -213,12 +213,17 @@ list(APPEND SRC_FILES ${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/offline_fixture_particle_adapter.cpp ${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_profile_validator.cpp ${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/local_particle_field_feed.cpp ${PROJECT_SOURCE_DIR}/src/mbgl/shaders/shader_program_base.cpp ) list(APPEND INCLUDE_FILES ${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/field_data_adapter.hpp + ${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/local_particle_field_feed.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/actor/actor_ref.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/actor/actor.hpp diff --git a/docs/50_Step记录/STEP-073_QBIN2_V4_Core与通用FieldProfileValidator_v1.md b/docs/50_Step记录/STEP-073_QBIN2_V4_Core与通用FieldProfileValidator_v1.md new file mode 100644 index 000000000..dc5de6ec3 --- /dev/null +++ b/docs/50_Step记录/STEP-073_QBIN2_V4_Core与通用FieldProfileValidator_v1.md @@ -0,0 +1,39 @@ +# STEP-073 — QBIN2 V4 Core 与通用 Field Profile Validator + +状态:完成 + +日期:2026-09-13 + +## 目标 + +建立不接入 Decoder、Renderer、Android/JNI/RN 或 V3 生产路径的 QBIN2 V4.4 provider-neutral Core primitive,并以数据驱动 Contract Schema v1 验证 `DecodedFieldFrame`。 + +## 完成项 + +- [x] 建立 `ResolvedFieldProfileContract`、`DecodedFieldFrame`、`FieldSeries`、`EncodedFieldStore` 与独立 `ForecastClock`。 +- [x] 以独立 Validity descriptor 加共享只读 payload 表达单一 Frame validity domain;descriptor 与 payload 使用同一 `shared_ptr`,不复制位图。 +- [x] 实现纯数据驱动 `FieldProfileValidator`:schema、REQUIRED 双向一致、额外 plane、shared-validity 能力、时间关系及 Core 单位/速度组规则均返回结构化结果。 +- [x] 保持普通 Scalar 的 semanticID/unitID opaque;无 profileID 产品分支。 +- [x] 保持同 validTime 的 Duplicate、identity conflict、revision replacement 与相邻插值兼容性语义;fieldKey 独立 cadence/window。 +- [x] 增加 C++ 自动化测试,并通过 Linux OpenGL 构建与 V3 Field/Timeline 定向回归。 + +## 允许修改 + +`include/mbgl/particle/field/`、`src/mbgl/particle/field/`、根/测试 CMake、`test/particle/`、本 Step、开发记录与完成报告。 + +## 禁止修改确认 + +未修改 Renderer、shader、`platform/android/`、RN/integration、Style JSON/source schema;未新增 QBIN decoder、JNI、Java/Kotlin 或 React API;旧 V3 Timeline/`submitField(...)` 路径未切换。 + +## 验证 + +- `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 或真机操作。 + +## 阻塞与下一步 + +无本 STEP 阻塞。下一步必须等待独立审计后再决定是否进入 QBIN2 Encoded Ingress / Decoder;本 STEP 未自行开展该工作。 diff --git a/include/mbgl/particle/field/field_profile_contract.hpp b/include/mbgl/particle/field/field_profile_contract.hpp new file mode 100644 index 000000000..7dc628474 --- /dev/null +++ b/include/mbgl/particle/field/field_profile_contract.hpp @@ -0,0 +1,56 @@ +#pragma once + +#include +#include +#include + +namespace mbgl::particle { + +enum class FieldValueClass : uint8_t { + Scalar = 0, + VelocityComponent = 1, + DirectionAngle = 2, + Validity = 3, +}; + +enum class FieldComponent : uint8_t { + None = 0, + UEast = 1, + VNorth = 2, + WUp = 3, +}; + +enum class FieldTimeRelation : uint8_t { + Any = 0, + ValidGteReference = 1, + ValidEqReference = 2, +}; + +// Core unit identifiers reserved by Contract Schema v1. Scalar unit identifiers +// deliberately remain opaque to the Native runtime. +constexpr uint16_t FieldUnitNone = 0; +constexpr uint16_t FieldUnitMetersPerSecond = 1; +constexpr uint16_t FieldUnitDegrees = 2; + +struct FieldPlaneRequirement { + uint16_t semanticID = 0; + FieldValueClass valueClass = FieldValueClass::Scalar; + uint16_t unitID = 0; + uint16_t groupID = 0; + FieldComponent component = FieldComponent::None; + bool requiredFlag = true; +}; + +struct ResolvedFieldProfileContract { + uint16_t contractSchemaVersion = 1; + std::string profileID; + uint32_t profileVersion = 0; + bool allowAdditionalPlanes = true; + bool sharedValidityRequired = true; + FieldTimeRelation timeRelation = FieldTimeRelation::Any; + std::vector requiredPlanes; + + bool isStructurallyValid() const; +}; + +} // namespace mbgl::particle diff --git a/include/mbgl/particle/field/field_profile_validator.hpp b/include/mbgl/particle/field/field_profile_validator.hpp new file mode 100644 index 000000000..b763e51f0 --- /dev/null +++ b/include/mbgl/particle/field/field_profile_validator.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include + +#include + +namespace mbgl::particle { + +// Structured outcome of Contract Schema v1 validation. This API deliberately +// validates only generic field metadata; it has no provider or product logic. +enum class FieldProfileValidationCode : uint8_t { + Accepted, + UnsupportedContractSchema, + InvalidContract, + InvalidFrame, + MissingRequiredPlane, + PlaneDescriptorMismatch, + UndeclaredRequiredPlane, + AdditionalPlaneNotAllowed, + InvalidVelocityGroup, + InvalidCoreUnit, + UnsupportedSharedValidityMode, + TimeRelationMismatch, +}; + +struct FieldProfileValidationResult { + FieldProfileValidationCode code = FieldProfileValidationCode::InvalidContract; + std::string detail; + + bool accepted() const { return code == FieldProfileValidationCode::Accepted; } +}; + +class FieldProfileValidator final { +public: + static FieldProfileValidationResult validate(const ResolvedFieldProfileContract&, const DecodedFieldFrame&); +}; + +} // namespace mbgl::particle diff --git a/include/mbgl/particle/field/field_runtime.hpp b/include/mbgl/particle/field/field_runtime.hpp new file mode 100644 index 000000000..7095043e9 --- /dev/null +++ b/include/mbgl/particle/field/field_runtime.hpp @@ -0,0 +1,162 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +namespace mbgl::particle { + +enum class FieldDType : uint8_t { + Int16 = 1, + UInt16 = 2, + UInt8 = 3, + Int8 = 4, + Float32 = 5, + Bitset = 6, +}; + +struct FrameContentIdentity { + std::string fieldID; + uint64_t generation = 0; + uint64_t dataVersion = 0; + uint64_t validTimeMs = 0; + + bool isValid() const; + bool operator==(const FrameContentIdentity&) const; + bool operator!=(const FrameContentIdentity& other) const { return !(*this == other); } +}; + +struct FieldPlaneKey { + uint16_t semanticID = 0; + uint16_t groupID = 0; + FieldComponent component = FieldComponent::None; + + bool operator==(const FieldPlaneKey&) const; + bool operator<(const FieldPlaneKey&) const; +}; + +struct DecodedFieldPlane { + FieldPlaneKey key; + FieldDType dtype = FieldDType::Float32; + FieldValueClass valueClass = FieldValueClass::Scalar; + uint16_t unitID = 0; + // REQUIRED is transport metadata retained by the provider-neutral runtime. + bool requiredFlag = false; + uint32_t count = 0; + float scale = 1.0f; + float offset = 0.0f; + std::string contentIdentity; + std::shared_ptr> storage; + + bool isStructurallyValid(uint64_t cellCount) const; + bool isInterpolationCompatibleWith(const DecodedFieldPlane&) const; +}; + +struct DecodedFieldGrid { + uint8_t layout = 1; + uint8_t projection = 0; + uint8_t sampleOrder = 0; + uint8_t gridRegistration = 0; + uint32_t width = 0; + uint32_t height = 0; + double west = -180.0; + double south = -85.0; + double east = 180.0; + double north = 85.0; + + bool isValid() const; + bool operator==(const DecodedFieldGrid&) const; +}; + +struct DecodedFieldFrame { + FrameContentIdentity frameContentIdentity; + uint64_t referenceTimeMs = 0; + DecodedFieldGrid grid; + // One frame-wide validity domain. The descriptor and payload share storage; + // projections can retain the same payload without copying it. + DecodedFieldPlane validityPlane; + std::string validityContentIdentity; + std::shared_ptr> validityBits; + std::vector planes; + + bool isStructurallyValid() const; + bool isInterpolationCompatibleWith(const DecodedFieldFrame&) const; + bool hasSameContentAs(const DecodedFieldFrame&) const; +}; + +enum class FieldSeriesSubmitResult : uint8_t { + Inserted, + ReplacedRevision, + Duplicate, + RejectedInvalidFieldKey, + RejectedInvalidFrame, + RejectedIdentityConflict, + RejectedStaleRevision, + RejectedIncompatibleFrame, + RejectedCapacity, +}; + +struct FieldSeriesWindow { + std::shared_ptr t0; + std::shared_ptr t1; + float mix = 0.0f; + uint64_t requestedForecastTimeMs = 0; +}; + +class FieldSeries final { +public: + explicit FieldSeries(size_t capacity = 8); + + FieldSeriesSubmitResult submit(std::shared_ptr); + std::optional resolve(uint64_t requestedForecastTimeMs) const; + size_t size() const { return frames.size(); } + size_t capacity() const { return capacity_; } + const std::vector>& entries() const { return frames; } + +private: + size_t capacity_; + std::vector> frames; +}; + +enum class ForecastPlaybackState : uint8_t { Paused, Playing }; + +class ForecastClock final { +public: + bool setRequestedForecastTime(uint64_t timestampMs); + bool setPlaybackSpeed(float value); + void play() { state_ = ForecastPlaybackState::Playing; } + void pause() { state_ = ForecastPlaybackState::Paused; } + bool advance(uint64_t elapsedMs); + + uint64_t requestedForecastTime() const { return requestedForecastTimeMs_; } + float playbackSpeed() const { return playbackSpeed_; } + ForecastPlaybackState playbackState() const { return state_; } + +private: + uint64_t requestedForecastTimeMs_ = 0; + float playbackSpeed_ = 1.0f; + ForecastPlaybackState state_ = ForecastPlaybackState::Paused; +}; + +class EncodedFieldStore final { +public: + explicit EncodedFieldStore(size_t perSeriesCapacity = 8) : perSeriesCapacity_(perSeriesCapacity) {} + + FieldSeriesSubmitResult submit(const std::string& fieldKey, std::shared_ptr); + std::optional resolve(const std::string& fieldKey, uint64_t requestedForecastTimeMs) const; + const FieldSeries* find(const std::string& fieldKey) const; + bool clear(const std::string& fieldKey); + void clearAll() { series.clear(); } + size_t fieldCount() const { return series.size(); } + +private: + size_t perSeriesCapacity_; + std::unordered_map series; +}; + +} // namespace mbgl::particle diff --git a/src/mbgl/particle/field/field_profile_validator.cpp b/src/mbgl/particle/field/field_profile_validator.cpp new file mode 100644 index 000000000..a030ecb99 --- /dev/null +++ b/src/mbgl/particle/field/field_profile_validator.cpp @@ -0,0 +1,108 @@ +#include + +#include +#include + +namespace mbgl::particle { +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; +} + +bool matches(const FieldPlaneRequirement& requirement, const DecodedFieldPlane& plane) { + return sameKey(requirement, plane) && requirement.valueClass == plane.valueClass && + requirement.unitID == plane.unitID && requirement.requiredFlag == plane.requiredFlag; +} + +FieldProfileValidationResult result(const FieldProfileValidationCode code, const std::string& detail) { + return {code, detail}; +} + +bool isNumeric(const FieldDType dtype) { + return dtype != FieldDType::Bitset; +} + +} // namespace + +FieldProfileValidationResult FieldProfileValidator::validate(const ResolvedFieldProfileContract& contract, + const DecodedFieldFrame& frame) { + if (contract.contractSchemaVersion != 1) + return result(FieldProfileValidationCode::UnsupportedContractSchema, "contractSchemaVersion"); + if (!contract.isStructurallyValid()) return result(FieldProfileValidationCode::InvalidContract, "contract"); + if (!frame.isStructurallyValid()) return result(FieldProfileValidationCode::InvalidFrame, "frame"); + if (!contract.sharedValidityRequired) + return result(FieldProfileValidationCode::UnsupportedSharedValidityMode, "sharedValidityRequired=false"); + + if ((contract.timeRelation == FieldTimeRelation::ValidGteReference && + frame.frameContentIdentity.validTimeMs < frame.referenceTimeMs) || + (contract.timeRelation == FieldTimeRelation::ValidEqReference && + frame.frameContentIdentity.validTimeMs != frame.referenceTimeMs)) + return result(FieldProfileValidationCode::TimeRelationMismatch, "reference/valid time"); + + std::vector actual; + actual.reserve(frame.planes.size() + 1); + actual.push_back(&frame.validityPlane); + for (const auto& plane : frame.planes) actual.push_back(&plane); + + for (const auto& required : contract.requiredPlanes) { + const auto same = std::find_if(actual.begin(), actual.end(), [&required](const PlaneRef plane) { + return sameKey(required, *plane); + }); + if (same == actual.end()) return result(FieldProfileValidationCode::MissingRequiredPlane, "required plane absent"); + if (!matches(required, **same)) + return result(FieldProfileValidationCode::PlaneDescriptorMismatch, "required plane descriptor"); + } + + for (const auto plane : actual) { + const auto declared = std::find_if(contract.requiredPlanes.begin(), contract.requiredPlanes.end(), + [plane](const FieldPlaneRequirement& requirement) { + return matches(requirement, *plane); + }); + if (plane->requiredFlag && declared == contract.requiredPlanes.end()) + return result(FieldProfileValidationCode::UndeclaredRequiredPlane, "actual REQUIRED plane"); + if (!plane->requiredFlag && !contract.allowAdditionalPlanes && declared == contract.requiredPlanes.end()) + return result(FieldProfileValidationCode::AdditionalPlaneNotAllowed, "additional plane"); + + if (plane->valueClass == FieldValueClass::Validity) { + if (plane->dtype != FieldDType::Bitset || plane->unitID != FieldUnitNone || plane->key.groupID != 0 || + plane->key.component != FieldComponent::None) + return result(FieldProfileValidationCode::InvalidCoreUnit, "validity descriptor"); + } else if (plane->valueClass == FieldValueClass::DirectionAngle) { + if (plane->unitID != FieldUnitDegrees || !isNumeric(plane->dtype)) + return result(FieldProfileValidationCode::InvalidCoreUnit, "direction unit/dtype"); + } else if (plane->valueClass == FieldValueClass::VelocityComponent) { + if (plane->unitID != FieldUnitMetersPerSecond || !isNumeric(plane->dtype)) + return result(FieldProfileValidationCode::InvalidCoreUnit, "velocity unit/dtype"); + } else if (plane->valueClass == FieldValueClass::Scalar && !isNumeric(plane->dtype)) { + return result(FieldProfileValidationCode::InvalidFrame, "scalar dtype"); + } + } + + std::map> velocityGroups; + for (const auto plane : actual) { + if (plane->valueClass == FieldValueClass::VelocityComponent) velocityGroups[plane->key.groupID].push_back(plane); + } + for (const auto& entry : velocityGroups) { + if (entry.first == 0) return result(FieldProfileValidationCode::InvalidVelocityGroup, "velocity group zero"); + PlaneRef u = nullptr; + PlaneRef v = nullptr; + for (const auto plane : entry.second) { + if (plane->key.component == FieldComponent::UEast) { + if (u) return result(FieldProfileValidationCode::InvalidVelocityGroup, "duplicate U"); + u = plane; + } else if (plane->key.component == FieldComponent::VNorth) { + if (v) return result(FieldProfileValidationCode::InvalidVelocityGroup, "duplicate V"); + v = plane; + } + } + if (!u || !v || u->count != v->count || u->unitID != v->unitID) + return result(FieldProfileValidationCode::InvalidVelocityGroup, "incomplete velocity group"); + } + return result(FieldProfileValidationCode::Accepted, "accepted"); +} + +} // namespace mbgl::particle diff --git a/src/mbgl/particle/field/field_runtime.cpp b/src/mbgl/particle/field/field_runtime.cpp new file mode 100644 index 000000000..ce8828700 --- /dev/null +++ b/src/mbgl/particle/field/field_runtime.cpp @@ -0,0 +1,237 @@ +#include + +#include +#include +#include +#include +#include + +namespace mbgl::particle { +namespace { + +bool validProfileID(const std::string& value) { + if (value.empty() || value.size() > 64) return false; + return std::all_of(value.begin(), value.end(), [](const unsigned char c) { + return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-'; + }); +} + +bool newerRevision(const FrameContentIdentity& next, const FrameContentIdentity& current) { + if (next.generation != current.generation) return next.generation > current.generation; + if (next.dataVersion != current.dataVersion) return next.dataVersion > current.dataVersion; + return false; +} + +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; +} + +bool validValueClass(const FieldValueClass value) { + return value <= FieldValueClass::Validity; +} + +bool validComponent(const FieldComponent value) { + return value <= FieldComponent::WUp; +} + +bool validDType(const FieldDType value) { + return value >= FieldDType::Int16 && value <= FieldDType::Bitset; +} + +} // namespace + +bool ResolvedFieldProfileContract::isStructurallyValid() const { + if (contractSchemaVersion != 1 || !validProfileID(profileID) || profileVersion == 0 || requiredPlanes.empty() || + timeRelation > FieldTimeRelation::ValidEqReference) + return false; + 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)); + if (!keys.insert(key).second) return false; + if (plane.valueClass == FieldValueClass::VelocityComponent && + (plane.groupID == 0 || plane.component == FieldComponent::None)) return false; + if (plane.valueClass == FieldValueClass::Validity && + (plane.groupID != 0 || plane.component != FieldComponent::None)) return false; + } + return true; +} + +bool FrameContentIdentity::isValid() const { + return !fieldID.empty() && generation > 0 && validTimeMs > 0; +} + +bool FrameContentIdentity::operator==(const FrameContentIdentity& other) const { + return fieldID == other.fieldID && generation == other.generation && dataVersion == other.dataVersion && + validTimeMs == other.validTimeMs; +} + +bool FieldPlaneKey::operator==(const FieldPlaneKey& other) const { + return semanticID == other.semanticID && groupID == other.groupID && component == other.component; +} + +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); +} + +bool DecodedFieldPlane::isStructurallyValid(const uint64_t cellCount) const { + return key.semanticID > 0 && validDType(dtype) && validValueClass(valueClass) && validComponent(key.component) && + count == cellCount && std::isfinite(scale) && scale > 0.0f && std::isfinite(offset) && + !contentIdentity.empty() && storage && !storage->empty(); +} + +bool DecodedFieldPlane::isInterpolationCompatibleWith(const DecodedFieldPlane& other) const { + return samePlaneSchema(*this, other); +} + +bool DecodedFieldGrid::isValid() const { + return width > 0 && height > 0 && std::isfinite(west) && std::isfinite(south) && std::isfinite(east) && + std::isfinite(north) && west < east && south < north; +} + +bool DecodedFieldGrid::operator==(const DecodedFieldGrid& other) const { + return layout == other.layout && projection == other.projection && sampleOrder == other.sampleOrder && + gridRegistration == other.gridRegistration && width == other.width && height == other.height && + west == other.west && south == other.south && east == other.east && north == other.north; +} + +bool DecodedFieldFrame::isStructurallyValid() const { + if (!frameContentIdentity.isValid() || referenceTimeMs == 0 || !grid.isValid() || !validityBits || + validityBits->empty() || validityContentIdentity.empty() || planes.empty()) return false; + const uint64_t cells = static_cast(grid.width) * grid.height; + const uint64_t expectedValidityBytes = (cells + 7u) / 8u; + if (validityBits->size() != expectedValidityBytes) return false; + if (!validityPlane.isStructurallyValid(cells) || validityPlane.contentIdentity != validityContentIdentity || + validityPlane.storage != validityBits) return false; + std::set keys; + if (!keys.insert(validityPlane.key).second) return false; + for (const auto& plane : planes) { + if (!plane.isStructurallyValid(cells) || !keys.insert(plane.key).second) return false; + } + return true; +} + +bool DecodedFieldFrame::isInterpolationCompatibleWith(const DecodedFieldFrame& other) const { + if (!(grid == other.grid) || !validityPlane.isInterpolationCompatibleWith(other.validityPlane) || + planes.size() != other.planes.size()) return false; + for (size_t i = 0; i < planes.size(); ++i) { + if (!planes[i].isInterpolationCompatibleWith(other.planes[i])) return false; + } + return true; +} + +bool DecodedFieldFrame::hasSameContentAs(const DecodedFieldFrame& other) const { + if (referenceTimeMs != other.referenceTimeMs || !(grid == other.grid) || + validityContentIdentity != other.validityContentIdentity || + !samePlaneSchema(validityPlane, other.validityPlane) || planes.size() != other.planes.size()) return false; + for (size_t i = 0; i < planes.size(); ++i) { + if (!samePlaneSchema(planes[i], other.planes[i]) || planes[i].contentIdentity != other.planes[i].contentIdentity) + return false; + } + return true; +} + +FieldSeries::FieldSeries(const size_t capacity) : capacity_(capacity) {} + +FieldSeriesSubmitResult FieldSeries::submit(std::shared_ptr frame) { + if (!frame || !frame->isStructurallyValid()) return FieldSeriesSubmitResult::RejectedInvalidFrame; + const auto timestamp = frame->frameContentIdentity.validTimeMs; + auto it = std::lower_bound(frames.begin(), frames.end(), timestamp, + [](const auto& item, const uint64_t value) { + return item->frameContentIdentity.validTimeMs < value; + }); + if (it != frames.end() && (*it)->frameContentIdentity.validTimeMs == timestamp) { + const auto& current = **it; + if (frame->frameContentIdentity == current.frameContentIdentity) { + return frame->hasSameContentAs(current) ? FieldSeriesSubmitResult::Duplicate + : FieldSeriesSubmitResult::RejectedIdentityConflict; + } + if (!newerRevision(frame->frameContentIdentity, current.frameContentIdentity)) + return FieldSeriesSubmitResult::RejectedStaleRevision; + if (!frame->isInterpolationCompatibleWith(current)) + return FieldSeriesSubmitResult::RejectedIncompatibleFrame; + if (it != frames.begin() && !frame->isInterpolationCompatibleWith(**std::prev(it))) + return FieldSeriesSubmitResult::RejectedIncompatibleFrame; + if (std::next(it) != frames.end() && !frame->isInterpolationCompatibleWith(**std::next(it))) + return FieldSeriesSubmitResult::RejectedIncompatibleFrame; + *it = std::move(frame); + return FieldSeriesSubmitResult::ReplacedRevision; + } + if (frames.size() >= capacity_) return FieldSeriesSubmitResult::RejectedCapacity; + if (it != frames.begin() && !frame->isInterpolationCompatibleWith(**std::prev(it))) + return FieldSeriesSubmitResult::RejectedIncompatibleFrame; + if (it != frames.end() && !frame->isInterpolationCompatibleWith(**it)) + return FieldSeriesSubmitResult::RejectedIncompatibleFrame; + frames.insert(it, std::move(frame)); + return FieldSeriesSubmitResult::Inserted; +} + +std::optional FieldSeries::resolve(const uint64_t requestedForecastTimeMs) const { + if (frames.size() < 2 || requestedForecastTimeMs < frames.front()->frameContentIdentity.validTimeMs || + requestedForecastTimeMs > frames.back()->frameContentIdentity.validTimeMs) return std::nullopt; + if (requestedForecastTimeMs == frames.back()->frameContentIdentity.validTimeMs) { + return FieldSeriesWindow{frames[frames.size() - 2], frames.back(), 1.0f, requestedForecastTimeMs}; + } + const auto upper = std::upper_bound(frames.begin(), frames.end(), requestedForecastTimeMs, + [](const uint64_t value, const auto& item) { + return value < item->frameContentIdentity.validTimeMs; + }); + if (upper == frames.begin() || upper == frames.end()) return std::nullopt; + const auto lower = std::prev(upper); + const uint64_t t0 = (*lower)->frameContentIdentity.validTimeMs; + const uint64_t t1 = (*upper)->frameContentIdentity.validTimeMs; + if (t1 <= t0 || !(*lower)->isInterpolationCompatibleWith(**upper)) return std::nullopt; + const double ratio = static_cast(requestedForecastTimeMs - t0) / static_cast(t1 - t0); + return FieldSeriesWindow{*lower, *upper, static_cast(std::clamp(ratio, 0.0, 1.0)), requestedForecastTimeMs}; +} + +bool ForecastClock::setRequestedForecastTime(const uint64_t timestampMs) { + if (timestampMs == 0) return false; + requestedForecastTimeMs_ = timestampMs; + return true; +} + +bool ForecastClock::setPlaybackSpeed(const float value) { + if (value != 0.5f && value != 1.0f && value != 2.0f) return false; + playbackSpeed_ = value; + return true; +} + +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)); + return true; +} + +FieldSeriesSubmitResult EncodedFieldStore::submit(const std::string& fieldKey, + std::shared_ptr frame) { + if (fieldKey.empty()) return FieldSeriesSubmitResult::RejectedInvalidFieldKey; + auto it = series.find(fieldKey); + if (it == series.end()) it = series.emplace(fieldKey, FieldSeries{perSeriesCapacity_}).first; + const auto result = it->second.submit(std::move(frame)); + if (result == FieldSeriesSubmitResult::RejectedInvalidFrame && it->second.size() == 0) series.erase(it); + return result; +} + +std::optional EncodedFieldStore::resolve(const std::string& fieldKey, + const uint64_t requestedForecastTimeMs) const { + const auto* found = find(fieldKey); + return found ? found->resolve(requestedForecastTimeMs) : std::nullopt; +} + +const FieldSeries* EncodedFieldStore::find(const std::string& fieldKey) const { + const auto it = series.find(fieldKey); + return it == series.end() ? nullptr : &it->second; +} + +bool EncodedFieldStore::clear(const std::string& fieldKey) { + return series.erase(fieldKey) != 0; +} + +} // namespace mbgl::particle diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index e1a882d60..47d228295 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -18,6 +18,7 @@ add_library( ${PROJECT_SOURCE_DIR}/test/math/minmax.test.cpp ${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/plugin/plugin.test.cpp ${PROJECT_SOURCE_DIR}/test/renderer/image_manager.test.cpp ${PROJECT_SOURCE_DIR}/test/renderer/pattern_atlas.test.cpp diff --git a/test/particle/field_runtime.test.cpp b/test/particle/field_runtime.test.cpp new file mode 100644 index 000000000..a6231265c --- /dev/null +++ b/test/particle/field_runtime.test.cpp @@ -0,0 +1,154 @@ +#include + +#include + +#include +#include +#include + +using namespace mbgl; + +namespace { + +particle::DecodedFieldPlane plane(uint16_t semantic, particle::FieldValueClass valueClass, uint16_t unit, + uint16_t group, particle::FieldComponent component, particle::FieldDType dtype, + bool required, std::shared_ptr> storage, + std::string identity = "plane") { + return {.key = {semantic, group, component}, .dtype = dtype, .valueClass = valueClass, .unitID = unit, + .requiredFlag = required, .count = 4, .scale = 0.1f, .offset = 0.0f, + .contentIdentity = std::move(identity), .storage = std::move(storage)}; +} + +particle::DecodedFieldFrame frame() { + const auto numbers = std::make_shared>(std::vector{1, 2, 3, 4}); + const auto bits = std::make_shared>(std::vector{0x0F}); + particle::DecodedFieldFrame value; + value.frameContentIdentity = {"future-product", 1, 1, 1'000}; + value.referenceTimeMs = 1'000; + value.grid = {.layout = 1, .projection = 1, .sampleOrder = 1, .gridRegistration = 1, .width = 2, .height = 2, + .west = 130.0, .south = 30.0, .east = 131.0, .north = 31.0}; + value.validityContentIdentity = "validity"; + value.validityBits = bits; + value.validityPlane = plane(900, particle::FieldValueClass::Validity, particle::FieldUnitNone, 0, + particle::FieldComponent::None, particle::FieldDType::Bitset, true, bits, "validity"); + value.planes.push_back(plane(777, particle::FieldValueClass::Scalar, 65432, 0, particle::FieldComponent::None, + particle::FieldDType::UInt8, true, numbers, "scalar")); + return value; +} + +particle::ResolvedFieldProfileContract contract() { + particle::ResolvedFieldProfileContract value; + value.profileID = "future.product-42"; + value.profileVersion = 1; + value.allowAdditionalPlanes = true; + value.requiredPlanes = {{777, particle::FieldValueClass::Scalar, 65432, 0, particle::FieldComponent::None, true}, + {900, particle::FieldValueClass::Validity, particle::FieldUnitNone, 0, + particle::FieldComponent::None, true}}; + return value; +} + +particle::FieldProfileValidationCode validate(const particle::ResolvedFieldProfileContract& c, + const particle::DecodedFieldFrame& f) { + return particle::FieldProfileValidator::validate(c, f).code; +} + +std::shared_ptr runtimeFrame(uint64_t time, uint64_t generation, + uint64_t version, std::string content, + std::string id = "field") { + auto value = frame(); + value.frameContentIdentity = {std::move(id), generation, version, time}; + value.planes.front().contentIdentity = std::move(content); + return std::make_shared(std::move(value)); +} + +} // namespace + +TEST(ParticleFieldProfileValidator, AcceptsGenericProfileAndOpaqueScalarUnit) { + EXPECT_EQ(validate(contract(), frame()), particle::FieldProfileValidationCode::Accepted); +} + +TEST(ParticleFieldProfileValidator, EnforcesRequiredPlanesAndAdditionalPlanePolicy) { + auto c = contract(); auto f = frame(); + f.planes.clear(); + EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::MissingRequiredPlane); + f = frame(); f.planes.front().unitID = 99; + EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::PlaneDescriptorMismatch); + f = frame(); f.planes.front().requiredFlag = false; + EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::PlaneDescriptorMismatch); + f = frame(); f.planes.push_back(plane(778, particle::FieldValueClass::Scalar, 123, 0, particle::FieldComponent::None, + particle::FieldDType::UInt8, true, f.planes.front().storage, "required-extra")); + EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::UndeclaredRequiredPlane); + f.planes.back().requiredFlag = false; c.allowAdditionalPlanes = false; + EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::AdditionalPlaneNotAllowed); + c.allowAdditionalPlanes = true; + EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::Accepted); +} + +TEST(ParticleFieldProfileValidator, EnforcesVelocityDirectionAndValidityCoreRules) { + 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)); + EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::InvalidVelocityGroup); + f.planes.push_back(plane(11, particle::FieldValueClass::VelocityComponent, particle::FieldUnitMetersPerSecond, 3, + particle::FieldComponent::VNorth, particle::FieldDType::Int16, false, bytes)); + EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::Accepted); + f.planes.back().unitID = 99; + EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::InvalidCoreUnit); + f = frame(); + f.planes.push_back(plane(10, particle::FieldValueClass::VelocityComponent, particle::FieldUnitMetersPerSecond, 0, + particle::FieldComponent::UEast, particle::FieldDType::Int16, false, bytes)); + EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::InvalidVelocityGroup); + f = frame(); + f.planes.push_back(plane(10, particle::FieldValueClass::VelocityComponent, particle::FieldUnitMetersPerSecond, 3, + particle::FieldComponent::UEast, particle::FieldDType::Int16, false, bytes)); + f.planes.push_back(plane(11, particle::FieldValueClass::VelocityComponent, particle::FieldUnitMetersPerSecond, 3, + particle::FieldComponent::UEast, particle::FieldDType::Int16, false, bytes)); + 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; + 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); + f = frame(); f.validityPlane.unitID = 1; + EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::InvalidCoreUnit); +} + +TEST(ParticleFieldProfileValidator, AppliesSchemaSharedValidityAndTimeRules) { + auto c = contract(); auto f = frame(); c.contractSchemaVersion = 2; + EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::UnsupportedContractSchema); + c = contract(); c.sharedValidityRequired = false; + EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::UnsupportedSharedValidityMode); + c = contract(); f.frameContentIdentity.validTimeMs = 999; + EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::Accepted); + c.timeRelation = particle::FieldTimeRelation::ValidGteReference; + EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::TimeRelationMismatch); + f.frameContentIdentity.validTimeMs = 1'000; + EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::Accepted); + c.timeRelation = particle::FieldTimeRelation::ValidEqReference; f.frameContentIdentity.validTimeMs = 1'001; + EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::TimeRelationMismatch); +} + +TEST(ParticleFieldRuntime, RevisionIdentityAndIndependentCadence) { + particle::FieldSeries series; const uint64_t base = 1'700'000'000'000ULL, hour = 3'600'000ULL; + EXPECT_EQ(series.submit(runtimeFrame(base, 1, 1, "a")), particle::FieldSeriesSubmitResult::Inserted); + EXPECT_EQ(series.submit(runtimeFrame(base, 1, 1, "a")), particle::FieldSeriesSubmitResult::Duplicate); + EXPECT_EQ(series.submit(runtimeFrame(base, 1, 1, "changed")), particle::FieldSeriesSubmitResult::RejectedIdentityConflict); + EXPECT_EQ(series.submit(runtimeFrame(base, 2, 2, "b")), particle::FieldSeriesSubmitResult::ReplacedRevision); + particle::EncodedFieldStore store; + EXPECT_EQ(store.submit("wind-key", runtimeFrame(base, 1, 1, "w0", "wind")), particle::FieldSeriesSubmitResult::Inserted); + EXPECT_EQ(store.submit("wind-key", runtimeFrame(base + hour, 2, 1, "w1", "wind")), particle::FieldSeriesSubmitResult::Inserted); + EXPECT_EQ(store.submit("wave-key", runtimeFrame(base, 1, 1, "a0", "wave")), particle::FieldSeriesSubmitResult::Inserted); + EXPECT_EQ(store.submit("wave-key", runtimeFrame(base + 3 * hour, 2, 1, "a3", "wave")), particle::FieldSeriesSubmitResult::Inserted); + const auto wind = store.resolve("wind-key", base + hour / 2), wave = store.resolve("wave-key", base + hour / 2); + ASSERT_TRUE(wind); ASSERT_TRUE(wave); EXPECT_FLOAT_EQ(wind->mix, 0.5f); EXPECT_FLOAT_EQ(wave->mix, 1.0f / 6.0f); +} + +TEST(ParticleFieldRuntime, ForecastClockOwnsRequestedTimeAndPlayback) { + particle::ForecastClock clock; + EXPECT_TRUE(clock.setRequestedForecastTime(1'700'000'000'000ULL)); EXPECT_FALSE(clock.advance(1'000)); + EXPECT_TRUE(clock.setPlaybackSpeed(2.0f)); clock.play(); EXPECT_TRUE(clock.advance(500)); + EXPECT_EQ(clock.requestedForecastTime(), 1'700'000'001'000ULL); clock.pause(); + EXPECT_FALSE(clock.advance(500)); EXPECT_FALSE(clock.setPlaybackSpeed(3.0f)); +} diff --git a/粒子开发记录.md b/粒子开发记录.md index f13c5e566..8f7a40818 100644 --- a/粒子开发记录.md +++ b/粒子开发记录.md @@ -685,3 +685,9 @@ WeatherColorLayer 的 `ParticleGpuValidationUBO` designated initializer 补齐 ` ### 2026-09-11 STEP-072 / V3-08 CLOSED / PASS Android V3-08 单次综合验收已完成并关闭。Native `13.4.1-particle.2` 已发布并验证;RN `13.4.1-particle.3` 通过正式 npm tarball + 远程 Maven 消费链。Win11 Gradle 8.14.3 / AGP 8.12.0 / Kotlin 2.1.20 构建 PASS。实体机 `192.168.1.146` 五组 Wind/Rain/Wave/Tide/Wave synthetic 均通过 T0/T1/READY 门槛,播放、速度、FPS、seek、独立显隐和视觉 API 均 ACCEPTED,fatal/JNI 扫描为空。 + +### 2026-09-13 STEP-073 QBIN2 V4 Core 与通用 Profile Validator + +- 新增 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。