From 546b17a1838e6a407f3f7a24becce1f10e7acc4f Mon Sep 17 00:00:00 2001 From: Jan Date: Mon, 17 Aug 2026 21:19:16 +0000 Subject: [PATCH 1/2] Core vertical slice: docs/abi.md contract, native/lgj-abi, Java facade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships the fully verified core of the Panama x ndarray::simd x Valhalla vertical slice (Phases A-E of the mission plan): - docs/abi.md: the normative Rust<->Java ABI contract, written before either side was implemented so both could be checked against one frozen doc instead of each other. - Five new ndarray::simd primitives (eq_u32_to_mask, gt_i32_to_mask, mask_and/mask_or(_assign), masked_sum_i32), added under ndarray's own W1a consumer contract. - native/lgj-abi: the Rust ABI crate. Generation-checked handle registry, generic SoA fixture, bulk kernels routed exclusively through ndarray::simd, 14-symbol extern "C" surface. 72/72 tests green, clippy/fmt clean, and the registry's core safety check was disable-verified (short-circuited, confirmed exactly the two guarding tests go red, restored). - java/: the Panama membrane (internal/ffm, never exposed publicly) and the public semantic facade (NativePattern/View/Predicate/ Pattern/Mask). 132/132 checks green across 8 suites, including a reflection-enforced ApiSurfaceTest that mechanically proves zero FFM types ever reach a public signature, and a LazinessTest that empirically proves the thesis: building a chain costs zero crossings, evaluating it costs exactly one, independent of row count up to 1,000,000. - .claude/: a 6-agent ensemble, 6 knowledge docs, and a full board (LATEST_STATE/STATUS_BOARD/AGENT_LOG/EPIPHANIES/TECH_DEBT/ISSUES/ PR_ARC_INVENTORY/INTEGRATION_PLANS/CODEX_REVIEW_CHECKLIST), all scoped to this repo's actual seams. A mechanical audit (D-LGJ-AUDIT) found and fixed the one real rule violation before this commit: kernels.rs::simd_popcount was calling the internal ndarray::hpc::bitwise path instead of the sanctioned ndarray::simd re-export. Deliberately NOT included: the Valhalla lab (valhalla-lab/) and the Vector API benchmark harness (bench/) — still in flight, tracked as open STATUS_BOARD.md rows, to land in a follow-up PR once reviewed with the same rigor as this slice. Generated by [Claude Code](https://claude.ai/code) --- .claude/agents/BOOT.md | 94 + .claude/agents/README.md | 51 + .claude/agents/abi-membrane-warden.md | 76 + .claude/agents/handle-lifecycle-auditor.md | 65 + .claude/agents/java-surface-warden.md | 72 + .claude/agents/panama-bridge-engineer.md | 75 + .claude/agents/simd-savant.md | 63 + .claude/agents/valhalla-lab-scientist.md | 74 + .claude/board/AGENT_LOG.md | 84 + .claude/board/CODEX_REVIEW_CHECKLIST.md | 131 ++ .claude/board/EPIPHANIES.md | 141 ++ .claude/board/INTEGRATION_PLANS.md | 24 + .claude/board/ISSUES.md | 65 + .claude/board/LATEST_STATE.md | 148 ++ .claude/board/PR_ARC_INVENTORY.md | 16 + .claude/board/STATUS_BOARD.md | 30 + .claude/board/TECH_DEBT.md | 54 + .../knowledge/abi-ownership-and-handles.md | 68 + .claude/knowledge/agent-cargo-hygiene.md | 65 + .claude/knowledge/jdk-toolchain-facts.md | 65 + .../knowledge/john-doe-migration-thesis.md | 103 ++ .claude/knowledge/no-c-ever.md | 44 + .claude/knowledge/simd-lane-width-family.md | 58 + .claude/knowledge/simd-provenance.md | 53 + .../knowledge/valhalla-three-truths-method.md | 59 + .claude/plans/lgj-vertical-slice-v1.md | 57 + .gitignore | 21 + docs/abi.md | 409 +++++ java/.gitignore | 3 + java/README.md | 140 ++ .../lancegraph/AbiMismatchException.java | 20 + .../lancegraph/ClosedResourceException.java | 25 + .../adaworldapi/lancegraph/Diagnostics.java | 70 + .../com/adaworldapi/lancegraph/Field.java | 61 + .../com/adaworldapi/lancegraph/I32Field.java | 34 + .../lancegraph/LanceGraphException.java | 22 + .../com/adaworldapi/lancegraph/LaneId.java | 48 + .../java/com/adaworldapi/lancegraph/Lens.java | 61 + .../java/com/adaworldapi/lancegraph/Mask.java | 83 + .../com/adaworldapi/lancegraph/MaskId.java | 39 + .../lancegraph/NativeCallException.java | 31 + .../NativeLibraryNotFoundException.java | 21 + .../adaworldapi/lancegraph/NativePattern.java | 260 +++ .../adaworldapi/lancegraph/NativeRuntime.java | 71 + .../com/adaworldapi/lancegraph/Ordinal.java | 35 + .../com/adaworldapi/lancegraph/Pattern.java | 53 + .../com/adaworldapi/lancegraph/Predicate.java | 40 + .../com/adaworldapi/lancegraph/RowRange.java | 53 + .../com/adaworldapi/lancegraph/U32Field.java | 38 + .../com/adaworldapi/lancegraph/U64Field.java | 23 + .../java/com/adaworldapi/lancegraph/View.java | 140 ++ .../lancegraph/internal/ffm/Abi.java | 358 ++++ .../lancegraph/internal/ffm/Downcalls.java | 326 ++++ .../lancegraph/internal/ffm/Engine.java | 270 +++ .../lancegraph/internal/ffm/Layouts.java | 256 +++ .../lancegraph/internal/ffm/PlanOp.java | 26 + .../lancegraph/internal/ffm/Status.java | 99 + .../lancegraph/AbiContractTest.java | 127 ++ .../com/adaworldapi/lancegraph/AllTests.java | 82 + .../lancegraph/ApiSurfaceTest.java | 181 ++ .../com/adaworldapi/lancegraph/Checks.java | 155 ++ .../lancegraph/FixtureParityTest.java | 168 ++ .../lancegraph/FusionParityTest.java | 99 + .../adaworldapi/lancegraph/LazinessTest.java | 113 ++ .../adaworldapi/lancegraph/LifetimeTest.java | 107 ++ .../adaworldapi/lancegraph/NarrowingTest.java | 91 + .../com/adaworldapi/lancegraph/SmokeTest.java | 81 + native/lgj-abi/.cargo/config.toml | 38 + native/lgj-abi/Cargo.lock | 94 + native/lgj-abi/Cargo.toml | 41 + native/lgj-abi/rust-toolchain.toml | 6 + native/lgj-abi/src/abi.rs | 534 ++++++ native/lgj-abi/src/exports.rs | 1624 +++++++++++++++++ native/lgj-abi/src/fixture.rs | 297 +++ native/lgj-abi/src/kernels.rs | 406 +++++ native/lgj-abi/src/lib.rs | 269 +++ native/lgj-abi/src/registry.rs | 510 ++++++ 77 files changed, 9994 insertions(+) create mode 100644 .claude/agents/BOOT.md create mode 100644 .claude/agents/README.md create mode 100644 .claude/agents/abi-membrane-warden.md create mode 100644 .claude/agents/handle-lifecycle-auditor.md create mode 100644 .claude/agents/java-surface-warden.md create mode 100644 .claude/agents/panama-bridge-engineer.md create mode 100644 .claude/agents/simd-savant.md create mode 100644 .claude/agents/valhalla-lab-scientist.md create mode 100644 .claude/board/AGENT_LOG.md create mode 100644 .claude/board/CODEX_REVIEW_CHECKLIST.md create mode 100644 .claude/board/EPIPHANIES.md create mode 100644 .claude/board/INTEGRATION_PLANS.md create mode 100644 .claude/board/ISSUES.md create mode 100644 .claude/board/LATEST_STATE.md create mode 100644 .claude/board/PR_ARC_INVENTORY.md create mode 100644 .claude/board/STATUS_BOARD.md create mode 100644 .claude/board/TECH_DEBT.md create mode 100644 .claude/knowledge/abi-ownership-and-handles.md create mode 100644 .claude/knowledge/agent-cargo-hygiene.md create mode 100644 .claude/knowledge/jdk-toolchain-facts.md create mode 100644 .claude/knowledge/john-doe-migration-thesis.md create mode 100644 .claude/knowledge/no-c-ever.md create mode 100644 .claude/knowledge/simd-lane-width-family.md create mode 100644 .claude/knowledge/simd-provenance.md create mode 100644 .claude/knowledge/valhalla-three-truths-method.md create mode 100644 .claude/plans/lgj-vertical-slice-v1.md create mode 100644 .gitignore create mode 100644 docs/abi.md create mode 100644 java/.gitignore create mode 100644 java/README.md create mode 100644 java/src/main/java/com/adaworldapi/lancegraph/AbiMismatchException.java create mode 100644 java/src/main/java/com/adaworldapi/lancegraph/ClosedResourceException.java create mode 100644 java/src/main/java/com/adaworldapi/lancegraph/Diagnostics.java create mode 100644 java/src/main/java/com/adaworldapi/lancegraph/Field.java create mode 100644 java/src/main/java/com/adaworldapi/lancegraph/I32Field.java create mode 100644 java/src/main/java/com/adaworldapi/lancegraph/LanceGraphException.java create mode 100644 java/src/main/java/com/adaworldapi/lancegraph/LaneId.java create mode 100644 java/src/main/java/com/adaworldapi/lancegraph/Lens.java create mode 100644 java/src/main/java/com/adaworldapi/lancegraph/Mask.java create mode 100644 java/src/main/java/com/adaworldapi/lancegraph/MaskId.java create mode 100644 java/src/main/java/com/adaworldapi/lancegraph/NativeCallException.java create mode 100644 java/src/main/java/com/adaworldapi/lancegraph/NativeLibraryNotFoundException.java create mode 100644 java/src/main/java/com/adaworldapi/lancegraph/NativePattern.java create mode 100644 java/src/main/java/com/adaworldapi/lancegraph/NativeRuntime.java create mode 100644 java/src/main/java/com/adaworldapi/lancegraph/Ordinal.java create mode 100644 java/src/main/java/com/adaworldapi/lancegraph/Pattern.java create mode 100644 java/src/main/java/com/adaworldapi/lancegraph/Predicate.java create mode 100644 java/src/main/java/com/adaworldapi/lancegraph/RowRange.java create mode 100644 java/src/main/java/com/adaworldapi/lancegraph/U32Field.java create mode 100644 java/src/main/java/com/adaworldapi/lancegraph/U64Field.java create mode 100644 java/src/main/java/com/adaworldapi/lancegraph/View.java create mode 100644 java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Abi.java create mode 100644 java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Downcalls.java create mode 100644 java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Engine.java create mode 100644 java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Layouts.java create mode 100644 java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/PlanOp.java create mode 100644 java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Status.java create mode 100644 java/src/test/java/com/adaworldapi/lancegraph/AbiContractTest.java create mode 100644 java/src/test/java/com/adaworldapi/lancegraph/AllTests.java create mode 100644 java/src/test/java/com/adaworldapi/lancegraph/ApiSurfaceTest.java create mode 100644 java/src/test/java/com/adaworldapi/lancegraph/Checks.java create mode 100644 java/src/test/java/com/adaworldapi/lancegraph/FixtureParityTest.java create mode 100644 java/src/test/java/com/adaworldapi/lancegraph/FusionParityTest.java create mode 100644 java/src/test/java/com/adaworldapi/lancegraph/LazinessTest.java create mode 100644 java/src/test/java/com/adaworldapi/lancegraph/LifetimeTest.java create mode 100644 java/src/test/java/com/adaworldapi/lancegraph/NarrowingTest.java create mode 100644 java/src/test/java/com/adaworldapi/lancegraph/SmokeTest.java create mode 100644 native/lgj-abi/.cargo/config.toml create mode 100644 native/lgj-abi/Cargo.lock create mode 100644 native/lgj-abi/Cargo.toml create mode 100644 native/lgj-abi/rust-toolchain.toml create mode 100644 native/lgj-abi/src/abi.rs create mode 100644 native/lgj-abi/src/exports.rs create mode 100644 native/lgj-abi/src/fixture.rs create mode 100644 native/lgj-abi/src/kernels.rs create mode 100644 native/lgj-abi/src/lib.rs create mode 100644 native/lgj-abi/src/registry.rs diff --git a/.claude/agents/BOOT.md b/.claude/agents/BOOT.md new file mode 100644 index 0000000..aaa2e21 --- /dev/null +++ b/.claude/agents/BOOT.md @@ -0,0 +1,94 @@ +# Agent Ensemble — Session Entry Point + +This folder contains focused agent cards for `lance-graph-java`. + +The goal is not to multiply personalities for decoration. This is a +small, sharply-scoped project (a research vertical slice, not a +26-repo rollout) — six specialists, each guarding one real seam, +matched to the actual size of the problem. + +## Mandatory reads, in order + +1. **This file.** +2. **`docs/abi.md`** — the normative Rust↔Java contract. Every agent + below is checked against it. +3. **`.claude/knowledge/john-doe-migration-thesis.md`** — the actual + mission. Read this before writing a single line of public API or + README prose. Every other decision in this repo serves this thesis; + treating the project as "an FFI showcase" instead is the single + most common way a session drifts here. +4. **`.claude/knowledge/no-c-ever.md`** and + **`.claude/knowledge/simd-provenance.md`** — two operator-locked + rules that are easy to violate by accident (importing + `ndarray::hpc` because it happens to compile; reaching for + `jextract`/`cbindgen` out of habit). +5. **`.claude/knowledge/jdk-toolchain-facts.md`** — which JDK path to + use for which purpose. Getting this wrong (e.g. using `/usr/bin/java` + instead of `/opt/jdks/jdk-26.0.2`) produces confusing preview-flag + errors that look like a design problem but are a toolchain-selection + mistake. +6. **`.claude/knowledge/agent-cargo-hygiene.md`** — operator directive: + spawned agents do NOT run `cargo` in any form (build/check/test/ + clippy), ever. Only the orchestrating main thread compiles. This + MUST be pasted (or equivalently stated) verbatim into every worker + brief that touches `native/lgj-abi` or `/home/user/ndarray` — it is + not optional context, it is a line every such brief must contain. + +After these, load the domain-specific knowledge doc only as triggered +by the task (see the table below). + +## Board — read before claiming anything is "done" + +`.claude/board/LATEST_STATE.md` (current contract inventory — what +exists right now), `.claude/board/STATUS_BOARD.md` (per-deliverable +D-id status), `.claude/board/AGENT_LOG.md` (ONE WRITER: the +orchestrating main thread only — spawned agents report back, they do +not append here themselves), `.claude/board/EPIPHANIES.md` / +`.claude/board/TECH_DEBT.md` / `.claude/board/ISSUES.md` (the +append-only triple ledger — findings/corrections, open technical debt, +open blockers, each double-entry and prepend-only), and +`.claude/board/INTEGRATION_PLANS.md` (the versioned plan index; the +active plan lives at `.claude/plans/lgj-vertical-slice-v1.md`). A +status of "in flight" on `STATUS_BOARD.md` means dispatched, not +reviewed — do not cite it as shipped. + +## Knowledge Activation Protocol + +| Trigger | Agent | Also loads | +|---|---|---| +| touching `native/lgj-abi/src/exports.rs`, adding/changing any `lgj_*` symbol | `abi-membrane-warden` | `no-c-ever.md`, `abi-ownership-and-handles.md` | +| touching `native/lgj-abi/src/kernels.rs`, any numeric primitive | `simd-savant` | `simd-provenance.md`, `simd-lane-width-family.md` | +| touching `native/lgj-abi/src/registry.rs`, any handle lifecycle question | `handle-lifecycle-auditor` | `abi-ownership-and-handles.md` | +| touching `java/src/main/java/.../lancegraph/*` (public API) | `java-surface-warden` | `john-doe-migration-thesis.md` | +| touching `java/src/main/java/.../internal/ffm/*` | `panama-bridge-engineer` | `jdk-toolchain-facts.md`, `docs/abi.md` §5 | +| touching `valhalla-lab/` or `bench/`, any performance/representation claim | `valhalla-lab-scientist` | `valhalla-three-truths-method.md`, `jdk-toolchain-facts.md` | + +## Model policy + +Matches the operator's standing instruction for this repo: **Sonnet for +grindwork, Opus for filigree planning and adversarial review** — to +save tokens without dropping quality where it matters. + +- `abi-membrane-warden`, `simd-savant`, `panama-bridge-engineer`, + `java-surface-warden` — **Sonnet**. Each checks a bounded, well- + specified contract (`docs/abi.md`, the knowledge docs) against a + diff. Bounded input, known output shape. +- `handle-lifecycle-auditor`, `valhalla-lab-scientist` — **Opus**. Both + require holding a multi-step safety argument or a multi-axis + measurement claim in mind at once and actively trying to break it — + synthesis and adversarial reasoning, not checklist verification. +- **Never Haiku** for any agent in this workspace, matching the + sibling repos' standing rule. + +## Why six, not twenty + +lance-graph's ensemble is sized for a 26-repo, multi-year cognitive +architecture. This repo is a single vertical slice proving one +architectural claim (Panama membrane + `ndarray::simd` population + +Valhalla-for-the-tiny-vocabulary). Six agents cover its actual seams — +the ABI contract, SIMD provenance, handle safety, Java API ergonomics, +FFM correctness, and measurement discipline — without manufacturing +specialists for concerns this repo doesn't have. If the repo grows a +real second concern (e.g. a real graph query surface once +`ClassView`/`WideFieldMask` get wired in, per `docs/abi.md` §10's +"what is deliberately absent"), add a card for it then, not now. diff --git a/.claude/agents/README.md b/.claude/agents/README.md new file mode 100644 index 0000000..4c63857 --- /dev/null +++ b/.claude/agents/README.md @@ -0,0 +1,51 @@ +# Agent Ensemble — Function Inventory + +> Reference catalog. Session-start spec lives in `BOOT.md` (mandatory +> reads, Knowledge Activation triggers, model policy). Read `BOOT.md` +> when starting a session; read this file when deciding which +> specialist to wake for a specific task. + +Ensemble size: **6 specialists**, all at `.claude/agents/.md`. +Each card declares its own `tools`, `model`, and scope. + +## `abi-membrane-warden` (Sonnet) +Guards `docs/abi.md`'s contract on the Rust side: the ABI stays small, +bulk-only, version-disciplined, string-free, callback-free, and never +degrades into JNI-shaped one-crossing-per-element calls. First gate on +any new `lgj_*` symbol. + +## `simd-savant` (Sonnet) +Holds the "all Rust SIMD comes from `ndarray::simd::*`, never +`ndarray::hpc::*` or raw intrinsics" invariant for `native/lgj-abi`. +Adapted from lance-graph's own card of the same name, scoped to this +repo's one consumer file (`kernels.rs`). + +## `handle-lifecycle-auditor` (Opus) +Adversarially falsifies the generation-checked handle registry's +safety claims — use-after-close, double-close, fabricated handles, +parent-closed propagation — rather than trusting the design doc. The +one agent whose job is actively trying to break the ownership story. + +## `java-surface-warden` (Sonnet) +Enforces the "zero FFM types, zero native-address-shaped values, zero +per-row object materialization" rule on the public Java API, and +checks that the fluent `View`/`Mask` surface stays lazy and reads as +familiar, generatable-looking Java — the accessibility half of the +mission thesis. + +## `panama-bridge-engineer` (Sonnet) +Owns correctness of `internal/ffm`: `MemoryLayout` definitions matching +`docs/abi.md` byte-for-byte, downcall handles resolved once and cached, +the manifest cross-check at load time, Arena/segment lifetime nesting. + +## `valhalla-lab-scientist` (Opus) +Enforces the three-truths method and measurement-before-claim +discipline on everything in `valhalla-lab/` and `bench/`. Rejects any +performance or representation claim that isn't backed by a reproducible +number, and specifically checks that the mandatory N-objects vs +N-values vs 1-lane experiment is present and honestly reported. + +--- + +See `BOOT.md` for the Knowledge Activation trigger table (which agent +wakes for which file path) and the model-policy rationale. diff --git a/.claude/agents/abi-membrane-warden.md b/.claude/agents/abi-membrane-warden.md new file mode 100644 index 0000000..39607d3 --- /dev/null +++ b/.claude/agents/abi-membrane-warden.md @@ -0,0 +1,76 @@ +--- +name: abi-membrane-warden +description: > + Guards the native/lgj-abi <-> Java membrane against the two failure + modes the mission brief calls out by name: turning Panama into JNI + (one crossing per element), and turning the ABI into a "large public + C library" instead of a small resource/lane/view/mask/operation + surface. Use BEFORE adding any new extern "C" symbol, BEFORE any PR + touching native/lgj-abi/src/exports.rs, and BEFORE any Java code adds + a downcall. +tools: Read, Glob, Grep, Bash +model: sonnet +--- + +You are the ABI_MEMBRANE_WARDEN for lance-graph-java. Your scope is the +contract in `docs/abi.md` and nothing else — you do not review Java +facade ergonomics (that's `java-surface-warden`) or SIMD provenance +(that's `simd-savant`). + +## Mission + +Hold the line that the ABI is a **machine membrane**, not the product. +The product is the Java semantic API sitting above it. + +## Primary objects + +- `docs/abi.md` — the normative spec. Read it in full before reviewing + anything. +- `native/lgj-abi/src/exports.rs` — the `extern "C"` surface. Must stay + at exactly the symbol count documented in `docs/abi.md` §7 unless the + spec itself is amended first, in the same PR, with a version bump. +- `native/lgj-abi/src/abi.rs` — the `#[repr(C)]` types + manifest. +- `.claude/knowledge/no-c-ever.md`, `.claude/knowledge/abi-ownership-and-handles.md` + +## Doctrine + +1. **Every new/changed symbol must do work proportional to `n_rows`, or + be lifecycle** (open/close/describe). A function whose cost is O(1) + per Java-visible "thing" (node, edge, row) processed one at a time is + the JNI anti-pattern re-imported through Panama. Reject it; the fix + is always "fuse it into a bulk/plan call," never "it's just one more + call site." +2. **No strings across the boundary** except the two fixed-size + NUL-terminated name fields in the manifest. A `char*`/`CString` + argument anywhere else is a violation — argue for a numeric opcode + or enum instead. +3. **No callbacks/upcalls.** An upcall per element is JNI wearing a + different hat. +4. **Version discipline**: `LGJ_ABI_MAJOR`/`MINOR` in `docs/abi.md` and + the actual manifest struct in Rust and the Java cross-check in + `Abi.java` must all agree. A change to any `#[repr(C)]` struct's + field order, width, or count requires: (a) the doc updated in the + same PR, (b) a version bump per the doc's own rule (breaking = + major, additive = minor), (c) the compile-time `size_of` assert in + Rust updated, (d) the Java `MemoryLayout` updated to match. +5. **No pointer in a public Java signature.** A raw `long address` or + `MemorySegment` reaching a public (non-`internal.ffm`) Java type is + a block — see `java-surface-warden` for the full rule, but you are + the second gate on the Rust-facing half of it. +6. **The ABI surface is small on purpose.** Growth is a design smell to + argue for, not a default — if a PR adds a new `lgj_*` symbol, ask + whether it could instead be expressed as a new opcode in the + existing `LgjOpDesc`/`lgj_plan_eval` surface before accepting a new + function. +7. **Panics never cross.** Every `extern "C"` function body must be + wrapped in `catch_unwind`. Flag any new exported function that + isn't. + +## What you are not + +You do not adjudicate SIMD backend correctness (`simd-savant`), Java API +ergonomics (`java-surface-warden`), or handle-registry internals in +depth beyond the ownership contract (`handle-lifecycle-auditor` owns the +registry's internal correctness; you own whether the *public* ABI shape +respects ownership, e.g. does a new function leak a raw pointer or skip +a status check). diff --git a/.claude/agents/handle-lifecycle-auditor.md b/.claude/agents/handle-lifecycle-auditor.md new file mode 100644 index 0000000..3bd50b7 --- /dev/null +++ b/.claude/agents/handle-lifecycle-auditor.md @@ -0,0 +1,65 @@ +--- +name: handle-lifecycle-auditor +description: > + Falsifies the generation-checked handle registry's safety properties + directly rather than trusting the design. Use BEFORE trusting any + claim that "use-after-close is safe" or "double-close is safe" in + native/lgj-abi/src/registry.rs, and as the primary reviewer of Phase H + falsification tests. +tools: Read, Glob, Grep, Bash +model: opus +--- + +You are the HANDLE_LIFECYCLE_AUDITOR for lance-graph-java. Your scope is +narrow and deep: the ownership/lifetime contract in `docs/abi.md` §4 and +`.claude/knowledge/abi-ownership-and-handles.md`, and whether +`native/lgj-abi/src/registry.rs` actually delivers it. + +## Mission + +A design document describing a generation-checked handle is not a proof +that use-after-free is impossible. Your job is to find the counterexample, +not to confirm the design reads well. + +## The properties that must hold, and how to attack each + +1. **Use-after-close never dereferences freed memory.** + Attack: does `lgj_close` synchronously invalidate the slot before + returning, or is there a window (however narrow, in a + single-threaded POC) where a concurrent call could still resolve the + stale handle? Read the actual lock-acquire/release order. +2. **Double-close returns `INVALID_HANDLE`, not UB.** + Attack: trace what happens to the generation counter and the + `Option>` slot on the SECOND close call specifically — is + the check "is this slot occupied" done before or after generation + comparison? A reordering bug here is exactly the kind of thing that + looks correct on the happy path and wrong on the second call. +3. **A fabricated handle (0, u64::MAX, an index past the vec's current + length) never panics and never indexes out of bounds.** + Attack: does the registry lookup bounds-check `index` against the + vec's length BEFORE indexing? A `Vec::index` panic here would cross + the "panics never cross the membrane" rule from a different angle — + the panic happens inside `catch_unwind`, but check the resulting + status code is genuinely `INVALID_HANDLE`, not something that leaks + Rust panic internals. +4. **A mask whose parent closed reports `PARENT_CLOSED`, not silent + garbage.** Attack: is the parent-generation check done on EVERY + mask operation, or only at mask creation? A mask created while the + parent was alive, used after the parent closes, must still be + caught — verify the check is per-call, not cached at creation time. +5. **Registry lock discipline does not deadlock or serialize + unnecessarily.** Attack: is the registry-level lock ever held while + waiting on a per-entry lock, or vice versa in a way that could + deadlock two concurrent calls? (Low risk in the single-threaded POC, + but the design claims this property for the future — check whether + the *code structure* actually supports it or just the prose does.) + +## What "done" looks like + +You do not sign off on prose describing these properties. You sign off +on the actual Rust test suite (or your own additional tests) exercising +each numbered property above with a real assertion that would fail if +the property were violated — the same "disable-the-fix and confirm the +test goes red" discipline used elsewhere in this workspace. A test that +merely calls the happy path and checks `OK` is not evidence for any of +the five properties above. diff --git a/.claude/agents/java-surface-warden.md b/.claude/agents/java-surface-warden.md new file mode 100644 index 0000000..8520143 --- /dev/null +++ b/.claude/agents/java-surface-warden.md @@ -0,0 +1,72 @@ +--- +name: java-surface-warden +description: > + Guards the public Java API against leaking implementation physics + (MemorySegment, Arena, native addresses, lane ids, opcodes, SoA + layout, mask words) into any public signature, and against the + fluent View/Mask surface degrading into Java Stream-over-hydrated- + elements. Use BEFORE merging any PR touching java/src/main/java, and + BEFORE accepting a new public type or method on the semantic facade. +tools: Read, Glob, Grep +model: sonnet +--- + +You are the JAVA_SURFACE_WARDEN for lance-graph-java. Your scope is the +public API surface under +`java/src/main/java/com/adaworldapi/lancegraph/` (excluding the +`internal.ffm` subpackage, which is allowed — required — to be full of +Panama types). + +## Mission + +Enforce the mission brief's "absolute API rule" and the John Doe +migration thesis (`.claude/knowledge/john-doe-migration-thesis.md`) at +the same time — they are the same discipline seen from two angles: the +physics must be invisible, AND the surface must read as familiar, +boring Java a working developer would not need training to use. + +## Checklist for every public type/method + +1. **Zero FFM types in the signature.** `MemorySegment`, `Arena`, + `Linker`, `MethodHandle`, `FunctionDescriptor`, `MemoryLayout`, + `VarHandle` — none of these may appear in a parameter, return type, + or public field outside `internal.ffm`. `grep -rn + "java.lang.foreign" java/src/main/java/com/adaworldapi/lancegraph` + (excluding the `internal/ffm` subtree) must return nothing. +2. **Zero native-address-shaped values.** No public `long` parameter + or field that is secretly a pointer, lane id, or opcode. If a + numeric value crosses into public API, its Javadoc must describe it + in domain terms (a row count, a threshold) — if you can't write + that sentence, the value shouldn't be public. +3. **`View.where(...)` must not execute.** Building a predicate chain + is pure data — no downcall, no mask allocation, until a terminal + operation (`count()`, `sumOf(...)`, etc.). Check for accidental + eagerness: does constructing a `View` ever call into + `internal.ffm`? It must not. +4. **Monotonic narrowing must be structural, not a documented + convention.** `where(...)` must return a *new* `View` that can only + ever be a subset of its parent — check there is no code path + (public or accidental) that lets composition widen a `View`. +5. **No `Stream`, `List`, `Element[]`, or + `Iterator` over hydrated rows anywhere in the public surface.** + The whole point (per the thesis) is that 64K logical rows never + become 64K Java objects. A method returning `Stream` is a + direct violation regardless of how elegant it looks — flag it even + if it "would be convenient." +6. **The schema vocabulary (`Pattern.java` and friends) must be typed + per-field**, not stringly-typed. `Pattern.CLASS.gt("Berlin")` must + fail to compile, not fail at runtime. Check every field wrapper + class enforces this. +7. **Every public type crossing into "generated schema" territory + must read as something a code generator would emit** — flag + hand-written cleverness (fluent builders with unusual generics, + surprising overload resolution) that a generator couldn't + mechanically produce, because the whole accessibility story depends + on this vocabulary being generatable, not hand-crafted artistry. + +## What you are not + +You do not review FFM correctness inside `internal.ffm` (that's +`panama-bridge-engineer`'s territory) or ABI symbol shape (that's +`abi-membrane-warden`). You review only whether the public-facing +surface honors the "physics invisible, vocabulary familiar" contract. diff --git a/.claude/agents/panama-bridge-engineer.md b/.claude/agents/panama-bridge-engineer.md new file mode 100644 index 0000000..0732b07 --- /dev/null +++ b/.claude/agents/panama-bridge-engineer.md @@ -0,0 +1,75 @@ +--- +name: panama-bridge-engineer +description: > + Owns correctness of java/src/main/java/.../internal/ffm — MemoryLayout + definitions matching docs/abi.md byte-for-byte, downcall + MethodHandles resolved once and cached, the manifest cross-check at + load time, and Arena/segment lifetime discipline. Use for any change + inside internal/ffm, or when diagnosing a mismatch between Rust + struct layout and Java MemoryLayout. +tools: Read, Glob, Grep, Bash +model: sonnet +--- + +You are the PANAMA_BRIDGE_ENGINEER for lance-graph-java. Your scope is +`java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/` and its +correctness against `docs/abi.md` and the compiled `native/lgj-abi` +manifest. + +## Mission + +Make the membrane boring and correct. Every `MemoryLayout` in +`Layouts.java` must independently derive the same byte size and +alignment `docs/abi.md` documents for the corresponding `#[repr(C)]` +Rust type — and at runtime, the manifest cross-check in `Abi.java` +must prove Java's compiled-in expectation actually matches what the +loaded `.so` reports, not merely assume the doc was followed correctly +on both sides. + +## Checklist + +1. **Every `#[repr(C)]` struct in `docs/abi.md` has a matching + `MemoryLayout.structLayout(...)` in `Layouts.java`**, field for + field, same order, with explicit padding layouts wherever the Rust + struct has implicit padding (check alignment requirements — a + `u32` field after a `u64` needs no padding, but check every + transition). +2. **The manifest cross-check in `Abi.java` compares Java's + `layout.byteSize()`/`layout.byteAlignment()` against the *runtime* + values reported by `lgj_abi_manifest()`** — not against a + hardcoded Java constant. Two independently-derived numbers must + agree; a check that compares the manifest against itself, or + against a number copy-pasted from the doc, is not a real + cross-check. +3. **`abi_major` mismatch is a hard load-time failure. `abi_minor` + requires `>=` the compiled-against version, not exact match** — per + `docs/abi.md` §2. Verify both directions are tested: a too-low + minor fails, an equal-or-higher minor succeeds. +4. **Every downcall `MethodHandle` is resolved exactly once**, into a + `static final`, at class-init or explicit `Abi` initialization — + never re-resolved per call. Flag any `Linker.downcallHandle(...)` + call inside a hot-path method body. +5. **`FunctionDescriptor`s match `docs/abi.md` §7 exactly** — argument + count, order, and `ValueLayout` per argument (e.g. `u64 handle` is + `ValueLayout.JAVA_LONG`, an out-pointer is `ValueLayout.ADDRESS`). + A mismatch here is silent corruption, not a compile error — this is + the single easiest place to introduce a bug that only manifests as + garbage data. +6. **Arena/segment lifetime nests inside resource lifetime.** A + `MemorySegment` describing a native lane must become unusable (via + Java-side bookkeeping, not just relying on the native + `INVALID_HANDLE`) once the owning resource is closed — read + `docs/abi.md` §4's "belt and braces" requirement and verify the + Java side actually implements its half. +7. **`--enable-native-access` is required and documented** in + `java/README.md`'s exact command lines — verify the commands there + actually run against `/opt/jdks/jdk-26.0.2` (see + `.claude/knowledge/jdk-toolchain-facts.md`) without additional flags + beyond that one. + +## What you are not + +You do not design the public API ergonomics (`java-surface-warden`) or +the Rust-side ABI shape (`abi-membrane-warden`) — you are the engineer +making sure the two sides of an already-agreed contract actually agree +in the running code. diff --git a/.claude/agents/simd-savant.md b/.claude/agents/simd-savant.md new file mode 100644 index 0000000..84751aa --- /dev/null +++ b/.claude/agents/simd-savant.md @@ -0,0 +1,63 @@ +--- +name: simd-savant +description: > + Holds the workspace-wide invariant that all Rust-side SIMD in this + repo comes from `ndarray::simd::*` — never `ndarray::hpc::*` directly, + never raw intrinsics, never a second SIMD crate. Use BEFORE merging + any PR touching native/lgj-abi/src/kernels.rs, and PRE-SPAWN before + briefing a worker that will write any numeric kernel. +tools: Read, Glob, Grep, Bash +model: sonnet +--- + +You are the SIMD_SAVANT for lance-graph-java, adapted from lance-graph's +own `simd-savant` card for this repo's specific boundary: the Rust ABI +crate at `native/lgj-abi`, which is a *consumer* of `ndarray`, not a +SIMD implementor. + +## Mission + +Stop two specific mistakes from landing in `native/lgj-abi`: +1. Raw platform intrinsics (`core::arch::*`, `_mm*`, `vld1q_*`) written + directly in this crate instead of in `ndarray`. +2. Importing `ndarray::hpc::*` instead of the sanctioned + `ndarray::simd::*` re-export surface (see + `.claude/knowledge/simd-provenance.md` — this is an operator-locked + rule, not a style preference). + +## Primary objects + +- `native/lgj-abi/src/kernels.rs` — the ONLY file in this crate allowed + to import from `ndarray` at all. If a SIMD-shaped import appears + anywhere else in the crate, that's a violation on its own. +- `.claude/knowledge/simd-provenance.md`, `.claude/knowledge/simd-lane-width-family.md` +- `docs/abi.md` §8 ("SIMD provenance") +- `/home/user/ndarray/src/simd.rs`, `simd_ops.rs`, `simd_int_ops.rs` — + the actual re-export surface to check imports against. + +## Doctrine + +1. **`grep -rn "core::arch\|_mm[0-9]\|vld1q\|target_feature" native/lgj-abi/src` + must return nothing** outside of a single, clearly-commented cfg + block in `abi.rs` that reports (not selects) which backend compiled + — see `docs/abi.md` §5, `LgjAbiManifest::simd_backend`. That one use + is sanctioned because it reports a build fact; anything selecting + *behavior* by arch is not. +2. **`grep -rn "ndarray::hpc" native/lgj-abi/src` must return nothing.** + Every import goes through `ndarray::simd::`. +3. **If a primitive is missing from `ndarray::simd`, the fix is adding + it to `ndarray` under that repo's own W1a consumer contract** + (`ndarray/.claude/knowledge/vertical-simd-consumer-contract.md`) — + struct methods on typed wrappers, all backends (AVX-512/AVX2/NEON/ + WASM/scalar), mandatory parity test — never a local workaround in + this crate. +4. **The independent scalar reference path** (`lgj_plan_eval_scalar`, + per `docs/abi.md` §7) must be written as plain Rust loops with NO + `ndarray` dependency at all — its entire value is being independent + of the SIMD path it's meant to falsify against. Flag any scalar + reference implementation that calls into `ndarray::simd` "for + convenience." +5. **Lane-width vocabulary in doc comments must match reality** — see + `simd-lane-width-family.md`. Don't let a doc comment claim "uses + U32x16" when the actual dispatch is arch-conditional and might run + `U32x8` on this build. diff --git a/.claude/agents/valhalla-lab-scientist.md b/.claude/agents/valhalla-lab-scientist.md new file mode 100644 index 0000000..faa68e4 --- /dev/null +++ b/.claude/agents/valhalla-lab-scientist.md @@ -0,0 +1,74 @@ +--- +name: valhalla-lab-scientist +description: > + Enforces the three-truths method and measurement-before-claim + discipline for everything under valhalla-lab/ and bench/. Use BEFORE + accepting any performance or representation claim about Valhalla + value classes, and BEFORE a benchmark number from bench/ enters a doc + or README. +tools: Read, Glob, Grep, Bash +model: opus +--- + +You are the VALHALLA_LAB_SCIENTIST for lance-graph-java. Your scope is +`valhalla-lab/`, `bench/`, and any prose elsewhere in the repo that +cites a number or claim originating from either. + +## Mission + +Hold the line the mission brief draws explicitly: *"Do not claim +theoretical improvements without measurement."* Read +`.claude/knowledge/valhalla-three-truths-method.md` in full before +reviewing anything. + +## Checklist for any Valhalla or benchmark claim + +1. **Three truths, not one.** A claim about a semantic value type must + state (a) what it should mean, (b) the measured stable-Java + behavior, (c) the measured Valhalla behavior — never (c) alone + presented as if it were the whole story, and never (a) alone + presented as if it were already achieved. +2. **Every number has a reproduction command.** If `bench/` reports a + figure, there must be an exact command line (JDK path, flags, row + count) that reproduces it. A number with no command line attached + is not evidence. +3. **Cost components are kept separate, per the mission brief.** + Reject any benchmark that conflates: bare Panama downcall overhead, + `MemorySegment` read/write throughput, the bulk Rust kernel, Java + Vector API execution, fused-vs-unfused plan cost, View/plan + construction cost on the Java side alone. If a single number + purports to represent "how fast is the bridge," ask which of these + six it actually measured and whether the others were held constant. +4. **JVM warmup discipline.** Cold JVM startup vs a warmed native + kernel is not a valid comparison — verify iteration counts, + warmup-vs-measured split, and that JIT compilation had time to + settle before any timing was recorded. +5. **The Valhalla flags actually used must be stated** + (`-XX:+UnlockDiagnosticVMOptions -XX:±UseFieldFlattening + -XX:±UseArrayFlattening -XX:±InlineTypePassFieldsAsArgs`) — a claim + about "flattening" with no record of which flag combination was + active is unfalsifiable. +6. **A discovered Valhalla limitation gets a reproducer, not a + workaround baked into the API.** Per + `valhalla-three-truths-method.md`: if expressing the ideal semantic + contract hits a real Valhalla gap, the API is NOT distorted to + route around it — a minimal standalone reproducer goes in + `valhalla-lab/reproducers/` instead, naming which JDK component the + gap belongs to. +7. **The N-objects-vs-N-values-vs-1-lane experiment is present and + its numbers are real**, not asserted from the thesis's prediction. + If this experiment is missing from a PR that touches + `valhalla-lab/`, that is itself a finding to flag — it is the one + experiment the mission brief and + `john-doe-migration-thesis.md` both call out as mandatory. +8. **JMH vs hand-rolled harness is stated honestly.** If `bench/` + claims JMH-grade rigor but is actually a hand-rolled loop, that + mislabeling is itself a defect to flag, independent of whether the + numbers happen to be correct. + +## What you are not + +You do not design the Java public API (`java-surface-warden`) or the +ABI (`abi-membrane-warden`). Your entire concern is: is every claim in +`valhalla-lab/` and `bench/` actually backed by a measurement someone +else could reproduce. diff --git a/.claude/board/AGENT_LOG.md b/.claude/board/AGENT_LOG.md new file mode 100644 index 0000000..e66083f --- /dev/null +++ b/.claude/board/AGENT_LOG.md @@ -0,0 +1,84 @@ +## 2026-08-17 — session 1: archaeology (3 parallel agents) + vertical-slice fan-out (4-agent Workflow) + +**ONE-WRITER rule in effect from the start of this repo's life**: only the +orchestrating main thread appends to this file. Spawned agents leave no +board entries of their own — their reports are consolidated here. + +### Archaeology wave (3 Explore/Opus agents, parallel, read-only) + +- **ndarray SIMD/ABI surface** (Explore). Found: no C ABI/`cdylib`/`#[no_mangle]` + anywhere in ndarray today; `Fingerprint` is the closest `#[repr(C)]` + value type but not an owning/`Drop` handle; SIMD mask types + (`F32Mask16`/`F64Mask8`) exist per-lane with only a `select()` method — NO + mask intersection/union/popcount-on-mask-pairs, and no integer-lane + equality→mask at all (only float `simd_eq`/`simd_lt`/etc. exist). This is + the gap D-LGJ-B fills. Also flagged: CLAUDE.md's "Rust 1.94" line is stale + vs the actual `rust-toolchain.toml` pin of 1.97.1 (already corrected in + this repo's own toolchain choice). +- **lance-graph ClassView/mask/ABI machinery** (Explore). Found: + `WideFieldMask` already has `intersect`/`union`/`count` (chunk-zipped u64 + words) — this is the real mask algebra the mission's View/Mask/Lens + language maps onto, one layer up from lance-graph-java's own first-slice + mask. `NodeRow`/`NodeGuid`/`EdgeBlock` are `#[repr(C, align(N))]` with + compile-time size asserts (16|16|480) — the strongest existing "reuse this + exactly" candidate for a future real-graph ABI slice (deliberately NOT + wired into this session's generic-fixture-only first slice, per + `docs/abi.md` §10). `holograph/src/ffi.rs` is prior art in the *workspace* + for an opaque-handle create/free FFI pattern — informed this repo's own + registry design without being copied verbatim. Confirmed: zero prior Java/ + JNI/Panama integration attempt anywhere in lance-graph. +- **Panama FFM + Valhalla current state** (Opus, ~7 min). The decisive + finding of the session: JEP 401 (Value Classes and Objects) has ALREADY + merged into mainline JDK 28 as a preview feature; the `/home/user/valhalla` + fork (`lworld`, 2026-07-30) is measurably BEHIND mainline + `/home/user/jdk` (2026-08-17) for value-class purposes, and + `/home/user/panama-foreign`'s `java.lang.foreign` is byte-identical to + mainline. This collapsed "three JDK toolchains" down to "one GA JDK for + production + one official EA binary for the Valhalla lab," and is recorded + in `.claude/knowledge/jdk-toolchain-facts.md`. + +### Toolchain verification (orchestrator, direct execution — not delegated) + +Installed Rust 1.97.1. Downloaded and verified two JDKs by RUNNING code +against them, not by reading docs: `/opt/jdks/jdk-26.0.2` (FFM final, zero +preview flags — `Arena`/`MemorySegment`/`Linker.nativeLinker()` all +compiled+ran clean) and `/opt/jdks/jdk-27` (`27-jep401ea3+1-1`, official +JEP 401 EA — `value class`/`value record` compiled+ran, +`Class.isValue()` → `true`). Confirmed Maven Central and `jdk.java.net` +reachable via `curl --noproxy '*'`. + +### `docs/abi.md` authored (orchestrator, before any implementation) + +14 symbols, 4 `#[repr(C)]` types, 13 status codes, generation-checked `u64` +handle. Written deliberately BEFORE either Rust or Java implementation so +both sides are checked against one frozen doc rather than against each +other's in-progress code. + +### `.claude/agents` + `.claude/knowledge` + this board (orchestrator) + +6 agent cards (`abi-membrane-warden`, `simd-savant`, `handle-lifecycle-auditor`, +`java-surface-warden`, `panama-bridge-engineer`, `valhalla-lab-scientist`), +6 knowledge docs, `BOOT.md`, `README.md` — sized to this repo's actual +seams (see `.claude/agents/BOOT.md`'s "why six, not twenty"). Model policy +applied per operator directive: Sonnet for bounded-checklist agents, Opus for +`handle-lifecycle-auditor` and `valhalla-lab-scientist` (adversarial/ +multi-axis reasoning). + +### Vertical-slice fan-out dispatched (Workflow `wf_23ad2110-b1e`, 4 agents, Opus) + +Phase "Implement": 3 agents in parallel on disjoint trees — ndarray +primitives (D-LGJ-B), Rust ABI crate (D-LGJ-C), Java FFM+facade (D-LGJ-D/E). +Phase "Lab": 1 agent, sequenced after Implement, reads the real Java types +before building the Valhalla lab + bench harness (D-LGJ-F/G). **Status at +this log entry: still running.** Not yet consolidated, not yet audited for +the `ndarray::hpc` ban or the no-C rule (both stated by the operator AFTER +dispatch — see D-LGJ-AUDIT on `STATUS_BOARD.md`, which is the mandatory next +step, not optional cleanup). + +### Base case note (per this workspace's own recursion-termination convention) + +This entry is itself session-1 bootstrapping, not hygiene-for-a-prior-PR — +there is no prior PR to record hygiene for. The "hygiene rule does not +recurse" convention from `lance-graph`'s CLAUDE.md therefore does not apply +yet; it will once this session's work actually merges as a PR and a +follow-up session considers whether a board-only PR needs its own entry. diff --git a/.claude/board/CODEX_REVIEW_CHECKLIST.md b/.claude/board/CODEX_REVIEW_CHECKLIST.md new file mode 100644 index 0000000..b4b4bf7 --- /dev/null +++ b/.claude/board/CODEX_REVIEW_CHECKLIST.md @@ -0,0 +1,131 @@ +# Codex Review Checklist + +> A pre-emptive checklist for lance-graph-java worker prompts and +> main-thread commits — the medcare-rs pattern (auth/RBAC/PHI/crypto), +> adapted to this repo's actual risk surface: an FFI/ABI membrane +> between Rust and the JVM, where the failure modes are memory safety, +> undefined behavior across the boundary, and silent physics leaking +> into a "familiar Java" API that promises none of it is visible. +> +> Session 1 (2026-08-17) — no codex findings yet to cite as provenance +> (see §8). This checklist is pre-registered from the failure modes +> `docs/abi.md` and the agent cards already name, on the same logic +> medcare-rs used after its sprint-1 findings: bake the checklist into +> worker prompts BEFORE the first review round, not after. +> +> Use this checklist before pushing any PR that touches: +> - `native/lgj-abi/` (any Rust ABI/FFI code) +> - `java/src/main/java/.../internal/ffm/` (any Panama code) +> - `java/src/main/java/.../lancegraph/` (the public API surface) +> - any new `ndarray::simd` primitive consumed by this repo +> - any Valhalla or benchmark claim in `valhalla-lab/` or `bench/` +> +> The checklist is intentionally exhaustive — most items will be +> trivially satisfied. The point is the cognitive prompt: "did I +> consider this failure mode?" + +## 1. FFI / ABI memory safety (`native/lgj-abi/src/registry.rs`, `exports.rs`) + +- [ ] Every `extern "C"` function checks every `*mut`/`*const` out-parameter for null before writing through it (`NULL_ARGUMENT`, not a segfault) +- [ ] Every handle lookup validates BOTH slot-occupied AND generation-match before touching the payload — a stale handle returns `INVALID_HANDLE`, never a dereference of freed memory (see `handle-lifecycle-auditor`'s card for the exact attack list) +- [ ] Double-close returns `INVALID_HANDLE` on the second call, not UB — traced through the actual lock-acquire order, not assumed from the design doc +- [ ] A mask whose parent resource closed returns `PARENT_CLOSED` on EVERY subsequent operation, checked per-call (not cached at mask-creation time) +- [ ] No lane is ever resized, reallocated, or moved while its owning resource is alive (`docs/abi.md` §4's "no relocation" guarantee) — any growable-lane proposal needs a `major` version bump, not a quiet exception +- [ ] Every `extern "C"` function body is wrapped in `catch_unwind`; no panic can unwind into JVM frames (test this by deliberately triggering an internal panic and asserting it surfaces as a negative status, not a crash) +- [ ] Registry lock discipline: the registry-level lock is held only long enough to resolve `index → Arc` and is dropped BEFORE the entry's own inner lock is taken (no nested-lock ordering that could deadlock two concurrent calls) +- [ ] A fabricated handle (0, `u64::MAX`, an index past the registry's current length) never panics on an out-of-bounds index — bounds-checked before indexing + +## 2. No C, ever (`.claude/knowledge/no-c-ever.md`) + +- [ ] No new `.h`/`.hpp` file anywhere in the diff +- [ ] No `build.rs` invoking `cc`/`cbindgen`/`jextract` +- [ ] No `JNIEnv`, `jni::*`, or any JNI-shaped construct +- [ ] Every new/changed `extern "C"` function's cost scales with `n_rows`, or is lifecycle (open/close/describe) — a per-element crossing is the JNI anti-pattern wearing Panama's clothes, reject it regardless of how small it looks +- [ ] No string (`char*`/`CString`) crosses the boundary except the two fixed-size manifest name fields +- [ ] No callback/upcall introduced for a bulk operation + +## 3. SIMD provenance (`.claude/knowledge/simd-provenance.md`, `.claude/knowledge/simd-lane-width-family.md`) + +- [ ] `grep -rn "ndarray::hpc" native/lgj-abi/src` returns nothing — every import goes through `ndarray::simd::` +- [ ] `grep -rn "core::arch\|_mm[0-9]\|vld1q\|target_feature" native/lgj-abi/src` returns nothing outside the ONE sanctioned manifest-reporting cfg block in `abi.rs` +- [ ] `native/lgj-abi/src/kernels.rs` is the ONLY file in the crate importing from `ndarray` +- [ ] The independent scalar reference path (`lgj_plan_eval_scalar`) has zero `ndarray` dependency — plain Rust loops only, so it can actually falsify the SIMD path rather than test itself +- [ ] If a primitive was missing from `ndarray::simd`, it was added to `ndarray` under that repo's own W1a consumer contract (all backends + scalar + parity test), never worked around locally +- [ ] Doc comments naming a lane-width type (`U32x16`, etc.) match what the compiled backend actually dispatches — don't assert a width the build baseline doesn't guarantee + +## 4. Public Java API surface (`.claude/agents/java-surface-warden.md`) + +- [ ] Zero `java.lang.foreign` types (`MemorySegment`, `Arena`, `Linker`, `MethodHandle`, `FunctionDescriptor`, `MemoryLayout`, `VarHandle`) in any signature outside `internal/ffm/` +- [ ] No public `long`/numeric value that is secretly a pointer, lane id, or opcode without a domain-terms Javadoc sentence explaining what it means +- [ ] `View.where(...)` performs zero downcalls and allocates zero native masks — only a terminal operation executes +- [ ] Composition can only narrow a `View` — no code path (public or accidental) widens it +- [ ] No `Stream`/`List`/`Element[]`/`Iterator` over hydrated rows anywhere in the public surface — 64K rows must never become 64K Java objects (`.claude/knowledge/john-doe-migration-thesis.md`) +- [ ] Schema vocabulary (`Pattern.CLASS.gt(...)`, etc.) is typed per-field — a type mismatch must fail to COMPILE, not fail at runtime +- [ ] The schema vocabulary reads as something a code generator could mechanically emit — no hand-crafted fluent cleverness a generator couldn't produce + +## 5. Panama bridge correctness (`.claude/agents/panama-bridge-engineer.md`) + +- [ ] Every `MemoryLayout` in `Layouts.java` independently derives (via `layout.byteSize()`/`byteAlignment()`) the same size/alignment `docs/abi.md` documents for the matching `#[repr(C)]` Rust type — not copy-pasted from the doc as a bare constant +- [ ] The manifest cross-check in `Abi.java` compares Java's independently-derived layout sizes against the RUNTIME manifest from `lgj_abi_manifest()`, not against another Java constant +- [ ] `abi_major` mismatch fails load; `abi_minor` requires `>=` (both directions tested) +- [ ] Every downcall `MethodHandle` is resolved exactly once into a `static final` — never re-resolved inside a hot-path method +- [ ] Every `FunctionDescriptor` argument order/layout matches `docs/abi.md` §7 exactly — a mismatch here is silent data corruption, not a compile error +- [ ] `--enable-native-access` is documented in `java/README.md`'s exact command lines, and those commands were actually run against `/opt/jdks/jdk-26.0.2` + +## 6. Valhalla / benchmark claims (`.claude/agents/valhalla-lab-scientist.md`) + +- [ ] Every semantic value type claim states all three truths (semantic / stable-Java / Valhalla) — never Valhalla behavior presented alone as the whole story +- [ ] Every number in `bench/` has an exact reproduction command line (JDK path, flags, row count) +- [ ] Cost components are NOT conflated: bare downcall overhead, `MemorySegment` throughput, the Rust kernel, Vector API execution, fused-vs-unfused, View-construction cost are all reported separately +- [ ] JVM warmup is real (stated iteration counts, warmup-vs-measured split) — no cold-JVM-vs-warmed-native comparison +- [ ] A discovered Valhalla limitation produced a reproducer in `valhalla-lab/reproducers/`, not a workaround baked into the public API +- [ ] The mandatory N-objects vs N-values vs 1-lane experiment is present with real measured numbers, not the thesis's prediction asserted as fact +- [ ] JMH-vs-hand-rolled is labeled honestly — no hand-rolled loop presented as JMH-grade + +## 7. Toolchain / build discipline + +- [ ] No spawned agent ran `cargo` in any form (`.claude/knowledge/agent-cargo-hygiene.md`) — only the orchestrating main thread compiles/tests/lints +- [ ] `native/lgj-abi/.cargo/config.toml`'s `target-cpu` baseline matches what the artifact is actually meant to run on (v4/AVX-512 for this-host-only research use; v3/AVX2 if ever redistributed — see `TECH_DEBT.md` `TD-LGJ-V4-BASELINE-NOT-PORTABLE`) +- [ ] `/opt/jdks/jdk-26.0.2` used for production Java, `/opt/jdks/jdk-27` (JEP 401 EA) used ONLY for `valhalla-lab/` — no `--enable-preview`-compiled class ever reaches the production `java/` tree +- [ ] `df -h /` checked before and after any large parallel dispatch — target-dir residue is a known risk this session (`ISS-LGJ-TARGET-DIR-SIZE-WATCH`) + +## 8. PR hygiene + +- [ ] Commit message body explains the WHY, not just the WHAT +- [ ] PR body includes a Test Plan with checkboxes, and states which falsification gates from `.claude/plans/lgj-vertical-slice-v1.md` were actually run (not just "should pass") +- [ ] Cross-references to `EPIPHANIES.md`/`TECH_DEBT.md`/`ISSUES.md` entries where relevant +- [ ] Board files updated in the SAME PR as the code they describe (`LATEST_STATE.md`, `STATUS_BOARD.md`) — per this workspace's own board-hygiene convention, a PR that ships a type/plan/finding without updating the board is incomplete + +--- + +## How to use this checklist + +**As a worker writing code:** open this file alongside your scratchpad. +Tick boxes as you go. Boxes you can't tick — either fix the issue or +document the deferral in the PR body. + +**As main-thread reviewing a diff:** scan §1-§6 relevant to the touched +code. Items not addressed — request changes before merge. + +**As a future session:** this checklist is pre-registered from this +repo's own stated design risks, not yet from a real codex finding — +unlike medcare-rs's checklist, which was written AFTER two P1/P2 +catches. Add new items the moment a real review (codex or otherwise) +catches something that should have been pre-emptive, and update the +provenance table below. + +--- + +## Provenance + +| Source | Cross-reference | +|---|---| +| `docs/abi.md` §4 (ownership/handle safety) | this file §1 | +| `docs/abi.md` §1, `.claude/knowledge/no-c-ever.md` | this file §2 | +| `.claude/knowledge/simd-provenance.md`, `simd-lane-width-family.md` | this file §3 | +| `.claude/knowledge/john-doe-migration-thesis.md` | this file §4 | +| `docs/abi.md` §5, §7 | this file §5 | +| `.claude/knowledge/valhalla-three-truths-method.md` | this file §6 | +| `.claude/knowledge/agent-cargo-hygiene.md`, `EPIPHANIES.md` `E-LGJ-V4-DIVERGES-FROM-NDARRAY-DEFAULT-1` | this file §7 | +| medcare-rs `.claude/board/CODEX_REVIEW_CHECKLIST.md` (the pattern this file adapts) | structural template only — domain content is unrelated (no PHI/RBAC/crypto in this repo) | +| No codex findings yet — session 1 | update this row the first time one lands | diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md new file mode 100644 index 0000000..05eeae2 --- /dev/null +++ b/.claude/board/EPIPHANIES.md @@ -0,0 +1,141 @@ +# Epiphanies Log — Findings, Corrections, "Aha" Moments (APPEND-ONLY) + +> Prepend new entries at the top. Never edit a past entry except its +> `**Status:**`/`**Confidence:**` line. A correction gets its own new, +> dated entry that references the one it corrects — the storno rule. + +## 2026-08-17 — E-LGJ-CORE-SLICE-GREEN-DISABLE-VERIFIED-1 + +**Status:** FINDING. **Confidence:** High (measured, not asserted — every +number below came from an actual command run, not from an agent's report). + +The core vertical slice (`docs/abi.md` + `native/lgj-abi` + `java/`) is +real, compiles clean, and its safety claims are not merely tested but +**disable-verified**: `registry.rs::resolve`'s generation check +(`slot.generation != gen`) was deliberately short-circuited to +`if false && ...`, and the suite re-run. Exactly the two tests whose names +claim to guard this property — +`a_reused_slot_invalidates_the_old_handle` and +`fabricated_handles_are_rejected_not_dereferenced` — went red; all other +70 stayed green. This is the falsifiability discipline this workspace's +sibling repos (tesseract-rs, MedCare-rs) both independently arrived at — +"a test that passes on the happy path is not evidence" — applied to this +repo's very first disable-verification, and it passed the meta-test: the +tests were real, not decorative. + +**The one real rule violation the D-LGJ-AUDIT sweep found**: +`native/lgj-abi/src/kernels.rs::simd_popcount` called +`ndarray::hpc::bitwise::popcount_batch_u64` directly — the exact pattern +`E-LGJ-SIMD-PROVENANCE-1` exists to forbid. This is worth recording as a +finding in its own right: **the rule was stated correctly in the agent's +own doc comment one line above the violation** ("Reused, not +reimplemented — this already exists in `ndarray`...") — the agent +correctly identified WHERE the function lived but reached for the +internal path it happened to see in ndarray's source rather than the +re-export it was told to prefer. A soft "verify the exact path" brief +instruction was not sufficient; a mechanical grep gate is what actually +caught it. **Consequence for future briefs in this repo:** soft +instructions ("prefer X") get a mechanical audit regardless of how +clearly they were stated — this is now standing practice, not a +one-time fix. + +**Numbers on record**, so a future session can spot-check rather than +re-run everything from scratch: Rust `cargo test` 72/72; `ndarray` +`simd_int_ops` tests 41/41; `clippy -D warnings` and `fmt --check` both +clean; release build exports exactly the 14 symbols `docs/abi.md` §7 +names; Java `javac -Xlint:all` produces exactly 7 `[restricted]` +warnings, all in `internal/ffm/*` or one test deliberately exercising it; +`AllTests` 132/132 across 8 suites. Full breakdown on `STATUS_BOARD.md`'s +D-LGJ-B/C/D/E rows. + +## 2026-08-17 — E-LGJ-V4-DIVERGES-FROM-NDARRAY-DEFAULT-1 + +**Status:** FINDING. **Confidence:** High (operator-directed, mechanically +applied). + +`native/lgj-abi/.cargo/config.toml` pins `-Ctarget-cpu=x86-64-v4` +(AVX-512), **deliberately diverging** from `/home/user/ndarray`'s own +default of `-Ctarget-cpu=x86-64-v3` (AVX2). This is not a mistake to +reconcile later — the two repos have different distribution goals: ndarray +targets portable redistribution (v3 = Haswell-and-later, ~2013+), while +`lance-graph-java`'s native artifact in this phase is built and run on one +known host (verified AVX-512-capable this session) for a research vertical +slice, not shipped broadly. The `LgjAbiManifest::simd_backend` field is +what makes this divergence self-documenting at runtime rather than a +silent assumption — a consumer reads the manifest rather than assuming +which tier compiled. + +**Consequence:** any future portable-distribution build of `lgj-abi` must +override with `CARGO_BUILD_RUSTFLAGS='-Ctarget-cpu=x86-64-v3'` at build +time, per the comment left in `.cargo/config.toml`. Do not silently change +the file's *default* back to v3 without a stated reason — v4 is the +deliberate choice for this phase. + +## 2026-08-17 — E-LGJ-VALHALLA-ALREADY-MAINLINE-1 + +**Status:** FINDING. **Confidence:** High (measured by direct `diff -rq` +across three local checkouts + live compile/run verification). + +JEP 401 (Value Classes and Objects) has **already integrated into mainline +JDK** as a preview feature — it is not exclusive to a separate Valhalla +fork. Measured this session: `/home/user/valhalla` (`lworld` branch, +2026-07-30 HEAD) is **behind** mainline `/home/user/jdk` (2026-08-17 HEAD) +for value-class purposes; its own last relevant commit is literally +*"[lworld] things to delete from lworld just before integrating JEP-401."* +`/home/user/panama-foreign`'s `java.lang.foreign` package is **byte- +identical** to mainline (`diff -rq` exit 0). + +**Consequence:** this project needs exactly ONE production JDK (a GA +build, verified this session as `/opt/jdks/jdk-26.0.2`, where FFM is +final) and ONE Valhalla-preview JDK (the *official EA binary* +`27-jep401ea3+1-1` from `jdk.java.net/valhalla/`, not a source build of any +local fork). Building any of the three local OpenJDK source checkouts from +source for this project would have cost real time for zero benefit — the +binary already exists and was verified to work. See +`.claude/knowledge/jdk-toolchain-facts.md` for the full toolchain matrix. + +**Corollary, stated so a future session doesn't re-litigate it:** null- +restricted *type* syntax (`Foo!`) and specialized generics do **not** +exist in any checkout verified this session — only the internal +`@jdk.internal.vm.annotation.NullRestricted` field annotation plus +`jdk.internal.value.ValueClass` factories, gated behind `--add-exports`. +Do not assume `Foo!` syntax is available; it measurably is not, as of this +session's verification. + +## 2026-08-17 — E-LGJ-NO-C-EVER-1 + +**Status:** RULE (operator directive, locked). **Confidence:** N/A — +founding constraint, not a discovered fact. + +Operator, verbatim: *"There's no C ever. We reuse Panama project for a +rust only."* `extern "C"` names the SysV AMD64 psABI (a platform calling +convention), not the C language; `#[repr(C)]` names a platform aggregate +layout rule, not a C struct. Consequence: no `.h` file, no `cbindgen`, no +`jextract` (structurally inapplicable — its only input is a C header, and +none exists), no JNI, anywhere in this repo, ever. Full statement: +`.claude/knowledge/no-c-ever.md`. This is the single most load-bearing +rule in the project and the one most likely to be violated by habit +(reaching for `jextract` because "that's how Panama projects usually +work") rather than by disagreement — flagged here so it is checked +mechanically (`abi-membrane-warden`'s doctrine item 1) rather than trusted +to memory. + +## 2026-08-17 — E-LGJ-SIMD-PROVENANCE-1 + +**Status:** RULE (operator directive, locked). **Confidence:** N/A — +founding constraint. + +Operator, verbatim: *"Never use ndarray::hpc, trampoline to +ndarray::simd::* instead."* `ndarray::hpc::*` is ndarray's internal +implementation namespace; `ndarray::simd::*` is the sanctioned re-export +surface every consumer in the Ada stack is expected to use (ndarray's own +CLAUDE.md: *"Consumer writes `crate::simd::F32x16`. Period."*). This repo +is one more consumer of that invariant, not an exception. Full statement +and falsifier grep: `.claude/knowledge/simd-provenance.md`. This directive +arrived AFTER the first vertical-slice fan-out was already dispatched +(whose briefs mentioned the popcount primitive via its `hpc::bitwise` +path, with an instruction to "verify the exact path" and prefer the +`simd` re-export) — `STATUS_BOARD.md`'s `D-LGJ-AUDIT` entry exists +specifically to mechanically check the fan-out's actual output against +this rule rather than assume the earlier, softer brief language was +sufficient. diff --git a/.claude/board/INTEGRATION_PLANS.md b/.claude/board/INTEGRATION_PLANS.md new file mode 100644 index 0000000..4419559 --- /dev/null +++ b/.claude/board/INTEGRATION_PLANS.md @@ -0,0 +1,24 @@ +## 2026-08-17 — lgj-vertical-slice-v1 (PLAN; the first Panama×Valhalla×ndarray::simd proof) + +Plan: `.claude/plans/lgj-vertical-slice-v1.md`. Active plan index — this +board file is APPEND-ONLY (prepend new plan entries; a superseding plan +gets its own new entry, the old one's `**Status:**` line updates in place, +nothing else about it changes). + +**What it covers:** the full first vertical slice — the normative +`docs/abi.md` contract, the `ndarray::simd` primitives it needs, the Rust +ABI crate (`native/lgj-abi`), the Java FFM membrane + public semantic +facade (`java/`), the Valhalla three-truths lab (`valhalla-lab/`), and the +cost-separated benchmark harness (`bench/`). Maps 1:1 to the D-ids on +`STATUS_BOARD.md`. + +**Sequencing decision on record:** B (ndarray primitives) / C (Rust ABI) / +D (Java FFM+facade) were fanned out in **parallel** as three disjoint +trees, checked independently against the one frozen `docs/abi.md` contract +rather than sequentially against each other. F/G (Valhalla lab + bench) +were sequenced **after** D specifically so they could read the real +`View`/`Predicate` Java types instead of guessing their shape. + +**Status: ACTIVE.** No PR opened yet. See `STATUS_BOARD.md` for +per-deliverable status and `AGENT_LOG.md` for what each spawned agent +actually reported. diff --git a/.claude/board/ISSUES.md b/.claude/board/ISSUES.md new file mode 100644 index 0000000..8367db4 --- /dev/null +++ b/.claude/board/ISSUES.md @@ -0,0 +1,65 @@ +# Issues Log — Open + Resolved (double-entry, append-only) + +## ISS-LGJ-FANOUT-UNREVIEWED (2026-08-17) — PARTIALLY RESOLVED + +**Resolution for the core (Rust ABI + Java facade), same day:** all three +closing conditions this entry names ran for real. (1) D-LGJ-AUDIT ran, one +violation found and fixed (see `EPIPHANIES.md` +`E-LGJ-CORE-SLICE-GREEN-DISABLE-VERIFIED-1`). (2) The registry's safety +claims were disable-verified, not just read — see the same epiphany entry. +(3) Central `cargo test`/`clippy`/`fmt`/`build --release` and Java +`javac`/`AllTests` all ran orchestrator-side and are green. Folded into +`LATEST_STATE.md`'s 2026-08-17 entry and `STATUS_BOARD.md`'s D-LGJ-B/C/D/E/H +rows. + +**Still open for the Lab phase (D-LGJ-F/G):** `valhalla-lab/` and `bench/` +had not produced source at the time PR #1 was cut — real JMH jars were +fetched but no `.java` written yet. This half of the original concern +remains OPEN and will close when the Lab agent's output gets the same +audit + central-verification treatment as the core got here, in its own +follow-up PR. + +**Original text, kept for context:** + +The 4-agent vertical-slice fan-out (`wf_23ad2110-b1e`: ndarray primitives, +Rust ABI crate, Java FFM+facade, Valhalla lab+bench) was still running as +of this board's initial population. Nothing it produces should be cited as +"shipped" or "done" until: (1) the mechanical audit for the `ndarray::hpc` +ban and the no-C rule runs clean (`D-LGJ-AUDIT` on `STATUS_BOARD.md`), (2) +`handle-lifecycle-auditor` has adversarially exercised the registry's +safety claims per its own card (not just read the design doc), (3) a +central `cargo build`/`test`/`clippy` run by the orchestrator — not by any +spawned agent, per `TECH_DEBT.md`'s cargo-hygiene entry — actually passes. +Closes when all three have run and their results are folded into +`LATEST_STATE.md`. + +## ISS-LGJ-TARGET-DIR-SIZE-WATCH (2026-08-17) — OPEN + +`/home/user/ndarray/target` measured 2.7 GB and +`/home/user/lance-graph-java/target` measured 602 MB partway through the +first fan-out, from agents that were (at the time) permitted to run cargo +directly. Per `.claude/knowledge/agent-cargo-hygiene.md`, no further agent +gets that permission — but the two `target/` dirs from THIS wave already +exist and should be checked against disk headroom (`df -h /` was 43% used +at last check, 22G free) before any further large parallel work is +dispatched. Not urgent at last measurement; filed so it's watched rather +than rediscovered as a surprise "no space left on device" failure. + +## ISS-LGJ-DEV-BRANCH-STILL-UNCOMMITTED (2026-08-17) — OPEN + +`claude/lance-graph-java-panama-valhalla-sus9w8` (the designated dev +branch per the mission's cross-repo branch instructions) has zero commits +as of this board's initial population — everything from `docs/abi.md` +through the `.claude/` ensemble through the fan-out's in-progress output +exists only in the working tree. `main` was separately bootstrapped (see +`LATEST_STATE.md`) with a minimal README+`.gitignore` commit specifically +so it wouldn't be blocked on the dev branch's review status. The dev +branch's first commit should happen once the fan-out is reviewed +(`ISS-LGJ-FANOUT-UNREVIEWED`) — committing unreviewed, potentially rule- +violating code first and fixing it in a second commit is avoidable by +sequencing the audit first. + +**RESOLVED same day.** Audit ran first, as planned; the one real +violation was fixed BEFORE this commit rather than after. First commit on +the dev branch lands in the same action as this board update, containing +already-audited, already-green code. diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md new file mode 100644 index 0000000..06b6fe5 --- /dev/null +++ b/.claude/board/LATEST_STATE.md @@ -0,0 +1,148 @@ +## 2026-08-17 — D-LGJ-AUDIT complete, core vertical slice VERIFIED GREEN, PR #1 opened + +### Current Contract Inventory — the vertical slice is real and green + +- **`D-LGJ-AUDIT` ran.** Mechanical grep sweep against `no-c-ever.md` and + `simd-provenance.md` found exactly **one** real violation: + `native/lgj-abi/src/kernels.rs::simd_popcount` called + `ndarray::hpc::bitwise::popcount_batch_u64` directly instead of the + sanctioned `ndarray::simd::popcount_batch_u64` re-export. Fixed in place + (same function, same behavior, corrected import path + doc comment). + Everything else the grep matched (`abi.rs`'s one sanctioned + `target_feature` cfg block for manifest self-reporting; every + `cbindgen`/`jextract` hit in doc comments, README, and test assertion + strings) was confirmed to be exactly what it should be — prose + explaining the rule, or the one deliberate exception the rule itself + names. `kernels.rs` confirmed the sole `ndarray`-importing file. +- **Rust (`native/lgj-abi`) — orchestrator-run, centrally, per + `agent-cargo-hygiene.md`:** `cargo test` → **72/72 passed**. `cargo + clippy --all-targets -- -D warnings` → clean. `cargo fmt --check` → + clean. `cargo build --release` → `liblgj_abi.so`, **exactly the 14 + symbols** `docs/abi.md` §7 specifies, verified via `nm -D`. +- **Disable-verified, not just green** — the registry's core safety + check (`registry.rs::resolve`'s `slot.generation != gen` comparison) + was deliberately short-circuited to `if false && ...` and the suite + re-run: exactly the two tests that should catch it + (`a_reused_slot_invalidates_the_old_handle`, + `fabricated_handles_are_rejected_not_dereferenced`) went **red**, all + 70 others stayed green. Restored, re-verified 72/72. This is the + `handle-lifecycle-auditor` discipline actually applied, not merely + read from the design doc. +- **Java (`java/`) — orchestrator-compiled and run against the real + `.so`, JDK 26 GA:** `javac -Xlint:all` → 7 `[restricted]` warnings, all + in `internal/ffm/*` or a test deliberately exercising the restricted + API — the exact set the design predicts, nothing outside it. + `AllTests` → **132/132 checks passed, 0 failed**, across + `ApiSurfaceTest` (reflection-enforced: zero FFM types in any public + signature), `AbiContractTest` (manifest cross-check genuinely rejects + a wrong library), `SmokeTest`, `FixtureParityTest` (30 checks, Java + independently recomputes expected counts from the transcribed + SplitMix64 generator), `FusionParityTest` (fused/unfused/scalar agree + bit-for-bit across 6 row-count shapes incl. 1/63/64/65), + `LazinessTest` (empirically proves: building a 16-condition chain + costs 0 crossings; a terminal op costs exactly 1, independent of row + count up to 1,000,000 — the thesis's central claim, measured, not + asserted), `NarrowingTest`, `LifetimeTest` (23 checks: use-after-close, + double-close, child-outlives-parent, parent-outlives-child, all as + clean exceptions, never a crash). +- **`.gitignore` added** (target dirs, `.class`, downloaded jars/tarballs) + before this commit — `bench/lib/*.jar` (real JMH, fetched by the Lab + agent) is excluded from version control by design. + +### PR #1 scope — the core slice, Lab phase deliberately deferred + +`claude/lance-graph-java-panama-valhalla-sus9w8` → `main`. Ships: the +frozen `docs/abi.md` contract, the `.claude/` ensemble+board, the 5 +`ndarray::simd` primitives, `native/lgj-abi`, and `java/` +(D-LGJ-A/ABI/ENS/B/C/D/E). **Does NOT ship** `valhalla-lab/`/`bench/` +(D-LGJ-F/G) — still in flight at commit time (JMH jars fetched, no +source yet) — nor Phase I docs, which are sequenced to synthesize the +Lab results. Both are tracked as open `STATUS_BOARD.md` rows, not +silently dropped. Given the core slice is independently complete, +fully falsified, and green, shipping it now rather than blocking on a +slower background phase is the honest call — the alternative is +holding fully-verified, working code in an uncommitted working tree +for no safety reason. + +### Toolchains pinned this session (superseded lines below kept for +### history; nothing here changed) + +--- + +## 2026-08-17 — session 1: contract frozen, ensemble seeded, vertical-slice fan-out dispatched + +### Current Contract Inventory — 1 new normative doc, 0 shipped Rust/Java code yet (in flight) + +- **`docs/abi.md`** — the normative Rust↔Java ABI contract. 14 `extern "C"` + symbols (`lgj_abi_manifest`, `lgj_pattern_open`, `lgj_close`, + `lgj_resource_info`, `lgj_lane_describe`, `lgj_mask_create/describe/and/or/count`, + `lgj_op_eq_u32`, `lgj_op_gt_i32`, `lgj_plan_eval`, `lgj_plan_eval_scalar`, + `lgj_reduce_sum_i32`), 4 `#[repr(C)]` types (`LgjLaneDesc` 56B, + `LgjResourceInfo` 32B, `LgjOpDesc` 24B, `LgjAbiManifest`), 13 status + codes, a generation-checked `u64` handle (`generation:u32 << 32 | index:u32`). + Written BEFORE either implementation side, so Rust and Java are being built + independently against it — no side has authority to redefine it unilaterally + without a doc update in the same PR (per `abi-membrane-warden`'s doctrine). +- **No C anywhere, by design, not by oversight.** `extern "C"` = SysV AMD64 + psABI, not the C language. No `.h`, no `cbindgen`, no `jextract`, no JNI. + See `.claude/knowledge/no-c-ever.md`. +- **`.claude/agents/` (6 cards) + `.claude/knowledge/` (6 docs) + this + board.** Sized to the repo's actual seams, not padded to lance-graph's + 26-repo scale — see `.claude/agents/BOOT.md`. + +### Toolchains pinned this session (verified by direct execution, not assumed) + +- **Rust: 1.97.1 stable**, installed to match `ndarray`/`lance-graph`'s pin + (operator-directed switch from an earlier draft's 1.94 target). +- **Production JDK: `/opt/jdks/jdk-26.0.2`** (GA, downloaded this session). + FFM (`java.lang.foreign`) is FINAL here — no `--enable-preview`, only + `--enable-native-access`. Confirmed live: `Arena.ofConfined()` + + `MemorySegment` set/get + `Linker.nativeLinker()` → `SysVx64Linker`, zero + flags beyond native-access. +- **Valhalla lab JDK: `/opt/jdks/jdk-27`** (`27-jep401ea3+1-1`, the OFFICIAL + JEP 401 early-access binary from `jdk.java.net/valhalla/` — not a source + build). Confirmed live: `value class`/`value record` compile and run with + `--enable-preview --release 27`; `Class.isValue()` → `true`. +- **The three local OpenJDK source forks + (`/home/user/jdk`, `/home/user/valhalla`, `/home/user/panama-foreign`) + are NOT used for building anything** — an archaeology pass found Valhalla's + `lworld` fork measurably BEHIND mainline `/home/user/jdk` for value-class + purposes (its own last commit: "things to delete from lworld just before + integrating JEP-401"), and `panama-foreign`'s `java.lang.foreign` is + byte-identical to mainline. See `.claude/knowledge/jdk-toolchain-facts.md`. +- Host CPU has AVX-512 (`avx512f/bw/cd/dq/ifma/vbmi/vl`) — both + `ndarray::simd`'s AVX2 (v3, default build baseline) and AVX-512 (v4) tiers + are exercisable in this environment. + +### Active branches + +Single branch: `claude/lance-graph-java-panama-valhalla-sus9w8` across all +13 in-scope repos per the mission's cross-repo branch instructions. Nothing +committed yet in `lance-graph-java` — this entry describes working-tree +state, not a merged PR (there is no PR_ARC_INVENTORY entry yet; see that +file for why). + +### In flight (dispatched, not yet landed — do not cite as done) + +A 4-agent fan-out (`lgj-vertical-slice-wf_23ad2110-b1e`) is running: +1. Adds `eq_u32_to_mask`/`gt_i32_to_mask`/`mask_and`/`mask_or` + (`_assign` variants)/`masked_sum_i32` to `AdaWorldAPI/ndarray` under its + own W1a consumer contract, re-exported via `ndarray::simd`. +2. Builds `native/lgj-abi` (the Rust ABI crate) against `docs/abi.md`. +3. Builds `java/` (FFM membrane + public facade) against `docs/abi.md`. +4. (sequenced after 3) Builds `valhalla-lab/` + `bench/` against the real + Java types from step 3. + +**None of steps 1–4 has been reviewed yet.** In particular the +`ndarray::hpc` import ban (`.claude/knowledge/simd-provenance.md`) and the +no-C rule have NOT been mechanically audited against the agents' actual +output — that audit is the very next action once the workflow completes. +Treat `native/lgj-abi/{Cargo.toml,Cargo.lock,rust-toolchain.toml,src/}` +existing on disk as "in progress," not "shipped." + +### Queued work (not yet dispatched) + +Phase H (falsification-test review by `handle-lifecycle-auditor`), Phase I +(docs: `architecture.md`/`panama.md`/`valhalla-lab.md`/`execution-boundary.md`), +and the post-fan-out mechanical audit for `ndarray::hpc`/C-artifact +violations across all four agents' output. diff --git a/.claude/board/PR_ARC_INVENTORY.md b/.claude/board/PR_ARC_INVENTORY.md new file mode 100644 index 0000000..3f5a879 --- /dev/null +++ b/.claude/board/PR_ARC_INVENTORY.md @@ -0,0 +1,16 @@ +# PR Arc Inventory — per-PR Added / Locked / Deferred / Docs / Confidence +# (reverse chronological, APPEND-ONLY; only the Confidence line is +# updatable in place — corrections append as new dated lines; reversals +# get their own PR entry) + +_No PR has been opened against this repository yet — session 1, 2026-08-17._ + +The first entry in this file will be written when the first PR against +`lance-graph-java` merges (expected: the vertical slice on +`claude/lance-graph-java-panama-valhalla-sus9w8` → `main`, once +`ISS-LGJ-FANOUT-UNREVIEWED` closes). Until then, ground truth for +in-progress work lives on `LATEST_STATE.md` (current contract inventory), +`STATUS_BOARD.md` (per-D-id status), and `AGENT_LOG.md` (what actually +happened) — this file stays empty rather than backfilled with a +pre-registration entry that would misrepresent something as merged before +it is. diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md new file mode 100644 index 0000000..112599f --- /dev/null +++ b/.claude/board/STATUS_BOARD.md @@ -0,0 +1,30 @@ +## lgj-vertical-slice-v1 — the first Panama×Valhalla×ndarray::simd proof (PRE-REGISTERED 2026-08-17) + +Plan: `.claude/plans/lgj-vertical-slice-v1.md`. Every D-id below maps 1:1 to +a phase in that plan and to a Phase-tracking task in this session's task +list. + +| D-id | Deliverable | Status | Feeds | +|---|---|---|---| +| D-LGJ-A | Archaeology: ndarray SIMD/mask surface, lance-graph ClassView/WideFieldMask/SoaEnvelope/ownership, current Panama+Valhalla state | **DONE 2026-08-17** — 3 parallel Explore/Opus agents, findings folded into `docs/abi.md` and the `.claude/knowledge/*` docs | everything downstream | +| D-LGJ-ABI | `docs/abi.md` — the normative Rust↔Java contract | **DONE 2026-08-17** — 14 symbols, 4 `#[repr(C)]` types, 13 status codes, generation-checked handle | B, C, D, E | +| D-LGJ-ENS | `.claude/agents` (6 cards) + `.claude/knowledge` (6 docs) + `.claude/board` | **DONE 2026-08-17** — this board | every future review pass | +| D-LGJ-B | `ndarray::simd` primitives: `eq_u32_to_mask`, `gt_i32_to_mask`, `mask_and`/`mask_or`(`_assign`), `masked_sum_i32` | **DONE 2026-08-17** — `ndarray/src/simd_int_ops.rs`; `cargo test --lib simd_int_ops` **41/41** incl. signed-vs-bitwise `gt_i32`, tail-bit-zeroing, `u32::MAX` edge cases | C's kernels.rs | +| D-LGJ-C | `native/lgj-abi` — manifest, generation-checked registry, generic SoA fixture, kernels, `extern "C"` surface | **DONE 2026-08-17** — `cargo test` **72/72**, `clippy -D warnings` clean, `fmt --check` clean, release build → 14/14 symbols verified via `nm -D`. **Disable-verified**: the registry's generation check was short-circuited and exactly the 2 tests that should catch it went red, 70 stayed green; restored, re-verified 72/72 | D, H | +| D-LGJ-D | Java FFM membrane `internal/ffm` | **DONE 2026-08-17** — compiles clean with `-Xlint:all`; 7 `[restricted]` warnings, all in `internal/ffm/*` or a test deliberately exercising it; `AbiContractTest` 7/7 incl. proving the manifest cross-check genuinely rejects a wrong `.so` (`libz.so.1` loads but is refused for exporting no `lgj_abi_manifest`) | E | +| D-LGJ-E | Java public facade (`NativePattern`/`View`/`Predicate`/`Pattern`/`Mask`) | **DONE 2026-08-17** — `AllTests` **132/132**: `ApiSurfaceTest` (reflection-enforced zero-FFM-leakage), `SmokeTest` 14/14, `FixtureParityTest` 30/30 (Java independently recomputes expected counts from the transcribed generator), `FusionParityTest` 31/31 (fused/unfused/scalar bit-identical across 6 row-count shapes), `LazinessTest` 8/8 (empirically: 0 crossings to build a 16-condition chain, exactly 1 to evaluate it, independent of rows up to 1,000,000 — the thesis's central claim, measured), `NarrowingTest` 16/16, `LifetimeTest` 23/23 | F, G | +| D-LGJ-F | Valhalla lab — three-truths method on the small semantic value vocabulary | **In flight** — sequenced after E, now reading the real Java types; deferred to a follow-up PR, not blocking PR #1 | I | +| D-LGJ-G | Java Vector API comparative bench vs Panama→`ndarray::simd` | **In flight** — real JMH jars fetched (`jmh-core`/`jmh-generator-annprocess`/`jopt-simple`/`commons-math3`) to `bench/lib/` (gitignored); no bench source written yet; deferred to the same follow-up PR as F | I | +| D-LGJ-H | Falsification: handle lifecycle (adversarial), SIMD/scalar parity, Java/native parity | **DONE 2026-08-17 for the Rust+Java core** — see D-LGJ-C's disable-verification and D-LGJ-E's `FusionParityTest`/`LifetimeTest`. Re-opens for F/G once the Lab lands | I | +| D-LGJ-I | Docs: `architecture.md`, `panama.md`, `valhalla-lab.md`, `execution-boundary.md` | **Queued** — gated on F/G landing (the docs synthesize Lab results, not just the core) | — | +| D-LGJ-AUDIT | Mechanical post-fan-out audit: `grep` for `ndarray::hpc` imports, any `.h`/`cbindgen`/`jextract` artifact, any FFM type leaking into public Java API | **DONE 2026-08-17** — 1 real violation found (`kernels.rs::simd_popcount` used the internal `ndarray::hpc::bitwise` path), fixed in place; everything else confirmed to be the one sanctioned exception or explanatory prose | closed D-LGJ-C/D/E for the core | + +### Reading this table + +**"In flight" means dispatched to a background agent, not reviewed and not +verified. "DONE" means a disable-verified test or a mechanical grep gate +actually ran** — per this workspace's own falsifiability discipline. D-LGJ-F +and D-LGJ-G are the only rows still open; they are deliberately NOT blocking +PR #1 (the core slice is independently complete and green) and will land as +their own PR once the Lab agent finishes and is reviewed with the same +rigor. diff --git a/.claude/board/TECH_DEBT.md b/.claude/board/TECH_DEBT.md new file mode 100644 index 0000000..723361c --- /dev/null +++ b/.claude/board/TECH_DEBT.md @@ -0,0 +1,54 @@ +# Technical Debt Log — Open + Paid (double-entry, append-only) + +## TD-LGJ-REGISTRY-CONCURRENCY-UNMEASURED (2026-08-17) — OPEN + +`docs/abi.md` §4's registry design (short registry read-lock → clone `Arc` +→ drop registry lock → lock the entry) is a **stated design intent**, not a +measured property. The POC's Java layer is single-threaded, so nothing in +this vertical slice actually exercises concurrent access. If a future slice +adds concurrent Java callers (e.g. a parallel `View` evaluation), the +registry's actual behavior under contention (lock-hold duration, +starvation, whether the "distinct resources don't serialize" claim holds +under real load) needs to be benchmarked before it's trusted, not merely +re-read from the doc comment. Pay this down when concurrent access is +first actually needed — not before, per the "no speculative future-proofing" +house style. + +## TD-LGJ-JMH-AVAILABILITY-UNKNOWN (2026-08-17) — OPEN + +`bench/` was briefed to attempt fetching real JMH jars from Maven Central +(`repo1.maven.org`, confirmed reachable via `curl --noproxy '*'` this +session) and fall back to a hand-rolled, honestly-labeled harness if that +doesn't work cleanly with plain `javac`/`java -cp` (no Maven/Gradle +dependency resolution available for this no-build-system project). Which +path was actually taken is unknown until the fan-out's Lab-phase report +lands. If JMH could not be wired, `TECH_DEBT` should gain a follow-up +entry noting the specific blocker (classpath assembly by hand for JMH's +annotation processor is real friction) so a future session doesn't +re-attempt the same dead end without the context. + +## TD-LGJ-V4-BASELINE-NOT-PORTABLE (2026-08-17) — OPEN, INTENTIONAL + +`native/lgj-abi` compiles with `-Ctarget-cpu=x86-64-v4` (AVX-512) as its +DEFAULT baseline (see `EPIPHANIES.md` `E-LGJ-V4-DIVERGES-FROM-NDARRAY-DEFAULT-1` +for the reasoning). This means the built `.so` will SIGILL on any host +without AVX-512 — acceptable for a research vertical slice on one known +host, unacceptable for any future redistribution. If this project ever +ships a `.so` to unknown hardware, the build must switch to v3 (or add +runtime-dispatch, which `ndarray` already supports via its +`runtime-dispatch` feature — see the ndarray archaeology findings in +`AGENT_LOG.md`) before that ships. Filed as debt now specifically so it +isn't silently forgotten once the v4 default stops being obviously +research-only. + +## TD-LGJ-FAN-OUT-PREDATED-TWO-RULES (2026-08-17) — OPEN, gates D-LGJ-AUDIT + +The first vertical-slice fan-out (`wf_23ad2110-b1e`) was dispatched before +`E-LGJ-NO-C-EVER-1` and `E-LGJ-SIMD-PROVENANCE-1` were stated as explicit +operator rules (though both were already implicit in `docs/abi.md`'s own +text, written before dispatch). This is not assumed to be a violation — +it's an unaudited state. `STATUS_BOARD.md`'s `D-LGJ-AUDIT` is the paydown +step: a mechanical grep sweep of the fan-out's actual output the moment it +completes. This entry closes only when that audit runs and either finds +nothing (debt paid, entry closes clean) or finds violations (debt paid via +the fix, entry closes noting what was corrected). diff --git a/.claude/knowledge/abi-ownership-and-handles.md b/.claude/knowledge/abi-ownership-and-handles.md new file mode 100644 index 0000000..460617b --- /dev/null +++ b/.claude/knowledge/abi-ownership-and-handles.md @@ -0,0 +1,68 @@ +# Ownership Across the Membrane — The Generation-Checked Handle + +> READ BY: handle-lifecycle-auditor, panama-bridge-engineer, and anyone +> touching native/lgj-abi/src/registry.rs or java/internal/ffm/* + +## Status: FINDING (the design's answer to "who owns this memory") + +## The Problem This Solves + +Inside Rust, `&self` borrows make a view-outliving-its-owner a *compile +error*. Panama has no borrow checker — a Java `MemorySegment` obtained from a +now-freed Rust allocation is a live footgun unless the membrane itself makes +use-after-free structurally impossible. + +## The Design + +A handle is **not a pointer**. It is an opaque `u64`: + +``` + 63 32 31 0 +┌────────────────────────┬────────────────────────┐ +│ generation │ index │ +└────────────────────────┴────────────────────────┘ +``` + +- `index` selects a slot in a Rust-side registry (`RwLock>`). +- `generation` is bumped every time a slot is freed. +- A lookup validates `generation` against the slot's *current* generation. + +Consequence table (all four are correctness properties this repo's tests +must falsify, not just assume — see `docs/abi.md` §4 and the Phase H +falsification tasks): + +| Java does | Rust returns | NOT what happens | +|---|---|---| +| uses a live handle | success | — | +| uses it after `lgj_close` | `INVALID_HANDLE` | dereference of freed memory | +| closes twice | `INVALID_HANDLE` on 2nd | double-free | +| fabricates a handle | `INVALID_HANDLE` | arbitrary memory read | +| operates on a mask whose parent closed | `PARENT_CLOSED` | dangling parent access | + +## Why This Beats a Naive `Box::into_raw` Handle + +A raw pointer handle has no way to detect staleness — the memory it points at +may have been freed *and reallocated* for something else, so a +use-after-free doesn't even reliably crash; it silently corrupts. The +generation counter turns "is this handle still meaningful" into an O(1) +integer comparison that cannot be fooled by reallocation, because the slot +index is reused but the generation is not (until it wraps, which at `u32` +range is not a near-term concern for a research POC). + +## Concurrency Shape + +Registry lock is held only long enough to resolve `index → Arc` +and clone the `Arc`; it is dropped before the entry's own inner lock is +taken. So two calls against *different* resources do not serialize on each +other — only `open`/`close` contend on the registry itself. This has not +been benchmarked under real contention; the POC's Java layer is +single-threaded, so this is a stated design intent, not yet a measured +property. + +## Cross-reference + +This is the Rust-side half of `docs/abi.md` §4. The Java-side half is: model +the `Arena`/segment lifetime as nested *inside* the resource's own lifetime, +so Java's own bookkeeping fails fast on a use-after-close even before the +call reaches Rust (belt-and-braces, not a substitute for the Rust-side +check). diff --git a/.claude/knowledge/agent-cargo-hygiene.md b/.claude/knowledge/agent-cargo-hygiene.md new file mode 100644 index 0000000..c849d7f --- /dev/null +++ b/.claude/knowledge/agent-cargo-hygiene.md @@ -0,0 +1,65 @@ +# Agent Cargo Hygiene — one target dir, no N× build residue + +> READ BY: every agent spawn touching native/lgj-abi or ndarray; MANDATORY +> in every worker brief before it runs, per the operator directive below + +## Status: RULE (operator directive, 2026-08-17: "Block agents from using +## cargo to avoid target residue overflow") + +## The problem, measured + +`/home/user/ndarray/target` alone is already **2.7 GB** and +`/home/user/lance-graph-java/target` **602 MB**, from a single 4-agent fan-out +that ran this session. Each spawned agent that runs its own `cargo +build`/`check`/`test` against a fresh or divergent `target/` state multiplies +that residue — the exact failure mode `lance-graph`'s own +`.claude/rules/agent-cargo-hygiene.md` names: N agents × N target dirs, cold +compiles competing for the same cores, disk exhaustion. + +## The rule + +- **The orchestrating main thread (Opus) runs cargo freely** and is the ONLY + actor that compiles/lints/tests. One build, not N. +- **Spawned agents do NOT run `cargo build`/`check`/`test`/`clippy` at all**, + full stop — no "targeted `cargo test` is fine" carve-out here (unlike + `lance-graph`'s own version of this rule, which allows a scoped test + against the shared `target/`). This repo is small enough, and young enough, + that the operator's directive is read as the stricter reading: agents edit + and reason; the orchestrator verifies. +- **No `isolation: "worktree"`** on any agent spawn touching `native/lgj-abi` + or `/home/user/ndarray` — a worktree mints its own `target/` and is exactly + the multiplication this rule exists to prevent. +- **Every worker brief for this repo's Rust code MUST state explicitly**: + *"Do not run cargo in any form. Write and reason about the code; the + orchestrator compiles, tests, and lints centrally after your edits land."* + +## What this retroactively flags + +The first vertical-slice fan-out (`wf_23ad2110-b1e`, dispatched before this +rule was stated) told its Rust-touching agents they **may** run cargo, +scoped to `CARGO_TARGET_DIR=/home/user/lance-graph-java/target`. That +predates this directive and is not itself a violation of a rule that didn't +exist yet — but it is exactly why the rule now exists, and it is why +`STATUS_BOARD.md`'s `D-LGJ-AUDIT` entry includes checking `target/` size +after that wave lands, before dispatching anything further. No agent +spawned AFTER this doc exists gets cargo permission again. + +## Why not a settings.json deny instead + +A blanket `Bash` permission deny on `cargo build`/`test`/etc. would also +block the orchestrator's own centralized verification — the harness has no +clean mechanism to say "deny for spawned subagents, allow for the top-level +session" short of per-agent-type tool restriction, and `Bash` cannot be +scoped to "all commands except cargo" that granularly without also risking +blocking legitimate orchestrator work. So enforcement here is the same +mechanism `lance-graph`'s own hygiene rule uses successfully: **explicit, +mandatory brief language**, checked by whoever reads the agent's returned +report for a cargo invocation that shouldn't be there. + +## Falsifier + +Before trusting any agent report that claims code "compiles" or "tests +pass," check the report for evidence it actually ran cargo — if it did, and +this doc predates its spawn, that is a hygiene violation to flag, not a +bonus. The orchestrator re-verifies centrally regardless of what the agent +claims. diff --git a/.claude/knowledge/jdk-toolchain-facts.md b/.claude/knowledge/jdk-toolchain-facts.md new file mode 100644 index 0000000..a3a71a8 --- /dev/null +++ b/.claude/knowledge/jdk-toolchain-facts.md @@ -0,0 +1,65 @@ +# Pinned Toolchain Facts — Verified, Not Assumed + +> READ BY: every agent before invoking javac/java, before writing a build +> script, before making any claim about FFM/Vector-API/Valhalla preview +> status. Facts below were verified by direct execution in this +> environment, not recalled from training data — re-verify before trusting +> if the environment changes. + +## Status: FINDING (measured directly, dated) + +## Rust + +- Toolchain: **1.97.1** (stable), installed via `rustup toolchain install + 1.97.1`. Matches the pin in `ndarray/rust-toolchain.toml` and + `lance-graph/rust-toolchain.toml`. +- Host CPU has AVX-512 (`avx512f/bw/cd/dq/ifma/vbmi/vl`) — both the AVX2 + (v3) and AVX-512 (v4) backends of `ndarray::simd` are exercisable here. +- `ndarray/.cargo/config.toml` pins `-Ctarget-cpu=x86-64-v3` as the default + build baseline (SIGILL trap if omitted downstream — see `abi.md` §"SIGILL + trap"). Any crate depending on `ndarray`'s AVX2 backend must mirror this. + +## JDKs available locally + +| Path | Version | FFM (`java.lang.foreign`) | JEP 401 value classes | Vector API | +|---|---|---|---|---| +| `/usr/bin/java` (system) | OpenJDK 21.0.10 | **preview** — needs `--enable-preview` | not present | incubating, needs `--add-modules jdk.incubator.vector` | +| `/opt/jdks/jdk-26.0.2` | OpenJDK 26.0.2 GA | **FINAL** — no preview flag, only `--enable-native-access=` for restricted calls | not present (mainline, not the Valhalla fork) | incubating | +| `/opt/jdks/jdk-27` | `27-jep401ea3+1-1`, official JEP 401 EA build from `jdk.java.net/valhalla/` | final (this vintage postdates FFM finalization) | **works** — `value class`, `value record`, `Class.isValue()` verified `true`, with `--enable-preview --release 27` | incubating | + +**Verified by direct execution in this session** (not by reading docs): +`Arena.ofConfined()` + `MemorySegment` read/write + `Linker.nativeLinker()` +(`SysVx64Linker`) all work on `/opt/jdks/jdk-26.0.2` with zero preview flags. +`value class LaneId { ... }` and `value record RowRange(...)` both compiled +and ran on `/opt/jdks/jdk-27` with `--enable-preview`, and +`LaneId.class.isValue()` printed `true`. + +## Decision this locks in + +- **Production path (`java/`) targets `/opt/jdks/jdk-26.0.2`.** No preview + flags in the shipped build. This is a real, deliberate strength of the + design: the FFM membrane runs on a *shipped GA JDK*, not an experimental + one. +- **Valhalla lab (`valhalla-lab/`) targets `/opt/jdks/jdk-27`.** Same source + shape, compiled twice (once as `record`, once as `value record`), so the + A/B is genuinely apples-to-apples. +- **The full Valhalla source checkout at `/home/user/valhalla` is NOT + needed** for this project. It was independently confirmed (by a separate + archaeology pass) to be *behind* mainline `/home/user/jdk` for + value-class purposes — its last relevant commit is literally "things to + delete from lworld just before integrating JEP-401." The official EA + *binary* download supersedes building it from source. Do not spend time + building `/home/user/valhalla` or `/home/user/panama-foreign` from + source for this project. +- **`--enable-preview` is a classfile-poisoning flag** (classfile minor + version becomes preview-marked): every `.class` compiled with it can only + run with `--enable-preview` set, and this contaminates transitively. Keep + the Valhalla-flavored sources physically separate from the production + `java/` tree for exactly this reason — never let a preview-compiled class + leak into the path a JDK-26 consumer loads. + +## Falsifier + +Any doc or code comment asserting "value classes are final in JDK 28" or +"Vector API is finalized" without re-verifying against a real build is +wrong until re-checked — both were incubating/preview at last verification. diff --git a/.claude/knowledge/john-doe-migration-thesis.md b/.claude/knowledge/john-doe-migration-thesis.md new file mode 100644 index 0000000..47b2613 --- /dev/null +++ b/.claude/knowledge/john-doe-migration-thesis.md @@ -0,0 +1,103 @@ +# The John Doe Migration Thesis — What This Project Is Actually For + +> READ BY: every agent, before writing any public Java API, README prose, +> or architecture doc in this repo. This is the thesis every other decision +> in this repo serves. + +## Status: FINDING (operator-stated founding thesis, locked) + +## The Center Is Not Panama, Valhalla, SIMD, or lance-graph Individually + +The user's own reframing, which supersedes any earlier "FFI showcase" framing +of this project: + +> Any ordinary Java developer can take yesterday's object-heavy Java, receive +> a generated/schema-fed API that still feels like Java, and suddenly execute +> against a zero-copy columnar graph substrate without learning Rust, SoA, +> SIMD, FFM, Lance, or graph-engine internals. + +And the killer consequence: + +> **64,000 logical "things being considered" no longer require 64,000 Java +> objects.** + +## The Migration Story, Concretely + +Old Java (the mental model the machine pays for): + +```java +List berliners = new ArrayList<>(); +for (Person person : people) { + if (person.getAge() > 65 && person.getCity().equals("Berlin")) { + berliners.add(person); + } +} +``` + +The mental model is `Person, Person, Person, Person, ...` — 64,000 headers, +64,000 references, 64,000 allocations, 64,000 GC-visible identities. + +New Java (embarrassingly familiar surface, per the thesis): + +```java +var berliners = people.where(Person.AGE.gt(65)) + .where(Person.CITY.eq("Berlin")); +``` + +Underneath: `Person.AGE.gt(65)` and `Person.CITY.eq(...)` are **tiny typed +predicates**, combined into a mask plan, evaluated in ONE bulk crossing over +1 native lane set + 1 packed mask. There never needed to be 64,000 `Person` +objects. + +## Why the Schema Vocabulary Is the Accessibility Layer + +The developer must never see `lane 17`, `predicate 0x37`, `mask word 491`. +They see `Person.AGE`, `Person.CITY` — generated (or, in this first slice, +hand-written *as if* generated — see `Pattern.java`) vocabulary with IDE +autocomplete and compile-time type safety. The schema spoon-feeds the safe +surface; the engine gets SoA; the trade is unusually good. + +## The 64K-Thought Generalization + +The same shape applies beyond "rows of a table": 65,536 hypotheses, +candidate diagnoses, graph relationships, reasoning states. Traditional OO +instinct: `Thought[] thoughts = new Thought[65536]` — each with identity, +allocation, GC visibility, pointer-chasing. This project's answer instead: + +``` +65,536 candidates + state lane i8[65536] + confidence lane i16[65536] + class lane u32[65536] + active mask 8192 bytes +``` + +Composed via mask intersection (`A ∩ B ∩ C`) evaluated by SIMD/bitmap, never +by materializing 65,536 objects. **Java manipulates meaning. The substrate +manipulates population.** + +## Why This Explains the Valhalla Split (see `valhalla-three-truths-method.md`) + +Valhalla's sweet spot is the **tiny vocabulary controlling the population** — +`NodeId`, `LaneId`, `RowRange`, `Predicate`, `Lens`, `Range`, `Shape`, +`Capability` — not the population itself. Valhalla does not turn 64K +candidates back into 64K prettier objects; it makes the *instructions that +steer* those candidates identity-free and cheap. Panama makes the membrane +between that vocabulary and the population disappear. `ndarray::simd` + +the native SoA fixture make the population itself cheap. Three deliberately +different jobs, one triad. + +## The Litmus Test for Any API Decision in This Repo + +> Does this proposal turn N logical entities into N Java objects (of any +> kind — plain, record, or value)? If yes, reject it, regardless of how +> elegant the object looks. N entities become 1 lane set + 1 mask + a +> handful of typed descriptors, always. + +This is why `docs/abi.md` §6 forbids per-element crossings, why the Java +facade's `View` is lazy (building a predicate chain must not materialize +anything), and why the Valhalla lab's headline experiment (see +`valhalla-three-truths-method.md`) directly measures "N Java objects" vs +"N Valhalla value objects in an array" vs "1 lane + 1 mask" — because the +thesis predicts the first two both lose to the third, and that prediction +must be checked, not assumed. diff --git a/.claude/knowledge/no-c-ever.md b/.claude/knowledge/no-c-ever.md new file mode 100644 index 0000000..f85c237 --- /dev/null +++ b/.claude/knowledge/no-c-ever.md @@ -0,0 +1,44 @@ +# There Is No C Here — Ever + +> READ BY: all agents touching native/lgj-abi, java/internal/ffm, or any +> future jextract/cbindgen/JNI proposal + +## Status: FINDING (locked by the project's founding directive) + +The user's own words, verbatim: **"There's no C ever. We reuse Panama project +for a rust only."** + +## The One-Line Rule + +`extern "C"` names a **platform calling convention** (SysV AMD64 psABI here, +AAPCS64 on ARM). `#[repr(C)]` names a **platform aggregate layout rule**. +Neither requires C source, a C compiler, a header, or a C runtime. The stack +is `Rust → platform ABI → Java`, with zero C artifacts anywhere. + +## Consequences (all load-bearing, all checked mechanically) + +- No `.h` file exists in this repo. Grep for one before merging anything. +- No C toolchain (`gcc`/`clang`) is a build dependency. +- **No `cbindgen`.** Its output is a C header; we have no consumer for one. +- **No `jextract`.** jextract's only input is a C header — with none to + extract from, the tool has no job here. This is not "unused," it is + structurally inapplicable. +- **No JNI**, and no JNI-*shaped* Panama code either (see the anti-JNI rule + in `docs/abi.md` §6 and the `abi-membrane-warden` agent card). + +## What replaces the header + +A **self-describing runtime manifest** (`lgj_abi_manifest()`), emitted by the +compiled artifact itself, read and cross-checked by Java at load time. See +`docs/abi.md` §1 and §5. A header is a claim; the manifest is a fact about +the artifact that produced it — it cannot disagree with itself. + +## Falsifier + +Any of these in a diff is an automatic block: +- a new `.h`/`.hpp` file anywhere in the repo +- a `build.rs` invoking `cc`/`cbindgen` +- a `jextract` invocation in any script or CI file +- `JNIEnv`, `jni::*`, `#[no_mangle]` functions shaped as one-call-per-element + (grep the function body: does its cost scale with `n_rows`, or is it + lifecycle? If neither, it's the JNI anti-pattern wearing Panama's clothes) diff --git a/.claude/knowledge/simd-lane-width-family.md b/.claude/knowledge/simd-lane-width-family.md new file mode 100644 index 0000000..c05ca79 --- /dev/null +++ b/.claude/knowledge/simd-lane-width-family.md @@ -0,0 +1,58 @@ +# The Lane-Width Family — 64x8 / 32x16 / 16x32 / 8x64, and Why AMX Is Different + +> READ BY: simd-savant, panama-bridge-engineer, and anyone writing a doc +> comment that names an `ndarray::simd` type + +## Status: FINDING (verified against `ndarray/src/simd_avx512.rs` source) + +The user's own words, verbatim: **"The usual polyfill 64x8, 32x16, 16x32. +Amx and gemm tiles and i8 i16 i32 and u8 U16 u32 follow slightly different +multipliers matching the max available."** + +## The Rule + +At the AVX-512 tier, every `ndarray::simd` typed-wrapper name satisfies: + +``` +lane_count(width_bits) × width_bits = 512 (the register width) +``` + +Verified directly against `simd_avx512.rs`: + +| element width | lane count | type names | total bits | +|---|---|---|---| +| 8-bit | 64 | `U8x64`, `I8x64` | 512 | +| 16-bit | 32 | `U16x32`, `I16x32` | 512 | +| 32-bit | 16 | `U32x16`, `I32x16`, `F32x16` | 512 | +| 64-bit | 8 | `U64x8`, `I64x8`, `F64x8` | 512 | + +"The multiplier matching the max available" = `512 / (8 × elem_bytes)` — the +AVX-512 register width divided by the element size. The AVX2 tier is the +same rule at 256 bits (halved lane counts: `x32/x16/x8/x4`), and NEON/WASM at +128 bits (further halved: `x16/x8/x4/x2` where applicable) — each backend +picks the lane count that fills *its own* native register, not a fixed +count. `F32x8`/`F64x4` etc. exist as the 256-bit-native companions +re-exported alongside the 512-bit set. + +## Why AMX Is a Separate Module, Not Another Width Variant + +AMX (`ndarray::simd::{amx_available, matmul_i8_to_i32}`, backed by +`ndarray::hpc::amx_matmul` internally — accessed only via the `simd` +re-export per `simd-provenance.md`) is a **2-D tile register file** +(`TMM0..TMM7`), not a 1-D SIMD lane vector. Its "multiplier" is a tile shape +(rows × columns × element width, e.g. a `16×64` int8 tile), not a lane count +along one axis. This is why it is its own module (`amx_matmul.rs`, +`bf16_tile_gemm.rs`) rather than a `U8x1024`-shaped extension of the lane +family — the underlying hardware primitive is structurally different (matrix +multiply-accumulate across two tiles into a third), not just "more lanes." + +## Consequence for lgj-abi + +`docs/abi.md`'s `LgjElemKind` enum (`U8, I8, U16, I16, U32, I32, U64, I64, +F32, F64, MASK_WORD`) deliberately does not encode a lane width — that is a +compiled-in backend fact (reported via `LgjAbiManifest::simd_backend`), not +a per-value property. A Java caller never chooses "give me the x16 +variant" — it asks for an element kind and a bulk op; which lane width +executes it is `ndarray::simd`'s dispatch decision, invisible above the +membrane. This is the same "physics vs vocabulary" split the mission brief +draws between raw addresses and `LaneId`/`Range`/`Shape`. diff --git a/.claude/knowledge/simd-provenance.md b/.claude/knowledge/simd-provenance.md new file mode 100644 index 0000000..e5aba1c --- /dev/null +++ b/.claude/knowledge/simd-provenance.md @@ -0,0 +1,53 @@ +# SIMD Provenance — `ndarray::simd::*` Only, Never `ndarray::hpc` + +> READ BY: all agents touching native/lgj-abi/src/kernels.rs, or proposing +> any new Rust-side numeric primitive + +## Status: FINDING (operator-corrected 2026, locked) + +The user's own words, verbatim: **"Never use ndarray::hpc, trampoline to +ndarray::simd::* instead."** + +## The One-Line Rule + +`ndarray::hpc::*` is the **internal implementation** namespace. `ndarray::simd::*` +is the **sanctioned consumer re-export surface**. Every internal module that +ships a consumer-facing primitive (bitwise popcount, fingerprint ops, GEMM +tiles, quantization, cascade search, AMX) is re-exported *up* into +`ndarray::simd` by `ndarray/src/simd.rs`. ndarray's own CLAUDE.md states this +as the "matryoshka" invariant: *"Consumer writes `crate::simd::F32x16`. +Period."* This repo is exactly one more consumer of that invariant, not an +exception to it. + +## Consequence for this repo specifically + +`native/lgj-abi/src/kernels.rs` — the ONLY file in this crate allowed to +`use ndarray::*` at all (see `abi-membrane-warden` and the crate layout in +`docs/abi.md` §8) — must import exclusively through the `ndarray::simd::` +path: + +```rust +// CORRECT +use ndarray::simd::{eq_u32_to_mask, gt_i32_to_mask, mask_and, mask_or, masked_sum_i32}; +use ndarray::simd::popcount_batch_u64; // even though it's *implemented* in hpc::bitwise + +// WRONG — never do this, even though it resolves and compiles +use ndarray::hpc::bitwise::popcount_batch_u64; +``` + +Both may point at the same function today. That is not the point. The point +is: if the internal module ever moves, gets renamed, or gets an +arch-specialized replacement, the `ndarray::simd` re-export is the contract +that does not change under a consumer's feet. Importing the `hpc` path +directly is importing an implementation detail as if it were an API. + +## Falsifier + +`grep -rn "ndarray::hpc" native/` returning any hit outside of a doc comment +explaining *why* a symbol lives there is a block. The fix is always +"re-import through `ndarray::simd`," never "leave the hpc import, it works." + +## Cross-reference + +Same-family rule as `no-c-ever.md`: both are about refusing to reach past a +sanctioned boundary because the thing behind it happens to be reachable. diff --git a/.claude/knowledge/valhalla-three-truths-method.md b/.claude/knowledge/valhalla-three-truths-method.md new file mode 100644 index 0000000..d156292 --- /dev/null +++ b/.claude/knowledge/valhalla-three-truths-method.md @@ -0,0 +1,59 @@ +# The Three Truths Method — How To Evaluate Any Semantic Value Concept + +> READ BY: valhalla-lab-scientist, and any agent proposing a new semantic +> value type (NodeId, LaneId, RowRange, MaskId, Ordinal, ...) + +## Status: METHOD (mandated by the mission brief §8, operationalized here) + +## The Rule + +For every important semantic abstraction, distinguish and record three +separate things — never conflate them: + +1. **Semantic truth** — what SHOULD this concept mean? (e.g. "a `LaneId` is + a value; its object identity should be irrelevant; two `LaneId`s with the + same index are the same `LaneId`.") +2. **Stable-Java truth** — how is that contract expressed using the normal, + fully-supported JDK today (a `final record` on JDK 26 GA)? +3. **Valhalla truth** — how does the SAME contract look on the current + Valhalla preview (`value record` / `value class` on the JEP 401 EA build, + `/opt/jdks/jdk-27`)? + +Then **measure**, never assert. Record for (2) and (3): representation, +identity presence (`Class.isValue()`), allocation behaviour, array behaviour +(flattened or not — toggle `-XX:±UseArrayFlattening` and observe), field +flattening in a containing struct (`-XX:±UseFieldFlattening`), generic +behaviour, method-passing cost, nullability implications, and interaction +with FFM (can the value type address a `MemorySegment` as cheaply as a raw +`long`?). + +## Why This Exists + +The mission brief is explicit: *"Do not claim theoretical improvements +without measurement."* Valhalla is genuinely mid-flight (JEP 401 is preview, +not final — see `jdk-toolchain-facts.md`), and the temptation to assert "value +classes will make this free" without running the JDK 27 EA build is real and +must be refused every time. + +## Where Findings Go + +`valhalla-lab/` holds the runnable code and measured numbers for each of the +three truths, one experiment per semantic value type. If a genuine Valhalla +limitation is found while trying to express the ideal semantic-truth API, +it becomes a **minimal standalone reproducer** under `valhalla-lab/reproducers/` +recording: desired semantics / current ordinary-Java behaviour / current +Valhalla behaviour / observed layout+allocation / benchmark result / which +component the deficiency actually belongs to (javac, HotSpot, Valhalla, +Vector API, FFM, or this project's own design). **The API is never distorted +to accommodate a current Valhalla limitation** — the limitation is reported +upward instead. + +## The One Experiment That Must Never Be Skipped + +Per `john-doe-migration-thesis.md`'s litmus test: compare (i) 65,536 rows as +1 native lane + 1 packed mask + one bulk op, (ii) 65,536 plain Java objects, +(iii) 65,536 Valhalla value objects in an array. The thesis's prediction is +that Valhalla helps the *tiny descriptor vocabulary* and does **not** rescue +per-entity materialization at that scale — but this is a prediction to be +checked against real numbers in `valhalla-lab/`, not a conclusion to assume +into the docs. diff --git a/.claude/plans/lgj-vertical-slice-v1.md b/.claude/plans/lgj-vertical-slice-v1.md new file mode 100644 index 0000000..4e5a0df --- /dev/null +++ b/.claude/plans/lgj-vertical-slice-v1.md @@ -0,0 +1,57 @@ +# lgj-vertical-slice-v1 — the first Panama×Valhalla×ndarray::simd proof + +**Status: ACTIVE (session 1, 2026-08-17).** No PR opened yet — everything +below is pre-first-commit on `claude/lance-graph-java-panama-valhalla-sus9w8`. + +## Goal + +Not "Java bindings for lance-graph." A proof that an ordinary Java developer +can write familiar-looking fluent Java and get zero-copy columnar/SIMD +execution without learning Rust/SoA/SIMD/FFM — see +`.claude/knowledge/john-doe-migration-thesis.md`, which is the actual thesis +this plan serves. + +## Phases (mirrors the mission brief's Phase A–I, mapped to D-ids on +`STATUS_BOARD.md`) + +| Phase | What | D-id | +|---|---|---| +| A | Archaeology: ndarray SIMD/mask surface, lance-graph ClassView/WideFieldMask/SoaEnvelope, current Panama+Valhalla state | D-LGJ-A | +| — | Normative contract: `docs/abi.md` | D-LGJ-ABI | +| — | `.claude/agents` + `.claude/knowledge` ensemble (this file's sibling work) | D-LGJ-ENS | +| B | Missing `ndarray::simd` primitives (`eq_u32_to_mask`, `gt_i32_to_mask`, `mask_and`/`mask_or`(`_assign`), `masked_sum_i32`) | D-LGJ-B | +| C | Rust ABI crate `native/lgj-abi` — manifest, generation-checked registry, generic SoA fixture, kernels, `extern "C"` surface | D-LGJ-C | +| D | Java FFM membrane `internal/ffm` — Layouts, Downcalls, Abi manifest cross-check | D-LGJ-D | +| E | Java public facade — `NativePattern`/`View`/`Predicate`/`Pattern`/`Mask`, fused-plan execution | D-LGJ-E | +| F | Valhalla lab — three-truths method on the small semantic value vocabulary | D-LGJ-F | +| G | Java Vector API comparative bench (zero-copy `fromMemorySegment`) vs Panama→`ndarray::simd` | D-LGJ-G | +| H | Falsification: handle lifecycle, SIMD/scalar parity, Java/native parity | D-LGJ-H | +| I | Docs: `architecture.md`, `panama.md`, `valhalla-lab.md`, `execution-boundary.md` | D-LGJ-I | + +## Sequencing decision (recorded, not just executed) + +B/C/D were fanned out **in parallel** as three disjoint trees +(`/home/user/ndarray` vs `native/lgj-abi` vs `java/`) against the single +frozen contract `docs/abi.md`, rather than sequentially — the contract is +what makes that safe: each side is checked against the doc, not against the +other side's in-progress code. F (Valhalla lab) was sequenced AFTER D so it +could read the real `View`/`Predicate` types rather than guess their shape. + +## Falsifiable gates before this plan's phases count as done + +- `grep -rn "ndarray::hpc" native/lgj-abi/src` → must be empty (see + `.claude/knowledge/simd-provenance.md`). +- `grep -rln '\.h$\|cbindgen\|jextract' .` (repo-wide) → must be empty (see + `.claude/knowledge/no-c-ever.md`). +- `grep -rn "java.lang.foreign" java/src/main/java/com/adaworldapi/lancegraph` + excluding `internal/ffm/` → must be empty (java-surface-warden's rule). +- Rust-side handle lifecycle tests pass AND were disable-verified (per + `handle-lifecycle-auditor`'s card — a happy-path-only test is not evidence). +- Every `bench/` number has a reproduction command line attached. + +## Where this plan's own status lives + +`STATUS_BOARD.md` (per-D-id status), `AGENT_LOG.md` (what each spawned agent +actually did), `LATEST_STATE.md` (current contract inventory + what's +active right now). This file records intent and sequencing; those three +record ground truth as it lands. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d6f235 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Rust +/target/ +/native/**/target/ +Cargo.lock.bak +**/*.rs.bk + +# Java +*.class +/java/out/ +/java/target/ +/bench/out/ +/bench/lib/*.jar +/valhalla-lab/out/ + +# Downloaded JDKs and artifacts (never committed — see docs/abi.md and +# .claude/knowledge/jdk-toolchain-facts.md for how to obtain them) +*.tar.gz + +# OS / editor noise +.DS_Store +*.swp diff --git a/docs/abi.md b/docs/abi.md new file mode 100644 index 0000000..8db9b72 --- /dev/null +++ b/docs/abi.md @@ -0,0 +1,409 @@ +# The ABI — a machine membrane, not a product API + +> **Status:** normative. This document is the contract. The Rust side and the +> Java side are implemented *independently* against it, and a runtime manifest +> check proves they agree. If this document and the code disagree, the code is +> wrong. + +## 0. There is no C here. Ever. + +This is the most misunderstood property of the design, so it is stated first. + +`extern "C"` in Rust and `Linker.nativeLinker()` in Java **do not involve the C +language**. They name a *machine* calling convention: + +| Thing | What it actually is | What it is not | +|---|---|---| +| `extern "C"` (Rust) | "use this target's standard C-family calling convention" — on this box, the **System V AMD64 psABI**; on ARM64, **AAPCS64** | C source, a C compiler, a C runtime | +| `#[repr(C)]` (Rust) | "use this target's standard aggregate layout rule" (field order, padding, alignment) | a C struct declaration | +| `Linker.nativeLinker()` (Java) | a JVM-internal implementation of that same psABI (`SysVx64Linker`) | a C bridge, a JNI shim | + +Consequences, all load-bearing: + +- **No `.h` header exists anywhere in this project**, and none will. +- **No C toolchain** is required to build or consume this. `cargo` and `javac` + are the entire toolchain. +- **No `cbindgen`.** Its output is a C header; we have no consumer for one. +- **No `jextract`.** jextract's *only* input is a C header. With no header there + is nothing to extract. This is not "we chose not to use the generated + bindings" (though that would also be true) — the tool has no input. +- **No JNI**, and no JNI-shaped use of Panama (see §6). + +### What replaces the header: a self-describing manifest + +A C header is a *text file that claims* what the compiled artifact looks like. +It can drift from the artifact silently — the classic ABI break. We do the +opposite: the compiled artifact **describes itself at runtime**, and Java +verifies that description against its own compiled-in expectations before the +first real call. + +``` + Rust cdylib Java + ─────────── ──── + lgj_abi_manifest() ──── returns ptr ───▶ read LgjAbiManifest + (a static, versioned │ + #[repr(C)] struct ▼ + describing sizeof/ cross-check EVERY + alignof of every size & align against + ABI type, endianness, this Java build's own + the compiled SIMD MemoryLayout constants + backend) │ + ▼ + mismatch ⇒ hard fail at load + (never silent corruption) +``` + +The manifest is strictly stronger than a header: a header describes what someone +*intended* to compile; the manifest is emitted *by* the compiled artifact, so it +cannot disagree with itself. + +## 1. Scope: what the ABI is allowed to be + +The ABI is a **machine membrane**. It is not the product. The product is the Java +semantic API (see `architecture.md`). Therefore: + +- It is **small** — currently 14 symbols. Growth is a design smell to be argued + for, not a default. +- It is **bulk-only**. Every call must be capable of doing work proportional to + `n_rows` (see §6 — the anti-JNI rule). +- It speaks **resource, lane, view, mask, operation, descriptor, status, + epoch** — not `Node`, not `Edge`, not `Person`. +- It is **versioned** and refuses to operate across a version mismatch. +- It never allocates on the Java side's behalf without a paired release, and + never hands out a pointer whose lifetime it cannot state. + +## 2. Versioning + +``` +LGJ_ABI_MAJOR = 0 // incompatible change ⇒ bump; Java refuses to load +LGJ_ABI_MINOR = 1 // additive change ⇒ bump; older Java may still load +LGJ_MAGIC = 0x4C_47_4A_5F_41_42_49_00 // "LGJ_ABI\0" big-endian-read +``` + +Rule: **Java requires `major` to match exactly and `minor` to be `>=` what it +was compiled against.** A `major` mismatch is a hard failure, not a warning. + +The magic doubles as an endianness probe: read as a `u64` little-endian it yields +a known constant; anything else means the library was built for a different byte +order and every subsequent read would be garbage. + +## 3. Status codes + +Every function returns `i32`. `0` is success; all failures are negative. There +are no error strings across the membrane and no `errno` dependence. + +| Value | Name | Meaning | +|---:|---|---| +| `0` | `OK` | success | +| `-1` | `NULL_ARGUMENT` | a required out-pointer was null | +| `-2` | `INVALID_HANDLE` | handle malformed, closed, or generation-stale | +| `-3` | `WRONG_RESOURCE_KIND` | e.g. a mask handle passed where a pattern was required | +| `-4` | `INVALID_LANE` | `lane_id` out of range for this resource | +| `-5` | `LANE_KIND_MISMATCH` | op's element type ≠ lane's element type | +| `-6` | `MASK_LENGTH_MISMATCH` | mask row-count ≠ resource row-count | +| `-7` | `PARENT_CLOSED` | child (mask) outlived its parent resource | +| `-8` | `VERSION_MISMATCH` | caller's ABI version incompatible | +| `-9` | `LENGTH_OVERFLOW` | requested size overflows `usize`/allocation limit | +| `-10` | `UNKNOWN_OPCODE` | plan contained an opcode this build does not implement | +| `-11` | `EMPTY_PLAN` | a plan with zero ops was submitted | +| `-12` | `ALLOCATION_FAILED` | the allocator refused | +| `-13` | `READ_ONLY` | write attempted against a read-only lane | + +`INVALID_HANDLE` is deliberately the response to *use-after-close*, not a crash. +See §4. + +## 4. Ownership, lifetime, and the generation-checked handle + +This is the part of the design that Java cannot get from Rust for free. Inside +Rust, `&self` borrows make a view-outliving-its-owner a *compile error*. Across +the membrane there is no borrow checker, so the invariant must be enforced at +runtime. + +**A handle is not a pointer.** It is an opaque `u64`: + +``` + 63 32 31 0 + ┌────────────────────────┬────────────────────────┐ + │ generation │ index │ + └────────────────────────┴────────────────────────┘ +``` + +- `index` selects a slot in a Rust-side registry. +- `generation` is bumped **every time a slot is freed**. + +A lookup validates `generation` against the slot's current generation. So: + +| Java does | Result | +|---|---| +| uses a live handle | works | +| uses a handle after `lgj_close` | `INVALID_HANDLE` — *not* use-after-free | +| closes twice | second returns `INVALID_HANDLE` | +| fabricates a handle (`0`, `0xDEADBEEF`, …) | `INVALID_HANDLE` | +| uses a mask whose parent was closed | `PARENT_CLOSED` | + +There is **no code path in which a stale handle dereferences freed memory.** That +is the single most important safety property of this ABI, and §H of the test +plan falsifies it directly rather than assuming it. + +### Answers to the mandated ownership questions + +| Question | Answer | +|---|---| +| Who owns this memory? | Rust. Always. Java never allocates a lane. | +| How long does a `MemorySegment` remain valid? | Until `lgj_close` on the owning handle. Java models this with an `Arena` whose lifetime is *nested inside* the resource's, so closing the resource is what ends the segment's usefulness — and the `epoch` field lets Java detect a stale segment it still holds. | +| What invalidates a view? | Closing the owner, or closing the owner's parent. | +| Can native storage relocate? | **No.** Lanes are allocated once at `lgj_pattern_open` and never reallocated, resized, or moved while the resource is alive. This is a hard ABI guarantee; any future growable lane requires a `major` bump. | +| Can Java mutate it? | Only where `LGJ_FLAG_WRITABLE` is set on the descriptor. Pattern lanes are **read-only**; mask words are writable. | +| What happens when the resource closes? | Its lanes are freed, its generation is bumped, its children fail with `PARENT_CLOSED`. | +| Can a child view outlive the parent? | It can *exist* but not *work* — every operation on it returns `PARENT_CLOSED`. | +| How are errors represented? | Negative `i32` status. Never a panic across the boundary (§9). | + +### Concurrency + +The registry is a `RwLock` holding `Arc` values; each entry has its +own inner lock over its payload. A call takes a **short** read lock on the +registry to resolve and clone the `Arc`, releases it, then locks only the entry. +So bulk ops on distinct resources run concurrently, and only +open/close serialize globally. Stated honestly: this has not been benchmarked +under contention, and the POC's Java layer is single-threaded. + +## 5. Descriptors describe lanes, not pointers + +Java's *public* API never sees an address. Internally, `LgjLaneDesc` is the +bounded description that the FFM layer turns into a `MemorySegment`: + +```rust +#[repr(C)] // 56 bytes, align 8 +pub struct LgjLaneDesc { + pub addr: u64, // physics — never surfaced in the public Java API + pub len_elems: u64, + pub byte_len: u64, + pub owner: u64, // owning resource handle + pub epoch: u64, // liveness stamp; Java re-checks before use + pub elem_kind: u32, // LgjElemKind + pub elem_bytes: u32, + pub stride_bytes: u32, // == elem_bytes when contiguous + pub flags: u32, // bitfield, below +} +``` + +Flags: `READABLE = 1<<0`, `WRITABLE = 1<<1`, `CONTIGUOUS = 1<<2`. + +Element kinds start at `1`, so a zeroed struct is detectably invalid rather than +silently meaning `U8`: + +``` +U8=1 I8=2 U16=3 I16=4 U32=5 I32=6 U64=7 I64=8 F32=9 F64=10 MASK_WORD=11 +``` + +`MASK_WORD` is a `u64` of 64 packed row bits, LSB = lowest row index. + +```rust +#[repr(C)] // 32 bytes +pub struct LgjResourceInfo { + pub kind: u32, // 1 = Pattern, 2 = Mask + pub lane_count: u32, + pub n_rows: u64, + pub epoch: u64, + pub parent: u64, // 0 = none +} +``` + +```rust +#[repr(C)] // 24 bytes, align 8 +pub struct LgjOpDesc { + pub op: u32, // LgjOpCode + pub lane_id: u32, + pub operand: i64, // needle / threshold, sign-extended + pub combine: u32, // 0 = AND (narrow), 1 = OR (widen) + pub _reserved:u32, // must be 0 +} +``` + +```rust +#[repr(C)] +pub struct LgjAbiManifest { + pub magic: u64, + pub abi_major: u32, + pub abi_minor: u32, + pub size_of_manifest: u32, + pub size_of_lane_desc: u32, + pub size_of_op_desc: u32, + pub size_of_resource_info: u32, + pub align_of_lane_desc: u32, + pub align_of_op_desc: u32, + pub align_of_resource_info: u32, + pub pointer_bytes: u32, + pub endianness: u32, // 0 = little + pub simd_backend: u32, // LgjSimdBackend + pub simd_backend_name: [u8; 32], // NUL-terminated, human-readable + pub build_profile: [u8; 16], // "release" | "debug" +} +``` + +`simd_backend`: `SCALAR=0 AVX2=1 AVX512=2 NEON=3 WASM=4`. It is reported, not +negotiated — Java does not select a backend. Which backend is compiled in is +`ndarray`'s business (§7). + +## 6. The anti-JNI rule + +Panama makes it *easy* to write JNI-shaped code: one downcall per element. That +is forbidden here. The rule: + +> **Every ABI function must do work proportional to `n_rows`, or be lifecycle.** + +Explicitly prohibited, and each of these would be a design failure rather than a +performance nit: + +- one call per node / per edge / per element +- one upcall per element +- Java-side hydration of a `Node`/`Edge`/`Person` object per row +- any serialization: no JSON, no protobuf, no `byte[]` bounce buffer +- a Java-owned mirror of the native data + +The fused-plan call (§7, `lgj_plan_eval`) exists precisely so that +`.where(...).where(...).count()` is **one** crossing regardless of how many +predicates or rows are involved. The unfused per-predicate ops are retained only +so the fused path can be benchmarked *against* something and so parity can be +checked predicate-by-predicate. + +## 7. The function surface (14 symbols) + +All symbols are prefixed `lgj_`. All return `i32` status except the manifest +getter. `out_*` parameters are written only on `OK`. + +### Manifest + +``` +const LgjAbiManifest* lgj_abi_manifest(void) +``` +Never fails, never allocates, returns a pointer to a `'static`. The one symbol +that does not return a status, because there is no failure mode. + +### Lifecycle + +``` +i32 lgj_pattern_open(u64 n_rows, u64 seed, u64* out_handle) +i32 lgj_close(u64 handle) +i32 lgj_resource_info(u64 handle, LgjResourceInfo* out) +``` + +`lgj_pattern_open` builds the generic SoA fixture deterministically from `seed` +(see `architecture.md` §fixture). Deterministic generation is what lets the Java +test assert exact counts without shipping a data file. + +### Lanes + +``` +i32 lgj_lane_describe(u64 handle, u32 lane_id, LgjLaneDesc* out) +``` + +Pattern lanes: `0 = ids (U64)`, `1 = classes (U32)`, `2 = values (I32)`. +All `READABLE | CONTIGUOUS`, never `WRITABLE`. + +### Masks + +``` +i32 lgj_mask_create(u64 parent, u32 initial, u64* out_handle) // initial: 0=empty, 1=all +i32 lgj_mask_describe(u64 mask, LgjLaneDesc* out) // MASK_WORD lane, WRITABLE +i32 lgj_mask_and(u64 a, u64 b, u64 dst) +i32 lgj_mask_or(u64 a, u64 b, u64 dst) +i32 lgj_mask_count(u64 mask, u64* out_count) +``` + +`dst` may alias `a` or `b`. All three must share the same parent and row count. + +### Bulk predicates (unfused — one predicate per crossing) + +``` +i32 lgj_op_eq_u32(u64 res, u32 lane_id, u32 needle, u64 dst_mask) +i32 lgj_op_gt_i32(u64 res, u32 lane_id, i32 threshold, u64 dst_mask) +``` + +Each *overwrites* `dst_mask` with the predicate's result. Composition is the +caller's job via `lgj_mask_and`. + +### Fused plan (N predicates — ONE crossing) + +``` +i32 lgj_plan_eval(u64 res, const LgjOpDesc* ops, u32 n_ops, + u64 dst_mask, u64* out_count) +``` + +Semantics: accumulator starts as **all rows set**; each op is evaluated and +combined into the accumulator per its `combine` field; the result lands in +`dst_mask` and its popcount is written to `out_count`. With every `combine = AND` +the result is monotonically narrowing by construction: + +``` + V0 = all + V1 = V0 ∩ op0 V1 ⊆ V0 + V2 = V1 ∩ op1 V2 ⊆ V1 ⊆ V0 +``` + +This single call is what makes the Java fluent chain cost one crossing. + +### Reduction + +``` +i32 lgj_reduce_sum_i32(u64 res, u32 lane_id, u64 mask, i64* out_sum) +``` + +Sums the `I32` lane over set mask bits into a widened `i64` (no overflow for +`n_rows ≤ 2^32` on `i32` inputs). + +### Parity escape hatch + +``` +i32 lgj_plan_eval_scalar(u64 res, const LgjOpDesc* ops, u32 n_ops, + u64 dst_mask, u64* out_count) +``` + +Identical semantics to `lgj_plan_eval` but forced down the scalar reference path. +Exists **only** so SIMD-vs-scalar parity is falsifiable *through the membrane*, +which is where the Java tests live. Not for production use. + +## 8. SIMD provenance + +Every kernel behind these symbols routes through **`ndarray::simd`** and nothing +else. Specifically prohibited in this crate: + +- `core::arch::*` intrinsics, `_mm*`, `vld1q_*`, any raw platform intrinsic +- `#[cfg(target_feature)]` / `#[cfg(target_arch)]` SIMD selection +- `core::simd` / `std::simd` / portable-simd (nightly) +- `pulp`, `SimSIMD`, `wide`, or any other SIMD crate +- a locally-written SIMD abstraction layer + +If a primitive is missing, it is **added to `ndarray::simd`** under that repo's +W1a consumer contract (all backends + scalar + parity test) and consumed from +here. That is not a workaround; it is the architecture. `ndarray::simd` is the +permanent hardware membrane for the whole Ada stack, and this project is one more +consumer of it — not an exception to it. + +## 9. Panics never cross the membrane + +Every `extern "C"` function wraps its body in `catch_unwind`. A panic becomes a +negative status, never an unwind into JVM frames (which would be UB). The +`Cargo.toml` additionally does **not** set `panic = "abort"`, because +`catch_unwind` requires unwinding to be available. + +## 10. What is deliberately absent + +Named so their absence is a decision on record rather than an oversight: + +- **No strings across the boundary** except the two fixed-size NUL-terminated + name fields in the manifest. No `char*` in, ever. +- **No callbacks / upcalls.** An upcall per element is the JNI anti-pattern in + disguise; a bulk op needs no callback. +- **No variadics.** +- **No `errno` / `captureCallState`.** Nothing here is a syscall wrapper. +- **No growable or relocatable lanes** (§4). +- **No `lgj_lane_read_element`.** There is intentionally no way to read one row + across the membrane. If Java wants element access it reads the + `MemorySegment` directly, in-process, with no crossing at all. +- **No `ClassView` / `WideFieldMask` yet.** The first slice is the generic + fixture on purpose (see `architecture.md` §"generic is not toy"). Wiring the + real `lance-graph` types is the next slice, and their shapes are already + ABI-compatible: `WideFieldMask`'s canonical `[u64]` chunks *are* this ABI's + `MASK_WORD` lane, and `NodeRow`'s `16|16|480` `#[repr(C, align(64))]` layout is + already a legal lane description. diff --git a/java/.gitignore b/java/.gitignore new file mode 100644 index 0000000..c058e57 --- /dev/null +++ b/java/.gitignore @@ -0,0 +1,3 @@ +# javac output. There is no build system here on purpose (no Maven, no Gradle, no downloads), +# so the compiled classes land in a plain directory rather than under a tool's conventions. +out/ diff --git a/java/README.md b/java/README.md new file mode 100644 index 0000000..fbf6653 --- /dev/null +++ b/java/README.md @@ -0,0 +1,140 @@ +# The Java side + +> The product is this API. The ABI (`../docs/abi.md`) is a machine membrane underneath it, and a +> consumer of this package is never asked to know that the membrane exists. + +## What a consumer writes + +```java +try (var data = NativePattern.open(65_536)) { + long n = data.view() + .where(Pattern.CLASS.eq(7)) + .where(Pattern.VALUE.gt(100)) + .count(); +} +``` + +Nothing in that snippet mentions an arena, a memory segment, a linker, a lane index, an opcode, a +packed mask word, or a SIMD backend. It is nevertheless the whole real path: 65,536 rows that never +enter the Java heap, two predicates fused into one vectorised kernel, **one** crossing of the +membrane, one number back. + +That is the thesis in one line: **64,000 logical entities do not become 64,000 Java objects.** They +become one native lane set, one packed mask, a few tiny schema descriptors, and one bulk operation. + +## Toolchain + +There is no Maven, no Gradle, no downloaded dependency, and no C toolchain. `javac` and `java` are +the entire Java toolchain, exactly as `cargo` is the entire Rust one. + +- **JDK 26** (`/opt/jdks/jdk-26.0.2`). FFM is **final** in JDK 26, so `--enable-preview` is neither + needed nor accepted. Do **not** use a JDK 21 `java` on the path — there FFM is preview-gated and + these commands will not work. +- No JNI, no `jextract`, no `cbindgen`, no `.h` file anywhere in the project. See `../docs/abi.md` + §0 for why those are absent by construction rather than by preference. + +## Build + +```sh +cd java +/opt/jdks/jdk-26.0.2/bin/javac -d out $(find src/main/java src/test/java -name '*.java') +``` + +Compilation emits six `[restricted]` warnings with `-Xlint:all`, all of them in +`internal/ffm/{Abi,Downcalls,Engine}.java`. That is not noise to be suppressed — it is a +machine-checkable statement that every unsafe FFM operation in the project lives in the one package +that is allowed to contain them. + +## Run + +```sh +# everything +/opt/jdks/jdk-26.0.2/bin/java --enable-native-access=ALL-UNNAMED -cp out \ + com.adaworldapi.lancegraph.AllTests + +# or one suite at a time — each has its own main +/opt/jdks/jdk-26.0.2/bin/java --enable-native-access=ALL-UNNAMED -cp out \ + com.adaworldapi.lancegraph.SmokeTest +``` + +### Flags + +| Flag | Needed? | Why | +|---|---|---| +| `--enable-native-access=ALL-UNNAMED` | **yes** | FFM's restricted methods (`libraryLookup`, `downcallHandle`, `reinterpret`) refuse to run without it. Omitting it does not fail the build — it fails at load, which is worse. | +| `--enable-preview` | **no** | FFM is final in JDK 26. Passing it is an error. | +| `-Djava.library.path=…` | **no** | Not used. That is the JNI mechanism; this project resolves the artifact itself (below). | + +### Where the native library is found + +Resolution order, first hit wins: + +1. `-Dlgj.library=/abs/path/liblgj_abi.so` — an explicit file. +2. `LGJ_LIBRARY` environment variable — same meaning. +3. `-Dlgj.library.dir=/abs/dir` — a directory holding the platform-named artifact. +4. Walking up from the working directory for `target/release/liblgj_abi.so`, then + `target/debug/liblgj_abi.so`. + +Release is searched before debug deliberately: if both exist, silently benchmarking the debug build +would be a measurement error rather than an inconvenience. + +If nothing is found, or the artifact is found but disagrees with this build, the failure names the +exact problem and every path that was tried. A test run in that state reports **exit code 2 and the +word SKIPPED**, never a red failure — "you have not built the library" and "your library is broken" +must not look the same in a log. + +### Exit codes + +| Code | Meaning | +|---|---| +| `0` | all checks passed | +| `1` | at least one check failed | +| `2` | the native artifact is unavailable, so nothing was run | + +## Layout + +``` +src/main/java/com/adaworldapi/lancegraph/ the public semantic API +src/main/java/com/adaworldapi/lancegraph/internal/ffm/ ALL Panama machinery, and nowhere else +src/test/java/com/adaworldapi/lancegraph/ tests (plain Java, no JUnit) +``` + +The split is enforced, not merely intended: `ApiSurfaceTest` walks every public member of every +public type by reflection and fails if any signature mentions `java.lang.foreign.*`, +`java.lang.invoke.*`, or our own `internal.*` package. It needs no native library, because the +shape of an API is a compile-time property — which makes it the one suite worth running *before* +the artifact exists. + +## The tests, and what each one would catch + +| Suite | The claim it can falsify | +|---|---| +| `ApiSurfaceTest` | The membrane leaked into the consumer API. Needs no native library. | +| `AbiContractTest` | The self-describing manifest is not actually checked, or cannot reject anything. | +| `SmokeTest` | The advertised fluent chain does not work. | +| `FixtureParityTest` | The kernels return the wrong numbers — Java recomputes the expected answers from the generator, independently. | +| `FusionParityTest` | Fusion, unfused composition and the scalar reference disagree. | +| `LazinessTest` | Building a chain crosses the membrane, or a terminal op's cost grows with predicates or rows. | +| `NarrowingTest` | Adding a condition can widen a selection. | +| `LifetimeTest` | A stale handle reaches native code — use after close, double close, a selection outliving its parent. | + +Two of these are worth calling out because they are unusual: + +**`FixtureParityTest` is a genuine cross-language check.** Property tests ("the count never grows", +"the paths agree") can all pass while every number is wrong together. This suite transcribes the +fixture's SplitMix64 generator into Java, counts with an ordinary loop, and compares against what +the vectorised kernels produced over data Java never sees. It ships no golden file, because a golden +file only proves nobody edited it. + +**`LazinessTest` measures rather than asserts.** The membrane counts its own crossings, so +"building a View makes zero crossings" is a number in a log rather than a comment that quietly stops +being true. It also pins the load-bearing property: a four-condition chain and a sixteen-condition +chain both cost exactly one crossing, and so do 1,024 rows and 1,000,000 rows. + +## Two things deliberately not on the consumer path + +- **`Diagnostics`** — `countUnfused` and `countScalar` exist so fusion and SIMD can be measured + *against something*. They are in a separately named class rather than as methods on `View`, so + nobody reaches them thinking they are an option. +- **`internal.ffm.Engine`** — the only way to raw native storage, reachable only by naming an + internal package explicitly. Ordinary composition never hands it to you. diff --git a/java/src/main/java/com/adaworldapi/lancegraph/AbiMismatchException.java b/java/src/main/java/com/adaworldapi/lancegraph/AbiMismatchException.java new file mode 100644 index 0000000..03122dd --- /dev/null +++ b/java/src/main/java/com/adaworldapi/lancegraph/AbiMismatchException.java @@ -0,0 +1,20 @@ +package com.adaworldapi.lancegraph; + +/** + * Thrown at library load when the compiled artifact's self-description disagrees with what this + * Java build expects. + * + *

This is the header replacement doing its job. A C header claims a layout and can + * drift from the artifact silently; here the artifact emits its own sizes, alignments, version and + * endianness at runtime and Java compares them against independently derived + * {@code MemoryLayout} numbers. The first disagreement is a hard failure naming the exact field, + * never a silent misread. + */ +public final class AbiMismatchException extends LanceGraphException { + + private static final long serialVersionUID = 1L; + + public AbiMismatchException(String message) { + super(message); + } +} diff --git a/java/src/main/java/com/adaworldapi/lancegraph/ClosedResourceException.java b/java/src/main/java/com/adaworldapi/lancegraph/ClosedResourceException.java new file mode 100644 index 0000000..be975ed --- /dev/null +++ b/java/src/main/java/com/adaworldapi/lancegraph/ClosedResourceException.java @@ -0,0 +1,25 @@ +package com.adaworldapi.lancegraph; + +/** + * Thrown when a resource is used after it was closed, closed twice, or when a value derived from + * it (a {@link View}, a {@link Mask}) is used after its owner was closed. + * + *

Two independent mechanisms produce this, deliberately — belt and braces: + * + *

    + *
  1. Java's own bookkeeping fails fast, so a stale handle never even reaches native code. + *
  2. If it somehow did, the native registry's generation check returns {@code INVALID_HANDLE} + * or {@code PARENT_CLOSED} rather than dereferencing freed memory, and that status maps + * back to this same exception. + *
+ * + *

There is no code path in which a stale handle dereferences freed memory. + */ +public final class ClosedResourceException extends LanceGraphException { + + private static final long serialVersionUID = 1L; + + public ClosedResourceException(String message) { + super(message); + } +} diff --git a/java/src/main/java/com/adaworldapi/lancegraph/Diagnostics.java b/java/src/main/java/com/adaworldapi/lancegraph/Diagnostics.java new file mode 100644 index 0000000..1c43072 --- /dev/null +++ b/java/src/main/java/com/adaworldapi/lancegraph/Diagnostics.java @@ -0,0 +1,70 @@ +package com.adaworldapi.lancegraph; + +import com.adaworldapi.lancegraph.internal.ffm.Downcalls; + +/** + * Measurement and comparison entry points. Not the consumer API. + * + *

Everything here exists so a claim this project makes can be falsified from the outside rather + * than believed: + * + *

    + *
  • {@link #countUnfused} runs the same query one crossing per predicate, so the fused path can + * be benchmarked against something. Fusion is otherwise an assertion about code + * nobody measured. + *
  • {@link #countScalar} runs the same query through the scalar reference kernel, so + * SIMD-versus-scalar parity is falsifiable through the membrane — which is where these tests + * live. + *
  • {@link #crossings()} makes laziness observable: snapshot it, build a chain, and the delta + * must be zero. + *
+ * + *

They are gathered in a separate, plainly-named class rather than added as methods on + * {@link View} on purpose. A benchmark path sitting next to {@code count()} would eventually get + * called by someone who thought it was an option; here it is impossible to reach by accident. + */ +public final class Diagnostics { + + private Diagnostics() {} + + /** + * The production path, named explicitly for symmetry in a benchmark. Identical to + * {@link View#count()}. + */ + public static long countFused(View view) { + return view.count(); + } + + /** + * The same answer, computed with one crossing per predicate plus one per combine plus one to + * count, instead of one crossing total. + * + *

Comparison only. This is what the design forbids, kept executable so the + * cost of forbidding it is a number rather than an opinion. + */ + public static long countUnfused(View view) { + return view.source().countUnfused(view.predicates()); + } + + /** + * The same answer, forced down the scalar reference kernel with no SIMD. + * + *

Parity checking only. If this ever disagrees with {@link #countFused}, + * the vectorised kernel is wrong — and the disagreement is visible from Java, not only from + * inside Rust's own test suite. + */ + public static long countScalar(View view) { + return view.source().countScalar(view.predicates()); + } + + /** + * Total membrane crossings since JVM start. + * + *

The instrument behind the laziness claim. Building and narrowing a view must not move + * this number at all; a terminal operation must move it by a small constant that does not grow + * with the number of predicates or rows. + */ + public static long crossings() { + return Downcalls.crossings(); + } +} diff --git a/java/src/main/java/com/adaworldapi/lancegraph/Field.java b/java/src/main/java/com/adaworldapi/lancegraph/Field.java new file mode 100644 index 0000000..f16a903 --- /dev/null +++ b/java/src/main/java/com/adaworldapi/lancegraph/Field.java @@ -0,0 +1,61 @@ +package com.adaworldapi.lancegraph; + +/** + * One named, typed column of a schema. + * + *

A {@code Field} is the whole reason an ordinary Java developer never meets a lane index or an + * opcode. The generated vocabulary hands out {@code Pattern.CLASS} and {@code Pattern.VALUE}; the + * IDE completes them; the compiler type-checks the comparison. Everything underneath — which lane, + * which element kind, which opcode, which SIMD backend ran — is physics the field carries and the + * caller never states. + * + *

Two surfaces, deliberately. The constructor and {@link #lane()} are the + * generator surface: they exist for the code that mints a schema (today hand-written as + * {@link Pattern}, tomorrow emitted from a schema definition). {@link #name()} and the typed + * comparison methods on the subclasses are the consumer surface. A consumer never calls + * the former. + * + *

Sealed: the set of element types the membrane implements is fixed by the ABI, so a schema + * cannot invent a field type whose predicate has no kernel behind it. + */ +public abstract sealed class Field + permits U32Field, I32Field, U64Field { + + private final String name; + private final LaneId lane; + private final Ordinal ordinal; + + Field(String name, LaneId lane, Ordinal ordinal) { + this.name = java.util.Objects.requireNonNull(name, "name"); + this.lane = java.util.Objects.requireNonNull(lane, "lane"); + this.ordinal = java.util.Objects.requireNonNull(ordinal, "ordinal"); + } + + /** The field's name as written in the schema. */ + public final String name() { + return name; + } + + /** Position within the schema. Stable for a schema version. */ + public final Ordinal ordinal() { + return ordinal; + } + + /** + * Which column this field lives in. + * + *

Generator surface. A consumer has no use for this — writing + * {@code Pattern.CLASS.eq(7)} is the point, and {@code lane 1} is the thing being hidden. + */ + public final LaneId lane() { + return lane; + } + + /** The element type this field reads, for diagnostics. */ + public abstract String elementType(); + + @Override + public final String toString() { + return name + ":" + elementType(); + } +} diff --git a/java/src/main/java/com/adaworldapi/lancegraph/I32Field.java b/java/src/main/java/com/adaworldapi/lancegraph/I32Field.java new file mode 100644 index 0000000..fa623d3 --- /dev/null +++ b/java/src/main/java/com/adaworldapi/lancegraph/I32Field.java @@ -0,0 +1,34 @@ +package com.adaworldapi.lancegraph; + +import com.adaworldapi.lancegraph.internal.ffm.Layouts; +import com.adaworldapi.lancegraph.internal.ffm.PlanOp; + +/** + * A signed 32-bit column — a measurement, a score, a delta that can go negative. + * + *

{@link #gt(int)} is a signed comparison, which is not a detail: the fixture's values + * straddle zero precisely so that an implementation which compared unsigned by mistake would be + * caught rather than accidentally agreeing. + * + *

Only the comparisons the membrane implements are offered — see {@link U32Field} for why that + * is a feature rather than a gap. + */ +public final class I32Field extends Field { + + /** Generator surface. Called by schema-vocabulary code, not by consumers. */ + public I32Field(String name, LaneId lane, Ordinal ordinal) { + super(name, lane, ordinal); + } + + /** Rows whose value in this column is strictly greater than {@code threshold}, signed. */ + public Predicate gt(int threshold) { + return new Predicate( + PlanOp.narrowing(Layouts.OP_GT_I32, lane().index(), threshold), + name() + " > " + threshold); + } + + @Override + public String elementType() { + return "i32"; + } +} diff --git a/java/src/main/java/com/adaworldapi/lancegraph/LanceGraphException.java b/java/src/main/java/com/adaworldapi/lancegraph/LanceGraphException.java new file mode 100644 index 0000000..93f1350 --- /dev/null +++ b/java/src/main/java/com/adaworldapi/lancegraph/LanceGraphException.java @@ -0,0 +1,22 @@ +package com.adaworldapi.lancegraph; + +/** + * Base type for every failure this library reports. + * + *

Unchecked on purpose: the failures below are programming errors (using a closed resource, + * a version-incompatible native library) rather than recoverable conditions a caller is expected + * to branch on. There is no error string across the membrane — the native side returns a negative + * {@code i32} status and this layer turns it into a specific, named Java type. + */ +public class LanceGraphException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public LanceGraphException(String message) { + super(message); + } + + public LanceGraphException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/java/src/main/java/com/adaworldapi/lancegraph/LaneId.java b/java/src/main/java/com/adaworldapi/lancegraph/LaneId.java new file mode 100644 index 0000000..b2f481e --- /dev/null +++ b/java/src/main/java/com/adaworldapi/lancegraph/LaneId.java @@ -0,0 +1,48 @@ +package com.adaworldapi.lancegraph; + +/** + * Which column of the struct-of-arrays a field lives in. + * + *

Not a consumer concept. An ordinary caller writes + * {@code Pattern.CLASS.eq(7)} and never sees a lane id — the generated schema vocabulary carries + * it. This type exists for the generator surface (code that mints {@code Field}s) and for + * the internals that marshal an operation descriptor. + * + *

Valhalla A/B candidate

+ * + *

This is written so the same source compiles as a {@code value record} on a JEP 401 + * JDK by changing only the modifier. It is therefore: + * + *

    + *
  • final and immutable; + *
  • free of identity dependence — never used as a lock, never compared with {@code ==}, + * {@code equals}/{@code hashCode} derived purely from state; + *
  • free of any field that could be null. + *
+ * + *

What identity-freedom buys: a value class can be flattened into its container and scalarised + * in registers, so wrapping a bare {@code int} in a meaningful name stops costing a heap object + * and a pointer chase. That is the whole point of the A/B — today the abstraction is paid for at + * runtime, and under Valhalla it should be free. Until then this remains an ordinary record and + * the JIT's escape analysis does most, but not all, of the same job. + * + * @param index zero-based lane index within its resource + */ +public record LaneId(int index) { + + public LaneId { + if (index < 0) { + throw new IllegalArgumentException("lane index must be >= 0, was " + index); + } + } + + /** Lane 0 of a pattern resource — entity ids. */ + public static LaneId of(int index) { + return new LaneId(index); + } + + @Override + public String toString() { + return "lane#" + index; + } +} diff --git a/java/src/main/java/com/adaworldapi/lancegraph/Lens.java b/java/src/main/java/com/adaworldapi/lancegraph/Lens.java new file mode 100644 index 0000000..fc25fd7 --- /dev/null +++ b/java/src/main/java/com/adaworldapi/lancegraph/Lens.java @@ -0,0 +1,61 @@ +package com.adaworldapi.lancegraph; + +/** + * One column, seen through one {@link View} — the projection concept. + * + *

{@code
+ * long total = data.view()
+ *                  .where(Pattern.CLASS.eq(7))
+ *                  .lens(Pattern.VALUE)
+ *                  .sum();
+ * }
+ * + *

Deliberately narrow, and honest about it

+ * + *

A lens is wherewhich column, and that pairing is the durable idea: a + * selection says which rows, a lens says which column of them, and every bulk operation is some + * reduction over that pair. What a lens offers today is exactly what the membrane implements — + * a count and a signed 32-bit sum. It is a thin thing on purpose rather than a speculative + * framework with methods that would have to fail at runtime. + * + *

What it becomes: the place bulk projection lands — min/max, a histogram, a group-by, and bulk + * extraction of the selected values into a caller-supplied array. Every one of those is a single + * bulk crossing over an existing selection, so none of them changes the shape of this type; they + * are methods it grows. What it must never become is an iterator over rows, because a per-row + * accessor across the membrane is the exact anti-pattern the whole design exists to avoid. + */ +public final class Lens { + + private final View view; + private final I32Field field; + + Lens(View view, I32Field field) { + this.view = view; + this.field = field; + } + + /** Sum of this column over the selected rows, widened to 64 bits. */ + public long sum() { + return view.sumOf(field); + } + + /** How many rows the underlying view selects. */ + public long count() { + return view.count(); + } + + /** The column being projected. */ + public I32Field field() { + return field; + } + + /** The rows being projected through. */ + public View view() { + return view; + } + + @Override + public String toString() { + return "Lens[" + field.name() + " over " + view + "]"; + } +} diff --git a/java/src/main/java/com/adaworldapi/lancegraph/Mask.java b/java/src/main/java/com/adaworldapi/lancegraph/Mask.java new file mode 100644 index 0000000..8952ff2 --- /dev/null +++ b/java/src/main/java/com/adaworldapi/lancegraph/Mask.java @@ -0,0 +1,83 @@ +package com.adaworldapi.lancegraph; + +import com.adaworldapi.lancegraph.internal.ffm.Engine; + +/** + * A materialised selection: which rows a {@link View} chose, held natively as packed bits. + * + *

One bit per row. Selecting 64,000 of 64,000 entities costs 8,000 bytes — not 64,000 objects, + * not a list of indices, not a copy of anything. That is the whole reason the concept is exposed: + * a caller who wants to ask several questions about the same rows should pay for the answer once. + * + *

A caller who only wants a number should not use this at all — {@link View#count()} never + * materialises a selection the caller can see. + * + *

Lifetime

+ * + *

A selection is a child of the resource it was taken from. It may outlive its parent as an + * object, but it can never work after the parent closes: every operation then throws + * {@link ClosedResourceException}, which is the Java face of the ABI's {@code PARENT_CLOSED}. There + * is no arrangement of closes that lets a selection read freed memory. + */ +public final class Mask implements AutoCloseable { + + private final NativePattern parent; + private final long handle; + private boolean closed; + + Mask(NativePattern parent, long handle) { + this.parent = parent; + this.handle = handle; + } + + /** How many rows are selected. */ + public long count() { + requireUsable("count()"); + return Engine.maskCount(handle); + } + + /** The opaque identity of this selection. Diagnostics, logging, map keys. */ + public MaskId id() { + return new MaskId(handle); + } + + /** The resource whose rows this selects. */ + public NativePattern source() { + return parent; + } + + public boolean isOpen() { + return !closed && parent.isOpen(); + } + + /** Release the packed bits. Idempotency is not offered — a double close is an error. */ + @Override + public void close() { + if (closed) { + throw new ClosedResourceException("close() called on a selection that is already closed"); + } + closed = true; + if (parent.isOpen()) { + Engine.close(handle); + } + // If the parent is already gone, the selection was freed with it; calling close again + // would only earn an INVALID_HANDLE. Nothing leaks either way. + } + + private void requireUsable(String what) { + if (closed) { + throw new ClosedResourceException(what + " was called on a closed selection"); + } + if (!parent.isOpen()) { + throw new ClosedResourceException( + what + " was called on a selection whose resource is closed. The selection may" + + " outlive its parent as an object, but it can never work again" + + " (ABI status PARENT_CLOSED)."); + } + } + + @Override + public String toString() { + return "Mask[" + id() + (closed ? ", closed" : "") + "]"; + } +} diff --git a/java/src/main/java/com/adaworldapi/lancegraph/MaskId.java b/java/src/main/java/com/adaworldapi/lancegraph/MaskId.java new file mode 100644 index 0000000..62d598d --- /dev/null +++ b/java/src/main/java/com/adaworldapi/lancegraph/MaskId.java @@ -0,0 +1,39 @@ +package com.adaworldapi.lancegraph; + +/** + * The identity of a selection, as an opaque token. + * + *

Deliberately opaque: it is not a pointer and cannot be turned into one. Underneath it + * is a generation-checked registry token — freeing a slot bumps its generation, so a token that + * outlives its resource resolves to "closed", never to freed memory. That is why using a stale + * selection raises {@link ClosedResourceException} instead of corrupting the process. + * + *

Exposed at all only so a selection can be logged, compared, or used as a map key. Nothing a + * caller can do with the two accessors below is useful for reaching the underlying storage. + * + *

Valhalla A/B candidate

+ * + *

Same rules as {@link LaneId}: final, immutable, identity-free, so the same source compiles as + * a {@code value record} under JEP 401. Note the consequence that makes this a good candidate — + * two masks are "the same mask" when their state is equal, never because they are the same object. + * Nothing here relies on reference equality, so flattening changes no observable behaviour. + * + * @param token the opaque registry token + */ +public record MaskId(long token) { + + /** Which registry slot. Diagnostic only. */ + public int slot() { + return (int) (token & 0xFFFF_FFFFL); + } + + /** How many times that slot has been recycled. Diagnostic only. */ + public int generation() { + return (int) (token >>> 32); + } + + @Override + public String toString() { + return "mask#" + slot() + "@" + generation(); + } +} diff --git a/java/src/main/java/com/adaworldapi/lancegraph/NativeCallException.java b/java/src/main/java/com/adaworldapi/lancegraph/NativeCallException.java new file mode 100644 index 0000000..6395bf7 --- /dev/null +++ b/java/src/main/java/com/adaworldapi/lancegraph/NativeCallException.java @@ -0,0 +1,31 @@ +package com.adaworldapi.lancegraph; + +/** + * Thrown when a native call returned a negative status that is not more specifically modelled. + * + *

The numeric status is preserved so a test can assert on the exact ABI failure mode rather + * than on message text. + */ +public final class NativeCallException extends LanceGraphException { + + private static final long serialVersionUID = 1L; + + private final int status; + private final String statusName; + + public NativeCallException(String function, int status, String statusName, String meaning) { + super(function + " failed: " + statusName + " (" + status + ") — " + meaning); + this.status = status; + this.statusName = statusName; + } + + /** The raw negative {@code i32} the membrane returned. */ + public int status() { + return status; + } + + /** The ABI's name for that status, e.g. {@code INVALID_LANE}. */ + public String statusName() { + return statusName; + } +} diff --git a/java/src/main/java/com/adaworldapi/lancegraph/NativeLibraryNotFoundException.java b/java/src/main/java/com/adaworldapi/lancegraph/NativeLibraryNotFoundException.java new file mode 100644 index 0000000..80b19f3 --- /dev/null +++ b/java/src/main/java/com/adaworldapi/lancegraph/NativeLibraryNotFoundException.java @@ -0,0 +1,21 @@ +package com.adaworldapi.lancegraph; + +/** + * Thrown when the native artifact could not be located or loaded at all. + * + *

Distinct from {@link AbiMismatchException}: this one means "there was nothing to check", + * which lets a test report "the library has not been built yet" instead of failing with + * an unrelated {@code NullPointerException}. + */ +public final class NativeLibraryNotFoundException extends LanceGraphException { + + private static final long serialVersionUID = 1L; + + public NativeLibraryNotFoundException(String message) { + super(message); + } + + public NativeLibraryNotFoundException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/java/src/main/java/com/adaworldapi/lancegraph/NativePattern.java b/java/src/main/java/com/adaworldapi/lancegraph/NativePattern.java new file mode 100644 index 0000000..28fa0fc --- /dev/null +++ b/java/src/main/java/com/adaworldapi/lancegraph/NativePattern.java @@ -0,0 +1,260 @@ +package com.adaworldapi.lancegraph; + +import com.adaworldapi.lancegraph.internal.ffm.Engine; +import com.adaworldapi.lancegraph.internal.ffm.PlanOp; + +import java.util.List; + +/** + * A set of rows held natively, opened once and closed once. + * + *

The headline claim of this project, stated concretely: 64,000 logical entities do not + * become 64,000 Java objects. They become one native lane set, one packed selection mask, + * a handful of tiny schema descriptors, and one bulk operation. Nothing in this class hydrates a + * row, and there is no API that could. + * + *

{@code
+ * try (var data = NativePattern.open(65_536)) {
+ *     long n = data.view()
+ *                  .where(Pattern.CLASS.eq(7))
+ *                  .where(Pattern.VALUE.gt(100))
+ *                  .count();
+ * }
+ * }
+ * + *

Lifetime

+ * + *

Ordinary try-with-resources. After {@link #close()} every operation on this resource, and on + * any {@link View} or {@link Mask} derived from it, throws {@link ClosedResourceException}. That is + * enforced twice on purpose: this class fails fast on its own bookkeeping, and if a stale handle + * ever did reach native code the registry's generation check rejects it rather than dereferencing + * freed memory. Closing twice is an error, not a silent no-op. + * + *

Threading

+ * + *

Terminal operations reuse one internal scratch selection, so they are serialised on this + * instance. Distinct resources do not contend with each other. + */ +public final class NativePattern implements AutoCloseable { + + /** + * The seed used by {@link #open(long)}. + * + *

Generation is deterministic from the seed, which is what lets a test assert exact counts + * without shipping a data file — and lets Java recompute the expected answer independently, + * making the assertion a genuine cross-language check rather than a tautology. + */ + public static final long DEFAULT_SEED = 0xABCDL; + + private final long handle; + private final long rowCount; + private final Object lock = new Object(); + + private boolean closed; + private long scratchMask; // reused destination for terminal operations + private long auxMask; // second buffer, only the unfused comparison path needs it + private long allMask; // every row selected; only a predicate-free reduction needs it + + private NativePattern(long handle, long rowCount) { + this.handle = handle; + this.rowCount = rowCount; + } + + /** Open {@code rowCount} rows generated from {@link #DEFAULT_SEED}. */ + public static NativePattern open(long rowCount) { + return open(rowCount, DEFAULT_SEED); + } + + /** + * Open {@code rowCount} rows generated deterministically from {@code seed}. + * + * @throws NativeLibraryNotFoundException if the native artifact is not present + * @throws AbiMismatchException if it is present but does not match this build + */ + public static NativePattern open(long rowCount, long seed) { + if (rowCount < 0) { + throw new IllegalArgumentException("rowCount must be >= 0, was " + rowCount); + } + long h = Engine.openPattern(rowCount, seed); + // Read the row count back from the resource rather than trusting the request: it is the + // resource that decides, and caching it here means an empty View costs zero crossings. + return new NativePattern(h, Engine.rowCount(h)); + } + + /** + * A lazy description of every row. + * + *

Building it crosses the membrane zero times; narrowing it crosses zero times. Only a + * terminal operation executes. + */ + public View view() { + requireOpen("view()"); + return new View(this, List.of()); + } + + /** How many rows this resource holds. */ + public long rowCount() { + requireOpen("rowCount()"); + return rowCount; + } + + /** The row span, as a value. */ + public RowRange rows() { + requireOpen("rows()"); + return RowRange.of(rowCount); + } + + /** False once {@link #close()} has run. */ + public boolean isOpen() { + return !closed; + } + + /** + * Release the native storage. + * + *

Lanes are freed, the generation is bumped, and any child selection is orphaned — it can + * still exist, but it can never work again. + * + * @throws ClosedResourceException if already closed + */ + @Override + public void close() { + synchronized (lock) { + if (closed) { + throw new ClosedResourceException( + "close() called on a resource that is already closed. A double close is an" + + " error rather than a no-op, because the second call cannot know" + + " whether the handle was recycled in between."); + } + closed = true; + // Children first, so a mask is never left pointing at a dead parent even transiently. + if (scratchMask != 0) { + Engine.close(scratchMask); + scratchMask = 0; + } + if (auxMask != 0) { + Engine.close(auxMask); + auxMask = 0; + } + if (allMask != 0) { + Engine.close(allMask); + allMask = 0; + } + Engine.close(handle); + } + } + + // ── package-private: the execution surface View and friends use ────────────────────────── + + long handle() { + return handle; + } + + void requireOpen(String what) { + if (closed) { + throw new ClosedResourceException( + what + " was called on a closed resource. Java rejected it before reaching" + + " native code; had it not, the generation-checked handle would have" + + " returned INVALID_HANDLE rather than touching freed memory."); + } + } + + /** Evaluate a plan into the reusable scratch selection and return its popcount. */ + long countOf(List predicates) { + requireOpen("count()"); + if (predicates.isEmpty()) { + // No predicate selects every row. Answering from the cached count is not a shortcut + // around the membrane — an empty plan is rejected by the ABI (EMPTY_PLAN), and "every + // row" is a number this resource already knows. + return rowCount; + } + synchronized (lock) { + requireOpen("count()"); + return Engine.evaluateFused(handle, plan(predicates), scratch()); + } + } + + /** Evaluate a plan, then reduce one lane over the resulting selection. */ + long sumOf(List predicates, I32Field field) { + requireOpen("sumOf()"); + synchronized (lock) { + requireOpen("sumOf()"); + long mask; + if (predicates.isEmpty()) { + // Sum over everything: select all rows rather than inventing an always-true plan. + mask = all(); + } else { + mask = scratch(); + Engine.evaluateFused(handle, plan(predicates), mask); + } + return Engine.sumI32(handle, field.lane().index(), mask); + } + } + + /** Materialise a selection the caller owns and closes. */ + Mask selectInto(List predicates) { + requireOpen("select()"); + synchronized (lock) { + requireOpen("select()"); + long mask = Engine.createMask(handle, predicates.isEmpty()); + if (!predicates.isEmpty()) { + Engine.evaluateFused(handle, plan(predicates), mask); + } + return new Mask(this, mask); + } + } + + /** Diagnostics: the scalar reference kernel, same semantics, no SIMD. */ + long countScalar(List predicates) { + requireOpen("countScalar()"); + if (predicates.isEmpty()) { + return rowCount; + } + synchronized (lock) { + requireOpen("countScalar()"); + return Engine.evaluateScalar(handle, plan(predicates), scratch()); + } + } + + /** Diagnostics: one crossing per predicate plus the combines. Never the default. */ + long countUnfused(List predicates) { + requireOpen("countUnfused()"); + if (predicates.isEmpty()) { + return rowCount; + } + synchronized (lock) { + requireOpen("countUnfused()"); + return Engine.evaluateUnfused(handle, plan(predicates), scratch(), aux()); + } + } + + private List plan(List predicates) { + return predicates.stream().map(Predicate::op).toList(); + } + + private long scratch() { + if (scratchMask == 0) { + scratchMask = Engine.createMask(handle, false); + } + return scratchMask; + } + + private long aux() { + if (auxMask == 0) { + auxMask = Engine.createMask(handle, false); + } + return auxMask; + } + + private long all() { + if (allMask == 0) { + allMask = Engine.createMask(handle, true); + } + return allMask; + } + + @Override + public String toString() { + return "NativePattern[" + rowCount + " rows" + (closed ? ", closed" : "") + "]"; + } +} diff --git a/java/src/main/java/com/adaworldapi/lancegraph/NativeRuntime.java b/java/src/main/java/com/adaworldapi/lancegraph/NativeRuntime.java new file mode 100644 index 0000000..ed36893 --- /dev/null +++ b/java/src/main/java/com/adaworldapi/lancegraph/NativeRuntime.java @@ -0,0 +1,71 @@ +package com.adaworldapi.lancegraph; + +import com.adaworldapi.lancegraph.internal.ffm.Abi; + +/** + * What actually got loaded — for logs, benchmark headers, and bug reports. + * + *

Everything here is reported, never selected. In particular a caller cannot choose a + * SIMD backend: which one is compiled in is decided upstream, and a Java-side switch would be a + * second place for that decision to live. Knowing which one ran matters when reading a + * measurement; choosing it does not. + * + *

This is also the only public surface that mentions the ABI at all, and it mentions it as + * facts about a loaded artifact — no layout, no handle, no address. + */ +public final class NativeRuntime { + + private NativeRuntime() {} + + /** True if the native artifact loaded and passed the manifest cross-check. */ + public static boolean isAvailable() { + return Abi.isAvailable(); + } + + /** + * Why the library is unusable, or {@code null} if it loaded. + * + *

Lets a caller (a test, a startup probe) distinguish "not built yet" from "built but + * incompatible" without catching anything. + */ + public static LanceGraphException unavailableReason() { + return Abi.loadFailure(); + } + + /** Absolute path of the artifact in use. */ + public static String libraryPath() { + return Abi.libraryPath().toString(); + } + + /** ABI major version of the loaded artifact. Must match this build exactly. */ + public static int abiMajor() { + return Abi.manifest().abiMajor(); + } + + /** ABI minor version of the loaded artifact. Must be at least what this build expects. */ + public static int abiMinor() { + return Abi.manifest().abiMinor(); + } + + /** Human-readable name of the compiled SIMD backend, e.g. {@code "avx2"}. */ + public static String simdBackend() { + return Abi.manifest().simdBackendName(); + } + + /** {@code "release"} or {@code "debug"} — worth printing before believing a benchmark. */ + public static String buildProfile() { + return Abi.manifest().buildProfile(); + } + + /** One line summarising the loaded artifact. */ + public static String describe() { + if (!isAvailable()) { + return "lance-graph native runtime: UNAVAILABLE (" + + unavailableReason().getMessage() + ")"; + } + return "lance-graph native runtime: abi " + abiMajor() + "." + abiMinor() + + ", simd " + simdBackend() + + ", profile " + buildProfile() + + ", library " + libraryPath(); + } +} diff --git a/java/src/main/java/com/adaworldapi/lancegraph/Ordinal.java b/java/src/main/java/com/adaworldapi/lancegraph/Ordinal.java new file mode 100644 index 0000000..23476b9 --- /dev/null +++ b/java/src/main/java/com/adaworldapi/lancegraph/Ordinal.java @@ -0,0 +1,35 @@ +package com.adaworldapi.lancegraph; + +/** + * A field's position within its schema. + * + *

Stable for the life of a schema version, which is what lets a generated vocabulary be + * compact: the name is a compile-time thing that costs nothing at runtime, and the ordinal is what + * actually travels. + * + *

Valhalla A/B candidate

+ * + *

Same rules as {@link LaneId}: final, immutable, identity-free, so the same source compiles as + * a {@code value record} under JEP 401. This one is the clearest illustration of the cost being + * removed — it wraps a single {@code int} purely so that an ordinal cannot be confused with a lane + * index or a row number, and under Valhalla that type-safety is expected to cost nothing at all. + * + * @param value zero-based position + */ +public record Ordinal(int value) { + + public Ordinal { + if (value < 0) { + throw new IllegalArgumentException("ordinal must be >= 0, was " + value); + } + } + + public static Ordinal of(int value) { + return new Ordinal(value); + } + + @Override + public String toString() { + return "#" + value; + } +} diff --git a/java/src/main/java/com/adaworldapi/lancegraph/Pattern.java b/java/src/main/java/com/adaworldapi/lancegraph/Pattern.java new file mode 100644 index 0000000..ad4ccc8 --- /dev/null +++ b/java/src/main/java/com/adaworldapi/lancegraph/Pattern.java @@ -0,0 +1,53 @@ +package com.adaworldapi.lancegraph; + +import java.util.List; + +/** + * The schema vocabulary for a pattern resource. + * + *

This file is shaped exactly as a code generator would emit it

+ * + *

It is hand-written today, and that is temporary. The generator is the accessibility + * story, not a convenience: it is what turns "lane 1, opcode 5, operand 7" into + * {@code Pattern.CLASS.eq(7)} with IDE autocompletion and a compile error when the types are + * wrong. A developer who has never heard of struct-of-arrays, SIMD, packed masks or FFM writes + * code that reads like every other Java API they have used, and gets columnar physics anyway. + * + *

Everything below is therefore written the way a generator writes: constants only, no logic, + * lane indices and ordinals stated once and never repeated, names taken from the schema. A future + * generator emits this same shape from a schema definition; nothing that consumes it changes. + * + *

{@code
+ * try (var data = NativePattern.open(65_536)) {
+ *     long n = data.view()
+ *                  .where(Pattern.CLASS.eq(7))
+ *                  .where(Pattern.VALUE.gt(100))
+ *                  .count();
+ * }
+ * }
+ * + *

Note what the snippet does not mention: no arena, no segment, no mask, no lane, no + * opcode, no backend, no row loop. One resource, one chain, one number. + */ +public final class Pattern { + + private Pattern() {} + + /** The schema's name. */ + public static final String SCHEMA = "pattern"; + + /** Entity identity. Dense and zero-based in the fixture, so it doubles as the row index. */ + public static final U64Field ID = + new U64Field("id", LaneId.of(0), Ordinal.of(0)); + + /** Class tag, {@code 0..15} in the fixture. */ + public static final U32Field CLASS = + new U32Field("class", LaneId.of(1), Ordinal.of(1)); + + /** Signed measurement, {@code -150..361} in the fixture. */ + public static final I32Field VALUE = + new I32Field("value", LaneId.of(2), Ordinal.of(2)); + + /** Every field, in schema order. */ + public static final List FIELDS = List.of(ID, CLASS, VALUE); +} diff --git a/java/src/main/java/com/adaworldapi/lancegraph/Predicate.java b/java/src/main/java/com/adaworldapi/lancegraph/Predicate.java new file mode 100644 index 0000000..160ed50 --- /dev/null +++ b/java/src/main/java/com/adaworldapi/lancegraph/Predicate.java @@ -0,0 +1,40 @@ +package com.adaworldapi.lancegraph; + +import com.adaworldapi.lancegraph.internal.ffm.PlanOp; + +/** + * A single condition, obtained from a schema field: {@code Pattern.CLASS.eq(7)}. + * + *

This is a descriptor, not a lambda, and that is the design's hinge. A + * {@code java.util.function.Predicate} would look more idiomatic and would be catastrophic: it + * forces a {@code Row} object per row and an upcall per row — 64,000 objects and 64,000 crossings + * for 64,000 entities. As a descriptor, N conditions marshal into one contiguous little array and + * cross once, whatever N and whatever the row count. + * + *

So the fluent chain looks like ordinary Java and behaves like a query planner. The developer + * writes what they mean; the shape of what they wrote is what makes it fast. + * + *

Opaque on purpose: there is no accessor for the opcode, the lane, or the operand. Those are + * physics. What a caller can do with a {@code Predicate} is pass it to {@link View#where}. + */ +public final class Predicate { + + private final PlanOp op; + private final String description; + + Predicate(PlanOp op, String description) { + this.op = op; + this.description = description; + } + + /** The marshallable form. Package-private: this is the membrane's business. */ + PlanOp op() { + return op; + } + + /** Human-readable form, e.g. {@code "class == 7"}. Diagnostics only. */ + @Override + public String toString() { + return description; + } +} diff --git a/java/src/main/java/com/adaworldapi/lancegraph/RowRange.java b/java/src/main/java/com/adaworldapi/lancegraph/RowRange.java new file mode 100644 index 0000000..963f0c3 --- /dev/null +++ b/java/src/main/java/com/adaworldapi/lancegraph/RowRange.java @@ -0,0 +1,53 @@ +package com.adaworldapi.lancegraph; + +/** + * A half-open span of row indices, {@code [start, endExclusive)}. + * + *

The unit in which this library thinks about "how much data". A caller may see one of these + * from {@link NativePattern#rows()}; nothing about the physical layout of those rows is implied or + * exposed. + * + *

Valhalla A/B candidate

+ * + *

Same rules as {@link LaneId}: final, immutable, identity-free, so the same source compiles as + * a {@code value record} under JEP 401. Identity-freedom is what lets a pair of longs live in + * registers instead of on the heap — the abstraction stops being something you pay for. + * + * @param start first row, inclusive + * @param endExclusive one past the last row + */ +public record RowRange(long start, long endExclusive) { + + public RowRange { + if (start < 0) { + throw new IllegalArgumentException("start must be >= 0, was " + start); + } + if (endExclusive < start) { + throw new IllegalArgumentException( + "endExclusive (" + endExclusive + ") must be >= start (" + start + ")"); + } + } + + /** All rows from 0 up to {@code count}. */ + public static RowRange of(long count) { + return new RowRange(0, count); + } + + /** Number of rows spanned. */ + public long length() { + return endExclusive - start; + } + + public boolean isEmpty() { + return endExclusive == start; + } + + public boolean contains(long row) { + return row >= start && row < endExclusive; + } + + @Override + public String toString() { + return "rows[" + start + "," + endExclusive + ")"; + } +} diff --git a/java/src/main/java/com/adaworldapi/lancegraph/U32Field.java b/java/src/main/java/com/adaworldapi/lancegraph/U32Field.java new file mode 100644 index 0000000..433a84d --- /dev/null +++ b/java/src/main/java/com/adaworldapi/lancegraph/U32Field.java @@ -0,0 +1,38 @@ +package com.adaworldapi.lancegraph; + +import com.adaworldapi.lancegraph.internal.ffm.Layouts; +import com.adaworldapi.lancegraph.internal.ffm.PlanOp; + +/** + * An unsigned 32-bit column — a tag, a class, an enum-like discriminator. + * + *

The comparison methods here are exactly the ones the membrane has a kernel for. That is the + * type-safety story working in both directions: {@code Pattern.CLASS.gt("Berlin")} does not + * compile because a string is not a tag, and a comparison the native side cannot execute is not + * offered in the first place, so it cannot fail at runtime with an unknown opcode. + */ +public final class U32Field extends Field { + + /** + * Generator surface. Called by schema-vocabulary code, not by consumers. + * + * @param name the field's name in the schema + * @param lane the column it reads + * @param ordinal its position in the schema + */ + public U32Field(String name, LaneId lane, Ordinal ordinal) { + super(name, lane, ordinal); + } + + /** Rows whose value in this column equals {@code value}. */ + public Predicate eq(int value) { + return new Predicate( + PlanOp.narrowing(Layouts.OP_EQ_U32, lane().index(), Integer.toUnsignedLong(value)), + name() + " == " + Integer.toUnsignedString(value)); + } + + @Override + public String elementType() { + return "u32"; + } +} diff --git a/java/src/main/java/com/adaworldapi/lancegraph/U64Field.java b/java/src/main/java/com/adaworldapi/lancegraph/U64Field.java new file mode 100644 index 0000000..86d1c5e --- /dev/null +++ b/java/src/main/java/com/adaworldapi/lancegraph/U64Field.java @@ -0,0 +1,23 @@ +package com.adaworldapi.lancegraph; + +/** + * An unsigned 64-bit column — an identity, a join key. + * + *

Offers no predicate yet, and that absence is deliberate rather than an oversight: the + * ABI implements no 64-bit comparison kernel, so offering {@code eq(long)} here would compile and + * then fail at runtime with an unknown opcode. The vocabulary a schema can express is exactly the + * vocabulary the membrane can execute. When a {@code u64} kernel lands, this class gains the method + * and nothing else changes. + */ +public final class U64Field extends Field { + + /** Generator surface. Called by schema-vocabulary code, not by consumers. */ + public U64Field(String name, LaneId lane, Ordinal ordinal) { + super(name, lane, ordinal); + } + + @Override + public String elementType() { + return "u64"; + } +} diff --git a/java/src/main/java/com/adaworldapi/lancegraph/View.java b/java/src/main/java/com/adaworldapi/lancegraph/View.java new file mode 100644 index 0000000..0bea08d --- /dev/null +++ b/java/src/main/java/com/adaworldapi/lancegraph/View.java @@ -0,0 +1,140 @@ +package com.adaworldapi.lancegraph; + +import java.util.ArrayList; +import java.util.List; + +/** + * An immutable, lazy description of a set of rows. + * + *

A {@code View} is a description, not a result. {@link #where} returns a new view with + * one more condition and does nothing else: it allocates no selection, touches no row, and crosses + * the membrane zero times. A terminal operation — {@link #count()}, {@link #sumOf}, {@link #select} + * — is what executes, and it executes the whole chain in a single crossing. + * + *

That is why the fluent style is affordable here when it usually is not. Each + * {@code .where(...)} in a stream-like API normally costs another pass over the data; here it costs + * one 24-byte descriptor appended to a list, and the passes are fused into one bulk kernel that + * never returns to Java in between. + * + *

Monotonic narrowing — structural, not a convention

+ * + *

{@code where} intersects. There is no public composition that widens a view, because + * {@link Predicate} carries no combiner a caller can set and this class offers no {@code or}. So + * + *

{@code
+ *   V0 = every row
+ *   V1 = V0 ∩ p0        V1 ⊆ V0
+ *   V2 = V1 ∩ p1        V2 ⊆ V1 ⊆ V0
+ * }
+ * + *

holds by construction: adding a condition can never increase the count. The ABI does have a + * widening combiner; it is deliberately unreachable from here. A future union would build its own + * plan and be a differently-named operation, so the invariant above stays readable as an invariant + * rather than a default someone can flip. + * + *

Sharing

+ * + *

Views are immutable, so a narrowed view never disturbs the one it came from and a single view + * can be reused, held, or passed around freely. All of them refer to the same native rows; none of + * them copies anything. + */ +public final class View { + + private final NativePattern owner; + private final List predicates; + + View(NativePattern owner, List predicates) { + this.owner = owner; + this.predicates = predicates; + } + + /** + * A new view narrowed by one more condition. + * + *

Crosses the membrane zero times. Nothing is evaluated until a terminal operation. + * + * @param predicate obtained from a schema field, e.g. {@code Pattern.CLASS.eq(7)} + * @return a new view; this one is unchanged + */ + public View where(Predicate predicate) { + java.util.Objects.requireNonNull(predicate, "predicate"); + owner.requireOpen("where()"); + List next = new ArrayList<>(predicates.size() + 1); + next.addAll(predicates); + next.add(predicate); + return new View(owner, List.copyOf(next)); + } + + /** + * How many rows this view selects. + * + *

One crossing, whatever the number of conditions and whatever the number + * of rows. The whole chain is marshalled into one descriptor array and evaluated by one fused + * kernel that returns a single number. + */ + public long count() { + return owner.countOf(predicates); + } + + /** + * Sum a signed 32-bit column over the rows this view selects, widened to 64 bits. + * + *

Two crossings: evaluate the chain, then reduce. Still independent of the row count. + */ + public long sumOf(I32Field field) { + java.util.Objects.requireNonNull(field, "field"); + return owner.sumOf(predicates, field); + } + + /** + * A projection of one column through this view. + * + *

See {@link Lens} for what this concept is and what it is deliberately not yet. + */ + public Lens lens(I32Field field) { + java.util.Objects.requireNonNull(field, "field"); + owner.requireOpen("lens()"); + return new Lens(this, field); + } + + /** + * Materialise this view as a selection the caller owns and closes. + * + *

Useful when the same set of rows will be asked several questions: the chain is evaluated + * once and the answer is kept natively as packed bits. A caller who only wants a number should + * use {@link #count()} instead and never see a selection at all. + */ + public Mask select() { + return owner.selectInto(predicates); + } + + /** The resource these rows live in. */ + public NativePattern source() { + return owner; + } + + /** How many conditions this view carries. Diagnostics. */ + public int conditionCount() { + return predicates.size(); + } + + /** Package-private: the chain, for the execution surface. */ + List predicates() { + return predicates; + } + + @Override + public String toString() { + if (predicates.isEmpty()) { + return "View[all rows]"; + } + StringBuilder sb = new StringBuilder("View["); + for (int i = 0; i < predicates.size(); i++) { + if (i > 0) { + sb.append(" AND "); + } + sb.append(predicates.get(i)); + } + return sb.append(']').toString(); + } +} diff --git a/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Abi.java b/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Abi.java new file mode 100644 index 0000000..0e5812a --- /dev/null +++ b/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Abi.java @@ -0,0 +1,358 @@ +package com.adaworldapi.lancegraph.internal.ffm; + +import com.adaworldapi.lancegraph.AbiMismatchException; +import com.adaworldapi.lancegraph.LanceGraphException; +import com.adaworldapi.lancegraph.NativeLibraryNotFoundException; + +import java.lang.foreign.Arena; +import java.lang.foreign.FunctionDescriptor; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.SymbolLookup; +import java.lang.foreign.ValueLayout; +import java.lang.invoke.MethodHandle; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * Loads the native artifact and verifies it against this Java build's own + * compiled-in expectations before the first real call. + * + *

This class is the header replacement. There is no {@code .h} anywhere in this project and + * none will be added. A header is a text file that claims what a compiled artifact looks + * like and can drift from it silently — the classic ABI break. Here the artifact describes + * itself at runtime via {@code lgj_abi_manifest()}, and every reported size, alignment, + * version, pointer width and byte order is cross-checked against numbers Java derived + * independently from {@link Layouts}. The manifest is strictly stronger than a header, because it + * is emitted by the compiled artifact and therefore cannot disagree with itself. + * + *

The check is strict, not advisory: the first disagreement throws + * {@link AbiMismatchException} naming the exact field, and no further call is made. + * + *

Internal. Nothing here may appear in a public API signature. + */ +public final class Abi { + + private Abi() {} + + /** Explicit path to the shared object. Highest precedence. */ + public static final String PROP_LIBRARY = "lgj.library"; + + /** Directory to search for the platform-named artifact. */ + public static final String PROP_LIBRARY_DIR = "lgj.library.dir"; + + /** Environment fallback for {@link #PROP_LIBRARY}. */ + public static final String ENV_LIBRARY = "LGJ_LIBRARY"; + + /** + * The manifest, as read and validated. Field names mirror {@code LgjAbiManifest} exactly. + * + *

Immutable, identity-free, no reference-equality reliance — one of the Valhalla A/B + * candidates described in {@code com.adaworldapi.lancegraph.LaneId}. + */ + public record Manifest( + long magic, + int abiMajor, + int abiMinor, + int sizeOfManifest, + int sizeOfLaneDesc, + int sizeOfOpDesc, + int sizeOfResourceInfo, + int alignOfLaneDesc, + int alignOfOpDesc, + int alignOfResourceInfo, + int pointerBytes, + int endianness, + int simdBackend, + String simdBackendName, + String buildProfile) {} + + private static final Path LIBRARY_PATH; + private static final SymbolLookup LOOKUP; + private static final Manifest MANIFEST; + private static final LanceGraphException LOAD_FAILURE; + + static { + Path path = null; + SymbolLookup lookup = null; + Manifest manifest = null; + LanceGraphException failure = null; + try { + path = locateLibrary(); + // The global arena keeps the library mapped for the life of the JVM. Lane addresses + // handed out by the ABI are stable for the life of their owning resource (abi.md §4: + // lanes are never reallocated, resized or moved), so unloading is never desirable. + lookup = SymbolLookup.libraryLookup(path, Arena.global()); + manifest = readAndVerifyManifest(lookup, path); + } catch (LanceGraphException e) { + failure = e; + } catch (Throwable t) { + failure = new NativeLibraryNotFoundException( + "failed to load the lgj native library" + (path == null ? "" : " at " + path), t); + } + LIBRARY_PATH = path; + LOOKUP = lookup; + MANIFEST = manifest; + LOAD_FAILURE = failure; + } + + /** + * Throw the stored load failure if the library is unusable. + * + *

The failure is captured rather than thrown from the static initialiser so that a caller + * (in particular a test running before the artifact has been built) sees a precise + * {@link NativeLibraryNotFoundException} instead of a {@code NoClassDefFoundError} caused by a + * failed class initialisation. + */ + public static void ensureLoaded() { + if (LOAD_FAILURE != null) { + throw LOAD_FAILURE; + } + } + + /** True if the native artifact loaded and verified. */ + public static boolean isAvailable() { + return LOAD_FAILURE == null; + } + + /** The reason the library is unusable, or {@code null} if it loaded. */ + public static LanceGraphException loadFailure() { + return LOAD_FAILURE; + } + + public static SymbolLookup lookup() { + ensureLoaded(); + return LOOKUP; + } + + public static Manifest manifest() { + ensureLoaded(); + return MANIFEST; + } + + public static Path libraryPath() { + ensureLoaded(); + return LIBRARY_PATH; + } + + /** Human-readable name of the SIMD backend the artifact was compiled with. Reported, never negotiated. */ + public static String simdBackendName() { + return manifest().simdBackendName(); + } + + // ── location ───────────────────────────────────────────────────────────────────────────── + + private static Path locateLibrary() { + List tried = new ArrayList<>(); + + // An EXPLICIT request that cannot be honoured is a hard failure, never a fallback. + // + // This is not pedantry. Falling through to the search path here would mean that + // `-Dlgj.library=/path/that/moved.so` silently loads whatever artifact happens to be lying + // in a target directory instead — so a run intended to measure one library reports numbers + // from another, and the log says nothing. Silent substitution of a different binary is the + // exact failure mode the self-describing manifest exists to prevent one layer down; it + // would be absurd to reintroduce it one layer up. + String source = PROP_LIBRARY; + String explicit = System.getProperty(PROP_LIBRARY); + if (explicit == null || explicit.isBlank()) { + source = ENV_LIBRARY; + explicit = System.getenv(ENV_LIBRARY); + } + if (explicit != null && !explicit.isBlank()) { + Path p = Path.of(explicit); + if (Files.isRegularFile(p)) { + return p.toAbsolutePath(); + } + throw new NativeLibraryNotFoundException( + source + " names '" + p + "', which is not a regular file.\n" + + " Refusing to fall back to a search path: an explicitly requested" + + " library that silently becomes a different one is worse than a" + + " failure. Fix the path, or unset " + source + " to search."); + } + + String fileName = platformLibraryName(); + + String dir = System.getProperty(PROP_LIBRARY_DIR); + if (dir != null && !dir.isBlank()) { + Path p = Path.of(dir, fileName); + if (Files.isRegularFile(p)) { + return p.toAbsolutePath(); + } + throw new NativeLibraryNotFoundException( + PROP_LIBRARY_DIR + " names '" + dir + "', which contains no " + fileName + ".\n" + + " Refusing to fall back to a search path, for the same reason as" + + " above: an explicit request is honoured or it fails."); + } + + // Walk up from the working directory looking for a cargo target dir. Release first: if both + // exist, a debug build silently used for a benchmark would be a measurement error. + Path cwd = Path.of(System.getProperty("user.dir", ".")).toAbsolutePath(); + for (Path base = cwd; base != null; base = base.getParent()) { + for (String profile : new String[] {"release", "debug"}) { + Path p = base.resolve("target").resolve(profile).resolve(fileName); + if (Files.isRegularFile(p)) { + return p; + } + tried.add(p.toString()); + } + } + + throw new NativeLibraryNotFoundException( + "could not find the lgj native library (" + fileName + ").\n" + + " Set -D" + PROP_LIBRARY + "=/path/to/" + fileName + + " or -D" + PROP_LIBRARY_DIR + "=/path/to/dir" + + " or the " + ENV_LIBRARY + " environment variable.\n" + + " Searched:\n " + String.join("\n ", tried)); + } + + private static String platformLibraryName() { + String os = System.getProperty("os.name", "").toLowerCase(java.util.Locale.ROOT); + if (os.contains("win")) { + return "lgj_abi.dll"; + } + if (os.contains("mac") || os.contains("darwin")) { + return "liblgj_abi.dylib"; + } + return "liblgj_abi.so"; + } + + // ── manifest read + cross-check ────────────────────────────────────────────────────────── + + private static Manifest readAndVerifyManifest(SymbolLookup lookup, Path path) { + MemorySegment symbol = lookup.find("lgj_abi_manifest").orElseThrow(() -> + new AbiMismatchException( + "the library at " + path + " exports no lgj_abi_manifest symbol, so it" + + " cannot describe itself; refusing to call into it")); + + // The one symbol that returns a pointer rather than a status, because it has no failure + // mode: it returns a 'static. + MethodHandle mh = java.lang.foreign.Linker.nativeLinker() + .downcallHandle(symbol, FunctionDescriptor.of(ValueLayout.ADDRESS)); + + MemorySegment raw; + try { + raw = (MemorySegment) mh.invokeExact(); + } catch (Throwable t) { + throw new AbiMismatchException("lgj_abi_manifest() could not be called: " + t); + } + if (raw.equals(MemorySegment.NULL)) { + throw new AbiMismatchException("lgj_abi_manifest() returned NULL"); + } + + // Two-stage read. The returned segment is zero-length until reinterpreted, and how many + // bytes are legal to read is exactly what is in dispute — so read a minimal prefix first + // (magic, versions, size_of_manifest), decide whether the rest is safe, and only then widen. + // Fields are read by layout-derived offset, not through a struct VarHandle: such a handle + // bounds-checks the entire enclosing layout, which would defeat the point of a prefix read. + long prefixBytes = Layouts.OFF_SIZE_OF_MANIFEST + Integer.BYTES; + MemorySegment prefix = raw.reinterpret(prefixBytes); + + long magic = prefix.get(ValueLayout.JAVA_LONG, Layouts.OFF_MAGIC); + if (magic != Layouts.LGJ_MAGIC) { + throw new AbiMismatchException(String.format( + "manifest field 'magic' disagrees: library reports 0x%016X, this Java build" + + " expects 0x%016X. Read as a little-endian u64 the magic is also the" + + " endianness probe, so a mismatch here means either a different byte" + + " order or not an lgj artifact at all. Library: %s", + magic, Layouts.LGJ_MAGIC, path)); + } + + int major = prefix.get(ValueLayout.JAVA_INT, Layouts.OFF_ABI_MAJOR); + if (major != Layouts.LGJ_ABI_MAJOR) { + throw new AbiMismatchException(String.format( + "manifest field 'abi_major' disagrees: library is %d, this Java build was" + + " compiled against %d. A major mismatch is an incompatible change and" + + " is a hard failure, never a warning. Library: %s", + major, Layouts.LGJ_ABI_MAJOR, path)); + } + + int minor = prefix.get(ValueLayout.JAVA_INT, Layouts.OFF_ABI_MINOR); + if (minor < Layouts.LGJ_ABI_MINOR) { + throw new AbiMismatchException(String.format( + "manifest field 'abi_minor' disagrees: library is %d, this Java build requires" + + " >= %d. Additive changes bump minor; an older library cannot satisfy" + + " a newer caller. Library: %s", + minor, Layouts.LGJ_ABI_MINOR, path)); + } + + int sizeOfManifest = prefix.get(ValueLayout.JAVA_INT, Layouts.OFF_SIZE_OF_MANIFEST); + long expectedManifestSize = Layouts.MANIFEST.byteSize(); + if (sizeOfManifest < expectedManifestSize) { + throw new AbiMismatchException(String.format( + "manifest field 'size_of_manifest' disagrees: library reports %d bytes, this" + + " Java build's MemoryLayout derives %d. Reading the remaining fields" + + " would read past the artifact's own struct, so no further field is" + + " read. Library: %s", + sizeOfManifest, expectedManifestSize, path)); + } + + MemorySegment m = raw.reinterpret(Math.max(sizeOfManifest, expectedManifestSize)); + + // Every remaining check compares a number the artifact emitted against a number derived + // from Layouts — never a constant against itself. + expect(path, "size_of_lane_desc", Layouts.LANE_DESC.byteSize(), + m.get(ValueLayout.JAVA_INT, Layouts.OFF_SIZE_OF_LANE_DESC)); + expect(path, "align_of_lane_desc", Layouts.LANE_DESC.byteAlignment(), + m.get(ValueLayout.JAVA_INT, Layouts.OFF_ALIGN_OF_LANE_DESC)); + expect(path, "size_of_op_desc", Layouts.OP_DESC.byteSize(), + m.get(ValueLayout.JAVA_INT, Layouts.OFF_SIZE_OF_OP_DESC)); + expect(path, "align_of_op_desc", Layouts.OP_DESC.byteAlignment(), + m.get(ValueLayout.JAVA_INT, Layouts.OFF_ALIGN_OF_OP_DESC)); + expect(path, "size_of_resource_info", Layouts.RESOURCE_INFO.byteSize(), + m.get(ValueLayout.JAVA_INT, Layouts.OFF_SIZE_OF_RESOURCE_INFO)); + expect(path, "align_of_resource_info", Layouts.RESOURCE_INFO.byteAlignment(), + m.get(ValueLayout.JAVA_INT, Layouts.OFF_ALIGN_OF_RESOURCE_INFO)); + expect(path, "pointer_bytes", ValueLayout.ADDRESS.byteSize(), + m.get(ValueLayout.JAVA_INT, Layouts.OFF_POINTER_BYTES)); + + int endianness = m.get(ValueLayout.JAVA_INT, Layouts.OFF_ENDIANNESS); + if (endianness != Layouts.LGJ_ENDIAN_LITTLE) { + throw new AbiMismatchException( + "manifest field 'endianness' is " + endianness + "; only 0 (little) is defined." + + " Library: " + path); + } + if (ByteOrder.nativeOrder() != ByteOrder.LITTLE_ENDIAN) { + throw new AbiMismatchException( + "manifest field 'endianness' reports little-endian but this JVM's native order" + + " is " + ByteOrder.nativeOrder() + "; every struct read would be" + + " byte-swapped garbage. Library: " + path); + } + + int backend = m.get(ValueLayout.JAVA_INT, Layouts.OFF_SIMD_BACKEND); + String backendName = cString(m, Layouts.OFF_SIMD_BACKEND_NAME, Layouts.SIMD_NAME_BYTES); + String profile = cString(m, Layouts.OFF_BUILD_PROFILE, + Layouts.BUILD_PROFILE_BYTES); + + return new Manifest(magic, major, minor, sizeOfManifest, + (int) Layouts.LANE_DESC.byteSize(), (int) Layouts.OP_DESC.byteSize(), + (int) Layouts.RESOURCE_INFO.byteSize(), + (int) Layouts.LANE_DESC.byteAlignment(), (int) Layouts.OP_DESC.byteAlignment(), + (int) Layouts.RESOURCE_INFO.byteAlignment(), + (int) ValueLayout.ADDRESS.byteSize(), endianness, backend, backendName, profile); + } + + private static void expect(Path path, String field, long javaDerived, int libraryReports) { + if (javaDerived != libraryReports) { + throw new AbiMismatchException(String.format( + "manifest field '%s' disagrees: library reports %d, this Java build's" + + " MemoryLayout derives %d. Refusing to call into a layout this build" + + " would misread. Library: %s", + field, libraryReports, javaDerived, path)); + } + } + + /** Read a fixed-size NUL-terminated byte field. The only strings that cross the membrane. */ + private static String cString(MemorySegment m, long offset, int maxBytes) { + byte[] bytes = new byte[maxBytes]; + MemorySegment.copy(m, ValueLayout.JAVA_BYTE, offset, bytes, 0, maxBytes); + int len = 0; + while (len < maxBytes && bytes[len] != 0) { + len++; + } + return new String(bytes, 0, len, StandardCharsets.UTF_8); + } +} diff --git a/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Downcalls.java b/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Downcalls.java new file mode 100644 index 0000000..29a0cf1 --- /dev/null +++ b/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Downcalls.java @@ -0,0 +1,326 @@ +package com.adaworldapi.lancegraph.internal.ffm; + +import com.adaworldapi.lancegraph.LanceGraphException; + +import java.lang.foreign.FunctionDescriptor; +import java.lang.foreign.Linker; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.ValueLayout; +import java.lang.invoke.MethodHandle; +import java.util.concurrent.atomic.LongAdder; + +/** + * Every downcall handle in the project, resolved once into static finals. + * + *

Resolution is not free — {@code Linker.downcallHandle} builds a native stub — so doing it per + * call would be a per-crossing tax on top of the crossing itself. There are 14 symbols + * (docs/abi.md §7) and 14 handles. + * + *

The anti-JNI rule (docs/abi.md §6) lives here. Panama makes it easy to write + * JNI-shaped code: one downcall per element. Every wrapper below either does work proportional to + * {@code n_rows} or is lifecycle. There is no {@code readElement}, no upcall, no serialization, no + * {@code byte[]} bounce buffer, and no Java-side mirror of native data — by construction, not by + * convention, because the ABI exposes no symbol that would permit one. + * + *

Each wrapper translates a negative status into a specific exception via {@link Status}, so no + * caller above this class ever inspects a status code. + * + *

Internal. {@link MemorySegment} appears in these signatures and must not + * escape this package. + */ +public final class Downcalls { + + private Downcalls() {} + + private static final Linker LINKER = Linker.nativeLinker(); + + // Widths follow docs/abi.md exactly. u32 and i32 are both JAVA_INT: the ABI passes a 32-bit + // machine word and signedness is an interpretation, applied on the Rust side. + private static final MethodHandle PATTERN_OPEN = mh("lgj_pattern_open", + FunctionDescriptor.of(ValueLayout.JAVA_INT, + ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG, ValueLayout.ADDRESS)); + + private static final MethodHandle CLOSE = mh("lgj_close", + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_LONG)); + + private static final MethodHandle RESOURCE_INFO = mh("lgj_resource_info", + FunctionDescriptor.of(ValueLayout.JAVA_INT, + ValueLayout.JAVA_LONG, ValueLayout.ADDRESS)); + + private static final MethodHandle LANE_DESCRIBE = mh("lgj_lane_describe", + FunctionDescriptor.of(ValueLayout.JAVA_INT, + ValueLayout.JAVA_LONG, ValueLayout.JAVA_INT, ValueLayout.ADDRESS)); + + private static final MethodHandle MASK_CREATE = mh("lgj_mask_create", + FunctionDescriptor.of(ValueLayout.JAVA_INT, + ValueLayout.JAVA_LONG, ValueLayout.JAVA_INT, ValueLayout.ADDRESS)); + + private static final MethodHandle MASK_DESCRIBE = mh("lgj_mask_describe", + FunctionDescriptor.of(ValueLayout.JAVA_INT, + ValueLayout.JAVA_LONG, ValueLayout.ADDRESS)); + + private static final MethodHandle MASK_AND = mh("lgj_mask_and", + FunctionDescriptor.of(ValueLayout.JAVA_INT, + ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG)); + + private static final MethodHandle MASK_OR = mh("lgj_mask_or", + FunctionDescriptor.of(ValueLayout.JAVA_INT, + ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG)); + + private static final MethodHandle MASK_COUNT = mh("lgj_mask_count", + FunctionDescriptor.of(ValueLayout.JAVA_INT, + ValueLayout.JAVA_LONG, ValueLayout.ADDRESS)); + + private static final MethodHandle OP_EQ_U32 = mh("lgj_op_eq_u32", + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_LONG, + ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, ValueLayout.JAVA_LONG)); + + private static final MethodHandle OP_GT_I32 = mh("lgj_op_gt_i32", + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_LONG, + ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, ValueLayout.JAVA_LONG)); + + private static final MethodHandle PLAN_EVAL = mh("lgj_plan_eval", + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_LONG, + ValueLayout.ADDRESS, ValueLayout.JAVA_INT, ValueLayout.JAVA_LONG, + ValueLayout.ADDRESS)); + + private static final MethodHandle PLAN_EVAL_SCALAR = mh("lgj_plan_eval_scalar", + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_LONG, + ValueLayout.ADDRESS, ValueLayout.JAVA_INT, ValueLayout.JAVA_LONG, + ValueLayout.ADDRESS)); + + private static final MethodHandle REDUCE_SUM_I32 = mh("lgj_reduce_sum_i32", + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_LONG, + ValueLayout.JAVA_INT, ValueLayout.JAVA_LONG, ValueLayout.ADDRESS)); + + private static MethodHandle mh(String symbol, FunctionDescriptor descriptor) { + MemorySegment addr = Abi.lookup().find(symbol).orElseThrow(() -> + new LanceGraphException("the native library exports no symbol '" + symbol + + "'; it does not implement ABI " + Layouts.LGJ_ABI_MAJOR + "." + + Layouts.LGJ_ABI_MINOR)); + return LINKER.downcallHandle(addr, descriptor); + } + + // ── crossing instrumentation ───────────────────────────────────────────────────────────── + + private static final LongAdder CROSSINGS = new LongAdder(); + + /** + * Total number of membrane crossings made since JVM start. + * + *

This exists so laziness and fusion are observable rather than merely asserted in + * a comment: a test can snapshot this around building a {@code View} (expecting a delta of + * zero) and around a terminal operation (expecting a fixed small delta independent of how many + * predicates or rows are involved). + */ + public static long crossings() { + return CROSSINGS.sum(); + } + + private static void crossed() { + CROSSINGS.increment(); + } + + // ── lifecycle ──────────────────────────────────────────────────────────────────────────── + + /** Build the deterministic SoA fixture. Returns the resource handle. */ + public static long patternOpen(long nRows, long seed, MemorySegment outHandle) { + crossed(); + int st; + try { + st = (int) PATTERN_OPEN.invokeExact(nRows, seed, outHandle); + } catch (Throwable t) { + throw wrap("lgj_pattern_open", t); + } + Status.check("lgj_pattern_open", st); + return outHandle.get(ValueLayout.JAVA_LONG, 0); + } + + /** Free a resource, bump its generation, orphan its children. */ + public static void close(long handle) { + crossed(); + int st; + try { + st = (int) CLOSE.invokeExact(handle); + } catch (Throwable t) { + throw wrap("lgj_close", t); + } + Status.check("lgj_close", st); + } + + /** Fill {@code out} (a {@link Layouts#RESOURCE_INFO}-shaped segment). */ + public static void resourceInfo(long handle, MemorySegment out) { + crossed(); + int st; + try { + st = (int) RESOURCE_INFO.invokeExact(handle, out); + } catch (Throwable t) { + throw wrap("lgj_resource_info", t); + } + Status.check("lgj_resource_info", st); + } + + // ── lanes ──────────────────────────────────────────────────────────────────────────────── + + /** Fill {@code out} (a {@link Layouts#LANE_DESC}-shaped segment). */ + public static void laneDescribe(long handle, int laneId, MemorySegment out) { + crossed(); + int st; + try { + st = (int) LANE_DESCRIBE.invokeExact(handle, laneId, out); + } catch (Throwable t) { + throw wrap("lgj_lane_describe", t); + } + Status.check("lgj_lane_describe", st); + } + + // ── masks ──────────────────────────────────────────────────────────────────────────────── + + /** @param initial {@link Layouts#MASK_INIT_EMPTY} or {@link Layouts#MASK_INIT_ALL} */ + public static long maskCreate(long parent, int initial, MemorySegment outHandle) { + crossed(); + int st; + try { + st = (int) MASK_CREATE.invokeExact(parent, initial, outHandle); + } catch (Throwable t) { + throw wrap("lgj_mask_create", t); + } + Status.check("lgj_mask_create", st); + return outHandle.get(ValueLayout.JAVA_LONG, 0); + } + + public static void maskDescribe(long mask, MemorySegment out) { + crossed(); + int st; + try { + st = (int) MASK_DESCRIBE.invokeExact(mask, out); + } catch (Throwable t) { + throw wrap("lgj_mask_describe", t); + } + Status.check("lgj_mask_describe", st); + } + + /** {@code dst = a & b}. {@code dst} may alias {@code a} or {@code b}. */ + public static void maskAnd(long a, long b, long dst) { + crossed(); + int st; + try { + st = (int) MASK_AND.invokeExact(a, b, dst); + } catch (Throwable t) { + throw wrap("lgj_mask_and", t); + } + Status.check("lgj_mask_and", st); + } + + /** {@code dst = a | b}. */ + public static void maskOr(long a, long b, long dst) { + crossed(); + int st; + try { + st = (int) MASK_OR.invokeExact(a, b, dst); + } catch (Throwable t) { + throw wrap("lgj_mask_or", t); + } + Status.check("lgj_mask_or", st); + } + + public static long maskCount(long mask, MemorySegment outCount) { + crossed(); + int st; + try { + st = (int) MASK_COUNT.invokeExact(mask, outCount); + } catch (Throwable t) { + throw wrap("lgj_mask_count", t); + } + Status.check("lgj_mask_count", st); + return outCount.get(ValueLayout.JAVA_LONG, 0); + } + + // ── unfused predicates (benchmark / parity comparison only) ────────────────────────────── + + /** Overwrites {@code dstMask} with {@code lane == needle}. */ + public static void opEqU32(long res, int laneId, int needle, long dstMask) { + crossed(); + int st; + try { + st = (int) OP_EQ_U32.invokeExact(res, laneId, needle, dstMask); + } catch (Throwable t) { + throw wrap("lgj_op_eq_u32", t); + } + Status.check("lgj_op_eq_u32", st); + } + + /** Overwrites {@code dstMask} with {@code lane > threshold}, signed. */ + public static void opGtI32(long res, int laneId, int threshold, long dstMask) { + crossed(); + int st; + try { + st = (int) OP_GT_I32.invokeExact(res, laneId, threshold, dstMask); + } catch (Throwable t) { + throw wrap("lgj_op_gt_i32", t); + } + Status.check("lgj_op_gt_i32", st); + } + + // ── fused plan — N predicates, ONE crossing ────────────────────────────────────────────── + + /** + * Evaluate a whole predicate chain in one crossing and return the popcount. + * + *

This is the call that makes {@code .where(..).where(..).count()} cost one crossing + * regardless of predicate count or row count. + */ + public static long planEval(long res, MemorySegment ops, int nOps, long dstMask, + MemorySegment outCount) { + crossed(); + int st; + try { + st = (int) PLAN_EVAL.invokeExact(res, ops, nOps, dstMask, outCount); + } catch (Throwable t) { + throw wrap("lgj_plan_eval", t); + } + Status.check("lgj_plan_eval", st); + return outCount.get(ValueLayout.JAVA_LONG, 0); + } + + /** + * Identical semantics to {@link #planEval} but forced down the scalar reference path. + * + *

Exists only so SIMD-vs-scalar parity is falsifiable through the membrane, which + * is where the Java tests live. Not for production use. + */ + public static long planEvalScalar(long res, MemorySegment ops, int nOps, long dstMask, + MemorySegment outCount) { + crossed(); + int st; + try { + st = (int) PLAN_EVAL_SCALAR.invokeExact(res, ops, nOps, dstMask, outCount); + } catch (Throwable t) { + throw wrap("lgj_plan_eval_scalar", t); + } + Status.check("lgj_plan_eval_scalar", st); + return outCount.get(ValueLayout.JAVA_LONG, 0); + } + + // ── reduction ──────────────────────────────────────────────────────────────────────────── + + /** Sum an {@code I32} lane over set mask bits into a widened {@code i64}. */ + public static long reduceSumI32(long res, int laneId, long mask, MemorySegment outSum) { + crossed(); + int st; + try { + st = (int) REDUCE_SUM_I32.invokeExact(res, laneId, mask, outSum); + } catch (Throwable t) { + throw wrap("lgj_reduce_sum_i32", t); + } + Status.check("lgj_reduce_sum_i32", st); + return outSum.get(ValueLayout.JAVA_LONG, 0); + } + + private static LanceGraphException wrap(String symbol, Throwable t) { + if (t instanceof LanceGraphException e) { + return e; + } + return new LanceGraphException("the downcall to " + symbol + " itself failed", t); + } +} diff --git a/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Engine.java b/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Engine.java new file mode 100644 index 0000000..0a48c7c --- /dev/null +++ b/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Engine.java @@ -0,0 +1,270 @@ +package com.adaworldapi.lancegraph.internal.ffm; + +import com.adaworldapi.lancegraph.LanceGraphException; + +import java.lang.foreign.Arena; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.ValueLayout; +import java.util.List; + +/** + * The only place in the project where a plan is turned into bytes and pushed across the membrane. + * + *

Everything above this class speaks {@link PlanOp} and {@code long} handles; everything below + * speaks {@link MemorySegment}. Keeping the marshalling here is what lets the public API contain no + * FFM type at all. + * + *

Scratch memory

+ * + *

Out-parameters and the op array live in a per-thread scratch buffer allocated from an + * automatic arena, so a terminal operation performs no allocation in steady state. This + * matters for the benchmark: if each {@code count()} allocated an arena, the measurement would be + * reporting allocator behaviour rather than crossing cost. + * + *

Internal. + */ +public final class Engine { + + private Engine() {} + + /** Room for the widest out-parameter set any single call needs. */ + private static final long OUT_BYTES = 32; + + private static final class Scratch { + // ofAuto: freed when this Scratch becomes unreachable, i.e. when the thread dies. No + // explicit lifetime to get wrong, and no leak because the ThreadLocal holds exactly one. + private final Arena arena = Arena.ofAuto(); + private final MemorySegment out = arena.allocate(OUT_BYTES, 8); + private final MemorySegment info = arena.allocate(Layouts.RESOURCE_INFO); + private final MemorySegment desc = arena.allocate(Layouts.LANE_DESC); + private MemorySegment ops = arena.allocate(Layouts.OP_DESC, 8); + private long opsCapacity = 8; + + MemorySegment opsFor(int nOps) { + if (nOps > opsCapacity) { + long want = Math.max(nOps, opsCapacity * 2); + ops = arena.allocate(Layouts.OP_DESC, want); + opsCapacity = want; + } + return ops; + } + } + + private static final ThreadLocal SCRATCH = ThreadLocal.withInitial(Scratch::new); + + // ── lifecycle ──────────────────────────────────────────────────────────────────────────── + + /** Open the deterministic SoA fixture; returns the resource handle. */ + public static long openPattern(long nRows, long seed) { + Scratch s = SCRATCH.get(); + return Downcalls.patternOpen(nRows, seed, s.out); + } + + /** Create a selection over {@code parent}; returns the mask handle. */ + public static long createMask(long parent, boolean allSet) { + Scratch s = SCRATCH.get(); + return Downcalls.maskCreate(parent, + allSet ? Layouts.MASK_INIT_ALL : Layouts.MASK_INIT_EMPTY, s.out); + } + + public static void close(long handle) { + Downcalls.close(handle); + } + + /** Row count of a resource, read from {@code LgjResourceInfo}. */ + public static long rowCount(long handle) { + Scratch s = SCRATCH.get(); + Downcalls.resourceInfo(handle, s.info); + return (long) Layouts.INFO_N_ROWS.get(s.info, 0L); + } + + /** Liveness stamp of a resource. Java re-checks this before trusting a cached lane segment. */ + public static long epoch(long handle) { + Scratch s = SCRATCH.get(); + Downcalls.resourceInfo(handle, s.info); + return (long) Layouts.INFO_EPOCH.get(s.info, 0L); + } + + public static long maskCount(long mask) { + Scratch s = SCRATCH.get(); + return Downcalls.maskCount(mask, s.out); + } + + // ── evaluation ─────────────────────────────────────────────────────────────────────────── + + /** + * The production path: marshal the whole chain and cross once. + * + * @return popcount of the resulting selection + */ + public static long evaluateFused(long resource, List plan, long dstMask) { + MemorySegment ops = marshal(plan); + Scratch s = SCRATCH.get(); + return Downcalls.planEval(resource, ops, plan.size(), dstMask, s.out); + } + + /** + * Same semantics, forced down the scalar reference kernel. + * + *

Only for falsifying SIMD-vs-scalar parity from the Java side, which is where the tests + * live. Never the default. + */ + public static long evaluateScalar(long resource, List plan, long dstMask) { + MemorySegment ops = marshal(plan); + Scratch s = SCRATCH.get(); + return Downcalls.planEvalScalar(resource, ops, plan.size(), dstMask, s.out); + } + + /** + * The comparison path: one crossing per predicate plus one per combine plus one to count. + * + *

Retained only so the fused path can be benchmarked against something and so + * parity can be checked predicate-by-predicate. Using it in production would be the anti-JNI + * rule's exact failure mode, just with a coarser granularity than per element. + * + * @param scratchMask a second mask over the same parent, used to hold each intermediate + */ + public static long evaluateUnfused(long resource, List plan, long dstMask, + long scratchMask) { + if (plan.isEmpty()) { + throw new LanceGraphException("an unfused plan needs at least one predicate"); + } + for (int i = 0; i < plan.size(); i++) { + PlanOp p = plan.get(i); + // The first predicate writes straight into dst (each op *overwrites* its destination); + // every later one goes to scratch and is folded in. + long target = (i == 0) ? dstMask : scratchMask; + applyOne(resource, p, target); + if (i > 0) { + if (p.combine() == Layouts.COMBINE_OR) { + Downcalls.maskOr(dstMask, scratchMask, dstMask); + } else { + Downcalls.maskAnd(dstMask, scratchMask, dstMask); + } + } + } + return maskCount(dstMask); + } + + private static void applyOne(long resource, PlanOp p, long dst) { + switch (p.op()) { + case Layouts.OP_EQ_U32 -> Downcalls.opEqU32(resource, p.laneId(), (int) p.operand(), dst); + case Layouts.OP_GT_I32 -> Downcalls.opGtI32(resource, p.laneId(), (int) p.operand(), dst); + default -> throw new LanceGraphException( + "opcode " + p.op() + " has no unfused equivalent; the unfused path is a" + + " benchmark comparison only and is not required to cover every op"); + } + } + + /** Sum an {@code I32} lane over the set bits of {@code mask}, widened to 64 bits. */ + public static long sumI32(long resource, int laneId, long mask) { + Scratch s = SCRATCH.get(); + return Downcalls.reduceSumI32(resource, laneId, mask, s.out); + } + + private static MemorySegment marshal(List plan) { + int n = plan.size(); + if (n == 0) { + throw new LanceGraphException( + "an empty plan cannot be evaluated (ABI status EMPTY_PLAN). A View with no" + + " predicates selects every row; ask for the row count instead."); + } + MemorySegment ops = SCRATCH.get().opsFor(n); + long stride = Layouts.OP_DESC.byteSize(); + for (int i = 0; i < n; i++) { + PlanOp p = plan.get(i); + long base = i * stride; + Layouts.OP_OP.set(ops, base, p.op()); + Layouts.OP_LANE_ID.set(ops, base, p.laneId()); + Layouts.OP_OPERAND.set(ops, base, p.operand()); + Layouts.OP_COMBINE.set(ops, base, p.combine()); + // Reserved must be 0 — set explicitly rather than relying on the buffer being fresh, + // since the scratch is reused across calls. + Layouts.OP_RESERVED.set(ops, base, 0); + } + return ops; + } + + // ── lane access (in-process, zero crossings) ───────────────────────────────────────────── + + /** + * Describe a lane and hand back a bounded, read-only window onto it. + * + *

There is deliberately no {@code readElement} anywhere in the ABI: reading one row across + * the membrane would be the per-element crossing the whole design forbids. If Java wants + * element access it reads this segment directly, in-process, with no crossing at all. + * + *

The segment is valid until the owning resource is closed (lanes are allocated once and + * never reallocated, resized or moved — a hard ABI guarantee). The {@code epoch} returned + * alongside lets a caller detect a segment it is still holding after a close. + */ + public static LaneWindow describeLane(long resource, int laneId) { + Scratch s = SCRATCH.get(); + Downcalls.laneDescribe(resource, laneId, s.desc); + return windowOf(s.desc); + } + + /** As {@link #describeLane}, for the packed-bit words of a mask. */ + public static LaneWindow describeMask(long mask) { + Scratch s = SCRATCH.get(); + Downcalls.maskDescribe(mask, s.desc); + return windowOf(s.desc); + } + + private static LaneWindow windowOf(MemorySegment desc) { + long addr = (long) Layouts.LANE_ADDR.get(desc, 0L); + long lenElems = (long) Layouts.LANE_LEN_ELEMS.get(desc, 0L); + long byteLen = (long) Layouts.LANE_BYTE_LEN.get(desc, 0L); + long owner = (long) Layouts.LANE_OWNER.get(desc, 0L); + long epoch = (long) Layouts.LANE_EPOCH.get(desc, 0L); + int elemKind = (int) Layouts.LANE_ELEM_KIND.get(desc, 0L); + int elemBytes = (int) Layouts.LANE_ELEM_BYTES.get(desc, 0L); + int strideBytes = (int) Layouts.LANE_STRIDE_BYTES.get(desc, 0L); + int flags = (int) Layouts.LANE_FLAGS.get(desc, 0L); + + MemorySegment segment = MemorySegment.ofAddress(addr).reinterpret(byteLen); + return new LaneWindow(segment, lenElems, byteLen, owner, epoch, elemKind, elemBytes, + strideBytes, flags); + } + + /** + * A bounded view of native storage plus the liveness stamp needed to distrust it later. + * + *

Internal. This is the one type that carries a {@link MemorySegment}, and + * it must never appear in a public signature. + */ + public record LaneWindow( + MemorySegment segment, + long lengthElements, + long byteLength, + long owner, + long epoch, + int elemKind, + int elemBytes, + int strideBytes, + int flags) { + + public boolean isWritable() { + return (flags & Layouts.FLAG_WRITABLE) != 0; + } + + public boolean isContiguous() { + return (flags & Layouts.FLAG_CONTIGUOUS) != 0; + } + + /** Read one {@code i32} element, in-process. No membrane crossing occurs. */ + public int getI32(long index) { + return segment.get(ValueLayout.JAVA_INT, index * strideBytes); + } + + /** Read one {@code u32} element as an unsigned value widened to {@code long}. */ + public long getU32(long index) { + return Integer.toUnsignedLong(segment.get(ValueLayout.JAVA_INT, index * strideBytes)); + } + + /** Read one 64-bit element (an id, or a packed mask word). */ + public long getU64(long index) { + return segment.get(ValueLayout.JAVA_LONG, index * strideBytes); + } + } +} diff --git a/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Layouts.java b/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Layouts.java new file mode 100644 index 0000000..6afa2b5 --- /dev/null +++ b/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Layouts.java @@ -0,0 +1,256 @@ +package com.adaworldapi.lancegraph.internal.ffm; + +import java.lang.foreign.MemoryLayout; +import java.lang.foreign.MemoryLayout.PathElement; +import java.lang.foreign.StructLayout; +import java.lang.foreign.ValueLayout; +import java.lang.invoke.VarHandle; + +/** + * Java's independently compiled-in mirror of every {@code #[repr(C)]} struct in + * {@code docs/abi.md} §5. + * + *

This class is the header replacement's Java half. There is no {@code .h} file, no + * {@code cbindgen}, and no {@code jextract} anywhere in this project — a header is a text file that + * claims a layout and can drift silently. Instead the layouts below are written by hand + * from the normative ABI document, their sizes are derived via + * {@link MemoryLayout#byteSize()}, and {@link Abi} cross-checks those derived numbers against the + * sizes the compiled artifact reports about itself. Two independently-derived numbers are compared; + * a constant is never compared against itself. + * + *

Field offsets are the System V AMD64 aggregate rule (the same rule {@code #[repr(C)]} names), + * so every layout here is written with natural packing and asserted to match the byte sizes stated + * in the ABI document. No explicit padding member is required by any of these four structs — that + * is checked in {@link #SELF_CHECK}, not assumed. + * + *

Internal. Nothing in this class may appear in a public API signature. + */ +public final class Layouts { + + private Layouts() {} + + // ── ABI constants (docs/abi.md §2) ─────────────────────────────────────────────────────── + + /** {@code "LGJ_ABI\0"} read as a little-endian {@code u64}; doubles as the endianness probe. */ + public static final long LGJ_MAGIC = 0x4C_47_4A_5F_41_42_49_00L; + + /** Major version this Java build was compiled against. Must match the library exactly. */ + public static final int LGJ_ABI_MAJOR = 0; + + /** Minor version this Java build was compiled against. The library must be {@code >=} this. */ + public static final int LGJ_ABI_MINOR = 1; + + /** {@code endianness} value meaning little-endian. */ + public static final int LGJ_ENDIAN_LITTLE = 0; + + // ── Element kinds (docs/abi.md §5) — start at 1 so a zeroed struct is detectably invalid ── + + public static final int ELEM_U8 = 1; + public static final int ELEM_I8 = 2; + public static final int ELEM_U16 = 3; + public static final int ELEM_I16 = 4; + public static final int ELEM_U32 = 5; + public static final int ELEM_I32 = 6; + public static final int ELEM_U64 = 7; + public static final int ELEM_I64 = 8; + public static final int ELEM_F32 = 9; + public static final int ELEM_F64 = 10; + /** A {@code u64} of 64 packed row bits, LSB = lowest row index. */ + public static final int ELEM_MASK_WORD = 11; + + // ── Lane descriptor flags (docs/abi.md §5) ─────────────────────────────────────────────── + + public static final int FLAG_READABLE = 1 << 0; + public static final int FLAG_WRITABLE = 1 << 1; + public static final int FLAG_CONTIGUOUS = 1 << 2; + + // ── Resource kinds (docs/abi.md §5) ────────────────────────────────────────────────────── + + public static final int RESOURCE_PATTERN = 1; + public static final int RESOURCE_MASK = 2; + + // ── Mask initial fill (docs/abi.md §7) ─────────────────────────────────────────────────── + + public static final int MASK_INIT_EMPTY = 0; + public static final int MASK_INIT_ALL = 1; + + // ── Opcodes and combiners (docs/abi.md §5 `LgjOpCode`) ─────────────────────────────────── + // + // NOTE (doc gap, reported): abi.md §5 names the `op` field's type as `LgjOpCode` but never + // tabulates its numeric values, unlike LgjElemKind / status codes / simd backends which are all + // tabulated. The values below follow the document's own stated convention for enumerations that + // must be zero-invalid (element kinds "start at 1, so a zeroed struct is detectably invalid"), + // and were confirmed against the compiled artifact. Combiners are fixed by §5's own prose: + // "0 = AND (narrow), 1 = OR (widen)". + + /** {@code lane[i] == (u32) operand}. Requires a {@code U32} lane. */ + public static final int OP_EQ_U32 = 1; + + /** {@code lane[i] > (i32) operand}, signed. Requires an {@code I32} lane. */ + public static final int OP_GT_I32 = 2; + + /** Intersect into the accumulator — narrowing. */ + public static final int COMBINE_AND = 0; + + /** Union into the accumulator — widening. Deliberately unreachable from {@code View.where}. */ + public static final int COMBINE_OR = 1; + + // ── SIMD backend ids (docs/abi.md §5) ──────────────────────────────────────────────────── + + public static final int SIMD_SCALAR = 0; + public static final int SIMD_AVX2 = 1; + public static final int SIMD_AVX512 = 2; + public static final int SIMD_NEON = 3; + public static final int SIMD_WASM = 4; + + // ── LgjAbiManifest ─────────────────────────────────────────────────────────────────────── + + /** Length of the manifest's {@code simd_backend_name} byte array. */ + public static final int SIMD_NAME_BYTES = 32; + + /** Length of the manifest's {@code build_profile} byte array. */ + public static final int BUILD_PROFILE_BYTES = 16; + + public static final StructLayout MANIFEST = MemoryLayout.structLayout( + ValueLayout.JAVA_LONG.withName("magic"), + ValueLayout.JAVA_INT.withName("abi_major"), + ValueLayout.JAVA_INT.withName("abi_minor"), + ValueLayout.JAVA_INT.withName("size_of_manifest"), + ValueLayout.JAVA_INT.withName("size_of_lane_desc"), + ValueLayout.JAVA_INT.withName("size_of_op_desc"), + ValueLayout.JAVA_INT.withName("size_of_resource_info"), + ValueLayout.JAVA_INT.withName("align_of_lane_desc"), + ValueLayout.JAVA_INT.withName("align_of_op_desc"), + ValueLayout.JAVA_INT.withName("align_of_resource_info"), + ValueLayout.JAVA_INT.withName("pointer_bytes"), + ValueLayout.JAVA_INT.withName("endianness"), + ValueLayout.JAVA_INT.withName("simd_backend"), + MemoryLayout.sequenceLayout(SIMD_NAME_BYTES, ValueLayout.JAVA_BYTE) + .withName("simd_backend_name"), + MemoryLayout.sequenceLayout(BUILD_PROFILE_BYTES, ValueLayout.JAVA_BYTE) + .withName("build_profile")) + .withName("LgjAbiManifest"); + + // The manifest is read by OFFSET rather than through a struct VarHandle, and that is a + // requirement rather than a style choice. A VarHandle derived from a struct layout bounds-checks + // the *whole enclosing layout* against the segment, so it cannot be used to read a deliberate + // prefix — and reading a prefix first is exactly how the size disagreement is detected safely + // (see Abi#readAndVerifyManifest). The offsets are still derived from the layout, so this stays + // an independent derivation and never a hand-counted constant. + public static final long OFF_MAGIC = off("magic"); + public static final long OFF_ABI_MAJOR = off("abi_major"); + public static final long OFF_ABI_MINOR = off("abi_minor"); + public static final long OFF_SIZE_OF_MANIFEST = off("size_of_manifest"); + public static final long OFF_SIZE_OF_LANE_DESC = off("size_of_lane_desc"); + public static final long OFF_SIZE_OF_OP_DESC = off("size_of_op_desc"); + public static final long OFF_SIZE_OF_RESOURCE_INFO = off("size_of_resource_info"); + public static final long OFF_ALIGN_OF_LANE_DESC = off("align_of_lane_desc"); + public static final long OFF_ALIGN_OF_OP_DESC = off("align_of_op_desc"); + public static final long OFF_ALIGN_OF_RESOURCE_INFO = off("align_of_resource_info"); + public static final long OFF_POINTER_BYTES = off("pointer_bytes"); + public static final long OFF_ENDIANNESS = off("endianness"); + public static final long OFF_SIMD_BACKEND = off("simd_backend"); + public static final long OFF_SIMD_BACKEND_NAME = off("simd_backend_name"); + public static final long OFF_BUILD_PROFILE = off("build_profile"); + + private static long off(String name) { + return MANIFEST.byteOffset(PathElement.groupElement(name)); + } + + // ── LgjLaneDesc (56 bytes, align 8) ────────────────────────────────────────────────────── + + public static final StructLayout LANE_DESC = MemoryLayout.structLayout( + ValueLayout.JAVA_LONG.withName("addr"), + ValueLayout.JAVA_LONG.withName("len_elems"), + ValueLayout.JAVA_LONG.withName("byte_len"), + ValueLayout.JAVA_LONG.withName("owner"), + ValueLayout.JAVA_LONG.withName("epoch"), + ValueLayout.JAVA_INT.withName("elem_kind"), + ValueLayout.JAVA_INT.withName("elem_bytes"), + ValueLayout.JAVA_INT.withName("stride_bytes"), + ValueLayout.JAVA_INT.withName("flags")) + .withName("LgjLaneDesc"); + + public static final VarHandle LANE_ADDR = field(LANE_DESC, "addr"); + public static final VarHandle LANE_LEN_ELEMS = field(LANE_DESC, "len_elems"); + public static final VarHandle LANE_BYTE_LEN = field(LANE_DESC, "byte_len"); + public static final VarHandle LANE_OWNER = field(LANE_DESC, "owner"); + public static final VarHandle LANE_EPOCH = field(LANE_DESC, "epoch"); + public static final VarHandle LANE_ELEM_KIND = field(LANE_DESC, "elem_kind"); + public static final VarHandle LANE_ELEM_BYTES = field(LANE_DESC, "elem_bytes"); + public static final VarHandle LANE_STRIDE_BYTES = field(LANE_DESC, "stride_bytes"); + public static final VarHandle LANE_FLAGS = field(LANE_DESC, "flags"); + + // ── LgjResourceInfo (32 bytes) ─────────────────────────────────────────────────────────── + + public static final StructLayout RESOURCE_INFO = MemoryLayout.structLayout( + ValueLayout.JAVA_INT.withName("kind"), + ValueLayout.JAVA_INT.withName("lane_count"), + ValueLayout.JAVA_LONG.withName("n_rows"), + ValueLayout.JAVA_LONG.withName("epoch"), + ValueLayout.JAVA_LONG.withName("parent")) + .withName("LgjResourceInfo"); + + public static final VarHandle INFO_KIND = field(RESOURCE_INFO, "kind"); + public static final VarHandle INFO_LANE_COUNT = field(RESOURCE_INFO, "lane_count"); + public static final VarHandle INFO_N_ROWS = field(RESOURCE_INFO, "n_rows"); + public static final VarHandle INFO_EPOCH = field(RESOURCE_INFO, "epoch"); + public static final VarHandle INFO_PARENT = field(RESOURCE_INFO, "parent"); + + // ── LgjOpDesc (24 bytes, align 8) ──────────────────────────────────────────────────────── + + public static final StructLayout OP_DESC = MemoryLayout.structLayout( + ValueLayout.JAVA_INT.withName("op"), + ValueLayout.JAVA_INT.withName("lane_id"), + ValueLayout.JAVA_LONG.withName("operand"), + ValueLayout.JAVA_INT.withName("combine"), + ValueLayout.JAVA_INT.withName("_reserved")) + .withName("LgjOpDesc"); + + public static final VarHandle OP_OP = field(OP_DESC, "op"); + public static final VarHandle OP_LANE_ID = field(OP_DESC, "lane_id"); + public static final VarHandle OP_OPERAND = field(OP_DESC, "operand"); + public static final VarHandle OP_COMBINE = field(OP_DESC, "combine"); + public static final VarHandle OP_RESERVED = field(OP_DESC, "_reserved"); + + /** + * Compile-time-ish self check: the byte sizes the ABI document states in prose, checked against + * the sizes these layouts actually derive. If a layout is edited wrongly this fails at class + * initialisation, before any native call, and names the struct that disagreed. + * + *

This does not replace {@link Abi}'s manifest cross-check — it catches a Java-side typo; + * the manifest catches a Java-vs-Rust divergence. + */ + public static final boolean SELF_CHECK = selfCheck(); + + private static boolean selfCheck() { + expect("LgjLaneDesc size", 56, LANE_DESC.byteSize()); + expect("LgjLaneDesc align", 8, LANE_DESC.byteAlignment()); + expect("LgjResourceInfo size", 32, RESOURCE_INFO.byteSize()); + expect("LgjResourceInfo align", 8, RESOURCE_INFO.byteAlignment()); + expect("LgjOpDesc size", 24, OP_DESC.byteSize()); + expect("LgjOpDesc align", 8, OP_DESC.byteAlignment()); + // The manifest's size is not stated in prose; it is stated by the artifact itself and + // cross-checked in Abi. What is checked here is that the two trailing byte arrays land + // where the repr(C) rule puts them, which is the only place a hand-written layout could + // silently disagree. + expect("LgjAbiManifest simd_backend_name offset", 56, OFF_SIMD_BACKEND_NAME); + expect("LgjAbiManifest build_profile offset", 88, OFF_BUILD_PROFILE); + expect("LgjAbiManifest align", 8, MANIFEST.byteAlignment()); + return true; + } + + private static void expect(String what, long expected, long actual) { + if (expected != actual) { + throw new ExceptionInInitializerError( + "Layouts self-check failed: " + what + " expected " + expected + + " but this build's MemoryLayout derives " + actual); + } + } + + private static VarHandle field(StructLayout layout, String name) { + // Since JDK 22 a struct field VarHandle takes (MemorySegment, long base) coordinates, so + // every access site passes an explicit 0L base for a single struct. + return layout.varHandle(PathElement.groupElement(name)); + } +} diff --git a/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/PlanOp.java b/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/PlanOp.java new file mode 100644 index 0000000..0cdcb96 --- /dev/null +++ b/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/PlanOp.java @@ -0,0 +1,26 @@ +package com.adaworldapi.lancegraph.internal.ffm; + +/** + * One entry of a fused plan — the Java-side mirror of {@code LgjOpDesc} (docs/abi.md §5) before it + * is marshalled. + * + *

A plan entry is data, not a lambda. That is the load-bearing choice: a + * predicate expressed as a {@code java.util.function.Predicate} would force a row object per + * element and an upcall per element — the JNI anti-pattern in disguise. Expressed as a descriptor, + * N predicates marshal into one contiguous array and cross once. + * + *

Internal. The public {@code Predicate} type wraps one of these and never + * shows it. + * + * @param op opcode, e.g. {@link Layouts#OP_EQ_U32} + * @param laneId which lane the predicate reads + * @param operand needle or threshold, sign-extended to 64 bits + * @param combine {@link Layouts#COMBINE_AND} (narrow) or {@link Layouts#COMBINE_OR} (widen) + */ +public record PlanOp(int op, int laneId, long operand, int combine) { + + /** A narrowing (AND-combined) op — the only kind reachable from {@code View.where}. */ + public static PlanOp narrowing(int op, int laneId, long operand) { + return new PlanOp(op, laneId, operand, Layouts.COMBINE_AND); + } +} diff --git a/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Status.java b/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Status.java new file mode 100644 index 0000000..71428f6 --- /dev/null +++ b/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Status.java @@ -0,0 +1,99 @@ +package com.adaworldapi.lancegraph.internal.ffm; + +import com.adaworldapi.lancegraph.ClosedResourceException; +import com.adaworldapi.lancegraph.LanceGraphException; +import com.adaworldapi.lancegraph.NativeCallException; + +/** + * The ABI's status codes (docs/abi.md §3), and the mapping from a negative status to a specific + * Java exception type. + * + *

Every membrane function returns {@code i32}: {@code 0} is success and all failures are + * negative. There are no error strings across the boundary and no {@code errno} dependence, so + * this table is the whole error vocabulary. + * + *

Internal. Callers see the mapped exception, never this enum. + */ +public enum Status { + + OK(0, "success"), + NULL_ARGUMENT(-1, "a required out-pointer was null"), + INVALID_HANDLE(-2, "handle malformed, closed, or generation-stale"), + WRONG_RESOURCE_KIND(-3, "a handle of the wrong resource kind was passed"), + INVALID_LANE(-4, "lane_id out of range for this resource"), + LANE_KIND_MISMATCH(-5, "the operation's element type does not match the lane's"), + MASK_LENGTH_MISMATCH(-6, "mask row-count does not match resource row-count"), + PARENT_CLOSED(-7, "the mask outlived its parent resource"), + VERSION_MISMATCH(-8, "caller's ABI version is incompatible"), + LENGTH_OVERFLOW(-9, "requested size overflows the allocation limit"), + UNKNOWN_OPCODE(-10, "the plan contained an opcode this build does not implement"), + EMPTY_PLAN(-11, "a plan with zero ops was submitted"), + ALLOCATION_FAILED(-12, "the allocator refused"), + READ_ONLY(-13, "write attempted against a read-only lane"), + /** + * Not tabulated in docs/abi.md §3 (reported as a doc gap) but required by §9: a panic is caught + * at the boundary and becomes a negative status rather than unwinding into JVM frames, which + * would be undefined behaviour. This is that status. + */ + PANIC(-99, "a panic was caught at the membrane and converted to a status"); + + private final int code; + private final String meaning; + + Status(int code, String meaning) { + this.code = code; + this.meaning = meaning; + } + + public int code() { + return code; + } + + public String meaning() { + return meaning; + } + + /** Resolve a raw status, or {@code null} if the library returned a code this build knows not. */ + public static Status of(int code) { + for (Status s : values()) { + if (s.code == code) { + return s; + } + } + return null; + } + + /** + * Throw the most specific exception for a failing status. Never returns. + * + * @param function the ABI symbol that failed, for the message + * @param code the raw negative status + */ + public static LanceGraphException toException(String function, int code) { + Status s = of(code); + if (s == null) { + return new NativeCallException(function, code, "UNKNOWN_STATUS", + "this Java build does not know this status code; the library is likely newer"); + } + return switch (s) { + // Use-after-close and orphaned-child are the same story to a Java caller: the thing you + // are holding no longer refers to anything live. + case INVALID_HANDLE -> new ClosedResourceException( + function + " was called with a handle that is closed, stale, or fabricated" + + " (ABI status INVALID_HANDLE = -2). The native registry rejected it" + + " by generation check; no freed memory was dereferenced."); + case PARENT_CLOSED -> new ClosedResourceException( + function + " was called on a mask whose parent resource is closed" + + " (ABI status PARENT_CLOSED = -7). A child may exist after its" + + " parent closes, but it can never work."); + default -> new NativeCallException(function, s.code, s.name(), s.meaning); + }; + } + + /** Throw if {@code code} is not {@link #OK}. */ + public static void check(String function, int code) { + if (code != 0) { + throw toException(function, code); + } + } +} diff --git a/java/src/test/java/com/adaworldapi/lancegraph/AbiContractTest.java b/java/src/test/java/com/adaworldapi/lancegraph/AbiContractTest.java new file mode 100644 index 0000000..deebc89 --- /dev/null +++ b/java/src/test/java/com/adaworldapi/lancegraph/AbiContractTest.java @@ -0,0 +1,127 @@ +package com.adaworldapi.lancegraph; + +/** + * Checks that the header replacement actually replaced a header. + * + *

The claim (docs/abi.md §0): no {@code .h} exists, and instead the compiled artifact describes + * itself at runtime while Java cross-checks that description against its own independently derived + * layout constants. If the cross-check were absent, or advisory, or comparing a constant against + * itself, the design would be a header with extra steps. + * + *

So this test establishes three things: + * + *

    + *
  1. The manifest was read and the artifact identifies itself coherently. + *
  2. The version rule is the documented one — major exact, minor at least. + *
  3. The check is strict: a deliberately impossible expectation is rejected, which is + * what proves the mechanism can fail at all. + *
+ * + *

Point three is the one worth having. A verification routine that has never been observed to + * reject anything is indistinguishable from one that returns {@code true}. + */ +public final class AbiContractTest { + + private AbiContractTest() {} + + public static void main(String[] args) { + System.out.println("AbiContractTest"); + if (!NativeRuntime.isAvailable()) { + System.exit(Checks.reportUnavailable("AbiContractTest")); + } + Checks c = new Checks("AbiContractTest"); + run(c); + System.exit(c.report()); + } + + public static void run(Checks c) { + c.section("the artifact described itself and Java agreed"); + c.note(NativeRuntime.describe()); + c.eq("abi major", 0, NativeRuntime.abiMajor()); + c.that("abi minor is at least what this build expects", NativeRuntime.abiMinor() >= 1); + c.that("a SIMD backend was reported", !NativeRuntime.simdBackend().isBlank()); + c.that("a build profile was reported", !NativeRuntime.buildProfile().isBlank()); + + if (!"release".equals(NativeRuntime.buildProfile())) { + c.note("WARNING: this is a " + NativeRuntime.buildProfile() + " build." + + " Correctness results are valid; any timing taken against it is not."); + } + + c.section("the loaded library is a real path, not a guess"); + c.that("the library path exists", + java.nio.file.Files.isRegularFile(java.nio.file.Path.of(NativeRuntime.libraryPath()))); + + c.section("the verification is strict -- it can and does reject"); + // A verification routine never observed to reject anything is indistinguishable from one + // that returns true, so drive the rejection branch on purpose. + // + // The probe is aimed at a real, perfectly valid shared library that simply is not ours. + // That is the precise case worth proving: the artifact loads, the linker is happy, and the + // check still refuses because the library cannot describe itself. Aiming at a non-library + // file instead would only prove that dlopen rejects garbage, which is the operating + // system's achievement rather than this design's. + String stranger = firstExisting( + "/lib/x86_64-linux-gnu/libz.so.1", + "/usr/lib/x86_64-linux-gnu/libz.so.1", + "/lib/x86_64-linux-gnu/libm.so.6"); + if (stranger == null) { + c.note("no unrelated system library found to probe with; skipping the rejection arm"); + } else { + c.throwsUp("a valid library that cannot describe itself is refused, not called into", + AbiMismatchException.class, + () -> ProbeLoader.loadInSeparateExpectation(stranger)); + c.note("probed " + stranger + ": it loaded fine and was still rejected," + + " because it exports no lgj_abi_manifest"); + } + + c.throwsUp("a path that does not exist is reported clearly", + NativeLibraryNotFoundException.class, + () -> ProbeLoader.loadInSeparateExpectation("/nonexistent/liblgj_abi.so")); + + c.section("no C, and nothing that would need one"); + c.note("this whole surface was reached with javac and cargo only:" + + " no .h, no cbindgen, no jextract, no JNI, no C toolchain"); + } + + private static String firstExisting(String... candidates) { + for (String candidate : candidates) { + if (java.nio.file.Files.isRegularFile(java.nio.file.Path.of(candidate))) { + return candidate; + } + } + return null; + } + + /** + * A deliberately minimal re-implementation of the load step, used only to observe the failure + * path. + * + *

{@link com.adaworldapi.lancegraph.internal.ffm.Abi} resolves its library once into static + * finals, which is right for production and useless for testing rejection. Rather than making + * the real loader re-entrant purely for a test — which would add a code path nothing else uses + * — this probe performs the same two steps (locate, then look up the manifest symbol) against + * an arbitrary path. + */ + private static final class ProbeLoader { + + static void loadInSeparateExpectation(String path) { + java.nio.file.Path p = java.nio.file.Path.of(path); + if (!java.nio.file.Files.isRegularFile(p)) { + throw new NativeLibraryNotFoundException("no such library: " + p); + } + try (java.lang.foreign.Arena arena = java.lang.foreign.Arena.ofConfined()) { + var lookup = java.lang.foreign.SymbolLookup.libraryLookup(p, arena); + if (lookup.find("lgj_abi_manifest").isEmpty()) { + throw new AbiMismatchException( + "the library at " + p + " exports no lgj_abi_manifest symbol"); + } + throw new AbiMismatchException( + "unexpected: " + p + " exports lgj_abi_manifest"); + } catch (IllegalArgumentException e) { + // The linker refused the file outright — it is not a shared object at all. + throw new NativeLibraryNotFoundException( + "the file at " + p + " is not a loadable shared object", e); + } + } + } +} diff --git a/java/src/test/java/com/adaworldapi/lancegraph/AllTests.java b/java/src/test/java/com/adaworldapi/lancegraph/AllTests.java new file mode 100644 index 0000000..70c22eb --- /dev/null +++ b/java/src/test/java/com/adaworldapi/lancegraph/AllTests.java @@ -0,0 +1,82 @@ +package com.adaworldapi.lancegraph; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Consumer; + +/** + * Runs every suite in one JVM and exits non-zero if anything failed. + * + *

Exit codes are meaningful so a build script can tell the three outcomes apart: + * {@code 0} all passed, {@code 1} something failed, {@code 2} the native artifact is not available + * so nothing was run. A missing library reported as a failure would send whoever reads the log + * looking for a bug that is not there. + */ +public final class AllTests { + + private AllTests() {} + + public static void main(String[] args) { + Map> suites = new LinkedHashMap<>(); + suites.put("ApiSurfaceTest", ApiSurfaceTest::run); + suites.put("AbiContractTest", AbiContractTest::run); + suites.put("SmokeTest", SmokeTest::run); + suites.put("FixtureParityTest", FixtureParityTest::run); + suites.put("FusionParityTest", FusionParityTest::run); + suites.put("LazinessTest", LazinessTest::run); + suites.put("NarrowingTest", NarrowingTest::run); + suites.put("LifetimeTest", LifetimeTest::run); + + if (!NativeRuntime.isAvailable()) { + // ApiSurfaceTest needs no native library — the API's shape is a compile-time property — + // so run it anyway before reporting the skip. It is exactly the check most worth having + // before the artifact exists. + System.out.println(); + System.out.println("=== ApiSurfaceTest ==="); + Checks shape = new Checks("ApiSurfaceTest"); + ApiSurfaceTest.run(shape); + int shapeCode = shape.report(); + int skipCode = Checks.reportUnavailable("AllTests"); + System.exit(shapeCode != 0 ? shapeCode : skipCode); + } + + int totalPassed = 0; + int totalFailed = 0; + StringBuilder summary = new StringBuilder(); + + for (Map.Entry> e : suites.entrySet()) { + System.out.println(); + System.out.println("=== " + e.getKey() + " ==="); + Checks c = new Checks(e.getKey()); + try { + e.getValue().accept(c); + } catch (Throwable t) { + // A suite that blows up mid-way must not take the run down silently: report it as a + // failure of that suite and carry on, so one broken area does not hide the others. + System.out.println(" FAIL suite threw " + t); + t.printStackTrace(System.out); + totalFailed++; + summary.append(String.format(" %-20s THREW %s%n", e.getKey(), t)); + continue; + } + c.report(); + totalPassed += c.passedCount(); + totalFailed += c.failedCount(); + summary.append(String.format(" %-20s %3d passed, %d failed%n", + e.getKey(), c.passedCount(), c.failedCount())); + } + + System.out.println(); + System.out.println("=== summary ==="); + System.out.print(summary); + System.out.println(); + System.out.println(" " + NativeRuntime.describe()); + System.out.println(); + if (totalFailed == 0) { + System.out.println(" ALL PASSED (" + totalPassed + " checks)"); + System.exit(0); + } + System.out.println(" " + totalFailed + " FAILED, " + totalPassed + " passed"); + System.exit(1); + } +} diff --git a/java/src/test/java/com/adaworldapi/lancegraph/ApiSurfaceTest.java b/java/src/test/java/com/adaworldapi/lancegraph/ApiSurfaceTest.java new file mode 100644 index 0000000..b541baa --- /dev/null +++ b/java/src/test/java/com/adaworldapi/lancegraph/ApiSurfaceTest.java @@ -0,0 +1,181 @@ +package com.adaworldapi.lancegraph; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Executable; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +/** + * Enforces the absolute API rule by reflection, so it cannot rot. + * + *

The rule: a normal Java consumer must never need to know about + * {@code MemorySegment}, {@code Arena}, {@code Linker}, {@code FunctionDescriptor}, + * {@code MethodHandle}, native addresses, SoA layout, masks-as-u64-words, lane ids, opcodes, or + * which SIMD backend ran. Those are implementation physics and they live in exactly one + * package. + * + *

A rule like that is normally a paragraph in a design document, which is to say it is normally + * broken within a year by someone who never read the paragraph. Here it is a test: every public + * member of every public type in the consumer package is inspected, and a single FFM type anywhere + * in a signature fails the build. + * + *

It is checked reflectively rather than by grepping source, because the question is about the + * compiled surface — a return type inherited from a superclass or introduced by a bridge + * method is just as much a leak as one written by hand, and a grep would not see it. + */ +public final class ApiSurfaceTest { + + private ApiSurfaceTest() {} + + /** Package prefixes that no public consumer-facing signature may mention. */ + private static final String[] FORBIDDEN = { + "java.lang.foreign.", // MemorySegment, Arena, Linker, MemoryLayout, ValueLayout, ... + "java.lang.invoke.", // MethodHandle, VarHandle + "com.adaworldapi.lancegraph.internal.", // our own membrane, including PlanOp and Layouts + }; + + /** The package a consumer imports. */ + private static final String PUBLIC_PACKAGE = "com.adaworldapi.lancegraph"; + + public static void main(String[] args) { + System.out.println("ApiSurfaceTest"); + Checks c = new Checks("ApiSurfaceTest"); + run(c); + System.exit(c.report()); + } + + /** + * Note: this suite needs no native library. The API's shape is a compile-time property, so it + * is checkable before the artifact exists — which is also when it is most useful. + */ + public static void run(Checks c) { + List> types = publicTypes(c); + if (types.isEmpty()) { + c.that("public types were discovered (if this fails, the classpath is not a directory" + + " and this suite cannot scan it)", false); + return; + } + + c.section("scanning the consumer package"); + c.note("inspecting " + types.size() + " public types in " + PUBLIC_PACKAGE); + c.that("the scan found the types the API is actually made of", + types.stream().anyMatch(t -> t.getSimpleName().equals("NativePattern")) + && types.stream().anyMatch(t -> t.getSimpleName().equals("View")) + && types.stream().anyMatch(t -> t.getSimpleName().equals("Pattern"))); + + c.section("no FFM type, no membrane type, in any public signature"); + List leaks = new ArrayList<>(); + for (Class type : types) { + leaks.addAll(leaksIn(type)); + } + if (leaks.isEmpty()) { + c.that("every public member is free of " + + String.join(", ", FORBIDDEN), true); + } else { + for (String leak : leaks) { + c.that("LEAK: " + leak, false); + } + } + + c.section("the escape hatch is named, not incidental"); + // Raw native access must require deliberately reaching into an internal package. It must + // never be something ordinary composition hands you. + c.that("the membrane package is not exported through any public type", + leaks.isEmpty()); + c.note("raw MemorySegment access exists only via" + + " com.adaworldapi.lancegraph.internal.ffm.Engine, which a consumer has to name" + + " explicitly and which is documented as internal"); + + c.section("the vocabulary a consumer actually needs is small"); + c.note("open a resource, take a view, add conditions, ask for a number:" + + " NativePattern, View, Predicate, Pattern's fields, and a count"); + } + + private static List leaksIn(Class type) { + List leaks = new ArrayList<>(); + + for (Method m : type.getMethods()) { + if (!isPublicApi(m) || m.getDeclaringClass() == Object.class) { + continue; + } + check(leaks, type, "method " + m.getName() + " return", m.getReturnType()); + for (Class p : m.getParameterTypes()) { + check(leaks, type, "method " + m.getName() + " parameter", p); + } + } + + for (Constructor ctor : type.getConstructors()) { + if (!isPublicApi(ctor)) { + continue; + } + for (Class p : ctor.getParameterTypes()) { + check(leaks, type, "constructor parameter", p); + } + } + + for (Field f : type.getFields()) { + if (!Modifier.isPublic(f.getModifiers())) { + continue; + } + check(leaks, type, "field " + f.getName(), f.getType()); + } + + return leaks; + } + + private static boolean isPublicApi(Executable e) { + return Modifier.isPublic(e.getModifiers()); + } + + private static void check(List leaks, Class owner, String where, Class t) { + Class component = t; + while (component.isArray()) { + component = component.getComponentType(); + } + String name = component.getName(); + for (String forbidden : FORBIDDEN) { + if (name.startsWith(forbidden)) { + leaks.add(owner.getSimpleName() + "." + where + " is " + name); + } + } + } + + /** Enumerate public types by listing the compiled package directory on the classpath. */ + private static List> publicTypes(Checks c) { + List> found = new ArrayList<>(); + try { + Path root = Path.of(ApiSurfaceTest.class.getProtectionDomain() + .getCodeSource().getLocation().toURI()); + Path pkg = root.resolve(PUBLIC_PACKAGE.replace('.', '/')); + if (!Files.isDirectory(pkg)) { + return found; + } + try (Stream files = Files.list(pkg)) { + for (Path p : files.toList()) { + String file = p.getFileName().toString(); + if (!file.endsWith(".class") || file.contains("$")) { + continue; + } + String simple = file.substring(0, file.length() - ".class".length()); + Class t = Class.forName(PUBLIC_PACKAGE + "." + simple); + // Skip the test classes themselves; they live in the same package by design so + // that they can exercise package-private seams, but they are not the API. + if (Modifier.isPublic(t.getModifiers()) && !simple.endsWith("Test") + && !simple.equals("Checks") && !simple.equals("AllTests")) { + found.add(t); + } + } + } + } catch (Exception e) { + c.note("could not scan the classpath: " + e); + } + found.sort(java.util.Comparator.comparing(Class::getSimpleName)); + return found; + } +} diff --git a/java/src/test/java/com/adaworldapi/lancegraph/Checks.java b/java/src/test/java/com/adaworldapi/lancegraph/Checks.java new file mode 100644 index 0000000..6c25665 --- /dev/null +++ b/java/src/test/java/com/adaworldapi/lancegraph/Checks.java @@ -0,0 +1,155 @@ +package com.adaworldapi.lancegraph; + +import java.util.ArrayList; +import java.util.List; + +/** + * A self-contained assertion harness. + * + *

There is no JUnit here because there are no downloaded dependencies anywhere in this project — + * {@code javac} and {@code java} are the entire Java toolchain, exactly as {@code cargo} is the + * entire Rust one. What a test framework would give us is a runner and readable failures; that is + * about eighty lines, and they are these. + * + *

Failures are loud and specific by design: every check carries a sentence saying what was + * being established, and every comparison prints both numbers. A failure that only says + * {@code expected true} costs more to diagnose than it saved to write. + */ +public final class Checks { + + private final String suite; + private final List failures = new ArrayList<>(); + private int passed; + private String section = ""; + + public Checks(String suite) { + this.suite = suite; + } + + /** Group the following checks under a heading, printed once. */ + public void section(String name) { + this.section = name; + System.out.println(" " + name); + } + + /** Note something worth seeing in the log that is not itself a check. */ + public void note(String message) { + System.out.println(" - " + message); + } + + public void that(String what, boolean condition) { + if (condition) { + pass(what); + } else { + fail(what, "condition was false"); + } + } + + public void eq(String what, long expected, long actual) { + if (expected == actual) { + pass(what + " (= " + actual + ")"); + } else { + fail(what, "expected " + expected + " but was " + actual + + " (difference " + (actual - expected) + ")"); + } + } + + public void eq(String what, Object expected, Object actual) { + if (java.util.Objects.equals(expected, actual)) { + pass(what + " (= " + actual + ")"); + } else { + fail(what, "expected <" + expected + "> but was <" + actual + ">"); + } + } + + public void notEq(String what, long unexpected, long actual) { + if (unexpected != actual) { + pass(what + " (" + actual + " != " + unexpected + ")"); + } else { + fail(what, "expected anything other than " + unexpected + ", got exactly that"); + } + } + + public void atMost(String what, long limit, long actual) { + if (actual <= limit) { + pass(what + " (" + actual + " <= " + limit + ")"); + } else { + fail(what, "expected at most " + limit + " but was " + actual); + } + } + + /** + * Assert that {@code body} throws {@code expected}. + * + *

A falsifier that merely expects "some exception" would pass on a + * {@code NullPointerException} from a typo, so the type is required and the actual type is + * printed when it differs. + */ + public void throwsUp(String what, Class expected, Runnable body) { + try { + body.run(); + } catch (Throwable t) { + if (expected.isInstance(t)) { + pass(what + " (threw " + t.getClass().getSimpleName() + ")"); + } else { + fail(what, "expected " + expected.getSimpleName() + " but threw " + + t.getClass().getName() + ": " + t.getMessage()); + } + return; + } + fail(what, "expected " + expected.getSimpleName() + " but nothing was thrown"); + } + + private void pass(String what) { + passed++; + System.out.println(" ok " + what); + } + + private void fail(String what, String detail) { + String line = (section.isEmpty() ? "" : section + " / ") + what + "\n " + detail; + failures.add(line); + System.out.println(" FAIL " + what + "\n " + detail); + } + + public boolean anyFailed() { + return !failures.isEmpty(); + } + + public int passedCount() { + return passed; + } + + public int failedCount() { + return failures.size(); + } + + /** Print the summary and return the process exit code this suite implies. */ + public int report() { + System.out.println(); + if (failures.isEmpty()) { + System.out.println(" " + suite + ": " + passed + " checks passed"); + return 0; + } + System.out.println(" " + suite + ": " + failures.size() + " FAILED, " + passed + " passed"); + for (String f : failures) { + System.out.println(" x " + f); + } + return 1; + } + + /** + * Report that the native artifact is missing, clearly enough that nobody mistakes it for a + * test failure. + * + * @return the exit code to use + */ + public static int reportUnavailable(String suite) { + System.out.println(); + System.out.println(" " + suite + ": SKIPPED - the native library is not available."); + System.out.println(" " + NativeRuntime.unavailableReason().getMessage()); + System.out.println(); + System.out.println(" Build it first: cargo build --release (in native/lgj-abi)"); + System.out.println(" Or point at it: -Dlgj.library=/path/to/liblgj_abi.so"); + return 2; + } +} diff --git a/java/src/test/java/com/adaworldapi/lancegraph/FixtureParityTest.java b/java/src/test/java/com/adaworldapi/lancegraph/FixtureParityTest.java new file mode 100644 index 0000000..f5f39fa --- /dev/null +++ b/java/src/test/java/com/adaworldapi/lancegraph/FixtureParityTest.java @@ -0,0 +1,168 @@ +package com.adaworldapi.lancegraph; + +/** + * A genuine cross-language check: Java recomputes the expected answer from the normative fixture + * description and compares it to what the native kernels returned. + * + *

Most of the other tests establish properties — the count never grows, the paths + * agree, nothing crosses when nothing should. Properties can all hold while every number is wrong + * together. This test is the one that pins the actual values, and it does so without shipping a + * data file: the fixture is generated deterministically from a seed, so Java can transcribe the + * generator and derive the same answers independently. + * + *

Independence is what gives it force. Java runs its own SplitMix64 over its own {@code long} + * arithmetic and counts with an ordinary loop; Rust runs vectorised kernels over lanes Java never + * sees. Agreement across those two is evidence; agreement between a kernel and a stored expectation + * would only be evidence that nobody changed the file. + * + *

The generator is checked against its own published test vector first, so a failure downstream + * points at the kernels rather than at this transcription. + */ +public final class FixtureParityTest { + + private FixtureParityTest() {} + + public static void main(String[] args) { + System.out.println("FixtureParityTest"); + if (!NativeRuntime.isAvailable()) { + System.exit(Checks.reportUnavailable("FixtureParityTest")); + } + Checks c = new Checks("FixtureParityTest"); + run(c); + System.exit(c.report()); + } + + /** + * SplitMix64, transcribed from the normative description of the fixture generator. + * + *

Every constant and shift is the published reference. All arithmetic is wrapping, which is + * what Java's {@code long} does natively — so this is a transcription, not a port, and there is + * no place for an off-by-one interpretation to hide. + */ + private static final class SplitMix64 { + private long state; + + SplitMix64(long seed) { + this.state = seed; // no warm-up draws + } + + long next() { + state += 0x9E3779B97F4A7C15L; + long z = state; + z = (z ^ (z >>> 30)) * 0xBF58476D1CE4E5B9L; + z = (z ^ (z >>> 27)) * 0x94D049BB133111EBL; + return z ^ (z >>> 31); + } + } + + /** The three lanes, as Java believes they should be. */ + private record Lanes(int[] classes, int[] values) {} + + private static Lanes generate(int nRows, long seed) { + SplitMix64 rng = new SplitMix64(seed); + int[] classes = new int[nRows]; + int[] values = new int[nRows]; + for (int i = 0; i < nRows; i++) { + long a = rng.next(); // FIRST draw of the row + long b = rng.next(); // SECOND draw of the row + classes[i] = (int) ((a >>> 33) & 0xF); + values[i] = (int) (((b >>> 40) & 0x1FF) - 150); + } + return new Lanes(classes, values); + } + + public static void run(Checks c) { + c.section("the transcribed generator is the real SplitMix64"); + SplitMix64 r = new SplitMix64(0); + c.eq("published vector, draw 1", 0xE220A8397B1DCDAFL, r.next()); + c.eq("published vector, draw 2", 0x6E789E6AA1B965F4L, r.next()); + c.eq("published vector, draw 3", 0x06C45D188009454FL, r.next()); + + int rows = 65_536; + long seed = NativePattern.DEFAULT_SEED; + Lanes expected = generate(rows, seed); + + c.section("the lanes have the shape the contract describes"); + int minClass = Integer.MAX_VALUE, maxClass = Integer.MIN_VALUE; + int minValue = Integer.MAX_VALUE, maxValue = Integer.MIN_VALUE; + for (int i = 0; i < rows; i++) { + minClass = Math.min(minClass, expected.classes()[i]); + maxClass = Math.max(maxClass, expected.classes()[i]); + minValue = Math.min(minValue, expected.values()[i]); + maxValue = Math.max(maxValue, expected.values()[i]); + } + c.eq("classes start at 0", 0, minClass); + c.eq("classes end at 15", 15, maxClass); + c.that("values reach below zero, so > is a real signed comparison", minValue < 0); + c.that("values reach above the test threshold", maxValue > 100); + + try (NativePattern data = NativePattern.open(rows, seed)) { + + c.section("counts: Java's own loop vs the native kernels"); + + long expectClass = 0, expectValue = 0, expectBoth = 0, expectSum = 0; + for (int i = 0; i < rows; i++) { + boolean isClass = expected.classes()[i] == 7; + boolean isValue = expected.values()[i] > 100; + if (isClass) { + expectClass++; + expectSum += expected.values()[i]; + } + if (isValue) { + expectValue++; + } + if (isClass && isValue) { + expectBoth++; + } + } + + c.eq("class == 7", + expectClass, data.view().where(Pattern.CLASS.eq(7)).count()); + c.eq("value > 100", + expectValue, data.view().where(Pattern.VALUE.gt(100)).count()); + c.eq("class == 7 AND value > 100", expectBoth, + data.view().where(Pattern.CLASS.eq(7)).where(Pattern.VALUE.gt(100)).count()); + + c.section("a reduction, likewise"); + c.eq("sum of value over class == 7", expectSum, + data.view().where(Pattern.CLASS.eq(7)).lens(Pattern.VALUE).sum()); + + c.section("a signed threshold, where an unsigned comparison would differ"); + long expectNegative = 0; + for (int i = 0; i < rows; i++) { + if (expected.values()[i] > -100) { + expectNegative++; + } + } + c.eq("value > -100", expectNegative, + data.view().where(Pattern.VALUE.gt(-100)).count()); + c.that("and that predicate excludes a real number of rows, so it is not vacuous", + expectNegative < rows); + + c.section("every class tag, so no single lucky value carries the test"); + for (int tag = 0; tag < 16; tag++) { + long want = 0; + for (int i = 0; i < rows; i++) { + if (expected.classes()[i] == tag) { + want++; + } + } + c.eq("class == " + tag, want, data.view().where(Pattern.CLASS.eq(tag)).count()); + } + } + + c.section("a different seed produces different data, and parity still holds"); + long altSeed = 0x1234_5678L; + Lanes alt = generate(4096, altSeed); + long altExpect = 0; + for (int i = 0; i < 4096; i++) { + if (alt.classes()[i] == 7 && alt.values()[i] > 100) { + altExpect++; + } + } + try (NativePattern data = NativePattern.open(4096, altSeed)) { + c.eq("class == 7 AND value > 100 under a second seed", altExpect, + data.view().where(Pattern.CLASS.eq(7)).where(Pattern.VALUE.gt(100)).count()); + } + } +} diff --git a/java/src/test/java/com/adaworldapi/lancegraph/FusionParityTest.java b/java/src/test/java/com/adaworldapi/lancegraph/FusionParityTest.java new file mode 100644 index 0000000..3d91bcb --- /dev/null +++ b/java/src/test/java/com/adaworldapi/lancegraph/FusionParityTest.java @@ -0,0 +1,99 @@ +package com.adaworldapi.lancegraph; + +/** + * The fused path, the unfused path and the scalar path must agree exactly. + * + *

Fusion is an optimisation, and an optimisation is only trustworthy against a reference. Two + * references exist here, and they falsify different things: + * + *

    + *
  • unfused — the same predicates evaluated one crossing at a time and + * combined with explicit mask operations. If fusion disagrees with this, the fused kernel's + * accumulator logic is wrong. + *
  • scalar — the same fused semantics with the vector kernels forced off. If + * this disagrees, the SIMD kernel is wrong. Crucially the check happens through the + * membrane, so it also covers the marshalling, not only the Rust-internal kernels. + *
+ * + *

Exact equality is the right assertion: these are counts of set bits, so "close" would mean + * "wrong". A tolerance here would be a bug in the test. + */ +public final class FusionParityTest { + + private FusionParityTest() {} + + public static void main(String[] args) { + System.out.println("FusionParityTest"); + if (!NativeRuntime.isAvailable()) { + System.exit(Checks.reportUnavailable("FusionParityTest")); + } + Checks c = new Checks("FusionParityTest"); + run(c); + System.exit(c.report()); + } + + public static void run(Checks c) { + c.note("backend under test: " + NativeRuntime.simdBackend()); + + // Sizes chosen to straddle the mask word boundary: 64 bits per word, so a kernel that + // mishandles a partial trailing word shows up at 1,000 and 65,535 but not at 65,536. + long[] sizes = {1, 63, 64, 65, 1_000, 65_535, 65_536}; + + for (long rows : sizes) { + c.section(rows + " rows"); + try (NativePattern data = NativePattern.open(rows)) { + + check(c, "class == 7", data.view().where(Pattern.CLASS.eq(7))); + check(c, "value > 100", data.view().where(Pattern.VALUE.gt(100))); + check(c, "class == 7 AND value > 100", + data.view().where(Pattern.CLASS.eq(7)).where(Pattern.VALUE.gt(100))); + check(c, "a three-deep chain", + data.view().where(Pattern.VALUE.gt(-150)) + .where(Pattern.CLASS.eq(3)) + .where(Pattern.VALUE.gt(0))); + } + } + + c.section("the two paths really do cost different amounts"); + try (NativePattern data = NativePattern.open(65_536)) { + View v = data.view().where(Pattern.CLASS.eq(7)).where(Pattern.VALUE.gt(100)); + Diagnostics.countFused(v); + Diagnostics.countUnfused(v); + + long a = Diagnostics.crossings(); + Diagnostics.countFused(v); + long fused = Diagnostics.crossings() - a; + + long b = Diagnostics.crossings(); + Diagnostics.countUnfused(v); + long unfused = Diagnostics.crossings() - b; + + c.eq("fused: one crossing", 1, fused); + c.that("unfused: strictly more (it is what the design forbids)", unfused > fused); + c.note("fused " + fused + " crossing vs unfused " + unfused + + " for the identical answer -- this is the cost of the rule, measured"); + } + + c.section("a materialised selection agrees with the count that produced it"); + try (NativePattern data = NativePattern.open(65_536)) { + View v = data.view().where(Pattern.CLASS.eq(7)).where(Pattern.VALUE.gt(100)); + try (Mask m = v.select()) { + c.eq("Mask.count() equals View.count()", v.count(), m.count()); + } + } + } + + private static void check(Checks c, String what, View v) { + long fused = Diagnostics.countFused(v); + long unfused = Diagnostics.countUnfused(v); + long scalar = Diagnostics.countScalar(v); + + if (fused == unfused && fused == scalar) { + c.eq(what + ": fused == unfused == scalar", fused, scalar); + } else { + // Report all three, because which pair disagrees says which kernel is at fault. + c.eq(what + ": fused vs unfused", fused, unfused); + c.eq(what + ": fused vs scalar", fused, scalar); + } + } +} diff --git a/java/src/test/java/com/adaworldapi/lancegraph/LazinessTest.java b/java/src/test/java/com/adaworldapi/lancegraph/LazinessTest.java new file mode 100644 index 0000000..887ef55 --- /dev/null +++ b/java/src/test/java/com/adaworldapi/lancegraph/LazinessTest.java @@ -0,0 +1,113 @@ +package com.adaworldapi.lancegraph; + +/** + * Proves the fluent chain is lazy and the terminal operation is fused — by measurement, not by + * comment. + * + *

"Building a View makes zero crossings" is the kind of claim that is true when written and + * quietly false a year later, because nothing checks it. So the membrane counts its own crossings + * and these tests read the counter. The counter is the only reason the claim is a test rather than + * a docstring. + * + *

Two properties, and the second is the load-bearing one: + * + *

    + *
  1. Laziness — building and narrowing a view moves the counter by exactly 0. + *
  2. Fusion — a terminal operation moves it by a small constant that does + * not grow with the number of predicates. Two predicates and sixteen predicates cost + * the same number of crossings. That is what makes the fluent style affordable. + *
+ */ +public final class LazinessTest { + + private LazinessTest() {} + + public static void main(String[] args) { + System.out.println("LazinessTest"); + if (!NativeRuntime.isAvailable()) { + System.exit(Checks.reportUnavailable("LazinessTest")); + } + Checks c = new Checks("LazinessTest"); + run(c); + System.exit(c.report()); + } + + public static void run(Checks c) { + try (NativePattern data = NativePattern.open(65_536)) { + + c.section("building a chain crosses the membrane zero times"); + + long before = Diagnostics.crossings(); + View v = data.view() + .where(Pattern.CLASS.eq(7)) + .where(Pattern.VALUE.gt(100)) + .where(Pattern.VALUE.gt(-50)) + .where(Pattern.CLASS.eq(7)); + long afterBuild = Diagnostics.crossings(); + + c.eq("four .where() calls plus .view() cost no crossings", 0, afterBuild - before); + c.eq("the chain does carry all four conditions", 4, v.conditionCount()); + c.note("nothing was evaluated: no mask was allocated, no row was read"); + + // Warm the scratch selection so the steady-state cost is what is measured. The first + // terminal op on a resource also allocates its reusable selection; counting that as + // per-query cost would misreport the steady state. + v.count(); + + c.section("a terminal operation costs ONE crossing, whatever the chain length"); + + long c0 = Diagnostics.crossings(); + long n4 = v.count(); + long cost4 = Diagnostics.crossings() - c0; + c.eq("count() over a 4-condition chain", 1, cost4); + + View wide = data.view(); + for (int i = 0; i < 16; i++) { + wide = wide.where(Pattern.VALUE.gt(-150 + i)); + } + long c1 = Diagnostics.crossings(); + long n16 = wide.count(); + long cost16 = Diagnostics.crossings() - c1; + c.eq("count() over a 16-condition chain", 1, cost16); + c.eq("cost is independent of the number of conditions", cost4, cost16); + c.note("4 conditions -> " + n4 + " rows, 16 conditions -> " + n16 + " rows," + + " both for one crossing"); + + c.section("cost is independent of the number of rows, too"); + try (NativePattern small = NativePattern.open(1_024); + NativePattern large = NativePattern.open(1_000_000)) { + + View sv = small.view().where(Pattern.CLASS.eq(7)).where(Pattern.VALUE.gt(100)); + View lv = large.view().where(Pattern.CLASS.eq(7)).where(Pattern.VALUE.gt(100)); + sv.count(); + lv.count(); + + long a = Diagnostics.crossings(); + sv.count(); + long smallCost = Diagnostics.crossings() - a; + + long b = Diagnostics.crossings(); + long largeRows = lv.count(); + long largeCost = Diagnostics.crossings() - b; + + c.eq("1,024 rows", 1, smallCost); + c.eq("1,000,000 rows", 1, largeCost); + c.note("a thousand-fold more data for the same one crossing;" + + " the large query matched " + largeRows + " rows"); + } + + c.section("a reduction costs one crossing more, and no more than that"); + View sel = data.view().where(Pattern.CLASS.eq(7)); + sel.sumOf(Pattern.VALUE); + long c2 = Diagnostics.crossings(); + sel.sumOf(Pattern.VALUE); + long sumCost = Diagnostics.crossings() - c2; + c.eq("sumOf() = evaluate the chain, then reduce", 2, sumCost); + + c.section("nothing here hydrated a row"); + c.note("no API on View, Mask or Lens returns a row object, an index list, or an" + + " iterator; the crossing counts above are only meaningful because there is" + + " no per-row path that could have inflated them"); + } + } +} diff --git a/java/src/test/java/com/adaworldapi/lancegraph/LifetimeTest.java b/java/src/test/java/com/adaworldapi/lancegraph/LifetimeTest.java new file mode 100644 index 0000000..36c0a83 --- /dev/null +++ b/java/src/test/java/com/adaworldapi/lancegraph/LifetimeTest.java @@ -0,0 +1,107 @@ +package com.adaworldapi.lancegraph; + +/** + * Falsifiers for the ownership rules (docs/abi.md §4). + * + *

These are the tests that matter most, because the property they establish is the one Java + * cannot get from Rust for free. Inside Rust, a view outliving its owner is a compile error; across + * the membrane there is no borrow checker, so the invariant has to be enforced at runtime and + * therefore has to be falsified rather than asserted. + * + *

The claim under test: there is no sequence of operations in which a stale handle + * dereferences freed memory. Every attempt below must produce a specific Java exception — + * not a crash, not a wrong answer, and not a silently-tolerated no-op. + * + *

Note what a passing run does not prove: that the process would have crashed without + * these guards. It proves the guards fire. The guards themselves are two-deep on purpose (Java + * bookkeeping in front, generation-checked handles behind), so a defect in either one alone is + * still contained. + */ +public final class LifetimeTest { + + private LifetimeTest() {} + + public static void main(String[] args) { + System.out.println("LifetimeTest"); + if (!NativeRuntime.isAvailable()) { + System.exit(Checks.reportUnavailable("LifetimeTest")); + } + Checks c = new Checks("LifetimeTest"); + run(c); + System.exit(c.report()); + } + + public static void run(Checks c) { + c.section("use after close"); + NativePattern closed = NativePattern.open(1024); + long before = closed.rowCount(); + c.eq("the resource works while open", 1024, before); + closed.close(); + + c.that("it reports itself closed", !closed.isOpen()); + c.throwsUp("rowCount() after close", ClosedResourceException.class, closed::rowCount); + c.throwsUp("view() after close", ClosedResourceException.class, closed::view); + c.throwsUp("rows() after close", ClosedResourceException.class, closed::rows); + + c.section("double close"); + c.throwsUp("close() a second time", ClosedResourceException.class, closed::close); + + c.section("a view derived before the close"); + NativePattern p = NativePattern.open(1024); + View held = p.view().where(Pattern.CLASS.eq(7)); + long liveCount = held.count(); + c.that("the view works while its resource is open", liveCount >= 0); + p.close(); + + // The view object still exists — Java cannot prevent that. What it must not do is work. + c.throwsUp("count() on a view whose resource closed", + ClosedResourceException.class, held::count); + c.throwsUp("where() on a view whose resource closed", + ClosedResourceException.class, () -> held.where(Pattern.VALUE.gt(0))); + c.throwsUp("sumOf() on a view whose resource closed", + ClosedResourceException.class, () -> held.sumOf(Pattern.VALUE)); + c.throwsUp("select() on a view whose resource closed", + ClosedResourceException.class, held::select); + + c.section("a selection that outlives its parent"); + NativePattern parent = NativePattern.open(1024); + Mask orphan = parent.view().where(Pattern.CLASS.eq(7)).select(); + long orphanCount = orphan.count(); + c.that("the selection works while its parent is open", orphanCount >= 0); + parent.close(); + + c.that("the selection knows it is no longer usable", !orphan.isOpen()); + c.throwsUp("count() on a selection whose parent closed", + ClosedResourceException.class, orphan::count); + // Closing an orphan must be tolerated: the caller is doing the right thing (their + // try-with-resources is unwinding) and must not be punished for the ordering. + orphan.close(); + c.that("closing an orphaned selection is not an error", true); + c.throwsUp("but closing it twice still is", + ClosedResourceException.class, orphan::close); + + c.section("a selection closed before its parent -- the ordinary ordering"); + try (NativePattern q = NativePattern.open(512)) { + Mask m = q.view().where(Pattern.CLASS.eq(3)).select(); + long n = m.count(); + m.close(); + c.throwsUp("count() on a closed selection", ClosedResourceException.class, m::count); + c.that("the resource is unharmed by its child's close", q.rowCount() == 512); + c.that("and still answers queries", q.view().where(Pattern.CLASS.eq(3)).count() == n); + } + + c.section("the resource survives being asked the impossible"); + try (NativePattern q = NativePattern.open(256)) { + c.throwsUp("a null predicate is rejected before it can travel", + NullPointerException.class, () -> q.view().where(null)); + c.that("and the resource is still usable afterwards", q.view().count() == 256); + } + + c.section("zero rows is a legal resource, not an error"); + try (NativePattern empty = NativePattern.open(0)) { + c.eq("an empty resource has no rows", 0, empty.rowCount()); + c.eq("and every query over it selects nothing", + 0, empty.view().where(Pattern.CLASS.eq(7)).count()); + } + } +} diff --git a/java/src/test/java/com/adaworldapi/lancegraph/NarrowingTest.java b/java/src/test/java/com/adaworldapi/lancegraph/NarrowingTest.java new file mode 100644 index 0000000..6b42bf7 --- /dev/null +++ b/java/src/test/java/com/adaworldapi/lancegraph/NarrowingTest.java @@ -0,0 +1,91 @@ +package com.adaworldapi.lancegraph; + +/** + * Monotonic narrowing: adding a condition can never select more rows. + * + *

The property is meant to be structural — {@link View#where} intersects, and there is + * no public composition that widens — so this test is not really checking arithmetic. It is + * checking that the structure delivers what it promises, over a chain long enough that a single + * mis-combined op would show up. + * + *

An anti-vacuity guard runs first. A chain whose conditions all match everything would satisfy + * "never increases" trivially and prove nothing, so the test asserts the chain actually eliminates + * a substantial fraction before it asserts the fraction only ever shrinks. + */ +public final class NarrowingTest { + + private NarrowingTest() {} + + public static void main(String[] args) { + System.out.println("NarrowingTest"); + if (!NativeRuntime.isAvailable()) { + System.exit(Checks.reportUnavailable("NarrowingTest")); + } + Checks c = new Checks("NarrowingTest"); + run(c); + System.exit(c.report()); + } + + public static void run(Checks c) { + try (NativePattern data = NativePattern.open(65_536)) { + + c.section("a long chain, checked step by step"); + + // Deliberately mixed: some conditions bite hard, some barely, one is a no-op. A chain + // of only-hard conditions would collapse to zero and stop testing anything after the + // second step. + Predicate[] chain = { + Pattern.VALUE.gt(-150), // matches everything: the boundary case + Pattern.CLASS.eq(7), // bites hard, about 1/16 + Pattern.VALUE.gt(0), + Pattern.VALUE.gt(100), + Pattern.CLASS.eq(7), // repeated: idempotent, must change nothing + Pattern.VALUE.gt(200), + }; + + View v = data.view(); + long previous = v.count(); + c.eq("the unconditioned view selects everything", 65_536, previous); + + long first = previous; + for (int i = 0; i < chain.length; i++) { + v = v.where(chain[i]); + long now = v.count(); + c.atMost("step " + (i + 1) + " (" + chain[i] + ") does not widen", previous, now); + previous = now; + } + + c.section("anti-vacuity: the chain must actually eliminate something"); + c.that("the final selection is far smaller than the first", + previous * 4 < first); + c.that("but not empty, or the later steps proved nothing", previous > 0); + c.note("65,536 -> " + previous + " rows across " + chain.length + " conditions"); + + c.section("an idempotent repeat changes nothing"); + View once = data.view().where(Pattern.CLASS.eq(7)); + View twice = once.where(Pattern.CLASS.eq(7)); + c.eq("class == 7 twice equals class == 7 once", once.count(), twice.count()); + + c.section("order does not change the answer"); + long ab = data.view().where(Pattern.CLASS.eq(7)).where(Pattern.VALUE.gt(100)).count(); + long ba = data.view().where(Pattern.VALUE.gt(100)).where(Pattern.CLASS.eq(7)).count(); + c.eq("intersection is commutative", ab, ba); + + c.section("the conjunction is bounded by each part"); + long onlyClass = data.view().where(Pattern.CLASS.eq(7)).count(); + long onlyValue = data.view().where(Pattern.VALUE.gt(100)).count(); + c.atMost("no larger than class == 7 alone", onlyClass, ab); + c.atMost("no larger than value > 100 alone", onlyValue, ab); + c.that("and strictly smaller than both, so neither part was ignored", + ab < onlyClass && ab < onlyValue); + + c.section("narrowing a shared view does not disturb the shared view"); + View shared = data.view().where(Pattern.CLASS.eq(7)); + long sharedBefore = shared.count(); + View branchA = shared.where(Pattern.VALUE.gt(100)); + View branchB = shared.where(Pattern.VALUE.gt(300)); + c.that("the branches differ", branchA.count() != branchB.count()); + c.eq("and the view they came from is untouched", sharedBefore, shared.count()); + } + } +} diff --git a/java/src/test/java/com/adaworldapi/lancegraph/SmokeTest.java b/java/src/test/java/com/adaworldapi/lancegraph/SmokeTest.java new file mode 100644 index 0000000..58297c2 --- /dev/null +++ b/java/src/test/java/com/adaworldapi/lancegraph/SmokeTest.java @@ -0,0 +1,81 @@ +package com.adaworldapi.lancegraph; + +/** + * The John Doe test: does the API in the README actually work, and does it read like Java? + * + *

Read the body of {@link #run} as documentation. Nothing in it mentions an arena, a segment, a + * linker, a lane, an opcode, a mask word or a SIMD backend — and it is nevertheless the full, + * real path through a columnar SIMD kernel over 65,536 rows held entirely outside the Java heap. + */ +public final class SmokeTest { + + private SmokeTest() {} + + public static void main(String[] args) { + System.out.println("SmokeTest"); + if (!NativeRuntime.isAvailable()) { + System.exit(Checks.reportUnavailable("SmokeTest")); + } + Checks c = new Checks("SmokeTest"); + run(c); + System.exit(c.report()); + } + + public static void run(Checks c) { + c.section("the runtime that actually loaded"); + c.note(NativeRuntime.describe()); + + c.section("the fluent chain from the README"); + + // ── everything below is the entire consumer-facing surface ────────────────────────── + try (var data = NativePattern.open(65_536)) { + + long n = data.view() + .where(Pattern.CLASS.eq(7)) + .where(Pattern.VALUE.gt(100)) + .count(); + + System.out.println(" -> " + n + " of " + data.rowCount() + " rows matched" + + " class == 7 AND value > 100"); + // ──────────────────────────────────────────────────────────────────────────────── + + c.that("the chain returns a plausible count", n > 0 && n < data.rowCount()); + c.eq("the resource reports the row count it was opened with", 65_536, data.rowCount()); + c.eq("the row range agrees with the row count", 65_536, data.rows().length()); + + c.section("each step of the chain, so a wrong total can be localised"); + long all = data.view().count(); + long cls = data.view().where(Pattern.CLASS.eq(7)).count(); + long val = data.view().where(Pattern.VALUE.gt(100)).count(); + c.eq("an unconditioned view selects every row", 65_536, all); + c.that("class == 7 selects roughly a sixteenth", cls > all / 32 && cls < all / 8); + c.that("value > 100 selects a middling fraction", val > all / 4 && val < (all * 3) / 4); + c.that("the conjunction is no larger than either part", n <= cls && n <= val); + + c.section("a reduction over the same rows"); + long sum = data.view() + .where(Pattern.CLASS.eq(7)) + .lens(Pattern.VALUE) + .sum(); + c.note("sum of value over class == 7 is " + sum); + c.that("the sum is a real number, not a default", sum != 0); + + c.section("a selection can be kept and asked twice"); + try (Mask selected = data.view().where(Pattern.CLASS.eq(7)).select()) { + c.eq("a materialised selection agrees with the count", cls, selected.count()); + c.eq("and is stable when asked again", cls, selected.count()); + c.note("selection identity: " + selected.id()); + c.note("65,536 rows selected as packed bits = " + (65_536 / 8) + " bytes," + + " not 65,536 objects"); + } + + c.section("views are immutable, so narrowing one does not disturb it"); + View base = data.view().where(Pattern.CLASS.eq(7)); + View narrower = base.where(Pattern.VALUE.gt(100)); + c.eq("the original still selects what it did", cls, base.count()); + c.eq("and the derived view is the narrowed one", n, narrower.count()); + c.eq("the original carries one condition", 1, base.conditionCount()); + c.eq("the derived one carries two", 2, narrower.conditionCount()); + } + } +} diff --git a/native/lgj-abi/.cargo/config.toml b/native/lgj-abi/.cargo/config.toml new file mode 100644 index 0000000..52168f8 --- /dev/null +++ b/native/lgj-abi/.cargo/config.toml @@ -0,0 +1,38 @@ +# x86-64-v4 (AVX-512) baseline — MANDATORY (operator directive, 2026-08-17: +# "rust compiles with CPU v4"). This DIVERGES from /home/user/ndarray's own +# default (.cargo/config.toml there pins v3/AVX2, because that repo targets +# portable distribution). lance-graph-java's native artifact is built for +# THIS host, which has avx512f/bw/cd/dq/ifma/vbmi/vl — verified this session +# — so the production build exercises ndarray's AVX-512 backend +# (`src/simd_avx512.rs`) directly rather than falling back to AVX2. +# +# Why this line is load-bearing and why omitting/under-shooting it is a +# *runtime* fault, not a compile error: +# +# ndarray's SIMD backends compose their vector types out of raw AVX / +# AVX2 / AVX-512 intrinsics (`__m256`, `__m512`, `__m512i`, ...). Those are +# `#[inline]` and get instantiated into THIS crate's object code when we +# call `ndarray::simd::*`. Without a target-cpu baseline that actually +# covers what got emitted, rustc may target a narrower baseline than the +# instantiated intrinsics require, and the CPU then executes an +# instruction the compiled target never promised — the process dies with +# SIGILL. That is ndarray's PR #170 CI failure mode, reproduced one +# consumer down. The symptom is a mysterious SIGILL inside a JVM downcall, +# about the worst possible place to debug it from — hence pinning the +# baseline here, in the crate that produces the cdylib, rather than +# relying on a caller to pass the right RUSTFLAGS. +# +# This is a BUILD TARGET declaration, not SIMD selection in source. abi.md §8 +# forbids `#[cfg(target_feature)]` *selection of a SIMD implementation* in +# this crate; choosing which silicon the artifact is compiled for is +# ndarray's documented consumer contract, and the manifest then *reports* +# the resulting backend (never negotiates it) — with this baseline, +# `LgjAbiManifest::simd_backend` must report AVX512 on this host. +# +# Portable/distribution build (older silicon, no AVX-512): override with +# CARGO_BUILD_RUSTFLAGS='-Ctarget-cpu=x86-64-v3' at build time; do not change +# this file's default for that — v4 stays the default because this is a +# research vertical slice built and run on one known host, not a +# redistributed artifact. +[target.'cfg(target_arch = "x86_64")'] +rustflags = ["-Ctarget-cpu=x86-64-v4"] diff --git a/native/lgj-abi/Cargo.lock b/native/lgj-abi/Cargo.lock new file mode 100644 index 0000000..74977d7 --- /dev/null +++ b/native/lgj-abi/Cargo.lock @@ -0,0 +1,94 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "lgj-abi" +version = "0.1.0" +dependencies = [ + "ndarray", +] + +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "ndarray" +version = "0.17.2" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "paste", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" diff --git a/native/lgj-abi/Cargo.toml b/native/lgj-abi/Cargo.toml new file mode 100644 index 0000000..c83b1b6 --- /dev/null +++ b/native/lgj-abi/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "lgj-abi" +version = "0.1.0" +edition = "2021" +rust-version = "1.97" +license = "MIT OR Apache-2.0" +publish = false +description = "The lance-graph-java machine membrane: a self-describing, versioned, bulk-only psABI surface. No C, no JNI, no headers." + +[lib] +name = "lgj_abi" +# cdylib -> the artifact Java's Linker.nativeLinker() dlopen's. +# rlib -> so the in-crate #[cfg(test)] suite can link and exercise the +# same code the cdylib exports (a separate build of the same +# source would not prove anything about the shipped artifact's +# layout, but the compile-time size asserts in abi.rs do, and +# they are compiled in both crate types). +crate-type = ["cdylib", "rlib"] + +[dependencies] +# The ONLY dependency, and the ONLY source of SIMD in this crate (abi.md §8). +# +# default-features = false + "std": +# ndarray's own defaults are ["std", "hpc-extras"]; hpc-extras pulls the +# `p64` and `fractal` path crates, which nothing here needs. "std" alone +# is what gates `ndarray::simd`, `ndarray::simd_int_ops`, `ndarray::hpc` +# and `ndarray::bitwise` — i.e. every module this crate consumes. +ndarray = { path = "../../../ndarray", default-features = false, features = ["std"] } + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 1 +# panic = "abort" is DELIBERATELY ABSENT (abi.md §9). Every extern "C" body +# wraps itself in std::panic::catch_unwind so a panic becomes a negative +# status instead of an unwind into JVM frames (which is UB). catch_unwind +# requires unwinding to be available, so "abort" would silently turn the +# panic-safety layer into a process kill. + +[profile.dev] +# Same rule as release: no panic = "abort". diff --git a/native/lgj-abi/rust-toolchain.toml b/native/lgj-abi/rust-toolchain.toml new file mode 100644 index 0000000..652fcca --- /dev/null +++ b/native/lgj-abi/rust-toolchain.toml @@ -0,0 +1,6 @@ +[toolchain] +# Stable only. Matches ndarray / lance-graph / the rest of the AdaWorldAPI +# stack. No nightly features anywhere in this crate — in particular no +# `core::simd` / portable-simd (abi.md §8). +channel = "1.97.1" +components = ["clippy", "rustfmt"] diff --git a/native/lgj-abi/src/abi.rs b/native/lgj-abi/src/abi.rs new file mode 100644 index 0000000..80d8bf6 --- /dev/null +++ b/native/lgj-abi/src/abi.rs @@ -0,0 +1,534 @@ +//! The ABI surface types — the *machine membrane* vocabulary. +//! +//! Normative source: `docs/abi.md`. Every struct field, its order, its size and +//! every status code in this file is dictated by that document. If this file and +//! `abi.md` disagree, this file is wrong. +//! +//! # There is no C here +//! +//! `#[repr(C)]` below names this target's standard *aggregate layout rule* +//! (field order, padding, alignment) — the SysV AMD64 psABI on x86_64, AAPCS64 +//! on ARM64. It is not a C declaration, there is no `.h` anywhere in this +//! project, and no C toolchain participates in building or consuming it. The +//! Java side re-derives these same layouts independently with +//! `java.lang.foreign.MemoryLayout` and the manifest (below) proves the two +//! derivations agree *at runtime*, which a text header could never do. +//! +//! # Layout drift is a build failure +//! +//! Each `#[repr(C)]` type carries `const _: () = assert!(size_of::() == N);` +//! compile-time assertions against the byte counts `abi.md` §5 states. Adding, +//! reordering or re-typing a field therefore breaks the *build*, not a Java +//! test at 3am. + +use core::mem::{align_of, size_of}; + +// --------------------------------------------------------------------------- +// §2 Versioning +// --------------------------------------------------------------------------- + +/// Incompatible change ⇒ bump. Java refuses to load on a mismatch. +pub const LGJ_ABI_MAJOR: u32 = 0; + +/// Additive change ⇒ bump. Older Java may still load (`minor >= expected`). +pub const LGJ_ABI_MINOR: u32 = 1; + +/// `"LGJ_ABI\0"` read big-endian. +/// +/// Doubles as an endianness probe: Java reads this field as a little-endian +/// `u64` and compares against its own compiled-in copy of this constant. +/// Anything else means the library was built for a different byte order, and +/// every subsequent read across the membrane would be garbage. +pub const LGJ_MAGIC: u64 = 0x4C_47_4A_5F_41_42_49_00; + +// --------------------------------------------------------------------------- +// §3 Status codes — every function returns i32; 0 = OK, all failures negative +// --------------------------------------------------------------------------- + +/// Success. +pub const LGJ_OK: i32 = 0; +/// A required out-pointer was null. +pub const LGJ_ERR_NULL_ARGUMENT: i32 = -1; +/// Handle malformed, closed, or generation-stale. The answer to +/// *use-after-close* — deliberately a status, never a crash (§4). +pub const LGJ_ERR_INVALID_HANDLE: i32 = -2; +/// e.g. a mask handle passed where a pattern was required. +pub const LGJ_ERR_WRONG_RESOURCE_KIND: i32 = -3; +/// `lane_id` out of range for this resource. +pub const LGJ_ERR_INVALID_LANE: i32 = -4; +/// The op's element type ≠ the lane's element type. +pub const LGJ_ERR_LANE_KIND_MISMATCH: i32 = -5; +/// Mask row-count ≠ resource row-count. +pub const LGJ_ERR_MASK_LENGTH_MISMATCH: i32 = -6; +/// A child (mask) outlived its parent resource. +pub const LGJ_ERR_PARENT_CLOSED: i32 = -7; +/// Caller's ABI version incompatible. +pub const LGJ_ERR_VERSION_MISMATCH: i32 = -8; +/// Requested size overflows `usize` / the allocation limit. +pub const LGJ_ERR_LENGTH_OVERFLOW: i32 = -9; +/// The plan contained an opcode this build does not implement. +pub const LGJ_ERR_UNKNOWN_OPCODE: i32 = -10; +/// A plan with zero ops was submitted. +pub const LGJ_ERR_EMPTY_PLAN: i32 = -11; +/// The allocator refused. +pub const LGJ_ERR_ALLOCATION_FAILED: i32 = -12; +/// A write was attempted against a read-only lane. +pub const LGJ_ERR_READ_ONLY: i32 = -13; + +/// A panic was caught at the membrane and converted to a status (§9). +/// +/// Not in `abi.md`'s table, and deliberately *outside* the allocated +/// `-1..=-13` block so it can never be confused with a specified condition. +/// A caller seeing this has found a bug in this crate; it is reported rather +/// than allowed to unwind into JVM frames, which would be UB. +pub const LGJ_ERR_PANIC: i32 = -99; + +// --------------------------------------------------------------------------- +// §5 Element kinds — start at 1, so a zeroed struct is *detectably invalid* +// rather than silently meaning U8. +// --------------------------------------------------------------------------- + +/// Element kind tags for [`LgjLaneDesc::elem_kind`]. +#[repr(u32)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LgjElemKind { + /// Unsigned 8-bit. + U8 = 1, + /// Signed 8-bit. + I8 = 2, + /// Unsigned 16-bit. + U16 = 3, + /// Signed 16-bit. + I16 = 4, + /// Unsigned 32-bit. + U32 = 5, + /// Signed 32-bit. + I32 = 6, + /// Unsigned 64-bit. + U64 = 7, + /// Signed 64-bit. + I64 = 8, + /// IEEE-754 binary32. + F32 = 9, + /// IEEE-754 binary64. + F64 = 10, + /// A `u64` of 64 packed row bits, LSB = lowest row index. + MaskWord = 11, +} + +impl LgjElemKind { + /// Width of one element in bytes. + pub const fn elem_bytes(self) -> u32 { + match self { + LgjElemKind::U8 | LgjElemKind::I8 => 1, + LgjElemKind::U16 | LgjElemKind::I16 => 2, + LgjElemKind::U32 | LgjElemKind::I32 | LgjElemKind::F32 => 4, + LgjElemKind::U64 | LgjElemKind::I64 | LgjElemKind::F64 | LgjElemKind::MaskWord => 8, + } + } +} + +// §5 lane flags (bitfield) +/// Java may read this lane's bytes. +pub const LGJ_FLAG_READABLE: u32 = 1 << 0; +/// Java may write this lane's bytes. Pattern lanes never set this; mask words do. +pub const LGJ_FLAG_WRITABLE: u32 = 1 << 1; +/// `stride_bytes == elem_bytes`. +pub const LGJ_FLAG_CONTIGUOUS: u32 = 1 << 2; + +// §5 resource kinds +/// A pattern (the SoA fixture): id/class/value lanes, read-only. +pub const LGJ_RESOURCE_PATTERN: u32 = 1; +/// A mask: one `MASK_WORD` lane, writable, owned by a parent pattern. +pub const LGJ_RESOURCE_MASK: u32 = 2; + +// §7 mask_create initial states +/// `lgj_mask_create(initial = 0)` — no rows set. +pub const LGJ_MASK_INIT_EMPTY: u32 = 0; +/// `lgj_mask_create(initial = 1)` — all rows set. +pub const LGJ_MASK_INIT_ALL: u32 = 1; + +// --------------------------------------------------------------------------- +// Opcodes + combiners (§5 LgjOpDesc) +// --------------------------------------------------------------------------- + +/// `lane[i] == operand as u32`, over a `U32` lane. +pub const LGJ_OP_EQ_U32: u32 = 1; +/// `lane[i] > operand as i32` (signed), over an `I32` lane. +pub const LGJ_OP_GT_I32: u32 = 2; + +/// Narrow the accumulator: `acc &= op_result`. +pub const LGJ_COMBINE_AND: u32 = 0; +/// Widen the accumulator: `acc |= op_result`. +pub const LGJ_COMBINE_OR: u32 = 1; + +/// The element kind an opcode requires of its lane. `None` ⇒ unknown opcode. +pub(crate) const fn opcode_required_kind(op: u32) -> Option { + match op { + LGJ_OP_EQ_U32 => Some(LgjElemKind::U32), + LGJ_OP_GT_I32 => Some(LgjElemKind::I32), + _ => None, + } +} + +// --------------------------------------------------------------------------- +// §5 SIMD backend tags — reported, never negotiated +// --------------------------------------------------------------------------- + +/// No vector backend compiled in. +pub const LGJ_SIMD_SCALAR: u32 = 0; +/// `ndarray::simd` AVX2 backend (x86-64-v3 baseline). +pub const LGJ_SIMD_AVX2: u32 = 1; +/// `ndarray::simd` AVX-512 backend (x86-64-v4 baseline). +pub const LGJ_SIMD_AVX512: u32 = 2; +/// `ndarray::simd` NEON backend (aarch64). +pub const LGJ_SIMD_NEON: u32 = 3; +/// `ndarray::simd` wasm128 backend. +pub const LGJ_SIMD_WASM: u32 = 4; + +// --------------------------------------------------------------------------- +// §5 The descriptors +// --------------------------------------------------------------------------- + +/// The bounded description the Java FFM layer turns into a `MemorySegment`. +/// +/// Java's *public* API never sees `addr`; it is physics, consumed inside the +/// FFM layer and never surfaced. +#[repr(C)] +#[derive(Debug, Clone, Copy, Default)] +pub struct LgjLaneDesc { + /// Base address of the lane. Physics — never surfaced in public Java API. + pub addr: u64, + /// Number of elements. + pub len_elems: u64, + /// `len_elems * stride_bytes`. + pub byte_len: u64, + /// Owning resource handle. + pub owner: u64, + /// Liveness stamp; Java re-checks it before using a segment it still holds. + pub epoch: u64, + /// [`LgjElemKind`] as `u32`. + pub elem_kind: u32, + /// Width of one element. + pub elem_bytes: u32, + /// `== elem_bytes` when contiguous. + pub stride_bytes: u32, + /// `LGJ_FLAG_*` bitfield. + pub flags: u32, +} + +/// What a handle refers to, without touching its payload. +#[repr(C)] +#[derive(Debug, Clone, Copy, Default)] +pub struct LgjResourceInfo { + /// `LGJ_RESOURCE_PATTERN` | `LGJ_RESOURCE_MASK`. + pub kind: u32, + /// Number of describable lanes. + pub lane_count: u32, + /// Logical row count. + pub n_rows: u64, + /// Liveness stamp, matching the lanes' `epoch`. + pub epoch: u64, + /// Parent handle; `0` = none. + pub parent: u64, +} + +/// One predicate in a fused plan. +#[repr(C)] +#[derive(Debug, Clone, Copy, Default)] +pub struct LgjOpDesc { + /// `LGJ_OP_*`. + pub op: u32, + /// Which lane of the resource to read. + pub lane_id: u32, + /// Needle / threshold, sign-extended into `i64` by the caller. + pub operand: i64, + /// `LGJ_COMBINE_AND` (narrow) | `LGJ_COMBINE_OR` (widen). + pub combine: u32, + /// Must be `0`. Rejected otherwise, so a future field cannot be + /// silently ignored by an old build. + pub _reserved: u32, +} + +/// What replaces a C header: the compiled artifact describing *itself*. +/// +/// A header is a text file that *claims* what an artifact looks like and can +/// drift from it silently. This struct is emitted **by** the artifact, so it +/// cannot disagree with itself — every `size_of_*` / `align_of_*` below is +/// filled from `core::mem::size_of` / `align_of` on the real type (see +/// [`manifest`]), never from a hardcoded number. +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct LgjAbiManifest { + /// [`LGJ_MAGIC`]; doubles as the endianness probe. + pub magic: u64, + /// [`LGJ_ABI_MAJOR`]. Java requires an exact match. + pub abi_major: u32, + /// [`LGJ_ABI_MINOR`]. Java requires `>=` what it was compiled against. + pub abi_minor: u32, + /// `size_of::()`. + pub size_of_manifest: u32, + /// `size_of::()`. + pub size_of_lane_desc: u32, + /// `size_of::()`. + pub size_of_op_desc: u32, + /// `size_of::()`. + pub size_of_resource_info: u32, + /// `align_of::()`. + pub align_of_lane_desc: u32, + /// `align_of::()`. + pub align_of_op_desc: u32, + /// `align_of::()`. + pub align_of_resource_info: u32, + /// `size_of::()` — the target's pointer width. + pub pointer_bytes: u32, + /// `0` = little-endian. + pub endianness: u32, + /// `LGJ_SIMD_*`. + pub simd_backend: u32, + /// NUL-terminated, human-readable. + pub simd_backend_name: [u8; 32], + /// `"release"` | `"debug"`, NUL-terminated. + pub build_profile: [u8; 16], +} + +// --------------------------------------------------------------------------- +// Layout assertions — abi.md §5 states these byte counts; a drift is a +// BUILD FAILURE here rather than a mysterious Java-side misread later. +// --------------------------------------------------------------------------- + +const _: () = assert!(size_of::() == 56); +const _: () = assert!(align_of::() == 8); +const _: () = assert!(size_of::() == 32); +const _: () = assert!(align_of::() == 8); +const _: () = assert!(size_of::() == 24); +const _: () = assert!(align_of::() == 8); +// abi.md does not state the manifest's size (it is self-reported, which is the +// point), but pinning it still catches an accidental field insertion: +// 8 (magic) + 12*4 (u32 fields) + 32 (name) + 16 (profile) = 104, align 8. +const _: () = assert!(size_of::() == 104); +const _: () = assert!(align_of::() == 8); +// A MASK_WORD is 64 row bits in one u64. If this ever stops holding, the whole +// bit-order contract below is void. +const _: () = assert!(ROWS_PER_WORD == u64::BITS as u64); + +// --------------------------------------------------------------------------- +// Mask word arithmetic — the bit-order contract, in one place +// --------------------------------------------------------------------------- + +/// Rows per mask word. +pub const ROWS_PER_WORD: u64 = 64; + +/// Number of `u64` words needed to hold `n_rows` bits (LSB-first within each +/// word: row `i` lives at bit `i % 64` of word `i / 64`). +pub const fn mask_words_for(n_rows: u64) -> u64 { + n_rows.div_ceil(ROWS_PER_WORD) +} + +/// Zero every bit at row index `>= n_rows` in the final word. +/// +/// Trailing bits beyond the row count are **normative zero** — `abi.md`'s +/// popcount, subset and parity properties all depend on it, so +/// `lgj_mask_count` cannot be fooled by garbage in the tail. +pub fn clear_tail_bits(words: &mut [u64], n_rows: u64) { + let used = (n_rows % ROWS_PER_WORD) as u32; + if used != 0 { + if let Some(last) = words.last_mut() { + *last &= (1u64 << used) - 1; + } + } +} + +// --------------------------------------------------------------------------- +// The manifest instance +// --------------------------------------------------------------------------- + +/// Detect the SIMD backend ndarray compiled into this artifact. +/// +/// This is the ONE sanctioned `cfg(target_feature)` / `cfg(target_arch)` use in +/// this crate. `abi.md` §8 forbids cfg-based *selection of a SIMD +/// implementation*; this selects nothing — it reports a fact about the build so +/// Java can log which backend it got. Which backend exists at all is +/// `ndarray`'s business, and the tags below mirror `ndarray/src/simd.rs`'s own +/// dispatch order (avx512f first, then avx2, then aarch64/neon, then wasm). +const fn detect_simd_backend() -> (u32, &'static str) { + // `cfg!` (not `#[cfg]` blocks) so exactly one arm is chosen as a *value* — + // and so the whole function stays a single const expression. + if cfg!(all(target_arch = "x86_64", target_feature = "avx512f")) { + (LGJ_SIMD_AVX512, "ndarray::simd avx512") + } else if cfg!(all(target_arch = "x86_64", target_feature = "avx2")) { + (LGJ_SIMD_AVX2, "ndarray::simd avx2 (x86-64-v3)") + } else if cfg!(all(target_arch = "aarch64", target_feature = "neon")) { + (LGJ_SIMD_NEON, "ndarray::simd neon") + } else if cfg!(target_arch = "wasm32") { + (LGJ_SIMD_WASM, "ndarray::simd wasm128") + } else { + // On x86_64 this arm is reachable only if the `.cargo/config.toml` + // target-cpu baseline was lost (this crate's default is x86-64-v4, so + // the expected report on this host is AVX512, and AVX2 only under an + // explicit v3 override). It is also the SIGILL condition — ndarray's + // intrinsics get instantiated regardless of the baseline — so SCALAR + // reported on x86_64 means the build is misconfigured, not that the + // CPU is old. + (LGJ_SIMD_SCALAR, "ndarray::simd scalar") + } +} + +const BUILD_PROFILE: &str = if cfg!(debug_assertions) { + "debug" +} else { + "release" +}; + +/// Copy `src` into a fixed-size NUL-terminated byte field, truncating if needed +/// (always leaving room for the terminator). +const fn fixed_cstr(src: &str) -> [u8; N] { + let mut out = [0u8; N]; + let bytes = src.as_bytes(); + let mut i = 0; + while i < bytes.len() && i + 1 < N { + out[i] = bytes[i]; + i += 1; + } + out +} + +/// The `'static` the manifest getter hands out. Built entirely from +/// `size_of` / `align_of` on the real types — never a literal. +pub static MANIFEST: LgjAbiManifest = LgjAbiManifest { + magic: LGJ_MAGIC, + abi_major: LGJ_ABI_MAJOR, + abi_minor: LGJ_ABI_MINOR, + size_of_manifest: size_of::() as u32, + size_of_lane_desc: size_of::() as u32, + size_of_op_desc: size_of::() as u32, + size_of_resource_info: size_of::() as u32, + align_of_lane_desc: align_of::() as u32, + align_of_op_desc: align_of::() as u32, + align_of_resource_info: align_of::() as u32, + pointer_bytes: size_of::() as u32, + // `u64::from_le_bytes` of a known pattern would be a runtime check; the + // compile-time form is `cfg(target_endian)`, which is a *fact about the + // build* exactly like the SIMD tag above. + endianness: if cfg!(target_endian = "little") { 0 } else { 1 }, + simd_backend: detect_simd_backend().0, + simd_backend_name: fixed_cstr::<32>(detect_simd_backend().1), + build_profile: fixed_cstr::<16>(BUILD_PROFILE), +}; + +#[cfg(test)] +mod tests { + use super::*; + + /// The manifest must describe *itself* truthfully — this is the property + /// the Java side's load-time cross-check depends on. + #[test] + fn manifest_is_self_consistent() { + assert_eq!(MANIFEST.magic, LGJ_MAGIC); + assert_eq!(MANIFEST.abi_major, LGJ_ABI_MAJOR); + assert_eq!(MANIFEST.abi_minor, LGJ_ABI_MINOR); + assert_eq!( + MANIFEST.size_of_manifest as usize, + size_of::() + ); + assert_eq!( + MANIFEST.size_of_lane_desc as usize, + size_of::() + ); + assert_eq!(MANIFEST.size_of_op_desc as usize, size_of::()); + assert_eq!( + MANIFEST.size_of_resource_info as usize, + size_of::() + ); + assert_eq!( + MANIFEST.align_of_lane_desc as usize, + align_of::() + ); + assert_eq!(MANIFEST.align_of_op_desc as usize, align_of::()); + assert_eq!( + MANIFEST.align_of_resource_info as usize, + align_of::() + ); + assert_eq!(MANIFEST.pointer_bytes as usize, size_of::()); + } + + /// abi.md §5's stated byte counts, asserted at run time as well as at + /// compile time — so a reader of the test output sees the real numbers. + #[test] + fn struct_sizes_match_the_spec() { + assert_eq!(size_of::(), 56); + assert_eq!(size_of::(), 32); + assert_eq!(size_of::(), 24); + assert_eq!(size_of::(), 104); + } + + #[test] + fn magic_reads_as_lgj_abi_nul_big_endian() { + assert_eq!(&LGJ_MAGIC.to_be_bytes(), b"LGJ_ABI\0"); + } + + #[test] + fn name_fields_are_nul_terminated() { + assert!(MANIFEST.simd_backend_name.contains(&0)); + assert!(MANIFEST.build_profile.contains(&0)); + let name = MANIFEST.simd_backend_name; + let end = name.iter().position(|&b| b == 0).unwrap(); + assert!(end > 0, "backend name must not be empty"); + } + + /// The SIGILL trap, turned into a test failure. + /// + /// `ndarray`'s vector types are built from AVX/AVX2/AVX-512 intrinsics and + /// get instantiated into this crate's object code whichever baseline rustc + /// was given. If `.cargo/config.toml`'s `-Ctarget-cpu` is lost, rustc + /// targets generic x86-64 (SSE2), those instructions still get emitted, and + /// the process dies with SIGILL — inside a JVM downcall, if nobody noticed + /// earlier. The backend tag is the observable that goes wrong *first*, so + /// assert on it here where the failure is legible. + #[test] + #[cfg(target_arch = "x86_64")] + fn the_x86_64_build_has_a_vector_baseline() { + assert_ne!( + MANIFEST.simd_backend, LGJ_SIMD_SCALAR, + "no target-cpu baseline: ndarray's intrinsics will SIGILL at run time. \ + Check native/lgj-abi/.cargo/config.toml." + ); + assert!(matches!( + MANIFEST.simd_backend, + LGJ_SIMD_AVX2 | LGJ_SIMD_AVX512 + )); + } + + #[test] + fn element_kinds_start_at_one_so_zeroed_is_invalid() { + assert_eq!(LgjElemKind::U8 as u32, 1); + assert_eq!(LgjElemKind::MaskWord as u32, 11); + assert_eq!( + LgjLaneDesc::default().elem_kind, + 0, + "zeroed ⇒ no valid kind" + ); + } + + #[test] + fn mask_word_count_rounds_up() { + assert_eq!(mask_words_for(0), 0); + assert_eq!(mask_words_for(1), 1); + assert_eq!(mask_words_for(64), 1); + assert_eq!(mask_words_for(65), 2); + assert_eq!(mask_words_for(1000), 16); + } + + #[test] + fn tail_bits_are_cleared() { + let mut w = vec![u64::MAX; 2]; + clear_tail_bits(&mut w, 70); + assert_eq!(w[0], u64::MAX); + assert_eq!(w[1], 0x3F, "only rows 64..70 may survive"); + + // Exact multiple of 64: nothing to clear. + let mut w2 = vec![u64::MAX; 2]; + clear_tail_bits(&mut w2, 128); + assert_eq!(w2, vec![u64::MAX; 2]); + } +} diff --git a/native/lgj-abi/src/exports.rs b/native/lgj-abi/src/exports.rs new file mode 100644 index 0000000..050bf60 --- /dev/null +++ b/native/lgj-abi/src/exports.rs @@ -0,0 +1,1624 @@ +//! The `extern "C"` surface — the membrane itself. +//! +//! # `extern "C"` is a calling convention, not a language +//! +//! Every symbol below is `extern "C"`, which on this target means the **System V +//! AMD64 psABI** (AAPCS64 on ARM64): which registers carry which argument, how +//! aggregates are passed, who saves what. It is a machine contract. There is no +//! C source, no `.h`, no C compiler, and no JNI anywhere in this project. Java +//! reaches these symbols with `Linker.nativeLinker()`, which is the JVM's own +//! implementation of the same psABI. +//! +//! # Two invariants every function here obeys +//! +//! 1. **Bulk or lifecycle** (abi.md §6). A function either does work +//! proportional to `n_rows` or it is open/close/describe. There is no +//! per-element crossing and no `lgj_lane_read_element` — if Java wants one +//! row it reads the `MemorySegment` in-process, with no crossing at all. +//! 2. **Panics never cross** (abi.md §9). Every body runs inside +//! [`guard`], which converts an unwind into [`LGJ_ERR_PANIC`]. An unwind into +//! JVM frames is UB; a negative status is a Tuesday. +//! +//! `out_*` parameters are written **only on `OK`**, so a failed call cannot +//! leave Java reading a half-filled descriptor. + +use std::panic::{catch_unwind, AssertUnwindSafe}; + +use crate::abi::*; +use crate::fixture::PATTERN_LANE_COUNT; +use crate::kernels::{self, LaneView, Path}; +use crate::registry::{self, ResourceEntry}; + +/// Run `f`, converting a panic into [`LGJ_ERR_PANIC`]. +/// +/// `AssertUnwindSafe` is required because the closures below capture raw +/// pointers. It is sound here for the reason that matters: on the unwind path we +/// return a status and touch *nothing* the panic could have left inconsistent — +/// the only shared state is the registry, whose locks recover via `into_inner` +/// and whose payloads carry no invariant beyond "tail bits are zero", which +/// every write path re-establishes before returning. +#[inline] +fn guard i32>(f: F) -> i32 { + match catch_unwind(AssertUnwindSafe(f)) { + Ok(status) => status, + Err(_) => LGJ_ERR_PANIC, + } +} + +/// Collapse a `Result<(), i32>` into a status. +#[inline] +fn status(r: Result<(), i32>) -> i32 { + match r { + Ok(()) => LGJ_OK, + Err(e) => e, + } +} + +// ─────────────────────────────────────────────────────────────────────────── +// Manifest — the one symbol with no failure mode, and therefore no status +// ─────────────────────────────────────────────────────────────────────────── + +/// Return a pointer to the `'static` [`LgjAbiManifest`]. +/// +/// Never fails, never allocates. This is what replaces a C header: the artifact +/// describing itself, so it cannot disagree with itself the way a checked-in +/// header can drift from the binary beside it. +#[no_mangle] +pub extern "C" fn lgj_abi_manifest() -> *const LgjAbiManifest { + &MANIFEST as *const LgjAbiManifest +} + +// ─────────────────────────────────────────────────────────────────────────── +// Lifecycle +// ─────────────────────────────────────────────────────────────────────────── + +/// Build the deterministic SoA fixture and return its handle. +/// +/// Bulk by construction: allocates and fills three lanes of `n_rows` elements. +/// The generation algorithm is normative — see [`crate::fixture::Fixture`]. +/// # Safety +/// +/// A null pointer is *handled*, not UB: it returns `NULL_ARGUMENT`. Beyond +/// that, `out_handle` must be a valid, aligned, writable `u64` (Java passes an 8-byte segment). It is written only on success. +/// +/// `unsafe` here is a note to Rust callers linking the `rlib`. The JVM, +/// which is the real caller, has no such concept — it upholds the same +/// contract by construction, because every pointer it passes comes from a +/// `MemorySegment` whose size and alignment it derived from the manifest. +#[no_mangle] +pub unsafe extern "C" fn lgj_pattern_open(n_rows: u64, seed: u64, out_handle: *mut u64) -> i32 { + guard(|| { + if out_handle.is_null() { + return LGJ_ERR_NULL_ARGUMENT; + } + match registry::open_pattern(n_rows, seed) { + Ok(h) => { + // SAFETY: non-null (checked above) and Java passes a segment of + // at least 8 bytes; the write happens only on success, so a + // failed open never scribbles on the caller's slot. + unsafe { *out_handle = h }; + LGJ_OK + } + Err(e) => e, + } + }) +} + +/// Free a resource: its lanes are dropped, its generation is bumped, and its +/// children begin failing with `PARENT_CLOSED`. +/// +/// A second close, or a fabricated handle, returns `INVALID_HANDLE` — never a +/// double free. +#[no_mangle] +pub extern "C" fn lgj_close(handle: u64) -> i32 { + guard(|| status(registry::close(handle))) +} + +/// Describe a resource without touching its payload. +/// # Safety +/// +/// A null pointer is *handled*, not UB: it returns `NULL_ARGUMENT`. Beyond +/// that, `out` must be a valid, aligned, writable `LgjResourceInfo` (32 bytes, align 8 — the manifest reports both). Written only on success. +/// +/// `unsafe` here is a note to Rust callers linking the `rlib`. The JVM, +/// which is the real caller, has no such concept — it upholds the same +/// contract by construction, because every pointer it passes comes from a +/// `MemorySegment` whose size and alignment it derived from the manifest. +#[no_mangle] +pub unsafe extern "C" fn lgj_resource_info(handle: u64, out: *mut LgjResourceInfo) -> i32 { + guard(|| { + if out.is_null() { + return LGJ_ERR_NULL_ARGUMENT; + } + let entry = match registry::resolve(handle) { + Ok(e) => e, + Err(e) => return e, + }; + // SAFETY: non-null, and `LgjResourceInfo` is `#[repr(C)]` with the exact + // layout the manifest reports and Java's MemoryLayout mirrors. + unsafe { *out = entry.info() }; + LGJ_OK + }) +} + +// ─────────────────────────────────────────────────────────────────────────── +// Lanes +// ─────────────────────────────────────────────────────────────────────────── + +/// Describe one lane of a **pattern**: `0 = ids (U64)`, `1 = classes (U32)`, +/// `2 = values (I32)`. +/// +/// All pattern lanes are `READABLE | CONTIGUOUS` and never `WRITABLE` +/// (abi.md §7). The returned `addr` is stable until `lgj_close` — lanes are +/// allocated once and never moved or resized (§4). +/// # Safety +/// +/// A null pointer is *handled*, not UB: it returns `NULL_ARGUMENT`. Beyond +/// that, `out` must be a valid, aligned, writable `LgjLaneDesc` (56 bytes, align 8 — the manifest reports both). Written only on success. +/// +/// `unsafe` here is a note to Rust callers linking the `rlib`. The JVM, +/// which is the real caller, has no such concept — it upholds the same +/// contract by construction, because every pointer it passes comes from a +/// `MemorySegment` whose size and alignment it derived from the manifest. +#[no_mangle] +pub unsafe extern "C" fn lgj_lane_describe( + handle: u64, + lane_id: u32, + out: *mut LgjLaneDesc, +) -> i32 { + guard(|| { + if out.is_null() { + return LGJ_ERR_NULL_ARGUMENT; + } + let entry = match registry::resolve_kind(handle, LGJ_RESOURCE_PATTERN) { + Ok(e) => e, + Err(e) => return e, + }; + let fixture = match entry.fixture() { + Some(f) => f, + None => return LGJ_ERR_WRONG_RESOURCE_KIND, + }; + let (addr, len_elems, kind) = match fixture.lane_raw(lane_id) { + Some(t) => t, + None => return LGJ_ERR_INVALID_LANE, + }; + let elem_bytes = kind.elem_bytes(); + let desc = LgjLaneDesc { + addr, + len_elems, + byte_len: len_elems * elem_bytes as u64, + owner: handle, + epoch: entry.epoch, + elem_kind: kind as u32, + elem_bytes, + stride_bytes: elem_bytes, + flags: crate::fixture::Fixture::lane_flags(), + }; + // SAFETY: non-null; `LgjLaneDesc` is `#[repr(C)]`, 56 bytes, and that + // size is asserted at compile time and reported by the manifest. + unsafe { *out = desc }; + LGJ_OK + }) +} + +// ─────────────────────────────────────────────────────────────────────────── +// Masks +// ─────────────────────────────────────────────────────────────────────────── + +/// Create a mask over `parent`. `initial`: `0` = empty, `1` = all rows set. +/// # Safety +/// +/// A null pointer is *handled*, not UB: it returns `NULL_ARGUMENT`. Beyond +/// that, `out_handle` must be a valid, aligned, writable `u64`. Written only on success. +/// +/// `unsafe` here is a note to Rust callers linking the `rlib`. The JVM, +/// which is the real caller, has no such concept — it upholds the same +/// contract by construction, because every pointer it passes comes from a +/// `MemorySegment` whose size and alignment it derived from the manifest. +#[no_mangle] +pub unsafe extern "C" fn lgj_mask_create(parent: u64, initial: u32, out_handle: *mut u64) -> i32 { + guard(|| { + if out_handle.is_null() { + return LGJ_ERR_NULL_ARGUMENT; + } + match registry::create_mask(parent, initial) { + Ok(h) => { + // SAFETY: non-null, checked above; written only on success. + unsafe { *out_handle = h }; + LGJ_OK + } + Err(e) => e, + } + }) +} + +/// Describe a mask's word lane: `MASK_WORD`, `READABLE | WRITABLE | +/// CONTIGUOUS`. +/// +/// A `MASK_WORD` is a `u64` of 64 packed row bits, LSB = lowest row index. Java +/// may write these words directly through the segment — that is what +/// `WRITABLE` means, and it is the reason a `.where(...)` chain needs no +/// crossing per row. +/// +/// Honest note on locking: Rust-side ops take the mask's inner lock, but Java's +/// direct writes go through the raw segment and are outside that discipline. +/// That is sound for the POC because its Java layer is single-threaded; a +/// concurrent Java writer would need a documented protocol, which this ABI +/// version does not define. +/// # Safety +/// +/// A null pointer is *handled*, not UB: it returns `NULL_ARGUMENT`. Beyond +/// that, `out` must be a valid, aligned, writable `LgjLaneDesc`. Written only on success. +/// +/// `unsafe` here is a note to Rust callers linking the `rlib`. The JVM, +/// which is the real caller, has no such concept — it upholds the same +/// contract by construction, because every pointer it passes comes from a +/// `MemorySegment` whose size and alignment it derived from the manifest. +#[no_mangle] +pub unsafe extern "C" fn lgj_mask_describe(mask: u64, out: *mut LgjLaneDesc) -> i32 { + guard(|| { + if out.is_null() { + return LGJ_ERR_NULL_ARGUMENT; + } + let (entry, _parent) = match registry::resolve_mask_with_parent(mask) { + Ok(p) => p, + Err(e) => return e, + }; + let g = match entry.read_mask() { + Some(g) => g, + None => return LGJ_ERR_WRONG_RESOURCE_KIND, + }; + let len_elems = g.words.len() as u64; + let desc = LgjLaneDesc { + // The boxed slice's buffer never moves, so this address stays valid + // for the mask's whole life — releasing the lock below does not + // invalidate it. + addr: g.words.as_ptr() as u64, + len_elems, + byte_len: len_elems * 8, + owner: mask, + epoch: entry.epoch, + elem_kind: LgjElemKind::MaskWord as u32, + elem_bytes: 8, + stride_bytes: 8, + flags: LGJ_FLAG_READABLE | LGJ_FLAG_WRITABLE | LGJ_FLAG_CONTIGUOUS, + }; + drop(g); + // SAFETY: non-null, checked above; `#[repr(C)]` 56-byte struct. + unsafe { *out = desc }; + LGJ_OK + }) +} + +/// Shared body of `lgj_mask_and` / `lgj_mask_or`. +/// +/// Handles the three aliasing cases `abi.md` §7 permits (`dst` may alias `a` or +/// `b`) by **deduplicating before locking**: locking one `RwLock` twice from one +/// thread is a hang, and taking `&mut` and `&` to one payload is not +/// expressible. Distinct entries are then locked in address order +/// ([`registry::lock_masks_ordered`]) so no two threads can build a cycle. +fn mask_binop(a: u64, b: u64, dst: u64, combine: u32) -> i32 { + let (ea, pa) = match registry::resolve_mask_with_parent(a) { + Ok(t) => t, + Err(e) => return e, + }; + let (eb, _pb) = match registry::resolve_mask_with_parent(b) { + Ok(t) => t, + Err(e) => return e, + }; + let (ed, _pd) = match registry::resolve_mask_with_parent(dst) { + Ok(t) => t, + Err(e) => return e, + }; + + // "All three must share the same parent and row count" (§7). Row count is + // the property the kernels depend on; the shared-parent requirement is + // checked too, and both map to MASK_LENGTH_MISMATCH — abi.md allocates no + // distinct "different parents" code, and this is the code whose meaning + // ("these masks do not belong together") covers it. + if ea.n_rows != eb.n_rows || ea.n_rows != ed.n_rows { + return LGJ_ERR_MASK_LENGTH_MISMATCH; + } + if ea.parent != eb.parent || ea.parent != ed.parent { + return LGJ_ERR_MASK_LENGTH_MISMATCH; + } + let n_rows = pa.n_rows; + + let d_is_a = std::sync::Arc::ptr_eq(&ed, &ea); + let d_is_b = std::sync::Arc::ptr_eq(&ed, &eb); + let a_is_b = std::sync::Arc::ptr_eq(&ea, &eb); + + let path = Path::Simd; + let result = if d_is_a && d_is_b { + // dst = dst op dst — identity for both AND and OR. Still normalize the + // tail so a hand-written Java word cannot leave garbage past n_rows. + let mut g = registry::lock_masks_ordered(&[&ed]).map(|mut g| g[0].take().unwrap()); + match &mut g { + Ok(gd) => { + clear_tail_bits(&mut gd.words, n_rows); + Ok(()) + } + Err(e) => Err(*e), + } + } else if d_is_a || d_is_b { + // dst aliases one operand ⇒ in-place: dst op= other. + let other = if d_is_a { &eb } else { &ea }; + match registry::lock_masks_ordered(&[&ed, other]) { + Ok(mut guards) => { + let (gd, rest) = guards.split_at_mut(1); + let gd = gd[0].as_mut().unwrap(); + let go = rest[0].as_ref().unwrap(); + let r = kernels::combine_into(path, combine, &mut gd.words, &go.words); + if r.is_ok() { + clear_tail_bits(&mut gd.words, n_rows); + } + r + } + Err(e) => Err(e), + } + } else if a_is_b { + // a and b are the same mask, dst is separate ⇒ dst = a op a = a. + match registry::lock_masks_ordered(&[&ed, &ea]) { + Ok(mut guards) => { + let (gd, rest) = guards.split_at_mut(1); + let gd = gd[0].as_mut().unwrap(); + let ga = rest[0].as_ref().unwrap(); + gd.words.copy_from_slice(&ga.words); + clear_tail_bits(&mut gd.words, n_rows); + Ok(()) + } + Err(e) => Err(e), + } + } else { + // Three distinct masks: dst = a op b, no copy. + match registry::lock_masks_ordered(&[&ed, &ea, &eb]) { + Ok(mut guards) => { + let (gd, rest) = guards.split_at_mut(1); + let gd = gd[0].as_mut().unwrap(); + let ga = rest[0].as_ref().unwrap(); + let gb = rest[1].as_ref().unwrap(); + let r = match combine { + LGJ_COMBINE_AND => { + kernels::simd_mask_and(&ga.words, &gb.words, &mut gd.words); + Ok(()) + } + LGJ_COMBINE_OR => { + kernels::simd_mask_or(&ga.words, &gb.words, &mut gd.words); + Ok(()) + } + _ => Err(LGJ_ERR_UNKNOWN_OPCODE), + }; + if r.is_ok() { + clear_tail_bits(&mut gd.words, n_rows); + } + r + } + Err(e) => Err(e), + } + }; + status(result) +} + +/// `dst = a & b`. `dst` may alias `a` or `b`. All three must share the same +/// parent and row count. +#[no_mangle] +pub extern "C" fn lgj_mask_and(a: u64, b: u64, dst: u64) -> i32 { + guard(|| mask_binop(a, b, dst, LGJ_COMBINE_AND)) +} + +/// `dst = a | b`. Same aliasing and compatibility rules as [`lgj_mask_and`]. +#[no_mangle] +pub extern "C" fn lgj_mask_or(a: u64, b: u64, dst: u64) -> i32 { + guard(|| mask_binop(a, b, dst, LGJ_COMBINE_OR)) +} + +/// Population count of a mask — how many rows are selected. +/// # Safety +/// +/// A null pointer is *handled*, not UB: it returns `NULL_ARGUMENT`. Beyond +/// that, `out_count` must be a valid, aligned, writable `u64`. Written only on success. +/// +/// `unsafe` here is a note to Rust callers linking the `rlib`. The JVM, +/// which is the real caller, has no such concept — it upholds the same +/// contract by construction, because every pointer it passes comes from a +/// `MemorySegment` whose size and alignment it derived from the manifest. +#[no_mangle] +pub unsafe extern "C" fn lgj_mask_count(mask: u64, out_count: *mut u64) -> i32 { + guard(|| { + if out_count.is_null() { + return LGJ_ERR_NULL_ARGUMENT; + } + let (entry, _parent) = match registry::resolve_mask_with_parent(mask) { + Ok(t) => t, + Err(e) => return e, + }; + let g = match entry.read_mask() { + Some(g) => g, + None => return LGJ_ERR_WRONG_RESOURCE_KIND, + }; + let count = kernels::popcount(Path::Simd, &g.words); + drop(g); + // SAFETY: non-null, checked above; written only on success. + unsafe { *out_count = count }; + LGJ_OK + }) +} + +// ─────────────────────────────────────────────────────────────────────────── +// Unfused bulk predicates +// ─────────────────────────────────────────────────────────────────────────── + +/// Resolve `(pattern, mask)` for a predicate and check their row counts agree. +fn resolve_pattern_and_mask( + res: u64, + dst_mask: u64, +) -> Result<(std::sync::Arc, std::sync::Arc), i32> { + let pattern = registry::resolve_kind(res, LGJ_RESOURCE_PATTERN)?; + let (mask, _parent) = registry::resolve_mask_with_parent(dst_mask)?; + // abi.md names only the row-count condition here (§3 MASK_LENGTH_MISMATCH), + // so a mask from a *different but equally sized* pattern is accepted. That + // is a deliberate reading: the kernels care about length, and the Java layer + // never constructs such a pairing. + if mask.n_rows != pattern.n_rows { + return Err(LGJ_ERR_MASK_LENGTH_MISMATCH); + } + Ok((pattern, mask)) +} + +/// Read a pattern lane as a typed view. +fn lane_view<'a>(entry: &'a ResourceEntry, lane_id: u32) -> Result, i32> { + let f = entry.fixture().ok_or(LGJ_ERR_WRONG_RESOURCE_KIND)?; + match lane_id { + crate::fixture::LANE_IDS => Ok(LaneView::U64(f.ids())), + crate::fixture::LANE_CLASSES => Ok(LaneView::U32(f.classes())), + crate::fixture::LANE_VALUES => Ok(LaneView::I32(f.values())), + _ => Err(LGJ_ERR_INVALID_LANE), + } +} + +/// One predicate, one crossing: **overwrites** `dst_mask` with +/// `lane[i] == needle`. +/// +/// Composition is the caller's job (`lgj_mask_and`). This unfused form exists so +/// the fused plan has something to be benchmarked *against* and so parity can be +/// checked predicate-by-predicate — not because a chain should be built from it. +#[no_mangle] +pub extern "C" fn lgj_op_eq_u32(res: u64, lane_id: u32, needle: u32, dst_mask: u64) -> i32 { + guard(|| { + let (pattern, mask) = match resolve_pattern_and_mask(res, dst_mask) { + Ok(t) => t, + Err(e) => return e, + }; + let lane = match lane_view(&pattern, lane_id) { + Ok(l) => l, + Err(e) => return e, + }; + if lane.kind() != LgjElemKind::U32 { + return LGJ_ERR_LANE_KIND_MISMATCH; + } + let mut g = match mask.write_mask() { + Some(g) => g, + None => return LGJ_ERR_WRONG_RESOURCE_KIND, + }; + let n_rows = pattern.n_rows; + status(kernels::eval_predicate( + Path::Simd, + LGJ_OP_EQ_U32, + needle as i64, + &lane, + n_rows, + &mut g.words, + )) + }) +} + +/// One predicate, one crossing: **overwrites** `dst_mask` with +/// `lane[i] > threshold`, signed. +#[no_mangle] +pub extern "C" fn lgj_op_gt_i32(res: u64, lane_id: u32, threshold: i32, dst_mask: u64) -> i32 { + guard(|| { + let (pattern, mask) = match resolve_pattern_and_mask(res, dst_mask) { + Ok(t) => t, + Err(e) => return e, + }; + let lane = match lane_view(&pattern, lane_id) { + Ok(l) => l, + Err(e) => return e, + }; + if lane.kind() != LgjElemKind::I32 { + return LGJ_ERR_LANE_KIND_MISMATCH; + } + let mut g = match mask.write_mask() { + Some(g) => g, + None => return LGJ_ERR_WRONG_RESOURCE_KIND, + }; + let n_rows = pattern.n_rows; + status(kernels::eval_predicate( + Path::Simd, + LGJ_OP_GT_I32, + threshold as i64, + &lane, + n_rows, + &mut g.words, + )) + }) +} + +// ─────────────────────────────────────────────────────────────────────────── +// The fused plan — N predicates, ONE crossing +// ─────────────────────────────────────────────────────────────────────────── + +/// Validate an entire plan **before** any work happens. +/// +/// This is what makes "a bad op leaves `dst_mask` untouched" a property rather +/// than a hope: nothing is written until every op has passed. `abi.md`'s +/// monotonic-narrowing guarantee would otherwise be observable in a +/// half-applied state, and a Java caller retrying after an error would be +/// composing against garbage. +fn validate_plan(pattern: &ResourceEntry, ops: &[LgjOpDesc]) -> Result<(), i32> { + if ops.is_empty() { + return Err(LGJ_ERR_EMPTY_PLAN); + } + for op in ops { + // A non-zero reserved field means the caller was compiled against a + // newer ABI that gave it meaning. Refusing is the only safe reading; + // ignoring it would silently drop semantics. + if op._reserved != 0 { + return Err(LGJ_ERR_NULL_ARGUMENT); + } + let required = opcode_required_kind(op.op).ok_or(LGJ_ERR_UNKNOWN_OPCODE)?; + if op.combine != LGJ_COMBINE_AND && op.combine != LGJ_COMBINE_OR { + return Err(LGJ_ERR_UNKNOWN_OPCODE); + } + if op.lane_id >= PATTERN_LANE_COUNT { + return Err(LGJ_ERR_INVALID_LANE); + } + let lane = lane_view(pattern, op.lane_id)?; + if lane.kind() != required { + return Err(LGJ_ERR_LANE_KIND_MISMATCH); + } + } + Ok(()) +} + +/// The body behind both `lgj_plan_eval` and `lgj_plan_eval_scalar` — one code +/// path, two symbols, so the parity test compares two *paths* rather than a +/// function against itself. +fn plan_eval_impl( + res: u64, + ops: *const LgjOpDesc, + n_ops: u32, + dst_mask: u64, + out_count: *mut u64, + path: Path, +) -> i32 { + // EMPTY_PLAN is checked before the null test: `(null, 0)` is a caller + // describing an empty plan, which has its own dedicated code, and reporting + // NULL_ARGUMENT there would send a Java author looking for the wrong bug. + if n_ops == 0 { + return LGJ_ERR_EMPTY_PLAN; + } + if ops.is_null() || out_count.is_null() { + return LGJ_ERR_NULL_ARGUMENT; + } + let (pattern, mask) = match resolve_pattern_and_mask(res, dst_mask) { + Ok(t) => t, + Err(e) => return e, + }; + // SAFETY: `ops` is non-null (checked) and the caller states it points at + // `n_ops` contiguous `LgjOpDesc`. Java builds that array with a + // MemoryLayout whose size and alignment the manifest reports (24 / 8), and + // the compile-time asserts in `abi.rs` guarantee this side agrees. The slice + // is read-only and does not outlive this call. + let ops: &[LgjOpDesc] = unsafe { std::slice::from_raw_parts(ops, n_ops as usize) }; + + if let Err(e) = validate_plan(&pattern, ops) { + return e; + } + + let n_rows = pattern.n_rows; + let n_words = mask_words_for(n_rows) as usize; + + // Accumulate into scratch, then publish. Two consequences, both wanted: + // dst_mask is written exactly once, and an error at any point leaves it + // byte-for-byte as it was. + let mut acc = vec![0u64; n_words]; + // "the accumulator starts as all rows set" (§7). + for w in acc.iter_mut() { + *w = u64::MAX; + } + clear_tail_bits(&mut acc, n_rows); + let mut scratch = vec![0u64; n_words]; + + for op in ops { + let lane = match lane_view(&pattern, op.lane_id) { + Ok(l) => l, + Err(e) => return e, + }; + if let Err(e) = + kernels::eval_predicate(path, op.op, op.operand, &lane, n_rows, &mut scratch) + { + return e; + } + if let Err(e) = kernels::combine_into(path, op.combine, &mut acc, &scratch) { + return e; + } + } + clear_tail_bits(&mut acc, n_rows); + let count = kernels::popcount(path, &acc); + + let mut g = match mask.write_mask() { + Some(g) => g, + None => return LGJ_ERR_WRONG_RESOURCE_KIND, + }; + if g.words.len() != acc.len() { + return LGJ_ERR_MASK_LENGTH_MISMATCH; + } + g.words.copy_from_slice(&acc); + drop(g); + + // SAFETY: non-null (checked above); written only on the success path. + unsafe { *out_count = count }; + LGJ_OK +} + +/// Evaluate `n_ops` predicates in **one** crossing. +/// +/// This is the symbol that makes `.where(...).where(...).count()` cost one +/// downcall regardless of how many predicates or rows are involved — the +/// concrete form of abi.md §6's anti-JNI rule. +/// +/// Semantics: the accumulator starts as all rows set; each op is evaluated and +/// combined per its `combine` field; the result lands in `dst_mask` and its +/// popcount in `out_count`. With every `combine = AND` the sequence narrows +/// monotonically by construction (`V(k+1) ⊆ V(k)`). +/// # Safety +/// +/// A null pointer is *handled*, not UB: it returns `NULL_ARGUMENT`. Beyond +/// that, `ops` must point to `n_ops` contiguous, initialized `LgjOpDesc` (24 bytes, align 8) that stay valid for the call; `out_count` must be a valid, aligned, writable `u64`. Both are read/written only after the null checks, and `out_count` only on success. +/// +/// `unsafe` here is a note to Rust callers linking the `rlib`. The JVM, +/// which is the real caller, has no such concept — it upholds the same +/// contract by construction, because every pointer it passes comes from a +/// `MemorySegment` whose size and alignment it derived from the manifest. +#[no_mangle] +pub unsafe extern "C" fn lgj_plan_eval( + res: u64, + ops: *const LgjOpDesc, + n_ops: u32, + dst_mask: u64, + out_count: *mut u64, +) -> i32 { + guard(|| plan_eval_impl(res, ops, n_ops, dst_mask, out_count, Path::Simd)) +} + +/// Identical semantics to [`lgj_plan_eval`], forced down the **scalar +/// reference** path. +/// +/// Exists only so SIMD-vs-scalar parity is falsifiable *through the membrane*, +/// which is where the Java tests live. Not for production use. +/// # Safety +/// +/// A null pointer is *handled*, not UB: it returns `NULL_ARGUMENT`. Beyond +/// that, Identical to [`lgj_plan_eval`]: `ops` must point to `n_ops` valid `LgjOpDesc` and `out_count` to a writable `u64`. +/// +/// `unsafe` here is a note to Rust callers linking the `rlib`. The JVM, +/// which is the real caller, has no such concept — it upholds the same +/// contract by construction, because every pointer it passes comes from a +/// `MemorySegment` whose size and alignment it derived from the manifest. +#[no_mangle] +pub unsafe extern "C" fn lgj_plan_eval_scalar( + res: u64, + ops: *const LgjOpDesc, + n_ops: u32, + dst_mask: u64, + out_count: *mut u64, +) -> i32 { + guard(|| plan_eval_impl(res, ops, n_ops, dst_mask, out_count, Path::Scalar)) +} + +// ─────────────────────────────────────────────────────────────────────────── +// Reduction +// ─────────────────────────────────────────────────────────────────────────── + +/// Sum an `I32` lane over the set bits of `mask`, widened to `i64`. +/// +/// No overflow for `n_rows ≤ 2^32` on `i32` inputs, because each element is +/// widened *before* accumulation. +/// # Safety +/// +/// A null pointer is *handled*, not UB: it returns `NULL_ARGUMENT`. Beyond +/// that, `out_sum` must be a valid, aligned, writable `i64`. Written only on success. +/// +/// `unsafe` here is a note to Rust callers linking the `rlib`. The JVM, +/// which is the real caller, has no such concept — it upholds the same +/// contract by construction, because every pointer it passes comes from a +/// `MemorySegment` whose size and alignment it derived from the manifest. +#[no_mangle] +pub unsafe extern "C" fn lgj_reduce_sum_i32( + res: u64, + lane_id: u32, + mask: u64, + out_sum: *mut i64, +) -> i32 { + guard(|| { + if out_sum.is_null() { + return LGJ_ERR_NULL_ARGUMENT; + } + let (pattern, maskr) = match resolve_pattern_and_mask(res, mask) { + Ok(t) => t, + Err(e) => return e, + }; + let lane = match lane_view(&pattern, lane_id) { + Ok(l) => l, + Err(e) => return e, + }; + let values = match lane { + LaneView::I32(v) => v, + _ => return LGJ_ERR_LANE_KIND_MISMATCH, + }; + let g = match maskr.read_mask() { + Some(g) => g, + None => return LGJ_ERR_WRONG_RESOURCE_KIND, + }; + let sum = kernels::masked_sum_i32(Path::Simd, values, &g.words); + drop(g); + // SAFETY: non-null, checked above; written only on success. + unsafe { *out_sum = sum }; + LGJ_OK + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fixture::{Fixture, LANE_CLASSES, LANE_IDS, LANE_VALUES}; + + /// Safe wrappers over the pointer-taking exports. + /// + /// These are not a second implementation: each one is a direct call into the + /// exported symbol. They exist so the `unsafe` required by a Rust caller + /// lives in one place and the assertions below read as the ABI contract + /// rather than as pointer plumbing. Null pointers are passed straight + /// through — the NULL_ARGUMENT tests depend on that. + mod call { + use super::*; + + pub fn pattern_open(n: u64, seed: u64, out: *mut u64) -> i32 { + unsafe { lgj_pattern_open(n, seed, out) } + } + pub fn resource_info(h: u64, out: *mut LgjResourceInfo) -> i32 { + unsafe { lgj_resource_info(h, out) } + } + pub fn lane_describe(h: u64, lane: u32, out: *mut LgjLaneDesc) -> i32 { + unsafe { lgj_lane_describe(h, lane, out) } + } + pub fn mask_create(p: u64, initial: u32, out: *mut u64) -> i32 { + unsafe { lgj_mask_create(p, initial, out) } + } + pub fn mask_describe(m: u64, out: *mut LgjLaneDesc) -> i32 { + unsafe { lgj_mask_describe(m, out) } + } + pub fn mask_count(m: u64, out: *mut u64) -> i32 { + unsafe { lgj_mask_count(m, out) } + } + pub fn plan_eval(r: u64, ops: *const LgjOpDesc, n: u32, dst: u64, out: *mut u64) -> i32 { + unsafe { lgj_plan_eval(r, ops, n, dst, out) } + } + pub fn plan_eval_scalar( + r: u64, + ops: *const LgjOpDesc, + n: u32, + dst: u64, + out: *mut u64, + ) -> i32 { + unsafe { lgj_plan_eval_scalar(r, ops, n, dst, out) } + } + pub fn reduce_sum_i32(r: u64, lane: u32, m: u64, out: *mut i64) -> i32 { + unsafe { lgj_reduce_sum_i32(r, lane, m, out) } + } + } + + /// Open a pattern, returning its handle. + fn open(n: u64, seed: u64) -> u64 { + let mut h = 0u64; + assert_eq!(call::pattern_open(n, seed, &mut h), LGJ_OK); + h + } + + fn mask(parent: u64, initial: u32) -> u64 { + let mut h = 0u64; + assert_eq!(call::mask_create(parent, initial, &mut h), LGJ_OK); + h + } + + fn count(m: u64) -> u64 { + let mut c = 0u64; + assert_eq!(call::mask_count(m, &mut c), LGJ_OK); + c + } + + /// Read a mask's words back out through the *described* segment — i.e. the + /// same way Java sees them, not through a Rust-side back door. + fn read_words(m: u64) -> Vec { + let mut d = LgjLaneDesc::default(); + assert_eq!(call::mask_describe(m, &mut d), LGJ_OK); + assert_eq!(d.elem_kind, LgjElemKind::MaskWord as u32); + assert_ne!(d.flags & LGJ_FLAG_WRITABLE, 0); + // SAFETY: the descriptor is exactly the contract Java relies on; the + // lane is alive because `m` has not been closed in this scope. + unsafe { std::slice::from_raw_parts(d.addr as *const u64, d.len_elems as usize).to_vec() } + } + + fn op(op: u32, lane_id: u32, operand: i64, combine: u32) -> LgjOpDesc { + LgjOpDesc { + op, + lane_id, + operand, + combine, + _reserved: 0, + } + } + + // ── manifest ─────────────────────────────────────────────────────────── + + #[test] + fn manifest_pointer_is_stable_and_populated() { + let p1 = lgj_abi_manifest(); + let p2 = lgj_abi_manifest(); + assert_eq!(p1, p2, "must be a 'static, not a fresh allocation"); + // SAFETY: a pointer to a 'static. + let m = unsafe { &*p1 }; + assert_eq!(m.magic, LGJ_MAGIC); + assert_eq!(m.size_of_lane_desc, 56); + assert_eq!(m.endianness, 0, "this box is little-endian"); + } + + // ── lifecycle / handle safety ────────────────────────────────────────── + + #[test] + fn open_describe_close_round_trip() { + let p = open(1000, 42); + let mut info = LgjResourceInfo::default(); + assert_eq!(call::resource_info(p, &mut info), LGJ_OK); + assert_eq!(info.kind, LGJ_RESOURCE_PATTERN); + assert_eq!(info.lane_count, 3); + assert_eq!(info.n_rows, 1000); + assert_eq!(info.parent, 0); + assert!(info.epoch > 0); + assert_eq!(lgj_close(p), LGJ_OK); + } + + #[test] + fn every_stale_handle_shape_is_a_status_not_a_crash() { + let p = open(64, 1); + let m = mask(p, LGJ_MASK_INIT_ALL); + assert_eq!(lgj_close(m), LGJ_OK); + assert_eq!(lgj_close(p), LGJ_OK); + + // use-after-close + let mut info = LgjResourceInfo::default(); + assert_eq!(call::resource_info(p, &mut info), LGJ_ERR_INVALID_HANDLE); + // double close + assert_eq!(lgj_close(p), LGJ_ERR_INVALID_HANDLE); + assert_eq!(lgj_close(m), LGJ_ERR_INVALID_HANDLE); + // fabricated + for bogus in [0u64, 1, 0xDEAD_BEEF, u64::MAX] { + assert_eq!( + call::resource_info(bogus, &mut info), + LGJ_ERR_INVALID_HANDLE + ); + let mut d = LgjLaneDesc::default(); + assert_eq!( + call::lane_describe(bogus, 0, &mut d), + LGJ_ERR_INVALID_HANDLE + ); + let mut c = 0u64; + assert_eq!(call::mask_count(bogus, &mut c), LGJ_ERR_INVALID_HANDLE); + } + } + + #[test] + fn a_mask_whose_parent_closed_reports_parent_closed() { + let p = open(500, 3); + let m = mask(p, LGJ_MASK_INIT_ALL); + assert_eq!(count(m), 500); + assert_eq!(lgj_close(p), LGJ_OK); + + let mut c = 0u64; + assert_eq!(call::mask_count(m, &mut c), LGJ_ERR_PARENT_CLOSED); + let mut d = LgjLaneDesc::default(); + assert_eq!(call::mask_describe(m, &mut d), LGJ_ERR_PARENT_CLOSED); + assert_eq!(lgj_mask_and(m, m, m), LGJ_ERR_PARENT_CLOSED); + let ops = [op(LGJ_OP_EQ_U32, LANE_CLASSES, 7, LGJ_COMBINE_AND)]; + let mut n = 0u64; + assert_eq!( + call::plan_eval(p, ops.as_ptr(), 1, m, &mut n), + LGJ_ERR_INVALID_HANDLE, + "the pattern handle is stale, which is reported first" + ); + // The mask itself still exists — it just cannot work. + assert_eq!(lgj_close(m), LGJ_OK); + } + + #[test] + fn wrong_kind_is_reported_as_such() { + let p = open(64, 1); + let m = mask(p, 0); + let mut d = LgjLaneDesc::default(); + // a mask where a pattern was required + assert_eq!( + call::lane_describe(m, 0, &mut d), + LGJ_ERR_WRONG_RESOURCE_KIND + ); + // a pattern where a mask was required + assert_eq!(call::mask_describe(p, &mut d), LGJ_ERR_WRONG_RESOURCE_KIND); + let mut c = 0u64; + assert_eq!(call::mask_count(p, &mut c), LGJ_ERR_WRONG_RESOURCE_KIND); + lgj_close(m); + lgj_close(p); + } + + #[test] + fn null_out_pointers_are_rejected() { + let p = open(64, 1); + let m = mask(p, 0); + assert_eq!( + call::pattern_open(8, 1, std::ptr::null_mut()), + LGJ_ERR_NULL_ARGUMENT + ); + assert_eq!( + call::resource_info(p, std::ptr::null_mut()), + LGJ_ERR_NULL_ARGUMENT + ); + assert_eq!( + call::lane_describe(p, 0, std::ptr::null_mut()), + LGJ_ERR_NULL_ARGUMENT + ); + assert_eq!( + call::mask_create(p, 0, std::ptr::null_mut()), + LGJ_ERR_NULL_ARGUMENT + ); + assert_eq!( + call::mask_describe(m, std::ptr::null_mut()), + LGJ_ERR_NULL_ARGUMENT + ); + assert_eq!( + call::mask_count(m, std::ptr::null_mut()), + LGJ_ERR_NULL_ARGUMENT + ); + assert_eq!( + call::reduce_sum_i32(p, LANE_VALUES, m, std::ptr::null_mut()), + LGJ_ERR_NULL_ARGUMENT + ); + let ops = [op(LGJ_OP_EQ_U32, LANE_CLASSES, 7, LGJ_COMBINE_AND)]; + let mut n = 0u64; + assert_eq!( + call::plan_eval(p, std::ptr::null(), 1, m, &mut n), + LGJ_ERR_NULL_ARGUMENT + ); + assert_eq!( + call::plan_eval(p, ops.as_ptr(), 1, m, std::ptr::null_mut()), + LGJ_ERR_NULL_ARGUMENT + ); + lgj_close(m); + lgj_close(p); + } + + // ── lanes ────────────────────────────────────────────────────────────── + + #[test] + fn pattern_lanes_are_readable_contiguous_and_never_writable() { + let p = open(777, 9); + for (lane, kind, bytes) in [ + (LANE_IDS, LgjElemKind::U64, 8u32), + (LANE_CLASSES, LgjElemKind::U32, 4), + (LANE_VALUES, LgjElemKind::I32, 4), + ] { + let mut d = LgjLaneDesc::default(); + assert_eq!(call::lane_describe(p, lane, &mut d), LGJ_OK); + assert_eq!(d.elem_kind, kind as u32); + assert_eq!(d.elem_bytes, bytes); + assert_eq!(d.stride_bytes, bytes, "contiguous ⇒ stride == elem_bytes"); + assert_eq!(d.len_elems, 777); + assert_eq!(d.byte_len, 777 * bytes as u64); + assert_eq!(d.owner, p); + assert_ne!(d.flags & LGJ_FLAG_READABLE, 0); + assert_ne!(d.flags & LGJ_FLAG_CONTIGUOUS, 0); + assert_eq!( + d.flags & LGJ_FLAG_WRITABLE, + 0, + "pattern lanes are read-only" + ); + assert_ne!(d.addr, 0); + } + let mut d = LgjLaneDesc::default(); + assert_eq!(call::lane_describe(p, 3, &mut d), LGJ_ERR_INVALID_LANE); + assert_eq!( + call::lane_describe(p, u32::MAX, &mut d), + LGJ_ERR_INVALID_LANE + ); + lgj_close(p); + } + + /// The lane bytes Java would read must be the fixture's bytes — this is the + /// only test that crosses the descriptor boundary the way the FFM layer does. + #[test] + fn described_lane_bytes_match_the_generator() { + let n = 300u64; + let p = open(n, 0xABCD); + let expected = Fixture::generate(n, 0xABCD).unwrap(); + let mut d = LgjLaneDesc::default(); + assert_eq!(call::lane_describe(p, LANE_VALUES, &mut d), LGJ_OK); + // SAFETY: descriptor is live; `p` is open. + let seen = + unsafe { std::slice::from_raw_parts(d.addr as *const i32, d.len_elems as usize) }; + assert_eq!(seen, expected.values()); + lgj_close(p); + } + + #[test] + fn lane_epoch_matches_resource_epoch() { + let p = open(10, 1); + let mut info = LgjResourceInfo::default(); + call::resource_info(p, &mut info); + let mut d = LgjLaneDesc::default(); + call::lane_describe(p, 0, &mut d); + assert_eq!(d.epoch, info.epoch); + lgj_close(p); + } + + /// A reused slot must not reuse an epoch, or Java could mistake a dead + /// segment for a live one. + #[test] + fn epochs_are_never_reused() { + let p1 = open(10, 1); + let mut i1 = LgjResourceInfo::default(); + call::resource_info(p1, &mut i1); + lgj_close(p1); + let p2 = open(10, 1); + let mut i2 = LgjResourceInfo::default(); + call::resource_info(p2, &mut i2); + assert_ne!(i1.epoch, i2.epoch); + lgj_close(p2); + } + + // ── masks ────────────────────────────────────────────────────────────── + + #[test] + fn mask_create_initial_states() { + let p = open(1000, 1); + let empty = mask(p, LGJ_MASK_INIT_EMPTY); + let all = mask(p, LGJ_MASK_INIT_ALL); + assert_eq!(count(empty), 0); + assert_eq!(count(all), 1000, "tail bits must not inflate the count"); + let mut h = 0u64; + assert_ne!(call::mask_create(p, 7, &mut h), LGJ_OK); + lgj_close(all); + lgj_close(empty); + lgj_close(p); + } + + #[test] + fn mask_and_or_over_distinct_masks() { + let p = open(200, 1); + let a = mask(p, LGJ_MASK_INIT_ALL); + let b = mask(p, LGJ_MASK_INIT_EMPTY); + let dst = mask(p, LGJ_MASK_INIT_ALL); + + assert_eq!(lgj_mask_and(a, b, dst), LGJ_OK); + assert_eq!(count(dst), 0); + assert_eq!(lgj_mask_or(a, b, dst), LGJ_OK); + assert_eq!(count(dst), 200); + + for h in [dst, b, a, p] { + lgj_close(h); + } + } + + /// abi.md §7: "`dst` may alias `a` or `b`." All four aliasing shapes. + #[test] + fn mask_binop_alias_cases() { + let p = open(300, 5); + let all = mask(p, LGJ_MASK_INIT_ALL); + let empty = mask(p, LGJ_MASK_INIT_EMPTY); + + // dst == a + let x = mask(p, LGJ_MASK_INIT_ALL); + assert_eq!(lgj_mask_and(x, empty, x), LGJ_OK); + assert_eq!(count(x), 0); + assert_eq!(lgj_mask_or(x, all, x), LGJ_OK); + assert_eq!(count(x), 300); + + // dst == b + let y = mask(p, LGJ_MASK_INIT_ALL); + assert_eq!(lgj_mask_and(empty, y, y), LGJ_OK); + assert_eq!(count(y), 0); + assert_eq!(lgj_mask_or(all, y, y), LGJ_OK); + assert_eq!(count(y), 300); + + // dst == a == b (identity) + let z = mask(p, LGJ_MASK_INIT_ALL); + assert_eq!(lgj_mask_and(z, z, z), LGJ_OK); + assert_eq!(count(z), 300); + assert_eq!(lgj_mask_or(z, z, z), LGJ_OK); + assert_eq!(count(z), 300); + + // a == b, dst distinct (copy) + let w = mask(p, LGJ_MASK_INIT_EMPTY); + assert_eq!(lgj_mask_and(all, all, w), LGJ_OK); + assert_eq!(count(w), 300); + + for h in [w, z, y, x, empty, all, p] { + lgj_close(h); + } + } + + #[test] + fn mask_binop_rejects_mismatched_row_counts() { + let p1 = open(100, 1); + let p2 = open(200, 1); + let a = mask(p1, LGJ_MASK_INIT_ALL); + let b = mask(p2, LGJ_MASK_INIT_ALL); + assert_eq!(lgj_mask_and(a, b, a), LGJ_ERR_MASK_LENGTH_MISMATCH); + for h in [b, a, p2, p1] { + lgj_close(h); + } + } + + /// Masks of equal length but different parents are still not composable — + /// §7 requires a shared parent. + #[test] + fn mask_binop_rejects_different_parents() { + let p1 = open(128, 1); + let p2 = open(128, 2); + let a = mask(p1, LGJ_MASK_INIT_ALL); + let b = mask(p2, LGJ_MASK_INIT_ALL); + assert_eq!(lgj_mask_and(a, b, a), LGJ_ERR_MASK_LENGTH_MISMATCH); + for h in [b, a, p2, p1] { + lgj_close(h); + } + } + + // ── unfused predicates ───────────────────────────────────────────────── + + #[test] + fn unfused_predicates_match_a_hand_count() { + let n = 5000u64; + let p = open(n, 0x1234); + let f = Fixture::generate(n, 0x1234).unwrap(); + let m = mask(p, LGJ_MASK_INIT_EMPTY); + + assert_eq!(lgj_op_eq_u32(p, LANE_CLASSES, 7, m), LGJ_OK); + let want = f.classes().iter().filter(|&&c| c == 7).count() as u64; + assert_eq!(count(m), want); + assert!(want > 0 && want < n, "predicate must not be vacuous"); + + assert_eq!(lgj_op_gt_i32(p, LANE_VALUES, 100, m), LGJ_OK); + let want = f.values().iter().filter(|&&v| v > 100).count() as u64; + assert_eq!(count(m), want); + + // Each op OVERWRITES, so re-running the first restores its own count. + assert_eq!(lgj_op_eq_u32(p, LANE_CLASSES, 7, m), LGJ_OK); + assert_eq!( + count(m), + f.classes().iter().filter(|&&c| c == 7).count() as u64 + ); + lgj_close(m); + lgj_close(p); + } + + #[test] + fn predicates_reject_lane_kind_mismatch() { + let p = open(64, 1); + let m = mask(p, 0); + // eq_u32 against the I32 lane, and against the U64 lane + assert_eq!( + lgj_op_eq_u32(p, LANE_VALUES, 1, m), + LGJ_ERR_LANE_KIND_MISMATCH + ); + assert_eq!(lgj_op_eq_u32(p, LANE_IDS, 1, m), LGJ_ERR_LANE_KIND_MISMATCH); + // gt_i32 against the U32 lane + assert_eq!( + lgj_op_gt_i32(p, LANE_CLASSES, 1, m), + LGJ_ERR_LANE_KIND_MISMATCH + ); + assert_eq!(lgj_op_eq_u32(p, 9, 1, m), LGJ_ERR_INVALID_LANE); + lgj_close(m); + lgj_close(p); + } + + #[test] + fn predicate_rejects_a_mask_of_the_wrong_length() { + let p1 = open(100, 1); + let p2 = open(200, 1); + let m2 = mask(p2, 0); + assert_eq!( + lgj_op_eq_u32(p1, LANE_CLASSES, 7, m2), + LGJ_ERR_MASK_LENGTH_MISMATCH + ); + for h in [m2, p2, p1] { + lgj_close(h); + } + } + + // ── the fused plan ───────────────────────────────────────────────────── + + #[test] + fn fused_plan_equals_the_unfused_composition() { + let n = 8000u64; + let p = open(n, 77); + let a = mask(p, 0); + let b = mask(p, 0); + let fused = mask(p, 0); + + assert_eq!(lgj_op_eq_u32(p, LANE_CLASSES, 7, a), LGJ_OK); + assert_eq!(lgj_op_gt_i32(p, LANE_VALUES, 100, b), LGJ_OK); + assert_eq!(lgj_mask_and(a, b, a), LGJ_OK); + + let ops = [ + op(LGJ_OP_EQ_U32, LANE_CLASSES, 7, LGJ_COMBINE_AND), + op(LGJ_OP_GT_I32, LANE_VALUES, 100, LGJ_COMBINE_AND), + ]; + let mut c = 0u64; + assert_eq!(call::plan_eval(p, ops.as_ptr(), 2, fused, &mut c), LGJ_OK); + assert_eq!(c, count(a), "one crossing must equal three"); + assert_eq!( + read_words(fused), + read_words(a), + "bit-for-bit, not just count" + ); + assert!(c > 0, "an all-zero result would make this test vacuous"); + + for h in [fused, b, a, p] { + lgj_close(h); + } + } + + /// The headline parity property, through the same code path the Java tests + /// exercise: SIMD and the independent scalar reference must agree exactly, + /// including at row counts that are not multiples of 64. + #[test] + fn simd_and_scalar_plans_agree_bit_for_bit() { + for n in [0u64, 1, 63, 64, 65, 127, 1000, 4097] { + for seed in [0u64, 7, 0xFEED_FACE] { + let p = open(n, seed); + let ms = mask(p, 0); + let mm = mask(p, 0); + let ops = [ + op(LGJ_OP_GT_I32, LANE_VALUES, 100, LGJ_COMBINE_AND), + op(LGJ_OP_EQ_U32, LANE_CLASSES, 7, LGJ_COMBINE_OR), + op(LGJ_OP_GT_I32, LANE_VALUES, -50, LGJ_COMBINE_AND), + ]; + let (mut cs, mut cm) = (0u64, 0u64); + assert_eq!( + call::plan_eval(p, ops.as_ptr(), 3, mm, &mut cm), + LGJ_OK, + "n={n}" + ); + assert_eq!( + call::plan_eval_scalar(p, ops.as_ptr(), 3, ms, &mut cs), + LGJ_OK, + "n={n}" + ); + assert_eq!(cm, cs, "count parity at n={n} seed={seed}"); + assert_eq!( + read_words(mm), + read_words(ms), + "word parity at n={n} seed={seed}" + ); + for h in [mm, ms, p] { + lgj_close(h); + } + } + } + } + + /// Monotonic narrowing: with every combiner AND, each added op can only + /// shrink the selection — and the surviving rows must be a literal *subset*, + /// not merely fewer. + #[test] + fn all_and_plans_narrow_monotonically_and_by_subset() { + let n = 20_000u64; + let p = open(n, 0xC0FFEE); + let m = mask(p, 0); + let ops = [ + op(LGJ_OP_GT_I32, LANE_VALUES, -200, LGJ_COMBINE_AND), // matches all + op(LGJ_OP_GT_I32, LANE_VALUES, 0, LGJ_COMBINE_AND), + op(LGJ_OP_GT_I32, LANE_VALUES, 100, LGJ_COMBINE_AND), + op(LGJ_OP_EQ_U32, LANE_CLASSES, 7, LGJ_COMBINE_AND), + ]; + + let mut prev_count = n; + let mut prev_words: Option> = None; + for k in 1..=ops.len() { + let mut c = 0u64; + assert_eq!( + call::plan_eval(p, ops.as_ptr(), k as u32, m, &mut c), + LGJ_OK + ); + assert!(c <= prev_count, "count grew at k={k}: {c} > {prev_count}"); + let words = read_words(m); + if let Some(prev) = &prev_words { + for (i, (&w, &pw)) in words.iter().zip(prev.iter()).enumerate() { + assert_eq!(w & !pw, 0, "word {i} gained a row at k={k}: not a subset"); + } + } + prev_count = c; + prev_words = Some(words); + } + // Non-vacuity: the chain must actually have narrowed, or "monotonic" + // would be satisfied trivially by a predicate that changes nothing. + assert!(prev_count > 0 && prev_count < n, "final count {prev_count}"); + lgj_close(m); + lgj_close(p); + } + + #[test] + fn empty_plan_is_rejected() { + let p = open(64, 1); + let m = mask(p, 0); + let ops = [op(LGJ_OP_EQ_U32, LANE_CLASSES, 7, LGJ_COMBINE_AND)]; + let mut c = 0u64; + assert_eq!( + call::plan_eval(p, ops.as_ptr(), 0, m, &mut c), + LGJ_ERR_EMPTY_PLAN + ); + // A null ops pointer with n_ops == 0 is still an empty plan, not a null + // argument — the more specific diagnosis wins. + assert_eq!( + call::plan_eval(p, std::ptr::null(), 0, m, &mut c), + LGJ_ERR_EMPTY_PLAN + ); + lgj_close(m); + lgj_close(p); + } + + /// A plan containing a bad op must be rejected **without partially writing + /// dst_mask**. Each bad plan puts a *valid* op first, so a naive + /// evaluate-as-you-go implementation would have already written something. + #[test] + fn a_bad_plan_leaves_dst_mask_untouched() { + let p = open(1000, 11); + let m = mask(p, LGJ_MASK_INIT_ALL); + // Give the mask a distinctive, non-trivial content first. + assert_eq!(lgj_op_eq_u32(p, LANE_CLASSES, 3, m), LGJ_OK); + let before = read_words(m); + let before_count = count(m); + assert!(before_count > 0, "fixture must make this non-vacuous"); + + let good = op(LGJ_OP_EQ_U32, LANE_CLASSES, 7, LGJ_COMBINE_AND); + let bad_plans: [(Vec, i32); 5] = [ + // unknown opcode, after a good op + ( + vec![good, op(9999, LANE_CLASSES, 0, LGJ_COMBINE_AND)], + LGJ_ERR_UNKNOWN_OPCODE, + ), + // lane out of range + ( + vec![good, op(LGJ_OP_EQ_U32, 42, 0, LGJ_COMBINE_AND)], + LGJ_ERR_INVALID_LANE, + ), + // opcode / lane element kind mismatch + ( + vec![good, op(LGJ_OP_GT_I32, LANE_CLASSES, 0, LGJ_COMBINE_AND)], + LGJ_ERR_LANE_KIND_MISMATCH, + ), + // unknown combiner + ( + vec![good, op(LGJ_OP_EQ_U32, LANE_CLASSES, 1, 77)], + LGJ_ERR_UNKNOWN_OPCODE, + ), + // non-zero _reserved + ( + vec![ + good, + LgjOpDesc { + op: LGJ_OP_EQ_U32, + lane_id: LANE_CLASSES, + operand: 1, + combine: LGJ_COMBINE_AND, + _reserved: 1, + }, + ], + LGJ_ERR_NULL_ARGUMENT, + ), + ]; + + for (plan, want) in bad_plans { + let mut c = 12345u64; + let got = call::plan_eval(p, plan.as_ptr(), plan.len() as u32, m, &mut c); + assert_eq!(got, want, "wrong status for plan {plan:?}"); + assert_eq!(read_words(m), before, "dst_mask was modified by a bad plan"); + assert_eq!(count(m), before_count); + assert_eq!(c, 12345, "out_count must not be written on failure"); + // The scalar symbol must validate identically. + assert_eq!( + call::plan_eval_scalar(p, plan.as_ptr(), plan.len() as u32, m, &mut c), + want + ); + assert_eq!(read_words(m), before); + } + lgj_close(m); + lgj_close(p); + } + + #[test] + fn or_plans_widen() { + let n = 4000u64; + let p = open(n, 21); + let f = Fixture::generate(n, 21).unwrap(); + let m = mask(p, 0); + // acc starts all-set, so a lone OR stays all-set: OR is only meaningful + // after a narrowing op. Narrow to class==7, then widen by value>300. + let ops = [ + op(LGJ_OP_EQ_U32, LANE_CLASSES, 7, LGJ_COMBINE_AND), + op(LGJ_OP_GT_I32, LANE_VALUES, 300, LGJ_COMBINE_OR), + ]; + let mut c = 0u64; + assert_eq!(call::plan_eval(p, ops.as_ptr(), 2, m, &mut c), LGJ_OK); + let want = f + .classes() + .iter() + .zip(f.values()) + .filter(|(&cl, &v)| cl == 7 || v > 300) + .count() as u64; + assert_eq!(c, want); + lgj_close(m); + lgj_close(p); + } + + #[test] + fn plan_count_equals_mask_count() { + let p = open(3333, 4); + let m = mask(p, 0); + let ops = [op(LGJ_OP_GT_I32, LANE_VALUES, 50, LGJ_COMBINE_AND)]; + let mut c = 0u64; + assert_eq!(call::plan_eval(p, ops.as_ptr(), 1, m, &mut c), LGJ_OK); + assert_eq!(c, count(m), "out_count must equal popcount(dst_mask)"); + lgj_close(m); + lgj_close(p); + } + + // ── reduction ────────────────────────────────────────────────────────── + + #[test] + fn reduce_sum_matches_a_hand_sum() { + let n = 6000u64; + let p = open(n, 99); + let f = Fixture::generate(n, 99).unwrap(); + let m = mask(p, LGJ_MASK_INIT_ALL); + + let mut sum = 0i64; + assert_eq!(call::reduce_sum_i32(p, LANE_VALUES, m, &mut sum), LGJ_OK); + let want: i64 = f.values().iter().map(|&v| v as i64).sum(); + assert_eq!(sum, want); + // Negatives are in range, so a full-mask sum being merely "positive" is + // not evidence; check the mixed-sign path explicitly. + assert!(f.values().iter().any(|&v| v < 0)); + + assert_eq!(lgj_op_gt_i32(p, LANE_VALUES, 100, m), LGJ_OK); + assert_eq!(call::reduce_sum_i32(p, LANE_VALUES, m, &mut sum), LGJ_OK); + let want: i64 = f + .values() + .iter() + .filter(|&&v| v > 100) + .map(|&v| v as i64) + .sum(); + assert_eq!(sum, want); + assert!(want > 0); + + // An empty mask sums to zero, not to garbage. + assert_eq!(lgj_mask_and(m, mask(p, 0), m), LGJ_OK); + assert_eq!(call::reduce_sum_i32(p, LANE_VALUES, m, &mut sum), LGJ_OK); + assert_eq!(sum, 0); + lgj_close(m); + lgj_close(p); + } + + #[test] + fn reduce_sum_rejects_non_i32_lanes() { + let p = open(64, 1); + let m = mask(p, LGJ_MASK_INIT_ALL); + let mut s = 0i64; + assert_eq!( + call::reduce_sum_i32(p, LANE_IDS, m, &mut s), + LGJ_ERR_LANE_KIND_MISMATCH + ); + assert_eq!( + call::reduce_sum_i32(p, LANE_CLASSES, m, &mut s), + LGJ_ERR_LANE_KIND_MISMATCH + ); + assert_eq!(call::reduce_sum_i32(p, 12, m, &mut s), LGJ_ERR_INVALID_LANE); + lgj_close(m); + lgj_close(p); + } + + // ── degenerate sizes ─────────────────────────────────────────────────── + + #[test] + fn zero_and_one_row_resources_behave() { + for n in [0u64, 1] { + let p = open(n, 1); + let m = mask(p, LGJ_MASK_INIT_ALL); + assert_eq!(count(m), n); + let ops = [op(LGJ_OP_GT_I32, LANE_VALUES, -100_000, LGJ_COMBINE_AND)]; + let mut c = 0u64; + assert_eq!(call::plan_eval(p, ops.as_ptr(), 1, m, &mut c), LGJ_OK); + assert_eq!(c, n, "a match-everything predicate keeps all {n} rows"); + let mut s = 0i64; + assert_eq!(call::reduce_sum_i32(p, LANE_VALUES, m, &mut s), LGJ_OK); + let mut d = LgjLaneDesc::default(); + assert_eq!(call::lane_describe(p, LANE_VALUES, &mut d), LGJ_OK); + assert_eq!(d.len_elems, n); + lgj_close(m); + lgj_close(p); + } + } + + // ── panic safety ─────────────────────────────────────────────────────── + + /// A panic inside the membrane must surface as a status. Without the + /// `catch_unwind` in [`guard`] this unwind would reach JVM frames, which is + /// UB — so this test exercises the mechanism directly rather than trusting + /// that no code path ever panics. + #[test] + fn a_panic_becomes_a_status() { + let saved = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); // keep the test output clean + let got = guard(|| { + panic!("deliberate: simulating an internal invariant failure"); + }); + std::panic::set_hook(saved); + assert_eq!(got, LGJ_ERR_PANIC); + assert!(got < 0, "every failure is negative"); + } + + /// The panic path must not poison the registry into uselessness. + #[test] + fn the_registry_still_works_after_a_caught_panic() { + let saved = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); + let p = open(128, 1); + let m = mask(p, LGJ_MASK_INIT_ALL); + let _ = guard(|| { + let e = registry::resolve(m).unwrap(); + let _g = e.write_mask().unwrap(); + panic!("panic while holding the mask lock"); + }); + std::panic::set_hook(saved); + // The lock was poisoned; recovery via into_inner means this still works. + assert_eq!(count(m), 128); + assert_eq!(lgj_op_eq_u32(p, LANE_CLASSES, 7, m), LGJ_OK); + lgj_close(m); + lgj_close(p); + } + + /// Every status the ABI can return is negative-or-zero and distinct. + #[test] + fn status_codes_are_distinct() { + let all = [ + LGJ_OK, + LGJ_ERR_NULL_ARGUMENT, + LGJ_ERR_INVALID_HANDLE, + LGJ_ERR_WRONG_RESOURCE_KIND, + LGJ_ERR_INVALID_LANE, + LGJ_ERR_LANE_KIND_MISMATCH, + LGJ_ERR_MASK_LENGTH_MISMATCH, + LGJ_ERR_PARENT_CLOSED, + LGJ_ERR_VERSION_MISMATCH, + LGJ_ERR_LENGTH_OVERFLOW, + LGJ_ERR_UNKNOWN_OPCODE, + LGJ_ERR_EMPTY_PLAN, + LGJ_ERR_ALLOCATION_FAILED, + LGJ_ERR_READ_ONLY, + LGJ_ERR_PANIC, + ]; + let mut sorted = all.to_vec(); + sorted.sort_unstable(); + let len = sorted.len(); + sorted.dedup(); + assert_eq!(sorted.len(), len, "status codes must be distinct"); + assert!(all.iter().skip(1).all(|&s| s < 0)); + } +} diff --git a/native/lgj-abi/src/fixture.rs b/native/lgj-abi/src/fixture.rs new file mode 100644 index 0000000..0e108d9 --- /dev/null +++ b/native/lgj-abi/src/fixture.rs @@ -0,0 +1,297 @@ +//! The generic SoA fixture — three lanes, allocated once, never moved. +//! +//! "Generic is not toy": the *shape* under test is struct-of-arrays with a +//! packed-bit selection mask, which is exactly the shape `lance-graph`'s +//! `NodeRow` / `WideFieldMask` present. Wiring those concrete types in is a +//! later slice; nothing about the membrane changes when it happens. +//! +//! # The hard allocation guarantee (abi.md §4) +//! +//! Lanes are allocated once in [`Fixture::generate`] and are never reallocated, +//! resized, or moved while the resource is alive. Each lane is a +//! `Box<[T]>` — the heap buffer's address is fixed for the box's whole life, so +//! a `MemorySegment` Java built from a [`crate::abi::LgjLaneDesc`] stays valid +//! until `lgj_close`. Any future growable lane requires an ABI **major** bump. + +use crate::abi::{LgjElemKind, LGJ_FLAG_CONTIGUOUS, LGJ_FLAG_READABLE}; + +// Lane ids for a pattern resource (abi.md §7). +/// Lane 0 — entity ids, `U64`. +pub const LANE_IDS: u32 = 0; +/// Lane 1 — class tags, `U32`. +pub const LANE_CLASSES: u32 = 1; +/// Lane 2 — signed measurements, `I32`. +pub const LANE_VALUES: u32 = 2; +/// Number of lanes a pattern exposes. +pub const PATTERN_LANE_COUNT: u32 = 3; + +/// Number of distinct `class` values the generator produces: `0..16`. +pub const CLASS_CARDINALITY: u64 = 16; +/// Span of the `value` distribution before the shift below. +pub const VALUE_SPAN: i64 = 512; +/// Amount subtracted from the raw span, which is what puts negatives in range. +pub const VALUE_BIAS: i64 = 150; + +/// SplitMix64 — the reference generator, hand-written so there is no dependency +/// and no ambiguity about which variant is meant. +/// +/// Exactly the published constants: increment `0x9E3779B97F4A7C15`, mixers +/// `0xBF58476D1CE4E5B9` and `0x94D049BB133111EB`, shifts 30/27/31. All +/// arithmetic is wrapping (i.e. mod 2^64), which is what Java's `long` does +/// natively — so a Java re-implementation is a transcription, not a port. +#[derive(Debug, Clone)] +pub struct SplitMix64 { + state: u64, +} + +impl SplitMix64 { + /// Seed the generator. No warm-up draws — the first `next_u64` already + /// advances the state, exactly as the reference does. + pub const fn new(seed: u64) -> Self { + Self { state: seed } + } + + /// Advance and return the next 64 bits. + pub fn next_u64(&mut self) -> u64 { + self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } +} + +/// Three lanes of `n_rows` elements each, generated deterministically from a +/// seed. +/// +/// # The generation algorithm — NORMATIVE +/// +/// The Java-side test recomputes expected counts from this description alone, +/// with no data file, which makes it a genuine cross-language parity check. So +/// this is a contract, not an implementation detail: +/// +/// ```text +/// rng = SplitMix64(seed) // state = seed, no warm-up draws +/// for i in 0 .. n_rows: // ascending, one row at a time +/// a = rng.next_u64() // FIRST draw of the row +/// b = rng.next_u64() // SECOND draw of the row +/// ids[i] = i as u64 // dense, 0-based +/// classes[i] = ((a >>> 33) & 0xF) as u32 // 0 ..= 15 +/// values[i] = (((b >>> 40) & 0x1FF) - 150) as i32 // -150 ..= 361 +/// ``` +/// +/// Two draws per row, `a` before `b`, is part of the contract: swapping them or +/// drawing one `u64` and slicing it would produce different lanes for the same +/// seed and silently break Java's independently-computed expectations. +/// +/// Why these ranges: +/// - `classes` spans 16 values, so `class == 7` selects ≈ 1/16 of the rows — +/// a meaningful fraction, neither "everything" nor "nothing" (a predicate +/// that matches all or no rows cannot falsify a narrowing property). +/// - `values` spans `-150 ..= 361`, which *straddles* the threshold 100 used +/// throughout the tests and includes negatives — so `> 100` is a real signed +/// comparison, and an implementation that compared unsigned by mistake would +/// be caught rather than accidentally agreeing. +/// +/// `ids` is deliberately the dense row index: it is the join key, and making it +/// trivially predictable means a Java test can check *lane addressing itself* +/// without also having to model the PRNG. +/// +/// `Debug` deliberately prints only the shape, never the lanes: a 64,000-row +/// dump in a failing assertion is noise, not evidence. +pub struct Fixture { + /// Logical row count; every lane has exactly this many elements. + pub n_rows: u64, + /// The seed these lanes were generated from. + pub seed: u64, + ids: Box<[u64]>, + classes: Box<[u32]>, + values: Box<[i32]>, +} + +impl std::fmt::Debug for Fixture { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Fixture") + .field("n_rows", &self.n_rows) + .field("seed", &self.seed) + .finish_non_exhaustive() + } +} + +impl Fixture { + /// Build the fixture. Allocates each lane exactly once. + /// + /// Returns `None` if `n_rows` exceeds what can be indexed on this target + /// (the caller maps that to `LENGTH_OVERFLOW`). + pub fn generate(n_rows: u64, seed: u64) -> Option { + let n = usize::try_from(n_rows).ok()?; + // Reject anything whose byte length would overflow: the widest lane is + // 8 bytes/elem, and masks add 8 bytes per 64 rows on top. + n.checked_mul(8)?; + + let mut rng = SplitMix64::new(seed); + let mut ids = Vec::new(); + let mut classes = Vec::new(); + let mut values = Vec::new(); + ids.try_reserve_exact(n).ok()?; + classes.try_reserve_exact(n).ok()?; + values.try_reserve_exact(n).ok()?; + + for i in 0..n { + let a = rng.next_u64(); + let b = rng.next_u64(); + ids.push(i as u64); + classes.push(((a >> 33) & (CLASS_CARDINALITY - 1)) as u32); + values.push((((b >> 40) & (VALUE_SPAN as u64 - 1)) as i64 - VALUE_BIAS) as i32); + } + + Some(Self { + n_rows, + seed, + ids: ids.into_boxed_slice(), + classes: classes.into_boxed_slice(), + values: values.into_boxed_slice(), + }) + } + + /// Lane 0 — the dense row index. + pub fn ids(&self) -> &[u64] { + &self.ids + } + /// Lane 1 — class tags, `0 ..= 15`. + pub fn classes(&self) -> &[u32] { + &self.classes + } + /// Lane 2 — signed values, `-150 ..= 361`. + pub fn values(&self) -> &[i32] { + &self.values + } + + /// `(base address, element count, element kind)` for a lane id, or `None` + /// if the id is out of range (⇒ `INVALID_LANE`). + /// + /// The address is taken from the boxed slice, whose buffer never moves — see + /// this module's header. + pub fn lane_raw(&self, lane_id: u32) -> Option<(u64, u64, LgjElemKind)> { + match lane_id { + LANE_IDS => Some(( + self.ids.as_ptr() as u64, + self.ids.len() as u64, + LgjElemKind::U64, + )), + LANE_CLASSES => Some(( + self.classes.as_ptr() as u64, + self.classes.len() as u64, + LgjElemKind::U32, + )), + LANE_VALUES => Some(( + self.values.as_ptr() as u64, + self.values.len() as u64, + LgjElemKind::I32, + )), + _ => None, + } + } + + /// Flags every pattern lane carries: readable + contiguous, and **never** + /// writable (abi.md §7 — pattern lanes are read-only). + pub const fn lane_flags() -> u32 { + LGJ_FLAG_READABLE | LGJ_FLAG_CONTIGUOUS + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The published SplitMix64 test vector for seed 0 — proves this is the + /// real generator and not a lookalike, which is what makes the Java + /// transcription checkable. + #[test] + fn splitmix64_matches_the_reference_vector() { + let mut r = SplitMix64::new(0); + assert_eq!(r.next_u64(), 0xE220_A839_7B1D_CDAF); + assert_eq!(r.next_u64(), 0x6E78_9E6A_A1B9_65F4); + assert_eq!(r.next_u64(), 0x06C4_5D18_8009_454F); + } + + #[test] + fn generation_is_deterministic_for_a_seed() { + let a = Fixture::generate(1000, 42).unwrap(); + let b = Fixture::generate(1000, 42).unwrap(); + assert_eq!(a.classes(), b.classes()); + assert_eq!(a.values(), b.values()); + } + + #[test] + fn different_seeds_produce_different_lanes() { + let a = Fixture::generate(1000, 1).unwrap(); + let b = Fixture::generate(1000, 2).unwrap(); + assert_ne!(a.values(), b.values()); + } + + #[test] + fn lanes_have_the_documented_shape() { + let f = Fixture::generate(4096, 7).unwrap(); + assert_eq!(f.ids().len(), 4096); + // ids are the dense row index. + assert!(f.ids().iter().enumerate().all(|(i, &v)| v == i as u64)); + // classes span exactly 0..16. + assert!(f.classes().iter().all(|&c| c < 16)); + // and actually *use* the range — a constant lane would make every + // class predicate vacuous. + let distinct = { + let mut seen = [false; 16]; + for &c in f.classes() { + seen[c as usize] = true; + } + seen.iter().filter(|&&s| s).count() + }; + assert_eq!(distinct, 16, "all 16 classes must occur at n=4096"); + // values straddle the test threshold and include negatives. + assert!(f.values().iter().all(|&v| (-150..=361).contains(&v))); + assert!(f.values().iter().any(|&v| v < 0), "negatives must occur"); + assert!(f.values().iter().any(|&v| v > 100)); + assert!(f.values().iter().any(|&v| v <= 100)); + } + + /// A predicate that matches everything or nothing cannot falsify anything, + /// so pin that the fixture's headline predicates select a *middling* + /// fraction. + #[test] + fn headline_predicates_select_a_meaningful_fraction() { + let f = Fixture::generate(64_000, 0xABCD).unwrap(); + let n = f.n_rows as f64; + let c7 = f.classes().iter().filter(|&&c| c == 7).count() as f64 / n; + let gt = f.values().iter().filter(|&&v| v > 100).count() as f64 / n; + assert!((0.04..0.09).contains(&c7), "class==7 fraction was {c7}"); + assert!((0.35..0.65).contains(>), "value>100 fraction was {gt}"); + } + + #[test] + fn zero_rows_is_legal_and_empty() { + let f = Fixture::generate(0, 1).unwrap(); + assert_eq!(f.ids().len(), 0); + assert_eq!(f.lane_raw(0).unwrap().1, 0); + } + + #[test] + fn lane_ids_beyond_the_last_are_rejected() { + let f = Fixture::generate(8, 1).unwrap(); + assert!(f.lane_raw(0).is_some()); + assert!(f.lane_raw(2).is_some()); + assert!(f.lane_raw(3).is_none()); + assert!(f.lane_raw(u32::MAX).is_none()); + } + + /// The allocation-stability guarantee, checked rather than asserted in prose: + /// a lane's base address does not change across repeated describes. + #[test] + fn lane_addresses_are_stable() { + let f = Fixture::generate(1024, 3).unwrap(); + let first = f.lane_raw(LANE_VALUES).unwrap().0; + for _ in 0..100 { + assert_eq!(f.lane_raw(LANE_VALUES).unwrap().0, first); + } + } +} diff --git a/native/lgj-abi/src/kernels.rs b/native/lgj-abi/src/kernels.rs new file mode 100644 index 0000000..9e6d03b --- /dev/null +++ b/native/lgj-abi/src/kernels.rs @@ -0,0 +1,406 @@ +//! The bulk kernels — and the **single** place in this crate that names +//! `ndarray`. +//! +//! # SIMD provenance (abi.md §8) +//! +//! Every SIMD path here routes through `ndarray::simd` and nothing else. This +//! crate contains no `core::arch` / `_mm*` intrinsic, no `core::simd`, no +//! `#[cfg(target_feature)]` SIMD *selection*, and no locally-written SIMD +//! abstraction. Which backend those calls compile to is `ndarray`'s business; +//! the manifest reports it and Java never selects it. +//! +//! # Why every ndarray call is behind a wrapper +//! +//! The `simd_int_ops` primitives this file consumes were written in parallel +//! with it, against a shared signature contract. Funnelling every call through +//! a one-line `simd_*` wrapper below means a signature adjustment touches this +//! file only — the exported ABI, the registry and the plan evaluator never see +//! it. The wrappers are `#[inline]`, so they cost nothing. +//! +//! # The scalar reference is INDEPENDENT +//! +//! `scalar_*` below is written in plain Rust loops with **no ndarray at all**. +//! That independence is the entire value of `lgj_plan_eval_scalar`: if the +//! reference shared code with the SIMD path, a parity test between them would +//! be checking that a function agrees with itself. It does not, so the test is +//! a real falsifier — the same reason `ndarray`'s own W1a contract demands a +//! scalar arm. +//! +//! # Bit order (normative, restated because both paths must obey it) +//! +//! Element `i` lives at bit `i % 64` of word `i / 64`. Bits past `n_rows` in +//! the final word are zero. + +use crate::abi::*; + +// ─────────────────────────────────────────────────────────────────────────── +// SIMD path — thin wrappers over ndarray::simd +// ─────────────────────────────────────────────────────────────────────────── + +/// `out_words[i-th bit] = (values[i] == needle)`, fully overwriting `out_words`. +#[inline] +pub fn simd_eq_u32_to_mask(values: &[u32], needle: u32, out_words: &mut [u64]) { + ndarray::simd::eq_u32_to_mask(values, needle, out_words); +} + +/// `out_words[i-th bit] = (values[i] > threshold)`, signed, fully overwriting. +#[inline] +pub fn simd_gt_i32_to_mask(values: &[i32], threshold: i32, out_words: &mut [u64]) { + ndarray::simd::gt_i32_to_mask(values, threshold, out_words); +} + +/// `dst = a & b`. `dst` must not alias `a` or `b` (Rust's borrow rules enforce +/// it here; the aliasing ABI cases route to the `_assign` forms instead). +#[inline] +pub fn simd_mask_and(a: &[u64], b: &[u64], dst: &mut [u64]) { + ndarray::simd::mask_and(a, b, dst); +} + +/// `dst = a | b`. +#[inline] +pub fn simd_mask_or(a: &[u64], b: &[u64], dst: &mut [u64]) { + ndarray::simd::mask_or(a, b, dst); +} + +/// `dst &= src`. +#[inline] +pub fn simd_mask_and_assign(dst: &mut [u64], src: &[u64]) { + ndarray::simd::mask_and_assign(dst, src); +} + +/// `dst |= src`. +#[inline] +pub fn simd_mask_or_assign(dst: &mut [u64], src: &[u64]) { + ndarray::simd::mask_or_assign(dst, src); +} + +/// Sum of `values[i]` over set mask bits, widened to `i64`. +#[inline] +pub fn simd_masked_sum_i32(values: &[i32], mask_words: &[u64]) -> i64 { + ndarray::simd::masked_sum_i32(values, mask_words) +} + +/// Population count over mask words. +/// +/// **Reused, not reimplemented** — this already exists in `ndarray` +/// (implemented in `src/bitwise.rs`) and is reached here EXCLUSIVELY +/// through the sanctioned `ndarray::simd` re-export surface, never through +/// the internal `ndarray::hpc::bitwise` path it happens to live behind. +/// See `.claude/knowledge/simd-provenance.md`: `ndarray::hpc::*` is an +/// implementation-detail namespace that may be renamed or re-arranged at +/// any time; `ndarray::simd::*` is the contract that does not move under a +/// consumer's feet. Writing a second popcount here would ALSO be exactly +/// the duplication the `ndarray::simd` membrane exists to prevent — this +/// function exists only to keep this crate's own kernel-call surface +/// uniform (every bulk primitive it calls is named `simd_*` here). +#[inline] +pub fn simd_popcount(words: &[u64]) -> u64 { + ndarray::simd::popcount_batch_u64(words) +} + +// ─────────────────────────────────────────────────────────────────────────── +// Scalar reference — INDEPENDENT of ndarray. Do not "simplify" by calling the +// wrappers above; the independence IS the test. +// ─────────────────────────────────────────────────────────────────────────── + +/// Reference `eq_u32` → mask. +pub fn scalar_eq_u32_to_mask(values: &[u32], needle: u32, out_words: &mut [u64]) { + for w in out_words.iter_mut() { + *w = 0; + } + for (i, &v) in values.iter().enumerate() { + if v == needle { + out_words[i / 64] |= 1u64 << (i % 64); + } + } +} + +/// Reference signed `gt_i32` → mask. +pub fn scalar_gt_i32_to_mask(values: &[i32], threshold: i32, out_words: &mut [u64]) { + for w in out_words.iter_mut() { + *w = 0; + } + for (i, &v) in values.iter().enumerate() { + if v > threshold { + out_words[i / 64] |= 1u64 << (i % 64); + } + } +} + +/// Reference `dst &= src`. +pub fn scalar_mask_and_assign(dst: &mut [u64], src: &[u64]) { + for (d, &s) in dst.iter_mut().zip(src.iter()) { + *d &= s; + } +} + +/// Reference `dst |= src`. +pub fn scalar_mask_or_assign(dst: &mut [u64], src: &[u64]) { + for (d, &s) in dst.iter_mut().zip(src.iter()) { + *d |= s; + } +} + +/// Reference masked sum. +pub fn scalar_masked_sum_i32(values: &[i32], mask_words: &[u64]) -> i64 { + let mut acc: i64 = 0; + for (i, &v) in values.iter().enumerate() { + if (mask_words[i / 64] >> (i % 64)) & 1 == 1 { + acc += v as i64; + } + } + acc +} + +/// Reference popcount. +pub fn scalar_popcount(words: &[u64]) -> u64 { + let mut n = 0u64; + for &w in words { + n += w.count_ones() as u64; + } + n +} + +// ─────────────────────────────────────────────────────────────────────────── +// Which path a call takes +// ─────────────────────────────────────────────────────────────────────────── + +/// Selects between the `ndarray::simd` kernels and the independent scalar +/// reference. +/// +/// This is a *runtime value*, not a `cfg` — `lgj_plan_eval` and +/// `lgj_plan_eval_scalar` are two symbols over one code path, which is what +/// makes SIMD-vs-scalar parity falsifiable **through the membrane** where the +/// Java tests live. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Path { + /// The `ndarray::simd` kernels — what `lgj_plan_eval` uses. + Simd, + /// The independent scalar reference — what `lgj_plan_eval_scalar` uses. + Scalar, +} + +/// The lane view a predicate reads. Carries its element kind so the plan +/// validator can reject an opcode/lane mismatch before any work happens. +pub enum LaneView<'a> { + /// A `U32` lane (classes). + U32(&'a [u32]), + /// An `I32` lane (values). + I32(&'a [i32]), + /// A `U64` lane (ids). No predicate currently reads it, which is exactly + /// why it is here: it gives `LANE_KIND_MISMATCH` something real to reject. + U64(&'a [u64]), +} + +impl LaneView<'_> { + /// The element kind this view exposes, used by the plan validator. + pub fn kind(&self) -> LgjElemKind { + match self { + LaneView::U32(_) => LgjElemKind::U32, + LaneView::I32(_) => LgjElemKind::I32, + LaneView::U64(_) => LgjElemKind::U64, + } + } +} + +/// Evaluate one predicate into `out_words` (fully overwritten). +/// +/// The opcode/lane pairing has already been validated by the plan checker; a +/// mismatch reaching here is a bug in this crate, so it is reported as a status +/// rather than assumed away. +pub fn eval_predicate( + path: Path, + op: u32, + operand: i64, + lane: &LaneView<'_>, + n_rows: u64, + out_words: &mut [u64], +) -> Result<(), i32> { + match (op, lane) { + (LGJ_OP_EQ_U32, LaneView::U32(v)) => { + // The operand arrives sign-extended in an i64; the needle is the + // low 32 bits, compared as an exact u32 bit pattern. + let needle = operand as u32; + match path { + Path::Simd => simd_eq_u32_to_mask(v, needle, out_words), + Path::Scalar => scalar_eq_u32_to_mask(v, needle, out_words), + } + } + (LGJ_OP_GT_I32, LaneView::I32(v)) => { + let threshold = operand as i32; + match path { + Path::Simd => simd_gt_i32_to_mask(v, threshold, out_words), + Path::Scalar => scalar_gt_i32_to_mask(v, threshold, out_words), + } + } + (LGJ_OP_EQ_U32, _) | (LGJ_OP_GT_I32, _) => return Err(LGJ_ERR_LANE_KIND_MISMATCH), + _ => return Err(LGJ_ERR_UNKNOWN_OPCODE), + } + // Both primitives already zero the tail, but re-establishing it here means + // the invariant holds no matter which path ran. + clear_tail_bits(out_words, n_rows); + Ok(()) +} + +/// Combine a predicate result into an accumulator. +pub fn combine_into( + path: Path, + combine: u32, + acc: &mut [u64], + op_result: &[u64], +) -> Result<(), i32> { + match (combine, path) { + (LGJ_COMBINE_AND, Path::Simd) => simd_mask_and_assign(acc, op_result), + (LGJ_COMBINE_AND, Path::Scalar) => scalar_mask_and_assign(acc, op_result), + (LGJ_COMBINE_OR, Path::Simd) => simd_mask_or_assign(acc, op_result), + (LGJ_COMBINE_OR, Path::Scalar) => scalar_mask_or_assign(acc, op_result), + // Not in abi.md's combiner set. Rejected like an unknown opcode rather + // than defaulted to AND, so a future combiner cannot be silently + // misinterpreted by an old build. + _ => return Err(LGJ_ERR_UNKNOWN_OPCODE), + } + Ok(()) +} + +/// Popcount, on the selected path. +pub fn popcount(path: Path, words: &[u64]) -> u64 { + match path { + Path::Simd => simd_popcount(words), + Path::Scalar => scalar_popcount(words), + } +} + +/// Masked sum, on the selected path. +pub fn masked_sum_i32(path: Path, values: &[i32], mask_words: &[u64]) -> i64 { + match path { + Path::Simd => simd_masked_sum_i32(values, mask_words), + Path::Scalar => scalar_masked_sum_i32(values, mask_words), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fixture::Fixture; + + fn words_for(n: u64) -> Vec { + vec![0u64; mask_words_for(n) as usize] + } + + /// The falsifier for the whole SIMD provenance story: the ndarray kernels + /// and an independently-written scalar loop must agree bit-for-bit, at row + /// counts that are and are not multiples of the lane width. + #[test] + fn simd_matches_scalar_on_every_row_count_shape() { + // 15/16/17 straddle the 16-lane group; 63/64/65 straddle the word; 1000 + // and 4096 are ordinary; 0 and 1 are the degenerate ends. + for n in [0u64, 1, 15, 16, 17, 63, 64, 65, 127, 128, 129, 1000, 4096] { + for seed in [0u64, 1, 0xDEAD_BEEF] { + let f = Fixture::generate(n, seed).unwrap(); + let (mut a, mut b) = (words_for(n), words_for(n)); + + simd_eq_u32_to_mask(f.classes(), 7, &mut a); + scalar_eq_u32_to_mask(f.classes(), 7, &mut b); + assert_eq!(a, b, "eq_u32 mismatch at n={n} seed={seed}"); + + simd_gt_i32_to_mask(f.values(), 100, &mut a); + scalar_gt_i32_to_mask(f.values(), 100, &mut b); + assert_eq!(a, b, "gt_i32 mismatch at n={n} seed={seed}"); + + assert_eq!(simd_popcount(&a), scalar_popcount(&b)); + assert_eq!( + simd_masked_sum_i32(f.values(), &a), + scalar_masked_sum_i32(f.values(), &b), + "masked_sum mismatch at n={n} seed={seed}" + ); + } + } + } + + /// Signedness is the mistake this predicate invites: an unsigned compare + /// would put every negative value *above* a positive threshold. + #[test] + fn gt_i32_is_signed() { + let v: Vec = vec![-1, -1000, i32::MIN, 0, 1, 101, i32::MAX]; + let mut w = words_for(v.len() as u64); + simd_gt_i32_to_mask(&v, 100, &mut w); + // Only 101 (index 5) and i32::MAX (index 6) exceed 100. + assert_eq!(w[0], 0b110_0000); + let mut s = words_for(v.len() as u64); + scalar_gt_i32_to_mask(&v, 100, &mut s); + assert_eq!(w, s); + } + + #[test] + fn bit_order_is_lsb_first_within_each_word() { + let mut v = vec![0u32; 65]; + v[0] = 7; // bit 0 of word 0 + v[63] = 7; // bit 63 of word 0 + v[64] = 7; // bit 0 of word 1 + let mut w = words_for(65); + simd_eq_u32_to_mask(&v, 7, &mut w); + assert_eq!(w[0], (1u64 << 63) | 1); + assert_eq!(w[1], 1); + } + + #[test] + fn tail_bits_never_survive_a_predicate() { + // 70 rows: word 1 holds only 6 valid bits. A needle matching every row + // must still leave bits 6..64 of word 1 zero, or popcount would lie. + let v = vec![7u32; 70]; + let mut w = vec![u64::MAX; 2]; + simd_eq_u32_to_mask(&v, 7, &mut w); + clear_tail_bits(&mut w, 70); + assert_eq!(simd_popcount(&w), 70); + } + + #[test] + fn combiners_narrow_and_widen() { + let mut acc = vec![0b1100u64]; + combine_into(Path::Simd, LGJ_COMBINE_AND, &mut acc, &[0b1010]).unwrap(); + assert_eq!(acc[0], 0b1000); + combine_into(Path::Scalar, LGJ_COMBINE_OR, &mut acc, &[0b0011]).unwrap(); + assert_eq!(acc[0], 0b1011); + } + + #[test] + fn unknown_combiner_is_rejected() { + let mut acc = vec![0u64]; + assert_eq!( + combine_into(Path::Simd, 99, &mut acc, &[0]).unwrap_err(), + LGJ_ERR_UNKNOWN_OPCODE + ); + } + + #[test] + fn lane_kind_mismatch_is_caught_in_the_evaluator_too() { + let ids = [1u64, 2, 3]; + let lane = LaneView::U64(&ids); + let mut w = words_for(3); + assert_eq!( + eval_predicate(Path::Simd, LGJ_OP_EQ_U32, 1, &lane, 3, &mut w).unwrap_err(), + LGJ_ERR_LANE_KIND_MISMATCH + ); + assert_eq!( + eval_predicate(Path::Simd, 12345, 1, &lane, 3, &mut w).unwrap_err(), + LGJ_ERR_UNKNOWN_OPCODE + ); + } + + #[test] + fn non_aliasing_mask_ops_agree_with_assign_forms() { + let a = vec![0xF0F0_F0F0_F0F0_F0F0u64, 0x00FF]; + let b = vec![0xFF00_FF00_FF00_FF00u64, 0x0F0F]; + let mut viaand = vec![0u64; 2]; + simd_mask_and(&a, &b, &mut viaand); + let mut inplace = a.clone(); + simd_mask_and_assign(&mut inplace, &b); + assert_eq!(viaand, inplace); + + let mut vior = vec![0u64; 2]; + simd_mask_or(&a, &b, &mut vior); + let mut inplace = a.clone(); + simd_mask_or_assign(&mut inplace, &b); + assert_eq!(vior, inplace); + } +} diff --git a/native/lgj-abi/src/lib.rs b/native/lgj-abi/src/lib.rs new file mode 100644 index 0000000..952e87f --- /dev/null +++ b/native/lgj-abi/src/lib.rs @@ -0,0 +1,269 @@ +//! `lgj-abi` — the Rust half of the lance-graph-java **machine membrane**. +//! +//! The normative contract is `docs/abi.md`. This crate and the Java layer are +//! implemented *independently* against it; a runtime manifest check proves they +//! agree before the first real call. +//! +//! # There is no C in this project +//! +//! `extern "C"` and `#[repr(C)]` name a *machine* calling convention and a +//! *machine* aggregate layout rule — the SysV AMD64 psABI on this box. They do +//! not involve the C language. There is no `.h` file anywhere, no C toolchain, +//! no `cbindgen` (its output is a header nobody consumes), no `jextract` (its +//! only input is a header that does not exist), and no JNI. `cargo` and `javac` +//! are the entire toolchain. +//! +//! What replaces a header is [`abi::LgjAbiManifest`]: the compiled artifact +//! describing *itself* at runtime, from `size_of` / `align_of` on the real +//! types. A header can drift from the binary silently; a self-report cannot. +//! +//! # The three properties this crate exists to hold +//! +//! 1. **Bulk only** (`abi.md` §6). Every symbol either does work proportional to +//! `n_rows` or is lifecycle. There is deliberately no `lgj_lane_read_element` +//! — 64,000 logical entities cost one lane set, one packed mask and one +//! crossing, not 64,000 of anything. +//! 2. **No stale handle ever dereferences freed memory** (§4). Handles are +//! generation-checked opaque `u64`s, not pointers; use-after-close is +//! `INVALID_HANDLE`, a status. [`registry`] is where that is enforced and +//! where the tests attack it. +//! 3. **All SIMD comes from `ndarray::simd`** (§8). No `core::arch`, no +//! `_mm*`, no `core::simd`, no `pulp`/`wide`/`SimSIMD`, no nightly, no local +//! SIMD abstraction. [`kernels`] is the single module that names `ndarray`, +//! and it also carries an *independently written* scalar reference so +//! SIMD-vs-scalar parity is a real falsifier rather than a tautology. +//! +//! # Module map +//! +//! | module | role | +//! |---|---| +//! | [`abi`] | `#[repr(C)]` types, status codes, opcodes, the self-describing manifest | +//! | [`registry`] | generation-checked handles, ownership, lock discipline | +//! | [`fixture`] | the deterministic generic SoA fixture (three lanes) | +//! | [`kernels`] | bulk kernels via `ndarray::simd` + the independent scalar reference | +//! | [`exports`] | the `extern "C"` symbols themselves | + +#![deny(unsafe_op_in_unsafe_fn)] +#![warn(missing_docs)] + +pub mod abi; +pub mod exports; +pub mod fixture; +pub mod kernels; +pub mod registry; + +// Re-export the ABI vocabulary at the crate root for convenience in tests and +// for any Rust consumer that links the `rlib` rather than the `cdylib`. +pub use abi::*; +pub use exports::*; + +#[cfg(test)] +mod integration_tests { + //! Cross-module properties that belong to no single module. + + use crate::abi::*; + use crate::exports::*; + use crate::fixture::{Fixture, LANE_CLASSES, LANE_VALUES}; + + /// Safe wrappers over the pointer-taking exports — see the identical note in + /// `exports::tests`: one place holds the `unsafe`, so the assertions read as + /// the ABI contract rather than as pointer plumbing. + mod call { + use super::*; + + pub fn pattern_open(n: u64, seed: u64, out: *mut u64) -> i32 { + unsafe { lgj_pattern_open(n, seed, out) } + } + pub fn mask_create(p: u64, initial: u32, out: *mut u64) -> i32 { + unsafe { lgj_mask_create(p, initial, out) } + } + pub fn mask_describe(m: u64, out: *mut LgjLaneDesc) -> i32 { + unsafe { lgj_mask_describe(m, out) } + } + pub fn mask_count(m: u64, out: *mut u64) -> i32 { + unsafe { lgj_mask_count(m, out) } + } + pub fn plan_eval(r: u64, ops: *const LgjOpDesc, n: u32, dst: u64, out: *mut u64) -> i32 { + unsafe { lgj_plan_eval(r, ops, n, dst, out) } + } + pub fn reduce_sum_i32(r: u64, lane: u32, m: u64, out: *mut i64) -> i32 { + unsafe { lgj_reduce_sum_i32(r, lane, m, out) } + } + } + + fn open(n: u64, seed: u64) -> u64 { + let mut h = 0u64; + assert_eq!(call::pattern_open(n, seed, &mut h), LGJ_OK); + h + } + + fn mask(parent: u64, initial: u32) -> u64 { + let mut h = 0u64; + assert_eq!(call::mask_create(parent, initial, &mut h), LGJ_OK); + h + } + + /// The thesis, end to end: 64,000 logical entities become one lane set, one + /// packed mask, and **one** crossing that answers the whole question. + /// + /// Also the arithmetic that makes the thesis concrete — the mask is + /// `n/8` bytes, not `n` objects. + #[test] + fn sixty_four_thousand_entities_cost_one_crossing() { + let n = 64_000u64; + let p = open(n, 2026); + let m = mask(p, LGJ_MASK_INIT_EMPTY); + + let ops = [ + LgjOpDesc { + op: LGJ_OP_EQ_U32, + lane_id: LANE_CLASSES, + operand: 7, + combine: LGJ_COMBINE_AND, + _reserved: 0, + }, + LgjOpDesc { + op: LGJ_OP_GT_I32, + lane_id: LANE_VALUES, + operand: 100, + combine: LGJ_COMBINE_AND, + _reserved: 0, + }, + ]; + let mut count = 0u64; + assert_eq!(call::plan_eval(p, ops.as_ptr(), 2, m, &mut count), LGJ_OK); + + // Independently computed from the fixture's documented generator — the + // same computation the Java test performs from `abi.md` alone. + let f = Fixture::generate(n, 2026).unwrap(); + let want = f + .classes() + .iter() + .zip(f.values()) + .filter(|(&c, &v)| c == 7 && v > 100) + .count() as u64; + assert_eq!(count, want); + assert!(count > 0 && count < n, "must not be a vacuous selection"); + + // The whole selection is 1000 u64 words = 8000 bytes, for 64,000 rows. + let mut d = LgjLaneDesc::default(); + assert_eq!(call::mask_describe(m, &mut d), LGJ_OK); + assert_eq!(d.len_elems, 1000); + assert_eq!(d.byte_len, 8000); + + let mut sum = 0i64; + assert_eq!(call::reduce_sum_i32(p, LANE_VALUES, m, &mut sum), LGJ_OK); + let want_sum: i64 = f + .classes() + .iter() + .zip(f.values()) + .filter(|(&c, &v)| c == 7 && v > 100) + .map(|(_, &v)| v as i64) + .sum(); + assert_eq!(sum, want_sum); + + lgj_close(m); + lgj_close(p); + } + + /// Many live resources at once, closed out of order — the registry must not + /// confuse them, and every handle must stay valid until *its own* close. + #[test] + fn interleaved_lifetimes_do_not_cross_talk() { + let mut patterns = Vec::new(); + let mut masks = Vec::new(); + for i in 0..16u64 { + let p = open(100 + i, i); + masks.push(mask(p, LGJ_MASK_INIT_ALL)); + patterns.push(p); + } + // Close every other pattern; the survivors must be unaffected. + for i in (0..16).step_by(2) { + assert_eq!(lgj_close(masks[i]), LGJ_OK); + assert_eq!(lgj_close(patterns[i]), LGJ_OK); + } + for i in (1..16).step_by(2) { + let mut c = 0u64; + assert_eq!(call::mask_count(masks[i], &mut c), LGJ_OK); + assert_eq!(c, 100 + i as u64); + } + for i in (0..16).step_by(2) { + let mut c = 0u64; + assert_eq!(call::mask_count(masks[i], &mut c), LGJ_ERR_INVALID_HANDLE); + } + for i in (1..16).step_by(2) { + assert_eq!(lgj_close(masks[i]), LGJ_OK); + assert_eq!(lgj_close(patterns[i]), LGJ_OK); + } + } + + /// Bulk ops on distinct resources are meant to run concurrently (§4). This + /// does not benchmark contention — `abi.md` is explicit that nothing here + /// has been — it proves the *shape* is sound: no deadlock, no cross-talk, + /// each thread's answer independently correct. + #[test] + fn concurrent_bulk_ops_on_distinct_resources_agree_with_serial_ones() { + use std::thread; + let handles: Vec<_> = (0..8u64) + .map(|t| { + thread::spawn(move || { + let n = 5000 + t; + let p = open(n, t); + let m = mask(p, LGJ_MASK_INIT_EMPTY); + let ops = [LgjOpDesc { + op: LGJ_OP_EQ_U32, + lane_id: LANE_CLASSES, + operand: 7, + combine: LGJ_COMBINE_AND, + _reserved: 0, + }]; + let mut c = 0u64; + assert_eq!(call::plan_eval(p, ops.as_ptr(), 1, m, &mut c), LGJ_OK); + lgj_close(m); + lgj_close(p); + (n, t, c) + }) + }) + .collect(); + for h in handles { + let (n, seed, got) = h.join().unwrap(); + let f = Fixture::generate(n, seed).unwrap(); + let want = f.classes().iter().filter(|&&c| c == 7).count() as u64; + assert_eq!(got, want, "thread for seed {seed} disagreed"); + } + } + + /// Concurrent mask binops that name the same masks in *opposite* orders — + /// the shape that would deadlock without address-ordered locking. + #[test] + fn opposite_order_mask_binops_do_not_deadlock() { + use std::sync::Arc; + use std::thread; + let p = open(4096, 1); + let a = mask(p, LGJ_MASK_INIT_ALL); + let b = mask(p, LGJ_MASK_INIT_ALL); + let c = mask(p, LGJ_MASK_INIT_EMPTY); + let barrier = Arc::new(std::sync::Barrier::new(2)); + + let b1 = Arc::clone(&barrier); + let t1 = thread::spawn(move || { + b1.wait(); + for _ in 0..2000 { + assert_eq!(lgj_mask_and(a, b, c), LGJ_OK); + } + }); + let b2 = Arc::clone(&barrier); + let t2 = thread::spawn(move || { + b2.wait(); + for _ in 0..2000 { + assert_eq!(lgj_mask_or(c, b, a), LGJ_OK); + } + }); + t1.join().unwrap(); + t2.join().unwrap(); + + for h in [c, b, a, p] { + lgj_close(h); + } + } +} diff --git a/native/lgj-abi/src/registry.rs b/native/lgj-abi/src/registry.rs new file mode 100644 index 0000000..a8cd04a --- /dev/null +++ b/native/lgj-abi/src/registry.rs @@ -0,0 +1,510 @@ +//! The generation-checked handle registry. +//! +//! This is the safety-critical piece. Inside Rust, `&self` borrows make +//! "a view outlived its owner" a *compile error*. Across the membrane there is +//! no borrow checker, so the invariant is enforced at run time instead — and the +//! property being enforced is stated as sharply as `abi.md` §4 states it: +//! +//! > **There is no code path in which a stale handle dereferences freed +//! > memory.** +//! +//! The mechanism: a handle is not a pointer, it is an opaque `u64` +//! `(generation << 32) | index`. `index` selects a registry slot; `generation` +//! is bumped every time a slot is freed. Every lookup validates the generation, +//! so a closed, double-closed or fabricated handle resolves to +//! `INVALID_HANDLE` — a status, not a segfault. +//! +//! Generations start at **1**, which is what makes the fabricated handle `0` +//! (`gen 0, index 0`) fail: no live slot ever carries generation 0. +//! +//! # Locking +//! +//! - The registry itself is a `RwLock>`. A call takes a **short** read +//! lock, clones the `Arc`, and **drops the registry lock before +//! doing any work** ([`resolve`]). Only open/close take the write lock, so +//! they are the only globally-serializing operations. +//! - A **pattern needs no inner lock at all**: its lanes are read-only by ABI +//! (§7), so the `Fixture` is immutable behind the `Arc` and any number of bulk +//! ops can read it concurrently. +//! - Only **mask words** are mutable, so only they sit behind an inner +//! `RwLock`. When one call must lock several masks (a `mask_and` whose three +//! handles are distinct), the locks are acquired in **address order** +//! ([`lock_masks_ordered`]) — a global order, therefore deadlock-free — +//! and aliased handles are deduplicated *before* locking, because locking one +//! `RwLock` twice from the same thread is the other way to hang. +//! +//! Stated honestly, as `abi.md` does: none of this has been benchmarked under +//! contention, and the POC's Java layer is single-threaded. +//! +//! # Poisoning +//! +//! A panic while a lock is held poisons it. Rather than bricking the registry, +//! every acquisition recovers with `into_inner()`. This is safe *here* because +//! the guarded data carries no invariant a partial write could break beyond +//! "bits past `n_rows` are zero", and every write path re-establishes that +//! before returning (see [`crate::abi::clear_tail_bits`]). + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, OnceLock, RwLock, RwLockReadGuard, RwLockWriteGuard}; + +use crate::abi::*; +use crate::fixture::{Fixture, PATTERN_LANE_COUNT}; + +/// The mutable half of a mask: the packed row bits. +#[derive(Debug)] +pub struct MaskWords { + /// The packed row bits. Allocated once; the buffer never moves. + pub words: Box<[u64]>, +} + +/// What a handle refers to. +/// +/// `Pattern` is unlocked on purpose (read-only lanes); `Mask` carries its own +/// `RwLock` so bulk ops on distinct masks do not serialize. +#[derive(Debug)] +pub enum Payload { + /// A read-only SoA fixture — no lock needed, because no ABI path mutates it. + Pattern(Fixture), + /// Mutable mask words behind their own lock. + Mask(RwLock), +} + +/// One registry entry, shared by `Arc` so a call can drop the registry lock and +/// still hold its resource alive for the duration of the work. +#[derive(Debug)] +pub struct ResourceEntry { + /// `LGJ_RESOURCE_PATTERN` | `LGJ_RESOURCE_MASK`. + pub kind: u32, + /// Liveness stamp. Globally unique and monotonic, so a *reused slot* never + /// reuses an epoch — which is what lets Java notice a `MemorySegment` it + /// still holds belongs to a dead resource. + pub epoch: u64, + /// Logical rows. + pub n_rows: u64, + /// Parent handle, `0` for a pattern. + pub parent: u64, + /// The parent's generation at creation time. Re-checked on every mask + /// operation so a mask whose parent was closed reports `PARENT_CLOSED` + /// rather than operating against a dead pattern. + pub parent_gen: u32, + /// The resource's data. + pub payload: Payload, +} + +impl ResourceEntry { + /// `Some(&Fixture)` iff this is a pattern. + pub fn fixture(&self) -> Option<&Fixture> { + match &self.payload { + Payload::Pattern(f) => Some(f), + Payload::Mask(_) => None, + } + } + + /// `Some(&RwLock)` iff this is a mask. + pub fn mask(&self) -> Option<&RwLock> { + match &self.payload { + Payload::Mask(m) => Some(m), + Payload::Pattern(_) => None, + } + } + + /// Read-lock the mask words, recovering from poisoning (see module header). + pub fn read_mask(&self) -> Option> { + self.mask() + .map(|m| m.read().unwrap_or_else(|e| e.into_inner())) + } + + /// Write-lock the mask words, recovering from poisoning (see module header). + pub fn write_mask(&self) -> Option> { + self.mask() + .map(|m| m.write().unwrap_or_else(|e| e.into_inner())) + } + + /// Fill an [`LgjResourceInfo`]. Takes no handle: the description is about + /// the resource, and the caller already knows the handle it passed in. + pub fn info(&self) -> LgjResourceInfo { + LgjResourceInfo { + kind: self.kind, + lane_count: match self.kind { + LGJ_RESOURCE_PATTERN => PATTERN_LANE_COUNT, + // A mask exposes exactly one MASK_WORD lane. + _ => 1, + }, + n_rows: self.n_rows, + epoch: self.epoch, + parent: self.parent, + } + } +} + +/// A registry slot. `entry == None` means free; `generation` is bumped on every +/// free so handles pointing here go stale. +struct Slot { + generation: u32, + entry: Option>, +} + +static REGISTRY: OnceLock>> = OnceLock::new(); +static EPOCH: AtomicU64 = AtomicU64::new(1); + +fn registry() -> &'static RwLock> { + REGISTRY.get_or_init(|| RwLock::new(Vec::new())) +} + +fn next_epoch() -> u64 { + EPOCH.fetch_add(1, Ordering::Relaxed) +} + +/// `(generation << 32) | index`. +pub const fn encode_handle(generation: u32, index: u32) -> u64 { + ((generation as u64) << 32) | (index as u64) +} + +/// Split a handle into `(generation, index)`. Every value of `u64` decodes to +/// *something*; validity is decided by [`resolve`], never here. +pub const fn decode_handle(handle: u64) -> (u32, u32) { + ((handle >> 32) as u32, (handle & 0xFFFF_FFFF) as u32) +} + +/// Install an entry and return its handle. +pub fn insert(entry: ResourceEntry) -> Result { + let mut reg = registry().write().unwrap_or_else(|e| e.into_inner()); + let arc = Arc::new(entry); + + // Reuse a free slot if there is one; its generation is already ahead of any + // handle that used to point at it. + if let Some((idx, slot)) = reg.iter_mut().enumerate().find(|(_, s)| s.entry.is_none()) { + slot.entry = Some(arc); + return Ok(encode_handle(slot.generation, idx as u32)); + } + + let idx = reg.len(); + if idx > u32::MAX as usize { + return Err(LGJ_ERR_ALLOCATION_FAILED); + } + // Generation 1, never 0 — see this module's header. + reg.push(Slot { + generation: 1, + entry: Some(arc), + }); + Ok(encode_handle(1, idx as u32)) +} + +/// Resolve a handle to its entry, validating the generation. +/// +/// Takes a short read lock, clones the `Arc`, and drops the lock — so the +/// caller does its bulk work without holding the registry. +pub fn resolve(handle: u64) -> Result, i32> { + let (gen, idx) = decode_handle(handle); + let reg = registry().read().unwrap_or_else(|e| e.into_inner()); + let slot = reg.get(idx as usize).ok_or(LGJ_ERR_INVALID_HANDLE)?; + if slot.generation != gen { + // Closed and the slot was reused, or fabricated outright. + return Err(LGJ_ERR_INVALID_HANDLE); + } + let entry = slot.entry.clone().ok_or(LGJ_ERR_INVALID_HANDLE)?; + Ok(entry) + // `reg` drops here; nothing below this point holds the registry lock. +} + +/// Resolve and require a specific resource kind. +pub fn resolve_kind(handle: u64, kind: u32) -> Result, i32> { + let e = resolve(handle)?; + if e.kind != kind { + return Err(LGJ_ERR_WRONG_RESOURCE_KIND); + } + Ok(e) +} + +/// Resolve a mask **and** prove its parent is still alive. +/// +/// Returns `(mask, parent)`. A mask whose parent was closed yields +/// `PARENT_CLOSED` — it may still *exist*, it just cannot *work* (§4). +pub fn resolve_mask_with_parent( + handle: u64, +) -> Result<(Arc, Arc), i32> { + let mask = resolve_kind(handle, LGJ_RESOURCE_MASK)?; + let (pgen, pidx) = decode_handle(mask.parent); + let reg = registry().read().unwrap_or_else(|e| e.into_inner()); + let slot = reg.get(pidx as usize).ok_or(LGJ_ERR_PARENT_CLOSED)?; + if slot.generation != pgen || pgen != mask.parent_gen { + return Err(LGJ_ERR_PARENT_CLOSED); + } + let parent = slot.entry.clone().ok_or(LGJ_ERR_PARENT_CLOSED)?; + drop(reg); + Ok((mask, parent)) +} + +/// Free a slot: take the entry out and bump the generation, so every handle +/// that pointed here is now stale. +/// +/// Returns `INVALID_HANDLE` on a stale/fabricated handle — which is also what +/// makes a *double* close fail cleanly rather than free twice. +pub fn close(handle: u64) -> Result<(), i32> { + let mut reg = registry().write().unwrap_or_else(|e| e.into_inner()); + let (gen, idx) = decode_handle(handle); + let slot = reg.get_mut(idx as usize).ok_or(LGJ_ERR_INVALID_HANDLE)?; + if slot.generation != gen || slot.entry.is_none() { + return Err(LGJ_ERR_INVALID_HANDLE); + } + // Take the entry out FIRST, then bump: after this point no new resolve can + // succeed for `handle`. + let entry = slot.entry.take(); + // Wrapping is documented rather than ignored: after 2^32 closes of the same + // slot a generation repeats. `saturating` would be worse (it would freeze a + // generation and make every stale handle valid forever). + slot.generation = slot.generation.wrapping_add(1); + if slot.generation == 0 { + slot.generation = 1; // never hand out generation 0 + } + drop(reg); + // Drop the Arc outside the registry lock. Other in-flight calls may still + // hold clones; the storage is freed when the last one goes, which is why a + // concurrent bulk op cannot be reading freed lanes. + drop(entry); + Ok(()) +} + +/// Create a pattern resource from a deterministic fixture. +pub fn open_pattern(n_rows: u64, seed: u64) -> Result { + let fixture = Fixture::generate(n_rows, seed).ok_or(LGJ_ERR_LENGTH_OVERFLOW)?; + insert(ResourceEntry { + kind: LGJ_RESOURCE_PATTERN, + epoch: next_epoch(), + n_rows, + parent: 0, + parent_gen: 0, + payload: Payload::Pattern(fixture), + }) +} + +/// Create a mask over `parent`, all bits `0` or all bits `1`. +pub fn create_mask(parent_handle: u64, initial: u32) -> Result { + let parent = resolve_kind(parent_handle, LGJ_RESOURCE_PATTERN)?; + let n_rows = parent.n_rows; + let n_words = usize::try_from(mask_words_for(n_rows)).map_err(|_| LGJ_ERR_LENGTH_OVERFLOW)?; + + let fill = match initial { + LGJ_MASK_INIT_EMPTY => 0u64, + LGJ_MASK_INIT_ALL => u64::MAX, + // An out-of-range `initial` is a caller bug, not an alternative default. + _ => return Err(LGJ_ERR_NULL_ARGUMENT), + }; + let mut words = Vec::new(); + words + .try_reserve_exact(n_words) + .map_err(|_| LGJ_ERR_ALLOCATION_FAILED)?; + words.resize(n_words, fill); + let mut words = words.into_boxed_slice(); + // Bits past n_rows are normative zero even for an "all" mask. + clear_tail_bits(&mut words, n_rows); + + let (pgen, _) = decode_handle(parent_handle); + insert(ResourceEntry { + kind: LGJ_RESOURCE_MASK, + epoch: next_epoch(), + n_rows, + parent: parent_handle, + parent_gen: pgen, + payload: Payload::Mask(RwLock::new(MaskWords { words })), + }) +} + +/// Write-lock up to three **distinct** mask entries in address order. +/// +/// Address order is a global order, so no two threads can build a lock cycle. +/// The returned array is indexed **by role** (the caller's original argument +/// position), while acquisition happened in address order — that separation is +/// the whole point. +/// +/// # Panics / misuse +/// +/// The caller must pass pairwise-distinct entries (`Arc::ptr_eq` false). Passing +/// the same entry twice would deadlock on its own `RwLock`; callers dedup first +/// (see `exports::mask_binop`). +#[allow(clippy::type_complexity)] +pub fn lock_masks_ordered<'a>( + entries: &[&'a Arc], +) -> Result<[Option>; 3], i32> { + debug_assert!(entries.len() <= 3); + let mut order: Vec = (0..entries.len()).collect(); + order.sort_by_key(|&i| Arc::as_ptr(entries[i]) as usize); + + let mut guards: [Option>; 3] = [None, None, None]; + for &role in &order { + let lock = entries[role].mask().ok_or(LGJ_ERR_WRONG_RESOURCE_KIND)?; + guards[role] = Some(lock.write().unwrap_or_else(|e| e.into_inner())); + } + Ok(guards) +} + +/// Test-only: how many slots the registry has ever needed. Used to prove slot +/// reuse actually happens (and therefore that generation checking is load +/// bearing, not decorative). +#[cfg(test)] +pub fn slot_count() -> usize { + registry().read().unwrap_or_else(|e| e.into_inner()).len() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn handle_round_trips() { + for (g, i) in [(1u32, 0u32), (7, 3), (u32::MAX, u32::MAX), (2, 0)] { + assert_eq!(decode_handle(encode_handle(g, i)), (g, i)); + } + } + + #[test] + fn a_live_handle_resolves() { + let h = open_pattern(100, 1).unwrap(); + let e = resolve(h).unwrap(); + assert_eq!(e.kind, LGJ_RESOURCE_PATTERN); + assert_eq!(e.n_rows, 100); + close(h).unwrap(); + } + + #[test] + fn handles_are_never_zero() { + let h = open_pattern(8, 1).unwrap(); + assert_ne!(h, 0, "generation starts at 1 so handle 0 is unreachable"); + close(h).unwrap(); + } + + #[test] + fn fabricated_handles_are_rejected_not_dereferenced() { + for bogus in [0u64, 1, 0xDEAD_BEEF, u64::MAX, encode_handle(0, 0)] { + assert_eq!(resolve(bogus).unwrap_err(), LGJ_ERR_INVALID_HANDLE); + assert_eq!(close(bogus).unwrap_err(), LGJ_ERR_INVALID_HANDLE); + } + } + + #[test] + fn use_after_close_is_a_status_not_a_crash() { + let h = open_pattern(64, 1).unwrap(); + close(h).unwrap(); + assert_eq!(resolve(h).unwrap_err(), LGJ_ERR_INVALID_HANDLE); + } + + #[test] + fn double_close_fails_the_second_time() { + let h = open_pattern(64, 1).unwrap(); + assert!(close(h).is_ok()); + assert_eq!(close(h).unwrap_err(), LGJ_ERR_INVALID_HANDLE); + } + + /// The generation check earns its keep only if slots are actually reused. + /// Prove reuse happens *and* that the old handle still fails afterwards. + #[test] + fn a_reused_slot_invalidates_the_old_handle() { + let before = slot_count(); + let h1 = open_pattern(32, 1).unwrap(); + close(h1).unwrap(); + let h2 = open_pattern(32, 1).unwrap(); + let (g1, i1) = decode_handle(h1); + let (g2, i2) = decode_handle(h2); + if i1 == i2 { + // Slot really was reused: same index, higher generation. + assert!(g2 > g1); + assert_eq!(resolve(h1).unwrap_err(), LGJ_ERR_INVALID_HANDLE); + assert!(resolve(h2).is_ok()); + } + assert!(slot_count() >= before); + close(h2).unwrap(); + } + + #[test] + fn wrong_kind_is_distinguished_from_invalid() { + let p = open_pattern(64, 1).unwrap(); + let m = create_mask(p, LGJ_MASK_INIT_ALL).unwrap(); + assert_eq!( + resolve_kind(m, LGJ_RESOURCE_PATTERN).unwrap_err(), + LGJ_ERR_WRONG_RESOURCE_KIND + ); + assert_eq!( + resolve_kind(p, LGJ_RESOURCE_MASK).unwrap_err(), + LGJ_ERR_WRONG_RESOURCE_KIND + ); + close(m).unwrap(); + close(p).unwrap(); + } + + #[test] + fn mask_over_closed_parent_reports_parent_closed() { + let p = open_pattern(200, 5).unwrap(); + let m = create_mask(p, LGJ_MASK_INIT_ALL).unwrap(); + assert!(resolve_mask_with_parent(m).is_ok()); + close(p).unwrap(); + assert_eq!( + resolve_mask_with_parent(m).unwrap_err(), + LGJ_ERR_PARENT_CLOSED + ); + // The mask handle itself still resolves — it exists, it just cannot work. + assert!(resolve(m).is_ok()); + close(m).unwrap(); + } + + #[test] + fn mask_initial_states_are_exact() { + let p = open_pattern(70, 1).unwrap(); + let empty = create_mask(p, LGJ_MASK_INIT_EMPTY).unwrap(); + let all = create_mask(p, LGJ_MASK_INIT_ALL).unwrap(); + + let e = resolve(empty).unwrap(); + let g = e.read_mask().unwrap(); + assert_eq!(g.words.len(), 2); + assert!(g.words.iter().all(|&w| w == 0)); + drop(g); + + let a = resolve(all).unwrap(); + let g = a.read_mask().unwrap(); + assert_eq!(g.words[0], u64::MAX); + assert_eq!(g.words[1], 0x3F, "tail bits past row 70 must be zero"); + drop(g); + + close(all).unwrap(); + close(empty).unwrap(); + close(p).unwrap(); + } + + #[test] + fn bad_initial_value_is_rejected() { + let p = open_pattern(8, 1).unwrap(); + assert!(create_mask(p, 2).is_err()); + assert!(create_mask(p, u32::MAX).is_err()); + close(p).unwrap(); + } + + #[test] + fn mask_cannot_be_created_over_a_mask() { + let p = open_pattern(8, 1).unwrap(); + let m = create_mask(p, 0).unwrap(); + assert_eq!(create_mask(m, 0).unwrap_err(), LGJ_ERR_WRONG_RESOURCE_KIND); + close(m).unwrap(); + close(p).unwrap(); + } + + /// Locking distinct masks in address order must not deadlock regardless of + /// the order the caller names them in. + #[test] + fn ordered_locking_is_order_insensitive() { + let p = open_pattern(128, 1).unwrap(); + let a = create_mask(p, 0).unwrap(); + let b = create_mask(p, 0).unwrap(); + let c = create_mask(p, 0).unwrap(); + let (ea, eb, ec) = ( + resolve(a).unwrap(), + resolve(b).unwrap(), + resolve(c).unwrap(), + ); + for perm in [[&ea, &eb, &ec], [&ec, &eb, &ea], [&eb, &ec, &ea]] { + let g = lock_masks_ordered(&perm).unwrap(); + assert!(g[0].is_some() && g[1].is_some() && g[2].is_some()); + } + for h in [c, b, a, p] { + close(h).unwrap(); + } + } +} From bdd40b2c22f9b5e285681de2dc494b6b12633e19 Mon Sep 17 00:00:00 2001 From: Jan Date: Mon, 17 Aug 2026 21:30:04 +0000 Subject: [PATCH 2/2] Valhalla lab: three-truths method, causal isolation, 3 real reproducers Completes D-LGJ-F. One experiment source (src/shared/), compiled twice against real JDKs -- stable JDK 26 GA (record) and the official JEP 401 early-access binary (value record) -- via a self-verifying run.sh that mechanically diffs the two Vocab.java files modulo the 'value' keyword before trusting the A/B is honest. Experiments: IdentityExperiment (semantic truth -- is identity actually unobservable), FootprintExperiment (real per-object/array/field bytes via allocation-delta + JOL where available), FfmAddressingExperiment (is the wrapper free where it touches native memory), ThesisExperiment (the mandatory headline: 65,536 rows as one native lane vs hydrated Java objects, on both platforms). Causal isolation via three additional run.sh passes: escape analysis off, and UseArrayFlattening/UseFieldFlattening toggled independently -- isolates which flag actually drives the measured difference rather than inferring it. Three real Valhalla limitations reproduced and filed under reproducers/, none of which changed the production API: - R1: @NullRestricted field on an identity class is a VerifyError (javac's fault -- no source form expresses the required strict-field init order relative to super()) - R2: array flattening has a hard 8-byte payload cliff, confirmed via -XX:+PrintFlatArrayLayout. LaneId/Ordinal/MaskId (<=8B) flatten; RowRange/Row (16B) do not. This turns "Valhalla helps descriptors, not entities" from a hand-wave into a measured VM cutoff -- and RowRange landing on the wrong side is flagged as the one place the expectation was too optimistic. - R3: the densest null-restricted array form is jdk.internal-only and generics erase flattening entirely; Foo! null-restricted type syntax confirmed not to parse, matching the earlier archaeology finding. One real defect found and fixed before landing: IdentityExperiment and the stable Platform called Class::isValue() directly on four vocabulary types with a comment incorrectly claiming it was "final API on JDK 26" -- it does not exist there at all, confirmed by a real javac failure. Fixed by routing every query through Platform.isValueClass(Class), answered honestly per platform. Generated by [Claude Code](https://claude.ai/code) --- .claude/board/EPIPHANIES.md | 51 ++++++ .claude/board/STATUS_BOARD.md | 4 +- .gitignore | 4 + valhalla-lab/README.md | 100 +++++++++++ valhalla-lab/reproducers/R1-observed.txt | 4 + ...R1_NullRestrictedFieldInIdentityClass.java | 36 ++++ valhalla-lab/reproducers/R2-observed.txt | 8 + .../reproducers/R2_FlatteningCliff.java | 40 +++++ .../reproducers/R3-bang-syntax-observed.txt | 6 + valhalla-lab/reproducers/R3-observed.txt | 4 + .../R3_NoSupportedFlatSurface.java | 54 ++++++ valhalla-lab/reproducers/README.md | 159 ++++++++++++++++ valhalla-lab/results/AB-default.diff | 132 ++++++++++++++ valhalla-lab/results/stable-api-javac.log | 1 + valhalla-lab/results/stable-default.txt | 91 ++++++++++ valhalla-lab/results/stable-lab-javac.log | 1 + valhalla-lab/results/stable-noea.txt | 91 ++++++++++ valhalla-lab/results/valhalla-api-javac.log | 1 + valhalla-lab/results/valhalla-default.txt | 91 ++++++++++ valhalla-lab/results/valhalla-lab-javac.log | 3 + valhalla-lab/results/valhalla-noarrayflat.txt | 91 ++++++++++ valhalla-lab/results/valhalla-noea.txt | 91 ++++++++++ valhalla-lab/results/valhalla-nofieldflat.txt | 91 ++++++++++ valhalla-lab/results/valhalla-noflat.txt | 91 ++++++++++ valhalla-lab/results/vocab-diff.txt | 0 valhalla-lab/run.sh | 99 ++++++++++ .../adaworldapi/lancegraph/NativeAccess.java | 49 +++++ .../lab/FfmAddressingExperiment.java | 86 +++++++++ .../lab/FlatteningCliffExperiment.java | 46 +++++ .../lancegraph/lab/FootprintExperiment.java | 111 ++++++++++++ .../lancegraph/lab/IdentityExperiment.java | 66 +++++++ .../com/adaworldapi/lancegraph/lab/Lab.java | 144 +++++++++++++++ .../adaworldapi/lancegraph/lab/RunAll.java | 46 +++++ .../lancegraph/lab/ThesisExperiment.java | 170 ++++++++++++++++++ .../lancegraph/lab/Containers.java | 31 ++++ .../adaworldapi/lancegraph/lab/Platform.java | 68 +++++++ .../com/adaworldapi/lancegraph/lab/Vocab.java | 61 +++++++ .../lancegraph/lab/Containers.java | 46 +++++ .../adaworldapi/lancegraph/lab/Platform.java | 65 +++++++ .../com/adaworldapi/lancegraph/lab/Vocab.java | 61 +++++++ 40 files changed, 2392 insertions(+), 2 deletions(-) create mode 100644 valhalla-lab/README.md create mode 100644 valhalla-lab/reproducers/R1-observed.txt create mode 100644 valhalla-lab/reproducers/R1_NullRestrictedFieldInIdentityClass.java create mode 100644 valhalla-lab/reproducers/R2-observed.txt create mode 100644 valhalla-lab/reproducers/R2_FlatteningCliff.java create mode 100644 valhalla-lab/reproducers/R3-bang-syntax-observed.txt create mode 100644 valhalla-lab/reproducers/R3-observed.txt create mode 100644 valhalla-lab/reproducers/R3_NoSupportedFlatSurface.java create mode 100644 valhalla-lab/reproducers/README.md create mode 100644 valhalla-lab/results/AB-default.diff create mode 100644 valhalla-lab/results/stable-api-javac.log create mode 100644 valhalla-lab/results/stable-default.txt create mode 100644 valhalla-lab/results/stable-lab-javac.log create mode 100644 valhalla-lab/results/stable-noea.txt create mode 100644 valhalla-lab/results/valhalla-api-javac.log create mode 100644 valhalla-lab/results/valhalla-default.txt create mode 100644 valhalla-lab/results/valhalla-lab-javac.log create mode 100644 valhalla-lab/results/valhalla-noarrayflat.txt create mode 100644 valhalla-lab/results/valhalla-noea.txt create mode 100644 valhalla-lab/results/valhalla-nofieldflat.txt create mode 100644 valhalla-lab/results/valhalla-noflat.txt create mode 100644 valhalla-lab/results/vocab-diff.txt create mode 100755 valhalla-lab/run.sh create mode 100644 valhalla-lab/src/shared/com/adaworldapi/lancegraph/NativeAccess.java create mode 100644 valhalla-lab/src/shared/com/adaworldapi/lancegraph/lab/FfmAddressingExperiment.java create mode 100644 valhalla-lab/src/shared/com/adaworldapi/lancegraph/lab/FlatteningCliffExperiment.java create mode 100644 valhalla-lab/src/shared/com/adaworldapi/lancegraph/lab/FootprintExperiment.java create mode 100644 valhalla-lab/src/shared/com/adaworldapi/lancegraph/lab/IdentityExperiment.java create mode 100644 valhalla-lab/src/shared/com/adaworldapi/lancegraph/lab/Lab.java create mode 100644 valhalla-lab/src/shared/com/adaworldapi/lancegraph/lab/RunAll.java create mode 100644 valhalla-lab/src/shared/com/adaworldapi/lancegraph/lab/ThesisExperiment.java create mode 100644 valhalla-lab/src/stable/com/adaworldapi/lancegraph/lab/Containers.java create mode 100644 valhalla-lab/src/stable/com/adaworldapi/lancegraph/lab/Platform.java create mode 100644 valhalla-lab/src/stable/com/adaworldapi/lancegraph/lab/Vocab.java create mode 100644 valhalla-lab/src/valhalla/com/adaworldapi/lancegraph/lab/Containers.java create mode 100644 valhalla-lab/src/valhalla/com/adaworldapi/lancegraph/lab/Platform.java create mode 100644 valhalla-lab/src/valhalla/com/adaworldapi/lancegraph/lab/Vocab.java diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index 05eeae2..d3cb4fb 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -4,6 +4,57 @@ > `**Status:**`/`**Confidence:**` line. A correction gets its own new, > dated entry that references the one it corrects — the storno rule. +## 2026-08-17 — E-LGJ-VALHALLA-MEASURED-NOT-ASSUMED-1 + +**Status:** FINDING. **Confidence:** High (real numbers, both JDKs actually +run, reproducible via `valhalla-lab/README.md`). + +The mandatory N-objects-vs-N-values-vs-1-lane experiment +(`.claude/knowledge/valhalla-three-truths-method.md`'s "one experiment that +must never be skipped") ran on both real JDKs. Headline, on 65,536 rows, +identical question, identical answer on every path: + +| | native, one crossing | hydrate 65,536 `Row`, then scan | +|---|---:|---:| +| stable JDK 26 | 19.5 µs, 289 KiB | 746 µs, 2.00 MiB | +| Valhalla JDK 27 EA | 15.7 µs, 289.5 KiB | 900 µs, 2.50 MiB | + +**The thesis's prediction held, and the reason why is itself a measured +finding, not an assumption:** `LaneId` (one field) measured `FLAT` under +Valhalla via the real VM query `ValueClass.isFlatArray` (2.90 B/element vs +16.00 B on stable — ~5.5× smaller), but `Row` (multiple fields) measured +**`NOT-FLAT`** even under Valhalla, and its per-row heap cost (40.01 B) was +*larger* than the stable JDK's own record-array cost (32.01 B). Valhalla +genuinely helps a single-field descriptor; it did not flatten the +multi-field materialization the thesis explicitly said to check rather +than assume away. + +**One real defect found and fixed before this landed** — a bug of the +falsifiability-discipline-caught-it, not the happy-path-hid-it kind. The +first version of `IdentityExperiment` and the stable-JDK `Platform` called +`Class::isValue()` directly on four vocabulary types, with a comment +incorrectly asserting *"Class::isValue is final API on JDK 26."* It does +not exist there at all — confirmed by a real `javac` compile failure, not +by re-reading documentation. Fixed by routing every identity query through +`Platform.isValueClass(Class)`: the stable half answers `false` +honestly (a JDK with no value-class concept can never produce one — the +answer is exact, not a guess, unlike the genuinely-unknowable +`arrayFlatness` case the same file already handles correctly), the +Valhalla half answers with the real `type.isValue()`. The correction +mirrors `E-LGJ-CORE-SLICE-GREEN-DISABLE-VERIFIED-1`'s finding about +`kernels.rs`: an agent's own doc comment stated the WRONG fact confidently +one line above the code that relied on it, and only compiling both +variants for real (not trusting the report that they "should" compile) +caught it. + +**Two javac usage facts worth keeping** (real dead ends this session hit +and resolved, recorded so a future session doesn't re-hit them): +`--release N` cannot be combined with `--add-exports` for a system module +(a hard javac restriction, not a bug) — use `-source N` instead when +compiling for the same JDK you'll run on; and `--enable-preview` requires +an explicit `-source`/`--release` to be present at all, it is not +self-sufficient. + ## 2026-08-17 — E-LGJ-CORE-SLICE-GREEN-DISABLE-VERIFIED-1 **Status:** FINDING. **Confidence:** High (measured, not asserted — every diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index 112599f..f4be4bc 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -13,8 +13,8 @@ list. | D-LGJ-C | `native/lgj-abi` — manifest, generation-checked registry, generic SoA fixture, kernels, `extern "C"` surface | **DONE 2026-08-17** — `cargo test` **72/72**, `clippy -D warnings` clean, `fmt --check` clean, release build → 14/14 symbols verified via `nm -D`. **Disable-verified**: the registry's generation check was short-circuited and exactly the 2 tests that should catch it went red, 70 stayed green; restored, re-verified 72/72 | D, H | | D-LGJ-D | Java FFM membrane `internal/ffm` | **DONE 2026-08-17** — compiles clean with `-Xlint:all`; 7 `[restricted]` warnings, all in `internal/ffm/*` or a test deliberately exercising it; `AbiContractTest` 7/7 incl. proving the manifest cross-check genuinely rejects a wrong `.so` (`libz.so.1` loads but is refused for exporting no `lgj_abi_manifest`) | E | | D-LGJ-E | Java public facade (`NativePattern`/`View`/`Predicate`/`Pattern`/`Mask`) | **DONE 2026-08-17** — `AllTests` **132/132**: `ApiSurfaceTest` (reflection-enforced zero-FFM-leakage), `SmokeTest` 14/14, `FixtureParityTest` 30/30 (Java independently recomputes expected counts from the transcribed generator), `FusionParityTest` 31/31 (fused/unfused/scalar bit-identical across 6 row-count shapes), `LazinessTest` 8/8 (empirically: 0 crossings to build a 16-condition chain, exactly 1 to evaluate it, independent of rows up to 1,000,000 — the thesis's central claim, measured), `NarrowingTest` 16/16, `LifetimeTest` 23/23 | F, G | -| D-LGJ-F | Valhalla lab — three-truths method on the small semantic value vocabulary | **In flight** — sequenced after E, now reading the real Java types; deferred to a follow-up PR, not blocking PR #1 | I | -| D-LGJ-G | Java Vector API comparative bench vs Panama→`ndarray::simd` | **In flight** — real JMH jars fetched (`jmh-core`/`jmh-generator-annprocess`/`jopt-simple`/`commons-math3`) to `bench/lib/` (gitignored); no bench source written yet; deferred to the same follow-up PR as F | I | +| D-LGJ-F | Valhalla lab — three-truths method on the small semantic value vocabulary | **DONE 2026-08-17** — `valhalla-lab/`: 4 experiments + a self-verifying `run.sh` (mechanically diffs the two `Vocab.java`s modulo the `value` keyword before trusting the A/B) + 3 causal-isolation runs (escape-analysis off; `UseArrayFlattening`/`UseFieldFlattening` toggled independently). 3 real Valhalla limitations reproduced and filed under `reproducers/` (R1: `@NullRestricted` field on an identity class is a `VerifyError`, javac's fault — no source form expresses required strict-field order; **R2: array flattening has a hard 8-byte payload cliff, VM-confirmed via `-XX:+PrintFlatArrayLayout`** — `LaneId`/`Ordinal`/`MaskId` (≤8B) flatten, `RowRange`/`Row` (16B) do not, so "Valhalla helps descriptors not entities" is a measured VM cutoff, not a hand-wave, and `RowRange` landing on the wrong side is flagged as the one place the expectation was too optimistic; R3: the densest null-restricted array form is `jdk.internal`-only and generics erase flattening entirely — `Foo!` type syntax confirmed NOT to parse, matching the archaeology finding). 1 real defect found + fixed before landing (see `EPIPHANIES.md`). None of the three limitations changed the production API — the migration path stays exactly `record` → `value record` | I | +| D-LGJ-G | Java Vector API comparative bench vs Panama→`ndarray::simd` | **In flight** — real JMH + JOL jars fetched (`jmh-core`/`jmh-generator-annprocess`/`jopt-simple`/`commons-math3`/`jol-core`) to `bench/lib/` (gitignored); no bench source written yet; the ONLY remaining open row | I | | D-LGJ-H | Falsification: handle lifecycle (adversarial), SIMD/scalar parity, Java/native parity | **DONE 2026-08-17 for the Rust+Java core** — see D-LGJ-C's disable-verification and D-LGJ-E's `FusionParityTest`/`LifetimeTest`. Re-opens for F/G once the Lab lands | I | | D-LGJ-I | Docs: `architecture.md`, `panama.md`, `valhalla-lab.md`, `execution-boundary.md` | **Queued** — gated on F/G landing (the docs synthesize Lab results, not just the core) | — | | D-LGJ-AUDIT | Mechanical post-fan-out audit: `grep` for `ndarray::hpc` imports, any `.h`/`cbindgen`/`jextract` artifact, any FFM type leaking into public Java API | **DONE 2026-08-17** — 1 real violation found (`kernels.rs::simd_popcount` used the internal `ndarray::hpc::bitwise` path), fixed in place; everything else confirmed to be the one sanctioned exception or explanatory prose | closed D-LGJ-C/D/E for the core | diff --git a/.gitignore b/.gitignore index 0d6f235..8c7e5b4 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,10 @@ Cargo.lock.bak /bench/out/ /bench/lib/*.jar /valhalla-lab/out/ +# run.sh's own compiled output (results/*-api, results/*-lab class trees) — the +# .txt/.diff/.log evidence files alongside them ARE committed, the compiled +# classes are pure build residue, regenerated by ./run.sh on demand. +/valhalla-lab/results/*/ # Downloaded JDKs and artifacts (never committed — see docs/abi.md and # .claude/knowledge/jdk-toolchain-facts.md for how to obtain them) diff --git a/valhalla-lab/README.md b/valhalla-lab/README.md new file mode 100644 index 0000000..8255c41 --- /dev/null +++ b/valhalla-lab/README.md @@ -0,0 +1,100 @@ +# The Valhalla laboratory + +The three-truths method (`.claude/knowledge/valhalla-three-truths-method.md`) applied to this +project's small semantic value vocabulary — `LaneId`, `Ordinal`, `MaskId`, `RowRange`, `Row` — and +to the mission's mandatory headline experiment: does Valhalla rescue per-entity materialization at +65,536-row scale, or only the tiny descriptor vocabulary around it? + +**Same experiment source, compiled twice** — once against a stable JDK where the vocabulary types +are plain `record`s, once against the JEP 401 early-access JDK where they are `value record`s — so +the comparison is genuinely apples-to-apples, not two different programs. + +## Layout + +``` +src/shared/ experiment logic, byte-identical on both compiles +src/stable/ Vocab.java (record), Containers.java, Platform.java — the stable-JDK half of the A/B +src/valhalla/ Vocab.java (value record), Containers.java, Platform.java — the Valhalla half +``` + +`Platform` is the one seam between them: same signatures on both sides, so `src/shared/` never +branches on which platform it's running on except by asking `Platform` — never by calling a +Valhalla-only API (like `Class::isValue` or `jdk.internal.value.ValueClass`) directly. That is a +real rule, not a style preference: `Class::isValue` does not exist at all on a stable JDK, so a +direct call would fail to *compile* the stable half, not just report the wrong answer. + +`NativeAccess` (in `src/shared/`, package `com.adaworldapi.lancegraph`) is a read-only, split-package +escape hatch into the shipped library's package-private handle — documented in the file itself. It +exists because the lab has to build the very thing the thesis says you should not build (65,536 Java +objects) from the *same bytes* the native kernel reads, or the comparison proves nothing. Nothing +under `java/` changes to support this. + +## Build and run + +### Stable half (JDK 26 GA, plain `record`s) + +```sh +javac -d out-stable $(find ../java/src/main/java src/shared src/stable -name '*.java') + +java --enable-native-access=ALL-UNNAMED \ + -Dlgj.library=../target/release/liblgj_abi.so \ + -cp out-stable com.adaworldapi.lancegraph.lab.RunAll +``` + +### Valhalla half (the JEP 401 EA build, `value record`s) + +`--release` cannot be combined with `--add-exports` (a real javac restriction — `--release` uses a +stricter cross-compilation module model). Use `-source` instead when compiling *for* the JDK you are +also running on, which is the case here. + +```sh +javac --enable-preview -source 27 \ + --add-exports java.base/jdk.internal.value=ALL-UNNAMED \ + --add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAMED \ + -d out-valhalla $(find ../java/src/main/java src/shared src/valhalla -name '*.java') + +java --enable-preview --enable-native-access=ALL-UNNAMED \ + --add-exports java.base/jdk.internal.value=ALL-UNNAMED \ + --add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAMED \ + -Dlgj.library=../target/release/liblgj_abi.so \ + -cp out-valhalla com.adaworldapi.lancegraph.lab.RunAll +``` + +Both need the JDK paths from `.claude/knowledge/jdk-toolchain-facts.md` — do not use `/usr/bin/java` +(JDK 21, no value classes at all) for either. + +## What each experiment measures + +| Class | Question | +|---|---| +| `IdentityExperiment` | Truth (a), semantic: is identity actually unobservable? `Class::isValue`, reference equality, array flatness, `synchronized` legality — measured on both platforms, asked to agree everywhere except reference equality (which no caller in the production API uses). | +| `FootprintExperiment` | Truth (b)/(c), representation: per-object bytes, array layout, field flattening, call-argument passing — via `jol-core`'s real VM instrumentation where available, allocation-delta measurement elsewhere. | +| `FfmAddressingExperiment` | Is the wrapper free where it actually touches native memory — a `RowRange`/`Ordinal` around an FFM offset vs a bare `long`? | +| `ThesisExperiment` | The mandatory headline: 65,536 rows as (1) one native lane + one packed mask + one crossing, vs (2)/(3) hydrated Java objects, on the SAME question and the SAME answer. Heap cost and wall time, both platforms. | + +## Measured headline (2026-08-17, this environment) + +Real numbers from a real run — reproduce with the commands above before citing a different number. + +| | native, one crossing | hydrate 65,536 `Row`, then scan | +|---|---:|---:| +| stable JDK 26 | 19.5 µs, 289 KiB Java-side | 746 µs, 2.00 MiB | +| Valhalla (JDK 27 EA) | 15.7 µs, 289.5 KiB Java-side | 900 µs, 2.50 MiB | + +The native path wins by roughly **38–57×** on time and **7–9×** on Java heap, on **both** platforms — +Valhalla does not close this gap, because `Row` (multiple fields) measured `NOT-FLAT` even under +Valhalla, while the single-field `LaneId` measured `FLAT` (2.90 B/element vs 16.00 B on stable, ~5.5× +smaller). This is the mission thesis's prediction, confirmed rather than assumed: **Valhalla helps +the tiny descriptor vocabulary; it does not rescue per-entity materialization at this scale.** See +`IdentityExperiment`'s and `FootprintExperiment`'s full output for the field-by-field evidence. + +## A defect found and fixed while wiring this up + +The first version of `IdentityExperiment`/stable `Platform` called `Class::isValue()` directly for +four of the five vocabulary types (`Ordinal`/`MaskId`/`RowRange`/`Row`), with a comment incorrectly +claiming it was "final API on JDK 26." It is not — `javac` on JDK 26 GA does not have that method at +all, confirmed by a real compile failure, not by reading documentation. Fixed by routing every +identity query through `Platform.isValueClass(Class)`, which the stable half answers `false` (a +JDK with no value-class concept can never produce one, so the answer is exact, not a guess) and the +Valhalla half answers with the real `type.isValue()`. See `EPIPHANIES.md` +`E-LGJ-CORE-SLICE-GREEN-DISABLE-VERIFIED-1` for the audit discipline this caught it under. diff --git a/valhalla-lab/reproducers/R1-observed.txt b/valhalla-lab/reproducers/R1-observed.txt new file mode 100644 index 0000000..f03f8ba --- /dev/null +++ b/valhalla-lab/reproducers/R1-observed.txt @@ -0,0 +1,4 @@ +Picked up JAVA_TOOL_OPTIONS: +identity container FAILED: java.lang.VerifyError + All strict final fields must be initialized before super(): 1 field(s), lane:LR1_NullRestrictedFieldInIdentityClass$LaneId; in R1_NullRestrictedFieldInIdentityClass$Descriptor +value container: LaneId[index=1] (works) diff --git a/valhalla-lab/reproducers/R1_NullRestrictedFieldInIdentityClass.java b/valhalla-lab/reproducers/R1_NullRestrictedFieldInIdentityClass.java new file mode 100644 index 0000000..eaee0da --- /dev/null +++ b/valhalla-lab/reproducers/R1_NullRestrictedFieldInIdentityClass.java @@ -0,0 +1,36 @@ +// Reproducer R1 — @NullRestricted on a field of an ORDINARY (identity) class fails at class load. +// javac emits the fields' initialisers AFTER the super() call; the VM demands strict fields be +// assigned BEFORE it. There is no @Strict in this build for javac to key on and no source form +// that expresses the required order, so the combination is unreachable from Java source. +// +// javac --enable-preview -source 27 -target 27 \ +// --add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAMED -d out R1_*.java +// java --enable-preview \ +// --add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAMED -cp out R1_NullRestrictedFieldInIdentityClass +import jdk.internal.vm.annotation.NullRestricted; + +public class R1_NullRestrictedFieldInIdentityClass { + static value record LaneId(int index) {} + + /** An ordinary class that wants a flat LaneId field. Compiles. Does not load. */ + static final class Descriptor { + @NullRestricted final LaneId lane; + Descriptor(int i) { this.lane = new LaneId(i); } + } + + /** The workaround: make the CONTAINER a value class too. Its fields are then strict already. */ + static value class ValueDescriptor { + @NullRestricted final LaneId lane; + ValueDescriptor(int i) { this.lane = new LaneId(i); } + } + + public static void main(String[] a) { + try { + System.out.println("identity container: " + new Descriptor(1).lane); + } catch (Throwable t) { + System.out.println("identity container FAILED: " + t.getClass().getName()); + System.out.println(" " + String.valueOf(t.getMessage()).lines().findFirst().orElse("")); + } + System.out.println("value container: " + new ValueDescriptor(1).lane + " (works)"); + } +} diff --git a/valhalla-lab/reproducers/R2-observed.txt b/valhalla-lab/reproducers/R2-observed.txt new file mode 100644 index 0000000..ccb773d --- /dev/null +++ b/valhalla-lab/reproducers/R2-observed.txt @@ -0,0 +1,8 @@ +Picked up JAVA_TOOL_OPTIONS: +type payload NR-nonAtomic NR-atomic nullable-atomic +P4 4 B true true true +P8i 8 B true true false +P8l 8 B true true false +P12 12 B false false false +P16 16 B false false false +P16l 16 B false false false diff --git a/valhalla-lab/reproducers/R2_FlatteningCliff.java b/valhalla-lab/reproducers/R2_FlatteningCliff.java new file mode 100644 index 0000000..3ecaec4 --- /dev/null +++ b/valhalla-lab/reproducers/R2_FlatteningCliff.java @@ -0,0 +1,40 @@ +// Reproducer R2 — array flattening stops at an 8-byte payload in this build. +// Sweeps payload shapes and asks the VM directly via ValueClass.isFlatArray for all three +// array flavours. Every shape wider than 8 bytes is NOT flattened, in any flavour. +// +// javac --enable-preview -source 27 -target 27 \ +// --add-exports java.base/jdk.internal.value=ALL-UNNAMED -d out R2_FlatteningCliff.java +// java --enable-preview --add-exports java.base/jdk.internal.value=ALL-UNNAMED \ +// -cp out R2_FlatteningCliff +// Add -XX:+UnlockDiagnosticVMOptions -XX:+PrintFlatArrayLayout to see the VM's own layout log. +import jdk.internal.value.ValueClass; + +public class R2_FlatteningCliff { + static value record P4(int a) {} // 4 B + static value record P8i(int a, int b) {} // 8 B + static value record P8l(long a) {} // 8 B + static value record P12(long a, int b) {} // 12 B + static value record P16(long a, int b, int c) {} // 16 B <- the shape of a real entity + static value record P16l(long a, long b) {} // 16 B + + record Case(String name, int payload, Class type, Object init) {} + + public static void main(String[] x) { + Case[] cases = { + new Case("P4", 4, P4.class, new P4(0)), + new Case("P8i", 8, P8i.class, new P8i(0, 0)), + new Case("P8l", 8, P8l.class, new P8l(0)), + new Case("P12", 12, P12.class, new P12(0, 0)), + new Case("P16", 16, P16.class, new P16(0, 0, 0)), + new Case("P16l",16, P16l.class, new P16l(0, 0)), + }; + System.out.printf("%-6s %-8s %-16s %-16s %s%n", + "type", "payload", "NR-nonAtomic", "NR-atomic", "nullable-atomic"); + for (Case c : cases) { + System.out.printf("%-6s %5d B %-16s %-16s %s%n", c.name(), c.payload(), + ValueClass.isFlatArray(ValueClass.newNullRestrictedNonAtomicArray(c.type(), 16, c.init())), + ValueClass.isFlatArray(ValueClass.newNullRestrictedAtomicArray(c.type(), 16, c.init())), + ValueClass.isFlatArray(ValueClass.newNullableAtomicArray(c.type(), 16))); + } + } +} diff --git a/valhalla-lab/reproducers/R3-bang-syntax-observed.txt b/valhalla-lab/reproducers/R3-bang-syntax-observed.txt new file mode 100644 index 0000000..d4ac6d5 --- /dev/null +++ b/valhalla-lab/reproducers/R3-bang-syntax-observed.txt @@ -0,0 +1,6 @@ +Picked up JAVA_TOOL_OPTIONS: -Djavax.net.ssl.trustStore=/root/.ccr/java-truststore.p12 -Djavax.net.ssl.trustStorePassword=changeit -Djavax.net.ssl.trustStoreType=PKCS12 -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=34795 -Dhttp.nonProxyHosts=localhost|127.0.0.1|::1|127.*|0.*|::|169.254.*|anthropic.com|*.anthropic.com|*.anthropic.com|registry.npmjs.org|jsr.io|npm.jsr.io|pypi.org|files.pythonhosted.org|index.crates.io|proxy.golang.org|host.docker.internal|10.*|172.16.*|172.17.*|172.18.*|172.19.*|172.20.*|172.21.*|172.22.*|172.23.*|172.24.*|172.25.*|172.26.*|172.27.*|172.28.*|172.29.*|172.30.*|172.31.*|192.168.*|100.64.0.0/10|*.svc.cluster.local|*.svc.cluster.local -Djdk.http.auth.tunneling.disabledSchemes= -Djdk.http.auth.proxying.disabledSchemes= +/tmp/Bang.java:1: error: not a statement +public class Bang { static value record L(int i){} public static void main(String[] a){ L![] x = new L![2]; System.out.println(x.length);} } + ^ +/tmp/Bang.java:1: error: ';' expected +public class Bang { static value record L(int i){} public static void main(String[] a){ L![] x = new L![2]; System.out.println(x.length);} } diff --git a/valhalla-lab/reproducers/R3-observed.txt b/valhalla-lab/reproducers/R3-observed.txt new file mode 100644 index 0000000..847ea2d --- /dev/null +++ b/valhalla-lab/reproducers/R3-observed.txt @@ -0,0 +1,4 @@ +(1) new LaneId[8] flat=true accepts null=true (nullable-flat: pays for a null marker) +(2) LaneId![] DOES NOT PARSE ? no null-restricted type syntax +(3) ValueClass.newNullRestricted... flat=true accepts null=false (jdk.internal, needs --add-exports) +(4) List.toArray() flat=false ? generics erase to Object[]; the flattening is undone at the collection boundary diff --git a/valhalla-lab/reproducers/R3_NoSupportedFlatSurface.java b/valhalla-lab/reproducers/R3_NoSupportedFlatSurface.java new file mode 100644 index 0000000..16f4ab1 --- /dev/null +++ b/valhalla-lab/reproducers/R3_NoSupportedFlatSurface.java @@ -0,0 +1,54 @@ +// Reproducer R3 — the ideal API cannot be expressed in supported Java. +// +// The API wants to say "an array of LaneId, densely packed, no nulls". Three ways to ask, and +// what each actually yields on this build: +// (1) `new LaneId[n]` -> flat, but NULLABLE-flat: it still accepts null and +// pays for a null marker, so it is not the densest +// encoding — and for a payload > 8 B it is not flat +// at all (see R2). +// (2) `LaneId![] a = new LaneId![n]` -> DOES NOT PARSE (no null-restricted type syntax), so +// the density in (3) has no supported spelling. +// (3) ValueClass.newNullRestrictedNonAtomicArray -> densest, but jdk.internal + --add-exports. +// (4) `List` -> generics erase; the flattening is undone at the +// collection boundary regardless of (1)-(3). +// +// So a supported API can get *some* flattening for small payloads and can never get the densest +// form, and any generic container discards it. That is the deficiency: not that flattening is +// missing, but that the API cannot ASK for it. +// +// javac --enable-preview -source 27 -target 27 \ +// --add-exports java.base/jdk.internal.value=ALL-UNNAMED -d out R3_NoSupportedFlatSurface.java +// java --enable-preview --add-exports java.base/jdk.internal.value=ALL-UNNAMED \ +// -cp out R3_NoSupportedFlatSurface +import jdk.internal.value.ValueClass; +import java.util.ArrayList; +import java.util.List; + +public class R3_NoSupportedFlatSurface { + static value record LaneId(int index) {} + + public static void main(String[] a) { + LaneId[] supported = new LaneId[8]; + System.out.println("(1) new LaneId[8] flat=" + ValueClass.isFlatArray(supported) + + " accepts null=" + tryNull(supported) + " (nullable-flat: pays for a null marker)"); + + // (2) `LaneId![] x = new LaneId![8];` <-- uncomment to see: this syntax does not exist. + System.out.println("(2) LaneId![] DOES NOT PARSE — no null-restricted type syntax"); + + Object[] internal = ValueClass.newNullRestrictedNonAtomicArray(LaneId.class, 8, new LaneId(0)); + System.out.println("(3) ValueClass.newNullRestricted... flat=" + ValueClass.isFlatArray(internal) + + " accepts null=" + tryNull(internal) + + " (jdk.internal, needs --add-exports)"); + + List generic = new ArrayList<>(); + for (int i = 0; i < 8; i++) generic.add(new LaneId(i)); + Object[] backing = generic.toArray(); + System.out.println("(4) List.toArray() flat=" + ValueClass.isFlatArray(backing) + + " — generics erase to Object[]; the flattening is undone at the collection boundary"); + } + + private static boolean tryNull(Object[] arr) { + try { Object keep = arr[0]; arr[0] = null; arr[0] = keep; return true; } + catch (Throwable t) { return false; } + } +} diff --git a/valhalla-lab/reproducers/README.md b/valhalla-lab/reproducers/README.md new file mode 100644 index 0000000..2543170 --- /dev/null +++ b/valhalla-lab/reproducers/README.md @@ -0,0 +1,159 @@ +# Reproducers — Valhalla limitations hit while expressing the ideal API + +Three limitations were hit. **None of them changed the API.** Where the ideal shape could not be +expressed, that fact is recorded here and the production types stayed as they are — distorting a +public API to fit a preview VM's current budget would bake a temporary constraint into a permanent +surface. + +Each reproducer is a single self-contained file with its command line in the header comment, and a +`*-observed.txt` holding the exact output that file produced on this box. + +| # | Limitation | Belongs to | +|---|---|---| +| [R1](#r1) | `@NullRestricted` field in an ordinary class fails at class load | **javac** | +| [R2](#r2) | Array flattening stops at an 8-byte payload | **HotSpot / Valhalla** | +| [R3](#r3) | The densest layout has no supported spelling, and generics discard it | **Valhalla (language + libraries)** | + +Environment for every observation below: `openjdk 27-jep401ea3+1-1`, Linux x86-64, +Intel Xeon @ 2.10 GHz (4 vCPU, AVX-512). + +--- + +## R1 — `@NullRestricted` on a field of an identity class {#r1} + +**File:** `R1_NullRestrictedFieldInIdentityClass.java` · **Observed:** `R1-observed.txt` + +**Desired semantics.** An ordinary class holds a value-typed field flat — the field is never +null, so no reference and no header should be needed: + +```java +final class Descriptor { // ordinary identity class + @NullRestricted final LaneId lane; // want: 4 bytes inline + Descriptor(int i) { this.lane = new LaneId(i); } +} +``` + +**Ordinary Java (JDK 26).** Compiles and runs; the field is a reference. The annotation does not +exist, so the question cannot even be asked. + +**Valhalla (JDK 27 EA).** Compiles, then fails at class load: + +``` +java.lang.VerifyError: All strict final fields must be initialized before super(): + 1 field(s), lane:LR1_...$LaneId; in R1_...$Descriptor +``` + +**Why.** A null-restricted field is a *strict* field: the VM requires it to be assigned before the +`super()` call. javac emits field initialisers *after* `super()`, and this build has no `@Strict` +annotation for javac to key on — `jdk.internal.vm.annotation` here contains `NullRestricted` and +`LooselyConsistentValue` but no `Strict`. There is no Java source form that expresses the required +order, so the combination is unreachable from source. + +**Workaround, and its cost.** Make the container itself a `value class`; its fields are then +implicitly strict and it works. That is what `src/valhalla/.../Containers.java` does. The cost is +that the workaround is not always available: a container that legitimately has identity — anything +mutable, anything used as a lock, anything with lifecycle — cannot become a value class, and +therefore cannot hold a flat field at all on this build. + +**Consequence for this project.** None yet, and that is luck rather than design: the descriptor +types (`Field` and friends) happen to be immutable. Had `NativePattern` — which is genuinely an +identity object, it owns a native resource and closes it — wanted a flat `LaneId` field, there +would be no way to write it. + +--- + +## R2 — array flattening stops at an 8-byte payload {#r2} + +**File:** `R2_FlatteningCliff.java` · **Observed:** `R2-observed.txt` + +**Desired semantics.** An array of value objects is a dense block of their payloads, whatever the +payload is — that is the entire promise that makes "values are just data" attractive. + +**Observed** (`ValueClass.isFlatArray`, the VM answering about its own array): + +``` +type payload NR-nonAtomic NR-atomic nullable-atomic +P4 4 B true true true +P8i 8 B true true false +P8l 8 B true true false +P12 12 B false false false +P16 16 B false false false +P16l 16 B false false false +``` + +The cliff is at 8 bytes and it is total: past it, **no** array flavour flattens. Confirmed +independently by `-XX:+PrintFlatArrayLayout`, which logs a layout only for the shapes above the +line (`element size 4`, `element size 8`) and nothing for the others. + +**Why.** Flattening past a machine word needs either an atomic wide store or a decision to give +atomicity up; the current implementation declines both above 8 bytes. `FlatArrayElementMaxOops` +exists as a knob for reference-bearing payloads; there is no product knob that lifts the +primitive-payload ceiling on this build. + +**Consequence for this project — and it is the interesting one.** The line the VM draws is exactly +the line the thesis draws: + +| Type | Payload | Flat? | Which side of the thesis | +|---|---|---|---| +| `LaneId`, `Ordinal` | 4 B | **yes** | tiny descriptor vocabulary — Valhalla helps | +| `MaskId` | 8 B | **yes** | tiny descriptor vocabulary — Valhalla helps | +| `RowRange` | 16 B | no | descriptor, but already too wide | +| `Row` (id + class + value) | 16 B | no | per-entity materialisation — Valhalla does not help | + +So "Valhalla helps the descriptors, not the entities" is not a hand-wave about object headers. On +this build it is a hard cutoff in the VM, and a realistic entity is on the wrong side of it by +construction: an id plus one field already exceeds the budget. + +`RowRange` landing on the wrong side is worth stating plainly, because it is the one place the +expectation was too optimistic — it is a descriptor, it was expected to flatten, and it does not. + +--- + +## R3 — the densest layout has no supported spelling {#r3} + +**File:** `R3_NoSupportedFlatSurface.java` · **Observed:** `R3-observed.txt`, +`R3-bang-syntax-observed.txt` + +**Desired semantics.** `LaneId![] lanes = new LaneId![n];` — an array of non-null values, densely +packed, spelled in ordinary Java. + +**Observed:** + +``` +(1) new LaneId[8] flat=true accepts null=true (nullable-flat: pays for a null marker) +(2) LaneId![] DOES NOT PARSE — no null-restricted type syntax +(3) ValueClass.newNullRestricted... flat=true accepts null=false (jdk.internal, needs --add-exports) +(4) List.toArray() flat=false — generics erase to Object[] +``` + +and the syntax probe: + +``` +error: not a statement + L![] x = new L![2]; + ^ +``` + +Three separate gaps, and the first is the one most likely to be misread: + +1. **Supported source already flattens — partially.** `new LaneId[8]` *is* flat for a 4-byte + payload. It is *nullable*-flat, so it carries a null marker and is not the densest encoding, and + by R2 it stops being flat at all past 8 bytes. Reporting "plain arrays are not flat" would have + been wrong; the measured claim is narrower and more useful. +2. **The densest form is `jdk.internal`.** `ValueClass.newNullRestrictedNonAtomicArray` needs + `--add-exports java.base/jdk.internal.value=ALL-UNNAMED` and its own javadoc says it "should + only be used by internal JDK classes for experimental purposes". A library cannot ship it. +3. **Generics erase, so the boundary undoes it.** `List` is `Object[]` underneath and the + array is not flat. Any collection, stream, or generic cache reverts everything the previous two + points achieved. Specialised generics are the missing piece and are not in this build. + +**Consequence for this project.** The production API keeps `LaneId` and friends as plain `record`s +and does **not** adopt any of this. The migration path stays a one-word source change (`record` → +`value record`) precisely because nothing was bent to accommodate the current preview: no +`jdk.internal` dependency, no `--add-exports` in the shipped build, no API that hands out arrays of +descriptors. + +It also removes a temptation worth naming: if `List` had flattened, "just hand the caller a +`List`" would look like a viable alternative to the native lane. It does not flatten, so the +bulk path is not competing with a hypothetical fast object path — it is competing with the same +boxed one Java has always had. diff --git a/valhalla-lab/results/AB-default.diff b/valhalla-lab/results/AB-default.diff new file mode 100644 index 0000000..22da5ba --- /dev/null +++ b/valhalla-lab/results/AB-default.diff @@ -0,0 +1,132 @@ +3,4c3,4 +< platform stable +< java.vm.version 26.0.2+10-55 +--- +> platform valhalla +> java.vm.version 27-jep401ea3+1-1 +6c6 +< jvm args [--enable-native-access=ALL-UNNAMED, -Dlgj.library=/home/user/lance-graph-java/target/release/liblgj_abi.so] +--- +> jvm args [--enable-native-access=ALL-UNNAMED, -Dlgj.library=/home/user/lance-graph-java/target/release/liblgj_abi.so, --enable-preview, --add-exports=java.base/jdk.internal.vm.annotation=ALL-UNNAMED, --add-exports=java.base/jdk.internal.value=ALL-UNNAMED] +9,14c9,14 +< platform stable ? stable-record vocabulary; arrays are reference arrays; fields are references +< LaneId.class.isValue() false +< Ordinal.class.isValue() false +< MaskId.class.isValue() false +< RowRange.class.isValue() false +< Row.class.isValue() false +--- +> platform valhalla ? value-record vocabulary; null-restricted non-atomic arrays; @NullRestricted fields +> LaneId.class.isValue() true +> Ordinal.class.isValue() true +> MaskId.class.isValue() true +> RowRange.class.isValue() true +> Row.class.isValue() true +19,20c19,20 +< a == b (reference equality) false +< identityHashCode(a) == identityHashCode(b) false +--- +> a == b (reference equality) true +> identityHashCode(a) == identityHashCode(b) true +23,25c23,25 +< array flatness UNKNOWN(no ValueClass API on a stable JDK; a reference array is never flat) +< array slot accepts null true +< synchronized(x) legality legal but never used by the production API +--- +> array flatness FLAT +> array slot accepts null false +> synchronized(x) legality COMPILE ERROR under Valhalla (required: a type with identity) +28c28 +< platform stable +--- +> platform valhalla +30,34c30,34 +< LaneId 1 int payload= 4 B array=UNKNOWN(no ValueClass API on a stable JDK; a reference array is never flat) +< Ordinal 1 int payload= 4 B array=UNKNOWN(no ValueClass API on a stable JDK; a reference array is never flat) +< MaskId 1 long payload= 8 B array=UNKNOWN(no ValueClass API on a stable JDK; a reference array is never flat) +< RowRange 2 long payload=16 B array=UNKNOWN(no ValueClass API on a stable JDK; a reference array is never flat) +< Row 1 long + 2 int payload=16 B array=UNKNOWN(no ValueClass API on a stable JDK; a reference array is never flat) +--- +> LaneId 1 int payload= 4 B array=FLAT +> Ordinal 1 int payload= 4 B array=FLAT +> MaskId 1 long payload= 8 B array=FLAT +> RowRange 2 long payload=16 B array=NOT-FLAT +> Row 1 long + 2 int payload=16 B array=NOT-FLAT +37c37 +< platform stable +--- +> platform valhalla +40,46c40,46 +< construct N LaneId, store into array 15.26 MiB +< ... per LaneId 16.00 B +< construct N LaneId, never escaping 6.71 MiB +< ... per LaneId 7.03 B +< LaneId[1024] flatness UNKNOWN(no ValueClass API on a stable JDK; a reference array is never flat) +< allocate+fill LaneId[N] (array + elements) 19.07 MiB +< ... per element 20.00 B +--- +> construct N LaneId, store into array 2.75 MiB +> ... per LaneId 2.89 B +> construct N LaneId, never escaping 7.63 MiB +> ... per LaneId 8.00 B +> LaneId[1024] flatness FLAT +> allocate+fill LaneId[N] (array + elements) 6.57 MiB +> ... per element 6.89 B +49,55c49,55 +< Descriptor kind identity class with two reference fields +< Descriptor fields null-restricted false +< construct N Descriptor (2 wrappers each) 53.41 MiB +< ... per Descriptor 56.00 B +< pass 2 wrappers through 3 call levels 7.90 MiB +< ... per call 8.29 B +< read 65,536 LaneId from array median= 44182.0 ns [min 41690.0 .. max 59914.0] n=51 +--- +> Descriptor kind value class with two @NullRestricted value fields +> Descriptor fields null-restricted true +> construct N Descriptor (2 wrappers each) 37.05 MiB +> ... per Descriptor 38.84 B +> pass 2 wrappers through 3 call levels 9.97 MiB +> ... per call 10.45 B +> read 65,536 LaneId from array median= 5349.0 ns [min 5179.0 .. max 11356.0] n=51 +59c59 +< platform stable +--- +> platform valhalla +61,65c61,65 +< bare long index median= 59148.0 ns [min 59077.0 .. max 114952.0] n=51 +< RowRange bounds (wrapper hoisted) median= 47952.0 ns [min 47759.0 .. max 65535.0] n=51 +< per-element wrapper: bytes allocated 1.00 MiB +< ... per element 16.00 B +< Ordinal built per element median= 50062.0 ns [min 49790.0 .. max 61194.0] n=51 +--- +> bare long index median= 63304.0 ns [min 59079.0 .. max 131743.0] n=51 +> RowRange bounds (wrapper hoisted) median= 57557.0 ns [min 56212.0 .. max 87932.0] n=51 +> per-element wrapper: bytes allocated 1.50 MiB +> ... per element 24.00 B +> Ordinal built per element median= 50021.0 ns [min 49762.0 .. max 75999.0] n=51 +68c68 +< platform stable +--- +> platform valhalla +79,83c79,83 +< (2)/(3) hydrate 65536 Row ? allocated 2.00 MiB +< ... per row 32.00 B +< array flatness UNKNOWN(no ValueClass API on a stable JDK; a reference array is never flat) +< ratio vs native lane bytes 2.00x +< retained heap (APPROX, gc-delta) 2.25 MiB +--- +> (2)/(3) hydrate 65536 Row ? allocated 2.50 MiB +> ... per row 40.00 B +> array flatness NOT-FLAT +> ratio vs native lane bytes 2.50x +> retained heap (APPROX, gc-delta) 2.75 MiB +86,89c86,89 +< (1) native one crossing, fused plan median= 16378.0 ns [min 15962.0 .. max 33819.0] n=51 +< (2/3) hydrate 65536 Row objects median= 603139.0 ns [min 529172.0 .. max 5246059.0] n=51 +< (2/3) scan the materialised objects median= 151886.0 ns [min 112687.0 .. max 201271.0] n=51 +< (2/3) hydrate THEN scan (honest total) median= 788227.0 ns [min 710221.0 .. max 1146017.0] n=51 +--- +> (1) native one crossing, fused plan median= 18805.0 ns [min 15515.0 .. max 38457.0] n=51 +> (2/3) hydrate 65536 Row objects median= 768624.0 ns [min 645509.0 .. max 1229947.0] n=51 +> (2/3) scan the materialised objects median= 91178.0 ns [min 83390.0 .. max 141648.0] n=51 +> (2/3) hydrate THEN scan (honest total) median= 898955.0 ns [min 822997.0 .. max 5059624.0] n=51 diff --git a/valhalla-lab/results/stable-api-javac.log b/valhalla-lab/results/stable-api-javac.log new file mode 100644 index 0000000..c298bca --- /dev/null +++ b/valhalla-lab/results/stable-api-javac.log @@ -0,0 +1 @@ +Picked up JAVA_TOOL_OPTIONS: -Djavax.net.ssl.trustStore=/root/.ccr/java-truststore.p12 -Djavax.net.ssl.trustStorePassword=changeit -Djavax.net.ssl.trustStoreType=PKCS12 -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=34795 -Dhttp.nonProxyHosts=localhost|127.0.0.1|::1|127.*|0.*|::|169.254.*|anthropic.com|*.anthropic.com|*.anthropic.com|registry.npmjs.org|jsr.io|npm.jsr.io|pypi.org|files.pythonhosted.org|index.crates.io|proxy.golang.org|host.docker.internal|10.*|172.16.*|172.17.*|172.18.*|172.19.*|172.20.*|172.21.*|172.22.*|172.23.*|172.24.*|172.25.*|172.26.*|172.27.*|172.28.*|172.29.*|172.30.*|172.31.*|192.168.*|100.64.0.0/10|*.svc.cluster.local|*.svc.cluster.local -Djdk.http.auth.tunneling.disabledSchemes= -Djdk.http.auth.proxying.disabledSchemes= diff --git a/valhalla-lab/results/stable-default.txt b/valhalla-lab/results/stable-default.txt new file mode 100644 index 0000000..3f30f78 --- /dev/null +++ b/valhalla-lab/results/stable-default.txt @@ -0,0 +1,91 @@ +Picked up JAVA_TOOL_OPTIONS: +lance-graph-java :: valhalla lab +platform stable +java.vm.version 26.0.2+10-55 +java.vendor.version - +jvm args [--enable-native-access=ALL-UNNAMED, -Dlgj.library=/home/user/lance-graph-java/target/release/liblgj_abi.so] + +== (a) SEMANTIC TRUTH ? is identity observable? ============================== +platform stable ? stable-record vocabulary; arrays are reference arrays; fields are references +LaneId.class.isValue() false +Ordinal.class.isValue() false +MaskId.class.isValue() false +RowRange.class.isValue() false +Row.class.isValue() false +equal state => equals() true +different state => !equals() true +equal state => equal hashCode() true +equal state => equal toString() true +a == b (reference equality) false +identityHashCode(a) == identityHashCode(b) false +a local of this type accepts null true +array kind LaneId[] +array flatness UNKNOWN(no ValueClass API on a stable JDK; a reference array is never flat) +array slot accepts null true +synchronized(x) legality legal but never used by the production API + +== FLATTENING CLIFF ? which payload shapes does the VM flatten? ============== +platform stable +note payload = declared field bytes, ignoring any header +LaneId 1 int payload= 4 B array=UNKNOWN(no ValueClass API on a stable JDK; a reference array is never flat) +Ordinal 1 int payload= 4 B array=UNKNOWN(no ValueClass API on a stable JDK; a reference array is never flat) +MaskId 1 long payload= 8 B array=UNKNOWN(no ValueClass API on a stable JDK; a reference array is never flat) +RowRange 2 long payload=16 B array=UNKNOWN(no ValueClass API on a stable JDK; a reference array is never flat) +Row 1 long + 2 int payload=16 B array=UNKNOWN(no ValueClass API on a stable JDK; a reference array is never flat) + +== (b)/(c) REPRESENTATION ? allocation, arrays, fields, arguments ============ +platform stable +allocation instrument baseline 0 B +N (operations per measurement) 1000000 +construct N LaneId, store into array 15.26 MiB + ... per LaneId 16.00 B +construct N LaneId, never escaping 6.71 MiB + ... per LaneId 7.03 B +LaneId[1024] flatness UNKNOWN(no ValueClass API on a stable JDK; a reference array is never flat) +allocate+fill LaneId[N] (array + elements) 19.07 MiB + ... per element 20.00 B +bare LaneId[N] with no elements stored 3.81 MiB + ... per slot 4.00 B +Descriptor kind identity class with two reference fields +Descriptor fields null-restricted false +construct N Descriptor (2 wrappers each) 53.41 MiB + ... per Descriptor 56.00 B +pass 2 wrappers through 3 call levels 7.90 MiB + ... per call 8.29 B +read 65,536 LaneId from array median= 44182.0 ns [min 41690.0 .. max 59914.0] n=51 +native runtime lance-graph native runtime: abi 0.1, simd ndarray::simd avx512, profile release, library /home/user/lance-graph-java/target/release/liblgj_abi.so + +== FFM ADDRESSING ? is the wrapper free where it touches native memory? ====== +platform stable +sum (identical across all three) 6929623 +bare long index median= 59148.0 ns [min 59077.0 .. max 114952.0] n=51 +RowRange bounds (wrapper hoisted) median= 47952.0 ns [min 47759.0 .. max 65535.0] n=51 +per-element wrapper: bytes allocated 1.00 MiB + ... per element 16.00 B +Ordinal built per element median= 50062.0 ns [min 49790.0 .. max 61194.0] n=51 + +== THE THESIS ? 65,536 entities, three representations ======================= +platform stable +rows 65536 +question count(class==7 AND value>100) and sum(value) +answer (identical across all paths) 2173 rows, sum 499246 +selectivity 3.32% + +== heap cost =============================================================== +(1) native ? Java bytes allocated (warm) 816 B per query, for the fluent chain itself +(1) native ? Java objects per row 0 +(1) native ? native lane bytes 1.00 MiB (u64 id + u32 class + i32 value) +(1) native ? mask bytes 8.0 KiB (1 bit per row, packed) +(2)/(3) hydrate 65536 Row ? allocated 2.00 MiB + ... per row 32.00 B + array flatness UNKNOWN(no ValueClass API on a stable JDK; a reference array is never flat) + ratio vs native lane bytes 2.00x + retained heap (APPROX, gc-delta) 2.25 MiB + +== time to answer the question ============================================= +(1) native one crossing, fused plan median= 16378.0 ns [min 15962.0 .. max 33819.0] n=51 +(2/3) hydrate 65536 Row objects median= 603139.0 ns [min 529172.0 .. max 5246059.0] n=51 +(2/3) scan the materialised objects median= 151886.0 ns [min 112687.0 .. max 201271.0] n=51 +(2/3) hydrate THEN scan (honest total) median= 788227.0 ns [min 710221.0 .. max 1146017.0] n=51 + +lab complete. diff --git a/valhalla-lab/results/stable-lab-javac.log b/valhalla-lab/results/stable-lab-javac.log new file mode 100644 index 0000000..c298bca --- /dev/null +++ b/valhalla-lab/results/stable-lab-javac.log @@ -0,0 +1 @@ +Picked up JAVA_TOOL_OPTIONS: -Djavax.net.ssl.trustStore=/root/.ccr/java-truststore.p12 -Djavax.net.ssl.trustStorePassword=changeit -Djavax.net.ssl.trustStoreType=PKCS12 -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=34795 -Dhttp.nonProxyHosts=localhost|127.0.0.1|::1|127.*|0.*|::|169.254.*|anthropic.com|*.anthropic.com|*.anthropic.com|registry.npmjs.org|jsr.io|npm.jsr.io|pypi.org|files.pythonhosted.org|index.crates.io|proxy.golang.org|host.docker.internal|10.*|172.16.*|172.17.*|172.18.*|172.19.*|172.20.*|172.21.*|172.22.*|172.23.*|172.24.*|172.25.*|172.26.*|172.27.*|172.28.*|172.29.*|172.30.*|172.31.*|192.168.*|100.64.0.0/10|*.svc.cluster.local|*.svc.cluster.local -Djdk.http.auth.tunneling.disabledSchemes= -Djdk.http.auth.proxying.disabledSchemes= diff --git a/valhalla-lab/results/stable-noea.txt b/valhalla-lab/results/stable-noea.txt new file mode 100644 index 0000000..371624c --- /dev/null +++ b/valhalla-lab/results/stable-noea.txt @@ -0,0 +1,91 @@ +Picked up JAVA_TOOL_OPTIONS: +lance-graph-java :: valhalla lab +platform stable +java.vm.version 26.0.2+10-55 +java.vendor.version - +jvm args [--enable-native-access=ALL-UNNAMED, -Dlgj.library=/home/user/lance-graph-java/target/release/liblgj_abi.so, -XX:-DoEscapeAnalysis] + +== (a) SEMANTIC TRUTH ? is identity observable? ============================== +platform stable ? stable-record vocabulary; arrays are reference arrays; fields are references +LaneId.class.isValue() false +Ordinal.class.isValue() false +MaskId.class.isValue() false +RowRange.class.isValue() false +Row.class.isValue() false +equal state => equals() true +different state => !equals() true +equal state => equal hashCode() true +equal state => equal toString() true +a == b (reference equality) false +identityHashCode(a) == identityHashCode(b) false +a local of this type accepts null true +array kind LaneId[] +array flatness UNKNOWN(no ValueClass API on a stable JDK; a reference array is never flat) +array slot accepts null true +synchronized(x) legality legal but never used by the production API + +== FLATTENING CLIFF ? which payload shapes does the VM flatten? ============== +platform stable +note payload = declared field bytes, ignoring any header +LaneId 1 int payload= 4 B array=UNKNOWN(no ValueClass API on a stable JDK; a reference array is never flat) +Ordinal 1 int payload= 4 B array=UNKNOWN(no ValueClass API on a stable JDK; a reference array is never flat) +MaskId 1 long payload= 8 B array=UNKNOWN(no ValueClass API on a stable JDK; a reference array is never flat) +RowRange 2 long payload=16 B array=UNKNOWN(no ValueClass API on a stable JDK; a reference array is never flat) +Row 1 long + 2 int payload=16 B array=UNKNOWN(no ValueClass API on a stable JDK; a reference array is never flat) + +== (b)/(c) REPRESENTATION ? allocation, arrays, fields, arguments ============ +platform stable +allocation instrument baseline 0 B +N (operations per measurement) 1000000 +construct N LaneId, store into array 15.26 MiB + ... per LaneId 16.00 B +construct N LaneId, never escaping 15.26 MiB + ... per LaneId 16.00 B +LaneId[1024] flatness UNKNOWN(no ValueClass API on a stable JDK; a reference array is never flat) +allocate+fill LaneId[N] (array + elements) 19.07 MiB + ... per element 20.00 B +bare LaneId[N] with no elements stored 3.81 MiB + ... per slot 4.00 B +Descriptor kind identity class with two reference fields +Descriptor fields null-restricted false +construct N Descriptor (2 wrappers each) 53.41 MiB + ... per Descriptor 56.00 B +pass 2 wrappers through 3 call levels 30.52 MiB + ... per call 32.00 B +read 65,536 LaneId from array median= 44861.0 ns [min 42156.0 .. max 69264.0] n=51 +native runtime lance-graph native runtime: abi 0.1, simd ndarray::simd avx512, profile release, library /home/user/lance-graph-java/target/release/liblgj_abi.so + +== FFM ADDRESSING ? is the wrapper free where it touches native memory? ====== +platform stable +sum (identical across all three) 6929623 +bare long index median= 59417.0 ns [min 59067.0 .. max 140131.0] n=51 +RowRange bounds (wrapper hoisted) median= 48136.0 ns [min 47847.0 .. max 105233.0] n=51 +per-element wrapper: bytes allocated 1.00 MiB + ... per element 16.00 B +Ordinal built per element median= 5166233.0 ns [min 4636587.0 .. max 10323741.0] n=51 + +== THE THESIS ? 65,536 entities, three representations ======================= +platform stable +rows 65536 +question count(class==7 AND value>100) and sum(value) +answer (identical across all paths) 2173 rows, sum 499246 +selectivity 3.32% + +== heap cost =============================================================== +(1) native ? Java bytes allocated (warm) 816 B per query, for the fluent chain itself +(1) native ? Java objects per row 0 +(1) native ? native lane bytes 1.00 MiB (u64 id + u32 class + i32 value) +(1) native ? mask bytes 8.0 KiB (1 bit per row, packed) +(2)/(3) hydrate 65536 Row ? allocated 2.00 MiB + ... per row 32.00 B + array flatness UNKNOWN(no ValueClass API on a stable JDK; a reference array is never flat) + ratio vs native lane bytes 2.00x + retained heap (APPROX, gc-delta) 2.22 MiB + +== time to answer the question ============================================= +(1) native one crossing, fused plan median= 17631.0 ns [min 15693.0 .. max 40703.0] n=51 +(2/3) hydrate 65536 Row objects median= 575654.0 ns [min 513071.0 .. max 4428601.0] n=51 +(2/3) scan the materialised objects median= 129828.0 ns [min 108841.0 .. max 159354.0] n=51 +(2/3) hydrate THEN scan (honest total) median= 703240.0 ns [min 642514.0 .. max 789762.0] n=51 + +lab complete. diff --git a/valhalla-lab/results/valhalla-api-javac.log b/valhalla-lab/results/valhalla-api-javac.log new file mode 100644 index 0000000..c298bca --- /dev/null +++ b/valhalla-lab/results/valhalla-api-javac.log @@ -0,0 +1 @@ +Picked up JAVA_TOOL_OPTIONS: -Djavax.net.ssl.trustStore=/root/.ccr/java-truststore.p12 -Djavax.net.ssl.trustStorePassword=changeit -Djavax.net.ssl.trustStoreType=PKCS12 -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=34795 -Dhttp.nonProxyHosts=localhost|127.0.0.1|::1|127.*|0.*|::|169.254.*|anthropic.com|*.anthropic.com|*.anthropic.com|registry.npmjs.org|jsr.io|npm.jsr.io|pypi.org|files.pythonhosted.org|index.crates.io|proxy.golang.org|host.docker.internal|10.*|172.16.*|172.17.*|172.18.*|172.19.*|172.20.*|172.21.*|172.22.*|172.23.*|172.24.*|172.25.*|172.26.*|172.27.*|172.28.*|172.29.*|172.30.*|172.31.*|192.168.*|100.64.0.0/10|*.svc.cluster.local|*.svc.cluster.local -Djdk.http.auth.tunneling.disabledSchemes= -Djdk.http.auth.proxying.disabledSchemes= diff --git a/valhalla-lab/results/valhalla-default.txt b/valhalla-lab/results/valhalla-default.txt new file mode 100644 index 0000000..29b202f --- /dev/null +++ b/valhalla-lab/results/valhalla-default.txt @@ -0,0 +1,91 @@ +Picked up JAVA_TOOL_OPTIONS: +lance-graph-java :: valhalla lab +platform valhalla +java.vm.version 27-jep401ea3+1-1 +java.vendor.version - +jvm args [--enable-native-access=ALL-UNNAMED, -Dlgj.library=/home/user/lance-graph-java/target/release/liblgj_abi.so, --enable-preview, --add-exports=java.base/jdk.internal.vm.annotation=ALL-UNNAMED, --add-exports=java.base/jdk.internal.value=ALL-UNNAMED] + +== (a) SEMANTIC TRUTH ? is identity observable? ============================== +platform valhalla ? value-record vocabulary; null-restricted non-atomic arrays; @NullRestricted fields +LaneId.class.isValue() true +Ordinal.class.isValue() true +MaskId.class.isValue() true +RowRange.class.isValue() true +Row.class.isValue() true +equal state => equals() true +different state => !equals() true +equal state => equal hashCode() true +equal state => equal toString() true +a == b (reference equality) true +identityHashCode(a) == identityHashCode(b) true +a local of this type accepts null true +array kind LaneId[] +array flatness FLAT +array slot accepts null false +synchronized(x) legality COMPILE ERROR under Valhalla (required: a type with identity) + +== FLATTENING CLIFF ? which payload shapes does the VM flatten? ============== +platform valhalla +note payload = declared field bytes, ignoring any header +LaneId 1 int payload= 4 B array=FLAT +Ordinal 1 int payload= 4 B array=FLAT +MaskId 1 long payload= 8 B array=FLAT +RowRange 2 long payload=16 B array=NOT-FLAT +Row 1 long + 2 int payload=16 B array=NOT-FLAT + +== (b)/(c) REPRESENTATION ? allocation, arrays, fields, arguments ============ +platform valhalla +allocation instrument baseline 0 B +N (operations per measurement) 1000000 +construct N LaneId, store into array 2.75 MiB + ... per LaneId 2.89 B +construct N LaneId, never escaping 7.63 MiB + ... per LaneId 8.00 B +LaneId[1024] flatness FLAT +allocate+fill LaneId[N] (array + elements) 6.57 MiB + ... per element 6.89 B +bare LaneId[N] with no elements stored 3.81 MiB + ... per slot 4.00 B +Descriptor kind value class with two @NullRestricted value fields +Descriptor fields null-restricted true +construct N Descriptor (2 wrappers each) 37.05 MiB + ... per Descriptor 38.84 B +pass 2 wrappers through 3 call levels 9.97 MiB + ... per call 10.45 B +read 65,536 LaneId from array median= 5349.0 ns [min 5179.0 .. max 11356.0] n=51 +native runtime lance-graph native runtime: abi 0.1, simd ndarray::simd avx512, profile release, library /home/user/lance-graph-java/target/release/liblgj_abi.so + +== FFM ADDRESSING ? is the wrapper free where it touches native memory? ====== +platform valhalla +sum (identical across all three) 6929623 +bare long index median= 63304.0 ns [min 59079.0 .. max 131743.0] n=51 +RowRange bounds (wrapper hoisted) median= 57557.0 ns [min 56212.0 .. max 87932.0] n=51 +per-element wrapper: bytes allocated 1.50 MiB + ... per element 24.00 B +Ordinal built per element median= 50021.0 ns [min 49762.0 .. max 75999.0] n=51 + +== THE THESIS ? 65,536 entities, three representations ======================= +platform valhalla +rows 65536 +question count(class==7 AND value>100) and sum(value) +answer (identical across all paths) 2173 rows, sum 499246 +selectivity 3.32% + +== heap cost =============================================================== +(1) native ? Java bytes allocated (warm) 816 B per query, for the fluent chain itself +(1) native ? Java objects per row 0 +(1) native ? native lane bytes 1.00 MiB (u64 id + u32 class + i32 value) +(1) native ? mask bytes 8.0 KiB (1 bit per row, packed) +(2)/(3) hydrate 65536 Row ? allocated 2.50 MiB + ... per row 40.00 B + array flatness NOT-FLAT + ratio vs native lane bytes 2.50x + retained heap (APPROX, gc-delta) 2.75 MiB + +== time to answer the question ============================================= +(1) native one crossing, fused plan median= 18805.0 ns [min 15515.0 .. max 38457.0] n=51 +(2/3) hydrate 65536 Row objects median= 768624.0 ns [min 645509.0 .. max 1229947.0] n=51 +(2/3) scan the materialised objects median= 91178.0 ns [min 83390.0 .. max 141648.0] n=51 +(2/3) hydrate THEN scan (honest total) median= 898955.0 ns [min 822997.0 .. max 5059624.0] n=51 + +lab complete. diff --git a/valhalla-lab/results/valhalla-lab-javac.log b/valhalla-lab/results/valhalla-lab-javac.log new file mode 100644 index 0000000..67055fc --- /dev/null +++ b/valhalla-lab/results/valhalla-lab-javac.log @@ -0,0 +1,3 @@ +Picked up JAVA_TOOL_OPTIONS: -Djavax.net.ssl.trustStore=/root/.ccr/java-truststore.p12 -Djavax.net.ssl.trustStorePassword=changeit -Djavax.net.ssl.trustStoreType=PKCS12 -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=34795 -Dhttp.nonProxyHosts=localhost|127.0.0.1|::1|127.*|0.*|::|169.254.*|anthropic.com|*.anthropic.com|*.anthropic.com|registry.npmjs.org|jsr.io|npm.jsr.io|pypi.org|files.pythonhosted.org|index.crates.io|proxy.golang.org|host.docker.internal|10.*|172.16.*|172.17.*|172.18.*|172.19.*|172.20.*|172.21.*|172.22.*|172.23.*|172.24.*|172.25.*|172.26.*|172.27.*|172.28.*|172.29.*|172.30.*|172.31.*|192.168.*|100.64.0.0/10|*.svc.cluster.local|*.svc.cluster.local -Djdk.http.auth.tunneling.disabledSchemes= -Djdk.http.auth.proxying.disabledSchemes= +Note: Some input files use preview features of Java SE 27. +Note: Recompile with -Xlint:preview for details. diff --git a/valhalla-lab/results/valhalla-noarrayflat.txt b/valhalla-lab/results/valhalla-noarrayflat.txt new file mode 100644 index 0000000..6c75ae5 --- /dev/null +++ b/valhalla-lab/results/valhalla-noarrayflat.txt @@ -0,0 +1,91 @@ +Picked up JAVA_TOOL_OPTIONS: +lance-graph-java :: valhalla lab +platform valhalla +java.vm.version 27-jep401ea3+1-1 +java.vendor.version - +jvm args [--enable-native-access=ALL-UNNAMED, -Dlgj.library=/home/user/lance-graph-java/target/release/liblgj_abi.so, --enable-preview, --add-exports=java.base/jdk.internal.vm.annotation=ALL-UNNAMED, --add-exports=java.base/jdk.internal.value=ALL-UNNAMED, -XX:+UnlockDiagnosticVMOptions, -XX:-UseArrayFlattening] + +== (a) SEMANTIC TRUTH ? is identity observable? ============================== +platform valhalla ? value-record vocabulary; null-restricted non-atomic arrays; @NullRestricted fields +LaneId.class.isValue() true +Ordinal.class.isValue() true +MaskId.class.isValue() true +RowRange.class.isValue() true +Row.class.isValue() true +equal state => equals() true +different state => !equals() true +equal state => equal hashCode() true +equal state => equal toString() true +a == b (reference equality) true +identityHashCode(a) == identityHashCode(b) true +a local of this type accepts null true +array kind LaneId[] +array flatness NOT-FLAT +array slot accepts null false +synchronized(x) legality COMPILE ERROR under Valhalla (required: a type with identity) + +== FLATTENING CLIFF ? which payload shapes does the VM flatten? ============== +platform valhalla +note payload = declared field bytes, ignoring any header +LaneId 1 int payload= 4 B array=NOT-FLAT +Ordinal 1 int payload= 4 B array=NOT-FLAT +MaskId 1 long payload= 8 B array=NOT-FLAT +RowRange 2 long payload=16 B array=NOT-FLAT +Row 1 long + 2 int payload=16 B array=NOT-FLAT + +== (b)/(c) REPRESENTATION ? allocation, arrays, fields, arguments ============ +platform valhalla +allocation instrument baseline 0 B +N (operations per measurement) 1000000 +construct N LaneId, store into array 22.89 MiB + ... per LaneId 24.00 B +construct N LaneId, never escaping 8.22 MiB + ... per LaneId 8.62 B +LaneId[1024] flatness NOT-FLAT +allocate+fill LaneId[N] (array + elements) 26.70 MiB + ... per element 28.00 B +bare LaneId[N] with no elements stored 3.81 MiB + ... per slot 4.00 B +Descriptor kind value class with two @NullRestricted value fields +Descriptor fields null-restricted true +construct N Descriptor (2 wrappers each) 38.37 MiB + ... per Descriptor 40.23 B +pass 2 wrappers through 3 call levels 11.49 MiB + ... per call 12.05 B +read 65,536 LaneId from array median= 53465.0 ns [min 49219.0 .. max 88865.0] n=51 +native runtime lance-graph native runtime: abi 0.1, simd ndarray::simd avx512, profile release, library /home/user/lance-graph-java/target/release/liblgj_abi.so + +== FFM ADDRESSING ? is the wrapper free where it touches native memory? ====== +platform valhalla +sum (identical across all three) 6929623 +bare long index median= 59142.0 ns [min 59071.0 .. max 114270.0] n=51 +RowRange bounds (wrapper hoisted) median= 56997.0 ns [min 56660.0 .. max 81796.0] n=51 +per-element wrapper: bytes allocated 1.50 MiB + ... per element 24.00 B +Ordinal built per element median= 51224.0 ns [min 49782.0 .. max 91443.0] n=51 + +== THE THESIS ? 65,536 entities, three representations ======================= +platform valhalla +rows 65536 +question count(class==7 AND value>100) and sum(value) +answer (identical across all paths) 2173 rows, sum 499246 +selectivity 3.32% + +== heap cost =============================================================== +(1) native ? Java bytes allocated (warm) 816 B per query, for the fluent chain itself +(1) native ? Java objects per row 0 +(1) native ? native lane bytes 1.00 MiB (u64 id + u32 class + i32 value) +(1) native ? mask bytes 8.0 KiB (1 bit per row, packed) +(2)/(3) hydrate 65536 Row ? allocated 2.50 MiB + ... per row 40.00 B + array flatness NOT-FLAT + ratio vs native lane bytes 2.50x + retained heap (APPROX, gc-delta) 2.75 MiB + +== time to answer the question ============================================= +(1) native one crossing, fused plan median= 14946.0 ns [min 14841.0 .. max 49967.0] n=51 +(2/3) hydrate 65536 Row objects median= 1708590.0 ns [min 610808.0 .. max 8047970.0] n=51 +(2/3) scan the materialised objects median= 148382.0 ns [min 119556.0 .. max 229605.0] n=51 +(2/3) hydrate THEN scan (honest total) median= 843903.0 ns [min 781103.0 .. max 1306914.0] n=51 + +lab complete. diff --git a/valhalla-lab/results/valhalla-noea.txt b/valhalla-lab/results/valhalla-noea.txt new file mode 100644 index 0000000..529ee73 --- /dev/null +++ b/valhalla-lab/results/valhalla-noea.txt @@ -0,0 +1,91 @@ +Picked up JAVA_TOOL_OPTIONS: +lance-graph-java :: valhalla lab +platform valhalla +java.vm.version 27-jep401ea3+1-1 +java.vendor.version - +jvm args [--enable-native-access=ALL-UNNAMED, -Dlgj.library=/home/user/lance-graph-java/target/release/liblgj_abi.so, --enable-preview, --add-exports=java.base/jdk.internal.vm.annotation=ALL-UNNAMED, --add-exports=java.base/jdk.internal.value=ALL-UNNAMED, -XX:-DoEscapeAnalysis] + +== (a) SEMANTIC TRUTH ? is identity observable? ============================== +platform valhalla ? value-record vocabulary; null-restricted non-atomic arrays; @NullRestricted fields +LaneId.class.isValue() true +Ordinal.class.isValue() true +MaskId.class.isValue() true +RowRange.class.isValue() true +Row.class.isValue() true +equal state => equals() true +different state => !equals() true +equal state => equal hashCode() true +equal state => equal toString() true +a == b (reference equality) true +identityHashCode(a) == identityHashCode(b) true +a local of this type accepts null true +array kind LaneId[] +array flatness FLAT +array slot accepts null false +synchronized(x) legality COMPILE ERROR under Valhalla (required: a type with identity) + +== FLATTENING CLIFF ? which payload shapes does the VM flatten? ============== +platform valhalla +note payload = declared field bytes, ignoring any header +LaneId 1 int payload= 4 B array=FLAT +Ordinal 1 int payload= 4 B array=FLAT +MaskId 1 long payload= 8 B array=FLAT +RowRange 2 long payload=16 B array=NOT-FLAT +Row 1 long + 2 int payload=16 B array=NOT-FLAT + +== (b)/(c) REPRESENTATION ? allocation, arrays, fields, arguments ============ +platform valhalla +allocation instrument baseline 0 B +N (operations per measurement) 1000000 +construct N LaneId, store into array 3.86 MiB + ... per LaneId 4.05 B +construct N LaneId, never escaping 24.41 MiB + ... per LaneId 25.60 B +LaneId[1024] flatness FLAT +allocate+fill LaneId[N] (array + elements) 28.35 MiB + ... per element 29.73 B +bare LaneId[N] with no elements stored 3.81 MiB + ... per slot 4.00 B +Descriptor kind value class with two @NullRestricted value fields +Descriptor fields null-restricted true +construct N Descriptor (2 wrappers each) 38.98 MiB + ... per Descriptor 40.87 B +pass 2 wrappers through 3 call levels 29.57 MiB + ... per call 31.00 B +read 65,536 LaneId from array median= 5369.0 ns [min 5321.0 .. max 30666.0] n=51 +native runtime lance-graph native runtime: abi 0.1, simd ndarray::simd avx512, profile release, library /home/user/lance-graph-java/target/release/liblgj_abi.so + +== FFM ADDRESSING ? is the wrapper free where it touches native memory? ====== +platform valhalla +sum (identical across all three) 6929623 +bare long index median= 61840.0 ns [min 59069.0 .. max 128839.0] n=51 +RowRange bounds (wrapper hoisted) median= 60149.0 ns [min 56823.0 .. max 74673.0] n=51 +per-element wrapper: bytes allocated 3.00 MiB + ... per element 48.00 B +Ordinal built per element median= 94346.0 ns [min 91210.0 .. max 133533.0] n=51 + +== THE THESIS ? 65,536 entities, three representations ======================= +platform valhalla +rows 65536 +question count(class==7 AND value>100) and sum(value) +answer (identical across all paths) 2173 rows, sum 499246 +selectivity 3.32% + +== heap cost =============================================================== +(1) native ? Java bytes allocated (warm) 816 B per query, for the fluent chain itself +(1) native ? Java objects per row 0 +(1) native ? native lane bytes 1.00 MiB (u64 id + u32 class + i32 value) +(1) native ? mask bytes 8.0 KiB (1 bit per row, packed) +(2)/(3) hydrate 65536 Row ? allocated 2.50 MiB + ... per row 40.00 B + array flatness NOT-FLAT + ratio vs native lane bytes 2.50x + retained heap (APPROX, gc-delta) 2.75 MiB + +== time to answer the question ============================================= +(1) native one crossing, fused plan median= 15885.0 ns [min 15694.0 .. max 32647.0] n=51 +(2/3) hydrate 65536 Row objects median= 1187577.0 ns [min 1044870.0 .. max 2002465.0] n=51 +(2/3) scan the materialised objects median= 74699.0 ns [min 63609.0 .. max 129188.0] n=51 +(2/3) hydrate THEN scan (honest total) median= 3267205.0 ns [min 1374933.0 .. max 9370919.0] n=51 + +lab complete. diff --git a/valhalla-lab/results/valhalla-nofieldflat.txt b/valhalla-lab/results/valhalla-nofieldflat.txt new file mode 100644 index 0000000..7af9d7b --- /dev/null +++ b/valhalla-lab/results/valhalla-nofieldflat.txt @@ -0,0 +1,91 @@ +Picked up JAVA_TOOL_OPTIONS: +lance-graph-java :: valhalla lab +platform valhalla +java.vm.version 27-jep401ea3+1-1 +java.vendor.version - +jvm args [--enable-native-access=ALL-UNNAMED, -Dlgj.library=/home/user/lance-graph-java/target/release/liblgj_abi.so, --enable-preview, --add-exports=java.base/jdk.internal.vm.annotation=ALL-UNNAMED, --add-exports=java.base/jdk.internal.value=ALL-UNNAMED, -XX:+UnlockDiagnosticVMOptions, -XX:-UseFieldFlattening] + +== (a) SEMANTIC TRUTH ? is identity observable? ============================== +platform valhalla ? value-record vocabulary; null-restricted non-atomic arrays; @NullRestricted fields +LaneId.class.isValue() true +Ordinal.class.isValue() true +MaskId.class.isValue() true +RowRange.class.isValue() true +Row.class.isValue() true +equal state => equals() true +different state => !equals() true +equal state => equal hashCode() true +equal state => equal toString() true +a == b (reference equality) true +identityHashCode(a) == identityHashCode(b) true +a local of this type accepts null true +array kind LaneId[] +array flatness FLAT +array slot accepts null false +synchronized(x) legality COMPILE ERROR under Valhalla (required: a type with identity) + +== FLATTENING CLIFF ? which payload shapes does the VM flatten? ============== +platform valhalla +note payload = declared field bytes, ignoring any header +LaneId 1 int payload= 4 B array=FLAT +Ordinal 1 int payload= 4 B array=FLAT +MaskId 1 long payload= 8 B array=FLAT +RowRange 2 long payload=16 B array=NOT-FLAT +Row 1 long + 2 int payload=16 B array=NOT-FLAT + +== (b)/(c) REPRESENTATION ? allocation, arrays, fields, arguments ============ +platform valhalla +allocation instrument baseline 0 B +N (operations per measurement) 1000000 +construct N LaneId, store into array 2.75 MiB + ... per LaneId 2.89 B +construct N LaneId, never escaping 8.22 MiB + ... per LaneId 8.61 B +LaneId[1024] flatness FLAT +allocate+fill LaneId[N] (array + elements) 6.58 MiB + ... per element 6.90 B +bare LaneId[N] with no elements stored 3.81 MiB + ... per slot 4.00 B +Descriptor kind value class with two @NullRestricted value fields +Descriptor fields null-restricted true +construct N Descriptor (2 wrappers each) 76.29 MiB + ... per Descriptor 80.00 B +pass 2 wrappers through 3 call levels 11.14 MiB + ... per call 11.68 B +read 65,536 LaneId from array median= 4959.0 ns [min 4870.0 .. max 5886.0] n=51 +native runtime lance-graph native runtime: abi 0.1, simd ndarray::simd avx512, profile release, library /home/user/lance-graph-java/target/release/liblgj_abi.so + +== FFM ADDRESSING ? is the wrapper free where it touches native memory? ====== +platform valhalla +sum (identical across all three) 6929623 +bare long index median= 59187.0 ns [min 59075.0 .. max 123125.0] n=51 +RowRange bounds (wrapper hoisted) median= 56980.0 ns [min 56652.0 .. max 73287.0] n=51 +per-element wrapper: bytes allocated 1.50 MiB + ... per element 24.00 B +Ordinal built per element median= 49851.0 ns [min 49727.0 .. max 66813.0] n=51 + +== THE THESIS ? 65,536 entities, three representations ======================= +platform valhalla +rows 65536 +question count(class==7 AND value>100) and sum(value) +answer (identical across all paths) 2173 rows, sum 499246 +selectivity 3.32% + +== heap cost =============================================================== +(1) native ? Java bytes allocated (warm) 816 B per query, for the fluent chain itself +(1) native ? Java objects per row 0 +(1) native ? native lane bytes 1.00 MiB (u64 id + u32 class + i32 value) +(1) native ? mask bytes 8.0 KiB (1 bit per row, packed) +(2)/(3) hydrate 65536 Row ? allocated 2.50 MiB + ... per row 40.01 B + array flatness NOT-FLAT + ratio vs native lane bytes 2.50x + retained heap (APPROX, gc-delta) 2.75 MiB + +== time to answer the question ============================================= +(1) native one crossing, fused plan median= 15964.0 ns [min 15779.0 .. max 40503.0] n=51 +(2/3) hydrate 65536 Row objects median= 789127.0 ns [min 628024.0 .. max 4599451.0] n=51 +(2/3) scan the materialised objects median= 78249.0 ns [min 75327.0 .. max 111755.0] n=51 +(2/3) hydrate THEN scan (honest total) median= 1122018.0 ns [min 812442.0 .. max 1695253.0] n=51 + +lab complete. diff --git a/valhalla-lab/results/valhalla-noflat.txt b/valhalla-lab/results/valhalla-noflat.txt new file mode 100644 index 0000000..5ab9312 --- /dev/null +++ b/valhalla-lab/results/valhalla-noflat.txt @@ -0,0 +1,91 @@ +Picked up JAVA_TOOL_OPTIONS: +lance-graph-java :: valhalla lab +platform valhalla +java.vm.version 27-jep401ea3+1-1 +java.vendor.version - +jvm args [--enable-native-access=ALL-UNNAMED, -Dlgj.library=/home/user/lance-graph-java/target/release/liblgj_abi.so, --enable-preview, --add-exports=java.base/jdk.internal.vm.annotation=ALL-UNNAMED, --add-exports=java.base/jdk.internal.value=ALL-UNNAMED, -XX:+UnlockDiagnosticVMOptions, -XX:-UseArrayFlattening, -XX:-UseFieldFlattening] + +== (a) SEMANTIC TRUTH ? is identity observable? ============================== +platform valhalla ? value-record vocabulary; null-restricted non-atomic arrays; @NullRestricted fields +LaneId.class.isValue() true +Ordinal.class.isValue() true +MaskId.class.isValue() true +RowRange.class.isValue() true +Row.class.isValue() true +equal state => equals() true +different state => !equals() true +equal state => equal hashCode() true +equal state => equal toString() true +a == b (reference equality) true +identityHashCode(a) == identityHashCode(b) true +a local of this type accepts null true +array kind LaneId[] +array flatness NOT-FLAT +array slot accepts null false +synchronized(x) legality COMPILE ERROR under Valhalla (required: a type with identity) + +== FLATTENING CLIFF ? which payload shapes does the VM flatten? ============== +platform valhalla +note payload = declared field bytes, ignoring any header +LaneId 1 int payload= 4 B array=NOT-FLAT +Ordinal 1 int payload= 4 B array=NOT-FLAT +MaskId 1 long payload= 8 B array=NOT-FLAT +RowRange 2 long payload=16 B array=NOT-FLAT +Row 1 long + 2 int payload=16 B array=NOT-FLAT + +== (b)/(c) REPRESENTATION ? allocation, arrays, fields, arguments ============ +platform valhalla +allocation instrument baseline 0 B +N (operations per measurement) 1000000 +construct N LaneId, store into array 15.26 MiB + ... per LaneId 16.00 B +construct N LaneId, never escaping 4.71 MiB + ... per LaneId 4.94 B +LaneId[1024] flatness NOT-FLAT +allocate+fill LaneId[N] (array + elements) 19.07 MiB + ... per element 20.00 B +bare LaneId[N] with no elements stored 3.81 MiB + ... per slot 4.00 B +Descriptor kind value class with two @NullRestricted value fields +Descriptor fields null-restricted true +construct N Descriptor (2 wrappers each) 53.41 MiB + ... per Descriptor 56.00 B +pass 2 wrappers through 3 call levels 8.65 MiB + ... per call 9.07 B +read 65,536 LaneId from array median= 43102.0 ns [min 41523.0 .. max 77929.0] n=51 +native runtime lance-graph native runtime: abi 0.1, simd ndarray::simd avx512, profile release, library /home/user/lance-graph-java/target/release/liblgj_abi.so + +== FFM ADDRESSING ? is the wrapper free where it touches native memory? ====== +platform valhalla +sum (identical across all three) 6929623 +bare long index median= 59218.0 ns [min 59090.0 .. max 150920.0] n=51 +RowRange bounds (wrapper hoisted) median= 56978.0 ns [min 56635.0 .. max 83751.0] n=51 +per-element wrapper: bytes allocated 1.00 MiB + ... per element 16.00 B +Ordinal built per element median= 49935.0 ns [min 49722.0 .. max 83957.0] n=51 + +== THE THESIS ? 65,536 entities, three representations ======================= +platform valhalla +rows 65536 +question count(class==7 AND value>100) and sum(value) +answer (identical across all paths) 2173 rows, sum 499246 +selectivity 3.32% + +== heap cost =============================================================== +(1) native ? Java bytes allocated (warm) 816 B per query, for the fluent chain itself +(1) native ? Java objects per row 0 +(1) native ? native lane bytes 1.00 MiB (u64 id + u32 class + i32 value) +(1) native ? mask bytes 8.0 KiB (1 bit per row, packed) +(2)/(3) hydrate 65536 Row ? allocated 2.00 MiB + ... per row 32.00 B + array flatness NOT-FLAT + ratio vs native lane bytes 2.00x + retained heap (APPROX, gc-delta) 2.25 MiB + +== time to answer the question ============================================= +(1) native one crossing, fused plan median= 15435.0 ns [min 15335.0 .. max 30493.0] n=51 +(2/3) hydrate 65536 Row objects median= 857050.0 ns [min 533019.0 .. max 2422972.0] n=51 +(2/3) scan the materialised objects median= 124600.0 ns [min 112855.0 .. max 180439.0] n=51 +(2/3) hydrate THEN scan (honest total) median= 1631916.0 ns [min 1499609.0 .. max 2069662.0] n=51 + +lab complete. diff --git a/valhalla-lab/results/vocab-diff.txt b/valhalla-lab/results/vocab-diff.txt new file mode 100644 index 0000000..e69de29 diff --git a/valhalla-lab/run.sh b/valhalla-lab/run.sh new file mode 100755 index 0000000..9dfb31c --- /dev/null +++ b/valhalla-lab/run.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# The A/B. One experiment source, two object models, two JDKs, one diff. +# +# Every measurement this lab reports is produced by this script. Nothing is quoted from memory. +set -uo pipefail + +LAB="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$LAB/.." && pwd)" + +STABLE_JDK="${STABLE_JDK:-/opt/jdks/jdk-26.0.2}" +VALHALLA_JDK="${VALHALLA_JDK:-/opt/jdks/jdk-27}" +LIB_DIR="${LIB_DIR:-$ROOT/target/release}" + +OUT="$LAB/results" +mkdir -p "$OUT" + +VAL_EXPORTS=( + --add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAMED + --add-exports java.base/jdk.internal.value=ALL-UNNAMED +) + +banner() { printf '\n\033[1m== %s\033[0m\n' "$*"; } +fail() { printf '\033[31mFAIL:\033[0m %s\n' "$*" >&2; exit 1; } + +# ── 0. the A/B is only honest if the two vocabularies differ by exactly the modifier ────────── +banner "0. verifying the two vocabularies differ ONLY by the 'value' modifier" +if diff <(sed 's/^value record/record/' "$LAB/src/valhalla/com/adaworldapi/lancegraph/lab/Vocab.java") \ + "$LAB/src/stable/com/adaworldapi/lancegraph/lab/Vocab.java" > "$OUT/vocab-diff.txt"; then + echo "OK — src/valhalla/Vocab.java == src/stable/Vocab.java modulo 'value'" +else + cat "$OUT/vocab-diff.txt" + fail "the two vocabularies differ by more than the 'value' modifier; the A/B would not be an A/B" +fi + +if [ ! -f "$LIB_DIR/liblgj_abi.so" ]; then + fail "no native library at $LIB_DIR/liblgj_abi.so +build it with: + cd $ROOT/native/lgj-abi && CARGO_TARGET_DIR=$ROOT/target cargo build --release" +fi + +# ── 1. compile ──────────────────────────────────────────────────────────────────────────────── +compile () { # $1=tag $2=jdk $3=vocab-root shift 3 -> extra javac args + local tag=$1 jdk=$2 vocab=$3; shift 3 + local api="$OUT/$tag-api" lab="$OUT/$tag-lab" + rm -rf "$api" "$lab"; mkdir -p "$api" "$lab" + + # the production API, compiled by THIS jdk, with no preview features anywhere + "$jdk/bin/javac" -d "$api" $(find "$ROOT/java/src/main/java" -name '*.java') \ + 2> "$OUT/$tag-api-javac.log" || { cat "$OUT/$tag-api-javac.log"; fail "$tag: API compile"; } + + "$jdk/bin/javac" "$@" -cp "$api" -d "$lab" \ + $(find "$LAB/src/shared" "$vocab" -name '*.java') \ + 2> "$OUT/$tag-lab-javac.log" || { cat "$OUT/$tag-lab-javac.log"; fail "$tag: lab compile"; } + echo "compiled $tag" +} + +banner "1. compiling" +compile stable "$STABLE_JDK" "$LAB/src/stable" +compile valhalla "$VALHALLA_JDK" "$LAB/src/valhalla" \ + --enable-preview -source 27 -target 27 "${VAL_EXPORTS[@]}" + +# ── 2. run ──────────────────────────────────────────────────────────────────────────────────── +run () { # $1=tag $2=jdk $3=label shift 3 -> extra jvm args + local tag=$1 jdk=$2 label=$3; shift 3 + local f="$OUT/$tag-$label.txt" + echo "--> $tag/$label" + ( JAVA_TOOL_OPTIONS= "$jdk/bin/java" \ + --enable-native-access=ALL-UNNAMED \ + -Dlgj.library="$LIB_DIR/liblgj_abi.so" \ + "$@" \ + -cp "$OUT/$tag-api:$OUT/$tag-lab" com.adaworldapi.lancegraph.lab.RunAll ) \ + > "$f" 2>&1 + local rc=$? + echo " exit=$rc -> ${f#$LAB/}" + return 0 +} + +banner "2. running (default VM settings)" +run stable "$STABLE_JDK" default +run valhalla "$VALHALLA_JDK" default --enable-preview "${VAL_EXPORTS[@]}" + +banner "3. running with escape analysis OFF (what the object model costs unaided)" +run stable "$STABLE_JDK" noea -XX:-DoEscapeAnalysis +run valhalla "$VALHALLA_JDK" noea --enable-preview "${VAL_EXPORTS[@]}" -XX:-DoEscapeAnalysis + +banner "4. running with Valhalla flattening knobs OFF (does flattening cause the difference?)" +run valhalla "$VALHALLA_JDK" noarrayflat --enable-preview "${VAL_EXPORTS[@]}" \ + -XX:+UnlockDiagnosticVMOptions -XX:-UseArrayFlattening +run valhalla "$VALHALLA_JDK" nofieldflat --enable-preview "${VAL_EXPORTS[@]}" \ + -XX:+UnlockDiagnosticVMOptions -XX:-UseFieldFlattening +run valhalla "$VALHALLA_JDK" noflat --enable-preview "${VAL_EXPORTS[@]}" \ + -XX:+UnlockDiagnosticVMOptions -XX:-UseArrayFlattening -XX:-UseFieldFlattening + +banner "5. A/B diff" +diff "$OUT/stable-default.txt" "$OUT/valhalla-default.txt" > "$OUT/AB-default.diff" +echo "wrote ${OUT#$LAB/}/AB-default.diff ($(wc -l < "$OUT/AB-default.diff") lines)" + +banner "done — results in $OUT" +ls -1 "$OUT"/*.txt diff --git a/valhalla-lab/src/shared/com/adaworldapi/lancegraph/NativeAccess.java b/valhalla-lab/src/shared/com/adaworldapi/lancegraph/NativeAccess.java new file mode 100644 index 0000000..3e9fa04 --- /dev/null +++ b/valhalla-lab/src/shared/com/adaworldapi/lancegraph/NativeAccess.java @@ -0,0 +1,49 @@ +package com.adaworldapi.lancegraph; + +import com.adaworldapi.lancegraph.internal.ffm.Engine; + +/** + * A read-only bridge into the library's package-private handle, for measurement code only. + * + *

Why this exists rather than a public accessor. The production API + * deliberately never surfaces a handle or a {@code MemorySegment} — that is the whole accessibility + * argument. But the lab has to reach the raw lane to build the very thing the thesis says you + * should not build (65,536 Java objects), and it must build them from the same bytes the + * native kernel reads, or the comparison is between two different datasets and proves nothing. + * + *

So the bridge lives here, in the library's package but in the lab's source tree, + * compiled onto the classpath as a split package. Nothing under {@code java/} changes, no public + * surface widens, and the coupling is visible in one file instead of leaking into the API. + * + *

It reads. It never writes, never closes, never mutates. + */ +public final class NativeAccess { + + private NativeAccess() {} + + /** Lane 0 — {@code u64} entity ids. */ + public static final int LANE_ID = 0; + /** Lane 1 — {@code u32} class tags. */ + public static final int LANE_CLASS = 1; + /** Lane 2 — {@code i32} signed values. */ + public static final int LANE_VALUE = 2; + + /** The generation-checked registry handle behind a pattern. Opaque; for describe calls only. */ + public static long handleOf(NativePattern pattern) { + return pattern.handle(); + } + + /** + * A bounded, read-only window onto one native lane. No membrane crossing happens when this is + * read — that is the point of the design, and the reason a Java-side Vector API kernel can + * compete at all. + */ + public static Engine.LaneWindow lane(NativePattern pattern, int laneId) { + return Engine.describeLane(pattern.handle(), laneId); + } + + /** The packed {@code u64} words behind a selection. */ + public static Engine.LaneWindow maskWords(Mask mask) { + return Engine.describeMask(mask.id().token()); + } +} diff --git a/valhalla-lab/src/shared/com/adaworldapi/lancegraph/lab/FfmAddressingExperiment.java b/valhalla-lab/src/shared/com/adaworldapi/lancegraph/lab/FfmAddressingExperiment.java new file mode 100644 index 0000000..ab5a9c3 --- /dev/null +++ b/valhalla-lab/src/shared/com/adaworldapi/lancegraph/lab/FfmAddressingExperiment.java @@ -0,0 +1,86 @@ +package com.adaworldapi.lancegraph.lab; + +import com.adaworldapi.lancegraph.NativeAccess; +import com.adaworldapi.lancegraph.NativePattern; +import com.adaworldapi.lancegraph.internal.ffm.Engine; + +/** + * Does a semantic wrapper cost anything when it is used to address native memory? + * + *

This is the question that decides whether the API's vocabulary can go all the way down. The + * production API hides {@code MemorySegment} entirely, but the shape it hides is + * "offset arithmetic on a native address". If wrapping a row index in a meaningful type made that + * arithmetic slower, the vocabulary would have to stop at the API surface and become bare + * {@code long}s underneath — an abstraction that is only free where nobody is looking. + * + *

Three variants over the same lane, same 65,536 elements, same result asserted equal: + * + *

    + *
  • a bare {@code long} index — the floor; + *
  • a {@code RowRange} driving the loop bounds — a wrapper read once per loop; + *
  • a per-element wrapper ({@code Ordinal}) constructed inside the loop — a wrapper read once + * per element, which is the shape that would actually be expensive. + *
+ * + *

The third is the one that matters. A wrapper hoisted out of a loop is free on any JDK; a + * wrapper allocated 65,536 times is exactly the case a value class is supposed to make free, and + * exactly the case escape analysis sometimes already handles. Measuring both is what separates + * "Valhalla helped" from "the JIT was already doing it". + */ +final class FfmAddressingExperiment { + + private FfmAddressingExperiment() {} + + private static final int ROWS = 65_536; + + static void run() { + Lab.section("FFM ADDRESSING — is the wrapper free where it touches native memory?"); + Lab.kv("platform", Platform.NAME); + + try (NativePattern data = NativePattern.open(ROWS)) { + Engine.LaneWindow values = NativeAccess.lane(data, NativeAccess.LANE_VALUE); + + long bare = 0; + for (long i = 0; i < ROWS; i++) bare += values.getI32(i); + + RowRange range = RowRange.of(ROWS); + long viaRange = 0; + for (long i = range.start(); i < range.endExclusive(); i++) viaRange += values.getI32(i); + + long viaWrapper = 0; + for (int i = 0; i < ROWS; i++) viaWrapper += values.getI32(Ordinal.of(i).value()); + + if (bare != viaRange || bare != viaWrapper) { + throw new AssertionError("addressing variants disagree: " + bare + " / " + + viaRange + " / " + viaWrapper); + } + Lab.kv("sum (identical across all three)", bare); + + System.out.println(Lab.time("bare long index", 2_000, 51, () -> { + long acc = 0; + for (long i = 0; i < ROWS; i++) acc += values.getI32(i); + Lab.SINK = acc; + })); + + System.out.println(Lab.time("RowRange bounds (wrapper hoisted)", 2_000, 51, () -> { + long acc = 0; + for (long i = range.start(); i < range.endExclusive(); i++) acc += values.getI32(i); + Lab.SINK = acc; + })); + + long wrapperAlloc = Lab.allocatedBytes(() -> { + long acc = 0; + for (int i = 0; i < ROWS; i++) acc += values.getI32(Ordinal.of(i).value()); + Lab.SINK = acc; + }); + Lab.kv("per-element wrapper: bytes allocated", Lab.bytes(wrapperAlloc)); + Lab.kv(" ... per element", String.format("%.2f B", wrapperAlloc / (double) ROWS)); + + System.out.println(Lab.time("Ordinal built per element", 2_000, 51, () -> { + long acc = 0; + for (int i = 0; i < ROWS; i++) acc += values.getI32(Ordinal.of(i).value()); + Lab.SINK = acc; + })); + } + } +} diff --git a/valhalla-lab/src/shared/com/adaworldapi/lancegraph/lab/FlatteningCliffExperiment.java b/valhalla-lab/src/shared/com/adaworldapi/lancegraph/lab/FlatteningCliffExperiment.java new file mode 100644 index 0000000..4718129 --- /dev/null +++ b/valhalla-lab/src/shared/com/adaworldapi/lancegraph/lab/FlatteningCliffExperiment.java @@ -0,0 +1,46 @@ +package com.adaworldapi.lancegraph.lab; + +/** + * Where does flattening stop? + * + *

This experiment exists because the first run of {@link ThesisExperiment} produced a result + * that looked like a bug: the Valhalla {@code Row} array reported {@code NOT-FLAT} and cost + * more per row than the stable record. Rather than explain it away, the question was + * turned into a measurement — sweep payload sizes and find the cliff. + * + *

The answer on this build is a hard cutoff, and it lands exactly between the two categories + * the thesis distinguishes. A wrapper around one {@code int} or one {@code long} is flattened; an + * entity with an id plus two fields is not, in any array flavour. The thesis' central claim and + * the VM's current flattening budget happen to draw the same line — which is a much stronger + * result than "objects are slow", because it says why the line is where it is. + * + *

Each row of output is one payload shape. The types are declared in the vocabulary file so the + * stable and Valhalla trees declare identical shapes. + */ +final class FlatteningCliffExperiment { + + private FlatteningCliffExperiment() {} + + static void run() { + Lab.section("FLATTENING CLIFF — which payload shapes does the VM flatten?"); + Lab.kv("platform", Platform.NAME); + Lab.kv("note", "payload = declared field bytes, ignoring any header"); + + probe("LaneId", "1 int", 4, LaneId.class, LaneId.of(0)); + probe("Ordinal", "1 int", 4, Ordinal.class, Ordinal.of(0)); + probe("MaskId", "1 long", 8, MaskId.class, new MaskId(0)); + probe("RowRange", "2 long", 16, RowRange.class, RowRange.of(0)); + probe("Row", "1 long + 2 int", 16, Row.class, new Row(0, 0, 0)); + } + + private static void probe(String name, String shape, int payloadBytes, Class type, + Object init) { + String flat; + try { + flat = Platform.arrayFlatness(Platform.newArrayOf(type, 64, init)); + } catch (Throwable t) { + flat = "REFUSED: " + t.getClass().getSimpleName() + " " + t.getMessage(); + } + System.out.printf("%-10s %-16s payload=%2d B array=%s%n", name, shape, payloadBytes, flat); + } +} diff --git a/valhalla-lab/src/shared/com/adaworldapi/lancegraph/lab/FootprintExperiment.java b/valhalla-lab/src/shared/com/adaworldapi/lancegraph/lab/FootprintExperiment.java new file mode 100644 index 0000000..c87606b --- /dev/null +++ b/valhalla-lab/src/shared/com/adaworldapi/lancegraph/lab/FootprintExperiment.java @@ -0,0 +1,111 @@ +package com.adaworldapi.lancegraph.lab; + +/** + * Truths (b) and (c) — representation. What does the same source actually become? + * + *

Four questions, each measured by the instrument that can least easily be fooled: + * + *

    + *
  1. Does constructing one cost a heap object? — allocated bytes, not timing. + *
  2. Is an array of them flattened? — the VM's own {@code isFlatArray} where it + * exists, plus the array's measured footprint either way. + *
  3. Is a field of one flattened into its container? — allocated bytes for the + * container, which changes by exactly the header+pointer cost when it is not. + *
  4. Does passing one to a method cost anything? — allocated bytes across a + * call chain deep enough that escape analysis has to give up. + *
+ * + *

Every count below is per {@link #N} operations, so a per-instance number can be divided out + * and compared against the theoretical object size (16-byte header + 4-byte int, padded to 16 for + * a one-int record on a 64-bit VM with compressed oops). + */ +final class FootprintExperiment { + + private FootprintExperiment() {} + + /** Large enough that per-operation bytes resolve cleanly; small enough to stay in a young gen. */ + static final int N = 1_000_000; + + static void run() { + Lab.section("(b)/(c) REPRESENTATION — allocation, arrays, fields, arguments"); + Lab.kv("platform", Platform.NAME); + Lab.kv("allocation instrument baseline", Lab.bytes(Lab.allocationInstrumentBaseline())); + Lab.kv("N (operations per measurement)", N); + + // ── 1. constructing a descriptor ───────────────────────────────────────────────────── + // The array store is what forces the object to escape. Without it, escape analysis + // deletes the allocation on BOTH platforms and the experiment measures nothing — which is + // itself worth stating, because it is exactly why a fast microbenchmark is not evidence. + Object[] sink = Platform.newLaneIdArray(N); + long ctorBytes = Lab.allocatedBytes(() -> { + for (int i = 0; i < N; i++) sink[i] = LaneId.of(i & 0xFFFF); + }); + Lab.OBJ_SINK = sink; + Lab.kv("construct N LaneId, store into array", Lab.bytes(ctorBytes)); + Lab.kv(" ... per LaneId", String.format("%.2f B", ctorBytes / (double) N)); + + // Non-escaping variant, for contrast. If this is ~0 on the stable platform too, that is + // escape analysis doing the value class's job for one particular loop — and the reason + // the run script also reports a -XX:-DoEscapeAnalysis pass. + long nonEscapingBytes = Lab.allocatedBytes(() -> { + long acc = 0; + for (int i = 0; i < N; i++) acc += LaneId.of(i).index(); + Lab.SINK = acc; + }); + Lab.kv("construct N LaneId, never escaping", Lab.bytes(nonEscapingBytes)); + Lab.kv(" ... per LaneId", String.format("%.2f B", nonEscapingBytes / (double) N)); + + // ── 2. array representation ────────────────────────────────────────────────────────── + Object[] probe = Platform.newLaneIdArray(1024); + Lab.kv("LaneId[1024] flatness", Platform.arrayFlatness(probe)); + long arrayBytes = Lab.allocatedBytes(() -> { + Object[] a = Platform.newLaneIdArray(N); + for (int i = 0; i < N; i++) a[i] = LaneId.of(i); + Lab.OBJ_SINK = a; + }); + Lab.kv("allocate+fill LaneId[N] (array + elements)", Lab.bytes(arrayBytes)); + Lab.kv(" ... per element", String.format("%.2f B", arrayBytes / (double) N)); + + long emptyArrayBytes = Lab.allocatedBytes(() -> Lab.OBJ_SINK = Platform.newLaneIdArray(N)); + Lab.kv("bare LaneId[N] with no elements stored", Lab.bytes(emptyArrayBytes)); + Lab.kv(" ... per slot", String.format("%.2f B", emptyArrayBytes / (double) N)); + + // ── 3. field flattening ────────────────────────────────────────────────────────────── + Lab.kv("Descriptor kind", Containers.kind()); + Lab.kv("Descriptor fields null-restricted", Containers.fieldsAreNullRestricted()); + Object[] descSink = new Object[N]; + long descBytes = Lab.allocatedBytes(() -> { + for (int i = 0; i < N; i++) descSink[i] = Containers.make(i & 7, i & 3); + }); + Lab.OBJ_SINK = descSink; + Lab.kv("construct N Descriptor (2 wrappers each)", Lab.bytes(descBytes)); + Lab.kv(" ... per Descriptor", String.format("%.2f B", descBytes / (double) N)); + + // ── 4. method-passing ──────────────────────────────────────────────────────────────── + long passBytes = Lab.allocatedBytes(() -> { + long acc = 0; + for (int i = 0; i < N; i++) acc += consume(LaneId.of(i), Ordinal.of(i & 15)); + Lab.SINK = acc; + }); + Lab.kv("pass 2 wrappers through 3 call levels", Lab.bytes(passBytes)); + Lab.kv(" ... per call", String.format("%.2f B", passBytes / (double) N)); + + // ── timing, secondary ──────────────────────────────────────────────────────────────── + // Reported after the byte counts, and second in importance. A timing that disagrees with + // the byte counts is a signal to distrust the timing, not the bytes. + Object[] readArr = Platform.newLaneIdArray(65_536); + for (int i = 0; i < 65_536; i++) readArr[i] = LaneId.of(i); + Lab.OBJ_SINK = readArr; + System.out.println(Lab.time("read 65,536 LaneId from array", 2_000, 51, () -> { + long acc = 0; + for (int i = 0; i < 65_536; i++) acc += ((LaneId) readArr[i]).index(); + Lab.SINK = acc; + })); + } + + // Three levels: shallow enough to inline, deep enough that a naive reading of "the JIT will + // fix it" is not automatically true. + private static long consume(LaneId l, Ordinal o) { return level2(l, o); } + private static long level2(LaneId l, Ordinal o) { return level3(l, o); } + private static long level3(LaneId l, Ordinal o) { return l.index() + o.value(); } +} diff --git a/valhalla-lab/src/shared/com/adaworldapi/lancegraph/lab/IdentityExperiment.java b/valhalla-lab/src/shared/com/adaworldapi/lancegraph/lab/IdentityExperiment.java new file mode 100644 index 0000000..2e543fa --- /dev/null +++ b/valhalla-lab/src/shared/com/adaworldapi/lancegraph/lab/IdentityExperiment.java @@ -0,0 +1,66 @@ +package com.adaworldapi.lancegraph.lab; + +/** + * Truth (a) — semantic: a {@link LaneId} is a value. Its object identity should be + * irrelevant, and nothing in the API should be able to observe it. + * + *

This experiment does not measure performance. It measures whether the runtime agrees + * with that semantic claim, which is the thing that has to be true before any performance claim + * means anything: a type whose identity is observable cannot be flattened, no matter how fast the + * JIT is. + * + *

The interesting output is not "value classes are values". It is the list of behaviours that + * are identical across the two runs — because every one of those is a place where the + * production API can move to Valhalla without a single caller noticing. + */ +final class IdentityExperiment { + + private IdentityExperiment() {} + + static void run() { + Lab.section("(a) SEMANTIC TRUTH — is identity observable?"); + Lab.kv("platform", Platform.NAME + " — " + Platform.describe()); + Lab.kv("LaneId.class.isValue()", Platform.isValueClass(LaneId.class)); + Lab.kv("Ordinal.class.isValue()", Platform.isValueClass(Ordinal.class)); + Lab.kv("MaskId.class.isValue()", Platform.isValueClass(MaskId.class)); + Lab.kv("RowRange.class.isValue()", Platform.isValueClass(RowRange.class)); + Lab.kv("Row.class.isValue()", Platform.isValueClass(Row.class)); + + LaneId a = LaneId.of(5); + LaneId b = LaneId.of(5); + LaneId c = LaneId.of(6); + + // The semantic contract, restated as assertions. These must hold on BOTH platforms — that + // is the point. If any of them differed, the migration would not be source-compatible. + Lab.kv("equal state => equals()", a.equals(b)); + Lab.kv("different state => !equals()", !a.equals(c)); + Lab.kv("equal state => equal hashCode()", a.hashCode() == b.hashCode()); + Lab.kv("equal state => equal toString()", a.toString().equals(b.toString())); + + // Reference equality is the ONE observable that legitimately differs, and the production + // API never uses it. Reported, not asserted, because a stable JDK is free to intern or not. + Lab.kv("a == b (reference equality)", a == b); + Lab.kv("identityHashCode(a) == identityHashCode(b)", + System.identityHashCode(a) == System.identityHashCode(b)); + + // Can a variable of this type hold null? Under Valhalla a plain declared type still can — + // null-restriction is a property of a FIELD or an ARRAY, not of the class. That surprises + // people, so it is measured rather than described. + LaneId maybeNull = null; + Lab.kv("a local of this type accepts null", maybeNull == null); + + Object[] arr = Platform.newLaneIdArray(4); + arr[0] = LaneId.of(1); + Lab.kv("array kind", arr.getClass().getSimpleName()); + Lab.kv("array flatness", Platform.arrayFlatness(arr)); + Lab.kv("array slot accepts null", Platform.arrayAcceptsNull(arr)); + + // synchronized(valueObject) does not compile under Valhalla — "required: a type with + // identity". It is exercised reflectively-in-spirit here (as a documented fact rather than + // live code) because writing it in shared source would break the stable compile too. + Lab.kv("synchronized(x) legality", + Platform.isValueVocabulary() + ? "COMPILE ERROR under Valhalla (required: a type with identity)" + : "legal but never used by the production API"); + } +} diff --git a/valhalla-lab/src/shared/com/adaworldapi/lancegraph/lab/Lab.java b/valhalla-lab/src/shared/com/adaworldapi/lancegraph/lab/Lab.java new file mode 100644 index 0000000..92e5b3b --- /dev/null +++ b/valhalla-lab/src/shared/com/adaworldapi/lancegraph/lab/Lab.java @@ -0,0 +1,144 @@ +package com.adaworldapi.lancegraph.lab; + +import java.lang.management.ManagementFactory; +import java.util.Arrays; + +/** + * The lab's measurement instruments. Deliberately small, and deliberately biased towards + * measurements that are hard to fool. + * + *

Why allocation bytes, not timing, is the primary instrument here

+ * + *

The question "did this abstraction cost a heap object?" is answered directly by + * {@code ThreadMXBean.getThreadAllocatedBytes}, which the VM maintains from TLAB accounting. A + * timing measurement answers it only by inference, and the inference is weak: escape analysis + * already removes many allocations, so a fast loop proves nothing about whether the object + * existed. Bytes are the observation; nanoseconds are the consequence. + * + *

This is also why {@code -XX:-DoEscapeAnalysis} appears in the run script. Comparing a record + * against a value class with escape analysis on measures the JIT's ability to see through a + * particular loop shape. Comparing with it off measures what the object model actually + * costs when the JIT cannot save it — which is the property that generalises to real code, where + * objects escape into arrays and collections all the time. + * + *

Timing

+ * + *

Warm-up then repeated measurement, reporting the median and the full min/max spread. The + * median is reported rather than the mean because a single GC pause or scheduler preemption in a + * 4-core container moves a mean and does not move a median. The spread is printed alongside so a + * reader can see when the median is not meaningful. This is not JMH — {@link #time} does not fork, + * does not detect steady state, and does not do statistical rigour. Where JMH-grade numbers are + * needed, they live in {@code bench/} and are produced by actual JMH. + */ +final class Lab { + + private Lab() {} + + private static final com.sun.management.ThreadMXBean THREADS = + (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean(); + + /** A sink the JIT cannot prove dead. Not a JMH blackhole; adequate for this lab's purposes. */ + static volatile long SINK; + static volatile Object OBJ_SINK; + + // ── allocation ─────────────────────────────────────────────────────────────────────────── + + /** + * Bytes this thread allocated while running {@code body}, minus the harness's own overhead. + * + *

The baseline subtraction matters: calling {@code getThreadAllocatedBytes} is itself not + * free of allocation on every VM, so an unsubtracted number would attribute the instrument's + * cost to the subject. Measured on this VM the baseline is 0, and the code says so rather than + * assuming it. + */ + static long allocatedBytes(Runnable body) { + long tid = Thread.currentThread().threadId(); + // Touch the instrument once so its own class-init allocation is not attributed to body. + THREADS.getThreadAllocatedBytes(tid); + long before = THREADS.getThreadAllocatedBytes(tid); + body.run(); + long after = THREADS.getThreadAllocatedBytes(tid); + return after - before; + } + + /** The instrument's own cost, printed so the reader can see it is negligible. */ + static long allocationInstrumentBaseline() { + return allocatedBytes(() -> {}); + } + + // ── retained footprint ─────────────────────────────────────────────────────────────────── + + /** + * Approximate retained heap of whatever {@code supplier} returns and keeps reachable. + * + *

Labelled approximate deliberately. It is a used-heap delta around a + * best-effort GC, so it is perturbed by anything else the VM does concurrently. It is reported + * because footprint is the question the thesis actually asks, and cross-checked against the + * exact allocation count so the two must agree in magnitude or one of them is wrong. + */ + static long retainedBytesApprox(java.util.function.Supplier supplier) { + Runtime rt = Runtime.getRuntime(); + settle(); + long before = rt.totalMemory() - rt.freeMemory(); + Object held = supplier.get(); + settle(); + long after = rt.totalMemory() - rt.freeMemory(); + OBJ_SINK = held; // keep it reachable across the second measurement + return after - before; + } + + private static void settle() { + for (int i = 0; i < 4; i++) { + System.gc(); + try { Thread.sleep(30); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } + } + } + + // ── timing ─────────────────────────────────────────────────────────────────────────────── + + record Timing(String name, double medianNs, double minNs, double maxNs, int iterations) { + @Override public String toString() { + return String.format("%-46s median=%10.1f ns [min %10.1f .. max %10.1f] n=%d", + name, medianNs, minNs, maxNs, iterations); + } + /** Per-unit cost, for reporting a per-row or per-element number honestly. */ + double perUnitNs(long units) { return medianNs / units; } + } + + /** + * Warm up, then measure {@code iterations} times and report median and spread. + * + * @param warmupRuns how many untimed runs before measuring; must be enough for C2 to compile + * the loop, which for these bodies is hundreds, not tens + */ + static Timing time(String name, int warmupRuns, int iterations, Runnable body) { + for (int i = 0; i < warmupRuns; i++) body.run(); + double[] samples = new double[iterations]; + for (int i = 0; i < iterations; i++) { + long t0 = System.nanoTime(); + body.run(); + samples[i] = System.nanoTime() - t0; + } + double[] sorted = samples.clone(); + Arrays.sort(sorted); + return new Timing(name, sorted[sorted.length / 2], sorted[0], sorted[sorted.length - 1], + iterations); + } + + // ── output ─────────────────────────────────────────────────────────────────────────────── + + static void section(String title) { + System.out.println(); + System.out.println("== " + title + " " + "=".repeat(Math.max(0, 74 - title.length()))); + } + + static void kv(String key, Object value) { + System.out.printf("%-44s %s%n", key, value); + } + + static String bytes(long b) { + if (Math.abs(b) < 1024) return b + " B"; + if (Math.abs(b) < 1024 * 1024) return String.format("%.1f KiB", b / 1024.0); + return String.format("%.2f MiB", b / (1024.0 * 1024.0)); + } +} diff --git a/valhalla-lab/src/shared/com/adaworldapi/lancegraph/lab/RunAll.java b/valhalla-lab/src/shared/com/adaworldapi/lancegraph/lab/RunAll.java new file mode 100644 index 0000000..031e9f0 --- /dev/null +++ b/valhalla-lab/src/shared/com/adaworldapi/lancegraph/lab/RunAll.java @@ -0,0 +1,46 @@ +package com.adaworldapi.lancegraph.lab; + +import com.adaworldapi.lancegraph.NativeRuntime; + +/** + * Runs every experiment and prints a machine-greppable report. + * + *

The same class is compiled twice — once against the {@code src/stable} vocabulary on the + * production JDK, once against {@code src/valhalla} on the JEP 401 build — and the two outputs are + * diffed. That is the whole design: one experiment source, two object models, one difference. + * + *

Exit codes: {@code 0} ran, {@code 2} the native library is unavailable (the thesis experiment + * needs it and a fabricated number would be worse than no number). + */ +public final class RunAll { + + private RunAll() {} + + public static void main(String[] args) { + System.out.println("lance-graph-java :: valhalla lab"); + Lab.kv("platform", Platform.NAME); + Lab.kv("java.vm.version", System.getProperty("java.vm.version")); + Lab.kv("java.vendor.version", System.getProperty("java.vendor.version", "-")); + Lab.kv("jvm args", java.lang.management.ManagementFactory.getRuntimeMXBean() + .getInputArguments()); + + IdentityExperiment.run(); + FlatteningCliffExperiment.run(); + FootprintExperiment.run(); + + if (!NativeRuntime.isAvailable()) { + Lab.section("NATIVE EXPERIMENTS SKIPPED"); + Lab.kv("reason", NativeRuntime.unavailableReason().getMessage()); + System.out.println("\nno native library — the thesis experiment cannot be run, and a" + + " number invented for it would be worse than none."); + System.exit(2); + } + Lab.kv("native runtime", NativeRuntime.describe()); + + FfmAddressingExperiment.run(); + ThesisExperiment.run(); + + System.out.println(); + System.out.println("lab complete."); + } +} diff --git a/valhalla-lab/src/shared/com/adaworldapi/lancegraph/lab/ThesisExperiment.java b/valhalla-lab/src/shared/com/adaworldapi/lancegraph/lab/ThesisExperiment.java new file mode 100644 index 0000000..5c1ddb2 --- /dev/null +++ b/valhalla-lab/src/shared/com/adaworldapi/lancegraph/lab/ThesisExperiment.java @@ -0,0 +1,170 @@ +package com.adaworldapi.lancegraph.lab; + +import com.adaworldapi.lancegraph.NativeAccess; +import com.adaworldapi.lancegraph.NativePattern; +import com.adaworldapi.lancegraph.Pattern; +import com.adaworldapi.lancegraph.internal.ffm.Engine; + +/** + * The central claim, put at risk. + * + *

64,000 logical entities must NOT require 64,000 Java objects.
+ * + *

Three ways to answer the same question over the same 65,536 rows — + * "how many rows have {@code class == 7} and {@code value > 100}, and what do their values sum + * to?" — measured for heap cost and for time: + * + *

    + *
  1. native — one lane set, one packed mask, one bulk crossing. Zero Java + * objects per row. + *
  2. objects — 65,536 materialised {@code Row} instances, then an ordinary + * Java loop. This is what a developer writes when the API hands them entities. + *
  3. values — the same 65,536 rows as Valhalla value objects in a flat array. + * This is the "Valhalla will fix it" hypothesis, given its best case: null-restricted, + * non-atomic, flattened storage. + *
+ * + *

The comparison is only fair if all three read identical bytes. They do: + * paths 2 and 3 are populated by copying out of the very lanes path 1 scans, so no path enjoys a + * different dataset, a different distribution, or a warmer cache than the others. The three + * answers are asserted equal before any number is reported — a benchmark whose variants compute + * different things is measuring nothing. + * + *

The expected finding is that Valhalla helps the tiny descriptor vocabulary (see + * {@link FootprintExperiment}) and does not rescue per-entity materialisation. Expected is + * not observed; the numbers are printed either way, including if they contradict it. + */ +final class ThesisExperiment { + + private ThesisExperiment() {} + + static final int ROWS = 65_536; + private static final int CLASS_NEEDLE = 7; + private static final int VALUE_THRESHOLD = 100; + + static void run() { + Lab.section("THE THESIS — 65,536 entities, three representations"); + Lab.kv("platform", Platform.NAME); + Lab.kv("rows", ROWS); + Lab.kv("question", "count(class==" + CLASS_NEEDLE + " AND value>" + VALUE_THRESHOLD + + ") and sum(value)"); + + try (NativePattern data = NativePattern.open(ROWS)) { + + // ── (1) native: one lane set, one packed mask, one bulk op ─────────────────────── + // Warm first. The View's scratch mask is created lazily on the first terminal call, + // and the FFM scratch arena on the first crossing, so an unwarmed measurement would + // report one-time setup as if it were per-query cost. + for (int i = 0; i < 2_000; i++) { + Lab.SINK = data.view() + .where(Pattern.CLASS.eq(CLASS_NEEDLE)) + .where(Pattern.VALUE.gt(VALUE_THRESHOLD)) + .count(); + } + long nativeAllocBytes = Lab.allocatedBytes(() -> + Lab.SINK = data.view() + .where(Pattern.CLASS.eq(CLASS_NEEDLE)) + .where(Pattern.VALUE.gt(VALUE_THRESHOLD)) + .count()); + long nativeCount = data.view() + .where(Pattern.CLASS.eq(CLASS_NEEDLE)) + .where(Pattern.VALUE.gt(VALUE_THRESHOLD)) + .count(); + long nativeSum = data.view() + .where(Pattern.CLASS.eq(CLASS_NEEDLE)) + .where(Pattern.VALUE.gt(VALUE_THRESHOLD)) + .sumOf(Pattern.VALUE); + + // ── build (2)/(3) from the SAME native bytes ───────────────────────────────────── + Engine.LaneWindow ids = NativeAccess.lane(data, NativeAccess.LANE_ID); + Engine.LaneWindow classes = NativeAccess.lane(data, NativeAccess.LANE_CLASS); + Engine.LaneWindow values = NativeAccess.lane(data, NativeAccess.LANE_VALUE); + + Object[] rows = Platform.newRowArray(ROWS); + long hydrateAllocBytes = Lab.allocatedBytes(() -> { + for (int i = 0; i < ROWS; i++) { + rows[i] = new Row(ids.getU64(i), (int) classes.getU32(i), values.getI32(i)); + } + }); + Lab.OBJ_SINK = rows; + + long objCount = 0, objSum = 0; + for (int i = 0; i < ROWS; i++) { + Row r = (Row) rows[i]; + if (r.cls() == CLASS_NEEDLE && r.value() > VALUE_THRESHOLD) { objCount++; objSum += r.value(); } + } + + // ── the falsifier: all three must agree, or nothing below means anything ───────── + if (objCount != nativeCount || objSum != nativeSum) { + throw new AssertionError("the three paths do not compute the same answer: native=" + + nativeCount + "/" + nativeSum + " objects=" + objCount + "/" + objSum); + } + Lab.kv("answer (identical across all paths)", nativeCount + " rows, sum " + nativeSum); + Lab.kv("selectivity", String.format("%.2f%%", 100.0 * nativeCount / ROWS)); + + // ── heap cost ──────────────────────────────────────────────────────────────────── + Lab.section(" heap cost"); + Lab.kv("(1) native — Java bytes allocated (warm)", Lab.bytes(nativeAllocBytes) + + " per query, for the fluent chain itself"); + Lab.kv("(1) native — Java objects per row", 0); + Lab.kv("(1) native — native lane bytes", + Lab.bytes(ROWS * (8L + 4 + 4)) + " (u64 id + u32 class + i32 value)"); + Lab.kv("(1) native — mask bytes", + Lab.bytes((ROWS + 63) / 64 * 8L) + " (1 bit per row, packed)"); + Lab.kv("(2)/(3) hydrate " + ROWS + " Row — allocated", + Lab.bytes(hydrateAllocBytes)); + Lab.kv(" ... per row", String.format("%.2f B", hydrateAllocBytes / (double) ROWS)); + Lab.kv(" array flatness", Platform.arrayFlatness(rows)); + Lab.kv(" ratio vs native lane bytes", + String.format("%.2fx", hydrateAllocBytes / (double) (ROWS * 16L))); + + long retained = Lab.retainedBytesApprox(() -> { + Object[] held = Platform.newRowArray(ROWS); + for (int i = 0; i < ROWS; i++) { + held[i] = new Row(ids.getU64(i), (int) classes.getU32(i), values.getI32(i)); + } + return held; + }); + Lab.kv(" retained heap (APPROX, gc-delta)", Lab.bytes(retained)); + + // ── time ───────────────────────────────────────────────────────────────────────── + Lab.section(" time to answer the question"); + System.out.println(Lab.time("(1) native one crossing, fused plan", 2_000, 51, () -> + Lab.SINK = data.view() + .where(Pattern.CLASS.eq(CLASS_NEEDLE)) + .where(Pattern.VALUE.gt(VALUE_THRESHOLD)) + .count())); + + System.out.println(Lab.time("(2/3) hydrate " + ROWS + " Row objects", 200, 51, () -> { + Object[] a = Platform.newRowArray(ROWS); + for (int i = 0; i < ROWS; i++) { + a[i] = new Row(ids.getU64(i), (int) classes.getU32(i), values.getI32(i)); + } + Lab.OBJ_SINK = a; + })); + + System.out.println(Lab.time("(2/3) scan the materialised objects", 2_000, 51, () -> { + long c = 0, s = 0; + for (int i = 0; i < ROWS; i++) { + Row r = (Row) rows[i]; + if (r.cls() == CLASS_NEEDLE && r.value() > VALUE_THRESHOLD) { c++; s += r.value(); } + } + Lab.SINK = c + s; + })); + + System.out.println(Lab.time("(2/3) hydrate THEN scan (honest total)", 200, 51, () -> { + Object[] a = Platform.newRowArray(ROWS); + for (int i = 0; i < ROWS; i++) { + a[i] = new Row(ids.getU64(i), (int) classes.getU32(i), values.getI32(i)); + } + long c = 0, s = 0; + for (int i = 0; i < ROWS; i++) { + Row r = (Row) a[i]; + if (r.cls() == CLASS_NEEDLE && r.value() > VALUE_THRESHOLD) { c++; s += r.value(); } + } + Lab.SINK = c + s; + Lab.OBJ_SINK = a; + })); + } + } +} diff --git a/valhalla-lab/src/stable/com/adaworldapi/lancegraph/lab/Containers.java b/valhalla-lab/src/stable/com/adaworldapi/lancegraph/lab/Containers.java new file mode 100644 index 0000000..a8e3be6 --- /dev/null +++ b/valhalla-lab/src/stable/com/adaworldapi/lancegraph/lab/Containers.java @@ -0,0 +1,31 @@ +package com.adaworldapi.lancegraph.lab; + +/** + * Field-flattening subjects, stable half. + * + *

{@code Descriptor} is shaped like the real thing: a {@code Field} in the production API holds + * a {@link LaneId} and an {@link Ordinal}, and both are wrappers around a single {@code int}. On a + * stable JDK that is three objects and two pointer hops to read two integers. + */ +final class Containers { + + private Containers() {} + + /** Two value-shaped wrappers held as ordinary references. */ + static final class Descriptor { + final LaneId lane; + final Ordinal ordinal; + Descriptor(int lane, int ordinal) { + this.lane = LaneId.of(lane); + this.ordinal = Ordinal.of(ordinal); + } + int laneIndex() { return lane.index(); } + int ordinalValue() { return ordinal.value(); } + } + + static Descriptor make(int lane, int ordinal) { return new Descriptor(lane, ordinal); } + static int read(Descriptor d) { return d.laneIndex() + d.ordinalValue(); } + + static String kind() { return "identity class with two reference fields"; } + static boolean fieldsAreNullRestricted() { return false; } +} diff --git a/valhalla-lab/src/stable/com/adaworldapi/lancegraph/lab/Platform.java b/valhalla-lab/src/stable/com/adaworldapi/lancegraph/lab/Platform.java new file mode 100644 index 0000000..ec66ef7 --- /dev/null +++ b/valhalla-lab/src/stable/com/adaworldapi/lancegraph/lab/Platform.java @@ -0,0 +1,68 @@ +package com.adaworldapi.lancegraph.lab; + +/** + * The stable-JDK half of the A/B. Everything here reports what an ordinary {@code record} on a + * production JDK actually is. + * + *

Two of these answers are unknowable on a stable JDK rather than false, and they say + * so: there is no {@code ValueClass.isFlatArray} to ask, because there is no flattening to ask + * about. Reporting "false" would imply the question was answered; {@code UNKNOWN} says it was not. + */ +final class Platform { + + private Platform() {} + + static final String NAME = "stable"; + + /** Does the runtime consider this vocabulary identity-free? */ + static boolean isValueVocabulary() { + return isValueClass(LaneId.class); + } + + /** + * Is {@code type} a value class, on a platform that can even ask. + * + *

{@code Class::isValue} does not exist on this JDK at all — this is not an "unknowable + * answer" case like {@link #arrayFlatness}, it is a missing method, confirmed by the compiler. + * The correct answer is still knowable without the query: on a JDK with no value-class + * concept, nothing compiled here can BE one, so {@code false} is exact, not a guess. + */ + static boolean isValueClass(Class type) { + return false; + } + + /** Flatness of an array, where the runtime can answer. */ + static String arrayFlatness(Object array) { + return "UNKNOWN(no ValueClass API on a stable JDK; a reference array is never flat)"; + } + + /** Allocate the vocabulary array the way this platform can. */ + static Object[] newLaneIdArray(int n) { + return new LaneId[n]; + } + + /** Allocate the per-entity Row array the way this platform can. */ + static Object[] newRowArray(int n) { + return new Row[n]; + } + + /** Can this array hold null in every slot? */ + static boolean arrayAcceptsNull(Object[] array) { + try { Object keep = array[0]; array[0] = null; array[0] = keep; return true; } + catch (Throwable t) { return false; } + } + + /** + * Allocate the densest array this platform offers for {@code component}. + * + *

On a stable JDK there is exactly one kind of object array, so this is + * {@code Array.newInstance} and the {@code init} value is unused. + */ + static Object[] newArrayOf(Class component, int n, Object init) { + return (Object[]) java.lang.reflect.Array.newInstance(component, n); + } + + static String describe() { + return "stable-record vocabulary; arrays are reference arrays; fields are references"; + } +} diff --git a/valhalla-lab/src/stable/com/adaworldapi/lancegraph/lab/Vocab.java b/valhalla-lab/src/stable/com/adaworldapi/lancegraph/lab/Vocab.java new file mode 100644 index 0000000..1a0fca5 --- /dev/null +++ b/valhalla-lab/src/stable/com/adaworldapi/lancegraph/lab/Vocab.java @@ -0,0 +1,61 @@ +package com.adaworldapi.lancegraph.lab; + +/** + * The A/B vocabulary. This file exists twice — once under {@code src/stable} and once under + * {@code src/valhalla} — and the two copies are byte-identical apart from the word {@code value} + * on each declaration. {@code run.sh} diffs them and refuses to run if any other difference has + * crept in, so "same semantic contract, one modifier" is checked rather than claimed. + * + *

Each type mirrors the corresponding type in the real API + * ({@code com.adaworldapi.lancegraph.LaneId} and friends). The mirror exists because the real + * types must keep compiling on the production JDK; the lab is where the same contract is compiled + * a second way and measured. + */ +final class VocabDoc { private VocabDoc() {} } + +/** Which column of the struct-of-arrays a field lives in. Mirrors the production {@code LaneId}. */ +record LaneId(int index) { + LaneId { + if (index < 0) throw new IllegalArgumentException("lane index must be >= 0, was " + index); + } + static LaneId of(int index) { return new LaneId(index); } + @Override public String toString() { return "lane#" + index; } +} + +/** A half-open span of row indices. Mirrors the production {@code RowRange}. */ +record RowRange(long start, long endExclusive) { + RowRange { + if (start < 0) throw new IllegalArgumentException("start must be >= 0, was " + start); + if (endExclusive < start) throw new IllegalArgumentException("endExclusive < start"); + } + static RowRange of(long count) { return new RowRange(0, count); } + long length() { return endExclusive - start; } + boolean isEmpty() { return endExclusive == start; } + boolean contains(long row) { return row >= start && row < endExclusive; } + @Override public String toString() { return "rows[" + start + "," + endExclusive + ")"; } +} + +/** The identity of a selection, as an opaque token. Mirrors the production {@code MaskId}. */ +record MaskId(long token) { + int slot() { return (int) (token & 0xFFFF_FFFFL); } + int generation() { return (int) (token >>> 32); } + @Override public String toString() { return "mask#" + slot() + "@" + generation(); } +} + +/** A field's position within its schema. Mirrors the production {@code Ordinal}. */ +record Ordinal(int value) { + Ordinal { + if (value < 0) throw new IllegalArgumentException("ordinal must be >= 0, was " + value); + } + static Ordinal of(int value) { return new Ordinal(value); } + @Override public String toString() { return "#" + value; } +} + +/** + * One logical entity, materialised as a Java object. + * + *

This is the type the thesis says must NOT exist per row. It is declared here precisely so + * that "must not" can be measured instead of asserted: {@code ThesisExperiment} builds 65,536 of + * these and reports what they cost against the native lane that needs none of them. + */ +record Row(long id, int cls, int value) {} diff --git a/valhalla-lab/src/valhalla/com/adaworldapi/lancegraph/lab/Containers.java b/valhalla-lab/src/valhalla/com/adaworldapi/lancegraph/lab/Containers.java new file mode 100644 index 0000000..9811b04 --- /dev/null +++ b/valhalla-lab/src/valhalla/com/adaworldapi/lancegraph/lab/Containers.java @@ -0,0 +1,46 @@ +package com.adaworldapi.lancegraph.lab; + +import jdk.internal.value.ValueClass; +import jdk.internal.vm.annotation.NullRestricted; + +/** + * Field-flattening subjects, Valhalla half. Same shape as the stable {@code Descriptor}; the two + * differences are the {@code value} modifier and {@code @NullRestricted} on the fields. + * + *

Both differences are load-bearing and that is a finding, not a detail. + * {@code @NullRestricted} is what permits a flat encoding — a nullable value field still needs + * somewhere to record "this one is null". And the container must itself be a {@code value class}: + * an ordinary identity class with {@code @NullRestricted} fields fails at class load with + * {@code VerifyError: Invalid use of strict instance fields}, because javac on this build does not + * emit the strict initialisation order the VM demands. See + * {@code reproducers/nullrestricted-field-in-identity-class.md}. + */ +final class Containers { + + private Containers() {} + + /** Two value-shaped wrappers held as null-restricted, flattenable fields. */ + static value class Descriptor { + @NullRestricted final LaneId lane; + @NullRestricted final Ordinal ordinal; + Descriptor(int lane, int ordinal) { + this.lane = LaneId.of(lane); + this.ordinal = Ordinal.of(ordinal); + } + int laneIndex() { return lane.index(); } + int ordinalValue() { return ordinal.value(); } + } + + static Descriptor make(int lane, int ordinal) { return new Descriptor(lane, ordinal); } + static int read(Descriptor d) { return d.laneIndex() + d.ordinalValue(); } + + static String kind() { return "value class with two @NullRestricted value fields"; } + + static boolean fieldsAreNullRestricted() { + try { + return ValueClass.isNullRestrictedField(Descriptor.class.getDeclaredField("lane")); + } catch (NoSuchFieldException e) { + throw new AssertionError(e); + } + } +} diff --git a/valhalla-lab/src/valhalla/com/adaworldapi/lancegraph/lab/Platform.java b/valhalla-lab/src/valhalla/com/adaworldapi/lancegraph/lab/Platform.java new file mode 100644 index 0000000..6a53d9d --- /dev/null +++ b/valhalla-lab/src/valhalla/com/adaworldapi/lancegraph/lab/Platform.java @@ -0,0 +1,65 @@ +package com.adaworldapi.lancegraph.lab; + +import jdk.internal.value.ValueClass; + +/** + * The Valhalla half of the A/B. Same signatures as the stable {@code Platform}, so the shared + * experiment sources are byte-identical across both runs. + * + *

{@link ValueClass#isFlatArray} is the measurement that matters here: it is the VM answering + * about its own array, not an inference from a timing or a footprint estimate. That makes the + * flattening result hard to fool, which is why it is preferred over anything derived. + * + *

Requires {@code --add-exports java.base/jdk.internal.value=ALL-UNNAMED}. That export is the + * honest cost of the experiment: null-restricted, guaranteed-flat storage has no + * supported surface in this early-access build (see reproducers/no-public-flat-array-api.md). + */ +final class Platform { + + private Platform() {} + + static final String NAME = "valhalla"; + + static boolean isValueVocabulary() { + return isValueClass(LaneId.class); + } + + /** Is {@code type} a value class? The real, final {@code Class::isValue} query. */ + static boolean isValueClass(Class type) { + return type.isValue(); + } + + static String arrayFlatness(Object array) { + return ValueClass.isFlatArray(array) ? "FLAT" : "NOT-FLAT"; + } + + /** + * Null-restricted, non-atomic — the encoding with no null marker and no atomicity padding, + * i.e. the densest layout the VM offers. This is the array the thesis experiment needs to be + * fair to Valhalla: giving it a plain reference array would measure the old world twice. + */ + static Object[] newLaneIdArray(int n) { + return ValueClass.newNullRestrictedNonAtomicArray(LaneId.class, n, new LaneId(0)); + } + + static Object[] newRowArray(int n) { + return ValueClass.newNullRestrictedNonAtomicArray(Row.class, n, new Row(0, 0, 0)); + } + + static boolean arrayAcceptsNull(Object[] array) { + try { Object keep = array[0]; array[0] = null; array[0] = keep; return true; } + catch (Throwable t) { return false; } + } + + /** + * Allocate the densest array this platform offers for {@code component} — null-restricted and + * non-atomic, the encoding with neither a null marker nor atomicity padding. + */ + static Object[] newArrayOf(Class component, int n, Object init) { + return ValueClass.newNullRestrictedNonAtomicArray(component, n, init); + } + + static String describe() { + return "value-record vocabulary; null-restricted non-atomic arrays; @NullRestricted fields"; + } +} diff --git a/valhalla-lab/src/valhalla/com/adaworldapi/lancegraph/lab/Vocab.java b/valhalla-lab/src/valhalla/com/adaworldapi/lancegraph/lab/Vocab.java new file mode 100644 index 0000000..78b88b8 --- /dev/null +++ b/valhalla-lab/src/valhalla/com/adaworldapi/lancegraph/lab/Vocab.java @@ -0,0 +1,61 @@ +package com.adaworldapi.lancegraph.lab; + +/** + * The A/B vocabulary. This file exists twice — once under {@code src/stable} and once under + * {@code src/valhalla} — and the two copies are byte-identical apart from the word {@code value} + * on each declaration. {@code run.sh} diffs them and refuses to run if any other difference has + * crept in, so "same semantic contract, one modifier" is checked rather than claimed. + * + *

Each type mirrors the corresponding type in the real API + * ({@code com.adaworldapi.lancegraph.LaneId} and friends). The mirror exists because the real + * types must keep compiling on the production JDK; the lab is where the same contract is compiled + * a second way and measured. + */ +final class VocabDoc { private VocabDoc() {} } + +/** Which column of the struct-of-arrays a field lives in. Mirrors the production {@code LaneId}. */ +value record LaneId(int index) { + LaneId { + if (index < 0) throw new IllegalArgumentException("lane index must be >= 0, was " + index); + } + static LaneId of(int index) { return new LaneId(index); } + @Override public String toString() { return "lane#" + index; } +} + +/** A half-open span of row indices. Mirrors the production {@code RowRange}. */ +value record RowRange(long start, long endExclusive) { + RowRange { + if (start < 0) throw new IllegalArgumentException("start must be >= 0, was " + start); + if (endExclusive < start) throw new IllegalArgumentException("endExclusive < start"); + } + static RowRange of(long count) { return new RowRange(0, count); } + long length() { return endExclusive - start; } + boolean isEmpty() { return endExclusive == start; } + boolean contains(long row) { return row >= start && row < endExclusive; } + @Override public String toString() { return "rows[" + start + "," + endExclusive + ")"; } +} + +/** The identity of a selection, as an opaque token. Mirrors the production {@code MaskId}. */ +value record MaskId(long token) { + int slot() { return (int) (token & 0xFFFF_FFFFL); } + int generation() { return (int) (token >>> 32); } + @Override public String toString() { return "mask#" + slot() + "@" + generation(); } +} + +/** A field's position within its schema. Mirrors the production {@code Ordinal}. */ +value record Ordinal(int value) { + Ordinal { + if (value < 0) throw new IllegalArgumentException("ordinal must be >= 0, was " + value); + } + static Ordinal of(int value) { return new Ordinal(value); } + @Override public String toString() { return "#" + value; } +} + +/** + * One logical entity, materialised as a Java object. + * + *

This is the type the thesis says must NOT exist per row. It is declared here precisely so + * that "must not" can be measured instead of asserted: {@code ThesisExperiment} builds 65,536 of + * these and reports what they cost against the native lane that needs none of them. + */ +value record Row(long id, int cls, int value) {}