chrisjowen/docket #48 Merged
feat(adapter-neo4j): answer searches with one native ranked subgraph query
Description
Intent
Neo4j's default search path answers with one native ranked subgraph: option A2 from the Neo4j native search report (data/docket-neo4j-native-search/report.md, under the Firstmate home), the second PR of option A after A1 (adapter subgraphs reach docket_search, merged as #47). A2 removes the per-hit N+1 queries and makes graph-native ranking the default without a model. "We need to initialise neo too": the integration test target is a real local Neo4j (the report's decision 2, option a), not only unit tests with a fake port.
What the report's A2 (its proposal N2) asks for: replace the Neo4j adapter's default no-model search path - today one full-text query plus one extra neighbour query per hit (N+1), each neighbourhood flattened into a detail string, answered as entities and passages only with no graph block - with one deterministic, adapter-authored Cypher query and no model involved: full-text seeds from the memory_text index in the instance's scope, then a bounded 1-2 hop typed expansion from each seed, ranked by Lucene score times path confidence (the product of the relationships' stored confidence), with hub damping (dividing by the log of the end node's degree) so the best-connected nodes do not dominate, and per-seed caps on paths. It returns, through the adapter's existing answer machinery: an entities block of the seeds with their Lucene scores; a graph block of the path nodes, the edges in their stored direction each citing its evidence, and the ordered paths; a passages block of relevant excerpts as today; and one derived-fact evidence item per path as its "why", e.g. orders -depends_on-> ledger (0.95, code+manifest) -depends_on-> ledger-db (0.9, runtime). Coverage stays top-k. Neighbours are context in the graph, not hits, keeping the existing concern that well-connected documents would otherwise come back for every query. Config knobs traverse: { hops (up to 2-3), minConfidence, rels? }, defaulting to all relationship types; direction is reported faithfully per edge. No contract change and no core change are required (A1 already makes adapter graph blocks visible in docket_search). Because the query is adapter-authored, the scope guard that covers only model-written Cypher does not cover it, so a unit test must check every node pattern carries $scope. One round trip instead of 1 + N per search. Ranking quality is a judgement call, so hops and minConfidence stay configurable. Integration tests run against a real Neo4j through NEO4J_TEST_URL.
What Changed
- The Neo4j adapter's no-model search (and the fallback when generated Cypher fails or finds nothing) no longer runs a full-text query plus one neighbour query per hit. It now runs one adapter-authored Cypher query in a timed read transaction (new
subgraph-search.ts). The query takes full-text seeds frommemory_textin the instance's scope and expands each seed by 1–3 hops in either direction, with every node checked against$scope. Paths are ranked by the product of their relationships' confidence, divided by the log of the end node's degree, and each seed keeps its best 3 paths. A path that another kept path extends is dropped. answer()now returns four kinds of block: an entities block of the seeds with their Lucene scores; a graph block of the path nodes and edges, each edge in its stored direction and citing its evidence; passages as before; and onederived-factevidence item per path explaining why it holds. The reached neighbours are graph context, not hits.search()hits now describe their paths indetail. The olddescribeNeighbourshelper is removed.- A new optional
traverse: { hops (1–3, default 2), minConfidence (0–1, default 0), rels? }config is validated inconfig.ts. Unit tests check that every node pattern in the query carries$scopeand that only a whole hop count is written into it. They also cover path pruning and the answer blocks. The integration tests run against a real Neo4j throughNEO4J_TEST_URLand cover the traversal reach and the ranking. The READMEs and the bundledwheelhouse/docketadapterdistare updated to match.
🤖 Generated with Claude Code
Risk Assessment
Testing
I stood up a throwaway Neo4j container, rebuilt the workspace, and ran the adapter's integration and unit tests against it (all pass). I then drove the real docket CLI and the docket open UI on a temp copy of the acme-platform example. Instrumenting the driver showed each search makes exactly one read query (after the one-off schema setup at startup), in a transaction capped at the request's remaining time. The answer carries the seeds' Lucene scores, directed edges with confidence that cite their paths, ordered paths, passages, and a derived fact per path. hops, rels and minConfidence change the result, and hops > 3 is rejected. Another scope's data never appears, even with a forced cross-scope relationship. UI screenshots show the graph block rendering. Everything passed and all temporary resources were torn down.
- Live validation: ✅ go - 7 of 7 scenarios driven live against the product
| Scenario | Result | Live | Evidence |
|---|---|---|---|
| Integration tests in the repo run against a real local Neo4j via NEO4J_TEST_URL and pass (including subgraph answer, traverse reach, confidence ranking with hub damping) | ✅ pass | live | integration-tests.log |
User runs docket ask with no model against a Neo4j projection and gets entities (seeds + Lucene scores), a graph block (nodes, directed edges citing evidence, ordered paths), passages, and one deriv… |
✅ pass | live | cli-ask-neo4j-checkout.txt, cli-ask-neo4j-orders.json |
| A search is one round trip: one read query per search instead of 1 + N | ✅ pass | live | round-trips-ask.txt, round-trips-search.txt |
| traverse.hops / rels / minConfidence change the answer; hops over 3 is rejected | ✅ pass | live | traverse-knobs.txt |
| Adversarial: the subgraph read cannot run past the request deadline | ✅ pass | live | deadline-bound.txt |
| Adversarial: another scope's nodes never appear, even through a hand-made cross-scope relationship | ✅ pass | live | scope-isolation.txt |
| Neo4j graph block is visible in the docket UI Ask page | ✅ pass | live | ui-ask-neo4j-checkout.png, ui-neo4j-graph-block.png |
Evidence: Integration + unit tests against real Neo4j
RUN v3.2.7 ~/.no-mistakes/worktrees/c33da487ab20/01M68231VHX6Q5D9WWHCCTTHST/packages/adapter-neo4j
✓ src/subgraph-search.test.ts > subgraphQuery > keeps every node it reads in scope, as a model-written query must 22ms
✓ src/subgraph-search.test.ts > subgraphQuery > writes only a whole number of hops into the query 2ms
✓ src/subgraph-search.test.ts > matchesOf > drops a path another one extends, and keeps stubs as stubs 4ms
✓ src/subgraph-search.test.ts > matchesOf > keeps the best three paths, none visiting a node twice 2ms
✓ src/subgraph-search.test.ts > matchesOf > reads a match with no paths 1ms
✓ src/subgraph-search.test.ts > describePath > draws each relationship in its stored direction, and with why how far it holds 0ms
✓ src/subgraph-search.test.ts > subgraphAnswer > answers with the matches, the paths from them and why each holds 24ms
✓ src/subgraph-search.test.ts > subgraphAnswer > cites every path a relationship lies on 2ms
✓ src/subgraph-search.test.ts > subgraphAnswer > still draws the paths whose why no longer fits the evidence budget, and says it was cut 3ms
✓ src/subgraph-search.test.ts > subgraphAnswer > has no graph for matches with no paths 1ms
✓ src/subgraph-search.test.ts > subgraphAnswer > keeps only the paths of the matches it shows 1ms
✓ src/neo4j-projection.integration.test.ts > neo4j projection > stores a document as a typed node with typed links to stub targets 2993ms
✓ src/neo4j-projection.integration.test.ts > neo4j projection > writes one relationship per (source, rel, target) with its evidence count and confidence 587ms
✓ src/neo4j-projection.integration.test.ts > neo4j projection > fills in a stub when its own document arrives, keeping the links to it 214ms
✓ src/neo4j-projection.integration.test.ts > neo4j projection > replaces the type label and outgoing links when a document changes 336ms
✓ src/neo4j-projection.integration.test.ts > neo4j projection > turns a removed document that others still link to back into a stub 426ms
✓ src/neo4j-projection.integration.test.ts > neo4j projection > deletes a removed document, and stubs nothing links to any more 178ms
✓ src/neo4j-projection.integration.test.ts > neo4j projection > keeps a document with index.graph false out of the graph 118ms
✓ src/neo4j-projection.integration.test.ts > neo4j projection > resets only its own scope 231ms
✓ src/neo4j-projection.integration.test.ts > neo4j projection > searches text, and answers with each match and what it is linked to 608ms
✓ src/neo4j-projection.integration.test.ts > neo4j projection > ignores stop words and matches other forms of a word 291ms
✓ src/neo4j-projection.integration.test.ts > neo4j projection > without a model > answers with the matches and the paths from them 398ms
✓ src/neo4j-projection.integration.test.ts > neo4j projection > without a model > reaches as far as traverse allows 611ms
✓ src/neo4j-projection.integration.test.ts > neo4j projection > without a model > ranks paths by confidence, damped where they end at a well-connected node 621ms
✓ src/neo4j-projection.integration.test.ts > neo4j projection > with a model writing Cypher > answers from the rows the query returns, and shows the query 344ms
✓ src/neo4j-projection.integration.test.ts > neo4j projection > with a model writing Cypher > maps returned ids back to documents 258ms
✓ src/neo4j-projection.integration.test.ts > neo4j projection > with a model writing Cypher > leaves out a detail that would only repeat the id 211ms
✓ src/neo4j-projection.integration.test.ts > neo4j projection > with a model writing Cypher > refuses a query that could read another scope, before running it 201ms
✓ src/neo4j-projection.integration.test.ts > neo4j projection > with a model writing Cypher > answers a count with the count, exhaustively, never with documents 151ms
✓ src/neo4j-projection.integration.test.ts > neo4j projection > with a model writing Cypher > answers grouped values as a typed table, keeping each row 134ms
✓ src/neo4j-projection.integration.test.ts > neo4j projection > with a model writing Cypher > falls back to full-text, saying why, when the query is refused 153ms
✓ src/neo4j-projection.integration.test.ts > neo4j projection > with a model writing Cypher > falls back to full-text when the query finds nothing 163ms
✓ src/neo4j-projection.integration.test.ts > neo4j projection > does not search other scopes 106ms
Test Files 2 passed (2)
Tests 33 passed (33)
Start at 12:16:08
Duration 13.41s (transform 1.26s, setup 0ms, collect 3.95s, tests 9.41s, environment 0ms, prepare 813ms)
Evidence: docket ask checkout (neo4j): entities, graph, paths, passages
Q. checkout
asked 2026-10-31T04:16:45.784Z (Asia/Singapore), scope "default"
Summary - claude
**Checkout** [service.checkout] is the active, TypeScript, browser-facing checkout flow. It collects the cart and payment intent and hands a submission to Orders [service.orders]. It holds no order state of its own [neo4j:passage-1].
- **Ownership and code:** Platform Engineering owns it [team.platform-engineering], and that team carries the production pager for the transactional path [neo4j:passage-3]. It lives in the acme-platform monorepo [repository.acme-platform] [neo4j:passage-4].
- **Rewrite flag:** A rewritten flow ships behind the temporary flag `checkout.rewrite` [feature_flag.checkout-rewrite]. The flag is currently at 10% of production traffic, and it will be removed once the old flow is deleted [neo4j:passage-2].
- **Dependencies:**
- It uses the shared `@acme/order-events` schema [library.order-events] [neo4j:passage-5].
- It depends on Orders, which in turn depends on Stripe [system.stripe] and is the only writer to Orders Postgres [datasource.orders-db].
- **Deployment:** It runs in production on the platform Kubernetes cluster in `eu-west-1` [environment.production] [neo4j:passage-6].
- **PCI constraint:** Card data never touches Acme systems [constraint.pci-scope]. Checkout collects card details through Stripe Elements, so they go from the browser straight to Stripe, which keeps Acme in PCI SAQ-A scope. Routing card details through any Acme service, including a proxy or a debug log of a payment payload, would break this [neo4j:passage-7].
**Caveats:**
- The relationship paths come from Neo4j's graph traversal, not from the records. Checkout's own links all carry only 0.5 confidence there [neo4j:path-1, neo4j:path-2, neo4j:path-3].
- The result is a top-matches sample, not every match.
- The search ran in scope "nm-e2e", not the "default" scope you asked in.
neo4j top matches only 0.51s
Neo4j full-text search (English analyzer) over titles, bodies, ids and tags in scope "nm-e2e", each match followed up to 2 hops through the graph
query: CALL db.index.fulltext.queryNodes('memory_text', $lucene) YIELD node AS seed, score
WHERE seed.scope = $scope AND seed.stub = false
WITH seed, score ORDER BY score DESC, seed.id LIMIT $matches
OPTIONAL MATCH p = (seed)-[*1..2]-(reached:Memory {scope: $scope})
WHERE reached <> seed
AND all(n IN nodes(p) WHERE n.scope = $scope)
AND all(r IN relationships(p) WHERE coalesce(r.confidence, 0.0) >= $minConfidence AND r.rel IN coalesce($rels, [r.rel]))
WITH seed, score, p,
reduce(c = 1.0, r IN relationships(p) | c * coalesce(r.confidence, 0.0)) / log(2 + COUNT { (reached)--() }) AS rank
ORDER BY score DESC, seed.id, rank DESC, length(p)
WITH seed, score, collect(CASE WHEN p IS NULL THEN null ELSE {
nodes: [n IN nodes(p) | n {.id, .title, .type, .hash, .stub}],
edges: [r IN relationships(p) | {source: startNode(r).id, rel: r.rel, target: endNode(r).id, confidence: r.confidence, sources: r.sources}]
} END)[0..$paths] AS paths
RETURN seed.id AS id, seed.title AS title, seed.content AS content, seed.hash AS hash, score, paths
ORDER BY score DESC, id
// $lucene: checkout
entities
service.checkout (2.005) service.checkout -guarded_by-> feature_flag.checkout-rewrite; service.checkout -depends_on-> servic…
feature_flag.checkout-rewrite (1.777) feature_flag.checkout-rewrite <-guarded_by- service.checkout <-applies_to- constraint.pci-scope; fe…
team.platform-engineering (0.628) team.platform-engineering <-owned_by- service.orders -depends_on-> system.stripe; team.platform-eng…
repository.acme-platform (0.567) repository.acme-platform -owned_by-> team.platform-engineering <-owned_by- service.orders; reposito…
library.order-events (0.547) library.order-events <-uses- service.orders -depends_on-> system.stripe; library.order-events <-use…
environment.production (0.534) environment.production <-deployed_to- service.orders -depends_on-> system.stripe; environment.produ…
constraint.pci-scope (0.401) constraint.pci-scope -applies_to-> system.stripe; constraint.pci-scope -applies_to-> service.orders…
graph - Paths from the matches
service.checkout -guarded_by-> feature_flag.checkout-rewrite [neo4j:path-1, neo4j:path-4, neo4j:path-5, neo4j:path-6]
service.checkout -depends_on-> service.orders [neo4j:path-2]
service.orders -depends_on-> system.stripe [neo4j:path-2, neo4j:path-7, neo4j:path-13, neo4j:path-16, neo4j:path-20]
constraint.pci-scope -applies_to-> service.checkout [neo4j:path-3, neo4j:path-4]
service.checkout -uses-> library.order-events [neo4j:path-5]
service.checkout -deployed_to-> environment.production [neo4j:path-6]
service.orders -owned_by-> team.platform-engineering [neo4j:path-7, neo4j:path-8, neo4j:path-9, neo4j:path-10]
service.orders -uses-> library.order-events [neo4j:path-8, neo4j:path-13, neo4j:path-14, neo4j:path-17, neo4j:path-21]
service.orders -depends_on-> datasource.orders-db [neo4j:path-9, neo4j:path-14, neo4j:path-18]
repository.acme-platform -owned_by-> team.platform-engineering [neo4j:path-10, neo4j:path-11, neo4j:path-12]
service.conversation-api -owned_by-> team.platform-engineering [neo4j:path-11]
datasource.orders-db -owned_by-> team.platform-engineering [neo4j:path-12]
agent.research-assistant -uses-> library.order-events [neo4j:path-15]
agent.research-assistant -owned_by-> team.research-platform [neo4j:path-15]
service.orders -deployed_to-> environment.production [neo4j:path-16, neo4j:path-17, neo4j:path-18]
constraint.pci-scope -applies_to-> system.stripe [neo4j:path-19]
constraint.pci-scope -applies_to-> service.orders [neo4j:path-20, neo4j:path-21]
path: service.checkout → feature_flag.checkout-rewrite
path: service.checkout → service.orders → system.stripe
path: service.checkout → constraint.pci-scope
path: feature_flag.checkout-rewrite → service.checkout → constraint.pci-scope
path: feature_flag.checkout-rewrite → service.checkout → library.order-events
path: feature_flag.checkout-rewrite → service.checkout → environment.production
path: team.platform-engineering → service.orders → system.stripe
path: team.platform-engineering → service.orders → library.order-events
path: team.platform-engineering → service.orders → datasource.orders-db
path: repository.acme-platform → team.platform-engineering → service.orders
path: repository.acme-platform → team.platform-engineering → service.conversation-api
path: repository.acme-platform → team.platform-engineering → datasource.orders-db
path: library.order-events → service.orders → system.stripe
path: library.order-events → service.orders → datasource.orders-db
path: library.order-events → agent.research-assistant → team.research-platform
path: environment.production → service.orders → system.stripe
path: environment.production → service.orders → library.order-events
path: environment.production → service.orders → datasource.orders-db
path: constraint.pci-scope → system.stripe
path: constraint.pci-scope → service.orders → system.stripe
path: constraint.pci-scope → service.orders → library.order-events
passages
[neo4j:passage-1] service.checkout: # Checkout The browser-facing checkout flow. Collects the cart and payment intent and hands a submission to Orders; it holds no order state of its own. The rew…
[neo4j:passage-2] feature_flag.checkout-rewrite: # Checkout rewrite Routes a percentage of sessions to the rewritten checkout flow. Currently at 10% of traffic in production. Temporary: the flag is removed on…
[neo4j:passage-3] team.platform-engineering: # Platform Engineering Owns the transactional path: Orders, Checkout, the Conversation API and the Postgres instances behind them. Carries the production pager…
[neo4j:passage-4] repository.acme-platform: # acme-platform The monorepo holding Orders, Checkout and the Conversation API, plus the shared `@acme/order-events` package. The Research Assistant lives in i…
[neo4j:passage-5] library.order-events: # Order Events The shared schema for order lifecycle events. Orders publishes them, Checkout and the Research Assistant consume them. It is a library rather th…
[neo4j:passage-6] environment.production: # Production Two distinct estates share the name: the platform Kubernetes cluster in `eu-west-1` where Orders, Checkout and the Conversation API run, and the D…
[neo4j:passage-7] constraint.pci-scope: # Card data never touches Acme systems No Acme service may receive, log or store a full card number. Checkout collects card details through Stripe Elements, so…
How they connect (canonical relationships)
constraint.pci-scope -applies_to-> service.orders
environment.production <-deployed_to- service.orders
library.order-events <-uses- service.orders
service.checkout <-applies_to- constraint.pci-scope
service.checkout -deployed_to-> environment.production
service.checkout -guarded_by-> feature_flag.checkout-rewrite
service.checkout -uses-> library.order-events
service.checkout -depends_on-> service.orders
service.checkout -owned_by-> team.platform-engineering
team.platform-engineering <-owned_by- library.order-events
team.platform-engineering <-owned_by- repository.acme-platform
team.platform-engineering <-owned_by- service.orders
environment.production <-deployed_to- service.checkout <-applies_to- constraint.pci-scope
feature_flag.checkout-rewrite <-guarded_by- service.checkout <-applies_to- constraint.pci-scope
feature_flag.checkout-rewrite <-guarded_by- service.checkout -deployed_to-> environment.production
feature_flag.checkout-rewrite <-guarded_by- service.checkout -uses-> library.order-events
feature_flag.checkout-rewrite <-guarded_by- service.checkout -depends_on-> service.orders
feature_flag.checkout-rewrite <-guarded_by- service.checkout -owned_by-> team.platform-engineering
library.order-events <-uses- agent.research-assistant -deployed_to-> environment.production
library.order-events <-uses- service.checkout <-applies_to- constraint.pci-scope
repository.acme-platform -owned_by-> team.platform-engineering <-owned_by- library.order-events
repository.acme-platform -owned_by-> team.platform-engineering <-owned_by- service.orders
service.checkout -owned_by-> team.platform-engineering <-owned_by- repository.acme-platform
team.platform-engineering <-owned_by- service.checkout <-applies_to- constraint.pci-scope
team.platform-engineering <-owned_by- service.checkout -deployed_to-> environment.production
feature_flag.checkout-rewrite <-guarded_by- service.checkout -owned_by-> team.platform-engineering <-owned_by- repository.acme-platform
repository.acme-platform -owned_by-> team.platform-engineering <-owned_by- service.checkout <-applies_to- constraint.pci-scope
repository.acme-platform -owned_by-> team.platform-engineering <-owned_by- service.checkout -deployed_to-> environment.production
Evidence: docket ask --json orders: graph block and derived-fact evidence
{
"requestId": "2e134af8-caf0-4937-b9b8-fdb1ab7e6678",
"question": "orders",
"context": {
"scope": "default",
"now": "2026-10-31T04:17:13.938Z",
"timezone": "Asia/Singapore",
"deadline": "2026-10-31T04:17:43.938Z"
},
"results": [
{
"adapter": "neo4j",
"state": "answered",
"answer": {
"interpretation": {
"description": "Neo4j full-text search (English analyzer) over titles, bodies, ids and tags in scope \"nm-e2e\", each match followed up to 2 hops through the graph",
"assumptions": [
"Matches are ranked by Lucene relevance: the best matches, not every match, and never a count.",
"Each match keeps its best 3 paths, ranked by the product of their relationships' confidence and damped where they end at a well-connected node. The nodes they reach are context, not matches."
],
"nativeQuery": "CALL db.index.fulltext.queryNodes('memory_text', $lucene) YIELD node AS seed, score\nWHERE seed.scope = $scope AND seed.stub = false\nWITH seed, score ORDER BY score DESC, seed.id LIMIT $matches\nOPTIONAL MATCH p = (seed)-[*1..2]-(reached:Memory {scope: $scope})\nWHERE reached <> seed\n AND all(n IN nodes(p) WHERE n.scope = $scope)\n AND all(r IN relationships(p) WHERE coalesce(r.confidence, 0.0) >= $minConfidence AND r.rel IN coalesce($rels, [r.rel]))\nWITH seed, score, p,\n reduce(c = 1.0, r IN relationships(p) | c * coalesce(r.confidence, 0.0)) / log(2 + COUNT { (reached)--() }) AS rank\nORDER BY score DESC, seed.id, rank DESC, length(p)\nWITH seed, score, collect(CASE WHEN p IS NULL THEN null ELSE {\n nodes: [n IN nodes(p) | n {.id, .title, .type, .hash, .stub}],\n edges: [r IN relationships(p) | {source: startNode(r).id, rel: r.rel, target: endNode(r).id, confidence: r.confidence, sources: r.sources}]\n } END)[0..$paths] AS paths\nRETURN seed.id AS id, seed.title AS title, seed.content AS content, seed.hash AS hash, score, paths\nORDER BY score DESC, id\n// $lucene: orders"
},
"blocks": [
{
"id": "neo4j:hits",
"evidenceIds": [
"neo4j:passage-1",
"neo4j:passage-2",
"neo4j:passage-3",
"neo4j:passage-4",
"neo4j:passage-5",
"neo4j:passage-6",
"neo4j:passage-7",
"neo4j:passage-8",
"neo4j:passage-9",
"neo4j:passage-10",
"neo4j:passage-11",
"neo4j:passage-12",
"neo4j:passage-13"
],
"kind": "entities",
"entities": [
{
"ref": {
"kind": "entity",
"id": "service.orders",
"revision": "sha256:f670981d71193db04ffc6ad36d1b5b81058d15f68b321843d1cfe03986720267"
},
"score": 1.212171196937561,
"detail": "service.orders -depends_on-> system.stripe; service.orders -uses-> library.order-events; service.orders -depends_on-> datasource.orders-db"
},
{
"ref": {
"kind": "entity",
"id": "datasource.orders-db",
"revision": "sha256:931cfb0b8fcad2a6195f1ed29e360aaa4f7236223d1e5b6524329e998bfd15bf"
},
"score": 1.0656180381774902,
"detail": "datasource.orders-db <-depends_on- service.orders -depends_on-> system.stripe; datasource.orders-db <-depends_on- service.orders -uses-> library.order-events; datasource.orders-db <-depends_on- service.orders -deployed_to-> environment.production"
},
{
"ref": {
"kind": "entity",
"id": "library.order-events",
"revision": "sha256:2782195f7ab1b12dffd760ad361085a0cc8a9fbfc55fe96bc129632d4149cc37"
},
"score": 1.043447494506836,
"detail": "library.order-events <-uses- service.orders -depends_on-> system.stripe; library.order-events <-uses- service.orders -depends_on-> datasource.orders-db; library.order-events <-uses- agent.research-assistant -owned_by-> team.research-platform"
},
{
"ref": {
"kind": "entity",
"id": "decision.orders-on-postgres",
"revision": "sha256:e8e4c2adcba25769b0ec11030ad85359afeb920308586c9d9f0446d7b1049f62"
},
"score": 0.8611199855804443,
"detail": "decision.orders-on-postgres -supersedes-> decision.orders-on-dynamodb; decision.orders-on-postgres -applies_to-> service.orders -depends_on-> system.stripe; decision.orders-on-postgres -applies_to-> datasource.orders-db"
},
{
"ref": {
"kind": "entity",
"id": "decision.orders-on-dynamodb",
"revision": "sha256:9ad917a3f2270e2c3494bbf71cdf2423f29496bb92701f2dacf405a3cacde8ca"
},
"score": 0.8457799553871155,
"detail": "decision.orders-on-dynamodb -applies_to-> service.orders -depends_on-> system.stripe; decision.orders-on-dynamodb <-supersedes- decision.orders-on-postgres; decision.orders-on-dynamodb -applies_to-> service.orders -uses-> library.order-events"
},
{
"ref": {
"kind": "entity",
"id": "repository.acme-platform",
"revision": "sha256:950513871a790168dd407ab88f325e88b5d161870b8e13155747d0967bbdec25"
},
"score": 0.33688002824783325,
"detail": "repository.acme-platform -owned_by-> team.platform-engineering <-owned_by- service.orders; repository.acme-platform -owned_by-> team.platform-engineering <-owned_by- service.conversation-api; repository.acme-platform -owned_by-> team.platform-engineering <-owned_by- datasource.orders-db"
},
{
"ref": {
"kind": "entity",
"id": "service.checkout",
"revision": "sha256:cdc164de91e221f80b0823b6c27bff267ca8f18a62a50278d8d191dbd79bb500"
},
"score": 0.3215426802635193,
"detail": "service.checkout -guarded_by-> feature_flag.checkout-rewrite; service.checkout -depends_on-> service.orders -depends_on-> system.stripe; service.checkout <-applies_to- constraint.pci-scope"
},
{
"ref": {
"kind": "entity",
"id": "constraint.eu-data-residency",
"revision": "sha256:7f729ce08c03cc8e457834711ca1bd5c67015c2650ad570d89560ee599d03596"
},
"score": 0.3191339373588562,
"detail": "constraint.eu-data-residency -applies_to-> datasource.sessions; constraint.eu-data-residency -applies_to-> environment.production <-deployed_to- service.orders; constraint.eu-data-residency -applies_to-> datasource.orders-db <-depends_on- service.orders"
},
{
"ref": {
"kind": "entity",
"id": "environment.staging",
"revision": "sha256:39e99e269a5029a16a952a18c93112aa16236e8a520afc0fcb3facf9e4597065"
},
"score": 0.288653701543808
},
{
"ref": {
"kind": "entity",
"id": "team.platform-engineering",
"revision": "sha256:911dfc773c6b5fbe333bc62c6e01a8b3603d40bafd9dd62bbcbb3994373fe662"
},
"score": 0.2847740650177002,
"detail": "team.platform-engineering <-owned_by- service.orders -depends_on-> system.stripe; team.platform-engineering <-owned_by- service.orders -uses-> library.order-events; team.platform-engineering <-owned_by- service.orders -depends_on-> datasource.orders-db"
},
{
"ref": {
"kind": "entity",
"id": "sy
... [81877 bytes truncated] ...
: false
}
]
},
{
"nodes": [
"decision.orders-on-postgres",
"decision.orders-on-dynamodb"
],
"steps": [
{
"rel": "supersedes",
"forward": true
}
]
},
{
"nodes": [
"library.order-events",
"service.checkout"
],
"steps": [
{
"rel": "uses",
"forward": false
}
]
},
{
"nodes": [
"service.orders",
"datasource.orders-db"
],
"steps": [
{
"rel": "depends_on",
"forward": true
}
]
},
{
"nodes": [
"service.orders",
"decision.orders-on-dynamodb"
],
"steps": [
{
"rel": "applies_to",
"forward": false
}
]
},
{
"nodes": [
"service.orders",
"decision.orders-on-postgres"
],
"steps": [
{
"rel": "applies_to",
"forward": false
}
]
},
{
"nodes": [
"service.orders",
"library.order-events"
],
"steps": [
{
"rel": "uses",
"forward": true
}
]
},
{
"nodes": [
"service.orders",
"service.checkout"
],
"steps": [
{
"rel": "depends_on",
"forward": false
}
]
},
{
"nodes": [
"datasource.orders-db",
"decision.orders-on-postgres",
"decision.orders-on-dynamodb"
],
"steps": [
{
"rel": "applies_to",
"forward": false
},
{
"rel": "supersedes",
"forward": true
}
]
},
{
"nodes": [
"datasource.orders-db",
"service.orders",
"library.order-events"
],
"steps": [
{
"rel": "depends_on",
"forward": false
},
{
"rel": "uses",
"forward": true
}
]
},
{
"nodes": [
"datasource.orders-db",
"service.orders",
"service.checkout"
],
"steps": [
{
"rel": "depends_on",
"forward": false
},
{
"rel": "depends_on",
"forward": false
}
]
},
{
"nodes": [
"datasource.orders-db",
"team.platform-engineering",
"repository.acme-platform"
],
"steps": [
{
"rel": "owned_by",
"forward": true
},
{
"rel": "owned_by",
"forward": false
}
]
},
{
"nodes": [
"decision.orders-on-dynamodb",
"service.orders",
"service.checkout"
],
"steps": [
{
"rel": "applies_to",
"forward": true
},
{
"rel": "depends_on",
"forward": false
}
]
},
{
"nodes": [
"decision.orders-on-postgres",
"datasource.orders-db",
"constraint.eu-data-residency"
],
"steps": [
{
"rel": "applies_to",
"forward": true
},
{
"rel": "applies_to",
"forward": false
}
]
},
{
"nodes": [
"decision.orders-on-postgres",
"service.orders",
"service.checkout"
],
"steps": [
{
"rel": "applies_to",
"forward": true
},
{
"rel": "depends_on",
"forward": false
}
]
},
{
"nodes": [
"library.order-events",
"service.orders",
"decision.orders-on-dynamodb"
],
"steps": [
{
"rel": "uses",
"forward": false
},
{
"rel": "applies_to",
"forward": false
}
]
},
{
"nodes": [
"library.order-events",
"service.orders",
"decision.orders-on-postgres"
],
"steps": [
{
"rel": "uses",
"forward": false
},
{
"rel": "applies_to",
"forward": false
}
]
},
{
"nodes": [
"library.order-events",
"team.platform-engineering",
"repository.acme-platform"
],
"steps": [
{
"rel": "owned_by",
"forward": true
},
{
"rel": "owned_by",
"forward": false
}
]
},
{
"nodes": [
"repository.acme-platform",
"team.platform-engineering",
"service.checkout"
],
"steps": [
{
"rel": "owned_by",
"forward": true
},
{
"rel": "owned_by",
"forward": false
}
]
},
{
"nodes": [
"service.checkout",
"environment.production",
"constraint.eu-data-residency"
],
"steps": [
{
"rel": "deployed_to",
"forward": true
},
{
"rel": "applies_to",
"forward": false
}
]
},
{
"nodes": [
"service.orders",
"datasource.orders-db",
"constraint.eu-data-residency"
],
"steps": [
{
"rel": "depends_on",
"forward": true
},
{
"rel": "applies_to",
"forward": false
}
]
},
{
"nodes": [
"service.orders",
"team.platform-engineering",
"repository.acme-platform"
],
"steps": [
{
"rel": "owned_by",
"forward": true
},
{
"rel": "owned_by",
"forward": false
}
]
},
{
"nodes": [
"decision.orders-on-dynamodb",
"decision.orders-on-postgres",
"datasource.orders-db",
"constraint.eu-data-residency"
],
"steps": [
{
"rel": "supersedes",
"forward": false
},
{
"rel": "applies_to",
"forward": true
},
{
"rel": "applies_to",
"forward": false
}
]
},
{
"nodes": [
"decision.orders-on-dynamodb",
"service.orders",
"team.platform-engineering",
"repository.acme-platform"
],
"steps": [
{
"rel": "applies_to",
"forward": true
},
{
"rel": "owned_by",
"forward": true
},
{
"rel": "owned_by",
"forward": false
}
]
},
{
"nodes": [
"decision.orders-on-postgres",
"datasource.orders-db",
"team.platform-engineering",
"repository.acme-platform"
],
"steps": [
{
"rel": "applies_to",
"forward": true
},
{
"rel": "owned_by",
"forward": true
},
{
"rel": "owned_by",
"forward": false
}
]
},
{
"nodes": [
"library.order-events",
"agent.research-assistant",
"datasource.sessions",
"constraint.eu-data-residency"
],
"steps": [
{
"rel": "uses",
"forward": false
},
{
"rel": "uses",
"forward": true
},
{
"rel": "applies_to",
"forward": false
}
]
},
{
"nodes": [
"repository.acme-platform",
"team.platform-engineering",
"datasource.orders-db",
"constraint.eu-data-residency"
],
"steps": [
{
"rel": "owned_by",
"forward": true
},
{
"rel": "owned_by",
"forward": false
},
{
"rel": "applies_to",
"forward": false
}
]
}
],
"diagnostics": [],
"notice": {
"reason": "disabled",
"message": "No summary was asked for: the results stand on their own."
}
}
- Evidence: Query count per search (ask): one bounded executeRead (local file:
~/.no-mistakes/evidence/01M68231VHX6Q5D9WWHCCTTHST/round-trips-ask.txt) - Evidence: Query count per search (search) (local file:
~/.no-mistakes/evidence/01M68231VHX6Q5D9WWHCCTTHST/round-trips-search.txt) - Evidence: traverse knobs (hops/rels/minConfidence/hops:4 rejected) (local file:
~/.no-mistakes/evidence/01M68231VHX6Q5D9WWHCCTTHST/traverse-knobs.txt)
Evidence: Deadline-bounded transaction timeout
query.timeoutMs: 3000 -> [executeRead timeout=2608]
\### v2 config with query.timeoutMs: 3000 (docket ask)
query:
timeoutMs: 3000
[executeRead timeout=2608]
neo4j top matches only, cut short 0.45s
- Evidence: Scope isolation with forced cross-scope edge (local file:
~/.no-mistakes/evidence/01M68231VHX6Q5D9WWHCCTTHST/scope-isolation.txt)


Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
⚠️ **Review** - 2 infos
⚠️ packages/adapter-neo4j/src/neo4j-projection.ts:254- The new single subgraph query goes throughrun()(executeQuerywith no transaction timeout), andanswer()gives it no deadline. The query lists every path of up tohopsrelationships from each seed (OPTIONAL MATCH p = (seed)-[*1..hops]-(reached…), subgraph-search.ts:52) and works outCOUNT { (reached)--() }for each one. Only after that does it sort and cut each seed's paths to$paths(6). Example: withtraverse.hops: 3, or with 2 hops where a seed sits next to a hub of degree ~1000, one seed produces about degree^hops paths, and maxResults seeds multiply that. The old N+1 neighbour queries were each one hop and cheap. The model-written Cypher path in the same method is bounded byMath.min(remaining, CYPHER_TIMEOUT_MS), but this default path can now run pastrequest.budget.deadline. Smallest fix: pass a transaction timeout taken from the request deadline (or CYPHER_TIMEOUT_MS forsearch()) to this query, the waycypherPort().readalready does. The same unbounded call is reached fromsearch()viasubgraphHits(neo4j-projection.ts:179,189).- ℹ️
packages/adapter-neo4j/src/subgraph-search.test.ts:46- The intent asks that "a unit test must check every node pattern carries $scope". The test runsscopeProblem, which accepts patterns anchored to a scoped node. So(seed)and the anonymous()inCOUNT { (reached)--() }(subgraph-search.ts:52,56) do not carry $scope themselves. The guard documents that this is safe because the projection never links nodes across scopes, and upsert MERGEs both ends under the same scope, so no node outside the scope can be read. This is noted only as a gap between the literal wording and the implementation; nothing needs to change.
🔧 Fix applied.
2 issues (1 error, 1 info) still open:
- ℹ️
packages/adapter-neo4j/src/subgraph-search.test.ts:46- The intent asks that "a unit test must check every node pattern carries $scope". The test runsscopeProblem, which accepts patterns anchored to a scoped node. So(seed)and the anonymous()inCOUNT { (reached)--() }(subgraph-search.ts:52,56) do not carry $scope themselves. The guard documents that this is safe because the projection never links nodes across scopes, and upsert MERGEs both ends under the same scope, so no node outside the scope can be read. This is noted only as a gap between the literal wording and the implementation; nothing needs to change. - 🚨
wheelhouse/docket/dist/bundled/adapter-neo4j/dist/neo4j-projection.js:184- The round-1 fix commit 1c375e9 changed onlypackages/adapter-neo4j/src/neo4j-projection.ts. It did not rebuild the committed plugin bundle. The bundledneo4j-projection.jsstill has the version from before the fix:subgraphSearch(query, limit)with no timeout, going throughrun()/executeQuery(lines 162, 176, 184), and the inlinecypherPort().read(lines 204-208). This causes two problems. (1) Wheelhouse installs the plugin from the committed files with no build step (wheelhouse/build.mjs header), so plugin users would get the subgraph query with no time limit, which is the exact problem the round-1 fix was meant to close. (2)pnpm -r testruns wheelhouse'stestscript (node build.mjs --check && vitest run), and that check fails when the committed files differ from what the sources build, so CI goes red. Fix: rebuild the workspace, then runpnpm buildin wheelhouse/ and commit the regeneratedwheelhouse/docket/dist/bundled/adapter-neo4j/dist/neo4j-projection.js.
🔧 Fix applied.
2 infos still open:
- ℹ️
packages/adapter-neo4j/src/subgraph-search.test.ts:46- The intent asks that "a unit test must check every node pattern carries $scope". The test runsscopeProblem, which accepts patterns anchored to a scoped node. So(seed)and the anonymous()inCOUNT { (reached)--() }(subgraph-search.ts:52,56) do not carry $scope themselves. The guard documents that this is safe because the projection never links nodes across scopes, and upsert MERGEs both ends under the same scope, so no node outside the scope can be read. This is noted only as a gap between the literal wording and the implementation; nothing needs to change. - ℹ️
packages/adapter-neo4j/src/subgraph-search.test.ts:46- The intent says "a unit test must check every node pattern carries $scope".(seed)and the anonymous()inCOUNT { (reached)--() }are anchored to nodes that are already scoped, and the projection never links nodes across scopes. This gap between the wording and the code was recorded earlier and the user chose to ignore it; nothing needs to change.
✅ **Test** - passed
✅ No issues found.
- Live validation: ✅ go - 7 of 7 scenarios driven live against the product
| Scenario | Result | Live | Evidence |
|---|---|---|---|
| Integration tests in the repo run against a real local Neo4j via NEO4J_TEST_URL and pass (including subgraph answer, traverse reach, confidence ranking with hub damping) | ✅ pass | live | integration-tests.log |
User runs docket ask with no model against a Neo4j projection and gets entities (seeds + Lucene scores), a graph block (nodes, directed edges citing evidence, ordered paths), passages, and one deriv… |
✅ pass | live | cli-ask-neo4j-checkout.txt, cli-ask-neo4j-orders.json |
| A search is one round trip: one read query per search instead of 1 + N | ✅ pass | live | round-trips-ask.txt, round-trips-search.txt |
| traverse.hops / rels / minConfidence change the answer; hops over 3 is rejected | ✅ pass | live | traverse-knobs.txt |
| Adversarial: the subgraph read cannot run past the request deadline | ✅ pass | live | deadline-bound.txt |
| Adversarial: another scope's nodes never appear, even through a hand-made cross-scope relationship | ✅ pass | live | scope-isolation.txt |
| Neo4j graph block is visible in the docket UI Ask page | ✅ pass | live | ui-ask-neo4j-checkout.png, ui-neo4j-graph-block.png |
docker run -d --rm -p 27787:7687 -e NEO4J_AUTH=none neo4j:5.26-community(disposable server, removed afterwards)pnpm build(root) so the CLI's bundled adapter matches HEADNEO4J_TEST_URL=bolt://127.0.0.1:27787 npx vitest run src/neo4j-projection.integration.test.ts src/subgraph-search.test.tsin packages/adapter-neo4j (33/33 against real Neo4j)docket syncthendocket ask --adapter neo4j checkout/--json orderson a temp copy of examples/acme-platform with a neo4j projectionnode -r count-queries.cjs docket ask|searchwith the neo4j-driver Session/Transaction run and executeRead calls patched to log each query and its timeoutdocket askwith traverse {hops:1}, {rels:[depends_on]}, {minConfidence:0.9} and {hops:4}v2.docket.yamlwithquery.timeoutMs: 3000: executeRead timeout observedSecond scopenm-otherholding a doc that mentions checkout, plus a hand-made Cypher relationship from nm-e2e service.checkout to it; asked nm-e2e twice, and asked nm-other as a controldocket openAsk page in Playwright: asked neo4j 'checkout' and screenshotted the result and its graph block
✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.
Reading plan
No AI: sorted by path rules and change size, so it’s free and instant. The guided review refines it.
Read first 2 stops, 4 files
1Changes in subgraph-search.ts
The largest source change here, read with 2 related files from the same folder.
packages/adapter-neo4j/src/subgraph-search.tsadded+278 −0
| @@ -0,0 +1,278 @@ | ||
| 1 | +import { questionTerms, relevantExcerpt, searchAnswer, type SearchHit } from '@docket/adapter-kit' | |
| 2 | +import type { | |
| 3 | + AdapterAnswer, | |
| 4 | + AskRequest, | |
| 5 | + CanonicalReference, | |
| 6 | + GraphBlock, | |
| 7 | + ResultBlock, | |
| 8 | + RetrievedEvidence | |
| 9 | +} from '@docket/contracts' | |
| 10 | + | |
| 11 | +import { MAX_HOPS } from './config.js' | |
| 12 | + | |
| 13 | +/* | |
| 14 | + * Search without a model: one query finds the full-text matches and, from | |
| 15 | + * each, the best few paths through the graph, ranked by how far their | |
| 16 | + * relationships are corroborated. The matches are the answer's entities, the | |
| 17 | + * paths its graph, and each path a derived fact saying why it holds. | |
| 18 | + */ | |
| 19 | + | |
| 20 | +export const FULLTEXT_INDEX = 'memory_text' | |
| 21 | +/** The paths an answer keeps from each match. */ | |
| 22 | +export const PATHS_PER_MATCH = 3 | |
| 23 | +/** The most of a matched node's body an answer quotes as its passage. */ | |
| 24 | +const PASSAGE_CHARS = 600 | |
| 25 | + | |
| 26 | +/** How far a search reaches from each match, from the `traverse` config. */ | |
| 27 | +export interface Traversal { | |
| 28 | + hops: number | |
| 29 | + minConfidence: number | |
| 30 | + /** Only relationships with one of these `rel` names; every one when unset. */ | |
| 31 | + rels?: readonly string[] | |
| 32 | +} | |
| 33 | + | |
| 34 | +export const DEFAULT_TRAVERSAL: Traversal = { hops: 2, minConfidence: 0 } | |
| 35 | + | |
| 36 | +/** | |
| 37 | + * The one query a search runs: full-text matches in scope, best first, and | |
| 38 | + * for each the paths of up to `hops` relationships to other nodes in scope, | |
| 39 | + * in either direction. A path ranks by the product of its relationships' | |
| 40 | + * confidence, divided by the log of its end's degree so the best-connected | |
| 41 | + * nodes do not end every path. Each match brings twice the paths it keeps, | |
| 42 | + * so dropping those another path extends still leaves enough. | |
| 43 | + * | |
| 44 | + * The hop count is a validated integer, the only value written into the | |
| 45 | + * query: Cypher takes no parameter for a path's length. | |
| 46 | + */ | |
| 47 | +export const subgraphQuery = (hops: number): string => { | |
| 48 | + if (!Number.isInteger(hops) || hops < 1 || hops > MAX_HOPS) { | |
| 49 | + throw new Error(`A search reaches 1 to ${MAX_HOPS} hops, not ${hops}`) | |
| 50 | + } | |
| 51 | + return `CALL db.index.fulltext.queryNodes('${FULLTEXT_INDEX}', $lucene) YIELD node AS seed, score | |
| 52 | +WHERE seed.scope = $scope AND seed.stub = false | |
| 53 | +WITH seed, score ORDER BY score DESC, seed.id LIMIT $matches | |
| 54 | +OPTIONAL MATCH p = (seed)-[*1..${hops}]-(reached:Memory {scope: $scope}) | |
| 55 | +WHERE reached <> seed | |
| 56 | + AND all(n IN nodes(p) WHERE n.scope = $scope) | |
| 57 | + AND all(r IN relationships(p) WHERE coalesce(r.confidence, 0.0) >= $minConfidence AND r.rel IN coalesce($rels, [r.rel])) | |
| 58 | +WITH seed, score, p, | |
| 59 | + reduce(c = 1.0, r IN relationships(p) | c * coalesce(r.confidence, 0.0)) / log(2 + COUNT { (reached)--() }) AS rank | |
| 60 | +ORDER BY score DESC, seed.id, rank DESC, length(p) | |
| 61 | +WITH seed, score, collect(CASE WHEN p IS NULL THEN null ELSE { | |
| 62 | + nodes: [n IN nodes(p) | n {.id, .title, .type, .hash, .stub}], | |
| 63 | + edges: [r IN relationships(p) | {source: startNode(r).id, rel: r.rel, target: endNode(r).id, confidence: r.confidence, sources: r.sources}] | |
| 64 | + } END)[0..$paths] AS paths | |
| 65 | +RETURN seed.id AS id, seed.title AS title, seed.content AS content, seed.hash AS hash, score, paths | |
| 66 | +ORDER BY score DESC, id` | |
| 67 | +} | |
| 68 | + | |
| 69 | +export interface PathNode { | |
| 70 | + id: string | |
| 71 | + title?: string | |
| 72 | + type?: string | |
| 73 | + /** The revision it was projected at; unset on a stub. */ | |
| 74 | + hash?: string | |
| 75 | + stub: boolean | |
| 76 | +} | |
| 77 | + | |
| 78 | +/** A relationship in its stored direction. */ | |
| 79 | +export interface PathEdge { | |
| 80 | + source: string | |
| 81 | + rel: string | |
| 82 | + target: string | |
| 83 | + confidence?: number | |
| 84 | + sources: string[] | |
| 85 | +} | |
| 86 | + | |
| 87 | +/** Nodes from the match outwards, and the relationship between each pair. */ | |
| 88 | +export interface MatchPath { | |
| 89 | + nodes: PathNode[] | |
| 90 | + edges: PathEdge[] | |
| 91 | +} | |
| 92 | + | |
| 93 | +/** A full-text match and the paths from it, best first. */ | |
| 94 | +export interface Match { | |
| 95 | + id: string | |
| 96 | + title?: string | |
| 97 | + content?: string | |
| 98 | + hash?: string | |
| 99 | + score: number | |
| 100 | + paths: MatchPath[] | |
| 101 | +} | |
| 102 | + | |
| 103 | +const text = (value: unknown): string | undefined => (typeof value === 'string' ? value : undefined) | |
| 104 | + | |
| 105 | +const pathNode = (value: Record<string, unknown>): PathNode => { | |
| 106 | + const [title, type, hash] = [text(value.title), text(value.type), text(value.hash)] | |
| 107 | + return { | |
| 108 | + id: String(value.id), | |
| 109 | + ...(title !== undefined ? { title } : {}), | |
| 110 | + ...(type !== undefined ? { type } : {}), | |
| 111 | + ...(hash !== undefined ? { hash } : {}), | |
| 112 | + stub: value.stub === true | |
| 113 | + } | |
| 114 | +} | |
| 115 | + | |
| 116 | +const pathEdge = (value: Record<string, unknown>): PathEdge => ({ | |
| 117 | + source: String(value.source), | |
| 118 | + rel: String(value.rel), | |
| 119 | + target: String(value.target), | |
| 120 | + ...(typeof value.confidence === 'number' && value.confidence >= 0 && value.confidence <= 1 ? { confidence: value.confidence } : {}), | |
| 121 | + sources: Array.isArray(value.sources) ? value.sources.filter((source): source is string => typeof source === 'string') : [] | |
| 122 | +}) | |
| 123 | + | |
| 124 | +const extendsPath = (longer: MatchPath, shorter: MatchPath): boolean => | |
| 125 | + longer.nodes.length > shorter.nodes.length && shorter.nodes.every((node, index) => longer.nodes[index]?.id === node.id) | |
| 126 | + | |
| 127 | +/** | |
| 128 | + * The query's rows as matches, each keeping its best `PATHS_PER_MATCH` | |
| 129 | + * paths. A path another one extends says nothing that one does not, so it | |
| 130 | + * goes; so does one that visits a node twice. | |
| 131 | + */ | |
| 132 | +export const matchesOf = (rows: readonly Record<string, unknown>[]): Match[] => | |
| 133 | + rows.map((row) => { | |
| 134 | + const all = (Array.isArray(row.paths) ? row.paths : []).map((value: Record<string, unknown>) => ({ | |
| 135 | + nodes: (value.nodes as Record<string, unknown>[]).map(pathNode), | |
| 136 | + edges: (value.edges as Record<string, unknown>[]).map(pathEdge) | |
| 137 | + })) | |
| 138 | + const simple = all.filter((path) => new Set(path.nodes.map((node) => node.id)).size === path.nodes.length) | |
| 139 | + const paths = simple.filter((path) => !simple.some((other) => extendsPath(other, path))).slice(0, PATHS_PER_MATCH) | |
| 140 | + const [title, content, hash] = [text(row.title), text(row.content), text(row.hash)] | |
| 141 | + return { | |
| 142 | + id: String(row.id), | |
| 143 | + ...(title !== undefined ? { title } : {}), | |
| 144 | + ...(content !== undefined ? { content } : {}), | |
| 145 | + ...(hash !== undefined ? { hash } : {}), | |
| 146 | + score: Number(row.score), | |
| 147 | + paths | |
| 148 | + } | |
| 149 | + }) | |
| 150 | + | |
| 151 | +const round = (value: number): string => String(Math.round(value * 100) / 100) | |
| 152 | + | |
| 153 | +/** | |
| 154 | + * A path step by step from the match, each relationship drawn in its stored | |
| 155 | + * direction: `a -depends_on-> b <-owned_by- c`. With `why`, each step also | |
| 156 | + * says how far it is corroborated and from which sources: `(0.95, code+manifest)`. | |
| 157 | + */ | |
| 158 | +export const describePath = (path: MatchPath, why = false): string => | |
| 159 | + path.nodes | |
| 160 | + .map((node, index) => { | |
| 161 | + const edge = path.edges[index - 1] | |
| 162 | + if (index === 0 || edge === undefined) return node.id | |
| 163 | + const arrow = edge.target === node.id ? `-${edge.rel}->` : `<-${edge.rel}-` | |
| 164 | + const support = [ | |
| 165 | + ...(edge.confidence !== undefined ? [round(edge.confidence)] : []), | |
| 166 | + ...(edge.sources.length > 0 ? [edge.sources.join('+')] : []) | |
| 167 | + ] | |
| 168 | + return ` ${arrow} ${node.id}${why && support.length > 0 ? ` (${support.join(', ')})` : ''}` | |
| 169 | + }) | |
| 170 | + .join('') | |
| 171 | + | |
| 172 | +/** A match as a search hit: its score, its paths as the detail, and the part of its body the question names. */ | |
| 173 | +export const hitOf = (match: Match, terms: readonly string[]): SearchHit => { | |
| 174 | + const detail = match.paths.map((path) => describePath(path)).join('; ') | |
| 175 | + const body = match.content !== undefined && match.content.trim() !== '' ? match.content : (match.title ?? '') | |
| 176 | + const passage = relevantExcerpt(body, [...terms], PASSAGE_CHARS).text | |
| 177 | + return { | |
| 178 | + id: match.id, | |
| 179 | + score: match.score, | |
| 180 | + ...(detail ? { detail } : {}), | |
| 181 | + ...(match.hash !== undefined ? { revision: match.hash } : {}), | |
| 182 | + ...(passage ? { passage: { text: passage } } : {}) | |
| 183 | + } | |
| 184 | +} | |
| 185 | + | |
| 186 | +const bytes = (value: string): number => Buffer.byteLength(value, 'utf8') | |
| 187 | + | |
| 188 | +const refOf = (node: PathNode): CanonicalReference | undefined => | |
| 189 | + node.stub ? undefined : { kind: 'entity', id: node.id, ...(node.hash !== undefined ? { revision: node.hash } : {}) } | |
| 190 | + | |
| 191 | +/** | |
| 192 | + * The matches as an answer: an entities block of the matches with their | |
| 193 | + * scores, a graph block of their paths - every relationship in its stored | |
| 194 | + * direction with its confidence, citing the paths it lies on - and a passages | |
| 195 | + * block of what each match says. Each path is a derived fact of its own, the | |
| 196 | + * why its relationships hold, within the evidence budget. The nodes a path | |
| 197 | + * reaches are context, never matches. Ranked, so a top-k. | |
| 198 | + */ | |
| 199 | +export const subgraphAnswer = ( | |
| 200 | + matches: readonly Match[], | |
| 201 | + request: Pick<AskRequest, 'question' | 'context' | 'budget'>, | |
| 202 | + interpretation: Partial<AdapterAnswer['interpretation']> | |
| 203 | +): AdapterAnswer => { | |
| 204 | + const terms = questionTerms(request.question) | |
| 205 | + const answer = searchAnswer({ hits: matches.map((match) => hitOf(match, terms)) }, request, interpretation) | |
| 206 | + const shown = matches.slice(0, request.budget.maxResults) | |
| 207 | + | |
| 208 | + const evidence: RetrievedEvidence[] = [] | |
| 209 | + let used = answer.evidence.reduce((total, item) => total + bytes(item.text), 0) | |
| 210 | + let truncated = answer.coverage.truncated | |
| 211 | + const nodes = new Map<string, GraphBlock['nodes'][number]>() | |
| 212 | + const edges = new Map<string, GraphBlock['edges'][number]>() | |
| 213 | + const paths: string[][] = [] | |
| 214 | + for (const path of shown.flatMap((match) => match.paths)) { | |
| 215 | + const why = describePath(path, true) | |
| 216 | + let evidenceId: string | undefined | |
| 217 | + if (used + bytes(why) <= request.budget.maxEvidenceBytes) { | |
| 218 | + used += bytes(why) | |
| 219 | + evidenceId = `path-${evidence.length + 1}` | |
| 220 | + const refs = new Map(path.nodes.flatMap((node) => { | |
| 221 | + const ref = refOf(node) | |
| 222 | + return ref ? [[node.id, ref] as const] : [] | |
| 223 | + })) | |
| 224 | + evidence.push({ id: evidenceId, kind: 'derived-fact', text: why, canonicalRefs: [...refs.values()], derivation: { engine: 'neo4j' } }) | |
| 225 | + } else { | |
| 226 | + truncated = true | |
| 227 | + } | |
| 228 | + | |
| 229 | + for (const node of path.nodes) { | |
| 230 | + if (nodes.has(node.id)) continue | |
| 231 | + const ref = refOf(node) | |
| 232 | + nodes.set(node.id, { | |
| 233 | + id: node.id, | |
| 234 | + label: node.title !== undefined && node.title !== '' ? node.title : node.id, | |
| 235 | + ...(node.type !== undefined ? { type: node.type } : {}), | |
| 236 | + ...(ref ? { ref } : {}) | |
| 237 | + }) | |
| 238 | + } | |
| 239 | + for (const edge of path.edges) { | |
| 240 | + const key = `${edge.source}\u0000${edge.rel}\u0000${edge.target}` | |
| 241 | + const known = edges.get(key) | |
| 242 | + if (known) { | |
| 243 | + if (evidenceId !== undefined) known.evidenceIds = [...(known.evidenceIds ?? []), evidenceId] | |
| 244 | + continue | |
| 245 | + } | |
| 246 | + edges.set(key, { | |
| 247 | + source: edge.source, | |
| 248 | + target: edge.target, | |
| 249 | + rel: edge.rel, | |
| 250 | + ...(edge.confidence !== undefined ? { confidence: edge.confidence } : {}), | |
| 251 | + ...(evidenceId !== undefined ? { evidenceIds: [evidenceId] } : {}) | |
| 252 | + }) | |
| 253 | + } | |
| 254 | + paths.push(path.nodes.map((node) => node.id)) | |
| 255 | + } | |
| 256 | + | |
| 257 | + const graph: ResultBlock[] = | |
| 258 | + edges.size > 0 | |
| 259 | + ? [ | |
| 260 | + { | |
| 261 | + kind: 'graph', | |
| 262 | + id: 'paths', | |
| 263 | + title: 'Paths from the matches', | |
| 264 | + evidenceIds: evidence.map((item) => item.id), | |
| 265 | + nodes: [...nodes.values()], | |
| 266 | + edges: [...edges.values()], | |
| 267 | + paths | |
| 268 | + } | |
| 269 | + ] | |
| 270 | + : [] | |
| 271 | + const [entities, ...rest] = answer.blocks | |
| 272 | + return { | |
| 273 | + ...answer, | |
| 274 | + blocks: entities ? [entities, ...graph, ...rest] : graph, | |
| 275 | + evidence: [...answer.evidence, ...evidence], | |
| 276 | + coverage: { ...answer.coverage, truncated } | |
| 277 | + } | |
| 278 | +} | |
packages/adapter-neo4j/src/neo4j-projection.ts+85 −82
| @@ -13,8 +13,6 @@ import { | ||
| 13 | 13 | checkoutScope, |
| 14 | 14 | ollamaChat, |
| 15 | 15 | questionTerms, |
| 16 | - relevantExcerpt, | |
| 17 | - searchAnswer, | |
| 18 | 16 | type Chat, |
| 19 | 17 | type EntityProjection, |
| 20 | 18 | type ProjectionContext, |
| @@ -25,23 +23,23 @@ import { | ||
| 25 | 23 | import type { Neo4jConfig } from './config.js' |
| 26 | 24 | import type { GraphSchema } from './cypher-prompt.js' |
| 27 | 25 | import { cypherAnswer, cypherSearch, type CypherPort } from './cypher-search.js' |
| 26 | +import { fulltextQuery, labelFor, NODE_PROPERTIES, nodeProperties, relationshipRows } from './graph-model.js' | |
| 28 | 27 | import { |
| 29 | - describeNeighbours, | |
| 30 | - fulltextQuery, | |
| 31 | - labelFor, | |
| 32 | - NODE_PROPERTIES, | |
| 33 | - nodeProperties, | |
| 34 | - relationshipRows, | |
| 35 | - type Neighbour | |
| 36 | -} from './graph-model.js' | |
| 37 | - | |
| 38 | -const FULLTEXT_INDEX = 'memory_text' | |
| 28 | + DEFAULT_TRAVERSAL, | |
| 29 | + FULLTEXT_INDEX, | |
| 30 | + hitOf, | |
| 31 | + matchesOf, | |
| 32 | + PATHS_PER_MATCH, | |
| 33 | + subgraphAnswer, | |
| 34 | + subgraphQuery, | |
| 35 | + type Match, | |
| 36 | + type Traversal | |
| 37 | +} from './subgraph-search.js' | |
| 38 | + | |
| 39 | 39 | /** How long a generated query may run before it is abandoned. */ |
| 40 | 40 | const CYPHER_TIMEOUT_MS = 10_000 |
| 41 | 41 | /** Lucene's English analyzer: drops stop words and stems, so `ordering` finds `orders`. */ |
| 42 | 42 | const FULLTEXT_ANALYZER = 'english' |
| 43 | -/** The most of a matched node's body a full-text answer quotes as its passage. */ | |
| 44 | -const PASSAGE_CHARS = 600 | |
| 45 | 43 | |
| 46 | 44 | /** |
| 47 | 45 | * neo4j-driver is an optional dependency: only repositories that configure a |
| @@ -83,12 +81,14 @@ class Neo4jProjection implements EntityProjection { | ||
| 83 | 81 | private scope = '' |
| 84 | 82 | /** Writes Cypher from a question; absent unless `cypher` is configured. */ |
| 85 | 83 | private readonly chat: Chat | undefined |
| 84 | + private readonly traversal: Traversal | |
| 86 | 85 | |
| 87 | 86 | constructor( |
| 88 | 87 | private readonly config: Neo4jConfig, |
| 89 | 88 | dependencies: Neo4jProjectionDependencies = {} |
| 90 | 89 | ) { |
| 91 | 90 | this.chat = dependencies.chat ?? (config.cypher ? ollamaChat(config.cypher) : undefined) |
| 91 | + this.traversal = { ...DEFAULT_TRAVERSAL, ...config.traverse } | |
| 92 | 92 | } |
| 93 | 93 | |
| 94 | 94 | async init(context: ProjectionContext): Promise<void> { |
| @@ -172,11 +172,11 @@ class Neo4jProjection implements EntityProjection { | ||
| 172 | 172 | /** |
| 173 | 173 | * With `cypher` configured, the model writes a query from the question and |
| 174 | 174 | * its rows are the answer, with the query in the note. When it fails or finds |
| 175 | - * nothing - or without `cypher` - the answer is full-text search, and the | |
| 176 | - * note says why. | |
| 175 | + * nothing - or without `cypher` - the answer is full-text matches, each with | |
| 176 | + * its paths as the detail, and the note says why. | |
| 177 | 177 | */ |
| 178 | 178 | async search(query: string, limit: number): Promise<SearchAnswer> { |
| 179 | - if (!this.chat) return { hits: await this.fulltextSearch(query, limit) } | |
| 179 | + if (!this.chat) return { hits: await this.subgraphHits(query, limit, CYPHER_TIMEOUT_MS) } | |
| 180 | 180 | |
| 181 | 181 | let reason: string |
| 182 | 182 | try { |
| @@ -186,7 +186,7 @@ class Neo4jProjection implements EntityProjection { | ||
| 186 | 186 | } catch (cause) { |
| 187 | 187 | reason = `cypher failed: ${cause instanceof Error ? cause.message : String(cause)}` |
| 188 | 188 | } |
| 189 | - return { hits: await this.fulltextSearch(query, limit), note: `${reason}\nfell back to full-text` } | |
| 189 | + return { hits: await this.subgraphHits(query, limit, CYPHER_TIMEOUT_MS), note: `${reason}\nfell back to full-text` } | |
| 190 | 190 | } |
| 191 | 191 | |
| 192 | 192 | /** |
| @@ -194,12 +194,16 @@ class Neo4jProjection implements EntityProjection { | ||
| 194 | 194 | * the rows are the answer, kept as the values they are: a count is a |
| 195 | 195 | * metric, a list a typed table, relationships a graph (docs/adapter-spec.md |
| 196 | 196 | * §9, §15 step 5). When it fails or finds nothing - or without `cypher` - |
| 197 | - * the answer is full-text search, saying why. | |
| 197 | + * the answer is the full-text matches and the paths from them, saying why. | |
| 198 | 198 | */ |
| 199 | 199 | async answer(request: AskRequest): Promise<AdapterAnswer> { |
| 200 | - const remaining = Date.parse(request.budget.deadline) - Date.now() | |
| 201 | - if (!(remaining > 0)) throw new Error('neo4j adapter: the question\'s deadline has already passed') | |
| 202 | - const timeoutMs = Math.min(remaining, CYPHER_TIMEOUT_MS) | |
| 200 | + const deadline = Date.parse(request.budget.deadline) | |
| 201 | + const timeLeft = (): number => { | |
| 202 | + const remaining = deadline - Date.now() | |
| 203 | + if (!(remaining > 0)) throw new Error('neo4j adapter: the question\'s deadline has already passed') | |
| 204 | + return Math.min(remaining, CYPHER_TIMEOUT_MS) | |
| 205 | + } | |
| 206 | + const timeoutMs = timeLeft() | |
| 203 | 207 | const diagnostics: Diagnostic[] = [] |
| 204 | 208 | if (this.chat) { |
| 205 | 209 | let reason: string |
| @@ -213,50 +217,58 @@ class Neo4jProjection implements EntityProjection { | ||
| 213 | 217 | } catch (cause) { |
| 214 | 218 | reason = `The Cypher query failed: ${cause instanceof Error ? cause.message : String(cause)}` |
| 215 | 219 | } |
| 216 | - diagnostics.push({ severity: 'info', code: 'cypher-fallback', message: `${reason}\nAnswered with full-text search instead.` }) | |
| 220 | + diagnostics.push({ severity: 'info', code: 'cypher-fallback', message: `${reason}\nAnswered with full-text matches and the paths from them instead.` }) | |
| 217 | 221 | } |
| 218 | - const answer = searchAnswer({ hits: await this.fulltextSearch(request.question, request.budget.maxResults) }, request, { | |
| 219 | - description: `Neo4j full-text search (English analyzer) over titles, bodies, ids and tags in scope "${this.scope}"`, | |
| 220 | - assumptions: ['Ranked by Lucene relevance: the best matches, not every match, and never a count.'], | |
| 221 | - nativeQuery: fulltextQuery(request.question) | |
| 222 | + const { hops, minConfidence, rels } = this.traversal | |
| 223 | + const reach = [ | |
| 224 | + `up to ${hops} hop${hops === 1 ? '' : 's'}`, | |
| 225 | + ...(minConfidence > 0 ? [`of confidence at least ${minConfidence}`] : []), | |
| 226 | + ...(rels ? [`along ${rels.join(', ')}`] : []) | |
| 227 | + ].join(' ') | |
| 228 | + const matches = await this.subgraphSearch(request.question, request.budget.maxResults, timeLeft()) | |
| 229 | + const answer = subgraphAnswer(matches, request, { | |
| 230 | + description: | |
| 231 | + `Neo4j full-text search (English analyzer) over titles, bodies, ids and tags in scope "${this.scope}", ` + | |
| 232 | + `each match followed ${reach} through the graph`, | |
| 233 | + assumptions: [ | |
| 234 | + 'Matches are ranked by Lucene relevance: the best matches, not every match, and never a count.', | |
| 235 | + `Each match keeps its best ${PATHS_PER_MATCH} paths, ranked by the product of their relationships' confidence ` + | |
| 236 | + 'and damped where they end at a well-connected node. The nodes they reach are context, not matches.' | |
| 237 | + ], | |
| 238 | + nativeQuery: `${subgraphQuery(hops)}\n// $lucene: ${fulltextQuery(request.question)}` | |
| 222 | 239 | }) |
| 223 | 240 | return { ...answer, diagnostics: [...diagnostics, ...answer.diagnostics] } |
| 224 | 241 | } |
| 225 | 242 | |
| 243 | + private async subgraphHits(query: string, limit: number, timeoutMs: number): Promise<SearchHit[]> { | |
| 244 | + const terms = questionTerms(query) | |
| 245 | + return (await this.subgraphSearch(query, limit, timeoutMs)).map((match) => hitOf(match, terms)) | |
| 246 | + } | |
| 247 | + | |
| 226 | 248 | /** |
| 227 | - * Full-text matches, each with its links in `detail` - the neighbourhood is | |
| 228 | - * what a graph adds to a search - the part of its body that matched, and the | |
| 229 | - * revision it was projected at. Linked documents are not answers of their | |
| 230 | - * own: the best-connected ones would otherwise come back for every query. | |
| 249 | + * The full-text matches for `query` and the best paths from each, in one | |
| 250 | + * query (subgraph-search.ts). The documents a path reaches are not matches | |
| 251 | + * of their own: the best-connected ones would otherwise come back for every | |
| 252 | + * query. | |
| 231 | 253 | */ |
| 232 | - private async fulltextSearch(query: string, limit: number): Promise<SearchHit[]> { | |
| 254 | + private async subgraphSearch(query: string, limit: number, timeoutMs: number): Promise<Match[]> { | |
| 233 | 255 | const lucene = fulltextQuery(query) |
| 234 | 256 | if (!lucene) return [] |
| 235 | - | |
| 236 | - const matches = await this.run( | |
| 237 | - `CALL db.index.fulltext.queryNodes('${FULLTEXT_INDEX}', $lucene) YIELD node, score | |
| 238 | - WHERE node.scope = $scope AND node.stub = false | |
| 239 | - RETURN node.id AS id, node.hash AS hash, node.title AS title, node.content AS content, score | |
| 240 | - ORDER BY score DESC, id LIMIT $limit`, | |
| 241 | - { lucene, scope: this.scope, limit: this.requireNeo4j().int(limit) } | |
| 257 | + const neo4j = this.requireNeo4j() | |
| 258 | + const { hops, minConfidence, rels } = this.traversal | |
| 259 | + const records = await this.read( | |
| 260 | + subgraphQuery(hops), | |
| 261 | + { | |
| 262 | + lucene, | |
| 263 | + scope: this.scope, | |
| 264 | + matches: neo4j.int(limit), | |
| 265 | + paths: neo4j.int(PATHS_PER_MATCH * 2), | |
| 266 | + minConfidence, | |
| 267 | + rels: rels ? [...rels] : null | |
| 268 | + }, | |
| 269 | + timeoutMs | |
| 242 | 270 | ) |
| 243 | - | |
| 244 | - const terms = questionTerms(query) | |
| 245 | - const hits: SearchHit[] = [] | |
| 246 | - for (const match of matches) { | |
| 247 | - const id = match.id as string | |
| 248 | - const detail = describeNeighbours(await this.neighbours(id)) | |
| 249 | - const body = typeof match.content === 'string' && match.content.trim() !== '' ? match.content : String(match.title ?? '') | |
| 250 | - const passage = relevantExcerpt(body, terms, PASSAGE_CHARS).text | |
| 251 | - hits.push({ | |
| 252 | - id, | |
| 253 | - score: match.score as number, | |
| 254 | - ...(detail ? { detail } : {}), | |
| 255 | - ...(typeof match.hash === 'string' ? { revision: match.hash } : {}), | |
| 256 | - ...(passage ? { passage: { text: passage } } : {}) | |
| 257 | - }) | |
| 258 | - } | |
| 259 | - return hits | |
| 271 | + return matchesOf(this.rowsOf(records)) | |
| 260 | 272 | } |
| 261 | 273 | |
| 262 | 274 | private cypherPort(): CypherPort { |
| @@ -264,17 +276,7 @@ class Neo4jProjection implements EntityProjection { | ||
| 264 | 276 | return { |
| 265 | 277 | scope: this.scope, |
| 266 | 278 | schema: () => this.schema(), |
| 267 | - read: async (cypher, timeoutMs = CYPHER_TIMEOUT_MS) => { | |
| 268 | - const session = this.requireDriver().session(this.sessionConfig()) | |
| 269 | - try { | |
| 270 | - const result = await session.executeRead((tx) => tx.run(cypher, { scope: this.scope }), { | |
| 271 | - timeout: timeoutMs | |
| 272 | - }) | |
| 273 | - return result.records as Neo4jRecord[] | |
| 274 | - } finally { | |
| 275 | - await session.close() | |
| 276 | - } | |
| 277 | - }, | |
| 279 | + read: (cypher, timeoutMs = CYPHER_TIMEOUT_MS) => this.read(cypher, { scope: this.scope }, timeoutMs), | |
| 278 | 280 | documents: async (ids) => { |
| 279 | 281 | if (ids.length === 0) return new Map() |
| 280 | 282 | const rows = await this.run( |
| @@ -338,20 +340,6 @@ class Neo4jProjection implements EntityProjection { | ||
| 338 | 340 | this.driver = null |
| 339 | 341 | } |
| 340 | 342 | |
| 341 | - private async neighbours(id: string): Promise<Neighbour[]> { | |
| 342 | - const rows = await this.run( | |
| 343 | - `MATCH (n:Memory {scope: $scope, id: $id})-[r]-(m:Memory {scope: $scope}) | |
| 344 | - RETURN startNode(r) = n AS out, r.rel AS rel, m.id AS id | |
| 345 | - ORDER BY out DESC, rel, id`, | |
| 346 | - { scope: this.scope, id } | |
| 347 | - ) | |
| 348 | - return rows.map((row) => ({ | |
| 349 | - direction: row.out ? 'out' : 'in', | |
| 350 | - rel: row.rel as string, | |
| 351 | - id: row.id as string | |
| 352 | - })) | |
| 353 | - } | |
| 354 | - | |
| 355 | 343 | /** Swaps the node's type label for `label`, or just removes it when `label` is null. */ |
| 356 | 344 | private async replaceTypeLabel(tx: ManagedTransaction, id: string, label: string | null): Promise<void> { |
| 357 | 345 | const result = await tx.run('MATCH (n:Memory {scope: $scope, id: $id}) RETURN labels(n) AS labels', { |
| @@ -396,9 +384,24 @@ class Neo4jProjection implements EntityProjection { | ||
| 396 | 384 | } |
| 397 | 385 | } |
| 398 | 386 | |
| 387 | + /** Runs `cypher` in a read transaction the server abandons after `timeoutMs`. */ | |
| 388 | + private async read(cypher: string, parameters: Record<string, unknown>, timeoutMs: number): Promise<Neo4jRecord[]> { | |
| 389 | + const session = this.requireDriver().session(this.sessionConfig()) | |
| 390 | + try { | |
| 391 | + const result = await session.executeRead((tx) => tx.run(cypher, parameters), { timeout: timeoutMs }) | |
| 392 | + return result.records as Neo4jRecord[] | |
| 393 | + } finally { | |
| 394 | + await session.close() | |
| 395 | + } | |
| 396 | + } | |
| 397 | + | |
| 399 | 398 | private async run(cypher: string, parameters: Record<string, unknown> = {}): Promise<Record<string, unknown>[]> { |
| 400 | 399 | const result = await this.requireDriver().executeQuery(cypher, parameters, this.sessionConfig()) |
| 401 | - return result.records.map((record) => { | |
| 400 | + return this.rowsOf(result.records) | |
| 401 | + } | |
| 402 | + | |
| 403 | + private rowsOf(records: readonly Neo4jRecord[]): Record<string, unknown>[] { | |
| 404 | + return records.map((record) => { | |
| 402 | 405 | const row = record.toObject() as Record<string, unknown> |
| 403 | 406 | return Object.fromEntries( |
| 404 | 407 | Object.entries(row).map(([key, value]) => [key, this.requireNeo4j().isInt(value) ? Number(value) : value]) |
packages/adapter-neo4j/src/config.ts+18 −2
| @@ -1,6 +1,9 @@ | ||
| 1 | 1 | import { ollamaModelSchema } from '@docket/adapter-kit' |
| 2 | 2 | import { z } from 'zod' |
| 3 | 3 | |
| 4 | +/** The furthest a search expands from a match: past three hops, most graphs reach everything. */ | |
| 5 | +export const MAX_HOPS = 3 | |
| 6 | + | |
| 4 | 7 | /** |
| 5 | 8 | * A Neo4j graph of the documents and their links. The password is read from the |
| 6 | 9 | * environment, never from the file; with `passwordEnv` unset the driver |
| @@ -30,9 +33,22 @@ export const neo4jConfigSchema = z.preprocess( | ||
| 30 | 33 | scope: z.string().min(1).optional(), |
| 31 | 34 | /** |
| 32 | 35 | * Answer searches by having a local Ollama model write a read-only Cypher |
| 33 | - * query against the graph's schema. Without it, search is full-text only. | |
| 36 | + * query against the graph's schema. Without it - or when its query fails | |
| 37 | + * or finds nothing - search is full-text matches and the paths from them. | |
| 38 | + */ | |
| 39 | + cypher: z.strictObject(ollamaModelSchema.shape).optional(), | |
| 40 | + /** | |
| 41 | + * How far a search reaches from each full-text match: up to `hops` | |
| 42 | + * relationships (default 2), each of confidence at least `minConfidence` | |
| 43 | + * (default 0), and of the `rels` named when set. Every field is optional. | |
| 34 | 44 | */ |
| 35 | - cypher: z.strictObject(ollamaModelSchema.shape).optional() | |
| 45 | + traverse: z | |
| 46 | + .strictObject({ | |
| 47 | + hops: z.number().int().min(1).max(MAX_HOPS).optional(), | |
| 48 | + minConfidence: z.number().min(0).max(1).optional(), | |
| 49 | + rels: z.array(z.string().min(1)).min(1).optional() | |
| 50 | + }) | |
| 51 | + .optional() | |
| 36 | 52 | }) |
| 37 | 53 | ) |
| 38 | 54 | |
2Changes in graph-model.ts
One of the largest source changes in the pull request.
packages/adapter-neo4j/src/graph-model.ts+0 −12
| @@ -119,15 +119,3 @@ export const fulltextQuery = (text: string): string => | ||
| 119 | 119 | .map((word) => word.replace(/["()?]/g, '').replace(LUCENE_SPECIAL, (c) => `\\${c}`)) |
| 120 | 120 | .filter((word) => word.length > 0 && word !== '\\') |
| 121 | 121 | .join(' OR ') |
| 122 | - | |
| 123 | -export interface Neighbour { | |
| 124 | - direction: 'out' | 'in' | |
| 125 | - rel: string | |
| 126 | - id: string | |
| 127 | -} | |
| 128 | - | |
| 129 | -/** A node's links, as a line a reader can scan: `depends_on → x; ← uses y`. */ | |
| 130 | -export const describeNeighbours = (neighbours: readonly Neighbour[]): string => | |
| 131 | - neighbours | |
| 132 | - .map((n) => (n.direction === 'out' ? `${n.rel} → ${n.id}` : `← ${n.rel} ${n.id}`)) | |
| 133 | - .join('; ') | |
Skim 8 files
Small source changes 1
packages/adapter-neo4j/src/index.ts+3 −2
| @@ -7,8 +7,9 @@ export { neo4jConfigSchema, type Neo4jConfig } from './config.js' | ||
| 7 | 7 | export { createNeo4jProjection, type Neo4jProjectionDependencies } from './neo4j-projection.js' |
| 8 | 8 | |
| 9 | 9 | /** |
| 10 | - * The Neo4j adapter: entities as typed nodes and relationships, searched | |
| 11 | - * full-text or through generated read-only Cypher. `neo4j-driver` is loaded | |
| 10 | + * The Neo4j adapter: entities as typed nodes and relationships, searched by | |
| 11 | + * full-text matches and the paths from them, or through generated read-only | |
| 12 | + * Cypher. `neo4j-driver` is loaded | |
| 12 | 13 | * when an instance is created, so importing this module needs no driver. |
| 13 | 14 | */ |
| 14 | 15 | const definition = entityProjectionDefinition<Neo4jConfig>({ |
Tests 4
packages/adapter-neo4j/src/graph-model.test.ts+0 −11
| @@ -4,7 +4,6 @@ import { entityInput, entityLink } from '@docket/adapter-kit/testing' | ||
| 4 | 4 | |
| 5 | 5 | import { |
| 6 | 6 | NODE_PROPERTIES, |
| 7 | - describeNeighbours, | |
| 8 | 7 | fulltextQuery, |
| 9 | 8 | labelFor, |
| 10 | 9 | nodeProperties, |
| @@ -36,16 +35,6 @@ describe('graph model', () => { | ||
| 36 | 35 | expect(fulltextQuery(' ')).toBe('') |
| 37 | 36 | }) |
| 38 | 37 | |
| 39 | - it('describes a node by its links in both directions', () => { | |
| 40 | - expect( | |
| 41 | - describeNeighbours([ | |
| 42 | - { direction: 'out', rel: 'depends_on', id: 'datasource.postgres' }, | |
| 43 | - { direction: 'in', rel: 'uses', id: 'service.web' } | |
| 44 | - ]) | |
| 45 | - ).toBe('depends_on → datasource.postgres; ← uses service.web') | |
| 46 | - expect(describeNeighbours([])).toBe('') | |
| 47 | - }) | |
| 48 | - | |
| 49 | 38 | describe('merged entities', () => { |
| 50 | 39 | const inManifest = { source: 'manifest', path: 'package.json', key: 'dependencies.pg' } |
| 51 | 40 | const inCode = { source: 'code', path: 'src/db.ts', lines: '12' } |
packages/adapter-neo4j/src/index.test.ts+12 −0
| @@ -40,6 +40,18 @@ describe('neo4j adapter', () => { | ||
| 40 | 40 | expect(() => neo4j.validateConfig({ cypher: { model: 'm', modle: 'n' } })).toThrow(/Unrecognized key: "modle"/) |
| 41 | 41 | }) |
| 42 | 42 | |
| 43 | + it('takes how far a search reaches from each match, within bounds', () => { | |
| 44 | + expect(neo4j.validateConfig({ traverse: { hops: 3, minConfidence: 0.5, rels: ['depends_on'] } })).toMatchObject({ | |
| 45 | + traverse: { hops: 3, minConfidence: 0.5, rels: ['depends_on'] } | |
| 46 | + }) | |
| 47 | + expect(neo4j.validateConfig({ traverse: {} })).toMatchObject({ traverse: {} }) | |
| 48 | + expect(() => neo4j.validateConfig({ traverse: { hops: 4 } })).toThrow() | |
| 49 | + expect(() => neo4j.validateConfig({ traverse: { hops: 1.5 } })).toThrow() | |
| 50 | + expect(() => neo4j.validateConfig({ traverse: { minConfidence: 2 } })).toThrow() | |
| 51 | + expect(() => neo4j.validateConfig({ traverse: { rels: [] } })).toThrow() | |
| 52 | + expect(() => neo4j.validateConfig({ traverse: { depth: 2 } })).toThrow(/Unrecognized key: "depth"/) | |
| 53 | + }) | |
| 54 | + | |
| 43 | 55 | it('names itself and its contract major', () => { |
| 44 | 56 | expect(neo4j).toMatchObject({ apiVersion: 1, name: 'neo4j' }) |
| 45 | 57 | }) |
packages/adapter-neo4j/src/neo4j-projection.integration.test.ts+123 −8
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import neo4j, { type Driver } from 'neo4j-driver' |
| 2 | 2 | import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' |
| 3 | 3 | |
| 4 | -import type { EntityInput } from '@docket/contracts' | |
| 4 | +import { validateAdapterAnswer, type AskRequest, type EntityInput, type GraphBlock } from '@docket/contracts' | |
| 5 | 5 | import type { EntityProjection } from '@docket/adapter-kit' |
| 6 | 6 | import { entityInput, entityLink } from '@docket/adapter-kit/testing' |
| 7 | 7 | |
| @@ -9,10 +9,15 @@ import type { Neo4jConfig } from './config.js' | ||
| 9 | 9 | import { createNeo4jProjection } from './neo4j-projection.js' |
| 10 | 10 | |
| 11 | 11 | /** |
| 12 | - * Runs against a real Neo4j: `NEO4J_TEST_URL=bolt://localhost:7687`, auth off. | |
| 13 | - * Skipped without it. Every test works in its own scope and clears it after. | |
| 12 | + * Runs against a real Neo4j: `NEO4J_TEST_URL=bolt://127.0.0.1:27687`, with | |
| 13 | + * the password in `NEO4J_PASSWORD` or auth off without it (see the README). | |
| 14 | + * Skipped without a URL. Every test works in its own scope and clears it after. | |
| 14 | 15 | */ |
| 15 | 16 | const URL = process.env.NEO4J_TEST_URL |
| 17 | +const PASSWORD_ENV = 'NEO4J_PASSWORD' | |
| 18 | +const PASSWORD = process.env[PASSWORD_ENV] | |
| 19 | +/** How the projection is told to log in: as the server expects, from the same variable. */ | |
| 20 | +const AUTH = PASSWORD ? { passwordEnv: PASSWORD_ENV } : {} | |
| 16 | 21 | |
| 17 | 22 | const CONTEXT = { projectRoot: '/repos/acme' } |
| 18 | 23 | |
| @@ -43,6 +48,7 @@ describe.skipIf(!URL)('neo4j projection', () => { | ||
| 43 | 48 | url: URL ?? '', |
| 44 | 49 | username: 'neo4j', |
| 45 | 50 | scope, |
| 51 | + ...AUTH, | |
| 46 | 52 | ...overrides |
| 47 | 53 | }) |
| 48 | 54 | await created.init?.(CONTEXT) |
| @@ -70,8 +76,7 @@ describe.skipIf(!URL)('neo4j projection', () => { | ||
| 70 | 76 | ) |
| 71 | 77 | |
| 72 | 78 | beforeAll(() => { |
| 73 | - // No token, as the projection connects to a NEO4J_AUTH=none server. | |
| 74 | - driver = neo4j.driver(URL ?? '') | |
| 79 | + driver = PASSWORD ? neo4j.driver(URL ?? '', neo4j.auth.basic('neo4j', PASSWORD)) : neo4j.driver(URL ?? '') | |
| 75 | 80 | }) |
| 76 | 81 | |
| 77 | 82 | afterAll(async () => { |
| @@ -236,14 +241,14 @@ describe.skipIf(!URL)('neo4j projection', () => { | ||
| 236 | 241 | }) |
| 237 | 242 | ) |
| 238 | 243 | |
| 239 | - // Only text matches are answers. Their links travel in `detail`, rather than | |
| 244 | + // Only text matches are answers. The paths from them travel in `detail`, rather than | |
| 240 | 245 | // as hits of their own - well-linked documents would otherwise answer everything. |
| 241 | 246 | expect(await projection.search?.('orders', 10)).toEqual({ |
| 242 | 247 | hits: [ |
| 243 | 248 | { |
| 244 | 249 | id: 'service.api', |
| 245 | 250 | score: expect.any(Number), |
| 246 | - detail: 'depends_on → datasource.postgres; ← uses service.web', | |
| 251 | + detail: 'service.api -depends_on-> datasource.postgres; service.api <-uses- service.web', | |
| 247 | 252 | revision: 'sha256:api', |
| 248 | 253 | passage: { text: 'Takes orders and stores them.' } |
| 249 | 254 | } |
| @@ -259,6 +264,116 @@ describe.skipIf(!URL)('neo4j projection', () => { | ||
| 259 | 264 | expect((await projection.search?.('ordering', 10))?.hits.map((hit) => hit.id)).toEqual(['service.api']) |
| 260 | 265 | }) |
| 261 | 266 | |
| 267 | + describe('without a model', () => { | |
| 268 | + const ask = (question: string): AskRequest => { | |
| 269 | + const now = new Date() | |
| 270 | + return { | |
| 271 | + requestId: 'native', | |
| 272 | + question, | |
| 273 | + context: { scope: 'default', now: now.toISOString(), timezone: 'UTC' }, | |
| 274 | + budget: { maxResults: 10, maxEvidenceBytes: 10_000, deadline: new Date(now.getTime() + 30_000).toISOString() } | |
| 275 | + } | |
| 276 | + } | |
| 277 | + | |
| 278 | + const graphOf = (answer: { blocks: readonly { kind: string }[] } | undefined) => | |
| 279 | + answer?.blocks.find((block) => block.kind === 'graph') as GraphBlock | undefined | |
| 280 | + | |
| 281 | + /** The scout report's case: orders depends on ledger, which depends on a database no file describes. */ | |
| 282 | + const payments = async (graph: EntityProjection) => { | |
| 283 | + await graph.upsert( | |
| 284 | + makeDocument({ | |
| 285 | + id: 'service.orders', | |
| 286 | + title: 'Orders', | |
| 287 | + content: 'Takes orders and records them.\n', | |
| 288 | + revision: 'sha256:orders', | |
| 289 | + links: [ | |
| 290 | + entityLink({ rel: 'depends_on', target: 'service.ledger', confidence: 0.95, sources: ['code', 'manifest'] }), | |
| 291 | + entityLink({ rel: 'owned_by', target: 'team.payments', confidence: 0.6 }) | |
| 292 | + ] | |
| 293 | + }) | |
| 294 | + ) | |
| 295 | + await graph.upsert( | |
| 296 | + makeDocument({ | |
| 297 | + id: 'service.ledger', | |
| 298 | + title: 'Ledger', | |
| 299 | + content: 'Double-entry bookkeeping.\n', | |
| 300 | + revision: 'sha256:ledger', | |
| 301 | + links: [entityLink({ rel: 'depends_on', target: 'datasource.ledger-db', confidence: 0.9, sources: ['runtime'] })] | |
| 302 | + }) | |
| 303 | + ) | |
| 304 | + await graph.upsert(makeDocument({ id: 'team.payments', type: 'team', title: 'Payments', content: 'Runs checkout.\n', revision: 'sha256:payments', links: [] })) | |
| 305 | + } | |
| 306 | + | |
| 307 | + it('answers with the matches and the paths from them', async () => { | |
| 308 | + await fresh() | |
| 309 | + await payments(projection) | |
| 310 | + const request = ask('what do orders rely on?') | |
| 311 | + | |
| 312 | + const answer = await projection.answer?.(request) | |
| 313 | + | |
| 314 | + expect(answer && validateAdapterAnswer(answer, request)).toEqual(answer) | |
| 315 | + expect(answer?.blocks.map((block) => block.kind)).toEqual(['entities', 'graph', 'passages']) | |
| 316 | + // Only the text match is an entity; what its paths reach is context. | |
| 317 | + expect(answer?.blocks[0]).toMatchObject({ entities: [{ ref: { kind: 'entity', id: 'service.orders', revision: 'sha256:orders' } }] }) | |
| 318 | + const graph = graphOf(answer) | |
| 319 | + expect(graph?.paths).toEqual([ | |
| 320 | + ['service.orders', 'service.ledger', 'datasource.ledger-db'], | |
| 321 | + ['service.orders', 'team.payments'] | |
| 322 | + ]) | |
| 323 | + expect(graph?.edges).toEqual([ | |
| 324 | + { source: 'service.orders', target: 'service.ledger', rel: 'depends_on', confidence: 0.95, evidenceIds: ['path-1'] }, | |
| 325 | + { source: 'service.ledger', target: 'datasource.ledger-db', rel: 'depends_on', confidence: 0.9, evidenceIds: ['path-1'] }, | |
| 326 | + { source: 'service.orders', target: 'team.payments', rel: 'owned_by', confidence: 0.6, evidenceIds: ['path-2'] } | |
| 327 | + ]) | |
| 328 | + expect(graph?.nodes).toContainEqual({ id: 'datasource.ledger-db', label: 'datasource.ledger-db' }) | |
| 329 | + expect(answer?.evidence.find((item) => item.id === 'path-1')).toMatchObject({ | |
| 330 | + kind: 'derived-fact', | |
| 331 | + text: 'service.orders -depends_on-> service.ledger (0.95, code+manifest) -depends_on-> datasource.ledger-db (0.9, runtime)' | |
| 332 | + }) | |
| 333 | + expect(answer?.interpretation.nativeQuery).toContain("db.index.fulltext.queryNodes('memory_text', $lucene)") | |
| 334 | + expect(answer?.coverage).toEqual({ mode: 'top-k', truncated: false, scope: 'default' }) | |
| 335 | + }) | |
| 336 | + | |
| 337 | + it('reaches as far as traverse allows', async () => { | |
| 338 | + await fresh() | |
| 339 | + await payments(projection) | |
| 340 | + const pathsWith = async (traverse: Neo4jConfig['traverse']) => | |
| 341 | + graphOf(await (await open({ traverse })).answer?.(ask('orders')))?.paths | |
| 342 | + | |
| 343 | + expect(await pathsWith({ hops: 1 })).toEqual([ | |
| 344 | + ['service.orders', 'service.ledger'], | |
| 345 | + ['service.orders', 'team.payments'] | |
| 346 | + ]) | |
| 347 | + expect(await pathsWith({ minConfidence: 0.7 })).toEqual([['service.orders', 'service.ledger', 'datasource.ledger-db']]) | |
| 348 | + expect(await pathsWith({ rels: ['owned_by'] })).toEqual([['service.orders', 'team.payments']]) | |
| 349 | + }) | |
| 350 | + | |
| 351 | + it('ranks paths by confidence, damped where they end at a well-connected node', async () => { | |
| 352 | + await fresh() | |
| 353 | + await projection.upsert( | |
| 354 | + makeDocument({ | |
| 355 | + id: 'service.orders', | |
| 356 | + title: 'Orders', | |
| 357 | + content: 'Takes orders.\n', | |
| 358 | + links: [ | |
| 359 | + entityLink({ rel: 'uses', target: 'platform.hub', confidence: 0.9 }), | |
| 360 | + entityLink({ rel: 'uses', target: 'library.shaky', confidence: 0.3 }), | |
| 361 | + entityLink({ rel: 'uses', target: 'library.leaf', confidence: 0.9 }) | |
| 362 | + ] | |
| 363 | + }) | |
| 364 | + ) | |
| 365 | + for (const n of [1, 2, 3, 4, 5]) { | |
| 366 | + await projection.upsert( | |
| 367 | + makeDocument({ id: `service.x${n}`, title: `X${n}`, content: 'Elsewhere.\n', links: [entityLink({ rel: 'uses', target: 'platform.hub' })] }) | |
| 368 | + ) | |
| 369 | + } | |
| 370 | + | |
| 371 | + const graph = graphOf(await (await open({ traverse: { hops: 1 } })).answer?.(ask('orders'))) | |
| 372 | + | |
| 373 | + expect(graph?.paths?.map((path) => path.at(-1))).toEqual(['library.leaf', 'platform.hub', 'library.shaky']) | |
| 374 | + }) | |
| 375 | + }) | |
| 376 | + | |
| 262 | 377 | describe('with a model writing Cypher', () => { |
| 263 | 378 | /** Stands in for the model: answers every prompt with `cypher`, and keeps the prompts. */ |
| 264 | 379 | const model = (cypher: string) => { |
| @@ -276,7 +391,7 @@ describe.skipIf(!URL)('neo4j projection', () => { | ||
| 276 | 391 | scope = `test-${Math.random().toString(36).slice(2)}` |
| 277 | 392 | const fake = model(cypher) |
| 278 | 393 | const created = createNeo4jProjection( |
| 279 | - { type: 'neo4j', url: URL ?? '', username: 'neo4j', scope }, | |
| 394 | + { type: 'neo4j', url: URL ?? '', username: 'neo4j', scope, ...AUTH }, | |
| 280 | 395 | { chat: fake.chat } |
| 281 | 396 | ) |
| 282 | 397 | await created.init?.(CONTEXT) |
packages/adapter-neo4j/src/subgraph-search.test.tsadded+193 −0
| @@ -0,0 +1,193 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest' | |
| 2 | + | |
| 3 | +import { validateAdapterAnswer, type AskRequest, type GraphBlock } from '@docket/contracts' | |
| 4 | + | |
| 5 | +import { scopeProblem } from './cypher-guard.js' | |
| 6 | +import { describePath, matchesOf, subgraphAnswer, subgraphQuery, type MatchPath } from './subgraph-search.js' | |
| 7 | + | |
| 8 | +const SCOPE = 'payments' | |
| 9 | + | |
| 10 | +const request = (maxResults = 10, maxEvidenceBytes = 100_000): Pick<AskRequest, 'question' | 'context' | 'budget'> => ({ | |
| 11 | + question: 'what do orders depend on?', | |
| 12 | + context: { scope: SCOPE, now: '2026-10-31T00:00:00.000Z', timezone: 'UTC' }, | |
| 13 | + budget: { maxResults, maxEvidenceBytes, deadline: '2026-10-31T00:01:00.000Z' } | |
| 14 | +}) | |
| 15 | + | |
| 16 | +/** A node as the query projects it. */ | |
| 17 | +const node = (id: string, extra: Record<string, unknown> = {}) => ({ id, title: id, type: id.split('.')[0], hash: `sha256:${id}`, stub: false, ...extra }) | |
| 18 | +const edge = (source: string, rel: string, target: string, confidence = 0.9, sources = ['code']) => ({ source, rel, target, confidence, sources }) | |
| 19 | + | |
| 20 | +/** The scout report's case: orders depends on ledger, which depends on ledger-db; payments owns orders. */ | |
| 21 | +const ordersRow = () => ({ | |
| 22 | + id: 'service.orders', | |
| 23 | + title: 'Orders', | |
| 24 | + content: 'Takes orders and stores them in the ledger.\n', | |
| 25 | + hash: 'sha256:service.orders', | |
| 26 | + score: 2.5, | |
| 27 | + paths: [ | |
| 28 | + { | |
| 29 | + nodes: [node('service.orders'), node('service.ledger')], | |
| 30 | + edges: [edge('service.orders', 'depends_on', 'service.ledger', 0.95, ['code', 'manifest'])] | |
| 31 | + }, | |
| 32 | + { | |
| 33 | + nodes: [node('service.orders'), node('service.ledger'), node('datasource.ledger-db', { stub: true, hash: null, title: null, type: null })], | |
| 34 | + edges: [edge('service.orders', 'depends_on', 'service.ledger', 0.95, ['code', 'manifest']), edge('service.ledger', 'depends_on', 'datasource.ledger-db', 0.9, ['runtime'])] | |
| 35 | + }, | |
| 36 | + { | |
| 37 | + nodes: [node('service.orders'), node('team.payments')], | |
| 38 | + edges: [edge('team.payments', 'owns', 'service.orders', 0.6, [])] | |
| 39 | + } | |
| 40 | + ] | |
| 41 | +}) | |
| 42 | + | |
| 43 | +describe('subgraphQuery', () => { | |
| 44 | + it('keeps every node it reads in scope, as a model-written query must', () => { | |
| 45 | + for (const hops of [1, 2, 3]) expect(scopeProblem(subgraphQuery(hops))).toBeUndefined() | |
| 46 | + }) | |
| 47 | + | |
| 48 | + it('writes only a whole number of hops into the query', () => { | |
| 49 | + expect(subgraphQuery(2)).toContain('[*1..2]') | |
| 50 | + for (const hops of [0, 4, 1.5, Number.NaN]) expect(() => subgraphQuery(hops)).toThrow(/1 to 3 hops/) | |
| 51 | + }) | |
| 52 | +}) | |
| 53 | + | |
| 54 | +describe('matchesOf', () => { | |
| 55 | + it('drops a path another one extends, and keeps stubs as stubs', () => { | |
| 56 | + const [orders] = matchesOf([ordersRow()]) | |
| 57 | + | |
| 58 | + expect(orders?.paths.map((path) => path.nodes.map((n) => n.id))).toEqual([ | |
| 59 | + ['service.orders', 'service.ledger', 'datasource.ledger-db'], | |
| 60 | + ['service.orders', 'team.payments'] | |
| 61 | + ]) | |
| 62 | + expect(orders?.paths[0]?.nodes[2]).toEqual({ id: 'datasource.ledger-db', stub: true }) | |
| 63 | + }) | |
| 64 | + | |
| 65 | + it('keeps the best three paths, none visiting a node twice', () => { | |
| 66 | + const leaf = (id: string) => ({ nodes: [node('service.orders'), node(id)], edges: [edge('service.orders', 'uses', id)] }) | |
| 67 | + const loop = { | |
| 68 | + nodes: [node('service.orders'), node('service.a'), node('service.orders')], | |
| 69 | + edges: [edge('service.orders', 'uses', 'service.a'), edge('service.a', 'calls', 'service.orders')] | |
| 70 | + } | |
| 71 | + const [orders] = matchesOf([{ ...ordersRow(), paths: [loop, leaf('service.a'), leaf('service.b'), leaf('service.c'), leaf('service.d')] }]) | |
| 72 | + | |
| 73 | + expect(orders?.paths.map((path) => path.nodes.at(-1)?.id)).toEqual(['service.a', 'service.b', 'service.c']) | |
| 74 | + }) | |
| 75 | + | |
| 76 | + it('reads a match with no paths', () => { | |
| 77 | + expect(matchesOf([{ id: 'service.lonely', title: 'Lonely', content: null, hash: 'sha256:l', score: 1, paths: [] }])).toEqual([ | |
| 78 | + { id: 'service.lonely', title: 'Lonely', hash: 'sha256:l', score: 1, paths: [] } | |
| 79 | + ]) | |
| 80 | + }) | |
| 81 | +}) | |
| 82 | + | |
| 83 | +describe('describePath', () => { | |
| 84 | + it('draws each relationship in its stored direction, and with why how far it holds', () => { | |
| 85 | + const path: MatchPath = { | |
| 86 | + nodes: [{ id: 'service.ledger', stub: false }, { id: 'service.orders', stub: false }, { id: 'team.payments', stub: false }], | |
| 87 | + edges: [ | |
| 88 | + { source: 'service.orders', rel: 'depends_on', target: 'service.ledger', confidence: 0.951, sources: ['code', 'manifest'] }, | |
| 89 | + { source: 'team.payments', rel: 'owns', target: 'service.orders', sources: [] } | |
| 90 | + ] | |
| 91 | + } | |
| 92 | + | |
| 93 | + expect(describePath(path)).toBe('service.ledger <-depends_on- service.orders <-owns- team.payments') | |
| 94 | + expect(describePath(path, true)).toBe('service.ledger <-depends_on- service.orders (0.95, code+manifest) <-owns- team.payments') | |
| 95 | + }) | |
| 96 | +}) | |
| 97 | + | |
| 98 | +describe('subgraphAnswer', () => { | |
| 99 | + const interpretation = { description: 'Neo4j full-text search', assumptions: [], nativeQuery: 'CALL ...' } | |
| 100 | + | |
| 101 | + it('answers with the matches, the paths from them and why each holds', () => { | |
| 102 | + const answer = subgraphAnswer(matchesOf([ordersRow()]), request(), interpretation) | |
| 103 | + | |
| 104 | + expect(validateAdapterAnswer(answer, request())).toEqual(answer) | |
| 105 | + expect(answer.blocks.map((block) => block.kind)).toEqual(['entities', 'graph', 'passages']) | |
| 106 | + // Only the full-text match is an entity: what its paths reach is context. | |
| 107 | + expect(answer.blocks[0]).toEqual({ | |
| 108 | + kind: 'entities', | |
| 109 | + id: 'hits', | |
| 110 | + evidenceIds: ['passage-1'], | |
| 111 | + entities: [ | |
| 112 | + { | |
| 113 | + ref: { kind: 'entity', id: 'service.orders', revision: 'sha256:service.orders' }, | |
| 114 | + score: 2.5, | |
| 115 | + detail: 'service.orders -depends_on-> service.ledger -depends_on-> datasource.ledger-db; service.orders <-owns- team.payments' | |
| 116 | + } | |
| 117 | + ] | |
| 118 | + }) | |
| 119 | + const graph = answer.blocks[1] as GraphBlock | |
| 120 | + expect(graph.paths).toEqual([ | |
| 121 | + ['service.orders', 'service.ledger', 'datasource.ledger-db'], | |
| 122 | + ['service.orders', 'team.payments'] | |
| 123 | + ]) | |
| 124 | + expect(graph.nodes).toContainEqual({ id: 'datasource.ledger-db', label: 'datasource.ledger-db' }) | |
| 125 | + expect(graph.nodes).toContainEqual({ | |
| 126 | + id: 'service.ledger', | |
| 127 | + label: 'service.ledger', | |
| 128 | + type: 'service', | |
| 129 | + ref: { kind: 'entity', id: 'service.ledger', revision: 'sha256:service.ledger' } | |
| 130 | + }) | |
| 131 | + expect(graph.edges).toEqual([ | |
| 132 | + { source: 'service.orders', target: 'service.ledger', rel: 'depends_on', confidence: 0.95, evidenceIds: ['path-1'] }, | |
| 133 | + { source: 'service.ledger', target: 'datasource.ledger-db', rel: 'depends_on', confidence: 0.9, evidenceIds: ['path-1'] }, | |
| 134 | + { source: 'team.payments', target: 'service.orders', rel: 'owns', confidence: 0.6, evidenceIds: ['path-2'] } | |
| 135 | + ]) | |
| 136 | + expect(answer.evidence.filter((item) => item.kind === 'derived-fact')).toEqual([ | |
| 137 | + { | |
| 138 | + id: 'path-1', | |
| 139 | + kind: 'derived-fact', | |
| 140 | + text: 'service.orders -depends_on-> service.ledger (0.95, code+manifest) -depends_on-> datasource.ledger-db (0.9, runtime)', | |
| 141 | + // The stub is no entity the files hold, so the fact cites the two that are. | |
| 142 | + canonicalRefs: [ | |
| 143 | + { kind: 'entity', id: 'service.orders', revision: 'sha256:service.orders' }, | |
| 144 | + { kind: 'entity', id: 'service.ledger', revision: 'sha256:service.ledger' } | |
| 145 | + ], | |
| 146 | + derivation: { engine: 'neo4j' } | |
| 147 | + }, | |
| 148 | + expect.objectContaining({ id: 'path-2', text: 'service.orders <-owns- team.payments (0.6)' }) | |
| 149 | + ]) | |
| 150 | + expect(answer.coverage).toEqual({ mode: 'top-k', truncated: false, scope: SCOPE }) | |
| 151 | + }) | |
| 152 | + | |
| 153 | + it('cites every path a relationship lies on', () => { | |
| 154 | + const row = ordersRow() | |
| 155 | + const second = { ...row, id: 'service.ledger', score: 1, paths: [row.paths[0] as (typeof row.paths)[number]] } | |
| 156 | + const answer = subgraphAnswer(matchesOf([row, second]), request(), interpretation) | |
| 157 | + | |
| 158 | + const graph = answer.blocks.find((block) => block.kind === 'graph') as GraphBlock | |
| 159 | + expect(graph.edges[0]).toMatchObject({ source: 'service.orders', target: 'service.ledger', evidenceIds: ['path-1', 'path-3'] }) | |
| 160 | + expect(validateAdapterAnswer(answer, request())).toEqual(answer) | |
| 161 | + }) | |
| 162 | + | |
| 163 | + it('still draws the paths whose why no longer fits the evidence budget, and says it was cut', () => { | |
| 164 | + const passage = 'Takes orders and stores them in the ledger.' | |
| 165 | + const answer = subgraphAnswer(matchesOf([ordersRow()]), request(10, Buffer.byteLength(passage) + 50), interpretation) | |
| 166 | + | |
| 167 | + // The long path's why is left out; the short one's still fits. | |
| 168 | + expect(answer.evidence.map((item) => [item.id, item.text])).toEqual([ | |
| 169 | + ['passage-1', passage], | |
| 170 | + ['path-1', 'service.orders <-owns- team.payments (0.6)'] | |
| 171 | + ]) | |
| 172 | + const graph = answer.blocks.find((block) => block.kind === 'graph') as GraphBlock | |
| 173 | + expect(graph.paths).toHaveLength(2) | |
| 174 | + expect(graph.edges[0]).toEqual({ source: 'service.orders', target: 'service.ledger', rel: 'depends_on', confidence: 0.95 }) | |
| 175 | + expect(graph.edges.at(-1)).toMatchObject({ rel: 'owns', evidenceIds: ['path-1'] }) | |
| 176 | + expect(answer.coverage.truncated).toBe(true) | |
| 177 | + expect(validateAdapterAnswer(answer, request())).toEqual(answer) | |
| 178 | + }) | |
| 179 | + | |
| 180 | + it('has no graph for matches with no paths', () => { | |
| 181 | + const answer = subgraphAnswer(matchesOf([{ ...ordersRow(), paths: [] }]), request(), interpretation) | |
| 182 | + | |
| 183 | + expect(answer.blocks.map((block) => block.kind)).toEqual(['entities', 'passages']) | |
| 184 | + }) | |
| 185 | + | |
| 186 | + it('keeps only the paths of the matches it shows', () => { | |
| 187 | + const row = ordersRow() | |
| 188 | + const answer = subgraphAnswer(matchesOf([row, { ...row, id: 'service.other', score: 1 }]), request(1), interpretation) | |
| 189 | + | |
| 190 | + expect((answer.blocks.find((block) => block.kind === 'graph') as GraphBlock).paths).toHaveLength(2) | |
| 191 | + expect(answer.coverage.truncated).toBe(true) | |
| 192 | + }) | |
| 193 | +}) | |
Config 1
packages/adapter-neo4j/package.json+1 −1
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | "name": "@docket/adapter-neo4j", |
| 3 | 3 | "version": "0.0.0", |
| 4 | 4 | "private": true, |
| 5 | - "description": "docket's Neo4j adapter: entities as a typed graph, searched full-text or through generated read-only Cypher.", | |
| 5 | + "description": "docket's Neo4j adapter: entities as a typed graph, searched by full-text matches and the paths from them, or through generated read-only Cypher.", | |
| 6 | 6 | "type": "module", |
| 7 | 7 | "exports": { |
| 8 | 8 | ".": { |
Docs 2
README.md+20 −8
| @@ -807,14 +807,22 @@ resource and one relationship per (source, rel, target), with `confidence`, | ||
| 807 | 807 | `evidenceCount`, `sources` and `evidence` on both, so a query can ask for what |
| 808 | 808 | rests on code alone — and a full-text index over the documents. It needs the |
| 809 | 809 | optional `neo4j-driver` package and a running Neo4j server, local or hosted. |
| 810 | -A question is answered with a full-text query, or, with `cypher` set, a local | |
| 811 | -Ollama model writes a read-only Cypher query against the graph's schema. The | |
| 812 | -query must bind every pattern it matches to the instance's scope or it is not | |
| 813 | -run; its rows are kept as the values they are - a single row of numbers as | |
| 814 | -metrics, anything else as a typed table, relationships as a graph - each row | |
| 815 | -cited as a derived fact. It counts as exhaustive only when it ran to | |
| 816 | -completion; full-text, or a cypher query that fails or finds nothing, answers | |
| 817 | -with the best matches instead. | |
| 810 | +A question is answered by one Cypher query the adapter writes itself: the | |
| 811 | +full-text matches, and from each its best few paths of up to `traverse.hops` | |
| 812 | +relationships (2 by default), ranked by the product of their relationships' | |
| 813 | +confidence and damped where they end at a well-connected node. The matches are | |
| 814 | +the answer's entities; the paths are its graph, each relationship in its stored | |
| 815 | +direction with its confidence, and each path a derived fact saying why it holds | |
| 816 | +(`service.orders -depends_on-> service.ledger (0.95, code+manifest)`). The | |
| 817 | +nodes a path reaches are context, not matches. `traverse.minConfidence` and | |
| 818 | +`traverse.rels` narrow which relationships a path may follow. With `cypher` | |
| 819 | +set, a local Ollama model writes a read-only Cypher query against the graph's | |
| 820 | +schema instead. The query must bind every pattern it matches to the instance's | |
| 821 | +scope or it is not run; its rows are kept as the values they are - a single | |
| 822 | +row of numbers as metrics, anything else as a typed table, relationships as a | |
| 823 | +graph - each row cited as a derived fact. It counts as exhaustive only when it | |
| 824 | +ran to completion; the adapter's own query, or a cypher query that fails or | |
| 825 | +finds nothing, answers with the best matches instead. | |
| 818 | 826 | |
| 819 | 827 | ```yaml |
| 820 | 828 | adapters: |
| @@ -826,6 +834,10 @@ adapters: | ||
| 826 | 834 | passwordEnv: NEO4J_PASSWORD # unset: connect without auth |
| 827 | 835 | # database: neo4j |
| 828 | 836 | # scope: payments-project |
| 837 | + # traverse: # how far a search reaches from each match | |
| 838 | + # hops: 2 # default; 1 to 3 | |
| 839 | + # minConfidence: 0 # default; follow only relationships this well supported | |
| 840 | + # rels: [depends_on, owned_by] # default: every relationship | |
| 829 | 841 | # cypher: |
| 830 | 842 | # model: "qwen2.5:7b" # Ollama at http://localhost:11434 |
| 831 | 843 | ``` |
packages/adapter-neo4j/README.md+51 −4
| @@ -1,19 +1,66 @@ | ||
| 1 | 1 | # @docket/adapter-neo4j |
| 2 | 2 | |
| 3 | 3 | docket's Neo4j adapter: one `(:Memory:<Type>)` node per entity and one typed |
| 4 | -relationship per (source, rel, target), with a full-text index, searched | |
| 5 | -full-text or through a read-only Cypher query a local Ollama model writes. | |
| 4 | +relationship per (source, rel, target), with a full-text index. A question is | |
| 5 | +answered by one query the adapter writes itself - the full-text matches and the | |
| 6 | +best paths from each, ranked by their relationships' confidence - or through a | |
| 7 | +read-only Cypher query a local Ollama model writes. | |
| 6 | 8 | |
| 7 | 9 | Its default export is the `AdapterDefinition` |
| 8 | 10 | ([`docs/adapter-spec.md`](../../docs/adapter-spec.md) §7). Its configuration is |
| 9 | 11 | an instance's `config` in a version 2 `.docket.yaml`, or a v1 `projections` |
| 10 | 12 | entry of `type: neo4j`: `uri` (or v1's `url`), `database`, `username`, |
| 11 | -`passwordEnv`, `scope` and `cypher`. Any other field is rejected. `neo4j-driver` is its dependency, | |
| 13 | +`passwordEnv`, `scope`, `traverse` and `cypher`. Any other field is rejected. `neo4j-driver` is its dependency, | |
| 12 | 14 | imported only when an instance is created. |
| 13 | 15 | |
| 14 | 16 | A private workspace package: it is not published on its own. |
| 15 | 17 | `@chrisjowen/docket` bundles it into `dist/bundled/adapter-neo4j`, and keeps |
| 16 | 18 | `neo4j-driver` an optional peer dependency. |
| 17 | 19 | |
| 20 | +## Search without a model | |
| 21 | + | |
| 22 | +`src/subgraph-search.ts` holds the query and how its rows become an answer. One | |
| 23 | +round trip finds the full-text matches in scope and, for each, its paths of up | |
| 24 | +to `traverse.hops` relationships (default 2) in either direction, each of | |
| 25 | +confidence at least `traverse.minConfidence` (default 0) and, when set, of a | |
| 26 | +`rel` in `traverse.rels`. A path ranks by the product of its relationships' | |
| 27 | +confidence divided by the log of its end node's degree, so the best-connected | |
| 28 | +nodes do not end every path; each match keeps its best three, less any path | |
| 29 | +another one extends. | |
| 30 | + | |
| 31 | +The answer is an entities block of the matches with their Lucene scores, a | |
| 32 | +graph block of the paths - every relationship in its stored direction with its | |
| 33 | +stored confidence - and a passages block of what each match says. Each path is | |
| 34 | +a derived fact citing the entities on it, the "why" its relationships hold: | |
| 35 | +`service.orders -depends_on-> service.ledger (0.95, code+manifest)`. The nodes | |
| 36 | +a path reaches are context, never matches. The query is the adapter's own, so | |
| 37 | +a unit test holds it to the same scope check a model-written query must pass. | |
| 38 | + | |
| 39 | +## Integration tests against a local Neo4j | |
| 40 | + | |
| 18 | 41 | `src/neo4j-projection.integration.test.ts` runs against a real server when |
| 19 | -`NEO4J_TEST_URL` is set (e.g. `bolt://localhost:7687`, auth off). | |
| 42 | +`NEO4J_TEST_URL` is set, and is skipped without it, so CI without Neo4j stays | |
| 43 | +green. With `NEO4J_PASSWORD` set it logs in as `neo4j` with that password, the | |
| 44 | +variable the adapter's own `passwordEnv` names in the examples; without it, | |
| 45 | +it connects with auth off. Every test works in a scope of its own and clears | |
| 46 | +it afterwards, so a shared server is safe to use. | |
| 47 | + | |
| 48 | +One way to run a server for them: the official image in Docker, bound to | |
| 49 | +127.0.0.1 only, with a generated password kept in an env file outside the | |
| 50 | +repository. | |
| 51 | + | |
| 52 | +```bash | |
| 53 | +env_file=~/.config/docket/neo4j-test.env # anywhere outside the repository | |
| 54 | +umask 077 | |
| 55 | +printf 'NEO4J_TEST_URL=bolt://127.0.0.1:27687\nNEO4J_PASSWORD=%s\n' "$(openssl rand -hex 24)" > "$env_file" | |
| 56 | +set -a; . "$env_file"; set +a | |
| 57 | + | |
| 58 | +docker run -d --name docket-neo4j-test \ | |
| 59 | + -p 127.0.0.1:27687:7687 -p 127.0.0.1:27474:7474 \ | |
| 60 | + -e NEO4J_AUTH="neo4j/$NEO4J_PASSWORD" \ | |
| 61 | + neo4j:5.26-community | |
| 62 | + | |
| 63 | +pnpm --filter @docket/adapter-neo4j test # once the log says "Started." | |
| 64 | +``` | |
| 65 | + | |
| 66 | +Later shells load the same file before testing: `set -a; . "$env_file"; set +a`. | |
Hidden generated
5 generated files hidden (+295 −75)
wheelhouse/docket/dist/bundled/adapter-neo4j/dist/config.js+17 −2wheelhouse/docket/dist/bundled/adapter-neo4j/dist/graph-model.js+0 −4wheelhouse/docket/dist/bundled/adapter-neo4j/dist/index.js+3 −2wheelhouse/docket/dist/bundled/adapter-neo4j/dist/neo4j-projection.js+71 −67wheelhouse/docket/dist/bundled/adapter-neo4j/dist/subgraph-search.js+204 −0