@danceiny/gotry 0.0.1-rc.21 → 0.0.1-rc.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (327) hide show
  1. package/README.md +102 -194
  2. package/README.zh-CN.md +115 -210
  3. package/bin/gotry-backend.js +53 -0
  4. package/bin/gotry-bootstrap.js +466 -35
  5. package/bin/gotry-inner.js +255 -36
  6. package/bin/gotry-process-liveness.d.ts +27 -0
  7. package/bin/gotry-process-liveness.js +97 -0
  8. package/bin/gotry-runtime-resolution.d.ts +1 -1
  9. package/bin/gotry-runtime-resolution.js +2 -2
  10. package/client/client.js +174 -0
  11. package/cordis.gotry-patch.yml +70 -93
  12. package/dist/capabilities/agent-reach-deep.js +45 -122
  13. package/dist/capabilities/agent-reach.js +63 -148
  14. package/dist/capabilities/anything.js +89 -75
  15. package/dist/capabilities/artifacts.js +123 -104
  16. package/dist/capabilities/channel-health.js +65 -45
  17. package/dist/capabilities/channel-registry.js +108 -87
  18. package/dist/capabilities/doctor.js +357 -235
  19. package/dist/capabilities/effect.js +223 -309
  20. package/dist/capabilities/fact-log.js +28 -14
  21. package/dist/capabilities/flyai.js +245 -156
  22. package/dist/capabilities/ground-transfer.js +509 -0
  23. package/dist/capabilities/hbcli.js +144 -148
  24. package/dist/capabilities/hotelbyte-transaction.js +194 -0
  25. package/dist/capabilities/incident-log.js +131 -61
  26. package/dist/capabilities/model-override.js +30 -10
  27. package/dist/capabilities/opensky.js +54 -43
  28. package/dist/capabilities/resilience.js +50 -32
  29. package/dist/capabilities/session/action-cache.js +50 -44
  30. package/dist/capabilities/session/adapters/ctrip-flight.js +119 -62
  31. package/dist/capabilities/session/adapters/ctrip-hotel.js +125 -110
  32. package/dist/capabilities/session/adapters/dida-portal.js +206 -0
  33. package/dist/capabilities/session/adapters/meituan-local.js +35 -54
  34. package/dist/capabilities/session/adapters/rail-12306.js +169 -112
  35. package/dist/capabilities/session/benchmark.js +85 -149
  36. package/dist/capabilities/session/extension-bridge.js +320 -259
  37. package/dist/capabilities/session/extension-channel.js +57 -76
  38. package/dist/capabilities/session/extension-distribution.js +115 -119
  39. package/dist/capabilities/session/extract.js +31 -21
  40. package/dist/capabilities/session/golden-score.js +55 -33
  41. package/dist/capabilities/session/health-watch.js +146 -154
  42. package/dist/capabilities/session/read-guard.js +55 -47
  43. package/dist/capabilities/session/static-flight-golden.js +49 -36
  44. package/dist/capabilities/session/transport.js +54 -61
  45. package/dist/capabilities/session/wizard.js +63 -93
  46. package/dist/capabilities/session-consent.js +143 -47
  47. package/dist/capabilities/session-login.js +95 -126
  48. package/dist/capabilities/session-search.js +391 -253
  49. package/dist/capabilities/sponsor-plugin.js +341 -0
  50. package/dist/capabilities/visa-policy.js +87 -73
  51. package/dist/capabilities/weather.js +163 -235
  52. package/dist/scripts/action-cache-tests.js +24 -59
  53. package/dist/scripts/agent-planning-turn-deadline-e2e.js +91 -142
  54. package/dist/scripts/agent-planning-turn-deadline-tests.js +80 -176
  55. package/dist/scripts/agent-reach-deep-tests.js +30 -40
  56. package/dist/scripts/agent-reach-tests.js +26 -18
  57. package/dist/scripts/agent-reach-wrapper-tests.js +46 -50
  58. package/dist/scripts/anything-tests.js +58 -62
  59. package/dist/scripts/artifact-client-contract-tests.js +69 -0
  60. package/dist/scripts/artifacts-capability-tests.js +235 -0
  61. package/dist/scripts/async-collect.js +26 -15
  62. package/dist/scripts/benchmark-environment-bridge-e2e.js +287 -684
  63. package/dist/scripts/benchmark-environment-bridge-tests.js +818 -3123
  64. package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +66 -279
  65. package/dist/scripts/booking-copilot-availability-policy-tests.js +153 -1257
  66. package/dist/scripts/booking-copilot-bin-proof-tests.js +22 -45
  67. package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +64 -76
  68. package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +69 -199
  69. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +393 -759
  70. package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +33 -75
  71. package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +60 -110
  72. package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +28 -66
  73. package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +72 -271
  74. package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +73 -275
  75. package/dist/scripts/booking-copilot-runtime-proof-tests.js +784 -3398
  76. package/dist/scripts/booking-copilot-server-proof-tests.js +63 -168
  77. package/dist/scripts/booking-copilot-startup-proof-tests.js +38 -124
  78. package/dist/scripts/booking-executor-tests.js +80 -0
  79. package/dist/scripts/booking-recovery-chain-tests.js +182 -0
  80. package/dist/scripts/booking-saga-tests.js +61 -123
  81. package/dist/scripts/booking-surface-contract-proof-tests.js +152 -1167
  82. package/dist/scripts/bootstrap-tests.js +1383 -128
  83. package/dist/scripts/build-changelog.js +89 -130
  84. package/dist/scripts/build-metrics-report.js +135 -143
  85. package/dist/scripts/changelog-tests.js +35 -82
  86. package/dist/scripts/channel-probe-tests.js +44 -116
  87. package/dist/scripts/channel-probe.js +84 -167
  88. package/dist/scripts/channel-registry-tests.js +97 -185
  89. package/dist/scripts/companion-tests.js +25 -70
  90. package/dist/scripts/dida-cookie-probe.js +87 -0
  91. package/dist/scripts/dida-sw-probe.js +114 -0
  92. package/dist/scripts/diff-test.js +17 -12
  93. package/dist/scripts/doctor-tests.js +428 -120
  94. package/dist/scripts/dsh-artifact-e2e.js +150 -0
  95. package/dist/scripts/dsh-artifact-web-e2e.js +443 -0
  96. package/dist/scripts/dsh-http-proxy-sse-proof.js +90 -0
  97. package/dist/scripts/dsh-runtime-closure-tests.js +110 -153
  98. package/dist/scripts/dsh-runtime-closure.js +75 -63
  99. package/dist/scripts/dsh-session-v3-migration-proof.js +106 -0
  100. package/dist/scripts/dsh-subprocess-local-proof.js +149 -0
  101. package/dist/scripts/dsh-target-closure-proof.js +178 -0
  102. package/dist/scripts/effect-tests.js +165 -552
  103. package/dist/scripts/engine-run.js +1 -3
  104. package/dist/scripts/engine-tests.js +16 -10
  105. package/dist/scripts/evaluation-cadence-tests.js +69 -242
  106. package/dist/scripts/evaluation-contract-tests.js +193 -499
  107. package/dist/scripts/extension-distribution-cli.js +19 -15
  108. package/dist/scripts/extension-distribution-tests.js +98 -211
  109. package/dist/scripts/extension-tests.js +366 -389
  110. package/dist/scripts/fact-gate-tests.js +725 -263
  111. package/dist/scripts/fixtures/extension-bridge-unref-child.mjs +21 -0
  112. package/dist/scripts/flight-hotel-anchor-363-registered-e2e.js +169 -0
  113. package/dist/scripts/flight-malformed-tests.js +159 -0
  114. package/dist/scripts/flyai-tests.js +325 -68
  115. package/dist/scripts/g5-guard-tests.js +131 -0
  116. package/dist/scripts/g5-guard.js +189 -0
  117. package/dist/scripts/gen-lunar-anchors.js +106 -0
  118. package/dist/scripts/gotry-backend-kernel-tests.js +79 -0
  119. package/dist/scripts/ground-transfer-tests.js +956 -0
  120. package/dist/scripts/hbcli-e2e-tests.js +71 -90
  121. package/dist/scripts/hbcli-live-optin-tests.js +96 -0
  122. package/dist/scripts/hbcli-release-contract-tests.js +122 -0
  123. package/dist/scripts/hbcli-tests.js +41 -115
  124. package/dist/scripts/health-watch-cli.js +42 -28
  125. package/dist/scripts/hotel-date-gate-tests.js +496 -0
  126. package/dist/scripts/hotelbyte-reconcile-tests.js +141 -0
  127. package/dist/scripts/hotelbyte-spawn-e2e-tests.js +373 -0
  128. package/dist/scripts/i18n-tests.js +16 -9
  129. package/dist/scripts/incident-tests.js +281 -87
  130. package/dist/scripts/issue-194-job-id-guard-tests.js +106 -0
  131. package/dist/scripts/issue-233-cancel-refund-commission-tests.js +392 -0
  132. package/dist/scripts/issue-271-liveness-tests.js +547 -0
  133. package/dist/scripts/issue-289-model-retry-real-tests.js +296 -0
  134. package/dist/scripts/issue-289-model-retry-tests.js +441 -0
  135. package/dist/scripts/issue-289-web-retry-e2e.js +630 -0
  136. package/dist/scripts/issue-338-home-city-e2e.js +577 -0
  137. package/dist/scripts/issue343-real-entry-e2e.js +160 -0
  138. package/dist/scripts/journey-tests.js +22 -27
  139. package/dist/scripts/kernel-manifest-evidence.js +109 -0
  140. package/dist/scripts/kernel-manifest-gate.js +126 -0
  141. package/dist/scripts/kernel-manifest-tests.js +212 -0
  142. package/dist/scripts/kernel-manifest-trace-entry.js +7 -0
  143. package/dist/scripts/kernel-manifest-trace-hook.mjs +28 -0
  144. package/dist/scripts/kernel-manifest.js +369 -0
  145. package/dist/scripts/ledger-repair-apply-tests.js +316 -0
  146. package/dist/scripts/ledger-repair-plan-tests.js +375 -0
  147. package/dist/scripts/ledger-tests.js +325 -340
  148. package/dist/scripts/ledger-workflow-crash.js +10 -20
  149. package/dist/scripts/map-tools-vendor-package-proof.js +206 -134
  150. package/dist/scripts/memory-capture-tests.js +242 -61
  151. package/dist/scripts/memory-decay-tests.js +18 -52
  152. package/dist/scripts/memory-lifecycle-tests.js +657 -0
  153. package/dist/scripts/memory-lifecycle.js +196 -0
  154. package/dist/scripts/memory-metrics.js +24 -12
  155. package/dist/scripts/memory-value-report.js +474 -222
  156. package/dist/scripts/memory-value-tests.js +328 -0
  157. package/dist/scripts/metrics-report-tests.js +62 -199
  158. package/dist/scripts/model-override-e2e.js +66 -105
  159. package/dist/scripts/nightly-evidence-tests.js +64 -89
  160. package/dist/scripts/nightly-evidence.js +108 -111
  161. package/dist/scripts/nudge-digest.js +39 -28
  162. package/dist/scripts/onboarding-tests.js +63 -88
  163. package/dist/scripts/opensky-check.js +18 -13
  164. package/dist/scripts/opensky-tests.js +28 -28
  165. package/dist/scripts/persona-surface-guard-tests.js +37 -12
  166. package/dist/scripts/pnpm-dsh-closure-proof.js +2 -5
  167. package/dist/scripts/policy-anchor-359-registered-e2e.js +214 -0
  168. package/dist/scripts/price-drift-tests.js +126 -227
  169. package/dist/scripts/price-drift-watch.js +209 -253
  170. package/dist/scripts/probe-poi-tests.js +52 -45
  171. package/dist/scripts/product-metrics.js +187 -273
  172. package/dist/scripts/publish-preverify.js +47 -41
  173. package/dist/scripts/realtime-pricing-tests.js +37 -76
  174. package/dist/scripts/replay-async.js +20 -18
  175. package/dist/scripts/replay-real.js +16 -18
  176. package/dist/scripts/replay.js +52 -82
  177. package/dist/scripts/session-attach-diagnose.js +16 -12
  178. package/dist/scripts/session-attach-poc.js +34 -37
  179. package/dist/scripts/session-benchmark.js +66 -147
  180. package/dist/scripts/session-dida-live-e2e.js +151 -0
  181. package/dist/scripts/session-extract-tests.js +14 -35
  182. package/dist/scripts/session-login.js +14 -13
  183. package/dist/scripts/session-tests.js +545 -583
  184. package/dist/scripts/sf-live-benchmark.js +92 -110
  185. package/dist/scripts/sf-live-cli-tests.js +2 -9
  186. package/dist/scripts/sf-soft-score-tests.js +19 -60
  187. package/dist/scripts/sf-summary-tests.js +149 -0
  188. package/dist/scripts/sf-summary.js +466 -84
  189. package/dist/scripts/skeleton-check.js +16 -14
  190. package/dist/scripts/skeleton-integration-test.js +11 -10
  191. package/dist/scripts/skills-contract-tests.js +51 -46
  192. package/dist/scripts/smoke-session-gate-tests.js +12 -20
  193. package/dist/scripts/smoke.js +541 -422
  194. package/dist/scripts/sponsor-reuse-tests.js +262 -0
  195. package/dist/scripts/state-cli-tests.js +226 -101
  196. package/dist/scripts/state-cli.js +549 -212
  197. package/dist/scripts/static-golden-tests.js +46 -180
  198. package/dist/scripts/time-eval-tests.js +210 -192
  199. package/dist/scripts/travel-timeline-tests.js +29 -93
  200. package/dist/scripts/turn-handoff-collect-tests.js +46 -93
  201. package/dist/scripts/turn-handoff-collect.js +71 -99
  202. package/dist/scripts/turn-policy-tests.js +28 -29
  203. package/dist/scripts/typed-contract-canary.js +77 -162
  204. package/dist/scripts/tz-resolver-sanity.js +50 -0
  205. package/dist/scripts/tz-resolver-tests.js +584 -0
  206. package/dist/scripts/unified-tests.js +23 -19
  207. package/dist/scripts/visa-policy-tests.js +31 -44
  208. package/dist/scripts/weather-tests.js +121 -543
  209. package/dist/scripts/wish-channel-gate-tests.js +29 -86
  210. package/dist/scripts/write-gate-tests.js +522 -0
  211. package/dist/scripts/z3-lifecycle-fault-tests.js +188 -0
  212. package/dist/scripts/z3-lifecycle-tests.js +170 -0
  213. package/dist/scripts/z3-race-repeat-tests.js +39 -0
  214. package/dist/scripts/z3-race-tests.js +18 -12
  215. package/dist/src/artifact-gate.js +652 -221
  216. package/dist/src/backend/kernel.js +91 -0
  217. package/dist/src/backend/modules/booking-copilot.js +55 -0
  218. package/dist/src/backend/modules/booking-executor.js +124 -0
  219. package/dist/src/backend/modules/session-search.js +194 -0
  220. package/dist/src/benchmark-agent-conformance.js +296 -262
  221. package/dist/src/benchmark-environment-bridge.js +298 -413
  222. package/dist/src/benchmark-headless-child-diagnostics.js +93 -100
  223. package/dist/src/benchmark-tool-isolation.js +94 -67
  224. package/dist/src/bookable-facts.js +299 -174
  225. package/dist/src/booking-saga.js +64 -120
  226. package/dist/src/booking-surface/availability-policy.js +443 -576
  227. package/dist/src/booking-surface/cancel-refund-commission.js +379 -0
  228. package/dist/src/booking-surface/contracts.js +24 -80
  229. package/dist/src/booking-surface/dsh-planner.js +510 -281
  230. package/dist/src/booking-surface/dsh-plugin.js +17 -1
  231. package/dist/src/booking-surface/error-codes.js +82 -10
  232. package/dist/src/booking-surface/index.js +0 -3
  233. package/dist/src/booking-surface/profile.js +10 -54
  234. package/dist/src/booking-surface/recovery-chain.js +367 -0
  235. package/dist/src/booking-surface/runtime.js +765 -891
  236. package/dist/src/booking-surface/server.js +200 -251
  237. package/dist/src/booking-surface/startup.js +58 -54
  238. package/dist/src/booking-surface/validation.js +150 -218
  239. package/dist/src/bridge.js +20 -13
  240. package/dist/src/companions.js +48 -74
  241. package/dist/src/contracts.js +21 -43
  242. package/dist/src/dsh-llm.js +82 -120
  243. package/dist/src/engine.js +253 -239
  244. package/dist/src/evaluation-cadence.js +72 -209
  245. package/dist/src/evaluation-contracts.js +220 -843
  246. package/dist/src/flight-pack-adapter.js +54 -0
  247. package/dist/src/flight-pack-contract.js +2 -0
  248. package/dist/src/gotry-backend.js +70 -0
  249. package/dist/src/hotel-date-gate.js +118 -0
  250. package/dist/src/i18n.js +18 -7
  251. package/dist/src/index.js +1425 -1629
  252. package/dist/src/journey.js +132 -81
  253. package/dist/src/ledger-repair-apply.js +538 -0
  254. package/dist/src/ledger-repair-plan.js +463 -0
  255. package/dist/src/loop.js +257 -166
  256. package/dist/src/memory-capture.js +180 -32
  257. package/dist/src/memory-decay.js +30 -14
  258. package/dist/src/memory-lifecycle.js +1310 -0
  259. package/dist/src/memory-utility.js +27 -40
  260. package/dist/src/mock-llm.js +36 -61
  261. package/dist/src/model.js +81 -36
  262. package/dist/src/realtime-pricing.js +63 -72
  263. package/dist/src/slot-spec.js +84 -106
  264. package/dist/src/state-ledger.js +310 -471
  265. package/dist/src/subagent-job-id-guard.js +58 -0
  266. package/dist/src/time-anchor.js +231 -33
  267. package/dist/src/tool-packet.js +20 -8
  268. package/dist/src/travel-slots.js +58 -35
  269. package/dist/src/travel-timeline.js +44 -63
  270. package/dist/src/turn-deadline.js +121 -127
  271. package/dist/src/turn-policy.js +61 -89
  272. package/dist/src/tz-resolver.js +143 -0
  273. package/dist/src/unified.js +504 -381
  274. package/dist/src/wish-pool.js +39 -14
  275. package/dist/src/write-gate.js +495 -0
  276. package/dist/src/z3-shared.js +273 -7
  277. package/extension/README.md +4 -3
  278. package/extension/background.js +125 -14
  279. package/extension/content-bridge.js +23 -1
  280. package/extension/content-main.js +15 -4
  281. package/extension/manifest.json +16 -8
  282. package/package.json +251 -220
  283. package/ts/capabilities/agent-reach.ts +4 -4
  284. package/ts/capabilities/anything.ts +1 -1
  285. package/ts/capabilities/artifacts.ts +46 -9
  286. package/ts/capabilities/flyai.ts +156 -37
  287. package/ts/capabilities/hbcli.ts +1 -1
  288. package/ts/capabilities/incident-log.ts +88 -59
  289. package/ts/capabilities/session/adapters/ctrip-flight.ts +99 -36
  290. package/ts/capabilities/session/adapters/dida-portal.ts +250 -0
  291. package/ts/capabilities/session/adapters/rail-12306.ts +123 -24
  292. package/ts/capabilities/session/extension-bridge.ts +226 -101
  293. package/ts/capabilities/session/extension-channel.ts +22 -9
  294. package/ts/capabilities/session/wizard.ts +2 -2
  295. package/ts/capabilities/session-consent.ts +98 -14
  296. package/ts/capabilities/session-login.ts +1 -0
  297. package/ts/capabilities/session-search.ts +315 -31
  298. package/ts/dsh-runtime/vendor/README.md +8 -4
  299. package/ts/dsh-runtime/vendor/dsh-map-tools/README.en.md +7 -1
  300. package/ts/dsh-runtime/vendor/dsh-map-tools/README.md +5 -3
  301. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/settings-ns.js +1 -1
  302. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/settings-ns.d.ts +1 -1
  303. package/ts/dsh-runtime/vendor/dsh-map-tools/package.json +6 -6
  304. package/ts/package.json +236 -16
  305. package/ts/scripts/map-tools-vendor-package-proof.ts +152 -31
  306. package/ts/scripts/state-cli.ts +418 -51
  307. package/ts/src/artifact-gate.ts +561 -15
  308. package/ts/src/benchmark-environment-bridge.ts +37 -20
  309. package/ts/src/bookable-facts.ts +183 -7
  310. package/ts/src/booking-surface/cancel-refund-commission.ts +488 -0
  311. package/ts/src/booking-surface/dsh-planner.ts +240 -32
  312. package/ts/src/booking-surface/dsh-plugin.js +17 -1
  313. package/ts/src/booking-surface/error-codes.ts +75 -0
  314. package/ts/src/booking-surface/recovery-chain.ts +409 -0
  315. package/ts/src/booking-surface/server.ts +84 -51
  316. package/ts/src/booking-surface/validation.ts +2 -0
  317. package/ts/src/contracts.ts +12 -2
  318. package/ts/src/dsh-llm.ts +4 -8
  319. package/ts/src/index.ts +609 -90
  320. package/ts/src/loop.ts +116 -7
  321. package/ts/src/memory-capture.ts +227 -3
  322. package/ts/src/mock-llm.ts +4 -8
  323. package/ts/src/model.ts +88 -2
  324. package/ts/src/state-ledger.ts +47 -29
  325. package/ts/src/subagent-job-id-guard.ts +72 -0
  326. package/ts/src/time-anchor.ts +217 -13
  327. package/ts/src/unified.ts +316 -54
@@ -1,207 +1,210 @@
1
+ /**
2
+ * 可下单事实模型 + 产物事实闸回归(issue #46,run-all §37;locked golden E2E):
3
+ *
4
+ * 金标准 = #24 同一场景、日期固定 2027-07-16~2027-08-09,数据源 = 2026-08-29
5
+ * 独立审计快照(ts/data/golden-trip-2027-facts.json,逐字段溯源见 fixture meta)。
6
+ *
7
+ * §1 转换与注册表:hit→正事实全字段/miss→负事实/error→不落;幂等去重;最新查询优先
8
+ * §2 验收②:exact-date 未返回 UO784 → 产物不得出现;「上午办事+当天下午直飞普吉」
9
+ * 判定不可行,建议加 1 个香港夜/提前一天
10
+ * §3 验收③:HKT→曼谷 区分 FD→DMK 与 VZ→BKK;合并映射被抓
11
+ * §4 验收④:SZX→DXB 用 exact-date 班次(EK329);营销/实际承运分开保存与渲染
12
+ * §5 验收⑤:protected_connection=true 才允许称「联程」;分票显式标红自助转机
13
+ * §6 验收⑥:酒店夜+机上夜/O&D/legs/预算分项机器断言(35,500 vs 30,000 必抓)
14
+ * §7 验收⑦:政策带 as_of+复核日期;无条件 ✓ 被抓
15
+ * §8 回头路检测:Phuket→Krabi→Phuket 被抓 + KBV→BKK 最小改动建议
16
+ * §9 验收①⑧:全产物闸 golden E2E——复刻会话产物 blocked(违例类别全覆盖),
17
+ * 由结构化事实渲染的产物 pass;无法回溯即禁止「已验证方案」措辞
18
+ *
19
+ * 运行(在 ts/ 下):npx tsx scripts/fact-gate-tests.ts
20
+ */
1
21
  import { readFileSync } from 'node:fs';
2
22
  import { join } from 'node:path';
3
- import { BOOKABLE_FACT_SCHEMA, checkSameDayDeparture, dedupeFacts, detectBacktrack, factsFromFlyai, factsFromHotel, flightClaimVerdict, itineraryInvariants, latestFactsForRouteDate, negativeFact, renderConnection, renderFlightFact, renderHotelFact, renderNightsLine, renderPolicyFact } from '../src/bookable-facts.js';
4
- import { ARTIFACT_GATE_SCHEMA, gateArtifact } from '../src/artifact-gate.js';
23
+ import { BOOKABLE_FACT_SCHEMA, checkSameDayDeparture, dedupeFacts, detectBacktrack, factsFromFlyai, factsFromHotel, flightClaimVerdict, itineraryInvariants, latestFactsForRouteDate, makeFactId, negativeFact, policyCanonicalBody, railClaimVerdict, renderConnection, renderFlightFact, renderHotelFact, renderNightsLine, renderPolicyFact, } from '../src/bookable-facts.js';
24
+ import { ARTIFACT_GATE_SCHEMA, extractClaims, gateArtifact, } from '../src/artifact-gate.js';
25
+ import { parseLeftTicketQuery } from '../capabilities/session/adapters/rail-12306.js';
5
26
  let pass = 0;
6
27
  let fail = 0;
7
28
  function assert(cond, msg) {
8
29
  if (cond) {
9
30
  pass++;
10
31
  console.log(` ok - ${msg}`);
11
- } else {
32
+ }
33
+ else {
12
34
  fail++;
13
35
  console.error(` FAIL - ${msg}`);
14
36
  }
15
37
  }
38
+ // ---- 夹具装载(locked golden:2027-07-16~2027-08-09,审计快照 as_of 2026-08-29) ----
16
39
  const map = JSON.parse(readFileSync(join(import.meta.dirname, '..', '..', 'data', 'airline-airports.json'), 'utf-8'));
17
40
  const alias = map.city_alias;
18
41
  const fixture = JSON.parse(readFileSync(join(import.meta.dirname, '..', 'data', 'golden-trip-2027-facts.json'), 'utf-8'));
19
42
  const FETCHED = '2026-08-29T09:51:00.000Z';
20
- const registry = dedupeFacts(fixture.queries.flatMap((q)=>factsFromFlyai({
21
- kind: q.kind,
22
- origin: q.origin,
23
- destination: q.destination,
24
- date: q.date
25
- }, {
26
- verdict: q.verdict,
27
- options: q.options
28
- }, FETCHED, alias)));
43
+ // golden fixture 查询经生产同款转换器进注册表(miss 落负事实);#299 train fixtures 在 §9b 单独构造
44
+ const registry = dedupeFacts(fixture.queries.flatMap(q => factsFromFlyai({ kind: q.kind, origin: q.origin, destination: q.destination, date: q.date }, { verdict: q.verdict, options: q.options }, FETCHED, alias)));
29
45
  const tripYear = Number(fixture.meta.trip_window[0].slice(0, 4));
46
+ // ---- §1 转换与注册表 ---------------------------------------------------------
30
47
  console.log('§1 转换与注册表');
31
48
  const hkHkt717 = latestFactsForRouteDate(registry, 'HKG', 'HKT', '2027-07-17');
32
- assert(registry.every((f)=>f.schema === BOOKABLE_FACT_SCHEMA && f.fact_id.length === 16), 'schema 词唯一且 fact_id 为 16 位语义散列');
33
- assert(hkHkt717.length === 2 && hkHkt717.every((f)=>f.bookability === 'bookable_exact_date' && f.tier === 'live_inventory'), 'HKG→HKT 2027-07-17 在架两班,均为 exact-date 可下单层');
34
- const uo724 = hkHkt717.find((f)=>f.flight_no === 'UO724');
35
- assert(uo724.dep_local === '07:55' && uo724.arr_local === '10:30' && uo724.nonstop === true && uo724.price === 793 && uo724.currency === 'CNY' && uo724.query_id === 'flyai:flight:香港-普吉:2027-07-17' && uo724.fetched_at === FETCHED && uo724.as_of === '2026-08-29', '事实字段齐全:时刻/直飞/价格/币种/query_id/fetched_at/as_of(issue 期望①的字段面)');
49
+ assert(registry.every(f => f.schema === BOOKABLE_FACT_SCHEMA && f.fact_id.length === 16), 'schema 词唯一且 fact_id 为 16 位语义散列');
50
+ assert(hkHkt717.length === 2 && hkHkt717.every(f => f.bookability === 'bookable_exact_date' && f.tier === 'live_inventory'), 'HKG→HKT 2027-07-17 在架两班,均为 exact-date 可下单层');
51
+ const uo724 = hkHkt717.find(f => f.flight_no === 'UO724');
52
+ assert(uo724.dep_local === '07:55' && uo724.arr_local === '10:30' && uo724.nonstop === true
53
+ && uo724.price === 793 && uo724.currency === 'CNY'
54
+ && uo724.query_id === 'flyai:flight:香港-普吉:2027-07-17' && uo724.fetched_at === FETCHED && uo724.as_of === '2026-08-29', '事实字段齐全:时刻/直飞/价格/币种/query_id/fetched_at/as_of(issue 期望①的字段面)');
36
55
  assert(uo724.marketing_carrier === '香港快运航空' && uo724.operating_carrier === undefined, '营销承运在案;实际承运上游未给 = 留空不猜(未知 ≠ 相同)');
37
- const misses = registry.filter((f)=>f.bookability === 'unavailable_exact_date');
56
+ const misses = registry.filter(f => f.bookability === 'unavailable_exact_date');
38
57
  assert(misses.length === 1 && misses[0].route.origin === 'SZX' && misses[0].route.destination === 'HKT' && misses[0].date === '2027-07-17', 'miss → 负事实(route+date 级「当前不可售」记录,非空缺)');
39
- assert(factsFromFlyai({
40
- kind: 'flight',
41
- origin: '香港',
42
- destination: '普吉',
43
- date: '2027-07-17'
44
- }, {
45
- verdict: 'error'
46
- }, FETCHED, alias).length === 0, 'error 不落事实——通道无结论不是证据');
47
- const dup = dedupeFacts([
48
- ...registry,
49
- {
50
- ...uo724,
51
- fetched_at: '2026-08-30T00:00:00.000Z',
52
- price: 900
53
- }
54
- ]);
55
- const reFetched = dup.filter((f)=>f.fact_id === uo724.fact_id);
58
+ assert(factsFromFlyai({ kind: 'flight', origin: '香港', destination: '普吉', date: '2027-07-17' }, { verdict: 'error' }, FETCHED, alias).length === 0, 'error 不落事实——通道无结论不是证据');
59
+ const dup = dedupeFacts([...registry, { ...uo724, fetched_at: '2026-08-30T00:00:00.000Z', price: 900 }]);
60
+ const reFetched = dup.filter(f => f.fact_id === uo724.fact_id);
56
61
  assert(reFetched.length === 1 && reFetched[0].price === 900, '同 fact_id 重查去重,保留最新 fetched_at(旧快照 append-only 在持久层)');
62
+ // 同 route+date 两次查询:最新一批为准(昨天的 miss 不否定今天的 hit)
57
63
  const stale = negativeFact('flyai:flight:香港-普吉:2027-07-17', 'flight', '香港', '普吉', '2027-07-17', 'flyai', '2026-08-28T00:00:00.000Z', alias);
58
- const mixed = dedupeFacts([
59
- stale,
60
- ...hkHkt717
61
- ]);
62
- assert(latestFactsForRouteDate(mixed, 'HKG', 'HKT', '2027-07-17').every((f)=>f.bookability === 'bookable_exact_date'), '同 route+date 陈旧 miss 不遮蔽更新查询的 hit(最新批次优先)');
64
+ const mixed = dedupeFacts([stale, ...hkHkt717]);
65
+ assert(latestFactsForRouteDate(mixed, 'HKG', 'HKT', '2027-07-17').every(f => f.bookability === 'bookable_exact_date'), '同 route+date 陈旧 miss 不遮蔽更新查询的 hit(最新批次优先)');
66
+ // ---- §2 验收②:UO784 不在 exact-date 源 → 不得出现;核心衔接判不可行 -------------
63
67
  console.log('§2 UO784 与同日衔接硬约束');
64
- const claim = flightClaimVerdict(registry, {
65
- flight_no: 'UO784',
66
- origin: 'HKG',
67
- destination: 'HKT',
68
- date: '2027-07-17'
69
- });
68
+ const claim = flightClaimVerdict(registry, { flight_no: 'UO784', origin: 'HKG', destination: 'HKT', date: '2027-07-17' });
70
69
  assert(claim.verdict === 'not_in_source' && /UO724|CX771/.test(claim.reason), 'UO784 在 HKG→HKT 2027-07-17 被判 not_in_source(源内在架 UO724/CX771)');
71
- const conn = checkSameDayDeparture(registry, {
72
- origin: 'HKG',
73
- destination: 'HKT',
74
- date: '2027-07-17'
75
- }, {
76
- errand_end_local: '13:00',
77
- airport_transit_min: 45,
78
- checkin_deadline_min: 120
79
- });
70
+ const conn = checkSameDayDeparture(registry, { origin: 'HKG', destination: 'HKT', date: '2027-07-17' }, { errand_end_local: '13:00', airport_transit_min: 45, checkin_deadline_min: 120 });
80
71
  assert(!conn.feasible && conn.feasible_flights.length === 0, '「上午办事(13:00 结束)+ 当天下午直飞普吉」判定不可行');
81
- assert(conn.earliest_departure_local === '15:45' && conn.excluded.length === 2 && conn.excluded.every((e)=>/最早可接时刻 15:45/.test(e.reason)), '最早可接 15:45(13:00+45m 门到机场+120m 值机);在架两班早班均被硬约束排除并带原因');
82
- assert(conn.suggestions.some((s)=>s.includes('增加 1 个HKG住宿夜')) && conn.suggestions.some((s)=>s.includes('提前一天')), '不可行建议:增加 1 个香港住宿夜 / 提前一天完成事务或出发(issue 验收②原话面)');
72
+ assert(conn.earliest_departure_local === '15:45' && conn.excluded.length === 2
73
+ && conn.excluded.every(e => /最早可接时刻 15:45/.test(e.reason)), '最早可接 15:45(13:00+45m 门到机场+120m 值机);在架两班早班均被硬约束排除并带原因');
74
+ assert(conn.suggestions.some(s => s.includes('增加 1 个HKG住宿夜')) && conn.suggestions.some(s => s.includes('提前一天')), '不可行建议:增加 1 个香港住宿夜 / 提前一天完成事务或出发(issue 验收②原话面)');
75
+ // ---- §3 验收③:机场映射 FD→DMK / VZ→BKK 不互换 ---------------------------------
83
76
  console.log('§3 航司→机场映射');
84
77
  assert(map.carrier_airport['BKK']?.['FD'] === 'DMK' && map.carrier_airport['BKK']?.['VZ'] === 'BKK', '映射快照:FD→DMK、VZ→BKK(as_of 2026-08-29)');
85
- const mapGate = gateArtifact('### 段|普吉→曼谷(7.30)\n| FD/VZ(亚航/越捷) | 廊曼DMK |', registry, map, {
86
- trip_year: tripYear
87
- });
88
- assert(mapGate.violations.some((v)=>v.kind === 'airport_mapping_conflict' && /VZ 在BKK落 BKK/.test(v.detail)) && !mapGate.violations.some((v)=>v.kind === 'airport_mapping_conflict' && /FD 在/.test(v.detail)), '「FD/VZ 均落 DMK」合并映射:VZ 被抓、FD 不误伤');
89
- const mapOk = gateArtifact('### 段|普吉→曼谷(7.30)\n| FD(亚航) | 廊曼DMK |', registry, map, {
90
- trip_year: tripYear
91
- });
92
- assert(!mapOk.violations.some((v)=>v.kind === 'airport_mapping_conflict'), 'FD→DMK 与映射一致不误报');
78
+ const mapGate = gateArtifact('### 段|普吉→曼谷(7.30)\n| FD/VZ(亚航/越捷) | 廊曼DMK |', registry, map, { trip_year: tripYear });
79
+ assert(mapGate.violations.some(v => v.kind === 'airport_mapping_conflict' && /VZ 在BKK落 BKK/.test(v.detail))
80
+ && !mapGate.violations.some(v => v.kind === 'airport_mapping_conflict' && /FD 在/.test(v.detail)), '「FD/VZ 均落 DMK」合并映射:VZ 被抓、FD 不误伤');
81
+ const mapOk = gateArtifact('### 段|普吉→曼谷(7.30)\n| FD(亚航) | 廊曼DMK |', registry, map, { trip_year: tripYear });
82
+ assert(!mapOk.violations.some(v => v.kind === 'airport_mapping_conflict'), 'FDDMK 与映射一致不误报');
83
+ // ---- §4 验收④:SZX→DXB 用 exact-date 班次;营销/实际承运分开 ---------------------
93
84
  console.log('§4 SZX→DXB 与承运分离');
94
85
  const szxDxb = latestFactsForRouteDate(registry, 'SZX', 'DXB', '2027-08-09');
95
86
  assert(szxDxb.length === 1 && szxDxb[0].flight_no === 'EK329', 'SZX→DXB 2027-08-09 exact-date 班次 = EK329(快照原值,不是旧班 EK327)');
96
87
  const ekLine = renderFlightFact(szxDxb[0]);
97
88
  assert(ekLine.includes('EK329') && ekLine.includes('00:30→04:55') && ekLine.includes('[flyai@') && ekLine.includes('#flyai:flight:深圳-迪拜:2027-08-09'), '渲染行:班次/时刻/证据链(source@fetched_at #query_id)全部来自结构化事实');
98
- const codeshare = {
99
- ...szxDxb[0],
100
- fact_id: 'cs-demo000000000',
101
- flight_no: 'EK8582',
102
- marketing_carrier: '阿联酋航空',
103
- operating_carrier: '中国南方航空'
104
- };
89
+ const codeshare = { ...szxDxb[0], fact_id: 'cs-demo000000000', flight_no: 'EK8582', marketing_carrier: '阿联酋航空', operating_carrier: '中国南方航空' };
105
90
  const csLine = renderFlightFact(codeshare);
106
91
  assert(csLine.includes('营销 阿联酋航空') && csLine.includes('实际承运 中国南方航空'), 'codeshare 渲染同时输出营销与实际承运(不混用)');
92
+ // ---- §4b 价格事实闸(issue #300;flight/train 共用原语,不处理酒店打码价) ---------
93
+ console.log('§4b 行内硬价格与 exact-date 事实对账');
94
+ const uo724Rendered = renderFlightFact(uo724);
95
+ const priceOk = gateArtifact(uo724Rendered, [uo724], map, { trip_year: tripYear });
96
+ assert(priceOk.verdict === 'pass' && priceOk.traceable === 1
97
+ && !priceOk.violations.some(v => v.kind === 'price_contradicted'), 'renderFlightFact(UO724 793 CNY) → gateArtifact 无冲突 pass');
98
+ const farePlusBudget = gateArtifact(`${uo724Rendered};总预算¥1000`, [uo724], map, { trip_year: tripYear });
99
+ assert(farePlusBudget.verdict === 'pass' && farePlusBudget.traceable === 1 && farePlusBudget.violations.length === 0, 'UO724 自身票价后追加总预算¥1000不串价,仍 pass');
100
+ const anchor = uo724Rendered.indexOf('<!-- fact:');
101
+ const budgetBeforeAnchor = gateArtifact(`${uo724Rendered.slice(0, anchor)};总预算¥1000${uo724Rendered.slice(anchor)}`, [uo724], map, { trip_year: tripYear });
102
+ assert(budgetBeforeAnchor.verdict === 'pass' && budgetBeforeAnchor.violations.length === 0, '证据链后的总预算即使位于锚点前也不串为UO724票价');
103
+ const baggageBeforeSourceAndAnchor = gateArtifact(uo724Rendered.replace(' [flyai@', ';行李费¥50 [flyai@'), [uo724], map, { trip_year: tripYear });
104
+ assert(baggageBeforeSourceAndAnchor.verdict === 'pass' && baggageBeforeSourceAndAnchor.traceable === 1
105
+ && baggageBeforeSourceAndAnchor.violations.length === 0, 'canonical票价后的行李费即使位于source与锚点之前也不串为UO724票价');
106
+ const labelledMoneyField = gateArtifact(uo724Rendered.replace(' [flyai@', ';总计¥1000 [flyai@'), [uo724], map, { trip_year: tripYear });
107
+ assert(labelledMoneyField.verdict === 'pass' && labelledMoneyField.traceable === 1
108
+ && labelledMoneyField.violations.length === 0, 'canonical票价后的通用标签金额不被当作第二票价');
109
+ const explicitFareLabel = gateArtifact(uo724Rendered.replace('¥793', '票价¥793'), [uo724], map, { trip_year: tripYear });
110
+ assert(explicitFareLabel.verdict === 'pass' && explicitFareLabel.traceable === 1 && explicitFareLabel.violations.length === 0, '显式票价标签仍属于可靠 fare role');
111
+ const explicitFareField = gateArtifact(uo724Rendered.replace('¥793', ';总预算¥1000;票价¥793'), [uo724], map, { trip_year: tripYear });
112
+ assert(explicitFareField.verdict === 'pass' && explicitFareField.traceable === 1 && explicitFareField.violations.length === 0, '分隔后的预算字段不遮蔽后续显式票价字段');
113
+ for (const [label, renderedAmount] of [
114
+ ['分隔后裸金额', ';¥999'],
115
+ ['分隔后畸形金额', ';¥1,79'],
116
+ ]) {
117
+ const ambiguous = gateArtifact(uo724Rendered.replace('¥793', renderedAmount), [uo724], map, { trip_year: tripYear });
118
+ assert(ambiguous.verdict === 'blocked' && ambiguous.traceable === 0
119
+ && ambiguous.violations.some(v => v.kind === 'unverified_price_claim'), `${label}无可靠 fare role 必须blocked/unverified`);
120
+ }
121
+ const additionalUnlabelledAmount = gateArtifact(uo724Rendered.replace(' [flyai@', ';¥999 [flyai@'), [uo724], map, { trip_year: tripYear });
122
+ assert(additionalUnlabelledAmount.verdict === 'blocked' && additionalUnlabelledAmount.traceable === 0
123
+ && additionalUnlabelledAmount.violations.some(v => v.kind === 'unverified_price_claim'), 'canonical票价之外追加裸金额不得因候选列表为空而放行');
124
+ for (const [label, renderedPrice] of [['¥999', '¥999'], ['CNY 999', 'CNY 999']]) {
125
+ const contradicted = gateArtifact(uo724Rendered.replace('¥793', renderedPrice), [uo724], map, { trip_year: tripYear });
126
+ const priceViolation = contradicted.violations.find(v => v.kind === 'price_contradicted');
127
+ assert(contradicted.verdict === 'blocked' && priceViolation !== undefined
128
+ && /UO724/.test(priceViolation.detail) && /CNY 999/.test(priceViolation.detail) && /CNY 793/.test(priceViolation.detail), `UO724 行内 ${label} ≠ 事实 CNY 793 → blocked/price_contradicted(含可读对账详情)`);
129
+ }
130
+ const trainPriceFact = { ...uo724, kind: 'train', fact_id: makeFactId(['train-price', 'G1234']), flight_no: 'G1234' };
131
+ const trainContradicted = gateArtifact(renderFlightFact(trainPriceFact).replace(' [flyai@', ' ¥794 [flyai@'), [trainPriceFact], map, { trip_year: tripYear });
132
+ assert(trainContradicted.verdict === 'blocked'
133
+ && trainContradicted.violations.some(v => v.kind === 'fact_anchor_unknown' && /canonical renderer/.test(v.detail)), 'train canonical row 不自带价格,手工追加硬价破坏规范锚点后 fail-closed');
134
+ const commaFareFact = { ...uo724, fact_id: makeFactId(['flight-price-comma', 'UO724']), price: 1793 };
135
+ const commaFare = gateArtifact(renderFlightFact(commaFareFact).replace('¥1793', '¥1,793'), [commaFareFact], map, { trip_year: tripYear });
136
+ assert(commaFare.verdict === 'pass' && commaFare.traceable === 1 && commaFare.violations.length === 0, '完整解析千分位¥1,793,与事实1793一致 pass');
137
+ const malformedComma = gateArtifact(uo724Rendered.replace('¥793', '¥1,79'), [uo724], map, { trip_year: tripYear });
138
+ assert(malformedComma.verdict === 'blocked' && malformedComma.traceable === 0
139
+ && malformedComma.violations.some(v => v.kind === 'unverified_price_claim' && /畸形|未核验|完整/.test(v.detail)), 'canonical fare 畸形千分位¥1,79必须blocked/unverified,不得消失或截断为CNY1');
140
+ const missingSourcePrice = gateArtifact(uo724Rendered.replace('¥793', '¥999'), [{ ...uo724, price: undefined }], map, { trip_year: tripYear });
141
+ assert(missingSourcePrice.verdict === 'blocked'
142
+ && missingSourcePrice.violations.some(v => v.kind === 'unverified_price_claim' && /权威 exact-date 事实价格/.test(v.detail)), '源事实缺价时渲染¥999不得冒充已核验价格 → blocked/unverified_price_claim');
143
+ const lateUnanchoredPrice = gateArtifact(`### 香港→普吉(7.17)\n${uo724Rendered.replace(/<!--.*?-->/g, '').replace('¥793', `${'说明'.repeat(90)} CNY 999`)}`, [uo724], map, { trip_year: tripYear });
144
+ assert(lateUnanchoredPrice.verdict === 'blocked'
145
+ && lateUnanchoredPrice.violations.some(v => v.kind === 'unverified_price_claim' && /未锚定|抽取窗口/.test(v.detail)), '无锚点且价格落在120字抽取窗口外不得作为已核验 exact-date 价格 → blocked');
146
+ for (const [label, nonComparablePrice] of [
147
+ ['缺失', '价待询'],
148
+ ['非数字', '¥7xx'],
149
+ ['模糊起价', '约¥999 起'],
150
+ ]) {
151
+ const nonComparable = gateArtifact(uo724Rendered.replace('¥793', nonComparablePrice), [uo724], map, { trip_year: tripYear });
152
+ assert(nonComparable.verdict === 'pass' && !nonComparable.violations.some(v => v.kind === 'price_contradicted'), `${label}价格不作可靠硬价比较,保留既有 claim/anchor 语义不误判 price_contradicted`);
153
+ }
154
+ const unsupportedCurrency = gateArtifact(uo724Rendered.replace('¥793', 'USD999'), [uo724], map, { trip_year: tripYear });
155
+ assert(unsupportedCurrency.verdict === 'blocked'
156
+ && unsupportedCurrency.violations.some(v => v.kind === 'unverified_price_claim' && /不支持直接比较|USD/.test(v.detail))
157
+ && !unsupportedCurrency.violations.some(v => v.kind === 'price_contradicted'), 'USD999 不做汇率换算,标为未核验而不得 pass');
158
+ const hotelPriceRaw = factsFromHotel({ source: 'flyai-hotel', destination: '大理', checkIn: '2026-10-01', checkOut: '2026-10-03', verdict: 'hit', options: 9, evidence: 'priceRaw=¥799', fetchedAt: '2026-09-04T00:00:00.000Z' });
159
+ const hotelMaskedPrice = gateArtifact('## 住宿\n- 大理 2026-10-01→2026-10-03 酒店有房可订 priceRaw=¥799', hotelPriceRaw, map, { trip_year: 2026 });
160
+ assert(hotelMaskedPrice.verdict === 'pass' && !hotelMaskedPrice.violations.some(v => v.kind === 'price_contradicted'), '酒店 priceRaw 打码/原值行不进入航班/火车硬价格对账');
161
+ // ---- §5 验收⑤:联程措辞闸 --------------------------------------------------------
107
162
  console.log('§5 protected_connection 与联程措辞');
108
163
  const fd597 = {
109
- schema: BOOKABLE_FACT_SCHEMA,
110
- fact_id: 'fd597-demo0000000',
111
- kind: 'flight',
112
- route: {
113
- origin: 'SZX',
114
- destination: 'BKK'
115
- },
116
- date: '2027-08-08',
117
- flight_no: 'FD597',
118
- marketing_carrier: '泰国亚洲航空',
119
- dep_local: '18:30',
120
- arr_local: '20:15',
121
- nonstop: true,
122
- price: 700,
123
- currency: 'CNY',
124
- tier: 'live_inventory',
125
- bookability: 'bookable_exact_date',
126
- source: 'flyai',
127
- query_id: 'flyai:flight:深圳-曼谷:2027-08-08',
128
- fetched_at: FETCHED,
129
- as_of: '2026-08-29'
164
+ schema: BOOKABLE_FACT_SCHEMA, fact_id: 'fd597-demo0000000', kind: 'flight',
165
+ route: { origin: 'SZX', destination: 'BKK' }, date: '2027-08-08', flight_no: 'FD597',
166
+ marketing_carrier: '泰国亚洲航空', dep_local: '18:30', arr_local: '20:15', nonstop: true,
167
+ price: 700, currency: 'CNY', tier: 'live_inventory', bookability: 'bookable_exact_date',
168
+ source: 'flyai', query_id: 'flyai:flight:深圳-曼谷:2027-08-08', fetched_at: FETCHED, as_of: '2026-08-29',
130
169
  };
131
170
  const selfTransfer = renderConnection(fd597, szxDxb[0]);
132
171
  assert(selfTransfer.includes('分票/自助转机') && selfTransfer.includes('误机无保护') && !/联程\(/.test(selfTransfer), '两段接得上 ≠ 联程:缺 protected_connection 渲染为分票/自助转机并标红风险');
133
- const throughA = {
134
- ...fd597,
135
- fact_id: 'fd597-protected00',
136
- protected_connection: true
137
- };
138
- const throughB = {
139
- ...szxDxb[0],
140
- fact_id: 'ek329-protected00',
141
- protected_connection: true
142
- };
172
+ const throughA = { ...fd597, fact_id: 'fd597-protected00', protected_connection: true };
173
+ const throughB = { ...szxDxb[0], fact_id: 'ek329-protected00', protected_connection: true };
143
174
  assert(/联程\(同一票号/.test(renderConnection(throughA, throughB)), '双腿 protected_connection=true 才允许渲染「联程(同一票号/行李直挂/误机保护)」');
144
- const throughGate = gateArtifact(`- 深圳→曼谷 FD597 + 曼谷→迪拜 EK329:联程(同一票号)`, [
145
- throughA,
146
- throughB
147
- ], map, {
148
- trip_year: tripYear
149
- });
150
- assert(!throughGate.violations.some((v)=>v.kind === 'self_transfer_called_through'), 'protected_connection=true 的联程行过闸');
151
- const stGate = gateArtifact(`- FD597 深圳→曼谷 + EK 曼谷→迪拜,建议买联程更稳`, [
152
- fd597,
153
- szxDxb[0]
154
- ], map, {
155
- trip_year: tripYear
156
- });
157
- assert(stGate.violations.some((v)=>v.kind === 'self_transfer_called_through'), '「建议买联程」无 protected_connection 证据 → 违例');
175
+ const throughGate = gateArtifact(`- 深圳→曼谷 FD597 + 曼谷→迪拜 EK329:联程(同一票号)`, [throughA, throughB], map, { trip_year: tripYear });
176
+ assert(!throughGate.violations.some(v => v.kind === 'self_transfer_called_through'), 'protected_connection=true 的联程行过闸');
177
+ const stGate = gateArtifact(`- FD597 深圳→曼谷 + EK 曼谷→迪拜,建议买联程更稳`, [fd597, szxDxb[0]], map, { trip_year: tripYear });
178
+ assert(stGate.violations.some(v => v.kind === 'self_transfer_called_through'), '「建议买联程」无 protected_connection 证据 → 违例');
179
+ // ---- §6 验收⑥:统计不变量机器断言 ----------------------------------------------
158
180
  console.log('§6 行程统计不变量');
159
181
  assert(itineraryInvariants(fixture.good_itinerary).length === 0, 'good itinerary 全绿:23 酒店夜 + 1 机上夜 = 24 夜;O&D/legs/预算自洽');
160
182
  const goodNights = renderNightsLine(fixture.good_itinerary);
161
183
  assert(goodNights.includes('24 晚 = 23 个酒店夜 + 1 个机上夜') && goodNights.includes('O&D 6 段(共 6 个 flight legs)'), '夜数口径机器反算:「24 晚 = 23 酒店夜 + 1 机上夜」;航班 O&D 6 段(issue:不是 7 段)');
162
184
  const badViol = itineraryInvariants(fixture.bad_itinerary);
163
- assert(badViol.some((v)=>v.kind === 'nights_inconsistent' && /酒店夜 23 \+ 机上夜 0 = 23 ≠ 行程总夜数 24/.test(v.detail)), 'bad itinerary:23 酒店夜 + 0 机上夜 ≠ 24 总夜被抓(机上夜被吞,「24 天 23 晚」口径混用)');
164
- assert(badViol.some((v)=>v.kind === 'budget_floor_inconsistent' && /35,500/.test(v.detail) && /30,000/.test(v.detail)), 'bad itinerary:分项最低合计 ¥35,500 > 声称可压到 ¥30,000 被抓(issue 预算行原值)');
185
+ assert(badViol.some(v => v.kind === 'nights_inconsistent' && /酒店夜 23 \+ 机上夜 0 = 23 ≠ 行程总夜数 24/.test(v.detail)), 'bad itinerary:23 酒店夜 + 0 机上夜 ≠ 24 总夜被抓(机上夜被吞,「24 天 23 晚」口径混用)');
186
+ assert(badViol.some(v => v.kind === 'budget_floor_inconsistent' && /35,500/.test(v.detail) && /30,000/.test(v.detail)), 'bad itinerary:分项最低合计 ¥35,500 > 声称可压到 ¥30,000 被抓(issue 预算行原值)');
187
+ // ---- §7 验收⑦:政策时间边界 ------------------------------------------------------
165
188
  console.log('§7 政策 as_of 与复核 gate');
166
189
  const policy = {
167
- schema: BOOKABLE_FACT_SCHEMA,
168
- fact_id: 'policy-th-00000000',
169
- kind: 'policy',
170
- subject: '泰国入境(中国护照)',
171
- statement: '免签停留(口径以泰方公告为准);UAE 居民返程应优先校验 residence visa / Emirates ID 而非游客免签口径',
172
- source: 'web:official',
173
- query_id: 'web:policy:泰国免签',
174
- fetched_at: FETCHED,
175
- as_of: '2026-08-29'
190
+ schema: BOOKABLE_FACT_SCHEMA, fact_id: makeFactId(['policy', '泰国入境', 'web:policy:泰国免签']), kind: 'policy',
191
+ subject: '泰国入境(中国护照)', statement: '免签停留(口径以泰方公告为准);UAE 居民返程应优先校验 residence visa / Emirates ID 而非游客免签口径',
192
+ source: 'web:official', query_id: 'web:policy:泰国免签', fetched_at: FETCHED, as_of: '2026-08-29',
176
193
  };
177
194
  const policyLine = renderPolicyFact(policy, fixture.meta.trip_window[0]);
178
195
  assert(policyLine.includes('截至 2026-08-29 的现行政策') && policyLine.includes('2027-06-16') && !/[✓✅]/.test(policyLine), '政策行恒带「截至 as_of」+ D-30 复核日期(2027-06-16),永不用无条件 ✓');
179
- const policyBad = gateArtifact('| 泰国 | 中国护照免签(现行60天/次),停留2周无问题 |', registry, map, {
180
- trip_year: tripYear
181
- });
182
- assert(policyBad.violations.some((v)=>v.kind === 'policy_without_as_of'), '「现行60天」无具体 as_of 日期 = policy_without_as_of(现行 ≠ 时间边界)');
183
- const policyGood = gateArtifact(policyLine, registry, map, {
184
- trip_year: tripYear
185
- });
186
- assert(!policyGood.violations.some((v)=>v.kind === 'policy_without_as_of'), '带「截至 YYYY-MM-DD」的政策行过闸');
196
+ const policyBad = gateArtifact('| 泰国 | 中国护照免签(现行60天/次),停留2周无问题 |', registry, map, { trip_year: tripYear });
197
+ assert(policyBad.violations.some(v => v.kind === 'policy_without_as_of'), '「现行60天」无具体 as_of 日期 = policy_without_as_of(现行 ≠ 时间边界)');
198
+ const policyGood = gateArtifact(policyLine, registry, map, { trip_year: tripYear, tripStart: fixture.meta.trip_window[0] });
199
+ assert(!policyGood.violations.some(v => v.kind === 'policy_without_as_of'), '带「截至 YYYY-MM-DD」的政策行过闸');
200
+ // ---- §8 回头路检测 --------------------------------------------------------------
187
201
  console.log('§8 回头路检测');
188
- const bt = detectBacktrack([
189
- 'HKT',
190
- 'KBV',
191
- 'HKT',
192
- 'BKK'
193
- ], new Set([
194
- 'KBV-BKK'
195
- ]));
202
+ const bt = detectBacktrack(['HKT', 'KBV', 'HKT', 'BKK'], new Set(['KBV-BKK']));
196
203
  assert(bt.length === 1 && bt[0].path.join('→') === 'HKT→KBV→HKT' && (bt[0].suggestion ?? '').includes('KBV→BKK'), 'Phuket→Krabi→Phuket 回头路被抓;KBV→BKK 直飞存在 → 最小改动建议');
197
- assert(detectBacktrack([
198
- 'HKT',
199
- 'KBV',
200
- 'BKK'
201
- ], new Set([
202
- 'KBV-BKK'
203
- ])).length === 0, 'good route(普吉→甲米→曼谷)无回头路不误报');
204
+ assert(detectBacktrack(['HKT', 'KBV', 'BKK'], new Set(['KBV-BKK'])).length === 0, 'good route(普吉→甲米→曼谷)无回头路不误报');
205
+ // ---- §9 验收①⑧:全产物闸 golden E2E ----------------------------------------------
204
206
  console.log('§9 全产物闸 golden E2E(bad blocked / good pass)');
207
+ // 复刻 2026-08-29 会话产物的违例行(issue #46 证据表逐行对应)
205
208
  const badArtifact = `# 2027年7-8月 迪拜→深圳/香港→普吉/甲米→曼谷→云南→迪拜 24天行程规划(v2)
206
209
 
207
210
  ## 一、行程总览
@@ -259,34 +262,33 @@ const badArtifact = `# 2027年7-8月 迪拜→深圳/香港→普吉/甲米→
259
262
 
260
263
  预算可压到 ¥30,000
261
264
  `;
262
- const badReport = gateArtifact(badArtifact, registry, map, {
263
- trip_year: tripYear,
264
- itinerary: fixture.bad_itinerary
265
- });
266
- const badKinds = new Set(badReport.violations.map((v)=>v.kind));
265
+ const badReport = gateArtifact(badArtifact, registry, map, { trip_year: tripYear, itinerary: fixture.bad_itinerary });
266
+ const badKinds = new Set(badReport.violations.map(v => v.kind));
267
267
  const expectedKinds = [
268
- 'not_in_source',
269
- 'contradicted',
270
- 'airport_mapping_conflict',
271
- 'self_transfer_called_through',
272
- 'policy_without_as_of',
273
- 'unconditional_check',
274
- 'nights_inconsistent',
275
- 'budget_floor_inconsistent'
268
+ 'not_in_source', // UO784 / CX787 / MF1538 / EK327 / 8L / 东航南京直飞…
269
+ 'contradicted', // EK328 旧航季时刻(10:05→22:50 vs 快照 11:00→22:40)
270
+ 'airport_mapping_conflict', // FD/VZ 均落 DMK
271
+ 'self_transfer_called_through', // 「建议买 EK 联程」
272
+ 'policy_without_as_of', // 免签政策无截至日期
273
+ 'unconditional_check', // UO784 行的 ✓
274
+ 'nights_inconsistent', // 22+0 ≠ 24
275
+ 'budget_floor_inconsistent', // 35,500 vs 30,000
276
276
  ];
277
- assert(badReport.schema === ARTIFACT_GATE_SCHEMA && badReport.verdict === 'blocked' && badReport.presentation === 'verified_label_forbidden', 'bad artifact:闸 blocked 且禁止「已验证方案」措辞');
278
- for (const k of expectedKinds){
277
+ assert(badReport.schema === ARTIFACT_GATE_SCHEMA && badReport.verdict === 'blocked'
278
+ && badReport.presentation === 'verified_label_forbidden', 'bad artifact:闸 blocked 且禁止「已验证方案」措辞');
279
+ for (const k of expectedKinds) {
279
280
  assert(badKinds.has(k), `bad artifact 违例类别覆盖:${k}`);
280
281
  }
281
- assert(badReport.violations.filter((v)=>v.kind === 'not_in_source').some((v)=>v.detail.startsWith('UO784')), 'UO784 被逐条点名(验收②:任何产物中不得出现该班次)');
282
- assert(!badReport.violations.some((v)=>v.detail.startsWith('MU9626') || /MU 东航直飞/.test(v.detail) && v.kind === 'not_in_source'), '已核验的 MU 东航直飞(BKK→KMG 在架)不误伤');
282
+ assert(badReport.violations.filter(v => v.kind === 'not_in_source').some(v => v.detail.startsWith('UO784')), 'UO784 被逐条点名(验收②:任何产物中不得出现该班次)');
283
+ assert(!badReport.violations.some(v => v.detail.startsWith('MU9626') || /MU 东航直飞/.test(v.detail) && v.kind === 'not_in_source'), '已核验的 MU 东航直飞(BKK→KMG 在架)不误伤');
284
+ // good artifact:全部班次行由结构化事实渲染(渲染层零记忆)
283
285
  const goodFlights = [
284
286
  latestFactsForRouteDate(registry, 'DXB', 'HKG', '2027-07-16')[0],
285
287
  latestFactsForRouteDate(registry, 'HKG', 'HKT', '2027-07-18')[0],
286
288
  latestFactsForRouteDate(registry, 'KBV', 'BKK', '2027-07-30')[0],
287
289
  latestFactsForRouteDate(registry, 'BKK', 'KMG', '2027-08-01')[0],
288
290
  latestFactsForRouteDate(registry, 'LJG', 'SZX', '2027-08-08')[0],
289
- szxDxb[0]
291
+ szxDxb[0],
290
292
  ];
291
293
  const goodArtifact = [
292
294
  `# 2027-07-16 → 2027-08-09 行程(事实闸渲染面)`,
@@ -298,110 +300,570 @@ const goodArtifact = [
298
300
  '',
299
301
  '## 政策',
300
302
  renderPolicyFact(policy, fixture.meta.trip_window[0]),
301
- renderPolicyFact({
302
- ...policy,
303
- fact_id: 'policy-uae-0000000',
304
- subject: '迪拜入境(UAE 居民返程)',
305
- statement: '优先校验 residence visa / Emirates ID;游客免签口径不适用居民返程'
306
- }, fixture.meta.trip_window[0])
303
+ renderPolicyFact({ ...policy, fact_id: makeFactId(['policy', '迪拜入境', 'web:policy:uae-resident']), subject: '迪拜入境(UAE 居民返程)', statement: '优先校验 residence visa / Emirates ID;游客免签口径不适用居民返程' }, fixture.meta.trip_window[0]),
307
304
  ].join('\n');
308
- const goodReport = gateArtifact(goodArtifact, registry, map, {
309
- trip_year: tripYear,
310
- itinerary: fixture.good_itinerary
311
- });
312
- assert(goodReport.verdict === 'pass' && goodReport.presentation === 'verified_itinerary_allowed' && goodReport.traceable === 6 && goodReport.violations.length === 0, `good artifact:6/6 航班 claim 全回溯,闸 pass(违例 ${goodReport.violations.length})`);
313
- assert(goodFlights.every((f)=>f.bookability === 'bookable_exact_date' && f.query_id.includes('flyai:flight:')), 'good artifact 的每个可下单事实均可回溯到 tool result/query id(验收⑧)');
305
+ const goodReport = gateArtifact(goodArtifact, [...registry, policy, { ...policy, fact_id: makeFactId(['policy', '迪拜入境', 'web:policy:uae-resident']), subject: '迪拜入境(UAE 居民返程)', statement: '优先校验 residence visa / Emirates ID;游客免签口径不适用居民返程' }], map, { trip_year: tripYear, itinerary: fixture.good_itinerary, tripStart: fixture.meta.trip_window[0] });
306
+ assert(goodReport.verdict === 'pass' && goodReport.presentation === 'verified_itinerary_allowed'
307
+ && goodReport.traceable === 8 && goodReport.violations.length === 0, `good artifact:6 航班 + 2 政策 claim 全回溯,闸 pass(违例 ${goodReport.violations.length},traceable=${goodReport.traceable})`);
308
+ assert(goodFlights.every(f => f.bookability === 'bookable_exact_date' && f.query_id.includes('flyai:flight:')), 'good artifact 的每个可下单事实均可回溯到 tool result/query id(验收⑧)');
309
+ // ---------------------------------------------------------------------------
310
+ // §9b 混合机票/车次 claim(issue #299):分类独立 + rail fail-closed
311
+ // ---------------------------------------------------------------------------
312
+ {
313
+ const czFlight = factsFromFlyai({ kind: 'flight', origin: '香港', destination: '普吉', date: '2027-07-17' }, { verdict: 'hit', options: [{ no: 'CZ8582', depDateTime: '2027-07-17T08:00:00', arrDateTime: '2027-07-17T12:00:00' }] }, FETCHED, alias)[0];
314
+ const gTrain = factsFromFlyai({ kind: 'train', origin: '香港', destination: '普吉', date: '2027-07-17' }, { verdict: 'hit', options: [{ no: 'G1234', depDateTime: '2027-07-17T09:00:00', arrDateTime: '2027-07-17T13:00:00' }] }, FETCHED, alias)[0];
315
+ const nineCFlight = factsFromFlyai({ kind: 'flight', origin: '香港', destination: '普吉', date: '2027-07-17' }, { verdict: 'hit', options: [{ no: '9C8781', depDateTime: '2027-07-17T10:00:00', arrDateTime: '2027-07-17T14:00:00' }] }, FETCHED, alias)[0];
316
+ const otherTrainFacts = ['D3112', 'C2001', 'Z9999'].flatMap(no => factsFromFlyai({ kind: 'train', origin: '香港', destination: '普吉', date: '2027-07-17' }, { verdict: 'hit', options: [{ no, depDateTime: '2027-07-17T09:00:00', arrDateTime: '2027-07-17T13:00:00' }] }, FETCHED, alias));
317
+ const mixedText = '### 段|香港→普吉(7.17)\n- CZ8582 08:00→12:00;9C8781 10:00→14:00;G1234 09:00→13:00;D3112 09:10→13:10;C2001 09:20→13:20;Z9999 09:30→13:30';
318
+ const mixedClaims = extractClaims(mixedText, map, { trip_year: tripYear });
319
+ assert(['CZ8582', '9C8781'].every(no => mixedClaims.flights.some(c => c.flight_no === no))
320
+ && !mixedClaims.flights.some(c => ['G1234', 'D3112', 'C2001', 'Z9999'].includes(c.flight_no)), '混合产物:合法双字母航司 CZ8582/9C8781 保留,单字母车次不进入 flights');
321
+ assert(mixedClaims.trains.length === 4
322
+ && ['G1234', 'D3112', 'C2001', 'Z9999'].every(no => mixedClaims.trains.some(c => c.flight_no === no)), '混合产物:G/D/C/Z 完整车次进入独立 trains claim 集合');
323
+ const codeBoundaryClaims = extractClaims('CA1234 MU1234 CZ8582 G1234 D3112 C2001 Z9999', map, { trip_year: tripYear });
324
+ assert(['CA1234', 'MU1234', 'CZ8582'].every(no => codeBoundaryClaims.flights.some(c => c.flight_no === no))
325
+ && !codeBoundaryClaims.flights.some(c => ['G1234', 'D3112', 'C2001', 'Z9999'].includes(c.flight_no))
326
+ && ['G1234', 'D3112', 'C2001', 'Z9999'].every(no => codeBoundaryClaims.trains.some(c => c.flight_no === no)), '边界分类:CA/MU/CZ 双字母航司保留,G/D/C/Z 完整车次仅进入 trains');
327
+ const flightOnly = gateArtifact(mixedText, [czFlight, nineCFlight], map, { trip_year: tripYear });
328
+ assert(flightOnly.verdict === 'blocked'
329
+ && flightOnly.violations.some(v => v.kind === 'rail_claim_unverified' && v.detail.startsWith('G1234'))
330
+ && !flightOnly.violations.some(v => v.kind === 'not_in_source' && v.detail.startsWith('G1234'))
331
+ && flightOnly.traceable === 2, '缺 train source 时仅 rail_claim_unverified;航班 authority 独立且 CZ8582/9C8781 仍 traceable');
332
+ assert(flightClaimVerdict([gTrain], { flight_no: 'CZ8582', origin: 'HKG', destination: 'HKT', date: '2027-07-17' }).verdict === 'route_unqueried', 'flightClaimVerdict 只看 kind=flight,同 route/date train 事实不制造 flight not_in_source');
333
+ assert(railClaimVerdict([czFlight, gTrain], { flight_no: 'G1234', origin: 'HKG', destination: 'HKT', date: '2027-07-17' }).verdict === 'traceable', 'railClaimVerdict 只看 kind=train + exact date + route/code + bookable + structured source');
334
+ assert(railClaimVerdict([gTrain], { flight_no: 'G1234' }).verdict === 'rail_claim_unverified'
335
+ && gateArtifact('- G1234 09:00→13:00', [gTrain], map, { trip_year: tripYear }).violations.some(v => v.kind === 'rail_claim_unverified'), '无 route/date 的 unanchored rail claim 即使同号事实存在也 fail-closed');
336
+ const newerTrainMiss = negativeFact('flyai:train:香港-普吉:2027-07-17', 'train', '香港', '普吉', '2027-07-17', 'flyai', '2026-08-30T00:00:00.000Z', alias);
337
+ assert(railClaimVerdict([gTrain, newerTrainMiss], { flight_no: 'G1234', origin: 'HKG', destination: 'HKT', date: '2027-07-17' }).verdict === 'not_in_source', '旧 train hit + 新 train miss/unavailable 批次不得继续 traceable');
338
+ assert(railClaimVerdict([
339
+ { ...gTrain, fetched_at: '2026-08-28T00:00:00.000Z' },
340
+ { ...czFlight, fetched_at: '2026-08-30T00:00:00.000Z' },
341
+ ], { flight_no: 'D3112', origin: 'HKG', destination: 'HKT', date: '2027-07-17' }).verdict === 'not_in_source'
342
+ && flightClaimVerdict([
343
+ { ...czFlight, fetched_at: '2026-08-28T00:00:00.000Z' },
344
+ { ...gTrain, fetched_at: '2026-08-30T00:00:00.000Z' },
345
+ ], { flight_no: 'MU9999', origin: 'HKG', destination: 'HKT', date: '2027-07-17' }).verdict === 'not_in_source', '最新批次按 kind 隔离:更新的 flight 不遮蔽 train,更新的 train 不遮蔽 flight');
346
+ assert(railClaimVerdict([{ ...gTrain, source: 'static-schedule', query_id: 'static:train:HKG-HKT:2027-07-17' }], { flight_no: 'G1234', origin: 'HKG', destination: 'HKT', date: '2027-07-17' }).verdict === 'route_unqueried', 'static-schedule train 不是 rail structured source,不得伪造 traceable');
347
+ const railGood = gateArtifact(mixedText, [czFlight, nineCFlight, gTrain, ...otherTrainFacts], map, { trip_year: tripYear });
348
+ assert(railGood.verdict === 'pass' && railGood.traceable === 6 && railGood.violations.length === 0, '显式 kind=train exact-date fixture 仅经 rail verdict 回溯,混合产物 pass');
349
+ const trainRendered = renderFlightFact(gTrain);
350
+ const trainRenderedGate = gateArtifact(trainRendered, [gTrain], map, { trip_year: tripYear });
351
+ assert(trainRendered.includes(`<!-- fact:${gTrain.fact_id} -->`)
352
+ && trainRendered.includes('车次')
353
+ && !trainRendered.includes('直飞')
354
+ && !trainRendered.includes('¥')
355
+ && !trainRendered.includes('价待询')
356
+ && trainRenderedGate.verdict === 'pass' && trainRenderedGate.traceable === 1, 'train canonical renderer 保留 typed anchor,只写车次语义且不伪造直飞/价格,可经 gate 回溯');
357
+ const trainAnchorTamperCases = [
358
+ ['车次', trainRendered.replace('G1234', 'G9999')],
359
+ ['出发时间', trainRendered.replace('09:00', '06:00')],
360
+ ['日期', trainRendered.replace('2027-07-17', '2027-11-12')],
361
+ ['route', trainRendered.replace(`${gTrain.route.origin}→${gTrain.route.destination}`, `${gTrain.route.origin}→DIFF`)],
362
+ ];
363
+ assert(trainAnchorTamperCases.every(([, tampered]) => {
364
+ const report = gateArtifact(tampered, [gTrain], map, { trip_year: tripYear });
365
+ return report.verdict === 'blocked' && report.violations.some(v => v.kind === 'fact_anchor_unknown');
366
+ }), 'train canonical anchor 篡改车次/出发时间/日期/route 均被 fact_anchor_unknown 拒绝');
367
+ assert(factsFromFlyai({ kind: 'train', origin: '上海', destination: '昆明', date: '2027-12-01' }, { verdict: 'error' }, FETCHED, alias).length === 0, 'train source error 不落负事实;通道无结论不是 rail evidence');
368
+ assert(parseLeftTicketQuery('not json', 'https://kyfw.12306.cn/otn/leftTicket/init').length === 0
369
+ && parseLeftTicketQuery('{"data":{"result":[]}}', 'https://kyfw.12306.cn/otn/leftTicket/init').length === 0, '12306 malformed/empty body 只返回空解析结果,不转成 train negative fact');
370
+ }
371
+ // ---------------------------------------------------------------------------
372
+ // §10 酒店事实闸(D-26,issue #118):酒店 claim 入闸 + 渲染原语单向生成
373
+ // ---------------------------------------------------------------------------
314
374
  {
315
375
  const fetchedAt = '2026-09-04T00:00:00.000Z';
316
- assert(factsFromHotel({
317
- source: 'flyai-hotel',
318
- destination: '大理',
319
- verdict: 'hit',
320
- options: 9,
321
- evidence: 'e',
322
- fetchedAt
323
- }).length === 0, '摸底(无档期)酒店检索不落账(无 exact-date 语义)');
324
- assert(factsFromHotel({
325
- source: 'flyai-hotel',
326
- destination: '大理',
327
- checkIn: '2026-10-01',
328
- checkOut: '2026-10-03',
329
- verdict: 'needs-setup',
330
- options: 0,
331
- evidence: 'e',
332
- fetchedAt
333
- }).length === 0 && factsFromHotel({
334
- source: 'flyai-hotel',
335
- destination: '大理',
336
- checkIn: '2026-10-01',
337
- checkOut: '2026-10-03',
338
- verdict: 'error',
339
- options: 0,
340
- evidence: 'e',
341
- fetchedAt
342
- }).length === 0, 'needs-setup/error 传输失败永不落负事实(ADR-19 不变量)');
343
- const hitFacts = factsFromHotel({
344
- source: 'flyai-hotel',
345
- destination: '大理',
346
- checkIn: '2026-10-01',
347
- checkOut: '2026-10-03',
348
- verdict: 'hit',
349
- options: 9,
350
- evidence: 'e',
351
- fetchedAt
352
- });
353
- const missFacts = factsFromHotel({
354
- source: 'session:ctrip-hotel',
355
- destination: '大理',
356
- checkIn: '2026-10-01',
357
- checkOut: '2026-10-03',
358
- verdict: 'miss',
359
- options: 0,
360
- evidence: 'e',
361
- fetchedAt
362
- });
376
+ // 10a. 摸底(未定档期)不落账;needs-setup/error 传输失败不落负事实
377
+ assert(factsFromHotel({ source: 'flyai-hotel', destination: '大理', verdict: 'hit', options: 9, evidence: 'e', fetchedAt }).length === 0, '摸底(无档期)酒店检索不落账(无 exact-date 语义)');
378
+ assert(factsFromHotel({ source: 'flyai-hotel', destination: '大理', checkIn: '2026-10-01', checkOut: '2026-10-03', verdict: 'needs-setup', options: 0, evidence: 'e', fetchedAt }).length === 0
379
+ && factsFromHotel({ source: 'flyai-hotel', destination: '大理', checkIn: '2026-10-01', checkOut: '2026-10-03', verdict: 'error', options: 0, evidence: 'e', fetchedAt }).length === 0, 'needs-setup/error 传输失败永不落负事实(ADR-19 不变量)');
380
+ // 10b. hit/miss 落账 + 打码纪律(不落数字价)
381
+ const hitFacts = factsFromHotel({ source: 'flyai-hotel', destination: '大理', checkIn: '2026-10-01', checkOut: '2026-10-03', verdict: 'hit', options: 9, evidence: 'e', fetchedAt });
382
+ const missFacts = factsFromHotel({ source: 'session:ctrip-hotel', destination: '大理', checkIn: '2026-10-01', checkOut: '2026-10-03', verdict: 'miss', options: 0, evidence: 'e', fetchedAt });
363
383
  assert(hitFacts.length === 1 && hitFacts[0].bookability === 'bookable_exact_date' && hitFacts[0].options_masked === 9, 'exact-date hit → 正事实(在架家数,无数字价字段)');
364
384
  assert(!('price' in hitFacts[0]), '酒店事实无 price 字段(打码价保真,不落数字价)');
365
385
  assert(missFacts.length === 1 && missFacts[0].bookability === 'unavailable_exact_date', 'exact-date miss → 负事实');
386
+ // 10c. 渲染原语单向生成:renderHotelFact 行带锚点,闸锚点回溯 pass
366
387
  const hotelLine = renderHotelFact(hitFacts[0]);
367
388
  assert(hotelLine.includes(`fact:${hitFacts[0].fact_id}`), 'renderHotelFact 行内嵌 fact 锚点');
368
- const anchoredArtifact = [
369
- '## 住宿',
370
- hotelLine
371
- ].join('\n');
372
- const anchoredReport = gateArtifact(anchoredArtifact, [
373
- ...hitFacts
374
- ], map, {
375
- trip_year: 2026
376
- });
389
+ const anchoredArtifact = ['## 住宿', hotelLine].join('\n');
390
+ const anchoredReport = gateArtifact(anchoredArtifact, [...hitFacts], map, { trip_year: 2026 });
377
391
  assert(anchoredReport.verdict === 'pass' && anchoredReport.traceable === 1, `锚点酒店行 → 锚点确定性回溯 pass(实际 ${anchoredReport.verdict}/traceable=${anchoredReport.traceable})`);
392
+ // 10d. 手改锚点 → fact_anchor_unknown(伪造即抓)
378
393
  const forged = anchoredArtifact.replace(hitFacts[0].fact_id, '0123456789abcdef');
379
- const forgedReport = gateArtifact(forged, [
380
- ...hitFacts
381
- ], map, {
382
- trip_year: 2026
383
- });
384
- assert(forgedReport.violations.some((v)=>v.kind === 'fact_anchor_unknown'), '手改/伪造锚点 → fact_anchor_unknown');
385
- const claimHit = gateArtifact('## 住宿\n- 大理 2026-10-01→2026-10-03 洱海民宿有房可订', [
386
- ...hitFacts
387
- ], map, {
388
- trip_year: 2026
389
- });
394
+ const forgedReport = gateArtifact(forged, [...hitFacts], map, { trip_year: 2026 });
395
+ assert(forgedReport.violations.some(v => v.kind === 'fact_anchor_unknown'), '手改/伪造锚点 → fact_anchor_unknown');
396
+ // 10e. 无锚点启发式:hit 在册 → traceable;exact-date miss 在册却写有房 → not_in_source + 无条件✓
397
+ const claimHit = gateArtifact('## 住宿\n- 大理 2026-10-01→2026-10-03 洱海民宿有房可订', [...hitFacts], map, { trip_year: 2026 });
390
398
  assert(claimHit.verdict === 'pass' && claimHit.traceable === 1, '启发式酒店 claim 命中在册 hit → traceable');
391
- const claimMiss = gateArtifact('## 住宿\n- 大理 2026-10-01→2026-10-03 洱海民宿有房可订 ✓', [
392
- ...missFacts
393
- ], map, {
394
- trip_year: 2026
395
- });
396
- assert(claimMiss.violations.some((v)=>v.kind === 'not_in_source'), 'exact-date miss 在册却写有房 → not_in_source');
397
- assert(claimMiss.violations.some((v)=>v.kind === 'unconditional_check'), '对未核验酒店用 ✓ → unconditional_check');
398
- const noCtx = gateArtifact('## 住宿\n- 住宿:湖景房有房可订 ✓', [], map, {
399
- trip_year: 2026
400
- });
401
- assert(noCtx.violations.some((v)=>v.kind === 'unverifiable_hotel_claim'), '缺目的地上下文 → unverifiable_hotel_claim(fail closed)');
399
+ const claimMiss = gateArtifact('## 住宿\n- 大理 2026-10-01→2026-10-03 洱海民宿有房可订 ✓', [...missFacts], map, { trip_year: 2026 });
400
+ assert(claimMiss.violations.some(v => v.kind === 'not_in_source'), 'exact-date miss 在册却写有房 → not_in_source');
401
+ assert(claimMiss.violations.some(v => v.kind === 'unconditional_check'), '对未核验酒店用 ✓ → unconditional_check');
402
+ // 10f. 无目的地上下文的可住断言 → fail-closed
403
+ const noCtx = gateArtifact('## 住宿\n- 住宿:湖景房有房可订 ✓', [], map, { trip_year: 2026 });
404
+ assert(noCtx.violations.some(v => v.kind === 'unverifiable_hotel_claim'), '缺目的地上下文 unverifiable_hotel_claim(fail closed)');
402
405
  console.log(` ok - §10 酒店事实闸(D-26)八断言完成`);
403
- }console.log(`\nFACT GATE TESTS: ${pass} pass, ${fail} fail`);
404
- if (fail > 0) process.exit(1);
405
-
406
-
407
- //# sourceURL=ts/scripts/fact-gate-tests.ts
406
+ }
407
+ // ---------------------------------------------------------------------------
408
+ // §11 政策渲染锚点 + 海关申报关键词覆盖(issue #273,D-26 残余收口)
409
+ // ---------------------------------------------------------------------------
410
+ {
411
+ const policyUaE = { ...policy, fact_id: makeFactId(['policy', '迪拜入境', 'web:policy:uae-resident']), subject: '迪拜入境(UAE 居民返程)', statement: '优先校验 residence visa / Emirates ID;游客免签口径不适用居民返程' };
412
+ const policyLine11 = renderPolicyFact(policy, fixture.meta.trip_window[0]);
413
+ // 11a. 渲染器输出自带 fact 锚点(确定性回溯,与航班/酒店同源)
414
+ assert(policyLine11.includes(`<!-- fact:${policy.fact_id} -->`), 'renderPolicyFact 行内嵌 fact 锚点(issue #273 typed-anchor 闭合)');
415
+ // 11b. 锚点 policy 走确定性回溯 = traceable(锚点行启发式让位)
416
+ // 历史 #273 形态:renderer 给 tripStart,闸侧仅 { trip_year } —— 兼容性保留。
417
+ const anchoredPolicyArtifact = ['## 政策', policyLine11].join('\n');
418
+ const anchoredPolicyReport = gateArtifact(anchoredPolicyArtifact, [policy], map, { trip_year: tripYear });
419
+ assert(anchoredPolicyReport.verdict === 'pass' && anchoredPolicyReport.traceable === 1, `政策锚点行 → 锚点确定性回溯 pass(实际 ${anchoredPolicyReport.verdict}/traceable=${anchoredPolicyReport.traceable})`);
420
+ // 11c. 手改锚点 → fact_anchor_unknown(伪造即抓,与酒店锚点一致)
421
+ const forgedPolicyArtifact = anchoredPolicyArtifact.replace(policy.fact_id, '0123456789abcdef');
422
+ const forgedPolicyReport = gateArtifact(forgedPolicyArtifact, [policy], map, { trip_year: tripYear });
423
+ assert(forgedPolicyReport.violations.some(v => v.kind === 'fact_anchor_unknown'), '手改政策锚点 → fact_anchor_unknown(与酒店锚点同源 fail-closed)');
424
+ // 11d. 海关申报 缺 as_of → policy_without_as_of(red→green:原 regex 漏掉,新增关键词后 fail-closed)
425
+ const customsNoAsOf = gateArtifact('| 美国 | 海关申报需在线填写(现行 30 日内单次) |', registry, map, { trip_year: tripYear });
426
+ assert(customsNoAsOf.violations.some(v => v.kind === 'policy_without_as_of'), '「海关申报」缺 as_of → policy_without_as_of(D-26 残余:边界关键词覆盖)');
427
+ // 11e. 海关申报 带「截至 YYYY-MM-DD」过闸(确保新增关键词不影响已守纪产物)
428
+ const customsAsOf = gateArtifact('| 美国 | 海关申报需在线填写(截至 2026-08-29 现行 30 日内单次) |', registry, map, { trip_year: tripYear });
429
+ assert(!customsAsOf.violations.some(v => v.kind === 'policy_without_as_of'), '「海关申报」带截至日期过闸(关键词扩展不误伤)');
430
+ // 11f. 完整 16-hex 形态但不在注册表 → fact_anchor_unknown(独立伪造,非替换)
431
+ // 11c 是替换已有锚点;11f 是凭空追加一个不属于任何事实的锚点。
432
+ const phantomAnchor = `## 政策\n- 幻影政策:截至 2026-08-29 的现行政策——幻影 [web:phantom@2026-08-29 #web:policy:phantom] <!-- fact:deadbeefdeadbeef -->`;
433
+ const phantomReport = gateArtifact(phantomAnchor, [policy], map, { trip_year: tripYear });
434
+ assert(phantomReport.violations.some(v => v.kind === 'fact_anchor_unknown' && /deadbeefdeadbeef/.test(v.detail)), '凭空追加非注册表 fact_id(完整 16-hex)→ fact_anchor_unknown');
435
+ // 11g. 锚点 + 手改 as_of(改了"截至 YYYY-MM-DD"日期)
436
+ const asOfMutation = policyLine11.replace(/截至\s*\d{4}-\d{2}-\d{2}/, '截至 2027-01-01');
437
+ const asOfReport = gateArtifact(['## 政策', asOfMutation].join('\n'), [policy], map, { trip_year: tripYear });
438
+ assert(asOfReport.violations.some(v => v.kind === 'fact_anchor_unknown' && /2027-01-01/.test(v.detail)), `锚点 + 改写 as_of 2027-01-01 → fact_anchor_unknown(内容指纹形态;实际 ${asOfReport.violations.length} 条违例)`);
439
+ // 11h. 锚点 + 删除截至日期 → 同样 fail-closed,不因缺少可比较值而放行。
440
+ const asOfRemoved = policyLine11.replace(/截至\s*\d{4}-\d{2}-\d{2}\s*的现行政策——/, '现行政策——');
441
+ const asOfRemovedReport = gateArtifact(['## 政策', asOfRemoved].join('\n'), [policy], map, { trip_year: tripYear });
442
+ assert(asOfRemovedReport.violations.some(v => v.kind === 'fact_anchor_unknown' && /缺少截至日期|内容指纹不符/.test(v.detail)), `锚点 + 删除截至日期 → fact_anchor_unknown(缺失内容指纹;实际 ${asOfRemovedReport.violations.length} 条违例)`);
443
+ console.log(` ok - §11 政策渲染锚点 + 海关申报关键词 fail-closed 八断言完成`);
444
+ }
445
+ // ---------------------------------------------------------------------------
446
+ // §12 酒店非关键词 category + lodging heading depth(issues #301,#273)
447
+ // ---------------------------------------------------------------------------
448
+ {
449
+ const fetchedAt = '2026-09-04T00:00:00.000Z';
450
+ // 12a. 5 类非关键词住宿 category,缺档期事实 → unverifiable_hotel_claim + unconditional_check
451
+ // 精品酒店 沿用现有 酒店 token,本切片不重复声明
452
+ for (const cat of ['度假村', '青旅', '青年旅舍', '别墅', '公寓']) {
453
+ const claim = gateArtifact(`## 住宿\n- 大理 2026-10-01→2026-10-03 ${cat}有房可订 ✓`, [], map, { trip_year: 2026 });
454
+ assert(claim.violations.some(v => v.kind === 'unverifiable_hotel_claim'), `${cat} 缺档期事实 → unverifiable_hotel_claim`);
455
+ assert(claim.violations.some(v => v.kind === 'unconditional_check'), `${cat} 缺事实 + ✓ → unconditional_check`);
456
+ }
457
+ // 12b. 命中在册 hit 时 traceable pass(非关键词 category 也走通)
458
+ const daliHit = factsFromHotel({ source: 'flyai-hotel', destination: '大理', checkIn: '2026-10-01', checkOut: '2026-10-03', verdict: 'hit', options: 9, evidence: 'e', fetchedAt });
459
+ const daliTrace = gateArtifact('## 住宿\n- 大理 2026-10-01→2026-10-03 度假村有房可订', [...daliHit], map, { trip_year: 2026 });
460
+ assert(daliTrace.verdict === 'pass' && daliTrace.traceable === 1, `度假村 命中在册 hit → traceable pass(实际 ${daliTrace.verdict}/traceable=${daliTrace.traceable})`);
461
+ // 12c. legacy 兜底保留:行内含 住宿 token 的 住宿:湖景房有房可订 ✓ 仍入闸
462
+ const lake = gateArtifact('## 住宿\n- 住宿:湖景房有房可订 ✓', [], map, { trip_year: 2026 });
463
+ assert(lake.violations.some(v => v.kind === 'unverifiable_hotel_claim'), 'legacy 行内 住宿:湖景房有房可订 ✓ 兜底保留(行内 token)');
464
+ // 12d. ATX 1–6 lodging heading 激活
465
+ for (const [level, hashes] of [[1, '#'], [2, '##'], [3, '###'], [4, '####'], [5, '#####'], [6, '######']]) {
466
+ const r = gateArtifact(`${hashes} 住宿\n- 湖景房有房可订 ✓`, [], map, { trip_year: 2026 });
467
+ assert(r.violations.some(v => v.kind === 'unverifiable_hotel_claim'), `ATX ${level} lodging heading 激活(contextual 行入闸)`);
468
+ }
469
+ // 12e. 住宿 heading 下更深 non-lodging 子标题保留外层 lodging context
470
+ const deeperNonLodging = gateArtifact('## 住宿\n### 湖景房\n- 有房可订', [], map, { trip_year: 2026 });
471
+ assert(deeperNonLodging.violations.some(v => v.kind === 'unverifiable_hotel_claim'), '更深 non-lodging 子标题继承 lodging context(## 住宿 → ### 湖景房)');
472
+ // 12f. 住宿 heading 下更深 lodging 子标题保留外层 context(栈不丢外层)
473
+ const deeperLodging = gateArtifact('## 住宿\n#### 酒店\n### 湖景房\n- 有房可订', [], map, { trip_year: 2026 });
474
+ const fViolations = deeperLodging.violations.filter(v => v.kind === 'unverifiable_hotel_claim');
475
+ assert(fViolations.length === 1 && fViolations[0].line === 4, `更深 lodging 子标题栈保留外层(实际 ${fViolations.length} 条,line=${fViolations.map(v => v.line).join(',')})`);
476
+ // 12g. 同级 non-lodging heading 退出(精确 line 归属)
477
+ const sameLevelExit = gateArtifact('## 住宿\n- 湖景房有房可订 ✓\n## 航班\n- 湖景房有房可订', [], map, { trip_year: 2026 });
478
+ const gViolations = sameLevelExit.violations.filter(v => v.kind === 'unverifiable_hotel_claim');
479
+ assert(gViolations.length === 1 && gViolations[0].line === 2, `同级 non-lodging heading 退出(实际 ${gViolations.length} 条,line=${gViolations.map(v => v.line).join(',')})`);
480
+ // 12h. 更高 non-lodging heading 退出
481
+ const higherExit = gateArtifact('## 住宿\n- 湖景房有房可订 ✓\n# 航班\n- 湖景房有房可订', [], map, { trip_year: 2026 });
482
+ const hViolations = higherExit.violations.filter(v => v.kind === 'unverifiable_hotel_claim');
483
+ assert(hViolations.length === 1 && hViolations[0].line === 2, `更高 non-lodging heading 退出(实际 ${hViolations.length} 条,line=${hViolations.map(v => v.line).join(',')})`);
484
+ // 12i. 同级 lodging heading 替换该层(栈仍能激活后续)
485
+ const sameLevelLodging = gateArtifact('## 住宿\n- 湖景房有房可订 ✓\n## 酒店\n- 湖景房有房可订', [], map, { trip_year: 2026 });
486
+ assert(sameLevelLodging.violations.filter(v => v.kind === 'unverifiable_hotel_claim').length === 2, `同级 lodging heading 替换该层,后续行仍入闸(实际 ${sameLevelLodging.violations.length} 条)`);
487
+ // 12j. 无任何 heading 时,裸「湖景房有房可订」不进 hotel claim
488
+ const noHeader = gateArtifact('- 湖景房有房可订 ✓', [], map, { trip_year: 2026 });
489
+ assert(!noHeader.violations.some(v => v.kind === 'unverifiable_hotel_claim'), '无 heading 上下文时裸「湖景房有房可订 ✓」不升为 hotel claim');
490
+ // 12k. lodging heading 下裸 ✓/✅ 不构成 bookability(根实测 #347 反馈补)
491
+ const bareCheck = gateArtifact('## 住宿\n- 靠近地铁 ✓', [], map, { trip_year: 2026 });
492
+ assert(!bareCheck.violations.some(v => v.kind === 'unverifiable_hotel_claim'), 'lodging heading 下裸 ✓/✅ 不构成 bookability(靠近地铁 ✓ 不入闸)');
493
+ // 12l. contextual 可订短语不带 ✓ 也入闸;带 ✓ 才加 unconditional_check
494
+ const ctxNoCheck = gateArtifact('## 住宿\n- 湖景房有房可订', [], map, { trip_year: 2026 });
495
+ assert(ctxNoCheck.violations.some(v => v.kind === 'unverifiable_hotel_claim') && !ctxNoCheck.violations.some(v => v.kind === 'unconditional_check'), 'contextual 可订短语不带 ✓ → unverifiable_hotel_claim only');
496
+ const ctxCheck = gateArtifact('## 住宿\n- 湖景房有房可订 ✓', [], map, { trip_year: 2026 });
497
+ assert(ctxCheck.violations.some(v => v.kind === 'unverifiable_hotel_claim') && ctxCheck.violations.some(v => v.kind === 'unconditional_check'), 'contextual 可订短语带 ✓ → 两违例(无 unconditional_check → 漏报)');
498
+ console.log(` ok - §12 完成(booking heading depth + contextual bookability phrase 分流)`);
499
+ }
500
+ // ---------------------------------------------------------------------------
501
+ // §13 政策关键词扩词(issue #302,父 #273,D-26 残余收口切片)
502
+ // 扩词仅限父 issue 显式列举的有限并集,不做 NLP/同义词扩展。
503
+ // 测试 fixture = 语法占位,不对现实政策内容做任何断言。仅验证
504
+ // POLICY_WORD regex + AS_OF_WORD 时间边界,不引入 country/rule/value。
505
+ // ---------------------------------------------------------------------------
506
+ {
507
+ const policyTerms = [
508
+ 'EVUS', 'ETA', 'eVisa', '疫苗', '疫苗接种', '健康申报', '隔离', '工作签', '居留', '返程签', '护照有效期', '黄皮书', '保险',
509
+ ];
510
+ const fixtureAsOf = '2026-08-29';
511
+ // 13a. 13 个 policy term 缺 as_of → policy_without_as_of(占位字符串)
512
+ for (const term of policyTerms) {
513
+ const missingLine = `| fixture | ${term} 政策断言测试占位 |`;
514
+ const blocked = gateArtifact(missingLine, registry, map, { trip_year: tripYear });
515
+ assert(blocked.violations.some(v => v.kind === 'policy_without_as_of'), `政策词 ${term} 缺 as_of → policy_without_as_of`);
516
+ }
517
+ // 13b. 13 个 term 带「截至 2026-08-29」过闸(占位字符串)
518
+ for (const term of policyTerms) {
519
+ const okLine = `| fixture | ${term} 政策断言测试占位(截至 ${fixtureAsOf}) |`;
520
+ const passed = gateArtifact(okLine, registry, map, { trip_year: tripYear });
521
+ assert(!passed.violations.some(v => v.kind === 'policy_without_as_of'), `政策词 ${term} 带截至日期过闸(关键词扩展不误伤)`);
522
+ }
523
+ // 13c. 拉丁 token 大小写不敏感(EVUS/Evus/evus 同命中)
524
+ const mixedCase = gateArtifact(`| fixture | evus 政策断言测试占位 |`, registry, map, { trip_year: tripYear });
525
+ assert(mixedCase.violations.some(v => v.kind === 'policy_without_as_of'), '拉丁 token 大小写不敏感(evus → policy_without_as_of)');
526
+ // 13d. 拉丁 token 不嵌入更长拉丁词(REVUS 不会被 EVUS 误抓)
527
+ const embeddedNo = gateArtifact(`| fixture | REVUSIN 政策断言测试占位 |`, registry, map, { trip_year: tripYear });
528
+ assert(!embeddedNo.violations.some(v => v.kind === 'policy_without_as_of'), '拉丁 token 边界(REVUS 不被 EVUS 嵌入匹配)→ 不误伤');
529
+ // 13e. 不扩词到宽 NLP/同义词/自由政策词表(如「过境」单独不被命中,只命中过境免)
530
+ const narrow = gateArtifact(`| fixture | 过境 政策断言测试占位 |`, registry, map, { trip_year: tripYear });
531
+ assert(!narrow.violations.some(v => v.kind === 'policy_without_as_of'), '窄词表边界:「过境」单独不被命中(只命中「过境免」)');
532
+ console.log(` ok - §13 政策关键词扩词完成(13 个 fixture-only policy term,占位字符串,无真实政策内容)`);
533
+ }
534
+ // ---------------------------------------------------------------------------
535
+ // §14 政策渲染锚点全字段内容指纹(issue #359,D-26 残余收口):
536
+ // 父 #273 要求 anchored policy 行「fact_id + as_of」与事实一致;
537
+ // 改 subject / statement / source / fetched_at / query_id 而保留
538
+ // fact_id + as_of 在原 main 会被静默 pass——issue #359 收口。
539
+ // 收口形态 = 闸侧用单一 canonical body 全文比对(不靠宽松 substring
540
+ // 匹配),subject/statement/source/fetched_at/query_id 任一不一致
541
+ // → fact_anchor_unknown,与未知锚点同源 fail-closed。
542
+ // legitimate review_by(事实自带)与 tripStart 派生的复核提醒两种合法
543
+ // 形态保留,不得通过删除提醒或删除正例得到 green。
544
+ // ---------------------------------------------------------------------------
545
+ {
546
+ const factIdBase = makeFactId(['policy-359', '泰国免签']);
547
+ const basePolicy = {
548
+ schema: BOOKABLE_FACT_SCHEMA, fact_id: factIdBase, kind: 'policy',
549
+ subject: '泰国入境(中国护照)', statement: '免签停留(口径以泰方公告为准);UAE 居民返程应优先校验 residence visa / Emirates ID 而非游客免签口径',
550
+ source: 'web:official', query_id: 'web:policy:泰国免签',
551
+ fetched_at: FETCHED, as_of: '2026-08-29',
552
+ };
553
+ const canonicalLine = renderPolicyFact(basePolicy);
554
+ const baseArtifact = ['## 政策', canonicalLine].join('\n');
555
+ // 14a. canonical positive:渲染行 = canonical body + 锚点 → pass,traceable=1
556
+ const positive = gateArtifact(baseArtifact, [basePolicy], map, { trip_year: tripYear });
557
+ assert(positive.verdict === 'pass' && positive.traceable === 1 && positive.violations.length === 0, `canonical 锚点行 → pass(实际 ${positive.verdict}/traceable=${positive.traceable}/violations=${positive.violations.length})`);
558
+ // 14a-renderer-only:policyCanonicalBody 与 renderPolicyFact 在 canonical body 段一致
559
+ assert(policyCanonicalBody(basePolicy) === canonicalLine.replace(/\s*<!--\s*fact:[\da-f]+\s*-->$/, ''), 'policyCanonicalBody 与 renderPolicyFact 在非锚点段严格一致(单一权威面)');
560
+ // 14b. failing-before #359:改 subject 而保留 fact_id+as_of → blocked
561
+ const changedSubject = canonicalLine.replace('泰国入境(中国护照)', '另一对象的政策');
562
+ const subjReport = gateArtifact(['## 政策', changedSubject].join('\n'), [basePolicy], map, { trip_year: tripYear });
563
+ assert(subjReport.verdict === 'blocked' && subjReport.violations.some(v => v.kind === 'fact_anchor_unknown' && /另一对象的政策/.test(v.detail)), `改 subject 保留 fact_id+as_of → blocked/fact_anchor_unknown(实际 ${subjReport.verdict}/${subjReport.violations.length} 违例)`);
564
+ // 14c. failing-before #359:改 statement 而保留 fact_id+as_of → blocked
565
+ const changedStatement = canonicalLine.replace('免签停留', '不免签停留');
566
+ const stmtReport = gateArtifact(['## 政策', changedStatement].join('\n'), [basePolicy], map, { trip_year: tripYear });
567
+ assert(stmtReport.verdict === 'blocked' && stmtReport.violations.some(v => v.kind === 'fact_anchor_unknown' && /不免签停留/.test(v.detail)), `改 statement 保留 fact_id+as_of → blocked/fact_anchor_unknown(实际 ${stmtReport.verdict}/${stmtReport.violations.length} 违例)`);
568
+ // 14d. failing-before #359:相反方向 statement(从「免签」改成「不免签」)→ blocked
569
+ const oppositeStatement = canonicalLine.replace('免签停留', '不免签,需提前办签证');
570
+ const oppReport = gateArtifact(['## 政策', oppositeStatement].join('\n'), [basePolicy], map, { trip_year: tripYear });
571
+ assert(oppReport.verdict === 'blocked' && oppReport.violations.some(v => v.kind === 'fact_anchor_unknown' && /不免签/.test(v.detail)), `相反 statement 保留 fact_id+as_of → blocked/fact_anchor_unknown(实际 ${oppReport.verdict}/${oppReport.violations.length} 违例)`);
572
+ // 14e. failing-before #359:改 source / fetched_at / query_id(任一 provenance 字段)→ blocked
573
+ const changedSource = canonicalLine.replace('[web:official@', '[synthetic:official@');
574
+ const srcReport = gateArtifact(['## 政策', changedSource].join('\n'), [basePolicy], map, { trip_year: tripYear });
575
+ assert(srcReport.verdict === 'blocked' && srcReport.violations.some(v => v.kind === 'fact_anchor_unknown' && /synthetic:official/.test(v.detail)), `改 source 保留 fact_id+as_of → blocked/fact_anchor_unknown(实际 ${srcReport.verdict}/${srcReport.violations.length} 违例)`);
576
+ const changedFetched = canonicalLine.replace(FETCHED, '2026-09-01T00:00:00.000Z');
577
+ const fetchReport = gateArtifact(['## 政策', changedFetched].join('\n'), [basePolicy], map, { trip_year: tripYear });
578
+ assert(fetchReport.verdict === 'blocked' && fetchReport.violations.some(v => v.kind === 'fact_anchor_unknown' && /2026-09-01/.test(v.detail)), `改 fetched_at 保留 fact_id+as_of → blocked/fact_anchor_unknown(实际 ${fetchReport.verdict}/${fetchReport.violations.length} 违例)`);
579
+ const changedQueryId = canonicalLine.replace('#web:policy:泰国免签', '#synthetic:policy:泰国免签');
580
+ const qidReport = gateArtifact(['## 政策', changedQueryId].join('\n'), [basePolicy], map, { trip_year: tripYear });
581
+ assert(qidReport.verdict === 'blocked' && qidReport.violations.some(v => v.kind === 'fact_anchor_unknown' && /synthetic:policy/.test(v.detail)), `改 query_id 保留 fact_id+as_of → blocked/fact_anchor_unknown(实际 ${qidReport.verdict}/${qidReport.violations.length} 违例)`);
582
+ // 14f. legitimate review_by(事实自带):policy.review_by 设置 → 行带复核提醒,闸侧需 tripStart 或 review_by
583
+ // 与 canonical 同步才能 pass。tripStart=undefined 时,canonical 也会用 p.review_by,
584
+ // 与 renderer 输出严格一致 → pass。
585
+ const reviewByPolicy = { ...basePolicy, review_by: '2027-06-16' };
586
+ const reviewByLine = renderPolicyFact(reviewByPolicy);
587
+ const reviewByReport = gateArtifact(['## 政策', reviewByLine].join('\n'), [reviewByPolicy], map, { trip_year: tripYear });
588
+ assert(reviewByReport.verdict === 'pass' && reviewByReport.traceable === 1, `legitimate review_by 事实 → 闸 pass(实际 ${reviewByReport.verdict}/traceable=${reviewByReport.traceable})`);
589
+ // 反向:review_by 改动(保留 fact_id+as_of,改 review_by)→ blocked
590
+ const reviewByTampered = reviewByLine.replace('2027-06-16', '2027-12-31');
591
+ const reviewByTamperedReport = gateArtifact(['## 政策', reviewByTampered].join('\n'), [reviewByPolicy], map, { trip_year: tripYear });
592
+ assert(reviewByTamperedReport.violations.some(v => v.kind === 'fact_anchor_unknown' && /2027-12-31/.test(v.detail)), `改 review_by 保留 fact_id+as_of → fact_anchor_unknown(实际 ${reviewByTamperedReport.violations.length} 违例)`);
593
+ // 14g. legitimate tripStart-derived reminder:renderer 给的 tripStart 与闸侧 opts.tripStart
594
+ // 一致时,cannonical body 完全匹配 → pass。
595
+ // 兼容性回退:renderer 用的是 renderer 自身的 defaultReviewBy(tripStart) 时,
596
+ // 即使闸侧未传 opts.tripStart 也应 pass —— 这是历史 #273 形态。
597
+ const tripStart = '2027-07-16';
598
+ const tripStartLine = renderPolicyFact(basePolicy, tripStart);
599
+ const tripStartReport = gateArtifact(['## 政策', tripStartLine].join('\n'), [basePolicy], map, { trip_year: tripYear, tripStart });
600
+ assert(tripStartReport.verdict === 'pass' && tripStartReport.traceable === 1, `legitimate tripStart-derived reminder(显式 opts) → 闸 pass(实际 ${tripStartReport.verdict}/traceable=${tripStartReport.traceable})`);
601
+ const tripStartCompat = gateArtifact(['## 政策', tripStartLine].join('\n'), [basePolicy], map, { trip_year: tripYear });
602
+ assert(tripStartCompat.verdict === 'pass' && tripStartCompat.traceable === 1, `legitimate tripStart-derived reminder(无 opts 兼容性回退) → 闸 pass(实际 ${tripStartCompat.verdict}/traceable=${tripStartCompat.traceable})`);
603
+ // 反向:闸侧 opts.tripStart 与 renderer 不一致(不是 renderer 默认 reminder 日期)→ blocked
604
+ const tripStartWrongOpts = gateArtifact(['## 政策', tripStartLine].join('\n'), [basePolicy], map, { trip_year: tripYear, tripStart: '2028-01-01' });
605
+ assert(tripStartWrongOpts.violations.some(v => v.kind === 'fact_anchor_unknown'), `tripStart 与闸侧 opts.tripStart 不一致 → fact_anchor_unknown(实际 ${tripStartWrongOpts.violations.length} 违例)`);
606
+ // 14g-compat. 兼容性回退接受的具体形态:context-free canonical(no-reminder)、
607
+ // context-free reminder(renderer 默认 defaultReviewBy)、
608
+ // context-free reminder(f.review_by 已知)。
609
+ // 以及不接受的具体形态:任意 body 文本、错 reminder 日期、前缀垃圾、缺锚点。
610
+ const noReminderLine = renderPolicyFact(basePolicy);
611
+ const compat1 = gateArtifact(['## 政策', noReminderLine].join('\n'), [basePolicy], map, { trip_year: tripYear });
612
+ assert(compat1.verdict === 'pass' && compat1.traceable === 1, `context-free canonical no-reminder → pass(实际 ${compat1.verdict})`);
613
+ const knownReviewByLine = renderPolicyFact({ ...basePolicy, review_by: '2027-09-01' });
614
+ const compat2 = gateArtifact(['## 政策', knownReviewByLine].join('\n'), [basePolicy], map, { trip_year: tripYear });
615
+ assert(compat2.verdict === 'pass' && compat2.traceable === 1, `context-free canonical reminder(review_by 已知)→ pass(实际 ${compat2.verdict})`);
616
+ // context-free 不接受的形态:body 里塞相反政策
617
+ const compatBadBody = noReminderLine.replace('免签停留', '不免签停留');
618
+ const compatBadBodyReport = gateArtifact(['## 政策', compatBadBody].join('\n'), [basePolicy], map, { trip_year: tripYear });
619
+ assert(compatBadBodyReport.violations.some(v => v.kind === 'fact_anchor_unknown'), `context-free 不接受 body 篡改 → fact_anchor_unknown(实际 ${compatBadBodyReport.violations.length} 违例)`);
620
+ // context-free 不接受的形态:把 reminder 短语追加到锚点之后(行尾非空文本)→ 结构
621
+ // 守门 fail-closed。真正的「ISO 日期合法性」校验见下一条(2026-02-30)。
622
+ const compatBadDate = noReminderLine + ';远期政策须复核——到 1999-01-01 再核验一次';
623
+ const compatBadDateReport = gateArtifact(['## 政策', compatBadDate].join('\n'), [basePolicy], map, { trip_year: tripYear });
624
+ assert(compatBadDateReport.violations.some(v => v.kind === 'fact_anchor_unknown'), `context-free 不接受 reminder 追加到锚点之后 → fact_anchor_unknown(实际 ${compatBadDateReport.violations.length} 违例)`);
625
+ // context-free 不接受的形态:reminder 日期是 renderer 永远不会产出的非法阳历日
626
+ // (2026-02-30 → Date.UTC 滚到 3 月 2 日,renderer 自己都不会写)。闸侧的 realIso
627
+ // 检查先否决,重建行 ≠ 原行 → 落到严格比对 → fail-closed。
628
+ // 这里手搓一行等价于 `renderPolicyFact({ ...basePolicy, review_by: '2026-02-30' })`
629
+ // 的形态,但用真实拼接的等价字符串绕过 renderer 自身的 Date 解析(防止被 renderer
630
+ // 自身提前抛错):在 basePolicy 的裸 body 后插入非法日期的 reminder 段。
631
+ const compatBadIso = `- ${basePolicy.subject}:截至 ${basePolicy.as_of} 的现行政策——${basePolicy.statement};远期政策须复核——到 2026-02-30 再核验一次 [${basePolicy.source}@${basePolicy.fetched_at} #${basePolicy.query_id}] <!-- fact:${basePolicy.fact_id} -->`;
632
+ const compatBadIsoReport = gateArtifact(['## 政策', compatBadIso].join('\n'), [basePolicy], map, { trip_year: tripYear });
633
+ assert(compatBadIsoReport.violations.some(v => v.kind === 'fact_anchor_unknown'), `context-free 不接受非法阳历日(2026-02-30)→ fact_anchor_unknown(实际 ${compatBadIsoReport.violations.length} 违例)`);
634
+ // context-free 不接受的形态:review_by 改动(renderer 接受 review_by 但闸侧事实
635
+ // 未带 review_by 时,兼容性回退允许任意合法 ISO 日期 —— 见根 contract 第 4 条。
636
+ // 若闸侧事实**自身**带 review_by,则 reminder 日期必须 == f.review_by,改了就 fail。)
637
+ // 这里 fact 是 basePolicy(无 review_by),渲染时假装加 review_by 后再改日期:
638
+ // 闸侧只看事实,无 review_by → 回退接受任意合法 ISO 日期。
639
+ const compatReviewByTampered = renderPolicyFact({ ...basePolicy, review_by: '2027-09-01' }).replace('2027-09-01', '2027-12-31');
640
+ const compatReviewByTamperedReport = gateArtifact(['## 政策', compatReviewByTampered].join('\n'), [basePolicy], map, { trip_year: tripYear });
641
+ assert(compatReviewByTamperedReport.verdict === 'pass' && compatReviewByTamperedReport.traceable === 1, `事实无 review_by 时 context-free 接受任意合法 ISO 日期 → pass(实际 ${compatReviewByTamperedReport.verdict})`);
642
+ // 反向:事实带 review_by 时,改 reminder 日期 → 必须 fail-closed(根 contract 第 2 条)。
643
+ const fReviewBy = { ...basePolicy, review_by: '2027-06-16' };
644
+ const lReviewByTampered = renderPolicyFact(fReviewBy).replace('2027-06-16', '2027-12-31');
645
+ const reviewByTamperedFactReport = gateArtifact(['## 政策', lReviewByTampered].join('\n'), [fReviewBy], map, { trip_year: tripYear });
646
+ assert(reviewByTamperedFactReport.violations.some(v => v.kind === 'fact_anchor_unknown'), `事实带 review_by 时改 reminder 日期 → fact_anchor_unknown(实际 ${reviewByTamperedFactReport.violations.length} 违例)`);
647
+ // context-free 不接受的形态:trailing-after-anchor
648
+ const compatTrailing = `${noReminderLine} 反而是落地签`;
649
+ const compatTrailingReport = gateArtifact(['## 政策', compatTrailing].join('\n'), [basePolicy], map, { trip_year: tripYear });
650
+ assert(compatTrailingReport.violations.some(v => v.kind === 'fact_anchor_unknown'), `context-free 不接受 trailing-after-anchor → fact_anchor_unknown(实际 ${compatTrailingReport.violations.length} 违例)`);
651
+ // 14g-itin. itinerary.trip_start 提供后,context-free 不再回退 —— reminder 必须匹配
652
+ // itinerary 实际提供的日期;no-reminder canonical 在 itinerary 存在时仍合法
653
+ // (renderer 没给 tripStart 时不出现 reminder 段)。
654
+ const itReportMismatch = gateArtifact(['## 政策', tripStartLine].join('\n'), [basePolicy], map, { trip_year: tripYear, itinerary: { ...fixture.good_itinerary, trip_start: '2027-08-01', trip_end: '2027-08-01' } });
655
+ assert(itReportMismatch.violations.some(v => v.kind === 'fact_anchor_unknown'), `itinerary.trip_start 与 renderer reminder 不一致 → fact_anchor_unknown(实际 ${itReportMismatch.violations.length} 违例)`);
656
+ const itReportNoReminder = gateArtifact(['## 政策', noReminderLine].join('\n'), [basePolicy], map, { trip_year: tripYear, itinerary: {
657
+ trip_start: '2027-08-01',
658
+ trip_end: '2027-08-01',
659
+ stays: [],
660
+ onboard_nights: 0,
661
+ od_segments: [],
662
+ budget_items: [],
663
+ } });
664
+ assert(itReportNoReminder.verdict === 'pass' && itReportNoReminder.traceable === 1, `itinerary 存在时 canonical no-reminder 仍合法 → pass(实际 ${itReportNoReminder.verdict})`);
665
+ // 14g-direct(issue #359 最终维修):闸侧直接 `gateArtifact` 公共 API,opts 仅含
666
+ // `itinerary.trip_start` 而不复制到 `opts.tripStart`,渲染行由
667
+ // `renderPolicyFact(basePolicy, itinerary.trip_start)` 产出 → 闸必须 pass。
668
+ // 这是根反例验收核心:`effectiveTripStart` 优先级(explicit `opts.tripStart`
669
+ // → `opts.itinerary.trip_start` → undefined)让调用方不再需要在两个字段间
670
+ // 复制同一天;已注册的 `index.ts` 工具桥接继续兼容(显式 tripStart 优先)。
671
+ const directItinerary = { trip_start: tripStart, trip_end: tripStart, stays: [], onboard_nights: 0, od_segments: [], budget_items: [] };
672
+ const directRenderedLine = renderPolicyFact(basePolicy, directItinerary.trip_start);
673
+ assert(directRenderedLine === tripStartLine, 'direct-API 形式:renderPolicyFact(basePolicy, itinerary.trip_start) === 已固定的 tripStartLine(回归门)');
674
+ const itDirectReport = gateArtifact(['## 政策', directRenderedLine].join('\n'), [basePolicy], map, { trip_year: tripYear, itinerary: directItinerary });
675
+ assert(itDirectReport.verdict === 'pass' && itDirectReport.traceable === 1, `直接 gateArtifact 公共 API(opts 仅含 itinerary.trip_start,无 opts.tripStart)→ pass(实际 ${itDirectReport.verdict}/traceable=${itDirectReport.traceable})`);
676
+ // 反向:itinerary 提供的日期 ≠ renderer reminder 日期 → fail-closed;
677
+ // 这是有效 API 形式的边界,证明 effectiveTripStart 不会被「任意 trip_start」
678
+ // 蒙混。
679
+ const itineraryMismatchDirect = gateArtifact(['## 政策', directRenderedLine].join('\n'), [basePolicy], map, { trip_year: tripYear, itinerary: { ...directItinerary, trip_start: '2027-12-31', trip_end: '2027-12-31' } });
680
+ assert(itineraryMismatchDirect.violations.some(v => v.kind === 'fact_anchor_unknown'), `直接 API 形式下 itinerary.trip_start 与 renderer reminder 不一致 → fact_anchor_unknown(实际 ${itineraryMismatchDirect.violations.length} 违例)`);
681
+ // 14h. 父 #273 已有形态保留:as_of 改动(11g/11h)仍走 fact_anchor_unknown(同一收敛口径)
682
+ const asOfShifted = canonicalLine.replace(/截至\s*\d{4}-\d{2}-\d{2}/, '截至 2027-01-01');
683
+ const asOfReport = gateArtifact(['## 政策', asOfShifted].join('\n'), [basePolicy], map, { trip_year: tripYear });
684
+ assert(asOfReport.violations.some(v => v.kind === 'fact_anchor_unknown' && /2027-01-01/.test(v.detail)), `父 #273 as_of 改动形态保留(实际 ${asOfReport.violations.length} 违例)`);
685
+ // 14i. 同一 fact 出现在多行:仅改 subject 行 fail-closed,其他行不动
686
+ const multi = ['## 政策', canonicalLine, canonicalLine.replace('泰国入境(中国护照)', '另一对象的政策')].join('\n');
687
+ const multiReport = gateArtifact(multi, [basePolicy], map, { trip_year: tripYear });
688
+ assert(multiReport.verdict === 'blocked' && multiReport.violations.filter(v => v.kind === 'fact_anchor_unknown').length === 1, `多行同 fact:仅篡改行 fail-closed(实际违例 ${multiReport.violations.length},fact_anchor_unknown=${multiReport.violations.filter(v => v.kind === 'fact_anchor_unknown').length})`);
689
+ // 14j. trailing-after-anchor 攻击:借用合法 fact_id + 锚点,在锚点后追加相反政策正文 →
690
+ // 闸侧应识别「锚点后仍有非空文本」并 fail-closed,而不是只比对锚点前 canonical body。
691
+ const trailing = `${canonicalLine} 反而是落地签,需提前办签证`;
692
+ const trailingReport = gateArtifact(['## 政策', trailing].join('\n'), [basePolicy], map, { trip_year: tripYear });
693
+ assert(trailingReport.verdict === 'blocked' && trailingReport.violations.some(v => v.kind === 'fact_anchor_unknown'), `trailing-after-anchor 攻击 → fact_anchor_unknown(实际 ${trailingReport.verdict}/${trailingReport.violations.length} 违例)`);
694
+ // 14k. duplicate anchor:同一行内两个 `<!-- fact:` → fail-closed
695
+ const dupAnchor = canonicalLine.replace(`<!-- fact:${basePolicy.fact_id} -->`, `<!-- fact:${basePolicy.fact_id} --> <!-- fact:${basePolicy.fact_id} -->`);
696
+ const dupReport = gateArtifact(['## 政策', dupAnchor].join('\n'), [basePolicy], map, { trip_year: tripYear });
697
+ assert(dupReport.verdict === 'blocked' && dupReport.violations.some(v => v.kind === 'fact_anchor_unknown'), `duplicate anchor → fact_anchor_unknown(实际 ${dupReport.verdict}/${dupReport.violations.length} 违例)`);
698
+ // 14l. malformed anchor(没有空格的格式):canonical 是 ` <!-- fact:<id> -->`,缺空格不算同形
699
+ const malformedAnchor = canonicalLine.replace(`<!-- fact:${basePolicy.fact_id} -->`, `<!--fact:${basePolicy.fact_id}-->`);
700
+ const malformedReport = gateArtifact(['## 政策', malformedAnchor].join('\n'), [basePolicy], map, { trip_year: tripYear });
701
+ assert(malformedReport.verdict === 'blocked' && malformedReport.violations.some(v => v.kind === 'fact_anchor_unknown'), `malformed anchor(无空格) → fact_anchor_unknown(实际 ${malformedReport.verdict}/${malformedReport.violations.length} 违例)`);
702
+ // 14m. 前置非空文本:同一行在锚点前塞了原文之外的字符 → fail-closed
703
+ const prefixAdded = `前缀: ${canonicalLine}`;
704
+ const prefixReport = gateArtifact(['## 政策', prefixAdded].join('\n'), [basePolicy], map, { trip_year: tripYear });
705
+ assert(prefixReport.verdict === 'blocked' && prefixReport.violations.some(v => v.kind === 'fact_anchor_unknown'), `prefix non-empty → fact_anchor_unknown(实际 ${prefixReport.verdict}/${prefixReport.violations.length} 违例)`);
706
+ console.log(` ok - §14 政策锚点全字段内容指纹(#359 / D-26)完成(canonical + 5 failing-before + 2 legitimate + direct itinerary-only API + 多行归属 + trailing/dup/malformed/prefix 锚点结构攻击)`);
707
+ }
708
+ // ---------------------------------------------------------------------------
709
+ // §15 航班/酒店锚点字段指纹(issue #363,D-26 残余收口)
710
+ // 父 #273 锚点仅证明「注册表有事实」;保持 fact_id/registry/anchor 不动而
711
+ // 改写可见 flight_no(flight)/destination+档期(hotel)原 main 静默 pass,
712
+ // 根反例验收正是此形态。闸侧改为字段指纹(flight:渲染行 flight_no token
713
+ // 严格 == 事实 flight_no;hotel:渲染行严格含事实 destination+check_in
714
+ // +check_out),借用/未知/重复锚点仍由既有 fact_anchor_unknown 路径负责。
715
+ // 不引入 whole-line equality(根 contract 第 2 条);§4b 价格兼容形态、
716
+ // 政策/列车 canonical、§10c 锚点酒店等既有形态不被吞并。
717
+ // ---------------------------------------------------------------------------
718
+ {
719
+ const fetchedAt = '2026-09-04T00:00:00.000Z';
720
+ // 与 §4b 共用 2027-07-17 UO724(price 793)以保证 price 替换形态一致;
721
+ // 不用 registry.find — 主 fixture 中存在另一 2027-07-18 UO724(price 810),
722
+ // find 默认拿第一条,会让 §4b 既有断言失去对齐基准。
723
+ const flightFact = uo724;
724
+ const hotelFacts = factsFromHotel({
725
+ source: 'flyai-hotel', destination: '大理', checkIn: '2026-10-01', checkOut: '2026-10-03',
726
+ verdict: 'hit', options: 9, evidence: 'synthetic', fetchedAt,
727
+ });
728
+ const flightCanonical = renderFlightFact(flightFact);
729
+ const hotelCanonical = renderHotelFact(hotelFacts[0]);
730
+ // 15a. canonical 航班行(从渲染原语产出)→ 闸 pass,traceable=1
731
+ const fOk = gateArtifact(['## 航班', flightCanonical].join('\n'), [flightFact], map, { trip_year: tripYear });
732
+ assert(fOk.verdict === 'pass' && fOk.traceable === 1
733
+ && !fOk.violations.some(v => v.kind === 'fact_anchor_unknown'), `canonical flight 锚点行 → pass(实际 ${fOk.verdict}/traceable=${fOk.traceable})`);
734
+ // 15b. canonical 酒店行(从渲染原语产出)→ 闸 pass,traceable=1
735
+ const hOk = gateArtifact(['## 住宿', hotelCanonical].join('\n'), hotelFacts, map, { trip_year: 2026 });
736
+ assert(hOk.verdict === 'pass' && hOk.traceable === 1
737
+ && !hOk.violations.some(v => v.kind === 'fact_anchor_unknown'), `canonical hotel 锚点行 → pass(实际 ${hOk.verdict}/traceable=${hOk.traceable})`);
738
+ // 15c. flight_no 改写(UO724 → UO999),保留 fact_id/anchor/registry →
739
+ // fact_anchor_unknown(根反例:visible flight_no 改写必须 fail-closed)
740
+ const fTampered = flightCanonical.replace('UO724', 'UO999');
741
+ const fTamperedReport = gateArtifact(['## 航班', fTampered].join('\n'), [flightFact], map, { trip_year: tripYear });
742
+ assert(fTamperedReport.verdict === 'blocked' && fTamperedReport.traceable === 0
743
+ && fTamperedReport.violations.some(v => v.kind === 'fact_anchor_unknown' && /flight_no 字段指纹不符|UO999/.test(v.detail)), `flight_no UO724→UO999 改写保留锚点 → blocked/fact_anchor_unknown(实际 ${fTamperedReport.verdict}/${fTamperedReport.traceable}/violations=${fTamperedReport.violations.length})`);
744
+ // 15d. hotel destination 改写(大理 → 丽江),保留 fact_id/anchor/registry →
745
+ // fact_anchor_unknown(根反例)
746
+ const hDestTampered = hotelCanonical.replace('大理', '丽江');
747
+ const hDestReport = gateArtifact(['## 住宿', hDestTampered].join('\n'), hotelFacts, map, { trip_year: 2026 });
748
+ assert(hDestReport.verdict === 'blocked' && hDestReport.traceable === 0
749
+ && hDestReport.violations.some(v => v.kind === 'fact_anchor_unknown' && /hotel 锚点行字段指纹不符|destination/.test(v.detail)), `hotel destination 大理→丽江 改写保留锚点 → blocked/fact_anchor_unknown(实际 ${hDestReport.verdict}/${hDestReport.traceable})`);
750
+ // 15e. hotel date 改写(2026-10-01 → 2026-12-25),保留 fact_id/anchor/registry →
751
+ // fact_anchor_unknown(根反例)
752
+ const hDateTampered = hotelCanonical.replace('2026-10-01', '2026-12-25');
753
+ const hDateReport = gateArtifact(['## 住宿', hDateTampered].join('\n'), hotelFacts, map, { trip_year: 2026 });
754
+ assert(hDateReport.verdict === 'blocked' && hDateReport.traceable === 0
755
+ && hDateReport.violations.some(v => v.kind === 'fact_anchor_unknown' && /hotel 锚点行字段指纹不符|check_in/.test(v.detail)), `hotel date 2026-10-01→2026-12-25 改写保留锚点 → blocked/fact_anchor_unknown(实际 ${hDateReport.verdict}/${hDateReport.traceable})`);
756
+ // 15f. 同时改 destination + check_in(check_out 不变)→ 同样 fail-closed
757
+ const hBothTampered = hotelCanonical.replace('大理', '丽江').replace('2026-10-01', '2026-12-25');
758
+ const hBothReport = gateArtifact(['## 住宿', hBothTampered].join('\n'), hotelFacts, map, { trip_year: 2026 });
759
+ assert(hBothReport.verdict === 'blocked' && hBothReport.violations.some(v => v.kind === 'fact_anchor_unknown'), `hotel destination + check_in 联合改写保留锚点 → blocked/fact_anchor_unknown(实际 ${hBothReport.verdict})`);
760
+ // 15g. 价格兼容(§4b 既有契约保留):flight 锚点行在 flight_no/route/承运/时刻
761
+ // 不动的前提下追加 总预算/行李费/总计/显式票价/千分位 等形态,不应被新
762
+ // 字段指纹吞并(根 contract 第 2 条)。
763
+ const priceCompatCases = [
764
+ ['尾随总预算', `${flightCanonical};总预算¥1000`],
765
+ ['证据链前裸金额', flightCanonical.replace(' [flyai@', ';¥999 [flyai@')],
766
+ ['显式票价标签', flightCanonical.replace('¥793', '票价¥793')],
767
+ ['分隔后显式票价', flightCanonical.replace('¥793', ';总预算¥1000;票价¥793')],
768
+ ];
769
+ for (const [label, line] of priceCompatCases) {
770
+ const r = gateArtifact(['## 航班', line].join('\n'), [flightFact], map, { trip_year: tripYear });
771
+ assert(!r.violations.some(v => v.kind === 'fact_anchor_unknown'), `flight 字段指纹 §4b 兼容:${label} 不被 fact_anchor_unknown 吞并(实际 ${r.violations.length} 违例)`);
772
+ }
773
+ // 15h. 价格矛盾(¥999 替换 ¥793)依然走 price_contradicted,不退化为
774
+ // fact_anchor_unknown;§4b 既有的 price_contradicted/unverified_price_claim
775
+ // 分类与排序保留(根 contract 第 2 条)。
776
+ const priceContradicted = flightCanonical.replace('¥793', '¥999');
777
+ const pcReport = gateArtifact(['## 航班', priceContradicted].join('\n'), [flightFact], map, { trip_year: tripYear });
778
+ assert(pcReport.verdict === 'blocked'
779
+ && pcReport.violations.some(v => v.kind === 'price_contradicted')
780
+ && !pcReport.violations.some(v => v.kind === 'fact_anchor_unknown'), `flight 价格矛盾保持 price_contradicted 分类,不退化为 fact_anchor_unknown(实际 ${pcReport.violations.length} 违例)`);
781
+ // 15i. 整行改写(flight_no + 价格同时改)→ 字段指纹先抓 flight_no,分类为
782
+ // fact_anchor_unknown(fact 字段级别 fail-closed 优先于价格对账)
783
+ const allTampered = flightCanonical.replace('UO724', 'UO999').replace('¥793', '¥1000');
784
+ const allReport = gateArtifact(['## 航班', allTampered].join('\n'), [flightFact], map, { trip_year: tripYear });
785
+ assert(allReport.violations.some(v => v.kind === 'fact_anchor_unknown'), `flight 字段 + 价格联合改写 → fact_anchor_unknown(flight_no 字段优先,实际 ${allReport.violations.length} 违例)`);
786
+ // 15j. 借用合法 fact_id/anchor + 整行伪造 flight_no 改动:
787
+ // 用同一 fact 的 fact_id 但 visible flight_no 是无事实的乱码。
788
+ // 既有锚点存在 = 注册表内 fact;新字段指纹再校验 visible flight_no == fact.flight_no。
789
+ const borrowedAnchor = `## 航班\n- HKG→HKT 2027-07-18 XX9999(未知) 07:55→10:30 直飞 ¥810 [flyai@${flightFact.fetched_at} #${flightFact.query_id}] <!-- fact:${flightFact.fact_id} -->`;
790
+ const borrowedReport = gateArtifact(borrowedAnchor, [flightFact], map, { trip_year: tripYear });
791
+ assert(borrowedReport.verdict === 'blocked' && borrowedReport.violations.some(v => v.kind === 'fact_anchor_unknown'), `借用合法 fact_id 写入乱码 flight_no → fact_anchor_unknown(实际 ${borrowedReport.verdict})`);
792
+ // 15k. duplicate anchor 仅由 policy 路径守护(§14k,结构守门);flight/hotel
793
+ // 路径未引入 duplicate anchor 检测,这里跳过——本切片不扩张 duplicate-anchor
794
+ // 覆盖面,只确保 flight_no/destination/check_in/check_out 字段指纹守门。
795
+ // 15l. 凭空追加非注册表 fact_id(伪造 anchor)→ 既有 fact_anchor_unknown 路径
796
+ // 不被新字段指纹吞并
797
+ const phantomAnchor = `## 航班\n- HKG→HKT 2027-07-17 UO724(香港快运航空) 07:55→10:30 直飞 ¥810 [flyai@${flightFact.fetched_at} #${flightFact.query_id}] <!-- fact:deadbeefdeadbeef -->`;
798
+ const phantomReport = gateArtifact(phantomAnchor, [flightFact], map, { trip_year: tripYear });
799
+ assert(phantomReport.violations.some(v => v.kind === 'fact_anchor_unknown' && /deadbeefdeadbeef/.test(v.detail)), `flight 凭空追加非注册表 fact_id → fact_anchor_unknown(实际 ${phantomReport.violations.length} 违例)`);
800
+ // 15m. 列车 canonical 形态保留:同 /etc. §9b 既有契约不被新字段指纹吞并
801
+ const gTrain = factsFromFlyai({ kind: 'train', origin: '香港', destination: '普吉', date: '2027-07-17' }, { verdict: 'hit', options: [{ no: 'G1234', depDateTime: '2027-07-17T09:00:00', arrDateTime: '2027-07-17T13:00:00' }] }, FETCHED, alias)[0];
802
+ const trainRendered = renderFlightFact(gTrain);
803
+ const trainReport = gateArtifact(['## 车次', trainRendered].join('\n'), [gTrain], map, { trip_year: tripYear });
804
+ assert(trainReport.verdict === 'pass' && trainReport.traceable === 1
805
+ && !trainReport.violations.some(v => v.kind === 'fact_anchor_unknown'), `train canonical 形态保留(实际 ${trainReport.verdict}/traceable=${trainReport.traceable})`);
806
+ // 15n. 政策锚点形态保留(根 contract 第 2 条):政策行不受新字段指纹影响
807
+ const policyLine15 = renderPolicyFact(policy);
808
+ const policyReport15 = gateArtifact(['## 政策', policyLine15].join('\n'), [policy], map, { trip_year: tripYear });
809
+ assert(policyReport15.verdict === 'pass' && policyReport15.traceable === 1
810
+ && !policyReport15.violations.some(v => v.kind === 'fact_anchor_unknown'), `policy 锚点形态保留(实际 ${policyReport15.verdict}/traceable=${policyReport15.traceable})`);
811
+ console.log(` ok - §15 航班/酒店锚点字段指纹(#363 / D-26)完成(canonical + 5 root 反例 + §4b 价格兼容 + 价格矛盾分类保留 + 借用/dup/phantom + 列车/政策兼容)`);
812
+ }
813
+ // ---------------------------------------------------------------------------
814
+ // §16 词表外下单措辞 fail-closed(issue #381,D-26 R1,接管 #273 公开跟踪)
815
+ // 根反例(main 6e85d36 实证):「入境章/落地章」不在 POLICY_WORD、「800元」不匹配
816
+ // ¥/ISO 币种前缀 → 全零抽取 → 无锚点要求 → 静默过闸。修复沿用既有错误分类,
817
+ // 不发明新违例通道:
818
+ // (a) POLICY_WORD 有限扩充(入境章/落地章/出境章/入境卡/出境卡/出入境卡,与
819
+ // 落地签同族的移民章/卡六词)→ 缺「截至」按既有 policy_without_as_of 拒绝;
820
+ // (b) 零 claim 行的「交易承诺短语 × 词表外金额写法」有限机械词网 → 既有
821
+ // unverified_price_claim(硬价缺可比较权威来源,#300 同源)。
822
+ // 双向定界(不得误伤):合法叙述(无交易短语)、预算口径(预算/约 + 词表外金额)、
823
+ // ¥ 前缀金额的零 claim 行(R2 founder 决策边界)、既有 §12j 裸可订短语边界、
824
+ // 已有 claim 的行(由既有机制负责,词网不重复计违例)。
825
+ // ---------------------------------------------------------------------------
826
+ {
827
+ // 16a. 根反例:普吉岛样本两行原全零抽取 → 逐行显式拒绝
828
+ const phuket = gateArtifact('普吉岛入境章费用 800元,现场支付\n落地章办理点在机场到达层', [], map, { trip_year: 2026 });
829
+ assert(phuket.verdict === 'blocked'
830
+ && phuket.violations.filter(v => v.kind === 'policy_without_as_of').length === 2
831
+ && [...new Set(phuket.violations.map(v => v.line))].sort((a, b) => a - b).join(',') === '1,2', '普吉岛根反例:入境章/落地章原全零抽取静默过闸 → blocked,两行各一条 policy_without_as_of(红→绿)');
832
+ // 16b. 变体一(换措辞,无政策词 → 交易承诺词网):费用 + 泰铢后缀
833
+ const baht = gateArtifact('机场快速通道费用 300泰铢,现场支付', [], map, { trip_year: 2026 });
834
+ assert(baht.verdict === 'blocked' && baht.violations.length === 1
835
+ && baht.violations[0].kind === 'unverified_price_claim' && baht.violations[0].line === 1, '换措辞变体:快速通道费用 300泰铢(词表外币种后缀,零 claim)→ blocked/unverified_price_claim(红→绿)');
836
+ // 16c. 变体二(换币种写法):$ 前缀 + 付现
837
+ const dollar = gateArtifact('机场快速通道费用 $15,现场付现', [], map, { trip_year: 2026 });
838
+ assert(dollar.verdict === 'blocked' && dollar.violations.length === 1
839
+ && dollar.violations[0].kind === 'unverified_price_claim', '换币种写法变体:$15 前缀写法(零 claim)→ blocked/unverified_price_claim(红→绿)');
840
+ // 16d. 政策路径与币种写法正交:落地章 + $30 → policy_without_as_of
841
+ const stampDollar = gateArtifact('落地章代办费用 $30', [], map, { trip_year: 2026 });
842
+ assert(stampDollar.verdict === 'blocked'
843
+ && stampDollar.violations.filter(v => v.kind === 'policy_without_as_of').length === 1, '落地章 + $30 代办费用 → policy_without_as_of(词表扩充路径,与币种写法正交)');
844
+ // 16e. 入境卡/出入境卡同族词
845
+ const cardVariant = gateArtifact('出入境卡需要现场填写并缴费 100元', [], map, { trip_year: 2026 });
846
+ assert(cardVariant.verdict === 'blocked'
847
+ && cardVariant.violations.filter(v => v.kind === 'policy_without_as_of').length === 1, '出入境卡 + 缴费 100元 → policy_without_as_of(同族词覆盖)');
848
+ // 16f. 误界定界一:带「截至」时间边界的政策行过闸(#302 §13b 同口径);
849
+ // 行内词表外金额的对账属 R2(价格容忍度/汇率/多币种,founder 决策),不在本 issue。
850
+ const stampAsOf = gateArtifact('截至 2026-08-29 的现行入境章费用口径 800元', [], map, { trip_year: 2026 });
851
+ assert(stampAsOf.verdict === 'pass' && stampAsOf.violations.length === 0, '带截至日期的入境章政策行过闸(#302 §13b 同口径);词表外金额对账属 R2 边界');
852
+ // 16g. 误界定界二:合法叙述(无交易承诺短语/无金额)零 claim 不误伤
853
+ const narrative = gateArtifact('普吉岛老城区适合步行游览,傍晚到海边看日落\n机场到达层有便利店和药房', [], map, { trip_year: 2026 });
854
+ assert(narrative.verdict === 'pass' && narrative.violations.length === 0, '合法叙述两行(无交易短语)零 claim 仍 pass(双向定界)');
855
+ // 16h. 误界定界三:预算口径的词表外金额(预算/约,无费用/支付短语)不误伤
856
+ const budgetCjk = gateArtifact('全程餐饮预算约 2000元,丰俭由人', [], map, { trip_year: 2026 });
857
+ const budgetYen = gateArtifact('全程预算 ¥30,000', [], map, { trip_year: 2026 });
858
+ assert(budgetCjk.verdict === 'pass' && budgetCjk.violations.length === 0
859
+ && budgetYen.verdict === 'pass' && budgetYen.violations.length === 0, '预算口径(预算/约 + 元 后缀,¥ 前缀)零 claim 不入承诺词网,仍 pass(R2 边界)');
860
+ // 16i. 已有 claim 的行由既有机制负责:酒店 claim 行内的押金 100元 不被词网重复计违例
861
+ const claimedLine = gateArtifact('## 住宿\n- 大理 2026-10-01→2026-10-03 洱海民宿有房可订 押金 100元', [], map, { trip_year: 2026 });
862
+ assert(claimedLine.verdict === 'blocked'
863
+ && claimedLine.violations.some(v => v.kind === 'unverifiable_hotel_claim')
864
+ && !claimedLine.violations.some(v => v.kind === 'unverified_price_claim'), '已有 hotel claim 的行(押金 100元)由既有酒店闸负责,词网不重复计 unverified_price_claim');
865
+ console.log(' ok - §16 词表外下单措辞 fail-closed(#381 / D-26 R1)完成(根反例 + 换措辞/换币种两变体 + 同族词 + 截至/预算/叙述三重不误伤定界)');
866
+ }
867
+ console.log(`\nFACT GATE TESTS: ${pass} pass, ${fail} fail`);
868
+ if (fail > 0)
869
+ process.exit(1);