nano-graphrag is a research workbench for iterative evidence acquisition and
graph-guided question answering. Its current development focus is a table-fill
pipeline that can search broadly, preserve source-level evidence, and decide
numerically whether another unit of acquisition is likely to be useful.
The repository keeps two search media conceptually separate:
- The current table-fill acquisition binding searches the Web through Firecrawl, processes returned sources one at a time, and writes accepted evidence into typed result tables.
- GASL is a query language and execution engine for searching an explicitly supplied, immutable knowledge-graph revision. It lets an LLM use graph operations and interpret their results without treating the graph as a bag of retrieved text.
Firecrawl and GASL are peer search types in the method design. Searching the Web does not automatically add its results to a graph, and running GASL does not imply that a graph was updated. The first live experiments exercise the Firecrawl binding so that nesting, accumulation, and search adaptation can be measured without graph construction confounding the result.
Acquisition is one generic method, implemented by method_loop.Episode and
composed recursively at several levels. Each Episode chooses and processes one
unit at a time, measures what that unit added, and uses its numerical component
to decide whether to continue. A unit can be an ordinary item or another
Episode.
+------------------------------------------------------------------+
| |
v |
source reads this Episode's immutable Goal, starting context,
compact updates, and control state
| |
v |
choose the next unit |
| |
+-- no unit left ------------------------> publish Episode record |
| |
+-- ordinary item -> run tool -> accept and store results --+ |
| | |
+-- child Episode -> run child loop -> receive results ------+ |
| |
v |
results from one unit |
| |
v |
send result to bound controller |
update its numbers and decision |
| |
v |
save record; update memory |
| |
+-- stop --> publish record |
| |
+-- continue -------------------+
The numerical component is attached to the method; it does not contain the
method. question_pipeline/utilities/rarefaction.py owns the paired incidence
estimator and numerical controller used by question-pipeline Episode types.
method_loop/ owns iteration, nesting, stable record keys, and the Episode
record tree.
For table filling, a counted result means that one subject has an accepted value in one declared result column. A directly reported value and a best guess for that same subject and column count as the same result, so they cannot be double counted. Finding the same result in another source is recorded as a repeat.
The table-fill numerical component keeps a separate count and estimate for each result column, then combines them into one hypervolume credit. The predicted credit from the next unit is the single number used to stop or continue. The per-column estimates remain visible so people and future search strategies can see which columns are still sparse.
Every attempted unit records whether it was successfully evaluated. A unit that was evaluated and found nothing is a real zero. A failed tool call or failed evaluation is not treated as evidence that no useful result existed.
An Episode can process an ordinary item or run another Episode. A child finishes its own loop and becomes one unit of its parent. The current Firecrawl table-fill binding has this shape:
run Episode
└── strategy Episode
└── search Episode
└── page Episode selected from the Jev-scored Firecrawl buffer
├── source-table Episode
│ └── execute one query over parsed source rows
├── report Episode
│ └── process one ordered report window
└── lexical-probe Episode
└── process one non-table chunk
Here is the same tree using the real CATDAT document and recorded chunk outcomes from the earthquake experiment. The row-native probe string was supplied explicitly for the numerical replay, so this example demonstrates the composition without claiming that the live model generated that exact string:
run: fatal-earthquake table
└── strategy: search annual earthquake-loss compilations
└── search: "CATDAT damaging earthquakes year in review"
└── page: CATDAT 2012 report
├── source table: detected earthquake-loss table
│ ├── table-language program -> interprets rows plus table context
│ ├── entity mentions -> resolve duplicates and apply typed admission
│ └── source-table query -> submits admitted entities to the Goal
└── lexical probe: "Mw MMI USD"
├── chunk 52 -> fills fields from prose outside the table
└── next probe ranks only unprocessed non-table chunks
Each level answers a different question with the same method:
| Episode level | One unit | What ending the Episode means |
|---|---|---|
| source table | one deterministic query over parsed, unprocessed source rows | return control to the page after the source-table query space is exhausted or rarefied |
| report | one ordered prose window with source-linked memory | return control to the page after the report is exhausted or rarefied |
| lexical probe | one previously unprocessed ranked chunk | return control to the page so it can propose another vocabulary over the remaining chunks |
| page | one completed source-table, report, or lexical-probe Episode | finish this document and return its distinct findings to the search |
| search | one Page Episode selected by Jev relevance probability, with Firecrawl rank as tie-breaker | stop consuming that Firecrawl result list |
| strategy | one completed search Episode | stop pursuing that strategy family |
| run | one completed strategy Episode | end the declared acquisition run |
The source-table binding's child update carries distinct accepted Goal results.
The parent treats the completed child as one unit and recalculates its own
counts, estimates, and decision. It does not add together the child's
hypervolume and its own. The full child record is retained only in the trace;
the running parent receives a compact EpisodeUpdate.
PARENT EPISODE CHILD EPISODE
choose the child --------------------> run the child's own loop
|
v
write the full child trace
and build EpisodeUpdate
|
<-----------------------------+
|
v
send EpisodeUpdate.controller_input to
the parent's own controller
|
v
update the parent's estimates and decision
|
v
expose only EpisodeUpdate.prompt_context
to the parent's next source call
|
+----+----+
| |
continue stop
| |
v v
choose the close this Episode and build
next unit its own compact EpisodeUpdate
Information moves up one level whenever a child finishes:
chunk completes -> lexical-probe view updates locally
lexical probe closes -> page receives its distinct results
page closes -> search receives its distinct results
search closes -> strategy receives its distinct results
strategy closes -> run receives its distinct results
Each source sees the compact child updates and opaque controller state from its own Episode. It can use that history to propose different work next time. For example, a page can replace an unproductive lexical query with abbreviations found in the document, while a strategy can replace a saturated Web search with a different search angle. The model proposes the next string; the numerical component decides whether there should be another attempt.
The search parent is also a proposer. Firecrawl returns a ranked buffer with inline-scraped page text. Before any Page Episode opens, Jev evaluates every candidate against the immutable Search Goal. The search parent selects the remaining candidate with the greatest relevance probability; Firecrawl rank breaks ties. This changes Page-child order, not the stop rule: realized Page results still go to the search controller, which alone decides when that Search Episode ends. Every probability and selection is retained with the provider rank in the search record. Jev inputs are measured in tokens: each request has a 64,000-token budget, 5,000 tokens are reserved for the Goal, query, metadata, and decision instructions, and page text uses the remaining 59,000 tokens before lossless token-boundary windowing is required.
source_table_language/ is a reusable source-table interpreter, separate from both
the question pipeline and the generic Episode method. It detects tables in
HTML, Markdown, delimited text, and fixed-width text; exposes addressable rows
and nearby headings, legends, units, and footnotes; validates a small program
written by the model; and executes that program deterministically.
source document
|
v
detect table region + address its surrounding context
|
v
model writes a validated table-language program
|
v
parse rows -> map source columns/context to semantic entity fields
|
v
source-local mentions --candidate links--> semantic community resolution
|
v
apply the target's typed admission rules
|
v
admitted, evidence-linked mentions
|
v
source-table binding -> evidence acceptance -> current Goal storage -> credit
The target defines each semantic field once. Reported and best-guess storage columns may point to the same field, subject-key columns mark its identity role, and admission rules also refer to that field. A date field can therefore be projected into the answer, help identify an event, and enforce a question's time boundary without creating a second qualification-only field. The reusable language emits source-local mentions and candidate co-reference links. Only the question-pipeline binding knows how to submit those mentions to the current Goal implementation, which today is a typed result table. Source tables and the tabular Goal are different objects even when the former fills the latter.
A new Episode type is made by connecting new work to Episode; it does not
need a new loop. Put its reusable binding in its own module under
question_pipeline/episode_binding/. The binding owns that type's Grain,
source and unit types, Episode builder, local hooks, and compact parent update.
It does not choose its parent or concrete child type. The terminal chunk Leaf
has its own binding module but no Grain.
Every Episode also has one immutable EpisodeGoal. A root Goal has no parent.
A nested Goal is derived from the containing Goal, and the method rejects a
child whose Goal does not name that parent. The first call to next(view) is
initial planning under that Goal because view.updates is empty; later calls
replan under the same Goal with measured updates.
First, declare the level. State plainly what one turn processes and what useful result one turn can add:
grain = Grain(
name="document_search",
unit="one document returned by this search",
result="the binding-defined result returned by one document",
controller=controller_function,
)Next, write a source that uses the Episode's history to return one item at a
time. Return None when there are no more items:
class ToolSource:
def next(self, view):
# view.goal is immutable for the lifetime of this Episode.
# view.updates contains compact messages from completed children.
return choose_next_item(view)Bind the work performed on each item:
source = leaves(
units=ToolSource(),
extract=run_tool,
accept=validate_and_store_results,
result=make_controller_input,
label=lambda item: item.stable_name,
)
goal = EpisodeGoal.for_grain(
grain,
objective={"task": "find documents relevant to the declared question"},
)
episode = Episode(
grain=grain,
key="search-1",
source=source,
request=EpisodeRequest(goal=goal),
on_unit=handle_completed_unit,
)These functions have separate jobs:
| Function | Job |
|---|---|
next(view) |
choose the next item using the work already completed at this level |
extract(item) |
run the tool and produce candidate results |
accept(item, results) |
validate and store results that are supported by evidence |
result(item, accepted) |
build the input expected by this Grain's controller |
on_unit(...) |
update memory or write logs after the numerical decision has been recorded |
on_close(record) |
write this Episode's own full trace or checkpoint without giving it to the parent |
For the table-fill binding, result returns an IncidenceObservation whose
identities are separated by logical result column. A different Episode type may
bind a different result type and controller.
When the outer Episode uses run_async(), the source and bound functions may
also be asynchronous.
A stable result key names a subject and result column. The same result found again must return the same key, so the estimator records a repeat instead of a new finding. For example, every supported value for the deaths field of the same earthquake uses the same result key whether it came from a reported value or an accepted best guess.
To nest Episodes, make the parent's source return a child Episode instead of
an ordinary item:
class ChildEpisodeSource:
def __init__(self, build_child):
self._build_child = build_child
def next(self, view):
child_goal = EpisodeGoal.for_grain(
child_grain,
parent=view.goal,
objective={"task": "complete one child operation"},
)
return self._build_child(child_goal) # or None when finishedThe child also needs a to_parent function. It converts the full child trace
into the compact message the parent actually needs:
child = Episode(
grain=child_grain,
key="child-1",
source=child_source,
request=EpisodeRequest(goal=child_goal),
to_parent=lambda record: EpisodeUpdate(
record_id=record.episode_id,
goal=record.goal,
controller_input=combine_child_results(record),
prompt_context=summarize_for_parent_prompt(record),
),
)The parent receives that update as one unit. The full EpisodeRecord remains
in the recursive trace for audit but is not placed in the parent's source view
or prompt. Do not write a loop around either Episode; calling run() or
run_async() on the outer Episode runs the complete nested tree.
Finally, export the binding type from episode_binding/__init__.py and connect
it in pipeline.py. The package initializer assembles the provider binding;
the pipeline entry point supplies the configured collaborators and starts the
root Episode once. The names and number of levels depend on the composition;
they are not fixed by the method. Controller configuration is already captured
by each Grain's controller function; Context does not know its schema:
ctx = Context(
tree=EpisodeTree.linear((outer_grain, child_grain)),
)
record = await outer_episode.run_async(ctx)Keep accepted-table projection in utilities/tables.py and evidence records in
utilities/evidence.py; neither is an Episode binding. Pass each binding only
the collaborators it calls rather than placing every dependency in one shared
context object.
Tool calls and model calls belong in next or extract. Evidence checking and
storage belong in accept. Counting must be a direct calculation from the
accepted stored results. The estimator and controller make the numerical
continue-or-stop decision.
The complete method contract is
docs/ACQUISITION_LOOP.md. Where another document
describes a phase-batched round loop or gives the numerical component ownership
of Episode, that description is stale.
method_loop/ generic Episode method: iteration, nesting, runtime
identity, scope routing, child-to-parent results,
and record trees
source_table_language/ reusable source-table language, deterministic
executor, and mention-community boundary
question_pipeline/ Firecrawl/table-fill application and future GASL
Episode integration
pipeline.py visible composition entry point
episode_binding/ one *_binding.py module per Episode type
utilities/ one module per supporting responsibility:
acquisition, evidence, extraction, model,
rarefaction, replay, search, and tables
run_question_pipeline.py command-line entry point
gasl/ graph query language and execution engine over an
explicitly supplied graph revision
nano_graphrag/ graph ingestion and construction substrate
domain_schemas/ reusable typed extraction schemas
experiments/ registered predictions and experimental records;
production code never imports from here
question_runs/ local run artifacts; not committed to GitHub
docs/ design charters, build trackers, and invariants
python3 -m venv .venv
. .venv/bin/activate
pip install -r requirements.txt
FIRECRAWL_API_KEY=... LLM_API_KEY=... TYPESAFE_API_KEY=... \
.venv/bin/python run_question_pipeline.py \
--pipeline-mode table-fill \
--question "Which fatal earthquakes since 1900 have reported magnitude, deaths, injuries, displacement, and economic damage?" \
--schema earthquake_impact_two_grain \
--output-dir question_runs/earthquake_exampleFirecrawl may return many results in one provider response, but that response is only a buffer. Jev scores the returned page texts against the Search Goal; the Search Episode then processes the highest-probability remaining page, accepts and credits its evidence, and evaluates the numerical verdict before selecting another Page child. Firecrawl rank remains the tie-breaker. There is no papers-per-query or rounds control.
Resume a durable Episode checkpoint with:
.venv/bin/python run_question_pipeline.py \
--continue question_runs/earthquake_exampleContinuation is accepted only when checkpoint.json, its state generation,
and the run's Goal, evidence, source, and completed-Episode artifacts all match
one recorded commit. Validation occurs before the pipeline is constructed. A
changed or incomplete run is refused rather than combined with checkpoint
state. Seed arguments start a new run from imported results; they do not resume
an interrupted Episode. Checkpoints from before episode_checkpoint_v2 do not
carry this guarantee and cannot be continued by the current runner.
The module docstring and --help output of run_question_pipeline.py are the
authoritative CLI references. Runs write source material, evidence-registry
records, typed tables, Episode trees, numerical verdicts, learning records,
and checkpoint.json beneath their output directory. AGENTS.md contains the
operator rules for live runs.
GASL provides explicit graph operations rather than treating graph context as ordinary retrieved prose. It can traverse, connect, filter, and inspect graph structure, then let an LLM interpret the returned subgraph for a scientific question. Its value is reuse of previously structured evidence, relationship analysis, and inspectable graph operations. GASL uses only the graph revision given to it; graph enrichment is a separate, reviewable workflow.
See docs/GASL_GUIDE.md for the language and docs/RUNTIME_INVARIANTS.md for its runtime boundaries.
There is no test suite in this repository, and none is to be created. Behavior is verified with registered live experiments on the real provider and model interfaces. Each experiment states its claim, numerical measurements, and falsifier, and reports out-of-scope failures separately from the mechanism being tested. Numerical-only shadow recalibration can replay immutable observed identities through a controller without pretending to re-run acquisition.
See docs/CONTROL_LAYER_EXPERIMENTS.md and
experiments/README.md for the evidence standard. Generic GASL runtime changes
also use .venv/bin/python tools/check_runtime_invariants.py.
- The Acquisition Control Loop — governing method and numerical-control design
- Next Step — current implementation and experimental handoff
- Control Layer Build Tracker — implementation phases and gates
- Control Layer Experiments — live-run evidence requirements
- Runtime Invariants — GASL layering and runtime rules
- GASL Guide — language and commands
- Graph Building — explicit graph-ingestion substrate
