Skip to content

Latest commit

 

History

History
302 lines (170 loc) · 96.8 KB

DEV.md

File metadata and controls

302 lines (170 loc) · 96.8 KB

Development

Keep the language concise to write, simple to read and consistent to understand. A design rule is a plan on trial. Discuss it when applying it makes a useful expression harder to read, removes something expressible or requires another special case. APL familiarity and compatibility are not costs in that decision. Change callers with an API change instead of adding compatibility layers.

Speed and simplicity come before edge cases no real user meets. Follow IEEE, Rust and CPU behaviour where it gives a reasonable result. The last bits of a result can differ between thread counts, platforms, builds and runs. Never spend speed, memory or code to make them agree. Don't add precision, sign normalization or defensive passes without an agreed need.

A form with a reasonable meaning gives a result, not an error. A result that would be a bug in some programs, with no error, is acceptable when it buys useful behaviour or speed. Negative positions count from the end in every selection, as in Python, even though x.[i-1] at i=0 then reads the last item. ⌈ beside NaN gives NaN or the other argument, whichever its kernel gives. Jeremy settled negative positions on 18 October 2026, after the question had come up repeatedly.

You MUST read this ENTIRE section in FULL first

LLMS: Read nbs/llms.txt in full before doing any work. BPL differs substantially from Dyalog APL in grouping, names, numbers and operators. Use BPL syntax, not APL idioms assumed to carry over. The links in nbs/llms.txt are relative to the published site. In this repository, read each linked page's source under nbs/, as in nbs/glyphs/at.qmd for glyphs/at.html.md and nbs/indexing.ipynb for indexing.html.md. The published site lacks unreleased changes.

Rustymath and basedpl have no outside users yet, so treat neither as published. Changing rustymath to give both projects shared machinery is encouraged. Separate machinery in the two for the same task is an error, unless Jeremy has asked for them to differ there.

cargo develop
cargo test
pytest -q

Use cargo develop to rebuild and install the native extension and bpl after Rust changes. It uses the test profile shared with cargo test. Cargo tests compile missing code but don't install it. Run plain cargo test while you work; use one test binary for a focused check. Run pytest -q at the end of a piece of work. Before you report a pytest failure in code you didn't touch, run cargo develop and rerun it. Give builds three minutes and test runs one minute; Cargo serializes builds in the same target directory.

Build nothing else without discussion: no scratch crates, alternate target directories, --release, -p or other-target builds. The documented build diagnostics, browser work and x86-64 inspection below are exceptions. Don't use maturin develop; its configuration does not reuse the normal local build.

When another agent's unfinished edits stop the shared tree compiling, run cargo wait with the Bash tool's run_in_background, then build after it exits. cargo wait comes from fastws-cli, as cargo develop does. It runs cargo check on every target of the workspace each time a source file or manifest of a local package changes, rustymath's included. It exits once the tree compiles, or after 30 minutes with the remaining errors. cargo wait --path ~/aai-ws/rustymath waits for rustymath's own workspace, its Python extension included. Use it rather than writing a wait loop.

An already-running Python process retains its loaded extension. In a kernel, use from basedpl.worker import fresh; fresh(code) to check the installed bpl in a fresh worker instead of restarting the kernel. A BPL error raises BplError. fresh(code, bpl=PATH) runs a saved copy of bpl, such as the baseline described below. Check one expression and read its result before building on it. basedpl.bpltests.check_file and check_page check reference files and documentation through the worker without building Rust. With bpl=PATH, they check a saved copy without installing it.

Measure with BASEDPL_COSTS=1 cargo test --test speed or basedpl.bpltests.time_cases(cases). Both count costs in copies. A copy is the time to write as many floats as the data has items, each negated, into a second buffer. Budgets are single-thread measurements. Use time_cases(..., threads=...) separately for scaling. speed_costs() reads the cases in tests/speed.txt. cases= selects expressions, wasm=DIR selects a browser build, bpl=PATH selects another native bpl, such as a saved copy, and native=True times rustymath's own linear algebra, which dev builds compile in. logged_costs(log) reads CI output. cargo_costs(path) runs either project's Rust speed test, and pytest_costs(path) runs rustymath's Python speed test.

Costs in copies can't show a per-call change of a few percent, because code layout alone moves a timing by more than that. instructions(cases) counts instructions, which load and layout don't change. Each case is BPL that makes one call per step of N, such as {⍵}⍣N⊢0. A map such as f¨N⍴0 calls a dfn once on all its cells, so it gives no per-call cost. It runs fresh single-threaded bpl processes at two values of N and reports the instructions retired per unit of N, so startup and setup= cancel out. It reads /usr/bin/time -l on macOS and perf stat on Linux, and bpl=PATH counts a saved copy.

bpl-tests NAME ARGUMENT... runs one of these functions from the shell and prints its result: check_file, check_page, time_cases, instructions, speed_costs, cargo_costs, pytest_costs or wasm_test. Run long checks and timings this way, not in the shared kernel, which runs one cell at a time for every agent. For example, bpl-tests time_cases '+/v' --threads 4 times +/v on four threads. time_cases, instructions and speed_costs take cases as positional arguments. When a case starts with -, put the cases last, after --, as in bpl-tests speed_costs -- '-.×m' '+/v'. bpl-tests NAME -h lists a function's arguments.

On each push, the Test workflow logs every native cost in its test job and every wasm cost in its wasm job. The speed test that writes them times the test-profile build that the budgets limit. bpl --costs FILE times the cases in a file in speed.txt's format as the speed test does, and prints each cost with this machine's budget, without failing on a budget. The manual Speed workflow runs it on a dist build, the profile releases use, on CI's runner, beside its wasm timing. The Release workflow runs it on each binary it builds, on GitHub's runners. Their logs show what releases cost, for comparison with the Test workflow's. logged_costs reads all three workflows' logs. When you review a run, look for costs over their budgets and for costs that changed by more than 20%. Study a slowdown until you know its cause only when it appears in more than one CI run, or when two or more similar cases slow by similar amounts. Treat any other slowdown as normal variation.

An initial budget is twice what a first-principles analysis says the case should cost, as each budget file's header says. A later budget comes from a fuller understanding of the case than that analysis gives, such as a platform difference or a cost the analysis missed, and the case's comment records it. A case over its budget needs study, which finds either a speed bug to fix or something the analysis missed. Avoid raising a budget to match a run without understanding why the case costs what it does. Each case line in a budget file holds a native budget for macOS on Apple silicon, then one for Linux on CI's x86 runner, then, in basedpl's file, a wasm budget. One expected cost serves every platform at first, until a specific difference on one platform is found. macOS running matrix products and decompositions on Accelerate, where CI runs rustymath's own kernels, is such a difference. A comment above each case, or above its group, gives the expected cost with its reason, and any platform's own cost with the reason for it. set_budgets(expected) writes the budgets for the cases that expected names, a dict from each case's code to its expected cost. builds= limits the change to one platform's column, for a cost that differs there. A case can appear in more than one section, such as [copies] and [widths]. sections= limits the change to the sections it names. path= selects one of rustymath's budget files, tests/speed.txt or tests/speed_python.txt.

tests/explain.rs's speed_cases_take_their_routes runs each speed case once more, after a warm-up run, as •explain runs its text (timing::routes). It fails on any slow-path event that the case's lines don't name: a pending array that a fused group computed on its own (stored), a call that computed pending items (forced), a batched map's fallback (fallbacks), a write that left an array mixed (mixed), or a route that handled items one at a time (itemwise). A line above a case names one event, as •explain's record names it: the function for forced, the reason for stored and fallbacks, the code for mixed and the route for itemwise. # allows RECORD "EVENT": principle names an event that a principle allows, such as a matrix product reading its arguments whole. # gap RECORD "EVENT": what should happen names an event that a faster route should remove, and the test prints these gaps, which cargo test -- --nocapture shows. Any other event is a route to study, as a case over its budget is: add an allows line only for a principle, and a gap line once the study finds what should happen. The test also fails on a line whose event no longer appears, so a fixed gap's line goes.

The speed tests and the scaling test time each failing case again after the rest, after each pause in rustymath::timing::RETRY_PAUSES, and fail only for a case that fails every time. Each retry prints the case's earlier and new costs, even in a passing run. logged_costs keeps a retried case's lowest cost. Study a case that needs a retry in most runs, as you would a slowdown.

For a change meant to make something faster, or a change to code that every call goes through, time the cases it affects before and after the change. Before your first cargo develop, copy the installed bpl in the kernel with shutil.copy(basedpl.worker.INSTALLED_BPL, '/tmp/<prefix>_bpl'). Time that copy with bpl='/tmp/<prefix>_bpl', and your build with the default. Another agent's cargo develop can replace the installed bpl at any time. The copy keeps your baseline fixed while agents build in parallel. Because costs in copies correct for the machine's overall speed, the two timings compare directly at the 20% level that review looks for. On this Mac, a fused case over a large input can cost up to about 15% more in one process than in another, depending on whether malloc starts the input on a 32 KB boundary, where every one of a graph's blocks then ends. Time both builds the same way, each alone in a fresh process or at the same place in one run, and alternate them before you read a lone difference of that size as a regression. The speed test fails a case only past its budget. It misses any smaller slowdown. Other changes need only the speed test. Don't restore old code in the shared tree to get a baseline. On macOS, native timings of matrix products and decompositions mostly measure Accelerate. Rustymath's DEV.md says how to time rustymath's own kernels.

Cooperating tools share the machine's timing lock, /tmp/aai-ws-timing.lock. Timings take it exclusively. cargo develop, cargo wait, cargo wasm, the reference test, pytest and wasm_test without speed take it shared. Rust code takes it through rustymath::timing. Python code takes it with fastws.cargo.timing_lock. Node can't lock a file. wasm_test and speed_costs(wasm=DIR) hold the lock in Python while Node runs. Other Rust tests and bare Cargo commands do not all take it. After five minutes a timing fails. A build or test run then goes ahead without the lock. After 30 seconds, an exclusive wait prints the processes with the lock file open. The list can include processes that are only waiting for the lock. Never take the lock yourself in the shared kernel process. Never coordinate timings by hand. A hold that outlasts its call blocks the timings that later calls launch. Each such call stalls every other agent's kernel calls while it waits. Native timings run on one thread and request user-interactive macOS QoS, which favours the performance cores. QoS is not CPU affinity.

Run cargo wasm during development only for browser work. Agents use cargo wasm --out DIR, then run the browser tests against that build with basedpl.bpltests.wasm_test(DIR) for tests/wasm.mjs and wasm_test(DIR, speed=True) for tests/wasm-speed.mjs. This avoids replacing a module another agent is using. For other edits under cfg(web) or host_web.rs, check compilation with cargo check -p basedpl --target wasm32-unknown-unknown.

X86-64 machine code

To inspect the x86-64 code that CI and Linux run, build the library for Linux x86-64. In basedpl:

CARGO_INCREMENTAL=0 CC_x86_64_unknown_linux_gnu=clang cargo build --lib --release --target x86_64-unknown-linux-gnu --target-dir /tmp/aai-ws-x86 -p basedpl

In rustymath, use -p rustymath. The target is Linux because macOS builds leave out rustymath's native linear algebra kernels. That target is installed here. -p builds only the library, because the Python extension crates can't be built for Linux on this Mac. CC_x86_64_unknown_linux_gnu=clang lets Apple's clang assemble the x86-64 source in basedpl's psm dependency, because this Mac has no x86_64-linux-gnu-gcc. Don't move that setting into .cargo/config.toml. CI's Linux release builds also read that file, and their manylinux container has no clang. The build matches CI's test profile in its x86-64 flags and optimization settings. CI turns off the profile's incremental compilation with CARGO_INCREMENTAL=0. The command does the same, to split code into compilation units as CI does. Run it only to answer an x86-code question. It doesn't take the timing lock and can disturb timings running alongside it.

Read the newest lib<crate>-<hash>.rlib under /tmp/aai-ws-x86/x86_64-unknown-linux-gnu/release/deps with objdump -d -r --no-show-raw-insn --disassemble-symbols=NAME. -r identifies called functions. objdump warns for each archive object without that symbol. -C matches demangled names but splits them at commas; for a generic name containing a comma, get its mangled name from nm and omit -C. Generic code belongs to the crate that instantiates it. Don't use --emit asm: the resulting codegen-unit arrangement can inline differently from the build being inspected.

Workspace, builds and CI

Crate Responsibility
basedpl Python-free interpreter and public Rust API.
cli/ (basedpl-cli) bpl, REPL, Jupyter kernel and JSON-lines worker.
py/ (basedpl-py) Unpublished PyO3 extension, basedpl._core.
wasm/ (basedpl-wasm) Browser bindings.
xtask/ cargo wasm, which builds the browser package.

The default workspace members are core, CLI and Python; wasm/xtask build only when requested. Frontends use the core's public API. Core/CLI Rust tests live in tests/ and cli/tests/. Libraries set test = false to avoid separate cfg(test) builds. Because no build runs #[cfg(test)] code, libraries hold no inline tests or test-only counters. Any internal behaviour worth a test, such as whether an expression fuses, is worth knowing for every user too. Tests in tests/ check it through the public API, such as •explain, •storage or •memory. PyO3 is in its own crate, not an optional core feature. The core uses serde_json for the process protocol and data functions.

Rust 1.98 or later supplies algebraic float operations. test inherits release: opt-level 2, incremental compilation, LTO off, no debug information, overflow checks or debug assertions. A debug_assert! therefore never runs here. Write a check that matters as an always-on test. .cargo/config.toml explicitly enables LLVM's SLP vectorizer and targets x86-64-v3 on x86. Target-specific rustflags repeat the SLP setting because they replace build-wide flags. dist disables incremental compilation, enables within-crate LTO and strips symbols. Keep these settings aligned with rustymath's.

fastws's cargo develop builds profile test, installs the extension and stages bpl into the venv. py/ enables PyO3's extension-module and supplies its link arguments without libpython. Editable installs use fastws.build_backend; they install metadata and a path to python/ without compiling Rust. After a standalone uv sync or editable pip install, run cargo develop. ws-sync performs that rebuild after syncing. Wheels use maturin for the extension and cargo stage for the Python-free CLI. Linux staging runs inside the manylinux container.

Basedpl's crates pass on rustymath's default native feature, which rustymath's DEV.md describes. Each depends on the crate below it with default-features = false and makes native its own default. The release job builds wheels with --no-default-features. cargo stage always builds without default features. The released Mac builds therefore leave out rustymath's own linear algebra. On Apple targets, flate2 uses the system zlib. Other targets keep its Rust backend, miniz_oxide.

Use published dependency ranges with the lower bounds required by the APIs in use. Cargo.toml names a published rustymath range; the ~/aai-ws Cargo patch selects the sibling checkout locally. CI resolves published crates. Release rustymath changes before relying on them in basedpl CI. Don't edit fastws-generated Cargo patches, .git/fastws-cargo-key or workspace configuration, and don't commit the workspace-generated Cargo.lock. meta/ is ignored planning material, not contributor documentation. [tool.uv] cache-keys tracks packaging manifests, not Rust sources.

Test runs native and wasm jobs on pushes to main and pull requests. Native CI runs cargo test, editable installation, cargo develop, pytest, bpl-nb and nbdev-test. Python tests use the same test-profile build. The wasm job builds with cargo wasm --strip and runs both Node suites. Both jobs use ubicloud-standard-4; speed budgets are set for that runner. They generate a lockfile before restoring Swatinem/rust-cache, making dependency changes part of the cache key. Speed is manually triggered. Its wasm job restores the Test workflow's wasm cache. Its native job builds the dist profile without one.

Release runs separately on v* tags on GitHub's runners. It builds dist wheels, sdist, standalone CLI artifacts and the Windows keyboard archive, then publishes Python artifacts and the GitHub release. Its npm job publishes the stripped browser package through trusted publishing and purges jsDelivr's package files to avoid stale unversioned URLs. Release neither runs tests nor waits for the Test workflow.

Codegen and rebuilds

LTO-off incremental builds keep an edit from recompiling every caller that inlined it. Measured on 18 October 2026 in a local incremental build, thin-local LTO doubled the rebuild after a one-line edit that recompiles large units, from about 8 s to about 18 s, cut the calls from Steps::combined into other units from 21 to 8, and moved the speed cases by 5% or less. Moving lines changes the code below them, because each panic site, such as a bounds check, holds its line. Every unit that holds that code then recompiles. Related source is split into child modules to limit those shifts. For example, fusion/blocks.rs holds the generic block runner of fusion.rs. After a one-line edit to Graph::run, the rebuild recompiles one unit of about 140 KB and takes about 2.4 s. It took about 9 s when the runner sat below it in the same file. Children use use super::* and expose items to parents and siblings with pub(super). Parents own public re-exports.

Generic loop instances can concentrate in the defining module's codegen unit. A closure passed to a generic adapter such as map makes an instance that can compile in another codegen unit. Every edit to the closure's function then recompiles that unit. When Graph::run built a vector with map, an edit to it recompiled a 1.9 MB unit in about 8 s. It now uses a plain loop. Mark loop helpers #[inline] when their instances need to compile with the caller's kernel table, including nested helpers. Kernel tables have separate modules: booleans, wholes, float_arithmetic, float_kernels and complex_kernels. at_widths! gives the concrete widths their own modules. Keep new tables separate and add kernel specializations only for measured gains.

Use cargo build --lib --bins --profile test --timings to inspect the normal build. Its report under target/cargo-timings/ separates frontend and codegen time. BASEDPL_UNITS=1 cargo test --test units -- --nocapture lists large units and their functions. BASEDPL_SIZES=pattern cargo test --test units -- --nocapture measures machine code matching a demangled-name regex. BASEDPL_SIZES sees only basedpl's own codegen units. BASEDPL_SIZES_DIFF=old:new cargo test --test units machine_code_growth -- --nocapture compares two binaries, such as copies of bpl from before and after a change, by generic function across every crate. BASEDPL_CALLS=pattern cargo test --test units -- --nocapture lists each call from a function whose demangled name matches to a function in another codegen unit, in basedpl's build and in the rustymath rlib it links. LLVM can't inline those calls in this profile. rustymath's DEV.md gives the cause, under "Loops and codegen". After an edit, BASEDPL_RECOMPILED=pattern cargo test --test units -- --nocapture lists the units that the rebuild compiled again, each with its biggest functions whose demangled names match. These commands diagnose the current compilation arrangement; don't create scratch builds to approximate it.

Code map

Files Responsibility
syntax.rs, eval/binder.rs, eval/tree.rs Structural parse, runtime category binding, trains, dot access and subscripts, and the recordings that dfn statements replay.
eval.rs, eval/session.rs, eval/operators.rs, eval/idioms.rs, eval/assign.rs Shared calls, sessions/modules, operators, fast combinations, scopes and assignments.
execution.rs, error.rs Cancellation, deadlines, input/output and retained diagnostics.
array.rs, array/storage.rs, array/text.rs Values, layouts, compact/mixed storage, gathering and source formatting.
agreement.rs, keyed.rs, selection.rs Rustymath's broadcasting Agreement with basedpl's layouts and their renderer, reading paired items, named/keyed alignment, and selection/writeback.
primitive.rs, primitive/ Primitive metadata, ranks/axes, inverses and array operations. Structural glyphs read their arguments, take each result's layout and Steps from a plan in rustymath's structure, and copy by it.
number.rs, element.rs, pervasive.rs, fusion.rs, explain.rs Numeric domains, item operations, compact kernels, pending arrays with the graphs that compute them, and the events that •explain records and the live nodes that •memory counts.
derivative.rs, linear.rs ∂: duals, tags, seeding, the partials table and the rules for operators, and the record of linear calls that reverse mode transposes.
transform.rs The core that running transformations share: one stack of levels, routing to the newest level among a call's arguments, and the read point for code that isn't a call.
batch.rs Batched maps: the batch carrier, the try in each_pair that calls the operand of an Each, a Rank or an outer product once on batches of its cells while the batch pref is on, and the batch rules that routing hands calls to. The pref is on by default. tests/reference.rs runs every reference case with it off and on. bpl-tests check_page PAGE --unbatched checks a page with it off.
search.rs, number_theory.rs, polynomial.rs, distribution.rs Array-level adapters to rustymath.
system.rs, data.rs System-function metadata, native functions, options and import rules.
csv.rs, json.rs, xml.rs, image.rs, plot.rs, canvas.rs, display.rs Formats, rich values and rendering.
protocol.rs, reference.rs, timing.rs Process encoding, semantic checks and measurements.
host.rs, host_web.rs, js.rs Platform services and JavaScript values/functions.
symbols.rs, inspection.rs Shared names, shortcuts, syntax/help metadata and inspection.
cli/src/, py/src/lib.rs, wasm/src/lib.rs Frontend boundaries.

User-facing language rules belong in nbs/syntax.qmd, evaluation.qmd, numbers.qmd, arrays.qmd, terms.qmd and the glyph/system pages. Define terms in terms.qmd before using them. The sections below record contributor contracts and ownership rather than duplicating the language reference.

Parser, evaluator and scopes

The lexer/parser records byte spans and structural syntax without evaluating expressions. parse distinguishes complete input, incomplete input and errors. It reads numbers under the thread's current settings, which Session::parse sets from the session first. syntax_tree supplies source tools with structural nodes and recovers invalid chunks as Error nodes. highlight shares the lexer.

symbols.rs owns APL, simplified and traditional Hanzi spellings. Its rows also hold each glyph's names: a glyph name for the character, which is also its page's file name in nbs/glyphs/, and a name for each valence, or one name where both valences do the same thing. The glyph reference, named entry, help and Python's exported names all read them there. hanzi_aliases enables Hanzi glyph spellings, canonicalizing token decisions while retaining original source and spans. Other Hanzi remain name characters. _ quotes the next non-whitespace character into a name, including a glyph alias. The notation preference affects structured function/operator display, not stored source, serialization or structural APIs; help and editor metadata use the same spelling table.

The binder resolves runtime categories right to left. It owns unfinished strands, trains and bound arguments; shared function nodes are immutable. Grammatical reduction produces application requests instead of calling functions. Primitive/operator rows own names, operand kinds, ranks, pervasion, axis behaviour and reduction identities. Change those rows instead of duplicating metadata in consumers. A non-extending primitive above its natural ranks runs through Rank; pervasive Each/Outer/Rank paths reuse whole-array kernels.

Dot names rewrite to Pick, and dot brackets/subscripts to selection. ⱼ, ₑ or ᵣ between two arrays then rewrites to the primitives it stands for, as xⱼy to x+y×0ⱼ1 (syntax::joined_term). Dot parentheses evaluate a namespace: T.(expr) and T⍎text bind each key on the first axis of T to its item or major cell as a local, resolve other names in the caller and keep assignments local. They share the scoped execution route and lexical lifetime checks. Between functions, dot remains inner product.

Lexical frames use non-owning parent indices. Closures see live bindings, not snapshots. Plain assignment is local; modified/selective assignment updates the nearest existing binding. Retain the resolved owner and original array across modifier calls rather than looking the name up again. Returns and outer writes reject functions, including those nested in arrays/prototypes, whose frames would not survive. Tail calls retain dependencies from arguments and the called function. No collector is used; recheck this lifetime reasoning before permitting escaping lexical captures.

Module 0 owns session names. Each •load runs in a new module, without the caller's frame and with echo off, and returns its non-private names alphabetically. Modules remain for the session's lifetime. Recursive loading of a canonical path is a DOMAIN error. Paths starting ./ or ../ resolve against the source file; other paths use the working directory. Execute/load do not run the top-level preference header.

Dfns run statements in order and return the last statement's value. Final assignments are silent. An absent left argument propagates through the binder's absent-binding rules; default-left assignment runs only when needed. Guard handlers see bindings as they stood at failure; nothing rolls back. Wildcard ∞:: excludes interrupt, timeout and unsupported errors. Explicit named guards can catch them; catching an interrupt clears its flag.

The binder records a dfn statement's steps on the statement's first evaluation (eval/tree.rs). Later evaluations replay that recording: they make the same calls in the same order, checking the category of each name, group and call result against the recorded one. Where a category differs, the binder carries on from the replay's state (Binder::resumed). Dot access, subscripts, joiners and runs that start with an operator rewrite a statement before the binder walks it (Session::joined, Session::members), and the recording keeps the rewritten nodes. The recording also keeps each fact about what names hold that the binding read (tree::Fact): whether a rewrite applies, and whether a run starts with an operator or ends in a dyadic one. A replay first checks those facts again, and evaluates the statement from its own nodes where one differs. A group or run whose leaves are all literals, primitives, operator glyphs or such groups, and which makes no call, records as the array or function it gave (Recording::constant), so a train like (+/÷≢) builds once. A replay reuses what an operator glyph, or one bound to a constant operand as in ⍤1, derived from a constant operand when the statement recorded. The binder evaluates a statement every time when the statement assigns, selects, contains a run that ends in a dyadic operator, or needs more than 8 items on a stack. The parse tree depends on the evaluator, because each syntax::Statement holds its own recording. A change to a reduction that the binder records (a drop, a strand, an attach, a call, a derive, a bound right operand or a train) must change the replay too. strand, attached, split_call, derived, bound_right, train and tail_call in eval/binder.rs serve both. The _ => rc.fail() arm in Binder::reduce stops recording any statement with a reduction that the replay doesn't cover. The binder then evaluates that statement correctly every time.

Tail calls reuse frames while preserving lexical dependencies. Handlers disable tail reuse. Native call depth is capped at 20,000; the browser cap is 380. Calls of primitives other than ⍎ don't count, because a primitive never calls back into BPL. They skip the depth, capture and stack bookkeeping, and on numbers a form that applies a kernel to each pair of items runs that kernel at once (Primitive::scalar_call). A separate 20,000-frame limit bounds retained lexical frames. Late-bound name resolution stops at 128 levels. These are execution limits, not a sandbox against arbitrarily deep source, arrays or function graphs.

Values, layouts and assignment

Value holds a number, character, function, operator or shared array. Compact storage holds a rustymath buffer::Buffer of Booleans, narrow integers, width-specific floats or complex numbers, or characters, all of one dtype, and Items borrows it as a buffer::View. BPL's promotion rules for those dtypes stay in basedpl, as Promotion in array/storage.rs. A rustymath buffer keeps the dtype it's given, and basedpl picks an integer's width from its values. Mixed storage holds Values and memoizes its prototype and innermost lexical dependency. Other properties are computed when needed. An array holds its layout and its storage in one allocation. Its Shape holds ranks 0 to 2 inline, so a small array allocates no shape. Constructors take impl Into<Shape>: pass a slice, or an array such as [n], rather than building a Vec. A shape, key or name change on a shared array makes a view of the base array's storage and copies no items. A large result of a fusible float call holds a pending call in place of its storage, and computes the storage on the first read (fusion.rs). Value::view and imported_buffer are the public width-preserving frontend boundary. They give and take rustymath's View and Buffer, which basedpl's API re-exports as basedpl::buffer.

Atoms, rank-0 arrays and singleton vectors are distinct. Function nodes retain handles, not lexical frames or Python objects. Functions match by their parts; dfns and generators match by identity. Operators can be values and can cross the native Python boundary. Values and function nodes are Send + Sync.

Numeric storage fills with zero and characters with blank. Mixed nonempty arrays derive their prototype from their first item on demand; empty arrays require one explicitly. Builders accept a value or a lazy prototype function and evaluate the function only for an empty result. Preserve function handles inside prototypes.

Layout wraps rustymath's layout::Layout, which holds the dimensions, position keys and axis names, and adds the renderer. Methods that change only keys or names keep the renderer, as select does. Methods that build a layout from axes drop it, and pairing two layouts by broadcasting keeps the renderer they agree on (Layout::paired). Rustymath's agreement does the pairing: it unites keys and broadcasts through index maps. Missing positions read each source's prototype. Agreement pairs equal axis names first. It pairs remaining axes positionally unless both have different names. Result assembly drops colliding axis names; public name attachment rejects duplicates. Contracted axes require equal key sets and retain the first order. Nonempty key lists with no named positions become unkeyed; empty record keys survive.

Gather owns copying and widening. Steps, from rustymath's gather, describes strides, ranges/fills, rotations and offset tables. Gather::walk copies compact storage through rustymath::gather::walk. Structural operations should use remap/gathering rather than reconstructing Values per item. Equal-shape cells copy in blocks. Items reads counts, positions and axes from compact storage without boxing every item. Shape, tally, positions and predicates have dedicated integer/Boolean builders.

Under with a function and selective assignment find their targets through labels. Each begins a label level (transform::Kind::Label) and runs the selector, or evaluates the target, on the root wrapped as a selection::Labelled value. A primitive whose row selects carries the labels through its own code. Planned forms and ⌷ compose a rustymath Mapping relative to the root, so a selection reads only the items it selects. Ordering forms select through Primitive::taken. Any other call strips the labels and runs on the values. Labels::read takes the labels of the selector's result. Labels that are still a mapping with no fill write back through that mapping, as a walk over the root (Targets::Walk). Labels::replacements turns any other labels into offsets, or into paths where the root has nested items. A strand of labelled items joins into one labelled array (Carrier::joins_at_once). An item that the selector makes rather than selects has the FOREIGN label. Writing to it is a DOMAIN ERROR. A result without labels at the level sends Under to the selector's inverse. A batched try refuses while a label level runs (Levels::admits_batch). Function-Under applies its selector to a left argument as a plain value. Positions-Under passes the left argument whole. Keyed values align to selected keys, and nested paths retain empty-cell structure.

Selective assignment removes the binding before writing. Arc::make_mut copies only when another holder needs the old array. Repeated targets must read values written by earlier occurrences. pervasive::scatter combines each target of + - × ÷ ⌈ ⌊ into compact numbers in place, in turn, at the kind that the call computes in, through rustymath's try_scatter_with. Other pervasive modifiers group repeated targets into rounds of distinct positions. A target at a fill reads the fill, and its write is dropped. Append updates use Value::append when layouts permit it; rank/key/name changes fall back to ordinary catenation. Modified destructuring calls modifiers left to right; ordinary destructuring assigns right to left. An assignment's RHS runs once.

Each, Rank and outer products call their operand through each_pair in eval/operators.rs. It first tries one batched call (batch::tried), then calls the operand once for each cell and assembles the results through shared layout and prototype rules. A 0-cell preserves an atom or encloses an array item. For custom operands, empty Each, Rank and outer product calls substitute prototypes for empty arguments and use prototype mode. Explicit effects remain observable when a call runs. Catchable prototype-call failures produce prototype zero, but interrupt, timeout and unsupported errors propagate. Restore prototype mode on success and error. A stencil runs as Rank over ↕'s windows, and reads its specification through ↕'s WindowSpec (primitive/structural.rs).

Rank tries whole before each_pair. whole calls the operand once where neither argument has a frame. It then tries decomposed, for •decompose⍤2 on a stack of matrices, and whole_cells for a primitive or a product. whole_cells holds Rank's routes for whole arguments, which batched calls share (batch::whole_call). In order, they are primitive::stacked's kernels for +.×, ⌹ and -.× on stacks of matrices, one padded call of a pervasive primitive (padded_call), primitive_cells for a primitive with one argument or with a left argument that every cell shares, and paired_cells for a primitive whose arguments' frames match or whose right argument every cell shares. An argument with no axes besides its cells' is the same in every cell. After whole_cells, whole tries pervasive_cells, for a pervasive primitive under nested Rank, and then, for a monadic operand, cell_rule, which also takes folds, atops and a bound left argument. primitive_cells runs a primitive on the whole argument along the cells' axes. A form that extends runs whole at a cell rank at least its own. Other forms follow the cells fact (CellRule) in their row of Primitive::info(). With it, ⊖ , ↑ ↓ ⌷ # and rotate-first call their axis forms, ⍉ sends each cell's axes after the frame's, ≢ and ⍴ give constants, and a reshape that keeps a cell's items in order is a view. Any other form with a left argument that every cell shares takes paired_cells. paired_cells joins each pair of cells for , and ⍪, and calls a form that extends once where both cells are at least its ranks. It first repeats an argument that every cell shares over the frame (agreement::spread). A pervasive primitive folds or scans a simple array along the cell's axis (folded_cells). f/∘, folds all the cell axes at once. An atop applies its inner function's rule, then Rank for its outer function. Each rule applies only where Rank's mix of the cells' results gives what the whole-array call gives. Nested results, and folds by other functions, keep the call for each cell.

Axis nodes select/move axes and reuse Rank when the function has no direct axis meaning. Vector-axis reduction uses the general composed-ravel rule, preserving axis order and the whole seed. Single-axis reductions retain direct kernels. Layout-only changes share storage. Structural results retain renderers where their rank permits; scans and in-place changes copy complete layouts. Match ignores renderers.

Inverse nodes propagate fixed-argument sides through composition. f⁻¹←g and f∂←g replace the binding with a Ruled node, which holds the function with its hand-written inverse and derivative. Calling it calls the function. ⁻¹ and ∂ use the rules, and Under counts an attached inverse as keeping every item. Candidate-building primitive inverses validate by applying the primitive again. Inverse scans share forward scan's seed/axis validation. Agenda calls its selector once, then passes the original arguments to the selected branch. Computed operands for Rank, Axis, Stencil and Agenda use shared operand evaluation. Python constructs these nodes directly, not by generating source.

Function::new recognises a fast combination (eval/idioms.rs) from the node it builds, once, and keeps the Idiom in FunctionData. dispatch tries the idiom first. Key tries its own idioms inside key, after one validation. A fold that pervasive::fold_groups can't compute takes the general path with the same classes. The idiom checks its arguments on each call, and gives None where they don't suit it, which sends the call down the general path. Only built functions get idioms: trains, compositions and operator applications. The binder doesn't look across an expression's calls. Each idiom has a row under "Fast combinations" on its glyph pages. tests/explain.rs checks through •explain that every documented spelling takes its idiom. A reference case in core.bpl checks that each gives what a spelling that no idiom recognises gives. An idiom chooses an algorithm, which stays in rustymath or the kernel modules. A whole-array path that follows from an operator's own definition, such as Rank's for pervasive operands, belongs with the operator.

Numbers and backend ownership

number.rs owns scalar kinds, promotion and exactness, and the suffix table that spells numbers (e/ₑ, j/ⱼ, r/ᵣ, ₓ and the width marks), which the lexer, Number::parse and display read. Integers and BigRational share an exact domain. Overflowing scalar integer arithmetic widens to exact rationals; integral rational results narrow back when possible. Booleans are a distinct kind. Arithmetic reads them as 0/1; logical operations preserve them. Match/search/grade compare their numeric value, while reference checks also distinguish representation and exactness.

Joining compact arrays widens Boolean -> integer -> float -> complex, and float widths join at the wider width. Item-by-item construction preserves exact/approximate kinds and stays mixed when needed; only approximate real/complex items widen together. A write into an existing array follows the items rule (Widening::Items), as a literal of the same items would: exact and approximate numbers stay apart, and so do Booleans and other numbers. A rearrangement or combination of mixed storage finishes in Gather::finish, which holds the items compact where one dtype holds them under the items rule, as a literal of the same items would be. Its scan compares each item's variant with the first item's, so a copy that stays mixed pays little. An in-place write into mixed storage keeps it mixed, because a scan on every write would make a loop of writes quadratic. Fresh computations choose storage from their result path. Results that an operation computes one cell at a time, as Each, Rank, Key, power histories, and the polynomial and number-theory functions do, assemble as items, through Value::assemble. Mix and catenation join arrays. Keep this distinction between whole-array joining and element assembly.

Bare numbers, empty literals and NaN use the parsed float width. Top-level evaluate_code first validates the whole syntax, runs leading literal-record •prefs statements, then parses the remainder at the new width. A syntax error prevents header execution. The default width also applies to imported numbers, random data and float results from exact operands. Time/date/metadata values use f64. Formatting marks non-default widths; serialization marks every float for width-independent readback.

Float operations compute at the wider operand width. Exact operands round to the width an operation computes at. f16 operations run on f32 copies. Numeric::halving decides when: for a call with an f16 operand, or any call while the default width is 16 bits. pervasive::halves applies it to whole operations and Number::halves to scalar arithmetic and comparisons. fusion::defer declines those calls. Exact inputs convert once, to f32, and float outputs round back to f16. The scalar Number path rounds each f16 step. Complex parts have f32/f64 widths; f16 becoming complex uses f32 parts. Scalar Complex32 arithmetic computes at Complex64 and rounds, while kernels can compute at f32. These paths need not agree in their last bits.

Floats use IEEE NaN, infinity and signed zero. Comparison treats NaN as unequal; match/search treat it as one value, and grade puts it last. Exact arithmetic involving division by zero can produce those float values. Floor/ceiling retain an exact operand beside infinity and give NaN beside NaN. Monadic floor/ceiling/sign turn whole finite results into exact integers, including big integers; nonfinite results remain floats. Use Number::float(value, width) and explicit-width array construction outside number.rs instead of implicit f64 conversion.

Under •prefs ["kinds":"static"], a result's kind follows from its arguments' kinds alone, as meta/kinds.md describes. Numeric carries kinds and int. pervasive::map chooses a dyadic call's kind through joined, which applies rustymath's DType::common after Numeric::kind. Atoms promote as arrays do. Python's API makes its scalar arguments weak through Value::weakened. The loops keep the failed results that keeps names: floats' NaNs, and the wrapped integers of objects that report WRAPS. The kernel tables choose float ⌊ ⌈ × and wrapping negation from run.numeric(). Stored::build_at keeps complex results complex. Primitive::number gives every pervasive result on numbers, as well as each step of the fold of numbers, and statically adjusts it. Number::parse keeps complex literals and checks integer literals against the int width.

Settings live once for the whole process, in settings.rs, and are read from there, not passed through functions, except where a read is slow and on a hot path. Each setting that calls and scalar operations read is an atomic: the numeric settings, the hanzi aliases, the batch pref, the fusion report flag and fusion's small sizes. The display settings sit behind one lock. •prefs writes them, the lexer writes a source's leading •prefs header as it reads it, and settings::reset restores the defaults, as ]clear and the test harnesses do. Nothing copies, captures or restores a setting. A pending array computes under the settings in force when it computes, and a module that •load runs changes the settings for the whole process. Rustymath holds the thread count, the one setting that it reads itself, and •prefs writes it there. Each call reads the store once where it starts: pervasive::halves, which every pervasive entry point goes through, fusion::defers and defer, Domain::of, the lexer and Storage::item's complex items. Values derived from the settings within a call stay explicit arguments. halves passes 16-bit floats' widened settings to its inner call. The loop structs keep them because their closures run on rustymath's worker threads. A fused graph reads the settings once when it's built at forcing, for the same reason. Number's tolerance methods and Primitive::number take them because they run once per item. The parser's leaves take a width marker's width, and matches_within takes ⍣∞'s convergence tolerance. Imports build complex numbers with Number::complex_in_session: Python's _Session.array and the JSON protocol's request. JavaScript values hold no complex numbers.

Only f64 comparisons use the session tolerance, from 0 to 1E-14. f32/f16 compare exactly except fixed-point convergence, which uses four ulps; f64 convergence uses 1E-14 independently of the preference. Index/count/Boolean/character-offset conversion uses near_integer with the fixed default tolerance, not the comparison preference. The tolerant types and objects come from rustymath. Keep independent inside/outside-tolerance tests with exact/mixed controls; don't use language comparison as its own oracle.

search.rs selects compact exact or tolerant backends. Exact integer/Boolean/character storage uses keys; float searches use their width. Integer haystacks accept the whole-number shortcut only for exactly whole float needles. Tolerant indexing validates whole-key matches unless the haystack is exactly whole and falls back to buckets on mismatch. Classification intentionally chains tolerance through sorted runs, so it need not match every member to one representative. Other cells use exact hashes or first-number buckets with Value::matches confirmation, falling back to pairwise comparison where indexing is unsuitable. Grade is stable. Position buffers use u32 and become integer storage without another pass. ⍷ and monadic Key take each class's positions from search::class_positions, which keeps a class's own buffer for 32-bit positions and copies narrower ones once from the shared buffer. Dyadic Key selects rows through Grouped::each slices. Key with the operand ≢⊢, which ≢∘⊢ also builds, counts its classes through search::class_counts and builds no positions.

Domain and with_field! select working reals, Complex64 or BigRational for generic numeric algorithms. Compact storage selects the domain without scanning its items. number::read converts once; Field::array builds compact float/complex results without a Number per item. Each caller lists the domains it supports. Compact complex input does not take a real path merely because all imaginary parts are zero; results built through Value::complex can normalize to float storage.

Rustymath owns the mathematical algorithms. Basedpl owns pervasion, storage, numeric promotion, axes and language policy. Code whose inputs and outputs are only buffers, shapes and plain numbers belongs in rustymath, even when only basedpl calls it. Approximate matrix solve/pseudoinverse and decomposition use rustymath factorizations. On macOS, rustymath sends much of this work to Accelerate, as its DEV.md describes. No platform-specific adapter belongs here. The Cargo configuration, for Cargo's own builds, and pyproject.toml, for the wheels, both set the macOS deployment minimum to 15. Exact solve/pseudoinverse/determinant use rational field algorithms. Generic permanent and polynomial helpers also run at floating/complex field types. Singular/wide matrix division gives least-norm solutions. LU eligibility and SVD cutoffs are rustymath policy, not duplicated here.

Hash maps and sets come from rustymath::hashing, as rustymath's DEV.md describes. Use its HashMap, HashSet and Hashing rather than another hasher, so every lookup in both projects inlines its hash the same way.

•decompose/•decompose⁻¹ share domain reading and Factor dispatch, with options for kind, vectors and complete QR. Polynomial adapters choose compact evaluation or generic field operations; gradients differentiate coefficients before evaluating. Number-theory adapters supply cancellation checks and assemble result cells. Distribution constructors expose native density/CDF/quantile/sample functions. Compact continuous calls use slice objects. Discrete samples and finite discrete quantiles are exact integers.

•rand seed makes a ChaCha8 stream behind a mutex (distribution::Stream), which every copy of its record shares. ¿ and a distribution's sample without a generator draw from the session's stream, which •prefs ["seed":n] reseeds. Draws take a stream's numbers in the order they run. The algorithm is fixed, but range/distribution sampler changes can change draws; the reference sequence detects that. Sampling from a generator record uses its roll function to recover the same stream.

Kernels, fusion and threading

A dispatch selects a primitive before its loop. Concrete Kernels tables call rustymath objects: many for slices and one/checked for scalar Numbers. at_widths! writes f32/f64 tables once. Arithmetic, circle-code conventions and scalar policy remain in the numeric modules rather than a second evaluator.

Cheap mixed kernels in Mixed convert as they read through Source. Other kernels use Chunked, which reads each argument through rustymath's Side, converting a run at a time before calling one homogeneous implementation. Direct conversion loops are worthwhile for cheap arithmetic; sharing expensive kernels avoids a storage-type cross-product of machine code. Compile only operand pairings that can occur. Same-type arguments can reach rustymath in one call; other shapes expand reused chunks or use block mappings to avoid per-item division.

Custom elementwise closures use rustymath::pairs and its uninitialized append path. Function objects use their own slice forms. Keep per-item bodies inlinable across codegen units; passing a closure by reference can introduce an uninlined Fn shim. Pass it by value, or wrap an existing reference in a new closure. Hot loops use slices and fixed chunks rather than iterator helpers that introduce calls in the incremental build. Avoid captured mutable flags that live in memory and block vectorization.

A failed compact kernel leaves placeholder results and triggers a wider/exact/complex or scalar path for the whole call. Complex fallback is shared with Number policy. Overflow detection belongs to rustymath: the integer forms of its arithmetic objects, its checked reductions and its checked products. Cheap integer multiplication uses bounds to select an exact width without a per-item overflow check. Add no blanket NaN/signed-zero canonicalization pass; algorithm-specific failure checks and rustymath's SIMD kernels remain part of their algorithms.

fusion.rs defers eligible float calls: Primitive::pervasive_mapped gives a pending array (Data::Node holding a fusion::Pending) in place of the result. Pending arrays survive assignment and link to the pending arrays they read. Reading one forces it through ArrayData::storage, which builds a Graph from the pending calls that only that graph reads and runs it block by block through the same pervasive kernels. Readers that need only the shape, kind or prototype, such as Value::len and Value::environment, answer without forcing. Value::force forces a result, as timing does. Each node writes a block to a buffer that later nodes reuse once its readers have run. A comparison that a later call in the graph reads writes 1 and 0 as floats there (Dyad::compared, rustymath's tolerant::Indicator). ⍚ and @[p] run cases that can't fail on every item and select their results by a Boolean mask (selected in eval/operators.rs). fusion::select defers the select where the mask is such a comparison, and otherwise runs it at once through rustymath's chosen. Small graphs run unfused unless a supported reduction consumes them directly. Node-making code stays out of line from dispatch, because inlined code grows the frame that stays on the stack at every recursion level. The module doc owns eligibility rules and limits; don't copy its thresholds here. A structural copy of one source through rustymath Steps (remapped) whose result has at least MIN_ITEMS items gives a pending copy (fusion::copy) for any compact kind. Its map is a Mapping::Steps with one entry for each result axis. Broadcasting's maps use the same variant. Forcing composes a read of a copy with the copy's map, and moves a read with no repeat or fill under the pervasive calls it reaches (Pending::resolved), so 2×⌽v+1 reads v backwards in one graph. A read that copies (rustymath's Mapping::copies) moves only under a call that reads one array. A call whose two array arguments copy arrays of one layout through one map runs on those arrays, and the map then copies its result once (fusion::copied_once). Graph::block shrinks a graph's blocks where its buffers wouldn't fit in the cache. Rustymath's agreement::expanded reads a forward run in place, and copies anything else through gather::walk_range. A copy that no call reads through is walked as remapped walks it. Fused and ordinary folds share rustymath's reduce::along/fold_into traversal. A fold of a copy of windows along its own axis slides over the copy's source instead (fusion::slide, through rustymath's reduce::sliding). Under ∂, fold_rule takes a maximum's or minimum's positions in such windows from the source (fusion::picks).

Ordinary pervasive kernels also read pending copies through their maps. fusion::source snapshots the source and mapping without taking the pending work, and rustymath composes that mapping with the call's agreement. pervasive::Input keeps the snapshot alive while kernels read its storage through existing blocked readers. Folds by rustymath's reductions read a pending copy through fusion::source too (pervasive::mapped_fold). reduce::along_mapped folds a copy that reads a contiguous block of its source with its axes in another order, as Mapping::block finds it, in the block's own order. Transposes, reversals and windows that don't overlap read such blocks. Where the totals come out in another order, pervasive::fold makes them a copy through the tables that along_mapped gives, and the next fold reads that copy as a block again. It reads any other copy block by block. A read of a few items from a pending array goes through fusion::narrow, which narrows the pending work to the positions read without consuming it. A chain of copies becomes a read of its source, for any item kind. A call or graph that nothing else holds becomes one over only those positions, when the read takes at most 1 in 128 of its items. Where maps don't compose, as a rotation's doesn't with a take, such a read lists its items' offsets. A pending array that a name holds is computed whole on its first read and keeps its items, so a loop of single reads doesn't compute it again. fusion::item serves single items and picks, and Gather::walk serves small copies and gathers through fusion::walk_through. Checks of a whole input's values, such as bounds and finiteness, read only the items that its logical ArrayView selects. Unsupported compositions and dense readers retain the pending array's one cached compact result.

explain.rs holds the events that •explain reports. One check in Function::reported serves both •prefs ["fusion":$t] and •explain: a call that computes a pending argument writes the pref's line and records a forced event. Graph::explained records a fused group where a graph runs, and where a fold reads through one. Idiom::call records each fast combination taken, by the name Idiom::name gives. linear::scatter_add, and the transpose of a gather that scatters a source's items, record each accumulation. Selection::write_into, where assignment and Under write, records each write that leaves a compact array mixed. Fusion runs graphs without a session, so events go to a sink on the evaluating thread, which holds them only while •explain runs. Outside it, call pays one branch on the session's flags. Each record node and pending array holds an explain::Live guard, which counts it for the thread that made it until it is dropped, on any thread. •memory reports those counts, and Value::footprint walks a value's allocations for its bytes.

derivative.rs implements ∂. A dual is a tag, a primal and a tangent: a transform::Carrier, held in Value::Carried for a number and in Data::Carried for an array, whose primal and tangent can be pending. A record of linear calls is the other kind of carrier. Routing (transform::call) hands a call to the newest level among its arguments, as Value::newest finds it. It reads their outer layer, but a newer level inside an argument held in mixed storage wins, as an array of dual atoms beside an older dual shows. A call whose newest level is a ∂ call goes to derivative::call, which reads how to run it from the form's facts in Primitive::info() (primitive::Facts). It applies the partials table to pervasive forms and its own rule to ⌻, and calls forms whose results carry no tangent on the primals. Structural glyphs, Under and selective assignment need no rules. Their own code moves each part of a carried array through the carrier's moves: Carrier::view, Carrier::at, Carrier::copied, and transform::written for writes, which a record answers through its ids. Gather copies a dual's primal and tangent together through derivative::Join. A glyph that copies its right argument through one plan, and a broadcast, copy a record tangent as one Op::Copy of the plan's Steps (primitive::copied, and Form::Moves's planned). Its transpose reads through rustymath's Steps::transposed: the inverse map for a copy that reads each item at most once, and a correlation for windows that move by 1. A copy of a copy transposes in one read where the composed copy still transposes by reads (linear::composed). The record keeps the chain, because recognising windows needs each copy's own shape. A sum along a window's axis transposes as a sliding sum (linear::slide), and a spread axis as a sum along it. Windows that move by more than 1 transpose by rustymath's signal::overlap_add, which adds each window back where it read (linear::strided_back). Other copies, and copies of several sources, keep ids and scatter. •explain lists each scatter as an accumulation. In reverse mode, the rule for a ⌈ or ⌊ fold is a sum of the tangent times a mask of each lane's chosen item. Its transpose reads. Mix of a dual whose primal is nested mixes the primal and the tangent apart. fold and scans with +, ×, ⌈ and ⌊, +.× and outer products have rules, and kernels that read numbers directly leave active arguments to their general paths. Folds by primitives, products of primitives, ⌹, ⊥ and stacks of matrices route through transform::call as operations (primitive::Operation), so their rules (derivative::solve, decode, product_dual, determinant and fold_dual) run on a &Context and call Operation::call for the products, folds and inverses that they need. Rank's routes for whole arguments send stacks that a level carries through Pairing::Matrices, so ⌹⍤2, +.×⍤2 and -.×⍤2 take their stacked rules in one call, with the batch pref on or off. Attached rules, system functions, and folds and products by functions that aren't primitives route through the same transform::call, as transform::Op's other variants. The rules for ⌹ and -.× keep the glyph's own result as the primal, so that a function computes the same values with or without ∂. Their tangents and transposes multiply by R, the inverse that numeric::matrix_inverse takes from one factorization through rustymath's linalg::inverse. regular_inverse gives UNSUPPORTED where rustymath's regularity test finds the matrix singular. With only B active, R is ⌹A, a pseudoinverse included. With one number in x, f∂ x runs f once with x seeded along it. With more, linearized pushes a record level just below its ∂ call's level, then runs f once with each part of x seeded with a record at that level, from linear.rs: a tangent whose items are never computed, which the same rules extend into a graph of linear calls. A call whose newest level is a record level records the linear map that its form's facts name (linear::call). reverse then reads the Jacobian from the record, by a transpose for each result item or a forward evaluation for each number of x, whichever are fewer. A function with a JVP rule that f∂←g attached calls the rule with ⍺ a dual of zeros whose tangent is the argument's, so in reverse mode the rule extends the record too (derivative::attached). A call of such a function with two arguments gives DOMAIN ERROR wherever ∂ needs a derivative through it (derivative::two_arguments). Code that reads a dual array's storage gets its items as dual atoms, which is correct at per-item speed. A rule packs an array that holds its level's duals, at any depth, into one dual before it splits the array (derivative::packed). A reader outside a call sees a value through transform.rs's read point. Value::observed, which counts, positions, ranks, selectors, ≡ and guards' predicates use, reads a dual's primal. The integer readers of Items read carried items through it (Value::observed_number), so a count or position that ∂ carries reads its primal. It returns a Result whose error, transform::Stop, names the level that owns a batched run. A stop is ErrorKind::Stop, which no guard catches, even one that names it, and which a handler that catches every error lets pass (ErrorKind::is_control). Display reads through Value::displayed, because it never runs inside a batched run. Value::computed, which kernels use through primitive::numeric, gives UNSUPPORTED for a dual atom. Writes to bindings outside a ∂ call are recorded and stripped when it returns.

batch.rs makes a map's batched call. batch::tried, which each_pair calls, begins a batched level for the run (transform::Kind::Batched), wraps each argument that has a frame in a Batch carrier at that level, calls the operand once, and ends the level. Each map first lays its arguments over its frame through its own agreement, the pairing that its calls on each cell read (batch::Arguments::laid and agreement::laid_cells). each_pair makes no batched call for one cell of atoms, which runs faster without one. Routing hands each call whose newest level is the run's to batch::call: a primitive, an operation that primitives name, such as a fold by a primitive or +.×, or an operation by a dfn. It leaves ⍕ and system functions to their own code, whose effect check decides for every argument (Levels::effect). batch::call runs the call on the whole arguments by Rank's routes: eval::whole_cells through batch::whole_call, and eval::folded_cells for a fold. A primitive therefore batches wherever Rank runs it on whole arguments. A fold or scan that folded_cells doesn't cover, such as one by a dfn, runs the fold's own loop over the batch's items. Each stage is then one call, on item j of every cell (Batch::at). fold_array and primitive_fold join a result that holds stage batches into one batch (joined). Every fold and scan route decides its structural cases, such as a unit, empty lanes and the result's shape, through FoldPlan, and ends in folded, which alone gives the result its layout, fused folds included. A fold or scan by any function other than a primitive, over more than one lane, also tries a batched run of its own: operators::by_lanes moves the fold axis last and runs the derived fold over the lanes through batch::tried. operators::by_triples does the same for f.g where g isn't a primitive: one batched call of g on a frame of rows by contraction by columns, then the fold by f along the contraction, for each chunk of rows whose triples hold at most batch::CHUNK items (operators::by_rows, which products of primitives share). Each runs its usual loop from the start where its try gives None. A fold keeps its stops on its operand's Definition (Function::remembers). batch::tries holds the conditions for any try. A call that no route covers stops the run (transform::Stop). A call whose constants with axes, repeated over the frame, would hold more than 4 times the items of its arguments and more than batch::CHUNK items, 2²¹, stops the run with the cells of a chunk whose share holds at most CHUNK items (oversized, Stop::chunk). tried then runs the operand once on each chunk of that many cells (batch::chunked), each as a batched level on a frame of one axis, and joins the chunks' results as the run over cells assembles its cells' results. A chunk that stops for too many items again starts the chunks over with its own smaller chunk. Where one cell's share alone passes CHUNK, the map calls its operand once for each cell. Code that must run once for each cell asks the levels first (Levels::effect): output, input, ⍕ of a carried value and system functions other than random draws (Effect::Acts), a guard or a call on prototypes that would catch an error (Effect::Catches), and a write to a binding outside the map's frames (Effect::Writes, through Levels::record_write). A read through Value::observed, as a count or a position makes, stops the run too. A guard whose predicate is a batch of the run splits the run instead (Session::guard through operators::guard). batch::truths gives each cell's Boolean, and each side runs once, as a guard case on its own cells (Levels::case), through the Cells that ⍚ uses. Each batched binding in the frames that the run started is read at the side's cells (Session::restricted, through batch::read_cells and operators::cells_at), and Cells::assembled joins the sides' results. During a case, the run's frame is the case's cells, a random draw draws for the case's cells, and a write to an enclosing frame stops the run. A guard of a dfn that already splits the cells in another frame stops the run as a recursive call (Cause::Recursion), and sides whose cells differ in layout or kind stop it as a call with no rule. Each stop carries its cause (transform::Cause), which says how long a dfn operand remembers it (Cause::kept). An effect, a write, an observing read or a recursive call lasts for every later call. A call that no route covers lasts while later calls repeat the arguments' kinds and cell shapes, and the memory guard lasts while they also repeat the frame's shape. A caught error never lasts, because whether an error arises depends on the cells' values. The dfn's Definition holds what it remembers (Definition::remember): ALWAYS, or a signature of the arguments (batch::signature), each holding the stop's cause (batch::holding). A later try that it covers falls back without calling the operand, with an "an earlier stop" event that names the cause. A stop's own fallbacks event names its cause (Cause::reason), and the glyph of a primitive whose call no rule covers (Stop::refusing). Code with no rule for batches that reads a batch's items gets item carriers, item i of every cell, and records an itemwise row, route "batched items". explain::itemwise is the one helper for routes that handle items one at a time, and the innermost call claims its counts (Function::reported), as forced does. A kernel that then reads a number meets the stop that Carrier::computed gives a batch. A missed reader therefore costs speed or a fallback, never a wrong result. A batched run can also start inside a ∂ call. The run's level is newer than the ∂ tag, so the batch rules take each call first, and their calls on whole arrays reach the derivative rules. A batch's whole can then carry a dual, whose kind batch checks read through Value::template_dtype. Stacks of matrices whose wholes carry a dual take the derivative rules through Pairing::Matrices (eval::whole_cells), because the stacked kernels read compact storage. A ∂ call inside the run differentiates with respect to batches of atoms or of arrays. derivative::numbers counts a cell's numbers from the batch's template, every cell shares each direction's seed, and the Jacobian joins into one batch (batch::joined). A move with no plan on a dual whose primal is a batch runs on the primal and the tangent apart (derivative::apart). Adjoints that a transpose adds into a batch add as one sum on its whole (batch::scatter_add). An attached rule runs on the whole batch at its argument's ∂ tag (batch::call), and batch::split_whole and batch::dual_of_wholes keep the newest level outermost. A roll draws once, on its argument laid over the frame (distribution::drawn_items), and deals and samples draw for each cell in turn (distribution::each_cell), all from one stream in cell order. Where each cell draws once, seeded draws match a run cell by cell, except under a guard that splits the cells, where each side draws for its own cells in turn. A try that falls back puts back the state of each generator it drew from (distribution::settle). The map's result must be a batch at its level, a strand of batches whose kinds join (joined), or an atom or compact array with no carrier, as every cell's result. Any other result falls back. A pervasive call stops where its result's kind differs from the kind that one route gives every item (pervasive::expected). Each cell then keeps the kind that a run cell by cell gives it. reference::check runs each reference case with the batch pref off, then on, then on under fusion's small sizes (fusion::Sizes::SMALL), and compares the storage kinds of the later runs' values with the first's.

Rustymath owns numeric folds, scans, adjacent pairs, grouped reductions and integer bounds. Basedpl selects kernels, tolerance, seeds, storage widths and exact fallbacks. Folds by algebraic float operations can regroup. Contiguous subtraction/division folds use Alternating; quotients fall back to item-by-item folding if their products leave the normal range. Boolean scans compose truth-table maps; comparison scans compute both possible totals in bulk and use reduce::chosen. Seeds, axis lanes and scalar fallback preserve the language's accumulation direction. Generic reductions stay right-associated and preserve operand call order/effects. Monadic f.g is the exception: it memoizes each minor's expansion, and a cached minor does not repeat operand calls or their effects. Determinant/permanent fast paths use the appropriate rustymath backend.

The automatic Rayon scheduler serves rustymath elementwise many and pairs, broadcasts through agreement::blocks, folds through reduce::along, fused graphs and matrix products. Scans don't split. Decompositions split in the products of their blocked updates, the passes of their Hermitian and bidiagonal reductions, and their divide-and-conquer merges. rank sends dyadic +.× and ⌹, monadic ⌹ and -.×, and •decompose over the 2-cells of an unkeyed stack with compact storage to primitive::stacked and stacked_decompositions. They run each cell through the rustymath function it uses alone. A pervasive operand, or Rank nested down to one, runs as one call: each argument gets length-1 axes after each level's frame, so leading-axis agreement pairs frames with frames and cells with cells. Arguments with axis names still go cell by cell. Rustymath's parallel::stack_with splits whole cells across threads in rounds, with check_now between rounds. A caller whose checks run far apart, such as a round or a rustymath _with callback, calls check_now, because Context::check reads the clock only on every 64th call. The scheduler adapts to call cost, uses RAYON_NUM_THREADS by default and accepts one override for the whole process through parallel::set_threads (0 default, 1 serial), which •prefs ["threads":n] writes directly. The timing harness sets one thread after each reset of the settings. Nested Rayon work does not split again; wasm is serial. A fused graph splits in pieces of whole blocks: an elementwise graph through parallel::extended, and a folded one through reduce::fold_pieces. On one thread an elementwise graph appends straight to its result, because copying each block into a piece's slots cost up to 1.2 copies there. Small converted chunks limit the work available to the scheduler. Don't infer multicore execution from a large array alone.

Packed numeric, Boolean and equality-count inner products use rustymath::linalg. Other native pairs use its bounded inner_with runner and reduce::right_fold, carrying the right tail across blocks. Basedpl supplies its primitive kernels, tolerance and exact-widening fallback. Integer calls use Inner::checked before relying on an exact compact result. pervasive::inner takes each operand's Orientation. Its float and complex products read a transposed operand in place, as reverse mode's product_transpose needs. A float +.× whose operand is a pending copy, or a view of one, hands rustymath's Inner::inner_mapped the copy's source and map (fusion::source, pervasive::mapped_product). Rustymath gives gemm an operand that its map reads at strides, such as a transpose, where it lies (Mapped::matrix). It reads any other copy a block of rows at a time, except in a product with one column, where the copy's rows are adjacent in storage, as for a vector or a single-channel image. It then adds each column of the copy where it lies (linalg::by_columns). Neither builds the copy. A copy that a name holds is read again for each product rather than cached. Reverse mode transposes a product whose operand is windows that move by 1 as one product: the windows of the zero-padded cotangent with the kernel reversed (linear::convolved_back, rustymath's signal::correlation). Structural gather/compress and interval index also reuse rustymath. Measure changes to generic or backend code in both projects. The callers' codegen and layout decide costs that a kernel measured alone misses.

Evaluation and frontend boundaries

eval_with/call_with install EvalOptions. Echo defaults to true; disabling it suppresses implicit display, not explicit output or display commands. Output is ordered Output { kind, data } MIME bundles. An optional sink receives events as produced; otherwise the evaluation retains them. Values, functions, operators, outputs and errors remain separate results.

Context carries source and session. Checks at interpreter boundaries and long loops count down before reading clocks/flags or calling the poll hook. Individual BigInt operations and bounded native kernels are not preempted. Every function receives &Context, which holds &Session. The session's state sits in Cells and RefCells whose methods hand out owned values, so no caller holds a borrow of it while a dfn evaluates (meta/session.md). An optional Input supplies ⎕ and •nget "-"; without one they raise IO. Python uses cooperative cancellation. The process worker can kill an unresponsive process after a grace period, losing its state; requests are never replayed.

Session::call resolves and calls a function expression on existing arrays without temporary argument bindings. Retained functions use call_function_with. Error objects retain source and byte spans, with Unicode-width display diagnostics. Python translates spans to character offsets; process JSON keeps bytes. Native function/operator handles cross the in-process Python boundary, not process JSON.

Python

python/basedpl/__init__.py owns the shared bpl workspace and Array/result/error wrappers. fn aliases bpl.fn; builtins and magics share that workspace. Registering the magics gives it the REPL's display defaults, through Session::make_interactive. Plain calls print explicit output and return Array, Function or Operator values. Capturing calls return Result. Capture mode is positional-only because keywords bind BPL names. _Session.eval and .call are the PyO3 entry points; request is their internal Rust helper.

functions.py constructs native nodes and lazy exports from Rust metadata. Builtins win name collisions with workspace names; operation names choose valence/currying. __all__ omits Python-builtin collisions but leaves explicit imports available. Function calls with keywords use a keyed left argument. A single positional argument stays the right argument unchanged; zero or multiple positional arguments form a vector. Operator properties and _Pending assemble native nodes, never generated BPL source. printing.py reads node parts without evaluating or resolving names.

.fn() retains a late-bound expression. Calls resolve affected nodes against the calling session while sharing unaffected graph nodes. This preserves primitive fast paths, inverses and reduction identities. The binder still resolves ordinary names during execution. Cyclic resolution is a LIMIT error.

Arrays retain native values. .py and .np make copies; NumPy is optional and lazy. The native _Array buffer export lends compact items read-only. An unbuilt structural copy whose read is affine (Value::affine) lends its source's items with strides, so windows reach NumPy without being built. Numeric buffers import with one copy through rustymath_buffer::read_array. Value::imported_buffer preserves supported item widths. The shared reader normalizes signed bytes, wider unsigned integers and foreign byte order. Sibling snapshots carry keys and names. A NumPy uint64 array containing a value above int64's range imports item by item as exact integers. own() moves compact items into a writable NumPy array where nothing else holds them, holding Booleans as u8 and characters as i32, because NumPy can write any bytes. Exact integers/fractions cross through native integers, not decimal strings. String/object arrays convert per item. Python data converts in Rust: the session's array (import in py/src/lib.rs) walks nested lists through rustymath_buffer::walk, the walker rustymath's Python package uses, with a collector that nests a level whose items differ in shape. Lists follow the shared import rule, and dictionaries read as literals do. rustymath_buffer::labels is the one rule for a dict's keys and a frame's column labels, in both Python packages. A frame reads through rustymath_buffer::frame as a keyed vector of its columns, which .df turns back into a frame. None reads as NaN as an item. A bare None doesn't convert, so an operator given one returns NotImplemented. syntax.py wraps the native syntax tree with character spans and span-editing helpers.

Array(existing) creates an independent native handle. Assignment and .keys/.names setters replace the owner's handle without changing prior views or captured functions. Both Python packages read written selectors as structure::IndexPart tokens through rustymath/buffer/src/selection.rs, and plan them with structure::plan_parts, the structural planner that ⌷ also uses. structure::addressed_parts routes source groups and expands Rest against the applicable frame. On a BPL array, Python's own kinds (selection::native: slices, keys, integers and lists of keys) decode in the buffer crate, Boolean masks select where they hold true, and other selectors become BPL values that decode as ⌷ decodes them (Value::planned). Pick paths select alone, as ⌷ reads them, keeping the written parts until the ending cell's geometry is known. _Array.select and _Array.assign use carrier-aware structural selection and writeback. Python assignment uses leading-axis broadcasting. Language writeback retains its own agreement.

Each _Session request waits for its evaluator mutex without the GIL, then evaluates on the caller's thread with the GIL released. A separate interrupt handle supports interruption from another thread. Signal polling briefly takes the GIL. Python input and optional streaming-output callbacks also cross at explicit boundary adapters. No Python numeric objects enter core values; arrays can be dropped on any thread without an unsafe Send implementation.

Process, CLI and notebooks

protocol.rs owns JSON request/value/error/output encoding. Requests are objects with fields such as code, call, args and bindings. Exact integers use arbitrary-precision JSON numbers; fractions and complex values have tags. Output bytes use base64. The worker omits eligible keyed entries holding functions/operators and returns value: null for other non-exportable values, retaining display output. JSON data serialization is a separate contract and can reject such values.

cli/src/worker.rs processes JSON lines in order. A separate reader handles interrupt controls but never evaluates BPL. IDs are optional, and bad requests don't end the session. python/basedpl/worker.py owns process lifetime, deadlines and hard-kill fallback. Protocol serialization walks native values directly rather than building an intermediate JSON value tree for every reply.

CLI -e, files and REPL stream outputs to stdout. Failed writes interrupt execution; a closed pipe exits successfully. -e, files and interactive input provide StandardInput; program text read from stdin does not. The REPL parses for completeness without evaluating twice. Rustyline owns terminal editing/history/modes; only typed backtick sequences auto-expand. Paste/history/navigation cancel automatic expansion. Shared symbol metadata supplies completion/highlighting. Keep pseudo-terminal tests for Ctrl-C/Ctrl-D and paste behaviour.

The Jupyter adapter in cli/src/kernel.rs uses kernmini and a ThreadWorker. It keeps transport/interrupts responsive, maps explicit output to streams and implicit output to results, and routes notebook input through input_request. The wheel and local install use the same kernelspec. inspection.rs embeds the glyph/system pages for help and uses the same names as the REPL and Python. Change names and shortcuts in symbols.rs/shared layout data, not each frontend.

Browser

cargo wasm builds an incremental release-profile npm package with wasm-bindgen glue, language/input assets, grammar and canvas support. --out DIR leaves the docs' local playground assets alone; --strip removes function-name metadata for distribution. Keep wasm-bindgen glue and crate versions aligned through the workspace lock. Avoid fat LTO here because rebuild cost and enlarged stack frames conflict with browser development and recursion limits. Liftoff, V8's first compiler, gives each wasm local its own stack slot, whether or not the path that uses it runs. So a function that recursion passes through keeps its general path out of line, as Each, Rank and the outer product do (each_items). For the same reason, ErrorKind, which the Result in every frame holds, stays at 16 bytes in wasm32 (transform::Stop). To find a frame that grew, read the function names in the stack of the RangeError at an overflow, then compare those functions' wasm locals between builds. wasm-objdump -h DIR/basedpl_wasm_bg.wasm lists the module's section sizes, including name. twiggy diff matches functions by full name, including crate hashes that change between builds. To compare two builds by function, compare their twiggy top -n 1000000 -f json listings with the hashes removed.

Browser Session.run streams MIME events and returns a protocol error or null. eval transfers values through js.rs but discards output and cannot export BPL functions/operators; bind imports page values/functions. Typed arrays transfer without another page-worker copy. JavaScript functions live in a thread-local registry and core function nodes hold slots, preserving Send/Sync values. host_web.rs implements the browser's clock, synchronous I/O/fetch and RegExp behaviour. File/URL reads resolve against the configured base. Unsupported native filesystem/locale operations stay unavailable; browser •nput writes only stdout and •js is browser-only.

The playground and gallery load released assets from jsDelivr, or local nbs/playground/ assets on localhost. Shared bpl.js owns editors, worker startup and output. Restarting a trapped/stopped worker loses its state. worker.js, page.js and gallery.js own their respective protocols/UI; nbs/driver.js documents canvas drawing data. Gallery demos are .bpl sources with shared preferences, checked natively by language_examples. Browser recursion tests cover both V8 compilers and fail with LIMIT before stack exhaustion.

System functions and rendering

system.rs owns case-insensitive system names, valence, item mapping and result prototypes. Native records use ordinary function nodes and calls. Empty native item mapping returns its declared prototype without calling the function. data::Options is the shared keyed-option decoder; formats don't invent separate option parsers.

optimize.rs implements ⇄ on rustymath::optimize. The left argument holds the options. When the method can use a derivative (none named, L-BFGS, Levenberg–Marquardt, CG or MINRES), one linearisation at the start (derivative::linearized) finds whether ∂ differentiates the function, and gives its value there. Goal evaluates the function at points in the start's layout for minimize. When the search runs L-BFGS, each value comes from a linearisation that gradient then transposes at the same point (linearized_at), so a value and its gradient run the function once. With "solve":$t, Residual is the Mapping that solve takes, or that solve_affine takes with "linear":$t as well. Where ∂ differentiates the function, Residual gives JVPs and vector–Jacobian products from one record at each point, or from the start's record for an affine function. At a later point where ∂ gives an UNSUPPORTED error, Residual::jvp declines, and rustymath takes forward differences for that Jacobian and later ones. A square linear system takes no products with the transpose, so it runs GMRES on A, not the normal equations. The function in option precondition gives the preconditioner.

CSV columns, JSON arrays and Python lists share data::imported: a non-fill finite float or any nonfinite float triggers float promotion when every number converts losslessly. Finite fills alone do not trigger it. Otherwise items keep their kinds. JSON objects and Python dicts read as literals do, through keyed::vector and Value::from_parts. Binary data uses exact bytes. Writes validate before opening and create exclusively unless overwrite is explicit. CSV/JSON/XML exports use •csv⁻¹, •json⁻¹ and •xml⁻¹.

regex.rs shares host regex state between match/replace native functions; hosts retain their own regex syntax. xml.rs represents keyed element trees and distinguishes XML escaping/validation from HTML raw script/style and void-element rules. SVG uses its own renderer. image.rs owns pictures, their options and animation timing, as plain pixel buffers. Its codec module decodes and encodes those buffers through ImageIO on Apple targets (image/imageio.rs) and through the image and png crates elsewhere (image/codecs.rs). Native/browser renderers use the MIME/byte formats their frontends support. plot.rs stores specs and draws SVG on demand; the renderer owns defaults/validation. Rendering options belong in their user pages and implementation comments.

display.rs owns plain/boxed/function display, elision and MIME rendering. Display settings affect implicit presentation, not returned values or ⍕; explicit output prints all items. Display and source text read the items, not their storage: Value::shown_dtype (array/text.rs) gives the dtype that compact storage of the items would take, by storage::items_dtype's rule for items, which the shared ₓ or width marker, plain display's choice between brackets and a strand, the bracketed grid that plain display and ⍕ share (display::table and display::bracketed), a keyed table's corner and the boxed marker all read. •mime calls a renderer with echo disabled and cancellation active. Failed implicit rendering falls back to text; interrupts/timeouts propagate. canvas.rs serializes drawing data and typed bytes; canvas.js and nbs/driver.js own browser playback. Keep formatting, serialization and rich output as separate contracts.

Tests and reference data

tests/core.rs checks public storage/layout/API behaviour, ownership, parser diagnostics and recovery. Use equiv_in! for session workflows and fails_in for errors; each expected expression runs in a fresh session after its code, under the settings that the code leaves. tests/core.rs never changes settings. The settings are one store for the whole process, so the checks that change them live in tests/settings.rs, which runs them in turn in one test, each from the defaults. Both binaries share the helpers in tests/common/mod.rs. Keep constructors for representation/prototype/buffer checks. Self-contained language cases belong in tests/reference/core.bpl. cli/tests/cli.rs and Python's pseudo-terminal tests exercise the installed process/editor boundary.

reference.rs serves Rust tests and worker requests. Every reference case uses a fresh session; documentation examples share a session within each BPL code block, which starts from the default settings. A reference file's leading •prefs line sets the settings of all its cases. Each case runs three times, with the batch pref off, then on, then on with fusion's small sizes, and each run starts from the defaults and the file's header. The cases of a file run in parallel, one run at a time. A case whose code calls •prefs, such as a seeded draw, carries serial and runs alone after them, and the harness rejects a •prefs call in a case without it. batched leaves out the run with the batch pref off, and unfused the run with the small sizes. Each expectation evaluates after its code, under the settings that the code leaves. .bpl files define active coverage. Captured foreign expectations have no exactness; local expectations compare exactness and structural values independently. A local expectation also fixes where storage is mixed: the result must be held in mixed storage exactly where the expectation is, at each level of nesting (kind_difference), unless the case's header carries mixed. Pending gaps belong in inventory JSONL, not passing error tests. See tests/reference/README.md for format and review/activation workflow. python/basedpl/reference.py, bpltests.py and scripts/reference.py own import, capture, inventory and worker checks. lib/*.bpl are readable dfn examples; modules import other library files with private relative names.

tests/scaling.rs measures time/allocation growth and result-size allocation budgets. tests/speed.rs reads native/browser budgets from speed.txt through timing.rs; the worker and Python measurement tools reuse it. tests/units.rs reads existing incremental object files and checks unit size without building. Python tests cover conversion/cancellation/frontend behaviour, not a second Rust semantic suite. Combine related checks into readable tests.

Documentation and generated assets

nbs/index.ipynb generates the homepage and README. Reference pages in nbs/glyphs/ and nbs/system/ describe forms/options/errors; concept pages explain them. sidebar.yml owns navigation. Edit source notebooks/pages, not generated outputs. magics.ipynb and dyalog.ipynb export Python modules; other Python modules are hand-written. Cargo owns versions.

bpl-nb
bpl-nb nbs/index.ipynb --save
nbdev-readme
nbdev-test --save
nbdev-test nbs/dyalog.ipynb --flags dyalog
nbdev-preview

Quarto renders saved outputs. bpl-nb --save updates notebook outputs without changing execution counts. Use it for BPL examples/figures and nbdev-test for Python notebook code. Rust language_examples checks BPL page/gallery examples; pytest checks Python page examples. Use BASEDPL_PAGE to focus page checks. Don't run a full documentation build for routine output updates. Release preparation runs scripts/prep.py after cargo develop; it calls editor generation and nbdev's export/test/clean/README preparation.

basedpl.editors.write() generates syntax grammars/highlighters, editor templates, keyboard sources/bundles and glyph metadata from shared Rust symbols and keyboard data. It also writes JetBrains assets and Windows editors/windows/layout.h. After a Rust change, run python -m basedpl.editors from the shell, because a running kernel keeps its old extension. layout.json owns composed input; captured keyboards.json supports generation without macOS. basedpl.keyboards.capture() refreshes native data when needed, and keyboards.write() generates regional macOS bundles/maps. Regenerate keyboard sources through these functions, not one-off kernel code. See editors/jetbrains/README.md, editors/windows/README.md and scripts/install-macos.sh for target-specific work. Rebuild the common icon only when its design changes.

nbs/bpl.xml, the APL/BQN grammars and vim rules share generated glyph classes. hpl.xml includes BPL and adds Hanzi aliases from the same symbol table. Use hpl fences for Hanzi highlighting, bpl for Hanzi names. For Hanzi notebooks, set kernelspec.language to hpl and keep kernelspec.name as bpl. These labels do not enable runtime Hanzi aliases. TextMate exports feed Monaco/Shiki. fonts.css owns the BPL/APL font choices; rerun affected magics after changing them. tests/highlighting.mjs, input.mjs, keylayout/Windows tests and glyph-page checks cover their respective generated interfaces. All artifact generation belongs in repository functions/scripts, not ad hoc kernel transformations.

Release

Before approval, verify successful tests for the release commit and the relevant installed artifacts. Reserve isolated wheel installs for packaging-sensitive work or release preparation, using a small relevant subset of existing tests. ship-rs-build --profile dist builds the distributed configuration; bare ship-rs-build uses release for local checks.

Confirm the clean tree and [workspace.package].version, then run ship-release without flags. Python gets that version through dynamic = ["version"]. Fastship pushes/tags the current version, leaves publication to the Release workflow and bumps Cargo without rebuilding. There is no changelog step. Never commit, push, tag or publish without approval.