Operators
The jpipe-operators module implements composition operators — the mechanism
by which new justification models are assembled from existing ones. It is
structured around three packages.
Packages
operators
The core framework. A composition operator transforms a list of source
JustificationModels into a command list that, when executed, builds the
result model. All operators extend the abstract CompositionOperator and
must only implement three hooks; the sealed apply() template method owns
the full four-phase algorithm.
Phases of apply()
- Partition — collects all elements from every source model into
SourcedElementrecords, then partitions them into equivalence classes using the operator'sequivalenceRelation. Uses the sharedPartitionsutility. - Create — calls the operator's
mergeFunctionfor each group. Each call producesCreate*commands and must register merged ids in the suppliedAliasRegistry. - Link — reconstructs support edges by translating original endpoints
through
AliasRegistry.resolve(), with set-based deduplication.RegisterAliascommands are also emitted here to persist the alias map in theUnit. - Additional — calls the optional
additionalCommandshook, which operators override to inject synthesized elements and edges that have no counterpart in any source (e.g. the aggregating strategy inassemble).
Phase 4 — automatic unification is applied by ApplyOperator.expand()
after apply() returns, via Unifier.unify(). It is not part of the
sealed template. See the Unifier subsection below.
Supporting types
SourcedElement— a record pairing aJustificationElementwith its originJustificationModelandSourceLocation. Used in Phases 1–2.ElementGroup— a partition member: an immutable list ofSourcedElements that belong to the same equivalence class.AliasRegistry— aLinkedHashMap<String, String>from old element id to new id.resolve(id)returnsidunchanged when no alias is registered, making Phase 3 edge rewriting safe for singleton groups.Partitions— package-private utility; the O(n²) representative-based partition algorithm shared byCompositionOperatorandUnifier.OperatorRegistry— a name-to-CompositionOperatormap. Populated at compiler startup inCompilerFactory.builtInOperators(); looked up byApplyOperatorat model-build time.ModelKind— two-value enum (JUSTIFICATION,TEMPLATE).ApplyOperatorvalidates that the operator'sresultKind()matches the kind declared in the source file.InvalidOperatorCallException— unchecked exception thrown for unknown operators, missing required arguments, incompatible kinds, or unknown unification methods. The engine wraps it inCompilationException.ModelReplicator— stateless utility that generates commands to copy a model's elements and edges under a given id prefix. Used to clone source content non-destructively.
ApplyOperator
ApplyOperator implements MacroCommand (from jpipe-model). It is emitted
by ActionListProvider whenever a justification or template declaration
carries an operator call in the source file. The engine defers its execution
until all named source models are present in the Unit.
condition()— defers untilunit.findModel(name)succeeds for every source name.expand(Unit)— looks up the operator, validatesresultKind, gathers source models, callsop.apply(…), then passes the result throughUnifier.unify()before returning the final command list.
Unifier
Post-processor invoked by ApplyOperator.expand() after op.apply().
Merges result-model elements that belong to the same equivalence class into
a single synthesized element whose id is "unified_N" (N = 0-based counter
per merged group). All original ids are aliased to the new id. AddSupport
commands referencing removed ids are rewritten and deduplicated.
Controlled by two optional operator config parameters:
| Parameter | Default | Meaning |
|---|---|---|
unifyBy |
"sameLabel" |
Name of the equivalence relation to use |
unifyExclude |
(empty) | Comma-separated result-model element ids to exclude from unification |
UnificationEquivalenceRegistry maps names to EquivalenceRelation
instances. It is populated at compiler startup in
CompilerFactory.builtInUnificationEquivalences(). SameShortId is
intentionally absent — it is reserved for Phase 1 operator equivalence only.
The sequence below shows ApplyOperator.expand() processing one operator
call and the post-composition unification pass.
operators.equivalences
Named implementations of EquivalenceRelation used in Phase 1 (operator
partitioning). Both are also available for use in UnificationEquivalenceRegistry
except where noted.
SameLabel— two elements are equivalent iff they share the same label string. Registered as"sameLabel"inUnificationEquivalenceRegistry.SameShortId— two elements are equivalent iff the suffix after the last:in their ids matches (e.g.a:s≡b:s). Used as the Phase 1 equivalence relation inCompositionOperatorTest.TestOperator. Not registered for unification.
operators.builtin
Built-in operator implementations. Each is registered in
CompilerFactory.builtInOperators() and receives a short name used in
.jd source files.
RefineOperator ("refine")
Syntax: justification R is refine(base, refinement) { hook: "base/elementId" }
Merges one element from base (the hook) with the conclusion of
refinement into a single SubConclusion whose id is "hook". All other
elements are copied with sourceName:elementId qualified ids. Requires the
hook argument in "modelName/elementId" form.
AssembleOperator ("assemble")
Syntax: justification A is assemble(s₁, …, sₙ) { conclusionLabel: "…" strategyLabel: "…" }
Demotes each source's Conclusion to a SubConclusion, wires all demoted
sub-conclusions through a synthesized aggregating Strategy (id:
"assembleStrategy"), and tops them with a synthesized Conclusion (id:
"assembleConclusion"). All other elements are copied with source-prefixed
ids. Result is a Template if any source is a Template.
Extension Points
Adding a new equivalence relation
An equivalence relation is used in two independent contexts:
- Phase 1 (operator partitioning) — determines which source elements are
merged during
CompositionOperator.apply(). - Phase 4 (post-composition unification) — determines which result-model
elements are merged by
Unifier.unify().
Whether a new relation is available in one or both contexts depends on where it is registered.
Step 1 — implement EquivalenceRelation
Create a class in operators.equivalences (or operators.builtin if it is
operator-specific):
package ca.mcscert.jpipe.operators.equivalences;
import ca.mcscert.jpipe.operators.EquivalenceRelation;
import ca.mcscert.jpipe.operators.SourcedElement;
public final class SimilarLabel implements EquivalenceRelation {
private final int maxDistance;
public SimilarLabel(int maxDistance) {
this.maxDistance = maxDistance;
}
@Override
public boolean areEquivalent(SourcedElement a, SourcedElement b) {
return levenshtein(a.element().label(), b.element().label()) <= maxDistance;
}
private static int levenshtein(String a, String b) { /* … */ }
}
Step 2 — register for unification
Open CompilerFactory.builtInUnificationEquivalences() and add:
registry.register("similarLabel", new SimilarLabel(2));
The string key ("similarLabel") is the value users write in unifyBy.
Do not register it in OperatorRegistry — that is for composition
operators only.
Step 3 — register for Phase 1 (optional)
If the relation should also be usable as a Phase 1 equivalence inside a
custom operator, simply pass it from that operator's equivalenceRelation()
hook. No global registry is needed for Phase 1.
Step 4 — test
Add unit tests in UnifierTest (for unification) and / or
CompositionOperatorTest (for Phase 1 use), then add a Cucumber scenario
in operators.feature that exercises the new key end-to-end.
Adding a new composition operator
A composition operator transforms a list of source JustificationModels into
a flat command list. Extending CompositionOperator requires implementing
exactly three abstract hooks; the four-phase algorithm is owned by the sealed
apply() method and must not be overridden.
Step 1 — extend CompositionOperator
Create a class in operators.builtin:
package ca.mcscert.jpipe.operators.builtin;
import ca.mcscert.jpipe.operators.*;
public final class MyOperator extends CompositionOperator {
// (1) Declare the result kind — JUSTIFICATION or TEMPLATE
@Override
public ModelKind resultKind() { return ModelKind.JUSTIFICATION; }
// (2) Required argument names (empty if none)
@Override
protected Set<String> requiredArguments() { return Set.of("myParam"); }
// (3) How source elements are partitioned into equivalence classes
@Override
protected EquivalenceRelation equivalenceRelation(
List<JustificationModel<?>> sources,
Map<String, String> args) {
return new SameShortId(); // Phase 1 only — not registered for unification
}
// (4) How each equivalence class is merged into a new element
@Override
protected MergeFunction mergeFunction(
List<JustificationModel<?>> sources,
Map<String, String> args) {
return (resultName, group, aliases) -> {
// Build Create* commands; call aliases.register(newId, oldIds)
// ...
};
}
// (5) The command that creates the result model itself
@Override
protected Command createResultModel(String name, SourceLocation loc,
List<JustificationModel<?>> sources,
Map<String, String> args) {
return new CreateJustification(name, loc);
}
// (6) Optional: synthesize elements/edges with no counterpart in any source
@Override
protected List<Command> additionalCommands(String name,
List<JustificationModel<?>> sources,
AliasRegistry aliases,
Map<String, String> args) {
return List.of(/* synthesized Create* and AddSupport commands */);
}
}
additionalCommands defaults to returning an empty list, so override it only
when the operator must inject elements that did not exist in any source.
Step 2 — register the operator
Open CompilerFactory.builtInOperators() and add:
operators.register("myOp", new MyOperator());
The string key ("myOp") is the name users write in .jd source files.
Step 3 — document the syntax
Operators are invoked with the syntax:
justification R is myOp(source1, source2) { myParam: "value" }
Document the required and optional arguments and the structural contract of the result model in a Javadoc comment on the class.
Step 4 — test
Add a Cucumber end-to-end scenario in operators.feature and at least one
.jd fixture in examples/ that exercises the new operator. Verify edge
cases such as missing required arguments (use the invalid/ fixture
directory) and unexpected model kinds.