Build74 Phase 2 后续最小修复方案(Stop-drain 生命周期补齐 + FrameTime 双时间域)
> 角色 Lead AI(方案阶段) · 2026-09-24 · 判据 judgement = pending-lead-ai · codeChanged = NO > 依据:R3 取证提交 1ef33f7(父 f5da818)之交付物 artifacts/build74-owner-e4-followup-20260924-1232/ > 冻结产品 1ec9462(Build 74);审计基线 4c1e424 > 本批只交方案,不修改冻结产品、不重打包、不删除任何原始 Session / .trace。
---
0. 授权与边界(先读)
| 项 | 本批处置 |
|---|---|
| 角色与阶段 | Lead AI 方案阶段。AGENTS.md §工作流程:「批准边界:'继续分析 / 把方案写细' = 调研」⇒ 只读、不改产品代码 |
| 改动产品代码 | 不做。任何改动都需 Project Owner 明确批准后由 Implementation AI 执行 |
| 「不得无条件改标全部 chunk」 | 遵守。方案不修改采集侧 coreProfileStreamRole 的标定逻辑(realtime.py:4049-4054 逐字节不动) |
「不得放宽 _recover_total_endpoint 锚点」 | 遵守。方案不触碰 _lifecycle_anchor_ticks() 与 Mode A candidates 过滤器 |
| 「禁止引用反事实 2300.676291 ms 为真值」 | 遵守。该值在本方案中只以 被否决反事实 身份出现,并在 §6 给出结构性防误用标记 |
Device Network(__aenter__ / 逐源失败隔离 / missing 语义) | 另开产品批次。本方案正文不含其修复项(§8.5 只给出与本次报告映射的边界划分) |
| Phase C Hybrid Total | 保持 FROZEN。本方案不产出、不推断、不改动任何 Hybrid 值 |
| 原始数据保护 | 原始 Session、原始 .trace、他人未提交文档(MacApp/Tests/1.trace/、site/devlog/p4-build74-instruments-…audit-plan.md、site/devlog/p4-build74-owner-e4-…plan-20260924.md)零读写副作用;全部离线分析在私有归档的副本上进行 |
---
1. 一页结论
任务 A:根因已被完整钉死(因果链单因、可机械复算)
四轮(S2 / r1 / r2 / r3)的采集侧遥测结构完全相同:coreProfileCaptureMode = "startup-only"、coreProfileRestartCount = 0、startupCoreProfileCompletionWaitStatus = "timed-out"、reason = "startup-completion-grace-expired"、grace 4.0 s。
实测因果链(r3,主机单调域,相对 launch.requested.raw):
| 里程碑 | 主机偏移 | 说明 |
|---|---|---|
startup_core_profile_completion_wait 超时 | ≈ +4.00 s | 活线解码器仍有 openPhases = ['UIKit Scene Creation'] |
t5 startupSemanticCompleteNs(startup_semantic_complete.set()) | +8 227.300 ms | realtime.py:4813 |
handoffRequestedNs | +8 236.281 ms(t5 + 8.981 ms) | 立即停 |
handoffStopAckNs | +8 236.847 ms(t5 + 9.547 ms) | |
首个 frame-continuation chunk 到达 | +8 544.005 ms(t5 + 316.705 ms) | 角色翻转的观测证据 |
| IFR opener 所在 chunk 到达 | +10 458.920 ms | 设备域 +2 246.633 ms(相对 launch start) |
| IFR closer 所在 chunk 到达 | +11 127.077 ms | 设备域 +2 300.676 / +2 313.027 ms |
| drain 结束 | +12 247.724 ms(t5 + 4 020.42 ms) | handoffDrainStatus = "timeout"(drain 上界 4.0 s) |
关键判读:coreProfileStreamRole 由主机侧完成信号决定(realtime.py:4051-4055:role = "frame-continuation" if (prestarted_core is None or startup_semantic_complete.is_set() or startup_rotation_complete.is_set()) else "startup-lifecycle")。四轮中 startup_rotation_complete 从未置位(restartCount = 0),故翻转由 startup_semantic_complete 单独驱动,实测翻转点落在 t5 之后 316.705 ms,与源码顺序一致。
startup-only 模式下不存在帧续采(restartCount = 0、handoffRestartCompleteNs = null),因此 drain 段携带的仍是启动窗口内的原始记录(实测设备域 +2.25 s / +2.31 s,早于 t5 的主机时刻 8.2 s)。该 frame-continuation 标签是主机侧策略产物,不是关于数据内容的证据——这正是缺陷本体。
任务 A 的定量边界(本方案最强结论)
在冻结解码器上,逐轮清点「位于 frame-continuation+post-stop-drain chunk 内 且 在生命周期命名空间 且 被产品自身 _thread_pids 归到目标线程 且 _lifecycle_actions() 非空」的记录:
| 轮次 | 控制角色 | 已准入(可产 marker) | 新增可准入 | 同条件但任意线程 |
|---|---|---|---|---|
| S2(历史参考) | NEGATIVE | 14 | 0 | 9 |
| T1-r1 | NEGATIVE | 9 | 0 | 0 |
| T1-r2 | NEGATIVE | 13 | 0 | 0 |
| T1-r3 | POSITIVE | 14 | 3 | 31 |
r3 的 3 条恰为:0x2B870030(code=12,END UIKit Scene Creation + BEGIN Initial Frame Rendering)与 2 × 0x31CA0006(END Initial Frame Rendering),全部 tid 归属目标线程,opener 早于首个 closer 54.0437 ms。
两条 S2 旁路事实(两者都独立保证负向回归不被破坏):
- S2 在 drain chunk 内确实有 9 条生命周期命名空间可产 marker 的记录,但无一条归属目标线程 ⇒ 目标 PID/generation 门天然把它清零;
- r1 / r2 的
frame-continuationchunk 不含任何 binary chunk(全部为bplist,零 kd 记录,与 R3 取证一致)⇒ 连清点对象都不存在。
⇒ 「有界 Stop-drain 补齐」的准入增量是 3 / 0 / 0 / 0,可论证为恰好命中 r3 且对三轮负向零副作用。
任务 B:报告映射问题已定位到具体的缺失映射行,且是「映射缺口」不是「数据缺口」
| 已请求显示指标 | valueQuality | sampleCount | 现存的唯一可能生产者 | 实际状态 |
|---|---|---|---|---|
ios.frame.time | candidate | 1686 | online_report._apply_frame_coverage()(metric_map 有该行) | 已接通 |
ios.display.refresh_rate | missing | 0 | DisplayPerformanceProjector.refresh_rate_hz | 无映射行 |
ios.foreground.fps | missing | 0 | DisplayPerformanceProjector.frames_per_second(trailing 1 s) | 无映射行 |
ios.frame.pacing_jitter | missing | 0 | DisplayPerformanceProjector.frame_pacing_jitter_ms(10 s 窗 P95−P50) | 无映射行 |
ios.frame.time.p95 | missing | 0 | DisplayPerformanceProjector.frame_time_p95_ms(10 s 滚动 P95) | 无映射行 |
机械证据(同一次 Session d3206bbe…):
metrics-derived.jsonl= 0 字节;metricSHA256 = e3b0c442…(空串的 SHA-256);metricIDs = [];frameCount = 0;formulaVersions = [];而consumedRawEventCount = 1717、lastConsumedRawSequence = 1717⇒ 派生引擎吃满整条流、产出为零。- 五项
coverage[]全部coverage = []、reason = null,且ios.frame.time唯一有样本(1686,来自frameSummary.intervalCount)。⇒coverage窗口数组对全部五项都没接线。 online_report.py:1341-1370的_apply_frame_coverage.metric_map**含ios.frame.time与 14 个ios.frame.*兄弟项,唯独不含上表四条**。- 四个 metricID 在
metric_engine.py中完全不出现(该引擎只有ios.foreground.fps一条,且源字段是CoreAnimationFramesPerSecond,属graphics.raw路径而非本次的display.frame-lifetime.raw)。 display_performance.py:2011-2019的DisplayPerformanceProjector已实现全部五项并写明口径(trailing 1 s / 10 s 滚动 P95 / 硬件 VSync / P95−P50),甚至已内置FRESHNESS_STALE_HOST_GAP_NS = 1.0 s的「陈旧但不伪造 0」规则;它被realtime.py:819正常喂入,但它产出的值只落在活线样本字典(realtime.py:1074-1097的metrics["framesPerSecond"]等 camelCase 字段),从不进入coverage[]/sample_count(后者的唯一写入点是online_report._consume_metric_frame(),只吃 metric-ID 形状的帧)。- 顶层
dataCompleteness = "complete"与「5 项里 4 项零样本」并存 ⇒ 报告自相矛盾。
结论:四项 sampleCount = 0 是映射/接线缺口,不是采集缺口——同源同窗的 ios.frame.time 拿到 1686 样本即为反证。修复方向是把 DisplayPerformanceProjector 声明为这四项口径的唯一所有者并接线,而不是去扩 _apply_frame_coverage 的 metric_map(那会造出第二个口径所有者,违反 realtime.py:1013-1015 已写明的「Metric Engine is the sole formula owner」)。
---
2. 任务 A 的完整因果链(已验证,含反例排除)
2.1 证据链
| # | 事实 | 来源 |
|---|---|---|
| A-1 | opener 0x2B870030(class/subclass 0x2B/0x87,code=12)在 r3 交付字节中恰 1 条,目标线程归属正确、顺序正确(早于 closer 54.0437 ms) | 全量字节台账 |
| A-2 | 该 opener 与 2 条 closer 全部落在 coreProfileStreamRole = "frame-continuation"、streamPhase = "post-stop-drain" 的 chunk 内 | chunk 几何 |
| A-3 | 解码器保留谓词 retain_record = … (lifecycle_namespace and not self._emitted and not is_frame_continuation)(launch_trace.py:1148-1151)⇒ 整段丢弃 | 源码 + 单变量对照 |
| A-4 | 丢弃导致 Mode A 零候选(ignoredTerminalCount = 0)⇒ totalDurationMs = null | 解码器内部态复算 |
| A-5 | 单变量对照(浅拷贝 rawPayload 覆盖 role,不 mock/patch)⇒ r3 立即配出 IFR span;同一改标对 S2 完全无影响 | 正 + 负对照 |
| A-6 | 四轮采集遥测结构相同(startup-only / restart 0 / wait timed-out / grace 4.0 s) | app 层 manifest |
| A-7 | 角色翻转由 startup_semantic_complete 单独驱动,实测落在 t5 + 316.705 ms | 源码 + 到达时刻 |
| A-8 | startup-only 无帧续采 ⇒ drain 携带的是启动窗口记录(设备域 +2.25 / +2.31 s ≪ t5 的 +8.23 s 主机时刻) | manifest + 设备 tick |
2.2 S2 的 null 与 r3 的 null 是两个不同的缺口(不可混为一谈)
| S2 | r3 | |
|---|---|---|
| 设备域是否存在 opener | 否(任意 func、任意线程,全轮 code==12 计数为 0) | 是(恰 1 条,目标线程) |
closer 是否被保留进 _ignored_terminal_events | 是(1 条,tid = 目标线程) | 否(ignoredTerminalCount = 0) |
Mode A 为何返回 None | 候选存在但位于 anchor 之前被过滤(anchor = 未闭合的 sceneWillEnterForeground() BEGIN tick,16 389 393 206 930;候选 tick 16 389 392 355 267 < anchor) | 连候选都没有 |
| 修复后应然 | 仍 null(缺的是字节,不是准入) | 阶段闭合、端点带 stop-drain 来源标记 |
r1 / r2 同理:ignoredTerminalCount = 0 ⇒ Mode A 零候选;且其 drain chunk 全为 bplist。
2.3 明确未定、本方案不升级的三项
- S2 / r1 / r2 的 opener 为何不在交付字节里(环形覆盖 / 不同 UIKit 路径 / 采集窗口截断,三候选均未排除);
- drain 内非目标线程的 31(r3)与 9(S2)条生命周期记录属于谁(另一进程 / 预热侧车 / 其它 target generation 均未区分);
- FrameTime 两轴 ≈4.4 倍差的定性成因(见 §8)。
---
3. 任务 A 方案:有界 Stop-drain 生命周期补齐
3.1 设计原则
- 不改采集侧标定:
realtime.py的 role 判定与 chunk 打标逐字节不动。「有界」体现在解码侧准入,不体现在改标。 - 准入必须是「补齐」而非「扩权」:drain 记录只能闭合已由保留记录开启的启动阶段;不得凭空开出新的、且结束时仍 open 的阶段。
- 结构性不动 Mode A:drain 记录永不进入
_markers与_ignored_terminal_events,因此_lifecycle_anchor_ticks()的取值集合与 Mode A 的candidates过滤器在构造上与冻结版逐字节一致。 - 来源必须可分辨:drain 挣得的端点不得复用
totalSource = "coreprofile-endpoint"+totalRepairReason = "endpoint"这一「真实配对」标签对(见 §6)。 - 有界资源:仅在存在候选 drain 记录时才多跑一遍;否则(S2/r1/r2 全轮)零增量。
3.2 四道准入门(AND,全部可机械检验)
| 门 | 内容 | 依据 | 本批实测 |
|---|---|---|---|
| G1 来源门 | chunk must be chunkKind == "binary" 且 coreProfileStreamRole == "frame-continuation" 且 streamPhase == "post-stop-drain" | 该 phase 由 adapters.py:1108-1135 在有界 drain 内产出,其时长上界即 STARTUP_CORE_PROFILE_DRAIN_MAX_SECONDS = 4.0 s | r3 chunk idx 49 / 50 命中 |
| G2 身份门 | chunk 的 targetGeneration 等于本次 launch 的 generation(consume() 既有门,launch_trace.py:959-966)且 记录的 tid 经产品自身 _thread_pids 解析后 == self._target_pid | 「同一 target PID / generation」 | S2 的 9 条全部被此门清零 |
| G3 设备时域门 | launch_start_ticks < tick ≤ launch_start_ticks + 15.0 s(且 tick > max(启动侧 chunk 内已见设备 tick)),全部以设备 Mach tick 表达 | 「设备侧时间依据」;上界取 STARTUP_CORE_PROFILE_MAX_WAIT_SECONDS = 15.0(采集方自报的启动预算) | r3 区间 = (+1 529.529 ms, +15 000 ms],三条记录落在 +2 246.6 / +2 300.7 / +2 313.0 ms ⇒ 全部纳入 |
| G4 链闭合门 | 按 _lifecycle_actions() 展开后,被准入的 drain 记录必须构成一条起点为「保留阶段的仍 open span」、终点为「闭合」的链,且准入后净新增 open span 为 0。0x31CA0006 仅当同一批准入集合内存在同 PID/TID 的 code=12 opener 时才可作为启动端点(保留 launch_trace.py:1822-1828 既有契约) | 「补齐」语义;防「静默多出 span」;防把后续应用帧的 0x31CA0006 误当启动端点 | r3:链 = [opener: END UIKit Scene Creation + BEGIN IFR] → [closer: END IFR],起点是仍 open 的 UIKit Scene Creation ✓ |
上界为何取 15.0 s 而不是 max(启动侧 tick):后者被实测否定——r3 启动侧 chunk 内最大设备 tick 仅 +1 529.529 ms,而端点记录在 +2 246.6 ms(采集 tap 停止后才从 drain 交付)。因此单边上界不足以表达该窗口,G3 必须写成区间(max(启动侧已见 tick) 之下、launch_start + 15 s 之上)。该结论直接来自实测,非设计偏好。
3.3 结果形态(r3 应然)
ios.process.startup.initial_frame_rendering_ms出现(PHASE_METRIC_IDS,launch_trace.py:135);UIKit Scene Creationspan 闭合 ⇒ 准入后unmatchedBeginCount由 1 归 0,spanCount11 → 12;requiredMissingPhases = []、phaseCoverageComplete = true;totalSource = "coreprofile-endpoint-stop-drain"、totalRepairReason = "frame-end-stop-drain"、totalQuality = "repaired";status仍为partial/reason = "lifecycle-spans-incomplete":本方案不置self._completed = True(见 §3.4);_ignored_terminal_events与冻结版完全一致(r3 仍为 0 条)。
3.4 为什么不置 _completed
_completed 参与两个语义:total_endpoint_complete(launch_trace.py:1435)⇒ status 可升为 candidate;以及 completionObserved。若 drain 端点被允许升为 candidate,则活线完成信号(launch_trace.py:1224-1228:要求 totalDurationMs is not None 且 totalQuality == "candidate")将据此关闭启动面板——把一个 drain 挣得的端点当成干净端点。因此:
- 不置
_completed⇒status留在partial,totalQuality = "repaired"⇒ 活线完成信号继续等待(与现状一致); - 这是刻意保留的诚实性:修复带来的是最终离线报告与阶段覆盖的正确,不是实时完成信号的改变。
3.5 活线仍不会完成的已知后果(必须显式记录,不得隐藏)
launch_trace.py:1211-1212 的活线完成发射带 not is_frame_continuation 前置条件。drain 是本次采集的最后阶段且其 chunk 全为 frame-continuation,故 drain 之后不会再有非 drain chunk。⇒ r3 这类会话在活线 UI 上仍不会自动完成启动面板;本方案只修最终报告。该限制在 §11 风险清单 R6 原样登记,作为后续独立批次的候选议题(不并入本批)。
---
4. 代码变更边界(文件 / 函数 / 常量级)
> 以下为待批准的实施边界,本批一行代码都未改。
4.1 MacApp/Sidecar/perfowl_collector_core/launch_trace.py(唯一必改文件)
| 编号 | 位置 | 变更性质 | 内容 | |
|---|---|---|---|---|
| E1 | 模块常量区(launch_trace.py:126 附近) | 新增 | STOP_DRAIN_LIFECYCLE_COMPLETION_ENABLED = True;STOP_DRAIN_COMPLETION_MAX_SECONDS = 15.0(对齐采集侧 STARTUP_CORE_PROFILE_MAX_WAIT_SECONDS);STOP_DRAIN_CHUNK_ROLE = "frame-continuation";STOP_DRAIN_CHUNK_PHASE = "post-stop-drain";STOP_DRAIN_RECORD_CAPACITY = 512;STOP_DRAIN_TOTAL_SOURCE = "coreprofile-endpoint-stop-drain";STOP_DRAIN_REPAIR_REASON = "frame-end-stop-drain" | |
| E2 | __init__ 与 _reset_semantics()(:1618-1645) | 新增状态 | _stop_drain_records: list(有界,容量 STOP_DRAIN_RECORD_CAPACITY)、_stop_drain_truncated: bool、_stop_drain_admitted_count、_stop_drain_rejected_count、_stop_drain_reject_reasons: dict[str,int]、_initial_frame_end_from_stop_drain: bool = False、`_stop_drain_completion: dict \ | None = None` |
| E3 | consume() 保留判定(:1146-1151) | 改判定,不改语义 | 在原 retain_record 之后追加第二级候选分支:lifecycle_namespace and is_stop_drain and STOP_DRAIN_LIFECYCLE_COMPLETION_ENABLED and not self._emitted ⇒ 用 _record() 取出并追加进 _stop_drain_records(不是 _records),并打 record["fromFrameContinuation"] = True、record["stopDrainPhase"] = payload.get("streamPhase")。不改 saw_lifecycle_record 的赋值(:1146-1147 保持只认非 drain)。超容量时置 _stop_drain_truncated = True 并停止接受(不静默丢弃) | |
| E4 | _rebuild_semantics()(:1647-1673) | 新增调用 | 在 self._pair_markers() 之后调用 self._complete_stop_drain_lifecycle()(仅当 _stop_drain_records 非空才进入,否则直接返回 ⇒ 零增量) | |
| E5 | 新方法 _complete_stop_drain_lifecycle()(放在 _pair_markers 之后) | 新增 | 实现 G2 / G3 / G4:逐条过滤 → 展开 _lifecycle_actions() → 判链 → 仅对通过者:闭合 self._open 中对应 span、追加 self._spans、必要时置 _initial_frame_end_ns/_ticks 与 self._initial_frame_end_from_stop_drain = True。不写入 _markers、不写入 _ignored_terminal_events、不置 _completed。同时填充 self._stop_drain_completion 台账(含逐门通过/拒绝计数与拒绝原因) | |
| E6 | report() 干净端点块(:1336-1350) | 来源可分辨 | 把 total_repair_reason 的初始化上提;在该块内:total_source = STOP_DRAIN_TOTAL_SOURCE if self._initial_frame_end_from_stop_drain else "coreprofile-endpoint";total_repair_reason = STOP_DRAIN_REPAIR_REASON if self._initial_frame_end_from_stop_drain else None | |
| E7 | _recover_total_endpoint()(:1882-1941) | 仅加来源标签 | 在 if self._initial_frame_end_ticks is not None: 分支内追加:if self._initial_frame_end_from_stop_drain: end_repair = "stop-drain-completion"。start_ticks 逻辑、anchor = self._lifecycle_anchor_ticks()、candidates = sorted(...)、end_repair = "repaired"/"repaired-ambiguous" 全部逐字节不变 | |
| E8 | report() 返回字典(:1577 附近) | 新增报告块 | 追加 "stopDrainCompletion": self._stop_drain_completion(或 None)。不改 totalQuality 的既有推导式(total_repair_reason not in (None, "endpoint") 会自动把它算成 "repaired") |
明确不动(逐字节):_lifecycle_anchor_ticks()、_recover_total_endpoint() 的锚点与候选逻辑、_pair_markers() 的 _ignored_terminal_events 分支(:1822-1833)、_process_lifecycle() 的 marker 准入门(:1704-1739)、_lifecycle_actions()(:2345-2378)、consume() 的 chunk 连续性/_minimal_live_scan/target scoping 门(:1072-1182)。
4.2 MacApp/Sidecar/perfowl_collector_core/realtime.py
本批不改。 role 判定(:4049-4054)、startup_semantic_complete.set()(:4813)、startup_rotation_complete.set()(:4386)、drain 上界(:4857-4874)、完成等待(:4776-4801)全部保持冻结。§1 的时序结论是只读核对得到的。
4.3 任务 B 的候选变更边界(本批只登记,需 Lead 另行批准)
| 编号 | 位置 | 内容 | 为何需要单独批准 |
|---|---|---|---|
| B1 | realtime.py:1069-1097 + online_report._consume_metric_frame() | 把 DisplayPerformanceProjector 的五项产出同时以 metric-ID 形状({"metricID","value","quality"})交到报告链路,使 coverage[].sampleCount 可计数 | 触及「唯一公式所有者」契约;须先裁定 DisplayPerformanceProjector 为这四项的唯一所有者 |
| B2 | realtime.py:1073 的 else 分支 / capture_plan.MetricCoverage | 当 is_active == False 时,为这五项写入有界且具名的 reason(如 display-projector-inactive),取代 reason = null | 与 Device Network 批次的 missing 语义 重叠 ⇒ 本批只做到「显示指标五项」范围并显式划界,全局词表留给 Network 批次 |
| B3 | online_report 顶层 | 在 dataCompleteness = "complete" 之外并置一个可机械检验的计数(如 derivedMetricCoverage = {"requested":5,"withSamples":1}),不静默重定义 dataCompleteness 的语义 | 顶层完整性语义属全局契约,改动面超出本批 |
| B4 | online_report._apply_frame_coverage() 的 metric_map(:1342-1358) | 明确不做:不为上述四条新增 metric_map 行 | 会造出第二个口径所有者(ios.frame.time.p95 的 10 s 窗、refresh_rate 的硬件 VSync 都无法从 frameSummary 取到),违反 realtime.py:1013-1015 既有原则;登记为被否决选项 |
---
5. 单元 / Fixture 测试设计
全部为新增测试,不改既有断言。命名建议 tests/test_build75_stop_drain_lifecycle_completion.py(若后续批次号变更,文件名随批次)。
5.1 正向(r3 形)
| 编号 | 用例 | 断言 |
|---|---|---|
| A-P-01 | 合成 chunk 序列:启动侧 chunk(含 open UIKit Scene Creation BEGIN)+ drain chunk(含 0x2B870030 与 0x31CA0006,同 tid = target) | initial_frame_rendering_ms 出现;spanCount +1;unmatchedBeginCount 归 0;stopDrainCompletion.admittedRecordCount == 3 |
| A-P-02 | 同上 | totalSource == "coreprofile-endpoint-stop-drain" 且 totalRepairReason == "frame-end-stop-drain" 且 totalQuality == "repaired" |
| A-P-03 | 同上 | status == "partial"、reason == "lifecycle-spans-incomplete"、_completed is False、completionObserved is False |
| A-P-04 | 同上 | ignoredTerminalBeforeBeginCount == 0(与冻结版一致) |
| A-P-05 | fixture 目录 fixtures/ 增加一份匿名化的 r3 形态 fixture(只保留必要 kd 记录,不含任何设备标识) | 端到端 report() 全字段比对基线快照 |
5.2 负向(必须继续 missing)
| 编号 | 用例 | 断言 |
|---|---|---|
| A-N-01 | drain chunk 内生命周期记录但 tid 不属于 target(复刻 S2 的 9 条形) | admittedRecordCount == 0;totalDurationMs is None;requiredMissingPhases 含 Initial Frame Rendering;报告中不出现 stop-drain 任何 token |
| A-N-02 | frame-continuation chunk 全为 bplist(复刻 r1/r2 形) | stopDrainCompletion is None 或 admittedRecordCount == 0;不新增 span |
| A-N-03 | S2 真实会话离线重放 | totalDurationMs is None、missing == ['Initial Frame Rendering']、ignoredTerminalBeforeBeginCount == 1;与冻结解码器逐字段一致(除本批新增块) |
| A-N-04 | r1 真实会话离线重放 | missing == ['System Interface Initialization','Initial Frame Rendering'];准入 0 |
| A-N-05 | r2 真实会话离线重放 | missing == ['Initial Frame Rendering'];准入 0 |
5.3 对抗性(提级与扩权探测)
| 编号 | 注入 | 必须仍为 |
|---|---|---|
| A-X-01 | 把 drain 记录的 target_generation 改成另一代 | 拒绝;rejectReasons["generation-mismatch"] >= 1 |
| A-X-02 | 只注入 0x31CA0006(无 code=12 opener) | 拒绝(G4 链门);totalDurationMs is None;不得进 _ignored_terminal_events |
| A-X-03 | 注入 tick <= max(启动侧已见 tick) 的 drain 记录 | 拒绝(G3 前向门);rejectReasons["device-window"] >= 1 |
| A-X-04 | 注入只有 BEGIN、无 END 的 drain 记录 | 拒绝(链不闭合)⇒ 净新增 open span 必须为 0 |
| A-X-05 | 关闭 STOP_DRAIN_LIFECYCLE_COMPLETION_ENABLED 后重放四轮 | 与冻结解码器逐字段一致(含 _ignored_terminal_events、anchor、Mode A 结果) |
| A-X-06 | 四轮重放,比较 _ignored_terminal_events 元组集合与 _lifecycle_anchor_ticks() | 逐轮逐值相等(证明「未放宽锚点」) |
| A-X-07 | 注入超过 STOP_DRAIN_RECORD_CAPACITY 的 drain 记录 | stopDrainCompletion.truncated is True,且不静默部分准入 |
5.4 效率门禁(按 AGENTS.md §工作流程)
- 关键路径:
consume()内每条 drain-chunk 记录多一次「namespace +_lifecycle_actions()」判定;_complete_stop_drain_lifecycle()只在_stop_drain_records非空时运行。 - 实测规模:r3 的
fc+actionable(任意线程)= 31、目标线程命中 3 ⇒ 缓冲上界 31 条(容量 512 有 16× 余量);S2/r1/r2 均为 0 ⇒ 零增量。 - 复杂度:
O(R_drain),R_drain有界;无新增全量扫描、无新增数据复制、无新增进程。 - 需补 benchmark:同一不可变 Session 上开关本特性各跑 N 轮,报
total (ms)、ru_maxrss增量、decodedRecordCount不变性(三项均须「本批自有增量」口径,未归因数字原样出表)。
---
6. 标签契约与「反事实」防误用
6.1 必须保持的不变式
> totalSource == "coreprofile-endpoint" 且 totalRepairReason == "endpoint" ⟺ 真实配对(非恢复路径)
本方案通过引入两个新 token 保持该不变式:totalSource = "coreprofile-endpoint-stop-drain"、totalRepairReason = "frame-end-stop-drain"。三处既有消费点因此自动正确:
totalQuality(launch_trace.py:1568-1572)⇒"repaired"(不是"candidate");- 活线完成信号(
:1224-1228)⇒ 要求"candidate"⇒ 不触发; _apply_frame_coverage()的items/total_item分支(online_report.py:1063-1068)⇒ 该端点不进入阶段级覆盖的candidate通路。
6.2 被否决的反事实
R3 记录的单变量对照曾把 drain chunk 的 role 直接改标,得到 totalDurationMs = 2300.676291、totalSource = "coreprofile-endpoint"、missing = []。
该组合被明确否决,理由有三,且均为结构性而非口味问题:
- 它把 drain 挣得的端点伪装成干净端点(
totalSource/totalRepairReason落在真实配对标签对)⇒ 违反 §6.1 不变式; - 它靠无条件改标 chunk 达成,正是本次被明令禁止的手法;本方案的 3/0/0/0 增量与链闭合门才是「有界」的证据;
- 该数值在本方案中不得作为真值引用。它只在 §1 / 本节以「被否决反事实」出现;实施后报告中它会以
totalSource="coreprofile-endpoint-stop-drain"+totalQuality="repaired"出现,与干净端点结构上不可混淆。
---
7. 任务 B 的时间域 / 覆盖度证据方案
7.1 现象基线(R3 已审计,逐字保留)
| 量 | 值 | 时间域 |
|---|---|---|
Session 墙钟跨度(durationMonotonicNs) | 137.304 s(R3 报 137.809 s,含 startedAt→endedAt 口径差) | 主机墙钟 |
主机接收轴跨度(1716 条 hostReceiveMonotonicNs) | 134.487 s | 主机单调 |
设备 presentation 轴跨度(1716 条 presentationTimestampNs) | 30.721 s | 设备呈现 |
| 比值 | 0.2284(≈1 : 4.38),200 事件分桶 [0.2608 … 0.2352] 无阶跃无漂移 | — |
| 设备序列完整性 | 2 → 1717,distinct 1716,缺口 0、乱序 0 | 完整 |
| 设备轴帧间隔 | p50 16.642 ms、mean 17.913、87.9% 恰 16.6 ms | 量化到刷新倍数 |
| 主机轴帧间隔 | p50 70.311 ms、mean 78.418、max 512.697 | — |
产品自报 frameSummary.durationMs | 30 721.029 ms | 设备轴 |
产品自报 displayAcquisitionCensus.observedSpanMs | 134 912.872 ms | 主机轴 |
产品自报 sourceGapCount | 0 | — |
| 产品内部比值 | 4.39×,未标注 | — |
7.2 已被证据排除的两项、与仍需证明的项
| 候选解释 | 关键反证 | 裁定 |
|---|---|---|
HOST_TRANSPORT_THROTTLED(主机内部管线积压) | displaySourceDeliveredCount = displaySourceConsumedCount = displayIngressAcceptedCount = 1716;displaySourceToIngressP50/P95/Max = 0.1616 / 0.2605 / 0.702 ms ⇒ 主机内部传输总代价 < 1 ms | 观察上不成立 |
TWO_SURFACES(双 surface 交错错配) | 全局 presentationTimestampNs 严格递增且唯一;两 surface 各约 859 / 857 帧,交错不可能产生恒定 4.38 倍差 | 观察上不成立 |
DEVICE_TIMELINE_QUANTIZED | 设备把间隔量化成 1×/2×/4×/5×/6× 刷新(分辨率 16.64 ms) | 待证明(最简) |
DEVICE_PRESENTATION_CLOCK_RESCALED | 比值恒定而非随机,符合系统性缩放特征 | 待证明(离线不可证伪) |
REAL_12FPS | 与设备自报 55.91 / median 60.09 fps 冲突 | 待证明 |
本批只主张:两轴恒定相差 ≈4.4 倍,且产品未标注。不主张任何单一成因。
7.3 证据方案(离线,全部只读、不需要真机)
E-B1 · 用产品自身的投影器把两轴并列出来
DisplayPerformanceProjector.consume(event, host_receive_monotonic_ns=…) + snapshot(now_ns) 是纯函数式接口,可直接离线重放 T2 的 1716 条 display.frame-lifetime.raw(含其 hostReceiveMonotonicNs),并对每次发布导出:
hostReceiveMonotonicNs / presentationTimestampNs /
framesPerSecond / fpsQuality / frameTimeMs / frameTimeP95Ms /
refreshRateHz / framePacingJitterMs / host_gap_ns(投影器内部)
判别力:FRESHNESS_STALE_HOST_GAP_NS = 1.0 s(display_performance.py:2036)已经把「无明显证据」与「源真的空闲」分开。若 refreshRateHz ≈ 60 稳定、且 stale 样本占比与 4.38 倍差相关(而设备轴 87.9% 恰 1× 刷新),则 DEVICE_TIMELINE_QUANTIZED 与 HOST_TRANSPORT_THROTTLED 可被分离:量化解释预测「设备轴丢长间隔」,陈旧解释预测「主机轴有 >1 s 静默段且与 stale 标记重合」。这是本方案最高优先级的可执行项,因为它同时验证 §8 的报告映射修复是否真的能产出这四项。
E-B2 · 覆盖度账本(两轴并列,作为报告新增块)
runtimeMs / sampleCount /
firstSampleHostNs / lastSampleHostNs / observedSpanMs(host) /
firstPresentationNs / lastPresentationNs / presentationSpanMs(device) /
spanRatio / gapCountOver1s / maxHostGapMs / staleSampleCount /
refreshRateHz / p95WindowsObserved / p95WindowsRequired
要求:observedSpanMs 必须与 displayAcquisitionCensus 自身口径可比,presentationSpanMs 必须与 frameSummary.durationMs 可比,并把 spanRatio 与 staleSampleCount 作为一等字段呈现——即「产品既没把 134.9 s 的观测跨度呈现出来」这一缺陷在报告层被关闭。
E-B3 · 交叉一致性门(可机械检验的自证)
| 门 | 断言 |
|---|---|
| 设备轴自证 | 由原始 1716 条 presentationTimestampNs 重算的跨度 = frameSummary.durationMs(30 721.029 ms),误差 ≤ 1 ms |
| 主机轴自证 | lastCallbackMonotonicNs − firstCallbackMonotonicNs = displayAcquisitionCensus.observedSpanMs(134 912.872 ms),精确相等 |
| 事件守恒 | eventCount = presentedFrameCount = 1716;intervalCount = 1686;frameLifetimeMatchedCount = 1716;frameLifetimeRejectedCount = 19 912;totalMessageCount = 21 628 |
| 量化自证 | refreshMultiple 取值集合 ⊆ {≈1, ≈2, ≈4, ≈5, ≈6}×,与 p50 16.642 ms 一致 |
E-B4 · 明确不可得的部分(守边界)
无设备侧独立墙钟桥时,设备↔主机的绝对偏斜不可证明(与项目自身 C0「离线结构上无法产出同事件双流配对 witness」的结论一致)。因此:
- 本方案不声称已证明成因;
- 若后续需要定性,唯一可行路径是 E4 期间增补 host 侧可观测桥(例如分段操作时由操作者在宿主侧留下带时刻的分段标记,使每段的
hostReceiveMonotonicNs区间可与人工时刻对齐),仍不构成设备轴→主机轴的绝对换算; - 该缺口原样登记,不做「零候选」式断言。
---
8. 四段滚动 / 静止同机测试与时间标记方案
8.1 配置(必须与 T2 逐项一致,否则标 INVALID)
| 项 | 期望值 |
|---|---|
| 载体 | 冻结 Build74 Perfowl App UI(MacApp/dist/ 冻结包,SHA-256 3a557cc0…) |
requestedMetricIDs | ["ios.display.refresh_rate","ios.foreground.fps","ios.frame.pacing_jitter","ios.frame.time","ios.frame.time.p95"] |
launchMode | controlled-cold-launch |
startupEnabled / screenshotsEnabled | false / false |
requestedFrameFilter / effectiveFrameFilter | 0x31800318 / ["0x31800318"] |
capturePlanHash | 必须等于 T2 的 bff494dc…(记录后立刻核对,不一致 ⇒ INVALID) |
8.2 分段与时间标记(真实打点、不代填)
| 段 | 计划时长 | 动作 |
|---|---|---|
| S1 | 0–30 s | 连续滚动 |
| S2 | 30–60 s | 静止(手指离开屏幕,App 保持前台) |
| S3 | 60–90 s | 连续滚动 |
| S4 | 90–120 s | 静止 |
| 尾 | +20 s | 保持前台静止,保证 Stop 前有静止样点 |
打点机制(三级,互为独立来源,任一缺失即记为缺口、不补造):
- 宿主侧 5 s 心跳账本(主,零产品改动):录制期间在另一终端运行
while :; do date -u +%FT%T.%3NZ; sleep 5; done > phase-marks.txt, 记录 Session 的 startedAt / endedAt 与 durationMonotonicNs,用两端锚点做一次线性拟合把墙钟心跳映射到单调域。140 s 量级下分辨率 ≤ 1 s,足以切段。
- 操作者手写四段起止(辅):操作者自报「滚动/静止」切换时刻(只记整秒),与 (1) 交叉核对;两者不一致时记录分歧,不取平均、不改数据。
- 信号自证(独立于人工):静止段的
refreshMultiple应几乎恒为 1×(设备轴 p50 ≈ 16.64 ms),滚动段应出现 2×–6× 倍数与更宽的refreshMultiple取值集合。用refreshMultiple的分布跳变独立推断段边界,再与 (1)(2) 对照。三条独立来源一致才认定为有效分段。
8.3 每轮交付物
- Session 目录 +
SHA256SUMS(含core-profile-chunks.bin、manifest.json、report-final.json、display-acquisition-census.json); - 段边界账本(心跳原文 + 手写原文 + 由
refreshMultiple推断的边界,三者并列,含分歧记录); - 双轴报告(按 §7.3 E-B1/E-B2 产出);
- 每轮的
capturePlanHash/effectiveFrameFilter核对结论。
---
9. 真机验收矩阵
9.1 任务 A
| # | 载体 / 输入 | 期望(判据) |
|---|---|---|
| A-1 | 冻结 App UI,Startup-only 冷启动(与 S2/r1/r2/r3 同配置) | ios.process.startup.initial_frame_rendering_ms 出现;requiredMissingPhases = [];phaseCoverageComplete = true;totalSource = "coreprofile-endpoint-stop-drain";totalRepairReason = "frame-end-stop-drain";totalQuality = "repaired";status = "partial";ignoredTerminalBeforeBeginCount = 0;stopDrainCompletion.admittedRecordCount = 3 |
| A-2 | 离线重放 S2 | totalDurationMs = null;missing = ["Initial Frame Rendering"];准入 0;报告内不出现任何 stop-drain token |
| A-3 | 离线重放 r1 | missing = ["System Interface Initialization","Initial Frame Rendering"];准入 0 |
| A-4 | 离线重放 r2 | missing = ["Initial Frame Rendering"];准入 0 |
| A-5 | 关闭 STOP_DRAIN_LIFECYCLE_COMPLETION_ENABLED 重放四轮 | 与冻结解码器逐字段一致(含 anchor 与 _ignored_terminal_events) |
| A-6 | 对抗注入 A-X-01…A-X-07 | 逐条按 §5.3 拒绝,不得提级 |
| A-7 | 效率对比 | §5.4 三项预算全过,decodedRecordCount 不变 |
9.2 任务 B
| # | 输入 | 期望 |
|---|---|---|
| B-1 | 离线重放 T2 的 1716 条 frame-lifetime(E-B1) | 五项指标可产出;stale 计数与 4.38 倍差的相关性可读;host_gap_ns 分布随发布可导出 |
| B-2 | E-B2 覆盖度账本 | observedSpanMs ≈ 134.9 s、presentationSpanMs ≈ 30.7 s、spanRatio ≈ 4.38 三值均出现在报告中 |
| B-3 | E-B3 交叉一致性门 | 四条断言全过(误差 ≤ 1 ms 或精确相等) |
| B-4 | 修复前后对比 | 五项 coverage[] 的 sampleCount 不全为 0;reason 不再为 null;dataCompleteness 与样本事实不再矛盾 |
| B-5 | §8 四段同机测试 | 四段边界三源一致(或分歧被显式记录);滚动/静止双轴账本可对比 |
所有判据的最终裁定归 Lead AI;本方案不输出 PASS/FAIL。
---
10. 风险清单
| # | 风险 | 影响 | 缓解 / 检出 |
|---|---|---|---|
| R1 | drain 端点被静默提升为「干净端点」 | 下游把 2.3 s 级 drain 端点当无异议真值 | 新 source/reason token(§6.1)+ totalQuality = "repaired" + 不置 _completed;活线完成信号继续等待 |
| R2 | 污染 _ignored_terminal_events ⇒ 锚点/候选集被放宽 | 直接违反明令 | 构造性规避:drain 记录不进 _markers、不进 _ignored_terminal_events;A-X-06 逐轮断言 anchor 与候选集逐值相等 |
| R3 | 后续应用帧的 0x31CA0006 被误认为启动端点 | 假 Total | G4 链门要求同一准入集合内存在同 PID/TID 的 code=12 opener(保留 :1822-1828 既有契约)+ G3 设备区间门 |
| R4 | G3 前向门可能误拒合法记录(drain 设备域回卷) | 漏修 r3 形 | 门写成区间而非单边;拒绝必计数并出表(rejectReasons),永不静默丢弃 |
| R5 | drain 缓冲无界 | 内存 | STOP_DRAIN_RECORD_CAPACITY = 512(实测需 31);超限置 truncated = true 并停止接受,A-X-07 钉死 |
| R6 | 活线 UI 仍不会自动完成(drain chunk 不发射完成快照,:1211-1212) | 用户体验与最终报告不一致 | 显式登记为本批不修项;作为后续独立批次候选,不并入本批 |
| R7 | 任务 B 的 reason 语义与 Device Network 批次重叠 | 双批冲突 | §4.3 B2 把范围限死在「显示指标五项」,全局 missing 词表显式让给 Network 批次 |
| R8 | 第二个口径所有者(metric engine / projector / frame report) | 口径分叉 | B4 明确拒绝扩 _apply_frame_coverage;须先裁定 projector 为唯一所有者,并加「单所有者」单测 |
| R9 | dataCompleteness 语义被静默重定义 | 全局契约漂移 | B3 只并置计数,不重定义;顶层语义变更需 Lead 单独批准 |
| R10 | 反事实 2300.676291 ms 被引用为真值 | 违反明令 | §6.2 结构化防误用;新 token 使二者不可混淆 |
| R11 | 站点侧效应(pre-push hook 在 site/+docs/ 变更时触发部署) | 非预期上线 | 已知并接受:本批确为文档批次;推送前复核 diff 路径集合 |
| R12 | 负向回归表述被误写成「r3 仍为 null」 | 自相矛盾 | 见 §11 诚实性声明:负向回归只能表述为「S2/r1/r2 继续 missing」,r3 的 null 不可能逐字保持 |
---
11. 诚实性声明(必须在验收时一并读)
- 负向回归不可能逐字保持:一旦修复生效,r3 的
totalDurationMs由null变为真实值。因此负向回归的唯一正确表述是:**「S2 / r1 / r2 必须继续 missing,且任何轮次、任何情况下都不得出现*-repaired之外的虚假干净标签、不得放宽锚点」**。把「r3 仍为null」写进验收判据,是把已修缺陷当回归基准。 - 本批零产品改动,故其自身证据等级为 E2(离线构建/测试级),不含任何真机 E4 结论。
- 不在本批范围内的三项未定项(§2.3)在本方案中保持未定,未以任何方式升级。
- 未自判:本方案不输出 PASS/FAIL,不宣称任何阶段门禁关闭或开启;
judgement = pending-lead-ai。 - 原始 Session、原始
.trace、他人未提交文档在本批中零读写副作用;全部离线分析在私有归档副本上进行,编号目录只增不删。
---
12. 明确不做的事 / 另开批次
| 项 | 处置 |
|---|---|
| 修改冻结产品代码 / 重打包 / 重新公证 | 不做(本批为方案阶段) |
采集侧 coreProfileStreamRole 标定 | 不做(不得无条件改标全部 chunk);仅在文档层登记「startup-only 模式下 drain 段被标为 frame-continuation 属语义不当」这一事实,供后续独立议题 |
放宽 _recover_total_endpoint 锚点 / Mode A 候选集 | 不做 |
Device Network __aenter__ / 逐源失败隔离 / 全局 missing 语义 | 另开产品批次(§4.3 已划界) |
| Phase C Hybrid Total 相关值 | 保持 FROZEN,不产出、不推断 |
| 活线完成信号(drain 段不发射完成快照) | 本批登记不修(R6) |
原始 Session / .trace 入库 | 不做 |
---
13. 复现与证据来源
只读分析脚本(私有归档,编号目录只增不删)
<PRIVATE_ARCHIVE>/build74-owner-e4-followup-20260924-1050/scripts/
analyze_drain_admission_delta.py → app-rounds/drain-admission-delta.json (§1 准入增量 3/0/0/0 + Mode A 复算)
analyze_startup_delivery_lag.py → app-rounds/startup-delivery-lag.json (§1 时序表 + 设备域区间 + §8.2 设备侧依据)
analyze_startup_rounds.py (既有引擎:cp / validate_recorded_session / 解码器构造,被上两个脚本复用)
probe_ifr_pairing.py / lifecycle_code_census.py / ifr_timeline_geometry.py /
control_ifr_retention.py / analyze_t2_frame.py / session_sha_inventory.py
R3 已审计交付物
artifacts/build74-owner-e4-followup-20260924-1232/
startup-endpoint-forensics.md (任务 A 端点取证)
t2-frame-dual-timeline.md (任务 B 双时间轴)
test-matrix.json / session-sha-inventory.json / evidence-hashes.json
产品只读核对锚点(本方案全部时序与准入结论的源码依据,均已逐字节未改)
realtime.py : 91-94 / 154 / 158 / 160(常量)· 4049-4055(role 判定)· 4776-4801(完成等待 emit)
4813(startup_semantic_complete.set)· 4386(startup_rotation_complete.set)
4857-4874(rotation 请求 + drain 上界)
launch_trace.py : 126-135(PHASE_METRIC_IDS)· 947-1062(consume)· 1146-1151(retain_record)
1192-1194(saw_completion_marker)· 1336-1350(干净端点 + totalSource)
1435-1464(status / publish_total)· 1568-1578(totalQuality/Source/RepairReason)
1618-1673(_reset_semantics / _rebuild_semantics)· 1704-1739(_process_lifecycle)
1773-1866(_pair_markers)· 1868-1880(_lifecycle_anchor_ticks,不动)
1882-1941(_recover_total_endpoint,不动)· 2345-2378(_lifecycle_actions)
adapters.py : 1108-1135(streamPhase: post-stop-drain)
online_report.py : 1087-1155(_consume_metric_frame)· 1341-1370(_apply_frame_coverage)
capture_plan.py : 233-310(MetricCoverage)
display_performance.py : 2011-2036(DisplayPerformanceProjector 五项口径 + FRESHNESS_STALE_HOST_GAP_NS)
realtime.py : 814-823(投影器喂入)· 902-906(_latest 唯一写入点)· 1069-1097(快照消费)
metric_engine.py : 141 / 476(仅 ios.foreground.fps,源字段 CoreAnimationFramesPerSecond)
---
14. 交给 Lead 的三个待决点
| 编号 | 待决问题 | 本方案倾向 | 影响 |
|---|---|---|---|
| D1 | drain 挣得的端点是否发布 totalDurationMs,还是只闭合阶段、Total 保持 null? | 发布,但只带 coreprofile-endpoint-stop-drain / frame-end-stop-drain / repaired 三标记(活线信号不受影响)。备选:STOP_DRAIN_PUBLISH_TOTAL = False,Total 保持 null、阶段照常闭合 | 决定 A-1 的判据形态 |
| D2 | G3 设备区间门的上界是否采用 launch_start + STARTUP_CORE_PROFILE_MAX_WAIT_SECONDS(15 s)? | 采用,并附「拒绝必计数出表」 | 决定对抗性用例 A-X-03 |
| D3 | 任务 B 的 B1/B2/B3 是否并入本批实施,还是拆为独立批次(与 Device Network missing 语义批次协调)? | 拆批:本批只落诊断与证据方案(§7),实施另立 | 决定本批的实施范围 |
judgement = pending-lead-ai