@vellumai/assistant 0.7.2 → 0.7.3
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/ARCHITECTURE.md +16 -1
- package/docs/architecture/memory.md +5 -2
- package/node_modules/@vellumai/gateway-client/src/ipc-client.ts +13 -4
- package/node_modules/@vellumai/skill-host-contracts/src/assistant-event.ts +0 -9
- package/node_modules/@vellumai/slack-text/src/index.test.ts +18 -35
- package/node_modules/@vellumai/slack-text/src/index.ts +2 -48
- package/openapi.yaml +449 -22
- package/package.json +1 -1
- package/src/__tests__/app-control-flow.test.ts +21 -11
- package/src/__tests__/assistant-event-hub.test.ts +48 -0
- package/src/__tests__/assistant-event.test.ts +0 -10
- package/src/__tests__/assistant-events-sse-hardening.test.ts +2 -7
- package/src/__tests__/assistant-feature-flags-integration.test.ts +18 -0
- package/src/__tests__/auto-analysis-end-to-end.test.ts +62 -1
- package/src/__tests__/background-workers-disk-pressure.test.ts +268 -0
- package/src/__tests__/call-conversation-messages.test.ts +8 -2
- package/src/__tests__/channel-inbound-disk-pressure.test.ts +537 -0
- package/src/__tests__/channel-readiness-service.test.ts +4 -2
- package/src/__tests__/config-loader-backfill.test.ts +379 -0
- package/src/__tests__/config-schema.test.ts +1 -0
- package/src/__tests__/config-watcher-cleanup-throttle.test.ts +18 -9
- package/src/__tests__/config-watcher.test.ts +140 -69
- package/src/__tests__/context-search-agent-runner.test.ts +61 -3
- package/src/__tests__/context-search-conversations-source.test.ts +0 -24
- package/src/__tests__/context-search-fanout.test.ts +0 -1
- package/src/__tests__/context-search-memory-source.test.ts +3 -7
- package/src/__tests__/context-search-memory-v2-source.test.ts +0 -2
- package/src/__tests__/context-search-pkb-source.test.ts +0 -1
- package/src/__tests__/context-search-workspace-source.test.ts +0 -1
- package/src/__tests__/conversation-abort-tool-results.test.ts +6 -0
- package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +223 -0
- package/src/__tests__/conversation-agent-loop.test.ts +454 -5
- package/src/__tests__/conversation-error.test.ts +150 -3
- package/src/__tests__/conversation-process-callsite.test.ts +43 -0
- package/src/__tests__/conversation-provider-retry-repair.test.ts +6 -0
- package/src/__tests__/conversation-runtime-assembly.test.ts +65 -0
- package/src/__tests__/conversation-slash-unknown.test.ts +6 -0
- package/src/__tests__/conversation-speed-override.test.ts +0 -3
- package/src/__tests__/conversation-store.test.ts +0 -18
- package/src/__tests__/conversation-surfaces-app-control.test.ts +15 -4
- package/src/__tests__/conversation-surfaces-data-persist.test.ts +404 -0
- package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +2 -5
- package/src/__tests__/conversation-workspace-injection.test.ts +6 -0
- package/src/__tests__/conversation-workspace-tool-tracking.test.ts +6 -0
- package/src/__tests__/credentials-cli.test.ts +7 -0
- package/src/__tests__/cu-unified-flow.test.ts +176 -10
- package/src/__tests__/date-context.test.ts +164 -2
- package/src/__tests__/disk-pressure-guard.test.ts +262 -0
- package/src/__tests__/disk-pressure-lifecycle.test.ts +168 -0
- package/src/__tests__/disk-pressure-policy.test.ts +241 -0
- package/src/__tests__/disk-pressure-routes.test.ts +379 -0
- package/src/__tests__/disk-pressure-tools.test.ts +277 -0
- package/src/__tests__/disk-usage.test.ts +150 -0
- package/src/__tests__/events-client-registration.test.ts +52 -0
- package/src/__tests__/events-dev-bypass-actor.test.ts +162 -0
- package/src/__tests__/file-write-tool.test.ts +4 -10
- package/src/__tests__/filing-service.test.ts +3 -4
- package/src/__tests__/heartbeat-disk-pressure.test.ts +183 -0
- package/src/__tests__/heartbeat-service.test.ts +260 -11
- package/src/__tests__/host-app-control-proxy.test.ts +195 -25
- package/src/__tests__/host-bash-proxy.test.ts +227 -34
- package/src/__tests__/host-bash-routes.test.ts +178 -13
- package/src/__tests__/host-cu-proxy.test.ts +210 -3
- package/src/__tests__/host-cu-routes-targeted.test.ts +141 -12
- package/src/__tests__/host-file-proxy-targeted.test.ts +48 -9
- package/src/__tests__/host-file-proxy.test.ts +268 -6
- package/src/__tests__/host-file-routes-targeted.test.ts +175 -17
- package/src/__tests__/host-transfer-proxy-targeted.test.ts +408 -59
- package/src/__tests__/host-transfer-routes-targeted.test.ts +232 -17
- package/src/__tests__/http-user-message-parity.test.ts +107 -1
- package/src/__tests__/injector-chain.test.ts +18 -6
- package/src/__tests__/injector-disk-pressure.test.ts +224 -0
- package/src/__tests__/managed-profile-guard.test.ts +18 -0
- package/src/__tests__/mcp-abort-signal.test.ts +130 -0
- package/src/__tests__/memory-admin-recall.test.ts +3 -11
- package/src/__tests__/memory-retrieval-pipeline.test.ts +22 -1
- package/src/__tests__/normalize-onboarding.test.ts +180 -0
- package/src/__tests__/oauth-connect-routes.test.ts +316 -0
- package/src/__tests__/oauth-provider-seed-logos.test.ts +24 -2
- package/src/__tests__/onboarding-persona-write.test.ts +308 -0
- package/src/__tests__/openai-provider.test.ts +45 -8
- package/src/__tests__/persist-onboarding-artifacts.test.ts +44 -64
- package/src/__tests__/platform-callback-registration.test.ts +21 -4
- package/src/__tests__/platform.test.ts +2 -1
- package/src/__tests__/playbook-execution.test.ts +0 -43
- package/src/__tests__/plugin-tool-contribution.test.ts +47 -0
- package/src/__tests__/prechat-onboarding-contract.test.ts +214 -27
- package/src/__tests__/provider-tool-name.test.ts +23 -0
- package/src/__tests__/relay-server.test.ts +15 -4
- package/src/__tests__/runtime-events-sse.test.ts +4 -8
- package/src/__tests__/scheduler-disk-pressure.test.ts +148 -0
- package/src/__tests__/secret-ingress-http.test.ts +0 -1
- package/src/__tests__/suggestion-routes.test.ts +46 -0
- package/src/__tests__/twilio-validation.test.ts +2 -2
- package/src/__tests__/workspace-migration-065-bump-stale-heartbeat-interval.test.ts +122 -0
- package/src/__tests__/workspace-migration-066-seed-heartbeat-callsite-cost-default.test.ts +285 -0
- package/src/__tests__/workspace-migration-068-release-notes-local-timezone.test.ts +90 -0
- package/src/__tests__/workspace-migration-safe-storage-limits-release.test.ts +90 -0
- package/src/approvals/guardian-decision-primitive.ts +13 -0
- package/src/approvals/guardian-request-resolvers.ts +16 -17
- package/src/backup/snapshot-lock.ts +2 -27
- package/src/bundler/compiler-tools.ts +3 -2
- package/src/calls/call-conversation-messages.ts +46 -10
- package/src/cli/commands/__tests__/webhooks.test.ts +0 -4
- package/src/cli/commands/bash.ts +35 -108
- package/src/cli/commands/contacts.ts +64 -25
- package/src/cli/commands/credentials.ts +56 -0
- package/src/cli/commands/memory-v2.ts +7 -6
- package/src/cli/commands/oauth/__tests__/connect.test.ts +437 -1
- package/src/cli/commands/oauth/connect.ts +127 -1
- package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +0 -3
- package/src/cli/commands/platform/__tests__/connect.test.ts +7 -1
- package/src/cli/commands/platform/__tests__/disconnect.test.ts +7 -1
- package/src/cli/commands/platform/__tests__/status.test.ts +103 -6
- package/src/cli/commands/platform/index.ts +16 -7
- package/src/cli/commands/status.ts +57 -0
- package/src/cli/program.ts +4 -2
- package/src/config/assistant-feature-flags.ts +13 -3
- package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +4 -3
- package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +13 -7
- package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +2 -2
- package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +2 -2
- package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +2 -2
- package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +2 -2
- package/src/config/env.ts +0 -8
- package/src/config/feature-flag-registry.json +27 -3
- package/src/config/loader.ts +127 -8
- package/src/config/schemas/__tests__/memory-v2.test.ts +10 -5
- package/src/config/schemas/call-site-catalog.ts +14 -0
- package/src/config/schemas/channels.ts +0 -5
- package/src/config/schemas/heartbeat.ts +1 -1
- package/src/config/schemas/llm.ts +2 -0
- package/src/config/schemas/memory-lifecycle.ts +13 -0
- package/src/config/schemas/memory-v2.ts +75 -11
- package/src/config/schemas/platform.ts +43 -3
- package/src/config/schemas/services.ts +28 -0
- package/src/config/seed-inference-profiles.ts +230 -33
- package/src/contacts/contact-store.ts +0 -25
- package/src/daemon/__tests__/conversation-tool-setup.test.ts +86 -25
- package/src/daemon/assistant-attachments.ts +4 -4
- package/src/daemon/config-watcher.ts +85 -57
- package/src/daemon/conversation-agent-loop-handlers.ts +6 -0
- package/src/daemon/conversation-agent-loop.ts +170 -33
- package/src/daemon/conversation-error.ts +87 -15
- package/src/daemon/conversation-lifecycle.ts +1 -3
- package/src/daemon/conversation-process.ts +8 -0
- package/src/daemon/conversation-runtime-assembly.ts +26 -0
- package/src/daemon/conversation-store.ts +2 -2
- package/src/daemon/conversation-surfaces.ts +195 -15
- package/src/daemon/conversation-tool-setup.ts +57 -14
- package/src/daemon/conversation.ts +17 -22
- package/src/daemon/date-context.ts +71 -22
- package/src/daemon/disk-pressure-background-gate.ts +73 -0
- package/src/daemon/disk-pressure-guard.ts +343 -0
- package/src/daemon/disk-pressure-policy.ts +163 -0
- package/src/daemon/handlers/shared.ts +0 -1
- package/src/daemon/handlers/skills.ts +3 -4
- package/src/daemon/host-app-control-proxy.ts +137 -41
- package/src/daemon/host-bash-proxy.ts +46 -21
- package/src/daemon/host-cu-proxy.ts +49 -3
- package/src/daemon/host-file-proxy.ts +43 -7
- package/src/daemon/host-transfer-proxy.ts +95 -4
- package/src/daemon/lifecycle.ts +79 -28
- package/src/daemon/meet-host-supervisor.ts +4 -4
- package/src/daemon/meet-manifest-loader.ts +0 -1
- package/src/daemon/memory-v2-startup.ts +14 -4
- package/src/daemon/message-protocol.ts +3 -0
- package/src/daemon/message-types/conversations.ts +4 -0
- package/src/daemon/message-types/disk-pressure.ts +9 -0
- package/src/daemon/message-types/messages.ts +3 -0
- package/src/daemon/profiler-run-store.ts +5 -5
- package/src/daemon/tool-setup-types.ts +2 -2
- package/src/documents/document-store.ts +85 -0
- package/src/filing/filing-service.ts +30 -5
- package/src/heartbeat/__tests__/heartbeat-feed-event.test.ts +9 -16
- package/src/heartbeat/__tests__/heartbeat-run-store.test.ts +36 -0
- package/src/heartbeat/heartbeat-run-store.ts +13 -0
- package/src/heartbeat/heartbeat-service.ts +205 -31
- package/src/home/feed-scheduler.ts +18 -0
- package/src/inbound/platform-callback-registration.ts +8 -15
- package/src/ipc/__tests__/clients-list-ipc.test.ts +169 -0
- package/src/ipc/assistant-server.ts +56 -2
- package/src/ipc/gateway-client.ts +37 -3
- package/src/live-voice/live-voice-archive.ts +4 -4
- package/src/live-voice/protocol.ts +5 -7
- package/src/media/image-service.ts +1 -7
- package/src/memory/__tests__/fixtures/memory-v2-activation-fixtures.ts +21 -13
- package/src/memory/__tests__/jobs-worker-v2-schedule.test.ts +52 -22
- package/src/memory/__tests__/memory-v2-activation-log-store.test.ts +0 -6
- package/src/memory/__tests__/memory-v2-concept-frequency.test.ts +272 -0
- package/src/memory/admin.ts +5 -9
- package/src/memory/context-search/agent-runner.ts +19 -2
- package/src/memory/context-search/sources/conversations.ts +2 -11
- package/src/memory/context-search/sources/memory-v2.ts +5 -4
- package/src/memory/context-search/sources/memory.ts +0 -1
- package/src/memory/context-search/types.ts +0 -1
- package/src/memory/conversation-crud.ts +4 -12
- package/src/memory/db-init.ts +2 -0
- package/src/memory/embedding-runtime-manager.ts +119 -5
- package/src/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +32 -21
- package/src/memory/graph/conversation-graph-memory.ts +42 -54
- package/src/memory/graph/extraction.ts +1 -3
- package/src/memory/graph/graph-search.test.ts +10 -67
- package/src/memory/graph/graph-search.ts +1 -20
- package/src/memory/graph/retriever.test.ts +6 -0
- package/src/memory/graph/retriever.ts +6 -10
- package/src/memory/indexer.ts +54 -45
- package/src/memory/job-handlers/backfill.ts +2 -11
- package/src/memory/job-handlers/cleanup.ts +43 -0
- package/src/memory/job-handlers/embedding.ts +6 -8
- package/src/memory/job-handlers/summarization.ts +2 -7
- package/src/memory/jobs-store.ts +48 -0
- package/src/memory/jobs-worker.ts +81 -43
- package/src/memory/memory-v2-activation-log-store.ts +32 -14
- package/src/memory/memory-v2-concept-frequency.ts +169 -0
- package/src/memory/migrations/239-trace-events-created-at-index.ts +18 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/pkb/pkb-search.test.ts +6 -0
- package/src/memory/qdrant-client.ts +0 -13
- package/src/memory/rerank-local.ts +374 -0
- package/src/memory/search/semantic.ts +6 -67
- package/src/memory/trace-event-store.ts +1 -17
- package/src/memory/v2/__tests__/activation.test.ts +311 -250
- package/src/memory/v2/__tests__/consolidation-job.test.ts +40 -8
- package/src/memory/v2/__tests__/injection.test.ts +157 -167
- package/src/memory/v2/__tests__/prompts-consolidation.test.ts +61 -2
- package/src/memory/v2/__tests__/qdrant.test.ts +16 -0
- package/src/memory/v2/__tests__/reranker.test.ts +338 -0
- package/src/memory/v2/__tests__/sim.test.ts +5 -199
- package/src/memory/v2/__tests__/skill-store.test.ts +71 -65
- package/src/memory/v2/__tests__/static-context.test.ts +76 -1
- package/src/memory/v2/activation.ts +149 -156
- package/src/memory/v2/consolidation-job.ts +62 -12
- package/src/memory/v2/injection.ts +47 -60
- package/src/memory/v2/prompts/consolidation.ts +36 -1
- package/src/memory/v2/qdrant.ts +99 -0
- package/src/memory/v2/reranker.ts +177 -0
- package/src/memory/v2/sim.ts +10 -84
- package/src/memory/v2/skill-content.ts +4 -3
- package/src/memory/v2/skill-store.ts +82 -59
- package/src/memory/v2/static-context.ts +22 -0
- package/src/memory/v2/types.ts +10 -10
- package/src/notifications/copy-composer.ts +13 -0
- package/src/notifications/signal.ts +4 -0
- package/src/oauth/AGENTS.md +3 -1
- package/src/oauth/__tests__/oauth-connect-state.test.ts +137 -0
- package/src/oauth/connect-orchestrator.ts +2 -0
- package/src/oauth/connection-resolver.test.ts +66 -1
- package/src/oauth/connection-resolver.ts +55 -1
- package/src/oauth/oauth-connect-state.ts +77 -0
- package/src/oauth/seed-providers.ts +58 -1
- package/src/plugins/defaults/injectors.ts +35 -2
- package/src/plugins/defaults/memory-retrieval.ts +5 -6
- package/src/plugins/types.ts +7 -0
- package/src/proactive-artifact/aux-message-injector.ts +74 -0
- package/src/proactive-artifact/decision.test.ts +226 -0
- package/src/proactive-artifact/decision.ts +165 -0
- package/src/proactive-artifact/index.ts +7 -0
- package/src/proactive-artifact/job.test.ts +867 -0
- package/src/proactive-artifact/job.ts +352 -0
- package/src/proactive-artifact/message-copy.ts +41 -0
- package/src/proactive-artifact/trigger-state.test.ts +277 -0
- package/src/proactive-artifact/trigger-state.ts +119 -0
- package/src/prompts/normalize-onboarding.ts +80 -0
- package/src/prompts/persona-resolver.ts +101 -9
- package/src/prompts/system-prompt.ts +21 -7
- package/src/prompts/templates/BOOTSTRAP.md +13 -5
- package/src/providers/__tests__/retry-callsite.test.ts +222 -1
- package/src/providers/model-intents.ts +7 -0
- package/src/providers/openrouter/client.ts +8 -0
- package/src/providers/retry.ts +50 -0
- package/src/providers/types.ts +1 -0
- package/src/runtime/__tests__/agent-wake.test.ts +456 -3
- package/src/runtime/agent-wake.ts +238 -100
- package/src/runtime/assistant-event-hub.ts +36 -6
- package/src/runtime/assistant-event.ts +0 -1
- package/src/runtime/auth/__tests__/route-policy.test.ts +64 -0
- package/src/runtime/auth/route-policy.ts +14 -1
- package/src/runtime/auth/same-actor.ts +216 -0
- package/src/runtime/channel-retry-sweep.ts +65 -1
- package/src/runtime/guardian-reply-router.ts +10 -0
- package/src/runtime/local-actor-identity.ts +52 -11
- package/src/runtime/pending-interactions.ts +8 -0
- package/src/runtime/routes/__tests__/client-routes.test.ts +155 -0
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +0 -5
- package/src/runtime/routes/__tests__/heartbeat-routes.test.ts +1 -1
- package/src/runtime/routes/client-routes.ts +20 -2
- package/src/runtime/routes/contact-routes.ts +0 -25
- package/src/runtime/routes/conversation-routes.ts +35 -26
- package/src/runtime/routes/debug-bash-routes.ts +163 -0
- package/src/runtime/routes/disk-pressure-routes.ts +121 -0
- package/src/runtime/routes/document-pdf-renderer.ts +6 -2
- package/src/runtime/routes/documents-routes.ts +2 -75
- package/src/runtime/routes/events-routes.ts +41 -9
- package/src/runtime/routes/host-bash-routes.ts +23 -3
- package/src/runtime/routes/host-cu-routes.ts +33 -6
- package/src/runtime/routes/host-file-routes.ts +32 -6
- package/src/runtime/routes/host-transfer-routes.ts +79 -16
- package/src/runtime/routes/identity-routes.ts +7 -138
- package/src/runtime/routes/inbound-message-handler.ts +77 -12
- package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +3 -0
- package/src/runtime/routes/index.ts +6 -0
- package/src/runtime/routes/memory-item-routes.test.ts +41 -15
- package/src/runtime/routes/memory-v2-routes.ts +33 -0
- package/src/runtime/routes/oauth-connect-routes.ts +153 -0
- package/src/runtime/verification-outbound-actions.ts +4 -4
- package/src/schedule/run-script.ts +37 -5
- package/src/schedule/scheduler.ts +20 -1
- package/src/security/encrypted-store.ts +2 -0
- package/src/security/secure-keys.ts +55 -0
- package/src/skills/remote-skill-policy.ts +4 -10
- package/src/subagent/index.ts +1 -7
- package/src/subagent/manager.ts +1 -15
- package/src/tasks/task-runner.ts +0 -1
- package/src/tasks/task-store.ts +0 -3
- package/src/tools/background-tool-registry.ts +17 -3
- package/src/tools/host-filesystem/edit.test.ts +151 -0
- package/src/tools/host-filesystem/edit.ts +43 -1
- package/src/tools/host-filesystem/read.test.ts +129 -0
- package/src/tools/host-filesystem/read.ts +43 -1
- package/src/tools/host-filesystem/transfer.test.ts +127 -2
- package/src/tools/host-filesystem/transfer.ts +56 -11
- package/src/tools/host-filesystem/write.test.ts +134 -0
- package/src/tools/host-filesystem/write.ts +43 -1
- package/src/tools/host-terminal/host-shell.ts +13 -6
- package/src/tools/mcp/mcp-tool-factory.ts +2 -1
- package/src/tools/memory/register.test.ts +12 -9
- package/src/tools/memory/register.ts +1 -2
- package/src/tools/provider-tool-name.ts +28 -0
- package/src/tools/registry.ts +30 -9
- package/src/tools/terminal/shell.ts +9 -1
- package/src/tools/tool-approval-handler.ts +31 -6
- package/src/tools/types.ts +24 -2
- package/src/tts/provider-catalog.ts +3 -5
- package/src/util/disk-usage.ts +138 -0
- package/src/util/platform.ts +21 -11
- package/src/util/process-liveness.ts +26 -0
- package/src/workspace/heartbeat-service.ts +19 -0
- package/src/workspace/migrations/065-bump-stale-heartbeat-interval.ts +60 -0
- package/src/workspace/migrations/066-seed-heartbeat-callsite-cost-default.ts +146 -0
- package/src/workspace/migrations/067-release-notes-safe-storage-limits.ts +72 -0
- package/src/workspace/migrations/068-release-notes-local-timezone.ts +65 -0
- package/src/workspace/migrations/registry.ts +8 -0
- package/src/__tests__/conversation-tool-setup-memory-scope.test.ts +0 -167
- package/src/memory/v2/__tests__/skill-qdrant.test.ts +0 -657
- package/src/memory/v2/skill-qdrant.ts +0 -404
- package/src/signals/bash.ts +0 -198
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `memory/v2/reranker.ts` — public `rerankCandidates` function.
|
|
3
|
+
*
|
|
4
|
+
* Mocks the underlying `LocalRerankBackend` and the `readPage` page reader so
|
|
5
|
+
* the test is hermetic (no subprocess, no filesystem). Verifies the public
|
|
6
|
+
* contract: scores keyed by slug, fail-open on backend failure, page-read
|
|
7
|
+
* failures drop slugs silently, LRU cache hits skip the backend.
|
|
8
|
+
*/
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
10
|
+
|
|
11
|
+
import { makeMockLogger } from "../../../__tests__/helpers/mock-logger.js";
|
|
12
|
+
import type { AssistantConfig } from "../../../config/types.js";
|
|
13
|
+
|
|
14
|
+
mock.module("../../../util/logger.js", () => ({
|
|
15
|
+
getLogger: () => makeMockLogger(),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
mock.module("../../../util/platform.js", () => ({
|
|
19
|
+
getWorkspaceDir: () => "/tmp/test-workspace",
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
const backendState = {
|
|
23
|
+
scores: [] as number[],
|
|
24
|
+
shouldThrow: false,
|
|
25
|
+
calls: [] as Array<{ queries: string[]; passages: string[] }>,
|
|
26
|
+
};
|
|
27
|
+
mock.module("../../rerank-local.js", () => ({
|
|
28
|
+
getOrCreateRerankBackend: (_model: string, _dtype: string) => ({
|
|
29
|
+
score: async (queries: string[], passages: string[]): Promise<number[]> => {
|
|
30
|
+
backendState.calls.push({
|
|
31
|
+
queries: [...queries],
|
|
32
|
+
passages: [...passages],
|
|
33
|
+
});
|
|
34
|
+
if (backendState.shouldThrow) throw new Error("backend down");
|
|
35
|
+
return backendState.scores.slice(0, passages.length);
|
|
36
|
+
},
|
|
37
|
+
}),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
const pageState = {
|
|
41
|
+
pages: new Map<string, { body: string } | null>(),
|
|
42
|
+
failingSlugs: new Set<string>(),
|
|
43
|
+
};
|
|
44
|
+
// Partial mock — Bun's `mock.module` is process-wide, so we re-export every
|
|
45
|
+
// real symbol and override only `readPage`. Without this, sibling test files
|
|
46
|
+
// that import `listPages` etc. would crash with "Export not found".
|
|
47
|
+
const realPageStore = await import("../page-store.js");
|
|
48
|
+
mock.module("../page-store.js", () => ({
|
|
49
|
+
...realPageStore,
|
|
50
|
+
readPage: async (_dir: string, slug: string) => {
|
|
51
|
+
if (pageState.failingSlugs.has(slug)) {
|
|
52
|
+
throw new Error("read failure");
|
|
53
|
+
}
|
|
54
|
+
return pageState.pages.get(slug) ?? null;
|
|
55
|
+
},
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
const { rerankCandidates, _resetRerankCacheForTests } =
|
|
59
|
+
await import("../reranker.js");
|
|
60
|
+
|
|
61
|
+
function configWithModel(model = "test-model"): AssistantConfig {
|
|
62
|
+
return {
|
|
63
|
+
memory: {
|
|
64
|
+
v2: {
|
|
65
|
+
rerank: {
|
|
66
|
+
model,
|
|
67
|
+
enabled: true,
|
|
68
|
+
top_k: 50,
|
|
69
|
+
alpha: 0.3,
|
|
70
|
+
dtype: "q8",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
} as unknown as AssistantConfig;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resetState() {
|
|
78
|
+
backendState.scores = [];
|
|
79
|
+
backendState.shouldThrow = false;
|
|
80
|
+
backendState.calls.length = 0;
|
|
81
|
+
pageState.pages.clear();
|
|
82
|
+
pageState.failingSlugs.clear();
|
|
83
|
+
_resetRerankCacheForTests();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
beforeEach(resetState);
|
|
87
|
+
afterEach(resetState);
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Convenience: run `rerankCandidates` with a single query and unwrap the
|
|
91
|
+
* returned array. Most tests below assert the contract for the
|
|
92
|
+
* legacy-equivalent single-query path.
|
|
93
|
+
*/
|
|
94
|
+
async function rerankSingle(
|
|
95
|
+
query: string,
|
|
96
|
+
candidates: readonly string[],
|
|
97
|
+
config: AssistantConfig,
|
|
98
|
+
): Promise<Map<string, number>> {
|
|
99
|
+
const out = await rerankCandidates([query], candidates, config);
|
|
100
|
+
return out[0] ?? new Map();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
describe("rerankCandidates", () => {
|
|
104
|
+
test("returns empty maps for empty candidates", async () => {
|
|
105
|
+
const out = await rerankCandidates(["query"], [], configWithModel());
|
|
106
|
+
expect(out).toHaveLength(1);
|
|
107
|
+
expect(out[0].size).toBe(0);
|
|
108
|
+
expect(backendState.calls).toHaveLength(0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("returns empty array when no queries are passed", async () => {
|
|
112
|
+
const out = await rerankCandidates([], ["a"], configWithModel());
|
|
113
|
+
expect(out).toHaveLength(0);
|
|
114
|
+
expect(backendState.calls).toHaveLength(0);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("returns empty map for whitespace-only query", async () => {
|
|
118
|
+
pageState.pages.set("a", { body: "content" });
|
|
119
|
+
const out = await rerankSingle(" ", ["a"], configWithModel());
|
|
120
|
+
expect(out.size).toBe(0);
|
|
121
|
+
expect(backendState.calls).toHaveLength(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("scores returned keyed by slug, in [0, 1]", async () => {
|
|
125
|
+
pageState.pages.set("a", { body: "first paragraph of a" });
|
|
126
|
+
pageState.pages.set("b", { body: "first paragraph of b" });
|
|
127
|
+
backendState.scores = [0.9, 0.1];
|
|
128
|
+
|
|
129
|
+
const out = await rerankSingle("query", ["a", "b"], configWithModel());
|
|
130
|
+
|
|
131
|
+
expect(out.get("a")).toBe(0.9);
|
|
132
|
+
expect(out.get("b")).toBe(0.1);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("clamps scores to [0, 1]", async () => {
|
|
136
|
+
pageState.pages.set("a", { body: "x" });
|
|
137
|
+
pageState.pages.set("b", { body: "x" });
|
|
138
|
+
backendState.scores = [1.5, -0.2];
|
|
139
|
+
|
|
140
|
+
const out = await rerankSingle("query", ["a", "b"], configWithModel());
|
|
141
|
+
|
|
142
|
+
expect(out.get("a")).toBe(1);
|
|
143
|
+
expect(out.get("b")).toBe(0);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("drops slugs whose page failed to read; others present", async () => {
|
|
147
|
+
pageState.pages.set("a", { body: "x" });
|
|
148
|
+
pageState.failingSlugs.add("b");
|
|
149
|
+
pageState.pages.set("c", { body: "y" });
|
|
150
|
+
backendState.scores = [0.5, 0.7];
|
|
151
|
+
|
|
152
|
+
const out = await rerankSingle("query", ["a", "b", "c"], configWithModel());
|
|
153
|
+
|
|
154
|
+
expect(out.has("b")).toBe(false);
|
|
155
|
+
expect(out.get("a")).toBe(0.5);
|
|
156
|
+
expect(out.get("c")).toBe(0.7);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("drops slugs whose page is null (missing on disk)", async () => {
|
|
160
|
+
pageState.pages.set("a", { body: "x" });
|
|
161
|
+
pageState.pages.set("missing", null);
|
|
162
|
+
backendState.scores = [0.5];
|
|
163
|
+
|
|
164
|
+
const out = await rerankSingle(
|
|
165
|
+
"query",
|
|
166
|
+
["a", "missing"],
|
|
167
|
+
configWithModel(),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
expect(out.size).toBe(1);
|
|
171
|
+
expect(out.get("a")).toBe(0.5);
|
|
172
|
+
expect(out.has("missing")).toBe(false);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("returns empty map when backend throws (fail-open)", async () => {
|
|
176
|
+
pageState.pages.set("a", { body: "x" });
|
|
177
|
+
backendState.shouldThrow = true;
|
|
178
|
+
|
|
179
|
+
const out = await rerankSingle("query", ["a"], configWithModel());
|
|
180
|
+
|
|
181
|
+
expect(out.size).toBe(0);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("returns empty map when no pages load (no backend call)", async () => {
|
|
185
|
+
pageState.failingSlugs.add("a");
|
|
186
|
+
|
|
187
|
+
const out = await rerankSingle("query", ["a"], configWithModel());
|
|
188
|
+
|
|
189
|
+
expect(out.size).toBe(0);
|
|
190
|
+
expect(backendState.calls).toHaveLength(0);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("LRU cache hit skips the backend on identical inputs", async () => {
|
|
194
|
+
pageState.pages.set("a", { body: "x" });
|
|
195
|
+
backendState.scores = [0.7];
|
|
196
|
+
|
|
197
|
+
const first = await rerankSingle("query", ["a"], configWithModel());
|
|
198
|
+
const second = await rerankSingle("query", ["a"], configWithModel());
|
|
199
|
+
|
|
200
|
+
expect(first.get("a")).toBe(0.7);
|
|
201
|
+
expect(second.get("a")).toBe(0.7);
|
|
202
|
+
// Backend called only once — second call hit the cache.
|
|
203
|
+
expect(backendState.calls).toHaveLength(1);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("cache key insensitive to candidate order", async () => {
|
|
207
|
+
pageState.pages.set("a", { body: "x" });
|
|
208
|
+
pageState.pages.set("b", { body: "y" });
|
|
209
|
+
backendState.scores = [0.5, 0.6];
|
|
210
|
+
|
|
211
|
+
await rerankSingle("query", ["a", "b"], configWithModel());
|
|
212
|
+
await rerankSingle("query", ["b", "a"], configWithModel());
|
|
213
|
+
|
|
214
|
+
// Same query, same set of candidates — second call hits cache.
|
|
215
|
+
expect(backendState.calls).toHaveLength(1);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("passage construction caps at 240 chars after slug newline", async () => {
|
|
219
|
+
const longBody = "a".repeat(500);
|
|
220
|
+
pageState.pages.set("slug", { body: longBody });
|
|
221
|
+
backendState.scores = [0.5];
|
|
222
|
+
|
|
223
|
+
await rerankSingle("q", ["slug"], configWithModel());
|
|
224
|
+
|
|
225
|
+
expect(backendState.calls).toHaveLength(1);
|
|
226
|
+
const passage = backendState.calls[0].passages[0];
|
|
227
|
+
// "slug\n" prefix + 240 chars of body
|
|
228
|
+
expect(passage.startsWith("slug\n")).toBe(true);
|
|
229
|
+
expect(passage.length).toBeLessThanOrEqual(5 + 240);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("first paragraph is taken (body truncated at blank line)", async () => {
|
|
233
|
+
pageState.pages.set("slug", {
|
|
234
|
+
body: "first para line\n\nsecond para should not appear",
|
|
235
|
+
});
|
|
236
|
+
backendState.scores = [0.5];
|
|
237
|
+
|
|
238
|
+
await rerankSingle("q", ["slug"], configWithModel());
|
|
239
|
+
|
|
240
|
+
const passage = backendState.calls[0].passages[0];
|
|
241
|
+
expect(passage).toContain("first para line");
|
|
242
|
+
expect(passage).not.toContain("second para");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("multiple queries are batched into one backend call", async () => {
|
|
246
|
+
pageState.pages.set("a", { body: "x" });
|
|
247
|
+
pageState.pages.set("b", { body: "y" });
|
|
248
|
+
// Two queries × two passages = four scores, query-major:
|
|
249
|
+
// q1×a, q1×b, q2×a, q2×b
|
|
250
|
+
backendState.scores = [0.9, 0.1, 0.2, 0.8];
|
|
251
|
+
|
|
252
|
+
const out = await rerankCandidates(
|
|
253
|
+
["user-text", "assistant-text"],
|
|
254
|
+
["a", "b"],
|
|
255
|
+
configWithModel(),
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
expect(out).toHaveLength(2);
|
|
259
|
+
// Backend invoked exactly once with both queries' pairs.
|
|
260
|
+
expect(backendState.calls).toHaveLength(1);
|
|
261
|
+
expect(backendState.calls[0].queries).toEqual([
|
|
262
|
+
"user-text",
|
|
263
|
+
"user-text",
|
|
264
|
+
"assistant-text",
|
|
265
|
+
"assistant-text",
|
|
266
|
+
]);
|
|
267
|
+
expect(backendState.calls[0].passages).toHaveLength(4);
|
|
268
|
+
|
|
269
|
+
expect(out[0].get("a")).toBe(0.9);
|
|
270
|
+
expect(out[0].get("b")).toBeCloseTo(0.1, 6);
|
|
271
|
+
expect(out[1].get("a")).toBe(0.2);
|
|
272
|
+
expect(out[1].get("b")).toBe(0.8);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("partial cache hit skips the backend for the cached query", async () => {
|
|
276
|
+
pageState.pages.set("a", { body: "x" });
|
|
277
|
+
// Prime the cache with q1.
|
|
278
|
+
backendState.scores = [0.7];
|
|
279
|
+
await rerankCandidates(["q1"], ["a"], configWithModel());
|
|
280
|
+
expect(backendState.calls).toHaveLength(1);
|
|
281
|
+
|
|
282
|
+
// Now request both q1 (cached) and q2 (fresh). The backend should see
|
|
283
|
+
// only q2's pair, not q1's.
|
|
284
|
+
backendState.scores = [0.4];
|
|
285
|
+
const out = await rerankCandidates(["q1", "q2"], ["a"], configWithModel());
|
|
286
|
+
|
|
287
|
+
expect(backendState.calls).toHaveLength(2);
|
|
288
|
+
expect(backendState.calls[1].queries).toEqual(["q2"]);
|
|
289
|
+
expect(out[0].get("a")).toBe(0.7);
|
|
290
|
+
expect(out[1].get("a")).toBe(0.4);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("forwards configured dtype to the backend factory", async () => {
|
|
294
|
+
pageState.pages.set("a", { body: "x" });
|
|
295
|
+
backendState.scores = [0.5];
|
|
296
|
+
const dtypes: string[] = [];
|
|
297
|
+
|
|
298
|
+
// Re-mock the factory just for this test to capture the dtype arg.
|
|
299
|
+
mock.module("../../rerank-local.js", () => ({
|
|
300
|
+
getOrCreateRerankBackend: (_model: string, dtype: string) => {
|
|
301
|
+
dtypes.push(dtype);
|
|
302
|
+
return {
|
|
303
|
+
score: async (
|
|
304
|
+
queries: string[],
|
|
305
|
+
passages: string[],
|
|
306
|
+
): Promise<number[]> => {
|
|
307
|
+
backendState.calls.push({
|
|
308
|
+
queries: [...queries],
|
|
309
|
+
passages: [...passages],
|
|
310
|
+
});
|
|
311
|
+
return backendState.scores.slice(0, passages.length);
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
},
|
|
315
|
+
}));
|
|
316
|
+
const { rerankCandidates: freshRerank, _resetRerankCacheForTests: reset } =
|
|
317
|
+
await import("../reranker.js");
|
|
318
|
+
reset();
|
|
319
|
+
|
|
320
|
+
const config = {
|
|
321
|
+
memory: {
|
|
322
|
+
v2: {
|
|
323
|
+
rerank: {
|
|
324
|
+
model: "test-model",
|
|
325
|
+
enabled: true,
|
|
326
|
+
top_k: 50,
|
|
327
|
+
alpha: 0.3,
|
|
328
|
+
dtype: "fp32",
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
} as unknown as AssistantConfig;
|
|
333
|
+
|
|
334
|
+
await freshRerank(["q"], ["a"], config);
|
|
335
|
+
|
|
336
|
+
expect(dtypes).toContain("fp32");
|
|
337
|
+
});
|
|
338
|
+
});
|
|
@@ -70,16 +70,6 @@ const state = {
|
|
|
70
70
|
points: Array<{ score?: number; payload: Record<string, unknown> }>;
|
|
71
71
|
}>,
|
|
72
72
|
},
|
|
73
|
-
// Separate response queue for the dedicated skills collection so a test
|
|
74
|
-
// querying both concept pages and skills doesn't have to interleave.
|
|
75
|
-
skillQueryResponses: {
|
|
76
|
-
dense: [] as Array<{
|
|
77
|
-
points: Array<{ score?: number; payload: Record<string, unknown> }>;
|
|
78
|
-
}>,
|
|
79
|
-
sparse: [] as Array<{
|
|
80
|
-
points: Array<{ score?: number; payload: Record<string, unknown> }>;
|
|
81
|
-
}>,
|
|
82
|
-
},
|
|
83
73
|
queryCalls: [] as Array<{
|
|
84
74
|
collection: string;
|
|
85
75
|
using: string;
|
|
@@ -147,11 +137,7 @@ class MockQdrantClient {
|
|
|
147
137
|
filter: params.filter,
|
|
148
138
|
});
|
|
149
139
|
const channel = params.using as "dense" | "sparse";
|
|
150
|
-
|
|
151
|
-
name === "memory_v2_skills"
|
|
152
|
-
? state.skillQueryResponses[channel]
|
|
153
|
-
: state.queryResponses[channel];
|
|
154
|
-
return queue.shift() ?? { points: [] };
|
|
140
|
+
return state.queryResponses[channel].shift() ?? { points: [] };
|
|
155
141
|
}
|
|
156
142
|
}
|
|
157
143
|
|
|
@@ -159,10 +145,7 @@ mock.module("@qdrant/js-client-rest", () => ({
|
|
|
159
145
|
QdrantClient: MockQdrantClient,
|
|
160
146
|
}));
|
|
161
147
|
|
|
162
|
-
const { simBatch,
|
|
163
|
-
await import("../sim.js");
|
|
164
|
-
const { _resetMemoryV2SkillQdrantForTests } =
|
|
165
|
-
await import("../skill-qdrant.js");
|
|
148
|
+
const { simBatch, clamp01, effectiveWeights } = await import("../sim.js");
|
|
166
149
|
const { _resetMemoryV2QdrantForTests } = await import("../qdrant.js");
|
|
167
150
|
|
|
168
151
|
// ---------------------------------------------------------------------------
|
|
@@ -175,15 +158,12 @@ function resetState(): void {
|
|
|
175
158
|
state.embedReturn = [[0.1, 0.2, 0.3]];
|
|
176
159
|
state.queryResponses.dense.length = 0;
|
|
177
160
|
state.queryResponses.sparse.length = 0;
|
|
178
|
-
state.skillQueryResponses.dense.length = 0;
|
|
179
|
-
state.skillQueryResponses.sparse.length = 0;
|
|
180
161
|
state.queryCalls.length = 0;
|
|
181
162
|
// Bun's `mock.module` persists across files in the same process, so the
|
|
182
|
-
// qdrant
|
|
183
|
-
// from a sibling test file. Reset
|
|
184
|
-
//
|
|
163
|
+
// qdrant module's singleton may already hold a MockQdrantClient instance
|
|
164
|
+
// from a sibling test file. Reset readiness so each test in this file
|
|
165
|
+
// gets a fresh `new QdrantClient()` resolved against our mock.
|
|
185
166
|
_resetMemoryV2QdrantForTests();
|
|
186
|
-
_resetMemoryV2SkillQdrantForTests();
|
|
187
167
|
}
|
|
188
168
|
|
|
189
169
|
function configWithWeights(
|
|
@@ -533,177 +513,3 @@ describe("simBatch", () => {
|
|
|
533
513
|
}
|
|
534
514
|
});
|
|
535
515
|
});
|
|
536
|
-
|
|
537
|
-
// ---------------------------------------------------------------------------
|
|
538
|
-
// simSkillBatch
|
|
539
|
-
// ---------------------------------------------------------------------------
|
|
540
|
-
|
|
541
|
-
/**
|
|
542
|
-
* Stage a single hybrid response on the dedicated skills queues. Mirrors
|
|
543
|
-
* `stageHybridResponse` but uses `payload.id` (skills' Qdrant payload key)
|
|
544
|
-
* instead of `payload.slug`.
|
|
545
|
-
*/
|
|
546
|
-
function stageSkillHybridResponse(
|
|
547
|
-
hits: Array<{ id: string; denseScore?: number; sparseScore?: number }>,
|
|
548
|
-
): void {
|
|
549
|
-
state.skillQueryResponses.dense.push({
|
|
550
|
-
points: hits
|
|
551
|
-
.filter((h) => h.denseScore !== undefined)
|
|
552
|
-
.map((h) => ({ score: h.denseScore, payload: { id: h.id } })),
|
|
553
|
-
});
|
|
554
|
-
state.skillQueryResponses.sparse.push({
|
|
555
|
-
points: hits
|
|
556
|
-
.filter((h) => h.sparseScore !== undefined)
|
|
557
|
-
.map((h) => ({ score: h.sparseScore, payload: { id: h.id } })),
|
|
558
|
-
});
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
describe("simSkillBatch", () => {
|
|
562
|
-
test("empty id list returns empty map without touching backends", async () => {
|
|
563
|
-
const config = configWithWeights(0.7, 0.3);
|
|
564
|
-
|
|
565
|
-
const out = await simSkillBatch("anything", [], config);
|
|
566
|
-
|
|
567
|
-
expect(out.size).toBe(0);
|
|
568
|
-
expect(state.embedCalls).toHaveLength(0);
|
|
569
|
-
expect(state.sparseCalls).toHaveLength(0);
|
|
570
|
-
expect(state.queryCalls).toHaveLength(0);
|
|
571
|
-
});
|
|
572
|
-
|
|
573
|
-
test("empty text returns empty map without touching backends", async () => {
|
|
574
|
-
const config = configWithWeights(0.7, 0.3);
|
|
575
|
-
|
|
576
|
-
for (const text of ["", " ", "\n\n"]) {
|
|
577
|
-
state.embedCalls.length = 0;
|
|
578
|
-
state.sparseCalls.length = 0;
|
|
579
|
-
state.queryCalls.length = 0;
|
|
580
|
-
const out = await simSkillBatch(text, ["example-skill-a"], config);
|
|
581
|
-
expect(out.size).toBe(0);
|
|
582
|
-
expect(state.embedCalls).toHaveLength(0);
|
|
583
|
-
expect(state.sparseCalls).toHaveLength(0);
|
|
584
|
-
expect(state.queryCalls).toHaveLength(0);
|
|
585
|
-
}
|
|
586
|
-
});
|
|
587
|
-
|
|
588
|
-
test("queries the dedicated skills collection and forwards an id-IN filter", async () => {
|
|
589
|
-
const config = configWithWeights(0.7, 0.3);
|
|
590
|
-
stageSkillHybridResponse([]);
|
|
591
|
-
|
|
592
|
-
await simSkillBatch(
|
|
593
|
-
"query",
|
|
594
|
-
["example-skill-a", "example-skill-b"],
|
|
595
|
-
config,
|
|
596
|
-
);
|
|
597
|
-
|
|
598
|
-
expect(state.queryCalls).toHaveLength(2);
|
|
599
|
-
for (const call of state.queryCalls) {
|
|
600
|
-
expect(call.collection).toBe("memory_v2_skills");
|
|
601
|
-
// The candidate ids are forwarded as a Qdrant filter so Qdrant scores
|
|
602
|
-
// exactly the candidate set, not its global top-K. Without this,
|
|
603
|
-
// candidate ids absent from the global top-K silently score 0.
|
|
604
|
-
expect(call.filter).toEqual({
|
|
605
|
-
must: [
|
|
606
|
-
{ key: "id", match: { any: ["example-skill-a", "example-skill-b"] } },
|
|
607
|
-
],
|
|
608
|
-
});
|
|
609
|
-
// Limit equals the candidate count.
|
|
610
|
-
expect(call.limit).toBe(2);
|
|
611
|
-
}
|
|
612
|
-
});
|
|
613
|
-
|
|
614
|
-
test("fuses dense + sparse with the configured weight blend", async () => {
|
|
615
|
-
const config = configWithWeights(0.4, 0.6);
|
|
616
|
-
stageSkillHybridResponse([
|
|
617
|
-
{ id: "example-skill-a", denseScore: 0.5, sparseScore: 4 }, // sparse-norm 1.0
|
|
618
|
-
{ id: "example-skill-b", denseScore: 0.25, sparseScore: 2 }, // sparse-norm 0.5
|
|
619
|
-
]);
|
|
620
|
-
|
|
621
|
-
const out = await simSkillBatch(
|
|
622
|
-
"query",
|
|
623
|
-
["example-skill-a", "example-skill-b"],
|
|
624
|
-
config,
|
|
625
|
-
);
|
|
626
|
-
|
|
627
|
-
// example-skill-a: 0.4 * 0.5 + 0.6 * 1.0 = 0.8
|
|
628
|
-
// example-skill-b: 0.4 * 0.25 + 0.6 * 0.5 = 0.4
|
|
629
|
-
expect(out.get("example-skill-a")).toBeCloseTo(0.8, 6);
|
|
630
|
-
expect(out.get("example-skill-b")).toBeCloseTo(0.4, 6);
|
|
631
|
-
});
|
|
632
|
-
|
|
633
|
-
test("dense-only and sparse-only hits are handled symmetrically", async () => {
|
|
634
|
-
const config = configWithWeights(0.7, 0.3);
|
|
635
|
-
stageSkillHybridResponse([
|
|
636
|
-
{ id: "example-skill-a", denseScore: 0.5 /* sparse omitted */ },
|
|
637
|
-
{ id: "example-skill-b", sparseScore: 8 /* dense omitted */ },
|
|
638
|
-
]);
|
|
639
|
-
|
|
640
|
-
const out = await simSkillBatch(
|
|
641
|
-
"query",
|
|
642
|
-
["example-skill-a", "example-skill-b"],
|
|
643
|
-
config,
|
|
644
|
-
);
|
|
645
|
-
|
|
646
|
-
// example-skill-a: 0.7 * 0.5 + 0.3 * 0 = 0.35
|
|
647
|
-
// example-skill-b: 0.7 * 0 + 0.3 * 1.0 = 0.30 (sparse-norm = 8/8)
|
|
648
|
-
expect(out.get("example-skill-a")).toBeCloseTo(0.35, 6);
|
|
649
|
-
expect(out.get("example-skill-b")).toBeCloseTo(0.3, 6);
|
|
650
|
-
});
|
|
651
|
-
|
|
652
|
-
test("forwards candidate ids as the Qdrant restriction; only candidates in result", async () => {
|
|
653
|
-
// The bug we're guarding against: when the skills collection has more
|
|
654
|
-
// skills than `ids.length`, calling `hybridQuerySkills` without a filter
|
|
655
|
-
// returns Qdrant's global top-K. Candidate ids absent from that top-K
|
|
656
|
-
// would silently score 0. The fix is to forward the candidate ids as a
|
|
657
|
-
// server-side restriction so Qdrant scores exactly the candidate set.
|
|
658
|
-
const config = configWithWeights(0.7, 0.3);
|
|
659
|
-
stageSkillHybridResponse([
|
|
660
|
-
{ id: "example-skill-a", denseScore: 0.5, sparseScore: 1 },
|
|
661
|
-
// `example-skill-c` would never be returned in production once the
|
|
662
|
-
// filter is applied; the post-filter in simSkillBatch defensively
|
|
663
|
-
// drops it even if a stale payload slips through.
|
|
664
|
-
{ id: "example-skill-c", denseScore: 0.9, sparseScore: 1 },
|
|
665
|
-
]);
|
|
666
|
-
|
|
667
|
-
const out = await simSkillBatch(
|
|
668
|
-
"query",
|
|
669
|
-
["example-skill-a", "example-skill-b"],
|
|
670
|
-
config,
|
|
671
|
-
);
|
|
672
|
-
|
|
673
|
-
// The Qdrant filter was forwarded — both channels carry the id-IN
|
|
674
|
-
// restriction matching the caller's candidate set.
|
|
675
|
-
expect(state.queryCalls).toHaveLength(2);
|
|
676
|
-
for (const call of state.queryCalls) {
|
|
677
|
-
expect(call.filter).toEqual({
|
|
678
|
-
must: [
|
|
679
|
-
{ key: "id", match: { any: ["example-skill-a", "example-skill-b"] } },
|
|
680
|
-
],
|
|
681
|
-
});
|
|
682
|
-
}
|
|
683
|
-
// Only candidate ids appear in the result map.
|
|
684
|
-
expect(out.has("example-skill-a")).toBe(true);
|
|
685
|
-
expect(out.has("example-skill-c")).toBe(false);
|
|
686
|
-
});
|
|
687
|
-
|
|
688
|
-
test("returned scores are clamped into [0, 1]", async () => {
|
|
689
|
-
const config = configWithWeights(0.8, 0.5); // intentionally sums to > 1
|
|
690
|
-
stageSkillHybridResponse([
|
|
691
|
-
{ id: "example-skill-a", denseScore: 1.0, sparseScore: 1 },
|
|
692
|
-
]);
|
|
693
|
-
|
|
694
|
-
const out = await simSkillBatch("query", ["example-skill-a"], config);
|
|
695
|
-
|
|
696
|
-
expect(out.get("example-skill-a")).toBe(1);
|
|
697
|
-
});
|
|
698
|
-
|
|
699
|
-
test("embeds the query text exactly once via dense + sparse backends", async () => {
|
|
700
|
-
const config = configWithWeights(0.7, 0.3);
|
|
701
|
-
stageSkillHybridResponse([]);
|
|
702
|
-
|
|
703
|
-
await simSkillBatch("hello skill", ["example-skill-a"], config);
|
|
704
|
-
|
|
705
|
-
expect(state.embedCalls).toHaveLength(1);
|
|
706
|
-
expect(state.embedCalls[0].inputs).toEqual(["hello skill"]);
|
|
707
|
-
expect(state.sparseCalls).toEqual(["hello skill"]);
|
|
708
|
-
});
|
|
709
|
-
});
|