Skip to content

Add test-time tensor shape contracts - #748

Draft
jchmura-sc wants to merge 5 commits into
mainfrom
jchmura/jaxtyping-shape-contracts
Draft

Add test-time tensor shape contracts#748
jchmura-sc wants to merge 5 commits into
mainfrom
jchmura/jaxtyping-shape-contracts

Conversation

@jchmura-sc

@jchmura-sc jchmura-sc commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Purpose of this PR

This PR adds targeted, runtime-checkable tensor contracts for: loader and sampler inputs, public model forward and decode methods, loss interfaces, and task-result containers.

Jaxtyping lets an annotation declare tensor dtype, rank, fixed dimensions, and relationships between named dimensions. For example, Float[Tensor, "queries embedding_dim"] and Float[Tensor, "candidates embedding_dim"] require matching embedding_dim; decoder output Float[Tensor, "queries candidates"] then documents both output axes. This makes malformed tensors fail close to the boundary.

The contracts are intentionally test-only. Unit, integration, and E2E launchers install a Jaxtyping hook before test discovery. Listed modules imported afterwards are instrumented; arguments are checked before execution and returns afterwards. An uncaught violation raises jaxtyping.TypeCheckError, which fails the test command. Production execution does not enable this mechanism, and this PR does not expose it as a user API.

Why this is useful:

  • documents the tensor contracts agents and users must satisfy
  • checks dtype, rank, fixed axes, and repeated named-axis equality in exercised code paths
  • caught the previously undocumented ABLP label contract: labels are padded [anchors, labels_per_anchor], not flat vectors

Potential downsides:

  • Developers must know a boundary’s rank and axis meaning. Unknown numeric sizes are fine: named axes bind runtime values. If rank or semantics are unclear, do not guess a contract.
  • Incorrect or overly strict contracts create false test failures and add maintenance during API changes.

Note:

  • low-level message-passing operations remain uncontracted because they do not expose one stable tensor shape
  • dictionary-valued contracts validate tensor values, but cannot express equality by matching dictionary key (e.g. happens a lot for heterogeneous graph data structures)

@jchmura-sc jchmura-sc self-assigned this Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These text fixtures were flagged as having invalid shapes w.r.t. to the prod path. Updates here pass shape checks.

enable_residual_topup: bool = True,
num_neighbors_per_hop: int = 100_000,
degree_tensors: Union[torch.Tensor, dict[NodeType, torch.Tensor]],
degree_tensors: Union[

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An example of where the union type is actually encoding two different shapes without a requirement that they match: the homogenous type (first type) and heterogeneous (second type) need not be equal.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case, the shape of all nodes in the dict shall not be equal, e.x. {"a": torch.ones(2), "b": torch.ones(5)} is valid an expected.

What's the jaxtyping behavior here?

@jchmura-sc jchmura-sc Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I understand it, typing the example like dict[str, Float[Tensor, "nodes"]] would force all values in the dictionary to have matching shape.

We specifically use _nodes (note the underscore _) which effectively skips any shape checks for the type. This allows us to communicate the expected semantics without actually enforcing a shape check (since they need not match).

self, query_embeddings: torch.Tensor, candidate_embeddings: torch.Tensor
) -> torch.Tensor:
return query_embeddings + candidate_embeddings
return torch.mm(query_embeddings, candidate_embeddings.T)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This mirrors the production decoder: [queries, embedding_dim] @ [embedding_dim, candidates] returns pairwise [queries, candidates] scores. Addition returns embeddings, not decoder scores.

@kmontemayor2-sc kmontemayor2-sc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neat! Thanks for exploring Jacob :) I left some comments :)

I guess for this we'd need to be careful that we only enable runtime shape checking for tests?

enable_residual_topup: bool = True,
num_neighbors_per_hop: int = 100_000,
degree_tensors: Union[torch.Tensor, dict[NodeType, torch.Tensor]],
degree_tensors: Union[

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case, the shape of all nodes in the dict shall not be equal, e.x. {"a": torch.ones(2), "b": torch.ones(5)} is valid an expected.

What's the jaxtyping behavior here?

Comment on lines 25 to 48
@@ -37,11 +42,15 @@ def __init__(
self._negative_label_by_edge_types = negative_label_by_edge_types

@property
def positive_label_by_edge_types(self) -> dict[EdgeType, torch.Tensor]:
def positive_label_by_edge_types(
self,
) -> dict[EdgeType, Int[torch.Tensor, "anchors positive_labels_per_anchor"]]:
return self._positive_label_by_edge_types

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case, is anchors bound per instance of ABLPSamplerInput?

e.x.

in1 = Input(node: torch.ones(10), pos: {e: torch.zeroes(10, 2))
in2  = Input(node: torch.ones(10), pos: {e: torch.zeroes(10, 3))

Can we later distinguish between the label size for different objects?

My question about dict key sizing from above still stands.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good question. Jaxtyping bindings are scoped to single call so your sample code would pass. This property in particular is essentially just validating a 2d integral output tensor.

Jaxtyping does not do any analysis across function calls and object lifetimes. It only validates between input and output tensors of the same function call.

For something that can check across function calls, I think we'd need to look into static type checks like pyrefly which has an experimental feature for tensor shape checks.

Comment thread tests/integration/main.py

from tests.test_assets.runtime_type_checking import install_runtime_typechecking

install_runtime_typechecking()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm, in some of our tests we fork / spawn new processes, would we need to call this again in those?

https://github.com/Snapchat/GiGL/blob/main/tests/unit/distributed/distributed_weighted_sampling_test.py#L415

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd have to double check! We specifically install the runtime type check in test module main, so the spawned process should end up re-running it automaitcally since its at module scope

from beartype import beartype
from jaxtyping import AbstractArray, install_import_hook

_SHAPE_CONTRACT_MODULES: Final[tuple[str, ...]] = (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm, so we'd need to add this for all source modules we want to test?

Do you think there's a way to enable this for all gigl/ and examples/ and then see if the calls seem as expected?

E.g. add some new make check_tensor?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should be able to directly put package prefixes to make this more maintainable.

It's important to realize that this is a runtime type checker, so only the code that runs is checked. So having make check_tensor is nice, but in practice we need something to actually run (e.g. the tests) in order for it to do anything.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants