feat(step098): single-frame display and keyed rolling cache

This commit is contained in:
tei
2026-09-27 08:42:00 +09:00
parent f3a44c7d5f
commit 159b0ad5f1
17 changed files with 1118 additions and 35 deletions

View File

@@ -0,0 +1,76 @@
# STEP-098 QBIN V4 单帧与八帧滚动缓存设计
日期:2026-09-26
状态:**已按接受的设计契约实现源码;Native 构建、定向测试与平台运行验收待 RDC 执行**。
## 1. 权威契约
- RFC-004:`/Users/tei/dev/navsea-particle-integration/rfc/RFC-004_QBIN_V4单帧首显与八帧滚动缓存.md`
- ADR-001:`/Users/tei/dev/navsea-particle-integration/adr/ADR-001_QBIN_V4单帧与滚动缓存语义.md`
- Contract C-002:`/Users/tei/dev/navsea-particle-integration/contracts/C-002_QBIN_V4单帧与滚动缓存.md`
- COORD-005:`/Users/tei/dev/navsea-particle-integration/coordination/active/COORD-005/决议.md`
- 读取时 Integration `main` / `origin/main` 均为 `1dd576d3304e267e53ed64d31f9711d2350ce10a`。四份文件为 ACCEPTED / DESIGN ONLY 或 IMPLEMENTATION_PENDING,不代表实现、设备或 V15 发布通过。
- 原任务 v1 的旧 RFC-003 闸门和 `docs/报告/STEP-098_公共契约阻塞报告_v1.md` 属于此前状态。用户已确认旧 RFC-003/COORD-004 B 作废删除;本次不恢复、不改写任务书或旧报告。
八帧时间示例按 C-002 为 **13:00–20:00**。提交 21:00 后删除最早端 13:00。等距离时固定删除原最早端。
## 2. Core 存储与身份
`EncodedFieldStore` 按 `fieldKey` 保存独立 `FieldSeries`,默认每个序列容量 8。每个序列按 `validTimeMs` 严格升序存储;插入新帧使用 lower-bound 定位,不假定时间间隔。重复时间继续走 Duplicate、revision、stale、identity conflict 现有判定,不触发容量淘汰。
为同时支持新预报批次单帧首显和防止跨批次混插,分开处理两类兼容性:
- **同 key 共存兼容性**:grid、validity plane schema、所有 data plane 的数量与 schema 相同。不同 `fieldID`、QBIN `referenceTimeMs` 或解析 profile 的批次可以在序列中共存;否则有效的新日/新批次单帧可能无法进入序列。
- **时间插值兼容性**:除共存 schema 相同外,`fieldID`、`referenceTimeMs`、`profileID` 和 `profileVersion` 必须全部相同。resolve 只在相邻时间端点满足这些条件时返回双帧;不同批次之间的时间返回 unavailable。Ingress 会把已验证的 profile 身份保留到解码帧,避免校验后丢失身份。
满额插入先复制现有有序序列,在候选序列上执行淘汰和新增端点 schema 校验。候选有效后才 swap 到正式序列;grid/plane schema 不兼容时返回既有 `RejectedIncompatible` 路径,不淘汰现有帧。对容量 8 的正常有效插入返回既有 `AcceptedInserted`;`RejectedCapacity` 枚举与 RN 字符串继续保留兼容,不用于正常满额滚动。
对提交时间 `n` 和原序列最早/最晚端点 `e/l`,淘汰较远端:`|n-e| >= |l-n|` 时淘汰原最早端,否则淘汰原最晚端。距离计算避免无符号时间相减下溢。该规则不读取 forecast clock,所以仍符合 RN 先 submit、后 setTime 的顺序。
## 3. 单帧、双帧与投影
- 精确匹配某个 `validTimeMs` 时 resolver 返回同一个不可变 frame 作为 T0/T1,`mix=0`。颜色和有合法 U/V 或方向分量的粒子可从该帧物化;标量帧仍不能通过向量绑定校验。
- 非精确时间只在合法的相邻双帧 bracket 内插值,mix 按两端实际时间比例计算。落在窗口外、跨 batch/profile 边界或只有一帧但时间不匹配时返回 unavailable;不回退为最近帧。
- 后续帧到达只更新实际变化的投影。新帧位于当前 bracket 外时保留 Source 的不可变物化 projection 与样本时刻;当前端点被淘汰后重新 resolve,原投影不可用则清除。
- 单帧 materialization 只创建一个 `ParticleFieldSnapshot` 并让 T0/T1 共用它。Particle 和 WeatherColor 的首次资源安装也共享相同 endpoint 的 GPU texture/image/scale;GL 与 Metal 共用这段 renderer 代码及语义。设备资源实际行为仍须各平台独立验证。
- 淘汰仅释放 Store 对已移除 frame 的引用。已发布 projection 或 GPU 更新仍持有共享 snapshot/texture 引用时,按 shared ownership 生命周期释放。
## 4. 内存观测范围
新增 `DecodedFieldFrame::decodedByteSize()`、`FieldSeries::decodedByteSize()` 与 `EncodedFieldStore::decodedByteSize()`,返回 validity bitset 加所有非 validity 原始 plane storage 的字节总和;共享 validity plane storage 不重复计数。该计数**不包含** profile/容器元数据、物化 Float32 snapshot、图像、GL/Metal texture、粒子 state 或 trail。
公式为单帧 `ceil(cellCount / 8) + Σ(plane payload bytes)`;序列与 Store 分别对帧及 key 求和。Native 定向测试代码覆盖滚动时每 key 保持 8 帧及此原始字节计数回落/清空,但本轮没有运行测试,也没有 CPU/GPU 峰值、32 帧总量或长期泄漏实测。8 帧不构成全局内存保证;不新增固定 MiB 上限或公开容量错误码。
## 5. API、职责与平台
RN TypeScript、Android Kotlin/JNI、iOS Objective-C++/Facade 保持 `submitEncodedField(...)` 与 `setEncodedForecastTime(...)` 两步调用及原有方法名、参数和结果名称;Codex 静态核对现有传递路径,本轮没有改 bridge。submit 不修改目标时钟,setTime 不下载数据。两调用仍非原子,不承诺无闪烁。
`DecodedFieldFrame` 增加内部解析 profile 身份字段及 decoded-byte 计数方法,改变该 C++ 数据结构的二进制布局;不改变 RN/Android/iOS bridge 的公开方法签名或结果枚举。Core 与两个平台必须使用同一源码重编;不得把既有 `.13` AAR 或旧发布物当作新 ABI 消费证据。
NavSea、Server、下载/cache/manifest、日期 UI、QBIN 格式、Rice 解码、V3、Style Z-order、provider、公开 RN API 均未改。
## 6. 定向测试映射
以下是本轮新增或扩展的测试代码覆盖,不代表已执行:
| 项目 | 测试入口 | 本轮代码覆盖 |
| --- | --- | --- |
| T01 | `ParticleFieldBinding.SingleFrameProjectsAtItsExactTimeAndUpgradesToInterpolation`;`Source.RasterArrayV4SingleExactFrameMaterializesAndSecondFrameEnablesInterpolation` | 单帧精确颜色/向量 projection、T0=T1、mix=0、第二帧中点升级;标量-only 无向量绑定 |
| T02 | 同上 | 第二帧到达后返回双帧与 mix=0.5 |
| T03 | `ParticleFieldRuntime.UnorderedSubmissionsAreSortedAndReferenceCyclesStaySeparate` | 乱序排序;wind/wave 分 key;跨 reference/profile 只可单帧解析 |
| T04 | `ParticleFieldRuntime.FullWindowRollsBothDirectionsAndBreaksTiesAtEarliest`;`Source.RasterArrayV4RollingWindowPreservesAnUnchangedActivePair` | 满 8 后向未来提交、淘汰最早端并保持活动 pair |
| T05 | `ParticleFieldRuntime.FullWindowRollsBothDirectionsAndBreaksTiesAtEarliest`;Source rolling 测试 | 满额向过去提交并淘汰最晚端 |
| T06 | `ParticleFieldRuntime.FullWindowRollsBothDirectionsAndBreaksTiesAtEarliest` | 中部等距插入固定淘汰最早端、有序且 8 帧 |
| T07 | `Source.RasterArrayV4StoresNewReferenceAndProfileAsSingleFrameWithoutCrossBatchMix` | 新 reference/profile 批次精确首显、补帧后同批插值、回到早批次单帧 |
| T08 | `ParticleFieldRuntime.RevisionIdentityAndIndependentCadence`;既有 Source stale/revision cases | Duplicate、identity conflict、revision;既有 stale 路径保留。满额下 revision 不占槽由同一 Store 分支处理 |
| T09 | `Source.RasterArrayV4HostApiRejectsKeepPublicProjectionStable`、`...RejectsNumericOverflowBeforeReplacingAcceptedRevision`、`ParticleFieldRuntime.RejectedFullWindowInsertDoesNotEvictAndKeysRemainIndependent` | 解码/profile/数值和 grid schema 拒绝路径、有效帧/投影/clock 零副作用。数据帧入 Store 前仍由现有 ingress 完成解码与 profile 校验 |
| T10 | `ParticleFieldBinding.MixChangesSampleStateButNotContentIdentityOrStorage`、单帧升级 Source 测试 | 合法 bracket 的中间比例;精确端点以单帧语义表示 |
| T11 | 既有 binding `WindowUnavailable` assertions;新单帧测试 | 非覆盖时间 unavailable,不用最近帧顶替 |
| T12 | `Source.RasterArrayV4RollingWindowPreservesAnUnchangedActivePair` | 外围补帧/双向滚动不改变当前物化 projection 与 requested time |
| T13 | `Source.RasterArrayV4EvictedActiveFrameClearsItsMaterializedProjection` | 当前单帧端点淘汰后 fail-closed 清除物化 projection |
| T14 | `ParticleFieldRuntime.RejectedFullWindowInsertDoesNotEvictAndKeysRemainIndependent` | 两个 key 各自 8 帧,独立计数与字节 |
| T15 | 单帧 binding/materialization 测试;GL/Metal renderer 资源安装源码 | T0/T1 共用物化 snapshot;renderer 首装共用 endpoint texture/image。GPU/context-loss 运行验证未覆盖 |
| T16 | `ParticleFieldRuntime.RepeatedBidirectionalRollingAndClearStayBounded` | 120 次双向提交、每 key 8 帧边界、clear/clearAll 和 decoded bytes 归零 |
| T17 | 现有 `FieldRuntime.ts`、`NativeWeatherRuntimeBridge.ts`、Android/iOS bridge 源码静态核对 | 旧 API、类型、方法名和两步语义未变;RN 自动化/双平台 consumer build 未运行 |
所有测试代码的执行状态均为 **NOT RUN**。完整执行状态与 RDC 命令见 STEP-098 完成报告。

View File

@@ -0,0 +1,141 @@
# STEP-098:QBIN V4 单帧首显与按 fieldKey 独立的八帧滚动缓存 — Codex 任务 v1
状态:**任务书 / 待契约闸门通过后执行**。本任务书不代表代码已经修改、编译或双平台运行验收通过。
## 0. 目标与执行环境
本任务落实已讨论的目标模型:**单帧可独立显示;第二帧到达后支持正常时间插值;后续时间片在 Native 内部进入每个 fieldKey 独立的、按 validTime 升序排序的固定 8 帧滚动缓存。** 满 8 帧提交新的合法时间片时应自动淘汰,不应因正常满额返回 `REJECTED_CAPACITY`。
当前用户维护和修改的源码以 **Mac** 为准:`/Users/tei/dev/MapLibreParticleLab`,分支 `particle/main`。编写本任务书时 Mac HEAD / origin HEAD 均为 `0d13552bed84f427dd43cae9856391bd26622179`。执行前重新查询 Git 事实,不以该 SHA 或本文行号覆盖后续提交;不要拿 Linux 旧工作树代替 Mac 代码。任务 MD 的目标归档位置为 `docs/40_任务/STEP-098_QBIN_V4单帧首显与八帧滚动缓存_Codex任务_v1.md`;不要创建 `docs/任务/`。
本阶段以 **Shared Core 公共语义和现有 RN V4 公共接口**为实施目标,不在 NavSea/Weather Server 中实施下载策略;Android 与 iOS 使用同一共享语义,平台专项编译/实体机验收分别另行完成。不得因 Mac 为编辑机而建立 iOS 专属 Core 行为。
### 固定边界
- RN:负责获取预报目录、QBIN 文件、本地下载缓存、用户选择的预报时间、异步命令 latest-wins 和 UI。RN 已提供 `FieldRuntime.submitEncodedField(...)`、`FieldRuntime.setEncodedForecastTime(...)`,**先 await 前者成功,再 await 后者**;无需新增组合提交 API。
- Shared Core/Native:负责 QBIN 校验、存储、每 fieldKey 内排序/淘汰/复用、单帧/双帧解析、颜色与粒子投影、GPU 资源正确性。
- 不增加 Native→RN 索取数据回调;Native 不接入 provider、HTTP、manifest、下载、缓存文件管理或 NavSea 业务语义。
- 不修改 QBIN 文件格式、Rice 解码语义、现有公开方法签名、Style Z-order 或 V3 路径,不在 JS 伪造第二帧,也不在 Host 手动清空 8 帧来实现正常拖动。
## 1. 首要闸门:Integration 公共契约
1. 先经 RDC 检查并同步 `/Users/tei/dev/navsea-particle-integration` 的最新 `origin/main`(`git fetch origin main` + 确认可 fast-forward;尊重现有本地改动)。读取该仓库 `README.md`、`WORKFLOW.md`、`coordination/TEAM_MODEL.md`、`contracts/`、`rfc/`、`adr/`、`status/`、`compatibility/`、`coordination/active/COORD-004/` 和 `instructions/PARTICLE_PROJECT_INSTRUCTIONS.md`。
2. 编写本任务书时 Integration HEAD 为 `318f8ccf911ac3136c3086a1d14e61f83e2d4bce`;`RFC-003_编码场两帧首显与增量补帧语义.md` 仍处于 DRAFT,其第一条要求至少两帧、满额返回容量拒绝,与本任务新目标**明确冲突**。本任务书不是已经接受的跨平台 RFC。
3. **开始改公共源码前,必须由粒子负责人/Integration 按既定流程正式更新 RFC-003、COORD/Contract 或 ADR,接受下述单帧和滚动淘汰语义,明确 Android/iOS 共用。** Codex 不得自行把 DRAFT 当 ACCEPTED,也不得静默更改 Integration 文档替代负责人决定。
4. 如果执行时仍没有已接受且不冲突的正式依据:先在 `docs/报告/STEP-098_公共契约阻塞报告_v1.md` 写出当前 SHA、冲突条目、拟实施语义与所需协调决策,**停止功能修改及 push**,状态记为 `BLOCKED_CONTRACT`,交回粒子负责人。不得绕过闸门做 Android/iOS 两套实现。
5. 若 Integration 已更新且批准,记录精确文档路径、版本与 commit SHA,后续实现以正式版本为准;如正式结论与本文不同,应停止并回报,不自行择一。
## 2. 开始前必须核对的 Mac 实际基线
先读根项目规范、`PARTICLE_BASELINE.md`(如存在)、`docs/00_项目规范/`、`docs/00_项目总则/README.md` 指定总则、与 STEP-097 的正在实施范围。读取并建立文件/函数对照表:
- `include/mbgl/particle/field/field_runtime.hpp`、`src/mbgl/particle/field/field_runtime.cpp`:`FieldSeries`、`EncodedFieldStore`、`resolve`、容量及 revision/identity。
- `src/mbgl/particle/field/field_binding.cpp`、`src/mbgl/particle/field/field_projection_materialization.cpp`:单/双帧投影、独立绑定和物化。
- `src/mbgl/style/sources/raster_array_source.cpp` 及相关头文件:`submitEncodedField`、`setRequestedForecastTime`、`refreshV4RendererState`、现有 projection equality/notification。
- `src/mbgl/renderer/layers/render_weather_color_layer.cpp`、`render_particle_validation_layer.cpp` 及 shader/texture 资源:单帧/双帧下颜色、粒子、轨迹和 GPU 复用。
- `packages/maplibre-particle-rn/src/FieldRuntime.ts`、`NativeWeatherRuntimeBridge.ts`、Android Kotlin bridge/JNI、iOS `WeatherRuntimeBridge.mm`/`MLNParticleFieldRuntime.mm`:确认两个现有公开调用完整传递结果。
- `test/particle/field_runtime.test.cpp`、`test/particle/field_binding.test.cpp`、`test/source/raster_array_v4_host_api.test.cpp` 及现有 Renderer 定向验证入口。
编写任务书时源码事实:`FieldSeries::submit` 对第 9 个不同时间直接 `RejectedCapacity`;`resolve` 对 `frames.size()<2` 返回空;`submitEncodedField` 已会刷新投影但不修改 forecast time。以上仅供定位,若新 HEAD 有变化应以实际代码为准。若 STEP-097 仍在改相同的 runtime/projection/Renderer 文件,先确认依赖顺序,不得并行覆盖或回滚已完成的修复;尤其保留 STEP-097 的输入校验、forecast clock、GPU 身份、资源生命周期等新修正。
## 3. 正式目标语义
### 3.1 缓存所有权、容量与排序
- `EncodedFieldStore` 按 `fieldKey` 分别持有 `FieldSeries`。`wind`、`wave`、`rain`、`tide` 使用不同 key 时分别最多 8 帧;同 key 的多个 Layer/Binding 共享该序列。不能把四类天气合为“全局 8 帧”。
- 每帧按 `frameContentIdentity.validTimeMs` **升序**保存,提交顺序不影响排序;不得假设固定一小时/三小时间隔。
- 已有相同 `validTime`:先走现有 Duplicate、identity conflict、stale、合法 revision 替换语义;不额外占一个槽,不因为满额淘汰别的帧。
- 不同时间且不足 8 帧:先完整校验,按时间有序插入。
- 不同时间且已满 8 帧:在成功通过解码、profile、grid/plane、身份和相邻兼容性校验后,选择淘汰一帧并原子提交新有序集合。正常满额替换成功返回现有 `ACCEPTED_INSERTED`(或正式合同要求的**既有**成功语义),不返回 `REJECTED_CAPACITY`。为了 TypeScript/ABI 向后兼容可以保留旧结果枚举,但正常容量=8的有效插入路径不再产生它;不可偷偷更名公共结果。
- 淘汰只作用于正在提交的 `fieldKey`,不影响其他天气种类、Bindings、requestedForecastTime、V3 数据或 Style。
### 3.2 双向淘汰,按新提交帧时间取中心
由于既定调用顺序是先 `submitEncodedField()`、后 `setEncodedForecastTime()`,**提交阶段不能依赖尚未下发的新目标时间**。本版使用“新提交帧 validTime 为中心,淘汰时间距离最远的端点”的确定性规则;新帧本身必须留下。对于已升序的满额序列,只在最早端或最晚端候选之间选择;若距离相同,按正式合同规定稳定决胜,允许优先避免淘汰当前显示端点,但不可牺牲新提交帧。不得永远只删除最旧帧。
必须覆盖:
- 原有示意 `13:00 ... 21:00` 共 8 帧;提交 `22:00` → 删除最早端 `13:00`,插入 `22:00`,最终仍 8 帧。
- 已向后滚动后提交 `12:00` → 删除最远的最晚端,重新保留 `12:00`;向过去拖动同样成立。
- 已满时提交位于内部的新时刻 → 仍保持有序、8 帧和确定的端点淘汰。
- 今天跳到后天某一时刻 → 接收该时刻 QBIN,而不是要求 RN 先清空旧窗口或填齐 8 帧。之后逐帧提交,Native 自行滚动。
- 乱序提交、Duplicate、同时间新 revision 不应造成意外淘汰。
- 插入不兼容数据或解码失败时,**零淘汰、零 Store/Projection/Clock 修改**;不得先删除旧帧再验证。
### 3.3 单帧首显与双帧升级
- 当请求时间精确对应某个已验证 `validTime`,即使整个 Store 只有一帧,或它与其余 7 帧相隔很远无法形成合理双帧窗口,也应能够显示该帧:颜色为这一帧的值;有有效 U/V(或规范允许的方向向量)时粒子可持续运动,**天气时间不变不等于粒子停止**;仅标量帧不能伪装成可平流向量。
- 单帧在运行时具有真实单端点语义。可在内部使用同一 Snapshot 作为 T0/T1、`mix=0` 来复用现有双纹理 shader,但不得向 RN 要求伪造第二个 QBIN 或捏造第二个不同 validTime;优先共享解码结果、物化 Snapshot 和 GPU 纹理,避免同一单帧无意义地双份常驻。
- 第二帧补入后,如果目标时间被合法的相邻端点覆盖,则自动升级为双帧插值,保持既有 `T0/T1/MIX`,端点、mix 与实际预报时间对应;如果并未覆盖,保持合法单帧或 fail-closed,不能强行把不相关的旧帧插值进去。
- 目标时间落在两帧之间且有合法覆盖时执行正常时间插值;目标时间既不精确匹配可用单帧、也没有合法双帧覆盖时返回明确 unavailable,不使用“最近旧帧”冒充该时刻。RN 若选择最近预报帧首显,必须以该帧实际 `validTime` 作为已显示时刻;用户日历选中时间与实际显示时间不可混称。例如用户初始选择 13:30,只有最近的 12:00 QBIN 时,RN 可先提交该帧并调用现有 `setEncodedForecastTime(12:00)` 显示 12:00 静态预报,仍将 13:30 保留为业务层待完成目标;等 15:00 帧到达后再设置 `setEncodedForecastTime(13:30)` 进入插值。不得在 Native 悄悄修改 `requestedForecastTime`,也不得将临时 12:00 画面标成 13:30 的插值天气。
- 不能把跨 dataset/reference/Profile 的帧混合成一个时间序列;沿用已有权威身份契约。不凭固定时间间隔猜测有效帧,也不能用任意远离的旧时间片推断新的天气。若当前元数据不足以明确区分“同一批次的合法双端点”与“跨批次伪兼容”,作为契约阻塞项报告,不能自行新造隐含协议。
- 明确 `fieldKey` 之间可有不同 validTime 覆盖。颜色与粒子在同一全局请求时刻分别 resolve,各自可处于单帧、双帧或 unavailable;不得因一个 fieldKey 无数据清空其他 key 的合法投影。
### 3.4 两个现有 RN API 的行为不变
正常时间轴操作为:
1. RN 从本地缓存或服务器取得对应 QBIN,先 `await FieldRuntime.submitEncodedField(...)`。
2. 仅在 `ACCEPTED_INSERTED`、`ACCEPTED_REPLACED_REVISION` 或可确认的 `DUPLICATE` 等成功结果后,`await FieldRuntime.setEncodedForecastTime(reactTag, targetTimeMs)`。
3. Native 的 submit **不擅自修改目标时间**,setTime 不触发网络下载。已缓存时间直接 setTime 即可;无需每次重新 submit。
4. 两调用非原子,可能在两次调用之间短暂显示旧时间、单帧或暂时不可用,**不可对外保证无闪烁**。不得把旧时刻天气伪标为新目标。RN 侧 latest-wins、目标版本和展示状态属于后续 Host 接入范围;本任务不私改 NavSea。
5. 不新增 `submitAndSetTime`、Native→RN 数据请求事件、逐帧删除 RN API 或新的 provider 参数;保留现有 `clearEncodedField()` / `clearAllEncodedFields()` 作为显式重置能力,而不是正常滚动手段。
### 3.5 Renderer 与资源安全
- 正常向前/向后补帧且当前已选 pair 和 mix 未变化时,避免无条件 rebuild、重复 GPU 上传、清空 trail 或发布无意义 Source revision;复用 existing `sameProjection`/GPU content identity。
- 单帧→双帧、双帧窗口变化和 revision 更新必须正确刷新,shader 无非法 T1/null;若相同 Snapshot 对应 T0/T1,避免重复上传同一内容。支持 GL/Metal 共享语义,不能只修 Android Renderer。
- 淘汰旧帧不能销毁仍被当前 projection/GPU 更新流程持有的不可变 Snapshot;通过正常引用计数和资源所有权安全释放。切换不同窗口时遵守现有 trail/particle reset 规则,不错误承诺跨任意远跳跃保留轨迹。
- 坏帧不允许覆盖已验证的好投影;生命周期关闭、clear、style reload、map 销毁与异步提交有明确所有权,不使旧请求重新点亮已 OFF 的画面。
### 3.6 内存边界
- 8 帧是**每 fieldKey 的数量上限**,不是无限制内存保证。每个 decoded frame 由原始 Plane 字节、validity bitset 和元数据构成;参与显示的端点另有 Float32 物化数组、图像、纹理和粒子/trail 资源。不得错误地用压缩 QBIN 大小表示运行内存。
- 不在本任务未经批准添加任意固定全局 MiB 上限或第二套容量公开错误码。为风/浪/雨/潮各 8 帧、单端点/双端点和跳跃替换提供 CPU/GPU 常驻及峰值测量入口或现有计数;记录可计算的 decoded byte 占用、资源复用/释放及最坏边界风险。
- 评估单帧 T0=T1 情况下重复物化/重复上传;保证淘汰与资源 release 后无持续增长。若测量显示 32 帧和显示资源会触及移动端内存风险,报告下一步“全局字节预算/多 fieldKey 优先级”设计建议,**不可把内存申请失败伪装成正常 REJECTED_CAPACITY**。
## 4. 实施顺序与测试矩阵
阶段 A:只读复核与契约闸门 → 阶段 B:Core Store 滚动淘汰及单帧 resolver → 阶段 C:Binding、物化、Renderer 单/双帧和资源复用 → 阶段 D:已有 RN Bridge 端到端语义检查 → 阶段 E:文件、报告、Git 收尾。前一阶段未明确通过不进入后一阶段;命令 timeout/进程未知不能算通过。发生公共 ABI、QBIN、profile、dataset 身份或无法证实跨平台一致性的阻塞时停止扩大范围。
至少具备以下定向验证;优先扩展既有 C++ 源级测试和可复用 Test Host 场景,不新建无关测试框架:
| 编号 | 场景 | 必须得到的结果 |
|---|---|---|
| T01 | 只有一帧,target=validTime | 投影合法;色场可显示;有 U/V 时粒子可动;实际时刻正确 |
| T02 | 首帧后补入第二帧,target 在两者中间 | 单帧→双帧,MIX 正确;不用伪造第二帧 |
| T03 | 8 帧未满乱序提交 | 按 validTime 升序;各 key 相互隔离 |
| T04 | 满 8 帧,向未来提交新帧 | 淘汰最早端、保留新帧,计数仍 8,无正常容量拒绝 |
| T05 | 满 8 帧,向过去提交新帧 | 淘汰最晚端、保留新帧,计数仍 8 |
| T06 | 满 8 帧,提交中间时刻 | 确定性端点淘汰;排序/唯一性不变 |
| T07 | 今天→后天单帧→后续补帧→返回今天 | 新时刻单帧可用,后续升级双帧;旧数据需 RN 重交时可正常滚动,不越界、不混批 |
| T08 | Duplicate、同时间合法新 revision、stale、identity conflict | 保留现有判定;重复/替换不淘汰其他时刻 |
| T09 | 错 QBIN、Profile/Grid/Plane 不兼容、无效向量 | 拒绝且 Store、forecast clock、原投影不被破坏 |
| T10 | 目标时间位于已缓存相邻两帧之间 | 正常插值,mix 首/中/末精确 |
| T11 | 目标不被合法帧覆盖也不精确匹配 | unavailable,不把最近帧冒充目标时间 |
| T12 | 新帧在当前 pair 外围、当前投影不变 | requestedTime、当前 pair/mix 保持;避免无意义资源重建 |
| T13 | 新帧改变当前 bracket,或当前端点被淘汰 | 正确更新;不沿用失效旧 GPU 数据、错误轨迹 |
| T14 | `wind` 满额后提交 `wave`;同 key Color+Particle | 独立 8 帧;其他 key 不受影响;同 key 不复制 Store |
| T15 | 单/双端点 Materialization/GL/Metal 资源 | 单帧无需两份独立 QBIN/CPU/GPU 内容;复用、release 与上下文丢失恢复正确 |
| T16 | 连续 100+ 次双向滚动、反复 clear、切 Runtime | 帧上限不破、无持续内存增长和悬挂引用 |
| T17 | RN 两步调用成功与失败分支 | 既有 API/TypeScript/Android/iOS bridge 类型兼容,失败不继续 setTime;不新增公开 API |
注意:Native 静态/定向测试只能证明对应源码路径,不等于手机 GPU 首帧或 NavSea UI 通过。对尚未执行的 Android/iOS 真机、滚动拖动、跨天回退、内存峰值必须标 `NOT RUN / WAITING_USER_VALIDATION`。
## 5. 修改范围与明确禁止项
允许根据当前真实源码在上述 Shared Core/Source/Renderer 及对应现有测试文件做必要的最小修改;仅在证明是既有 API 传递障碍时修改 Android/iOS bridge,保持方法签名和结果语义。可在 `docs/30_设计/` 记录淘汰不变量与单帧/双帧选择表,在 `docs/50_Step记录/` 记录实施进度。
禁止:修改 NavSea、Server、天气 provider、下载缓存、Manifest、日历 UI;重新生成 PBF、修改 Style Z-order、强行更改 QBIN/QBV3 格式;新建第二套 FieldStore/Timeline、额外 JS Normalizer/Wrapper、私有 Native 通道、自动请求 RN 数据、提交单帧伪造第二份不同时间、给四类天气合用 8 帧、让正常满额仍返回容量拒绝。不允许顺带完成 STEP-097 的未授权项,也不允许覆盖现有发布 tag/manifest 或把源码通过称为正式发布通过。
## 6. 构建、交付与 Git
- 本次编译、Gradle/Pod/Xcode 构建、APK/IPA 安装、ADB/设备操作及实际 GPU 验收由用户人工执行。Codex 可做源码、`git diff --check`、格式/静态检查及环境许可的低成本 C++ 定向测试;**不得擅自启动完整 Android/iOS 编译或设备操作**,也不得把未运行项目写成 PASS。
- 准备一份 Windows 11 可直接执行的 Android 手动验证清单,ADB 命令如需涉及当前虚拟机必须显式 `adb -s 192.168.200.139 ...`;iOS 只列 Mac 用户手工构建/设备验收条件。合并一轮验证 T01–T17,不为单个按钮或日志要求反复重装。
- 必做输出:`docs/30_设计/STEP-098_QBIN_V4单帧与八帧滚动缓存设计_v1.md`、`docs/50_Step记录/STEP-098_QBIN_V4单帧与八帧滚动缓存_v1.md`、`docs/报告/STEP-098_QBIN_V4单帧与八帧滚动缓存_Codex完成报告_v1.md`。目录不存在先创建;同主题报告已存在则递增版本,不覆盖旧报告。全部中文。
- 报告明确记录:当前 HEAD、获接受的 Integration 协议版本/commit、真实改动列表、API/ABI 是否变化、每项 T01–T17 执行状态、`REJECTED_CAPACITY` 新语义、单帧显示实际范围、CPU/GPU 内存测量与未验证项、下一步 Android/iOS/Host 责任。最多只能报告 `IMPLEMENTED / WAITING_PLATFORM_RUNTIME_VALIDATION`;契约闸门未过则 `BLOCKED_CONTRACT`。
- 完成可实施阶段且检查通过后,只提交本次任务相关变更到 **Mac 当前项目 `particle/main`**,并 push 到公开 `origin/particle/main`。提交前检查未跟踪/用户既有文件,不得 `git add -A` 混入无关变更;先核验 `origin` 是否前进,冲突时停下处理而非强推。完成报告必须写最终 commit hash、commit message、push 结果和远端 branch/HEAD;push 失败不能声称完成。若被契约闸门阻塞,不提交或 push 功能代码。
## 7. 最终验收口径
用户能在 RN 维持两个现有 API 和顺序:第一帧即显示其真实有效时间;第二帧具备合法覆盖时升级插值;后续任意顺序提交,Native 每 key 自动保持最多 8 帧。向未来提交淘汰最早,向过去提交淘汰最晚;正常已满不会 `REJECTED_CAPACITY`;四类天气互不挤占。无效帧零副作用、时间语义不造假、Core 不掌握下载/日历。Renderer 不因正常补帧无谓清屏或持续泄漏。Android 和 iOS 分别通过用户实际设备验证后,才能由 Integration 更新兼容性及正式状态。

View File

@@ -0,0 +1,137 @@
# STEP-098 QBIN V4 单帧与八帧滚动缓存
状态:**进行中(Codex 源码交付;等待 RDC 独立构建/定向测试及平台验收)**
日期:2026-09-26(JST)
## 目标与基线
- 目标:单帧真实时刻首显、合法同批双帧插值、每 `fieldKey` 最多八帧有序滚动,保留现有 RN API 两步调用。
- 仓库:Mac `/Users/tei/dev/MapLibreParticleLab`;branch `particle/main`;开工 HEAD `b1239ac3b1554943200e6fbf14321459ccc86527`,结束时 HEAD 未变。
- Integration:`main` / `origin/main`=`1dd576d3304e267e53ed64d31f9711d2350ce10a`;RFC-004、ADR-001、C-002、COORD-005 均已读。旧 RFC-003 阻塞由本轮明确的新接受契约解除;旧阻塞报告保留不改。
- 当前 Android Native/RN 坐标仍为 `13.4.1-particle.13`;本次未改版本或发布资产。iOS 基础版本 `6.28.0`。
- 工作树开工已有 STEP-097 B 修改/未跟踪文件、STEP-098 原任务与旧阻塞报告;保留原样。`git submodule status` 全部与记录 SHA 一致。
## 本轮完成
- `FieldSeries` 在 exact validTime 返回真实单帧窗口;非精确时间只解析本地相邻 pair。
- 按 key 区分共存 schema 与插值批次身份。新批次可进入滚动序列并按精确时间单帧显示;插值要求 fieldID、reference time、resolved profile ID/version 与 grid/plane schema 全部相同。Ingress 保存已验证 profile 身份。
- 满额候选集合先按新帧时间选择远端点,等距删除原最早端;通过 schema 邻接校验后原子 swap。拒绝帧不先淘汰。八帧以上正常插入使用既有成功结果。
- 新增 decoded raw-plane + validity 字节计数;单帧 T0/T1 共享一份 materialized Snapshot,WeatherColor/Particle 首次 GPU 安装共享相同 endpoint 资源。
- 扩展 Runtime、Binding、Ingress、RasterArraySource 定向测试代码,覆盖单帧/双帧/滚动/跨批次/原子拒绝/内存计数/重复清理场景;详见设计和完成报告矩阵。
- RN TypeScript、Android Kotlin/JNI、iOS bridge 静态核对:旧 API 名称、参数、返回类型和两步顺序不变;未修改 bridge。
- 新增中文设计文档与本报告;开发记录仅追加本轮事实。
## 修改范围
STEP-098 本轮源代码与测试:
- `include/mbgl/particle/field/field_runtime.hpp`
- `src/mbgl/particle/field/field_runtime.cpp`
- `src/mbgl/particle/field/field_projection_materialization.cpp`
- `src/mbgl/particle/ingress/qbin_v2_decoder.cpp`
- `src/mbgl/renderer/layers/render_particle_validation_layer.cpp`
- `src/mbgl/renderer/layers/render_weather_color_layer.cpp`
- `test/particle/field_binding.test.cpp`
- `test/particle/field_runtime.test.cpp`
- `test/particle/qbin_v2_decoder.test.cpp`
- `test/source/raster_array_v4_host_api.test.cpp`
文档/记录:
- `docs/30_设计/STEP-098_QBIN_V4单帧与八帧滚动缓存设计_v1.md`
- `docs/50_Step记录/STEP-098_QBIN_V4单帧与八帧滚动缓存_v1.md`
- `docs/报告/STEP-098_QBIN_V4单帧与八帧滚动缓存_Codex完成报告_v1.md`
- 根目录 `粒子开发记录.md`(追加)
未修改用户原任务书、STEP-097 的矩阵/记录/清单/桥接文件、NavSea/Server、QBIN 格式、版本锁、tag、manifest、制品。`test/particle/field_runtime.test.cpp` 与 `test/source/raster_array_v4_host_api.test.cpp` 开工前已有 B 阶段 dirty 内容;原 B 的 fixture 修正保留,本轮只叠加 STEP-098 测试,并为 Source 测试 QBIN helper 增加 forecast reference 参数。
## API / ABI 与限制
- RN 和平台 bridge 公开方法签名/枚举名称:未变。
- `DecodedFieldFrame` 增加了解析 profile 身份字段,C++ 结构二进制布局变化;Native/RN 配套版本必须同源码重编。没有用 `.13` 旧 AAR 验证该结构。
- `decodedByteSize()` 只计原始 plane storage 与 validity bitset,不含 profile 元数据、CPU 物化数组、图像、GPU texture、粒子/trail。没有执行 CPU/GPU 峰值或长时间泄漏测量。
- 单帧 Particle 的实际 GPU 运动、GL/Metal texture 复用、context loss 和设备视觉尚待平台实测。
## RDC 分阶段执行命令(本轮未运行)
### B:Shared Core Store / Ingress / Binding
```bash
ninja -C build-step088-opengl mbgl-test-runner
build-step088-opengl/mbgl-test-runner --gtest_filter='ParticleFieldRuntime.*:ParticleFieldBinding.*:ParticleEncodedFieldIngress.*'
```
### C:Projection / Source / Renderer 与 OpenGL、Metal host 回归
```bash
ninja -C build-step088-opengl mbgl-test-runner
build-step088-opengl/mbgl-test-runner --gtest_filter='Particle*.*:Source.RasterArray*'
ninja -C build-step097-metal-host mbgl-test-runner
build-step097-metal-host/mbgl-test-runner --gtest_filter='Particle*.*:Source.RasterArray*'
```
先完成 B 并检查结果,再执行 C;Metal host GTest 不等于 iOS 设备 Metal 通过。未通过/退出未知时停在原阶段。Codex 本轮只运行 `git diff --check`,未运行上述命令、Android Gradle、iOS/Xcode、Jest、设备或发布。
### D:RN / 平台验证前置条件
RN 端确认现有顺序为成功 await `submitEncodedField(...)` 后才 await `setEncodedForecastTime(...)`;失败提交不得调用 setTime。T17 使用 V15 同源码 Native/RN 配套构建和 Host 手工验证,不能用现有 `.13` AAR 替代。
Windows 11 用户手工验收(待相同 V15 Test Host APK、RN 包与 Native AAR 可用后):
```powershell
cd D:\React\MapLibreParticleRNTestHost-V4
npm install <RDC提供的同版本V15 RN package tgz路径>
npx expo prebuild --platform android
cd android
$env:ANDROID_HOME='C:\Users\taibe\AppData\Local\Android\Sdk'
$env:ANDROID_SDK_ROOT=$env:ANDROID_HOME
.\gradlew.bat :app:assembleDebug
adb -s 192.168.200.139 install -r <同源码V15 Test Host APK路径>
adb -s 192.168.200.139 shell am force-stop org.maplibre.particle.rntesthost
adb -s 192.168.200.139 shell am start -n org.maplibre.particle.rntesthost/.MainActivity
```
在 Host 可复用的本地 QBIN 场景中一次完成:首帧单帧显示/粒子持续运动、标量无粒子、第二帧中点 mix、乱序 8 帧、未来/过去/内部插入与淘汰、跨 reference/profile 日切首帧及回退、duplicate/revision/stale/损坏帧、wind/wave key 隔离、当前 endpoint 淘汰清场、100+ 次滚动及 clear。逐项记录真实请求时间与实际显示 validTime、T0/T1/mix、提交结果、异常日志、进程内存趋势。ADB 全部显式指定 `192.168.200.139`;不得将 Test Host 冒充 NavSea 验收。
iOS 由 Mac 用户使用同一冻结源码和配套产物手工构建;在 Metal 设备验证单帧首显、单帧向量运动、双帧 mix、跨批次 unavailable、双向淘汰、key 隔离、坏帧不覆盖好投影、clear/context 恢复及长时间内存趋势。Metal host runner 结果不能替代该设备证据。
## T01–T17 状态
测试覆盖映射见设计文档 §6。以下“代码覆盖”仅说明测试源码中存在场景;**实际运行均为 NOT RUN**。
| 项 | 代码覆盖状态 | 实际运行 |
| --- | --- | --- |
| T01 | 已覆盖 | NOT RUN |
| T02 | 已覆盖 | NOT RUN |
| T03 | 已覆盖 | NOT RUN |
| T04 | 已覆盖 | NOT RUN |
| T05 | 已覆盖 | NOT RUN |
| T06 | 已覆盖 | NOT RUN |
| T07 | 已覆盖 | NOT RUN |
| T08 | 部分覆盖;现有 revision/stale 入口 + 满额分支,尚无单一 T08 全覆盖测试 | NOT RUN |
| T09 | 部分覆盖;profile/decode/numeric/grid 拒绝及原子性,错误向量仍由既有 binding 测试覆盖 | NOT RUN |
| T10 | 部分覆盖;pair mix 0.25/0.5/0.99 已覆盖,精确 T1 以单帧表示 | NOT RUN |
| T11 | 已覆盖 | NOT RUN |
| T12 | 已覆盖 | NOT RUN |
| T13 | 已覆盖 | NOT RUN |
| T14 | 已覆盖 | NOT RUN |
| T15 | 部分覆盖;materialization/renderer 源码有覆盖,GPU/context loss 未运行 | NOT RUN |
| T16 | 部分覆盖;120 次 Core Store 双向滚动/clear 及 byte count 已覆盖,Runtime 切换/GPU 泄漏未运行 | NOT RUN |
| T17 | 部分覆盖;bridge 静态检查,自动化成功/失败序列与配套消费构建未运行 | NOT RUN |
## 当前结果与下一步
- 当前结果:**源码已实施,等待 RDC 阶段 B 独立构建和测试;没有 PASS 结论。**
- `git diff --check`:通过(只代表空白/patch 格式检查)。
- 未完成:所有新增 C++ 测试执行、OpenGL/Metal host 构建、Android/RN 同版本配套构建、iOS/Metal 设备验证、窗口拖动/跨天/内存实测、V15 pipeline、commit/push。
- 剩余阻塞:当前仅为 RDC 尚未运行本轮实现;`.13` 不能与新增 C++ Frame layout 配对,需后续同源码新版本构建。
- 下一责任:RDC 按 B→C 顺序执行并反馈真实日志;Android、iOS 设备与 Test Host 验收在后续配套阶段完成。当前停止于 Codex 源码交付,不进入 V15 pipeline。
## 2026-09-27 RDC 独立构建与定向测试补充(覆盖“本轮 NOT RUN”的后续事实)
- Codex `turn.completed` / exit 0 后,RDC 先执行 B;初次 runner 无 ninja PATH、Source 测试访问 private store、两处 fixture Grid 不合法,均已按首个真实错误做定向修复,记录见 `docs/报告/STEP-098_RDC定向验收报告_v1.md`。
- B OpenGL Core/Binding/Ingress 编译 PASS,19/19 GTest PASS。
- C1 OpenGL Host 扩展 `Particle*.*:Source.RasterArray*` 119/119 PASS。
- C2 Metal Host 编译 PASS,同 filter 119/119 PASS。
- 源级测试不等于 Android RN consumer/iOS 真机 GPU、内存/交互通过;V15 Pipeline 此时尚未启动。下一关必须为同源码发布构建和分端实测。

View File

@@ -0,0 +1,129 @@
# STEP-098 QBIN V4 单帧与八帧滚动缓存 Codex 完成报告 v1
日期:2026-09-26(JST)
结论:**IMPLEMENTED / WAITING_RDC_VALIDATION_AND_PLATFORM_RUNTIME**。Codex 源码阶段已交付;未构建、未运行 T01–T17,不能标记功能 PASS。
## 1. 执行身份与契约
- 执行器指定实际模型:**GPT-6 Luna High**。
- 仓库:Mac 原始正式源码目录 `/Users/tei/dev/MapLibreParticleLab`;branch `particle/main`。
- 起始/结束 HEAD:`b1239ac3b1554943200e6fbf14321459ccc86527`(本轮无 commit)。
- 起始时 `origin/particle/main` 同为 `b1239ac3b1554943200e6fbf14321459ccc86527`。
- Integration `main`/`origin/main`:`1dd576d3304e267e53ed64d31f9711d2350ce10a`,工作树干净。
- 本轮依据 RFC-004、ADR-001、C-002、COORD-005。新契约状态为已接受的设计决定,非代码/设备/V15 发布验收。旧 RFC-003/COORD-004 B 已按用户指示作废删除;此事实解除 `STEP-098_公共契约阻塞报告_v1.md` 的历史阻塞,本轮未修改旧报告或原任务 v1。
- 本机 Android Native/RN 版本文件仍为 `13.4.1-particle.13`,iOS VERSION 为 `6.28.0`。未更改版本锁、`.13` release/tag/manifest/AAR、独立 checkpoint worktree 或远端。
- `git submodule status` 与记录 SHA 一致。开工时工作树已有 STEP-097 B tracked/untracked 改动及 STEP-098 原任务等,共 29 条 status 项;均保留。没有执行 reset、clean、stage、commit 或 push。
## 2. 实施结果
### Core、Ingress、Resolver
- Store 按每个 `fieldKey` 最多 8 个帧升序存储。精确 validTime 返回真实单帧 T0=T1、mix=0;只有非精确请求且存在合法相邻 pair 时才做插值,未覆盖时间 fail-closed。
- 分离可共存 schema 与插值身份:相同 grid/plane schema 的新 reference/profile/fieldID 批次可进入窗口并按精确时刻单帧首显;双帧插值必须匹配 `fieldID`、QBIN `referenceTimeMs`、resolved `profileID/version` 和 grid/plane schema。Ingress 验证后保留 profile 身份,避免同 schema 不同 profile 被混插。
- 满额新时间插入使用临时候选有序集合:以新帧 validTime 为中心比较旧最早/最晚端点距离,远端先在候选集删除;等距按 C-002 删除原最早端。候选邻接 schema 验证成功后才 `swap`;失败不改 Store。满额正常有效插入仍返回既有 `AcceptedInserted`,`RejectedCapacity` 名称保留兼容。
- Duplicate、同时间 revision/stale/identity-conflict 仍走既有分支,不触发其他时间淘汰;submit 不触碰 forecast clock。每 key 可独立达到 8 帧。
- 增加原始 decoded 字节计数:每帧为有效性 bitset + 非 validity plane payload,Store/Series 汇总;不计 profile 元数据、Float32 物化快照、图像、GPU texture、particle/trail。
### Projection、Renderer、Bridge
- Binding 和 materialization 支持精确单帧,T0/T1 共享同一个物化 Snapshot。标量-only 帧仍不能成为向量 Particle projection。
- Particle 与 WeatherColor 首次安装同一 endpoint 时复用 image/texture/scale;当前 bracket 不变时 Source projection 继续沿用 existing equality/materialization reuse;被淘汰的活动端点重新 resolve 后失效清除。
- 实现位于 Shared Core/common Renderer,GL/Metal 共用语义;没有声明 Android 或 iOS runtime 通过。
- `FieldRuntime.ts`、`NativeWeatherRuntimeBridge.ts`、Android Kotlin/JNI 与 iOS Objective-C++/Facade 静态核对:旧 `submitEncodedField(...)`、`setEncodedForecastTime(...)` 名称、签名、返回名称和调用顺序未修改;bridge 没有本轮改动。
- **ABI 说明**:RN/平台 bridge 方法 ABI 未变;`DecodedFieldFrame` 新增内部 profile 字段,C++ 数据结构布局发生变化,需同源码重新构建 Native 消费端。未使用 `.13` AAR 冒充该 ABI 验证。
## 3. 本轮修改文件
### STEP-098 源码与测试
- `include/mbgl/particle/field/field_runtime.hpp`
- `src/mbgl/particle/field/field_runtime.cpp`
- `src/mbgl/particle/field/field_projection_materialization.cpp`
- `src/mbgl/particle/ingress/qbin_v2_decoder.cpp`
- `src/mbgl/renderer/layers/render_particle_validation_layer.cpp`
- `src/mbgl/renderer/layers/render_weather_color_layer.cpp`
- `test/particle/field_binding.test.cpp`
- `test/particle/field_runtime.test.cpp`
- `test/particle/qbin_v2_decoder.test.cpp`
- `test/source/raster_array_v4_host_api.test.cpp`
### 新增/追加文档
- 新增 `docs/30_设计/STEP-098_QBIN_V4单帧与八帧滚动缓存设计_v1.md`
- 新增 `docs/50_Step记录/STEP-098_QBIN_V4单帧与八帧滚动缓存_v1.md`
- 新增本报告 `docs/报告/STEP-098_QBIN_V4单帧与八帧滚动缓存_Codex完成报告_v1.md`
- 仅追加根目录 `粒子开发记录.md`
现存 dirty B 文件、STEP-098 原任务、旧阻塞报告与 B untracked helper/test 均保留。`test/particle/field_runtime.test.cpp` 和 `test/source/raster_array_v4_host_api.test.cpp` 开工前已有 B dirty 内容;其既有 fixture 修正未覆盖,本轮在其上增加 STEP-098 测试,并给 Source QBIN helper 加入 reference-time 参数。本轮没有新增 RN、Android/iOS bridge、Host/NavSea/Server 文件。
## 4. T01–T17 测试代码覆盖与真实运行状态
“代码覆盖”指对应源码测试场景已新增或由既有定向测试覆盖;所有行的**实际运行状态均为 NOT RUN**。没有将未运行测试写成 PASS。
| 项 | 代码覆盖 | 实际运行 |
| --- | --- | --- |
| T01 | 单帧精确 projection/materialization、标量-only 不产生向量、Source 色场/粒子单帧 | NOT RUN |
| T02 | 第二帧加入后中点进入双帧插值 | NOT RUN |
| T03 | 乱序升序;wind/wave 独立 key;批次边界不混插 | NOT RUN |
| T04 | 满 8 后未来帧保留、最早端淘汰 | NOT RUN |
| T05 | 满 8 后过去帧保留、最晚端淘汰 | NOT RUN |
| T06 | 内部时间等距 tie 删除原最早端并维持 8 帧有序 | NOT RUN |
| T07 | 新 reference/profile 批次精确首显、补帧升级、回到旧批次精确单帧 | NOT RUN |
| T08 | Duplicate、identity conflict、revision、stale 有既有 Core/Source 用例;无单个覆盖所有分支的新满额综合用例 | NOT RUN |
| T09 | 损坏/合同/数值/Grid schema 拒绝及旧投影/字节计数保持;既有 Binding 覆盖错误向量 | NOT RUN |
| T10 | pair mix 0.25/0.5/0.99 与真实 validTime;精确端点使用单帧表示 | NOT RUN |
| T11 | 非覆盖时间返回 WindowUnavailable | NOT RUN |
| T12 | 外围滚动时活动 pair 与 requestedTime 保持 | NOT RUN |
| T13 | 活动 endpoint 被淘汰后清除物化 projection | NOT RUN |
| T14 | wind 与 wave 各持 8 帧、各自字节计数 | NOT RUN |
| T15 | 同帧共用 Snapshot/首次 GPU endpoint 资源的源码路径;context loss/真实 GPU 未覆盖 | NOT RUN |
| T16 | Core 120 次双向滚动、8 帧上限、clear/clearAll 与 decoded byte 回落;Runtime/GPU 长期泄漏未覆盖 | NOT RUN |
| T17 | 现有 API/类型/方法顺序静态核对;自动化两步成功/失败 consumer 测试未运行 | NOT RUN |
## 5. 已执行与未执行验证
| 项目 | 实际结果 |
| --- | --- |
| `git diff --check` | **通过**,仅为空白与 patch 格式检查 |
| 新增 C++ 编译 | **NOT RUN** |
| Core/OpenGL GTest | **NOT RUN** |
| Metal host GTest / iOS 编译 | **NOT RUN** |
| Android Gradle、RN consumer、Test Host | **NOT RUN** |
| iOS/Xcode、Metal 设备 | **NOT RUN** |
| Jest/npm CI/ADB/模拟器/实体机 | **NOT RUN** |
| release pipeline、artifact、commit/push | **NOT RUN** |
RDC 按顺序执行以下命令,阶段 B 通过后才进入 C:
### 阶段 B:Store / Ingress / Binding
```bash
ninja -C build-step088-opengl mbgl-test-runner
build-step088-opengl/mbgl-test-runner --gtest_filter='ParticleFieldRuntime.*:ParticleFieldBinding.*:ParticleEncodedFieldIngress.*'
```
### 阶段 C:Source / Projection / Renderer(OpenGL 与 Metal host)
```bash
ninja -C build-step088-opengl mbgl-test-runner
build-step088-opengl/mbgl-test-runner --gtest_filter='Particle*.*:Source.RasterArray*'
ninja -C build-step097-metal-host mbgl-test-runner
build-step097-metal-host/mbgl-test-runner --gtest_filter='Particle*.*:Source.RasterArray*'
```
OpenGL/Metal host 命令均未在本轮执行。Metal host 结果也不能替代 iOS device runtime。
## 6. 手工验收、内存与剩余事项
Windows 11 Android 手工命令和 T01–T17 一轮场景清单见 [STEP-098 记录 §D](../50_Step记录/STEP-098_QBIN_V4单帧与八帧滚动缓存_v1.md)。只可使用后续相同源码生成且 Native/RN 版本匹配的 V15 Host APK、RN 包和 Native AAR;命令显式使用 `adb -s 192.168.200.139`。本轮未启动 V15 pipeline。iOS 需由 Mac 用户在同一冻结源码手工构建并用 Metal 设备检查单帧、双帧、批次边界、双向滚动、clear/context 恢复和长时内存。
CPU decoded 原始存储字节测量 API 已加入,测试源码含 120 次循环和 clear 后归零断言,但断言未运行。Float32 物化占用、GL/Metal 纹理、trail/GPU 峰值、32 帧风险和长时间内存趋势均未测量;不能据此报告内存安全 PASS。当前没有证据显示必须增加全局 MiB 上限,后续由 RDC/设备数据判断是否另行设计预算。
## 7. 工作树、阻塞与停止点
- 旧 `BLOCKED_CONTRACT` 不再适用,RFC-004 等新契约已正式接受。
- 本轮剩余门槛是 RDC 尚未构建/测试。测试失败、编译失败或状态未知时,停留该阶段并保留现场;不得跳过到平台/V15。
- STEP-097 B 的既有源码仍在本地工作树,原 Mac 工作树与 checkpoint worktree 均未重置或清理。本轮没有将 B 改动归入 STEP-098 报告修改集。
- 当前版本锁仍是 Native/RN `.13`;新增 `DecodedFieldFrame` C++ layout 不兼容证据尚未通过同版本新构建验证。不能用旧 `.13` AAR 替代。
- 未完成:RDC Core/Renderer 构建与 GTest、Android/iOS 同源码配套编译与真实 runtime、T01–T17 逐项实际执行、长时间内存/资源验证、V15 pipeline、版本制品与 Git 收尾。
- 本次停止点:**Codex 源码交付**。无 commit、push、release,也没有宣称 STEP-098 全部验收完成。

View File

@@ -0,0 +1,33 @@
# STEP-098 RDC 定向验收报告 v1
日期:2026-09-27(JST)
状态:**CORE / OPENGL_HOST / METAL_HOST_SOURCE_VALIDATION_PASS;PLATFORM_AND_RELEASE_PENDING**
仓库:Mac `/Users/tei/dev/MapLibreParticleLab`,执行前 `particle/main` HEAD `b1239ac3b1554943200e6fbf14321459ccc86527`,以此工作树叠加 STEP-097 B 及 STEP-098 实际源代码测试。
## 实际顺序和证据
1. Codex `GPT-6 Luna High` 源码运行 `step098-codex-20260926-224510` 的 JSONL 已有 `turn.completed`、退出码 0。设计/Step/完成报告存在;它自身没有编译,也不作为独立功能 PASS。
2. RDC B 初次运行 `step098-b-core-20260927-0833` 因 launchd PATH 不含 ninja 退出 127;核实原进程已结束才补 PATH 重新运行。
3. B 第一次真实编译 `step098-b-core-retry1-20260927-0834` 在 Source 测试引用不存在的 `Impl.encodedFieldStore` 处失败。按 public Source projection/seek 改测试断言,不改生产 API。下一次 `step098-b-core-retry2-20260927-0836` 编译 PASS,但 19 个定向测试中 2 个失败:测试 fixture 把 grid east=2 设成了小于 west=130 的非法 Grid,实际正确返回 `RejectedInvalidFrame`,而用例期待 `RejectedIncompatibleFrame`。将两处 east 改成 132(有效但与原 east=131 不兼容),没有降低生产校验。
4. B 再次执行 `step098-b-core-retry3-20260927-0838`:OpenGL Host Native C++ 编译退出 0,Core/Binding/Ingress 19/19 PASS;原始日志 `artifacts/maplibre-particle/step098-rdc/b-core-build.log`、`b-core-gtest.log`。
5. C1 `step098-c-opengl-20260927-0840`:Source/Projection/Renderer 扩展 OpenGL Host 119/119 PASS,日志 `c-opengl-gtest.log`。
6. C2 `step098-c-metal-host-20260927-0841`:Metal Host C++ 编译退出 0,同 filter 119/119 PASS,日志 `c-metal-host-build.log`、`c-metal-host-gtest.log`。现有 maplibre-tile-spec 个别对象的 macOS deployment target 警告非失败。
7. 全部新旧文件 `git diff --check` PASS;本记录时尚未执行正式 Android AAR、RN Test Host、iOS XCFramework、V15 Pipeline 或任何实体机验证。
## 未满足的门槛与边界
- `T01~T17` 已有对应源码/测试覆盖;上列 19/119/119 是实际运行的 Host GTest,不可将 T15 GPU/context loss、T16 长时 CPU/GPU 内存或 T17 RN 两步调用/真实 bridge 集成全部标 PASS。
- `DecodedFieldFrame` 新增 profile 身份,需用**同一冻结源提交**构建 Android/RN/iOS;现有正式 `.13` AAR 与旧 `.14-dev` 不能替代。
- V15 正式构建/发布需要先取得干净且确定的 release Git commit、检验本地空间、版本锁、脚本 prepare/build/publish/finalize 各自证据;公开版本不可覆盖。
- STEP-097 B 用户原工作内容必须保留,禁止 reset/clean、混入无关 untracked。按已授权的统一 V15 发行目标进行 scoped Git 收尾,不能将 Host 源级 PASS 冒称设备验收或正式 RELEASE_PASS。
## 真实统计
| 阶段 | 构建 | GTest | 结论 |
| --- | --- | --- | --- |
| B OpenGL Core/Binding/Ingress | PASS | 19/19 | PASS |
| C1 OpenGL Host | 已由 B 更新编译 | 119/119 | PASS |
| C2 Metal Host | PASS | 119/119 | PASS |
| Android/RN 同源配套 | NOT RUN | NOT RUN | PENDING |
| iOS XCFramework/设备 | NOT RUN | NOT RUN | PENDING |
| V15 Pipeline | NOT RUN | NOT RUN | PENDING |

View File

@@ -0,0 +1,47 @@
# STEP-098:公共契约闸门阻塞报告 v1
日期:2026-09-26(日本时间)
状态:**BLOCKED_CONTRACT / 未进入功能实施和发行**。
## 1. 当前真实基线
- 用户本轮要求:RDC 严格串行执行 STEP-098,并使用统一 Pipeline 发布 V15;未要求将历史 .14-dev AAR 当作 V15 产物。
- 源码仓库:Mac `/Users/tei/dev/MapLibreParticleLab`,分支 `particle/main`,本轮开工 HEAD 与公开 `origin/particle/main` 均为 `b1239ac3b1554943200e6fbf14321459ccc86527`。
- 该正式工作树已有 22 项修改/未跟踪内容(含 STEP-097 B 和本任务 MD);这些既有内容全部受保护,不得 reset、clean、覆盖或整体暂存。
- 本任务原始文件:`docs/40_任务/STEP-098_QBIN_V4单帧首显与八帧滚动缓存_Codex任务_v1.md`;其状态明确为“待契约闸门通过后执行”。
- Integration:Mac `/Users/tei/dev/navsea-particle-integration` 已执行 `git fetch origin main`;本地 `main` 和 `origin/main` 一致,均为 `318f8ccf911ac3136c3086a1d14e61f83e2d4bce`,工作树干净。
- 当前已安装正式版本:Android/RN `13.4.1-particle.13`,iOS 基础 VERSION `6.28.0`。先前 Native `13.4.1-particle.14-dev-d00d2ddc-bf4539890c6f` 仅为本地开发 AAR,不能替代 RN 联编、双平台测试或 V15 正式发行证据。
## 2. 契约冲突与停止原因
- `rfc/RFC-003_编码场两帧首显与增量补帧语义.md` 仍标为 `DRAFT/Shared Core 与 Android/iOS 评审待完成`,其目标语义第 1 项是至少两帧覆盖后才合法显示,第 4 项是超容量明确拒绝。
- STEP-098 的目标不同:一个有效时间的单帧可按真实 validTime 首显并驱动合法向量粒子;后续每 fieldKey 维持按时间升序的最多 8 帧,合法第 9 帧按新帧时间选择远端淘汰,正常满额提交不返回容量拒绝。
- `coordination/active/COORD-004/状态.md` 将 B 列为 `B_RFC_DRAFT`;同目录 `决议.md` 仅明确 A 的 React Style 方向,B 仍待调查。未发现正式接受 STEP-098 新语义的 RFC/ADR/COORD 决定或合同更新。现有 C-001 不可被当成这项新行为已经批准。
- 按 STEP-098 任务 §1 第 3~4 项,必须先由粒子负责人/Integration 正式接受且消除上述冲突,记录准确文件、版本、commit;本执行窗口不能把聊天目标、自身判断或 DRAFT 冒充正式跨平台决定。
## 3. 需要的正式协调决定
请由粒子负责人在 Integration 的 COORD-004/RFC-003 或新 ADR/Contract 中明确并正式接受:
1. 单帧真实时刻的首显和向量粒子行为;合法相邻两帧插值;不覆盖目标的旧帧必须 fail-closed,不能伪造数据或时间。
2. 每 fieldKey 独立的 8 帧排序/容量;正常已满时先验证、再原子淘汰并保留新帧;无效帧零副作用、不同 key 不相互挤占。
3. 未来/过去/内部插入的确定性端点淘汰,距离相同时的 tie-break,以及当前显示端点能否参与稳定优先级;禁止依赖尚未下发的新目标时间决定淘汰。
4. 同 validTime duplicate/revision/stale/conflict、dataset/profile/grid 身份、请求时间不被 submit 静默修改、现有 RN 两步调用、Renderer/GPU 资源复用与释放、内存边界及 Android/iOS 同一 Shared Core 契约。
5. 核对任务书中“13:00 ... 21:00 共 8 帧”的计数错误(若逐小时含两端实际为 9 个时间点),在正式合同及实施示例中写为确实 8 帧的区间。
6. 指明是否因生命周期、错误/数据所有权语义变化需要升级 C-001,以及适用的正式版本与跨平台责任。
以上正式结果若与 STEP-098 MD 的行为不一致,应先修订任务书,不得由 Android/iOS 各自选择不同语义。
## 4. Git 和 V15 Pipeline 独立门槛
- 用户提出 V15:统一脚本 `tools/particle_release_pipeline/release.sh 15` 的目标将是 Android/RN `13.4.1-particle.15` 与 iOS `6.28.0-particle.15`。这是待发行目标,不是已经构建/发布的事实。
- Pipeline 的 `prepare`、`build` 均要求工作树干净且版本锁正确;当前主工作树 dirty,不能直接运行。STEP-097 B 的未提交工作及正式版本锁需先按范围审计、受控收尾,不能 `git add -A` 或把 .14-dev 当正式 .15。
- Pipeline 的 public target 不得覆盖现有版本;正式发布必须在同一冻结 source commit 上完成 Android Native 四 ABI、RN、iOS Metal/XCFramework,分别验证,再检查 SHA、manifest、SwiftPM tag 与公网实际回读。
- Mac 只余约 11 GiB 可用空间,是完整构建前需要核实的容量风险;不能据此虚报构建失败或 PASS。
## 5. 本轮实际执行与未执行
- 已执行:RDC 只读 Git/任务/发布脚本审计、Integration fetch 和 SHA 对齐、RFC-003/COORD-004/Contract/职责核查。
- 未执行:Codex 功能实现、Core/Bridge/Renderer 修改、T01~T17 测试、Android/iOS 编译或设备验收、V15 Pipeline 的 prepare/build/publish/finalize、Git commit/push。
- 本报告是独立新增文件,不修改 STEP-098 MD、STEP-097 既有改动、Integration 文档、VERSION、历史制品或任何公网发行目录。
**当前结论:BLOCKED_CONTRACT。第一闸门未正式通过,不启动第二阶段,也不进行 V15 发行。**

View File

@@ -78,6 +78,10 @@ struct DecodedFieldGrid {
struct DecodedFieldFrame {
FrameContentIdentity frameContentIdentity;
uint64_t referenceTimeMs = 0;
// Validation provenance retained so a stored frame cannot be interpolated
// across two otherwise byte-compatible but semantically different profiles.
std::string resolvedProfileID;
uint32_t resolvedProfileVersion = 0;
DecodedFieldGrid grid;
// One frame-wide validity domain. The descriptor and payload share storage;
// projections can retain the same payload without copying it.
@@ -87,8 +91,12 @@ struct DecodedFieldFrame {
std::vector<DecodedFieldPlane> planes;
bool isStructurallyValid() const;
bool isSeriesCompatibleWith(const DecodedFieldFrame&) const;
bool isInterpolationCompatibleWith(const DecodedFieldFrame&) const;
bool hasSameContentAs(const DecodedFieldFrame&) const;
// Raw decoded plane payload plus the shared validity bitset; excludes
// separately materialized CPU snapshots and renderer/GPU resources.
uint64_t decodedByteSize() const;
};
enum class FieldSeriesSubmitResult : uint8_t {
@@ -118,6 +126,7 @@ public:
std::optional<FieldSeriesWindow> resolve(uint64_t requestedForecastTimeMs) const;
size_t size() const { return frames.size(); }
size_t capacity() const { return capacity_; }
uint64_t decodedByteSize() const;
const std::vector<std::shared_ptr<const DecodedFieldFrame>>& entries() const { return frames; }
private:
@@ -158,6 +167,7 @@ public:
bool clear(const std::string& fieldKey);
void clearAll() { series.clear(); }
size_t fieldCount() const { return series.size(); }
uint64_t decodedByteSize() const;
private:
size_t perSeriesCapacity_;

View File

@@ -192,11 +192,13 @@ std::optional<MaterializedColorProjection> materializeColorProjection(const Colo
};
auto t0 = std::make_shared<ParticleFieldSnapshot>(
baseSnapshot(*projection.t0Frame, u0->contentIdentity + "|" + v0->contentIdentity));
if (!vectorSnapshot(*projection.t0Frame, vectorBinding, *t0)) return {};
if (projection.t0Frame == projection.t1Frame)
return MaterializedColorProjection{t0, t0, projection.contentIdentity, projection.sampleState,
MaterializedColorValueSource::VectorMagnitude};
auto t1 = std::make_shared<ParticleFieldSnapshot>(
baseSnapshot(*projection.t1Frame, u1->contentIdentity + "|" + v1->contentIdentity));
if (!vectorSnapshot(*projection.t0Frame, vectorBinding, *t0) ||
!vectorSnapshot(*projection.t1Frame, vectorBinding, *t1))
return {};
if (!vectorSnapshot(*projection.t1Frame, vectorBinding, *t1)) return {};
return MaterializedColorProjection{std::move(t0),
std::move(t1),
projection.contentIdentity,
@@ -209,9 +211,13 @@ std::optional<MaterializedColorProjection> materializeColorProjection(const Colo
if (!p0 || !p1) return {};
auto t0 = std::make_shared<ParticleFieldSnapshot>(
baseSnapshot(*projection.t0Frame, projection.contentIdentity.t0Plane));
if (!scalarSnapshot(*projection.t0Frame, *p0, *t0)) return {};
if (projection.t0Frame == projection.t1Frame)
return MaterializedColorProjection{t0, t0, projection.contentIdentity, projection.sampleState,
MaterializedColorValueSource::Scalar};
auto t1 = std::make_shared<ParticleFieldSnapshot>(
baseSnapshot(*projection.t1Frame, projection.contentIdentity.t1Plane));
if (!scalarSnapshot(*projection.t0Frame, *p0, *t0) || !scalarSnapshot(*projection.t1Frame, *p1, *t1)) return {};
if (!scalarSnapshot(*projection.t1Frame, *p1, *t1)) return {};
return MaterializedColorProjection{std::move(t0),
std::move(t1),
projection.contentIdentity,
@@ -223,11 +229,12 @@ std::optional<MaterializedParticleProjection> materializeParticleProjection(cons
if (!projection.t0Frame || !projection.t1Frame || !compatible(*projection.t0Frame, *projection.t1Frame)) return {};
auto t0 = std::make_shared<ParticleFieldSnapshot>(
baseSnapshot(*projection.t0Frame, projection.contentIdentity.t0FirstPlane));
if (!vectorSnapshot(*projection.t0Frame, projection.contentIdentity.binding, *t0)) return {};
if (projection.t0Frame == projection.t1Frame)
return MaterializedParticleProjection{t0, t0, projection.contentIdentity, projection.sampleState};
auto t1 = std::make_shared<ParticleFieldSnapshot>(
baseSnapshot(*projection.t1Frame, projection.contentIdentity.t1FirstPlane));
if (!vectorSnapshot(*projection.t0Frame, projection.contentIdentity.binding, *t0) ||
!vectorSnapshot(*projection.t1Frame, projection.contentIdentity.binding, *t1))
return {};
if (!vectorSnapshot(*projection.t1Frame, projection.contentIdentity.binding, *t1)) return {};
return MaterializedParticleProjection{
std::move(t0), std::move(t1), projection.contentIdentity, projection.sampleState};
}

View File

@@ -2,6 +2,7 @@
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <limits>
#include <set>
@@ -119,7 +120,7 @@ bool DecodedFieldFrame::isStructurallyValid() const {
return true;
}
bool DecodedFieldFrame::isInterpolationCompatibleWith(const DecodedFieldFrame& other) const {
bool DecodedFieldFrame::isSeriesCompatibleWith(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) {
@@ -128,8 +129,15 @@ bool DecodedFieldFrame::isInterpolationCompatibleWith(const DecodedFieldFrame& o
return true;
}
bool DecodedFieldFrame::isInterpolationCompatibleWith(const DecodedFieldFrame& other) const {
return frameContentIdentity.fieldID == other.frameContentIdentity.fieldID &&
referenceTimeMs == other.referenceTimeMs && resolvedProfileID == other.resolvedProfileID &&
resolvedProfileVersion == other.resolvedProfileVersion && isSeriesCompatibleWith(other);
}
bool DecodedFieldFrame::hasSameContentAs(const DecodedFieldFrame& other) const {
if (referenceTimeMs != other.referenceTimeMs || !(grid == other.grid) ||
if (referenceTimeMs != other.referenceTimeMs || resolvedProfileID != other.resolvedProfileID ||
resolvedProfileVersion != other.resolvedProfileVersion || !(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) {
@@ -139,8 +147,29 @@ bool DecodedFieldFrame::hasSameContentAs(const DecodedFieldFrame& other) const {
return true;
}
uint64_t DecodedFieldFrame::decodedByteSize() const {
uint64_t total = validityBits ? validityBits->size() : 0;
for (const auto& plane : planes) {
if (!plane.storage) continue;
const auto bytes = static_cast<uint64_t>(plane.storage->size());
if (bytes > std::numeric_limits<uint64_t>::max() - total) return std::numeric_limits<uint64_t>::max();
total += bytes;
}
return total;
}
FieldSeries::FieldSeries(const size_t capacity) : capacity_(capacity) {}
uint64_t FieldSeries::decodedByteSize() const {
uint64_t total = 0;
for (const auto& frame : frames) {
const auto bytes = frame->decodedByteSize();
if (bytes > std::numeric_limits<uint64_t>::max() - total) return std::numeric_limits<uint64_t>::max();
total += bytes;
}
return total;
}
FieldSeriesSubmitResult FieldSeries::submit(std::shared_ptr<const DecodedFieldFrame> frame) {
if (!frame || !frame->isStructurallyValid()) return FieldSeriesSubmitResult::RejectedInvalidFrame;
const auto timestamp = frame->frameContentIdentity.validTimeMs;
@@ -156,30 +185,51 @@ FieldSeriesSubmitResult FieldSeries::submit(std::shared_ptr<const DecodedFieldFr
}
if (!newerRevision(frame->frameContentIdentity, current.frameContentIdentity))
return FieldSeriesSubmitResult::RejectedStaleRevision;
if (!frame->isInterpolationCompatibleWith(current))
if (!frame->isSeriesCompatibleWith(current))
return FieldSeriesSubmitResult::RejectedIncompatibleFrame;
if (it != frames.begin() && !frame->isInterpolationCompatibleWith(**std::prev(it)))
if (it != frames.begin() && !frame->isSeriesCompatibleWith(**std::prev(it)))
return FieldSeriesSubmitResult::RejectedIncompatibleFrame;
if (std::next(it) != frames.end() && !frame->isInterpolationCompatibleWith(**std::next(it)))
if (std::next(it) != frames.end() && !frame->isSeriesCompatibleWith(**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)))
if (capacity_ == 0) return FieldSeriesSubmitResult::RejectedCapacity;
// Build and validate the complete replacement before mutating the live
// window. A failed frame must never evict a previously accepted endpoint.
auto candidate = frames;
size_t insertionIndex = static_cast<size_t>(std::distance(frames.begin(), it));
if (candidate.size() >= capacity_) {
const uint64_t earliest = frames.front()->frameContentIdentity.validTimeMs;
const uint64_t latest = frames.back()->frameContentIdentity.validTimeMs;
const uint64_t distanceToEarliest = timestamp >= earliest ? timestamp - earliest : earliest - timestamp;
const uint64_t distanceToLatest = timestamp >= latest ? timestamp - latest : latest - timestamp;
// C-002: equidistant candidates evict the original earliest endpoint.
const size_t victimIndex = distanceToEarliest >= distanceToLatest ? 0 : frames.size() - 1;
if (victimIndex < insertionIndex) --insertionIndex;
candidate.erase(candidate.begin() + static_cast<std::ptrdiff_t>(victimIndex));
}
candidate.insert(candidate.begin() + static_cast<std::ptrdiff_t>(insertionIndex), std::move(frame));
const auto inserted = candidate.begin() + static_cast<std::ptrdiff_t>(insertionIndex);
if (inserted != candidate.begin() && !(*inserted)->isSeriesCompatibleWith(**std::prev(inserted)))
return FieldSeriesSubmitResult::RejectedIncompatibleFrame;
if (it != frames.end() && !frame->isInterpolationCompatibleWith(**it))
if (std::next(inserted) != candidate.end() && !(*inserted)->isSeriesCompatibleWith(**std::next(inserted)))
return FieldSeriesSubmitResult::RejectedIncompatibleFrame;
frames.insert(it, std::move(frame));
frames.swap(candidate);
return FieldSeriesSubmitResult::Inserted;
}
std::optional<FieldSeriesWindow> FieldSeries::resolve(const uint64_t requestedForecastTimeMs) const {
if (frames.empty()) return std::nullopt;
const auto exact = std::lower_bound(frames.begin(), frames.end(), requestedForecastTimeMs,
[](const auto& item, const uint64_t value) {
return item->frameContentIdentity.validTimeMs < value;
});
if (exact != frames.end() && (*exact)->frameContentIdentity.validTimeMs == requestedForecastTimeMs)
return FieldSeriesWindow{*exact, *exact, 0.0f, requestedForecastTimeMs};
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;
@@ -241,6 +291,16 @@ const FieldSeries* EncodedFieldStore::find(const std::string& fieldKey) const {
return it == series.end() ? nullptr : &it->second;
}
uint64_t EncodedFieldStore::decodedByteSize() const {
uint64_t total = 0;
for (const auto& [_, fieldSeries] : series) {
const auto bytes = fieldSeries.decodedByteSize();
if (bytes > std::numeric_limits<uint64_t>::max() - total) return std::numeric_limits<uint64_t>::max();
total += bytes;
}
return total;
}
bool EncodedFieldStore::clear(const std::string& fieldKey) {
return series.erase(fieldKey) != 0;
}

View File

@@ -119,6 +119,9 @@ EncodedFieldIngressResult EncodedFieldIngress::decodeAndValidate(const EncodedFi
validation.code,
{}};
}
return {EncodedFieldIngressCode::Accepted, "accepted", decodeCode, validation.code, std::move(frame)};
auto resolvedFrame = std::make_shared<DecodedFieldFrame>(*frame);
resolvedFrame->resolvedProfileID = c->profileID;
resolvedFrame->resolvedProfileVersion = c->profileVersion;
return {EncodedFieldIngressCode::Accepted, "accepted", decodeCode, validation.code, std::move(resolvedFrame)};
}
} // namespace mbgl::particle::ingress

View File

@@ -1574,8 +1574,16 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
gfx::Texture2DPtr textureT1;
float scaleT0 = 1.0f;
float scaleT1 = 1.0f;
if (!takeSlot(nextT0, "NEW_T0", assignedT0, imageT0, textureT0, scaleT0) ||
!takeSlot(nextT1, "NEW_T1", assignedT1, imageT1, textureT1, scaleT1)) {
if (!takeSlot(nextT0, "NEW_T0", assignedT0, imageT0, textureT0, scaleT0)) {
Log::Error(Event::Render, "ParticleGPU: field switch resource creation failed");
return;
}
if (particle::sameFieldGpuEndpoint(nextT0, nextT1)) {
assignedT1 = assignedT0;
imageT1 = imageT0;
textureT1 = textureT0;
scaleT1 = scaleT0;
} else if (!takeSlot(nextT1, "NEW_T1", assignedT1, imageT1, textureT1, scaleT1)) {
Log::Error(Event::Render, "ParticleGPU: field switch resource creation failed");
return;
}

View File

@@ -275,10 +275,16 @@ void RenderWeatherColorLayer::update(gfx::ShaderRegistry& shaders,
RenderStaticData::heatmapTextureVertices());
state->segments = RenderStaticData::heatmapTextureSegments();
state->valueSource = valueSource;
if (!state->shader ||
!uploadField(context, *sourceField, valueSource, state->image0, state->texture0, state->scale0) ||
!uploadField(context, *sourceNextField, valueSource, state->image1, state->texture1, state->scale1))
if (!state->shader || !uploadField(context, *sourceField, valueSource, state->image0, state->texture0,
state->scale0))
return;
if (particle::sameFieldGpuEndpoint(*sourceField, *sourceNextField)) {
state->image1 = state->image0;
state->texture1 = state->texture0;
state->scale1 = state->scale0;
} else if (!uploadField(context, *sourceNextField, valueSource, state->image1, state->texture1, state->scale1)) {
return;
}
state->t0 = *sourceField;
state->t1 = *sourceNextField;
resources = state;

View File

@@ -1,4 +1,5 @@
#include <mbgl/particle/field/field_binding.hpp>
#include <mbgl/particle/field/field_projection_materialization.hpp>
#include <gtest/gtest.h>
@@ -27,7 +28,8 @@ std::shared_ptr<const particle::DecodedFieldFrame> makeFrame(std::string field,
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;
// All frames in these series represent one forecast reference cycle.
frame.referenceTimeMs = Base;
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;
@@ -81,6 +83,51 @@ TEST(ParticleFieldBinding, ResolvesIndependentFieldKeyCadence) {
EXPECT_NE(c.projection->t0ValidityStorage, p.projection->t0ValidityStorage);
}
TEST(ParticleFieldBinding, SingleFrameProjectsAtItsExactTimeAndUpgradesToInterpolation) {
particle::EncodedFieldStore store;
submit(store, "weather.wind", makeFrame("wind", Base, 1, "single"));
const auto colorAtFrame = particle::BindingProjectionResolver::resolve(store, color("weather.wind"), Base);
const auto particleAtFrame = particle::BindingProjectionResolver::resolve(store, wind(), Base);
ASSERT_TRUE(colorAtFrame.accepted());
ASSERT_TRUE(particleAtFrame.accepted());
EXPECT_EQ(colorAtFrame.projection->t0Frame, colorAtFrame.projection->t1Frame);
EXPECT_EQ(particleAtFrame.projection->t0Frame, particleAtFrame.projection->t1Frame);
EXPECT_FLOAT_EQ(colorAtFrame.projection->sampleState.mix, 0.0f);
EXPECT_FLOAT_EQ(particleAtFrame.projection->sampleState.mix, 0.0f);
EXPECT_EQ(particle::BindingProjectionResolver::resolve(store, color("weather.wind"), Base + 1).error,
particle::BindingProjectionError::WindowUnavailable);
const auto colorSnapshot = particle::materializeColorProjection(*colorAtFrame.projection);
const auto particleSnapshot = particle::materializeParticleProjection(*particleAtFrame.projection);
ASSERT_TRUE(colorSnapshot);
ASSERT_TRUE(particleSnapshot);
EXPECT_EQ(colorSnapshot->t0, colorSnapshot->t1);
EXPECT_EQ(particleSnapshot->t0, particleSnapshot->t1);
EXPECT_EQ(colorSnapshot->t0->scalar, (std::vector<float>{1, 2, 3, 4}));
EXPECT_TRUE(particleSnapshot->t0->hasVector());
particle::EncodedFieldStore scalarOnly;
submit(scalarOnly, "weather.rain", makeFrame("rain", Base, 1, "scalar-only", false));
const auto scalarProjection = particle::BindingProjectionResolver::resolve(
scalarOnly, color("weather.rain"), Base);
ASSERT_TRUE(scalarProjection.accepted());
const auto scalarSnapshot = particle::materializeColorProjection(*scalarProjection.projection);
ASSERT_TRUE(scalarSnapshot);
EXPECT_TRUE(scalarSnapshot->t0->hasScalar());
EXPECT_FALSE(scalarSnapshot->t0->hasVector());
auto invalidParticleBinding = wind();
invalidParticleBinding.fieldKey = "weather.rain";
EXPECT_EQ(particle::BindingProjectionResolver::resolve(scalarOnly, invalidParticleBinding, Base).error,
particle::BindingProjectionError::PlaneMissing);
submit(store, "weather.wind", makeFrame("wind", Base + 4 * Hour, 1, "later"));
const auto interpolated = particle::BindingProjectionResolver::resolve(store, wind(), Base + 2 * Hour);
ASSERT_TRUE(interpolated.accepted());
EXPECT_NE(interpolated.projection->t0Frame, interpolated.projection->t1Frame);
EXPECT_FLOAT_EQ(interpolated.projection->sampleState.mix, 0.5f);
}
TEST(ParticleFieldBinding, MixChangesSampleStateButNotContentIdentityOrStorage) {
particle::EncodedFieldStore store;
submit(store, "weather.wave", makeFrame("wave", Base, 1, "0"));
@@ -92,9 +139,9 @@ TEST(ParticleFieldBinding, MixChangesSampleStateButNotContentIdentityOrStorage)
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->t0Frame, at0.projection->t1Frame);
EXPECT_EQ(at1.projection->contentIdentity, at2.projection->contentIdentity);
EXPECT_EQ(at1.projection->contentIdentity, at3.projection->contentIdentity);
EXPECT_EQ(at0.projection->t0PlaneStorage, at3.projection->t0PlaneStorage);
}

View File

@@ -20,6 +20,10 @@ particle::DecodedFieldPlane plane(uint16_t semantic, particle::FieldValueClass v
.contentIdentity = std::move(identity), .storage = std::move(storage)};
}
std::shared_ptr<const std::vector<uint8_t>> fourLittleEndian16BitSamples() {
return std::make_shared<const std::vector<uint8_t>>(std::vector<uint8_t>{1, 0, 2, 0, 3, 0, 4, 0});
}
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});
@@ -126,7 +130,7 @@ TEST(ParticleFieldProfileValidator, EnforcesVelocityDirectionAndValidityCoreRule
EXPECT_EQ(particle::FieldUnitNone, 0);
EXPECT_EQ(particle::FieldUnitMetersPerSecond, 1);
EXPECT_EQ(particle::FieldUnitDegrees, 4);
auto c = contract(); auto f = frame(); const auto bytes = f.planes.front().storage;
auto c = contract(); auto f = frame(); const auto bytes = fourLittleEndian16BitSamples();
f.planes.push_back(plane(100, particle::FieldValueClass::VelocityComponent, particle::FieldUnitMetersPerSecond, 3,
particle::FieldComponent::UEast, particle::FieldDType::Int16, false, bytes));
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::InvalidVelocityGroup);
@@ -153,6 +157,7 @@ TEST(ParticleFieldProfileValidator, EnforcesVelocityDirectionAndValidityCoreRule
f.planes.front().scale = 360.0f / 65536.0f;
c.requiredPlanes.front().semanticID = 10; c.requiredPlanes.front().valueClass = particle::FieldValueClass::DirectionAngle;
c.requiredPlanes.front().unitID = particle::FieldUnitDegrees;
f.planes.front().storage = bytes;
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::Accepted);
f.planes.front().unitID = 2;
c.requiredPlanes.front().unitID = 2;
@@ -178,7 +183,7 @@ TEST(ParticleFieldProfileValidator, EnforcesReservedCoreSemanticContractsFromPro
particle::FieldComponent::UEast, true};
f.planes.front() = plane(2, particle::FieldValueClass::VelocityComponent, particle::FieldUnitMetersPerSecond, 4,
particle::FieldComponent::UEast, particle::FieldDType::Int16, true,
f.planes.front().storage, "u-spoof");
fourLittleEndian16BitSamples(), "u-spoof");
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::SemanticContractMismatch);
c = contract(); f = frame();
@@ -195,7 +200,7 @@ TEST(ParticleFieldProfileValidator, EnforcesDirectionCircularQuantizationContrac
particle::FieldComponent::None, true};
f.planes.front() = plane(10, particle::FieldValueClass::DirectionAngle, particle::FieldUnitDegrees, 0,
particle::FieldComponent::None, particle::FieldDType::UInt16, true,
f.planes.front().storage, "direction");
fourLittleEndian16BitSamples(), "direction");
f.planes.front().scale = 360.0f / 65536.0f;
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::Accepted);
@@ -269,6 +274,160 @@ TEST(ParticleFieldRuntime, RevisionIdentityAndIndependentCadence) {
ASSERT_TRUE(wind); ASSERT_TRUE(wave); EXPECT_FLOAT_EQ(wind->mix, 0.5f); EXPECT_FLOAT_EQ(wave->mix, 1.0f / 6.0f);
}
TEST(ParticleFieldRuntime, ResolvesExactSingleFrameAndRejectsUncoveredTimes) {
constexpr uint64_t base = 1'700'000'000'000ULL;
particle::FieldSeries series;
ASSERT_EQ(series.submit(runtimeFrame(base, 1, 1, "only")), particle::FieldSeriesSubmitResult::Inserted);
EXPECT_EQ(series.decodedByteSize(), 5u);
const auto exact = series.resolve(base);
ASSERT_TRUE(exact);
EXPECT_EQ(exact->t0, exact->t1);
EXPECT_FLOAT_EQ(exact->mix, 0.0f);
EXPECT_EQ(exact->requestedForecastTimeMs, base);
EXPECT_FALSE(series.resolve(base + 1));
ASSERT_EQ(series.submit(runtimeFrame(base + 10'000, 1, 2, "next")),
particle::FieldSeriesSubmitResult::Inserted);
EXPECT_EQ(series.decodedByteSize(), 10u);
const auto middle = series.resolve(base + 5'000);
ASSERT_TRUE(middle);
EXPECT_NE(middle->t0, middle->t1);
EXPECT_FLOAT_EQ(middle->mix, 0.5f);
EXPECT_FALSE(series.resolve(base - 1));
EXPECT_FALSE(series.resolve(base + 10'001));
}
TEST(ParticleFieldRuntime, FullWindowRollsBothDirectionsAndBreaksTiesAtEarliest) {
constexpr uint64_t base = 1'700'000'000'000ULL;
constexpr uint64_t hour = 3'600'000ULL;
const auto fill = [&](particle::FieldSeries& series) {
for (uint64_t i = 0; i < 8; ++i)
ASSERT_EQ(series.submit(runtimeFrame(base + i * hour, 1, i + 1, "frame-" + std::to_string(i))),
particle::FieldSeriesSubmitResult::Inserted);
};
const auto times = [](const particle::FieldSeries& series) {
std::vector<uint64_t> values;
for (const auto& entry : series.entries()) values.push_back(entry->frameContentIdentity.validTimeMs);
return values;
};
particle::FieldSeries future;
fill(future);
ASSERT_EQ(future.submit(runtimeFrame(base + 8 * hour, 1, 9, "future")),
particle::FieldSeriesSubmitResult::Inserted);
EXPECT_EQ(future.size(), 8u);
EXPECT_EQ(times(future).front(), base + hour);
EXPECT_EQ(times(future).back(), base + 8 * hour);
particle::FieldSeries past;
fill(past);
ASSERT_EQ(past.submit(runtimeFrame(base - hour, 1, 9, "past")), particle::FieldSeriesSubmitResult::Inserted);
EXPECT_EQ(past.size(), 8u);
EXPECT_EQ(times(past).front(), base - hour);
EXPECT_EQ(times(past).back(), base + 6 * hour);
particle::FieldSeries internal;
fill(internal);
ASSERT_EQ(internal.submit(runtimeFrame(base + 3 * hour + hour / 2, 1, 9, "internal")),
particle::FieldSeriesSubmitResult::Inserted);
EXPECT_EQ(internal.size(), 8u);
EXPECT_EQ(times(internal), (std::vector<uint64_t>{base + hour, base + 2 * hour, base + 3 * hour,
base + 3 * hour + hour / 2, base + 4 * hour,
base + 5 * hour, base + 6 * hour, base + 7 * hour}));
}
TEST(ParticleFieldRuntime, UnorderedSubmissionsAreSortedAndReferenceCyclesStaySeparate) {
constexpr uint64_t base = 1'700'000'000'000ULL;
constexpr uint64_t hour = 3'600'000ULL;
particle::EncodedFieldStore store;
for (const uint64_t index : {4ULL, 1ULL, 3ULL, 0ULL, 2ULL})
ASSERT_EQ(store.submit("wind", runtimeFrame(base + index * hour, 1, index + 1,
"wind-" + std::to_string(index))),
particle::FieldSeriesSubmitResult::Inserted);
const auto* series = store.find("wind");
ASSERT_TRUE(series);
ASSERT_EQ(series->size(), 5u);
for (size_t i = 0; i < series->size(); ++i)
EXPECT_EQ(series->entries()[i]->frameContentIdentity.validTimeMs, base + i * hour);
auto differentReference = std::make_shared<particle::DecodedFieldFrame>(*runtimeFrame(
base + 5 * hour, 1, 6, "new-reference"));
differentReference->referenceTimeMs = 2'000;
differentReference->resolvedProfileID = "wind-profile-v2";
differentReference->resolvedProfileVersion = 2;
EXPECT_EQ(store.submit("wind", differentReference), particle::FieldSeriesSubmitResult::Inserted);
EXPECT_EQ(store.find("wind")->size(), 6u);
EXPECT_FALSE(store.resolve("wind", base + 4 * hour + hour / 2));
const auto exactNewBatch = store.resolve("wind", base + 5 * hour);
ASSERT_TRUE(exactNewBatch);
EXPECT_EQ(exactNewBatch->t0, exactNewBatch->t1);
auto incompatibleGrid = std::make_shared<particle::DecodedFieldFrame>(*runtimeFrame(
base + 6 * hour, 1, 7, "wrong-grid"));
incompatibleGrid->grid.east = 132.0; // valid grid, incompatible with 131.0 bounds
EXPECT_EQ(store.submit("wind", incompatibleGrid), particle::FieldSeriesSubmitResult::RejectedIncompatibleFrame);
EXPECT_EQ(store.find("wind")->size(), 6u);
EXPECT_EQ(store.submit("wave", runtimeFrame(base, 1, 1, "wave", "wave")),
particle::FieldSeriesSubmitResult::Inserted);
EXPECT_EQ(store.find("wind")->size(), 6u);
EXPECT_EQ(store.find("wave")->size(), 1u);
}
TEST(ParticleFieldRuntime, RejectedFullWindowInsertDoesNotEvictAndKeysRemainIndependent) {
constexpr uint64_t base = 1'700'000'000'000ULL;
constexpr uint64_t hour = 3'600'000ULL;
particle::EncodedFieldStore store;
for (uint64_t i = 0; i < 8; ++i)
ASSERT_EQ(store.submit("wind", runtimeFrame(base + i * hour, 1, i + 1, "wind-" + std::to_string(i))),
particle::FieldSeriesSubmitResult::Inserted);
const auto bytesBeforeRejectedFrame = store.decodedByteSize();
auto incompatible = std::make_shared<particle::DecodedFieldFrame>(*runtimeFrame(
base + 8 * hour, 1, 9, "wrong-grid"));
incompatible->grid.east = 132.0; // valid grid, incompatible with 131.0 bounds
ASSERT_EQ(store.submit("wind", incompatible), particle::FieldSeriesSubmitResult::RejectedIncompatibleFrame);
ASSERT_EQ(store.find("wind")->size(), 8u);
EXPECT_EQ(store.decodedByteSize(), bytesBeforeRejectedFrame);
EXPECT_EQ(store.find("wind")->entries().front()->frameContentIdentity.validTimeMs, base);
for (uint64_t i = 0; i < 8; ++i)
ASSERT_EQ(store.submit("wave", runtimeFrame(base + (i + 20) * hour, 1, i + 1,
"wave-" + std::to_string(i), "wave-dataset")),
particle::FieldSeriesSubmitResult::Inserted);
EXPECT_EQ(store.find("wind")->size(), 8u);
EXPECT_EQ(store.find("wave")->size(), 8u);
EXPECT_EQ(store.find("wave")->entries().front()->frameContentIdentity.validTimeMs, base + 20 * hour);
EXPECT_FALSE(store.resolve("wave", base + 1));
}
TEST(ParticleFieldRuntime, RepeatedBidirectionalRollingAndClearStayBounded) {
constexpr uint64_t base = 1'700'000'000'000ULL;
constexpr uint64_t hour = 3'600'000ULL;
particle::EncodedFieldStore store;
auto fill = [&] {
for (uint64_t i = 0; i < 8; ++i)
ASSERT_EQ(store.submit("wind", runtimeFrame(base + i * hour, 1, i + 1,
"seed-" + std::to_string(i))),
particle::FieldSeriesSubmitResult::Inserted);
};
fill();
for (uint64_t iteration = 0; iteration < 120; ++iteration) {
const uint64_t time = iteration % 2 == 0 ? base + (8 + iteration) * hour
: base - (iteration + 1) * hour;
ASSERT_EQ(store.submit("wind", runtimeFrame(time, 1, iteration + 20,
"rolling-" + std::to_string(iteration))),
particle::FieldSeriesSubmitResult::Inserted);
ASSERT_EQ(store.find("wind")->size(), 8u);
EXPECT_EQ(store.decodedByteSize(), 40u);
if (iteration % 10 == 9) {
EXPECT_TRUE(store.clear("wind"));
EXPECT_EQ(store.decodedByteSize(), 0u);
fill();
}
}
store.clearAll();
EXPECT_EQ(store.fieldCount(), 0u);
EXPECT_EQ(store.decodedByteSize(), 0u);
}
TEST(ParticleFieldRuntime, ForecastClockOwnsRequestedTimeAndPlayback) {
particle::ForecastClock clock;
EXPECT_TRUE(clock.setRequestedForecastTime(1'700'000'000'000ULL)); EXPECT_FALSE(clock.advance(1'000));

View File

@@ -91,3 +91,12 @@ TEST(ParticleQbinV2Decoder, PlaneIdentityIsIndependentAndCanonical) { auto ps=ba
TEST(ParticleQbinV2Decoder, DecodesLargeFixtureByteForByte) { auto b=fixture(513,769);auto r=particle::ingress::QbinV2Decoder::decode(meta(),b);ASSERT_TRUE(r.accepted())<<r.detail;const uint64_t cells=513u*769u;const size_t uv=cells*2, validity=(cells+7)/8;EXPECT_EQ(b.size()-208,a16(a16(uv)+uv)+a16(validity));EXPECT_EQ(r.frame->planes[0].storage->size(),uv);EXPECT_EQ(r.frame->planes[1].storage->size(),uv);EXPECT_EQ(r.frame->validityBits->size(),validity);EXPECT_EQ(r.frame->validityPlane.storage,r.frame->validityBits);EXPECT_EQ(*r.frame->planes[0].storage,std::vector<uint8_t>(b.begin()+208,b.begin()+208+uv));EXPECT_EQ(*r.frame->planes[1].storage,std::vector<uint8_t>(b.begin()+208+a16(uv),b.begin()+208+a16(uv)+uv));EXPECT_EQ(*r.frame->validityBits,std::vector<uint8_t>(b.begin()+208+a16(a16(uv)+uv),b.begin()+208+a16(a16(uv)+uv)+validity)); }
TEST(ParticleEncodedFieldIngress, AcceptsAndRejectsContracts) { auto good=wind();EXPECT_TRUE(particle::ingress::EncodedFieldIngress::decodeAndValidate(meta(),&good,fixture()).accepted());EXPECT_EQ(particle::ingress::EncodedFieldIngress::decodeAndValidate(meta(),nullptr,fixture()).code,particle::ingress::EncodedFieldIngressCode::ContractMissing);auto invalid=good;invalid.contractSchemaVersion=2;EXPECT_EQ(particle::ingress::EncodedFieldIngress::decodeAndValidate(meta(),&invalid,fixture()).code,particle::ingress::EncodedFieldIngressCode::ContractRejected);auto missing=good;missing.requiredPlanes.pop_back();EXPECT_EQ(particle::ingress::EncodedFieldIngress::decodeAndValidate(meta(),&missing,fixture()).code,particle::ingress::EncodedFieldIngressCode::ContractRejected); }
TEST(ParticleEncodedFieldIngress, RejectsKnownCoreConflictAndAcceptsUnknownOpaqueScalar) { auto ps=basePlanes();ps[0]={1,65535,0,3,1,0,0,1,0,{1,2,3,4}};auto c=contract({{1,particle::FieldValueClass::Scalar,65535,0,particle::FieldComponent::None,true},{2,particle::FieldValueClass::VelocityComponent,1,1,particle::FieldComponent::VNorth,true},{3,particle::FieldValueClass::Validity,0,0,particle::FieldComponent::None,true}});EXPECT_EQ(particle::ingress::EncodedFieldIngress::decodeAndValidate(meta(),&c,fixture(2,2,ps)).code,particle::ingress::EncodedFieldIngressCode::ContractRejected);ps={{3,0,0,6,1,3,0,1,0,{15}},{99,65535,0,3,1,0,0,1,0,{1,2,3,4}}};c=contract({{3,particle::FieldValueClass::Validity,0,0,particle::FieldComponent::None,true},{99,particle::FieldValueClass::Scalar,65535,0,particle::FieldComponent::None,true}});EXPECT_TRUE(particle::ingress::EncodedFieldIngress::decodeAndValidate(meta(),&c,fixture(2,2,ps)).accepted()); }
TEST(ParticleEncodedFieldIngress, RetainsResolvedProfileIdentityForRuntimeWindowing) {
auto resolved = wind();
resolved.profileID = "wind.test.v2";
resolved.profileVersion = 2;
const auto ingress = particle::ingress::EncodedFieldIngress::decodeAndValidate(meta(), &resolved, fixture());
ASSERT_TRUE(ingress.accepted()) << ingress.detail;
EXPECT_EQ(ingress.frame->resolvedProfileID, "wind.test.v2");
EXPECT_EQ(ingress.frame->resolvedProfileVersion, 2u);
}

View File

@@ -10,6 +10,7 @@
#include <algorithm>
#include <cstring>
#include <limits>
#include <vector>
using namespace mbgl;
@@ -63,7 +64,8 @@ struct Plane {
std::vector<uint8_t> bytes;
};
std::vector<uint8_t> qbin(uint64_t validTime, bool scalar = false) {
std::vector<uint8_t> qbin(uint64_t validTime, bool scalar = false, uint8_t validity = 0x0f,
uint64_t referenceTime = Base) {
std::vector<Plane> planes;
if (scalar)
planes.push_back({99, 77, 0, 3, 0, 0, {1, 2, 3, 4}});
@@ -71,7 +73,15 @@ std::vector<uint8_t> qbin(uint64_t validTime, bool scalar = false) {
planes.push_back({1, 1, 1, 1, 1, 1, {1, 0, 2, 0, 3, 0, 4, 0}});
planes.push_back({2, 1, 1, 1, 1, 2, {5, 0, 6, 0, 7, 0, 8, 0}});
}
planes.push_back({3, 0, 0, 6, 3, 0, {15}});
for (auto& plane : planes) {
if (plane.type == 6) continue;
for (size_t cell = 0; cell < 4; ++cell) {
if (validity & (1u << cell)) continue;
const size_t bytesPerCell = plane.type == 1 || plane.type == 2 ? 2 : plane.type == 5 ? 4 : 1;
std::fill_n(plane.bytes.begin() + cell * bytesPerCell, bytesPerCell, uint8_t{0});
}
}
planes.push_back({3, 0, 0, 6, 3, 0, {validity}});
std::sort(planes.begin(), planes.end(), [](const Plane& a, const Plane& b) { return a.semantic < b.semantic; });
size_t payloadLength = 0;
for (const auto& plane : planes) payloadLength = aligned16(payloadLength + plane.bytes.size());
@@ -84,7 +94,7 @@ std::vector<uint8_t> qbin(uint64_t validTime, bool scalar = false) {
u16(bytes, 12, planes.size());
u32(bytes, 16, 2);
u32(bytes, 20, 2);
i64(bytes, 24, static_cast<int64_t>(validTime / 1000));
i64(bytes, 24, static_cast<int64_t>(referenceTime / 1000));
i64(bytes, 32, static_cast<int64_t>(validTime / 1000));
f64(bytes, 40, -10);
f64(bytes, 48, -10);
@@ -117,6 +127,11 @@ std::vector<uint8_t> qbin(uint64_t validTime, bool scalar = false) {
return bytes;
}
void recomputeQbinCrc(std::vector<uint8_t>& bytes) {
u32(bytes, 76, 0);
u32(bytes, 76, crc(bytes));
}
particle::ingress::EncodedFieldRuntimeMetadata metadata(const char* id, uint64_t generation, uint64_t version = 1) {
return {id, generation, version};
}
@@ -296,6 +311,62 @@ TEST(Source, RasterArrayV4HostApiRejectsKeepPublicProjectionStable) {
expectNoPartialMutation();
}
TEST(Source, RasterArrayV4RejectsNumericOverflowBeforeReplacingAcceptedRevision) {
style::RasterArraySource source{"v4-numeric-transaction"};
submit(source, "wind", "wind", Base, 1);
submit(source, "wind", "wind", Base + Hour, 2);
source.setParticleBinding({particle::ParticleBindingMode::VectorComponents,
"wind",
{1, 1, particle::FieldComponent::UEast},
{2, 1, particle::FieldComponent::VNorth},
{},
1});
ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour / 2));
const auto baseline = source.resolveParticleProjection();
ASSERT_TRUE(baseline.accepted());
const auto materialized = source.impl().v4RendererState.particle;
ASSERT_TRUE(materialized);
const auto countersBefore = particle::rendererDebugCounterSnapshot();
auto overflowing = qbin(Base + Hour);
const size_t firstPlaneDescriptor = Header;
f32(overflowing, firstPlaneDescriptor + 4, std::numeric_limits<float>::max());
f32(overflowing, firstPlaneDescriptor + 8, std::numeric_limits<float>::max());
recomputeQbinCrc(overflowing);
EXPECT_EQ(source.submitEncodedField("wind", metadata("wind", 3, 2), windContract(), overflowing),
style::RasterArrayEncodedFieldSubmitResult::RejectedContract);
const auto after = source.resolveParticleProjection();
ASSERT_TRUE(after.accepted());
expectSameParticleProjection(*baseline.projection, *after.projection);
EXPECT_EQ(after.projection->t1Frame, baseline.projection->t1Frame);
EXPECT_EQ(source.impl().v4RendererState.particle, materialized);
const auto countersAfter = particle::rendererDebugCounterSnapshot();
EXPECT_EQ(countersAfter.v4ProjectionMaterialize, countersBefore.v4ProjectionMaterialize);
EXPECT_EQ(countersAfter.fieldTextureUpload, countersBefore.fieldTextureUpload);
}
TEST(Source, RasterArrayV4KeepsValidityZeroCellsAsLegalNoData) {
style::RasterArraySource source{"v4-validity-nodata"};
EXPECT_EQ(source.submitEncodedField("wind", metadata("wind", 1), windContract(), qbin(Base, false, 0x0e)),
style::RasterArrayEncodedFieldSubmitResult::AcceptedInserted);
submit(source, "wind", "wind", Base + Hour, 2);
source.setParticleBinding({particle::ParticleBindingMode::VectorComponents,
"wind",
{1, 1, particle::FieldComponent::UEast},
{2, 1, particle::FieldComponent::VNorth},
{},
1});
ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour / 2));
const auto projection = source.resolveParticleProjection();
ASSERT_TRUE(projection.accepted());
const auto snapshots = particle::materializeParticleProjection(*projection.projection);
ASSERT_TRUE(snapshots);
ASSERT_EQ(snapshots->t0->valid.size(), 4u);
EXPECT_EQ(snapshots->t0->valid[0], 0);
EXPECT_EQ(snapshots->t0->valid[1], 1);
}
TEST(Source, RasterArrayV4HostApiResolvesIndependentBindingsFromOneForecastClock) {
style::RasterArraySource source{"v4"};
submit(source, "wind", "wind", Base, 1);
@@ -573,6 +644,128 @@ TEST(Source, RasterArrayV4RendererStateMaterializesBindingsAndMixWithoutTouching
EXPECT_FALSE(source.impl().v4RendererState.particle);
EXPECT_FALSE(source.impl().v4RendererState.color);
}
TEST(Source, RasterArrayV4SingleExactFrameMaterializesAndSecondFrameEnablesInterpolation) {
style::RasterArraySource source{"v4-single-first"};
submit(source, "wind", "wind", Base, 1);
source.setParticleBinding({particle::ParticleBindingMode::VectorComponents,
"wind",
{1, 1, particle::FieldComponent::UEast},
{2, 1, particle::FieldComponent::VNorth},
{},
1});
source.setColorBinding({"wind", {1, 1, particle::FieldComponent::UEast},
particle::FieldValueClass::VelocityComponent, 1});
ASSERT_TRUE(source.setRequestedForecastTime(Base));
ASSERT_TRUE(source.resolveParticleProjection().accepted());
ASSERT_TRUE(source.resolveColorProjection().accepted());
ASSERT_TRUE(source.impl().v4RendererState.particle);
ASSERT_TRUE(source.impl().v4RendererState.color);
EXPECT_EQ(source.impl().v4RendererState.particle->t0, source.impl().v4RendererState.particle->t1);
EXPECT_EQ(source.impl().v4RendererState.color->t0, source.impl().v4RendererState.color->t1);
EXPECT_FLOAT_EQ(source.impl().v4RendererState.particle->sampleState.mix, 0.0f);
EXPECT_EQ(source.impl().v4RendererState.particle->t0->validTime, Base);
EXPECT_TRUE(source.impl().v4RendererState.particle->t0->hasVector());
submit(source, "wind", "wind", Base + Hour, 2);
ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour / 2));
const auto pair = source.resolveParticleProjection();
ASSERT_TRUE(pair.accepted());
EXPECT_NE(pair.projection->t0Frame, pair.projection->t1Frame);
EXPECT_FLOAT_EQ(pair.projection->sampleState.mix, 0.5f);
EXPECT_EQ(source.impl().v4RendererState.particle->t0->validTime, Base);
EXPECT_EQ(source.impl().v4RendererState.particle->t1->validTime, Base + Hour);
}
TEST(Source, RasterArrayV4RollingWindowPreservesAnUnchangedActivePair) {
style::RasterArraySource source{"v4-rolling-window"};
for (uint64_t index = 0; index < 8; ++index)
submit(source, "wind", "wind", Base + index * Hour, index + 1);
source.setParticleBinding({particle::ParticleBindingMode::VectorComponents,
"wind",
{1, 1, particle::FieldComponent::UEast},
{2, 1, particle::FieldComponent::VNorth},
{},
1});
ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour + Hour / 2));
ASSERT_TRUE(source.resolveParticleProjection().accepted());
// Source-level test: verify public projection behavior; Core tests own the
// internal FieldSeries size/order assertions.
const auto retainedProjection = source.impl().v4RendererState.particle;
ASSERT_TRUE(retainedProjection);
submit(source, "wind", "wind", Base + 8 * Hour, 9);
EXPECT_EQ(source.impl().v4RendererState.particle->sampleState.requestedForecastTimeMs,
Base + Hour + Hour / 2);
EXPECT_EQ(source.impl().v4RendererState.particle, retainedProjection);
submit(source, "wind", "wind", Base - Hour, 10);
EXPECT_EQ(source.impl().v4RendererState.particle, retainedProjection);
ASSERT_TRUE(source.setRequestedForecastTime(Base - Hour));
const auto pastProjection = source.resolveParticleProjection();
ASSERT_TRUE(pastProjection.accepted());
ASSERT_TRUE(pastProjection.projection);
EXPECT_EQ(pastProjection.projection->t0Frame->frameContentIdentity.validTimeMs, Base - Hour);
EXPECT_EQ(pastProjection.projection->t1Frame->frameContentIdentity.validTimeMs, Base - Hour);
ASSERT_TRUE(source.setRequestedForecastTime(Base + 8 * Hour));
EXPECT_FALSE(source.resolveParticleProjection().accepted());
}
TEST(Source, RasterArrayV4EvictedActiveFrameClearsItsMaterializedProjection) {
style::RasterArraySource source{"v4-evicted-active-frame"};
for (uint64_t index = 0; index < 8; ++index)
submit(source, "wind", "wind", Base + index * Hour, index + 1);
source.setParticleBinding({particle::ParticleBindingMode::VectorComponents,
"wind",
{1, 1, particle::FieldComponent::UEast},
{2, 1, particle::FieldComponent::VNorth},
{},
1});
ASSERT_TRUE(source.setRequestedForecastTime(Base));
ASSERT_TRUE(source.impl().v4RendererState.particle);
EXPECT_EQ(source.impl().v4RendererState.particle->t0->validTime, Base);
EXPECT_EQ(source.impl().v4RendererState.particle->t0, source.impl().v4RendererState.particle->t1);
submit(source, "wind", "wind", Base + 8 * Hour, 9);
EXPECT_FALSE(source.resolveParticleProjection().accepted());
EXPECT_FALSE(source.impl().v4RendererState.particle);
}
TEST(Source, RasterArrayV4StoresNewReferenceAndProfileAsSingleFrameWithoutCrossBatchMix) {
style::RasterArraySource source{"v4-batch-boundary"};
ASSERT_EQ(source.submitEncodedField("wind", metadata("wind", 1), windContract(), qbin(Base)),
style::RasterArrayEncodedFieldSubmitResult::AcceptedInserted);
auto nextProfile = windContract();
nextProfile.profileID = "test.wind.v2";
ASSERT_EQ(source.submitEncodedField("wind", metadata("wind", 2), nextProfile,
qbin(Base + Hour, false, 0x0f, Base + Hour)),
style::RasterArrayEncodedFieldSubmitResult::AcceptedInserted);
source.setParticleBinding({particle::ParticleBindingMode::VectorComponents,
"wind",
{1, 1, particle::FieldComponent::UEast},
{2, 1, particle::FieldComponent::VNorth},
{},
1});
ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour / 2));
EXPECT_EQ(source.resolveParticleProjection().error, particle::BindingProjectionError::WindowUnavailable);
ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour));
const auto latestExact = source.resolveParticleProjection();
ASSERT_TRUE(latestExact.accepted());
EXPECT_EQ(latestExact.projection->t0Frame, latestExact.projection->t1Frame);
EXPECT_EQ(latestExact.projection->t0Frame->resolvedProfileID, "test.wind.v2");
ASSERT_EQ(source.submitEncodedField("wind", metadata("wind", 3), nextProfile,
qbin(Base + 2 * Hour, false, 0x0f, Base + Hour)),
style::RasterArrayEncodedFieldSubmitResult::AcceptedInserted);
ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour + Hour / 2));
const auto nextPair = source.resolveParticleProjection();
ASSERT_TRUE(nextPair.accepted());
EXPECT_FLOAT_EQ(nextPair.projection->sampleState.mix, 0.5f);
ASSERT_TRUE(source.setRequestedForecastTime(Base));
const auto returnedToEarlierBatch = source.resolveParticleProjection();
ASSERT_TRUE(returnedToEarlierBatch.accepted());
EXPECT_EQ(returnedToEarlierBatch.projection->t0Frame, returnedToEarlierBatch.projection->t1Frame);
}
TEST(Source, RasterArrayV4VelocityColorReconstructsDecodedUVIndependentlyOfParticles) {
style::RasterArraySource source{"v4-vector-color"};
submit(source, "wind", "wind", Base, 1);

View File

@@ -990,3 +990,21 @@ Android V3-08 单次综合验收已完成并关闭。Native `13.4.1-particle.2`
### 2026-09-26 A2 源码独立 scoped commit 核验
- A2 的 26 个生产源码、shader、TestApp-only V4 Golden fixture 和定向测试文件已固定为 `f49ffa68a17e0a505199bdbeccf07f8543f3a05f` 并推送,`origin/particle/main` 回读一致;没有 stage 用户未跟踪 STEP-098。完整代码/设备证据仍见 `docs/报告/STEP-097_A2_阶段验证记录_v3.md`,不因 Git 提交改变原 APK 的 `git_dirty=true` 来源事实。STEP-097 整体 PARTIAL,B/C 待用户指令。
### 2026-09-26 STEP-097 B 阶段(独立核查中,未关闭)
- 08/09/10/20 的 B 源码及测试入口已形成;Codex 使用 GPT-6 Luna High 的两次 `exec --json` 均取得 `turn.completed`,保存 JSONL/status.json。
- RDC Core 专项 4/4 PASS;旧 16-bit fixture 修正后 OpenGL broad 134/134 PASS。
- B10 新 Map 销毁 hook 不在正式 .13 Android AAR,Test Host 默认 .13 无法直接编译此 Kotlin 调用;B 记 PARTIAL/BLOCKED,不提交、不进入 C,见 B 独立验收报告。
- 2026-09-26 STEP-097 B:RDC 恢复 B10 / fixture 两个 `codex exec --json` 完成状态;Mac Core 6/6、139/139,Android Native JUnit 2/2、Java 编译、隔离 owner JUnit 4/4 通过。默认 .13 AAR 不含新销毁 hook,B 仍 PARTIAL/BLOCKED,C 和 STEP-098 未启动;证据见 B 专项报告。
- STEP-097 B 已将 21 个源码、测试和阻塞报告保存至独立远端分支 `step097-b-blocked-20260926`,首提交 `e463ba04ce956a54eed02b77f981eeeedd9a5534`。主线未推送不兼容桥接;B 仍 BLOCKED,Linux 隔离构建结果另记。
### 2026-09-26 STEP-098 Codex 源码交付
- 用户批准按 Integration `1dd576d3304e267e53ed64d31f9711d2350ce10a` 的 RFC-004、ADR-001、C-002、COORD-005 实施;该新接受契约解除旧 RFC-003 历史阻塞。原任务书与旧阻塞报告均未修改。
- Core 增加精确单帧窗口、同 key 不同批次共存但严格禁止跨 fieldID/reference/profile 插值、先候选验证再原子滚动淘汰、等距离淘汰原最早端;解码 profile 身份与 decoded raw-plane/validity byte count 可观测。单帧 materialized snapshot 和首次 GPU endpoint 安装复用。
- 新增/扩展 Core、Binding、Ingress、RasterArraySource 定向测试代码,覆盖 T01–T17 的源代码场景;所有新增/扩展测试均未运行。仅执行 `git diff --check`;没有 CMake、Ninja、Gradle、Pod、npm、Jest、ADB、设备、发布、commit 或 push。
- 完成报告 `docs/报告/STEP-098_QBIN_V4单帧与八帧滚动缓存_Codex完成报告_v1.md`;源码交付等待 RDC 按 B→C 构建测试和后续 Android/iOS 配套实测。当前不进入 V15 pipeline。
- 2026-09-27 STEP-098 RDC:Core B 19/19、OpenGL Host 119/119、Metal Host 119/119 定向 GTest PASS;修复 Source 私有 Store 测试断言和两处无效 Grid fixture。Android/RN/iOS 制品、真机 GPU/内存与 V15 Pipeline 尚未验证;报告见 docs/报告/STEP-098_RDC定向验收报告_v1.md。