Skip to content

Robust Context merge: rename, scope, conflict policy #1024

@crowlogic

Description

@crowlogic

Problem

Context merging is currently fragile and has caused recurring bugs (at least 10 instances) when one Context's bindings need to be visible in another Context's compilation/instantiation. The latest manifestation is #1021: the OPS Wheeler recurrence had to thread the Riccati muntz.context all the way down through three constructor layers because Context.registerFunction("m", m) discards the source Expression on the mapping, and any inner self-instance allocated lazily by the compiled class has no way to resolve m from the OPS-local context.

Context.mergeFrom(Context) exists but only handles the easy case: same name → same instance is coalesced; same name → different instance throws CompilerException. There is no rename, no scope qualification, no conflict-resolution strategy. Callers are forced to either (a) thread one context through every constructor (current OPS workaround), (b) accept the failure mode, or (c) write ad-hoc copy loops at every site.

Root Cause: Context Has No Package Identity

The fundamental problem is that Context has no notion of identity as a mathematical object — it is a flat bag of name→binding mappings with no scope hierarchy and no way to express "these bindings form a coherent unit." Merge is fragile because there is no first-class concept of a context package that defines the boundary of what merges atomically. Until Context has that self-knowledge, mergeFrom is just string-matching on a flat map.

This must be solved before the merge semantics in the sections below, and independently of any disk-caching of compiled classes (that is a downstream consequence, not a prerequisite).

Prerequisite Fix: Context.packageName + Expression.internalName()

Step 1 — Add packageName to Context

public String packageName = null;

public Context(String packageName) {
  this();
  this.packageName = packageName;
}

Context callers that represent named mathematical objects (e.g. Jacobi polynomials, Riccati solvers) pass the package at construction:

context = new Context("arb.jacobi");

Step 2 — Add internalName() to Expression

Expression.className stays as the short name ("P", "a", etc.) — that is what the functions map, FunctionMapping.functionName, and all cross-expression symbolic references use. A single derived accessor computes the bytecode-level identity:

public String internalName() {
  return (context != null && context.packageName != null)
    ? context.packageName.replace('.', '/') + "/" + className
    : className;
}

Step 3 — All ASM emit sites use internalName() instead of className

Every ClassWriter.visit(...), visitTypeInsn(NEW, ...), visitFieldInsn(..., owner, ...), visitMethodInsn(..., owner, ...), and every field descriptor of the form "L" + className + ";" in Expression.generate() must use internalName() instead of bare className. Since Expression owns the ClassWriter, all these sites are local to it.

Step 4 — registerBytecodes is called with internalName()

Wherever Expression.generate() calls classLoader.registerBytecodes(className, bytecodes), change to classLoader.registerBytecodes(internalName(), bytecodes). Now pendingBytecodes is keyed by e.g. "arb/jacobi/P".

Step 5 — Normalize the key in ExpressionClassLoader.findClass

The JVM calls findClass("arb.jacobi.P") (dots). pendingBytecodes is keyed with slashes. One normalization line fixes it:

byte[] bytecodes = pendingBytecodes.remove(name.replace('.', '/'));

Result

className remains the short symbolic name used throughout expression logic. internalName() is the bytecode-level identity. Two contexts with different packageName values can never have a genuine class identity clash — arb.jacobi.P and arb.riccati.P are different classes. This gives the merge semantics below a coherent foundation: merging two contexts means merging two named mathematical objects, not colliding flat symbol tables.


What is needed (merge semantics, now unblocked)

A first-class Context merge feature that handles every realistic case:

  1. Identical bindings — same name, same instance → silent coalesce (current behaviour, keep).
  2. Compatible bindings — same name, different but equivalent instances (same class, same expression source) → coalesce with a configurable policy: prefer-this | prefer-other | error.
  3. Name clashes with rename — same name, genuinely different bindings → automatically rename the incoming binding (e.g. mm_2) and rewrite every dependent Expression / FunctionMapping that referenced the old name in the incoming context. The rename must propagate through:
    • Expression.referencedFunctions keys and node graphs
    • Expression.expressionString / source representation
    • Every downstream FunctionMapping instance whose compiled class field of that name was emitted
    • Already-instantiated functions: their reflective field for the renamed binding has to be re-bound, or a fresh instance allocated and wired in
  4. Unrenameable clashes — when rename is impossible (e.g. the incoming binding's name appears in a hand-written Java field, or its rewrite would collide with an already-bound name in this context that is itself unrenameable) → throw a structured exception that names both sides and explains why rename failed.
  5. Variable AND function bindings — both namespaces must be handled. A variable named p and a function named p can coexist (different field descriptors), so the merge must operate per-namespace.
  6. Transitive dependencies — when an incoming FunctionMapping's expression references functions that are themselves in the incoming context, those must be merged together as a unit (topological merge), not one at a time, so partially-merged contexts never exist.

API sketch

public enum ConflictPolicy { PREFER_THIS, PREFER_OTHER, RENAME_INCOMING, ERROR }

public MergeReport mergeFrom(Context other, ConflictPolicy policy);

public record MergeReport(
    Set<String> coalesced,         // names that matched identically
    Map<String, String> renamed,   // old name → new name in `this`
    Set<String> imported,          // names new to `this`
    List<MergeFailure> failures    // populated only with policy=ERROR or unrenameable
) {}

Acceptance criteria

  • All existing mergeFrom(Context) callers continue to work with the default policy.
  • A new test class ContextMergeTest covers each of the five cases above.
  • The OPS #1021 workaround (threading muntz.context through three constructor layers) can be replaced by opsContext.mergeFrom(muntz.context, RENAME_INCOMING) at the construction site, and the OPS test passes without the constructor threading.
  • The variable-namespace and function-namespace fixtures both have rename coverage.
  • Documentation: a Context.md describing the merge model, with a worked example covering each policy.

References

  • arb.expressions.Context.mergeFrom(Context) — current implementation
  • arb.expressions.Context.registerFunctionMapping(...) — see comment about replace=true semantics (Context.java:485-498) for the precedent that already partially handles overwrite
  • arb.functions.polynomials.orthogonal.complex.OrthogonalPolynomialMomentFunctionalSequence — current #1021 workaround that this issue would obsolete
  • arb.functions.complex.RiccatiMuntzPadeFunctional.partialDerivativeWithRespectToV — another caller that depends on careful context lifetime management
  • arb.expressions.ExpressionClassLoaderpendingBytecodes and findClass are the load-side of the package identity fix

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementthings that are not bugs

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions