Skip to content

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()

  1. Partition — collects all elements from every source model into SourcedElement records, then partitions them into equivalence classes using the operator's equivalenceRelation. Uses the shared Partitions utility.
  2. Create — calls the operator's mergeFunction for each group. Each call produces Create* commands and must register merged ids in the supplied AliasRegistry.
  3. Link — reconstructs support edges by translating original endpoints through AliasRegistry.resolve(), with set-based deduplication. RegisterAlias commands are also emitted here to persist the alias map in the Unit.
  4. Additional — calls the optional additionalCommands hook, which operators override to inject synthesized elements and edges that have no counterpart in any source (e.g. the aggregating strategy in assemble).

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 a JustificationElement with its origin JustificationModel and SourceLocation. Used in Phases 1–2.
  • ElementGroup — a partition member: an immutable list of SourcedElements that belong to the same equivalence class.
  • AliasRegistry — a LinkedHashMap<String, String> from old element id to new id. resolve(id) returns id unchanged 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 by CompositionOperator and Unifier.
  • OperatorRegistry — a name-to-CompositionOperator map. Populated at compiler startup in CompilerFactory.builtInOperators(); looked up by ApplyOperator at model-build time.
  • ModelKind — two-value enum (JUSTIFICATION, TEMPLATE). ApplyOperator validates that the operator's resultKind() 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 in CompilationException.
  • 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 until unit.findModel(name) succeeds for every source name.
  • expand(Unit) — looks up the operator, validates resultKind, gathers source models, calls op.apply(…), then passes the result through Unifier.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

Merging a group that mixes element kinds. Because the default relation compares labels, a group can legitimately hold elements of different kinds: one source argued a claim (a sub-conclusion) while another still asserts it (an evidence). The merged element is built from the group's dominant member, not from whichever member appears first in the command list, so the result does not depend on the order the sources were listed in:

Group Merged element
sub-conclusion + evidence sub-conclusion — being both StrategyBacked and SupportLeaf, it supports whatever the evidence supported and can additionally carry the strategy that argues it
any other mix of kinds rejected with the incompatible-unification diagnostic, naming both elements and their shared label

Among members of the same kind the first one wins, and provides the merged element's label and source location. Nothing else is comparable: a conclusion is the model's single root, a strategy is the support rather than a supporter, and an @support placeholder is discharged by an explicit override, never by a merge. Use unifyExclude to keep colliding elements apart.

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.

uml diagram

The sequence below shows ApplyOperator.expand() processing one operator call and the post-composition unification pass.

uml diagram

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" in UnificationEquivalenceRegistry.
  • SameShortId — two elements are equivalent iff the suffix after the last : in their ids matches (e.g. a:sb:s). Used as the Phase 1 equivalence relation in CompositionOperatorTest.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.

uml diagram