Skip to content

#3838 introduce to ket format haptic bond support - #3849

Open
NikolaiBalabanov wants to merge 12 commits into
masterfrom
3838-Introduce-to-KET-format-haptic-bond-support
Open

#3838 introduce to ket format haptic bond support#3849
NikolaiBalabanov wants to merge 12 commits into
masterfrom
3838-Introduce-to-KET-format-haptic-bond-support

Conversation

@NikolaiBalabanov

@NikolaiBalabanov NikolaiBalabanov commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Update Ket format loader/saver to support new model including Attachment Group.

Generic request

  • PR name follows the pattern #1234 – issue name
  • branch name does not contain '#'
  • base branch (master or release/xx) is correct
  • PR is linked with the issue
  • task status changed to "Code review"
  • code follows product standards
  • regression tests updated
  • unit-tests written

A molecule node declares the attachment groups whose atoms it holds, and every
haptic bond is a connection of the root keyed by `type` rather than by the
`connectionType` the monomer connections use, so the haptic branch is taken before
that logic. Ids are strings while the member atom list is numbers, as the contract
requires; a duplicate id, an unknown group, an out-of-range atom or a value of the
wrong JSON type is rejected with a message naming the offender.

One connection maps to one HapticBond, in both directions. The pairs an endpoint may
not form are left to addHapticBond, which guards them for every producer rather than
for this loader alone.

Groups are parsed after the merge and added straight to the target molecule through
that merge's atom mapping, so a KET group id resolves to a group index without
assuming anything about the order in which the merge hands indices out. On saving
they are distributed over the nodes after the component split and the connections are
written from the whole molecule, because a haptic bond crosses that split: a ring and
the metal it binds are separate fragments of the graph. A group whose atoms did not
all reach one node is refused outright instead of being written with -1 members.

A variable attachment (#3731) shares the container with the other type; KET has no
representation for it, so it is dropped rather than written as a haptic bond.

A connection without `connectionType` is rejected with a message in the monomer
document loader, where a haptic one has no meaning, instead of being read as an
absent member.
Six defects, all of the same family: a contract that lived in a comment instead of
in the code, so nothing stopped a caller from breaking it.

Removing an attachment group is the molecule's business alone - the haptic bonds
that address it have to go with it, and the container knows nothing of them. The
comment said so; now the method is private with BaseMolecule as its only friend,
because the pool hands a freed index straight back and a bond left behind would
attach itself to the next group to take that index.

addHapticBond is where a group meets the molecule, and it was the one place that
could have noticed a member atom nobody owns - the group takes any int. It now
does: an index that is not a vertex would otherwise reach the connectivity sets and
every consumer of them, down to getAtomXyz in the renderer.

SKIP_ALL stopped meaning "skip all" when the two new flags went past 0xFF, so
dearomatization and aromatic matching were dragging groups and bonds into the
temporary molecules they build. An atom-to-atom bond, meanwhile, was being dropped
by SKIP_ATTACHMENT_GROUPS, a flag about groups it does not have.

Both merges read the pool as they walked it, so on a merge with itself a copy
landing in a freed slot below end() was taken for a source of its own and copied
twice. The sources are now listed before anything is added.

The decomposition cache recorded "computed with external neighbours" by looking at
the first set alone: an s-group without atoms contributes an empty one, and the
whole decomposition was then recomputed on every call of the loop in the KET saver.

The KET saver kept _mappings, _atom_to_mol_id and _monomers_enum across calls while
restarting the node numbering at zero, so a second save on the same instance would
have addressed nodes of the first.
The decomposition cache was the sharp one. A haptic bond holds atoms together
without an edge, so it is the first thing that changes the answer of
countComponents() while the graph, which drops the cache whenever a vertex or an
edge moves, sees nothing happen at all: ask once, add or remove a bond, ask again,
and the second answer is the first one. Graph now offers invalidateComponents() for
exactly this, and the two entry points that edit haptic data call it. The test
fails without the call.

A template atom stands for a whole monomer and is written as a node of its own, so
no format can address it as an end of a haptic bond, nor as a member of a group.
Such a molecule used to load and then fail on saving with "Atom %d not found";
addHapticBond now refuses it where the mistake was made.

Filling a group went through addAtom(), whose linear search for a duplicate made
building one quadratic in the length of the list - and that list comes from a KET
file, which the HTTP service takes from anywhere. setAtoms() filters in one pass
instead, and the loader hands it the whole list rather than adding atom by atom.
The DoD asks that a ferrocene reactant arrive as one component instead of
three. Both connectivity sites were wired in #3837, but nothing exercised
the reaction loader, so the guarantee rested on reading the code.
The reader and the writer of a KET field each spelled its name inline, in
four files. That is how the highlight round-trip broke: the saver writes
"entityType" as "atom"/"bond" and the loader compares against "atoms"/
"bonds", so highlights Indigo writes Indigo does not read. The names the
haptic feature touches now come from ket_keys.h; the rest of the KET
vocabulary is a migration of its own.

Two things the review found on the ids themselves:

atoi() read a non-numeric "atomId" as 0 and bound the endpoint to the
first atom of the molecule instead of failing, and the stoi() inside
extract_id() throws a std::exception rather than an Indigo one on a
malformed "moleculeId". Both arrive from outside through the HTTP
service. They go through one strict parser now.

A duplicate group id was rejected only after the group had been added to
the molecule; the check moved ahead of the work.

Also drops the attachmentGroupRef() search in saveAttachmentGroups: the
KET id of a group is its position in the list that loop already walks.
The group carried the charge and the radical of the star atom it absorbed, so that
they would not be lost. The star is no longer absorbed: it stays an ordinary
pseudo-atom of the graph, exactly as it is today, and its charge stays on it. There
is nothing left for the group to carry.

That also settles what the atom-charge sum of a ferrocene is. With the star kept,
`ferrocene-variant5.mol` still adds up to zero, the way master reports it now, and
the reference output changes only where the feature actually changes something -
the connectivity that ENDPTS used to lose.

A group is a set of atoms and nothing else.
The comment policy of Task/3233 allows four kinds: an invariant a type cannot
express, a pointer to an external source of truth, a trap confirmed by a test,
and a short file header. Everything else belongs in the commit message or the
Task documents. The keys header broke that worst: thirteen lines defending the
decision, all of it already in the previous commit message and in
CODE-REVIEW-3838.md, plus section banners restating the constant names.

Also drops the ket:: namespace that made every use read ket::KetConnections.
The codebase already names KET strings this way in monomers_defs.h
(KetConnectionSingle, KetConnectionHydro) with no namespace, and the two
awkward ...GroupIdField / ...GroupAtoms names become KetGroupId / KetGroupAtoms.

One comment was duplicated verbatim in molecule_attachment_groups.cpp and
molecule_haptic_bonds.cpp - two copies of one explanation drift apart, which is
exactly what the policy warns about. Both are shortened to a single line.
@NikolaiBalabanov NikolaiBalabanov linked an issue Aug 19, 2026 that may be closed by this pull request
@NikolaiBalabanov NikolaiBalabanov changed the title 3838 introduce to ket format haptic bond support #3838 introduce to ket format haptic bond support Aug 19, 2026
The previous pass covered only what this ticket wrote. These two headers came
with the model in #3837 and carry the same faults, so they are cleaned here
rather than left as the odd ones out.

The worst of it was repetition: molecule_attachment_groups.h stated "the haptic
bonds address groups by index, so they have to be told" four times - in the
class header, above removeGroup, above onAtomsRemoved and above
mergeWithSubmolecule. Four copies of one sentence drift apart; it is stated once
on the class now, and each method documents only its own contract.

The rest is the usual: a class comment defending a design instead of naming an
invariant, "Reusable: restore the default-constructed state" restating the
method it sits on, and two sentences that could not be parsed on first reading -
the ket_document guard and Graph::invalidateComponents.

Traps stay untouched, including the one that earns its lines: the group pool
recycles freed indices, so a haptic bond outliving its group silently reattaches
to whatever takes that index next.
A reviewer read the constants as class names, and fairly: Ket* is what the
classes of ket_objects.h are called - KetConnection, KetConnectionEndPoint,
KetMolecule, KetDocument and six more. KetConnections differed from the class
KetConnection by one letter.

Three things are now visible from a name alone.

Keys carry a Field suffix; values do not. monomers_defs.h already holds
KetConnectionSingle and KetConnectionHydro, which are values of connectionType,
and the old KetConnectionType - a key - was indistinguishable from them.

KetConnectionKind and KetConnectionType were the worst pair: kind and type are
synonyms, so which one meant "type" and which meant "connectionType" could only
be learned by looking. Worse, "type" is not a connection key at all - it labels
molecule nodes, rgroups, monomer templates, query components, atom lists and
S-group types. It is KetTypeField now, named for what it is.

The keys a group object shares with the rest of KET are likewise generic:
KetGroupId and KetGroupAtoms claimed a scope they do not have and became
KetIdField and KetAtomsField. KetMoleculePrefix is not a key at all, so it says
so: KetMoleculeRefPrefix.
Comment thread core/indigo-core/molecule/src/ket_document_json_loader.cpp Outdated
Comment thread core/indigo-core/molecule/src/ket_document_json_loader.cpp Outdated
Comment thread core/indigo-core/molecule/src/molecule_json_loader.cpp Outdated
Comment thread core/indigo-core/molecule/base_molecule.h Outdated
The accumulator in parseNumericId was a `long`, so on LLP64 - Win64, the
platform this is built and tested on - it is 32 bits and the overflow guard
`value > INT_MAX` could never be true. An atomId of "4294967297" wrapped to 1
and bound itself silently to the second atom, which is the exact failure the
strict parser was written to prevent. It is a `long long` now, with a test that
fails on the old type.

The key constants take the KetKey prefix the reviewer suggested. It reads better
than the Field suffix it replaces: KetKeyEndpoint1 cannot be confused with the
class KetConnectionEndPoint, all keys group together under one prefix, and
values keep the bare form of KetConnectionSingle. The suffix `Key` was not an
option - monomers_defs.h already spells map lookup keys MonomerKey and
NucleotideKey, and those are type aliases.

The "connectionType" spelled inside an error message now comes from the constant
that the check above it already used.

SKIP_ALL moves to the end of the enum and is the OR of every flag rather than a
hex literal, so a new flag is added on the line below itself instead of by
recounting 0x3FF.
Error messages quoted identifiers with escaped double quotes. The codebase
quotes them with single ones - 77 such messages across core, and not one of the
other form - so ours were the only ones of their kind, and they reach users
through the HTTP service. No test asserts on the quotes, so the change is safe.

The fixture returned nothing and one test then reached for atom 10 with a
comment saying it was the iron. makeFerrocene returns the metal index now; a
magic number explained by a comment is the comment doing a refactoring's job.
The reviewer asked for `long long` because `long` is 32 bits on LLP64 and the
overflow guard could never fire there. That fixes the defect, but it leaves the
arithmetic in place, and the arithmetic is what went wrong: the guard sat after
the multiply, so any 32-bit accumulator wraps before it can be consulted.
`unsigned int` does not help either - it makes the wrap defined rather than
undefined, and "4294967297" still comes back as atom 1; the test built for this
fails on it.

std::from_chars removes the accumulator, so there is no overflow left to reason
about. It is already used in the codebase and already linked into indigo-core
through molecule_cdxml_loader.h, so it reaches every target including WASM.
Checked against the compiler of the CI image, g++ 10.2.1: an out-of-range value
reports result_out_of_range, "12abc" parses as 12 and is caught by ptr != end,
and "-1" parses successfully and is caught by value < 0 - both guards carry
weight rather than decorate.

Neither Scanner::readInt nor extract_id could be reused: the first parses with
sscanf("%d"), which is undefined on overflow, and the second throws a std
exception rather than an Indigo one.

OnlyAPlainDecimalIsAcceptedAsAnAtomId pins what the parser takes, since swapping
a hand-written loop for a library call changes what counts as malformed.
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.

Introduce to KET format haptic bond support

2 participants