@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
@@ -0,0 +1,956 @@
1
+ /**
2
+ * Issue #341 focused proof, Issue #364 direction-binding additions, and the
3
+ * 2026-09-11 wider D-39 boundary freeze.
4
+ *
5
+ * The product path is exercised through the registered gotry_feasibility_check
6
+ * tool. Map results come from an isolated public-tool-shaped fixture; the
7
+ * separate ToolRuntime case verifies the production nested-dispatch seam.
8
+ * No shared state or live session is used.
9
+ *
10
+ * Each `ground_transfer` request resolves both directions (outbound = origin
11
+ * → destination, return = destination → origin) with isolated cache,
12
+ * freshness, and fallback. A legacy single-pair shape is normalized to
13
+ * outbound + the swapped pair as return.
14
+ *
15
+ * The boundary-freeze section pins the offline remainder of the wider D-39
16
+ * transfer boundary: mode/position vocabulary closure, fallback-completeness
17
+ * counterexamples, the static↔dynamic switch contract, evidence-annotation
18
+ * completeness, and cache boundedness. Everything beyond (live traffic,
19
+ * transit/rail, fares, address resolution, broader transfer combinations) is
20
+ * gated real-data work and deliberately absent here.
21
+ */
22
+ import assert from 'node:assert/strict';
23
+ import { mkdtempSync, rmSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+ import { tmpdir } from 'node:os';
26
+ import { readFile } from 'node:fs/promises';
27
+ import { Context } from '@deepseek-ai/cordis';
28
+ import { defineTool, ToolRuntime } from '@deepseek-ai/dsh-tools';
29
+ import { SystemPrompt } from '@deepseek-ai/dsh-system-prompt';
30
+ import { apply } from '../src/index.js';
31
+ import { evaluateChoice, parseCandidate, parseRequest } from '../src/model.js';
32
+ import { segmentsFromCandidate, solveChoiceSegment } from '../src/unified.js';
33
+ import { createGroundTransferResolver, resolveGroundTransferPayload, GROUND_TRANSFER_DEFAULT_MAX_AGE_S, GROUND_TRANSFER_STATIC_MODE_MISMATCH, GROUND_TRANSFER_STATIC_PRICE_EVIDENCE, } from '../capabilities/ground-transfer.js';
34
+ const FIXED_NOW = new Date('2026-09-10T12:00:00.000Z');
35
+ const GROUND_TRANSFER = {
36
+ candidate_id: 'qiandao',
37
+ transfer_index: 0,
38
+ position: 'destination',
39
+ mode: 'driving',
40
+ origin: { longitude: 120.1234, latitude: 30.2345 },
41
+ destination: { longitude: 119.4567, latitude: 29.6789 },
42
+ };
43
+ function clone(value) {
44
+ return JSON.parse(JSON.stringify(value));
45
+ }
46
+ function asRecord(value, label) {
47
+ assert.ok(value !== null && typeof value === 'object' && !Array.isArray(value), `${label} must be an object`);
48
+ return value;
49
+ }
50
+ function withoutLatency(value) {
51
+ const record = asRecord(value, 'tool result');
52
+ const { latency_ms: _latency, ...stable } = record;
53
+ return stable;
54
+ }
55
+ function resultOf(value) {
56
+ return asRecord(value, 'feasibility result');
57
+ }
58
+ function route(durationS, provider = 'fixture-osrm') {
59
+ return {
60
+ provider,
61
+ distanceM: 42_500,
62
+ durationS,
63
+ polyline: 'fixture-polyline',
64
+ steps: [],
65
+ };
66
+ }
67
+ function fakeHost(provider, clock = () => FIXED_NOW) {
68
+ const root = mkdtempSync(join(tmpdir(), 'gotry-341-ground-transfer-'));
69
+ const registered = [];
70
+ const ctx = {
71
+ tools: {
72
+ register(tool) {
73
+ registered.push(tool);
74
+ },
75
+ },
76
+ systemPrompt: {
77
+ variable() { },
78
+ },
79
+ get() {
80
+ return undefined;
81
+ },
82
+ on() {
83
+ return () => { };
84
+ },
85
+ };
86
+ apply(ctx, {
87
+ stateRoot: root,
88
+ timeoutMs: 30_000,
89
+ hbcliBin: 'hbcli-not-on-path',
90
+ sessionAccess: 'off',
91
+ }, {
92
+ clock,
93
+ groundTransfer: { provider },
94
+ });
95
+ const feasibility = registered.find(tool => tool.name === 'gotry_feasibility_check');
96
+ assert.ok(feasibility, 'gotry_feasibility_check must be registered');
97
+ return { root, feasibility };
98
+ }
99
+ async function call(tool, payload) {
100
+ return resultOf(await tool.execute({ payload: clone(payload) }, null));
101
+ }
102
+ async function publicToolExecution(payload) {
103
+ const root = mkdtempSync(join(tmpdir(), 'gotry-341-ground-transfer-runtime-'));
104
+ const mapCalls = [];
105
+ const ctx = new Context();
106
+ await ctx.plugin(SystemPrompt);
107
+ await ctx.plugin(ToolRuntime, {});
108
+ const unregisterMap = ctx.tools.register(defineTool({
109
+ name: 'map_driving_route',
110
+ description: 'isolated public map tool fixture',
111
+ parameters: {
112
+ origin: { type: 'string', required: true },
113
+ destination: { type: 'string', required: true },
114
+ },
115
+ output: {
116
+ schema: { type: 'json' },
117
+ render: () => [{ type: 'text', text: 'fixture map route' }],
118
+ },
119
+ async execute(args, _execution) {
120
+ mapCalls.push({ ...args });
121
+ return route(780, 'fixture-public-map-tool');
122
+ },
123
+ }));
124
+ apply(ctx, {
125
+ stateRoot: root,
126
+ timeoutMs: 30_000,
127
+ hbcliBin: 'hbcli-not-on-path',
128
+ sessionAccess: 'off',
129
+ }, { clock: () => FIXED_NOW });
130
+ const execution = await ctx.tools.execute({
131
+ callId: 'issue-341-ground-transfer-root',
132
+ name: 'gotry_feasibility_check',
133
+ arguments: { payload: clone(payload) },
134
+ signal: new AbortController().signal,
135
+ });
136
+ return {
137
+ execution,
138
+ mapCalls,
139
+ dispose: async () => {
140
+ unregisterMap();
141
+ await ctx.fiber.dispose();
142
+ rmSync(root, { recursive: true, force: true });
143
+ },
144
+ };
145
+ }
146
+ async function publicToolHost(payload) {
147
+ const host = await publicToolExecution(payload);
148
+ if (host.execution.isError)
149
+ throw new Error(`nested host execution failed: ${host.execution.error.message}`);
150
+ return { result: resultOf(host.execution.value), mapCalls: host.mapCalls, dispose: host.dispose };
151
+ }
152
+ function directionOf(value, label) {
153
+ const record = asRecord(value, label);
154
+ assert.ok(record['direction'] === 'outbound' || record['direction'] === 'return', `${label}.direction must be outbound or return`);
155
+ return record;
156
+ }
157
+ /** Isolated host lifecycle for one boundary-freeze case. */
158
+ async function withFeasibilityHost(provider, run) {
159
+ const host = fakeHost(provider);
160
+ try {
161
+ await run(host.feasibility);
162
+ }
163
+ finally {
164
+ rmSync(host.root, { recursive: true, force: true });
165
+ }
166
+ }
167
+ async function main() {
168
+ const payload = JSON.parse(await readFile(join(import.meta.dirname, '..', '..', 'data', 'golden_erhai.json'), 'utf8'));
169
+ const fixtureCalls = [];
170
+ let durationS = 900;
171
+ const provider = async (request) => {
172
+ fixtureCalls.push({ ...request });
173
+ return route(durationS);
174
+ };
175
+ let issueNowMs = FIXED_NOW.getTime();
176
+ const host = fakeHost(provider, () => new Date(issueNowMs));
177
+ try {
178
+ const baseline = await call(host.feasibility, payload);
179
+ const legacyRepeat = await call(host.feasibility, payload);
180
+ assert.deepEqual(withoutLatency(legacyRepeat), withoutLatency(baseline), 'payloads without ground_transfer must keep their legacy result shape');
181
+ assert.equal('ground_transfer' in baseline, false);
182
+ const parsed = payload.candidates;
183
+ const parsedCandidates = parsed.map(parseCandidate);
184
+ const originalCandidates = clone(parsedCandidates);
185
+ const directResolver = createGroundTransferResolver({
186
+ provider: async () => route(900),
187
+ clock: () => FIXED_NOW,
188
+ });
189
+ const directPrepared = await resolveGroundTransferPayload(GROUND_TRANSFER, parsedCandidates, directResolver);
190
+ assert.equal(directPrepared.resolution.applied, true);
191
+ // Legacy single-pair shape: outbound = origin→destination, return = swapped
192
+ // pair. Both directions resolve to 15 min (route(900) = 15 min).
193
+ assert.equal(directPrepared.resolution.outboundMinutes, 15);
194
+ assert.equal(directPrepared.resolution.returnMinutes, 15);
195
+ assert.equal(directionOf(directPrepared.resolution.outbound, 'outbound')['direction'], 'outbound');
196
+ assert.equal(directionOf(directPrepared.resolution.return, 'return')['direction'], 'return');
197
+ assert.equal(directPrepared.resolution.outbound.staticMinutes, 25);
198
+ assert.equal(directPrepared.resolution.return.staticMinutes, 25);
199
+ const patchedDirect = directPrepared.candidates[1].destTransfers[0];
200
+ assert.equal(patchedDirect.minutes, 25);
201
+ assert.equal(patchedDirect.minutesOut, 15);
202
+ assert.equal(patchedDirect.minutesRet, 15);
203
+ assert.equal(patchedDirect.priceCny, originalCandidates[1].destTransfers[0].priceCny);
204
+ assert.equal(patchedDirect.mode, originalCandidates[1].destTransfers[0].mode);
205
+ assert.deepEqual(JSON.parse(JSON.stringify(directPrepared.candidates[0])), JSON.parse(JSON.stringify(originalCandidates[0])), 'unrelated candidates must not be patched');
206
+ assert.deepEqual(directPrepared.candidates[1].destTransfers[1], originalCandidates[1].destTransfers[1], 'unrelated transfers must not be patched');
207
+ // explicit-mode mismatch: bus / bus_plus_taxi → static fallback, driver never called.
208
+ let staticModeProviderCalls = 0;
209
+ const staticModeResolver = createGroundTransferResolver({
210
+ provider: async () => {
211
+ staticModeProviderCalls += 1;
212
+ return route(900);
213
+ },
214
+ clock: () => FIXED_NOW,
215
+ });
216
+ for (const staticModeRequest of [
217
+ { ...GROUND_TRANSFER, candidate_id: 'qiandao', transfer_index: 1 },
218
+ { ...GROUND_TRANSFER, candidate_id: 'dali', transfer_index: 1 },
219
+ ]) {
220
+ const before = JSON.stringify(parsedCandidates);
221
+ const prepared = await resolveGroundTransferPayload(staticModeRequest, parsedCandidates, staticModeResolver);
222
+ assert.equal(prepared.resolution.applied, false);
223
+ assert.equal(prepared.resolution.provenance, 'static-transfer-pack');
224
+ assert.equal(prepared.resolution.evidenceClass, 'static_transfer_estimate');
225
+ assert.match(prepared.resolution.fallbackReason ?? '', new RegExp(`^${GROUND_TRANSFER_STATIC_MODE_MISMATCH}`));
226
+ assert.equal(directionOf(prepared.resolution.outbound, 'mismatch.outbound').provenance, 'static-transfer-pack');
227
+ assert.equal(directionOf(prepared.resolution.return, 'mismatch.return').provenance, 'static-transfer-pack');
228
+ assert.equal(JSON.stringify(prepared.candidates), before, 'non-taxi static transfer must stay structurally unchanged');
229
+ }
230
+ assert.equal(staticModeProviderCalls, 0, 'bus and bus_plus_taxi must not call the driving provider');
231
+ const fresh = await call(host.feasibility, { ...payload, ground_transfer: GROUND_TRANSFER });
232
+ assert.equal(fresh.ok, true);
233
+ // Both directions are queried through the same provider with different origin/destination pairs.
234
+ assert.equal(fixtureCalls.length, 2);
235
+ const outboundCall = fixtureCalls[0];
236
+ const returnCall = fixtureCalls[1];
237
+ assert.equal(outboundCall['origin'], '120.1234,30.2345');
238
+ assert.equal(outboundCall['destination'], '119.4567,29.6789');
239
+ assert.equal(returnCall['origin'], '119.4567,29.6789');
240
+ assert.equal(returnCall['destination'], '120.1234,30.2345');
241
+ const freshEvidence = asRecord(fresh.ground_transfer, 'fresh ground_transfer');
242
+ assert.equal(freshEvidence['applied'], true);
243
+ assert.equal(freshEvidence['candidateId'], 'qiandao');
244
+ assert.equal(freshEvidence['transferIndex'], 0);
245
+ assert.equal(freshEvidence['outboundMinutes'], 15);
246
+ assert.equal(freshEvidence['returnMinutes'], 15);
247
+ assert.equal(freshEvidence['staticMinutes'], 25);
248
+ assert.equal(freshEvidence['priceCny'], 30);
249
+ assert.equal(freshEvidence['priceEvidence'], GROUND_TRANSFER_STATIC_PRICE_EVIDENCE);
250
+ assert.equal(freshEvidence['provenance'], 'map_driving_route');
251
+ assert.equal(freshEvidence['asOf'], FIXED_NOW.toISOString());
252
+ assert.equal(freshEvidence['freshness'], 'fresh');
253
+ assert.equal(asRecord(freshEvidence['cache'], 'fresh cache')['status'], 'miss');
254
+ const freshOutbound = directionOf(freshEvidence['outbound'], 'fresh.outbound');
255
+ const freshReturn = directionOf(freshEvidence['return'], 'fresh.return');
256
+ assert.equal(freshOutbound.minutes, 15);
257
+ assert.equal(freshOutbound.freshness, 'fresh');
258
+ assert.equal(freshOutbound.provenance, 'map_driving_route');
259
+ assert.equal(asRecord(freshOutbound['cache'], 'fresh.outbound.cache')['status'], 'miss');
260
+ assert.deepEqual(asRecord(freshOutbound['routeFact'], 'fresh.outbound.routeFact')['origin'], GROUND_TRANSFER.origin);
261
+ assert.deepEqual(asRecord(freshOutbound['routeFact'], 'fresh.outbound.routeFact')['destination'], GROUND_TRANSFER.destination);
262
+ assert.equal(asRecord(freshOutbound['routeFact'], 'fresh.outbound.routeFact')['direction'], 'outbound');
263
+ assert.equal(asRecord(freshOutbound['routeFact'], 'fresh.outbound.routeFact')['provider'], 'fixture-osrm');
264
+ assert.equal(asRecord(freshOutbound['routeFact'], 'fresh.outbound.routeFact')['durationMin'], 15);
265
+ assert.equal(asRecord(freshOutbound['routeFact'], 'fresh.outbound.routeFact')['evidenceClass'], 'public_map_route_estimate');
266
+ assert.equal(asRecord(freshOutbound['routeFact'], 'fresh.outbound.routeFact')['trafficStatus'], 'not-live-route-estimate');
267
+ assert.equal('priceCny' in asRecord(freshOutbound['routeFact'], 'fresh.outbound.routeFact'), false, 'route result must not invent a price');
268
+ assert.equal(freshReturn.minutes, 15);
269
+ assert.equal(freshReturn.freshness, 'fresh');
270
+ assert.deepEqual(asRecord(freshReturn['routeFact'], 'fresh.return.routeFact')['origin'], GROUND_TRANSFER.destination);
271
+ assert.deepEqual(asRecord(freshReturn['routeFact'], 'fresh.return.routeFact')['destination'], GROUND_TRANSFER.origin);
272
+ assert.equal(asRecord(freshReturn['routeFact'], 'fresh.return.routeFact')['direction'], 'return');
273
+ const freshEvidenceVerdicts = fresh.verdicts ?? [];
274
+ assert.equal(freshEvidenceVerdicts.filter(v => 'transfer_evidence' in v).length, 1);
275
+ const verdictEvidence = asRecord(freshEvidenceVerdicts.find(v => v['candidate_id'] === 'qiandao')?.['transfer_evidence'], 'bound transfer evidence');
276
+ assert.equal(verdictEvidence['outboundMinutes'], 15);
277
+ assert.equal(verdictEvidence['returnMinutes'], 15);
278
+ const cacheHit = await call(host.feasibility, { ...payload, ground_transfer: GROUND_TRANSFER });
279
+ assert.equal(fixtureCalls.length, 2, 'fresh cache hit must avoid a second provider call');
280
+ const hitEvidence = asRecord(cacheHit.ground_transfer, 'cache-hit ground_transfer');
281
+ assert.equal(hitEvidence['outboundMinutes'], 15);
282
+ assert.equal(hitEvidence['returnMinutes'], 15);
283
+ // Full cache hit keeps the legacy aggregate freshness semantics;
284
+ // per-direction freshness is the authoritative per-direction record.
285
+ assert.equal(hitEvidence['freshness'], 'cache_hit');
286
+ assert.equal(asRecord(hitEvidence['cache'], 'cache-hit aggregate cache')['status'], 'hit');
287
+ assert.equal(asRecord(directionOf(hitEvidence['outbound'], 'hit.outbound')['cache'], 'cache-hit outbound cache')['status'], 'hit');
288
+ assert.equal(asRecord(directionOf(hitEvidence['return'], 'hit.return')['cache'], 'cache-hit return cache')['status'], 'hit');
289
+ assert.equal(asRecord(directionOf(hitEvidence['outbound'], 'hit.outbound')['cache'], 'cache-hit outbound cache')['ageS'], 0);
290
+ assert.equal(asRecord(directionOf(hitEvidence['return'], 'hit.return')['cache'], 'cache-hit return cache')['ageS'], 0);
291
+ assert.equal(directionOf(hitEvidence['outbound'], 'hit.outbound')['freshness'], 'cache_hit');
292
+ assert.equal(directionOf(hitEvidence['return'], 'hit.return')['freshness'], 'cache_hit');
293
+ issueNowMs += (GROUND_TRANSFER_DEFAULT_MAX_AGE_S + 1) * 1_000;
294
+ durationS = 1_200;
295
+ const stale = await call(host.feasibility, {
296
+ ...payload,
297
+ ground_transfer: GROUND_TRANSFER,
298
+ });
299
+ assert.equal(fixtureCalls.length, 4, 'stale cache entries must requery the provider for both directions');
300
+ const staleEvidence = asRecord(stale.ground_transfer, 'stale ground_transfer');
301
+ assert.equal(staleEvidence['outboundMinutes'], 20);
302
+ assert.equal(staleEvidence['returnMinutes'], 20);
303
+ assert.equal(staleEvidence['freshness'], 'fresh');
304
+ assert.equal(asRecord(staleEvidence['cache'], 'stale aggregate cache')['status'], 'requery', 'stale aggregate cache keeps the legacy requery semantics');
305
+ assert.equal(directionOf(staleEvidence['outbound'], 'stale.outbound')['freshness'], 'fresh');
306
+ assert.equal(directionOf(staleEvidence['return'], 'stale.return')['freshness'], 'fresh');
307
+ assert.equal(asRecord(directionOf(staleEvidence['outbound'], 'stale.outbound')['cache'], 'stale.outbound.cache')['status'], 'requery');
308
+ assert.equal(asRecord(directionOf(staleEvidence['return'], 'stale.return')['cache'], 'stale.return.cache')['status'], 'requery');
309
+ const failingHost = fakeHost(async () => {
310
+ throw new Error('fixture provider unavailable');
311
+ });
312
+ try {
313
+ const fallback = await call(failingHost.feasibility, {
314
+ ...payload,
315
+ ground_transfer: { ...GROUND_TRANSFER, origin: { longitude: 120.5, latitude: 30.5 } },
316
+ });
317
+ const fallbackEvidence = asRecord(fallback.ground_transfer, 'provider fallback ground_transfer');
318
+ assert.equal(fallbackEvidence['applied'], false);
319
+ assert.equal(fallbackEvidence['provenance'], 'static-transfer-pack');
320
+ assert.equal(fallbackEvidence['evidenceClass'], 'static_transfer_estimate');
321
+ assert.equal(fallbackEvidence['asOf'], null);
322
+ assert.equal(asRecord(fallbackEvidence['cache'], 'provider fallback cache')['status'], 'bypass');
323
+ assert.match(String(fallbackEvidence['fallbackReason']), /provider unavailable/);
324
+ const fallbackOutbound = directionOf(fallbackEvidence['outbound'], 'fallback.outbound');
325
+ const fallbackReturn = directionOf(fallbackEvidence['return'], 'fallback.return');
326
+ assert.equal(fallbackOutbound.applied, false);
327
+ assert.equal(fallbackOutbound.staticMinutes, 25);
328
+ assert.equal(fallbackReturn.applied, false);
329
+ assert.equal(fallbackReturn.staticMinutes, 25);
330
+ assert.match(String(fallbackOutbound['fallbackReason']), /provider unavailable/);
331
+ assert.match(String(fallbackReturn['fallbackReason']), /provider unavailable/);
332
+ }
333
+ finally {
334
+ rmSync(failingHost.root, { recursive: true, force: true });
335
+ }
336
+ const missingHost = fakeHost(async () => undefined);
337
+ try {
338
+ const missing = await call(missingHost.feasibility, {
339
+ ...payload,
340
+ ground_transfer: { ...GROUND_TRANSFER, origin: { longitude: 120.6, latitude: 30.6 } },
341
+ });
342
+ const missingEvidence = asRecord(missing.ground_transfer, 'provider-miss ground_transfer');
343
+ assert.equal(missingEvidence['applied'], false);
344
+ assert.equal(missingEvidence['provenance'], 'static-transfer-pack');
345
+ assert.match(String(missingEvidence['fallbackReason']), /invalid public result/);
346
+ const missingOutbound = directionOf(missingEvidence['outbound'], 'missing.outbound');
347
+ const missingReturn = directionOf(missingEvidence['return'], 'missing.return');
348
+ assert.equal(missingOutbound.applied, false);
349
+ assert.equal(missingOutbound.staticMinutes, 25);
350
+ assert.equal(missingReturn.applied, false);
351
+ assert.equal(missingReturn.staticMinutes, 25);
352
+ }
353
+ finally {
354
+ rmSync(missingHost.root, { recursive: true, force: true });
355
+ }
356
+ const invalidCoordinates = await call(host.feasibility, {
357
+ ...payload,
358
+ ground_transfer: { ...GROUND_TRANSFER, destination: { longitude: 181, latitude: 29 } },
359
+ });
360
+ const invalidEvidence = asRecord(invalidCoordinates.ground_transfer, 'invalid-coordinate ground_transfer');
361
+ assert.equal(invalidEvidence['applied'], false);
362
+ assert.equal(invalidEvidence['provenance'], 'static-transfer-pack');
363
+ assert.match(String(invalidEvidence['fallbackReason']), /invalid_ground_transfer_coordinates/);
364
+ assert.equal(fixtureCalls.length, 4, 'invalid coordinates must not call the provider');
365
+ const unsupportedMode = await call(host.feasibility, {
366
+ ...payload,
367
+ ground_transfer: { ...GROUND_TRANSFER, mode: 'walking' },
368
+ });
369
+ const unsupportedEvidence = asRecord(unsupportedMode.ground_transfer, 'unsupported-mode ground_transfer');
370
+ assert.equal(unsupportedEvidence['applied'], false);
371
+ assert.equal(unsupportedEvidence['provenance'], 'static-transfer-pack');
372
+ assert.match(String(unsupportedEvidence['fallbackReason']), /unsupported_ground_transfer_mode:walking/);
373
+ assert.equal(fixtureCalls.length, 4, 'unsupported mode must not call the provider');
374
+ assert.doesNotMatch(JSON.stringify(unsupportedMode), /public transit|rail|transit/i);
375
+ const unknownMode = await call(host.feasibility, {
376
+ ...payload,
377
+ ground_transfer: { ...GROUND_TRANSFER, mode: 'unknown' },
378
+ });
379
+ const unknownEvidence = asRecord(unknownMode.ground_transfer, 'unknown-mode ground_transfer');
380
+ assert.equal(unknownEvidence['applied'], false);
381
+ assert.match(String(unknownEvidence['fallbackReason']), /unsupported_ground_transfer_mode:unknown/);
382
+ assert.equal(fixtureCalls.length, 4, 'unknown mode must not call the provider');
383
+ const busTransfer = await call(host.feasibility, {
384
+ ...payload,
385
+ ground_transfer: { ...GROUND_TRANSFER, candidate_id: 'qiandao', transfer_index: 1 },
386
+ });
387
+ const busEvidence = asRecord(busTransfer.ground_transfer, 'bus ground_transfer');
388
+ assert.equal(busEvidence['applied'], false);
389
+ assert.equal(busEvidence['staticMode'], 'bus');
390
+ assert.equal(busEvidence['staticMinutes'], 40);
391
+ assert.equal(busEvidence['priceCny'], 8);
392
+ assert.equal(busEvidence['provenance'], 'static-transfer-pack');
393
+ assert.equal(busEvidence['freshness'], 'not_attempted');
394
+ assert.match(String(busEvidence['fallbackReason']), /^ground_transfer_static_mode_mismatch/);
395
+ assert.equal(fixtureCalls.length, 4, 'bus transfer must not call the provider');
396
+ assert.equal(asRecord(busTransfer.verdicts?.find(v => v['candidate_id'] === 'qiandao')?.['transfer_evidence'], 'bus transfer evidence')['applied'], false);
397
+ const busPlusTaxiTransfer = await call(host.feasibility, {
398
+ ...payload,
399
+ ground_transfer: { ...GROUND_TRANSFER, candidate_id: 'dali', transfer_index: 1 },
400
+ });
401
+ const busPlusTaxiEvidence = asRecord(busPlusTaxiTransfer.ground_transfer, 'bus_plus_taxi ground_transfer');
402
+ assert.equal(busPlusTaxiEvidence['applied'], false);
403
+ assert.equal(busPlusTaxiEvidence['staticMode'], 'bus_plus_taxi');
404
+ assert.equal(busPlusTaxiEvidence['staticMinutes'], 105);
405
+ assert.equal(busPlusTaxiEvidence['priceCny'], 40);
406
+ assert.equal(busPlusTaxiEvidence['provenance'], 'static-transfer-pack');
407
+ assert.match(String(busPlusTaxiEvidence['fallbackReason']), /^ground_transfer_static_mode_mismatch/);
408
+ assert.equal(fixtureCalls.length, 4, 'bus_plus_taxi transfer must not call the provider');
409
+ const bindingMismatch = await call(host.feasibility, {
410
+ ...payload,
411
+ ground_transfer: { ...GROUND_TRANSFER, candidate_id: 'not-a-candidate' },
412
+ });
413
+ const mismatchEvidence = asRecord(bindingMismatch.ground_transfer, 'binding-mismatch ground_transfer');
414
+ assert.equal(mismatchEvidence['applied'], false);
415
+ assert.equal(mismatchEvidence['provenance'], 'none');
416
+ assert.equal(mismatchEvidence['freshness'], 'not_attempted');
417
+ assert.match(String(mismatchEvidence['fallbackReason']), /ground_transfer_binding_mismatch/);
418
+ assert.equal(fixtureCalls.length, 4, 'binding mismatch must not call the provider');
419
+ const transferMismatch = await call(host.feasibility, {
420
+ ...payload,
421
+ ground_transfer: { ...GROUND_TRANSFER, transfer_index: 99 },
422
+ });
423
+ const transferMismatchEvidence = asRecord(transferMismatch.ground_transfer, 'transfer-index-mismatch ground_transfer');
424
+ assert.equal(transferMismatchEvidence['applied'], false);
425
+ assert.match(String(transferMismatchEvidence['fallbackReason']), /ground_transfer_binding_mismatch/);
426
+ assert.equal(fixtureCalls.length, 4, 'transfer binding mismatch must not call the provider');
427
+ // Real ToolRuntime nested map_driving_route: both directions dispatched,
428
+ // both return 13 min from the same public map fixture.
429
+ const runtimeHost = await publicToolHost({ ...payload, ground_transfer: GROUND_TRANSFER });
430
+ try {
431
+ const runtimeEvidence = asRecord(runtimeHost.result.ground_transfer, 'real ToolRuntime ground_transfer');
432
+ assert.equal(runtimeEvidence['provenance'], 'map_driving_route');
433
+ assert.equal(runtimeEvidence['outboundMinutes'], 13);
434
+ assert.equal(runtimeEvidence['returnMinutes'], 13);
435
+ assert.equal(runtimeEvidence['asOf'], FIXED_NOW.toISOString());
436
+ assert.equal(runtimeHost.mapCalls.length, 2);
437
+ assert.deepEqual(runtimeHost.mapCalls[0], {
438
+ origin: '120.1234,30.2345',
439
+ destination: '119.4567,29.6789',
440
+ });
441
+ assert.deepEqual(runtimeHost.mapCalls[1], {
442
+ origin: '119.4567,29.6789',
443
+ destination: '120.1234,30.2345',
444
+ });
445
+ assert.equal('mode' in runtimeHost.mapCalls[0], false, 'mode is implied by the public map tool name');
446
+ }
447
+ finally {
448
+ await runtimeHost.dispose();
449
+ }
450
+ const unknownAgeHost = await publicToolExecution({
451
+ ...payload,
452
+ ground_transfer: { ...GROUND_TRANSFER, max_age_s: 1 },
453
+ });
454
+ try {
455
+ assert.equal(unknownAgeHost.execution.isError, false, 'guarded ToolRuntime schema rejection is returned as a structured tool failure');
456
+ if (!unknownAgeHost.execution.isError) {
457
+ const rejection = asRecord(unknownAgeHost.execution.value, 'unknown max_age_s rejection');
458
+ assert.equal(rejection['ok'], false, 'closed ToolRuntime schema must reject caller TTL');
459
+ assert.match(`${rejection['summary'] ?? ''} ${rejection['evidence'] ?? ''}`, /max_age_s|additional|unknown/i);
460
+ }
461
+ }
462
+ finally {
463
+ await unknownAgeHost.dispose();
464
+ }
465
+ // ─── Issue #364 per-direction binding additions ──────────────────────────
466
+ // taxi-only payload: constrains the solver to the patched transfer so the
467
+ // asymmetric route minutes flow into `evaluateChoice` without being
468
+ // shadowed by a cheaper unpatched sibling transfer.
469
+ const taxiOnlyPayload = {
470
+ ...payload,
471
+ candidates: payload.candidates.map((candidate) => {
472
+ if (candidate['id'] !== 'qiandao')
473
+ return candidate;
474
+ const transfers = candidate['dest_transfers'];
475
+ return { ...candidate, dest_transfers: [transfers[0]] };
476
+ }),
477
+ };
478
+ // 1. Asymmetric fake: A→B = 13 min (780s), B→A = 31 min (1860s). The solver
479
+ // must consume each direction separately and reflect 13 for arrival
480
+ // and 31 for return through the actual `solveChoiceSegment` path.
481
+ const asymmetricCalls = [];
482
+ const asymmetricHost = fakeHost(async (request) => {
483
+ asymmetricCalls.push({ ...request });
484
+ const isReturn = request.origin === '119.4567,29.6789';
485
+ return route(isReturn ? 1_860 : 780, isReturn ? 'asymmetric-return' : 'asymmetric-outbound');
486
+ });
487
+ try {
488
+ const asymmetric = await call(asymmetricHost.feasibility, { ...taxiOnlyPayload, ground_transfer: GROUND_TRANSFER });
489
+ assert.equal(asymmetric.ok, true);
490
+ assert.equal(asymmetricCalls.length, 2);
491
+ const asymEvidence = asRecord(asymmetric.ground_transfer, 'asymmetric ground_transfer');
492
+ assert.equal(asymEvidence['applied'], true);
493
+ assert.equal(asymEvidence['outboundMinutes'], 13);
494
+ assert.equal(asymEvidence['returnMinutes'], 31);
495
+ const asymVerdict = (asymmetric.verdicts ?? []).find(v => v['candidate_id'] === 'qiandao');
496
+ const asymTc = asRecord(asymVerdict['true_cost'], 'asymmetric true_cost');
497
+ assert.equal(asymTc['arrive_stay'], '10:00', 'outbound 13 min ⇒ G7315 9:47 + 13 = 10:00');
498
+ assert.equal(asymTc['leave_stay_return'], '17:49', 'return 31 min ⇒ G7316 18:40 − 20 − 31 = 17:49');
499
+ const asymChosen = asRecord(asymVerdict['chosen'], 'asymmetric chosen');
500
+ assert.equal(asymChosen['out_service'], 'G7315');
501
+ assert.equal(asymChosen['ret_service'], 'G7316');
502
+ assert.equal(asymChosen['out_transfer'], 'taxi');
503
+ assert.equal(asymChosen['ret_transfer'], 'taxi');
504
+ // End-to-end through `resolveGroundTransferPayload` → `segmentsFromCandidate`
505
+ // → `solveChoiceSegment` → `evaluateChoice`: the same path the registered
506
+ // tool executes. The arrival/return minutes are read per direction from the
507
+ // patched transfer, so swapping them in the route must not change the verdict.
508
+ const asymCandidates = parsedCandidates.map(candidate => {
509
+ if (candidate.id !== 'qiandao')
510
+ return candidate;
511
+ return {
512
+ ...candidate,
513
+ destTransfers: [{
514
+ ...candidate.destTransfers[0],
515
+ minutesOut: 13,
516
+ minutesRet: 31,
517
+ }],
518
+ };
519
+ });
520
+ const asymReq = parseRequest(payload['request']);
521
+ const asymSpec = segmentsFromCandidate(asymReq, asymCandidates);
522
+ const asymSolved = solveChoiceSegment(asymSpec, asymReq);
523
+ const asymPathVerdict = asymSolved['verdicts'].find(v => v['candidate_id'] === 'qiandao');
524
+ const asymPathTc = asRecord(asymPathVerdict['true_cost'], 'asymmetric path true_cost');
525
+ assert.equal(asymPathTc['arrive_stay'], '10:00');
526
+ assert.equal(asymPathTc['leave_stay_return'], '17:49');
527
+ // Direct evaluateChoice sanity: with both overrides on the same transfer,
528
+ // arrival uses minutesOut and return uses minutesRet independently.
529
+ const asymTaxi = asymCandidates[1].destTransfers[0];
530
+ const asymCost = evaluateChoice(asymCandidates[1], asymReq, {
531
+ outService: asymCandidates[1].servicesOut[0],
532
+ outTransfer: asymTaxi,
533
+ retService: asymCandidates[1].servicesRet[0],
534
+ retTransfer: asymTaxi,
535
+ days: asymReq.windowDays,
536
+ });
537
+ assert.equal(asymCost.arriveStayMin, 600, 'outbound 9:47 + minutesOut(13) = 10:00');
538
+ assert.equal(asymCost.departHomeRetMin, 945, 'return G7482 16:36 − 20 − minutesRet(31) = 15:45');
539
+ // Counter-direction sanity: if we swap the overrides, the arrival uses 31
540
+ // and the return uses 13 — proves the binding is genuinely per-direction
541
+ // and not just "use the route minutes for both".
542
+ const swappedTaxi = { ...asymTaxi, minutesOut: 31, minutesRet: 13 };
543
+ const swappedCost = evaluateChoice(asymCandidates[1], asymReq, {
544
+ outService: asymCandidates[1].servicesOut[0],
545
+ outTransfer: swappedTaxi,
546
+ retService: asymCandidates[1].servicesRet[0],
547
+ retTransfer: swappedTaxi,
548
+ days: asymReq.windowDays,
549
+ });
550
+ assert.equal(swappedCost.arriveStayMin, 618, 'arrival 9:47 + 31 = 10:18');
551
+ assert.equal(swappedCost.departHomeRetMin, 963, 'return 16:36 − 20 − 13 = 16:03 = 963');
552
+ // Direct `evaluateChoice` on the same scenario but with G7316 ret (the
553
+ // service the production solver actually picks) gives the 17:49 figure
554
+ // asserted through the full registered-tool pipeline above.
555
+ const asymCostLateRet = evaluateChoice(asymCandidates[1], asymReq, {
556
+ outService: asymCandidates[1].servicesOut[0],
557
+ outTransfer: asymTaxi,
558
+ retService: asymCandidates[1].servicesRet[1],
559
+ retTransfer: asymTaxi,
560
+ days: asymReq.windowDays,
561
+ });
562
+ assert.equal(asymCostLateRet.departHomeRetMin, 1069, 'G7316 18:40 − 20 − 31 = 17:49 = 1069 (matches verdict)');
563
+ }
564
+ finally {
565
+ rmSync(asymmetricHost.root, { recursive: true, force: true });
566
+ }
567
+ // 2. Per-direction cache: A→B and B→A are separate cache entries.
568
+ const isolatedCalls = [];
569
+ const isolatedHost = fakeHost(async (request) => {
570
+ isolatedCalls.push({ ...request });
571
+ return route(720, 'isolated-fixture');
572
+ });
573
+ try {
574
+ await call(isolatedHost.feasibility, { ...payload, ground_transfer: GROUND_TRANSFER });
575
+ assert.equal(isolatedCalls.length, 2);
576
+ const firstOutbound = isolatedCalls[0];
577
+ const firstReturn = isolatedCalls[1];
578
+ assert.deepEqual(firstOutbound, { origin: '120.1234,30.2345', destination: '119.4567,29.6789', mode: 'driving' });
579
+ assert.deepEqual(firstReturn, { origin: '119.4567,29.6789', destination: '120.1234,30.2345', mode: 'driving' });
580
+ // Second call: both directions are cache hits, no new provider call.
581
+ await call(isolatedHost.feasibility, { ...payload, ground_transfer: GROUND_TRANSFER });
582
+ assert.equal(isolatedCalls.length, 2, 'second call must reuse both direction caches independently');
583
+ // Explicit outbound/return shape: cache for those exact coordinate pairs.
584
+ const explicitOutbound = {
585
+ longitude: 121.0,
586
+ latitude: 31.0,
587
+ };
588
+ const explicitReturn = {
589
+ longitude: 122.0,
590
+ latitude: 32.0,
591
+ };
592
+ await call(isolatedHost.feasibility, {
593
+ ...payload,
594
+ ground_transfer: {
595
+ candidate_id: 'qiandao',
596
+ transfer_index: 0,
597
+ position: 'destination',
598
+ mode: 'driving',
599
+ outbound: { origin: explicitOutbound, destination: GROUND_TRANSFER.destination },
600
+ return: { origin: GROUND_TRANSFER.destination, destination: explicitReturn },
601
+ },
602
+ });
603
+ assert.equal(isolatedCalls.length, 4);
604
+ const newOutboundCall = isolatedCalls[2];
605
+ const newReturnCall = isolatedCalls[3];
606
+ assert.deepEqual(newOutboundCall, { origin: '121,31', destination: '119.4567,29.6789', mode: 'driving' });
607
+ assert.deepEqual(newReturnCall, { origin: '119.4567,29.6789', destination: '122,32', mode: 'driving' });
608
+ }
609
+ finally {
610
+ rmSync(isolatedHost.root, { recursive: true, force: true });
611
+ }
612
+ // 3. One-sided failure: A→B succeeds (13 min), B→A throws. Arrival uses
613
+ // 13 from the live route; return falls back to static minutes (25).
614
+ // The aggregate provenance reflects the partial route success.
615
+ const oneSidedCalls = [];
616
+ const oneSidedHost = fakeHost(async (request) => {
617
+ oneSidedCalls.push({ ...request });
618
+ if (request.destination === '120.1234,30.2345') {
619
+ throw new Error('return-side provider outage');
620
+ }
621
+ return route(780, 'one-sided-outbound');
622
+ });
623
+ try {
624
+ const oneSided = await call(oneSidedHost.feasibility, { ...taxiOnlyPayload, ground_transfer: GROUND_TRANSFER });
625
+ assert.equal(oneSidedCalls.length, 2);
626
+ const osEvidence = asRecord(oneSided.ground_transfer, 'one-sided ground_transfer');
627
+ assert.equal(osEvidence['applied'], true);
628
+ assert.equal(osEvidence['outboundMinutes'], 13);
629
+ assert.equal(osEvidence['returnMinutes'], undefined, 'failed return direction does not borrow the outbound value');
630
+ assert.equal(osEvidence['provenance'], 'map_driving_route');
631
+ const osOutbound = directionOf(osEvidence['outbound'], 'one-sided.outbound');
632
+ const osReturn = directionOf(osEvidence['return'], 'one-sided.return');
633
+ assert.equal(osOutbound.applied, true);
634
+ assert.equal(osOutbound.minutes, 13);
635
+ assert.equal(osReturn.applied, false);
636
+ assert.equal(osReturn.minutes, undefined);
637
+ assert.equal(osReturn.staticMinutes, 25);
638
+ assert.match(String(osReturn.fallbackReason), /return-side provider outage/);
639
+ const osVerdict = (oneSided.verdicts ?? []).find(v => v['candidate_id'] === 'qiandao');
640
+ const osTc = asRecord(osVerdict['true_cost'], 'one-sided true_cost');
641
+ assert.equal(osTc['arrive_stay'], '10:00', 'arrival uses the successful outbound 13 min');
642
+ // 18*60+40 − 20 − 25 = 1075 = 17:55 (static minutes win on the failed return).
643
+ assert.equal(osTc['leave_stay_return'], '17:55', 'return falls back to static 25 min ⇒ 18:40 − 20 − 25 = 17:55');
644
+ }
645
+ finally {
646
+ rmSync(oneSidedHost.root, { recursive: true, force: true });
647
+ }
648
+ // 4. Real ToolRuntime nested map_driving_route through registered
649
+ // gotry_feasibility_check into actual solveChoiceSegment. Use an
650
+ // asymmetric map tool that returns 780s outbound and 1860s return.
651
+ const realAsymmetricRoot = mkdtempSync(join(tmpdir(), 'gotry-364-real-asymmetric-'));
652
+ const realMapCalls = [];
653
+ const realCtx = new Context();
654
+ await realCtx.plugin(SystemPrompt);
655
+ await realCtx.plugin(ToolRuntime, {});
656
+ realCtx.tools.register(defineTool({
657
+ name: 'map_driving_route',
658
+ description: 'asymmetric real map tool',
659
+ parameters: {
660
+ origin: { type: 'string', required: true },
661
+ destination: { type: 'string', required: true },
662
+ },
663
+ output: {
664
+ schema: { type: 'json' },
665
+ render: () => [{ type: 'text', text: 'asymmetric map route' }],
666
+ },
667
+ async execute(args, _execution) {
668
+ realMapCalls.push({ ...args });
669
+ const isReturn = args.origin === '119.4567,29.6789';
670
+ return route(isReturn ? 1_860 : 780, isReturn ? 'real-return' : 'real-outbound');
671
+ },
672
+ }));
673
+ apply(realCtx, {
674
+ stateRoot: realAsymmetricRoot,
675
+ timeoutMs: 30_000,
676
+ hbcliBin: 'hbcli-not-on-path',
677
+ sessionAccess: 'off',
678
+ }, { clock: () => FIXED_NOW });
679
+ try {
680
+ const realExecution = await realCtx.tools.execute({
681
+ callId: 'issue-364-asymmetric-root',
682
+ name: 'gotry_feasibility_check',
683
+ arguments: { payload: clone({ ...taxiOnlyPayload, ground_transfer: GROUND_TRANSFER }) },
684
+ signal: new AbortController().signal,
685
+ });
686
+ assert.equal(realExecution.isError, false, `nested tool execution failed: ${realExecution.isError ? realExecution.error.message : ''}`);
687
+ const realResult = resultOf(realExecution.value);
688
+ const realEvidence = asRecord(realResult.ground_transfer, 'real ToolRuntime asymmetric evidence');
689
+ assert.equal(realEvidence['applied'], true);
690
+ assert.equal(realEvidence['outboundMinutes'], 13);
691
+ assert.equal(realEvidence['returnMinutes'], 31);
692
+ assert.equal(realMapCalls.length, 2);
693
+ const realOutboundCall = realMapCalls[0];
694
+ const realReturnCall = realMapCalls[1];
695
+ assert.equal(realOutboundCall['origin'], '120.1234,30.2345');
696
+ assert.equal(realOutboundCall['destination'], '119.4567,29.6789');
697
+ assert.equal(realReturnCall['origin'], '119.4567,29.6789');
698
+ assert.equal(realReturnCall['destination'], '120.1234,30.2345');
699
+ const realVerdict = (realResult.verdicts ?? []).find(v => v['candidate_id'] === 'qiandao');
700
+ const realTc = asRecord(realVerdict['true_cost'], 'real ToolRuntime asymmetric true_cost');
701
+ assert.equal(realTc['arrive_stay'], '10:00');
702
+ assert.equal(realTc['leave_stay_return'], '17:49', 'real ToolRuntime → real solveChoiceSegment → real evaluateChoice uses minutesRet = 31');
703
+ // Reverse coordinates: prove the solver no longer reuses the outbound
704
+ // value for the return when the route fact is genuinely asymmetric.
705
+ const reverseReq = parseRequest(payload['request']);
706
+ const reverseCandidates = payload.candidates.filter((c) => c['id'] === 'qiandao').map(parseCandidate);
707
+ reverseCandidates[0].destTransfers = [reverseCandidates[0].destTransfers[0]];
708
+ // Force the patched transfer to keep the symmetric minutes field (25) and
709
+ // assign per-direction overrides to mirror the asymmetric fake.
710
+ reverseCandidates[0].destTransfers[0].minutes = 25;
711
+ reverseCandidates[0].destTransfers[0].minutesOut = 13;
712
+ reverseCandidates[0].destTransfers[0].minutesRet = 31;
713
+ const reverseSpec = segmentsFromCandidate(reverseReq, reverseCandidates);
714
+ const reverseSolved = solveChoiceSegment(reverseSpec, reverseReq);
715
+ const reverseVerdict = reverseSolved['verdicts'].find(v => v['candidate_id'] === 'qiandao');
716
+ const reverseTc = asRecord(reverseVerdict['true_cost'], 'reverse-coordinate true_cost');
717
+ assert.equal(reverseTc['arrive_stay'], '10:00');
718
+ assert.equal(reverseTc['leave_stay_return'], '17:49');
719
+ }
720
+ finally {
721
+ await realCtx.fiber.dispose();
722
+ rmSync(realAsymmetricRoot, { recursive: true, force: true });
723
+ }
724
+ // 5. Backward-compat: legacy payload with no ground_transfer keeps byte-
725
+ // for-byte behavior; same baseline is reused unchanged.
726
+ const baselineAgain = await call(host.feasibility, payload);
727
+ assert.deepEqual(withoutLatency(baselineAgain), withoutLatency(baseline));
728
+ // ─── Wider D-39 boundary freeze (2026-09-11, offline remainder) ──────────
729
+ const TAXI_ONLY = taxiOnlyPayload;
730
+ // B1. Vocabulary closure. The GoTry boundary accepts exactly
731
+ // `mode=driving` at `position=destination`. The vendored public seam
732
+ // also exposes transit / walking / bicycling routes; none of them may
733
+ // leak through this seam as a route fact, and no malformed request
734
+ // shape may reach the provider.
735
+ let closureCalls = 0;
736
+ await withFeasibilityHost(async () => {
737
+ closureCalls += 1;
738
+ return route(600);
739
+ }, async (feasibility) => {
740
+ // provenance: cases where no valid transfer is identified report 'none'
741
+ // (nothing to attribute); cases that identify the transfer but carry an
742
+ // invalid mode/position/coordinate report the static pack.
743
+ const closedCases = [
744
+ { override: { mode: 'transit' }, reason: /^unsupported_ground_transfer_mode:transit/, label: 'transit', provenance: 'static-transfer-pack' },
745
+ { override: { mode: 'bicycling' }, reason: /^unsupported_ground_transfer_mode:bicycling/, label: 'bicycling', provenance: 'static-transfer-pack' },
746
+ { override: { mode: 'walking' }, reason: /^unsupported_ground_transfer_mode:walking/, label: 'walking', provenance: 'static-transfer-pack' },
747
+ { override: { position: 'origin' }, reason: /^unsupported_ground_transfer_position:origin/, label: 'position=origin', provenance: 'static-transfer-pack' },
748
+ { override: { position: undefined }, reason: /^unsupported_ground_transfer_position:unknown/, label: 'position missing', provenance: 'static-transfer-pack' },
749
+ { override: { candidate_id: undefined }, reason: /^ground_transfer_binding_mismatch:/, label: 'candidate_id missing', provenance: 'none' },
750
+ { override: { transfer_index: -1 }, reason: /^ground_transfer_binding_mismatch:/, label: 'transfer_index negative', provenance: 'none' },
751
+ {
752
+ override: { outbound: { origin: GROUND_TRANSFER.origin, destination: GROUND_TRANSFER.destination } },
753
+ reason: /^invalid_ground_transfer_coordinates:/,
754
+ label: 'explicit outbound without return',
755
+ provenance: 'static-transfer-pack',
756
+ },
757
+ ];
758
+ for (const closed of closedCases) {
759
+ const rejected = await call(feasibility, { ...payload, ground_transfer: { ...GROUND_TRANSFER, ...closed.override } });
760
+ const rejectedEvidence = asRecord(rejected.ground_transfer, `closure ${closed.label} ground_transfer`);
761
+ assert.equal(rejectedEvidence['applied'], false, `closure ${closed.label} must not apply`);
762
+ assert.equal(rejectedEvidence['provenance'], closed.provenance, `closure ${closed.label} provenance`);
763
+ assert.match(String(rejectedEvidence['fallbackReason']), closed.reason, `closure ${closed.label} reason`);
764
+ }
765
+ // Schema-ring closure: shapes the closed tool schema refuses outright
766
+ // (non-object ground_transfer, fractional transfer_index) never reach
767
+ // the capability layer — the structured failure names the violation.
768
+ for (const schemaCase of [
769
+ { ground_transfer: 'driving-please', pattern: /"payload\.ground_transfer" must be an object/, label: 'non-object' },
770
+ {
771
+ ground_transfer: { ...GROUND_TRANSFER, transfer_index: 1.5 },
772
+ pattern: /"payload\.ground_transfer\.transfer_index" must be an integer/,
773
+ label: 'fractional transfer_index',
774
+ },
775
+ ]) {
776
+ const schemaRejected = await call(feasibility, { ...payload, ...schemaCase });
777
+ assert.equal(schemaRejected.ok, false, `schema ring must refuse ${schemaCase.label}`);
778
+ assert.match(String(schemaRejected['summary']), schemaCase.pattern, `schema ring reason for ${schemaCase.label}`);
779
+ assert.equal('ground_transfer' in schemaRejected, false, `schema ring rejection carries no transfer evidence (${schemaCase.label})`);
780
+ }
781
+ assert.equal(closureCalls, 0, 'no closed-vocabulary or malformed shape may call the provider');
782
+ });
783
+ // B2. Fallback completeness, mixed directions: a direction whose cache
784
+ // entry went stale and whose re-query fails falls back that direction
785
+ // only, with the stale entry visible (cache status 'stale' and a
786
+ // `stale_route_requery_error` reason), while the other direction keeps
787
+ // its re-queried dynamic value.
788
+ let mixedCalls = 0;
789
+ let mixedClockMs = FIXED_NOW.getTime();
790
+ let mixedReturnOutage = false;
791
+ const mixedHost = fakeHost(async (request) => {
792
+ mixedCalls += 1;
793
+ if (mixedReturnOutage && request.origin === '119.4567,29.6789')
794
+ throw new Error('return requery outage');
795
+ return route(900);
796
+ }, () => new Date(mixedClockMs));
797
+ try {
798
+ const mixedFirst = await call(mixedHost.feasibility, { ...TAXI_ONLY, ground_transfer: GROUND_TRANSFER });
799
+ assert.equal(mixedCalls, 2);
800
+ assert.equal(mixedFirst.ok, true);
801
+ mixedClockMs += (GROUND_TRANSFER_DEFAULT_MAX_AGE_S + 1) * 1_000;
802
+ mixedReturnOutage = true;
803
+ const mixed = await call(mixedHost.feasibility, { ...TAXI_ONLY, ground_transfer: GROUND_TRANSFER });
804
+ assert.equal(mixedCalls, 4, 'both stale entries must re-query; only the return fails');
805
+ assert.equal(mixed.ok, true);
806
+ const mixedEvidence = asRecord(mixed.ground_transfer, 'mixed stale/fallback ground_transfer');
807
+ assert.equal(mixedEvidence['applied'], true);
808
+ assert.equal(mixedEvidence['outboundMinutes'], 15);
809
+ assert.equal(mixedEvidence['returnMinutes'], undefined, 'failed stale re-query must not borrow any dynamic value');
810
+ assert.equal(mixedEvidence['freshness'], 'fresh', 'aggregate freshness follows the re-queried direction');
811
+ assert.equal(asRecord(mixedEvidence['cache'], 'mixed aggregate cache')['status'], 'requery');
812
+ assert.equal(mixedEvidence['priceCny'], 30, 'mixed path keeps the static price value');
813
+ assert.equal(mixedEvidence['priceEvidence'], GROUND_TRANSFER_STATIC_PRICE_EVIDENCE);
814
+ const mixedOutbound = directionOf(mixedEvidence['outbound'], 'mixed.outbound');
815
+ const mixedReturn = directionOf(mixedEvidence['return'], 'mixed.return');
816
+ assert.equal(mixedOutbound.applied, true);
817
+ assert.equal(mixedOutbound.freshness, 'fresh');
818
+ assert.equal(asRecord(mixedOutbound['cache'], 'mixed.outbound.cache')['status'], 'requery');
819
+ assert.equal(mixedReturn.applied, false);
820
+ assert.equal(mixedReturn.staticMinutes, 25);
821
+ assert.equal(mixedReturn.freshness, 'fallback');
822
+ assert.equal(asRecord(mixedReturn['cache'], 'mixed.return.cache')['status'], 'stale', 'the stale entry itself must be visible on fallback');
823
+ assert.match(String(mixedReturn.fallbackReason), /^stale_route_requery_error:return:return requery outage$/);
824
+ const mixedVerdict = (mixed.verdicts ?? []).find(v => v['candidate_id'] === 'qiandao');
825
+ const mixedTc = asRecord(mixedVerdict['true_cost'], 'mixed true_cost');
826
+ assert.equal(mixedTc['arrive_stay'], '10:02', 're-queried outbound 15 min ⇒ 9:47 + 15 = 10:02');
827
+ assert.equal(mixedTc['leave_stay_return'], '17:55', 'stale-failed return keeps static 25 min ⇒ 18:40 − 20 − 25 = 17:55');
828
+ const mixedVerdictEvidence = asRecord(mixedVerdict['transfer_evidence'], 'mixed verdict transfer_evidence');
829
+ assert.equal(mixedVerdictEvidence['outboundMinutes'], 15);
830
+ assert.equal(mixedVerdictEvidence['returnMinutes'], undefined);
831
+ assert.equal(mixedVerdictEvidence['priceEvidence'], GROUND_TRANSFER_STATIC_PRICE_EVIDENCE);
832
+ }
833
+ finally {
834
+ rmSync(mixedHost.root, { recursive: true, force: true });
835
+ }
836
+ // B2b. A route fact that contradicts itself — positive distance covered in
837
+ // zero time — is not a credible route fact. It must fall into the
838
+ // explicit provider-miss fallback instead of silently binding
839
+ // minutes = 0 into the solver (issue #341: “不静默改数”).
840
+ let degenerateCalls = 0;
841
+ await withFeasibilityHost(async () => {
842
+ degenerateCalls += 1;
843
+ return { provider: 'degenerate-osrm', distanceM: 42_500, durationS: 0 };
844
+ }, async (feasibility) => {
845
+ const degenerate = await call(feasibility, { ...TAXI_ONLY, ground_transfer: GROUND_TRANSFER });
846
+ const degenerateEvidence = asRecord(degenerate.ground_transfer, 'degenerate ground_transfer');
847
+ assert.equal(degenerateEvidence['applied'], false, 'a contradictory route fact must not apply');
848
+ assert.equal(degenerateEvidence['provenance'], 'static-transfer-pack');
849
+ assert.equal(degenerateEvidence['evidenceClass'], 'static_transfer_estimate');
850
+ assert.equal(degenerateEvidence['outboundMinutes'], undefined);
851
+ assert.equal(degenerateEvidence['returnMinutes'], undefined);
852
+ assert.match(String(degenerateEvidence['fallbackReason']), /invalid public result/);
853
+ assert.equal(degenerateEvidence['priceCny'], 30);
854
+ assert.equal(degenerateEvidence['priceEvidence'], GROUND_TRANSFER_STATIC_PRICE_EVIDENCE);
855
+ const degenerateOutbound = directionOf(degenerateEvidence['outbound'], 'degenerate.outbound');
856
+ assert.equal(degenerateOutbound.applied, false);
857
+ assert.equal(degenerateOutbound.staticMinutes, 25);
858
+ assert.equal(degenerateOutbound['routeFact'], undefined, 'a rejected fact is never cached into evidence');
859
+ const degenerateSerialized = JSON.stringify(degenerate);
860
+ assert.equal(degenerateSerialized.includes('"minutesOut":0'), false, 'zero minutes must never be bound');
861
+ assert.equal(degenerateSerialized.includes('"minutesRet":0'), false);
862
+ const degenerateVerdict = (degenerate.verdicts ?? []).find(v => v['candidate_id'] === 'qiandao');
863
+ const degenerateTc = asRecord(degenerateVerdict['true_cost'], 'degenerate true_cost');
864
+ assert.equal(degenerateTc['leave_stay_return'], '17:55', 'static 25 min preserved, not the degenerate 0');
865
+ assert.equal(degenerateCalls, 2, 'the contradictory response is still a provider query for both directions');
866
+ });
867
+ // B3a. Static↔dynamic switch contract, TTL boundary: a cache entry aged
868
+ // exactly the fixed 900 seconds is still a hit; one second later it
869
+ // is stale and re-queried.
870
+ let ttlCalls = 0;
871
+ let ttlClockMs = FIXED_NOW.getTime();
872
+ const ttlHost = fakeHost(async () => {
873
+ ttlCalls += 1;
874
+ return route(780);
875
+ }, () => new Date(ttlClockMs));
876
+ try {
877
+ await call(ttlHost.feasibility, { ...payload, ground_transfer: GROUND_TRANSFER });
878
+ assert.equal(ttlCalls, 2);
879
+ ttlClockMs += GROUND_TRANSFER_DEFAULT_MAX_AGE_S * 1_000;
880
+ const atBoundary = await call(ttlHost.feasibility, { ...payload, ground_transfer: GROUND_TRANSFER });
881
+ assert.equal(ttlCalls, 2, 'age == TTL (900s) is still a cache hit');
882
+ const atBoundaryEvidence = asRecord(atBoundary.ground_transfer, 'ttl-boundary ground_transfer');
883
+ assert.equal(directionOf(atBoundaryEvidence['outbound'], 'ttl-boundary.outbound')['freshness'], 'cache_hit');
884
+ assert.equal(directionOf(atBoundaryEvidence['return'], 'ttl-boundary.return')['freshness'], 'cache_hit');
885
+ ttlClockMs += 1_000;
886
+ const pastBoundary = await call(ttlHost.feasibility, { ...payload, ground_transfer: GROUND_TRANSFER });
887
+ assert.equal(ttlCalls, 4, 'age > TTL (901s) must re-query both directions');
888
+ const pastBoundaryEvidence = asRecord(pastBoundary.ground_transfer, 'ttl-past ground_transfer');
889
+ assert.equal(directionOf(pastBoundaryEvidence['outbound'], 'ttl-past.outbound')['freshness'], 'fresh');
890
+ assert.equal(asRecord(directionOf(pastBoundaryEvidence['outbound'], 'ttl-past.outbound')['cache'], 'ttl-past.outbound.cache')['status'], 'requery');
891
+ }
892
+ finally {
893
+ rmSync(ttlHost.root, { recursive: true, force: true });
894
+ }
895
+ // B3b. Identity switch: a route whose duration equals the static minutes
896
+ // is still a dynamic fact — the switch is provenance-driven, not
897
+ // value-diff-driven; the symmetric `minutes` field stays untouched
898
+ // and evidence stays fully annotated in both directions.
899
+ await withFeasibilityHost(async () => route(1_500), async (feasibility) => {
900
+ const identity = await call(feasibility, { ...payload, ground_transfer: GROUND_TRANSFER });
901
+ const identityEvidence = asRecord(identity.ground_transfer, 'identity ground_transfer');
902
+ assert.equal(identityEvidence['applied'], true);
903
+ assert.equal(identityEvidence['provenance'], 'map_driving_route', 'identity minutes must keep the dynamic provenance');
904
+ assert.equal(identityEvidence['outboundMinutes'], 25);
905
+ assert.equal(identityEvidence['returnMinutes'], 25);
906
+ assert.equal(identityEvidence['staticMinutes'], 25);
907
+ const identityOutboundFact = asRecord(directionOf(identityEvidence['outbound'], 'identity.outbound')['routeFact'], 'identity.outbound.routeFact');
908
+ const identityReturnFact = asRecord(directionOf(identityEvidence['return'], 'identity.return')['routeFact'], 'identity.return.routeFact');
909
+ for (const [label, fact] of [['outbound', identityOutboundFact], ['return', identityReturnFact]]) {
910
+ assert.equal('priceCny' in fact, false, `identity ${label} route fact must not invent a price`);
911
+ assert.equal(fact['trafficStatus'], 'not-live-route-estimate', `identity ${label} route fact must disclaim live traffic`);
912
+ assert.equal(fact['evidenceClass'], 'public_map_route_estimate');
913
+ }
914
+ });
915
+ const boundedRequests = [];
916
+ const pairFor = (longitude) => ({
917
+ candidateId: 'qiandao',
918
+ transferIndex: 0,
919
+ mode: 'driving',
920
+ position: 'destination',
921
+ outbound: { direction: 'outbound', origin: { longitude, latitude: 20 }, destination: { longitude: longitude + 1, latitude: 21 } },
922
+ ret: { direction: 'return', origin: { longitude: longitude + 1, latitude: 21 }, destination: { longitude, latitude: 20 } },
923
+ });
924
+ const boundedSelection = { candidateId: 'qiandao', transferIndex: 0, staticMode: 'taxi', staticMinutes: 25, staticPriceCny: 30 };
925
+ const boundedResolver = createGroundTransferResolver({
926
+ provider: async (request) => {
927
+ boundedRequests.push({ ...request });
928
+ return route(600);
929
+ },
930
+ clock: () => FIXED_NOW,
931
+ maxEntries: 2,
932
+ });
933
+ await boundedResolver.resolve(pairFor(100), boundedSelection);
934
+ assert.equal(boundedRequests.length, 2);
935
+ await boundedResolver.resolve(pairFor(110), boundedSelection);
936
+ assert.equal(boundedRequests.length, 4);
937
+ await boundedResolver.resolve(pairFor(100), boundedSelection);
938
+ assert.equal(boundedRequests.length, 6, 'at maxEntries=2 the first pair was evicted and must re-query');
939
+ const relaxedRequests = [];
940
+ const relaxedResolver = createGroundTransferResolver({
941
+ provider: async (request) => {
942
+ relaxedRequests.push({ ...request });
943
+ return route(600);
944
+ },
945
+ clock: () => FIXED_NOW,
946
+ });
947
+ await relaxedResolver.resolve(pairFor(120), boundedSelection);
948
+ await relaxedResolver.resolve(pairFor(120), boundedSelection);
949
+ assert.equal(relaxedRequests.length, 2, 'the default cache keeps entries: the second resolve is a hit');
950
+ console.log('issue #341 ground-transfer focused suite: OK');
951
+ }
952
+ finally {
953
+ rmSync(host.root, { recursive: true, force: true });
954
+ }
955
+ }
956
+ await main();