Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .claude/board/ISSUES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,43 @@
## ISS-LGJ-CONSUMERS-HAVE-NO-CI-LINE (2026-09-22) — OPEN

`main` was RED and nothing said so. `GraphHopTest` reported 1 FAILED / 65
passed at `bb81d80`, established by running it from a clean worktree, not
inferred. The defect itself was small — a pin asserting a second
`materializeRows()` costs zero crossings, written before `Mask.words()` began
re-validating its cached window per facade call — and is fixed
(D-LGJ-FOLD-5's second commit). **The issue is that it survived a merge.**

`.github/workflows/` contains exactly one workflow and it gates `lgj-abi`
only: `cargo fmt --check`, `cargo clippy -D warnings`, `cargo test`. Nothing
compiles a single line of Java. The **612-check core suite and all three
consumer suites are LOCAL gates**, run by whoever remembers to run them, which
root `CLAUDE.md` states plainly (*"the merge gate is the Rust suite plus the
409-check Java run"*). A consumer is one step further out still: even a
session that runs `AllTests` religiously never touches `consumers/`.

This is the **same shape** as the r2il probe step that sat absent from
lance-graph CI until its OGAR dependency reached main — a gate that exists
and is simply never dispatched is indistinguishable from no gate. There the
absence was deliberate and documented; here it is neither.

**Why it is not just "add a job":** a Java job needs JDK 28 with
`--enable-preview` (JEP 401 is preview-gated, per
`ISS-LGJ-TOOLCHAIN-MUST-BE-JDK28-VALHALLA-PANAMA`) and JDK 28 is an EA build
that apt does not carry — the container obtains it from a GitHub release
download path because the distribution hosts are gateway-blocked. A CI runner
has its own network posture, so the acquisition ladder that works here is not
evidence it works there. Scoping that is this issue's first task, not an
assumption to build on.

**Minimum that would have caught this one:** a job that builds
`java/src/{main,test}` plus `consumers/*/src` and runs `AllTests` and the
three consumer mains. No new test, no new assertion — only dispatch.

**Falsifier for any fix:** re-introduce the stale `0` pin on a branch and
confirm the job goes red. A job that builds the consumers but never runs
their mains would pass, and would be the no-gate-with-extra-steps outcome
this entry exists to name.

## ISS-LGJ-TOOLCHAIN-MUST-BE-JDK28-VALHALLA-PANAMA — UNBLOCKED; JDK 28 installed and the flip proven (2026-09-19)

⊘ The entry below says the migration is blocked because no JDK 28 can be
Expand Down
82 changes: 79 additions & 3 deletions .claude/board/LATEST_STATE.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,76 @@
## 2026-09-22 — D-LGJ-FOLD-5: the consumer stops asking sixteen times, and a red pin on `main` gets root-caused

The consumer half of minor 12. `BricksQuery.sumBy()` was sixteen queries —
one `sumOf` over `where(REGION.eq(v))` per region, each paying plan evaluation
into a selection mask plus the reduction over it — and is now one fused
`sumByGroup`. No selection mask is built at any point.

- **Measured through the consumer, not inherited from the core suite:** 1
crossing at 1,000 and 64,000 rows, and — new — at 1, 16 and 64 groups.
The old path was already invariant in ROWS, which is why its literal was
asserted at both row counts; this one is invariant in GROUPS as well, a
strictly stronger claim, so it gets its own arm instead of riding on the
row-count one.
- **The group-count arm needed a seam, and the seam is the honest kind.** The
public `sumBy()` always asks for exactly `Orders.REGIONS` groups, so nothing
the test could previously reach varied the group count and *"one crossing
whatever the group count"* was a doc comment no input could falsify. A
package-private `sumByGroupCount` serves that arm alone; `getMethods()` does
not see it, so the aggregate-only-egress guard still audits exactly the
public surface the guarantee is about.
- **Disable arm A recovered the old cost formula exactly:** reverting to the
per-group loop measured **2 / 32 / 128 at 1 / 16 / 64 groups**, identical at
both row counts — `2 × groups` — with **parity GREEN throughout**. The two
paths agree on the answer and differ only in cost, which is what makes this a
migration rather than a behaviour change. Note even the degenerate case
discriminates: 1 group costs 2 on the old path, 1 on the new.
- **`Orders.REGIONS` is a mirror, so it is pinned to the fixture and not to a
copied number.** It replaces three literal 16s with one named source for the
native generator's classid cardinality, which the ABI manifest does not
report. A comparison against a second Java literal would drift in lockstep
with whatever edited it and prove nothing; instead every id below REGIONS
must carry rows and id REGIONS itself must carry none. Two-sided for a
reason: arm B (REGIONS = 20) fires the first half only, arm C (REGIONS = 8)
the second only — at 3,960 rows on id 8 — so neither half alone catches
both directions.
- **Return type stays `Map<Integer, Long>`:** sized by the question, one entry
per group, never by the data. Handing back `GroupTotals` would leak a facade
type into a consumer's public surface for nothing.

**And a separate finding, which is the part worth remembering:
`GraphHopTest` was RED on `main`.** 1 FAILED / 65 passed at `bb81d80`,
established by running it from a clean worktree rather than inferred from the
diff's shape. Consumers have **no CI line** — the only workflow gates
`lgj-abi` (fmt, clippy, test) — so nothing reported it, the same
no-CI-line shape that hid the r2il probe step upstream. Filed as
`ISS-LGJ-CONSUMERS-HAVE-NO-CI-LINE`.

Root cause, and it is the design rather than a regression: `Mask.words()` no
longer returns its cached window directly — it re-describes through
`lgj_mask_describe` and compares the returned epoch against its stamp, so a
cached address is never read after the substrate moved under it. Root
`CLAUDE.md` names that as shipped. The pin asserting a second
`materializeRows()` costs **zero** was written against the pure-cache
behaviour that preceded it, so the test moved, not the code; reverting the
number would mean deleting the re-validation. The plan's §3.5 *"describe
cached per Mask; zero crossings steady-state"* is superseded for `Mask`: the
words are still not re-fetched and the population is still never re-scanned,
but the describe is no longer free. Steady-state is **constant per call**.

The re-pin asserts that rather than flipping a literal: a THIRD call is
measured and required to equal the second. A one-off extra describe would
satisfy a bare `== 1` on the second call; only a per-call cost satisfies
second == third. **Disable arm D proves the two arms are independent** —
restoring the pure cache kills the magnitude arm (expected 1, was 0) and
leaves the constancy arm GREEN at 0, because it tests a different property.

- **Gates:** core **612/612**, bricks **70/70** (was 62; the six group-count
arms and two cardinality checks are the +8), graph **68/68** (was 65 + 1
failed; the third-call cost and its content check are the +2), trades
**12/12 + 3/3** — **765 checks**, JDK 28 `--release 28 --enable-preview`
against a freshly built `abi 0.12, ndarray::simd avx512, release`. Four
disable arms, each red-then-green.

## 2026-09-22 — minor 12: `lgj_plan_group_sum_i32`, the grouped sum that never builds a selection (fold-distillation wave 4)

Upstream first (lance-graph #1256 merged `99cdca38`: the tiled executor,
Expand Down Expand Up @@ -26,9 +99,12 @@ selection exists at any point.
- **The eighth named materialisation site:** `Engine.groupSumI32`'s
`toArray`, sized by `groups` (the question), pinned in `DoctrineFenceTest`
and listed in root `CLAUDE.md`. `GroupTotals` exposes no array.
- **Still owed:** the `bricks` consumer's `sumBy()` still runs the 32-crossing
path; migrating it to `sumByGroup` is a consumer-wave change, not this
one. `lgj_hop` still holds mask-sized Vecs (pre-existing, unrelated).
- **Still owed:** ⊘ the first clause is DISCHARGED (2026-09-22, D-LGJ-FOLD-5,
see the section above) — struck in place, not deleted: it was true when
written. It read *"the `bricks` consumer's `sumBy()` still runs the
32-crossing path; migrating it to `sumByGroup` is a consumer-wave change,
not this one."* That consumer wave has now run. `lgj_hop` still holds
mask-sized Vecs (pre-existing, unrelated) and remains owed.

## 2026-09-19 — PR #81 merged (`07aa441`): production IS the Valhalla arm; Panama × Valhalla is one membrane

Expand Down
2 changes: 1 addition & 1 deletion .claude/board/STATUS_BOARD.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,5 +131,5 @@ same lowering: lance-graph #1257 (SAP CATS, merged).
| D-id | Deliverable | Status |
|---|---|---|
| D-LGJ-FOLD-4 | ABI minor 12 `lgj_plan_group_sum_i32` + `View.sumByGroup` / `sumByGroupVia` + `GroupTotals`: a `GROUP BY … SUM` as ONE program, one crossing, no selection (`docs/abi.md` §20) | **DONE 2026-09-22** — native 191 tests, Java 612 checks (JDK 28), 1 crossing measured beside the 32-crossing path, six disable arms red-then-green |
| D-LGJ-FOLD-5 | Migrate `consumers/bricks` `sumBy()` from the 32-crossing per-group path onto `sumByGroup` (re-pin BricksAuthTest's crossing arithmetic 32 → 1) | Queued |
| D-LGJ-FOLD-5 | Migrate `consumers/bricks` `sumBy()` from the 32-crossing per-group path onto `sumByGroup` (re-pin BricksAuthTest's crossing arithmetic 32 → 1) | **DONE 2026-09-22** — one fused call; **1 crossing at 1,000 and 64,000 rows AND at 1, 16 and 64 groups**. The group-count arm is new and load-bearing: the public `sumBy()` always asks for `Orders.REGIONS` groups, so nothing previously reachable varied the group count and "one crossing whatever the group count" was unfalsifiable; a package-private `sumByGroupCount` exists for that arm alone (invisible to `getMethods()`, so the aggregate-egress guard still audits exactly the public surface). Disable arm A (revert to the per-group loop) measured the old cost formula exactly — **2 / 32 / 128 at 1 / 16 / 64 groups, identical at both row counts, i.e. `2 × groups`** — with parity GREEN throughout, so the two paths agree on the answer and differ only in cost. `Orders.REGIONS` replaces three literal 16s with one named source, a MIRROR of the native generator's classid cardinality (the manifest does not report it) pinned two-sided against the fixture rather than against a copied literal; arms B (20) and C (8) each fire a DIFFERENT half, so neither half alone catches both directions. BricksAuthTest **70/70** (was 62) |

Original file line number Diff line number Diff line change
Expand Up @@ -94,35 +94,57 @@ public long sum(com.adaworldapi.lancegraph.I32Field field) {
/**
* Sum {@code value} grouped by every possible value of {@code group}.
*
* <p>{@code group} is a {@link com.adaworldapi.lancegraph.U32Field}, and this consumer's fixture
* gives such fields exactly 16 distinct values ({@code 0..15}) — see {@link Orders#REGION}. This
* method issues one fused native query per group value (16 total), each narrowing this query's
* already-authorized chain by one more {@code group.eq(v)} condition and summing {@code value}
* over the result. Each of those 16 sums costs <em>two</em> native crossings — plan evaluation
* into the selection mask, then the {@code lgj_reduce_sum_i32} reduction — so the measured
* total is 32 crossings (unlike {@link #count()}, whose plan evaluation returns the count and
* pays one). <strong>The crossing count scales with the number of groups, never with the
* number of rows</strong> — the same laziness guarantee every other terminal operation in this
* codebase carries, just paid per group instead of once.
* <p><strong>One crossing</strong>, whatever the number of groups or rows. The whole question —
* this query's authorized chain plus the grouped fold — is a single fused native program; no
* selection is built, nothing is asked per group, and only the totals cross. See {@link
* com.adaworldapi.lancegraph.View#sumByGroup}.
*
* <p>Every group value {@code 0..15} appears as a key in the returned map, including groups with
* zero matching rows (mapped to a sum of {@code 0L}): a group's absence from a real dataset is
* itself a legitimate aggregate fact, not something to hide by omitting the key.
* <p>This used to be sixteen separate queries: one {@code sumOf} over {@code
* where(group.eq(v))} for each {@code v}, each costing two crossings (plan evaluation into a
* selection mask, then the reduction), measured here at <strong>32</strong>. That path was
* invariant in the number of rows but proportional to the number of groups; this one is
* invariant in both, which is the stronger claim and is asserted as such — {@code
* BricksAuthTest} pins the cost at 1 across two row counts <em>and</em> three group
* counts: 1, {@link Orders#REGIONS}, and four times {@code REGIONS}.
*
* <p>If measurement ever shows this 32-crossing loop is a bottleneck, a native grouped-aggregate
* kernel (one crossing, sixteen output buckets) is the natural W6-tier follow-up — not built
* here, because nothing has measured a need for it yet.
* <p>Groups come from {@link Orders#REGIONS}, the fixture's region cardinality. Every id in
* {@code 0..REGIONS-1} appears as a key in the returned map, including ids no selected row
* carries (mapped to {@code 0L}): a group's absence is itself a legitimate aggregate fact, not
* something to hide by omitting the key.
*
* <p>The returned map is sized by the question — one entry per group — never by the data, so
* the answer for a billion rows is the same sixteen numbers as the answer for a thousand.
*
* @throws UnauthorizedQueryException if {@link #authorize(Role)} was never called on this chain
* @throws com.adaworldapi.lancegraph.AbiMismatchException if the loaded library reports ABI
* minor &lt; 12, which is where the fused grouped fold arrived
*/
public Map<Integer, Long> sumBy(
com.adaworldapi.lancegraph.U32Field group, com.adaworldapi.lancegraph.I32Field value) {
return sumByGroupCount(group, value, Orders.REGIONS);
}

/**
* {@link #sumBy} with the group count as a parameter, so a test can vary it.
*
* <p>Package-private on purpose. The public {@code sumBy} answers for exactly the fixture's
* regions and a caller has no business asking for a different number; but the claim that this
* costs one crossing <em>whatever</em> the group count is only a claim if something varies the
* group count, and nothing else in this package can. It is not part of the aggregate-egress
* surface the reflection guard audits — that guard reads public methods, which is the surface
* the guarantee is about.
*/
Map<Integer, Long> sumByGroupCount(
com.adaworldapi.lancegraph.U32Field group,
com.adaworldapi.lancegraph.I32Field value,
int groups) {
requireAuthorized("sumBy()");
java.util.Objects.requireNonNull(group, "group");
java.util.Objects.requireNonNull(value, "value");
Map<Integer, Long> result = new LinkedHashMap<>(16);
for (int v = 0; v < 16; v++) {
result.put(v, view.where(group.eq(v)).sumOf(value));
com.adaworldapi.lancegraph.GroupTotals totals = view.sumByGroup(group, value, groups);
Map<Integer, Long> result = new LinkedHashMap<>(totals.groups());
for (int v = 0; v < totals.groups(); v++) {
result.put(v, totals.total(v));
}
return result;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,25 @@ private Orders() {
public static final I32Field REVENUE =
new I32Field("revenue", LaneId.of(2), Ordinal.of(1));

/**
* How many distinct {@link #REGION} values the fixture produces: ids {@code 0..15}.
*
* <p>This is a fixture fact, not a schema choice — the generator derives the lane-1 tag as
* {@code (a >> 33) & (ROWSTORE_CLASS_CARDINALITY - 1)}, so the cardinality is the native
* generator's, mirrored here because the ABI manifest does not report it. A mirror can drift,
* so it is pinned empirically rather than by comparison: {@code BricksAuthTest}'s
* region-cardinality check counts rows for every id in {@code 0..REGIONS} and requires the
* first {@code REGIONS} to be populated and id {@code REGIONS} itself to be empty. Raise this
* constant without the generator changing and that check goes red.
*
* <p>It is also the {@code groups} argument {@link BricksQuery#sumBy} passes, which is why
* there is one named source for it instead of a literal at each site.
*/
public static final int REGIONS = 16;

/**
* The region id used by {@link Role#EU_ONLY} to build its authorization mask. One of the
* fixture's 16 distinct {@link #REGION} values.
* fixture's {@link #REGIONS} distinct {@link #REGION} values.
*/
public static final int EU = 7;

Expand Down
Loading
Loading