Skip to content

chore: add indictment support to Bavet's Node Network - #2528

Draft
Christopher-Chianelli wants to merge 17 commits into
TimefoldAI:mainfrom
Christopher-Chianelli:feat/729
Draft

chore: add indictment support to Bavet's Node Network#2528
Christopher-Chianelli wants to merge 17 commits into
TimefoldAI:mainfrom
Christopher-Chianelli:feat/729

Conversation

@Christopher-Chianelli

Copy link
Copy Markdown
Contributor

Each tuple now have an IndictmentSource field. This field is set to IndictmentSource.DISABLED when indictments are disabled, which is used to exit early and avoid work only required for indictments. ConstraintMatchPolicy have two new values:

  • ENABLED_WITHOUT_JUSTIFICATIONS_AND_INDICTMENTS this has the previous behaviour of ENABLED_WITHOUT_JUSTIFICATIONS; has constraint matches but no justifications nor indictments
  • ENABLED_WITHOUT_INDICTMENTS this includes justifications but not indictments

ENABLED was changed to include both justifications and indictments instead of just justifications. ENABLED_WITHOUT_JUSTIFICATIONS was changed to include indictments.

The actual work each node does is pretty simple. Do note that the attach IndictmentSource does not change after an insert:

  • forEach: if indictments are enabled, set the source of the tuple to a RootIndictmentSource (perhaps LeafIndictmentSource would be a better name) on insert. Retract and update do no extra work; retract removes the tuple (and hence the indictment source and potential constraint match), and update does not change the instance tracked.
  • filter: not a node, and does not affect what object is indicted.
  • map/expand/flatten: these creates new tuples from a source tuple, and simply copy the source's indictment source on insert. Update and retract do not need to do anything following the same reasoning as forEach`.
  • join: on insert, set the indictment source of the joined tuple to a JoinedIndictmentSource that have a reference to both the left and right tuples' indictment sources.
  • groupBy: on insert, set the indictment source of the group's tuple to an aggregation containing the indictment sources of the tuples inside the group. On update, remove the tuple's indictment source from the old group aggregation and add it to the new group aggregation. On retract, remove the tuple's indictment source from the aggregation.
  • distinct: this is groupBy in a trenchcoat and behaves similarly.
  • concat: behaves similarly to map/expand/flatten, since the left side and right side act independently and don't affect each other.
  • ifExists/ifNotExists: The most complex of the bunch. When indictments are enabled, it takes a drastically different path than normal. In particular, right tuple will propagate updates even if the corresponding left tuple is not update. This is to prevent constraint matches with stale indictments referring to removed/retracted entities/problem facts. Additional tuple iteration occurs for indexed ifExists nodes so the tuple's indictments are up to date when the left tuple changes. Moreover, since ifExists does not create a new tuple but still adds to the indictment, the same indictment source is used, but the indictment source's support map for the key corresponding to the ifExists node's id is updated.
  • precompute: Since it uses an independent node network, its node's ids do not correspond to the outer node network's node ids. As such, the indictment support map of the produced tuples must be aggregated along with the original indictment source.

Each constraint is now aware of the ids of the nodes that affect the constraint. It uses it when creating constraint matches to iterate through the support map so it does not indict objects from other constraints with ifExists that so happen to share the same tuple. Each IndictmentSource is iterated in a tree like matter to collect a set of unique indicted objects which is converted to a list inside the actual ConstraintMatch.

The vast majority of the changes were making all existing ConstraintStream tests aware of indictments. assertMatch was changed so it sets the indictment list to the justifications, which work for some but not all tests. For the other tests, withIndictedObjects is used to set the expected indicted objects for each match.

Note: I am aware of the stale "NOTE: By not propagating here, ..." comment in IfExistsNode and will remove it.
Note: I am aware that the code under if (testFiltering(...)) in IfExistsNode should do a branch depending on if indictments are enabled and call the right method (was written before the change to keep the indictments up to date and the two methods were not too different from each other).
Note: I am aware the carnality and storeSize methods added to Tuple can be removed; they are an artifact from when tuple cloning were used for ifExists's indictment enabled path.

This allows support infomation to be automatically
passed when a new tuple is created (ex: in map/expand).
…d, add notes about stale ifExists indictments
…oesn't update, make PrecomputeUni tests aware of indictments
… if exists, aggregate support for precompute, make BiPrecomputeTest Indictment aware
@sonarqubecloud

Copy link
Copy Markdown

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

First read-through. An elegant solution, was expecting this to be more code; not happy with some bits, though. Let's try to adjust if we can.

Before we move further though, I believe we need to have some clarity on how stable this is. We have the turtle tests - let's run them on this. (They may need some ad-hoc adjusting to be able to run with indictments.) If at the same time we can gather move speed data from them, we can compare to non-indictment runs and have a decent idea as to how much slower this will be.

Comment on lines 75 to +76
var created = createTuple(originalTuple, bag.value);
created.setIndictmentSource(originalTuple.getIndictmentSource());

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.

Cases like this would benefit from inheriting the indictment source at construction time; the mutability aspect will probably be necessary in some places (haven't yet read the entire thing), but I think the default pattern here should not happen post-construction.

if (tuple.getIndictmentSource() != IndictmentSource.DISABLED) {
if (outTuple.getIndictmentSource() == IndictmentSource.DISABLED) {
outTuple.setIndictmentSource(
new IndictmentSource.AggregateIndictmentSource(new ArrayList<>(), new LinkedHashMap<>()));

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.

This is inconsistent with the otherwise seemingly standard pattern of having a named static factory for these operations.

*/
public ConstraintMatch(ConstraintRef constraintRef, @Nullable ConstraintJustification justification, Score_ score) {
public ConstraintMatch(ConstraintRef constraintRef, @Nullable ConstraintJustification justification,
@Nullable List<Object> indictedObjects, Score_ score) {

@triceo triceo Jul 23, 2026

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.

I wonder if - instead of dealing with null here - we instead want to use an empty immutable collection here.

return switch (scoreAnalysisFetchPolicy) {
case FETCH_MATCH_COUNT, FETCH_SHALLOW -> ENABLED_WITHOUT_JUSTIFICATIONS;
case FETCH_MATCH_COUNT, FETCH_SHALLOW -> ENABLED_WITHOUT_JUSTIFICATIONS_AND_INDICTMENTS;
case FETCH_ALL -> ENABLED;

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.

You could argue this is not backwards compatible. People will get indictments where previously they did not.

Not sure what I think about this yet.

}
}

public void setNodeIds(long[] scorerNodeIds) {

@triceo triceo Jul 23, 2026

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.

Not excited that we're effectively creating more and more shared mutable state. Let's try to keep these things final and supplied at construction time. These "half-initialized" lazy objects make reasoning about the functionality/interactions significantly more difficult.

var abstractConstraint = (AbstractConstraint<?, ?, ?>) constraint;
var involvedNodeIds = Objects.requireNonNull(abstractConstraint.getInvolvedNodeIds());
tuple.getIndictmentSource().visitSources(involvedNodeIds, out::add);
return new ArrayList<>(out);

@triceo triceo Jul 23, 2026

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.

I wonder if we need this to be a list. (It used to be one in the old days.) But what would be the actual benefit/need? A SequencedSet works equally well IMO, and avoids this inefficient copy.

SequencedCollection? I'm thinking that List.of(...) may be useful for testing, but new LinkedHashSet<>() is best for performance.

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