feat: add QBIN2 V4 generic field profile validation
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled

This commit is contained in:
OpenAI Codex
2026-09-13 09:42:41 +08:00
parent a0aa367f30
commit 0a190adff3
10 changed files with 806 additions and 0 deletions

View File

@@ -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

View File

@@ -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 未自行开展该工作。

View File

@@ -0,0 +1,56 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
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<FieldPlaneRequirement> requiredPlanes;
bool isStructurallyValid() const;
};
} // namespace mbgl::particle

View File

@@ -0,0 +1,38 @@
#pragma once
#include <mbgl/particle/field/field_runtime.hpp>
#include <string>
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

View File

@@ -0,0 +1,162 @@
#pragma once
#include <mbgl/particle/field/field_profile_contract.hpp>
#include <cstdint>
#include <memory>
#include <optional>
#include <string>
#include <unordered_map>
#include <vector>
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<const std::vector<uint8_t>> 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<const std::vector<uint8_t>> validityBits;
std::vector<DecodedFieldPlane> 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<const DecodedFieldFrame> t0;
std::shared_ptr<const DecodedFieldFrame> t1;
float mix = 0.0f;
uint64_t requestedForecastTimeMs = 0;
};
class FieldSeries final {
public:
explicit FieldSeries(size_t capacity = 8);
FieldSeriesSubmitResult submit(std::shared_ptr<const DecodedFieldFrame>);
std::optional<FieldSeriesWindow> resolve(uint64_t requestedForecastTimeMs) const;
size_t size() const { return frames.size(); }
size_t capacity() const { return capacity_; }
const std::vector<std::shared_ptr<const DecodedFieldFrame>>& entries() const { return frames; }
private:
size_t capacity_;
std::vector<std::shared_ptr<const DecodedFieldFrame>> 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<const DecodedFieldFrame>);
std::optional<FieldSeriesWindow> 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<std::string, FieldSeries> series;
};
} // namespace mbgl::particle

View File

@@ -0,0 +1,108 @@
#include <mbgl/particle/field/field_profile_validator.hpp>
#include <algorithm>
#include <map>
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<PlaneRef> 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<uint16_t, std::vector<PlaneRef>> 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

View File

@@ -0,0 +1,237 @@
#include <mbgl/particle/field/field_runtime.hpp>
#include <algorithm>
#include <cmath>
#include <limits>
#include <set>
#include <tuple>
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<std::tuple<uint16_t, uint16_t, uint8_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));
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<uint8_t>(component) < static_cast<uint8_t>(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<uint64_t>(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<FieldPlaneKey> 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<const DecodedFieldFrame> 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<FieldSeriesWindow> 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<double>(requestedForecastTimeMs - t0) / static_cast<double>(t1 - t0);
return FieldSeriesWindow{*lower, *upper, static_cast<float>(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<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));
return true;
}
FieldSeriesSubmitResult EncodedFieldStore::submit(const std::string& fieldKey,
std::shared_ptr<const DecodedFieldFrame> 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<FieldSeriesWindow> 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

View File

@@ -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

View File

@@ -0,0 +1,154 @@
#include <mbgl/particle/field/field_profile_validator.hpp>
#include <gtest/gtest.h>
#include <memory>
#include <string>
#include <vector>
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<const std::vector<uint8_t>> 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<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>{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<const particle::DecodedFieldFrame> 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<const particle::DecodedFieldFrame>(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));
}

View File

@@ -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。