Skip to content

The graph answers with paths, neighbours and a timeline - #565

Open
WaylandYang wants to merge 7 commits into
devfrom
feat/graph-query
Open

The graph answers with paths, neighbours and a timeline#565
WaylandYang wants to merge 7 commits into
devfrom
feat/graph-query

Conversation

@WaylandYang

@WaylandYang WaylandYang commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Closes #558.

What changed

The model's graph tools were a name lookup and a fact dump. Now the query work happens on the server:

  • paths_between(from, to, max_hops ≤ 3, at?, as_of?) (utopia-store::paths): the chains of facts joining two entities, shortest first, at most ten, deduplicated (the same edge is often two facts, one dated and one not). The search meets in the middle (a's two hops against b's one), never expands a hub past the first hop, and runs both clocks: with at every edge on a path must hold at that moment, with as_of the edges and the entity ownership are read as the base held them then.
  • neighbors(entity, predicate?, object_type?, at?): one hop, grouped by predicate, each neighbour with its type and range.
  • timeline(entity, since?, until?, predicate?): the entity's facts that state a world-time position, in order; undated facts are counted and left out.
  • entity_facts takes predicate, object_type, since / until and limit, and its output is grouped by predicate with a header that says how much was cut and where to narrow. A filter that matches nothing lists the predicates the entity does have; the model's guess ("board member") and the base's word (comprised) rarely coincide (One relation arrives under several predicates #560).
  • find_entities ranks typed before untyped, exact before partial, heavy before light; untyped phrase entities are left out when anything typed matches (A descriptive phrase becomes an entity #559); the reply names the best match when one dominates.
  • Every entity parameter takes a name as well as an id. A name is resolved by the same ranking (canonical name or alias, substring, case-insensitive), and the reply's first line says which entity was chosen and which others matched, with their ids. That saves a find_entities round and the "which OpenAI?" that usually followed it.
  • Rendering: a fact with no stated start reads undated (it read attested 2026-09-06T12:23:58.175078Z, and Qwen quoted that as the date something happened); one the text says is over reads ended, date unknown.
  • MCP exposes the three new tools; the manual's MCP page lists them; the chat shows them with the entity and facts icons (six lines in Chat.tsx).

As the model sees it

paths_between("OpenAI", "Anthropic"):

"OpenAI" = OpenAI (ResearchProject, 433 facts); other matches: OpenAI's board of directors (Organization, 16 facts, 01a076ad-…); OpenAI Global, LLC (Corporation, 10 facts, …); … Pass an id to choose another.
"Anthropic" = Anthropic (Organization, 151 facts); other matches: Anthropic Institute (ResearchOrganization, 2 facts, …). Pass an id to choose another.
10 paths between OpenAI and Anthropic (up to 3 hops, shortest first):
1. OpenAI —founded→ Anthropic (undated → now) [90%]
2. OpenAI ←isOfferedBy— Anthropic (2020-12 → 2021-01) [90%]
3. OpenAI ←participated_in_financing_round— Coatue (2025-04-01 → now) [90%]; Coatue —funding→ Anthropic (2025-12-31 → now) [90%]
…
7. OpenAI ←worksFor— Daniela Amodei (undated → 2021) [90%]; Daniela Amodei ←employee— Anthropic (undated → now) [90%]
8. OpenAI ←worksFor— Daniela Amodei (undated → 2021) [90%]; Daniela Amodei ←founders— Anthropic (undated → now) [90%]
10. OpenAI —founders→ John Schulman (2015-12 → now) [90%]; John Schulman ←employee— Anthropic (2024-08 → now) [90%]

timeline("OpenAI"), 3.7 KB where entity_facts was 26 KB:

OpenAI (ResearchProject): 317 dated facts, 60 shown from 2015-01-01 to 2023-03-01 (pass since/until to see the rest); 116 undated facts omitted (entity_facts has them)
2015  co-chair_of ← Elon Musk (2015 → 2018) [90%]
2015-12  founders → Elon Musk (2015-12 → now) [90%]
2015-12  founders → Sam Altman (2015-12 → now) [90%]
…

entity_facts(OpenAI, predicate="founder", object_type="Person"):

OpenAI (ResearchProject) · 433 facts, 11 match the filter
## founders → (11)
Elon Musk (2015-12 → now) [90%]
Sam Altman (2015-12 → now) [90%]
…

neighbors(Anthropic, object_type="Person"):

Anthropic (Organization): 16 linked entities under 5 predicates
employee → John Schulman [Person] (2024 → now) [90%] · Daniela Amodei [Person] (undated → now) [90%] · Dario Amodei [Person] (undated → now) [90%] · …
founders → Dario Amodei [Person] (2021 → now) [90%] · Daniela Amodei [Person] (2021 → now) [90%] · …
alumni → Rick Levin [Person] (2025-08 → now) [90%]

neighbors(OpenAI's board, predicate="member"), the miss that now explains itself:

OpenAI's board of directors (Organization): no linked entities match the filter. Predicates on this entity: comprised → (4), ← resignation (3), employee → (2), announced → (1), can remove → (1), …

End to end

The twelve fresh data questions from #558 (relation ×6 in two phrasings, timeline ×3, board ×3), same scratch base, run on the rig loop of #548 merged locally with this branch. "Before" is the same harness on the same loop and prompt without these tools.

model question before: answered from the graph after: answered from the graph
DeepSeek-V3 relation ×6 3, four or more calls each 4, one paths_between each; the other 2 were no_evidence_needed before any tool (the loop's gate, not the tools)
DeepSeek-V3 timeline ×3 2, one of them a 26 KB dump 3, all through timeline
DeepSeek-V3 board ×3 2 0 from the graph, 2 from text search (see below)
Qwen2.5-72B relation ×6 0 (gave up after six or seven calls, or asked which OpenAI) 5 through paths_between
Qwen2.5-72B timeline ×3 1 0: it calls find_entities, reads "Best match", and still asks which OpenAI
Qwen2.5-72B board ×3 2 partial 0

Every turn finished with done; steps, sources and tool_exchange are stored as before; MCP lists eleven tools.

The board question stays open, and the run says why: the four comprised facts on "OpenAI's board of directors" carry no date, so entity_facts(…, at: "2023") correctly excludes them, and the model turns to text search. That is extraction, #559's territory (the same run shows two "Sam Altman" entities, one typed Organization with 66 facts), not the query.

Also visible in the raw output and left alone here: the base holds OpenAI —founded→ Anthropic, which is false and ranks first because it is one hop; and the specificity ranking puts investor-in-both paths above the Amodei path, because Coatue has fewer facts than Daniela Amodei.

Tests

  • Store, against a database: a_path_joins_two_entities — shortest first and cut at max_hops; a path holds only when every edge holds at at; a hub is not walked through; the record axis hides an edge recorded later; a second fact on the same edge does not make a second path.
  • Server, pure: ranking (typed, exact, weight; untyped only when nothing else; namesakes of equal weight are not dominant), grouping, the filter on predicate / type / window, the chain rendering in both walking directions, the predicate list on an empty filter; time_text updated for undated / ended, date unknown; check_call and MCP untouched.
  • Web: typecheck, style guard, vitest.

Where vectors go (second commit batch)

  • A namesake is picked by the question. When a name matches several entities, the user's question is embedded once (the workspace's embedding model, bge-m3 here) and compared with each candidate's profile_embedding, the centroid of the contexts it was extracted from. Exact-name matches stay ahead of partial ones; inside a tier, the closest profile wins. The reply says "closest to the question". MCP passes no question and keeps the fact-count order.
  • A predicate is read by its meaning. When a predicate filter matches no fact by substring, the word is embedded and compared with relation_types.embedding; keys within cosine distance 0.45 stand in for it, and the reply says so: predicate "member" read as has_member, member, member_of, members. Measured distances for "member": has_member 0.27, members 0.32, member_of 0.38. Relations minted by auto-extension carry no embedding (comprised among them, One relation arrives under several predicates #560), so the miss that started this still ends with the predicate list rather than the alignment; the model then asks for comprised and gets the four board members from the graph.
  • A name is found by most of its words. A multi-word name that matches nothing as a substring is retried word by word; a candidate needs all words but one ("OpenAI board members" → "OpenAI's board of directors"). Chinese words against English names still miss ("OpenAI 董事会"): that needs a name embedding, which is A descriptive phrase becomes an entity #559's side.
  • neighbors and timeline accept entity_id as well as entity; the models pass both.

Third pass on DeepSeek after these: the relation question came from paths_between 6 of 6 times; timelines 3 of 3; the board question answered correctly from the graph once the model followed the predicate list (entity_facts(predicate="comprised")), from text search otherwise.

Not here

  • Name recall across languages and nicknames: the profile embedding is a context centroid, not a name embedding, so it disambiguates but cannot find; a name-embedding column is extraction's to add.
  • The path ranking's notion of specificity is the intermediate node's degree, which favours one-mention investors over people with roles on both sides.
  • neighbors is one hop by design; the model takes further hops itself.

🤖 Generated with Claude Code

@WaylandYang

Copy link
Copy Markdown
Contributor Author

Two more commits put vectors where the runs said they belong, and one small lookup change:

  • Namesakes are ordered by the question. The user's message is embedded once and compared with each candidate's profile_embedding; exact-name matches stay ahead, the closest profile wins inside a tier. The reply says "closest to the question". MCP has no question and keeps fact-count order.
  • A predicate that matches nothing by substring is read by meaning against relation_types.embedding (cosine distance ≤ 0.45), and the reply names the substitution: predicate "member" read as has_member, member, member_of, members. Measured: has_member 0.27, members 0.32, member_of 0.38. comprised has no embedding (auto-extended, One relation arrives under several predicates #560), so the board case still ends with the predicate list, which the model then follows: entity_facts(predicate="comprised") returns the four members.
  • A multi-word name is found by most of its words: "OpenAI board members" → "OpenAI's board of directors". "OpenAI 董事会" still misses; that is a name embedding, A descriptive phrase becomes an entity #559's side.

Third DeepSeek pass on the same twelve questions: relation from paths_between 6/6 (one call each), timeline 3/3, board 1 from the graph and 1 from text (one turn lost to a transient endpoint error, surfaced as an error frame). Five targeted probes on the board and the namesake all answered correctly; the calls and replies are in the PR body.

WaylandYang and others added 7 commits September 9, 2026 22:31
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: WaylandYang <wayland0916@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: WaylandYang <wayland0916@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: WaylandYang <wayland0916@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: WaylandYang <wayland0916@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: WaylandYang <wayland0916@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: WaylandYang <wayland0916@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: WaylandYang <wayland0916@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The graph answers a relation question three times in sixteen

1 participant