Skip to content

MILAB-6651: pin cellLinker side assignment in the TS linker engine - #1768

Draft
PaulNewling wants to merge 3 commits into
mainfrom
MILAB-6651_linker-side-parity-test
Draft

MILAB-6651: pin cellLinker side assignment in the TS linker engine#1768
PaulNewling wants to merge 3 commits into
mainfrom
MILAB-6651_linker-side-parity-test

Conversation

@PaulNewling

Copy link
Copy Markdown
Contributor

What this does

Adds characterisation tests for LinkerMap, mirroring tests added to pframes-rs. Test-only change.

They assert the behaviour that exists today, which is wrong. They must flip when linker sides become explicit, and that flip is the proof the fix worked.

Why LinkerMap needs its own tests

Two engines implement the same linker convention. Rust LinkerIndex plans queries inside pframes-rs. This TS LinkerMap collects columns at model time — sdk/model/src/render/util/column_collection.ts:520 and sdk/model/src/pframe_utils/axes.ts:77 — where only specs are available and pl-model-common deliberately carries no pframes-rs dependency.

Both derive component ordering independently: Rust from disjoint::DisjointSet::sets(), TS from its own index scan in getAxesGroups. Nothing couples them, and they have already drifted twice — Rust hard-errors on more than two components while TS silently skips, and only Rust honours excludeColumns. So each side asserts the expectation separately. A genuine shared fixture would need a generated cross-repo contract.

What the tests establish

pl7.app/sc/cellLinker is authored [sampleId, cellId <- sampleId, scClonotypeKey]. getAxesGroups returns groups ordered by smallest contained index, and fromColumns destructures const [left, right] = groups (linker_columns.ts:53), so the sample/cell component becomes the one-side. That is backwards: many cells map to one clonotype.

Two consequences follow, both asserted:

  • A clonotype-keyed source reaches cell-level axes. Edges are stored many-side → one-side (linker_columns.ts:91), so inverted sides let a clonotype-keyed table traverse down to per-cell axes. This is how cell-level columns reach a clonotype-keyed p-frame.
  • A cell-keyed source cannot reach the clonotype. Authoring the clonotype component first reverses both directions, which makes the intended cell → clonotype enrichment appear.

A fourth test walks all six authoring orders and separates the two things this issue turns on: group membership is authoring-order independent; group position is not. The axes group correctly however they are written down, and position alone decides which component is treated as the one-side. No structural rule can recover the intended direction from the spec — it must be declared or read from data.

Verification

vitest run in lib/model/common — 112 pass across 9 files. ts-builder formatter, linter, and type-check all clean.

Two notes for a reviewer

The trunk in these tests carries its whole parent tree. Linker map keys come from getArrayFromAxisTree, so a cell trunk's key is [cellId, sampleId]. Passing cellId alone yields the key [cellId] and misses on key shape rather than on reachability. An early draft of these tests passed for exactly that wrong reason.

Group contents are compared as sorted sets, deliberately. getAxesGroups documents "There are no order inside every group" (linker_columns.ts:271), so asserting a specific order within a group would pin something the source disclaims. Only order between groups is pinned.

Separately: this package's test script passes --coverage but instruments nothing — it reports 0% with 112 tests passing. Pre-existing, and not addressed here.

Characterization tests for LinkerMap, mirroring the Rust LinkerIndex tests.
They assert today's behaviour, which is wrong, so they must flip when sides
become explicit.

The real cellLinker is authored [sampleId, cellId <- sampleId, scClonotypeKey].
getAxesGroups returns groups ordered by smallest contained index and
fromColumns destructures [left, right], so the sample/cell component becomes
the one-side. Consequences, both asserted:

- a clonotype-keyed source reaches cell-level axes (edges are stored
  many-side -> one-side), which is how cell-level columns end up in a
  clonotype-keyed p-frame;
- a cell-keyed source cannot reach the clonotype at all. Authoring the same
  linker with the clonotype first reverses both.

Also records the cross-engine parity expectation. Rust takes its component
ordering from disjoint::DisjointSet::sets(); this file's getAxesGroups derives
it from its own index scan. Two independent implementations that agree by
coincidence rather than by contract, and they have already drifted twice.
Keeping the two sides in sync is manual — a real shared fixture would need a
generated contract.

Note the trunk in these tests carries the whole parent tree: linker map keys
come from getArrayFromAxisTree, so a cell trunk keyed on cellId alone misses
for an unrelated reason.
Two problems with the tests added in 1ed1727.

A test whose name overstated it. "parity: TS groups the real cellLinker the
same way Rust splits it" asserted a strict subset of the test above it and
performed no cross-engine check of any kind — the two repos share no fixture.
Replaced with an exhaustive test that earns its place, and the parity rationale
is kept as prose where it belongs.

Assertions pinning order the source disclaims. getAxesGroups documents "There
are no order inside every group" (linker_columns.ts:271), but the tests
asserted group contents as ordered arrays. Now compared as sorted sets, so only
the order *between* groups is pinned — which is the part side assignment
actually reads.

The replacement walks all 6 authoring orders and separates the two things
MILAB-6651 turns on: membership is authoring-order independent, position is
not. That is why no structural rule can recover the intended direction — it
has to be declared or read from data.

Mirrors split_component_order_follows_authoring_order_for_every_permutation on
the pframes-rs side.
@notion-workspace

Copy link
Copy Markdown

@changeset-bot

changeset-bot Bot commented Jul 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c990c47

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 0 packages

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 52.89%. Comparing base (eac18ff) to head (5f5f16f).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1768      +/-   ##
==========================================
- Coverage   52.90%   52.89%   -0.01%     
==========================================
  Files         370      370              
  Lines       19994    19994              
  Branches     4435     4435              
==========================================
- Hits        10578    10576       -2     
- Misses       8110     8112       +2     
  Partials     1306     1306              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

check-changesets failed: the change touches a file under
lib/model/common/src, so changesets counts the package as modified and requires
a changeset. An empty one declares that no release is needed, which is accurate
— the tests add no API, behaviour, or type change.

Note the changeset has to be tracked by git. `changeset status --since` reads
changesets from git rather than the working tree, so an untracked file is
invisible to it and the check keeps failing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant