@remnic/core 9.3.694 → 9.3.695

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 (330) hide show
  1. package/dist/access-boundary.d.ts +12 -11
  2. package/dist/access-boundary.js +34 -28
  3. package/dist/access-cli.js +60 -59
  4. package/dist/access-cli.js.map +1 -1
  5. package/dist/access-http.d.ts +12 -11
  6. package/dist/access-http.js +38 -32
  7. package/dist/access-mcp.d.ts +12 -11
  8. package/dist/access-mcp.js +37 -31
  9. package/dist/access-operations.d.ts +12 -11
  10. package/dist/access-operations.js +36 -30
  11. package/dist/access-schema.js +3 -3
  12. package/dist/{access-service-Ceq4TdPO.d.ts → access-service-BqBQeUJd.d.ts} +15 -2
  13. package/dist/access-service.d.ts +8 -7
  14. package/dist/access-service.js +33 -27
  15. package/dist/access-surface-catalog.d.ts +12 -11
  16. package/dist/action-confidence.d.ts +1 -1
  17. package/dist/active-memory-bridge.d.ts +1 -1
  18. package/dist/active-recall.d.ts +1 -1
  19. package/dist/active-recall.js +1 -1
  20. package/dist/{auto-sync-RWXT6P5M.js → auto-sync-PFW2NJZY.js} +8 -8
  21. package/dist/behavior-learner.d.ts +1 -1
  22. package/dist/behavior-signals.d.ts +1 -1
  23. package/dist/bootstrap.d.ts +9 -8
  24. package/dist/briefing.d.ts +1 -1
  25. package/dist/briefing.js +12 -5
  26. package/dist/buffer-surprise-report.d.ts +1 -1
  27. package/dist/buffer.d.ts +1 -1
  28. package/dist/calibration.d.ts +1 -1
  29. package/dist/calibration.js +2 -2
  30. package/dist/capabilities.d.ts +1 -1
  31. package/dist/{capsule-crypto-7FJQINUR.js → capsule-crypto-YO5QJ6L3.js} +2 -2
  32. package/dist/{catalog-Tkb991rg.d.ts → catalog-D7YDNNF7.d.ts} +1 -1
  33. package/dist/causal-behavior.d.ts +1 -1
  34. package/dist/causal-behavior.js +2 -2
  35. package/dist/causal-chain.js +2 -2
  36. package/dist/causal-consolidation.d.ts +1 -1
  37. package/dist/causal-consolidation.js +17 -16
  38. package/dist/causal-consolidation.js.map +1 -1
  39. package/dist/causal-retrieval.js +2 -2
  40. package/dist/causal-trajectory-graph.js +2 -2
  41. package/dist/causal-trajectory.js +1 -1
  42. package/dist/{chunk-UPTZYUYJ.js → chunk-24D66MMN.js} +2 -1
  43. package/dist/chunk-24D66MMN.js.map +1 -0
  44. package/dist/{chunk-FCH766FX.js → chunk-32XTY2DQ.js} +51 -51
  45. package/dist/{chunk-YN4ZT4CW.js → chunk-3LYJIA76.js} +1 -1
  46. package/dist/{chunk-24FCDBJV.js → chunk-3P2XEQSV.js} +2 -2
  47. package/dist/{chunk-JIKBEDSB.js → chunk-4NNDJOAO.js} +8 -8
  48. package/dist/{chunk-EPVIBPVE.js → chunk-52QIAN76.js} +2 -2
  49. package/dist/chunk-5AT62NQH.js +553 -0
  50. package/dist/chunk-5AT62NQH.js.map +1 -0
  51. package/dist/{chunk-TAPSNIMA.js → chunk-5CEJH5ZN.js} +4 -4
  52. package/dist/{chunk-4RGWCRIJ.js → chunk-5TYA4OCF.js} +4 -4
  53. package/dist/{chunk-PILLH262.js → chunk-6GAUF4OU.js} +3 -3
  54. package/dist/{chunk-QXWQ5V24.js → chunk-AF6DSAWD.js} +2 -2
  55. package/dist/{chunk-2NLLXCJG.js → chunk-BXLOS5AJ.js} +2 -2
  56. package/dist/{chunk-32KD5IHZ.js → chunk-CLXTDOKV.js} +2 -2
  57. package/dist/{chunk-ARV3AUOM.js → chunk-DL6H3D7S.js} +2 -2
  58. package/dist/{chunk-X7Y7WX73.js → chunk-DQEMWVMT.js} +1 -1
  59. package/dist/{chunk-KCRV3HVN.js → chunk-EC3A6M43.js} +2 -2
  60. package/dist/{chunk-PNW4KKJX.js → chunk-EK6FYL2E.js} +27 -1
  61. package/dist/chunk-EK6FYL2E.js.map +1 -0
  62. package/dist/{chunk-YI3KC2B5.js → chunk-FZMH66NT.js} +2 -2
  63. package/dist/{chunk-DT2ZGQT7.js → chunk-G663XUEG.js} +2 -2
  64. package/dist/{chunk-TYEOAFH3.js → chunk-G6SVZWPB.js} +10 -1
  65. package/dist/chunk-G6SVZWPB.js.map +1 -0
  66. package/dist/{chunk-CDOIDOTQ.js → chunk-HKET3ZOI.js} +4 -4
  67. package/dist/{chunk-YOI3ELXF.js → chunk-IF362TF6.js} +2 -2
  68. package/dist/{chunk-UX5QTAFE.js → chunk-J45OCIVH.js} +2 -2
  69. package/dist/{chunk-UDJLF3BO.js → chunk-JI6HWBYL.js} +2 -2
  70. package/dist/{chunk-ECZPWSB5.js → chunk-JJT3AGL4.js} +4 -4
  71. package/dist/{chunk-6C4WHMKV.js → chunk-JMAKCWJ3.js} +6 -6
  72. package/dist/{chunk-4NFVPDIL.js → chunk-LM6JB2EG.js} +2 -2
  73. package/dist/{chunk-MOGFK2DU.js → chunk-LPIWEKZ3.js} +2 -2
  74. package/dist/{chunk-LD53WPMU.js → chunk-LQ6JI4VH.js} +4 -4
  75. package/dist/{chunk-IZLNJYLX.js → chunk-MNFAGE5D.js} +2 -2
  76. package/dist/{chunk-NXNUG7IA.js → chunk-MOBRVKWE.js} +23 -12
  77. package/dist/chunk-MOBRVKWE.js.map +1 -0
  78. package/dist/{chunk-5A35EGHM.js → chunk-N4PD5IY3.js} +35 -35
  79. package/dist/{chunk-PQP6MFPL.js → chunk-NFMDHE45.js} +2 -2
  80. package/dist/{chunk-WVTNZUER.js → chunk-NLZO5NO6.js} +2 -2
  81. package/dist/{chunk-5S53ZBPH.js → chunk-Q6ZHGGNJ.js} +2 -2
  82. package/dist/{chunk-SN5DFE34.js → chunk-Q7SFJURX.js} +2 -2
  83. package/dist/{chunk-MXZWKOOR.js → chunk-QJM2XAJD.js} +11 -11
  84. package/dist/{chunk-6ZNIUGXB.js → chunk-QQW2LIEA.js} +4 -4
  85. package/dist/{chunk-ZKD3UVEO.js → chunk-QVTSLRKT.js} +3 -3
  86. package/dist/{chunk-C5AOVGVC.js → chunk-RWST6NOL.js} +166 -102
  87. package/dist/chunk-RWST6NOL.js.map +1 -0
  88. package/dist/{chunk-YXZPKBFW.js → chunk-SPVIG2R3.js} +10 -10
  89. package/dist/{chunk-B55EAZNF.js → chunk-TIVZ4MCG.js} +2 -2
  90. package/dist/{chunk-5KBNNUXS.js → chunk-TT7BJWGI.js} +2 -2
  91. package/dist/{chunk-KQAFEZQX.js → chunk-VDX2J7OX.js} +2 -2
  92. package/dist/{chunk-E6BOD3UJ.js → chunk-VPAFWWCL.js} +2 -2
  93. package/dist/{chunk-D7PQ7HZL.js → chunk-W54EAJT4.js} +2 -2
  94. package/dist/{chunk-QVG4LAQO.js → chunk-WR6KFJKA.js} +7 -7
  95. package/dist/{chunk-YQGHW5ML.js → chunk-WRFKZEO6.js} +4 -4
  96. package/dist/{chunk-SJGTY2TW.js → chunk-XUNQLJT2.js} +6 -6
  97. package/dist/{chunk-P7IDNF7H.js → chunk-XWT4JXXA.js} +2 -2
  98. package/dist/{chunk-MSUA4KAK.js → chunk-XXA6T3O4.js} +2 -2
  99. package/dist/{chunk-KF74X62T.js → chunk-YGKUAX2B.js} +4 -4
  100. package/dist/{chunk-YWYZPKK3.js → chunk-YOZNTNNA.js} +2 -2
  101. package/dist/{chunk-U62ZGWU7.js → chunk-ZXUOAFUG.js} +1 -1
  102. package/dist/chunk-ZXUOAFUG.js.map +1 -0
  103. package/dist/{cli-CxtZyBLl.d.ts → cli-DBDgdvh9.d.ts} +3 -3
  104. package/dist/cli.d.ts +11 -10
  105. package/dist/cli.js +56 -50
  106. package/dist/compounding/engine.d.ts +1 -1
  107. package/dist/compounding/engine.js +12 -5
  108. package/dist/compounding/preference-consolidator.d.ts +1 -1
  109. package/dist/compression-optimizer.d.ts +1 -1
  110. package/dist/config.d.ts +1 -1
  111. package/dist/config.js +1 -1
  112. package/dist/connectors/codex-materialize-runner.d.ts +1 -1
  113. package/dist/connectors/codex-materialize-runner.js +12 -5
  114. package/dist/connectors/codex-materialize.d.ts +1 -1
  115. package/dist/connectors/index.d.ts +1 -1
  116. package/dist/connectors/index.js +12 -5
  117. package/dist/consolidation-provenance-check.d.ts +3 -3
  118. package/dist/consolidation-undo.d.ts +2 -2
  119. package/dist/contradiction/index.d.ts +1 -1
  120. package/dist/conversation-index/backend.d.ts +1 -1
  121. package/dist/conversation-index/chunker.d.ts +1 -1
  122. package/dist/conversation-index/faiss-adapter.d.ts +1 -1
  123. package/dist/conversation-index/indexer.d.ts +1 -1
  124. package/dist/conversation-index/search.d.ts +1 -1
  125. package/dist/day-summary.d.ts +1 -1
  126. package/dist/delinearize.d.ts +1 -1
  127. package/dist/direct-answer-wiring.d.ts +1 -1
  128. package/dist/direct-answer.d.ts +1 -1
  129. package/dist/embedding-fallback.d.ts +1 -1
  130. package/dist/enrichment/index.d.ts +1 -1
  131. package/dist/entity-retrieval.d.ts +1 -1
  132. package/dist/entity-retrieval.js +13 -5
  133. package/dist/entity-schema.d.ts +1 -1
  134. package/dist/explicit-capture.d.ts +9 -8
  135. package/dist/extraction-faithfulness.d.ts +211 -0
  136. package/dist/extraction-faithfulness.js +35 -0
  137. package/dist/extraction-judge-telemetry.d.ts +1 -1
  138. package/dist/extraction-judge-training.d.ts +1 -1
  139. package/dist/extraction-judge.d.ts +1 -1
  140. package/dist/extraction-judge.js +4 -4
  141. package/dist/extraction.d.ts +1 -1
  142. package/dist/extraction.js +7 -7
  143. package/dist/fallback-llm.d.ts +1 -1
  144. package/dist/fallback-llm.js +2 -2
  145. package/dist/{graph-edge-decay-D7OESCBR.js → graph-edge-decay-JLOF3YW4.js} +3 -3
  146. package/dist/graph-snapshot.js +3 -3
  147. package/dist/graph.js +2 -2
  148. package/dist/identity-continuity.d.ts +1 -1
  149. package/dist/importance.d.ts +1 -1
  150. package/dist/index.d.ts +12 -11
  151. package/dist/index.js +96 -95
  152. package/dist/index.js.map +1 -1
  153. package/dist/intent.d.ts +1 -1
  154. package/dist/lcm/engine.d.ts +1 -1
  155. package/dist/lcm/index.d.ts +1 -1
  156. package/dist/lcm/index.js +3 -3
  157. package/dist/lcm/tools.d.ts +1 -1
  158. package/dist/lifecycle.d.ts +1 -1
  159. package/dist/live-connectors-runner.d.ts +1 -1
  160. package/dist/local-llm.d.ts +1 -1
  161. package/dist/local-llm.js +1 -1
  162. package/dist/maintenance/memory-governance.d.ts +1 -1
  163. package/dist/maintenance/memory-governance.js +13 -5
  164. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +13 -5
  165. package/dist/maintenance/rebuild-memory-projection.js +14 -6
  166. package/dist/mcp-memory-inspector-app.d.ts +12 -11
  167. package/dist/memory-action-policy.d.ts +1 -1
  168. package/dist/memory-cache.d.ts +1 -1
  169. package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
  170. package/dist/memory-projection-store.d.ts +1 -1
  171. package/dist/memory-provenance.d.ts +1 -1
  172. package/dist/memory-worth-outcomes.d.ts +1 -1
  173. package/dist/models-json.d.ts +1 -1
  174. package/dist/namespaces/migrate.d.ts +2 -2
  175. package/dist/namespaces/migrate.js +20 -12
  176. package/dist/namespaces/principal.d.ts +1 -1
  177. package/dist/namespaces/search.d.ts +1 -1
  178. package/dist/namespaces/search.js +6 -6
  179. package/dist/namespaces/storage.d.ts +2 -2
  180. package/dist/namespaces/storage.js +13 -5
  181. package/dist/native-knowledge.d.ts +1 -1
  182. package/dist/operator-toolkit.d.ts +1 -1
  183. package/dist/operator-toolkit.js +26 -19
  184. package/dist/orchestration/maintenance.d.ts +2 -2
  185. package/dist/orchestration/maintenance.js +15 -7
  186. package/dist/{orchestrator-CnbxRQs8.d.ts → orchestrator-Dv8JQu3-.d.ts} +20 -3
  187. package/dist/orchestrator.d.ts +6 -5
  188. package/dist/orchestrator.js +47 -46
  189. package/dist/patterns-cli.d.ts +1 -1
  190. package/dist/policy-runtime.d.ts +1 -1
  191. package/dist/provenance.d.ts +1 -1
  192. package/dist/qmd-recall-cache.d.ts +1 -1
  193. package/dist/qmd.d.ts +1 -1
  194. package/dist/recall-disclosure-escalation.d.ts +1 -1
  195. package/dist/recall-explain-renderer.d.ts +1 -1
  196. package/dist/recall-explain-renderer.js +3 -3
  197. package/dist/recall-planner-llm.d.ts +1 -1
  198. package/dist/recall-planner-llm.js +2 -2
  199. package/dist/recall-state.d.ts +1 -1
  200. package/dist/recall-tag-filter.d.ts +1 -1
  201. package/dist/recall-xray-cli.d.ts +1 -1
  202. package/dist/recall-xray-cli.js +4 -4
  203. package/dist/recall-xray-renderer.d.ts +1 -1
  204. package/dist/recall-xray-renderer.js +3 -3
  205. package/dist/recall-xray.d.ts +1 -1
  206. package/dist/recall-xray.js +2 -2
  207. package/dist/resolve-auth-token.d.ts +1 -1
  208. package/dist/resume-bundles.js +2 -2
  209. package/dist/retrieval-agents.d.ts +1 -1
  210. package/dist/retrieval-tiers.d.ts +1 -1
  211. package/dist/routing/engine.d.ts +1 -1
  212. package/dist/routing/store.d.ts +1 -1
  213. package/dist/search/embed-helper.d.ts +1 -1
  214. package/dist/search/factory.d.ts +1 -1
  215. package/dist/search/factory.js +5 -5
  216. package/dist/search/index.d.ts +1 -1
  217. package/dist/search/index.js +5 -5
  218. package/dist/search/lancedb-backend.d.ts +1 -1
  219. package/dist/search/lancedb-backend.js +2 -2
  220. package/dist/search/meilisearch-backend.d.ts +1 -1
  221. package/dist/search/meilisearch-backend.js +2 -2
  222. package/dist/search/noop-backend.d.ts +1 -1
  223. package/dist/search/orama-backend.d.ts +1 -1
  224. package/dist/search/orama-backend.js +2 -2
  225. package/dist/search/port.d.ts +1 -1
  226. package/dist/search/remote-backend.d.ts +1 -1
  227. package/dist/{semantic-consolidation-DXmtQ_4B.d.ts → semantic-consolidation-CgREi9E0.d.ts} +1 -1
  228. package/dist/semantic-consolidation.d.ts +2 -2
  229. package/dist/semantic-consolidation.js +13 -6
  230. package/dist/semantic-rule-promotion.js +13 -5
  231. package/dist/semantic-rule-verifier.d.ts +1 -1
  232. package/dist/semantic-rule-verifier.js +13 -5
  233. package/dist/session-observer-bands.d.ts +1 -1
  234. package/dist/session-observer-state.d.ts +1 -1
  235. package/dist/shared-context/manager.d.ts +1 -1
  236. package/dist/signal.d.ts +1 -1
  237. package/dist/{state-NCHQ4TRG.js → state-ITXJKXRE.js} +2 -2
  238. package/dist/storage.d.ts +11 -1
  239. package/dist/storage.js +12 -4
  240. package/dist/summarizer.d.ts +1 -1
  241. package/dist/summarizer.js +6 -6
  242. package/dist/summary-snapshot.d.ts +1 -1
  243. package/dist/temporal-supersession.d.ts +1 -1
  244. package/dist/temporal-validity.d.ts +1 -1
  245. package/dist/threading.d.ts +1 -1
  246. package/dist/tier-migration.d.ts +1 -1
  247. package/dist/tier-routing.d.ts +1 -1
  248. package/dist/topics.d.ts +1 -1
  249. package/dist/{trace-WM7V4CKI.js → trace-ZFVRH4GO.js} +3 -3
  250. package/dist/transcript.d.ts +1 -1
  251. package/dist/transfer/backup.js +2 -2
  252. package/dist/transfer/capsule-export.js +2 -2
  253. package/dist/transfer/capsule-import.js +2 -2
  254. package/dist/{tui-RI7P6PBS.js → tui-55UJMJX7.js} +3 -3
  255. package/dist/tui-55UJMJX7.js.map +1 -0
  256. package/dist/{types-CEWBmjCH.d.ts → types-CeZYSC-E.d.ts} +37 -1
  257. package/dist/types.d.ts +1 -1
  258. package/dist/types.js +1 -1
  259. package/dist/utility-runtime.d.ts +1 -1
  260. package/dist/verified-recall.js +13 -5
  261. package/package.json +2 -2
  262. package/src/config.test.ts +16 -0
  263. package/src/config.ts +30 -0
  264. package/src/console/state.ts +32 -0
  265. package/src/extraction-faithfulness.test.ts +1112 -0
  266. package/src/extraction-faithfulness.ts +993 -0
  267. package/src/faithfulness-graph-guard.test.ts +252 -0
  268. package/src/local-llm.ts +1 -0
  269. package/src/orchestrator.ts +169 -45
  270. package/src/storage.ts +19 -0
  271. package/src/types.ts +47 -0
  272. package/dist/chunk-C5AOVGVC.js.map +0 -1
  273. package/dist/chunk-NXNUG7IA.js.map +0 -1
  274. package/dist/chunk-PNW4KKJX.js.map +0 -1
  275. package/dist/chunk-TYEOAFH3.js.map +0 -1
  276. package/dist/chunk-U62ZGWU7.js.map +0 -1
  277. package/dist/chunk-UPTZYUYJ.js.map +0 -1
  278. /package/dist/{auto-sync-RWXT6P5M.js.map → auto-sync-PFW2NJZY.js.map} +0 -0
  279. /package/dist/{capsule-crypto-7FJQINUR.js.map → capsule-crypto-YO5QJ6L3.js.map} +0 -0
  280. /package/dist/{chunk-FCH766FX.js.map → chunk-32XTY2DQ.js.map} +0 -0
  281. /package/dist/{chunk-YN4ZT4CW.js.map → chunk-3LYJIA76.js.map} +0 -0
  282. /package/dist/{chunk-24FCDBJV.js.map → chunk-3P2XEQSV.js.map} +0 -0
  283. /package/dist/{chunk-JIKBEDSB.js.map → chunk-4NNDJOAO.js.map} +0 -0
  284. /package/dist/{chunk-EPVIBPVE.js.map → chunk-52QIAN76.js.map} +0 -0
  285. /package/dist/{chunk-TAPSNIMA.js.map → chunk-5CEJH5ZN.js.map} +0 -0
  286. /package/dist/{chunk-4RGWCRIJ.js.map → chunk-5TYA4OCF.js.map} +0 -0
  287. /package/dist/{chunk-PILLH262.js.map → chunk-6GAUF4OU.js.map} +0 -0
  288. /package/dist/{chunk-QXWQ5V24.js.map → chunk-AF6DSAWD.js.map} +0 -0
  289. /package/dist/{chunk-2NLLXCJG.js.map → chunk-BXLOS5AJ.js.map} +0 -0
  290. /package/dist/{chunk-32KD5IHZ.js.map → chunk-CLXTDOKV.js.map} +0 -0
  291. /package/dist/{chunk-ARV3AUOM.js.map → chunk-DL6H3D7S.js.map} +0 -0
  292. /package/dist/{chunk-X7Y7WX73.js.map → chunk-DQEMWVMT.js.map} +0 -0
  293. /package/dist/{chunk-KCRV3HVN.js.map → chunk-EC3A6M43.js.map} +0 -0
  294. /package/dist/{chunk-YI3KC2B5.js.map → chunk-FZMH66NT.js.map} +0 -0
  295. /package/dist/{chunk-DT2ZGQT7.js.map → chunk-G663XUEG.js.map} +0 -0
  296. /package/dist/{chunk-CDOIDOTQ.js.map → chunk-HKET3ZOI.js.map} +0 -0
  297. /package/dist/{chunk-YOI3ELXF.js.map → chunk-IF362TF6.js.map} +0 -0
  298. /package/dist/{chunk-UX5QTAFE.js.map → chunk-J45OCIVH.js.map} +0 -0
  299. /package/dist/{chunk-UDJLF3BO.js.map → chunk-JI6HWBYL.js.map} +0 -0
  300. /package/dist/{chunk-ECZPWSB5.js.map → chunk-JJT3AGL4.js.map} +0 -0
  301. /package/dist/{chunk-6C4WHMKV.js.map → chunk-JMAKCWJ3.js.map} +0 -0
  302. /package/dist/{chunk-4NFVPDIL.js.map → chunk-LM6JB2EG.js.map} +0 -0
  303. /package/dist/{chunk-MOGFK2DU.js.map → chunk-LPIWEKZ3.js.map} +0 -0
  304. /package/dist/{chunk-LD53WPMU.js.map → chunk-LQ6JI4VH.js.map} +0 -0
  305. /package/dist/{chunk-IZLNJYLX.js.map → chunk-MNFAGE5D.js.map} +0 -0
  306. /package/dist/{chunk-5A35EGHM.js.map → chunk-N4PD5IY3.js.map} +0 -0
  307. /package/dist/{chunk-PQP6MFPL.js.map → chunk-NFMDHE45.js.map} +0 -0
  308. /package/dist/{chunk-WVTNZUER.js.map → chunk-NLZO5NO6.js.map} +0 -0
  309. /package/dist/{chunk-5S53ZBPH.js.map → chunk-Q6ZHGGNJ.js.map} +0 -0
  310. /package/dist/{chunk-SN5DFE34.js.map → chunk-Q7SFJURX.js.map} +0 -0
  311. /package/dist/{chunk-MXZWKOOR.js.map → chunk-QJM2XAJD.js.map} +0 -0
  312. /package/dist/{chunk-6ZNIUGXB.js.map → chunk-QQW2LIEA.js.map} +0 -0
  313. /package/dist/{chunk-ZKD3UVEO.js.map → chunk-QVTSLRKT.js.map} +0 -0
  314. /package/dist/{chunk-YXZPKBFW.js.map → chunk-SPVIG2R3.js.map} +0 -0
  315. /package/dist/{chunk-B55EAZNF.js.map → chunk-TIVZ4MCG.js.map} +0 -0
  316. /package/dist/{chunk-5KBNNUXS.js.map → chunk-TT7BJWGI.js.map} +0 -0
  317. /package/dist/{chunk-KQAFEZQX.js.map → chunk-VDX2J7OX.js.map} +0 -0
  318. /package/dist/{chunk-E6BOD3UJ.js.map → chunk-VPAFWWCL.js.map} +0 -0
  319. /package/dist/{chunk-D7PQ7HZL.js.map → chunk-W54EAJT4.js.map} +0 -0
  320. /package/dist/{chunk-QVG4LAQO.js.map → chunk-WR6KFJKA.js.map} +0 -0
  321. /package/dist/{chunk-YQGHW5ML.js.map → chunk-WRFKZEO6.js.map} +0 -0
  322. /package/dist/{chunk-SJGTY2TW.js.map → chunk-XUNQLJT2.js.map} +0 -0
  323. /package/dist/{chunk-P7IDNF7H.js.map → chunk-XWT4JXXA.js.map} +0 -0
  324. /package/dist/{chunk-MSUA4KAK.js.map → chunk-XXA6T3O4.js.map} +0 -0
  325. /package/dist/{chunk-KF74X62T.js.map → chunk-YGKUAX2B.js.map} +0 -0
  326. /package/dist/{chunk-YWYZPKK3.js.map → chunk-YOZNTNNA.js.map} +0 -0
  327. /package/dist/{state-NCHQ4TRG.js.map → extraction-faithfulness.js.map} +0 -0
  328. /package/dist/{graph-edge-decay-D7OESCBR.js.map → graph-edge-decay-JLOF3YW4.js.map} +0 -0
  329. /package/dist/{tui-RI7P6PBS.js.map → state-ITXJKXRE.js.map} +0 -0
  330. /package/dist/{trace-WM7V4CKI.js.map → trace-ZFVRH4GO.js.map} +0 -0
@@ -0,0 +1,1112 @@
1
+ /**
2
+ * Tests for extraction-faithfulness.ts (issue #1576 PR 1).
3
+ *
4
+ * All tests stub the LLM adapter — no network, no GPU. The stub signatures
5
+ * match the production LocalLlmClient / FallbackLlmClient interfaces so
6
+ * these tests exercise the real call→parse→tag path.
7
+ */
8
+
9
+ import assert from "node:assert/strict";
10
+ import test from "node:test";
11
+
12
+ import { parseConfig } from "./config.js";
13
+ import {
14
+ checkFaithfulnessBatch,
15
+ parseFaithfulnessResponse,
16
+ serializeFaithfulnessFields,
17
+ parseFaithfulnessField,
18
+ runFaithfulnessGateBatch,
19
+ applyFaithfulnessVerdict,
20
+ createFaithfulnessCounters,
21
+ locateFactQuote,
22
+ FAITHFULNESS_PROMPT_HASH,
23
+ extractContextWindow,
24
+ } from "./extraction-faithfulness.js";
25
+ import type { FaithfulnessResult } from "./extraction-faithfulness.js";
26
+ import type { FallbackLlmClient } from "./fallback-llm.js";
27
+ import type { LocalLlmClient } from "./local-llm.js";
28
+ import type { MemoryFrontmatter } from "./types.js";
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Helpers — stub LLM clients
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /**
35
+ * Create a stub FallbackLlmClient that returns the given content.
36
+ * The stub's call signature matches the real client.
37
+ */
38
+ function stubFallbackLlm(
39
+ content: string | null,
40
+ captured?: Array<{ messages: unknown; options: unknown }>,
41
+ modelUsed = "stub-model",
42
+ ): FallbackLlmClient {
43
+ return {
44
+ isAvailable: () => true,
45
+ chatCompletion: async (
46
+ messages: Array<{ role: string; content: string }>,
47
+ options: Record<string, unknown>,
48
+ ) => {
49
+ if (captured) captured.push({ messages, options });
50
+ if (content === null) return null;
51
+ return { content, modelUsed, usage: undefined };
52
+ },
53
+ } as unknown as FallbackLlmClient;
54
+ }
55
+
56
+ /**
57
+ * Create a stub FallbackLlmClient that throws on call (simulates network error).
58
+ */
59
+ function throwingFallbackLlm(error: Error): FallbackLlmClient {
60
+ return {
61
+ isAvailable: () => true,
62
+ chatCompletion: async () => {
63
+ throw error;
64
+ },
65
+ } as unknown as FallbackLlmClient;
66
+ }
67
+
68
+ /**
69
+ * Create a stub FallbackLlmClient that never resolves within the test timeout
70
+ * (simulates a hung request). Respects the gate's AbortSignal: when the
71
+ * signal fires, the stub throws — mirroring a real fetch that is aborted by
72
+ * the gate timer. (The previous stub ignored the signal and returned "[]"
73
+ * after the delay, which is unrealistic and masked the timeout-race fix.)
74
+ */
75
+ function slowFallbackLlm(delayMs: number): FallbackLlmClient {
76
+ return {
77
+ isAvailable: () => true,
78
+ chatCompletion: async (
79
+ _messages: Array<{ role: string; content: string }>,
80
+ options: Record<string, unknown> = {},
81
+ ) => {
82
+ const signal = options.signal as AbortSignal | undefined;
83
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
84
+ const timer = setTimeout(resolve, delayMs);
85
+ if (signal) {
86
+ if (signal.aborted) {
87
+ clearTimeout(timer);
88
+ throw new Error("aborted");
89
+ }
90
+ signal.addEventListener(
91
+ "abort",
92
+ () => {
93
+ clearTimeout(timer);
94
+ reject(new Error("aborted"));
95
+ },
96
+ { once: true },
97
+ );
98
+ }
99
+ await promise;
100
+ return { content: "[]", modelUsed: "slow", usage: undefined };
101
+ },
102
+ } as unknown as FallbackLlmClient;
103
+ }
104
+
105
+ /**
106
+ * Create a stub FallbackLlmClient that IGNORES the abort signal and returns
107
+ * valid content after a delay longer than the gate's timeout. This simulates
108
+ * the race the cursor review flagged (#1576): the abort timer fires
109
+ * (`timedOut = true`) but the response lands a moment later with genuine
110
+ * verdicts. The race fix must honor that content instead of discarding it as
111
+ * a timeout error.
112
+ */
113
+ function racingFallbackLlm(delayMs: number, content: string): FallbackLlmClient {
114
+ return {
115
+ isAvailable: () => true,
116
+ chatCompletion: async () => {
117
+ const { promise, resolve } = Promise.withResolvers<void>();
118
+ setTimeout(resolve, delayMs);
119
+ await promise;
120
+ return { content, modelUsed: "racing", usage: undefined };
121
+ },
122
+ } as unknown as FallbackLlmClient;
123
+ }
124
+
125
+ function baseConfig(): ReturnType<typeof parseConfig> {
126
+ return parseConfig({
127
+ extractionFaithfulnessTimeoutMs: 200,
128
+ extractionFaithfulnessContextChars: 400,
129
+ });
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // parseFaithfulnessResponse — pure, no LLM
134
+ // ---------------------------------------------------------------------------
135
+
136
+ test("parseFaithfulnessResponse: valid array with all three verdicts", () => {
137
+ const raw = JSON.stringify([
138
+ { index: 0, verdict: "entailed", rationale: "direct paraphrase" },
139
+ { index: 1, verdict: "contradicted", rationale: "opposite meaning" },
140
+ { index: 2, verdict: "unsupported", rationale: "not in quote" },
141
+ ]);
142
+ const map = parseFaithfulnessResponse(raw, 3);
143
+ assert.ok(map);
144
+ assert.equal(map.size, 3);
145
+ assert.equal(map.get(0)?.verdict, "entailed");
146
+ assert.equal(map.get(1)?.verdict, "contradicted");
147
+ assert.equal(map.get(2)?.verdict, "unsupported");
148
+ });
149
+
150
+ test("parseFaithfulnessResponse: partial array (missing index → null map if none valid)", () => {
151
+ const raw = JSON.stringify([
152
+ { index: 0, verdict: "entailed" },
153
+ { index: 2, verdict: "unsupported" },
154
+ ]);
155
+ const map = parseFaithfulnessResponse(raw, 3);
156
+ assert.ok(map);
157
+ assert.equal(map.size, 2);
158
+ assert.equal(map.get(0)?.verdict, "entailed");
159
+ assert.equal(map.get(2)?.verdict, "unsupported");
160
+ assert.ok(!map.has(1));
161
+ });
162
+
163
+ test("parseFaithfulnessResponse: empty string → null", () => {
164
+ assert.equal(parseFaithfulnessResponse("", 1), null);
165
+ assert.equal(parseFaithfulnessResponse(" ", 1), null);
166
+ });
167
+
168
+ test("parseFaithfulnessResponse: malformed JSON → null", () => {
169
+ assert.equal(parseFaithfulnessResponse("maybe?", 1), null);
170
+ assert.equal(parseFaithfulnessResponse("{broken", 1), null);
171
+ assert.equal(parseFaithfulnessResponse("I think it's entailed", 1), null);
172
+ });
173
+
174
+ test("parseFaithfulnessResponse: valid JSON but wrong shape → null", () => {
175
+ assert.equal(parseFaithfulnessResponse('{"key": "value"}', 1), null);
176
+ assert.equal(parseFaithfulnessResponse("[]", 1), null);
177
+ assert.equal(parseFaithfulnessResponse('["not", "objects"]', 1), null);
178
+ });
179
+
180
+ test("parseFaithfulnessResponse: entries with invalid verdict strings are dropped", () => {
181
+ const raw = JSON.stringify([
182
+ { index: 0, verdict: "entailed" },
183
+ { index: 1, verdict: "maybe" },
184
+ { index: 2, verdict: "unsupported" },
185
+ ]);
186
+ const map = parseFaithfulnessResponse(raw, 3);
187
+ assert.ok(map);
188
+ assert.equal(map.size, 2);
189
+ assert.ok(map.has(0));
190
+ assert.ok(!map.has(1));
191
+ assert.ok(map.has(2));
192
+ });
193
+
194
+ test("parseFaithfulnessResponse: index out of range is dropped", () => {
195
+ const raw = JSON.stringify([
196
+ { index: 0, verdict: "entailed" },
197
+ { index: 99, verdict: "unsupported" },
198
+ ]);
199
+ const map = parseFaithfulnessResponse(raw, 2);
200
+ assert.ok(map);
201
+ assert.equal(map.size, 1);
202
+ assert.ok(map.has(0));
203
+ assert.ok(!map.has(99));
204
+ });
205
+
206
+ test("parseFaithfulnessResponse: rationale is capped at 500 chars", () => {
207
+ const longRationale = "A".repeat(600);
208
+ const raw = JSON.stringify([
209
+ { index: 0, verdict: "entailed", rationale: longRationale },
210
+ ]);
211
+ const map = parseFaithfulnessResponse(raw, 1);
212
+ assert.ok(map);
213
+ assert.equal(map.get(0)?.rationale?.length, 500);
214
+ });
215
+
216
+ test("parseFaithfulnessResponse: JSON embedded in fenced code block", () => {
217
+ const raw = "```json\n" +
218
+ ' [{"index": 0, "verdict": "entailed", "rationale": "ok"}]\n' +
219
+ " ```";
220
+ const map = parseFaithfulnessResponse(raw, 1);
221
+ assert.ok(map);
222
+ assert.equal(map.get(0)?.verdict, "entailed");
223
+ });
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // checkFaithfulnessBatch — entailed / contradicted / unsupported fixtures
227
+ // ---------------------------------------------------------------------------
228
+
229
+ test("checkFaithfulnessBatch: entailed — paraphrase of duration", async () => {
230
+ const inputs = [{
231
+ factText: "The user has used Vim for approximately a decade.",
232
+ quote: "I've been using Vim for about ten years now.",
233
+ }];
234
+ const llm = JSON.stringify([
235
+ { index: 0, verdict: "entailed", rationale: "Paraphrase of the same duration." },
236
+ ]);
237
+ const result = await checkFaithfulnessBatch(inputs, baseConfig(), null, stubFallbackLlm(llm));
238
+ assert.equal(result.results.length, 1);
239
+ assert.equal(result.results[0]?.ok, true);
240
+ if (result.results[0]?.ok) {
241
+ assert.equal(result.results[0].verdict, "entailed");
242
+ assert.ok(result.results[0].rationale);
243
+ }
244
+ });
245
+
246
+ test("checkFaithfulnessBatch: contradicted — negation (stopped vs active)", async () => {
247
+ const inputs = [{
248
+ factText: "The user drinks coffee regularly.",
249
+ quote: "I stopped drinking coffee last month.",
250
+ }];
251
+ const llm = JSON.stringify([
252
+ { index: 0, verdict: "contradicted", rationale: "Quote negates the fact." },
253
+ ]);
254
+ const result = await checkFaithfulnessBatch(inputs, baseConfig(), null, stubFallbackLlm(llm));
255
+ assert.equal(result.results[0]?.ok, true);
256
+ if (result.results[0]?.ok) {
257
+ assert.equal(result.results[0].verdict, "contradicted");
258
+ }
259
+ });
260
+
261
+ test("checkFaithfulnessBatch: unsupported — entity swap (Vim → Emacs)", async () => {
262
+ const inputs = [{
263
+ factText: "The user prefers Emacs.",
264
+ quote: "I've been using Vim for about ten years now.",
265
+ }];
266
+ const llm = JSON.stringify([
267
+ { index: 0, verdict: "unsupported", rationale: "Quote mentions Vim, not Emacs." },
268
+ ]);
269
+ const result = await checkFaithfulnessBatch(inputs, baseConfig(), null, stubFallbackLlm(llm));
270
+ assert.equal(result.results[0]?.ok, true);
271
+ if (result.results[0]?.ok) {
272
+ assert.equal(result.results[0].verdict, "unsupported");
273
+ }
274
+ });
275
+
276
+ test("checkFaithfulnessBatch: contradicted — date shift", async () => {
277
+ const inputs = [{
278
+ factText: "The project started in January 2024.",
279
+ quote: "We kicked off the project in March 2024.",
280
+ }];
281
+ const llm = JSON.stringify([
282
+ { index: 0, verdict: "contradicted", rationale: "January vs March." },
283
+ ]);
284
+ const result = await checkFaithfulnessBatch(inputs, baseConfig(), null, stubFallbackLlm(llm));
285
+ assert.equal(result.results[0]?.ok, true);
286
+ if (result.results[0]?.ok) {
287
+ assert.equal(result.results[0].verdict, "contradicted");
288
+ }
289
+ });
290
+
291
+ test("checkFaithfulnessBatch: contradicted — quantity change", async () => {
292
+ const inputs = [{
293
+ factText: "The team has 50 engineers.",
294
+ quote: "We have a team of 12 engineers.",
295
+ }];
296
+ const llm = JSON.stringify([
297
+ { index: 0, verdict: "contradicted", rationale: "50 vs 12." },
298
+ ]);
299
+ const result = await checkFaithfulnessBatch(inputs, baseConfig(), null, stubFallbackLlm(llm));
300
+ assert.equal(result.results[0]?.ok, true);
301
+ if (result.results[0]?.ok) {
302
+ assert.equal(result.results[0].verdict, "contradicted");
303
+ }
304
+ });
305
+
306
+ test("checkFaithfulnessBatch: unsupported — unrelated quote", async () => {
307
+ const inputs = [{
308
+ factText: "The user lives in Tokyo.",
309
+ quote: "I love playing chess on weekends.",
310
+ }];
311
+ const llm = JSON.stringify([
312
+ { index: 0, verdict: "unsupported", rationale: "No location info in quote." },
313
+ ]);
314
+ const result = await checkFaithfulnessBatch(inputs, baseConfig(), null, stubFallbackLlm(llm));
315
+ assert.equal(result.results[0]?.ok, true);
316
+ if (result.results[0]?.ok) {
317
+ assert.equal(result.results[0].verdict, "unsupported");
318
+ }
319
+ });
320
+
321
+ test("checkFaithfulnessBatch: entailed — pronoun resolution", async () => {
322
+ const inputs = [{
323
+ factText: "Alice is a senior engineer at Acme Corp.",
324
+ quote: "Alice is a senior engineer at Acme Corp.",
325
+ context: "We were discussing the team structure.",
326
+ }];
327
+ const llm = JSON.stringify([
328
+ { index: 0, verdict: "entailed", rationale: "Exact match." },
329
+ ]);
330
+ const result = await checkFaithfulnessBatch(inputs, baseConfig(), null, stubFallbackLlm(llm));
331
+ assert.equal(result.results[0]?.ok, true);
332
+ if (result.results[0]?.ok) {
333
+ assert.equal(result.results[0].verdict, "entailed");
334
+ }
335
+ });
336
+
337
+ test("checkFaithfulnessBatch: batch of 3 — all processed in one LLM call", async () => {
338
+ const inputs = [
339
+ { factText: "Fact A", quote: "Quote A" },
340
+ { factText: "Fact B", quote: "Quote B" },
341
+ { factText: "Fact C", quote: "Quote C" },
342
+ ];
343
+ const llm = JSON.stringify([
344
+ { index: 0, verdict: "entailed" },
345
+ { index: 1, verdict: "contradicted" },
346
+ { index: 2, verdict: "unsupported" },
347
+ ]);
348
+ const captured: Array<{ messages: unknown; options: unknown }> = [];
349
+ const result = await checkFaithfulnessBatch(
350
+ inputs, baseConfig(), null, stubFallbackLlm(llm, captured),
351
+ );
352
+ assert.equal(captured.length, 1, "should make exactly one LLM call for the batch");
353
+ assert.equal(result.results.length, 3);
354
+ assert.equal(result.results[0]?.ok && result.results[0].verdict, "entailed");
355
+ assert.equal(result.results[1]?.ok && result.results[1].verdict, "contradicted");
356
+ assert.equal(result.results[2]?.ok && result.results[2].verdict, "unsupported");
357
+ });
358
+
359
+ // ---------------------------------------------------------------------------
360
+ // checkFaithfulnessBatch — error paths
361
+ // ---------------------------------------------------------------------------
362
+
363
+ test("checkFaithfulnessBatch: empty quote → no_span", async () => {
364
+ const inputs = [{ factText: "Some fact", quote: "" }];
365
+ const result = await checkFaithfulnessBatch(inputs, baseConfig(), null, stubFallbackLlm("[]"));
366
+ assert.equal(result.results[0]?.ok, false);
367
+ if (!result.results[0]?.ok) {
368
+ assert.equal(result.results[0].error.code, "no_span");
369
+ }
370
+ });
371
+
372
+ test("checkFaithfulnessBatch: missing quote property → no_span", async () => {
373
+ const inputs = [{ factText: "Some fact", quote: " " }];
374
+ const result = await checkFaithfulnessBatch(inputs, baseConfig(), null, stubFallbackLlm("[]"));
375
+ assert.equal(result.results[0]?.ok, false);
376
+ if (!result.results[0]?.ok) {
377
+ assert.equal(result.results[0].error.code, "no_span");
378
+ }
379
+ });
380
+
381
+ test("checkFaithfulnessBatch: empty factText → no_span", async () => {
382
+ const inputs = [{ factText: "", quote: "Some quote" }];
383
+ const result = await checkFaithfulnessBatch(inputs, baseConfig(), null, stubFallbackLlm("[]"));
384
+ assert.equal(result.results[0]?.ok, false);
385
+ if (!result.results[0]?.ok) {
386
+ assert.equal(result.results[0].error.code, "no_span");
387
+ }
388
+ });
389
+
390
+ test("checkFaithfulnessBatch: backend returns null → backend_unavailable", async () => {
391
+ const inputs = [{ factText: "Fact", quote: "Quote" }];
392
+ const result = await checkFaithfulnessBatch(inputs, baseConfig(), null, stubFallbackLlm(null));
393
+ assert.equal(result.results[0]?.ok, false);
394
+ if (!result.results[0]?.ok) {
395
+ assert.equal(result.results[0].error.code, "backend_unavailable");
396
+ }
397
+ });
398
+
399
+ test("checkFaithfulnessBatch: backend throws → backend_unavailable", async () => {
400
+ const inputs = [{ factText: "Fact", quote: "Quote" }];
401
+ const result = await checkFaithfulnessBatch(
402
+ inputs, baseConfig(), null, throwingFallbackLlm(new Error("ECONNREFUSED")),
403
+ );
404
+ assert.equal(result.results[0]?.ok, false);
405
+ if (!result.results[0]?.ok) {
406
+ assert.equal(result.results[0].error.code, "backend_unavailable");
407
+ }
408
+ });
409
+
410
+ test("checkFaithfulnessBatch: malformed LLM output → malformed_output", async () => {
411
+ const inputs = [{ factText: "Fact", quote: "Quote" }];
412
+ const result = await checkFaithfulnessBatch(
413
+ inputs, baseConfig(), null, stubFallbackLlm("maybe? I think so."),
414
+ );
415
+ assert.equal(result.results[0]?.ok, false);
416
+ if (!result.results[0]?.ok) {
417
+ assert.equal(result.results[0].error.code, "malformed_output");
418
+ }
419
+ });
420
+
421
+ test("checkFaithfulnessBatch: timeout → tagged timeout, not a crash", async () => {
422
+ const inputs = [{ factText: "Fact", quote: "Quote" }];
423
+ // Use a very short timeout and a slow LLM to force the abort path.
424
+ const config = parseConfig({ extractionFaithfulnessTimeoutMs: 50 });
425
+ const result = await checkFaithfulnessBatch(
426
+ inputs, config, null, slowFallbackLlm(5000),
427
+ );
428
+ assert.equal(result.results[0]?.ok, false);
429
+ if (!result.results[0]?.ok) {
430
+ assert.equal(result.results[0].error.code, "timeout");
431
+ }
432
+ });
433
+
434
+ test("checkFaithfulnessBatch: content landing within the budget is used; content past the budget fails open as timeout", async () => {
435
+ // The batch races the LLM call against extractionFaithfulnessTimeoutMs so a
436
+ // wedged/slow backend cannot hold the extraction flush past the budget
437
+ // (codex review PRRT_kwDORJXyws6ObgMJ). Content that lands WITHIN the budget
438
+ // is used; content that lands AFTER the budget fails open as timeout.
439
+ const inputs = [{ factText: "Fact", quote: "Quote" }];
440
+ const config = parseConfig({ extractionFaithfulnessTimeoutMs: 50 });
441
+ const llm = JSON.stringify([{ index: 0, verdict: "entailed" }]);
442
+
443
+ // Within budget (30ms < 50ms): the real verdict is used.
444
+ const within = await checkFaithfulnessBatch(inputs, config, null, racingFallbackLlm(30, llm));
445
+ assert.equal(within.results[0]?.ok, true, "content within the budget must be used");
446
+ if (within.results[0]?.ok) {
447
+ assert.equal(within.results[0].verdict, "entailed");
448
+ }
449
+
450
+ // Past budget (80ms > 50ms): fail open as timeout, do not block the flush.
451
+ const past = await checkFaithfulnessBatch(inputs, config, null, racingFallbackLlm(80, llm));
452
+ assert.equal(past.results[0]?.ok, false, "content past the budget fails open as timeout");
453
+ if (!past.results[0]?.ok) {
454
+ assert.equal(past.results[0].error.code, "timeout");
455
+ }
456
+ });
457
+
458
+ test("checkFaithfulnessBatch: LLM returns partial results — missing index → malformed_output", async () => {
459
+ const inputs = [
460
+ { factText: "Fact A", quote: "Quote A" },
461
+ { factText: "Fact B", quote: "Quote B" },
462
+ ];
463
+ // LLM only returns index 0, missing index 1
464
+ const llm = JSON.stringify([{ index: 0, verdict: "entailed" }]);
465
+ const result = await checkFaithfulnessBatch(inputs, baseConfig(), null, stubFallbackLlm(llm));
466
+ assert.equal(result.results.length, 2);
467
+ assert.equal(result.results[0]?.ok && result.results[0].verdict, "entailed");
468
+ assert.equal(result.results[1]?.ok, false);
469
+ if (!result.results[1]?.ok) {
470
+ assert.equal(result.results[1].error.code, "malformed_output");
471
+ }
472
+ });
473
+
474
+ test("checkFaithfulnessBatch: no inputs with quotes → no LLM call, all no_span", async () => {
475
+ const inputs = [
476
+ { factText: "Fact A", quote: "" },
477
+ { factText: "Fact B", quote: "" },
478
+ ];
479
+ const captured: Array<{ messages: unknown; options: unknown }> = [];
480
+ const result = await checkFaithfulnessBatch(
481
+ inputs, baseConfig(), null, stubFallbackLlm("[]", captured),
482
+ );
483
+ assert.equal(captured.length, 0, "no LLM call when all inputs lack quotes");
484
+ assert.equal(result.elapsedMs, 0);
485
+ assert.equal(result.results[0]?.ok, false);
486
+ assert.equal(result.results[1]?.ok, false);
487
+ });
488
+
489
+ test("checkFaithfulnessBatch: mixed — some with quotes, some without", async () => {
490
+ const inputs = [
491
+ { factText: "Fact A", quote: "Quote A" },
492
+ { factText: "Fact B", quote: "" },
493
+ { factText: "Fact C", quote: "Quote C" },
494
+ ];
495
+ const llm = JSON.stringify([
496
+ { index: 0, verdict: "entailed" },
497
+ { index: 1, verdict: "unsupported" }, // maps to checkable index 1 (Fact C)
498
+ ]);
499
+ const result = await checkFaithfulnessBatch(inputs, baseConfig(), null, stubFallbackLlm(llm));
500
+ assert.equal(result.results.length, 3);
501
+ // Index 0: entailed
502
+ assert.equal(result.results[0]?.ok && result.results[0].verdict, "entailed");
503
+ // Index 1: no_span (empty quote)
504
+ assert.equal(result.results[1]?.ok, false);
505
+ if (!result.results[1]?.ok) {
506
+ assert.equal(result.results[1].error.code, "no_span");
507
+ }
508
+ // Index 2: unsupported (second checkable item)
509
+ assert.equal(result.results[2]?.ok && result.results[2].verdict, "unsupported");
510
+ });
511
+
512
+ test("checkFaithfulnessBatch: elapsedMs is non-negative", async () => {
513
+ const inputs = [{ factText: "Fact", quote: "Quote" }];
514
+ const result = await checkFaithfulnessBatch(
515
+ inputs, baseConfig(), null, stubFallbackLlm(JSON.stringify([{ index: 0, verdict: "entailed" }])),
516
+ );
517
+ assert.ok(result.elapsedMs >= 0);
518
+ });
519
+
520
+ // ---------------------------------------------------------------------------
521
+ // Prompt hash — stable for caching
522
+ // ---------------------------------------------------------------------------
523
+
524
+ test("FAITHFULNESS_PROMPT_HASH is a 64-char hex string", () => {
525
+ assert.equal(FAITHFULNESS_PROMPT_HASH.length, 64);
526
+ assert.match(FAITHFULNESS_PROMPT_HASH, /^[0-9a-f]{64}$/);
527
+ });
528
+
529
+ // ---------------------------------------------------------------------------
530
+ // Frontmatter serialization round-trip
531
+ // ---------------------------------------------------------------------------
532
+
533
+ test("serializeFaithfulnessFields: absent field → no line emitted (byte-identical)", () => {
534
+ const lines: string[] = [];
535
+ const fm: MemoryFrontmatter = {
536
+ id: "test",
537
+ category: "preference",
538
+ created: "2026-01-01T00:00:00Z",
539
+ updated: "2026-01-01T00:00:00Z",
540
+ source: "test",
541
+ confidence: 0.9,
542
+ confidenceTier: "explicit",
543
+ tags: [],
544
+ };
545
+ serializeFaithfulnessFields(fm, lines);
546
+ assert.equal(lines.length, 0);
547
+ });
548
+
549
+ test("serializeFaithfulnessFields: present field → single JSON line", () => {
550
+ const lines: string[] = [];
551
+ const fm: MemoryFrontmatter = {
552
+ id: "test",
553
+ category: "preference",
554
+ created: "2026-01-01T00:00:00Z",
555
+ updated: "2026-01-01T00:00:00Z",
556
+ source: "test",
557
+ confidence: 0.9,
558
+ confidenceTier: "explicit",
559
+ tags: [],
560
+ faithfulness: { verdict: "entailed", model: "glm-4.7-flash", rationale: "ok" },
561
+ };
562
+ serializeFaithfulnessFields(fm, lines);
563
+ assert.equal(lines.length, 1);
564
+ assert.match(lines[0]!, /^faithfulness: /);
565
+ const parsed = JSON.parse(lines[0]!.replace(/^faithfulness: /, ""));
566
+ assert.equal(parsed.verdict, "entailed");
567
+ assert.equal(parsed.model, "glm-4.7-flash");
568
+ });
569
+
570
+ test("parseFaithfulnessField: round-trip", () => {
571
+ const fm: MemoryFrontmatter = {
572
+ id: "test",
573
+ category: "preference",
574
+ created: "2026-01-01T00:00:00Z",
575
+ updated: "2026-01-01T00:00:00Z",
576
+ source: "test",
577
+ confidence: 0.9,
578
+ confidenceTier: "explicit",
579
+ tags: [],
580
+ faithfulness: { verdict: "contradicted", model: "m", rationale: "r", at: "2026-01-01T00:00:00Z" },
581
+ };
582
+ const lines: string[] = [];
583
+ serializeFaithfulnessFields(fm, lines);
584
+ const raw = lines[0]!.replace(/^faithfulness: /, "");
585
+ const parsed = parseFaithfulnessField(raw);
586
+ assert.deepEqual(parsed, { verdict: "contradicted", model: "m", rationale: "r", at: "2026-01-01T00:00:00Z" });
587
+ });
588
+
589
+ test("parseFaithfulnessField: undefined / blank / corrupt → undefined", () => {
590
+ assert.equal(parseFaithfulnessField(undefined), undefined);
591
+ assert.equal(parseFaithfulnessField(""), undefined);
592
+ assert.equal(parseFaithfulnessField(" "), undefined);
593
+ assert.equal(parseFaithfulnessField("{broken json"), undefined);
594
+ assert.equal(parseFaithfulnessField('"a string"'), undefined);
595
+ assert.equal(parseFaithfulnessField("[]"), undefined);
596
+ assert.equal(parseFaithfulnessField('{}'), undefined);
597
+ assert.equal(parseFaithfulnessField('{"verdict": "maybe"}'), undefined);
598
+ });
599
+
600
+ test("parseFaithfulnessField: accepts all valid verdict values", () => {
601
+ for (const v of ["entailed", "contradicted", "unsupported", "unchecked", "skipped_no_span"]) {
602
+ const parsed = parseFaithfulnessField(JSON.stringify({ verdict: v }));
603
+ assert.equal(parsed?.verdict, v);
604
+ }
605
+ });
606
+
607
+ test("serializeFaithfulnessFields: empty verdict omitted → no line", () => {
608
+ const lines: string[] = [];
609
+ const fm = {
610
+ faithfulness: { verdict: "" as "entailed" },
611
+ } as MemoryFrontmatter;
612
+ serializeFaithfulnessFields(fm, lines);
613
+ assert.equal(lines.length, 0);
614
+ });
615
+
616
+ // ---------------------------------------------------------------------------
617
+ // runFaithfulnessGateBatch / applyFaithfulnessVerdict — orchestration helpers
618
+ // (issue #1576). These are the orchestrator-facing API; the substantive
619
+ // algorithm is covered by the checkFaithfulnessBatch tests above.
620
+ // ---------------------------------------------------------------------------
621
+
622
+ test("createFaithfulnessCounters: returns zeroed counters", () => {
623
+ assert.deepEqual(createFaithfulnessCounters(), {
624
+ entailed: 0,
625
+ contradicted: 0,
626
+ unsupported: 0,
627
+ unchecked: 0,
628
+ skippedNoSpan: 0,
629
+ });
630
+ });
631
+
632
+ test("runFaithfulnessGateBatch: facts with no span and no sourceText match skipped", async () => {
633
+ const facts = [
634
+ { content: "Fact with no sources" },
635
+ { content: "Another", sources: [] },
636
+ ];
637
+ const captured: Array<{ messages: unknown; options: unknown }> = [];
638
+ const counters = createFaithfulnessCounters();
639
+ const result = await runFaithfulnessGateBatch(
640
+ facts,
641
+ "shadow",
642
+ baseConfig(),
643
+ null,
644
+ stubFallbackLlm("[]", captured),
645
+ counters,
646
+ "", // no source text → no fallback quote
647
+ );
648
+ assert.equal(captured.length, 0, "no LLM call when no facts have a located quote");
649
+ assert.ok(result instanceof Map, "returns an empty map");
650
+ assert.equal(result.size, 0);
651
+ assert.equal(counters.entailed, 0);
652
+ });
653
+
654
+ test("runFaithfulnessGateBatch: locateFactQuote fallback feeds the gate when no #1575 sources", async () => {
655
+ // Fact has no `sources` but the source text contains a matching sentence →
656
+ // locateFactQuote finds it so the gate actually runs (P1 fix).
657
+ const facts = [{ content: "The user prefers Vim." }];
658
+ const llm = JSON.stringify([{ index: 0, verdict: "entailed" }]);
659
+ const counters = createFaithfulnessCounters();
660
+ const result = await runFaithfulnessGateBatch(
661
+ facts,
662
+ "shadow",
663
+ baseConfig(),
664
+ null,
665
+ stubFallbackLlm(llm),
666
+ counters,
667
+ "I have used Vim for ten years and I really prefer Vim over Emacs.",
668
+ );
669
+ assert.ok(result instanceof Map);
670
+ assert.equal(result.size, 1);
671
+ const r0 = result.get(0);
672
+ assert.equal(r0?.ok, true);
673
+ if (r0?.ok) assert.equal(r0.verdict, "entailed");
674
+ // counters still 0 here — bumped at apply time.
675
+ assert.equal(counters.entailed, 0);
676
+ });
677
+
678
+ test("runFaithfulnessGateBatch: builds index→result map + updates counters", async () => {
679
+ // Fact 0 has a source, fact 1 does NOT, fact 2 has a source. The batch
680
+ // skips fact 1, so the LLM sees 2 inputs and results map back to the
681
+ // ORIGINAL fact indices (0 and 2), not batch positions.
682
+ const facts = [
683
+ { content: "Fact 0", sources: [{ quote: "Quote 0" }] },
684
+ { content: "Fact 1 (no sources)" },
685
+ { content: "Fact 2", sources: [{ quote: "Quote 2" }] },
686
+ ];
687
+ const llm = JSON.stringify([
688
+ { index: 0, verdict: "entailed" },
689
+ { index: 1, verdict: "contradicted" },
690
+ ]);
691
+ const counters = createFaithfulnessCounters();
692
+ const result = await runFaithfulnessGateBatch(
693
+ facts,
694
+ "shadow",
695
+ baseConfig(),
696
+ null,
697
+ stubFallbackLlm(llm),
698
+ counters,
699
+ );
700
+ assert.ok(result instanceof Map, "batch returns a map on success");
701
+ assert.equal(result.size, 2);
702
+ assert.equal(result.has(1), false, "fact without a source is absent from the map");
703
+ const r0 = result.get(0);
704
+ assert.equal(r0?.ok, true);
705
+ if (r0?.ok) assert.equal(r0.verdict, "entailed");
706
+ const r2 = result.get(2);
707
+ assert.equal(r2?.ok, true);
708
+ if (r2?.ok) assert.equal(r2.verdict, "contradicted");
709
+ // Counters are bumped at apply time (applyFaithfulnessVerdict), not here.
710
+ assert.equal(counters.entailed, 0);
711
+ assert.equal(counters.contradicted, 0);
712
+ });
713
+
714
+ test("runFaithfulnessGateBatch: backend throw → unchecked result, fail-open (checklist §4)", async () => {
715
+ // checkFaithfulnessBatch is total: it catches an LLM throw and surfaces it
716
+ // as an ok:false result rather than propagating. So the gate records the
717
+ // failure as "unchecked" and proceeds — a backend outage never blocks writes.
718
+ const facts = [{ content: "Fact", sources: [{ quote: "Quote" }] }];
719
+ const counters = createFaithfulnessCounters();
720
+ const result = await runFaithfulnessGateBatch(
721
+ facts,
722
+ "enforce",
723
+ baseConfig(),
724
+ null,
725
+ throwingFallbackLlm(new Error("ECONNREFUSED")),
726
+ counters,
727
+ );
728
+ assert.ok(result instanceof Map);
729
+ const r0 = result.get(0);
730
+ assert.equal(r0?.ok, false);
731
+ if (!r0?.ok) assert.equal(r0.error.code, "backend_unavailable");
732
+ // Counters bump at apply time (cursor review), so still 0 after the batch.
733
+ assert.equal(counters.unchecked, 0);
734
+ // Applying the verdict bumps unchecked.
735
+ const applied = applyFaithfulnessVerdict(result, 0, "enforce", "Fact", counters);
736
+ assert.equal(applied.faithfulness?.verdict, "unchecked");
737
+ assert.equal(counters.unchecked, 1);
738
+ });
739
+
740
+ test("applyFaithfulnessVerdict: gate off (null map) → no frontmatter", () => {
741
+ const counters = createFaithfulnessCounters();
742
+ const r = applyFaithfulnessVerdict(null, 0, "off", "fact", counters);
743
+ assert.equal(r.faithfulness, undefined);
744
+ assert.equal(r.enforceStatus, undefined);
745
+ assert.equal(counters.skippedNoSpan, 0);
746
+ });
747
+
748
+ test("applyFaithfulnessVerdict: fact absent from map → skipped_no_span, never gated", () => {
749
+ const counters = createFaithfulnessCounters();
750
+ const map = new Map();
751
+ const r = applyFaithfulnessVerdict(map, 5, "enforce", "legacy fact", counters);
752
+ assert.equal(r.faithfulness?.verdict, "skipped_no_span");
753
+ assert.equal(r.enforceStatus, undefined);
754
+ assert.equal(counters.skippedNoSpan, 1);
755
+ });
756
+
757
+ test("applyFaithfulnessVerdict: enforce + unsupported → pending_review (issue #1576 done-when)", () => {
758
+ const counters = createFaithfulnessCounters();
759
+ const map: Map<number, FaithfulnessResult> = new Map([[0, { ok: true, verdict: "unsupported" as const, model: "stub-model" }]]);
760
+ const r = applyFaithfulnessVerdict(map, 0, "enforce", "hallucinated fact", counters);
761
+ assert.equal(r.faithfulness?.verdict, "unsupported");
762
+ assert.equal(r.faithfulness?.model, "stub-model");
763
+ assert.equal(r.enforceStatus, "pending_review");
764
+ });
765
+
766
+ test("applyFaithfulnessVerdict: enforce + contradicted → pending_review", () => {
767
+ const counters = createFaithfulnessCounters();
768
+ const map: Map<number, FaithfulnessResult> = new Map([[0, { ok: true, verdict: "contradicted" as const, model: "stub" }]]);
769
+ const r = applyFaithfulnessVerdict(map, 0, "enforce", "wrong fact", counters);
770
+ assert.equal(r.enforceStatus, "pending_review");
771
+ });
772
+
773
+ test("applyFaithfulnessVerdict: shadow + unsupported → recorded but NOT gated", () => {
774
+ const counters = createFaithfulnessCounters();
775
+ const map: Map<number, FaithfulnessResult> = new Map([[0, { ok: true, verdict: "unsupported" as const, model: "stub" }]]);
776
+ const r = applyFaithfulnessVerdict(map, 0, "shadow", "fact", counters);
777
+ assert.equal(r.faithfulness?.verdict, "unsupported");
778
+ assert.equal(r.enforceStatus, undefined);
779
+ });
780
+
781
+ test("applyFaithfulnessVerdict: entailed → proceeds, no enforce", () => {
782
+ const counters = createFaithfulnessCounters();
783
+ const map: Map<number, FaithfulnessResult> = new Map([[0, { ok: true, verdict: "entailed" as const, model: "stub" }]]);
784
+ const r = applyFaithfulnessVerdict(map, 0, "enforce", "fact", counters);
785
+ assert.equal(r.faithfulness?.verdict, "entailed");
786
+ assert.equal(r.enforceStatus, undefined);
787
+ });
788
+
789
+ test("applyFaithfulnessVerdict: per-fact backend failure (ok:false) → unchecked, proceeds", () => {
790
+ const counters = createFaithfulnessCounters();
791
+ const map: Map<number, FaithfulnessResult> = new Map([[0, { ok: false, error: { code: "timeout" as const } }]]);
792
+ const r = applyFaithfulnessVerdict(map, 0, "enforce", "fact", counters);
793
+ assert.equal(r.faithfulness?.verdict, "unchecked");
794
+ assert.equal(r.enforceStatus, undefined);
795
+ });
796
+
797
+ test("locateFactQuote: finds the best-overlap sentence", () => {
798
+ const src = "I drove to Berlin yesterday. My favorite editor is Vim.";
799
+ assert.equal(
800
+ locateFactQuote("The user likes Vim.", src),
801
+ "My favorite editor is Vim.",
802
+ );
803
+ });
804
+
805
+ test("locateFactQuote: returns undefined below the overlap threshold", () => {
806
+ const src = "The weather is sunny. Stocks rose 2 percent.";
807
+ assert.equal(locateFactQuote("The user lives in Tokyo.", src), undefined);
808
+ });
809
+
810
+ test("locateFactQuote: empty inputs → undefined", () => {
811
+ assert.equal(locateFactQuote("", "text"), undefined);
812
+ assert.equal(locateFactQuote("fact", ""), undefined);
813
+ });
814
+
815
+ test("applyFaithfulnessVerdict: bumps verdict counters at apply time (cursor review)", () => {
816
+ const counters = createFaithfulnessCounters();
817
+ const map: Map<number, FaithfulnessResult> = new Map([
818
+ [0, { ok: true, verdict: "entailed", model: "m" }],
819
+ [1, { ok: true, verdict: "contradicted", model: "m" }],
820
+ [2, { ok: true, verdict: "unsupported", model: "m" }],
821
+ [3, { ok: false, error: { code: "timeout" } }],
822
+ ]);
823
+ applyFaithfulnessVerdict(map, 0, "enforce", "f0", counters);
824
+ applyFaithfulnessVerdict(map, 1, "enforce", "f1", counters);
825
+ applyFaithfulnessVerdict(map, 2, "enforce", "f2", counters);
826
+ applyFaithfulnessVerdict(map, 3, "enforce", "f3", counters);
827
+ assert.equal(counters.entailed, 1);
828
+ assert.equal(counters.contradicted, 1);
829
+ assert.equal(counters.unsupported, 1);
830
+ assert.equal(counters.unchecked, 1);
831
+ });
832
+
833
+
834
+ // ---------------------------------------------------------------------------
835
+ // #1576: model-override routing — codex P2 (PRRT_kwDORJXyws6ObYQ8)
836
+ // ---------------------------------------------------------------------------
837
+
838
+ test("checkFaithfulnessBatch: extractionFaithfulnessModel override routes to gateway, skips local (codex P2)", async () => {
839
+ // LocalLlmClient always sends config.localLlmModel and ignores options.model,
840
+ // so a local success would silently run the wrong model and the override
841
+ // would never reach the gateway. When an override is set the local backend
842
+ // must be skipped and the gateway fallback must receive the override model.
843
+ const inputs = [{ factText: "Fact", quote: "Quote" }];
844
+ const config = parseConfig({
845
+ extractionFaithfulnessModel: "verifier-ft-v1",
846
+ });
847
+ const localCalls: Array<{ options: unknown }> = [];
848
+ const localLlm = {
849
+ chatCompletion: async (
850
+ _messages: Array<{ role: string; content: string }>,
851
+ options: Record<string, unknown> = {},
852
+ ) => {
853
+ localCalls.push({ options });
854
+ return {
855
+ content: JSON.stringify([{ index: 0, verdict: "unsupported" }]),
856
+ };
857
+ },
858
+ };
859
+ const fallbackCalls: Array<{ messages: unknown; options: unknown }> = [];
860
+ const fallbackContent = JSON.stringify([{ index: 0, verdict: "entailed" }]);
861
+ const result = await checkFaithfulnessBatch(
862
+ inputs,
863
+ config,
864
+ localLlm as unknown as LocalLlmClient,
865
+ stubFallbackLlm(fallbackContent, fallbackCalls),
866
+ );
867
+ assert.equal(localCalls.length, 0, "local LLM must be skipped when a model override is set");
868
+ assert.equal(fallbackCalls.length, 1, "gateway fallback must run with the override");
869
+ const opts = fallbackCalls[0]?.options as Record<string, unknown>;
870
+ assert.equal(opts?.model, "verifier-ft-v1", "override model must reach the gateway");
871
+ assert.equal(result.results[0]?.ok, true);
872
+ if (result.results[0]?.ok) {
873
+ assert.equal(result.results[0].verdict, "entailed", "gateway verdict is used, not the local one");
874
+ }
875
+ });
876
+
877
+ test("checkFaithfulnessBatch: no override keeps the local backend first (regression guard)", async () => {
878
+ // Without an override, the local backend must still be tried first (the
879
+ // override-skip is conditional, not unconditional).
880
+ const inputs = [{ factText: "Fact", quote: "Quote" }];
881
+ const config = baseConfig();
882
+ const localCalls: number[] = [];
883
+ const localLlm = {
884
+ chatCompletion: async () => {
885
+ localCalls.push(1);
886
+ return { content: JSON.stringify([{ index: 0, verdict: "entailed" }]) };
887
+ },
888
+ };
889
+ const fallbackCalls: Array<{ messages: unknown; options: unknown }> = [];
890
+ const result = await checkFaithfulnessBatch(
891
+ inputs,
892
+ config,
893
+ localLlm as unknown as LocalLlmClient,
894
+ stubFallbackLlm("[]", fallbackCalls),
895
+ );
896
+ assert.equal(localCalls.length, 1, "local backend is used when no override is set");
897
+ assert.equal(fallbackCalls.length, 0, "fallback is not reached when local succeeds");
898
+ assert.equal(result.results[0]?.ok, true);
899
+ });
900
+
901
+ test("checkFaithfulnessBatch: local call is bounded by timeoutMs, not the batch signal (kilo)", async () => {
902
+ // LocalLlmClient.chatCompletion does not read options.signal — it uses its
903
+ // own per-attempt AbortController keyed on timeoutMs. The gate must forward
904
+ // timeoutMs (so each local attempt is bounded by the faithfulness budget)
905
+ // and must NOT forward the batch signal (dead code the local client drops).
906
+ const inputs = [{ factText: "Fact", quote: "Quote" }];
907
+ const config = baseConfig();
908
+ const captured: Array<{ options: Record<string, unknown> }> = [];
909
+ const localLlm = {
910
+ chatCompletion: async (
911
+ _messages: Array<{ role: string; content: string }>,
912
+ options: Record<string, unknown> = {},
913
+ ) => {
914
+ captured.push({ options });
915
+ return { content: JSON.stringify([{ index: 0, verdict: "entailed" }]) };
916
+ },
917
+ };
918
+ await checkFaithfulnessBatch(
919
+ inputs,
920
+ config,
921
+ localLlm as unknown as LocalLlmClient,
922
+ stubFallbackLlm("[]"),
923
+ );
924
+ assert.equal(captured.length, 1, "local backend runs once");
925
+ const opts = captured[0]?.options;
926
+ assert.equal(typeof opts?.timeoutMs, "number", "timeoutMs is forwarded to bound each local attempt");
927
+ assert.equal(opts?.operation, "extraction-faithfulness");
928
+ assert.equal("signal" in (opts ?? {}), false, "batch signal is not forwarded to the local client (it ignores it)");
929
+ assert.equal("model" in (opts ?? {}), false, "model override is not forwarded to the local client (it ignores it)");
930
+ });
931
+
932
+ // ---------------------------------------------------------------------------
933
+ // #1576: multi-source verification — codex P2 (PRRT_kwDORJXyws6ObYQ_)
934
+ // ---------------------------------------------------------------------------
935
+
936
+ test("runFaithfulnessGateBatch: multi-source facts verify against ALL source spans (codex P2)", async () => {
937
+ // A composite fact whose support is split across two adjacent source spans.
938
+ // Previously only sources[0] reached the verifier; the second span (the
939
+ // actual support for the date/status) was discarded, risking a false
940
+ // unsupported verdict (and a spurious pending_review in enforce mode). The
941
+ // verifier must now see the full evidence.
942
+ const facts = [
943
+ {
944
+ content: "The Acme project launched in March 2024 at beta status.",
945
+ sources: [
946
+ { quote: "We started the Acme project earlier this year." },
947
+ { quote: "It launched in March 2024 at beta status." },
948
+ ],
949
+ },
950
+ ];
951
+ const counters = createFaithfulnessCounters();
952
+ const captured: Array<{ messages: Array<{ role: string; content: string }>; options: unknown }> = [];
953
+ const llm = JSON.stringify([{ index: 0, verdict: "entailed" }]);
954
+ const result = await runFaithfulnessGateBatch(
955
+ facts,
956
+ "shadow",
957
+ baseConfig(),
958
+ null,
959
+ stubFallbackLlm(llm, captured),
960
+ counters,
961
+ );
962
+ assert.ok(result instanceof Map);
963
+ assert.equal(result.size, 1);
964
+ const r0 = result.get(0);
965
+ assert.equal(r0?.ok, true);
966
+ if (r0?.ok) assert.equal(r0.verdict, "entailed");
967
+ // The verifier prompt must include BOTH source spans, not just sources[0].
968
+ const userMsg = captured[0]?.messages.find((m) => m.role === "user");
969
+ assert.ok(
970
+ userMsg?.content.includes("We started the Acme project"),
971
+ "first source span must reach the verifier prompt",
972
+ );
973
+ assert.ok(
974
+ userMsg?.content.includes("It launched in March 2024 at beta status."),
975
+ "second source span must reach the verifier prompt (multi-source fix)",
976
+ );
977
+ });
978
+
979
+ test("checkFaithfulnessBatch: a wedged local backend that ignores the signal still fails open at the budget (codex P2)", async () => {
980
+ // LocalLlmClient ignores options.signal and aborts each attempt via its own
981
+ // controller. A wedged local verifier that never resolves within the budget
982
+ // must NOT hold the batch past extractionFaithfulnessTimeoutMs — the race
983
+ // returns timeout promptly so the extraction flush is not blocked
984
+ // (codex review PRRT_kwDORJXyws6ObgMJ).
985
+ const inputs = [{ factText: "Fact", quote: "Quote" }];
986
+ const config = parseConfig({ extractionFaithfulnessTimeoutMs: 50 });
987
+ const hungLocal = {
988
+ // Never resolves within the test window and ignores any signal.
989
+ chatCompletion: async () => {
990
+ const { promise } = Promise.withResolvers<never>();
991
+ return promise;
992
+ },
993
+ };
994
+ const startedAt = Date.now();
995
+ const result = await checkFaithfulnessBatch(
996
+ inputs, config, hungLocal as unknown as LocalLlmClient, null,
997
+ );
998
+ const elapsed = Date.now() - startedAt;
999
+ assert.equal(result.results[0]?.ok, false);
1000
+ if (!result.results[0]?.ok) {
1001
+ assert.equal(result.results[0].error.code, "timeout");
1002
+ }
1003
+ // The batch returned near the budget, not after the hung promise.
1004
+ assert.ok(elapsed < 1000, `batch must fail open near the budget, not block (elapsed=${elapsed}ms)`);
1005
+ });
1006
+
1007
+
1008
+ // ---------------------------------------------------------------------------
1009
+ // #1576: fallback-locator source context — codex P2 (PRRT_kwDORJXyws6OblI1)
1010
+ // extractionFaithfulnessContextChars must actually reach the verifier in the
1011
+ // fallback-locator path, otherwise composite facts whose support depends on
1012
+ // surrounding source text get misrouted to pending_review in enforce mode.
1013
+ // ---------------------------------------------------------------------------
1014
+
1015
+ test("extractContextWindow: returns a bounded window centered on the quote", () => {
1016
+ const sourceText =
1017
+ "We started the Acme project earlier this year. It launched in March 2024 at beta status. The team is small.";
1018
+ const quote = "It launched in March 2024 at beta status.";
1019
+ // contextChars=40 must bound the window; the quote itself is 42 chars so the
1020
+ // window is at least the quote plus a sliver of neighbors on each side.
1021
+ const ctx = extractContextWindow(sourceText, quote, 40);
1022
+ assert.ok(ctx, "expected a context window");
1023
+ assert.ok(ctx!.length <= 40, `window must be bounded by contextChars (got ${ctx!.length})`);
1024
+ assert.ok(ctx!.includes("launched"), "window must overlap the quote");
1025
+ });
1026
+
1027
+ test("extractContextWindow: grows neighbors when budget exceeds quote length", () => {
1028
+ const sourceText =
1029
+ "Earlier the project was unnamed. We started the Acme project earlier this year. Later we renamed it.";
1030
+ const quote = "We started the Acme project earlier this year.";
1031
+ const ctx = extractContextWindow(sourceText, quote, 120);
1032
+ assert.ok(ctx, "expected a context window");
1033
+ assert.ok(ctx!.length <= 120, `window bounded (got ${ctx!.length})`);
1034
+ // Neighboring sentences must be pulled in now that the budget allows it.
1035
+ assert.ok(ctx!.includes("Earlier the project was unnamed"), "preceding context included");
1036
+ assert.ok(ctx!.includes("Later we renamed it"), "following context included");
1037
+ });
1038
+
1039
+ test("extractContextWindow: returns undefined when quote is absent from sourceText", () => {
1040
+ assert.equal(extractContextWindow("some source", "not present", 400), undefined);
1041
+ assert.equal(extractContextWindow("", "x", 400), undefined);
1042
+ assert.equal(extractContextWindow("source", "s", 0), undefined);
1043
+ });
1044
+
1045
+ test("runFaithfulnessGateBatch: fallback locator injects CONTEXT into the verifier prompt (codex P2 PRRT_kwDORJXyws6OblI1)", async () => {
1046
+ // No #1575 sources → the fallback locator locates a quote from sourceText.
1047
+ // Previously the input never set `context`, so the surrounding turn text
1048
+ // (and thus extractionFaithfulnessContextChars) was ignored in production.
1049
+ // The verifier prompt must now include a CONTEXT line around the quote.
1050
+ const facts = [{ content: "The Acme project launched in March." }];
1051
+ const sourceText =
1052
+ "We started the Acme project earlier this year. It launched in March at beta status. The team is small.";
1053
+ const captured: Array<{ messages: Array<{ role: string; content: string }>; options: unknown }> = [];
1054
+ const llm = JSON.stringify([{ index: 0, verdict: "entailed" }]);
1055
+ const result = await runFaithfulnessGateBatch(
1056
+ facts,
1057
+ "shadow",
1058
+ baseConfig(),
1059
+ null,
1060
+ stubFallbackLlm(llm, captured),
1061
+ createFaithfulnessCounters(),
1062
+ sourceText,
1063
+ );
1064
+ assert.ok(result instanceof Map);
1065
+ assert.equal(result.size, 1);
1066
+ const userMsg = captured[0]?.messages.find((m) => m.role === "user");
1067
+ assert.ok(userMsg, "verifier was called");
1068
+ assert.match(userMsg!.content, /CONTEXT:/, "fallback-locator prompt must include a CONTEXT line");
1069
+ // The surrounding sentence must be present in the context window.
1070
+ assert.ok(
1071
+ userMsg!.content.includes("We started the Acme project"),
1072
+ "context window must include the neighboring source text",
1073
+ );
1074
+ });
1075
+
1076
+ test("runFaithfulnessGateBatch: #1575 verified spans do NOT synthesize a context window (regression guard)", async () => {
1077
+ // When per-fact sources are present, the verified span already carries full
1078
+ // evidence — no fallback context window should be synthesized.
1079
+ const facts = [
1080
+ {
1081
+ content: "The Acme project launched in March 2024 at beta status.",
1082
+ sources: [{ quote: "It launched in March 2024 at beta status." }],
1083
+ },
1084
+ ];
1085
+ const captured: Array<{ messages: Array<{ role: string; content: string }>; options: unknown }> = [];
1086
+ const llm = JSON.stringify([{ index: 0, verdict: "entailed" }]);
1087
+ await runFaithfulnessGateBatch(
1088
+ facts,
1089
+ "shadow",
1090
+ baseConfig(),
1091
+ null,
1092
+ stubFallbackLlm(llm, captured),
1093
+ createFaithfulnessCounters(),
1094
+ "We started the Acme project earlier this year. It launched in March 2024 at beta status.",
1095
+ );
1096
+ const userMsg = captured[0]?.messages.find((m) => m.role === "user");
1097
+ assert.ok(userMsg);
1098
+ assert.doesNotMatch(userMsg!.content, /CONTEXT:/, "no context window for #1575 verified spans");
1099
+ });
1100
+
1101
+ test("parseFaithfulnessResponse: object-wrapped arrays unwrap (results/verdicts/entries/facts) — gate-bypass fix #1576 Ob4RO", () => {
1102
+ // Before the fix these object-wrapped shapes were rejected as malformed, so in
1103
+ // enforce mode the batch went `unchecked` and unsupported facts wrote ACTIVE.
1104
+ for (const key of ["results", "verdicts", "entries", "facts"]) {
1105
+ const raw = JSON.stringify({ [key]: [{ index: 0, verdict: "unsupported", rationale: "x" }] });
1106
+ const map = parseFaithfulnessResponse(raw, 1);
1107
+ assert.ok(map, `${key}-wrapped array should parse, not be rejected as malformed`);
1108
+ assert.equal(map.get(0)?.verdict, "unsupported");
1109
+ }
1110
+ // A wrapper object with no recognized array key still returns null (no over-match).
1111
+ assert.equal(parseFaithfulnessResponse('{"data": {"nested": []}}', 1), null);
1112
+ });