Architecture¶
This page is for people changing QProgram rather than using it: what lives
where under src/qprogram/, which direction the imports run between those
modules, and the patterns that recur across the package. Most of the code below
is package source, each snippet headed by the file it comes from and keeping
that file's intra-package imports. One snippet is source from a vendor package,
headed by a # qprogram-<vendor>/... path comment: that is external code, so it
reaches QProgram symbols through qp. and uses its own dotted paths for its own
modules. The rest is ordinary code written against the installed package, which
reaches everything through qp..
Repository layout¶
qprogram/
├── LICENSE # Apache-2.0
├── README.md
├── CHANGELOG.md # assembled by towncrier from fragments in changelog/
├── pyproject.toml # metadata, deps, extras, ruff / ty / pytest / towncrier config
├── zensical.toml # docs site config and the explicit nav (this site)
├── uv.lock
├── .github/workflows/ # tests, code quality, docs, publish
├── .claude/skills/qprogram-docs/ # the documentation writing guide and its checker
├── docs/ # markdown sources for this site
├── tests/ # 1582 tests, roughly one module per source area
│ ├── conftest.py # shared schema / program / waveform fixtures
│ └── _dummy_vendor.py # a complete in-tree vendor extension, used as a fixture
└── src/qprogram/
├── __init__.py # the public surface: 105 names, parser entry points lazy
├── py.typed # PEP 561 marker: the package ships its own annotations
├── qprogram.py # QProgram builder, control-flow contexts, vendor registry
├── buses.py # BusSchema, BusRef, BusNaming, typed presets
├── variable.py # the symbolic Expression AST
├── fragments.py # Fragment / Parameter: named, parameterized sub-programs
├── waveform_library.py # per-bus waveform-name resolution, the .wfl format
├── result.py # MeasurementHandle, MeasurementResult, QProgramResult
├── errors.py # exception hierarchy
├── _reserved.py # RESERVED_KEYWORDS
├── _structural.py # ast_eq / ast_hash helpers
├── vendor.py # VendorNamespace base
├── platform.py # PlatformProtocol: capabilities, validate, plan, explain, execute
├── protocol.py # capability descriptors, Diagnostic, Profile, token registry
├── profiles.py # QPROGRAM_BASE_V1, the core platform-level profile
├── validation.py # the two-pass validator + domain classifier
├── paths.py # structural node paths: node_path / resolve_path / format_path
├── explain.py # renders an execution plan as an annotated tree
├── optimization.py # optimize(): plan-improving program rewrites
├── executor.py # ReferencePlatform + simulate(): the reference interpreter
├── lsp.py # check_text(), the check|explain|serve CLI, the language server
├── operations/ # one module per leaf op, plus the Operation base
├── blocks/ # Block, Sweep, Average, Parallel, Conditional
├── sweeps/ # SweepSource contract, built-in sources, combinators
├── waveforms/ # Waveform / IQWaveform bases and 17 built-in shapes
├── grammar/
│ ├── __init__.py # grammar_text(), the reference Lark parser
│ └── qp.lark # the normative machine-readable grammar
└── serialization/
├── writer.py # dumps / save
├── parser.py # loads / load
├── registry.py # registry-driven dispatch
├── _specs.py # per-op serialize/parse callbacks
└── _format.py # the format version constant
qprogram is the whole language: the AST, the .qp format, the capability
protocol, the validator, and the reference executor. It depends on numpy>=2.1
and xarray>=2026.4.0 at runtime and nothing else; matplotlib (the viz
extra) and pygls (the lsp extra) are optional, and lark is a development
dependency used only to cross-check the grammar. A vendor extension is a
separate package in its own repository that depends on qprogram and registers
itself on import. See Building a vendor extension.
tests/_dummy_vendor.py is a complete vendor extension living in the test
suite. Its activate() runs the same registration calls an installed extension
runs on import, and deactivate() pops the same entries back out, so a fixture
can install and remove the vendor at well-defined points. It ships no
pyproject.toml, so entry-point discovery is covered separately, with a stub
entry point, in tests/test_vendor_discovery.py.
What each module owns¶
The AST is the center of the package. qprogram.py holds the QProgram
builder, the private context-manager classes its control-flow methods return,
the class-level vendor-namespace registry, and the whole-program transforms
(expand, rebind, with_waveforms). blocks/ holds the container nodes:
the Block base plus Sweep, Average, Parallel, and Conditional.
operations/ holds one module per leaf node (12 classes, from play and
measure through set_parameter and the fragment Call), plus the
Operation base and the MeasurementField vocabulary in operation.py.
Four vocabularies feed those nodes. variable.py is the symbolic expression
AST: Expression and its Variable, Constant, BinaryOp, UnaryOp,
Comparison, LogicalBinaryOp, LogicalNot, MathFunc, Where, and
MeasurementRef nodes. waveforms/ holds the Waveform and IQWaveform
bases and 17 shapes, one per module. sweeps/ holds the SweepSource
contract, the five parameter-only sources in builtin.py (Range, Values,
Linspace, Logspace, File) and the three that wrap another source in
combinators.py (Repeat, Rotate, Concat). buses.py holds BusSchema,
BusNaming, and BusRef, which subclasses str so a typed reference is a
plain string everywhere downstream.
The rest of the AST layer is supporting structure. fragments.py holds
Fragment, Parameter, and the expand_program lowering that inlines every
call site. result.py holds MeasurementHandle, MeasurementResult, and
QProgramResult. waveform_library.py resolves a waveform alias per bus and
owns the .wfl text format, which is deliberately not part of a .qp file:
calibration state travels alongside a program, not inside it. errors.py
defines the whole exception hierarchy under QProgramError, including the
platform-side classes that core QProgram never raises but every backend shares.
_reserved.py holds RESERVED_KEYWORDS, and _structural.py the two equality
helpers described below.
Analysis sits above the AST. protocol.py defines what a platform declares:
PlatformCapabilities (per-bus profiles plus one platform-wide profile),
BusCapabilities(rt, host), CompilerCapabilities for a single slot,
Domain, DomainConstraint, Diagnostic, Profile, ValidationContext, and
the two registries behind them, CAPABILITY_REGISTRY (67 core tokens) and
PROFILE_REGISTRY. profiles.py registers QPROGRAM_BASE_V1, the
platform-level base of block, sweep, and expression tokens, as a side effect of
import qprogram. validation.py is the two-pass validator: a per-node
capability check and a bottom-up domain classification, returning a list of
Diagnostics and an ExecutionPlan. paths.py gives every node a structural
address that survives a .qp round-trip, which is how a diagnostic maps back
to a line. explain.py renders a plan as an annotated tree, and
optimization.py applies the one rewrite the validator's
reorderable-averaging hint suggests, sharing the match decision with the
validator so the hint and the rewrite cannot disagree.
Execution and tooling sit at the top. platform.py defines
PlatformProtocol, the seam a backend implements; its validate, plan, and
explain have working defaults, so a concrete platform supplies its resources,
its PlatformCapabilities, and execute. executor.py is ReferencePlatform
and simulate(), the in-tree interpreter that defines the reference semantics
vendor compilers are tested against. serialization/ is the .qp writer,
parser, registries, and per-operation callbacks. grammar/ ships qp.lark,
the normative grammar, and builds a reference Lark parser from it for the CI
cross-check. lsp.py exposes the real toolchain to editors through
check_text() and a check | explain | serve command line.
Which way the imports run¶
The floor of the package is seven modules that anything else may import without
ordering concerns: _reserved.py, _structural.py, errors.py, buses.py,
protocol.py, platform.py, and serialization/_format.py. None of them
imports from qprogram at module scope, which is what makes them safe to reach
from anywhere: protocol.py is what every operation, block, and sweep source
has to reach for its capability tokens, and platform.py is what a vendor
package subclasses. Two more modules have no module-scope package import
either, but they sit at the far end of the tree rather than under it: nothing in
src/qprogram/ imports lsp.py or grammar/__init__.py, and each defers an
optional dependency into a function body alongside its package imports, pygls
and lsprotocol from the lsp extra in lsp.py and the dev-only lark in
grammar/__init__.py.
Above that floor the direction is one way, from the AST outward. The
vocabularies (variable, waveforms/waveform, sweeps/source) import only
the floor. operations/operation.py imports the vocabularies; the concrete
operations import the base; blocks/ imports the Block base and, in Sweep,
the sweep-source contract, naming Operation only under TYPE_CHECKING; and
qprogram.py imports blocks, operations, buses, sweeps, waveforms, and the
waveform library. Analysis imports the AST (validation reads blocks and
operations, explain reads validation and the writer), execution imports
analysis (executor imports platform and validation), and serialization
imports the AST plus its own registries. Nothing in the AST layer imports the
analysis, execution, or serialization layers at module scope.
Where an edge genuinely has to run the other way it is deferred into a function body instead of the module scope. The main cases:
| In | Deferred import | Resolved when |
|---|---|---|
operations/*.py |
qprogram.protocol |
required_capabilities() needs a waveform or expression token |
qprogram.py |
qprogram.fragments |
call() binds arguments, or expand() lowers a call |
qprogram.py |
qprogram.serialization.registry |
a fluent from_* sweep builder is resolved |
waveforms/waveform.py |
qprogram.waveforms.chained |
two envelopes are joined with | |
waveform_library.py |
qprogram.serialization.writer / .parser |
a .wfl file is written or read |
platform.py |
qprogram.validation, qprogram.explain |
a default validate / plan / explain runs |
protocol.py |
qprogram.buses, .variable, .waveforms, .paths |
a routing, token, or path helper is called |
The reasons differ but the shape does not. protocol.py needs paths.py to
stamp a diagnostic, and paths.py imports qprogram.py, so an eager import
there would pull the builder into the descriptor module. The sweep builder's
from_* lookup initializes the entire qprogram.serialization package, which
nothing in the builder needs until a reader actually writes from_range(...).
Every deferred import in the package carries the annotation ruff wants
(# ruff: ignore[import-outside-top-level]), so a deferred import is always
visible as a decision rather than an accident.
The parser's lazily resolved names¶
loads, load, and ParseError are the only names in __all__ that
qprogram/__init__.py does not import at module scope. They are resolved on
first attribute access instead:
# src/qprogram/__init__.py
def __getattr__(name: str):
if name in {"loads", "load", "ParseError"}:
from qprogram.serialization.parser import ParseError, load, loads
return {"loads": loads, "load": load, "ParseError": ParseError}[name]
msg = f"module {__name__!r} has no attribute {name!r}"
raise AttributeError(msg)
qprogram/serialization/__init__.py carries the same three names behind the
same __getattr__. The parser is the one module that sits above almost
everything: it constructs QProgram, Fragment, BusSchema, BusRef,
MeasurementHandle, and Variable instances, and it reaches back into
qprogram.serialization for the spec callbacks. Importing it from either
__init__.py closes a cycle in the module graph, through a package that is
still executing its own import block.
The effect a caller can observe is that import qprogram does not load the
parser at all:
import sys
import qprogram as qp
"qprogram.serialization.parser" in sys.modules # False
qp.loads # resolves the name, which imports the parser now
"qprogram.serialization.parser" in sys.modules # True
qprogram.lsp and qprogram.grammar stay out of that set too, for the same
reason from the other end: neither is imported by __init__.py, so the
optional pygls and lark dependencies are only reached by a caller that
asks for them.
The builder and the block stack¶
QProgram is a fluent builder over a stack of open blocks. __init__ creates
the root Block and puts it on the stack; _active_block is the top of that
stack; every operation appender goes through _append_to_active.
# src/qprogram/qprogram.py, abridged
class QProgram:
def __init__(self, label="", description=None, schema=None):
self._body = Block()
self._block_stack: deque[Block] = deque([self._body])
@property
def _active_block(self) -> Block:
return self._block_stack[-1]
def play(self, bus: str, waveform: Waveform | IQWaveform | str) -> None:
self._validate_bus(bus)
_validate_waveform_channel(bus, waveform)
self._append_to_active(Play(bus=bus, waveform=waveform))
Past the read-only properties (body, schema, buses, variables,
fragments, source_map), the measurement_handles() accessor, and the two
declaration helpers (variable, register_vendor), every method on the builder
is one of three kinds. Operation appenders (play, measure, wait, sync,
set_frequency, set_phase, set_gain, set_offset, reset_phase,
set_parameter, get_parameter, call) construct one Operation and append
it to the active block, after checking what can only be checked at the call
site: that the bus belongs to this program's schema, and that a concrete
waveform's channel count matches the bus's.
Control-flow methods (sweep, average, block, and the if_ / elif_ /
else_ chain) return a context manager rather than a node. Its __enter__
appends the new Block to the active block and then pushes it onto the stack;
its __exit__ pops it. Nesting with statements therefore nests blocks, and
the depth of the Python indentation is the depth of the tree. The
context-manager classes are private, and none of them is meant to be
constructed directly.
Two details of the stack are worth knowing before changing it. The if_ chain
is not a stack discipline: elif_ and else_ mutate the Conditional that
if_ left in _pending_conditional and push a new arm body themselves, which
is why they bypass _append_to_active. That pending chain is closed by the
first append that lands at the conditional's own parent level, since anything
other than an elif_ or else_ there would make the chain ambiguous. And
_LoopContext.__or__, repeat, and rotate are all pure: each returns a
fresh context and touches the program only in __enter__, which is what lets
functools.reduce(operator.or_, ...) fold a list of sweeps into one Parallel
block.
Transformers (expand, rebind, with_waveforms) never mutate. Each deep
copies the program and rewrites the copy: expand replaces every fragment
Call with the fragment body inlined, rebind re-resolves schema-backed bus
references through a schema factory so the result is still a typed BusRef,
and with_waveforms resolves string waveform aliases per bus against a
WaveformLibrary.
Operations and blocks¶
The AST has exactly two kinds of node, and they share one introspection
contract. Operations are the leaves: a typed class whose __init__ parameters
are its attributes, with nothing hidden.
# src/qprogram/operations/play.py
class Play(Operation):
WAVEFORM_ATTRS: ClassVar[tuple[str, ...]] = ("waveform",)
def __init__(self, bus: str, waveform: Waveform | IQWaveform | str) -> None:
self.bus = bus
self.waveform = waveform
Blocks are the containers. Block itself is an ordered list of children;
Sweep, Average, Parallel, and Conditional subclass it to add structure
the validator, writer, and executor understand.
# src/qprogram/blocks/block.py, abridged
class Block:
REPEATS: ClassVar[bool] = False
def __init__(self) -> None:
self._elements: list[Block | Operation] = []
def append(self, element: Block | Operation) -> None: ...
def walk(self) -> Iterator[Block | Operation]: ... # self, then descendants, pre-order
def variables(self) -> set[Variable]: ...
def buses(self) -> set[str]: ...
def waveforms(self) -> set[Waveform | IQWaveform | str]: ...
def required_capabilities(self) -> set[str]: ...
Operation implements all of those but append, so a caller can write
for node in program.body.walk(): and treat what comes back uniformly.
Operation.walk() yields just the leaf, and variables() walks every public
attribute, descending into expressions, waveform parameters, and lists.
buses() and waveforms() take the attribute names to read off two class
attributes, which is what makes them free for a vendor operation: BUS_ATTRS
(default ("bus",)) names the attributes holding bus references, and
WAVEFORM_ATTRS (default empty) names the ones holding waveforms. Sync sets
BUS_ATTRS = ("targets",) because it holds a list; Call sets it empty,
because buses reach a call site only as bound argument values.
Three more class attributes carry information the analysis layer needs and
would otherwise have to recover with an isinstance ladder.
Block.REPEATS is true on Sweep, Parallel, and Average, and is how the
validator computes max_loop_nesting; a Parallel counts as one level in
total, because its loop headers live on loops rather than among its children.
Operation.AFFECTS_AVERAGING is true on MeasurementOperation only, and marks
the ops whose presence decides whether an Average can run in real time.
Operation.BROADCASTS_WHEN_NO_BUS is true on Sync, and tells the validator to
route an op with no resolved bus across every bus in the program instead of the
default slot. A vendor class that sets any of these is counted correctly with no
core change.
required_capabilities() is non-recursive on both kinds of node. The validator
visits every node and checks each one's own token set against the slot that node
routes to, so recursing here would double-count. See
Capability protocol internals.
Expressions¶
variable.py is a small AST of its own, rooted at Expression. Every numeric
parameter of an operation or a waveform accepts an Expression in place of a
plain number, and Expression.evaluate() takes no arguments: each Variable
carries its own current value, written by the runtime once per loop iteration,
and the UNASSIGNED sentinel propagates upward while any variable is unbound.
The operator overloads follow the NumPy and SymPy convention rather than
Python's keywords: &, |, and ~ build logical nodes, because and, or,
and not cannot be overloaded. & and | bind tighter than the comparison
operators, so a compound condition needs parentheses:
(freq < 5e9) & (gain > 0.5). The ordering comparisons (<, <=, >, >=)
build Comparison nodes, but == and != deliberately do not: Variable.__eq__
has to return a bool so that variables can live in the sets that
Expression.variables() returns and be used as dictionary keys. Equality
comparisons are written with the named helpers qp.eq and qp.ne instead.
Structural equality¶
_structural.py holds the two helpers the whole AST shares, ast_eq(a, b) and
ast_hash(value). Both recurse through the container shapes that actually
appear inside AST attributes, ndarray, list, and dict, and defer to the
value's own == or hash for everything else.
Four places define the same pair of methods over them: Operation, Block,
SweepSource, and the _StructuralEqMixin that Waveform and IQWaveform
inherit.
# src/qprogram/operations/operation.py, abridged
class Operation:
def __eq__(self, other: object) -> bool:
if type(self) is not type(other):
return False
return ast_eq(vars(self), vars(other))
def __hash__(self) -> int:
items = tuple(sorted((k, ast_hash(v)) for k, v in vars(self).items()))
return hash((type(self).__name__, items))
Routing every node through vars(self) means a new operation, block, waveform,
or sweep source needs no __eq__ or __hash__ of its own, and no edit to
either when it gains an attribute, and it treats the awkward attribute types the
way every other node does: a nested IQPair recurses, a Variable attribute
compares by id, an ndarray of samples compares by contents. That is what makes
whole-program comparison work across a deepcopy and across a .qp round-trip,
since Variable compares equal whenever the id matches and
QProgram.variable rejects a duplicate id, making id-equality identity within
one program.
The two helpers are not exact mirrors.
ast_eq compares arrays with np.array_equal, which ignores dtype; ast_hash
hashes (shape, tobytes()), which does not. Two nodes that differ only in a
sample array's dtype therefore compare equal but hash apart, and land in
different buckets of a dict or set:
import numpy as np
import qprogram as qp
ints = qp.waveforms.Arbitrary(samples=np.array([1, 2, 3]))
floats = qp.waveforms.Arbitrary(samples=np.array([1.0, 2.0, 3.0]))
ints == floats # True: ast_eq compares contents
hash(ints) == hash(floats) # False: ast_hash includes the dtype
Every one of these classes is a value object whose attributes must stop
changing once it has been hashed. QProgram.rebind and the other transformers
rewrite operations on a fresh deepcopy for that reason, never in place.
The three vendor hooks¶
A vendor extension plugs into core QProgram in three places, each independent
of the others: the runtime namespace that makes its operations callable, the
serialization registries that make them survive a .qp round-trip, and the
capability protocol that says which of them a given platform supports. The
activate() function in tests/_dummy_vendor.py is the whole sequence in one
place: register_vendor, register_vendor_version, one
register_vendor_operation per operation, register_capability_tokens, and
register_profile.
Runtime namespace¶
QProgram._vendor_registry is a class-level dict mapping a vendor name to a
VendorNamespace subclass. QProgram.__getattr__ looks the name up and caches
an instantiated namespace on the program with object.__setattr__, so a
namespace costs nothing until it is first reached.
# qprogram-myvendor/src/qprogram_myvendor/__init__.py
import qprogram as qp
from qprogram_myvendor.namespace import MyVendorNamespace
qp.QProgram.register_vendor("myvendor", MyVendorNamespace)
After that call program.myvendor.<op>(...) resolves on any QProgram
instance, including one built from the base class. register_vendor refuses
three kinds of name: a reserved keyword or the "core" sentinel, a name that
collides with a QProgram attribute (normal attribute lookup wins over
__getattr__, so the namespace would be unreachable), and a name already
registered to a different class. Re-registering the same class under the same
name is a no-op, since import-time side-effect modules can run twice.
__getattr__ also refuses any underscore-prefixed name immediately, so
protocol probes such as __deepcopy__ fail fast instead of reaching the
registry.
VendorNamespace gives a namespace method the two helpers it needs: _append
for a plain operation, which validates any BusRef attribute against the
program's schema before appending, and _append_measurement for one that
returns a MeasurementHandle, which shares the per-bus name counter with
QProgram.measure so vendor and core measurements on a bus cannot collide.
Each vendor package also ships a mixin with one typed @property and a
pre-combined QProgram class (qprogram_myvendor.QProgram), and several
vendors compose through multiple inheritance. That is a typing aid, not a
fourth hook: the dynamic __getattr__ resolves the namespace at runtime
whether or not the mixin is present, and the mixin exists so that an editor can
complete program.myvendor. and a type checker can check the arguments.
Serialization registries¶
qprogram/serialization/registry.py holds seven module-level dicts making up
five registries: operations by class and by (vendor, name), blocks by keyword
and by class, sweep sources by class name, waveforms by class name, and vendor
protocol versions by vendor name. The writer looks an operation up by its class
and the parser reverses the lookup by (vendor, name), so neither one contains
an isinstance ladder or a hard-coded keyword list.
A vendor calls register_vendor_operation(vendor, name, cls) and
register_vendor_version(vendor, version) at import time, plus
register_vendor_block for a control-flow block of its own. It also declares a
[project.entry-points."qprogram.vendors"] entry point, which is what lets
loads() import an installed-but-unimported extension when a file's require
line names it.
The block registry covers the keyword-led headers, block: and
average 1000:, and a vendor block's <vendor>.<keyword>:. The three
structural blocks are not registered: a Sweep header is emitted as
for <var> in <Source>(...) with the source rendered from its own attributes,
and Parallel and Conditional have fixed grammar in both the writer and the
parser. Adding a sweep shape is therefore a new SweepSource, not a new block.
qp.lark in grammar/ is the normative statement of all of this, and
tests/test_grammar.py parses the writer's output with it so the hand-written
parser and the grammar cannot drift.
Capability protocol¶
Every Operation and Block subclass implements required_capabilities(),
returning the dotted-string tokens that instance needs. A vendor registers its
tokens with register_capability_tokens, maps its waveform classes to tokens
with register_waveform_token, and registers one or more Profile bundles of
capabilities, limits, and predicates with register_profile. Profile rejects
an unknown token in __post_init__, so the token registration has to happen
before the profile is constructed. A vendor profile either extends
qprogram-base-v1 or fills its platform-level slot from it with
CompilerCapabilities.from_profile("qprogram-base-v1", limit_overrides=...).
validate(program, capabilities) then walks the AST, checking each node's
tokens against the routed slot and classifying each block's execution domain,
and returns the diagnostics and the plan. Full details in
Capability protocol internals.
Where to add things¶
| Adding ... | Where it goes |
|---|---|
| A new core operation | operations/<name>.py (subclass Operation), export in operations/__init__.py, add a QProgram.<verb> method, implement required_capabilities(), register in serialization/_specs.py:_register_core_specs(); see Adding operations. |
| A new waveform | waveforms/<name>.py (subclass Waveform or IQWaveform), add to serialization/registry._register_builtin_waveforms(), register a class to token mapping in protocol._register_builtin_waveform_tokens(). See Adding waveforms. |
| A new sweep source | sweeps/builtin.py (or sweeps/combinators.py if it wraps another source), declare KIND and TOKEN, one line in the register_sweep_source loop in _specs.py, the token in protocol._BASE_TOKENS and profiles._SWEEP_SOURCES. |
| A new core block kind | blocks/<name>.py (subclass Block), set REPEATS if it re-runs its body, then register_block(name, cls, serialize_header=..., parse_header=...) in _specs.py. |
| A new vendor operation | Inside the vendor package: new Operation subclass with required_capabilities(), a typed method on the VendorNamespace, one register_vendor_operation call, the vendor token in the profile's capability set. No core changes. |
| A new vendor (whole namespace) | A new package depending on qprogram. Mirror the four-step activation pattern (namespace, version, operations, profile) plus the entry point; tests/_dummy_vendor.py is a working reference for the four registration steps. |
| A new capability token | Edit protocol._BASE_TOKENS (core) or call register_capability_tokens from a vendor package. See Capability protocol internals. |
| A new profile bundle | Create a Profile in the vendor package's profiles.py. Register via register_profile(profile) from the vendor __init__.py. |
Why a vendor extension is a separate package¶
A platform library that supports many instruments tends to pull in many vendor
SDKs. Keeping each extension in its own package, registering itself on import,
keeps qprogram installable with two runtime dependencies for someone who only
wants the language, and keeps every vendor's hard dependencies out of
qprogram's dependency graph. The cost is that a program's .qp file can
require a vendor the reader does not have installed, which is why the parser
checks the require lines against the registry before parsing the body and
raises rather than loading a program it cannot represent.
The boundary is enforced at import time: import qprogram does not
transitively import any vendor package. The other direction is intended, and
vendor packages import from qprogram freely.