Project lightning: compiled graphs generated from procedurally wired ones (#726) - #769
Draft
0-jake-0 wants to merge 17 commits into
Draft
Project lightning: compiled graphs generated from procedurally wired ones (#726)#7690-jake-0 wants to merge 17 commits into
0-jake-0 wants to merge 17 commits into
Conversation
Implements #726 — `func!` quotation, `EmitLiteral`, node metadata, and the walker that turns a wired graph back into `nitro!` source. Buys the one thing `nitro!` structurally cannot do: **topology decided by running code**, at compiled speed. let src = codegen::generate("desk", "f64", |g| desk(g, &config))?; codegen::write_artifact("src/desk.gen.rs", &src)?; A loop over N instruments emits as N unrolled pipelines with no loop surviving. Pass 1 ran it; `nitro!` never sees one. ## The mechanism The engine erases closures — a node's cycle is a `Box<dyn FnMut>` over `Rc<dyn Any>` slots — so traversal recovers topology but never what a node computes. `func!` keeps the tokens at the call site, before erasure, and because the text and the closure come from the *same* tokens, drift is structurally impossible. That was the failure that sank the deleted legacy `codegen` retrofit, where a human re-stated each closure by hand. Data configs are not erased at all — the value sits in the op's `Cfg` cell — so `#[op(emit_cfg)]` records them with no user annotation. `EmitLiteral` renders them to self-contained source, absolute paths throughout, since a generated file is compiled in a scope the generator does not control. ## Deviations from the decision doc, both recorded as D28 **§4's `OpFn` bound does not work.** Binding every closure-config op by a trait with two implementors costs closure *signature* inference: rustc propagates it only from `Fn`/`FnMut`/`FnOnce` bounds, so behind any other trait a closure literal loses parameter-type *and* higher-ranked-lifetime inference. Measured on this catalog: ~370 errors across 41 targets, and the residue after reverting the fluent layer was entirely *inside* `nitro!` blocks — `compiled()` emits closure literals into forwarders whose bounds come from the op, so the macro's whole inference rooting depends on that bound being an `Fn` bound. Ops therefore keep `Fn` and never see a `QuotedFn`; quotation is unwrapped at the fluent layer and recorded against the node, which is where a traversal looks anyway. **§3's fn-pointer coercion is not general.** It has to name an arity (`fn(&_) -> _`), so it would leave `join` and `fold` unquotable. An undeclared capture therefore surfaces at pass 2 rather than the call site. ## The surface let net = px.map_q(func!([fee] move |p: &f64| p - fee)); `_q` twins are generated per closure-config op as *inherent* methods, so the extension trait needs no second declaration and no import is required to quote. Tier-2 capture lists record the captured **value** and re-materialise it as `{ let fee = 2.5f64; move |p| p - fee }`, which is what makes a per-instrument parameter generatable at all. ## How correctness is established Emission cannot be proven by inspection, so each expected artifact sits in the test file as a real `nitro!` block and the walker must emit byte-identical text: strings match plus the file compiles means the output is valid wiring source by construction. Parity then runs against the graph it came from, on values **and** tick times, plus the compiled tier. `EmitLiteral` round-trips the same way — every expected literal is also written out as real source. Refusals report every reason at once and never emit a partial artifact, which would compile into a graph quietly missing nodes. ## Known gaps Variadic ops (`merge_all`, `combine`) have hand-written forwarders and no `build` name, so they are refused. Refusals name node indices rather than the call sites `#[track_caller]` would give. `feedback` remains inexpressible — a cycle is not straight-line emission. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X4eojqRWBJ6uK5v5JDzmkd
Squashing the stack left three comments explaining the `_q` twins as replacing a `quoted!(recv => method(..))` grammar. That macro existed only in the intermediate history — against `main` it never has, so a reader would go looking for something that is not there. Reframed as what they actually are: the reasons the twins have the shape they do. `map` cannot take a quotation because its bound must be `Fn(&T) -> B` for a closure literal to infer, and `QuotedFn` cannot implement `Fn` on stable. A wrapping macro is the obvious alternative and is worse — `macro_rules!` cannot destructure `receiver.method(args)`, so it costs a separator token and chainability, and buys nothing back because either form still needs the closure's parameter annotated. Same content, no phantom. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X4eojqRWBJ6uK5v5JDzmkd
This was referenced Aug 8, 2026
…ers) #769 branched from main at 442c0e8, before #758 landed at 32ff98c, so this brings the branch up to date rather than leaving reviewers to diff against a main that has moved. Textually clean, and verified rather than assumed: the two branches touch the same four files (ops.rs, interp.rs, fluent.rs, and expand_compiled), and both had regenerated the unknown_combinator trybuild snapshot. #758's spins const-fold, its island_spins thread through __composite, and its poll_all_tiers parity all survive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X4eojqRWBJ6uK5v5JDzmkd
An alternative to `func!` + the `_q` twins, built to be looked at rather
than merged. A user writes completely ordinary wiring:
#[wiring]
fn desk(g: &GraphBuilder, cfg: &[Instrument]) -> Stream<f64> {
cfg.iter()
.map(|inst| g.ticker(inst.period).count().map(|n: &u64| *n as f64))
.reduce(|a, b| a.join(&b, |x: &f64, y: &f64| x + y))
.expect("at least one instrument")
}
No `func!`, no `_q`, no `with_src`, no `with_cfg` — and the graph emits.
## How it rewrites blindly
The macro sees tokens, so it cannot tell `Stream::map` from `Iterator::map`.
It does not try: every method call carrying a closure becomes
`.<method>(..).__wf_src(<text>, <loc>)`, and method resolution sorts it out.
`Stream` gets an *inherent* `__wf_src` that records; everything else picks
up a blanket `MaybeSrc` no-op. Inherent methods win over trait methods, so
there is no ambiguity and nothing needs to opt out — the `iter().map(..)`
and `.reduce(..)` above are rewritten too and compile to what they were.
A test pins that inertness by comparing values against the same graph wired
without the attribute. If the precedence ever broke it would fail to
compile rather than silently mis-record, which is the right failure.
## The two costs, asserted rather than described
**Text is normalised, not verbatim.** A proc macro cannot recover the
original snippet on stable — `Span::source_text` gives only the first token
of a multi-token expression, and joining spans is nightly — so artifacts
carry `| n : & u64 | * n as f64` where `stringify!` would give
`|n: &u64| *n as f64`. `rustfmt` does not repair it; it does not format
inside macro bodies. That is the whole trade against the twins, so it is a
test, not a doc comment.
**Captures are not detected.** `func!([fee] ..)` records the captured
*value* and re-materialises it; this records a body referencing a name that
exists only in the wiring. The node looks emittable and the artifact then
fails at pass 2 — worse than a refusal. Closing it means free-variable
analysis over the closure body.
Not proposed for merge, and deliberately not a PR: it competes with the
design in the codegen branch, and the choice is which cost to pay.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X4eojqRWBJ6uK5v5JDzmkd
`#[wiring]` recorded a capturing closure's body verbatim, so the artifact referred to a name that existed only in the wiring and failed at pass 2. `func!([fee] ..)` was the only way to write one. Now the attribute finds `fee` itself and re-materialises it, so a capturing closure needs no annotation at all. The rewrite runs free-variable analysis over each closure body and renders every name it finds. The rendering goes through a new `emit::Probe` — an autoref-specialisation ladder yielding `Some(literal)` when the type implements `EmitLiteral` and `None` when it does not — and that softness is the whole design decision. The attribute annotates a *whole wiring function*, most of whose nodes are never emitted, so an `EmitLiteral` bound per detected capture would make `orders.map(move |o| book.lock()..)` over an `Arc<Mutex<_>>` a hard compile error in ordinary wiring. Instead the wiring compiles and runs, and only generation refuses — naming the binding rather than saying "not quoted", which would send a reader to fix something already done. Detection deliberately excludes callee position, so calling a free helper function in a closure body is not mistaken for a capture. The cost is a captured *callable* invoked as `f(x)` is missed; that and two other residual classes (names used only inside a macro, names both bound and free in one closure) are documented on `free_vars` and fall through to pass 2 as before. `func!` remains the explicit override, and the only option for a closure built outside the annotated function. Proven the same way as the rest of the generator: the artifact for a two-instrument graph with a per-leg fee sits in the test file as a real `nitro!` block, so byte-identical output plus a compiling file establishes validity by construction, with parity then asserted across all three tiers on values and tick times. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X4eojqRWBJ6uK5v5JDzmkd
`fold`/`scan` take their seed via `#[op(init_arg)]` — a call-site argument that is *not* the op's `Cfg`. `Fold`'s `Cfg` is its closure, so recording the closure satisfied `takes_closure_cfg` while saying nothing about the seed, and the walker emitted `.fold(f)`: an artifact silently missing the accumulator's starting value, reported as fully emittable. That is the exact failure `codegen`'s own docs promise cannot happen — "a refusal, never a partial artifact". Pass 2 would have caught it on arity, but inside generated code, which is the wrong place to learn about it. `#[op]` knows which ops take a seed, so it records `has_init_arg` on the node and `ineligible` refuses when no `with_cfg` accompanied it. The seed cannot simply join `#[op(emit_cfg)]`: its type is generic, so an `EmitLiteral` bound would land on the public signature and forbid folding into any accumulator the emitter cannot render — which is why `with_cfg` exists for this case at all. Found by probing the generator's edges rather than by a failing test, so both directions are now pinned: the refusal, and that a recorded seed emits ahead of the closure in the position the catalog's signatures use. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X4eojqRWBJ6uK5v5JDzmkd
Two arbitrary edges a user finds only by hitting them, both now closed. `[T; N]` did not reach the `[T]` impl — an unsized slice is only ever seen behind a reference — so it fell through to `Probe`'s fallback. With capture detection that is no longer a quiet gap but a *refusal*: capturing a `[f64; 3]` made the graph unemittable, while the `&[f64]` spelling of the same data worked. A const-generic impl fixes it. Tuples stopped at 4, which was where the list stopped rather than a design limit. Carried to 12, the arity std implements its own traits at. Both round-trip-proven the way the rest of the trait is: the expected source is written out in `tests/emit_literal.rs` as real Rust, so the file compiling proves the output parses and the equality proves it reconstructs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X4eojqRWBJ6uK5v5JDzmkd
The invariant behind "develop against the waker, deploy to busy-spin": those are not two sources, they are one source under two kernel *waiting* strategies. `begin_cycle` calls `drain_ready` before it branches on `spin`, so a `KernelWaker` firing lands in `dirty` either way — `spin` only decides whether the kernel parks between cycles. Worth pinning because the alternative reading is a parity hole with teeth. `poll` (ALWAYS) is realtime-only, rejecting `HistoricalFrom` outright, while `channel` (THREADED) replays timestamped sends deterministically. Since the house convention is that tests run historically and assert values *and* tick times, a deployment built on `poll` could never be covered by a test built on `channel` — you would verify one graph and ship another. This says that trade is unnecessary: one `channel` wiring serves the deterministic test, the parked realtime run, and the spinning realtime run. Established by spike rather than assumed, alongside a second spike that closed off the alternative: giving an op a mode type parameter (`Ingest<T, Waked>` / `Ingest<T, Spun>`) with `const ACTIVATION = M::ACTIVATION` cannot work, because `#[op]` lifts that expression verbatim into a module-level non-generic const where the parameter is not in scope. Making it generic would mean the macro naming type arguments at every dispatch site, which is exactly what its name-based forwarder mechanism exists to avoid. Interpreted only. `compiled()` still builds its `Kernel` without a ready receiver, so a THREADED node's dirty bit cannot be set there; the module doc records what closing that needs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X4eojqRWBJ6uK5v5JDzmkd
`TokenStream::to_string` spaces every token out, so an artifact carried
`move | n : & u64 | * n as f64 - fee` and kept it — `rustfmt` does not format
inside macro bodies, so nothing downstream could clean it up.
That matters more than it looks. The design's *only* mitigation for
undetectable stale generation is that the artifact is plain reviewable Rust
you can diff; text nobody wants to read undermines the one safeguard there
is. Routing through `prettyplease` re-prints the tokens the way a human would
write them:
let n2 = n1.map({ let fee = 0.5f64; move |n: &u64| *n as f64 - fee });
let n6 = n2.join(&n5, |x: &f64, y: &f64| x + y);
`prettyplease` formats a `syn::File`, not an expression, so the closure is
parked in a throwaway `let` inside a throwaway `fn`, formatted, cut back out
and dedented — falling back to the raw token string if any step surprises us,
since layout must never panic a proc macro. It costs nothing in the
dependency graph: it depends only on syn and proc-macro2, both already there.
The text is canonical rather than verbatim, which is better than `func!`'s
`stringify!` for this purpose: the macro's input is a token stream carrying no
whitespace, so any two spellings that tokenise alike record alike, and
reformatting the wiring cannot churn a checked-in artifact. That holds by
construction, so it is documented rather than tested — a test that cannot fail
would be worse than none. The formatting itself is pinned, both as derive unit
tests over exact strings and in the artifact this file must emit byte-for-byte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X4eojqRWBJ6uK5v5JDzmkd
The one failure this design has no runtime symptom for. Everything here freezes configuration into code, so an artifact generated from last quarter's parameters compiles, runs, and produces plausible numbers — and the standing mitigation was that the artifact is reviewable `nitro!` input, which only helps someone already looking at it. `check_artifact(path, source)` compares the file on disk against what the generator now produces, so a build script or test can fail on the difference. Because pass 1 is deterministic and re-runnable, that single comparison catches both ways an artifact goes wrong — a config change nobody regenerated for, and a hand edit to the generated file — with no hashing and no new dependency. Header text moved into one constant so the check can strip it and compare only the wiring, and a mismatch names the first differing line rather than dumping two files. What it cannot catch is stated on the function: a config change the *test's* own inputs do not see. That is a property of where the config lives, not of the check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X4eojqRWBJ6uK5v5JDzmkd
The feature had ten tests and no example, against the house rule that every capability gets a directory with a `main.rs`, a `README.md` carrying real pasted output, and an explicit `Cargo.toml` target. Tests prove it works; nothing showed anyone how to use it. The example runs the whole loop in one binary rather than describing it. Pass 1 is a `#[wiring]` function looping over a two-instrument book; the artifact it produces is checked in beside it and `include!`d, so pass 2 happened when the example was built, and both tiers run side by side at the end. That makes the payoff legible: the loop is gone, each instrument became its own unrolled pipeline, and each carries the `fee` and `size` it was configured with baked in as literals. It also shows the two failure surfaces, because both are part of using this safely. `check_artifact` runs on every plain invocation, so editing the config without regenerating is an error rather than a wrong number; `--regenerate` rewrites the file, and is idempotent. And it ends by generating from a closure capturing an `Arc<Mutex<_>>` — a graph that wires and runs perfectly well and is refused only at generation, naming the binding. `scripts/check-example-docs.sh` passes at 48 targets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X4eojqRWBJ6uK5v5JDzmkd
#758 made `poll` expressible in `nitro!`; this makes it reachable by the two-pass generator, which is the combination that matters — a busy-poll ingest graph whose *shape* comes from run-time config can now be compiled. One line was missing. `poll`'s builder is hand-written, because it also sets `has_always` and `re_runnable`, so `#[op(no_builder)]` suppresses the generated builder and with it the `set_node_build` call that records the op's method name. The walker therefore saw a nameless node and refused — offering advice about variadic ops that does not apply to `poll`. It records `"poll"` by hand instead, declaring the config a closure so an unquoted one is still refused rather than emitted as a bare `g.poll()`. Found by probing after the merge rather than by a failing test: the merge was textually clean and all 92 suites passed, because nothing yet asked whether the two halves met. Proven the usual way — the artifact sits in `poll_all_tiers.rs` as a real `nitro!` block, so byte-identical output plus a compiling file establishes validity by construction, with parity then asserted against the wiring on the compiled tier. Note the emitted source binds its capture (`{ let seed = 3u64; move || Some(seed) }`) rather than inlining it, which is what makes the block resolve where it is spliced. Also brings C4 and port-plan footnote 17 up to date. C4 still said busy-poll was not expressible at all; it now records what #758 settled (the wake-channel vs busy-spin decision, taken on the busy-spin side), what remains excluded and why, and that the two ingest styles are not two sources — `begin_cycle` drains wake-ups before it branches on `spin`, so the residue is a dev/deploy usability gap rather than the parity non-issue the cutover ruling addressed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X4eojqRWBJ6uK5v5JDzmkd
4 tasks
The example covered `ticker`-driven topology only, which is the easy half. Adding an `Activation::ALWAYS` feed per venue covers the case #758 unblocked and surfaces the idiom that actually makes an I/O graph generatable — which was nowhere in the docs. Written the obvious way, an ingest graph does not generate at all: `g.poll(move || rx.try_recv().ok())` captures a receiver, which no artifact can reconstruct, so the node is ineligible and the graph is refused. Putting the connection behind a function the artifact can *call* leaves the closure capturing only the config: let n0 = g.poll({ let venue = 7u64; move || venue_feed(venue) }); That works because free functions in call position are deliberately excluded from capture detection, and it has a consequence worth planning for: the artifact now names `venue_feed`, so it must be in scope wherever the generated file is compiled. Both are stated in the README rather than left to be discovered. The example also prints the historical rejection instead of describing it — a busy-poll graph never parks the kernel, so there is nothing to replay — and `--regenerate` now writes both artifacts, verified idempotent. README output is a real run, pasted, with only the two long refusal lines elided and marked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X4eojqRWBJ6uK5v5JDzmkd
Four things from the review of what this design still got wrong, none of which changes how generation works. **Artifact node names carry the op.** `let n147 = n146.map(..)` tells a reviewer nothing, and the artifact *is* the review surface — the only guard against stale generation that does not need a test. Now `n147_map = n146_count.map(..)`: the index keeps names unique and in wiring order, the op's method name says what it is. The user's own `let` bindings are gone by the time the walker runs, so the method name is the best label available. **A wrong `out_ty` is caught at generation.** The parameter exists because `type_name` is not valid source (`alloc::vec::Vec<u64>`), but a wrong string was accepted and failed at pass 2, inside generated code, with a type error naming a function the user never wrote. Now compared after stripping module qualifiers — deliberately permissive, since a false positive blocks a legitimate generation, which is worse than the late error it replaces: anything the normaliser cannot reason about confidently passes. **Refusals name the call site.** `node 2 (Map)` identifies a node in a graph nobody has printed; `src/desk.rs:37 — node 2 (Map)` identifies a line. The location comes from whatever annotated the node, so `#[wiring]` graphs get it throughout. Still absent for a node nothing annotated — the case `#[track_caller]` on the generated wiring methods would close. **`BTreeMap`/`BTreeSet` render.** `HashMap` deliberately does not, and that is the interesting half: its iteration order varies run to run, so an artifact built from one would churn between identical generations and destroy the diff-the-artifact workflow. A `HashMap` capture takes `Probe`'s fallback and is refused, which is the correct outcome rather than a missing impl. Every pinned artifact was rewritten to match, per block rather than per file — the fixtures each restart at `n0`, so a file-wide index map assigns the wrong op to a reused index. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X4eojqRWBJ6uK5v5JDzmkd
The twins were the previous PR's answer to "record a closure without an `OpFn` bound": one generated inherent method per closure-config op, taking a `func!` quotation. `#[wiring]` records every closure with no call-site annotation at all, captures included, which leaves them buying nothing while doubling the fluent surface — sixteen extra methods on `Stream<T>`, each needing its own `macro_rules!` arm out of `#[op(fluent)]`. So they go before either lands, and `main` never carries a surface we intended to remove. `func!` + `Stream::with_src` remains for wiring not under the attribute — one method covering the whole catalog, which is what the `OpFn` deviation settled on in the first place. Removed with them: `Stream::__with_src_text`, the doc-hidden seam whose only caller was the generated twins, and `tests/quoted_twins.rs`. Refusal advice now names both routes rather than a method that no longer exists: put `#[wiring]` on the wiring function, or record by hand with `let f = func!(..); ..map(f.f).with_src(&f)`. One tidy-up the removal exposed: `cfg_is_closure` and `expand_builder` held byte-identical copies of the "does this op's Cfg carry an Fn bound?" test, because the helper existed for the twins and the builder had grown its own. With the twins gone the helper was dead code; `expand_builder` now calls it, so the predicate has one definition. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X4eojqRWBJ6uK5v5JDzmkd
The last hole in refusal locations, and the one that mattered most: a node
*nothing* annotated had no call site, so it reported only an index — and that
is precisely the node a refusal exists for, since `#[wiring]` and `func!` both
record a location for anything they touch. An index identifies a node in a
graph nobody has printed.
Every `#[op(fluent)]`-generated method is now `#[track_caller]` and stamps its
call site unconditionally, so:
crates/.../desk.rs:37 — node 2 (Map): `map`'s closure was not recorded ...
Spiked before building on it, because the obvious worry was that a trait
method needs the attribute on its declaration too: it does not — the impl
alone reports the caller, and it survives being generated inside
`macro_rules!`. So no change to the 40 hand-written `StreamOps` signatures.
Three things now record a location, each overwriting the last and each more
precise: the call-site stamp, then `#[wiring]`'s per-closure record, then
`func!`'s quotation. `poll` gets the stamp by hand, since it is
`#[op(no_builder)]` with a hand-written fluent method and takes a closure, so
it is refusable. `count` and friends deliberately do not: no closure, no
config, never refused, so a breadcrumb there is decoration.
Two knock-on effects worth naming. `#body` had to be braced — a multi-edge
op's body is several statements, not one expression. And `Breadcrumbs::On`
now annotates every generated-fluent node rather than only quoted ones, which
is strictly more useful; the test that pinned the old count now pins the new
coverage *and* that a quotation still wins over the call-site stamp.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X4eojqRWBJ6uK5v5JDzmkd
Keeps the branch current so CI runs against the tree that would actually land. #771 touches only `js/`, which nothing here goes near. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X4eojqRWBJ6uK5v5JDzmkd
3 tasks
0-jake-0
added a commit
that referenced
this pull request
Aug 12, 2026
…drop dead prose (#785) A review of all 18 pages in docs/, checking each claim against the code and the tracker. Three problems, all the same shape: the directory a page lives in stopped predicting what the page is. decisions/ now holds only settled rulings. The stated split was by audience, but both dirs carried "why" prose and both carried history, so it did not discriminate. The test that does: if the work never happens, is the doc still valuable? A ruling survives; a plan becomes a stale backlog. docs/README.md now states it, with the two mechanical consequences. Two of the five "decisions" failed it and moved to planning/proposals/: - fpga-hdl-backend — its own status is "not scheduled", §7 is a spike checklist, §8 is a four-gate sequencing plan, and it gets rewritten as facts move (#727). - wired-graph-codegen — "accepted, not built", §8 Sequencing, #726, and an implementation on the unmerged #769 branch. It also claimed the software generator now exists; it does not on main, and wingfoil-derive still exports only nitro!, #[op] and latency_stages!. Corrected in both places. docs/adding-an-op.md — extracted from port-plan.md. That file opens by saying it is a historical record and not a status board, then carries the recipe /new-op calls "authoritative ... read it first", with 47 inbound references from outside docs/. The recipe is now its own page and the skills, crates/README.md, wingfoil-derive/README.md and ops.rs point at it. The banner is honest about the two parity records that genuinely stay behind (the capability matrix, the Phase 4/6 tick lists) and why they die with legacy/. source-lifecycle: 282 lines to 85. It was a proposal — "Proposed design", "Migration order", "Acceptance criteria" — for work that finished, half of which was dropped on a false premise. It also contradicted itself: the header said the produce_async family was migrated and RunParams gone, while §"Not yet done" still listed both as outstanding. Rewritten as a decision record. macro-extensibility §4 carried three open engineering items where nobody reading the tracker would find them. Filed as #782 (#[op] out-of-crate), #783 (nitro!/compiled never call the generated _stop/_teardown forwarders — the forwarders exist, the emission side does not), #784 (denylist Stream's inherent methods). §6 reframed from a recommendation to the ruling it became. comparison.md moves to docs/. It is the most outward-facing page in the tree — the repo README links straight to it and it solicits corrections from the maintainers of the projects it describes — and it was filed under "internal, and mostly historical". Also fixed, all verified against the tree rather than assumed: - next is gone from origin, so its inert CI residue is stripped: push/pull_request filters and refs/heads/next cache save-if guards in rust-test.yml, python-test.yml, security-audit.yml. The legacy/* workflows keep theirs — they retire wholesale with the tree, not twice. cutover-runbook step 7 marked done (it still said main carries the pre-cutover world) and CLAUDE.md's "strip them when it is actually deleted" note settled. - CLAUDE.md's repo-structure block: introspection-plan.md was missing, and the docs rows now match the new layout. - Every relative link in every .md/.rst in the repo now resolves. Four were broken, one of them in the compressed doc; js/README.md and legacy/CONTRIBUTING.md carried pre-existing ones and are fixed too. No behaviour change — the .rs edits are doc-comment paths only.
0-jake-0
added a commit
that referenced
this pull request
Aug 13, 2026
* Reframe the positioning page as where wingfoil *currently* sits, and name what moves it "Where wingfoil sits" read as a settled position. It is a snapshot: every number on it is a reading of main today, and four separate pieces of work already exist to move the line it draws. The page said nothing about them, so a reader finished it with the gaps (TCP-class ingress, unpinned graph thread, no hardware path) and no route to closing any of them. Retitles the section "Where wingfoil currently sits", marks the latency-class bullets as a reading of today, and adds "What moves the line": core pin (#392, already prototyped in trading_e2e/shared.rs), kernel bypass (roadmap items 1 and 7 — a NIC and a measurement run, not a diff), Project Lightning (#726/#769, implemented and unmerged) and Project Metal (#727, gated behind Lightning on its de-risk spike). Each entry says where it stands and what the first move is, and none of them is claimed as shipping. The two proposals now carry their project names, so the references resolve back; the trading roadmap points at the new section. Anchor moved, so the root README and the roadmap's companion-reading link move with it, and the spectrum chart is regenerated with the retitled heading. Also corrects a stale path in the same section: the shared-memory hop is measured by examples/showcase/trading_e2e/, not the long-renamed examples/latency/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018PYCTWPFrMotHat5s1hGDS * Move the invitation off the benchmark page and into the root README The benches page reports measurements and states what they do not prove; "say so on the issue and it is yours" is contributor copy, and it does not belong at the end of that. It also promised a first move that "fits in an afternoon", which is not true of booking a NIC. What stays on the benches page is the analysis: the four projects, what each moves against the measured numbers, and the ladder they form — with a pointer to the root README for picking one up. The invitation itself now opens Get Involved, where a reader looking for something to do actually lands, as a table of the four with their issues and current state. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018PYCTWPFrMotHat5s1hGDS --------- Co-authored-by: Claude <noreply@anthropic.com>
0-jake-0
marked this pull request as draft
August 16, 2026 13:20
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The whole of project lightning in one PR. Supersedes #770 and the earlier ten-deep stack (#759–#768), all now closed. 25 files, +5014/−11.
What this changes
A graph whose shape comes from a config file can now be compiled.
Pass 1 runs that against the interpreted builder and walks the graph it built; pass 2 is an ordinary
cargo buildover the result:The loop is gone — unrolled into one pipeline per instrument, each carrying the parameters it was configured with.
nitro!never sees a loop.Why
Implements #726.
nitro!reads tokens, so its topology is fixed at compile time — the straight-line ruledual_modestates. This buys the one thing that rules out, topology decided by running code, while keepingnitro!as the sole backend: there remains exactly one place that knows how to turn wiring into a monomorphized runner, and the generator emits input to it rather than runner code, so the two cannot drift.The engine erases closures (
Box<dyn FnMut>overRcslots), so a traversal recovers topology but never what a node computes.#[wiring]keeps the tokens at the call site, before erasure — and because text and closure come from the same tokens, drift is structurally impossible. That was the failure that sank the deleted legacycodegenretrofit.The three pieces
quote/emit—func!keeps a closure's tokens;EmitLiteralrenders data configs back to source. Configs are never erased (the value sits in the op'sCfgcell), so#[op(emit_cfg)]records them with no annotation at all.codegen— runs the wiring, walks the graph, printsnitro!input. Refuses rather than emitting a partial artifact, reporting every reason at once.#[wiring]— the attribute that removes the annotation burden entirely. It rewrites every closure-carrying method call to keep its tokens, and runs free-variable analysis to find captures, somove |p| p - feeneeds no declaration.Decisions worth reviewing
Capture detection is soft, and that is the load-bearing choice. Captures render through
emit::Probe, an autoref-specialisation ladder yieldingSome(literal)when the type implementsEmitLiteralandNonewhen it does not. The attribute annotates a whole wiring function, most of whose nodes are never emitted — so anEmitLiteralbound per detected capture would makeorders.map(move |o| book.lock()…)over anArc<Mutex<Book>>a hard compile error in ordinary wiring, and#[wiring]unusable on any real graph. Instead the wiring compiles and runs; only generation refuses, naming the binding.Free functions in call position are excluded from detection. Otherwise every closure calling a helper would be mistaken for capturing it. That exclusion turns out to be what makes an ingest graph generatable at all —
g.poll(move || venue_feed(venue))captures au64, not a receiver. The trade is that a captured callable invoked asf(x)is missed; documented onfree_varswith the other two escape classes.§4's
OpFnbound does not work (D28). Binding closure-config ops by a trait with two implementors costs closure signature inference — rustc propagates it only fromFn-family bounds. Measured: ~370 errors across 41 targets, and the residue after reverting the fluent layer was entirely insidenitro!blocks, becausecompiled()emits closure literals into forwarders whose bounds come from the op. Ops keepFnand never see aQuotedFn.A generated
_qtwin per op was built, then deleted. It was the pre-attribute answer;#[wiring]records the same thing with no call-site annotation, so sixteen extraStreammethods bought nothing. Removed before landing somainnever carries a surface we intended to remove.Bugs found and fixed along the way
fold/scansilently dropped their seed and reported the graph emittable —Fold'sCfgis its closure, so the seed arrives via#[op(init_arg)]with no other trace on the node. That is the partial emissioncodegen's own docs promise cannot happen.[T; N]and 5-tuples fell offEmitLiteral— quiet gaps before, but capture detection routes through it, so they had become refusals.pollwas expressible innitro!but not emittable —#[op(no_builder)]suppressed theset_node_buildcall recording the method name, so the walker saw a nameless node and refused with advice about variadic ops. Fixing it joins Make busy-poll sources expressible innitro!, and correct the compiled-tier IO classification #758 to the generator: a busy-poll ingest graph whose shape comes from run-time config now compiles.Operational guards
check_artifact— staleness is the failure with no runtime symptom (an artifact built from last quarter's fees compiles, runs, looks plausible). One comparison in a test catches both a config change nobody regenerated for and a hand edit.prettypleaseformatting — the artifact is the review surface, so it has to be readable.rustfmtdoes not format inside macro bodies, so the text must arrive right.n6_join, notn6) and refusals that name a line, via#[track_caller]on every generated fluent method — including the node nothing annotated, which is exactly the one a refusal exists for.How it was verified
cargo fmt --allcargo lintandcargo lint-all— both greencargo test --manifest-path crates/wingfoil/Cargo.toml --all-features— 91 suites, plus 15 doctests, 5 derive unit tests,scripts/check-example-docs.shat 48 targets. The nine*_integrationsuites fail onSocket not found: /var/run/docker.sock— testcontainers, no Docker in this sandbox, unrelated to this diff.How correctness is established, since emission cannot be proven by inspection: every expected artifact sits in the test file as a real
nitro!block and the walker must emit byte-identical text — strings match plus the file compiles means the output is valid wiring source by construction, not merely plausible. Parity then runs against the graph it came from, across interpreted, nested and compiled, on values and tick times.EmitLiteralround-trips the same way.Two mechanisms were spiked before anything was built on them, rather than assumed: the
Probeladder (the double&&is load-bearing — with one, the fallback always wins), and#[track_caller]on a trait impl method (no declaration attribute needed, and it survivesmacro_rules!generation).Try it
Runs both passes in one binary — the checked-in artifacts are
include!d — and prints a refusal at the end so the failure mode is visible too.Known limits, each pinned by a test
Variadic ops (
merge_all,combine) carry nobuildname and are refused.feedbackis inexpressible — a cycle is not straight-line emission. Three capture-detection escape classes, all failing as pass-2 compile errors rather than wrong graphs.HashMapdeliberately has noEmitLiteralimpl: its iteration order varies run to run, so an artifact built from one would churn between identical generations.Wake-driven I/O (
external/channel) is still excluded fromcompiled(), tracked as #502/#503 and C4. Busy-poll ingest — the shape a compiled graph actually wants, since kernel-bypass NICs are polled rather than woken — works end to end. Closing the rest needsnitro!to express a tuple-returning source and a scoped entry point so a producer handle can escape before the run starts; that is a project, not a follow-up, and deliberately not in here.Generated by Claude Code