PR review for humans

chrisjowen/docket #48 Merged

feat(adapter-neo4j): answer searches with one native ranked subgraph query

chrisjowen wants to merge fm/docket-neo4j-native-subgraph into main · +1079 −205 · 17 files · view on GitHub

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 from memory_text in 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 one derived-fact evidence item per path explaining why it holds. The reached neighbours are graph context, not hits. search() hits now describe their paths in detail. The old describeNeighbours helper is removed.
  • A new optional traverse: { hops (1–3, default 2), minConfidence (0–1, default 0), rels? } config is validated in config.ts. Unit tests check that every node pattern in the query carries $scope and 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 through NEO4J_TEST_URL and cover the traversal reach and the ranking. The READMEs and the bundled wheelhouse/docket adapter dist are updated to match.

🤖 Generated with Claude Code

Risk Assessment

⚠️ Medium: The change matches the A2 intent and is well tested against a real Neo4j, but the new default query lists every path before capping, runs with no timeout or deadline, and can be slow on dense graphs.

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)
    docket open Ask page: neo4j answer
    docket open: 'Paths from the matches' graph block

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 through run() (executeQuery with no transaction timeout), and answer() gives it no deadline. The query lists every path of up to hops relationships from each seed (OPTIONAL MATCH p = (seed)-[*1..hops]-(reached…), subgraph-search.ts:52) and works out COUNT { (reached)--() } for each one. Only after that does it sort and cut each seed's paths to $paths (6). Example: with traverse.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 by Math.min(remaining, CYPHER_TIMEOUT_MS), but this default path can now run past request.budget.deadline. Smallest fix: pass a transaction timeout taken from the request deadline (or CYPHER_TIMEOUT_MS for search()) to this query, the way cypherPort().read already does. The same unbounded call is reached from search() via subgraphHits (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 runs scopeProblem, which accepts patterns anchored to a scoped node. So (seed) and the anonymous () in COUNT { (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 runs scopeProblem, which accepts patterns anchored to a scoped node. So (seed) and the anonymous () in COUNT { (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 only packages/adapter-neo4j/src/neo4j-projection.ts. It did not rebuild the committed plugin bundle. The bundled neo4j-projection.js still has the version from before the fix: subgraphSearch(query, limit) with no timeout, going through run()/executeQuery (lines 162, 176, 184), and the inline cypherPort().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 test runs wheelhouse's test script (node build.mjs --check &amp;&amp; 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 run pnpm build in wheelhouse/ and commit the regenerated wheelhouse/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 runs scopeProblem, which accepts patterns anchored to a scoped node. So (seed) and the anonymous () in COUNT { (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 () in COUNT { (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 HEAD
  • NEO4J_TEST_URL=bolt://127.0.0.1:27787 npx vitest run src/neo4j-projection.integration.test.ts src/subgraph-search.test.ts in packages/adapter-neo4j (33/33 against real Neo4j)
  • docket sync then docket ask --adapter neo4j checkout / --json orders on a temp copy of examples/acme-platform with a neo4j projection
  • node -r count-queries.cjs docket ask|search with the neo4j-driver Session/Transaction run and executeRead calls patched to log each query and its timeout
  • docket ask with traverse {hops:1}, {rels:[depends_on]}, {minConfidence:0.9} and {hops:4}
  • v2 .docket.yaml with query.timeoutMs: 3000: executeRead timeout observed
  • Second scope nm-other holding 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 control
  • docket open Ask 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 {
1313 checkoutScope,
1414 ollamaChat,
1515 questionTerms,
16- relevantExcerpt,
17- searchAnswer,
1816 type Chat,
1917 type EntityProjection,
2018 type ProjectionContext,
@@ -25,23 +23,23 @@ import {
2523 import type { Neo4jConfig } from './config.js'
2624 import type { GraphSchema } from './cypher-prompt.js'
2725 import { cypherAnswer, cypherSearch, type CypherPort } from './cypher-search.js'
26+import { fulltextQuery, labelFor, NODE_PROPERTIES, nodeProperties, relationshipRows } from './graph-model.js'
2827 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+
3939 /** How long a generated query may run before it is abandoned. */
4040 const CYPHER_TIMEOUT_MS = 10_000
4141 /** Lucene's English analyzer: drops stop words and stems, so `ordering` finds `orders`. */
4242 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
4543
4644 /**
4745 * neo4j-driver is an optional dependency: only repositories that configure a
@@ -83,12 +81,14 @@ class Neo4jProjection implements EntityProjection {
8381 private scope = ''
8482 /** Writes Cypher from a question; absent unless `cypher` is configured. */
8583 private readonly chat: Chat | undefined
84+ private readonly traversal: Traversal
8685
8786 constructor(
8887 private readonly config: Neo4jConfig,
8988 dependencies: Neo4jProjectionDependencies = {}
9089 ) {
9190 this.chat = dependencies.chat ?? (config.cypher ? ollamaChat(config.cypher) : undefined)
91+ this.traversal = { ...DEFAULT_TRAVERSAL, ...config.traverse }
9292 }
9393
9494 async init(context: ProjectionContext): Promise<void> {
@@ -172,11 +172,11 @@ class Neo4jProjection implements EntityProjection {
172172 /**
173173 * With `cypher` configured, the model writes a query from the question and
174174 * 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.
177177 */
178178 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) }
180180
181181 let reason: string
182182 try {
@@ -186,7 +186,7 @@ class Neo4jProjection implements EntityProjection {
186186 } catch (cause) {
187187 reason = `cypher failed: ${cause instanceof Error ? cause.message : String(cause)}`
188188 }
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` }
190190 }
191191
192192 /**
@@ -194,12 +194,16 @@ class Neo4jProjection implements EntityProjection {
194194 * the rows are the answer, kept as the values they are: a count is a
195195 * metric, a list a typed table, relationships a graph (docs/adapter-spec.md
196196 * §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.
198198 */
199199 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()
203207 const diagnostics: Diagnostic[] = []
204208 if (this.chat) {
205209 let reason: string
@@ -213,50 +217,58 @@ class Neo4jProjection implements EntityProjection {
213217 } catch (cause) {
214218 reason = `The Cypher query failed: ${cause instanceof Error ? cause.message : String(cause)}`
215219 }
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.` })
217221 }
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)}`
222239 })
223240 return { ...answer, diagnostics: [...diagnostics, ...answer.diagnostics] }
224241 }
225242
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+
226248 /**
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.
231253 */
232- private async fulltextSearch(query: string, limit: number): Promise<SearchHit[]> {
254+ private async subgraphSearch(query: string, limit: number, timeoutMs: number): Promise<Match[]> {
233255 const lucene = fulltextQuery(query)
234256 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
242270 )
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))
260272 }
261273
262274 private cypherPort(): CypherPort {
@@ -264,17 +276,7 @@ class Neo4jProjection implements EntityProjection {
264276 return {
265277 scope: this.scope,
266278 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),
278280 documents: async (ids) => {
279281 if (ids.length === 0) return new Map()
280282 const rows = await this.run(
@@ -338,20 +340,6 @@ class Neo4jProjection implements EntityProjection {
338340 this.driver = null
339341 }
340342
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-
355343 /** Swaps the node's type label for `label`, or just removes it when `label` is null. */
356344 private async replaceTypeLabel(tx: ManagedTransaction, id: string, label: string | null): Promise<void> {
357345 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 {
396384 }
397385 }
398386
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+
399398 private async run(cypher: string, parameters: Record<string, unknown> = {}): Promise<Record<string, unknown>[]> {
400399 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) => {
402405 const row = record.toObject() as Record<string, unknown>
403406 return Object.fromEntries(
404407 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 @@
11 import { ollamaModelSchema } from '@docket/adapter-kit'
22 import { z } from 'zod'
33
4+/** The furthest a search expands from a match: past three hops, most graphs reach everything. */
5+export const MAX_HOPS = 3
6+
47 /**
58 * A Neo4j graph of the documents and their links. The password is read from the
69 * environment, never from the file; with `passwordEnv` unset the driver
@@ -30,9 +33,22 @@ export const neo4jConfigSchema = z.preprocess(
3033 scope: z.string().min(1).optional(),
3134 /**
3235 * 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.
3444 */
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()
3652 })
3753 )
3854

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 =>
119119 .map((word) => word.replace(/["()?]/g, '').replace(LUCENE_SPECIAL, (c) => `\\${c}`))
120120 .filter((word) => word.length > 0 && word !== '\\')
121121 .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'
77 export { createNeo4jProjection, type Neo4jProjectionDependencies } from './neo4j-projection.js'
88
99 /**
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
1213 * when an instance is created, so importing this module needs no driver.
1314 */
1415 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'
44
55 import {
66 NODE_PROPERTIES,
7- describeNeighbours,
87 fulltextQuery,
98 labelFor,
109 nodeProperties,
@@ -36,16 +35,6 @@ describe('graph model', () => {
3635 expect(fulltextQuery(' ')).toBe('')
3736 })
3837
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-
4938 describe('merged entities', () => {
5039 const inManifest = { source: 'manifest', path: 'package.json', key: 'dependencies.pg' }
5140 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', () => {
4040 expect(() => neo4j.validateConfig({ cypher: { model: 'm', modle: 'n' } })).toThrow(/Unrecognized key: "modle"/)
4141 })
4242
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+
4355 it('names itself and its contract major', () => {
4456 expect(neo4j).toMatchObject({ apiVersion: 1, name: 'neo4j' })
4557 })
packages/adapter-neo4j/src/neo4j-projection.integration.test.ts+123 −8
@@ -1,7 +1,7 @@
11 import neo4j, { type Driver } from 'neo4j-driver'
22 import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
33
4-import type { EntityInput } from '@docket/contracts'
4+import { validateAdapterAnswer, type AskRequest, type EntityInput, type GraphBlock } from '@docket/contracts'
55 import type { EntityProjection } from '@docket/adapter-kit'
66 import { entityInput, entityLink } from '@docket/adapter-kit/testing'
77
@@ -9,10 +9,15 @@ import type { Neo4jConfig } from './config.js'
99 import { createNeo4jProjection } from './neo4j-projection.js'
1010
1111 /**
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.
1415 */
1516 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 } : {}
1621
1722 const CONTEXT = { projectRoot: '/repos/acme' }
1823
@@ -43,6 +48,7 @@ describe.skipIf(!URL)('neo4j projection', () => {
4348 url: URL ?? '',
4449 username: 'neo4j',
4550 scope,
51+ ...AUTH,
4652 ...overrides
4753 })
4854 await created.init?.(CONTEXT)
@@ -70,8 +76,7 @@ describe.skipIf(!URL)('neo4j projection', () => {
7076 )
7177
7278 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 ?? '')
7580 })
7681
7782 afterAll(async () => {
@@ -236,14 +241,14 @@ describe.skipIf(!URL)('neo4j projection', () => {
236241 })
237242 )
238243
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
240245 // as hits of their own - well-linked documents would otherwise answer everything.
241246 expect(await projection.search?.('orders', 10)).toEqual({
242247 hits: [
243248 {
244249 id: 'service.api',
245250 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',
247252 revision: 'sha256:api',
248253 passage: { text: 'Takes orders and stores them.' }
249254 }
@@ -259,6 +264,116 @@ describe.skipIf(!URL)('neo4j projection', () => {
259264 expect((await projection.search?.('ordering', 10))?.hits.map((hit) => hit.id)).toEqual(['service.api'])
260265 })
261266
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+
262377 describe('with a model writing Cypher', () => {
263378 /** Stands in for the model: answers every prompt with `cypher`, and keeps the prompts. */
264379 const model = (cypher: string) => {
@@ -276,7 +391,7 @@ describe.skipIf(!URL)('neo4j projection', () => {
276391 scope = `test-${Math.random().toString(36).slice(2)}`
277392 const fake = model(cypher)
278393 const created = createNeo4jProjection(
279- { type: 'neo4j', url: URL ?? '', username: 'neo4j', scope },
394+ { type: 'neo4j', url: URL ?? '', username: 'neo4j', scope, ...AUTH },
280395 { chat: fake.chat }
281396 )
282397 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 @@
22 "name": "@docket/adapter-neo4j",
33 "version": "0.0.0",
44 "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.",
66 "type": "module",
77 "exports": {
88 ".": {

Docs 2

README.md+20 −8
@@ -807,14 +807,22 @@ resource and one relationship per (source, rel, target), with `confidence`,
807807 `evidenceCount`, `sources` and `evidence` on both, so a query can ask for what
808808 rests on code alone — and a full-text index over the documents. It needs the
809809 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.
818826
819827 ```yaml
820828 adapters:
@@ -826,6 +834,10 @@ adapters:
826834 passwordEnv: NEO4J_PASSWORD # unset: connect without auth
827835 # database: neo4j
828836 # 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
829841 # cypher:
830842 # model: "qwen2.5:7b" # Ollama at http://localhost:11434
831843 ```
packages/adapter-neo4j/README.md+51 −4
@@ -1,19 +1,66 @@
11 # @docket/adapter-neo4j
22
33 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.
68
79 Its default export is the `AdapterDefinition`
810 ([`docs/adapter-spec.md`](../../docs/adapter-spec.md) §7). Its configuration is
911 an instance's `config` in a version 2 `.docket.yaml`, or a v1 `projections`
1012 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,
1214 imported only when an instance is created.
1315
1416 A private workspace package: it is not published on its own.
1517 `@chrisjowen/docket` bundles it into `dist/bundled/adapter-neo4j`, and keeps
1618 `neo4j-driver` an optional peer dependency.
1719
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+
1841 `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 −2
  • wheelhouse/docket/dist/bundled/adapter-neo4j/dist/graph-model.js +0 −4
  • wheelhouse/docket/dist/bundled/adapter-neo4j/dist/index.js +3 −2
  • wheelhouse/docket/dist/bundled/adapter-neo4j/dist/neo4j-projection.js +71 −67
  • wheelhouse/docket/dist/bundled/adapter-neo4j/dist/subgraph-search.js +204 −0