swarph-cli 0.37.0__tar.gz → 0.38.0__tar.gz

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 (202) hide show
  1. {swarph_cli-0.37.0/src/swarph_cli.egg-info → swarph_cli-0.38.0}/PKG-INFO +3 -1
  2. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/pyproject.toml +7 -1
  3. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/__init__.py +1 -1
  4. swarph_cli-0.38.0/src/swarph_cli/bench/__init__.py +22 -0
  5. swarph_cli-0.38.0/src/swarph_cli/bench/backends.py +191 -0
  6. swarph_cli-0.38.0/src/swarph_cli/bench/pack.py +145 -0
  7. swarph_cli-0.38.0/src/swarph_cli/bench/prices.py +103 -0
  8. swarph_cli-0.38.0/src/swarph_cli/bench/quality.py +122 -0
  9. swarph_cli-0.38.0/src/swarph_cli/bench/refresh_prices.py +110 -0
  10. swarph_cli-0.38.0/src/swarph_cli/bench/runner.py +217 -0
  11. swarph_cli-0.38.0/src/swarph_cli/bench/validate.py +283 -0
  12. swarph_cli-0.38.0/src/swarph_cli/commands/bench.py +257 -0
  13. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/main.py +1 -0
  14. {swarph_cli-0.37.0 → swarph_cli-0.38.0/src/swarph_cli.egg-info}/PKG-INFO +3 -1
  15. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli.egg-info/SOURCES.txt +16 -0
  16. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli.egg-info/requires.txt +3 -0
  17. swarph_cli-0.38.0/tests/test_bench_backends.py +179 -0
  18. swarph_cli-0.38.0/tests/test_bench_command.py +365 -0
  19. swarph_cli-0.38.0/tests/test_bench_pack.py +193 -0
  20. swarph_cli-0.38.0/tests/test_bench_prices.py +93 -0
  21. swarph_cli-0.38.0/tests/test_bench_quality.py +180 -0
  22. swarph_cli-0.38.0/tests/test_bench_runner.py +243 -0
  23. swarph_cli-0.38.0/tests/test_bench_validate.py +318 -0
  24. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_brain_ask_command.py +1 -1
  25. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_watchdog.py +1 -1
  26. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/LICENSE +0 -0
  27. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/README.md +0 -0
  28. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/setup.cfg +0 -0
  29. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/caller.py +0 -0
  30. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/capture/__init__.py +0 -0
  31. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/capture/harden.py +0 -0
  32. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/capture/lineage.py +0 -0
  33. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/capture/liveness.py +0 -0
  34. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/capture/manifest.py +0 -0
  35. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/capture/paths.py +0 -0
  36. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/capture/verify.py +0 -0
  37. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/cell.py +0 -0
  38. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/chain_token.py +0 -0
  39. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/__init__.py +0 -0
  40. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/_display.py +0 -0
  41. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/_gateway_client.py +0 -0
  42. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/add.py +0 -0
  43. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/board.py +0 -0
  44. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/brain.py +0 -0
  45. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/brain_ask.py +0 -0
  46. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/cell.py +0 -0
  47. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/channel.py +0 -0
  48. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/chat.py +0 -0
  49. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/codegraph.py +0 -0
  50. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/compress.py +0 -0
  51. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/daemon.py +0 -0
  52. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/event.py +0 -0
  53. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/gateway.py +0 -0
  54. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/highlight.py +0 -0
  55. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/hook_output.py +0 -0
  56. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/hooks.py +0 -0
  57. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/import_session.py +0 -0
  58. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/init.py +0 -0
  59. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/install_hook.py +0 -0
  60. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/install_multiplexer.py +0 -0
  61. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/lane.py +0 -0
  62. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/mcp_server.py +0 -0
  63. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/memory.py +0 -0
  64. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/memory_sync.py +0 -0
  65. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/mesh.py +0 -0
  66. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/okf_links.py +0 -0
  67. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/onboard.py +0 -0
  68. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/protocol_handler.py +0 -0
  69. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/ratify.py +0 -0
  70. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/schedule.py +0 -0
  71. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/security.py +0 -0
  72. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/service.py +0 -0
  73. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/spawn.py +0 -0
  74. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/timeline.py +0 -0
  75. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/commands/watchdog.py +0 -0
  76. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/compress/__init__.py +0 -0
  77. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/compress/levers.py +0 -0
  78. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/compress/marker.py +0 -0
  79. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/compress/verify.py +0 -0
  80. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/delivery_queue.py +0 -0
  81. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/gateway/__init__.py +0 -0
  82. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/gateway/feature_registry.py +0 -0
  83. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/gateway/lanes_control.py +0 -0
  84. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/gateway/schema.sql +0 -0
  85. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/gateway/server.py +0 -0
  86. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/gateway/services_control.py +0 -0
  87. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/multiplexer.py +0 -0
  88. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/parsers/__init__.py +0 -0
  89. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/parsers/claude.py +0 -0
  90. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/service/__init__.py +0 -0
  91. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/service/app.py +0 -0
  92. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/service/providers.py +0 -0
  93. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/session_bridge.py +0 -0
  94. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/stall_alert.py +0 -0
  95. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/systemd/swarph-watchdog.default +0 -0
  96. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/systemd/swarph-watchdog.service +0 -0
  97. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli/systemd/swarph-watchdog.timer +0 -0
  98. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli.egg-info/dependency_links.txt +0 -0
  99. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli.egg-info/entry_points.txt +0 -0
  100. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/src/swarph_cli.egg-info/top_level.txt +0 -0
  101. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_add_security_gate.py +0 -0
  102. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_artifact_add.py +0 -0
  103. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_artifact_hash.py +0 -0
  104. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_artifact_lib.py +0 -0
  105. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_artifact_mcp.py +0 -0
  106. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_artifact_skill.py +0 -0
  107. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_artifact_tool.py +0 -0
  108. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_artifact_uri.py +0 -0
  109. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_board_command.py +0 -0
  110. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_brain_command.py +0 -0
  111. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_caller_meta_guard.py +0 -0
  112. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_capture_lineage.py +0 -0
  113. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_capture_liveness.py +0 -0
  114. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_capture_manifest.py +0 -0
  115. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_capture_paths.py +0 -0
  116. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_capture_security.py +0 -0
  117. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_cell_command_dispatch.py +0 -0
  118. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_cell_harden.py +0 -0
  119. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_cell_loader.py +0 -0
  120. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_cell_verify.py +0 -0
  121. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_chain_token.py +0 -0
  122. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_channel_command.py +0 -0
  123. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_channel_post.py +0 -0
  124. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_chat_command.py +0 -0
  125. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_claude_parser.py +0 -0
  126. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_claude_tmux_template.py +0 -0
  127. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_codegraph_command.py +0 -0
  128. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_codegraph_mcp.py +0 -0
  129. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_compress_caller_convention.py +0 -0
  130. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_compress_command.py +0 -0
  131. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_compress_levers.py +0 -0
  132. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_compress_marker.py +0 -0
  133. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_compress_verify.py +0 -0
  134. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_daemon_command.py +0 -0
  135. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_daemon_delivery.py +0 -0
  136. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_delivery_queue.py +0 -0
  137. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_display_sanitize.py +0 -0
  138. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_event_emit.py +0 -0
  139. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_feature_to_uri.py +0 -0
  140. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_gateway_brain.py +0 -0
  141. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_gateway_client.py +0 -0
  142. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_gateway_command.py +0 -0
  143. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_highlight_command.py +0 -0
  144. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_hook_output.py +0 -0
  145. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_hooks_activity_marker.py +0 -0
  146. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_hooks_add.py +0 -0
  147. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_hooks_bundle.py +0 -0
  148. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_hooks_init.py +0 -0
  149. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_hooks_lifecycle.py +0 -0
  150. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_hooks_merge.py +0 -0
  151. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_import_command.py +0 -0
  152. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_init_command.py +0 -0
  153. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_install_hook.py +0 -0
  154. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_install_multiplexer_command.py +0 -0
  155. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_lane_command.py +0 -0
  156. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_main.py +0 -0
  157. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_mcp_server.py +0 -0
  158. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_memory_command.py +0 -0
  159. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_memory_gateway.py +0 -0
  160. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_memory_sync.py +0 -0
  161. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_memory_sync_membrane.py +0 -0
  162. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_mesh_command.py +0 -0
  163. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_mesh_inbox_mark_read.py +0 -0
  164. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_mesh_inbox_sanitize.py +0 -0
  165. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_mesh_sidecar.py +0 -0
  166. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_mesh_sidecar_wake_survives_read.py +0 -0
  167. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_meta_edge_identity.py +0 -0
  168. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_multiplexer.py +0 -0
  169. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_okf_links.py +0 -0
  170. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_onboard_command.py +0 -0
  171. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_protocol_handler.py +0 -0
  172. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_providers_isolation.py +0 -0
  173. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_ratify_command.py +0 -0
  174. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_repl_caller_convention.py +0 -0
  175. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_schedule_command.py +0 -0
  176. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_security.py +0 -0
  177. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_service_app.py +0 -0
  178. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_service_command.py +0 -0
  179. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_service_providers.py +0 -0
  180. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_session_bridge_inject.py +0 -0
  181. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_session_bridge_probe.py +0 -0
  182. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_session_bridge_resolve.py +0 -0
  183. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_session_path_role_gate.py +0 -0
  184. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_smoke_chat.py +0 -0
  185. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_smoke_one_shot.py +0 -0
  186. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_smoke_phase_5_5.py +0 -0
  187. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_spawn_base_hoist.py +0 -0
  188. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_spawn_command.py +0 -0
  189. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_spawn_live_pin.py +0 -0
  190. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_spawn_mitosis_lineage.py +0 -0
  191. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_spawn_tmux_session.py +0 -0
  192. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_spawn_windows_relaunch.py +0 -0
  193. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_stall_alert.py +0 -0
  194. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_timeline_command.py +0 -0
  195. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_version_consistency.py +0 -0
  196. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_watchdog_dm_wake.py +0 -0
  197. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_watchdog_dm_wake_cooldown.py +0 -0
  198. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_watchdog_dm_wake_wiring.py +0 -0
  199. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_watchdog_emit_health.py +0 -0
  200. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_watchdog_install_flags.py +0 -0
  201. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_watchdog_model_rung.py +0 -0
  202. {swarph_cli-0.37.0 → swarph_cli-0.38.0}/tests/test_watchdog_stale_peers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: swarph-cli
3
- Version: 0.37.0
3
+ Version: 0.38.0
4
4
  Summary: The `swarph` binary — a multi-LLM CLI and mesh-gateway client + bundled server (`swarph gateway`): one-shot prompts across providers, interactive `chat`, multi-provider `spawn` (claude/codex/antigravity via a ProviderMembrane with subprocess billing-scrub), `mesh` send/inbox/register, `brain-ask` over the swarph-brain (gbrain) semantic memory, git-backed `assisted_memory`, session `import`, and a stranded-session `watchdog`.
5
5
  Author: Pierre Samson, Claude Opus
6
6
  License: MIT
@@ -31,6 +31,8 @@ Provides-Extra: dev
31
31
  Requires-Dist: pytest>=7.0; extra == "dev"
32
32
  Provides-Extra: mcp
33
33
  Requires-Dist: mcp>=1.0; extra == "mcp"
34
+ Provides-Extra: bench
35
+ Requires-Dist: google-genai>=0.3; extra == "bench"
34
36
  Provides-Extra: gateway
35
37
  Requires-Dist: fastapi>=0.115; extra == "gateway"
36
38
  Requires-Dist: uvicorn[standard]>=0.32; extra == "gateway"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "swarph-cli"
7
- version = "0.37.0"
7
+ version = "0.38.0"
8
8
  description = "The `swarph` binary — a multi-LLM CLI and mesh-gateway client + bundled server (`swarph gateway`): one-shot prompts across providers, interactive `chat`, multi-provider `spawn` (claude/codex/antigravity via a ProviderMembrane with subprocess billing-scrub), `mesh` send/inbox/register, `brain-ask` over the swarph-brain (gbrain) semantic memory, git-backed `assisted_memory`, session `import`, and a stranded-session `watchdog`."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -60,6 +60,12 @@ dev = ["pytest>=7.0"]
60
60
  # so the core one-shot/mesh paths don't pull it in; `swarph mcp-server`
61
61
  # prints a `pip install swarph-cli[mcp]` hint when it's absent.
62
62
  mcp = ["mcp>=1.0"]
63
+ # `swarph bench run` (metered backend) calls the google-genai Developer API
64
+ # directly. The dependency is an extra so `bench`'s pack loader/validate/
65
+ # distance-engine paths (and every other swarph-cli verb) stay usable without
66
+ # it; MeteredGeminiBackend prints a `pip install swarph-cli[bench]` hint via
67
+ # its ImportError message when it's absent.
68
+ bench = ["google-genai>=0.3"]
63
69
  # `swarph gateway serve` runs the bundled mesh-gateway HTTP server. The
64
70
  # FastAPI/uvicorn stack is an extra so the core client paths stay
65
71
  # dependency-light; the verb prints a `pip install swarph-cli[gateway]`
@@ -16,6 +16,6 @@ The architecture splits CLI from substrate so:
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
- __version__ = "0.37.0"
19
+ __version__ = "0.38.0"
20
20
 
21
21
  __all__ = ["__version__"]
@@ -0,0 +1,22 @@
1
+ """``swarph bench`` — deterministic LLM benchmark-pack runner (board card #101).
2
+
3
+ A *pack* is a self-contained, subject-agnostic unit of {skill, tests, expected
4
+ results} — one JSON file with three required parts: ``system`` (skill/context),
5
+ ``tasks[].prompt`` (tests), ``tasks[].expected`` (ground truth). Every score is a
6
+ DISTANCE in [0,1] (0 = perfect); the engine is fixed and domain-agnostic, packs
7
+ ship data only, never code.
8
+
9
+ Ported from the reference implementation (``hedge-fund-mcp/scripts/{model_showdown,
10
+ bench_quality,bench_news98,build_judgment_pack}.py``) per
11
+ ``docs/2026-07-24-swarph-bench-pack-spec.md``. See submodules:
12
+
13
+ - :mod:`swarph_cli.bench.quality` — the distance engine (numeric/categorical/
14
+ ranking/text) + answer parsing. Ported verbatim from ``bench_quality.py``.
15
+ - :mod:`swarph_cli.bench.pack` — pack loading + normative JSON-Schema validation.
16
+ - :mod:`swarph_cli.bench.prices` — a small model-id -> ($/1M in, $/1M out) table.
17
+ - :mod:`swarph_cli.bench.backends` — the backend abstraction (metered google-genai
18
+ Developer API + a subscription stub).
19
+ - :mod:`swarph_cli.bench.runner` — the N-way showdown loop -> confusion view.
20
+ - :mod:`swarph_cli.bench.validate` — the four ``validate`` disciplines.
21
+ """
22
+ from __future__ import annotations
@@ -0,0 +1,191 @@
1
+ """The backend abstraction (spec §4). ``bench`` OWNS a thin, provider-agnostic
2
+ backend interface — it does NOT reuse the LLM services layer's :878x/:879x
3
+ lane map (decision #3: bench owns its own).
4
+
5
+ A backend maps ``(model_id, prompt, system) -> BackendResult`` (tokens in /
6
+ thought / out, latency, text, whether tokens are ESTIMATED). ``system``
7
+ threads to every backend (metered: ``system_instruction``; a CLI-shelling
8
+ backend would prepend it — see :class:`SubscriptionBackend`).
9
+
10
+ Ships two backends:
11
+
12
+ - :class:`MeteredGeminiBackend` — google-genai Developer API, DEFAULT for v1
13
+ (real ``usage_metadata``: prompt/candidates/thoughts tokens). CRITICAL:
14
+ passes ``vertexai=False`` EXPLICITLY — a ``GOOGLE_GENAI_USE_VERTEXAI=true``
15
+ env 401s API-key auth (this exact bug bit the reference lab; see
16
+ ``model_showdown.py::run_metered``).
17
+ - :class:`SubscriptionBackend` — a STUB/interface only. Tokens are always
18
+ ESTIMATED (``~len(text)/4``, flagged via ``estimated=True``) and it does
19
+ NOT couple to any specific subscription lane map; a caller wires an actual
20
+ $0 CLI/OIDC path in by passing ``call_fn``. Not exercised against a live
21
+ provider anywhere in this package.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import os
26
+ import time
27
+ from dataclasses import dataclass
28
+ from typing import Callable, Optional, Protocol
29
+
30
+
31
+ @dataclass
32
+ class BackendResult:
33
+ text: str
34
+ tokens_in: int
35
+ tokens_thought: int
36
+ tokens_out: int
37
+ latency_s: float
38
+ estimated: bool
39
+ error: Optional[str] = None
40
+
41
+ @property
42
+ def total_tokens(self) -> int:
43
+ return self.tokens_in + (self.tokens_thought or 0) + self.tokens_out
44
+
45
+
46
+ class Backend(Protocol):
47
+ def generate(self, model_id: str, prompt: str, system: str = "") -> BackendResult:
48
+ ...
49
+
50
+ def missing_creds(self) -> list[str]:
51
+ """-> human-readable names of missing REQUIRED credentials, e.g.
52
+ ``["GEMINI_API_KEY (or GOOGLE_API_KEY)"]``. Empty = ready to dispatch.
53
+ Checked by :func:`swarph_cli.bench.runner.preflight` BEFORE any
54
+ network call, so a missing key surfaces as one clear warning instead
55
+ of a mid-run 401 traceback."""
56
+ ...
57
+
58
+
59
+ def estimate_tokens(text: Optional[str]) -> int:
60
+ """Rough ~4-chars/token estimate for backends that report no usage
61
+ metadata (ported from the reference ``_est_tokens``)."""
62
+ return max(1, round(len(text or "") / 4))
63
+
64
+
65
+ class MeteredGeminiBackend:
66
+ """google-genai Developer API — the v1 DEFAULT metered backend. Real
67
+ ``usage_metadata`` (prompt/candidates/thoughts token counts), pennies per
68
+ call, no subscription-quota throttling.
69
+
70
+ ``google-genai`` is imported LAZILY (inside :meth:`generate`, not at
71
+ module import time) so importing :mod:`swarph_cli.bench.backends` never
72
+ requires the dependency unless the metered backend is actually used —
73
+ consistent with swarph-cli's dependency-light-core-paths convention
74
+ (``[mcp]``/``[gateway]``/``[service]`` extras).
75
+ """
76
+
77
+ #: env vars checked (in order) when no api_key is passed to __init__.
78
+ ENV_VARS = ("GEMINI_API_KEY", "GOOGLE_API_KEY")
79
+
80
+ def __init__(self, api_key: Optional[str] = None):
81
+ self._api_key = api_key
82
+
83
+ def missing_creds(self) -> list[str]:
84
+ if self._api_key:
85
+ return []
86
+ if any(os.environ.get(v) for v in self.ENV_VARS):
87
+ return []
88
+ return [f"{self.ENV_VARS[0]} (or {self.ENV_VARS[1]})"]
89
+
90
+ def credentials_ok(self) -> bool:
91
+ return not self.missing_creds()
92
+
93
+ def generate(self, model_id: str, prompt: str, system: str = "") -> BackendResult:
94
+ try:
95
+ from google import genai
96
+ from google.genai import types
97
+ except ImportError as exc:
98
+ return BackendResult(
99
+ text="", tokens_in=0, tokens_thought=0, tokens_out=0,
100
+ latency_s=0.0, estimated=False,
101
+ error=f"google-genai not installed: {exc} "
102
+ f"(pip install swarph-cli[bench] or `pip install google-genai`)",
103
+ )
104
+ api_key = self._api_key or os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
105
+ if not api_key:
106
+ return BackendResult(
107
+ text="", tokens_in=0, tokens_thought=0, tokens_out=0,
108
+ latency_s=0.0, estimated=False,
109
+ error="no API key: set GEMINI_API_KEY (or GOOGLE_API_KEY)",
110
+ )
111
+ t0 = time.time()
112
+ try:
113
+ # vertexai=False EXPLICITLY: if GOOGLE_GENAI_USE_VERTEXAI=true is
114
+ # set in the environment, Vertex rejects API-key auth with a 401.
115
+ # This is not optional — it's the exact bug that bit swarph-news.
116
+ client = genai.Client(api_key=api_key, vertexai=False)
117
+ cfg = types.GenerateContentConfig(system_instruction=system) if system else None
118
+ resp = client.models.generate_content(model=model_id, contents=prompt, config=cfg)
119
+ except Exception as exc: # provider/network errors surface, not swallow silently
120
+ return BackendResult(
121
+ text="", tokens_in=0, tokens_thought=0, tokens_out=0,
122
+ latency_s=round(time.time() - t0, 2), estimated=False, error=str(exc),
123
+ )
124
+ latency_s = round(time.time() - t0, 2)
125
+ usage = getattr(resp, "usage_metadata", None)
126
+ return BackendResult(
127
+ text=getattr(resp, "text", "") or "",
128
+ tokens_in=(usage.prompt_token_count or 0) if usage else 0,
129
+ tokens_thought=(getattr(usage, "thoughts_token_count", 0) or 0) if usage else 0,
130
+ tokens_out=(usage.candidates_token_count or 0) if usage else 0,
131
+ latency_s=latency_s,
132
+ estimated=False,
133
+ )
134
+
135
+
136
+ class SubscriptionBackend:
137
+ """STUB/interface-only backend for a $0 subscription path (e.g. a node's
138
+ own CLI/OIDC lane). Deliberately NOT coupled to the reference stack's
139
+ :878x/:879x lane map (decision #3) — a caller supplies ``call_fn`` to
140
+ wire an actual subscription transport; without one, every call fails
141
+ clearly rather than silently degrading.
142
+
143
+ Token counts are ALWAYS estimated (``estimated=True``) — subscription
144
+ CLIs commonly emit no usage metadata at all. This is UNVERIFIED against
145
+ any live subscription provider; it exists so ``bench`` has a second
146
+ backend shape to validate against without hardcoding a real one.
147
+ """
148
+
149
+ def __init__(self, call_fn: Optional[Callable[[str, str, str], str]] = None):
150
+ # call_fn(model_id, prompt, system) -> raw response text
151
+ self._call_fn = call_fn
152
+
153
+ def missing_creds(self) -> list[str]:
154
+ """Not env-checkable (the credential is a node's own CLI/OIDC
155
+ session, which varies per deployment) — per decision, this FLAGS
156
+ rather than hard-checks: no ``call_fn`` wired means there is no
157
+ transport at all, which the preflight treats the same as a missing
158
+ credential so it's skipped with a clear reason instead of erroring
159
+ mid-run."""
160
+ if self._call_fn is None:
161
+ return ["subscription CLI/OIDC auth (not env-checkable; no call_fn wired — stub backend)"]
162
+ return []
163
+
164
+ def credentials_ok(self) -> bool:
165
+ return not self.missing_creds()
166
+
167
+ def generate(self, model_id: str, prompt: str, system: str = "") -> BackendResult:
168
+ if self._call_fn is None:
169
+ return BackendResult(
170
+ text="", tokens_in=0, tokens_thought=0, tokens_out=0,
171
+ latency_s=0.0, estimated=True,
172
+ error="SubscriptionBackend is a stub — no call_fn wired for a real "
173
+ "$0 subscription transport",
174
+ )
175
+ t0 = time.time()
176
+ try:
177
+ text = self._call_fn(model_id, prompt, system) or ""
178
+ except Exception as exc:
179
+ return BackendResult(
180
+ text="", tokens_in=0, tokens_thought=0, tokens_out=0,
181
+ latency_s=round(time.time() - t0, 2), estimated=True, error=str(exc),
182
+ )
183
+ latency_s = round(time.time() - t0, 2)
184
+ return BackendResult(
185
+ text=text,
186
+ tokens_in=estimate_tokens(prompt),
187
+ tokens_thought=0,
188
+ tokens_out=estimate_tokens(text),
189
+ latency_s=latency_s,
190
+ estimated=True,
191
+ )
@@ -0,0 +1,145 @@
1
+ """Pack loader + the normative JSON-Schema validation (spec §1).
2
+
3
+ A pack is one JSON file, three required parts: ``system`` (skill/context),
4
+ ``tasks[].prompt`` (tests), ``tasks[].expected`` (ground truth). Hand-rolled
5
+ schema check (no ``jsonschema`` dependency — swarph-cli keeps its core paths
6
+ dependency-light, per the existing ``codegraph``/``compress`` pattern) that
7
+ enforces exactly the normative schema from the spec plus the constraints
8
+ listed beneath it:
9
+
10
+ - ``id`` unique within a pack
11
+ - ``expected`` shape matches ``type`` (numeric->number, categorical->string,
12
+ ranking->array, text->string)
13
+ - ``system`` SHOULD be present (warning, not an error, if absent)
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import re
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ TASK_TYPES = ("numeric", "categorical", "ranking", "text")
23
+
24
+
25
+ class PackError(Exception):
26
+ """Raised by :func:`load_pack` when the JSON itself can't be read/parsed."""
27
+
28
+
29
+ def load_pack(path: str | Path) -> dict:
30
+ """Read + JSON-parse a pack file. Raises :class:`PackError` on I/O or
31
+ JSON-decode failure (schema validity is a separate step — see
32
+ :func:`validate_schema` — so callers can report *why* a pack is invalid
33
+ rather than crash)."""
34
+ p = Path(path)
35
+ try:
36
+ raw = p.read_text()
37
+ except OSError as exc:
38
+ raise PackError(f"cannot read pack {p}: {exc}") from exc
39
+ try:
40
+ data = json.loads(raw)
41
+ except json.JSONDecodeError as exc:
42
+ raise PackError(f"pack {p} is not valid JSON: {exc}") from exc
43
+ if not isinstance(data, dict):
44
+ raise PackError(f"pack {p} must be a JSON object, got {type(data).__name__}")
45
+ return data
46
+
47
+
48
+ def _expected_matches_type(expected: Any, ttype: str) -> bool:
49
+ if ttype == "numeric":
50
+ return isinstance(expected, (int, float)) and not isinstance(expected, bool)
51
+ if ttype == "categorical":
52
+ return isinstance(expected, str)
53
+ if ttype == "ranking":
54
+ return isinstance(expected, list) and all(isinstance(x, (str, int, float)) for x in expected)
55
+ if ttype == "text":
56
+ return isinstance(expected, str)
57
+ return False
58
+
59
+
60
+ def validate_schema(pack: dict) -> tuple[list[str], list[str]]:
61
+ """Validate ``pack`` against the normative JSON-Schema (spec §1) plus the
62
+ constraints beneath it. -> ``(errors, warnings)``. Non-empty ``errors``
63
+ means the pack is INVALID (schema violation, dup id, type/expected
64
+ mismatch); ``warnings`` are advisory (e.g. missing ``system``) and don't
65
+ fail the pack on their own."""
66
+ errors: list[str] = []
67
+ warnings: list[str] = []
68
+
69
+ if not isinstance(pack, dict):
70
+ return ([f"pack must be a JSON object, got {type(pack).__name__}"], warnings)
71
+
72
+ theme = pack.get("theme")
73
+ if not isinstance(theme, str) or not theme.strip():
74
+ errors.append("'theme' is required and must be a non-empty string")
75
+
76
+ if "version" in pack and not isinstance(pack["version"], str):
77
+ errors.append("'version' must be a string when present")
78
+
79
+ if "system" not in pack or not str(pack.get("system") or "").strip():
80
+ warnings.append(
81
+ "pack has no 'system' — a pack without a system tests raw model priors, "
82
+ "not applying a real operating context (spec §5d: system is the DOMINANT "
83
+ "variable and must mirror real deployment)"
84
+ )
85
+ elif not isinstance(pack.get("system"), str):
86
+ errors.append("'system' must be a string when present")
87
+
88
+ if "description" in pack and not isinstance(pack["description"], str):
89
+ errors.append("'description' must be a string when present")
90
+
91
+ tasks = pack.get("tasks")
92
+ if not isinstance(tasks, list) or len(tasks) < 1:
93
+ errors.append("'tasks' is required and must be a non-empty array")
94
+ return (errors, warnings)
95
+
96
+ seen_ids: set[str] = set()
97
+ for i, task in enumerate(tasks):
98
+ where = f"tasks[{i}]"
99
+ if not isinstance(task, dict):
100
+ errors.append(f"{where} must be an object")
101
+ continue
102
+
103
+ tid = task.get("id")
104
+ if not isinstance(tid, str) or not tid.strip():
105
+ errors.append(f"{where}.id is required and must be a non-empty string")
106
+ elif tid in seen_ids:
107
+ errors.append(f"duplicate task id {tid!r}")
108
+ else:
109
+ seen_ids.add(tid)
110
+
111
+ prompt = task.get("prompt")
112
+ if not isinstance(prompt, str) or not prompt.strip():
113
+ errors.append(f"{where}.prompt is required and must be a non-empty string")
114
+
115
+ ttype = task.get("type")
116
+ if ttype not in TASK_TYPES:
117
+ errors.append(f"{where}.type must be one of {TASK_TYPES}, got {ttype!r}")
118
+ continue # can't check expected-shape against an unknown type
119
+
120
+ if "expected" not in task:
121
+ errors.append(f"{where}.expected is required")
122
+ elif not _expected_matches_type(task["expected"], ttype):
123
+ errors.append(
124
+ f"{where}.expected does not match type={ttype!r} "
125
+ f"(got {type(task['expected']).__name__})"
126
+ )
127
+
128
+ if "weight" in task:
129
+ w = task["weight"]
130
+ if not isinstance(w, (int, float)) or isinstance(w, bool) or w <= 0:
131
+ errors.append(f"{where}.weight must be a number > 0, got {w!r}")
132
+
133
+ if "meta" in task and not isinstance(task["meta"], dict):
134
+ errors.append(f"{where}.meta must be an object when present")
135
+
136
+ return (errors, warnings)
137
+
138
+
139
+ def slugify_theme(theme: str) -> str:
140
+ """``theme`` -> a filesystem-safe pack slug: lowercase, non-alnum runs ->
141
+ ``_``, trimmed. Drives the ``packs/<theme>.json`` naming convention
142
+ (spec §6 / ``bench add``) — the pack's OWN header names the file, no
143
+ separate name is ever asked for on the command line."""
144
+ s = re.sub(r"[^a-z0-9]+", "_", theme.strip().lower()).strip("_")
145
+ return s or "pack"
@@ -0,0 +1,103 @@
1
+ """The auto-refreshed LLM LIST-price table (spec §4) — LIFTED from the
2
+ reference lab's already-tested ``workers/llm_prices.py`` (adapted imports/
3
+ cache path only; lookup semantics, fallback table, and substring-order bug
4
+ guard are unchanged). Do NOT reinvent this — see
5
+ ``docs/2026-07-24-model-showdown-findings.md`` shipped-alongside note: it
6
+ already caught a real ~5x under-count on gemini-2.5-flash-lite.
7
+
8
+ The LIST-price BASE is auto-refreshed from the community-maintained LiteLLM
9
+ repo (:mod:`swarph_cli.bench.refresh_prices` -> a local JSON cache; TODO wire
10
+ a weekly cron the way the reference lab does), so new models are never
11
+ silently mis-priced by a stale hardcoded bucket.
12
+
13
+ Public API:
14
+ lookup(model) -> (input_$/Mtok, output_$/Mtok) # never raises
15
+ cost_usd(model, tokens_in, tokens_thought, tokens_out) -> $
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import logging
21
+ import os
22
+
23
+ log = logging.getLogger("swarph.bench.prices")
24
+
25
+ _CACHE_PATH = os.path.join(os.path.dirname(__file__), "data", "llm_prices.json")
26
+ _MAP: dict | None = None
27
+
28
+ # Reconciled fallbacks ($/Mtok input, output) — used ONLY when a model isn't in
29
+ # the refreshed cache. The cache is the real source; these are the safety net.
30
+ _FALLBACK = {
31
+ "pro": (1.25, 10.0),
32
+ "lite": (0.019, 0.075),
33
+ "flash": (0.30, 2.50),
34
+ }
35
+
36
+
37
+ def load(force: bool = False) -> dict:
38
+ """The cached ``{normalized_model: {"in": $/Mtok, "out": $/Mtok}}`` map.
39
+ Cached in-process; ``force=True`` re-reads the file. Never raises."""
40
+ global _MAP
41
+ if _MAP is not None and not force:
42
+ return _MAP
43
+ try:
44
+ with open(_CACHE_PATH) as f:
45
+ _MAP = (json.load(f) or {}).get("prices", {}) or {}
46
+ except Exception as e: # missing/corrupt cache -> fall back to built-ins
47
+ log.debug("llm price cache unavailable (%s) — using fallbacks", e)
48
+ _MAP = {}
49
+ return _MAP
50
+
51
+
52
+ def _norm(model: str) -> str:
53
+ """Strip provider prefix (gemini/, vertex_ai/, ...) + lowercase."""
54
+ return (model or "").split("/")[-1].strip().lower()
55
+
56
+
57
+ def lookup(model: str) -> tuple[float, float]:
58
+ """(input_$/Mtok, output_$/Mtok) for ``model``. Exact cache hit ->
59
+ substring fallback (lite before flash so 'flash-lite' resolves as lite —
60
+ order-sensitive, don't reorder). Never raises."""
61
+ m = _norm(model)
62
+ prices = load()
63
+ p = prices.get(m)
64
+ if p and "in" in p and "out" in p:
65
+ return (float(p["in"]), float(p["out"]))
66
+ # substring fallback — ORDER MATTERS: lite is a substring of flash-lite
67
+ # and must win over flash.
68
+ if "lite" in m:
69
+ return _FALLBACK["lite"]
70
+ if "pro" in m:
71
+ return _FALLBACK["pro"]
72
+ return _FALLBACK["flash"] # flash + safest default
73
+
74
+
75
+ def is_known(model_id: str) -> bool:
76
+ """True if ``model_id`` has an exact hit in the refreshed cache (as
77
+ opposed to resolving via the substring fallback)."""
78
+ p = load().get(_norm(model_id))
79
+ return bool(p and "in" in p and "out" in p)
80
+
81
+
82
+ def cost_usd(model_id: str, tokens_in: int, tokens_thought: int, tokens_out: int) -> float:
83
+ """Metered $ for one call: in-tokens at the input price, (thought + out)
84
+ tokens at the output price (mirrors the reference ``metered_usd()``)."""
85
+ price_in, price_out = lookup(model_id)
86
+ thought = tokens_thought or 0
87
+ return (tokens_in * price_in + (thought + tokens_out) * price_out) / 1_000_000
88
+
89
+
90
+ def all_cached(force: bool = False) -> dict[str, tuple[float, float]]:
91
+ """Every ``{model_id: (in, out)}`` currently in the refreshed cache — the
92
+ full LiteLLM-sourced price list, not just the models ``bench`` happens to
93
+ price. Exposed so the cache is a reusable mesh asset (e.g. a
94
+ cost-governor/ledger can read the same cache via ``swarph bench prices``)
95
+ rather than plumbing private to the runner."""
96
+ return {m: (float(v["in"]), float(v["out"])) for m, v in load(force=force).items()
97
+ if isinstance(v, dict) and "in" in v and "out" in v}
98
+
99
+
100
+ def fallback_buckets() -> dict[str, tuple[float, float]]:
101
+ """The 3 built-in substring-fallback buckets (pro/lite/flash), for
102
+ display when the cache is empty/stale."""
103
+ return dict(_FALLBACK)
@@ -0,0 +1,122 @@
1
+ """The FIXED distance engine — ported VERBATIM (math unchanged) from the
2
+ reference ``bench_quality.py`` (``d_numeric/d_categorical/d_ranking/d_text``,
3
+ ``parse_answer``, ``score``). See spec §2.
4
+
5
+ Every score is a DISTANCE in [0, 1] where 0 = perfect. The engine is fixed and
6
+ domain-agnostic — a pack never ships code, only data (``system`` + tasks +
7
+ expected answers); this module is the ONLY place distance math lives.
8
+
9
+ **Parse-fail -> distance = 1.0** is a load-bearing rule, not an edge case: a
10
+ model that can't follow the structured-output instruction gets the worst
11
+ possible score, because that IS a real quality signal (findings.md).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+ from typing import Any, Callable, Optional
18
+
19
+ # ---- distance functions (0 = exact, 1 = worst) -----------------------------
20
+
21
+
22
+ def d_numeric(provided: Any, expected: Any) -> float:
23
+ """Capped relative error. ``e == 0`` is a special case: 0.0 if ``p == 0``
24
+ else 1.0 (relative error is undefined at zero)."""
25
+ try:
26
+ p, e = float(provided), float(expected)
27
+ except (TypeError, ValueError):
28
+ return 1.0
29
+ if e == 0:
30
+ return 0.0 if p == 0 else 1.0
31
+ return min(abs(p - e) / abs(e), 1.0)
32
+
33
+
34
+ def d_categorical(provided: Any, expected: Any) -> float:
35
+ """0 if ``upper(strip(provided)) == upper(strip(expected))`` else 1."""
36
+ return 0.0 if str(provided).strip().upper() == str(expected).strip().upper() else 1.0
37
+
38
+
39
+ def d_ranking(provided: Any, expected: Any) -> float:
40
+ """Normalized Kendall-tau distance on the ranked list (0 = same order,
41
+ 1 = fully reversed). Wrong item SET (not a list, or items don't match the
42
+ expected set) -> 1.0 (worst), per spec §2."""
43
+ if not isinstance(provided, list) or sorted(map(str, provided)) != sorted(map(str, expected)):
44
+ return 1.0
45
+ pos = {str(x): i for i, x in enumerate(provided)}
46
+ exp = [str(x) for x in expected]
47
+ n = len(exp)
48
+ if n < 2:
49
+ return 0.0
50
+ discord = sum(1 for i in range(n) for j in range(i + 1, n) if pos[exp[i]] > pos[exp[j]])
51
+ return discord / (n * (n - 1) / 2)
52
+
53
+
54
+ def d_text(provided: Any, expected: Any, embedder: Optional[Callable[[str], Any]] = None) -> float:
55
+ """Free-text distance: ``1 - Jaccard(tokens(p), tokens(e))`` — a cheap, $0,
56
+ deterministic proxy with no external dependency (v1 default, spec §2 /
57
+ decision #2).
58
+
59
+ ``embedder`` is a clearly-marked SEAM for an optional embedding-cosine
60
+ distance (e.g. gbrain's $0 embeddings) — NOT wired in v1. When provided it
61
+ must be a ``str -> vector`` callable; cosine distance replaces Jaccard.
62
+ Left unimplemented deliberately: gbrain is not coupled here yet.
63
+ """
64
+ if embedder is not None:
65
+ raise NotImplementedError(
66
+ "d_text(embedder=...) is a seam for a future gbrain-cosine embedder; "
67
+ "not wired in v1 — call d_text(provided, expected) without embedder."
68
+ )
69
+ pa = set(str(provided).lower().split())
70
+ ea = set(str(expected).lower().split())
71
+ if not ea:
72
+ return 0.0 if not pa else 1.0
73
+ union = len(pa | ea)
74
+ return 1.0 - (len(pa & ea) / union if union else 0.0)
75
+
76
+
77
+ DISTANCE: dict[str, Callable[[Any, Any], float]] = {
78
+ "numeric": d_numeric,
79
+ "categorical": d_categorical,
80
+ "ranking": d_ranking,
81
+ "text": d_text,
82
+ }
83
+
84
+
85
+ # ---- answer extraction ------------------------------------------------------
86
+
87
+
88
+ def parse_answer(text: Optional[str]) -> Any:
89
+ """Pull the LAST ``{"answer": ...}`` JSON object from a model reply
90
+ (tolerant of code fences / surrounding prose). Returns ``None`` on parse
91
+ failure — the caller (``score``) turns that into distance=1.0."""
92
+ if not text:
93
+ return None
94
+ last = None
95
+ found = False
96
+ for m in re.finditer(r'\{[^{}]*"answer"[^{}]*\}', text, re.DOTALL):
97
+ try:
98
+ last = json.loads(m.group(0)).get("answer")
99
+ found = True
100
+ except Exception:
101
+ continue
102
+ return last if found else None
103
+
104
+
105
+ def score(task: dict, text: str) -> dict:
106
+ """-> ``{distance, parsed, parse_ok}``.
107
+
108
+ ``numeric``/``categorical``/``ranking`` are parsed from the structured
109
+ ``{"answer": ...}`` JSON; ``text`` scores the raw response verbatim.
110
+ Unparseable structured answers are the worst score (1.0) — a real quality
111
+ signal, not a bug (spec §2).
112
+ """
113
+ ttype = task["type"]
114
+ if ttype == "text":
115
+ ans = text or ""
116
+ dist = d_text(ans, task["expected"])
117
+ return {"distance": round(dist, 4), "parsed": ans, "parse_ok": True}
118
+ ans = parse_answer(text)
119
+ if ans is None:
120
+ return {"distance": 1.0, "parsed": None, "parse_ok": False}
121
+ dist = DISTANCE[ttype](ans, task["expected"])
122
+ return {"distance": round(dist, 4), "parsed": ans, "parse_ok": True}