GraphRAG¶
The graph subsystem (spec_graphrag §5.2, §10;
ADR-017) — a peer retrieval
corpus beside chunk-RAG, not a sixth retriever: structured messages (an
iMessage chat.db in v1) become a knowledge graph with per-message
provenance.
Stage 1 shipped the engine-independent foundations plus the bake-off
harness that decided the engine; stage 2 productionized the winner —
the resumable build behind API routes, the
graph query path, the web surfaces (Corpus tab, source
selector, evidence, and the /graph view), and the workdir-derived
gauges.
- Message sources are a registry family chosen by structural
dispatch — a file goes to the first source whose
matches()accepts it (the parser precedent), so there is noconfig.pyvocabulary tuple to keep in lockstep. v1 registers onlyimessage. - Graph engines are a registry family in the usual
@register("name")shape, selected byGRAPH_ENGINE. ADR-017 pickedlightragand the losing adapters were deleted at stage-2 start; the seam is what stays, so the decision remains benchmark-revisitable. Importing the package must not import an engine library — the adapter importslightraginsidesession(), so the registry stays free even though the engine is a main dependency. - Rendering is pure and shared: messages become thread-day
transcripts, and identical thread-days always render to an identical
doc_keyand text — the property the build diff rests on. - The workdir owns two sidecars.
varagity_manifest.jsonrecordsdoc_key → {content hash, message guids, thread, span}, which makes a build a diffing upsert (LightRAG's enqueue dedup would otherwise keep a stale transcript forever) and keeps provenance durable across restarts.varagity_graph_summary.jsoncaches the graph's size so status polls and metric scrapes never walk the graphml. - One process is the single writer.
varagity.graph.serviceholds the lone session, guards writes with a single-flight lock, and lets reads through unlocked — the session runs its event loop on its own thread, so a chat query is answered while a multi-day backfill is still extracting. - Answer synthesis is the repo's, not the engine's (ADR-017 chose
retrieval-only): the adapter returns entities, relations, and transcript
excerpts, and
varagity.graph.answerwrites the grounded, capped,<think>-stripped answer over them.
Message sources¶
varagity.graph.sources.base
¶
Message-source protocol and registry (spec_graphrag §5.2, §10.2; the spec §5.1 pattern).
Each message-source module defines one implementation decorated with
@register("name"); unlike the chat/retriever registries (settings-field
selection), sources are chosen by structural dispatch — the parser
precedent (varagity.ingest.loader._BUCKET_PARSERS): a file is routed to
the first source whose :meth:MessageSource.matches accepts it, so there is
no config.py vocabulary tuple to keep in lockstep. v1 registers only
imessage; adding a platform later is one new module plus its import line
in :mod:varagity.graph.sources, zero caller edits.
A message source produces structured messages (speaker, timestamp,
thread, per-message identity) — not prose for chunking — so the family lives
under :mod:varagity.graph, not :mod:varagity.ingest. The models are
pydantic (not dataclasses) because the fixture generator, eval results, and
stage-2 wire payloads all serialize them.
MessageBatch
¶
Bases: BaseModel
A parsed source file: its stable identity plus every message in it.
The identity recipe is the corpus recipe reused verbatim
(:func:varagity.stores.records.derive_doc_id over the path relative to
GRAPH_DOCS_PATH plus the file's byte hash), so a graph document's
doc_id is portable across host/container/machines exactly as a RAG
document's is.
Attributes:
| Name | Type | Description |
|---|---|---|
doc_id |
str
|
Stable id of the source file (see :func: |
relative_path |
str
|
POSIX path of the file relative to the graph root. |
messages |
list[SourceMessage]
|
Every parsed message, oldest first. |
Source code in varagity/graph/sources/base.py
82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 | |
MessageSource
¶
Bases: Protocol
Parses one file format into :class:SourceMessage objects.
runtime_checkable mirrors :class:~varagity.chat.base.ChatEngine:
the protocol will appear in stage-2 Prefect flow signatures, and Prefect
builds a pydantic parameter schema from the annotations at decoration
time, which requires types usable with isinstance.
Source code in varagity/graph/sources/base.py
102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 | |
matches(path)
¶
Report whether this source can parse path (a cheap probe).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
Path
|
Candidate file. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
|
bool
|
a probe that can't read the file returns |
Source code in varagity/graph/sources/base.py
112 113 114 115 116 117 118 119 120 121 122 | |
parse(path, *, verbose=0)
¶
Parse path into normalized messages, oldest first.
Called only after :meth:matches has accepted path.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
Path
|
File to parse (its format already confirmed by
:meth: |
required |
verbose
|
int
|
Validated console verbosity (0–2). |
0
|
Returns:
| Type | Description |
|---|---|
list[SourceMessage]
|
Every recoverable message, oldest first (unrecoverable rows are |
list[SourceMessage]
|
counted and skipped, never emitted with empty text). |
Source code in varagity/graph/sources/base.py
124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 | |
SourceMessage
¶
Bases: BaseModel
One normalized message from any message source (spec_graphrag §10.2).
Attributes:
| Name | Type | Description |
|---|---|---|
guid |
str
|
Stable per-message identity — |
thread_id |
str
|
Stable thread identity ( |
thread_name |
str
|
Human-facing thread label: the chat's |
sender_handle |
str
|
Raw handle (phone number / email); |
sender_name |
str
|
Mapped display name ( |
is_from_me |
bool
|
Whether the owner sent it ( |
timestamp |
datetime
|
Send time as a timezone-aware UTC datetime (both Apple epoch eras decoded to the same wall clock). |
text |
str
|
The decoded message body; never empty (rows with no recoverable text are skipped at parse time, not emitted blank). |
tapbacks |
list[Tapback]
|
Reactions folded onto this message (empty when none). |
Source code in varagity/graph/sources/base.py
47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 | |
Tapback
¶
Bases: BaseModel
One folded reaction on a message (a sentiment signal, not a message).
Tapback rows in chat.db never become standalone
:class:SourceMessage records — they fold onto the message they react to
(spec_graphrag §10.1).
Attributes:
| Name | Type | Description |
|---|---|---|
kind |
str
|
The reaction, one of |
sender_name |
str
|
Display name of whoever reacted (the owner label when the reaction is the owner's). |
Source code in varagity/graph/sources/base.py
29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 | |
batch_for_path(path, root, *, verbose=0)
¶
Parse path into a :class:MessageBatch with a stable doc_id.
doc_id is derived from the path relative to root plus the file's
byte hash — the same recipe as RAG documents
(:func:varagity.stores.records.derive_doc_id) — so overlapping
re-ingests of a growing database can upsert against a stable id. A
path outside root (e.g. a symlink target) falls back to the file
name, exactly as :func:varagity.ingest.loader._ingest_file does.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
Path
|
File to parse (must be claimed by a registered source). |
required |
root
|
Path
|
Corpus root the |
required |
verbose
|
int
|
Validated console verbosity (0–2). |
0
|
Returns:
| Type | Description |
|---|---|
MessageBatch
|
The parsed batch: identity plus its messages. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If no registered source matches |
Source code in varagity/graph/sources/base.py
200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 | |
find_message_source(path)
¶
Find the first registered source whose :meth:MessageSource.matches accepts path.
Structural dispatch (the _BUCKET_PARSERS precedent): a file with no
matching source returns None rather than raising, so callers can
count-and-skip an unsupported drop-in the way ingest discovery does.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
Path
|
Candidate file. |
required |
Returns:
| Type | Description |
|---|---|
MessageSource | None
|
The matching source, or |
Source code in varagity/graph/sources/base.py
181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 | |
get_message_source(name)
¶
Look up a registered message source by name.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Registry key (e.g. |
required |
Returns:
| Type | Description |
|---|---|
MessageSource
|
The registered source instance. |
Raises:
| Type | Description |
|---|---|
KeyError
|
If no source is registered under |
Source code in varagity/graph/sources/base.py
161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 | |
register(name)
¶
Class decorator registering a message-source instance under name.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Registry key (also the source's stable identifier). |
required |
Returns:
| Type | Description |
|---|---|
Callable[[T], T]
|
The decorator, which instantiates and registers the class unchanged. |
Source code in varagity/graph/sources/base.py
144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 | |
varagity.graph.sources.imessage
¶
The iMessage chat.db message source (spec_graphrag §10.1).
Parses a copied iMessage SQLite store into :class:SourceMessage objects
with full fidelity — the structure today's prose parsers throw away:
- Stable identity is
message.guid(never the volatileROWID), so an overlapping re-ingest of a growing database upserts rather than duplicates. - Both Apple epoch eras are decoded per row by magnitude:
message.datecounts from 2001-01-01 UTC, in seconds pre-macOS 10.13/iOS 11 and nanoseconds after — a decade-spanning database contains both. attributedBodyis decoded when the plaintextcolumn is NULL (the newer-OS default): the body lives in a serializedNSAttributedStringtypedstream blob, decoded here viapytypedstream.- Tapbacks (
associated_message_type2000–2005 add / 3000–3005 remove) fold onto the message they react to; they never become standalone messages. - Ego is structural:
is_from_memarks the owner's messages, mapped tosettings.graph_owner_labelwith an empty handle. - Handles → names: participant handles (phone/email) are mapped through
settings.graph_handle_name_map, falling back to the raw handle.
The database is the export: copy it with its -wal/-shm sidecars
or recent messages are missing (SQLite honors the sidecars automatically when
they sit beside the file). The parser opens the file read-only and never
writes.
Running the module is the R3 verification tool
(uv run python -m varagity.graph.sources.imessage /path/to/chat.db): it
prints message/thread counts, the date range, tapback-fold count, and a few
sample messages so a real export can be sanity-checked by hand.
IMessageSource
¶
Parses an iMessage chat.db into normalized messages (spec_graphrag §10.1).
Source code in varagity/graph/sources/imessage.py
96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 | |
matches(path)
¶
Report whether path is an iMessage chat.db (a cheap probe).
Three gates, cheapest first: a .db suffix, the 16-byte SQLite
magic header, and a message table in sqlite_master. Any read
or SQLite error means "not my format" — the probe never raises.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
Path
|
Candidate file. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
|
Source code in varagity/graph/sources/imessage.py
100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 | |
parse(path, *, verbose=0)
¶
Parse the database into messages, oldest first (spec_graphrag §10.1).
Reads handles, threads, and messages in three passes, folds tapbacks onto their targets, and skips rows with no recoverable body (counted in the summary log, never emitted blank). A message linked into more than one chat is emitted once (its first thread).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
Path
|
The |
required |
verbose
|
int
|
Validated console verbosity (0–2); the parse summary is
a |
0
|
Returns:
| Type | Description |
|---|---|
list[SourceMessage]
|
Every recoverable message, sorted by timestamp then guid. |
Source code in varagity/graph/sources/imessage.py
134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 | |
Records, engine seam, rendering, and synthesis¶
varagity.graph.records
¶
Engine-independent graph records (spec_graphrag §5.2, §10.2).
Every adapter normalizes its engine's native payloads into these models —
which is what let the ADR-017 bake-off score three very different engines
through one shape, and what keeps the seam benchmark-revisitable now that
one of them won. They are pydantic (not dataclasses) for the same reason
:class:~varagity.graph.sources.base.SourceMessage is: they go on the wire
(graph evidence in the SSE stream, the graph-export endpoint) and into
persisted eval results.
Two deliberate honesty rules run through the models:
- Optional counts mean "the engine cannot report this", not zero —
:class:
GraphStatsfields areNonewhen an engine exposes no way to ask (criterion §8.2#4 is measured honesty, not a hidden default). - :attr:
GraphEvidence.rawkeeps the engine-native payload so the ADR autopsy can look at what an adapter couldn't map, rather than at a silently lossy projection.
BuildReport
¶
Bases: BaseModel
What one :meth:~varagity.graph.base.GraphSession.build call did.
Attributes:
| Name | Type | Description |
|---|---|---|
messages_seen |
int
|
Messages handed to the engine after guid-merging the batches (the upsert grain — a second build over an overlapping batch sees the union, not the sum). |
wall_clock_s |
float
|
Seconds the indexing took. The headline bake-off number (§8.2#8) and the input to Phase 5's scheduling. |
failures |
list[str]
|
Human-readable failures the adapter caught and continued past (a rejected document, an extraction call that never returned). Empty means a clean build; the count is criterion §8.2#2 data, so failures are collected rather than raised. |
Source code in varagity/graph/records.py
187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 | |
EngineFormat
¶
Bases: BaseModel
Which engine wrote a graph's working directory, in which version of its files.
A graph backup is the workdir verbatim (ADR-019), so it is only as
portable as the engine's on-disk format: a bundle records this beside
its files, and a restore holds it to the running engine's. The engine
reports its own format (:meth:~varagity.graph.base.GraphEngine.storage_format)
— the registry is pluggable, so nothing outside an adapter names a
library.
Attributes:
| Name | Type | Description |
|---|---|---|
name |
str
|
The engine's registry name — what |
package |
str
|
The distribution whose code reads and writes the files
( |
version |
str
|
That distribution's installed version ( |
Source code in varagity/graph/records.py
291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 | |
GraphAnswer
¶
Bases: BaseModel
One engine's answer to one question, with its evidence.
Attributes:
| Name | Type | Description |
|---|---|---|
answer |
str
|
The generated answer text, |
evidence |
GraphEvidence
|
What the engine retrieved to produce it. |
mode |
str
|
The engine query mode actually used (the |
latency_s |
float
|
Wall-clock seconds for the whole query, retrieval and generation together. |
Source code in varagity/graph/records.py
165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 | |
GraphCommunity
¶
Bases: BaseModel
One community (cluster) summary the engine surfaced.
Only an engine with a community tier ever populates this, and the
shipped one has none (LightRAG builds no community layer at all — R1,
which is why the graph view clusters by entity_type instead). The
model stays because the seam does: an engine that does summarize
clusters slots in without a record change.
Attributes:
| Name | Type | Description |
|---|---|---|
id |
str
|
The engine's community identifier. |
title |
str | None
|
Human-facing community label ( |
summary |
str
|
The community's summary text. |
Source code in varagity/graph/records.py
59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 | |
GraphEntity
¶
Bases: BaseModel
One node the engine surfaced as evidence for an answer.
Attributes:
| Name | Type | Description |
|---|---|---|
name |
str
|
The entity's canonical name, as the engine resolved it. |
type |
str | None
|
The engine's entity type/category ( |
summary |
str | None
|
The engine's description of the entity ( |
Source code in varagity/graph/records.py
26 27 28 29 30 31 32 33 34 35 36 37 38 39 | |
GraphEvidence
¶
Bases: BaseModel
What the engine retrieved to support one answer (spec_graphrag §10.2).
Attributes:
| Name | Type | Description |
|---|---|---|
entities |
list[GraphEntity]
|
Nodes cited by the retrieval. |
relations |
list[GraphRelation]
|
Edges cited by the retrieval. |
communities |
list[GraphCommunity]
|
Community summaries cited by the retrieval (empty for engines with no community layer). |
message_guids |
list[str]
|
Source-message provenance — |
raw |
dict[str, Any] | None
|
The engine-native payload the mapping was derived from, kept
verbatim for the ADR autopsy ( |
Source code in varagity/graph/records.py
116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 | |
GraphExport
¶
Bases: BaseModel
A renderable slice of the whole graph (spec_graphrag §4.4).
Attributes:
| Name | Type | Description |
|---|---|---|
nodes |
list[GraphExportNode]
|
The exported entities, engine order (degree-descending for a whole-graph export). |
edges |
list[GraphExportEdge]
|
Every edge between exported nodes. |
truncated |
bool
|
Whether the cap hid nodes the graph actually holds — the honesty flag the view must surface rather than silently pretending it drew everything. |
Source code in varagity/graph/records.py
254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 | |
GraphExportEdge
¶
Bases: BaseModel
One relation in a graph export, flattened for the wire.
Attributes:
| Name | Type | Description |
|---|---|---|
id |
str
|
Stable edge identity within the export ( |
source |
str
|
Name of the edge's source entity. |
target |
str
|
Name of the edge's target entity. |
label |
str | None
|
Short relation label/keywords ( |
description |
str | None
|
The engine's longer description of the relation. |
Source code in varagity/graph/records.py
236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 | |
GraphExportNode
¶
Bases: BaseModel
One entity in a graph export, flattened for the wire.
Attributes:
| Name | Type | Description |
|---|---|---|
id |
str
|
The entity's canonical name — LightRAG's node id is the name. |
entity_type |
str | None
|
The engine's type/category, which is what the view colors and clusters by (LightRAG builds no community tier, so type is the honest grouping — ADR-017). |
description |
str | None
|
The engine's merged description of the entity. |
degree |
int
|
How many of this export's edges touch the node. It is a subgraph degree, not a whole-graph one: an export is capped and degree-ordered, so the number describes the picture being drawn. |
doc_keys |
list[str]
|
The transcript documents the entity was extracted from —
the drill-down join back to the corpus (a name in the graph view
resolves to the days it was said on, resolved through
:mod: |
Source code in varagity/graph/records.py
207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 | |
GraphRelation
¶
Bases: BaseModel
One edge the engine surfaced as evidence for an answer.
Attributes:
| Name | Type | Description |
|---|---|---|
source |
str
|
Name of the edge's source entity. |
target |
str
|
Name of the edge's target entity. |
label |
str | None
|
Short relation label/keywords ( |
description |
str | None
|
The engine's longer description of the relation — the
fact our synthesis grounds on (:func: |
Source code in varagity/graph/records.py
42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 | |
GraphRetrieval
¶
Bases: BaseModel
What one graph retrieval found, before any answer is written.
The unit ADR-017's retrieval-only design turns on: the engine finds
entities, relations, and transcript passages, and this repo writes the
answer over them — non-streaming for the harness
(:func:varagity.graph.answer.synthesize), streamed for a chat turn
(:func:varagity.pipeline.graph_flow.graph_query_stream_flow). Both
paths therefore ground on the same payload, which is what keeps the
eval graph harness a regression guard for the shipped query path.
Attributes:
| Name | Type | Description |
|---|---|---|
evidence |
GraphEvidence
|
Entities and relations the retrieval surfaced (plus communities, for an engine that has them — LightRAG does not). |
excerpts |
list[TranscriptExcerpt]
|
The retrieved transcript passages, in the engine's own relevance order. |
mode |
str
|
The engine query mode that produced it, as the caller named it
(a |
Source code in varagity/graph/records.py
140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 | |
GraphStats
¶
Bases: BaseModel
Size of the graph an engine holds, as far as it will say.
Every field is optional because "the engine exposes no count API" is a
real and reportable state — the incremental-reindex check reads these
before and after a delta build, and a None there is honest data for
the ADR rather than a zero that reads like an empty graph.
Attributes:
| Name | Type | Description |
|---|---|---|
entities |
int | None
|
Entity/node count, or |
relations |
int | None
|
Relation/edge count, or |
communities |
int | None
|
Community count, or |
Source code in varagity/graph/records.py
271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 | |
TranscriptExcerpt
¶
Bases: BaseModel
One retrieved transcript passage, normalized across engines.
The document-grain unit of graph evidence: a document-shaped engine retrieves text alongside its entities and relations, and that text is what a grounded answer is actually written from (plan decision #6 — the shipped query path grounds on the full retrieval payload, so the gate measures the same diet).
Grain is the transcript day-span, not the message: that is what the
engines can honestly attribute (ADR-017's priced regret), which is why
:attr:message_guids is a span of guids rather than one.
Attributes:
| Name | Type | Description |
|---|---|---|
doc_key |
str
|
The :attr: |
thread_name |
str
|
Human-facing thread label, for the answer's citation — resolved from the workdir manifest when it knows the key, so a chunk-grain hit carries the same label as a doc-grain hit of the same document (falls back to the passage's transcript header, then to the thread id). |
span |
str
|
The document's day span ( |
text |
str
|
The retrieved passage, verbatim. |
message_guids |
list[str]
|
Per-message provenance for the passage's document, or
|
Source code in varagity/graph/records.py
79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 | |
varagity.graph.base
¶
Graph-engine protocol and registry (spec_graphrag §5.2, §8; the spec §5.1 pattern).
Each engine module defines one adapter decorated with @register("name");
callers resolve one by name from :attr:~varagity.config.Settings.GRAPH_ENGINE
via :func:get_graph_engine. ADR-017 chose lightrag and the losing
bake-off adapters were deleted at stage-2 start; the seam is what stays,
so the decision remains benchmark-revisitable exactly as the retriever and
chat-engine registries are.
The family is split in two on purpose:
- :class:
GraphEngineis the registered singleton — stateless, zero-arg constructible, and cheap to import (adapters import their heavy engine libs inside :meth:GraphEngine.session, stage-1 decision #8). - :class:
GraphSessionholds every piece of per-run state — the engine handle, its working directory, its event loop, the transcript→guid index — behind a context manager, because teardown is not optional.
:meth:GraphSession.build takes a sequence of batches and upserts at
message grain: adapters guid-merge across source files
(:func:varagity.graph.render.merge_batches) before rendering, so an
overlapping chat.db upload or a re-export of a grown database never
duplicates a message. A second build call with an overlapping batch must
not duplicate either, and — the harder half — a changed document must not
be silently kept: the shipped adapter diffs rendered content hashes against
:mod:varagity.graph.manifest and deletes before re-inserting.
The rest of the protocol is what an application needs beyond a benchmark
(stage-2 decision #11): :meth:GraphSession.resume to finish a killed build,
:meth:GraphSession.retrieve to feed a streamed answer the app writes
itself, :meth:GraphSession.export to draw the graph,
:meth:GraphSession.delete_documents to retract sources, and
:meth:GraphSession.document_statuses to report progress. Sessions are
single-writer per working directory by engine invariant, which is why
exactly one process (the API) ever opens one.
GraphEngine
¶
Bases: Protocol
Opens working sessions against a graph engine (spec_graphrag §8).
It also names the format its working-directory files are in
(:meth:storage_format), which is what lets a graph backup refuse a
bundle another engine version wrote rather than hand it the wrong files.
runtime_checkable for the same reason
:class:~varagity.chat.base.ChatEngine is: the protocol will appear in
stage-2 Prefect flow signatures, and Prefect builds a pydantic parameter
schema from the annotations at decoration time, which requires types
usable with isinstance.
Source code in varagity/graph/base.py
199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 | |
session(workdir)
¶
Open a session whose state lives under workdir.
Engines self-store (plan decision #9): every byte an engine writes —
graph, vectors, KV, caches — belongs under workdir, so wiping it
is a clean slate and no compose service is involved.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
workdir
|
Path
|
The engine's working directory (created if absent). |
required |
Returns:
| Type | Description |
|---|---|
AbstractContextManager[GraphSession]
|
A context manager yielding the session; exiting it tears down |
AbstractContextManager[GraphSession]
|
whatever the engine started (subprocesses, event loops, stores). |
Source code in varagity/graph/base.py
214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 | |
storage_format()
¶
Name the format this engine's working-directory files are in.
What a graph backup records beside the workdir it copies (ADR-019), and what a restore holds a bundle to — the files are only as portable as the engine version that wrote them. Must stay import-light, like the rest of the registered surface: read the installed version from package metadata, never by importing the engine library.
Returns:
| Type | Description |
|---|---|
EngineFormat
|
The engine's registry name, its distribution, and that |
EngineFormat
|
distribution's installed version. |
Source code in varagity/graph/base.py
230 231 232 233 234 235 236 237 238 239 240 241 242 243 | |
GraphSession
¶
Bases: Protocol
One engine's open working session over one working directory.
Sessions are handed out by :meth:GraphEngine.session and are valid only
inside its with block.
Source code in varagity/graph/base.py
54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 | |
build(batches, *, verbose=0, prune_removed=True)
¶
Index (or re-index) messages into the graph — an upsert, not an append.
Re-callable by design: a build killed halfway through resumes by being called again, and one over an unchanged corpus costs no extraction at all.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
batches
|
Sequence[MessageBatch]
|
Parsed source files. Messages are guid-merged across them before rendering, so overlapping exports of the same conversation collapse to one message stream. |
required |
verbose
|
int
|
Validated console verbosity (0–2). |
0
|
prune_removed
|
bool
|
Whether |
True
|
Returns:
| Type | Description |
|---|---|
BuildReport
|
What the build did: messages seen, wall clock, and any failures |
BuildReport
|
the adapter caught and continued past. |
Source code in varagity/graph/base.py
61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 | |
delete_documents(doc_keys)
¶
Remove documents, and whatever the graph derived from them.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
doc_keys
|
Sequence[str]
|
Transcript document keys to retract. |
required |
Returns:
| Type | Description |
|---|---|
int
|
How many deletions the engine accepted. |
Source code in varagity/graph/base.py
187 188 189 190 191 192 193 194 195 196 | |
document_statuses()
¶
Count the engine's documents by processing status.
Returns:
| Type | Description |
|---|---|
dict[str, int]
|
Status name → document count, in the engine's own vocabulary, or |
dict[str, int]
|
an empty mapping when it would not say (which a progress reader |
dict[str, int]
|
must treat as "no news", never as "zero documents"). |
Source code in varagity/graph/base.py
156 157 158 159 160 161 162 163 164 | |
export(label='*', *, max_depth=3, max_nodes=5000)
¶
Read a renderable slice of the graph.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
label
|
str
|
Entity name to centre the slice on; |
'*'
|
max_depth
|
int
|
Hops to walk out from |
3
|
max_nodes
|
int
|
Node cap; the engine may clamp it further and says so
through :attr: |
5000
|
Returns:
| Type | Description |
|---|---|
GraphExport
|
The slice, empty when the graph cannot be read. |
Source code in varagity/graph/base.py
166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 | |
query(question, *, mode=None, verbose=0)
¶
Answer one question from the graph.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
question
|
str
|
The user's question, verbatim (golden |
required |
mode
|
str | None
|
Engine query mode; |
None
|
verbose
|
int
|
Validated console verbosity (0–2). |
0
|
Returns:
| Type | Description |
|---|---|
GraphAnswer
|
The answer with its graph evidence and the mode actually used. |
Source code in varagity/graph/base.py
130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 | |
resume(*, verbose=0)
¶
Finish whatever a killed build left in flight, without re-rendering.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
verbose
|
int
|
Validated console verbosity (0–2). |
0
|
Returns:
| Type | Description |
|---|---|
BuildReport
|
What the pass did. |
BuildReport
|
indexed, not a fresh hand-off. |
Source code in varagity/graph/base.py
93 94 95 96 97 98 99 100 101 102 103 | |
retrieve(question, *, mode=None, verbose=0)
¶
Find the evidence for one question, writing no answer at all.
The shipped query path's first half (ADR-017's retrieval-only
decision): the app streams its own grounded answer over what this
returns, so an engine must be able to retrieve without spending its
own answer call. :meth:query is this composed with an answer stage,
which is what keeps the harness measuring the shipped diet.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
question
|
str
|
The search query, verbatim — already condensed by the chat engine when the turn needed it (the answer prompt still gets the user's own words; spec_v3 §4.2). |
required |
mode
|
str | None
|
Engine query mode; |
None
|
verbose
|
int
|
Validated console verbosity (0–2). |
0
|
Returns:
| Type | Description |
|---|---|
GraphRetrieval
|
The evidence and transcript passages, empty when the engine |
GraphRetrieval
|
could not retrieve (a graph turn degrades to "no facts", it does |
GraphRetrieval
|
not raise). |
Source code in varagity/graph/base.py
105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 | |
stats()
¶
Report the current graph size, as far as the engine will say.
Returns:
| Type | Description |
|---|---|
GraphStats
|
Entity/relation/community counts, each |
GraphStats
|
exposes no way to ask (the incremental-reindex check reads this |
GraphStats
|
before and after a delta build). |
Source code in varagity/graph/base.py
146 147 148 149 150 151 152 153 154 | |
get_graph_engine(name)
¶
Look up a registered graph engine by name.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Registry key (e.g. |
required |
Returns:
| Type | Description |
|---|---|
GraphEngine
|
The registered engine instance. |
Raises:
| Type | Description |
|---|---|
KeyError
|
If no engine is registered under |
Source code in varagity/graph/base.py
266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 | |
register(name)
¶
Class decorator registering a graph-engine instance under name.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Registry key (the |
required |
Returns:
| Type | Description |
|---|---|
Callable[[T], T]
|
The decorator, which instantiates and registers the class unchanged. |
Source code in varagity/graph/base.py
249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 | |
varagity.graph.render
¶
Rendering messages into what the graph engine eats (spec_graphrag §5.2, §10.2).
Rendering lives here rather than in the adapter — pure functions over
:class:~varagity.graph.sources.base.SourceMessage are where most adapter
correctness actually is, and they are testable without the engine installed.
The shipped engine is document-shaped (ADR-017), so messages become thread
transcripts split on day boundaries (:func:thread_transcripts). The
episode-shaped rendering the bake-off's Graphiti seat ate went out with that
adapter (stage-2 loser removal); git history has it if an episode-shaped
engine ever re-enters the seam.
Upstream of it is :func:merge_batches, the upsert seam: source
files overlap (a re-export of a grown chat.db is a superset of the older
one), so messages are guid-deduped across batches before anything is
rendered. Rendering is then a pure function of the merged messages: the
same thread-days always produce the same :attr:TranscriptDoc.doc_key and
the same text, so an unchanged conversation stays upsert-identical no matter
how many times it is re-uploaded — which is exactly what
:mod:varagity.graph.manifest's content hashes turn into a build diff.
Purity is why nothing here reads :func:~varagity.config.get_settings: the
owner's display label rides in on
:attr:~varagity.graph.sources.base.SourceMessage.sender_name, which the
parser already resolved from GRAPH_OWNER_ALIASES. Re-deriving it at
render time would let a settings change silently rewrite documents that the
engines have already indexed under the same key.
TranscriptDoc
¶
Bases: BaseModel
One rendered thread transcript, as the document-shaped engines see it.
Attributes:
| Name | Type | Description |
|---|---|---|
doc_key |
str
|
Stable document identity, |
thread_id |
str
|
The conversation this document came from. |
thread_name |
str
|
Human-facing thread label (the header's subject). |
text |
str
|
The rendered transcript — a |
message_guids |
list[str]
|
Every message in the document, in transcript order — the map back from an engine's document-grain citation to per-message provenance. |
Source code in varagity/graph/render.py
55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 | |
doc_guid_index(docs)
¶
Index transcript documents by key, for mapping citations back to messages.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
docs
|
Sequence[TranscriptDoc]
|
Rendered transcripts. |
required |
Returns:
| Type | Description |
|---|---|
dict[str, list[str]]
|
|
Source code in varagity/graph/render.py
172 173 174 175 176 177 178 179 180 181 | |
guids_in_payload(payload, index)
¶
Recover message provenance from an engine-native payload.
Every adapter hands its engine document keys as the document ids/file
names it indexes under, so an engine that cites its sources cites those
keys — somewhere inside a shape this code deliberately does not model.
The walk is therefore structural rather than schema-aware: every string
in the payload (dict keys included, pydantic models dumped) is checked
against the index, exactly first and as a substring second, so a bare key,
a /path/to/key.txt, and a key<SEP>key join all resolve.
An engine that cites nothing simply yields [] — which is reported as
"no provenance", never as a zero score (criterion §8.2#4).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
payload
|
Any
|
Anything the engine returned (mapping, sequence, model, or string). |
required |
index
|
Mapping[str, Sequence[str]]
|
|
required |
Returns:
| Type | Description |
|---|---|
list[str]
|
The cited messages' guids, deduplicated, in the order the keys were |
list[str]
|
encountered. |
Source code in varagity/graph/render.py
184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 | |
merge_batches(batches)
¶
Guid-merge parsed source files into one ordered message stream.
The upsert seam (plan amendment, owner-approved 2026-07-25): overlapping
chat.db uploads and re-exports of a growing database share message
guids, so the union — not the concatenation — is what any engine should
index. First occurrence wins: batches are processed in the order given,
and a later batch's copy of an already-seen guid is dropped rather than
overwriting (a re-export's rows are the same rows; preferring the first
keeps a build reproducible regardless of upload order).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
batches
|
Sequence[MessageBatch]
|
Parsed source files, in caller order. |
required |
Returns:
| Type | Description |
|---|---|
list[SourceMessage]
|
Every distinct message, sorted by |
list[SourceMessage]
|
stable order the parser emits, so equal input renders equally. |
Source code in varagity/graph/render.py
82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 | |
thread_transcripts(messages, *, max_chars=DEFAULT_TRANSCRIPT_MAX_CHARS)
¶
Render messages into per-thread transcript documents split on day boundaries.
Days are the atom: consecutive days of one thread are packed into a
document until the next day would push it past max_chars, and a day
is never split in two. That keeps :attr:TranscriptDoc.doc_key meaningful
and stable, and costs nothing in practice because both document-shaped
engines chunk documents themselves before extraction. A single day whose
messages exceed the cap therefore becomes one oversized document, logged
rather than silently truncated.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
messages
|
Sequence[SourceMessage]
|
Merged messages (any order; they are re-sorted here). |
required |
max_chars
|
int
|
Size target per document, in characters, counted over the message lines (the header adds a short constant on top). |
DEFAULT_TRANSCRIPT_MAX_CHARS
|
Returns:
| Type | Description |
|---|---|
list[TranscriptDoc]
|
One document per thread-day-span, threads in first-appearance order |
list[TranscriptDoc]
|
and documents in chronological order within a thread. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Source code in varagity/graph/render.py
118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 | |
varagity.graph.answer
¶
Grounded answer synthesis over graph evidence (spec_graphrag §7; ADR-017).
ADR-017 chose LightRAG retrieval-only: the engine finds the evidence and this repo writes the answer, so a graph answer obeys the same grounding discipline as a chunk-RAG one. This module is that seam, shared by every engine adapter rather than reimplemented per engine — it started as the Graphiti adapter's private synthesis (the fact-shaped candidate had no answer pipeline of its own) and was generalized when it became the shipped design.
Two renderers, one diet: :func:synthesis_context grounds the harness's
non-streaming :func:synthesize (and the adapter's +synthesis modes),
while :func:graph_answer_context renders the same facts and passages in
the chunk-RAG [SOURCE] block shape the streamed API path answers from.
Keeping the two payloads identical is what lets eval graph keep guarding
the shipped query path.
Two rules run through it:
- The context is the whole retrieval payload, not just relation facts: entities and relations answer "who/what", but the transcript excerpts are where the archive actually says things (plan decision #6). An engine that retrieves both and is scored on facts alone under-measures itself.
- Nothing here raises. A failed synthesis returns
""— a scored miss in the harness and a degrade-to-honest turn in the app, never a dead run. An empty context short-circuits to :data:NO_EVIDENCE_ANSWERwithout spending an LLM call at all.
Sizing follows the same clamp discipline as
:func:varagity.graph.engines.lightrag.fit_max_tokens: llama.cpp with
context shift disabled hard-500s at the window instead of stopping
gracefully, and a retrieval payload is bounded by the engine's token budget
(LightRAG's max_total_tokens defaults far above a 16k window), so the
cap has to be applied here — see :func:synthesis_max_chars.
answer_context_max_chars(context_tokens, generation_tokens)
¶
Size a grounding context so prompt + answer fits the context window.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
context_tokens
|
int
|
The served model's context window
( |
required |
generation_tokens
|
int
|
The generation cap the answer call will request. |
required |
Returns:
| Type | Description |
|---|---|
int
|
The character budget, never below :data: |
Source code in varagity/graph/answer.py
100 101 102 103 104 105 106 107 108 109 110 111 112 | |
excerpts_block(excerpts, *, max_chars)
¶
Render retrieved transcript passages as labelled prompt blocks.
Whole excerpts are packed until the next one would not fit; a single
excerpt larger than the whole budget is truncated rather than dropped
(one oversized passage must not cost the answer its only grounding). The
label is [{thread_name} ({span})] — the same
:func:transcript_label the shipped query path cites with, so the model
learns one source format.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
excerpts
|
Sequence[TranscriptExcerpt]
|
Retrieved passages, most relevant first (the engine's order is preserved: packing drops from the tail). |
required |
max_chars
|
int
|
Character budget for the whole block. |
required |
Returns:
| Type | Description |
|---|---|
str
|
The blocks, blank-line separated, never longer than |
Source code in varagity/graph/answer.py
171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 | |
facts_block(evidence)
¶
Render retrieved relations and communities as prompt facts.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
evidence
|
GraphEvidence
|
The normalized evidence from a graph retrieval. |
required |
Returns:
| Type | Description |
|---|---|
str
|
One |
str
|
them), or |
str
|
which is what makes the synthesis call skippable. |
Source code in varagity/graph/answer.py
147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 | |
graph_answer_context(retrieval, *, max_chars)
¶
Render one graph retrieval into the chunk-RAG grounding context.
:func:synthesis_context's twin for the shipped streaming path
(stage-2 decision #5): the same diet — relation facts first, then as
many transcript passages as the budget holds — rendered in the shape
:data:varagity.generation.answer.ANSWER_PROMPT expects, so a graph
turn inherits the repo's grounding prompt, <think> splitting,
citation contract, abort handling, and token accounting whole.
The diets are deliberately identical: the eval graph harness scores
:func:synthesis_context, and it can only keep guarding this path while
both ground on the same evidence.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
retrieval
|
GraphRetrieval
|
What the engine found (entities and relations plus the transcript passages). |
required |
max_chars
|
int
|
Character budget for the whole context — size it with
:func: |
required |
Returns:
| Type | Description |
|---|---|
str
|
The context block, never longer than |
str
|
the retrieval surfaced nothing at all (which the grounding prompt |
str
|
turns into an honest "I don't know", not a fabrication). |
Source code in varagity/graph/answer.py
299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 | |
synthesis_context(evidence, excerpts=(), *, max_chars)
¶
Render one retrieval into the grounding context of a synthesis prompt.
Facts come first (they are small and they name the people), then as many
transcript excerpts as the remaining budget holds. Empty sections are
omitted entirely, so a retrieval that surfaced nothing renders to ""
and :func:synthesize can skip the model call.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
evidence
|
GraphEvidence
|
Entities, relations, and communities the engine retrieved. |
required |
excerpts
|
Sequence[TranscriptExcerpt]
|
Transcript passages the engine retrieved (empty for fact-shaped engines, which have no document plane). |
()
|
max_chars
|
int
|
Character budget for the whole context — size it with
:func: |
required |
Returns:
| Type | Description |
|---|---|
str
|
The context, never longer than |
Source code in varagity/graph/answer.py
256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 | |
synthesis_max_chars(context_tokens)
¶
Size the synthesis context so prompt + answer fits the context window.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
context_tokens
|
int
|
The served model's context window
( |
required |
Returns:
| Type | Description |
|---|---|
int
|
The character budget for :func: |
int
|
data: |
Source code in varagity/graph/answer.py
115 116 117 118 119 120 121 122 123 124 125 126 | |
synthesize(llm, question, context)
¶
Write a grounded answer over one retrieval's context.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
llm
|
LLMClient
|
The chat client (:class: |
required |
question
|
str
|
The question, verbatim — never the condensed or otherwise rewritten form (the repo-wide rule: the original words drive the answer prompt). |
required |
context
|
str
|
The rendered grounding context, from
:func: |
required |
Returns:
| Type | Description |
|---|---|
str
|
The |
str
|
context is empty (no model call), or |
str
|
a scored miss, never a raise. |
Source code in varagity/graph/answer.py
339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 | |
transcript_blocks(excerpts, *, max_chars)
¶
Render retrieved transcript passages as [SOURCE] provenance blocks.
:func:excerpts_block's twin for the streamed answer path, which is
read under :data:varagity.generation.answer.ANSWER_PROMPT rather than
:data:SYNTHESIS_PROMPT: same packing, same labels, but the chunk-RAG
block shape, so a graph turn's citations, chips, and evidence-card
matching all work through the machinery chunk turns already use.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
excerpts
|
Sequence[TranscriptExcerpt]
|
Retrieved passages, most relevant first. |
required |
max_chars
|
int
|
Character budget for the whole block. |
required |
Returns:
| Type | Description |
|---|---|
str
|
The blocks, blank-line separated, never longer than |
Source code in varagity/graph/answer.py
196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 | |
transcript_label(excerpt)
¶
Render the citation label of one transcript passage.
The one owner of the label, because it is matched three ways: the model
cites it out of the prompt, the evidence panel keys its cards by it, and
the persisted message_sources snapshot stores it. A day-grain label
is also the ADR-017 honesty line — the engines cannot attribute a single
message, so nothing here pretends to.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
excerpt
|
TranscriptExcerpt
|
The retrieved passage. |
required |
Returns:
| Type | Description |
|---|---|
str
|
|
Source code in varagity/graph/answer.py
129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 | |
Workdir state and the process-wide handle¶
varagity.graph.manifest
¶
The graph workdir's sidecar files — build diff and durable provenance.
Two small JSON files live beside the engine's own storage inside the
workdir, both owned by this module and both written atomically
(tmp + :func:os.replace, the discipline the engine's own storages use):
-
:data:
MANIFEST_FILENAME—doc_key → {content_sha256, message_guids, thread_name, span}for every transcript document the graph holds. It earns its keep twice (stage-2 decision #9): -
It is the upsert diff LightRAG refuses to do. The engine's enqueue stage drops a re-submitted
doc_idin any status, so a re-exportedchat.dbwhose most recent thread-day gained messages would silently keep the stale transcript. Comparing rendered content hashes against the manifest is what turns that into an explicit delete-then-reinsert (:attr:ManifestDiff.changed). -
It is a durable provenance index. Stage 1 rebuilt
doc_key → guidsinsidebuild()and lost it on every restart, so a re-opened session (or a--skip-buildre-score) could not map a citation back to messages. The manifest survives both. -
:data:
SUMMARY_FILENAME— the last known graph size, refreshed by build and delete. It is what :meth:~varagity.graph.base.GraphSession.statsand the Prometheus gauges read, so neither has to walk the graphml at request or scrape time.
Neither file is authoritative over the engine: both are caches of what the last write did. Anything unreadable, malformed, or written by a different schema version degrades to "no manifest" — i.e. every rendered document looks new, which is safe (the engine's own dedup absorbs the re-enqueue) where trusting a mis-shaped file would not be.
ManifestDiff
¶
Bases: BaseModel
What one rendered corpus changes about an indexed workdir.
Attributes:
| Name | Type | Description |
|---|---|---|
new |
list[str]
|
Document keys the graph has never seen. |
changed |
list[str]
|
Keys whose rendered content no longer hashes to what was
indexed — the engine must delete these before re-inserting,
since its enqueue stage drops an already-known |
unchanged |
list[str]
|
Keys whose content is byte-identical to what is indexed; re-inserting them would cost extraction for nothing. |
removed |
list[str]
|
Keys the workdir holds that this render does not mention.
A full-corpus render may delete them all; a bounded build
( |
Source code in varagity/graph/manifest.py
76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 | |
pending
property
¶
Keys that must be (re-)indexed: the new ones and the changed ones.
Returns:
| Type | Description |
|---|---|
list[str]
|
|
ManifestDoc
¶
Bases: BaseModel
One indexed transcript document, as the workdir remembers it.
Attributes:
| Name | Type | Description |
|---|---|---|
content_sha256 |
str
|
Hash of the rendered transcript text. Rendering is a pure function of the merged messages, so an unchanged conversation re-renders to an identical hash no matter how many times its source file is re-uploaded. |
message_guids |
list[str]
|
The document's messages, in transcript order — the provenance map behind a document-grain citation. |
thread_name |
str
|
Human-facing thread label at index time. |
span |
str
|
The document's day span ( |
Source code in varagity/graph/manifest.py
56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 | |
WorkdirManifest
¶
Bases: BaseModel
Every transcript document one workdir's graph was built from.
Attributes:
| Name | Type | Description |
|---|---|---|
version |
int
|
Schema version (:data: |
docs |
dict[str, ManifestDoc]
|
|
Source code in varagity/graph/manifest.py
110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 | |
diff(docs)
¶
Classify a freshly rendered corpus against what is indexed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
docs
|
Sequence[TranscriptDoc]
|
The rendered transcripts (see
:func: |
required |
Returns:
| Type | Description |
|---|---|
ManifestDiff
|
The four-way split. Rendered order is preserved within each |
ManifestDiff
|
bucket; |
Source code in varagity/graph/manifest.py
157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 | |
guid_index()
¶
Project the manifest into the provenance index the adapters use.
Returns:
| Type | Description |
|---|---|
dict[str, list[str]]
|
|
dict[str, list[str]]
|
func: |
dict[str, list[str]]
|
fresh render. |
Source code in varagity/graph/manifest.py
121 122 123 124 125 126 127 128 129 | |
merged(docs, *, prune, retain=())
¶
Fold a rendered corpus into a new manifest.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
docs
|
Sequence[TranscriptDoc]
|
The rendered transcripts the build just indexed. |
required |
prune
|
bool
|
Whether the render was the whole corpus. |
required |
retain
|
Collection[str]
|
Keys whose existing record must survive verbatim, for documents the engine refused to delete. Their old content is still in the graph, so writing the new hash would make the next build believe they are up to date and the stale transcript would become permanent — the exact failure this manifest exists to prevent. |
()
|
Returns:
| Type | Description |
|---|---|
WorkdirManifest
|
The new manifest (this one is left untouched). |
Source code in varagity/graph/manifest.py
216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 | |
message_guid_count()
¶
Count the distinct messages the manifest accounts for.
Returns:
| Type | Description |
|---|---|
int
|
The number of distinct guids across every document (documents |
int
|
never overlap in practice, but distinctness is what the number |
int
|
claims). |
Source code in varagity/graph/manifest.py
147 148 149 150 151 152 153 154 155 | |
respanned(removed, docs)
¶
Pick out the removed keys that are re-span casualties of one render.
Day-span packing is greedy, which makes document keys
window-relative: growing a bounded window backward shifts a thread's
pack boundaries, re-keying downstream documents whose messages did
not change. Their old keys then read as removed on every later
build, and keeping them (the bounded default) piles duplicate
transcripts — and inflated entity mentions — into the graph at each
growth step.
Full guid coverage is the proof that deleting one loses nothing: every message its record accounts for is in this render, being (re-)indexed under the new keys. Partial or unknown coverage — a record without guids proves nothing — reads as genuinely out-of-window content and is kept, per the bounded-build contract.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
removed
|
Sequence[str]
|
Removed keys from :meth: |
required |
docs
|
Sequence[TranscriptDoc]
|
The same build's rendered transcripts. |
required |
Returns:
| Type | Description |
|---|---|
list[str]
|
The removed keys a bounded build may safely prune, input order |
list[str]
|
preserved. |
Source code in varagity/graph/manifest.py
182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 | |
thread_name_index()
¶
Project the manifest into the display-name map the adapters use.
A retrieved chunk-grain passage carries no transcript header, so its excerpt would otherwise be labelled with the thread id while a doc-grain hit of the same document shows the rendered display name — two labels for one citation target. This index resolves both grains to the name the document was indexed under.
Returns:
| Type | Description |
|---|---|
dict[str, str]
|
|
dict[str, str]
|
Records without a name are omitted, so a miss falls through to |
dict[str, str]
|
the caller's next resolution step instead of blanking the label. |
Source code in varagity/graph/manifest.py
131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 | |
without(doc_keys)
¶
Drop documents that were deleted from the graph.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
doc_keys
|
Sequence[str]
|
The keys removed from the engine. |
required |
Returns:
| Type | Description |
|---|---|
WorkdirManifest
|
The new manifest (this one is left untouched). |
Source code in varagity/graph/manifest.py
256 257 258 259 260 261 262 263 264 265 266 267 268 269 | |
WorkdirSummary
¶
Bases: BaseModel
The last known size of one workdir's graph.
A cache, not a source of truth: it is written by whatever last changed the graph, so a workdir mutated behind the adapter's back reports what the adapter last saw. That is the deliberate trade — Prometheus scrapes and status polls must never parse a multi-megabyte graphml.
Attributes:
| Name | Type | Description |
|---|---|---|
entities |
int | None
|
Node count, or |
relations |
int | None
|
Edge count, or |
message_guids |
int
|
Messages the manifest accounts for. |
docs |
int
|
Transcript documents the manifest accounts for. |
refreshed_at |
datetime
|
When this summary was written (aware UTC) — the staleness signal for anything reading it. |
Source code in varagity/graph/manifest.py
272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 | |
content_sha256(text)
¶
Hash a rendered transcript for the build diff.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
text
|
str
|
The rendered document text. |
required |
Returns:
| Type | Description |
|---|---|
str
|
The hex digest of its UTF-8 bytes. |
Source code in varagity/graph/manifest.py
296 297 298 299 300 301 302 303 304 305 | |
load_manifest(workdir)
¶
Read a workdir's manifest, degrading to an empty one.
A missing, unreadable, malformed, or differently-versioned file yields an empty manifest: every rendered document then looks new, the engine's own dedup absorbs the re-enqueue, and the next build writes a correct file. The alternative — trusting a file whose fields may mean something else — would corrupt the graph silently.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
workdir
|
Path
|
The engine's working directory. |
required |
Returns:
| Type | Description |
|---|---|
WorkdirManifest
|
The manifest, or an empty one. |
Source code in varagity/graph/manifest.py
332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 | |
load_summary(workdir)
¶
Read a workdir's summary sidecar.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
workdir
|
Path
|
The engine's working directory. |
required |
Returns:
| Type | Description |
|---|---|
WorkdirSummary | None
|
The summary, or |
WorkdirSummary | None
|
answer by asking the graph itself). |
Source code in varagity/graph/manifest.py
376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 | |
manifest_path(workdir)
¶
Locate the manifest inside a workdir.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
workdir
|
Path
|
The engine's working directory. |
required |
Returns:
| Type | Description |
|---|---|
Path
|
The manifest's path (which may not exist yet). |
Source code in varagity/graph/manifest.py
308 309 310 311 312 313 314 315 316 317 | |
save_manifest(workdir, manifest)
¶
Write a workdir's manifest atomically.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
workdir
|
Path
|
The engine's working directory (created if absent). |
required |
manifest
|
WorkdirManifest
|
The manifest to persist. |
required |
Source code in varagity/graph/manifest.py
366 367 368 369 370 371 372 373 | |
save_summary(workdir, manifest, *, entities, relations)
¶
Refresh a workdir's summary sidecar atomically.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
workdir
|
Path
|
The engine's working directory (created if absent). |
required |
manifest
|
WorkdirManifest
|
The manifest the document/message counts come from. |
required |
entities
|
int | None
|
Node count from the graph, or |
required |
relations
|
int | None
|
Edge count from the graph, or |
required |
Returns:
| Type | Description |
|---|---|
WorkdirSummary
|
The summary that was written. |
Source code in varagity/graph/manifest.py
396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 | |
summary_path(workdir)
¶
Locate the summary sidecar inside a workdir.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
workdir
|
Path
|
The engine's working directory. |
required |
Returns:
| Type | Description |
|---|---|
Path
|
The sidecar's path (which may not exist yet). |
Source code in varagity/graph/manifest.py
320 321 322 323 324 325 326 327 328 329 | |
varagity.graph.service
¶
The process-wide graph engine handle (spec_graphrag §5.2; ADR-017).
One process, one session, one writer. The engine's storages are
single-writer per working directory by explicit invariant, so the graph
corpus can only stay coherent if exactly one process ever opens it — which
is why builds are API-only and there is deliberately no CLI graph build
(stage-2 decision #4). This module is that single owner: a lazily-opened
session behind a process singleton, in the shape
:func:varagity.api.ingest_runner.get_ingest_runner established.
Two locks, doing different jobs:
- A build lock, non-reentrant and single-flight — the write lock. A
second build attempt raises :class:
GraphBuildInProgressrather than queueing, because the caller (an HTTP route) wants a409, not a request that blocks for a day. Deletes take it too — they mutate the same graph — and so do a backup export and a backup restore (ADR-019), through :meth:GraphService.writing: holding it is what guarantees no file changes while the workdir is copied, and no build starts while it is swapped. - An open lock, so two concurrent first-callers cannot open two sessions over the same directory.
Reads deliberately take neither. The session multiplexes its engine calls onto a loop thread, so queries, exports, and status polls are answered while a multi-day backfill is still extracting (stage-2 decision #10). That is the whole reason the session's threading model changed at stage 2, and the reason the graph corpus is usable during its own backfill.
A backup restore swaps the whole workdir (ADR-019):
:meth:GraphService.replace_workdir moves a restored copy into place under
both locks — the write lock, so no build can start, and the open lock, so
no lazy open (a status poll, a /graph read) can land on a half-swapped
folder — and puts the previous graph back if the new one won't open.
Constructing the service finishes a swap that a crash interrupted, before
anything in the process can read the workdir.
Nothing here raises on a broken engine except :class:GraphUnavailable,
which callers turn into a structured degrade (ADR-017's degrade semantics) —
a graph the app cannot open must cost the graph turn, never the process.
GraphBuildInProgress
¶
Bases: RuntimeError
Raised when a build is requested while one is already running.
Source code in varagity/graph/service.py
106 107 | |
GraphService
¶
Owner of the process's single graph session (see the module docstring).
Source code in varagity/graph/service.py
114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 | |
building
property
¶
Whether a write — a build, resume, delete, or backup export or restore — holds it.
Returns:
| Type | Description |
|---|---|
bool
|
|
engine_name
property
¶
Name of the engine this service resolved.
Returns:
| Type | Description |
|---|---|
str
|
The registry key ( |
workdir
property
¶
Where the engine stores this process's graph.
Returns:
| Type | Description |
|---|---|
Path
|
The absolute working directory. |
__init__(engine_name=None, *, workdir=None)
¶
Resolve the engine and its working directory (both injectable).
Nothing is opened here: a session that opened at import time would
make an unbuilt graph an API startup failure, and would open the
workdir in every process that merely imports this module. The one
thing construction does on disk is finish a backup restore's swap
that a crash interrupted (:meth:_heal_interrupted_swap) — first,
before anything in this process can read, open, wipe or restore the
workdir.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
engine_name
|
str | None
|
Registry name of the engine; |
None
|
workdir
|
Path | None
|
The engine's working directory; |
None
|
Raises:
| Type | Description |
|---|---|
KeyError
|
If |
Source code in varagity/graph/service.py
117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 | |
build(batches, *, verbose=0, prune_removed=True)
¶
Upsert a rendered corpus into the graph, one build at a time.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
batches
|
Sequence[MessageBatch]
|
Parsed source files. |
required |
verbose
|
int
|
Validated console verbosity (0–2). |
0
|
prune_removed
|
bool
|
Whether |
True
|
Returns:
| Type | Description |
|---|---|
BuildReport
|
What the build did. |
Raises:
| Type | Description |
|---|---|
GraphBuildInProgress
|
If another build or delete is running. |
GraphUnavailable
|
If the session cannot be opened. |
Source code in varagity/graph/service.py
211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 | |
close()
¶
Tear the session down (API lifespan shutdown); idempotent.
A teardown that raised would take an orderly shutdown with it, so failures are logged and swallowed — the session is dropped either way, and the next call opens a fresh one. The teardown runs under the open lock, so a concurrent first caller waits for it instead of opening a second session over files the first is still flushing.
Source code in varagity/graph/service.py
352 353 354 355 356 357 358 359 360 361 362 | |
delete_documents(doc_keys)
¶
Retract documents from the graph.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
doc_keys
|
Sequence[str]
|
Transcript document keys to remove. |
required |
Returns:
| Type | Description |
|---|---|
int
|
How many deletions the engine accepted. |
Raises:
| Type | Description |
|---|---|
GraphBuildInProgress
|
If a build is running — deletion rebuilds the entities a document partly supported, which may not race extraction. |
GraphUnavailable
|
If the session cannot be opened. |
Source code in varagity/graph/service.py
252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 | |
document_statuses()
¶
Count the engine's documents by processing status.
Returns:
| Type | Description |
|---|---|
dict[str, int]
|
Status name → document count, empty when the engine would not say. |
Raises:
| Type | Description |
|---|---|
GraphUnavailable
|
If the session cannot be opened. |
Source code in varagity/graph/service.py
319 320 321 322 323 324 325 326 327 328 | |
export(label='*', *, max_depth=3, max_nodes=5000)
¶
Read a renderable slice of the graph.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
label
|
str
|
Entity name to centre on; |
'*'
|
max_depth
|
int
|
Hops to walk out from |
3
|
max_nodes
|
int
|
Node cap. |
5000
|
Returns:
| Type | Description |
|---|---|
GraphExport
|
The slice. |
Raises:
| Type | Description |
|---|---|
GraphUnavailable
|
If the session cannot be opened. |
Source code in varagity/graph/service.py
330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 | |
query(question, *, mode=None, verbose=0)
¶
Answer one question from the graph — never blocked by a build.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
question
|
str
|
The question, verbatim. |
required |
mode
|
str | None
|
Engine query mode; |
None
|
verbose
|
int
|
Validated console verbosity (0–2). |
0
|
Returns:
| Type | Description |
|---|---|
GraphAnswer
|
The answer with its evidence. |
Raises:
| Type | Description |
|---|---|
GraphUnavailable
|
If the session cannot be opened. |
Source code in varagity/graph/service.py
292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 | |
replace_workdir(staged, run_id, *, on_open=None)
¶
Swap a restored working directory in for the live one (a graph restore, ADR-019).
_reset_workdir's close → change → reopen move, made safe to lose.
Under the write lock throughout (no build, resume, delete or
export can start), and under the open lock for the renames (no
lazy open — a status poll, a /graph read — can land on a
half-swapped folder):
- close the session — the engine flushes what it buffers into the old files, and its process-global state is reset so the next open reads the new ones from disk;
- move the live workdir aside to
<workdir>.previous-<run>(when there is one), thenstagedinto its place; - open the restored graph and read its size — opening is what
checks the files (LightRAG asserts its vectors'
embedding_dim); - rename the previous graph to
<workdir>.replaced-<run>— the commit point: until it, any failure (or a crash, which the next process's construction heals) brings the previous graph back; after it, the restored graph stands and the replaced one is deleted.
A failure before the commit undoes the swap: the restored folder
goes back to staged (its owner removes it), the previous graph
returns and its session is reopened, and the error is re-raised.
Reads stay unlocked outside the renames, so a graph turn already streaming when the session closes can end in an error (chat refuses new graph turns for the whole restore, before they open one).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
staged
|
Path
|
The restored workdir, complete — at :meth: |
required |
run_id
|
str
|
The restore run's id (names the folders beside the workdir). |
required |
on_open
|
Callable[[], None] | None
|
Called once the restored folder is in place, just before
it is opened — the restore's |
None
|
Returns:
| Type | Description |
|---|---|
GraphStats
|
The restored graph's size, as its freshly opened session reports |
GraphStats
|
it. |
Raises:
| Type | Description |
|---|---|
GraphBuildInProgress
|
If a write holds the write lock (nothing was moved). |
GraphUnavailable
|
If the restored graph would not open (the previous one is back in place). |
OSError
|
If a rename fails (whatever had moved is moved back first). |
Source code in varagity/graph/service.py
398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 | |
resume(*, verbose=0)
¶
Finish whatever a killed build left in flight.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
verbose
|
int
|
Validated console verbosity (0–2). |
0
|
Returns:
| Type | Description |
|---|---|
BuildReport
|
What the pass did. |
Raises:
| Type | Description |
|---|---|
GraphBuildInProgress
|
If another build or delete is running. |
GraphUnavailable
|
If the session cannot be opened. |
Source code in varagity/graph/service.py
236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 | |
retrieve(question, *, mode=None, verbose=0)
¶
Find evidence for one question — never blocked by a build.
The chat path's read: the API streams its own grounded answer over what this returns, so no engine answer call is spent here.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
question
|
str
|
The search query, verbatim. |
required |
mode
|
str | None
|
Engine query mode; |
None
|
verbose
|
int
|
Validated console verbosity (0–2). |
0
|
Returns:
| Type | Description |
|---|---|
GraphRetrieval
|
The evidence and transcript excerpts. |
Raises:
| Type | Description |
|---|---|
GraphUnavailable
|
If the session cannot be opened (the caller turns that into ADR-017's per-turn degrade). |
Source code in varagity/graph/service.py
270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 | |
session()
¶
Return the open session, opening it on first use.
Returns:
| Type | Description |
|---|---|
GraphSession
|
The process's one session. |
Raises:
| Type | Description |
|---|---|
GraphUnavailable
|
If the engine's session cannot be opened. The failure is not cached — the next call tries again, so a transient one (a model service still warming up) does not need a restart to clear. |
Source code in varagity/graph/service.py
183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 | |
staging_dir(run_id)
¶
Name the folder a restore extracts a bundle into before the swap.
<workdir>.restore-<run>: beside the workdir, so on the same
filesystem — every rename of :meth:replace_workdir stays atomic.
Nothing is created here.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
run_id
|
str
|
The restore run's id. |
required |
Returns:
| Type | Description |
|---|---|
Path
|
The staging folder's path. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Source code in varagity/graph/service.py
380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 | |
stats()
¶
Report the graph's size.
Returns:
| Type | Description |
|---|---|
GraphStats
|
Entity/relation/community counts, each |
Raises:
| Type | Description |
|---|---|
GraphUnavailable
|
If the session cannot be opened. |
Source code in varagity/graph/service.py
308 309 310 311 312 313 314 315 316 317 | |
storage_format()
¶
Name the format the engine's workdir files are in (what a backup records).
Returns:
| Type | Description |
|---|---|
EngineFormat
|
The engine's :meth: |
EngineFormat
|
— read from package metadata, so this opens no session. |
Source code in varagity/graph/service.py
174 175 176 177 178 179 180 181 | |
writing()
¶
Take the single-flight write lock for the duration of a block.
The lock :meth:build, :meth:resume and :meth:delete_documents
take, public so a caller that must see the workdir hold still can
take it too — a backup export copies the files under it (ADR-019),
so no build, resume or delete can start mid-copy, while reads stay
unlocked and chat keeps answering (:meth:replace_workdir swaps a
restored workdir in under it). Never blocks: a held lock is an
answer (the caller's 409), not a wait.
Returns:
| Type | Description |
|---|---|
_WriteLock
|
A context manager holding the lock (released on block exit, |
_WriteLock
|
however the block ends). |
Raises:
| Type | Description |
|---|---|
GraphBuildInProgress
|
If it is already held. |
Source code in varagity/graph/service.py
640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 | |
GraphUnavailable
¶
Bases: RuntimeError
Raised when the graph engine's session cannot be opened.
Source code in varagity/graph/service.py
110 111 | |
get_graph_service()
¶
Provide the process-wide graph service (FastAPI dependency, override seam).
Returns:
| Type | Description |
|---|---|
GraphService
|
The lazily created singleton. Construction only resolves settings and |
GraphService
|
the registry — the engine session opens on first real use. |
Source code in varagity/graph/service.py
693 694 695 696 697 698 699 700 701 702 703 704 | |
graph_workdir(engine_name=None)
¶
Locate the working directory an engine stores this graph in.
The one derivation of "where the graph lives", shared by the service
and by anything that must read the workdir without going through the
engine — the Prometheus gauges, which resolve a path at scrape time and
must not depend on the engine registry being warm
(:mod:varagity.observability.graph).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
engine_name
|
str | None
|
Registry name of the engine; |
None
|
Returns:
| Type | Description |
|---|---|
Path
|
The absolute directory (relative workdirs have bitten this repo |
Path
|
before — an engine consumes the path verbatim, from whatever cwd it |
Path
|
happens to run in). It may not exist yet: nothing is created here. |
Source code in varagity/graph/service.py
83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 | |
Engine adapter¶
varagity.graph.engines.lightrag
¶
The LightRAG adapter — the shipped graph engine (spec_graphrag §8; ADR-017).
LightRAG is the document-shaped engine with the cleanest injection points:
a custom async llm_model_func and an EmbeddingFunc(embedding_dim=1024)
are first-class constructor arguments, so this is an engine where the
<think>-strip template applies fully — every extraction, gleaning, and
answer completion passes through :func:~varagity.models.llm.clean_response
before LightRAG parses it. A reasoning model's unstripped <think> block
breaks its delimiter grammar silently, which is exactly the trap the condense
and HyDE features already paid for.
Storage is the file-based default (NetworkXStorage + NanoVectorDBStorage
+ JSON KV/doc-status) inside the session's working directory (ADR-017 pinned
all four classes): LightRAG's Postgres graph plane needs the Apache AGE
extension, which is a different image than this stack runs.
One process, one writer, queries live throughout. The session drives a
private event loop on a daemon thread and submits every engine call with
asyncio.run_coroutine_threadsafe, so a chat query is answered while a
multi-day backfill is still extracting — which is how LightRAG's own server
behaves, and why :class:~varagity.graph.service.GraphService keeps exactly
one session per process (stage-2 decisions #10/#11). The engine's storages
are single-writer per workspace by explicit invariant, so nothing outside
this process may write the workdir; that is the reason there is no CLI graph
build.
Builds are diffing upserts, not appends. LightRAG's enqueue stage drops
a re-submitted doc_id in any status, so a re-exported chat.db whose
newest thread-day grew would silently keep the stale transcript.
:mod:varagity.graph.manifest records what was indexed; :meth:_LightRAGSession.build
deletes the genuinely changed documents before re-enqueueing them and pays
extraction for nothing else. Enqueue and process are separate engine calls,
and process re-selects every in-flight/failed document at the top of each
batch — so a killed build resumes by calling :meth:_LightRAGSession.resume.
Two more things this adapter owns, both recorded rather than hidden (spec §6):
- e5's asymmetric prefix rides
GRAPH_QUERY_PREFIX. The bake-off ran unprefixed (a recorded stage-1 deviation) because LightRAG appeared to call one embedding function for passages and queries alike. It does not:EmbeddingFunc(supports_asymmetric=True)forwards acontext="document"|"query"keyword to the hook (lightrag/utils.py:596-602), and the pinned vector store passes it on every search (kg/nano_vector_db_impl.py:429-431; alsooperate.py:4382for the dual-level keyword embeddings). Passages stay unprefixed either way — which is correct under e5 discipline — so the setting was measurable on the already-built bake-off graph without re-embedding anything, and the acceptance gate shipped it on. :func:varagity.models.embeddings.format_querystays the single owner of the query format. - Concurrency knobs are pinned through
os.environ. They are read at import time by LightRAG's own settings, so they must be set before the lazy import — the one place in this repo where writing environment variables is the mechanism rather than a smell (varagity's own config is still read only through :func:~varagity.config.get_settings).
Answer composition is ours, not the engine's (ADR-017's retrieval-only
decision). :meth:_LightRAGSession.retrieve is that half on its own: one
aquery_data call returning structured entities/relations/chunks with no
LLM answer stage. The app streams its answer over it
(:func:varagity.pipeline.graph_flow.graph_query_stream_flow), and a
<base>+synthesis mode composes the same retrieval with
:mod:varagity.graph.answer's non-streaming writer — which is what the
eval graph harness scores, so the harness keeps guarding the shipped
path. Unsuffixed modes keep the bake-off's engine-composed path verbatim, so
ADR-017's numbers stay reproducible.
LightRAGEngine
¶
LightRAG behind the :class:~varagity.graph.base.GraphEngine protocol.
Source code in varagity/graph/engines/lightrag.py
513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 | |
session(workdir)
¶
Open a LightRAG session storing everything under workdir.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
workdir
|
Path
|
The engine's working directory (created if absent). |
required |
Yields:
| Type | Description |
|---|---|
GraphSession
|
The open session; storages are initialized on entry and finalized |
GraphSession
|
on exit. |
Source code in varagity/graph/engines/lightrag.py
540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 | |
storage_format()
¶
Name the file format this engine's workdir is in: the installed lightrag-hku.
Every file in the workdir is LightRAG's own JSON/GraphML (plus the
repo's two sidecars), so the distribution's version is the
format's version. Read through :mod:importlib.metadata — the
registry must stay free of the engine library, which only
:meth:session may import (stage-1 decision #8).
Returns:
| Type | Description |
|---|---|
EngineFormat
|
|
Raises:
| Type | Description |
|---|---|
PackageNotFoundError
|
If |
Source code in varagity/graph/engines/lightrag.py
517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 | |
evidence_from_context(context, index)
¶
Normalize LightRAG's retrieval context into engine-independent evidence.
LightRAG returns its context as JSON (or as a formatted string, depending
on version and mode), so the mapping is alias-tolerant and every unknown
shape degrades to raw plus empty lists rather than raising — a
normalizer that throws would turn an answered question into a harness
crash.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
context
|
Any
|
Whatever |
required |
index
|
Mapping[str, Sequence[str]]
|
|
required |
Returns:
| Type | Description |
|---|---|
GraphEvidence
|
The normalized evidence. LightRAG has no community layer, so |
GraphEvidence
|
|
Source code in varagity/graph/engines/lightrag.py
365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 | |
export_from_knowledge_graph(graph)
¶
Flatten LightRAG's KnowledgeGraph into the wire-ready export.
The engine's own shape keeps everything it knows in a free-form
properties dict and reports no degree at all, so this projects the
two fields the view actually draws with (type, description), unpacks the
file_path provenance the drill-down joins on
(:func:_doc_keys), and counts each node's edges within the returned
slice — an export is capped and degree-ordered, so a slice-local degree
is the honest number for the picture being drawn.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
graph
|
Any
|
Whatever |
required |
Returns:
| Type | Description |
|---|---|
GraphExport
|
The export. An unknown shape degrades to an empty one rather than |
GraphExport
|
raising — the same rule the evidence normalizers follow. |
Source code in varagity/graph/engines/lightrag.py
451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 | |
fit_max_tokens(messages, cap, context_tokens)
¶
Clamp a generation cap so prompt + generation fits the context window.
The same discipline :func:varagity.models.llm._fit_max_tokens applies to
the app's own client, re-implemented here because LightRAG is handed a raw
async callable rather than an :class:~varagity.models.llm.LLMClient:
llama.cpp with context shift disabled hard-500s mid-decode at the window
instead of stopping gracefully, and LightRAG's extraction prompts are
large.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
messages
|
Sequence[Mapping[str, Any]]
|
The chat messages about to be sent (string contents are counted). |
required |
cap
|
int
|
The requested generation cap. |
required |
context_tokens
|
int
|
The served model's context window
( |
required |
Returns:
| Name | Type | Description |
|---|---|---|
int
|
The cap, reduced to fit. A prompt that leaves no room at all still |
|
gets |
int
|
data: |
int
|
keeps the failure legible in the engine's own error rather than in a |
|
int
|
zero-token request. A caller asking for less than the floor still |
|
int
|
gets what it asked for. |
Source code in varagity/graph/engines/lightrag.py
242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 | |
make_embedding_func(client, *, model, query_prefix=False)
¶
Build the async embedding callable LightRAG embeds documents and queries with.
Passages are always sent unprefixed, which is what e5 discipline requires
of documents. Queries are wrapped with
:func:~varagity.models.embeddings.format_query only when
query_prefix is on and the caller declared the call's side — which
LightRAG does through the context keyword its
EmbeddingFunc(supports_asymmetric=True) wrapper forwards. Both gates
matter: with supports_asymmetric off the wrapper strips context
before this function ever sees it, so the flag has to reach both.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
client
|
AsyncOpenAI
|
An async OpenAI-compatible client pointed at infinity. |
required |
model
|
str
|
Served embedding model name. |
required |
query_prefix
|
bool
|
Whether to apply e5's query-side instruction wrapper
( |
False
|
Returns:
| Type | Description |
|---|---|
Callable[..., Awaitable[list[list[float]]]]
|
A coroutine function mapping texts to their 1024-dim vectors, in |
Callable[..., Awaitable[list[list[float]]]]
|
order, accepting LightRAG's optional |
Source code in varagity/graph/engines/lightrag.py
330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 | |
make_llm_func(client, *, model, temperature, context_tokens)
¶
Build the async completion callable LightRAG drives every LLM stage with.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
client
|
AsyncOpenAI
|
An async OpenAI-compatible client pointed at llama.cpp. |
required |
model
|
str
|
Model name to send (llama.cpp echoes it — provenance, not routing). |
required |
temperature
|
float
|
Sampling temperature. |
required |
context_tokens
|
int
|
The served model's context window, for the clamp. |
required |
Returns:
| Type | Description |
|---|---|
Callable[..., Awaitable[str]]
|
A coroutine function with LightRAG's expected signature |
Callable[..., Awaitable[str]]
|
( |
Callable[..., Awaitable[str]]
|
whatever keyword arguments the calling stage passes) returning |
Callable[..., Awaitable[str]]
|
|
Source code in varagity/graph/engines/lightrag.py
277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 | |
retrieval_from_query_data(payload, index, names=None)
¶
Normalize an aquery_data payload into evidence plus transcript excerpts.
aquery_data is the structured retrieval API: it forces
only_need_context internally and returns
{"status", "data": {"entities", "relationships", "chunks",
"references"}, "metadata"} with no answer call, which is exactly
what ADR-017's retrieval-only design needs. A failure payload carries an
empty data, and — like every normalizer here — an unknown shape
degrades to empty lists rather than raising.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
payload
|
Any
|
Whatever |
required |
index
|
Mapping[str, Sequence[str]]
|
|
required |
names
|
Mapping[str, str] | None
|
|
None
|
Returns:
| Type | Description |
|---|---|
GraphEvidence
|
The evidence (entities/relations; LightRAG has no community layer) |
list[TranscriptExcerpt]
|
and the retrieved transcript passages, in the engine's own relevance |
tuple[GraphEvidence, list[TranscriptExcerpt]]
|
order — one per transcript document: |
tuple[GraphEvidence, list[TranscriptExcerpt]]
|
document through more than one arm, and the best-ranked hit wins |
tuple[GraphEvidence, list[TranscriptExcerpt]]
|
(:func: |
Source code in varagity/graph/engines/lightrag.py
399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 | |