diff --git a/.trellis/spec/pipeline/cli-and-stages.md b/.trellis/spec/pipeline/cli-and-stages.md index 3908911..b1b326f 100644 --- a/.trellis/spec/pipeline/cli-and-stages.md +++ b/.trellis/spec/pipeline/cli-and-stages.md @@ -1464,6 +1464,72 @@ parity 校验依赖 stage 的 stdout 标记来判断阶段是否跑到(如 `SC --- +## Native Road Parity + +### 1. Scope / Trigger + +拆分 native road compiler、移动其模块,或声称 native-road JSON / GeoJSON 未变化时, +用 `scripts/road-parity.js`。它独立于 build stage,只读现有 native-road 输出。 + +### 2. Signatures + +```bash +node scripts/road-parity.js --config config/areas/.json --snapshot +node scripts/road-parity.js --config config/areas/.json --compare +``` + +`--config` 必填,且 `--snapshot` 与 `--compare` 必须二选一。 + +### 3. Contracts + +- 快照覆盖 `outputs//native-road/` 下全部文件和同级 + `native-traffic-signals.json`。 +- 普通 JSON 递归按对象 key 排序后生成 `contentHash`。 +- GeoJSON 同时生成排序 feature 的 `contentHash` 与原序 `orderHash`;任一不同都算 + parity 失败。 +- 绝对路径归一化为 `/...` 或 `/`,随机 + `native-road-*` staging 名归一化为 ``。坐标不做容差或舍入。 + +### 4. Validation & Error Matrix + +| 条件 | 结果 | +|---|---| +| 缺 `--config` 或同时/均未传 snapshot、compare | 打印 usage,退出非零 | +| native-road 输出目录不存在 | 抛出 `Native road output not found`,退出非零 | +| baseline 缺文件、出现额外文件或任一 hash/count/bytes 不同 | 每项打印 `ROAD_PARITY_DIFF`,退出非零 | +| 完全一致 | 打印 `ROAD_PARITY_OK`,退出 0 | + +### 5. Good/Base/Bad Cases + +- Good: 移动编译器前后,两个 hash 均一致。 +- Base: 绝对 OSM 输入路径变化但归一化后内容一致。 +- Bad: 只比较排序后的 GeoJSON 内容并忽略 `orderHash`,会掩盖输出顺序漂移。 + +### 6. Tests Required + +- `npm run test:road-parity`:相等基线、顺序 hash 变化与缺文件都应被断言。 +- `node --check scripts/road-parity.js`。 +- 对每个受支持区域运行 `--compare` 对已提交基线自比对。 +- 迁移前先连续编译两次;只有 control 实验全绿,基线才可作为 oracle。 + +### 7. Wrong vs Correct + +Wrong: + +```js +// Sorting hides a changed feature order. +assert.equal(sortedContentHash, baseline.contentHash); +``` + +Correct: + +```js +assert.equal(contentHash, baseline.contentHash); +assert.equal(orderHash, baseline.orderHash); +``` + +--- + ## 反模式 | 反模式 | 后果 | diff --git a/.trellis/tasks/08-25-rc-p0-contract-baseline/check.jsonl b/.trellis/tasks/08-25-rc-p0-contract-baseline/check.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/08-25-rc-p0-contract-baseline/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/08-25-rc-p0-contract-baseline/design.md b/.trellis/tasks/08-25-rc-p0-contract-baseline/design.md new file mode 100644 index 0000000..720823f --- /dev/null +++ b/.trellis/tasks/08-25-rc-p0-contract-baseline/design.md @@ -0,0 +1,81 @@ +# Phase 0 技术设计 + +契约本体见父任务 `design.md` §1,本文件只补 Phase 0 自身的实现设计。 + +## 1. 确定性核查的具体做法 + +编译器的潜在非确定性来源,按可疑度排序: + +| 来源 | 表现 | 处置 | +|---|---|---| +| `fs.mkdtempSync(path.join(pipelineDir, "native-road-"))` | staging 目录名随机 | 若名字出现在 `compiled.json` 的 `source` 字段里,需归一化 | +| `compiled.json` 内的绝对路径 | `source.osm` / `source.overrides` / `source.trafficSignals` 是绝对路径 | 归一化为相对 repo root | +| 时间戳 | 未确认是否存在 | 核查 `compile-native-roads.js` 写入的 metadata | +| `Map` / `Set` 迭代顺序 | JS 保证插入序,若插入源本身有序则确定 | 观测两次运行 `orderHash` 是否一致即可暴露 | +| 浮点累加顺序 | 几何坐标末位抖动 | 若出现,说明存在顺序依赖,属真 bug,需修 | +| `crypto` 随机(`traffic-signals.js` require 了 `crypto`) | 信号 ID 可能含随机成分 | 重点核查 —— `native-traffic-signals.json` 是 `loadOrGenerate`,已存在则复用,首次生成可能不确定 | + +**核查顺序**: +1. 备份两个有效区域的 `native-road/` 与 `native-traffic-signals.json` +2. 删除 `native-road/`(保留 signals,因为 `loadOrGenerate` 语义是已存在则复用) +3. 跑第一次 → snapshot A +4. 再删 `native-road/`,跑第二次 → snapshot B +5. diff A/B → volatile 字段清单 +6. 额外一轮:删掉 signals 文件也重生成一次,确认 signal ID 是否确定 + +## 2. `scripts/road-parity.js` 设计 + +``` +用法: + node scripts/road-parity.js --config --snapshot + node scripts/road-parity.js --config --compare +``` + +### 归一化规则(写死在脚本里,并回写进契约文档) + +1. **路径**:任何绝对路径 → 相对 repo root;staging 目录名 → 字面量 `` +2. **JSON**:递归按 key 排序后 `JSON.stringify`,再 hash +3. **GeoJSON**: + - `contentHash` = features 按 `properties.native_id`(缺失则按几何首坐标)排序后 hash + - `orderHash` = 原序 hash + - 两者都记录;比对时 `contentHash` 不一致 = 语义变化(严重), + 仅 `orderHash` 不一致 = 顺序抖动(需解释但可能可接受) +4. **浮点**:不做舍入。坐标变化就是变化,不允许用容差掩盖 + (拆分是纯搬迁,任何坐标位移都是 bug) + +### 输出形状 + +```json +{ + "contract": "native-road-package/v1", + "areaId": "fengshu-er-road", + "files": { + "compiled.json": { "contentHash": "…", "bytes": 12345 }, + "diagnostics.json": { "contentHash": "…", "bytes": 678, "count": 42 }, + "layers/road_surface.geojson": { + "contentHash": "…", "orderHash": "…", "features": 128 } + }, + "volatileExcluded": ["source.osm", "source.overrides", ""] +} +``` + +`count` / `features` 是冗余的人类可读字段 —— hash 变了但计数没变, +说明是内容变化;计数也变了,说明是增删。加速归因。 + +## 3. 基线目录布局 + +``` +.trellis/tasks/08-25-road-compiler-extraction/baseline/ + fengshu-er-road.json + nantaizi-lake-innovation-valley.json + README.md ← 记录生成时的 git commit、命令、volatile 字段清单 +``` + +`README.md` 必须记录生成基线时的 commit hash,否则将来无法判断 +"基线是在哪个代码状态下产生的"。 + +## 4. 为什么校验脚本放宿主而不是直接放未来的编译器仓库 + +Phase 0 时编译器仓库还不存在。脚本先落宿主 `scripts/`, +Phase 2 随编译器一起搬走,届时宿主保留一个薄封装(或直接调编译器包的 CLI)。 +这样 Phase 1 的 parity 验证不需要等仓库拆分。 diff --git a/.trellis/tasks/08-25-rc-p0-contract-baseline/implement.jsonl b/.trellis/tasks/08-25-rc-p0-contract-baseline/implement.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/08-25-rc-p0-contract-baseline/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/08-25-rc-p0-contract-baseline/implement.md b/.trellis/tasks/08-25-rc-p0-contract-baseline/implement.md new file mode 100644 index 0000000..1b6c675 --- /dev/null +++ b/.trellis/tasks/08-25-rc-p0-contract-baseline/implement.md @@ -0,0 +1,118 @@ +# Phase 0 执行计划 + +## 前置 + +- [ ] 分支:`experiment/road-compiler-rethink`(已在) +- [ ] 确认工作区干净(`.trellis/tmp-*.js` 五个未跟踪文件与本任务无关,可先清理或忽略) +- [ ] 阅读父任务 `design.md` §1 契约定义 + +## Step 1 — 摸清两区域当前状态 + +```bash +ls config/areas/ +for d in outputs/*/native-road/layers; do echo "$d: $(ls $d | wc -l)"; done +``` + +判定:`hanyang-block` 为废案,不纳入本阶段;只核查两个有效区域的配置与当前产物。 + +- [x] 逐区域检查 config 的 `nativeRoad.edgeLines` / `junctionTemplates.enabled` +- [x] 将两个有效区域的配置差异记录在基线 README + +**门槛**:两个有效区域的图层差异有书面解释。 + +## Step 2 — 确定性核查 + +```bash +# 备份 +cp -r outputs//native-road /tmp/rc-baseline-a +# 重编 +rm -rf outputs//native-road +npm run road:compile -- --config config/areas/.json +cp -r outputs//native-road /tmp/rc-baseline-b +# 比对 +diff -r /tmp/rc-baseline-a /tmp/rc-baseline-b +``` + +- [x] 对两个有效区域各跑一遍 +- [x] 记录所有差异字段 → volatile 清单 +- [x] 额外一轮:删掉 `native-traffic-signals.json` 重生成, + 确认信号 ID 是否确定(`traffic-signals.js` require 了 `crypto`,重点核查) +- [ ] 若发现**几何坐标级**的非确定性 → 停止本阶段,先开 bug 任务修掉 + +**门槛** 🔴:确定性已证明,或非确定性已定位并修复。这是全案的地基。 + +## Step 3 — 写 `scripts/road-parity.js` + +按 `design.md` §2 实现。 + +- [x] `--snapshot` 生成归一化 checksum 清单 +- [x] `--compare` 比对并逐文件报差异,不一致时非零退出 +- [x] GeoJSON 双 hash(`contentHash` 排序后 / `orderHash` 原序) +- [x] volatile 字段按 Step 2 结论归一化 +- [x] 自验:同一区域连跑两次 snapshot,`--compare` 必须通过 + +```bash +node scripts/road-parity.js --config config/areas/fengshu-er-road.json --snapshot /tmp/s1.json +node scripts/road-parity.js --config config/areas/fengshu-er-road.json --compare /tmp/s1.json +echo "exit=$?" # 必须为 0 +``` + +**门槛**:脚本对未改动的代码报告一致。 + +## Step 4 — 建立两区域基线 + +```bash +mkdir -p .trellis/tasks/08-25-road-compiler-extraction/baseline +for a in fengshu-er-road nantaizi-lake-innovation-valley; do + rm -rf outputs/$a/native-road + npm run road:compile -- --config config/areas/$a.json + node scripts/road-parity.js --config config/areas/$a.json \ + --snapshot .trellis/tasks/08-25-road-compiler-extraction/baseline/$a.json +done +``` + +- [x] 两个基线 JSON 生成 +- [x] 写 `baseline/README.md`:生成时的 git commit、命令、volatile 字段清单、 + 两个有效区域图层差异的解释 +- [x] 反向自验:再跑一次 `--compare` 两个基线全部通过 + +## Step 5 — K2 决策 + +- [x] 检查 `comparison.json` 当前是否真被消费(grep 宿主与 workbench) +- [x] 若有 → 方案 B(可选注入),在契约里定义 `comparisonDir` 为 optional + +## Step 6 — 落契约文档 + +- [x] 写 `docs/native-road-package-v1.md`,内容源自父任务 `design.md` §1 +- [x] 补入 Step 2 的 volatile 字段清单 +- [x] 补入 Step 5 的 K2 决策 +- [x] 在 `docs/changelog.md` 记一行 + +## 验证命令汇总 + +```bash +# parity 两区域全绿 +for a in fengshu-er-road nantaizi-lake-innovation-valley; do + node scripts/road-parity.js --config config/areas/$a.json \ + --compare .trellis/tasks/08-25-road-compiler-extraction/baseline/$a.json || echo "FAIL $a" +done + +# 既有测试未破 +npm run test:native-road +npm run test:road-workbench + +# 确认无生产代码改动 +git diff --stat -- scripts/lib blender +``` + +最后一条必须为空输出 —— Phase 0 不碰生产代码。 + +## Review Gate + +全部 AC0.1–AC0.6 打勾后,再进 Phase 1。**基线不可靠时不要前进。** + +## Rollback + +本阶段纯新增(文档 / 脚本 / 基线数据)。回滚 = 删除新增文件。 +唯一例外:Step 2 若发现并修复了非确定性 bug,那部分是真实代码改动, +应拆成独立 commit 并单独保留。 diff --git a/.trellis/tasks/08-25-rc-p0-contract-baseline/prd.md b/.trellis/tasks/08-25-rc-p0-contract-baseline/prd.md new file mode 100644 index 0000000..be74352 --- /dev/null +++ b/.trellis/tasks/08-25-rc-p0-contract-baseline/prd.md @@ -0,0 +1,84 @@ +# Phase 0:冻结契约与 parity 基线 + +父任务:`.trellis/tasks/08-25-road-compiler-extraction/` +契约权威定义:父任务 `design.md` §1 + +## Goal + +在任何代码移动之前,把 `native-road-package/v1` 契约落成仓库内文档, +并为两个有效区域建立可复跑的逐文件 parity 基线,作为全案唯一 oracle。 + +**本阶段不移动、不重构任何生产代码。** 只新增文档、基线数据和一个校验脚本。 + +## Requirements + +### R0.1 契约文档 + +- 把父任务 `design.md` §1(输入契约 / 输出契约 / 消费方式)落成 + `docs/native-road-package-v1.md`。 +- 文档必须列全 12 个图层名、`compiled.json` 顶层结构、`diagnostics.json` 记录形状、 + stdout 标记格式。 +- Phase 2 会把该文档搬进编译器仓库,此处先落在宿主。 + +### R0.2 确定性前置核查 🔴 + +**基线只有在编译器确定性的前提下才有意义。** 必须先证明这一点。 + +- 对同一输入连续跑两次 `road:compile`,比较全部输出文件。 +- 识别并记录所有 volatile 字段(时间戳、绝对路径、staging 目录名、 + 任何来自 `mkdtemp` 的随机名、Map/Set 迭代顺序敏感的输出)。 +- 校验脚本必须归一化或排除这些字段。 +- 若发现真实的非确定性(相同输入产出不同几何),**必须先修掉再继续**, + 否则整个拆分没有 oracle。 + +### R0.3 parity 校验脚本 + +- 新增 `scripts/road-parity.js`(宿主侧,Phase 2 随编译器搬走)。 +- 能力: + - `--snapshot ` 遍历指定区域的 `native-road/` 全部文件 + + `native-traffic-signals.json`,归一化后输出排序稳定的 checksum 清单 + - `--compare ` 与基线比对,差异逐文件报告 + - 非零退出码表示不一致 +- GeoJSON 需按稳定键排序后再 hash(避免 feature 顺序抖动造成假阳性), + 但**顺序本身若变化必须被报告**——用两个 hash:`contentHash`(排序后) + 与 `orderHash`(原序),分别报告。 + +### R0.4 两区域基线 + +- 区域:`fengshu-er-road`、`nantaizi-lake-innovation-valley` +- 从干净状态跑 `npm run road:compile`,产出基线 JSON 提交进版本控制 +- 基线文件位置:`.trellis/tasks/08-25-road-compiler-extraction/baseline/.json` + (Phase 2 搬进编译器仓库当测试语料,见父任务 R4) + +`hanyang-block` 是废案,不纳入编译、快照或后续 parity 兼容范围。 + +### R0.5 K2 决策定档 + +`comparison.json` 依赖宿主 osm2streets 产物(`compile-native-roads.js:87` 读 +`area.outputs.geojsonDir`),拆出后不可达。本阶段必须做出决策并写入契约文档: + +- 方案 A:移除 `comparison.json`(osm2streets 已 legacy) +- 方案 B:保留,`comparisonDir` 作为可选输入注入 +- 倾向 A。决策写入 `docs/native-road-package-v1.md` 的「已移除能力」一节。 + +## Acceptance Criteria + +- [x] AC0.1 `docs/native-road-package-v1.md` 存在,覆盖输入契约、输出契约、 + 12 图层清单、stdout 标记、已移除能力 +- [x] AC0.2 连续两次编译的 parity 比对通过(确定性已证明),volatile 字段清单已记录在文档内 +- [x] AC0.3 `scripts/road-parity.js` 可用,`--snapshot` / `--compare` 均工作, + 不一致时非零退出 +- [x] AC0.4 两个区域基线 JSON 已提交,且每个都能用 `--compare` 自比对通过 +- [x] AC0.5 K2 决策已定档并写入契约文档 +- [x] AC0.6 未修改任何生产代码(`git diff` 只含新增文档 / 脚本 / 基线) + +## 依赖与顺序 + +- 无前置依赖,本阶段是全案入口 +- **阻塞** Phase 1、2、3、4 —— 没有基线就没有 oracle + +## Out of Scope + +- 任何生产代码改动(含"顺手修一下") +- 目录结构调整 +- `compiled.json` 结构变更 diff --git a/.trellis/tasks/08-25-rc-p0-contract-baseline/task.json b/.trellis/tasks/08-25-rc-p0-contract-baseline/task.json new file mode 100644 index 0000000..a0f05ad --- /dev/null +++ b/.trellis/tasks/08-25-rc-p0-contract-baseline/task.json @@ -0,0 +1,26 @@ +{ + "id": "rc-p0-contract-baseline", + "name": "rc-p0-contract-baseline", + "title": "Phase 0:冻结契约与 parity 基线", + "description": "写死 native-road-package/v1 输入输出契约,并对两个有效区域建立逐文件 checksum 基线作为全案 oracle", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P1", + "creator": "dingkang", + "assignee": "dingkang", + "createdAt": "2026-08-25", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "08-25-road-compiler-extraction", + "relatedFiles": [], + "notes": "", + "meta": {} +} diff --git a/.trellis/tasks/08-25-road-compiler-extraction/baseline/README.md b/.trellis/tasks/08-25-road-compiler-extraction/baseline/README.md new file mode 100644 index 0000000..d8beb7d --- /dev/null +++ b/.trellis/tasks/08-25-road-compiler-extraction/baseline/README.md @@ -0,0 +1,21 @@ +# Native Road Parity Baselines + +Generated from commit `25cf82e7c75ea8d881639c4a80648121efbd39f6` on 2026-08-25. + +```bash +npm run road:compile -- --config config/areas/fengshu-er-road.json +node scripts/road-parity.js --config config/areas/fengshu-er-road.json \ + --snapshot .trellis/tasks/08-25-road-compiler-extraction/baseline/fengshu-er-road.json + +npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json +node scripts/road-parity.js --config config/areas/nantaizi-lake-innovation-valley.json \ + --snapshot .trellis/tasks/08-25-road-compiler-extraction/baseline/nantaizi-lake-innovation-valley.json +``` + +Both samples emit all twelve native-road layers. `fengshu-er-road` enables its +configured complex-junction template; `nantaizi-lake-innovation-valley` uses +the default, template-free compiler path. `hanyang-block` is an abandoned area +and deliberately excluded from this corpus. + +The snapshot normalizes absolute source paths and random `native-road-*` +staging names. It does not round coordinates or ignore feature ordering. diff --git a/.trellis/tasks/08-25-road-compiler-extraction/baseline/fengshu-er-road.json b/.trellis/tasks/08-25-road-compiler-extraction/baseline/fengshu-er-road.json new file mode 100644 index 0000000..1d10646 --- /dev/null +++ b/.trellis/tasks/08-25-road-compiler-extraction/baseline/fengshu-er-road.json @@ -0,0 +1,108 @@ +{ + "contract": "native-road-package/v1", + "areaId": "fengshu-er-road", + "files": { + "../native-traffic-signals.json": { + "contentHash": "88e7f0ef4fc7bdb9ef202af018b3fb3179035ffc8ed5eeba03276c263e1f0476", + "bytes": 5886 + }, + "comparison.json": { + "contentHash": "651eb4000a44a7521a79c3e1429795b83d20f8899e0c67c4bb25b56cf4a6f75d", + "bytes": 1295 + }, + "compiled.json": { + "contentHash": "2ab82eafcdab70155078fae3559692ef1fde0645a52c6decbaea762a9ffeb24a", + "bytes": 159230 + }, + "diagnostics.json": { + "contentHash": "1d9fdae06ddcbc19b5262b797a4a35c494fc30a98a66dbfa111533b12c5fdfc7", + "bytes": 6862 + }, + "layers/center_lines.geojson": { + "contentHash": "04db66df5821b319d59297b8515502fabcad95faf083cae19edb338be2f6024b", + "orderHash": "04db66df5821b319d59297b8515502fabcad95faf083cae19edb338be2f6024b", + "features": 5, + "bytes": 6909 + }, + "layers/connectors.geojson": { + "contentHash": "e7979d4642c7c10f33fb23e92721f22533a851ae1a2b4d35f224d50927bf2fe9", + "orderHash": "c327b7b154d4d9ca04cd895523eb0d8ad67c37051ef1d2135b2b46c51e12df58", + "features": 90, + "bytes": 197885 + }, + "layers/crosswalks.geojson": { + "contentHash": "354cd4d7ed105a11b95904b44848dce567b3a3383f76755936d8c00691fe29af", + "orderHash": "d24376438ac956bde52fcbfa955dd72c2ba31a97995608aaed1c79f6fd3633ab", + "features": 152, + "bytes": 219286 + }, + "layers/direction_arrows.geojson": { + "contentHash": "7b8362ad4912c997a7661befde55ddfe58757c3c8d139c15867e1938ea8f6918", + "orderHash": "5274e8b433946410a7cdffdaa452c1750e04f53e73a86bf81d034cfb26d4c254", + "features": 686, + "bytes": 1053308 + }, + "layers/edge_lines.geojson": { + "contentHash": "ad37fe6278e0c7caf3b77c1c5068a56e18a6c87b78ed5a840ee22a0f961ea7a8", + "orderHash": "ad37fe6278e0c7caf3b77c1c5068a56e18a6c87b78ed5a840ee22a0f961ea7a8", + "features": 0, + "bytes": 52 + }, + "layers/intersection_surface.geojson": { + "contentHash": "b2022ca70339aa470f3be451bf3ba2f55b59c3900645c864538372173811309f", + "orderHash": "b2022ca70339aa470f3be451bf3ba2f55b59c3900645c864538372173811309f", + "features": 5, + "bytes": 13048 + }, + "layers/lane_centerlines.geojson": { + "contentHash": "19c550c794810c1071de55dee158295235106a56e3877a2ba10da920ee214593", + "orderHash": "67f8e7d33ec578644416e4837dd4aafef62a55aaeb4d438d07ab07eddfbb1df1", + "features": 84, + "bytes": 73294 + }, + "layers/lane_separators.geojson": { + "contentHash": "02fdf2c7b6e89c2850a60ea2ac72b9f62910259bdb2949681fcaa7e67d2b51f0", + "orderHash": "81782a059c4ff9f69fdf7de9107d20a8d6cf55d34bb0620ec28a9a8c5106c982", + "features": 3596, + "bytes": 4164106 + }, + "layers/road_surface.geojson": { + "contentHash": "5a2936a705e64338d0d79b395a13d761f1893dd72330a9b9ee4696ae75eab295", + "orderHash": "d8402ae6e772e0778b862b190abfa291de1b02a15537d455f7dcd8596245dfd8", + "features": 30, + "bytes": 49554 + }, + "layers/sidewalk_surface.geojson": { + "contentHash": "b6f8f876e9f18fd2ef6749125a09d654c73d986e916bd262ceb9967bca1a54c6", + "orderHash": "3fd42a67e18e3b8743fa538428e039ef66a1f44150a6881a7aaa18326607b5cb", + "features": 26, + "bytes": 54997 + }, + "layers/turn_arrows.geojson": { + "contentHash": "ad37fe6278e0c7caf3b77c1c5068a56e18a6c87b78ed5a840ee22a0f961ea7a8", + "orderHash": "ad37fe6278e0c7caf3b77c1c5068a56e18a6c87b78ed5a840ee22a0f961ea7a8", + "features": 0, + "bytes": 52 + }, + "layers/vehicle_stop_lines.geojson": { + "contentHash": "cb904550ab8eec011cf33e5edfa81044de6dfca0280dbfe488e77229055ed590", + "orderHash": "8a4b877a17a5af60adc8656465afad6756944736291664139e878d46c5a4f363", + "features": 4, + "bytes": 4191 + }, + "traffic-signal-assemblies.json": { + "contentHash": "1788026a0c8c531b2fc7dd49628f7fbadd809c7b2f7f8819cffb3ac7d30689ca", + "orderHash": "b409ea6b77b6c8639f63c33a156581f9b184a84fde34d265b5deb05705828251", + "features": 7, + "bytes": 5411 + }, + "traffic-signals.json": { + "contentHash": "625b79b6eb82b41f2099ba51960717c7f794fdfbd87310eb12e99da966540d89", + "bytes": 15491 + } + }, + "volatileExcluded": [ + "absolute paths -> or ", + "native-road-* staging directory -> " + ] +} diff --git a/.trellis/tasks/08-25-road-compiler-extraction/baseline/nantaizi-lake-innovation-valley.json b/.trellis/tasks/08-25-road-compiler-extraction/baseline/nantaizi-lake-innovation-valley.json new file mode 100644 index 0000000..554b0e5 --- /dev/null +++ b/.trellis/tasks/08-25-road-compiler-extraction/baseline/nantaizi-lake-innovation-valley.json @@ -0,0 +1,108 @@ +{ + "contract": "native-road-package/v1", + "areaId": "nantaizi-lake-innovation-valley", + "files": { + "../native-traffic-signals.json": { + "contentHash": "36cb9f069fb44a6e24f0af0d130ef8cb5b4a93250dc9fb0891191d1ba039a5b2", + "bytes": 28329 + }, + "comparison.json": { + "contentHash": "5aa11da443221a48288641ac6950c6e4d75d834713a93110bbf265859202a265", + "bytes": 1153 + }, + "compiled.json": { + "contentHash": "ea5ae4a04f1259b1ccec9fc1cca7ef8ba81426a77584a447480b6047b37f5e01", + "bytes": 184985 + }, + "diagnostics.json": { + "contentHash": "7e9327f0424dd612f389c560c114a4cafee1349bbfa36f49b2b4eab846e5ca91", + "bytes": 9755 + }, + "layers/center_lines.geojson": { + "contentHash": "2b2710fce06e75b0a3e4941c2da6119cea7b8bb7834345607bbee384de20ee6b", + "orderHash": "ea71a8841370689d6fc2b75d0b30ec7104638fb2904da6d707dc82ff3131e065", + "features": 511, + "bytes": 694102 + }, + "layers/connectors.geojson": { + "contentHash": "901a86bc6c536d38bb73ae4654ec1ce7d1c0790c2d6eba92543c48f584cd15ac", + "orderHash": "4df882ad59b65a97d892df64de0156b35beaf071b04eed671ef7b535e9e2a7b9", + "features": 76, + "bytes": 160866 + }, + "layers/crosswalks.geojson": { + "contentHash": "53fda76182c0d1db5f9e7ac284337ece62655d7fee8bb446fe170ea26abe8bbc", + "orderHash": "53fda76182c0d1db5f9e7ac284337ece62655d7fee8bb446fe170ea26abe8bbc", + "features": 48, + "bytes": 50536 + }, + "layers/direction_arrows.geojson": { + "contentHash": "38e80bbfe02d4f12ce40e7d0c8342e3bef6b602c64f81dd320b366b6a6b3d205", + "orderHash": "579aa6c37585bf4edf9d6b6b1e120f976b93ec2d0c303d82a154b75c114a45f5", + "features": 324, + "bytes": 493431 + }, + "layers/edge_lines.geojson": { + "contentHash": "ad37fe6278e0c7caf3b77c1c5068a56e18a6c87b78ed5a840ee22a0f961ea7a8", + "orderHash": "ad37fe6278e0c7caf3b77c1c5068a56e18a6c87b78ed5a840ee22a0f961ea7a8", + "features": 0, + "bytes": 52 + }, + "layers/intersection_surface.geojson": { + "contentHash": "7f79e57215e3fd07c5c0f703c71e516873db770cb869efdf18925a3184059ca3", + "orderHash": "d69df8d92d0fce8f7ee172959b978d941fd011e094857e9ab7304b3d1e98bf92", + "features": 12, + "bytes": 38528 + }, + "layers/lane_centerlines.geojson": { + "contentHash": "3fea4ebfe62e0d720c6977e2289f4e6517ed7b3c5ab361270d03378971aec780", + "orderHash": "69a8ebaa42a74da4f7d5940c5a81bd82ceb104efd3f858723dccfadbae12121e", + "features": 50, + "bytes": 43582 + }, + "layers/lane_separators.geojson": { + "contentHash": "dc0b50b53da191e65f3dba01162acb7561966d1796a9c0789527cebfd6ed06a4", + "orderHash": "0bfd873e5698869fb4855783fe30bb6db29456958e4d154b7264374a5fdb4a10", + "features": 731, + "bytes": 835933 + }, + "layers/road_surface.geojson": { + "contentHash": "81af19c71cf6fc8a3884488c00cfb590e1a7fe9ed9e4351fce65825ceadd50fa", + "orderHash": "f2ddec5258411fbdbc050b7ecefbb157b59e00b8226afcc30d2b772e1859e357", + "features": 25, + "bytes": 38275 + }, + "layers/sidewalk_surface.geojson": { + "contentHash": "bb8f4516e27b16238d753b112d18b8acfa5cfad2cea522a65b7972a609be9563", + "orderHash": "4fa773a6f39c5b6d23551e1338c6d99ef8a5ecd9ebfcafa16154c9765fef8c6e", + "features": 73, + "bytes": 122432 + }, + "layers/turn_arrows.geojson": { + "contentHash": "a65bb8fe079f27e00bf2e6208148fc6b73d2d07e76d48b1cd1bf8a610d7e2d66", + "orderHash": "66966ac2c65464f9c6715ca9cda5a3620520bf1d066cba2dee36114dc46af936", + "features": 108, + "bytes": 132536 + }, + "layers/vehicle_stop_lines.geojson": { + "contentHash": "4f45caf3c0bf13bbf10d9cac15fa80b1a9be3969ed84803ba48e56c4a0080992", + "orderHash": "4f45caf3c0bf13bbf10d9cac15fa80b1a9be3969ed84803ba48e56c4a0080992", + "features": 8, + "bytes": 8475 + }, + "traffic-signal-assemblies.json": { + "contentHash": "9f8d032c00784bb72bb1322238cd7b0a80e621d435b43c728da86f1e77ca352a", + "orderHash": "0b9cae13094dba1994faca4bf1d4d80e8bc5497daa46df5899bf5e6fc7f26d61", + "features": 35, + "bytes": 26398 + }, + "traffic-signals.json": { + "contentHash": "c4b68cc1cefeb0d393a18eef6c7eb79933fba28630826745165ed433c25779d0", + "bytes": 74151 + } + }, + "volatileExcluded": [ + "absolute paths -> or ", + "native-road-* staging directory -> " + ] +} diff --git a/.trellis/tasks/08-25-road-compiler-extraction/check.jsonl b/.trellis/tasks/08-25-road-compiler-extraction/check.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/08-25-road-compiler-extraction/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/08-25-road-compiler-extraction/design.md b/.trellis/tasks/08-25-road-compiler-extraction/design.md new file mode 100644 index 0000000..4273102 --- /dev/null +++ b/.trellis/tasks/08-25-road-compiler-extraction/design.md @@ -0,0 +1,294 @@ +# 道路编译器独立化 — 技术设计(parent) + +本文件是 **契约的权威定义**,供所有子任务引用。 +Phase 0 的交付物是把本节内容落成编译器仓库内的正式文档 + 校验脚本, +而不是重新发明契约。 + +--- + +## 1. 契约:`native-road-package/v1` + +### 1.1 输入契约 + +现状:`compile-native-roads.js` 接收整个 normalized areaConfig,但**实际只用 9 个字段**。 + +``` +area.id → areaId +area.input → osmFile (OSM XML 路径) +area.nativeRoad.edgeLines → options.edgeLines +area.nativeRoad.junctionTemplates → options.junctionTemplates +area.outputs.nativeRoadOverrides → overridesFile +area.outputs.nativeTrafficSignals → trafficSignalsFile +area.outputs.nativeRoadDir → outDir +area.outputs.pipelineDir → stagingDir +area.outputs.geojsonDir → comparisonDir (见 K2,倾向移除) +``` + +目标形状: + +```js +// RoadCompilerInput —— 编译器唯一入口参数 +{ + areaId: string, + osmFile: string, // 绝对路径 + outDir: string, // native-road/ 的目标位置 + stagingDir: string, // 原子提升用的临时目录父级 + overridesFile: string, + trafficSignalsFile: string, + options: { + edgeLines: boolean, + junctionTemplates: { + enabled: boolean, + references: [], + clusters: [{ id, template, referenceFile, nodeIds, ...几何参数 }] + } + } +} +``` + +**宿主侧责任**:`scripts/lib/area-config.js` 把 areaConfig 映射成 `RoadCompilerInput`。 +**编译器侧责任**:不认识 areaConfig,不读 `config/areas/*.json`,不推导任何路径。 + +### 1.2 输出契约 + +`compiled.json` 顶层键(已核实): + +``` +schema, areaId, source, model, movements, trafficSignals, diagnostics, layers +``` + +`source` 含 `{ osm, overrides, trafficSignals }` 三个路径 —— 是 parity 归一化的重点对象。 + +``` +/ # 原子提升:先写 staging 再 rename + manifest.json # ← Phase 3 新增,图层自声明 + compiled.json # 单一读模型,含 model + movements + diagnostics.json # severity / subject / sourceIds / rule / message / geometry? + comparison.json # ← 见 K2,可能移除 + layers/ + road_surface.geojson + intersection_surface.geojson + sidewalk_surface.geojson + edge_lines.geojson + lane_separators.geojson + center_lines.geojson + crosswalks.geojson + vehicle_stop_lines.geojson + direction_arrows.geojson + turn_arrows.geojson + lane_centerlines.geojson # 语义层,不渲染 + connectors.geojson # 语义层,不渲染 + + # 兄弟文件,不在 outDir 内 +``` + +**stdout 完成标记**(与宿主 `SCENE_DONE` / `CESIUM_EXPORT_DONE` 同约定): + +``` +NATIVE_ROAD_COMPILE_DONE {"areaId":…,"roads":N,"endpoints":N,"diagnostics":N,"output":…,"comparison":…} +``` + +### 1.3 消费方式:子进程为主契约 + +| 方式 | 定位 | +|---|---| +| CLI 子进程 + 读 `outDir` + 解析 stdout 标记 | **主契约**。宿主 pipeline 层本就是唯一能启动外部进程的层 | +| `require()` in-process | 仅作性能优化,不得成为唯一路径 | + +选子进程的理由: +1. 语言无关 —— 编译器将来若换 TS/Rust,宿主零改动。 +2. 强制文件契约成为真契约,无法偷传对象绕过边界。 +3. 与既有 QGIS / GDAL / Blender 调用方式一致。 + +`build-area.js:7` 现在是 in-process `require`。Phase 2 改为子进程调用。 + +--- + +## 2. 模块清单:什么搬、什么留 + +### 2.1 搬(约 3400 行核心 + workbench) + +| 文件 | 行数 | 依赖 | +|---|---|---| +| `scripts/lib/native-road.js` | 1695 | fs, path, turn-lane-arrows, complex-junction | +| `scripts/lib/complex-junction.js` | 474 | fs, gaode-junction-reference | +| `scripts/lib/turn-lane-arrows.js` | 502 | fs, path, lane-geometry | +| `scripts/lib/gaode-junction-reference.js` | 231 | fs | +| `scripts/lib/lane-geometry.js` | 161 | 无(纯函数) | +| `scripts/lib/native-traffic-signals.js` | 49 | osm, traffic-signals ← **见 K1** | +| `scripts/lib/osm.js` | 102 | 无 | +| `scripts/compile-native-roads.js` | — | area-config ← **要换成窄契约** | +| `scripts/check-native-roads.js` | — | area-config ← 同上 | +| `scripts/road-workbench.js` + `scripts/workbench/app.js` | 173 + — | area-config ← 同上 | +| `scripts/test-native-road.js`、`scripts/test-road-workbench.js` | — | fixture ← **见 K5** | + +依赖图(已核实,`native-road.js` 对宿主零耦合): + +``` +native-road.js ──→ turn-lane-arrows ──→ lane-geometry (纯) + └──────────→ complex-junction ──→ gaode-junction-reference +native-traffic-signals ──→ osm.js + └──→ traffic-signals.js ← 共用,需拆 +``` + +### 2.2 留 + +- `scripts/lib/area-config.js` —— 宿主拥有,新增 `toRoadCompilerInput()` 映射 +- `scripts/build-area.js` —— 改为子进程调用编译器 +- `scripts/lib/traffic-signals.js` 的 legacy 读取器部分(见 K1) +- `blender/` 全部 —— Phase 3 内部重组,但不搬出仓库 +- `scripts/lib/scene-layers.js`、osm2streets / QGIS legacy 链路 —— 完全不动 + +### 2.3 K1 的拆分建议(Phase 1 需细读确认) + +`lib/traffic-signals.js` 当前混了两类东西: + +| 类别 | 使用方 | 归属 | +|---|---|---| +| OSM 信号节点提取 + 信号文档 schema | `native-traffic-signals.js` | **随编译器走** —— 编译器生成该文档,就该拥有其契约 | +| `readTrafficSignalFeatures` | `build-osm2streets-qgis.js` | 留宿主 | +| `readTrafficSignals` | `build-area.js` | 留宿主 | +| `buildTrafficSignals` | `test-preview-assets.js` | 待判定 | + +拆完后宿主从编译器包 import 信号文档 schema,反向依赖为 0 不受影响 +(宿主依赖编译器是允许的方向)。 + +--- + +## 3. Phase 3:渲染分离的设计 + +### 3.1 现状问题 + +`blender/osmassets/catalog.py:53` 的 `NATIVE_ROAD_LAYERS` 是一张跨仓库重复表: + +```python +# 注释自陈:"It is a provider adapter, not a second scene-layer registry." +NATIVE_ROAD_LAYERS = ( + {"source": "road_surface", "material_layer": "road_surface"}, + {"source": "edge_lines", "material_layer": "lane_separators"}, + ... 共 10 项 +) +``` + +编译器写 12 个 geojson,这张表只列 10 项 —— `lane_centerlines` / `connectors` +是语义层不参与渲染。**但这个事实只存在于这张表的省略里,编译器侧没有任何声明。** + +后果:编译器新增图层 → 必须有人记得去另一个仓库改 `catalog.py` → 忘了就静默少渲染一层。 +这正是 `.trellis/spec/pipeline/index.md` 首页警告的「最容易出静默错误」。 + +### 3.2 目标:编译器自声明图层 + +`/manifest.json`: + +```json +{ + "contract": "native-road-package/v1", + "areaId": "fengshu-er-road", + "layers": [ + { "source": "road_surface", "role": "surface", + "materialLayer": "road_surface" }, + + { "source": "lane_centerlines", "role": "semantic" }, + { "source": "connectors", "role": "semantic" }, + + { "source": "center_lines", "role": "marking", + "materialLayer": "center_lines", + "splitBy": { "prop": "color", + "cases": [ { "match": "white", "material": "native_center_line_white" }, + { "default": true, "material": "center_lines" } ] } }, + + { "source": "lane_separators", "role": "marking", + "materialLayer": "lane_separators", + "splitBy": { "prop": "color", + "cases": [ { "match": "yellow", "material": "native_lane_separator_yellow" }, + { "default": true, "material": "lane_separators" } ] } } + ] +} +``` + +- `role: "semantic"` 的图层 Blender 直接跳过 —— 把"不渲染"从省略变成显式声明。 +- `splitBy` 表达当前 `generate_scene.py:815-819` 里硬编码的 + `color != "white"` / `color == "yellow"` 分流逻辑。 +- 材质本体(颜色、z 高度、贴图)仍归宿主 `catalog.py::MATERIALS` —— 编译器不懂渲染。 + 编译器只声明"我有这些图层、它们是什么角色、该用哪个材质槽"。 + +### 3.3 Blender 侧改造 + +- `generate_scene.py` 内道路分支抽为 `blender/osmassets/native_roads.py`,读 manifest 遍历。 +- 建筑(`handle_building` / `dispatch_ways`)、植被、水体保持原位不动。 +- `catalog.NATIVE_ROAD_LAYERS` 删除。 + +### 3.4 AC5 的验证方法 + +向编译器加一个 throwaway 图层(例如 `debug_probe.geojson` + manifest 声明), +不改宿主任何代码,跑 blender 阶段,确认它被渲染出来。验证完回滚该图层。 + +--- + +## 4. 决策记录 + +### D1 不用 drawtonomy 替代编译器 ❌ + +评估结论(2026-08-25): + +- 克隆仓库内**没有编辑器本体**。`packages/` 只有 SDK / dev-server / mcp-server; + 白板闭源,托管在 drawtonomy.com。README 卖点里的 topology-aware lanes、 + lane tool、intersection/roundabout templates、Map→lanes **全部不在开源代码内**。 +- **它完全不做 raw OSM 推导**。`exporter/osmParser.ts` 首行自陈是 + "Lanelet2 OSM (.osm XML) parser" —— 读的是 Lanelet2(车道左右边界已显式)。 + 整个 `exporter/` 目录 grep `highway` 命中 1 次,是 `opendrive.ts:685` 的 + 车道类型字符串,不是 OSM 标签解析。从不读 `highway=*` / `lanes` / `turn:lanes`。 +- 本编译器的核心能力恰是"从稀疏有歧义的中国 OSM 标签推导车道几何",方向垂直。 +- 语义成熟度对比:本编译器有逐值 provenance + (`tag:lanes:forward` / `inferred:highway-default` / `override:`)+ 31 条语义诊断规则; + drawtonomy SDK 两者皆无,其 25 条校验规则全是 OpenDRIVE 结构/XML 完整性检查。 + +### D2 drawtonomy 作为**下游后端 + 编辑器**(后续独立 PoC)✅ + +可用能力(扩展 API,`types.ts:257` 8 个 capability): +`shapes:write` + `ui:panel` 足以把编译产物注入编辑器; +`snapshot:read` 回读后由本地跑其开源 `exportToOpenDrive` / `lanelet2`。 + +不可用能力(决定 junctionTools 不能做成扩展): +- 无 canvas / overlay 能力,UI 只能是侧栏 iframe +- 无工具注册,画布指针事件完全归宿主 +- **无任何 change 事件推送** —— `ExtensionClient.handleMessage` 入站只有 + `ext:init` / 按 requestId 匹配的 5 个 `*-response` / `ext:error`,所有读取靠轮询 + +结论:junctionTools 留在自有 workbench(画布自己的,可任意绘制)。 +drawtonomy 承担场景编排 + 工业格式导出。二者是不同的活,不强行合并 UI。 + +依赖风险记录:`drawtonomy-dev-server` 是 `https://www.drawtonomy.com` 的 +**缓存代理**(TTL 1 小时),非自托管。manifest 有 `minHostVersion` 字段, +说明宿主协议会漂。后续 PoC 不把它放进关键路径。 + +### D3 值得从 drawtonomy 借用的(Apache-2.0,需保留 NOTICE/署名) + +| 来源 | 行数 | 用途 | 建议阶段 | +|---|---|---|---| +| `exporter/odrGeometryFit.ts` | 610 | 折线→解析曲线拟合(中位数去噪 + 贪心生长 + 最简原语优先 + 逐拟合回验 + G1 硬不变量)。可替掉手调的 `approachWidthMultiplier=1.45` / `coreRadiusMeters=28`,改为对 `referenceFile` 拟合、残差作质量指标 | 拆分后独立任务 | +| validator 的 mutation-proven 方法 | — | 故意破坏合法输入、断言校验器必须抓到。本编译器 31 条诊断规则目前无任何触发证明 | 拆分后独立任务,成本低 | +| validator 的分层 + 命名空间(`xml.*` → `ref.*` → `junction.*` → `geom.*`) | — | 替代当前 31 条平铺规则 | 同上 | +| OpenDRIVE + Lanelet2 导出器 | 3059 + 869 | 补齐工业格式输出(Lanelet2 是 Autoware 的输入格式) | 后续独立 PoC | + +### D4 为什么 Phase 1 与 Phase 2 必须分开 + +Phase 1 只改"包边界与入口契约",仓库不变 → 若产物变化,成因必在代码改动。 +Phase 2 只改"仓库位置与消费方式",代码不变 → 若产物变化,成因必在搬迁。 +合并执行则两者混淆,parity oracle 失去诊断价值。 + +### D5 IR 重构推迟到拆分之后 + +见 prd.md C1。拆分的正确性完全建立在"产物逐字节不变"上, +同期改 IR 会同时摧毁 oracle 与归因能力。 + +--- + +## 5. 回滚形状 + +| Phase | 回滚方式 | +|---|---| +| 0 | 无代码改动,仅新增文档与基线,无需回滚 | +| 1 | `git revert`;`packages/road-compiler/` 与旧 `scripts/lib/*` 并存过渡期内可切回旧路径 | +| 2 | 宿主依赖回指本仓库内路径(`file:packages/road-compiler`),编译器仓库保留不动 | +| 3 | Blender 侧恢复 `catalog.NATIVE_ROAD_LAYERS`,manifest 保留但不消费 | diff --git a/.trellis/tasks/08-25-road-compiler-extraction/implement.jsonl b/.trellis/tasks/08-25-road-compiler-extraction/implement.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/08-25-road-compiler-extraction/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/08-25-road-compiler-extraction/prd.md b/.trellis/tasks/08-25-road-compiler-extraction/prd.md new file mode 100644 index 0000000..fe22f1d --- /dev/null +++ b/.trellis/tasks/08-25-road-compiler-extraction/prd.md @@ -0,0 +1,139 @@ +# 道路编译器独立化(parent) + +## Goal + +把 native road compiler 从本仓库拆成可独立维护的项目,与本项目通过**版本化文件契约**相辅相成; +同时把道路渲染与 OSM 建筑渲染分离,使二者可各自演进。 + +本任务是 parent:它拥有源需求、契约定义、子任务地图、跨子任务验收标准和最终集成评审。 +**它自身不承担实现工作**,所有可交付物在子任务中完成。 + +## 背景:为什么现在拆 + +- `scripts/lib/native-road.js` 已 1695 行,`compileGeometry()` 单函数编排 15+ 个 pass, + 顺序依赖只由行号编码,每加一个特性就往既有函数尾部挂位置参数 + (`compileLaneMarkings` 已 7 个位置参数)。 +- 道路与建筑渲染纠缠在 `blender/generate_scene.py` 一个脚本内, + 道路图层表 `NATIVE_ROAD_LAYERS` 硬编码在 `blender/osmassets/catalog.py:53`。 +- 道路编译的迭代节奏(几何、路口、标线、V2X 语义)与建筑/植被/水体渲染完全不同, + 放在一个仓库里互相牵制。 + +拆分的可行性前提(已核实): +- `native-road.js` 只 require `fs` / `path` / 两个同族道路模块,**对宿主项目零耦合**。 +- 对宿主的唯一耦合是三个 CLI 入口里的 `readAreaConfig`。 +- 输出侧 `native-road/` **已经是文件契约**,`blender/generate_scene.py:801` 按图层名读取。 +- stdout 标记 `NATIVE_ROAD_COMPILE_DONE` 已存在,与 `SCENE_DONE` / `CESIUM_EXPORT_DONE` + 同一约定 —— 子进程边界事实上已预留。 + +## Requirements + +### R1 契约先行 + +- R1.1 输入输出契约必须在任何代码移动之前写死并版本化为 `native-road-package/v1`, + 命名对齐既有 `osm-asset-package/v1` 约定。 +- R1.2 契约的权威定义见本任务 `design.md`;Phase 0 负责把它落成仓库内文档 + 校验脚本。 + +### R2 可证明的等价性 + +- R2.1 拆分过程中每一步都必须对两个有效区域 + (`fengshu-er-road` / `nantaizi-lake-innovation-valley`) + 验证产物与基线一致。 +- R2.2 任何差异必须逐条书面解释后才可接受,禁止"看起来差不多"。 +- R2.3 验证方法沿用既有 `.trellis/spec/guides/artifact-parity-guide.md`。 + +### R3 独立可维护 + +- R3.1 拆出的项目必须能脱离宿主自测(自带 fixture,不读宿主 `inputs/` 或 `outputs/`)。 +- R3.2 保留 git 历史(`git subtree split` 或 `git filter-repo`)—— + 1695 行几何逻辑的 blame 是踩坑记录,丢失后无人敢改。 +- R3.3 编译器不得反向依赖宿主任何模块、配置或目录布局。 + +### R4 相辅相成(防漂移) + +四个机制缺一不可: + +| 机制 | 作用 | 落地于 | +|---|---|---| +| `native-road-package/v1` 版本号 | 破坏性变更必须升版本,宿主主动 opt-in | Phase 0 | +| 编译器自声明图层(layer manifest) | 加图层不需要改宿主代码 | Phase 3 | +| 两区域 parity 基线留在编译器仓库当测试语料 | 编译器无法静默弄坏宿主 | Phase 0 → Phase 2 | +| 宿主锁版本依赖,不用 `latest` | 升级是决定,不是意外 | Phase 2 | + +### R5 渲染分离 + +- R5.1 道路图层表必须从宿主 `catalog.py` 的硬编码变为编译器输出的 manifest。 +- R5.2 `blender/generate_scene.py` 内道路渲染分支抽离为独立模块; + 建筑 / 植被 / 水体保持原位。 +- R5.3 分离后,编译器新增图层不需要修改宿主任何代码即可被渲染。 + +## Constraints(硬约束) + +### C1 不得在拆分过程中重构 IR 🔴 + +lane graph 提为一等 IR 是正确方向,但**必须在 Phase 2 完成之后作为独立任务**。 + +理由:拆分的正确性完全依赖"产物逐字节不变"这一 oracle。 +一旦同时改 IR,oracle 失效,且无法判断产物变化来自搬迁还是重设计。 + +未来若做,形式是**新增输出**而非替换 `compiled.json`, +靠"解锁 drawtonomy / OpenDRIVE / Lanelet2 导出"赚取存在理由。 + +### C2 拆分期不引入构建步骤 + +编译器保持 CommonJS、无构建步骤,与宿主 pipeline 层运行时一致 +(见 `.trellis/spec/pipeline/index.md`)。 +TypeScript / ESM 是独立决策;若后续 drawtonomy PoC 需要 SDK 互操作,只能在其独立任务的扩展目录内处理。 + +### C3 Phase 1 必须先于 Phase 2 + +"包边界"与"换仓库"分两步做。合并执行时若产物变化,无法区分成因。 + +## 子任务地图 + +| Phase | 子任务 | 交付物 | 阻塞后续 | +|---|---|---|---| +| 0 | `08-25-rc-p0-contract-baseline` | 契约文档 + 两区域 checksum 基线 + 校验脚本 | 是 | +| 1 | `08-25-rc-p1-package-boundary` | 本仓库内 `packages/road-compiler/`,窄输入契约 | 是 | +| 2 | `08-25-rc-p2-repo-split` | 独立仓库 + 宿主锁版本消费 | 是 | +| 3 | `08-25-rc-p3-render-separation` | layer manifest + Blender 道路模块抽离 | 否 | + +顺序约束:**0 → 1 → 2 必须串行**。3 依赖 2 完成。 + +## 跨子任务验收标准 + +- [ ] AC1 Phase 0 基线建立后,两区域每个输出文件的 checksum 已提交进版本控制 +- [ ] AC2 Phase 1 结束时,`npm run build:area` 两区域产物对 AC1 基线逐字节一致 +- [ ] AC3 Phase 2 结束时,宿主从打包依赖构建,两区域 parity 仍成立 +- [ ] AC4 Phase 2 结束时,编译器仓库 `npm test` 在不访问宿主仓库的情况下通过 +- [ ] AC5 Phase 3 结束时,向编译器新增一个图层,宿主**零代码改动**即可渲染出来(实测验证) +- [ ] AC6 Phase 3 结束时,`.blend` 结构摘要对基线一致(走 artifact-parity-guide) +- [ ] AC7 全程未修改 `compiled.json` 的结构(C1 未被违反) +- [ ] AC8 编译器仓库对宿主的反向依赖数为 0(grep 验证) + +## 已识别风险(跨子任务,逐个必须有归属) + +| # | 风险 | 归属 Phase | 处置 | +|---|---|---|---| +| K1 | `lib/traffic-signals.js` 是真共用模块:`reimport-gpkg.js` / `build-osm2streets-qgis.js` / `build-area.js` / `test-preview-assets.js` 都在用,而 `native-traffic-signals.js` 也依赖它 | 1 | 需细读后拆分:信号文档 schema + OSM 信号节点提取随编译器走(编译器生成它就该拥有契约),legacy QGIS/预览读取器留宿主。**全案唯一需要细读再动的地方** | +| K2 | `comparison.json` 依赖宿主产物 —— `compile-native-roads.js:87` 读 `area.outputs.geojsonDir` 里 osm2streets 输出做对比,拆出后摸不到 | 0(决策)/ 1(执行) | osm2streets 已 legacy,倾向直接砍掉,用高德参考 + 规范校验取代。决策需在 Phase 0 定档 | +| K3 | config 内绝对路径指向宿主仓库:`config/areas/fengshu-er-road.json` 的 `referenceFile: "/Users/que01/osm2streets-qgis-workflow/inputs/osm/珠山湖大道(枫树二路)口.geojson"` | 1 | 需定参考文件解析约定(相对 config 目录 / 显式 basePath) | +| K4 | workbench 的 OpenLayers 来自宿主 `node_modules`(design.md 的 import map 方案) | 2 | 新仓库自带依赖 | +| K5 | 测试 fixture 依赖宿主:`test-native-road.js:167` 读 `inputs/osm/枫树二路.osm`,其余为内联合成 OSM | 1 | 该文件(或裁剪版)作为测试数据提交进编译器仓库 | + +## Out of Scope + +- lane graph IR 重构(见 C1,未来独立任务) +- pass manager / 显式依赖声明重构(同上,属编译器内部演进,不属本次拆分) +- 替换编译器为 drawtonomy 或 osm2streets —— 已评估否决: + drawtonomy 开源部分只读 Lanelet2(显式车道边界),不做 OSM `highway=*` 推导, + 与本编译器的核心能力方向垂直 +- osm2streets / QGIS legacy 链路的任何改动 +- 宿主侧建筑 / 植被 / 水体渲染逻辑的改动(Phase 3 只抽离道路部分) + +## Notes + +- 评估结论与选型依据见 `design.md` 的「决策记录」一节。 +- 时间估计:Phase 0-2 约 3 天(搬家 + 证明没搬坏),Phase 3 约 1-2 天。 + 拿到"道路与建筑渲染分离、可独立维护"是在 Phase 3 结束。 +- 父任务验收完成后,下一项候选任务是独立的 + `08-25-rc-p4-drawtonomy-ext`(drawtonomy 扩展 PoC);它不属于本任务完成条件。 diff --git a/.trellis/tasks/08-25-road-compiler-extraction/task.json b/.trellis/tasks/08-25-road-compiler-extraction/task.json new file mode 100644 index 0000000..dcf2b37 --- /dev/null +++ b/.trellis/tasks/08-25-road-compiler-extraction/task.json @@ -0,0 +1,33 @@ +{ + "id": "road-compiler-extraction", + "name": "road-compiler-extraction", + "title": "道路编译器独立化(parent)", + "description": "把 native road compiler 拆成可独立维护的项目,并与本项目通过版本化契约相辅相成;同时分离道路与 OSM 建筑渲染", + "status": "planning", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P1", + "creator": "dingkang", + "assignee": "dingkang", + "createdAt": "2026-08-25", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [ + "08-25-rc-p0-contract-baseline", + "08-25-rc-p1-package-boundary", + "08-25-rc-p2-repo-split", + "08-25-rc-p3-render-separation" + ], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": { + "next_task": "08-25-rc-p4-drawtonomy-ext" + } +} \ No newline at end of file diff --git a/docs/changelog.md b/docs/changelog.md index 256ec6c..7ffbdd2 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,15 @@ # Changelog +## 2026-08-25 + +- Frozen the `native-road-package/v1` host/compiler contract in + `docs/native-road-package-v1.md`, added `scripts/road-parity.js`, and + committed checksum baselines for `fengshu-er-road` and + `nantaizi-lake-innovation-valley`. The control experiment covers two clean + compiles plus editable signal regeneration for each area; JSON key ordering, + absolute paths, and temporary staging names are normalized while geometry and + GeoJSON feature order remain strict. + ## 2026-08-04 - 重构区域构建入口的 preview 实现边界:`build-area.js` 现在只调度阶段、写 preview diff --git a/docs/native-road-package-v1.md b/docs/native-road-package-v1.md new file mode 100644 index 0000000..b508157 --- /dev/null +++ b/docs/native-road-package-v1.md @@ -0,0 +1,98 @@ +# Native Road Package v1 + +`native-road-package/v1` defines the boundary between the host area pipeline +and the native road compiler. The compiler accepts only the input below; it +does not read `config/areas/*.json` or derive host paths. + +## Input + +```js +{ + areaId: string, + osmFile: string, + outDir: string, + stagingDir: string, + overridesFile: string, + trafficSignalsFile: string, + comparisonDir?: string, + options: { + edgeLines: boolean, + junctionTemplates: { + enabled: boolean, + references: [], + clusters: [] + } + } +} +``` + +All paths are absolute at the process boundary. `stagingDir` is the parent for +the compiler's temporary output and `outDir` is promoted atomically only after +all files are written. The host maps normalized area configuration to this +shape and owns all path derivation. + +## Output + +`outDir` contains these JSON documents: + +- `compiled.json`: top-level keys are `schema`, `areaId`, `source`, `model`, + `movements`, `trafficSignals`, `diagnostics`, and `layers`. `source` has + `osm`, `overrides`, and `trafficSignals` paths. +- `diagnostics.json`: `{ schema: "native-road-diagnostics/v1", diagnostics: [] }`. + Each diagnostic has `id`, `severity`, `subjectId`, `sourceIds`, `rule`, + `message`, and `geometry` (a GeoJSON geometry or `null`). +- `comparison.json`: coverage counts for the road workbench. +- `traffic-signal-assemblies.json` and `traffic-signals.json`: compiler-owned + signal assembly and runtime views. + +`layers/` always contains these twelve GeoJSON FeatureCollections: + +1. `road_surface.geojson` +2. `intersection_surface.geojson` +3. `sidewalk_surface.geojson` +4. `edge_lines.geojson` +5. `lane_separators.geojson` +6. `center_lines.geojson` +7. `crosswalks.geojson` +8. `vehicle_stop_lines.geojson` +9. `direction_arrows.geojson` +10. `turn_arrows.geojson` +11. `lane_centerlines.geojson` (semantic, not rendered) +12. `connectors.geojson` (semantic, not rendered) + +The editable signal source at `trafficSignalsFile` is a sibling of `outDir`. +It is included in the parity baseline because regeneration must be stable. + +Successful CLI execution prints exactly one completion marker: + +```text +NATIVE_ROAD_COMPILE_DONE {"areaId":...,"roads":N,"endpoints":N,"diagnostics":N,"output":...,"comparison":...} +``` + +Consumers use the CLI process plus files in `outDir` and parse this marker. +An in-process import may be an optimization, never the sole contract. + +## Comparison Input Decision + +`comparison.json` remains part of v1. The road workbench currently reads it, +so removing it would break a real consumer. `comparisonDir` is therefore an +optional compiler input for a future standalone package: when absent, the +compiler emits native-only counts and marks osm2streets coverage unavailable. + +## Parity Rules + +`scripts/road-parity.js` snapshots every file below `outDir` plus the sibling +editable signal document. JSON object keys are recursively sorted before +hashing. GeoJSON records both a sorted-feature `contentHash` and original-order +`orderHash`; either difference is reported. + +The only normalization is intentional volatility removal: + +- absolute paths below this repository become `/...`; +- other absolute paths become `/`; +- `native-road-*` temporary directory names become ``. + +No coordinates are rounded and no feature order changes are ignored. Control +experiments on 2026-08-25 compiled `fengshu-er-road` and +`nantaizi-lake-innovation-valley` twice, then regenerated their editable +signal documents. All 18 tracked files per area matched. diff --git a/package.json b/package.json index d879b30..465ca6f 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "serve:v2x-preview": "node scripts/v2x-preview-server.js", "test:road-workbench": "node scripts/test-road-workbench.js", "test:native-road": "node scripts/test-native-road.js", + "test:road-parity": "node scripts/test-road-parity.js", "test:gaode-junction-reference": "node scripts/test-gaode-junction-reference.js", "test:preflight": "node scripts/test-area-preflight.js", "test:build-stages": "node scripts/test-build-stages.js", diff --git a/scripts/road-parity.js b/scripts/road-parity.js new file mode 100644 index 0000000..ecd1c38 --- /dev/null +++ b/scripts/road-parity.js @@ -0,0 +1,152 @@ +#!/usr/bin/env node +"use strict"; + +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); +const { readAreaConfig } = require("./lib/area-config"); + +const repoRoot = path.resolve(__dirname, ".."); + +function parseArgs(argv) { + const result = {}; + for (let index = 0; index < argv.length; index += 1) { + if (!argv[index].startsWith("--")) continue; + const key = argv[index].slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); + result[key] = argv[index + 1] && !argv[index + 1].startsWith("--") ? argv[++index] : true; + } + return result; +} + +function usage() { + return "Usage: node scripts/road-parity.js --config (--snapshot | --compare )"; +} + +function hash(value) { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +function stable(value) { + if (Array.isArray(value)) return value.map(stable); + if (!value || typeof value !== "object") return normalizeString(value); + return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])])); +} + +function normalizeString(value) { + if (typeof value !== "string") return value; + const normalized = value.replace(/([\\/])native-road-[^\\/]+/g, "$1"); + if (!path.isAbsolute(normalized)) return normalized; + const relative = path.relative(repoRoot, normalized); + return relative && !relative.startsWith("..") && !path.isAbsolute(relative) + ? `/${relative.split(path.sep).join("/")}` + : `/${path.basename(normalized)}`; +} + +function encoded(value) { + return JSON.stringify(stable(value)); +} + +function featureKey(feature) { + const nativeId = feature?.properties?.native_id ?? feature?.properties?.nativeId ?? feature?.id; + if (nativeId !== undefined && nativeId !== null) return `id:${nativeId}`; + return `geometry:${encoded(feature?.geometry ?? null)}`; +} + +function geoJsonRecord(value, bytes) { + if (value?.type !== "FeatureCollection" || !Array.isArray(value.features)) return null; + const normalized = { ...value, features: value.features.map(stable) }; + const ordered = encoded(normalized); + const content = encoded({ ...normalized, features: [...normalized.features].sort((left, right) => featureKey(left).localeCompare(featureKey(right))) }); + return { contentHash: hash(content), orderHash: hash(ordered), features: value.features.length, bytes }; +} + +function jsonRecord(text, bytes) { + const value = JSON.parse(text); + return geoJsonRecord(value, bytes) || { contentHash: hash(encoded(value)), bytes }; +} + +function filesBelow(directory) { + const result = []; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const full = path.join(directory, entry.name); + if (entry.isDirectory()) result.push(...filesBelow(full)); + else if (entry.isFile()) result.push(full); + } + return result; +} + +function snapshot(configPath) { + const area = readAreaConfig(configPath, { repoRoot }); + if (!fs.existsSync(area.outputs.nativeRoadDir)) throw new Error(`Native road output not found: ${area.outputs.nativeRoadDir}`); + const files = {}; + for (const file of filesBelow(area.outputs.nativeRoadDir).sort()) { + const relative = path.relative(area.outputs.nativeRoadDir, file).split(path.sep).join("/"); + const text = fs.readFileSync(file, "utf8"); + files[relative] = jsonRecord(text, Buffer.byteLength(text)); + } + if (fs.existsSync(area.outputs.nativeTrafficSignals)) { + const text = fs.readFileSync(area.outputs.nativeTrafficSignals, "utf8"); + files["../native-traffic-signals.json"] = jsonRecord(text, Buffer.byteLength(text)); + } + return { + contract: "native-road-package/v1", + areaId: area.id, + files: Object.fromEntries(Object.keys(files).sort().map((file) => [file, files[file]])), + volatileExcluded: ["absolute paths -> or ", "native-road-* staging directory -> "], + }; +} + +function compare(actual, baseline) { + const differences = []; + if (actual.contract !== baseline.contract) differences.push(`contract: expected ${baseline.contract}, got ${actual.contract}`); + if (actual.areaId !== baseline.areaId) differences.push(`areaId: expected ${baseline.areaId}, got ${actual.areaId}`); + const names = new Set([...Object.keys(baseline.files || {}), ...Object.keys(actual.files || {})]); + for (const name of [...names].sort()) { + const expected = baseline.files?.[name]; + const received = actual.files?.[name]; + if (!expected) { differences.push(`${name}: unexpected file`); continue; } + if (!received) { differences.push(`${name}: missing file`); continue; } + for (const field of ["contentHash", "orderHash", "features", "bytes"]) { + if ((expected[field] ?? null) !== (received[field] ?? null)) { + differences.push(`${name}: ${field} expected ${expected[field] ?? ""}, got ${received[field] ?? ""}`); + } + } + } + return differences; +} + +function writeJson(file, value) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + if (!args.config || Boolean(args.snapshot) === Boolean(args.compare)) throw new Error(usage()); + const actual = snapshot(path.resolve(args.config)); + if (args.snapshot) { + const output = path.resolve(args.snapshot); + writeJson(output, actual); + console.log(`ROAD_PARITY_SNAPSHOT ${JSON.stringify({ areaId: actual.areaId, output, files: Object.keys(actual.files).length })}`); + return; + } + const baseline = JSON.parse(fs.readFileSync(path.resolve(args.compare), "utf8")); + const differences = compare(actual, baseline); + if (differences.length) { + for (const difference of differences) console.error(`ROAD_PARITY_DIFF ${difference}`); + process.exitCode = 1; + return; + } + console.log(`ROAD_PARITY_OK ${JSON.stringify({ areaId: actual.areaId, baseline: path.resolve(args.compare), files: Object.keys(actual.files).length })}`); +} + +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} + +module.exports = { compare, snapshot }; diff --git a/scripts/test-road-parity.js b/scripts/test-road-parity.js new file mode 100644 index 0000000..38c5555 --- /dev/null +++ b/scripts/test-road-parity.js @@ -0,0 +1,30 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("assert/strict"); +const { compare } = require("./road-parity"); + +const baseline = { + contract: "native-road-package/v1", + areaId: "fixture", + files: { + "layers/road_surface.geojson": { + contentHash: "content-a", + orderHash: "order-a", + features: 2, + bytes: 100, + }, + }, +}; + +assert.deepEqual(compare(baseline, baseline), []); +assert.match( + compare({ ...baseline, files: { "layers/road_surface.geojson": { ...baseline.files["layers/road_surface.geojson"], orderHash: "order-b" } } }, baseline).join("\n"), + /orderHash/, +); +assert.match( + compare({ ...baseline, files: {} }, baseline).join("\n"), + /missing file/, +); + +console.log("road parity tests passed");