Pipeline stages¶
A generator runs its spec as a Program: an ordered list of stages that hand each other per-env channels, planned once
when the generator is built and launched once per generation. This page
describes that machinery for tracks: the stage kinds, how build() plans a spec, the
Program it returns, the channel catalogue, the default stages, width, fail bits, the
commit, per-point attributes, taps and the parameter latch.
Generating tracks and gates shows the user-facing side (presets, specs, Params); The gate pipeline
lists what differs for gates; CUDA Graph — capture, replay, and buffer reuse covers how a Program is recorded and
replayed. Code written against an earlier development release of the pipeline API:
If you used a 0.2.0.devN preview lists what changed.
Stage kinds¶
kind |
built-ins |
may write |
where it comes from |
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
another stage’s dynamic |
TrackGen’s private catalogue (not open to plugins; Per-env draws) |
|
their catalogue channels |
TrackGen’s private catalogue (not open to plugins) |
|
|
see The gate stages |
their catalogue channels |
TrackGen’s private catalogue (not open to plugins) |
A plugin stage may also write namespaced plugin channels of its domain:
cl.<package>.<name> for a track stage, gates.<package>.<name> for a gate stage
(see Plugin channels). The built-ins are registered under their plain names by
TrackGen’s own register(reg), exactly as a plugin registers, so
registry.available("relax")
lists them with the installed plugins and registry.describe("elevation", "noise")
reports their reads, writes and params schema.
The planner resolves a stage of an open kind with registry.get(kind, name) when it
builds, and keeps that class: a later registry change never alters a built Program.
An unknown name raises PlanError with the registry’s list, for example
stage 'relax' (relax.nope): unknown relax 'nope'; available: ['none', 'xpbd']; a
name served by a quarantined plugin raises its PluginLoadError. An unknown stage of
a closed kind lists the catalogue’s names and says that the kind is not open to plugins.
build() and the planner¶
build(spec, *, registry=None, seeds=None, out=None, stream=None, prepare=True)
checks the spec before it allocates or launches anything, then allocates once:
Batch and ids.
num_envs >= 1,n_max >= 3,closedabool,on_invalidin("keep", "poison"),max_gatesNoneor an int>= 2; slot ids are unique; exactly one generator stage (kindgeneratororgate_generator), the first stage that is not a randomizer, with the id"generator"(only randomizers of the generator’s own fields may precede it); only the generator has acapacity, and only"raw";seedsis one of the forms below. Then topology: withclosed=Falsethe planner looks up every stage’s class (nothing is bound or validated yet) and raises, naming those that declaretopology="closed"(Topology). A gate pipeline starts with agate_generator, or with ageneratorfollowed by astationsstage; agate_generatorin a track pipeline is refused.Resolve. The generator and every stage of an open kind are resolved through
registry(defaulttrack_gen.plugin.registry), the generator exactly asTrackGenerator(or, for agate_generator,GateGenerator) resolvesconfig.generator. The other stages come from TrackGen’s built-in catalogue.Params. Each stage’s params are coerced to its Params class and validated (the built-ins with the messages
TrackGenConfig/GateGenConfiguse for the same fields). Every stage that has ahalf_width,gate_widthorgate_radiusfield must agree on its value (forgate_width, a dynamic per-env field ofgate_frames.default, on its initial value): forhalf_widththat bindswidth.constantand the plugin stages that keep such a field (the nominal width; the per-point width iscl.half_width). In a gate pipeline each stage’sLayoutis derived from its ports before its params are validated (see Two point domains).Channels. After
portsrewiring, the registration rules hold again (a plugin stage writes only what its kind may write, and rewrites a channel through one port; see Ports and channels), and every required read port is wired (Required read ports). The resamplers and the stations get one implicit read and write port per carried channel (Carried channels), so the rules below see the carries too. Walking the stages in order, every channel a stage reads must have been written by an earlier stage, and a channel written twice must be read by its second writer (an in-place rewrite, see In-place rewrites). No stage writesseed, and no stage writescl.half_widthafteroffset(Width). EveryTrackfield has a writer (theTrackreads them all: removingvalidityorliftis refused),valid.failstarts atvalidity.default(a check that writes it without reading it would drop the built-in checks, truncation included, and is refused), andlift.flat, which lifts at a constantz_base, may not follow an elevation other thanelevation.flat(uselift.elevated). A gate pipeline has the same rules for theGateSequenceandvalidity.gate_default, plus its own (Planner rules for gate pipelines). Each randomizer is checked against its target (Per-env draws).Capture class and segments. Each stage’s capture class comes from its
capture_class(params)(a legacyGeneratorSpec/GateGeneratorSpec:STATIC, orHOSTwithcapturable=False). The Program’s is the highest (STATIC<BOUNDED<HOST), and the stages are grouped into segments of equal host-ness (see Capture classes and segments). WithPipelineSpec.require_caller_capturable=TrueaHOSTstage raisesPlanError, naming every one.Allocate. The
SeedState(its own checks run first), theTrackor theGateSequence(or use the one passed asout=, validated beforehand, which may be a view of a larger result written by several generators, see Caller-owned results: out=), the[E]effective-seed buffer, one buffer per channel version and each stage’s scratch, namespacedscratch:<id>/<name>. Then each stage’sbuildruns, once, in pipeline order, against aStageBuilder; right after it, every write port the stage declares must have been written, and a validity stage must own a fail bit (fail bits are handed out in this order, see Fail bits). Last, the commit and its held copy of the result.Program. With
prepare=Truethe Program runs once eagerly (the warm-up, with every bounded loop unrolled).
A rule broken in steps 1 to 5 raises PlanError, a ValueError naming the stage,
its kind.name, the port and the channel, before anything is allocated. Some errors
keep their own type: params raise their own ValueError / TypeError, an unknown
generator raises the registry’s ValueError, a quarantined plugin its
PluginLoadError, and a bad seeds raises TypeError. The rules only a stage’s
build reveals (a skipped write port, a validity stage without a fail bit, a 16th
plugin fail bit, a fail-bit name taken twice, a stage-written cl.* plugin channel
read across the uniform resample: the stale-read rule of Carried channels) raise
PlanError after allocation, as do the interp= / export= rules of
b.write, with their own TypeError / ValueError.
build inside a capture raises CaptureError before allocating.
from track_gen.pipeline import PlanError, StageRef, build, presets
spec = presets.track(num_envs=64, device="cpu")
build(spec.remove("width"))
# PlanError: stage 'relax' (relax.xpbd) reads 'cl.half_width' (port 'half_width'),
# but no earlier stage writes it; the width stage runs before relax ... (generator,
# resample, width, relax, uniform, ...): move it, or insert
# StageRef('width', 'constant') before 'relax'
seeds is anything SeedState accepts (None, an int, an [E] int32
wp.array of seeds, a PerEnvSeededRNG) or a SeedState for the
batch. Anything else,
a NumPy array or a list included, raises TypeError before allocating. With out=
a view starting at env k of its root result, None and an int are offset by
k (env e gets base + k + e), so the generators of a mixed batch get the
seeds one batch would (Mixed batches).
build derives the generator’s config from the BatchSpec and the generator’s
StageRef (its params, and capacity["raw"] as num_points, 256 when absent).
That covers the built-ins and Stage generators. A GeneratorSpec plugin that reads
config fields outside its Params class needs TrackGenerator with a config. A gate
generator is bound the same way from a GateGenConfig derived from the spec
(num_envs, device, max_gates, scale, its params, and min_gates,
gate_ordering and the gate scalars from the stages that hold them); a legacy
GateGeneratorSpec that reads other config fields needs GateGenerator with a
config.
build accepts both kinds of spec: the Program it returns has a Track or a
GateSequence as its result.
The Program¶
Program is a GraphRunner launchable, so it follows the rules of
CUDA Graph — capture, replay, and buffer reuse exactly as TrackGenerator does: recorded into a caller’s
capture, replayed from its own CUDA graph, or run eagerly.
run(*, stream=None, sync=False, advance=False, mask=None, policy="auto")runs one generation: recorded into the caller’s capture, replayed from the Program’s own graph (from one graph per capturable segment when it hasHOSTstages), or eager.advanceandmaskmean what they mean forgenerate().enqueue(ctx, *, advance=False, mask=None)launches one generation onctx: the seed prologue (advance, effective seed, parameter latch), with a mask the hold of the unmasked envs’Trackrows, every stage in order for every env, the commit, and thegenerationwrite. It allocates nothing and never host-syncs (aHOSTstage’s own host operations aside); called eagerly oncuda, aBOUNDEDstage runs its full iteration bound (see Iterative stages (BOUNDED)). It is every segment’senqueue_segmentin order.enqueue_segment(i, ctx, *, advance=False, mask=None)launches segmentionly (see Capture classes and segments).prepare()runs the warm-up, whenbuild(..., prepare=False). Otherwise the firstrun()outside a capture runs it, and recording a Program that was never prepared (run()orenqueue()inside a capture) raisesCaptureError.resultis theTrack(theGateSequenceof a gate pipeline), the same instance with the same array addresses forever (outwhen one was passed), andlayoutitsLayout(Layout).specis the resolved spec: everyStageRef.paramsis the stage’s Params instance.tap(channel, *, after=None),channels,fail_bitsandon_invalid: see Taps, Fail bits and The commit.attributes: the per-point attributes the result exports, a read-only mapping from key toAttributeInfo(see Per-point attributes in the result).seeds(theSeedState),seed(the[E]effective seed),inputs("regen_mask"plus the inputs the stages declare),input_info(a read-only mapping from each name ofinputsto atrack_gen.pipeline.InputInfo:name,dtype,per,length,latched,stage_id),params(aParamTablekeyed by stage id; the dynamic fields of the built-ins are the generators’ shape knobs, the elevation stages’z_min/z_max/z_max_step/z_noise_amplitude,width.from_channel’sscale/lo/hiand a randomizer’slo/hi, see Dynamic built-in knobs) andgraph(Nonefor a Program withHOSTstages, whose per-segment graphssegment_graphs(key=None)returns,Noneat theHOSTsegments).capture_class(the highest of its stages’,STATIC<BOUNDED<HOST),capturable(Truewhen a caller can record it: noHOSTstage),host_stage_idsandsegments(see Capture classes and segments).prepared(whether the warm-up has run),device,default_stream(the stream a call runs on when it passes nostream=and no ambient scope applies),runner(itsGraphRunner) andlegacy_stream(Truefor a generator on the legacy plugin ABI, which launches on Warp’s current stream: HOST stages and segments).
Capture classes and segments¶
Every stage has a capture class (Capture classes): STATIC (a fixed
launch sequence), BOUNDED (an iteration with a static bound and a data-dependent
early stop, through ctx.bounded_loop; capturable by anything, see
Iterative stages (BOUNDED)) or HOST (host readbacks or host control flow: eager only,
see HOST stages and segments). The planner groups consecutive stages into segments:
maximal runs whose stages are all HOST or all capturable, STATIC and BOUNDED
together. A Program without HOST stages has one segment, and runs exactly as a single
body. Program.segments is a tuple of Segment (track_gen.pipeline.Segment):
index, the segment’s position;capture_class,HOST, or the highest of its stages’ (STATICorBOUNDED);stage_ids, its slot ids in pipeline order;host,Truefor aHOSTsegment.
A legacy GeneratorSpec(capturable=False) gives (HOST: generator) + (STATIC: the
rest); a HOST relax stage between capturable stages gives three segments. Outside a
capture on cuda the Program records every capturable segment into a graph of its own
and runs the HOST ones eagerly between the graph launches (HOST stages and segments).
Under a caller’s capture, run and enqueue of a
Program with HOST stages raise NotCapturableError naming them, before any launch.
enqueue_segment(i, ctx, *, advance=False, mask=None) lets an advanced caller do what
the Program does outside a capture: record the capturable segments into its own graph and
run the HOST segments eagerly.
Segment
0opens with the seed prologue (the epoch advance, the effective seed, the parameter latch and, with amask, the hold); the last segment closes with the commit and thegenerationwrite. So a write to any dynamic parameter must be enqueued before segment0to apply to that generation: one recorded in a later segment’s graph (after the latch) applies at the next generation. A one-segment Program does both, andenqueue(ctx)is every segment in order. Pass the sameadvanceandmaskto every segment of a generation, on one stream: a recorded segment keeps the arguments it was recorded with, so run the eager segments with those.A
HOSTsegment raisesNotCapturableErrorwhenctxis capturing; a capturable segment can be recorded into a caller’s capture once the Program is prepared, even when other segments areHOST.Mixing
advance/maskbetween the segments of a generation, running them out of order, or skipping one is not checked (a replayed segment runs no Python, so the Program cannot see its arguments) and leaves the output undefined.A legacy-ABI generator (
Program.legacy_stream) lives in segment0and launches on Warp’s current stream: whenctx’s stream is another one,enqueueandenqueue_segment(0, ...)raiseLegacyPluginStreamErrorifctxis capturing, and otherwise run on the current stream, ordered after and beforectx’s stream with events.
The checks run on the host before any launch, in this order: run validates mask
and advance (ValueError; enqueue and enqueue_segment leave that to their
caller) and enqueue_segment its index (IndexError); then run, enqueue and
enqueue_segment of a HOST segment refuse a capturing ctx
(NotCapturableError, also for a Program built with prepare=False); then
recording a Program that was never prepared raises CaptureError.
PipelineSpec(..., require_caller_capturable=True) refuses HOST stages at build,
before anything is allocated:
build(dataclasses.replace(spec, require_caller_capturable=True))
# PlanError: PipelineSpec.require_caller_capturable=True, but stage(s) 'generator'
# (generator.acme.blob) is HOST (they read device data back or run host control
# flow, so they run eagerly and refuse a caller's capture): use STATIC / BOUNDED
# stages or drop require_caller_capturable
A legacy GeneratorSpec(capturable=False) is named 'generator' (generator 'acme.old',
GeneratorSpec capturable=False).
Channels¶
Stages talk through named per-env channels; a stage declares the ones it reads and
writes as {local port: channel} maps (Stage.reads / Stage.writes), and
StageBuilder.read(port) / write(port, dtype, per=...) hand it the buffers.
per="env" is [E], per="cl" is [E * N_max] (N_max points per
env), per="raw" is
[E * raw]; the gate pipeline adds per="anchors" ([E * A]) and per="gates"
([E * G]), and its channels (anchors.*, gates.*) are listed under
Gate channels. The track catalogue in this release:
channel |
per |
dtype |
meaning ( |
|---|---|---|---|
|
env |
int32 |
The effective seed, written by the Program’s prologue; no stage writes it. Stages
read it through a |
|
raw |
vec2f |
The generator’s raw closed centerline. |
|
raw |
float32 |
Altitude and half-width per raw point, written by a generator that |
|
env |
int32 |
The generator’s per-env flag (fail bit 0 when |
|
cl |
vec2f |
The centerline: constant-spacing, then relaxed, then re-uniformised (three versions). |
|
env |
int32 |
Real points per env; NaN padding after. |
|
env |
int32 |
|
|
cl |
float32 |
Per-point half-width, written by the width stage (or carried from
|
|
cl |
vec2f |
Plan-view frames. |
|
cl |
float32 |
Curvature. |
|
cl, env |
float32 |
Plan-view cumulative arc length and loop length ( |
|
env |
float32 |
Signed winding ( |
|
cl |
vec2f |
Plan-view borders. |
|
cl |
float32 |
Per-point elevation (zero on |
|
cl |
float32 |
The band’s half-width on the real points, NaN padding ( |
|
env |
int32 |
The per-env fail bitmask: bit |
|
env |
int32 |
|
|
env |
int32 |
|
|
cl, env |
float32 |
The 3D arc length and loop length of the lifted centerline ( |
|
cl |
vec3f |
The lifted 3D geometry (the |
Plugin channels¶
A plugin may also read and write channels of its own, named
<domain>.<package>.<name> (at least two dot-separated identifiers after the prefix,
such as cl.acme.curvature_rank), for example to hand a buffer from one of its stages
to another, or to give the result a per-point attribute. The prefix is the point domain:
prefix |
|
written by |
|---|---|---|
|
|
A centerline generator: a per-point attribute of its raw loop, carried to
|
|
|
Any track-domain stage, and the resamplers (the image of a raw attribute). |
|
|
A gate generator, or a |
|
|
Any gate-domain stage, and |
A channel’s dtype and per are fixed by its first writer’s b.write(port, dtype,
per=...); a later writer must match, and reading one needs an earlier writer like any
channel. A stage uses the channels of its own domain: a gate-domain stage with a
cl.<package>.<name> port raises PlanError (plugin channel 'cl.acme.x' is a
centerline channel; a gate-domain stage reads and writes gates.<pkg>.<name>), and so
does a track stage with a gates.* one. Read any of them with Program.tap; a
per-point one written with export=True is also a field of the result (Per-point
attributes in the result).
Carried channels¶
The centerline changes point sets twice: the constant-spacing resample maps the
generator’s raw points to the beads (cl.*), and the uniform resample maps the
relaxed beads to the final points. A per-point value the generator computes (an
altitude, a half-width, a surface label) follows the points through both: it is a
carried channel. A carried channel and its images on the later point sets form a
family (raw.acme.grip -> cl.acme.grip; raw.z -> cl.z; raw.half_width ->
cl.half_width; in a gate pipeline also -> anchors.acme.grip -> gates.acme.grip).
Its dtype, interpolation policy and export flag are fixed by its first writer, the
generator’s b.write, and shared by every image:
# in a centerline generator whose writes include "grip": "raw.acme.grip"
grip = b.write("grip", wp.float32, per="raw", interp="linear", export=True)
A carried channel is float32, int32, vec2f or vec3f (TypeError
otherwise). interp= is required on every raw.<package>.<name> write (a raw
channel exists only to be carried), and only there: a stage-written channel is not
resampled, and interp= on it raises ValueError. With a and b the source
values at the ends of the source segment and f the output point’s fraction along it:
|
dtypes |
value |
|---|---|---|
|
float32, vec2f, vec3f |
|
|
any |
|
|
any |
|
|
float32 (radians) |
|
An int32 channel takes "nearest" or "previous" (TypeError otherwise).
The catalogue families have fixed policies (raw.z and raw.half_width are
"linear"), and interp= on a later writer of a family raises ValueError
(policy and export belong to the first writer).
Who carries what. The sets are static: the planner derives them from the declared writes and reads before it allocates anything.
resample.constant_spacingcarries every raw family (everyraw.*channel butraw.xy) to itscl.*image.resample.uniformre-interpolates every carriedcl.*family onto the final points, as a new version (a rewrite):cl.half_widthandcl.zwhoever wrote them, and the image of every raw attribute.A relax stage carries nothing (bead attachment, below).
stations.rawandstations.relaxedcarry a family of their source toanchors.<attr>only when a later stage reads that image (3D station spacing, Gate plugin channels).carry.attributescopies everyanchors.<package>.<name>togates.<package>.<name>by anchor index.
The carriers are writers like any stage: Program.channels["cl.half_width"] is
('width', 'uniform') in the default pipeline, and tap(channel, after=<carrier>)
reads each image.
How. A carrying resampler runs a variant of its lookup kernel that also writes the
resample tables (ops.ResampleTables): for every output point, the global indices
i0 and i1 of its source segment’s ends and its fraction frac along it. Then
ops.resample_channels interpolates each family through the tables, one launch per
family. A carried channel thus uses exactly the segment and fraction of the points
themselves. A resampler with nothing to carry runs its plain lookup kernels, and a
constant family costs no interpolation (Width), so the default pipelines pay nothing
for the mechanism.
Padding. NaN (every component of a float vector) or -1 (int32) at every
slot past count[e], written by every carrier (an output point outside the real ones
has the table sentinel i0 = i1 = -1, frac = NaN) and by the commit when it
poisons an env. One exception: the uniform image of a constant cl.half_width
(width.constant) fills its value into every slot, padding included; read
Track.half_width for a NaN-padded half-width.
Through relaxation: bead-attached. A relax stage moves points and never re-indexes
them: point i of its output is point i of its input, moved. A carried channel
keeps its bead index through any relax stage (no launch, no new version:
tap("cl.acme.grip", after="relax") is the after="resample" version), and the
uniform resample re-interpolates it with the relaxed loop’s own tables. So the
half-width the uniform resample gives a final point is the one XPBD separated that
point with, and a label (a curb, a surface) stays with the point that carries it.
Re-assigning attributes by arc fraction after relaxation would decouple them from the
geometry: XPBD moves beads a long way (a median displacement of 0.085 to 0.55 times the
two-bead distance per batch, up to 0.89 on single envs, in the measurements behind this
choice). A relax plugin must keep this contract (a relax stage that resamples internally
must not be combined with carried channels); the conformance kit does not check it
(the relax contract).
Blending. "linear" and "angle" interpolate across the closing segment (the
last bead back to bead 0), so a sawtooth attribute such as an arc fraction going
from 0 to 1 takes intermediate values at the seam: carry a (cos, sin) pair, or use
"angle". On a truncated env (always invalid) the closing segment is the chord over
the dropped arc.
Stage-written channels are not carried. A cl.<package>.<name> channel a stage
writes (per="cl") lives on the points that stage saw. Reading it on the other side of
the uniform resample (written before uniform, read after it) raises PlanError
when the reading stage is built: stage 'validity_acme' reads cl.acme.curv, written by
'width' before the uniform resample ('uniform'), which does not carry stage-written
channels (the values sit on the pre-uniform points): write it after 'uniform', or carry
it from the generator (raw.acme.curv). The commit’s read of an exported channel is
checked the same way. Relax stages do not trigger it (bead attachment), and the gate
domain has no resampler after the gates.* channels exist.
Cost.
carrier |
launches added |
|---|---|
|
one per raw family; none (the plain lookup kernels) without one. |
|
one per non-constant family; a constant |
|
one per family a later stage reads; none (the plain stations kernel) otherwise. |
|
one per |
Memory: the tables, 12 B per output point for each carrying resampler, and one buffer per family per carrier, plus a held copy per exported attribute (The commit). A generator with four raw attributes adds eight launches to a track generation (two per family).
Required read ports¶
A reads value may name a domain instead of a channel, "cl.*" or "gates.*":
the stage reads a channel the user chooses. Such a port has no default: the
StageRef must wire it with ports to a concrete channel of that domain, and the
planner refuses one left unwired:
StageRef("width", "from_channel", ports={"src": "cl.acme.hw"}) # wired
build(presets.track(num_envs=64, device="cpu", width="from_channel"))
# PlanError: stage 'width' (width.from_channel): read port 'src' (cl.*) must be
# wired: StageRef('width', 'from_channel', ports={'src': 'cl.<pkg>.<name>'});
# the cl image of a generator's raw.<pkg>.<name> is cl.<pkg>.<name>
With plugin channels written before the stage, the message lists them instead
(plugin channels written before it: ['cl.acme.grip', 'cl.acme.hw', ...]).
A required port is never a write port. registry.describe(kind, name).reads shows it
as is ({"src": "cl.*", "count": "cl.count"} for width.from_channel).
The default track stages¶
legacy.to_pipeline_spec(config) and presets.track(...) produce this list.
width.constant launches nothing (its value is filled at build), and the uniform
resample fills the final cl.half_width with it (see Width). The kernels that read a
dynamic built-in knob read it from an [E] array, filled by the Parameter latch
at the start of each generation (Dynamic built-in knobs lists them).
id |
stage |
reads |
writes |
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
nothing |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Each built-in stage’s params keep the flat TrackGenConfig field names, copied from
the config: resample.constant_spacing takes spacing; width.constant takes
half_width; relax.xpbd takes the relax_* fields; the elevation stages take
the z_* fields their profile uses; validity.default takes turning_tol,
w_floor, relax_tol, z_valid_grade and validity_border_check;
lift.flat takes z_base. relax.xpbd, offset.default and
validity.default take no half_width: they read cl.half_width. The elevation
stages’ z_min and z_max (and z_max_step for
random_walk, z_noise_amplitude for noise) are dynamic (Dynamic built-in
knobs). elevation.flat takes z_base too (the gate altitude; it must be 0 on a track,
where lift.flat carries the track’s flat altitude). The
generator’s params are its generator_params (see Parameters: Params). A plugin
stage named by relax_solver or z_profile gets its Params.from_legacy(config)
(its defaults unless it maps flat fields).
In-place rewrites¶
A stage that both reads and writes a channel (relax.xpbd, resample.uniform, a
chained validity check) rewrites it: in place in the data flow, not in memory. There
is one model, versioned rewrites, and no aliasing form:
One port. A rewrite declares the channel in
readsand inwritesthrough the same port name:reads = {"xy": "cl.xy", ...},writes = {"xy": "cl.xy"}. Reading a channel through one port and writing it through another is refused, at registration and again at plan time afterportsrewiring; a port present in both maps must name one channel.Two buffers.
b.read(port)returns the version the last earlier writer produced;b.write(port, ...)returns a NEW buffer, allocated once at build and zero-initialised, which every later stage reads. The version read is never handed to a later stage again.Whole output. The stage writes its whole output into the write buffer (every
[E * N_max]slot of a per-point channel, NaN padding included; every env of an[E]channel) and never writes a read buffer. An update made only to the read buffer is lost, and it corrupts that version astap(after=...)shows it.check_stage’sstage.isolationcheck catches it.One first writer. A second writer of a channel that does not read it is a
PlanError; a stage has at most one write port per channel and never writesseed.Scratch through the builder. Ping-pong partners and temporaries come from
b.temp_like(port)/b.scratch(name, ...), allocated once and namespaced.
So the pre-relax centerline survives the relax stage (tap("cl.xy",
after="resample")), and relax.none (or removing the slot) leaves
resample.uniform reading the constant-spacing version. The price is one buffer per
rewrite. An iterative stage needs no extra copy: a
ping-pong between b.temp_like(port) and the write buffer with an even number of
steps ends in the write buffer (see Writing a Stage), and a chained
check is one kernel, fail_out[e] = fail_in[e] | bit.
Width¶
cl.half_width is the band’s per-point half-width, and its only source. The width
stage writes it on the constant-spacing beads, before relax; XPBD separates the
beads with it; the uniform resample re-interpolates it onto the final points (it is
bead-attached, see Carried channels); offset.default builds the band from it and
writes Track.half_width; validity.default checks the band’s thickness and its
floor against it.
writer |
|
launches |
|---|---|---|
|
Its |
none: the buffer is filled when it is allocated |
|
|
one |
a generator that provides |
The carry of its |
the carry |
a plugin width stage |
Whatever it computes (Writing a width stage). |
its own |
width.from_channel takes scale (dynamic, default 1.0, >= 0), lo
(dynamic, default 0.0, >= 0) and hi (dynamic, default 1e9, >= 0), with
lo <= hi checked at build (a run-time hi below lo clamps to lo). Its src is a required read port (Required read ports): wire it to the
image of a generator’s raw attribute (raw.acme.hw is carried to cl.acme.hw) or to
a per="cl" plugin channel written before it. build refuses a source that is not
a per-point float32 channel (ValueError), and cl.half_width itself, the
stage’s own output (PlanError: rescaling the current width is a rewrite stage of
its own). lo defaults to 0 on purpose: a
source that pinches below w_floor must fail the floor check, not be clamped away.
spec = presets.track(
num_envs=64, device="cpu", generator="acme.ribbon", half_width=0.1,
width=StageRef("width", "from_channel", {"scale": 1.0, "hi": 0.2},
ports={"src": "cl.acme.hw"}))
Constant width keeps its kernels. width.constant declares its value constant (a
private declaration of the built-in). A consumer that reads a constant cl.half_width
runs its scalar-width kernels with that value as a host scalar; any other width selects
per-point kernels when the Program is built:
consumer |
constant width ( |
per-point width ( |
|---|---|---|
|
pair target |
|
|
|
|
|
|
every non-neighbouring pair |
|
|
the same |
Two kernels, because a target computed in float32 inside the kernel is not
bit-exact against the scalar kernels’ double-precision host scalars (for 24 of 104
(half_width, margin) pairs tried; at half_width=0.07, margin=0.2 a full solve
moves 3909 of 12288 points by up to 6e-3). The constant path therefore keeps every
default pipeline on the scalar kernels, and the per-point kernels reproduce the scalar ones at
constant width when given the same float32 scalars. A plugin width that writes a
constant value runs the per-point path: its outputs are close to, not equal to,
width.constant’s.
The floor and the thickness checks read the final points. Fail bit 4 is set when a
real final point i < count has not (cl.half_width[i] > w_floor) (a NaN fails): the
band pinches to the floor somewhere. At constant width it gives the same answer at
every point of every env. Both checks read the half-width the uniform resample carried to the final
points (Track.half_width), the band the Track has: a pinch on a single bead that
falls between two final samples is not in the band and does not fail. Two band edges
overlap exactly when two non-neighbouring centerline points are closer than the sum of
their half-widths, or a point’s curvature radius is below its own half-width: that is
the per-point thickness test.
Non-finite widths. Warp’s min and max drop a NaN operand, so in the
per-point kernels a NaN half-width does not enter the neighbour band, gives no bend
correction and never triggers a separation: XPBD leaves the bead unconstrained. Such an
env fails bit 4 (not (NaN > w_floor)); XPBD does not clamp, the verdict stays
validity’s. A negative half-width fails bit 4 for any w_floor >= 0.
Placement. The width stage runs before relax. A stage that writes
cl.half_width after offset raises PlanError (stage 'X' writes
cl.half_width after 'offset': the band and Track.half_width come from offset's read, so
validity would check another width than the band; move it before 'offset'). A width
stage after uniform is legitimate: a rewrite of the final points’ half-width, which
XPBD did not see. That is the recipe for a geometry-dependent width, such as one scaled
by the curvature frames computes: keep a nominal width in the width slot, for XPBD,
and insert the geometry-dependent stage after frames as a rewrite of
cl.half_width:
spec = presets.track(num_envs=64, device="cpu", half_width=0.1)
spec = spec.insert_after("frames", StageRef("width", "acme.curvature_scaled",
id="width_kappa"))
spec.ids
# ('generator', 'resample', 'width', 'relax', 'uniform', 'frames', 'width_kappa',
# 'offset', 'elevation', 'validity', 'lift')
Writing a width stage shows how to write width stages, both kinds.
Generator-provided altitude and width¶
A centerline generator may compute an altitude or a half-width per raw point itself. It
writes raw.z or raw.half_width (float32, carried with interp="linear")
and declares it in provides, a subset of {"z", "half_width"} naming exactly what
it writes. The constant-spacing resample carries them to cl.z / cl.half_width,
the uniform resample re-interpolates them, and the stages that read those channels use
them:
|
|
then |
|---|---|---|
|
no |
|
|
no |
XPBD, the band and the checks use the carried half-width (the per-point kernels). |
The presets refuse the selection a provider replaces: an explicit non-flat elevation or
a non-zero z_base (ValueError: presets.track(elevation='noise'): generator
'acme.helix_loop' provides its own altitudes (z); leave elevation at its default
('flat')), and a width= other than the default "constant" (... provides
its own half-widths (raw.half_width -> cl.half_width); leave width at its default, or
add a rewrite of cl.half_width to a hand-built spec). A hand-built spec that keeps the stage gets the
planner’s second-writer PlanError, with a hint (generator 'acme.helix_loop'
provides z (raw.z -> cl.z): drop the elevation stage). In a gate pipeline, a z
provider puts the altitude on the loop before the stations (3D station spacing).
Fail bits¶
Validity is a bitmask of fail bits, the [E] int32 channel valid.fail: bit i is set when
check i failed for that env. validity.default writes the first version (it
writes valid.fail without reading it), and each further validity stage rewrites it,
OR-ing in the bits it owns. The final version is Track.fail_mask, and
Track.valid == (fail_mask == 0): validity.default’s flag when it is the last
check, else derived by the commit.
bit |
name |
set when |
|---|---|---|
0 |
|
the generator wrote |
1 |
|
the turning number is not |
2 |
|
a real centerline point is NaN or infinite |
3 |
|
the band overlaps itself: at constant width, the loop’s thickness is below
|
4 |
|
a real final point’s |
5 |
|
a border self-intersects (only with |
6 |
|
a segment’s |
7 |
|
|
8-15 |
reserved |
TrackGen’s future built-in checks |
16-30 |
plugin checks |
assigned by |
31 |
unused |
never set (the sign bit) |
validity.default owns bits 0 to 7 and computes all of them in one fused kernel,
which also writes its own 0/1 flag track.valid. An env with count == 0 gets bit 1
plus bits 0 and 7 when they apply. track_gen.pipeline.BUILTIN_FAIL_BITS is the
name-to-bit table above (a read-only mapping); a name is listed even when its check is
off (bits 5 and 6), and is then never set.
A validity plugin calls b.fail_bit(name=None) in its build for each independent
check it runs and gets bits 16, 17, … in pipeline order; the name (default: the stage
id) keys the bit in Program.fail_bits and Track.fail_bits. fail_bit() from a
stage of another kind raises ValueError; a 16th plugin bit in one pipeline, or a
name that is already taken, raises PlanError; a validity stage that never calls it
raises PlanError (a check owns at least one bit). Bits 0 to 15 are reserved for
TrackGen’s own checks.
spec = presets.track(num_envs=64, device="cpu",
checks=("default", ("acme.max_curvature", {"kappa_max": 8.0})))
gen = TrackGenerator(spec, rng)
gen.program.fail_bits["acme.max_curvature"] # 16
track = gen.generate()
too_curvy = (track.fail_mask.numpy() >> 16) & 1
Track.fail_mask is None and Track.fail_bits empty on a Track built
elsewhere (the standalone inflate_warp path, for example). When fail_mask is set,
valid[e] == (fail_mask[e] == 0) for every env.
The commit¶
The Track arrays are the final versions of the channels it exports: the stage that
last writes track.outer (lift), cl.truncated (resample), cl.winding
(frames) or valid.fail (the last check) writes straight into the Track array,
with no copy. Track.arclen / Track.length are track.arclen /
track.length when a stage writes them (lift.elevated), else the plan-view
cl.arclen2 / cl.length2 (lift.flat: a constant z_base changes neither);
Track.fail_mask is the final valid.fail; Track.valid is validity.default’s
track.valid unless a check runs after it; Track.half_width is track.half_width
(offset), and each Track.attributes[key] the final version of an exported
channel (Per-point attributes in the result). Intermediate versions get buffers of
their own. Track.generation is not a channel.
The commit is the Program’s epilogue: it runs after the stages and settles what each
env’s Track rows hold at the end of the generation. A generation is: advance the
masked epochs, compute the effective seeds, latch the parameters, with a mask hold the
unmasked envs’ rows, run every stage for every env, commit, write generation.
Masked regeneration is exact. The stages have fixed shapes, so under
generate(advance=True, mask=m) they still recompute, and write, every env’s rows.
Before them, a kernel copies the Track rows of every env with m[e] == 0 into a
held copy; after them, the commit copies those rows back, bit for bit. An env outside
the mask ends the generation with exactly the bytes it had, generation included
(written for the masked envs only), whatever its recomputation gave: masked regeneration
no longer relies on the stages being bitwise deterministic, and an all-zero mask is an
exact no-op.
This is a hold / restore, not a commit that alone writes the Track: within one
generation the stages overwrite the unmasked envs’ rows with their recomputation, and
the commit then restores them, both on the generation’s stream. A consumer ordered
after the generation on that stream (later work on it, or work waiting on an event
recorded on it, as in a caller’s graph) never observes the recomputed rows. A consumer
on another stream with no such dependency can: for a while an unmasked env may show its
recomputed track (after a reseed, a different one; under on_invalid="poison", a
poisoned env with count > 0 and finite geometry). Order such readers after the
generation, as for any write to the Track.
Invalid envs. BatchSpec.on_invalid (presets.track(on_invalid=...)) chooses
what the commit does with an invalid env, one whose fail_mask is not zero
(Invalid envs is the user-facing account):
value |
an invalid env leaves the commit with |
|---|---|
|
its computed geometry, and |
|
|
With "poison", a utility fed the Track meets an empty track for an invalid env
instead of finite geometry that failed a check, so its count < 3 handling runs: for
that env TrackLocalizer returns a NaN s and segment = -1,
CollisionChecker reports oob = 1 with boundary = -1 and a NaN distance, and
CheckpointSampler and PropSampler place no point. Valid envs are byte-identical
under both policies, and a masked generation poisons only
the masked envs (the others are restored). The utilities answer an
invalid env the same way under both policies (Invalid envs); keep gating
rewards and observations on valid. "poison" is planned to become the default at
1.0.
Cost. The commit launches only when it has work to do:
generation |
launches added |
|---|---|
unmasked, |
none (156 launches for a default |
unmasked, with a check after |
one ( |
unmasked, |
one (the commit, |
masked ( |
two (the hold before the stages, the commit after them, |
with exported attributes |
per attribute: two more for a masked generation (its hold and its commit), one
more for an unmasked |
The held copy costs the size of the Track: 68 B per point (five vec3f and two
float32, arclen and half_width), plus each exported attribute’s size, and 24 B
per env: about 107 MB at E = 4096, N_max = 384, allocated once at build for
every Program, whether or not it ever runs a masked generation. The Track and every
working buffer keep their addresses forever.
Program.on_invalid reports the policy. TrackGenConfig has no on_invalid
field: build the spec with presets.track(..., on_invalid="poison"), or edit a spec’s
batch (both are frozen dataclasses):
import dataclasses
from track_gen import TrackGenConfig
from track_gen.pipeline import legacy
config = TrackGenConfig(num_envs=64, device="cpu")
spec = legacy.to_pipeline_spec(config)
spec = dataclasses.replace(spec, batch=dataclasses.replace(spec.batch,
on_invalid="poison"))
Per-point attributes in the result¶
Track.half_width is the band’s half-width per point, [E * N_max] float32:
the value on the real points, NaN on the padding (the constant on every real point at
constant width). It is offset.default’s track.half_width, so the band and the
field agree: |outer - center| == half_width on every real point. It is None on a
Track built by hand.
Track.attributes maps a key to the final version of each per-point plugin channel
exported with export=; the key is the channel’s name without its domain prefix:
# in a generator: writes = {..., "surface": "raw.acme.surface_id"}
b.write("surface", wp.int32, per="raw", interp="nearest", export=True)
track = gen.generate()
track.attributes["acme.surface_id"] # [E * N_max] int32, -1 on the padding
info = gen.program.attributes["acme.surface_id"] # an AttributeInfo
info.channel, info.interp, info.padding, info.domain
# ('cl.acme.surface_id', 'nearest', -1, 'cl'); info.dtype is wp.int32
Each array is
[E * N_max]([E * G]on gates), of the family’s dtype, padded with NaN or-1; it is allocated once at build and keeps its address forever, like everyTrackarray. The mapping is read-only.export=True, or the key itself (export="acme.surface_id"): another string raisesValueError(export='surface': the key of raw.acme.surface_id is 'acme.surface_id' (renaming is not supported)), another typeTypeError.export=is for per-point plugin channels: araworanchorsfamily, or acl/gateschannel a stage writes withper="cl"/per="gates". On a per-env channel it raisesValueError(export= needs a per-point channel: per='cl' (got the default per='env')), on a catalogue family too (raw.z reaches Track.center.z; export= is for plugin channels). A later writer of the family may not pass it.An exported raw family reaches
Track.attributesthrough the resamplers, on every track pipeline. A channel a stage writes is exported as written, so the stale-read rule of Carried channels applies to the commit’s read of it.The commit holds and restores attributes under a masked regeneration and fills them with their padding when it poisons an env (The commit).
Track.clone()copieshalf_widthand every attribute.GateSequence.attributesis the same for the gate pipeline’sgates.<package>.<name>channels (Gate plugin channels), with one difference: a family with no index path to the gates is left out (best effort).Program.attributesmaps each key of the result to anAttributeInfo(key,channel,dtype,interp,padding,domain;track_gen.pipeline.AttributeInfo), the policy a consumer that samples an attribute along the track needs (interpisNonefor a channel a stage wrote on the result’s points, or one carried by index only).registry.describe("generator", name)lists the raw channels a generator declares (interpandexportare decided inbuild).
Caller-owned results (build(..., out=...), TrackGenerator(..., out=...))
follow one rule for the optional fields (fail_mask, generation, half_width,
attribute keys): a field the caller leaves None, or a key it leaves
out of out.attributes, is not added to the caller’s object, and the Program keeps that
work buffer for itself (tap reaches it). A key of out.attributes the Program
exports is bound after a check ([E * N_max], the dtype, the device, contiguous). On a
view of a larger result (a mixed batch), a key it does not export (a family it does not
write, or one it carries without exporting it) is checked, filled once with its padding
at build and never written again, so the batch can share one attribute array between
generators that export it and generators that do not; on a whole result such a key
raises ValueError (Attributes of a caller-owned result).
Taps¶
Program.tap(channel, *, after=None) returns the working buffer of a channel: its
final version, or with after=<stage id> the version visible right after that stage.
It replaces reading private scratch (gen._scratch) in visualisation and benchmark
code:
from track_gen import TrackGenerator
gen = TrackGenerator(presets.track(num_envs=64, device="cpu"))
p = gen.program
raw = p.tap("raw.xy") # the generator's raw loop
resampled = p.tap("cl.xy", after="resample") # before relaxation
relaxed = p.tap("cl.xy", after="relax")
kappa = p.tap("cl.kappa")
p.channels["cl.xy"] # ('resample', 'relax', 'uniform')
beads_hw = p.tap("cl.half_width", after="width") # the width XPBD separates with
p.channels["cl.half_width"] # ('width', 'uniform')
A tap is read-only by contract: never write it, the next stage reads it.
It is stable: the same array for the Program’s lifetime, recordable by pointer.
It holds the last generation’s values once that generation has completed on its stream. The stages recompute every env, so an intermediate version holds every env’s recomputation, also for the envs a masked regeneration left out. The final version of a channel the
Trackexports is theTrack’s array, so it holds the committed values: an unmasked env’s previous track after a masked regeneration, the poisoned rows underon_invalid="poison". One exception: when a plugin check runs aftervalidity.default, the commit derivesTrack.validfromvalid.fail, andtap("track.valid")staysvalidity.default’s own flag, blind to the later checks and to poison. ReadTrack.validortap("valid.fail") == 0instead.tap("seed")is the effective seed.Program.channelsmaps each written channel to the ids of its writers, in order ("seed"to("<prologue>",)).An unknown channel, an unknown stage, or a stage before the channel’s first writer raises
KeyError.
Parameter latch¶
A dynamic field has two [E] arrays per stage: the live array that
gen.params[stage][field] returns, which you write (set, your own kernels, a
curriculum), and a working copy, which the stage’s kernels read. At the start of every
generation, right after the epoch advance and the effective seed, the Program’s prologue
latches each live array into its working copy, for the envs it regenerates: every env,
or only the masked ones under generate(advance=True, mask=m). An input a stage
declares per="env" is latched the same way. Three consequences:
a write to a live array applies at the next generation, to the envs that generation regenerates;
an env a masked regeneration leaves out keeps the values its rows were built with, so masked regeneration stays exact for dynamic parameters too (The commit);
in a Program with
HOSTstages the latch belongs to segment0: a write recorded into a later segment’s graph applies at the following generation (Capture classes and segments).
A drawn field does not go through the latch: the stage that draws it (a
randomize.uniform stage, or bezier’s deprecated style_sampling for a knob with
a range) writes the one array the target reads, during the generation. So the runtime
arrays come in two kinds:
kind |
what |
when a write applies |
|---|---|---|
latched |
dynamic parameters, |
Snapshotted at the start of a generation: a write applies to the envs the next generation regenerates. |
unlatched |
a drawn field; a |
Read during the generation. A drawn field belongs to the stage that draws it
( |
Program.input_info (gen.input_info) says which inputs are latched, and
StageParams.drawn (gen.params[stage].drawn) maps each drawn field to the id of the
stage that draws it (the stage’s own id for a style_sampling draw).
Randomizers. randomize.uniform is a STATIC stage of a closed kind. Its Params,
RandomizeUniformParams, hold stage and field (static: the target) and lo
and hi (dynamic, so latched). It reads seed, writes no channel, and draws with
the salt b.salt("draw") under its own id, so two randomizers never share a stream
and a draw does not depend on the randomizer’s position. It is listed right before its
target and runs there, so the target reads this generation’s draw; only the generator’s
randomizers may precede the generator. Before a HOST generator, those randomizers
form segment 0 (a graph segment that opens with the prologue) and the generator
segment 1. Per-env draws: randomize= has the user-level rules and the checks
build runs on them.
What a stage read. gen.params[stage].in_effect(field) returns the [E] array
the stage read for the current rows: the working copy of a latched field, the drawn array
of a drawn field. Without out= it is that internal array (zero-copy, a stable pointer,
recordable, read-only by contract); in_effect(field, out=arr) enqueues one copy into
your array. A latched field is current once segment 0 has run; a drawn field once the
segment that holds its drawing stage has run. Both are written on the generation’s stream.
What each env was built with: in_effect and bounds is the user-level account.
Kernels and bounds. Each built-in kernel that reads a dynamic field (the stages and
fields are listed in The built-in dynamic knobs) loads its env’s value once and
clamps it to the field’s declared bounds with comparisons, so a NaN and every in-range
value, signed zeros included, pass bit for bit, and an unbounded side is not clamped.
Then it runs the body of the scalar-argument kernel it replaces, verbatim: a
configuration gives the same bytes through either kernel. A scalar is
checked on the host instead, in float32 for a float32 field (at construction, by
set, for a randomizer’s initial range). StageParams.bounds(field) reports the
bounds, narrowed per instance (min_num_points: (2, max_num_points), which set
also enforces), so the value a built-in kernel used is
clamp(in_effect(field), *bounds(field)). Used as written, without a clamp:
width.from_channel’s scale / lo / hi (it clamps its output), a
randomizer’s lo / hi (it clamps its draw), and style_sampling’s draws.
bezier passes its bounds to its kernel as arguments, which is how the last exception
works. The scalar-argument kernels stay, unchanged, for the legacy entry points
(generate_*_warp, apply_z_profile*, the sample_* helpers).
Size. size multiplies where scale already acts, after it: the corner grid
((x * cell_size + jitter) * scale) * size[e], or the normalization target
target * size[e] before its division. Neither product feeds an addition, so a size of
1 is bit-exact on CPU and CUDA (no fused multiply-add is created or removed; a CPU
test locks the generated CUDA source of each kernel pair). repulsive’s final copy
multiplies only finite values (where(size == 1 or not finite, v, w * size), with w
the finite-gated value): a size-1 copy keeps every finite value bit for bit, and a
non-finite point is copied unscaled at every size (a NaN bit for bit, payload included).
Under style_sampling, size composes with the scale_range draw
(scale[e] * size[e]).
Corner count. _sample_counts_dyn_k loads env e’s min_num_points, clamps
it to [2, max_num_points] and draws the count as the scalar kernel does.
Cross-field rules. At build, every stage whose Params class defines the private
_check_draws hook is checked, drawn or not: crossing z_min / z_max
randomizers raise PlanError; an inert elevation window, a rad drawn above its
clamp, a generator.size range (or construction value) that would truncate the nominal
longest loop at the pipeline’s n_max, and a generator.size randomizer combined
with style_sampling’s scale_range emit a UserWarning. Dynamic fields
and Size: the sizing rule give the rules from the user’s side.
Launches. One latch launch per latched dynamic field per generation, right after the
effective seed (and the advance), in slot order then field order: an int32 field
through _latch_all_i32_k, a float32 one through _latch_all_f32_k, and under a
mask the _latch_masked_* twins. A drawn field has none. A randomizer adds two launches
per generation: its draw, and the latches of its lo and hi, minus the latch its
target field no longer needs. In a launch trace, each kernel that reads a knob from an
array keeps the position, dimensions, arrays and other scalars of its scalar-argument
twin; its knob scalars become arrays (_assemble_dyn_k also takes the four bound
scalars; _sample_counts_dyn_k and _finalize_frame_dyn_k take the array in place
of lo and gate_width), the size kernels append the [E] size array, and
repulsive’s final copy is the _copy_sized_k launch.
generation (CPU, launches per |
launches |
of which latches |
|---|---|---|
default track ( |
156 |
4 |
|
154 / 146 / 154 / 150 |
3 / 3 / 3 / 4 |
|
162 / 163 / 161 |
6 / 7 / 7 |
|
158 |
5 (and the draw) |
gates: |
16 |
2 ( |
gates: |
18 / 18 / 19 |
4 (two ring knobs, |
gates: |
19 / 20 / 20 |
4 / 5 / 5 |
gates: |
32 |
5 (the track generator’s four, |
A masked generation runs the same latches as _latch_masked_* launches after the
advance and the masked effective seed, and the hold comes right after them (index 6 of a
default track generation, 4 of a default gate generation).
Not open yet¶
What the pipeline does not do in this release:
More dynamic built-in knobs:
edgy; a dynamic corner-count upper bound (the corner axis only narrows towardmax_num_points); per-env knot, anchor, checkpoint and gate counts (they size buffers). Other distributions than uniform,int32and categorical draws, and a public sampler API for plugins. Asize=on further public ops (ops.closed_catmull_rom).Results and inputs: open topology (
closed=False) in any stage or utility, and aclosedargument to the kernels;build(inputs=)binding caller arrays as a Program’s inputs;declare()for gate generators; gate ingestion (aGateSequencefrom caller arrays);Track.capabilities(); a ready-made mixed-batch producer (aCoursedrives any object with thetrack_gen.pipeline.Producersurface, so a mixed batch ofout=views can be wrapped by hand: The Producer protocol).Channels: carries by plugin stations stages (a public
StageBuilder.carried()); carried channels written by a stage rather than the generator (interp=on stage writes); an order that publishes its permutation, so attributes reach the gates through any order;ops.arc_resample(tables=); a public constant declaration for width stages; an arc-fraction attribute policy; renaming onexport=; per-env attributes;GateGenConfig.spacing_metric; an elevation on the raw loop ("xyz"withsource="stations"needs azprovider); a dynamicwidth.constant.Gates: composite generators, provided attributes other than
zfromstations(tangent,half_size,roll),rollongate_frames.default,preferredhints of track generators for the gate stages (they declare"relax"only), split gate checks, and aGateGenConfig.on_invalidfield.Opening
resample,frames,offsetandliftto plugins; split built-in checks (validity.turning, …); an aliasing in-place write form; aTrackGenConfig.on_invalidfield; a segmentedCourse(over a generator with aHOSTstage,Course.regenerate()runs eagerly).
Reference compositions¶
The scalar track composition (warp_pipeline._run_pipeline, inflate_warp,
_inflate_warp_alloc) stays importable: TrackGenerator does not call it, and the
tests use it as the byte-identity reference of the default pipelines. gen._scratch is
a view of the Program’s buffers kept for the same reason; new code reads
gen.program.tap. The gate pipeline has no such composition (the tests keep a frozen
copy as its reference) and GateGenerator has no _scratch: read
gen.program.tap. The deprecated warp_gate helpers that legacy gate plugins call
(alloc_order_scratch, order_points, finish_ordered_gates, _GateStaging)
stay until 1.0; the library does not call them (GeneratorSpec and the 0.1 plugin ABIs).