Physical plans
The planner converts the logical plan into a physical plan: a graph of typed nodes that specifies exactly what the GPU and CPU execute. Each node has typed input and output ports, so the graph can be validated, serialized, and edited before execution.
Structure
Nodes and ports
Each node has typed input and output ports. An InputPort reads
from one OutputPort of another node. Value types include
DOCUMENT_IDS, FILTER_ANSWERS, JOIN_ANSWERS, and ROWS. The
graph validates that connected ports have matching types.
@dataclass(frozen=True)
class PhysicalNode:
node_id: str
inputs: tuple[InputPort, ...] = ()
type_name: ClassVar[str]
runtime_key: ClassVar[str]
location: ClassVar[ExecutionLocation]Node types
All node types are defined in physical/nodes.py.
Generic nodes (used by all backends):
| Node | What it does |
|---|---|
Scan | Names one tokenized input by alias. |
Barrier | Materializes survivor ids between join steps. |
Exchange | Routes documents across GPUs. |
Recombine | Joins TRUE answer tables using Arrow. |
Project | Reads the selected columns for surviving rows. |
Limit | Truncates the result. |
Quail backend nodes:
Node ids
Node ids are readable names like ai_filter:r, ai_join:r,
scan:r, barrier:e, apply:same_page, project.
Editing a plan
Physical plans are inspectable and editable. You get the plan, modify it, and run the modified version. Every edit returns a new immutable plan; the original is unchanged.
This is useful for:
- Database integration. Another query engine can call Quail's planner, inspect the physical plan, insert or remove nodes, and then execute the modified plan. The plan is a typed graph with a serialization format, so it can cross process boundaries.
- Custom operators. You can insert a user-defined
Foreignnode between two operators, or remove one that the planner added. - Debugging. Inspect the plan with
explain(), edit it, and compare the execution of different plans.
API
plan = query.plan()Get the physical plan without running it.
plan2 = plan.insert(node, between=("ai_filter:c", "ai_join:e"))Insert a node on the edge between two existing nodes. The new node must have one output of the same value type as the edge.
plan3 = plan2.remove("apply:same_page")Remove a node and rewire its consumers to its producer. Only
Foreign, Barrier, and Exchange nodes can be removed — removing
a Scan, AiFilter, or AiJoin would change what the query
computes.
plan4 = plan3.move("apply:same_page", between=("barrier:e", "ai_join:e"))Move a node from its current position to a new edge.
result = query.run(plan=plan4)Run the edited plan. After each edit, Quail re-validates the graph
(ports, types, cycles) and re-estimates every node. An illegal edit
raises PlanEditError.
Example: inserting a custom operator
from quail.physical.nodes import Foreign
plan = query.plan()
custom = Foreign(
node_id="apply:my_filter",
function="my_filter",
kind="per_batch",
)
edited = plan.insert(custom, between=("ai_filter:r", "ai_join:r"))
result = query.run(plan=edited)See Execution for how the plan runs on the GPU.
Serialization
Each node type has a NodeCodec for encoding and decoding across
the process boundary. plan_envelope wraps the encoded graph with
the backend, model, device, and worker count.