Skip to content

Extending cuik-molmaker to reactions (cuik-reactmaker)#4

Open
akshatzalte wants to merge 18 commits into
NVIDIA-BioNeMo:mainfrom
akshatzalte:feat/cgr-reaction-featurization
Open

Extending cuik-molmaker to reactions (cuik-reactmaker)#4
akshatzalte wants to merge 18 commits into
NVIDIA-BioNeMo:mainfrom
akshatzalte:feat/cgr-reaction-featurization

Conversation

@akshatzalte

Copy link
Copy Markdown

Add batch_reaction_featurizer for CGR reaction featurization

What this PR adds

A new batch_reaction_featurizer function — the reaction analogue of batch_mol_featurizer — using the Condensed Graph of Reaction (CGR) representation (same as Chemprop's CondensedGraphOfReactionFeaturizer). API is consistent with the existing package:

atom_onehot = cuik_molmaker.atom_onehot_feature_names_to_array([...])
atom_float  = cuik_molmaker.atom_float_feature_names_to_array(['aromaticity', 'mass'])
bond_feats  = cuik_molmaker.bond_feature_names_to_array([...])
mode_int    = cuik_molmaker.reaction_mode_names_to_array(['REAC_DIFF'])[0]

V, E, edge_index, rev_edge_index, batch = cuik_molmaker.batch_reaction_featurizer(
    reac_smiles_list, prod_smiles_list,
    atom_onehot, atom_float, bond_feats,
    keep_h=True, add_h=False, offset_carbon=False, mode=mode_int,
)
# Returns 5 NumPy arrays — same convention as batch_mol_featurizer

Supported: all 4 atom featurizer modes (V1, V2, ORGANIC, RIGR) and all 6 reaction modes (REAC_DIFF, REAC_PROD, PROD_DIFF, and their _BALANCE variants). keep_h/add_h semantics match Chemprop's make_mol exactly.


Implementation notes

All new code is in src/reaction_features.cpp (701 lines); additions to features.h, one_hot.cpp, and cuik_molmaker_cpp.cpp. No existing code was modified.

Two design choices worth noting:

  • parse_rxn_side_mol does not clear atom-map numbers. The existing parse_mol strips them for reordering purposes; reaction featurization needs them for reactant↔product correspondence. Fully additive — molecule featurization is unchanged.
  • Bond lookup is O(bonds) via hash map (unordered_map<uint64_t, size_t> keyed by (min_idx << 32) | max_idx), replacing the Python CGR featurizer's O(n²) atom-pair scan.

Node ordering matches Chemprop exactly: reactant atoms 0..n_reac−1, then product-only atoms n_reac..n_cgr−1.


Correctness

Verified against Chemprop's Python CondensedGraphOfReactionFeaturizer on:

  • E2 dataset (1264 reactions, atom-mapped explicit H)
  • SN2 dataset (2362 reactions, includes [H-:2] lone-hydride nucleophiles)
  • 10 hand-crafted unbalanced reactions (num_only vs BALANCE divergence)

A test fixture CSV (tests/data/sample_rxns_100.csv, 110 reactions) is included. Golden .xz reference files can be committed once you've had a look at the design.

Speedup benchmarks present here

Implements C++ CGR (Condensed Graph of Reaction) featurization to
accelerate chemprop reaction property prediction (~9x over Python path).

New C++ code:
- src/reaction_features.cpp: batch_reaction_featurizer supporting all 6
  RxnModes (REAC_DIFF, REAC_PROD, PROD_DIFF and BALANCE variants) and all
  4 atom featurizer modes (V1, V2, ORGANIC, RIGR). Uses O(bonds) hash-map
  bond enumeration instead of O(n^2) atom-pair scan.
- src/features.h: ReactionMode enum, reaction_mode_names_to_array, and
  parse_reaction declarations; get_atomic_num_onehot_index helper for
  num_only representation of unmatched atoms
- src/one_hot.cpp/h: get_atomic_num_onehot_index for num_only encoding
- src/cuik_molmaker_cpp.cpp: exports reaction_mode_names_to_array and
  batch_reaction_featurizer to Python
- CMakeLists.txt: add reaction_features.cpp to cuik_molmaker_core sources

Test fixtures:
- tests/data/sample_rxns_100.csv: 100 balanced reactions (50 E2 + 50 SN2)
  plus 10 hand-crafted unbalanced reactions covering num_only and BALANCE
  mode edge cases. Verified against chemprop CondensedGraphOfReactionFeaturizer
  across all 6 modes with max_diff=0.
test_reaction_features.py: parametrized over all 4 atom featurizer versions
(V1, V2, ORGANIC, RIGR) × all 6 reaction modes (REAC_DIFF, REAC_PROD,
PROD_DIFF and BALANCE variants) = 24 test cases.

RIGR uses reduced bond features (["is-null", "in-ring"], bond_fdim=2 per
side = 4 total) unlike V1/V2/ORGANIC which use 5 bond features (bond_fdim=14
per side = 28 total).

Golden .xz files generated from C++ batch_reaction_featurizer output after
verifying agreement with chemprop CondensedGraphOfReactionFeaturizer
(max_diff=0 on E2/SN2 data across all 6 modes).
@akshatzalte

Copy link
Copy Markdown
Author

Hi @sveccham , just a friendly ping on the PR whenever you have a chance. I'd appreciate any feedback/comments before it is ready to merge.

@sveccham

Copy link
Copy Markdown
Collaborator

Hi @akshatzalte,

Thank you for your patience. I will have a look at this PR in the next few days. We are currently working on releasing v0.2.1.

@sveccham sveccham 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.

Hi @akshatzalte,
Thanks again for the PR. Very neat idea using a hashmap to reduce the problem complexity. This is also a very good use case for moving to C++.

I had a high level look at the PR and left a couple of initial comments. I had one question about the overall design. The CGR bond lookup is constructed once for each reaction in a batch. Would it be more computationally efficient to construct it for the entire batch (with appropriate atom and bond offsets) at once and exploit the O(1) lookup?

Comment thread src/cuik_molmaker_cpp.cpp Outdated
Comment thread tests/data/sample_rxns_100_ORGANIC_PROD_DIFF_BALANCE_ref.xz
@akshatzalte

Copy link
Copy Markdown
Author

Hi @sveccham ,
Thank you for your comments. I will resolve them over this weekend.

For the high-level design question, doing a batch level map construction won't be super advantageous. My current understanding is that merging into a single batch-wide table wouldn't change the number of lookups: since bonds only exist within a single reaction, the queries are all intra-reaction and their count is set by the pair scan rather than by how the table is partitioned. So a batch table would be queried the same number of times. It would be just larger and colder in cache, with some added offset bookkeeping.

If that's right, the bigger lever for the bond step might be the iteration strategy rather than the table's scope: replacing the O(atoms^2) pair scan with direct bond iteration (O(atoms + bonds)), still reproducing chemprop's exact edge ordering. I'd be very happy to prototype that and benchmark it if you think it's worth it. This would make the code look significantly different from the current python-only handling in chemprop and the real advantage will be visible for super large molecules only. Open to your thoughts on this.

Add a terse binding docstring and a CGR batch example in docs/USAGE.md
covering all 5 returned arrays, mirroring the existing batch_mol_featurizer
documentation. Addresses review feedback on the reaction return value.
The reaction regression fixtures in tests/data/sample_rxns_100.csv are
sampled from the RDB7 dataset (Spiekermann, K.; Pattanaik, L.; Green, W. H.
Scientific Data 2022, 9, 417), distributed under CC BY 4.0. Record the
required attribution in LICENSE/third-party.txt alongside the other
third-party notices.
Replace the reaction regression fixtures with 100 atom-mapped reactions
sampled from RDB7 (CC BY 4.0; see LICENSE/third-party.txt) and regenerate
all 24 golden references (V1/V2/ORGANIC/RIGR x 6 RxnModes). Reactions are
real published chemistry rather than the previous mixed/hand-crafted set.
Run the repo's pre-commit hooks over the branch's source files. Only the
reaction test module required reformatting (black list-wrapping + isort);
the C++ sources were already clang-format clean.
Comment thread src/reaction_features.cpp Outdated
Comment thread src/reaction_features.cpp Outdated
Comment thread src/features.h Outdated
Comment thread src/reaction_features.cpp Outdated
Comment thread src/reaction_features.cpp Outdated

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.

How were these reference files generated? It would be good to document the process in this codebase. Perhaps as a README file with some associated code. Open to your suggestions here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added tests/data/gen_reaction_refs.py, a single self-contained script that downloads the RDB7 source from Zenodo, samples the 100 reactions deterministically, and regenerates all 24 golden files. Added tests/data/README.md documenting the pipeline, provenance, and the cross-verification against Chemprop's CGR featurizer. I previously also included a license under third-party.txt to ensure we comply with the data license.

Happy to adjust the location or format if you prefer.

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.

So golden values should not be generated with the same codebase that we are testing. It would be great if you could document the process of generating golden values using Chemprop's CGR featurizer. I will try to reproduce the golden values following those instructions.
You can delete the tests/data/gen_reaction_refs.py

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Got it! Sorry I misunderstood your previous comment. I originally had the generation using Chemprop's CGR code but did not want to add that script as it adds new dependency to be able to run everything in this repo. I will document the recipe in a README and provide code snippets from my script so that the process is fully reproducible and unambiguous. Thank you for clarifying this!

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.

Does it take a significant amount of time for chemprop to generate these? If it doesn't pull too many dependencies in, would seem to make more sense to have it be a test dependency. I know since cuik-molmaker is a dep of chemprop, that would be circular if we put it in the source code, but for a dev test setup it doesn't seem to bad, compared to binary reference files, even if they're regeneratable.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thank you @scal444 for adding to this discussion.

It doesn't take that long for chemprop to generate these (just a few seconds). However, I don't think it's worth adding due to the weight of the dependencies. chemprop's base install pulls torch, lightning, pandas, scikit-learn and the CUDA runtime, which comes to 76 packages and roughly 5.5 GB, most of it torch plus CUDA. That is a lot to require in the test environment of an otherwise lean featurization package (today the reaction tests need only pytest, numpy and rdkit) to replace about 370 KB of frozen references.

So for now I am leaning toward keeping the committed goldens plus the documentation of fully reproducible recipe in the README. Happy to discuss further!

- bond-type-float: map bond order to chemically meaningful floats
  (aromatic -> 1.5, not the raw RDKit enum 12.0), matching the molecule
  featurizer in float_features.cpp.
- reaction mode: replace reaction_mode_names_to_array (array) with a scalar
  reaction_mode_to_int(str) that raises on an unknown name; guard
  batch_reaction_featurizer against invalid/UNKNOWN mode ints so a typo no
  longer silently produces REAC_PROD features.
- CGR layout: explicitly require the first one-hot atom feature to be an
  atomic-number block (atomic-number / -common / -organic) instead of
  assuming it silently, since that block is stripped from the CGR second half
  and used by num_only. All input validation runs before the parse loop.

Updates docs/USAGE.md and the reaction test to the new scalar API.
- Add tests/data/gen_reaction_refs.py: a single end-to-end reproducible
  generator that downloads the RDB7 wb97xd3.csv directly from Zenodo (record
  6618262), builds the forward+reverse reaction pool, samples 100 reactions
  deterministically (seed=7) into sample_rxns_100.csv, and writes the 24 golden
  .xz files (V1/V2/ORGANIC/RIGR x 6 reaction modes). Fully self-contained, no
  manual data setup.
- Add tests/data/README.md documenting the data provenance, the one-command
  regeneration pipeline, and the chemprop cross-verification result (bond
  features and edge indices exact; atom features within float32 round-off).
- Regenerate sample_rxns_100.csv and all 24 goldens from this pipeline.
- Correct the RDB7 attribution in LICENSE/third-party.txt to the record the
  data is actually sampled from (Zenodo 6618262, Sci. Data 2022, 9, 417).
@akshatzalte

Copy link
Copy Markdown
Author

Thank you for all your comments @sveccham! I think the current version resolves all of them.

We can discuss about adding more tests so that the molecular and reaction featurizers do not diverge by mistake (e.g. bond-type-float bug) but that can be a separate PR for better long term maintainability.

Comment thread src/reaction_features.cpp
// ---------------------------------------------------------------------------
// parse_reaction
// ---------------------------------------------------------------------------
CompactReaction parse_reaction(const std::string& reac_smi, const std::string& prod_smi, bool keep_h, bool add_h) {

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.

Atom-map uniqueness is not validated. Product maps overwrite at src/reaction_features.cpp:116, and reactant/product inverse maps overwrite at src/reaction_features.cpp:130. Duplicate atom-map numbers are accepted and produce arbitrary correspondence.
Leaving this comment here as it is the entry point.
Is the onus of passing the correct mapping on the user? If so, it would be good to document this.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yes, providing the correct atom-mapped reaction SMILES in user's responsibility. This is how we do it in Chemprop. I will document it similar to how we do it in Chemprop documentation.

Comment thread docs/USAGE.md Outdated
mode = cuik_molmaker.reaction_mode_names_to_array(["REAC_DIFF"])[0]

keep_h = True # keep explicit (mapped) hydrogens, e.g. [H:3]
add_h = False # add implicit hydrogens via RDKit AddHs (per reaction side)

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.

Is add_h=True intended for CGRs? An identity [CH4:1] >> [CH4:1] returns 9 nodes with add_h=True because RDKit-added H atoms are unmapped on both sides. If this is not intentional, I recommend disabling it or documenting it very explicitly.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This is the expected behavior. In my personal opinion, add_h=True is one of the most useless flags in chemprop. The most used is keep_h as it allows you to keep the hydrogen atoms already expressed in the atom-mapped SMILES provided by the user. I will document this in the code (along with your example as it makes the behavior clear). There may be some reactions where add_h may be used but it's definitely rare. I propose we still keep it to mirror the Chemprop functionality.

@sveccham

sveccham commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Thank you for all your comments @sveccham! I think the current version resolves all of them.

We can discuss about adding more tests so that the molecular and reaction featurizers do not diverge by mistake (e.g. bond-type-float bug) but that can be a separate PR for better long term maintainability.

Thank you! Left a couple of minor comments/requests for fixes. Some of your answers also helps me understand what is going on better.

- Note that batch_reaction_featurizer operates on atom-mapped reaction SMILES
  (Daylight SMILES/SMIRKS) and that providing a correct, unique mapping is the
  caller's responsibility (uniqueness is not validated).
- Explain keep_h (keeps explicit hydrogens already in the SMILES) vs add_h
  (adds new unmapped hydrogens), with a note that add_h hydrogens become phantom
  atoms in a CGR: the identity [CH4:1] >> [CH4:1] yields 9 nodes, not 5.

Documented in docs/USAGE.md and the batch_reaction_featurizer pybind docstring.
Address review feedback that golden values should not be produced by the code
under test. The reference .xz files are now documented as, and reproducible
from, Chemprop's CondensedGraphOfReactionFeaturizer + BatchMolGraph collation
(pinned to chemprop==2.2.4), with cuik-molmaker never imported during golden
generation.

- Rewrite tests/data/README.md to embed the full, self-contained generation
  recipe (env setup pinned to chemprop 2.2.4, exact dependency versions used,
  and the complete script) so the goldens are reproducible without ambiguity.
- Delete tests/data/gen_reaction_refs.py, the cuik-based generator.

The committed goldens are byte-identical whether produced by cuik-molmaker or
by stock Chemprop 2.2.4, so no golden values change and the regression test
still passes with an exact positional comparison (24/24).
- USAGE.md: the identity reaction [CH4:1] >> [CH4:1] with add_h=True yields 9
  CGR nodes (1 matched carbon + 8 unmatched hydrogens), verified against both
  cuik-molmaker and Chemprop 2.2.4. The previous "9 nodes, not 5" was
  misleading: no setting produces 5 (add_h=False yields a single carbon node).
- LICENSE/third-party.txt: point the RDB7 sampling note at tests/data/README.md;
  the referenced tests/data/gen_rdb7_sample.py never existed, and the actual
  generator has been removed in favor of the documented Chemprop recipe.
Describe the two flags in the general terms Chemprop uses (keep_h keeps
hydrogens already written in the SMILES; add_h adds new ones), and note that
add_h's hydrogens are unmapped and become phantom atoms in a CGR. Kept to a
short inline comment rather than a separate worked example.
The embedded golden-generation script had one print statement exceeding the
project's 88-character line limit. Wrap it so the recipe is black-clean if a
user runs it through the project's pre-commit hooks.
@akshatzalte

Copy link
Copy Markdown
Author

Thank you! Left a couple of minor comments/requests for fixes. Some of your answers also helps me understand what is going on better.

Hope this version addresses your comments and requests!

Your feedback helped me understand a few things better too. I originally started this because I was trying to use Chemprop CGR for one of my own projects and was frustrated by how slow it was. It turned into a fun weekend learning project, and extending cuik-molmaker felt like the obvious direction to take. Hopefully this makes it easier for people to train foundation models for reaction property prediction, things like yield, bond dissociation energies, barrier heights, solvation, etc.

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.

Does it take a significant amount of time for chemprop to generate these? If it doesn't pull too many dependencies in, would seem to make more sense to have it be a test dependency. I know since cuik-molmaker is a dep of chemprop, that would be circular if we put it in the source code, but for a dev test setup it doesn't seem to bad, compared to binary reference files, even if they're regeneratable.

Comment thread src/reaction_features.cpp Outdated
const RDKit::Bond* b = mol.getBondWithIdx(i);
gd.bonds[i] = CompactBond{uint8_t(b->getBondType()),
b->getIsConjugated(),
ringInfo->numBondRings(i) != 0,

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.

Is ringInfo guaranteed to be initialized by whatever pathway is calling this? Just checking, if not might want to form an error message.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good question. On the current paths it is always initialized: the only caller reaches populate_graph_arrays via SmilesToMol, which sanitizes by default and runs ring perception. I also confirmed it holds after add_h on ring systems (the appended C-H bonds come back as not-in-ring, with no out-of-range access). I added an explicit ringInfo->isInitialized() check that throws a clear error, so the precondition is enforced rather than assumed if a non-sanitizing path is ever added.

Comment thread src/reaction_features.cpp Outdated
gd.atoms = std::unique_ptr<CompactAtom[]>(new CompactAtom[num_atoms]);
for (size_t i = 0; i < num_atoms; ++i) {
const RDKit::Atom* a = mol.getAtomWithIdx(i);
gd.atoms[i] = CompactAtom{uint8_t(a->getAtomicNum()),

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.

Might want to use designated initializers here and elsewhere with CompactAtom/bond instantiation, helps reduce ordering bugs and improve readability.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed. Switched CompactAtom, CompactBond, and the two GraphData aggregates to designated initializers. These structs have several same-typed fields, so naming each one removes the risk of a silent positional swap. Thank you!

Comment thread src/reaction_features.cpp
@@ -0,0 +1,746 @@
// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

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.

General note - I know the data struct layout requires direct pointer math in some of these cases, but since the codebase is C++ 20, a subset of the raw pointer array stuff here could be more safely represented using std::span

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good call. I applied std::span<float> to the two clearest spots, build_num_only and fill_single_bond_feats

Comment thread src/reaction_features.cpp Outdated
// Build r2p / p2r maps; classify reactant atoms as matched or reac-only
std::unordered_map<uint32_t, uint32_t> r2p_idx_map, p2r_idx_map;
std::vector<uint32_t> reac_only_idxs;
r2p_idx_map.reserve(n_reac_atoms);

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.

For dense int->int mappings where the size is known and manageable, it'll likely be much more performant to use a vector, with a sentinel value indicating no entry. While std::unordered_map is definitely the clearer semantic choice, it separately allocates each value, so it's more expensive to populate and has worse cache usage on lookups.

Best of both worlds would be a flat map, with map semantics and vector storage - if this ever bumps to C++23, that's included in the standard library, or cuik_molmaker could pull in one of several third party flat map implementations. That would also be able to optimize your bond lookup table, which currently can't trivially be ported to vector like r2p and p2r. For now I'd suggest using a vector for just r2p and p2r.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done for r2p and p2r. Both are dense over 0..n-1, so they are now std::vector<uint32_t> initialized to a NO_IDX sentinel, which avoids the per-node allocation and is more cache-friendly on lookup. I left the two bond-lookup maps as hash maps since they are keyed on packed atom pairs and are not densely enumerable. One thing I fixed along the way: p2r is indexed by product atom index, so it now sizes to n_prod_atoms (the previous reserve used n_reac_atoms, which was fine for a hash map but would undersize a vector when the product has more atoms than the reactant).

… parse

Addresses two review nits in populate_graph_arrays:

- Use C++20 designated initializers for CompactAtom, CompactBond, and the
  two GraphData aggregates. These structs have several same-typed fields, so
  positional init could silently reorder them; naming each field guards
  against that class of bug.
- Assert ringInfo->isInitialized() before calling numBondRings(). SmilesToMol
  sanitizes by default so ring perception has always run on the current paths
  (verified across add_h=True on ring systems); the guard makes the
  precondition explicit and fails loudly if a non-sanitizing path is ever added.

No behavior change: all 24 reaction goldens and the full python suite pass.
The reactant->product and product->reactant atom-index maps are dense over
0..n-1, so replace the unordered_map<uint32_t,uint32_t> with std::vector<uint32_t>
initialized to a NO_IDX sentinel. This avoids per-node heap allocation and gives
contiguous, cache-friendly lookups on the hot CGR path. The two bond-lookup maps
are left as hash maps (keyed on packed atom-pair values, not densely enumerable).

p2r is now correctly sized to n_prod_atoms (it is indexed by product atom index);
the previous reserve() used n_reac_atoms, which was harmless for a hash map but
would be undersized for a vector on reactions where the product has more atoms.

No behavior change: all 24 reaction goldens are byte-identical, and asymmetric
reactions (product larger/smaller than reactant, plus add_h phantom Hs) run
correctly across all six modes.
Replace the (float* buf, size_t len) pair with a single std::span<float> in
build_num_only() and fill_single_bond_feats(). The span carries the length with
the pointer, so call sites pass the backing std::vector directly and can no
longer pass a mismatched length. Narrow, targeted use of C++20 span; the
resizing feature buffers and tight inner diff loops are left as raw pointers.

No behavior change: all 24 reaction goldens remain byte-identical.
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.

3 participants