test: benchmark particle-only C FastPath CPU across R layouts
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled

This commit is contained in:
tei
2026-10-11 18:02:33 +09:00
parent 1072816740
commit 32ac795fc4
10 changed files with 710 additions and 87 deletions

View File

@@ -64,3 +64,13 @@ Mac Metal原生CMake配置首次因PATH找不到cmake而失败,改用既有/us
- Test App 增加三种 `RCRR_*_NO_TIDE` 场景,分别测试窄 C、宽 C 与错误动态 R;基于上述真实 Manifest 校准成 Native64 精确 R/C 区间,未修改正式 PBF/原场景/24 Gate。验证器精确验三段 R 边界;4项 Python unittest PASS、Style Manifest 静态索引核对 PASS、git diff --check PASS。
- **尚未完成**:新 Kotlin Test App 的用户手工 APK 编译及三场景真机 90秒回归、图像目视、Camera/Source/Style/Tile/Annotation/Context 真机失效和恢复、STEP119 13项 GTest 真跑、Metal/iOS。Mac 原有 libuv 缺失限制保持,不擅自安装或升级工具链。STEP-119 **PARTIAL,不 CLOSED**。
- 文档:`docs/60_测试/STEP-119_RCRR多配置验收矩阵_v1.md`、`docs/报告/STEP-119_RCRR多场景局限性_阶段验收_v1.md`。原始真机记录位于 ignored `artifacts/maplibre-particle/android-validation/step119-rcrr-observe-20261011_172720/partial_rcrr/`。
## RDC STEP-119 粒子独占 C 与 CPU 效率收敛(2026-10-11 JST)
- 用户确认:RCRR 正常运行的 `CONTINUOUS` 原则上**只含 `RasterParticleLayer`**,`WeatherColorLayer` 与其他非逐帧图层在 R;下一轮验收必须结合 CPU/帧、CPU/秒和实际 FPS,不只观察 FastDelta。
- RDC 已同步 Integration `main`=`80667d6c343411d03a760425438e3d292f452118`;Particle 以 `1072816740fa69b7b23cd70602a402be19e5be2a` 为起点,现有 `particle/main` 与 STEP110/111 未跟踪文件不变。
- Native 源码检查确认 `singleContinuousSegment(plan)` 是 Fast 准入条件,Fast 上传/绘制仅访问 C 范围,所有 R 需满足原24 Gate 与真实 Render/Symbol 缓存证书;WeatherColor 放 R 后是否能安全 Build/Reuse 尚待真机验证,不能提前 PASS。
- 将 `RCRR_C_NARROW_NO_TIDE` 改为粒子独占 C[32,33)(WeatherColor 在 R[0,32)),新增 RCR 两 R、RCRRR 四 R,以及同 Style/Plan 关闭缓存的 Full 对照;保留旧 C 宽、粒子误入 R 两种负例和原63层 `PARTIAL_RCRR`。菜单 ID 改为100~102避免新增enum与操作冲突。
- 新增严格 SHA/HEAD/设备锁的 CPU 采样器(App进程 utime+stime、6秒采样、35秒warmup、90秒观测)、A-B-A 对照比较器;对照同时列 CPU核秒/秒、CPU估算ms/完成帧、Native FPS、FastFraction≥95%、Build/Reuse/Invalidate。ADB实机只读确认 `CLK_TCK=100`,未以旧APK冒充新测试;未进行Android编译。
- Python 非Jest结构+CPU测试 12项 PASS。正式测试矩阵 `docs/60_测试/STEP-119_RCRR多配置验收矩阵_v2.md`,本轮阶段报告 `docs/报告/STEP-119_粒子独占C与CPU效率_阶段验收_v1.md`。
- **待验**:用户手工编译、安装新APK及Full-Fast-Full CPU实测,三种粒子独占C布局的Build/Reuse/Fast、视觉等价、变更后失效与恢复,13项GTest/Metal/iOS。STEP-119继续PARTIAL、不宣称CPU性能提升。

View File

@@ -0,0 +1,93 @@
# STEP-119 RCRR 粒子独占 C 与 CPU 效率测试矩阵 v2
日期:2026-10-11 JST。状态:**TEST SOURCE PREPARED / ANDROID NOT RUN / STEP-119 PARTIAL**。本 v2 取代 v1 对“窄 C 包含 WeatherColor+Particle”的测试建议;v1 保留为历史证据,不改写当时实机事实。
## 一、目标与核心原则
原则:**一个 Continuous 段只放 RasterParticleLayer**。WeatherColorLayer 不是逐帧运动图层,正常预测/颜色不变化期间可作为 R 候选,但数据提交、forecast time、色阶、Opacity、Style、Camera、Source/Tile/Atlas 等发生实际结果变化时必须安全撤销旧缓存并 Full,再重 Build/Reuse。这些具体动态失效条件仍需真机证明,不能因为分段配置正确而提前视为 PASS。
Native 当前代码以 `singleContinuousSegment(plan)` 选择唯一 C;快路只在这个 C 范围执行 tweaker/upload/postRender,全部 R 需具备有效缓存、Revision 与 Symbol 结果证书;现有 24 Gate 不能放宽。因而“一个 C + 多个 R”符合技术实现边界,但不是任意 R 划分都必然适合 Fast。
## 二、可比 Style 与真实图层顺序
正式原 PBF Style 63 层、测试副本只排除无源 `tide-spots`,其余 62 层定义和次序完全保留,WeatherColor / Particle **按原插入点顺序**位于 `clip-outline` 之后,Native 预计总 64 层。基于设备导出与原正向成功相同指纹的 Style Manifest:
- R 下部 [0,32):含基础海图 [0,31),并在索引31绘制 `rn-weather-color`;WeatherColor 留在 R 中。
- Particle C 唯一图层 [32,33):`rn-weather-particles`。
- 上部 R [33,64):各类静态海图、Symbol 和文字。按测试目的拆成 1/2/3 个 R 片段,不改实际图层顺序。
**不要在 NavSea 产品中按本测试索引硬编码**;生产实际 Style 上应按图层 ID 验证真实边界。
## 三、新一轮候选与负对照
| 场景 | 预期分段 | 目的 |
|---|---|---|
| `RCRR_FULL_CONTROL_NO_TIDE` | R[0,32) C[32,33) R[33,63) R[63,64) | 与主 RCRR 相同 Style/Plan/Field,但关闭 Retained 缓存,取得 Full CPU 基线 |
| `RCRR_C_NARROW_NO_TIDE` | R[0,32) C[32,33) R[33,63) R[63,64) | 主 RCRR 正向,仅粒子进入 C;3 个 R |
| `RCR_PARTICLE_ONLY_NO_TIDE` | R[0,32) C[32,33) R[33,64) | 仅 2 个 R,测缓存片段数量收益/开销 |
| `RCRRR_PARTICLE_ONLY_NO_TIDE` | R[0,32) C[32,33) R[33,38) R[38,63) R[63,64) | 4 个 R,测额外 RenderTarget/Composite/缓存生命周期成本 |
| `RCRR_C_WIDE_NO_TIDE` | R[0,2) C[2,38) R[38,63) R[63,64) | 历史宽 C 错误配置,继续作为负对照,不作为性能候选 |
| `RCRR_R_DYNAMIC_NO_TIDE` | R[0,33) C[33,38) R[38,63) R[63,64) | Particle 在 R 内的错误配置,必须 Fail-Closed |
| 原 `PARTIAL_RCRR` | R[0,3) C[3,39) R[39,64) R[64,66) | **已经实测**:R0 缺 tide、R2/R3 各 Build1/Reuse536、FastDelta0/FullDelta596、decisionMismatch0 |
旧正向 `STATIC_RC_NO_TIDE` 用于证明原有 FastPath(FastDelta1819),但天气层位置与粒子独占 C 的四个 A/B 场景不相同,**不能直接拿其 CPU 数据得出 RCRR 快慢结论**。
## 四、CPU 指标及可比性
使用 Android `/proc/<PID>/stat` 同一进程的 `utime+stime`(含 App 主线程与原生工作线程),以设备实际 `getconf CLK_TCK` 转换为 CPU 秒。记录该进程在观察期内消耗的累计 CPU 核秒。设备实测此接口可访问且 `CLK_TCK=100`,此预检不是新 APK 实测。
每场景冷启动不同 PID,固定设备、x86_64 OpenGL APK、Style URL/PBF fingerprint、中心/zoom/rotation、1920×1200 设备条件、Weather 数据、forecast pause、1024 粒子和目标 FPS30。先 Warmup35 秒(等待载入/Placement/Cache settle),再观察至少 90 秒;每 6 秒读取一次 CPU 累计值,场景 Native summary 同样按 6 秒采样,最多允许 CPU/Native 边界时间差 9 秒,否则记测量不完整。
同时必须记录:
- `CPU 核秒/秒 = Δ(utime+stime)/CLK_TCK/观察秒数`;乘100为**相对单核的进程 CPU%**,多线程时可超过100%。
- `Native FPS = (ΔFast+ΔFull)/Native摘要实际区间秒数`。
- `估算 CPU ms/完成帧 = (CPU核秒/秒) × 1000 / Native FPS`;这是进程级近似值,不是 C++ 单次 render 函数的精确 CPU 耗时。
- FastFraction、Full/Fast 增量、R Build/Reuse/Invalidate、24 Gate mismatch、源指纹、生命周期、帧率、帧率波动及进程 PID。
- Full→Fast→Full(A-B-A)控制热机/网络缓存时序偏差;Full 前后的 CPU 核秒/秒漂移超过15%则需要复测,不凭一次高速帧数下性能结论。
- 如果 Fast FPS 比 Full 高而 CPU/秒也提高,必须同时呈报“每帧成本下降”和“整机持续负载未必下降”。`CPUms/frame`改善不等于省电或 GPU time 已改善。
## 五、顺序执行(用户手工编译一次,其余通过 RDC)
必须先将本轮 Test App 修正提交/Push,**由用户手工使用当前 HEAD** 进行 Android APK 构建/安装;禁止用旧 `3d989a...` APK 冒充新场景。完成后 RDC 检查 build-info commit、SHA-256、设备 base.apk SHA 和目标 ABI。
```bash
cd /Users/tei/dev/MapLibreParticleLab
./tools/particle_android_validation/build_validation_apk.sh
adb -s 192.168.200.139:5555 install -r artifacts/maplibre-particle/android-validation/MapLibreAndroidTestApp-validation-opengl-debug-x86_64.apk
python3 -B tools/particle_android_validation/verify_step113_fast_path.py --serial 192.168.200.139:5555 --preflight-only
# 每条命令执行完读取状态/result.json,再进入下一条;A-B-A:
python3 -B tools/particle_android_validation/benchmark_step119_cpu.py --scenario RCRR_FULL_CONTROL_NO_TIDE --seconds 90 --warmup 35
python3 -B tools/particle_android_validation/benchmark_step119_cpu.py --scenario RCRR_C_NARROW_NO_TIDE --seconds 90 --warmup 35
python3 -B tools/particle_android_validation/benchmark_step119_cpu.py --scenario RCRR_FULL_CONTROL_NO_TIDE --seconds 90 --warmup 35
python3 -B tools/particle_android_validation/benchmark_step119_cpu.py --scenario RCR_PARTICLE_ONLY_NO_TIDE --seconds 90 --warmup 35
python3 -B tools/particle_android_validation/benchmark_step119_cpu.py --scenario RCRRR_PARTICLE_ONLY_NO_TIDE --seconds 90 --warmup 35
```
每次自动写入 Git 忽略目录:
`artifacts/maplibre-particle/android-validation/step119-rcrr-cpu/<时间>-<场景>/result.json`;同目录保存日志和启动记录。脚本全程真实读取现有 Native 摘要,不改 Gate、不执行 Gradle。验证器 Fast 场景须稳定 Fast 比例≥95%,Full 对照须 FastDelta=0、FullDelta>0。未满足时标记 `MIXED_OR_NO_FAST` 或 `NO_EXPECTED_FRAMES`,**不把回退帧混入 Fast 成本声称速度提升**。
在取得相应结果路径后使用:
```bash
python3 -B tools/particle_android_validation/compare_step119_cpu.py \
--full-before '<第1次Full/result.json>' \
--fast '<RCRR正向/result.json>' \
--full-after '<第2次Full/result.json>' \
--rcr '<RCR/result.json>' \
--rcrrr '<RCRRR/result.json>'
```
结果自动核验同 SHA/设备/Style 指纹、Native窗口、失配、Full控制与顺序漂移,输出 CPU per frame 节省比例、CPU 核秒/秒和 FPS 对照。若主 RCRR 尚不能稳定 Fast,不执行“有性能提升”的结论,只报告准确的 Gate 和成本。
## 六、其他 STEP-119 待验
相同视角的 Full/Fast 截图与 Symbol/文字/航标图层层序人工对比;Camera pan/zoom/rotate、Source/Style/Tile/Annotation/context/forecast 变更导致的证书失效和 Full → Build → Reuse 恢复;13 项新增 C++ GTest 真实运行和 macOS Metal/iOS 独立验证,均保持 NOT VERIFIED。Mac 现存 libuv CMake 配置缺口不授权升级第三方系统工具。全任务 STEP119 仍是 `PARTIAL`,本批仅准备测试能力,没有 Android 编译或新数据。
## 七、保护
- 不改变 Shared Core 公开 API、24 Gate、Renderer 行为、正式远端 PBF Style、发行 Tag/SOURCE_LOCK。
- 原 `STATIC_RC` 63 层与历史 RCRR/负对照保留。
- 不清理两份 STEP110/111 未跟踪文档。
- 本阶段只把 Test App/CPU 工具/中文报告/Step/开发记录按 Git 任务范围提交到已有 `particle/main`,Push后回读远端 HEAD。

View File

@@ -0,0 +1,51 @@
# STEP-119 粒子独占 C 与 CPU 效率阶段验收报告 v1
日期:2026-10-11(JST)。阶段结果:**SOURCE_READY / CPU_BENCH_READY / ANDROID_NEW_APK_NOT_RUN / STEP119_PARTIAL**。
## 一、可行性结论
用户确认 RCRR 的正常使用原则是 C 只放粒子,后续必须以实际 CPU 效率衡量 FastPath 而非单看 Fast 计数。源码检查确认:Native `tryRenderParticleFastFrame()` 使用 `singleContinuousSegment(plan)`;Fast 执行仅访问当前唯一 C 段的 LayerGroup,R 需全部缓存有效、无版本/内容/Placement/Annotation 等 24 Gate 安全问题。原则**有条件可行**,不等于 WeatherColor 放入 R 后已经获得 Fast 资格。
**没有取得新 APK 的 Full/Fast CPU 性能对比数值**;本报告仅完成测试场景、测量器与验收准备。不能宣称 CPU 性能提升、GTest 或 iOS 完成。
## 二、实际基线和执行状态
- 设备 Mac.lan,通过 RDC 操作 `/Users/tei/dev/MapLibreParticleLab`;原有 `particle/main` 分支,起点 `1072816740fa69b7b23cd70602a402be19e5be2a`。
- Integration `/Users/tei/dev/navsea-particle-integration` 已 `fetch main`、`merge --ff-only origin/main`,本地/远端一致 `80667d6c343411d03a760425438e3d292f452118`。已依据当前规则确认 Android/iOS/Shared Core 边界及项目 AGENTS。
- 既有设备 APK 源 commit `3d989a00a822989339a6d7cb4d23acc04396670e`、SHA-256 `57d68800770d431a2ce9247406f636e7a01cc19fccaf4c59f5eb3a7c99128135`;**未重新编译或安装,不得对新场景使用旧 APK 验收**。
- 之前的原 `PARTIAL_RCRR` 80秒真机负例保留:R0 因缺 tide Build=0/Reuse=0、R2/R3 各 Build=1/Reuse=536、FastDelta=0/FullDelta=596、decisionMismatch=0。旧 `STATIC_RC_NO_TIDE` 正向 Fast=1819,数据/图层顺序不同,不当作新 CPU 对照数据。
## 三、新 Test App 结构
不改生产 PBF、原 63 层场景或 Shared Core 的 24 Gate。Test App 在唯一无 tide 测试副本中得到 62 个基础层,插入的 WeatherColor 索引31、Particle 索引32。正常性能候选只允许 Particle C[32,33):
- `RCRR_C_NARROW_NO_TIDE`:R[0,32)、C[32,33)、R[33,63)、R[63,64)。
- `RCR_PARTICLE_ONLY_NO_TIDE`:R[0,32)、C[32,33)、R[33,64)。
- `RCRRR_PARTICLE_ONLY_NO_TIDE`:R[0,32)、C[32,33)、R[33,38)、R[38,63)、R[63,64)。
- `RCRR_FULL_CONTROL_NO_TIDE`:与第一组同源、同图层、同 RCRR Plan,但调用 `setRetainedUnderlayEnabled(false)`,目的是同屏 Full CPU 基线;预期原生 Fast=0,实际须设备证明。
老 `RCRR_C_WIDE_NO_TIDE`、`RCRR_R_DYNAMIC_NO_TIDE` 仍保留为错误划分安全负对照,不列为正常性能候选。原 `PARTIAL_RCRR` 和 `STATIC_RC` 及 v1 报告均保留历史事实。
Test App 菜单的操作 ID 从10/11/12迁移到100/101/102,避免新增 enum 场景 10/11 与 Snapshot、Mutate 冲突;Full 对照按钮显示 CACHE OFF。Native 摘要新增 `sampleElapsedMs` 与 benchmark 档 6 秒报告周期,仅用于关联 CPU 采样,不修改 Gate 或 Render 路径。
## 四、CPU 真实测量方案
新增 `tools/particle_android_validation/benchmark_step119_cpu.py`:在用户提供相同源码构建并正确安装的 APK 上,调用既有严格 `inspect()` 锁 commit、SHA、ABI/安装设备;启动目标 Activity,Warmup35秒,稳定观察90秒,按6秒读取 Android `/proc/PID/stat` 的 utime+stime 和 `/proc/uptime`。设备访问能力已只读检查:真实 `CLK_TCK=100`,`/proc/<PID>/stat` 可读取,但**CPU Benchmark 本身尚未对新 APK 运行**。
输出 `CPU Core Seconds`、`CPU Core Equivalent`(CPU%相对单核)、`Native FPS`、Fast/Full增量、FastFraction、估算 `CPU ms/完成帧` 和分段 Build/Reuse/Invalidation。CPU 为**整个 App 进程**的时间,并非 C++ renderer 独占的精准逐帧计时。Fast 稳定区间要求 FastFraction≥95%,Full 对照要求 Full>0、Fast=0,且 24 Gate mismatch=0;失败按原始事实保存 JSON 并返回非零。
新增 `tools/particle_android_validation/compare_step119_cpu.py`:读取 Full→Fast→Full A-B-A 实际结果与 RCR/RCRRR 结果,校验同设备/APK SHA/Style 指纹、场景生命周期和 CPU/Native 边界,将 Full 两轮作为 CPU/帧基线,计算各 Fast 组 CPU/帧变化比例及 CPU/秒和 FPS。若 Full 两端 CPU/秒漂移大于15%,结果标记需复测。此比较用于检验 CPU FastPath 收益,**不代表 GPU 时间、能耗或省电已经证明**;FPS 不同时必须同时呈报 CPU每秒与CPU每帧。
## 五、当前已执行验证与未执行项
- Python 非 Jest 结构/CPU 算法测试:12 项 PASS,运行退出码 0;涵盖4类有效性能结构与2类保守负例、错误分段/缺源/Annotation失败、原 STATIC_RC / 原 PBF63 边界、proc/stat解析与 CPU/fps 归一计算。
- CPU 工具 CLI 参数校验/AST:PASS;Android ADB 读设备时钟率:`100`(只读)。
- `git diff --check`:本阶段代码检查通过,最终 commit/push 信息以后续 RDC 实际状态补充。
- 新 Kotlin Test App 尚未经用户手工 Gradle 编译/打包;四种 CPU 场景 Native 真实 Full/Fast、CPU时间、帧率与图像仍为 **NOT VERIFIED**。
- Source/Style/Tile/Camera/Annotation 等失效及恢复、13项 STEP119 Native GTest、Metal/iOS 独立验证均未完成。Mac libuv 既有缺失不擅自装依赖/升级系统工具。
## 六、工程风险与下一步
只有在 C[32,33) 实际得到 Fast、视觉匹配及 CPU/帧或 CPU/秒的受控改善后,才考虑产品采用。多 R 可能提高 FBO/Composite 成本;WeatherColor 放 R 可能因当前 Core 类型/动态数据结果证书被保守拒绝;如果 Main CPU 指标改善但持续功耗/热度上升,也不等于总体有益。
详见 `docs/60_测试/STEP-119_RCRR多配置验收矩阵_v2.md`。本报告是阶段验收,不进入发行、不创建 Tag/SOURCE_LOCK,也不覆盖上轮或未跟踪的 STEP110/111。Git 以 RDC 实际新 commit/push/远端 SHA 返回为准。

View File

@@ -792,16 +792,16 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
Step113PbfGateScene.Scenario.values().forEachIndexed { index, scenario ->
menu.add(0, index, index, "STEP-113 正式 PBF · ${scenario.name}")
}
menu.add(0, 10, 10, "EXPORT LAST SNAPSHOT")
menu.add(0, 11, 11, "MUTATE PROBE SOURCE ONCE")
menu.add(0, 12, 12, "EXIT STEP-113")
menu.add(0, 100, 100, "EXPORT LAST SNAPSHOT")
menu.add(0, 101, 101, "MUTATE PROBE SOURCE ONCE")
menu.add(0, 102, 102, "EXIT STEP-113")
setOnMenuItemClickListener { item ->
when (item.itemId) {
in Step113PbfGateScene.Scenario.values().indices ->
startStep113Scene(Step113PbfGateScene.Scenario.values()[item.itemId])
10 -> step113Scene?.export()
11 -> step113Scene?.mutateSource()
12 -> { stopStep113Scene(); loadValidationStyle(active, enabled, "STEP113_EXIT") }
100 -> step113Scene?.export()
101 -> step113Scene?.mutateSource()
102 -> { stopStep113Scene(); loadValidationStyle(active, enabled, "STEP113_EXIT") }
}
true
}
@@ -827,7 +827,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
styleReady = false
++styleLoadEpoch // Cancel previous asynchronous style callbacks.
step113Scenario = scenario
retainedCacheEnabled = true
retainedCacheEnabled = scenario != Step113PbfGateScene.Scenario.RCRR_FULL_CONTROL_NO_TIDE
updateRetainedCacheButtonState()
step113DiagnosticsHeight = diagnostics.layoutParams.height
step113DiagnosticsMaxLines = diagnostics.maxLines

View File

@@ -29,7 +29,8 @@ internal class Step113PbfGateScene(
private val display: (String) -> Unit
) {
enum class Scenario { PBF63, STATIC_RC, PARTICLE_OFF, PARTIAL_RCRR, SOURCE_CHANGE, STATIC_RC_NO_TIDE,
RCRR_C_NARROW_NO_TIDE, RCRR_C_WIDE_NO_TIDE, RCRR_R_DYNAMIC_NO_TIDE }
RCRR_C_NARROW_NO_TIDE, RCRR_C_WIDE_NO_TIDE, RCRR_R_DYNAMIC_NO_TIDE,
RCR_PARTICLE_ONLY_NO_TIDE, RCRRR_PARTICLE_ONLY_NO_TIDE, RCRR_FULL_CONTROL_NO_TIDE }
private val handler = Handler(Looper.getMainLooper())
private var generation = 0
@@ -80,7 +81,8 @@ internal class Step113PbfGateScene(
}
})
val now = SystemClock.elapsedRealtime()
if (now - lastLogMs >= 30000L) {
val summaryIntervalMs = if (isCpuBenchmark(scenario)) 6000L else 30000L
if (now - lastLogMs >= summaryIntervalMs) {
lastLogMs = now
logRecord("summary", summary(native))
}
@@ -112,7 +114,11 @@ internal class Step113PbfGateScene(
ui.isDoubleTapGesturesEnabled(), ui.isQuickZoomGesturesEnabled())
ui.setAllGesturesEnabled(false)
map.cancelTransitions()
map.setRetainedUnderlayEnabled(true)
// CPU A/B control: exactly the same style, camera, weather and RCRR plan.
// Only the retained-cache enable bit differs.
val fullControl = scenario == Scenario.RCRR_FULL_CONTROL_NO_TIDE
map.setRetainedUnderlayEnabled(!fullControl)
commandResults.put("forceFullControl", fullControl)
if (!paused) handler.post(poll)
map.setStyle(Style.Builder().fromUri(STYLE_URL)) production@ { loaded ->
if (!running || epoch != generation) return@production
@@ -190,7 +196,7 @@ internal class Step113PbfGateScene(
check(!sources.has(SOURCE)) { "天气 Source ID 冲突" }
sources.put(SOURCE, JSONObject().put("type", "raster-array").put("host-field-submission", true))
val layers = JSONArray()
val prefix = isRcrrVariant(scenario)
val prefix = isSegmentedScenario(scenario)
for (i in 0 until effectiveBase.length()) {
layers.put(effectiveBase.getJSONObject(i))
if (prefix && effectiveBase.getJSONObject(i).getString("id") == "clip-outline") addFields(layers)
@@ -230,9 +236,12 @@ internal class Step113PbfGateScene(
val plan = planJson(scenario, effectiveIds.last(), effectiveIds[clipIndex + 1])
parity.put("testPlanSelector", when (scenario) {
Scenario.PARTIAL_RCRR -> "旧RCRR缺源负对照:R0包含缺源tide,C1混合天气与大量静态PBF"
Scenario.RCRR_C_NARROW_NO_TIDE -> "RCRR窄C:仅天气;两段后续静态R"
Scenario.RCRR_C_NARROW_NO_TIDE -> "RCRR粒子独占C:WeatherColor及海图均在R"
Scenario.RCRR_C_WIDE_NO_TIDE -> "RCRR宽C:天气及部分PBF;后续静态R"
Scenario.RCRR_R_DYNAMIC_NO_TIDE -> "RCRR错误动态R:天气落R0;验证安全拒绝"
Scenario.RCRR_R_DYNAMIC_NO_TIDE -> "RCRR负例:粒子进入R,C含普通海图"
Scenario.RCR_PARTICLE_ONLY_NO_TIDE -> "RCR粒子独占C:与RCRR同Style测试缓存分段开销"
Scenario.RCRRR_PARTICLE_ONLY_NO_TIDE -> "RCRRR粒子独占C:增加R段测试分段开销"
Scenario.RCRR_FULL_CONTROL_NO_TIDE -> "与粒子独占C的RCRR同Style/Plan;缓存禁用Full CPU基线"
else -> if (hasField) "当前NavSea源码RC:rn-weather-color" else "基础63层全RETAINED"
})
.put("requestedPlan", JSONObject(plan))
@@ -382,9 +391,12 @@ internal class Step113PbfGateScene(
counts.put(JSONArray().put(g.optString("reason")).put(g.optLong("failures")).put(g.optLong("firstFailures")))
}
val rs = JSONArray()
val segmentRanges = JSONArray()
val segments = native.optJSONArray("segments") ?: JSONArray()
for (i in 0 until segments.length()) {
val s = segments.getJSONObject(i)
segmentRanges.put(JSONObject().put("strategy", s.optString("strategy"))
.put("begin", s.optInt("beginIndex")).put("end", s.optInt("endExclusive")))
val c = s.optJSONObject("cache") ?: continue
rs.put(JSONObject().put("segment", s.optInt("segmentIndex")).put("begin", s.optInt("beginIndex"))
.put("end", s.optInt("endExclusive")).put("state", c.optString("cacheState"))
@@ -397,6 +409,7 @@ internal class Step113PbfGateScene(
.put("placementGatePassed", s.optBoolean("placementGatePassed"))
}
return JSONObject().put("schema", "step113-summary").put("schemaVersion", 1).put("scenario", scenario.name)
.put("sampleElapsedMs", SystemClock.elapsedRealtime())
.put("sceneReady", ready).put("blockedReason", failureReason ?: JSONObject.NULL)
.put("sceneGeneration", generation).put("full113Equivalent", false).put("basePreserved", parity.optBoolean("baseLayersPreserved"))
.put("originalBaseLayerCount", parity.optInt("baseLayerCount"))
@@ -415,7 +428,7 @@ internal class Step113PbfGateScene(
.put("processSimulationAdvance", lastPost(native)?.opt("processSimulationAdvance") ?: JSONObject.NULL)
.put("overwritten", t.optLong("overwritten")).put("decisionMismatch", t.optLong("decisionMismatch"))
.put("fullRendered", t.optLong("fullRendered")).put("fastRendered", t.optLong("fastRendered"))
.put("gateFailureCounts", counts).put("retained", rs)
.put("gateFailureCounts", counts).put("segmentRanges", segmentRanges).put("retained", rs)
.put("commands", JSONObject(commandResults.toString()))
.put("sources", native.optJSONArray("sources") ?: JSONArray())
.put("placementChanged", native.optBoolean("placementChanged"))
@@ -503,13 +516,19 @@ internal class Step113PbfGateScene(
private const val ANNOTATION_LAYER = "org.maplibre.annotations.points"
private const val FORECAST_MS = 1786060800000L
private fun isRcrrVariant(s: Scenario): Boolean = s == Scenario.PARTIAL_RCRR ||
private fun isSegmentedScenario(s: Scenario): Boolean = s == Scenario.PARTIAL_RCRR ||
s == Scenario.RCRR_C_NARROW_NO_TIDE || s == Scenario.RCRR_C_WIDE_NO_TIDE ||
s == Scenario.RCRR_R_DYNAMIC_NO_TIDE
s == Scenario.RCRR_R_DYNAMIC_NO_TIDE || s == Scenario.RCR_PARTICLE_ONLY_NO_TIDE ||
s == Scenario.RCRRR_PARTICLE_ONLY_NO_TIDE || s == Scenario.RCRR_FULL_CONTROL_NO_TIDE
private fun isCpuBenchmark(s: Scenario): Boolean = s == Scenario.RCRR_C_NARROW_NO_TIDE ||
s == Scenario.RCR_PARTICLE_ONLY_NO_TIDE || s == Scenario.RCRRR_PARTICLE_ONLY_NO_TIDE ||
s == Scenario.RCRR_FULL_CONTROL_NO_TIDE
private fun isNoTideVariant(s: Scenario): Boolean = s == Scenario.STATIC_RC_NO_TIDE ||
s == Scenario.RCRR_C_NARROW_NO_TIDE || s == Scenario.RCRR_C_WIDE_NO_TIDE ||
s == Scenario.RCRR_R_DYNAMIC_NO_TIDE
s == Scenario.RCRR_R_DYNAMIC_NO_TIDE || s == Scenario.RCR_PARTICLE_ONLY_NO_TIDE ||
s == Scenario.RCRRR_PARTICLE_ONLY_NO_TIDE || s == Scenario.RCRR_FULL_CONTROL_NO_TIDE
private fun planJson(s: Scenario, finalBaseId: String, firstAfterClipId: String): String {
val segments = JSONArray().put(JSONObject().put("strategy", "RETAINED"))
@@ -517,14 +536,27 @@ internal class Step113PbfGateScene(
val continuousStart = when (s) {
Scenario.PARTIAL_RCRR, Scenario.RCRR_C_WIDE_NO_TIDE -> "sea-area-fill"
Scenario.RCRR_R_DYNAMIC_NO_TIDE -> firstAfterClipId
else -> COLOR
else -> if (s == Scenario.RCRR_C_NARROW_NO_TIDE ||
s == Scenario.RCR_PARTICLE_ONLY_NO_TIDE ||
s == Scenario.RCRRR_PARTICLE_ONLY_NO_TIDE ||
s == Scenario.RCRR_FULL_CONTROL_NO_TIDE) PARTICLE else COLOR
}
segments.put(JSONObject().put("strategy", "CONTINUOUS").put("startLayerId", continuousStart))
if (isRcrrVariant(s)) {
val upperRetainedStart = if (s == Scenario.RCRR_C_NARROW_NO_TIDE)
if (isSegmentedScenario(s)) {
val particleOnlyC = s == Scenario.RCRR_C_NARROW_NO_TIDE ||
s == Scenario.RCR_PARTICLE_ONLY_NO_TIDE ||
s == Scenario.RCRRR_PARTICLE_ONLY_NO_TIDE ||
s == Scenario.RCRR_FULL_CONTROL_NO_TIDE
val upperRetainedStart = if (particleOnlyC)
firstAfterClipId else "special-pattern-line-754"
segments.put(JSONObject().put("strategy", "RETAINED").put("startLayerId", upperRetainedStart))
segments.put(JSONObject().put("strategy", "RETAINED").put("startLayerId", finalBaseId))
if (s == Scenario.RCRRR_PARTICLE_ONLY_NO_TIDE) {
segments.put(JSONObject().put("strategy", "RETAINED")
.put("startLayerId", "special-pattern-line-754"))
}
if (s != Scenario.RCR_PARTICLE_ONLY_NO_TIDE) {
segments.put(JSONObject().put("strategy", "RETAINED").put("startLayerId", finalBaseId))
}
}
}
return JSONObject().put("schemaVersion", 1).put("segments", segments).toString()

View File

@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""STEP-119 / RCRR CPU 对照。只启动已安装测试场景并读取真机CPU/Native计数,不编译/修改24 Gate。"""
import argparse
import json
import statistics
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
import verify_step113_fast_path as verifier
BENCH_SCENARIOS = (
"RCRR_FULL_CONTROL_NO_TIDE",
"RCRR_C_NARROW_NO_TIDE",
"RCR_PARTICLE_ONLY_NO_TIDE",
"RCRRR_PARTICLE_ONLY_NO_TIDE",
)
def parse_proc_stat(raw: str):
"""Linux /proc/PID/stat: comm may contain whitespace or ')'; indices are post-comm."""
close = raw.rfind(")")
if close <= 0 or not raw.startswith(raw.split(" ", 1)[0] + " ("):
raise ValueError("invalid /proc/PID/stat header")
rest = raw[close + 2:].split()
if len(rest) < 20:
raise ValueError("truncated /proc/PID/stat")
# Fields 14/15 utime/stime and field 22 starttime; rest starts at field 3.
return int(rest[11]) + int(rest[12]), int(rest[19])
def collect_result(samples, cpu_start, cpu_end, clock_ticks, elapsed):
native_start, native_end = samples
fast_delta = native_end["fastRendered"] - native_start["fastRendered"]
full_delta = native_end["fullRendered"] - native_start["fullRendered"]
interval = (native_end["sampleElapsedMs"] - native_start["sampleElapsedMs"]) / 1000
if interval <= 0 or fast_delta < 0 or full_delta < 0:
raise ValueError("invalid Native window/counters")
cpu_sec = (cpu_end["ticks"] - cpu_start["ticks"]) / clock_ticks
if cpu_sec < 0:
raise ValueError("CPU counter reset")
fps = (fast_delta + full_delta) / interval
cpu_cores = cpu_sec / elapsed
return {
"cpuSeconds": cpu_sec,
"cpuCoreEquivalent": cpu_cores,
"cpuPercentOfSingleCore": 100 * cpu_cores,
"nativeWindowSeconds": interval,
"nativeWindowFullFrames": full_delta,
"nativeWindowFastFrames": fast_delta,
"nativeObservedFps": fps,
"fastFraction": fast_delta / (fast_delta + full_delta) if fast_delta + full_delta else None,
"estimatedCpuMsPerNativeFrame": 1000 * cpu_cores / fps if fps else None,
"caveat": ("CPU时间为整个App进程,含UI、日志和其他线程;"
"帧数由邻近原生摘要窗口得出;CPU毫秒/帧为近似值,不是原生逐帧计时。"),
}
def run(args):
adb = args.adb or str(Path.home() / "Library/Android/sdk/platform-tools/adb")
info, sha, head, _ = verifier.inspect(verifier.APK, adb, args.serial)
def device(*argv):
return verifier.call([adb, "-s", args.serial, *argv])
expected_full = args.scenario == "RCRR_FULL_CONTROL_NO_TIDE"
clock_ticks = int(device("shell", "getconf", "CLK_TCK"))
if clock_ticks <= 0:
raise RuntimeError("invalid CLOCK_TICKS")
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
out = verifier.ARTIFACTS / "step119-rcrr-cpu" / f"{stamp}-{args.scenario.lower()}"
out.mkdir(parents=True, exist_ok=False)
launch = device("shell", "am", "start", "-S", "-n", verifier.ACTIVITY,
"--es", "step113_scenario", args.scenario)
(out / "launch.txt").write_text(launch + "\n", encoding="utf-8")
pid = None
for _ in range(12):
time.sleep(1)
pid_tokens = verifier.call([adb, "-s", args.serial, "shell", "pidof",
verifier.PACKAGE], check=False).split()
if pid_tokens:
pid = int(pid_tokens[0])
break
if pid is None:
raise RuntimeError("APP_PID_MISSING")
print(f"SCENARIO={args.scenario} PID={pid} SHA_MATCHED={sha} SOURCE={head}", flush=True)
time.sleep(args.warmup)
warm_log = device("logcat", "-d", "-v", "brief", "-s", "STEP113Gate:I", "*:S")
warm_samples = verifier.extract(warm_log, pid, args.scenario)
if not any(s.get("sceneReady") and not s.get("blockedReason") for s in warm_samples):
(out / "warmup_logcat.txt").write_text(warm_log, encoding="utf-8")
raise RuntimeError("scene not ready after warmup (saved log)")
def cpu_sample():
raw = device("shell", "sh", "-c", f"cat /proc/{pid}/stat; cat /proc/uptime")
lines = [line for line in raw.splitlines() if line.strip()]
if len(lines) != 2:
raise RuntimeError(f"unexpected /proc sample: {raw[:120]}")
ticks, born = parse_proc_stat(lines[0])
uptime = float(lines[1].split()[0])
return {"ticks": ticks, "startTimeTicks": born, "deviceElapsedMs": uptime * 1000,
"hostMonotonic": time.monotonic()}
observations = [cpu_sample()]
deadline = observations[0]["hostMonotonic"] + args.seconds
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
break
time.sleep(min(args.interval, remaining))
sample = cpu_sample()
if sample["startTimeTicks"] != observations[0]["startTimeTicks"]:
raise RuntimeError("process PID reused/restarted: cannot compare")
observations.append(sample)
first_cpu, last_cpu = observations[0], observations[-1]
elapsed = last_cpu["hostMonotonic"] - first_cpu["hostMonotonic"]
raw_log = device("logcat", "-d", "-v", "brief", "-s", "STEP113Gate:I", "*:S")
(out / "logcat.txt").write_text(raw_log + "\n", encoding="utf-8")
native = verifier.extract(raw_log, pid, args.scenario)
ready = [s for s in native if s.get("sceneReady") and not s.get("blockedReason")
and isinstance(s.get("sampleElapsedMs"), (float, int))]
def closest(device_time):
if not ready:
raise RuntimeError("no timestamped Native summary")
return min(ready, key=lambda s: abs(s["sampleElapsedMs"] - device_time))
a, b = closest(first_cpu["deviceElapsedMs"]), closest(last_cpu["deviceElapsedMs"])
offsets = [abs(a["sampleElapsedMs"] - first_cpu["deviceElapsedMs"]),
abs(b["sampleElapsedMs"] - last_cpu["deviceElapsedMs"])]
parity = all(verifier.scene_parity_valid(s, args.scenario) for s in (a, b))
same_lifecycle = (a.get("sceneGeneration"), a.get("lifecycle")) == (
b.get("sceneGeneration"), b.get("lifecycle"))
control_match = all(s.get("commands", {}).get("forceFullControl") == expected_full
for s in (a, b))
metrics = collect_result((a, b), first_cpu, last_cpu, clock_ticks, elapsed)
loads = []
for old, new in zip(observations, observations[1:]):
dt = new["hostMonotonic"] - old["hostMonotonic"]
if dt > 0:
loads.append((new["ticks"] - old["ticks"]) / clock_ticks / dt)
metrics["medianCpuCoreEquivalentPerInterval"] = statistics.median(loads) if loads else None
status = (
"INVALID_PARITY_OR_LIFECYCLE" if not parity or not same_lifecycle or not control_match else
"SUMMARY_ALIGNMENT_LIMIT" if max(offsets) > 9000 else
"DECISION_MISMATCH" if b.get("decisionMismatch") != 0 else
"FULL_CONTROL_OBSERVED" if expected_full and metrics["nativeWindowFullFrames"] > 0
and metrics["nativeWindowFastFrames"] == 0 else
"FAST_DOMINANT" if not expected_full and
metrics["fastFraction"] is not None and metrics["fastFraction"] >= 0.95 else
"MIXED_OR_NO_FAST" if not expected_full else "NO_EXPECTED_FRAMES"
)
data = {
"schema": "step119-rcrr-cpu-benchmark-v1",
"status": status, "sourceCommit": head, "apkSha256": sha,
"device": args.serial, "pid": pid, "scenario": args.scenario,
"fullControl": expected_full, "durationSecondsRequested": args.seconds,
"durationSecondsObserved": elapsed, "warmupSeconds": args.warmup,
"clockTicksPerSecond": clock_ticks, "nativeCount": len(ready),
"nativeCpuBoundaryOffsetMs": offsets, "sameLifecycle": same_lifecycle,
"parityValid": parity, "controlMatched": control_match,
"nativeStart": a, "nativeEnd": b,
"cpuObservations": observations, "metrics": metrics,
"performanceQualification": ("进程级CPU测量仅可与同APK/相同Style相机负载的对照比较;"
"应取Full-Fast-Full或交叉顺序重复,排除热机、缓存热度和帧率不同造成的误判;"
"不能用此数据宣称GPU时间、省电或逐帧CPU精确耗时。"),
}
(out / "result.json").write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8")
print(json.dumps({"status": status, "scenario": args.scenario, "metrics": metrics,
"evidence": str(out / "result.json")}, ensure_ascii=False), flush=True)
return 0 if status in ("FAST_DOMINANT", "FULL_CONTROL_OBSERVED") else 2
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--serial", default="192.168.200.139:5555")
parser.add_argument("--adb", default=None)
parser.add_argument("--scenario", choices=BENCH_SCENARIOS, required=True)
parser.add_argument("--seconds", type=int, default=90)
parser.add_argument("--warmup", type=int, default=35)
parser.add_argument("--interval", type=float, default=6.0)
args = parser.parse_args()
if args.seconds < 75 or args.warmup < 20 or args.interval < 1:
parser.error("观察至少75秒、Warmup至少20秒、采样间隔至少1秒")
try:
return run(args)
except (RuntimeError, OSError, ValueError, KeyError) as e:
print(f"STEP119_CPU_BENCH_BLOCKED: {e}", file=sys.stderr)
return 3
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""STEP-119 RCRR CPU效益对比;仅整合已采集真机JSON,不会重新启动设备/构建。"""
import argparse
import json
import statistics
from datetime import datetime
from pathlib import Path
def read(path):
data = json.loads(Path(path).read_text(encoding="utf-8"))
if data.get("schema") != "step119-rcrr-cpu-benchmark-v1":
raise ValueError(f"invalid schema: {path}")
return data
def check_common(reports):
ref = reports[0]
for r in reports:
for key in ("sourceCommit", "apkSha256", "device"):
if r.get(key) != ref.get(key):
raise ValueError(f"cross-run {key} mismatch: {r['scenario']}")
for key in ("fingerprint", "actualLayers"):
if r["nativeStart"].get(key) != ref["nativeStart"].get(key):
raise ValueError(f"style/actual layers mismatch {key}: {r['scenario']}")
if r["nativeStart"].get("actualLayers") != 64:
raise ValueError("not 64-layer isolated Test App")
if r["nativeEnd"].get("lifecycle") != r["nativeStart"].get("lifecycle"):
raise ValueError("scene lifecycle changed")
if r.get("nativeCpuBoundaryOffsetMs") is None or max(
r["nativeCpuBoundaryOffsetMs"]) > 9000:
raise ValueError("CPU/Native summary not well-aligned")
if not r.get("parityValid") or not r.get("controlMatched"):
raise ValueError("parity/control mismatch")
if r["nativeEnd"].get("decisionMismatch") != 0:
raise ValueError("FastGate decisionMismatch")
return {"commit": ref["sourceCommit"], "apkSha256": ref["apkSha256"],
"device": ref["device"], "styleFingerprint": ref["nativeStart"]["fingerprint"]}
def summarize(full_before, fast, full_after, others):
inputs = [full_before, fast, full_after, *others]
identity = check_common(inputs)
if full_before["status"] != "FULL_CONTROL_OBSERVED" or full_after[
"status"] != "FULL_CONTROL_OBSERVED":
raise ValueError("Full control run missing/invalid")
if full_before["scenario"] != "RCRR_FULL_CONTROL_NO_TIDE" or full_after[
"scenario"] != "RCRR_FULL_CONTROL_NO_TIDE":
raise ValueError("Full AB control is not the same RCRR scene")
if fast["scenario"] != "RCRR_C_NARROW_NO_TIDE" or fast["status"] != "FAST_DOMINANT":
raise ValueError("fast run has no accepted particle-only-C FastPath")
def value(r, key):
x = r["metrics"].get(key)
if not isinstance(x, (int, float)) or x <= 0:
raise ValueError(f"invalid {key} in {r['scenario']}")
return x
baseline_ms = statistics.median([
value(full_before, "estimatedCpuMsPerNativeFrame"),
value(full_after, "estimatedCpuMsPerNativeFrame"),
])
drift = abs(value(full_before, "cpuCoreEquivalent") -
value(full_after, "cpuCoreEquivalent")) / max(
value(full_before, "cpuCoreEquivalent"), 1e-9)
comparisons = []
for r in [fast, *others]:
if r["status"] != "FAST_DOMINANT":
comparisons.append({"scenario": r["scenario"], "status": "FAST_NOT_STABLE",
"sourceStatus": r["status"]})
continue
ms = value(r, "estimatedCpuMsPerNativeFrame")
comparisons.append({
"scenario": r["scenario"],
"cpuMsPerNativeFrameEstimated": ms,
"cpuMsPerFrameSavingPercentVsFull": 100 * (1 - ms / baseline_ms),
"cpuCoreEquivalent": value(r, "cpuCoreEquivalent"),
"nativeFps": value(r, "nativeObservedFps"),
"fastFraction": r["metrics"]["fastFraction"],
"buildReuse": [
{"segment": s["segment"], "build": s["build"], "reuse": s["reuse"],
"invalidation": s["invalidation"]}
for s in r["nativeEnd"].get("retained", [])
],
})
return {
"schema": "step119-rcrr-cpu-comparison-v1", "identity": identity,
"fullControlCpuMsPerFrameEstimatedMedian": baseline_ms,
"fullControlCpuCoreEquivalent": [value(full_before, "cpuCoreEquivalent"),
value(full_after, "cpuCoreEquivalent")],
"fullControlFrameRate": [value(full_before, "nativeObservedFps"),
value(full_after, "nativeObservedFps")],
"fullBeforeAfterCpuCoreRelativeDrift": drift,
"comparisons": comparisons,
"conclusion": "CPU_DRIFT_REQUIRES_RETEST" if drift > .15 else
"NUMERICAL_COMPARISON_ONLY_NEEDS_VISUAL_AND_THERMAL_VALIDATION",
"caveats": ("同源/同层序A-B-A的进程CPU时间近似对比;Full/Fast FPS不同时必须同时解读CPU/秒、CPU/帧和FPS。"
"CPU指标不等于GPU耗时或电量,未通过人工视觉和失效矩阵不能据此认定正式性能PASS。"),
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--full-before", required=True)
ap.add_argument("--fast", required=True)
ap.add_argument("--full-after", required=True)
ap.add_argument("--rcr")
ap.add_argument("--rcrrr")
ap.add_argument("--output")
args = ap.parse_args()
others = [read(p) for p in (args.rcr, args.rcrrr) if p]
result = summarize(read(args.full_before), read(args.fast),
read(args.full_after), others)
target = Path(args.output) if args.output else (
Path(__file__).resolve().parents[2] /
"artifacts/maplibre-particle/android-validation/step119-rcrr-cpu" /
f"compare-{datetime.now().strftime('%Y%m%d_%H%M%S')}.json")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8")
print(json.dumps(result, ensure_ascii=False, indent=2))
print(f"EVIDENCE={target}")
if __name__ == "__main__":
main()

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""STEP-119 RCRR 验证器结构契约测试;合成样本不代表真机 GTest 或 GPU PASS。"""
"""STEP-119:基于真实 Style 索引的粒子独占 C 分段验证器测试。仅结构测试,非 GPU。"""
import importlib.util
import unittest
from pathlib import Path
@@ -10,72 +10,171 @@ verifier = importlib.util.module_from_spec(spec)
spec.loader.exec_module(verifier)
def sample(retained):
def sample(ranges):
return {
"planValid": True,
"basePreserved": False,
"tideLayerExcluded": True,
"originalBaseLayerCount": 63,
"effectiveBaseLayerCount": 62,
"excludedLayerId": "tide-spots",
"excludedLayerSource": "tide",
"excludedLayerCount": 1,
"actualLayers": 64,
"planValid": True, "basePreserved": False, "tideLayerExcluded": True,
"originalBaseLayerCount": 63, "effectiveBaseLayerCount": 62,
"excludedLayerId": "tide-spots", "excludedLayerSource": "tide",
"excludedLayerCount": 1, "actualLayers": 64,
"commands": {"emptyBuiltinAnnotationLayerRemoved": True,
"annotationSourcePreserved": True},
"sources": [{"sourceId": "navsea_delivery"},
{"sourceId": "rn-weather-fields"}],
"retained": retained,
"retained": [{"begin": start, "end": end} for start, end in ranges],
"segmentRanges": (
[{"strategy": "RETAINED", "begin": ranges[0][0], "end": ranges[0][1]},
{"strategy": "CONTINUOUS", "begin": ranges[0][1], "end": ranges[1][0]}]
+ [{"strategy": "RETAINED", "begin": a, "end": b}
for a, b in ranges[1:]]
if len(ranges) >= 2 else []
),
}
def regions(r0_end, r2_start):
return [
{"begin": 0, "end": r0_end},
{"begin": r2_start, "end": 63},
{"begin": 63, "end": 64},
]
SHAPES = {
# Stable WeatherColor is in R[0,32); only Particle at [32,33) is C.
"RCRR_C_NARROW_NO_TIDE": [(0, 32), (33, 63), (63, 64)],
"RCRR_FULL_CONTROL_NO_TIDE": [(0, 32), (33, 63), (63, 64)],
"RCR_PARTICLE_ONLY_NO_TIDE": [(0, 32), (33, 64)],
"RCRRR_PARTICLE_ONLY_NO_TIDE": [(0, 32), (33, 38), (38, 63), (63, 64)],
# Intentionally unsupported boundaries: keep as independent negative scenes.
"RCRR_C_WIDE_NO_TIDE": [(0, 2), (38, 63), (63, 64)],
"RCRR_R_DYNAMIC_NO_TIDE": [(0, 33), (38, 63), (63, 64)],
}
class Step119RcrrVerifierTests(unittest.TestCase):
def test_three_rcrr_shapes_have_distinct_expected_boundaries(self):
shapes = {
"RCRR_C_NARROW_NO_TIDE": regions(31, 33),
"RCRR_C_WIDE_NO_TIDE": regions(2, 38),
"RCRR_R_DYNAMIC_NO_TIDE": regions(33, 38),
def test_all_expected_shapes_pass_exact_validation(self):
for name, ranges in SHAPES.items():
with self.subTest(name=name):
self.assertTrue(verifier.scene_parity_valid(sample(ranges), name))
def test_incorrect_ranges_do_not_pass_other_layouts(self):
for name, ranges in SHAPES.items():
for other in SHAPES:
if other == name or {name, other} == {
"RCRR_C_NARROW_NO_TIDE", "RCRR_FULL_CONTROL_NO_TIDE"}:
continue
with self.subTest(name=name, other=other):
self.assertFalse(verifier.scene_parity_valid(sample(ranges), other))
def test_missing_source_or_annotation_guard_fails_closed(self):
for name, ranges in SHAPES.items():
a = sample(ranges)
a["sources"].append({"sourceId": "tide"})
self.assertFalse(verifier.scene_parity_valid(a, name))
b = sample(ranges)
b["commands"]["emptyBuiltinAnnotationLayerRemoved"] = False
self.assertFalse(verifier.scene_parity_valid(b, name))
def test_invalid_last_extent_or_count(self):
for name, ranges in SHAPES.items():
bad = [*ranges]
bad[-1] = (bad[-1][0], 65)
self.assertFalse(verifier.scene_parity_valid(sample(bad), name))
self.assertFalse(verifier.scene_parity_valid(sample(ranges[:-1]), name))
def test_actual_c_segment_must_match_particle_only_boundary(self):
exact = sample(SHAPES["RCRR_C_NARROW_NO_TIDE"])
exact["segmentRanges"][1]["end"] = 38
self.assertFalse(verifier.scene_parity_valid(
exact, "RCRR_C_NARROW_NO_TIDE"))
missing = sample(SHAPES["RCRR_C_NARROW_NO_TIDE"])
missing.pop("segmentRanges")
self.assertFalse(verifier.scene_parity_valid(
missing, "RCRR_C_NARROW_NO_TIDE"))
def test_full_control_may_omit_cache_but_not_segment_plan(self):
control = sample(SHAPES["RCRR_FULL_CONTROL_NO_TIDE"])
control["retained"] = []
self.assertTrue(verifier.scene_parity_valid(
control, "RCRR_FULL_CONTROL_NO_TIDE"))
control["segmentRanges"][1]["begin"] = 31
self.assertFalse(verifier.scene_parity_valid(
control, "RCRR_FULL_CONTROL_NO_TIDE"))
def test_static_rc_no_tide_old_contract_unchanged(self):
old = sample([(0, 62)])
self.assertTrue(verifier.scene_parity_valid(old, "STATIC_RC_NO_TIDE"))
self.assertFalse(verifier.scene_parity_valid(old, "RCRR_C_NARROW_NO_TIDE"))
def test_legacy_original_63_layer_fails_if_changed(self):
original = {"planValid": True, "basePreserved": True,
"tideLayerExcluded": False}
self.assertTrue(verifier.scene_parity_valid(original, "PARTIAL_RCRR"))
original["tideLayerExcluded"] = True
self.assertFalse(verifier.scene_parity_valid(original, "PARTIAL_RCRR"))
class Step119ProcessCpuTests(unittest.TestCase):
def test_proc_stat_parses_cpu_and_stable_birth_with_parentheses(self):
spec_cpu = importlib.util.spec_from_file_location(
"benchmark", Path(__file__).with_name("benchmark_step119_cpu.py"))
# Direct loading tests the real no-third-party benchmark implementation.
import sys
parent = str(Path(__file__).parent)
if parent not in sys.path:
sys.path.insert(0, parent)
benchmark = importlib.util.module_from_spec(spec_cpu)
spec_cpu.loader.exec_module(benchmark)
parts = ["S"] + ["0"] * 19
parts[11] = "120"
parts[12] = "20"
parts[19] = "90000"
ticks, born = benchmark.parse_proc_stat(
"222 (android app (bench)) " + " ".join(parts))
self.assertEqual((ticks, born), (140, 90000))
def test_cpu_per_frame_is_normalized_by_actual_native_fps(self):
import sys
parent = str(Path(__file__).parent)
if parent not in sys.path:
sys.path.insert(0, parent)
import benchmark_step119_cpu as benchmark
a = {"fastRendered": 100, "fullRendered": 5, "sampleElapsedMs": 1000}
b = {"fastRendered": 200, "fullRendered": 5, "sampleElapsedMs": 61000}
result = benchmark.collect_result(
(a, b), {"ticks": 200}, {"ticks": 500}, 100, 60.0)
self.assertAlmostEqual(result["cpuSeconds"], 3.0)
self.assertAlmostEqual(result["cpuCoreEquivalent"], .05)
self.assertAlmostEqual(result["nativeObservedFps"], 100.0 / 60.0)
self.assertAlmostEqual(result["estimatedCpuMsPerNativeFrame"], 30.0)
class Step119CpuComparisonTests(unittest.TestCase):
@staticmethod
def report(scenario, status, ms, cores, fps):
native = {"fingerprint": "test-pbf", "actualLayers": 64,
"lifecycle": 7, "decisionMismatch": 0, "retained": []}
return {
"sourceCommit": "source-locked", "apkSha256": "sha-locked",
"device": "test-device", "scenario": scenario, "status": status,
"nativeStart": dict(native), "nativeEnd": dict(native),
"nativeCpuBoundaryOffsetMs": [100, 300],
"parityValid": True, "controlMatched": True,
"metrics": {"estimatedCpuMsPerNativeFrame": ms, "cpuCoreEquivalent": cores,
"nativeObservedFps": fps, "fastFraction": .98},
}
for scenario, retained in shapes.items():
with self.subTest(scenario=scenario):
self.assertTrue(verifier.scene_parity_valid(sample(retained), scenario))
for other in shapes:
if other != scenario:
self.assertFalse(verifier.scene_parity_valid(sample(retained), other))
def test_missing_source_or_annotation_policy_never_passes(self):
for scenario, retained in [
("RCRR_C_NARROW_NO_TIDE", regions(31, 33)),
("RCRR_C_WIDE_NO_TIDE", regions(2, 38)),
("RCRR_R_DYNAMIC_NO_TIDE", regions(33, 38)),
]:
with self.subTest(scenario=scenario):
a = sample(retained)
a["sources"].append({"sourceId": "tide"})
self.assertFalse(verifier.scene_parity_valid(a, scenario))
b = sample(retained)
b["commands"]["emptyBuiltinAnnotationLayerRemoved"] = False
self.assertFalse(verifier.scene_parity_valid(b, scenario))
def test_valid_aba_cpu_saving_report(self):
import compare_step119_cpu as compare
a = self.report("RCRR_FULL_CONTROL_NO_TIDE", "FULL_CONTROL_OBSERVED", 40, .50, 15)
b = self.report("RCRR_C_NARROW_NO_TIDE", "FAST_DOMINANT", 20, .42, 28)
c = self.report("RCRR_FULL_CONTROL_NO_TIDE", "FULL_CONTROL_OBSERVED", 42, .51, 14)
result = compare.summarize(a, b, c, [])
self.assertAlmostEqual(result["fullControlCpuMsPerFrameEstimatedMedian"], 41)
self.assertAlmostEqual(result["comparisons"][0]["cpuMsPerFrameSavingPercentVsFull"],
100 * (1 - 20 / 41))
self.assertNotEqual(result["conclusion"], "CPU_DRIFT_REQUIRES_RETEST")
def test_wrong_final_extent_and_wrong_retained_count_rejected(self):
a = sample(regions(31, 33))
a["retained"][-1]["end"] = 65
self.assertFalse(verifier.scene_parity_valid(a, "RCRR_C_NARROW_NO_TIDE"))
b = sample(regions(31, 33)[:2])
self.assertFalse(verifier.scene_parity_valid(b, "RCRR_C_NARROW_NO_TIDE"))
def test_existing_static_no_tide_contract_preserved(self):
a = sample([{"begin": 0, "end": 62}])
self.assertTrue(verifier.scene_parity_valid(a, "STATIC_RC_NO_TIDE"))
self.assertFalse(verifier.scene_parity_valid(a, "RCRR_C_NARROW_NO_TIDE"))
def test_aba_source_mismatch_is_rejected(self):
import compare_step119_cpu as compare
a = self.report("RCRR_FULL_CONTROL_NO_TIDE", "FULL_CONTROL_OBSERVED", 40, .5, 15)
b = self.report("RCRR_C_NARROW_NO_TIDE", "FAST_DOMINANT", 20, .4, 28)
c = self.report("RCRR_FULL_CONTROL_NO_TIDE", "FULL_CONTROL_OBSERVED", 41, .5, 15)
b["apkSha256"] = "different-apk"
with self.assertRaises(ValueError):
compare.summarize(a, b, c, [])
if __name__ == "__main__":

View File

@@ -19,9 +19,12 @@ BUILD_INFO = ARTIFACTS / "build-info.txt"
PACKAGE = "org.maplibre.android.testapp"
ACTIVITY = f"{PACKAGE}/{PACKAGE}.activity.particle.ParticleCoreLayerValidationActivity"
SCENARIOS = ("STATIC_RC", "PBF63", "PARTICLE_OFF", "PARTIAL_RCRR", "SOURCE_CHANGE", "STATIC_RC_NO_TIDE",
"RCRR_C_NARROW_NO_TIDE", "RCRR_C_WIDE_NO_TIDE", "RCRR_R_DYNAMIC_NO_TIDE")
"RCRR_C_NARROW_NO_TIDE", "RCRR_C_WIDE_NO_TIDE", "RCRR_R_DYNAMIC_NO_TIDE",
"RCR_PARTICLE_ONLY_NO_TIDE", "RCRRR_PARTICLE_ONLY_NO_TIDE", "RCRR_FULL_CONTROL_NO_TIDE")
NO_TIDE_SCENARIOS = {"STATIC_RC_NO_TIDE", "RCRR_C_NARROW_NO_TIDE",
"RCRR_C_WIDE_NO_TIDE", "RCRR_R_DYNAMIC_NO_TIDE"}
"RCRR_C_WIDE_NO_TIDE", "RCRR_R_DYNAMIC_NO_TIDE",
"RCR_PARTICLE_ONLY_NO_TIDE", "RCRRR_PARTICLE_ONLY_NO_TIDE",
"RCRR_FULL_CONTROL_NO_TIDE"}
LOG_ENTRY = re.compile(r"^[A-Z]/STEP113Gate\(\s*(\d+)\):\s*(\{.+\})\s*$")
@@ -112,19 +115,36 @@ def extract(logcat, pid, expected):
def rcrr_segments_valid(sample, scenario):
"""Verify exact R/C boundaries in the new isolated, 64-layer PBF Test App cases."""
retained = sample.get("retained", [])
if len(retained) != 3:
ranges = [(entry.get("begin"), entry.get("end")) for entry in retained]
expected = {
"RCRR_C_NARROW_NO_TIDE": [(0, 32), (33, 63), (63, 64)],
"RCRR_FULL_CONTROL_NO_TIDE": [(0, 32), (33, 63), (63, 64)],
"RCR_PARTICLE_ONLY_NO_TIDE": [(0, 32), (33, 64)],
"RCRRR_PARTICLE_ONLY_NO_TIDE": [(0, 32), (33, 38), (38, 63), (63, 64)],
"RCRR_C_WIDE_NO_TIDE": [(0, 2), (38, 63), (63, 64)],
"RCRR_R_DYNAMIC_NO_TIDE": [(0, 33), (38, 63), (63, 64)],
}
expected_r = expected.get(scenario)
if expected_r is None:
return False
r0, r2, r3 = retained
if (r0.get("begin") != 0 or r2.get("end") != r3.get("begin")
or r3.get("end") != 64 or r3.get("begin") != 63):
# The actual Native segment ranges must prove that the single C consists
# only of Particle for the normal CPU candidates. An R-only list cannot
# prove the C type/range; fail closed if the Native list is missing.
boundaries = sample.get("segmentRanges")
if not isinstance(boundaries, list):
return False
if scenario == "RCRR_C_NARROW_NO_TIDE":
return r0.get("end") == 31 and r2.get("begin") == 33
if scenario == "RCRR_C_WIDE_NO_TIDE":
return r0.get("end") == 2 and r2.get("begin") == 38
if scenario == "RCRR_R_DYNAMIC_NO_TIDE":
return r0.get("end") == 33 and r2.get("begin") == 38
return False
expected_segments = [{"strategy": "RETAINED", "begin": expected_r[0][0],
"end": expected_r[0][1]},
{"strategy": "CONTINUOUS", "begin": expected_r[0][1],
"end": expected_r[1][0]}]
expected_segments.extend({"strategy": "RETAINED", "begin": a, "end": b}
for a, b in expected_r[1:])
if boundaries != expected_segments:
return False
# Cache-disabled Full control may omit cache diagnostics but must still
# supply the exact actual R/C segment list above.
return (not ranges or ranges == expected_r) if scenario == (
"RCRR_FULL_CONTROL_NO_TIDE") else ranges == expected_r
def scene_parity_valid(sample, scenario):

View File

@@ -1200,3 +1200,5 @@ Android V3-08 单次综合验收已完成并关闭。Native `13.4.1-particle.2`
- 2026-10-11 STEP119实机首次FAST_OBSERVED:APK源3d989a00a822989339a6d7cb4d23acc04396670e SHA=57d68800770d431a2ce9247406f636e7a01cc19fccaf4c59f5eb3a7c99128135,FZG1-2安装/preflight PASS;STATIC_RC_NO_TIDE 90秒 FastDelta1819/FullDelta0、R Build1/Reuse3276、24Gate mismatch0、Native64,missingSourceIds=[]。仅正向目标首次通过,负例/视觉/GTest/iOS未验,STEP119 PARTIAL。详见 docs/报告/STEP-119_Android真机Fast首次通过_阶段验收_v1.md。
- 2026-10-11 STEP-119 RCRR续验:原四段 `PARTIAL_RCRR` 在已锁 APK 真机80秒 R0缺tide(Build0/Reuse0)、R2/R3各Build1/Reuse536;FastDelta0/FullDelta596、24Gate mismatch0,安全Full回退。真机原Style Manifest证实tide索引2、clip索引31、weather实际在宽C内。新增窄C、宽C、错误动态R三种Test App RCRR_NO_TIDE对照入口和精确验证器;Style结构静态核对PASS、非Jest Python 4 tests PASS。新APK编译/实机、13项GTest、Metal/iOS和视觉仍未验证,STEP119保持PARTIAL。见docs/60_测试/STEP-119_RCRR多配置验收矩阵_v1.md及docs/报告/STEP-119_RCRR多场景局限性_阶段验收_v1.md。
- 2026-10-11 STEP-119 RCRR CPU效率测试准备:按用户新原则,C仅保留RasterParticleLayer,WeatherColor+海图进入R;新增RCR/RCRR/RCRRR的粒子独占C布局和同Style关闭缓存的Full CPU对照。加入进程CPU核秒/秒、近似CPUms/Native完成帧、实际FPS与Full-Fast-Full A-B-A比较器,保留原24Gate和所有历史负例;修复Test App菜单ID冲突。Python 12项非Jest测试PASS、ADB CLK_TCK=100预检;新APK编译/安装和CPU真实对比仍NOT VERIFIED,STEP119继续PARTIAL。见docs/60_测试/STEP-119_RCRR多配置验收矩阵_v2.md及docs/报告/STEP-119_粒子独占C与CPU效率_阶段验收_v1.md。