argo-search 1.0.1 → 2.9.7
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.
- package/LICENSE +201 -21
- package/README.en.md +822 -0
- package/README.es.md +864 -0
- package/README.ja.md +864 -0
- package/README.ko.md +864 -0
- package/README.md +867 -320
- package/SKILL.md +135 -0
- package/assets/readme/hero.svg +121 -0
- package/assets/readme/made-with-beautify.svg +10 -0
- package/assets/readme/proof-routes.svg +73 -0
- package/assets/readme/settings-keys.png +0 -0
- package/assets/readme/settings-tools.png +0 -0
- package/assets/readme/why-better.svg +104 -0
- package/assets/readme/workflow.svg +83 -0
- package/backends/domain_profiles.json +2308 -96
- package/backends/engine_registry.yaml +3314 -503
- package/backends/query_synonyms_cn.json +35 -0
- package/backends/quota_profiles.json +2409 -161
- package/backends/source_types_cn.json +83 -0
- package/backends/text_lexicon.json +44 -0
- package/bin/argo +703 -0
- package/bin/argo.js +71 -39
- package/config.yaml +4976 -363
- package/cordis.patch.yml +21 -0
- package/data/countries.json +40 -0
- package/data/lang_matrix/lang_capability.json +842 -0
- package/data/lang_matrix/matrix_2026-09-10.json +11921 -0
- package/data/regions_cn.json +1 -0
- package/docs/ADDING_NEW_ENGINE.md +184 -0
- package/docs/ARGO_INTRO.md +124 -0
- package/docs/DATA_SOURCE_GAPS.md +81 -0
- package/docs/DESIGN_LOCAL_DATA_FUSION.md +90 -0
- package/docs/ENGINE_CATALOG.md +564 -0
- package/docs/MCP_SETUP.md +106 -0
- package/docs/PLATFORM_PATHS.md +111 -0
- package/docs/RELEASE_NOTES_v2.8.0.md +71 -0
- package/docs/RELEASE_NOTES_v2.8.2.md +63 -0
- package/docs/RELEASE_NOTES_v2.8.3.md +55 -0
- package/docs/RELEASE_NOTES_v2.8.4.md +79 -0
- package/docs/RELEASE_NOTES_v2.8.5.md +102 -0
- package/docs/RELEASE_NOTES_v2.8.6.md +66 -0
- package/docs/RELEASE_NOTES_v2.8.7.md +65 -0
- package/docs/RELEASE_NOTES_v2.8.8.md +67 -0
- package/docs/RELEASE_NOTES_v2.8.9.md +67 -0
- package/docs/RELEASE_NOTES_v2.9.0.md +64 -0
- package/docs/SEARCH_ARCHIVE.md +115 -0
- package/docs/assets/architecture.svg +104 -0
- package/docs/assets/hero.svg +98 -0
- package/docs/engines/bangumi.md +27 -0
- package/docs/engines/biorxiv.md +27 -0
- package/docs/engines/clawhub.md +27 -0
- package/docs/engines/crt_sh.md +27 -0
- package/docs/engines/datacite.md +27 -0
- package/docs/engines/deps_dev.md +27 -0
- package/docs/engines/douban_movie.md +27 -0
- package/docs/engines/endoflife.md +27 -0
- package/docs/engines/flk_law.md +27 -0
- package/docs/engines/fxtwitter.md +27 -0
- package/docs/engines/google_news.md +27 -0
- package/docs/engines/gov_regulations.md +27 -0
- package/docs/engines/guqian.md +27 -0
- package/docs/engines/hackernews.md +27 -0
- package/docs/engines/iplant.md +27 -0
- package/docs/engines/keenable.md +27 -0
- package/docs/engines/met_museum.md +27 -0
- package/docs/engines/nasa_images.md +27 -0
- package/docs/engines/netease_music.md +27 -0
- package/docs/engines/npm.md +27 -0
- package/docs/engines/nvd.md +27 -0
- package/docs/engines/openreview.md +27 -0
- package/docs/engines/openstd.md +27 -0
- package/docs/engines/parallel_free.md +27 -0
- package/docs/engines/people_daily.md +27 -0
- package/docs/engines/realtime_index.md +27 -0
- package/docs/engines/sec_edgar.md +27 -0
- package/docs/engines/seltz.md +27 -0
- package/docs/engines/skillsmp.md +27 -0
- package/docs/engines/sspai.md +27 -0
- package/docs/engines/std_samr.md +27 -0
- package/docs/engines/train.md +27 -0
- package/docs/engines/un_comtrade.md +27 -0
- package/docs/engines/wikisource.md +27 -0
- package/docs/engines/zdic.md +27 -0
- package/docs/engines/zenodo.md +27 -0
- package/docs/engines/zhihu_hot_app.md +27 -0
- package/docs//344/270/272/344/273/200/344/271/210/351/200/211/346/213/251argo.md +176 -0
- package/engines/README.md +10 -0
- package/engines/_template_cli.yaml +39 -0
- package/engines/_template_http.yaml +37 -0
- package/engines/plugins/_template_plugin.py +50 -0
- package/engines/specs/_cisa_kev.yaml +54 -0
- package/engines/specs/_osv.yaml +63 -0
- package/engines/specs/aljazeera.yaml +43 -0
- package/engines/specs/antara.yaml +43 -0
- package/engines/specs/artic.yaml +20 -0
- package/engines/specs/aviation_weather.yaml +39 -0
- package/engines/specs/bangumi.yaml +21 -0
- package/engines/specs/biorxiv.yaml +24 -0
- package/engines/specs/carbon_intensity.yaml +32 -0
- package/engines/specs/cinii.yaml +44 -0
- package/engines/specs/clawhub.yaml +49 -0
- package/engines/specs/cleveland.yaml +19 -0
- package/engines/specs/cn_ai_news.yaml +43 -0
- package/engines/specs/core.yaml +35 -0
- package/engines/specs/crt_sh.yaml +38 -0
- package/engines/specs/ct24.yaml +43 -0
- package/engines/specs/datacite.yaml +27 -0
- package/engines/specs/datagov_uk.yaml +41 -0
- package/engines/specs/ddgs_images.yaml +45 -0
- package/engines/specs/deepseek_search.yaml +74 -0
- package/engines/specs/deezer.yaml +20 -0
- package/engines/specs/deps_dev.yaml +21 -0
- package/engines/specs/digup.yaml +36 -0
- package/engines/specs/doi.yaml +39 -0
- package/engines/specs/douban_movie.yaml +22 -0
- package/engines/specs/dw_news.yaml +47 -0
- package/engines/specs/egov_law.yaml +21 -0
- package/engines/specs/elpais.yaml +43 -0
- package/engines/specs/endoflife.yaml +21 -0
- package/engines/specs/energy_charts.yaml +21 -0
- package/engines/specs/factcheck_org.yaml +52 -0
- package/engines/specs/faz.yaml +43 -0
- package/engines/specs/federal_register.yaml +57 -0
- package/engines/specs/firecrawl.yaml +53 -0
- package/engines/specs/flk_law.yaml +24 -0
- package/engines/specs/folha.yaml +43 -0
- package/engines/specs/france24.yaml +44 -0
- package/engines/specs/full_fact.yaml +47 -0
- package/engines/specs/fxtwitter.yaml +31 -0
- package/engines/specs/gbfs_nyc.yaml +32 -0
- package/engines/specs/gdacs.yaml +21 -0
- package/engines/specs/gdelt.yaml +64 -0
- package/engines/specs/google_news.yaml +22 -0
- package/engines/specs/gov_regulations.yaml +38 -0
- package/engines/specs/guardian_rss.yaml +59 -0
- package/engines/specs/hurriyet.yaml +43 -0
- package/engines/specs/iplant.yaml +25 -0
- package/engines/specs/jikan.yaml +24 -0
- package/engines/specs/jolpica.yaml +20 -0
- package/engines/specs/k10plus.yaml +20 -0
- package/engines/specs/keenable.yaml +44 -0
- package/engines/specs/lefigaro.yaml +43 -0
- package/engines/specs/listenbrainz.yaml +19 -0
- package/engines/specs/matichon.yaml +43 -0
- package/engines/specs/met_museum.yaml +21 -0
- package/engines/specs/nasa_images.yaml +45 -0
- package/engines/specs/netease_music.yaml +38 -0
- package/engines/specs/nhk.yaml +43 -0
- package/engines/specs/nhtsa_vpic.yaml +20 -0
- package/engines/specs/noaa_swpc.yaml +20 -0
- package/engines/specs/nvd.yaml +38 -0
- package/engines/specs/obis.yaml +20 -0
- package/engines/specs/opencitations.yaml +52 -0
- package/engines/specs/openf1.yaml +21 -0
- package/engines/specs/openfoodfacts.yaml +45 -0
- package/engines/specs/openligadb.yaml +20 -0
- package/engines/specs/openreview.yaml +37 -0
- package/engines/specs/openstd.yaml +21 -0
- package/engines/specs/people_daily.yaml +22 -0
- package/engines/specs/realtime_index.yaml +34 -0
- package/engines/specs/ror.yaml +20 -0
- package/engines/specs/satnogs.yaml +20 -0
- package/engines/specs/sec_edgar.yaml +46 -0
- package/engines/specs/skillsmp.yaml +50 -0
- package/engines/specs/sogou_weixin_browser.yaml +30 -0
- package/engines/specs/soilgrids.yaml +25 -0
- package/engines/specs/sspai.yaml +36 -0
- package/engines/specs/stackexchange.yaml +46 -0
- package/engines/specs/std_samr.yaml +21 -0
- package/engines/specs/tass.yaml +43 -0
- package/engines/specs/tle_mirror.yaml +21 -0
- package/engines/specs/train.yaml +37 -0
- package/engines/specs/tvmaze.yaml +20 -0
- package/engines/specs/twitter_syndication.yaml +31 -0
- package/engines/specs/un_comtrade.yaml +23 -0
- package/engines/specs/unpaywall.yaml +54 -0
- package/engines/specs/vnpress.yaml +43 -0
- package/engines/specs/weather.yaml +32 -0
- package/engines/specs/who_don.yaml +21 -0
- package/engines/specs/who_gho.yaml +20 -0
- package/engines/specs/wikimedia_commons.yaml +34 -0
- package/engines/specs/wikisource.yaml +22 -0
- package/engines/specs/worms.yaml +19 -0
- package/engines/specs/x_trending.yaml +34 -0
- package/engines/specs/yna.yaml +43 -0
- package/engines/specs/ynet.yaml +43 -0
- package/engines/specs/zbmath.yaml +40 -0
- package/engines/specs/zdic.yaml +22 -0
- package/engines/specs/zenodo.yaml +27 -0
- package/engines/specs/zhihu_hot_app.yaml +35 -0
- package/mcp/clients.yaml +74 -0
- package/package.json +51 -12
- package/packages/dsh-plugin/README.md +134 -0
- package/packages/dsh-plugin/cordis.patch.yml +48 -0
- package/packages/dsh-plugin/dsh/client.js +1244 -0
- package/packages/dsh-plugin/dsh/index.js +2290 -0
- package/packages/dsh-plugin/dsh/native-tools.mjs +743 -0
- package/packages/dsh-plugin/package.json +38 -0
- package/packages/dsh-plugin/test/client.test.js +232 -0
- package/packages/dsh-plugin/test/config.test.js +30 -0
- package/packages/dsh-plugin/test/index.test.js +1338 -0
- package/references/query-templates.md +96 -0
- package/references/query-templates.yaml +118 -0
- package/scripts/ab_eval.py +219 -0
- package/scripts/ab_eval_p0p1.py +266 -0
- package/scripts/adaptive.py +271 -70
- package/scripts/answer.py +154 -0
- package/scripts/archive_run.py +591 -0
- package/scripts/argo_engine_registry.py +172 -0
- package/scripts/argo_paths.py +728 -0
- package/scripts/article.py +136 -0
- package/scripts/arxiv_source.py +201 -0
- package/scripts/badcase_ledger.py +252 -0
- package/scripts/batch_probe.py +241 -0
- package/scripts/benchmark.py +19 -0
- package/scripts/bounded_run.py +144 -0
- package/scripts/browser_auth.py +323 -0
- package/scripts/burstiness_detector.py +147 -0
- package/scripts/cache.py +1078 -67
- package/scripts/cache_guard.py +271 -0
- package/scripts/cache_key_vdom.py +56 -0
- package/scripts/cache_similarity.py +170 -0
- package/scripts/caliber_isolation.py +195 -0
- package/scripts/calibrate_lowquality.py +210 -0
- package/scripts/candidate_envelope.py +366 -0
- package/scripts/chrome_cdp.py +871 -0
- package/scripts/circuit_breaker.py +389 -0
- package/scripts/citations.py +334 -0
- package/scripts/cite_cli.py +59 -0
- package/scripts/cjk_tokens.py +95 -0
- package/scripts/clarify.py +47 -24
- package/scripts/cli_io.py +240 -0
- package/scripts/config.py +683 -45
- package/scripts/config_cache.py +402 -0
- package/scripts/conn_pool.py +220 -0
- package/scripts/content_security.py +685 -0
- package/scripts/content_signals.py +730 -0
- package/scripts/crawl.py +74 -35
- package/scripts/daemon.py +866 -0
- package/scripts/digup_bridge.py +214 -0
- package/scripts/engine_admission.py +366 -0
- package/scripts/engine_dispatch.py +1019 -0
- package/scripts/engine_env.py +534 -0
- package/scripts/engine_failure.py +411 -0
- package/scripts/engine_families.py +732 -0
- package/scripts/engine_policy.py +218 -0
- package/scripts/engine_requires.py +190 -0
- package/scripts/engine_status.py +322 -0
- package/scripts/engine_validate.py +638 -0
- package/scripts/engines.py +778 -294
- package/scripts/engines_base.py +1521 -0
- package/scripts/engines_builders.py +298 -0
- package/scripts/engines_builders_batch10.py +159 -0
- package/scripts/engines_builders_batch11.py +174 -0
- package/scripts/engines_builders_batch12.py +103 -0
- package/scripts/engines_builders_batch13.py +340 -0
- package/scripts/engines_builders_batch9.py +1468 -0
- package/scripts/engines_builders_cn.py +2283 -0
- package/scripts/engines_builders_data.py +2923 -0
- package/scripts/engines_builders_data_macro.py +775 -0
- package/scripts/engines_builders_feeds.py +255 -0
- package/scripts/engines_builders_intl.py +1239 -0
- package/scripts/engines_builders_search.py +440 -0
- package/scripts/engines_builders_tech.py +1360 -0
- package/scripts/evidence.py +588 -162
- package/scripts/evidence_loop.py +592 -0
- package/scripts/evidence_tier.py +298 -0
- package/scripts/except_sets.py +25 -0
- package/scripts/extract.py +55 -16
- package/scripts/fact_align.py +207 -0
- package/scripts/fact_crosscheck.py +230 -0
- package/scripts/fetch.py +25 -12
- package/scripts/fetch_browser.py +87 -0
- package/scripts/fetch_jsrun.py +122 -0
- package/scripts/fetch_quality.py +225 -0
- package/scripts/fetch_render_tinyfish.py +117 -0
- package/scripts/fetch_v3.py +1779 -0
- package/scripts/focus_extract.py +350 -0
- package/scripts/fulltext_store.py +169 -0
- package/scripts/fxtwitter_api.py +325 -0
- package/scripts/gen_engine_catalog.py +364 -0
- package/scripts/gen_native_tools.py +91 -0
- package/scripts/gen_routing_snapshot.py +184 -0
- package/scripts/health_check.py +140 -402
- package/scripts/health_probe.py +23 -8
- package/scripts/hot_state.py +134 -0
- package/scripts/http_client.py +1053 -0
- package/scripts/image_ops.py +430 -0
- package/scripts/image_vision/vision_probe +0 -0
- package/scripts/image_vision/vision_probe.swift +126 -0
- package/scripts/install.ps1 +148 -0
- package/scripts/install.sh +206 -0
- package/scripts/job.py +1186 -0
- package/scripts/jump_resolver.py +235 -0
- package/scripts/lang_capability.py +150 -0
- package/scripts/lang_detect.py +289 -0
- package/scripts/lang_matrix_probe.py +329 -0
- package/scripts/lang_pref.py +311 -0
- package/scripts/link_source.py +340 -0
- package/scripts/local_engines.py +321 -0
- package/scripts/local_image.py +970 -0
- package/scripts/local_seek.py +366 -0
- package/scripts/macro_countries.py +82 -0
- package/scripts/matrix_search_eval.py +1105 -0
- package/scripts/mcp_diag.py +8 -3
- package/scripts/mcp_handlers.py +1074 -0
- package/scripts/mcp_handlers_surface.py +157 -0
- package/scripts/mcp_launch.sh +122 -0
- package/scripts/mcp_server.py +118 -417
- package/scripts/mcp_setup.py +407 -0
- package/scripts/mcp_tools.py +330 -0
- package/scripts/mcp_transport.py +191 -0
- package/scripts/min_version_scan.py +335 -0
- package/scripts/net_proxy.py +258 -0
- package/scripts/network_aware.py +152 -0
- package/scripts/paper.py +274 -0
- package/scripts/pdf_extract.py +518 -0
- package/scripts/phrase_filter.py +66 -0
- package/scripts/plan.py +422 -0
- package/scripts/postprocess_signals.py +150 -0
- package/scripts/pre-commit +48 -0
- package/scripts/query_enhance.py +157 -0
- package/scripts/query_operators.py +196 -0
- package/scripts/query_rewriter.py +116 -7
- package/scripts/query_signals.py +300 -0
- package/scripts/query_templates.py +167 -0
- package/scripts/query_understanding.py +411 -0
- package/scripts/query_variants.py +298 -0
- package/scripts/quota.py +488 -76
- package/scripts/rank_signals.py +775 -0
- package/scripts/ranking_eval.py +319 -0
- package/scripts/readability_extract.py +433 -0
- package/scripts/recompute.py +415 -0
- package/scripts/recovery.py +616 -0
- package/scripts/recovery_l0.py +160 -0
- package/scripts/redskill/redskill_engine.py +332 -0
- package/scripts/regression_p0p1.py +318 -0
- package/scripts/replay_eval.py +640 -0
- package/scripts/rerank_tfidf.py +225 -0
- package/scripts/research.py +591 -403
- package/scripts/research_cli.py +361 -0
- package/scripts/research_dossier.py +492 -0
- package/scripts/research_expand.py +146 -0
- package/scripts/research_gates.py +272 -0
- package/scripts/research_report.py +209 -0
- package/scripts/research_strategy.py +37 -0
- package/scripts/research_work_packages.py +195 -0
- package/scripts/result_lang.py +311 -0
- package/scripts/robots_guard.py +259 -0
- package/scripts/route.py +921 -254
- package/scripts/route_bangs.py +40 -0
- package/scripts/route_cache.py +225 -0
- package/scripts/route_combo.py +910 -0
- package/scripts/route_domains.py +328 -0
- package/scripts/route_lang.py +605 -0
- package/scripts/route_log.py +63 -0
- package/scripts/route_policy.py +250 -0
- package/scripts/rrf_classic.py +66 -0
- package/scripts/screenshot.py +73 -0
- package/scripts/search.py +897 -405
- package/scripts/search_attribution.py +88 -0
- package/scripts/search_benchmark.py +407 -0
- package/scripts/search_cli.py +627 -0
- package/scripts/search_entry.py +389 -0
- package/scripts/search_output.py +606 -0
- package/scripts/search_pipeline.py +1077 -0
- package/scripts/search_rank.py +991 -0
- package/scripts/search_types.py +27 -12
- package/scripts/seek_locator.py +68 -0
- package/scripts/semantic_evidence.py +215 -0
- package/scripts/serp_guard.py +228 -0
- package/scripts/serp_kill_log.py +51 -0
- package/scripts/single_flight.py +120 -0
- package/scripts/social_engines/__init__.py +10 -0
- package/scripts/social_engines/bilibili_engine.py +131 -0
- package/scripts/social_engines/hackernews_engine.py +113 -0
- package/scripts/social_engines/reddit_engine.py +172 -0
- package/scripts/social_engines/twitter_engine.py +397 -0
- package/scripts/social_engines/v2ex_engine.py +101 -0
- package/scripts/social_engines/weibo_engine.py +128 -0
- package/scripts/social_engines/xiaohongshu_engine.py +137 -0
- package/scripts/social_engines/zhihu_engine.py +139 -0
- package/scripts/social_research.py +201 -0
- package/scripts/sogou_weixin_browser.py +318 -0
- package/scripts/source_ledger.py +249 -0
- package/scripts/stage_timing.py +58 -0
- package/scripts/stats_cli.py +95 -0
- package/scripts/straggler_policy.py +60 -0
- package/scripts/stylometry_detector.py +231 -0
- package/scripts/sync_backends.py +353 -0
- package/scripts/sync_installs.py +29 -0
- package/scripts/template_cli.py +127 -0
- package/scripts/text_lexicon.py +72 -0
- package/scripts/tfidf_router.py +109 -37
- package/scripts/time_utils.py +233 -0
- package/scripts/topic_research_profiles.py +643 -0
- package/scripts/train.py +737 -0
- package/scripts/tweet.py +596 -0
- package/scripts/url_canon.py +217 -0
- package/scripts/url_optimize.py +56 -0
- package/scripts/url_safety.py +236 -0
- package/scripts/usage_log.py +116 -0
- package/scripts/usage_log_clean.py +113 -0
- package/scripts/v2ex_nodes.py +311 -0
- package/scripts/variant_recall.py +188 -0
- package/scripts/watch.py +205 -0
- package/scripts/wechat_article.py +258 -0
- package/scripts/wide_research.py +402 -0
- package/scripts/wx.py +349 -0
- package/scripts/x_article_fetch.py +141 -0
- package/scripts/x_search.py +234 -0
- package/scripts/x_trending.py +189 -0
- package/scripts/yaml_load.py +67 -0
- package/sub-skills/ego-search/SKILL.md +253 -0
- package/sub-skills/ego-search/references/browser-runtime.md +43 -0
- package/sub-skills/ego-search/references/install.md +67 -0
- package/sub-skills/ego-search/references/original-ego-upgrade.md +51 -0
- package/sub-skills/ego-search/scripts/ego_search.py +800 -0
- package/sub-skills/ego-search/scripts/merge.py +226 -0
- package/sub-skills/ego-search/scripts/quality.py +64 -0
- package/sub-skills/ego-search/scripts/runtime.py +172 -0
- package/sub-skills/ego-search/scripts/safety.py +109 -0
- package/sub-skills/ego-search/scripts/serp_spec.py +109 -0
- package/sub-skills/ego-search/scripts/webbridge_adapter.py +374 -0
- package/sub-skills/ego-search/tests/test_query_normalize.py +68 -0
- package/sub-skills/js-run/SKILL.md +67 -0
- package/sub-skills/js-run/scripts/jsrun.py +112 -0
- package/sub-skills/js-run/scripts/shim.js +188 -0
- package/sub-skills/js-run/tests/fixtures/challenge_like.js +27 -0
- package/sub-skills/js-run/tests/test_jsrun.py +165 -0
- package/sub-skills/local-search/SKILL.md +50 -53
- package/sub-skills/local-search/bm25.py +162 -0
- package/sub-skills/local-search/config.yaml +152 -55
- package/sub-skills/local-search/engine_registry.py +91 -26
- package/sub-skills/local-search/{health_check.py → local_health_check.py} +140 -10
- package/sub-skills/local-search/local_search_adapter.py +13 -2
- package/sub-skills/local-search/parse_maps.yaml +40 -42
- package/sub-skills/local-search/search_v3.py +1182 -65
- package/sub-skills/local-search/smart_router.py +47 -7
- package/sub-skills/local-seek/SKILL.md +110 -0
- package/sub-skills/local-seek/config/domains.yaml +20 -0
- package/sub-skills/local-seek/references/auto-behaviors.md +24 -0
- package/sub-skills/local-seek/references/fd-mdfind-recipes.md +62 -0
- package/sub-skills/local-seek/references/git-integration.md +54 -0
- package/sub-skills/local-seek/references/remote-handoff.md +46 -0
- package/sub-skills/local-seek/references/rg-recipes.md +96 -0
- package/sub-skills/local-seek/references/strategies.md +52 -0
- package/sub-skills/local-seek/references/structural-search.md +59 -0
- package/sub-skills/local-seek/scripts/eval_rank.py +216 -0
- package/sub-skills/local-seek/scripts/eval_seek.py +224 -0
- package/sub-skills/local-seek/scripts/seek.py +1465 -0
- package/sub-skills/local-seek/scripts/seek_frecency.py +126 -0
- package/sub-skills/local-seek/scripts/seek_fts.py +430 -0
- package/sub-skills/local-seek/scripts/seek_rank.py +193 -0
- package/sub-skills/local-seek/tests/test_content_rank.py +222 -0
- package/sub-skills/local-seek/tests/test_determinism_20261006.py +145 -0
- package/sub-skills/local-seek/tests/test_noise_tier.py +101 -0
- package/sub-skills/local-seek/tests/test_pcre2_probe.py +136 -0
- package/sub-skills/local-seek/tests/test_rank_pinyin.py +77 -0
- package/sub-skills/local-seek/tests/test_regression_20261003.py +169 -0
- package/sub-skills/local-seek/tests/test_regression_20261006.py +146 -0
- package/sub-skills/local-seek/tests/test_seek_degradation.py +111 -0
- package/sub-skills/local-seek/tests/test_seek_fts.py +313 -0
|
@@ -0,0 +1,1019 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""engine_dispatch.py — 一次搜索的引擎编排。
|
|
3
|
+
|
|
4
|
+
从 search.py 的 execute_search 中外提:该函数原本一千余行,而「把 N 个引擎跑完
|
|
5
|
+
并收账」这段独占三分之一——限流收紧、重试与每引擎墙钟预算、缺 env 拦截、
|
|
6
|
+
熔断与负缓存、per-engine 缓存、有界并发(wave-1 hedged race / wave-2 / 串行)、
|
|
7
|
+
结局分类与配额记账,全挤在一个函数里,与融合/精排/输出无关的读者也得读它。
|
|
8
|
+
|
|
9
|
+
**依赖显式注入,不在本模块直接 import search。** 三个理由:
|
|
10
|
+
|
|
11
|
+
1. 测试用 `patch.object(search, "engine_search", ...)` / `"get_engines"` /
|
|
12
|
+
`"_missing_env_for"` / `"get_cost_factor"` 替换这些入口(race 预算、
|
|
13
|
+
时间窗、阶段耗时三套用例都靠它)。若本模块直接引用 search 的全局名,
|
|
14
|
+
补丁打在 search 上而实际调用走本模块的绑定,测试会**静默失效**——
|
|
15
|
+
假引擎不被调用、真网络被打开。
|
|
16
|
+
2. 常量 `_FAST_TOTAL_BUDGET_S` / `_PRIMARY_GRACE_S` 同样被测试打补丁,
|
|
17
|
+
必须由调用方在**调用时**读取模块全局再传进来。
|
|
18
|
+
3. 避免 search ⇄ engine_dispatch 循环导入。
|
|
19
|
+
|
|
20
|
+
结论:新增入口时一律走参数,不写模块级 import search。
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import time
|
|
26
|
+
import threading
|
|
27
|
+
from typing import Any, Callable
|
|
28
|
+
|
|
29
|
+
from cache_guard import (
|
|
30
|
+
_QUOTA_ERROR_KEYWORDS,
|
|
31
|
+
attempt_cache_write,
|
|
32
|
+
is_state_failure_outcome,
|
|
33
|
+
)
|
|
34
|
+
from time_utils import is_time_capable
|
|
35
|
+
from query_signals import (
|
|
36
|
+
cumulative_sufficient,
|
|
37
|
+
results_sufficient,
|
|
38
|
+
query_coverage_ok,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# 失败原因分类(engine_failure.py 的类别全集)→ 引擎结果 status 的对照表。
|
|
42
|
+
# 未列出的类别经 .get(cat, "error") 归为 error:以后新增失败类别时默认可见,
|
|
43
|
+
# 不必再回来补白名单。文本里出现超时特征时再细分为 timeout(见 run_one)。
|
|
44
|
+
_NOTE_STATUS = {
|
|
45
|
+
"auth": "auth-failed", # 凭证失效/未登录
|
|
46
|
+
"rate_limited": "rate-limited", # 源端限流
|
|
47
|
+
"quota_exhausted": "quota-exhausted", # 余额/套餐耗尽(停用,非短冷却)
|
|
48
|
+
"blocked": "blocked", # 反爬/拦截页
|
|
49
|
+
"dependency": "error", # 缺后端命令(requires 未满足)
|
|
50
|
+
"upstream": "error", # 上游改版/页面结构变化
|
|
51
|
+
"network": "error", # 连接失败/超时(超时再细分)
|
|
52
|
+
"unknown": "error", # 信息不足(含非 200 状态码)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# 「这次调用对最终答案有贡献」的 outcome 状态(唯一来源)。
|
|
56
|
+
# `no-results` / `skipped-*` / `timeout` / `error` 一律不算——它们消耗了时间但
|
|
57
|
+
# 没有交出任何可用结果。wasted 记账与 timing 可观测面共用这张表。
|
|
58
|
+
_CONTRIBUTING_STATUS = frozenset({"ok", "ok-cached", "partial"})
|
|
59
|
+
|
|
60
|
+
# 非贡献结局 status → 熔断器 failure kind 映射(未列出的状态归为 "error")。
|
|
61
|
+
_BREAKER_FAIL_KIND = {
|
|
62
|
+
"no-results": "empty",
|
|
63
|
+
"timeout": "timeout",
|
|
64
|
+
"blocked": "blocked",
|
|
65
|
+
"rate-limited": "rate-limited",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# 并发上限的兜底值 = 原硬编码的 3。配置缺失 / 非法 / 读不到时用它,
|
|
69
|
+
# 保证「接上配置」这件事不会改变任何既有环境下的行为。
|
|
70
|
+
_DEFAULT_MAX_PARALLEL = 3
|
|
71
|
+
# deep 模式要多铺几个源,值得付更高并发;其余模式沿用配置值。
|
|
72
|
+
# 它现在是**下限**而不是定值:配置调到 6 时 deep 若仍是 5,就成了
|
|
73
|
+
# 「深研比日常还收得更紧」,与这行的本意相反(B-4 并发 3→6 时暴露)。
|
|
74
|
+
_DEEP_MAX_PARALLEL = 5
|
|
75
|
+
# 并发上限的环境覆盖(对拍 / 运维应急)。config.yaml 是唯一真源,但 A/B
|
|
76
|
+
# 要求**同一份代码、逐对交错**切换取值——改配置文件做不到交错(每对都要
|
|
77
|
+
# 重写文件+失效配置磁盘缓存),而「后跑的一组系统性偏慢」在本仓造过 37%
|
|
78
|
+
# 的假阳性。取值非法/<=0 时回落配置,不报错:旋钮是加速器不是依赖。
|
|
79
|
+
_MAX_PARALLEL_ENV = "ARGO_MAX_PARALLEL_ENGINES"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _configured_max_parallel() -> int:
|
|
83
|
+
try:
|
|
84
|
+
import os
|
|
85
|
+
raw = os.environ.get(_MAX_PARALLEL_ENV, "").strip()
|
|
86
|
+
if raw:
|
|
87
|
+
n = int(raw)
|
|
88
|
+
if n >= 1:
|
|
89
|
+
return n
|
|
90
|
+
except (ValueError, TypeError):
|
|
91
|
+
pass
|
|
92
|
+
try:
|
|
93
|
+
from config import get_execution_config
|
|
94
|
+
n = int(get_execution_config().get("max_parallel_engines",
|
|
95
|
+
_DEFAULT_MAX_PARALLEL))
|
|
96
|
+
except Exception:
|
|
97
|
+
return _DEFAULT_MAX_PARALLEL
|
|
98
|
+
return n if n >= 1 else _DEFAULT_MAX_PARALLEL
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _max_parallel_engines(*, depth: str | None = None) -> int:
|
|
102
|
+
"""一轮编排内最多同时跑几个引擎。
|
|
103
|
+
|
|
104
|
+
唯一来源是 config.yaml 的 `execution.max_parallel_engines`——此前这个键
|
|
105
|
+
只在 config.py 的默认值与 config.yaml 里出现,**全仓没有任何读取点**,
|
|
106
|
+
调度用的是 engine_dispatch 里写死的 3:改配置不生效,且没有任何提示。
|
|
107
|
+
调参的人只会得出「改了没用」的结论,不会想到代码没读它。
|
|
108
|
+
|
|
109
|
+
deep 模式取「配置值与 _DEEP_MAX_PARALLEL 的较大者」:深研要证据覆盖、
|
|
110
|
+
优先墙钟,只保证不比日常档更保守,不再压下配置。
|
|
111
|
+
"""
|
|
112
|
+
n = _configured_max_parallel()
|
|
113
|
+
if depth == "deep":
|
|
114
|
+
return max(_DEEP_MAX_PARALLEL, n)
|
|
115
|
+
return n
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _adaptive_grace(engine: str, base_grace_s: float) -> float:
|
|
119
|
+
"""对冲宽限窗的自适应收窄:快引擎收窄到自己的 P75,慢引擎不动(只缩不放)。
|
|
120
|
+
|
|
121
|
+
窗口语义不变(见 search._PRIMARY_GRACE_S):「先给首选引擎多久,没回来才
|
|
122
|
+
补发备选源」。变的只是窗的取值来源——固定 0.8s 对快引擎(redskill P75
|
|
123
|
+
236ms、eastmoney 258ms)太钝:主引擎异常挂住时要多等 0.5s 才有人救场;
|
|
124
|
+
而对慢引擎(anysearch P75 2.6s)0.8s 已经是「几乎必补发」,放宽它只是
|
|
125
|
+
把尾延迟换成调用量,方向不对(用户取舍:稳定性 > 尾延迟 > 成本)。
|
|
126
|
+
|
|
127
|
+
所以这里**只缩不放**:grace = max(0.3, min(base, P75(引擎))),上界仍是
|
|
128
|
+
base(默认 0.8s),下界 0.3s 与 wave-1 的既有 floor 同源。快引擎的对冲
|
|
129
|
+
提前到「比它平时明显慢」的时刻点火,尾部有人救场;慢引擎行为逐位不变。
|
|
130
|
+
|
|
131
|
+
口径与失败处理:
|
|
132
|
+
- P75 取 adaptive.latency_percentile(7 天窗、仅 success 且非空的交付),
|
|
133
|
+
样本不足 5 条或快照过时(STALE_AFTER_SECONDS)→ 不收窄,回到 base。
|
|
134
|
+
- 任何异常(学习库损坏/被锁/缺模块)一律 fail-open 回 base,观测层不阻断
|
|
135
|
+
调度主路径(同 file_lock / 熔断器的契约)。
|
|
136
|
+
- `ARGO_ADAPTIVE_GRACE=0` 整体关闭(回固定 base 窗)。**默认开**:v2 冻结期
|
|
137
|
+
的 A/B 曾判未证实收益,c52b7d4 起转默认启用——「只缩不放」对慢引擎逐位
|
|
138
|
+
无行为变化,收益只落在快引擎的救场提前量上。要退回去设 =0,别改这里默认值。
|
|
139
|
+
"""
|
|
140
|
+
try:
|
|
141
|
+
from engine_env import env_flag
|
|
142
|
+
if not env_flag("ARGO_ADAPTIVE_GRACE", True):
|
|
143
|
+
return base_grace_s
|
|
144
|
+
except Exception:
|
|
145
|
+
return base_grace_s
|
|
146
|
+
try:
|
|
147
|
+
base = float(base_grace_s)
|
|
148
|
+
except (TypeError, ValueError):
|
|
149
|
+
return base_grace_s
|
|
150
|
+
if base <= 0.3 or not engine:
|
|
151
|
+
return base_grace_s
|
|
152
|
+
try:
|
|
153
|
+
from adaptive import get_learner
|
|
154
|
+
p75_ms = get_learner().latency_percentile(engine, 0.75)
|
|
155
|
+
except Exception:
|
|
156
|
+
return base_grace_s
|
|
157
|
+
if not p75_ms or p75_ms <= 0:
|
|
158
|
+
return base_grace_s
|
|
159
|
+
return max(0.3, min(base, float(p75_ms) / 1000.0))
|
|
160
|
+
|
|
161
|
+
# 拦截页特征词(唯一来源):error 文本里出现即判 blocked。HTML 引擎的反爬
|
|
162
|
+
# 命中没有 error 文本(静默空结果),走 engines_base 的归因寄存器;
|
|
163
|
+
# 这张表兜住「error 结果里带拦截页字样」的可见路径。
|
|
164
|
+
_BLOCKED_ERROR_KEYWORDS = (
|
|
165
|
+
"just a moment", "checking your browser", "cf-browser-verification",
|
|
166
|
+
"challenge", "ddos-guard", "perimeterx", "access denied",
|
|
167
|
+
"handshake failure", "unable to handshake", "安全验证", "滑动验证",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def classify_engine_outcome(eng: str, res: list[dict[str, Any]],
|
|
172
|
+
latency_ms: int, status_hint: str | None = None
|
|
173
|
+
) -> dict[str, Any]:
|
|
174
|
+
"""将单引擎结果归类为可观测 outcome。"""
|
|
175
|
+
if status_hint:
|
|
176
|
+
return {
|
|
177
|
+
"engine": eng, "status": status_hint,
|
|
178
|
+
"results_count": 0, "latency_ms": latency_ms,
|
|
179
|
+
}
|
|
180
|
+
if not res:
|
|
181
|
+
return {
|
|
182
|
+
"engine": eng, "status": "no-results",
|
|
183
|
+
"results_count": 0, "latency_ms": latency_ms,
|
|
184
|
+
}
|
|
185
|
+
errors = [r for r in res if isinstance(r, dict) and "error" in r]
|
|
186
|
+
goods = [r for r in res if isinstance(r, dict) and "error" not in r]
|
|
187
|
+
if errors and not goods:
|
|
188
|
+
msg = str(errors[0].get("error", "")).lower()
|
|
189
|
+
if "timeout" in msg:
|
|
190
|
+
st = "timeout"
|
|
191
|
+
elif any(k in msg for k in _QUOTA_ERROR_KEYWORDS):
|
|
192
|
+
st = "quota-exhausted"
|
|
193
|
+
elif "rate" in msg or "429" in msg:
|
|
194
|
+
st = "rate-limited"
|
|
195
|
+
elif any(k in msg for k in _BLOCKED_ERROR_KEYWORDS):
|
|
196
|
+
st = "blocked"
|
|
197
|
+
elif "auth" in msg or "401" in msg or "403" in msg:
|
|
198
|
+
st = "auth-failed"
|
|
199
|
+
else:
|
|
200
|
+
st = "error"
|
|
201
|
+
return {
|
|
202
|
+
"engine": eng, "status": st,
|
|
203
|
+
"results_count": 0, "latency_ms": latency_ms,
|
|
204
|
+
"detail": str(errors[0].get("error", ""))[:200],
|
|
205
|
+
}
|
|
206
|
+
if goods and errors:
|
|
207
|
+
return {
|
|
208
|
+
"engine": eng, "status": "partial",
|
|
209
|
+
"results_count": len(goods), "latency_ms": latency_ms,
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
"engine": eng, "status": "ok",
|
|
213
|
+
"results_count": len(goods), "latency_ms": latency_ms,
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class DispatchResult:
|
|
218
|
+
"""编排产出 + 两个钩子(融合后仍要补搜的路径用)。
|
|
219
|
+
|
|
220
|
+
`run_one` / `ingest` 必须外露:macro_data 域的 D6 证据下限补搜在融合之后,
|
|
221
|
+
它要跑单个引擎并把结果并进同一份账(raw_results / engine_outcomes /
|
|
222
|
+
engine_latency / wasted 计数),另起一份会让 engines_used 与配额记账对不上。
|
|
223
|
+
|
|
224
|
+
`budget_used_ms` / `budget_total_ms`:本次编排的实际墙钟消耗与总预算
|
|
225
|
+
(deep 等无预算模式 total 为 None)。搜索输出把它挂进 timing.budget,
|
|
226
|
+
让「这次离预算上限还有多远」可观测,而不必反推 process_ms。
|
|
227
|
+
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
__slots__ = ("raw_results", "engine_outcomes", "engine_latency",
|
|
231
|
+
"wasted_ms", "early_stopped", "run_one", "ingest",
|
|
232
|
+
"budget_used_ms", "budget_total_ms", "useful_ms")
|
|
233
|
+
|
|
234
|
+
def __init__(self, raw_results, engine_outcomes, engine_latency,
|
|
235
|
+
wasted_ms, early_stopped, run_one, ingest,
|
|
236
|
+
budget_used_ms=None, budget_total_ms=None,
|
|
237
|
+
useful_ms: int = 0) -> None:
|
|
238
|
+
self.raw_results = raw_results
|
|
239
|
+
self.engine_outcomes = engine_outcomes
|
|
240
|
+
self.engine_latency = engine_latency
|
|
241
|
+
self.wasted_ms = wasted_ms
|
|
242
|
+
self.early_stopped = early_stopped
|
|
243
|
+
self.run_one = run_one
|
|
244
|
+
self.ingest = ingest
|
|
245
|
+
self.budget_used_ms = budget_used_ms
|
|
246
|
+
self.budget_total_ms = budget_total_ms
|
|
247
|
+
self.useful_ms = useful_ms
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def run_dispatch(*, query: str, retrieval_query: str, engines: list[str],
|
|
251
|
+
decision: dict[str, Any], parallel: bool,
|
|
252
|
+
domain: str, mode: str, depth: str,
|
|
253
|
+
max_results: int, timeout: int, net_timeout: float,
|
|
254
|
+
skip_cache: bool, cache: Any, breaker: Any,
|
|
255
|
+
since_iso: str | None, until_iso: str | None,
|
|
256
|
+
t0: float,
|
|
257
|
+
t0_mono: float | None = None,
|
|
258
|
+
engine_search: Callable,
|
|
259
|
+
get_engines_fn: Callable,
|
|
260
|
+
get_execution_config_fn: Callable,
|
|
261
|
+
missing_env_for: Callable,
|
|
262
|
+
classify_outcome: Callable,
|
|
263
|
+
quota_batch: Any,
|
|
264
|
+
note_quota_exhausted: Callable,
|
|
265
|
+
per_engine_budget_s: float,
|
|
266
|
+
fast_budget_s: float,
|
|
267
|
+
auto_budget_s: float,
|
|
268
|
+
deep_budget_s: float | None = None,
|
|
269
|
+
primary_grace_s: float,
|
|
270
|
+
straggler_grace_s: float = 1.5,
|
|
271
|
+
straggler_tail_fn: Callable[[str, float], float] | None = None,
|
|
272
|
+
serial_stagger_s: float = 0.8,
|
|
273
|
+
engine_domain: str | None = None,
|
|
274
|
+
engine_sub_domain: str | None = None) -> DispatchResult:
|
|
275
|
+
"""把 engines 跑完并收账:并发/串行调度 → 结局分类 → 熔断与配额记账。
|
|
276
|
+
|
|
277
|
+
依赖全部显式注入(原因见模块头)。返回编排产出与两个补搜钩子。
|
|
278
|
+
"""
|
|
279
|
+
raw_results: dict[str, list[dict[str, Any]]] = {}
|
|
280
|
+
engine_outcomes: list[dict[str, Any]] = []
|
|
281
|
+
engine_latency: dict[str, int] = {}
|
|
282
|
+
|
|
283
|
+
# 远端配额耗尽快照(route_combo 的 F7 已在 combo 层排除 hard-down
|
|
284
|
+
# 引擎;本快照供下方 per-engine 闸口兜底主路由/恢复链/显式 engine=
|
|
285
|
+
# 路径)。一次性取快照:per-engine 闸口只做集合查询,不再逐引擎
|
|
286
|
+
# 走配额管理器的热读锁。fail-open:读取失败按「无耗尽」处理,
|
|
287
|
+
# 交由执行层把配额错误暴露出来(与 route_combo 同口径)。
|
|
288
|
+
try:
|
|
289
|
+
from quota import get_quota_manager
|
|
290
|
+
_exhausted_marks = get_quota_manager().remote_exhausted_marks()
|
|
291
|
+
except Exception:
|
|
292
|
+
_exhausted_marks = {}
|
|
293
|
+
|
|
294
|
+
exec_cfg = get_execution_config_fn()
|
|
295
|
+
retry_count = exec_cfg.get("retry_count", 0)
|
|
296
|
+
# 单引擎墙钟预算:config `execution.per_engine_budget_s` 可覆盖。
|
|
297
|
+
# 用 exec_cfg 读取(与本函数其它 execution 项同源),这样用户可在
|
|
298
|
+
# config.yaml 调整而无需改代码;非法值(非正数)回落到常量默认。
|
|
299
|
+
try:
|
|
300
|
+
_budget_cfg = float(exec_cfg.get("per_engine_budget_s",
|
|
301
|
+
per_engine_budget_s))
|
|
302
|
+
except (TypeError, ValueError):
|
|
303
|
+
_budget_cfg = per_engine_budget_s
|
|
304
|
+
if _budget_cfg <= 0:
|
|
305
|
+
_budget_cfg = per_engine_budget_s
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# 慢源禁重试:timeout ≥ 8s 的引擎超时即放弃,避免「10s×3 次=30s」线性放大。
|
|
309
|
+
# 超时本质上是源端慢/网络抖,重试不改变结果,只放大尾延迟;快速失败
|
|
310
|
+
# (连接错/4xx)保留重试,重试成本低。
|
|
311
|
+
try:
|
|
312
|
+
_engine_specs = get_engines_fn()
|
|
313
|
+
except Exception as e:
|
|
314
|
+
import sys
|
|
315
|
+
sys.stderr.write(
|
|
316
|
+
f"[dispatch] 引擎规格加载失败({type(e).__name__}),"
|
|
317
|
+
f"本次按无声明超时/重试\n")
|
|
318
|
+
_engine_specs = {}
|
|
319
|
+
|
|
320
|
+
def _declared_timeout(eng: str) -> float | None:
|
|
321
|
+
"""引擎声明的超时(秒);缺失/非数值/非正数一律 None。
|
|
322
|
+
|
|
323
|
+
这段解析此前在「重试策略」与「超时收紧」两处各写了一遍,任何一处漏掉
|
|
324
|
+
类型守卫,另一条路径就会拿到 `None >= 8.0` 的 TypeError;把「声明超时
|
|
325
|
+
长什么样」定在一处,两条路径就永远同步。
|
|
326
|
+
"""
|
|
327
|
+
spec = (_engine_specs or {}).get(eng) or {}
|
|
328
|
+
if not isinstance(spec, dict):
|
|
329
|
+
return None
|
|
330
|
+
t = spec.get("timeout")
|
|
331
|
+
# bool 是 int 的子类:`timeout: true` 会被 isinstance 放行成 1.0 秒
|
|
332
|
+
if isinstance(t, bool) or not isinstance(t, (int, float)):
|
|
333
|
+
return None
|
|
334
|
+
return float(t) if t > 0 else None
|
|
335
|
+
|
|
336
|
+
def _engine_retries(eng: str) -> int:
|
|
337
|
+
eng_timeout = _declared_timeout(eng)
|
|
338
|
+
if eng_timeout is not None and eng_timeout >= 8.0:
|
|
339
|
+
return 0
|
|
340
|
+
return retry_count
|
|
341
|
+
|
|
342
|
+
def _engine_domain_kwargs() -> dict[str, str]:
|
|
343
|
+
"""CLI --domain / --sub_domain 的引擎入参(未给则不下发空键)。
|
|
344
|
+
|
|
345
|
+
刻意**不**并进缓存键:这两个值此前既没进键也没进请求,而任何引擎
|
|
346
|
+
都可能对同 query 返回不同结果集;先让参数真正生效,键的隔离由
|
|
347
|
+
engine_domain_key 单独补齐(见下方 cache key 注释)。
|
|
348
|
+
"""
|
|
349
|
+
out: dict[str, str] = {}
|
|
350
|
+
if engine_domain:
|
|
351
|
+
out["domain"] = engine_domain
|
|
352
|
+
if engine_sub_domain:
|
|
353
|
+
out["sub_domain"] = engine_sub_domain
|
|
354
|
+
return out
|
|
355
|
+
|
|
356
|
+
def _exec_engine(eng: str, retries: int | None = None,
|
|
357
|
+
eff_timeout: float | None = None) -> list[dict[str, Any]]:
|
|
358
|
+
# P0-001:用 retrieval_query(clean_query)检索
|
|
359
|
+
if retries is None:
|
|
360
|
+
retries = _engine_retries(eng)
|
|
361
|
+
# 默认超时用网络感知后的 net_timeout(慢网放大),与外层等待预算一致;
|
|
362
|
+
# 非 tight 引擎(anysearch 等)慢网下同样获得放大窗口。
|
|
363
|
+
to = eff_timeout if eff_timeout is not None else net_timeout
|
|
364
|
+
|
|
365
|
+
# ── 每引擎墙钟硬预算 ──────────────────────────────────────────
|
|
366
|
+
# 问题(2026-09-10 实测):重试会**叠乘**。anysearch 曾同时有
|
|
367
|
+
# 引擎级重试 retry_count=1 → 2 次
|
|
368
|
+
# HTTP 级重试 max_retries=1 → 2 次
|
|
369
|
+
# 8s 超时
|
|
370
|
+
# 最坏 2×2×8 = 32s(实测 31.3s)。用户侧表现是「搜一个查询卡半分钟」,
|
|
371
|
+
# 而这期间既没有切备选源、也没有任何信号说明在等什么。
|
|
372
|
+
#
|
|
373
|
+
# 修法不是逐处调小超时(那会误杀慢网下正常的源),而是给**单个引擎的
|
|
374
|
+
# 总墙钟**设上界:后续尝试的可用超时 = 剩余预算,预算耗尽即停。
|
|
375
|
+
# 这样无论嵌套几层重试,单引擎都不可能超过 cap。
|
|
376
|
+
# 取值优先级:execution.per_engine_budget_s(config)> 常量默认 10.0。
|
|
377
|
+
# fast 模式已有 6s 全局预算,此处取更紧的那个,避免互相打架。
|
|
378
|
+
_eng_budget = _budget_cfg
|
|
379
|
+
if mode == "fast":
|
|
380
|
+
_eng_budget = min(_eng_budget, fast_budget_s)
|
|
381
|
+
_t_eng_start = _now()
|
|
382
|
+
|
|
383
|
+
last_result: list[dict[str, Any]] = []
|
|
384
|
+
for _attempt in range(retries + 1):
|
|
385
|
+
_remain = _eng_budget - (_now() - _t_eng_start)
|
|
386
|
+
# 只跳过**后续**尝试。首次必须发出:若因预算小而整段跳过,
|
|
387
|
+
# 引擎的 outcome 会从 timeout 变成 no-results —— 语义从「慢」
|
|
388
|
+
# 变成「没尝试」,会破坏既有 fast 预算测试的契约
|
|
389
|
+
# (实测:patch 预算 0.5s 时首试被跳过,slow_bad_a/b 被标成
|
|
390
|
+
# no-results 而非 timeout)。
|
|
391
|
+
if _attempt > 0 and _remain <= 0.5:
|
|
392
|
+
break
|
|
393
|
+
# 每次尝试的可用超时 = min(声明超时, 剩余预算),下限 0.5s:
|
|
394
|
+
# 首试受总预算约束(否则 fast 的 6s 预算会被 8s 首试突破),
|
|
395
|
+
# 后续尝试自动收缩,保证单引擎总耗时不越界。
|
|
396
|
+
attempt_to = min(to, max(0.5, _remain))
|
|
397
|
+
last_result = engine_search(
|
|
398
|
+
retrieval_query, eng, n=max_results, timeout=attempt_to, depth=depth, mode=mode,
|
|
399
|
+
since=since_iso, until=until_iso, skip_cache=skip_cache,
|
|
400
|
+
**_engine_domain_kwargs(),
|
|
401
|
+
)
|
|
402
|
+
if last_result and any("error" not in r for r in last_result):
|
|
403
|
+
return last_result
|
|
404
|
+
# 慢源(retries=0,超时即弃)不再用 balanced 补跑,避免超时场景双倍耗时
|
|
405
|
+
if retries > 0 and depth != "balanced":
|
|
406
|
+
_remain = _eng_budget - (_now() - _t_eng_start)
|
|
407
|
+
if _remain > 0.5:
|
|
408
|
+
last_result = engine_search(
|
|
409
|
+
retrieval_query, eng, n=max_results,
|
|
410
|
+
timeout=min(to, max(0.5, _remain)),
|
|
411
|
+
depth="balanced", mode=mode,
|
|
412
|
+
since=since_iso, until=until_iso, skip_cache=skip_cache,
|
|
413
|
+
**_engine_domain_kwargs(),
|
|
414
|
+
)
|
|
415
|
+
return last_result
|
|
416
|
+
|
|
417
|
+
def _run_one(eng: str) -> tuple[str, list[dict[str, Any]], dict[str, Any], int]:
|
|
418
|
+
"""单引擎:缺 env → 负缓存 → 熔断 → per-engine 缓存 → 网络。"""
|
|
419
|
+
from engines_base import pop_failure_note
|
|
420
|
+
t_eng = _now()
|
|
421
|
+
|
|
422
|
+
# 缺环境变量前置拦截:把「静默 no-results」变成可行动的 error。
|
|
423
|
+
# 显式 engine= 覆盖会绕过路由的 env 过滤(zhihu/exa 未配密钥时曾
|
|
424
|
+
# 返回空列表,用户无法区分「没结果」和「没配置」)。
|
|
425
|
+
missing_env = missing_env_for(eng)
|
|
426
|
+
if missing_env:
|
|
427
|
+
lat = int((_now() - t_eng) * 1000)
|
|
428
|
+
outcome = classify_outcome(
|
|
429
|
+
eng, [], lat, status_hint="skipped-missing-env")
|
|
430
|
+
outcome["detail"] = (
|
|
431
|
+
f"缺少环境变量:{' / '.join(missing_env)}(配置后重试)")
|
|
432
|
+
return eng, [], outcome, lat
|
|
433
|
+
|
|
434
|
+
# 时间窗只隔离带时间能力引擎的 per-engine 缓存(与 combo 键同语义)
|
|
435
|
+
eng_since = since_iso if is_time_capable(eng) else None
|
|
436
|
+
eng_until = until_iso if is_time_capable(eng) else None
|
|
437
|
+
|
|
438
|
+
# 远端配额耗尽:配额状态机已标记的引擎不参与调用。
|
|
439
|
+
# route_combo 的 F7 只覆盖 combo 路由;主路由(TF-IDF)/
|
|
440
|
+
# 恢复链 / 显式 engine= 都不经那里,耗尽引擎被路由即白烧
|
|
441
|
+
# 一次往返并触发恢复链(实测 bocha 403「not enough money」:
|
|
442
|
+
# 单轮 ~0.9s 引擎调用 + ~1.7s 恢复 = ~2.6s 纯浪费,占基线
|
|
443
|
+
# 总时长 63%)。early-return 与熔断/缺密钥同形;skip 状态的
|
|
444
|
+
# 下游记账由 ingest 段同名分支处理(不计失败、不负缓存、
|
|
445
|
+
# 不重标记——标记只由真 403 现场写,避免滑动延长冷却窗口)。
|
|
446
|
+
if eng in _exhausted_marks:
|
|
447
|
+
lat = int((_now() - t_eng) * 1000)
|
|
448
|
+
outcome = classify_outcome(
|
|
449
|
+
eng, [], lat, status_hint="skipped-quota-exhausted")
|
|
450
|
+
_mark = _exhausted_marks.get(eng) or {}
|
|
451
|
+
_until = float(_mark.get("until") or 0)
|
|
452
|
+
_until_s = (time.strftime("%m-%d %H:%M", time.localtime(_until))
|
|
453
|
+
if _until else "未知")
|
|
454
|
+
outcome["detail"] = (
|
|
455
|
+
f"远端配额耗尽,跳过调用(自愈至 {_until_s}):"
|
|
456
|
+
f"{str(_mark.get('reason') or '')[:80]}")
|
|
457
|
+
return eng, [], outcome, lat
|
|
458
|
+
|
|
459
|
+
# 熔断
|
|
460
|
+
if breaker is not None:
|
|
461
|
+
allowed, reason = breaker.allow(eng)
|
|
462
|
+
if not allowed:
|
|
463
|
+
lat = int((_now() - t_eng) * 1000)
|
|
464
|
+
outcome = classify_outcome(eng, [], lat, status_hint="skipped-circuit-open")
|
|
465
|
+
outcome["detail"] = reason
|
|
466
|
+
return eng, [], outcome, lat
|
|
467
|
+
neg = breaker.get_negative(query, eng)
|
|
468
|
+
if neg:
|
|
469
|
+
lat = int((_now() - t_eng) * 1000)
|
|
470
|
+
outcome = classify_outcome(
|
|
471
|
+
eng, [], lat, status_hint="no-results-cached",
|
|
472
|
+
)
|
|
473
|
+
outcome["detail"] = neg.get("status", "no-results")
|
|
474
|
+
return eng, [], outcome, lat
|
|
475
|
+
|
|
476
|
+
# per-engine 缓存
|
|
477
|
+
if not skip_cache:
|
|
478
|
+
# 键用 retrieval_query(clean_query)而非原始 query:实际发往引擎
|
|
479
|
+
# 的是 retrieval_query(389-400 行),键与内容必须同源——否则
|
|
480
|
+
# clean_query 逻辑一变,同键会照发旧语义的结果(2026-10-17 审查)。
|
|
481
|
+
eng_hit = cache.get_engine(
|
|
482
|
+
retrieval_query, eng, max_results, domain=domain, mode=mode, depth=depth,
|
|
483
|
+
since=eng_since, until=eng_until,
|
|
484
|
+
engine_domain=engine_domain, engine_sub_domain=engine_sub_domain,
|
|
485
|
+
)
|
|
486
|
+
if eng_hit is not None:
|
|
487
|
+
lat = int((_now() - t_eng) * 1000)
|
|
488
|
+
# 标记缓存来源
|
|
489
|
+
for r in eng_hit:
|
|
490
|
+
if isinstance(r, dict):
|
|
491
|
+
r.setdefault("_engine", eng)
|
|
492
|
+
outcome = classify_outcome(eng, eng_hit, lat)
|
|
493
|
+
outcome["status"] = "ok-cached" if eng_hit else "no-results-cached"
|
|
494
|
+
return eng, eng_hit, outcome, lat
|
|
495
|
+
|
|
496
|
+
# 网络调用
|
|
497
|
+
# 答案型域(early_min 存在,1 条快照即可交付)的慢源收紧超时:
|
|
498
|
+
# FRED/Eurostat 这类 timeout=10s 的源一旦挂掉就阻塞整条串行路径,
|
|
499
|
+
# 而快源(worldbank 等 ~150ms)已能交付答案。慢源 5s 内没回就让位。
|
|
500
|
+
# 非答案域(fast/auto/budget 且非 deep):timeout≥10s 的引擎同样收紧
|
|
501
|
+
# 到 6s——多数正常引擎 <2s,10-15s 的超时只为极端慢源保底,
|
|
502
|
+
# 串行/并行组合里一个慢源就会拖垮整个响应尾部。
|
|
503
|
+
eff_to: float | None = None
|
|
504
|
+
_tighten = (early_min is not None) or (
|
|
505
|
+
mode in ("fast", "auto", "budget") and depth != "deep"
|
|
506
|
+
)
|
|
507
|
+
if _tighten:
|
|
508
|
+
eng_to = _declared_timeout(eng)
|
|
509
|
+
cap = 5.0 if early_min is not None else 6.0
|
|
510
|
+
# half_open 半开探测收紧到 2s:熔断器允许半开探测恢复,但探测应短促,
|
|
511
|
+
# 避免 6s 探测阻塞串行/并行主路径(慢源拖尾主因)。2026-08 修复。
|
|
512
|
+
if breaker is not None:
|
|
513
|
+
try:
|
|
514
|
+
if breaker.status(eng).get("state") == "half_open":
|
|
515
|
+
cap = min(cap, 2.0)
|
|
516
|
+
except Exception as _exc:
|
|
517
|
+
# 读失败按默认 cap 处理(fail-open),但必须留痕:
|
|
518
|
+
# 静默回退曾让「探测收紧到 2s」的慢源修复失明
|
|
519
|
+
import logging
|
|
520
|
+
logging.getLogger("unified_search").debug(
|
|
521
|
+
f"breaker.status({eng}) 读取失败,探测收紧未生效: {_exc!r}")
|
|
522
|
+
if eng_to is not None and eng_to >= 8.0:
|
|
523
|
+
eff_to = min(float(timeout), cap)
|
|
524
|
+
# 声明值 < 8s 的收紧由 engines.search 分发层统一执行
|
|
525
|
+
# (spec timeout 是硬上限,调用方超时不得覆盖)
|
|
526
|
+
try:
|
|
527
|
+
res = _exec_engine(eng, eff_timeout=eff_to)
|
|
528
|
+
except Exception as e:
|
|
529
|
+
res = [{"error": str(e), "source": eng}]
|
|
530
|
+
lat = int((_now() - t_eng) * 1000)
|
|
531
|
+
for r in res:
|
|
532
|
+
if isinstance(r, dict):
|
|
533
|
+
r.setdefault("_engine", eng)
|
|
534
|
+
r.setdefault("_elapsed", lat / 1000.0)
|
|
535
|
+
|
|
536
|
+
outcome = classify_outcome(eng, res, lat)
|
|
537
|
+
# 失败原因合入:引擎内部那些不报错的失败路径(反爬命中/HTTP 状态码/网络异常)
|
|
538
|
+
# 没有 error 文本,结果会落成 no-results;失败原因记录把它们还原成
|
|
539
|
+
# 真实状态,供熔断与 --json 可观测面使用。
|
|
540
|
+
#
|
|
541
|
+
# 改用「类别对照表 + 文本细分」而不是 if-elif 白名单:白名单只认
|
|
542
|
+
# blocked/rate_limited/auth,engine_failure.py 类别全集里的
|
|
543
|
+
# network(超时/连接失败)、upstream(上游改版)、unknown(非 200
|
|
544
|
+
# 状态码)、dependency(缺后端命令)会被当成 no-results——
|
|
545
|
+
# 2026-09-15 实测:`--engine you` 的 SSL 超时上报成
|
|
546
|
+
# `status=completed, count=0, errors=[]`,调用方(Agent)据此判定
|
|
547
|
+
# 「网上没有这个信息」,而引擎实际坏了;熔断还按 empty 记账
|
|
548
|
+
# (不累计 opens、负缓存用 EMPTY_NEGATIVE_TTL 45s 而非 30s)。
|
|
549
|
+
_note = pop_failure_note(eng)
|
|
550
|
+
_attr: dict[str, Any] | None = None
|
|
551
|
+
if _note and outcome["status"] in ("no-results", "error", "auth-failed"):
|
|
552
|
+
_text = f"{_note.get('reason', '')} {_note.get('detail', '')}".strip()
|
|
553
|
+
_mapped = _NOTE_STATUS.get(str(_note.get("category") or ""), "error")
|
|
554
|
+
if _mapped == "error" and (
|
|
555
|
+
"timeout" in _text.lower() or "timed out" in _text.lower()):
|
|
556
|
+
_mapped = "timeout"
|
|
557
|
+
# auth 只在引擎确实无输出时升级:已有明确 error 时不改写(原语义)
|
|
558
|
+
if not (_mapped == "auth-failed" and outcome["status"] != "no-results"):
|
|
559
|
+
outcome["status"] = _mapped
|
|
560
|
+
outcome["detail"] = _text or outcome.get("detail")
|
|
561
|
+
if _note:
|
|
562
|
+
# 归因随熔断状态一起持久化:「为什么坏」必须在失败现场写下来,
|
|
563
|
+
# 事后只能看到 kind 粗标签(把 kind 当响应文本再归类只会得到 unknown)
|
|
564
|
+
try:
|
|
565
|
+
from engine_failure import from_note
|
|
566
|
+
_attr = from_note(_note, eng)
|
|
567
|
+
except ImportError:
|
|
568
|
+
_attr = None
|
|
569
|
+
goods = [r for r in res if isinstance(r, dict) and "error" not in r]
|
|
570
|
+
quota_batch.add(eng, bool(goods))
|
|
571
|
+
if outcome["status"] == "quota-exhausted":
|
|
572
|
+
# 远端配额耗尽:交由 quota 状态机接管(周期边界自愈),
|
|
573
|
+
# 不计入下面的健康熔断——配额问题不是引擎健康问题
|
|
574
|
+
note_quota_exhausted(eng, outcome.get("detail") or "")
|
|
575
|
+
|
|
576
|
+
if breaker is not None:
|
|
577
|
+
st = outcome["status"]
|
|
578
|
+
if st in _CONTRIBUTING_STATUS:
|
|
579
|
+
# 「交出可用结果」的判据只在这一张表里(同 _ingest 的 wasted 记账):
|
|
580
|
+
# partial 是「有结果 + 有错误条目」,同样属于贡献者。
|
|
581
|
+
breaker.record_success(eng)
|
|
582
|
+
breaker.clear_negative(query, eng)
|
|
583
|
+
elif st == "quota-exhausted":
|
|
584
|
+
# 配额问题交由配额状态机接管,不计入健康熔断,但保留归因供诊断。
|
|
585
|
+
breaker.record_note(eng, _attr)
|
|
586
|
+
elif st != "skipped-quota-exhausted":
|
|
587
|
+
breaker.record_failure(
|
|
588
|
+
eng, kind=_BREAKER_FAIL_KIND.get(st, "error"), attribution=_attr
|
|
589
|
+
)
|
|
590
|
+
# 配置/状态类失败(缺密钥/熔断/鉴权/配额)不写负缓存,换好环境即恢复。
|
|
591
|
+
if not is_state_failure_outcome(outcome):
|
|
592
|
+
breaker.set_negative(query, eng, status=st)
|
|
593
|
+
|
|
594
|
+
# 写 per-engine 缓存。守卫拒绝(登录态 / 退化 / 失败态)只该跳过这次写入:
|
|
595
|
+
# 异常不接的话会被 _daemon_start 的兜底 except 接走,把一次**成功**的
|
|
596
|
+
# 引擎调用改写成 status=error、结果清空,还按 kind=error 记账进熔断器
|
|
597
|
+
# ——健康引擎被自己刚交出的结果推向 auto-disable(2026-09-27 实锤)。
|
|
598
|
+
# 空结果同样要写(短 TTL 负缓存,配合熔断的 set_negative);TTL 的
|
|
599
|
+
# 空/非空分支由 cache.set_engine 内部处理,这里只决定写什么。
|
|
600
|
+
# 配置/状态类失败(缺密钥/熔断/鉴权/配额)连空负缓存也不写:配好
|
|
601
|
+
# 密钥后 45s 内仍要回放 {"results": []},与 combo 层同款事故。
|
|
602
|
+
if not skip_cache and not is_state_failure_outcome(outcome):
|
|
603
|
+
attempt_cache_write(
|
|
604
|
+
lambda: cache.set_engine(
|
|
605
|
+
# 与上方 get_engine 同源:键=实际检索词(retrieval_query)
|
|
606
|
+
retrieval_query, eng, max_results, goods if goods else [],
|
|
607
|
+
domain=domain, mode=mode, depth=depth,
|
|
608
|
+
since=eng_since, until=eng_until,
|
|
609
|
+
engine_domain=engine_domain, engine_sub_domain=engine_sub_domain,
|
|
610
|
+
),
|
|
611
|
+
context=f"dispatch.cache_set_engine({eng})",
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
return eng, (goods if goods else res), outcome, lat
|
|
615
|
+
|
|
616
|
+
def _ingest(eng: str, res: list, outcome: dict, lat: int) -> None:
|
|
617
|
+
raw_results[eng] = res
|
|
618
|
+
engine_outcomes.append(outcome)
|
|
619
|
+
engine_latency[eng] = lat
|
|
620
|
+
# 完成时刻(相对本次编排起点):wasted 的真值来源,见函数尾部的记账
|
|
621
|
+
engine_done_ms[eng] = (_now() - _budget_base) * 1000.0
|
|
622
|
+
if outcome["status"] in _CONTRIBUTING_STATUS:
|
|
623
|
+
contributed_ms.append(engine_done_ms[eng])
|
|
624
|
+
|
|
625
|
+
engine_done_ms: dict[str, float] = {}
|
|
626
|
+
contributed_ms: list[float] = []
|
|
627
|
+
# 被编排主动**弃置**的引擎(早停收工时仍在跑的)已经花掉的墙钟。
|
|
628
|
+
# 这些时间既没换来结果、也没拖住返回(函数当刻就返回了),此前只把
|
|
629
|
+
# lat_ms 记进 engine_latency 就丢掉——「答案就绪后还在等」这个口径在
|
|
630
|
+
# 有界并发下恒为 0(最后完成的引擎就是触发早停的那个,wall==useful),
|
|
631
|
+
# 仪表盘因此永远显示 0,无法据此调 tail_grace / hedge / 并发上限。
|
|
632
|
+
abandoned_ms: list[float] = []
|
|
633
|
+
# 配额批次的构造在调用方:flush 必须发生在**最后一个 _ingest 之后**,
|
|
634
|
+
# 而那个点在融合后的 D6 补搜里(本模块之外)。在这里另建一个实例,
|
|
635
|
+
# 调用方 flush 到的就是空批次——补搜引擎的记账永远落不了盘。
|
|
636
|
+
# 预算与延迟统一走调用方选定的钟:传 t0_mono(单调钟)时整套换
|
|
637
|
+
# time.monotonic——墙钟会被 NTP 跳变拉扯,预算窗随之失真;不传时保持
|
|
638
|
+
# wall _now(),与既有调用方/测试逐位兼容。垫片必须是**整套**换:
|
|
639
|
+
# deadline 基准与所有 now 采样混用两种钟,预算判断就是废纸。
|
|
640
|
+
_now = time.monotonic if t0_mono is not None else time.time
|
|
641
|
+
_budget_base = t0_mono if t0_mono is not None else t0
|
|
642
|
+
early_stopped = False
|
|
643
|
+
to_run = [e for e in engines if e not in _exhausted_marks] + [e for e in engines if e in _exhausted_marks]
|
|
644
|
+
# deep 模式全量并行;fast/auto/budget 可渐进 early-stop
|
|
645
|
+
allow_early = mode in ("fast", "auto", "budget") and depth != "deep"
|
|
646
|
+
|
|
647
|
+
# 总墙钟预算:deadline 之后不再起新引擎、不再等待慢线程。
|
|
648
|
+
# fast 6s(成本优先)/ auto·budget 10s(质量优先但有界)。
|
|
649
|
+
# deep 此前不设预算(研究场景宁可等待),但 2026-09-30 实测它被一个慢源
|
|
650
|
+
# 独占:firecrawl 声明 timeout=20,单查询 dispatch 7338ms 里 6470ms 在等它,
|
|
651
|
+
# 而 wave 判据早已认定「累计够用」——「宁可等待」的本意是**不早停**,
|
|
652
|
+
# 不是「让最慢的那个源定义墙钟」。给 deep 也上预算(search._DEEP_TOTAL_BUDGET_S,
|
|
653
|
+
# 默认 15s,execution.deep_budget_s 可覆盖):只约束「还等不等/还起不起」,
|
|
654
|
+
# 已完成引擎的证据一条不丢,正常 deep 查询(p50 7s)完全无感。
|
|
655
|
+
budget_s = {"fast": fast_budget_s,
|
|
656
|
+
"auto": auto_budget_s,
|
|
657
|
+
"budget": auto_budget_s}.get(mode)
|
|
658
|
+
if budget_s is None and mode == "deep" and deep_budget_s:
|
|
659
|
+
budget_s = deep_budget_s
|
|
660
|
+
_deadline = _budget_base + (budget_s if budget_s is not None else float("inf"))
|
|
661
|
+
|
|
662
|
+
early_min = decision.get("early_stop_min_results")
|
|
663
|
+
no_early = bool(decision.get("no_early_stop", False))
|
|
664
|
+
|
|
665
|
+
def _cumulative_stop(eng: str, goods: list[dict[str, Any]]) -> bool:
|
|
666
|
+
"""wave-2 收工判据:把已完成的引擎合起来看是否已够(跨引擎累计)。"""
|
|
667
|
+
return (not no_early) and cumulative_sufficient(
|
|
668
|
+
raw_results, mode=mode, min_results=early_min, query=query)
|
|
669
|
+
|
|
670
|
+
def _daemon_start(eng: str, done_evt: threading.Event | None = None):
|
|
671
|
+
"""daemon 线程跑 _run_one:弃置线程不阻塞进程退出。"""
|
|
672
|
+
holder: dict[str, Any] = {"t0": _now()}
|
|
673
|
+
|
|
674
|
+
def _work() -> None:
|
|
675
|
+
try:
|
|
676
|
+
holder["r"] = _run_one(eng)
|
|
677
|
+
except Exception as exc:
|
|
678
|
+
holder["r"] = (
|
|
679
|
+
eng,
|
|
680
|
+
[{"error": str(exc), "source": eng}],
|
|
681
|
+
classify_outcome(
|
|
682
|
+
eng, [{"error": str(exc), "source": eng}], 0),
|
|
683
|
+
0,
|
|
684
|
+
)
|
|
685
|
+
finally:
|
|
686
|
+
if done_evt is not None:
|
|
687
|
+
done_evt.set()
|
|
688
|
+
t = threading.Thread(target=_work, daemon=True)
|
|
689
|
+
t.start()
|
|
690
|
+
return holder, t
|
|
691
|
+
|
|
692
|
+
def _ingest_holder(holder: dict[str, Any]) -> None:
|
|
693
|
+
r = holder.get("r")
|
|
694
|
+
if r:
|
|
695
|
+
_ingest(r[0], r[1], r[2], r[3])
|
|
696
|
+
|
|
697
|
+
def _holder_goods(holder: dict[str, Any]) -> list[dict[str, Any]]:
|
|
698
|
+
r = holder.get("r")
|
|
699
|
+
if not r:
|
|
700
|
+
return []
|
|
701
|
+
return [x for x in r[1] if isinstance(x, dict) and "error" not in x]
|
|
702
|
+
|
|
703
|
+
def _settle_pending(pending: list[tuple[dict[str, Any], Any, str]],
|
|
704
|
+
*, status: str) -> None:
|
|
705
|
+
"""收尾一组待定线程:按弃置原因记账(daemon 自行结束,不阻塞退出),
|
|
706
|
+
恰在末次轮询后完成的照常入账——否则它既不 ingest 也不标状态,
|
|
707
|
+
结果会悄悄丢掉。latency 用真实等待时长(原 timeout 参数×1000 是假值)。
|
|
708
|
+
|
|
709
|
+
status 对应两种不同的弃置原因,必须分开:
|
|
710
|
+
- "timeout":等待预算到期仍活——引擎确实超窗,沿用 timeout 语义
|
|
711
|
+
(error 条目 + 熔断可见的 outcome);race 双超窗测试依赖此形态。
|
|
712
|
+
- "cancelled":早停收工(结果已够)主动弃置——引擎没有失败,是
|
|
713
|
+
编排的收工决定。此前一律标 timeout 的实测后果:每次带对冲且主搜
|
|
714
|
+
成功的搜索,errors[] 必出一条假超时(anysearch: timeout @26ms,
|
|
715
|
+
8s 超时上限下 26ms 不可能是真超时);且 adaptive 学习器把
|
|
716
|
+
{"error": ...} 记成 success=False,健康引擎的 combo 分数被早停
|
|
717
|
+
逐次毒化(2026-09-26 复现并定位)。
|
|
718
|
+
"""
|
|
719
|
+
for holder, th, eng in pending:
|
|
720
|
+
lat_ms = int((_now() - holder.get("t0", _now())) * 1000)
|
|
721
|
+
if th.is_alive():
|
|
722
|
+
if status == "cancelled":
|
|
723
|
+
# 空列表而非 error 条目:error 条目会被学习器当失败、
|
|
724
|
+
# 被 _collect_errors 当错误文本收进 errors[]
|
|
725
|
+
# 弃置的引擎白花的墙钟计入 wasted(见 abandoned_ms):
|
|
726
|
+
# 它是「调度多花了多少」的量,不是「多等了多久」。
|
|
727
|
+
abandoned_ms.append(lat_ms)
|
|
728
|
+
_ingest(eng, [],
|
|
729
|
+
classify_outcome(eng, [], lat_ms, "cancelled"),
|
|
730
|
+
lat_ms)
|
|
731
|
+
else:
|
|
732
|
+
# 走 _ingest 而不是手写两行收账:超时路径此前漏记 engine_latency,
|
|
733
|
+
# 而 timing.dispatch 的 engines_run / engine_sum_ms / parallel_efficiency
|
|
734
|
+
# 全是从 engine_latency 推的——一轮里引擎全部超时时,会报出「跑了 0 个
|
|
735
|
+
# 引擎」,而那恰恰是最该被看见的一轮。归一到同一处记账,以后 _ingest
|
|
736
|
+
# 再加字段,超时路径自动跟上。
|
|
737
|
+
res = [{"error": "timeout", "source": eng}]
|
|
738
|
+
_ingest(eng, res,
|
|
739
|
+
classify_outcome(eng, res, lat_ms, "timeout"), lat_ms)
|
|
740
|
+
else:
|
|
741
|
+
_ingest_holder(holder)
|
|
742
|
+
|
|
743
|
+
def _run_engines_bounded(engs: list[str], wait_s: float,
|
|
744
|
+
stop: Callable[[str, list[dict[str, Any]]], bool] | None = None,
|
|
745
|
+
stagger_s: float = 0.0,
|
|
746
|
+
start_before_s: float | None = None,
|
|
747
|
+
tail_grace_s: float = 0.0,
|
|
748
|
+
tail_grace_fn: Callable[[str], float] | None = None) -> bool:
|
|
749
|
+
"""并发跑一组引擎(≤3 并发),等待上限 wait_s 秒;返回是否已「够用」。
|
|
750
|
+
|
|
751
|
+
`stop(eng, goods)` 是某引擎完成后的收工判据,返回真即立刻收尾返回。
|
|
752
|
+
判据由调用方给:串行路径是「逐引擎充分性」,wave-2 是「跨引擎累计
|
|
753
|
+
充分性」——两种语义留成参数,调度本身只有一套。
|
|
754
|
+
|
|
755
|
+
`stagger_s` 起步间隔(秒),单调:0 = 不节流(立即填满并发位,wave-2
|
|
756
|
+
语义);值越大越接近严格串行(大于引擎超时上限即完全退回串行)。
|
|
757
|
+
串行路径靠它获得对冲:happy path(首个引擎很快给出合格结果)仍然只发
|
|
758
|
+
一次调用,只有首个引擎「异常地慢」时才提前补发备选源。实测意义见本
|
|
759
|
+
函数调用点的注释。
|
|
760
|
+
|
|
761
|
+
`start_before_s` 之后不再起新引擎(已起的不受影响,让它跑完自己的
|
|
762
|
+
单引擎预算)。与 wait_s 分开是刻意的:串行路径要「预算耗尽就不要再
|
|
763
|
+
发新引擎」,但不能因此把已经发出去的那一枪的成果丢掉。
|
|
764
|
+
|
|
765
|
+
`tail_grace_s`(>0 时生效):一旦**已经有引擎交出了非空结果**(哪怕
|
|
766
|
+
判据认为不够),剩余等待收窄到这么久。理由是「有东西可交付」和
|
|
767
|
+
「一个能交付的东西都没有」是两种处境——后者值得等,前者已经可以
|
|
768
|
+
收工。与 wave-1 竞速分支的 `_STRAGGLER_GRACE_S` 同一语义、同一开关
|
|
769
|
+
(`ARGO_STRAGGLER_GRACE_S=0` 可整体关闭做消融对照)。
|
|
770
|
+
|
|
771
|
+
`tail_grace_fn(eng)`(可选):把上面的固定尾宽换成**按引擎分层**的
|
|
772
|
+
尾宽(2026-10-16,ARGO_ADAPTIVE_TAIL 控制,见
|
|
773
|
+
straggler_policy.straggler_tail)。
|
|
774
|
+
在首个交付时刻对**还没回来的引擎**(在跑的 + 还没起步的,含队列里等
|
|
775
|
+
stagger 的)逐个取值、取最保守的那个(max):尾宽治理的是「还等不
|
|
776
|
+
等没回来的引擎」,判据自然该由没回来的那些引擎的历史速度决定,而
|
|
777
|
+
不是由刚交付的那个决定。无 fn 或取值抛错时逐位退回固定
|
|
778
|
+
tail_grace_s(旧行为)。
|
|
779
|
+
|
|
780
|
+
为什么不用 ThreadPoolExecutor:它的 `with` 退出会
|
|
781
|
+
`shutdown(wait=True)` 并 join 所有已提交任务,把「超时即返回」的语义
|
|
782
|
+
架空——上面每个预算判断都以为自己已经止损,进程却还在等一个卡住的
|
|
783
|
+
HTTP 读(models.dev 全量 API 超时 15s,实测单查询被拖到 76s,而
|
|
784
|
+
w2_wait / deadline 早已到期)。daemon 线程 + 轮询才真正有界:
|
|
785
|
+
早停后弃置线程既不阻塞函数返回,也不阻塞进程退出——与上面的
|
|
786
|
+
hedged 分支共用同一套并发执行方式。
|
|
787
|
+
"""
|
|
788
|
+
if not engs:
|
|
789
|
+
return False
|
|
790
|
+
queue = list(engs)
|
|
791
|
+
pending: list[tuple[dict[str, Any], Any, str]] = []
|
|
792
|
+
deadline = _now() + max(0.0, wait_s)
|
|
793
|
+
gate = None if start_before_s is None else _now() + max(0.0, start_before_s)
|
|
794
|
+
last_start: float | None = None
|
|
795
|
+
done_evt = threading.Event()
|
|
796
|
+
max_concurrency = _max_parallel_engines(depth=depth)
|
|
797
|
+
while (queue or pending) and _now() < deadline:
|
|
798
|
+
done_evt.clear()
|
|
799
|
+
started = False
|
|
800
|
+
while queue and len(pending) < max_concurrency:
|
|
801
|
+
if gate is not None and _now() >= gate:
|
|
802
|
+
break # 预算耗尽:不再起新引擎(既有 fast 契约)
|
|
803
|
+
if (pending and stagger_s > 0.0 and last_start is not None
|
|
804
|
+
and (_now() - last_start) < stagger_s):
|
|
805
|
+
break # 起步节流:等满 stagger 再补发(串行路径的对冲)
|
|
806
|
+
eng = queue.pop(0)
|
|
807
|
+
holder, th = _daemon_start(eng, done_evt)
|
|
808
|
+
pending.append((holder, th, eng))
|
|
809
|
+
last_start = _now()
|
|
810
|
+
started = True
|
|
811
|
+
progressed = False
|
|
812
|
+
for item in list(pending):
|
|
813
|
+
holder, th, eng = item
|
|
814
|
+
if th.is_alive():
|
|
815
|
+
continue
|
|
816
|
+
_ingest_holder(holder)
|
|
817
|
+
pending.remove(item)
|
|
818
|
+
progressed = True
|
|
819
|
+
goods = _holder_goods(holder)
|
|
820
|
+
if stop is not None and stop(eng, goods):
|
|
821
|
+
_settle_pending(pending, status="cancelled")
|
|
822
|
+
return True
|
|
823
|
+
if goods and tail_grace_s > 0.0:
|
|
824
|
+
# 有东西可交付了:剩余等待收窄到宽限窗(只收紧一次,不回扩)
|
|
825
|
+
tail = tail_grace_s
|
|
826
|
+
if tail_grace_fn is not None:
|
|
827
|
+
# 分层尾宽:按**还没回来的引擎**(在跑的 + 还没起步的)
|
|
828
|
+
# 各自的历史 P75 取值,取最保守(最长)的那个。不能只
|
|
829
|
+
# 看 pending——首个交付时刻 backup 常常还在队列里等
|
|
830
|
+
# stagger 起步(2026-10-16 实测:只算 pending 时分层
|
|
831
|
+
# 从不生效,墙钟与固定尾宽无异)。慢源不值得等,但不
|
|
832
|
+
# 因为「有个更慢的在场」把快源也提前埋掉。fn 抛错/无
|
|
833
|
+
# 数据由 fn 自身 fail-open 回 base。
|
|
834
|
+
try:
|
|
835
|
+
# gate 关(预算耗尽不再起新引擎)时,队列里的
|
|
836
|
+
# 条目永远不会起步——把它们算进「还没回来」
|
|
837
|
+
# 只会把分层尾宽被死条目拉长,方向反了。
|
|
838
|
+
startable = (list(queue)
|
|
839
|
+
if gate is None or _now() < gate else [])
|
|
840
|
+
waiting = ([e for _h, _t, e in pending] + startable)
|
|
841
|
+
if waiting:
|
|
842
|
+
tail = max(tail_grace_fn(e, tail_grace_s)
|
|
843
|
+
for e in waiting)
|
|
844
|
+
except Exception:
|
|
845
|
+
tail = tail_grace_s
|
|
846
|
+
deadline = min(deadline, _now() + max(0.0, tail))
|
|
847
|
+
if not progressed and not started:
|
|
848
|
+
if not pending:
|
|
849
|
+
break # 队列里还有引擎,但起步闸门已关:没有下一步了
|
|
850
|
+
# 完成事件唤醒 + 剩余时间/stagger 窗口上限:线程完成即醒(零轮询税),
|
|
851
|
+
# 无完成事件时按 20ms 节拍复核 stagger/deadline(兼容假时钟测试)。
|
|
852
|
+
remain = deadline - _now()
|
|
853
|
+
wait_step = min(0.02, remain / 10) if remain > 0 else 0.005
|
|
854
|
+
if queue and stagger_s > 0.0 and last_start is not None:
|
|
855
|
+
stagger_rem = max(0.0, stagger_s - (_now() - last_start))
|
|
856
|
+
wait_step = min(wait_step, stagger_rem)
|
|
857
|
+
done_evt.wait(max(0.0, wait_step))
|
|
858
|
+
_settle_pending(pending, status="timeout")
|
|
859
|
+
return False
|
|
860
|
+
|
|
861
|
+
if parallel and to_run and allow_early and len(to_run) > 1:
|
|
862
|
+
# Wave-1 race(2026-09-06):primary 与次引擎并行起跑,先完成且结果
|
|
863
|
+
# 合格者赢——原「primary 先行」串行等待下,primary 慢则整体慢(实测
|
|
864
|
+
# github 引擎 8.5s 拖尾而次引擎 2.4s 就绪);race 后墙钟由最先合格
|
|
865
|
+
# 者决定。双成员都不合格则落 wave-2 并行补全(语义不变,且 wave-2
|
|
866
|
+
# 的累计充分性判定天然包含 race 已收入的结果)。
|
|
867
|
+
# 成本语义:hedge 只在主引擎跑过宽限窗还没回来时才多发一次调用——
|
|
868
|
+
# 主引擎在窗内交付就仍然只付 1 次(与串行路径同一套语义、同一个值,
|
|
869
|
+
# 见 search._PRIMARY_GRACE_S 的实测依据)。结果质量仍由充分性判定 +
|
|
870
|
+
# 覆盖守卫把关,先到不等于放行。
|
|
871
|
+
#
|
|
872
|
+
# 实现形态:wave-1 就是「起步间隔 = grace 的有界并发」——primary 先跑,
|
|
873
|
+
# grace 内合格则只付一次调用(成本回退),未完成才补发 backup 并行竞速。
|
|
874
|
+
# 此前这里另有一份手写的赛车循环,与 `_run_engines_bounded` 是同一段
|
|
875
|
+
# 逻辑的两个拷贝(收尸、宽限窗、轮询间隔全都要各维护一遍)。现在两者
|
|
876
|
+
# 是同一函数的两次配置,改一处即改两处。
|
|
877
|
+
grace = max(0.3, min(primary_grace_s, net_timeout * 0.25))
|
|
878
|
+
# 自适应收窄(2026-10-11,调度微手术):快引擎的窗收到自己的历史 P75
|
|
879
|
+
# (异常慢时更早有人救场),慢引擎不动;只缩不放,语义与失败处理见
|
|
880
|
+
# _adaptive_grace。ARGO_ADAPTIVE_GRACE=0 消融对照(A/B 基线臂)。
|
|
881
|
+
grace = _adaptive_grace(to_run[0], grace)
|
|
882
|
+
if _now() + grace > _deadline:
|
|
883
|
+
grace = max(0.0, _deadline - _now())
|
|
884
|
+
|
|
885
|
+
def _wave1_stop(eng: str, goods: list[dict[str, Any]]) -> bool:
|
|
886
|
+
"""先合格者赢(单引擎达标或 Wave-1 跨引擎累计达标即停,不启 Wave-2)。"""
|
|
887
|
+
if no_early or not goods:
|
|
888
|
+
return False
|
|
889
|
+
return (results_sufficient(goods, mode=mode, min_results=early_min, query=query)
|
|
890
|
+
or cumulative_sufficient(raw_results, mode=mode, min_results=early_min, query=query))
|
|
891
|
+
|
|
892
|
+
# 竞速窗口:grace 之后还剩 net_timeout+2 的观察窗(不越过总预算)。
|
|
893
|
+
# 窗口按「从起步算起」折算,所以是 grace + 剩余额度——等价于原来的
|
|
894
|
+
# 「先 join(grace),再从当下算 min(net_timeout+2, deadline-now)」,
|
|
895
|
+
# 但不必把 grace 白白耗在 join 上。
|
|
896
|
+
#
|
|
897
|
+
# 质量守卫拒绝早停后的等待上限由 tail_grace_s 表达:它防的是「对冲的
|
|
898
|
+
# 备份先回来但零词面交集(实测「上海 地铁 线路图」拿到纽约共享单车
|
|
899
|
+
# 站点),主引擎却已被证明答不了这类查询」时把墙钟拖到窗口耗尽。
|
|
900
|
+
# 守卫没错,错的是拒绝之后没有等待上限。
|
|
901
|
+
race_wait = grace + min(
|
|
902
|
+
net_timeout + 2, max(0.1, _deadline - _now() - grace))
|
|
903
|
+
if _run_engines_bounded(
|
|
904
|
+
to_run[:2], race_wait,
|
|
905
|
+
stop=None if no_early else _wave1_stop,
|
|
906
|
+
stagger_s=grace,
|
|
907
|
+
tail_grace_s=straggler_grace_s,
|
|
908
|
+
tail_grace_fn=straggler_tail_fn):
|
|
909
|
+
early_stopped = True
|
|
910
|
+
# 第 3 个及之后的引擎归 wave-2(竞速是 2 匹马的比赛,再多的交给
|
|
911
|
+
# 累计充分性判定批量补全)
|
|
912
|
+
rest = to_run[2:]
|
|
913
|
+
if (not early_stopped and rest
|
|
914
|
+
and not (budget_s is not None and _now() >= _deadline)):
|
|
915
|
+
# 预算检查(与串行路径 `_now() >= _deadline` 同语义):
|
|
916
|
+
# deadline 已过不再起新引擎;等待窗口也不越过 deadline——
|
|
917
|
+
# 「总墙钟预算」对并行路径同样成立
|
|
918
|
+
w2_wait = min(net_timeout + 2,
|
|
919
|
+
max(0.1, _deadline - _now()))
|
|
920
|
+
# wave-2 同样接尾宽(2026-10-02,与 wave-1/串行同一
|
|
921
|
+
# straggler_grace_s 旋钮):质量守卫拒绝早停但已有
|
|
922
|
+
# 可交付结果时,等待上限 = 首个合格结果 + 尾宽,而非
|
|
923
|
+
# 一路等到 net_timeout+2——实测垂直域第 3+ 引擎常为
|
|
924
|
+
# 慢源/死源(体育 5-10s、学术 6-9s 的长尾主要来自
|
|
925
|
+
# 这段空等)。守卫本身不被绕过:累计充分性仍按原判据
|
|
926
|
+
# 判定,尾宽只约束「拒绝早停后的最长空等」。
|
|
927
|
+
if _run_engines_bounded(rest, w2_wait, stop=_cumulative_stop,
|
|
928
|
+
tail_grace_s=straggler_grace_s,
|
|
929
|
+
tail_grace_fn=straggler_tail_fn):
|
|
930
|
+
early_stopped = True
|
|
931
|
+
elif parallel and to_run:
|
|
932
|
+
# 全量并行(depth=deep,或 mode 不在 fast/auto/budget 内):没有早停判据,
|
|
933
|
+
# 但**总墙钟预算照样成立**——与上面 wave-2、下面串行两条路径同语义。
|
|
934
|
+
# 此前这里直接等 net_timeout+2,而 budget_s 是按 mode 定的(--depth deep
|
|
935
|
+
# 时 mode 仍是 auto,10s),于是会报出 used_ms > total_ms 的自相矛盾读数。
|
|
936
|
+
_wait = net_timeout + 2
|
|
937
|
+
if budget_s is not None:
|
|
938
|
+
_wait = min(_wait, max(0.1, _deadline - _now()))
|
|
939
|
+
_run_engines_bounded(to_run, _wait)
|
|
940
|
+
else:
|
|
941
|
+
# 串行路径(`parallel=False` 的垂直域)。
|
|
942
|
+
#
|
|
943
|
+
# 它此前是**严格串行**:前一个引擎必须耗满自己的超时上限(收紧后
|
|
944
|
+
# 5-8s)才轮到下一个。实测(2026-09-17,24 条代表性查询):dispatch
|
|
945
|
+
# 超出「首个可用引擎完成时刻」的部分 p50 39ms、p90 2436ms、合计
|
|
946
|
+
# 22.4s(平均 0.93s/查询,占平均墙钟 36%)——尾部全在这里,而
|
|
947
|
+
# wave-1/wave-2 早就有对冲,只有这条路径没有。
|
|
948
|
+
#
|
|
949
|
+
# 改法不是把垂直域改成并行(那会连 happy path 都多发调用),而是
|
|
950
|
+
# **错开起步**:首个引擎跑过 serial_stagger_s 还没回来才补发备选源。
|
|
951
|
+
# 首个引擎正常完成时仍然只付一次调用(成本语义不变);它异常地慢时,
|
|
952
|
+
# 备选源不再等到它超时才起跑。垂直域的源(行情/宏观/天气/赛事/地理)
|
|
953
|
+
# 都是免密钥的,增量调用没有成本含义。
|
|
954
|
+
def _serial_stop(eng: str, goods: list[dict[str, Any]]) -> bool:
|
|
955
|
+
if not allow_early or no_early or not goods:
|
|
956
|
+
return False
|
|
957
|
+
# 答案型域 min_results=1:1 条快照即 early-stop
|
|
958
|
+
if results_sufficient(goods, mode=mode, min_results=early_min,
|
|
959
|
+
query=query):
|
|
960
|
+
return True
|
|
961
|
+
# 默认串行:任一引擎有结果即停(历史行为);答案型不够用则继续补源。
|
|
962
|
+
# 词面覆盖守卫同语义:结果与查询几乎无交集 → 试下一引擎(救援线)
|
|
963
|
+
return early_min is None and query_coverage_ok(goods, query)
|
|
964
|
+
|
|
965
|
+
stagger = max(0.0, serial_stagger_s)
|
|
966
|
+
# 与 wave-1 同一个窗语义(见 _adaptive_grace 的自适应收窄)。显式给
|
|
967
|
+
# 更大的值是「消融对照 / 完全退回严格串行」的逃生门,原样尊重不收窄。
|
|
968
|
+
if stagger <= primary_grace_s:
|
|
969
|
+
stagger = _adaptive_grace(to_run[0], stagger)
|
|
970
|
+
# 等待上限:给「最晚起步的那个引擎」留足它自己的单引擎预算。
|
|
971
|
+
# 旧语义等价于 n × 单引擎预算;对冲把引擎错开起步,所以是
|
|
972
|
+
# (n-1) × stagger + 单引擎预算——差的正是要消灭的那段串行拖尾。
|
|
973
|
+
_serial_wait = (len(to_run) - 1) * stagger + _budget_cfg
|
|
974
|
+
if budget_s is not None:
|
|
975
|
+
# 总预算只管「还起不起新引擎」,不没收已经发出去的枪:已起的
|
|
976
|
+
# 引擎可以跑完自己的单引擎预算(与旧串行行为一致,不比它更差)
|
|
977
|
+
_start_before = max(0.0, _deadline - _now())
|
|
978
|
+
else:
|
|
979
|
+
_start_before = None
|
|
980
|
+
if _run_engines_bounded(to_run, _serial_wait,
|
|
981
|
+
stop=_serial_stop if allow_early else None,
|
|
982
|
+
stagger_s=stagger,
|
|
983
|
+
start_before_s=_start_before,
|
|
984
|
+
tail_grace_s=straggler_grace_s,
|
|
985
|
+
tail_grace_fn=straggler_tail_fn):
|
|
986
|
+
early_stopped = True
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
budget_total_ms = int(budget_s * 1000) if budget_s is not None else None
|
|
991
|
+
# 墙钟账:`useful_ms` = 最后一个有效贡献引擎完成的时刻(答案从这一刻起
|
|
992
|
+
# 就已经在手里了)。`wasted_ms` 见下方口径说明。
|
|
993
|
+
#
|
|
994
|
+
# 此前的 wasted 是「所有非 ok 引擎的 latency 之和」——它根本不是时间量:
|
|
995
|
+
# 并行时必然大于墙钟(实测「世界杯 2026 主办国」wasted 2847ms > dispatch
|
|
996
|
+
# 2313ms),而且把「引擎如实返回了 0 条」也算成浪费。两个已知代价:
|
|
997
|
+
# ① 上一轮的优化盘点据此写下「早停与 hedge race 记账诚实、调度不用动」,
|
|
998
|
+
# 而实测调度是最大的单一浪费源;② 读者无法从它推出任何行动。
|
|
999
|
+
#
|
|
1000
|
+
# 再往后改成 `wall - useful`(useful = 最后一个**贡献结果**的引擎的完成
|
|
1001
|
+
# 时刻),并宣称口径是「答案就绪后还在等」。但有界并发下这仍是恒等式:
|
|
1002
|
+
# 触发早停的引擎自己就是最后完成的那个,返回当刻 wall == useful,于是
|
|
1003
|
+
# wasted 永远是 0——实测每轮皆 0。空转的仪表比没有仪表更糟:它让人以为
|
|
1004
|
+
# 调度无浪费可调,于是谁都不会去调 tail_grace / hedge / 并发上限。
|
|
1005
|
+
#
|
|
1006
|
+
# 现在的口径:**被编排主动弃置的引擎已经花掉的墙钟之和**。
|
|
1007
|
+
# 这些时间确实白花(没换来结果、也没拖住返回),是唯一既能算清、
|
|
1008
|
+
# 又能指向具体动作的量;与 wall 无关(弃置不阻塞返回),故不再保证
|
|
1009
|
+
# useful + wasted == wall。仍被等待的超时引擎不算浪费——那是引擎不健康,
|
|
1010
|
+
# 在 engine_latency / 熔断里已可见。
|
|
1011
|
+
wall_ms = int((_now() - _budget_base) * 1000)
|
|
1012
|
+
useful_ms = int(min(max(contributed_ms), wall_ms)) if contributed_ms else 0
|
|
1013
|
+
return DispatchResult(
|
|
1014
|
+
raw_results, engine_outcomes, engine_latency,
|
|
1015
|
+
int(sum(abandoned_ms)), early_stopped, _run_one, _ingest,
|
|
1016
|
+
budget_used_ms=wall_ms,
|
|
1017
|
+
budget_total_ms=budget_total_ms,
|
|
1018
|
+
useful_ms=useful_ms,
|
|
1019
|
+
)
|