Skip to content

Land cross/outer pairwise axes + LoopIR optimizations; release 0.0.40 - #34

Merged
amuta merged 36 commits into
mainfrom
pairwise-cross
Jun 15, 2026
Merged

Land cross/outer pairwise axes + LoopIR optimizations; release 0.0.40#34
amuta merged 36 commits into
mainfrom
pairwise-cross

Conversation

@amuta

@amuta amuta commented Jun 15, 2026

Copy link
Copy Markdown
Owner

Summary

Lands the pairwise axis work (cross/outer all-pairs primitives), the LoopIR optimization pipeline (loop fusion + array contraction), streaming JS codegen, a pass-conventions refactor (contract DSL + parameterized IR passes), and golden-suite consolidation onto v2. Cuts release 0.0.40.

This branch already contains everything from outer-axis (it's an ancestor), so this is the single PR that brings all of it onto main.

Highlights

Language / semantics

  • cross — all-pairs / self-join axis primitive (A × A'), the basis for N-body and pairwise math.
  • outer — all-pairs over two different arrays (A × B). Now usable from text schemas too (requires kumi-parser ≥ 0.0.33). Golden outer_let covers the text path with Ruby/JS parity.
  • Fix cross through an import boundary; inliner skips are now observable.
  • Fix outer/cross value used through a let that lives purely on the inner pairing axis reading nil past the inner array length — the materialized read now matches by axis, not positional loop depth (Ruby + JS).
  • New elementwise functions: sqrt, sin, cos, exp, log, tanh.
  • Array input arity errors now explain the element rule and show the correct single-child form.

Compiler backend

  • LoopIR optimization pipeline (previously empty) now runs on every compile:
    • LoopFusion — merges sibling loops over the same axis/source into one traversal; stencil consumers and accumulator reads correctly block fusion.
    • ArrayContraction — replaces fill-then-read-at-same-index intermediate arrays with the scalar, eliminating the materialization.
  • Allocation-free streaming JS codegen.
  • Break loudly on unknown clone opcodes; version the compile cache.

Pass conventions (refactor)

  • reads/writes contract DSL on PassBase, enforced in PassManager; contracts declared across DEFAULT/lowering/codegen passes.
  • IR lower/validate passes collapsed into parameterized IRLowerPass / IRValidatePass.
  • Passes renamed to a consistent Pass suffix; NAST children protocol for traversal; pipeline contract self-check spec.
  • Documented in docs/PASSES.md; rubocop ratchet scoped to pass dirs.

Testing / tooling

  • Golden suite consolidated into v2; legacy v1 runner deleted.
  • New goldens: transcendentals, MLP backprop, outer_let.
  • Isolated a flaky loader spec into its own temp dir.

Docs

  • Expanded docs/SYNTAX.md; new docs/INPUTS.md input-shape reference.
  • A separate follow-up PR adds the docs-portal generator (rake docs:portal) and the kumi-docs site — intentionally not in this PR.

Release

  • Version → 0.0.40, CHANGELOG [Unreleased] rolled into a dated 0.0.40 section, Gemfile.lock re-locked.

Verification

  • bundle exec rspec947 examples, 0 failures, 3 pending.
  • bundle exec bin/kumi golden_v2 verify → all schemas pass (IR + codegen snapshots, Ruby/JS parity).

Andre Muta and others added 30 commits June 11, 2026 23:43
Streaming exports now reuse the target's storage end to end instead of
clearing and re-pushing:

- Returned arrays are written by cursor and truncated, never cleared
  upfront. Record elements and nested row arrays are mutated in place
  across calls, so steady-state frames allocate nothing.
- Intermediate scratch arrays persist at module scope when their
  identity cannot escape (init at loop depth 0, never pushed, not read
  by length/shift), eliminating per-call backing-store regrowth.
- Typed-array targets (Float32Array, Float64Array, ...) are supported
  for scalar array outputs, with a RangeError on overflow. Previously
  they were silently treated as object targets and left zeroed.
- Targets that alias an input array now throw a TypeError instead of
  silently computing over zero elements (the old length = 0 reset
  cleared the input itself in feedback loops).

Measured on simulation schemas (node 22): 5x faster frames and ~70x
less GC time at 100k array elements, 1.7x on a 144x144 two-field
reaction-diffusion grid, with byte-identical outputs over hundreds of
feedback steps.

Also fixes Ruby DSL let, broken since 0.0.37: inline: true leaked into
the ValueDeclaration struct (ArgumentError: struct size differs)
instead of flowing through hints. Bumps dev kumi-parser to 0.0.31 so
text-frontend specs can parse codegen hints.
The LoopIR pipeline existed but ran no passes; the lowerer emits one
loop per statement group, so loop-invariant scalar work between two
vector statements split what could be a single traversal, and the
intermediate vectors crossing the split were materialized as arrays.

LoopFusion merges sibling loops over the same axis and source register,
hoisting the independent scalar barrier above the fused loop and
renaming the second loop's element/index registers. Fusion is blocked
when the barrier depends on anything the first loop defines, pushes to,
or accumulates, and when the second loop touches a first-loop array
through anything other than index_read at its own loop index — shifts,
lengths, and re-iteration keep their two-pass structure, so stencil
schemas are untouched.

ArrayContraction then removes arrays with exactly one push that are
only read back at the same index in the same loop, rewiring readers to
the pushed scalar.

Both backends benefit since the passes run before codegen. Generated
code shrinks ~2x on multi-statement schemas (us_tax_2024 fed_eff: two
loops + intermediate array -> one fused loop). Streaming JS on the
orbital simulation reaches hand-written parity: 100k records per frame
in 2.57ms vs 2.58ms for a hand kernel with the same output contract
(19.6ms before the streaming rewrite, 3.7ms after it, 2.6ms with these
passes). Outputs verified byte-identical across 500 feedback frames,
42 golden schemas re-executed on both backends, snapshots regenerated.
Adds core.sqrt/core.sin/core.cos as data-only elementwise functions
(spec + ruby/js kernels), verified identical on both targets. Unblocks
trig/wave math in the kumi-play examples.

Also adds docs/pairwise-design.md: decomposable pairwise already works
via whole-axis reduce + broadcast; true N-body needs a new cross-axis
op that mints a fresh axis token over an existing carrier.
`cross(v)` (a.k.a. `fn(:cross, v)`) re-exposes an array's carrier under a
fresh, independent axis, so an array can be combined with itself into a
rank-2 (i × j) intermediate and reduced back. This is the broadcast-dual of
a reduction and makes non-decomposable pairwise math — true N-body, all-pairs
forces — expressible for the first time.

Implementation mirrors `shift`/`axis_shift`, threading a new `axis_cross` op
through the pipeline:
- DSL: `cross` in schema_builder + DSL_METHODS; registered as a unary function
- analyzer: mints child axis token `<src>__x`, records carrier alias in
  :cross_axes; anchor pass drops cross tokens when resolving carriers
- DFIR/VecIR: new AxisCross op, builders, validators, instruction cloners
- Loop lowering: cross axis opens a second loop over the same carrier with an
  independent index; chain_read redirects the source-axis read to it

Broadcasting and suffix-reduce absorb the new axis with no changes. Codegen
needs no changes — the op fully lowers to nested loops. Verified Ruby + JS
bit-identical on 1-D N-body; golden `pairwise_cross` passes both targets.

Also adds elementwise sqrt/sin/cos (see prior commit) usage to SYNTAX.md.
0.0.32 recognizes cross(...) sugar in the text frontend, so the golden and
docs now use the clean cross(expr) form. Adds a SYNTAX.md perf note: return
multiple pairwise results from one object value to share the O(n^2) nest.

No new optimization pass is needed for cross — it rides the axis_shift rails,
so CSE/DCE/loop-fusion/load-dedup all handle it for free (verified: repeated
cross of the same field dedups within a function; object-return fuses ax/ay
into a single nest with shared accumulators).
Two fixes prompted by testing cross() inside an imported schema:

1. cross-through-import was broken. The cross-axis carrier was resolved from
   an analyzer side-table (:cross_axes) that only carried the CALLER's crosses,
   so a cross minted inside an imported (inlined) schema failed loop lowering
   with "no carrier for axis :vals__x". Loop lowering now derives the cross
   carrier from the axis_cross op's own source_axis attribute, which is present
   regardless of origin — local and imported crosses are handled identically.
   Locked in by golden `cross_import` (imports Kumi::TestSharedSchemas::Pairwise,
   which uses cross internally) — verified Ruby + JS produce [4, 1, -5].

2. Import inlining no longer fails silently. inline_import had ~8 bare `return
   nil` bail points that left an import_call in place with no signal — a lost
   fusion (and slower path) the user could never see. Now:
   - benign skips (callee unresolved / not self-contained / arity mismatch)
     log their reason under KUMI_DEBUG_IMPORT_INLINING=1 and return nil;
   - post-commit failures (unmappable use, missing arg, no final reg) raise a
     clear SemanticError instead of dropping to a slow path that would also be
     broken — surfacing the real IR bug at compile time.
`cross` self-joins one array (A × A') for all-pairs within it (e.g. N-body).
`outer` is its cross-array sibling: it re-exposes a value from a DIFFERENT
array as a fresh inner axis, so two distinct arrays can be paired all-pairs.
`pixel_x - outer(light_x)` builds the (pixels × lights) grid; `fn(:sum, ...)`
reduces the light axis back to per-pixel — the pattern a software rasterizer
needs (every pixel reduces over every light).

Mirrors the `cross` machinery across all layers:
- DSL `outer(v)` + `outer` registry entry (stencil.yaml)
- dimensional analyzer: `analyze_outer` mints a free `__o` axis alias and
  tags it; `lub_by_prefix` gains `merge_with_outer_axes` so an outer scope
  attaches as an inner axis against the surrounding bound scope
- DF/Vec ops `AxisOuter` + builders/cloners/validators
- DF `align_axes` accepts a suffix broadcast (adding an OUTER axis), not just
  a prefix one
- Loop lower: `:outer` axis re-iterates the other root array's carrier as a
  fresh inner loop; read lazily at use so it pairs at the full nest
- AttachAnchors handles purely-outer decls

Ruby and JS bit-identical. cross unchanged (golden still green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two silent failure modes that masqueraded as compiler bugs (both hit while
building `outer`):

1. InstructionCloner (DF and Vec) had an `else => instr` fallthrough: an opcode
   with no clone branch was returned UNCHANGED, keeping its original inputs.
   Any pass that remaps registers (dedup/inlining/CSE) then left a dangling
   reference and produced a wrong result with no error. Now raises ArgumentError
   naming the opcode — every opcode must have a clone branch.

2. The JIT compile cache keyed generated code on the SCHEMA digest only, not the
   compiler version. Editing the compiler and re-running silently reused stale
   generated code from the cache dir. Added Configuration#code_version (hashes
   the gem's Ruby sources by mtime; overridable via KUMI_CODE_VERSION or the
   setter) and folded it into the cache filename, so a compiler change
   invalidates the cache automatically.

Also upgraded two bare/vague raises to clear typed SemanticErrors:
the axis-merge failure now distinguishes an `outer` mismatch from a plain
tree/prefix mismatch, and DF align_axes explains the prefix-vs-suffix rule.

Specs added for both guards. No behavior change on valid schemas
(933 -> still green; the lone pre-existing agg.any dup failure is unrelated).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
exp/log/tanh are elementwise transcendentals (Math.exp/log/tanh in both Ruby
and JS — bit-identical). They unlock real activation functions: sigmoid and
tanh compose directly, enabling neural nets.

Two goldens demonstrate the payoff:
- transcendentals: exp/log/tanh + a sigmoid built from exp.
- mlp_backprop: a 2-H-1 net (tanh hidden, sigmoid output, BCE loss) whose
  ENTIRE forward + backward pass — every gradient, including the (hidden x
  inputs) weight-gradient matrix — is expressed in one schema. A harness
  applies the gradients and loops; the net learns XOR (verified separately),
  which a linear model cannot. Kumi has no autodiff: gradients are hand-derived
  but evaluated by the compiled schema, bit-identical on Ruby and JS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
v2 (IR + codegen text snapshots) was meant to replace v1 (execute Ruby+JS,
compare hand-written expected.json), but v1 was still the CI gate and provided
runtime + Ruby/JS-parity coverage v2 lacked. Port that into v2, then delete v1.

- New `runtime` representation (lib/kumi/dev/golden_runtime.rb): executes the
  generated Ruby AND JS against input.json, snapshots outputs to
  expected/runtime.json, and asserts Ruby == JS bit-identical. Parity is skipped
  (with a recorded note) for `import` schemas (need shared modules) and
  `to_decimal` schemas (Ruby BigDecimal vs JS float genuinely diverge) — Ruby
  outputs are still snapshotted for regression detection.
- golden_v2 verify treats a representation that legitimately yields no output
  (e.g. runtime with no input.json) as skipped, not failed.
- CI runs `golden_v2 verify` (+ setup-node); `bin/kumi golden` is now an alias
  for v2. precompile_schemas! moved to GoldenV2.
- Deleted lib/kumi/dev/golden.rb, lib/kumi/dev/golden/**, and the orphaned
  hand-written golden/*/expected.json (superseded by expected/runtime.json).

Full suite green (1 pre-existing unrelated agg.any dup fails on main too);
golden_v2 verify: 49/49 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
load_functions globs its directory recursively, but the backward-compat spec
pointed it at the shared system temp dir (File.dirname of a Tempfile). That
picked up unrelated YAML written by other tempfiles during the run, tripping
the duplicate-function-id guard (`agg.any`). Use Dir.mktmpdir so the glob only
sees this test's fixture. Full suite now green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Andre Muta and others added 6 commits June 14, 2026 04:45
This checkout's uncommitted work — never committed from earlier sessions — is
now recorded on top of the merged outer-axis work:

- pass_manager: per-pass wall-clock compile budget (PassBudgetError + DEFAULT_
  PASS_BUDGET_MS, KUMI_PASS_BUDGET_MS override) so a runaway pass fails with a
  located error instead of hanging. Spec in pass_manager_spec.
- js loop emitter + pretty_printer: streaming / typed-array codegen handling
  (reuse record elements, truncate streaming outputs); schema_spec covers it.

Plus post-merge tidy of the outer-axis files: drop a now-dead
incompatible_axes_message (superseded by the outer-aware axis_merge_error),
move the cloner spec under passes/support/, and clear rubocop (autocorrect +
ratchet pass_manager/import_inlining length, drop the stale golden/reporter
todo entry now that v1 is gone).

Full suite green (946 ex, 0 failures); golden_v2 verify 50/50; rubocop clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- outer(...) usable from text schemas (kumi-parser >= 0.0.33); outer_let golden
  covers the text path with Ruby/JS parity.
- Fix outer/cross value used through a let on the inner pairing axis reading nil
  past the inner array length; materialized read now matches by axis (Ruby + JS).
- Array input arity errors explain the element rule and show the single-child form.
- Expand docs/SYNTAX.md and add docs/INPUTS.md input-shape reference.
Root#digest folded RUBY_VERSION into the compiled module name, so the
schema_ruby.rb goldens embedded a per-Ruby hash and could not pass the strict
golden_v2 verify across the 3.1-3.4 CI matrix (and broke on the 0.0.40 bump).
The generated code is plain Ruby with identical semantics across supported
Rubies, so drop RUBY_VERSION from the digest and regenerate the codegen
goldens for 0.0.40.
Removing RUBY_VERSION from the digest was not enough: the AST was folded in via
"#{self}-#{hints.inspect}", and Struct#to_s / Hash#inspect changed their
rendering in Ruby 3.4 ({:a=>1} -> {a: 1}). Schemas whose AST/hints contained
hashes or nested structs (imports, shift/roll stencils) therefore hashed
differently across the CI Ruby matrix, failing strict golden_v2 verify.

Serialize the AST through a format-pinned stable_encode encoder that does not
rely on inspect/to_s, and regenerate the codegen goldens.
@amuta
amuta merged commit 01a77f4 into main Jun 15, 2026
8 checks passed
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.

1 participant