Skip to content

Project lightning: compiled graphs generated from procedurally wired ones (#726) - #769

Draft
0-jake-0 wants to merge 17 commits into
mainfrom
lightning-codegen
Draft

Project lightning: compiled graphs generated from procedurally wired ones (#726)#769
0-jake-0 wants to merge 17 commits into
mainfrom
lightning-codegen

Conversation

@0-jake-0

@0-jake-0 0-jake-0 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

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.

#[wiring]
fn desk(g: &GraphBuilder, book: &[Instrument]) -> Stream<f64> {
    book.iter()
        .map(|inst| {
            let fee = inst.fee;
            g.ticker(inst.period).count().map(move |n: &u64| *n as f64 - fee)
        })
        .reduce(|a, b| a.join(&b, |x: &f64, y: &f64| x + y))
        .expect("at least one instrument")
}

Pass 1 runs that against the interpreted builder and walks the graph it built; pass 2 is an ordinary cargo build over the result:

wingfoil::nitro! {
    fn desk_generated(g: &GraphBuilder) -> Stream<f64> {
        let n0_ticker = g.ticker(::core::time::Duration::new(0u64, 1000000u32));
        let n1_count = n0_ticker.count();
        let n2_map = n1_count.map({ let fee = 0.25f64; move |n: &u64| *n as f64 - fee });
        let n3_ticker = g.ticker(::core::time::Duration::new(0u64, 4000000u32));
        let n4_count = n3_ticker.count();
        let n5_map = n4_count.map({ let fee = 1.75f64; move |n: &u64| *n as f64 - fee });
        let n6_join = n2_map.join(&n5_map, |x: &f64, y: &f64| x + y);
        n6_join
    }
}

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 rule dual_mode states. This buys the one thing that rules out, topology decided by running code, while keeping nitro! 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> over Rc slots), 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 legacy codegen retrofit.

The three pieces

quote / emitfunc! keeps a closure's tokens; EmitLiteral renders data configs back to source. Configs are never erased (the value sits in the op's Cfg cell), so #[op(emit_cfg)] records them with no annotation at all.

codegen — runs the wiring, walks the graph, prints nitro! 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, so move |p| p - fee needs 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 yielding Some(literal) when the type implements EmitLiteral and None when it does not. 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<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 a u64, not a receiver. The trade is that a captured callable invoked as f(x) is missed; documented on free_vars with the other two escape classes.

§4's OpFn bound does not work (D28). Binding closure-config ops by a trait with two implementors costs closure signature inference — rustc propagates it only from Fn-family bounds. Measured: ~370 errors across 41 targets, and the residue after reverting the fluent layer was entirely inside nitro! blocks, because compiled() emits closure literals into forwarders whose bounds come from the op. Ops keep Fn and never see a QuotedFn.

A generated _q twin per op was built, then deleted. It was the pre-attribute answer; #[wiring] records the same thing with no call-site annotation, so sixteen extra Stream methods bought nothing. Removed before landing so main never carries a surface we intended to remove.

Bugs found and fixed along the way

  • fold/scan silently dropped their seed and reported the graph emittable — Fold's Cfg is its closure, so the seed arrives via #[op(init_arg)] with no other trace on the node. That is the partial emission codegen's own docs promise cannot happen.
  • [T; N] and 5-tuples fell off EmitLiteral — quiet gaps before, but capture detection routes through it, so they had become refusals.
  • poll was expressible in nitro! but not emittable#[op(no_builder)] suppressed the set_node_build call 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 in nitro!, 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.
  • prettyplease formatting — the artifact is the review surface, so it has to be readable. rustfmt does not format inside macro bodies, so the text must arrive right.
  • Named nodes (n6_join, not n6) 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 --all
  • cargo lint and cargo lint-all — both green
  • cargo test --manifest-path crates/wingfoil/Cargo.toml --all-features — 91 suites, plus 15 doctests, 5 derive unit tests, scripts/check-example-docs.sh at 48 targets. The nine *_integration suites fail on Socket not found: /var/run/docker.sock — testcontainers, no Docker in this sandbox, unrelated to this diff.
  • New behaviour covered by tests asserting values and tick times

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. EmitLiteral round-trips the same way.

Two mechanisms were spiked before anything was built on them, rather than assumed: the Probe ladder (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 survives macro_rules! generation).

Try it

cargo run --manifest-path crates/wingfoil/Cargo.toml --example codegen

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 no build name and are refused. feedback is inexpressible — a cycle is not straight-line emission. Three capture-detection escape classes, all failing as pass-2 compile errors rather than wrong graphs. HashMap deliberately has no EmitLiteral impl: 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 from compiled(), 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 needs nitro! 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

claude added 2 commits August 8, 2026 12:38
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
claude added 10 commits August 8, 2026 19:36
…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
claude added 5 commits August 8, 2026 20:07
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
@0-jake-0 0-jake-0 changed the title Two-pass codegen: generate compiled graphs from procedurally wired ones (#726) Project lightning: compiled graphs generated from procedurally wired ones (#726) Aug 9, 2026
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
0-jake-0 marked this pull request as draft August 16, 2026 13:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants